Repository navigation
feat(plugin-security)!: a flow that runs as system writes no identity assignment - #25483
Conversation
… assignment The data middleware refuses a governed write to sys_user_position, sys_user_permission_set, sys_invitation or sys_member from a context that carries isSystem and a flowRunId, ahead of its isSystem bypass, with PERMISSION_DENIED and the remedy (runAs 'user' for grants, the organization endpoints for invitations and memberships). System writers without a run, reads, other objects and runAs 'user' runs are unchanged; a runAs 'user' run reaches the delegated-administration gate as the user it acts for. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAdmc3d4UL4jFaffAor6eu
… write refusal check-system-context-census holds every elevation read site to a row on the page; the new refusal reads isSystem, so it gains row 1b. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAdmc3d4UL4jFaffAor6eu
…non-member Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAdmc3d4UL4jFaffAor6eu
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAdmc3d4UL4jFaffAor6eu
📓 Docs Drift CheckThis PR changes 2 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 13 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 19 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 b2205d81b939db54e4b3f7dfee54668a084ee22a && git checkout b2205d81b939db54e4b3f7dfee54668a084ee22a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ea33919f56a9879f95a00d45418385ff7dbb974f 648d903049c0a2f64baa919581404c5dab393d3f && git checkout -B drift-repro ea33919f56a9879f95a00d45418385ff7dbb974f && git merge --no-ff 648d903049c0a2f64baa919581404c5dab393d3f
node scripts/docs-audit/affected-docs.mjs --json ea33919f56a9879f95a00d45418385ff7dbb974f
|
Contract reviewServed-tier: ① Derived judgments
② Semver level
ADR-0087 marker: ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 · ACCEPT · 2026-10-23T16:26Z
Generated by Claude Code |
Requested by Sales · project thread
Part of #25251
Clause-②: no (narrowing)
Metadata-protocol: semantic
This is PR1 of ruling 6354966423 (letter B): the run-time arm, in plugin-security. PR2, the
runAsdescribe sentence and the ADR-0087 D3 semantic entry inpackages/spec, belongs to the spec lane and is handed to its seat; the card stays open for it.What changed
The data middleware refuses one system context ahead of its
isSystembypass: a context that carriesisSystemand aflowRunId, which is what arunAs: 'system'flow's data nodes write under (resolveRunDataContext, service-automation). Its governed writes to the four identity-assignment objects are refused withPERMISSION_DENIED(403) and nothing is written.sys_user_position,sys_user_permission_set,sys_invitation,sys_memberisSystem+flowRunId(runAs: 'system'flow, user-triggered or scheduled)isSystem, noflowRunId(seeders, better-auth and its reconciliation, invitation placement, the organization-administrator grant)flowRunId, noisSystem(runAs: 'user'flow)flow-system-identity-write-refusal.ts:assertFlowSystemIdentityWriteAllowed(opCtx)and the object set. The predicate is the ruling's,isSystemANDflowRunId, read by truthiness as the bypass readsisSystem. The verbs are the D12 gate's ownGOVERNED_OPERATIONS, now exported fromdelegated-admin-gate.tsso that a verb added there is refused here too.runAsto'user', so the gate judges the acting user and records them asgranted_by. For invitations and memberships it is the organization endpoints (invite-member,cancel-invitation,update-member-role,remove-member), which apply the invitation's placement and role-cap checks.security-plugin.ts: one import and one call in the data middleware, immediately before// System operations bypass security. Nothing else in the file moves.delegated-admin-gate.tsheader andGRANTED_BY_OBJECTSdocblock, which said every system write short-circuits before the gate, and theresolveRunDataContextdocblock sentence in service-automation that said a system run "reads/writes with full access".content/docs/permissions/system-context.mdxgains row 1b, becausecheck-system-context-censusholds everyisSystemread site to a row on the page.@objectstack/plugin-securityminor, with the BREAKING banner (FROM / TO / Unchanged / Fix) and the ADR-0087not-required (no-migration-prescription)marker. The semantic entry itself is PR2's.Census (taken before any edit)
create_record,update_record,delete_recordandupsert_recordnode literals in objectstack atb2c075290and hotcrm atc14ffde5f(TS, JS, JSON, YAML, MD, MDX;node_modulesanddistexcluded): 317 nodes, 0 aimed at any of the four objects. 78 carry no literal target near the node literal; each was read by hand and none names the four. The count is zero, so nothing is worked around.Tests
plugin-security/src/flow-system-identity-write-refusal.test.ts(44 cases). Most drive the realSecurityPluginmiddleware over a realObjectQLengine and a real SQL driver, and read the stored rows back:GOVERNED_OPERATIONSon each of the four objects.transfer,restoreandpurgehave no engine verb to drive.code,status,details) and the store does not move.{ isSystem: true }inserts, updates and deletes each object.sys_business_unit, under the flow's system context.runAs: 'user'context: a tenant-level administrator's grant is admitted and storesgranted_by= that user. A plain member's is refused by the D12 gate (its own message,details.userId= the member), not by this refusal.service-automation/src/runas-system-identity-write-refusal.integration.test.ts(9 cases): real kernel,ObjectQLPlugin, driver-sql, the identity objects, the realSecurityPluginand the realAutomationEngineCRUD nodes.runAs: 'system'flow'screate_recordon each of the four objects, user-triggered and scheduled, fails withinnerRefusal.codePERMISSION_DENIEDand no row is written. Itsupdate_recordanddelete_recordon a grant and on a membership are refused too, and the rows do not move.runAs: 'user'flow triggered by a tenant-level administrator grants the set, withgranted_by= that administrator. Triggered by a plain member, the D12 gate refuses it.runAs: 'system'flow still reads the four and still writes an ordinary object.Pins describe behaviour only.
Verification
pnpm --filter @objectstack/plugin-security --filter @objectstack/service-automation test(under the verify lock, VERDICTcommand-exit 0): plugin-security 215 files, 4509 passed and 45 skipped; service-automation 199 files, 2628 passed.pnpm typecheckin both packages: exit 0, includingcheck:test-typecheck(both test layers compile, 0 pinned signatures).eslint --no-inline-configon the six changed TypeScript files: exit 0.scripts/ablation-replace.mjs, one behaviour): the middleware's call was disabled behind a planted marker, which was proven in the source (anchor 1 → 0) and indist/(ablation-dist-preflight, 2 built files) before the service-automation leg ran, because that suite reads plugin-security fromdist/.runAs: 'user'cases stay green, as expected.runAs: 'system'creates and the update and delete case. TherunAs: 'user'cases and both controls stay green.git diff HEADempty, rebuilt, marker absent from all 6 built files, and both pins green again (44/44, 9/9).dispatch-gates --commands, 70 families): on the first pass 67 exited 0. The other three:check-system-context-census: fixed. The new module readsisSystemand had no row; row 1b is added and the gate exits 0.check-plugin-teardown-shape --self-test: exit 3, because the clone was shallow. Deepened withgit fetch --unshallow origin main.check:dual-build-cjs-loads: exit 3, because 7 prerequisite dists were missing. They are now built.(narrowing)arm and the!) are running now. Their counts go in theos-dev-reporton Security: a runAs system flow writes identity-assignment rows (sys_user_position, sys_invitation) past the delegated-administration gate #25251.Acceptance notes
claude/project-thread-t123rb, notclaude/issue-25251-…. The claim comment is the identity record.delegated-admin-gate.tswas claimed as comment only; it also gainsexportonGOVERNED_OPERATIONS(and aReadonlySettype), so that both checks read one verb list. The package index is unchanged.content/docs/permissions/system-context.mdxwas out of the claimed surface; the census gate requires the row.set_nullleg of a system flow's delete of a parent row, and a hook body'sctx.apiwrite, are refused the same way. The census found no system flow that deletes a parent of the four objects. Exempting nested writes would let a hook body reopen what the ruling closes.runAs: 'system'flow "stays god-mode throughout".docs/adris outside this card's surface, and the precedent run-time PR (fix(service-automation): flow write nodes refuse a stored-metadata family target (#21624) #21649) did not amend its ADR either. The spec lane's PR2 or a separate ADR edit can carry it.security-plugin.tsand the same docs page; its hunks insecurity-plugin.tsare elsewhere. Whichever lands second mergesmain.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_01YAdmc3d4UL4jFaffAor6eu