From 61cab48fcaafb833dcdb60d6267085ccd0ded090 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 30 Mar 2026 19:38:49 +0800 Subject: [PATCH] feat(admin): implement group-centric relations and system/module/company linkage views --- app/api/admin_catalog.py | 200 ++++++++++++++++++++- app/repositories/permission_groups_repo.py | 125 +++++++++++++ app/repositories/permissions_repo.py | 14 +- app/schemas/catalog.py | 35 +++- app/schemas/permissions.py | 20 ++- 5 files changed, 378 insertions(+), 16 deletions(-) diff --git a/app/api/admin_catalog.py b/app/api/admin_catalog.py index 818a775..a7b54a7 100644 --- a/app/api/admin_catalog.py +++ b/app/api/admin_catalog.py @@ -13,6 +13,10 @@ from app.repositories.users_repo import UsersRepository from app.schemas.catalog import ( CompanyCreateRequest, CompanyItem, + GroupBindingSnapshot, + GroupBindingUpdateRequest, + GroupRelationItem, + MemberRelationItem, CompanyUpdateRequest, MemberItem, MemberPermissionGroupsResponse, @@ -69,6 +73,14 @@ 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 _split_module_key(payload_module: str | None) -> str: + if not payload_module: + return "__system__" + if "." in payload_module: + return payload_module.split(".", 1)[1] + return payload_module + + def _sync_member_to_authentik( *, authentik_sub: str, @@ -195,6 +207,96 @@ def update_module( return ModuleItem(id=row.id, system_key=system_key, module_key=row.module_key, name=row.name, status=row.status) +@router.get("/systems/{system_key}/groups") +def list_system_groups( + system_key: str, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> dict[str, list[dict]]: + systems_repo = SystemsRepository(db) + groups_repo = PermissionGroupsRepository(db) + if not systems_repo.get_by_key(system_key): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found") + groups = groups_repo.list_system_groups(system_key) + return { + "items": [ + GroupRelationItem(group_key=g.group_key, group_name=g.name, status=g.status).model_dump() + for g in groups + ] + } + + +@router.get("/systems/{system_key}/members") +def list_system_members( + system_key: str, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> dict[str, list[dict]]: + systems_repo = SystemsRepository(db) + groups_repo = PermissionGroupsRepository(db) + if not systems_repo.get_by_key(system_key): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found") + members = groups_repo.list_system_members(system_key) + return { + "items": [ + MemberRelationItem( + authentik_sub=m.authentik_sub, + email=m.email, + display_name=m.display_name, + is_active=m.is_active, + ).model_dump() + for m in members + ] + } + + +@router.get("/modules/{module_key}/groups") +def list_module_groups( + module_key: str, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> dict[str, list[dict]]: + modules_repo = ModulesRepository(db) + groups_repo = PermissionGroupsRepository(db) + module = modules_repo.get_by_key(module_key) + if not module: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="module_not_found") + system_key, module_name = module.module_key.split(".", 1) if "." in module.module_key else ("", module.module_key) + groups = groups_repo.list_module_groups(system_key, module_name) + return { + "items": [ + GroupRelationItem(group_key=g.group_key, group_name=g.name, status=g.status).model_dump() + for g in groups + ] + } + + +@router.get("/modules/{module_key}/members") +def list_module_members( + module_key: str, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> dict[str, list[dict]]: + modules_repo = ModulesRepository(db) + groups_repo = PermissionGroupsRepository(db) + module = modules_repo.get_by_key(module_key) + if not module: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="module_not_found") + system_key, module_name = module.module_key.split(".", 1) if "." in module.module_key else ("", module.module_key) + members = groups_repo.list_module_members(system_key, module_name) + return { + "items": [ + MemberRelationItem( + authentik_sub=m.authentik_sub, + email=m.email, + display_name=m.display_name, + is_active=m.is_active, + ).model_dump() + for m in members + ] + } + + @router.get("/companies") def list_companies( _: ApiClient = Depends(require_api_client), @@ -236,6 +338,32 @@ def update_company( return CompanyItem(id=row.id, company_key=row.company_key, name=row.name, status=row.status) +@router.get("/companies/{company_key}/sites") +def list_company_sites( + company_key: str, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> dict[str, list[dict]]: + companies_repo = CompaniesRepository(db) + sites_repo = SitesRepository(db) + company = companies_repo.get_by_key(company_key) + if not company: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found") + items, _ = sites_repo.list(company_id=company.id, limit=1000, offset=0) + return { + "items": [ + SiteItem( + id=i.id, + site_key=i.site_key, + company_key=company.company_key, + name=i.name, + status=i.status, + ).model_dump() + for i in items + ] + } + + @router.get("/sites") def list_sites( _: ApiClient = Depends(require_api_client), @@ -481,16 +609,78 @@ def list_permission_group_permissions( PermissionGroupPermissionItem( id=r.id, system=r.system, - module=r.module, + module=("" if r.module == "__system__" else (r.module if "." in r.module else f"{r.system}.{r.module}")), action=r.action, scope_type=r.scope_type, scope_id=r.scope_id, ).model_dump() for r in rows + if r.action in {"view", "edit"} and r.scope_type == "site" ] } +@router.get("/permission-groups/{group_key}/bindings", response_model=GroupBindingSnapshot) +def get_permission_group_bindings( + group_key: str, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> GroupBindingSnapshot: + 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") + snapshot = repo.get_group_binding_snapshot(group.id, group_key) + return GroupBindingSnapshot(**snapshot) + + +@router.put("/permission-groups/{group_key}/bindings", response_model=GroupBindingSnapshot) +def replace_permission_group_bindings( + group_key: str, + payload: GroupBindingUpdateRequest, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> GroupBindingSnapshot: + repo = PermissionGroupsRepository(db) + sites_repo = SitesRepository(db) + systems_repo = SystemsRepository(db) + modules_repo = ModulesRepository(db) + + group = repo.get_by_key(group_key) + if not group: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found") + + site_keys = list(dict.fromkeys(payload.site_keys)) + system_keys = list(dict.fromkeys(payload.system_keys)) + module_keys = list(dict.fromkeys(payload.module_keys)) + + valid_sites = {s.site_key for s in sites_repo.list(limit=10000, offset=0)[0]} + missing_sites = [k for k in site_keys if k not in valid_sites] + if missing_sites: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"site_not_found:{','.join(missing_sites)}") + + valid_systems = {s.system_key for s in systems_repo.list(limit=10000, offset=0)[0]} + missing_systems = [k for k in system_keys if k not in valid_systems] + if missing_systems: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"system_not_found:{','.join(missing_systems)}") + + valid_modules = {m.module_key for m in modules_repo.list(limit=10000, offset=0)[0]} + missing_modules = [k for k in module_keys if k not in valid_modules] + if missing_modules: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"module_not_found:{','.join(missing_modules)}") + + repo.replace_group_bindings( + group_id=group.id, + site_keys=site_keys, + system_keys=system_keys, + module_keys=module_keys, + member_subs=payload.member_subs, + actions=payload.actions, + ) + snapshot = repo.get_group_binding_snapshot(group.id, group_key) + return GroupBindingSnapshot(**snapshot) + + @router.post("/permission-groups", response_model=PermissionGroupItem) def create_permission_group( payload: PermissionGroupCreateRequest, @@ -562,11 +752,11 @@ def grant_group_permission( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found") _resolve_module_id(db, payload.system, payload.module) _resolve_scope_ids(db, payload.scope_type, payload.scope_id) - module_key = f"{payload.system}.{payload.module}" if payload.module else f"{payload.system}.__system__" + module_name = _split_module_key(payload.module) row = groups_repo.grant_group_permission( group_id=group.id, system=payload.system, - module=module_key, + module=module_name, action=payload.action, scope_type=payload.scope_type, scope_id=payload.scope_id, @@ -587,11 +777,11 @@ def revoke_group_permission( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found") _resolve_module_id(db, payload.system, payload.module) _resolve_scope_ids(db, payload.scope_type, payload.scope_id) - module_key = f"{payload.system}.{payload.module}" if payload.module else f"{payload.system}.__system__" + module_name = _split_module_key(payload.module) deleted = groups_repo.revoke_group_permission( group_id=group.id, system=payload.system, - module=module_key, + module=module_name, action=payload.action, scope_type=payload.scope_type, scope_id=payload.scope_id, diff --git a/app/repositories/permission_groups_repo.py b/app/repositories/permission_groups_repo.py index ff2477f..289217b 100644 --- a/app/repositories/permission_groups_repo.py +++ b/app/repositories/permission_groups_repo.py @@ -1,11 +1,14 @@ from __future__ import annotations +from collections import defaultdict + from sqlalchemy import delete, func, select from sqlalchemy.orm import Session from app.models.permission_group import PermissionGroup from app.models.permission_group_member import PermissionGroupMember from app.models.permission_group_permission import PermissionGroupPermission +from app.models.user import User class PermissionGroupsRepository: @@ -125,6 +128,128 @@ class PermissionGroupsRepository: ) return list(self.db.scalars(stmt).all()) + def replace_group_bindings( + self, + *, + group_id: str, + site_keys: list[str], + system_keys: list[str], + module_keys: list[str], + member_subs: list[str], + actions: list[str], + ) -> None: + normalized_sites = list(dict.fromkeys([s for s in site_keys if s])) + normalized_actions = [a for a in list(dict.fromkeys(actions)) if a in {"view", "edit"}] + normalized_member_subs = list(dict.fromkeys([s for s in member_subs if s])) + + modules_by_system: dict[str, list[str]] = defaultdict(list) + for full_module_key in list(dict.fromkeys([m for m in module_keys if m])): + if "." not in full_module_key: + continue + system_key, module_name = full_module_key.split(".", 1) + if module_name == "__system__": + continue + modules_by_system[system_key].append(module_name) + + normalized_systems = set([s for s in system_keys if s]) + normalized_systems.update(modules_by_system.keys()) + + self.db.execute(delete(PermissionGroupPermission).where(PermissionGroupPermission.group_id == group_id)) + self.db.execute(delete(PermissionGroupMember).where(PermissionGroupMember.group_id == group_id)) + + for sub in normalized_member_subs: + self.db.add(PermissionGroupMember(group_id=group_id, authentik_sub=sub)) + + for site_key in normalized_sites: + for action in normalized_actions: + for system_key in sorted(normalized_systems): + module_names = modules_by_system.get(system_key) or ["__system__"] + for module_name in module_names: + self.db.add( + PermissionGroupPermission( + group_id=group_id, + system=system_key, + module=module_name, + action=action, + scope_type="site", + scope_id=site_key, + ) + ) + + self.db.commit() + + def get_group_binding_snapshot(self, group_id: str, group_key: str) -> dict: + permissions = self.list_group_permissions(group_id) + site_keys = sorted({p.scope_id for p in permissions if p.scope_type == "site"}) + system_keys = sorted({p.system for p in permissions}) + actions = sorted({p.action for p in permissions if p.action in {"view", "edit"}}) + module_keys = sorted( + { + p.module if "." in p.module else f"{p.system}.{p.module}" + for p in permissions + if p.module and p.module != "__system__" + } + ) + member_subs = sorted(self.list_group_member_subs(group_id)) + return { + "group_key": group_key, + "site_keys": site_keys, + "system_keys": system_keys, + "module_keys": module_keys, + "member_subs": member_subs, + "actions": actions, + } + + def list_group_member_subs(self, group_id: str) -> list[str]: + stmt = ( + select(PermissionGroupMember.authentik_sub) + .where(PermissionGroupMember.group_id == group_id) + .order_by(PermissionGroupMember.authentik_sub.asc()) + ) + return [row[0] for row in self.db.execute(stmt).all()] + + def list_system_groups(self, system_key: str) -> list[PermissionGroup]: + stmt = ( + select(PermissionGroup) + .join(PermissionGroupPermission, PermissionGroupPermission.group_id == PermissionGroup.id) + .where(PermissionGroupPermission.system == system_key) + .order_by(PermissionGroup.name.asc()) + .distinct() + ) + return list(self.db.scalars(stmt).all()) + + def list_system_members(self, system_key: str) -> list[User]: + stmt = ( + select(User) + .join(PermissionGroupMember, PermissionGroupMember.authentik_sub == User.authentik_sub) + .join(PermissionGroupPermission, PermissionGroupPermission.group_id == PermissionGroupMember.group_id) + .where(PermissionGroupPermission.system == system_key) + .order_by(User.email.asc(), User.authentik_sub.asc()) + .distinct() + ) + return list(self.db.scalars(stmt).all()) + + def list_module_groups(self, system_key: str, module_name: str) -> list[PermissionGroup]: + stmt = ( + select(PermissionGroup) + .join(PermissionGroupPermission, PermissionGroupPermission.group_id == PermissionGroup.id) + .where(PermissionGroupPermission.system == system_key, PermissionGroupPermission.module == module_name) + .order_by(PermissionGroup.name.asc()) + .distinct() + ) + return list(self.db.scalars(stmt).all()) + + def list_module_members(self, system_key: str, module_name: str) -> list[User]: + stmt = ( + select(User) + .join(PermissionGroupMember, PermissionGroupMember.authentik_sub == User.authentik_sub) + .join(PermissionGroupPermission, PermissionGroupPermission.group_id == PermissionGroupMember.group_id) + .where(PermissionGroupPermission.system == system_key, PermissionGroupPermission.module == module_name) + .order_by(User.email.asc(), User.authentik_sub.asc()) + .distinct() + ) + 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 014cd56..ce0a78c 100644 --- a/app/repositories/permissions_repo.py +++ b/app/repositories/permissions_repo.py @@ -29,6 +29,8 @@ class PermissionsRepository: .join(Company, Company.id == UserScopePermission.company_id, isouter=True) .join(Site, Site.id == UserScopePermission.site_id, isouter=True) .where(UserScopePermission.user_id == user_id) + .where(UserScopePermission.action.in_(["view", "edit"])) + .where(UserScopePermission.scope_type == "site") ) group_stmt = ( select( @@ -42,6 +44,8 @@ class PermissionsRepository: .select_from(PermissionGroupPermission) .join(PermissionGroupMember, PermissionGroupMember.group_id == PermissionGroupPermission.group_id) .where(PermissionGroupMember.authentik_sub == authentik_sub) + .where(PermissionGroupPermission.action.in_(["view", "edit"])) + .where(PermissionGroupPermission.scope_type == "site") ) rows = self.db.execute(direct_stmt).all() + self.db.execute(group_stmt).all() result: list[tuple[str, str, str | None, str, str]] = [] @@ -50,6 +54,10 @@ class PermissionsRepository: source = row[0] if source == "group": _, scope_type, scope_id, system_key, module_key, action = row + if module_key == "__system__": + module_key = f"{system_key}.__system__" + elif module_key and "." not in module_key: + module_key = f"{system_key}.{module_key}" else: _, scope_type, company_key, site_key, module_key, action = row scope_id = company_key if scope_type == "company" else site_key @@ -147,6 +155,8 @@ class PermissionsRepository: .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) + .where(UserScopePermission.action.in_(["view", "edit"])) + .where(UserScopePermission.scope_type == "site") ) count_stmt = ( select(func.count()) @@ -155,9 +165,11 @@ class PermissionsRepository: .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) + .where(UserScopePermission.action.in_(["view", "edit"])) + .where(UserScopePermission.scope_type == "site") ) - if scope_type in {"company", "site"}: + if scope_type == "site": stmt = stmt.where(UserScopePermission.scope_type == scope_type) count_stmt = count_stmt.where(UserScopePermission.scope_type == scope_type) diff --git a/app/schemas/catalog.py b/app/schemas/catalog.py index ad58ad8..90af683 100644 --- a/app/schemas/catalog.py +++ b/app/schemas/catalog.py @@ -1,4 +1,5 @@ from pydantic import BaseModel +from typing import Literal class SystemCreateRequest(BaseModel): @@ -134,11 +135,41 @@ class PermissionGroupPermissionItem(BaseModel): id: str system: str module: str - action: str - scope_type: str + action: Literal["view", "edit"] + scope_type: Literal["site"] scope_id: str class MemberPermissionGroupsResponse(BaseModel): authentik_sub: str group_keys: list[str] + + +class GroupBindingUpdateRequest(BaseModel): + site_keys: list[str] + system_keys: list[str] + module_keys: list[str] + member_subs: list[str] + actions: list[Literal["view", "edit"]] + + +class GroupBindingSnapshot(BaseModel): + group_key: str + site_keys: list[str] + system_keys: list[str] + module_keys: list[str] + member_subs: list[str] + actions: list[Literal["view", "edit"]] + + +class GroupRelationItem(BaseModel): + group_key: str + group_name: str + status: str + + +class MemberRelationItem(BaseModel): + authentik_sub: str + email: str | None = None + display_name: str | None = None + is_active: bool diff --git a/app/schemas/permissions.py b/app/schemas/permissions.py index a6ba358..2b83224 100644 --- a/app/schemas/permissions.py +++ b/app/schemas/permissions.py @@ -1,34 +1,38 @@ from datetime import datetime +from typing import Literal from pydantic import BaseModel +ActionType = Literal["view", "edit"] +ScopeType = Literal["site"] + class PermissionGrantRequest(BaseModel): authentik_sub: str email: str | None = None display_name: str | None = None - scope_type: str + scope_type: ScopeType scope_id: str system: str module: str | None = None - action: str + action: ActionType class PermissionRevokeRequest(BaseModel): authentik_sub: str - scope_type: str + scope_type: ScopeType scope_id: str system: str module: str | None = None - action: str + action: ActionType class PermissionItem(BaseModel): - scope_type: str + scope_type: ScopeType scope_id: str system: str | None = None module: str - action: str + action: ActionType class PermissionSnapshotResponse(BaseModel): @@ -41,11 +45,11 @@ class DirectPermissionRow(BaseModel): authentik_sub: str email: str | None = None display_name: str | None = None - scope_type: str + scope_type: ScopeType scope_id: str system: str | None = None module: str | None = None - action: str + action: ActionType created_at: datetime