Repository navigation
Commit 21510bb
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
File tree
- .changeset
- examples/app-showcase/src/data/objects/external
- packages
- core/src/security
- plugins/plugin-security/src
- qa/dogfood/test
- runtime/src
- spec/src/migrations/entries/semantic
- scripts
- adr-anchors
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
18 | 25 | | |
19 | 26 | | |
20 | 27 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
16 | 21 | | |
17 | 22 | | |
18 | 23 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
69 | 69 | | |
70 | 70 | | |
71 | 71 | | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
72 | 85 | | |
73 | 86 | | |
74 | 87 | | |
| |||
123 | 136 | | |
124 | 137 | | |
125 | 138 | | |
126 | | - | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
127 | 142 | | |
128 | 143 | | |
129 | 144 | | |
130 | 145 | | |
131 | 146 | | |
132 | 147 | | |
133 | | - | |
134 | | - | |
135 | | - | |
| 148 | + | |
| 149 | + | |
136 | 150 | | |
137 | 151 | | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
138 | 161 | | |
139 | 162 | | |
140 | 163 | | |
| |||
0 commit comments