Files
storage/docs/architecture/acl.md
glm-5.1 6aa2fcc6ff Architect storage around SQLite+Honker: remove PG, add multi-tenant identity, scoping
Reorient @alkdev/storage around a single SQLite database host with Honker
for pub/sub, event streams, and task queues. PostgreSQL is removed as a
target (ADR-038), eliminating dual schema maintenance and infrastructure
complexity. Honker provides DB + pubsub + queues in one .db file (ADR-039).

Add system/tenant DB model (ADR-040): identity tables in system.db, all
graph data in tenant-{orgId}.db files. Identity tables move from the hub
into storage (ADR-041). Scoping columns (ownerId, projectId) added to
graphs table (ADR-042). Graph types get scope (system/tenant/user) to
protect infrastructure schemas (ADR-043).

Define Drizzle-Honker session adapter (ADR-044): ~100-line adapter enabling
Drizzle typed queries and Honker pubsub/queue on a single connection with
transactional consistency.

Resolve OQ-03, OQ-04, OQ-19, OQ-21, OQ-22, OQ-23, OQ-24. Add new
open questions OQ-26 through OQ-29 for Honker integration specifics.

New docs: honker-integration.md (adapter, event patterns, migration).
Scrub all PG/jsonb/libsql references from existing spec docs.
2026-05-31 15:41:41 +00:00

26 KiB
Raw Blame History

status, last_updated
status last_updated
draft 2026-05-31

Access Control Graph

How access control is modeled as a metagraph, how it integrates with @alkdev/operations' Identity and AccessControl, and how the principal-agent framework maps to graph relationships.

Purpose

Access control in the alk.dev platform needs to serve two distinct but overlapping audiences:

  1. Setup-time — A hub/spoke administrator defines which operations exist, what their access requirements are, and which principal-agent relationships are valid. This is the schema of authorization.

  2. Runtime — When a session calls an operation, the system resolves whether that specific call is permitted given the caller's identity, their scope within the org, and any delegation chain involved. This is the evaluation of authorization.

Both can be expressed as graphs. The setup-time definitions (operations, org structures, trust relationships) become graph types — schema-level declarations of what kinds of nodes and edges are valid. The runtime instances (who has delegated to whom, which org has which scope over which resource) become graph instances — concrete data that the authorization evaluator traverses.

This document defines:

  • The AclGraph Module — a TypeBox Type.Module that defines the node and edge types for access control
  • The ACL graph instance model — how principal-agent relationships, org-scoped permissions, and operation-level access control are represented as graph instances
  • The relationship between setup-time and runtime — how OperationSpec definitions seed ACL graph types, and how Identity is evaluated at runtime
  • The principal-agent hierarchy — how liability flows upward and permissions flow downward through delegation edges
  • The scoping model — how org membership and resource access are graph edges

Design Decisions

ADR Decision Status
034 ACL is a metagraph, not domain-specific tables Accepted
035 Actors become node types in ACL and org graphs, standalone table removed Accepted
036 Principal-agent relationships are delegates edges with scope narrowing Accepted
037 Setup-time definitions seed graph types; runtime instances are separate graphs Accepted

Sub-decisions (documented inline, not separate ADRs):

  • identityType discriminator over separate node types: A single PrincipalNode with an identityType field is used instead of separate AccountNode, OrgNode, ServiceNode, RoleNode types. This avoids node type proliferation and keeps the AclGraph Module compact. The type discriminator drives evaluation branching rather than requiring separate Module entries for each identity type.
  • AclGraph Config: directed, no multi, no self-loops: Delegation and scoping are inherently directional. Multi-edges between the same pair (delegating different scope sets) carry no additional information — a single edge with narrowedScopes attributes is sufficient. Self-loops would represent self-delegation, which is semantically meaningless.

Two Kinds of Authorization Scopes

A distinction that makes ACL tractable:

Aspect Setup-Time (Schema) Runtime (Data)
What it is Operation definitions and their AccessControl requirements Who can do what, where, under whose authority
Source OperationSpec.accessControl registered in the operations registry ACL graph instances, org memberships, delegation chains
Stored as Operation rows in the hub's operations table Nodes and edges in ACL graph instances
Mutability Changes when operations are registered/updated Changes when org membership, delegation, or scoping changes
Evaluation None — it's declarative Traversed at call time to resolve permissions

Setup-time answers: "What operations could require?" — it defines the AccessControl schema per operation.

Runtime answers: "Does this specific caller have permission for this specific call?" — it traverses the ACL graph instance.

The ACL graph type captures runtime authorization data. Setup-time definitions live in @alkdev/operations and the hub's operations table. They are related but distinct: setup-time defines the rules, runtime instances store the facts about who follows those rules.

The AclGraph Module

Graph Type Configuration

The ACL graph type is directed, does not allow multi-edges, and does not allow self-loops. Delegation and scoping are inherently directional (principal → agent, org → member). Self-delegation and multi-edges between the same pair don't carry additional information — a single delegates edge with narrowed scope attributes is sufficient.

Config: Type.Composite([
  Metagraph.Import("Config"),
  Type.Object({
    type: Type.Literal("directed"),
    multi: Type.Literal(false),
    allowSelfLoops: Type.Literal(false),
  }),
]),

Node Types

Node Type Purpose Key Attributes
PrincipalNode An identity that can delegate authority identityId, identityType, scopes, resources
ResourceNode A resource that can be scoped resourceType, resourceId

PrincipalNode

Represents an entity that holds authority and can delegate it. This is the node-level equivalent of @alkdev/operations' Identity interface, extended with the identityType concept needed for the principal-agent framework.

PrincipalNode: Type.Composite([
  Metagraph.Import("BaseNode"),
  Type.Object({
    identityId: Type.String({ minLength: 1, maxLength: 255 }),
    identityType: Type.Union([
      Type.Literal("account"),
      Type.Literal("service"),
      Type.Literal("org"),
      Type.Literal("role"),
    ]),
    scopes: Type.Array(Type.String()),
    resources: Type.Optional(Type.Record(Type.String(), Type.Array(Type.String()))),
  }),
]),
  • identityId references the account, service account, org, or role by its canonical identifier. This is not a foreign key — it's a logical reference to an entity in another system (the hub's accounts or organizations table, or a role name in the roles table). The ACL graph stores the shape; the hub stores the authoritative identity records. (See ADR-034.)
  • identityType determines what kind of entity this principal represents. This matters for evaluation: an org principal aggregates its members' scopes, a role principal is a named set of scopes, an account principal is a human or service identity.
  • scopes and resources map directly from @alkdev/operations' Identity interface. They represent the base authority of this principal before any delegation narrowing.
  • OrgNode and AccountNode are not separate node types. The identityType discriminator on PrincipalNode avoids proliferating node types for entities that are structurally identical (they all hold scopes and resources). When evaluation needs entity-specific behavior (e.g., org account aggregation), the identityType drives branching, not the node type.

ResourceNode

Represents a resource that permission scopes can reference. Resources are the targets of AccessControl.resourceType and AccessControl.resourceAction — projects, spokes, workspaces, or any entity that an operation scopes to.

ResourceNode: Type.Composite([
  Metagraph.Import("BaseNode"),
  Type.Object({
    resourceType: Type.String({ minLength: 1, maxLength: 255 }),
    resourceId: Type.String({ minLength: 1, maxLength: 255 }),
  }),
]),
  • resourceType maps to AccessControl.resourceType (e.g., "project", "spoke", "workspace").
  • resourceId is the canonical identifier for the specific resource instance. Together, resourceType:resourceId forms the key for Identity.resources lookups.

Edge Types

Edge Type Source → Target Purpose
DelegatesEdge PrincipalNode → PrincipalNode Principal delegates (a subset of) authority to agent
ScopesEdge PrincipalNode → ResourceNode Principal has specific access to a resource
BelongsToEdge PrincipalNode → PrincipalNode (identityType org) Account/service is a member of an org

DelegatesEdge

The core of the principal-agent framework. When principal P delegates to agent A, a DelegatesEdge is created from P → A with narrowedScopes attributes that represent the subset of P's authority transferred to A.

DelegatesEdge: Type.Composite([
  Metagraph.Import("BaseEdge"),
  Type.Object({
    type: Type.Literal("delegates"),
    narrowedScopes: Type.Array(Type.String()),
    narrowedResources: Type.Optional(Type.Record(Type.String(), Type.Array(Type.String()))),
  }),
]),

DelegatesEdgeConstraints: Type.Object({
  edgeType: Type.Literal("delegates"),
  allowedSourceTypes: Type.Array(Type.Literal("Principal")),
  allowedTargetTypes: Type.Array(Type.Literal("Principal")),
}),
  • narrowedScopes is the subset of the principal's scopes that are delegated. It MUST be a subset — an agent cannot receive more authority than the principal holds. Enforcement happens at edge creation time: the evaluator validates that narrowedScopes ⊆ principal's effective scopes.
  • narrowedResources is an optional narrowing of resource-scoped permissions. If absent, the agent inherits all of the principal's resource scoping. If present, it must be a subset of the principal's effective resources.
  • The no-escalation invariant: a delegation chain must never grant an agent higher privilege than any ancestor. This is enforced by the evaluator at edge creation time and at call-time evaluation. See Principal-Agent Framework below.

ScopesEdge

Links a principal to a resource they have specific access to. This is the graph-level representation of Identity.resources.

ScopesEdge: Type.Composite([
  Metagraph.Import("BaseEdge"),
  Type.Object({
    type: Type.Literal("scopes"),
    actions: Type.Array(Type.String()),
  }),
]),

ScopesEdgeConstraints: Type.Object({
  edgeType: Type.Literal("scopes"),
  allowedSourceTypes: Type.Array(Type.Literal("Principal")),
  allowedTargetTypes: Type.Array(Type.Literal("Resource")),
}),
  • actions maps to AccessControl.resourceAction values. For example, ["read", "write"] on a ScopesEdge from an account principal to a project resource means that account has read+write access to that project.
  • This edge type directly mirrors Identity.resources where the key format "type:id" maps to resourceType:resourceId on the target ResourceNode, and the value array maps to actions.

BelongsToEdge

Declares that an account (or service) is a member of an organization. This is the graph-level representation of the hub's organization_members table.

BelongsToEdge: Type.Composite([
  Metagraph.Import("BaseEdge"),
  Type.Object({
    type: Type.Literal("belongs_to"),
    membershipLevel: Type.Union([
      Type.Literal("owner"),
      Type.Literal("admin"),
      Type.Literal("member"),
    ]),
  }),
]),

BelongsToEdgeConstraints: Type.Object({
  edgeType: Type.Literal("belongs_to"),
  allowedSourceTypes: Type.Array(Type.Literal("Principal")),
  allowedTargetTypes: Type.Array(Type.Literal("Principal")),
  sourceTypeConstraint: Type.Optional(Type.Literal("account")),
  targetTypeConstraint: Type.Optional(Type.Literal("org")),
}),
  • membershipLevel maps directly from the hub's organization_members.membershipLevel.
  • The target must be a PrincipalNode with identityType: "org".
  • The source is typically a PrincipalNode with identityType: "account" or "service".
  • sourceTypeConstraint and targetTypeConstraint are optional metadata on the edge constraints. They are NOT enforced by the metagraph schema (which validates attribute schemas, not identityType values). They serve as documentation for evaluation logic and can be validated at the application layer. This is consistent with ADR-020 (no nodeTypeId on nodes): the metagraph validates structural constraints (which node types can be source/target), not domain semantics (which identityType values are valid for a given edge type).

Complete AclGraph Module

export const AclGraph = Type.Module({
  Config: /* directed, no multi, no self-loops */,

  PrincipalNode: /* identityId, identityType, scopes, resources */,
  ResourceNode: /* resourceType, resourceId */,

  DelegatesEdge: /* delegates with narrowedScopes, narrowedResources */,
  DelegatesEdgeConstraints: /* Principal → Principal */,

  ScopesEdge: /* scopes with actions */,
  ScopesEdgeConstraints: /* Principal → Resource */,

  BelongsToEdge: /* belongs_to with membershipLevel */,
  BelongsToEdgeConstraints: /* account/service → org */,

  MembershipLevel: Type.Union([
    Type.Literal("owner"),
    Type.Literal("admin"),
    Type.Literal("member"),
  ]),

  IdentityType: Type.Union([
    Type.Literal("account"),
    Type.Literal("service"),
    Type.Literal("org"),
    Type.Literal("role"),
  ]),
});

Principal-Agent Framework

Core Principle

The principal-agent framework comes from legal theory: a principal delegates authority to an agent, and the principal bears responsibility for the agent's actions within the scope of delegation. Two rules follow:

  1. Liability flows upward: If an agent makes a mistake, the principal who delegated to them is responsible. This chain continues upward through delegation levels.
  2. Permissions flow downward: An agent's effective permissions are always a subset of their principal's permissions. Delegation can only narrow scope, never expand it.

As a Directed Graph

In the ACL graph, delegation is a DelegatesEdge from principal → agent:

       User (principal)
        │
        │ delegates [{ scopes: ["dev:*"], narrowedResources: { "project:alpha": ["read", "write"] } }]
        │
        ▼
    Coordinator (agent of User)
        │
        │ delegates [{ scopes: ["dev.fs.read", "dev.fs.write"], narrowedResources: { "project:alpha": ["read"] } }]
        │
        ▼
    Implementer (agent of Coordinator)

Each DelegatesEdge carries narrowedScopes — what the principal is passing down. The no-escalation invariant means:

  • If User has scopes: ["admin", "dev:*"], the Coordinator can only receive a subset: narrowedScopes: ["dev:*"]
  • If the Coordinator has effective scopes: ["dev:*"], the Implementer can only receive narrowedScopes: ["dev.fs.read", "dev.fs.write"]
  • At no point can the Implementer have higher privilege than User

Evaluation Algorithm

Given a call with Identity { id, scopes, resources } and an operation's AccessControl { requiredScopes, requiredScopesAny, resourceType, resourceAction }:

  1. Check scopes (AND): Every scope in requiredScopes must be in the identity's effective scopes.
  2. Check scopes (OR): At least one scope in requiredScopesAny must be in the effective scopes.
  3. Check resource: If resourceType and resourceAction are set, the identity's resources["resourceType:resourceId"] must include resourceAction.

The effective scopes for an identity are computed by traversing the ACL graph:

effectiveScopes(identity) =
  identity.scopes
  ∩ (delegation chain intersection)
   (org membership scopes)

For a chain of DelegatesEdges from root principal to this identity, each edge narrows the scope. The effective scopes are the intersection of all scope sets along the chain from root to leaf.

For resources, the same narrowing applies: if a DelegatesEdge has narrowedResources, those narrow the resource scoping. If absent, the chain is unchanged (agent inherits principal's resource scoping).

Scope string semantics follow @alkdev/operations' keypal convention: colon-separated hierarchical segments where * is a wildcard matching any suffix. "dev:*" matches "dev.read", "dev.write", "dev.fs.read", etc. "dev:read" matches only "dev:read", not "dev:write". The subset check narrowedScopes ⊆ effectiveScopes uses this wildcard matching, not simple string equality. This is consistent with @alkdev/operations' AccessControl enforcement (see checkAccess in access.ts).

Graph traversal optimization: The evaluator can cache effective scopes per identity per graph. When a DelegatesEdge or ScopesEdge is created or removed, only identities in the affected subtree need recalculation.

Multi-Parent Aggregation

A PrincipalNode can have multiple incoming edges. The aggregation rules differ by edge type:

Multiple DelegatesEdge incoming edges (multiple principals delegating to the same agent): The agent's effective scopes are the union of delegation chains. Each chain is independently intersected (narrowed), then the results are unioned. This allows an agent to receive authority from multiple independent principals without either principal's chain restricting the other.

effectiveScopes(agent) = (chain_i: root_i.scopes ∩ ... ∩ edge_n.narrowedScopes)

This preserves the no-escalation invariant per chain: no single chain can exceed its root principal's authority. The union across chains means the agent accumulates authority from multiple independent delegations, each bounded by its own chain.

DelegatesEdge + BelongsToEdge incoming edges: These are additive. DelegatesEdge provides delegation-chained scopes; BelongsToEdge provides org-level scopes. The identity's effective scopes are the union of all delegation chain results plus org-derived scopes.

effectiveScopes(identity) =
  ( delegation_chains)  ( org_memberships)

Each component is independently bounded: delegation chains by their root principal's authority, org memberships by the membershipLevel-derived scope mapping.

Transitivity

Delegation edges are transitive: if A delegates to B, and B delegates to C, then C's effective authority is:

effectiveScopes(C) = A.scopes ∩ B.narrowedScopes ∩ C.narrowedScopes

This is equivalent to: C's effective authority is whatever A gave B, further narrowed by what B gave C. The graph traversal is a depth-first walk up the delegation chain, intersecting at each step.

Cycle Detection

The ACL graph must be a directed acyclic graph (DAG). Cycles in delegation create paradoxes (A delegates to B who delegates back to A) and make scope computation non-terminating.

Detection algorithm: At edge creation time, perform a DFS from the target node upward through all DelegatesEdges. If the source node is reachable, the edge would create a cycle and must be rejected.

allowSelfLoops: false in the AclGraph Config prevents self-delegation edges (A → A), but does NOT prevent longer cycles (A → B → A). Cycle detection is performed at the application layer during edge insertion. The BelongsToEdge type cannot create cycles because its target must have identityType: "org" and orgs cannot delegate (they are not agents), so only DelegatesEdge cycles need to be checked.

The chain always terminates** because the ACL graph is a DAG enforced at edge creation time.

BelongsTo vs Delegates

BelongsToEdge and DelegatesEdge serve different purposes:

  • BelongsToEdge declares organizational membership: "Account A is a member of Org O with level 'admin'." It enables org-level scoping: when an operation requires resourceType: "project" access, and the project belongs to Org O, account A's membership in O can be checked.
  • DelegatesEdge declares authority transfer: "Principal P delegates scopes S to Agent A." It creates the principal-agent relationship for a specific task or session.

A BelongsToEdge does NOT carry scope narrowing — org membership is not delegation. The org's effective scopes for a member are determined by the member's membershipLevel, which maps to org-level permissions (owner → full control, admin → manage projects and members, member → access org resources).

Org-level permissions can also be modeled explicitly with ScopesEdge: an org principal can have a ScopesEdge to a resource representing the org itself, with actions: ["admin"] for owners, ["manage"] for admins.

Scoping Model

With the system/tenant DB model (ADR-040), ACL scoping is simplified:

  • One ACL graph instance per tenant DB — The tenant DB is inherently org-scoped. OQ-22 is resolved: each org gets its own ACL graph instance.
  • No cross-org scoping within a tenant — The entire tenant DB is one org. The ACL graph does not need orgId columns or cross-org filtering.
  • Cross-org delegation requires the hub to mediate between tenant DBs (OQ-28, open).

How They Connect

OperationSpec.accessControl         ACL Graph Instance
┌─────────────────────┐            ┌─────────────────────────┐
│ requiredScopes:      │            │ PrincipalNode            │
│   ["dev:read"]       │◄───────────│   identityId: "user-1"  │
│ resourceType:        │  evaluate  │   scopes: ["dev:*"]      │
│   "project"          │            │                          │
│ resourceAction:      │            │ ScopesEdge               │
│   "write"            │            │   → ResourceNode          │
│                      │            │     (project:alpha)      │
└─────────────────────┘            │   actions: ["read","write"]│
                                   └─────────────────────────┘

The evaluator bridges the two:

  1. Receives AccessControl from the operation definition
  2. Looks up the caller's Identity (which may reference an ACL graph)
  3. Traverses the ACL graph to compute effective scopes and resources
  4. Applies enforceAccess(accessControl, effectiveIdentity)

Org-Scoped Access

Within a tenant DB, ACL evaluation is straightforward — the entire DB is one org. The PrincipalNode's identityId logically references accounts.id in the system DB (ADR-041). When evaluating ACL, the hub reads the account's org membership from the system DB's organization_members table (authoritative per ADR-045) and the ACL graph's BelongsToEdge (derived) in the tenant DB.

Account PrincipalNode ──belongs_to──→ Org PrincipalNode
                                          │
                                     scopes (actions: ["manage", "read"])
                                          │
                                          ▼
                                   ResourceNode (project:alpha)

The evaluation rule: if Account belongs to Org with membershipLevel: "admin", and Org has ScopesEdge to Resource with actions: ["manage", "read"], then Account has the intersection of [Org-scopes for membershipLevel admin] and [actions on ScopesEdge] for that resource.

This intersection ensures that org-level permissions don't automatically grant admin access just because the org admin has actions: ["manage"] — the account's own base scopes limit what they can exercise from org membership.

What This Replaces

The Disconnected actors Table

The actors table in src/sqlite/tables/actors.ts is replaced by PrincipalNode in the ACL graph. The standalone table has been removed (ADR-035, ADR-038).

Hub's organization_members as Authoritative Source

The hub's (now storage's) organization_members table is the authoritative source for who belongs to which org (ADR-045). When org membership changes, the consumer writes both:

  1. The organization_members row (fast lookup, FK constraints)
  2. The BelongsToEdge in the ACL graph instance (graph traversal, evaluation)

This dual-write is necessary because the SQL table provides O(1) membership lookups and cascade behavior, while the ACL graph needs the edge for traversal-based evaluation.

Hub's Permission Resolution

The hub's current permission resolution model (role requests ∩ account scopes ∩ spoke trust level) is preserved. The ACL graph adds a fourth input: delegation chain intersection. The full resolution becomes:

Effective permissions = ACL Effective ∩ Role.requests ∩ Account.allowed ∩ SpokeType.capable

The "ACL Effective" is the delegation-traversal result from the ACL graph. This ensures that even if a role requests admin access, and an account has an API key with admin scope, if the ACL delegation chain only grants dev:read, the effective scope is dev:read.

Open Questions

  • OQ-20: Should DelegatesEdge support temporary delegation with expiration? (Referenced in open-questions.md)
  • OQ-25: What are the scope string semantics for subset validation? (Referenced in open-questions.md)
  • OQ-28: How does cross-tenant delegation work with separate DBs? (Referenced in open-questions.md)

Resolved Questions

  • OQ-21 (ACL evaluator location): Storage provides traversal primitives; hub composes with operations. Simplified by single-host model — no cross-DB joins needed within a tenant DB.
  • OQ-22 (ACL graph instance lifecycle): One per tenant DB. ADR-040.
  • OQ-23 (BelongsToEdge derivation): Derived from organization_members. ADR-045.
  • OQ-24 (identityId reference): Logical reference to accounts.id in system DB. ADR-041.

References

  • Metagraph Module system: metagraph-module.md
  • SQLite host and actors table: sqlite-host.md
  • Operations Identity and AccessControl: /workspace/@alkdev/operations/src/types.ts
  • Hub identity tables: /workspace/@alkdev/hub/docs/architecture/storage/identity.md
  • Hub agent-roles model: /workspace/@alkdev/hub/docs/architecture/agent-roles.md
  • Hub roles table: /workspace/@alkdev/hub/docs/architecture/storage/roles.md
  • Encrypted data and SecretGraph pattern: encrypted-data.md