Some checks failed
Deploy Development / deploy (push) Successful in 33s
Test Suite / pytest-backend (push) Failing after 4s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 1s
Test Suite / compose-smoke (push) Has been skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
163 lines
5.2 KiB
Python
163 lines
5.2 KiB
Python
"""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]
|
|
|
|
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
|