· SQLModel· RBAC· FastAPI· multi-tenant· adgangskontrol· JWT· dependency injection· Python· sikkerhed
SQLModel RBAC mønstre — multi-tenant adgangskontrol i FastAPI
RBAC mønstre med SQLModel og FastAPI: require_role dependency, tenant_id isolation, super_admin bypass, impersonering og unit tests for adgangskontrol.
Af M-Bus Gateway
M-Bus Gateway platformen har 6 brugerroller og streng multi-tenant isolation. Her er RBAC-mønstrene der sikrer at ingen bruger ser andres data.
Roller og adgangsniveauer
6 roller i platformen (mindst privilegeret til mest):
tenant: Magic link → egne afregninger (ingen login)
installer: Tech PWA → installér målere
technician: Tech PWA + portal → gateway-admin
landlord: Portal → egne ejendomme/lejere/afregninger
external_admin: Flere udlejeres porteføljer (impersonering)
super_admin: Alt — ingen tenant_id-binding
Multi-tenant regel:
→ Alle tabeller har tenant_id
→ Alle queries filtrerer på tenant_id
→ Undtagelse: super_admin og external_admin
require_role dependency
# server/src/auth/deps.py
from fastapi import Depends, HTTPException
from server.src.auth.tokens import verify_token
def get_current_user(token: str = Depends(oauth2_scheme)) -> TokenPayload:
"""Dekod JWT og returnér payload. Kaster 401 ved ugyldig token."""
try:
payload = verify_token(token)
except JWTError:
raise HTTPException(401, "Ugyldig token")
return payload
def require_role(*roles: str):
"""
Returnerer FastAPI dependency der kræver én af de givne roller.
Brug:
@router.get("/admin")
async def admin_only(user = Depends(require_role("super_admin"))):
...
@router.get("/property")
async def landlord_or_admin(
user = Depends(require_role("landlord", "super_admin"))
):
...
"""
def _check(user: TokenPayload = Depends(get_current_user)) -> TokenPayload:
if user.role not in roles:
raise HTTPException(403, f"Kræver rolle: {' eller '.join(roles)}")
return user
return _check
Tenant-isolering: Automatisk filtering
# server/src/properties/router.py
from fastapi import APIRouter, Depends
from server.src.auth.deps import get_current_user, require_role
router = APIRouter(prefix="/properties", tags=["properties"])
@router.get("")
async def list_properties(
session: AsyncSession = Depends(get_session),
user: TokenPayload = Depends(require_role("landlord", "super_admin", "external_admin")),
):
"""
Returnerer ejendomme for den autentificerede tenant.
super_admin: Alle ejendomme (ingen tenant_id filter).
landlord: Kun egne ejendomme.
external_admin ved impersonering: Impersoneret tenants ejendomme.
"""
stmt = select(Property).where(Property.deleted_at.is_(None))
# Tenant-isolation:
if user.role != "super_admin":
if user.tenant_id is None:
# external_admin UDEN impersonering: Ingen ejendomme
return []
stmt = stmt.where(Property.tenant_id == user.tenant_id)
result = await session.execute(stmt)
return result.scalars().all()
@router.post("")
async def create_property(
body: PropertyIn,
session: AsyncSession = Depends(get_session),
user: TokenPayload = Depends(require_role("landlord", "super_admin")),
):
"""
Opret ejendom.
landlord: tenant_id hentes fra JWT (ikke fra request body).
super_admin: Kan angive tenant_id via body.tenant_id.
"""
# Bestem tenant_id — ALDRIG fra body for ikke-admin:
if user.role == "super_admin":
tenant_id = body.tenant_id or user.tenant_id
if not tenant_id:
raise HTTPException(400, "tenant_id påkrævet")
else:
tenant_id = user.tenant_id # Ignorer body.tenant_id (sikkerhed)
property = Property(
tenant_id=tenant_id,
name=body.name,
# ...
)
session.add(property)
await session.commit()
return property
Object-level authorization
# Tjek ejerskab af specifikt objekt (ikke kun rolle):
@router.get("/{property_id}")
async def get_property(
property_id: str,
session: AsyncSession = Depends(get_session),
user: TokenPayload = Depends(require_role("landlord", "super_admin")),
):
property = await session.get(Property, property_id)
if not property or property.deleted_at:
raise HTTPException(404, "Ejendom ikke fundet")
# Object-level auth: Er brugeren ejer af DENNE ejendom?
if user.role != "super_admin" and property.tenant_id != user.tenant_id:
# Returner 404 (ikke 403) — undgå at afsløre at objektet eksisterer:
raise HTTPException(404, "Ejendom ikke fundet")
return property
Impersonering: JWT-scope override
# server/src/auth/deps.py
def get_current_user(token: str = Depends(oauth2_scheme)) -> TokenPayload:
"""
Ved impersonering indeholder JWT:
role = "landlord"
tenant_id = <impersoneret_tenant_id>
impersonated = True
impersonated_by = <original_admin_user_id>
Det er JWT-payload der bestemmer adgang — ikke DB-brugerens rolle.
"""
try:
raw = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
raise HTTPException(401, "Ugyldig token")
return TokenPayload(
sub=raw["sub"],
role=raw["role"], # Impersoneret rolle (landlord)
tenant_id=raw.get("tenant_id"), # Impersoneret tenant
impersonated=raw.get("impersonated", False),
impersonated_by=raw.get("impersonated_by"),
)
Unit tests: RBAC
# server/tests/test_rbac.py
import pytest
from unittest.mock import patch, AsyncMock
from fastapi.testclient import TestClient
@pytest.mark.parametrize("role,expected_status", [
("installer", 403),
("technician", 403),
("landlord", 200),
("super_admin", 200),
])
def test_property_list_requires_landlord(role, expected_status, client):
"""Kun landlord og super_admin kan liste ejendomme."""
token = make_test_token(role=role, tenant_id="tenant-123")
resp = client.get(
"/api/v1/properties",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == expected_status
def test_tenant_isolation(client):
"""Landlord kan ikke se anden tenants ejendomme."""
# Opret ejendom for tenant A:
property_id = create_test_property(tenant_id="tenant-A")
# Forsøg adgang som tenant B:
token = make_test_token(role="landlord", tenant_id="tenant-B")
resp = client.get(
f"/api/v1/properties/{property_id}",
headers={"Authorization": f"Bearer {token}"},
)
# Skal returnere 404 (ikke 403 — afslører ikke eksistens):
assert resp.status_code == 404
Konklusion
RBAC med FastAPI Depends() er elegant: require_role() returnerer en callable der FastAPI kører automatisk. Tenant-isolation sker i queryfilter — ikke i application-layer logik. Object-level authorization returnerer 404 (ikke 403) for at undgå information disclosure. Impersonering sker udelukkende via JWT-scope — aldrig via DB-opslag.