Relationships
Some access decisions depend on a connection between two people: a guardian who reads their ward’s records, a manager who edits their team’s reports, a member who sees their group’s content. Maravilla models those connections as typed relation edges between users and lets policies traverse them with the RELATES operator.
There are two halves:
- Relation types — the kinds of connection your app supports (
STEWARDS,MANAGES,MEMBER_OF, …). You declare these inmaravilla.config.ts(or the admin UI). They’re versioned with your code. - Relation edges — the actual connections between specific users (
alice STEWARDS bob). You create and query these at runtime withplatform.auth.addRelation/removeRelation/listRelations. They’re tied to individual users, so they live in the database, not your repo.
Declaring relation types
Relation types go in the relations block of your config:
// maravilla.config.ts
import { defineConfig } from '@maravilla-labs/platform/config';
export default defineConfig({
auth: {
relations: [
{
relation_name: 'STEWARDS',
title: 'Stewards',
category: 'family',
implies_stewardship: true, // edges of this type grant stewardship rights
},
{
relation_name: 'MANAGES',
title: 'Manages',
category: 'work',
inverse_relation_name: 'REPORTS_TO',
},
{
relation_name: 'MEMBER_OF',
title: 'Member of',
category: 'work',
bidirectional: true, // A↔B; traversal works in both directions
},
],
},
});
| Field | Meaning |
|---|---|
relation_name | Uppercase identifier referenced from policies via VIA '<name>'. Required. |
title | Human-readable label for the admin UI. Required. |
description | Optional longer description. |
category | Grouping for the admin UI, e.g. "family", "work". |
icon, color | Optional UI affordances. |
inverse_relation_name | Name of the inverse relation type, if one exists (e.g. MANAGES ↔ REPORTS_TO). |
implies_stewardship | When true, an edge of this type grants the source user stewardship rights over the target. |
requires_minor | When true, the relation can only target users flagged as minors. |
bidirectional | When true, the relation is symmetric — A→B implies B→A, and traversal follows the edge either way. |
Declaring relations upserts the types by relation_name. Types created in the admin UI but not in your config are kept, never auto-deleted.
Creating relation edges at runtime
Relation types are declarative; the edges between users are data. Manage them from your deployed code:
// Make `guardianId` a steward of `wardId`.
const edge = await platform.auth.addRelation({
from_user_id: guardianId,
to_user_id: wardId,
relation_type: 'STEWARDS', // a relation name OR a relation-type id (rlt_…)
metadata: { added_by: 'enrollment-flow' },
});
// List the edges touching a user.
const outgoing = await platform.auth.listRelations({
user_id: guardianId,
direction: 'outgoing', // 'outgoing' | 'incoming' | 'both' (default 'both')
});
// Remove an edge by its endpoints and relation-type id.
await platform.auth.removeRelation(guardianId, wardId, edge.relation_type_id);
A returned Relation carries:
interface Relation {
id: string;
from_user_id: string;
to_user_id: string;
relation_type: string; // the type's name, e.g. "STEWARDS"
relation_type_id: string; // the type's id, "rlt_…"
metadata?: Record<string, any> | null;
created_at: number;
}
Traversing relations in a policy
The RELATES operator asks the relation graph whether a path exists between two users. The shape is:
<object> RELATES <subject> VIA '<relation_name>' [DEPTH a..b] [DIRECTION OUTGOING|INCOMING|ANY]
<object>is the anchor — usually a field on the record, likenode.owner.<subject>is the related party — usuallyauth.user_id.VIA '<relation_name>'names the relation type. It must be a single-quoted relation name (or an array of names:VIA ['STEWARDS', 'MANAGES']).DEPTH a..bbounds the traversal to betweenaandbhops (inclusive). Omitted, it defaults to1..1(direct edges only).DIRECTIONisOUTGOING(default),INCOMING, orANY.
# Allow when the caller is a direct steward of whoever owns the record
node.owner RELATES auth.user_id VIA 'STEWARDS'
# Allow up to two hops away — e.g. steward-of-a-steward
node.owner RELATES auth.user_id VIA 'STEWARDS' DEPTH 1..2
# A manager (or their manager) can act on a report's record
node.owner RELATES auth.user_id VIA 'MANAGES' DEPTH 1..3 DIRECTION INCOMING
Traversal is a bounded breadth-first search over the edge graph. Deep traversals cost more per check, so keep DEPTH small — the runtime caps it at 6 hops regardless of what you write.
The typed builder
Rather than writing the RELATES clause by hand, the policy builder emits the correct single-quoted form:
import { defineConfig, relatesVia, ownsIt } from '@maravilla-labs/platform/config';
export default defineConfig({
auth: {
relations: [
{ relation_name: 'STEWARDS', title: 'Stewards', implies_stewardship: true },
],
resources: [
{
name: 'health_records',
title: 'Health records',
type: 'database',
actions: ['read', 'write'],
// owner OR a steward (1–2 hops) of the owner
policy: ownsIt().or(relatesVia('STEWARDS', { depth: [1, 2] })),
},
],
},
});
relatesVia('NAME') defaults to node.owner RELATES auth.user_id VIA 'NAME'. Override the anchors with { object, subject } and bound the search with { depth: [min, max] }. defineConfig cross-checks that every relation name you reference exists in relations[] — a typo throws at build time.
Stewardship — the built-in family relation
Stewardship is the platform’s built-in guardianship model: one user (the steward) acts on behalf of another (the ward). It backs scenarios like a parent managing a minor’s account or a caregiver managing a dependent’s records.
Stewardship participates in RELATES as a first-class edge. A relation type declared with implies_stewardship: true contributes both its own relation edges and the stewardship rows derived from it — so VIA '<that relation name>' (e.g. VIA 'STEWARDS' once you’ve declared a STEWARDS relation) traverses them together. A RELATES … VIA '<name>' clause matches stewardship through the relation type that created it; stewardship created directly via the API without a relation type isn’t reached by name — manage and query it through platform.auth.stewardship (below).
The stewardship API lives under platform.auth.stewardship:
// Who stewards this user, and who do they steward?
const { stewards, wards } = await platform.auth.stewardship.resolve(userId);
// Grant a scoped override directly (independent of a relation edge).
await platform.auth.stewardship.createOverride({
steward_id: guardianId,
ward_id: wardId,
delegation_mode: 'scoped',
scoped_permissions: [{ resource: 'health_records', actions: ['read'] }],
valid_until: Math.floor(Date.now() / 1000) + 30 * 24 * 3600,
});
// Check a single permission without writing a policy.
const ok = await platform.auth.stewardship.checkPermission(
guardianId, wardId, 'health_records', 'read'
);
// Audit trail of actions taken on behalf of a ward.
const log = await platform.auth.stewardship.listAudit(wardId, { limit: 50 });
An override carries a delegation_mode of 'full' (steward inherits the ward’s access) or 'scoped' (only the listed scoped_permissions), an optional validity window (valid_from / valid_until), and a status (active, suspended, revoked, expired). Expired and revoked overrides stop contributing edges immediately.
A worked example: guardian reads a ward’s records
// maravilla.config.ts
import { defineConfig, ownsIt, isAdmin, relatesVia } from '@maravilla-labs/platform/config';
export default defineConfig({
auth: {
relations: [
{ relation_name: 'STEWARDS', title: 'Stewards', category: 'family',
implies_stewardship: true },
],
resources: [
{
name: 'records',
title: 'Records',
type: 'database',
actions: ['read', 'write', 'delete'],
// The owner, an admin, or a steward of the owner.
policy: ownsIt().or(isAdmin()).or(relatesVia('STEWARDS', { depth: [1, 1] })),
},
],
},
});
// In a deployed function: enroll a child and link the guardian.
const child = await platform.auth.createManagedUser({
profile: { display_name: 'Sam', is_minor: true },
external_id: `child:${enrollmentId}`, // idempotent — safe to retry
});
await platform.auth.addRelation({
from_user_id: guardian.id,
to_user_id: child.id,
relation_type: 'STEWARDS',
});
// Now, when the guardian is the bound caller, this returns true:
const canRead = await platform.auth.can('read', 'records', { owner: child.id });
The child here is a managed user — a login-less account used to model someone who never signs in themselves. See the Authentication guide for createManagedUser.
Slugs for stable addressing
Groups, circles, and relation types each carry a tenant-unique slug in addition to their generated id. The API methods that take an id (getGroup, updateRelationType, getCircle, …) accept the slug too, resolved server-side. That lets you address these objects by a stable, human-readable handle that survives across environments — instead of pinning to a generated rlt_… / grp_… id that differs per tenant.
// Both work — the slug is resolved to the underlying id.
await platform.auth.getGroup('grp_a1b2c3');
await platform.auth.getGroup('moderators');
Circles
A circle is a named set of users with per-member relationship labels — a lightweight way to model a household, a care team, or a project group. Members carry a relationship string and an optional is_primary_contact flag.
const circle = await platform.auth.createCircle({ name: 'Smith household' });
await platform.auth.addCircleMember(circle.id, {
user_id: parent.id,
relationship: 'parent',
is_primary_contact: true,
});
const members = await platform.auth.getCircleMembers(circle.id);
In policies, the caller’s circle slugs are available as auth.circles:
auth.circles.contains('smith-household')
API summary
| Method | Description |
|---|---|
createRelationType(opts) / listRelationTypes() | Manage relation types in code |
updateRelationType(idOrSlug, opts) / deleteRelationType(idOrSlug) | Update or remove a type |
addRelation({ from_user_id, to_user_id, relation_type, metadata? }) | Create an edge |
removeRelation(from, to, relationTypeId) | Remove an edge |
listRelations({ user_id, direction? }) | List edges touching a user |
stewardship.resolve(userId) | Stewards and wards of a user |
stewardship.createOverride(opts) / stewardship.revoke(id) | Manage stewardship overrides |
stewardship.checkPermission(steward, ward, resource, action) | One-off stewardship check |
stewardship.listAudit(userId, opts?) | Audit of on-behalf-of actions |
createCircle(opts) / addCircleMember(...) / getCircleMembers(...) | Manage circles |
Next steps
- Authorization — Write the policies that traverse these relations
- Authentication — Register users and create managed (login-less) users
- Database — Where relation edges and the records they gate live