M-Bus Gateway
← Tilbage til blog
· 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.

Se JWT auth guide eller FastAPI SQLModel guide.