Skip to content

Feature: Shared/private workflows and image boards in multiuser mode#9018

Merged
lstein merged 46 commits intoinvoke-ai:mainfrom
lstein:copilot/enhancement-allow-shared-boards
Apr 13, 2026
Merged

Feature: Shared/private workflows and image boards in multiuser mode#9018
lstein merged 46 commits intoinvoke-ai:mainfrom
lstein:copilot/enhancement-allow-shared-boards

Conversation

@lstein
Copy link
Copy Markdown
Collaborator

@lstein lstein commented Apr 4, 2026

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

  • Migration 28: Adds user_id (DEFAULT 'system') and is_public (DEFAULT FALSE) columns + indexes to workflow_library. Uses executescript() for the DDL statements so that transaction management is handled explicitly (executescript always issues COMMIT first), which avoids edge-cases in Python's sqlite3 implicit transaction handling for DDL on tables that contain VIRTUAL generated columns. A post-check raises a clear RuntimeError if the columns were not actually added, preventing silent failures. No cross-module imports in the migration callback.
  • WORKFLOW_LIBRARY_DEFAULT_USER_ID constant added to workflow_records_common.py to avoid magic strings across service and base layers
  • workflow_records_*: All query methods (create, get_many, counts_by_*, get_all_tags) accept user_id and is_public filters; new update_is_public() method — which also automatically adds the "shared" tag when is_public=true and removes it when is_public=false
  • workflows.py router:
    • All endpoints now use CurrentUserOrDefault
    • list_workflows / counts_by_* / get_all_tags automatically scope user category results to the current user in multiuser mode (bypassed when is_public=true is explicitly requested)
    • Ownership enforced on GET (non-owner blocked unless public/default/admin), PATCH, DELETE, and thumbnail endpoints
    • New PATCH /api/v1/workflows/i/{workflow_id}/is_public endpoint

Frontend

  • openapi.json updated; schema.ts regenerated via make frontend-typegen
  • WorkflowRecordOrderBy gains is_public; WorkflowLibraryView gains 'shared'
  • Shared Workflows nav section added to WorkflowLibrarySideNav; WorkflowList routes 'shared' view to is_public=true query
  • WorkflowGeneralTab (Details panel): new ShareWorkflowCheckbox component 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; toggles is_public via the API
  • WorkflowListItem: owners see a ShareWorkflowToggle switch; public workflows display a "Shared" badge; EditWorkflow and DeleteWorkflow buttons are now gated behind isOwner || is_admin so non-owners cannot edit or delete others' workflows
  • SaveWorkflowAsDialog: "Share workflow" checkbox — marks new workflow public immediately after creation
  • WorkflowSortControl + sort options updated to include is_public
  • i18n: sharedWorkflows, shareWorkflow ("Shared workflow") added to en.json

Single-user mode behavior is completely unchanged — no workflow filtering is applied when multiuser is false.

QA Instructions

** Warning: ** be aware that this PR performs a database migration before testing.

Workflow Isolation

  1. Run InvokeAI in multiuser mode multiuser: true and create two users: user1 and user2. If the user interface for managing users isn't yet merged in, use the invoke-useradd CLI.
  2. Log in as user1 and create two workflows: user1 workflow shared and user1 workflow private
  3. When you save the shared workflow, check the "shared workflow" box. Leave it unchecked for the private workflow.
  4. Go to the workflow library. Confirm that you see both workflows in the "Your Workflows" section, and that "user1 workflow shared" bears the green "SHARED" emblem.
  5. Now check the "Shared Workflows" section, and confirm that you see the shared workflow only.
  6. Log out and log back in as user2
  7. Open the workflow library and confirm that "user1 workflow shared" is present in the Shared Workflows section and that "Your Workflows" is empty.
  8. Open "user 1 workflow shared" and confirm that you can edit it, but can't save it (you can "Save As" however).

Board Sharing

This PR implements image board sharing among users when multiuser mode is active. It adds three visibility levels for the boards:

  • private -- This board is visible only to the current logged-in user (the default)
  • shared -- This board is visible to all users on a read-only basis. Only the owner or administrator can modify its contents or delete the board.
  • public -- This board is visible to all users and they have read/write/delete access to the images contained within it. Only the owner or administrator can rename, delete, or otherwise modify the board.

Summary

Backend

  • BoardVisibility enum (private | shared | public) added to board_records_common.py; board_visibility field added to BoardRecord and BoardChanges
  • Migration 29: adds board_visibility TEXT NOT NULL DEFAULT 'private' column to boards table; migrates existing is_public=1 rows to 'public'
  • board_records_sqlite.py: update() handles board_visibility; get_many()/get_all() queries use board_visibility IN ('shared', 'public') instead of is_public = 1
  • boards.py router: get_board and list_all_board_image_names allow non-owner access for shared/public boards; update_board and delete_board remain owner/admin-only

Frontend

  • schema.ts: BoardVisibility enum, board_visibility field on BoardDTO and BoardChanges
  • BoardContextMenu.tsx: "Set Private / Set Shared / Set Public" menu items (owner and admins only); visibility handlers extracted into named useCallback hooks to comply with react/jsx-no-bind; Delete Board, Archive, and Unarchive menu items are disabled (greyed out) for non-owners of shared and public boards
  • BoardEditableTitle.tsx: pencil icon and double-click rename hidden for non-owners of shared and public boards
  • GalleryBoard.tsx: blue share icon badge for shared boards, green globe badge for public boards; DnD drop target disabled for non-owners of shared boards
  • GalleryImage.tsx: drag-out disabled for non-owners viewing a shared board, preventing images from being moved out
  • GalleryItemDeleteIconButton.tsx: shift+hover trash icon hidden when viewing a shared board as a non-owner
  • ContextMenuItemDeleteImage.tsx: delete image menu item hidden when viewing a shared board as a non-owner
  • ContextMenuItemChangeBoard.tsx: "Change Board" menu item disabled when viewing a shared board as a non-owner
  • MultipleSelectionMenuItems.tsx: "Change Board" and "Delete Selection" disabled when viewing a shared board as a non-owner
  • InvokeQueueBackButton.tsx: main Invoke/generate button disabled when the auto-add board is a shared board the current user does not own
  • FloatingLeftPanelButtons.tsx: floating invoke icon button also disabled when the auto-add board is a shared board the current user does not own
  • ChangeBoardModal.tsx: destination board list filtered to exclude shared boards the current user does not own, preventing moves into read-only boards
  • New hooksuseBoardAccess(board) returns { canWriteImages, canRenameBoard, canDeleteBoard }; useSelectedBoard() and useAutoAddBoard() look up the relevant BoardDTO from the RTK Query cache
  • en.json: i18n strings for all new visibility UI

Tests

10 new tests in test_boards_multiuser.py covering 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.

  1. Enable multiuser mode (multiuser: true in config)
  2. Create a board as User A — verify it defaults to private (User B cannot see it)
  3. Right-click board → Set Shared — verify User B can now view it but:
    • The pencil icon is hidden and double-clicking the name does nothing
    • The Delete Board, Archive, and Unarchive options in the context menu are greyed out
    • The trash icon on images and "Delete Image" context menu item are hidden
    • Both the main Invoke button and the floating invoke icon button are disabled if this shared board is the auto-add target
    • Dragging images onto the board is disabled
    • Dragging images out of the board is disabled
    • The "Change Board" destination list does not include this shared board
  4. Set to Public — verify User B can view and write images (including generating into it), but cannot rename, delete, or archive the board
  5. Set back to Private — verify User B loses access again
  6. Verify shared/public boards show the appropriate icon badge in the boards list
  7. Verify admins retain full access to all boards regardless of visibility

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.

  1. Create the admin user and a non-admin user.
  2. Log in as the admin user and confirm that the splash screen that says "start by installing models" is still there, and that when you try to select a model you see a similar message.
  3. Log out and log in as the non-admin user. Now check the splash message. Instead of asking you to install a model, it instructs you to contact the administrator to install models. To avoid ambiguity, it gives the administrator's email address used at registration time.
  4. There will be similar messages when you try to select a model, or when you go to the upscaling tab and necessary models are missing.

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

  • The PR has a short but descriptive title, suitable for a changelog
  • Tests added / updated (if applicable)
  • ❗Changes to a redux slice have a corresponding migration
  • Documentation added / updated (if applicable)
  • Updated What's New copy (if doing a release after this PR)

Copilot AI and others added 15 commits March 5, 2026 20:09
* 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>
… 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>
@github-actions github-actions bot added api python PRs that change python files services PRs that change app services frontend PRs that change frontend files python-tests PRs that change python tests labels Apr 4, 2026
@lstein lstein changed the title Feature: Shared and public boards in multiuser mode Feature: Shared/private workflows and image boards in multiuser mode Apr 5, 2026
Copy link
Copy Markdown
Collaborator

@JPPhoto JPPhoto left a comment

Choose a reason for hiding this comment

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

Some issues:

  1. 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 CurrentUserOrDefault dependency and no owner/visibility checks. In practice, another user can list image names on a shared board via the new allowed GET /boards/{id}/image_names, then call DELETE /images/i/{image_name} or POST /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.

  2. 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}/thumbnail in 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 when GET /workflows/i/{workflow_id} is now correctly blocked.

  3. The new admin_email field on unauthenticated setup status leaks administrator identity. The PR extends GET /api/v1/auth/status in 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 the setup_required=true path or otherwise gated/sanitized.

JPPhoto and others added 2 commits April 12, 2026 10:04
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>
@lstein
Copy link
Copy Markdown
Collaborator Author

lstein commented Apr 12, 2026

Bulk download cross-user exfiltration fix + pre-existing toast race condition

This is what is fixed in 494fc15

Vulnerability

In multiuser mode, any authenticated user could bulk-download images or boards belonging to other users. The attack surface had five layers:

  1. POST /download accepted arbitrary image_names or board_id without checking read access
  2. GET /download/{name} served any zip file without verifying ownership
  3. Bulk download handler operated without user context
  4. Socket events were broadcast to a shared room, leaking the zip filename (the token needed to fetch it)
  5. Socket room subscription allowed any authenticated client to join the bulk download room

Fixes

Access control at creation time (images.py):

  • POST /download now validates _assert_image_read_access() for each image name and _assert_board_read_access() for board downloads before queuing
  • New _assert_board_read_access() helper: grants access to admins, board owners, or users viewing Shared/Public boards

Unauthenticated GET endpoint (images.py):

  • GET /download/{name} intentionally drops CurrentUserOrDefault — browser <a download> links cannot carry Authorization headers. Security relies on: POST-time access checks, UUID filename unguessability (returned only to the authenticated caller), and single-fetch deletion.

User identity threading (8 files):

  • Added user_id field to BulkDownloadEventBase and all three event build() methods
  • Threaded user_id through emit_bulk_download_*() in events_base.py
  • Handler and base class accept user_id parameter; service tracks ownership in _download_owners dict
  • Socket bulk download room subscription restricted to authenticated sockets in multiuser mode

Event dispatch resilience (events_fastapievents.py):

  • Added exception handling in _dispatch_from_queue to log errors instead of silently crashing the dispatch task

Pre-existing toast race condition (frontend, 2 files):

  • The BackgroundTasks handler completes in ~17ms, so the bulk_download_complete socket event can arrive at the frontend before the Redux middleware processes the POST response. Both used the same toast ID, causing "Preparing Download" to overwrite "Ready to Download".
  • Fix: POST response toast now uses preparing:{name} as its ID; socket handlers dismiss it before showing their own toast.

Tests (8 new)

Test Asserts
test_bulk_download_by_image_names_rejected_for_non_owner Non-owner gets 403
test_bulk_download_by_image_names_allowed_for_owner Owner gets 202
test_bulk_download_by_board_rejected_for_private_board Private board → 403
test_bulk_download_by_shared_board_allowed Shared board → 202
test_admin_can_bulk_download_any_images Admin gets 202 for any user's images
test_bulk_download_events_carry_user_id user_id field on all 3 event types
test_bulk_download_event_emitted_to_download_room Event emitted to bulk_download_id room
test_get_bulk_download_unauthenticated_returns_404 Unknown filename → 404 (not 401)

lstein and others added 4 commits April 12, 2026 14:18
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>
@lstein
Copy link
Copy Markdown
Collaborator Author

lstein commented Apr 12, 2026

Fixed in 8182c08

Fix: private board image enumeration via generic listing endpoints

Vulnerability

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 — even though GET /api/v1/boards/{board_id} and GET /api/v1/boards/{board_id}/image_names correctly enforced visibility.

Fix

Both list_image_dtos() and get_image_names() in invokeai/app/api/routers/images.py now call _assert_board_read_access(board_id, current_user) before querying when board_id is a real board ID (not None or "none"). Returns 403 unless the caller is the board owner, an admin, or the board is Shared/Public.

Tests (6 new)

Test Asserts
test_list_images_private_board_rejected_for_non_owner GET /images?board_id=... returns 403 for non-owner
test_list_images_shared_board_allowed_for_non_owner GET /images?board_id=... returns 200 for shared board
test_get_image_names_private_board_rejected_for_non_owner GET /images/names?board_id=... returns 403 for non-owner
test_get_image_names_shared_board_allowed_for_non_owner GET /images/names?board_id=... returns 200 for shared board
test_list_images_own_private_board_allowed Owner can list own private board (200)
test_admin_can_list_images_on_any_board Admin can list any board (200)

@lstein
Copy link
Copy Markdown
Collaborator Author

lstein commented Apr 12, 2026

Fixed in 345d039.

Fix: privilege escalation via image-to-board reassignment

Vulnerability

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. This enabled a privilege escalation attack:

  1. Attacker calls POST /board_images/ { board_id: attacker-board, image_name: victim-image }
  2. Board write access passes — attacker owns the destination board
  3. Image is reassigned to attacker's board (upsert replaces old board association)
  4. _assert_image_owner() in images.py treats board ownership as sufficient authority
  5. Attacker can now delete, patch, star, or unstar the victim's image through normal image mutation routes

Fix

Added _assert_image_direct_owner() to board_images.py — intentionally stricter than _assert_image_owner() in images.py. It requires direct image ownership (image_records.user_id) or admin. Board ownership is explicitly not sufficient, breaking the escalation chain.

Both add_image_to_board and add_images_to_board (batch) now call this check before allowing the operation.

Also fixed a pre-existing bug where HTTPException raised inside the batch loop was caught by the outer except Exception and returned as 500 instead of propagating the correct status code.

Tests (2 new, 2 updated)

Test Asserts
test_non_owner_cannot_add_other_users_image_to_own_board New — attacker adding victim's image to own board gets 403
test_non_owner_cannot_batch_add_other_users_images_to_own_board New — same attack via batch endpoint gets 403
test_admin_can_add_image_to_any_board Updated — now creates a real image record so the ownership check passes
test_owner_can_add_image_to_own_board Updated — same

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>
@lstein
Copy link
Copy Markdown
Collaborator Author

lstein commented Apr 12, 2026

Fixed in 9e7354d.

Fix: unauthorized image access via recall parameter resolution

Vulnerability

POST /api/v1/recall/{queue_id} 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. Two information leaks:

  1. Dimension extraction: load_image_file() at recall_parameters.py:138-166 opens any image by name and returns { image_name, width, height } — no access check.
  2. Derived image minting: The ControlNet branch at recall_parameters.py:228 calls process_controlnet_image(), which builds an invocation around the attacker-supplied image (controlnet_processor.py:121-166) and returns a new processed image token plus dimensions. The attacker can then fetch this derived image by its returned name.

An attacker who knows another user's image UUID (e.g., from a previously-shared board) could exploit both paths.

Fix

Added _assert_recall_image_access() in recall_parameters.py which validates read access for every image referenced in the request before any resolution or processing occurs. Access is granted when:

  • The caller is an admin
  • The caller is the image's direct owner (image_records.user_id)
  • The image sits on a Shared or Public board

The check runs before the try block so the HTTPException propagates correctly (not caught as 500).

Tests (5 new)

Test Asserts
test_recall_controlnet_with_other_users_image_rejected ControlNet with victim's private image → 403
test_recall_ip_adapter_with_other_users_image_rejected IP adapter with victim's private image → 403
test_recall_own_image_allowed Owner can reference own image (not 403)
test_recall_shared_board_image_allowed Image on shared board usable by other users (not 403)
test_recall_admin_can_reference_any_image Admin can reference any image (not 403)

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>
@lstein
Copy link
Copy Markdown
Collaborator Author

lstein commented Apr 12, 2026

Fixed in a6308b4.

Fix: unauthenticated model install job endpoints

Vulnerability

Six model install job endpoints were missing authentication, even though the neighboring cancel and prune routes correctly required AdminUserOrDefault:

Endpoint Method Risk
/api/v2/models/install GET Exposes source, local_path, download_parts, error, error_traceback
/api/v2/models/install/{id} GET Same sensitive fields for a specific job
/api/v2/models/install/{id}/pause POST Mutating — pauses installation
/api/v2/models/install/{id}/resume POST Mutating — resumes installation
/api/v2/models/install/{id}/restart_failed POST Mutating — restarts failed downloads
/api/v2/models/install/{id}/restart_file POST Mutating — restarts a specific file download

Any caller who could reach the API could view sensitive model installation details and interfere with installation state.

Fix

All six endpoints now require AdminUserOrDefault, consistent with the existing cancel_model_install_job and prune_model_install_jobs routes. Model installation is an admin operation.

Tests (8 new)

Test Asserts
test_list_model_installs_requires_auth GET /install without token → 401
test_get_model_install_job_requires_auth GET /install/{id} without token → 401
test_pause_model_install_requires_auth POST /install/{id}/pause without token → 401
test_resume_model_install_requires_auth POST /install/{id}/resume without token → 401
test_restart_failed_model_install_requires_auth POST /install/{id}/restart_failed without token → 401
test_restart_model_install_file_requires_auth POST /install/{id}/restart_file without token → 401
test_non_admin_cannot_list_model_installs Non-admin user → 403
test_non_admin_cannot_pause_model_install Non-admin user → 403

@lstein
Copy link
Copy Markdown
Collaborator Author

lstein commented Apr 12, 2026

I think we discussed this and how we were willing to live with this, but non-admin users can still enumerate the global queue as a redacted activity feed....

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.

@JPPhoto
Copy link
Copy Markdown
Collaborator

JPPhoto commented Apr 12, 2026

Bulk download cross-user exfiltration fix + pre-existing toast race condition

There's still a problem with bulk downloads. The chain is:

  1. Bulk download ownership is tracked but not enforced on fetch.
    In images.py, get_bulk_download_item() takes only bulk_download_item_name and does not accept CurrentUserOrDefault. It calls bulk_download.get_path(...) and serves the file if it exists.
    In bulk_download_default.py, the service stores _download_owners.
    In bulk_download_default.py, there is a get_owner() helper.
    I do not see get_owner() used by images.py or anywhere else on the fetch path.

  2. The websocket event still exposes the capability token.
    In events_common.py, BulkDownloadEventBase includes bulk_download_item_name.
    In sockets.py, _handle_bulk_image_download_event() emits the event payload to room=event_data.bulk_download_id.

  3. That room is still shared.
    In bulk_download_common.py, DEFAULT_BULK_DOWNLOAD_ID = "default".
    In bulk_download_default.py, every job uses bulk_download_id = DEFAULT_BULK_DOWNLOAD_ID.

  4. Any authenticated socket can subscribe to that shared room.
    In sockets.py, _handle_sub_bulk_download() only checks that the socket is authenticated in multiuser mode, then joins BulkDownloadSubscriptionEvent(**data).bulk_download_id.
    There is no ownership check on the requested bulk download room.

So the code path is still:

  • user A starts a download
  • event containing bulk_download_item_name is emitted to shared room default
  • user B, if authenticated and subscribed to default, receives that token
  • user B calls the unauthenticated fetch route before deletion

Hope this analysis helps.

@JPPhoto
Copy link
Copy Markdown
Collaborator

JPPhoto commented Apr 12, 2026

Plus...

GET /api/v1/boards/none/image_names still leaks every uncategorized image name across users. In boards.py, list_all_board_image_names() skips authorization entirely when board_id == "none". It then calls the storage method in board_image_records_sqlite.py, which resolves "none" to board_images.board_id IS NULL with no images.user_id filter. That means any authenticated user can enumerate all uncategorized image UUIDs on the instance. Because the binary image routes are still intentionally unauthenticated in images.py and images.py, this is a still a real cross-user image disclosure path.

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, _assert_board_write_access() only allows the board owner or an admin. That blocks non-owners from adding or removing images through board_images.py and board_images.py. Uploads are also owner-only in images.py, and image mutation authority still ignores BoardVisibility.Public in images.py. Thus, public boards are readable to everyone, but not actually writable or deletable by everyone through these API paths.

@JPPhoto
Copy link
Copy Markdown
Collaborator

JPPhoto commented Apr 12, 2026

Feel free to apply this git patch to expose the flaws.
pr-9018-adversarial-tests.patch

…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>
@lstein lstein force-pushed the copilot/enhancement-allow-shared-boards branch from 6baa510 to 58cb8aa Compare April 12, 2026 22:59
@lstein
Copy link
Copy Markdown
Collaborator Author

lstein commented Apr 12, 2026

Any authenticated socket can subscribe to that shared room.
In sockets.py, _handle_sub_bulk_download() only checks that the socket is authenticated in multiuser mode, then joins BulkDownloadSubscriptionEvent(**data).bulk_download_id.
There is no ownership check on the requested bulk download room.

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 path

Bulk download capability token exfiltration

Bulk download flow still leaks the zip filename (capability token) to other users:

  1. User A starts a download
  2. bulk_download_complete event (containing bulk_download_item_name) is emitted to shared room "default"
  3. User B, subscribed to "default", receives the token
  4. User B calls the unauthenticated GET /download/{name} before the file is deleted

Fixes (3 layers):

Layer Before After
Socket event routing Emitted to shared "default" room Emitted to user:{user_id} + admin rooms only
GET endpoint auth Unauthenticated Requires CurrentUserOrDefault + ownership check via get_owner()
Frontend download <a download> (no auth headers) fetch() with Authorization: Bearer header + programmatic blob download

Additional fixes from @JPPhoto 's tests (thanks!)

Public board write access (board_images.py, images.py):

  • _assert_board_write_access() now grants access when board_visibility == Public (public boards accept contributions from any authenticated user)
  • _assert_image_owner() now grants mutation rights when the image sits on a Public board

Uncategorized image enumeration (boards.py):

  • GET /boards/none/image_names now filters to the caller's own images (non-admin), preventing cross-user enumeration of uncategorized images

board_images router resilience (board_images.py):

  • Replaced images.get_dto(image_name).board_id with board_image_records.get_board_for_image(image_name) to determine old board association — avoids dependency on image_files service

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>
@JPPhoto
Copy link
Copy Markdown
Collaborator

JPPhoto commented Apr 12, 2026

Public-board write semantics are still incomplete for uploads. In invokeai/app/api/routers/images.py, upload_image() still blocks any non-admin caller when board_id is set and board.user_id != current_user.user_id. That means a non-owner still cannot upload directly into a public board through /api/v1/images/upload, even though the PR description says public boards allow all users to read, write, and delete contained images. The later fixes in invokeai/app/api/routers/board_images.py and _assert_image_owner() in invokeai/app/api/routers/images.py do cover moving existing images into public boards and mutating images already on public boards, but this upload path remains owner-only. This needs a test and then fixing and I hope that's it!

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>
@lstein
Copy link
Copy Markdown
Collaborator Author

lstein commented Apr 13, 2026

Public-board write semantics are still incomplete for uploads. In invokeai/app/api/routers/images.py,

Fixed in c19f040

@JPPhoto
Copy link
Copy Markdown
Collaborator

JPPhoto commented Apr 13, 2026

I think this is all good. I'll try testing it out manually today.

@JPPhoto JPPhoto self-requested a review April 13, 2026 18:29
Copy link
Copy Markdown
Collaborator

@JPPhoto JPPhoto left a comment

Choose a reason for hiding this comment

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

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.

@lstein lstein merged commit 33ec16d into invoke-ai:main Apr 13, 2026
13 checks passed
lstein added a commit that referenced this pull request Apr 14, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api frontend PRs that change frontend files python PRs that change python files python-tests PRs that change python tests services PRs that change app services

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants