refactor: rebuild backend around role-site authorization model
This commit is contained in:
153
app/api/admin.py
153
app/api/admin.py
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
],
|
||||
|
||||
@@ -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=[])
|
||||
|
||||
Reference in New Issue
Block a user