Skip to content

fix(security): enforce flow runAs execution identity (#1888)#2302

Merged
os-zhuang merged 1 commit into
mainfrom
fix/flow-runas-identity
Jun 24, 2026
Merged

fix(security): enforce flow runAs execution identity (#1888)#2302
os-zhuang merged 1 commit into
mainfrom
fix/flow-runas-identity

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Resolves #1888[P0][security] Flow runAs never switches execution identity (part of the metadata-liveness audit umbrella #1878).

Decision: ENFORCE

The platform expresses an elevated identity via the ObjectQL options.context{ isSystem: true } short-circuits the security middleware (RLS/tenant bypass), and { userId, roles, … } drives row-level security. That mechanism exists and is used pervasively (plugin-security / plugin-sharing / plugin-auth), so runAs is enforced, not removed.

Root cause

The CRUD nodes (get/create/update/delete_record) passed no identity at all to ObjectQL. With no userId/roles/isSystem, the auth middleware is a no-op and the security middleware skips entirely — so every flow ran effectively elevated regardless of runAs. runAs:'user' did not de-elevate (a privilege-boundary surprise); runAs:'system' did not explicitly elevate (it merely happened to be unscoped too).

The fix

  • Engine (service-automation): execute() (and the retry path) establish the run's effective identity from flow.runAs on a copy of the context (so the caller's context is restored afterward — the elevation is scoped to the run), persisted on suspend so resume() re-establishes it.
  • CRUD nodes: every ObjectQL read/write now passes options.context derived from the run's runAs via the new resolveRunDataContext helper (the single mapping point, shared so the policy can't drift between node types):
    • runAs:'system'{ isSystem: true } (elevated, RLS-bypassing),
    • runAs:'user' (default) → the triggering user { userId, roles, permissions, tenantId }.
  • Trigger surfaces forward the full identity so runAs:'user' matches a direct request by that user (not a member fallback): the REST trigger route (runtime) and the record-change trigger (trigger-record-change) now forward the caller's resolved roles/tenantId into the AutomationContext (new optional contract fields), not just userId.
  • Spec: removed the [EXPERIMENTAL — not enforced] marker from FlowSchema.runAs; api-surface unchanged (additive optional fields).
  • Audit: docs/audits/2026-06-flowschema-property-liveness.mdrunAs marked live/enforced.

Tests (both directions, with a negative/regression guard)

  • Dogfood regression gatepackages/dogfood/test/flow-runas.dogfood.test.ts (+ fixture): boots a real app, a restricted member triggers flows against an owner-scoped record. runAs:'system' can read/write the record the member is RLS-denied; runAs:'user' is correctly denied. Verified RED before / GREEN after the engine fix (the two user-mode cases flip).
  • Unitpackages/services/service-automation/src/builtin/crud-runas.test.ts: captures the context each data op receives — system → isSystem:true (not attributed to the trigger user); user → { userId, roles, tenantId }; default = user; caller context not mutated (restore/no-leak); a regression test that fails if runAs:'system' ever stops elevating; plus direct resolveRunDataContext cases (incl. the schedule/no-user fallback).

Behavior change / migration

Flows that relied on the implicit elevation (default runAs:'user' ran unscoped) now run as the triggering user and are subject to their RLS. Declare runAs:'system' on any flow that must read/write beyond the trigger's access (system automations, cross-owner roll-ups). Schedule-triggered runs have no trigger user; under user they stay unscoped — declare system to make elevation explicit. The example CRM/showcase flows already declare runAs (lead-qualification/renewal-reminder = system; convert-lead/showcase = user) and the full dogfood gate stays green.

Verification

  • service-automation vitest: 215 pass (incl. 10 new) · dogfood gate: 168 pass (33 files) · trigger-record-change: 20 · spec automation+contracts: 526 · runtime dispatcher: 158 · spec api-surface: unchanged ✓ · all touched packages build clean.

Context: this is the security-layer instance of the "configured in the UI, silently does nothing at runtime" anti-pattern fixed for assignment/decision nodes (#2250, objectui #1927/#1930). Sibling P0s in the same cluster: #1882, #1886, #1883.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Jun 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jun 24, 2026 4:45pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/l labels Jun 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/dogfood, @objectstack/runtime, packages/services, @objectstack/spec, packages/triggers.

100 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/cloud-artifact-api.mdx (via packages/runtime, packages/spec)
  • content/docs/concepts/cluster-semantics.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/implementation-status.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/runtime, packages/spec)
  • content/docs/concepts/packages.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/concepts/setup-app.mdx (via @objectstack/spec)
  • content/docs/concepts/skills.mdx (via @objectstack/spec)
  • content/docs/concepts/webhook-delivery.mdx (via @objectstack/spec)
  • content/docs/getting-started/architecture.mdx (via @objectstack/spec)
  • content/docs/getting-started/cli.mdx (via @objectstack/spec)
  • content/docs/getting-started/core-concepts.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/guides/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/guides/ai-capabilities.mdx (via @objectstack/spec)
  • content/docs/guides/airtable-dashboard-analysis.mdx (via @objectstack/spec)
  • content/docs/guides/analytics-datasets.mdx (via @objectstack/spec)
  • content/docs/guides/api-reference.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/guides/authentication.mdx (via @objectstack/runtime)
  • content/docs/guides/business-logic.mdx (via @objectstack/spec)
  • content/docs/guides/cheatsheets/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/guides/cheatsheets/error-catalog.mdx (via @objectstack/spec)
  • content/docs/guides/cheatsheets/field-type-gallery.mdx (via @objectstack/spec)
  • content/docs/guides/cheatsheets/field-validation-rules.mdx (via @objectstack/spec)
  • content/docs/guides/cheatsheets/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/guides/cheatsheets/protocol-diagram.mdx (via packages/spec)
  • content/docs/guides/cheatsheets/query-cheat-sheet.mdx (via @objectstack/spec)
  • content/docs/guides/cheatsheets/quick-reference.mdx (via @objectstack/spec)
  • content/docs/guides/client-sdk.mdx (via @objectstack/spec)
  • content/docs/guides/cloud-deployment.mdx (via @objectstack/runtime)
  • content/docs/guides/common-patterns.mdx (via @objectstack/spec)
  • content/docs/guides/contracts/auth-service.mdx (via packages/spec)
  • content/docs/guides/contracts/cache-service.mdx (via packages/spec)
  • content/docs/guides/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/guides/contracts/index.mdx (via @objectstack/spec)
  • content/docs/guides/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/guides/contracts/storage-service.mdx (via packages/spec)
  • content/docs/guides/data-modeling.mdx (via @objectstack/spec)
  • content/docs/guides/deployment-vercel.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/guides/driver-configuration.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/guides/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/guides/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/guides/external-datasources.mdx (via @objectstack/spec)
  • content/docs/guides/formula.mdx (via @objectstack/spec)
  • content/docs/guides/hook-bodies.mdx (via @objectstack/runtime, packages/spec)
  • content/docs/guides/kernel-services.mdx (via @objectstack/spec)
  • content/docs/guides/metadata/dashboard.mdx (via @objectstack/spec)
  • content/docs/guides/metadata/field.mdx (via @objectstack/spec)
  • content/docs/guides/metadata/flow.mdx (via @objectstack/spec)
  • content/docs/guides/metadata/index.mdx (via @objectstack/spec)
  • content/docs/guides/metadata/object.mdx (via @objectstack/spec)
  • content/docs/guides/metadata/validation.mdx (via @objectstack/spec)
  • content/docs/guides/metadata/workflow.mdx (via @objectstack/spec)
  • content/docs/guides/packages.mdx (via @objectstack/runtime, packages/services, @objectstack/spec)
  • content/docs/guides/plugin-chatbot-integration.mdx (via @objectstack/runtime)
  • content/docs/guides/plugin-development.mdx (via @objectstack/spec)
  • content/docs/guides/plugins.mdx (via @objectstack/spec)
  • content/docs/guides/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/guides/project-scoping.mdx (via @objectstack/spec)
  • content/docs/guides/public-forms.mdx (via @objectstack/spec)
  • content/docs/guides/runtime-services/audit-service.mdx (via packages/services)
  • content/docs/guides/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/guides/runtime-services/index.mdx (via packages/services, packages/spec)
  • content/docs/guides/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/guides/runtime-services/settings-service.mdx (via packages/services)
  • content/docs/guides/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/guides/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/guides/security.mdx (via @objectstack/spec)
  • content/docs/guides/seed-data.mdx (via @objectstack/spec)
  • content/docs/guides/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/guides/skills.mdx (via @objectstack/spec)
  • content/docs/guides/standards.mdx (via @objectstack/spec)
  • content/docs/guides/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/guides/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectos/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/objectos/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/objectos/i18n-standard.mdx (via packages/services, @objectstack/spec)
  • content/docs/protocol/objectos/index.mdx (via @objectstack/runtime)
  • content/docs/protocol/objectos/lifecycle.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/protocol/objectos/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/objectos/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via packages/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via packages/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang force-pushed the fix/flow-runas-identity branch from a7b79db to 21bbc57 Compare June 24, 2026 16:16
The service-automation engine ignored `flow.runAs`: CRUD nodes passed no
identity to ObjectQL, so the security middleware was skipped and every flow ran
effectively elevated regardless of `runAs`. `runAs:'user'` did not de-elevate
and `runAs:'system'` did not explicitly elevate — a privilege-boundary surprise
in both directions (part of metadata-liveness audit #1878).

Enforce it: the engine establishes the run's data-layer identity from
`flow.runAs` at setup (a copy, so the caller's context is restored afterward)
and every CRUD node threads it to ObjectQL as `options.context`:
  - system            → { isSystem: true } (elevated, RLS-bypassing),
  - user (default)    → the triggering user (RLS-respecting; roles/tenant
    forwarded by the REST trigger route + record-change trigger so it matches a
    direct request by that user).

`resolveRunDataContext` is the single mapping point shared by all data nodes.
Removes the [EXPERIMENTAL — not enforced] marker from FlowSchema.runAs, marks
the audit entry live, and classifies `flow/runAs` in the spec liveness ledger as
a proof-backed high-risk class (ADR-0054 prove-it-runs).

Behavior change: flows that relied on the implicit elevation now run as the
triggering user — declare `runAs:'system'` on flows that must exceed the
trigger's access.

Proven both directions by the dogfood gate (flow-runas.dogfood.test.ts — a
restricted member triggering system vs user flows on an owner-scoped record,
RED before / GREEN after) and service-automation unit + regression tests
(crud-runas.test.ts).

Refs #1888 #1878

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@os-zhuang
os-zhuang force-pushed the fix/flow-runas-identity branch from 21bbc57 to 312a368 Compare June 24, 2026 16:18
@os-zhuang
os-zhuang merged commit 6c4fbd9 into main Jun 24, 2026
16 of 17 checks passed
@os-zhuang
os-zhuang deleted the fix/flow-runas-identity branch June 24, 2026 16:37
os-zhuang added a commit that referenced this pull request Jun 24, 2026
…follow-up) (#2308)

* fix(security): surface schedule/user-less flow runAs fail-open (#1888 follow-up)

With flow `runAs` now enforced (#1888, PR #2302), a SCHEDULE-triggered flow with
the default `runAs:'user'` has no trigger user: `resolveRunDataContext` returns
undefined, so the CRUD nodes pass no ObjectQL `options.context` and the security
middleware — which skips when there is no identity (it delegates auth to the auth
layer) — runs the operation UNSCOPED (effectively elevated). An author who left
`runAs` at the 'user' default expecting a restricted run silently gets an
unscoped one. That is fail-open, and exactly the kind of "security property that
silently does the opposite of what it implies" ADR-0049 prohibits.

This makes the boundary case #1888 deliberately left open an explicit product
decision. Denying outright (fail-closed) would break legitimate scheduled CRUD —
empirically 2 of 3 example scheduled CRUD flows relied on the default — and
silently elevating would hide the author's intent. So prevention happens where
the platform can tell intent apart (author/build time), and the runtime stays
non-breaking but is no longer silent:

- Author-time lint (@objectstack/cli, lintFlowPatterns): a new advisory rule
  `flow-schedule-runas-unscoped` flags a schedule-triggered flow whose effective
  `runAs` is user (explicit or unset) and which performs a data op, pointing the
  author at `runAs:'system'`. Catches it at compile time before deploy (most
  flows are AI-authored).
- Runtime warning (@objectstack/service-automation): the engine emits one clear
  warning per run when a user-mode run resolves no trigger identity and the flow
  touches data — the fail-open is audible, not silent. Behavior is otherwise
  unchanged (scheduled CRUD is not broken). New helpers `runIsUnscopedUserMode`,
  `flowTouchesData`, `DATA_NODE_TYPES` exported beside `resolveRunDataContext`;
  the two runContext construction sites collapse into one `resolveRunContext`.
- Spec describe (@objectstack/spec): FlowSchema.runAs now states a scheduled run
  has no user, so under `user` it runs unscoped — declare `system`.

First-party example flows that tripped the new lint are fixed to declare
`runAs:'system'` (stale_opportunity_sweep, app-todo task_reminder /
overdue_escalation) — they read/write across owners and were running unscoped.

Longer term, attributing scheduled runs to a dedicated service principal
(scopable + audit-attributable) is the right enforcement — M2 follow-up.

Proven by a service-automation unit test (warns once for a user-less user-mode
data run; silent for system / an identified user / a data-less flow) and a
dogfood gate (flow-runas-schedule.dogfood.test.ts) that drives user-less runs
through the real automation + security + data stack: a `runAs:'user'` run reads +
writes an owner-scoped note a member cannot — audibly — while `runAs:'system'` is
the explicit, warning-free equivalent.

Refs #1888, ADR-0049.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(trigger-schedule): end-to-end runAs fail-open via the real cron path

Add an e2e test wiring the REAL ScheduleTrigger to the REAL AutomationEngine: a
fired scheduled job builds a user-less { event:'schedule' } context, reaches the
engine, threads the unscoped (user-mode, user-less) identity to the data node, and
trips the [runAs] warning — proving the fail-open via the actual cron path (the
unit + dogfood tests call engine.execute() with a hand-made context). Also asserts
runAs:'system' propagates elevated with no warning.

Adds @objectstack/service-automation as a dev-dependency of trigger-schedule (the
first place the two real halves meet; the FlowTrigger contract is declared
structurally so there is no runtime/build dependency).

Refs #1888, ADR-0049.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant