1017 lines
38 KiB
Python
1017 lines
38 KiB
Python
from __future__ import annotations
|
|
|
|
import secrets
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.keygen import generate_key
|
|
from app.db.session import get_db
|
|
from app.repositories.api_clients_repo import ApiClientsRepository
|
|
from app.repositories.companies_repo import CompaniesRepository
|
|
from app.repositories.roles_repo import RolesRepository
|
|
from app.repositories.site_roles_repo import SiteRolesRepository
|
|
from app.repositories.sites_repo import SitesRepository
|
|
from app.repositories.systems_repo import SystemsRepository
|
|
from app.repositories.users_repo import UsersRepository
|
|
from app.repositories.user_sites_repo import UserSitesRepository
|
|
from app.schemas.api_clients import ApiClientCreateRequest, ApiClientCreateResponse, ApiClientRotateKeyResponse, ApiClientUpdateRequest
|
|
from app.schemas.catalog import (
|
|
ApiClientItem,
|
|
CompanyCreateRequest,
|
|
CompanyItem,
|
|
CompanySitesResponse,
|
|
CompanyUpdateRequest,
|
|
ListResponse,
|
|
MemberItem,
|
|
MemberPasswordResetResponse,
|
|
MemberUpsertRequest,
|
|
MemberUpdateRequest,
|
|
RoleCreateRequest,
|
|
RoleItem,
|
|
RoleSitesResponse,
|
|
RoleUpdateRequest,
|
|
SiteCreateRequest,
|
|
SiteItem,
|
|
SiteMembersResponse,
|
|
SiteRoleAssignRequest,
|
|
SiteRoleItem,
|
|
SiteRolesResponse,
|
|
SiteUpdateRequest,
|
|
SystemCreateRequest,
|
|
SystemItem,
|
|
SystemRolesResponse,
|
|
SystemUpdateRequest,
|
|
UserEffectiveRoleItem,
|
|
UserEffectiveRolesResponse,
|
|
UserSiteAssignRequest,
|
|
UserSiteItem,
|
|
UserSitesResponse,
|
|
)
|
|
from app.security.admin_guard import require_admin_principal
|
|
from app.security.api_client_auth import hash_api_key
|
|
from app.services.idp_admin_service import ProviderAdminService
|
|
from app.services.idp_catalog_sync import sync_from_provider
|
|
from app.core.config import get_settings
|
|
|
|
router = APIRouter(
|
|
prefix="/admin",
|
|
tags=["admin"],
|
|
dependencies=[Depends(require_admin_principal)],
|
|
)
|
|
|
|
|
|
def _generate_unique_key(prefix: str, exists_check) -> str:
|
|
for salt in range(5000):
|
|
key = generate_key(prefix, salt)
|
|
if not exists_check(key):
|
|
return key
|
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"failed_generate_{prefix.lower()}_key")
|
|
|
|
|
|
def _company_item(company) -> CompanyItem:
|
|
return CompanyItem(
|
|
id=company.id,
|
|
company_key=company.company_key,
|
|
display_name=company.display_name,
|
|
legal_name=company.legal_name,
|
|
provider_group_id=company.provider_group_id,
|
|
status=company.status,
|
|
)
|
|
|
|
|
|
def _site_item(site, company) -> SiteItem:
|
|
return SiteItem(
|
|
id=site.id,
|
|
site_key=site.site_key,
|
|
company_key=company.company_key,
|
|
company_display_name=company.display_name,
|
|
display_name=site.display_name,
|
|
domain=site.domain,
|
|
provider_group_id=site.provider_group_id,
|
|
status=site.status,
|
|
)
|
|
|
|
|
|
def _system_item(system) -> SystemItem:
|
|
return SystemItem(
|
|
id=system.id,
|
|
system_key=system.system_key,
|
|
name=system.name,
|
|
provider_client_id=system.provider_client_id,
|
|
status=system.status,
|
|
)
|
|
|
|
|
|
def _member_item(user) -> MemberItem:
|
|
return MemberItem(
|
|
id=user.id,
|
|
user_sub=user.user_sub,
|
|
provider_user_id=user.provider_user_id,
|
|
username=user.username,
|
|
email=user.email,
|
|
display_name=user.display_name,
|
|
is_active=user.is_active,
|
|
status=user.status,
|
|
)
|
|
|
|
|
|
def _company_group_name(display_name: str, company_key: str) -> str:
|
|
normalized = display_name.strip() if isinstance(display_name, str) else ""
|
|
if not normalized:
|
|
return company_key
|
|
return normalized
|
|
|
|
|
|
def _site_group_name(display_name: str, site_key: str) -> str:
|
|
normalized = display_name.strip() if isinstance(display_name, str) else ""
|
|
if not normalized:
|
|
return site_key
|
|
return normalized
|
|
|
|
|
|
@router.get("/companies", response_model=ListResponse)
|
|
def list_companies(
|
|
db: Session = Depends(get_db),
|
|
keyword: str | None = Query(default=None),
|
|
limit: int = Query(default=100, ge=1, le=500),
|
|
offset: int = Query(default=0, ge=0),
|
|
) -> ListResponse:
|
|
sync_from_provider(db)
|
|
repo = CompaniesRepository(db)
|
|
items, total = repo.list(keyword=keyword, limit=limit, offset=offset)
|
|
return ListResponse(items=[_company_item(i) for i in items], total=total, limit=limit, offset=offset)
|
|
|
|
|
|
@router.post("/companies", response_model=CompanyItem)
|
|
def create_company(payload: CompanyCreateRequest, db: Session = Depends(get_db)) -> CompanyItem:
|
|
repo = CompaniesRepository(db)
|
|
idp = ProviderAdminService(get_settings())
|
|
company_key = _generate_unique_key("CP", lambda key: repo.get_by_key(key) is not None)
|
|
group_name = _company_group_name(payload.display_name, company_key)
|
|
group = idp.ensure_group(
|
|
name=group_name,
|
|
attributes={
|
|
"member_entity_type": "company",
|
|
"company_key": company_key,
|
|
"display_name": payload.display_name,
|
|
"status": payload.status,
|
|
},
|
|
)
|
|
item = repo.create(
|
|
company_key=company_key,
|
|
display_name=payload.display_name,
|
|
legal_name=payload.legal_name,
|
|
provider_group_id=group.group_id,
|
|
status=payload.status,
|
|
)
|
|
return _company_item(item)
|
|
|
|
|
|
@router.patch("/companies/{company_key}", response_model=CompanyItem)
|
|
def update_company(company_key: str, payload: CompanyUpdateRequest, db: Session = Depends(get_db)) -> CompanyItem:
|
|
repo = CompaniesRepository(db)
|
|
item = repo.get_by_key(company_key)
|
|
if not item:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found")
|
|
idp = ProviderAdminService(get_settings())
|
|
resolved_display_name = payload.display_name if payload.display_name is not None else item.display_name
|
|
resolved_status = payload.status if payload.status is not None else item.status
|
|
resolved_group_id = payload.provider_group_id or item.provider_group_id
|
|
group_name = _company_group_name(resolved_display_name, company_key)
|
|
group = idp.ensure_group(
|
|
group_id=resolved_group_id,
|
|
name=group_name,
|
|
attributes={
|
|
"member_entity_type": "company",
|
|
"company_key": company_key,
|
|
"display_name": resolved_display_name,
|
|
"status": resolved_status,
|
|
},
|
|
)
|
|
item = repo.update(
|
|
item,
|
|
display_name=payload.display_name,
|
|
legal_name=payload.legal_name,
|
|
provider_group_id=group.group_id,
|
|
status=payload.status,
|
|
)
|
|
return _company_item(item)
|
|
|
|
|
|
@router.delete("/companies/{company_key}")
|
|
def delete_company(company_key: str, db: Session = Depends(get_db)) -> dict[str, str]:
|
|
repo = CompaniesRepository(db)
|
|
idp = ProviderAdminService(get_settings())
|
|
item = repo.get_by_key(company_key)
|
|
if not item:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found")
|
|
idp.delete_group(group_id=item.provider_group_id)
|
|
repo.delete(item)
|
|
return {"deleted": company_key}
|
|
|
|
|
|
@router.get("/companies/{company_key}/sites", response_model=CompanySitesResponse)
|
|
def list_company_sites(company_key: str, db: Session = Depends(get_db)) -> CompanySitesResponse:
|
|
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")
|
|
sites, _ = sites_repo.list(company_id=company.id, limit=2000, offset=0)
|
|
return CompanySitesResponse(company_key=company_key, sites=[_site_item(site, company) for site in sites])
|
|
|
|
|
|
@router.get("/sites", response_model=ListResponse)
|
|
def list_sites(
|
|
db: Session = Depends(get_db),
|
|
keyword: str | None = Query(default=None),
|
|
company_key: str | None = Query(default=None),
|
|
limit: int = Query(default=100, ge=1, le=500),
|
|
offset: int = Query(default=0, ge=0),
|
|
) -> ListResponse:
|
|
sync_from_provider(db)
|
|
companies_repo = CompaniesRepository(db)
|
|
sites_repo = SitesRepository(db)
|
|
company_id = None
|
|
if company_key:
|
|
company = companies_repo.get_by_key(company_key)
|
|
if not company:
|
|
return ListResponse(items=[], total=0, limit=limit, offset=offset)
|
|
company_id = company.id
|
|
|
|
companies, _ = companies_repo.list(limit=5000, offset=0)
|
|
company_map = {c.id: c for c in companies}
|
|
items, total = sites_repo.list(keyword=keyword, company_id=company_id, limit=limit, offset=offset)
|
|
response_items = [_site_item(i, company_map[i.company_id]) for i in items if i.company_id in company_map]
|
|
return ListResponse(items=response_items, total=total, limit=limit, offset=offset)
|
|
|
|
|
|
@router.post("/sites", response_model=SiteItem)
|
|
def create_site(payload: SiteCreateRequest, db: Session = Depends(get_db)) -> SiteItem:
|
|
companies_repo = CompaniesRepository(db)
|
|
sites_repo = SitesRepository(db)
|
|
idp = ProviderAdminService(get_settings())
|
|
company = companies_repo.get_by_key(payload.company_key)
|
|
if not company:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found")
|
|
|
|
site_key = _generate_unique_key("ST", lambda key: sites_repo.get_by_key(key) is not None)
|
|
group_name = _site_group_name(payload.display_name, site_key)
|
|
group = idp.ensure_group(
|
|
group_id=None,
|
|
name=group_name,
|
|
parent_group_id=company.provider_group_id,
|
|
attributes={
|
|
"member_entity_type": "site",
|
|
"site_key": site_key,
|
|
"company_key": company.company_key,
|
|
"display_name": payload.display_name,
|
|
"domain": payload.domain or "",
|
|
"status": payload.status,
|
|
},
|
|
)
|
|
item = sites_repo.create(
|
|
site_key=site_key,
|
|
company_id=company.id,
|
|
display_name=payload.display_name,
|
|
domain=payload.domain,
|
|
provider_group_id=group.group_id,
|
|
status=payload.status,
|
|
)
|
|
return _site_item(item, company)
|
|
|
|
|
|
@router.patch("/sites/{site_key}", response_model=SiteItem)
|
|
def update_site(site_key: str, payload: SiteUpdateRequest, db: Session = Depends(get_db)) -> SiteItem:
|
|
companies_repo = CompaniesRepository(db)
|
|
sites_repo = SitesRepository(db)
|
|
idp = ProviderAdminService(get_settings())
|
|
|
|
item = sites_repo.get_by_key(site_key)
|
|
if not item:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found")
|
|
|
|
target_company = companies_repo.get_by_id(item.company_id)
|
|
company_id = None
|
|
if payload.company_key:
|
|
target_company = companies_repo.get_by_key(payload.company_key)
|
|
if not target_company:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found")
|
|
company_id = target_company.id
|
|
if not target_company:
|
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="company_reference_missing")
|
|
|
|
resolved_display_name = payload.display_name if payload.display_name is not None else item.display_name
|
|
resolved_domain = payload.domain if payload.domain is not None else item.domain
|
|
resolved_status = payload.status if payload.status is not None else item.status
|
|
resolved_group_id = payload.provider_group_id or item.provider_group_id
|
|
group_name = _site_group_name(resolved_display_name, site_key)
|
|
group = idp.ensure_group(
|
|
group_id=resolved_group_id,
|
|
name=group_name,
|
|
parent_group_id=target_company.provider_group_id,
|
|
attributes={
|
|
"member_entity_type": "site",
|
|
"site_key": site_key,
|
|
"company_key": target_company.company_key,
|
|
"display_name": resolved_display_name,
|
|
"domain": resolved_domain or "",
|
|
"status": resolved_status,
|
|
},
|
|
)
|
|
|
|
item = sites_repo.update(
|
|
item,
|
|
company_id=company_id,
|
|
display_name=payload.display_name,
|
|
domain=payload.domain,
|
|
provider_group_id=group.group_id,
|
|
status=payload.status,
|
|
)
|
|
company = companies_repo.get_by_id(item.company_id)
|
|
if not company:
|
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="company_reference_missing")
|
|
return _site_item(item, company)
|
|
|
|
|
|
@router.delete("/sites/{site_key}")
|
|
def delete_site(site_key: str, db: Session = Depends(get_db)) -> dict[str, str]:
|
|
repo = SitesRepository(db)
|
|
idp = ProviderAdminService(get_settings())
|
|
item = repo.get_by_key(site_key)
|
|
if not item:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found")
|
|
idp.delete_group(group_id=item.provider_group_id)
|
|
repo.delete(item)
|
|
return {"deleted": site_key}
|
|
|
|
|
|
@router.get("/systems", response_model=ListResponse)
|
|
def list_systems(
|
|
db: Session = Depends(get_db),
|
|
keyword: str | None = Query(default=None),
|
|
status_filter: str | None = Query(default=None, alias="status"),
|
|
limit: int = Query(default=100, ge=1, le=500),
|
|
offset: int = Query(default=0, ge=0),
|
|
) -> ListResponse:
|
|
sync_from_provider(db)
|
|
repo = SystemsRepository(db)
|
|
items, total = repo.list(keyword=keyword, status=status_filter, limit=limit, offset=offset)
|
|
return ListResponse(items=[_system_item(i) for i in items], total=total, limit=limit, offset=offset)
|
|
|
|
|
|
@router.post("/systems", response_model=SystemItem)
|
|
def create_system(payload: SystemCreateRequest, db: Session = Depends(get_db)) -> SystemItem:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="system_manage_in_provider_only")
|
|
|
|
|
|
@router.patch("/systems/{system_key}", response_model=SystemItem)
|
|
def update_system(system_key: str, payload: SystemUpdateRequest, db: Session = Depends(get_db)) -> SystemItem:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="system_manage_in_provider_only")
|
|
|
|
|
|
@router.delete("/systems/{system_key}")
|
|
def delete_system(system_key: str, db: Session = Depends(get_db)) -> dict[str, str]:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="system_manage_in_provider_only")
|
|
|
|
|
|
@router.get("/roles", response_model=ListResponse)
|
|
def list_roles(
|
|
db: Session = Depends(get_db),
|
|
keyword: str | None = Query(default=None),
|
|
system_key: str | None = Query(default=None),
|
|
status_filter: str | None = Query(default=None, alias="status"),
|
|
limit: int = Query(default=100, ge=1, le=500),
|
|
offset: int = Query(default=0, ge=0),
|
|
) -> ListResponse:
|
|
sync_from_provider(db)
|
|
systems_repo = SystemsRepository(db)
|
|
roles_repo = RolesRepository(db)
|
|
|
|
system_id = None
|
|
system_map: dict[str, object] = {}
|
|
systems, _ = systems_repo.list(limit=5000, offset=0)
|
|
for system in systems:
|
|
system_map[system.id] = system
|
|
|
|
if system_key:
|
|
system = systems_repo.get_by_key(system_key)
|
|
if not system:
|
|
return ListResponse(items=[], total=0, limit=limit, offset=offset)
|
|
system_id = system.id
|
|
|
|
rows, total = roles_repo.list(keyword=keyword, system_id=system_id, status=status_filter, limit=limit, offset=offset)
|
|
items = [
|
|
RoleItem(
|
|
id=row.id,
|
|
role_key=row.role_key,
|
|
system_key=system_map[row.system_id].system_key,
|
|
system_name=system_map[row.system_id].name,
|
|
name=row.name,
|
|
provider_role_name=row.provider_role_name,
|
|
description=row.description,
|
|
status=row.status,
|
|
)
|
|
for row in rows
|
|
if row.system_id in system_map
|
|
]
|
|
return ListResponse(items=items, total=total, limit=limit, offset=offset)
|
|
|
|
|
|
@router.post("/roles", response_model=RoleItem)
|
|
def create_role(payload: RoleCreateRequest, db: Session = Depends(get_db)) -> RoleItem:
|
|
systems_repo = SystemsRepository(db)
|
|
roles_repo = RolesRepository(db)
|
|
idp = ProviderAdminService(get_settings())
|
|
|
|
system = systems_repo.get_by_key(payload.system_key)
|
|
if not system:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found")
|
|
if not system.provider_client_id:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="system_provider_client_id_missing")
|
|
|
|
idp.ensure_client_role(
|
|
provider_client_id=system.provider_client_id,
|
|
provider_role_name=payload.provider_role_name,
|
|
description=payload.description,
|
|
)
|
|
|
|
role_key = _generate_unique_key("RL", lambda key: roles_repo.get_by_key(key) is not None)
|
|
try:
|
|
row = roles_repo.create(
|
|
role_key=role_key,
|
|
system_id=system.id,
|
|
name=payload.name,
|
|
description=payload.description,
|
|
provider_role_name=payload.provider_role_name,
|
|
status=payload.status,
|
|
)
|
|
except IntegrityError:
|
|
db.rollback()
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="role_name_conflict")
|
|
|
|
return RoleItem(
|
|
id=row.id,
|
|
role_key=row.role_key,
|
|
system_key=system.system_key,
|
|
system_name=system.name,
|
|
name=row.name,
|
|
provider_role_name=row.provider_role_name,
|
|
description=row.description,
|
|
status=row.status,
|
|
)
|
|
|
|
|
|
@router.patch("/roles/{role_key}", response_model=RoleItem)
|
|
def update_role(role_key: str, payload: RoleUpdateRequest, db: Session = Depends(get_db)) -> RoleItem:
|
|
systems_repo = SystemsRepository(db)
|
|
roles_repo = RolesRepository(db)
|
|
idp = ProviderAdminService(get_settings())
|
|
|
|
role = roles_repo.get_by_key(role_key)
|
|
if not role:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="role_not_found")
|
|
|
|
old_system = systems_repo.get_by_id(role.system_id)
|
|
if not old_system:
|
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="system_reference_missing")
|
|
if not old_system.provider_client_id:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="system_provider_client_id_missing")
|
|
|
|
target_system = old_system
|
|
system_id = None
|
|
if payload.system_key:
|
|
system = systems_repo.get_by_key(payload.system_key)
|
|
if not system:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found")
|
|
system_id = system.id
|
|
target_system = system
|
|
if not target_system.provider_client_id:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="system_provider_client_id_missing")
|
|
|
|
next_provider_role_name = payload.provider_role_name if payload.provider_role_name is not None else role.provider_role_name
|
|
next_description = payload.description if payload.description is not None else role.description
|
|
|
|
if target_system.id != old_system.id:
|
|
idp.ensure_client_role(
|
|
provider_client_id=target_system.provider_client_id,
|
|
provider_role_name=next_provider_role_name,
|
|
description=next_description,
|
|
)
|
|
idp.delete_client_role(
|
|
provider_client_id=old_system.provider_client_id,
|
|
provider_role_name=role.provider_role_name,
|
|
)
|
|
else:
|
|
idp.update_client_role(
|
|
provider_client_id=target_system.provider_client_id,
|
|
old_provider_role_name=role.provider_role_name,
|
|
new_provider_role_name=next_provider_role_name,
|
|
description=next_description,
|
|
)
|
|
|
|
try:
|
|
role = roles_repo.update(
|
|
role,
|
|
system_id=system_id,
|
|
name=payload.name,
|
|
description=payload.description,
|
|
provider_role_name=payload.provider_role_name,
|
|
status=payload.status,
|
|
)
|
|
except IntegrityError:
|
|
db.rollback()
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="role_name_conflict")
|
|
|
|
system = systems_repo.get_by_id(role.system_id)
|
|
if not system:
|
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="system_reference_missing")
|
|
|
|
return RoleItem(
|
|
id=role.id,
|
|
role_key=role.role_key,
|
|
system_key=system.system_key,
|
|
system_name=system.name,
|
|
name=role.name,
|
|
provider_role_name=role.provider_role_name,
|
|
description=role.description,
|
|
status=role.status,
|
|
)
|
|
|
|
|
|
@router.delete("/roles/{role_key}")
|
|
def delete_role(role_key: str, db: Session = Depends(get_db)) -> dict[str, str]:
|
|
roles_repo = RolesRepository(db)
|
|
systems_repo = SystemsRepository(db)
|
|
idp = ProviderAdminService(get_settings())
|
|
|
|
role = roles_repo.get_by_key(role_key)
|
|
if not role:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="role_not_found")
|
|
system = systems_repo.get_by_id(role.system_id)
|
|
if not system:
|
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="system_reference_missing")
|
|
if not system.provider_client_id:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="system_provider_client_id_missing")
|
|
|
|
idp.delete_client_role(
|
|
provider_client_id=system.provider_client_id,
|
|
provider_role_name=role.provider_role_name,
|
|
)
|
|
roles_repo.delete(role)
|
|
return {"deleted": role_key}
|
|
|
|
|
|
@router.get("/systems/{system_key}/roles", response_model=SystemRolesResponse)
|
|
def list_system_roles(system_key: str, db: Session = Depends(get_db)) -> SystemRolesResponse:
|
|
systems_repo = SystemsRepository(db)
|
|
roles_repo = RolesRepository(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")
|
|
|
|
rows, _ = roles_repo.list(system_id=system.id, limit=5000, offset=0)
|
|
return SystemRolesResponse(
|
|
system_key=system_key,
|
|
roles=[
|
|
RoleItem(
|
|
id=row.id,
|
|
role_key=row.role_key,
|
|
system_key=system.system_key,
|
|
system_name=system.name,
|
|
name=row.name,
|
|
provider_role_name=row.provider_role_name,
|
|
description=row.description,
|
|
status=row.status,
|
|
)
|
|
for row in rows
|
|
],
|
|
)
|
|
|
|
|
|
@router.get("/sites/{site_key}/roles", response_model=SiteRolesResponse)
|
|
def list_site_roles(site_key: str, db: Session = Depends(get_db)) -> SiteRolesResponse:
|
|
sites_repo = SitesRepository(db)
|
|
site_roles_repo = SiteRolesRepository(db)
|
|
|
|
site = sites_repo.get_by_key(site_key)
|
|
if not site:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found")
|
|
|
|
rows = site_roles_repo.list_site_role_rows(site.id)
|
|
return SiteRolesResponse(
|
|
site_key=site_key,
|
|
roles=[
|
|
SiteRoleItem(
|
|
id=site_role.id,
|
|
role_key=role.role_key,
|
|
role_name=role.name,
|
|
system_key=system.system_key,
|
|
system_name=system.name,
|
|
)
|
|
for site_role, role, system in rows
|
|
],
|
|
)
|
|
|
|
|
|
@router.put("/sites/{site_key}/roles", response_model=SiteRolesResponse)
|
|
def assign_site_roles(site_key: str, payload: SiteRoleAssignRequest, db: Session = Depends(get_db)) -> SiteRolesResponse:
|
|
sites_repo = SitesRepository(db)
|
|
roles_repo = RolesRepository(db)
|
|
site_roles_repo = SiteRolesRepository(db)
|
|
|
|
site = sites_repo.get_by_key(site_key)
|
|
if not site:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found")
|
|
|
|
role_ids: list[str] = []
|
|
for role_key in list(dict.fromkeys(payload.role_keys)):
|
|
role = roles_repo.get_by_key(role_key)
|
|
if not role:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"role_not_found:{role_key}")
|
|
role_ids.append(role.id)
|
|
|
|
site_roles_repo.set_site_roles(site_id=site.id, role_ids=role_ids)
|
|
return list_site_roles(site_key=site_key, db=db)
|
|
|
|
|
|
@router.get("/roles/{role_key}/sites", response_model=RoleSitesResponse)
|
|
def list_role_sites(role_key: str, db: Session = Depends(get_db)) -> RoleSitesResponse:
|
|
roles_repo = RolesRepository(db)
|
|
companies_repo = CompaniesRepository(db)
|
|
site_roles_repo = SiteRolesRepository(db)
|
|
|
|
role = roles_repo.get_by_key(role_key)
|
|
if not role:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="role_not_found")
|
|
|
|
rows = site_roles_repo.list_role_site_rows(role.id)
|
|
company_cache = {}
|
|
result = []
|
|
for _, site in rows:
|
|
company = company_cache.get(site.company_id)
|
|
if company is None:
|
|
company = companies_repo.get_by_id(site.company_id)
|
|
company_cache[site.company_id] = company
|
|
if not company:
|
|
continue
|
|
result.append(
|
|
UserSiteItem(
|
|
id=site.id,
|
|
site_key=site.site_key,
|
|
site_display_name=site.display_name,
|
|
company_key=company.company_key,
|
|
company_display_name=company.display_name,
|
|
)
|
|
)
|
|
|
|
return RoleSitesResponse(role_key=role_key, sites=result)
|
|
|
|
|
|
@router.get("/members", response_model=ListResponse)
|
|
def list_members(
|
|
db: Session = Depends(get_db),
|
|
keyword: str | None = Query(default=None),
|
|
is_active: bool | None = Query(default=None),
|
|
limit: int = Query(default=100, ge=1, le=500),
|
|
offset: int = Query(default=0, ge=0),
|
|
) -> ListResponse:
|
|
sync_from_provider(db)
|
|
repo = UsersRepository(db)
|
|
rows, total = repo.list(keyword=keyword, is_active=is_active, limit=limit, offset=offset)
|
|
return ListResponse(items=[_member_item(r) for r in rows], total=total, limit=limit, offset=offset)
|
|
|
|
|
|
@router.post("/members", response_model=MemberItem)
|
|
def create_member(payload: MemberUpsertRequest, db: Session = Depends(get_db)) -> MemberItem:
|
|
users_repo = UsersRepository(db)
|
|
|
|
resolved_sub = payload.user_sub
|
|
provider_user_id: str | None = None
|
|
if payload.sync_to_idp:
|
|
if not payload.email:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="email_required_for_idp_sync")
|
|
idp = ProviderAdminService(get_settings())
|
|
sync_result = idp.ensure_user(
|
|
sub=payload.user_sub,
|
|
email=payload.email,
|
|
username=payload.username,
|
|
display_name=payload.display_name,
|
|
is_active=payload.is_active,
|
|
)
|
|
provider_user_id = sync_result.user_id
|
|
resolved_sub = resolved_sub or sync_result.user_sub
|
|
|
|
if not resolved_sub:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="user_sub_required")
|
|
|
|
user = 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=payload.status,
|
|
provider_user_id=provider_user_id,
|
|
)
|
|
return _member_item(user)
|
|
|
|
|
|
@router.patch("/members/{user_sub}", response_model=MemberItem)
|
|
def update_member(user_sub: str, payload: MemberUpdateRequest, db: Session = Depends(get_db)) -> MemberItem:
|
|
users_repo = UsersRepository(db)
|
|
user = users_repo.get_by_sub(user_sub)
|
|
if not user:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found")
|
|
|
|
next_username = payload.username if payload.username is not None else user.username
|
|
next_email = payload.email if payload.email is not None else user.email
|
|
next_display_name = payload.display_name if payload.display_name is not None else user.display_name
|
|
next_is_active = payload.is_active if payload.is_active is not None else user.is_active
|
|
|
|
if payload.sync_to_idp:
|
|
if not next_email:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="email_required_for_idp_sync")
|
|
idp = ProviderAdminService(get_settings())
|
|
sync_result = idp.ensure_user(
|
|
sub=user.user_sub,
|
|
email=next_email,
|
|
username=next_username,
|
|
display_name=next_display_name,
|
|
is_active=next_is_active,
|
|
provider_user_id=user.provider_user_id,
|
|
)
|
|
user.provider_user_id = sync_result.user_id
|
|
|
|
updated = users_repo.update_member(
|
|
user,
|
|
username=payload.username,
|
|
email=payload.email,
|
|
display_name=payload.display_name,
|
|
is_active=payload.is_active,
|
|
status=payload.status,
|
|
)
|
|
return _member_item(updated)
|
|
|
|
|
|
@router.delete("/members/{user_sub}")
|
|
def delete_member(user_sub: str, db: Session = Depends(get_db), sync_to_idp: bool = Query(default=True)) -> dict[str, str]:
|
|
users_repo = UsersRepository(db)
|
|
user = users_repo.get_by_sub(user_sub)
|
|
if not user:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found")
|
|
|
|
if sync_to_idp:
|
|
idp = ProviderAdminService(get_settings())
|
|
idp.delete_user(provider_user_id=user.provider_user_id, email=user.email, username=user.username)
|
|
|
|
users_repo.delete(user)
|
|
return {"deleted": user_sub}
|
|
|
|
|
|
@router.post("/members/{user_sub}/password/reset", response_model=MemberPasswordResetResponse)
|
|
def reset_member_password(user_sub: str, db: Session = Depends(get_db)) -> MemberPasswordResetResponse:
|
|
users_repo = UsersRepository(db)
|
|
user = users_repo.get_by_sub(user_sub)
|
|
if not user:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found")
|
|
|
|
idp = ProviderAdminService(get_settings())
|
|
result = idp.reset_password(provider_user_id=user.provider_user_id, email=user.email, username=user.username)
|
|
if user.provider_user_id != result.user_id:
|
|
user.provider_user_id = result.user_id
|
|
db.commit()
|
|
|
|
return MemberPasswordResetResponse(user_sub=user_sub, temporary_password=result.temporary_password)
|
|
|
|
|
|
@router.get("/sites/{site_key}/members", response_model=SiteMembersResponse)
|
|
def list_site_members(site_key: str, db: Session = Depends(get_db)) -> SiteMembersResponse:
|
|
sites_repo = SitesRepository(db)
|
|
user_sites_repo = UserSitesRepository(db)
|
|
|
|
site = sites_repo.get_by_key(site_key)
|
|
if not site:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found")
|
|
|
|
rows = user_sites_repo.list_site_member_rows(site.id)
|
|
return SiteMembersResponse(site_key=site_key, members=[_member_item(user) for _, user in rows])
|
|
|
|
|
|
@router.get("/members/{user_sub}/sites", response_model=UserSitesResponse)
|
|
def list_member_sites(user_sub: str, db: Session = Depends(get_db)) -> UserSitesResponse:
|
|
users_repo = UsersRepository(db)
|
|
user_sites_repo = UserSitesRepository(db)
|
|
|
|
user = users_repo.get_by_sub(user_sub)
|
|
if not user:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found")
|
|
|
|
rows = user_sites_repo.list_user_site_rows(user.id)
|
|
items = [
|
|
UserSiteItem(
|
|
id=user_site.id,
|
|
site_key=site.site_key,
|
|
site_display_name=site.display_name,
|
|
company_key=company.company_key,
|
|
company_display_name=company.display_name,
|
|
)
|
|
for user_site, site, company in rows
|
|
]
|
|
return UserSitesResponse(user_sub=user_sub, sites=items)
|
|
|
|
|
|
@router.put("/members/{user_sub}/sites", response_model=UserSitesResponse)
|
|
def set_member_sites(user_sub: str, payload: UserSiteAssignRequest, db: Session = Depends(get_db)) -> UserSitesResponse:
|
|
users_repo = UsersRepository(db)
|
|
sites_repo = SitesRepository(db)
|
|
user_sites_repo = UserSitesRepository(db)
|
|
|
|
user = users_repo.get_by_sub(user_sub)
|
|
if not user:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found")
|
|
|
|
site_ids: list[str] = []
|
|
for site_key in list(dict.fromkeys(payload.site_keys)):
|
|
site = sites_repo.get_by_key(site_key)
|
|
if not site:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"site_not_found:{site_key}")
|
|
site_ids.append(site.id)
|
|
|
|
user_sites_repo.set_user_sites(user_id=user.id, site_ids=site_ids)
|
|
return list_member_sites(user_sub=user_sub, db=db)
|
|
|
|
|
|
@router.get("/members/{user_sub}/roles", response_model=UserEffectiveRolesResponse)
|
|
def list_member_effective_roles(user_sub: str, db: Session = Depends(get_db)) -> UserEffectiveRolesResponse:
|
|
users_repo = UsersRepository(db)
|
|
user_sites_repo = UserSitesRepository(db)
|
|
|
|
user = users_repo.get_by_sub(user_sub)
|
|
if not user:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found")
|
|
|
|
rows = user_sites_repo.get_user_role_rows(user.id)
|
|
items = [
|
|
UserEffectiveRoleItem(
|
|
site_key=site.site_key,
|
|
site_display_name=site.display_name,
|
|
company_key=company.company_key,
|
|
company_display_name=company.display_name,
|
|
system_key=system.system_key,
|
|
system_name=system.name,
|
|
role_key=role.role_key,
|
|
role_name=role.name,
|
|
provider_role_name=role.provider_role_name,
|
|
)
|
|
for site, company, role, system in rows
|
|
]
|
|
return UserEffectiveRolesResponse(user_sub=user_sub, roles=items)
|
|
|
|
|
|
@router.get("/api-clients", response_model=ListResponse)
|
|
def list_api_clients(
|
|
db: Session = Depends(get_db),
|
|
keyword: str | None = Query(default=None),
|
|
status_filter: str | None = Query(default=None, alias="status"),
|
|
limit: int = Query(default=100, ge=1, le=500),
|
|
offset: int = Query(default=0, ge=0),
|
|
) -> ListResponse:
|
|
repo = ApiClientsRepository(db)
|
|
items, total = repo.list(keyword=keyword, status=status_filter, limit=limit, offset=offset)
|
|
return ListResponse(
|
|
items=[ApiClientItem.model_validate(i, from_attributes=True) for i in items],
|
|
total=total,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
|
|
|
|
@router.post("/sync/from-provider")
|
|
@router.post("/sync/from-keycloak", include_in_schema=False)
|
|
def sync_catalog_from_provider(db: Session = Depends(get_db), force: bool = Query(default=True)) -> dict[str, int]:
|
|
return sync_from_provider(db, force=force)
|
|
|
|
|
|
@router.post("/sync/provider-group-names")
|
|
@router.post("/sync/keycloak-group-names", include_in_schema=False)
|
|
def sync_provider_group_names(db: Session = Depends(get_db)) -> dict[str, int]:
|
|
companies_repo = CompaniesRepository(db)
|
|
sites_repo = SitesRepository(db)
|
|
idp = ProviderAdminService(get_settings())
|
|
|
|
companies, _ = companies_repo.list(limit=5000, offset=0)
|
|
company_count = 0
|
|
for company in companies:
|
|
if not company.provider_group_id:
|
|
continue
|
|
group_name = _company_group_name(company.display_name, company.company_key)
|
|
idp.ensure_group(
|
|
group_id=company.provider_group_id,
|
|
name=group_name,
|
|
attributes={
|
|
"member_entity_type": "company",
|
|
"company_key": company.company_key,
|
|
"display_name": company.display_name,
|
|
"status": company.status,
|
|
},
|
|
)
|
|
company_count += 1
|
|
|
|
sites, _ = sites_repo.list(limit=5000, offset=0)
|
|
site_count = 0
|
|
company_map = {company.id: company for company in companies}
|
|
for site in sites:
|
|
if not site.provider_group_id:
|
|
continue
|
|
company = company_map.get(site.company_id)
|
|
if not company:
|
|
continue
|
|
group_name = _site_group_name(site.display_name, site.site_key)
|
|
idp.ensure_group(
|
|
group_id=site.provider_group_id,
|
|
name=group_name,
|
|
parent_group_id=company.provider_group_id,
|
|
attributes={
|
|
"member_entity_type": "site",
|
|
"site_key": site.site_key,
|
|
"company_key": company.company_key,
|
|
"display_name": site.display_name,
|
|
"domain": site.domain or "",
|
|
"status": site.status,
|
|
},
|
|
)
|
|
site_count += 1
|
|
|
|
return {"companies_updated": company_count, "sites_updated": site_count}
|
|
|
|
|
|
@router.post("/api-clients", response_model=ApiClientCreateResponse)
|
|
def create_api_client(payload: ApiClientCreateRequest, db: Session = Depends(get_db)) -> ApiClientCreateResponse:
|
|
repo = ApiClientsRepository(db)
|
|
|
|
client_key = payload.client_key or _generate_unique_key("AK", lambda key: repo.get_by_key(key) is not None)
|
|
if repo.get_by_key(client_key):
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="client_key_exists")
|
|
|
|
plain_key = secrets.token_urlsafe(32)
|
|
api_key_hash = hash_api_key(plain_key)
|
|
|
|
item = repo.create(
|
|
client_key=client_key,
|
|
name=payload.name,
|
|
status=payload.status,
|
|
api_key_hash=api_key_hash,
|
|
allowed_origins=payload.allowed_origins,
|
|
allowed_ips=payload.allowed_ips,
|
|
allowed_paths=payload.allowed_paths,
|
|
rate_limit_per_min=payload.rate_limit_per_min,
|
|
expires_at=payload.expires_at,
|
|
)
|
|
return ApiClientCreateResponse(item=ApiClientItem.model_validate(item, from_attributes=True), api_key=plain_key)
|
|
|
|
|
|
@router.patch("/api-clients/{client_key}", response_model=ApiClientItem)
|
|
def update_api_client(client_key: str, payload: ApiClientUpdateRequest, db: Session = Depends(get_db)) -> ApiClientItem:
|
|
repo = ApiClientsRepository(db)
|
|
item = repo.get_by_key(client_key)
|
|
if not item:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="api_client_not_found")
|
|
|
|
repo.update(
|
|
item,
|
|
name=payload.name,
|
|
status=payload.status,
|
|
allowed_origins=payload.allowed_origins,
|
|
allowed_ips=payload.allowed_ips,
|
|
allowed_paths=payload.allowed_paths,
|
|
rate_limit_per_min=payload.rate_limit_per_min,
|
|
expires_at=payload.expires_at,
|
|
)
|
|
return ApiClientItem.model_validate(item, from_attributes=True)
|
|
|
|
|
|
@router.post("/api-clients/{client_key}/rotate-key", response_model=ApiClientRotateKeyResponse)
|
|
def rotate_api_client_key(client_key: str, db: Session = Depends(get_db)) -> ApiClientRotateKeyResponse:
|
|
repo = ApiClientsRepository(db)
|
|
item = repo.get_by_key(client_key)
|
|
if not item:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="api_client_not_found")
|
|
|
|
plain_key = secrets.token_urlsafe(32)
|
|
repo.update(item, api_key_hash=hash_api_key(plain_key))
|
|
return ApiClientRotateKeyResponse(client_key=client_key, api_key=plain_key)
|
|
|
|
|
|
@router.delete("/api-clients/{client_key}")
|
|
def delete_api_client(client_key: str, db: Session = Depends(get_db)) -> dict[str, str]:
|
|
repo = ApiClientsRepository(db)
|
|
item = repo.get_by_key(client_key)
|
|
if not item:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="api_client_not_found")
|
|
repo.delete(item)
|
|
return {"deleted": client_key}
|