feat(auth): implement group-role mapping and permission system
Architecture changes: - Permission format: domain.category:action (e.g., control-room.general:admin) - Decoupled groups from roles via group_roles mapping table - Groups are organizational (synced from Authentik) - Roles are permissions (admin-managed via API) New features: - require_permission() and require_any_permission() dependency factories - Action hierarchy: admin > editor > user > viewer - Global admin override (admin.general:admin grants all) - Group-role management endpoints (assign/remove roles) - GET /auth/roles endpoint to list all roles Database changes: - Added category column to roles table (default: general) - Removed authentik_group column (decoupled) - Added group_roles association table - Added user_groups association table - Migration updates role names to domain.general:action format Tests: - 67 new tests for auth service and controller - Covers token validation, user sync, role sync - Covers group-role assignment/removal - Covers schema conversions and permission system 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
075b0ec297
commit
7752cd9d23
+335
-1
@@ -3,15 +3,57 @@ 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 Dict, Optional
|
||||
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)
|
||||
|
||||
@@ -352,3 +394,295 @@ async def get_forward_auth_admin(
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user