From 3a5f9e9001ace2cccf48f76c77c952cd603ceca2 Mon Sep 17 00:00:00 2001 From: jigoong Date: Thu, 4 Jun 2026 18:22:22 +0700 Subject: [PATCH] feat: replace SQLAdmin with Keycloak-protected API management page - Disable SQLAdmin basic auth (comment out mount_admin, statics, redirect) - Add /api-management page (Keycloak admin role required) - Add admin_api_keys.py: REST endpoints for list/create clients and keys - Add api_management.html: manage API clients, keys, permissions with copy-once key display - Update index.html: API Management link -> /api-management - Update auth middleware: add /api-management and /admin/users to PROTECTED_PATHS - Add CHANGES-2026-06-04.md dev notes --- 03-apiservice/CHANGES-2026-06-04.md | 138 +++++ 03-apiservice/app/admin.py | 9 +- 03-apiservice/app/main.py | 15 +- .../app/middleware/auth_middleware.py | 8 +- 03-apiservice/app/routes/admin_api_keys.py | 141 +++++ 03-apiservice/app/routes/pages.py | 16 + .../app/templates/api_management.html | 491 ++++++++++++++++++ 03-apiservice/app/templates/index.html | 9 + 8 files changed, 807 insertions(+), 20 deletions(-) create mode 100644 03-apiservice/CHANGES-2026-06-04.md create mode 100644 03-apiservice/app/routes/admin_api_keys.py create mode 100644 03-apiservice/app/templates/api_management.html diff --git a/03-apiservice/CHANGES-2026-06-04.md b/03-apiservice/CHANGES-2026-06-04.md new file mode 100644 index 0000000..3ea462c --- /dev/null +++ b/03-apiservice/CHANGES-2026-06-04.md @@ -0,0 +1,138 @@ +# Changes — 2026-06-04 + +## สรุป + +วันนี้เพิ่ม 2 feature ใหม่ใน `03-apiservice`: +1. **VOC Data endpoint** — รับข้อมูลข้อร้องเรียน (Voice of Customer) จาก programmatic client +2. **API Management page** — หน้าจัดการ API clients/keys ด้วย Keycloak admin auth แทน SQLAdmin basic auth เดิม + +--- + +## Feature 1 — VOC Data Endpoint + +### ไฟล์ที่แก้ไข + +| ไฟล์ | การเปลี่ยนแปลง | +|------|----------------| +| `app/api/v1/schemas.py` | เพิ่ม `VocDataIn` schema | +| `app/db/models.py` | เพิ่ม `RawVocData` model (table: `rawdata.raw_voc_data`) | +| `app/api/v1/routes.py` | เพิ่ม `POST /api/v1/voc-data` endpoint | + +### Endpoint + +``` +POST /api/v1/voc-data +Authorization: Bearer (permission required: voc.data:write) +Content-Type: application/json +``` + +**Request body** (batch array): +```json +[ + { + "date": "2026-06-04", + "topic": "บริการพยาบาล", + "sub_topic": "ความรวดเร็ว", + "level": "3", + "depart_id": "OPD01", + "dep_name": "ผู้ป่วยนอก" + } +] +``` + +**Response:** +```json +{ + "inserted": 1, + "rowcount": 1, + "supabase": { "success": true, "result": {...}, "error": null } +} +``` + +### Database Table + +```sql +CREATE TABLE rawdata.raw_voc_data ( + id BIGSERIAL PRIMARY KEY, + date DATE NOT NULL, + topic VARCHAR(200) NOT NULL, + sub_topic VARCHAR(200) NOT NULL, + level VARCHAR(50) NOT NULL, + depart_id VARCHAR(50) NOT NULL, + dep_name VARCHAR(200), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +- `id` เป็น BIGSERIAL auto-increment — server generate เอง, client ไม่ต้องส่ง +- ทุก record ที่ส่งมาจะ INSERT เพิ่มเสมอ (ไม่มี upsert/on_conflict) +- สร้าง table อัตโนมัติจาก `Base.metadata.create_all()` ตอน startup +- Dual-write ไปยัง Supabase (`raw_voc_data` table) เหมือน endpoint อื่นๆ + +### Deploy status + +✅ Deploy แล้ว บน server .8 — table `rawdata.raw_voc_data` ถูกสร้างแล้ว + +--- + +## Feature 2 — API Management Page (Keycloak auth) + +### ปัญหาเดิม + +SQLAdmin panel (`/admin/`) ใช้ basic auth (username/password จาก `.env`) แยกต่างหากจาก Keycloak ซึ่งเป็น auth system หลักของระบบ + +### การแก้ไข + +ปิด SQLAdmin และสร้างหน้าจัดการ API keys ใหม่ที่ใช้ Keycloak admin auth แทน + +### ไฟล์ที่แก้ไข + +| ไฟล์ | การเปลี่ยนแปลง | +|------|----------------| +| `app/main.py` | ลบ SQLAdmin imports/mounts (`sqladmin`, statics, `mount_admin`) | +| `app/admin.py` | Comment out `/admin` redirect route | +| `app/middleware/auth_middleware.py` | เพิ่ม `/api-management`, `/admin/users` ใน `PROTECTED_PATHS` | +| `app/routes/pages.py` | เพิ่ม `GET /api-management` route | +| `app/templates/index.html` | เปลี่ยน link จาก `/admin/` → `/api-management` | + +### ไฟล์ใหม่ + +| ไฟล์ | คำอธิบาย | +|------|---------| +| `app/routes/admin_api_keys.py` | REST endpoints สำหรับจัดการ API clients/keys (Keycloak admin auth) | +| `app/templates/api_management.html` | หน้าจัดการ API clients และ keys | + +### Endpoints ใหม่ (ทั้งหมดต้องการ Keycloak admin role) + +| Method | Path | คำอธิบาย | +|--------|------|---------| +| GET | `/admin/api-keys/clients` | List ทุก API client พร้อม nested keys | +| POST | `/admin/api-keys/clients` | สร้าง API client ใหม่ | +| POST | `/admin/api-keys/generate` | สร้าง API key (คืน plaintext ครั้งเดียว) | +| POST | `/admin/api-keys/{id}/regenerate` | Regenerate key (คืน plaintext ครั้งเดียว) | +| PATCH | `/admin/api-keys/{id}/toggle` | Toggle is_active | + +### Features ของหน้า `/api-management` + +- Stats: จำนวน clients, total keys, active keys +- สร้าง API Client พร้อมกำหนดชื่อ +- สร้าง API Key พร้อมกำหนด permissions เป็น JSON array +- แสดง plaintext key ใน modal ครั้งเดียวหลัง generate/regenerate พร้อมปุ่ม Copy +- Activate/Deactivate key +- เข้าถึงได้ที่ `https://ai.sriphat.com/apiservice/api-management` (ต้อง login ด้วย Keycloak admin account) + +### Deploy status + +⏳ ยังไม่ deploy — รอ review ก่อน + +--- + +## Blockers / สิ่งที่ค้างอยู่ + +- **Airflow API token** ยังไม่ได้ config → Finance upload จะ set status=error หลัง upload สำเร็จ (ไฟล์อัปขึ้น MinIO ได้ แต่ trigger DAG ไม่ได้) +- **VOC API key** — ยังต้องสร้าง ApiClient + ApiKey ที่มี permission `voc.data:write` สำหรับ client ที่จะส่งข้อมูล (ทำได้หลัง deploy Feature 2) + +--- + +## 🧠 Decision & Lesson +_(เขียนเอง)_ diff --git a/03-apiservice/app/admin.py b/03-apiservice/app/admin.py index 344c780..46b7ea7 100644 --- a/03-apiservice/app/admin.py +++ b/03-apiservice/app/admin.py @@ -91,10 +91,11 @@ def mount_admin(app): admin.add_view(ApiClientAdmin) admin.add_view(ApiKeyAdmin) - @app.get("/admin") - async def _admin_redirect(request: Request): - root_path = request.scope.get("root_path") or "" - return RedirectResponse(url=f"{root_path}/admin/") + # SQLAdmin /admin route disabled — replaced by Keycloak-protected /api-management page + # @app.get("/admin") + # async def _admin_redirect(request: Request): + # root_path = request.scope.get("root_path") or "" + # return RedirectResponse(url=f"{root_path}/admin/") @app.post("/admin/api-keys/generate") async def _admin_generate_api_key( diff --git a/03-apiservice/app/main.py b/03-apiservice/app/main.py index bd49e54..b70c03f 100644 --- a/03-apiservice/app/main.py +++ b/03-apiservice/app/main.py @@ -1,20 +1,16 @@ from contextlib import asynccontextmanager import logging -import os from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles from starlette.datastructures import Headers from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.sessions import SessionMiddleware -import sqladmin - -from app.admin import mount_admin from app.api.v1.routes import router as v1_router from app.routes.pages import router as pages_router from app.routes.auth import router as auth_router from app.routes.admin_users import router as admin_users_router +from app.routes.admin_api_keys import router as admin_api_keys_router from app.middleware.auth_middleware import WebAuthenticationMiddleware from app.core.config import settings from app.db.init_db import init_db @@ -26,7 +22,6 @@ logging.basicConfig( ) logging.getLogger("uvicorn.error").setLevel(logging.DEBUG) logging.getLogger("uvicorn.access").setLevel(logging.INFO) -logging.getLogger("sqladmin").setLevel(logging.DEBUG) logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) @@ -68,9 +63,6 @@ async def lifespan(_: FastAPI): yield -sqladmin_dir = os.path.dirname(sqladmin.__file__) -statics_path = os.path.join(sqladmin_dir, "statics") - app = FastAPI(title=settings.APP_NAME, root_path=settings.ROOT_PATH, lifespan=lifespan) # Add exception handler to log all errors with traceback @@ -105,7 +97,4 @@ app.include_router(v1_router) # API endpoints - use API Key auth app.include_router(pages_router) # Web pages - use Keycloak auth app.include_router(auth_router) # Authentication routes app.include_router(admin_users_router) # Admin user management API - -app.mount("/admin/statics", StaticFiles(directory=statics_path), name="admin_statics") -app.mount("/apiservice/admin/statics", StaticFiles(directory=statics_path), name="proxy_admin_statics") -mount_admin(app) +app.include_router(admin_api_keys_router) # API key management - use Keycloak admin auth diff --git a/03-apiservice/app/middleware/auth_middleware.py b/03-apiservice/app/middleware/auth_middleware.py index bfbbbea..bae6389 100644 --- a/03-apiservice/app/middleware/auth_middleware.py +++ b/03-apiservice/app/middleware/auth_middleware.py @@ -34,14 +34,16 @@ class WebAuthenticationMiddleware(BaseHTTPMiddleware): "/docs", "/redoc", "/openapi.json", - "/data-management" + "/data-management", + "/api-management", + "/admin/users", ] - + # Routes that are excluded from user authentication EXCLUDED_PATHS = [ "/auth", # Authentication endpoints "/api/v1", # API endpoints (use API Key) - "/admin", # SQLAdmin (has own auth) + "/admin", # Admin API endpoints (use require_role dependency) ] async def dispatch(self, request: Request, call_next): diff --git a/03-apiservice/app/routes/admin_api_keys.py b/03-apiservice/app/routes/admin_api_keys.py new file mode 100644 index 0000000..0c375e0 --- /dev/null +++ b/03-apiservice/app/routes/admin_api_keys.py @@ -0,0 +1,141 @@ +""" +API Client and API Key management endpoints (Admin only) +Uses Keycloak admin role authentication — same pattern as admin_users.py +""" +from typing import List +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.orm import Session +from pydantic import BaseModel +from datetime import datetime + +from app.db.session import get_db +from app.db.models import ApiClient, ApiKey +from app.security.permissions import require_role, Roles +from app.security.api_key import generate_api_key, hash_api_key, encrypt_api_key, get_prefix +import logging + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/api-keys", tags=["admin-api-keys"]) + + +class ApiKeySchema(BaseModel): + id: int + name: str | None = None + key_prefix: str + permissions: list + is_active: bool + created_at: datetime + + class Config: + from_attributes = True + + +class ApiClientSchema(BaseModel): + id: int + name: str + is_active: bool + api_keys: List[ApiKeySchema] = [] + + class Config: + from_attributes = True + + +class ApiClientCreateSchema(BaseModel): + name: str + + +class ApiKeyCreateSchema(BaseModel): + client_id: int + name: str | None = None + permissions: list[str] = [] + + +@router.get("/clients", response_model=List[ApiClientSchema]) +async def list_clients( + db: Session = Depends(get_db), + current_user: dict = Depends(require_role(Roles.ADMIN)), +): + """List all API clients with their keys (Admin only)""" + return db.query(ApiClient).order_by(ApiClient.id).all() + + +@router.post("/clients", response_model=ApiClientSchema) +async def create_client( + data: ApiClientCreateSchema, + db: Session = Depends(get_db), + current_user: dict = Depends(require_role(Roles.ADMIN)), +): + """Create a new API client (Admin only)""" + existing = db.query(ApiClient).filter(ApiClient.name == data.name).first() + if existing: + raise HTTPException(status_code=400, detail="Client name already exists") + client = ApiClient(name=data.name, is_active=True) + db.add(client) + db.commit() + db.refresh(client) + return client + + +@router.post("/generate") +async def generate_key( + data: ApiKeyCreateSchema, + db: Session = Depends(get_db), + current_user: dict = Depends(require_role(Roles.ADMIN)), +): + """Generate a new API key for a client (Admin only). Returns plaintext key once.""" + client = db.get(ApiClient, data.client_id) + if not client: + raise HTTPException(status_code=404, detail="Client not found") + + plain_key = generate_api_key() + api_key = ApiKey( + client_id=data.client_id, + name=data.name, + key_prefix=get_prefix(plain_key), + key_hash=hash_api_key(plain_key), + encrypted_key=encrypt_api_key(plain_key), + permissions=data.permissions, + is_active=True, + ) + db.add(api_key) + db.commit() + db.refresh(api_key) + logger.info(f"Admin {current_user.get('username')} created API key {api_key.id} for client {client.name}") + return {"key_id": api_key.id, "api_key": plain_key, "key_prefix": api_key.key_prefix, "permissions": data.permissions} + + +@router.post("/{key_id}/regenerate") +async def regenerate_key( + key_id: int, + db: Session = Depends(get_db), + current_user: dict = Depends(require_role(Roles.ADMIN)), +): + """Regenerate an API key — preserves permissions, returns new plaintext once (Admin only)""" + api_key = db.get(ApiKey, key_id) + if not api_key: + raise HTTPException(status_code=404, detail="API Key not found") + + plain_key = generate_api_key() + api_key.key_prefix = get_prefix(plain_key) + api_key.key_hash = hash_api_key(plain_key) + api_key.encrypted_key = encrypt_api_key(plain_key) + db.commit() + db.refresh(api_key) + logger.info(f"Admin {current_user.get('username')} regenerated API key {key_id}") + return {"key_id": api_key.id, "api_key": plain_key, "key_prefix": api_key.key_prefix, "permissions": api_key.permissions} + + +@router.patch("/{key_id}/toggle") +async def toggle_key( + key_id: int, + db: Session = Depends(get_db), + current_user: dict = Depends(require_role(Roles.ADMIN)), +): + """Toggle API key active/inactive (Admin only)""" + api_key = db.get(ApiKey, key_id) + if not api_key: + raise HTTPException(status_code=404, detail="API Key not found") + api_key.is_active = not api_key.is_active + db.commit() + return {"key_id": key_id, "is_active": api_key.is_active} diff --git a/03-apiservice/app/routes/pages.py b/03-apiservice/app/routes/pages.py index c6810c2..a030fef 100644 --- a/03-apiservice/app/routes/pages.py +++ b/03-apiservice/app/routes/pages.py @@ -95,6 +95,22 @@ async def admin_users_page( ) +@router.get("/api-management", response_class=HTMLResponse) +async def api_management_page( + request: Request, + current_user: dict = Depends(require_role(Roles.ADMIN)) +): + """API Key management page - Admin only""" + return templates.TemplateResponse( + "api_management.html", + { + "request": request, + "root_path": settings.ROOT_PATH, + "user": current_user + } + ) + + @router.post("/data-management/finance/upload") async def upload_finance_file( request: Request, diff --git a/03-apiservice/app/templates/api_management.html b/03-apiservice/app/templates/api_management.html new file mode 100644 index 0000000..f072126 --- /dev/null +++ b/03-apiservice/app/templates/api_management.html @@ -0,0 +1,491 @@ + + + + + + API Management - Admin + + + +
+ ← Back to Dashboard + +
+

🔑 API Management

+ {% if user %} + + {% endif %} +
+ +
+ +
+
+
-
+
API Clients
+
+
+
-
+
Total Keys
+
+
+
-
+
Active Keys
+
+
+ +
+

API Clients

+ +
+ +
+
Loading...
+
+
+ + + + + + + + + + + + + diff --git a/03-apiservice/app/templates/index.html b/03-apiservice/app/templates/index.html index aa7b1f5..37440d1 100644 --- a/03-apiservice/app/templates/index.html +++ b/03-apiservice/app/templates/index.html @@ -275,6 +275,15 @@

Manage users and roles (Admin only)

{% endif %} + + + {% if user and user.roles and 'admin' in user.roles %} + + 🔑 +

API Management

+

Manage API clients and keys (Admin only)

+
+ {% endif %}