feat(auth): implement Keycloak authentication with RBAC and pagination
Major changes: - Add Keycloak integration via token introspection endpoint - Implement RBAC system with roles: admin, user, guest - Add role-based permissions for post operations - Add pagination support (default limit: 10) to list endpoints - Add published_only filter with admin-only override for unpublished posts Security improvements: - Remove hardcoded default secrets (SECRET_KEY, KEYCLOAK_CLIENT_SECRET) - Update .env.example with proper security placeholders - Add comprehensive RBAC unit tests Infrastructure: - Add httpx dependency for HTTP client - Add KeycloakAuthClient with token caching (TTL: 60s) - Add role-based dependencies (RequireAdmin, RequireUser, etc.) - Update DI container with Keycloak provider Endpoints updated: - GET /posts: filter by published status (admin can see all) - Add pagination params (limit, offset) to list endpoints - Enforce RBAC on post operations Tests: - Add 16 auth infrastructure tests - Add 13 RBAC role tests - Update existing tests for new required settings Breaking changes: - SECRET_KEY and KEYCLOAK_CLIENT_SECRET now required (no defaults)
This commit is contained in:
102
app/domain/roles.py
Normal file
102
app/domain/roles.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Role-based access control definitions."""
|
||||
|
||||
from enum import Enum
|
||||
from functools import wraps
|
||||
from typing import Any, Callable
|
||||
|
||||
from app.domain.exceptions import ForbiddenException
|
||||
|
||||
|
||||
class Role(str, Enum):
|
||||
"""User roles in the system."""
|
||||
|
||||
ADMIN = "admin"
|
||||
USER = "user"
|
||||
GUEST = "guest"
|
||||
|
||||
|
||||
class Permission:
|
||||
"""Permission definitions."""
|
||||
|
||||
# Post permissions
|
||||
POST_CREATE = "post:create"
|
||||
POST_READ = "post:read"
|
||||
POST_READ_UNPUBLISHED = "post:read_unpublished"
|
||||
POST_UPDATE = "post:update"
|
||||
POST_DELETE = "post:delete"
|
||||
POST_PUBLISH = "post:publish"
|
||||
|
||||
|
||||
# Role-based permission mapping
|
||||
ROLE_PERMISSIONS: dict[Role, list[str]] = {
|
||||
Role.ADMIN: [
|
||||
Permission.POST_CREATE,
|
||||
Permission.POST_READ,
|
||||
Permission.POST_READ_UNPUBLISHED,
|
||||
Permission.POST_UPDATE,
|
||||
Permission.POST_DELETE,
|
||||
Permission.POST_PUBLISH,
|
||||
],
|
||||
Role.USER: [
|
||||
Permission.POST_CREATE,
|
||||
Permission.POST_READ,
|
||||
Permission.POST_UPDATE,
|
||||
Permission.POST_DELETE,
|
||||
Permission.POST_PUBLISH,
|
||||
],
|
||||
Role.GUEST: [
|
||||
Permission.POST_READ,
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def has_permission(role: Role, permission: str) -> bool:
|
||||
"""Check if role has specific permission."""
|
||||
return permission in ROLE_PERMISSIONS.get(role, [])
|
||||
|
||||
|
||||
def require_permission(
|
||||
permission: str,
|
||||
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||
"""Decorator to require specific permission."""
|
||||
|
||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
# Get token_info from kwargs
|
||||
token_info = kwargs.get("token_info")
|
||||
if not token_info:
|
||||
raise ForbiddenException("Authentication required")
|
||||
|
||||
# Determine role from token or default to guest
|
||||
roles = getattr(token_info, "roles", [])
|
||||
if Role.ADMIN.value in roles:
|
||||
role = Role.ADMIN
|
||||
elif Role.USER.value in roles:
|
||||
role = Role.USER
|
||||
else:
|
||||
role = Role.GUEST
|
||||
|
||||
if not has_permission(role, permission):
|
||||
raise ForbiddenException(
|
||||
f"Permission '{permission}' required for role '{role.value}'"
|
||||
)
|
||||
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_effective_role(roles: list[str]) -> Role:
|
||||
"""Determine effective role from list of roles.
|
||||
|
||||
Priority: admin > user > guest
|
||||
"""
|
||||
if Role.ADMIN.value in roles:
|
||||
return Role.ADMIN
|
||||
elif Role.USER.value in roles:
|
||||
return Role.USER
|
||||
else:
|
||||
return Role.GUEST
|
||||
Reference in New Issue
Block a user