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:
@@ -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)
|
||||
Reference in New Issue
Block a user