Authorization Architecture
The one-page map of ObjectStack authorization — the enforcement chain, combination semantics, package provenance, lifecycle coverage, and the CI governance — with its limits — behind "declared" equals "enforced". Stitches ADR-0049/0054/0056/0057/0066/0068/0069/0078/0086 into a single narrative.
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 / positions / user
bindings (
sys_permission_set,sys_position,sys_user_permission_set,sys_position_permission_set,sys_user_position). 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) plus the external dial (externalSharingModel, ADR-0090 D11), 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; 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 from two anchors, either of which is sufficient: the deployment's configured administrator list — an address declared inOS_PLATFORM_OWNER_EMAIL, matched against the caller's own stored and email-verifiedsys_userrow — and an unscopedadmin_full_accessgrant row. 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), recorded with
managedBy: 'package'+ owningpackageIdonsys_permission_set(package_id/managed_bycolumns).bootstrapDeclaredPermissions(packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts) seedsstack.permissionsat boot — idempotent, re-seeded on upgrade, and it never clobbers env-authored (platform/user/legacy) rows. A package never writes into a foreign record. - 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 positions and sharing rules seed the same way
(
bootstrapDeclaredPositions, ADR-0057 D6) — a declarable-but-never-seeded array is exactly the inert-metadata smell ADR-0078 prohibits.
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' },
capabilities: [ExportDataCapability], // ← DEFINE
permissions: [{ name: 'billing_admin', systemPermissions: ['export_data'] }], // ← GRANT
// a resource REQUIRES it: requiredPermissions: ['export_data']
});At boot bootstrapDeclaredCapabilities
(packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts) seeds
each declaration into sys_capability with managed_by: 'package' + package_id
provenance — idempotent, re-seeded on upgrade, and it never clobbers
admin-authored rows, refuses to hijack a curated platform capability, and refuses
to write into another package's capability.
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 still runs for back-compat (a
reference with no declaration keeps resolving as a managed_by:'platform'
placeholder), but an explicit defineCapability takes precedence and — for a
pre-existing derived placeholder — claims it, upgrading the row to package
provenance with the authored label/description/scope. 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)
The managedBy provenance axis is not just descriptive — the platform
populates and 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, a registered materializer (registerPublishMaterializer) projects the published body intosys_permission_setasmanagedBy:'package'+packageId, reusing the same upsert as the boot seeder (upsertPackagePermissionSet). 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,sys_position_permission_set), edited live (config). It owns env-authored sets (managedByplatform/user) and assignments — not package sets. Since ADR-0094 the set definition itself has one authoritative store — the metadata layer: an env-door write tosys_permission_set(the Setup CRUD) is transparently redirected into an env-scope metadata save (saveMetaItem), and the data record is a pure projection the platform re-derives on every metadata mutation (awaited — no staleness window) and at boot. Deleting an artifact-backed set through this door resets it to its declared body rather than removing it; renaming through the data door is rejected (the name is the metadata identity — clone instead). Subject assignments remain plain config rows, unchanged. - Data-layer gate (evolved by ADR-0094) — the security middleware still
refuses any payload that forges package provenance (insert or update,
single or array) and the lifecycle ops with no overlay translation
(
transfer/restore/purge) on package rows, failing closed ahead of the CRUD check — even amodifyAllRecordssuper-user is blocked. An ordinary admin-door edit of a set whose definition ships as a code artifact is refused with403 not_overridable, loudly, at the moment of the write: the ADR-0094 write-through still translates the edit into a metadata save, butpermissiondeclaresallowOrgOverride: false(ADR-0005's security row — overlays of the authorization surface would create silent privilege drift), so the tier gate refuses it and no env-scope overlay is minted. The supported channel is the one 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 through the data door, whose definition lives only insys_metadata, rides the still-openallowRuntimeCreatetier and stays editable. A "delete" of a packaged set through this door still degrades to a reset to the shipped declaration — the admin door can never remove a packaged definition. System / boot writes carryisSystemand bypass it, so the seeder and materializer 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; positions / sharingRules / permissions seeded at boot with provenance | ADR-0057 D6, ADR-0086 D5, 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 dates the catalogue row itself: sys_permission_set.active and
sys_position.active, the switch behind the Deactivate action on both objects.
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.
- 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 catalogue row, so the flag 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 re-activating the catalogue row 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
catalogue row somebody switched off. 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 stored value that really reads false takes a grant
away, so a row that predates the column keeps granting. The same predicate
(isRowActive) is used by every reader, including the last-administrator
guard's simulation — a guard that modelled "deactivated" differently from the
resolver would permit exactly the write it exists to refuse.
Deactivating the break-glass set is refused. Switching admin_full_access
off un-makes every platform admin who holds it through a grant row, in one
write — and re-activating it needs the permission just lost. That write is
judged like deleting or renaming the row (ADR-0024 D5.2): it is refused while
it would leave the environment with no administrator who can sign in. The
enumeration behind that judgement counts both anchors, so an environment
whose administrators are configured rather than granted is not empty, and the
write is permitted there. Re-activation 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 keep reading every row, deactivated included: dropping rows there 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, an external dial wider than internal (security-external-wider-than-internal),'*'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 with no relation the platform can derive access from (ADR-0055: no requiredmaster_detail, nomaster_detailat all, and no requiredlookup— so 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 may only tightensharingModel/externalSharingModel, never widen them beyond the packaged declaration (403 owd_widening_forbidden— widen it in the package source and publish instead; this closes theOS_METADATA_WRITABLE=objectescape hatch as an unvalidated widening path, ADR-0086 D1). 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 external-wider and for unauthored-OWD bodies, and the 403 gate remains for packaged-baseline widening only. -
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) — landed: capabilities are seeded
as first-class
sys_capabilityrecords from the canonical list in@objectstack/spec(security/capabilities.ts→PLATFORM_CAPABILITIES), seeded bypackages/plugins/plugin-security/src/bootstrap-system-capabilities.ts. A package now DEFINES its own capabilities explicitly viadefineCapability/stack.capabilities, seeded withmanaged_by:'package'+package_idprovenance bybootstrap-declared-capabilities.ts— replacing the implicit derive-from-systemPermissionsback-door (which stays for back-compat). 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, no permission set grants it viasystemPermissions, nosys_capabilityseed. - 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 |