Repository navigation
Commit 14ccb1d
fix(service-automation): a runAs:'user' flow carries its user's email, so an email-keyed row policy admits the run as it admits the user's own request (#24636)
Fixes #24613
Clause-②: yes (widening). `AutomationContext.email` and the exported
`FlowUserGrants` / `RunIdentityContext` types gain one optional member
each. A run reaches rows its user's own request reaches, and nothing
beyond.
## What was wrong
A `runAs: 'user'` flow's data context carried no `email`. The RLS
compiler binds `current_user.email` from the execution context's `email`
and nothing else, so every row policy keyed on it (the stock showcase's
`task_own_rows`, `assignee == current_user.email`) dropped out inside
the run. A record-change flow triggered by a member whose only access to
a row is such a policy was refused on that row, while the member's own
request reads and updates it. The request path gets the email from the
core resolver (`resolveUserAuthzGrants`, through `resolveAuthzContext`);
the run called the same resolver and dropped the field at every step.
## The change
- **The bridge passes the resolver's email on** (`plugin.ts`). The
grants resolver the plugin wires already called
`resolveUserAuthzGrants`; it now returns `grants.email` beside the
positions, permission sets and peer list. `FlowUserGrants` gains an
optional `email`.
- **Run setup seeds it** (`seedRunGrants`, `engine.ts`), by the rule the
peer list uses: only what the context lacks. A `runAs: 'user'` context
with a user and no email is resolved and takes the email; a context that
already carries one keeps it verbatim (the request path keeps a
session's email the same way). Forwarded positions and permission sets
stay untouched.
- **The decider's segment carries the decider's email**
(`resolveDecisionSegmentContext`). It now removes the submitter's
`email` with the submitter's other facts, on both postures, and
`seedRunGrants` resolves the decider's. Without the removal the decider
would run with the submitter's email under the decider's id.
- **`resolveRunDataContext` stays the single mapping**
(`runtime-identity.ts`). Its `runAs: 'user'` arm copies the email onto
the data context (`RunIdentityContext.email`, optional). No email is
invented when none was seeded. The `runAs: 'system'` arm is untouched.
- **Contract** (`packages/spec/src/contracts/automation-service.ts`, a
spec-lane rider): `AutomationContext.email?: string` with TSDoc naming
who sets it, who reads it, and what its absence means. It regenerates no
artefact: `pnpm --filter @objectstack/spec check:generated` reports all
14 generated artefacts up to date.
- The `[runAs]` warning on a resolver failure names the email beside the
peer list.
- Docblocks that said no run context carries an email are corrected
(`runUserOf`, the CEL scope note).
## Measurement: the card's reproduction on the real stack
`packages/services/service-automation/src/runas-user-email-owner.integration.test.ts`,
beside `runas-user-hook-org-peers.integration.test.ts`. It boots
ObjectKernel, ObjectQLPlugin, driver-sql on better-sqlite3 `:memory:`,
the identity objects, the real SecurityPlugin (shipped sets plus one
fixture set: `select` and `update` on `assignee == current_user.email`)
and the real AutomationEngine with its CRUD nodes. Runs start with the
record-change trigger's own envelope (`record`, `object`, `event`,
`userId`, `tenantId`, `params`). `@objectstack/trigger-record-change`
depends on this package, so it is not composed here.
Before: the three producer files at `c3954057f` (`git restore --source`,
each file's blob hash checked against the base blob), the new pin at
head. After: head.
| case | before | after |
|:--|:--|:--|
| CONTROL: the member's own request reads and updates the row | pass |
pass |
| GUARD: the same request without the email does not see the row | pass
| pass |
| the card's reproduction (record-change run, read and stamp) |
**fail**: `update_record(qa_owner_task) failed: Record ID not found in
qa_owner_task` | pass, `notes` equals the member's id |
| the doors' shape (positions and permission sets forwarded, no email) |
**fail**, same message | pass |
| nothing beyond: a row assigned to someone else's email | pass
(refused) | pass (refused) |
| after a decision, the decider's row is stamped with the decider |
**fail**, same message | pass |
| after a decision, the submitter's row is refused to the decider | pass
(refused) | pass (refused) |
| CONTROL: a `runAs: 'system'` run | pass | pass |
Before-leg totals: `Tests 3 failed | 5 passed (8)`. After: `Tests 8
passed (8)`. The card's showcase run reported the record-level 403
sentence; this stack answers the not-found that by-id writes of an
unreadable row answer. Both are the refusal of a row the member can
read.
## Ablations (committed first, mutated through
`scripts/ablation-replace.mjs`, each restored to the HEAD blob with `git
diff HEAD` empty)
The subjects resolve through relative imports to `src/`, so no build
sits between the mutation and the run.
1. **Drop the seed** (`if (needsEmail && grants.email) runContext.email
= grants.email;` deleted): 7 red across three files. The card's
reproduction, the doors' shape and the decider's row on the real stack
(`Record ID not found`), the decider envelope (`after.email`), and three
unit pins in `crud-runas.test.ts`. `Tests 7 failed | 59 passed (66)`.
2. **Keep the submitter's email in the decider's segment** (`delete
switched.email;` deleted): 6 red. On the real stack the submitter's row
is written by the decider (`expected true to be false`) and the
decider's own row is refused. In the envelope layer: `expected
'rep@example.test' to be 'mgr@example.test'`, the failed-resolution case
(`expected 'rep@example.test' to be undefined`), and both `runAs:
'system'` decision cases. `Tests 6 failed | 23 passed (29)`.
Reverse type check: an `AutomationContext` literal with `email: 42`
planted in `runtime-identity.ts` gives `TS2322: Type 'number' is not
assignable to type 'string'` under `tsc --noEmit`. So the type check
reads the rebuilt `.d.ts` that declares the member. A stale one would
report an unknown property. Restored to the HEAD blob.
## Tests and gates (head `5318e5810`, which merges `origin/main` at
`973917c57`)
- `pnpm --filter @objectstack/service-automation exec vitest run
--maxWorkers=2`: `Test Files 193 passed (193)`, `Tests 2580 passed
(2580)`.
- `pnpm --filter @objectstack/service-automation typecheck`: exit 0
(`tsc --noEmit`, then `check:test-typecheck: OK`).
- `pnpm --filter @objectstack/spec typecheck`: exit 0. `vitest run
--project local --maxWorkers=2` (the `test` script's runner): `Test
Files 701 passed (701)`, `Tests 21312 passed | 1 todo`. Spec run at
`0663fe26c`; the merge moved no file under `packages/spec`.
- `pnpm --filter @objectstack/spec check:generated`: all 14 generated
artefacts up to date.
- Derived gates (`node scripts/pm/dispatch-gates.mjs --commands`, 90
commands) on `5318e5810`: all 90 exit 0, each recorded with its exit
code. `--ran`: `90 derived famil(ies) accounted for — 90 run, 0
NOT-MEASURED` (0 UNRUN). An earlier run at `0663fe26c` had one exit 3,
`check:dual-build-cjs-loads` (`PREREQUISITE NOT MET`, it reads every
package's `dist/`). It exited 0 once the dists were built, and on the
final head it exits 0 on the first run.
- `check:dev-prereqs` (local-only family; this diff touches
`packages/spec`): exit 0.
- `test:repo` is not run locally: the diff edits no repo-project test,
and `Test Core` runs it.
## Acceptance notes
- **The hook-side twin is not in this PR.**
`ScopedContext[SEED_USER_GRANTS]` (`packages/objectql`) seeds a `runAs:
'user'` hook's positions, permission sets and peer list, but not the
email. It is outside this card's surface and is reported to the seat for
the `domain:engine` lane. A hook fired by a `runAs: 'user'` flow's write
now inherits the run's email through the write's context.
- **The flow CEL `current_user` still binds no email.** The run context
now holds one for a `runAs: 'user'` run. The binding follows the ruled
list (id, positions, organization, the platform-admin flag), and the
email serves the data operations' row policies. A pin in
`flow-cel-current-user.test.ts` holds that. Whether the binding should
widen is a question for the seat, not a change made here.
- **Persistence.** A paused `runAs: 'user'` run's
`sys_automation_run.context_json` now carries the user's email beside
the positions and permission sets it already carried. That row's read is
governed by `sys_automation_run`'s own permissions.
- **Runs suspended before this release** resume without the email (a
resume does not re-run setup) and keep today's answer until they end.
- **Cost.** A context that carries permissions and a peer list but no
email now asks the resolver once, for the email alone. That shape is a
`subflow` / `map` child of an email-less user, or an in-process caller
that resolved the rest itself. Every door-started run already paid this
resolution for the peer list.
- **Contract pin.** No type-level pin file was added under
`packages/spec/src/contracts/`: the claim's surface names only
`automation-service.ts` there. The member's type is exercised by the
service-automation test layer (`check:test-typecheck`) and by the
reverse type check above.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 35450f0 commit 14ccb1d
9 files changed
Lines changed: 527 additions & 41 deletions
File tree
- .changeset
- packages
- services/service-automation/src
- builtin
- spec/src/contracts
| 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 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
449 | 449 | | |
450 | 450 | | |
451 | 451 | | |
452 | | - | |
| 452 | + | |
453 | 453 | | |
454 | 454 | | |
455 | 455 | | |
| |||
463 | 463 | | |
464 | 464 | | |
465 | 465 | | |
| 466 | + | |
466 | 467 | | |
467 | 468 | | |
468 | 469 | | |
469 | | - | |
| 470 | + | |
470 | 471 | | |
471 | 472 | | |
472 | 473 | | |
| |||
475 | 476 | | |
476 | 477 | | |
477 | 478 | | |
478 | | - | |
| 479 | + | |
479 | 480 | | |
480 | 481 | | |
481 | 482 | | |
482 | | - | |
| 483 | + | |
483 | 484 | | |
484 | 485 | | |
485 | 486 | | |
486 | 487 | | |
487 | 488 | | |
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 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
489 | 551 | | |
490 | 552 | | |
491 | 553 | | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
| 563 | + | |
| 564 | + | |
| 565 | + | |
492 | 566 | | |
493 | 567 | | |
494 | 568 | | |
| |||
529 | 603 | | |
530 | 604 | | |
531 | 605 | | |
| 606 | + | |
532 | 607 | | |
533 | 608 | | |
534 | 609 | | |
| |||
0 commit comments