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
23 changes: 23 additions & 0 deletions .changeset/22345-run-provenance-context-retired.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: not-required (runtime-interface-only packages/services/service-automation/src/runtime-identity.ts#RunDataContext) Two TypeScript-only declarations in one runtime file move, and neither is metadata: `RunDataContext` loses its `RunProvenanceContext` arm, and the `RunProvenanceContext` interface declared beside it in the same file is removed together with its type export. No spec key, authorable spelling, Zod schema or stored shape moves, and no stored row is read or rewritten, so `objectstack migrate meta` has nothing to rewrite. The channel that reaches an affected consumer is the compiler, at the consumer's own import. -->

**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.
8 changes: 5 additions & 3 deletions examples/app-showcase/src/automation/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions packages/platform-objects/src/system/sys-secret.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
4 changes: 2 additions & 2 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/plugins/plugin-auth/src/scim-connection-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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']);
Expand Down
13 changes: 8 additions & 5 deletions packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
5 changes: 3 additions & 2 deletions packages/qa/dogfood/test/flow-runas-schedule.dogfood.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
12 changes: 8 additions & 4 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down
2 changes: 1 addition & 1 deletion packages/services/service-automation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading