Skip to content

fix(automation)!: refuse data ops for a run with no trigger user (#3760) - #3784

Merged
os-zhuang merged 1 commit into
mainfrom
fix/user-less-fail-open-3760
Jul 28, 2026
Merged

fix(automation)!: refuse data ops for a run with no trigger user (#3760)#3784
os-zhuang merged 1 commit into
mainfrom
fix/user-less-fail-open-3760

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #3760.

The finding that changed the decision

The issue offered four options and favoured Option 1 — "inherit the triggering write's posture → run explicitly isSystem; effective permissions unchanged, so breakage is close to zero."

That premise is false. isSystem: true is strictly wider than the principal-less context these runs carry today. The security middleware's isSystem short-circuit is at security-plugin.ts:722before four fail-closed gates a principal-less context still has to clear:

line gate principal-less isSystem
795 package-managed row runs skipped
809 system row (sys_position/sys_capability) runs skipped
817 audience anchor runs skipped
844 delegated admin (RBAC link tables) throws on no userId skipped
852 empty-principal fall-open next()

Concretely: today one of these cascade flows cannot write sys_user_positiondelegated-admin-gate.ts:172 throws, and the repo already has a test locking that. Under Option 1 it could. So Option 1 is a real privilege escalation, not a relabel.

Option 3 as literally written also doesn't work: a manufactured {isSystem:false, positions:[], permissions:[]} satisfies the line-852 predicate exactly and falls open again. Genuine "restricted" needs a middleware change.

Maintainer chose Option 2 (runtime fail-closed).

What ships

resolveRunDataContext — the documented single place every data node resolves its context — now throws UnscopedRunDataAccessError instead of handing back a principal-less envelope. The error names runAs:'system' as the fix.

This is not a new policy: ADR-0073 D5 already ruled that "user-less + runAs:'user' is a configuration error". It deferred the runtime half on two grounds this PR falsifies:

  1. "An unprivileged user cannot trigger a schedule, so there is no untrusted-input path." True of schedules, but schedules were never the boundary. The predicate is runAs !== 'system' && !userId, and the dominant shape is a record-change flow fired by a write that carried no userisSystem does not suppress trigger dispatch, only skipTriggers does, and exactly three first-party paths set it. Every plugin/service system write, the approvals status mirror, and a runAs:'system' flow's own data node dispatched record-change flows with userId: undefined. Submitting for approval mirrors a status onto the target record — so ordinary users reach it.
  2. "Fail-closed breaks legitimate scheduled CRUD — 2/3 example flows relied on the default." Those flows were fixed as part of fix(security): surface schedule/user-less flow runAs fail-open (#1888 follow-up) #2308; every first-party schedule-triggered flow now declares runAs:'system'. The stated cost expired.

ADR-0073 is amended in place rather than quietly contradicted, and the refusal is marked interim and forward-compatible: when its automation principal lands, the refusal point becomes the place that resolves it.

Also in this PR

  • flow-schedule-runas-unscopedflow-runas-unscoped, and it now fails the build. compile.ts documented in-line that the flow lint "NEVER fails the build" — it read as a gate and behaved as a comment, which is close to no net for the audience it protects. Extended to time_relative and api (ADR-0073 D5). It still cannot cover record_change — undecidable at authoring time, which is exactly why the runtime refusal exists; that limit is pinned by a test so nobody "fixes" the gap by guessing.
  • Three seed writes stopped firing automation. The seed loader's pass-2 deferred-reference back-fill and both AppPlugin basic-insert fallbacks inlined a bare {isSystem:true} instead of the shared seed options, seeding with record-change automation live — the self-trigger vector skipTriggers exists to prevent, on the writes that skipped it.
  • Approval: a schedule-triggered run still can't write its own locked record — it carries no ObjectQL context to hold flowRunId (#3456 residual) #3712's user-less provenance path is subsumed, not broken. Those runs are refused before the approvals lock is consulted; a schedule reaches its own record via runAs:'system', which the lock hook exempts on its own isSystem branch. The flowRunId exemption stays live and load-bearing for what fix(approvals): a dead approval run no longer leaves the record RECORD_LOCKED (#3456) #3703 built it for — a runAs:'user' run that does have a user.

Migration (breaking)

A flow reacting to system writes that must act beyond one user's grants declares runAs: 'system' — one line of metadata, and the elevation becomes explicit and audit-attributable. Otherwise ensure the trigger supplies a user. Flows touching no data are unaffected. The failure is isolated: the record-change trigger already swallows flow errors, so the originating write still succeeds, and the engine warns at run setup, before any node executes.

Verification

  • pnpm build (real type-check, no OS_SKIP_DTS) and pnpm test132/132 tasks green.
  • Both new gates proven able to go red. Reverting the refusal turns the new end-to-end test red with expected 'done' to be undefined — the flow really did write the record under the old behaviour; reverting the seed fix turns its test red too.
  • The live dogfood stack (flow-runas-schedule.dogfood.test.ts) previously pinned the fail-open explicitly, asserting a user-less run reads and writes an admin-owned record a member cannot touch. Its own header said a fail-closed change must turn it red and "force the product decision to be revisited deliberately" — that is what happened; it is inverted to pin the refusal, with the unchanged-record assertion carrying the weight.
  • Generated spec docs regenerated (gen:schema + gen:docs); check:api-surface clean.

Filed separately (Prime Directive #10)

🤖 Generated with Claude Code

An effective `runAs:'user'` run that resolves no trigger user executed its
data nodes UNSCOPED: it presented no principal, and the data security
middleware skips when there is no principal, so the run read and wrote every
row. `runAs:'user'` is an access-NARROWING declaration, and ADR-0049's standing
rule is that failing to resolve one must never resolve to a grant. It now
throws `UnscopedRunDataAccessError` from `resolveRunDataContext` — the single
place every data node resolves its context.

This was never really about schedules. The docs, the spec, the runtime warning
and the lint all described a schedule-shaped problem, and the lint only matched
that shape, but the runtime predicate is "no user". The commonest way to have
no user is a record-change flow fired by a write that carried none: `isSystem`
does not suppress trigger dispatch — only `skipTriggers` does, and three
first-party paths set it — so plugin/service system writes, the approvals
status mirror, and a `runAs:'system'` flow's own data node all dispatched
record-change flows with `userId: undefined`. Ordinary users reach those writes
routinely, so the fail-open was reachable by unprivileged input.

Deliberately NOT implemented as "inherit the write's posture and run as
isSystem". That reads like a relabel but is an escalation: the middleware's
isSystem short-circuit (security-plugin.ts:722) fires before the
package-managed-row, system-row, audience-anchor and delegated-admin gates
(795/809/817/844), all of which a principal-less context still clears. Such a
run cannot write sys_user_position today; as isSystem it could.

- Lint `flow-schedule-runas-unscoped` -> `flow-runas-unscoped`, now FAILS the
  build and covers time_relative + api (ADR-0073 D5). It documented itself as
  "NEVER fails the build" — a gate that behaved as a comment. It still cannot
  cover record_change, which is undecidable at authoring time.
- Three seed writes (seed-loader pass-2 back-fill, both app-plugin fallbacks)
  inlined a bare `{isSystem:true}` and so seeded with automation live.
- #3712's user-less provenance path is subsumed: such runs are refused before
  the approvals lock is consulted, and a schedule reaches its own record via
  `runAs:'system'`. The flowRunId exemption stays live for runs with a user.
- ADR-0073 amended: its "no untrusted-input path" severity claim is falsified,
  and its rejection of fail-closed expired when the example flows it cited were
  fixed to declare `runAs:'system'`.

Both new gates were verified to go red against the pre-fix behavior, including
the live dogfood stack, which previously pinned the fail-open explicitly.

Refs #1888, #3712, #3749, #3456, ADR-0049, ADR-0073.

Co-Authored-By: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 28, 2026 4:32am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Jul 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 8 package(s): @objectstack/cli, @objectstack/metadata-protocol, @objectstack/plugin-approvals, packages/qa, @objectstack/runtime, packages/services, @objectstack/spec, packages/triggers.

119 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via packages/cli, @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/cli, packages/runtime, @objectstack/spec)
  • content/docs/api/data-flow.mdx (via @objectstack/cli)
  • content/docs/api/environment-routing.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/approvals.mdx (via @objectstack/plugin-approvals, packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/cli, @objectstack/runtime, packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via packages/services, @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/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 @objectstack/metadata-protocol, packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/runtime, packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/backup-restore.mdx (via @objectstack/cli)
  • content/docs/deployment/cli.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/self-hosting.mdx (via @objectstack/cli)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/cli, @objectstack/runtime, @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/audit-service.mdx (via packages/services)
  • content/docs/kernel/runtime-services/data-service.mdx (via packages/cli)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/cli, packages/services, packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/settings-service.mdx (via packages/services)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/cli, @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/qa, packages/runtime, @objectstack/spec)
  • content/docs/permissions/delegated-administration.mdx (via packages/qa)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/cli, @objectstack/plugin-approvals, @objectstack/runtime, packages/services, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/services, @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/cli)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/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 @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/cli, @objectstack/plugin-approvals, @objectstack/runtime, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-approvals, @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The #1888 user-less fail-open is wider than the lint that guards it — record-change flows fired by a system write run UNSCOPED, unlinted

1 participant