fix(security): surface schedule/user-less flow runAs fail-open (#1888 follow-up)#2308
Merged
Conversation
…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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📓 Docs Drift CheckThis PR changes 5 package(s): 96 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…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>
This was referenced Jun 25, 2026
os-zhuang
added a commit
that referenced
this pull request
Jun 25, 2026
…pal for user-less runs) (#2318) * docs(adr): ADR-0073 — automation execution identity (non-human principal for user-less runs) Completes the identity model (ADR-0068 unified the human surface) by adding the non-human / automation identity that user-less runs need: - Automation is a first-class identity expressed as built-in roles (`automation` org-scoped, `platform_automation` unscoped) — the ADR-0068 idiom; no anonymous run. - `runAs` becomes an authorization POSTURE (user / automation-with-RLS / system), decoupling authorization from attribution; `automation` (RLS-enforced) is the new safe default for user-less triggers, `system` (god-mode) unchanged for back-compat. - Attribution always concrete (audit actor), decoupled from record ownership so automation never silently owns the rows it writes (owner-RLS would hide them). - User-less `runAs:'user'` is a configuration error. Staged per ADR-0049: contract + seeded roles + lint completeness now (non-breaking, mirrors ADR-0068 v1); runtime wiring (attribution then RLS-enforced authorization + scopable grants) deferred to M2. Grounds the decision in how Salesforce / ServiceNow / AWS IAM / K8s / iPaaS / GitHub Actions handle automation identity. Status: Proposed — for discussion. Refs #1888, #2308; builds on ADR-0049/0057/0066/0068. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(adr): ADR-0073 v2 — right-size v1 to decision-record + guardrail, defer runtime to M2 (gated on first consumer) * docs(adr): ADR-0073 — correct tenancy model to environment-per-database (ADR-0002); automation principal is per-env Data-Plane, cross-env is Control-Plane --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This was referenced Jul 15, 2026
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.
Problem
Follow-up to #1888 (flow
runAsenforcement). WithrunAsnow enforced, a schedule-triggered flow with the defaultrunAs:'user'has no trigger user.resolveRunDataContextreturnsundefined, so the CRUD nodes pass no ObjectQLoptions.context, and the security middleware — which skips when there is no identity (it delegates auth to the auth layer,security-plugin.ts:343-349) — runs the operation UNSCOPED (effectively elevated).An author who left
runAsat 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.Key architectural finding: a "zero-authority user context" does not fail closed — the middleware skips on no-identity by design. (And
report-scheduleis not a second fail-open: report-service uses an explicitSYSTEM_CTX = { isSystem:true }.)Decision — Option 1 (author-time prevention + non-breaking runtime visibility)
Empirically, 2 of 3 first-party scheduled CRUD flows relied on the default, so fail-closed (option 3) would break legitimate scheduled CRUD. Silently elevating would hide author intent. A dedicated service principal (option 2) is the right long-term enforcement (attributable + scopable) but is M2 work. So:
@objectstack/cli): new advisory ruleflow-schedule-runas-unscoped— a schedule-triggered flow whose effectiverunAsisuser(explicit or unset) and which performs a data op is flagged towardrunAs:'system'. Catches it at compile time (most flows are AI-authored).@objectstack/service-automation): the engine emits one clear[runAs]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). The tworunContextsites collapse into oneresolveRunContext; new helpersrunIsUnscopedUserMode/flowTouchesData/DATA_NODE_TYPESexported besideresolveRunDataContext.@objectstack/spec):FlowSchema.runAsnow states a scheduled run has no user → underuserit runs unscoped → declaresystem.stale_opportunity_sweep, app-todotask_reminder/overdue_escalationnow declarerunAs:'system'— they read/write across owners and were running unscoped by default.Tests (all green)
crud-runas.test.ts): engine warns once for a user-less user-mode data run; silent forsystem, for an identified user, and for a data-less flow; plusrunIsUnscopedUserMode/flowTouchesDataunits. (221 pass)flow-runas-schedule.dogfood.test.ts): drives user-less runs through the real automation + security + data stack viakernel.getService('automation').execute— arunAs:'user'run reads + writes an owner-scoped note a member cannot (audibly), whilerunAs:'system'is the explicit, warning-free equivalent. (8 pass)lint-flow-patterns.test.ts): fires on schedule + user/unset + data node; silent forsystem, no-data, and non-schedule triggers. (35 pass)Refs #1888, ADR-0049.
🤖 Generated with Claude Code