Role-Based Access Control with Scopes and Tenant Isolation
The authorization model implements three layers:
- RBAC - Role-based permission checks
- Scopes - Fine-grained permission enforcement
- Tenant Isolation - Multi-tenant access boundaries
Request ──▶ RBAC Check ──▶ Tenant Check ──▶ Resource Access
│ │
▼ ▼
403 Forbidden 403 Forbidden
| Role | Description | Use Case |
|---|---|---|
viewer |
Read-only access | Auditors, read-only users |
editor |
Read + write access | Regular users |
operator |
Operational access | Service accounts, operators |
admin |
Full access | Administrators |
admin ──────────────────────────▶ All permissions
│
▼
operator ─────────────────────▶ config:*, audit:*, resource:*
│
▼
editor ───────────────────────▶ resource:read, resource:write
│
▼
viewer ───────────────────────▶ resource:read
| Permission | Description | Roles |
|---|---|---|
resource:read |
Read tenant resources | viewer, editor, operator, admin |
resource:write |
Create/update resources | editor, operator, admin |
resource:delete |
Delete resources | operator, admin |
config:read |
Read configuration | operator, admin |
config:write |
Modify configuration | admin |
audit:read |
Read audit logs | operator, admin |
| Role | resource:read | resource:write | resource:delete | config:read | config:write | audit:read |
|---|---|---|---|---|---|---|
| viewer | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| editor | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| operator | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
| admin | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
from iam.rbac import require_permission
from iam.rbac_roles import Permission
@router.get("/tenants/{tenant_id}/resources")
async def list_resources(
claims: dict = Depends(require_permission(Permission.RESOURCE_READ))
):
# Token verified and has resource:read permission
return {"resources": [...]}from iam.rbac import require_role
from iam.rbac_roles import Role
@router.delete("/admin/cache")
async def clear_cache(
claims: dict = Depends(require_role(Role.ADMIN))
):
# Only admin role allowed
return {"status": "cleared"}@router.put("/tenants/{tenant_id}/config")
async def update_config(
claims: dict = Depends(require_permission(
Permission.CONFIG_READ,
Permission.CONFIG_WRITE
))
):
# Requires BOTH permissions
return {"status": "updated"}def get_role_from_claims(claims: dict) -> Role:
role_value = claims.get("role")
if not role_value:
# Keycloak tokens may have roles in a list
roles = claims.get("roles", [])
for r in ["admin", "operator", "editor", "viewer"]:
if r in roles:
return Role(r)
return Role(role_value) if role_value else Role.VIEWERdef has_permission(role: Role, permission: Permission) -> bool:
role_permissions = ROLE_PERMISSIONS.get(role, set())
return permission in role_permissionsif not has_permission(role, required_permission):
# Log to audit trail
audit_logger.log_authz_failure(
actor_id=claims["sub"],
role=role.value,
required_permission=required_permission.value,
endpoint=request.url.path,
)
raise HTTPException(
status_code=403,
detail=f"Permission denied: {required_permission.value} required"
)Missing or invalid JWT token:
{
"detail": "Missing Authorization header"
}Valid token but insufficient permissions:
{
"detail": "Permission denied: resource:write required"
}Valid token and permissions, but wrong tenant:
{
"detail": "Access denied: insufficient permissions for this resource"
}Note: Tenant mismatch errors use a generic message to avoid leaking tenant information.
All authorization decisions are logged:
{
"event_type": "AUTHZ_SUCCESS",
"event_category": "authorization",
"severity": "info",
"actor": {"type": "user", "id": "alice-uuid"},
"details": {
"role": "viewer",
"permission": "resource:read",
"endpoint": "/tenants/acme-corp/resources"
},
"outcome": "success"
}{
"event_type": "AUTHZ_FAILURE",
"event_category": "authorization",
"severity": "warning",
"actor": {"type": "user", "id": "alice-uuid"},
"details": {
"role": "viewer",
"required_permission": "resource:write",
"endpoint": "/tenants/acme-corp/resources"
},
"outcome": "denied",
"reason": "missing_permission"
}| Endpoint | Method | Permission | Description |
|---|---|---|---|
/tenants/{tenant}/resources |
GET | resource:read |
List resources |
/tenants/{tenant}/resources |
POST | resource:write |
Create resource |
/tenants/{tenant}/resources/{id} |
GET | resource:read |
Get resource |
/tenants/{tenant}/resources/{id} |
PUT | resource:write |
Update resource |
/tenants/{tenant}/resources/{id} |
DELETE | resource:delete |
Delete resource |
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/health |
GET | None | Health check |
/metrics |
GET | None | Prometheus metrics |
If no role or unknown role, default to minimum permissions:
def get_role_from_claims(claims: dict) -> Role:
# Unknown role? Default to viewer (least privilege)
return Role.VIEWER403 responses don't reveal which roles would have access:
# Good: Generic message
detail="Permission denied: resource:write required"
# Bad: Would reveal role information
detail="Only admin and operator roles can access this"- 401 = Authentication failure (identity not verified)
- 403 = Authorization failure (identity verified, access denied)
# Get viewer token
TOKEN=$(curl -s -X POST "http://localhost:8080/realms/iam-demo/protocol/openid-connect/token" \
-d "client_id=iam-cli&username=alice-viewer&password=alice123&grant_type=password" \
| jq -r '.access_token')
# Try to create resource
curl -s -X POST "http://localhost:8001/tenants/acme-corp/resources" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "test"}'
# Expected: 403 Forbidden# Get admin token
TOKEN=$(curl -s -X POST "http://localhost:8080/realms/iam-demo/protocol/openid-connect/token" \
-d "client_id=iam-cli&username=charlie-admin&password=charlie123&grant_type=password" \
| jq -r '.access_token')
# Create resource
curl -s -X POST "http://localhost:8001/tenants/acme-corp/resources" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "test"}'
# Expected: 201 Created- 04-tenant-isolation.md - Multi-tenant access control
- 05-auditability-logging.md - Audit logging
- ADR-002: Authorization Model - Design decisions