feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
186
backend/app/middleware/rbac.py
Normal file
186
backend/app/middleware/rbac.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
Role-Based Access Control (RBAC) middleware.
|
||||
|
||||
Provides dependency factories for enforcing role-based access control
|
||||
on FastAPI routes. Roles are hierarchical:
|
||||
|
||||
super_admin > tenant_admin > operator > viewer
|
||||
|
||||
Role permissions per plan TENANT-04/05/06:
|
||||
- viewer: GET endpoints only (read-only)
|
||||
- operator: GET + device/config management endpoints
|
||||
- tenant_admin: full access within their tenant
|
||||
- super_admin: full access across all tenants
|
||||
"""
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.params import Depends as DependsClass
|
||||
|
||||
from app.middleware.tenant_context import CurrentUser, get_current_user
|
||||
|
||||
# Role hierarchy (higher index = more privilege)
|
||||
# api_key is at operator level for RBAC checks; fine-grained access controlled by scopes.
|
||||
ROLE_HIERARCHY = {
|
||||
"viewer": 0,
|
||||
"api_key": 1,
|
||||
"operator": 1,
|
||||
"tenant_admin": 2,
|
||||
"super_admin": 3,
|
||||
}
|
||||
|
||||
|
||||
def _get_role_level(role: str) -> int:
|
||||
"""Return numeric privilege level for a role string."""
|
||||
return ROLE_HIERARCHY.get(role, -1)
|
||||
|
||||
|
||||
def require_role(*allowed_roles: str) -> Callable:
|
||||
"""
|
||||
FastAPI dependency factory that checks the current user's role.
|
||||
|
||||
Usage:
|
||||
@router.post("/items", dependencies=[Depends(require_role("tenant_admin", "super_admin"))])
|
||||
|
||||
Args:
|
||||
*allowed_roles: Role strings that are permitted to access the endpoint
|
||||
|
||||
Returns:
|
||||
FastAPI dependency that raises 403 if the role is insufficient
|
||||
"""
|
||||
async def dependency(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CurrentUser:
|
||||
if current_user.role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Access denied. Required roles: {', '.join(allowed_roles)}. "
|
||||
f"Your role: {current_user.role}",
|
||||
)
|
||||
return current_user
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
def require_min_role(min_role: str) -> Callable:
|
||||
"""
|
||||
Dependency factory that allows any role at or above the minimum level.
|
||||
|
||||
Usage:
|
||||
@router.get("/items", dependencies=[Depends(require_min_role("operator"))])
|
||||
# Allows: operator, tenant_admin, super_admin
|
||||
# Denies: viewer
|
||||
"""
|
||||
min_level = _get_role_level(min_role)
|
||||
|
||||
async def dependency(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CurrentUser:
|
||||
user_level = _get_role_level(current_user.role)
|
||||
if user_level < min_level:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Access denied. Minimum required role: {min_role}. "
|
||||
f"Your role: {current_user.role}",
|
||||
)
|
||||
return current_user
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
def require_write_access() -> Callable:
|
||||
"""
|
||||
Dependency that enforces viewer read-only restriction.
|
||||
|
||||
Viewers are NOT allowed on POST/PUT/PATCH/DELETE endpoints.
|
||||
Call this on any mutating endpoint to deny viewers.
|
||||
"""
|
||||
async def dependency(
|
||||
request: Request,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CurrentUser:
|
||||
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
|
||||
if current_user.role == "viewer":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Viewers have read-only access. "
|
||||
"Contact your administrator to request elevated permissions.",
|
||||
)
|
||||
return current_user
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
def require_scope(scope: str) -> DependsClass:
|
||||
"""FastAPI dependency that checks API key scopes.
|
||||
|
||||
No-op for regular users (JWT auth) -- scopes only apply to API keys.
|
||||
For API key users: checks that the required scope is in the key's scope list.
|
||||
|
||||
Returns a Depends() instance so it can be used in dependency lists:
|
||||
@router.get("/items", dependencies=[require_scope("devices:read")])
|
||||
|
||||
Args:
|
||||
scope: Required scope string (e.g. "devices:read", "config:write").
|
||||
|
||||
Raises:
|
||||
HTTPException 403 if the API key is missing the required scope.
|
||||
"""
|
||||
async def _check_scope(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CurrentUser:
|
||||
if current_user.role == "api_key":
|
||||
if not current_user.scopes or scope not in current_user.scopes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"API key missing required scope: {scope}",
|
||||
)
|
||||
return current_user
|
||||
|
||||
return Depends(_check_scope)
|
||||
|
||||
|
||||
# Pre-built convenience dependencies
|
||||
|
||||
async def require_super_admin(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CurrentUser:
|
||||
"""Require super_admin role (portal-wide admin)."""
|
||||
if current_user.role != "super_admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied. Super admin role required.",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def require_tenant_admin_or_above(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CurrentUser:
|
||||
"""Require tenant_admin or super_admin role."""
|
||||
if current_user.role not in ("tenant_admin", "super_admin"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied. Tenant admin or higher role required.",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def require_operator_or_above(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CurrentUser:
|
||||
"""Require operator, tenant_admin, or super_admin role."""
|
||||
if current_user.role not in ("operator", "tenant_admin", "super_admin"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied. Operator or higher role required.",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def require_authenticated(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CurrentUser:
|
||||
"""Require any authenticated user (viewer and above)."""
|
||||
return current_user
|
||||
Reference in New Issue
Block a user