python-jose jwt.decode() requires audience as string or None, not list. Now extract and validate audience from unverified claims first, then use token's actual audience for JWT decode. Fixes "audience must be a string or None" error. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
777 lines
25 KiB
Python
777 lines
25 KiB
Python
"""
|
|
OIDC Authentication Module
|
|
|
|
Provides OAuth2/OIDC token validation for FastAPI using Authentik as IdP.
|
|
Implements bearer token authentication with JWT verification.
|
|
|
|
Permission Format: domain.category:action
|
|
- domain: Main area (control-room, library, media, ai, etc.)
|
|
- category: Sub-area within domain (general for full domain, or specific tools)
|
|
- action: Permission level (viewer, user, editor, admin)
|
|
|
|
Examples:
|
|
- control-room.general:admin - Full access to Control Room
|
|
- media.general:viewer - View-only access to Media area
|
|
|
|
Action Hierarchy (higher implies lower):
|
|
- admin > editor > user > viewer
|
|
"""
|
|
from fastapi import Depends, HTTPException, Security, Request
|
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
from jose import jwt, JWTError
|
|
import httpx
|
|
from functools import lru_cache
|
|
from typing import Callable, Dict, List, Optional
|
|
from src.shared.logging import get_logger
|
|
|
|
|
|
# =============================================================================
|
|
# Permission System
|
|
# =============================================================================
|
|
|
|
# Action hierarchy: higher actions imply lower ones
|
|
ACTION_HIERARCHY: Dict[str, int] = {
|
|
"viewer": 1,
|
|
"user": 2,
|
|
"editor": 3,
|
|
"admin": 4,
|
|
}
|
|
|
|
# Valid domains (main areas)
|
|
VALID_DOMAINS = {
|
|
"control-room",
|
|
"library",
|
|
"media",
|
|
"ai",
|
|
"housekeeper",
|
|
"developer",
|
|
"documents",
|
|
"gaming",
|
|
"admin", # Global admin domain
|
|
}
|
|
|
|
# Default category for general domain access
|
|
DEFAULT_CATEGORY = "general"
|
|
|
|
logger = get_logger(__name__)
|
|
security = HTTPBearer(auto_error=False)
|
|
|
|
|
|
class OIDCConfig:
|
|
"""OIDC configuration from environment"""
|
|
|
|
def __init__(self):
|
|
# These will be set from environment variables in config.py
|
|
self.enabled = False
|
|
self.issuers: list[str] = []
|
|
self.audiences: list[str] = []
|
|
|
|
def configure(self, enabled: bool, issuers: list[str], audiences: list[str]):
|
|
"""Configure OIDC settings"""
|
|
self.enabled = enabled
|
|
self.issuers = [iss.rstrip('/') for iss in issuers] # Normalize without trailing slash
|
|
self.audiences = audiences
|
|
logger.info(f"OIDC configured: enabled={enabled}, issuers={self.issuers}, audiences={audiences}")
|
|
|
|
def get_jwks_uri(self, issuer: str) -> str:
|
|
"""Get JWKS URI for a specific issuer"""
|
|
return f"{issuer.rstrip('/')}/jwks/"
|
|
|
|
def is_valid_issuer(self, issuer: str) -> bool:
|
|
"""Check if issuer is in the allowed list"""
|
|
normalized = issuer.rstrip('/')
|
|
return normalized in self.issuers
|
|
|
|
|
|
# Global OIDC config instance
|
|
oidc_config = OIDCConfig()
|
|
|
|
|
|
# Per-issuer JWKS cache
|
|
_jwks_cache: Dict[str, Dict] = {}
|
|
|
|
|
|
def get_jwks_for_issuer(issuer: str) -> Dict:
|
|
"""
|
|
Fetch JSON Web Key Set (JWKS) for a specific issuer.
|
|
|
|
Cached per-issuer to avoid repeated requests. Cache is cleared on server restart.
|
|
|
|
Args:
|
|
issuer: The token issuer URL
|
|
|
|
Returns:
|
|
JWKS dictionary containing public keys for token verification
|
|
|
|
Raises:
|
|
HTTPException: If JWKS fetch fails
|
|
"""
|
|
if not oidc_config.enabled:
|
|
return {}
|
|
|
|
normalized_issuer = issuer.rstrip('/')
|
|
|
|
# Return cached JWKS if available
|
|
if normalized_issuer in _jwks_cache:
|
|
return _jwks_cache[normalized_issuer]
|
|
|
|
jwks_uri = oidc_config.get_jwks_uri(normalized_issuer)
|
|
|
|
try:
|
|
logger.debug(f"Fetching JWKS from {jwks_uri}")
|
|
response = httpx.get(jwks_uri, timeout=10.0)
|
|
response.raise_for_status()
|
|
jwks = response.json()
|
|
logger.info(f"JWKS fetched successfully for {normalized_issuer} ({len(jwks.get('keys', []))} keys)")
|
|
_jwks_cache[normalized_issuer] = jwks
|
|
return jwks
|
|
except Exception as e:
|
|
logger.error(f"Failed to fetch JWKS from {jwks_uri}: {e}")
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Authentication service unavailable"
|
|
)
|
|
|
|
|
|
async def get_current_user(
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(security)
|
|
) -> Optional[Dict]:
|
|
"""
|
|
Validate OIDC token from Authorization: Bearer header
|
|
|
|
Extracts and validates JWT token from request header. Verifies:
|
|
- Token signature using JWKS
|
|
- Token expiration
|
|
- Issuer matches Authentik
|
|
- Audience matches core-api
|
|
|
|
Args:
|
|
credentials: HTTP Bearer token from Authorization header
|
|
|
|
Returns:
|
|
User claims dictionary containing email, name, groups, etc.
|
|
Returns None if OIDC is disabled (allows unauthenticated access)
|
|
|
|
Raises:
|
|
HTTPException 401: If token is invalid, expired, or missing when OIDC enabled
|
|
"""
|
|
# If OIDC is disabled, return a default local user
|
|
if not oidc_config.enabled:
|
|
logger.debug("OIDC disabled - using local user")
|
|
return {
|
|
"sub": "local-user",
|
|
"email": "local@localhost",
|
|
"preferred_username": "local",
|
|
"name": "Local User",
|
|
"groups": ["admin"],
|
|
"auth_method": "local"
|
|
}
|
|
|
|
# OIDC enabled - token required
|
|
if not credentials:
|
|
logger.warning("Authentication required but no token provided")
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="Authentication required",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
token = credentials.credentials
|
|
|
|
try:
|
|
# First, decode token without verification to get issuer and key ID
|
|
unverified_header = jwt.get_unverified_header(token)
|
|
unverified_claims = jwt.get_unverified_claims(token)
|
|
|
|
kid = unverified_header.get("kid")
|
|
token_issuer = unverified_claims.get("iss", "")
|
|
token_audience = unverified_claims.get("aud", "")
|
|
|
|
if not kid:
|
|
raise HTTPException(status_code=401, detail="Invalid token format")
|
|
|
|
# Validate issuer is in allowed list
|
|
logger.debug(f"Token issuer: {token_issuer}, allowed issuers: {oidc_config.issuers}")
|
|
if not oidc_config.is_valid_issuer(token_issuer):
|
|
logger.warning(f"Invalid token issuer: {token_issuer} (allowed: {oidc_config.issuers})")
|
|
raise HTTPException(status_code=401, detail="Invalid token issuer")
|
|
|
|
# Validate audience is in allowed list
|
|
if token_audience not in oidc_config.audiences:
|
|
logger.warning(f"Invalid token audience: {token_audience} (allowed: {oidc_config.audiences})")
|
|
raise HTTPException(status_code=401, detail="Invalid token audience")
|
|
|
|
# Get JWKS for this specific issuer
|
|
jwks = get_jwks_for_issuer(token_issuer)
|
|
rsa_key = None
|
|
|
|
for key in jwks.get("keys", []):
|
|
if key.get("kid") == kid:
|
|
rsa_key = key
|
|
break
|
|
|
|
if not rsa_key:
|
|
logger.warning(f"No matching key found for kid: {kid}")
|
|
raise HTTPException(status_code=401, detail="Invalid token key")
|
|
|
|
# Verify and decode token using the token's actual issuer and audience
|
|
payload = jwt.decode(
|
|
token,
|
|
rsa_key,
|
|
algorithms=["RS256"],
|
|
audience=token_audience, # Use the token's audience (already validated)
|
|
issuer=token_issuer, # Use the token's issuer (already validated)
|
|
)
|
|
|
|
user_email = payload.get("email", "unknown")
|
|
logger.info(f"Authenticated user: {user_email} (issuer: {token_issuer})")
|
|
|
|
return payload
|
|
|
|
except jwt.ExpiredSignatureError:
|
|
logger.warning("Token expired")
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="Token expired",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
except jwt.JWTClaimsError as e:
|
|
logger.warning(f"Invalid token claims: {e}")
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="Invalid token claims",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
except JWTError as e:
|
|
logger.error(f"JWT validation error: {e}")
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="Invalid authentication token",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Unexpected authentication error: {e}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Authentication error",
|
|
)
|
|
|
|
|
|
async def get_admin_user(
|
|
user: Optional[Dict] = Depends(get_current_user)
|
|
) -> Dict:
|
|
"""
|
|
Require admin group membership
|
|
|
|
Use this dependency for endpoints that require admin access.
|
|
Checks if user is member of 'admin' group in Authentik.
|
|
|
|
Args:
|
|
user: User claims from get_current_user
|
|
|
|
Returns:
|
|
User claims dictionary if user is admin
|
|
|
|
Raises:
|
|
HTTPException 403: If user is not in admin group
|
|
HTTPException 401: If OIDC enabled but user not authenticated
|
|
"""
|
|
# If OIDC disabled, allow all (backward compatibility)
|
|
if not oidc_config.enabled or user is None:
|
|
logger.debug("OIDC disabled - allowing admin access")
|
|
return {"email": "unauthenticated", "groups": ["admin"]}
|
|
|
|
# Check admin group membership
|
|
groups = user.get("groups", [])
|
|
|
|
if "admin" not in groups and "authentik Admins" not in groups:
|
|
user_email = user.get("email", "unknown")
|
|
logger.warning(f"User {user_email} attempted admin access (groups: {groups})")
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Admin access required"
|
|
)
|
|
|
|
return user
|
|
|
|
|
|
async def get_optional_user(
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(security)
|
|
) -> Optional[Dict]:
|
|
"""
|
|
Optional authentication - allows both authenticated and unauthenticated access
|
|
|
|
Use for endpoints that should be accessible to everyone but can provide
|
|
enhanced functionality for authenticated users.
|
|
|
|
Args:
|
|
credentials: HTTP Bearer token from Authorization header
|
|
|
|
Returns:
|
|
User claims if valid token provided, local user if OIDC disabled, None otherwise
|
|
"""
|
|
# If OIDC is disabled, return the local user
|
|
if not oidc_config.enabled:
|
|
return {
|
|
"sub": "local-user",
|
|
"email": "local@localhost",
|
|
"preferred_username": "local",
|
|
"name": "Local User",
|
|
"groups": ["admin"],
|
|
"auth_method": "local"
|
|
}
|
|
|
|
if not credentials:
|
|
logger.debug("No credentials provided for optional auth")
|
|
return None
|
|
|
|
try:
|
|
return await get_current_user(credentials)
|
|
except HTTPException as e:
|
|
# Invalid token - log and return None instead of raising
|
|
logger.warning(f"Optional auth failed: {e.detail}")
|
|
return None
|
|
|
|
|
|
async def get_forward_auth_user(
|
|
request: Request
|
|
) -> Optional[Dict]:
|
|
"""
|
|
Authentik Forward Auth authentication for external access via NPM
|
|
|
|
This dependency allows:
|
|
- External access through api.schweitz.net (with Authentik forward auth headers) - REQUIRES authentication
|
|
- Internal direct access (no forward auth headers) - ALLOWED without authentication
|
|
|
|
When accessing through NPM with Authentik forward auth enabled, NPM adds headers like:
|
|
- X-authentik-username
|
|
- X-authentik-email
|
|
- X-authentik-groups
|
|
- X-authentik-name
|
|
- X-authentik-uid
|
|
|
|
Args:
|
|
request: FastAPI request object containing headers
|
|
|
|
Returns:
|
|
User info dict if authenticated via forward auth headers
|
|
None if accessed internally (no forward auth headers)
|
|
|
|
Raises:
|
|
HTTPException 401: If forward auth headers present but invalid/incomplete
|
|
"""
|
|
# Check for Authentik forward auth headers
|
|
username = request.headers.get("x-authentik-username")
|
|
email = request.headers.get("x-authentik-email")
|
|
groups = request.headers.get("x-authentik-groups")
|
|
name = request.headers.get("x-authentik-name")
|
|
uid = request.headers.get("x-authentik-uid")
|
|
|
|
# If NO forward auth headers present, this is internal access - allow it
|
|
if not username and not email:
|
|
logger.debug("No forward auth headers - allowing internal access")
|
|
return None
|
|
|
|
# Forward auth headers present (external access via api.schweitz.net)
|
|
# Validate authentication
|
|
if not username or not email:
|
|
logger.warning("Incomplete forward auth headers detected")
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="Authentication required - incomplete forward auth headers"
|
|
)
|
|
|
|
# Parse groups (comma-separated string to list)
|
|
groups_list = [g.strip() for g in groups.split(",")] if groups else []
|
|
|
|
user_info = {
|
|
"username": username,
|
|
"email": email,
|
|
"name": name or username,
|
|
"groups": groups_list,
|
|
"uid": uid,
|
|
"auth_method": "forward_auth"
|
|
}
|
|
|
|
logger.info(f"Authenticated via forward auth: {email} (groups: {groups_list})")
|
|
return user_info
|
|
|
|
|
|
async def get_forward_auth_admin(
|
|
user: Optional[Dict] = Depends(get_forward_auth_user)
|
|
) -> Dict:
|
|
"""
|
|
Require admin access for external requests, allow all internal requests
|
|
|
|
Use this dependency for endpoints that require admin access when accessed
|
|
externally through api.schweitz.net, but allow unrestricted internal access.
|
|
|
|
Args:
|
|
user: User info from get_forward_auth_user
|
|
|
|
Returns:
|
|
User info dict if user is admin or if accessed internally
|
|
|
|
Raises:
|
|
HTTPException 403: If external user is not in admin/authentik Admins group
|
|
"""
|
|
# Internal access (no forward auth headers) - allow all
|
|
if user is None:
|
|
logger.debug("Internal access - allowing without admin check")
|
|
return {"email": "internal", "groups": ["admin"], "auth_method": "internal"}
|
|
|
|
# External access - check admin group membership
|
|
groups = user.get("groups", [])
|
|
|
|
if "admin" not in groups and "authentik Admins" not in groups:
|
|
user_email = user.get("email", "unknown")
|
|
logger.warning(f"User {user_email} attempted admin access (groups: {groups})")
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Admin access required"
|
|
)
|
|
|
|
return user
|
|
|
|
|
|
async def get_current_user_or_forward_auth(
|
|
request: Request,
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(security)
|
|
) -> Dict:
|
|
"""
|
|
Combined auth: Try forward auth headers first, then JWT Bearer token.
|
|
|
|
Supports both:
|
|
- Web clients via NPM forward auth (X-authentik-* headers from proxy)
|
|
- Mobile/native clients via OIDC JWT Bearer tokens
|
|
|
|
This is the preferred dependency for /auth/users/me and similar endpoints
|
|
that need to work with both web (cookie-based via NPM) and mobile (token-based).
|
|
|
|
Args:
|
|
request: FastAPI request object containing headers
|
|
credentials: HTTP Bearer token from Authorization header
|
|
|
|
Returns:
|
|
User claims dictionary with at minimum: sub, email, name, groups, auth_method
|
|
|
|
Raises:
|
|
HTTPException 401: If neither forward auth headers nor valid JWT provided
|
|
"""
|
|
# 1. Try forward auth headers first (web via NPM)
|
|
username = request.headers.get("x-authentik-username")
|
|
email = request.headers.get("x-authentik-email")
|
|
|
|
if username and email:
|
|
# Forward auth headers present - use them
|
|
groups = request.headers.get("x-authentik-groups", "")
|
|
name = request.headers.get("x-authentik-name", username)
|
|
uid = request.headers.get("x-authentik-uid")
|
|
|
|
user_info = {
|
|
"sub": uid, # Use authentik UID as subject (for user lookup)
|
|
"email": email,
|
|
"preferred_username": username,
|
|
"name": name,
|
|
"groups": [g.strip() for g in groups.split(",")] if groups else [],
|
|
"auth_method": "forward_auth"
|
|
}
|
|
|
|
logger.info(f"Authenticated via forward auth: {email}")
|
|
return user_info
|
|
|
|
# 2. Fall back to JWT Bearer token (mobile/native)
|
|
return await get_current_user(credentials)
|
|
|
|
|
|
# =============================================================================
|
|
# Permission-Based Access Control
|
|
# =============================================================================
|
|
|
|
def _parse_permission(permission: str) -> tuple[str, str, str]:
|
|
"""
|
|
Parse a permission string into (domain, category, action)
|
|
|
|
Supports formats:
|
|
- domain.category:action (full): "control-room.general:admin"
|
|
- domain:action (shorthand): "control-room:admin" -> ("control-room", "general", "admin")
|
|
|
|
Returns:
|
|
Tuple of (domain, category, action)
|
|
|
|
Raises:
|
|
ValueError: If permission format is invalid
|
|
"""
|
|
# Split on colon first to get action
|
|
if ":" not in permission:
|
|
raise ValueError(f"Invalid permission format (missing ':'): {permission}")
|
|
|
|
location, action = permission.rsplit(":", 1)
|
|
|
|
# Split location on dot to get domain and category
|
|
if "." in location:
|
|
domain, category = location.split(".", 1)
|
|
else:
|
|
# Shorthand: domain:action -> domain.general:action
|
|
domain = location
|
|
category = DEFAULT_CATEGORY
|
|
|
|
return domain, category, action
|
|
|
|
|
|
def _action_satisfies(user_action: str, required_action: str) -> bool:
|
|
"""
|
|
Check if user's action level satisfies the required action
|
|
|
|
Due to hierarchy, admin satisfies editor, editor satisfies user, etc.
|
|
|
|
Args:
|
|
user_action: The action the user has
|
|
required_action: The action required for access
|
|
|
|
Returns:
|
|
True if user's action is >= required action
|
|
"""
|
|
user_level = ACTION_HIERARCHY.get(user_action, 0)
|
|
required_level = ACTION_HIERARCHY.get(required_action, 0)
|
|
return user_level >= required_level
|
|
|
|
|
|
def _user_has_permission(
|
|
user_permissions: List[str],
|
|
required_domain: str,
|
|
required_category: str,
|
|
required_action: str,
|
|
) -> bool:
|
|
"""
|
|
Check if user has a permission that satisfies the requirement
|
|
|
|
Checks:
|
|
1. Exact match: domain.category:action
|
|
2. Domain-wide: domain.general:action (if category != general)
|
|
3. Global admin: admin.general:admin (superuser)
|
|
|
|
Args:
|
|
user_permissions: List of user's permission strings
|
|
required_domain: Required domain
|
|
required_category: Required category
|
|
required_action: Required action
|
|
|
|
Returns:
|
|
True if user has sufficient permission
|
|
"""
|
|
for perm in user_permissions:
|
|
try:
|
|
dom, cat, act = _parse_permission(perm)
|
|
except ValueError:
|
|
continue
|
|
|
|
# Global admin (admin.general:admin) grants all permissions
|
|
if dom == "admin" and cat == "general" and act == "admin":
|
|
return True
|
|
|
|
# Check if this permission covers the requirement
|
|
if dom == required_domain:
|
|
# Exact category match
|
|
if cat == required_category and _action_satisfies(act, required_action):
|
|
return True
|
|
# Domain-wide permission (general category) covers all categories in domain
|
|
if cat == "general" and _action_satisfies(act, required_action):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def _extract_permissions_from_groups(groups: List[str]) -> List[str]:
|
|
"""
|
|
Extract permission strings from Authentik group names
|
|
|
|
Authentik groups follow naming: tatlock-{domain}-{category}-{action}
|
|
or shorthand: tatlock-{domain}-{action} (implies category=general)
|
|
|
|
Examples:
|
|
- tatlock-control-room-general-admin -> control-room.general:admin
|
|
- tatlock-media-viewer -> media.general:viewer (shorthand)
|
|
- tatlock-tools-dns-user -> tools.dns:user
|
|
|
|
Args:
|
|
groups: List of Authentik group names
|
|
|
|
Returns:
|
|
List of permission strings
|
|
"""
|
|
permissions = []
|
|
|
|
for group in groups:
|
|
if not group.startswith("tatlock-"):
|
|
continue
|
|
|
|
# Remove prefix
|
|
parts = group[8:].split("-") # Remove "tatlock-"
|
|
|
|
if len(parts) >= 3:
|
|
# Could be domain-category-action or domain-with-hyphen-action
|
|
# Try to find a valid action at the end
|
|
action = parts[-1]
|
|
if action in ACTION_HIERARCHY:
|
|
# Check if domain-category or single domain with hyphen
|
|
remaining = parts[:-1]
|
|
|
|
# Try to find known domain (greedy match from start)
|
|
for i in range(len(remaining), 0, -1):
|
|
potential_domain = "-".join(remaining[:i])
|
|
if potential_domain in VALID_DOMAINS:
|
|
category_parts = remaining[i:]
|
|
category = "-".join(category_parts) if category_parts else DEFAULT_CATEGORY
|
|
permissions.append(f"{potential_domain}.{category}:{action}")
|
|
break
|
|
elif len(parts) == 2:
|
|
# Shorthand: domain-action (domain might have hyphen)
|
|
action = parts[-1]
|
|
if action in ACTION_HIERARCHY:
|
|
domain = parts[0]
|
|
if domain in VALID_DOMAINS:
|
|
permissions.append(f"{domain}.{DEFAULT_CATEGORY}:{action}")
|
|
|
|
return permissions
|
|
|
|
|
|
def require_permission(
|
|
domain: str,
|
|
action: str,
|
|
category: str = DEFAULT_CATEGORY,
|
|
) -> Callable:
|
|
"""
|
|
Dependency factory for permission-based access control
|
|
|
|
Creates a FastAPI dependency that checks if the current user has
|
|
the required permission. Considers action hierarchy and global admin.
|
|
|
|
Usage:
|
|
@router.get("/containers")
|
|
async def list_containers(
|
|
user: Dict = Depends(require_permission("control-room", "viewer"))
|
|
):
|
|
...
|
|
|
|
@router.delete("/container/{id}")
|
|
async def delete_container(
|
|
user: Dict = Depends(require_permission("control-room", "admin"))
|
|
):
|
|
...
|
|
|
|
Args:
|
|
domain: Permission domain (e.g., "control-room", "media")
|
|
action: Required action level (viewer, user, editor, admin)
|
|
category: Permission category within domain, defaults to "general"
|
|
|
|
Returns:
|
|
FastAPI dependency function
|
|
"""
|
|
perm_str = f"{domain}.{category}:{action}"
|
|
|
|
async def permission_checker(
|
|
user: Optional[Dict] = Depends(get_current_user)
|
|
) -> Dict:
|
|
"""Check if user has required permission"""
|
|
|
|
# If OIDC disabled, allow all (local dev mode)
|
|
if not oidc_config.enabled:
|
|
logger.debug(f"OIDC disabled - allowing {perm_str}")
|
|
return user or {"email": "local", "groups": ["admin"]}
|
|
|
|
if user is None:
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="Authentication required",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
# Extract permissions from user's groups
|
|
groups = user.get("groups", [])
|
|
permissions = _extract_permissions_from_groups(groups)
|
|
|
|
# Check if user has required permission
|
|
if _user_has_permission(permissions, domain, category, action):
|
|
logger.debug(f"User {user.get('email')} granted {perm_str}")
|
|
return user
|
|
|
|
# Permission denied
|
|
user_email = user.get("email", "unknown")
|
|
logger.warning(
|
|
f"User {user_email} denied {perm_str} "
|
|
f"(groups: {groups}, permissions: {permissions})"
|
|
)
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=f"Permission required: {perm_str}",
|
|
)
|
|
|
|
return permission_checker
|
|
|
|
|
|
def require_any_permission(*required_permissions: str) -> Callable:
|
|
"""
|
|
Dependency factory requiring any one of multiple permissions
|
|
|
|
Useful for endpoints accessible to multiple roles.
|
|
|
|
Usage:
|
|
@router.get("/shared-resource")
|
|
async def get_shared(
|
|
user: Dict = Depends(require_any_permission(
|
|
"control-room:viewer",
|
|
"media:viewer",
|
|
))
|
|
):
|
|
...
|
|
|
|
Args:
|
|
*required_permissions: Permission strings (domain.category:action or domain:action)
|
|
|
|
Returns:
|
|
FastAPI dependency function
|
|
"""
|
|
|
|
async def permission_checker(
|
|
user: Optional[Dict] = Depends(get_current_user)
|
|
) -> Dict:
|
|
"""Check if user has any of the required permissions"""
|
|
|
|
# If OIDC disabled, allow all
|
|
if not oidc_config.enabled:
|
|
return user or {"email": "local", "groups": ["admin"]}
|
|
|
|
if user is None:
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="Authentication required",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
groups = user.get("groups", [])
|
|
permissions = _extract_permissions_from_groups(groups)
|
|
|
|
# Check each required permission
|
|
for perm in required_permissions:
|
|
try:
|
|
dom, cat, act = _parse_permission(perm)
|
|
if _user_has_permission(permissions, dom, cat, act):
|
|
logger.debug(f"User {user.get('email')} granted via {perm}")
|
|
return user
|
|
except ValueError:
|
|
logger.warning(f"Invalid permission format: {perm}")
|
|
continue
|
|
|
|
# None matched
|
|
user_email = user.get("email", "unknown")
|
|
logger.warning(
|
|
f"User {user_email} denied (required any of: {required_permissions})"
|
|
)
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=f"One of these permissions required: {', '.join(required_permissions)}",
|
|
)
|
|
|
|
return permission_checker
|