Skip to content

Authorization (L3)

In xNet, authorization is data, not application logic: rules are declared on schemas and delegated through grant nodes and UCAN tokens, and enforced at the sync boundary. Because two implementations that disagree on policy make different read/write decisions on the same graph, the decision semantics are normative. Normative text: 04-authorization.md.

action ∈ { read, create, update, write, delete, share, admin }
can(subject: DID, action, nodeId) → { allowed, reasons }

create and update are refinements of write: create governs bringing a node into existence, update governs mutating one that exists. A schema may declare either; when absent, each falls back to the schema’s write expression (and a legacy write check follows the update rule), so schemas that only declare write behave exactly as before. Create checks evaluate against the draft node built from the payload — container relations in the payload (e.g. space, channel) resolve membership roles, which is how creation into a shared container is gated. A grant carrying write satisfies both refinements; a granular grant covers only itself.

actions: {
read: allow('owner', 'spaceMember'),
create: allow('spaceMember'), // members may add…
update: allow('owner', 'spaceAdmin'), // …only the author (or admins) may edit
write: allow('owner', 'spaceMember'), // fallback for engines without the split
delete: allow('owner', 'spaceAdmin'),
share: allow('owner', 'spaceAdmin')
}

A schema may carry an authorization block defining roles (how a subject earns a role on a node) and actions (which roles may perform each action):

interface AuthorizationDefinition {
roles: Record<string, RoleResolver>
actions: Record<AuthAction, AuthExpression>
publicProps?: string[]
fieldRules?: Record<string, { allow: AuthExpression; deny?: AuthExpression }>
}
KindSubject earns the role when…
creatorsubject == node.createdBy
propertyit appears in a named person/relation property (e.g. editors)
relationit holds a role on a related node (inheritance)
membershipa membership edge (e.g. SpaceMembership) links it to a container with a role ≥ minRole, cascading to nested containers

Resolution walks relations/memberships with a bounded depth and must terminate.

expr ::= allow(role…) | deny(role…) | roleRef(name)
| and(expr…) | or(expr…) | not(expr) | PUBLIC | AUTHENTICATED

Deny wins: if any matching deny is true, the action is denied regardless of any allow. Evaluation is total and deterministic.

For private nodes, the ability to decrypt is the read‑control mechanism: content keys are wrapped per recipient (X25519 + XChaCha20). A subject not in the recipient set can’t read encrypted properties even if it receives the bytes. publicProps and the four universal node fields stay readable for indexing and attribution.

  • Grant nodes — ordinary xNet nodes recording { issuer, grantee, resource, actions, expiresAt, revokedAt }. Active iff not revoked and not expired.
  • UCAN tokens — capability tokens (JWT/EdDSA) with a proof chain. Each capability must be an attenuation of its proof; child expiry ≤ parent; chains acyclic.
flowchart TB
  Q["can(subject, action, node)"] --> ND{node-level deny?}
  ND -->|yes| DENY
  ND -->|no| RR["resolve roles (depth-bounded)"]
  RR --> SE["evaluate schema expression (deny-wins)"]
  SE -->|allowed| ALLOW
  SE -->|no| GR{active grant / UCAN?}
  GR -->|yes| ALLOW
  GR -->|no| PUB{PUBLIC / publicProps?}
  PUB -->|yes| ALLOW
  PUB -->|no| DENY

Caching and indexing are private; the decisions must match the decision‑trace conformance vectors.

Next: Implement it in your language →