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:
- Capability — what can be done (
manage_users,export_data). Defined by the platform (curatedPLATFORM_CAPABILITIES) or by a package viadefineCapability; extended by admins in Setup. A capability is not a contract and has noinputs— a resource merely references one by name (see Requirement, below). - 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 rowssys_user_permission_setandsys_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 delegatedadminScope, never just CRUD on the tables. - 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.
| # | Gate | What it decides | Enforcement site | Failure direction |
|---|---|---|---|---|
| 1 | Anonymous deny | No 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 files | fail-closed |
| 2 | Public-form grant | An 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 |
| 3 | Object CRUD | allowRead/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 checkObjectPermission | fail-closed 403 |
| 4 | OWD / sharing | Org-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.ts | fail-closed to owner-only |
| 5 | Row-level security | CEL 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.ts | fail-closed |
| 6 | Field-level security | Read 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.ts | fail-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 sameExecutionContexta REST call would; nouserIdand notisSystem⇒ 401 (advertising RFC 9728 OAuth metadata inWWW-Authenticatewhen the OAuth track is live, else a plain 401). An OAuth token additionally narrows the exposed tool families to its granted scopes (403on none). Enforcement:packages/runtime/src/http-dispatcher.tshandleMcp+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 sameresolveAuthzContextchain 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 nosystembypass (full authority means minting an admin or service key). Enforcement:packages/mcp/src/plugin.tsstart().
Combination semantics (the fixed order)
From ADR-0066 Precedence / combination semantics — the contract, not an implementation detail:
- AND-gates first (hard prerequisites). A resource's
requiredPermissionsand aprivateobject posture must ALL clear before any grant is consulted. Missing one denies regardless of everything else. - 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.
- RLS: OR within an object, AND with tenant-global. Multiple row policies
for the same object/operation OR-combine their
usingon reads and on the rows a write may target. Thecheckon the rows an insert or update writes is chosen per operation first: when any applicable policy declarescheck, only the declared checks decide (OR-combined) and a USING-only policy adds nothing; otherwise eachusingstands 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 thePLATFORM_ADMINposture (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 inOS_PLATFORM_OWNER_EMAIL, matched against the caller's own stored and email-verifiedsys_userrow — is sufficient under every posture. An unscopedadmin_full_accessgrant row is sufficient only undersingle; 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 noPLATFORM_ADMINstanding, so there standing comes from configuration alone. See who holdsadmin_full_accessfor both. A scoped grant, or piecemeal platform capabilities (studio.access,manage_users, …), grant Studio/admin functions but never widen the tenant data boundary. A tenantorganization_adminnever crosses it (invariant I1). - 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.permissionsare registry items stamped with the owning package (_packageId), served by name from the security catalog (ADR-0131 D3). The boot writes nosys_permission_setrow 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_positionrow (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 thepackageId(saveMetaItemmode:'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 firesonMetadataMutation, which runs the audience-binding suggestion reconcile and re-derives theeveryoneanchor'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'spermissionSets. 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: thesys_permission_setobject is no longer registered, so no data route serves one. The save requiresmanage_metadata; under agrouporisolatedtenancy posture it is the platform operator's, and anyone else is refused403(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:permissiondeclaresallowEnvironmentOverlay: 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, orPOST /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 insys_metadata, rides the still-openallowRuntimeCreatetier and stays editable. System / boot writes carryisSystemand 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)
| Stage | What holds today | Owned by |
|---|---|---|
| 1 · Package development | Zod-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 / uninstall | Install-consent scopes (ADR-0025 — consent ≠ RBAC grants); namespaced, collision-free composition; provenance axis makes uninstall well-defined | ADR-0025/0028/0048/0086 |
| 3 · Environment composition / assignment | Platform-owned assignment records (sys_user_position etc.); anti-escalation; union semantics | ADR-0057 D4 |
| 4 · Runtime enforcement | The 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 check | ADR-0056 D10 matrix |
| 5 · Production / enterprise | ADR-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-0069 | ADR-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 → rlsEach 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:
| Column | On sys_user_position | On sys_user_permission_set |
|---|---|---|
reason | Enforced 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_from | Enforced 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_by | Inert — 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 itstab_permissions. As with an expired grant, a deactivated unscopedadmin_full_accessno longer derivesplatform_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 ownsys_userrow, 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_positionandsys_user_permission_setrows 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,
validateSecurityPosturein@objectstack/lint, gatingos compile— and, since the #7891 rollout completed with #8310, the runtime publish door forobject/permission/book/ seed writes, where a gating finding refuses the save with422 INVALID_METADATAand the rule id inissues): unset OWD on custom objects (security-owd-unset— an object publish with no authoredsharingModelis 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-privilegeisDefault(everyone-suggested) sets, the reserved word "role" in security identifiers, acontrolled_by_parentobject without exactly one requiredmaster_detailto derive access from (ADR-0055 §1:security-controlled-by-parent-no-relationwhen it declares none — amaster_detailwithoutrequired: trueand a requiredlookupderive nothing — andsecurity-controlled-by-parent-ambiguous-relationwhen 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 mandatoryreason) — every error rule mirrors a runtime gate. -
Runtime OWD posture gate (#3050,
objectPostureGatein@objectstack/plugin-security, registered on the metadata protocol's pre-persistenceregisterAuthoringGateseam): 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 widenssharingModel, and withOS_METADATA_WRITABLEset 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): withaccess-matrix.jsoncommitted next to the config,os compilefails 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, orremoved. 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
enforcedrows 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 declaredcapabilitymetadata served by the registry (registerBuiltinCapabilities,GET /api/v1/meta/capability), with nosys_capabilityrow. A package now DEFINES its own capabilities explicitly viadefineCapability/stack.capabilities, served by the registry with owning-package provenance (no row is written for it) — replacing the implicit derive-from-systemPermissionsback-door (now retired). The authoring lint (ADR-0066 ⑨) is also landed:validateCapabilityReferences(@objectstack/lint) warns at author time (os validate/os lint) when arequiredPermissionsnames a capability registered nowhere — no built-in, nodefineCapabilitydeclaration, no permission set grants it viasystemPermissions. Asys_capabilityseed row is not counted: no runtime reader consults one. - Per-operation
requiredPermissions(ADR-0066 ⑤) — landed: an object'srequiredPermissionsmay be astring[](gates all CRUD) or a{ read, create, update, delete }map (read-open / write-gated), enforced per operation by plugin-security (security-plugin.tscapability 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) declareaccess: { default: 'private' }— no wildcard grant reaches them; platform admins retain access via the posture-gated superuser bypass.sys_sso_provideris 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
| ADR | Owns |
|---|---|
| 0049 | No unenforced security properties (enforce / mark / remove) |
| 0054 | Prove-it-runs — high-risk classes need runtime proofs |
| 0056 | Permission-model landing: OWD, anonymous deny default, D10 matrix |
| 0057 | Business units, scope depth, declarative RBAC seeding, platform-owned assignment |
| 0066 | Unified model: capability registry, posture, precedence, future refinements |
| 0068 | Built-in identity positions (formerly "identity roles"), EvalUser |
| 0069 | Enterprise authentication hardening (phased) |
| 0078 | No inert declarable metadata |
| 0086 | Metadata↔config boundary, package provenance, cross-package composition |
| 0090 | Permission 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 |
| 0091 | Grant lifecycle: validity windows + resolution-time filtering (L1, landed), delegation, break-glass, recertification substrate |
| 0096 | Execution-surface identity admission — no data-engine call without an explicit principal (the MCP HTTP surface admits identity here) |
| 0101 | MCP stdio principal admission — env-supplied API-key identity, fail-closed, no system bypass |