Skip to content

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

Merged
lstein merged 46 commits into
invoke-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 into
invoke-ai:mainfrom
lstein:copilot/enhancement-allow-shared-boards

Conversation

@lstein

@lstein lstein commented Apr 4, 2026

Copy link
Copy Markdown
Collaborator

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

@JPPhoto JPPhoto left a comment

Copy link
Copy Markdown
Collaborator

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.

lstein and others added 3 commits April 12, 2026 14:21
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

lstein commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator Author

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

lstein commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator Author

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

lstein commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator Author

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

lstein commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator Author

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

lstein commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator Author

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

JPPhoto commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator

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

JPPhoto commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator

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

JPPhoto commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator

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

lstein commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator Author

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

JPPhoto commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator

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

lstein commented Apr 13, 2026

Copy link
Copy Markdown
Collaborator Author

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

Fixed in c19f040

@JPPhoto

JPPhoto commented Apr 13, 2026

Copy link
Copy Markdown
Collaborator

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

@JPPhoto JPPhoto left a comment

Copy link
Copy Markdown
Collaborator

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>
lstein added a commit that referenced this pull request Jun 23, 2026
…r non-admins (#9262)

* fix(multiuser): restore global queue counts + redacted entries for non-admins

PR #9018 changed the queue status/list endpoints from "global counts +
per-user counts" to "user-scoped only" for non-admins. This silently broke
two multiuser behaviors:

1. The hamburger-menu badge lost its "X/Y" form. #9018 dropped the
   user_pending/user_in_progress fields, made pending/in_progress/total
   user-scoped (so a non-admin had no global total), and reduced the badge
   to a single number.

2. The virtualized queue list stopped showing other users' redacted
   entries. #9018 added `AND user_id = ?` to get_queue_item_ids, so a
   non-admin only ever received their own item ids — the (intact)
   sanitize_queue_item_for_user redaction never ran for other users' items.

Restore the original design (PR #8822) while keeping the #9087 current-item
identifier redaction:

- SessionQueueStatus regains optional user_pending/user_in_progress.
- get_queue_status always computes GLOBAL aggregate counts and, when
  user_id is provided, additionally returns that user's own counts. The
  current item's identifiers are still gated to the owner/admin via a
  single get_current() snapshot (race-free, #9087 preserved).
- get_queue_item_ids no longer filters by user; ids carry no sensitive
  data and items are redacted at hydration by sanitize_queue_item_for_user,
  so non-admins again see partially-redacted entries for other users' jobs.
- QueueCountBadge renders "<own>/<global total>" in multiuser mode and the
  plain total for admins / single-user mode.
- Regenerate the frontend OpenAPI schema.

Aggregate counts are global but non-identifying (no session_id/batch_id/
params/graph), consistent with #9087's "counts remain global" decision.

Tests:
- Update the model-shape test to assert user_pending/user_in_progress exist.
- Add session-queue integration tests: global counts + per-user subcounts,
  admin/global omission of subcounts, current-item redaction with global
  counts intact, and get_queue_item_ids returning every user's ids.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(frontend): typegen + openapi

* chore(backend): ruff

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Eichhorn <alex@eichhorn.dev>
dunkeroni pushed a commit to dunkeroni/InvokeAI that referenced this pull request Jun 29, 2026
…r non-admins (invoke-ai#9262)

* fix(multiuser): restore global queue counts + redacted entries for non-admins

PR invoke-ai#9018 changed the queue status/list endpoints from "global counts +
per-user counts" to "user-scoped only" for non-admins. This silently broke
two multiuser behaviors:

1. The hamburger-menu badge lost its "X/Y" form. invoke-ai#9018 dropped the
   user_pending/user_in_progress fields, made pending/in_progress/total
   user-scoped (so a non-admin had no global total), and reduced the badge
   to a single number.

2. The virtualized queue list stopped showing other users' redacted
   entries. invoke-ai#9018 added `AND user_id = ?` to get_queue_item_ids, so a
   non-admin only ever received their own item ids — the (intact)
   sanitize_queue_item_for_user redaction never ran for other users' items.

Restore the original design (PR invoke-ai#8822) while keeping the invoke-ai#9087 current-item
identifier redaction:

- SessionQueueStatus regains optional user_pending/user_in_progress.
- get_queue_status always computes GLOBAL aggregate counts and, when
  user_id is provided, additionally returns that user's own counts. The
  current item's identifiers are still gated to the owner/admin via a
  single get_current() snapshot (race-free, invoke-ai#9087 preserved).
- get_queue_item_ids no longer filters by user; ids carry no sensitive
  data and items are redacted at hydration by sanitize_queue_item_for_user,
  so non-admins again see partially-redacted entries for other users' jobs.
- QueueCountBadge renders "<own>/<global total>" in multiuser mode and the
  plain total for admins / single-user mode.
- Regenerate the frontend OpenAPI schema.

Aggregate counts are global but non-identifying (no session_id/batch_id/
params/graph), consistent with invoke-ai#9087's "counts remain global" decision.

Tests:
- Update the model-shape test to assert user_pending/user_in_progress exist.
- Add session-queue integration tests: global counts + per-user subcounts,
  admin/global omission of subcounts, current-item redaction with global
  counts intact, and get_queue_item_ids returning every user's ids.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(frontend): typegen + openapi

* chore(backend): ruff

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Eichhorn <alex@eichhorn.dev>
lstein added a commit that referenced this pull request Jul 6, 2026
* feat: Per-user workflow libraries in multiuser mode (#114)

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

* Restrict model sync to admin users only (#118)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>

* feat: distinct splash screens for admin/non-admin users in multiuser 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>

* Disable Save when editing another user's shared workflow in multiuser 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>

* chore(app): ruff

* Add board visibility (private/shared/public) feature with tests and UI

Co-authored-by: lstein <111189+lstein@users.noreply.github.com>

* Enforce read-only access for non-owners of shared/public boards in UI

Co-authored-by: lstein <111189+lstein@users.noreply.github.com>

* Fix remaining board access enforcement: invoke icon, drag-out, change-board filter, archive

Co-authored-by: lstein <111189+lstein@users.noreply.github.com>

* fix: allow drag from shared boards to non-board targets (viewer, ref 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>

* fix(security): add auth requirement to all sensitive routes in multimodal mode

* chore(backend): ruff

* Add workflow live update events

Stacked on top of origin PR #9018 (shared/private workflows and boards) for multiuser workflow visibility semantics.

* Add persisted call-saved-workflows node stub

* Add saved workflow picker state handling

* Clarify saved workflow missing state

* Validate selected saved workflows at runtime

* Generalize saved workflow picker into a field type

* Harden saved workflow field behavior

* Rename saved workflow node to singular

* Fix saved workflow frontend build issues

* Add dynamic saved workflow form fields

* Seed and handle dynamic workflow fields

* Align dynamic workflow fields with node styling

* Use standard templates for dynamic workflow fields

* Revert stale dynamic edge graph guard

* Preserve inbound edges when compatbile with a new selected workflow

* Add call saved workflow design document

* Clarify workflow return runtime design

* Add workflow return node contract

* Allow bounded recursive workflow calls

* fix (backend): improve user isolation for session queue and recall parameters

 - Sanitize session queue information of all cross-user fields except for the timestamps and status.
 - Recall parameters are now user-scoped.
 - Queue status endpoints now report user-scoped activity rather than global activity
 - Tests added:

  TestSessionQueueSanitization (4 tests):
  1. test_owner_sees_all_fields - Owner sees complete queue item data
  2. test_admin_sees_all_fields - Admin sees complete queue item data
  3. test_non_owner_sees_only_status_timestamps_errors -
     Non-owner sees only item_id, queue_id, status, and timestamps; everything else is redacted
  4. test_sanitization_does_not_mutate_original - Sanitization doesn't modify the original object

  TestRecallParametersIsolation (2 tests):

  5. test_user1_write_does_not_leak_to_user2 - User1's recall params are not visible in user2's client state
  6. test_two_users_independent_state - Both users can write recall params independently without overwriting each other

fix(backend): queue status endpoints report user-scoped stats rather than global stats

* fix(workflow): do not filter default workflows in multiuser mode

  Problem: When categories=['user', 'default'] (or no category filter)
  and user_id was set for multiuser scoping, the SQL query became
     WHERE category IN ('user', 'default') AND user_id = ?,
     which  excluded default workflows (owned by "system").

  Fix: Changed user_id = ? to (user_id = ? OR category = 'default') in
  all 6 occurrences across workflow_records_sqlite.py — in get_many,
  counts_by_category, counts_by_tag, and get_all_tags. Default
  workflows are now always visible regardless of user scoping.

  Tests added (2):
  - test_default_workflows_visible_when_listing_user_and_default — categories=['user','default'] includes both
  - test_default_workflows_visible_when_no_category_filter — no filter still shows defaults

* Fix frontend lint on workflow node fixtures

* Add workflow call runtime state scaffolding

* Add workflow call runner boundary handling

* Create child workflow execution state on call boundary

* Update workflow call runtime documentation

* Localize saved workflow node UI strings

* Execute saved workflow children inline

* chore: ruff

* Test inline saved workflow execution semantics

* Fix saved workflow lint and schema warnings

* Updated documentation

* Propagate workflow return values to callers

* Fix lazy If branch pruning and skipped-parent handling in graph runtime

* Extract workflow call coordinator

* Track workflow call lifecycle state

* Add workflow call queue item metadata

* Add workflow call queue item migration

* Queue child workflow executions and suspend parents

* Cascade workflow call cancel and root retry

* Refine workflow call queue UI and docs

* Harden workflow call queue API behavior

* Harden workflow call retry event delivery

* Extract workflow call runtime module

* Format workflow call queue migration

* Validate callable workflow compatibility

* Support direct batched child workflows

* Support generator-backed batched child workflows

* Split workflow call runtime tests

* Clarify workflow call batch semantics

* Expose callable workflow compatibility metadata

* Surface callable workflow compatibility in library UI

* Fix workflow call privacy and compatibility edge cases

* Harden workflow call queue boundary and cleanup semantics

* Harden workflow call cancel retry and batch edge cases

* Repaired database migration

* bring documentation back in line with the tested current state

* Harden saved workflow frontend compatibility state

* Treat waiting queue items as nonterminal in staging area

* chore: typegen

* Harden workflow call child propagation

* Tighten workflow call compatibility reporting

* Removed to-do items that have been done

* Update docs

* Documented `WorkflowCallQueueLifecycle` as an intentional bounded component

* Fix saved workflow selection fallback

* Fix workflow call startup cancellation

* Fix single-user workflow socket rooms

* Ignore stale saved workflow input payloads

* Fix saved workflow picker discovery

* Preserve workflow call batch field values

* chore: linting

* Harden workflow-call queue cancellation semantics

* Harden saved workflow caller UI state

* Avoid eager workflow compatibility expansion

* Fix workflow call README list nesting

* chore: mdformat

* Updated documentation

* Removed the anonymous collection from the workflow return production contract, fixed various bugs.

* chore: lint

* Remove noise in tests

* Bugfixes and optimization - workflow_return can now take a single or collection

* Bugfixes

* Fixed a persistence failure

* Fix saved workflow call field rehydration

* Preserve workflow call completion state on resume

* Update queue status from socket events

* Updated tests to match the current workflow-return contract

* Fix workflow child queue status isolation

* chore: typegen

* chore: typegen

* Fixed failing migration test

* Updated documentation to match schema

* Update call saved workflow design docs

* Renamed to follow current convention

* chore: typegen

* chore: openapi.json

* Fix tests

* chore: typegen

* chore: lint

* Fix workflow socket test async backend

* Test workflow call batch cancellation

* Address outstanding issues based on PR comments

* Test workflow call destination cancellation

Add the missing F2 regression test for destination-scoped bulk cancellation of workflow-call parent/child chains.

* Fix workflow call runtime test double

* chore: typegen/openapi

* chore: typegen/openapi

* Fixed database migration 33

* Update tests after queue status merge

* chore: lint

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
Co-authored-by: Lincoln Stein <lincoln.stein@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Eichhorn <alex@eichhorn.dev>
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