Add administrative portal and API key management features

- Implement admin portal at `/admin/portal` with dashboard and configuration editor.
- Add endpoints for API key creation, listing, and revocation.
- Introduce runtime configuration editing with validation.
- Enhance gateway settings to support API key protection.
- Create ApiKeyStore for managing API keys with persistence.
- Update tests to cover new admin functionalities and API key lifecycle.
This commit is contained in:
2026-05-31 22:18:19 +08:00
parent 3fb04e24f9
commit 64b4daeb50
8 changed files with 516 additions and 5 deletions
+24
View File
@@ -7,6 +7,9 @@ This repository now includes a lightweight, configurable API gateway framework b
- Configurable upstream targets and per-upstream timeout settings
- OpenAPI docs for gateway endpoints via FastAPI (`/docs`)
- Optional serving of an external OpenAPI contract (`/openapi/external.json`)
- Administrative portal at `/admin/portal`
- Runtime configuration editor via admin endpoints
- API key creation/list/revocation with optional route protection
- Built-in management endpoints:
- `GET /healthz`
- `GET /readyz`
@@ -52,6 +55,9 @@ settings:
version: string
description: string
external_openapi_file: path/to/openapi.json
require_api_key: false
api_keys_file: config/api_keys.json
admin_api_key_env: ADMIN_API_KEY
upstreams:
service_name:
@@ -74,6 +80,24 @@ Notes:
- `upstream_path` overrides forwarded path.
- `strip_prefix` removes a leading path segment before forwarding.
## Administrative Portal and Dashboard
- Admin UI: `GET /admin/portal`
- Admin summary: `GET /admin/dashboard`
- Get config YAML: `GET /admin/config`
- Save config YAML: `PUT /admin/config`
- List API keys: `GET /admin/api-keys`
- Create API key: `POST /admin/api-keys` with body `{"name":"client-name"}`
- Revoke API key: `DELETE /admin/api-keys/{key_id}`
Optional admin auth:
- Set environment variable `ADMIN_API_KEY` before startup.
- Send header `x-admin-key: <your-admin-key>` to admin endpoints.
## API Key Protection for Gateway Routes
- Enable gateway key checks by setting `settings.require_api_key: true`.
- Clients then must send `x-api-key: <key>` on proxied route requests.
- Create keys from the admin portal or `POST /admin/api-keys`.
## Scaling and Configurability Guidance
- Add routes through config, not code, for repeatable deployments.
- Split large configurations into environment-specific files and set `GATEWAY_CONFIG_PATH`.
+3
View File
@@ -3,6 +3,9 @@ settings:
version: 0.1.0
description: Configurable gateway for OpenAPI-based upstream services
external_openapi_file: openapi_json/lta_datamall_openapi_v0-1-1.json
require_api_key: false
api_keys_file: config/api_keys.json
admin_api_key_env: ADMIN_API_KEY
upstreams:
lta_datamall:
+181
View File
@@ -0,0 +1,181 @@
from __future__ import annotations
from fastapi import HTTPException, Request
def require_admin_access(request: Request) -> None:
expected_key = request.app.state.admin_api_key
if not expected_key:
return
presented = request.headers.get("x-admin-key")
if presented != expected_key:
raise HTTPException(status_code=401, detail="invalid_admin_api_key")
def render_admin_portal() -> str:
return """<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>Gateway Admin Portal</title>
<style>
:root {
--bg: #f5f7f2;
--panel: #ffffff;
--text: #1f2d24;
--muted: #5d6f63;
--accent: #0d7f5f;
--warn: #a23b3b;
--border: #d9e2d9;
}
body {
margin: 0;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
background: radial-gradient(circle at 0 0, #dceadf, var(--bg));
color: var(--text);
}
.wrap {
max-width: 1100px;
margin: 0 auto;
padding: 24px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.05);
}
h1, h2 { margin: 0 0 10px; }
textarea, input {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
font-family: "IBM Plex Mono", monospace;
}
textarea { min-height: 220px; }
button {
background: var(--accent);
color: #fff;
border: 0;
border-radius: 8px;
padding: 10px 14px;
cursor: pointer;
margin-right: 8px;
}
button.warn { background: var(--warn); }
.row { display: flex; gap: 8px; align-items: center; }
.hint { color: var(--muted); font-size: 0.92rem; }
code { background: #f0f5ef; padding: 2px 6px; border-radius: 4px; }
ul { padding-left: 18px; }
</style>
</head>
<body>
<div class=\"wrap\">
<h1>Gateway Admin Portal</h1>
<p class=\"hint\">Use header <code>x-admin-key</code> for protected actions when ADMIN_API_KEY is configured.</p>
<div class=\"card\">
<h2>Access</h2>
<input id=\"adminKey\" type=\"password\" placeholder=\"Admin API key (optional if server does not require it)\" />
</div>
<div class=\"card\">
<h2>Dashboard</h2>
<button onclick=\"refreshDashboard()\">Refresh</button>
<pre id=\"dashboard\" class=\"hint\">Loading...</pre>
</div>
<div class=\"card\">
<h2>Configuration Editor</h2>
<p class=\"hint\">Edits are validated and written to disk. Structural route changes may require app restart.</p>
<button onclick=\"loadConfig()\">Load</button>
<button onclick=\"saveConfig()\">Save</button>
<textarea id=\"configText\"></textarea>
</div>
<div class=\"card\">
<h2>API Key Management</h2>
<div class=\"row\">
<input id=\"apiKeyName\" placeholder=\"New key name (for example dashboard-client)\" />
<button onclick=\"createApiKey()\">Create Key</button>
</div>
<p class=\"hint\" id=\"newKey\"></p>
<ul id=\"apiKeys\"></ul>
</div>
</div>
<script>
function headers() {
const k = document.getElementById('adminKey').value.trim();
return k ? {'x-admin-key': k} : {};
}
async function refreshDashboard() {
const res = await fetch('/admin/dashboard', {headers: headers()});
const data = await res.json();
document.getElementById('dashboard').textContent = JSON.stringify(data, null, 2);
await listApiKeys();
}
async function loadConfig() {
const res = await fetch('/admin/config', {headers: headers()});
const data = await res.json();
document.getElementById('configText').value = data.yaml;
}
async function saveConfig() {
const body = {yaml: document.getElementById('configText').value};
const res = await fetch('/admin/config', {
method: 'PUT',
headers: {...headers(), 'content-type': 'application/json'},
body: JSON.stringify(body),
});
const data = await res.json();
alert(JSON.stringify(data));
await refreshDashboard();
}
async function listApiKeys() {
const res = await fetch('/admin/api-keys', {headers: headers()});
const data = await res.json();
const list = document.getElementById('apiKeys');
list.innerHTML = '';
for (const item of data.keys) {
const li = document.createElement('li');
li.textContent = `${item.name} (${item.prefix}) revoked=${item.revoked}`;
const btn = document.createElement('button');
btn.textContent = 'Revoke';
btn.className = 'warn';
btn.onclick = async () => {
await fetch(`/admin/api-keys/${item.id}`, {method: 'DELETE', headers: headers()});
await listApiKeys();
};
li.appendChild(btn);
list.appendChild(li);
}
}
async function createApiKey() {
const name = document.getElementById('apiKeyName').value.trim() || 'unnamed';
const res = await fetch('/admin/api-keys', {
method: 'POST',
headers: {...headers(), 'content-type': 'application/json'},
body: JSON.stringify({name}),
});
const data = await res.json();
document.getElementById('newKey').textContent = `New API key (shown once): ${data.api_key}`;
await listApiKeys();
}
loadConfig();
refreshDashboard();
</script>
</body>
</html>
"""
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
import hashlib
import json
import secrets
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
def _utc_now_iso() -> str:
return datetime.now(UTC).replace(microsecond=0).isoformat()
def _hash_key(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
@dataclass
class ApiKeyStore:
path: Path
def ensure_exists(self) -> None:
if self.path.exists():
return
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(json.dumps({"keys": []}, indent=2), encoding="utf-8")
def _load(self) -> dict[str, Any]:
self.ensure_exists()
raw = json.loads(self.path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise ValueError("API key store must be a JSON object")
keys = raw.get("keys")
if not isinstance(keys, list):
raise ValueError("API key store must contain a 'keys' array")
return raw
def _save(self, payload: dict[str, Any]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
def list_keys(self) -> list[dict[str, Any]]:
payload = self._load()
return list(payload["keys"])
def create_key(self, *, name: str) -> tuple[str, dict[str, Any]]:
payload = self._load()
secret = f"ak_{secrets.token_urlsafe(24)}"
metadata = {
"id": secrets.token_hex(8),
"name": name,
"prefix": secret[:10],
"hash": _hash_key(secret),
"created_at": _utc_now_iso(),
"revoked": False,
}
payload["keys"].append(metadata)
self._save(payload)
safe_metadata = {k: v for k, v in metadata.items() if k != "hash"}
return secret, safe_metadata
def revoke_key(self, key_id: str) -> bool:
payload = self._load()
updated = False
for item in payload["keys"]:
if item.get("id") == key_id and not item.get("revoked", False):
item["revoked"] = True
item["revoked_at"] = _utc_now_iso()
updated = True
break
if updated:
self._save(payload)
return updated
def verify(self, presented_key: str) -> bool:
if not presented_key:
return False
hashed = _hash_key(presented_key)
payload = self._load()
for item in payload["keys"]:
if item.get("revoked", False):
continue
if item.get("hash") == hashed:
return True
return False
+104 -3
View File
@@ -7,15 +7,27 @@ from contextlib import asynccontextmanager
from pathlib import Path
import httpx
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import yaml
from fastapi import Body, FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel
from .admin import render_admin_portal, require_admin_access
from .api_keys import ApiKeyStore
from .config import GatewayConfig, RouteConfig, load_gateway_config
from .proxy import proxy_request
DEFAULT_CONFIG_PATH = "config/gateway.yaml"
class ConfigUpdateRequest(BaseModel):
yaml: str
class ApiKeyCreateRequest(BaseModel):
name: str
def _resolve_config_path(explicit_path: str | None = None) -> str:
if explicit_path:
return explicit_path
@@ -24,10 +36,18 @@ def _resolve_config_path(explicit_path: str | None = None) -> str:
def _make_proxy_handler(route: RouteConfig) -> Callable:
async def handler(request: Request):
config = request.app.state.gateway_config
if config.settings.require_api_key:
presented = request.headers.get("x-api-key", "")
if not presented:
return JSONResponse(status_code=401, content={"error": "missing_api_key"})
if not request.app.state.api_key_store.verify(presented):
return JSONResponse(status_code=403, content={"error": "invalid_api_key"})
return await proxy_request(
request=request,
client=request.app.state.http_client,
config=request.app.state.gateway_config,
config=config,
route=route,
)
@@ -92,6 +112,83 @@ def _register_management_routes(app: FastAPI) -> None:
return JSONResponse(content=json.loads(path.read_text(encoding="utf-8")))
@app.get("/admin/portal", tags=["Admin"], include_in_schema=False)
async def admin_portal() -> HTMLResponse:
return HTMLResponse(render_admin_portal())
@app.get("/admin/dashboard", tags=["Admin"], summary="Admin dashboard summary")
async def admin_dashboard(request: Request) -> dict[str, object]:
require_admin_access(request)
cfg: GatewayConfig = request.app.state.gateway_config
return {
"title": cfg.settings.title,
"version": cfg.settings.version,
"require_api_key": cfg.settings.require_api_key,
"admin_api_key_required": bool(request.app.state.admin_api_key),
"upstreams": sorted(cfg.upstreams.keys()),
"routes_count": len(cfg.routes),
"api_keys_file": str(request.app.state.api_key_store.path),
}
@app.get("/admin/config", tags=["Admin"], summary="Get active gateway YAML config")
async def get_admin_config(request: Request) -> dict[str, str]:
require_admin_access(request)
config_path: Path = request.app.state.gateway_config_path
return {"yaml": config_path.read_text(encoding="utf-8")}
@app.put("/admin/config", tags=["Admin"], summary="Validate and save gateway YAML config")
async def update_admin_config(
request: Request,
payload: ConfigUpdateRequest = Body(...),
) -> dict[str, str]:
require_admin_access(request)
config_path: Path = request.app.state.gateway_config_path
previous_yaml = config_path.read_text(encoding="utf-8")
try:
parsed = yaml.safe_load(payload.yaml)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"invalid_yaml: {exc}") from exc
if not isinstance(parsed, dict):
raise HTTPException(status_code=400, detail="invalid_yaml_root")
# Validate with existing loader by writing candidate content first.
config_path.write_text(payload.yaml, encoding="utf-8")
try:
reloaded = load_gateway_config(config_path)
except Exception as exc:
config_path.write_text(previous_yaml, encoding="utf-8")
raise HTTPException(status_code=400, detail=f"invalid_config: {exc}") from exc
request.app.state.gateway_config = reloaded
request.app.state.api_key_store = ApiKeyStore(Path(reloaded.settings.api_keys_file))
request.app.state.api_key_store.ensure_exists()
request.app.state.admin_api_key = os.getenv(reloaded.settings.admin_api_key_env, "")
return {"status": "saved"}
@app.get("/admin/api-keys", tags=["Admin"], summary="List API keys metadata")
async def list_api_keys(request: Request) -> dict[str, object]:
require_admin_access(request)
keys = request.app.state.api_key_store.list_keys()
safe = [{k: v for k, v in item.items() if k != "hash"} for item in keys]
return {"keys": safe}
@app.post("/admin/api-keys", tags=["Admin"], summary="Create API key")
async def create_api_key(
request: Request,
payload: ApiKeyCreateRequest = Body(...),
) -> dict[str, object]:
require_admin_access(request)
api_key, metadata = request.app.state.api_key_store.create_key(name=payload.name)
return {"api_key": api_key, "metadata": metadata}
@app.delete("/admin/api-keys/{key_id}", tags=["Admin"], summary="Revoke API key")
async def revoke_api_key(key_id: str, request: Request) -> dict[str, object]:
require_admin_access(request)
revoked = request.app.state.api_key_store.revoke_key(key_id)
return {"revoked": revoked}
def create_app(config_path: str | None = None) -> FastAPI:
resolved_path = _resolve_config_path(config_path)
@@ -100,7 +197,11 @@ def create_app(config_path: str | None = None) -> FastAPI:
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
app.state.gateway_config = gateway_config
app.state.gateway_config_path = Path(resolved_path)
app.state.http_client = httpx.AsyncClient()
app.state.api_key_store = ApiKeyStore(Path(gateway_config.settings.api_keys_file))
app.state.api_key_store.ensure_exists()
app.state.admin_api_key = os.getenv(gateway_config.settings.admin_api_key_env, "")
try:
yield
finally:
+3
View File
@@ -12,6 +12,9 @@ class GatewaySettings(BaseModel):
version: str = "0.1.0"
description: str = "Configurable API gateway"
external_openapi_file: str | None = None
require_api_key: bool = False
api_keys_file: str = "config/api_keys.json"
admin_api_key_env: str = "ADMIN_API_KEY"
class UpstreamConfig(BaseModel):
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
from gateway_framework.api_keys import ApiKeyStore
def test_api_key_store_create_verify_revoke(tmp_path) -> None:
store = ApiKeyStore(tmp_path / "api_keys.json")
store.ensure_exists()
api_key, meta = store.create_key(name="integration-client")
assert api_key.startswith("ak_")
assert meta["name"] == "integration-client"
assert store.verify(api_key) is True
assert store.verify("ak_invalid") is False
revoked = store.revoke_key(meta["id"])
assert revoked is True
assert store.verify(api_key) is False
+93 -2
View File
@@ -1,14 +1,29 @@
from __future__ import annotations
import json
from pathlib import Path
import httpx
import pytest
from gateway_framework.api_keys import ApiKeyStore
from gateway_framework.app import create_app
from gateway_framework.config import load_gateway_config
def _prepare_state(app, config_file: Path) -> None:
def failing_upstream(req: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("upstream unavailable", request=req)
app.state.gateway_config = load_gateway_config(config_file)
app.state.gateway_config_path = Path(config_file)
app.state.admin_api_key = ""
app.state.http_client = httpx.AsyncClient(transport=httpx.MockTransport(failing_upstream))
store = ApiKeyStore(Path(config_file.parent / "api_keys.json"))
store.ensure_exists()
app.state.api_key_store = store
@pytest.mark.anyio
async def test_management_endpoints_and_external_openapi(tmp_path) -> None:
external_openapi = tmp_path / "external-openapi.json"
@@ -38,7 +53,7 @@ routes:
)
app = create_app(str(config_file))
app.state.gateway_config = load_gateway_config(config_file)
_prepare_state(app, config_file)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
health = await client.get("/healthz")
@@ -73,10 +88,86 @@ routes:
)
app = create_app(str(config_file))
app.state.gateway_config = load_gateway_config(config_file)
_prepare_state(app, config_file)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
response = await client.get("/openapi/external.json")
assert response.status_code == 404
assert response.json()["error"] == "external_openapi_not_configured"
@pytest.mark.anyio
async def test_admin_api_key_lifecycle(tmp_path) -> None:
config_file = tmp_path / "gateway.yaml"
config_file.write_text(
"""
settings:
api_keys_file: PLACEHOLDER
upstreams:
demo:
base_url: https://example.com/
routes:
- path: /api/v1/demo
methods: [GET]
upstream: demo
""".strip().replace("PLACEHOLDER", str(tmp_path / "api_keys.json")),
encoding="utf-8",
)
app = create_app(str(config_file))
_prepare_state(app, config_file)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
created = await client.post("/admin/api-keys", json={"name": "portal-user"})
assert created.status_code == 200
payload = created.json()
assert payload["api_key"].startswith("ak_")
listed = await client.get("/admin/api-keys")
assert listed.status_code == 200
assert len(listed.json()["keys"]) == 1
key_id = listed.json()["keys"][0]["id"]
revoked = await client.delete(f"/admin/api-keys/{key_id}")
assert revoked.status_code == 200
assert revoked.json()["revoked"] is True
@pytest.mark.anyio
async def test_proxy_route_requires_api_key_when_enabled(tmp_path) -> None:
config_file = tmp_path / "gateway.yaml"
api_keys_file = tmp_path / "api_keys.json"
config_file.write_text(
f"""
settings:
require_api_key: true
api_keys_file: {api_keys_file}
upstreams:
demo:
base_url: https://example.com/
routes:
- path: /api/v1/demo
methods: [GET]
upstream: demo
""".strip(),
encoding="utf-8",
)
app = create_app(str(config_file))
_prepare_state(app, config_file)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
missing = await client.get("/api/v1/demo")
assert missing.status_code == 401
assert missing.json()["error"] == "missing_api_key"
created = await client.post("/admin/api-keys", json={"name": "client"})
api_key = created.json()["api_key"]
invalid = await client.get("/api/v1/demo", headers={"x-api-key": "ak_bad"})
assert invalid.status_code == 403
assert invalid.json()["error"] == "invalid_api_key"
valid = await client.get("/api/v1/demo", headers={"x-api-key": api_key})
assert valid.status_code == 502