Repository navigation
fix(plugin-security): under group, a no-active-organization insert gets the ADR-0123 D2 403, not a 500 - #24819
Conversation
…rganization insert Reads red on the base: group answers ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED/500 where isolated answers PERMISSION_DENIED/403 for the same context. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
…n gets the ADR-0123 D2 403 Under group, a member whose session has no active organization passed the Layer 0 scope pre-test (it reads the membership set) and the engine then refused the row as an organization-less system write with a 500. The wall now refuses that insert on its stored image with the same answer isolated gives. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
…oup-no-active-org-write-403
…oup-no-active-org-write-403
📓 Docs Drift CheckThis PR changes 1 package(s): 4 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 3fd0e664c69869edfd7c4eecf9e54e4ca8b99ee0 && git checkout 3fd0e664c69869edfd7c4eecf9e54e4ca8b99ee0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c56115bf962cb301fb046c54680620d37e5ab2fb 94f661fbeab27684553f418c182d46fb8e700dcd && git checkout -B drift-repro c56115bf962cb301fb046c54680620d37e5ab2fb && git merge --no-ff 94f661fbeab27684553f418c182d46fb8e700dcd
node scripts/docs-audit/affected-docs.mjs --json c56115bf962cb301fb046c54680620d37e5ab2fb
|
席位核对 · ACCEPT ·
|
…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 #24784
Clause-②: no
What changed
On the
grouptenancy posture (any union-scope posture), a member whose session has no active organization sent a create on a tenant-scoped object. The Layer 0 write wall inplugin-securitylet it through: its scope pre-test (callerHasOrganizationScope) reads the membership set under a union-scope posture, and a member's set is not empty. But a new row lands in the active organization (ADR-0105 D5), and there was none. So the engine met the row as an organization-less system write and refused it with500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED. Nothing was written.The wall now refuses that insert itself, with the ADR-0123 D2 answer
isolatedalready gave the same session:403 PERMISSION_DENIED, "[Security] Access denied: 'OBJECT' is scoped to an organization, and this session has no active organization — so this insert has no organization to place the record in. Join or select an active organization and retry."packages/plugins/plugin-security/src/security-plugin.ts, the step 3.7 write wall:carriesActiveOrganization(context)is new, besidecallerHasOrganizationScope. The non-union branch ofcallerHasOrganizationScopenow calls it, with the same answer as before.refuseNoActiveOrganization. The scope pre-test throws it as before, with the same message, details and operator text. The new arm throws it too, with its own operator text, which says what the context does and does not carry under this posture.insert, under a union-scope posture, when the caller's own Layer 0 applies to the object and the context carries notenantId. It judges the stored image, in the post-hook seam the engine already runs (postHookWriteImageCheck). That is the row after thebeforeInsertchain, the same rows the engine's own organization check (resolveSystemInsertOrganization) reads one step later. It runs first in that seam, so the session's state is the reason reported.canWriteObjectdoc names the new arm among the post-image refusals it does not judge.no-active-organization-write-refusal.test.ts: the control "a NON-EMPTY membership set lands, even with no active organization" pinned exactly the branch this card changes. Its terminal stands in for the engine, so it never met the 500. It is replaced by the refusal case and its single-fact control: the same set with an active organization lands.no-active-organization-insert-union-scope.test.tsis new. It runs on the real stack: ObjectQL, SqlDriver and SecurityPlugin, booted the waytenant-wall-post-hook-image.test.tsboots them.Why the stored image, and not
organizationWallRefusalI measured the base before choosing. Under
grouptoday, an insert whose own appbeforeInserthook places the row in an organization the membership wall admits is admitted, whileisolatedrefuses it ahead of the hooks. A row-less change to the predicate inorganizationWallRefusalwould refuse it too. That turns a 2xx into a 403, which is outside "the change turns a 500 into the designed 403". The stored image asks exactly the engine's question, so every answer this PR changes was a 500 before.The cost:
canWriteObjectis asked about no row. It keeps answeringtruefor this caller's create preview, as it did before. Its doc already lists post-image refusals as outside its answer, and now names this arm there.Readings: real stack, in process
A member of
org_aandorg_c, no active organization, on a public tenant object. Basea43405d596, fixcca0ba958a:groupbeforegroupafterisolatedbefore and afterERR_SYSTEM_WRITE_ORGANIZATION_REQUIREDPERMISSION_DENIEDPERMISSION_DENIEDorg_aorg_aThe engine's static read-only strip drops a session payload's
organization_idon insert. So a payload value never decides where a session's new row lands, before or after this change.Which verbs ADR-0123 D2 and the checklist item cover
access-security.no-active-org-session-semantics: acceptance[1] and step 3 test a create, and acceptance[5], acceptance[6] and negative[3] require no 500 anywhere. Covered for insert, single and bulk.groupit states the scope as the membership set. A no-active-org member's update of a row that set admits answers 200 before and after, and the row keeps its own organization. Nothing has nowhere to land, so it is not changed.Tests (head
94f661fbea, the merge oforigin/main307ce4c4b4)a43405d596, before the fix: 4 failed, 2 passed.groupansweredERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED/ 500 whereisolatedansweredPERMISSION_DENIED/ 403. The fourth failure was the active-org control's sort order in the test itself, fixed before the fix commit.pnpm --filter @objectstack/plugin-security test: 193 files passed, 4013 passed, 45 skipped.pnpm --filter @objectstack/plugin-security typecheck: exit 0, andcheck:test-typecheckis OK.tsc -p tsconfig.test.json --listFileslists both test files among the package's test files.&&-joined command under the verify lock, after the closure build (turbo,--filter='@objectstack/plugin-security...', 19/19 tasks). The lock printedVERDICT command-exit 0.Ablation, on the committed fix, run on
cca0ba958aand again on94f661fbea.node scripts/ablation-replace.mjsreplaced the arm's last conjunct with a constant false, which puts the old predicate back:0f12f626to4c7f7ebfon the final head).groupcase receivedERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED, the equality-with-isolatedcase failed, and the unit case was not refused. Every control stayed green.git checkout HEAD -- PATH: the blob equals HEAD's, andgit diff HEADandgit statusare empty.Gates (local, head
94f661fbea)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, with no paths, derived 69 commands for this diff. All 69 exited 0, and each exit was captured before any pipe.--ranover that record answered "69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN" and exited 0.check:i18nandcheck:dual-build-cjs-loadsfirst answered exit 3 (PREREQUISITE NOT MET, nothing measured) on an earlier head. Both measured green once the builds they name existed.pnpm lintfirst),test:repo, Test Core, Build Core and the Type Check lanes. The main-only battery (dogfood, Temporal Conformance) runs after merge. This PR's check-runs decide them.origin/mainwas 6 commits past this head's merge base when it last ran. The queue rebuilds on currentmain.Acceptance notes
group, 'no organization scope' means an empty membership set". That sentence now describes the scope pre-test only. A union-scope insert with no active organization is also refused, with the same answer. This is the card's ruled answer (triage record 6285293541). An ADR addendum or status-line amendment is a governed docs change, not part of this PR.500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIREDunder bothgroupandisolated. This is an in-process reading only, not measured at the HTTP door. The card's second candidate, the engine mapping a non-system caller to a 4xx, would cover it.group, a member with active organizationorg_awhose payload namesorg_cfrom the same set gets a 2xx, and the row is stored inorg_a. The payload value is dropped by the engine's read-only strip, not refused. The organizations runtime's stamping comment says a supplied value is admitted or refused, never rewritten.packages/objectql/src/engine.tsstill says, at the insert post-image seam, that the Layer 0 tenant wall judges only the pre-hook image. That has been stale since the wall gained its stored-image judgement. It is out of bounds for this PR.organization_idrows from framework writers and remains open. This PR changes none of them.Generated by Claude Code