Added get_current_user_or_forward_auth() combined dependency that: - First checks for X-authentik-* headers from NPM forward auth (web) - Falls back to JWT Bearer token validation (mobile/native) This fixes web authentication where browsers don't send Bearer tokens but rely on NPM's forward auth proxy to pass user info via headers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
739 lines
23 KiB
Python
739 lines
23 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
|
|
- ai.ollama:user - User-level access to Ollama specifically (future)
|
|
|
|
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.issuer = ""
|
|
self.audience = ""
|
|
self.jwks_uri = ""
|
|
|
|
def configure(self, enabled: bool, issuer: str, audience: str):
|
|
"""Configure OIDC settings"""
|
|
self.enabled = enabled
|
|
self.issuer = issuer
|
|
self.audience = audience
|
|
self.jwks_uri = f"{issuer.rstrip('/')}/jwks/"
|
|
logger.info(f"OIDC configured: enabled={enabled}, issuer={issuer}")
|
|
|
|
|
|
# Global OIDC config instance
|
|
oidc_config = OIDCConfig()
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def get_jwks() -> Dict:
|
|
"""
|
|
Fetch JSON Web Key Set (JWKS) from Authentik
|
|
|
|
Cached to avoid repeated requests. Cache is cleared on server restart.
|
|
|
|
Returns:
|
|
JWKS dictionary containing public keys for token verification
|
|
|
|
Raises:
|
|
HTTPException: If JWKS fetch fails
|
|
"""
|
|
if not oidc_config.enabled:
|
|
return {}
|
|
|
|
try:
|
|
logger.debug(f"Fetching JWKS from {oidc_config.jwks_uri}")
|
|
response = httpx.get(oidc_config.jwks_uri, timeout=10.0)
|
|
response.raise_for_status()
|
|
jwks = response.json()
|
|
logger.info(f"JWKS fetched successfully ({len(jwks.get('keys', []))} keys)")
|
|
return jwks
|
|
except Exception as e:
|
|
logger.error(f"Failed to fetch JWKS: {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:
|
|
# Decode token header to get key ID
|
|
unverified_header = jwt.get_unverified_header(token)
|
|
kid = unverified_header.get("kid")
|
|
|
|
if not kid:
|
|
raise HTTPException(status_code=401, detail="Invalid token format")
|
|
|
|
# Find matching key in JWKS
|
|
jwks = get_jwks()
|
|
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
|
|
payload = jwt.decode(
|
|
token,
|
|
rsa_key,
|
|
algorithms=["RS256"],
|
|
audience=oidc_config.audience,
|
|
issuer=oidc_config.issuer,
|
|
)
|
|
|
|
user_email = payload.get("email", "unknown")
|
|
logger.info(f"Authenticated user: {user_email}")
|
|
|
|
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:
|
|
return None
|
|
|
|
try:
|
|
return await get_current_user(credentials)
|
|
except HTTPException:
|
|
# Invalid token - return None instead of raising
|
|
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-ai-ollama-user -> ai.ollama: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
|