from __future__ import annotations from datetime import UTC, datetime from flask import request from flask_restx import Resource from werkzeug.exceptions import NotFound from controllers.openapi import openapi_ns from controllers.openapi._models import ( MAX_PAGE_LIMIT, AccountPayload, AccountResponse, PaginationEnvelope, RevokeResponse, SessionListResponse, SessionRow, WorkspacePayload, ) from controllers.openapi.auth.composition import auth_router from controllers.openapi.auth.data import AuthData from extensions.ext_database import db from extensions.ext_redis import redis_client from libs.oauth_bearer import ( Scope, TokenType, get_auth_ctx, ) from libs.rate_limit import ( LIMIT_ME_PER_ACCOUNT, enforce, ) from services.account_service import AccountService, TenantService from services.oauth_device_flow import ( list_active_sessions, revoke_oauth_token, token_belongs_to_subject, ) @openapi_ns.route("/account") class AccountApi(Resource): @openapi_ns.response(200, "Account info", openapi_ns.models[AccountResponse.__name__]) @auth_router.guard(scope=Scope.FULL, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT})) def get(self, *, auth_data: AuthData): enforce(LIMIT_ME_PER_ACCOUNT, key=f"account:{auth_data.account_id}") account_id_str = str(auth_data.account_id) if auth_data.account_id else None account = AccountService.get_account_by_id(db.session, account_id_str) if account_id_str else None memberships = TenantService.get_account_memberships(db.session, account_id_str) if account_id_str else [] default_ws_id = _pick_default_workspace(memberships) return AccountResponse( subject_type="account", subject_email=account.email if account else None, account=_account_payload(account) if account else None, workspaces=[_workspace_payload(m) for m in memberships], default_workspace_id=default_ws_id, ).model_dump(mode="json") @openapi_ns.route("/account/sessions/self") class AccountSessionsSelfApi(Resource): @openapi_ns.response(200, "Session revoked", openapi_ns.models[RevokeResponse.__name__]) @auth_router.guard(scope=Scope.FULL, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT})) def delete(self, *, auth_data: AuthData): revoke_oauth_token(db.session, redis_client, str(auth_data.token_id)) return RevokeResponse(status="revoked").model_dump(mode="json"), 200 @openapi_ns.route("/account/sessions") class AccountSessionsApi(Resource): @openapi_ns.response(200, "Session list", openapi_ns.models[SessionListResponse.__name__]) @auth_router.guard(scope=Scope.FULL, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT})) def get(self, *, auth_data: AuthData): ctx = get_auth_ctx() now = datetime.now(UTC) page = int(request.args.get("page", "1")) limit = min(int(request.args.get("limit", "100")), MAX_PAGE_LIMIT) all_rows = list_active_sessions(db.session, ctx, now) total = len(all_rows) sliced = all_rows[(page - 1) * limit : page * limit] items = [ SessionRow( id=str(r.id), prefix=r.prefix, client_id=r.client_id, device_label=r.device_label, created_at=_iso(r.created_at), last_used_at=_iso(r.last_used_at), expires_at=_iso(r.expires_at), ) for r in sliced ] return ( PaginationEnvelope.build(page=page, limit=limit, total=total, items=items).model_dump(mode="json"), 200, ) @openapi_ns.route("/account/sessions/") class AccountSessionByIdApi(Resource): @openapi_ns.response(200, "Session revoked", openapi_ns.models[RevokeResponse.__name__]) @auth_router.guard(scope=Scope.FULL, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT})) def delete(self, session_id: str, *, auth_data: AuthData): ctx = get_auth_ctx() # 404 (not 403) on cross-subject so the endpoint doesn't leak # token IDs that belong to other subjects. if not token_belongs_to_subject(db.session, session_id, ctx): raise NotFound("session not found") revoke_oauth_token(db.session, redis_client, session_id) return RevokeResponse(status="revoked").model_dump(mode="json"), 200 def _iso(dt: datetime | None) -> str | None: if dt is None: return None if dt.tzinfo is None: dt = dt.replace(tzinfo=UTC) return dt.isoformat().replace("+00:00", "Z") def _pick_default_workspace(memberships) -> str | None: if not memberships: return None for join, tenant in memberships: if getattr(join, "current", False): return str(tenant.id) return str(memberships[0][1].id) def _workspace_payload(row) -> WorkspacePayload: join, tenant = row return WorkspacePayload(id=str(tenant.id), name=tenant.name, role=getattr(join, "role", "")) def _account_payload(account) -> AccountPayload: return AccountPayload(id=str(account.id), email=account.email, name=account.name)