Repository navigation
Commit b61353f
fix(plugin-approvals): an approval's status write-back runs its automation as the person who acted, in the record's organisation (#24634)
Fixes #24519
Clause-②: no (a producer fix inside `@objectstack/plugin-approvals`: no
`@objectstack/spec` key, export, type or stored column moves; ruled by
`6271152775`)
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. Ruling `6271152775` on
#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 merges `origin/main` past it
at `8b118b6b3`. The two changes touch disjoint regions. The PR stays
draft for the seat's review of record.
## What was wrong
`ApprovalService.mirrorStatusField` wrote 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 (report `6270420946`) measured on a real stack what a
`runAs: 'user'` consumer of that write received:
- a `runAs: 'user'` hook kept the carried empty arrays verbatim (the
rule PR #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
in `sys_user`;
- a `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 `single` the driver scopes nothing. Its other claim, that the
automation engine back-fills the run's `tenantId`, was false: the grants
bridge answers a `tenantId` only when it was handed one.
## The change
- **`mirrorStatusField`'s named-actor branch** writes under
`attributedMirrorContext(...)`: `{ isSystem: true, userId, tenantId }`
and nothing else.
- `tenantId` is the subject record's LIVE organisation. It is read at
write time, one by-id read of the organisation column, through
`recordOrgResolver`, 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.
- No `positions` / `permissions`. The write itself is authorized by
`isSystem` alone (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. Every `runAs: 'user'` consumer now resolves the
person's own envelope in that organisation.
- **The user-less branch** (the dead-run sweep) keeps `SYSTEM_CTX`: a
`runAs: 'user'` automation it fires is still refused.
- **Every attributed write acts in the record's organisation,** an
approver drawn from another organisation's directory (ADR-0105 D9)
included. That is the reading PR #24527 applies to a `runAs: 'user'`
decider.
- **The six call sites** each pass the request organisation they already
hold (`org` / `requestOrg`) as the fallback: one argument each.
- **The docblock** is rewritten, the false back-fill sentence included.
- **Untouched**, as the claim fences: `approval-node.ts`, `SYSTEM_CTX`,
the action-link context (Phase 1 measured that no write is made under
any of them), and PR #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
real `ApprovalService` and approval node executor, and driver-sql on
better-sqlite3 `:memory:`. The authz tables the resolver reads are
spelled locally (the `status-mirror-cascade` precedent: 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 a `runAs: 'user'` record-change flow receive equals
`resolveUserAuthzGrants(person, { tenantId: ORG })` (positions,
permission sets, peers), and the write carries `isSystem`, the person,
`ORG` and no arrays:
- open through the approval node (the submitter), approve, reject,
recall, send back, send back past the revision limit, action-link
redeem;
- direction 1: the grant held beyond the baseline is carried (approve,
reject);
- direction 2: the person whose memberships differ carries only the
record organisation's (open, recall);
- the approver from another organisation acts in the record's
organisation, with its members as peers;
- a record re-parented while its request was pending is written, and
cascades, in its new organisation;
- CONTROL: the dead-run sweep stays user-less, the hook is refused
(`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`'s
`runas-user-hook-org-peers.integration.test.ts`).
**Moved pin, one:** `approval-service.test.ts` › `status mirror identity
(#3783)` asserted the mirror carries NO `tenantId`, 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.mjs` on
`approval-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.
- **Leg 1, restore the empty arrays** (`{ ...SYSTEM_CTX, userId,
tenantId }`; blob `9ceb29f2c7f9` → `d2258164b9de`): **9 failed / 1
passed**. Each fails at the hook's envelope: positions and permission
sets `[]` where the person's own were expected.
- **Leg 2, drop the organisation** (`{ isSystem: true, userId }`; blob
`9ceb29f2c7f9` → `3522d1ac69f9`): **9 failed / 1 passed**. Each fails at
the hook's envelope: `tenantId` undefined, peers = the person alone, and
positions and permission sets that are not the record organisation's.
- Each leg restored by the tool: blob after restore `9ceb29f2c7f9` ==
HEAD, `git diff HEAD` empty.
## Tests and gates (final head `8b118b6b3`, which merges `origin/main`
at `65a8eeb7d`)
- `pnpm --filter @objectstack/plugin-approvals test`: 83 files, 1132
tests passed (`TEST_EXIT=0`, under `scripts/pm/os-verify-lock.sh`).
- `pnpm --filter @objectstack/plugin-approvals typecheck`: exit 0;
`check:test-typecheck: OK` (324 errors / 27 signatures held, debt
unchanged).
- Derived gates (`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`.
- On the first pass, `check:query-options-erasure` and
`check:slot-lookup` each 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 typed `ObjectQL`), not by baselining.
- `check:i18n` and `check:dual-build-cjs-loads` first exited 3
(`PREREQUISITE NOT MET`); after a full `turbo run build` both exit 0.
- `node scripts/check-nul-bytes.mjs`: OK.
- CI's families are read from this PR's check-runs.
## Acceptance notes
- **Same family, out of this PR:** `service-automation`'s
`resolveRunDataContext` system arm carries `positions: []` /
`permissions: []` beside a carried `userId`. It is the shape PR #24527's
`runAs: 'system'` post-decision segment writes with. The seat files it
as its own family card under the same rule (ruling `6271152775` item 5).
- **Visible to hook authors:** on a mirror write,
`ctx.session.organizationId` now carries the organisation, and
`ctx.session.positions` is absent instead of `[]`. The changeset says
so.
- **Cost:** one by-id read of the record's organisation column per
named-actor mirror write, skipped for an object with no organisation
column.
- **Walled posture:** naming the record's live organisation scopes the
mirror update to that organisation (and organisation-less rows), which
the record always satisfies. A `runAs: 'system'` cascade of the mirror
is now org-scoped there as well, the #5494 posture user-triggered system
runs already have.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent af1b3f8 commit b61353f
4 files changed
Lines changed: 503 additions & 27 deletions
File tree
- .changeset
- packages/plugins/plugin-approvals/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5121 | 5121 | | |
5122 | 5122 | | |
5123 | 5123 | | |
5124 | | - | |
5125 | | - | |
5126 | | - | |
5127 | | - | |
5128 | | - | |
| 5124 | + | |
| 5125 | + | |
| 5126 | + | |
| 5127 | + | |
| 5128 | + | |
| 5129 | + | |
| 5130 | + | |
| 5131 | + | |
| 5132 | + | |
| 5133 | + | |
| 5134 | + | |
| 5135 | + | |
5129 | 5136 | | |
5130 | | - | |
5131 | | - | |
| 5137 | + | |
| 5138 | + | |
5132 | 5139 | | |
5133 | 5140 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3183 | 3183 | | |
3184 | 3184 | | |
3185 | 3185 | | |
3186 | | - | |
3187 | | - | |
| 3186 | + | |
3188 | 3187 | | |
3189 | 3188 | | |
3190 | 3189 | | |
3191 | 3190 | | |
3192 | | - | |
3193 | | - | |
3194 | | - | |
3195 | | - | |
3196 | | - | |
3197 | | - | |
3198 | | - | |
3199 | | - | |
3200 | | - | |
3201 | | - | |
3202 | | - | |
| 3191 | + | |
| 3192 | + | |
| 3193 | + | |
| 3194 | + | |
| 3195 | + | |
| 3196 | + | |
| 3197 | + | |
| 3198 | + | |
| 3199 | + | |
| 3200 | + | |
| 3201 | + | |
| 3202 | + | |
| 3203 | + | |
| 3204 | + | |
| 3205 | + | |
| 3206 | + | |
| 3207 | + | |
| 3208 | + | |
| 3209 | + | |
| 3210 | + | |
| 3211 | + | |
| 3212 | + | |
| 3213 | + | |
| 3214 | + | |
| 3215 | + | |
| 3216 | + | |
| 3217 | + | |
| 3218 | + | |
| 3219 | + | |
| 3220 | + | |
| 3221 | + | |
| 3222 | + | |
| 3223 | + | |
| 3224 | + | |
| 3225 | + | |
| 3226 | + | |
| 3227 | + | |
| 3228 | + | |
| 3229 | + | |
3203 | 3230 | | |
3204 | 3231 | | |
3205 | 3232 | | |
3206 | 3233 | | |
3207 | 3234 | | |
3208 | 3235 | | |
3209 | 3236 | | |
| 3237 | + | |
3210 | 3238 | | |
3211 | 3239 | | |
3212 | | - | |
| 3240 | + | |
| 3241 | + | |
| 3242 | + | |
3213 | 3243 | | |
3214 | 3244 | | |
3215 | 3245 | | |
3216 | 3246 | | |
3217 | 3247 | | |
3218 | 3248 | | |
| 3249 | + | |
| 3250 | + | |
| 3251 | + | |
| 3252 | + | |
| 3253 | + | |
| 3254 | + | |
| 3255 | + | |
| 3256 | + | |
| 3257 | + | |
| 3258 | + | |
| 3259 | + | |
| 3260 | + | |
| 3261 | + | |
| 3262 | + | |
| 3263 | + | |
| 3264 | + | |
| 3265 | + | |
| 3266 | + | |
| 3267 | + | |
| 3268 | + | |
| 3269 | + | |
| 3270 | + | |
| 3271 | + | |
| 3272 | + | |
| 3273 | + | |
| 3274 | + | |
| 3275 | + | |
| 3276 | + | |
3219 | 3277 | | |
3220 | 3278 | | |
3221 | 3279 | | |
| |||
3697 | 3755 | | |
3698 | 3756 | | |
3699 | 3757 | | |
3700 | | - | |
| 3758 | + | |
3701 | 3759 | | |
3702 | 3760 | | |
3703 | 3761 | | |
| |||
4008 | 4066 | | |
4009 | 4067 | | |
4010 | 4068 | | |
4011 | | - | |
| 4069 | + | |
4012 | 4070 | | |
4013 | 4071 | | |
4014 | 4072 | | |
| |||
4498 | 4556 | | |
4499 | 4557 | | |
4500 | 4558 | | |
4501 | | - | |
| 4559 | + | |
4502 | 4560 | | |
4503 | 4561 | | |
4504 | 4562 | | |
| |||
4837 | 4895 | | |
4838 | 4896 | | |
4839 | 4897 | | |
4840 | | - | |
| 4898 | + | |
4841 | 4899 | | |
4842 | 4900 | | |
4843 | 4901 | | |
| |||
4879 | 4937 | | |
4880 | 4938 | | |
4881 | 4939 | | |
4882 | | - | |
| 4940 | + | |
4883 | 4941 | | |
4884 | 4942 | | |
4885 | 4943 | | |
| |||
6552 | 6610 | | |
6553 | 6611 | | |
6554 | 6612 | | |
6555 | | - | |
| 6613 | + | |
6556 | 6614 | | |
6557 | 6615 | | |
6558 | 6616 | | |
| |||
0 commit comments