Release v0.1.5

This commit is contained in:
2026-07-07 15:49:06 +02:00
parent f37c6668e5
commit efb69b3d2d
43 changed files with 6464 additions and 192 deletions

View File

@@ -0,0 +1,300 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.orm import Session
from govoplan_core.api.v1.schemas import (
GroupInfo,
LoginRequest,
LoginResponse,
MeResponse,
ProfileUpdateRequest,
RoleInfo,
SwitchTenantRequest,
TenantInfo,
TenantMembershipInfo,
UserInfo,
)
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal
from govoplan_core.audit.logging import audit_from_principal
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_core.db.session import get_session
from govoplan_core.security.permissions import normalize_email, scopes_grant
from govoplan_core.security.time import utc_now
from govoplan_core.settings import settings
from govoplan_access.backend.security.passwords import verify_password
from govoplan_access.backend.security.sessions import (
collect_system_roles,
collect_tenant_memberships,
collect_user_groups,
collect_user_roles,
collect_user_scopes,
create_auth_session,
switch_auth_session_tenant,
)
router = APIRouter(prefix="/auth", tags=["auth"])
def _cookie_samesite() -> str:
value = settings.auth_cookie_samesite.lower().strip()
if value not in {"lax", "strict", "none"}:
return "lax"
if value == "none" and not settings.auth_cookie_secure:
return "lax"
return value
def _set_auth_cookies(response: Response, created) -> None:
max_age = max(0, int((created.model.expires_at - utc_now()).total_seconds()))
common = {
"secure": settings.auth_cookie_secure,
"samesite": _cookie_samesite(),
"max_age": max_age,
"path": "/",
}
if settings.auth_cookie_domain:
common["domain"] = settings.auth_cookie_domain
response.set_cookie(settings.auth_session_cookie_name, created.token, httponly=True, **common)
response.set_cookie(settings.auth_csrf_cookie_name, created.csrf_token, httponly=False, **common)
def _clear_auth_cookies(response: Response) -> None:
kwargs = {"path": "/"}
if settings.auth_cookie_domain:
kwargs["domain"] = settings.auth_cookie_domain
response.delete_cookie(settings.auth_session_cookie_name, **kwargs)
response.delete_cookie(settings.auth_csrf_cookie_name, **kwargs)
def _tenant_info(tenant: Tenant) -> TenantInfo:
return TenantInfo(
id=tenant.id,
slug=tenant.slug,
name=tenant.name,
is_active=tenant.is_active,
default_locale=tenant.default_locale,
)
def _user_info(user: User, account: Account) -> UserInfo:
return UserInfo(
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(
id=role.id,
slug=role.slug,
name=role.name,
permissions=role.permissions or [],
level=level,
)
for role in roles
]
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]:
memberships: list[TenantMembershipInfo] = []
for user, tenant in collect_tenant_memberships(session, account):
roles = collect_user_roles(session, user)
memberships.append(
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=[role.slug for role in roles],
)
)
return memberships
def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Account, User, Tenant]:
account = (
session.query(Account)
.filter(Account.normalized_email == normalize_email(payload.email), Account.is_active.is_(True))
.one_or_none()
)
if account is None or not verify_password(payload.password, account.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
query = (
session.query(User, Tenant)
.join(Tenant, Tenant.id == User.tenant_id)
.filter(
User.account_id == account.id,
User.is_active.is_(True),
Tenant.is_active.is_(True),
)
)
if payload.tenant_slug:
query = query.filter(Tenant.slug == payload.tenant_slug)
row = query.order_by(Tenant.name.asc()).first()
if row is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No active tenant membership")
return account, row[0], row[1]
def _me_response(
session: Session,
*,
account: Account,
user: User,
tenant: Tenant,
effective_scopes: list[str] | None = None,
include_system: bool = True,
include_all_memberships: bool = True,
) -> 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)
active_tenant = _tenant_info(tenant)
memberships = _tenant_memberships(session, account) if include_all_memberships 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=[role.slug for role in tenant_roles],
)
]
return MeResponse(
user=_user_info(user, account),
tenant=active_tenant,
active_tenant=active_tenant,
tenants=memberships,
scopes=effective_scopes if effective_scopes is not None else collect_user_scopes(session, user, include_system=include_system),
roles=_roles_info(tenant_roles) + _roles_info(system_roles, level="system"),
groups=_groups_info(groups),
)
@router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)):
account, user, tenant = _resolve_login_user(session, payload)
me_payload = _me_response(session, account=account, user=user, tenant=tenant)
maintenance_mode = saved_maintenance_mode(session)
if maintenance_mode.enabled and not scopes_grant(me_payload.scopes, MAINTENANCE_ACCESS_SCOPE):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=maintenance_response_detail(maintenance_mode),
)
user_agent = request.headers.get("user-agent")
ip_address = request.client.host if request.client else None
created = create_auth_session(
session,
user=user,
hours=settings.auth_session_hours,
user_agent=user_agent,
ip_address=ip_address,
)
session.commit()
_set_auth_cookies(response, created)
return LoginResponse(
access_token=created.token,
expires_at=created.model.expires_at,
**me_payload.model_dump(),
)
@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")
return _me_response(
session,
account=principal.account,
user=principal.user,
tenant=tenant,
effective_scopes=principal.scopes,
include_system=principal.auth_session is not None,
include_all_memberships=principal.auth_session is not None,
)
@router.patch("/profile", response_model=MeResponse)
def update_profile(
payload: ProfileUpdateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
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")
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)
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)
audit_from_principal(
session,
principal,
action="profile.updated",
object_type="account",
object_id=principal.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,
include_system=True,
include_all_memberships=True,
)
@router.post("/switch-tenant", response_model=MeResponse)
def switch_tenant(
payload: SwitchTenantRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
if principal.auth_session is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot switch tenant context")
try:
membership = switch_auth_session_tenant(session, principal.auth_session, payload.tenant_id)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
tenant = session.get(Tenant, membership.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
session.commit()
return _me_response(session, account=principal.account, user=membership, tenant=tenant)
@router.post("/logout")
def logout(
response: Response,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
if principal.auth_session is not None:
principal.auth_session.revoked_at = utc_now()
session.add(principal.auth_session)
session.commit()
_clear_auth_cookies(response)
return {"ok": True}