Initial commit: API gateway with admin console

- FastAPI async gateway with httpx proxying to multiple upstreams
- SQLite database with SQLAlchemy ORM
- Admin console: manage services, users, API keys, endpoint access
- Per-key, per-endpoint granular access control
- OpenAPI document sync and caching (5-minute TTL)
- Request/response logging with full transaction inspection
- In-memory rate limiting (per-key, fixed-window)
- Tiered log retention (7d payloads, 90d rows, incremental vacuum)
- TLS verification toggle per service (for self-signed certificates)
- Service connectivity validation with automatic endpoint refresh
- Request browser with filters and deep-link inspection
- Docker setup with persistent volume
- Modal forms for create/edit flows

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Samuel Amar
2026-07-29 14:00:22 +02:00
co-authored by Claude Haiku 4.5
commit 77d7a50fa9
31 changed files with 2927 additions and 0 deletions
View File
+29
View File
@@ -0,0 +1,29 @@
from fastapi import Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from app import config, security
from app.database import get_db
from app.models import User
class LoginRequired(HTTPException):
"""Raised when there is no valid session; handled by redirecting to /admin/login."""
def __init__(self):
super().__init__(status_code=status.HTTP_303_SEE_OTHER)
def login_redirect_handler(request: Request, exc: LoginRequired):
return RedirectResponse("/admin/login", status_code=303)
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
token = request.cookies.get(config.SESSION_COOKIE)
user_id = security.read_session_token(token) if token else None
if user_id is None:
raise LoginRequired()
user = db.get(User, user_id)
if user is None or not user.is_active:
raise LoginRequired()
return user
+423
View File
@@ -0,0 +1,423 @@
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import func
from sqlalchemy.orm import Session
from app import config, discovery, security
from app.admin.deps import current_user
from app.database import get_db
from app.models import ApiKey, Endpoint, RequestLog, Service, User
router = APIRouter(prefix="/admin")
templates = Jinja2Templates(directory=str(config.BASE_DIR / "app" / "templates"))
# Slugs the proxy catch-all must never claim
RESERVED_SLUGS = {"admin", "static", "health", "docs", "redoc", "openapi.json"}
def build_tree(endpoints: list[Endpoint]) -> dict:
"""Nest endpoints by path segment for the hierarchical access picker.
Node: {name, children: {segment: node}, endpoints: [Endpoint]}.
Chains of empty single-child nodes are compressed ('api' + 'v1' -> 'api/v1')."""
root = {"name": "", "children": {}, "endpoints": []}
for e in endpoints:
node = root
for part in (p for p in e.path.split("/") if p):
node = node["children"].setdefault(
part, {"name": part, "children": {}, "endpoints": []})
node["endpoints"].append(e)
def compress(node: dict) -> None:
for key in list(node["children"]):
child = node["children"][key]
while not child["endpoints"] and len(child["children"]) == 1:
(grandchild,) = child["children"].values()
child["name"] = child["name"] + "/" + grandchild["name"]
child["endpoints"] = grandchild["endpoints"]
child["children"] = grandchild["children"]
compress(child)
if child["name"] != key:
node["children"][child["name"]] = node["children"].pop(key)
compress(root)
return root
def render(request: Request, name: str, user: User | None = None, **ctx):
return templates.TemplateResponse(
request, name, {"user": user, "active": name.split(".")[0], **ctx}
)
def _redirect(url: str) -> RedirectResponse:
return RedirectResponse(url, status_code=303)
# ---------- auth ----------
@router.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
return render(request, "login.html")
@router.post("/login")
def login(request: Request, username: str = Form(...), password: str = Form(...),
db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == username).one_or_none()
if not user or not user.is_active or not security.verify_password(password, user.password_hash):
return render(request, "login.html", error="Invalid username or password.")
response = _redirect("/admin")
response.set_cookie(
config.SESSION_COOKIE,
security.create_session_token(user.id),
max_age=config.SESSION_MAX_AGE,
httponly=True,
samesite="lax",
)
return response
@router.get("/logout")
def logout():
response = _redirect("/admin/login")
response.delete_cookie(config.SESSION_COOKIE)
return response
# ---------- dashboard ----------
@router.get("", response_class=HTMLResponse)
@router.get("/", response_class=HTMLResponse)
def dashboard(request: Request, user: User = Depends(current_user),
db: Session = Depends(get_db)):
recent = (
db.query(RequestLog).order_by(RequestLog.timestamp.desc()).limit(15).all()
)
return render(request, "dashboard.html", user, recent=recent)
# ---------- services & endpoints ----------
@router.get("/services", response_class=HTMLResponse)
def services_page(request: Request, user: User = Depends(current_user),
db: Session = Depends(get_db)):
services = db.query(Service).order_by(Service.name).all()
counts = dict(
db.query(RequestLog.service_id, func.count(RequestLog.id))
.group_by(RequestLog.service_id).all()
)
return render(request, "services.html", user, services=services, counts=counts)
@router.post("/services")
def create_service(name: str = Form(...), slug: str = Form(...), base_url: str = Form(...),
description: str = Form(""), timeout_seconds: float = Form(30.0),
verify_tls: bool = Form(False),
user: User = Depends(current_user), db: Session = Depends(get_db)):
slug = slug.strip().lower()
if slug in RESERVED_SLUGS:
raise HTTPException(400, f"Slug '{slug}' is reserved by the gateway itself.")
if db.query(Service).filter(Service.slug == slug).count():
raise HTTPException(400, f"Slug '{slug}' is already taken.")
service = Service(name=name.strip(), slug=slug, base_url=base_url.strip().rstrip("/"),
description=description.strip(), timeout_seconds=timeout_seconds,
verify_tls=verify_tls)
db.add(service)
db.commit()
# The page auto-validates the new service (which also caches its endpoints).
return _redirect(f"/admin/services?validate={service.id}")
@router.post("/services/{service_id}/update")
def update_service(service_id: int, name: str = Form(...), base_url: str = Form(...),
description: str = Form(""), timeout_seconds: float = Form(30.0),
verify_tls: bool = Form(False),
user: User = Depends(current_user), db: Session = Depends(get_db)):
service = db.get(Service, service_id)
if not service:
raise HTTPException(404)
service.name, service.base_url = name.strip(), base_url.strip().rstrip("/")
service.description, service.timeout_seconds = description.strip(), timeout_seconds
service.verify_tls = verify_tls
db.commit()
return _redirect(f"/admin/services?validate={service.id}")
@router.post("/services/{service_id}/validate")
def validate_service(service_id: int, user: User = Depends(current_user),
db: Session = Depends(get_db)):
"""Probe the upstream; if it publishes an OpenAPI document this also
refreshes the endpoint cache."""
service = db.get(Service, service_id)
if not service:
raise HTTPException(404)
return discovery.validate_service(db, service)
@router.post("/services/{service_id}/toggle")
def toggle_service(service_id: int, user: User = Depends(current_user),
db: Session = Depends(get_db)):
service = db.get(Service, service_id)
if not service:
raise HTTPException(404)
service.is_active = not service.is_active
db.commit()
return _redirect("/admin/services")
@router.post("/services/{service_id}/delete")
def delete_service(service_id: int, user: User = Depends(current_user),
db: Session = Depends(get_db)):
service = db.get(Service, service_id)
if service:
db.delete(service)
db.commit()
return _redirect("/admin/services")
# ---------- users ----------
@router.get("/users", response_class=HTMLResponse)
def users_page(request: Request, user: User = Depends(current_user),
db: Session = Depends(get_db)):
users = db.query(User).order_by(User.username).all()
return render(request, "users.html", user, users=users)
@router.post("/users")
def create_user(username: str = Form(...), password: str = Form(...),
user: User = Depends(current_user), db: Session = Depends(get_db)):
username = username.strip()
if db.query(User).filter(User.username == username).count():
raise HTTPException(400, f"Username '{username}' is already taken.")
db.add(User(username=username, password_hash=security.hash_password(password)))
db.commit()
return _redirect("/admin/users")
@router.post("/users/{user_id}/toggle")
def toggle_user(user_id: int, user: User = Depends(current_user),
db: Session = Depends(get_db)):
target = db.get(User, user_id)
if not target:
raise HTTPException(404)
if target.id == user.id:
raise HTTPException(400, "You cannot deactivate your own account.")
target.is_active = not target.is_active
db.commit()
return _redirect("/admin/users")
@router.post("/users/{user_id}/password")
def reset_password(user_id: int, password: str = Form(...),
user: User = Depends(current_user), db: Session = Depends(get_db)):
target = db.get(User, user_id)
if not target:
raise HTTPException(404)
target.password_hash = security.hash_password(password)
db.commit()
return _redirect("/admin/users")
@router.post("/users/{user_id}/delete")
def delete_user(user_id: int, user: User = Depends(current_user),
db: Session = Depends(get_db)):
if user_id == user.id:
raise HTTPException(400, "You cannot delete your own account.")
target = db.get(User, user_id)
if target:
db.delete(target)
db.commit()
return _redirect("/admin/users")
# ---------- API keys / endpoint access ----------
@router.get("/keys", response_class=HTMLResponse)
def keys_page(request: Request, user: User = Depends(current_user),
db: Session = Depends(get_db)):
services = db.query(Service).order_by(Service.name).all()
# Rendered straight from the database — the OpenAPI sync runs in the
# background via /admin/api/endpoints/sync, triggered by the page's JS.
trees = {s.id: build_tree(s.endpoints) for s in services}
synced = {s.id: discovery.spec_status(s.id) for s in services}
keys = db.query(ApiKey).order_by(ApiKey.created_at.desc()).all()
users = db.query(User).filter(User.is_active).order_by(User.username).all()
new_key = request.query_params.get("new_key")
return render(request, "keys.html", user, keys=keys, users=users,
services=services, trees=trees, synced=synced, new_key=new_key)
@router.get("/api/endpoints/sync")
def sync_endpoints(force: bool = False, user: User = Depends(current_user),
db: Session = Depends(get_db)):
"""Refresh all endpoint catalogs from their OpenAPI documents (TTL-cached).
The keys page calls this in the background and reloads if anything changed."""
changed_any = False
specs = {}
for service in db.query(Service).order_by(Service.name).all():
found, changed = discovery.sync_service(db, service, force=force)
specs[service.id] = found
changed_any = changed_any or changed
return {"changed": changed_any, "specs": specs}
@router.post("/keys")
def create_key(request: Request, name: str = Form(...), user_id: int = Form(...),
rate_limit_per_minute: int = Form(60),
endpoint_ids: list[int] = Form([]),
user: User = Depends(current_user), db: Session = Depends(get_db)):
owner = db.get(User, user_id)
if not owner:
raise HTTPException(400, "Unknown user.")
plain, prefix, key_hash = security.generate_api_key()
key = ApiKey(user_id=owner.id, name=name.strip(), prefix=prefix, key_hash=key_hash,
rate_limit_per_minute=max(0, rate_limit_per_minute))
key.endpoints = db.query(Endpoint).filter(
Endpoint.id.in_(endpoint_ids)).all() if endpoint_ids else []
db.add(key)
db.commit()
# Shown once on the next page load; never stored in plain text.
return _redirect(f"/admin/keys?new_key={plain}")
@router.post("/keys/{key_id}/access")
def update_key_access(key_id: int, endpoint_ids: list[int] = Form([]),
rate_limit_per_minute: int = Form(60),
user: User = Depends(current_user), db: Session = Depends(get_db)):
key = db.get(ApiKey, key_id)
if not key:
raise HTTPException(404)
key.endpoints = db.query(Endpoint).filter(
Endpoint.id.in_(endpoint_ids)).all() if endpoint_ids else []
key.rate_limit_per_minute = max(0, rate_limit_per_minute)
db.commit()
return _redirect("/admin/keys")
@router.post("/keys/{key_id}/toggle")
def toggle_key(key_id: int, user: User = Depends(current_user),
db: Session = Depends(get_db)):
key = db.get(ApiKey, key_id)
if not key:
raise HTTPException(404)
key.is_active = not key.is_active
db.commit()
return _redirect("/admin/keys")
@router.post("/keys/{key_id}/delete")
def delete_key(key_id: int, user: User = Depends(current_user),
db: Session = Depends(get_db)):
key = db.get(ApiKey, key_id)
if key:
db.delete(key)
db.commit()
return _redirect("/admin/keys")
# ---------- request browser ----------
PAGE_SIZE = 50
def _int_or_none(value: str | None) -> int | None:
"""HTML GET forms submit empty strings for untouched fields — treat
anything non-numeric as 'no filter' instead of a validation error."""
try:
return int(value) if value else None
except ValueError:
return None
@router.get("/requests", response_class=HTMLResponse)
def requests_page(request: Request, user: User = Depends(current_user),
db: Session = Depends(get_db),
service_id: str | None = None, user_id: str | None = None,
key_id: str | None = None, status_class: str | None = None,
q: str | None = None, page: str | None = None):
service_id = _int_or_none(service_id)
user_id = _int_or_none(user_id)
key_id = _int_or_none(key_id)
page = _int_or_none(page) or 1
query = db.query(RequestLog)
if service_id:
query = query.filter(RequestLog.service_id == service_id)
if key_id:
query = query.filter(RequestLog.api_key_id == key_id)
if user_id:
query = query.join(ApiKey, RequestLog.api_key_id == ApiKey.id).filter(
ApiKey.user_id == user_id)
if status_class in ("2", "3", "4", "5"):
low = int(status_class) * 100
query = query.filter(RequestLog.status_code >= low,
RequestLog.status_code < low + 100)
if q:
query = query.filter(RequestLog.path.contains(q))
total = query.count()
page = max(1, page)
logs = (query.order_by(RequestLog.timestamp.desc())
.offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE).all())
services = db.query(Service).order_by(Service.name).all()
users = db.query(User).order_by(User.username).all()
keys = db.query(ApiKey).order_by(ApiKey.name).all()
return render(request, "requests.html", user, logs=logs, total=total,
page=page, pages=max(1, -(-total // PAGE_SIZE)),
services=services, users=users, keys=keys,
f={"service_id": service_id, "user_id": user_id, "key_id": key_id,
"status_class": status_class or "", "q": q or ""})
def _pretty_json(text: str) -> str:
import json
try:
return json.dumps(json.loads(text), indent=2, ensure_ascii=False)
except (ValueError, TypeError):
return text
@router.get("/requests/{log_id}/data")
def request_data(log_id: int, user: User = Depends(current_user),
db: Session = Depends(get_db)):
"""Everything the inline request inspector needs, as JSON."""
log = db.get(RequestLog, log_id)
if not log:
raise HTTPException(404)
return {
"id": log.id,
"time": log.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
"status": log.status_code,
"latency_ms": round(log.latency_ms, 1),
"method": log.method,
"path": log.path,
"query_string": log.query_string,
"service": log.service.name if log.service else None,
"slug": log.service.slug if log.service else None,
"forwarded_to": (log.service.base_url + log.path +
("?" + log.query_string if log.query_string else ""))
if log.service else None,
"endpoint": f"{log.endpoint.method} {log.endpoint.path}" if log.endpoint else None,
"endpoint_description": log.endpoint.description if log.endpoint else "",
"key": log.api_key.name if log.api_key else None,
"key_prefix": log.api_key.prefix if log.api_key else "",
"user": log.api_key.user.username if log.api_key else None,
"client_ip": log.client_ip,
"request_body": _pretty_json(log.request_body),
"response_body": _pretty_json(log.response_body),
}
# ---------- monitoring ----------
@router.get("/monitoring", response_class=HTMLResponse)
def monitoring_page(request: Request, user: User = Depends(current_user),
db: Session = Depends(get_db)):
logs = db.query(RequestLog).order_by(RequestLog.timestamp.desc()).limit(100).all()
services = db.query(Service).order_by(Service.name).all()
users = db.query(User).order_by(User.username).all()
keys = db.query(ApiKey).order_by(ApiKey.name).all()
return render(request, "monitoring.html", user, logs=logs,
services=services, users=users, keys=keys)
+237
View File
@@ -0,0 +1,237 @@
"""JSON endpoints backing the monitoring charts.
All endpoints accept:
hours — window size (1..720)
service_id — restrict to one service
user_id — restrict to keys owned by one user
key_id — restrict to one API key
Time series use adaptive buckets: 5 min (<=6 h), 1 h (<=48 h), 1 day beyond.
SQLite-specific date functions are used (see README).
"""
import time
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query
from sqlalchemy import Integer, func
from sqlalchemy.orm import Query as OrmQuery, Session
from app.admin.deps import current_user
from app.database import get_db
from app.models import ApiKey, Endpoint, RequestLog, Service, User
router = APIRouter(prefix="/admin/api", dependencies=[Depends(current_user)])
class Filters:
def __init__(
self,
hours: int = Query(24, ge=1, le=720),
service_id: int | None = Query(None),
user_id: int | None = Query(None),
key_id: int | None = Query(None),
):
self.hours = hours
self.since = datetime.now(timezone.utc) - timedelta(hours=hours)
self.service_id = service_id
self.user_id = user_id
self.key_id = key_id
def apply(self, q: OrmQuery, joined_key: bool = False) -> OrmQuery:
"""Apply window + filters. Pass joined_key when the query already
joins ApiKey so we don't join twice."""
q = q.filter(RequestLog.timestamp >= self.since)
if self.service_id:
q = q.filter(RequestLog.service_id == self.service_id)
if self.key_id:
q = q.filter(RequestLog.api_key_id == self.key_id)
if self.user_id:
if not joined_key:
q = q.join(ApiKey, RequestLog.api_key_id == ApiKey.id)
q = q.filter(ApiKey.user_id == self.user_id)
return q
def _bucket_seconds(hours: int) -> int:
if hours <= 6:
return 300
if hours <= 48:
return 3600
return 86400
def _label_format(hours: int, bucket: int) -> str:
if bucket < 3600:
return "%H:%M"
if bucket == 3600:
return "%H:00" if hours <= 24 else "%m-%d %H:00"
return "%Y-%m-%d"
def _bucket_expr(bucket: int):
# epoch - (epoch % bucket) floors to the bucket start; plain `/` must be
# avoided because SQLAlchemy renders it as true (float) division.
epoch = func.cast(func.strftime("%s", RequestLog.timestamp), Integer)
return epoch - (epoch % bucket)
def _series(f: Filters, db: Session, *aggregates):
"""Group the filtered logs into time buckets and fill gaps with None.
Returns (labels, [values-per-aggregate])."""
bucket = _bucket_seconds(f.hours)
fmt = _label_format(f.hours, bucket)
expr = _bucket_expr(bucket).label("bucket")
rows = (
f.apply(db.query(expr, *aggregates))
.group_by("bucket").order_by("bucket").all()
)
found = {int(r[0]): r for r in rows}
start = int(f.since.timestamp()) // bucket * bucket
now = int(time.time())
labels: list[str] = []
series: list[list] = [[] for _ in aggregates]
for t in range(start, now + 1, bucket):
labels.append(datetime.fromtimestamp(t, timezone.utc).strftime(fmt))
row = found.get(t)
for i in range(len(aggregates)):
series[i].append(row[i + 1] if row else None)
return labels, series
@router.get("/stats/summary")
def summary(f: Filters = Depends(), db: Session = Depends(get_db)):
total = f.apply(db.query(func.count(RequestLog.id))).scalar() or 0
errors = f.apply(db.query(func.count(RequestLog.id))).filter(
RequestLog.status_code >= 500).scalar() or 0
avg_latency = f.apply(db.query(func.avg(RequestLog.latency_ms))).scalar() or 0
return {
"total_requests": total,
"error_count": errors,
"error_rate": round(errors / total * 100, 2) if total else 0,
"avg_latency_ms": round(avg_latency, 1),
"active_services": db.query(Service).filter(Service.is_active).count(),
"active_keys": db.query(ApiKey).filter(ApiKey.is_active).count(),
}
@router.get("/stats/timeseries")
def timeseries(f: Filters = Depends(), db: Session = Depends(get_db)):
labels, (ok, errors) = _series(
f, db,
func.sum(func.iif(RequestLog.status_code < 500, 1, 0)),
func.sum(func.iif(RequestLog.status_code >= 500, 1, 0)),
)
return {
"labels": labels,
"ok": [int(v) if v is not None else 0 for v in ok],
"errors": [int(v) if v is not None else 0 for v in errors],
}
@router.get("/stats/latency-timeseries")
def latency_timeseries(f: Filters = Depends(), db: Session = Depends(get_db)):
labels, (avg_ms,) = _series(f, db, func.avg(RequestLog.latency_ms))
return {
"labels": labels,
"avg_ms": [round(v, 1) if v is not None else None for v in avg_ms],
}
@router.get("/stats/by-service")
def by_service(f: Filters = Depends(), db: Session = Depends(get_db)):
q = (
db.query(Service.name, func.count(RequestLog.id).label("count"))
.join(RequestLog, RequestLog.service_id == Service.id)
)
rows = (
f.apply(q)
.group_by(Service.id)
.order_by(func.count(RequestLog.id).desc())
.all()
)
return {"labels": [r.name for r in rows], "counts": [r.count for r in rows]}
@router.get("/stats/by-endpoint")
def by_endpoint(f: Filters = Depends(), db: Session = Depends(get_db)):
q = (
db.query(Service.slug, Endpoint.method, Endpoint.path,
func.count(RequestLog.id).label("count"))
.select_from(RequestLog)
.join(Endpoint, RequestLog.endpoint_id == Endpoint.id)
.join(Service, Endpoint.service_id == Service.id)
)
rows = (
f.apply(q)
.group_by(Endpoint.id)
.order_by(func.count(RequestLog.id).desc())
.limit(15)
.all()
)
return {
"labels": [f"{r.slug}: {r.method} {r.path}" for r in rows],
"counts": [r.count for r in rows],
}
@router.get("/stats/by-status")
def by_status(f: Filters = Depends(), db: Session = Depends(get_db)):
q = (
db.query(RequestLog.status_code, func.count(RequestLog.id))
)
rows = (
f.apply(q)
.group_by(RequestLog.status_code)
.order_by(RequestLog.status_code)
.all()
)
return {"labels": [str(r[0]) for r in rows], "counts": [r[1] for r in rows]}
@router.get("/stats/latency-by-service")
def latency_by_service(f: Filters = Depends(), db: Session = Depends(get_db)):
q = (
db.query(Service.name, func.avg(RequestLog.latency_ms).label("avg_ms"))
.join(RequestLog, RequestLog.service_id == Service.id)
)
rows = (
f.apply(q)
.group_by(Service.id)
.order_by(func.avg(RequestLog.latency_ms).desc())
.all()
)
return {"labels": [r.name for r in rows], "avg_ms": [round(r.avg_ms, 1) for r in rows]}
@router.get("/stats/top-keys")
def top_keys(f: Filters = Depends(), db: Session = Depends(get_db)):
q = (
db.query(ApiKey.name, func.count(RequestLog.id).label("count"))
.join(RequestLog, RequestLog.api_key_id == ApiKey.id)
)
rows = (
f.apply(q, joined_key=True)
.group_by(ApiKey.id)
.order_by(func.count(RequestLog.id).desc())
.limit(10)
.all()
)
return {"labels": [r.name for r in rows], "counts": [r.count for r in rows]}
@router.get("/stats/by-user")
def by_user(f: Filters = Depends(), db: Session = Depends(get_db)):
q = (
db.query(User.username, func.count(RequestLog.id).label("count"))
.select_from(RequestLog)
.join(ApiKey, RequestLog.api_key_id == ApiKey.id)
.join(User, ApiKey.user_id == User.id)
)
rows = (
f.apply(q, joined_key=True)
.group_by(User.id)
.order_by(func.count(RequestLog.id).desc())
.limit(15)
.all()
)
return {"labels": [r.username for r in rows], "counts": [r.count for r in rows]}