Repository navigation
fix(plugin-approvals): an approval's status write-back runs its automation as the person who acted, in the record's organisation - #24634
Conversation
…isation and carries no grants of its own
A named actor's mirror write now carries { isSystem, userId, tenantId }
with tenantId read live from the subject record (the request's
organisation for an object with none), and no positions / permissions,
so a runAs:'user' hook or record-change flow it fires resolves the
actor's own request envelope in that organisation.
Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s
Co-authored-by: Claude <noreply@anthropic.com>
…nAs:'user' cascade, door by door Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude <noreply@anthropic.com>
…oducer's write shape Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude <noreply@anthropic.com>
…ion as the actor in the record's organisation Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude <noreply@anthropic.com>
…s effect Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude <noreply@anthropic.com>
…anisation, not its absence Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude <noreply@anthropic.com>
…gine lookup Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude <noreply@anthropic.com>
…provals-context-identity
📓 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:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 6 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 b17aecbd1ec03aaab04161f8f618faf2bcd518d2 && git checkout b17aecbd1ec03aaab04161f8f618faf2bcd518d2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 973917c5795f7a440d46a15bc5cf8961af9d8703 8b118b6b3672586c68e4fe31ef4146aaf7fcaec9 && git checkout -B drift-repro 973917c5795f7a440d46a15bc5cf8961af9d8703 && git merge --no-ff 8b118b6b3672586c68e4fe31ef4146aaf7fcaec9
node scripts/docs-audit/affected-docs.mjs --json 973917c5795f7a440d46a15bc5cf8961af9d8703
|
席位核对ACCEPT · #24519 ·
Generated by Claude Code |
Fixes #24519
Clause-②: no (a producer fix inside
@objectstack/plugin-approvals: no@objectstack/speckey, export, type or stored column moves; ruled by6271152775)The status write-back an approval makes (
approvalStatusField) now runs the automation it fires as the person who acted, with that person's own request envelope in the record's organisation. Ruling6271152775on #24519 (maintainer: Gap 1 「A 带记录组织」, the cross-organisation approver 「x 按记录组织」, the live widening 「随 #24519 一起修」).Landing order. The claim made this PR wait for PR #24527 (#24321), which edits the same file at the decision to resume hand-off. PR #24527 has merged (
1b4078077), and this branch mergesorigin/mainpast it at8b118b6b3. The two changes touch disjoint regions. The PR stays draft for the seat's review of record.What was wrong
ApprovalService.mirrorStatusFieldwrote as{ isSystem: true, userId, positions: [], permissions: [] }, with no organisation, on every door that names a person: submit (through the approval node), approve, reject, recall, send back (returned and auto-reject), and the action link. Phase 1 (report6270420946) measured on a real stack what arunAs: 'user'consumer of that write received:runAs: 'user'hook kept the carried empty arrays verbatim (the rule PR fix(objectql): a runAs:'user' hook sees its organization's members in sys_user, as the user's own request does #24518 keeps for a narrowed ceiling), so it ran on the member baseline instead of the person's own grants, and it saw only the person insys_user;runAs: 'user'record-change flow fired by the write got no organisation, so its grants resolved organisation-less, which is not the person's request in the record's organisation.The docblock's reason for leaving the organisation off no longer held: since #10101 the request's organisation is the subject record's, and under
singlethe driver scopes nothing. Its other claim, that the automation engine back-fills the run'stenantId, was false: the grants bridge answers atenantIdonly when it was handed one.The change
mirrorStatusField's named-actor branch writes underattributedMirrorContext(...):{ isSystem: true, userId, tenantId }and nothing else.tenantIdis the subject record's LIVE organisation. It is read at write time, one by-id read of the organisation column, throughrecordOrgResolver, the resolver the request row was stamped by. The request's organisation is the fallback for an object (or a record) with none, as at the stamp.positions/permissions. The write itself is authorized byisSystemalone (the security middleware short-circuits before it reads a grant), so it still crosses the record lock and still lands a read-only status column. EveryrunAs: 'user'consumer now resolves the person's own envelope in that organisation.SYSTEM_CTX: arunAs: 'user'automation it fires is still refused.runAs: 'user'decider.org/requestOrg) as the fallback: one argument each.approval-node.ts,SYSTEM_CTX, the action-link context (Phase 1 measured that no write is made under any of them), and PR feat(automation)!: an approval decision resumes a flow as the person who decided #24527's resume hand-off.Pins
packages/plugins/plugin-approvals/src/status-mirror-envelope.integration.test.ts(new, 10 cases). It uses a real kernel, the real ObjectQL engine and hook wrappers, the real record-change trigger and automation engine, the realApprovalServiceand approval node executor, and driver-sql on better-sqlite3:memory:. The authz tables the resolver reads are spelled locally (thestatus-mirror-cascadeprecedent: no new dependency, no lockfile change), and the security catalog is bound with the package's test kit. The fixture is synthetic: two organisations; a person whose memberships differ between them; a person holding a grant the baseline lacks; an approver from the other organisation.For each door that names a person, the envelope a
runAs: 'user'hook and arunAs: 'user'record-change flow receive equalsresolveUserAuthzGrants(person, { tenantId: ORG })(positions, permission sets, peers), and the write carriesisSystem, the person,ORGand no arrays:HOOK_UNSCOPED_DATA_ACCESS), and the flow runs with no person.What the security plugin then enforces for an envelope stays pinned where the seed lives (
service-automation'srunas-user-hook-org-peers.integration.test.ts).Moved pin, one:
approval-service.test.ts›status mirror identity (#3783)asserted the mirror carries NOtenantId, which is the contract the ruling reverses. It now asserts the full context,{ isSystem: true, userId: 'u1', tenantId: 't1' }, on that suite's schema-less engine double (the request's organisation as the fallback). The other nine pins of that describe are unchanged and green. Kin sweep: no other package test reaches the real mirror.Ablation (fix committed first;
scripts/ablation-replace.mjsonapproval-service.ts; the suite imports the service from source, so no dist leg)Both legs were predicted before running: 9 red (every door that names a person), the CONTROL green.
{ ...SYSTEM_CTX, userId, tenantId }; blob9ceb29f2c7f9→d2258164b9de): 9 failed / 1 passed. Each fails at the hook's envelope: positions and permission sets[]where the person's own were expected.{ isSystem: true, userId }; blob9ceb29f2c7f9→3522d1ac69f9): 9 failed / 1 passed. Each fails at the hook's envelope:tenantIdundefined, peers = the person alone, and positions and permission sets that are not the record organisation's.9ceb29f2c7f9== HEAD,git diff HEADempty.Tests and gates (final head
8b118b6b3, which mergesorigin/mainat65a8eeb7d)pnpm --filter @objectstack/plugin-approvals test: 83 files, 1132 tests passed (TEST_EXIT=0, underscripts/pm/os-verify-lock.sh).pnpm --filter @objectstack/plugin-approvals typecheck: exit 0;check:test-typecheck: OK(324 errors / 27 signatures held, debt unchanged).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths, after the last commit): 69 commands, all 69 exit 0.--ran:69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN.check:query-options-erasureandcheck:slot-lookupeach flagged one new erasure in this diff. Both were fixed by typing (the record read no longer casts its options; the pin's engine lookup is typedObjectQL), not by baselining.check:i18nandcheck:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET); after a fullturbo run buildboth exit 0.node scripts/check-nul-bytes.mjs: OK.Acceptance notes
service-automation'sresolveRunDataContextsystem arm carriespositions: []/permissions: []beside a carrieduserId. It is the shape PR feat(automation)!: an approval decision resumes a flow as the person who decided #24527'srunAs: 'system'post-decision segment writes with. The seat files it as its own family card under the same rule (ruling6271152775item 5).ctx.session.organizationIdnow carries the organisation, andctx.session.positionsis absent instead of[]. The changeset says so.runAs: 'system'cascade of the mirror is now org-scoped there as well, the Automationcreate_recordunderrunAs:'system'inserts rows withowner_id/organization_id/created_byall NULL — records born untouchable even by admin #5494 posture user-triggered system runs already have.Generated by Claude Code