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

vercelBot commented Jun 24, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
specReadyReadyPreview, CommentJun 24, 2026 4:45pm

Request Review

@github-actionsgithub-actionsBot 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-zhuangforce-pushed the fix/flow-runas-identity branch from a7b79db to 21bbc57CompareJune 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-zhuangforce-pushed the fix/flow-runas-identity branch from 21bbc57 to 312a368CompareJune 24, 2026 16:18
@os-zhuang
os-zhuang merged commit 6c4fbd9 into mainJun 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@os-zhuang