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
49 changes: 49 additions & 0 deletions .changeset/runas-user-grant-resolution-3356.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
"@objectstack/core": minor
"@objectstack/service-automation": minor
"@objectstack/trigger-record-change": patch
---

fix(service-automation): `runAs:'user'` runs data ops with the triggering user's
real permission sets + positions, not a bare member fallback (#3356, follow-up to
#1888)

Since #1888 the automation engine honours `flow.runAs` (`system` elevates), but
the `runAs:'user'` credential propagation was hollow. A record-change-triggered
`runAs:'user'` flow ran its data nodes (`update_record`, …) with a **zero-grant**
principal — only the `member`/`everyone` baseline — even when the triggering user
was fully authorized. Two faces by object config: a `private` object 403'd the
in-flow write (`not permitted for positions [org_member, everyone]` — the user's
permission sets were invisible); a `public_read_write` object let the write
through but **silently stripped** readonly/FLS-gated fields. The root cause: the
ObjectQL record-change hook session carries only a `userId` — never the writer's
positions/permission sets — and nothing in between resolved them, so the comment
promising "enforces RLS exactly as the user who made the change" never held.

The fix resolves the triggering user's **actual** authorization at run setup, from
the same tables a direct REST request resolves through:

- **`@objectstack/core`** factors the userId-driven core of `resolveAuthzContext`
into a new exported `resolveUserAuthzGrants(ql, userId, opts)` — the single place
that reads `sys_member` / `sys_user_position` / `sys_*_permission_set` and
derives positions, permission-set names, `platform_admin`, and posture. The
HTTP resolver now delegates to it (behaviour byte-identical; the full contract
suite still passes), so a non-HTTP surface that already knows the user id builds
the SAME envelope instead of re-implementing the reads.
- **`@objectstack/service-automation`** gains `AutomationEngine.setUserGrantsResolver`,
wired by the plugin to `resolveUserAuthzGrants` over the objectql/data engine.
For a `runAs:'user'` run whose trigger left the authz envelope unresolved (no
`permissions`), the engine now resolves the user's positions + permission sets
once at run setup and threads them into every data node's ObjectQL context —
so the run enforces RLS/FLS exactly as that user. Contexts that already carry
`permissions` are left untouched (a REST trigger, and notably an ADR-0090 agent
ceiling acting on-behalf-of a user — always non-empty — so a deliberately
narrowed identity is never re-broadened). `runAs:'system'` is unchanged, and a
resolver error fails safe (warns, keeps the bare user — never elevates).
- **`@objectstack/trigger-record-change`** stops forwarding the misleading
half-populated `positions` (empty in practice, and never `permissions`) from the
hook session; it forwards `userId` + tenant only and lets the engine resolve the
full grants authoritatively.

When no ObjectQL engine is present (bare engine / tests) the resolver is unwired
and run identity is unchanged from before.
3 changes: 3 additions & 0 deletions packages/core/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,12 @@ export {

export {
resolveAuthzContext,
resolveUserAuthzGrants,
resolveLocalizationContext,
type ResolvedAuthzContext,
type ResolveAuthzInput,
type UserAuthzGrants,
type ResolveUserAuthzGrantsOptions,
type ResolveLocalizationInput,
} from './resolve-authz-context.js';

Expand Down
93 changes: 92 additions & 1 deletion packages/core/src/security/resolve-authz-context.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { resolveAuthzContext, resolveLocalizationContext } from './resolve-authz-context.js';
import { resolveAuthzContext, resolveUserAuthzGrants, resolveLocalizationContext } from './resolve-authz-context.js';
import { POSTURE_RANK } from './posture-ladder.js';
import type { AuthzPosture } from '@objectstack/spec/security';

Expand Down Expand Up @@ -375,3 +375,94 @@ describe('resolveAuthzContext — posture ladder (ADR-0095 D2/D3)', () => {
});
});

/**
* #3356 — the userId-driven core, callable WITHOUT an HTTP request. A
* `runAs:'user'` automation run knows the triggering user's id (the record-change
* hook session carries only that) and must build the SAME positions/permissions
* envelope a direct REST request from that user would resolve, so its data ops
* enforce RLS as that user — not the bare member/everyone fallback.
*/
describe('resolveUserAuthzGrants — userId-driven authz for non-HTTP surfaces (#3356)', () => {
it("resolves a known user's positions + permission-set names from the DB", async () => {
const ql = makeQl({
sys_user: [{ id: 'u1', email: 'ada@x.com' }],
sys_member: [{ user_id: 'u1', role: 'admin', organization_id: 'o1' }],
sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: null }],
sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', organization_id: null }],
sys_permission_set: [{ id: 'psA', name: 'ehr_all', system_permissions: ['cap_ehr'] }],
});
const grants = await resolveUserAuthzGrants(ql, 'u1', { tenantId: 'o1' });
expect(grants.positions).toContain('org_admin'); // sys_member owner/admin normalized
expect(grants.positions).toContain('approver'); // sys_user_position
expect(grants.positions).toContain('everyone'); // implicit audience anchor
expect(grants.permissions).toContain('ehr_all'); // user-scoped permission set
expect(grants.systemPermissions).toContain('cap_ehr');
expect(grants.email).toBe('ada@x.com');
});

it('matches resolveAuthzContext for the same user — one resolver, one envelope', async () => {
const tables = {
sys_user: [{ id: 'u1', email: 'ada@x.com' }],
sys_member: [],
sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }],
sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'ps1', organization_id: null }],
sys_position: [{ id: 'r1', name: 'contributor' }],
sys_position_permission_set: [{ position_id: 'r1', permission_set_id: 'ps1' }],
sys_permission_set: [{ id: 'ps1', name: 'contributor_ps', system_permissions: ['cap_x'] }],
};
const viaHttp = await resolveAuthzContext({ ql: makeQl(tables), headers: H(), getSession: session('u1') });
const viaUser = await resolveUserAuthzGrants(makeQl(tables), 'u1');
expect([...viaUser.positions].sort()).toEqual([...viaHttp.positions].sort());
expect([...viaUser.permissions].sort()).toEqual([...viaHttp.permissions].sort());
expect([...viaUser.systemPermissions].sort()).toEqual([...viaHttp.systemPermissions].sort());
expect(viaUser.posture).toBe(viaHttp.posture);
});

it('seeds caller-supplied permissions FIRST, then appends resolved set names', async () => {
const ql = makeQl({
sys_user: [{ id: 'u1' }],
sys_member: [],
sys_user_position: [],
sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'ps1', organization_id: null }],
sys_permission_set: [{ id: 'ps1', name: 'sales_ps' }],
});
const grants = await resolveUserAuthzGrants(ql, 'u1', { seedPermissions: ['api:scope'] });
expect(grants.permissions[0]).toBe('api:scope');
expect(grants.permissions).toContain('sales_ps');
});

it('a caller-supplied email wins over the sys_user read', async () => {
const ql = makeQl({ sys_user: [{ id: 'u1', email: 'db@x.com' }], sys_member: [], sys_user_position: [], sys_user_permission_set: [] });
const grants = await resolveUserAuthzGrants(ql, 'u1', { seedEmail: 'session@x.com' });
expect(grants.email).toBe('session@x.com');
});

it('a user with no grants gets the implicit everyone anchor, empty permissions (never null)', async () => {
const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], sys_user_position: [], sys_user_permission_set: [] });
const grants = await resolveUserAuthzGrants(ql, 'u1');
expect(grants.positions).toEqual(['everyone']);
expect(grants.permissions).toEqual([]);
expect(grants.org_user_ids).toEqual(['u1']);
});

it('fail-closed: no data engine yields an empty-but-valid envelope and never throws', async () => {
const grants = await resolveUserAuthzGrants(undefined, 'u1', { seedPermissions: ['api:scope'] });
expect(grants.positions).toEqual([]);
expect(grants.permissions).toEqual(['api:scope']);
expect(grants.org_user_ids).toEqual(['u1']);
});

it('drops permission-set grants outside their validity window (ADR-0091)', async () => {
const past = new Date(Date.now() - 86_400_000).toISOString();
const ql = makeQl({
sys_user: [{ id: 'u1' }],
sys_member: [],
sys_user_position: [],
sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', organization_id: null, valid_until: past }],
sys_permission_set: [{ id: 'psA', name: 'expired_ps' }],
});
const grants = await resolveUserAuthzGrants(ql, 'u1');
expect(grants.permissions).not.toContain('expired_ps');
});
});

Loading