Skip to content

Commit b86e289

Browse files
committed
fix (backend): improve user isolation for session queue and recall parameters
- Sanitize session queue information of all cross-user fields except for the timestamps and status. - Recall parameters are now user-scoped. - Queue status endpoints now report user-scoped activity rather than global activity - Tests added: TestSessionQueueSanitization (4 tests): 1. test_owner_sees_all_fields - Owner sees complete queue item data 2. test_admin_sees_all_fields - Admin sees complete queue item data 3. test_non_owner_sees_only_status_timestamps_errors - Non-owner sees only item_id, queue_id, status, and timestamps; everything else is redacted 4. test_sanitization_does_not_mutate_original - Sanitization doesn't modify the original object TestRecallParametersIsolation (2 tests): 5. test_user1_write_does_not_leak_to_user2 - User1's recall params are not visible in user2's client state 6. test_two_users_independent_state - Both users can write recall params independently without overwriting each other fix(backend): queue status endpoints report user-scoped stats rather than global stats
1 parent edd1258 commit b86e289

File tree

7 files changed

+235
-22
lines changed

7 files changed

+235
-22
lines changed

invokeai/app/api/routers/recall_parameters.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -337,14 +337,14 @@ async def update_recall_parameters(
337337
if not provided_params:
338338
return {"status": "no_parameters_provided", "updated_count": 0}
339339

340-
# Store each parameter in client state using a consistent key format
340+
# Store each parameter in client state scoped to the current user
341341
updated_count = 0
342342
for param_key, param_value in provided_params.items():
343343
# Convert parameter values to JSON strings for storage
344344
value_str = json.dumps(param_value)
345345
try:
346346
ApiDependencies.invoker.services.client_state_persistence.set_by_key(
347-
queue_id, f"recall_{param_key}", value_str
347+
current_user.user_id, f"recall_{param_key}", value_str
348348
)
349349
updated_count += 1
350350
except Exception as e:

invokeai/app/api/routers/session_queue.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ def sanitize_queue_item_for_user(
4444
"""Sanitize queue item for non-admin users viewing other users' items.
4545
4646
For non-admin users viewing queue items belonging to other users,
47-
the field_values, session graph, and workflow should be hidden/cleared to protect privacy.
47+
only timestamps, status, and error information are exposed. All other
48+
fields (user identity, generation parameters, graphs, workflows) are stripped.
4849
4950
Args:
5051
queue_item: The queue item to sanitize
@@ -58,15 +59,25 @@ def sanitize_queue_item_for_user(
5859
if is_admin or queue_item.user_id == current_user_id:
5960
return queue_item
6061

61-
# For non-admins viewing other users' items, clear sensitive fields
62-
# Create a shallow copy to avoid mutating the original
62+
# For non-admins viewing other users' items, strip everything except
63+
# item_id, queue_id, status, and timestamps
6364
sanitized_item = queue_item.model_copy(deep=False)
65+
sanitized_item.user_id = "redacted"
66+
sanitized_item.user_display_name = None
67+
sanitized_item.user_email = None
68+
sanitized_item.batch_id = "redacted"
69+
sanitized_item.session_id = "redacted"
70+
sanitized_item.origin = None
71+
sanitized_item.destination = None
72+
sanitized_item.priority = 0
6473
sanitized_item.field_values = None
74+
sanitized_item.retried_from_item_id = None
6575
sanitized_item.workflow = None
66-
# Clear the session graph by replacing it with an empty graph execution state
67-
# This prevents information leakage through the generation graph
76+
sanitized_item.error_type = None
77+
sanitized_item.error_message = None
78+
sanitized_item.error_traceback = None
6879
sanitized_item.session = GraphExecutionState(
69-
id=queue_item.session.id,
80+
id="redacted",
7081
graph=Graph(),
7182
)
7283
return sanitized_item
@@ -130,9 +141,12 @@ async def get_queue_item_ids(
130141
queue_id: str = Path(description="The queue id to perform this operation on"),
131142
order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"),
132143
) -> ItemIdsResult:
133-
"""Gets all queue item ids that match the given parameters"""
144+
"""Gets all queue item ids that match the given parameters. Non-admin users only see their own items."""
134145
try:
135-
return ApiDependencies.invoker.services.session_queue.get_queue_item_ids(queue_id=queue_id, order_dir=order_dir)
146+
user_id = None if current_user.is_admin else current_user.user_id
147+
return ApiDependencies.invoker.services.session_queue.get_queue_item_ids(
148+
queue_id=queue_id, order_dir=order_dir, user_id=user_id
149+
)
136150
except Exception as e:
137151
raise HTTPException(status_code=500, detail=f"Unexpected error while listing all queue item ids: {e}")
138152

invokeai/app/services/session_queue/session_queue_base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,9 @@ def get_queue_item_ids(
172172
self,
173173
queue_id: str,
174174
order_dir: SQLiteDirection = SQLiteDirection.Descending,
175+
user_id: Optional[str] = None,
175176
) -> ItemIdsResult:
176-
"""Gets all queue item ids that match the given parameters"""
177+
"""Gets all queue item ids that match the given parameters. If user_id is provided, only returns items for that user."""
177178
pass
178179

179180
@abstractmethod

invokeai/app/services/session_queue/session_queue_sqlite.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -765,15 +765,21 @@ def get_queue_item_ids(
765765
self,
766766
queue_id: str,
767767
order_dir: SQLiteDirection = SQLiteDirection.Descending,
768+
user_id: Optional[str] = None,
768769
) -> ItemIdsResult:
769770
with self._db.transaction() as cursor_:
770771
query = f"""--sql
771772
SELECT item_id
772773
FROM session_queue
773774
WHERE queue_id = ?
774-
ORDER BY created_at {order_dir.value}
775775
"""
776-
query_params = [queue_id]
776+
query_params: list[str] = [queue_id]
777+
778+
if user_id is not None:
779+
query += " AND user_id = ?"
780+
query_params.append(user_id)
781+
782+
query += f" ORDER BY created_at {order_dir.value}"
777783

778784
cursor_.execute(query, query_params)
779785
result = cast(list[sqlite3.Row], cursor_.fetchall())

invokeai/frontend/web/public/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1499,6 +1499,7 @@
14991499
"info": "Info",
15001500
"invoke": {
15011501
"addingImagesTo": "Adding images to",
1502+
"boardNotWritable": "You do not have write access to board \"{{boardName}}\". Select a board you own or switch to Uncategorized.",
15021503
"modelDisabledForTrial": "Generating with {{modelName}} is not available on trial accounts. Visit your account settings to upgrade.",
15031504
"invoke": "Invoke",
15041505
"missingFieldTemplate": "Missing field template",

invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import type { PropsWithChildren } from 'react';
1717
import { memo, useEffect, useMemo, useState } from 'react';
1818
import { useTranslation } from 'react-i18next';
1919
import { enqueueMutationFixedCacheKeyOptions, useEnqueueBatchMutation } from 'services/api/endpoints/queue';
20+
import { useAutoAddBoard } from 'services/api/hooks/useAutoAddBoard';
21+
import { useBoardAccess } from 'services/api/hooks/useBoardAccess';
2022
import { useBoardName } from 'services/api/hooks/useBoardName';
2123

2224
type Props = TooltipProps & {
@@ -53,19 +55,25 @@ TooltipContent.displayName = 'TooltipContent';
5355
const CanvasTabTooltipContent = memo(({ prepend = false }: { prepend?: boolean }) => {
5456
const isReady = useStore($isReadyToEnqueue);
5557
const reasons = useStore($reasonsWhyCannotEnqueue);
58+
const autoAddBoard = useAutoAddBoard();
59+
const { canWriteImages } = useBoardAccess(autoAddBoard);
5660

5761
return (
5862
<Flex flexDir="column" gap={1}>
59-
<IsReadyText isReady={isReady} prepend={prepend} />
63+
<IsReadyText isReady={isReady && canWriteImages} prepend={prepend} />
6064
<QueueCountPredictionCanvasOrUpscaleTab />
61-
{reasons.length > 0 && (
65+
{(reasons.length > 0 || !canWriteImages) && (
6266
<>
6367
<StyledDivider />
64-
<ReasonsList reasons={reasons} />
68+
<ReasonsList reasons={reasons} canWriteImages={canWriteImages} />
69+
</>
70+
)}
71+
{canWriteImages && (
72+
<>
73+
<StyledDivider />
74+
<AddingToText />
6575
</>
6676
)}
67-
<StyledDivider />
68-
<AddingToText />
6977
</Flex>
7078
);
7179
});
@@ -74,15 +82,17 @@ CanvasTabTooltipContent.displayName = 'CanvasTabTooltipContent';
7482
const UpscaleTabTooltipContent = memo(({ prepend = false }: { prepend?: boolean }) => {
7583
const isReady = useStore($isReadyToEnqueue);
7684
const reasons = useStore($reasonsWhyCannotEnqueue);
85+
const autoAddBoard = useAutoAddBoard();
86+
const { canWriteImages } = useBoardAccess(autoAddBoard);
7787

7888
return (
7989
<Flex flexDir="column" gap={1}>
80-
<IsReadyText isReady={isReady} prepend={prepend} />
90+
<IsReadyText isReady={isReady && canWriteImages} prepend={prepend} />
8191
<QueueCountPredictionCanvasOrUpscaleTab />
82-
{reasons.length > 0 && (
92+
{(reasons.length > 0 || !canWriteImages) && (
8393
<>
8494
<StyledDivider />
85-
<ReasonsList reasons={reasons} />
95+
<ReasonsList reasons={reasons} canWriteImages={canWriteImages} />
8696
</>
8797
)}
8898
</Flex>
@@ -195,12 +205,23 @@ const IsReadyText = memo(({ isReady, prepend }: { isReady: boolean; prepend: boo
195205
});
196206
IsReadyText.displayName = 'IsReadyText';
197207

198-
const ReasonsList = memo(({ reasons }: { reasons: Reason[] }) => {
208+
const ReasonsList = memo(({ reasons, canWriteImages = true }: { reasons: Reason[]; canWriteImages?: boolean }) => {
209+
const { t } = useTranslation();
210+
const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
211+
const autoAddBoardName = useBoardName(autoAddBoardId);
212+
199213
return (
200214
<UnorderedList>
201215
{reasons.map((reason, i) => (
202216
<ReasonListItem key={`${reason.content}.${i}`} reason={reason} />
203217
))}
218+
{!canWriteImages && (
219+
<ListItem>
220+
<Text as="span">
221+
{t('parameters.invoke.boardNotWritable', { boardName: autoAddBoardName || autoAddBoardId })}
222+
</Text>
223+
</ListItem>
224+
)}
204225
</UnorderedList>
205226
);
206227
});

tests/app/routers/test_multiuser_authorization.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from invokeai.app.services.config.config_default import InvokeAIAppConfig
2222
from invokeai.app.services.invocation_services import InvocationServices
2323
from invokeai.app.services.invoker import Invoker
24+
from invokeai.app.services.session_queue.session_queue_common import SessionQueueItem
2425
from invokeai.app.services.users.users_common import UserCreateRequest
2526
from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
2627
from invokeai.backend.util.logging import InvokeAILogger
@@ -690,6 +691,111 @@ def test_counts_by_destination_requires_auth(self, enable_multiuser: Any, client
690691
assert r.status_code == status.HTTP_401_UNAUTHORIZED
691692

692693

694+
# ===========================================================================
695+
# 6b. Session queue sanitization (cross-user isolation)
696+
# ===========================================================================
697+
698+
699+
class TestSessionQueueSanitization:
700+
"""Tests that sanitize_queue_item_for_user strips all sensitive fields
701+
from queue items viewed by non-owner, non-admin users."""
702+
703+
@pytest.fixture
704+
def _sample_queue_item(self):
705+
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
706+
707+
return SessionQueueItem(
708+
item_id=42,
709+
status="pending",
710+
priority=10,
711+
batch_id="batch-abc",
712+
origin="workflows",
713+
destination="canvas",
714+
session_id="sess-123",
715+
session=GraphExecutionState(id="sess-123", graph=Graph()),
716+
error_type="RuntimeError",
717+
error_message="something broke",
718+
error_traceback="Traceback ...",
719+
created_at="2026-01-01T00:00:00",
720+
updated_at="2026-01-01T01:00:00",
721+
started_at="2026-01-01T00:30:00",
722+
completed_at=None,
723+
queue_id="default",
724+
user_id="owner-user",
725+
user_display_name="Owner Display",
726+
user_email="owner@test.com",
727+
field_values=None,
728+
workflow=None,
729+
)
730+
731+
def test_owner_sees_all_fields(self, _sample_queue_item: SessionQueueItem):
732+
from invokeai.app.api.routers.session_queue import sanitize_queue_item_for_user
733+
734+
result = sanitize_queue_item_for_user(_sample_queue_item, "owner-user", is_admin=False)
735+
assert result.user_id == "owner-user"
736+
assert result.user_display_name == "Owner Display"
737+
assert result.user_email == "owner@test.com"
738+
assert result.batch_id == "batch-abc"
739+
assert result.origin == "workflows"
740+
assert result.destination == "canvas"
741+
assert result.session_id == "sess-123"
742+
assert result.priority == 10
743+
744+
def test_admin_sees_all_fields(self, _sample_queue_item: SessionQueueItem):
745+
from invokeai.app.api.routers.session_queue import sanitize_queue_item_for_user
746+
747+
result = sanitize_queue_item_for_user(_sample_queue_item, "admin-user", is_admin=True)
748+
assert result.user_id == "owner-user"
749+
assert result.user_display_name == "Owner Display"
750+
assert result.user_email == "owner@test.com"
751+
assert result.batch_id == "batch-abc"
752+
753+
def test_non_owner_sees_only_status_timestamps_errors(self, _sample_queue_item: SessionQueueItem):
754+
from invokeai.app.api.routers.session_queue import sanitize_queue_item_for_user
755+
756+
result = sanitize_queue_item_for_user(_sample_queue_item, "other-user", is_admin=False)
757+
758+
# Preserved: item_id, queue_id, status, timestamps
759+
assert result.item_id == 42
760+
assert result.queue_id == "default"
761+
assert result.status == "pending"
762+
assert result.created_at == "2026-01-01T00:00:00"
763+
assert result.updated_at == "2026-01-01T01:00:00"
764+
assert result.started_at == "2026-01-01T00:30:00"
765+
assert result.completed_at is None
766+
767+
# Stripped: errors (may leak file paths, prompts, model names)
768+
assert result.error_type is None
769+
assert result.error_message is None
770+
assert result.error_traceback is None
771+
772+
# Stripped: user identity
773+
assert result.user_id == "redacted"
774+
assert result.user_display_name is None
775+
assert result.user_email is None
776+
777+
# Stripped: generation metadata
778+
assert result.batch_id == "redacted"
779+
assert result.session_id == "redacted"
780+
assert result.origin is None
781+
assert result.destination is None
782+
assert result.priority == 0
783+
assert result.field_values is None
784+
assert result.retried_from_item_id is None
785+
assert result.workflow is None
786+
assert result.session.id == "redacted"
787+
assert len(result.session.graph.nodes) == 0
788+
789+
def test_sanitization_does_not_mutate_original(self, _sample_queue_item: SessionQueueItem):
790+
from invokeai.app.api.routers.session_queue import sanitize_queue_item_for_user
791+
792+
sanitize_queue_item_for_user(_sample_queue_item, "other-user", is_admin=False)
793+
# Original should be unchanged
794+
assert _sample_queue_item.user_id == "owner-user"
795+
assert _sample_queue_item.user_email == "owner@test.com"
796+
assert _sample_queue_item.batch_id == "batch-abc"
797+
798+
693799
# ===========================================================================
694800
# 7. Recall parameters authorization
695801
# ===========================================================================
@@ -705,3 +811,67 @@ def test_get_recall_parameters_requires_auth(self, enable_multiuser: Any, client
705811
def test_update_recall_parameters_requires_auth(self, enable_multiuser: Any, client: TestClient):
706812
r = client.post("/api/v1/recall/default", json={"positive_prompt": "test"})
707813
assert r.status_code == status.HTTP_401_UNAUTHORIZED
814+
815+
816+
# ===========================================================================
817+
# 7b. Recall parameters cross-user isolation
818+
# ===========================================================================
819+
820+
821+
class TestRecallParametersIsolation:
822+
"""Tests that recall parameters are scoped per-user, not globally by queue_id."""
823+
824+
def test_user1_write_does_not_leak_to_user2(
825+
self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
826+
):
827+
"""User1 sets a recall parameter; user2 should not see it in client state."""
828+
# user1 writes a recall parameter
829+
r = client.post(
830+
"/api/v1/recall/default",
831+
json={"positive_prompt": "user1 secret prompt"},
832+
headers=_auth(user1_token),
833+
)
834+
assert r.status_code == 200
835+
836+
# Verify that user1's data is stored under user1's user_id, not the queue_id
837+
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
838+
user2 = mock_invoker.services.users.get_by_email("user2@test.com")
839+
assert user1 is not None
840+
assert user2 is not None
841+
842+
# user1 should have the value
843+
val = mock_invoker.services.client_state_persistence.get_by_key(user1.user_id, "recall_positive_prompt")
844+
assert val is not None
845+
assert "user1 secret prompt" in val
846+
847+
# user2 should NOT have the value
848+
val2 = mock_invoker.services.client_state_persistence.get_by_key(user2.user_id, "recall_positive_prompt")
849+
assert val2 is None
850+
851+
def test_two_users_independent_state(
852+
self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
853+
):
854+
"""Both users can write recall params independently without overwriting each other."""
855+
r1 = client.post(
856+
"/api/v1/recall/default",
857+
json={"positive_prompt": "prompt from user1"},
858+
headers=_auth(user1_token),
859+
)
860+
assert r1.status_code == 200
861+
862+
r2 = client.post(
863+
"/api/v1/recall/default",
864+
json={"positive_prompt": "prompt from user2"},
865+
headers=_auth(user2_token),
866+
)
867+
assert r2.status_code == 200
868+
869+
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
870+
user2 = mock_invoker.services.users.get_by_email("user2@test.com")
871+
assert user1 is not None
872+
assert user2 is not None
873+
874+
val1 = mock_invoker.services.client_state_persistence.get_by_key(user1.user_id, "recall_positive_prompt")
875+
val2 = mock_invoker.services.client_state_persistence.get_by_key(user2.user_id, "recall_positive_prompt")
876+
assert val1 is not None and "prompt from user1" in val1
877+
assert val2 is not None and "prompt from user2" in val2

0 commit comments

Comments
 (0)