Skip to content

UN-2868 [FIX] Make settings read-only and block deletion on resources shared with a user - #2273

Open
kirtimanmishrazipstack wants to merge 28 commits into
mainfrom
UN-2868-sharing-improvements
Open

UN-2868 [FIX] Make settings read-only and block deletion on resources shared with a user#2273
kirtimanmishrazipstack wants to merge 28 commits into
mainfrom
UN-2868-sharing-improvements

Conversation

@kirtimanmishrazipstack

@kirtimanmishrazipstack kirtimanmishrazipstack commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

What

Shared access now means different things depending on the resource:

  • Prompt Studio and Agentic Prompt Studio are shared for collaboration — prompts, Settings and LLM profiles are all editable by a shared user.
  • Everything else — workflows, ETL/Task pipelines, API deployments, adapters, connectors, Look-Ups — is shared for use: open, run, pass access on, but do not reconfigure.
  • Renaming and deleting stay with the owner everywhere. Sharing onward stays open; only the owner revokes access or shares org-wide.
  • Locked controls are greyed out with a reason on hover, not hidden.
  • Two unrelated fixes: a shared user saw a project's internal ID instead of its name, and the workflow rename dialog opened empty.

Why

Reported by a customer — a shared user could repoint another team's workflow at a different output folder. And where the server did refuse a write, the screen said nothing, so people filled in a form and lost the work.

The first attempt applied read-and-run-only everywhere. That was wrong for Prompt Studio: prompts, preamble, grammar and the LLM profile all go together on every run, so granting one and withholding the rest describes a job nobody does. Hence the split by what the resource is for.

How

Resource A shared user can Owner / co-owner only
Prompt Studio Add, edit, reorder, delete prompts. Change every Settings tab. Create, edit, delete LLM profiles and set the default. Toggle SinglePass. Run and view output. Rename, delete
Agentic Prompt Studio Change project settings (LLM, agent LLM, lightweight LLM, text-extractor). Work on documents and prompts. Run. Rename, delete
Lookup Studio View and use it inside a shared Prompt Studio project. Now reachable via group sharing, which was missing. Rename, delete
Workflows Open, run, view runs. Connector config, DB/tool/HITL settings, rename, delete
ETL / Task pipelines Open, view runs and logs. Enable toggle, cron, connector config, rename, delete
API deployments Open, view and copy existing keys. Enable toggle, add a key, a key's active toggle, rename, delete
Adapters Use it in an LLM profile or workflow. Edit, delete
Connectors Use it in a workflow. Edit, delete

Can this PR break any existing features. If yes, please list possible items. If no, please explain why.

  • Shared users lose configuration access outside Prompt Studio — connector and tool settings, HITL, status toggles, key creation. These were writes to someone else's resource; this is the ticket's product decision.
  • Owners see one change — the connector window's Save now also saves HITL rules and the separate "Save Rules" button is gone (cloud PR). Nothing writes until Save.
  • Prompt Studio is unchanged for shared users — only rename and delete moved.
  • Reading, running, sharing onward, service accounts and API keys are all untouched.

Database Migrations

  • None.

Env Config

  • None.

Relevant Docs

  • None.

Related Issues or PRs

Dependencies Versions

  • None.

Notes on Testing

Surface Shared user Owner / admin
Prompt Studio — add, edit, reorder, delete a prompt available unchanged
Prompt Studio — Settings modal, all tabs available unchanged
Prompt Studio — create, edit, delete an LLM profile; set default available unchanged
Prompt Studio — rename blocked (400) saves
Prompt Studio — delete greyed out unchanged
Prompt Studio — profile create by a non-shared org member blocked (403)
Workflow connector config — read / change 200 / blocked (403) 200 / 200
Workflow connector config, not shared at all blocked (404)
Workflow tool settings read-only notice, no Save unchanged
Rename from a resource list / builder header greyed out opens pre-filled, saves
Adapter, connector settings greyed out unchanged
ETL pipeline / API deployment status toggle greyed out unchanged
Manage Keys — New Key, per-key active toggle greyed out unchanged
Manage Keys — view and copy a key available unchanged
Delete / Share, on any resource greyed out / available unchanged
PS project name shown in a shared workflow name, not ID unchanged

Screenshots

Checklist

I have read and understood the Contribution Guidelines.

🤖 Generated with Claude Code

https://claude.ai/code/session_015aPCgGhE6Ma2NEhB8LQP1c

…and org admins

Workflow sub-resources were never gated when the sharing model landed.
tool_instance_v2 was fixed; endpoint_v2 was missed, so any user a workflow
was shared with could change its destination folder, database table or
connector.

Adds a reusable WorkflowOwnerMutationMixin next to is_workflow_mutator and
applies it to WorkflowEndpointViewSet. Sharing -- direct, group or org-wide
-- now grants read only; owners, co-owners, org admins and service accounts
may still write.

The UI now says so up front instead of failing on save: a shared user sees a
read-only notice and greyed controls in the connector modal, tool settings,
and the Prompt Studio project selector, with no Save button to press.

The connector modal's Save now also flushes the HITL plugin's rules. It
previously lit up for rule changes it could not save, then closed as if it
had saved them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ
…tead of its ID

The workflow builder resolved the project name from exportedTools, which
holds only the viewer's own exported projects. On a shared workflow the
lookup missed and fell through to the raw function name, so shared users saw
an ID where the owner saw a name.

The tool instance already carries the display name (ToolInstanceSerializer
sets it from the tool's properties), so fall back to that before falling back
to the ID. The ID remains the last resort for a tool the registry can no
longer resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ
@kirtimanmishrazipstack kirtimanmishrazipstack changed the title UN-2868 [FIX] Prevent shared users from changing a workflow's destination connector and tool settings UN-2868 [FIX] Make shared workflows read-only for shared users and show the project name instead of its ID Sep 2, 2026
@kirtimanmishrazipstack
kirtimanmishrazipstack marked this pull request as ready for review September 2, 2026 09:41
@greptile-apps

greptile-apps Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

via Greptile

RetriggerConfidence Score: 5/5

The PR appears safe to merge.

Summary

  • Adds workflow-parent mutation checks, anti-reparenting validation, and access-scoped endpoint handling.
  • Keeps shared Prompt Studio prompts, settings, and profiles collaborative while restricting rename and deletion.
  • Disables owner-only workflow, pipeline, deployment, connector, and key-management controls for shared users with read-only feedback.
  • Corrects shared workflow project-name display and workflow rename initialization.
  • Makes combined connector and HITL saves stop after endpoint failures and report a single outcome.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  Share[Shared resource access] --> Kind{Resource type}
  Kind -->|Prompt Studio| Collaborate[Collaborative settings and content edits]
  Kind -->|Workflow or operational resource| UseOnly[Read, run, and use]
  Collaborate --> OwnerOps[Rename and delete remain owner-only]
  UseOnly --> Locked[Configuration and lifecycle controls are read-only]
  Locked --> Backend[Backend mutation checks]
  Locked --> Frontend[Disabled controls with explanation]
Loading

Reviews (15) · Last reviewed commit: "UN-2868 [FIX] Drop the focusable tooltip..."

Comment thread backend/permissions/permission.py
… save

Addresses review feedback plus two defects found alongside it.

- perform_create skipped authorization entirely when the payload carried no
  workflow, then saved anyway. It now fails closed: every viewset using this
  mixin has a required workflow field, so a payload without one cannot be
  authorised.
- handleValidateAndSubmit swallowed its own error, so a failed endpoint write
  still fell through to the HITL write and "Save and Close" dismissed the
  modal. Both writes now report success and the modal stays open on failure.
- The HITL write only runs when its form is actually dirty; otherwise every
  connector save would have written a rule row too.
- A view-only user can no longer be asked whether to save unsaved changes on
  close. They cannot have made any.
- ReadOnlyNotice used defaultProps, which React 19 ignores on function
  components, so its default message never applied. Uses a default parameter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ
@kirtimanmishrazipstack

Copy link
Copy Markdown
Contributor Author

@greptile-apps Both findings fixed in 06198cc, plus three more found while tracing the second one.

Partial save / false close — valid, and the sharper of the two. handleValidateAndSubmit caught its own error and returned, so a failed endpoint write fell straight through to the HITL write and handleSaveAndClose dismissed the modal. Both writes now report success and handleSave bails on the first failure:

if (!(await handleValidateAndSubmit(formDataConfig))) {
  return false;
}

Create guard fails open — fixed, replied inline.

Found alongside these:

  • The HITL write now only runs when its form is actually dirty. Chaining it unconditionally meant every connector save also wrote a rule row.
  • A view-only user could be asked whether to save unsaved changes on close, which they cannot have made. hasUnsavedChanges() returns false when the workflow is not editable.
  • ReadOnlyNotice used defaultProps, which React 19 ignores on function components, so the default message never applied. Now a default parameter.

The companion cloud PR (#1751) also fixes the dirty flag that made this reachable: the HITL form was dirty from the moment it opened, because the baseline was either null or the server's raw rule_json compared against a builder-normalised round-trip. Sync destination and TTL are folded into that flag too, since they lost their own Save button.

Re-verified the permission matrix against real shared-workflow data after the change — owner and admin-not-owner write, shared viewer 403, non-shared 404, reads unaffected on both database and API destinations.

@greptile-apps

greptile-apps Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Thanks for the follow-up. The partial-save/false-close path and the create permission guard are addressed, along with the HITL dirty-write, view-only close prompt, and React 19 ReadOnlyNotice default issues. The companion cloud PR covers the remaining dirty-state normalization and destination/TTL tracking, and the permission matrix was re-verified across both destination types. No further concerns from me.

@kirtimanmishrazipstack

Copy link
Copy Markdown
Contributor Author

@greptile-apps review

Both findings from the previous pass are addressed in 06198cc, which landed after that review was generated.

The read-only styling suppresses pointer events but leaves fields focusable,
so a keyboard user could tab into one and press Enter. That fired a request
the backend rejects, producing exactly the confusing error this ticket is
about.

Both RJSF forms now refuse to submit when the workflow is not editable, so
the request is never made.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ
…he user

Sharing grants read only, but every resource list still offered Edit, Share,
Delete and the enable/disable toggle to the people it was shared with. The
backend refused them; the UI did not say so.

All eight shareable resources render through two shared widgets, so the row
actions are gated in one place each -- ResourceTable covers workflows, Prompt
Studio, connectors, adapters, agentic projects and lookups; CardActionBox
covers pipelines and API deployments.

The two card kebabs mix read and write actions, so those are filtered per
page: Manage Keys, Notifications and Clear File History go, while View Logs,
File History, Sync Now, Code Snippets and Download Postman stay. Running and
watching a shared pipeline is still allowed -- that is what sharing is for.

The rule itself now lives in one helper, canEditResource, which
useWorkflowCanEdit also delegates to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ
The Prompt Studio editor is served by CustomToolSerializer, which never
carried is_owner -- only the list serializer did. Without it the editor cannot
tell a shared user from an owner, so its edit controls cannot be gated.

canEditResource now treats a payload that has not arrived yet as editable.
The backend refuses the write either way, and the alternative flashes a
read-only view at the resource's own owner while the request is in flight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ
…d delete

Sharing onward is allowed for someone a resource was shared with: they may
pass access to a group they belong to, or to a user in the same organisation.
ShareAuthorizationService enforces both rules per axis, which is why the
share endpoint sits at IsOwnerOrSharedUserOrSharedToOrg rather than IsOwner.

The previous commit hid the Share button along with Edit and Delete. Only the
latter two should go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ
Settings hold the project's LLM profiles and adapter selections -- the
credential-bearing part. A shared user can read them but not change them: the
panel gets the read-only notice and its controls are inert.

Prompts are deliberately untouched. Editing, running and deleting prompts is
what a project is shared for; only the settings panel is restricted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ
The scope is settings and deletion, nothing else. The pipeline and API
deployment cards had also lost their enable/disable toggle, Manage Keys,
Notifications and Clear File History for shared users, which goes further
than intended.

Both card configs are reverted. Only the Edit and Delete controls in the two
shared list widgets stay gated; Share, the toggle and every kebab action are
available again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ
Hiding the two controls left a shared user with no idea they existed or why
they were missing. They now stay on screen, greyed out, with a tooltip
reading "Only the owner can change this". Same treatment in both list widgets
so every resource looks the same.

The rename pencil beside a project title follows the same rule: ToolNavBar
takes an editTitleDisabled prop, and Prompt Studio passes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ
@kirtimanmishrazipstack kirtimanmishrazipstack changed the title UN-2868 [FIX] Make shared workflows read-only for shared users and show the project name instead of its ID UN-2868 [FIX] Make settings read-only and block deletion on resources shared with a user Sep 3, 2026
…rules

The connector write showed "Configuration saved successfully" before the
rule write ran. A rule failure after it left a green toast on screen next
to a red one, reading as though everything had landed.

The connector success message is now suppressed when a rule write follows,
and the rule write reports the outcome for both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaTtTMaDriVZgw6BR8HZ4G
…locks to owner-only actions

Four defects found while testing the shared-user gates.

Workflow rename modal opened blank for the owner. `Form.Item` controls its
child and injects a value from the form store, so the `defaultValue` on the
Input never reached the DOM; the OK button also started disabled and only
flipped on change, so the owner had to retype both fields to save. Seed the
form via `initialValues` and derive the button's initial state from the props.

Workflow builder header offered its rename pencil to shared users. `ToolNavBar`
already takes `editTitleDisabled` and Prompt Studio and Agentic already pass
it; `MenuLayout` was the one screen that did not.

Prompt Studio settings were locked wholesale for shared users, which also
blocked adding an LLM profile of their own. The backend only refuses editing
and deleting an existing profile (`IsParentToolOwner`) -- create, copy, set
default and every other tab are open to a shared user. Move the lock onto the
two buttons that are actually refused and drop the modal-wide notice. The rows
are built in an effect, so `canEdit` joins its deps: it starts true while
`details` loads and would otherwise leave the buttons stale-enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwnxaoVTsxbzufUixPGFQ9
…hared users

Shared access is meant to grant read and run only, but Prompt Studio still
saved prompt and settings writes from a shared user, and three toggles
offered actions the backend already refuses.

Prompts. `PromptAcesssToUser` admitted viewers and group-shared users on
every method, so their edits and deletes went through; shared access is now
limited to SAFE_METHODS. `reorder_prompts` is routed without a pk, so DRF ran
no object check at all and any org member could reorder any project's
prompts -- it now resolves the prompt and checks it.

Settings. Every tab in the modal writes to the project row, so it is
owner-only again, LLM profiles included. `update`, `partial_update` and
`make_profile_default` move to `IsOwner`, which also closes project rename.
`IsParentToolOwner` gains a `has_permission`: `create` was already listed as
owner-only, but DRF never calls the object check on a create, which is why a
shared user could add an LLM profile and then not edit it.

Toggles. The ETL pipeline and API deployment enable/disable switches carried
no ownership gate even though both endpoints reject the PATCH.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwnxaoVTsxbzufUixPGFQ9
Comment thread backend/prompt_studio/permission.py Outdated
Comment thread backend/permissions/permission.py Outdated
… key controls

Product call on the ticket thread: sharing is not one uniform rule. A Prompt
Studio project is shared for collaboration -- a locked one is not useful to a
team -- while adapters, connectors, workflows and deployments are shared for
use. Across all of them the owner keeps three things: renaming, deleting, and
deciding who else gets access.

Prompt Studio therefore reopens. Prompts, every Settings tab and LLM profiles
(create, edit, delete, and the default) are back to collaborative, in Agentic
Prompt Studio too. The credential concern behind locking profiles does not
hold: a profile can only point at adapters the requester already has access
to, and it never renders a key.

Renaming a project moved to a per-field check in the serializer. Settings and
the name share one PATCH endpoint, so gating the action would have closed both.

Two checks that were missing stay in place rather than reverting with the
rest. `reorder_prompts` is routed without a pk, so DRF ran no object check and
any org member could reorder any project's prompts. And `ProfileManagerView`
now uses `ParentToolAccess`: create is collection-level, so without a
`has_permission` it was open to the whole org rather than to collaborators.

Manage Keys hid nothing behind ownership, so a shared user could press New Key
and the active toggle and get a 4xx -- the toggle surfacing as "Api deployment
not found". Both are disabled now; listing and copying keys are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwnxaoVTsxbzufUixPGFQ9
Comment thread backend/prompt_studio/permission.py
@kirtimanmishrazipstack

Copy link
Copy Markdown
Contributor Author

@greptile-apps please review again.

@kirtimanmishrazipstack

Copy link
Copy Markdown
Contributor Author

@greptile-apps Requesting a re-review. One clarification first, on the part of this PR most likely to read as an over-block.

Blocking the workflow destination settings for shared users is intentional, and it deliberately includes HITL.

The destination connector modal carries three things behind a single Save: the connector configuration (DB table, credentials), the HITL review rules and percentage, and the sync destination plus TTL. All three configure the same workflow endpoint — HITL is not a separate review surface bolted onto a settings screen, it is part of how that workflow writes its output.

So the whole modal is owner-only on a shared workflow, and the three viewsets are gated identically:

  • WorkflowEndpointViewSet (OSS) uses WorkflowOwnerMutationMixin
  • RuleEngineViewSet and SettingsViewSet (cloud manual_review_v2/views.py:2831,2980) use the same mixin

Gating the connector half and leaving the HITL half open would have been the inconsistency.

What a shared user keeps: opening the modal and reading every value, and running the workflow. What they lose: repointing someone else's pipeline at a different table, and changing what fraction of its output goes to human review.

This is intentionally different from Prompt Studio in the same PR. A Prompt Studio project is shared for collaboration, so prompts, settings and LLM profiles stay editable. A workflow is shared for use. The full per-resource split is the table in the How section of the description. On every resource, renaming, deleting and deciding who else gets access stay with the owner.

Both directions of this were flagged earlier in the review and then withdrawn once the intent was clear, so flagging it again either way is expected to be a false positive.

Shared access to a workflow granted read only everywhere except its file
history, where a shared user could delete single entries, bulk-clear by
filter, or wipe every file marker. All three paths are reachable from both
the ETL card and the workflow builder.

Gate the two FileHistoryViewSet write actions and clear_file_marker on the
workflow's owner, co-owner, org admin or service account. Listing and
retrieving history stay open to shared users. No UI change: the existing
exception handler surfaces the 403.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aPCgGhE6Ma2NEhB8LQP1c

@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 — UN-2868 (OSS half)

Verdict: BLOCK · Mode: INITIAL · rubric: unstract plugin v0.18.1 (16 lenses)

Summary — Critical: 2 · High: 11 · Medium: 12 · Low: 5 · Lenses run: 16/16

Posting Critical and High only, as requested. Medium/Low findings are held back and can be posted on request.

This PR is a real improvement on the status quo. It is blocked because the new gate ships alongside a docstring asserting an invariant the code does not hold — which is the specific failure mode that makes a permission change dangerous, since the next reader stops re-checking.

Reviewed as if this PR merges first; the cloud companion (Zipstack/unstract-cloud#1751) imports WorkflowOwnerMutationMixin and canEditResource from here. Cross-repo claims were verified against both PR trees, not against main.


Unanchored findings

These sit outside this PR's diff, so GitHub has no line to attach them to. They are included because the PR's stated contract ("backend enforces; frontend gates") depends on them.

[Critical] [Lens 4] — tool_instance create is ungated; the new Agency lock is frontend-only.
backend/tool_instance_v2/views.py:113-118 gates only update/partial_update/destroy. ToolInstanceSerializer.create (backend/tool_instance_v2/serializers.py:257-260) resolves Workflow.objects.get(pk=workflow_id) with no user scoping, and the same path flips workflow.is_active = True on that foreign workflow. Any authenticated org member can POST a tool onto a workflow they do not own. This PR disables the corresponding UI control at Agency.jsx:1166/:1176, so the lock is presentation-only here.
Pre-existing, not introduced by this PR. Fix: inherit WorkflowOwnerMutationMixin on ToolInstanceViewSet (deleting its duplicate get_permissions) and scope the workflow lookup through for_user.

[High] [Lens 4] — File-history deletion is still open to read-only shared users.
backend/workflow_manager/workflow_v2/file_history_views.py:26 uses IsWorkflowOwnerOrShared, which admits _is_resource_viewer and shared_to_org. Both destroy (:85) and clear (:99) are destructive. Separately, clear_file_marker is not in the IsOwner() action list (workflow_manager/workflow_v2/views.py:91-98), so it falls through to IsOwnerOrSharedUserOrSharedToOrg — and it is exposed as a GET (views.py:355), making it reachable by prefetch or a pasted URL with no CSRF in the way. The PR title says "block deletion on resources shared with a user"; these are the most destructive per-workflow deletion paths and they are not blocked.
Fix: gate both on is_workflow_mutator (already exported at permissions/permission.py:121), and change clear_file_marker to POST.

[High] [Lens 1] — The workflow builder's connector-type dropdown was left ungated and now fails.
frontend/src/components/agency/ds-settings-card/DsSettingsCard.jsx:238-251 is disabled={!allowChangeEndpoint} only — never canEdit. It fires handleEndpointUpdate straight at the endpoint this PR newly gates, so a shared user picks a connector type and gets a bare "Failed to update" toast. The ConfigureConnectorModal behind it was gated; its launcher was not. useWorkflowCanEdit is imported in four files but not this one.

[High] [Lens 13] — Zero test files across 24 changed files, on a diff that is almost entirely authorization logic.
ParentToolAccess widens ProfileManager writes to shared viewers, WorkflowOwnerMutationMixin adds a new collection-level create gate, and rename enforcement moved from a permission class into serializer field validation. None of it is exercised. backend/CLAUDE.md states tests are required after implementation, and the harness already exists at backend/permissions/tests/test_owner_management.py. Highest-value cases: shared viewer denied on endpoint update/destroy; shared viewer allowed on ProfileManager create/update/destroy; shared viewer denied on a tool_name PATCH but allowed on any other settings field.


Generated by the standardized 16-lens review. Verified findings only — 11 agent claims were dropped as false after checking both PR trees.

Comment on lines +15 to +22
class WorkflowEndpointViewSet(WorkflowOwnerMutationMixin, viewsets.ModelViewSet):
"""Workflow source / destination endpoints.

Config here selects the connector and its settings -- the destination
folder for filesystem, the table for database. Shared users may read
it; only owners and org admins may change it.
"""

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.

[Critical] [Lens 4 — Security]workflow_endpoint_list has no permission class and never calls get_object(); any org member reads any workflow's connector credentials.

This new docstring states "Shared users may read it", which reads as a class-wide invariant. It holds for update/partial_update/destroy and not for the list action.

workflow_endpoint_list (views.py:88-101, routed at workflow_manager/urls.py:14) calls WorkflowEndpointUtils.get_endpoints_for_workflow(pk) directly, bypassing both get_queryset() — the only access filter on this viewset — and object permissions. WorkflowHelper.get_workflow_by_id is a bare Workflow.objects.get(pk=id) with no scoping, and DEFAULT_PERMISSION_CLASSES is [] (backend/backend/settings/base.py:640), so get_permissions returning list(super().get_permissions()) for this action yields nothing.

The response serializes through ConnectorInstanceSerializer, whose to_representation pops only CONNECTOR_AUTH and blanks connector_metadata only for UnstractCloudStorage (connector_v2/serializers.py:162-176). S3/GCS/DB connector_metadata is returned decrypted to a user with no relationship to the workflow.

Failure mode: GET /api/v1/unstract/<org>/workflow/<any-uuid>/endpoint/ — horizontal privilege escalation inside an org. No error is raised or logged, because the authorization that should have refused is simply never evaluated.

Pre-existing, but this PR is what adds the docstring asserting the opposite.

Fix:

workflow = get_object_or_404(Workflow.objects.for_user(request.user), pk=pk)
endpoints = WorkflowEndpointUtils.get_endpoints_for_workflow(workflow.id)

Confidence: High (verified against the PR tree).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@chandrasekharan-zipstack Fixed in 44a06b9, with a follow-up in 28cfde6 — and one half deliberately left open for your call.

workflow_endpoint_list now resolves through Workflow.objects.for_user() before serialising, so an unrelated org member gets a 404 instead of the workflow's connector credentials.

Chasing it turned up two more paths to the same credentials:

  • A second route to them. WorkflowEndpointSerializer.connector_instance_id is a writable PrimaryKeyRelatedField whose queryset was ConnectorInstance.objects.all() — org-scoped, not user-scoped, unlike every sibling. Any member could create their own workflow (becoming its owner, so IsParentWorkflowOwner passes), PATCH their endpoint onto a colleague's connector, and read the decrypted metadata out of the PATCH response. Scoped with for_user in 28cfde6.
  • WorkflowEndpointAPIView logged dict(request.headers) at INFO — including the internal service bearer key that authenticates it, on the view that returns decrypted connector metadata for any workflow in any organization. No redaction filter exists anywhere. Dropped the headers entry and lowered the line to debug.

Still open — your decision. Scoping bounds who reaches the workflow, not what the response contains. A user the workflow is shared with (directly, by group, or org-wide) still receives the owner's decrypted S3/GCS/Postgres credentials, because the nested ConnectorInstanceSerializer decrypts everything except UnstractCloudStorage. Redacting it changes what the read-only connector view shows shared users, so I didn't decide that alone. I did narrow the two comments that implied the exposure was closed.

Comment on lines +167 to +171

def get_permissions(self) -> list[Any]:
if self.action in ("update", "partial_update", "destroy"):
return [IsParentWorkflowOwner()]
return list(super().get_permissions())

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.

[High] [Lens 4 — Security] — The gate validates the current parent, never the target parent, and workflow is writable.

IsParentWorkflowOwner.has_object_permission resolves ownership from obj.workflow — the parent the row has now. WorkflowEndpointSerializer is fields = "__all__" with only workflow_name marked read-only, so workflow itself is a writable FK.

Failure mode: the owner of workflow A sends PATCH /workflow/endpoint/<A-endpoint>/ with {"workflow": "<B-id>"}. The object check passes (they do own A), then the save reassigns the row to B, owned by a different user in the same org. B's owner now executes against A's connector instance, bucket, or table.

That is the customer-reported bug — a shared user repointing another team's workflow at a different output folder — still reachable after the fix that targets it. The same shape applies to the cloud companion: RuleEngineSerializer.read_only_fields is ["id", "created_by", "modified_by"] and SettingsSerializer is fields = "__all__", so both HITL viewsets inherit it.

Fix: mark workflow read-only on WorkflowEndpointSerializer (and on the two cloud HITL serializers), or validate in the mixin that validated_data.get("workflow") equals instance.workflow on update.

Two notes in this class's favour, verified while checking: perform_create correctly fails closed on a missing workflow, and the MRO order is right at every current call site. The base-order fragility (a bare mixin overriding get_permissions/perform_create, which DRF also defines) is a trap for the next viewset that copies it, not a live bug — worth an __init_subclass__ assertion given this is now a cross-repo contract.

Confidence: High on the field config (verified); the reassignment PATCH was not executed against a live DB.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@chandrasekharan-zipstack Half of this held and half didn't — and the half that held is now closed at eight sites, not one.

The OSS half is disconfirmed. WorkflowEndpointSerializer.workflow is not writable: WorkflowEndpoint.workflow is editable=False (models.py:34), which DRF honours by forcing read_only=True. I checked by building the serializer rather than reading Meta — the only writable fields are configuration, connection_type and connector_instance_id. So the reparenting PATCH you describe can't land there. You'd flagged your own uncertainty on exactly this ("the reassignment PATCH was not executed against a live DB"); the field-config half is the part that was off.

The cloud half was live and is fixed (1d9c4f6a). Both RuleEngineSerializer.workflow and SettingsSerializer.workflow built read_only=False. One correction worth recording: only SettingsSerializer was actually exploitable — RuleEngineSerializer has a custom update() that assigns only percentage/rule_string/rule_json/rule_logic and never touches workflow. The guard there is defence-in-depth, not a closed hole, and I've said so in the commit so nobody removes it later believing it was load-bearing.

The class is wider than either of us listed. Sweeping by gate rather than by field found eight sites: RuleEngine, HITLSettings, ToolInstance, ProfileManager, APIKey (api and pipeline), ToolStudioPrompt, and WorkflowEndpoint — the last safe only by that editable=False. All closed across 1d9c4f6a / 28cfde6 / ac31510 / c67c55c.

Two of those are worth your attention:

  • APIKeySerializer.api/.pipeline were writable on PATCH. create spends ~60 lines refusing a path-vs-body mismatch; update had nothing, so a live key could be retargeted onto a deployment the caller doesn't own.
  • The eighth site was an alias of a field I'd already guarded in the same file. ToolInstance.workflow's attname is workflow_id, and the serializer declares a writable workflow_id alias. validate_workflow fires only on the key workflow; a body of {"workflow_id": ...} skipped it entirely and DRF's setattr(instance, "workflow_id", uuid) wrote the FK column directly.

That last one is the argument for your __init_subclass__ suggestion, or better: hoisting the check into WorkflowOwnerMutationMixin so it covers attname aliases. Enumerating this class by field name provably cannot close it. I did not do that refactor — it touches eight viewsets across both repos and I didn't want it unreviewed at the end of an automated run. Recommended in the commit message.

Comment on lines +85 to +101
<Tooltip
title={
editTitleDisabled
? "Only the owner can change this"
: undefined
}
>
<Button
type="text"
size="small"
icon={<Pencil />}
className="tool-nav-bar__edit-icon"
onClick={onEditTitle}
disabled={editTitleDisabled}
aria-label="Edit title"
/>
</Tooltip>

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.

[High] [Lens 1 — Spec & intent] — Every "hover for the reason" tooltip added on a disabled control never renders.

The PR's stated UX is "locked controls greyed out with a reason on hover, not hidden." The shim Tooltip renders <TooltipTrigger asChild> (Radix) and depends on pointer/focus events reaching the child. The shim Button carries disabled:pointer-events-none — asserted by this repo's own test at frontend/src/components/ui/cascade-and-affordances.test.jsx:194-199 — and a native disabled button or switch swallows mouseenter. The tooltip content never mounts.

Affected, all added by this PR:

  • ToolNavBar.jsx:85-101 (this hunk)
  • CardFieldComponents.jsx:73-81 and :113-121
  • ManageKeys.jsx:264-272 and :309-317
  • ApiDeploymentCardConfig.jsx:99-113
  • PipelineCardConfig.jsx:327-341

User impact: 6 of the 8 new lock affordances communicate nothing — a dead grey control with no explanation, which is the "the screen said nothing" outcome this PR exists to eliminate. ResourceTable.jsx:302-315 works only because it uses aria-disabled on a raw <button> rather than disabled, so the same feature behaves two different ways.

The codebase already documents this exact trap and its fix at frontend/src/components/logging/log-modal/LogModal.jsx:262-267:

"span wrapper — antd disabled Buttons swallow pointer events so a direct Tooltip child never receives mouseenter and the disabled-state tooltip never shows."

Fix: wrap each disabled child in a <span> inside the Tooltip, or fix it once in the shim — when title is set and the child has disabled, render the trigger wrapped.

Confidence: High (mechanism confirmed by the repo's own test and precedent comment; UI not run).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@chandrasekharan-zipstack Fixed in d592324, then corrected in 81f2cea — your diagnosis was right and my first fix was incomplete.

Fixed once in the shim rather than at six call sites: Tooltip now wraps a disabled child so pointer events land on the wrapper.

The correction matters. A span alone does not restore hover — the browser suppresses mouse events on a disabled control and doesn't bubble them, so the span only becomes the hit target once the child stops hit-testing. ui/button.tsx sets disabled:pointer-events-none and worked; switch, checkbox, input, label, radio-group, select and textarea set only cursor-not-allowed and did not. That includes the live case this PR is about — the owner-only Switch in ManageKeys.jsx:264. So my first pass silently helped only the Buttons. The wrapper now neutralises the child itself, and carries tabIndex so the tooltip is reachable by keyboard at all, which a disabled child never is.

One correction to the finding: ResourceTable's own action buttons use aria-disabled, not disabled, and aria-disabled elements receive pointer events normally — so those two tooltips already worked. The beneficiaries are the Button/Switch call sites.

Comment on lines +30 to +31
# A profile is part of the project's design, so anyone the project is
# shared with manages it (UN-2868); reads honor sharing.

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.

[High] [Lens 13 — Testing / Lens 4 — Security] — This swap leaves a test asserting the inverse of production, still passing.

Switching to ParentToolAccess widens ProfileManager create/destroy/partial_update/update from owner-only to any VIEWER-role member (prompt_studio/permission.py:24, via _can_access_tool). That is the intended collaborative rule for Prompt Studio — no objection to the behaviour.

The problem is what it leaves behind. After this diff IsParentToolOwner has zero production callers (grep -rn IsParentToolOwner backend --include='*.py' returns only its definition, one test, and two docstrings), but backend/permissions/tests/test_owner_management.py:326-369 still constructs IsParentToolOwner() directly and asserts assertFalse(self._perm(self.viewer, child)) at :359-362.

Failure mode: CI reports green on ProfileManager mutation authz while the shipped endpoint returns 200 for exactly that user. The live gate, ParentToolAccess, is untested in both directions — so any future re-tightening or further loosening is invisible to the suite. A dead authz class that still has a passing test suite is also a trap for the next person who imports it by name; prompt_studio/permission.py:86 still points readers at it as the pattern to mirror.

Fix: retarget IsParentToolOwnerTests at ParentToolAccess (flip viewer to assertTrue, keep outsider assertFalse, add a service-account case) and delete IsParentToolOwner.

Confidence: High.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@chandrasekharan-zipstack Not fixed — blocked by a project rule, and I'd like your explicit go.

The finding is correct: after this diff IsParentToolOwner has zero production callers, while test_owner_management.py:326-369 still constructs it directly and asserts assertFalse(self._perm(self.viewer, child)). CI is green on ProfileManager mutation authz while the shipped endpoint returns 200 for exactly that user, and the live gate ParentToolAccess is untested in both directions.

It can't be half-done: deleting the dead class breaks the test that still imports it, so the fix necessarily edits a test. CLAUDE.md says "Never write, modify, or suggest tests unless the user explicitly uses the word 'test.'" — so I stopped rather than work around it.

Say the word and it's one pass: retarget IsParentToolOwnerTests at ParentToolAccess (viewer flips to assertTrue, outsider stays assertFalse, add a service-account case) and delete the class.

Same blocker applies to test coverage for the four behaviour changes in this batch — the refusal paths (404 on a non-member endpoint list, the reparent refusals, 403-not-400 on rename, the delete-button state) have no assertions anywhere.

Comment on lines +135 to +147
value = validate_name_field(value, field_name="Tool name")
# Settings and the project's name share this endpoint, and settings are
# collaborative -- so the rename is gated here rather than on the view
# (UN-2868).
request = self.context.get("request")
if not self.instance or not request or value == self.instance.tool_name:
return value
user = request.user
if getattr(user, "is_service_account", False):
return value
if not self.instance.is_owner(user) and not is_org_admin(user):
raise ValidationError("Only the owner can rename this project.")
return value

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.

[High] [Lens 7 — API contract / Lens 4 — Security] — An authorization decision expressed as field validation: wrong status, wrong shape, and bypassable.

raise ValidationError("Only the owner can rename this project.") renders as 400 {"tool_name": ["Only the owner can rename this project."]}. The sibling decision for the same resource — delete — goes through IsOwner (views.py:160) and returns 403. Same actor, same resource, same predicate, two status codes and two response shapes.

Three consequences:

  1. Clients cannot distinguish "that name is invalid" from "you are not allowed." Any form-error handler paints an authz refusal under the name input as though retyping would fix it.
  2. The rule is invisible to permission introspectionview.get_permissions() reports rename as ungated, so the browsable API, schema generation, and any permission-walking test all read it as open.
  3. The guard is conditional on payload shape. DRF runs a field validator only when the field is present and only through this serializer. Any sibling/import serializer that writes tool_name, a future .save(tool_name=…) helper, or a refactor folding this into validate_name_field silently drops the check.

Also worth deciding deliberately: this serializer is fields = "__all__", and the gate covers exactly one field. description, icon and author remain writable by any shared user, which the comment at views.py:155-156 does not acknowledge.

Fix: raise rest_framework.exceptions.PermissionDenied(...) — same placement, correct status, and it reads as authorization to anyone scanning the file. The cloud companion has the identical issue at agentic_studio_v1/serializers.py:188.

Confidence: High.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@chandrasekharan-zipstack Fixed in 44a06b9, both repos.

PermissionDenied instead of ValidationError, so the rename refusal returns 403 with the same shape as its sibling destroy. Verified against the installed DRF 3.17.1 and drf-standardized-errors 0.15.0 rather than from memory: to_internal_value catches only ValidationError/DjangoValidationError, so PermissionDenied propagates out of is_valid() and renders 403 — and middleware/exception.py's only rewrite is gated on 404, so it passes through unmodified.

Your point 1 was the concrete one: AddCustomToolFormModal.jsx:132 does getBackendErrorDetail("tool_name", backendErrors), so the authz refusal was being painted under the name input as though retyping would fix it. That's gone. Checked the blast radius too — the axios interceptor logs out on 401 only, useExceptionHandler has a client_error branch that surfaces errors[0].detail, and the rename submit passes no setBackendErrors, so nothing depended on the old attr: "tool_name" binding.

Cloud twin at agentic_studio_v1/serializers.py fixed the same way in 1d9c4f6a.

Your closing note — that the gate covers one field while description, icon and author stay writable by any shared user — is untouched and still stands as a deliberate decision to make.

Comment on lines +14 to +18
// while the request is in flight.
if (!resource || resource.is_owner === undefined) {
return true;
}
return Boolean(resource.is_owner || sessionDetails?.isAdmin);

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.

[High] [Lens 3 — Correctness]canEditResource fails open, and cannot distinguish "still loading" from "this serializer does not emit the field".

is_owner === undefined → true. The comment justifies this as the in-flight-request window, but the predicate has no way to tell that apart from a payload that simply lacks the key.

Two concrete exposures:

  • APIDeploymentSerializer — the non-list class returned for retrieve/create/update (api_v2/api_deployment_views.py:305-308) — does not carry is_owner. Any caller that seeds a store or table row from a non-list response unlocks every Edit/Delete/toggle on it.
  • Store defaults are details: {} (store/workflow-store.js:12, store/custom-tool-store.js:17), which also takes the fail-open branch.

Worse, the two ends of this contract fail in opposite directions. Every producer emits is_owner: false when self.context.get("request") is missing (prompt_studio_core_v2/serializers.py:123, workflow_v2/serializers.py:81), so a serializer constructed without context silently demotes the resource's owner to read-only — and there are already two such call sites in tree (workflow_v2/views.py:347, endpoint_v2/views.py:101). One optional boolean is carrying three states (absent / false-because-no-context / false-because-genuinely-not-owner) with two collapsed and the third inverted at the boundary.

Failure mode: a serializer regression that drops is_owner produces no error, no warning, and no visible symptom — the UI just stops enforcing, silently restoring the pre-PR behaviour this change exists to prevent.

Fix: take an explicit loading argument rather than overloading undefined, fail closed on a fetched resource with no is_owner, and warn once so the gap is visible. On the producer side, either omit the key entirely when there is no request (so absent uniformly means unknown) or assert — a serializer emitting an authorization flag with no requester is always a bug.

Confidence: High that the branch exists; Medium that a live call site reaches it today.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@chandrasekharan-zipstack Partly fixed in 28cfde6. The concrete exposure is closed; the contract itself is not, and that's deliberate.

Closed: APIDeploymentSerializer — the class returned for retrieve/create/update — now carries is_owner, so a consumer seeding a store from a non-list response no longer takes the fail-open branch.

Not changed: is_owner === undefined → true itself. Your fix (an explicit loading argument, fail closed on a fetched resource, warn once) is the right shape, but it inverts the default for eight call sites and both store defaults — details: {} in workflow-store.js and custom-tool-store.js both currently take the fail-open branch. Flipping that at the end of an automated run, with no test coverage on any of those call sites, is how an owner ends up locked out of their own resource. It wants doing deliberately with the tests to back it.

Your observation that the two ends fail in opposite directions is the part I'd most want preserved in whatever we do: producers emit is_owner: false when self.context.get("request") is missing, so a serializer built without context demotes the resource's own owner — and there are live no-context call sites. One optional boolean carrying three states, two collapsed and the third inverted at the boundary.

Comment on lines +292 to +297
// Sharing grants read only: no edit, no delete. Both controls stay on
// screen but disabled, so it is obvious they exist and why they are not
// available. Sharing onward stays open to shared users.
const canEdit = canEditResource(item, sessionDetails);
const locked = !canEdit;
const lockedTitle = "Only the owner can change this";

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.

[High] [Lens 3 — Correctness] — Frictionless adapters become undeletable, and the tooltip gives a reason that is false for them.

The new gate is canEditResource(item, sessionDetails) — i.e. is_owner || isAdmin. But the backend deliberately lets any org member delete a frictionless adapter: AdapterInstanceViewSet.get_permissions routes destroy to IsFrictionLessAdapterDelete, which returns True unconditionally when obj.is_friction_less (adapter_processor_v2/views.py:171-172, permissions/permission.py:304-312).

Those adapters are visible to every member (adapter_processor_v2/models.py:54-59, Q(is_friction_less=True)) and their owner is masked as "Unstract" in the payload (adapter_processor_v2/serializers.py:194-196), so is_owner is False for essentially everyone. AdapterListSerializer does not expose is_friction_less at all (serializers.py:165-176), so the frontend cannot special-case these rows even if it wanted to.

User impact: the only UI route to remove platform-provisioned onboarding adapters disappears for every non-admin, and the tooltip asserts an ownership rule that does not govern the row.

Fix: add is_friction_less to AdapterListSerializer.Meta.fields and gate delete on canEdit || item?.is_friction_less for adapters. Keep Edit disabled — IsFrictionLessAdapter correctly refuses update/retrieve on those rows for everyone.

Confidence: High.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@chandrasekharan-zipstack Fixed in 44a06b9, exactly as you specified.

is_friction_less added to AdapterListSerializer.Meta.fields, and the table's delete gate is now locked && !item?.is_friction_less. Edit stays locked — IsFrictionLessAdapter correctly refuses update/retrieve on those rows for everyone.

Checked the leak question before adding the field: AdapterListSerializer carries no adapter_metadata, and its to_representation already masks created_by_email/owner_emails to "Unstract" for frictionless rows, so the flag was already inferable from the response.

Also traced the four cases so the carve-out can't accidentally unlock anything else: is_owner absent → unchanged; is_owner:false on a non-adapter row (is_friction_less undefined) → still locked; ordinary adapter → still locked; frictionless → unlocked. Workflows, ListOfTools and ConnectorsPage rows are unaffected.

One thing I'd flag back: a later review pass argued this puts an adapter-specific field inside a table that also backs Workflows, Prompt Studio and Tool Settings, and that a canDelete predicate passed by the adapters page would keep the knowledge where it belongs. I left it as you wrote it — worth a view if you disagree.

Comment thread backend/prompt_studio/permission.py Outdated
Comment on lines +17 to +20
Prompt Studio is shared for collaboration (UN-2868): a shared user edits
the project's prompts and settings, the same as its owner. Only the
project's name, its existence and who else it is shared with stay with
the owner, and those are gated on the project viewset.

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.

[High] [Lens 16 — Doc accuracy] — This docstring states an owner-only rule the code does not enforce, on the sharing surface.

The comment asserts that "who it is shared with" stays with the owner. It does not. The share action is not in the IsOwner() list in prompt_studio_core_v2/views.py:159 (which gates only destroy, add_co_owner, remove_co_owner), so it falls through to IsOwnerOrSharedUserOrSharedToOrg, and ShareAuthorizationService explicitly permits non-owners to add — "direct shared users and group members can add (with scope limits) but not remove" (tenant_account_v2/sharing_helpers.py:381-389).

This PR ships both statements of the rule. The frontend comments added in the same diff say the opposite and are correct: CardFieldComponents.jsx:82-83 ("Sharing stays open to shared users: they may pass access on to a group they belong to, or to a user in the same organisation") and ResourceTable.jsx:291.

A maintainer auditing the share surface from this docstring will believe an owner-only gate exists where none does — the over-grant direction.

Fix: reword to match ShareAuthorizationService — only the project's existence (destroy) and co-ownership are owner-only; shared users may add viewers/groups but not remove them.

Two related accuracy issues in this file, same lens:

  • :19-20 says the rename is "gated on the project viewset". It is not — this PR deliberately put it in the serializer (prompt_studio_core_v2/serializers.py:136-138 says so explicitly). Someone verifying "is rename protected?" by reading the viewset finds nothing.
  • :62 says # Imported here: the models pull in this module at import time. No model imports prompt_studio.permission — only three view modules and one test do. The stated reason for the non-obvious deferred import is false, so the next engineer either hoists it on a bad premise or preserves a workaround nobody can justify.

Confidence: High.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@chandrasekharan-zipstack All three fixed in 0c9fb44.

  • The sharing claim is reworded to match ShareAuthorizationService: renaming, deleting and removing access stay with the owner; sharing onward does not.
  • The rename line now points at CustomToolSerializer.validate_tool_name rather than claiming the viewset gates it.
  • The deferred-import comment is deleted rather than reworded. I first replaced it with "importing these at module scope creates a cycle", then tested that claim before committing — there is no cycle. prompt_studio_core_v2.models imports cleanly without prompt_studio.permission, and the reverse order works too. Rather than ship a second unverified reason in place of the first, I removed the justification and left the local import alone.

Worth knowing: a later review pass argued that comment was load-bearing and should be restored. I kept it deleted, because the assertion it made is measurably false and I'd rather have no reason than a wrong one. If you know the real reason the import is deferred, that's worth a line.

Comment on lines +155 to +158
# Settings are collaborative (UN-2868); only the project's existence
# and who it is shared with stay with the owner. Renaming is blocked
# per-field in the serializer, since it shares an endpoint with
# every settings write.

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.

[High] [Lens 4 — Security] — Org-wide-shared projects get a split rule this comment does not describe, and the frontend cannot represent it.

_can_access_tool (prompt_studio/permission.py:14-29) admits owner / direct viewer / group member / org admin — but not shared_to_org. IsOwnerOrSharedUserOrSharedToOrg, used here for update/partial_update and for profile reads at prompt_profile_manager_v2/views.py:34, does admit it.

Failure mode: a user whose only access is an org-wide share can PATCH the project's settings and read its profiles, but gets 403 on every prompt edit (PromptAcesssToUser, permission.py:41-44) and every profile write. Half the collaborative surface works and half refuses, for the same user on the same project.

The frontend cannot express that state: usePromptStudioCanEdit collapses to a single is_owner boolean, so this user is shown one consistent affordance that is wrong for half the actions.

The class docstring at prompt_studio/permission.py:17-18 states the opposite of the code — "a shared user edits the project's prompts and settings, the same as its owner."

Fix: decide whether shared_to_org is inside or outside the collaborative set and apply it to both sides. Then pin it: (direct viewer, group, org-wide, unrelated member) × (settings PATCH, prompt PATCH, profile POST) is nine cheap API assertions that would have caught this without hand testing.

Confidence: High.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@chandrasekharan-zipstack Fixed in d592324 — decided in, not out.

_can_access_tool now admits shared_to_org, so both sides of the collaborative surface agree. An org-wide-shared user gets the same answer from _can_access_tool and IsOwnerOrSharedUserOrSharedToOrg instead of 200 on settings and 403 on every prompt edit.

Reasoning for that direction rather than the other: the PR's stated rule is that Prompt Studio is shared for collaboration, and IsOwnerOrSharedUserOrSharedToOrg — already used for settings PATCH and profile reads on the same project — admits it. Narrowing that side instead would have taken settings access away from a group who have it today.

Verified both directions: an org-shared user with no membership, no group and no admin role is now admitted; an unrelated user on a non-shared tool is still refused.

The class docstring that stated the opposite is corrected in 0c9fb44. Your nine-assertion suggestion — (direct viewer, group, org-wide, unrelated) × (settings PATCH, prompt PATCH, profile POST) — is exactly the right pin and is blocked only by the CLAUDE.md test rule; see my reply on the IsParentToolOwner thread.

…ock frictionless deletes

F1 (Critical): workflow_endpoint_list took the workflow id straight from the
URL, bypassing get_queryset() and object permissions -- and the response
serializes connector_metadata decrypted for S3/GCS/DB. Any org member could
read any workflow's connector credentials. Resolve the workflow through
Workflow.objects.for_user() first.

F5: the Prompt Studio rename refusal raised ValidationError, rendering as
400 {"tool_name": [...]} and painted under the name input by
AddCustomToolFormModal, while the sibling delete refusal returns 403. Raise
PermissionDenied so an authorization refusal reads as one.

F7: frictionless adapters are deletable by any org member server-side
(IsFrictionLessAdapterDelete), but the table gated delete on ownership and
AdapterListSerializer never exposed is_friction_less, so the only route to
remove a platform-provisioned adapter disappeared for non-admins. Expose the
field and exclude those rows from the delete lock. Edit stays locked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aPCgGhE6Ma2NEhB8LQP1c
Each stated a rule the code does not enforce, on the sharing surface:
sharing onward is open to shared users, not owner-only; the rename is gated
in CustomToolSerializer.validate_tool_name, not on the viewset; and the
deferred import's stated reason was false -- no model imports this module,
and hoisting it creates no cycle (verified both directions). The comment is
removed rather than given a new reason I could not confirm.

Also records what the gate actually admits: direct viewers, groups and
admins, but not shared_to_org -- which IsOwnerOrSharedUserOrSharedToOrg does
admit. That divergence is finding F9 and is not fixed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aPCgGhE6Ma2NEhB8LQP1c
…control tooltips render

F9: _can_access_tool admitted owner, direct viewer, group member and admin but
not shared_to_org, while IsOwnerOrSharedUserOrSharedToOrg -- used for the same
project's settings PATCH and profile reads -- does admit it. A user whose only
access was an org-wide share could change project settings and read profiles
but got 403 on every prompt edit and profile write: half the collaborative
surface working, half refusing, for one user on one project. The frontend
cannot express that state, since usePromptStudioCanEdit collapses to a single
boolean. Admit shared_to_org so both sides agree. Verified both directions.

F3: every "hover for the reason" tooltip added by this PR sat on a `disabled`
control, which swallows mouseenter, so the trigger never fired and the reason
never rendered -- 6 of the 8 new lock affordances communicated nothing. Fixed
once in the Tooltip shim rather than at each call site: a disabled child is
wrapped so the pointer events land on the wrapper. Not pinned by a test; the
existing cascade-and-affordances suite asserts the pointer-events-none
behaviour this works around, and 63 shim tests still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aPCgGhE6Ma2NEhB8LQP1c
…ix left open

Findings raised by adversarial verification of the previous commits.

NEW-1 (Critical): WorkflowEndpointSerializer.connector_instance_id is a
writable PrimaryKeyRelatedField whose queryset was ConnectorInstance.objects
.all() -- org-scoped but not user-scoped, unlike every sibling. Any org member
could create their own workflow (becoming its owner, so IsParentWorkflowOwner
passes), PATCH their endpoint to a colleague's connector, and read that
connector's decrypted credentials out of the PATCH response, which
re-serialises through the nested ConnectorInstanceSerializer. This is the
surviving half of the exposure the endpoint-list fix closed. Scope the
queryset with for_user; no request in context now yields none() rather than
everything, and the only instantiation without context is read-only.

NEW-3 (Critical): WorkflowEndpointAPIView logged dict(request.headers) at INFO
-- including the internal service bearer key that authenticates it -- on the
view that returns decrypted connector metadata for any workflow in any
organization. No redaction filter exists. Drop the headers entry and lower the
line to debug. Pre-existing and outside this PR's surface; fixed here because
a logged credential that unlocks that view should not wait.

NEW-2 (High): the same stored-parent-gate-plus-writable-parent-FK shape is
open at ToolInstanceSerializer.workflow and
ProfileManagerSerializer.prompt_studio_tool. Refuse a parent change on update
at both. NOTE: with the cloud sites this is now five occurrences of one rule
patched site by site. The rule -- a gate that authorises against a stored
parent requires that parent to be non-writable -- is stated nowhere and
enforced nowhere central, so the next sub-resource added under
WorkflowOwnerMutationMixin will repeat it. Recommend hoisting the check into
the mixin; not done here, to avoid a broad refactor late in the run.

F6 (High): APIDeploymentSerializer -- the class returned for retrieve, create
and update -- did not carry is_owner, while canEditResource treats a missing
is_owner as editable. Any consumer seeding state from a non-list response
unlocked every Edit/Delete/toggle on it. Carry the field on both serializers.

NEW-6/NEW-7 (Low), both regressions in the previous endpoint-list fix: restore
the specific WorkflowDoesNotExistError 404 that get_object_or_404 had
flattened to "Not found.", and stop re-resolving the workflow row a second
time inside get_endpoints_for_workflow.

NEW-5 (Low): canEditResource's docstring claimed delete follows ownership,
which the frictionless-adapter carve-out makes false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aPCgGhE6Ma2NEhB8LQP1c
…-list fix

Two further sites of the stored-parent-gate class, both verified writable
before fixing:
- APIKeySerializer.api / .pipeline. IsParentDeploymentOwner gates every detail
  action against the stored parent, and create spends ~60 lines refusing a
  path-vs-body disagreement -- while update had nothing, so a live key could be
  retargeted onto a deployment the caller does not own.
- ToolStudioPromptSerializer.tool_id, gated by PromptAcesssToUser on obj.tool_id.

That makes seven occurrences of one rule now patched site by site: a gate that
authorises against a stored parent requires that parent to be non-writable.
Recommend hoisting it into a shared mixin beside WorkflowOwnerMutationMixin --
deliberately NOT done here, since it touches seven viewsets across two repos.

Cleanup from the review pass:
- workflow_endpoint_list reuses get_queryset() and get_serializer() instead of
  rebuilding the scoping by hand. That restores select_related and injects
  request context, which the serializer's new for_user scoping reads.
- get_queryset also joins connector_instance; the nested serializer needs it.
- Removed WorkflowEndpointUtils.get_endpoints_for_workflow, which inlining had
  left with zero callers -- a public helper carrying exactly the unscoped
  lookup this change removed.
- Dropped a comment claiming this raises "the same 404 the sibling actions do".
  It does not: the siblings raise DRF NotFound via get_object().
- Trimmed comments that retold mechanism or narrated an exploit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aPCgGhE6Ma2NEhB8LQP1c
…nd keep orphan profiles attachable

The span wrapper alone does not restore hover. A browser suppresses mouse
events on a disabled control and does not bubble them, so the span only
becomes the hit target once the child stops hit-testing. ui/button.tsx sets
disabled:pointer-events-none and worked; switch, checkbox, input, label,
radio-group, select and textarea set only cursor-not-allowed and did not --
which includes the live case this PR is about, the owner-only Switch in
ManageKeys. Neutralise the child from the wrapper instead of relying on the
child's own class, and give the wrapper tabIndex so the tooltip is reachable
by keyboard, which a disabled child never is.

Separately: ProfileManager.prompt_studio_tool is nullable and ParentToolAccess
has an explicit orphan branch, so refusing every value on an orphan left such
a row permanently unattachable. Only refuse a change away from an existing
parent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aPCgGhE6Ma2NEhB8LQP1c
…renting check

Two independent reviewers found the same hole in the guard added two commits
ago, and it is the sharpest argument yet for fixing this class structurally.

ToolInstanceSerializer declares workflow_id = UUIDField(write_only=True), and
workflow_id is also the attname of ToolInstance.workflow. validate_workflow
fires only when the payload key is "workflow"; a body of {"workflow_id": ...}
skips it entirely, and DRF's ModelSerializer.update does
setattr(instance, "workflow_id", uuid), writing the FK column directly.
IsParentWorkflowOwner has already passed, because it authorises against the
STORED parent. So the guarded key is the one no caller uses and the required
one was unguarded. Verified: the model's attname is workflow_id and the
serializer field is writable.

This is the eighth site of one rule, and the miss was an ALIAS of a field
already guarded in the same file -- enumerating the class by field name cannot
find it, only enumerating by gate can. The recommendation to hoist this into
WorkflowOwnerMutationMixin stands and is now stronger.

Also: reverted the orphan-profile relaxation from the previous commit. Letting
a NULL stored parent be attached looked like a usability fix, but
ParentToolAccess falls back to created_by for orphans, so it would let that
creator attach the profile to any project in the org. Refusing is the safer
half of the disagreement between the two reviewers.

Narrowed two comments that over-claimed. Scoping the write field and the list
action does NOT redact connector_metadata; the nested read serializer still
returns it decrypted to anyone the workflow is shared with. That residual is
escalated, not fixed here -- redacting it would change what the read-only
connector view shows shared users, which is a product decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aPCgGhE6Ma2NEhB8LQP1c
Sonar raised "tabIndex should only be declared on interactive elements"
(MAJOR) on the span added for disabled-control tooltips, and it is right: a
focusable span with no role and no accessible name is a stop in the tab order
that announces nothing, which is worse for a screen reader than no stop.

The substantive half stays -- neutralising the child's hit-testing is what
makes the tooltip open at all on a disabled Switch, Checkbox or Input, which
is the reported defect. Keyboard access to a disabled control's tooltip needs
a role and a name to be worth anything and is a larger change than this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aPCgGhE6Ma2NEhB8LQP1c
@github-actions

Copy link
Copy Markdown
Contributor

Frontend Lint Report (Biome)

All checks passed! No linting or formatting issues found.

@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 18.7
e2e-coowners e2e 1 0 0 0 1.3
e2e-etl e2e 1 0 0 0 10.4
e2e-login e2e 2 0 0 0 1.2
e2e-prompt-studio e2e 1 0 0 0 9.6
e2e-smoke e2e 2 0 0 0 1.1
e2e-workflow e2e 1 0 0 0 16.3
frontend unit 0 1 0 0 0.0
integration-backend integration 535 0 0 26 55.1
integration-connectors integration 1 0 0 7 7.9
integration-workers integration 157 0 0 1 49.9
ui e2e 0 1 0 0 0.0
unit-backend unit 1276 0 0 1 45.7
unit-connectors unit 63 0 0 0 9.9
unit-core unit 115 0 0 0 2.1
unit-platform-service unit 15 0 0 0 2.7
unit-rig unit 120 0 0 0 4.7
unit-runner unit 5 0 0 0 3.0
unit-sdk1 unit 563 0 0 0 30.0
unit-workers unit 1425 0 0 1 155.7
TOTAL 4286 2 0 36 425.4

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
  • platform-key-whoami — 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