From 706c9c3f53b00ac473ec0ed43303035a6af6b6c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:19:43 +0000 Subject: [PATCH 1/2] fix(service-automation): carry the user's email through a runAs:'user' run identity Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .../services/service-automation/src/engine.ts | 95 +++++++++++++------ .../services/service-automation/src/plugin.ts | 5 + .../src/runtime-identity.ts | 18 +++- .../spec/src/contracts/automation-service.ts | 25 +++++ 4 files changed, 112 insertions(+), 31 deletions(-) diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 019ddb6c6fd..0e77233a0aa 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -99,9 +99,14 @@ import { ExpressionEngine, validateExpression, nearestName } from '@objectstack/ * Built through `createEvalUser`, the one factory every surface builds the * canonical user with (ADR-0068), from what the run context holds and * nothing else: `id` from `userId`, `positions`, and `organizationId` from - * `tenantId`; `isPlatformAdmin` is derived from the positions there. No run - * context carries an email or a name, so neither is bound — a read of either - * is the user record's (`current_user.id`), not the run's. + * `tenantId`; `isPlatformAdmin` is derived from the positions there. Neither + * an email nor a name is bound (Q2 A enumerates the four above). A + * `runAs:'user'` run's context does carry the user's email + * (`AutomationContext.email`, seeded at run setup), but for its data + * operations' row policies, which read it as `current_user.email` through + * `resolveRunDataContext`; it is not a CEL fact here, and a `runAs:'system'` + * run carries none. A flow that needs either reads the user record + * (`current_user.id`). * * A run with no user — a schedule, a record change made by a system write, a * caller that passed no context — answers `null`, never a pseudo-user @@ -739,6 +744,14 @@ export interface FlowUserGrants { * run sees less, never more. */ org_user_ids?: string[]; + /** + * The user's unique email, the value RLS reads as `current_user.email` + * (`resolveUserAuthzGrants`' own field, under its own name). Run setup + * seeds it on the run context as {@link AutomationContext.email}. A + * resolver that omits it leaves the run without one, so every policy that + * reads it drops out: the run sees less, never more. + */ + email?: string; } /** @@ -6439,6 +6452,12 @@ export class AutomationEngine implements IAutomationService { // human's peers in the run's organization, which is what the request // path's agent envelope carries as well. A context that already carries // one — a `subflow` / `map` child, a resumed leg — keeps it. + // + // The user's email is seeded by the same rule. No trigger surface + // forwards it either, and without it every row policy that reads + // `current_user.email` dropped out: the run was refused on rows the + // same user's request reads and writes. A context that already carries + // an email keeps it verbatim, as the request path keeps a session's. await this.seedRunGrants(flow.name, runContext, 'triggering'); if (runIsUnscopedUserMode(runContext) && flowTouchesData(flow)) { @@ -6460,21 +6479,22 @@ export class AutomationEngine implements IAutomationService { /** * Seed a `runAs:'user'` run context's authorization facts — `positions`, - * `permissions` and the peer list `org_user_ids` — for its `userId`, through - * the wired grants resolver: the one resolver the request path seeds a - * request's envelope through (`resolveUserAuthzGrants`, bridged by the - * automation plugin). Mutates `runContext`; a fact it already carries is - * kept verbatim, so only the missing ones are resolved (see the setup - * comment in {@link resolveRunContext} for why carried positions and - * permissions are never re-broadened). + * `permissions`, the peer list `org_user_ids` and the user's `email` — for + * its `userId`, through the wired grants resolver: the one resolver the + * request path seeds a request's envelope through + * (`resolveUserAuthzGrants`, bridged by the automation plugin). Mutates + * `runContext`; a fact it already carries is kept verbatim, so only the + * missing ones are resolved (see the setup comment in + * {@link resolveRunContext} for why carried positions and permissions are + * never re-broadened). * * Two callers, one resolution: run setup for the TRIGGER user * ({@link resolveRunContext}), and the decision switch for the DECIDER * ({@link resolveDecisionSegmentContext}), which hands this a context whose * trigger-user facts it has already removed — so the decider's segment - * carries the decider's own positions, permission sets and peers, resolved - * exactly as the decider's own request resolves them, and never a copy of - * anybody else's. + * carries the decider's own positions, permission sets, peers and email, + * resolved exactly as the decider's own request resolves them, and never a + * copy of anybody else's. * * A `runAs:'system'` run, a user-less run, a context carrying every fact, * and an engine with no resolver wired resolve nothing. @@ -6486,10 +6506,11 @@ export class AutomationEngine implements IAutomationService { ): Promise { const needsGrants = !Array.isArray(runContext.permissions); const needsPeers = runOrgUserIds(runContext) === undefined; + const needsEmail = !runContext.email; if ( runContext.runAs !== 'system' && runContext.userId && - (needsGrants || needsPeers) && + (needsGrants || needsPeers || needsEmail) && this.userGrantsResolver ) { try { @@ -6503,6 +6524,7 @@ export class AutomationEngine implements IAutomationService { if (needsPeers && Array.isArray(grants.org_user_ids)) { (runContext as SeededRunContext).org_user_ids = grants.org_user_ids; } + if (needsEmail && grants.email) runContext.email = grants.email; } } catch (err) { // Fail-safe, never fail-open: on a resolution error the run keeps @@ -6518,15 +6540,24 @@ export class AutomationEngine implements IAutomationService { // run's own data-op refusals/strips, and nothing // claimed-persisted failed to land. // - // Unresolved peers degrade the same way, narrower and never - // wider: a policy that reads `current_user.org_user_ids` drops - // out, so the run sees its own `sys_user` row and not its - // organization's members. The sentence names which half applies. + // Unresolved peers and email degrade the same way, narrower and + // never wider: a policy that reads `current_user.org_user_ids` + // or `current_user.email` drops out, so the run sees its own + // `sys_user` row and not its organization's members, and no row + // an email-keyed policy would admit. The sentence names which + // half applies, and which facts it lost. + const lost = [ + ...(needsPeers ? ['current_user.org_user_ids'] : []), + ...(needsEmail ? ['current_user.email'] : []), + ].join(' or '); + // Without grants the lost list may be empty (the context carried + // its peers and email); with grants carried it never is, since + // one of the two is what sent the run here. const consequence = needsGrants - ? `its data ops fall back to baseline member permissions (not elevated), and every ` + - `row policy reading current_user.org_user_ids drops out` + ? `its data ops fall back to baseline member permissions (not elevated)` + + (lost ? `, and every row policy reading ${lost} drops out` : '') : `its data ops keep the trigger's positions and permission sets, but every row ` + - `policy reading current_user.org_user_ids drops out (narrower, not elevated)`; + `policy reading ${lost} drops out (narrower, not elevated)`; this.logger.warn( `[runAs] flow '${flowName}' could not resolve grants for ${whose} user ` + `'${runContext.userId}' — ${consequence}. The resolver's failure is in this record's meta.`, @@ -6547,8 +6578,8 @@ export class AutomationEngine implements IAutomationService { * as that decider — identity and permissions together. The answer is a * COPY of the persisted context with the trigger user's authorization facts * REMOVED, `userId` set to the decider, and the decider's own positions, - * permission sets and peers resolved by {@link seedRunGrants}, the trigger - * setup's own resolver. Nothing of the trigger user's survives into the + * permission sets, peers and email resolved by {@link seedRunGrants}, the + * trigger setup's own resolver. Nothing of the trigger user's survives into the * segment: copying the decision request's envelope by hand, or keeping the * submitter's grants under the decider's name, would be two identities in * one context — the reading ruling A rejected. @@ -6569,9 +6600,9 @@ export class AutomationEngine implements IAutomationService { * row and `ctx.user` in the hooks those writes fire name the decider, while * elevation is untouched and no grant is resolved (a system run's data * context carries none: {@link resolveRunDataContext}'s system arm). The - * trigger user's positions, permission sets and peers are removed here too, - * so `current_user` in the segment is the decider and never the decider's id - * over the submitter's grants. + * trigger user's positions, permission sets, peers and email are removed + * here too, so `current_user` in the segment is the decider and never the + * decider's id over the submitter's grants. * * Unchanged: * - a signal with no decider — an SLA auto-decision, a screen, a wait @@ -6603,6 +6634,11 @@ export class AutomationEngine implements IAutomationService { delete switched.positions; delete switched.permissions; delete switched.org_user_ids; + // The trigger user's email is theirs too: kept under the decider's id, + // it would admit the decider to every row an email-keyed policy grants + // the submitter. Removed on both postures, and seeded for the decider + // below like their grants. + delete switched.email; // A system run keeps its elevation: attribution moves, no grant is resolved. if (switched.runAs === 'system') return switched; await this.seedRunGrants(run.flowName, switched, 'deciding'); @@ -12698,9 +12734,10 @@ export class AutomationEngine implements IAutomationService { * The scope also binds `current_user`, ADR-0068's canonical user root, from * the run context the caller passes ({@link runUserOf}): the run's * `EvalUser` when the run has a user, and `null` when it has none — never - * a pseudo-user (ADR-0118 D1, D4). It carries only what the run holds: - * `id`, `positions`, `organizationId` and the `isPlatformAdmin` flag - * derived from them; no run carries an email or a name. This is what the + * a pseudo-user (ADR-0118 D1, D4). It carries `id`, `positions`, + * `organizationId` and the `isPlatformAdmin` flag derived from them, and no + * email or name: the run context's email is for row policies only (see + * {@link runUserOf}). This is what the * retired `{$User.Id}` value-slot token is refused in favour of, so it * reads the same context the 17.x interpolator's `$User.Id` branch read. * diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index 7b428b19954..be6e929b2f0 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -1298,6 +1298,11 @@ export class AutomationServicePlugin implements Plugin { // the user, so `sys_user_org_members` resolves for the // run as it does for the user's own request. org_user_ids: grants.org_user_ids, + // The email the request path seeds through this same + // resolver (the user's `sys_user` email), so a row + // policy reading `current_user.email` resolves for the + // run as it does for the user's own request. + ...(grants.email ? { email: grants.email } : {}), ...(tenantId ? { tenantId } : {}), }; }); diff --git a/packages/services/service-automation/src/runtime-identity.ts b/packages/services/service-automation/src/runtime-identity.ts index f7d04696289..01817d7d8c0 100644 --- a/packages/services/service-automation/src/runtime-identity.ts +++ b/packages/services/service-automation/src/runtime-identity.ts @@ -53,6 +53,16 @@ export interface RunIdentityContext { * `runAs:'system'` run, whose `isSystem` short-circuit precedes every policy. */ org_user_ids?: string[]; + /** + * The acting user's unique email: the value RLS reads as + * `current_user.email` (an owner policy such as + * `assignee == current_user.email`). Present on a `runAs:'user'` run whose + * context carries it ({@link AutomationContext.email}, seeded at run setup); + * a policy that reads it drops out without it, so the run would be refused + * on every row only such a policy admits. Never set on a `runAs:'system'` + * run, whose `isSystem` short-circuit precedes every policy. + */ + email?: string; /** Acting user's tenant/org id. */ tenantId?: string; /** @@ -200,14 +210,17 @@ export function runOrgUserIds(context: AutomationContext | undefined): string[] * `flowRunId` are its provenance, and ADR-0118 D1 forbids inventing a * sentinel or pseudo-user in its place. * - `runAs:'user'` (default) → the triggering user's identity - * (`{ userId, positions, permissions, org_user_ids?, tenantId? }`), so the + * (`{ userId, positions, permissions, org_user_ids?, email?, tenantId? }`), so the * security middleware enforces that user's row-level security. The run can * never exceed the triggering user's grants. Empty `positions` falls back to * the platform's baseline permission set, exactly like a fresh member's own * REST request. `org_user_ids` is the peer list run setup seeded * ({@link SeededRunContext}): with it, `sys_user` answers the acting user and * their organization's members, as the same user's request does; without - * it, every policy that reads it drops out. + * it, every policy that reads it drops out. `email` is the user's, run setup + * seeded or the trigger carried ({@link AutomationContext.email}): with it, + * an owner policy on `current_user.email` admits the rows it admits for the + * same user's request; without it, that policy drops out. * - neither (an effective `runAs:'user'` run with no trigger user) → **throws** * {@link UnscopedRunDataAccessError} (#3760). This used to return a * provenance-only envelope and let the run proceed UNSCOPED — the #1888 @@ -299,6 +312,7 @@ export function resolveRunDataContext(context: AutomationContext | undefined): R }; const orgUserIds = runOrgUserIds(context); if (orgUserIds) out.org_user_ids = orgUserIds; + if (context.email) out.email = context.email; if (context.tenantId) out.tenantId = context.tenantId; if (flowRunId) out.flowRunId = flowRunId; return out; diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 30a2dc07821..c8abbda0624 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -97,6 +97,31 @@ export interface AutomationContext { * stays tenant-scoped. ADR-0049 / #1888. */ tenantId?: string; + /** + * The acting user's unique email, the value row-level security reads as + * `current_user.email` (an owner policy such as + * `assignee == current_user.email`). The kernel `ExecutionContext.email`, + * under its own name. + * + * Who sets it: the engine's run setup, for a `runAs:'user'` run whose + * context names a user and carries no email yet. It is taken from the + * resolver a request's envelope is built through (`resolveUserAuthzGrants` + * in `@objectstack/core`, the user's `sys_user` email), so the run matches + * the same user's own request. After an approval decision it is the + * DECIDER's, resolved for them; the trigger user's never survives into the + * decider's segment. A context that already carries one keeps it verbatim, + * as a request's session email wins over the stored one. Absent on a + * `runAs:'system'` run (nothing is resolved for it), on a user-less run, + * and for a user with no stored email: every policy that reads it then + * drops out, which narrows and never widens. + * + * It reaches the data engine through `resolveRunDataContext` in + * `@objectstack/service-automation`, and rides the run's context into a + * `subflow` / `map` child and through a suspension, as {@link positions} + * and {@link permissions} do. Identity for row policies only: the flow CEL + * scope's `current_user` does not bind it. + */ + email?: string; /** * The language of the person who started the run (BCP-47, e.g. `zh-CN`): * the door's already-resolved `ExecutionContext.locale`, forwarded as-is. From 0663fe26c0b41c22d07ff13fdad25d1f5eee81bf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:26:10 +0000 Subject: [PATCH 2/2] test(service-automation): pin the run identity's email on the real stack, and the decider's Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .changeset/24613-run-identity-email.md | 22 ++ .../src/builtin/crud-runas.test.ts | 83 ++++- .../src/flow-cel-current-user.test.ts | 6 + ...esume-decider-identity.integration.test.ts | 17 +- ...runas-user-email-owner.integration.test.ts | 297 ++++++++++++++++++ 5 files changed, 415 insertions(+), 10 deletions(-) create mode 100644 .changeset/24613-run-identity-email.md create mode 100644 packages/services/service-automation/src/runas-user-email-owner.integration.test.ts diff --git a/.changeset/24613-run-identity-email.md b/.changeset/24613-run-identity-email.md new file mode 100644 index 00000000000..3ecbeb440c7 --- /dev/null +++ b/.changeset/24613-run-identity-email.md @@ -0,0 +1,22 @@ +--- +'@objectstack/service-automation': minor +'@objectstack/spec': minor +--- + +fix(service-automation): a `runAs: 'user'` flow now carries its user's email, so a row policy on `current_user.email` admits the run on the rows it admits for the same user's own request + +Clause-②: yes (widening) + +A `runAs: 'user'` flow's data nodes ran without the acting user's email, which the request path resolves for every user. The row-level security compiler reads `current_user.email` from the execution context's `email` and nothing else, so every owner policy keyed on it (such as `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 the row the member had just written, and could read and update with their own request. + +Run setup now seeds the email through the resolver the request path uses (`resolveUserAuthzGrants` from `@objectstack/core`, the user's stored `sys_user` email), and every data node passes it on: + +- It is seeded for every trigger shape that names a user and carries no email: the record-change shape (`userId` and `tenantId` only) and the REST and action doors' shape (positions and permission sets already resolved). Only the missing facts are taken from the resolver; forwarded positions and permission sets stay exactly as they were. +- A context that already carries an email keeps it verbatim, as a request's session email wins over the stored one. A `subflow` or `map` child and a suspended run keep the email their run was seeded with. +- After an approval decision, the nodes that run as the decider carry the decider's own email, resolved for them. The submitter's email is removed with the rest of the submitter's grants, on both `runAs` postures. +- A user with no stored email, or a resolver failure, leaves the run without one: policies that read it drop out, and the run sees less, never more. The `[runAs]` warning on a resolver failure now names the email beside the peer list. +- A run suspended before this release resumes without the email until it ends. + +A `runAs: 'system'` flow is unchanged. The flow CEL scope's `current_user` is unchanged too: it still binds no email. + +Published types: `AutomationContext` (`@objectstack/spec/contracts`) gains an optional `email` member, and `RunIdentityContext` / `RunDataContext` and `FlowUserGrants` (`@objectstack/service-automation`) each gain an optional `email` member. diff --git a/packages/services/service-automation/src/builtin/crud-runas.test.ts b/packages/services/service-automation/src/builtin/crud-runas.test.ts index 4e01387c43e..07a4106b823 100644 --- a/packages/services/service-automation/src/builtin/crud-runas.test.ts +++ b/packages/services/service-automation/src/builtin/crud-runas.test.ts @@ -449,7 +449,7 @@ describe("runAs:'user' resolves the triggering user's grants at run setup (#3356 const seen: Array<{ userId: string; tenantId?: string }> = []; engine.setUserGrantsResolver((userId, tenantId) => { seen.push({ userId, tenantId }); - return { positions: ['SHOULD_NOT_APPLY'], permissions: ['SHOULD_NOT_APPLY'], org_user_ids: ['u1', 'u2'] }; + return { positions: ['SHOULD_NOT_APPLY'], permissions: ['SHOULD_NOT_APPLY'], org_user_ids: ['u1', 'u2'], email: 'u1@example.test' }; }); // An already-resolved envelope — e.g. an ADR-0090 agent ceiling acting @@ -463,10 +463,11 @@ describe("runAs:'user' resolves the triggering user's grants at run setup (#3356 expect(c.ctx.permissions).toEqual(['mcp_agent_read']); expect(c.ctx.positions).toEqual(['agent']); expect(c.ctx.org_user_ids, `${c.op} lost the seeded peer list`).toEqual(['u1', 'u2']); + expect(c.ctx.email, `${c.op} lost the seeded email`).toBe('u1@example.test'); } }); - it('resolves nothing when the trigger already carried permissions AND a peer list (a subflow child, a resumed leg)', async () => { + it('resolves nothing when the trigger already carried permissions, a peer list AND an email (a subflow child, a resumed leg)', async () => { const engine = new AutomationEngine(makeLogger()); const { data, calls } = fakeData(); registerCrudNodes(engine, ctxWith(data)); @@ -475,20 +476,93 @@ describe("runAs:'user' resolves the triggering user's grants at run setup (#3356 let called = 0; engine.setUserGrantsResolver(() => { called++; - return { positions: ['SHOULD_NOT_APPLY'], permissions: ['SHOULD_NOT_APPLY'], org_user_ids: ['SHOULD_NOT_APPLY'] }; + return { positions: ['SHOULD_NOT_APPLY'], permissions: ['SHOULD_NOT_APPLY'], org_user_ids: ['SHOULD_NOT_APPLY'], email: 'SHOULD_NOT_APPLY' }; }); await engine.execute('usr', { - userId: 'u1', positions: ['member'], permissions: ['member_default'], tenantId: 't1', org_user_ids: ['u1', 'u3'], + userId: 'u1', positions: ['member'], permissions: ['member_default'], tenantId: 't1', org_user_ids: ['u1', 'u3'], email: 'u1@example.test', } as SeededRunContext); expect(called).toBe(0); expect(calls.length).toBeGreaterThan(0); for (const c of calls) { expect(c.ctx.permissions).toEqual(['member_default']); expect(c.ctx.org_user_ids).toEqual(['u1', 'u3']); + expect(c.ctx.email).toBe('u1@example.test'); + } + }); + + it('a context carrying permissions and a peer list but no email resolves the email alone, and keeps the rest verbatim', async () => { + const engine = new AutomationEngine(makeLogger()); + const { data, calls } = fakeData(); + registerCrudNodes(engine, ctxWith(data)); + engine.registerFlow('usr', allOpsFlow('usr', 'user')); + + const seen: Array<{ userId: string; tenantId?: string }> = []; + engine.setUserGrantsResolver((userId, tenantId) => { + seen.push({ userId, tenantId }); + return { positions: ['SHOULD_NOT_APPLY'], permissions: ['SHOULD_NOT_APPLY'], org_user_ids: ['SHOULD_NOT_APPLY'], email: 'u1@example.test' }; + }); + + await engine.execute('usr', { + userId: 'u1', positions: ['member'], permissions: ['member_default'], tenantId: 't1', org_user_ids: ['u1', 'u3'], + } as SeededRunContext); + expect(seen).toEqual([{ userId: 'u1', tenantId: 't1' }]); + expect(calls.length).toBeGreaterThan(0); + for (const c of calls) { + expect(c.ctx.positions).toEqual(['member']); + expect(c.ctx.permissions).toEqual(['member_default']); + expect(c.ctx.org_user_ids).toEqual(['u1', 'u3']); + expect(c.ctx.email, `${c.op} lost the seeded email`).toBe('u1@example.test'); + } + }); + + it('seeds the email beside the resolved grants and threads it to EVERY data op', async () => { + const engine = new AutomationEngine(makeLogger()); + const { data, calls } = fakeData(); + registerCrudNodes(engine, ctxWith(data)); + engine.registerFlow('usr', allOpsFlow('usr', 'user')); + engine.setUserGrantsResolver(() => ({ positions: ['org_member'], permissions: ['member_default'], org_user_ids: ['u1'], email: 'u1@example.test' })); + + // The record-change trigger's shape: a user id and an organization, no email. + const res = await engine.execute('usr', { userId: 'u1', tenantId: 't1' }); + expect(res.success).toBe(true); + expect(calls.length).toBeGreaterThan(0); + for (const c of calls) expect(c.ctx.email, `${c.op} lost the seeded email`).toBe('u1@example.test'); + }); + + it('a context that already carries an email keeps it verbatim; the resolver\'s stored one is not applied', async () => { + const engine = new AutomationEngine(makeLogger()); + const { data, calls } = fakeData(); + registerCrudNodes(engine, ctxWith(data)); + engine.registerFlow('usr', allOpsFlow('usr', 'user')); + let called = 0; + engine.setUserGrantsResolver(() => { + called++; + return { positions: ['org_member'], permissions: ['member_default'], org_user_ids: ['u1'], email: 'SHOULD_NOT_APPLY' }; + }); + + // A door that forwards the request's own (session) email. + await engine.execute('usr', { userId: 'u1', tenantId: 't1', email: 'u1.session@example.test' }); + expect(called, 'the other facts are still resolved').toBe(1); + expect(calls.length).toBeGreaterThan(0); + for (const c of calls) { + expect(c.ctx.email).toBe('u1.session@example.test'); + expect(c.ctx.permissions).toEqual(['member_default']); } }); + it('a resolver that answers no email seeds none — the data context carries no email, never a made-up one', async () => { + const engine = new AutomationEngine(makeLogger()); + const { data, calls } = fakeData(); + registerCrudNodes(engine, ctxWith(data)); + engine.registerFlow('usr', allOpsFlow('usr', 'user')); + engine.setUserGrantsResolver(() => ({ positions: ['org_member'], permissions: ['member_default'], org_user_ids: ['u1'] })); + + await engine.execute('usr', { userId: 'u1', tenantId: 't1' }); + expect(calls.length).toBeGreaterThan(0); + for (const c of calls) expect('email' in c.ctx, `${c.op} carried an invented email`).toBe(false); + }); + it('seeds the peer list beside the resolved grants and threads it to EVERY data op', async () => { const engine = new AutomationEngine(makeLogger()); const { data, calls } = fakeData(); @@ -529,6 +603,7 @@ describe("runAs:'user' resolves the triggering user's grants at run setup (#3356 expect(c.ctx.isSystem).toBe(false); expect(c.ctx.permissions).toEqual(['member_default']); expect('org_user_ids' in c.ctx).toBe(false); + expect('email' in c.ctx).toBe(false); } }); diff --git a/packages/services/service-automation/src/flow-cel-current-user.test.ts b/packages/services/service-automation/src/flow-cel-current-user.test.ts index 818a1696435..5d01afd78d5 100644 --- a/packages/services/service-automation/src/flow-cel-current-user.test.ts +++ b/packages/services/service-automation/src/flow-cel-current-user.test.ts @@ -80,6 +80,12 @@ describe('`current_user` with a user — the run\'s EvalUser, and only what the expect((result.output as { v: unknown }).v).toBe('no'); }); + it("binds no email even when the run context carries one: that email is the data operations' row-policy fact, not Q2 A's list", async () => { + const result = await evaluate("has(current_user.email) ? 'yes' : 'no'", { ...USER, email: 'usr_1@example.test' }); + expect(result.success, result.error).toBe(true); + expect((result.output as { v: unknown }).v).toBe('no'); + }); + it('leaves organizationId out when the run carries no tenant', async () => { const result = await evaluate("has(current_user.organizationId) ? 'yes' : 'no'", { userId: 'usr_1' }); expect((result.output as { v: unknown }).v).toBe('no'); diff --git a/packages/services/service-automation/src/resume-decider-identity.integration.test.ts b/packages/services/service-automation/src/resume-decider-identity.integration.test.ts index 40e0fc06a3c..64dd66b0036 100644 --- a/packages/services/service-automation/src/resume-decider-identity.integration.test.ts +++ b/packages/services/service-automation/src/resume-decider-identity.integration.test.ts @@ -144,10 +144,10 @@ function reviewFlow(name: string, opts: { runAs?: 'system' | 'user'; gate?: stri // ───────────────────────────────────────────────────────────────────────────── /** Each user's grants as the resolver answers them, deliberately disjoint. */ -const GRANTS: Record = { - usr_rep: { positions: ['sales_rep', 'everyone'], permissions: ['deal_rep'], org_user_ids: ['usr_rep', 'usr_mgr', 'usr_dir'] }, - usr_mgr: { positions: ['sales_manager', 'everyone'], permissions: ['deal_manager'], org_user_ids: ['usr_mgr', 'usr_rep'] }, - usr_dir: { positions: ['sales_director', 'everyone'], permissions: ['deal_director'], org_user_ids: ['usr_dir'] }, +const GRANTS: Record = { + usr_rep: { positions: ['sales_rep', 'everyone'], permissions: ['deal_rep'], org_user_ids: ['usr_rep', 'usr_mgr', 'usr_dir'], email: 'rep@example.test' }, + usr_mgr: { positions: ['sales_manager', 'everyone'], permissions: ['deal_manager'], org_user_ids: ['usr_mgr', 'usr_rep'], email: 'mgr@example.test' }, + usr_dir: { positions: ['sales_director', 'everyone'], permissions: ['deal_director'], org_user_ids: ['usr_dir'], email: 'dir@example.test' }, }; function envelopeHarness(opts: { failFor?: string } = {}) { @@ -162,7 +162,7 @@ function envelopeHarness(opts: { failFor?: string } = {}) { ...resolveRunDataContext(context), // The run context's own facts, beside the envelope: what `current_user` // is built from in the segment's CEL. - raw: { userId: context?.userId, positions: context?.positions, permissions: context?.permissions }, + raw: { userId: context?.userId, positions: context?.positions, permissions: context?.permissions, email: context?.email }, }); return { success: true }; }, @@ -205,6 +205,8 @@ describe("after an approval decision a runAs:'user' run's data operations carry expect(after.positions).toEqual(GRANTS.usr_mgr.positions); expect(after.permissions).toEqual(GRANTS.usr_mgr.permissions); expect(after.org_user_ids).toEqual(GRANTS.usr_mgr.org_user_ids); + expect(after.email, "the decider's own email, never the submitter's").toBe(GRANTS.usr_mgr.email); + expect(before.email).toBe(GRANTS.usr_rep.email); expect(after.positions).not.toContain('sales_rep'); expect(after.permissions).not.toContain('deal_rep'); // Through the same resolver the trigger was seeded through, for the decider. @@ -257,6 +259,7 @@ describe("after an approval decision a runAs:'user' run's data operations carry expect(after.positions).toEqual([]); expect(after.permissions).toEqual([]); expect(after.org_user_ids).toBeUndefined(); + expect(after.email, "the submitter's email does not stand in for the decider's").toBeUndefined(); }); it.each([['approve', 'after1'], ['reject', 'rejected']] as const)( @@ -265,7 +268,7 @@ describe("after an approval decision a runAs:'user' run's data operations carry const h = envelopeHarness(); h.engine.registerFlow('sys_review', reviewFlow('sys_review', { runAs: 'system', recorder: true }) as never); // The REST / action doors' shape: the trigger forwards its own grants. - const paused = await h.engine.execute('sys_review', { ...TRIGGER, positions: ['sales_rep'], permissions: ['deal_rep'] } as never); + const paused = await h.engine.execute('sys_review', { ...TRIGGER, positions: ['sales_rep'], permissions: ['deal_rep'], email: GRANTS.usr_rep.email } as never); expect(h.ctxOf('submit')).toMatchObject({ isSystem: true, userId: 'usr_rep' }); await h.engine.resume(paused.runId!, decided(decision, 'usr_mgr')); @@ -277,6 +280,8 @@ describe("after an approval decision a runAs:'user' run's data operations carry // grants left under the decider's name. expect(h.resolved.map(([u]) => u), 'a system run resolves no grant for the decider').not.toContain('usr_mgr'); expect(after.raw).toEqual({ userId: 'usr_mgr', positions: undefined, permissions: undefined }); + // The submitter's email is theirs too: it does not ride the decider's segment. + expect(after.raw.email).toBeUndefined(); }, ); diff --git a/packages/services/service-automation/src/runas-user-email-owner.integration.test.ts b/packages/services/service-automation/src/runas-user-email-owner.integration.test.ts new file mode 100644 index 00000000000..61a05cd50b2 --- /dev/null +++ b/packages/services/service-automation/src/runas-user-email-owner.integration.test.ts @@ -0,0 +1,297 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A `runAs:'user'` flow reads and writes the rows an email-keyed row policy + * grants its user, exactly as that user's own request does: the email-keyed + * twin of `runas-user-org-peers.integration.test.ts`. + * + * The defect this pins: the run's data context carried no `email`. The RLS + * compiler binds `current_user.email` from the execution context's `email` + * and nothing else, so a policy such as `assignee == current_user.email` (the + * stock showcase's `task_own_rows`) dropped out inside the run, and a + * record-change flow was refused on the row its triggering member had just + * written and could read and update with their own request. On the request + * path the email is seeded by the core resolver (`resolveUserAuthzGrants`, + * through `resolveAuthzContext`); the run now seeds it through the same + * resolver at run setup, and the decider's segment through the same seed. + * + * The REAL stack runs here, as in its twin: ObjectKernel, ObjectQLPlugin, + * driver-sql on better-sqlite3 `:memory:`, the identity objects, the real + * SecurityPlugin with its shipped default permission sets plus the fixture's + * set, and the real AutomationEngine and CRUD nodes. The defect lived in the + * hand-off between the run's identity and the RLS compiler, which no + * single-layer test sees. + * + * Fixture: `qa_owner_task`, whose only row scope is the set + * `qa_owner_contributor`: select and update both keyed on + * `assignee == current_user.email`. `usr_member` and `usr_decider` hold the + * set in the Default Organization, each with their own stored email. + * + * The record-change runs are started with the record-change trigger's own + * envelope (`record`, `object`, `event`, `userId`, `tenantId`, `params`: what + * `@objectstack/trigger-record-change` hands `automation.execute`), which + * carries no positions, permission sets or email. That package depends on + * this one, so its trigger is not composed here. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectKernel, resolveUserAuthzGrants, type PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { createIdentityObjectsPlugin } from '@objectstack/plugin-auth'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { defineActionDescriptor } from '@objectstack/spec/automation'; +import { RESUME_AUTHORITY_SERVICE, type ResumeSignal } from '@objectstack/spec/contracts'; +import { AutomationServicePlugin } from './plugin.js'; +import type { AutomationEngine } from './engine.js'; + +const SYS = { isSystem: true } as const; +const OBJECT = 'qa_owner_task'; +const MEMBER = 'usr_member'; +const DECIDER = 'usr_decider'; +const EMAIL: Record = { + [MEMBER]: 'qa-member@example.test', + [DECIDER]: 'qa-decider@example.test', +}; + +/** The showcase task's shape, cut to the fields the policy and the stamp read. */ +const taskObject = { + name: OBJECT, + label: 'Owner task', + sharingModel: 'public_read_write', + fields: { + title: { name: 'title', label: 'Title', type: 'text' }, + assignee: { name: 'assignee', label: 'Assignee', type: 'text' }, + notes: { name: 'notes', label: 'Notes', type: 'text' }, + }, +}; + +/** `showcase_contributor`'s `task_own_rows`, on both verbs the run uses. */ +const contributorSet = PermissionSetSchema.parse({ + name: 'qa_owner_contributor', + label: 'Owner task contributor', + objects: { [OBJECT]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false } }, + rowLevelSecurity: [ + { name: 'qa_task_own_rows', object: OBJECT, operation: 'select', using: 'assignee == current_user.email' }, + { name: 'qa_task_own_writes', object: OBJECT, operation: 'update', using: 'assignee == current_user.email' }, + ], +}); + +const cel = (source: string) => ({ dialect: 'cel', source }); + +/** + * The card's flow: a `runAs:'user'` record-change flow that reads the row it + * was triggered by, then stamps `notes` with `current_user.id`. + */ +const stampFlow = (name: string, runAs: 'user' | 'system' = 'user') => ({ + name, + label: name, + type: 'record_change', + runAs, + variables: [{ name: 'seen', type: 'object', isOutput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { objectName: OBJECT, triggerType: 'record-after-insert' } }, + { id: 'read', type: 'get_record', label: 'Read', config: { objectName: OBJECT, filter: { id: cel('record.id') }, outputVariable: 'seen' } }, + { id: 'stamp', type: 'update_record', label: 'Stamp', config: { objectName: OBJECT, filter: { id: cel('record.id') }, fields: { notes: cel('current_user.id') } } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'read' }, + { id: 'e2', source: 'read', target: 'stamp' }, + { id: 'e3', source: 'stamp', target: 'end' }, + ], +}); + +/** The approval node's stand-in: a pause only its owning service may continue. */ +const GATED = defineActionDescriptor({ + type: 'decision_gate', version: '1.0.0', name: 'Decision Gate', + supportsPause: true, resumeAuthority: 'service', +}); + +/** What `ApprovalService.decide` hands the engine for a human decision. */ +const decided = (resumedBy: string): ResumeSignal => ({ + branchLabel: 'approve', + output: { decision: 'approve' }, + resumedBy, + [RESUME_AUTHORITY_SERVICE]: true, +}); + +/** start → gate → stamp(`taskId`, notes = current_user.id) → end, under runAs:'user'. */ +const reviewFlow = (name: string) => ({ + name, + label: name, + type: 'autolaunched', + runAs: 'user', + variables: [{ name: 'taskId', type: 'text', isInput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'gate', type: 'decision_gate', label: 'Review' }, + { id: 'after', type: 'update_record', label: 'After the decision', config: { objectName: OBJECT, filter: { id: cel('taskId') }, fields: { notes: cel('current_user.id') } } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'gate' }, + { id: 'e2', source: 'gate', target: 'after', label: 'approve' }, + { id: 'e3', source: 'after', target: 'end' }, + ], +}); + +describe("a runAs:'user' flow reads and writes the rows an email-keyed policy grants its user, as the user's own request does", () => { + let kernel: ObjectKernel; + let ql: ObjectQL; + let automation: AutomationEngine; + let orgA = ''; + + beforeAll(async () => { + kernel = new ObjectKernel({ logger: { level: 'fatal' } }); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await kernel.use({ + name: 'test.driver.sqlite', type: 'standard', version: '1.0.0', + async init(ctx: PluginContext) { ctx.registerService('driver.default', driver); }, + }); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' })); + await kernel.use(createIdentityObjectsPlugin()); + await kernel.use(new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets, contributorSet] })); + await kernel.bootstrap(); + + ql = kernel.getService('objectql'); + automation = kernel.getService('automation'); + automation.registerNodeExecutor({ type: 'decision_gate', descriptor: GATED, async execute() { return { success: true, suspend: true }; } }); + ql.registry.registerObject(taskObject as any, 'email-owner-test', 'email-owner-test'); + await ql.syncSchemas(); + + const orgs = await ql.find('sys_organization', { context: SYS }); + expect(orgs.map((o: any) => o.slug)).toEqual(['default']); + orgA = String(orgs[0].id); + for (const id of [MEMBER, DECIDER]) { + await ql.insert('sys_user', { id, name: id, email: EMAIL[id] }, { context: SYS }); + await ql.insert('sys_member', { id: `mem_${id}`, user_id: id, organization_id: orgA, role: 'member' }, { context: SYS }); + await ql.insert('sys_user_permission_set', { id: `ups_${id}`, user_id: id, permission_set: contributorSet.name, organization_id: orgA }, { context: SYS }); + } + + automation.registerFlow('qa_stamp_current_user', stampFlow('qa_stamp_current_user') as never); + automation.registerFlow('qa_stamp_system', stampFlow('qa_stamp_system', 'system') as never); + automation.registerFlow('qa_review', reviewFlow('qa_review') as never); + }, 30_000); + + afterAll(async () => { + try { await kernel?.shutdown(); } catch { /* noop */ } + }); + + /** + * The context the request path hands ObjectQL for `userId` acting in the + * organization: `resolveAuthzContext` copies exactly these grants onto it, + * the email included (`ctx.email`, the session's or else the stored one). + */ + async function requestContext(userId: string, opts: { email?: boolean } = {}) { + const g = await resolveUserAuthzGrants(ql, userId, { tenantId: orgA, bypassGrantsCache: true }); + return { + userId, + tenantId: orgA, + isSystem: false, + positions: g.positions, + permissions: g.permissions, + systemPermissions: g.systemPermissions, + org_user_ids: g.org_user_ids, + accessible_org_ids: g.accessible_org_ids, + ...(opts.email === false || !g.email ? {} : { email: g.email }), + }; + } + + const stored = async (id: string) => (await ql.findOne(OBJECT, { where: { id }, context: SYS })) as any; + + /** The member writes a task assigned to `assignee` through their own request. */ + async function memberWrites(title: string, assignee = EMAIL[MEMBER]) { + const row: any = await ql.insert(OBJECT, { title, assignee }, { context: await requestContext(MEMBER) }); + return { ...row, ...(await stored(String(row.id))) }; + } + + /** A task the platform writes, assigned to `assignee`: nobody's own write. */ + async function systemWrites(title: string, assignee: string) { + const row: any = await ql.insert(OBJECT, { title, assignee }, { context: SYS }); + return String(row.id); + } + + /** What the record-change trigger hands `automation.execute` for the member's insert. */ + const recordChangeEnvelope = (row: Record, extra: Record = {}) => ({ + record: row, + object: OBJECT, + event: 'record-after-insert', + userId: MEMBER, + tenantId: orgA, + params: row, + ...extra, + }); + + it("CONTROL — the member's own request reads and updates the row assigned to their email", async () => { + const row = await memberWrites('qa-flow control'); + const ctx = await requestContext(MEMBER); + expect(ctx.email, 'the request path seeds the stored email').toBe(EMAIL[MEMBER]); + expect((await ql.findOne(OBJECT, { where: { id: row.id }, context: ctx }) as any)?.id).toBe(row.id); + await ql.update(OBJECT, { id: row.id, notes: 'by hand' }, { context: ctx }); + expect((await stored(row.id)).notes).toBe('by hand'); + }); + + it('GUARD — the email-keyed policy is the only access: the same request without the email does not see the row', async () => { + const row = await memberWrites('qa-flow guard'); + const noEmail = await requestContext(MEMBER, { email: false }); + expect(await ql.findOne(OBJECT, { where: { id: row.id }, context: noEmail })).toBeFalsy(); + }); + + it("the card's reproduction: a record-change run triggered by the member reads the row and stamps it with the member's id", async () => { + const row = await memberWrites('qa-flow-member3'); + const res: any = await automation.execute('qa_stamp_current_user', recordChangeEnvelope(row) as never); + expect(res.success, `run failed: ${JSON.stringify(res)}`).toBe(true); + expect(res.output?.seen?.id, 'the run read the row').toBe(row.id); + expect((await stored(row.id)).notes, 'the stamp is the member').toBe(MEMBER); + }); + + it("the doors' pre-resolved shape (positions and permission sets forwarded, no email) reads and stamps the same", async () => { + const row = await memberWrites('qa-flow forwarded'); + const req = await requestContext(MEMBER); + const res: any = await automation.execute('qa_stamp_current_user', recordChangeEnvelope(row, { + positions: req.positions, permissions: req.permissions, + }) as never); + expect(res.success, `run failed: ${JSON.stringify(res)}`).toBe(true); + expect((await stored(row.id)).notes).toBe(MEMBER); + }); + + it("nothing beyond: the run is refused on a row assigned to someone else's email, as the member's own request is", async () => { + const id = await systemWrites('qa-flow theirs', EMAIL[DECIDER]); + expect(await ql.findOne(OBJECT, { where: { id }, context: await requestContext(MEMBER) }), 'control: the request does not see it').toBeFalsy(); + const res: any = await automation.execute('qa_stamp_current_user', recordChangeEnvelope({ id, title: 'qa-flow theirs' }) as never); + expect(res.success).toBe(false); + expect((await stored(id)).notes ?? null, 'nothing landed').toBeNull(); + }); + + it("after an approval decision the post-decision write carries the decider's email: the decider's row is stamped with the decider", async () => { + const id = await systemWrites('qa-flow decider row', EMAIL[DECIDER]); + const paused = await automation.execute('qa_review', { userId: MEMBER, tenantId: orgA, params: { taskId: id } } as never); + expect(paused.status, JSON.stringify(paused)).toBe('paused'); + const res: any = await automation.resume(paused.runId!, decided(DECIDER)); + expect(res.success, `resume failed: ${JSON.stringify(res)}`).toBe(true); + expect((await stored(id)).notes, 'the post-decision write is the decider\'s').toBe(DECIDER); + }); + + it("after an approval decision the submitter's email does not survive: the submitter's row is refused to the decider, as the decider's own request is", async () => { + const id = await systemWrites('qa-flow submitter row', EMAIL[MEMBER]); + await expect( + ql.update(OBJECT, { id, notes: 'by hand' }, { context: await requestContext(DECIDER) }), + 'control: the decider\'s own request may not write the submitter\'s row', + ).rejects.toThrow(); + const paused = await automation.execute('qa_review', { userId: MEMBER, tenantId: orgA, params: { taskId: id } } as never); + expect(paused.status, JSON.stringify(paused)).toBe('paused'); + const res: any = await automation.resume(paused.runId!, decided(DECIDER)); + expect(res.success).toBe(false); + expect((await stored(id)).notes ?? null, 'nothing landed').toBeNull(); + }); + + it("CONTROL — a runAs:'system' run is unchanged: elevated, it stamps the row whatever the policy reads", async () => { + const id = await systemWrites('qa-flow system', 'nobody@example.test'); + const res: any = await automation.execute('qa_stamp_system', recordChangeEnvelope({ id, title: 'qa-flow system' }) as never); + expect(res.success, `run failed: ${JSON.stringify(res)}`).toBe(true); + expect((await stored(id)).notes).toBe(MEMBER); + }); +});