Skip to content

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

Merged
os-zhuang merged 2 commits into
mainfrom
fix/scheduled-flow-runas-unscoped
Jun 24, 2026
Merged

fix(security): surface schedule/user-less flow runAs fail-open (#1888 follow-up)#2308
os-zhuang merged 2 commits into
mainfrom
fix/scheduled-flow-runas-unscoped

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Problem

Follow-up to #1888 (flow runAs enforcement). With runAs now enforced, 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, security-plugin.ts:343-349) — 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.

Key architectural finding: a "zero-authority user context" does not fail closed — the middleware skips on no-identity by design. (And report-schedule is not a second fail-open: report-service uses an explicit SYSTEM_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:

  • Author-time lint (@objectstack/cli): new advisory rule flow-schedule-runas-unscoped — a schedule-triggered flow whose effective runAs is user (explicit or unset) and which performs a data op is flagged toward runAs:'system'. Catches it at compile time (most flows are AI-authored).
  • Runtime warning (@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 two runContext sites collapse into one resolveRunContext; new helpers runIsUnscopedUserMode / flowTouchesData / DATA_NODE_TYPES exported beside resolveRunDataContext.
  • Spec describe (@objectstack/spec): FlowSchema.runAs now states a scheduled run has no user → under user it runs unscoped → declare system.
  • Example fixes: stale_opportunity_sweep, app-todo task_reminder / overdue_escalation now declare runAs:'system' — they read/write across owners and were running unscoped by default.

Tests (all green)

  • service-automation unit (crud-runas.test.ts): engine warns once for a user-less user-mode data run; silent for system, for an identified user, and for a data-less flow; plus runIsUnscopedUserMode / flowTouchesData units. (221 pass)
  • dogfood gate (flow-runas-schedule.dogfood.test.ts): drives user-less runs through the real automation + security + data stack via kernel.getService('automation').execute — a runAs:'user' run reads + writes an owner-scoped note a member cannot (audibly), while runAs:'system' is the explicit, warning-free equivalent. (8 pass)
  • cli lint (lint-flow-patterns.test.ts): fires on schedule + user/unset + data node; silent for system, no-data, and non-schedule triggers. (35 pass)

Refs #1888, ADR-0049.

🤖 Generated with Claude Code

…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>
@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 5:19pm

Request Review

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

github-actions Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

96 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/cli, 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/cli, @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/spec)
  • content/docs/concepts/packages.mdx (via @objectstack/cli, @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/cli, @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/cli, @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/spec)
  • content/docs/guides/authentication.mdx (via @objectstack/cli)
  • 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/cli, @objectstack/spec)
  • 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/spec)
  • content/docs/guides/driver-configuration.mdx (via @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 packages/cli, 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/cli, packages/services, @objectstack/spec)
  • content/docs/guides/plugin-development.mdx (via @objectstack/spec)
  • content/docs/guides/plugins.mdx (via @objectstack/spec)
  • content/docs/guides/project-scoping.mdx (via @objectstack/cli, @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/data-service.mdx (via packages/cli)
  • content/docs/guides/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/guides/runtime-services/index.mdx (via packages/cli, 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/skills.mdx (via packages/cli, @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/i18n-standard.mdx (via packages/services, @objectstack/spec)
  • content/docs/protocol/objectos/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/objectos/plugin-spec.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/protocol/objectos/realtime-protocol.mdx (via @objectstack/cli)
  • 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.

…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>
@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file tests tooling labels Jun 24, 2026
@os-zhuang
os-zhuang merged commit ad143ce into main Jun 24, 2026
18 checks passed
@os-zhuang
os-zhuang deleted the fix/scheduled-flow-runas-unscoped branch June 24, 2026 17:51
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file 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