"""TenantContext resolution — single layer, not scattered header logic in routers.""" from __future__ import annotations from dataclasses import dataclass from typing import Any, Optional from fastapi import Depends, HTTPException from psycopg2.extras import RealDictCursor from auth import require_auth from db import get_connection from rights_registry import load_grants_for_roles from services.actors import get_human_actor @dataclass(frozen=True) class TenantContext: user_id: str email: str display_name: str portal_role: str tenant_id: Optional[str] tenant_slug: Optional[str] tenant_name: Optional[str] tenant_role: Optional[str] actor_id: Optional[str] actor_type: Optional[str] session_token: str capabilities: frozenset[str] auth_source: str = "session" service_token_id: Optional[str] = None def to_dict(self) -> dict[str, Any]: return { "user_id": self.user_id, "email": self.email, "display_name": self.display_name, "portal_role": self.portal_role, "capabilities": sorted(self.capabilities), "tenant": ( { "id": self.tenant_id, "slug": self.tenant_slug, "name": self.tenant_name, "role": self.tenant_role, } if self.tenant_id else None ), "actor": ( { "id": self.actor_id, "type": self.actor_type, } if self.actor_id else None ), } def list_user_tenants(user_id: str) -> list[dict[str, Any]]: conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT t.id, t.slug, t.name, tm.tenant_role, tm.is_active AS membership_active FROM tenant_memberships tm JOIN tenants t ON t.id = tm.tenant_id WHERE tm.user_id = %s AND tm.is_active = TRUE AND t.is_active = TRUE ORDER BY t.name """, (user_id,), ) rows = [] for row in cur.fetchall(): item = dict(row) item["id"] = str(item["id"]) rows.append(item) return rows finally: conn.close() def resolve_tenant_context(session: dict[str, Any]) -> TenantContext: user_id = str(session["user_id"]) tenant_id = session.get("active_tenant_id") portal_role = session["portal_role"] if not tenant_id: caps = frozenset(load_grants_for_roles(portal_role=portal_role, tenant_role=None)) return TenantContext( user_id=user_id, email=session["email"], display_name=session["display_name"], portal_role=portal_role, tenant_id=None, tenant_slug=None, tenant_name=None, tenant_role=None, actor_id=None, actor_type=None, session_token=session["token"], capabilities=caps, ) conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT t.id, t.slug, t.name, tm.tenant_role FROM tenant_memberships tm JOIN tenants t ON t.id = tm.tenant_id WHERE tm.user_id = %s AND tm.tenant_id = %s AND tm.is_active = TRUE AND t.is_active = TRUE """, (user_id, tenant_id), ) membership = cur.fetchone() if not membership: raise HTTPException(status_code=403, detail="Keine gültige Tenant-Mitgliedschaft") tenant_id_str = str(membership["id"]) tenant_role = membership["tenant_role"] human = get_human_actor(tenant_id_str, user_id) caps = frozenset( load_grants_for_roles(portal_role=portal_role, tenant_role=tenant_role) ) return TenantContext( user_id=user_id, email=session["email"], display_name=session["display_name"], portal_role=portal_role, tenant_id=tenant_id_str, tenant_slug=membership["slug"], tenant_name=membership["name"], tenant_role=tenant_role, actor_id=human["id"] if human else None, actor_type=human["actor_type"] if human else None, session_token=session["token"], capabilities=caps, ) finally: conn.close() def get_tenant_context(session: dict[str, Any] = Depends(require_auth)) -> TenantContext: return resolve_tenant_context(session) def require_tenant_context(session: dict[str, Any] = Depends(require_auth)) -> TenantContext: ctx = resolve_tenant_context(session) if not ctx.tenant_id: raise HTTPException(status_code=403, detail="Kein aktiver Tenant — Tenant wählen") return ctx