Skip to content

Latest commit

 

History

History
332 lines (249 loc) · 8.09 KB

File metadata and controls

332 lines (249 loc) · 8.09 KB

Authorization Model (RBAC)

Role-Based Access Control with Scopes and Tenant Isolation


Overview

The authorization model implements three layers:

  1. RBAC - Role-based permission checks
  2. Scopes - Fine-grained permission enforcement
  3. Tenant Isolation - Multi-tenant access boundaries
Request ──▶ RBAC Check ──▶ Tenant Check ──▶ Resource Access
              │                │
              ▼                ▼
          403 Forbidden    403 Forbidden

Roles

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

Role Hierarchy

admin ──────────────────────────▶ All permissions
  │
  ▼
operator ─────────────────────▶ config:*, audit:*, resource:*
  │
  ▼
editor ───────────────────────▶ resource:read, resource:write
  │
  ▼
viewer ───────────────────────▶ resource:read

Permissions

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-Permission Matrix

Role resource:read resource:write resource:delete config:read config:write audit:read
viewer
editor
operator
admin

Implementation

Permission Decorator

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": [...]}

Role Decorator

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"}

Multiple Permissions

@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"}

Authorization Flow

Step 1: Extract Role from Token

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.VIEWER

Step 2: Check Permission

def has_permission(role: Role, permission: Permission) -> bool:
    role_permissions = ROLE_PERMISSIONS.get(role, set())
    return permission in role_permissions

Step 3: Deny or Allow

if 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"
    )

Error Responses

401 Unauthorized

Missing or invalid JWT token:

{
  "detail": "Missing Authorization header"
}

403 Forbidden (RBAC)

Valid token but insufficient permissions:

{
  "detail": "Permission denied: resource:write required"
}

403 Forbidden (Tenant)

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.


Audit Logging

All authorization decisions are logged:

Authorization Success

{
  "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"
}

Authorization Failure

{
  "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 Authorization

Resource Endpoints

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

Public Endpoints

Endpoint Method Auth Description
/health GET None Health check
/metrics GET None Prometheus metrics

Security Principles

Deny-by-Default

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.VIEWER

No Role Enumeration

403 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"

Separation of Concerns

  • 401 = Authentication failure (identity not verified)
  • 403 = Authorization failure (identity verified, access denied)

Testing Authorization

Test: Viewer Cannot Write

# 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

Test: Admin Can Write

# 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

Related Documentation