aegislm / security /permissions.py
ACA050's picture
Upload 57 files
f2c6053 verified
Raw
History Blame Contribute Delete
15.4 kB
"""
FastAPI Permission Dependencies for RBAC
Provides FastAPI dependency functions for role-based access control
and tenant-scoped access enforcement.
"""
import uuid
from typing import Optional
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from security.rbac import RBAC, Role, Permission, RBACContext
from security.tenant_scope import TenantScope, set_tenant_scope, get_tenant_scope
from backend.db.session import get_db_session
from backend.db.models import User, Tenant
# Security scheme
security = HTTPBearer(auto_error=False)
async def get_current_user(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = None,
) -> "AuthenticatedUser":
"""
Get the current authenticated user from the request.
CRITICAL: This validates JWT signature FIRST (no DB), then only queries DB
if the token is valid. This ensures 401 is returned BEFORE any database
access for unauthenticated requests.
Args:
request: FastAPI request object
credentials: HTTP Authorization credentials
Returns:
AuthenticatedUser object with user info
Raises:
HTTPException: If authentication fails (401 returned BEFORE DB access)
"""
from security.auth_middleware import decode_jwt_token, hash_api_key
user = None
# Try JWT token first - VALIDATE WITHOUT DB (fails fast)
if credentials:
token = credentials.credentials
try:
# Step 1: Validate JWT signature and expiration (NO DB ACCESS)
payload = decode_jwt_token(token)
user_id = uuid.UUID(payload["sub"])
tenant_id = uuid.UUID(payload["tenant_id"])
email = payload["email"]
role = payload["role"]
# Token is valid - return user without DB query for now
# The tenant_id and role are embedded in the token
return AuthenticatedUser(
user_id=user_id,
tenant_id=tenant_id,
email=email,
role=Role(role),
is_api_client=False,
)
except HTTPException:
# Re-raise HTTP exceptions (401, etc)
raise
except Exception:
# Token invalid or expired - return 401 BEFORE any DB access
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
# Try API key - this DOES require DB but check header first
api_key = request.headers.get("X-API-Key")
if api_key:
# Need DB for API key verification - but header must exist first
from sqlalchemy import select
from backend.db.models import APIKey
from backend.db.session import get_db_session
# Get a fresh DB session for API key
async for db in get_db_session():
try:
key_hash = hash_api_key(api_key)
query = select(APIKey).where(
APIKey.key_hash == key_hash,
APIKey.active == True,
)
result = await db.execute(query)
api_key_obj = result.scalar_one_or_none()
if api_key_obj:
# Update last used
from datetime import datetime
api_key_obj.last_used = datetime.utcnow()
await db.commit()
return AuthenticatedUser(
user_id=api_key_obj.id,
tenant_id=api_key_obj.tenant_id,
email=f"api:{api_key_obj.owner}",
role=Role.API_CLIENT,
is_api_client=True,
)
finally:
break
# No valid authentication provided
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
async def get_current_user_optional(
request: Request,
db: AsyncSession = Depends(get_db_session),
) -> Optional["AuthenticatedUser"]:
"""
Get the current authenticated user, optionally.
Returns None if no valid authentication is provided.
Args:
request: FastAPI request object
db: Database session
Returns:
AuthenticatedUser object or None
"""
try:
return await get_current_user(request, None, db)
except HTTPException:
return None
class AuthenticatedUser:
"""
Represents an authenticated user with tenant and role information.
"""
def __init__(
self,
user_id: uuid.UUID,
tenant_id: uuid.UUID,
email: str,
role: Role,
is_api_client: bool = False,
):
self.user_id = user_id
self.tenant_id = tenant_id
self.email = email
self.role = role
self.is_api_client = is_api_client
def to_rbac_context(self) -> RBACContext:
"""Convert to RBAC context for permission checks."""
return RBACContext(
user_id=self.user_id,
tenant_id=self.tenant_id,
role=self.role,
)
def has_permission(self, permission: Permission) -> bool:
"""Check if user has a specific permission."""
return RBAC.has_permission(self.role, permission)
def __repr__(self) -> str:
return f"<AuthenticatedUser(email={self.email}, role={self.role}, tenant_id={self.tenant_id})>"
def require_authenticated_user(
user: AuthenticatedUser = Depends(get_current_user),
) -> AuthenticatedUser:
"""
Require an authenticated user.
Usage:
@router.get("/protected")
async def protected_endpoint(user: AuthenticatedUser = Depends(require_authenticated_user)):
...
"""
return user
def require_role(required_role: Role):
"""
Require a specific role.
Usage:
@router.get("/admin/users")
async def list_users(user: AuthenticatedUser = Depends(require_role(Role.ADMIN))):
...
Args:
required_role: The role required to access the endpoint
Returns:
FastAPI dependency that checks role
"""
async def role_checker(
user: AuthenticatedUser = Depends(get_current_user),
) -> AuthenticatedUser:
if user.role != required_role:
# Check if user has the required role
# ADMIN can do everything
if required_role != Role.ADMIN and user.role == Role.ADMIN:
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires {required_role.value} role"
)
return user
return role_checker
def require_permission(required_permission: Permission):
"""
Require a specific permission.
Usage:
@router.post("/jobs")
async def create_job(
user: AuthenticatedUser = Depends(require_permission(Permission.CREATE_JOB))
):
...
Args:
required_permission: The permission required to access the endpoint
Returns:
FastAPI dependency that checks permission
"""
async def permission_checker(
user: AuthenticatedUser = Depends(get_current_user),
) -> AuthenticatedUser:
if not user.has_permission(required_permission):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires {required_permission.value} permission"
)
return user
return permission_checker
def require_tenant_access(tenant_id: uuid.UUID):
"""
Require access to a specific tenant.
This ensures users can only access their own tenant's data.
Usage:
@router.get("/tenants/{tenant_id}/users")
async def list_tenant_users(
tenant_id: uuid.UUID,
user: AuthenticatedUser = Depends(require_tenant_access(tenant_id))
):
...
Args:
tenant_id: The tenant ID to check access for
Returns:
FastAPI dependency that checks tenant access
"""
async def tenant_checker(
user: AuthenticatedUser = Depends(get_current_user),
) -> AuthenticatedUser:
# API clients can only access their own tenant
if user.is_api_client and user.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot access other tenants"
)
# Regular users can only access their own tenant
if user.tenant_id != tenant_id:
# Only platform admins can access other tenants
if user.role != Role.ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot access other tenants"
)
return user
return tenant_checker
class TenantContext:
"""
Tenant context for the current request.
This ensures all database queries are scoped to the current tenant.
"""
def __init__(
self,
user: AuthenticatedUser = Depends(get_current_user),
):
self.user = user
self.tenant_id = user.tenant_id
self.user_id = user.user_id
self.role = user.role
# Set tenant scope in context
scope = TenantScope(tenant_id=self.tenant_id)
set_tenant_scope(scope)
def get_tenant_filter(self, table_class) -> dict:
"""
Get tenant filter for a table.
Args:
table_class: SQLAlchemy model class
Returns:
Dictionary with tenant_id filter
"""
return {"tenant_id": self.tenant_id}
def __repr__(self) -> str:
return f"<TenantContext(tenant_id={self.tenant_id}, user={self.user.email})>"
def get_tenant_context(
user: AuthenticatedUser = Depends(get_current_user),
) -> TenantContext:
"""
Get the current tenant context.
This dependency ensures all queries are scoped to the current tenant
and sets up the tenant context for the request.
Usage:
@router.get("/jobs")
async def list_jobs(
ctx: TenantContext = Depends(get_tenant_context)
):
# All queries will automatically be scoped to tenant
...
"""
return TenantContext(user)
# =============================================================================
# Permission-guarded endpoints helpers
# =============================================================================
# Job permissions
def can_create_job(user: AuthenticatedUser = Depends(get_current_user)) -> AuthenticatedUser:
"""Require permission to create jobs."""
if not user.has_permission(Permission.CREATE_JOB):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Requires create_job permission"
)
return user
def can_view_job(user: AuthenticatedUser = Depends(get_current_user)) -> AuthenticatedUser:
"""Require permission to view jobs."""
if not user.has_permission(Permission.VIEW_JOB):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Requires view_job permission"
)
return user
def can_cancel_job(user: AuthenticatedUser = Depends(get_current_user)) -> AuthenticatedUser:
"""Require permission to cancel jobs."""
if not user.has_permission(Permission.CANCEL_JOB):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Requires cancel_job permission"
)
return user
def can_delete_job(user: AuthenticatedUser = Depends(get_current_user)) -> AuthenticatedUser:
"""Require permission to delete jobs."""
if not user.has_permission(Permission.DELETE_JOB):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Requires delete_job permission"
)
return user
# Report permissions
def can_export_report(user: AuthenticatedUser = Depends(get_current_user)) -> AuthenticatedUser:
"""Require permission to export reports."""
if not user.has_permission(Permission.EXPORT_REPORT):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Requires export_report permission"
)
return user
# API Key permissions
def can_manage_api_keys(user: AuthenticatedUser = Depends(get_current_user)) -> AuthenticatedUser:
"""Require permission to manage API keys."""
if not user.has_permission(Permission.MANAGE_API_KEYS):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Requires manage_api_keys permission"
)
return user
# Monitoring permissions
def can_view_monitoring(user: AuthenticatedUser = Depends(get_current_user)) -> AuthenticatedUser:
"""Require permission to view monitoring."""
if not user.has_permission(Permission.VIEW_MONITORING):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Requires view_monitoring permission"
)
return user
# User management permissions
def can_manage_users(user: AuthenticatedUser = Depends(get_current_user)) -> AuthenticatedUser:
"""Require permission to manage users."""
if not user.has_permission(Permission.MANAGE_USERS):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Requires manage_users permission"
)
return user
# Release permissions
def can_approve_release(user: AuthenticatedUser = Depends(get_current_user)) -> AuthenticatedUser:
"""Require permission to approve releases."""
if not user.has_permission(Permission.APPROVE_RELEASE):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Requires approve_release permission"
)
return user
__all__ = [
"AuthenticatedUser",
"TenantContext",
"get_current_user",
"get_current_user_optional",
"get_tenant_context",
"require_authenticated_user",
"require_role",
"require_permission",
"require_tenant_access",
# Permission helpers
"can_create_job",
"can_view_job",
"can_cancel_job",
"can_delete_job",
"can_export_report",
"can_manage_api_keys",
"can_view_monitoring",
"can_manage_users",
"can_approve_release",
]