diff --git a/app/api/admin_catalog.py b/app/api/admin_catalog.py index 8823057..d795953 100644 --- a/app/api/admin_catalog.py +++ b/app/api/admin_catalog.py @@ -1,9 +1,12 @@ +import secrets from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import select from sqlalchemy.orm import Session from app.core.keygen import generate_key from app.core.config import get_settings from app.db.session import get_db +from app.models.api_client import ApiClient from app.repositories.companies_repo import CompaniesRepository from app.repositories.modules_repo import ModulesRepository from app.repositories.permission_groups_repo import PermissionGroupsRepository @@ -38,8 +41,16 @@ from app.schemas.catalog import ( SystemItem, SystemUpdateRequest, ) +from app.schemas.api_clients import ( + ApiClientCreateRequest, + ApiClientCreateResponse, + ApiClientItem, + ApiClientRotateKeyResponse, + ApiClientUpdateRequest, +) from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest 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 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") +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( *, authentik_sub: str | None, @@ -627,6 +659,106 @@ def set_member_permission_groups( 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") def list_permission_groups( db: Session = Depends(get_db), diff --git a/app/schemas/api_clients.py b/app/schemas/api_clients.py new file mode 100644 index 0000000..66a7961 --- /dev/null +++ b/app/schemas/api_clients.py @@ -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 diff --git a/app/security/api_client_auth.py b/app/security/api_client_auth.py index 4854ae4..c51b6f6 100644 --- a/app/security/api_client_auth.py +++ b/app/security/api_client_auth.py @@ -13,6 +13,10 @@ from app.models.api_client import ApiClient 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: # Support sha256: for bootstrap, and bcrypt/argon2 for production. if stored_hash.startswith("sha256:"):