Skip to content

Latest commit

 

History

History
376 lines (323 loc) · 24.8 KB

File metadata and controls

376 lines (323 loc) · 24.8 KB
title Authorization Architecture
description 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.

# Gate What it decides Enforcement site Failure direction
1 Anonymous deny No identity → HTTP 401 on /data/*. Default-on (ADR-0056 D2): public data 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/rest/src/rest-server.ts enforceAuth (default in packages/spec/src/api/rest-server.zod.ts) 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:

  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)

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 → 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

Four CI-time mechanisms 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.

  • 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/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

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