Skip to content

Commit d2227be

Browse files
lsteinclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 1b50c1a commit d2227be

File tree

7 files changed

+272
-21
lines changed

7 files changed

+272
-21
lines changed

docs/multiuser/user_guide.md

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -140,13 +140,13 @@ As a regular user, you can:
140140
- ✅ View your own generation queue
141141
- ✅ Customize your UI preferences (theme, hotkeys, etc.)
142142
- ✅ View available models (read-only access to Model Manager)
143-
- Access shared boards (based on permissions granted to you) (FUTURE FEATURE)
144-
- Access workflows marked as public (FUTURE FEATURE)
143+
- View shared and public boards created by other users
144+
- View and use workflows marked as shared by other users
145145

146146
You cannot:
147147

148148
- ❌ Add, delete, or modify models
149-
- ❌ View or modify other users' boards, images, or workflows
149+
- ❌ View or modify other users' private boards, images, or workflows
150150
- ❌ Manage user accounts
151151
- ❌ Access system configuration
152152
- ❌ View or cancel other users' generation tasks
@@ -173,7 +173,7 @@ Administrators have all regular user capabilities, plus:
173173
- ✅ Full model management (add, delete, configure models)
174174
- ✅ Create and manage user accounts
175175
- ✅ View and manage all users' generation queues
176-
- Create and manage shared boards (FUTURE FEATURE)
176+
- View and manage all users' boards, images, and workflows (including system-owned legacy content)
177177
- ✅ Access system configuration
178178
- ✅ Grant or revoke admin privileges
179179

@@ -183,23 +183,30 @@ Administrators have all regular user capabilities, plus:
183183

184184
### Image Boards
185185

186-
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.
186+
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:
187187

188-
!!! tip "Shared Boards"
189-
InvokeAI 6.13 will add support for creating public boards that are accessible to all users.
188+
- **Private** (default): Only you (and administrators) can see and modify the board.
189+
- **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).
190+
- **Public**: All users can view the board. Only you (and administrators) can modify the board's structure (rename, archive, delete).
190191

191-
The Administrator can see all users Image Boards and their contents.
192+
To change a board's visibility, right-click on the board and select the desired visibility option.
192193

193-
### Going From Multi-User to Single-User mode
194+
Administrators can see and manage all users' image boards and their contents regardless of visibility settings.
195+
196+
### Going From Multi-User to Single-User Mode
194197

195198
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.
196199

197200
### Workflows
198201

199-
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.
202+
Each user has their own private workflow library. Workflows you create are visible only to you by default.
203+
204+
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.
205+
206+
To share a workflow, open it and use the sharing controls to toggle its public/shared status.
200207

201-
!!! tip "Private and Shared Workflows"
202-
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.
208+
!!! warning "Preexisting workflows after enabling multi-user mode"
209+
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.
203210

204211

205212
### The Generation Queue
@@ -330,11 +337,11 @@ These settings are stored per-user and won't affect other users.
330337

331338
### Can other users see my images?
332339

333-
No, unless you add them to a shared board (FUTURE FEATURE). All your personal boards and images are private.
340+
Not unless you change your board's visibility to "shared" or "public". All personal boards and images are private by default.
334341

335342
### Can I share my workflows with others?
336343

337-
Not directly. Ask your administrator to mark workflows as public if you want to share them.
344+
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.
338345

339346
### How long do sessions last?
340347

invokeai/app/api/routers/workflows.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,10 @@ async def list_workflows(
144144
"""Gets a page of workflows"""
145145
config = ApiDependencies.invoker.services.configuration
146146

147-
# In multiuser mode, scope user-category workflows to the current user unless fetching shared workflows
147+
# In multiuser mode, scope user-category workflows to the current user unless fetching shared workflows.
148+
# Admins skip the user_id filter so they can see and manage all workflows including system-owned ones.
148149
user_id_filter: Optional[str] = None
149-
if config.multiuser:
150-
# Only filter 'user' category results by user_id when not explicitly listing public workflows
150+
if config.multiuser and not current_user.is_admin:
151151
has_user_category = not categories or WorkflowCategory.User in categories
152152
if has_user_category and is_public is not True:
153153
user_id_filter = current_user.user_id
@@ -320,7 +320,7 @@ async def get_all_tags(
320320
"""Gets all unique tags from workflows"""
321321
config = ApiDependencies.invoker.services.configuration
322322
user_id_filter: Optional[str] = None
323-
if config.multiuser:
323+
if config.multiuser and not current_user.is_admin:
324324
has_user_category = not categories or WorkflowCategory.User in categories
325325
if has_user_category and is_public is not True:
326326
user_id_filter = current_user.user_id
@@ -341,7 +341,7 @@ async def get_counts_by_tag(
341341
"""Counts workflows by tag"""
342342
config = ApiDependencies.invoker.services.configuration
343343
user_id_filter: Optional[str] = None
344-
if config.multiuser:
344+
if config.multiuser and not current_user.is_admin:
345345
has_user_category = not categories or WorkflowCategory.User in categories
346346
if has_user_category and is_public is not True:
347347
user_id_filter = current_user.user_id
@@ -361,7 +361,7 @@ async def counts_by_category(
361361
"""Counts workflows by category"""
362362
config = ApiDependencies.invoker.services.configuration
363363
user_id_filter: Optional[str] = None
364-
if config.multiuser:
364+
if config.multiuser and not current_user.is_admin:
365365
has_user_category = WorkflowCategory.User in categories
366366
if has_user_category and is_public is not True:
367367
user_id_filter = current_user.user_id

invokeai/app/services/shared/sqlite/sqlite_util.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_27 import build_migration_27
3333
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_28 import build_migration_28
3434
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_29 import build_migration_29
35+
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_30 import build_migration_30
3536
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import SqliteMigrator
3637

3738

@@ -81,6 +82,7 @@ def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileSto
8182
migrator.register_migration(build_migration_27())
8283
migrator.register_migration(build_migration_28())
8384
migrator.register_migration(build_migration_29())
85+
migrator.register_migration(build_migration_30())
8486
migrator.run_migrations()
8587

8688
return db
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Migration 30: Make preexisting system-owned workflows public.
2+
3+
Migration 28 added user_id and is_public columns to workflow_library, but
4+
assigned preexisting workflows user_id='system' with is_public=FALSE. This
5+
caused those workflows to disappear from users' libraries because the query
6+
filter scopes by user_id and excludes non-public workflows owned by other
7+
users. This migration fixes the issue by marking all system-owned workflows
8+
as public so they are visible to all users.
9+
"""
10+
11+
import sqlite3
12+
13+
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
14+
15+
16+
class Migration30Callback:
17+
"""Migration to make system-owned workflows publicly visible."""
18+
19+
def __call__(self, cursor: sqlite3.Cursor) -> None:
20+
self._make_system_workflows_public(cursor)
21+
22+
def _make_system_workflows_public(self, cursor: sqlite3.Cursor) -> None:
23+
"""Set is_public=TRUE for all workflows owned by the 'system' user."""
24+
cursor.execute("UPDATE workflow_library SET is_public = TRUE WHERE user_id = 'system';")
25+
26+
27+
def build_migration_30() -> Migration:
28+
"""Builds the migration object for migrating from version 29 to version 30.
29+
30+
This migration marks all preexisting system-owned workflows as public
31+
so they remain visible to all users after the multiuser migration.
32+
"""
33+
return Migration(
34+
from_version=29,
35+
to_version=30,
36+
callback=Migration30Callback(),
37+
)

invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
160160
</Text>
161161
)}
162162
<Spacer />
163-
{isOwner && <ShareWorkflowToggle workflow={workflow} />}
163+
{canEditOrDelete && <ShareWorkflowToggle workflow={workflow} />}
164164
{workflow.category === 'default' && <ViewWorkflow workflowId={workflow.workflow_id} />}
165165
{workflow.category !== 'default' && (
166166
<>

tests/app/routers/test_workflows_multiuser.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,3 +332,147 @@ def test_workflow_has_user_id_and_is_public_fields(client: TestClient, user1_tok
332332
assert "user_id" in data
333333
assert "is_public" in data
334334
assert data["is_public"] is False
335+
336+
337+
# ---------------------------------------------------------------------------
338+
# System-owned workflow visibility (regression tests for migration 30 fix)
339+
# ---------------------------------------------------------------------------
340+
341+
342+
def _insert_system_workflow(mock_invoker: Invoker, name: str = "Legacy Workflow", is_public: bool = True) -> str:
343+
"""Insert a workflow owned by 'system' directly via the service layer, then set is_public."""
344+
from invokeai.app.services.workflow_records.workflow_records_common import WorkflowWithoutID
345+
346+
wf = WorkflowWithoutID(**{**WORKFLOW_BODY, "name": name})
347+
record = mock_invoker.services.workflow_records.create(workflow=wf, user_id="system")
348+
if is_public:
349+
mock_invoker.services.workflow_records.update_is_public(workflow_id=record.workflow_id, is_public=True)
350+
return record.workflow_id
351+
352+
353+
def test_system_public_workflow_visible_in_shared_listing(client: TestClient, user1_token: str, mock_invoker: Invoker):
354+
"""After migration 30, system-owned public workflows should appear in the shared workflows listing."""
355+
wf_id = _insert_system_workflow(mock_invoker, "Legacy Workflow")
356+
357+
response = client.get(
358+
"/api/v1/workflows/?categories=user&is_public=true",
359+
headers={"Authorization": f"Bearer {user1_token}"},
360+
)
361+
assert response.status_code == 200
362+
ids = [w["workflow_id"] for w in response.json()["items"]]
363+
assert wf_id in ids
364+
365+
366+
def test_system_public_workflow_not_in_your_workflows(client: TestClient, user1_token: str, mock_invoker: Invoker):
367+
"""System-owned public workflows should NOT appear in 'Your Workflows' listing."""
368+
wf_id = _insert_system_workflow(mock_invoker, "Legacy Workflow")
369+
370+
response = client.get(
371+
"/api/v1/workflows/?categories=user",
372+
headers={"Authorization": f"Bearer {user1_token}"},
373+
)
374+
assert response.status_code == 200
375+
ids = [w["workflow_id"] for w in response.json()["items"]]
376+
assert wf_id not in ids
377+
378+
379+
def test_system_private_workflow_not_visible_to_regular_user(
380+
client: TestClient, user1_token: str, mock_invoker: Invoker
381+
):
382+
"""A system-owned workflow that is still private should NOT appear for regular users in any listing."""
383+
wf_id = _insert_system_workflow(mock_invoker, "Private Legacy", is_public=False)
384+
385+
# Not in "Your Workflows"
386+
response = client.get(
387+
"/api/v1/workflows/?categories=user",
388+
headers={"Authorization": f"Bearer {user1_token}"},
389+
)
390+
assert response.status_code == 200
391+
ids = [w["workflow_id"] for w in response.json()["items"]]
392+
assert wf_id not in ids
393+
394+
# Not in "Shared Workflows" either
395+
response = client.get(
396+
"/api/v1/workflows/?categories=user&is_public=true",
397+
headers={"Authorization": f"Bearer {user1_token}"},
398+
)
399+
assert response.status_code == 200
400+
ids = [w["workflow_id"] for w in response.json()["items"]]
401+
assert wf_id not in ids
402+
403+
404+
def test_admin_can_list_system_workflows(client: TestClient, admin_token: str, mock_invoker: Invoker):
405+
"""Admins should see system-owned workflows in their listing."""
406+
wf_id = _insert_system_workflow(mock_invoker, "Admin Visible Workflow")
407+
408+
response = client.get(
409+
"/api/v1/workflows/?categories=user",
410+
headers={"Authorization": f"Bearer {admin_token}"},
411+
)
412+
assert response.status_code == 200
413+
ids = [w["workflow_id"] for w in response.json()["items"]]
414+
assert wf_id in ids
415+
416+
417+
def test_admin_can_update_system_workflow(client: TestClient, admin_token: str, mock_invoker: Invoker):
418+
"""Admins should be able to update a system-owned workflow."""
419+
wf_id = _insert_system_workflow(mock_invoker, "Editable Legacy")
420+
421+
# Get the full workflow to update it
422+
get_resp = client.get(
423+
f"/api/v1/workflows/i/{wf_id}",
424+
headers={"Authorization": f"Bearer {admin_token}"},
425+
)
426+
assert get_resp.status_code == 200
427+
workflow_data = get_resp.json()["workflow"]
428+
workflow_data["name"] = "Updated by Admin"
429+
430+
update_resp = client.patch(
431+
f"/api/v1/workflows/i/{wf_id}",
432+
json={"workflow": workflow_data},
433+
headers={"Authorization": f"Bearer {admin_token}"},
434+
)
435+
assert update_resp.status_code == 200
436+
assert update_resp.json()["workflow"]["name"] == "Updated by Admin"
437+
438+
439+
def test_admin_can_delete_system_workflow(client: TestClient, admin_token: str, mock_invoker: Invoker):
440+
"""Admins should be able to delete a system-owned workflow."""
441+
wf_id = _insert_system_workflow(mock_invoker, "Deletable Legacy")
442+
443+
response = client.delete(
444+
f"/api/v1/workflows/i/{wf_id}",
445+
headers={"Authorization": f"Bearer {admin_token}"},
446+
)
447+
assert response.status_code == 200
448+
449+
450+
def test_regular_user_cannot_update_system_workflow(client: TestClient, user1_token: str, mock_invoker: Invoker):
451+
"""Regular users should NOT be able to update a system-owned workflow."""
452+
wf_id = _insert_system_workflow(mock_invoker, "Protected Legacy")
453+
454+
get_resp = client.get(
455+
f"/api/v1/workflows/i/{wf_id}",
456+
headers={"Authorization": f"Bearer {user1_token}"},
457+
)
458+
assert get_resp.status_code == 200
459+
workflow_data = get_resp.json()["workflow"]
460+
workflow_data["name"] = "Hijacked"
461+
462+
update_resp = client.patch(
463+
f"/api/v1/workflows/i/{wf_id}",
464+
json={"workflow": workflow_data},
465+
headers={"Authorization": f"Bearer {user1_token}"},
466+
)
467+
assert update_resp.status_code == status.HTTP_403_FORBIDDEN
468+
469+
470+
def test_regular_user_cannot_delete_system_workflow(client: TestClient, user1_token: str, mock_invoker: Invoker):
471+
"""Regular users should NOT be able to delete a system-owned workflow."""
472+
wf_id = _insert_system_workflow(mock_invoker, "Undeletable Legacy")
473+
474+
response = client.delete(
475+
f"/api/v1/workflows/i/{wf_id}",
476+
headers={"Authorization": f"Bearer {user1_token}"},
477+
)
478+
assert response.status_code == status.HTTP_403_FORBIDDEN

tests/test_sqlite_migrator.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,3 +468,64 @@ def test_migration_27_without_client_state_data_column(logger: Logger) -> None:
468468
assert cursor.fetchone()[0] == 0
469469

470470
db._conn.close()
471+
472+
473+
def test_migration_30_makes_system_workflows_public(logger: Logger) -> None:
474+
"""Test that migration 30 sets is_public=TRUE for all system-owned workflows."""
475+
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_28 import Migration28Callback
476+
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_30 import Migration30Callback
477+
478+
db = SqliteDatabase(db_path=None, logger=logger, verbose=False)
479+
cursor = db._conn.cursor()
480+
481+
# Create workflow_library table as it would exist before migration 28
482+
cursor.execute("""
483+
CREATE TABLE workflow_library (
484+
workflow_id TEXT PRIMARY KEY,
485+
workflow TEXT NOT NULL DEFAULT '',
486+
name TEXT NOT NULL DEFAULT '',
487+
created_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP),
488+
updated_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP),
489+
opened_at DATETIME,
490+
category TEXT NOT NULL DEFAULT 'user',
491+
tags TEXT NOT NULL DEFAULT ''
492+
);
493+
""")
494+
495+
# Insert preexisting workflows (simulating data from before multiuser)
496+
cursor.execute("INSERT INTO workflow_library (workflow_id, name) VALUES ('wf-1', 'Old Workflow 1');")
497+
cursor.execute("INSERT INTO workflow_library (workflow_id, name) VALUES ('wf-2', 'Old Workflow 2');")
498+
db._conn.commit()
499+
500+
# Run migration 28 — adds user_id='system' and is_public=FALSE
501+
Migration28Callback()(cursor)
502+
db._conn.commit()
503+
504+
# Verify migration 28 state: system-owned and private
505+
cursor.execute("SELECT user_id, is_public FROM workflow_library WHERE workflow_id = 'wf-1';")
506+
row = cursor.fetchone()
507+
assert row[0] == "system"
508+
assert row[1] == 0 # FALSE
509+
510+
# Insert a non-system workflow (created after migration 28)
511+
cursor.execute(
512+
"INSERT INTO workflow_library (workflow_id, name, user_id, is_public) VALUES ('wf-3', 'New Workflow', 'user-123', 0);"
513+
)
514+
db._conn.commit()
515+
516+
# Run migration 30
517+
Migration30Callback()(cursor)
518+
db._conn.commit()
519+
520+
# System-owned workflows should now be public
521+
cursor.execute("SELECT is_public FROM workflow_library WHERE workflow_id = 'wf-1';")
522+
assert cursor.fetchone()[0] == 1 # TRUE
523+
524+
cursor.execute("SELECT is_public FROM workflow_library WHERE workflow_id = 'wf-2';")
525+
assert cursor.fetchone()[0] == 1 # TRUE
526+
527+
# Non-system workflow should remain private
528+
cursor.execute("SELECT is_public FROM workflow_library WHERE workflow_id = 'wf-3';")
529+
assert cursor.fetchone()[0] == 0 # FALSE
530+
531+
db._conn.close()

0 commit comments

Comments
 (0)