From 35ffff1d194e72a1b09c25249b3e885f6324e328 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 30 Mar 2026 03:54:22 +0800 Subject: [PATCH] feat(flow): unify member-group-permission admin workflow and docs --- app/api/admin.py | 50 +++++++++- app/api/admin_catalog.py | 68 ++++++++++++++ app/repositories/permission_groups_repo.py | 32 +++++++ app/repositories/permissions_repo.py | 101 ++++++++++++++++++++- app/schemas/catalog.py | 18 ++++ app/schemas/permissions.py | 22 +++++ 6 files changed, 288 insertions(+), 3 deletions(-) diff --git a/app/api/admin.py b/app/api/admin.py index 5be9951..b8bdd61 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -1,4 +1,6 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from app.db.session import get_db @@ -9,7 +11,12 @@ from app.repositories.permissions_repo import PermissionsRepository from app.repositories.sites_repo import SitesRepository from app.repositories.systems_repo import SystemsRepository from app.repositories.users_repo import UsersRepository -from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest +from app.schemas.permissions import ( + DirectPermissionListResponse, + DirectPermissionRow, + PermissionGrantRequest, + PermissionRevokeRequest, +) from app.security.api_client_auth import require_api_client router = APIRouter(prefix="/admin", tags=["admin"]) @@ -98,3 +105,42 @@ def revoke_permission( site_id=site_id, ) return {"deleted": deleted, "result": "revoked"} + + +@router.get("/permissions/direct", response_model=DirectPermissionListResponse) +def list_direct_permissions( + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), + keyword: str | None = Query(default=None), + scope_type: str | None = Query(default=None), + limit: int = Query(default=200, ge=1, le=500), + offset: int = Query(default=0, ge=0), +) -> DirectPermissionListResponse: + perms_repo = PermissionsRepository(db) + items, total = perms_repo.list_direct_permissions( + keyword=keyword, + scope_type=scope_type, + limit=limit, + offset=offset, + ) + return DirectPermissionListResponse( + items=[DirectPermissionRow(**item) for item in items], + total=total, + limit=limit, + offset=offset, + ) + + +@router.delete("/permissions/direct/{permission_id}") +def delete_direct_permission( + permission_id: str, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> dict[str, int | str]: + try: + normalized_permission_id = str(UUID(permission_id)) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_permission_id") + perms_repo = PermissionsRepository(db) + deleted = perms_repo.revoke_by_permission_id(normalized_permission_id) + return {"deleted": deleted, "result": "revoked"} diff --git a/app/api/admin_catalog.py b/app/api/admin_catalog.py index 3ee0d84..818a775 100644 --- a/app/api/admin_catalog.py +++ b/app/api/admin_catalog.py @@ -15,6 +15,8 @@ from app.schemas.catalog import ( CompanyItem, CompanyUpdateRequest, MemberItem, + MemberPermissionGroupsResponse, + MemberPermissionGroupsUpdateRequest, MemberUpdateRequest, MemberUpsertRequest, ModuleCreateRequest, @@ -22,6 +24,7 @@ from app.schemas.catalog import ( ModuleUpdateRequest, PermissionGroupCreateRequest, PermissionGroupItem, + PermissionGroupPermissionItem, PermissionGroupUpdateRequest, SiteCreateRequest, SiteItem, @@ -411,6 +414,45 @@ def update_member( ) +@router.get("/members/{authentik_sub}/permission-groups", response_model=MemberPermissionGroupsResponse) +def get_member_permission_groups( + authentik_sub: str, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> MemberPermissionGroupsResponse: + users_repo = UsersRepository(db) + groups_repo = PermissionGroupsRepository(db) + user = users_repo.get_by_sub(authentik_sub) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found") + group_keys = groups_repo.list_group_keys_by_member_sub(authentik_sub) + return MemberPermissionGroupsResponse(authentik_sub=authentik_sub, group_keys=group_keys) + + +@router.put("/members/{authentik_sub}/permission-groups", response_model=MemberPermissionGroupsResponse) +def set_member_permission_groups( + authentik_sub: str, + payload: MemberPermissionGroupsUpdateRequest, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> MemberPermissionGroupsResponse: + users_repo = UsersRepository(db) + groups_repo = PermissionGroupsRepository(db) + user = users_repo.get_by_sub(authentik_sub) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found") + + unique_group_keys = list(dict.fromkeys(payload.group_keys)) + groups = groups_repo.get_by_keys(unique_group_keys) + found_keys = {g.group_key for g in groups} + missing = [k for k in unique_group_keys if k not in found_keys] + if missing: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"group_not_found:{','.join(missing)}") + + groups_repo.replace_member_groups(authentik_sub, [g.id for g in groups]) + return MemberPermissionGroupsResponse(authentik_sub=authentik_sub, group_keys=unique_group_keys) + + @router.get("/permission-groups") def list_permission_groups( _: ApiClient = Depends(require_api_client), @@ -423,6 +465,32 @@ def list_permission_groups( return {"items": [PermissionGroupItem(id=i.id, group_key=i.group_key, name=i.name, status=i.status).model_dump() for i in items], "total": total, "limit": limit, "offset": offset} +@router.get("/permission-groups/{group_key}/permissions") +def list_permission_group_permissions( + group_key: str, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> dict[str, list[dict]]: + repo = PermissionGroupsRepository(db) + group = repo.get_by_key(group_key) + if not group: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found") + rows = repo.list_group_permissions(group.id) + return { + "items": [ + PermissionGroupPermissionItem( + id=r.id, + system=r.system, + module=r.module, + action=r.action, + scope_type=r.scope_type, + scope_id=r.scope_id, + ).model_dump() + for r in rows + ] + } + + @router.post("/permission-groups", response_model=PermissionGroupItem) def create_permission_group( payload: PermissionGroupCreateRequest, diff --git a/app/repositories/permission_groups_repo.py b/app/repositories/permission_groups_repo.py index b67dd8c..ff2477f 100644 --- a/app/repositories/permission_groups_repo.py +++ b/app/repositories/permission_groups_repo.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from sqlalchemy import delete, func, select from sqlalchemy.orm import Session @@ -16,6 +18,12 @@ class PermissionGroupsRepository: def get_by_id(self, group_id: str) -> PermissionGroup | None: return self.db.scalar(select(PermissionGroup).where(PermissionGroup.id == group_id)) + def get_by_keys(self, group_keys: list[str]) -> list[PermissionGroup]: + if not group_keys: + return [] + stmt = select(PermissionGroup).where(PermissionGroup.group_key.in_(group_keys)) + return list(self.db.scalars(stmt).all()) + def list(self, limit: int = 100, offset: int = 0) -> tuple[list[PermissionGroup], int]: stmt = select(PermissionGroup).order_by(PermissionGroup.created_at.desc()).limit(limit).offset(offset) count_stmt = select(func.count()).select_from(PermissionGroup) @@ -60,6 +68,22 @@ class PermissionGroupsRepository: self.db.commit() return int(result.rowcount or 0) + def list_group_keys_by_member_sub(self, authentik_sub: str) -> list[str]: + stmt = ( + select(PermissionGroup.group_key) + .select_from(PermissionGroupMember) + .join(PermissionGroup, PermissionGroup.id == PermissionGroupMember.group_id) + .where(PermissionGroupMember.authentik_sub == authentik_sub) + .order_by(PermissionGroup.group_key.asc()) + ) + return [row[0] for row in self.db.execute(stmt).all()] + + def replace_member_groups(self, authentik_sub: str, group_ids: list[str]) -> None: + self.db.execute(delete(PermissionGroupMember).where(PermissionGroupMember.authentik_sub == authentik_sub)) + for group_id in group_ids: + self.db.add(PermissionGroupMember(group_id=group_id, authentik_sub=authentik_sub)) + self.db.commit() + def grant_group_permission( self, group_id: str, @@ -93,6 +117,14 @@ class PermissionGroupsRepository: self.db.refresh(row) return row + def list_group_permissions(self, group_id: str) -> list[PermissionGroupPermission]: + stmt = ( + select(PermissionGroupPermission) + .where(PermissionGroupPermission.group_id == group_id) + .order_by(PermissionGroupPermission.scope_type.asc(), PermissionGroupPermission.scope_id.asc(), PermissionGroupPermission.system.asc(), PermissionGroupPermission.module.asc(), PermissionGroupPermission.action.asc()) + ) + return list(self.db.scalars(stmt).all()) + def revoke_group_permission( self, group_id: str, diff --git a/app/repositories/permissions_repo.py b/app/repositories/permissions_repo.py index 393b58a..014cd56 100644 --- a/app/repositories/permissions_repo.py +++ b/app/repositories/permissions_repo.py @@ -1,4 +1,4 @@ -from sqlalchemy import and_, delete, literal, or_, select +from sqlalchemy import and_, delete, func, literal, or_, select from sqlalchemy.orm import Session from app.models.company import Company @@ -6,6 +6,7 @@ from app.models.module import Module from app.models.permission_group_member import PermissionGroupMember from app.models.permission_group_permission import PermissionGroupPermission from app.models.site import Site +from app.models.user import User from app.models.user_scope_permission import UserScopePermission @@ -119,3 +120,101 @@ class PermissionsRepository: result = self.db.execute(stmt) self.db.commit() return int(result.rowcount or 0) + + def list_direct_permissions( + self, + *, + keyword: str | None = None, + scope_type: str | None = None, + limit: int = 200, + offset: int = 0, + ) -> tuple[list[dict], int]: + stmt = ( + select( + UserScopePermission.id, + User.authentik_sub, + User.email, + User.display_name, + UserScopePermission.scope_type, + Company.company_key, + Site.site_key, + Module.module_key, + UserScopePermission.action, + UserScopePermission.created_at, + ) + .select_from(UserScopePermission) + .join(User, User.id == UserScopePermission.user_id) + .join(Module, Module.id == UserScopePermission.module_id) + .join(Company, Company.id == UserScopePermission.company_id, isouter=True) + .join(Site, Site.id == UserScopePermission.site_id, isouter=True) + ) + count_stmt = ( + select(func.count()) + .select_from(UserScopePermission) + .join(User, User.id == UserScopePermission.user_id) + .join(Module, Module.id == UserScopePermission.module_id) + .join(Company, Company.id == UserScopePermission.company_id, isouter=True) + .join(Site, Site.id == UserScopePermission.site_id, isouter=True) + ) + + if scope_type in {"company", "site"}: + stmt = stmt.where(UserScopePermission.scope_type == scope_type) + count_stmt = count_stmt.where(UserScopePermission.scope_type == scope_type) + + if keyword: + pattern = f"%{keyword}%" + cond = or_( + User.authentik_sub.ilike(pattern), + User.email.ilike(pattern), + User.display_name.ilike(pattern), + Module.module_key.ilike(pattern), + Company.company_key.ilike(pattern), + Site.site_key.ilike(pattern), + UserScopePermission.action.ilike(pattern), + ) + stmt = stmt.where(cond) + count_stmt = count_stmt.where(cond) + + stmt = stmt.order_by(UserScopePermission.created_at.desc()).limit(limit).offset(offset) + rows = self.db.execute(stmt).all() + total = int(self.db.scalar(count_stmt) or 0) + items: list[dict] = [] + for row in rows: + ( + permission_id, + authentik_sub, + email, + display_name, + row_scope_type, + company_key, + site_key, + module_key, + action, + created_at, + ) = row + scope_id = company_key if row_scope_type == "company" else site_key + system_key = module_key.split(".", 1)[0] if isinstance(module_key, str) and "." in module_key else None + module_name = module_key.split(".", 1)[1] if isinstance(module_key, str) and "." in module_key else module_key + if module_name == "__system__": + module_name = None + items.append( + { + "permission_id": permission_id, + "authentik_sub": authentik_sub, + "email": email, + "display_name": display_name, + "scope_type": row_scope_type, + "scope_id": scope_id, + "system": system_key, + "module": module_name, + "action": action, + "created_at": created_at, + } + ) + return items, total + + def revoke_by_permission_id(self, permission_id: str) -> int: + stmt = delete(UserScopePermission).where(UserScopePermission.id == permission_id) + result = self.db.execute(stmt) + self.db.commit() + return int(result.rowcount or 0) diff --git a/app/schemas/catalog.py b/app/schemas/catalog.py index 1242447..ad58ad8 100644 --- a/app/schemas/catalog.py +++ b/app/schemas/catalog.py @@ -101,6 +101,10 @@ class MemberUpdateRequest(BaseModel): sync_to_authentik: bool = True +class MemberPermissionGroupsUpdateRequest(BaseModel): + group_keys: list[str] + + class ListResponse(BaseModel): items: list total: int @@ -124,3 +128,17 @@ class PermissionGroupItem(BaseModel): group_key: str name: str status: str + + +class PermissionGroupPermissionItem(BaseModel): + id: str + system: str + module: str + action: str + scope_type: str + scope_id: str + + +class MemberPermissionGroupsResponse(BaseModel): + authentik_sub: str + group_keys: list[str] diff --git a/app/schemas/permissions.py b/app/schemas/permissions.py index 489b9bf..a6ba358 100644 --- a/app/schemas/permissions.py +++ b/app/schemas/permissions.py @@ -1,3 +1,5 @@ +from datetime import datetime + from pydantic import BaseModel @@ -32,3 +34,23 @@ class PermissionItem(BaseModel): class PermissionSnapshotResponse(BaseModel): authentik_sub: str permissions: list[PermissionItem] + + +class DirectPermissionRow(BaseModel): + permission_id: str + authentik_sub: str + email: str | None = None + display_name: str | None = None + scope_type: str + scope_id: str + system: str | None = None + module: str | None = None + action: str + created_at: datetime + + +class DirectPermissionListResponse(BaseModel): + items: list[DirectPermissionRow] + total: int + limit: int + offset: int