feat(sync): keycloak as source-of-truth with auto catalog sync and token refresh
This commit is contained in:
@@ -52,6 +52,7 @@ from app.schemas.catalog import (
|
||||
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 KeycloakAdminService
|
||||
from app.services.idp_catalog_sync import sync_from_keycloak
|
||||
from app.core.config import get_settings
|
||||
|
||||
router = APIRouter(
|
||||
@@ -123,6 +124,7 @@ def list_companies(
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> ListResponse:
|
||||
sync_from_keycloak(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)
|
||||
@@ -131,8 +133,24 @@ def list_companies(
|
||||
@router.post("/companies", response_model=CompanyItem)
|
||||
def create_company(payload: CompanyCreateRequest, db: Session = Depends(get_db)) -> CompanyItem:
|
||||
repo = CompaniesRepository(db)
|
||||
idp = KeycloakAdminService(get_settings())
|
||||
company_key = _generate_unique_key("CP", lambda key: repo.get_by_key(key) is not None)
|
||||
item = repo.create(company_key=company_key, display_name=payload.display_name, legal_name=payload.legal_name, status=payload.status)
|
||||
group = idp.ensure_group(
|
||||
name=company_key,
|
||||
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,
|
||||
idp_group_id=group.group_id,
|
||||
status=payload.status,
|
||||
)
|
||||
return _company_item(item)
|
||||
|
||||
|
||||
@@ -142,11 +160,25 @@ def update_company(company_key: str, payload: CompanyUpdateRequest, db: Session
|
||||
item = repo.get_by_key(company_key)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found")
|
||||
idp = KeycloakAdminService(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.idp_group_id or item.idp_group_id
|
||||
group = idp.ensure_group(
|
||||
group_id=resolved_group_id,
|
||||
name=company_key,
|
||||
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,
|
||||
idp_group_id=payload.idp_group_id,
|
||||
idp_group_id=group.group_id,
|
||||
status=payload.status,
|
||||
)
|
||||
return _company_item(item)
|
||||
@@ -155,9 +187,11 @@ def update_company(company_key: str, payload: CompanyUpdateRequest, db: Session
|
||||
@router.delete("/companies/{company_key}")
|
||||
def delete_company(company_key: str, db: Session = Depends(get_db)) -> dict[str, str]:
|
||||
repo = CompaniesRepository(db)
|
||||
idp = KeycloakAdminService(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.idp_group_id)
|
||||
repo.delete(item)
|
||||
return {"deleted": company_key}
|
||||
|
||||
@@ -181,6 +215,7 @@ def list_sites(
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> ListResponse:
|
||||
sync_from_keycloak(db)
|
||||
companies_repo = CompaniesRepository(db)
|
||||
sites_repo = SitesRepository(db)
|
||||
company_id = None
|
||||
@@ -201,16 +236,31 @@ def list_sites(
|
||||
def create_site(payload: SiteCreateRequest, db: Session = Depends(get_db)) -> SiteItem:
|
||||
companies_repo = CompaniesRepository(db)
|
||||
sites_repo = SitesRepository(db)
|
||||
idp = KeycloakAdminService(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 = idp.ensure_group(
|
||||
group_id=None,
|
||||
name=site_key,
|
||||
parent_group_id=company.idp_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,
|
||||
idp_group_id=group.group_id,
|
||||
status=payload.status,
|
||||
)
|
||||
return _site_item(item, company)
|
||||
@@ -220,24 +270,46 @@ def create_site(payload: SiteCreateRequest, db: Session = Depends(get_db)) -> Si
|
||||
def update_site(site_key: str, payload: SiteUpdateRequest, db: Session = Depends(get_db)) -> SiteItem:
|
||||
companies_repo = CompaniesRepository(db)
|
||||
sites_repo = SitesRepository(db)
|
||||
idp = KeycloakAdminService(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:
|
||||
company = companies_repo.get_by_key(payload.company_key)
|
||||
if not company:
|
||||
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 = company.id
|
||||
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.idp_group_id or item.idp_group_id
|
||||
group = idp.ensure_group(
|
||||
group_id=resolved_group_id,
|
||||
name=site_key,
|
||||
parent_group_id=target_company.idp_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,
|
||||
idp_group_id=payload.idp_group_id,
|
||||
idp_group_id=group.group_id,
|
||||
status=payload.status,
|
||||
)
|
||||
company = companies_repo.get_by_id(item.company_id)
|
||||
@@ -249,9 +321,11 @@ def update_site(site_key: str, payload: SiteUpdateRequest, db: Session = Depends
|
||||
@router.delete("/sites/{site_key}")
|
||||
def delete_site(site_key: str, db: Session = Depends(get_db)) -> dict[str, str]:
|
||||
repo = SitesRepository(db)
|
||||
idp = KeycloakAdminService(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.idp_group_id)
|
||||
repo.delete(item)
|
||||
return {"deleted": site_key}
|
||||
|
||||
@@ -264,6 +338,7 @@ def list_systems(
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> ListResponse:
|
||||
sync_from_keycloak(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)
|
||||
@@ -271,30 +346,17 @@ def list_systems(
|
||||
|
||||
@router.post("/systems", response_model=SystemItem)
|
||||
def create_system(payload: SystemCreateRequest, db: Session = Depends(get_db)) -> SystemItem:
|
||||
repo = SystemsRepository(db)
|
||||
system_key = _generate_unique_key("SY", lambda key: repo.get_by_key(key) is not None)
|
||||
item = repo.create(system_key=system_key, name=payload.name, idp_client_id=payload.idp_client_id, status=payload.status)
|
||||
return _system_item(item)
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="system_manage_in_keycloak_only")
|
||||
|
||||
|
||||
@router.patch("/systems/{system_key}", response_model=SystemItem)
|
||||
def update_system(system_key: str, payload: SystemUpdateRequest, db: Session = Depends(get_db)) -> SystemItem:
|
||||
repo = SystemsRepository(db)
|
||||
item = repo.get_by_key(system_key)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found")
|
||||
item = repo.update(item, name=payload.name, idp_client_id=payload.idp_client_id, status=payload.status)
|
||||
return _system_item(item)
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="system_manage_in_keycloak_only")
|
||||
|
||||
|
||||
@router.delete("/systems/{system_key}")
|
||||
def delete_system(system_key: str, db: Session = Depends(get_db)) -> dict[str, str]:
|
||||
repo = SystemsRepository(db)
|
||||
item = repo.get_by_key(system_key)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found")
|
||||
repo.delete(item)
|
||||
return {"deleted": system_key}
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="system_manage_in_keycloak_only")
|
||||
|
||||
|
||||
@router.get("/roles", response_model=ListResponse)
|
||||
@@ -306,6 +368,7 @@ def list_roles(
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> ListResponse:
|
||||
sync_from_keycloak(db)
|
||||
systems_repo = SystemsRepository(db)
|
||||
roles_repo = RolesRepository(db)
|
||||
|
||||
@@ -544,6 +607,7 @@ def list_members(
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> ListResponse:
|
||||
sync_from_keycloak(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)
|
||||
@@ -754,6 +818,11 @@ def list_api_clients(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync/from-keycloak")
|
||||
def sync_catalog_from_keycloak(db: Session = Depends(get_db), force: bool = Query(default=True)) -> dict[str, int]:
|
||||
return sync_from_keycloak(db, force=force)
|
||||
|
||||
|
||||
@router.post("/api-clients", response_model=ApiClientCreateResponse)
|
||||
def create_api_client(payload: ApiClientCreateRequest, db: Session = Depends(get_db)) -> ApiClientCreateResponse:
|
||||
repo = ApiClientsRepository(db)
|
||||
|
||||
@@ -5,7 +5,7 @@ import httpx
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.schemas.login import LoginRequest, LoginResponse, OIDCAuthUrlResponse, OIDCCodeExchangeRequest
|
||||
from app.schemas.login import LoginRequest, LoginResponse, OIDCAuthUrlResponse, OIDCCodeExchangeRequest, RefreshTokenRequest
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -45,12 +45,7 @@ def login(payload: LoginRequest) -> LoginResponse:
|
||||
token = data.get("access_token")
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_missing_access_token")
|
||||
return LoginResponse(
|
||||
access_token=token,
|
||||
token_type=data.get("token_type", "Bearer"),
|
||||
expires_in=data.get("expires_in"),
|
||||
scope=data.get("scope"),
|
||||
)
|
||||
return _build_login_response(data)
|
||||
|
||||
|
||||
@router.get("/oidc/url", response_model=OIDCAuthUrlResponse)
|
||||
@@ -123,9 +118,50 @@ def exchange_oidc_code(payload: OIDCCodeExchangeRequest) -> LoginResponse:
|
||||
token = data.get("access_token")
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_missing_access_token")
|
||||
return _build_login_response(data)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=LoginResponse)
|
||||
def refresh_access_token(payload: RefreshTokenRequest) -> LoginResponse:
|
||||
settings = get_settings()
|
||||
client_id = settings.idp_client_id or settings.idp_audience
|
||||
if not settings.idp_base_url or not client_id or not settings.idp_client_secret:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="idp_login_not_configured")
|
||||
|
||||
form = {
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": client_id,
|
||||
"client_secret": settings.idp_client_secret,
|
||||
"refresh_token": payload.refresh_token,
|
||||
}
|
||||
try:
|
||||
resp = httpx.post(
|
||||
settings.idp_token_endpoint,
|
||||
data=form,
|
||||
timeout=10,
|
||||
verify=settings.idp_verify_tls,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_unreachable") from exc
|
||||
|
||||
if resp.status_code >= 400:
|
||||
logger.warning("idp refresh-token grant failed: status=%s body=%s", resp.status_code, resp.text)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_refresh_token")
|
||||
|
||||
data = resp.json()
|
||||
token = data.get("access_token")
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_missing_access_token")
|
||||
return _build_login_response(data)
|
||||
|
||||
|
||||
def _build_login_response(data: dict) -> LoginResponse:
|
||||
return LoginResponse(
|
||||
access_token=token,
|
||||
access_token=data.get("access_token", ""),
|
||||
refresh_token=data.get("refresh_token"),
|
||||
token_type=data.get("token_type", "Bearer"),
|
||||
expires_in=data.get("expires_in"),
|
||||
refresh_expires_in=data.get("refresh_expires_in"),
|
||||
scope=data.get("scope"),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user