diff --git a/README.md b/README.md index 9a5264d..3760561 100644 --- a/README.md +++ b/README.md @@ -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: ` 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: ` 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`. diff --git a/config/gateway.yaml b/config/gateway.yaml index 3c5fcde..16fcf29 100644 --- a/config/gateway.yaml +++ b/config/gateway.yaml @@ -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: diff --git a/src/gateway_framework/admin.py b/src/gateway_framework/admin.py new file mode 100644 index 0000000..80aae67 --- /dev/null +++ b/src/gateway_framework/admin.py @@ -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 """ + + + + + Gateway Admin Portal + + + +
+

Gateway Admin Portal

+

Use header x-admin-key for protected actions when ADMIN_API_KEY is configured.

+ +
+

Access

+ +
+ +
+

Dashboard

+ +
Loading...
+
+ +
+

Configuration Editor

+

Edits are validated and written to disk. Structural route changes may require app restart.

+ + + +
+ +
+

API Key Management

+
+ + +
+

+
    +
    +
    + + + + +""" diff --git a/src/gateway_framework/api_keys.py b/src/gateway_framework/api_keys.py new file mode 100644 index 0000000..67c532d --- /dev/null +++ b/src/gateway_framework/api_keys.py @@ -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 diff --git a/src/gateway_framework/app.py b/src/gateway_framework/app.py index 288e160..189053c 100644 --- a/src/gateway_framework/app.py +++ b/src/gateway_framework/app.py @@ -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: diff --git a/src/gateway_framework/config.py b/src/gateway_framework/config.py index 490b425..81354df 100644 --- a/src/gateway_framework/config.py +++ b/src/gateway_framework/config.py @@ -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): diff --git a/tests/test_api_keys.py b/tests/test_api_keys.py new file mode 100644 index 0000000..c600170 --- /dev/null +++ b/tests/test_api_keys.py @@ -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 diff --git a/tests/test_app.py b/tests/test_app.py index 6683da4..87409aa 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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