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
@@ -3,8 +3,9 @@ Authentication Controller
|
||||
|
||||
Provides authentication endpoints for OIDC token sync and user management.
|
||||
"""
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -13,7 +14,8 @@ from src.shared.logging import get_logger
|
||||
from src.shared.database import get_async_session
|
||||
from src.domains.auth.schemas import (
|
||||
AuthSyncRequest, AuthSyncResponse, UsersListResponse,
|
||||
BulkSyncResultSchema, GroupsListResponse
|
||||
BulkSyncResultSchema, GroupsListResponse, RolesListResponse,
|
||||
GroupRoleAssignmentResponse,
|
||||
)
|
||||
from src.domains.auth.service import AuthService
|
||||
|
||||
@@ -218,6 +220,94 @@ class AuthController(BaseController):
|
||||
logger.error(f"Groups bulk sync failed: {e}")
|
||||
raise HTTPException(status_code=401, detail=str(e))
|
||||
|
||||
@router.get(
|
||||
"/roles",
|
||||
summary="List all roles",
|
||||
response_model=RolesListResponse,
|
||||
responses={
|
||||
200: {"description": "List of all available roles"},
|
||||
},
|
||||
)
|
||||
async def list_roles(
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> RolesListResponse:
|
||||
"""
|
||||
List all available roles in the system
|
||||
|
||||
Returns all domain.category:action role combinations.
|
||||
Use these when assigning roles to groups.
|
||||
"""
|
||||
service = AuthService(session)
|
||||
roles = await service.list_roles()
|
||||
return RolesListResponse(
|
||||
items=service.roles_to_schema(roles),
|
||||
total=len(roles),
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/groups/{group_id}/roles/{role_id}",
|
||||
summary="Assign role to group",
|
||||
response_model=GroupRoleAssignmentResponse,
|
||||
responses={
|
||||
200: {"description": "Role assigned successfully"},
|
||||
404: {"description": "Group or role not found"},
|
||||
},
|
||||
)
|
||||
async def assign_role_to_group(
|
||||
group_id: uuid.UUID = Path(..., description="Group ID"),
|
||||
role_id: uuid.UUID = Path(..., description="Role ID to assign"),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> GroupRoleAssignmentResponse:
|
||||
"""
|
||||
Assign a role to a group
|
||||
|
||||
All users in this group will inherit this role's permissions.
|
||||
"""
|
||||
service = AuthService(session)
|
||||
|
||||
try:
|
||||
group = await service.assign_role_to_group(group_id, role_id)
|
||||
await session.commit()
|
||||
return GroupRoleAssignmentResponse(
|
||||
group_id=group.id,
|
||||
group_name=group.name,
|
||||
roles=[role.name for role in group.roles],
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
@router.delete(
|
||||
"/groups/{group_id}/roles/{role_id}",
|
||||
summary="Remove role from group",
|
||||
response_model=GroupRoleAssignmentResponse,
|
||||
responses={
|
||||
200: {"description": "Role removed successfully"},
|
||||
404: {"description": "Group or role not found"},
|
||||
},
|
||||
)
|
||||
async def remove_role_from_group(
|
||||
group_id: uuid.UUID = Path(..., description="Group ID"),
|
||||
role_id: uuid.UUID = Path(..., description="Role ID to remove"),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> GroupRoleAssignmentResponse:
|
||||
"""
|
||||
Remove a role from a group
|
||||
|
||||
Users in this group will no longer inherit this role's permissions.
|
||||
"""
|
||||
service = AuthService(session)
|
||||
|
||||
try:
|
||||
group = await service.remove_role_from_group(group_id, role_id)
|
||||
await session.commit()
|
||||
return GroupRoleAssignmentResponse(
|
||||
group_id=group.id,
|
||||
group_name=group.name,
|
||||
roles=[role.name for role in group.roles],
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
@router.get(
|
||||
"/me",
|
||||
summary="Get current user profile",
|
||||
|
||||
Reference in New Issue
Block a user