From d2227bec1912c8dccbfcfa6fc60bc086e16f09ef Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Mon, 13 Apr 2026 23:39:33 -0400 Subject: [PATCH 1/7] fix(multiuser): make preexisting workflows visible after migration 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) --- docs/multiuser/user_guide.md | 35 +++-- invokeai/app/api/routers/workflows.py | 12 +- .../app/services/shared/sqlite/sqlite_util.py | 2 + .../migrations/migration_30.py | 37 +++++ .../WorkflowLibrary/WorkflowListItem.tsx | 2 +- tests/app/routers/test_workflows_multiuser.py | 144 ++++++++++++++++++ tests/test_sqlite_migrator.py | 61 ++++++++ 7 files changed, 272 insertions(+), 21 deletions(-) create mode 100644 invokeai/app/services/shared/sqlite_migrator/migrations/migration_30.py diff --git a/docs/multiuser/user_guide.md b/docs/multiuser/user_guide.md index 9c950913de0..87587c599f1 100644 --- a/docs/multiuser/user_guide.md +++ b/docs/multiuser/user_guide.md @@ -140,13 +140,13 @@ As a regular user, you can: - ✅ View your own generation queue - ✅ Customize your UI preferences (theme, hotkeys, etc.) - ✅ View available models (read-only access to Model Manager) -- ✅ Access shared boards (based on permissions granted to you) (FUTURE FEATURE) -- ✅ Access workflows marked as public (FUTURE FEATURE) +- ✅ View shared and public boards created by other users +- ✅ View and use workflows marked as shared by other users You cannot: - ❌ Add, delete, or modify models -- ❌ View or modify other users' boards, images, or workflows +- ❌ View or modify other users' private boards, images, or workflows - ❌ Manage user accounts - ❌ Access system configuration - ❌ View or cancel other users' generation tasks @@ -173,7 +173,7 @@ Administrators have all regular user capabilities, plus: - ✅ Full model management (add, delete, configure models) - ✅ Create and manage user accounts - ✅ View and manage all users' generation queues -- ✅ Create and manage shared boards (FUTURE FEATURE) +- ✅ View and manage all users' boards, images, and workflows (including system-owned legacy content) - ✅ Access system configuration - ✅ Grant or revoke admin privileges @@ -183,23 +183,30 @@ Administrators have all regular user capabilities, plus: ### Image Boards -In multi-user model, Image Boards work as before. Each user can create an unlimited number of boards and organize their images and assets as they see fit. Boards are private: you cannot see a board owned by a different user. +In multi-user mode, each user can create an unlimited number of boards and organize their images and assets as they see fit. Boards have three visibility levels: -!!! tip "Shared Boards" - InvokeAI 6.13 will add support for creating public boards that are accessible to all users. +- **Private** (default): Only you (and administrators) can see and modify the board. +- **Shared**: All users can view the board and its contents, but only you (and administrators) can modify it (rename, archive, delete, or add/remove images). +- **Public**: All users can view the board. Only you (and administrators) can modify the board's structure (rename, archive, delete). -The Administrator can see all users Image Boards and their contents. +To change a board's visibility, right-click on the board and select the desired visibility option. -### Going From Multi-User to Single-User mode +Administrators can see and manage all users' image boards and their contents regardless of visibility settings. + +### Going From Multi-User to Single-User Mode If an InvokeAI instance was in multiuser mode and then restarted in single user mode (by setting `multiuser: false` in the configuration file), all users' boards will be consolidated in one place. Any images that were in "Uncategorized" will be merged together into a single Uncategorized board. If, at a later date, the server is restarted in multi-user mode, the boards and images will be separated and restored to their owners. ### Workflows -In the current released version (6.12) workflows are always shared among users. Any workflow that you create will be visible to other users and vice-versa, and there is no protection against one user modifying another user's workflow. +Each user has their own private workflow library. Workflows you create are visible only to you by default. + +You can share a workflow with other users by marking it as **shared** (public). Shared workflows appear in all users' workflow libraries and can be opened by anyone, but only the owner (or an administrator) can modify or delete them. + +To share a workflow, open it and use the sharing controls to toggle its public/shared status. -!!! tip "Private and Shared Workflows" - InvokeAI 6.13 will provide the ability to create private and shared workflows. A private workflow can only be viewed by the user who created it. At any time, however, the user can designate the workflow *shared*, in which case it can be opened on a read-only basis by all logged-in users. +!!! warning "Preexisting workflows after enabling multi-user mode" + When you enable multi-user mode for the first time on an existing InvokeAI installation, all workflows that were created before multi-user mode was activated will appear in the **shared workflows** section. These preexisting workflows are owned by the internal "system" account and are visible to all users. Administrators can edit or delete these shared legacy workflows. Regular users can view and use them but cannot modify them. ### The Generation Queue @@ -330,11 +337,11 @@ These settings are stored per-user and won't affect other users. ### Can other users see my images? -No, unless you add them to a shared board (FUTURE FEATURE). All your personal boards and images are private. +Not unless you change your board's visibility to "shared" or "public". All personal boards and images are private by default. ### Can I share my workflows with others? -Not directly. Ask your administrator to mark workflows as public if you want to share them. +Yes. You can mark any workflow as shared (public), which makes it visible to all users. Other users can view and use shared workflows, but only you or an administrator can modify or delete them. ### How long do sessions last? diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py index 1c88a77a3f6..12716993ee1 100644 --- a/invokeai/app/api/routers/workflows.py +++ b/invokeai/app/api/routers/workflows.py @@ -144,10 +144,10 @@ async def list_workflows( """Gets a page of workflows""" config = ApiDependencies.invoker.services.configuration - # In multiuser mode, scope user-category workflows to the current user unless fetching shared workflows + # In multiuser mode, scope user-category workflows to the current user unless fetching shared workflows. + # Admins skip the user_id filter so they can see and manage all workflows including system-owned ones. user_id_filter: Optional[str] = None - if config.multiuser: - # Only filter 'user' category results by user_id when not explicitly listing public workflows + if config.multiuser and not current_user.is_admin: has_user_category = not categories or WorkflowCategory.User in categories if has_user_category and is_public is not True: user_id_filter = current_user.user_id @@ -320,7 +320,7 @@ async def get_all_tags( """Gets all unique tags from workflows""" config = ApiDependencies.invoker.services.configuration user_id_filter: Optional[str] = None - if config.multiuser: + if config.multiuser and not current_user.is_admin: has_user_category = not categories or WorkflowCategory.User in categories if has_user_category and is_public is not True: user_id_filter = current_user.user_id @@ -341,7 +341,7 @@ async def get_counts_by_tag( """Counts workflows by tag""" config = ApiDependencies.invoker.services.configuration user_id_filter: Optional[str] = None - if config.multiuser: + if config.multiuser and not current_user.is_admin: has_user_category = not categories or WorkflowCategory.User in categories if has_user_category and is_public is not True: user_id_filter = current_user.user_id @@ -361,7 +361,7 @@ async def counts_by_category( """Counts workflows by category""" config = ApiDependencies.invoker.services.configuration user_id_filter: Optional[str] = None - if config.multiuser: + if config.multiuser and not current_user.is_admin: has_user_category = WorkflowCategory.User in categories if has_user_category and is_public is not True: user_id_filter = current_user.user_id diff --git a/invokeai/app/services/shared/sqlite/sqlite_util.py b/invokeai/app/services/shared/sqlite/sqlite_util.py index fb8ca9fca38..19e3b897202 100644 --- a/invokeai/app/services/shared/sqlite/sqlite_util.py +++ b/invokeai/app/services/shared/sqlite/sqlite_util.py @@ -32,6 +32,7 @@ from invokeai.app.services.shared.sqlite_migrator.migrations.migration_27 import build_migration_27 from invokeai.app.services.shared.sqlite_migrator.migrations.migration_28 import build_migration_28 from invokeai.app.services.shared.sqlite_migrator.migrations.migration_29 import build_migration_29 +from invokeai.app.services.shared.sqlite_migrator.migrations.migration_30 import build_migration_30 from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import SqliteMigrator @@ -81,6 +82,7 @@ def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileSto migrator.register_migration(build_migration_27()) migrator.register_migration(build_migration_28()) migrator.register_migration(build_migration_29()) + migrator.register_migration(build_migration_30()) migrator.run_migrations() return db diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_30.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_30.py new file mode 100644 index 00000000000..ad4d3bbd5ff --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_30.py @@ -0,0 +1,37 @@ +"""Migration 30: Make preexisting system-owned workflows public. + +Migration 28 added user_id and is_public columns to workflow_library, but +assigned preexisting workflows user_id='system' with is_public=FALSE. This +caused those workflows to disappear from users' libraries because the query +filter scopes by user_id and excludes non-public workflows owned by other +users. This migration fixes the issue by marking all system-owned workflows +as public so they are visible to all users. +""" + +import sqlite3 + +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration + + +class Migration30Callback: + """Migration to make system-owned workflows publicly visible.""" + + def __call__(self, cursor: sqlite3.Cursor) -> None: + self._make_system_workflows_public(cursor) + + def _make_system_workflows_public(self, cursor: sqlite3.Cursor) -> None: + """Set is_public=TRUE for all workflows owned by the 'system' user.""" + cursor.execute("UPDATE workflow_library SET is_public = TRUE WHERE user_id = 'system';") + + +def build_migration_30() -> Migration: + """Builds the migration object for migrating from version 29 to version 30. + + This migration marks all preexisting system-owned workflows as public + so they remain visible to all users after the multiuser migration. + """ + return Migration( + from_version=29, + to_version=30, + callback=Migration30Callback(), + ) diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx index a184f04039a..1db0c9110b9 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx @@ -160,7 +160,7 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi )} - {isOwner && } + {canEditOrDelete && } {workflow.category === 'default' && } {workflow.category !== 'default' && ( <> diff --git a/tests/app/routers/test_workflows_multiuser.py b/tests/app/routers/test_workflows_multiuser.py index 28b301e18e3..8725a7571ad 100644 --- a/tests/app/routers/test_workflows_multiuser.py +++ b/tests/app/routers/test_workflows_multiuser.py @@ -332,3 +332,147 @@ def test_workflow_has_user_id_and_is_public_fields(client: TestClient, user1_tok assert "user_id" in data assert "is_public" in data assert data["is_public"] is False + + +# --------------------------------------------------------------------------- +# System-owned workflow visibility (regression tests for migration 30 fix) +# --------------------------------------------------------------------------- + + +def _insert_system_workflow(mock_invoker: Invoker, name: str = "Legacy Workflow", is_public: bool = True) -> str: + """Insert a workflow owned by 'system' directly via the service layer, then set is_public.""" + from invokeai.app.services.workflow_records.workflow_records_common import WorkflowWithoutID + + wf = WorkflowWithoutID(**{**WORKFLOW_BODY, "name": name}) + record = mock_invoker.services.workflow_records.create(workflow=wf, user_id="system") + if is_public: + mock_invoker.services.workflow_records.update_is_public(workflow_id=record.workflow_id, is_public=True) + return record.workflow_id + + +def test_system_public_workflow_visible_in_shared_listing(client: TestClient, user1_token: str, mock_invoker: Invoker): + """After migration 30, system-owned public workflows should appear in the shared workflows listing.""" + wf_id = _insert_system_workflow(mock_invoker, "Legacy Workflow") + + response = client.get( + "/api/v1/workflows/?categories=user&is_public=true", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert response.status_code == 200 + ids = [w["workflow_id"] for w in response.json()["items"]] + assert wf_id in ids + + +def test_system_public_workflow_not_in_your_workflows(client: TestClient, user1_token: str, mock_invoker: Invoker): + """System-owned public workflows should NOT appear in 'Your Workflows' listing.""" + wf_id = _insert_system_workflow(mock_invoker, "Legacy Workflow") + + response = client.get( + "/api/v1/workflows/?categories=user", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert response.status_code == 200 + ids = [w["workflow_id"] for w in response.json()["items"]] + assert wf_id not in ids + + +def test_system_private_workflow_not_visible_to_regular_user( + client: TestClient, user1_token: str, mock_invoker: Invoker +): + """A system-owned workflow that is still private should NOT appear for regular users in any listing.""" + wf_id = _insert_system_workflow(mock_invoker, "Private Legacy", is_public=False) + + # Not in "Your Workflows" + response = client.get( + "/api/v1/workflows/?categories=user", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert response.status_code == 200 + ids = [w["workflow_id"] for w in response.json()["items"]] + assert wf_id not in ids + + # Not in "Shared Workflows" either + response = client.get( + "/api/v1/workflows/?categories=user&is_public=true", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert response.status_code == 200 + ids = [w["workflow_id"] for w in response.json()["items"]] + assert wf_id not in ids + + +def test_admin_can_list_system_workflows(client: TestClient, admin_token: str, mock_invoker: Invoker): + """Admins should see system-owned workflows in their listing.""" + wf_id = _insert_system_workflow(mock_invoker, "Admin Visible Workflow") + + response = client.get( + "/api/v1/workflows/?categories=user", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + ids = [w["workflow_id"] for w in response.json()["items"]] + assert wf_id in ids + + +def test_admin_can_update_system_workflow(client: TestClient, admin_token: str, mock_invoker: Invoker): + """Admins should be able to update a system-owned workflow.""" + wf_id = _insert_system_workflow(mock_invoker, "Editable Legacy") + + # Get the full workflow to update it + get_resp = client.get( + f"/api/v1/workflows/i/{wf_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert get_resp.status_code == 200 + workflow_data = get_resp.json()["workflow"] + workflow_data["name"] = "Updated by Admin" + + update_resp = client.patch( + f"/api/v1/workflows/i/{wf_id}", + json={"workflow": workflow_data}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert update_resp.status_code == 200 + assert update_resp.json()["workflow"]["name"] == "Updated by Admin" + + +def test_admin_can_delete_system_workflow(client: TestClient, admin_token: str, mock_invoker: Invoker): + """Admins should be able to delete a system-owned workflow.""" + wf_id = _insert_system_workflow(mock_invoker, "Deletable Legacy") + + response = client.delete( + f"/api/v1/workflows/i/{wf_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + + +def test_regular_user_cannot_update_system_workflow(client: TestClient, user1_token: str, mock_invoker: Invoker): + """Regular users should NOT be able to update a system-owned workflow.""" + wf_id = _insert_system_workflow(mock_invoker, "Protected Legacy") + + get_resp = client.get( + f"/api/v1/workflows/i/{wf_id}", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert get_resp.status_code == 200 + workflow_data = get_resp.json()["workflow"] + workflow_data["name"] = "Hijacked" + + update_resp = client.patch( + f"/api/v1/workflows/i/{wf_id}", + json={"workflow": workflow_data}, + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert update_resp.status_code == status.HTTP_403_FORBIDDEN + + +def test_regular_user_cannot_delete_system_workflow(client: TestClient, user1_token: str, mock_invoker: Invoker): + """Regular users should NOT be able to delete a system-owned workflow.""" + wf_id = _insert_system_workflow(mock_invoker, "Undeletable Legacy") + + response = client.delete( + f"/api/v1/workflows/i/{wf_id}", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/tests/test_sqlite_migrator.py b/tests/test_sqlite_migrator.py index e03224b5a9a..4420bfe0c00 100644 --- a/tests/test_sqlite_migrator.py +++ b/tests/test_sqlite_migrator.py @@ -468,3 +468,64 @@ def test_migration_27_without_client_state_data_column(logger: Logger) -> None: assert cursor.fetchone()[0] == 0 db._conn.close() + + +def test_migration_30_makes_system_workflows_public(logger: Logger) -> None: + """Test that migration 30 sets is_public=TRUE for all system-owned workflows.""" + from invokeai.app.services.shared.sqlite_migrator.migrations.migration_28 import Migration28Callback + from invokeai.app.services.shared.sqlite_migrator.migrations.migration_30 import Migration30Callback + + db = SqliteDatabase(db_path=None, logger=logger, verbose=False) + cursor = db._conn.cursor() + + # Create workflow_library table as it would exist before migration 28 + cursor.execute(""" + CREATE TABLE workflow_library ( + workflow_id TEXT PRIMARY KEY, + workflow TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP), + updated_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP), + opened_at DATETIME, + category TEXT NOT NULL DEFAULT 'user', + tags TEXT NOT NULL DEFAULT '' + ); + """) + + # Insert preexisting workflows (simulating data from before multiuser) + cursor.execute("INSERT INTO workflow_library (workflow_id, name) VALUES ('wf-1', 'Old Workflow 1');") + cursor.execute("INSERT INTO workflow_library (workflow_id, name) VALUES ('wf-2', 'Old Workflow 2');") + db._conn.commit() + + # Run migration 28 — adds user_id='system' and is_public=FALSE + Migration28Callback()(cursor) + db._conn.commit() + + # Verify migration 28 state: system-owned and private + cursor.execute("SELECT user_id, is_public FROM workflow_library WHERE workflow_id = 'wf-1';") + row = cursor.fetchone() + assert row[0] == "system" + assert row[1] == 0 # FALSE + + # Insert a non-system workflow (created after migration 28) + cursor.execute( + "INSERT INTO workflow_library (workflow_id, name, user_id, is_public) VALUES ('wf-3', 'New Workflow', 'user-123', 0);" + ) + db._conn.commit() + + # Run migration 30 + Migration30Callback()(cursor) + db._conn.commit() + + # System-owned workflows should now be public + cursor.execute("SELECT is_public FROM workflow_library WHERE workflow_id = 'wf-1';") + assert cursor.fetchone()[0] == 1 # TRUE + + cursor.execute("SELECT is_public FROM workflow_library WHERE workflow_id = 'wf-2';") + assert cursor.fetchone()[0] == 1 # TRUE + + # Non-system workflow should remain private + cursor.execute("SELECT is_public FROM workflow_library WHERE workflow_id = 'wf-3';") + assert cursor.fetchone()[0] == 0 # FALSE + + db._conn.close() From 0549b12d9668d6d831cfeafc7ee795f999d4eccc Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Tue, 14 Apr 2026 08:28:23 -0400 Subject: [PATCH 2/7] fix(migration): when migrating, make workflows public by default --- .../services/shared/sqlite_migrator/migrations/migration_28.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_28.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_28.py index 0cbd683ab5e..c56899769ef 100644 --- a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_28.py +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_28.py @@ -27,7 +27,7 @@ def _update_workflow_library_table(self, cursor: sqlite3.Cursor) -> None: cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflow_library_user_id ON workflow_library(user_id);") if "is_public" not in columns: - cursor.execute("ALTER TABLE workflow_library ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;") + cursor.execute("ALTER TABLE workflow_library ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT TRUE;") cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflow_library_is_public ON workflow_library(is_public);") From 59ac8e7e974721d6192830241122fd14097a78f8 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Tue, 14 Apr 2026 08:51:46 -0400 Subject: [PATCH 3/7] chore(tests): fix tests to conform to new policies on system-owned workflows --- tests/app/routers/test_workflows_multiuser.py | 27 +------------------ tests/test_sqlite_migrator.py | 1 - 2 files changed, 1 insertion(+), 27 deletions(-) diff --git a/tests/app/routers/test_workflows_multiuser.py b/tests/app/routers/test_workflows_multiuser.py index 8725a7571ad..1954fd5ca16 100644 --- a/tests/app/routers/test_workflows_multiuser.py +++ b/tests/app/routers/test_workflows_multiuser.py @@ -331,7 +331,7 @@ def test_workflow_has_user_id_and_is_public_fields(client: TestClient, user1_tok data = response.json() assert "user_id" in data assert "is_public" in data - assert data["is_public"] is False + assert data["is_public"] is True # --------------------------------------------------------------------------- @@ -376,31 +376,6 @@ def test_system_public_workflow_not_in_your_workflows(client: TestClient, user1_ assert wf_id not in ids -def test_system_private_workflow_not_visible_to_regular_user( - client: TestClient, user1_token: str, mock_invoker: Invoker -): - """A system-owned workflow that is still private should NOT appear for regular users in any listing.""" - wf_id = _insert_system_workflow(mock_invoker, "Private Legacy", is_public=False) - - # Not in "Your Workflows" - response = client.get( - "/api/v1/workflows/?categories=user", - headers={"Authorization": f"Bearer {user1_token}"}, - ) - assert response.status_code == 200 - ids = [w["workflow_id"] for w in response.json()["items"]] - assert wf_id not in ids - - # Not in "Shared Workflows" either - response = client.get( - "/api/v1/workflows/?categories=user&is_public=true", - headers={"Authorization": f"Bearer {user1_token}"}, - ) - assert response.status_code == 200 - ids = [w["workflow_id"] for w in response.json()["items"]] - assert wf_id not in ids - - def test_admin_can_list_system_workflows(client: TestClient, admin_token: str, mock_invoker: Invoker): """Admins should see system-owned workflows in their listing.""" wf_id = _insert_system_workflow(mock_invoker, "Admin Visible Workflow") diff --git a/tests/test_sqlite_migrator.py b/tests/test_sqlite_migrator.py index 4420bfe0c00..c12699b2e16 100644 --- a/tests/test_sqlite_migrator.py +++ b/tests/test_sqlite_migrator.py @@ -505,7 +505,6 @@ def test_migration_30_makes_system_workflows_public(logger: Logger) -> None: cursor.execute("SELECT user_id, is_public FROM workflow_library WHERE workflow_id = 'wf-1';") row = cursor.fetchone() assert row[0] == "system" - assert row[1] == 0 # FALSE # Insert a non-system workflow (created after migration 28) cursor.execute( From 18c98935c6a3b6c79dc09a3e75a87dc90f990574 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Tue, 14 Apr 2026 09:03:37 -0400 Subject: [PATCH 4/7] fix(migration): remove migration_30 --- .../app/services/shared/sqlite/sqlite_util.py | 2 - .../migrations/migration_30.py | 37 ------------ tests/test_sqlite_migrator.py | 59 ------------------- 3 files changed, 98 deletions(-) delete mode 100644 invokeai/app/services/shared/sqlite_migrator/migrations/migration_30.py diff --git a/invokeai/app/services/shared/sqlite/sqlite_util.py b/invokeai/app/services/shared/sqlite/sqlite_util.py index 19e3b897202..fb8ca9fca38 100644 --- a/invokeai/app/services/shared/sqlite/sqlite_util.py +++ b/invokeai/app/services/shared/sqlite/sqlite_util.py @@ -32,7 +32,6 @@ from invokeai.app.services.shared.sqlite_migrator.migrations.migration_27 import build_migration_27 from invokeai.app.services.shared.sqlite_migrator.migrations.migration_28 import build_migration_28 from invokeai.app.services.shared.sqlite_migrator.migrations.migration_29 import build_migration_29 -from invokeai.app.services.shared.sqlite_migrator.migrations.migration_30 import build_migration_30 from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import SqliteMigrator @@ -82,7 +81,6 @@ def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileSto migrator.register_migration(build_migration_27()) migrator.register_migration(build_migration_28()) migrator.register_migration(build_migration_29()) - migrator.register_migration(build_migration_30()) migrator.run_migrations() return db diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_30.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_30.py deleted file mode 100644 index ad4d3bbd5ff..00000000000 --- a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_30.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Migration 30: Make preexisting system-owned workflows public. - -Migration 28 added user_id and is_public columns to workflow_library, but -assigned preexisting workflows user_id='system' with is_public=FALSE. This -caused those workflows to disappear from users' libraries because the query -filter scopes by user_id and excludes non-public workflows owned by other -users. This migration fixes the issue by marking all system-owned workflows -as public so they are visible to all users. -""" - -import sqlite3 - -from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration - - -class Migration30Callback: - """Migration to make system-owned workflows publicly visible.""" - - def __call__(self, cursor: sqlite3.Cursor) -> None: - self._make_system_workflows_public(cursor) - - def _make_system_workflows_public(self, cursor: sqlite3.Cursor) -> None: - """Set is_public=TRUE for all workflows owned by the 'system' user.""" - cursor.execute("UPDATE workflow_library SET is_public = TRUE WHERE user_id = 'system';") - - -def build_migration_30() -> Migration: - """Builds the migration object for migrating from version 29 to version 30. - - This migration marks all preexisting system-owned workflows as public - so they remain visible to all users after the multiuser migration. - """ - return Migration( - from_version=29, - to_version=30, - callback=Migration30Callback(), - ) diff --git a/tests/test_sqlite_migrator.py b/tests/test_sqlite_migrator.py index c12699b2e16..20c123b213c 100644 --- a/tests/test_sqlite_migrator.py +++ b/tests/test_sqlite_migrator.py @@ -469,62 +469,3 @@ def test_migration_27_without_client_state_data_column(logger: Logger) -> None: db._conn.close() - -def test_migration_30_makes_system_workflows_public(logger: Logger) -> None: - """Test that migration 30 sets is_public=TRUE for all system-owned workflows.""" - from invokeai.app.services.shared.sqlite_migrator.migrations.migration_28 import Migration28Callback - from invokeai.app.services.shared.sqlite_migrator.migrations.migration_30 import Migration30Callback - - db = SqliteDatabase(db_path=None, logger=logger, verbose=False) - cursor = db._conn.cursor() - - # Create workflow_library table as it would exist before migration 28 - cursor.execute(""" - CREATE TABLE workflow_library ( - workflow_id TEXT PRIMARY KEY, - workflow TEXT NOT NULL DEFAULT '', - name TEXT NOT NULL DEFAULT '', - created_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP), - updated_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP), - opened_at DATETIME, - category TEXT NOT NULL DEFAULT 'user', - tags TEXT NOT NULL DEFAULT '' - ); - """) - - # Insert preexisting workflows (simulating data from before multiuser) - cursor.execute("INSERT INTO workflow_library (workflow_id, name) VALUES ('wf-1', 'Old Workflow 1');") - cursor.execute("INSERT INTO workflow_library (workflow_id, name) VALUES ('wf-2', 'Old Workflow 2');") - db._conn.commit() - - # Run migration 28 — adds user_id='system' and is_public=FALSE - Migration28Callback()(cursor) - db._conn.commit() - - # Verify migration 28 state: system-owned and private - cursor.execute("SELECT user_id, is_public FROM workflow_library WHERE workflow_id = 'wf-1';") - row = cursor.fetchone() - assert row[0] == "system" - - # Insert a non-system workflow (created after migration 28) - cursor.execute( - "INSERT INTO workflow_library (workflow_id, name, user_id, is_public) VALUES ('wf-3', 'New Workflow', 'user-123', 0);" - ) - db._conn.commit() - - # Run migration 30 - Migration30Callback()(cursor) - db._conn.commit() - - # System-owned workflows should now be public - cursor.execute("SELECT is_public FROM workflow_library WHERE workflow_id = 'wf-1';") - assert cursor.fetchone()[0] == 1 # TRUE - - cursor.execute("SELECT is_public FROM workflow_library WHERE workflow_id = 'wf-2';") - assert cursor.fetchone()[0] == 1 # TRUE - - # Non-system workflow should remain private - cursor.execute("SELECT is_public FROM workflow_library WHERE workflow_id = 'wf-3';") - assert cursor.fetchone()[0] == 0 # FALSE - - db._conn.close() From 749ffd65a9c63b0f4a9e87f204a67da98685fd8d Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Tue, 14 Apr 2026 09:25:05 -0400 Subject: [PATCH 5/7] chore: ruff --- tests/test_sqlite_migrator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_sqlite_migrator.py b/tests/test_sqlite_migrator.py index 20c123b213c..e03224b5a9a 100644 --- a/tests/test_sqlite_migrator.py +++ b/tests/test_sqlite_migrator.py @@ -468,4 +468,3 @@ def test_migration_27_without_client_state_data_column(logger: Logger) -> None: assert cursor.fetchone()[0] == 0 db._conn.close() - From d669e3637551f9a671a63be75172970e884fb9da Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Tue, 14 Apr 2026 10:09:40 -0400 Subject: [PATCH 6/7] fix(frontend): suppress workflow sharing features in single-user mode --- .../sqlite_migrator/migrations/migration_28.py | 5 ++++- .../WorkflowLibrary/WorkflowLibrarySideNav.tsx | 7 ++++++- .../workflow/WorkflowLibrary/WorkflowListItem.tsx | 12 +++++++++--- .../components/SaveWorkflowAsDialog.tsx | 13 +++++++++---- tests/app/routers/test_workflows_multiuser.py | 2 +- 5 files changed, 29 insertions(+), 10 deletions(-) diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_28.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_28.py index c56899769ef..60e5d8f19bf 100644 --- a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_28.py +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_28.py @@ -27,8 +27,11 @@ def _update_workflow_library_table(self, cursor: sqlite3.Cursor) -> None: cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflow_library_user_id ON workflow_library(user_id);") if "is_public" not in columns: - cursor.execute("ALTER TABLE workflow_library ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT TRUE;") + cursor.execute("ALTER TABLE workflow_library ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;") cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflow_library_is_public ON workflow_library(is_public);") + cursor.execute( + "UPDATE workflow_library SET is_public = TRUE WHERE user_id = 'system';" + ) # one-time fix for legacy workflows def build_migration_28() -> Migration: diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx index 501b8365db5..e01aed95a79 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx @@ -31,17 +31,22 @@ import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold, PiStarFill } from 'react-icons/pi'; import { useDispatch } from 'react-redux'; +import { useGetSetupStatusQuery } from 'services/api/endpoints/auth'; import { useGetAllTagsQuery, useGetCountsByTagQuery } from 'services/api/endpoints/workflows'; export const WorkflowLibrarySideNav = () => { const { t } = useTranslation(); + const { data: setupStatus } = useGetSetupStatusQuery(); + const multiuserEnabled = setupStatus?.multiuser_enabled ?? false; return ( {t('workflows.recentlyOpened')} - {t('workflows.sharedWorkflows')} + {multiuserEnabled && ( + {t('workflows.sharedWorkflows')} + )} diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx index 1db0c9110b9..3291d75f59e 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx @@ -9,6 +9,7 @@ import InvokeLogo from 'public/assets/images/invoke-symbol-wht-lrg.svg'; import { type ChangeEvent, memo, type MouseEvent, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiImage } from 'react-icons/pi'; +import { useGetSetupStatusQuery } from 'services/api/endpoints/auth'; import { useUpdateWorkflowIsPublicMutation } from 'services/api/endpoints/workflows'; import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types'; @@ -36,6 +37,7 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi const dispatch = useAppDispatch(); const workflowId = useAppSelector(selectWorkflowId); const currentUser = useAppSelector(selectCurrentUser); + const { data: setupStatus } = useGetSetupStatusQuery(); const loadWorkflowWithDialog = useLoadWorkflowWithDialog(); const isActive = useMemo(() => { @@ -47,8 +49,12 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi }, [currentUser, workflow.user_id]); const canEditOrDelete = useMemo(() => { + // In single-user (legacy) mode, all workflows are editable — no concept of ownership. + if (!setupStatus?.multiuser_enabled) { + return true; + } return isOwner || (currentUser?.is_admin ?? false); - }, [isOwner, currentUser]); + }, [setupStatus?.multiuser_enabled, isOwner, currentUser]); const tags = useMemo(() => { if (!workflow.tags) { @@ -113,7 +119,7 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi {t('workflows.opened')} )} - {workflow.is_public && workflow.category !== 'default' && ( + {setupStatus?.multiuser_enabled && workflow.is_public && workflow.category !== 'default' && ( )} - {canEditOrDelete && } + {setupStatus?.multiuser_enabled && canEditOrDelete && } {workflow.category === 'default' && } {workflow.category !== 'default' && ( <> diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx index 1637cf56781..9e0f01ae365 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx +++ b/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx @@ -20,6 +20,7 @@ import { t } from 'i18next'; import { atom, computed } from 'nanostores'; import type { ChangeEvent, RefObject } from 'react'; import { memo, useCallback, useRef, useState } from 'react'; +import { useGetSetupStatusQuery } from 'services/api/endpoints/auth'; import { useUpdateWorkflowIsPublicMutation } from 'services/api/endpoints/workflows'; import { assert } from 'tsafe'; @@ -90,6 +91,8 @@ const Content = memo(({ workflow, cancelRef }: { workflow: WorkflowV3; cancelRef return ''; }); const [isPublic, setIsPublic] = useState(false); + const { data: setupStatus } = useGetSetupStatusQuery(); + const multiuserEnabled = setupStatus?.multiuser_enabled ?? false; const { createNewWorkflow } = useCreateLibraryWorkflow(); const [updateIsPublic] = useUpdateWorkflowIsPublicMutation(); @@ -143,10 +146,12 @@ const Content = memo(({ workflow, cancelRef }: { workflow: WorkflowV3; cancelRef {t('workflows.workflowName')} - - - {t('workflows.shareWorkflow')} - + {multiuserEnabled && ( + + + {t('workflows.shareWorkflow')} + + )} diff --git a/tests/app/routers/test_workflows_multiuser.py b/tests/app/routers/test_workflows_multiuser.py index 1954fd5ca16..1b6fba5e540 100644 --- a/tests/app/routers/test_workflows_multiuser.py +++ b/tests/app/routers/test_workflows_multiuser.py @@ -331,7 +331,7 @@ def test_workflow_has_user_id_and_is_public_fields(client: TestClient, user1_tok data = response.json() assert "user_id" in data assert "is_public" in data - assert data["is_public"] is True + assert data["is_public"] is False # --------------------------------------------------------------------------- From 2191959868dcdbcffd207b57714adedd9efe13f1 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Tue, 14 Apr 2026 10:30:35 -0400 Subject: [PATCH 7/7] fix(workflows): new single-user mode workflows become shared in multiuser mode --- invokeai/app/api/routers/workflows.py | 8 +++- .../workflow_records/workflow_records_base.py | 7 +++- .../workflow_records_sqlite.py | 14 +++++-- tests/app/routers/test_workflows_multiuser.py | 42 +++++++++++++++++++ 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py index 12716993ee1..eb893251953 100644 --- a/invokeai/app/api/routers/workflows.py +++ b/invokeai/app/api/routers/workflows.py @@ -117,7 +117,13 @@ async def create_workflow( workflow: WorkflowWithoutID = Body(description="The workflow to create", embed=True), ) -> WorkflowRecordDTO: """Creates a workflow""" - return ApiDependencies.invoker.services.workflow_records.create(workflow=workflow, user_id=current_user.user_id) + # In single-user mode, workflows are owned by 'system' and shared by default so all legacy/single-user + # workflows remain visible. In multiuser mode, workflows are private to the creator by default. + config = ApiDependencies.invoker.services.configuration + is_public = not config.multiuser + return ApiDependencies.invoker.services.workflow_records.create( + workflow=workflow, user_id=current_user.user_id, is_public=is_public + ) @workflows_router.get( diff --git a/invokeai/app/services/workflow_records/workflow_records_base.py b/invokeai/app/services/workflow_records/workflow_records_base.py index 856a6c6d490..c07daa2662e 100644 --- a/invokeai/app/services/workflow_records/workflow_records_base.py +++ b/invokeai/app/services/workflow_records/workflow_records_base.py @@ -23,7 +23,12 @@ def get(self, workflow_id: str) -> WorkflowRecordDTO: pass @abstractmethod - def create(self, workflow: WorkflowWithoutID, user_id: str = WORKFLOW_LIBRARY_DEFAULT_USER_ID) -> WorkflowRecordDTO: + def create( + self, + workflow: WorkflowWithoutID, + user_id: str = WORKFLOW_LIBRARY_DEFAULT_USER_ID, + is_public: bool = False, + ) -> WorkflowRecordDTO: """Creates a workflow.""" pass diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py index c83d87eff68..a62dbb9dfa8 100644 --- a/invokeai/app/services/workflow_records/workflow_records_sqlite.py +++ b/invokeai/app/services/workflow_records/workflow_records_sqlite.py @@ -48,7 +48,12 @@ def get(self, workflow_id: str) -> WorkflowRecordDTO: raise WorkflowNotFoundError(f"Workflow with id {workflow_id} not found") return WorkflowRecordDTO.from_dict(dict(row)) - def create(self, workflow: WorkflowWithoutID, user_id: str = WORKFLOW_LIBRARY_DEFAULT_USER_ID) -> WorkflowRecordDTO: + def create( + self, + workflow: WorkflowWithoutID, + user_id: str = WORKFLOW_LIBRARY_DEFAULT_USER_ID, + is_public: bool = False, + ) -> WorkflowRecordDTO: if workflow.meta.category is WorkflowCategory.Default: raise ValueError("Default workflows cannot be created via this method") @@ -59,11 +64,12 @@ def create(self, workflow: WorkflowWithoutID, user_id: str = WORKFLOW_LIBRARY_DE INSERT OR IGNORE INTO workflow_library ( workflow_id, workflow, - user_id + user_id, + is_public ) - VALUES (?, ?, ?); + VALUES (?, ?, ?, ?); """, - (workflow_with_id.id, workflow_with_id.model_dump_json(), user_id), + (workflow_with_id.id, workflow_with_id.model_dump_json(), user_id, is_public), ) return self.get(workflow_with_id.id) diff --git a/tests/app/routers/test_workflows_multiuser.py b/tests/app/routers/test_workflows_multiuser.py index 1b6fba5e540..fee3953dbb8 100644 --- a/tests/app/routers/test_workflows_multiuser.py +++ b/tests/app/routers/test_workflows_multiuser.py @@ -451,3 +451,45 @@ def test_regular_user_cannot_delete_system_workflow(client: TestClient, user1_to headers={"Authorization": f"Bearer {user1_token}"}, ) assert response.status_code == status.HTTP_403_FORBIDDEN + + +# --------------------------------------------------------------------------- +# Single-user mode: default ownership + sharing on create +# --------------------------------------------------------------------------- + + +@pytest.fixture +def single_user_mode(monkeypatch: Any, mock_invoker: Invoker): + """Configure the app for single-user (legacy) mode.""" + mock_invoker.services.configuration.multiuser = False + mock_workflow_thumbnails = MagicMock() + mock_workflow_thumbnails.get_url.return_value = None + mock_invoker.services.workflow_thumbnails = mock_workflow_thumbnails + + mock_deps = MockApiDependencies(mock_invoker) + monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", mock_deps) + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps) + monkeypatch.setattr("invokeai.app.api.routers.workflows.ApiDependencies", mock_deps) + yield + + +def test_single_user_create_workflow_owned_by_system_and_public(single_user_mode: Any, client: TestClient): + """In single-user mode, newly created workflows should be owned by 'system' and shared (is_public=True).""" + response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY}) + assert response.status_code == 200, response.text + payload = response.json() + assert payload["user_id"] == "system" + assert payload["is_public"] is True + + +def test_multiuser_create_workflow_owned_by_user_and_private(client: TestClient, user1_token: str): + """In multiuser mode, newly created workflows should be owned by the creator and private (is_public=False).""" + response = client.post( + "/api/v1/workflows/", + json={"workflow": WORKFLOW_BODY}, + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert response.status_code == 200, response.text + payload = response.json() + assert payload["user_id"] != "system" + assert payload["is_public"] is False