Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 15 additions & 5 deletions invokeai/app/api/routers/session_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
SessionQueueItemNotFoundError,
SessionQueueStatus,
)
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection

session_queue_router = APIRouter(prefix="/v1/queue", tags=["queue"])
Expand All @@ -43,23 +44,32 @@ def sanitize_queue_item_for_user(
"""Sanitize queue item for non-admin users viewing other users' items.

For non-admin users viewing queue items belonging to other users,
the field_values should be hidden/cleared to protect privacy.
the field_values, session graph, and workflow should be hidden/cleared to protect privacy.

Args:
queue_item: The queue item to sanitize
current_user_id: The ID of the current user viewing the item
is_admin: Whether the current user is an admin

Returns:
The sanitized queue item (field_values cleared if necessary)
The sanitized queue item (sensitive fields cleared if necessary)
"""
# Admins and item owners can see everything
if is_admin or queue_item.user_id == current_user_id:
return queue_item

# For non-admins viewing other users' items, clear field_values
queue_item.field_values = None
return queue_item
# For non-admins viewing other users' items, clear sensitive fields
# Create a shallow copy to avoid mutating the original
sanitized_item = queue_item.model_copy(deep=False)
sanitized_item.field_values = None
sanitized_item.workflow = None
# Clear the session graph by replacing it with an empty graph execution state
# This prevents information leakage through the generation graph
sanitized_item.session = GraphExecutionState(
id=queue_item.session.id,
graph=Graph(),
)
return sanitized_item


@session_queue_router.post(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ def validate_graph(cls, v: Graph):
# region Queue Items

DEFAULT_QUEUE_ID = "default"
SYSTEM_USER_ID = "system" # Default user_id for system-generated queue items

QUEUE_ITEM_STATUS = Literal["pending", "in_progress", "completed", "failed", "canceled"]

Expand Down
1 change: 1 addition & 0 deletions invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@
"gallery": "Gallery",
"batchFieldValues": "Batch Field Values",
"fieldValuesHidden": "Hidden for privacy",
"cannotViewDetails": "You do not have permission to view the details of this queue item",
"item": "Item",
"session": "Session",
"notReady": "Unable to Queue",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { ChakraProps, CollapseProps, FlexProps } from '@invoke-ai/ui-library';
import { ButtonGroup, Collapse, Flex, IconButton, Text } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { selectCurrentUser } from 'features/auth/store/authSlice';
import QueueStatusBadge from 'features/queue/components/common/QueueStatusBadge';
import { useDestinationText } from 'features/queue/components/QueueList/useDestinationText';
import { useOriginText } from 'features/queue/components/QueueList/useOriginText';
Expand All @@ -12,7 +14,7 @@ import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold, PiXBold } from 'react-icons/pi';
import type { S } from 'services/api/types';

import { COLUMN_WIDTHS } from './constants';
import { COLUMN_WIDTHS, SYSTEM_USER_ID } from './constants';
import QueueItemDetail from './QueueItemDetail';

const selectedStyles = { bg: 'base.700' };
Expand All @@ -30,7 +32,31 @@ const sx: ChakraProps['sx'] = {
const QueueItemComponent = ({ index, item }: InnerItemProps) => {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const handleToggle = useCallback(() => setIsOpen((s) => !s), [setIsOpen]);
const currentUser = useAppSelector(selectCurrentUser);

// Check if the current user can view this queue item's details
const canViewDetails = useMemo(() => {
// Admins can view all items
if (currentUser?.is_admin) {
return true;
}
// Users can view their own items
if (currentUser?.user_id === item.user_id) {
return true;
}
// System items can be viewed by anyone
if (item.user_id === SYSTEM_USER_ID) {
return true;
}
return false;
}, [currentUser, item.user_id]);

const handleToggle = useCallback(() => {
if (canViewDetails) {
setIsOpen((s) => !s);
}
}, [canViewDetails]);

const cancelQueueItem = useCancelQueueItem();
const onClickCancelQueueItem = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
Expand Down Expand Up @@ -69,7 +95,7 @@ const QueueItemComponent = ({ index, item }: InnerItemProps) => {
if (item.user_email) {
return item.user_email;
}
return item.user_id || 'system';
return item.user_id || SYSTEM_USER_ID;
}, [item.user_display_name, item.user_email, item.user_id]);

return (
Expand All @@ -82,7 +108,16 @@ const QueueItemComponent = ({ index, item }: InnerItemProps) => {
sx={sx}
data-testid="queue-item"
>
<Flex minH={9} alignItems="center" gap={4} p={1.5} cursor="pointer" onClick={handleToggle}>
<Flex
minH={9}
alignItems="center"
gap={4}
p={1.5}
cursor={canViewDetails ? 'pointer' : 'not-allowed'}
onClick={handleToggle}
title={!canViewDetails ? t('queue.cannotViewDetails') : undefined}
opacity={canViewDetails ? 1 : 0.7}
>
<Flex w={COLUMN_WIDTHS.number} alignItems="center" flexShrink={0}>
<Text variant="subtext">{index + 1}</Text>
</Flex>
Expand Down Expand Up @@ -126,7 +161,7 @@ const QueueItemComponent = ({ index, item }: InnerItemProps) => {
))}
</Flex>
)}
{!item.field_values && item.user_id !== 'system' && (
{!item.field_values && item.user_id !== SYSTEM_USER_ID && (
<Text as="span" color="base.500" fontStyle="italic">
{t('queue.fieldValuesHidden')}
</Text>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ export const COLUMN_WIDTHS = {
completedAt: '9.5rem',
actions: 'auto',
} as const;

// System user ID constant - matches backend SYSTEM_USER_ID
export const SYSTEM_USER_ID = 'system';
126 changes: 126 additions & 0 deletions tests/app/routers/test_session_queue_sanitization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Tests for session queue item sanitization in multiuser mode."""

from datetime import datetime

import pytest

from invokeai.app.api.routers.session_queue import sanitize_queue_item_for_user
from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output
from invokeai.app.invocations.fields import InputField, OutputField
from invokeai.app.services.session_queue.session_queue_common import NodeFieldValue, SessionQueueItem
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
from invokeai.app.services.shared.invocation_context import InvocationContext


# Define a minimal test invocation for the test
@invocation_output("test_sanitization_output")
class TestSanitizationInvocationOutput(BaseInvocationOutput):
value: str = OutputField(default="")


@invocation("test_sanitization", version="1.0.0")
class TestSanitizationInvocation(BaseInvocation):
test_field: str = InputField(default="")

def invoke(self, context: InvocationContext) -> TestSanitizationInvocationOutput:
return TestSanitizationInvocationOutput(value=self.test_field)


@pytest.fixture
def sample_session_queue_item() -> SessionQueueItem:
"""Create a sample queue item with full data for testing."""
graph = Graph()
# Add a simple node to the graph
graph.add_node(TestSanitizationInvocation(id="test_node", test_field="test value"))

session = GraphExecutionState(id="test_session", graph=graph)

# Create timestamps for the queue item
now = datetime.now()

return SessionQueueItem(
item_id=1,
status="pending",
batch_id="batch_123",
session_id="session_123",
queue_id="default",
user_id="user_123",
user_display_name="Test User",
user_email="test@example.com",
field_values=[
NodeFieldValue(node_path="test_node", field_name="test_field", value="sensitive prompt data"),
],
session=session,
workflow=None,
created_at=now,
updated_at=now,
started_at=None,
completed_at=None,
)


def test_sanitize_queue_item_for_admin(sample_session_queue_item):
"""Test that admins can see all data regardless of user_id."""
result = sanitize_queue_item_for_user(
queue_item=sample_session_queue_item,
current_user_id="different_user",
is_admin=True,
)

# Admin should see everything
assert result.field_values is not None
assert len(result.field_values) == 1
assert result.session.graph.nodes is not None
assert len(result.session.graph.nodes) == 1


def test_sanitize_queue_item_for_owner(sample_session_queue_item):
"""Test that queue item owners can see their own data."""
result = sanitize_queue_item_for_user(
queue_item=sample_session_queue_item,
current_user_id="user_123", # Same as queue item user_id
is_admin=False,
)

# Owner should see everything
assert result.field_values is not None
assert len(result.field_values) == 1
assert result.session.graph.nodes is not None
assert len(result.session.graph.nodes) == 1


def test_sanitize_queue_item_for_different_user(sample_session_queue_item):
"""Test that non-admin users cannot see other users' sensitive data."""
result = sanitize_queue_item_for_user(
queue_item=sample_session_queue_item,
current_user_id="different_user",
is_admin=False,
)

# Non-admin viewing another user's item should have sanitized data
assert result.field_values is None
assert result.workflow is None
# Session should be replaced with empty graph
assert result.session.graph.nodes is not None
assert len(result.session.graph.nodes) == 0
# Session ID should be preserved
assert result.session.id == "test_session"


def test_sanitize_preserves_non_sensitive_fields(sample_session_queue_item):
"""Test that sanitization preserves non-sensitive fields."""
result = sanitize_queue_item_for_user(
queue_item=sample_session_queue_item,
current_user_id="different_user",
is_admin=False,
)

# These fields should be preserved
assert result.item_id == 1
assert result.status == "pending"
assert result.batch_id == "batch_123"
assert result.session_id == "session_123"
assert result.queue_id == "default"
assert result.user_id == "user_123"
assert result.user_display_name == "Test User"
assert result.user_email == "test@example.com"