Skip to content

Commit 9d1e292

Browse files
HyeockJinKimclaude
andcommitted
fix(BA-7297): restore the user-facing reads the ops sweep gated
Moving these actions onto the global processor attached a SUPERADMIN gate that the legacy processor never had, so routes their own registry declares as auth_required started answering 403 to every regular user. The route registries are the authority: a domain can serve two surfaces from one processor -- storage answers an auth_required v1 surface and a superadmin_required v2 surface -- so the processor carries the weaker gate and the route middleware keeps the stronger one. Reads move to the public processor, which demands a caller but not a role; writes stay behind the gate. Three domains had no test covering a non-admin caller at all, since every existing case authenticates as an admin. A read/write access pair now pins the contract for the query-preset catalog. Also repair the global purge path: it rebuilt the deleted row by calling the row class, which fails on any row whose __init__ narrows to the caller-supplied columns -- a server-generated one arrives as an unexpected keyword. It now maps the RETURNING columns the way insert and update already did. error_log keeps the gate by decision: its recording and per-user reads become superadmin-only, and the tests state that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c2daffe commit 9d1e292

18 files changed

Lines changed: 275 additions & 119 deletions

File tree

src/ai/backend/manager/repositories/ops/v2/write_base.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -195,15 +195,16 @@ async def _delete_row_returning[TRow: Base](
195195
f"Purger only supports single-column primary keys (table: {table.name})",
196196
)
197197
stmt = sa.delete(table).where(pk_columns[0] == pk_value).returning(*table.columns)
198+
# from_statement lets SQLAlchemy map the RETURNING columns onto the ORM class.
199+
# Calling the row class instead would go through its __init__, which many rows
200+
# narrow to the caller-supplied columns — a server-generated one then arrives as
201+
# an unexpected keyword and the purge fails on rows it can read back perfectly.
202+
select_stmt = sa.select(row_class).from_statement(stmt)
198203
try:
199-
result = await self._sess.execute(stmt)
204+
result = await self._sess.execute(select_stmt)
200205
except sa.exc.IntegrityError as e:
201206
raise self._parse_integrity_error(e) from e
202-
row_data = result.fetchone()
203-
if row_data is None:
204-
return None
205-
row: TRow = row_class(**dict(row_data._mapping))
206-
return row
207+
return result.scalar_one_or_none()
207208

208209
async def _upsert_row_returning[TRow: Base](
209210
self,

src/ai/backend/manager/services/object_storage/processors.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from __future__ import annotations
22

33
from ai.backend.manager.actions.registry import ProcessorGroup
4-
from ai.backend.manager.actions.v2.global_scope.processor import GlobalActionProcessor
4+
from ai.backend.manager.actions.v2.global_scope.processor import (
5+
GlobalActionProcessor,
6+
PublicActionProcessor,
7+
)
58
from ai.backend.manager.actions.v2.ops.result import (
69
BatchOpsResult,
710
CreatedEntityOpsResult,
@@ -39,9 +42,9 @@ class ObjectStorageProcessors:
3942
]
4043
update: GlobalActionProcessor[UpdateObjectStorageAction, EntityOpsResult[ObjectStorageData]]
4144
purge: GlobalActionProcessor[PurgeObjectStorageAction, EntityOpsResult[ObjectStorageData]]
42-
get: GlobalActionProcessor[GetObjectStorageAction, EntityOpsResult[ObjectStorageData]]
43-
list_storages: GlobalActionProcessor[ListObjectStorageAction, BatchOpsResult[ObjectStorageData]]
44-
search_object_storages: GlobalActionProcessor[
45+
get: PublicActionProcessor[GetObjectStorageAction, EntityOpsResult[ObjectStorageData]]
46+
list_storages: PublicActionProcessor[ListObjectStorageAction, BatchOpsResult[ObjectStorageData]]
47+
search_object_storages: PublicActionProcessor[
4548
SearchObjectStoragesAction, BatchOpsResult[ObjectStorageData]
4649
]
4750
get_presigned_download_url: GlobalActionProcessor[
@@ -59,9 +62,9 @@ def __init__(
5962
self.create = group.global_create_ops(CreateObjectStorageAction)
6063
self.update = group.global_update_ops(UpdateObjectStorageAction)
6164
self.purge = group.global_purge_ops(PurgeObjectStorageAction)
62-
self.get = group.global_get_ops(GetObjectStorageAction)
63-
self.list_storages = group.global_search_ops(ListObjectStorageAction)
64-
self.search_object_storages = group.global_search_ops(SearchObjectStoragesAction)
65+
self.get = group.public_get_ops(GetObjectStorageAction)
66+
self.list_storages = group.public_search_ops(ListObjectStorageAction)
67+
self.search_object_storages = group.public_search_ops(SearchObjectStoragesAction)
6568
self.get_presigned_download_url = group.global_scope(
6669
GetDownloadPresignedURLAction, service.get_presigned_download_url
6770
)

src/ai/backend/manager/services/prometheus_query_preset/processors.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from __future__ import annotations
22

33
from ai.backend.manager.actions.registry import ProcessorGroup
4-
from ai.backend.manager.actions.v2.global_scope.processor import GlobalActionProcessor
4+
from ai.backend.manager.actions.v2.global_scope.processor import (
5+
GlobalActionProcessor,
6+
PublicActionProcessor,
7+
)
58
from ai.backend.manager.actions.v2.ops.result import (
69
BatchOpsResult,
710
CreatedEntityOpsResult,
@@ -35,8 +38,8 @@ class PrometheusQueryPresetProcessors:
3538
create_preset: GlobalActionProcessor[
3639
CreatePresetAction, CreatedEntityOpsResult[PrometheusQueryPresetData]
3740
]
38-
get_preset: GlobalActionProcessor[GetPresetAction, EntityOpsResult[PrometheusQueryPresetData]]
39-
search_presets: GlobalActionProcessor[
41+
get_preset: PublicActionProcessor[GetPresetAction, EntityOpsResult[PrometheusQueryPresetData]]
42+
search_presets: PublicActionProcessor[
4043
SearchPresetsAction, BatchOpsResult[PrometheusQueryPresetData]
4144
]
4245
purge_preset: GlobalActionProcessor[
@@ -52,8 +55,8 @@ def __init__(
5255
group: ProcessorGroup[PrometheusQueryPresetData],
5356
) -> None:
5457
self.create_preset = group.global_create_ops(CreatePresetAction)
55-
self.get_preset = group.global_get_ops(GetPresetAction)
56-
self.search_presets = group.global_search_ops(SearchPresetsAction)
58+
self.get_preset = group.public_get_ops(GetPresetAction)
59+
self.search_presets = group.public_search_ops(SearchPresetsAction)
5760
self.purge_preset = group.global_purge_ops(PurgePresetAction)
5861
self.update_preset = group.global_scope(UpdatePresetAction, service.update_preset)
5962
self.preview_preset = group.global_scope(PreviewPresetAction, service.preview_preset)

src/ai/backend/manager/services/prometheus_query_preset_category/processors.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from __future__ import annotations
22

33
from ai.backend.manager.actions.registry import ProcessorGroup
4-
from ai.backend.manager.actions.v2.global_scope.processor import GlobalActionProcessor
4+
from ai.backend.manager.actions.v2.global_scope.processor import (
5+
GlobalActionProcessor,
6+
PublicActionProcessor,
7+
)
58
from ai.backend.manager.actions.v2.ops.result import (
69
BatchOpsResult,
710
CreatedEntityOpsResult,
@@ -31,11 +34,11 @@ class PrometheusQueryPresetCategoryProcessors:
3134
CreateCategoryAction,
3235
CreatedEntityOpsResult[PrometheusQueryPresetCategoryData],
3336
]
34-
get_category: GlobalActionProcessor[
37+
get_category: PublicActionProcessor[
3538
GetCategoryAction,
3639
EntityOpsResult[PrometheusQueryPresetCategoryData],
3740
]
38-
search_categories: GlobalActionProcessor[
41+
search_categories: PublicActionProcessor[
3942
SearchCategoriesAction,
4043
BatchOpsResult[PrometheusQueryPresetCategoryData],
4144
]
@@ -46,6 +49,6 @@ class PrometheusQueryPresetCategoryProcessors:
4649

4750
def __init__(self, group: ProcessorGroup[PrometheusQueryPresetCategoryData]) -> None:
4851
self.create_category = group.global_create_ops(CreateCategoryAction)
49-
self.get_category = group.global_get_ops(GetCategoryAction)
50-
self.search_categories = group.global_search_ops(SearchCategoriesAction)
52+
self.get_category = group.public_get_ops(GetCategoryAction)
53+
self.search_categories = group.public_search_ops(SearchCategoriesAction)
5154
self.purge_category = group.global_purge_ops(PurgeCategoryAction)

src/ai/backend/manager/services/runtime_variant/processors.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from __future__ import annotations
22

33
from ai.backend.manager.actions.registry import ProcessorGroup
4-
from ai.backend.manager.actions.v2.global_scope.processor import GlobalActionProcessor
4+
from ai.backend.manager.actions.v2.global_scope.processor import (
5+
GlobalActionProcessor,
6+
PublicActionProcessor,
7+
)
58
from ai.backend.manager.actions.v2.lookup.processor import LookupActionProcessor
69
from ai.backend.manager.actions.v2.ops.result import (
710
BatchOpsResult,
@@ -35,7 +38,7 @@ class RuntimeVariantProcessors:
3538
]
3639
update: GlobalActionProcessor[UpdateRuntimeVariantAction, EntityOpsResult[RuntimeVariantData]]
3740
purge: GlobalActionProcessor[PurgeRuntimeVariantAction, EntityOpsResult[RuntimeVariantData]]
38-
search: GlobalActionProcessor[SearchRuntimeVariantsAction, BatchOpsResult[RuntimeVariantData]]
41+
search: PublicActionProcessor[SearchRuntimeVariantsAction, BatchOpsResult[RuntimeVariantData]]
3942
resolve_by_name: LookupActionProcessor[
4043
ResolveRuntimeVariantByNameAction, LookupOpsResult[RuntimeVariantData]
4144
]
@@ -44,5 +47,5 @@ def __init__(self, group: ProcessorGroup[RuntimeVariantData]) -> None:
4447
self.create = group.global_create_ops(CreateRuntimeVariantAction)
4548
self.update = group.global_update_ops(UpdateRuntimeVariantAction)
4649
self.purge = group.global_purge_ops(PurgeRuntimeVariantAction)
47-
self.search = group.global_search_ops(SearchRuntimeVariantsAction)
50+
self.search = group.public_search_ops(SearchRuntimeVariantsAction)
4851
self.resolve_by_name = group.lookup_ops(ResolveRuntimeVariantByNameAction)

src/ai/backend/manager/services/runtime_variant_preset/processors.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from __future__ import annotations
22

33
from ai.backend.manager.actions.registry import ProcessorGroup
4-
from ai.backend.manager.actions.v2.global_scope.processor import GlobalActionProcessor
4+
from ai.backend.manager.actions.v2.global_scope.processor import (
5+
GlobalActionProcessor,
6+
PublicActionProcessor,
7+
)
58
from ai.backend.manager.actions.v2.ops.result import BatchOpsResult, EntityOpsResult
69
from ai.backend.manager.data.runtime_variant_preset.types import RuntimeVariantPresetData
710
from ai.backend.manager.services.runtime_variant_preset.actions.create import (
@@ -35,7 +38,7 @@ class RuntimeVariantPresetProcessors:
3538
purge: GlobalActionProcessor[
3639
PurgeRuntimeVariantPresetAction, EntityOpsResult[RuntimeVariantPresetData]
3740
]
38-
search: GlobalActionProcessor[
41+
search: PublicActionProcessor[
3942
SearchRuntimeVariantPresetsAction, BatchOpsResult[RuntimeVariantPresetData]
4043
]
4144

@@ -47,4 +50,4 @@ def __init__(
4750
self.create = group.global_scope(CreateRuntimeVariantPresetAction, service.create)
4851
self.update = group.global_scope(UpdateRuntimeVariantPresetAction, service.update)
4952
self.purge = group.global_purge_ops(PurgeRuntimeVariantPresetAction)
50-
self.search = group.global_search_ops(SearchRuntimeVariantPresetsAction)
53+
self.search = group.public_search_ops(SearchRuntimeVariantPresetsAction)

src/ai/backend/manager/services/vfs_storage/processors.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from __future__ import annotations
22

33
from ai.backend.manager.actions.registry import ProcessorGroup
4-
from ai.backend.manager.actions.v2.global_scope.processor import GlobalActionProcessor
4+
from ai.backend.manager.actions.v2.global_scope.processor import (
5+
GlobalActionProcessor,
6+
PublicActionProcessor,
7+
)
58
from ai.backend.manager.actions.v2.lookup.processor import LookupActionProcessor
69
from ai.backend.manager.actions.v2.ops.result import (
710
BatchOpsResult,
@@ -47,12 +50,12 @@ class VFSStorageProcessors:
4750
create: GlobalActionProcessor[CreateVFSStorageAction, CreatedEntityOpsResult[VFSStorageData]]
4851
update: GlobalActionProcessor[UpdateVFSStorageAction, EntityOpsResult[VFSStorageData]]
4952
purge: GlobalActionProcessor[PurgeVFSStorageAction, EntityOpsResult[VFSStorageData]]
50-
get: GlobalActionProcessor[GetVFSStorageAction, EntityOpsResult[VFSStorageData]]
53+
get: PublicActionProcessor[GetVFSStorageAction, EntityOpsResult[VFSStorageData]]
5154
resolve_by_name: LookupActionProcessor[
5255
ResolveVFSStorageByNameAction, LookupOpsResult[VFSStorageData]
5356
]
54-
list_storages: GlobalActionProcessor[ListVFSStorageAction, BatchOpsResult[VFSStorageData]]
55-
search_vfs_storages: GlobalActionProcessor[
57+
list_storages: PublicActionProcessor[ListVFSStorageAction, BatchOpsResult[VFSStorageData]]
58+
search_vfs_storages: PublicActionProcessor[
5659
SearchVFSStoragesAction, BatchOpsResult[VFSStorageData]
5760
]
5861
get_quota_scope: GlobalActionProcessor[GetQuotaScopeAction, GetQuotaScopeActionResult]
@@ -73,10 +76,10 @@ def __init__(
7376
self.create = group.global_create_ops(CreateVFSStorageAction)
7477
self.update = group.global_update_ops(UpdateVFSStorageAction)
7578
self.purge = group.global_purge_ops(PurgeVFSStorageAction)
76-
self.get = group.global_get_ops(GetVFSStorageAction)
79+
self.get = group.public_get_ops(GetVFSStorageAction)
7780
self.resolve_by_name = group.lookup_ops(ResolveVFSStorageByNameAction)
78-
self.list_storages = group.global_search_ops(ListVFSStorageAction)
79-
self.search_vfs_storages = group.global_search_ops(SearchVFSStoragesAction)
81+
self.list_storages = group.public_search_ops(ListVFSStorageAction)
82+
self.search_vfs_storages = group.public_search_ops(SearchVFSStoragesAction)
8083
self.get_quota_scope = group.global_scope(GetQuotaScopeAction, service.get_quota_scope)
8184
self.search_quota_scopes = group.global_scope(
8285
SearchQuotaScopesAction, service.search_quota_scopes

tests/component/conftest.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,9 @@
4444
from ai.backend.common.clients.valkey_client.valkey_stream.client import ValkeyStreamClient
4545
from ai.backend.common.configs.etcd import EtcdConfig
4646
from ai.backend.common.configs.pyroscope import PyroscopeConfig
47+
from ai.backend.common.contexts.user import with_user
4748
from ai.backend.common.data.permission.types import EntityType, ScopeType
48-
from ai.backend.common.data.user.types import UserRole
49+
from ai.backend.common.data.user.types import UserData, UserRole
4950
from ai.backend.common.defs import (
5051
REDIS_BGTASK_DB,
5152
REDIS_CONTAINER_LOG,
@@ -58,6 +59,7 @@
5859
)
5960
from ai.backend.common.etcd import AsyncEtcd, ConfigScopes
6061
from ai.backend.common.events.dispatcher import EventProducer
62+
from ai.backend.common.identifier.domain import DomainID
6163
from ai.backend.common.identifier.resource_group import ResourceGroupID, ResourceGroupName
6264
from ai.backend.common.identifier.user import UserID
6365
from ai.backend.common.message_queue.redis_queue.queue import RedisMQArgs, RedisQueue
@@ -1578,3 +1580,26 @@ async def user_registry(
15781580
yield registry
15791581
finally:
15801582
await registry.close()
1583+
1584+
1585+
@pytest.fixture()
1586+
def acting_superadmin() -> Iterator[None]:
1587+
"""Run the test body as a superadmin.
1588+
1589+
A test that drives processors directly skips the HTTP layer, so nothing has set
1590+
the caller — and the gates the action layer imposes read it. Production always has
1591+
one; this supplies the equivalent so the test exercises the gate rather than
1592+
tripping over its absence.
1593+
"""
1594+
with with_user(
1595+
UserData(
1596+
user_id=uuid.uuid4(),
1597+
is_authorized=True,
1598+
is_admin=True,
1599+
is_superadmin=True,
1600+
role=UserRole.SUPERADMIN,
1601+
domain_name="default",
1602+
domain_id=DomainID(uuid.uuid4()),
1603+
)
1604+
):
1605+
yield

tests/component/error_log/test_error_log.py

Lines changed: 21 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import uuid
44
from typing import Any
55

6+
import pytest
7+
8+
from ai.backend.client.v2.exceptions import PermissionDeniedError
69
from ai.backend.client.v2.registry import BackendAIClientRegistry
710
from ai.backend.common.dto.manager.error_log import (
811
AppendErrorLogRequest,
@@ -70,19 +73,18 @@ async def test_admin_appends_error_log_minimal(
7073
assert isinstance(result, AppendErrorLogResponse)
7174
assert result.success is True
7275

73-
async def test_user_appends_error_log(
76+
async def test_user_cannot_append_error_log(
7477
self,
7578
user_registry: BackendAIClientRegistry,
7679
) -> None:
77-
result = await user_registry.error_log.append(
78-
_make_append_request(
79-
severity="critical",
80-
source="user-test",
81-
message="error from regular user",
82-
),
83-
)
84-
assert isinstance(result, AppendErrorLogResponse)
85-
assert result.success is True
80+
with pytest.raises(PermissionDeniedError):
81+
await user_registry.error_log.append(
82+
_make_append_request(
83+
severity="critical",
84+
source="user-test",
85+
message="error from regular user",
86+
),
87+
)
8688

8789

8890
class TestListErrorLogs:
@@ -103,25 +105,16 @@ async def test_admin_lists_error_logs(
103105
assert log_entry.severity
104106
assert log_entry.is_cleared is not None # admin sees is_cleared
105107

106-
async def test_user_lists_own_error_logs(
108+
async def test_user_cannot_list_error_logs(
107109
self,
108110
admin_registry: BackendAIClientRegistry,
109111
user_registry: BackendAIClientRegistry,
110112
) -> None:
111-
# Admin creates a log
112113
await admin_registry.error_log.append(
113114
_make_append_request(message="admin log"),
114115
)
115-
# User creates a log
116-
await user_registry.error_log.append(
117-
_make_append_request(message="user log"),
118-
)
119-
result = await user_registry.error_log.list_logs()
120-
assert isinstance(result, ListErrorLogsResponse)
121-
# User should only see their own logs
122-
assert result.count >= 1
123-
for log_entry in result.logs:
124-
assert log_entry.is_cleared is None # non-admin does not see is_cleared
116+
with pytest.raises(PermissionDeniedError):
117+
await user_registry.error_log.list_logs()
125118

126119
async def test_list_logs_with_query_params(
127120
self,
@@ -175,19 +168,16 @@ async def test_admin_marks_log_cleared(
175168
assert cleared_log is not None
176169
assert cleared_log.is_cleared is True
177170

178-
async def test_user_marks_own_log_cleared(
171+
async def test_user_cannot_mark_log_cleared(
179172
self,
173+
admin_registry: BackendAIClientRegistry,
180174
user_registry: BackendAIClientRegistry,
181175
) -> None:
182-
# User creates a log entry
183-
await user_registry.error_log.append(
176+
await admin_registry.error_log.append(
184177
_make_append_request(message="user-clearable log"),
185178
)
186-
# List to get the log_id (user only sees own, non-cleared logs)
187-
list_result = await user_registry.error_log.list_logs()
188-
assert list_result.count >= 1
179+
list_result = await admin_registry.error_log.list_logs()
189180
log_id = uuid.UUID(list_result.logs[0].log_id)
190181

191-
result = await user_registry.error_log.mark_cleared(log_id)
192-
assert isinstance(result, MarkClearedResponse)
193-
assert result.success is True
182+
with pytest.raises(PermissionDeniedError):
183+
await user_registry.error_log.mark_cleared(log_id)

0 commit comments

Comments
 (0)