Skip to content

fix(workflows): record the owner, then scope control to them (#17014) - #17666

Merged
mrveiss merged 5 commits into
mainfrom
issue-17014-workflow-owner
Sep 27, 2026
Merged

mrveiss merged 5 commits into
mainfrom
issue-17014-workflow-owner

Conversation

@mrveiss

@mrveiss mrveiss commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

Thinking Path

ActiveWorkflow.owner_id already existed. The WebSocket's may_control already admitted
"an admin, or the owner the workflow records". Both were inert, because no creation path
ever set an owner
— so every workflow answered owner_id is None, an owner check against
None matched nobody, and the WebSocket was admin-only by accident rather than by design.
REST control_workflow had no check at all, so any signed-in user could pause, cancel or
approve any workflow.

That shape drove the design: the identity a workflow records and the identity a check
compares must come from the same function, or the check silently never matches and looks
correct while doing nothing. Hence one caller_id used by both sides rather than each site
picking its own claim out of the user dict.

The second decision was what an absent owner means. "Nobody owns it, so anybody may" is the
hole itself; this fails closed — no owner means admin-only.

Two things surfaced while implementing that changed the work:

A defect that would have silently defeated the fix. These routes wrap their body in
try: ... except Exception: raise HTTPException(500). A 403 raised inside would have
surfaced as "Internal server error" — and the pre-existing 404 already did, so "workflow
not found" has been returning 500. A refusal indistinguishable from a bug is also a refusal
no test can assert, so except HTTPException: raise was required, not cosmetic.

A ceiling. routes.py sat at 599 of its 600-line limit. The ownership checks had to
go somewhere, so the approval helpers moved out instead of the ceiling moving up.

What Changed

New — services/workflow_automation/workflow_ownership.py (98 lines): caller_id,
is_owner_or_admin, require_owner_or_admin, find_workflow. One home for the rule.

Owner recorded on every creation path that has a caller (AC1):

path file
REST create workflow_automation/routes.py:79
chat-derived routes.py:274 → manager.py (new owner_id parameter)
templates api/templates.py — route gained current_user; the router's admin gate returns a bool, so there was no user object to record
coordinator advanced_workflow/coordinator.py → its two routes in advanced_workflow/routes.py
import services/workflow_serializer.py — already passed one; unchanged

Scoped to owner-or-admin (AC2): control_workflow, start_workflow, workflow_status,
present_plan, approve_plan, pending_approval.

approve_plan's docstring claimed an authorization it did not perform — "validated by
checking session_id matches the workflow", which the client supplies. So it authorised
whoever asked and deferred the real check to an API gateway that does not perform one. The
docstring now states what the code does.

Executor (AC4): workflow.owner_id or workflow.session_id → workflow.owner_id. A
session id is not a user id; the fallback made an opaque session string the key secrets were
resolved and redacted under, and every workflow reached it. An unowned workflow now
resolves no user secrets rather than another namespace's, and manager.py logs at creation
that it will be admin-only with no user secrets — once per workflow, where the choice is made.

AC3 needed no code. ws_endpoint.may_control already admitted owners; it was waiting on
an owner to exist. It gets tests.

New — approval_support.py (45 lines): the two approval helpers, moved so routes.py
went 599 → 573 rather than raising a ceiling.

Which reading of #17014 this PR satisfies — say it before anyone quotes "one exception"

#17014 is titled "workflow control cannot be scoped to its owner (REST control is open
to any signed-in user)"
, and its ACs name creation, the control/state/approval routes, the
WebSocket control check and the executor's secret resolution. This PR takes the control
reading.
Under it, AC1 has one exception (below) and ACs 2-4 are delivered.

That matters because there is a second, broader reading — "another user's workflow is
protected"
— and under that reading this PR is not sufficient, because export_workflow
authenticates and never authorises: any signed-in user can export any workflow by id
, receiving
every step's literal command, its step_config, and the owner's identity
(api/workflow_export.py:59, services/workflow_serializer.py:271,295-309,150-153). Found by
autobot-ai-87 reviewing this PR; filed as #17669.

It is deliberately not fixed here — different file, different verb (read, not control), and
folding an unreviewed security change into a reviewed PR is the wrong trade. But it cuts one way
worth stating: before #17014, original_owner_id in that export payload was always None,
because nothing recorded an owner. This PR populates it, so it gives that unguarded endpoint a
real new field to disclose. Still net-positive — an owner recorded and enforced on control beats
no owner and no control anywhere — but #17669 should land close behind, not drift.

So: do not quote "one stated exception" when asked whether #17014 can close. The honest
answer is "ACs 2-4 delivered, AC1 has one stated exception (#17655), and a read surface outside
these ACs is open (#17669)" — and whether that last clause blocks closure is a scope decision for
whoever owns the issue.

Deliberately not fixed here, stated rather than quiet

  • api/workflow.py:292 is a sixth creation path. Recording an owner needs +3 lines
    minimum (two parameters, one import) and that file is a shrink-only baselined file at
    1027
    . Doing it properly means splitting a 1027-line file — its own PR. I reverted my
    change to it entirely rather than leave it half-done. It is fail-closed as it stands:
    the route is admin-only, and an unowned workflow is admin-only to control, so there is no
    authorisation hole — only an admin-created workflow that resolves no user secrets. AC1
    therefore has one stated exception.
  • /active_workflows and /completed_workflows return every workflow to any signed-in
    user.
    Same class, different remedy: filtering changes what the UI shows for existing
    unowned workflows, which is a product decision rather than a security patch.
  • Four more routes in routes.py still convert HTTPException to 500 — create_workflow,
    get_active_workflows, get_completed_workflows, create_workflow_from_chat. Five already
    had the guard; this change added it to the six it gave a 403. Filed as fix(workflows): four routes convert their own HTTPException into a 500, so a deliberate 404 reads as a server error #17653, with the
    lineage: fix(api): /agents/recommend swallows HTTPException(400) into a generic 500 #10662 fixed this same defect at one route in another file and closed, and the
    class was never swept.

Filed rather than widened into this branch: #17653 (the swallow class), #17654 (the
list routes), #17655 (the api/workflow.py split, which is what lets AC1's last path be
delivered).

AC1 therefore has one undelivered criterion, so this is Refs #17014, not Closes.
Partial delivery closes nothing.

Verification

  • Pre-push hook: [pre-push OK] pytest: all relevant tests pass on
    workflow_ownership_17014_test.py, and the full pre-commit run passed, including the
    Python file size guard and detect-secrets.
  • 25 new tests, co-located, driving the real route functions through TestClient with
    only get_current_user and the manager stood in — so the ownership lookup, the 403 and the
    re-raise all execute.
  • Negative control (local): removing the ownership calls turns 4 tests red
    (control_workflow cross-user, the masked-404 assertion, workflow_status and
    start_workflow cross-user); restoring them returns 25 green. So the tests fail for the
    reason their titles claim.
  • Regression (local): 53 passing in test_planning_caller_wiring,
    test_chat_workflow_is_request_shaped, test_workflow_notification_persistence and
    api/ws_auth_17009_test.py — the callers that still pass two arguments keep working.
  • File sizes: routes.py 599 → 573; executor.py exactly 1104, its recorded
    ceiling, after restructuring away a first version that reached 1117; api/workflow.py
    reverted to exactly 1027. No ceiling raised, no baseline entry added.
  • black, isort, flake8 clean over all changed files.

One defect in this change, self-caught and fixed in 72a42ac. present_plan was one of
the routes given an ownership check and one of the routes whose except Exception swallows
HTTPException — so its 403 came back as a 500: a working check that looked like a crash. It
survived the first pass because /present_plan was missing from the parametrized cross-user
test, which is the whole lesson: an unlisted route is an unchecked route. It is in the list
now, and an AST assertion over the file confirms no route carrying an ownership check still
swallows its 403
.

Local counts are labelled local; CI is the authority. The dependency-floor report in this
checkout states 91 of 139 comparable versions sit below their declared floor, so a local pass
here carries no information about CI's installed set.

Model Used

Claude Opus 5 (1M context)

Single-issue rationale

#17014 is a v0.9.0 release-blocking authorisation hole and the branch freeze was lifted for
it by name, for this issue alone. Nothing else is authorised to ride with it, and the three
adjacent defects this work surfaced were filed rather than folded in (#17653, #17654, #17655).

Refs #17009, #2304, #16628, #17653, #17654, #17655
Refs #17014

mrveiss and others added 3 commits September 27, 2026 21:43
`ActiveWorkflow.owner_id` and the WebSocket's `may_control` both existed and both
were inert: no creation path set an owner, so every workflow answered
`owner_id is None`. An owner check against None admits nobody, which made the
WebSocket admin-only, while REST `control_workflow` had no check at all -- any
signed-in user could pause, cancel or approve any workflow.

Recording and checking now share one identity function, because a check that
compares a different claim than the one recorded silently never matches.

- `workflow_ownership.py` (new): `caller_id`, `is_owner_or_admin`,
  `require_owner_or_admin`, `find_workflow`. An absent owner means admin-only,
  not open -- fail closed.
- Owner recorded on every creation path that has a caller: the REST route,
  chat-derived (via the manager), templates, and the advanced-workflow
  coordinator. `workflow_serializer` already passed one.
- Scoped to owner-or-admin: `control_workflow`, `start_workflow`,
  `workflow_status`, `present_plan`, `approve_plan`, `pending_approval`.
- `approve_plan`'s docstring claimed authorization "by checking session_id
  matches the workflow" -- client-supplied, so it authorised whoever asked, and
  deferred the real check to a gateway that does not perform it.
- Executor no longer resolves secrets under `workflow.session_id`. A session id
  is not a user id; an unowned workflow now resolves no user secrets rather than
  another namespace's, and creation logs that it will.
- `except HTTPException: raise` on the routes gaining a 403. Without it the
  route's `except Exception` returned 500 for a refusal -- and already did for
  the pre-existing 404, so "workflow not found" was an Internal Server Error.
- `approval_support.py` (new): `routes.py` was at 599 of its 600-line ceiling,
  so the two approval helpers moved out rather than the ceiling moving up.
  599 -> 573.

Refs #17009, #2304, #16628
Found by enumerating the routes whose `except Exception` converts an
`HTTPException` raised in the try body into 500. `present_plan` is one of five,
and it is the one this change had just given an ownership check -- so its refusal
would have surfaced as "Internal server error".

The cross-user test did not cover `/present_plan`, which is why the gap survived
the first pass: an unlisted route is an unchecked route. It is in the
parametrized list now, and an AST assertion over the file confirms no route
carrying an ownership check still swallows its 403.

The remaining four swallows are pre-existing and got no 403 from this change, so
they stay out of scope and are filed separately.
@coderabbitai

coderabbitai Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Warning

Review limit reached

Next included review available in 21 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: dd98f2d9-e5a8-462c-970e-0ac485e17060

📥 Commits

Reviewing files that changed from the base of the PR and between dac33c9 and b9ee299.

⛔ Files ignored due to path filters (1)
  • autobot-frontend/src/types/generated/api.ts is excluded by !**/generated/**
📒 Files selected for processing (9)
  • autobot-backend/api/templates.py
  • autobot-backend/services/advanced_workflow/coordinator.py
  • autobot-backend/services/advanced_workflow/routes.py
  • autobot-backend/services/workflow_automation/approval_support.py
  • autobot-backend/services/workflow_automation/executor.py
  • autobot-backend/services/workflow_automation/manager.py
  • autobot-backend/services/workflow_automation/routes.py
  • autobot-backend/services/workflow_automation/workflow_ownership.py
  • autobot-backend/services/workflow_automation/workflow_ownership_17014_test.py

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 575d6d04-305e-4f6d-b297-60ba87657554

📥 Commits

Reviewing files that changed from the base of the PR and between 897c3e0 and dac33c9.

⛔ Files ignored due to path filters (1)
  • autobot-frontend/src/types/generated/api.ts is excluded by !**/generated/**
📒 Files selected for processing (9)
  • autobot-backend/api/templates.py
  • autobot-backend/services/advanced_workflow/coordinator.py
  • autobot-backend/services/advanced_workflow/routes.py
  • autobot-backend/services/workflow_automation/approval_support.py
  • autobot-backend/services/workflow_automation/executor.py
  • autobot-backend/services/workflow_automation/manager.py
  • autobot-backend/services/workflow_automation/routes.py
  • autobot-backend/services/workflow_automation/workflow_ownership.py
  • autobot-backend/services/workflow_automation/workflow_ownership_17014_test.py

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Workflow creation now records the authenticated caller as the owner. Workflow routes check owner or admin access, and secret resolution uses the recorded owner. Approval validation and outcome handling use shared helpers.

Changes

Workflow ownership and access

Layer / File(s) Summary
Record caller ownership
autobot-backend/services/workflow_automation/workflow_ownership.py, autobot-backend/api/templates.py, autobot-backend/services/advanced_workflow/*, autobot-backend/services/workflow_automation/manager.py, autobot-backend/services/workflow_automation/routes.py, autobot-backend/services/workflow_automation/workflow_ownership_17014_test.py
Caller identity claims determine the owner ID. Template, advanced workflow and chat creation paths pass that ID to workflow creation. Calls without an owner ID remain supported, and workflow creation logs a warning when no owner is set.
Resolve secrets using workflow owner
autobot-backend/services/workflow_automation/executor.py, autobot-backend/services/workflow_automation/workflow_ownership_17014_test.py
Secret resolution uses workflow.owner_id. If no owner ID is available, the command remains unchanged and secret resolution is skipped.
Enforce owner or admin access
autobot-backend/services/workflow_automation/workflow_ownership.py, autobot-backend/services/workflow_automation/routes.py, autobot-backend/services/workflow_automation/workflow_ownership_17014_test.py
Workflow lookup searches active and completed workflows. Workflow routes check owner or admin access and preserve HTTP 403 and 404 responses. Tests cover REST and WebSocket access.
Validate and process approvals
autobot-backend/services/workflow_automation/approval_support.py, autobot-backend/services/workflow_automation/routes.py
Shared helpers validate pending approvals and process approval outcomes. Approved workflows start execution; rejected workflows are cancelled with the supplied reason or No reason provided.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to dac33

The identified access-control failures are not established for supported workflow users. No actionable merge-blocking risk remains after normal checks.

Security Architecture Review

Security architecture risk: 🟠 High · up to dac33

Workflow access is more restricted overall, but the new owner check can treat a changeable username as equivalent to the identity recorded for a workflow. Where a workflow records a username, another account that later obtains that name may gain control of it.

Retained concerns

  • High · security · inferred: Owner recording chooses one identity claim, but authorization accepts any current claim without recording its type. A username-derived owner can consequently be matched by a different account after that username changes hands.
Security review details

Security Blast Radius

  • inferred — Affected operations include workflow control and plan approval. The observed check is per workflow; the number of workflows with username-derived owners and their downstream execution authority are not established.

Security Findings and Attack Paths

  • inferred — If a workflow records a username-derived owner, a different authenticated account that later acquires that username can satisfy the new owner check and reach workflow operations. The retained security finding verifies the claim-comparison condition; the prevalence of qualifying production workflows remains uncertain.

Trust Boundaries and Controls

  • observed — REST control and approval now authorize before mutating workflow state; the WebSocket control path has its own existing owner comparison. Neither comparison establishes a typed, immutable identity for a username-derived owner.

Resilience and Maintainability Implications

  • inferred — Active and completed lookup shares a check, but the available source does not establish that owner identity survives completion, reload, retry, or recovery unchanged.

Hardening Proposals

  • proposed — Store a typed, immutable owner identity and compare it only with the corresponding authenticated identity. Define explicit handling for historical username owners before removing legacy matching.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 63.64% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 55 functions across 9 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarises the main changes: recording workflow ownership and restricting workflow control to the owner or an administrator.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No new hardcoded values of either class — ssot and other both block.

Known backlog in pipeline-scripts/hardcoded_values_baseline.txt is suppressed and tracked in #14371.

Auto-regenerated (py3.14) to match the backend and/or SLM backend
schema so the required verify-generated-types gate(s) pass.
Triggered by auto-fix-generated-types.yml.

@mrveiss mrveiss left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-author review of dac33c9b5b18, on scope rather than correctness — is the change's reach what the body claims?

The stated exception is genuinely fail-closed. Verified, not taken.

is_owner_or_admin (workflow_ownership.py:49) returns False for a non-admin when owner_id is falsy, with the reasoning in the docstring — "the alternative reading ('nobody owns it, so anybody may') is the hole #17014 is about". And api/workflow.py's routes are each Depends(check_admin_permission): :389 list, :429 details, :449 status, :526 approve, :572 execute. So the :292 exception is a missing capability, not a missing guard — today an owner who is not an admin cannot act there at all. That is the right direction to be incomplete in, and #17655 is the right home for the +3 lines against a 1027-line baseline.

One correction to my own method, before the finding

My first pass counted is_owner_or_admin per route file and got 0 everywhere — including the files that plainly enforce. The guard is called inside find_workflow (workflow_ownership.py), which raises 404 for missing and 403 for not-permitted, and the routes call that: 11 uses in workflow_automation/routes.py, 3 in advanced_workflow/routes.py. A lookup that cannot be performed without the check is a better design than a check at each handler, and my grep at the endpoint would have reported the opposite. Naming it because it is the grep the endpoint, miss the shared helper trap and I nearly published it.

Reach: three of the four untouched surfaces have their own enforcement. One does not.

file enforcement at this head
api/workflow.py admin-only on every route — the stated exception
api/workflow_permissions.py its own require_workflow_permission("view"/"grant"), documented "Only owners and admins"
api/workflow_secrets.py scopes every query by owner_id = current_user.get("user_id")
api/workflow_export.py:59 authenticated only — no ownership scoping

export_workflow takes current_user purely to log it:

doc = await serializer.export_workflow(workflow_id)     # no owner argument
if doc is None:
    raise HTTPException(status_code=404, …)
logger.info("User %s exported workflow %s", current_user.get("user_id"), workflow_id)
return {"success": True, "export": doc.to_dict()}

So any signed-in user can export any workflow by id, and the export is the full definition. That is #17014's shape on the disclosure side rather than the control side — any signed-in user reaches another user's workflow — and it is not the stated exception, which is an admin-only route.

I am raising it as a reach question rather than a defect in this PR, and the judgement is not mine to make: if #17014 is scoped to control, export is a sibling and belongs on its own issue; if it is scoped to another user's workflow, then the body's "one exception" is two and the second is open to any authenticated caller rather than admin-gated. Either answer is defensible; what should not happen is the number being quoted as one when someone later asks whether #17014 can close.

I did not check /validate or /import on that router — import sets owner_id from the caller (:134), which looks right, and validate takes no workflow id. Nor did I look at the frontend types or the WebSocket may_control path.

Otherwise

25 tests with a negative control, one identity function shared by the recording and the checking path so a workflow cannot be recorded under one identity and checked against another, and Refs not Closes with the exception stated. On scope, everything the body claims holds except the export surface above.

@mrveiss
mrveiss merged commit f9e7ff6 into main Sep 27, 2026
80 checks passed
@mrveiss
mrveiss deleted the issue-17014-workflow-owner branch September 27, 2026 22:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant