intermittent commit

This commit is contained in:
2026-07-14 13:22:09 +02:00
parent f2afcaa09c
commit ab07075a67
17 changed files with 1821 additions and 586 deletions

View File

@@ -1,9 +1,17 @@
from __future__ import annotations
from dataclasses import dataclass
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.orm import Session
from govoplan_core.api.v1.schemas import (
AuthGroupsResponse,
AuthProfileResponse,
AuthRolesResponse,
AuthShellResponse,
AuthSessionResponse,
AuthSessionUserInfo,
GroupInfo,
LoginRequest,
LoginResponse,
@@ -22,9 +30,9 @@ from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityD
from govoplan_core.core.registry import PlatformRegistry
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal
from govoplan_core.admin.settings import get_system_settings
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.audit.logging import audit_event
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
from govoplan_access.backend.db.models import Account, Tenant, User
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, Group, Role, Tenant, User
from govoplan_core.db.session import get_session
from govoplan_core.i18n import (
i18n_settings,
@@ -36,24 +44,39 @@ from govoplan_core.i18n import (
tenant_enabled_language_codes,
user_enabled_language_codes,
)
from govoplan_access.backend.permissions.catalog import normalize_email, scopes_grant
from govoplan_access.backend.permissions.catalog import intersect_api_key_scopes, normalize_email, scopes_grant
from govoplan_core.security.time import utc_now
from govoplan_core.settings import settings
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account
from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher
from govoplan_access.backend.security.api_keys import authenticate_api_key
from govoplan_access.backend.security.passwords import verify_password
from govoplan_access.backend.security.sessions import (
authenticate_session_token,
collect_user_authorization_context,
collect_system_roles,
collect_tenant_memberships,
collect_user_groups,
collect_user_roles,
collect_user_scopes,
create_auth_session,
verify_auth_session_csrf,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@dataclass(slots=True)
class AuthContext:
account: Account
user: User
tenant: Tenant
auth_method: str
source: str
auth_session: AuthSession | None = None
api_key: ApiKey | None = None
def _cookie_samesite() -> str:
value = settings.auth_cookie_samesite.lower().strip()
if value not in {"lax", "strict", "none"}:
@@ -125,6 +148,18 @@ def _user_info(
)
def _session_user_info(user: User, account: Account) -> AuthSessionUserInfo:
return AuthSessionUserInfo(
id=user.id,
account_id=account.id,
email=account.email,
display_name=account.display_name or user.display_name,
tenant_display_name=user.display_name,
is_tenant_admin=user.is_tenant_admin,
password_reset_required=account.password_reset_required,
)
def _roles_info(roles, *, level: str = "tenant") -> list[RoleInfo]:
return [
RoleInfo(
@@ -142,10 +177,20 @@ def _groups_info(groups) -> list[GroupInfo]:
return [GroupInfo(id=group.id, slug=group.slug, name=group.name) for group in groups]
def _tenant_memberships(session: Session, account: Account) -> list[TenantMembershipInfo]:
def _tenant_memberships(
session: Session,
account: Account,
*,
active_user_id: str | None = None,
active_tenant_roles: list[Role] | None = None,
) -> list[TenantMembershipInfo]:
memberships: list[TenantMembershipInfo] = []
for user, tenant in collect_tenant_memberships(session, account):
roles = collect_user_roles(session, user)
roles = (
active_tenant_roles
if active_tenant_roles is not None and user.id == active_user_id
else collect_user_roles(session, user)
)
memberships.append(
TenantMembershipInfo(
id=tenant.id,
@@ -159,6 +204,20 @@ def _tenant_memberships(session: Session, account: Account) -> list[TenantMember
return memberships
def _tenant_membership_summaries(session: Session, account: Account) -> list[TenantMembershipInfo]:
return [
TenantMembershipInfo(
id=tenant.id,
slug=tenant.slug,
name=tenant.name,
is_active=tenant.is_active and user.is_active,
default_locale=tenant.default_locale,
roles=[],
)
for user, tenant in collect_tenant_memberships(session, account)
]
def _language_context(session: Session, *, tenant: Tenant, user: User) -> dict[str, object]:
system_settings = get_system_settings(session)
system_payload = system_i18n_payload(system_settings)
@@ -214,6 +273,254 @@ def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Accoun
return account, row[0], row[1]
def _extract_auth_token(request: Request) -> tuple[str | None, str]:
x_api_key = request.headers.get("x-api-key")
if x_api_key:
return x_api_key.strip(), "api_key"
authorization = request.headers.get("authorization")
if authorization and authorization.lower().startswith("bearer "):
return authorization[7:].strip(), "bearer"
cookie_token = request.cookies.get(settings.auth_session_cookie_name)
if cookie_token:
return cookie_token.strip(), "cookie"
return None, "none"
def _active_context_or_401(
*,
user: User | None,
account: Account | None,
tenant: Tenant | None,
expected_tenant_id: str,
) -> tuple[Account, User, Tenant]:
if (
not user or not account or not tenant
or not user.is_active or not account.is_active or not tenant.is_active
or user.account_id != account.id
or user.tenant_id != expected_tenant_id
or tenant.id != expected_tenant_id
):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent session")
return account, user, tenant
def _session_response(
*,
account: Account,
user: User,
tenant: Tenant,
auth_method: str,
auth_session: AuthSession | None = None,
api_key: ApiKey | None = None,
) -> AuthSessionResponse:
active_tenant = _tenant_info(tenant)
return AuthSessionResponse(
authenticated=True,
auth_method=auth_method, # type: ignore[arg-type]
user=_session_user_info(user, account),
tenant=active_tenant,
active_tenant=active_tenant,
session_id=auth_session.id if auth_session else None,
api_key_id=api_key.id if api_key else None,
expires_at=auth_session.expires_at if auth_session else api_key.expires_at if api_key else None,
)
def _resolve_auth_context(request: Request, session: Session) -> AuthContext:
token, source = _extract_auth_token(request)
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token")
api_key = authenticate_api_key(session, token) if source != "cookie" else None
if api_key is not None:
user = session.get(User, api_key.user_id)
account = session.get(Account, user.account_id) if user else None
tenant = session.get(Tenant, api_key.tenant_id)
account, user, tenant = _active_context_or_401(
user=user,
account=account,
tenant=tenant,
expected_tenant_id=api_key.tenant_id,
)
return AuthContext(
account=account,
user=user,
tenant=tenant,
auth_method="api_key",
source=source,
api_key=api_key,
)
auth_session = authenticate_session_token(session, token)
if auth_session is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
user = session.get(User, auth_session.user_id)
account = session.get(Account, auth_session.account_id)
tenant = session.get(Tenant, auth_session.tenant_id)
account, user, tenant = _active_context_or_401(
user=user,
account=account,
tenant=tenant,
expected_tenant_id=auth_session.tenant_id,
)
return AuthContext(
account=account,
user=user,
tenant=tenant,
auth_method="session",
source=source,
auth_session=auth_session,
)
def _shell_response(
session: Session,
*,
account: Account,
user: User,
tenant: Tenant,
scopes: list[str],
auth_method: str,
auth_session: AuthSession | None = None,
api_key: ApiKey | None = None,
) -> AuthShellResponse:
active_tenant = _tenant_info(tenant)
memberships = (
_tenant_membership_summaries(session, account)
if auth_session is not None
else [
TenantMembershipInfo(
id=tenant.id,
slug=tenant.slug,
name=tenant.name,
is_active=tenant.is_active and user.is_active,
default_locale=tenant.default_locale,
roles=[],
)
]
)
return AuthShellResponse(
user=_user_info(user, account),
tenant=active_tenant,
active_tenant=active_tenant,
tenants=memberships,
scopes=scopes,
principal=PrincipalContextInfo(
account_id=account.id,
membership_id=user.id,
tenant_id=tenant.id,
scopes=scopes,
auth_method=auth_method, # type: ignore[arg-type]
api_key_id=api_key.id if api_key else None,
session_id=auth_session.id if auth_session else None,
email=account.email,
display_name=account.display_name or user.display_name,
),
)
def _resolve_lightweight_session(request: Request, session: Session) -> AuthSessionResponse:
context = _resolve_auth_context(request, session)
return _session_response(
account=context.account,
user=context.user,
tenant=context.tenant,
auth_method=context.auth_method,
auth_session=context.auth_session,
api_key=context.api_key,
)
def _resolve_shell_auth(request: Request, session: Session) -> AuthShellResponse:
context = _resolve_auth_context(request, session)
if context.api_key is not None:
user_scopes = collect_user_scopes(session, context.user, include_system=False)
scopes = intersect_api_key_scopes(user_scopes, context.api_key.scopes or [])
return _shell_response(
session,
account=context.account,
user=context.user,
tenant=context.tenant,
scopes=scopes,
auth_method="api_key",
api_key=context.api_key,
)
scopes = collect_user_scopes(session, context.user, include_system=True)
return _shell_response(
session,
account=context.account,
user=context.user,
tenant=context.tenant,
scopes=scopes,
auth_method="session",
auth_session=context.auth_session,
)
def _profile_response(session: Session, context: AuthContext) -> AuthProfileResponse:
languages = _language_context(session, tenant=context.tenant, user=context.user)
tenant_enabled = list(languages["tenant_enabled_language_codes"])
user_enabled = list(languages["user_enabled_language_codes"])
preferred_language = str(languages["preferred_language"])
active_tenant = _tenant_info(context.tenant, enabled_language_codes=tenant_enabled)
return AuthProfileResponse(
user=_user_info(
context.user,
context.account,
preferred_language=preferred_language,
enabled_language_codes=user_enabled,
),
tenant=active_tenant,
active_tenant=active_tenant,
available_languages=languages["available_languages"],
enabled_language_codes=tenant_enabled,
default_language=preferred_language,
)
def _roles_response(session: Session, context: AuthContext) -> AuthRolesResponse:
tenant_roles = collect_user_roles(session, context.user)
system_roles = collect_system_roles(session, context.account) if context.auth_session is not None else []
return AuthRolesResponse(roles=_roles_info(tenant_roles) + _roles_info(system_roles, level="system"))
def _groups_response(session: Session, context: AuthContext) -> AuthGroupsResponse:
return AuthGroupsResponse(groups=_groups_info(collect_user_groups(session, context.user)))
def _verify_profile_mutation_allowed(request: Request, context: AuthContext) -> None:
if context.auth_session is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot edit an interactive user profile")
if context.source != "cookie":
return
header_token = request.headers.get("x-csrf-token")
cookie_token = request.cookies.get(settings.auth_csrf_cookie_name)
if not header_token or not cookie_token or header_token != cookie_token or not verify_auth_session_csrf(context.auth_session, header_token):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing CSRF token")
def _roles_from_principal(session: Session, principal: ApiPrincipal, *, tenant_id: str) -> tuple[list[Role], list[Role]]:
roles = (
session.query(Role)
.filter(Role.id.in_(sorted(principal.role_ids)))
.order_by(Role.name.asc())
.all()
)
tenant_roles = [role for role in roles if role.tenant_id == tenant_id]
system_roles = [role for role in roles if role.tenant_id is None and principal.auth_session is not None]
return tenant_roles, system_roles
def _groups_from_principal(session: Session, principal: ApiPrincipal, *, tenant_id: str) -> list[Group]:
return (
session.query(Group)
.filter(Group.tenant_id == tenant_id, Group.id.in_(sorted(principal.group_ids)))
.order_by(Group.name.asc())
.all()
)
def _me_response(
session: Session,
*,
@@ -229,17 +536,42 @@ def _me_response(
include_all_memberships: bool = True,
identity_directory: IdentityDirectory | None = None,
identity_id: str | None = None,
tenant_roles: list[Role] | None = None,
system_roles: list[Role] | None = None,
groups: list[Group] | None = None,
function_assignment_ids: tuple[str, ...] | None = None,
delegation_ids: tuple[str, ...] | None = None,
) -> MeResponse:
tenant_roles = collect_user_roles(session, user)
system_roles = collect_system_roles(session, account) if include_system else []
groups = collect_user_groups(session, user)
if tenant_roles is None:
tenant_roles = collect_user_roles(session, user)
if system_roles is None:
system_roles = collect_system_roles(session, account) if include_system else []
elif not include_system:
system_roles = []
if groups is None:
groups = collect_user_groups(session, user)
scopes = effective_scopes if effective_scopes is not None else collect_user_scopes(session, user, include_system=include_system)
resolved_function_assignment_ids = (
function_assignment_ids
if function_assignment_ids is not None
else tuple(collect_function_assignment_ids(session, user))
)
resolved_delegation_ids = (
delegation_ids
if delegation_ids is not None
else tuple(collect_function_delegation_ids(session, user))
)
languages = _language_context(session, tenant=tenant, user=user)
tenant_enabled = list(languages["tenant_enabled_language_codes"])
user_enabled = list(languages["user_enabled_language_codes"])
preferred_language = str(languages["preferred_language"])
active_tenant = _tenant_info(tenant, enabled_language_codes=tenant_enabled)
memberships = _tenant_memberships(session, account) if include_all_memberships else [
memberships = _tenant_memberships(
session,
account,
active_user_id=user.id,
active_tenant_roles=tenant_roles,
) if include_all_memberships else [
TenantMembershipInfo(
id=tenant.id,
slug=tenant.slug,
@@ -266,8 +598,8 @@ def _me_response(
scopes=frozenset(scopes),
group_ids=frozenset(group.id for group in groups),
role_ids=frozenset(role.id for role in tenant_roles + system_roles),
function_assignment_ids=frozenset(collect_function_assignment_ids(session, user)),
delegation_ids=frozenset(collect_function_delegation_ids(session, user)),
function_assignment_ids=frozenset(resolved_function_assignment_ids),
delegation_ids=frozenset(resolved_delegation_ids),
auth_method=auth_method,
api_key_id=api_key_id,
session_id=session_id,
@@ -286,9 +618,18 @@ def _me_response(
def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)):
account, user, tenant = _resolve_login_user(session, payload)
identity_directory = _identity_directory_from_request(request)
me_payload = _me_response(session, account=account, user=user, tenant=tenant, identity_directory=identity_directory)
authorization_context = collect_user_authorization_context(
session,
user,
account=account,
include_system=True,
)
tenant_roles = authorization_context.tenant_roles
system_roles = authorization_context.system_roles
groups = authorization_context.groups
effective_scopes = authorization_context.scopes
maintenance_mode = saved_maintenance_mode(session)
if maintenance_mode.enabled and not scopes_grant(me_payload.scopes, MAINTENANCE_ACCESS_SCOPE):
if maintenance_mode.enabled and not scopes_grant(effective_scopes, MAINTENANCE_ACCESS_SCOPE):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=maintenance_response_detail(maintenance_mode),
@@ -312,19 +653,66 @@ def login(payload: LoginRequest, request: Request, response: Response, session:
account=account,
user=user,
tenant=tenant,
effective_scopes=me_payload.scopes,
effective_scopes=effective_scopes,
auth_method="session",
session_id=created.model.id,
identity_directory=identity_directory,
tenant_roles=tenant_roles,
system_roles=system_roles,
groups=groups,
function_assignment_ids=authorization_context.function_assignment_ids,
delegation_ids=authorization_context.function_delegation_ids,
).model_dump(),
)
@router.get("/session", response_model=AuthSessionResponse)
def auth_session(request: Request, session: Session = Depends(get_session)):
return _resolve_lightweight_session(request, session)
@router.get("/shell", response_model=AuthShellResponse)
def auth_shell(request: Request, session: Session = Depends(get_session)):
return _resolve_shell_auth(request, session)
@router.get("/profile", response_model=AuthProfileResponse)
def auth_profile(request: Request, session: Session = Depends(get_session)):
return _profile_response(session, _resolve_auth_context(request, session))
@router.get("/roles", response_model=AuthRolesResponse)
def auth_roles(request: Request, session: Session = Depends(get_session)):
context = _resolve_auth_context(request, session)
authorization_context = collect_user_authorization_context(
session,
context.user,
account=context.account,
include_system=context.auth_session is not None,
)
return AuthRolesResponse(
roles=_roles_info(authorization_context.tenant_roles)
+ _roles_info(authorization_context.system_roles, level="system")
)
@router.get("/groups", response_model=AuthGroupsResponse)
def auth_groups(request: Request, session: Session = Depends(get_session)):
return _groups_response(session, _resolve_auth_context(request, session))
@router.get("/me", response_model=MeResponse)
def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session)):
tenant = session.get(Tenant, principal.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found")
tenant_roles: list[Role] | None = None
system_roles: list[Role] | None = None
groups: list[Group] | None = None
if principal.role_ids:
tenant_roles, system_roles = _roles_from_principal(session, principal, tenant_id=tenant.id)
if principal.group_ids:
groups = _groups_from_principal(session, principal, tenant_id=tenant.id)
return _me_response(
session,
account=principal.account,
@@ -338,33 +726,42 @@ def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session =
include_system=principal.auth_session is not None,
include_all_memberships=principal.auth_session is not None,
identity_id=principal.principal.identity_id,
tenant_roles=tenant_roles,
system_roles=system_roles,
groups=groups,
function_assignment_ids=tuple(principal.function_assignment_ids),
delegation_ids=tuple(principal.delegation_ids),
)
@router.patch("/profile", response_model=MeResponse)
@router.patch("/profile", response_model=AuthProfileResponse)
def update_profile(
payload: ProfileUpdateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
request: Request,
session: Session = Depends(get_session),
):
if principal.auth_session is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot edit an interactive user profile")
context = _resolve_auth_context(request, session)
_verify_profile_mutation_allowed(request, context)
if "display_name" in payload.model_fields_set:
principal.account.display_name = payload.display_name.strip() if payload.display_name else None
session.add(principal.account)
context.account.display_name = payload.display_name.strip() if payload.display_name else None
session.add(context.account)
if "tenant_display_name" in payload.model_fields_set:
principal.user.display_name = payload.tenant_display_name.strip() if payload.tenant_display_name else None
session.add(principal.user)
next_settings = dict(principal.user.settings or {})
context.user.display_name = payload.tenant_display_name.strip() if payload.tenant_display_name else None
session.add(context.user)
next_settings = dict(context.user.settings or {})
settings_changed = False
if {"preferred_language", "enabled_language_codes"}.intersection(payload.model_fields_set):
tenant = session.get(Tenant, principal.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found")
system_settings = get_system_settings(session)
system_enabled = system_enabled_language_codes(system_settings.settings, default_locale=system_settings.default_locale)
tenant_enabled = tenant_enabled_language_codes(tenant.settings, system_enabled, default_locale=tenant.default_locale)
system_enabled = system_enabled_language_codes(
system_settings.settings,
default_locale=system_settings.default_locale,
)
tenant_enabled = tenant_enabled_language_codes(
context.tenant.settings,
system_enabled,
default_locale=context.tenant.default_locale,
)
next_i18n = i18n_settings(next_settings)
if "preferred_language" in payload.model_fields_set:
preferred = normalize_language_code(payload.preferred_language)
@@ -394,35 +791,23 @@ def update_profile(
next_settings["ui"] = UserUiPreferences.model_validate(next_ui).model_dump()
settings_changed = True
if settings_changed:
principal.user.settings = next_settings
session.add(principal.user)
context.user.settings = next_settings
session.add(context.user)
audit_from_principal(
audit_event(
session,
principal,
tenant_id=context.tenant.id,
user_id=context.user.id,
action="profile.updated",
object_type="account",
object_id=principal.account.id,
object_id=context.account.id,
details={"fields": sorted(payload.model_fields_set)},
)
tenant = session.get(Tenant, principal.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found")
session.commit()
return _me_response(
session,
account=principal.account,
user=principal.user,
tenant=tenant,
auth_method=principal.auth_method,
session_id=principal.session_id,
include_system=True,
include_all_memberships=True,
identity_id=principal.principal.identity_id,
)
return _profile_response(session, context)
@router.post("/switch-tenant", response_model=MeResponse)
@router.post("/switch-tenant", response_model=AuthShellResponse)
def switch_tenant(
payload: SwitchTenantRequest,
principal: ApiPrincipal = Depends(get_api_principal),
@@ -441,14 +826,20 @@ def switch_tenant(
if membership is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant membership not found")
session.commit()
return _me_response(
authorization_context = collect_user_authorization_context(
session,
membership,
account=principal.account,
include_system=True,
)
return _shell_response(
session,
account=principal.account,
user=membership,
tenant=tenant,
scopes=authorization_context.scopes,
auth_method="session",
session_id=principal.session_id,
identity_id=principal.principal.identity_id,
auth_session=principal.auth_session,
)