feat(flow): unify member-group-permission admin workflow and docs
This commit is contained in:
@@ -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"}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user