Repository navigation
fix(plugin-security): a platform operator's insert with no active organization gets the ADR-0123 D2 403, not a 500 - #24868
Conversation
…on insert on a posture-permitting object, per walled posture Real stack (ObjectQL + SqlDriver + SecurityPlugin), group and isolated. On this commit the operator's insert answers 500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED (4 cases red); the member's D2 403 on the same object is the control, and the unchanged neighbours are green. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
…anization gets the ADR-0123 D2 403, not the engine's 500 A true PLATFORM_ADMIN crosses the Layer 0 wall on a posture-permitting object (ADR-0095 D3), so neither the D2 pre-test nor the union-scope insert arm reached it, and its organization-less insert met the engine's system-write refusal (ADR-0131 D9) as a 500. Step 3.7 now judges that insert's stored image and refuses a row with nowhere to land with the member's answer. The crossing fact is read from the one Layer 0 ladder, asked again with only the exemption withheld. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
…organization insert refusal Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
…erator-no-org-insert
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 15 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 4bb57ba2dd4129ddafc00eae9accd75910c42f8a && git checkout 4bb57ba2dd4129ddafc00eae9accd75910c42f8a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6b9e6df909dade0e2e2f2e4ee90de22fefff2a91 d366c1712d1d9f3d979bbea5293e5870c7ebe5d1 && git checkout -B drift-repro 6b9e6df909dade0e2e2f2e4ee90de22fefff2a91 && git merge --no-ff d366c1712d1d9f3d979bbea5293e5870c7ebe5d1
node scripts/docs-audit/affected-docs.mjs --json 6b9e6df909dade0e2e2f2e4ee90de22fefff2a91
|
席位核对ACCEPT · #24821 ·
Generated by Claude Code |
…s — the union-scope insert and the crossing operator's ADR-0123 D2's scope paragraph and its "What does not change" line were narrower than the runtime after PR #24819 (8a058d8) and PR #24868 (98c3d00). A dated amendment in the status block names both arms and their reasons (ADR-0105 D5; ADR-0095 D3 with ADR-0131 D9); the scope paragraph keeps its text with a dated pointer, and the "Platform operators" entry is struck and corrected in place. Claude-Session: https://claude.ai/code/session_01M82feuetLzrCiBz41BJb7L Co-authored-by: Claude <noreply@anthropic.com>
Fixes #24821
Clause-②: no
What changed
A platform operator (the
PLATFORM_ADMINrung,admin_full_access) whose session has no active organization sent a create on a tenant object whose posture lets the operator cross the organization wall (access.default: 'private', ADR-0095 D3). Undergroupandisolatedit got500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED. It now gets the answer a member gets in the same state:403 PERMISSION_DENIED, with a message that names the missing active organization (ADR-0123 D2/D4).The fix is in
plugin-security's Layer 0 write wall,security-plugin.tsstep 3.7, and nowhere else:nullon such an object. So neither the D2 pre-test (organizationWallRefusal) nor the union-scope insert arm from group posture: a member whose session has no active organization gets 500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED on a tenant-scoped create, where isolated answers 403 naming the missing organization #24784 reached it. The row then met the engine's organization check (resolveSystemInsertOrganization, ADR-0131 D9) as an organization-less system write, which refuses with a fixed 500 and keeps the prose off the wire.tenantIdis judged on its stored image, in the samepostHookWriteImageCheckseam and with the same D2 throw (refuseNoActiveOrganization) as the group posture: a member whose session has no active organization gets 500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED on a tenant-scoped create, where isolated answers 403 naming the missing organization #24784 arm. The seam runs after thebeforeInsertchain and before the engine's organization check. One row with no organization refuses the whole write.computeLayeredRlsFilternow also returnscrossedByPlatformAdmin. It asks the one Layer 0 ladder (computeTenantLayer0Verdict) a second time with onlyisPlatformAdminwithheld: the verdict isnone, and it would not benonewithout the exemption. No rung of the ladder (posture, tenant object, phantom anchor, tenancy opt-out) is re-derived.computeWriteTenantWallhands step 3.7 the filter and the fact from one computation.organizationWallRefusal,callerHasOrganizationScope, the union-scope arm, the forge guard,tenant-layer.ts, and everything inpackages/objectql. No new error code. The engine's 500 still answers system contexts, which are what it was written for.Why this answer (governing text)
single… and refused otherwise, with a message naming what is missing (ADR-0123 D4). ⛔ Nothing defaults to any owner other than the derivable one." No governing text lands an operator's organization-less insert anywhere. I searched ADR-0123, ADR-0131 (D5, D9, D12 item 12) and the security plugin's docblocks. So the insert was already refused; this changes the answer, not the outcome.[#12974]note incomputeLayeredRlsFilteralready records an org-less write by the platform owner as "REFUSED 403 … (500 where the 403 refusal belongs)".docs/adr/**is Tier H).Measured (real stack: ObjectQL + SqlDriver + SecurityPlugin)
Before, at
4be728131(the pin alone, committed as743cacb33and unchanged since):Tests 4 failed | 12 passed (16). The four failures, under bothgroupandisolated, were the single and bulk operator inserts:expected 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED' to be 'PERMISSION_DENIED'. Every control was already green.After: the operator's insert gets exactly the member's outcome on the same object (
code,statusand message compared whole), and nothing is stored. These are unchanged, each pinned per posture:beforeInserthook places in an organization is admitted (the stored image is judged, not the session);500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED.Also measured at base (scratch file, not committed): a payload that names
organization_idis stripped by the engine for a session on a business object, so the operator's org-less insert naming one was a 500 as well. It is now the same 403.Tests
no-active-organization-insert-platform-operator.test.ts: the real-stack pin above, one block per posture, with the member's D2 403 as the control.no-active-organization-write-refusal.test.ts: its control "a true PLATFORM operator crosses the wall on a posture-permitting object" pinned exactly the changed branch, an org-less insert passing the middleware. In the real stack that insert answered 500. It is re-judged as follows:d366c1712(the merge oforigin/mainabd5acdaf), one locked run: closure build OK;pnpm --filter @objectstack/plugin-security test:Test Files 196 passed (196),Tests 4066 passed | 45 skipped (4111);typecheckexit 0 (check:test-typecheck: OK … 0 error(s)).tsc -p tsconfig.test.json --listFilescompiles both edited test files (196 test files in all).Ablations (each one committed first, mutated through
scripts/ablation-replace.mjsin a script with a restore trap, and restored to the HEAD blob withgit diff HEADempty)No build or
dist/leg was needed: the pins import./security-plugin.jsrelatively, and objectql and driver-sql are source aliases in this package's vitest config.callerCrossesTenantWall &&was replaced by/* ABLATION-24821 */ false &&(anchor x1 to x0, marker x0 to x1, blob25d6a049to9786d9c1). Result:Tests 5 failed | 34 passed (39), in the predicted direction (red). The four real-stack refusals failed withReceived: "ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED", and the unit refusal failed withthe write was not refused at all. Every control stayed green.noneverdict for an operator counts as a crossing). The second-ladder comparison was replaced by/* ABLATION-24821-B */ true;(blob25d6a049tof9b75967). Result:Tests 2 failed | 37 passed (39), in the predicted direction: the opted-out-of-tenancy control under both postures (expected the write to be admitted).Gates (local, head
d366c1712)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths) derived 69 commands from the 4 changed paths against merge baseabd5acdaf. All 69 exit 0, each code captured before any pipe.check:dual-build-cjs-loadsandcheck:i18nfirst answered exit 3PREREQUISITE NOT MET, which is not a measurement. After a workspace build (turbo run build --filter=!@objectstack/docs, 76/76) both measured exit 0.--ran:69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN. The repo-wide runs (pnpm lint,test:repo) are CI's.Acceptance notes (observed, not fixed here)
ADR-0123 D2's scope sentence now under-describes the runtime. It still says a platform operator crossing the wall is outside the refusal. Carrier: ADR-0123 D2 says "under group, no organization scope means an empty membership set"; since PR #24819 a union-scope insert with no active organization is refused too, so the ADR under-describes the runtime #24823 (its amendment note can name this arm next to the union-scope one). See the open question in the report.
A platform operator's cross-organization by-id update on a posture-permitting object answers 404, while the same caller reads the row. Measured in-process at
4be728131onqa_vault(private), row inorg_b:groupwith no active organization:404 RECORD_NOT_FOUND;groupwith activeorg_a:404;isolatedwith activeorg_a:404;isolatedwith no active organization:200.findOnereturns the row in all four. This looks like the engine's by-id write scope being computed over its own facts (no platform-admin fact), against ADR-0131 D8's one predicate. Reach was not measured at a public door. Carrier: none.The engine's organization check has no
isSystemtest. Any non-system caller that reaches it without an organization still gets the system-write 500. After group posture: a member whose session has no active organization gets 500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED on a tenant-scoped create, where isolated answers 403 naming the missing organization #24784 and this PR the wall covers members and crossing operators on every object the wall itself recognises as a tenant object. Populations it may not cover, all unmeasured:tenancy.tenantFieldis notorganization_id;systemFields.tenant: falsetogether with an explicitorganization_idcolumn;organization_id;This is the card's second candidate, which is
domain:engine's. Carrier: none.canWriteObject's create preview. It is row-less, so for this operator it still answerstrue(as it did while the write answered 500). The same boundary group posture: a member whose session has no active organization gets 500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED on a tenant-scoped create, where isolated answers 403 naming the missing organization #24784 recorded.Generated by Claude Code