mirror of
https://github.com/furyhawk/api_gateway.git
synced 2026-07-21 02:06:50 +00:00
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:
@@ -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,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:
|
||||
|
||||
@@ -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>
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user