feat(admin): add api client management UI and backend CRUD/rotate endpoints

This commit is contained in:
Chris
2026-03-30 23:28:27 +08:00
parent 75f9f28588
commit 3fe5ce4ce7
3 changed files with 185 additions and 0 deletions

View File

@@ -1,9 +1,12 @@
import secrets
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.keygen import generate_key from app.core.keygen import generate_key
from app.core.config import get_settings from app.core.config import get_settings
from app.db.session import get_db from app.db.session import get_db
from app.models.api_client import ApiClient
from app.repositories.companies_repo import CompaniesRepository from app.repositories.companies_repo import CompaniesRepository
from app.repositories.modules_repo import ModulesRepository from app.repositories.modules_repo import ModulesRepository
from app.repositories.permission_groups_repo import PermissionGroupsRepository from app.repositories.permission_groups_repo import PermissionGroupsRepository
@@ -38,8 +41,16 @@ from app.schemas.catalog import (
SystemItem, SystemItem,
SystemUpdateRequest, SystemUpdateRequest,
) )
from app.schemas.api_clients import (
ApiClientCreateRequest,
ApiClientCreateResponse,
ApiClientItem,
ApiClientRotateKeyResponse,
ApiClientUpdateRequest,
)
from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest
from app.security.admin_guard import require_admin_principal from app.security.admin_guard import require_admin_principal
from app.security.api_client_auth import hash_api_key
from app.services.authentik_admin_service import AuthentikAdminService from app.services.authentik_admin_service import AuthentikAdminService
router = APIRouter( router = APIRouter(
@@ -99,6 +110,27 @@ def _generate_unique_key(prefix: str, exists_fn) -> str:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"failed_to_generate_{prefix.lower()}_key") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"failed_to_generate_{prefix.lower()}_key")
def _serialize_api_client(item: ApiClient) -> ApiClientItem:
return ApiClientItem(
id=item.id,
client_key=item.client_key,
name=item.name,
status=item.status,
allowed_origins=item.allowed_origins or [],
allowed_ips=item.allowed_ips or [],
allowed_paths=item.allowed_paths or [],
rate_limit_per_min=item.rate_limit_per_min,
expires_at=item.expires_at,
last_used_at=item.last_used_at,
created_at=item.created_at,
updated_at=item.updated_at,
)
def _generate_api_key() -> str:
return secrets.token_urlsafe(36)
def _sync_member_to_authentik( def _sync_member_to_authentik(
*, *,
authentik_sub: str | None, authentik_sub: str | None,
@@ -627,6 +659,106 @@ def set_member_permission_groups(
return MemberPermissionGroupsResponse(authentik_sub=authentik_sub, group_keys=unique_group_keys) return MemberPermissionGroupsResponse(authentik_sub=authentik_sub, group_keys=unique_group_keys)
@router.get("/api-clients")
def list_api_clients(
db: Session = Depends(get_db),
keyword: str | None = Query(default=None),
limit: int = Query(default=200, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> dict:
stmt = select(ApiClient)
count_stmt = select(ApiClient)
if keyword:
pattern = f"%{keyword}%"
filter_cond = (ApiClient.client_key.ilike(pattern)) | (ApiClient.name.ilike(pattern))
stmt = stmt.where(filter_cond)
count_stmt = count_stmt.where(filter_cond)
items = list(db.scalars(stmt.order_by(ApiClient.created_at.desc()).limit(limit).offset(offset)).all())
total = len(list(db.scalars(count_stmt)))
return {
"items": [_serialize_api_client(item).model_dump() for item in items],
"total": total,
"limit": limit,
"offset": offset,
}
@router.post("/api-clients", response_model=ApiClientCreateResponse)
def create_api_client(
payload: ApiClientCreateRequest,
db: Session = Depends(get_db),
) -> ApiClientCreateResponse:
status_value = payload.status.strip().lower()
if status_value not in {"active", "inactive"}:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_status")
client_key = payload.client_key or _generate_unique_key(
"AC", lambda value: db.scalar(select(ApiClient).where(ApiClient.client_key == value)) is not None
)
exists = db.scalar(select(ApiClient).where(ApiClient.client_key == client_key))
if exists:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="client_key_already_exists")
api_key = _generate_api_key()
row = ApiClient(
client_key=client_key,
name=payload.name,
status=status_value,
api_key_hash=hash_api_key(api_key),
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,
)
db.add(row)
db.commit()
db.refresh(row)
return ApiClientCreateResponse(item=_serialize_api_client(row), api_key=api_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:
row = db.scalar(select(ApiClient).where(ApiClient.client_key == client_key))
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="api_client_not_found")
if payload.name is not None:
row.name = payload.name
if payload.status is not None:
next_status = payload.status.strip().lower()
if next_status not in {"active", "inactive"}:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_status")
row.status = next_status
if payload.allowed_origins is not None:
row.allowed_origins = payload.allowed_origins
if payload.allowed_ips is not None:
row.allowed_ips = payload.allowed_ips
if payload.allowed_paths is not None:
row.allowed_paths = payload.allowed_paths
row.rate_limit_per_min = payload.rate_limit_per_min
row.expires_at = payload.expires_at
db.commit()
db.refresh(row)
return _serialize_api_client(row)
@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:
row = db.scalar(select(ApiClient).where(ApiClient.client_key == client_key))
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="api_client_not_found")
api_key = _generate_api_key()
row.api_key_hash = hash_api_key(api_key)
db.commit()
return ApiClientRotateKeyResponse(client_key=row.client_key, api_key=api_key)
@router.get("/permission-groups") @router.get("/permission-groups")
def list_permission_groups( def list_permission_groups(
db: Session = Depends(get_db), db: Session = Depends(get_db),

View File

@@ -0,0 +1,49 @@
from datetime import datetime
from pydantic import BaseModel, Field
class ApiClientItem(BaseModel):
id: str
client_key: str
name: str
status: str
allowed_origins: list[str] = Field(default_factory=list)
allowed_ips: list[str] = Field(default_factory=list)
allowed_paths: list[str] = Field(default_factory=list)
rate_limit_per_min: int | None = None
expires_at: datetime | None = None
last_used_at: datetime | None = None
created_at: datetime
updated_at: datetime
class ApiClientCreateRequest(BaseModel):
name: str
client_key: str | None = None
status: str = "active"
allowed_origins: list[str] = Field(default_factory=list)
allowed_ips: list[str] = Field(default_factory=list)
allowed_paths: list[str] = Field(default_factory=list)
rate_limit_per_min: int | None = None
expires_at: datetime | None = None
class ApiClientUpdateRequest(BaseModel):
name: str | None = None
status: str | None = None
allowed_origins: list[str] | None = None
allowed_ips: list[str] | None = None
allowed_paths: list[str] | None = None
rate_limit_per_min: int | None = None
expires_at: datetime | None = None
class ApiClientCreateResponse(BaseModel):
item: ApiClientItem
api_key: str
class ApiClientRotateKeyResponse(BaseModel):
client_key: str
api_key: str

View File

@@ -13,6 +13,10 @@ from app.models.api_client import ApiClient
pwd_context = CryptContext(schemes=["argon2", "bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["argon2", "bcrypt"], deprecated="auto")
def hash_api_key(plain_key: str) -> str:
return pwd_context.hash(plain_key)
def _verify_api_key(plain_key: str, stored_hash: str) -> bool: def _verify_api_key(plain_key: str, stored_hash: str) -> bool:
# Support sha256:<hex> for bootstrap, and bcrypt/argon2 for production. # Support sha256:<hex> for bootstrap, and bcrypt/argon2 for production.
if stored_hash.startswith("sha256:"): if stored_hash.startswith("sha256:"):