Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/24613-run-identity-email.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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));
Expand All @@ -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();
Expand Down Expand Up @@ -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);
}
});

Expand Down
Loading
Loading