Skip to content

UN-2651 [FIX] Show execution logs to group and org-shared users - #2234

Open
kirtimanmishrazipstack wants to merge 6 commits into
mainfrom
UN-2651-shared-project-logs
Open

UN-2651 [FIX] Show execution logs to group and org-shared users#2234
kirtimanmishrazipstack wants to merge 6 commits into
mainfrom
UN-2651-shared-project-logs

Conversation

@kirtimanmishrazipstack

@kirtimanmishrazipstack kirtimanmishrazipstack commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

What

  • Teammates who get a deployment or pipeline shared with them through a group, or shared with the whole organisation, can now see its runs on the Logs page. That page used to come up empty for them.
  • A run's logs, its per-file results and its execution list can now only be opened by people the deployment or pipeline was actually shared with.

Why

  • A shared deployment whose Logs page is empty reads as broken, and it let down exactly the teammates sharing was meant to help. Sharing with one person already worked; sharing through a group or with the whole org did not.
  • Anyone in the organisation could read a run's logs if they had its link, even when nothing had been shared with them.

How

  • Executions resolve visibility through each resource's own for_user, which covers owner, co-owner, direct share, group share and shared_to_org.
  • Every 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.
  • Dropped IsOwner from the log viewset and from /workflow/<id>/execution/ — it implements only has_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)

  • Three read endpoints narrow: execution/<id>/logs/, execution/<id>/files/ and workflow/<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

  • None

Env Config

  • None

Relevant Docs

Related Issues or PRs

Dependencies Versions

  • None

Notes on Testing

Automated

backend/workflow_manager/execution/tests/test_shared_execution_access.py covers 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)

# Who Action Result
1 Owner Opens the Logs page for a workflow run Logs listed
2 Owner Exports the run's logs CSV and JSON both download
3 Second user Workflow is group-shared; opens the Logs page Sees the same logs — this is the fix
4 Owner / admin Unshares the workflow from the group (removing the user from the group does the same) Workflow disappears from the second user's UI, and its logs with it

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.

# Who Action Result
5 Second user GET /execution/<execution_id>/logs/ with the workflow unshared 403You do not have access to this execution.
6 Second user GET /execution/<execution_id>/files/ with the workflow unshared 403, same message

Worth 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:

4

Checklist

I have read and understood the Contribution Guidelines.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • Bug Fixes
    • Improved access controls for workflow execution logs.
    • Prevented unauthorized users from viewing logs or discovering restricted executions.
    • Ensured organization and group sharing permissions are consistently respected across executions, workflows, deployments, and pipelines.
    • Added coverage for shared, restricted, and inaccessible execution scenarios.

Walkthrough

The 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.

Changes

Execution access control

Layer / File(s) Summary
User-scoped execution visibility
backend/workflow_manager/workflow_v2/models/execution.py
WorkflowExecutionManager.for_user now resolves workflow, API deployment, and pipeline visibility through their for_user querysets.
Execution log authorization
backend/workflow_manager/workflow_v2/execution_log_view.py
The log view uses WorkflowExecution.objects.for_user(...) and raises PermissionDenied for inaccessible or unknown executions before querying logs.
Shared access validation
backend/workflow_manager/execution/tests/test_shared_execution_access.py
Tests cover organization-wide sharing, group sharing, unshared deployments, denied log access, and log access after deployment sharing.

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
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: enabling execution log visibility for group- and organization-shared users.
Description check ✅ Passed The description covers the required change, rationale, implementation, risks, migrations, configuration, testing, and checklist items.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch UN-2651-shared-project-logs

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.

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>
@kirtimanmishrazipstack
kirtimanmishrazipstack force-pushed the UN-2651-shared-project-logs branch from f680ac4 to 5faa618 Compare August 7, 2026 17:54
@kirtimanmishrazipstack kirtimanmishrazipstack changed the title UN-2651 [FIX] Show execution logs to group-shared and org-shared users UN-2651 [FIX] Show execution logs to group and org-shared users Aug 7, 2026
@kirtimanmishrazipstack
kirtimanmishrazipstack marked this pull request as ready for review August 10, 2026 06:26

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 6b916ea and 6c5f8cd.

📒 Files selected for processing (3)
  • backend/workflow_manager/execution/tests/test_shared_execution_access.py
  • backend/workflow_manager/workflow_v2/execution_log_view.py
  • backend/workflow_manager/workflow_v2/models/execution.py

@greptile-apps

greptile-apps Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR aligns execution visibility with workflow, pipeline, and API-deployment sharing while consistently authorizing execution logs and per-file results.

  • Adds a shared execution-access gate for execution-derived endpoints.
  • Expands execution querysets to direct, group, and organization-wide shares while retaining organization-scoped administrative bypasses.
  • Makes public execution viewsets explicitly read-only and adds access-matrix coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

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]
Loading

Reviews (5): Last reviewed commit: "UN-2651 [MISC] Trim the comments added b..." | Re-trigger Greptile

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
backend/workflow_manager/workflow_v2/execution_log_view.py (1)

32-32: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Resolve 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 ClassVar or 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

📥 Commits

Reviewing files that changed from the base of the PR and between 6b916ea and 6c5f8cd.

📒 Files selected for processing (3)
  • backend/workflow_manager/execution/tests/test_shared_execution_access.py
  • backend/workflow_manager/workflow_v2/execution_log_view.py
  • backend/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 chandrasekharan-zipstack left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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-24

No 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-83workflow_manager/urls.py:20backend/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 IsOwner removal 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

  1. /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.
  2. WorkflowExecutionViewSet — same question. The commit message diagnoses the pattern precisely; was the sibling viewset checked and consciously deferred?
  3. Was the missing OWNER membership row in _api_deployment deliberate, or an oversight? It changes what four of the six tests actually prove.

Comment thread backend/workflow_manager/execution/tests/test_shared_execution_access.py Outdated
Comment thread backend/workflow_manager/execution/tests/test_shared_execution_access.py Outdated
Comment thread backend/workflow_manager/workflow_v2/execution_log_view.py
Comment thread backend/workflow_manager/workflow_v2/models/execution.py
Comment thread backend/workflow_manager/workflow_v2/models/execution.py Outdated
Comment thread backend/workflow_manager/workflow_v2/models/execution.py
@chandrasekharan-zipstack

Copy link
Copy Markdown
Contributor

Standardized review — the 3 Low findings

Split out from the review above so they don't compete with the High/Medium ones. None of these block anything.


[Low] [Lens 3 — Correctness] — for_user now returns empty rather than membership-scoped results outside request context

backend/workflow_manager/workflow_v2/models/execution.py:66-78

The removed helper documented an explicit organization argument for exactly this case:

Falls back to UserContext so request paths need no change; pass organization explicitly on worker/management paths where UserContext is empty.
tenant_account_v2/sharing_helpers.py:208-213

The replacements go through DefaultOrganizationManagerMixin.get_queryset, which does filter(organization=UserContext.get_organization()) (utils/models/organization_mixin.py:27-30). With no org context that becomes organization_id IS NULL, so a non-admin for_user call from Celery or a management command silently returns nothing.

That is fail-closed, so it is not a security issue, and I confirmed every current caller is a request-context viewset (workflow_manager/execution/views/execution.py:30,44 and execution_log_view.py:46). But the escape hatch the old helper deliberately provided is gone, and the new comment at :68 — "Those managers are org-scoped, so no explicit org arg" — reads as unconditional when it really means "correct inside a request".

Suggested fix: narrow the comment to something like "org-scoped via UserContext; request paths only", so a future worker-side caller is warned rather than surprised.

Confidence: Medium — would rise to High if a worker or management path is later shown to call WorkflowExecution.objects.for_user.


[Low] [Lens 13 — Testing] — test_logs_denied_when_the_execution_is_unknown cannot fail independently

backend/workflow_manager/execution/tests/test_shared_execution_access.py:104-108

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 PermissionDenied is raised — from the single raise at execution_log_view.py:50 that both cases reach identically. It is true by construction and can only fail in lockstep with its sibling at :99-102. It never compares status code, body, or headers, which are what an enumerator actually observes.

There is also a real distinction at the HTTP layer that this test structurally cannot see: execution/urls.py:22 uses <uuid:pk>, so a malformed id 404s from URL resolution while an unknown-but-valid UUID 403s.

Suggested fix: drive both cases through as_view({"get": "list"}) and assert the two responses have equal status_code and equal .data. That is the actual property, and it can then fail on its own.

Confidence: High.


[Low] [Lens 13 — Testing] — Randomised fixture value makes a failure non-reproducible from CI output

backend/workflow_manager/execution/tests/test_shared_execution_access.py:43

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>
@kirtimanmishrazipstack

Copy link
Copy Markdown
Contributor Author

@chandrasekharan-zipstack On the three Lows — two fixed in 8e2649c, one I think is not worth acting on.

L1 (comment reads unconditional) — agreed, fixed. models/execution.py:66-70 now reads "org-scope themselves via UserContext, so this is correct on request paths only — a worker or management command with no org context gets an empty queryset (fail-closed)."

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 test_unknown_execution_is_indistinguishable_from_an_inaccessible_one, which drives both ids through as_view({"get": "list"}) and asserts equal status_code and equal .data. It fails on its own now.

L3 (randomised api_name) — not taking this one. API_NAME_MAX_LENGTH is 30 and the method names here run past 50, so deriving the name from _testMethodName overflows the column. More fundamentally, no assertion reads api_name, so a failure cannot depend on its value — and secrets.token_hex is already the module’s idiom for throwaway uniqueness (tenant_account_v2/tests.py:44). Happy to revisit if you have a case where the name reaches an assertion.

@kirtimanmishrazipstack

Copy link
Copy Markdown
Contributor Author

Self-review — high + medium findings

Ran 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.


HIGH

H1 — The fix is incomplete: /execution/<id>/files/ takes the same id and is still ungated within the org

backend/workflow_manager/file_execution/views.py:14-35

class FileCentricExecutionViewSet(viewsets.ReadOnlyModelViewSet):
    permission_classes = [IsAuthenticated]
    def get_queryset(self):
        return FileExecution.objects.filter(workflow_execution_id=execution_id)...

Same <uuid:pk> — a WorkflowExecution id — with no for_user check. OrganizationFilterBackend still applies (this viewset does not override filter_backends), so cross-org is blocked, but nothing scopes it within the org.

The payload is not metadata-only. FileCentricExecutionSerializer uses exclude = ["file_hash"], so the response carries file_name, file_path, execution_error, provider_file_uuid, fs_metadata — plus status_msg, which get_status_msg resolves from the latest non-DEBUG/WARN ExecutionLog.data["log"]. That is log text.

Both endpoints back the same screen: DetailedLogs.jsx:177 fetches /execution/${id}/files/ and LogModal.jsx:47 fetches /execution/${executionId}/logs/, for the same id. After this PR a de-shared user gets 403 on one and filenames + per-file error text + a log line on the other.

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 assert_execution_accessible(user, execution_id) helper and calling it from both get_querysets so the two cannot drift again.


H2 — execution.py:69-73: the "empty queryset (fail-closed)" claim is false

DefaultOrganizationManagerMixin.get_queryset (backend/utils/models/organization_mixin.py:27-30) is:

organization = UserContext.get_organization()
return super().get_queryset().filter(organization=organization)

With no org context that is filter(organization=None), which Django compiles to organization_id IS NULL — orphan rows, not zero rows. organization is null=True on the mixin, and DefaultOrganizationMixin.save() assigns it from UserContext, so rows created off the request path are born with NULL org — exactly what this branch would return.

The genuine fail-closed mechanism is OrganizationFilterBackend (utils/filters/organization_filter.py:52-60, explicit .none() plus a warning) — which ExecutionViewSet drops by overriding filter_backends. Either reword the comment to what the code does, or make the mixin .none() on a missing org.


H3 — execution_log_view.py:31-33: the read-only rationale is inverted, and the editable=False half is wrong

Read-only on purpose: the access gate lives in get_queryset, which write handlers never call. ExecutionLog rows are written by workers and their fields are editable=False, so there is nothing to expose.

  • DRF's update / partial_update / destroy all call get_object()filter_queryset(self.get_queryset()). They are precisely the handlers that do run the gate. create is the only one that skips it.
  • Only id, execution_id, wf_execution, file_execution carry editable=False. data (JSONField) and event_time (DateTimeField) do not, and the serializer is fields = "__all__" (workflow_v2/serializers.py:154-157), so DRF would expose both as writable on a PATCH.
  • Rows are written by the backend Celery task execution_log_utils.consume_log_history, not by the workers/ processes.

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 pipeline_id__isnull=True conjunct is unpinned

execution.py:88:

final_filter = (workflow_filter & Q(pipeline_id__isnull=True)) | deployment_filter

Deleting & Q(pipeline_id__isnull=True) breaks no test. Every fixture deployment hangs off self.workflow (test file lines 48, 61), and self.workflow is reachable only by self.owner — so the assertFalse in test_group_share_exposes_the_deployment_executions and test_pipeline_execution_follows_the_pipeline_share holds with or without the conjunct. The mutation survives.

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

tenant_account_v2/organization_member_service.py:10-14, 41-44

OrganizationMember.objects is OrganizationMemberModelManager(DefaultOrganizationManagerMixin, ...) (tenant_account_v2/models.py:12), so OrganizationMember.objects.get(user=user.id) resolves against UserContext. The answer is a function of (user, current org); the memo stores only the bool.

The justifying comment — "Django rebuilds that instance per request, so the memo lives exactly one request" — asserts an invariant nothing enforces. authentication_controller.set_user_organization:178 flips UserContext mid-request while holding the same request.user, and the predicate is a public static called with non-request users (prompt_studio_helper.py:241 passes profile_manager.created_by; tool_instance_helper.py:525 takes an arbitrary user).

I traced the mid-request switch: that view is a bare @api_view with no permission class that would seed the memo beforehand, so there is no live bug today. But a stale True in for_user means "see every resource in the org" — the widest possible answer — and the guard is currently a comment rather than code. One-line fix:

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 for_user call, non-admins only (admins short-circuit before the three delegates). That is 22 lines and a new staleness mode, in a module with 20+ call sites, riding along in an access-control fix. Hoisting the predicate to a local in for_user and passing it down saves the same 3 queries with no lifetime question at all.


MEDIUM

M1 — organization_member_service.py:62-65: except AttributeError: pass guards an unreachable case and silently disables the memo

AnonymousUser cannot reach that line (line 38 returns first — is_authenticated is False), and it is not immutable anyway (plain __dict__, setattr succeeds). No user type in either repo raises here. If a future cloud proxy ever wraps User with a custom __setattr__, this pass silently restores the 4x query amplification the hunk exists to remove, with zero diagnostic signal. Drop the try/except, or log a warning instead of passing. It is also the only bare except: pass in the diff.

M2 — The org-admin and service-account branches have zero test coverage on a cross-tenant boundary

execution.py:57-61 and :63-67 — 4 lines each, 0 hits in the integration coverage report for this branch's own CI run. The setUp patch (test file lines 40-42) pins is_user_organization_admin to False for the whole class, so neither branch ever executes.

These branches are the only tenant boundary for admins on /execution/, per this PR's own docstring. GroupSharingTestBase already creates self.admin with role "admin" and it is unused in this file. A sibling class without the patch closes both. Note the patch buys nothing for the current assertions either — every fixture user has role "user", so real admin resolution already returns False.

M3 — execution.py:61, 67: return self.all() is a cross-org fallback under a docstring that says it cannot happen

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 UserContext.get_organization() is None — which UserContext returns on three silent paths (no StateStore key, Organization.DoesNotExist, ProgrammingError), none of them logged.

I confirmed all three non-test callers of WorkflowExecution.objects.for_user are request-path, so self.none() would break nothing. Either make it self.none() (2 lines, arguably in scope since the PR is claiming the guarantee) or soften the docstring so the claim does not outrun the code.

M4 — execution_log_view.py:57-59: the backward-compat Q(execution_id=...) branch is dead in request context

ExecutionLog.objects is an OrgAwareManager (models/execution_log.py:42) that BFS-resolves the org path to wf_execution__workflow__organization. That is an INNER JOIN, so any legacy row with wf_execution IS NULL — the only rows the second Q can match — is dropped before the OR is evaluated.

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 {"count": 0, "results": []} with no error and no log line. Since this method's contract is being rewritten anyway, worth answering the question — delete the branch and the TODO, or make it reachable via an unfiltered manager plus an explicit org check.

M5 — The denial is not logged with the user id

execution_log_view.py:53. The global handler (middleware.exception.drf_logging_exc_handler) does log method, full URI and request id at ERROR, so this is not silent. What is missing is who — the field needed to answer "is someone enumerating execution ids?". Since this PR deliberately makes unknown and inaccessible ids indistinguishable to the client (correct), the server is now the only place that can tell them apart, and that distinction is worth recording:

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 main)". The file has 13 tests, and 8 fail on main. Two listed names do not exist in the file (test_unshared_deployment_stays_invisible, test_logs_denied_when_the_execution_is_unknown), and seven real tests are missing from the table. Reviewers calibrate on that table. A table mirroring a test file also rots on every edit — one sentence naming the file is more durable.


Lows, not detailed here

.values("pk") redundant on pk__in=<queryset>; .distinct() vestigial (all terms are IN (subquery), no outer join); _log_queryset redundant now that _call drives the real endpoint, and two of its three uses are strict subsets of the _call tests; _share_with_group hand-writes the ContentType row instead of using the production set_resource_share_groups; JSON export (the default file_format) untested; group-share and direct-viewer tested for APIDeployment but not Pipeline; export 400/413 paths uncovered; cross-org test has no positive control; UserContext never torn down in GroupSharingTestBase; comment cites APIDeploymentViewSet.perform_create, which does not exist (the OWNER row is created in create()); class docstring says self.owner "owns every fixture here", not true of the cross-org fixtures; six intermediate Q locals in for_user collapse to one expression.


Holding up well

The gate itself is real and pinned — delete the raise PermissionDenied block and four tests fail. Dropping IsOwner genuinely widens nothing: it implements only has_object_permission, and all four routes bind list/export, neither of which calls get_object(). test_unknown_execution_is_indistinguishable_from_an_inaccessible_one asserting on the response (status and body) rather than the exception type is the right call — the usual assertRaises version ships an enumeration oracle. And test_unshared_deployment_is_visible_only_to_its_owner asserting its own positive control is the discipline H4 and the cross-org test are missing.

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>
@kirtimanmishrazipstack

Copy link
Copy Markdown
Contributor Author

Addressed in 92a06516. All five Highs and five of six Mediums fixed; M4 got a comment rather than a deletion. Also fixed @chandrasekharan-zipstack's second unanchored High from the earlier review, which this self-review did not repeat.

H1 — <id>/files/ ungated. New workflow_manager/execution/access.py holds assert_execution_accessible(user, execution_id); WorkflowExecutionLogViewSet.get_queryset and FileCentricExecutionViewSet.get_queryset both call it. One gate, so the two routes on the same id cannot drift apart again.

H2 — the fail-closed claim. Confirmed: DefaultOrganizationManagerMixin compiles filter(organization=None) to organization_id IS NULL, which matches orphan rows rather than nothing. Comment now states that, and points the reader at the request path. I did not change the mixin — that is a repo-wide behavioural change well outside this PR.

H3 — the read-only rationale. Both halves confirmed. update/partial_update/destroy do run the gate via get_object(); create is the single handler that does not. Only id, execution_id, wf_execution and file_execution are editable=Falsedata and event_time are writable under fields = "__all__". Writer is the backend consume_log_history Celery task, not workers/. Comment rewritten to the actual mechanism.

H4 — the pipeline_id__isnull=True conjunct. Both suggested tests added verbatim in intent. Verified by mutation: deleting the conjunct now fails test_workflow_share_does_not_expose_unshared_deployment_runs.

H5 — memo staleness. Memo is now (organization_identifier, is_admin) and the comment no longer asserts an instance-lifetime invariant. Kept the memo rather than hoisting the predicate into for_user: the three delegates would each need a new parameter in their public signature, which is a wider change than the one it replaces.

M1except AttributeError: pass deleted; plain setattr.

M2 — new ExecutionBypassRoleTests: org admin sees unshared runs, service account sees them without any membership, both stay inside the current org, and no-org-in-context returns nothing. One correction to the finding, though: leaving the predicate to resolve for real does not work. AuthenticationController resolves auth_service from the active auth plugin, and the auth0 plugin's UserRole.ADMIN is "unstract_admin" where OSS's is "admin" — so is_admin_by_role("admin") returns False on any checkout carrying the plugin and True in OSS CI. A test depending on that is environment-dependent by construction. Patched deterministically instead (only self.admin), same idiom as ShareAuthorizationServiceTests, which pins what for_user does once the predicate is True — the role string belongs to the plugin, not to this PR.

M3 — extracted _org_scoped(); self.none() plus a warning when UserContext has no org. Confirmed all three non-test callers are request-path.

M4 — not deleting the branch. The reachability analysis is right — the OrgAwareManager join through wf_execution drops legacy-only rows before the OR is evaluated — but "delete it" turns on whether rows with wf_execution IS NULL still exist in production, which I cannot verify from here, and the branch still functions in the unscoped contexts (Celery, shell) where the org filter is absent. Replaced the stale TODO with the actual reason it matches nothing in request context.

M5 — the denial now logs user, execution, org and exists=. It sits in the shared helper, so both routes get it, and it runs only on the denial path.

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 files/test_file_executions_follow_the_same_gate_as_the_logs; for_user removed from WorkflowExecutionViewSettest_workflow_execution_list_is_scoped_to_accessible_workflows; self.none()self.all()test_no_organization_in_context_returns_nothing; conjunct removed → test_workflow_share_does_not_expose_unshared_deployment_runs.

Lows not acted on beyond the three I corrected in passing (the perform_create citation, the class docstring's ownership claim, and the missing positive control on the cross-org test).

@chandrasekharan-zipstack chandrasekharan-zipstack left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@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>
@kirtimanmishrazipstack

Copy link
Copy Markdown
Contributor Author

@chandrasekharan-zipstack Fair — done in c2e28a98 (7 files, 42 insertions / 83 deletions, comments only).

What I cut: line-by-line retellings of the mechanism, file.py:NN citations, and "this used to be X" history. Those are the parts that rot on the next refactor and that a whole-file read has to wade through.

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 Q term still exists, why the admin memo is keyed by org, and why the bypass tests patch the predicate instead of resolving it. One or two lines each.

@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown
Contributor

Unstract test results

Per-group results

Status Group Tier Passed Failed Errors Skipped Duration (s)
e2e-api-deployment e2e 3 0 0 0 20.6
e2e-coowners e2e 1 0 0 0 1.5
e2e-etl e2e 1 0 0 0 8.3
e2e-login e2e 2 0 0 0 1.1
e2e-prompt-studio e2e 1 0 0 0 4.6
e2e-smoke e2e 2 0 0 0 1.0
e2e-workflow e2e 1 0 0 0 17.9
integration-backend integration 290 0 0 26 46.4
integration-connectors integration 1 0 0 7 8.2
integration-workers integration 140 0 0 1 50.5
unit-backend unit 998 0 0 1 39.6
unit-connectors unit 63 0 0 0 9.9
unit-core unit 33 0 0 0 1.4
unit-platform-service unit 15 0 0 0 2.7
unit-rig unit 117 0 0 0 5.4
unit-sdk1 unit 480 0 0 0 26.1
unit-workers unit 1335 0 0 1 96.8
TOTAL 3483 0 0 36 342.1

Critical paths

⚠️ Critical paths not yet covered

  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (declared coverage: no groups declared)
✅ Covered critical paths
  • auth-login — covered by e2e-login
  • adapter-register-llm — covered by integration-backend
  • workflow-author — covered by integration-backend
  • co-owner-manage — covered by integration-backend, e2e-coowners
  • workflow-create-execute — covered by e2e-workflow
  • api-deployment-provision — covered by integration-backend
  • api-deployment-auth — covered by integration-backend
  • api-deployment-run — covered by e2e-api-deployment
  • mcp-server-auth — covered by integration-backend
  • mcp-platform-auth — covered by integration-backend
  • prompt-studio-author — covered by integration-backend
  • prompt-studio-fetch-response — covered by e2e-prompt-studio
  • connector-register-test — covered by integration-backend
  • pipeline-etl-execute — covered by e2e-etl
  • usage-aggregate-read — covered by integration-backend
  • usage-token-tracking — covered by e2e-api-deployment
  • callback-result-delivery — covered by e2e-api-deployment

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.

2 participants