feat(authz)!: group tenancy posture + org scope as a first-class dimension — ADR-0105 Phase 0/1#3559
Conversation
…nsion — ADR-0105 Phase 0/1 (#3541) Implements ADR-0105 Phase 0 (correctness) and Phase 1 (group posture MVP). Kernel: - D1: tenancy becomes a spectrum — `single | group | isolated`, selected by `OS_TENANCY_POSTURE` and resolved by the `tenancy` service. Unset derives from `OS_MULTI_ORG_ENABLED`, so existing deployments are unchanged; an unrecognized value throws at boot rather than silently dropping the wall. - D2: `ExecutionContext.accessible_org_ids` — the caller's org access set, resolved once by `resolveAuthzContext` under ADR-0091 validity windows and carried by every transport. The `group` Layer 0 wall is `organization_id IN accessible_org_ids` (MOAC union); empty/absent fails closed. Only the predicate widens — Layer 0 stays independent, AND-composed first, and crossable only by a true PLATFORM_ADMIN, so ADR-0095 W1/W2 hold in every posture. - D5: the engine now stamps `organization_id` on insert under any walled posture and validates every supplied value — including BULK array payloads, which the pre-existing post-image check skipped entirely (#2937 one call site down). P0 correctness (fixes #3539, #3540): - D3: `collectRLSPolicies` strips by PROVENANCE, not by substring-matching `current_user.organization_id`. App-authored policies are retained, warned about once, and fail closed instead of vanishing (F1 / ADR-0049 class). `getReadFilter` shared the defect, so analytics reads were unscoped. - D4: `viewAllRecords`/`modifyAllRecords` never cross an org boundary. Wall-less postures get a de-VAMA'd `organization_admin_no_bypass` variant — DERIVED from `organization_admin`, never a second literal — and the superseded variant is revoked whenever the posture changes (F2 / personal-org amplification). Model + product: - D6: `sys_organization.parent_organization_id` / `sort_order` as a reporting dimension, with both red lines lint-enforced (`validateOrgAxisRedLines`, wired into os lint/compile/validate): no permission inheritance along the org axis, and no business-unit grants on platform-global objects. - D7: extension fields on better-auth-managed objects ride the ADR-0092 whitelist; a new guard derives better-auth's real field surface from `getAuthTables()` at the pinned version and fails on any name collision. Enforce-or-remove (BREAKING, no runtime behavior change — all three had zero consumers): - `PermissionSet.contextVariables` removed; `security/territory.zod.ts` removed. - `ExecutionContext.rlsMembership` productized: register an `IRlsMembershipResolver` under `rls-membership-resolver`. Kernel-owned keys are reserved; an unresolved key makes its policies drop out (fail closed). Edition boundary (D12): the `group` wall and its write-side twin ship OPEN — the correctness of a wall is never paid (cloud ADR-0016). `isolated` keeps its enterprise `org-scoping` probe, so the commercial boundary for legal-entity isolation is unchanged by this PR. Adds 5 ADR-0105 rows to the dogfood authz conformance ledger. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 23 package(s): 135 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…major` Every publishable package is in the Changesets `fixed` (lockstep) group, so a single `major` promotes the ENTIRE monorepo to a new major version. The repo ships breaking changes as `minor` during the launch window (enforced by scripts/check-changeset-no-major.mjs); #3494's spec prune set the precedent. The FROM → TO migration notes are unchanged — they are what reaches consumers in CHANGELOG.md, and they carry the breaking-ness regardless of the bump level. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL
… D12 correction (#3570) Follow-up to #3559, which read ADR-0105 D12 as "the `group` wall ships open" and made the posture self-activating — it never probed for @objectstack/organizations. That turned `group` into a free multi-org path around the `isolated` gate (ADR-0081 D2), leaving the WEAKER isolation as the free one, and it opened a silent-degradation hole: `os serve` still gated the enterprise package load on OS_MULTI_ORG_ENABLED, so OS_TENANCY_POSTURE=group skipped both the load and the ADR-0093 D5 fail-fast. The distinction missed: open code is not free activation. The wall's implementation has always lived in open packages — equally true of `isolated`, whose Layer 0 wall sits in plugin-security gated on a service the enterprise package registers. Cloud ADR-0016's 铁律 guarantees a deployment RUNNING a multi-org shape is safe; that is satisfied by REFUSING to run one unwalled, not by giving the posture away. - tenancy-service: `group` probes `org-scoping` exactly like `isolated`; without it the posture resolves to `single` + `degraded`. - serve.ts: the ADR-0093 D5 boot guard keys off the resolved POSTURE, with posture-aware messaging. A `group` request without the runtime now refuses to boot unless OS_ALLOW_DEGRADED_TENANCY=1. - New seam: `org-scoping` may declare `supportedPostures` (`OrgScopingEntitlement`); the open side honours it and fails closed on anything unlisted. Whether `group` and `isolated` are one commercial tier or two is packaging policy, which belongs to the commercial runtime rather than hard-coded in open core. Omitting it entitles every walled posture. - `organization_id` STAMPING returns to the enterprise runtime. #3559 moved it into the open engine, removing the closed package's only load-bearing runtime duty — a five-line forged `org-scoping` registration would then have produced a fully working multi-org deployment. Back where it was, a forged registration yields NULL-org rows the wall hides. Write-side VALIDATION stays open and unchanged, bulk-insert coverage included: rejecting a forged organization_id is a security property, not a packaging one. - Default-org bootstrap returns to `single`-only (ADR-0081 D1). - Documents OS_TENANCY_POSTURE in the environment-variables reference. Operator note: OS_TENANCY_POSTURE=group without the enterprise runtime now refuses to boot rather than silently running single-org.
… ADR-0105 D1 (#3596) Since #3559 a walled posture is an explicit operator request resolved from env when AuthPlugin registers the `tenancy` service; mounting the enterprise organizations plugin only ENTITLES it. bootStack's multiTenant opt-in predates that split and only mounted the plugin, so multi-org fixtures silently booted `single` — no Layer 0 wall, default-org write stamping — and every cross-tenant proof asserted against the wrong posture. First surfaced by cloud's security-enterprise multi-org integration test (the licensed path open-core CI cannot run), which fails 3/4 against any framework checkout at or past #3559. bootStack({multiTenant: true}) now sets OS_TENANCY_POSTURE=isolated for the boot — before AuthPlugin snapshots the requested posture — unless the caller already provided one; the request is restored on stop() and on a failed multi-tenant boot, so later single-tenant boots in the same worker are unaffected. The verify package also gains a `test` script: `turbo run test` never ran its suite before, so the new posture regression pin (and the existing derive tests) now actually gate. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…led, not open (#3570) (#3603) Founder ruling: OS_TENANCY_POSTURE=group is not an open-edition feature. The code was already corrected in #3570 (group probes org-scoping exactly like isolated; no runtime => single + degraded; an os serve boot configured for group fail-fasts). This brings the ADR text in line: - D12 rewritten to the code-vs-activation split: the wall's implementation ships open, posture activation is entitled for BOTH multi-org postures; iron-rule safety is satisfied by refusing to run an unwalled group boot, not by free activation. ADR-0081 D2's commercial line stands. - Amendment block records the original text, why it was wrong (inverted ADR-0081 D2 + the silent-degradation hole), and the #3559 -> #3570 trail. - Consequences clause updated to match; header Status moves to Accepted with the amendment note; Tracking points at #3541 / #3539 / #3540 / cloud #874. Claude-Session: https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL Co-authored-by: Claude <noreply@anthropic.com>
Implements ADR-0105 Phase 0 (correctness) and Phase 1 (group posture MVP).
Tracking: #3541 · Closes #3539 · Closes #3540 · Cloud counterpart: objectstack-ai/cloud#874
Kernel
D1 — tenancy is a spectrum:
single | group | isolated. Selected by the newOS_TENANCY_POSTURE, resolved by thetenancyservice. Unset derives fromOS_MULTI_ORG_ENABLED(true⇒isolated, elsesingle), so every existing deployment keeps its posture with no config change. An unrecognized value throws at boot rather than silently landing in a posture with no organization wall.D2 —
accessible_org_idsis a first-class context field. Every organization the caller holds a currently-valid membership in (ADR-0091 windows), resolved once byresolveAuthzContextand carried by every transport (REST, runtime, MCP, hono). ThegroupLayer 0 wall isorganization_id IN accessible_org_ids; empty or absent fails closed.Only the predicate widens. Layer 0 is still computed independently of the RLS compiler, still AND-composed outermost, and still crossable only by a true
PLATFORM_ADMINon a posture-permitting object — so ADR-0095's W1/W2 hold in every posture.tenant-layer.test.tsasserts the exemption rules identically forgroupandisolated.D5 — engine-owned stamping + write validation. Under any walled posture the engine stamps
organization_idfrom the caller's active org on an insert that omits it, and validates every supplied value. Idempotent with the enterprise auto-stamp. This also closes a real hole: the pre-existing post-image check required a non-array payload, so a bulk insert could carry a forgedorganization_idper row — the #2937 defect one call site down. One forged row now denies the whole write. No tenant is ever invented: a caller with no active org leaves the value absent.P0 correctness — the two behavior changes to review closely
D3 (#3539) — authored RLS policies are no longer silently dropped.
collectRLSPoliciesstrips by provenance (identity against the shipped declaration), not by substring-matchingcurrent_user.organization_id.D4 (#3540) — VAMA never crosses an organization boundary. Wall-less postures get a de-VAMA'd
organization_admin_no_bypass, derived fromorganization_admin(never a second literal, so the two cannot drift), and the superseded variant is revoked whenever the posture changes.Model + product
sys_organization.parent_organization_id/sort_orderas a reporting dimension only, with both red lines lint-enforced by the newvalidateOrgAxisRedLines(wired intoos lint/os compile/os validate): no permission inheritance along the org axis, and no business-unit grants on platform-global objects.getAuthTables()at the pinned version and fails the build on any name collision, so a library upgrade cannot silently take ownership of a column.Enforce-or-remove (D11) — BREAKING
All three had zero runtime consumers, so nothing changes at runtime.
PermissionSet.contextVariablesTerritory/TerritoryModel/TerritoryTypeExecutionContext.rlsMembershipIRlsMembershipResolverunderrls-membership-resolverThe resolver seam is fail-closed by construction (an unresolved key makes its policies drop out) and kernel-owned keys (
accessible_org_ids,org_user_ids, …) are reserved — an app cannot redefine the wall's own vocabulary.The
groupposture's enforcement ships open — the union wall,accessible_org_ids, D5 stamping/validation, the D3/D4 fixes and the D6 lints — because the correctness of a wall is never a paid feature (cloud ADR-0016 铁律「强制免费、治理收费」).To keep that decision as small and separable as possible,
isolatedkeeps its existing enterpriseorg-scopingprobe, so the current commercial boundary for legal-entity isolation is unchanged by this PR. The consequence to sign off on: an open build set toOS_TENANCY_POSTURE=groupgets a working, engine-enforced multi-org wall. That is the intended concession, but it re-draws the ADR-0081 D2 line and should be confirmed before merge.Verification
pnpm build— 71/71 taskspnpm test— 131/131 tasks (plugin-security 580 tests, plugin-auth 480, spec 6815)pnpm lint+ all five repo grep guards (nul-bytes,doc-authoring,role-word,org-identifier,authz-resolver) — cleancheck:api-surface,check:docs,check:spec-changes,check:upgrade-guide,check:liveness,check:skill-refs— all regenerated and in syncNew/updated coverage:
tenant-layer.test.ts(posture spectrum + W1/W2 across postures),platform-tenant-policies.test.ts(D3 provenance, both directions),rls-membership-resolver.test.ts(the seam has a producer; every failure mode narrows),managed-extension-fields.test.ts(collision guard vs. the live better-auth schema),validate-org-axis-red-lines.test.ts, plus posture cases inauto-org-admin-grant.test.tsand bulk-insert cases insecurity-plugin.test.ts.Two full-suite runs each hit one different unrelated failure (
cloud-connectionmarketplace seed,clidist/utils/storage-driver.test.js); both pass in isolation and neither touches this diff — parallel-run flakiness, called out rather than papered over.Not in this PR
Phase 2 (D8 scoped invitations, D9 cross-org approval targeting, D13 promotion tooling), D10 layered master data (blocked on its follow-up ADR), Phase 3 governance, and the console UI affordances the ADR wants alongside Phase 1 (org switcher as write context, "all my organizations" read views) — the backend now reports
features.tenancyPostureso objectui can build them.🤖 Generated with Claude Code
https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL
Generated by Claude Code