feat: add workspace RBAC audit foundations

This commit is contained in:
Christian Krakau-Louis
2026-05-23 19:22:56 +02:00
parent 6ddd42bb3e
commit d180f984a6
20 changed files with 1199 additions and 54 deletions
+97
View File
@@ -0,0 +1,97 @@
"""Workspace role and permission foundations."""
from __future__ import annotations
from typing import Dict, List, Set
from fastapi import HTTPException, status
ROLE_WORKSPACE_OWNER = "workspace_owner"
ROLE_DOMAIN_ADMIN = "domain_admin"
ROLE_OPERATOR = "operator"
ROLE_ANALYST = "analyst"
ROLE_AUDITOR = "auditor"
PERMISSION_WORKSPACE_ADMIN = "workspace:admin"
PERMISSION_DOMAINS_WRITE = "domains:write"
PERMISSION_MAIL_SOURCES_WRITE = "mail_sources:write"
PERMISSION_NOTIFICATIONS_WRITE = "notifications:write"
PERMISSION_INTEGRATIONS_WRITE = "integrations:write"
PERMISSION_AUDIT_READ = "audit:read"
PERMISSION_REPORTS_READ = "reports:read"
ROLE_PERMISSIONS: Dict[str, Set[str]] = {
ROLE_WORKSPACE_OWNER: {
PERMISSION_WORKSPACE_ADMIN,
PERMISSION_DOMAINS_WRITE,
PERMISSION_MAIL_SOURCES_WRITE,
PERMISSION_NOTIFICATIONS_WRITE,
PERMISSION_INTEGRATIONS_WRITE,
PERMISSION_AUDIT_READ,
PERMISSION_REPORTS_READ,
},
ROLE_DOMAIN_ADMIN: {
PERMISSION_DOMAINS_WRITE,
PERMISSION_MAIL_SOURCES_WRITE,
PERMISSION_NOTIFICATIONS_WRITE,
PERMISSION_AUDIT_READ,
PERMISSION_REPORTS_READ,
},
ROLE_OPERATOR: {
PERMISSION_MAIL_SOURCES_WRITE,
PERMISSION_NOTIFICATIONS_WRITE,
PERMISSION_AUDIT_READ,
PERMISSION_REPORTS_READ,
},
ROLE_ANALYST: {
PERMISSION_REPORTS_READ,
},
ROLE_AUDITOR: {
PERMISSION_AUDIT_READ,
PERMISSION_REPORTS_READ,
},
}
def permissions_for_role(role: str) -> Set[str]:
"""Return normalized permissions for a workspace role."""
return set(ROLE_PERMISSIONS.get((role or "").strip().lower(), set()))
def role_allows(role: str, permission: str) -> bool:
"""Return True when a role grants a permission."""
return permission in permissions_for_role(role)
def list_workspace_roles() -> List[dict]:
"""Return API-safe role definitions for operator documentation and UI use."""
return [
{
"role": role,
"permissions": sorted(permissions),
}
for role, permissions in sorted(ROLE_PERMISSIONS.items())
]
def role_for_auth_context(auth_context: dict) -> str:
"""Map current admin auth into an initial workspace role.
Logto users and static admin credentials keep owner-level access until
membership assignment and enforcement screens are added.
"""
auth_type = (auth_context or {}).get("auth_type")
if auth_type in {"session", "bearer", "jwt", "api_key", "disabled"}:
return ROLE_WORKSPACE_OWNER
return ROLE_AUDITOR
def require_workspace_permission(auth_context: dict, permission: str) -> None:
"""Raise HTTP 403 when the current role does not grant a permission."""
role = role_for_auth_context(auth_context)
if role_allows(role, permission):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Workspace permission required: {permission}",
)
+162
View File
@@ -0,0 +1,162 @@
"""Sanitized workspace audit logging helpers."""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any, Dict, Iterable, List, Optional
from fastapi import Request
from sqlalchemy.orm import Session
from app.models.workspace import Workspace
from app.models.workspace_access import WorkspaceAuditLog
SECRET_FIELD_MARKERS = (
"password",
"secret",
"token",
"credential",
"authorization",
"api_key",
"access_token",
"refresh_token",
"client_secret",
"webhook_secret",
)
def _is_secret_key(key: str) -> bool:
normalized = key.lower()
return any(marker in normalized for marker in SECRET_FIELD_MARKERS)
def sanitize_audit_details(value: Any) -> Any:
"""Return a JSON-serializable value with secret-like fields redacted."""
if isinstance(value, dict):
return {
str(key): "[redacted]" if _is_secret_key(str(key)) else sanitize_audit_details(item)
for key, item in value.items()
}
if isinstance(value, (list, tuple, set)):
return [sanitize_audit_details(item) for item in value]
if isinstance(value, (str, int, float, bool)) or value is None:
return value
return str(value)
def actor_from_auth(auth_context: Optional[Dict[str, Any]]) -> Dict[str, Optional[str]]:
"""Normalize an auth context into audit actor fields."""
auth_context = auth_context or {}
auth_type = str(auth_context.get("auth_type") or "unknown")
actor_id: Optional[str] = None
if auth_context.get("user_id") is not None:
actor_id = str(auth_context["user_id"])
elif auth_context.get("payload", {}).get("sub"):
actor_id = str(auth_context["payload"]["sub"])
elif auth_context.get("token_id") is not None:
actor_id = str(auth_context["token_id"])
elif auth_type:
actor_id = auth_type
return {"actor_type": auth_type, "actor_id": actor_id}
def client_ip_from_request(request: Optional[Request]) -> Optional[str]:
"""Return the client IP address from a FastAPI request, if available."""
if request is None:
return None
forwarded_for = (request.headers.get("x-forwarded-for") or "").split(",", maxsplit=1)[0]
if forwarded_for.strip():
return forwarded_for.strip()
real_ip = request.headers.get("x-real-ip")
if real_ip and real_ip.strip():
return real_ip.strip()
if request.client is None:
return None
return request.client.host
def record_workspace_audit_log(
db: Session,
*,
workspace: Workspace,
action: str,
entity_type: str,
auth_context: Optional[Dict[str, Any]] = None,
entity_id: Optional[Any] = None,
entity_name: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
request: Optional[Request] = None,
created_at: Optional[datetime] = None,
commit: bool = False,
) -> WorkspaceAuditLog:
"""Persist one sanitized workspace audit event."""
actor = actor_from_auth(auth_context)
safe_details = sanitize_audit_details(details or {})
row = WorkspaceAuditLog(
workspace_id=workspace.id,
actor_type=actor["actor_type"] or "unknown",
actor_id=actor["actor_id"],
action=action,
entity_type=entity_type,
entity_id=str(entity_id) if entity_id is not None else None,
entity_name=entity_name,
details=json.dumps(safe_details, sort_keys=True),
ip_address=client_ip_from_request(request),
created_at=created_at or datetime.utcnow(),
)
db.add(row)
if commit:
db.commit()
db.refresh(row)
else:
db.flush()
return row
def audit_log_to_dict(row: WorkspaceAuditLog) -> Dict[str, Any]:
"""Return an API-safe audit row."""
try:
details = json.loads(row.details or "{}")
except (json.JSONDecodeError, TypeError):
details = {}
return {
"id": row.id,
"workspace_id": row.workspace_id,
"actor_type": row.actor_type,
"actor_id": row.actor_id,
"action": row.action,
"entity_type": row.entity_type,
"entity_id": row.entity_id,
"entity_name": row.entity_name,
"details": details,
"ip_address": row.ip_address,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
def list_workspace_audit_logs(
db: Session,
*,
workspace: Workspace,
limit: int = 50,
action: Optional[str] = None,
entity_type: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Return recent audit logs for a workspace."""
query = db.query(WorkspaceAuditLog).filter(WorkspaceAuditLog.workspace_id == workspace.id)
if action:
query = query.filter(WorkspaceAuditLog.action == action)
if entity_type:
query = query.filter(WorkspaceAuditLog.entity_type == entity_type)
rows = (
query.order_by(WorkspaceAuditLog.created_at.desc(), WorkspaceAuditLog.id.desc())
.limit(max(1, min(limit, 200)))
.all()
)
return [audit_log_to_dict(row) for row in rows]
def changed_fields(fields: Iterable[str]) -> List[str]:
"""Return sorted field names safe for audit details."""
return sorted({str(field) for field in fields})