Skip to content

Commit 21510bb

Browse files
feat(plugin-security)!: under a walled tenancy posture every external object states its organization scope (#29867)
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj Clause-②: no (narrowing) — under the `isolated` and `group` tenancy postures an external object (an `external` block) that states no organization scope is refused by name when the boot completes; one registered after the boot is denied at Layer 0 for reads and every insert, update and delete of it is refused at the write wall. Nothing is widened, and the `single` posture is unchanged. Metadata-protocol: narrowed Skills-docs: follow-up #29866 — `content/docs/data-modeling/virtual-objects.mdx` (a multi-organization section), `skills/objectstack-data/rules/datasources.md` (the three declarations), `docs/adr/0015-external-datasource-federation.md` (the §10 and D7 amendment); the sibling docs PR on `claude/sec-hardening-g-docs`, governed (Tier H), landed by the maintainer's hand. Both docs mention the write refusal; the whole-block `systemFields: false` is not a declaration. ## 维护者速读 - **改了什么**:在 `isolated` / `group` 租户姿态下,每个外部对象(带 `external` 块)必须声明自己的组织范围——三选一:自带 `organization_id` 字段(远端行带该列、驱动返回它;另名远端列用 `external.columnMap` 映射到它)、`tenancy: { enabled: false }`(全组织共享)、`systemFields: { tenant: false }`(同一声明,写在系统字段上)。整块 `systemFields: false` 不算声明;对象自带 `organization_id` 时,除非显式退出,一律按有墙处理。什么都没声明的外部对象在启动完成时(`kernel:ready`)按名拒绝启动;启动后才注册的(Studio 导入、运行时数据源)在 Layer 0 判 `deny`(每次读零行),写墙在第一步就拒绝它的每一次增删改(拒绝文案指出未声明的范围与三种声明),并告警一次。外部对象上的 `tenancy.tenantField` 同样按名拒绝(墙只认 `organization_id`)。showcase 的两个联邦对象声明为共享。 - **为什么改**:外部对象没有平台存储,注册表注入的 `organization_id` 只是声明不是列;它的行属于一个组织还是所有组织,只有作者知道。规则由安全频道裁决(D1 B,D2 (c2)+(a));`single` 姿态不受影响。 - **风险与回滚**:破坏性(`!`):有墙姿态下存量的未声明外部对象会拒绝启动,错误信息列出对象名与三种声明;改元数据即可,不需要迁移数据。回滚 = 回退本 PR。 - **先看**:① `packages/plugins/plugin-security/src/external-organization-scope.ts`(一处判定 + 启动拒绝文案);② `packages/core/src/security/tenant-layer0-verdict.ts`(新的 `deny` 一阶);③ 写墙 `composeWriteTenantWall` 的未声明拒绝(`undeclaredExternalScopeWriteRefusal`);④ `examples/app-showcase/src/data/objects/external/*.object.ts`(声明)。 - **两分钟验证**:`OS_TENANCY_POSTURE=isolated OS_PLATFORM_OWNER_EMAIL=you@example.com pnpm --filter @objectstack/runtime exec vitest run src/plugin-driver-external-object.spike.test.ts` —— 预期 19/19 通过,其中 (h6) 断言拒绝启动的错误按名列出对象与三种声明,(h5b) 断言启动后注册的未声明对象的写入被拒。 - **需要你做的**:无,席位落地(本 PR 不触及受管面;配套的 ADR/文档 PR #29866 另行由你落地)。 ## What changes Under a walled tenancy posture every external object states its organization scope (ADR-0015 §10, amended by the sibling docs PR). - **`@objectstack/plugin-security`** — `external-organization-scope.ts` is the one reading of the declaration (`walled` / `shared` / `undeclared` / `tenant-field-unenforceable`), the boot refusal (`ExternalObjectOrganizationScopeUndeclaredError`, from `SecurityPlugin`'s `kernel:ready` handler beside the ADR-0131 D5 refusal; a context without hooks judges at the end of `start()`), and the three ways out as every door spells them. `getObjectSecurityMeta` reads the same predicate into the Layer 0 input and warns once for an object registered after the boot that states nothing. - **The write wall** — `composeWriteTenantWall` refuses every insert, update and delete of an object registered after the boot that states no scope, as its first pre-test (ahead of the no-active-organization pre-test), whenever the caller's Layer 0 (or the delegator's) is the deny sentinel. The refusal (`undeclaredExternalScopeWriteRefusal`) is a rule-level sentence naming the posture, the object, the undeclared scope and the three declarations; it never answers with the "join an organization" sentence. - **The declarations** — only the wall's two opt-out clauses (`tenancy: { enabled: false }`, `systemFields: { tenant: false }`) and an own `organization_id` count. The whole-block `systemFields: false` is in neither opt-out clause and states nothing; an object with its own `organization_id` is never answered `shared` unless it opted out (`systemFields: false` with an own column reads `walled`). - **`@objectstack/core`** — `TenantLayer0Input.externalOrganizationScopeUndeclared` (optional); set, `computeTenantLayer0Verdict` answers `deny` under a walled posture, after the tenancy opt-out and ahead of the column question and the platform-admin crossing. Omitted, every existing caller reads as before. - **`@objectstack/spec`** — ADR-0087 D3 semantic entry `external-object-organization-scope-declared`, telling an upgrading operator what to declare; its acceptance text names the `kernel:ready` / withheld-hooks registrations as met by the denial. - **`examples/app-showcase`** — `showcase_ext_customer` and `showcase_ext_order` declare `tenancy: { enabled: false }` (the remote tables carry no organization column). - **`tenancy.tenantField` on an external object** is closed by refusal, not enforcement: the wall keys on `organization_id` alone, the one reader of a declared tenant field is storage the platform provisions, and `external.columnMap` already reaches the wall's column. Enforcing it would need the verdict schema and the deny sentinel to carry a second column. - Not touched: `@objectstack/objectql` (the engine already withholds the driver's native scope for external objects; the floor re-applies the wall's `where`). ## Tests - `packages/runtime/src/plugin-driver-external-object.spike.test.ts` block (h), under `isolated` with two organizations: (h1) A reads only A's rows of a walled object and the driver is asked with the organization predicate; (h2) a driver that ignores `where` stays walled; a row without the column reads to no one (h1, h2); (h3) `tenancy.enabled: false` and `systemFields.tenant: false` objects are shared; (h4) a local object stays walled; (h5) an object registered after the boot that states nothing is denied (the spec's deny sentinel, by identity, for both organizations; the remote is asked for nothing it holds); (h5b) the same object's writes over HTTP — POST answers 403 `PERMISSION_DENIED` with the write wall's sentence, PATCH and DELETE stay refused, and the remote records no write; control: the same caller's insert on a declared shared object lands; (h6) a boot holding an undeclared object, and one declaring a foreign `tenantField`, is refused by name with the three ways out. Blocks (a)–(g) boot `single` with undeclared objects and are the control. - `packages/qa/dogfood/test/external-object-scope-after-boot-writes.dogfood.test.ts` (new): the write refusal at the dogfood door (showcase plus a writable plugin-driver datasource). - `packages/plugins/plugin-security/src/external-organization-scope.test.ts`: the predicate (every declaration, both `fields` shapes, the parsed-copy rule, `systemFields: false` stating nothing, an own column with and without the opt-out), the refusal text, the boot door through the plugin (`kernel:ready`, no-hook, `single`), the Layer 0 door (`isolated`, `group`, `tenantField`, declared shared, declared `organization_id`, `single`, local control), the write wall through `writeWallVerdict` (caller with an organization, caller without one, `group`, shared control, `single` control), and the once-only warning. - `packages/core/src/security/tenant-layer0-verdict.test.ts`: the new rung, its order, and the omitted-flag control. - Updated: `federated-tenant-layer0.test.ts` and `tenant-layer0-verdict-on-operation.test.ts` (the undeclared external object now records `deny` on a delete sweep and is refused on an update sweep), four dogfood files whose premise was the showcase object's injected `organization_id` (it now declares its scope; `federated-rls-injectors` gains the after-boot deny case). <details><summary>Suites and typechecks (final trees, under the shared verify lock)</summary> | run | reading | |:--|:--| | `pnpm --filter @objectstack/plugin-security test` | Test Files 242 passed · Tests 5242 passed, 45 skipped · command-exit 0 | | `pnpm --filter @objectstack/core test` | Test Files 133 passed · Tests 2941 passed · command-exit 0 (core unchanged since) | | `pnpm --filter @objectstack/runtime test` | Test Files 535 passed · Tests 8573 passed, 19 skipped · command-exit 0 (earlier round); spike file 19 passed (19) incl. (h5b) at the final head | | `pnpm --filter @objectstack/spec exec vitest run src/migrations` | Tests 196 passed · command-exit 0 | | `pnpm --filter @objectstack/spec check:generated` | all 14 generated artifacts up to date · command-exit 0 | | `typecheck` — runtime, core, plugin-security | command-exit 0 each (`check:test-typecheck` OK in each) | | unit files `external-organization-scope` + `federated-tenant-layer0` + `tenant-layer0-verdict-on-operation` | 64 passed | | `pnpm --filter @objectstack/dogfood test` (full battery) | Test Files 408 passed, 3 skipped (411) · Tests 3365 passed, 28 skipped (3393) · command-exit 0 · 29m43s held; the one file changed after the battery started (the new write-door file) re-run alone at `554573c4f4`: 4 passed | </details> <details><summary>One-shot ablations (mutate → rebuild → dist preflight → red → restore from HEAD → rebuild → preflight absent → green)</summary> - Boot refusal (`external-organization-scope.ts`, the throw guarded by a literal the posture can never equal): marker present in `plugin-security/dist` (preflight exit 0); boot-door pins 4 failed / 12 passed, spike (h6) 1 failed → restored, `git diff HEAD` empty, blob hash equals HEAD, preflight `--absent` exit 0, pins 16 passed, (h6) 1 passed. - Layer 0 rung (`tenant-layer0-verdict.ts`, the deny guarded the same way): marker present in `core/dist`; core verdict pins 3 failed / 4 passed, spike (h5) 1 failed → restored (same proofs), 7 passed, (h5) 1 passed. - Write wall (A3b, the refusal guarded by a literal the operation can never equal): unit "the write wall" 3 failed / 2 passed, spike `-t h5` 1 failed / 1 passed ((h5b) red, (h5) green), dogfood insert pin 1 failed → restored (same proofs), 5 passed, 2 passed, 1 passed. </details> ## Acceptance notes - The boot refusal runs at `kernel:ready` rather than inside `start()`: the registry is still filling during `start()` (AGENTS.md, startup registry reads), and the identity-object refusal in the same plugin already judges there. The dispatch said "in `start()` beside the precedent"; the subscription is placed exactly there. - The verified platform owner's read crossing still lifts the read wall for an undeclared object registered after the boot (a standing ruling, recorded by the contract review as the maintainer's question and not changed here); writes by the owner are refused like everyone's. - An author-time `os validate` warning for an external object stating no scope would be a fourth door (now refused loudly at boot); not added here. `carrier: none`. - `packages/objectql/src/engine-external-tenant-scope.test.ts` and `packages/drivers/driver-sql/src/sql-driver-external-unprovisioned-sort-anchor.test.ts` copy the showcase object's former shape in local fixtures and say so; they are fixtures of that shape, not mirrors, and are left as they are. - The spike file's `test-budget` is re-pinned from a local ratio (two boots became three); the first scheduled run that carries block (h) gives the CI reading. ## Verification `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `554573c4f4` derived 98 families; every command was run byte-for-byte with its exit captured before any pipe, and `--ran` reconciled: 98 derived, 98 run, 0 NOT MEASURED, 0 UNRUN. - `pnpm check:objectql-double-limit` was red on the previous head (the new dogfood driver's `find` ignored `limit`); it now honours `limit` / `offset` (`554573c4f4`), re-run exit 0. - Every family CI runs on the PR is read from the PR's check-runs, not re-run here. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 83d7e4a commit 21510bb

24 files changed

Lines changed: 1736 additions & 85 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
'@objectstack/core': minor
4+
'@objectstack/spec': minor
5+
---
6+
7+
feat(plugin-security)!: under a walled tenancy posture every external object states its organization scope
8+
9+
Clause-②: no (narrowing) — under the `isolated` and `group` postures an external object (an `external` block) that states no organization scope is refused by name when the boot completes, and one registered after the boot answers no row and accepts no write; nothing is widened, and the `single` posture is unchanged.
10+
Metadata-protocol: narrowed
11+
12+
<!-- adr-0087: registered external-object-organization-scope-declared -->
13+
14+
**BREAKING** for a deployment running the `isolated` or `group` tenancy posture (`OS_TENANCY_POSTURE`) with an external object that states nothing about its organization scope.
15+
16+
- **The rule (ADR-0015 §10 as amended, ADR-0095 D1).** The platform provisions no storage for an external object, so the `organization_id` it injects into the registered schema is a column the remote may not carry. Whose rows an external object serves — one organization's or every organization's — is a fact only its author knows, and under a walled posture the object states it, in one of three ways: its own `organization_id` field (the remote rows carry the organization column and the driver returns it; a remote column of another name is mapped onto it with `external.columnMap: { <remote column>: 'organization_id' }`); `tenancy: { enabled: false }` (shared by every organization); or `systemFields: { tenant: false }` (the same opt-out on the system fields). These are the wall's own two opt-out clauses and the one column it reads; the whole-block `systemFields: false` states nothing.
17+
- **What the rule does.** `SecurityPlugin` refuses the boot from its `kernel:ready` handler, beside the ADR-0131 D5 refusal, naming every external object that states nothing and the three ways out (`ExternalObjectOrganizationScopeUndeclaredError`). An external object registered after the boot — a Studio import, a runtime datasource — that states nothing answers no row and accepts no write: the Layer 0 verdict is `deny` (`computeTenantLayer0Verdict`), the write wall refuses every insert, update and delete with a sentence naming the undeclared scope and the three ways out, and the plugin says so once in a warning. An object declaring its own `organization_id` is walled: a member of organization A reads only A's rows, through a driver that honours `where` and through one that ignores it (the engine's floor re-applies the predicate), and a row missing the column reads to no one. An object declaring `tenancy: { enabled: false }` or `systemFields: { tenant: false }` is shared.
18+
- **`tenancy.tenantField` on an external object is closed the same way.** The organization wall keys on `organization_id` alone, and a declared tenant field is read only by storage the platform provisions, which an external object has none of; so on an external object a `tenantField` naming another column is refused at boot and denied after it, with the remedy spelled out (`external.columnMap` onto `organization_id`, or `tenancy: { enabled: false }`). Local objects are unchanged.
19+
- **Who moves.** Every walled deployment with an external object stating nothing: declare one of the three and restart. The showcase's two federated analytics objects now declare `tenancy: { enabled: false }`. A `single`-posture deployment owes no declaration and behaves exactly as before.
20+
- **`@objectstack/core`.** `TenantLayer0Input` gains the optional `externalOrganizationScopeUndeclared`; set, the verdict is `deny` under a walled posture, ahead of the platform-admin crossing. Omitted, every existing caller reads as before.
21+
- **`@objectstack/spec`.** The ADR-0087 D3 entry `external-object-organization-scope-declared` tells an upgrading operator what to declare.
22+
23+
Skills-docs: follow-up #29866 — `content/docs/data-modeling/virtual-objects.mdx` (a multi-organization section), `skills/objectstack-data/rules/datasources.md` (the three declarations), `docs/adr/0015-external-datasource-federation.md` (the §10 and D7 amendment); the sibling docs PR on `claude/sec-hardening-g-docs`, governed (Tier H), landed by the maintainer's hand.

‎examples/app-showcase/src/data/objects/external/customer.object.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ export const ExternalCustomer = ObjectSchema.create({
1515
// object is RLS-owned / intentionally public; without this the new secure
1616
// default (unset OWD => private) would owner-filter it.
1717
sharingModel: 'public_read_write',
18+
// [ADR-0015 §10] Under a walled tenancy posture every external object states
19+
// its organization scope. The remote `customers` table carries no
20+
// organization column — it is the deployment's analytics catalogue, read by
21+
// every organization alike — so this object declares itself shared. (The
22+
// other declaration is an `organization_id` field of its own, when the
23+
// remote rows carry one.)
24+
tenancy: { enabled: false },
1825
label: 'External Customer',
1926
pluralLabel: 'External Customers',
2027
icon: 'database',

‎examples/app-showcase/src/data/objects/external/order.object.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ export const ExternalOrder = ObjectSchema.create({
1313
// object is RLS-owned / intentionally public; without this the new secure
1414
// default (unset OWD => private) would owner-filter it.
1515
sharingModel: 'public_read_write',
16+
// [ADR-0015 §10] Under a walled tenancy posture every external object states
17+
// its organization scope. The remote `orders` table carries no organization
18+
// column — the deployment's shared analytics catalogue — so this object
19+
// declares itself shared, as `showcase_ext_customer` does.
20+
tenancy: { enabled: false },
1621
label: 'External Order',
1722
pluralLabel: 'External Orders',
1823
icon: 'shopping-cart',
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The Layer 0 verdict ladder (ADR-0095 D1 / ADR-0131 D8), rung by rung — in
5+
* particular the rung an external object reaches when it states no
6+
* organization scope under a walled posture (ADR-0015 §10): the wall DENIES,
7+
* it does not stand down.
8+
*/
9+
10+
import { describe, it, expect } from 'vitest';
11+
import { computeTenantLayer0Verdict, type TenantLayer0Input } from './tenant-layer0-verdict.js';
12+
13+
const member = (over: Partial<TenantLayer0Input> = {}): TenantLayer0Input => ({
14+
tenancyPosture: 'isolated',
15+
organizationId: 'org-1',
16+
objectHasOrgIdField: true,
17+
tenancyDisabled: false,
18+
posturePermitsCrossTenant: false,
19+
isPlatformAdmin: false,
20+
...over,
21+
});
22+
23+
describe('computeTenantLayer0Verdict — an external object that states no organization scope', () => {
24+
it('under `isolated` the wall denies: no provable scope, no row', () => {
25+
expect(computeTenantLayer0Verdict(member({ externalOrganizationScopeUndeclared: true, objectHasOrgIdField: false })))
26+
.toEqual({ kind: 'deny' });
27+
});
28+
29+
it('under `group` the wall denies too — the union predicate has no column to name', () => {
30+
expect(computeTenantLayer0Verdict(member({
31+
tenancyPosture: 'group', accessibleOrgIds: ['org-1', 'org-2'],
32+
externalOrganizationScopeUndeclared: true, objectHasOrgIdField: false,
33+
}))).toEqual({ kind: 'deny' });
34+
});
35+
36+
it('the denial precedes the platform-admin crossing: an undeclared object is a configuration defect, not a posture', () => {
37+
expect(computeTenantLayer0Verdict(member({
38+
externalOrganizationScopeUndeclared: true, objectHasOrgIdField: false,
39+
isPlatformAdmin: true, posturePermitsCrossTenant: true,
40+
}))).toEqual({ kind: 'deny' });
41+
});
42+
43+
it('a declared opt-out (`tenancy.enabled: false`) still contributes nothing — the object is shared by every organization', () => {
44+
expect(computeTenantLayer0Verdict(member({ tenancyDisabled: true, externalOrganizationScopeUndeclared: false, objectHasOrgIdField: false })))
45+
.toEqual({ kind: 'none' });
46+
});
47+
48+
it('a declared `organization_id` keeps the wall', () => {
49+
expect(computeTenantLayer0Verdict(member({ externalOrganizationScopeUndeclared: false })))
50+
.toEqual({ kind: 'organization', organizationId: 'org-1' });
51+
});
52+
53+
it('CONTROL: under `single` nothing is read — an undeclared object answers `none` like every other', () => {
54+
expect(computeTenantLayer0Verdict(member({ tenancyPosture: 'single', externalOrganizationScopeUndeclared: true, objectHasOrgIdField: false })))
55+
.toEqual({ kind: 'none' });
56+
});
57+
58+
it('CONTROL: the flag omitted reads as every caller before it — a column-less object answers `none`', () => {
59+
expect(computeTenantLayer0Verdict(member({ objectHasOrgIdField: false }))).toEqual({ kind: 'none' });
60+
});
61+
});

‎packages/core/src/security/tenant-layer0-verdict.ts‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,19 @@ export interface TenantLayer0Input {
6969
* are NOT tenant objects; Layer 0 contributes nothing.
7070
*/
7171
tenancyDisabled: boolean;
72+
/**
73+
* [ADR-0015 §10] The object is EXTERNAL (ADR-0015 `external`: the platform
74+
* provisions none of its storage) and states no organization scope the wall
75+
* can enforce — neither its own `organization_id` field, nor
76+
* `tenancy.enabled: false`, nor `systemFields.tenant: false`; or it names a
77+
* `tenancy.tenantField` other than `organization_id`, which the wall does not
78+
* read. Under a walled posture such an object is DENIED: whose rows it serves
79+
* is a fact only its author can state, and an unstated scope fails toward
80+
* isolation, never toward exposure (the shape the owner anchor takes when no
81+
* owner is provable). The boot refuses such an object by name; this rung
82+
* meets the one registered after the boot. Omitted, nothing changes.
83+
*/
84+
externalOrganizationScopeUndeclared?: boolean;
7285
/**
7386
* Whether the object's POSTURE permits a platform admin to cross the tenant
7487
* wall — `private`, platform-global (`tenancy.enabled:false`), or
@@ -123,18 +136,28 @@ const LAYER0_DENY: TenantLayer0Verdict = Object.freeze({ kind: 'deny' as const }
123136
* (ADR-0105 D2).
124137
* - `deny` → a walled posture on a tenant object, but the context carries no
125138
* organization scope to enforce with (no active org under `isolated`, empty
126-
* access set under `group`) → fail closed (zero rows / write denied).
139+
* access set under `group`); or a walled posture on an external object that
140+
* states no organization scope (ADR-0015 §10) → fail closed (zero rows /
141+
* write denied).
127142
*/
128143
export function computeTenantLayer0Verdict(input: TenantLayer0Input): TenantLayer0Verdict {
129144
// `single` posture (incl. a degraded deployment, which resolves to `single`)
130145
// → parity with today's policy stripping.
131146
if (!postureEnforcesWall(input.tenancyPosture)) return LAYER0_NONE;
132147

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.
148+
// Not a tenant object: platform-global (tenancy disabled). Layer 0
149+
// contributes nothing — the object's own business RLS (Layer 1, e.g.
136150
// `_self` carve-outs) is its only scoping.
137151
if (input.tenancyDisabled) return LAYER0_NONE;
152+
153+
// [ADR-0015 §10] An external object that states no organization scope is
154+
// denied under a wall — ahead of the column question (its `organization_id`
155+
// is the registry's, not the remote's) and ahead of the platform-admin
156+
// crossing (an unstated scope is a configuration defect, not a posture).
157+
if (input.externalOrganizationScopeUndeclared) return LAYER0_DENY;
158+
159+
// Simply carries no `organization_id` column (e.g. better-auth identity
160+
// tables like `sys_user`): not a tenant object, Layer 0 contributes nothing.
138161
if (input.objectHasOrgIdField === false) return LAYER0_NONE;
139162

140163
// Exemption is a Layer 0 rule (W2 fix): only a TRUE PLATFORM_ADMIN caller on an

0 commit comments

Comments
 (0)