Skip to content

feat(spec,security): OrgScopingEntitlement grows platform-global exemption + unbounded-admin suppression, consumed by Layer 0 arming - #12704

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-12699-org-scoping-entitlement
Aug 27, 2026
Merged

feat(spec,security): OrgScopingEntitlement grows platform-global exemption + unbounded-admin suppression, consumed by Layer 0 arming#12704
hotlong merged 3 commits into
mainfrom
claude/issue-12699-org-scoping-entitlement

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#12699
Part of objectstack-ai/cloud#1653

What

OrgScopingEntitlement (packages/spec/src/security/tenancy-posture.ts) grows two per-deployment wall-shaping keys, both declared by the mounted org-scoping service (per-deployment, never authorable app metadata), both consumed by plugin-security when arming the Layer 0 organization wall, both fail closed — an absent (or junk) declaration leaves behaviour byte-identical to today:

  1. platformGlobalObjects — objects THIS deployment declares platform-global. Layer 0 does not wall them here, exactly as if the object had declared tenancy: { enabled: false } itself — but only on this deployment. It composes with, never replaces, the per-object authoring channel, which stays wrong for these objects because they DO wall on tenant runtimes (same object, different deployments, different wall).
  2. suppressUnboundedOrgAdminGrant — arming a walled posture must not auto-grant organization_admin's unbounded viewAllRecords/modifyAllRecords superbits: the membership-driven auto-grant hands out organization_admin_no_bypass instead (the epic's measured regression: sys_secret 403-to-200, 17 objects regaining cross-tenant writes, cloud#1257's write gate voided by the posture flip).

Dead seams stay dead, per the ruling: host self-declaration of the boundary is not revived (the org-create gate is untouched — only a mounted enterprise runtime can declare anything here), and nothing rides through objectExtensions (the merge silently drops tenancy — objectstack#12680).

Consumer contract — for cloud Phase 2 (objectstack-ai/cloud#1664)

Key names and TypeScript shapes (verbatim)

exportinterfaceOrgScopingEntitlement{readonlysupportedPostures?: readonlyTenancyPosture[];// pre-existing (ADR-0105 D12)readonlyplatformGlobalObjects?: readonlystring[];// NEWreadonlysuppressUnboundedOrgAdminGrant?: boolean;// NEW}
  • platformGlobalObjects entries are exact object machine names (regex ^[a-z_][a-z0-9_]*$) — no wildcards, no globs, no empty strings. One junk entry voids the whole key (refused loudly, never partially honoured), and refusal resolves to "absent" = everything walls.
  • Runtime twins exported from @objectstack/spec/security: OrgScopingEntitlementSchema (non-strict object — a live service instance's extra machinery never disqualifies its declaration) and PlatformGlobalObjectsSchema (+ type alias PlatformGlobalObjects). Consumers validate per key: junk in one key never voids the other's valid declaration.

Declaration-site pattern

The keys are readonly properties on the object registered as the org-scoping service — the same object that already declares supportedPostures. The in-repo model is SimulatedOrgScopingPlugin in packages/verify/src/harness.ts; the cloud runtime declares the same way:

classControlPlaneOrgScopingPlugin{readonlyprovidesServices=['org-scoping'];readonlysupportedPostures=['isolated']asconst;readonlyplatformGlobalObjects=['sys_setting','sys_job'/* , the bucket-(b) list */]asconst;readonlysuppressUnboundedOrgAdminGrant=true;asyncinit(ctx){ctx.registerService('org-scoping',this);}}

Read-timing semantics

  • Posture is unchanged: SecurityPlugin captures it once at start().
  • The entitlement is read LIVE via getService('org-scoping') at each consuming decision — mirroring the seam's one pre-existing consumer, plugin-auth's probeEntitledPostures (auth-plugin.ts:581, live per call because the provider registers after the reader's init). Validation is memoized per service instance (WeakMap); a junk key warns once per boot, naming the key and the declared value, and fails closed.
  • The exemption folds into the per-object security meta (getObjectSecurityMeta), which is cached per object and invalidated on metadata change or restart. Effective contract for the declarer: declare at registration and treat the declaration as immutable for the kernel's life; a re-registered service instance re-validates. Live resolution is ordering-robust by construction: a deployment where the wall is ARMED had org-scoping registered before start() captured the posture, so the declaration is present from the first read; where it registered too late, posture resolves single and Layer 0 is inert, so the exemption decides nothing.
  • Suppression is read live at each sys_member reconcile and at the kernel:ready backfill ("org-admin grant backfill complete" in boot logs). Toggling it converges standing grants in BOTH directions via the existing superseded-variant reconcile — proven in tests, so the fail-closed default protects any deployment that RELIES on today's auto-grant.
  • A walled boot logs the declaration at arming time: the carve-out count + sorted names, and the suppression line — grep [security/#12699].

What the exemption reaches — and deliberately does not

Reaches, through the single meta fold (all proven or structurally derived in this PR):

  • the Layer 0 read wall (computeTenantLayer0Filter's tenancyDisabled input);
  • the ADR-0123 D2 write refusal and the forge guard — computeWriteTenantCheckFilter IS the same layer0, so read and write cannot disagree;
  • the Layer 1 wildcard organization_id policy drop (the sys_package cross-org-catalog precedent — without it every read of an exempted catalog would return zero rows);
  • posturePermitsCrossTenant and the superuser write-check bypass — an exempted object carries full platform-global posture semantics on this deployment.

Deliberately NOT reached — boundaries Phase 2 must know:

  • Engine/driver native scope: buildDriverOptions (objectql engine.ts) still threads tenantId for exempted objects (it reads schema-declared tenancy only), and SqlDriver's per-table scope is fail-open on NULL org (organization_id equals the active org OR IS NULL) — so NULL-org platform rows stay readable and behaviour is byte-identical to today. Session-context inserts still stamp the active org at the driver, also today's behaviour. Widening this would change behaviour beyond the ruling; if Phase 2 measures a need, that is a new card.
  • objectql system-write refusal (system-write-organization.ts) keys on schema-declared tenancy, and the sys_ / cloud_ / ai_ namespaces are excluded by construction — declare exempted platform tables in those namespaces (every control-plane table already is).
  • postureStampsOrganization has no consumer in this repo; the D5 stamper is the enterprise runtime — the declarer itself, which holds the exemption list first-hand.

Dispatch mechanism assumptions — verified

  1. supportedPostures consumer found. The dispatch premise "no consumer inside plugin-security" is confirmed — and the seam is NOT dormant: plugin-auth consumes it (auth-plugin.ts:581 feeding createTenancyService.probeEntitledPostures, evaluated live inside isolationActive()). SecurityPlugin reads the entitlement via getService('org-scoping') directly, mirroring that pattern rather than widening TenancyService (which stays posture-only — routing deployment declarations through it would grow a second interface for zero consumer benefit).
  2. Single choke point confirmed. The two predicate sites the card names (now at security-plugin.ts ~6383 getObjectSecurityMeta and ~6677 loadObjectFieldNames) merge into one local in computeLayeredRlsFilter; the write path derives from the same layer0. The fold lands in getObjectSecurityMeta alone and every consumer follows. Genuinely separate wall-adjacent consumers (driver native scope, system-write refusal, D5 stamper) are enumerated above with the argument for leaving each untouched — none produces the "exempt from read wall but still refused/required to stamp" inconsistency: the only refusal seam (ADR-0123 D2) derives from the exempted layer0, and driver stamping never refuses.
  3. Superbit grant located and suppressed without orphaning. The grant is orgAdminSetNameForPosture (auto-org-admin-grant.ts), wired from SecurityPlugin's sys_member middleware and the kernel:ready backfill (the "org-admin grant backfill" boot log). Suppression flips BOTH the granted and the superseded variant, so the existing convergence machinery revokes the stale one on the next reconcile — and the fail-closed default (absent = today's grant) is pinned by the untouched ADR-0105 D4 test block plus an explicit suppressUnboundedOrgAdminGrant: false case.

Tests

New/extended, all green at b49ebc290:

  • packages/plugins/plugin-security/src/deployment-platform-global-exemption.test.ts (12 cases): exempted object not walled (isolated + group, read AND the D2 write derivation) / non-exempted sibling still walled / absent declaration byte-identical (regression pin) / junk shapes refused loudly (bare string, wildcard entry poisoning the whole key, junk suppress flag not voiding the valid key, warn-once-per-boot) / tenancy.enabled:false composition / single posture inertness / arming-log surface.
  • packages/plugins/plugin-security/src/auto-org-admin-grant.test.ts (+6 cases): suppression on (isolated + group) / explicit off byte-identical / convergence both directions / backfill threading incl. the orphan sweep.
  • packages/spec/src/security/tenancy-posture.test.ts (19 cases): schema accepts valid declarations and live-service shapes, refuses junk; schema/interface parity witness.

Suites: plugin-security full suite 87 files / 1578 tests passed; spec security domain 7 files / 197 tests passed; pnpm --filter @objectstack/spec typecheck and pnpm --filter @objectstack/plugin-security typecheck green. plugin-security's test layer sits outside every tsc program (pre-existing: tsconfig.json excludes test files and the package has no test-typecheck sibling), so the new test files' types are NOT MEASURED by tsc — same posture as every existing test there; check:type-check-coverage and check:type-check-debt --re-measure both green.

Ablation, one leg per key (vitest runs on src/ via relative imports — no dist resolution in the loop; mutation and restore both proven on disk by anchor grep -c, restore additionally by an empty git diff HEAD):

  • Key 1: removed the meta fold (platformGlobalObjects.has(object) anchor 1 to 0 on disk) — 4 of 12 exemption cases went red (both read postures, the write-choke case, the valid-exemption-standing case); restored (anchor back to 1, clean diff), 12/12 green.
  • Key 2: removed the suppression veto (postureEnforcesWall(posture) && !suppressUnbounded anchor 1 to 0 on disk) — exactly the 4 suppression cases went red; restored (anchor 1, clean diff), 32/32 green.

Gates

Derived from the real changeset via node scripts/pm/dispatch-gates.mjs (answer taken from this worktree at 1e61a0de5, re-checked at head b49ebc290). All derived families ran locally and green, including: check:generated ("All 14 generated artifacts are up to date"), check:i18n ("OK (9 package(s)…)" after building the CLI), check:type-check-debt --re-measure ("none above its recorded number"), check:engine-double-contract, check:where-matcher, check:cross-package-test-inputs, check:test-source-alias, check-changeset-no-major, check:nul-bytes, and the docs family (the reference-doc regeneration in this diff is generator output). scripts/pm/check-half-states.mjs ran authenticated and is report-only. The two docs-drift-check.yml scripts are PR-context CI jobs and run on this PR. Changeset-family gates re-ran on the final head b49ebc290.

Verification mode declaration: this ran on a macOS host where scripts/pm/os-verify-lock.sh has no usable flock — every build/test above went through the entry point in its DECLARED UNLOCKED MODE (its own VERDICT lines record UNLOCKED (declared)); nothing was serialized against sibling agents.

Generated-artifact churn in this diff (api-surface/security.json, export-origins/security.json, authorable-surface/security.json, json-schema.manifest/security.json, content/docs/references/index.mdx, content/docs/references/security/misc.mdx) is generator output for the three new spec exports, proven stale-then-regenerated by check:generated --fix (never the whole set).

Session: https://claude.ai/code/session_71836b57-5db6-459d-ae4d-c6d9d7dd2cc6

Generated by Claude Code

os-zhuangand others added 3 commits August 27, 2026 19:04
…+ unbounded-admin suppression
Part of objectstack-ai/cloud#1653 (Phase 1).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cts type alias
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-security, @objectstack/spec, touching 23 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/spec/api-surface/security.json, packages/spec/authorable-surface/security.json, packages/spec/export-origins/security.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/tenancy-modes.mdx(via ABSENT (symbol), supportedPostures (symbol))
  • content/docs/permissions/field-level-security.mdx(via SecurityPlugin (symbol))
  • content/docs/permissions/index.mdx(via SecurityPlugin (symbol))
  • content/docs/plugins/packages.mdx(via SecurityPlugin (symbol))
  • content/docs/ui/forms.mdx(via SecurityPlugin (symbol))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via SecurityPlugin (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/spec/api-surface/security.json, packages/spec/authorable-surface/security.json, packages/spec/export-origins/security.json, …) — pages documenting those are invisible to this run
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 129 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 15bf9e859e56862e6ebe7b5c42404de103362457packageMentionDocs.

Which tree this was computed on

This run read content/docs from f262c14437f5343972b5672e345b652f1824accb — the merge of head b49ebc290c0161fe48e0a7940603620af85a70ad into base 15bf9e859e56862e6ebe7b5c42404de103362457, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f262c14437f5343972b5672e345b652f1824accb && git checkout f262c14437f5343972b5672e345b652f1824accb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 15bf9e859e56862e6ebe7b5c42404de103362457 b49ebc290c0161fe48e0a7940603620af85a70ad && git checkout -B drift-repro 15bf9e859e56862e6ebe7b5c42404de103362457 && git merge --no-ff b49ebc290c0161fe48e0a7940603620af85a70ad
node scripts/docs-audit/affected-docs.mjs --json 15bf9e859e56862e6ebe7b5c42404de103362457

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 15bf9e859e56862e6ebe7b5c42404de103362457 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-zhuang

Copy link
Copy Markdown
ContributorAuthor

Enqueue attribution note: the merge-queue enqueue mutation for this PR was fired with the hotlong account's token (surgical per-command GH_TOKEN injection; the machine's active gh account stays os-zhuang), on the maintainer's approval, because the shared os-zhuang GraphQL pool was under chronic contention — every review, claim and verdict on this card remains os-zhuang. Queue state verified directly: position 1, AWAITING_CHECKS.

Merged via the queue into main with commit a65db76Aug 27, 2026
41 checks passed
@hotlong
hotlong deleted the claude/issue-12699-org-scoping-entitlement branch August 27, 2026 14:43
os-sales pushed a commit that referenced this pull request Aug 27, 2026
os-regen-merge four-step: merge commit first (deferral recorded), then the
deliberate manifest + surface-baseline deletions re-applied on the merged
tree and the whole chain regenerated with repo tooling. Both sides' sources
verified present (retirement tombstone + registry entries; #12704
OrgScopingEntitlement tenancy-posture + capabilities).
Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(spec,security): OrgScopingEntitlement grows platform-global exemption + unbounded-admin suppression, consumed by Layer 0 arming

2 participants

@os-zhuang@hotlong