Skip to content

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

Open
lstein wants to merge 28 commits intoinvoke-ai:mainfrom
lstein:copilot/enhancement-allow-shared-boards
Open

Feature: Shared/private workflows and image boards in multiuser mode#9018
lstein wants to merge 28 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.

@lstein
Copy link
Copy Markdown
Collaborator Author

lstein commented Apr 6, 2026

  1. All of the board, image and workflow endpoints that potentially leak data from one user to another now do user ID and permissions checks. In addition, I found unprotected endpoints in the session queue and recall parameters routers. I had mistakenly believed that auth had been added to all the router endpoints in the original multiuser PR, but apparently just the data mutating routes were protected. Note that the URLs that return image binary data are not protected by auth because these use regular tags. However, you need to know the UUID of an image to get it, and the UUID listing endpoints are protected, so the image itself is protected by the unguessability of the UUID. Hope this is OK.
  2. Protected the thumbnail route.
  3. The admin email is now only exposed during the initial setup.

@JPPhoto
Copy link
Copy Markdown
Collaborator

JPPhoto commented Apr 7, 2026

Some issues still, maybe they're not a big deal but maybe they are:

  1. The board/image leak is still present because the actual board-image and image routes remain unauthenticated. In board_images.py, board_images.py, board_images.py, and board_images.py, there is still no CurrentUserOrDefault and no ownership/visibility check. The same is true for destructive and metadata-bearing image routes in images.py, images.py, images.py, images.py, and images.py. A non-owner can still take a shared board's image names from the allowed listing endpoint and then move/delete those images or pull their metadata/workflow directly.

  2. The workflow thumbnail route is still unprotected. The write-side thumbnail endpoints now check ownership, but GET /api/v1/workflows/i/{workflow_id}/thumbnail in workflows.py still takes no user context and performs no public/default/admin check before returning the file from workflows.py. That is the same private-thumbnail disclosure as before.

  3. The session queue still has unauthenticated full-item disclosure endpoints. GET /{queue_id}/current and GET /{queue_id}/next in session_queue.py and session_queue.py return raw SessionQueueItem objects without auth or sanitization. SessionQueueItem includes the full session graph, workflow, field values, and user identity fields in session_queue_common.py. So even after the partial queue hardening, an unauthenticated caller can still read in-flight job internals.

  4. The admin email is still exposed after initial setup. In auth.py, the route always resolves admin_email, and auth.py always returns it in multiuser mode. That does not match the "only during initial setup" behavior; once an admin exists, /api/v1/auth/status still reveals the first active admin's email to any unauthenticated caller.

  5. The recall-parameters router is still completely unauthenticated, including the mutating route. POST /api/v1/recall/{queue_id} in recall_parameters.py accepts arbitrary writes to client-state-backed recall values for any queue id and emits a frontend event, with no user binding at all. Even if the corresponding GET is intentionally minimal, the write path still lets one user overwrite another user's persisted recall state.

@lstein
Copy link
Copy Markdown
Collaborator Author

lstein commented Apr 7, 2026

Apparently I didn't push my commit last night. Sorry for the trouble.

@JPPhoto
Copy link
Copy Markdown
Collaborator

JPPhoto commented Apr 8, 2026

I left comments on Discord but I'll also put them here:

  • It looks like the session queue still leaks cross-user metadata even after the auth decorators were added.
  • sanitize_queue_item_for_user() only strips field_values, workflow, and the graph from session while leaving user_id, user_display_name, user_email, origin, destination, timestamps, and error fields intact.
  • Queue status endpoints still report global queue activity rather than user-scoped activity.
  • I think the tests need to cover non-owner reads of data as well as owner reads.
  • The Recall parameters router is authenticated now, but it is still not user-scoped. current_user is accepted and then ignored in both handlers.
  • Writes are only keyed by queue_id. Since the queue layer still has a shared DEFAULT_QUEUE_ID = "default", this is potentially problematic.

IMO, testing needs to also expand for cross-user isolation - maybe this can be done via parameterization even so your testing code changes are minimal.

@JPPhoto
Copy link
Copy Markdown
Collaborator

JPPhoto commented Apr 8, 2026

Another issue:

List scoping appears to be filtering out default workflows in multiuser mode.

In invokeai/app/api/routers/workflows.py, list_workflows() sets user_id_filter = current_user.user_id whenever user is among the requested categories and is_public is not True. That user_id_filter is then passed to workflow_records.get_many() for the entire query, not just the user category. Default workflows belong to system, so they are excluded when categories=['user', 'default'] is requested by a normal user.

The solution looks fairly straightforward:

  • Write a failing test.
  • In invokeai/app/api/routers/workflows.py, scope user_id_filter only to user workflows, while still allowing default workflows through in the same request.
  • If the storage layer cannot express category-specific filtering in one query, split the request or adjust workflow_records.get_many() so mixed-category queries do not apply user_id to default rows.
  • The test should pass.

JPPhoto added a commit to JPPhoto/InvokeAI that referenced this pull request Apr 8, 2026
Stacked on top of origin PR invoke-ai#9018 (shared/private workflows and boards) for multiuser workflow visibility semantics.
JPPhoto added a commit to JPPhoto/InvokeAI that referenced this pull request Apr 8, 2026
Stacked on top of origin PR invoke-ai#9018 (shared/private workflows and boards) for multiuser workflow visibility semantics.
JPPhoto and others added 2 commits April 9, 2026 14:11
…rameters

 - 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
@lstein lstein force-pushed the copilot/enhancement-allow-shared-boards branch from cbc03f4 to b86e289 Compare April 10, 2026 01:26
lstein added 2 commits April 9, 2026 21:31
  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
@JPPhoto
Copy link
Copy Markdown
Collaborator

JPPhoto commented Apr 10, 2026

Recall parameters are now stored per-user, but the update event is still broadcast only by queue_id, and the frontend applies it without any user or queue guard. The router writes state under current_user.user_id in recall_parameters.py, but then emits emit_recall_parameters_updated(queue_id, provided_params) in recall_parameters.py. That event carries only queue_id plus raw parameters in events_base.py and events_common.py, and the frontend listener applies incoming values directly into local state in setEventListeners.tsx. In a shared default queue, one user's recall update can still be pushed into another user's active UI session even though persistence is isolated.

Several read-only queue/image status endpoints still leak aggregate cross-user activity because they authenticate the caller but do not scope the returned data to that caller. get_queue_status() includes global counts and the globally current item_id/session_id/batch_id in session_queue.py, backed by all-row queries in session_queue_sqlite.py. get_batch_status() and counts_by_destination() are also still global in session_queue.py and session_queue.py, backed by unscoped queries in session_queue_sqlite.py. Separately, GET /api/v1/images/intermediates now requires auth but still returns the global intermediate-image count via images.py and images_default.py.

All of these cases need tests first, then fixes.

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