diff --git a/changes/9719.feature.md b/changes/9719.feature.md new file mode 100644 index 00000000000..cd2a10a5aca --- /dev/null +++ b/changes/9719.feature.md @@ -0,0 +1 @@ +Add REST endpoints and GraphQL schema for login session management (GET /login-sessions, DELETE /login-sessions/{id}, my_login_sessions query, update_user_login_security_policy and revoke_login_session mutations). \ No newline at end of file diff --git a/docs/manager/graphql-reference/supergraph.graphql b/docs/manager/graphql-reference/supergraph.graphql index ff61daf3b17..f3fd29f7695 100644 --- a/docs/manager/graphql-reference/supergraph.graphql +++ b/docs/manager/graphql-reference/supergraph.graphql @@ -5854,6 +5854,39 @@ enum LivenessStatus DEGRADED @join__enumValue(graph: STRAWBERRY) } +"""Added in 26.3.0. Login security policy settings for a user.""" +type LoginSecurityPolicyGQL + @join__type(graph: STRAWBERRY) +{ + """ + Maximum number of concurrent login sessions allowed. None means unlimited. + """ + maxConcurrentLogins: Int +} + +"""Added in 26.3.0. Represents an active login session for a user.""" +type LoginSessionGQL + @join__type(graph: STRAWBERRY) +{ + """Unique identifier of the login session.""" + id: UUID! + + """Opaque session token.""" + sessionToken: String! + + """IP address of the client that created the session.""" + clientIp: String + + """Timestamp when the session was created.""" + createdAt: DateTime! + + """Timestamp when the session expires, or None if no expiry.""" + expiredAt: DateTime + + """Reason for session creation or revocation, if any.""" + reason: String +} + """Added in 25.6.0. A pair of timestamp and value.""" type MetricResultValue @join__type(graph: GRAPHENE) @@ -7510,6 +7543,16 @@ type Mutation """Added in 26.3.0. Revoke a role from a user (admin only).""" adminRevokeRole(input: RevokeRoleInput!): RoleAssignment! @join__field(graph: STRAWBERRY) + + """ + Added in 26.3.0. Update the login security policy for a user (admin only). Requires superadmin privileges. + """ + updateUserLoginSecurityPolicy(userId: UUID!, input: UpdateLoginSecurityPolicyInput!): UpdateUserLoginSecurityPolicyPayload! @join__field(graph: STRAWBERRY) + + """ + Added in 26.3.0. Revoke a specific login session. Users may revoke their own sessions; admins may revoke any session. + """ + revokeLoginSession(sessionId: UUID!): RevokeLoginSessionPayload! @join__field(graph: STRAWBERRY) } """Added in 24.12.0.""" @@ -9517,6 +9560,11 @@ type Query """List route history (superadmin only)""" routeHistories(filter: RouteHistoryFilter = null, orderBy: [RouteHistoryOrderBy!] = null, before: String = null, after: String = null, first: Int = null, last: Int = null, limit: Int = null, offset: Int = null): RouteHistoryConnection @join__field(graph: STRAWBERRY) @deprecated(reason: "Use admin_route_histories instead. This API will be removed after v26.3.0. See BEP-1041 for migration guide.") + """ + Added in 26.3.0. List all active login sessions for the currently authenticated user. + """ + myLoginSessions: [LoginSessionGQL!]! @join__field(graph: STRAWBERRY) + """ Added in 26.2.0. Get a single user by UUID (admin only). Requires superadmin privileges. Returns an error if user is not found. """ @@ -10245,6 +10293,14 @@ type RestoreArtifactsPayload artifacts: [Artifact!]! } +"""Added in 26.3.0. Payload for revokeLoginSession mutation.""" +type RevokeLoginSessionPayload + @join__type(graph: STRAWBERRY) +{ + """Whether the session was successfully revoked.""" + success: Boolean! +} + """Added in 26.3.0. Input for revoking a role from a user""" input RevokeRoleInput @join__type(graph: STRAWBERRY) @@ -11923,6 +11979,18 @@ type UpdateHuggingFaceRegistryPayload huggingfaceRegistry: HuggingFaceRegistry! } +""" +Added in 26.3.0. Input for updating a user's login security policy. max_concurrent_logins must be a positive integer or None (unlimited). +""" +input UpdateLoginSecurityPolicyInput + @join__type(graph: STRAWBERRY) +{ + """ + Maximum number of concurrent login sessions allowed. Must be a positive integer (greater than 0), or None for unlimited. Zero and negative values are rejected. + """ + maxConcurrentLogins: Int = null +} + """Input for updating a notification channel""" input UpdateNotificationChannelInput @join__type(graph: STRAWBERRY) @@ -12113,6 +12181,14 @@ type UpdateRouteTrafficStatusPayload route: Route! } +"""Added in 26.3.0. Payload for updateUserLoginSecurityPolicy mutation.""" +type UpdateUserLoginSecurityPolicyPayload + @join__type(graph: STRAWBERRY) +{ + """The updated login security policy.""" + loginSecurityPolicy: LoginSecurityPolicyGQL! +} + """ Added in 26.3.0. Input for updating user information. All fields are optional - only provided fields will be updated. """ diff --git a/docs/manager/graphql-reference/v2-schema.graphql b/docs/manager/graphql-reference/v2-schema.graphql index e98175038c1..04180cd5638 100644 --- a/docs/manager/graphql-reference/v2-schema.graphql +++ b/docs/manager/graphql-reference/v2-schema.graphql @@ -3231,6 +3231,35 @@ enum LivenessStatus { DEGRADED } +"""Added in 26.3.0. Login security policy settings for a user.""" +type LoginSecurityPolicyGQL { + """ + Maximum number of concurrent login sessions allowed. None means unlimited. + """ + maxConcurrentLogins: Int +} + +"""Added in 26.3.0. Represents an active login session for a user.""" +type LoginSessionGQL { + """Unique identifier of the login session.""" + id: UUID! + + """Opaque session token.""" + sessionToken: String! + + """IP address of the client that created the session.""" + clientIp: String + + """Timestamp when the session was created.""" + createdAt: DateTime! + + """Timestamp when the session expires, or None if no expiry.""" + expiredAt: DateTime + + """Reason for session creation or revocation, if any.""" + reason: String +} + type ModelDeployment implements Node { """The Globally Unique ID of this object""" id: ID! @@ -3943,6 +3972,16 @@ type Mutation { """Added in 26.3.0. Revoke a role from a user (admin only).""" adminRevokeRole(input: RevokeRoleInput!): RoleAssignment! + + """ + Added in 26.3.0. Update the login security policy for a user (admin only). Requires superadmin privileges. + """ + updateUserLoginSecurityPolicy(userId: UUID!, input: UpdateLoginSecurityPolicyInput!): UpdateUserLoginSecurityPolicyPayload! + + """ + Added in 26.3.0. Revoke a specific login session. Users may revoke their own sessions; admins may revoke any session. + """ + revokeLoginSession(sessionId: UUID!): RevokeLoginSessionPayload! } """An object with a Globally Unique ID""" @@ -5244,6 +5283,11 @@ type Query { """List route history (superadmin only)""" routeHistories(filter: RouteHistoryFilter = null, orderBy: [RouteHistoryOrderBy!] = null, before: String = null, after: String = null, first: Int = null, last: Int = null, limit: Int = null, offset: Int = null): RouteHistoryConnection @deprecated(reason: "Use admin_route_histories instead. This API will be removed after v26.3.0. See BEP-1041 for migration guide.") + """ + Added in 26.3.0. List all active login sessions for the currently authenticated user. + """ + myLoginSessions: [LoginSessionGQL!]! + """ Added in 26.2.0. Get a single user by UUID (admin only). Requires superadmin privileges. Returns an error if user is not found. """ @@ -5886,6 +5930,12 @@ type RestoreArtifactsPayload { artifacts: [Artifact!]! } +"""Added in 26.3.0. Payload for revokeLoginSession mutation.""" +type RevokeLoginSessionPayload { + """Whether the session was successfully revoked.""" + success: Boolean! +} + """Added in 26.3.0. Input for revoking a role from a user""" input RevokeRoleInput { userId: UUID! @@ -6999,6 +7049,16 @@ type UpdateHuggingFaceRegistryPayload { huggingfaceRegistry: HuggingFaceRegistry! } +""" +Added in 26.3.0. Input for updating a user's login security policy. max_concurrent_logins must be a positive integer or None (unlimited). +""" +input UpdateLoginSecurityPolicyInput { + """ + Maximum number of concurrent login sessions allowed. Must be a positive integer (greater than 0), or None for unlimited. Zero and negative values are rejected. + """ + maxConcurrentLogins: Int = null +} + """Input for updating a notification channel""" input UpdateNotificationChannelInput { id: ID! @@ -7159,6 +7219,12 @@ type UpdateRouteTrafficStatusPayload { route: Route! } +"""Added in 26.3.0. Payload for updateUserLoginSecurityPolicy mutation.""" +type UpdateUserLoginSecurityPolicyPayload { + """The updated login security policy.""" + loginSecurityPolicy: LoginSecurityPolicyGQL! +} + """ Added in 26.3.0. Input for updating user information. All fields are optional - only provided fields will be updated. """ diff --git a/src/ai/backend/common/dto/manager/login_session/__init__.py b/src/ai/backend/common/dto/manager/login_session/__init__.py new file mode 100644 index 00000000000..78e281cbed4 --- /dev/null +++ b/src/ai/backend/common/dto/manager/login_session/__init__.py @@ -0,0 +1,7 @@ +""" +Common DTOs for login session management used by the Manager. + +Import directly from submodules: +- request: RevokeLoginSessionRequest, UpdateLoginSecurityPolicyRequest +- response: LoginSessionItemResponse, ListLoginSessionsResponse +""" diff --git a/src/ai/backend/common/dto/manager/login_session/request.py b/src/ai/backend/common/dto/manager/login_session/request.py new file mode 100644 index 00000000000..5ff57ce5f37 --- /dev/null +++ b/src/ai/backend/common/dto/manager/login_session/request.py @@ -0,0 +1,33 @@ +""" +Request DTOs for login session management. +Used by Manager REST API endpoints. +""" + +from __future__ import annotations + +from pydantic import Field + +from ai.backend.common.api_handlers import BaseRequestModel + +__all__ = ( + "RevokeLoginSessionRequest", + "UpdateLoginSecurityPolicyRequest", +) + + +class RevokeLoginSessionRequest(BaseRequestModel): + """Request to revoke a specific login session.""" + + session_id: str = Field( + description="Unique identifier of the login session to revoke", + ) + + +class UpdateLoginSecurityPolicyRequest(BaseRequestModel): + """Request to update the login security policy for a user.""" + + max_concurrent_logins: int | None = Field( + default=None, + gt=0, + description="Maximum number of concurrent login sessions allowed; None means unlimited", + ) diff --git a/src/ai/backend/common/dto/manager/login_session/response.py b/src/ai/backend/common/dto/manager/login_session/response.py new file mode 100644 index 00000000000..093ae6670ca --- /dev/null +++ b/src/ai/backend/common/dto/manager/login_session/response.py @@ -0,0 +1,45 @@ +""" +Response DTOs for login session management. +Used by Manager REST API endpoints. +""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import Field + +from ai.backend.common.api_handlers import BaseResponseModel + +__all__ = ( + "LoginSessionItemResponse", + "ListLoginSessionsResponse", +) + + +class LoginSessionItemResponse(BaseResponseModel): + """A single login session entry.""" + + id: str = Field(description="Unique identifier of the login session") + session_token: str = Field(description="Opaque token identifying this session") + client_ip: str | None = Field( + default=None, + description="IP address of the client that created this session", + ) + created_at: datetime = Field(description="Timestamp when the session was created") + expired_at: datetime | None = Field( + default=None, + description="Timestamp when the session expires; None if it does not expire", + ) + reason: str | None = Field( + default=None, + description="Human-readable reason for the session state (e.g., revocation reason)", + ) + + +class ListLoginSessionsResponse(BaseResponseModel): + """Response containing a list of login sessions for the current user.""" + + items: list[LoginSessionItemResponse] = Field( + description="List of active login sessions", + ) diff --git a/src/ai/backend/common/dto/manager/user/__init__.py b/src/ai/backend/common/dto/manager/user/__init__.py index feae769fe43..1c0a9789ed5 100644 --- a/src/ai/backend/common/dto/manager/user/__init__.py +++ b/src/ai/backend/common/dto/manager/user/__init__.py @@ -7,6 +7,7 @@ from .request import ( CreateUserRequest, DeleteUserRequest, + LoginSecurityPolicyRequest, PurgeUserRequest, SearchUsersRequest, UpdateUserRequest, @@ -38,6 +39,7 @@ "UserStatus", # Request DTOs "CreateUserRequest", + "LoginSecurityPolicyRequest", "UpdateUserRequest", "SearchUsersRequest", "DeleteUserRequest", diff --git a/src/ai/backend/common/dto/manager/user/request.py b/src/ai/backend/common/dto/manager/user/request.py index cebd7972977..55e478e2dab 100644 --- a/src/ai/backend/common/dto/manager/user/request.py +++ b/src/ai/backend/common/dto/manager/user/request.py @@ -7,7 +7,7 @@ from uuid import UUID -from pydantic import Field +from pydantic import Field, field_validator from ai.backend.common.api_handlers import BaseRequestModel from ai.backend.common.dto.manager.query import StringFilter, UUIDFilter @@ -17,6 +17,7 @@ __all__ = ( "CreateUserRequest", "DeleteUserRequest", + "LoginSecurityPolicyRequest", "PurgeUserRequest", "SearchUsersRequest", "UpdateUserRequest", @@ -25,6 +26,25 @@ ) +class LoginSecurityPolicyRequest(BaseRequestModel): + """Login security policy settings for a user.""" + + max_concurrent_logins: int | None = Field( + default=None, + description=( + "Maximum number of concurrent login sessions allowed. " + "Must be a positive integer (greater than 0), or None for unlimited." + ), + ) + + @field_validator("max_concurrent_logins") + @classmethod + def validate_max_concurrent_logins(cls, v: int | None) -> int | None: + if v is not None and v <= 0: + raise ValueError("max_concurrent_logins must be a positive integer or None") + return v + + class CreateUserRequest(BaseRequestModel): """Request to create a new user.""" @@ -81,6 +101,9 @@ class UpdateUserRequest(BaseRequestModel): default=None, description="Updated container additional GIDs" ) group_ids: list[str] | None = Field(default=None, description="Updated group IDs") + login_security_policy: LoginSecurityPolicyRequest | None = Field( + default=None, description="Login security policy settings (e.g. max_concurrent_logins)" + ) class UserFilter(BaseRequestModel): diff --git a/src/ai/backend/manager/api/gql/login_session/__init__.py b/src/ai/backend/manager/api/gql/login_session/__init__.py new file mode 100644 index 00000000000..8a1301be9a5 --- /dev/null +++ b/src/ai/backend/manager/api/gql/login_session/__init__.py @@ -0,0 +1,29 @@ +"""LoginSession GraphQL API package. + +Added in 26.3.0. Provides login session management API including +session listing, revocation, and login security policy updates. +""" + +from .resolver import ( + my_login_sessions, + revoke_login_session, + update_user_login_security_policy, +) +from .types import ( + LoginSecurityPolicyGQL, + LoginSessionGQL, + RevokeLoginSessionPayloadGQL, + UpdateLoginSecurityPolicyInputGQL, + UpdateUserLoginSecurityPolicyPayloadGQL, +) + +__all__ = [ + "LoginSessionGQL", + "LoginSecurityPolicyGQL", + "UpdateLoginSecurityPolicyInputGQL", + "UpdateUserLoginSecurityPolicyPayloadGQL", + "RevokeLoginSessionPayloadGQL", + "my_login_sessions", + "update_user_login_security_policy", + "revoke_login_session", +] diff --git a/src/ai/backend/manager/api/gql/login_session/resolver/__init__.py b/src/ai/backend/manager/api/gql/login_session/resolver/__init__.py new file mode 100644 index 00000000000..1c26537a3a1 --- /dev/null +++ b/src/ai/backend/manager/api/gql/login_session/resolver/__init__.py @@ -0,0 +1,10 @@ +"""LoginSession GraphQL resolvers.""" + +from .mutation import revoke_login_session, update_user_login_security_policy +from .query import my_login_sessions + +__all__ = [ + "my_login_sessions", + "update_user_login_security_policy", + "revoke_login_session", +] diff --git a/src/ai/backend/manager/api/gql/login_session/resolver/mutation.py b/src/ai/backend/manager/api/gql/login_session/resolver/mutation.py new file mode 100644 index 00000000000..8bcb1e8ea56 --- /dev/null +++ b/src/ai/backend/manager/api/gql/login_session/resolver/mutation.py @@ -0,0 +1,63 @@ +"""LoginSession GraphQL mutation resolvers.""" + +from __future__ import annotations + +from uuid import UUID + +import strawberry +from strawberry import Info + +from ai.backend.manager.api.gql.login_session.types.inputs import UpdateLoginSecurityPolicyInputGQL +from ai.backend.manager.api.gql.login_session.types.payloads import ( + RevokeLoginSessionPayloadGQL, + UpdateUserLoginSecurityPolicyPayloadGQL, +) +from ai.backend.manager.api.gql.types import StrawberryGQLContext +from ai.backend.manager.api.gql.utils import check_admin_only + + +@strawberry.mutation( + description=( + "Added in 26.3.0. Update the login security policy for a user (admin only). " + "Requires superadmin privileges." + ) +) # type: ignore[misc] +async def update_user_login_security_policy( + info: Info[StrawberryGQLContext], + user_id: UUID, + input: UpdateLoginSecurityPolicyInputGQL, +) -> UpdateUserLoginSecurityPolicyPayloadGQL: + """Update login security policy for a user. + + Args: + info: Strawberry GraphQL context. + user_id: UUID of the user to update. + input: Login security policy update input. + + Raises: + NotImplementedError: This mutation is not yet implemented. + """ + check_admin_only() + raise NotImplementedError("update_user_login_security_policy is not yet implemented") + + +@strawberry.mutation( + description=( + "Added in 26.3.0. Revoke a specific login session. " + "Users may revoke their own sessions; admins may revoke any session." + ) +) # type: ignore[misc] +async def revoke_login_session( + info: Info[StrawberryGQLContext], + session_id: UUID, +) -> RevokeLoginSessionPayloadGQL: + """Revoke a login session by ID. + + Args: + info: Strawberry GraphQL context. + session_id: UUID of the login session to revoke. + + Raises: + NotImplementedError: This mutation is not yet implemented. + """ + raise NotImplementedError("revoke_login_session is not yet implemented") diff --git a/src/ai/backend/manager/api/gql/login_session/resolver/query.py b/src/ai/backend/manager/api/gql/login_session/resolver/query.py new file mode 100644 index 00000000000..74ede8016bb --- /dev/null +++ b/src/ai/backend/manager/api/gql/login_session/resolver/query.py @@ -0,0 +1,25 @@ +"""LoginSession GraphQL query resolvers.""" + +from __future__ import annotations + +import strawberry +from strawberry import Info + +from ai.backend.manager.api.gql.login_session.types.node import LoginSessionGQL +from ai.backend.manager.api.gql.types import StrawberryGQLContext + + +@strawberry.field( + description=( + "Added in 26.3.0. List all active login sessions for the currently authenticated user." + ) +) # type: ignore[misc] +async def my_login_sessions( + info: Info[StrawberryGQLContext], +) -> list[LoginSessionGQL]: + """Return the list of active login sessions for the current user. + + Raises: + NotImplementedError: This query is not yet implemented. + """ + raise NotImplementedError("my_login_sessions is not yet implemented") diff --git a/src/ai/backend/manager/api/gql/login_session/types/__init__.py b/src/ai/backend/manager/api/gql/login_session/types/__init__.py new file mode 100644 index 00000000000..6563146626a --- /dev/null +++ b/src/ai/backend/manager/api/gql/login_session/types/__init__.py @@ -0,0 +1,13 @@ +"""LoginSession GraphQL types.""" + +from .inputs import UpdateLoginSecurityPolicyInputGQL +from .node import LoginSecurityPolicyGQL, LoginSessionGQL +from .payloads import RevokeLoginSessionPayloadGQL, UpdateUserLoginSecurityPolicyPayloadGQL + +__all__ = [ + "LoginSessionGQL", + "LoginSecurityPolicyGQL", + "UpdateLoginSecurityPolicyInputGQL", + "UpdateUserLoginSecurityPolicyPayloadGQL", + "RevokeLoginSessionPayloadGQL", +] diff --git a/src/ai/backend/manager/api/gql/login_session/types/inputs.py b/src/ai/backend/manager/api/gql/login_session/types/inputs.py new file mode 100644 index 00000000000..1ec2209c6fd --- /dev/null +++ b/src/ai/backend/manager/api/gql/login_session/types/inputs.py @@ -0,0 +1,29 @@ +"""LoginSession GraphQL input types.""" + +from __future__ import annotations + +import strawberry + + +@strawberry.input( + name="UpdateLoginSecurityPolicyInput", + description=( + "Added in 26.3.0. Input for updating a user's login security policy. " + "max_concurrent_logins must be a positive integer or None (unlimited)." + ), +) +class UpdateLoginSecurityPolicyInputGQL: + """Input for updating login security policy.""" + + max_concurrent_logins: int | None = strawberry.field( + default=None, + description=( + "Maximum number of concurrent login sessions allowed. " + "Must be a positive integer (greater than 0), or None for unlimited. " + "Zero and negative values are rejected." + ), + ) + + def __post_init__(self) -> None: + if self.max_concurrent_logins is not None and self.max_concurrent_logins <= 0: + raise ValueError("max_concurrent_logins must be a positive integer or None") diff --git a/src/ai/backend/manager/api/gql/login_session/types/node.py b/src/ai/backend/manager/api/gql/login_session/types/node.py new file mode 100644 index 00000000000..8e349ca3469 --- /dev/null +++ b/src/ai/backend/manager/api/gql/login_session/types/node.py @@ -0,0 +1,41 @@ +"""LoginSession GraphQL node types.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +import strawberry + + +@strawberry.type( + name="LoginSessionGQL", + description="Added in 26.3.0. Represents an active login session for a user.", +) +class LoginSessionGQL: + """An active login session.""" + + id: UUID = strawberry.field(description="Unique identifier of the login session.") + session_token: str = strawberry.field(description="Opaque session token.") + client_ip: str | None = strawberry.field( + description="IP address of the client that created the session." + ) + created_at: datetime = strawberry.field(description="Timestamp when the session was created.") + expired_at: datetime | None = strawberry.field( + description="Timestamp when the session expires, or None if no expiry." + ) + reason: str | None = strawberry.field( + description="Reason for session creation or revocation, if any." + ) + + +@strawberry.type( + name="LoginSecurityPolicyGQL", + description="Added in 26.3.0. Login security policy settings for a user.", +) +class LoginSecurityPolicyGQL: + """Login security policy for a user.""" + + max_concurrent_logins: int | None = strawberry.field( + description="Maximum number of concurrent login sessions allowed. None means unlimited." + ) diff --git a/src/ai/backend/manager/api/gql/login_session/types/payloads.py b/src/ai/backend/manager/api/gql/login_session/types/payloads.py new file mode 100644 index 00000000000..049aff658d3 --- /dev/null +++ b/src/ai/backend/manager/api/gql/login_session/types/payloads.py @@ -0,0 +1,29 @@ +"""LoginSession GraphQL mutation payload types.""" + +from __future__ import annotations + +import strawberry + +from .node import LoginSecurityPolicyGQL + + +@strawberry.type( + name="UpdateUserLoginSecurityPolicyPayload", + description="Added in 26.3.0. Payload for updateUserLoginSecurityPolicy mutation.", +) +class UpdateUserLoginSecurityPolicyPayloadGQL: + """Payload for login security policy update.""" + + login_security_policy: LoginSecurityPolicyGQL = strawberry.field( + description="The updated login security policy." + ) + + +@strawberry.type( + name="RevokeLoginSessionPayload", + description="Added in 26.3.0. Payload for revokeLoginSession mutation.", +) +class RevokeLoginSessionPayloadGQL: + """Payload for login session revocation.""" + + success: bool = strawberry.field(description="Whether the session was successfully revoked.") diff --git a/src/ai/backend/manager/api/gql/schema.py b/src/ai/backend/manager/api/gql/schema.py index 70852ca2dc6..40e2f0cfe6e 100644 --- a/src/ai/backend/manager/api/gql/schema.py +++ b/src/ai/backend/manager/api/gql/schema.py @@ -124,6 +124,11 @@ image_v2, ) from .kernel.resolver import admin_kernels_v2, kernel_v2, session_kernels_v2 +from .login_session.resolver import ( + my_login_sessions, + revoke_login_session, + update_user_login_security_policy, +) from .notification import ( admin_create_notification_channel, admin_create_notification_rule, @@ -360,6 +365,8 @@ class Query: session_scheduling_histories = session_scheduling_histories deployment_histories = deployment_histories route_histories = route_histories + # Login Session APIs (added in 26.3.0) + my_login_sessions = my_login_sessions # User V2 APIs admin_user_v2 = admin_user_v2 admin_users_v2 = admin_users_v2 @@ -482,6 +489,9 @@ class Mutation: admin_delete_permission = admin_delete_permission admin_assign_role = admin_assign_role admin_revoke_role = admin_revoke_role + # Login Session APIs (added in 26.3.0) + update_user_login_security_policy = update_user_login_security_policy + revoke_login_session = revoke_login_session @strawberry.type diff --git a/src/ai/backend/manager/api/rest/login_session/__init__.py b/src/ai/backend/manager/api/rest/login_session/__init__.py new file mode 100644 index 00000000000..29d1c542963 --- /dev/null +++ b/src/ai/backend/manager/api/rest/login_session/__init__.py @@ -0,0 +1,3 @@ +from .registry import register_login_session_routes + +__all__ = ["register_login_session_routes"] diff --git a/src/ai/backend/manager/api/rest/login_session/handler.py b/src/ai/backend/manager/api/rest/login_session/handler.py new file mode 100644 index 00000000000..4c0c3cfcabb --- /dev/null +++ b/src/ai/backend/manager/api/rest/login_session/handler.py @@ -0,0 +1,43 @@ +"""Login session handler. + +Provides stub endpoints for login session management. +All methods raise NotImplementedAPI until the integration story (BA-4905) is completed. +""" + +from __future__ import annotations + +import logging +from typing import Final + +from ai.backend.common.api_handlers import APIResponse, PathParam +from ai.backend.logging import BraceStyleAdapter +from ai.backend.manager.dto.context import UserContext +from ai.backend.manager.dto.login_session_request import RevokeLoginSessionPathParam +from ai.backend.manager.errors.api import NotImplementedAPI + +log: Final = BraceStyleAdapter(logging.getLogger(__spec__.name)) + + +class LoginSessionHandler: + """Login session API handler with constructor-injected dependencies.""" + + # ------------------------------------------------------------------ + # list_sessions (GET /login-sessions) + # ------------------------------------------------------------------ + + async def list_sessions(self, ctx: UserContext) -> APIResponse: + log.info("LOGIN_SESSION.LIST(ak:{})", ctx.access_key) + raise NotImplementedAPI + + # ------------------------------------------------------------------ + # revoke_session (DELETE /login-sessions/{session_id}) + # ------------------------------------------------------------------ + + async def revoke_session( + self, + path: PathParam[RevokeLoginSessionPathParam], + ctx: UserContext, + ) -> APIResponse: + params = path.parsed + log.info("LOGIN_SESSION.REVOKE(ak:{}, session_id:{})", ctx.access_key, params.session_id) + raise NotImplementedAPI diff --git a/src/ai/backend/manager/api/rest/login_session/registry.py b/src/ai/backend/manager/api/rest/login_session/registry.py new file mode 100644 index 00000000000..0ed0787188f --- /dev/null +++ b/src/ai/backend/manager/api/rest/login_session/registry.py @@ -0,0 +1,25 @@ +"""Login session module registrar.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ai.backend.manager.api.rest.middleware.auth import auth_required +from ai.backend.manager.api.rest.routing import RouteRegistry + +from .handler import LoginSessionHandler + +if TYPE_CHECKING: + from ai.backend.manager.api.rest.types import RouteDeps + + +def register_login_session_routes( + handler: LoginSessionHandler, route_deps: RouteDeps +) -> RouteRegistry: + """Build the login-sessions sub-application.""" + reg = RouteRegistry.create("login-sessions", route_deps.cors_options) + + reg.add("GET", "", handler.list_sessions, middlewares=[auth_required]) + reg.add("DELETE", r"/{session_id}", handler.revoke_session, middlewares=[auth_required]) + + return reg diff --git a/src/ai/backend/manager/api/rest/tree.py b/src/ai/backend/manager/api/rest/tree.py index 52c3db3cdeb..45966b1f39a 100644 --- a/src/ai/backend/manager/api/rest/tree.py +++ b/src/ai/backend/manager/api/rest/tree.py @@ -96,6 +96,8 @@ def build_api_routes( from .health.registry import register_health_routes from .image.handler import ImageHandler from .image.registry import register_image_routes + from .login_session.handler import LoginSessionHandler + from .login_session.registry import register_login_session_routes from .manager.handler import ManagerHandler from .manager.registry import register_manager_api_routes from .notification.handler import NotificationHandler @@ -144,6 +146,7 @@ def build_api_routes( ) # 2. Build all handlers + login_session_handler = LoginSessionHandler() acl_handler = AclHandler() auth_handler = AuthHandler(auth=processors.auth) agent_handler = AgentHandler(agent=processors.agent) @@ -348,4 +351,5 @@ def build_api_routes( register_export_routes(export_handler, route_deps), register_agent_routes(agent_handler, route_deps), register_resource_slot_routes(resource_slot_handler, route_deps), + register_login_session_routes(login_session_handler, route_deps), ] diff --git a/src/ai/backend/manager/api/rest/user/adapter.py b/src/ai/backend/manager/api/rest/user/adapter.py index 1c7e97e1a3b..de5543e9c6f 100644 --- a/src/ai/backend/manager/api/rest/user/adapter.py +++ b/src/ai/backend/manager/api/rest/user/adapter.py @@ -6,6 +6,7 @@ from __future__ import annotations +from typing import Any from uuid import UUID from ai.backend.common.data.user.types import UserRole @@ -90,6 +91,7 @@ def build_updater( container_main_gid = TriState[int].nop() container_gids = TriState[list[int]].nop() group_ids = OptionalState[list[str]].nop() + login_security_policy: OptionalState[dict[str, Any]] = OptionalState.nop() if request.username is not None: username = OptionalState.update(request.username) @@ -125,6 +127,10 @@ def build_updater( container_gids = TriState.update(request.container_gids) if request.group_ids is not None: group_ids = OptionalState.update(request.group_ids) + if request.login_security_policy is not None: + login_security_policy = OptionalState.update( + request.login_security_policy.model_dump(exclude_none=False) + ) updater_spec = UserUpdaterSpec( username=username, @@ -144,6 +150,7 @@ def build_updater( container_main_gid=container_main_gid, container_gids=container_gids, group_ids=group_ids, + login_security_policy=login_security_policy, ) return Updater( spec=updater_spec, pk_value=UUID(int=0) diff --git a/src/ai/backend/manager/dto/login_session_request.py b/src/ai/backend/manager/dto/login_session_request.py new file mode 100644 index 00000000000..5d04dd2ffc6 --- /dev/null +++ b/src/ai/backend/manager/dto/login_session_request.py @@ -0,0 +1,13 @@ +""" +Manager-specific path parameter models for login session REST API. +""" + +from __future__ import annotations + +from pydantic import Field + +from ai.backend.common.api_handlers import BaseRequestModel + + +class RevokeLoginSessionPathParam(BaseRequestModel): + session_id: str = Field(description="The login session ID to revoke") diff --git a/src/ai/backend/manager/repositories/user/updaters.py b/src/ai/backend/manager/repositories/user/updaters.py index 665e9ac46c4..a2ff57ff022 100644 --- a/src/ai/backend/manager/repositories/user/updaters.py +++ b/src/ai/backend/manager/repositories/user/updaters.py @@ -37,6 +37,7 @@ class UserUpdaterSpec(UpdaterSpec[UserRow]): container_main_gid: TriState[int] = field(default_factory=TriState.nop) container_gids: TriState[list[int]] = field(default_factory=TriState.nop) group_ids: OptionalState[list[str]] = field(default_factory=OptionalState.nop) + login_security_policy: OptionalState[dict[str, Any]] = field(default_factory=OptionalState.nop) @property @override @@ -65,6 +66,7 @@ def build_values(self) -> dict[str, Any]: self.container_uid.update_dict(to_update, "container_uid") self.container_main_gid.update_dict(to_update, "container_main_gid") self.container_gids.update_dict(to_update, "container_gids") + self.login_security_policy.update_dict(to_update, "login_security_policy") # Set status based on is_active if not explicitly set status = self.status.optional_value() if status is not None: