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
+133
View File
@@ -0,0 +1,133 @@
import asyncio
from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import RedirectResponse
from sqlalchemy import text
from app import config, proxy, retention, security
from app.admin import routes as admin_routes
from app.admin import stats as admin_stats
from app.admin.deps import LoginRequired, login_redirect_handler
from app.database import Base, SessionLocal, engine
from app.models import User
def enable_incremental_vacuum() -> None:
"""Deleted log rows should hand their pages back to the filesystem;
switching auto_vacuum requires a one-time VACUUM (cannot run in a
transaction, hence the raw connection)."""
raw = engine.raw_connection()
try:
cursor = raw.cursor()
mode = cursor.execute("PRAGMA auto_vacuum").fetchone()[0]
if mode != 2: # 2 = INCREMENTAL
cursor.execute("PRAGMA auto_vacuum=INCREMENTAL")
raw.commit()
cursor.execute("VACUUM")
raw.commit()
finally:
raw.close()
def _table_exists(db, name: str) -> bool:
return db.execute(text(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=:n"
), {"n": name}).first() is not None
def migrate_schema() -> None:
"""Bring older databases up to date (SQLite)."""
with engine.begin() as conn:
cols = [row[1] for row in conn.exec_driver_sql("PRAGMA table_info(request_logs)")]
if cols and "endpoint_id" not in cols:
conn.exec_driver_sql(
"ALTER TABLE request_logs ADD COLUMN endpoint_id INTEGER "
"REFERENCES endpoints(id) ON DELETE SET NULL"
)
for column, ddl in (
("query_string", "VARCHAR(2048) DEFAULT ''"),
("request_body", "TEXT DEFAULT ''"),
("response_body", "TEXT DEFAULT ''"),
):
if cols and column not in cols:
conn.exec_driver_sql(f"ALTER TABLE request_logs ADD COLUMN {column} {ddl}")
service_cols = [row[1] for row in conn.exec_driver_sql("PRAGMA table_info(services)")]
if service_cols and "verify_tls" not in service_cols:
conn.exec_driver_sql("ALTER TABLE services ADD COLUMN verify_tls BOOLEAN DEFAULT 1")
def seed_and_migrate() -> None:
db = SessionLocal()
try:
# Role-era databases: replace role->service grants with per-key grants
# on a service-wide catch-all endpoint, then drop the role tables.
if _table_exists(db, "roles"):
db.execute(text(
"INSERT INTO endpoints (service_id, method, path, description) "
"SELECT id, '*', '/**', 'Migrated: full service access' FROM services"
))
if _table_exists(db, "role_service_access"):
db.execute(text(
"INSERT OR IGNORE INTO key_endpoint_access (api_key_id, endpoint_id) "
"SELECT k.id, e.id "
"FROM api_keys k "
"JOIN users u ON u.id = k.user_id "
"JOIN role_service_access rsa ON rsa.role_id = u.role_id "
"JOIN endpoints e ON e.service_id = rsa.service_id AND e.path = '/**'"
))
db.execute(text("DROP TABLE role_service_access"))
if _table_exists(db, "role_permissions"):
db.execute(text("DROP TABLE role_permissions"))
db.execute(text("ALTER TABLE users DROP COLUMN role_id"))
db.execute(text("DROP TABLE roles"))
db.commit()
print("Migrated role-based access to per-key endpoint grants.")
if db.query(User).count() == 0:
db.add(User(
username=config.DEFAULT_ADMIN_USERNAME,
password_hash=security.hash_password(config.DEFAULT_ADMIN_PASSWORD),
))
db.commit()
print(f"Seeded admin user '{config.DEFAULT_ADMIN_USERNAME}' "
f"(password: '{config.DEFAULT_ADMIN_PASSWORD}' — change it after first login).")
finally:
db.close()
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.create_all(engine)
migrate_schema()
seed_and_migrate()
enable_incremental_vacuum()
retention_task = asyncio.create_task(retention.retention_loop())
yield
retention_task.cancel()
with suppress(asyncio.CancelledError):
await retention_task
await proxy.close_client()
app = FastAPI(title="API Gateway", version="2.0.0", lifespan=lifespan)
app.add_exception_handler(LoginRequired, login_redirect_handler)
@app.get("/", include_in_schema=False)
def root():
return RedirectResponse("/admin")
@app.get("/health", include_in_schema=False)
def health():
return {"status": "ok"}
app.include_router(admin_routes.router)
app.include_router(admin_stats.router)
app.mount("/static", StaticFiles(directory=str(config.BASE_DIR / "app" / "static")), name="static")
# The proxy catch-all (/{slug}/...) must come last so it never shadows
# /admin, /static, /docs or /health.
app.include_router(proxy.router)