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
+119
-12
@@ -141,30 +141,43 @@ class AuthService:
|
||||
await self.session.flush()
|
||||
return user, is_new
|
||||
|
||||
async def sync_roles(self, user: User, groups: list[str]) -> list[Role]:
|
||||
async def sync_roles(self, user: User, group_names: list[str]) -> list[Role]:
|
||||
"""
|
||||
Synchronize user roles from Authentik groups
|
||||
Synchronize user roles from Authentik groups via group_roles mapping
|
||||
|
||||
Maps Authentik groups (e.g., 'tatlock-control-room-admin')
|
||||
to application roles (e.g., 'control-room:admin').
|
||||
Looks up the user's groups in the database, then retrieves all roles
|
||||
assigned to those groups via the group_roles mapping table.
|
||||
|
||||
Args:
|
||||
user: User to sync roles for
|
||||
groups: List of Authentik group names
|
||||
group_names: List of Authentik group names
|
||||
|
||||
Returns:
|
||||
List of synced Role objects
|
||||
"""
|
||||
# Get all roles that match the user's Authentik groups
|
||||
stmt = select(Role).where(Role.authentik_group.in_(groups))
|
||||
# Find local Group records matching the Authentik group names
|
||||
stmt = (
|
||||
select(Group)
|
||||
.options(selectinload(Group.roles))
|
||||
.where(Group.name.in_(group_names))
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
matching_roles = list(result.scalars().all())
|
||||
matching_groups = list(result.scalars().all())
|
||||
|
||||
# Collect all unique roles from all matching groups
|
||||
roles_set: dict[uuid.UUID, Role] = {}
|
||||
for group in matching_groups:
|
||||
for role in group.roles:
|
||||
roles_set[role.id] = role
|
||||
|
||||
matching_roles = list(roles_set.values())
|
||||
|
||||
# Clear existing roles and set new ones
|
||||
user.roles = matching_roles
|
||||
|
||||
role_names = [r.name for r in matching_roles]
|
||||
logger.info(f"Synced roles for {user.email}: {role_names}")
|
||||
group_names_found = [g.name for g in matching_groups]
|
||||
logger.info(f"Synced roles for {user.email} via groups {group_names_found}: {role_names}")
|
||||
|
||||
return matching_roles
|
||||
|
||||
@@ -183,7 +196,7 @@ class AuthService:
|
||||
def roles_to_schema(self, roles: list[Role]) -> list[RoleSchema]:
|
||||
"""Convert Role models to schemas"""
|
||||
return [
|
||||
RoleSchema(name=r.name, domain=r.domain, action=r.action)
|
||||
RoleSchema(id=r.id, name=r.name, domain=r.domain, category=r.category, action=r.action)
|
||||
for r in roles
|
||||
]
|
||||
|
||||
@@ -492,8 +505,8 @@ class AuthService:
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
# Base query
|
||||
base_query = select(Group)
|
||||
# Base query with roles loaded
|
||||
base_query = select(Group).options(selectinload(Group.roles))
|
||||
|
||||
# Apply search filter if provided
|
||||
if search:
|
||||
@@ -520,12 +533,106 @@ class AuthService:
|
||||
parent_name=group.parent_name,
|
||||
member_count=group.member_count,
|
||||
synced_at=group.synced_at,
|
||||
roles=[role.name for role in group.roles],
|
||||
)
|
||||
for group in groups
|
||||
]
|
||||
|
||||
return items, total
|
||||
|
||||
async def list_roles(self) -> list[Role]:
|
||||
"""
|
||||
List all available roles
|
||||
|
||||
Returns:
|
||||
List of all Role objects
|
||||
"""
|
||||
stmt = select(Role).order_by(Role.domain, Role.category, Role.action)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_group_by_id(self, group_id: uuid.UUID) -> Optional[Group]:
|
||||
"""
|
||||
Get a group by its ID with roles loaded
|
||||
|
||||
Args:
|
||||
group_id: The group's UUID
|
||||
|
||||
Returns:
|
||||
Group object or None if not found
|
||||
"""
|
||||
stmt = (
|
||||
select(Group)
|
||||
.options(selectinload(Group.roles))
|
||||
.where(Group.id == group_id)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def assign_role_to_group(self, group_id: uuid.UUID, role_id: uuid.UUID) -> Group:
|
||||
"""
|
||||
Assign a role to a group
|
||||
|
||||
Args:
|
||||
group_id: The group's UUID
|
||||
role_id: The role's UUID to assign
|
||||
|
||||
Returns:
|
||||
Updated Group object
|
||||
|
||||
Raises:
|
||||
ValueError: If group or role not found
|
||||
"""
|
||||
group = await self.get_group_by_id(group_id)
|
||||
if not group:
|
||||
raise ValueError(f"Group not found: {group_id}")
|
||||
|
||||
stmt = select(Role).where(Role.id == role_id)
|
||||
result = await self.session.execute(stmt)
|
||||
role = result.scalar_one_or_none()
|
||||
if not role:
|
||||
raise ValueError(f"Role not found: {role_id}")
|
||||
|
||||
# Add role if not already assigned
|
||||
if role not in group.roles:
|
||||
group.roles.append(role)
|
||||
await self.session.flush()
|
||||
logger.info(f"Assigned role {role.name} to group {group.name}")
|
||||
|
||||
return group
|
||||
|
||||
async def remove_role_from_group(self, group_id: uuid.UUID, role_id: uuid.UUID) -> Group:
|
||||
"""
|
||||
Remove a role from a group
|
||||
|
||||
Args:
|
||||
group_id: The group's UUID
|
||||
role_id: The role's UUID to remove
|
||||
|
||||
Returns:
|
||||
Updated Group object
|
||||
|
||||
Raises:
|
||||
ValueError: If group or role not found
|
||||
"""
|
||||
group = await self.get_group_by_id(group_id)
|
||||
if not group:
|
||||
raise ValueError(f"Group not found: {group_id}")
|
||||
|
||||
stmt = select(Role).where(Role.id == role_id)
|
||||
result = await self.session.execute(stmt)
|
||||
role = result.scalar_one_or_none()
|
||||
if not role:
|
||||
raise ValueError(f"Role not found: {role_id}")
|
||||
|
||||
# Remove role if assigned
|
||||
if role in group.roles:
|
||||
group.roles.remove(role)
|
||||
await self.session.flush()
|
||||
logger.info(f"Removed role {role.name} from group {group.name}")
|
||||
|
||||
return group
|
||||
|
||||
async def bulk_sync_groups_from_authentik(self) -> BulkSyncResultSchema:
|
||||
"""
|
||||
Fetch all groups from Authentik admin API and sync to local database
|
||||
|
||||
Reference in New Issue
Block a user