UN-2651 [FIX] Show execution logs to group and org-shared users - #2234
UN-2651 [FIX] Show execution logs to group and org-shared users#2234kirtimanmishrazipstack wants to merge 6 commits into
Conversation
Summary by CodeRabbit
WalkthroughThe change updates execution visibility to use user-scoped resource querysets. Execution log access now checks execution visibility before querying logs. Tests cover organization sharing, group sharing, unshared deployments, and denied or allowed log access. ChangesExecution access control
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant RequestingUser
participant WorkflowExecutionLogViewSet
participant WorkflowExecutionManager
participant ExecutionLogs
RequestingUser->>WorkflowExecutionLogViewSet: Request execution logs
WorkflowExecutionLogViewSet->>WorkflowExecutionManager: Check execution access for user
WorkflowExecutionManager-->>WorkflowExecutionLogViewSet: Return accessible execution or no match
WorkflowExecutionLogViewSet->>ExecutionLogs: Query logs for accessible execution
WorkflowExecutionLogViewSet-->>RequestingUser: Return logs or PermissionDenied
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
The executions list resolved visibility through direct memberships only, so a deployment reached via a group share or shared_to_org opened fine while its Logs page came back empty. Defer to each resource's own for_user, which spans every sharing path the resource list itself honours (owner, co-owner, direct share, group share, shared_to_org). The per-execution logs and export endpoints had the mirrored problem: no scoping at all. IsOwner sat in permission_classes but implements only has_object_permission, which DRF never invokes on list/export, so any org member holding an execution id could read and CSV-export its logs. Gate the queryset on the executions the caller can see instead, and deny a missing execution the same way as an inaccessible one so the response does not confirm which ids exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
f680ac4 to
5faa618
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/workflow_manager/execution/tests/test_shared_execution_access.py`:
- Around line 98-101: Add a test case alongside
test_logs_denied_when_the_execution_is_not_accessible that calls _log_queryset
with the outsider user and a nonexistent execution ID, and assert it raises
PermissionDenied, preserving the same response as for inaccessible existing
executions.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 11b32fb9-dd05-4444-aaa8-e84fba18d494
📒 Files selected for processing (3)
backend/workflow_manager/execution/tests/test_shared_execution_access.pybackend/workflow_manager/workflow_v2/execution_log_view.pybackend/workflow_manager/workflow_v2/models/execution.py
|
| Filename | Overview |
|---|---|
| backend/workflow_manager/execution/access.py | Introduces a common authorization gate that denies inaccessible and unknown execution identifiers uniformly. |
| backend/workflow_manager/workflow_v2/models/execution.py | Delegates execution visibility to each resource manager and preserves organization-scoped bypass behavior. |
| backend/workflow_manager/workflow_v2/execution_log_view.py | Applies the shared gate to log listing and export while restricting the public viewset to read operations. |
| backend/workflow_manager/file_execution/views.py | Applies execution-level authorization before returning per-file execution data. |
| backend/workflow_manager/workflow_v2/execution_view.py | Scopes workflow execution lists through the user-visible execution queryset. |
| backend/tenant_account_v2/organization_member_service.py | Memoizes the organization-admin predicate on the request user with organization-aware invalidation. |
| backend/workflow_manager/execution/tests/test_shared_execution_access.py | Covers sharing modes, tenant isolation, bypass roles, and authorization across execution-derived endpoints. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
U[Authenticated user] --> E[Execution endpoint]
E --> G[Shared execution access gate]
G --> Q[WorkflowExecution.for_user]
Q --> W[Workflow.for_user]
Q --> A[API deployment.for_user]
Q --> P[Pipeline.for_user]
W --> V{Execution visible?}
A --> V
P --> V
V -->|Yes| D[Return logs, export, files, or execution list]
V -->|No| X[Return permission denied]
Reviews (5): Last reviewed commit: "UN-2651 [MISC] Trim the comments added b..." | Re-trigger Greptile
There was a problem hiding this comment.
🧹 Nitpick comments (1)
backend/workflow_manager/workflow_v2/execution_log_view.py (1)
32-32: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winResolve or intentionally suppress Ruff RUF012.
Line 32 assigns a mutable list to a class attribute. All viewset instances share this list. Annotate the attribute as
ClassVaror use the repository's approved immutable iterable so the shared permission configuration is explicit. Verify that the chosen form matches the DRF version used by this project.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/workflow_manager/workflow_v2/execution_log_view.py` at line 32, Update the permission_classes class attribute in the viewset to resolve Ruff RUF012 by annotating it with ClassVar or using the repository-approved immutable iterable. Preserve DRF’s expected permission configuration and confirm the chosen form is compatible with the project’s DRF version.Source: Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@backend/workflow_manager/workflow_v2/execution_log_view.py`:
- Line 32: Update the permission_classes class attribute in the viewset to
resolve Ruff RUF012 by annotating it with ClassVar or using the
repository-approved immutable iterable. Preserve DRF’s expected permission
configuration and confirm the chosen form is compatible with the project’s DRF
version.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 8ba2a165-f055-432f-90a1-aee20ed7414e
📒 Files selected for processing (3)
backend/workflow_manager/execution/tests/test_shared_execution_access.pybackend/workflow_manager/workflow_v2/execution_log_view.pybackend/workflow_manager/workflow_v2/models/execution.py
🚧 Files skipped from review as they are similar to previous changes (2)
- backend/workflow_manager/execution/tests/test_shared_execution_access.py
- backend/workflow_manager/workflow_v2/models/execution.py
…sible ones Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chandrasekharan-zipstack
left a comment
There was a problem hiding this comment.
Standardized PR Review — INITIAL
Verdict: REQUEST CHANGES
Critical: 0 · High: 4 · Medium: 5 · Low: 3 · Lenses run: 16/16
The change itself is correct and is a net security improvement — it fixes a real intra-org log-read hole and a real visibility bug, and nothing in the diff is a regression. REQUEST CHANGES is for three things: the PR's stated security claim is falsified by a sibling endpoint on the same URL prefix, the identical anti-pattern the PR diagnoses is left in place one file away, and the negative control in the new test suite asserts nothing.
Reviewed at head ef3c01d. Posted as COMMENT, so it carries no merge-gate weight. The 3 Low findings are in a separate follow-up comment.
Unanchored findings
Both of these are High, and both are about files this PR does not touch — so there is no diff line to attach them to. They are the two most consequential findings in this review.
[High] [Lens 1 — Spec & intent, Lens 4 — Security] — /execution/<id>/files/ still returns log text for any execution id
Would anchor to: backend/workflow_manager/file_execution/views.py:14-36
The PR body states: "A run's logs can now only be opened by people the deployment or pipeline was actually shared with." That is not true after this diff.
FileCentricExecutionViewSet is mounted on the same execution/ prefix as the two endpoints being gated — backend/backend/urls_v2.py:65 and :66 — and is fetched by the same UI page. It carries permission_classes = [IsAuthenticated] and keys its queryset on the URL id alone:
execution_id = self.kwargs.get("pk") # views.py:23
return FileExecution.objects.filter(
workflow_execution_id=execution_id # views.py:34
).annotate(latest_log_data=Subquery(latest_log_subquery))No for_user anywhere. Any authenticated org member holding an execution id gets file names, file_path, sizes, statuses, execution_error, and — via get_status_msg (file_execution/serializers.py:36,40) — the latest ExecutionLog.data["log"] string for that run. That is the same data class this PR is protecting, leaking through the sibling endpoint at the same URL depth.
Note the resulting inconsistency: the detail route /execution/<id>/ is gated (workflow_manager/execution/views/execution.py:28-30), so an unauthorized caller now gets 403 on detail and 200 on files.
Suggested fix: the same one-line gate in FileCentricExecutionViewSet.get_queryset. Better still, factor the check into a small mixin shared with WorkflowExecutionLogViewSet, so the next execution/<pk>/<thing>/ route inherits it instead of re-deriving it.
Pre-existing rather than introduced here — but it defeats the PR's own stated goal, so it belongs in this PR or an explicitly linked follow-up. Confidence: High.
[High] [Lens 4 — Security, Lens 2 — Precedent] — GET /workflow/<workflow_id>/execution/ has the identical dead-IsOwner defect, left in place
Would anchor to: backend/workflow_manager/workflow_v2/execution_view.py:13-26
permission_classes = [IsOwner] # :15
...
queryset = WorkflowExecution.objects.filter(workflow_id=workflow_id) # :22-24No for_user. IsOwner implements only has_object_permission (permissions/permission.py:113), which DRF never invokes on list — verbatim the reasoning this PR gives for the log viewset. Any authenticated org member can enumerate every execution of any workflow id in the org (ids, statuses, timings, execution_error), including workflows never shared with them.
The route is live: workflow_v2/urls/workflow.py:79-83 → workflow_manager/urls.py:20 → backend/urls.py:34. DEFAULT_PERMISSION_CLASSES is [] (settings/base.py:643), so [IsOwner] is the entire gate. OrganizationFilterBackend still applies here (this viewset does not override filter_backends), so it is org-bounded but not user-bounded.
Secondary: the class also omits IsAuthenticated — not anonymously exploitable only because auth middleware covers it, which leaves it one settings change from being open.
Suggested fix: WorkflowExecution.objects.for_user(self.request.user).filter(workflow_id=workflow_id), plus IsAuthenticated in permission_classes.
Separately and out of scope: retrieve on this viewset filters workflow_id=pk while pk is an execution id, so execution/<uuid:pk>/ can never resolve — likely a dead route worth a look.
Pre-existing; flagged because leaving it makes this cleanup partial and preserves the exact anti-pattern the diff is retiring. Confidence: High.
Lens checklist (16/16)
| # | Lens | Result |
|---|---|---|
| 1 | Spec & intent | See unanchored finding 1 |
| 2 | Architectural fit & precedent | See execution_log_view.py:32, unanchored finding 2 — the delegation to each resource's own for_user is the right consolidation; the objection is to what was left behind |
| 3 | Correctness & edge cases | See Low findings (follow-up comment) |
| 4 | Security | See both unanchored findings, execution_log_view.py:32 |
| 5 | Data integrity & migrations | N/A — no migration, schema, or persisted-field change |
| 6 | Concurrency | N/A — no locks, threads, async ordering or retries in the diff |
| 7 | API & contract compatibility | Clean — the logs endpoint narrows 200→403 for unauthorized callers and the PR body declares it; no wire-format or field change |
| 8 | Reliability & resilience | N/A — no external calls, timeouts, retries or unbounded buffers in the diff |
| 9 | Performance & cost | See models/execution.py:69-75 |
| 10 | Observability | Clean — the denial is logged via ExceptionLoggingMiddleware.format_exc_and_log (middleware/exception.py:63-66), so the new gate is not a blind spot |
| 11 | Operational safety | N/A — no IaC, Helm, CI or feature-flag change; rollback is a revert |
| 12 | LLM/agent | N/A — no model, prompt, tool or eval code touched |
| 13 | Testing | See test_shared_execution_access.py:42-48, :50-58, :73-80, models/execution.py:66-68, plus Lows |
| 14 | Dependencies & build | N/A — no dependency, lockfile or build change |
| 15 | Code quality | Clean |
| 16 | Doc & comment accuracy | See models/execution.py:37-38 — the two comments this diff adds are both accurate; the defect is a pre-existing line inside the edited block |
Notes
- Nothing was weakened to green CI. No existing test file is touched; no assertion removed or relaxed. The
IsOwnerremoval is a genuine correctness fix, not test-silencing. - I did not run the test suite. The 6/6 claim is unverified by me; none of the findings above depend on a test failing.
- CodeRabbit's one finding (unknown-id denial test) was addressed by the author in
ef3c01d. Nothing to promote. - No cloud-side override of these viewsets or managers exists — checked
unstract-cloud.
Open questions
/execution/<id>/files/— fix here, or land as a linked follow-up? Either is fine, but the PR body's security claim should be softened if it is deferred.WorkflowExecutionViewSet— same question. The commit message diagnoses the pattern precisely; was the sibling viewset checked and consciously deferred?- Was the missing
OWNERmembership row in_api_deploymentdeliberate, or an oversight? It changes what four of the six tests actually prove.
Standardized review — the 3 Low findingsSplit out from the review above so they don't compete with the High/Medium ones. None of these block anything. [Low] [Lens 3 — Correctness] —
The removed helper documented an explicit
The replacements go through That is fail-closed, so it is not a security issue, and I confirmed every current caller is a request-context viewset ( Suggested fix: narrow the comment to something like "org-scoped via Confidence: Medium — would rise to High if a worker or management path is later shown to call [Low] [Lens 13 — Testing] —
The property being claimed is that an unknown id and an inaccessible id are indistinguishable to an attacker probing for valid ids. The test asserts only that There is also a real distinction at the HTTP layer that this test structurally cannot see: Suggested fix: drive both cases through Confidence: High. [Low] [Lens 13 — Testing] — Randomised fixture value makes a failure non-reproducible from CI output
api_name=f"api-{secrets.token_hex(4)}"Only uniqueness is required, and per-test rollback makes collisions a non-issue — so this is not a flake source. But nothing seeds or logs the value, so a failure whose cause depended on the name could not be reproduced from the CI output. Suggested fix: derive the name from the test method name or a per-instance counter. Equivalent uniqueness, reproducible. Confidence: High. |
Review follow-up on the sharing tests and the log viewset. - Test fixtures built API deployments with no OWNER membership row, so they were visible to nobody and the negative test constrained nothing. The fixture now creates the row the deployment viewset creates, and asserts the owner sees the execution — which is what makes the denial a control. - Executions were only ever built with pipeline_id set, leaving the workflow-level and Pipeline branches of for_user unexercised. _execution now takes an optional resource, with tests for both. - Tests called get_queryset() directly, so the 403 and the whole export action were unverified. Both endpoints now go through as_view(). - Unknown and inaccessible execution ids are compared on status and body, which is what an enumerator observes. - Added a cross-organization test: for_user is the only tenant boundary on /execution/, since the viewset replaces filter_backends. - The log viewset is read-only; the queryset gate does not cover writes. - Memoize the org-admin predicate on the user instance: for_user resolved it and then delegated to three managers that each re-resolved it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@chandrasekharan-zipstack On the three Lows — two fixed in 8e2649c, one I think is not worth acting on. L1 (comment reads unconditional) — agreed, fixed. L2 (unknown-id test cannot fail independently) — agreed, and your framing is the right one: the property is about what an enumerator observes. Replaced it with L3 (randomised |
Self-review — high + medium findingsRan a multi-pass review over this branch (correctness, tests, error handling, comment accuracy). Every claim below was verified against the code. Lows are listed at the bottom without detail. HIGHH1 — The fix is incomplete:
class FileCentricExecutionViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = [IsAuthenticated]
def get_queryset(self):
return FileExecution.objects.filter(workflow_execution_id=execution_id)...Same The payload is not metadata-only. Both endpoints back the same screen: Pre-existing, but same threat model, same id space, same page — and this PR's own rationale ("unknown ids are denied like inaccessible ones so the response does not reveal which ids exist") applies verbatim. Suggest extracting the gate into a shared H2 —
organization = UserContext.get_organization()
return super().get_queryset().filter(organization=organization)With no org context that is The genuine fail-closed mechanism is H3 —
The decision to go read-only is right. The stated reason is the inverse of the mechanism, and would justify someone later adding a write handler on the belief it bypasses the gate. Worth also noting that the reason no write verb is reachable today is that both URLconfs bind GET only — not anything in this class. H4 — Test gap: the
final_filter = (workflow_filter & Q(pipeline_id__isnull=True)) | deployment_filterDeleting That conjunct is what stops workflow access from leaking into runs of deployments deliberately not shared — a privilege widening on the endpoint this PR is hardening. It is also the behaviour the PR description calls out as counter-intuitive ("every path has to be revoked"), so it is the line most likely to be "simplified" later. def test_workflow_share_does_not_expose_unshared_deployment_runs(self) -> None:
deployment = self._api_deployment() # shared with nobody
execution = self._execution(deployment)
self._share_with_group(self.workflow) # member now sees the workflow
self.assertIn(self.workflow, Workflow.objects.for_user(self.member)) # control
self.assertFalse(self._visible_to(self.member, execution))
def test_deployment_share_does_not_expose_workflow_level_runs(self) -> None:
deployment = self._api_deployment(shared_to_org=True)
self.assertTrue(self._visible_to(self.outsider, self._execution(deployment))) # control
self.assertFalse(self._visible_to(self.outsider, self._execution()))H5 — The admin memo is not keyed by organization, but the predicate it caches is org-dependent
The justifying comment — "Django rebuilds that instance per request, so the memo lives exactly one request" — asserts an invariant nothing enforces. I traced the mid-request switch: that view is a bare org_id = UserContext.get_organization_identifier()
memo = getattr(user, _ADMIN_MEMO_ATTR, None)
if memo is not None and memo[0] == org_id:
return memo[1]
...
setattr(user, _ADMIN_MEMO_ATTR, (org_id, is_admin))Separately worth asking whether the memo belongs in this PR. Measured saving is exactly 3 single-row indexed lookups per MEDIUMM1 —
M2 — The org-admin and service-account branches have zero test coverage on a cross-tenant boundary
These branches are the only tenant boundary for admins on M3 — Pre-existing and unchanged by this PR. But this PR's new docstring promotes this manager to the tenant boundary ("That org scoping is enforced here, not by the view"), and these two lines return every execution in every organization when I confirmed all three non-test callers of M4 —
Pre-existing, but this PR makes it more confusing: the gate now explicitly confirms "you have access to this execution", and the body then returns M5 — The denial is not logged with the user id
logger.warning(
"Execution log access denied: user=%s execution=%s org=%s exists=%s",
getattr(self.request.user, "id", None), execution_id,
UserContext.get_organization_identifier(),
WorkflowExecution.objects.filter(pk=execution_id).exists(),
)M6 — The PR description's test table is inaccurate It claims "6 / 6 pass (4 of them fail on Lows, not detailed here
Holding up wellThe gate itself is real and pinned — delete the |
The logs endpoint was gated on its own, but `<id>/files/` takes the same execution id, backs the same screen, and returned file names, per-file errors and the latest log line to any authenticated org member. `/workflow/<id>/ execution/` carried the same dead `IsOwner` this PR retires elsewhere. Both now go through `assert_execution_accessible`, which also logs the denial with the user id — the server is the only place left that can tell an unknown id from an inaccessible one, since the response deliberately cannot. Also: fail closed rather than returning every tenant's executions when no organization is in context; key the admin memo by organization, since the predicate it caches is org-dependent; and correct two comments that described the inverse of the mechanism they sat on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Addressed in H1 — H2 — the fail-closed claim. Confirmed: H3 — the read-only rationale. Both halves confirmed. H4 — the H5 — memo staleness. Memo is now M1 — M2 — new M3 — extracted M4 — not deleting the branch. The reachability analysis is right — the M5 — the denial now logs M6 — PR body updated. Dropped the table for one sentence naming the file, as suggested. Four mutation checks run against the new tests, all caught, files restored: gate removed from Lows not acted on beyond the three I corrected in passing (the |
chandrasekharan-zipstack
left a comment
There was a problem hiding this comment.
@kirtimanmishrazipstack consider making the code comments concise and generic. These can go stale quickly and also lead to context rot when the agent scans codebases
Several of them narrated the mechanism line by line or recorded what the code used to be. That kind of comment goes stale on the next refactor and adds noise for anything reading the file whole. Kept the reason, dropped the retelling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@chandrasekharan-zipstack Fair — done in What I cut: line-by-line retellings of the mechanism, What I kept: the reason a thing is the way it is, where it isn't inferable from the code — why the log viewset is read-only, why the compat |
|
Unstract test resultsPer-group results
Critical paths
|



What
Why
How
for_user, which covers owner, co-owner, direct share, group share andshared_to_org.execution/<id>/...route now calls one shared gate (workflow_manager/execution/access.py) — logs, log export and the per-file list all take the same id and are authorised the same way. An unknown id is denied exactly like an inaccessible one, so responses do not reveal which ids exist; the server logs the difference instead.IsOwnerfrom the log viewset and from/workflow/<id>/execution/— it implements onlyhas_object_permission, which DRF never calls on list/export. Both viewsets are read-only and gate on the queryset instead.Can this PR break any existing features. If yes, please list possible items. If no, please explain why. (PS: Admins do not merge the PR without this section filled)
execution/<id>/logs/,execution/<id>/files/andworkflow/<id>/execution/. A user who could previously fetch any of them by id now needs access to that execution. No UI path exposed those ids to users without access, so normal flows are unaffected. The other change only widens visibility.Database Migrations
Env Config
Relevant Docs
Related Issues or PRs
Dependencies Versions
Notes on Testing
Automated
backend/workflow_manager/execution/tests/test_shared_execution_access.pycovers the sharing matrix (owner, co-owner, direct viewer, group, org-wide, cross-org), the two bypass roles, and both log endpoints plus the per-file endpoint at the HTTP layer. Each gate was checked by mutation — removing it fails a named test.Manual
Two accounts in one organisation: an owner/admin and a second user who is a member of a group.
Part 1 — the UI (visibility)
Part 2 — the API (access control)
The UI never links to a resource you can't see, so the gate itself can only be exercised by calling the endpoint directly.
GET /execution/<execution_id>/logs/with the workflow unsharedYou do not have access to this execution.GET /execution/<execution_id>/files/with the workflow unsharedWorth knowing: access is granted through the workflow or the API deployment or the pipeline. Unsharing only the workflow still returns 200, correctly — every path has to be revoked before the 403 appears.
This is added in screenshot below
Screenshots:
Checklist
I have read and understood the Contribution Guidelines.