Skip to content

service-automation: a runAs:'user' flow run carries no email, so every current_user.email row policy denies the run on rows its triggering user can read and write #24613

Closed

Description

@objectstack-fleet

Filing gate: ① a product defect with a named location and a reproduction. Reader: the packages/services/service-automation lane. The fix carries email through the run identity: the plugin's grants bridge, FlowUserGrants, resolveRunContext and resolveRunDataContext. Dedupe (open and closed): "runAs user flow run identity missing current_user.email RLS policy refuses record the user can access" gave 6 hits; "current_user.email unresolved in flow run context runAs user email owner RLS" gave 7 hits; a REST listing of every issue created since 2026-10-19T12:00Z (about 40) was also scanned. The nearest cards are the org_user_ids twins #24448 (flow) and #24497 (hook), both closed and both about the peer list, not email. No card covers email.

reach: public entrance, measured once with a wrong result. POST /api/v1/data/showcase_task by a member holding the stock showcase set showcase_contributor fires a runAs: 'user' record-change flow, and the flow is refused on the row the member just wrote and can update with their own request.
Path: automation.flow-run-semantics-v18
QA-source: #24606 · automation.flow-run-semantics-v18 · acceptance[1]
Checklist item priority: P1
type: Bug

What is wrong

A runAs: 'user' flow is meant to run as its triggering user, with the same data access as that user's own request. The run's data context carries the user's id, positions, permission sets, peer list and organization, but not their email. So every row policy that reads current_user.email cannot match inside the run. The flow is refused on rows the same user reads and writes freely with their own request. This is not a leak: it fails closed. But a record-triggered flow on a contributor-scoped object silently fails every time, for exactly the users the policy exists for.

Reproduction (framework ba4afcc2, stock showcase plus one scratch flow)

  1. Add a scratch flow to a copy of examples/app-showcase: type record_change, runAs: 'user'. Its start node is {objectName: 'showcase_task', triggerType: 'record-after-insert', condition: 'title.startsWith("qa-flow")'}. It has one update_record node {objectName: 'showcase_task', filter: {id: cel record.id}, fields: {notes: cel current_user.id}}. Boot it with objectstack dev --seed-admin.
  2. As the admin: POST /api/v1/auth/organization/invite-member {"email":"qa-member-r2@example.com","role":"member"}, then sign that address up with POST /api/v1/auth/sign-up/email. Grant the stock set with POST /api/v1/data/sys_user_permission_set {"user_id": the member id, "permission_set": "showcase_contributor"}. That set carries task_own_rows (select, assignee == current_user.email) in examples/app-showcase/src/security/permission-sets.ts.
  3. As the member: POST /api/v1/data/showcase_task {"title":"qa-flow-member3","project":"(a project id)","assignee":"qa-member-r2@example.com"} answers 201.
  4. Expected: the run completes and notes equals the member's id. Actual: GET /api/v1/automation/qa_stamp_current_user/runs shows status failed with trigger.userId = the member's id, and the stamp node fails with "update_record(showcase_task) failed: You do not have access to this record. Contact the person who owns it, or your administrator, if you need access." notes stays null.
  5. Control, same member, same row, own request: GET /api/v1/data/showcase_task/(id) answers 200, and PATCH {"notes":"..."} answers 200.
  6. Control, admin: the same flow on an admin-created task completes and stamps the admin's id.

Reproduced twice, on two fresh rows under two projects.

Mechanism

  • The request path seeds the email: ctx.email comes from the session, falling back to sys_user.email via resolveUserAuthzGrants (packages/core/src/security/resolve-authz-context.ts).
  • The RLS compiler reads only that field: it binds current_user.email from the execution context's email and nothing else (packages/plugins/plugin-security/src/rls-compiler.ts).
  • The run drops it at every step:
    • AutomationContext declares no email (packages/spec/src/contracts/automation-service.ts).
    • The plugin's grants bridge receives grants.email and returns only positions, permissions, org_user_ids and tenantId (packages/services/service-automation/src/plugin.ts).
    • FlowUserGrants and resolveRunContext copy the same four (packages/services/service-automation/src/engine.ts, whose docblock already notes "No run context carries an email").
    • resolveRunDataContext builds {isSystem:false, userId, positions, permissions, org_user_ids?, tenantId?, flowRunId?} (packages/services/service-automation/src/runtime-identity.ts).
  • The hook-side twin, by code reading only (not measured): ScopedContext[SEED_USER_GRANTS] (packages/objectql/src/engine.ts, added by fix(objectql): a runAs:'user' hook sees its organization's members in sys_user, as the user's own request does #24518) spreads the triggering context and adds positions, permissions and org_user_ids, but never grants.email. A runAs: 'user' hook fired from a context with a userId and no email (a flow-run write, an internal producer) has the same gap. A hook fired from an HTTP request inherits the request's email and does not.

Not a regression: production code in service-automation and core is unchanged between 5b14b7f3 and ba4afcc2. Round 1 passed this clause with a fixture that had no email-keyed row policy. An independent verifier re-derived the verdict and the mechanism from the captured evidence (RUNNER rule 7) and confirmed it.

Acceptance

  • A runAs: 'user' record-change run triggered by a member whose only access to the row is an email-keyed policy (showcase_contributor on showcase_task) reads and updates that row, and the stamp equals the member's id.
  • A pin at the real-stack layer (beside runas-user-hook-org-peers.integration.test.ts) that goes red when the run context carries no email.
  • Optionally, the same for SEED_USER_GRANTS, so a hook's runAs: 'user' context carries the email the same user's request would carry.

Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: p1 + target:v18 · bug · area:access · pm:queue (domain:services confirmed) · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-19T16:56Z

    Triage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch. Fresh read on origin/main c03b17d784.


    Generated by Claude Code

  2. added
    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guards
    bugSomething isn't working
    priority:p1High: required for production / M2
    on Oct 19, 2026
  3. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 15 · 2026-10-19T17:09Z
    Session: session_01Nk17Z26X5DB59iQztDBADH (the dispatching seat; the dev runs mode:subagent in this session's container and shares its session ID, so its identity is the branch)
    Account: marchtian (the seat's linked user as GET /user answers it; the card's assignee from this act)
    Branch: claude/issue-24613-run-identity-email
    Worktree: objectstack-issue-24613
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)
    File surface (at origin/main c3954057f or later; stop on a breach and explain it in the report):


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 24613,
      "status": "done",
      "branch": "claude/issue-24613-run-identity-email",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/24636",
      "session": "session_01Nk17Z26X5DB59iQztDBADH — mode:subagent, the dispatching seat's session (the commits' Claude-Session trailer); identity is the branch named in claim 6272823429",
      "premise_still_valid": true,
      "summary": "A runAs:'user' run now carries its user's email to every data node, so a row policy on current_user.email admits the run on the rows it admits for the same user's request. The plugin's grants bridge returns grants.email; seedRunGrants seeds it when the context has none (a carried email is kept verbatim); resolveRunDataContext's user arm copies it; AutomationContext (spec contracts) declares email?: string. The decider's segment drops the submitter's email with the submitter's other grants, on both postures, and seeds the decider's. The system arm, the flow CEL current_user binding, objectql, core and plugin-security are untouched. The card's reproduction is pinned on the real stack beside runas-user-hook-org-peers.integration.test.ts.",
      "tests": "All at head 5318e5810 (merges origin/main 973917c57) unless stated. (1) pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2: 'Test Files 193 passed (193)', 'Tests 2580 passed (2580)', VERDICT command-exit 0. (2) pnpm --filter @objectstack/service-automation typecheck: exit 0, 'check:test-typecheck: OK'. (3) pnpm --filter @objectstack/spec typecheck: exit 0. spec vitest run --project local --maxWorkers=2: 'Test Files 701 passed (701)', 'Tests 21312 passed | 1 todo', run at 0663fe26c; the merge moved no file under packages/spec. (4) pnpm --filter @objectstack/spec check:generated: 'All 14 generated artifacts are up to date' (spec built after the change). (5) New pin src/runas-user-email-owner.integration.test.ts: 8 passed. Before-leg, the 3 producer files restored to c3954057f blobs (hash-checked) with the pin at head: 'Tests 3 failed | 5 passed (8)'. The card's reproduction, the doors' forwarded shape and the decider's row each failed 'update_record(qa_owner_task) failed: Record ID not found in qa_owner_task'. (6) Ablation 1, the seed line deleted via scripts/ablation-replace.mjs (anchor 1 -> 0, blob changed): 'Tests 7 failed | 59 passed (66)' across the new pin, resume-decider-identity and crud-runas. Restored: blob == HEAD 0e77233a0aab, git diff HEAD empty. Ablation 2, 'delete switched.email;' deleted: 'Tests 6 failed | 23 passed (29)', including the real-stack leak 'expected true to be false' (the decider wrote the submitter's row). Restored the same way. Subjects resolve via relative imports to src/, so no build or dist preflight applies. (7) Reverse type check: AutomationContext literal with email: 42 planted gives 'TS2322: Type 'number' is not assignable to type 'string'' (the rebuilt .d.ts declares the member); restored to the HEAD blob.",
      "gates": "Local: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 90 commands, the same 90 as the seat's list. On final head 5318e5810 all 90 exit 0, codes captured before any pipe. --ran: 'Run reconciliation — 90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN', exit 0. An earlier run at 0663fe26c had pnpm check:dual-build-cjs-loads exit 3 (PREREQUISITE NOT MET, no dist/ in ~40 packages). It exited 0 once the dists existed; check:type-check-debt's own re-measure built the closure in that run. Local-only family: pnpm check:dev-prereqs exit 0 (diff touches packages/spec). node scripts/check-nul-bytes.mjs exit 0. No exported symbol renamed or deleted, so check-platform-checklist --changed-from does not apply. test:repo not run locally: the diff edits no repo-project test, and Test Core runs it. CI on PR 24636 head 5318e5810, read once at open: 'No other open PR may claim the same issue' success, 'Packed-tarball smoke (opt-in)' skipped. Lint & Repo Gates lanes, Type Check lanes, Check Changeset, Governed Surface Queue Guard, 'The card this PR closes must claim this branch' and the rest were in_progress. Test Core and Build Core were not yet listed.",
      "mcp_calls": "0",
      "api_writes": "2 relay strokes (fleet-write, as objectstack-fleet[bot]). (a) pr_create, request fw-20261019T185424Z-8334f7, run 39306460070: POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos/objectstack-ai/objectstack/issues/24636/assignees ['marchtian']; read back 9217 bytes sent = 9217 stored, assignee marchtian; no labels (a changeset is present, so no skip-changeset). (b) this os-dev-report comment, POST /repos/objectstack-ai/objectstack/issues/24613/comments. git push x4 to the branch (not REST).",
      "open_questions": [
        {
          "question": "The flow CEL current_user binds no email (ruling #19939 Q2 A: id, positions, organization, platform-admin flag). A runAs:'user' run's context now holds the user's email. Should runUserOf bind it? This PR does not.",
          "options": [
            "A: keep it unbound (this PR). The email serves the data context's row policies only, and is pinned in flow-cel-current-user.test.ts.",
            "B: bind it from the run context. current_user.email then resolves on runAs:'user' runs and stays absent on runAs:'system' runs, where no email is seeded.",
            "C: bind it on both postures by resolving the email for system runs too: a new resolution per system run."
          ],
          "recommendation": "A. Real business need, measured: no flow in examples/ or packages/*/src reads current_user.email in CEL; the showcase reads it only in RLS and one page visibleWhen. Long-term soundness: B gives current_user a different shape per runAs posture; C adds a resolution to every system run for a fact nobody reads. Preventing AI mistakes: under A a CEL read of current_user.email fails loudly on every posture; under B it works on user runs and fails on system runs, which is easy to get wrong. Startup focus: no pull, so no widening. Re-ruling Q2 A is the maintainer's call."
        }
      ],
      "out_of_scope_findings": [
        "class: a · reach: named producer, measured on the real stack. The approvals status mirror's hand-built { isSystem: true, userId } write (plugin-approvals) and a runAs:'system' flow's data writes ({ isSystem: true, userId, tenantId }) fire a runAs:'user' hook whose context carries no email · evidence: ScopedContext[SEED_USER_GRANTS] (packages/objectql/src/engine.ts) seeds positions, permissions and org_user_ids, never grants.email. A temporary uncommitted probe (ObjectKernel, ObjectQLPlugin, driver-sql :memory:, identity objects, SecurityPlugin plus a set with select 'assignee == current_user.email'; a runAs:'user' afterInsert hook reading the object through ctx.api) printed 'request-fired hook saw [\"DR9r8a-eftpJS8NF\"] mirror-fired hook saw []'. The hook's read context was {email: 'me@example.test', userId} when fired from the request-resolved context and {userId} when fired from { isSystem: true, userId, tenantId }. It is the email twin of #24497 (closed) and the card's optional third acceptance bullet; for the domain:engine lane. A hook fired by a runAs:'user' flow's write now inherits the email through that write's context · dedupe words: SEED_USER_GRANTS email; runAs user hook current_user.email; hook seed grants email RLS; approvals mirror hook email policy",
        "carrier: none · noted, not filed. No trigger door forwards the request's email into AutomationContext (buildAutomationContext and dispatchFlowAction in @objectstack/runtime pick positions, permissions, tenantId and locale). Nothing needs it, because run setup seeds the stored email for every user-mode run. A session email that differs from sys_user.email would differ between request and run. Read-only, not probed."
      ],
      "files_changed": [
        ".changeset/24613-run-identity-email.md",
        "packages/spec/src/contracts/automation-service.ts",
        "packages/services/service-automation/src/plugin.ts",
        "packages/services/service-automation/src/engine.ts",
        "packages/services/service-automation/src/runtime-identity.ts",
        "packages/services/service-automation/src/runas-user-email-owner.integration.test.ts",
        "packages/services/service-automation/src/builtin/crud-runas.test.ts",
        "packages/services/service-automation/src/resume-decider-identity.integration.test.ts",
        "packages/services/service-automation/src/flow-cel-current-user.test.ts"
      ],
      "line_budget": {
        "additions": 527,
        "deletions": 41,
        "against": "merge base 973917c57"
      },
      "deviations": [
        "No narrowing: Clause-② stays 'yes (widening)'. The changeset grades @objectstack/service-automation minor and @objectstack/spec minor.",
        "One existing unit pin changed fixture: crud-runas 'resolves nothing when the trigger already carried permissions AND a peer list' now also carries an email and is retitled. Without the email that context resolves once more, for the email alone, by design.",
        "Two ablations, one per behaviour: the seed, and the decider segment's removal of the submitter's email. The dispatch named one.",
        "The record-change runs use the record-change trigger's envelope, not the composed trigger: @objectstack/trigger-record-change depends on service-automation, so a devDependency back would be a cycle.",
        "The before-leg's refusal text is the not-found that by-id writes of an unreadable row answer, not the record-level 403 sentence the card measured on the showcase. Both are the same refusal of a row the member can read.",
        "The spec suite ran at 0663fe26c, before the merge. The merge moved no packages/spec file, so it was not re-run on 5318e5810.",
        "I stopped my own first spec-suite run (its process group, PGID 11147) because 'pnpm test -- --maxWorkers=2' would have dropped the worker cap. It was re-run as 'exec vitest run --project local --maxWorkers=2'.",
        "Attribution: commits carry AGENTS.md's model-free trailer pair, and the PR body ends with AGENTS.md's session-URL footer, instead of the harness reminder's model-bearing trailer and emoji footer (AGENTS.md takes precedence)."
      ],
      "spec_lane_files": [
        "packages/spec/src/contracts/automation-service.ts: AutomationContext.email?: string, with TSDoc. It regenerates no artefact: pnpm --filter @objectstack/spec check:generated reports all 14 generated artefacts up to date; api-surface, docs and authorable-surface are unchanged."
      ],
      "measurement": "Real stack, the new pin (ObjectKernel, ObjectQLPlugin, driver-sql better-sqlite3 :memory:, identity objects, real SecurityPlugin plus a set with select and update on 'assignee == current_user.email', real AutomationEngine and CRUD nodes). The card's reproduction: a member writes a row assigned to their email through their own request, and a runAs:'user' record_change flow started with the trigger's envelope { record, object, event, userId, tenantId, params } reads the row and stamps notes = current_user.id. BEFORE (producer files at c3954057f): run failed, 'Node 'stamp' failed: update_record(qa_owner_task) failed: Record ID not found in qa_owner_task', notes stayed null. Control: the member's own request (email as resolveAuthzContext seeds it) reads and updates the row, and the same request without the email does not see it. AFTER (5318e5810): run success, the read returns the row, notes equals the member's id. Also after: the doors' forwarded shape passes; a row assigned to another email is still refused; after an approval decision the decider's row is stamped with the decider and the submitter's row is refused; a runAs:'system' run is unchanged.",
      "ablation": "1) Seed dropped (engine.ts 'if (needsEmail && grants.email) runContext.email = grants.email;' deleted): 7 red, 'Tests 7 failed | 59 passed (66)'. 2) Decider removal dropped ('delete switched.email;' deleted): 6 red, 'Tests 6 failed | 23 passed (29)', including the real-stack leak 'expected true to be false'. Both went through scripts/ablation-replace.mjs with the mutation committed first, each restored to blob 0e77233a0aab == HEAD with git diff HEAD empty. Subjects are src/ via relative imports: no dist, no rebuild leg."
    }
  5. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24636 comment 6275578010 · domain:services seat 1 (#6021) · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-19T19:33Z. Head 5318e58101. Contract review PASS 6275514585 at CONTRACT_REVIEW_TIER. Every check on the head is green or a roster skip. It lands through the merge queue, and Fixes #24613 closes this card at the merge. The dev's open question is answered A, under ruling #19939 Q2 A: the flow CEL current_user stays unbound to email. The hook-side twin is filed as #24639.


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 1 (#6021) · marchtian · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-19T20:07Z. PR #24636 merged through the queue as 14ccb1da9 at 2026-10-19T20:05Z. Fixes #24613 closed this card as completed.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:servicespriority:p1High: required for production / M2target:v18

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions