Skip to content

Commit 6dc37a6

Browse files
kanghengliuaarora79
authored andcommitted
add management API
1 parent e2e87f3 commit 6dc37a6

File tree

5 files changed

+610
-2
lines changed

5 files changed

+610
-2
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ dependencies = [
3535
"langchain-anthropic>=0.3.17",
3636
"matplotlib>=3.10.5",
3737
"psutil>=6.1.0",
38+
"email-validator>=2.2.0",
3839
"aiohttp>=3.8.0",
3940
"rich>=13.0.0",
4041
"requests>=2.31.0",

registry/api/management_routes.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from typing import Annotated
5+
6+
from fastapi import APIRouter, Depends, HTTPException, status
7+
8+
from ..auth.dependencies import nginx_proxied_auth
9+
from ..schemas.management import (
10+
HumanUserRequest,
11+
KeycloakUserSummary,
12+
M2MAccountRequest,
13+
UserDeleteResponse,
14+
UserListResponse,
15+
)
16+
from ..utils.keycloak_manager import (
17+
KeycloakAdminError,
18+
create_human_user_account,
19+
create_service_account_client,
20+
delete_keycloak_user,
21+
list_keycloak_groups,
22+
list_keycloak_users,
23+
)
24+
25+
26+
logger = logging.getLogger(__name__)
27+
28+
router = APIRouter(prefix="/management", tags=["Management API"])
29+
30+
31+
def _translate_keycloak_error(exc: KeycloakAdminError) -> HTTPException:
32+
"""Map Keycloak admin errors to HTTP responses."""
33+
detail = str(exc)
34+
lowered = detail.lower()
35+
status_code = status.HTTP_502_BAD_GATEWAY
36+
if any(keyword in lowered for keyword in ("already exists", "not found", "provided")):
37+
status_code = status.HTTP_400_BAD_REQUEST
38+
return HTTPException(status_code=status_code, detail=detail)
39+
40+
41+
def _require_admin(user_context: dict) -> None:
42+
if not user_context.get("is_admin"):
43+
raise HTTPException(
44+
status_code=status.HTTP_403_FORBIDDEN,
45+
detail="Administrator permissions are required for this operation",
46+
)
47+
48+
49+
@router.get("/iam/users", response_model=UserListResponse)
50+
async def management_list_users(
51+
search: str | None = None,
52+
limit: int = 500,
53+
user_context: Annotated[dict, Depends(nginx_proxied_auth)] = None,
54+
):
55+
"""List Keycloak users for administrators."""
56+
_require_admin(user_context)
57+
try:
58+
raw_users = await list_keycloak_users(search=search, max_results=limit)
59+
except KeycloakAdminError as exc:
60+
raise _translate_keycloak_error(exc) from exc
61+
62+
summaries = [
63+
KeycloakUserSummary(
64+
id=user.get("id", ""),
65+
username=user.get("username", ""),
66+
email=user.get("email"),
67+
firstName=user.get("firstName"),
68+
lastName=user.get("lastName"),
69+
enabled=user.get("enabled", True),
70+
groups=user.get("groups", []),
71+
)
72+
for user in raw_users
73+
]
74+
return UserListResponse(users=summaries, total=len(summaries))
75+
76+
77+
@router.post("/iam/users/m2m")
78+
async def management_create_m2m_user(
79+
payload: M2MAccountRequest,
80+
user_context: Annotated[dict, Depends(nginx_proxied_auth)] = None,
81+
):
82+
"""Create a service account client and return its credentials."""
83+
_require_admin(user_context)
84+
try:
85+
result = await create_service_account_client(
86+
client_id=payload.name,
87+
group_names=payload.groups,
88+
description=payload.description,
89+
)
90+
except KeycloakAdminError as exc:
91+
raise _translate_keycloak_error(exc) from exc
92+
return result
93+
94+
95+
@router.post("/iam/users/human")
96+
async def management_create_human_user(
97+
payload: HumanUserRequest,
98+
user_context: Annotated[dict, Depends(nginx_proxied_auth)] = None,
99+
):
100+
"""Create a Keycloak human user and assign groups."""
101+
_require_admin(user_context)
102+
try:
103+
user_doc = await create_human_user_account(
104+
username=payload.username,
105+
email=payload.email,
106+
first_name=payload.first_name,
107+
last_name=payload.last_name,
108+
groups=payload.groups,
109+
password=payload.password,
110+
)
111+
except KeycloakAdminError as exc:
112+
raise _translate_keycloak_error(exc) from exc
113+
114+
return KeycloakUserSummary(
115+
id=user_doc.get("id", ""),
116+
username=user_doc.get("username", payload.username),
117+
email=user_doc.get("email"),
118+
firstName=user_doc.get("firstName"),
119+
lastName=user_doc.get("lastName"),
120+
enabled=user_doc.get("enabled", True),
121+
groups=user_doc.get("groups", payload.groups),
122+
)
123+
124+
125+
@router.delete("/iam/users/{username}", response_model=UserDeleteResponse)
126+
async def management_delete_user(
127+
username: str,
128+
user_context: Annotated[dict, Depends(nginx_proxied_auth)] = None,
129+
):
130+
"""Delete a Keycloak user by username."""
131+
_require_admin(user_context)
132+
try:
133+
await delete_keycloak_user(username)
134+
except KeycloakAdminError as exc:
135+
raise _translate_keycloak_error(exc) from exc
136+
return UserDeleteResponse(username=username)
137+
138+
139+
@router.get("/iam/groups")
140+
async def management_list_keycloak_groups(
141+
user_context: Annotated[dict, Depends(nginx_proxied_auth)] = None,
142+
):
143+
"""List raw Keycloak IAM groups (without scopes)."""
144+
_require_admin(user_context)
145+
try:
146+
return await list_keycloak_groups()
147+
except Exception as exc: # noqa: BLE001 - surface upstream failure
148+
logger.error("Failed to list Keycloak groups: %s", exc)
149+
raise HTTPException(
150+
status_code=status.HTTP_502_BAD_GATEWAY,
151+
detail="Unable to list Keycloak groups",
152+
) from exc

registry/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from registry.api.wellknown_routes import router as wellknown_router
2525
from registry.api.registry_routes import router as registry_router
2626
from registry.api.agent_routes import router as agent_router
27+
from registry.api.management_routes import router as management_router
2728
from registry.health.routes import router as health_router
2829

2930
# Import auth dependencies
@@ -201,6 +202,7 @@ async def lifespan(app: FastAPI):
201202
app.include_router(auth_router, prefix="/api/auth", tags=["Authentication"])
202203
app.include_router(servers_router, prefix="/api", tags=["Server Management"])
203204
app.include_router(agent_router, prefix="/api", tags=["Agent Management"])
205+
app.include_router(management_router, prefix="/api")
204206
app.include_router(search_router, prefix="/api/search", tags=["Semantic Search"])
205207
app.include_router(health_router, prefix="/api/health", tags=["Health Monitoring"])
206208

registry/schemas/management.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from __future__ import annotations
2+
3+
from typing import List, Optional
4+
5+
from pydantic import BaseModel, EmailStr, Field
6+
7+
8+
class M2MAccountRequest(BaseModel):
9+
"""Payload for creating a Keycloak service account client."""
10+
11+
name: str = Field(..., min_length=1)
12+
groups: List[str] = Field(..., min_length=1)
13+
description: Optional[str] = None
14+
15+
16+
class HumanUserRequest(BaseModel):
17+
"""Payload for creating a Keycloak human user."""
18+
19+
username: str = Field(..., min_length=1)
20+
email: EmailStr
21+
first_name: str = Field(..., min_length=1, alias="firstname")
22+
last_name: str = Field(..., min_length=1, alias="lastname")
23+
groups: List[str] = Field(..., min_length=1)
24+
password: Optional[str] = Field(
25+
None, description="Initial password (optional, generated elsewhere)"
26+
)
27+
28+
model_config = {"populate_by_name": True}
29+
30+
31+
class UserDeleteResponse(BaseModel):
32+
"""Standard response returned when a Keycloak user is deleted."""
33+
34+
username: str
35+
deleted: bool = True
36+
37+
38+
class KeycloakUserSummary(BaseModel):
39+
"""Subset of Keycloak user information exposed through the API."""
40+
41+
id: str
42+
username: str
43+
email: Optional[str] = None
44+
firstName: Optional[str] = None
45+
lastName: Optional[str] = None
46+
enabled: bool = True
47+
groups: List[str] = Field(default_factory=list)
48+
49+
50+
class UserListResponse(BaseModel):
51+
"""Wrapper for list users endpoint."""
52+
53+
users: List[KeycloakUserSummary] = Field(default_factory=list)
54+
total: int

0 commit comments

Comments
 (0)