diff --git a/.changeset/22345-run-provenance-context-retired.md b/.changeset/22345-run-provenance-context-retired.md new file mode 100644 index 00000000000..f39500a97cd --- /dev/null +++ b/.changeset/22345-run-provenance-context-retired.md @@ -0,0 +1,23 @@ +--- +'@objectstack/service-automation': minor +'@objectstack/plugin-security': patch +'@objectstack/runtime': patch +'@objectstack/service-analytics': patch +'@objectstack/platform-objects': patch +--- + +fix(service-automation)!: retire the `RunProvenanceContext` type; `RunDataContext` names only what `resolveRunDataContext` returns + +Clause-②: no (narrowing) + + + +**BREAKING** for TypeScript code that imports from `@objectstack/service-automation`: one type export is removed and one type narrows. It ships as `minor` under the launch-window convention for breaking changes. No runtime behaviour changes in any package. + +**What is removed.** The `RunProvenanceContext` type export, the `{ flowRunId }`-only "provenance" envelope, and its arm of `RunDataContext`. No code in this repository builds or reads one. Since ADR-0096 D5, the data engine's security middleware refuses a context that carries no principal and is not a system context (`403 PERMISSION_DENIED`, for every verb). So the type described a context the engine refuses, while its docstring said such a context was "indistinguishable from passing no context at all" and was let through. + +**What `RunDataContext` is now.** Exactly what `resolveRunDataContext` returns: `isSystem: true` for a `runAs: 'system'` run, and the triggering user's principal for a `runAs: 'user'` run. A run that resolves no principal is refused before any data operation (`AUTOMATION_UNSCOPED_RUN_DATA_ACCESS`), as before. + +**If your code named the type.** There is no replacement, because the context it described is refused. A custom data node builds its context with `resolveRunDataContext(context)`, typed `RunDataContext`. + +**`@objectstack/plugin-security`, `@objectstack/runtime`, `@objectstack/service-analytics`, `@objectstack/platform-objects`:** comment and docstring text only. Shipped comments that said the security middleware hands through, skips or falls open for a context with no principal now say that it used to, and that ADR-0096 D5 refuses such a context. Nothing these packages do changes. diff --git a/examples/app-showcase/src/automation/flows/index.ts b/examples/app-showcase/src/automation/flows/index.ts index 562e884310e..be08d64ff11 100644 --- a/examples/app-showcase/src/automation/flows/index.ts +++ b/examples/app-showcase/src/automation/flows/index.ts @@ -1581,9 +1581,11 @@ export const InboundTaskWebhookFlow = defineFlow({ type: 'api', status: 'active', // An inbound webhook has no authenticated user, so the create must run as the - // system principal (#1888 runAs is now enforced). Without this it relies on the - // "no identity → security-skipped" fall-through, which breaks the moment the - // target object carries row-level security. + // system principal (#1888 runAs is now enforced). Without this the run is + // user-less under the default `runAs:'user'`, so its create is refused + // (`AUTOMATION_UNSCOPED_RUN_DATA_ACCESS`); the "no identity → security-skipped" + // fall-through it once relied on is gone (ADR-0096 D5 refuses a principal-less + // context in the security middleware too). runAs: 'system', nodes: [ { diff --git a/packages/metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts b/packages/metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts index ec14d6f1c2c..eca38ece3f8 100644 --- a/packages/metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts +++ b/packages/metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts @@ -4,9 +4,10 @@ * [#21911, ADR-0096] The metadata protocol's platform-store calls carry the * EXPLICIT system opt-in, never a principal-less context. * - * plugin-security hands an engine operation whose context carries no user, no - * position, no permission set and no `isSystem` straight to `next()` (the - * ADR-0096 E1 hand-off). The protocol's own reads and writes of the + * plugin-security used to hand an engine operation whose context carries no + * user, no position, no permission set and no `isSystem` straight to `next()` + * (the ADR-0096 E1 hand-off); since ADR-0096 D5 it refuses one. The protocol's + * own reads and writes of the * `sys_metadata` family reached the engine exactly that way — a `{ where }` * with no `context`, or a repository transaction context with only its * handle — so they worked only because the hand-off let them through. They diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index a505fc715a4..402b5b9f50d 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -11230,11 +11230,13 @@ export class ObjectStackProtocolImplementation implements // were eliminated by measurement, not by reading: // - the driver's tenant wall never engages — `buildDriverOptions` // sets `DriverOptions.tenantId` only from `execCtx.tenantId` - // (`objectql/engine.ts`), and this read passes no context; - // - plugin-security's Layer 0 never engages — the middleware takes - // its principal-less `return next()` thousands of lines before the - // `objectFields.has('organization_id')` gate that would have - // carried it; + // (`objectql/engine.ts`), and this read's context carries no + // `tenantId` (only the system opt-in below); + // - plugin-security's Layer 0 never engages — the middleware + // short-circuits on that `isSystem` opt-in (it used to take its + // principal-less `return next()`, refused since ADR-0096 D5) + // thousands of lines before the `objectFields.has('organization_id')` + // gate that would have carried it; // - no posture would save it anyway: `computeTenantLayer0Filter` // yields `null` under `single` and the deny sentinel under // `isolated` with no tenantId. diff --git a/packages/platform-objects/src/system/sys-secret.object.ts b/packages/platform-objects/src/system/sys-secret.object.ts index 363fad88596..6c099986490 100644 --- a/packages/platform-objects/src/system/sys-secret.object.ts +++ b/packages/platform-objects/src/system/sys-secret.object.ts @@ -46,9 +46,10 @@ export const SysSecret = ObjectSchema.create({ // store (settings/datasource credentials). Not covered by the wildcard `'*'` // grant — ordinary members get 403 from the generic data layer. Platform // admins retain access via the posture-gated superuser bypass. Internal - // readers are unaffected: `engine.resolveSecret` reads at DRIVER level, - // SettingsService / the datasource secret-binder read with no principal - // (middleware falls open for principal-less internal calls). + // readers are unaffected: `engine.resolveSecret` reads at DRIVER level, and + // SettingsService / the datasource secret-binder read with the explicit system + // opt-in (`isSystem: true`), which the middleware short-circuits. A + // principal-less read is refused (ADR-0096 D5). access: { default: 'private' }, description: 'Cipher store written by three privileged producers (see managedBy); each holds its handle in its own column. Never holds plaintext.', diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 0139548b7aa..adfd58d0907 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -3382,8 +3382,8 @@ export class AuthManager { // Both reads run as the platform (`withSystemContext`): this hook // IS the slug guard — the organization id is the `where`, not the // reader — so neither read reaches the engine with no principal - // and no opt-in (the security middleware's principal-less - // hand-off, ADR-0096). + // and no opt-in (the security middleware's former principal-less + // hand-off, which ADR-0096 D5 replaced with a refusal). const dataEngine = withSystemContext(rawEngine) as any; let currentSlug: string | undefined; diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 49113b925d9..6068aa6fc3b 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -2478,8 +2478,8 @@ export class AuthPlugin implements Plugin { // the write both carry the explicit system opt-in (`isSystem: true`). // The platform-admin judge above is this route's authorization; the // raw engine would have handed the security middleware a context with - // no principal and no opt-in — the principal-less hand-off (ADR-0096), - // which is not an authorization at all. + // no principal and no opt-in — the principal-less hand-off it no longer + // makes (ADR-0096 D5 refuses that context), and never an authorization. const rawEngine = this.authManager!.getDataEngine(); if (!rawEngine) { return c.json({ success: false, error: { code: 'SERVICE_UNAVAILABLE', message: 'Data engine unavailable' } }, 503); diff --git a/packages/plugins/plugin-auth/src/principal-less-producers-system-context.test.ts b/packages/plugins/plugin-auth/src/principal-less-producers-system-context.test.ts index 3a820bec5ef..034f8adb2ee 100644 --- a/packages/plugins/plugin-auth/src/principal-less-producers-system-context.test.ts +++ b/packages/plugins/plugin-auth/src/principal-less-producers-system-context.test.ts @@ -10,9 +10,10 @@ * - the SCIM bearer verifier (`verifyScimBearerToken`): its credential probe * carries the opt-in in the read's trailing options. * - * A context with neither a principal nor `isSystem` is the security - * middleware's principal-less hand-off (ADR-0096), which is not an - * authorization: each of these calls is already authorized by its own door + * A context with neither a principal nor `isSystem` was the security + * middleware's principal-less hand-off (ADR-0096 E1) and is refused since + * ADR-0096 D5; it was never an authorization: each of these calls is already + * authorized by its own door * (the platform-admin judge; the bearer digest). The opt-in names that. * * Measured on a REAL engine: a middleware registered on it records the context diff --git a/packages/plugins/plugin-auth/src/scim-connection-service.ts b/packages/plugins/plugin-auth/src/scim-connection-service.ts index 2c7cb0a2900..ce429163916 100644 --- a/packages/plugins/plugin-auth/src/scim-connection-service.ts +++ b/packages/plugins/plugin-auth/src/scim-connection-service.ts @@ -119,7 +119,8 @@ interface CredentialEngine { * verifier runs BEFORE any caller is known — the bearer it is verifying is the * only identity on the request — so there is no principal to carry, and the * read must say so rather than reach the engine with no principal and no - * opt-in (the security middleware's principal-less hand-off, ADR-0096). The + * opt-in (the security middleware's former principal-less hand-off, which + * ADR-0096 D5 replaced with a refusal). The * digest equality in the `where` is the whole of what the probe may match. */ const CREDENTIAL_PROBE_CONTEXT = { context: { isSystem: true } } as const; diff --git a/packages/plugins/plugin-security/src/get-metadata-readable-fields.test.ts b/packages/plugins/plugin-security/src/get-metadata-readable-fields.test.ts index 7cd022d0fbc..2bb1bea4931 100644 --- a/packages/plugins/plugin-security/src/get-metadata-readable-fields.test.ts +++ b/packages/plugins/plugin-security/src/get-metadata-readable-fields.test.ts @@ -5,10 +5,11 @@ * `getReadableFields`. * * The two differ in exactly one place: what a caller resolving to ZERO - * permission sets gets. On the DATA plane, falling open is the drift-free - * answer — the engine middleware skips its whole gate for such a caller, and - * reporting a narrowing the data path would not enforce is its own kind of - * drift. On the METADATA plane the question is disclosure, and D7 rules that a + * permission sets gets. On the DATA plane `getReadableFields` keeps its + * fall-open answer, written when the engine middleware skipped its whole gate + * for such a caller (the middleware now refuses it: ADR-0056 D2's deny + * baseline at object admission, and ADR-0096 D5 outright for a principal-less + * one). On the METADATA plane the question is disclosure, and D7 rules that a * guest/public deployment's schema exposure must be a deliberate * permission-set decision rather than an accidental everything-default. So the * zero-set caller goes through the same fallback resolution @@ -81,7 +82,8 @@ describe('[ADR-0106 D7] SecurityPlugin.getMetadataReadableFields', () => { const dataPlane = await plugin.getReadableFields('deal', context); const metadataPlane = await plugin.getMetadataReadableFields('deal', context); - // The data plane keeps its middleware-mirroring fall-open — unchanged. + // The data plane keeps its fall-open answer — unchanged (the middleware it + // once mirrored refuses this principal-less caller since ADR-0096 D5). expect(dataPlane).toEqual(['id', 'name', 'secret']); // The metadata plane does not. expect(metadataPlane).toEqual(['id', 'name']); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index d6a9b105c69..5777136ffba 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -6058,11 +6058,14 @@ export class SecurityPlugin implements Plugin { * `/auth/me/permissions` uses ({@link fallbackPermissionSet}, default * `member_default`) instead of falling open to the full field set. * - * Why the two differ rather than converge. `getReadableFields` mirrors the - * engine middleware, which skips its grant-based gates for a caller with no - * permission sets — reporting a narrowing the data path would not enforce is - * its own kind of drift, so on the DATA plane falling open is the correct, - * drift-free answer. What reaches that caller is the object's posture + * Why the two differ rather than converge. `getReadableFields` keeps the + * answer it gave when the engine middleware skipped its grant-based gates for + * a caller with no permission sets — reporting a narrowing the data path did + * not enforce was its own kind of drift, so on the DATA plane falling open was + * the drift-free answer. The middleware no longer serves that caller: object + * admission refuses it (ADR-0056 D2's deny baseline) and a principal-less one + * is refused outright (ADR-0096 D5), so no data-plane read reaches the fields + * named here. What reaches that caller is the object's posture * ({@link resolveCallerPosture}): [#20995] its masking rules, and a masked * field is a served column, so it stays; [#21063] its per-field capability * contract, and a capability-gated field is not served, so it leaves. The diff --git a/packages/qa/dogfood/test/declarative-endpoint-anonymous-guest.dogfood.test.ts b/packages/qa/dogfood/test/declarative-endpoint-anonymous-guest.dogfood.test.ts index 4414137585a..6ec9612e070 100644 --- a/packages/qa/dogfood/test/declarative-endpoint-anonymous-guest.dogfood.test.ts +++ b/packages/qa/dogfood/test/declarative-endpoint-anonymous-guest.dogfood.test.ts @@ -13,9 +13,10 @@ // `authRequired: true` keeps answering 401. // // Why a status code alone cannot hold this: a principal-less context and a -// guest both end in `403 PERMISSION_DENIED` once the engine-level deny for the -// principal-less hand-off lands, so `declarative-endpoint-policy`'s 403 stops -// telling the two apart. What this file pins is the context the ENGINE is +// guest both end in `403 PERMISSION_DENIED` now that the engine-level deny that +// replaced the principal-less hand-off has landed (ADR-0096 D5), so +// `declarative-endpoint-policy`'s 403 no longer tells the two apart. What this +// file pins is the context the ENGINE is // handed (an engine spy), whether the DRIVER is reached (a middleware // registered after boot runs last, right before the driver), and what the // caller is told (status, envelope, and that no byte of the row comes back). diff --git a/packages/qa/dogfood/test/flow-runas-schedule.dogfood.test.ts b/packages/qa/dogfood/test/flow-runas-schedule.dogfood.test.ts index eeb98aaf5a7..314658307be 100644 --- a/packages/qa/dogfood/test/flow-runas-schedule.dogfood.test.ts +++ b/packages/qa/dogfood/test/flow-runas-schedule.dogfood.test.ts @@ -8,8 +8,9 @@ // for a USER-triggered run; this one pins the boundary case: a run with NO // trigger user under an effective `runAs:'user'` (the default) resolves no // identity, so its CRUD nodes would present no ObjectQL context → the security -// middleware SKIPS (it delegates auth to the auth layer) → the run would execute -// UNSCOPED (effectively elevated), not restricted. +// middleware used to SKIP (it delegated auth to the auth layer; since ADR-0096 +// D5 it refuses such a context) → the run would execute UNSCOPED (effectively +// elevated), not restricted. // // Until #3760 that is exactly what happened, and THIS FILE PINNED IT — asserting // that a user-less run read and wrote an admin-owned record a member cannot diff --git a/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts b/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts index f17becb2029..4fc0f52a8da 100644 --- a/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts +++ b/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts @@ -549,11 +549,12 @@ describe('the wired chain — policies, then the real pipeline (#5129)', () => { // An unauthenticated request an `authRequired: false` endpoint admits // executes as the GUEST principal the runtime face's explicit guest entry // (`assembleExecutionContextOrGuest`) builds — never principal-less (which - // the security middleware hands straight through, ADR-0096 E1) and never - // the system principal. These pin the context the delegated call CARRIES, - // which is the one fact a status code cannot show: this boot composes no - // security plugin, and once a principal-less context is denied too, a - // guest and a principal-less call answer the same 403. The real-boot + // the security middleware used to hand straight through, ADR-0096 E1, and + // since ADR-0096 D5 refuses) and never the system principal. These pin the + // context the delegated call CARRIES, which is the one fact a status code + // cannot show: this boot composes no security plugin, and now that a + // principal-less context is denied too, a guest and a principal-less call + // answer the same 403. The real-boot // counterpart, with the deny it earns, is // `declarative-endpoint-anonymous-guest.dogfood.test.ts`. diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 946bc08a022..f6065d6b160 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1241,9 +1241,12 @@ export class HttpDispatcher { /** * Slim engine facade matching the ActionContext.engine shape handlers expect. * - * ⚠️ TRUSTED (SECURITY-DEFINER-like) BY DESIGN (#2849): these calls carry NO - * ExecutionContext, so the data engine's security middleware skips RLS / FLS / - * CRUD / tenant scoping entirely. Action bodies are the app author's own code + * ⚠️ TRUSTED (SECURITY-DEFINER-like) BY DESIGN (#2849): these calls carry the + * caller's envelope elevated with `isSystem: true` (`buildActionExecutionContext` + * in `./action-execution.ts`, #3914), so the data engine's security middleware + * short-circuits RLS / FLS / CRUD. They used to carry NO ExecutionContext, + * which the middleware then skipped and since ADR-0096 D5 refuses. Action + * bodies are the app author's own code * and legitimately perform cross-object writes the invoking user could not * (convert-lead, cascade-close). The boundary is therefore enforced at INVOKE * time (`ai.exposed` + ADR-0066 D4 capability gate), and every dispatch is @@ -1508,7 +1511,8 @@ export class HttpDispatcher { // gate is the one asking the question — the caller's user id is // the `where`, not the reader — so the read runs as the platform, // never as a context with no principal and no opt-in (the - // security middleware's principal-less hand-off, ADR-0096). + // security middleware's former principal-less hand-off, which + // ADR-0096 D5 replaced with a refusal). rows = await ql.find(ENVIRONMENT_MEMBER_OBJECT, { where: { environment_id: environmentId, user_id: userId }, limit: 1, diff --git a/packages/services/service-analytics/src/strategies/objectql-strategy.ts b/packages/services/service-analytics/src/strategies/objectql-strategy.ts index b6ff007d337..5b4daf1e458 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -408,7 +408,8 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // layer's own scoping; handing the engine the context makes ITS middleware // inject RLS too, so a future strategy that forgets `withReadScope` still // cannot read across tenants. Without it the operation reaches the engine - // principal-less and plugin-security falls open — the #3597 shape. + // principal-less, which plugin-security used to let through — the #3597 + // shape — and since ADR-0096 D5 refuses. context: ctx.context, }); diff --git a/packages/services/service-automation/README.md b/packages/services/service-automation/README.md index c29b4d3cdf0..8b2b51ab947 100644 --- a/packages/services/service-automation/README.md +++ b/packages/services/service-automation/README.md @@ -448,7 +448,7 @@ Types: `AutomationEngineOptions`, `AutomationServicePluginOptions`, `RunSummaryL `FlowTrigger`, `FlowTriggerBinding`, `RegisteredConnector`, `SuspendedRun`, `SuspendedRunStore`, `SuspendedRunStoreEngine`, `ObjectStoreSuspendedRunStoreOptions`, `RunRecord`, `StepLogEntry`, `UnknownNodeTypeAuditEntry`, `RunDataContext`, -`RunIdentityContext`, `RunProvenanceContext`, `ConnectorProviderFactory`, +`RunIdentityContext`, `ConnectorProviderFactory`, `ConnectorProviderContext`, `ConnectorMaterialization`, `ConnectorMaterializationHandler`, `ConnectorOrigin`, `ConnectorState`, `ConnectorDescriptor`, `ConnectorActionDescriptor`, `ConnectorActionHandler`, `ConnectorActionContext`. 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 ecbc8dfba56..073b2d4fc2d 100644 --- a/packages/services/service-automation/src/builtin/crud-runas.test.ts +++ b/packages/services/service-automation/src/builtin/crud-runas.test.ts @@ -217,7 +217,8 @@ describe('resolveRunDataContext (#1888 unit)', () => { // #3760 — a user-mode run with NO user is REFUSED, not resolved. Previously // this returned `undefined` (or, after #3712, a provenance-only envelope) and // let the op proceed with no principal — which the data security middleware - // waves straight through, running it UNSCOPED. That was the #1888 fail-open. + // then waved straight through, running it UNSCOPED (since ADR-0096 D5 it + // refuses one). That was the #1888 fail-open. it('THROWS for a user-mode run with no user (with or without a run id)', () => { for (const ctx of [{ runAs: 'user' as const }, { runAs: 'user' as const, flowRunId: 'run_1' }, undefined]) { expect(() => resolveRunDataContext(ctx)).toThrow(UnscopedRunDataAccessError); @@ -256,7 +257,8 @@ describe('resolveRunDataContext (#1888 unit)', () => { * #1888 FOLLOW-UP, CLOSED BY #3760 — the user-less fail-open. A run with no * trigger user and an effective `runAs:'user'` (the default) resolves no * identity → CRUD nodes present no principal → the data security middleware - * skips → the run used to execute UNSCOPED (effectively elevated). + * skipped (it refuses since ADR-0096 D5) → the run used to execute UNSCOPED + * (effectively elevated). * * #2308 made that AUDIBLE (a warning) but left it working, on the grounds that * denying would break legitimate scheduled CRUD. That rationale expired: the diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index fb1b2f8bfeb..864cbaf51b0 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -6054,8 +6054,9 @@ export class AutomationEngine implements IAutomationService { * * Also warns about the user-less case (#1888 follow-up, closed by #3760): a * flow whose effective `runAs` is `'user'` but whose trigger resolved no user - * has no identity to scope to, so its data nodes would run UNSCOPED (the data - * security middleware skips when there is no identity). Those data ops are now + * has no identity to scope to, so its data nodes used to run UNSCOPED (the + * data security middleware skipped a context with no identity; since + * ADR-0096 D5 it refuses one). Those data ops are now * REFUSED at `resolveRunDataContext`; the warning here fires at run SETUP, * before any node executes, so the refusal is diagnosable rather than a * surprise mid-flow. Authors declare `runAs:'system'` to make the elevation diff --git a/packages/services/service-automation/src/index.ts b/packages/services/service-automation/src/index.ts index 9add96a9274..b3bfcdbe0c1 100644 --- a/packages/services/service-automation/src/index.ts +++ b/packages/services/service-automation/src/index.ts @@ -192,7 +192,7 @@ export { stampSystemInsertOwner, UnscopedRunDataAccessError, } from './runtime-identity.js'; -export type { RunDataContext, RunIdentityContext, RunProvenanceContext } from './runtime-identity.js'; +export type { RunDataContext, RunIdentityContext } from './runtime-identity.js'; // Built-in node executors (ADR-0018). These are seeded by AutomationServicePlugin // and exported for advanced hosts that build a custom engine. They are functions, diff --git a/packages/services/service-automation/src/runtime-identity.ts b/packages/services/service-automation/src/runtime-identity.ts index 4bed142c70c..19e1dde08e8 100644 --- a/packages/services/service-automation/src/runtime-identity.ts +++ b/packages/services/service-automation/src/runtime-identity.ts @@ -53,38 +53,17 @@ export interface RunIdentityContext { flowRunId?: string; } -/** - * The PROVENANCE-ONLY envelope, for a run that resolves NO principal. - * - * It names the run that made the write and carries nothing else: no `userId`, - * no `positions`, no `permissions`, not even `isSystem: false`. That absence is - * the point. Every principal gate in the data security middleware keys on one - * of those fields — the elevation short-circuit on `isSystem`, the ADR-0103 - * engine-owned write guard and the ADR-0090 D12 delegated-admin gate on - * `context.userId`, the empty-principal fall-open on - * `positions`/`permissions`/`userId` (and the delegated-admin gate normalizes a - * missing context to `{}` before testing it) — so this envelope is - * indistinguishable from passing no context at all. - * - * Since #3760 a principal-less run may no longer reach a DATA node at all - * ({@link resolveRunDataContext} refuses), so this envelope is no longer the - * carrier of the old #1888 fail-open. It survives for the non-data provenance - * uses that motivated #3712 — a run id is still attributable without presenting - * an identity it does not have. - */ -export interface RunProvenanceContext { - /** The run performing this operation. The whole envelope (#3712). */ - flowRunId: string; -} - /** * Thrown when a run whose effective identity is the #1888 *unscoped* case tries * to perform a data operation (#3760). * * The refusal is the point: an effective `runAs:'user'` with no resolvable * trigger user used to execute its data nodes UNSCOPED — the data security - * middleware skips when there is no principal, so the run read and wrote EVERY - * row of EVERY tenant. `runAs:'user'` is an access-NARROWING declaration, and + * middleware then skipped a context with no principal, so the run read and + * wrote EVERY row of EVERY tenant. Since ADR-0096 D5 that middleware refuses + * such a context itself (`403 PERMISSION_DENIED`, every verb); this refusal + * runs first, names the run and the remedy, and holds on a kernel with no + * security plugin. `runAs:'user'` is an access-NARROWING declaration, and * ADR-0049's standing rule is that failing to resolve a narrowing declaration * must never resolve to a grant. So the operation is refused instead. * @@ -120,8 +99,14 @@ export class UnscopedRunDataAccessError extends Error { } } -/** What a flow's data nodes pass to ObjectQL as `options.context`. */ -export type RunDataContext = RunIdentityContext | RunProvenanceContext; +/** + * What a flow's data nodes pass to ObjectQL as `options.context`: exactly the + * envelope {@link resolveRunDataContext} returns — `isSystem: true` for a + * `runAs:'system'` run, the triggering user's principal for a `runAs:'user'` + * run. A run that resolves no principal is handed no context at all; it is + * refused ({@link UnscopedRunDataAccessError}). + */ +export interface RunDataContext extends RunIdentityContext {} /** * Translate a flow run's {@link AutomationContext} into the ObjectQL `context` @@ -217,10 +202,13 @@ export function resolveRunDataContext(context: AutomationContext | undefined): R }; } if (!context?.userId) { - // #3760 — FAIL CLOSED. There is no identity to present, and presenting none - // means the data security middleware skips every principal gate and runs the - // operation unscoped. `runAs:'user'` asked for restriction; silently - // delivering elevation is the fail-open ADR-0049 forbids. Refuse instead. + // #3760 — FAIL CLOSED. There is no identity to present. Presenting none + // used to mean the data security middleware skipped every principal gate + // and ran the operation unscoped; since ADR-0096 D5 it refuses that context + // instead, but only where a security plugin is composed, and with a 403 that + // names neither the run nor `runAs`. `runAs:'user'` asked for restriction; + // silently delivering elevation is the fail-open ADR-0049 forbids. Refuse + // here, on every kernel, with the remedy. // // Deliberately NOT `{ isSystem: true }`: the middleware's isSystem // short-circuit precedes the package-managed-row / system-row / @@ -332,10 +320,11 @@ export function flowTouchesData(flow: { nodes?: ReadonlyArray<{ type?: string }> * * Since #3760 such a run may not touch data at all: {@link resolveRunDataContext} * throws {@link UnscopedRunDataAccessError} rather than handing the data engine - * a principal-less context that the security middleware would wave straight - * through. The engine uses this predicate to warn at run SETUP — before any node - * executes — that a data-touching run is going to be refused, so the failure is - * diagnosable rather than a surprise mid-flow. The build-time lint + * a principal-less context (which the security middleware used to wave straight + * through, and since ADR-0096 D5 refuses). The engine uses this predicate to + * warn at run SETUP — before any node executes — that a data-touching run is + * going to be refused, so the failure is diagnosable rather than a surprise + * mid-flow. The build-time lint * `flow-runas-unscoped` rejects the statically-decidable shapes at * publish time. Declaring `runAs:'system'` makes the elevation explicit and * intended (ADR-0049). diff --git a/packages/triggers/trigger-record-change/src/record-change-integration.test.ts b/packages/triggers/trigger-record-change/src/record-change-integration.test.ts index 98ce0a054c3..c52185c5f2c 100644 --- a/packages/triggers/trigger-record-change/src/record-change-integration.test.ts +++ b/packages/triggers/trigger-record-change/src/record-change-integration.test.ts @@ -393,7 +393,8 @@ const beforeHookObjectDef = (name: string) => ({ * node — fires the record-change flows bound to that object with * `userId: undefined`. A flow left at the spec default * `runAs:'user'` then presented NO principal to ObjectQL, and the data security - * middleware skips when there is no principal: the flow read and wrote every row. + * middleware then skipped a context with no principal (since ADR-0096 D5 it + * refuses one): the flow read and wrote every row. * * Nothing flags this at authoring time and nothing can — whether a given write * carries a user is only knowable at run time — so the runtime refusal is the