feat(access): add governed session management
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -38,7 +39,7 @@ from govoplan_core.core.idm import (
|
||||
IdmDirectory,
|
||||
OrganizationFunctionAssignmentRef,
|
||||
)
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, require_scope
|
||||
from govoplan_core.admin.settings import get_system_settings
|
||||
from govoplan_core.audit.logging import audit_event
|
||||
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
|
||||
@@ -78,6 +79,13 @@ from govoplan_access.backend.security.sessions import (
|
||||
create_auth_session,
|
||||
verify_auth_session_csrf,
|
||||
)
|
||||
from govoplan_access.backend.session_management import (
|
||||
SessionSummary,
|
||||
list_account_sessions,
|
||||
revoke_account_session,
|
||||
revoke_other_account_sessions,
|
||||
session_summary,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
@@ -96,6 +104,44 @@ class ActingContextListResponse(BaseModel):
|
||||
active_assignment_id: str | None = None
|
||||
|
||||
|
||||
class AccountSessionInfo(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
current: bool
|
||||
status: Literal["active", "expired", "revoked"]
|
||||
created_at: datetime
|
||||
last_seen_at: datetime | None = None
|
||||
expires_at: datetime
|
||||
revoked_at: datetime | None = None
|
||||
client: str | None = None
|
||||
|
||||
|
||||
class AccountSessionListResponse(BaseModel):
|
||||
sessions: list[AccountSessionInfo] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AccountSessionRevocationResponse(BaseModel):
|
||||
session: AccountSessionInfo
|
||||
revoked: bool
|
||||
|
||||
|
||||
class OtherSessionRevocationResponse(BaseModel):
|
||||
revoked_count: int
|
||||
|
||||
|
||||
def _account_session_info(item: SessionSummary) -> AccountSessionInfo:
|
||||
return AccountSessionInfo(**asdict(item))
|
||||
|
||||
|
||||
def _interactive_session(principal: ApiPrincipal) -> AuthSession:
|
||||
if principal.auth_session is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Session management requires an interactive browser session.",
|
||||
)
|
||||
return principal.auth_session
|
||||
|
||||
|
||||
def _acting_assignments(
|
||||
request: Request,
|
||||
*,
|
||||
@@ -1002,6 +1048,98 @@ def switch_tenant(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=AccountSessionListResponse)
|
||||
def list_own_sessions(
|
||||
principal: ApiPrincipal = Depends(require_scope("access:session:manage_own")),
|
||||
session: Session = Depends(get_session),
|
||||
) -> AccountSessionListResponse:
|
||||
current = _interactive_session(principal)
|
||||
items = list_account_sessions(
|
||||
session,
|
||||
account_id=principal.account_id,
|
||||
current_session_id=current.id,
|
||||
)
|
||||
return AccountSessionListResponse(
|
||||
sessions=[_account_session_info(item) for item in items]
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/revoke-others",
|
||||
response_model=OtherSessionRevocationResponse,
|
||||
)
|
||||
def revoke_own_other_sessions(
|
||||
principal: ApiPrincipal = Depends(require_scope("access:session:manage_own")),
|
||||
session: Session = Depends(get_session),
|
||||
) -> OtherSessionRevocationResponse:
|
||||
current = _interactive_session(principal)
|
||||
revoked_ids = revoke_other_account_sessions(
|
||||
session,
|
||||
account_id=principal.account_id,
|
||||
current_session_id=current.id,
|
||||
)
|
||||
if revoked_ids:
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
action="access.sessions.other_sessions_revoked",
|
||||
object_type="access_account",
|
||||
object_id=principal.account_id,
|
||||
details={"revoked_count": len(revoked_ids)},
|
||||
)
|
||||
session.commit()
|
||||
principal_summary_cache.clear()
|
||||
return OtherSessionRevocationResponse(revoked_count=len(revoked_ids))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/revoke",
|
||||
response_model=AccountSessionRevocationResponse,
|
||||
)
|
||||
def revoke_own_session(
|
||||
session_id: str,
|
||||
principal: ApiPrincipal = Depends(require_scope("access:session:manage_own")),
|
||||
session: Session = Depends(get_session),
|
||||
) -> AccountSessionRevocationResponse:
|
||||
current = _interactive_session(principal)
|
||||
try:
|
||||
item, changed = revoke_account_session(
|
||||
session,
|
||||
account_id=principal.account_id,
|
||||
session_id=session_id,
|
||||
protected_session_id=current.id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
if item is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found.",
|
||||
)
|
||||
if changed:
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
action="access.session.revoked",
|
||||
object_type="access_auth_session",
|
||||
object_id=item.id,
|
||||
details={"actor_kind": "self"},
|
||||
)
|
||||
session.commit()
|
||||
principal_summary_cache.clear()
|
||||
return AccountSessionRevocationResponse(
|
||||
session=_account_session_info(
|
||||
session_summary(item, current_session_id=current.id)
|
||||
),
|
||||
revoked=changed,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/acting-contexts", response_model=ActingContextListResponse)
|
||||
def list_acting_contexts(
|
||||
request: Request,
|
||||
|
||||
Reference in New Issue
Block a user