- New acl.md: AclGraph Module definition (PrincipalNode, ResourceNode, DelegatesEdge, ScopesEdge, MemberEdge), principal-agent hierarchy with no-escalation invariant, setup-time vs runtime separation, multi-parent aggregation rules, cycle detection, scope semantics - ADR-034: ACL as metagraph (not domain-specific tables) - ADR-035: Actors become PrincipalNode entries, standalone table removed - ADR-036: Principal-agent as DelegatesEdge with scope narrowing - ADR-037: Setup-time definitions seed graph types, runtime instances are separate graphs - Resolve OQ-03 (actors table design) — actors become ACL nodes - Add OQ-20 through OQ-25 (delegation expiration, evaluator location, graph instance lifecycle, BelongsToEdge derivation, identityId references, scope string semantics) - Update README.md and overview.md to reflect new doc and ADRs - Note: multi-tenancy / graph scoping problem (no ownerId/scopeId on graphs table, no identity tables at this level) still needs resolution — identity and org tables will likely need to be added at this level for referential integrity
26 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-05-30 |
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:
-
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.
-
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.Modulethat 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
OperationSpecdefinitions seed ACL graph types, and howIdentityis 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):
identityTypediscriminator over separate node types: A singlePrincipalNodewith anidentityTypefield is used instead of separateAccountNode,OrgNode,ServiceNode,RoleNodetypes. 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
narrowedScopesattributes 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()))),
}),
]),
identityIdreferences 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'saccountsororganizationstable, or a role name in therolestable). The ACL graph stores the shape; the hub stores the authoritative identity records. (See ADR-034.)identityTypedetermines what kind of entity this principal represents. This matters for evaluation: anorgprincipal aggregates its members' scopes, aroleprincipal is a named set of scopes, anaccountprincipal is a human or service identity.scopesandresourcesmap directly from@alkdev/operations'Identityinterface. They represent the base authority of this principal before any delegation narrowing.OrgNodeandAccountNodeare not separate node types. TheidentityTypediscriminator onPrincipalNodeavoids 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), theidentityTypedrives 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 }),
}),
]),
resourceTypemaps toAccessControl.resourceType(e.g.,"project","spoke","workspace").resourceIdis the canonical identifier for the specific resource instance. Together,resourceType:resourceIdforms the key forIdentity.resourceslookups.
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")),
}),
narrowedScopesis 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 thatnarrowedScopes⊆ principal's effective scopes.narrowedResourcesis 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")),
}),
actionsmaps toAccessControl.resourceActionvalues. For example,["read", "write"]on aScopesEdgefrom an account principal to a project resource means that account has read+write access to that project.- This edge type directly mirrors
Identity.resourceswhere the key format"type:id"maps toresourceType:resourceIdon the targetResourceNode, and the value array maps toactions.
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")),
}),
membershipLevelmaps directly from the hub'sorganization_members.membershipLevel.- The target must be a PrincipalNode with
identityType: "org". - The source is typically a
PrincipalNodewithidentityType: "account"or"service". sourceTypeConstraintandtargetTypeConstraintare optional metadata on the edge constraints. They are NOT enforced by the metagraph schema (which validates attribute schemas, notidentityTypevalues). 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:
- Liability flows upward: If an agent makes a mistake, the principal who delegated to them is responsible. This chain continues upward through delegation levels.
- 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 receivenarrowedScopes: ["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 }:
- Check scopes (AND): Every scope in
requiredScopesmust be in the identity's effective scopes. - Check scopes (OR): At least one scope in
requiredScopesAnymust be in the effective scopes. - Check resource: If
resourceTypeandresourceActionare set, the identity'sresources["resourceType:resourceId"]must includeresourceAction.
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:
BelongsToEdgedeclares organizational membership: "Account A is a member of Org O with level 'admin'." It enables org-level scoping: when an operation requiresresourceType: "project"access, and the project belongs to Org O, account A's membership in O can be checked.DelegatesEdgedeclares 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
Two-Level Scoping
The ACL system operates at two levels:
-
Operation-level (setup-time):
AccessControlonOperationSpecdefines what scopes and resource actions are required to invoke an operation. This is registered once when the operation is defined and changes infrequently. This data lives in@alkdev/operationsand the hub'soperationstable. -
Graph-level (runtime): The ACL graph instance stores who has what and who delegates to whom. This is queried at call time to resolve whether a specific identity satisfies an operation's access control requirements.
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:
- Receives
AccessControlfrom the operation definition - Looks up the caller's
Identity(which may reference an ACL graph) - Traverses the ACL graph to compute effective scopes and resources
- Applies
enforceAccess(accessControl, effectiveIdentity)
Org-Scoped Access
When a BelongsToEdge connects an account to an org, and the org has
ScopesEdge connections to resources, the account inherits org-level access
through its membership:
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 ACTOR_TYPE enum (Human, Llm,
Agent) maps to identityType values (account, service, account — LLMs
are accounts in the hub model per ADR-012). The standalone table has no
foreign key relationships and was explicitly deferred pending ACL design (OQ-03).
This does not mean the hub's accounts table is replaced. The hub's
accounts table remains the authoritative identity store with email, access
level, and Gitea linking. PrincipalNode in the ACL graph references the
account by identityId but does not duplicate its columns. The ACL graph
stores authorization data; the hub's identity tables store authentication
data.
Hub's organization_members as a Source
The hub's organization_members table is the authoritative source for who
belongs to which org. When org membership changes, the hub updates both:
- The
organization_membersrow (fast lookup, FK constraints) - The
BelongsToEdgein the ACL graph instance (graph traversal, evaluation)
This dual-write is necessary because the hub needs fast SQL lookups for membership checks (e.g., "list all members of this org"), while the ACL graph needs the edge for traversal-based evaluation (e.g., "compute effective scopes for this account across all orgs").
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
DelegatesEdgesupport temporary delegation with expiration? (Referenced in open-questions.md) - OQ-21: Should the ACL evaluator live in
@alkdev/storageor in the hub? (Referenced in open-questions.md) - OQ-22: How are ACL graph instances created and managed? (Referenced in open-questions.md)
- OQ-23: Should
BelongsToEdgebe derived (materialized fromorganization_members) or primary (ACL graph is the source of truth)? (Referenced in open-questions.md) - OQ-24: How does
identityIdreference hub entities without creating a package dependency? (Referenced in open-questions.md)
References
- Metagraph Module system: metagraph-module.md
- SQLite host and actors table: sqlite-host.md
- Operations
IdentityandAccessControl:/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