Authorization Architecture
The one-page map of ObjectStack authorization — the enforcement chain, combination semantics, package provenance, lifecycle coverage, and the CI governance that keeps "declared" equal to "enforced". Stitches ADR-0049/0054/0056/0057/0066/0068/0069/0078/0086 into a single narrative.
Authorization Architecture
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. The whole surface is governed by a CI-checked conformance matrix: a declared-but-unenforced primitive fails the build.
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, the metadata endpoints (/meta), the dispatcher GraphQL endpoint (/graphql), and the raw-hono standard /data routes — one shared decision, so a caller denied on /data can't read the same rows through a sibling door. Default-on (ADR-0056 D2): public serving requires an explicit api.requireAuth: false opt-out, which logs a boot warning. Control plane (/auth, /health, /discovery) is exempt; share-links validate their token then read as SYSTEM. | packages/core/src/security/anonymous-deny.ts shouldDenyAnonymous — called by rest-server.ts enforceAuth, the dispatcher handleGraphQL/handleMetadata/handleAI, and plugin-hono-server denyAnonymous (default in packages/spec/src/api/rest-server.zod.ts); a source-enumerating ratchet in authz-conformance.test.ts fails CI if a new surface ships ungated | 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 are declared but seed-skipped — not enforced), 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 no applicable policy compiles, the result is a deny-all sentinel (fail-closed); 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.
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 wildcard tenant-isolation
policy ANDs on top.
viewAllRecords/modifyAllRecords(super-user bypass, posture-gated) short-circuit RLS for the object. - 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-0094 D5
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. Ordinary admin-door edits of a packaged set are no longer refused: the ADR-0094 write-through translates them into the standard ADR-0005 env-scope overlay — the record projects the effective body while the package keeps owning the row, and "delete" resets to the shipped declaration. System / boot writes carryisSystemand bypass it, so the seeder and materializer are never self-blocked.
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; ~18 primitives enforced and CI-guarded | ADR-0056 D10 matrix |
| 5 · Production / enterprise | ADR-0056 D8 dispositions settled (2026-07): compliance configs, data masking, and the global RLSConfig were removed (never enforced); field encryption stays honestly [EXPERIMENTAL] (roadmap); agent visibility is marked [EXPERIMENTAL] pending #1901. 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) — plus the lifecycle-audit columns
reason, delegated_from, last_certified_at / certified_by.
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.
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. Two authoring lint rules mirror the runtime
behavior: a seed grant whose valid_until is already past (or unparseable)
is dead on arrival (error), and a delegation row (delegated_from) without
reason breaks the dual audit (error).
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.
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): unset OWD on custom objects, retired OWD aliases, an external dial 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, 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 two OWD rules the CLI linter can only check at build time are also 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), andexternalSharingModel ≤ sharingModel(ADR-0090 D11) is rejected at save time (403 owd_external_wider). Write-path only: stored metadata keeps loading unchanged. -
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. A new fail-open or a deleted proof fails CI. -
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_scim_provideris capability-gated likesys_sso_provider. 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 |