From e85c9a123d3dd311d93f1b080b5347bcf2832893 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 1 Jan 2026 22:28:06 +0100 Subject: [PATCH] feat: add groups management API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /auth/groups - list groups with search/pagination - POST /auth/groups/sync-from-authentik - bulk sync from Authentik - Group model and database migration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 9 + .../20260101_0002_002_create_groups_table.py | 40 +++++ pyproject.toml | 2 +- src/auth/controller.py | 65 ++++++- src/auth/schemas.py | 19 +++ src/auth/service.py | 158 +++++++++++++++++- src/db/models/__init__.py | 2 + src/db/models/group.py | 85 ++++++++++ 8 files changed, 376 insertions(+), 4 deletions(-) create mode 100644 alembic/versions/20260101_0002_002_create_groups_table.py create mode 100644 src/db/models/group.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f6e1aa..f751549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.5.0] - 2026-01-01 + +### Added + +- **Groups Management** - Authentik group synchronization + - `GET /auth/groups` - List all groups with search and pagination + - `POST /auth/groups/sync-from-authentik` - Bulk sync groups from Authentik admin API +- Database model and migration for groups table + ## [1.4.6] - 2026-01-01 ### Changed diff --git a/alembic/versions/20260101_0002_002_create_groups_table.py b/alembic/versions/20260101_0002_002_create_groups_table.py new file mode 100644 index 0000000..f7f103f --- /dev/null +++ b/alembic/versions/20260101_0002_002_create_groups_table.py @@ -0,0 +1,40 @@ +"""Create groups table + +Revision ID: 002 +Revises: 001 +Create Date: 2026-01-01 + +Creates the groups table for syncing Authentik groups. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "002" +down_revision: Union[str, None] = "001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Groups table + op.create_table( + "groups", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("authentik_id", postgresql.UUID(as_uuid=True), nullable=False, comment="Authentik group UUID"), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("is_superuser", sa.Boolean(), nullable=False, server_default="false", comment="Whether members have superuser privileges"), + sa.Column("parent_name", sa.String(255), nullable=True, comment="Parent group name for hierarchy"), + sa.Column("member_count", sa.Integer(), nullable=False, server_default="0", comment="Number of users in this group"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("synced_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, comment="Last sync from Authentik"), + ) + op.create_index("ix_groups_authentik_id", "groups", ["authentik_id"], unique=True) + op.create_index("ix_groups_name", "groups", ["name"], unique=True) + + +def downgrade() -> None: + op.drop_table("groups") diff --git a/pyproject.toml b/pyproject.toml index 198304d..4f98038 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "core-api" -version = "1.4.6" +version = "1.5.0" description = "Core Code API - Infrastructure management and tools API" readme = "README.md" requires-python = ">=3.12" diff --git a/src/auth/controller.py b/src/auth/controller.py index 26497ac..88d34ce 100644 --- a/src/auth/controller.py +++ b/src/auth/controller.py @@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.controllers.base import BaseController from src.logging_config import get_logger from src.db import get_async_session -from src.auth.schemas import AuthSyncRequest, AuthSyncResponse, UsersListResponse, BulkSyncResultSchema +from src.auth.schemas import AuthSyncRequest, AuthSyncResponse, UsersListResponse, BulkSyncResultSchema, GroupsListResponse from src.auth.service import AuthService logger = get_logger(__name__) @@ -152,6 +152,69 @@ class AuthController(BaseController): logger.error(f"Bulk sync failed: {e}") raise HTTPException(status_code=401, detail=str(e)) + @router.get( + "/groups", + summary="List all groups", + response_model=GroupsListResponse, + responses={ + 200: {"description": "List of groups"}, + }, + ) + async def list_groups( + search: Optional[str] = Query(None, description="Search by group name"), + offset: int = Query(0, ge=0, description="Number of records to skip"), + limit: int = Query(50, ge=1, le=100, description="Maximum records to return"), + session: AsyncSession = Depends(get_async_session), + ) -> GroupsListResponse: + """ + List all groups synced from Authentik + + Returns paginated list of groups with their details. + Supports search filtering by name. + """ + service = AuthService(session) + items, total = await service.list_groups( + search=search, + offset=offset, + limit=limit, + ) + return GroupsListResponse(items=items, total=total) + + @router.post( + "/groups/sync-from-authentik", + summary="Bulk sync groups from Authentik", + response_model=BulkSyncResultSchema, + responses={ + 200: {"description": "Sync completed"}, + 401: {"description": "Authentik API credentials invalid"}, + 503: {"description": "Authentik service unavailable"}, + }, + ) + async def sync_groups_from_authentik( + session: AsyncSession = Depends(get_async_session), + ) -> BulkSyncResultSchema: + """ + Fetch all groups from Authentik and sync to local database + + This endpoint uses the Authentik admin API to fetch all groups + and create/update them in the local database. Requires + AUTHENTIK_USERNAME and AUTHENTIK_PASSWORD to be configured. + + Use this to populate groups or to re-sync after changes in Authentik. + """ + service = AuthService(session) + + try: + result = await service.bulk_sync_groups_from_authentik() + logger.info( + f"Groups bulk sync completed: {result.created} created, " + f"{result.updated} updated, {result.failed} failed" + ) + return result + except ValueError as e: + logger.error(f"Groups bulk sync failed: {e}") + raise HTTPException(status_code=401, detail=str(e)) + @router.get( "/me", summary="Get current user profile", diff --git a/src/auth/schemas.py b/src/auth/schemas.py index 05c0998..ce1bd6b 100644 --- a/src/auth/schemas.py +++ b/src/auth/schemas.py @@ -108,3 +108,22 @@ class BulkSyncResultSchema(BaseSchema): failed: int = Field(..., description="Number of users that failed to sync") total_in_authentik: int = Field(..., description="Total users in Authentik") errors: list[str] = Field(default_factory=list, description="Error messages for failed syncs") + + +class GroupListItemSchema(BaseSchema): + """Group item for list display""" + + id: uuid.UUID = Field(..., description="Internal group ID") + authentik_id: uuid.UUID = Field(..., description="Authentik group ID") + name: str = Field(..., description="Group name") + is_superuser: bool = Field(default=False, description="Whether group has superuser privileges") + parent_name: Optional[str] = Field(None, description="Parent group name") + member_count: int = Field(default=0, description="Number of users in this group") + synced_at: datetime = Field(..., description="Last sync timestamp") + + +class GroupsListResponse(BaseSchema): + """Response from GET /auth/groups""" + + items: list[GroupListItemSchema] = Field(..., description="List of groups") + total: int = Field(..., description="Total count of groups") diff --git a/src/auth/service.py b/src/auth/service.py index b738f29..c930bf7 100644 --- a/src/auth/service.py +++ b/src/auth/service.py @@ -15,8 +15,8 @@ from sqlalchemy.orm import selectinload from src.config import get_settings from src.logging_config import get_logger -from src.db.models import User, Role, UserPreferences -from src.auth.schemas import TokenInfoSchema, UserSchema, RoleSchema, UserPreferencesSchema, UserListItemSchema, BulkSyncResultSchema +from src.db.models import User, Role, UserPreferences, Group +from src.auth.schemas import TokenInfoSchema, UserSchema, RoleSchema, UserPreferencesSchema, UserListItemSchema, BulkSyncResultSchema, GroupListItemSchema logger = get_logger(__name__) settings = get_settings() @@ -470,6 +470,160 @@ class AuthService: errors=errors, ) + async def list_groups( + self, + search: Optional[str] = None, + offset: int = 0, + limit: int = 50, + ) -> tuple[list[GroupListItemSchema], int]: + """ + List all groups with optional search and pagination + + Args: + search: Optional search query (matches name) + offset: Number of records to skip + limit: Maximum number of records to return + + Returns: + Tuple of (list of group schemas, total count) + """ + from sqlalchemy import func + + # Base query + base_query = select(Group) + + # Apply search filter if provided + if search: + search_filter = f"%{search}%" + base_query = base_query.where(Group.name.ilike(search_filter)) + + # Get total count + count_query = select(func.count()).select_from(base_query.subquery()) + total_result = await self.session.execute(count_query) + total = total_result.scalar() or 0 + + # Apply pagination and ordering + query = base_query.order_by(Group.name).offset(offset).limit(limit) + result = await self.session.execute(query) + groups = list(result.scalars().all()) + + # Convert to schemas + items = [ + GroupListItemSchema( + id=group.id, + authentik_id=group.authentik_id, + name=group.name, + is_superuser=group.is_superuser, + parent_name=group.parent_name, + member_count=group.member_count, + synced_at=group.synced_at, + ) + for group in groups + ] + + return items, total + + async def bulk_sync_groups_from_authentik(self) -> BulkSyncResultSchema: + """ + Fetch all groups from Authentik admin API and sync to local database + + Returns: + BulkSyncResultSchema with counts of created/updated/failed groups + """ + if not settings.authentik_username or not settings.authentik_password: + raise ValueError("AUTHENTIK_USERNAME and AUTHENTIK_PASSWORD must be configured") + + created = 0 + updated = 0 + failed = 0 + errors = [] + total_in_authentik = 0 + + # Step 1: Fetch all group data from Authentik API + authentik_groups = [] + try: + async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: + # Authenticate with Authentik to get session cookie + session_cookie = await self._authentik_session_login(client) + + # Fetch groups from Authentik admin API using session cookie + response = await client.get( + f"{settings.authentik_url}/api/v3/core/groups/", + params={"page_size": 500}, + headers={ + "Accept": "application/json", + "Cookie": f"authentik_session={session_cookie}", + }, + ) + + if response.status_code == 401: + raise ValueError("Authentik API token is invalid or expired") + + response.raise_for_status() + data = response.json() + + authentik_groups = data.get("results", []) + total_in_authentik = data.get("pagination", {}).get("count", len(authentik_groups)) + + except httpx.HTTPStatusError as e: + raise ValueError(f"Authentik API error: {e.response.status_code}") + except httpx.RequestError as e: + raise ValueError(f"Failed to connect to Authentik: {str(e)}") + + # Step 2: Sync groups to database (outside of httpx context to avoid greenlet issues) + for auth_group in authentik_groups: + try: + # Extract group data from Authentik + authentik_id = uuid.UUID(auth_group["pk"]) + name = auth_group.get("name", "Unknown") + is_superuser = auth_group.get("is_superuser", False) + parent_name = auth_group.get("parent_name") + # users field contains list of user PKs + member_count = len(auth_group.get("users", [])) + + # Check if group exists + stmt = select(Group).where(Group.authentik_id == authentik_id) + result = await self.session.execute(stmt) + group = result.scalar_one_or_none() + + if group is None: + # Create new group + group = Group( + authentik_id=authentik_id, + name=name, + is_superuser=is_superuser, + parent_name=parent_name, + member_count=member_count, + ) + self.session.add(group) + created += 1 + logger.info(f"Created group from Authentik: {name}") + else: + # Update existing group + group.name = name + group.is_superuser = is_superuser + group.parent_name = parent_name + group.member_count = member_count + updated += 1 + logger.info(f"Updated group from Authentik: {name}") + + except Exception as e: + failed += 1 + error_msg = f"Failed to sync group {auth_group.get('name', 'unknown')}: {str(e)}" + errors.append(error_msg) + logger.warning(error_msg) + + # Commit all changes + await self.session.commit() + + return BulkSyncResultSchema( + created=created, + updated=updated, + failed=failed, + total_in_authentik=total_in_authentik, + errors=errors, + ) + # Factory function for dependency injection def get_auth_service(session: AsyncSession) -> AuthService: diff --git a/src/db/models/__init__.py b/src/db/models/__init__.py index 14c244c..7ab9d60 100644 --- a/src/db/models/__init__.py +++ b/src/db/models/__init__.py @@ -7,6 +7,7 @@ from src.db.models.user import User from src.db.models.role import Role, UserRole from src.db.models.user_preferences import UserPreferences from src.db.models.api_key import ApiKey +from src.db.models.group import Group __all__ = [ "User", @@ -14,4 +15,5 @@ __all__ = [ "UserRole", "UserPreferences", "ApiKey", + "Group", ] diff --git a/src/db/models/group.py b/src/db/models/group.py new file mode 100644 index 0000000..53d7228 --- /dev/null +++ b/src/db/models/group.py @@ -0,0 +1,85 @@ +""" +Group Model + +Represents groups synced from Authentik. +Groups are used for access control and user organization. +""" +import uuid +from datetime import datetime +from typing import TYPE_CHECKING, List + +from sqlalchemy import String, Boolean, DateTime, func, Table, Column, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.database import Base + + +# Association table for User-Group many-to-many relationship +user_groups = Table( + "user_groups", + Base.metadata, + Column("user_id", UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True), + Column("group_id", UUID(as_uuid=True), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True), +) + + +class Group(Base): + """ + Group model synced from Authentik + + Groups are fetched from Authentik admin API and cached locally. + They represent organizational units for access control. + """ + + __tablename__ = "groups" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + authentik_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + unique=True, + nullable=False, + index=True, + comment="Authentik group UUID", + ) + name: Mapped[str] = mapped_column( + String(255), + unique=True, + nullable=False, + index=True, + ) + is_superuser: Mapped[bool] = mapped_column( + Boolean, + default=False, + nullable=False, + comment="Whether members of this group have superuser privileges", + ) + parent_name: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + comment="Parent group name for hierarchy", + ) + member_count: Mapped[int] = mapped_column( + default=0, + nullable=False, + comment="Number of users in this group", + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + synced_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + comment="Last sync from Authentik", + ) + + def __repr__(self) -> str: + return f""