Skip to content

Commit 129d42a

Browse files
authored
Merge pull request #47 from All-Hands-AI/refactor/separate-user-service-security-concerns
Refactor: Separate security concerns from SQLUserService
2 parents f6cf4fa + 57954c1 commit 129d42a

File tree

9 files changed

+427
-197
lines changed

9 files changed

+427
-197
lines changed

openhands_server/sandboxed_conversation/sandboxed_conversation_models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from datetime import datetime
2+
from enum import Enum
23
from uuid import UUID, uuid4
34

45
from pydantic import BaseModel, Field
@@ -27,6 +28,15 @@ class SandboxedConversationResponse(StoredConversationInfo):
2728
agent_status: AgentExecutionStatus
2829

2930

31+
class SandboxedConversationResponseSortOrder(Enum):
32+
CREATED_AT = "CREATED_AT"
33+
CREATED_AT_DESC = "CREATED_AT_DESC"
34+
UPDATED_AT = "UPDATED_AT"
35+
UPDATED_AT_DESC = "UPDATED_AT_DESC"
36+
TITLE = "TITLE"
37+
TITLE_DESC = "TITLE_DESC"
38+
39+
3040
class SandboxedConversationResponsePage(BaseModel):
3141
items: list[SandboxedConversationResponse]
3242
next_page_id: str | None = None

openhands_server/sandboxed_conversation/sandboxed_conversation_router.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77

88
from openhands_server.dependency import get_dependency_resolver
99
from openhands_server.sandboxed_conversation.sandboxed_conversation_models import (
10-
SandboxedConversationInfo,
11-
SandboxedConversationPage,
10+
SandboxedConversationResponse,
11+
SandboxedConversationResponsePage,
1212
StartSandboxedConversationRequest,
1313
)
1414
from openhands_server.sandboxed_conversation.sandboxed_conversation_service import (
@@ -39,12 +39,12 @@ async def search_sandboxed_conversations(
3939
sandboxed_conversation_service: SandboxedConversationService = (
4040
sandboxed_conversation_service_dependency
4141
),
42-
) -> SandboxedConversationPage:
42+
) -> SandboxedConversationResponsePage:
4343
"""Search / List sandboxed conversations"""
4444
assert limit > 0
4545
assert limit <= 100
4646
return await sandboxed_conversation_service.search_sandboxed_conversations(
47-
page_id, limit
47+
page_id=page_id, limit=limit
4848
)
4949

5050

@@ -54,7 +54,7 @@ async def get_sandboxed_conversation(
5454
sandboxed_conversation_service: SandboxedConversationService = (
5555
sandboxed_conversation_service_dependency
5656
),
57-
) -> SandboxedConversationInfo:
57+
) -> SandboxedConversationResponse:
5858
"""Get a sandboxed conversation given an id"""
5959
sandboxed_conversation = (
6060
await sandboxed_conversation_service.get_sandboxed_conversation(id)
@@ -70,7 +70,7 @@ async def batch_get_sandboxed_conversations(
7070
sandboxed_conversation_service: SandboxedConversationService = (
7171
sandboxed_conversation_service_dependency
7272
),
73-
) -> list[SandboxedConversationInfo | None]:
73+
) -> list[SandboxedConversationResponse | None]:
7474
"""Get a batch of sandboxed conversations given their ids, returning null for
7575
any missing spec."""
7676
assert len(ids) < 100
@@ -86,5 +86,5 @@ async def start_sandboxed_conversation(
8686
sandboxed_conversation_service: SandboxedConversationService = (
8787
sandboxed_conversation_service_dependency
8888
),
89-
) -> SandboxedConversationInfo:
89+
) -> SandboxedConversationResponse:
9090
return await sandboxed_conversation_service.start_sandboxed_conversation(request)

openhands_server/sandboxed_conversation/sandboxed_conversation_service.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import asyncio
22
from abc import ABC, abstractmethod
3+
from datetime import datetime
34
from typing import Callable
45
from uuid import UUID
56

67
from openhands.sdk.utils.models import DiscriminatedUnionMixin
78
from openhands_server.sandboxed_conversation.sandboxed_conversation_models import (
8-
SandboxedConversationInfo,
9-
SandboxedConversationPage,
9+
SandboxedConversationResponse,
10+
SandboxedConversationResponsePage,
1011
StartSandboxedConversationRequest,
1112
)
1213

@@ -20,21 +21,37 @@ class SandboxedConversationService(ABC):
2021
@abstractmethod
2122
async def search_sandboxed_conversations(
2223
self,
24+
title__contains: str | None = None,
25+
created_at__gte: datetime | None = None,
26+
created_at__lt: datetime | None = None,
27+
updated_at__gte: datetime | None = None,
28+
updated_at__lt: datetime | None = None,
2329
page_id: str | None = None,
2430
limit: int = 100,
25-
) -> SandboxedConversationPage:
31+
) -> SandboxedConversationResponsePage:
2632
"""Search for sandboxed conversations."""
2733

34+
@abstractmethod
35+
async def count_sandboxed_conversations(
36+
self,
37+
title__contains: str | None = None,
38+
created_at__gte: datetime | None = None,
39+
created_at__lt: datetime | None = None,
40+
updated_at__gte: datetime | None = None,
41+
updated_at__lt: datetime | None = None,
42+
) -> int:
43+
"""Count sandboxed conversations."""
44+
2845
@abstractmethod
2946
async def get_sandboxed_conversation(
3047
self, conversation_id: UUID
31-
) -> SandboxedConversationInfo | None:
48+
) -> SandboxedConversationResponse | None:
3249
"""Get a single sandboxed conversation info. Return None if the conversation
3350
was not found."""
3451

3552
async def batch_get_sandboxed_conversations(
3653
self, conversation_ids: list[UUID]
37-
) -> list[SandboxedConversationInfo | None]:
54+
) -> list[SandboxedConversationResponse | None]:
3855
"""Get a batch of sandboxed conversations. Return None for any conversation
3956
which was not found."""
4057
return await asyncio.gather(
@@ -47,7 +64,7 @@ async def batch_get_sandboxed_conversations(
4764
@abstractmethod
4865
async def start_sandboxed_conversation(
4966
self, request: StartSandboxedConversationRequest
50-
) -> SandboxedConversationInfo:
67+
) -> SandboxedConversationResponse:
5168
"""Start a conversation, optionally specifying a sandbox in which to start. If
5269
no sandbox is specified a default may be used or started. This is a convenience
5370
method - the same effect should be achievable by creating / getting a sandbox
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
"""Constrained User Service - Security wrapper for UserService implementations.
2+
3+
This module provides a security wrapper that enforces access control and permission
4+
checks around any UserService implementation. It separates security concerns from
5+
the core data access logic, allowing the underlying service to focus purely on
6+
data operations while this wrapper handles:
7+
8+
- SUPER_ADMIN scope validation
9+
- Self-access restrictions for regular users
10+
- Permission checks for user creation, updates, and deletion
11+
- Scope assignment validation
12+
13+
The ConstrainedUserService can wrap any UserService implementation, making it
14+
reusable across different data storage backends.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import logging
20+
from dataclasses import dataclass
21+
from typing import TYPE_CHECKING
22+
23+
from openhands_server.errors import AuthError
24+
from openhands_server.user.user_models import (
25+
CreateUserRequest,
26+
UpdateUserRequest,
27+
UserInfo,
28+
UserInfoPage,
29+
UserScope,
30+
UserSortOrder,
31+
)
32+
from openhands_server.user.user_service import UserService
33+
34+
35+
if TYPE_CHECKING:
36+
pass
37+
38+
logger = logging.getLogger(__name__)
39+
40+
41+
@dataclass
42+
class ConstrainedUserService(UserService):
43+
"""Security wrapper for UserService implementations.
44+
45+
This class wraps any UserService implementation and enforces security
46+
constraints and permission checks. It implements the same UserService
47+
interface, making it a drop-in replacement that adds security.
48+
"""
49+
50+
wrapped_service: UserService
51+
current_user_id: str
52+
53+
async def get_current_user(self) -> UserInfo | None:
54+
"""Get the current user."""
55+
return await self.wrapped_service.get_user(self.current_user_id)
56+
57+
async def search_users(
58+
self,
59+
name__contains: str | None = None,
60+
email__contains: str | None = None,
61+
user_scopes__contains: UserScope | None = None,
62+
sort_order: UserSortOrder = UserSortOrder.EMAIL,
63+
page_id: str | None = None,
64+
limit: int = 100,
65+
) -> UserInfoPage:
66+
"""Search for users with security constraints."""
67+
# Check if current user has permission to search users
68+
current_user = await self.get_current_user()
69+
if current_user is None:
70+
raise AuthError("Not logged in!")
71+
if UserScope.SUPER_ADMIN not in current_user.user_scopes:
72+
# Regular users can only see themselves
73+
if (
74+
_contains(name__contains, current_user.name)
75+
and _contains(email__contains, current_user.email)
76+
and _contains(user_scopes__contains, current_user.user_scopes)
77+
):
78+
return UserInfoPage(items=[current_user], next_page_id=None)
79+
80+
return UserInfoPage(items=[], next_page_id=None)
81+
82+
# Super admin can search all users - delegate to wrapped service
83+
return await self.wrapped_service.search_users(
84+
name__contains=name__contains,
85+
email__contains=email__contains,
86+
user_scopes__contains=user_scopes__contains,
87+
sort_order=sort_order,
88+
page_id=page_id,
89+
limit=limit,
90+
)
91+
92+
async def count_users(
93+
self,
94+
name__contains: str | None = None,
95+
email__contains: str | None = None,
96+
user_scopes__contains: UserScope | None = None,
97+
) -> int:
98+
"""Search for users with security constraints."""
99+
# Check if current user has permission to search users
100+
current_user = await self.get_current_user()
101+
if current_user is None:
102+
raise AuthError("Not logged in!")
103+
if UserScope.SUPER_ADMIN not in current_user.user_scopes:
104+
# Regular users can only see themselves
105+
if (
106+
_contains(name__contains, current_user.name)
107+
and _contains(email__contains, current_user.email)
108+
and _contains(user_scopes__contains, current_user.user_scopes)
109+
):
110+
return 1
111+
112+
return 0
113+
114+
# Super admin can search all users - delegate to wrapped service
115+
return await self.wrapped_service.count_users(
116+
name__contains=name__contains,
117+
email__contains=email__contains,
118+
user_scopes__contains=user_scopes__contains,
119+
)
120+
121+
async def get_user(self, id: str) -> UserInfo | None:
122+
"""Get a single user with permission checks."""
123+
# Check permissions - users can only see themselves unless they're super admin
124+
current_user = await self.get_current_user()
125+
if (
126+
current_user is not None
127+
and UserScope.SUPER_ADMIN not in current_user.user_scopes
128+
and current_user.id != id
129+
):
130+
return None
131+
132+
# Permission check passed - delegate to wrapped service
133+
return await self.wrapped_service.get_user(id)
134+
135+
async def create_user(self, request: CreateUserRequest) -> UserInfo:
136+
"""
137+
Create a user with permission validation.
138+
139+
Raises:
140+
PermissionError: If current user lacks permission to create users
141+
or grant the requested scopes
142+
"""
143+
# Check if current user has permission to create users
144+
current_user = await self.get_current_user()
145+
if (
146+
current_user is None
147+
or UserScope.SUPER_ADMIN not in current_user.user_scopes
148+
):
149+
raise PermissionError("Only super admins can create users")
150+
151+
# Permission checks passed - delegate to wrapped service
152+
return await self.wrapped_service.create_user(request)
153+
154+
async def update_user(self, request: UpdateUserRequest) -> UserInfo:
155+
"""
156+
Update a user with permission validation.
157+
158+
Raises:
159+
PermissionError: If current user lacks permission to update the user
160+
or grant the requested scopes
161+
ValueError: If the user to update is not found
162+
"""
163+
# Check permissions
164+
current_user = await self.get_current_user()
165+
if current_user is None:
166+
raise PermissionError("Authentication required")
167+
168+
# Users can update themselves, super admins can update anyone
169+
if (
170+
current_user.id != request.id
171+
and UserScope.SUPER_ADMIN not in current_user.user_scopes
172+
):
173+
raise PermissionError("You can only update your own profile")
174+
175+
# Check if user exists (delegate to wrapped service for existence check)
176+
existing_user = await self.wrapped_service.get_user(request.id)
177+
if existing_user is None:
178+
raise ValueError(f"User with id {request.id} not found")
179+
180+
# Check scope permissions
181+
if UserScope.SUPER_ADMIN in request.user_scopes:
182+
# Only super admins can grant super admin privileges
183+
if UserScope.SUPER_ADMIN not in current_user.user_scopes:
184+
raise PermissionError(
185+
"Only super admins can grant super admin privileges"
186+
)
187+
188+
# Permission checks passed - delegate to wrapped service
189+
return await self.wrapped_service.update_user(request)
190+
191+
async def delete_user(self, user_id: str) -> bool:
192+
"""
193+
Delete a user with permission validation.
194+
195+
Raises:
196+
PermissionError: If current user lacks permission to delete users
197+
"""
198+
# Check if current user has permission to delete users
199+
current_user = await self.get_current_user()
200+
if (
201+
current_user is None
202+
or UserScope.SUPER_ADMIN not in current_user.user_scopes
203+
):
204+
raise PermissionError("Only super admins can delete users")
205+
206+
# Permission check passed - delegate to wrapped service
207+
return await self.wrapped_service.delete_user(user_id)
208+
209+
210+
def _contains(query, value):
211+
if not query:
212+
return True
213+
if not value:
214+
return False
215+
return query in value

openhands_server/user/models.py

Lines changed: 0 additions & 33 deletions
This file was deleted.

0 commit comments

Comments
 (0)