feat(flow): unify member-group-permission admin workflow and docs

This commit is contained in:
Chris
2026-03-30 03:54:22 +08:00
parent cc9ad16311
commit 35ffff1d19
6 changed files with 288 additions and 3 deletions

View File

@@ -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 sqlalchemy.orm import Session
from app.db.session import get_db 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.sites_repo import SitesRepository
from app.repositories.systems_repo import SystemsRepository from app.repositories.systems_repo import SystemsRepository
from app.repositories.users_repo import UsersRepository 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 from app.security.api_client_auth import require_api_client
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -98,3 +105,42 @@ def revoke_permission(
site_id=site_id, site_id=site_id,
) )
return {"deleted": deleted, "result": "revoked"} 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"}

View File

@@ -15,6 +15,8 @@ from app.schemas.catalog import (
CompanyItem, CompanyItem,
CompanyUpdateRequest, CompanyUpdateRequest,
MemberItem, MemberItem,
MemberPermissionGroupsResponse,
MemberPermissionGroupsUpdateRequest,
MemberUpdateRequest, MemberUpdateRequest,
MemberUpsertRequest, MemberUpsertRequest,
ModuleCreateRequest, ModuleCreateRequest,
@@ -22,6 +24,7 @@ from app.schemas.catalog import (
ModuleUpdateRequest, ModuleUpdateRequest,
PermissionGroupCreateRequest, PermissionGroupCreateRequest,
PermissionGroupItem, PermissionGroupItem,
PermissionGroupPermissionItem,
PermissionGroupUpdateRequest, PermissionGroupUpdateRequest,
SiteCreateRequest, SiteCreateRequest,
SiteItem, 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") @router.get("/permission-groups")
def list_permission_groups( def list_permission_groups(
_: ApiClient = Depends(require_api_client), _: 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} 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) @router.post("/permission-groups", response_model=PermissionGroupItem)
def create_permission_group( def create_permission_group(
payload: PermissionGroupCreateRequest, payload: PermissionGroupCreateRequest,

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -16,6 +18,12 @@ class PermissionGroupsRepository:
def get_by_id(self, group_id: str) -> PermissionGroup | None: def get_by_id(self, group_id: str) -> PermissionGroup | None:
return self.db.scalar(select(PermissionGroup).where(PermissionGroup.id == group_id)) 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]: 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) stmt = select(PermissionGroup).order_by(PermissionGroup.created_at.desc()).limit(limit).offset(offset)
count_stmt = select(func.count()).select_from(PermissionGroup) count_stmt = select(func.count()).select_from(PermissionGroup)
@@ -60,6 +68,22 @@ class PermissionGroupsRepository:
self.db.commit() self.db.commit()
return int(result.rowcount or 0) 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( def grant_group_permission(
self, self,
group_id: str, group_id: str,
@@ -93,6 +117,14 @@ class PermissionGroupsRepository:
self.db.refresh(row) self.db.refresh(row)
return 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( def revoke_group_permission(
self, self,
group_id: str, group_id: str,

View File

@@ -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 sqlalchemy.orm import Session
from app.models.company import Company 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_member import PermissionGroupMember
from app.models.permission_group_permission import PermissionGroupPermission from app.models.permission_group_permission import PermissionGroupPermission
from app.models.site import Site from app.models.site import Site
from app.models.user import User
from app.models.user_scope_permission import UserScopePermission from app.models.user_scope_permission import UserScopePermission
@@ -119,3 +120,101 @@ class PermissionsRepository:
result = self.db.execute(stmt) result = self.db.execute(stmt)
self.db.commit() self.db.commit()
return int(result.rowcount or 0) 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)

View File

@@ -101,6 +101,10 @@ class MemberUpdateRequest(BaseModel):
sync_to_authentik: bool = True sync_to_authentik: bool = True
class MemberPermissionGroupsUpdateRequest(BaseModel):
group_keys: list[str]
class ListResponse(BaseModel): class ListResponse(BaseModel):
items: list items: list
total: int total: int
@@ -124,3 +128,17 @@ class PermissionGroupItem(BaseModel):
group_key: str group_key: str
name: str name: str
status: 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]

View File

@@ -1,3 +1,5 @@
from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel
@@ -32,3 +34,23 @@ class PermissionItem(BaseModel):
class PermissionSnapshotResponse(BaseModel): class PermissionSnapshotResponse(BaseModel):
authentik_sub: str authentik_sub: str
permissions: list[PermissionItem] 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