Skip to content
Draft
1 change: 1 addition & 0 deletions changes/12974.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Open the session, deployment and route scheduling-history scoped searches to non-admin callers, authorized against the requested scope through the RBAC scope chain.
3 changes: 3 additions & 0 deletions docs/manager/graphql-reference/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -15545,6 +15545,9 @@ enum RBACElementType
PROJECT_RESOURCE_POLICY @join__enumValue(graph: STRAWBERRY)
ROLE @join__enumValue(graph: STRAWBERRY)
AUDIT_LOG @join__enumValue(graph: STRAWBERRY)
SESSION_HISTORY @join__enumValue(graph: STRAWBERRY)
DEPLOYMENT_HISTORY @join__enumValue(graph: STRAWBERRY)
ROUTE_HISTORY @join__enumValue(graph: STRAWBERRY)
EVENT_LOG @join__enumValue(graph: STRAWBERRY)
PROJECT_ADMIN_PAGE @join__enumValue(graph: STRAWBERRY)
DOMAIN_ADMIN_PAGE @join__enumValue(graph: STRAWBERRY)
Expand Down
3 changes: 3 additions & 0 deletions docs/manager/graphql-reference/v2-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -10531,6 +10531,9 @@ enum RBACElementType {
PROJECT_RESOURCE_POLICY
ROLE
AUDIT_LOG
SESSION_HISTORY
DEPLOYMENT_HISTORY
ROUTE_HISTORY
EVENT_LOG
PROJECT_ADMIN_PAGE
DOMAIN_ADMIN_PAGE
Expand Down
9 changes: 6 additions & 3 deletions docs/manager/rest-reference/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -15050,6 +15050,9 @@
"project_resource_policy",
"role",
"audit_log",
"session:history",
"deployment:history",
"route:history",
"event_log",
"project_admin_page",
"domain_admin_page",
Expand Down Expand Up @@ -47763,7 +47766,7 @@
}
}
],
"description": "Search session scheduling histories scoped to a specific session.\n\n**Preconditions:**\n* Superadmin privilege required.\n"
"description": "Search session scheduling histories scoped to a specific session.\n\n**Preconditions:**\n* User privilege required.\n"
}
},
"/v2/scheduling-history/deployments/search": {
Expand Down Expand Up @@ -47830,7 +47833,7 @@
}
}
],
"description": "Search deployment histories scoped to a specific deployment.\n\n**Preconditions:**\n* Superadmin privilege required.\n"
"description": "Search deployment histories scoped to a specific deployment.\n\n**Preconditions:**\n* User privilege required.\n"
}
},
"/v2/scheduling-history/routes/search": {
Expand Down Expand Up @@ -47897,7 +47900,7 @@
}
}
],
"description": "Search route histories scoped to a specific route.\n\n**Preconditions:**\n* Superadmin privilege required.\n"
"description": "Search route histories scoped to a specific route.\n\n**Preconditions:**\n* User privilege required.\n"
}
},
"/v2/service-catalogs/search": {
Expand Down
4 changes: 4 additions & 0 deletions src/ai/backend/common/data/permission/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ class ScopeType(enum.StrEnum):
SESSION = "session"
DEPLOYMENT = "deployment"
MODEL_DEPLOYMENT = "model_deployment"
ROUTING = "routing"
VFOLDER = "vfolder"
IMAGE = "image"
ARTIFACT = "artifact"
Expand Down Expand Up @@ -424,6 +425,9 @@ class RBACElementType(enum.StrEnum):
PROJECT_RESOURCE_POLICY = "project_resource_policy"
ROLE = "role"
AUDIT_LOG = "audit_log"
SESSION_HISTORY = "session:history"
DEPLOYMENT_HISTORY = "deployment:history"
ROUTE_HISTORY = "route:history"
EVENT_LOG = "event_log"

# === Admin page access control ===
Expand Down
3 changes: 3 additions & 0 deletions src/ai/backend/common/dto/manager/v2/rbac/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ class RBACElementTypeDTO(StrEnum):
PROJECT_RESOURCE_POLICY = "project_resource_policy"
ROLE = "role"
AUDIT_LOG = "audit_log"
SESSION_HISTORY = "session:history"
DEPLOYMENT_HISTORY = "deployment:history"
ROUTE_HISTORY = "route:history"
EVENT_LOG = "event_log"

# Admin page access control
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
SessionHistoryNode,
)
from ai.backend.common.dto.manager.v2.scheduling_history.types import SubStepResultInfo
from ai.backend.common.identifier.deployment import DeploymentID
from ai.backend.common.identifier.replica import ReplicaID
from ai.backend.common.types import SessionId
from ai.backend.manager.api.adapter_options.pagination.pagination import PaginationSpec
from ai.backend.manager.api.adapters.base import BaseAdapter
from ai.backend.manager.data.deployment.types import DeploymentHistoryData, RouteHistoryData
Expand Down Expand Up @@ -60,11 +62,6 @@
combine_conditions_or,
negate_conditions,
)
from ai.backend.manager.repositories.scheduling_history.types import (
DeploymentHistorySearchScope,
RouteHistorySearchScope,
SessionSchedulingHistorySearchScope,
)
from ai.backend.manager.services.scheduling_history.actions.search_deployment_history import (
SearchDeploymentHistoryAction,
)
Expand Down Expand Up @@ -203,10 +200,9 @@ async def session_scoped_search(
input: AdminSearchSessionHistoriesInput,
) -> AdminSearchSessionHistoriesPayload:
"""Search session scheduling histories scoped to a session."""
scope = SessionSchedulingHistorySearchScope(session_id=session_id)
querier = self._build_session_querier(input)
action_result = await self._processors.scheduling_history.search_session_scoped_history.wait_for_complete(
SearchSessionScopedHistoryAction(scope=scope, querier=querier)
SearchSessionScopedHistoryAction(session_id=SessionId(session_id), querier=querier)
)
return AdminSearchSessionHistoriesPayload(
items=[self._session_data_to_dto(h) for h in action_result.histories],
Expand Down Expand Up @@ -374,10 +370,11 @@ async def deployment_scoped_search(
input: AdminSearchDeploymentHistoriesInput,
) -> AdminSearchDeploymentHistoriesPayload:
"""Search deployment histories scoped to a deployment."""
scope = DeploymentHistorySearchScope(deployment_id=deployment_id)
querier = self._build_deployment_querier(input)
action_result = await self._processors.scheduling_history.search_deployment_scoped_history.wait_for_complete(
SearchDeploymentScopedHistoryAction(scope=scope, querier=querier)
SearchDeploymentScopedHistoryAction(
deployment_id=DeploymentID(deployment_id), querier=querier
)
)
return AdminSearchDeploymentHistoriesPayload(
items=[self._deployment_data_to_dto(h) for h in action_result.histories],
Expand Down Expand Up @@ -537,11 +534,10 @@ async def route_scoped_search(
input: AdminSearchRouteHistoriesInput,
) -> AdminSearchRouteHistoriesPayload:
"""Search route histories scoped to a route."""
scope = RouteHistorySearchScope(route_id=ReplicaID(route_id))
querier = self._build_route_querier(input)
action_result = (
await self._processors.scheduling_history.search_route_scoped_history.wait_for_complete(
SearchRouteScopedHistoryAction(scope=scope, querier=querier)
SearchRouteScopedHistoryAction(route_id=ReplicaID(route_id), querier=querier)
)
)
return AdminSearchRouteHistoriesPayload(
Expand Down
3 changes: 3 additions & 0 deletions src/ai/backend/manager/api/gql/rbac/types/permission.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,9 @@ async def scope(
| RBACElementType.KEYPAIR_RESOURCE_POLICY
| RBACElementType.PROJECT_RESOURCE_POLICY
| RBACElementType.AUDIT_LOG
| RBACElementType.SESSION_HISTORY
| RBACElementType.DEPLOYMENT_HISTORY
| RBACElementType.ROUTE_HISTORY
| RBACElementType.EVENT_LOG
| RBACElementType.NOTIFICATION_RULE
| RBACElementType.AGENT
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from typing import TYPE_CHECKING

from ai.backend.manager.api.rest.middleware.auth import superadmin_required
from ai.backend.manager.api.rest.middleware.auth import auth_required, superadmin_required
from ai.backend.manager.api.rest.routing import RouteRegistry

from .handler import V2SchedulingHistoryHandler
Expand All @@ -31,7 +31,7 @@ def register_v2_scheduling_history_routes(
"POST",
"/sessions/{session_id}/search",
handler.admin_session_scoped_search,
middlewares=[superadmin_required],
middlewares=[auth_required],
)

# Deployment history
Expand All @@ -45,7 +45,7 @@ def register_v2_scheduling_history_routes(
"POST",
"/deployments/{deployment_id}/search",
handler.admin_deployment_scoped_search,
middlewares=[superadmin_required],
middlewares=[auth_required],
)

# Route history
Expand All @@ -59,7 +59,7 @@ def register_v2_scheduling_history_routes(
"POST",
"/routes/{route_id}/search",
handler.admin_route_scoped_search,
middlewares=[superadmin_required],
middlewares=[auth_required],
)

return registry
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""add scheduling history read permissions to roles that can read the scoped entity

Revision ID: c8e2f41a67d5
Revises: 3f9a1c7b2e04
Create Date: 2026-07-20 00:00:00.000000

"""

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "c8e2f41a67d5"
down_revision = "3f9a1c7b2e04"
branch_labels = None
depends_on = None

# (scope entity that already carries a READ grant, history entity to mirror it onto)
_HISTORY_GRANTS = [
("session", "session:history"),
("model_deployment", "deployment:history"),
("routing", "route:history"),
]


def upgrade() -> None:
conn = op.get_bind()

# Reading an entity carries reading its scheduling history, so mirror every
# existing READ grant onto the history entity within the same scope.
# ON CONFLICT DO NOTHING keeps this idempotent for release-branch backports.
for scope_entity, history_entity in _HISTORY_GRANTS:
conn.execute(
sa.text("""
INSERT INTO permissions (role_id, scope_type, scope_id, entity_type, operation)
SELECT DISTINCT p.role_id, p.scope_type, p.scope_id, :history_entity, 'read'
FROM permissions p
WHERE p.entity_type = :scope_entity
AND p.operation = 'read'
ON CONFLICT DO NOTHING
"""),
{"scope_entity": scope_entity, "history_entity": history_entity},
)


def downgrade() -> None:
conn = op.get_bind()

for _, history_entity in _HISTORY_GRANTS:
conn.execute(
sa.text("""
DELETE FROM permissions
WHERE entity_type = :history_entity
AND operation = 'read'
"""),
{"history_entity": history_entity},
)
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@
SessionSchedulingHistoryListResult,
)
from ai.backend.manager.repositories.base import BatchQuerier
from ai.backend.manager.repositories.scheduling_history.types import (
DeploymentHistorySearchScope,
RouteHistorySearchScope,
SessionSchedulingHistorySearchScope,
)

from .db_source import SchedulingHistoryDBSource
from .types import (
DeploymentHistorySearchScope,
KernelSchedulingHistorySearchScope,
RouteHistorySearchScope,
SessionSchedulingHistorySearchScope,
)

if TYPE_CHECKING:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,53 +3,66 @@
from dataclasses import dataclass
from typing import override

from ai.backend.common.data.permission.types import EntityType
from ai.backend.manager.actions.action import BaseActionResult
from ai.backend.common.data.permission.types import EntityType, RBACElementType, ScopeType
from ai.backend.common.identifier.deployment import DeploymentID
from ai.backend.manager.actions.action.scope import BaseScopeAction, BaseScopeActionResult
from ai.backend.manager.actions.types import ActionOperationType
from ai.backend.manager.data.deployment.types import DeploymentHistoryData
from ai.backend.manager.data.permission.types import RBACElementRef
from ai.backend.manager.repositories.base import BatchQuerier
from ai.backend.manager.repositories.scheduling_history.types import (
DeploymentHistorySearchScope,
)

from .base import SchedulingHistoryAction


@dataclass
class SearchDeploymentScopedHistoryAction(SchedulingHistoryAction):
"""Action to search deployment history within a deployment scope.
class SearchDeploymentScopedHistoryAction(BaseScopeAction):
"""Action to search the scheduling history of one deployment.

This is the scoped version used by entity-scoped APIs.
Scope is required and specifies which deployment to query history for.
The history is the entity being read and the deployment is the scope containing it,
so the RBAC scope chain authorizes the caller for reading history there.
"""

scope: DeploymentHistorySearchScope
deployment_id: DeploymentID
querier: BatchQuerier

@override
@classmethod
def entity_type(cls) -> EntityType:
return EntityType.DEPLOYMENT_SCOPED_HISTORY
return EntityType.DEPLOYMENT_HISTORY

@override
@classmethod
def operation_type(cls) -> ActionOperationType:
return ActionOperationType.SEARCH

@override
def entity_id(self) -> str | None:
return str(self.scope.deployment_id)
def scope_type(self) -> ScopeType:
return ScopeType.MODEL_DEPLOYMENT

@override
def scope_id(self) -> str:
return str(self.deployment_id)

@override
def target_element(self) -> RBACElementRef:
return RBACElementRef(
element_type=RBACElementType.MODEL_DEPLOYMENT,
element_id=str(self.deployment_id),
)


@dataclass
class SearchDeploymentScopedHistoryActionResult(BaseActionResult):
"""Result of searching deployment history within scope."""
class SearchDeploymentScopedHistoryActionResult(BaseScopeActionResult):
"""Result of searching the scheduling history of one deployment."""

histories: list[DeploymentHistoryData]
total_count: int
has_next_page: bool
has_previous_page: bool
deployment_id: DeploymentID

@override
def scope_type(self) -> ScopeType:
return ScopeType.MODEL_DEPLOYMENT

@override
def entity_id(self) -> str | None:
return None
def scope_id(self) -> str:
return str(self.deployment_id)
Loading
Loading