Files
stack/docs/requirements/rbac-grant-model.md
T
2026-08-29 19:34:48 +00:00

29 KiB
Raw Blame History

RBAC Grant Model Contract

Status: DRAFT — awaiting ratification (webui-audit S2, contract 2 of 9). Authority: PRD Part I §4 ("Granular RBAC: admins restrict access per company, estate, and project; grants are evaluated down the chain") and the native-kanban SOT Amendment A1 (§8.1.3 RBAC evaluation, §8.3 acceptance 2). This document defines the grant vocabulary, evaluation semantics, and revocation propagation that the hierarchy schema contract (docs/requirements/hierarchy-schema.md, contract 1) attaches to. Contract 1 pins the hierarchy_grants table shape and defers the role vocabulary and the meaning of "authority" here; the identity contract (docs/requirements/identity-lifecycle.md §1.4) pins that account creation grants nothing.

Revision 2 (independent review, GLM 5.3): §1.1 consequence analysis completed — the two existing platform-admin bypass code paths are named as non-conformant and §7.4 retires them; team grant subjects suspended pending a team contract (§1.4, §3.33.4, §7.5); no-self-escalation restated with its true rationale and a constructible observable (§4.2, §7.7); node-creation seeding scoped to the bootstrap path, resolving the §7.7/§4.3 contradiction; A1 quotation corrected; audit-field provenance corrected; principal-position consequence named (§1.3); membership-row, fail-closed-fault, and existence-oracle observables added (§7); role-string namespacing rule added (§4.5); ruling request now names the interpretive resolution of PRD "admins".

Amendment 1 (company-CRUD capability): defines the capability class that contract 1 Amendment 1 (Ruling 4b, 2026-08-28) and hierarchy schema §5.5 anticipate. §8 defines the capability as a platform-scoped, admin-assigned, audited delegation of exactly the hierarchy schema §5.5 company visibility command — no read command, no other company operation; the mutation's inherent existence disclosure is ratified as a bounded carve-out to hierarchy schema §6.7/§2.8 and to kanban SOT Amendment A2's sole-disclosure rule — SOT Amendment A3 (native-kanban-sot.md §10, this PR) expressly extends A2 by exactly this carve-out (§8.1). The holder sees only a redacted actor receipt; the canonical audit event keeps hierarchy schema §5.2's full immutable snapshot. The hierarchy role vocabulary (§2), every evaluation rule (§3), and grant management (§4) are untouched: the capability is not a hierarchy_grants.role value and evaluates outside the chain; capability-row deletion joins §6.1's revocation enumeration (§8.4). Until this amendment ratifies, the capability class is empty and the visibility command remains admin-only (hierarchy schema §5.5 states this fallback; the shipped gate at apps/gateway/src/hierarchy/hierarchy.repository.ts implements it).

Scope: the roles that can appear in hierarchy_grants.role, what a grant at each hierarchy level confers, how grants evaluate down the chain, how revocation propagates, and who may manage grants. Out of scope: the hierarchy tables themselves (contract 1), workspace-internal membership and its role/capability vocabulary (native-kanban SOT REQ-ID-001 and its implementing schema), roll-up projection semantics (contract 8), wizard seeding (contract 3), the team model (suspended here; see §1.4).

1. Three authority layers, none substitutable

  1. Platform role (users.role, better-auth: member | admin) governs instance administration — user management, system settings, provider configuration. It is not tenancy authority: holding platform admin confers no implicit hierarchy grant and no workspace authorization. An operator who should see tenant content holds an explicit, audited grant like anyone else. This is the deny-by-default consequence of A1 §8.1.3 ("not a bypass of workspace authorization"). AdminGuard's role === 'admin' check on admin endpoints stays the platform role's only meaning. Two shipped code paths violate this rule today and are implementation defects this contract makes non-conformant: (a) the command authorization service short-circuits every command scope to allowed for platform admins (apps/gateway/src/commands/command-authorization.service.ts, hasScope returning true when role === 'admin'), and (b) the MCP scope derivation maps platform admin to tenant-admin MCP scopes including task create/update (apps/gateway/src/mcp/mcp.service.ts, deriveMcpToolScopesForUser). Ratifying this contract revokes both; §7.4 names them as the surfaces the deny-by-default test retires.
  2. Hierarchy grants (hierarchy_grants, contract 1 §3) declare tenancy authority at company, estate, or platform-project scope and evaluate down the chain to workspace-scoped authorization (§3 below).
  3. Workspace membership (SOT REQ-ID-001) remains its own mechanism. A chain grant confers command authorization over descendant workspaces; it does not create membership rows, and row-level principal positions (task owner, proposer, decision actor) still require ACTIVE workspace membership exactly as REQ-TEN-001/REQ-ID-001 acceptance states. Consequence, stated so implementing PRs do not weaken REQ-TEN-001 to remove the friction: a chain-granted actor who is not a workspace member may issue the write commands their role implies but cannot occupy a principal position — any command taking a principal argument must name an ACTIVE member of the target workspace (§7.2 enumerates this cell).
  4. Team grant subjects are suspended. Contract 1 §3.1 reserves a team_id attachment point, but no ratified contract yet defines the team it would bind: the only existing teams table is the legacy global Brain table (own authority columns, no workspace binding, not repurposed per contract 1 §1.3), while the SOT's teams are workspace-bound (REQ-ID-001) — and a workspace-bound team holding a company-level grant would be a cross-workspace authority group nothing has ratified. Until a team contract defines the subject (which table, which membership rows, and its relation to D2/REQ-ID-001), creating a grant with a team subject MUST be refused at the command surface (the schema column remains, per contract 1). §3's evaluation semantics for team-conferred grants are specified now so the team contract activates them without amending this one.

2. Role vocabulary

One vocabulary at every hierarchy level, totally ordered — a higher role includes everything below it:

  1. viewer — read: sees the node, its subtree structure, and the roll-up aggregates over descendant workspaces (within contract 8's carve-out bounds); read access to descendant workspace content per the SOT's read command families. No mutation of anything.
  2. member — work: everything viewer has, plus write authorization for business/orchestration command families in descendant workspaces (the concrete command-family mapping is implementation work under SOT REQ-ID-001; this contract pins that member maps to the workspace write families and nothing structural).
  3. owner — structure: everything member has, plus hierarchy mutations on the subtree (create/rename/delete child nodes, transfers per §5), and grant management on the node and its subtree (§4).

No other value is valid in hierarchy_grants.role; the column is constraint-checked against exactly these three. Extending the vocabulary is a contract amendment, not an implementation decision.

3. Evaluation semantics

  1. Deny by default. No grant on any ancestor → no authority. There are no implicit grants: not from platform role (§1.1), not from creating a node (§4.3), not from workspace membership (membership without a chain grant confers exactly what the SOT's own membership rules confer inside that workspace, nothing up the chain).
  2. Down-the-chain only. A grant on a node applies to that node and its entire descendant subtree. Nothing evaluates upward or sideways: a grant on an estate says nothing about the parent company or sibling estates.
  3. Effective role = maximum. A subject's effective role at any node is the highest role among grants held directly by the subject's user on that node or any ancestor — and, once the team contract activates team subjects (§1.4), grants held by any team the user is a member of on that node or any ancestor. Roles never subtract — there is no negative/deny grant in this model; revocation is deletion (§6).
  4. Team grants follow live membership (specified now, active only per §1.4). A team grant confers its role on the team's current members, evaluated at decision time. Leaving the team is loss of the grant with §6's propagation bound.
  5. Live evaluation, fail closed. Authorization decisions derive from the live grant and team-membership rows (or from a cache that is invalidated in the same transaction as any grant/membership/hierarchy mutation). A decision path that cannot read grant state denies. No materialized ACL is ever authoritative.
  6. Tenant context stays derived from authenticated authority (REQ-TEN-001). The chain adds where grants can be declared; a workspace request is still authorized against that workspace, with the chain contributing the effective role — never letting the chain become what A1 §8.1.3 forbids: "a bypass of workspace authorization".

4. Grant management

  1. Creating, changing, or revoking a grant on a node requires effective owner on that node (directly or via any ancestor).
  2. No self-escalation. A grant manager cannot create a grant with a role higher than their own effective role on the target node. Under the §2 vocabulary this rule is currently implied by §4.1 (managers are owner, the top role — no constructible grant exceeds it); it is stated explicitly so it survives any future amendment that decouples grant-management authority from role height. Its observable is the §7.7 audit invariant, not a refusal test.
  3. Bootstrap of authority is explicit; inheritance covers the rest. Creating the first company (the wizard path, contract 3) and any top-level company creation MUST name the initial owner grant in the same audited operation — a top-level node has no ancestor to inherit from, so without this the node would be unownable. Creating a child node (estate, platform-project, workspace) requires effective owner on the parent (§2.3) and confers no automatic grant; the creator's authority over the new node already follows from §3.2 down-the-chain evaluation. The creating command MAY additionally name an explicit initial grant for a child node; it is not required to.
  4. Every grant mutation is a semantic audit event under contract 1 §5.2's guarantees, extended by this contract with two further fields: the event carries actor, verb, target, subject, and role (subject and role are this contract's addition; contract 1 §5.2 does not enumerate them).
  5. Role strings are namespaced. viewer/member exist at hierarchy level, member/admin on users.role, and the current command layer uses a third viewer|member|admin vocabulary — same strings, different meanings. Any serialized role string (audit events per §4.4, API responses, logs) MUST identify its layer (e.g. hierarchy:owner, platform:admin); a bare role string in a serialized artifact is non-conformant.

5. Transfer authority (completes contract 1 §4.2)

"Authority over BOTH the source and the destination parent" means: effective owner on the current parent node (or an ancestor) AND effective owner on the destination parent node (or an ancestor), evaluated at transfer time in the transfer's own transaction. One subject must hold both; two cooperating half-authorized subjects are not a transfer protocol this contract defines.

6. Revocation propagation

  1. Revoking a grant (deleting the row), removing a user from a team that carries a grant (once team subjects activate, §1.4), or the cascade deletion of a node's grants during node deletion (contract 1 §3.3) all propagate identically: the authority derived from that grant is gone for every descendant workspace.
  2. Bound: the next authorization decision on any affected transport decides against the revoked grant. Concretely: no new HTTP/MCP command authorized by the revoked grant after the revoking transaction commits; an open Socket.IO connection whose subscriptions depend on the revoked grant is re-evaluated within 30 seconds or at its next inbound message, whichever comes first (same bound as the identity contract's §7.1 deactivation rule; same mechanism may serve both).
  3. Revocation is subtractive only in effect, not in representation: the evaluator never needs tombstones; deletion of the row is the revocation.

7. Verification requirements

Binding on the implementing PRs (extends A1 §8.3 acceptance 23 and contract 1 §6):

  1. Vocabulary: the role CHECK constraint rejects any value outside viewer|member|owner (real-PostgreSQL witness, ci-postgres service in the test CI step).
  2. Per-level conferral: for each of the three levels × three roles, a grant yields exactly the implied workspace authorization in a descendant workspace and nothing in a non-descendant workspace (the A1 §8.3 "exactly the permissions the chain implies" matrix, enumerated). The matrix includes: a chain grant creates zero workspace-membership rows (assert row counts); a chain-granted non-member is refused as the principal argument of any principal-taking command while their non-principal writes succeed (§1.3); structure reads leak no existence of nodes the reader holds no grant on (no cross-tenant existence oracle, A1 §8.3 acceptance 3).
  3. Ordering: ownermemberviewer behaviorally — each higher role passes every lower role's positive cases.
  4. Deny-by-default: platform admin with no grant reaches no tenant content — asserted against the two §1.1 non-conformant surfaces after their retirement: the command-authorization admin short-circuit and the MCP tenant-admin scope derivation both gone (a platform admin with no grant is refused workspace commands and receives no tenant MCP scopes); workspace member with no chain grant gains nothing outside SOT membership semantics; fresh account reaches nothing (identity contract §1.4 cross-check).
  5. Team subjects: while suspended (§1.4), creating a team-subject grant is refused at the command surface. On activation by the team contract: user-direct and team-conferred grants combine to the maximum; team-leave drops authority within the §6.2 bound; decision-time evaluation witnessed (grant added → next decision allows; no restart or re-login required).
  6. Revocation: each revocation path in §6.1 denies the next command on every transport; the socket bound is measured; a cached-authorization implementation proves transactional invalidation (grant revoked and decision made on two distinct physical connections). Fail-closed fault witness for §3.5: with grant state unreadable (fault injection), the decision denies.
  7. Grant management: non-owner cannot mutate grants; top-level company creation without the named initial owner grant is refused, while child node creation under ancestor authority succeeds without one (§4.3 both directions); every mutation produces its audit event with the §4.4 fields. Self-escalation observable: over the audit event stream, every grant-create/change event's role is ≤ the acting user's effective role on the target at event time (reconstructable invariant, not a refusal test — see §4.2).
  8. Transfer: both-sides owner accepted, each single-side case refused (completing contract 1 §6.5).

8. Company-CRUD capability (Amendment 1)

Hierarchy schema §5.5 authorizes the company visibility mutation for exactly two actor classes: platform admins and "subjects holding the company-CRUD capability that a follow-up amendment to contract 2 will define". This section is that definition. The name is historical — coined in contract 1 Amendment 1 before the capability's content was fixed — and confers nothing by connotation: the ratified content is exactly §8.1. Company creation is already ruled open to active users and always private (contract 3 §5.2, Ruling 4); rename, delete, and transfer of companies remain hierarchy owner operations (§2.3, §5); none of those is part of this capability, and widening it to any other operation is a further amendment, not an implementation decision.

  1. Content: exactly one command, no read command, disclosure stated. Holding the capability authorizes executing the hierarchy schema §5.5 visibility command (companies.visibility, both directions: private → directory and directory → private) on any company in the deployment, and no other command of any family. It confers no read command: no company enumeration, no curation listing, no structure read. The practical flow this implies is deliberate: to publish a private company, the holder is given the target identifier by the requesting company owner out of band; to unpublish, the target is already directory-listed. A curation listing for capability holders, if ever wanted, is a further amendment with its own disclosure analysis under hierarchy schema §6.7.

    Existence disclosure carve-out, stated rather than pretended away: exercising a mutation inherently discloses its target's existence. The command's result distinguishes an existing company (success, for private and directory targets alike) from a nonexistent id (not_found), so a holder presenting candidate ids learns existence — exactly as a platform admin already does through the same command. This amendment ratifies that disclosure as part of the §5.5 curation authority, bounded as follows. The holder-visible surface is the command's actor receipt — the mutation result payload, carrying exactly the target id, old visibility, and new visibility, and nothing else: no name, slug, structure, content, grant, or membership information. The actor receipt is a redacted projection distinct from the canonical audit event, which is unchanged by this amendment: it keeps hierarchy schema §5.2's deletion-safe immutable target snapshot (id, slug, and parent chain at event time) in full. The two never converge on the holder: the capability confers no audit read (§8.5), so the canonical event — and with it the slug and parent chain — is reachable only by subjects independently authorized to read audit data, never through this capability. A successful publish additionally makes the target directory-listed to every authenticated user; that is the command's ratified purpose (hierarchy schema §5.5), not a leak. Hierarchy schema §6.7's existence-oracle rule and §2.8's directory-only disclosure are amended by exactly this carve-out for capability holders, kanban SOT Amendment A3 (native-kanban-sot.md §10, this PR) expressly extends A2's sole-disclosure enumeration by the same carve-out, and all three are otherwise untouched. Witnessed in §8.6.3.

  2. Holding: platform-scoped assignment, user subjects only. The capability is not a hierarchy grant: it attaches to no node, has no role, and never enters §3 chain evaluation. It is held via a platform_capabilities table whose column set is exactly (nothing else, per the contract 1 §2.7 exhaustiveness discipline):

    • id — uuid, primary key;
    • user_id — text, NOT NULL, FK users ON DELETE RESTRICT;
    • capability — text, NOT NULL, constraint-checked against exactly company_crud;
    • granted_by — text, NOT NULL, FK users ON DELETE RESTRICT;
    • created_at — timestamptz, NOT NULL;
    • UNIQUE (user_id, capability).

    The user FKs are text, not uuid, because users.id is a BetterAuth text key (packages/db/src/schema.ts; custody schema records the same) — PostgreSQL cannot reference a text primary key with a uuid column. This matches the shipped hierarchy_grants shape exactly: uuid surrogate id, text FKs to users.

    Both user FKs are RESTRICT for the same reason contract 1 §3.3 pins RESTRICT on principal FKs: the identity contract (§7.3) gates user deletion, and a cascade here could silently destroy a capability without its §8.3 revocation audit event. Revocation is row deletion through the §8.3 command — there is no other removal path, no expiry column, and no tombstone. A deactivated holder confers nothing while deactivated: identity contract §7.1 denies all authorization to deactivated accounts, and the §8.4 predicate evaluates on the authenticated live user. No team subjects (§1.4's suspension reasoning applies with more force here — a workspace-bound team holding deployment-wide curation authority has no ratified meaning).

  3. Assignment is instance administration on the normal admin surface. Only platform admins (users.role = 'admin') may assign or revoke the capability, through an ordinary admin command (the same command class AdminGuard governs, §1.1) — not through direct table writes. Assignment delegates a slice of instance administration and is itself an instance-administration act under §1.1. A capability holder as such may NOT assign or revoke it (no self-propagation). Every assignment and revocation is a semantic audit event carrying actor, verb, subject user, and capability; serialized capability strings are namespaced per §4.5 (platform-capability:company-crud — a bare company_crud in any serialized artifact is non-conformant).

  4. Evaluation and revocation follow this contract's existing rules. The hierarchy schema §5.5 command's authorization predicate is: users.role = 'admin' OR a live platform_capabilities row (user_id, company_crud). Both disjuncts are evaluated live and fail closed per §3.5 — independently: with capability state unreadable (fault), the capability disjunct denies, but a platform admin whose users.role is readable remains authorized through the admin disjunct; with role state unreadable, the admin disjunct denies likewise. A decision that can read neither denies. Capability-row deletion is hereby added to §6.1's enumerated revocation paths: it propagates identically, under §6.2's bound, on every transport — no new HTTP/MCP command authorized by the deleted row after the revoking transaction commits, and any cached authorization is invalidated in the revoking transaction (§3.5).

  5. What it does not confer, stated so implementing PRs cannot drift: no hierarchy grant or effective role at any node; no workspace authorization or membership; no content, structure, or roll-up read; no grant management (§4.1 unchanged); no MCP scope; no other instance administration (user management, system settings, provider configuration remain platform-admin-only); no company create, rename, delete, or transfer. Hierarchy schema §5.5's rule that a company owner as such may NOT change visibility is unchanged — owner and this capability are disjoint authorities that combine only by a subject holding both.

  6. Verification requirements (extends §7, binding on implementing PRs):

    1. Schema witnesses (real PostgreSQL, ci-postgres service in the test CI step): the capability CHECK constraint rejects any value outside company_crud; NOT NULL enforced on every declared NOT NULL column; UNIQUE (user_id, capability) rejects a duplicate; both user FKs reject a dangling reference AND deleting a referenced user is refused (RESTRICT witnessed in both directions); the table's column set is exactly the §8.2 declared set (contract 1 §6.2 discipline).
    2. Capability-only command matrix — the witness that proves "exactly one command", not merely "at least one": a non-admin holder with no other grants succeeds on the visibility command in both directions with contract 1 §5.2's audit event (old and new values as semantic content), and the same actor is refused, case by enumerated case: every hierarchy mutation family (company/child create under another's node, rename, delete, transfer); grant create/change/revoke; the workspace read and write command families; roll-up reads; structure reads — including the not-found-indistinguishable refusal on a structure read of the very company they just mutated (hierarchy schema §6.7); every instance-administration surface other than the visibility command (user management, system settings, provider configuration, and capability assign/revoke itself); and MCP scope derivation yields nothing — the §7.4 deny-by-default matrix gains this row. Company creation compares against an eligible-user baseline: the holder's create behaves exactly as any active user's — always private, and a creation request carrying a visibility argument is refused for holder and baseline alike (contract 3 §5.2).
    3. Disclosure bound (§8.1 carve-out witnessed, receipt and canonical event separately): the mutation result for a private-valid target, a directory-valid target, and a nonexistent id is exactly {success, success, not_found}; the actor receipt for a success carries exactly {target id, old visibility, new visibility} and no result or error payload carries name, slug, structure, content, grant, or membership data; the canonical audit event for the same mutation — asserted directly against the hierarchy outbox, not through any holder-facing surface — carries hierarchy schema §5.2's full immutable snapshot (id, slug, parent chain); and the holder's attempt to read audit data is refused (no audit read conferred, §8.5), proving the receipt/event separation reaches the holder as a redaction, not a weakened event.
    4. Assignment path, both polarities: a platform admin assigns and revokes through the normal admin command (positive witnesses — assign then observe the §8.6.2 allow, revoke then observe deny); a non-admin — including a current capability holder — is refused assign and revoke; every assign/revoke produces its audit event with the namespaced string (§8.3); a direct-write path that skips the command surface is non-conformant (the §8.3 command is the only writer of platform_capabilities).
    5. Revocation joins the §7.6 matrix: assignment is decision-time-live (capability assigned → the holder's next visibility command allows, no re-login); after row deletion, the ex-holder's next visibility command is refused on every exposed transport, measured with the revocation and the decision on distinct physical connections; a cached-authorization implementation proves transactional invalidation (§3.5). Fail-closed fault witnesses, both disjuncts (§8.4): with platform_capabilities unreadable, a non-admin holder is denied while a platform admin remains authorized; with role state unreadable, the admin disjunct denies.
    6. Owner-as-such refusal re-witnessed: hierarchy schema §6.9's owner-cannot-publish witness re-asserted with the platform_capabilities table present and empty for that owner.

Ruling request

Ratify sections 17 as written, with one decision embedded and one interpretive resolution named:

  • Decision: platform admin confers no implicit tenant access — operators see tenant content only through explicit, audited grants (§1.1), which retires the two existing admin bypass paths named there. Say "agreed" or name the implicit access you want platform admins to keep.
  • Interpretive resolution (for visibility, not a separate question): PRD Part I §4 says "admins restrict access per company, estate, and project"; this contract resolves "admins" as hierarchy owners (§4.1), not platform admins. A1 §8.1.3 does not attribute grant declaration to platform admins, and the §1.1 decision above is what makes this reading binding.

Ruling request (Amendment 1)

Ratify §8, the Amendment 1 header note, and kanban SOT Amendment A3 (native-kanban-sot.md §10 — the express A2 carve-out extension, which binds only with this ratification) as written, with one decision embedded:

  • Decision: the company-CRUD capability is a platform-scoped, admin-assigned, audited delegation of exactly the hierarchy schema §5.5 visibility command — no read command, no other company operation, with the mutation's inherent existence disclosure ratified as a bounded carve-out (§8.1). Say "agreed" or name the additional operations (or the curation listing) you want it to carry.