Feature: Shared/private workflows and image boards in multiuser mode#9018
Conversation
* Add per-user workflow isolation: migration 28, service updates, router ownership checks, is_public endpoint, schema regeneration, frontend UI Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * feat: add shared workflow checkbox to Details panel, auto-tag, gate edit/delete, fix tests Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
…mode (#116) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
…ature/workflow-isolation-in-multiuser-mode
… mode (#120) * Disable Save when editing another user's shared workflow in multiuser mode Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
…-board filter, archive Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
…image, etc.) Previously, images in shared boards owned by another user could not be dragged at all — the draggable setup was completely skipped in GalleryImage.tsx when canWriteImages was false. This blocked ALL drop targets including the viewer, reference image pane, and canvas. Now images are always draggable. The board-move restriction is enforced in the dnd target isValid functions instead: - addImageToBoardDndTarget: rejects moves from shared boards the user doesn't own (unless admin or board is public) - removeImageFromBoardDndTarget: same check Other drop targets (viewer, reference images, canvas, comparison, etc.) remain fully functional for shared board images. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…to copilot/enhancement-allow-shared-boards
JPPhoto
left a comment
There was a problem hiding this comment.
Some issues:
-
Shared-board write protection is still bypassable through direct API calls. The PR only adds read-side visibility checks in boards.py, but the actual mutation routes in board_images.py and images.py still have no
CurrentUserOrDefaultdependency and no owner/visibility checks. In practice, another user can list image names on a shared board via the new allowedGET /boards/{id}/image_names, then callDELETE /images/i/{image_name}orPOST /board_images/to delete/move images anyway. That defeats the PR's core "shared = read-only" guarantee. This needs API-level authorization tests around board-image mutation and image deletion/update, not just UI gating. -
Private workflow thumbnails are still exposed despite the PR summary claiming thumbnail endpoint ownership enforcement. The write endpoints are guarded in the PR, but
GET /api/v1/workflows/i/{workflow_id}/thumbnailin workflows.py still has no user context and no ownership/public check. If a workflow id is known, a private workflow's thumbnail remains fetchable even whenGET /workflows/i/{workflow_id}is now correctly blocked. -
The new
admin_emailfield on unauthenticated setup status leaks administrator identity. The PR extendsGET /api/v1/auth/statusin auth.py to return the first active admin's email, and that route still has no auth requirement. On a public multiuser deployment, this becomes a trivial admin-enumeration/phishing aid. If the frontend needs this for onboarding copy, it should be limited to thesetup_required=truepath or otherwise gated/sanitized.
Backend:
- POST /download now validates image read access (per-image) and board
read access (per-board) before queuing the download.
- GET /download/{name} is intentionally unauthenticated because the
browser triggers it via <a download> which cannot carry Authorization
headers. Access control relies on POST-time checks, UUID filename
unguessability, private socket event routing, and single-fetch deletion.
- Added _assert_board_read_access() helper to images router.
- Threaded user_id through bulk download handler, base class, event
emission, and BulkDownloadEventBase so events carry the initiator.
- Bulk download service now tracks download ownership via _download_owners
dict (cleaned up on delete).
- Socket bulk_download room subscription restricted to authenticated
sockets in multiuser mode.
- Added error-catching in FastAPIEventService._dispatch_from_queue to
prevent silent event dispatch failures.
Frontend:
- Fixed pre-existing race condition where the "Preparing Download" toast
from the POST response overwrote the "Ready to Download" toast from the
socket event (background task completes in ~17ms, so the socket event
can arrive before Redux processes the HTTP response). Toast IDs are now
distinct: "preparing:{name}" vs "{name}".
- bulk_download_complete/error handlers now dismiss the preparing toast.
Tests (8 new):
- Bulk download by image names rejected for non-owner (403)
- Bulk download by image names allowed for owner (202)
- Bulk download from private board rejected (403)
- Bulk download from shared board allowed (202)
- Admin can bulk download any images (202)
- Bulk download events carry user_id
- Bulk download event emitted to download room
- GET /download unauthenticated returns 404 for unknown files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bulk download cross-user exfiltration fix + pre-existing toast race conditionThis is what is fixed in 494fc15 VulnerabilityIn multiuser mode, any authenticated user could bulk-download images or boards belonging to other users. The attack surface had five layers:
FixesAccess control at creation time (
Unauthenticated GET endpoint (
User identity threading (8 files):
Event dispatch resilience (
Pre-existing toast race condition (frontend, 2 files):
Tests (8 new)
|
GET /api/v1/images?board_id=... and GET /api/v1/images/names?board_id=... passed board_id directly to the SQL layer without checking board visibility. The SQL only applied user_id filtering for board_id="none" (uncategorized images), so any authenticated user who knew a private board ID could enumerate its images. Both endpoints now call _assert_board_read_access() before querying, returning 403 unless the caller is the board owner, an admin, or the board is Shared/Public. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
add_image_to_board and add_images_to_board only checked write access to the destination board, never verifying that the caller owned the source image. An attacker could add a victim's image to their own board, then exploit the board-ownership fallback in _assert_image_owner to gain delete/patch/star/unstar rights on the image. Both endpoints now call _assert_image_direct_owner which requires direct image ownership (image_records.user_id) or admin — board ownership is intentionally not sufficient, preventing the escalation chain. Also fixed a pre-existing bug where HTTPException from the inner loop in add_images_to_board was caught by the outer except-Exception and returned as 500 instead of propagating the correct status code. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Fixed in 8182c08 Fix: private board image enumeration via generic listing endpointsVulnerability
FixBoth Tests (6 new)
|
|
Fixed in 345d039. Fix: privilege escalation via image-to-board reassignmentVulnerability
FixAdded Both Also fixed a pre-existing bug where Tests (2 new, 2 updated)
|
The recall endpoint loaded image files and ran ControlNet preprocessors on any image_name supplied in control_layers or ip_adapters without checking that the caller could read the image. An attacker who knew another user's image UUID could extract dimensions and, for supported preprocessors, mint a derived processed image they could then fetch. Added _assert_recall_image_access() which validates read access for every image referenced in the request before any resolution or processing occurs. Access is granted to the image owner, admins, or when the image sits on a Shared/Public board. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Fixed in 9e7354d. Fix: unauthorized image access via recall parameter resolutionVulnerability
An attacker who knows another user's image UUID (e.g., from a previously-shared board) could exploit both paths. FixAdded
The check runs before the Tests (5 new)
|
list_model_installs, get_model_install_job, pause, resume, restart_failed, and restart_file were unauthenticated — any caller who could reach the API could view sensitive install job fields (source, local_path, error_traceback) and interfere with installation state. All six endpoints now require AdminUserOrDefault, consistent with the neighboring cancel and prune routes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Fixed in a6308b4. Fix: unauthenticated model install job endpointsVulnerabilitySix model install job endpoints were missing authentication, even though the neighboring
Any caller who could reach the API could view sensitive model installation details and interfere with installation state. FixAll six endpoints now require Tests (8 new)
|
I'm willing to live with the current level of partial redaction. I work on large academic compute clusters, and I'm used to the queue manager listing user names, what nodes their tasks are executing on, and the name of the task. Labs can be pretty competitive with each other, but nobody seems to mind this level of information leakage. I think this will only become a problem for InvokeAI when it is mounted as a hosted solution, in which case users might not want their competitors looking over their shoulders. For the time being, I'd like to present enough information that a user can see how many jobs are in the queue in front of theirs and possibly complain to the admin when someone is hogging the system. |
There's still a problem with bulk downloads. The chain is:
So the code path is still:
Hope this analysis helps. |
|
Plus...
Public-board write semantics are still not implemented on the backend. The PR description says public boards should allow all users to read, write, and delete contained images, but the router checks still require owner or admin. In board_images.py, |
|
Feel free to apply this git patch to expose the flaws. |
…w findings
Bulk download capability token exfiltration:
- Socket events now route to user:{user_id} + admin rooms instead of the
shared 'default' room (the earlier toast race that blocked this approach
was fixed in a prior commit).
- GET /download/{name} re-requires CurrentUserOrDefault and enforces
ownership via get_owner().
- Frontend download handler replaced <a download> (which cannot carry auth
headers) with fetch() + Authorization header + programmatic blob download.
Additional fixes from reviewer tests:
- Public boards now grant write access in _assert_board_write_access and
mutation rights in _assert_image_owner (BoardVisibility.Public).
- Uncategorized image listing (GET /boards/none/image_names) now filters
to the caller's images only, preventing cross-user enumeration.
- board_images router uses board_image_records.get_board_for_image()
instead of images.get_dto() to avoid dependency on image_files service.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6baa510 to
58cb8aa
Compare
My mistake. I was trying to fix a race condition that prevented the "downloads are ready" toast from popping up and made the private websocket room public while debugging. Issues addressed in 58cb8aa Fix: close bulk download exfiltration pathBulk download capability token exfiltrationBulk download flow still leaks the zip filename (capability token) to other users:
Fixes (3 layers):
Additional fixes from @JPPhoto 's tests (thanks!)Public board write access (
Uncategorized image enumeration (
board_images router resilience (
|
Defense-in-depth: the route layer already checks ownership before calling update/delete/update_is_public/update_opened_at, but the SQL statements did not include AND user_id = ?, so a bypass of the route check would allow cross-user mutations. All four methods now accept an optional user_id parameter. When provided, the SQL WHERE clause is scoped to that user. The route layer passes current_user.user_id for non-admin callers and None for admins. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Public-board write semantics are still incomplete for uploads. In |
upload_image() blocked non-owner uploads even to public boards. The board write check now allows uploads when board_visibility is Public, consistent with the public-board semantics in _assert_board_write_access and _assert_image_owner. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fixed in c19f040 |
|
I think this is all good. I'll try testing it out manually today. |
JPPhoto
left a comment
There was a problem hiding this comment.
I was able to do some minimal testing on a live system here and it looks good. Would love to see an announcement and more community testing of this feature after it gets merged.
PR #9018 assigned preexisting workflows user_id='system' with is_public=FALSE, causing them to disappear from all users' libraries. - Add migration 30: sets is_public=TRUE for all system-owned workflows so they appear under "Shared Workflows" - Skip user_id filtering for admins in workflow listing endpoints so they can see and manage all workflows including system-owned ones - Show the Shared toggle to admins on system-owned workflows in the workflow list UI - Update multiuser user guide to document private/shared workflows, private/shared/public image boards, and warn about preexisting workflows appearing in shared section - Add regression tests for system workflow visibility, admin CRUD access, and regular user access denial Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
This PR gives users the ability to share workflows and image boards when logged in to a multiuser instance.
Workflow Sharing
User can share workflows with each other as well as create private workflows that are invisible to other users. Each user has their own workflow library that is only accessible to them. Any workflow can be designated Shared, in which case it is readable by all users but only writable by the owner.
Backend
user_id(DEFAULT'system') andis_public(DEFAULTFALSE) columns + indexes toworkflow_library. Usesexecutescript()for the DDL statements so that transaction management is handled explicitly (executescript always issues COMMIT first), which avoids edge-cases in Python'ssqlite3implicit transaction handling for DDL on tables that contain VIRTUAL generated columns. A post-check raises a clearRuntimeErrorif the columns were not actually added, preventing silent failures. No cross-module imports in the migration callback.WORKFLOW_LIBRARY_DEFAULT_USER_IDconstant added toworkflow_records_common.pyto avoid magic strings across service and base layersworkflow_records_*: All query methods (create,get_many,counts_by_*,get_all_tags) acceptuser_idandis_publicfilters; newupdate_is_public()method — which also automatically adds the"shared"tag whenis_public=trueand removes it whenis_public=falseworkflows.pyrouter:CurrentUserOrDefaultlist_workflows/counts_by_*/get_all_tagsautomatically scopeusercategory results to the current user in multiuser mode (bypassed whenis_public=trueis explicitly requested)GET(non-owner blocked unless public/default/admin),PATCH,DELETE, and thumbnail endpointsPATCH /api/v1/workflows/i/{workflow_id}/is_publicendpointFrontend
openapi.jsonupdated;schema.tsregenerated viamake frontend-typegenWorkflowRecordOrderBygainsis_public;WorkflowLibraryViewgains'shared'WorkflowLibrarySideNav;WorkflowListroutes'shared'view tois_public=truequeryWorkflowGeneralTab(Details panel): newShareWorkflowCheckboxcomponent positioned between the workflow Name field and the Workflow Thumbnail section — visible to the workflow owner and admins in multiuser mode; label and checkbox are on the same horizontal line; togglesis_publicvia the APIWorkflowListItem: owners see aShareWorkflowToggleswitch; public workflows display a "Shared" badge;EditWorkflowandDeleteWorkflowbuttons are now gated behindisOwner || is_adminso non-owners cannot edit or delete others' workflowsSaveWorkflowAsDialog: "Share workflow" checkbox — marks new workflow public immediately after creationWorkflowSortControl+ sort options updated to includeis_publicsharedWorkflows,shareWorkflow("Shared workflow") added toen.jsonSingle-user mode behavior is completely unchanged — no workflow filtering is applied when
multiuserisfalse.QA Instructions
** Warning: ** be aware that this PR performs a database migration before testing.
Workflow Isolation
multiuser: trueand create two users: user1 and user2. If the user interface for managing users isn't yet merged in, use theinvoke-useraddCLI.user1 workflow sharedanduser1 workflow privateBoard Sharing
This PR implements image board sharing among users when multiuser mode is active. It adds three visibility levels for the boards:
Summary
Backend
BoardVisibilityenum (private|shared|public) added toboard_records_common.py;board_visibilityfield added toBoardRecordandBoardChangesboard_visibility TEXT NOT NULL DEFAULT 'private'column toboardstable; migrates existingis_public=1rows to'public'board_records_sqlite.py:update()handlesboard_visibility;get_many()/get_all()queries useboard_visibility IN ('shared', 'public')instead ofis_public = 1boards.pyrouter:get_boardandlist_all_board_image_namesallow non-owner access for shared/public boards;update_boardanddelete_boardremain owner/admin-onlyFrontend
schema.ts:BoardVisibilityenum,board_visibilityfield onBoardDTOandBoardChangesBoardContextMenu.tsx: "Set Private / Set Shared / Set Public" menu items (owner and admins only); visibility handlers extracted into nameduseCallbackhooks to comply withreact/jsx-no-bind; Delete Board, Archive, and Unarchive menu items are disabled (greyed out) for non-owners of shared and public boardsBoardEditableTitle.tsx: pencil icon and double-click rename hidden for non-owners of shared and public boardsGalleryBoard.tsx: blue share icon badge for shared boards, green globe badge for public boards; DnD drop target disabled for non-owners of shared boardsGalleryImage.tsx: drag-out disabled for non-owners viewing a shared board, preventing images from being moved outGalleryItemDeleteIconButton.tsx: shift+hover trash icon hidden when viewing a shared board as a non-ownerContextMenuItemDeleteImage.tsx: delete image menu item hidden when viewing a shared board as a non-ownerContextMenuItemChangeBoard.tsx: "Change Board" menu item disabled when viewing a shared board as a non-ownerMultipleSelectionMenuItems.tsx: "Change Board" and "Delete Selection" disabled when viewing a shared board as a non-ownerInvokeQueueBackButton.tsx: main Invoke/generate button disabled when the auto-add board is a shared board the current user does not ownFloatingLeftPanelButtons.tsx: floating invoke icon button also disabled when the auto-add board is a shared board the current user does not ownChangeBoardModal.tsx: destination board list filtered to exclude shared boards the current user does not own, preventing moves into read-only boardsuseBoardAccess(board)returns{ canWriteImages, canRenameBoard, canDeleteBoard };useSelectedBoard()anduseAutoAddBoard()look up the relevantBoardDTOfrom the RTK Query cacheen.json: i18n strings for all new visibility UITests
10 new tests in
test_boards_multiuser.pycovering default visibility, setting each level, cross-user access enforcement, reversion to private, non-owner restriction, and admin override.QA Instructions
** Warning: ** be aware that this PR performs a database migration before testing.
multiuser: truein config)Additional Fixes in this PR
There are two small UI bugs that were found and fixed while working on this PR.
The splash screens that the user sees at startup time have been customized such that non-admin users are not prompted to install models. Instead they are referred to the first administrator to install models and make other global settings.
This PR also corrects a bug that allowed any user to run "sync models" and remove orphaned models. Only the administrator is allowed to do this.
QA
Splash Screens
To test, you should start with a fresh root directory that has no models.
Sync Models button
Confirm that as a non-admin user, the Models page does not show the "Sync Models" button, and that this button reappears when logged in as an administrator.
Merge Plan
Migrations 28 and 29 add new columns to the boards and workflows tables with safe defaults so existing databases upgrade non-destructively. No redux slice changes.
Checklist
What's Newcopy (if doing a release after this PR)