Skip to content

Commit eebaf3b

Browse files
feat(objectql,core): one tenant read predicate for Layer 0 and every driver (ADR-0131 C8 stage 8a) (#24389)
Part of #15212 Clause-②: yes ADR-0131 C8, **stage 8a — one tenant read predicate (D8)**, per the stage plan 6251466182 and the claim 6251491183. #15831 (the driver leg ignored `platformGlobalObjects`) is closed by pointer after this merges; its tripwire pin already reads 2 (measured below). ## What changed - **One function decides.** `computeTenantLayer0Verdict` and `TenantLayer0Input` move to `packages/core/src/security/tenant-layer0-verdict.ts` (new `@objectstack/core` export). `plugin-security`'s `tenant-layer.ts` re-exports and calls it; its Layer 0 filter, the recorded verdict and the intersect are byte-identical. - **The engine projects that verdict onto the driver.** `buildDriverOptions` (`packages/objectql/src/engine.ts`) no longer derives `DriverOptions.tenantId` / `tenantIds` on its own. For a read, count, aggregate, update or delete it projects: - the verdict the wall RECORDED on this operation (`OperationContext.tenantLayer0Verdict`, parsed with `TenantLayer0VerdictSchema`), where the security layer composed its wall. That verdict carries the exempt `PLATFORM_ADMIN`, the verified owner's read lift and the delegator intersection; - otherwise the same function over the engine's own facts: the posture in force (`resolveEnginePosture`), the context's organization and membership set, no admin exemption. This covers a system context, a by-id write, and a composition with no security layer. - Projection: `none` → no scope; `organization` → `tenantId`; `organizations` → `tenantIds` (with the active organization as `tenantId`, which the driver keys on); `deny` → the active organization if any (the narrowest scope a driver can express). - **The write target is untouched.** The insert path passes `writeTarget: true` and keeps the active organization as `tenantId` (and the `group` membership set) in every posture, so the row stamp and the counter scope hold. The engine has no upsert door (`ENGINE_UPDATE_UPSERT_REMOVED`); the SQL driver's own upsert cross-organization conflict refusal is reached only by direct driver callers and is untouched by this change. (Wording corrected by the epic seat from contract review 6254710664 ①(d).) - **Not touched:** both `orWhereNull` arms in `sql-driver.ts` (stage 8c-1), `sharing-rule-service.ts`, the ownership ceremony, and the 8b-0a / 8b-0b producers. - Docblocks that described the double derivation are corrected (`buildDriverOptions`, `memory-tenancy-guard.ts`). ADR-0087 semantic entry `driver-read-scope-follows-tenant-wall`; changeset `15212-8a-one-predicate.md`. ## Clause-② — what widens | posture / caller | driver scope before | driver scope now | |:--|:--|:--| | `single`, a caller naming an organization | that organization (plus organization-less rows) | none | | walled, exempt `PLATFORM_ADMIN` on a posture-permitting object, or the verified owner's read | the active organization | none (what Layer 0 already admits) | | `isolated` / `group` member; walled system context naming an organization | equality / union | unchanged | **Consequence, settled by ADR-0131 D8/D11:** a `single` install holding more than one organization (the `org_bu` dogfood fixture shape) loses the driver isolation it had by accident. `single` enforces no wall. Organizations are kept apart on `isolated` / `group`. ## Mechanism assumptions (Section 2), measured - **A1 — holds.** `buildDriverOptions` derived `tenantId` from `execCtx.tenantId` alone (and `tenantIds` only via the provider's `group`). Implemented as above. - **A2 — holds.** `@objectstack/objectql` and `@objectstack/plugin-security` both already depend on `@objectstack/core`, and `core` depends on neither. `check-undeclared-dep-imports` is green. - **A3 — already true before this PR.** `tenant-layer0-verdict-end-to-end.test.ts:181` reads `toBe(2)` on `main`. ADR-0131 D7 (C6) made the carve-out total: a declared object is planned with no organization column, so the driver has nothing to scope. This PR leaves that pin as it is. - **A4 — holds,** with the table above. - **A5 — measured.** 8-M's heuristic counted up to 38 suites. The suites that actually failed were 13 files across 6 packages: - objectql: `engine.test.ts`, `engine-external-tenant-scope`, `engine-reference-tenant-scope`, `engine-findone-contract`, `engine-aggregate-search`; - plugin-security: `explain-enforce-parity`; - plugin-sharing: `rule-criteria-org-scope`, `record-share-organization-stamp`; - service-storage: `chunk-part-record-concurrency`; - dogfood: `schedule-sweep-organization-scope`, `schedule-acting-organization`, `invitation-ledger-row-scope`. Each pin that asserted cross-organization isolation now runs under an explicit `isolated` posture. The `single` cases assert the widened answer. In `explain-enforce-parity`, the `NATIVE_SCOPING_UNDER_SINGLE` "measured divergence" joins the invariant: explain and enforcement now agree under `single`. - **A6 — holds.** 1,008 changed lines in total (763 added, 245 deleted, 20 files): 609 in source, changeset and entry, and 399 in tests. **Landing points outside the claim's named list.** These are the default-posture suites the claim's last bullet covers, measured above: `service-storage/src/chunk-part-record-concurrency.test.ts`, `plugin-sharing/src/record-share-organization-stamp.test.ts`, and the three dogfood suites. No source file outside the claim surface was edited. ## Verification (on `cc6801a0e`, `main` merged) - **Package suites:** - objectql: 444 files, 8,337 passed. - plugin-security: 187 files, 3,736 passed, 45 skipped. - plugin-sharing: 1,117 passed. service-storage: 1,041 passed. core: 2,634 passed. - **Dogfood:** all 8 shards, 2,266 passed, 18 skipped, 0 failed. - **Before the merge** (same diff): driver-sql 3,677 passed; runtime (4 shards) 7,414 passed; plugin-audit, plugin-auth, plugin-approvals, plugin-email, organizations, driver-memory, service-messaging, service-settings, service-analytics, service-mailbox and service-automation all green. - `@objectstack/rest`: only the ObjectQL-with-tenant suite was run (52 passed), because the full suite is longer than the foreground cap. CI runs the rest. - **Typecheck green:** core, objectql, plugin-security, plugin-sharing, service-storage, driver-memory, dogfood, spec. - **Ablation** (`scripts/ablation-replace.mjs`, test reads `src/`): re-adding the active-organization scope on a `none` verdict reddens exactly the two widening pins in `engine-driver-read-scope.test.ts` (2 failed, 11 passed). The restore was verified: blob equals HEAD and `git diff HEAD` is empty. - **Gates:** `dispatch-gates --commands` derived 100 families, and `--ran` reconciled 100 run with 0 NOT-MEASURED. `pnpm --filter @objectstack/spec check:generated` is green, and `check:adr-0087-registration` reads 1 non-breaking changeset. ## Acceptance notes - **On a `single` install, an org-bound session's reads no longer reach the memory driver with a tenant scope.** The guard's seam 3 judges whatever scope arrives. Writes still carry the target, so the memory driver still refuses them. - **The `@objectstack/verify` harness comment is now stale.** It says the audience-gate invitations under `org_verify_audience_gate` are invisible to fixtures (`harness.ts`, `inviteForAudienceGate`). On `single` they are now visible to the owner, and `invitation-ledger-row-scope` filters them. The comment was left as it is because it is outside this stage's surface. - **The schedule dogfood suites set the engine's posture provider to `isolated` around the bind.** They model a walled deployment without booting the enterprise organizations plugin, and the driver scope now follows the engine's posture rather than the context's organization alone. --- _Generated by [Claude Code](https://claude.ai/code/session_01Wq39WWXWX71NLEFRMMKqFE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 584b422 commit eebaf3b

20 files changed

Lines changed: 763 additions & 245 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/core': minor
4+
'@objectstack/spec': minor
5+
'@objectstack/plugin-security': patch
6+
---
7+
8+
feat(objectql,core): the driver's tenant read scope is the organization wall's verdict — one predicate for Layer 0 and every driver (ADR-0131 D8)
9+
10+
Clause-②: yes (widening)
11+
12+
- **What changed.** The engine no longer derives `DriverOptions.tenantId` / `tenantIds` beside the organization wall. `buildDriverOptions` projects the wall's own verdict onto them: the verdict plugin-security records on the operation (`OperationContext.tenantLayer0Verdict`) where its wall ran, and otherwise the same function over the engine's own facts (the posture in force, the context's organization and membership set). The function, `computeTenantLayer0Verdict` with its input type `TenantLayer0Input`, moves to `@objectstack/core` (new export); `@objectstack/plugin-security`'s `tenant-layer.ts` re-exports it and its Layer 0 is byte-identical.
13+
- **FROM → TO, per posture, for a read, count, aggregate, update or delete on an object with an organization column:**
14+
15+
| posture / caller | driver scope before | driver scope now |
16+
|:--|:--|:--|
17+
| `single`, any caller naming an organization | that organization (plus organization-less rows) | **none** |
18+
| `isolated`, member | the active organization | unchanged |
19+
| `group`, member | the membership set | unchanged |
20+
| walled, exempt `PLATFORM_ADMIN` on a posture-permitting object, or the verified platform owner's read | the active organization | **none** — what Layer 0 already admitted |
21+
| walled, system context naming an organization | that organization | unchanged |
22+
23+
- **Not changed.** Inserts and upserts keep the active organization as `tenantId` in every posture: the row is stamped with it, its counter scopes by it, and an upsert that would merge into another organization's row is still refused. Both `orWhereNull` compatibility arms in `SqlDriver.applyTenantScope` stay (their retirement is a later stage of ADR-0131 D13).
24+
- **Who notices.** A `single` deployment that holds more than one organization was kept apart by the driver only by accident; postures now differ only in enforcement (ADR-0131 D11), and `single` enforces no wall. To keep organizations apart, run the `isolated` or `group` posture. Code that relied on a context organization to narrow a read on `single` names the organization in its own `where`.
25+
- **Migration.** ADR-0087 semantic entry `driver-read-scope-follows-tenant-wall`. A deployment's `platformGlobalObjects` carve-out already reaches the driver through the column plan (ADR-0131 D7): a declared object carries no organization column, so neither wall has anything to scope.

‎packages/core/src/security/index.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ export {
7373
type RefusedEntitlementKey,
7474
} from './deployment-org-scoping-entitlement.js';
7575

76+
// [ADR-0131 D8] The ONE tenant read predicate — the Layer 0 verdict that
77+
// plugin-security projects onto its `where` wall and the engine projects onto
78+
// the driver's native scope (`DriverOptions.tenantId` / `tenantIds`).
79+
export {
80+
computeTenantLayer0Verdict,
81+
type TenantLayer0Input,
82+
} from './tenant-layer0-verdict.js';
83+
7684
// `PluginConfigValidator` / `createPluginConfigValidator` were RETIRED here on
7785
// 2026-08-27 (#11982, ADR-0049 enforce-or-remove; recorded in ADR-0025 §3.7).
7886
// The kernel never received a plugin's config to validate — factories close
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import {
4+
postureEnforcesWall,
5+
postureUsesUnionScope,
6+
type TenancyPosture,
7+
type TenantLayer0Verdict,
8+
} from '@objectstack/spec/security';
9+
10+
/**
11+
* ── The ONE tenant read predicate (ADR-0131 D8 「一道谓词,算一次」) ──────────
12+
*
13+
* What the organization wall decides for one operation, computed by ONE
14+
* function for both of its enforcers:
15+
*
16+
* - **Layer 0** (`plugin-security`'s `tenant-layer.ts`, ADR-0095 D1) projects
17+
* the verdict onto the `where` predicate it AND-composes first, and records
18+
* it on the operation (`OperationContext.tenantLayer0Verdict`, #15813);
19+
* - **the engine** (`@objectstack/objectql`, `buildDriverOptions`) projects the
20+
* same verdict onto `DriverOptions.tenantId` / `tenantIds`, the driver's
21+
* native read scope — the recorded verdict where the wall ran, this function
22+
* over the engine's own facts where it did not (a system context, a by-id
23+
* write, a composition with no security layer).
24+
*
25+
* Before this module the engine derived the driver's scope on its own, from
26+
* the active organization alone: a scope under `single`, where the wall is
27+
* inert, and a scope for the exempt platform admin, whom the wall lets
28+
* through — two walls, two answers. Here, so `@objectstack/objectql` and
29+
* `@objectstack/plugin-security` (both already depend on this package) read
30+
* one decision rather than two copies of it.
31+
*/
32+
33+
/** Inputs the Layer 0 decision needs — all cheap, already-loaded facts. */
34+
export interface TenantLayer0Input {
35+
/**
36+
* [ADR-0105 D1] The tenancy posture actually IN FORCE (`tenancy.posture`) —
37+
* the single fact that decides what this layer enforces:
38+
*
39+
* - `single` → inert (no wall)
40+
* - `group` → `organization_id IN accessibleOrgIds` (union / MOAC)
41+
* - `isolated` → `organization_id = organizationId` (the hard wall)
42+
*
43+
* A degraded deployment (a wall requested but not enforceable) already
44+
* resolves to `single` at the service, so this layer never sees a posture it
45+
* cannot honour.
46+
*/
47+
tenancyPosture: TenancyPosture;
48+
/** The resolved authz context's active organization id (`ExecutionContext.tenantId`). */
49+
organizationId?: string;
50+
/**
51+
* [ADR-0105 D2] The caller's org access set
52+
* (`ExecutionContext.accessible_org_ids`) — every organization they hold a
53+
* currently-valid membership in. Read ONLY under the `group` posture, where
54+
* it is the wall. An empty/absent set there denies (fail closed): a principal
55+
* with no membership has no group data reach.
56+
*/
57+
accessibleOrgIds?: readonly string[];
58+
/**
59+
* Whether the object carries an `organization_id` column. `undefined` = the
60+
* schema/field-set could not be resolved yet (boot); treated as "assume tenant
61+
* object" so a not-yet-registered object still fails safe toward isolation
62+
* (parity with the pre-extraction path, which kept the wildcard tenant policy
63+
* when the field set was unavailable).
64+
*/
65+
objectHasOrgIdField?: boolean;
66+
/**
67+
* Whether the object opted out of tenancy (`tenancy.enabled === false` or
68+
* `systemFields.tenant === false`) — a platform-global object. Such objects
69+
* are NOT tenant objects; Layer 0 contributes nothing.
70+
*/
71+
tenancyDisabled: boolean;
72+
/**
73+
* Whether the object's POSTURE permits a platform admin to cross the tenant
74+
* wall — `private`, platform-global (`tenancy.enabled:false`), or
75+
* better-auth-managed (exactly the ADR-0066 ① gate). Business (public) tenant
76+
* objects do NOT permit it, so a platform admin stays org-scoped on them.
77+
*/
78+
posturePermitsCrossTenant: boolean;
79+
/**
80+
* TRUE-platform-admin evidence (ADR-0095 D3 `PLATFORM_ADMIN` rung) for THIS
81+
* operation side. This is the ONLY gate that lets a caller cross the tenant
82+
* wall, so it MUST distinguish a PLATFORM operator from a TENANT `org_admin`.
83+
*
84+
* It is NOT merely the superuser bit (`viewAllRecords`/`modifyAllRecords`):
85+
* `organization_admin` holds that bit via its `'*'` wildcard grant
86+
* (`default-permission-sets.ts`), so gating the exemption on the superuser bit
87+
* alone let an org admin cross the wall on a `private` (or platform-global /
88+
* better-auth) TENANT object — the D1 "B2 posture stand-in" gap this field's
89+
* former semantics carried. The caller now requires the superuser bit AND a
90+
* platform-EXCLUSIVE capability (`manage_metadata`/`manage_platform_settings`/
91+
* `studio.access`/`manage_users` — the caps `admin_full_access` carries and
92+
* `organization_admin` deliberately withholds), i.e. the `PLATFORM_ADMIN`
93+
* posture. It is NEVER the better-auth role. A caller holding no permission
94+
* sets (the engine, over its own facts) passes `false`.
95+
*/
96+
isPlatformAdmin: boolean;
97+
}
98+
99+
/** The two verdicts that carry no organization — shared, frozen, safe to hand out. */
100+
const LAYER0_NONE: TenantLayer0Verdict = Object.freeze({ kind: 'none' as const });
101+
const LAYER0_DENY: TenantLayer0Verdict = Object.freeze({ kind: 'deny' as const });
102+
103+
/**
104+
* [#15813 / ADR-0131 D8] Compute the Layer 0 VERDICT — what the wall decides
105+
* for this operation, as a value another package can read without re-deriving
106+
* it (`TenantLayer0VerdictSchema`, `@objectstack/spec/security`).
107+
* `computeTenantLayer0Filter` (plugin-security) is this verdict PROJECTED onto
108+
* a `where` predicate; the engine's driver scope is the same verdict projected
109+
* onto `DriverOptions`. Neither can disagree with the other, because both are
110+
* derived from this verdict, never computed beside it.
111+
*
112+
* The decision ladder is the wall's own, in the wall's own order: every exit
113+
* that answers `null` as a filter answers `none` here, every fail-closed exit
114+
* answers `deny`, and the two walled shapes answer the organization(s) they
115+
* name. The `group` set is DEDUPLICATED here (`accessible_org_ids` is a
116+
* membership list; a repeated membership names one organization once), so a
117+
* reader may test `organizationIds.length === 1` and read no further.
118+
*
119+
* - `none` → the wall contributes nothing (`single` posture; non-tenant object;
120+
* or an exempt platform admin).
121+
* - `organization` → the `isolated` wall, `organization_id = <id>`.
122+
* - `organizations` → the `group` union wall, `organization_id IN <set>`
123+
* (ADR-0105 D2).
124+
* - `deny` → a walled posture on a tenant object, but the context carries no
125+
* organization scope to enforce with (no active org under `isolated`, empty
126+
* access set under `group`) → fail closed (zero rows / write denied).
127+
*/
128+
export function computeTenantLayer0Verdict(input: TenantLayer0Input): TenantLayer0Verdict {
129+
// `single` posture (incl. a degraded deployment, which resolves to `single`)
130+
// → parity with today's policy stripping.
131+
if (!postureEnforcesWall(input.tenancyPosture)) return LAYER0_NONE;
132+
133+
// Not a tenant object: platform-global (tenancy disabled) or simply carries no
134+
// `organization_id` column (e.g. better-auth identity tables like `sys_user`).
135+
// Layer 0 contributes nothing — the object's own business RLS (Layer 1, e.g.
136+
// `_self` carve-outs) is its only scoping.
137+
if (input.tenancyDisabled) return LAYER0_NONE;
138+
if (input.objectHasOrgIdField === false) return LAYER0_NONE;
139+
140+
// Exemption is a Layer 0 rule (W2 fix): only a TRUE PLATFORM_ADMIN caller on an
141+
// object whose posture permits it crosses the wall — NOT a tenant `org_admin`
142+
// that merely holds the superuser bit (Finding 2 / #2937). Layer 1's superuser
143+
// bypass no longer implies Layer 0's.
144+
if (input.isPlatformAdmin && input.posturePermitsCrossTenant) return LAYER0_NONE;
145+
146+
// [ADR-0105 D2] `group`: union access over the caller's memberships (MOAC).
147+
// The ACTIVE organization no longer bounds reads here — membership does — so
148+
// a group-HQ analyst sees every plant they belong to on one screen without
149+
// context switching, and a plant admin still sees only their own plants.
150+
// An empty access set is the fail-closed case: no membership, no reach.
151+
if (postureUsesUnionScope(input.tenancyPosture)) {
152+
const orgIds = input.accessibleOrgIds;
153+
if (!orgIds || orgIds.length === 0) return LAYER0_DENY;
154+
return { kind: 'organizations', organizationIds: [...new Set(orgIds)] };
155+
}
156+
157+
// `isolated`: the hard wall. Missing active org → fail closed.
158+
if (!input.organizationId) return LAYER0_DENY;
159+
return { kind: 'organization', organizationId: input.organizationId };
160+
}

‎packages/drivers/driver-memory/src/memory-tenancy-guard.ts‎

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -46,21 +46,25 @@
4646
*
4747
* Seams 1 and 2 read the two STATIC signals: the deployment posture and the
4848
* object's own metadata. The engine's decision to scope is neither. It is
49-
* `Engine.buildDriverOptions`:
50-
*
51-
* ```ts
52-
* const hasTenant =
53-
* execCtx?.tenantId !== undefined && !isTenancyDisabled(objectSchema) && !isFederated;
54-
* ```
55-
*
56-
* — the engine scopes unless the object opts OUT, while seam 2 refuses only an
57-
* explicit opt-IN. An object that OMITS the `tenancy` block falls between them:
58-
* the engine scopes it, seam 2 never sees it, and seam 1 passes because the
59-
* posture really is `single`. A `single` posture constrains the WALL, not the
60-
* number of organizations, so rows still carry whichever `organization_id` they
61-
* were written with — and this driver used to discard the scope and answer with
62-
* EVERY organization's rows. That is the silent non-isolation this whole guard
63-
* exists to remove, reappearing on the DEFAULT case.
49+
* `Engine.buildDriverOptions`, and it is made per CALL: a read, update or
50+
* delete is scoped by the organization wall's verdict (ADR-0131 D8 — none
51+
* under `single`), and an insert or upsert carries the caller's active
52+
* organization as its write target in EVERY posture, unless the object opts
53+
* OUT of tenancy.
54+
*
55+
* Seam 2 refuses only an explicit opt-IN. An object that OMITS the `tenancy`
56+
* block falls between them: the engine hands it the write target, seam 2 never
57+
* sees it, and seam 1 passes because the posture really is `single`. A `single`
58+
* posture constrains the WALL, not the number of organizations, so rows still
59+
* carry whichever `organization_id` they were written with — and this driver
60+
* used to discard the scope it was handed and answer as if it had applied it.
61+
* That is the silent non-isolation this whole guard exists to remove,
62+
* reappearing on the DEFAULT case.
63+
*
64+
* (Before ADR-0131 D8 the engine also derived a READ scope under `single` from
65+
* the context's organization alone; it no longer does, because the wall it would
66+
* mirror is inert there. The seam still judges whatever scope arrives, so it is
67+
* unchanged by that: what no longer arrives is no longer refused.)
6468
*
6569
* Why the gap cannot be closed at seam 1 or 2 — measured, not assumed:
6670
* `execCtx?.tenantId !== undefined` is a property of the REQUEST, and at
@@ -183,8 +187,9 @@ export interface TenancyAwareSchema {
183187
* (twelve seeded by the app, one minted by the platform for the admin), with
184188
* rows carrying whichever `organization_id` they were written with. And the
185189
* omission it describes is not the absence of a tenant signal but its default
186-
* PRESENCE — `Engine.buildDriverOptions` scopes an object unless it opts out,
187-
* so "omits the block" is precisely the SCOPED case, not the exempt one.
190+
* PRESENCE — `Engine.buildDriverOptions` hands an object a write target (and,
191+
* under a wall, a read scope) unless it opts out, so "omits the block" is
192+
* precisely the SCOPED case, not the exempt one.
188193
*
189194
* The sentence is recorded rather than deleted because it is what justified
190195
* this predicate being an opt-IN test, and anyone re-reading that decision needs

‎packages/objectql/src/engine-aggregate-search.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@ const TIERS: readonly Tier[] = ['native', 'in-memory'];
145145
async function makeEngine(tier: Tier) {
146146
const { driver, reads } = makeDriver(tier === 'native');
147147
const engine = new ObjectQL();
148+
// [ADR-0131 D8] The `context` proof is that the caller's organization
149+
// reaches the driver as its read scope — which it does only under a wall;
150+
// under `single` (the env default) the scope is none.
151+
engine.setTenancyPostureProvider(() => 'isolated');
148152
engine.registerDriver(driver, true);
149153
await engine.init();
150154
(engine.registry as any).registerObject(account);

0 commit comments

Comments
 (0)