"""Account session-policy endpoints — owner-only. GET /accounts/me/security — read the policy + system bounds. PATCH /accounts/me/security — set or clear the per-account override. POST /accounts/me/security/revoke-sessions lands in the next commit. See docs/plans/2026-05-13-session-expiration-policy.md §4.7 / §4.11. """ from datetime import datetime, timezone from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select, update as sa_update from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import require_account_owner from app.core.admin_database import get_admin_db from app.core.audit import log_audit from app.core.config import settings from app.core.security import resolve_session_policy from app.models.account import Account from app.models.refresh_token import RefreshToken from app.models.user import User from app.schemas.account_security import ( RevokeSessionsRequest, RevokeSessionsResponse, SessionPolicyResponse, SessionPolicyUpdateRequest, ) router = APIRouter(prefix="/accounts/me/security", tags=["account-security"]) def _policy_response(account: Account) -> SessionPolicyResponse: eff_idle, eff_abs = resolve_session_policy(account) return SessionPolicyResponse( idle_minutes=account.session_idle_minutes, absolute_minutes=account.session_absolute_minutes, effective_idle_minutes=eff_idle, effective_absolute_minutes=eff_abs, idle_minutes_min=settings.SESSION_IDLE_MINUTES_MIN, idle_minutes_max=settings.SESSION_IDLE_MINUTES_MAX, absolute_minutes_min=settings.SESSION_ABSOLUTE_MINUTES_MIN, absolute_minutes_max=settings.SESSION_ABSOLUTE_MINUTES_MAX, ) async def _load_account(db: AsyncSession, account_id) -> Account: return ( await db.execute(select(Account).where(Account.id == account_id)) ).scalar_one() @router.get("", response_model=SessionPolicyResponse) async def get_session_policy( current_user: Annotated[User, Depends(require_account_owner)], db: Annotated[AsyncSession, Depends(get_admin_db)], ): account = await _load_account(db, current_user.account_id) return _policy_response(account) @router.patch("", response_model=SessionPolicyResponse) async def update_session_policy( body: SessionPolicyUpdateRequest, current_user: Annotated[User, Depends(require_account_owner)], db: Annotated[AsyncSession, Depends(get_admin_db)], ): account = await _load_account(db, current_user.account_id) # Snapshot effective values BEFORE change, for audit. old_idle = account.session_idle_minutes old_abs = account.session_absolute_minutes effective_old_idle, effective_old_abs = resolve_session_policy(account) new_idle = body.idle_minutes new_abs = body.absolute_minutes # Per-field bound checks. NULL clears the override and is always valid. if new_idle is not None and not ( settings.SESSION_IDLE_MINUTES_MIN <= new_idle <= settings.SESSION_IDLE_MINUTES_MAX ): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=( f"idle_minutes must be between {settings.SESSION_IDLE_MINUTES_MIN} " f"and {settings.SESSION_IDLE_MINUTES_MAX}" ), ) if new_abs is not None and not ( settings.SESSION_ABSOLUTE_MINUTES_MIN <= new_abs <= settings.SESSION_ABSOLUTE_MINUTES_MAX ): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=( f"absolute_minutes must be between {settings.SESSION_ABSOLUTE_MINUTES_MIN} " f"and {settings.SESSION_ABSOLUTE_MINUTES_MAX}" ), ) # Effective-value invariant: idle must not exceed absolute after defaults. # The DB CHECK only catches the both-set case; this catches the partial- # override case where (e.g.) idle=43200 with absolute=NULL would yield an # effective idle larger than the system default absolute. effective_new_idle = new_idle if new_idle is not None else settings.SESSION_IDLE_MINUTES_DEFAULT effective_new_abs = new_abs if new_abs is not None else settings.SESSION_ABSOLUTE_MINUTES_DEFAULT if effective_new_idle > effective_new_abs: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=( f"Effective idle ({effective_new_idle}min) cannot exceed effective " f"absolute ({effective_new_abs}min)" ), ) account.session_idle_minutes = new_idle account.session_absolute_minutes = new_abs await log_audit( db, user_id=current_user.id, account_id=account.id, action="account.session_policy_update", resource_type="account", resource_id=account.id, details={ "old": {"idle_minutes": old_idle, "absolute_minutes": old_abs}, "new": {"idle_minutes": new_idle, "absolute_minutes": new_abs}, "effective_old": { "idle_minutes": effective_old_idle, "absolute_minutes": effective_old_abs, }, "effective_new": { "idle_minutes": effective_new_idle, "absolute_minutes": effective_new_abs, }, }, ) await db.commit() await db.refresh(account) return _policy_response(account) @router.post("/revoke-sessions", response_model=RevokeSessionsResponse) async def revoke_sessions( body: RevokeSessionsRequest, current_user: Annotated[User, Depends(require_account_owner)], db: Annotated[AsyncSession, Depends(get_admin_db)], ): """Bulk-revoke refresh tokens for users in the caller's account. `scope="all"` revokes every active session in the account, including the caller's own. `scope="others"` preserves the caller's sessions. The caller's access token is NOT revoked (we don't track access JTIs); it dies on its 5-minute timer. For `scope="all"`, the frontend is expected to log the caller out locally after the response. See docs/plans/2026-05-13-session-expiration-policy.md §4.11. """ # Subquery: refresh-token rows belonging to users in this account. user_ids_subq = select(User.id).where(User.account_id == current_user.account_id) stmt = ( sa_update(RefreshToken) .where( RefreshToken.user_id.in_(user_ids_subq), RefreshToken.revoked_at.is_(None), ) .values(revoked_at=datetime.now(timezone.utc)) .returning(RefreshToken.id) ) if body.scope == "others": stmt = stmt.where(RefreshToken.user_id != current_user.id) result = await db.execute(stmt) revoked_count = len(result.all()) await log_audit( db, user_id=current_user.id, account_id=current_user.account_id, action="account.sessions_revoked_bulk", resource_type="account", resource_id=current_user.account_id, details={"scope": body.scope, "revoked_count": revoked_count}, ) await db.commit() return RevokeSessionsResponse(revoked_count=revoked_count)