ObjectStackObjectStack

Authorization architecture — how it decides

One-page map of ObjectStack authorization — the enforcement chain, how grants combine, package provenance, lifecycle coverage and the CI checks behind it.

This page is the consolidated overview of how authorization works across the platform. Every decision summarized here is owned by an ADR (index at the bottom) — this page adds no new decisions; it exists so you don't have to read nine ADRs to hold the model in your head.

TL;DR — A request passes through six gates, each fail-closed at its own layer: anonymous deny → declaration-derived public-form grant → object CRUD (permission-set union) → OWD/sharing → row-level security → field-level security. Grants union most-permissively; hard prerequisites (AND-gates) and fail-closed defaults are the only implicit denies. Enforcement is tracked in a conformance matrix whose CI ratchet covers a curated set of HTTP/transport entry points: a new ungated route there, or a deleted guard on one already pinned, fails the build. For a primitive enforced by a predicate inside a resolver the matrix is a hand-maintained ledger, not a checked one.


The three-way separation (ADR-0066)

Authorization splits into three concerns that stay decoupled:

  1. Capability — what can be done (manage_users, export_data). Defined by the platform (curated PLATFORM_CAPABILITIES) or by a package via defineCapability; extended by admins in Setup. A capability is not a contract and has no inputs — a resource merely references one by name (see Requirement, below).
  2. Assignment — who holds it — permission sets and positions, which are catalog definitions served by name from the environment registry (a position names the sets it distributes in its permissionSets; ADR-0131 D3), handed to users by the assignment rows sys_user_permission_set and sys_user_position, which name the set or position. The assignment rows are runtime records, maintained by admins in Setup — and, since ADR-0090 D12, a governed surface: writing them requires tenant-level administration or a covering delegated adminScope, never just CRUD on the tables.
  3. Requirement — what a resource needs — an object / field / action references a capability as a contract. A resource never bakes in "who", only "what is required".

The enforcement chain

Every data request traverses these gates in order. Each names its enforcement site — the file you read when behavior surprises you.

#GateWhat it decidesEnforcement siteFailure direction
1Anonymous denyNo identity → HTTP 401. Uniform across every HTTP surface that reaches object data (#2567): REST /data and the metadata endpoints (/meta) — the raw-hono standard /data routes left the matrix when that duplicate surface was deleted in v17 (#4073), and the dispatcher GraphQL endpoint left when the GraphQL surface was removed (/graphql now 404s) — one shared decision, so a caller denied on /data can't read the same rows through a sibling door. Unconditional (#3963, closing out ADR-0056 D2): the api.requireAuth: false opt-out was retired in v17 — the key is tombstoned, so authoring it is a parse error, and anonymous callers are denied on every data surface with no deployment-level escape hatch. Narrower public surfaces do not need it — each derives its own authorization from a declaration rather than from the deployment posture: control plane (/auth, /health, /discovery) is allow-listed; public form submission carries a publicFormGrant (ADR-0056 Option A); share-links validate their token then read as SYSTEM; and an anonymous GET of the book/doc read surface is admitted so book.audience: 'public' works under the secure default, with the ADR-0046 §6.7 audience gate — 'public' only, fail-closed — doing the authorizing (#3963).packages/core/src/security/anonymous-deny.ts shouldDenyAnonymous — called by rest-server.ts enforceAuth and the dispatcher handleMetadata/handleAI (default in packages/spec/src/api/rest-server.zod.ts); a ratchet in authz-conformance.test.ts enumerates these entry points from source across a curated file list, and fails CI if a new surface ships ungated in one of those filesfail-closed
2Public-form grantAn anonymous form submission carries a declaration-derived publicFormGrant authorizing ONLY create + read-back on the form's declared target object — never anything else (ADR-0056 Option A). No guest-portal configuration needed (anonymous principals hold the guest position).packages/plugins/plugin-security/src/security-plugin.ts (ObjectQL middleware)scope-limited allow
3Object CRUDallowRead/Create/Edit/Delete (+ the destructive lifecycle class allowTransfer/Restore/Purge, gated ahead of the M2 operations — #1883) resolved across the caller's permission sets.packages/plugins/plugin-security/src/permission-evaluator.ts checkObjectPermissionfail-closed 403
4OWD / sharingOrg-wide default (private / public_read / public_read_write / controlled_by_parent; unset or unknown ⇒ private, fail-closed — ADR-0090 D1), manual record shares, criteria sharing rules (owner-type rules were removed from the authoring surface in v17 rather than left declared-but-skipped — Sharing Rules), business-unit hierarchy widening (ADR-0057 D5: scope-depth hierarchy lives on sys_business_unit, not positions).packages/plugins/plugin-sharing/src/sharing-service.ts + sharing-rule-service.tsfail-closed to owner-only
5Row-level securityCEL predicates (using read filter, check write post-image) compiled into the query. If applicable policies exist but none of them compiles, the result is a deny-all sentinel (fail-closed); an object with no applicable policy is simply unfiltered at this layer — RLS narrows what the earlier gates already allowed, it never denies on its own. An uncompilable policy alongside compilable ones is excluded from the OR-union with a logged warning — exclusion can only narrow access, never widen it. Tenant isolation is a wildcard RLS rule AND-ed on top.packages/plugins/plugin-security/src/rls-compiler.ts + security-plugin.tsfail-closed
6Field-level securityRead mask (strip non-readable fields) + write deny per fields rules. Caller queries that filter, sort, group, or aggregate by a non-readable field are rejected outright (HTTP 403, field_predicate_denied) — masking only the output would leave row presence as a value oracle. RLS-injected predicates are exempt (they run after the guard and may reference hidden fields like owner_id).packages/plugins/plugin-security/src/field-masker.ts + predicate-guard.tsfail-closed on predicates; see posture note below

Two orthogonal identity-layer gates run before all of this: the ADR-0069 authentication-policy gate (password expiry / enforced MFA blocks a gated session from protected resources while keeping remediation reachable), and anti-escalation — RBAC tables are read-only for organization_admin, and since ADR-0090 D12 every RBAC-table write is additionally checked by the delegated-admin gate: tenant admins pass, delegates are confined to their adminScope (BU subtree + assignable-set allowlist, no self-escalation, strict containment for scope grants), and everyone else is denied. The everyone/guest audience-anchor bindings reject high-privilege sets at the data layer for every caller.

The MCP execution surface (ADR-0096 / ADR-0101)

The serve-side MCP server (@objectstack/mcp) is a first-class execution surface, not a side door around the chain above. Whatever an agent does over MCP reaches object data only through the same gates 3–6 as a REST request: a per-request, principal-bound bridge runs every tool through callData with the caller's ExecutionContext, so object CRUD, OWD/sharing, RLS, and FLS apply identically. Identity is admitted fail-closed at each transport boundary, and both transports hold an enforced row in the conformance matrix (mcp-http-identity, mcp-stdio-authority), so a fail-open regression breaks CI like any other surface.

  • HTTP (/api/v1/mcp, default-on — ADR-0096). The dispatcher resolves the same ExecutionContext a REST call would; no userId and not isSystem ⇒ 401 (advertising RFC 9728 OAuth metadata in WWW-Authenticate when the OAuth track is live, else a plain 401). An OAuth token additionally narrows the exposed tool families to its granted scopes (403 on none). Enforcement: packages/runtime/src/http-dispatcher.ts handleMcp + buildMcpBridge(context).
  • stdio (opt-in — ADR-0101). The long-lived local transport has no ambient identity: it mints its principal solely from OS_MCP_STDIO_API_KEY, resolved through the same resolveAuthzContext chain and re-resolved per call (revocation takes effect live). It is fail-closed — a missing / unknown / revoked / expired / owner-less key refuses to start the transport — with no system bypass (full authority means minting an admin or service key). Enforcement: packages/mcp/src/plugin.ts start().

Combination semantics (the fixed order)

From ADR-0066 Precedence / combination semantics — the contract, not an implementation detail:

  1. AND-gates first (hard prerequisites). A resource's requiredPermissions and a private object posture must ALL clear before any grant is consulted. Missing one denies regardless of everything else.
  2. Grants union (most-permissive). Object CRUD and field grants combine across all of the caller's permission sets — any set that allows, wins. Keys never collide across packages because object api names are package-namespaced.
  3. RLS: OR within an object, AND with tenant-global. Multiple row policies for the same object/operation OR-combine their using on reads and on the rows a write may target. The check on the rows an insert or update writes is chosen per operation first: when any applicable policy declares check, only the declared checks decide (OR-combined) and a USING-only policy adds nothing; otherwise each using stands in (OR-combined) — see the fail-closed contract. The tenant wall (Layer 0, ADR-0095 D1) is a separate always-first AND conjunct, not an OR-mergeable policy. viewAllRecords / modifyAllRecords (super-user bypass, posture-gated) short-circuit the object's business RLS. Crossing the tenant wall, though, requires the PLATFORM_ADMIN posture (ADR-0099 D1). That posture is derived at one site, and which anchors it reads depends on the deployment's tenancy posture. The deployment's configured administrator list — an address declared in OS_PLATFORM_OWNER_EMAIL, matched against the caller's own stored and email-verified sys_user row — is sufficient under every posture. An unscoped admin_full_access grant row is sufficient only under single; under the walled postures (group / isolated) it is no longer an anchor — the set it names still grants what any held set grants, but it confers no PLATFORM_ADMIN standing, so there standing comes from configuration alone. See who holds admin_full_access for both. A scoped grant, or piecemeal platform capabilities (studio.access, manage_users, …), grant Studio/admin functions but never widen the tenant data boundary. A tenant organization_admin never crosses it (invariant I1).
  4. Explicit deny — reserved. There is no deny layer yet; the only implicit denies are the AND-gates in (1) and fail-closed defaults. Permission-set groups + subtractive muting (Salesforce-style) are the planned step 4 (ADR-0066 ⑦) — when they land they must cover field grants too (ADR-0066 ⑧).

FLS posture (ADR-0066 ⑧): runtime field security is block-list-shaped — an undeclared field is visible by default, and field grants union (one set's readable: true out-votes another's false). Until the muting layer lands, protect sensitive fields by granting them only in the sets that need them, and treat "sensitive field on a public object" as a review smell.

Package provenance & composition (ADR-0086)

The metadata↔config boundary follows one line: definitions travel with the package (metadata); subject bindings and env-specific values stay as config.

  • A package ships its own permission sets (Shape B): its stack.permissions are registry items stamped with the owning package (_packageId), served by name from the security catalog (ADR-0131 D3). The boot writes no sys_permission_set row for them, and a package never writes into a foreign definition.
  • The environment admin assigns sets to positions/users; the runtime unions them. One shared set with hand-picked cross-package grants remains an env-admin-only construct.
  • This is what makes package uninstall well-defined (drop the package's own sets) and the objectui Access matrix scopable to { packageId }.
  • Declared sharing rules seed the same way (ADR-0057 D6) — a declarable-but-never-seeded array is exactly the inert-metadata smell ADR-0078 prohibits. Declared positions need no row at all: the security catalog serves them from the registry, and no boot step writes a sys_position row (ADR-0131 D2).

Package capability declaration (ADR-0066 D1)

A package DEFINES its own authorization capabilities with defineCapability, collected on the stack's capabilities array — the declaration-side counterpart of the platform's curated PLATFORM_CAPABILITIES:

import { defineCapability, defineStack } from '@objectstack/spec';

export const ExportDataCapability = defineCapability({
  name: 'export_data',
  label: 'Export Data',
  description: 'Bulk-export records to CSV/XLSX.',
  scope: 'org', // 'platform' (global) | 'org' (scoped to the caller's org)
});

export default defineStack({
  manifest: {
    namespace: 'billing',
    /* …full ObjectStackManifest… */
  },
  capabilities: [ExportDataCapability],   // ← DEFINE
  permissions: [{ name: 'billing_admin', systemPermissions: ['export_data'] }], // ← GRANT
  // a resource REQUIRES it:  requiredPermissions: ['export_data']
});

At boot each declaration becomes a registry item owned by its package — the capability's one home (ADR-0131 D3). No sys_capability row is written for it. The registry's one-holder rule refuses a second holder of a name at boot with a 422, so a package can neither take a curated platform capability nor take over another package's.

This replaces the implicit back-door where a capability existed only as an untitled placeholder derived from whatever a permission set happened to reference in systemPermissions[]. That derivation is retired: a reference with no declaration still matches by name at runtime but gets no sys_capability row. An explicit defineCapability is the attributable form: its label, description, scope and owning package live on the registry item, and it gets no row either. This is the ADR-0066 D1 direction: retire the implicit managed_by-guessing back-doors in favour of explicit, attributable declarations.

Remember the three-way separation: a capability is not a contract. You DEFINE it here, GRANT it via a permission set's systemPermissions, and REQUIRE it on a resource via requiredPermissions. There is no inputs.

Two doors, one metadata (ADR-0086 D6/D7)

Package provenance is not just descriptive — the registry records the owning package on every item it serves and the metadata door gates on it, so editing a permission set flows through exactly one of two doors, each writing only what it owns:

  • Package door (studio /studio/:packageId/access) — a package's own set is metadata, so edits are saved as a draft stamped with the packageId (saveMetaItem mode:'draft') and go live with the package's atomic Publish, exactly like Data and Interfaces. On publish, the security catalog serves the published body from the registry, owned by the package (ADR-0131 D3); no row is written for it. The landed publish fires onMetadataMutation, which runs the audience-binding suggestion reconcile and re-derives the everyone anchor's accepted package defaults. Enforcement is unaffected while a set is still a draft (drafts never enter the active resolve).
  • Environment-admin door (metadata-admin) — the cross-package all-objects matrix plus subject assignment (sys_user_permission_set), edited live (config); the sets a position distributes are its definition's permissionSets. It owns environment-authored sets and assignments — not package sets. The set definition has one authoritative store, the metadata layer (ADR-0094 D1, ADR-0131 D3): Setup → Access Control → Permission Sets opens the catalog list (/apps/setup/metadata/permission?scope=environment), and a create or edit there is a metadata save (PUT /api/v1/meta/permission/:name) into the environment's definition ledger (sys_metadata), served from the registry beside the packages' sets. No row mirrors it: the sys_permission_set object is no longer registered, so no data route serves one. The save requires manage_metadata; under a group or isolated tenancy posture it is the platform operator's, and anyone else is refused 403 (ADR-0131 D3). The name is the item's identity, so there is no rename — clone instead. A delete cannot remove a set a package ships: its definition stays served from the package. Subject assignments remain plain config rows, unchanged.
  • Packaged sets are sealed — an edit of a set whose definition ships as a code artifact is refused with 403 NOT_OVERRIDABLE, loudly, at the moment of the write: permission declares allowEnvironmentOverlay: false (ADR-0005's security row — overlays of the authorization surface would create silent privilege drift), so no environment-scope overlay is minted, and the refusal names the clone (the "Clone" action on the set, or POST /api/v1/security/permission-sets/:name/clone). Narrowing the packaged set itself takes the channel ADR-0086 two-doors always named: edit the package and re-publish. (ADR-0094 D5's 2026-07-14 direction — translate such an edit into a first-class ADR-0005 env-scope overlay — was retired on 2026-08-09; see ADR-0094 D5-R.) A set authored in the environment, whose definition lives only in sys_metadata, rides the still-open allowRuntimeCreate tier and stays editable. System / boot writes carry isSystem and pass the door's capability gate, so the platform's own writes are never self-blocked. That bypass is not local to this gate — the full set of behaviours the flag changes is catalogued in System Context (isSystem).

Lifecycle coverage (five stages)

StageWhat holds todayOwned by
1 · Package developmentZod-validated authoring; sharingRules seeded at boot with provenance; permission sets and positions served from the registry, with no seeded row (ADR-0131 D2/D3)ADR-0057 D6, ADR-0086 D5, ADR-0131 D2, ADR-0049/0078 gates
2 · Distribution / install / upgrade / uninstallInstall-consent scopes (ADR-0025 — consent ≠ RBAC grants); namespaced, collision-free composition; provenance axis makes uninstall well-definedADR-0025/0028/0048/0086
3 · Environment composition / assignmentPlatform-owned assignment records (sys_user_position etc.); anti-escalation; union semanticsADR-0057 D4
4 · Runtime enforcementThe six-gate chain above; each enforced primitive holds a row naming its enforcement site — the matrix file is the authority on the count and on which rows CI can checkADR-0056 D10 matrix
5 · Production / enterpriseADR-0056 D8 dispositions settled (2026-07): compliance configs, data masking, the global RLSConfig, and agent visibility were removed (never enforced — for visibility, correct owner/org enforcement is undesigned, so it was dropped rather than carried; re-introduce under #1901); field encryption stays honestly [EXPERIMENTAL] (roadmap — stable schema shape). Enterprise authentication hardening staged per ADR-0069ADR-0049/0056 D8, ADR-0069

Explaining a decision (ADR-0090 D6)

The security kernel service exposes explain(request, callerContext) — the first-class answer to "why can 张三 PATCH 李四's leave_request?". It walks the SAME code paths the middleware enforces with (shared set resolution, evaluator, FLS mask, RLS composition — explained by construction) and reports every pipeline layer in order:

principal → required_permissions → object_crud → fls → owd_baseline
         → depth → sharing → vama_bypass → rls

Each layer carries a verdict (grants / denies / narrows / widens / neutral / not_applicable), a human explanation, and contributor attribution — which permission set granted, reached via which position / additive baseline / direct grant. For reads, the decision includes the composed row filter as the machine artifact.

The same report is reachable over REST as GET/POST /api/v1/security/explain (object, operation, optional userId; validated against ExplainRequestSchema). The endpoint is authenticated-only and delegates to the service, so both surfaces share one authorization rule: explaining another user requires the manage_users capability or a delegated adminScope whose business-unit subtree covers that user (ADR-0090 D12) — an administrator who can rewire a user's grants may read why they resolve as they do. Studio's Access pillar ships a "why can this user access?" panel on top of this endpoint.

Full request/response walkthrough, layer vocabulary, and caller-authorization details: Explain Engine.

Grant lifecycle: validity windows (ADR-0091 L1)

Every user-grant row (sys_user_position, sys_user_permission_set) carries optional effective-dating columns — valid_from / valid_until (half-open [from, until), UTC; null = unbounded).

ADR-0091 D1 declared four further nullable columns on both grant tables: reason, delegated_from, last_certified_at, certified_by. Three of the four are still declared on both; delegated_from now exists only on sys_user_position — it was retired from sys_user_permission_set under ADR-0049 enforce-or-remove (maintainer ruling 2026-08-18): the runtime delegation gate is structurally scoped to the position table, so on the permission-set table the column was writable provenance no runtime consumer ever read. The columns are declared together but enforced separately, so they are listed here one by one rather than as one set of audit columns — what a value in any of them is worth depends on the column and on which grant table it sits on. Access recertification is a compliance surface (SOX / ISO 27001 access review), where "the platform maintains this column" and "the platform stores what you write here" are very different statements:

ColumnOn sys_user_positionOn sys_user_permission_set
reasonEnforced at runtime. The D3 delegation gate rejects a delegation insert whose row carries no non-empty reason — the dual-audit half described below.Written by the platform, read by nothing. The org-admin grant auto-derived from a membership grade stamps its own provenance here; no gate, resolver or lint reads the value back.
delegated_fromEnforced at runtime, and load-bearing. Stamping it is what makes a write a delegation: the gate requires it to name the writer and refuses to re-delegate a row that itself arrived by delegation, and the explain engine attributes the position "via delegation from X, until Y".Not declared — retired. Removed under ADR-0049 enforce-or-remove (maintainer ruling 2026-08-18): both runtime readers opposite are guarded on sys_user_position, so here the column was provenance an author could record and nothing checked or acted on. A row written with the key today is refused as an undeclared field (400 INVALID_FIELD). If permission-set-granularity delegation is ever wanted, the column returns together with a runtime reader in the same change.
last_certified_at / certified_byInert — the ADR-0091 D5 recertification substrate, storage and nothing more.Inert — identically.

The D5 pair is worth spelling out, because it is the pair a compliance reader is likeliest to over-read: no framework code writes either column and none reads either one, on either table — no resolution path, gate or lint consults them, and nothing derives "never certified" or "certification stale" from them. A null therefore means the recertification workflow does not exist here, not that the grant went unreviewed; a value means some client wrote one, and the platform checked nothing about it. Their field descriptions on both objects say the same in the same words, and ADR-0091 D5 is where that split was decided: framework ships the substrate, cloud ships the campaign.

Correctness lives in resolution-time filtering, fail-closed (ADR-0091 D2): a row outside its window simply stops resolving — in resolveAuthzContext, the explain engine, sharing-rule position expansion, and (transitively) the delegated-admin gate's held-scope resolution. No background cleanup job is involved (ADR-0049); the clock is checked on every resolution. An expired unscoped admin_full_access grant no longer derives platform_admin — a statement about the grant anchor. The configured-administrator anchor is not a stored grant row at all, so it carries no validity window and nothing here dates it; it is revoked by changing the configuration.

The explain engine reports an expired-but-present row as a dedicated contributor state ("held until 2026-08-01 — expired"), so "why did access disappear" is self-answering. The state member is one shared "held but not resolving, because X" vocabulary: a closed enumeration of reasons (expired here; deactivated for the ADR-0049 active switch below), extended only deliberately — every lifecycle control that silently drops a held grant answers through the same member rather than growing its own shape. Two authoring lint rules cover seed grants: one mirrors D2 — a seed grant whose valid_until is already past (or unparseable) is dead on arrival (error) — and one mirrors the D3 dual audit: a delegation row (delegated_from) without reason is an error. The first runs on both grant tables; the second runs on sys_user_position only, the one table that declares delegated_from — on sys_user_permission_set this lint used to be the retired column's only enforcement, which is precisely why the column is gone rather than still linted.

Delegation of duty (职务代理, ADR-0091 D3) builds on this substrate and is enforced today. A position opts in with delegatable: true; a holder may then self-service assign it to a delegate WITHOUT being an administrator — the D12 gate grows a branch that approves a sys_user_position insert iff it is a well-formed delegation: delegated_from = the writer, a mandatory valid_until within the 30-day ceiling, a mandatory reason, and the writer holds the position directly (a grant that itself arrived via delegation is not re-delegatable — chains are cut). Delegation is insert-only, so a "temporary" grant can't be silently rolled forever (no self-renewal); continuing past expiry needs a fresh delegation, leaving a new audit record. A delegatable position may never distribute an adminScope-carrying set — administration is never self-delegated (that would bypass the D12 containment). The write is dual-audited (granted_by = writer, delegated_from = authority source) and the explain engine attributes a delegated hat "via delegation from X, until Y".

Break-glass activation and recertification campaigns remain enterprise product — see ADR-0091 D4–D7 for the open-core line; their community shapes (a time-boxed direct grant with a reason; certification stamps) are the L1 substrate above.

Grant lifecycle: the active switch (ADR-0049)

Validity windows above date a user's grant row. The second lifecycle control switches off the permission set or position itself: a sys_metadata_activation row of type permission or position whose active reads false. The activation ledger is deployment-wide. It answers a different question — "switch this grant off for everyone, without deleting it or unwinding the assignments" — and it is enforced in the same place, by the same discipline: resolution-time filtering, fail-closed, in resolveAuthzContext, with no cleanup job involved.

No definition carries the switch, and no row does: the active column of the retired catalog row objects (sys_permission_set.active, sys_position.active) decides nothing, and neither object is registered any more (ADR-0131 D3). Setup's active switch on the Positions and Permission Sets catalog lists writes the ledger row, through the door below.

The ledger row is written through POST /api/v1/security/_activation/:type/:name, with :type either position or permission and the body { enabled } (false switches the grant off, true back on). The caller needs manage_metadata; under a group or isolated tenancy posture the caller must also be the platform operator, because the row reaches every organization (ADR-0126 §5). The name must resolve in the security catalog, or the route answers 404. That covers a position or permission set the environment authored through the metadata door as well as one a package ships: the ledger row records the package when there is one and leaves package_id empty when there is none, and the resolver honours both alike, because it keys the row by type and name. A write the ledger refuses answers with the status its code means: a record validation failure is 400 VALIDATION_FAILED, a hook's refusal keeps its own pair, and a store failure is 503 SERVICE_UNAVAILABLE. It is the same authority as the flow and action activation switches. The two audience anchors, everyone and guest, are refused with 400 in either direction: one row would switch the authenticated or anonymous baseline off for every principal.

The anchors are sealed against every caller-reachable write: this door, the metadata door and an organization overlay all refuse them. A package's isDefault set still reaches every signed-in member, and the seal reads this way: an acceptance changes the input of a derivation, and no caller writes the anchor's definition. The security plugin is the only declarer of everyone's definition. It derives it as the deployment's baseline plus each package default a manage_metadata holder accepted, and re-declares it on every accept, revoke, uninstall and package publish. The acceptance record is one row per suggestion, owned by the Default Organization, and the high-privilege gate runs on the definition each time it is derived. See Permission sets.

  • A deactivated permission set contributes nothing: not its name, not its system_permissions, not its tab_permissions. As with an expired grant, a deactivated unscoped admin_full_access no longer derives platform_admin — the flag is applied before the posture is derived, not after. A configured administrator is judged elsewhere: that anchor reads the deployment's configuration and the caller's own sys_user row, never the ledger, so the switch does not decide their standing.
  • A deactivated position stops carrying its permission sets, and its name stops appearing in positions, so a permission set that merely shares the position's name cannot resolve through it either.
  • Assignments are untouched. sys_user_position and sys_user_permission_set rows stay exactly as they were, and switching the ledger row back on restores every grant it carried, at the next resolution.

The explain engine reports a deactivated-but-held row with the same dedicated contributor state the expired case gets (state: 'deactivated', "held — deactivated") — the shared vocabulary above. This matters more here than for an expiry: deactivation is an incident-response control, installation-wide, and there is no date on the user's own grant row to notice — without the state, the admin diagnosing "why did access disappear" would get the same answer as for a user who never held the grant, with no pointer to the sys_metadata_activation row somebody switched off — the explainer reads the same ledger the resolver does, so explain and enforce agree. Reported for the grant rows explain already walks: sys_user_position rows and direct sys_user_permission_set grants (a deactivated set reached only through an active position's linkage is not re-derived — that would replicate the resolver's aggregation).

Absent is ACTIVE. Only a ledger row whose active reads false takes a grant away; a permission set or position with no ledger row is in effect. The last-administrator guard reads the same two places the resolver does: whether the catalogue holds admin_full_access, and whether the ledger switches it off. It reads no catalogue row, so a row's active changes nothing it counts.

Switching the break-glass set off is refused while it would leave no administrator. A ledger row switching admin_full_access off un-makes every platform admin who holds it through a grant row, in one write — and switching it back on needs the permission just lost. The last-administrator guard judges such a ledger write like every other write that can take standing away (ADR-0135 D5.2): it is refused only when nobody who can sign in would still administer the environment. A configured administrator or an organization owner or admin still standing permits it. A ledger read that fails refuses the write. Switching it back on is never refused.

Deactivation is an incident-response control, so what it does not touch is deliberate. Administration surfaces keep listing and editing a deactivated position — an admin must still be able to unbind and clean up what they just switched off — and the write gates that judge audience-anchor bindings and a delegated administrator's blast radius do not consult the activation ledger: they judge every binding and assignment in front of them — including the permission sets a position's definition names — whether or not the ledger switched it off, because skipping a switched-off one would make a refused binding permitted and a delegate's boundary narrower, which is the opposite of switching access off.

Governance: how "declared = enforced" is kept true

Five mechanisms — four CI-time, one runtime — make the security posture a checked artifact rather than a belief:

  • Security publish linter (ADR-0090 D7, validateSecurityPosture in @objectstack/lint, gating os compile — and, since the #7891 rollout completed with #8310, the runtime publish door for object / permission / book / seed writes, where a gating finding refuses the save with 422 INVALID_METADATA and the rule id in issues): unset OWD on custom objects (security-owd-unset — an object publish with no authored sharingModel is refused; absence is not a decision, at the CLI and at the runtime door alike), retired OWD aliases, '*' wildcards carrying View/Modify All outside the platform admin set, high-privilege isDefault (everyone-suggested) sets, the reserved word "role" in security identifiers, a controlled_by_parent object without exactly one required master_detail to derive access from (ADR-0055 §1: security-controlled-by-parent-no-relation when it declares none — a master_detail without required: true and a required lookup derive nothing — and security-controlled-by-parent-ambiguous-relation when it declares two or more; either way the runtime denies every read and refuses every write), and the ADR-0091 grant-lifecycle rules (a seed grant already expired at authoring time; a delegation row missing its mandatory reason) — every error rule mirrors a runtime gate.

  • Runtime OWD posture gate (#3050, objectPostureGate in @objectstack/plugin-security, registered on the metadata protocol's pre-persistence registerAuthoringGate seam): the packaged-baseline rule no lint rule can judge, enforced on every runtime-authored object body — Studio drafts, REST saves, AI builders. An environment overlay of a packaged object is refused outright, whether it tightens or widens sharingModel, and with OS_METADATA_WRITABLE set or not (403 NOT_OVERRIDABLE — managed content is sealed, ADR-0131 D6): change the posture in the package source and publish instead. The package door answers ahead of this gate, so its packaged-baseline rule (403 owd_widening_forbidden, ADR-0086 D1) is not reached through the metadata API. Write-path only: stored metadata keeps loading unchanged. The gate's former second rule (403 owd_external_wider, external ≤ internal) was retired as a duplicate when the lint block crossed to the runtime door (#8310 maintainer ruling): the 422 lint door answers first for unauthored-OWD bodies, and the 403 gate remains for packaged-baseline widening only. The key that rule ordered, externalSharingModel, has since been removed (ADR-0049): the object schema refuses it with the prescription "delete the key".

  • Access-matrix snapshot (ADR-0090 D6, buildAccessMatrix / diffAccessMatrix): with access-matrix.json committed next to the config, os compile fails on any capability drift with semantic lines ('crm_admin' gains delete on 'crm_lead') until the snapshot is updated via --update-access-matrix — the snapshot's git diff is the review artifact. Opt-in, format, and workflow: Access-Matrix Snapshot Gate.

  • Conformance matrix (packages/qa/dogfood/test/authz-conformance.matrix.ts, ADR-0056 D10): every authorization primitive sits in exactly one honest state — enforced (must name its enforcement site; high-risk rows must reference an end-to-end dogfood proof), experimental, or removed. CI checks the row shape, that every cited proof file exists, and that the row ↔ proof pairing is mutual in both directions (#7976) — a cited proof must name the rows it proves via a header // authz-row: <id> line, so a row cannot cite a test that exercises a neighbouring primitive, and a shared proof file has to say which rows it covers. A surface ratchet additionally enumerates HTTP/transport entry points from source over a curated file list: a new ungated route in one of those files fails CI as an unclassified surface, and deleting a guard that a row pins makes the pinned key vanish from source, so the row goes stale and the build goes red. Most keys pinned this way name the enforcement call rather than merely a function name, which is what makes them anti-regression — removing the check breaks CI, not just renaming something.

    What CI does not check is "one row per primitive" itself. A primitive enforced by a predicate inside an existing resolver adds no HTTP entry point, so the ratchet cannot see it — and most enforced rows are exactly that shape. For them the matrix is a hand-maintained ledger kept honest by review. Read a row as "this is where the check lives", not as "CI proves nothing was forgotten"; the matrix header carries the measured breakdown.

  • Liveness ledger (packages/spec/liveness/, ADR-0049/0054): every governed spec property is classified live / experimental / dead, with author-time warnings for declared-but-unenforced flags.

The operating rule behind both (ADR-0049): never advertise a capability the runtime doesn't deliver — enforce it, mark it experimental, or remove it.

Known gaps & roadmap

The complete, prioritized gap map lives in issue #2561 (the production "definition of done" for authorization). The headline items:

  • Deny/muting layer (ADR-0066 ⑦⑧) — union-only grants can't take access away; needed for large-org governance and packaged-set adjustment.
  • Capability registry (ADR-0066 D1, ADR-0131 D3) — landed: the platform's curated capabilities, from the canonical list in @objectstack/spec (security/capabilities.ts → PLATFORM_CAPABILITIES), are declared capability metadata served by the registry (registerBuiltinCapabilities, GET /api/v1/meta/capability), with no sys_capability row. A package now DEFINES its own capabilities explicitly via defineCapability / stack.capabilities, served by the registry with owning-package provenance (no row is written for it) — replacing the implicit derive-from-systemPermissions back-door (now retired). The authoring lint (ADR-0066 ⑨) is also landed: validateCapabilityReferences (@objectstack/lint) warns at author time (os validate / os lint) when a requiredPermissions names a capability registered nowhere — no built-in, no defineCapability declaration, no permission set grants it via systemPermissions. A sys_capability seed row is not counted: no runtime reader consults one.
  • Per-operation requiredPermissions (ADR-0066 ⑤) — landed: an object's requiredPermissions may be a string[] (gates all CRUD) or a { read, create, update, delete } map (read-open / write-gated), enforced per operation by plugin-security (security-plugin.ts capability AND-gate).
  • Secure-by-default rollout (ADR-0066 ④) — system-object slice landed: the raw secret/credential stores (sys_secret, sys_jwks, sys_verification, sys_oauth_access_token, sys_oauth_refresh_token, sys_device_code) declare access: { default: 'private' } — no wildcard grant reaches them; platform admins retain access via the posture-gated superuser bypass. sys_sso_provider is capability-gated (manage_platform_settings). Member self-service objects (sys_session, sys_api_key, sys_oauth_application, sys_two_factor) deliberately stay public-posture (the Account app reads them as the member; row scoping is their guard). Still open: Studio posture surfacing (objectui).
  • Deny/muting subtract layer (ADR-0005 overlay; ADR-0066 precedence step 4) — how an environment adjusts a packaged set without forking it; deferred until proven need (ADR-0086 P2).
  • Enterprise authentication (ADR-0069) — password policy, lockout, enforced MFA (P1) and session lifecycle + global IP allowlist + shared multi-node rate-limit store (P2) are landed; the remaining gap is per-org allowed_ip_ranges (#2571). SSO/SCIM is P3.

ADR index

ADROwns
0049No unenforced security properties (enforce / mark / remove)
0054Prove-it-runs — high-risk classes need runtime proofs
0056Permission-model landing: OWD, anonymous deny default, D10 matrix
0057Business units, scope depth, declarative RBAC seeding, platform-owned assignment
0066Unified model: capability registry, posture, precedence, future refinements
0068Built-in identity positions (formerly "identity roles"), EvalUser
0069Enterprise authentication hardening (phased)
0078No inert declarable metadata
0086Metadata↔config boundary, package provenance, cross-package composition
0090Permission Model v2: position rename + vocabulary freeze, profile removal, fail-closed OWD default + external dial, audience anchors, principal taxonomy, publish linter, delegated administration, explain engine + access matrix
0091Grant lifecycle: validity windows + resolution-time filtering (L1, landed), delegation, break-glass, recertification substrate
0096Execution-surface identity admission — no data-engine call without an explicit principal (the MCP HTTP surface admits identity here)
0101MCP stdio principal admission — env-supplied API-key identity, fail-closed, no system bypass

On this page