fix(security): enforce flow runAs execution identity (#1888)#2302
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📓 Docs Drift CheckThis PR changes 5 package(s): 100 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
force-pushed
the
fix/flow-runas-identity
branch
from
June 24, 2026 16:16
a7b79db to
21bbc57
Compare
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
force-pushed
the
fix/flow-runas-identity
branch
from
June 24, 2026 16:18
21bbc57 to
312a368
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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), sorunAsis enforced, not removed.Root cause
The CRUD nodes (
get/create/update/delete_record) passed no identity at all to ObjectQL. With nouserId/roles/isSystem, the auth middleware is a no-op and the security middleware skips entirely — so every flow ran effectively elevated regardless ofrunAs.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
service-automation):execute()(and the retry path) establish the run's effective identity fromflow.runAson a copy of the context (so the caller's context is restored afterward — the elevation is scoped to the run), persisted on suspend soresume()re-establishes it.options.contextderived from the run'srunAsvia the newresolveRunDataContexthelper (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 }.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 resolvedroles/tenantIdinto theAutomationContext(new optional contract fields), not justuserId.[EXPERIMENTAL — not enforced]marker fromFlowSchema.runAs; api-surface unchanged (additive optional fields).docs/audits/2026-06-flowschema-property-liveness.md—runAsmarked live/enforced.Tests (both directions, with a negative/regression guard)
packages/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).packages/services/service-automation/src/builtin/crud-runas.test.ts: captures thecontexteach 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 ifrunAs:'system'ever stops elevating; plus directresolveRunDataContextcases (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. DeclarerunAs:'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; underuserthey stay unscoped — declaresystemto make elevation explicit. The example CRM/showcase flows already declarerunAs(lead-qualification/renewal-reminder= system;convert-lead/showcase = user) and the full dogfood gate stays green.Verification
service-automationvitest: 215 pass (incl. 10 new) · dogfood gate: 168 pass (33 files) ·trigger-record-change: 20 · spec automation+contracts: 526 · runtime dispatcher: 158 ·specapi-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