From 2f97f457951ef10bb2eff20fdcf64bf60d4868ae Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 30 Mar 2026 03:25:53 +0800 Subject: [PATCH] feat(admin): add edit flows for all catalogs and member authentik sync --- app/api/admin_catalog.py | 191 +++++++++++++++++++++ app/repositories/companies_repo.py | 9 + app/repositories/modules_repo.py | 9 + app/repositories/permission_groups_repo.py | 9 + app/repositories/sites_repo.py | 18 ++ app/repositories/systems_repo.py | 9 + app/schemas/catalog.py | 41 +++++ 7 files changed, 286 insertions(+) diff --git a/app/api/admin_catalog.py b/app/api/admin_catalog.py index 7eb71e1..19fb4eb 100644 --- a/app/api/admin_catalog.py +++ b/app/api/admin_catalog.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session +from app.core.config import get_settings from app.db.session import get_db from app.models.api_client import ApiClient from app.repositories.companies_repo import CompaniesRepository @@ -12,18 +13,26 @@ from app.repositories.users_repo import UsersRepository from app.schemas.catalog import ( CompanyCreateRequest, CompanyItem, + CompanyUpdateRequest, MemberItem, + MemberUpdateRequest, + MemberUpsertRequest, ModuleCreateRequest, ModuleItem, + ModuleUpdateRequest, PermissionGroupCreateRequest, PermissionGroupItem, + PermissionGroupUpdateRequest, SiteCreateRequest, SiteItem, + SiteUpdateRequest, SystemCreateRequest, SystemItem, + SystemUpdateRequest, ) from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest from app.security.api_client_auth import require_api_client +from app.services.authentik_admin_service import AuthentikAdminService router = APIRouter(prefix="/admin", tags=["admin"]) @@ -57,6 +66,26 @@ def _resolve_scope_ids(db: Session, scope_type: str, scope_id: str) -> tuple[str raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_scope_type") +def _sync_member_to_authentik( + *, + authentik_sub: str, + email: str | None, + display_name: str | None, + is_active: bool, +) -> dict[str, str | int]: + if not email: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="email_required_for_authentik_sync") + settings = get_settings() + service = AuthentikAdminService(settings=settings) + result = service.ensure_user( + sub=authentik_sub, + email=email, + display_name=display_name, + is_active=is_active, + ) + return {"authentik_user_id": result.user_id, "sync_action": result.action} + + @router.get("/systems") def list_systems( _: ApiClient = Depends(require_api_client), @@ -82,6 +111,21 @@ def create_system( return SystemItem(id=row.id, system_key=row.system_key, name=row.name, status=row.status) +@router.patch("/systems/{system_key}", response_model=SystemItem) +def update_system( + system_key: str, + payload: SystemUpdateRequest, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> SystemItem: + repo = SystemsRepository(db) + row = repo.get_by_key(system_key) + if not row: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found") + row = repo.update(row, name=payload.name, status=payload.status) + return SystemItem(id=row.id, system_key=row.system_key, name=row.name, status=row.status) + + @router.get("/modules") def list_modules( _: ApiClient = Depends(require_api_client), @@ -128,6 +172,22 @@ def create_module( return ModuleItem(id=row.id, system_key=payload.system_key, module_key=row.module_key, name=row.name, status=row.status) +@router.patch("/modules/{module_key}") +def update_module( + module_key: str, + payload: ModuleUpdateRequest, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> ModuleItem: + modules_repo = ModulesRepository(db) + row = modules_repo.get_by_key(module_key) + if not row: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="module_not_found") + row = modules_repo.update(row, name=payload.name, status=payload.status) + system_key = row.module_key.split(".", 1)[0] if "." in row.module_key else None + return ModuleItem(id=row.id, system_key=system_key, module_key=row.module_key, name=row.name, status=row.status) + + @router.get("/companies") def list_companies( _: ApiClient = Depends(require_api_client), @@ -154,6 +214,21 @@ def create_company( return CompanyItem(id=row.id, company_key=row.company_key, name=row.name, status=row.status) +@router.patch("/companies/{company_key}", response_model=CompanyItem) +def update_company( + company_key: str, + payload: CompanyUpdateRequest, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> CompanyItem: + repo = CompaniesRepository(db) + row = repo.get_by_key(company_key) + if not row: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found") + row = repo.update(row, name=payload.name, status=payload.status) + return CompanyItem(id=row.id, company_key=row.company_key, name=row.name, status=row.status) + + @router.get("/sites") def list_sites( _: ApiClient = Depends(require_api_client), @@ -210,6 +285,33 @@ def create_site( return SiteItem(id=row.id, site_key=row.site_key, company_key=payload.company_key, name=row.name, status=row.status) +@router.patch("/sites/{site_key}", response_model=SiteItem) +def update_site( + site_key: str, + payload: SiteUpdateRequest, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> SiteItem: + companies_repo = CompaniesRepository(db) + sites_repo = SitesRepository(db) + row = sites_repo.get_by_key(site_key) + if not row: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found") + company_id = None + company_key = None + if payload.company_key is not None: + company = companies_repo.get_by_key(payload.company_key) + if not company: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found") + company_id = company.id + company_key = company.company_key + row = sites_repo.update(row, company_id=company_id, name=payload.name, status=payload.status) + if company_key is None: + current_company = companies_repo.get_by_id(row.company_id) + company_key = current_company.company_key if current_company else "" + return SiteItem(id=row.id, site_key=row.site_key, company_key=company_key, name=row.name, status=row.status) + + @router.get("/members") def list_members( _: ApiClient = Depends(require_api_client), @@ -223,6 +325,80 @@ def list_members( return {"items": [MemberItem(id=i.id, authentik_sub=i.authentik_sub, email=i.email, display_name=i.display_name, is_active=i.is_active).model_dump() for i in items], "total": total, "limit": limit, "offset": offset} +@router.post("/members/upsert", response_model=MemberItem) +def upsert_member( + payload: MemberUpsertRequest, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> MemberItem: + users_repo = UsersRepository(db) + authentik_user_id = None + if payload.sync_to_authentik: + sync = _sync_member_to_authentik( + authentik_sub=payload.authentik_sub, + email=payload.email, + display_name=payload.display_name, + is_active=payload.is_active, + ) + authentik_user_id = int(sync["authentik_user_id"]) + row = users_repo.upsert_by_sub( + authentik_sub=payload.authentik_sub, + email=payload.email, + display_name=payload.display_name, + is_active=payload.is_active, + authentik_user_id=authentik_user_id, + ) + return MemberItem( + id=row.id, + authentik_sub=row.authentik_sub, + email=row.email, + display_name=row.display_name, + is_active=row.is_active, + ) + + +@router.patch("/members/{authentik_sub}", response_model=MemberItem) +def update_member( + authentik_sub: str, + payload: MemberUpdateRequest, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> MemberItem: + users_repo = UsersRepository(db) + row = users_repo.get_by_sub(authentik_sub) + if not row: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found") + + next_email = payload.email if payload.email is not None else row.email + next_display_name = payload.display_name if payload.display_name is not None else row.display_name + next_is_active = payload.is_active if payload.is_active is not None else row.is_active + + authentik_user_id = row.authentik_user_id + if payload.sync_to_authentik: + sync = _sync_member_to_authentik( + authentik_sub=row.authentik_sub, + email=next_email, + display_name=next_display_name, + is_active=next_is_active, + ) + authentik_user_id = int(sync["authentik_user_id"]) + + row = users_repo.upsert_by_sub( + authentik_sub=row.authentik_sub, + email=next_email, + display_name=next_display_name, + is_active=next_is_active, + authentik_user_id=authentik_user_id, + ) + return MemberItem( + id=row.id, + authentik_sub=row.authentik_sub, + email=row.email, + display_name=row.display_name, + is_active=row.is_active, + ) + + @router.get("/permission-groups") def list_permission_groups( _: ApiClient = Depends(require_api_client), @@ -248,6 +424,21 @@ def create_permission_group( return PermissionGroupItem(id=row.id, group_key=row.group_key, name=row.name, status=row.status) +@router.patch("/permission-groups/{group_key}", response_model=PermissionGroupItem) +def update_permission_group( + group_key: str, + payload: PermissionGroupUpdateRequest, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> PermissionGroupItem: + repo = PermissionGroupsRepository(db) + row = repo.get_by_key(group_key) + if not row: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found") + row = repo.update(row, name=payload.name, status=payload.status) + return PermissionGroupItem(id=row.id, group_key=row.group_key, name=row.name, status=row.status) + + @router.post("/permission-groups/{group_key}/members/{authentik_sub}") def add_group_member( group_key: str, diff --git a/app/repositories/companies_repo.py b/app/repositories/companies_repo.py index 47628f2..184ff21 100644 --- a/app/repositories/companies_repo.py +++ b/app/repositories/companies_repo.py @@ -33,3 +33,12 @@ class CompaniesRepository: self.db.commit() self.db.refresh(item) return item + + def update(self, item: Company, *, name: str | None = None, status: str | None = None) -> Company: + if name is not None: + item.name = name + if status is not None: + item.status = status + self.db.commit() + self.db.refresh(item) + return item diff --git a/app/repositories/modules_repo.py b/app/repositories/modules_repo.py index b457266..2c5e486 100644 --- a/app/repositories/modules_repo.py +++ b/app/repositories/modules_repo.py @@ -24,3 +24,12 @@ class ModulesRepository: self.db.commit() self.db.refresh(item) return item + + def update(self, item: Module, *, name: str | None = None, status: str | None = None) -> Module: + if name is not None: + item.name = name + if status is not None: + item.status = status + self.db.commit() + self.db.refresh(item) + return item diff --git a/app/repositories/permission_groups_repo.py b/app/repositories/permission_groups_repo.py index a1fa6e4..b67dd8c 100644 --- a/app/repositories/permission_groups_repo.py +++ b/app/repositories/permission_groups_repo.py @@ -28,6 +28,15 @@ class PermissionGroupsRepository: self.db.refresh(item) return item + def update(self, item: PermissionGroup, *, name: str | None = None, status: str | None = None) -> PermissionGroup: + if name is not None: + item.name = name + if status is not None: + item.status = status + self.db.commit() + self.db.refresh(item) + return item + def add_member_if_not_exists(self, group_id: str, authentik_sub: str) -> PermissionGroupMember: existing = self.db.scalar( select(PermissionGroupMember).where( diff --git a/app/repositories/sites_repo.py b/app/repositories/sites_repo.py index d3e58c4..f17cc5b 100644 --- a/app/repositories/sites_repo.py +++ b/app/repositories/sites_repo.py @@ -38,3 +38,21 @@ class SitesRepository: self.db.commit() self.db.refresh(item) return item + + def update( + self, + item: Site, + *, + company_id: str | None = None, + name: str | None = None, + status: str | None = None, + ) -> Site: + if company_id is not None: + item.company_id = company_id + if name is not None: + item.name = name + if status is not None: + item.status = status + self.db.commit() + self.db.refresh(item) + return item diff --git a/app/repositories/systems_repo.py b/app/repositories/systems_repo.py index 0464fea..d265822 100644 --- a/app/repositories/systems_repo.py +++ b/app/repositories/systems_repo.py @@ -31,3 +31,12 @@ class SystemsRepository: self.db.commit() self.db.refresh(item) return item + + def update(self, item: System, *, name: str | None = None, status: str | None = None) -> System: + if name is not None: + item.name = name + if status is not None: + item.status = status + self.db.commit() + self.db.refresh(item) + return item diff --git a/app/schemas/catalog.py b/app/schemas/catalog.py index 90c0d98..637f8e8 100644 --- a/app/schemas/catalog.py +++ b/app/schemas/catalog.py @@ -7,6 +7,11 @@ class SystemCreateRequest(BaseModel): status: str = "active" +class SystemUpdateRequest(BaseModel): + name: str | None = None + status: str | None = None + + class SystemItem(BaseModel): id: str system_key: str @@ -21,6 +26,11 @@ class ModuleCreateRequest(BaseModel): status: str = "active" +class ModuleUpdateRequest(BaseModel): + name: str | None = None + status: str | None = None + + class ModuleItem(BaseModel): id: str system_key: str | None = None @@ -35,6 +45,11 @@ class CompanyCreateRequest(BaseModel): status: str = "active" +class CompanyUpdateRequest(BaseModel): + name: str | None = None + status: str | None = None + + class CompanyItem(BaseModel): id: str company_key: str @@ -49,6 +64,12 @@ class SiteCreateRequest(BaseModel): status: str = "active" +class SiteUpdateRequest(BaseModel): + company_key: str | None = None + name: str | None = None + status: str | None = None + + class SiteItem(BaseModel): id: str site_key: str @@ -65,6 +86,21 @@ class MemberItem(BaseModel): is_active: bool +class MemberUpsertRequest(BaseModel): + authentik_sub: str + email: str | None = None + display_name: str | None = None + is_active: bool = True + sync_to_authentik: bool = True + + +class MemberUpdateRequest(BaseModel): + email: str | None = None + display_name: str | None = None + is_active: bool | None = None + sync_to_authentik: bool = True + + class ListResponse(BaseModel): items: list total: int @@ -78,6 +114,11 @@ class PermissionGroupCreateRequest(BaseModel): status: str = "active" +class PermissionGroupUpdateRequest(BaseModel): + name: str | None = None + status: str | None = None + + class PermissionGroupItem(BaseModel): id: str group_key: str