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.
620 lines
26 KiB
Markdown
620 lines
26 KiB
Markdown
---
|
||
status: draft
|
||
last_updated: 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](decisions/034-acl-as-metagraph.md) | ACL is a metagraph, not domain-specific tables | Accepted |
|
||
| [035](decisions/035-actors-become-acl-nodes.md) | Actors become node types in ACL and org graphs, standalone table removed | Accepted |
|
||
| [036](decisions/036-principal-agent-as-delegation-edges.md) | Principal-agent relationships are `delegates` edges with scope narrowing | Accepted |
|
||
| [037](decisions/037-setup-vs-runtime-separation.md) | 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.
|
||
|
||
```ts
|
||
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.
|
||
|
||
```ts
|
||
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.
|
||
|
||
```ts
|
||
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.
|
||
|
||
```ts
|
||
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`.
|
||
|
||
```ts
|
||
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.
|
||
|
||
```ts
|
||
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
|
||
|
||
```ts
|
||
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 `DelegatesEdge`s 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 `DelegatesEdge`s. 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](open-questions.md))
|
||
- **OQ-25**: What are the scope string semantics for subset validation?
|
||
(Referenced in [open-questions.md](open-questions.md))
|
||
- **OQ-28**: How does cross-tenant delegation work with separate DBs?
|
||
(Referenced in [open-questions.md](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](metagraph-module.md)
|
||
- SQLite host and actors table: [sqlite-host.md](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](encrypted-data.md) |