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.services.runtime_cache import runtime_cache 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 _resolve_role_code(value: str | None, fallback_name: str) -> str: candidate = (value or "").strip() if candidate: return candidate return fallback_name.strip() def _company_item(company) -> CompanyItem: return CompanyItem( id=company.id, company_key=company.company_key, name=company.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.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, 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(name: str, company_key: str) -> str: normalized = name.strip() if isinstance(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 def _sync_site_client_roles( *, idp: ProviderAdminService, site, site_role_rows, provider_client_ids: set[str], ) -> None: if not site.provider_group_id: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"site_provider_group_missing:{site.site_key}") role_names_by_client: dict[str, list[str]] = {} for _, role, system in site_role_rows: provider_client_id = str(system.name or "").strip() if not provider_client_id: continue role_names_by_client.setdefault(provider_client_id, []).append(role.name) for provider_client_id in sorted(provider_client_ids): idp.set_group_client_roles( group_id=site.provider_group_id, provider_client_id=provider_client_id, role_names=role_names_by_client.get(provider_client_id, []), ) @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: cache_key = f"admin:companies:{keyword or ''}:{limit}:{offset}" cached = runtime_cache.get(cache_key) if isinstance(cached, ListResponse): return cached repo = CompaniesRepository(db) items, total = repo.list(keyword=keyword, limit=limit, offset=offset) result = ListResponse(items=[_company_item(i) for i in items], total=total, limit=limit, offset=offset) runtime_cache.set(cache_key, result, ttl_seconds=20) return result @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.name, company_key) group = idp.ensure_group( name=group_name, attributes={ "member_entity_type": "company", "company_key": company_key, "name": payload.name, "status": payload.status, }, ) item = repo.create( company_key=company_key, name=payload.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_name = payload.name if payload.name is not None else item.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_name, company_key) group = idp.ensure_group( group_id=resolved_group_id, name=group_name, attributes={ "member_entity_type": "company", "company_key": company_key, "name": resolved_name, "status": resolved_status, }, ) item = repo.update( item, name=payload.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: cache_key = f"admin:sites:{keyword or ''}:{company_key or ''}:{limit}:{offset}" cached = runtime_cache.get(cache_key) if isinstance(cached, ListResponse): return cached 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] result = ListResponse(items=response_items, total=total, limit=limit, offset=offset) runtime_cache.set(cache_key, result, ttl_seconds=20) return result @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: cache_key = f"admin:systems:{keyword or ''}:{status_filter or ''}:{limit}:{offset}" cached = runtime_cache.get(cache_key) if isinstance(cached, ListResponse): return cached repo = SystemsRepository(db) items, total = repo.list(keyword=keyword, status=status_filter, limit=limit, offset=offset) result = ListResponse(items=[_system_item(i) for i in items], total=total, limit=limit, offset=offset) runtime_cache.set(cache_key, result, ttl_seconds=20) return result @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: cache_key = f"admin:roles:{keyword or ''}:{system_key or ''}:{status_filter or ''}:{limit}:{offset}" cached = runtime_cache.get(cache_key) if isinstance(cached, ListResponse): return cached 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, role_code=row.role_code, system_key=system_map[row.system_id].system_key, system_name=system_map[row.system_id].name, name=row.name, description=row.description, status=row.status, ) for row in rows if row.system_id in system_map ] result = ListResponse(items=items, total=total, limit=limit, offset=offset) runtime_cache.set(cache_key, result, ttl_seconds=20) return result @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") idp.ensure_client_role( provider_client_id=system.name, provider_role_name=payload.name, description=payload.description, ) role_key = _generate_unique_key("RL", lambda key: roles_repo.get_by_key(key) is not None) role_code = _resolve_role_code(payload.role_code, payload.name) try: row = roles_repo.create( role_key=role_key, role_code=role_code, system_id=system.id, name=payload.name, description=payload.description, 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, role_code=row.role_code, system_key=system.system_key, system_name=system.name, name=row.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") 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 next_provider_role_name = payload.name if payload.name is not None else role.name next_role_code = _resolve_role_code(payload.role_code, next_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.name, provider_role_name=next_provider_role_name, description=next_description, ) idp.delete_client_role( provider_client_id=old_system.name, provider_role_name=role.name, ) else: idp.update_client_role( provider_client_id=target_system.name, old_provider_role_name=role.name, new_provider_role_name=next_provider_role_name, description=next_description, ) try: role = roles_repo.update( role, system_id=system_id, role_code=next_role_code, name=payload.name, description=payload.description, 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, role_code=role.role_code, system_key=system.system_key, system_name=system.name, name=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") idp.delete_client_role( provider_client_id=system.name, provider_role_name=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, role_code=row.role_code, system_key=system.system_key, system_name=system.name, name=row.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_code=role.role_code, 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) idp = ProviderAdminService(get_settings()) site = sites_repo.get_by_key(site_key) if not site: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found") current_rows = site_roles_repo.list_site_role_rows(site.id) current_client_ids = {str(system.name or "").strip() for _, _, system in current_rows if str(system.name or "").strip()} 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, commit=False) updated_rows = site_roles_repo.list_site_role_rows(site.id) updated_client_ids = {str(system.name or "").strip() for _, _, system in updated_rows if str(system.name or "").strip()} clients_to_sync = current_client_ids | updated_client_ids try: _sync_site_client_roles( idp=idp, site=site, site_role_rows=updated_rows, provider_client_ids=clients_to_sync, ) db.commit() except Exception: db.rollback() raise 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.name, ) ) return RoleSitesResponse(role_key=role_key, sites=result) @router.put("/roles/{role_key}/sites", response_model=RoleSitesResponse) def assign_role_sites(role_key: str, payload: UserSiteAssignRequest, db: Session = Depends(get_db)) -> RoleSitesResponse: roles_repo = RolesRepository(db) sites_repo = SitesRepository(db) systems_repo = SystemsRepository(db) site_roles_repo = SiteRolesRepository(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_404_NOT_FOUND, detail="system_not_found") provider_client_id = str(system.name or "").strip() if not provider_client_id: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"provider_client_id_missing:{system.system_key}") previous_rows = site_roles_repo.list_role_site_rows(role.id) previous_site_ids = {site.id for _, site in previous_rows} 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) site_roles_repo.set_role_sites(role_id=role.id, site_ids=site_ids, commit=False) affected_site_ids = previous_site_ids | set(site_ids) try: for site_id in affected_site_ids: site = sites_repo.get_by_id(site_id) if not site: continue site_rows = site_roles_repo.list_site_role_rows(site.id) _sync_site_client_roles( idp=idp, site=site, site_role_rows=site_rows, provider_client_ids={provider_client_id}, ) db.commit() except Exception: db.rollback() raise return list_role_sites(role_key=role_key, db=db) @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: is_active_key = "" if is_active is None else ("1" if is_active else "0") cache_key = f"admin:members:{keyword or ''}:{is_active_key}:{limit}:{offset}" cached = runtime_cache.get(cache_key) if isinstance(cached, ListResponse): return cached repo = UsersRepository(db) rows, total = repo.list(keyword=keyword, is_active=is_active, limit=limit, offset=offset) result = ListResponse(items=[_member_item(r) for r in rows], total=total, limit=limit, offset=offset) runtime_cache.set(cache_key, result, ttl_seconds=20) return result @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.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.name, system_key=system.system_key, system_name=system.name, role_key=role.role_key, role_code=role.role_code, role_name=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: cache_key = f"admin:api_clients:{keyword or ''}:{status_filter or ''}:{limit}:{offset}" cached = runtime_cache.get(cache_key) if isinstance(cached, ListResponse): return cached repo = ApiClientsRepository(db) items, total = repo.list(keyword=keyword, status=status_filter, limit=limit, offset=offset) result = ListResponse( items=[ApiClientItem.model_validate(i, from_attributes=True) for i in items], total=total, limit=limit, offset=offset, ) runtime_cache.set(cache_key, result, ttl_seconds=20) return result @router.post("/sync/from-provider") 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.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, "name": company.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}