ObjectStackObjectStack

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:

  1. Capabilitywhat can be done (manage_users, export_data). Defined by the platform (curated PLATFORM_CAPABILITIES) or by a package via defineCapability; extended by admins in Setup. A capability is not a contract and has no inputs — a resource merely references one by name (see Requirement, below).
  2. Assignmentwho 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 delegated adminScope, never just CRUD on the tables.
  3. Requirementwhat a resource needs — an object / field / action references a capability as a contract. A resource never bakes in "who", only "what is required".

The enforcement chain

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

#GateWhat it decidesEnforcement siteFailure direction
1Anonymous denyNo identity → HTTP 401. Uniform across every HTTP surface that reaches object data (#2567): REST /data, 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 ungatedfail-closed
2Public-form grantAn anonymous form submission carries a declaration-derived publicFormGrant authorizing ONLY create + read-back on the form's declared target object — never anything else (ADR-0056 Option A). No guest-portal configuration needed (anonymous principals hold the guest position).packages/plugins/plugin-security/src/security-plugin.ts (ObjectQL middleware)scope-limited allow
3Object CRUDallowRead/Create/Edit/Delete (+ the destructive lifecycle class allowTransfer/Restore/Purge, gated ahead of the M2 operations — #1883) resolved across the caller's permission sets.packages/plugins/plugin-security/src/permission-evaluator.ts checkObjectPermissionfail-closed 403
4OWD / sharingOrg-wide default (private / public_read / public_read_write / controlled_by_parent; unset or unknown ⇒ private, fail-closed — ADR-0090 D1) 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.tsfail-closed to owner-only
5Row-level securityCEL 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.tsfail-closed
6Field-level securityRead mask (strip non-readable fields) + write deny per fields rules. Caller queries that filter, sort, group, or aggregate by a non-readable field are rejected outright (HTTP 403, field_predicate_denied) — masking only the output would leave row presence as a value oracle. RLS-injected predicates are exempt (they run after the guard and may reference hidden fields like owner_id).packages/plugins/plugin-security/src/field-masker.ts + predicate-guard.tsfail-closed on predicates; see posture note below

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

Combination semantics (the fixed order)

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

  1. AND-gates first (hard prerequisites). A resource's requiredPermissions and a private object posture must ALL clear before any grant is consulted. Missing one denies regardless of everything else.
  2. Grants union (most-permissive). Object CRUD and field grants combine across all of the caller's permission sets — any set that allows, wins. Keys never collide across packages because object api names are package-namespaced.
  3. RLS: OR within an object, AND with tenant-global. Multiple row policies for the same object/operation OR-combine; the wildcard tenant-isolation policy ANDs on top. viewAllRecords / modifyAllRecords (super-user bypass, posture-gated) short-circuit RLS for the object.
  4. Explicit deny — reserved. There is no deny layer yet; the only implicit denies are the AND-gates in (1) and fail-closed defaults. Permission-set groups + subtractive muting (Salesforce-style) are the planned step 4 (ADR-0066 ⑦) — when they land they must cover field grants too (ADR-0066 ⑧).

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

Package provenance & composition (ADR-0086)

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

  • A package ships its own permission sets (Shape B), recorded with managedBy: 'package' + owning packageId on sys_permission_set (package_id / managed_by columns). bootstrapDeclaredPermissions (packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts) seeds stack.permissions at 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 the packageId (saveMetaItem mode:'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 into sys_permission_set as managedBy:'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 (managedBy platform/user) and assignments — not package sets. Since ADR-0094 the set definition itself has one authoritative store — the metadata layer: an env-door write to sys_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 a modifyAllRecords super-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 carry isSystem and bypass it, so the seeder and materializer are never self-blocked.

Lifecycle coverage (five stages)

StageWhat holds todayOwned by
1 · Package developmentZod-validated authoring; positions / sharingRules / permissions seeded at boot with provenanceADR-0057 D6, ADR-0086 D5, ADR-0049/0078 gates
2 · Distribution / install / upgrade / uninstallInstall-consent scopes (ADR-0025 — consent ≠ RBAC grants); namespaced, collision-free composition; provenance axis makes uninstall well-definedADR-0025/0028/0048/0086
3 · Environment composition / assignmentPlatform-owned assignment records (sys_user_position etc.); anti-escalation; union semanticsADR-0057 D4
4 · Runtime enforcementThe six-gate chain above; ~18 primitives enforced and CI-guardedADR-0056 D10 matrix
5 · Production / enterpriseADR-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-0069ADR-0049/0056 D8, ADR-0069

Explaining a decision (ADR-0090 D6)

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

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

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

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

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

Grant lifecycle: validity windows (ADR-0091 L1)

Every user-grant row (sys_user_position, sys_user_permission_set) carries optional effective-dating columnsvalid_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, validateSecurityPosture in @objectstack/lint, gating os 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-privilege isDefault (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 mandatory reason) — every error rule mirrors a runtime gate.

  • Runtime OWD posture gate (#3050, objectPostureGate in @objectstack/plugin-security, registered on the metadata protocol's pre-persistence registerAuthoringGate seam): the 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 tighten sharingModel / externalSharingModel, never widen them beyond the packaged declaration (403 owd_widening_forbidden — widen it in the package source and publish instead; this closes the OS_METADATA_WRITABLE=object escape hatch as an unvalidated widening path, ADR-0086 D1), and externalSharingModel ≤ 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): with access-matrix.json committed next to the config, os compile fails on any capability drift with semantic lines ('crm_admin' gains delete on 'crm_lead') until the snapshot is updated via --update-access-matrix — the snapshot's git diff is the review artifact. Opt-in, format, and workflow: Access-Matrix Snapshot Gate.

  • Conformance matrix (packages/qa/dogfood/test/authz-conformance.matrix.ts, ADR-0056 D10): every authorization primitive sits in exactly one honest state — enforced (must name its enforcement site; high-risk rows must reference an end-to-end dogfood proof), experimental, or removed. 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_capability records from the canonical list in @objectstack/spec (security/capabilities.tsPLATFORM_CAPABILITIES), seeded by packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts. A package now DEFINES its own capabilities explicitly via defineCapability / stack.capabilities, seeded with managed_by:'package' + package_id provenance by bootstrap-declared-capabilities.ts — replacing the implicit derive-from-systemPermissions back-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 a requiredPermissions names a capability registered nowhere — no built-in, no permission set grants it via systemPermissions, no sys_capability seed.
  • Per-operation requiredPermissions (ADR-0066 ⑤) — landed: an object's requiredPermissions may be a string[] (gates all CRUD) or a { read, create, update, delete } map (read-open / write-gated), enforced per operation by plugin-security (security-plugin.ts capability AND-gate).
  • Secure-by-default rollout (ADR-0066 ④) — system-object slice landed: the raw secret/credential stores (sys_secret, sys_jwks, sys_verification, sys_oauth_access_token, sys_oauth_refresh_token, sys_device_code) declare access: { default: 'private' } — no wildcard grant reaches them; platform admins retain access via the posture-gated superuser bypass. sys_scim_provider is capability-gated like sys_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

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

On this page