feat(access): add governed session management

This commit is contained in:
2026-08-19 22:50:15 +02:00
parent 38fc22c06b
commit 2be1dbc132
16 changed files with 1302 additions and 10 deletions
@@ -37,6 +37,33 @@ class AdminOverviewResponse(BaseModel):
capabilities: list[str] = Field(default_factory=list)
class AdminSessionItem(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 AdminSessionListResponse(BaseModel):
sessions: list[AdminSessionItem] = Field(default_factory=list)
class AdminSessionRevocationRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
current_password: str = Field(min_length=1, max_length=1024)
class AdminSessionRevocationResponse(BaseModel):
session: AdminSessionItem
revoked: bool
class TenantAdminItem(BaseModel):
id: str
slug: str = Field(min_length=1, max_length=100)
+140 -2
View File
@@ -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,
@@ -1,6 +1,7 @@
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import asdict
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -62,6 +63,10 @@ from govoplan_access.backend.api.v1.admin_common import (
_user_item,
)
from govoplan_access.backend.api.v1.admin_schemas import (
AdminSessionItem,
AdminSessionListResponse,
AdminSessionRevocationRequest,
AdminSessionRevocationResponse,
AdminApiKeyCreateRequest,
AdminApiKeyCreateResponse,
ApiKeyAdminItem,
@@ -137,6 +142,14 @@ from govoplan_access.backend.api.v1.admin_schemas import (
UserUpdateRequest,
)
from govoplan_access.backend.security.api_keys import create_api_key
from govoplan_access.backend.security.passwords import verify_password
from govoplan_access.backend.session_management import (
SessionSummary,
list_account_sessions,
revoke_account_session,
session_summary,
)
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope, require_any_scope, require_scope
from govoplan_core.audit.logging import audit_event, audit_from_principal
from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY, SqlAccessConfigurationProvider
@@ -2380,6 +2393,124 @@ def get_user_access_explanation(
)
def _admin_session_item(item: SessionSummary) -> AdminSessionItem:
return AdminSessionItem(**asdict(item))
def _require_session_admin_reauthorization(
principal: ApiPrincipal,
current_password: str,
) -> None:
if principal.auth_session is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Administrative session revocation requires an interactive session.",
)
if not verify_password(current_password, principal.account.password_hash):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Current password re-authorization failed.",
)
@router.get(
"/users/{user_id}/sessions",
response_model=AdminSessionListResponse,
)
def list_user_sessions(
user_id: str,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_any_scope("admin:users:read", "access:membership:read")
),
) -> AdminSessionListResponse:
tenant = _resolve_tenant(session, principal, tenant_id)
user = (
session.query(User)
.filter(User.id == user_id, User.tenant_id == tenant.id)
.one_or_none()
)
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
items = list_account_sessions(
session,
account_id=user.account_id,
tenant_id=tenant.id,
current_session_id=principal.session_id,
)
return AdminSessionListResponse(
sessions=[_admin_session_item(item) for item in items]
)
@router.post(
"/users/{user_id}/sessions/{session_id}/revoke",
response_model=AdminSessionRevocationResponse,
)
def revoke_user_session(
user_id: str,
session_id: str,
payload: AdminSessionRevocationRequest,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_any_scope("admin:users:update", "access:membership:update")
),
) -> AdminSessionRevocationResponse:
_require_session_admin_reauthorization(principal, payload.current_password)
tenant = _resolve_tenant(session, principal, tenant_id)
user = (
session.query(User)
.filter(User.id == user_id, User.tenant_id == tenant.id)
.one_or_none()
)
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
try:
item, changed = revoke_account_session(
session,
account_id=user.account_id,
session_id=session_id,
tenant_id=tenant.id,
protected_session_id=principal.session_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_from_principal(
session,
principal,
action="access.session.revoked_by_administrator",
scope="tenant",
object_type="access_auth_session",
object_id=item.id,
details={"target_membership_id": user.id},
)
session.commit()
principal_summary_cache.clear()
return AdminSessionRevocationResponse(
session=_admin_session_item(
session_summary(item, current_session_id=principal.session_id)
),
revoked=changed,
)
@router.get("/access/resource-explanation", response_model=ResourceAccessExplanationResponse)
def get_resource_access_explanation(
user_id: str = Query(...),