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

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>

This branch was successfully deployed

1 active deployment
Preview — 7c5cd37c Deployed Jun 24, 2026 by vercel[bot]
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