Repository navigation
Commit f72ce93
fix(service-automation): a runAs:'user' flow sees its organization's members in sys_user, as the user's own request does (#24495)
Fixes #24448
Clause-②: yes (widening: the exported `RunIdentityContext` /
`RunDataContext` type gains the optional `org_user_ids` member, the key
`ExecutionContext` already declares; no accepted input moves)
## What was wrong
A `runAs: 'user'` flow's data context carried no `org_user_ids`.
`sys_user`'s shipped member scope is two policies in `member_default`:
`sys_user_self` (`id == current_user.id`) and `sys_user_org_members`
(`id in current_user.org_user_ids`). With the peer list absent, the
second compiles `unresolved-variable` and drops out. So a `get_record`
on `sys_user` in such a flow answered the acting user's own row only,
while the same user's request answered their organization's members too.
## Failing probe first
`packages/services/service-automation/src/runas-user-org-peers.integration.test.ts`
boots the real stack: ObjectKernel, ObjectQLPlugin, driver-sql on
better-sqlite3 `:memory:`, the identity objects, the real SecurityPlugin
with its shipped default permission sets, and the real AutomationEngine
with its CRUD nodes. Fixture: organization A (the Default Organization)
and organization B. `usr_me` belongs to A and B and acts in A;
`usr_peer` belongs to A; `usr_other` belongs to B only; `usr_loner`
belongs to none.
Committed alone as `6ca644728` and run against the unchanged source:
| case | request path (control) | flow before the fix |
|:--|:--|:--|
| acting in A, record-change shape `{ userId, tenantId }` | `usr_me,
usr_peer` | `expected [ 'usr_me' ] to deeply equal [ 'usr_me',
'usr_peer' ]` |
| acting in A, the doors' shape (positions and permission sets already
resolved) | `usr_me, usr_peer` | `expected [ 'usr_me' ] to deeply equal
[ 'usr_me', 'usr_peer' ]` |
| no organization `{ userId }` | `usr_me` | `usr_me` (already equal) |
The control context is `resolveUserAuthzGrants(ql, userId, { tenantId
})` copied the way `resolveAuthzContext` copies it onto a request.
## The change
- **Run setup seeds the peer list**
(`AutomationEngine.resolveRunContext`, `engine.ts`). For a `runAs:
'user'` run with a user, the engine asks its grants resolver whenever
the context carries no `org_user_ids` yet, not only when it carries no
`permissions`. When permissions were already forwarded (the REST
trigger, action and endpoint doors forward them), ONLY the peer list is
taken from the answer: the forwarded positions and permission sets stay
verbatim, so an agent's narrowed ceiling is not re-broadened. A context
that already carries a list (a `subflow` / `map` child, a resumed leg)
keeps it and resolves nothing.
- **The bridge passes the resolver's list on** (`plugin.ts`). The
plugin's grants resolver already called `resolveUserAuthzGrants`, the
resolver the request path seeds `org_user_ids` through, and dropped the
field. It now returns it. `FlowUserGrants` gains an optional
`org_user_ids`.
- **`resolveRunDataContext` stays the single mapping**
(`runtime-identity.ts`). Its `runAs: 'user'` branch copies the seeded
list onto the data context (`RunIdentityContext.org_user_ids`,
optional), so every data node and the lookup expander get it. No list is
invented when none was seeded: the policies that read it drop out, which
narrows. `runAs: 'system'` is unchanged.
- The `[runAs]` warning on a resolver failure now says which half
degraded (baseline member permissions, or only the peer list). The
existing sentence `baseline member permissions (not elevated)` is kept
for the full case.
## Mechanism assumptions, measured
- **A1** holds: the `const out: RunIdentityContext` block had no
`org_user_ids`; the probe above is the failing reading.
- **A2** refined: no trigger surface forwards `org_user_ids`
(`buildAutomationContext` and `dispatchFlowAction` in
`@objectstack/runtime` forward positions/permissions only when
non-empty, the record-change trigger forwards `userId` + `tenantId`). So
carrying a trigger's value could fix nothing on its own; the engine
seeds at its one run-setup point for every producer. The approvals
mirror's hand-built `{ isSystem: true, userId }` write reaches a flow
through the record-change trigger as `{ userId }` (plus `tenantId` when
the hook session has one), which the same seeding covers.
- **A3** holds: the resolver is async and engine-bound, so the seed is
at run setup (`resolveRunContext`), not per node.
- **A4** holds, see "Hook path" below.
- **A5** holds: the list is `sys_member` rows with `organization_id =
tenantId` plus the user, from the same `resolveUserAuthzGrants` call the
request path makes. The fixture pins it: `usr_other` shares organization
B with `usr_me` and is still absent when the run acts in A.
## Ablations (each committed first, mutated through
`scripts/ablation-replace.mjs`, restored to the HEAD blob with `git diff
HEAD` empty)
1. Drop the copy in `resolveRunDataContext` (`if (orgUserIds)
out.org_user_ids = orgUserIds;` deleted): both organization cases red,
`expected [ 'usr_me' ] to deeply equal [ 'usr_me', 'usr_peer' ]`;
control and no-organization stay green.
2. The "nothing beyond" pin, with the forbidden behaviour put back: the
bridge returns the union of the user's peers across every organization
they belong to. Three cases red: `expected [ 'usr_me', 'usr_other',
'usr_peer' ] to deeply equal [ 'usr_me', 'usr_peer' ]` (twice) and `...
to deeply equal [ 'usr_me' ]` for the no-organization run.
3. Seed only when permissions are absent (the old condition): the doors'
shape goes red in the integration file, and the two unit pins for that
shape in `crud-runas.test.ts` go red.
## Hook path: probed, not in this PR
A temporary probe (not committed) bound a `runAs: 'user'` hook that
reads `sys_user` through `ctx.api` on the same real stack:
```
PROBE request-fired hook saw: ["usr_me","usr_peer"]
PROBE mirror-fired hook saw: ["usr_me"]
```
The second write was the approvals-mirror shape `{ isSystem: true,
userId, tenantId }`. `ScopedContext.withRunAs('user')`
(`packages/objectql/src/engine.ts`) pins `{ ...executionContext,
isSystem: false }`, so from a hand-built context the hook carries no
positions, no permission sets and no `org_user_ids`. Repairing it needs
an async resolution through the core resolver in `engine.ts` or
`hook-wrappers.ts`, or the producers seeding the envelope in
`plugin-approvals`. Neither is inside the rider surface the claim
declared (`hook-run-as.ts` and its test), so the rider is cut and
reported for `domain:engine`.
## Tests and gates (head `304dea50d`, which merges `origin/main` at
`ae6bd1a69`)
- `pnpm --filter @objectstack/service-automation typecheck`: exit 0
(`tsc --noEmit`, then `check:test-typecheck: OK`).
- `pnpm --filter @objectstack/service-automation exec vitest run
--maxWorkers=2`: `Test Files 190 passed (190)`, `Tests 2541 passed
(2541)`.
- Derived gates (`node scripts/pm/dispatch-gates.mjs --commands`, 68
commands): 67 exit 0; `pnpm check:dual-build-cjs-loads` exit 3,
`PREREQUISITE NOT MET` (it reads every package's `dist/`), NOT MEASURED
here and left to `Lint & Repo Gates`. `--ran` reconciliation: `68
derived famil(ies) accounted for — 67 run, 1 NOT-MEASURED`.
- `test:repo` is not run locally: the diff edits no repo-project test,
and `Test Core` runs it.
- `origin/main` has moved 5 commits past the merge base since; none
touches `service-automation` or the run identity path, so the branch is
not re-merged before CI.
## Acceptance notes
- **Where the list rides.** It rides the run's `AutomationContext`
object under `org_user_ids`, typed by a service-automation-local
`SeededRunContext`, because the claim's file surface excludes
`packages/spec`. The alternative is declaring the key on
`AutomationContext` in `@objectstack/spec/contracts`, which would let
the doors forward the request's list and skip the re-resolution below.
Correctness does not need it. Carrier: none.
- **Cost.** A run started from the REST trigger, action or endpoint
doors now pays one grants resolution at setup (`resolveUserAuthzGrants`,
cache bypassed as the existing bridge already rules). The record-change
path already paid it.
- **Runs suspended before this release** resume without the list (a
resume does not re-run run setup) and keep today's answer, their own
`sys_user` row, until they end.
- **Persistence.** A paused `runAs: 'user'` run's
`sys_automation_run.context_json` now carries the peer list beside the
positions and permission sets it already carried.
- **Organization vetting.** The request path vets a claimed organization
against the user's memberships under a walled posture; the run takes the
trigger's `tenantId` as handed. Every user-triggered door hands over an
already-vetted one. Read-only inference, not probed.
- **Run context vs request context beyond this card.**
`accessible_org_ids`, `systemPermissions` and `posture` are not carried
into a `runAs: 'user'` run's data context either; the `group` posture's
wall reads `accessible_org_ids`. Read-only, not probed. Carrier: none.
- **Clause-②** stays `no` as claimed. The only type movement is two
optional members: `RunIdentityContext.org_user_ids` and
`FlowUserGrants.org_user_ids`.
## Seat's append (patch round 1)
`domain:services` seat 2 re-judged `Clause-②` from the diff:
`@objectstack/service-automation` is `access: public` and its index
re-exports `RunIdentityContext` / `RunDataContext`, so the new optional
member is an additive widening of the published surface. Under the Check
Changeset step's WHICH LEVEL rule (ruled 2026-09-04 on #15294) that
takes `minor`. The declaration above and the changeset level are
corrected in this round; the last Acceptance-notes line, which says it
stays `no`, is superseded by this append.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 8eac321 commit f72ce93
7 files changed
Lines changed: 371 additions & 19 deletions
File tree
- .changeset
- packages/services/service-automation/src
- builtin
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
30 | 30 | | |
31 | 31 | | |
32 | 32 | | |
| 33 | + | |
33 | 34 | | |
34 | 35 | | |
35 | 36 | | |
| |||
439 | 440 | | |
440 | 441 | | |
441 | 442 | | |
442 | | - | |
| 443 | + | |
443 | 444 | | |
444 | 445 | | |
445 | 446 | | |
446 | 447 | | |
447 | 448 | | |
448 | | - | |
449 | | - | |
450 | | - | |
451 | | - | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
452 | 453 | | |
453 | 454 | | |
454 | 455 | | |
455 | 456 | | |
456 | | - | |
| 457 | + | |
| 458 | + | |
457 | 459 | | |
458 | | - | |
| 460 | + | |
| 461 | + | |
459 | 462 | | |
460 | 463 | | |
461 | 464 | | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
462 | 532 | | |
463 | 533 | | |
464 | 534 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
212 | 212 | | |
213 | 213 | | |
214 | 214 | | |
215 | | - | |
| 215 | + | |
216 | 216 | | |
217 | 217 | | |
218 | 218 | | |
| |||
723 | 723 | | |
724 | 724 | | |
725 | 725 | | |
726 | | - | |
| 726 | + | |
| 727 | + | |
727 | 728 | | |
728 | 729 | | |
729 | 730 | | |
730 | 731 | | |
731 | 732 | | |
| 733 | + | |
| 734 | + | |
| 735 | + | |
| 736 | + | |
| 737 | + | |
| 738 | + | |
| 739 | + | |
| 740 | + | |
| 741 | + | |
732 | 742 | | |
733 | 743 | | |
734 | 744 | | |
| |||
6412 | 6422 | | |
6413 | 6423 | | |
6414 | 6424 | | |
| 6425 | + | |
| 6426 | + | |
| 6427 | + | |
| 6428 | + | |
| 6429 | + | |
| 6430 | + | |
| 6431 | + | |
| 6432 | + | |
| 6433 | + | |
| 6434 | + | |
| 6435 | + | |
| 6436 | + | |
| 6437 | + | |
| 6438 | + | |
| 6439 | + | |
| 6440 | + | |
| 6441 | + | |
6415 | 6442 | | |
6416 | 6443 | | |
6417 | 6444 | | |
6418 | | - | |
| 6445 | + | |
6419 | 6446 | | |
6420 | 6447 | | |
6421 | 6448 | | |
6422 | 6449 | | |
6423 | 6450 | | |
6424 | | - | |
6425 | | - | |
6426 | | - | |
| 6451 | + | |
| 6452 | + | |
| 6453 | + | |
| 6454 | + | |
| 6455 | + | |
| 6456 | + | |
| 6457 | + | |
| 6458 | + | |
6427 | 6459 | | |
6428 | 6460 | | |
6429 | 6461 | | |
| |||
6438 | 6470 | | |
6439 | 6471 | | |
6440 | 6472 | | |
| 6473 | + | |
| 6474 | + | |
| 6475 | + | |
| 6476 | + | |
| 6477 | + | |
| 6478 | + | |
| 6479 | + | |
| 6480 | + | |
| 6481 | + | |
| 6482 | + | |
6441 | 6483 | | |
6442 | 6484 | | |
6443 | | - | |
6444 | | - | |
| 6485 | + | |
6445 | 6486 | | |
6446 | 6487 | | |
6447 | 6488 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1293 | 1293 | | |
1294 | 1294 | | |
1295 | 1295 | | |
| 1296 | + | |
| 1297 | + | |
| 1298 | + | |
| 1299 | + | |
| 1300 | + | |
1296 | 1301 | | |
1297 | 1302 | | |
1298 | 1303 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
111 | 111 | | |
112 | 112 | | |
113 | 113 | | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
114 | 117 | | |
115 | 118 | | |
116 | 119 | | |
| |||
0 commit comments