From bd3ac11c094058270ab0f1c10dc3f9820a2443b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 13:40:03 +0000 Subject: [PATCH 1/2] fix(runtime): a flow started through the REST or action door carries its caller's posture rung beside their positions and permission sets The trigger door (buildAutomationContext) and the action door (dispatchFlowAction) forwarded the request's positions, permission sets, organization and locale, but not its posture rung. A run that carries grants is not re-resolved at setup, so it ran with no rung, and a platform administrator's manual run was served the organization's ledger rows only (fail closed). - AutomationContext gains one optional member, posture, typed ExecutionContext['posture'] and documented beside positions and permissions. - Both doors copy ec.posture as-is, absent when the request has none; a request body key cannot name it. - seedRunGrants: when run setup resolves the grants itself, the rung of that resolution replaces a forwarded one, or none is carried. - record-view-auditing.mdx: the door case is no longer "not yet". Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4czNtyUvgvgGC1mdxrU --- .../28099-door-runs-carry-posture-rung.md | 17 +++ .../docs/permissions/record-view-auditing.mdx | 3 +- packages/runtime/src/action-execution.ts | 4 + packages/runtime/src/domains/automation.ts | 6 + packages/runtime/src/flow-run-posture.test.ts | 135 ++++++++++++++++++ .../service-automation/src/run-resume.ts | 3 + ...unas-user-posture-rung.integration.test.ts | 99 +++++++++++++ .../spec/src/contracts/automation-service.ts | 23 +++ 8 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 .changeset/28099-door-runs-carry-posture-rung.md create mode 100644 packages/runtime/src/flow-run-posture.test.ts 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..da3e04b885c --- /dev/null +++ b/.changeset/28099-door-runs-carry-posture-rung.md @@ -0,0 +1,17 @@ +--- +'@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 resolves the grants itself and takes the rung off that same resolution, dropping the forwarded one (or carrying none when the resolution has none). +- **Unchanged.** A run whose grants the engine resolves (#28070), the approval decision switch, and a `runAs: 'system'` run. 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 eea583c679a..de5377bfa93 100644 --- a/packages/services/service-automation/src/run-resume.ts +++ b/packages/services/service-automation/src/run-resume.ts @@ -219,7 +219,10 @@ export async function seedRunGrants( // as the user's request carries it. Seeded with them and // only with them: carried grants keep the rung they came // with, so a narrowed ceiling never gains a wider rung. + // A rung a door forwarded without grants (#28099) gives + // way to this one, or to none. if (grants.posture) (runContext as SeededRunContext).posture = grants.posture; + else delete (runContext as SeededRunContext).posture; } if (needsPeers && Array.isArray(grants.org_user_ids)) { (runContext as SeededRunContext).org_user_ids = grants.org_user_ids; 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..57a4f5d7ebf 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,63 @@ 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'); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // 2. The real stack // ───────────────────────────────────────────────────────────────────────────── @@ -321,6 +386,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..341955b437b 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -93,6 +93,29 @@ 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 takes the rung off that same resolution and drops any other + * one, 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. From b53b0297da66c321f28074e3b9d646cdba0c4301 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:18:28 +0000 Subject: [PATCH 2/2] fix(service-automation): a rung forwarded without grants is dropped whenever run setup resolves the grants, and the trigger route names every fact the doors forward seedRunGrants drops a forwarded posture up front whenever the context carries no permission sets, then re-seeds it only from a resolution that answers grants; a resolver that rejects, answers nothing or is not wired now leaves the run with no rung instead of the forwarded one. flows.mdx: the trigger route's Identity paragraph lists the posture rung and language beside user, positions, permissions and tenant. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4czNtyUvgvgGC1mdxrU --- .../28099-door-runs-carry-posture-rung.md | 3 ++- content/docs/automation/flows.mdx | 10 +++++++--- .../service-automation/src/run-resume.ts | 8 +++++--- .../runas-user-posture-rung.integration.test.ts | 17 +++++++++++++++++ .../spec/src/contracts/automation-service.ts | 6 ++++-- 5 files changed, 35 insertions(+), 9 deletions(-) diff --git a/.changeset/28099-door-runs-carry-posture-rung.md b/.changeset/28099-door-runs-carry-posture-rung.md index da3e04b885c..18af3dcb2fe 100644 --- a/.changeset/28099-door-runs-carry-posture-rung.md +++ b/.changeset/28099-door-runs-carry-posture-rung.md @@ -13,5 +13,6 @@ A request carries the posture rung its user resolves to (`ExecutionContext.postu - **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 resolves the grants itself and takes the rung off that same resolution, dropping the forwarded one (or carrying none when the resolution has none). +- **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/packages/services/service-automation/src/run-resume.ts b/packages/services/service-automation/src/run-resume.ts index cb862a7b1c8..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 && @@ -222,10 +227,7 @@ export async function seedRunGrants( // as the user's request carries it. Seeded with them and // only with them: carried grants keep the rung they came // with, so a narrowed ceiling never gains a wider rung. - // A rung a door forwarded without grants (#28099) gives - // way to this one, or to none. if (grants.posture) (runContext as SeededRunContext).posture = grants.posture; - else delete (runContext as SeededRunContext).posture; } if (needsPeers && Array.isArray(grants.org_user_ids)) { (runContext as SeededRunContext).org_user_ids = grants.org_user_ids; 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 57a4f5d7ebf..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 @@ -249,6 +249,23 @@ describe("[#28099] a door-started run carries the rung its door forwarded, besid 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'); + } + }); }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 341955b437b..dc8933b464c 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -109,8 +109,10 @@ export interface AutomationContext { * 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 takes the rung off that same resolution and drops any other - * one, so a rung stands only beside the grants it was derived from. Absent: + * 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.