Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
66ba32b
feat: Per-user workflow libraries in multiuser mode (#114)
Copilot Mar 6, 2026
c9f2e2d
Restrict model sync to admin users only (#118)
Copilot Mar 6, 2026
1ed9349
feat: distinct splash screens for admin/non-admin users in multiuser …
Copilot Mar 6, 2026
a4bbccd
Merge branch 'lstein/feature/multiuser-splash-screens' into lstein/fe…
lstein Mar 6, 2026
d965d60
Merge branch 'main' into lstein/feature/workflow-isolation-in-multius…
lstein Mar 6, 2026
b37bc8d
Disable Save when editing another user's shared workflow in multiuser…
Copilot Mar 6, 2026
298ce54
Merge branch 'main' into lstein/feature/workflow-isolation-in-multius…
lstein Mar 9, 2026
3dbc290
chore(app): ruff
lstein Mar 9, 2026
13faa0f
Add board visibility (private/shared/public) feature with tests and UI
Copilot Mar 9, 2026
f38d1ab
Enforce read-only access for non-owners of shared/public boards in UI
Copilot Mar 9, 2026
9f8f7a1
Fix remaining board access enforcement: invoke icon, drag-out, change…
Copilot Mar 10, 2026
74c293e
chore: merge and resolve conflicts
lstein Apr 4, 2026
3559a10
chore: merge with upstream
lstein Apr 4, 2026
f128121
fix: allow drag from shared boards to non-board targets (viewer, ref …
lstein Apr 4, 2026
b4276fd
Merge branch 'lstein/feature/workflow-isolation-in-multiuser-mode' in…
lstein Apr 4, 2026
0ecdeb7
Merge branch 'main' into lstein/feature/workflow-isolation-in-multius…
JPPhoto Apr 5, 2026
23ab8f5
Merge branch 'main' into copilot/enhancement-allow-shared-boards
JPPhoto Apr 5, 2026
ac4ef09
fix(security): add auth requirement to all sensitive routes in multim…
lstein Apr 6, 2026
24d0d38
Merge branch 'main' into copilot/enhancement-allow-shared-boards
lstein Apr 6, 2026
5ba03e9
Merge branch 'main' into copilot/enhancement-allow-shared-boards
JPPhoto Apr 7, 2026
915239b
Merge branch 'main' into copilot/enhancement-allow-shared-boards
JPPhoto Apr 7, 2026
61c884c
Merge remote-tracking branch 'refs/remotes/origin/copilot/enhancement…
lstein Apr 7, 2026
ac1f1a5
chore(backend): ruff
lstein Apr 7, 2026
ed45bd4
Merge branch 'main' into copilot/enhancement-allow-shared-boards
JPPhoto Apr 8, 2026
edd1258
Merge branch 'main' into copilot/enhancement-allow-shared-boards
JPPhoto Apr 9, 2026
b86e289
fix (backend): improve user isolation for session queue and recall pa…
lstein Apr 10, 2026
797638b
fix(workflow): do not filter default workflows in multiuser mode
lstein Apr 10, 2026
8f792fc
Merge branch 'main' into copilot/enhancement-allow-shared-boards
lstein Apr 10, 2026
c7eeb26
Merge branch 'main' into copilot/enhancement-allow-shared-boards
JPPhoto Apr 10, 2026
763944b
Merge branch 'main' into copilot/enhancement-allow-shared-boards
JPPhoto Apr 10, 2026
79de869
Merge branch 'lstein/feature/workflow-isolation-in-multiuser-mode' in…
lstein Apr 10, 2026
5ce3c17
fix(multiuser): scope queue/recall/intermediates endpoints to current…
lstein Apr 10, 2026
59d6d27
chore(backend): ruff
lstein Apr 10, 2026
ef082af
fix(multiuser): reject anonymous websockets and scope queue item events
lstein Apr 11, 2026
2403177
fix(multiuser): verify user record on websocket connect
lstein Apr 12, 2026
c7bce4f
Merge branch 'main' into copilot/enhancement-allow-shared-boards
JPPhoto Apr 12, 2026
494fc15
fix(multiuser): close bulk download cross-user exfiltration path
lstein Apr 12, 2026
8182c08
fix(multiuser): enforce board visibility on image listing endpoints
lstein Apr 12, 2026
5d589ab
chore(backend): ruff
lstein Apr 12, 2026
345d039
fix(multiuser): require image ownership when adding images to boards
lstein Apr 12, 2026
9703812
chore(backend): ruff
lstein Apr 12, 2026
9e7354d
fix(multiuser): validate image access in recall parameter resolution
lstein Apr 12, 2026
a6308b4
fix(multiuser): require admin auth on model install job endpoints
lstein Apr 12, 2026
58cb8aa
fix(multiuser): close bulk download exfiltration and additional revie…
lstein Apr 12, 2026
95c6e3c
fix(multiuser): add user_id scoping to workflow SQL mutations
lstein Apr 12, 2026
c19f040
fix(multiuser): allow non-owner uploads to public boards
lstein Apr 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions invokeai/app/api/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ class SetupStatusResponse(BaseModel):
setup_required: bool = Field(description="Whether initial setup is required")
multiuser_enabled: bool = Field(description="Whether multiuser mode is enabled")
strict_password_checking: bool = Field(description="Whether strict password requirements are enforced")
admin_email: str | None = Field(default=None, description="Email of the first active admin user, if any")


@auth_router.get("/status", response_model=SetupStatusResponse)
Expand All @@ -94,15 +95,25 @@ async def get_setup_status() -> SetupStatusResponse:
# If multiuser is disabled, setup is never required
if not config.multiuser:
return SetupStatusResponse(
setup_required=False, multiuser_enabled=False, strict_password_checking=config.strict_password_checking
setup_required=False,
multiuser_enabled=False,
strict_password_checking=config.strict_password_checking,
admin_email=None,
)

# In multiuser mode, check if an admin exists
user_service = ApiDependencies.invoker.services.users
setup_required = not user_service.has_admin()

# Only expose admin_email during initial setup to avoid leaking
# administrator identity on public deployments.
admin_email = user_service.get_admin_email() if setup_required else None

return SetupStatusResponse(
setup_required=setup_required, multiuser_enabled=True, strict_password_checking=config.strict_password_checking
setup_required=setup_required,
multiuser_enabled=True,
strict_password_checking=config.strict_password_checking,
admin_email=admin_email,
)


Expand Down
71 changes: 68 additions & 3 deletions invokeai/app/api/routers/board_images.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,53 @@
from fastapi import Body, HTTPException
from fastapi.routing import APIRouter

from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.images.images_common import AddImagesToBoardResult, RemoveImagesFromBoardResult

board_images_router = APIRouter(prefix="/v1/board_images", tags=["boards"])


def _assert_board_write_access(board_id: str, current_user: CurrentUserOrDefault) -> None:
"""Raise 403 if the current user may not mutate the given board.

Write access is granted when ANY of these hold:
- The user is an admin.
- The user owns the board.
- The board visibility is Public (public boards accept contributions from any user).
"""
from invokeai.app.services.board_records.board_records_common import BoardVisibility

try:
board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
except Exception:
raise HTTPException(status_code=404, detail="Board not found")
if current_user.is_admin:
return
if board.user_id == current_user.user_id:
return
if board.board_visibility == BoardVisibility.Public:
return
raise HTTPException(status_code=403, detail="Not authorized to modify this board")


def _assert_image_direct_owner(image_name: str, current_user: CurrentUserOrDefault) -> None:
"""Raise 403 if the current user is not the direct owner of the image.

This is intentionally stricter than _assert_image_owner in images.py:
board ownership is NOT sufficient here. Allowing a user to add someone
else's image to their own board would grant them mutation rights via the
board-ownership fallback in _assert_image_owner, escalating read access
into write access.
"""
if current_user.is_admin:
return
owner = ApiDependencies.invoker.services.image_records.get_user_id(image_name)
if owner is not None and owner == current_user.user_id:
return
raise HTTPException(status_code=403, detail="Not authorized to move this image")


@board_images_router.post(
"/",
operation_id="add_image_to_board",
Expand All @@ -17,14 +58,17 @@
response_model=AddImagesToBoardResult,
)
async def add_image_to_board(
current_user: CurrentUserOrDefault,
board_id: str = Body(description="The id of the board to add to"),
image_name: str = Body(description="The name of the image to add"),
) -> AddImagesToBoardResult:
"""Creates a board_image"""
_assert_board_write_access(board_id, current_user)
_assert_image_direct_owner(image_name, current_user)
try:
added_images: set[str] = set()
affected_boards: set[str] = set()
old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
old_board_id = ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name) or "none"
ApiDependencies.invoker.services.board_images.add_image_to_board(board_id=board_id, image_name=image_name)
added_images.add(image_name)
affected_boards.add(board_id)
Expand All @@ -48,13 +92,16 @@ async def add_image_to_board(
response_model=RemoveImagesFromBoardResult,
)
async def remove_image_from_board(
current_user: CurrentUserOrDefault,
image_name: str = Body(description="The name of the image to remove", embed=True),
) -> RemoveImagesFromBoardResult:
"""Removes an image from its board, if it had one"""
try:
old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
if old_board_id != "none":
_assert_board_write_access(old_board_id, current_user)
removed_images: set[str] = set()
affected_boards: set[str] = set()
old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name)
removed_images.add(image_name)
affected_boards.add("none")
Expand All @@ -64,6 +111,8 @@ async def remove_image_from_board(
affected_boards=list(affected_boards),
)

except HTTPException:
raise
except Exception:
raise HTTPException(status_code=500, detail="Failed to remove image from board")

Expand All @@ -78,16 +127,21 @@ async def remove_image_from_board(
response_model=AddImagesToBoardResult,
)
async def add_images_to_board(
current_user: CurrentUserOrDefault,
board_id: str = Body(description="The id of the board to add to"),
image_names: list[str] = Body(description="The names of the images to add", embed=True),
) -> AddImagesToBoardResult:
"""Adds a list of images to a board"""
_assert_board_write_access(board_id, current_user)
try:
added_images: set[str] = set()
affected_boards: set[str] = set()
for image_name in image_names:
try:
old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
_assert_image_direct_owner(image_name, current_user)
old_board_id = (
ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name) or "none"
)
ApiDependencies.invoker.services.board_images.add_image_to_board(
board_id=board_id,
image_name=image_name,
Expand All @@ -96,12 +150,16 @@ async def add_images_to_board(
affected_boards.add(board_id)
affected_boards.add(old_board_id)

except HTTPException:
raise
except Exception:
pass
return AddImagesToBoardResult(
added_images=list(added_images),
affected_boards=list(affected_boards),
)
except HTTPException:
raise
except Exception:
raise HTTPException(status_code=500, detail="Failed to add images to board")

Expand All @@ -116,6 +174,7 @@ async def add_images_to_board(
response_model=RemoveImagesFromBoardResult,
)
async def remove_images_from_board(
current_user: CurrentUserOrDefault,
image_names: list[str] = Body(description="The names of the images to remove", embed=True),
) -> RemoveImagesFromBoardResult:
"""Removes a list of images from their board, if they had one"""
Expand All @@ -125,15 +184,21 @@ async def remove_images_from_board(
for image_name in image_names:
try:
old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
if old_board_id != "none":
_assert_board_write_access(old_board_id, current_user)
ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name)
removed_images.add(image_name)
affected_boards.add("none")
affected_boards.add(old_board_id)
except HTTPException:
raise
except Exception:
pass
return RemoveImagesFromBoardResult(
removed_images=list(removed_images),
affected_boards=list(affected_boards),
)
except HTTPException:
raise
except Exception:
raise HTTPException(status_code=500, detail="Failed to remove images from board")
28 changes: 25 additions & 3 deletions invokeai/app/api/routers/boards.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy
from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy, BoardVisibility
from invokeai.app.services.boards.boards_common import BoardDTO
from invokeai.app.services.image_records.image_records_common import ImageCategory
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
Expand Down Expand Up @@ -56,7 +56,14 @@ async def get_board(
except Exception:
raise HTTPException(status_code=404, detail="Board not found")

if not current_user.is_admin and result.user_id != current_user.user_id:
# Admins can access any board.
# Owners can access their own boards.
# Shared and public boards are visible to all authenticated users.
if (
not current_user.is_admin
and result.user_id != current_user.user_id
and result.board_visibility == BoardVisibility.Private
):
raise HTTPException(status_code=403, detail="Not authorized to access this board")

return result
Expand Down Expand Up @@ -188,12 +195,27 @@ async def list_all_board_image_names(
except Exception:
raise HTTPException(status_code=404, detail="Board not found")

if not current_user.is_admin and board.user_id != current_user.user_id:
if (
not current_user.is_admin
and board.user_id != current_user.user_id
and board.board_visibility == BoardVisibility.Private
):
raise HTTPException(status_code=403, detail="Not authorized to access this board")

image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
board_id,
categories,
is_intermediate,
)

# For uncategorized images (board_id="none"), filter to only the caller's
# images so that one user cannot enumerate another's uncategorized images.
# Admin users can see all uncategorized images.
if board_id == "none" and not current_user.is_admin:
image_names = [
name
for name in image_names
if ApiDependencies.invoker.services.image_records.get_user_id(name) == current_user.user_id
]

return image_names
Loading
Loading