Repository navigation
feat(plugin-security)!: under a walled tenancy posture every external object states its organization scope - #29867
Conversation
… object states its organization scope Claude-Session: https://claude.ai/code/session_01QSeKYmV7B85WZ7TtYWZkaG Co-authored-by: Claude <noreply@anthropic.com>
…r external objects Claude-Session: https://claude.ai/code/session_01QSeKYmV7B85WZ7TtYWZkaG Co-authored-by: Claude <noreply@anthropic.com>
…ation scope; an undeclared one registered after boot is denied Claude-Session: https://claude.ai/code/session_01QSeKYmV7B85WZ7TtYWZkaG Co-authored-by: Claude <noreply@anthropic.com>
…e engine refuses; the pinned ledger learns it Claude-Session: https://claude.ai/code/session_01QSeKYmV7B85WZ7TtYWZkaG Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QSeKYmV7B85WZ7TtYWZkaG Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 179 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 97b05d2cd396156aa0dc38c32fb004e0e2557b10 && git checkout 97b05d2cd396156aa0dc38c32fb004e0e2557b10
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7b2bb668dacc8d915c161791facf20be78138a99 554573c4f4e71abebfce28cd6d466b998dc1a109 && git checkout -B drift-repro 7b2bb668dacc8d915c161791facf20be78138a99 && git merge --no-ff 554573c4f4e71abebfce28cd6d466b998dc1a109
node scripts/docs-audit/affected-docs.mjs --json 7b2bb668dacc8d915c161791facf20be78138a99
|
|
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj Contract-review-claim: session_01QSeKYn5CmmdShCJifm6WYj · head 778365a · 2026-11-03T10:29Z |
… as the predicate guard does Claude-Session: https://claude.ai/code/session_01QSeKYmV7B85WZ7TtYWZkaG Co-authored-by: Claude <noreply@anthropic.com>
|
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj Contract-review-claim: session_01QSeKYn5CmmdShCJifm6WYj · head 29680f9 · 2026-11-03T12:30Z |
|
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj Contract reviewServed-tier: ① Derived judgmentsRead in the tree at the head above, against the maintainer's seven rulings and the two choices taken at seat discretion. The check-runs on this head are the gate verdict: every required context is
Seat discretion, judged: the refusal at Attempts to break it: ② Semver levelBREAKING ( ③ Boundary flags
④ Design judgment
Implemented-by: VERDICT: FAIL |
…nal object that states no organization scope; systemFields: false states nothing Under a walled posture an external object registered after the boot that states no organization scope answers no row and accepts no write: the write wall refuses the insert, update and delete ahead of its scope pre-test, with a sentence naming the undeclared scope and the three declarations. The whole-block systemFields: false is not one of the wall's two opt-out clauses and is no longer read as a declaration; an own organization_id is never answered shared unless the object opted out. Claude-Session: https://claude.ai/code/session_01QSeKYmV7B85WZ7TtYWZkaG Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QSeKYmV7B85WZ7TtYWZkaG Co-authored-by: Claude <noreply@anthropic.com>
|
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj Contract-review-claim: session_01QSeKYn5CmmdShCJifm6WYj · head 554573c · 2026-11-03T14:39Z |
|
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj Contract reviewServed-tier: ① Derived judgmentsDelta only, read in the tree; the check-runs on this head are the gate verdict — every required context (
Nothing in the delta touches the boot refusal, the Layer 0 rung, the showcase, or the ② Semver levelUnchanged: BREAKING ( ③ Boundary flagsNo must-fix. Nits, for the seat's discretion, not for the landing:
④ Design judgmentThe fix is in the right door: the write wall's own verdict ( Implemented-by: VERDICT: PASS |
|
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj 席位核对ACCEPT · head
|
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj
Clause-②: no (narrowing) — under the
isolatedandgrouptenancy postures an external object (anexternalblock) 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 thesingleposture 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 onclaude/sec-hardening-g-docs, governed (Tier H), landed by the maintainer's hand. Both docs mention the write refusal; the whole-blocksystemFields: falseis 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) 断言启动后注册的未声明对象的写入被拒。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.tsis the one reading of the declaration (walled/shared/undeclared/tenant-field-unenforceable), the boot refusal (ExternalObjectOrganizationScopeUndeclaredError, fromSecurityPlugin'skernel:readyhandler beside the ADR-0131 D5 refusal; a context without hooks judges at the end ofstart()), and the three ways out as every door spells them.getObjectSecurityMetareads the same predicate into the Layer 0 input and warns once for an object registered after the boot that states nothing.composeWriteTenantWallrefuses 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.tenancy: { enabled: false },systemFields: { tenant: false }) and an ownorganization_idcount. The whole-blocksystemFields: falseis in neither opt-out clause and states nothing; an object with its ownorganization_idis never answeredsharedunless it opted out (systemFields: falsewith an own column readswalled).@objectstack/core—TenantLayer0Input.externalOrganizationScopeUndeclared(optional); set,computeTenantLayer0Verdictanswersdenyunder 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 entryexternal-object-organization-scope-declared, telling an upgrading operator what to declare; its acceptance text names thekernel:ready/ withheld-hooks registrations as met by the denial.examples/app-showcase—showcase_ext_customerandshowcase_ext_orderdeclaretenancy: { enabled: false }(the remote tables carry no organization column).tenancy.tenantFieldon an external object is closed by refusal, not enforcement: the wall keys onorganization_idalone, the one reader of a declared tenant field is storage the platform provisions, andexternal.columnMapalready reaches the wall's column. Enforcing it would need the verdict schema and the deny sentinel to carry a second column.@objectstack/objectql(the engine already withholds the driver's native scope for external objects; the floor re-applies the wall'swhere).Tests
packages/runtime/src/plugin-driver-external-object.spike.test.tsblock (h), underisolatedwith 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 ignoreswherestays walled; a row without the column reads to no one (h1, h2); (h3)tenancy.enabled: falseandsystemFields.tenant: falseobjects 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 403PERMISSION_DENIEDwith 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 foreigntenantField, is refused by name with the three ways out. Blocks (a)–(g) bootsinglewith 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, bothfieldsshapes, the parsed-copy rule,systemFields: falsestating 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, declaredorganization_id,single, local control), the write wall throughwriteWallVerdict(caller with an organization, caller without one,group, shared control,singlecontrol), and the once-only warning.packages/core/src/security/tenant-layer0-verdict.test.ts: the new rung, its order, and the omitted-flag control.federated-tenant-layer0.test.tsandtenant-layer0-verdict-on-operation.test.ts(the undeclared external object now recordsdenyon a delete sweep and is refused on an update sweep), four dogfood files whose premise was the showcase object's injectedorganization_id(it now declares its scope;federated-rls-injectorsgains the after-boot deny case).Suites and typechecks (final trees, under the shared verify lock)
pnpm --filter @objectstack/plugin-security testpnpm --filter @objectstack/core testpnpm --filter @objectstack/runtime testpnpm --filter @objectstack/spec exec vitest run src/migrationspnpm --filter @objectstack/spec check:generatedtypecheck— runtime, core, plugin-securitycheck:test-typecheckOK in each)external-organization-scope+federated-tenant-layer0+tenant-layer0-verdict-on-operationpnpm --filter @objectstack/dogfood test(full battery)554573c4f4: 4 passedOne-shot ablations (mutate → rebuild → dist preflight → red → restore from HEAD → rebuild → preflight absent → green)
external-organization-scope.ts, the throw guarded by a literal the posture can never equal): marker present inplugin-security/dist(preflight exit 0); boot-door pins 4 failed / 12 passed, spike (h6) 1 failed → restored,git diff HEADempty, blob hash equals HEAD, preflight--absentexit 0, pins 16 passed, (h6) 1 passed.tenant-layer0-verdict.ts, the deny guarded the same way): marker present incore/dist; core verdict pins 3 failed / 4 passed, spike (h5) 1 failed → restored (same proofs), 7 passed, (h5) 1 passed.-t h51 failed / 1 passed ((h5b) red, (h5) green), dogfood insert pin 1 failed → restored (same proofs), 5 passed, 2 passed, 1 passed.Acceptance notes
kernel:readyrather than insidestart(): the registry is still filling duringstart()(AGENTS.md, startup registry reads), and the identity-object refusal in the same plugin already judges there. The dispatch said "instart()beside the precedent"; the subscription is placed exactly there.os validatewarning 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.tsandpackages/drivers/driver-sql/src/sql-driver-external-unprovisioned-sort-anchor.test.tscopy 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.test-budgetis 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/objectstackat554573c4f4derived 98 families; every command was run byte-for-byte with its exit captured before any pipe, and--ranreconciled: 98 derived, 98 run, 0 NOT MEASURED, 0 UNRUN.pnpm check:objectql-double-limitwas red on the previous head (the new dogfood driver'sfindignoredlimit); it now honourslimit/offset(554573c4f4), re-run exit 0.