refactor: rebuild backend around role-site authorization model

This commit is contained in:
Chris
2026-04-02 23:58:13 +08:00
parent 0bc667847d
commit 2f92b94f59
43 changed files with 1593 additions and 2257 deletions

View File

@@ -1,153 +0,0 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.repositories.companies_repo import CompaniesRepository
from app.repositories.modules_repo import ModulesRepository
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 (
DirectPermissionListResponse,
DirectPermissionRow,
PermissionGrantRequest,
PermissionRevokeRequest,
)
from app.security.admin_guard import require_admin_principal
router = APIRouter(
prefix="/admin",
tags=["admin"],
dependencies=[Depends(require_admin_principal)],
)
def _resolve_module_id(db: Session, system_key: str, module_key: str | None) -> str:
systems_repo = SystemsRepository(db)
modules_repo = ModulesRepository(db)
system = systems_repo.get_by_key(system_key)
if not system:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found")
target_module_key = module_key if module_key else f"__system__{system_key}"
module = modules_repo.get_by_key(target_module_key)
if module and module.system_key != system_key:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="module_system_mismatch")
if not module:
module = modules_repo.create(
module_key=target_module_key,
system_key=system_key,
name=target_module_key,
status="active",
)
return module.id
def _resolve_scope_ids(db: Session, scope_type: str, scope_id: str) -> tuple[str | None, str | None]:
companies_repo = CompaniesRepository(db)
sites_repo = SitesRepository(db)
if scope_type == "company":
company = companies_repo.get_by_key(scope_id)
if not company:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found")
return company.id, None
if scope_type == "site":
site = sites_repo.get_by_key(scope_id)
if not site:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found")
return None, site.id
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_scope_type")
@router.post("/permissions/grant")
def grant_permission(
payload: PermissionGrantRequest,
db: Session = Depends(get_db),
) -> dict[str, str]:
users_repo = UsersRepository(db)
perms_repo = PermissionsRepository(db)
user = users_repo.upsert_by_sub(
user_sub=payload.user_sub,
username=None,
email=payload.email,
display_name=payload.display_name,
is_active=True,
)
module_id = _resolve_module_id(db, payload.system, payload.module)
company_id, site_id = _resolve_scope_ids(db, payload.scope_type, payload.scope_id)
permission = perms_repo.create_if_not_exists(
user_id=user.id,
module_id=module_id,
action=payload.action,
scope_type=payload.scope_type,
company_id=company_id,
site_id=site_id,
)
return {"permission_id": permission.id, "result": "granted"}
@router.post("/permissions/revoke")
def revoke_permission(
payload: PermissionRevokeRequest,
db: Session = Depends(get_db),
) -> dict[str, int | str]:
users_repo = UsersRepository(db)
perms_repo = PermissionsRepository(db)
user = users_repo.get_by_sub(payload.user_sub)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found")
module_id = _resolve_module_id(db, payload.system, payload.module)
company_id, site_id = _resolve_scope_ids(db, payload.scope_type, payload.scope_id)
deleted = perms_repo.revoke(
user_id=user.id,
module_id=module_id,
action=payload.action,
scope_type=payload.scope_type,
company_id=company_id,
site_id=site_id,
)
return {"deleted": deleted, "result": "revoked"}
@router.get("/permissions/direct", response_model=DirectPermissionListResponse)
def list_direct_permissions(
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,
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"}

File diff suppressed because it is too large Load Diff

View File

@@ -3,11 +3,11 @@ from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.db.session import get_db
from app.repositories.permissions_repo import PermissionsRepository
from app.schemas.internal import InternalUpsertUserBySubResponse
from app.repositories.users_repo import UsersRepository
from app.repositories.user_sites_repo import UserSitesRepository
from app.schemas.idp_admin import KeycloakEnsureUserRequest, KeycloakEnsureUserResponse
from app.schemas.permissions import PermissionSnapshotResponse
from app.schemas.internal import InternalUpsertUserBySubResponse, InternalUserRoleItem, InternalUserRoleResponse
from app.schemas.permissions import RoleSnapshotResponse
from app.schemas.users import UserUpsertBySubRequest
from app.security.api_client_auth import require_api_client
from app.services.idp_admin_service import KeycloakAdminService
@@ -28,32 +28,84 @@ def upsert_user_by_sub(
email=payload.email,
display_name=payload.display_name,
is_active=payload.is_active,
status=payload.status,
)
return InternalUpsertUserBySubResponse(
id=user.id,
user_sub=user.user_sub,
idp_user_id=user.idp_user_id,
username=user.username,
email=user.email,
display_name=user.display_name,
is_active=user.is_active,
status=user.status,
)
return {
"id": user.id,
"user_sub": user.user_sub,
"idp_user_id": user.idp_user_id,
"username": user.username,
"email": user.email,
"display_name": user.display_name,
"is_active": user.is_active,
}
@router.get("/permissions/{user_sub}/snapshot", response_model=PermissionSnapshotResponse)
def get_permission_snapshot(
user_sub: str,
db: Session = Depends(get_db),
) -> PermissionSnapshotResponse:
def _build_user_role_rows(db: Session, user_sub: str) -> list[tuple[str, str, str, str, str, str, str, str, str]]:
users_repo = UsersRepository(db)
perms_repo = PermissionsRepository(db)
user_sites_repo = UserSitesRepository(db)
user = users_repo.get_by_sub(user_sub)
if user is None:
return PermissionSnapshotResponse(user_sub=user_sub, permissions=[])
return []
permissions = perms_repo.list_by_user(user.id, user.user_sub)
return PermissionService.build_snapshot(user_sub=user_sub, permissions=permissions)
rows = user_sites_repo.get_user_role_rows(user.id)
return [
(
site.site_key,
site.display_name,
company.company_key,
company.display_name,
system.system_key,
system.name,
role.role_key,
role.name,
role.idp_role_name,
)
for site, company, role, system in rows
]
@router.get("/users/{user_sub}/roles", response_model=InternalUserRoleResponse)
def get_user_roles(user_sub: str, db: Session = Depends(get_db)) -> InternalUserRoleResponse:
rows = _build_user_role_rows(db, user_sub)
return InternalUserRoleResponse(
user_sub=user_sub,
roles=[
InternalUserRoleItem(
site_key=site_key,
site_display_name=site_display_name,
company_key=company_key,
company_display_name=company_display_name,
system_key=system_key,
system_name=system_name,
role_key=role_key,
role_name=role_name,
idp_role_name=idp_role_name,
)
for (
site_key,
site_display_name,
company_key,
company_display_name,
system_key,
system_name,
role_key,
role_name,
idp_role_name,
) in rows
],
)
@router.get("/permissions/{user_sub}/snapshot", response_model=RoleSnapshotResponse)
def get_permission_snapshot(
user_sub: str,
db: Session = Depends(get_db),
) -> RoleSnapshotResponse:
rows = _build_user_role_rows(db, user_sub)
return PermissionService.build_role_snapshot(user_sub=user_sub, rows=rows)
@router.post("/idp/users/ensure", response_model=KeycloakEnsureUserResponse)
@@ -73,17 +125,17 @@ def ensure_idp_user(
)
users_repo = UsersRepository(db)
resolved_sub = payload.user_sub or ""
if sync_result.user_sub:
resolved_sub = sync_result.user_sub
resolved_sub = payload.user_sub or sync_result.user_sub or ""
if not resolved_sub:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_missing_sub")
users_repo.upsert_by_sub(
user_sub=resolved_sub,
username=payload.username,
email=payload.email,
display_name=payload.display_name,
is_active=payload.is_active,
status="active",
idp_user_id=sync_result.user_id,
)
return KeycloakEnsureUserResponse(idp_user_id=sync_result.user_id, action=sync_result.action)

View File

@@ -3,14 +3,15 @@ from sqlalchemy.orm import Session
from app.db.session import get_db
from app.repositories.companies_repo import CompaniesRepository
from app.repositories.modules_repo import ModulesRepository
from app.repositories.roles_repo import RolesRepository
from app.repositories.sites_repo import SitesRepository
from app.repositories.systems_repo import SystemsRepository
from app.repositories.users_repo import UsersRepository
from app.schemas.internal import (
InternalCompanyListResponse,
InternalMemberListResponse,
InternalModuleListResponse,
InternalRoleItem,
InternalRoleListResponse,
InternalSiteListResponse,
InternalSystemListResponse,
)
@@ -27,24 +28,13 @@ def internal_list_systems(
) -> InternalSystemListResponse:
repo = SystemsRepository(db)
items, total = repo.list(limit=limit, offset=offset)
return {"items": [{"id": i.id, "system_key": i.system_key, "name": i.name, "status": i.status} for i in items], "total": total, "limit": limit, "offset": offset}
@router.get("/modules", response_model=InternalModuleListResponse)
def internal_list_modules(
db: Session = Depends(get_db),
limit: int = Query(default=500, ge=1, le=2000),
offset: int = Query(default=0, ge=0),
) -> InternalModuleListResponse:
modules_repo = ModulesRepository(db)
items, total = modules_repo.list(limit=limit, offset=offset)
return {
"items": [
{
"id": i.id,
"module_key": i.module_key,
"system_key": i.system_key,
"name": i.name,
"idp_client_id": i.idp_client_id,
"status": i.status,
}
for i in items
@@ -55,6 +45,43 @@ def internal_list_modules(
}
@router.get("/roles", response_model=InternalRoleListResponse)
def internal_list_roles(
db: Session = Depends(get_db),
system_key: str | None = Query(default=None),
limit: int = Query(default=500, ge=1, le=2000),
offset: int = Query(default=0, ge=0),
) -> InternalRoleListResponse:
systems_repo = SystemsRepository(db)
roles_repo = RolesRepository(db)
system_id = None
systems, _ = systems_repo.list(limit=5000, offset=0)
system_map = {s.id: s for s in systems}
if system_key:
system = systems_repo.get_by_key(system_key)
if not system:
return InternalRoleListResponse(items=[], total=0, limit=limit, offset=offset)
system_id = system.id
items, total = roles_repo.list(system_id=system_id, limit=limit, offset=offset)
rows = [
InternalRoleItem(
id=i.id,
role_key=i.role_key,
system_key=system_map[i.system_id].system_key,
system_name=system_map[i.system_id].name,
name=i.name,
idp_role_name=i.idp_role_name,
description=i.description,
status=i.status,
)
for i in items
if i.system_id in system_map
]
return InternalRoleListResponse(items=rows, total=total, limit=limit, offset=offset)
@router.get("/companies", response_model=InternalCompanyListResponse)
def internal_list_companies(
db: Session = Depends(get_db),
@@ -64,7 +91,21 @@ def internal_list_companies(
) -> InternalCompanyListResponse:
repo = CompaniesRepository(db)
items, total = repo.list(keyword=keyword, limit=limit, offset=offset)
return {"items": [{"id": i.id, "company_key": i.company_key, "name": i.name, "status": i.status} for i in items], "total": total, "limit": limit, "offset": offset}
return {
"items": [
{
"id": i.id,
"company_key": i.company_key,
"display_name": i.display_name,
"legal_name": i.legal_name,
"status": i.status,
}
for i in items
],
"total": total,
"limit": limit,
"offset": offset,
}
@router.get("/sites", response_model=InternalSiteListResponse)
@@ -81,10 +122,27 @@ def internal_list_sites(
company = companies_repo.get_by_key(company_key)
if company:
company_id = company.id
companies, _ = companies_repo.list(limit=2000, offset=0)
mapping = {c.id: c.company_key for c in companies}
companies, _ = companies_repo.list(limit=5000, offset=0)
mapping = {c.id: c for c in companies}
items, total = sites_repo.list(company_id=company_id, limit=limit, offset=offset)
return {"items": [{"id": i.id, "site_key": i.site_key, "company_key": mapping.get(i.company_id), "name": i.name, "status": i.status} for i in items], "total": total, "limit": limit, "offset": offset}
return {
"items": [
{
"id": i.id,
"site_key": i.site_key,
"company_key": mapping[i.company_id].company_key,
"company_display_name": mapping[i.company_id].display_name,
"display_name": i.display_name,
"domain": i.domain,
"status": i.status,
}
for i in items
if i.company_id in mapping
],
"total": total,
"limit": limit,
"offset": offset,
}
@router.get("/members", response_model=InternalMemberListResponse)
@@ -105,6 +163,7 @@ def internal_list_members(
"email": i.email,
"display_name": i.display_name,
"is_active": i.is_active,
"status": i.status,
}
for i in items
],

View File

@@ -3,10 +3,10 @@ from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.repositories.permissions_repo import PermissionsRepository
from app.repositories.users_repo import UsersRepository
from app.repositories.user_sites_repo import UserSitesRepository
from app.schemas.auth import KeycloakPrincipal, MeSummaryResponse
from app.schemas.permissions import PermissionSnapshotResponse
from app.schemas.permissions import RoleSnapshotResponse
from app.security.idp_jwt import require_authenticated_principal
from app.services.permission_service import PermissionService
@@ -26,10 +26,10 @@ def get_me(
email=principal.email,
display_name=principal.name or principal.preferred_username,
is_active=True,
status="active",
)
return MeSummaryResponse(sub=user.user_sub, email=user.email, display_name=user.display_name)
except SQLAlchemyError:
# DB schema compatibility fallback for local bring-up.
return MeSummaryResponse(
sub=principal.sub,
email=principal.email,
@@ -37,14 +37,14 @@ def get_me(
)
@router.get("/permissions/snapshot", response_model=PermissionSnapshotResponse)
@router.get("/permissions/snapshot", response_model=RoleSnapshotResponse)
def get_my_permission_snapshot(
principal: KeycloakPrincipal = Depends(require_authenticated_principal),
db: Session = Depends(get_db),
) -> PermissionSnapshotResponse:
) -> RoleSnapshotResponse:
try:
users_repo = UsersRepository(db)
perms_repo = PermissionsRepository(db)
user_sites_repo = UserSitesRepository(db)
user = users_repo.upsert_by_sub(
user_sub=principal.sub,
@@ -52,8 +52,23 @@ def get_my_permission_snapshot(
email=principal.email,
display_name=principal.name or principal.preferred_username,
is_active=True,
status="active",
)
permissions = perms_repo.list_by_user(user.id, user.user_sub)
return PermissionService.build_snapshot(user_sub=principal.sub, permissions=permissions)
rows = user_sites_repo.get_user_role_rows(user.id)
serialized = [
(
site.site_key,
site.display_name,
company.company_key,
company.display_name,
system.system_key,
system.name,
role.role_key,
role.name,
role.idp_role_name,
)
for site, company, role, system in rows
]
return PermissionService.build_role_snapshot(user_sub=principal.sub, rows=serialized)
except SQLAlchemyError:
return PermissionSnapshotResponse(user_sub=principal.sub, permissions=[])
return RoleSnapshotResponse(user_sub=principal.sub, roles=[])