Files
Api-Gateway/app/admin/routes.py
T
Samuel AmarandClaude Fable 5 9a3feccc90 Add hierarchical key-scoped catalog API for machine consumers
GET /catalog walks agents through progressive disclosure: available
APIs with descriptions, then one API's tags, then a tag's endpoints,
then full detail for a single operation with $refs resolved inline.
Oversized tags (tag-poor upstreams) fall back to path-prefix groups,
drillable with ?prefix= and compressed through single-child chains.

Responses are filtered to the key's grants, carry ETags for cheap
revalidation, and reuse the discovery spec cache. Key auth is shared
with the docs portal via portal.resolve_api_key; 'catalog' and 'mcp'
are now reserved slugs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 11:39:07 +02:00

425 lines
17 KiB
Python

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",
"catalog", "mcp"}
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)