diff --git a/.changeset/28099-door-runs-carry-posture-rung.md b/.changeset/28099-door-runs-carry-posture-rung.md new file mode 100644 index 00000000000..18af3dcb2fe --- /dev/null +++ b/.changeset/28099-door-runs-carry-posture-rung.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': patch +'@objectstack/runtime': patch +'@objectstack/service-automation': patch +--- + +fix(runtime): a flow started through the REST or action door carries its caller's posture rung beside their positions and permission sets, so a platform administrator's manual run is served the ledger's deployment-level rows their request is served (#28099) + +Clause-②: yes (widening) — `AutomationContext` (`@objectstack/spec/contracts`) gains one optional member, `posture` (typed `ExecutionContext['posture']`); the REST trigger door and the action door fill it from the request's resolved execution context, and a run carrying it is served what that rung admits. +Metadata-protocol: none + +A request carries the posture rung its user resolves to (`ExecutionContext.posture`, ADR-0095 D2/D3). Since #28070 a `runAs: 'user'` run whose grants the engine resolves (a record-change run, the segment after an approval decision) carries that rung too. A run started through a door did not: the trigger door (`buildAutomationContext`, `POST /api/v1/automation/:name/trigger`, the legacy trigger route and a declared `type: 'flow'` endpoint) and the action door (`dispatchFlowAction`, REST `/actions` and MCP `run_action`) forward the caller's positions and permission sets, so run setup does not re-resolve them, and they dropped the rung. A platform administrator's manually-run flow was then served the organization's ledger rows only. That failed closed (fewer rows, never more). + +- **Now.** Both doors copy the request's own `ec.posture` onto the run context, as they copy its positions, permission sets, organization and locale. Run setup keeps it beside the forwarded grants and does not re-derive it, and every data node carries it to the data engine. The platform administrator's door-started run is served the deployment-level rows, as their request is. +- **The caller's own rung, as-is.** It is read off the execution context only (a request body key named `posture` stays a flow param), never derived from the flow's author, never raised, and never filled in when the request carries none: such a run carries no rung, as before. +- **Only beside the grants it came from.** A door forwards positions and permission sets only when they are non-empty, so a rung can arrive without them. Run setup then drops the forwarded rung and resolves the grants itself, taking the rung off that same resolution; a run whose grants stay unresolved (the resolver has no rung, rejects, answers nothing, or is not wired) carries none. +- **Docs.** The trigger route's *Identity* paragraph in `flows.mdx` now names every fact the doors forward (user, positions, permissions, tenant, posture rung, language), and `record-view-auditing.mdx` no longer says a door-started run is "not, yet" served the deployment-level rows. +- **Unchanged.** A run whose grants the engine resolves (#28070), the approval decision switch, and a `runAs: 'system'` run. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 83713fecee3..fd52661f089 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -2515,9 +2515,13 @@ unbound unless its declaration carries a [`defaultValue`](#defaultvalue--declaring-a-variable-is-not-the-same-as-binding-it); a param you send explicitly always wins, `false` and `null` included. -**Identity.** The caller's identity (user, positions, permissions, tenant) is -forwarded into the run, so a `runAs: 'user'` flow executes under that caller's -row-level security. See [API Authentication](/docs/api#authentication) for +**Identity.** The caller's identity (user, positions, permissions, tenant, +posture rung) and language are forwarded into the run, as the caller's request +resolved them, so a `runAs: 'user'` flow executes under that caller's +row-level security and reads what the caller's own request reads. The rung is +the caller's own, never the flow author's and never raised; a request without +one starts a run without one. The run's setup adds the user's email and +organization peers. See [API Authentication](/docs/api#authentication) for credentials. diff --git a/content/docs/permissions/record-view-auditing.mdx b/content/docs/permissions/record-view-auditing.mdx index 7c537c32490..84f4a7177ee 100644 --- a/content/docs/permissions/record-view-auditing.mdx +++ b/content/docs/permissions/record-view-auditing.mdx @@ -228,7 +228,8 @@ reader is served one, this capability's holders included. A `runAs: 'user'` flow whose grants the engine resolves for its user (a record-change run, or the segment after an approval decision) carries that user's posture, so one run as the platform administrator is served these rows as their request is; a run -started through the REST or action door is not, yet. +started through the REST or action door carries the posture its caller's +request carries, and is served them the same way. Rows carry the ADR-0057 `audit` lifecycle class: retained hot for 90 days, then archived for seven years where an `archive` datasource is diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 8dfaf574d5e..8591efd8e2e 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -2077,6 +2077,10 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps, ...(Array.isArray(ec?.positions) && ec.positions.length ? { positions: ec.positions } : {}), ...(Array.isArray(ec?.permissions) && ec.permissions.length ? { permissions: ec.permissions } : {}), ...(ec?.tenantId ? { tenantId: ec.tenantId } : {}), + // [#28099] The caller's already-resolved posture rung, the trigger + // door's twin (`buildAutomationContext`): never re-derived or raised, + // absent when the request carries none. See `AutomationContext.posture`. + ...(typeof ec?.posture === 'string' && ec.posture.length > 0 ? { posture: ec.posture } : {}), // [#22450] The caller's already-resolved language, the trigger door's // twin (`buildAutomationContext`): a refused run's message renders in // it. See `AutomationContext.locale`. diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 87963f89fea..53e3de08b8c 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -204,6 +204,12 @@ export function buildAutomationContext(body: any, context: HttpProtocolContext): if (Array.isArray(ec?.positions) && ec.positions.length) automationContext.positions = ec.positions; if (Array.isArray(ec?.permissions) && ec.permissions.length) automationContext.permissions = ec.permissions; if (ec?.tenantId) automationContext.tenantId = ec.tenantId; + // [#28099] The caller's posture rung, already resolved beside the grants + // above — forwarded, never re-derived or raised, absent when the request + // carries none. The run carries those grants, so its setup does not + // re-resolve them and this is the only way it gets one. + // `AutomationContext.posture` names its readers. + if (typeof ec?.posture === 'string' && ec.posture.length > 0) automationContext.posture = ec.posture; // [#22450] The caller's language, already resolved (the request's // `Accept-Language` first, then the `localization` settings) — forwarded, // never re-derived, so a refused run's message renders in the language of diff --git a/packages/runtime/src/flow-run-posture.test.ts b/packages/runtime/src/flow-run-posture.test.ts new file mode 100644 index 00000000000..1cea0b846a3 --- /dev/null +++ b/packages/runtime/src/flow-run-posture.test.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The two flow doors forward the caller's posture rung — + * `AutomationContext.posture` (#28099, the door half of #28070). + * + * A request carries the rung its user resolves to (`ExecutionContext.posture`, + * ADR-0095 D2/D3), derived once by `resolveAuthzContext`. The doors already + * hold it beside the caller's positions and permission sets, which they + * forward; a run that carries those grants is not re-resolved, so a rung the + * door drops is a rung the run never has. The door forwards the caller's OWN + * rung as-is — ⛔ never derived from the flow's author, never raised, never + * filled in when the request carries none. + * + * What this file pins — the PRODUCER half. The consumer half, on the real + * automation engine, is `runas-user-posture-rung.integration.test.ts` in + * `@objectstack/service-automation`, which runs a door-shaped context (carried + * grants plus a carried rung) to a data node. + * + * 1. The trigger door (`buildAutomationContext`) forwards `ec.posture`, and + * both of its routes hand it to the engine. + * 2. The action door (`dispatchFlowAction`) forwards it. + * 3. A request with no rung (anonymous, or a principal the resolver gave + * none) adds no key — absent, never a default rung. + * 4. The request body cannot name one: `buildAutomationContext` reads the + * rung off the execution context only, and a body key named `posture` + * stays a flow param. + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { HttpDispatcher } from './http-dispatcher.js'; +import { dispatchFlowAction, loadActionSubjectRecord } from './action-execution.js'; +import { buildAutomationContext } from './domains/automation.js'; + +const FLOW_NAME = 'quote_generation'; + +const QUOTE = { + name: 'generate_quote', + label: 'Generate quote', + objectName: 'crm_opportunity', + type: 'flow', + target: FLOW_NAME, +}; + +function ec(posture?: string) { + return { + userId: 'usr_1', tenantId: 'org_1', positions: ['platform_admin'], permissions: ['admin_full_access'], systemPermissions: [], + ...(posture !== undefined ? { posture } : {}), + }; +} + +/** An automation double that records the context it is handed. */ +function makeAutomation() { + const execute = vi.fn(async (_flow: string, _context?: any) => ({ success: true, output: {} })); + const getFlow = vi.fn(async (name: string) => (name === FLOW_NAME ? { name } : null)); + return { execute, getFlow }; +} +const contextOf = (automation: { execute: any }) => automation.execute.mock.calls[0]?.[1]; +const flowDeps = (automation: any): any => ({ + resolveService: async (_ctx: any, name: string) => (name === 'automation' ? automation : undefined), +}); +const REQUEST: any = { request: {}, environmentId: 'platform' }; + +describe('[#28099] the trigger door forwards the caller\'s posture rung', () => { + it('`buildAutomationContext` carries `executionContext.posture` as `posture`, beside the grants it carries', () => { + const ctx: any = buildAutomationContext( + { recordId: 'opp_1', objectName: 'crm_opportunity', params: {} }, + { request: {}, executionContext: ec('PLATFORM_ADMIN') } as any, + ); + expect(ctx).toMatchObject({ permissions: ['admin_full_access'], posture: 'PLATFORM_ADMIN' }); + }); + + it('a request with no rung adds no key: absent, never a default rung', () => { + const ctx: any = buildAutomationContext( + { recordId: 'opp_1', objectName: 'crm_opportunity', params: {} }, + { request: {}, executionContext: ec() } as any, + ); + expect('posture' in ctx).toBe(false); + const anonymous: any = buildAutomationContext({ params: {} }, { request: {} } as any); + expect('posture' in anonymous).toBe(false); + }); + + it('the body cannot name a rung: a flat `posture` key stays a flow param, a wrapped one never leaves `params`', () => { + const flat: any = buildAutomationContext( + { posture: 'PLATFORM_ADMIN' }, + { request: {}, executionContext: ec('MEMBER') } as any, + ); + expect(flat.posture).toBe('MEMBER'); + expect(flat.params.posture).toBe('PLATFORM_ADMIN'); + const noRung: any = buildAutomationContext( + { posture: 'PLATFORM_ADMIN', params: { posture: 'PLATFORM_ADMIN' } }, + { request: {}, executionContext: ec() } as any, + ); + expect('posture' in noRung).toBe(false); + }); + + for (const route of [ + { label: 'POST /:name/trigger', path: `/${FLOW_NAME}/trigger` }, + { label: 'legacy POST /trigger/:name', path: `/trigger/${FLOW_NAME}` }, + ]) { + it(`${route.label} hands the engine the rung`, async () => { + const automation = makeAutomation(); + const resolve = (n: string) => (n === 'automation' ? automation : undefined); + const kernel: any = { getService: resolve, getServiceAsync: async (n: string) => resolve(n), context: { getService: resolve } }; + await new HttpDispatcher(kernel).handleAutomation( + route.path, 'POST', + { recordId: 'opp_1', objectName: 'crm_opportunity', params: {} }, + { request: {}, executionContext: ec('PLATFORM_ADMIN') } as any, + ); + expect(automation.execute).toHaveBeenCalledTimes(1); + expect(contextOf(automation).posture).toBe('PLATFORM_ADMIN'); + }); + } +}); + +describe('[#28099] the action door forwards the caller\'s posture rung', () => { + it('`dispatchFlowAction` carries `ec.posture` as `posture`, beside the grants it carries', async () => { + const automation = makeAutomation(); + const subject = await loadActionSubjectRecord('crm_opportunity', 'opp_1', async () => ({ record: { id: 'opp_1' } })); + await dispatchFlowAction(flowDeps(automation), REQUEST, QUOTE, { + objectName: 'crm_opportunity', subject, params: {}, recordId: 'opp_1', ec: ec('PLATFORM_ADMIN'), envId: 'platform', + }); + expect(contextOf(automation)).toMatchObject({ permissions: ['admin_full_access'], posture: 'PLATFORM_ADMIN' }); + }); + + it('a request with no rung adds no key: absent, never a default rung', async () => { + const automation = makeAutomation(); + const subject = await loadActionSubjectRecord('crm_opportunity', 'opp_1', async () => ({ record: { id: 'opp_1' } })); + await dispatchFlowAction(flowDeps(automation), REQUEST, QUOTE, { + objectName: 'crm_opportunity', subject, params: {}, recordId: 'opp_1', ec: ec(), envId: 'platform', + }); + expect('posture' in contextOf(automation)).toBe(false); + }); +}); diff --git a/packages/services/service-automation/src/run-resume.ts b/packages/services/service-automation/src/run-resume.ts index e836ac2222a..6d754af7e3a 100644 --- a/packages/services/service-automation/src/run-resume.ts +++ b/packages/services/service-automation/src/run-resume.ts @@ -205,6 +205,11 @@ export async function seedRunGrants( const needsGrants = !Array.isArray(runContext.permissions); const needsPeers = runOrgUserIds(runContext) === undefined; const needsEmail = !runContext.email; + // A rung a door forwarded without grants (#28099) is never kept beside + // grants it was not derived from: the resolution below re-seeds the rung + // with the grants, and a run whose grants stay unresolved (the resolver + // throws, answers nothing, or is not wired) carries none. + if (needsGrants) delete (runContext as SeededRunContext).posture; if ( runContext.runAs !== 'system' && runContext.userId && diff --git a/packages/services/service-automation/src/runas-user-posture-rung.integration.test.ts b/packages/services/service-automation/src/runas-user-posture-rung.integration.test.ts index 28e4bb09120..bdeeb338e65 100644 --- a/packages/services/service-automation/src/runas-user-posture-rung.integration.test.ts +++ b/packages/services/service-automation/src/runas-user-posture-rung.integration.test.ts @@ -31,6 +31,14 @@ * administrator reads the deployment-level ledger rows its user's request * reads, and a member's run is served what the member's request is * served, with or without the rung. + * + * [#28099] The door half: a run started through the REST or action door + * carries the caller's resolved grants, so run setup does not re-resolve them, + * and its rung is the one the door forwarded (`AutomationContext.posture`). + * Both layers pin it: the forwarded rung is carried as forwarded, never + * re-derived or raised; no forwarded rung is no rung; a rung forwarded without + * grants gives way to the rung of the grants run setup resolves. The doors' + * producer half is `flow-run-posture.test.ts` in `@objectstack/runtime`. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -186,6 +194,80 @@ describe("[#28070] a runAs:'user' run's data context carries the rung its resolv }); }); +describe("[#28099] a door-started run carries the rung its door forwarded, beside the grants it forwarded", () => { + /** + * What the REST trigger door (`buildAutomationContext`) and the action door + * (`dispatchFlowAction`) in `@objectstack/runtime` hand `execute`: the + * request's own resolved grants, and since #28099 its rung. + */ + const doorEnvelope = (userId: string, posture?: string) => ({ + userId, tenantId: 'org_a', event: 'manual', params: {}, + positions: GRANTS[userId].positions, permissions: GRANTS[userId].permissions, + ...(posture !== undefined ? { posture } : {}), + }); + + it("the platform administrator's door-started run carries the forwarded PLATFORM_ADMIN with the forwarded grants", async () => { + const h = envelopeHarness(); + h.engine.registerFlow('rung', recordingFlow('rung') as never); + await h.engine.execute('rung', doorEnvelope('usr_admin', 'PLATFORM_ADMIN') as never); + expect(h.ctxOf('first')).toMatchObject({ + isSystem: false, userId: 'usr_admin', permissions: ['admin_full_access'], posture: 'PLATFORM_ADMIN', + }); + }); + + it("the forwarded rung is the run's: run setup does not re-derive it, so it is never raised to the one the resolver would answer", async () => { + const h = envelopeHarness(); + h.engine.registerFlow('rung', recordingFlow('rung') as never); + await h.engine.execute('rung', doorEnvelope('usr_admin', 'MEMBER') as never); + expect(h.ctxOf('first')).toMatchObject({ userId: 'usr_admin', permissions: ['admin_full_access'], posture: 'MEMBER' }); + }); + + it('a door that forwarded no rung gives a run without one: absent, never a default', async () => { + const h = envelopeHarness(); + h.engine.registerFlow('rung', recordingFlow('rung') as never); + await h.engine.execute('rung', doorEnvelope('usr_admin') as never); + const ctx = h.ctxOf('first'); + expect(ctx).toMatchObject({ userId: 'usr_admin', permissions: ['admin_full_access'] }); + expect(ctx).not.toHaveProperty('posture'); + }); + + it('a forwarded rung without forwarded grants is replaced by the rung of the grants run setup resolves', async () => { + const h = envelopeHarness(); + h.engine.registerFlow('rung', recordingFlow('rung') as never); + // A door forwards positions and permission sets only when non-empty: the + // rung can arrive alone, and the grants are then re-resolved. + await h.engine.execute('rung', { userId: 'usr_member', tenantId: 'org_a', posture: 'PLATFORM_ADMIN' } as never); + expect(h.ctxOf('first')).toMatchObject({ userId: 'usr_member', permissions: ['member_default'], posture: 'MEMBER' }); + }); + + it('a forwarded rung without forwarded grants is dropped when the resolution answers none', async () => { + const h = envelopeHarness(); + h.engine.registerFlow('rung', recordingFlow('rung') as never); + h.engine.setUserGrantsResolver(async () => ({ positions: ['everyone'], permissions: ['member_default'] })); + await h.engine.execute('rung', { userId: 'usr_member', tenantId: 'org_a', posture: 'PLATFORM_ADMIN' } as never); + const ctx = h.ctxOf('first'); + expect(ctx).toMatchObject({ userId: 'usr_member', permissions: ['member_default'] }); + expect(ctx).not.toHaveProperty('posture'); + }); + + it('a forwarded rung without forwarded grants is dropped when the resolution fails: the resolver rejects, answers nothing, or is not wired', async () => { + const alone = { userId: 'usr_member', tenantId: 'org_a', posture: 'PLATFORM_ADMIN' }; + for (const resolver of [ + async () => { throw new Error('grants store unavailable'); }, + async () => undefined, + null, + ]) { + const h = envelopeHarness(); + h.engine.registerFlow('rung', recordingFlow('rung') as never); + h.engine.setUserGrantsResolver(resolver as never); + await h.engine.execute('rung', { ...alone } as never); + const ctx = h.ctxOf('first'); + expect(ctx).toMatchObject({ isSystem: false, userId: 'usr_member' }); + expect(ctx).not.toHaveProperty('posture'); + } + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // 2. The real stack // ───────────────────────────────────────────────────────────────────────────── @@ -321,6 +403,40 @@ describe("[#28070] a runAs:'user' flow is served the ledger rows its user's requ expect(await requestReads(await requestContext(MEMBER, { posture: false }))).toEqual(ran); }); + /** + * [#28099] What the REST and action doors hand `automation.execute` for the + * user's request: its own resolved grants, and its rung unless `posture` is + * false (the doors' shape before #28099). + */ + async function doorEnvelope(userId: string, opts: { posture?: boolean } = {}) { + const r = await requestContext(userId, opts); + return { + userId, tenantId: orgA, event: 'manual', params: {}, + positions: r.positions, permissions: r.permissions, + ...('posture' in r ? { posture: r.posture } : {}), + }; + } + + it("[#28099] CONTROL: a door-started run without the forwarded rung (the doors' shape before the fix) is served the organization row only", async () => { + const res: any = await automation.execute('qa_ledger_read', (await doorEnvelope(ADMIN, { posture: false })) as never); + expect(res.success, `run failed: ${JSON.stringify(res)}`).toBe(true); + expect(ids(res.output?.rows)).toEqual(orgIds); + }); + + it("[#28099] the platform administrator's door-started run, carrying the rung the door forwarded, is served every deployment-level row, as their request is", async () => { + const res: any = await automation.execute('qa_ledger_read', (await doorEnvelope(ADMIN)) as never); + expect(res.success, `run failed: ${JSON.stringify(res)}`).toBe(true); + expect(ids(res.output?.rows)).toEqual([...standingIds, ...orgIds].sort()); + expect(ids(res.output?.rows)).toEqual(await requestReads(await requestContext(ADMIN))); + }); + + it("[#28099] a member's door-started run is served what the member's request is served, and no deployment-level row", async () => { + const res: any = await automation.execute('qa_ledger_read', (await doorEnvelope(MEMBER)) as never); + expect(res.success, `run failed: ${JSON.stringify(res)}`).toBe(true); + expect(ids(res.output?.rows)).toEqual(orgIds); + expect(await requestReads(await requestContext(MEMBER))).toEqual(orgIds); + }); + it("a runAs:'system' run is unchanged: elevated, it is served every row as before", async () => { const res: any = await automation.execute('qa_ledger_read_system', userOnlyEnvelope(MEMBER) as never); expect(res.success, `run failed: ${JSON.stringify(res)}`).toBe(true); diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 2d27992419e..dc8933b464c 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -93,6 +93,31 @@ export interface AutomationContext { * direct REST request). Forwarded alongside {@link positions}. ADR-0049 / #1888. */ permissions?: string[]; + /** + * The posture rung of the triggering identity (ADR-0095 D2/D3), the kernel + * `ExecutionContext.posture` under its own name: what the readers keyed on + * the rung read, the compliance ledger's deployment-level rows among them + * (`PLATFORM_ADMIN` alone is served those). Forwarded alongside + * {@link positions} and {@link permissions} by the two doors that start a + * run on a person's behalf (`buildAutomationContext` and + * `dispatchFlowAction` in `@objectstack/runtime`), copied off the + * request's already-resolved execution context, so the run reads what the + * same caller's request reads. #28099. + * + * ⛔ It is the caller's own rung, as-is: never derived from the flow's + * author, never raised, and never filled in when the request carries none. + * Run setup keeps a forwarded rung beside the forwarded grants and does not + * re-derive it. When it resolves the grants itself (a context with no + * {@link permissions}: a record-change run, the segment after an approval + * decision), it drops any forwarded rung and takes the rung off that same + * resolution; grants that stay unresolved (the resolver rejects, answers + * nothing, or is not wired) leave the run with none. So a rung stands only + * beside the grants it was derived from. Absent: + * every reader of it answers as for a principal below the rung, which + * narrows and never widens. Absent on a `runAs:'system'` run's data + * context, whose `isSystem` short-circuit precedes every reader of it. + */ + posture?: ExecutionContext['posture']; /** * Tenant/org id of the triggering identity, carried so a `runAs:'user'` run * stays tenant-scoped. ADR-0049 / #1888.