Technical Specification
Rev 1.0.0

Technical writing from the command line. Systems, scripts, and discoveries.

Production ready RBAC implementation using FastAPI

Full production ready example of how to implement RBAC using FastAPI and SQLAlchemy

RBAC FastAPI SQLAlchemy Migrations

Try the interactive demo →

The problem

Most RBAC write-ups show you a has_permission(user, action) helper and call it done. Real systems are messier. You have multiple tenants, a mix of human and machine actors, and permission requirements that only become clear after you’ve shipped something that doesn’t have them.

This post walks through a production-grade RBAC implementation built for an IoT railway monitoring platform — two tenants (ACME Rail and RailCorp), human operators, edge devices authenticating as service accounts, and an admin hierarchy that spans both. The full stack is FastAPI, SQLAlchemy, and Alembic.

The interactive demo lets you switch between users and roles to explore the permission model before diving into the code.


Design constraints

Before writing a line of code, three constraints shaped the entire model:

Single role per user. Many RBAC systems allow multiple roles. This sounds flexible but creates a class of bugs where you grant a user viewer and admin and can’t reason about which permissions they actually have. A single role per user forces clarity — if someone needs elevated access, you give them an elevated role, you don’t stack them.

Architect-defined permissions. Permissions are seeded via Alembic migration and are not configurable by end users. There is no UI for creating new permissions. This is a deliberate constraint — if an end user can define permissions, you’ve built a security footgun. The set of things the system can do is finite and known at design time.

Tenant scoping at the role level. Every role except super_admin is scoped to a single tenant. The super admin has cross-tenant visibility. Everything else is isolated. This means the tenant check is part of the permission check, not an afterthought.


The data model

# models.py
class Tenant(Base):
    __tablename__ = "tenants"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(100), unique=True)
    slug: Mapped[str] = mapped_column(String(50), unique=True)

class Role(Base):
    __tablename__ = "roles"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(50), unique=True)
    description: Mapped[str] = mapped_column(String(200))

class Permission(Base):
    __tablename__ = "permissions"
    id: Mapped[int] = mapped_column(primary_key=True)
    resource: Mapped[str] = mapped_column(String(50))   # e.g. "device"
    action: Mapped[str] = mapped_column(String(50))     # e.g. "write"
    __table_args__ = (UniqueConstraint("resource", "action"),)

class RolePermission(Base):
    __tablename__ = "role_permissions"
    role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"), primary_key=True)
    permission_id: Mapped[int] = mapped_column(ForeignKey("permissions.id"), primary_key=True)

class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(200), unique=True)
    tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id"), nullable=True)
    role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"))

Permissions use a resource:action format — device:read, telemetry:write, alert:acknowledge. The full matrix is seeded once and never touched at runtime.

The tenant_id is nullable on User because super_admin is cross-tenant. Every other user belongs to exactly one tenant.


Seeding via Alembic migration

Permissions are data, not config. They live in the database and are version-controlled alongside the schema:

# migrations/versions/0002_seed_roles_permissions.py
PERMISSIONS = [
    ("device",      "read"),
    ("device",      "write"),
    ("device",      "delete"),
    ("telemetry",   "read"),
    ("telemetry",   "write"),
    ("alert",       "read"),
    ("alert",       "acknowledge"),
    ("maintenance", "read"),
    ("maintenance", "write"),
    ("config",      "read"),
    ("config",      "write"),
    ("user",        "read"),
    ("user",        "write"),
]

ROLE_PERMISSIONS = {
    "super_admin": "*",          # all permissions
    "admin":       ["device:read", "device:write", "telemetry:read",
                    "alert:read", "alert:acknowledge", "maintenance:read",
                    "maintenance:write", "config:read", "user:read", "user:write"],
    "technician":  ["device:read", "telemetry:read", "alert:read",
                    "alert:acknowledge", "maintenance:read", "maintenance:write"],
    "device":      ["telemetry:write", "alert:read"],
    "viewer":      ["device:read", "telemetry:read", "alert:read"],
    "demo":        ["device:read", "telemetry:read", "alert:read"],
}

def upgrade() -> None:
    conn = op.get_bind()
    # Insert permissions
    for resource, action in PERMISSIONS:
        conn.execute(
            sa.text("INSERT INTO permissions (resource, action) VALUES (:r, :a)"),
            {"r": resource, "a": action},
        )
    # Assign to roles
    for role_name, perms in ROLE_PERMISSIONS.items():
        role_id = conn.execute(
            sa.text("SELECT id FROM roles WHERE name = :n"), {"n": role_name}
        ).scalar()
        if perms == "*":
            all_perm_ids = conn.execute(sa.text("SELECT id FROM permissions")).fetchall()
            for (pid,) in all_perm_ids:
                conn.execute(
                    sa.text("INSERT INTO role_permissions VALUES (:r, :p)"),
                    {"r": role_id, "p": pid},
                )
        else:
            for perm_str in perms:
                resource, action = perm_str.split(":")
                pid = conn.execute(
                    sa.text("SELECT id FROM permissions WHERE resource=:r AND action=:a"),
                    {"r": resource, "a": action},
                ).scalar()
                conn.execute(
                    sa.text("INSERT INTO role_permissions VALUES (:r, :p)"),
                    {"r": role_id, "p": pid},
                )

Running alembic upgrade head on a fresh database seeds the full permission set deterministically. Rollback (downgrade) simply deletes the rows.


The permission check

The core check is a single database query:

# dependencies.py
def require_permission(resource: str, action: str):
    async def _check(
        current_user: User = Depends(get_current_user),
        db: AsyncSession = Depends(get_db),
    ) -> User:
        # super_admin bypasses tenant scoping
        if current_user.role.name == "super_admin":
            return current_user

        has_perm = await db.scalar(
            select(func.count())
            .select_from(RolePermission)
            .join(Permission)
            .where(
                RolePermission.role_id == current_user.role_id,
                Permission.resource == resource,
                Permission.action == action,
            )
        )
        if not has_perm:
            raise HTTPException(status_code=403, detail="Forbidden")
        return current_user

    return _check

Used as a FastAPI dependency:

# routes/devices.py
@router.get("/devices", dependencies=[Depends(require_permission("device", "read"))])
async def list_devices(
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    # Tenant scoping — super_admin sees all
    query = select(Device)
    if current_user.tenant_id:
        query = query.where(Device.tenant_id == current_user.tenant_id)
    result = await db.scalars(query)
    return result.all()

The permission check and tenant scoping are separate concerns deliberately. require_permission answers “can this role do this action”. The route handler answers “which rows can this user see”. Mixing them makes both harder to reason about.


Roles in this system

RoleTenant scopedIntended actor
super_adminNoPlatform operator
adminYesTenant administrator
technicianYesField engineer
deviceYesEdge device service account
viewerYesRead-only human operator
demoYesSandboxed demo account

The device role deserves a note. Edge devices authenticate with a service account and are granted only what they need — writing telemetry and reading alerts. They cannot read other devices, modify configuration, or access user data. If a device is compromised, the blast radius is limited to the telemetry stream.


What this doesn’t cover

This model is intentionally flat. It does not handle:

  • Hierarchical roles — if you need “admin inherits all technician permissions”, the RolePermission seed handles that explicitly. There is no parent/child role tree.
  • Resource-instance permissions — this model controls access to resource types (device:read), not specific instances (device:read:device-id-42). If you need row-level security, PostgreSQL’s RLS is a better fit than adding another layer here.
  • Dynamic permission grants — no user can grant or revoke permissions at runtime. If that’s a requirement, you’re building a different system.

For the vast majority of multi-tenant SaaS and IoT platforms, flat RBAC with architect-defined permissions is the right level of complexity. As Martin Kleppmann notes in Designing Data-Intensive Applications — the complexity you add to handle edge cases that don’t exist yet is the complexity you pay to maintain forever.


Explore the full permissions matrix in the interactive demo →