Files
Samuel AmarandClaude Haiku 4.5 77d7a50fa9 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>
2026-07-29 14:00:22 +02:00

240 lines
10 KiB
HTML

{% extends "base.html" %}
{% block title %}API Keys · API Gateway{% endblock %}
{% macro trash(label) %}
<button class="icon" title="{{ label }}" aria-label="{{ label }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
</button>
{% endmacro %}
{% macro pencil(label, modal) %}
<button class="icon edit" data-modal="{{ modal }}" title="{{ label }}" aria-label="{{ label }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
</button>
{% endmacro %}
{% macro close_button() %}
<button type="button" class="icon modal-close" title="Close" aria-label="Close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
{% endmacro %}
{% macro tree_node(node, granted) %}
{% if node.children %}
<details open>
<summary>
<label onclick="event.stopPropagation()">
<input type="checkbox" class="group-box"> <span class="seg">{{ node.name }}</span>
</label>
</summary>
<div class="tree-children">
{% for e in node.endpoints | sort(attribute='method') %}
<label>
<input type="checkbox" name="endpoint_ids" value="{{ e.id }}" {% if e.id in granted %}checked{% endif %}>
<span class="mchip">{{ 'ANY' if e.method == '*' else e.method }}</span> {{ node.name }}
{% if e.description %}<span class="ep-desc">— {{ e.description }}</span>{% endif %}
</label>
{% endfor %}
{% for name, child in node.children | dictsort %}
{{ tree_node(child, granted) }}
{% endfor %}
</div>
</details>
{% else %}
{% for e in node.endpoints | sort(attribute='method') %}
<label>
<input type="checkbox" name="endpoint_ids" value="{{ e.id }}" {% if e.id in granted %}checked{% endif %}>
<span class="mchip">{{ 'ANY' if e.method == '*' else e.method }}</span> {{ node.name }}
{% if e.description %}<span class="ep-desc">— {{ e.description }}</span>{% endif %}
</label>
{% endfor %}
{% endif %}
{% endmacro %}
{% macro endpoint_picker(services, trees, synced, granted) %}
<div class="tree">
{% for s in services %}
<details open>
<summary>
<label onclick="event.stopPropagation()">
<input type="checkbox" class="group-box">
<span class="seg">{{ s.name }}</span>
<code>/{{ s.slug }}</code>
{% if synced.get(s.id) is sameas false %}<span class="ep-desc">(no OpenAPI document — catalog not auto-synced)</span>{% endif %}
</label>
</summary>
<div class="tree-children">
{% for e in trees[s.id].endpoints | sort(attribute='method') %}
<label>
<input type="checkbox" name="endpoint_ids" value="{{ e.id }}" {% if e.id in granted %}checked{% endif %}>
<span class="mchip">{{ 'ANY' if e.method == '*' else e.method }}</span> /
{% if e.description %}<span class="ep-desc">— {{ e.description }}</span>{% endif %}
</label>
{% endfor %}
{% for name, child in trees[s.id].children | dictsort %}
{{ tree_node(child, granted) }}
{% endfor %}
{% if not trees[s.id].children and not trees[s.id].endpoints %}
<span class="hint">No endpoints known for this service.</span>
{% endif %}
</div>
</details>
{% else %}
<span class="hint">Register a service first.</span>
{% endfor %}
</div>
{% endmacro %}
{% block content %}
<div class="row" style="margin-bottom:20px">
<h1 style="margin:0">API Keys &amp; Access</h1>
<button class="right" data-modal="modal-new-key">+ Issue key</button>
</div>
{% if new_key %}
<div class="alert success">
New API key created — copy it now, it will not be shown again:<br>
<code style="font-size:14px">{{ new_key }}</code>
</div>
{% endif %}
<div class="card">
<table>
<thead><tr><th>Name</th><th>Owner</th><th>Key</th><th>Access</th><th>Rate limit</th><th>Last used</th><th>On</th><th></th></tr></thead>
<tbody>
{% for k in keys %}
<tr>
<td class="strong">{{ k.name }}</td>
<td>{{ k.user.username }}</td>
<td><code>{{ k.prefix }}…</code></td>
<td>
{% if k.endpoints %}{{ k.endpoints | length }} endpoint{{ '' if k.endpoints | length == 1 else 's' }}
{% else %}<span class="badge off">none</span>{% endif %}
</td>
<td>{{ k.rate_limit_per_minute if k.rate_limit_per_minute else '∞' }}/min</td>
<td>{{ k.last_used_at.strftime("%Y-%m-%d %H:%M") if k.last_used_at else 'never' }}</td>
<td>
<form class="inline" method="post" action="/admin/keys/{{ k.id }}/toggle">
<label class="switch" title="{{ 'Revoke' if k.is_active else 'Restore' }}">
<input type="checkbox" {% if k.is_active %}checked{% endif %} onchange="this.form.submit()">
<span class="slider"></span>
</label>
</form>
</td>
<td style="white-space:nowrap">
{{ pencil('Edit access for ' ~ k.name, 'modal-key-' ~ k.id) }}
<form class="inline" method="post" action="/admin/keys/{{ k.id }}/delete"
data-confirm="Permanently deletes key {{ k.name }}">
{{ trash('Delete key ' ~ k.name) }}
</form>
</td>
</tr>
{% else %}
<tr><td colspan="8">No API keys yet.</td></tr>
{% endfor %}
</tbody>
</table>
<div class="hint">Endpoint catalogs are mirrored from each service's OpenAPI document
(refreshed at most every 5 minutes; grants on removed endpoints are cleaned up).
<a href="#" id="sync-now">Refresh now</a>
<span id="sync-status"></span></div>
</div>
<div class="card">
<h2>How consumers call the gateway</h2>
<p style="color:var(--ink-2)">Send requests to <code>/&lt;service-slug&gt;/&lt;path&gt;</code> with the header <code>X-API-Key: &lt;key&gt;</code>. The request must match one of the service's endpoints and the key must hold a grant on it.</p>
</div>
{% for k in keys %}
<div class="modal-overlay" id="modal-key-{{ k.id }}" role="dialog" aria-modal="true" aria-label="Edit access for {{ k.name }}">
<div class="modal">
<h2>Access for {{ k.name }} <span class="hint">owned by {{ k.user.username }}</span></h2>
{{ close_button() }}
<form method="post" action="/admin/keys/{{ k.id }}/access">
{% set granted = k.endpoints | map(attribute='id') | list %}
{{ endpoint_picker(services, trees, synced, granted) }}
<div class="row" style="margin-top:14px">
<label style="margin:0">Rate limit/min (0 = unlimited)</label>
<input type="number" name="rate_limit_per_minute" value="{{ k.rate_limit_per_minute }}" min="0" style="max-width:110px">
<button>Save</button>
</div>
</form>
</div>
</div>
{% endfor %}
<div class="modal-overlay" id="modal-new-key" role="dialog" aria-modal="true" aria-label="Issue a new key">
<div class="modal">
<h2>Issue a new key</h2>
{{ close_button() }}
<form method="post" action="/admin/keys">
<div class="grid cols-3">
<div><label>Key name</label><input type="text" name="name" placeholder="mobile-app-prod" required></div>
<div><label>Owner</label>
<select name="user_id">
{% for u in users %}<option value="{{ u.id }}">{{ u.username }}</option>{% endfor %}
</select>
</div>
<div><label>Rate limit/min (0 = unlimited)</label>
<input type="number" name="rate_limit_per_minute" value="60" min="0"></div>
</div>
<label>Endpoint access</label>
{{ endpoint_picker(services, trees, synced, []) }}
<div style="margin-top:14px"><button>Create key</button></div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// The page renders instantly from the stored catalog; the OpenAPI sync runs
// here in the background. Spinners sit on the endpoint trees while it's
// pending, and the page reloads only if the catalog actually changed.
async function syncCatalogs(force = false) {
const trees = document.querySelectorAll('.tree');
const status = document.getElementById('sync-status');
trees.forEach(t => t.classList.add('syncing'));
status.innerHTML = '<span class="spinner"></span> refreshing catalogs…';
try {
const r = await GW.fetchJSON('/admin/api/endpoints/sync' + (force ? '?force=1' : ''));
if (r.changed) { location.reload(); return; }
status.textContent = 'up to date';
} catch {
status.textContent = 'refresh failed';
} finally {
trees.forEach(t => t.classList.remove('syncing'));
}
}
syncCatalogs();
document.getElementById('sync-now').addEventListener('click', e => {
e.preventDefault();
syncCatalogs(true);
});
// Hierarchical picker: a group checkbox selects everything beneath it;
// leaf changes roll their state back up (checked / indeterminate).
function refreshGroups(scope) {
[...scope.querySelectorAll('.tree details')].reverse().forEach(d => {
const group = d.querySelector(':scope > summary .group-box');
const leaves = d.querySelectorAll('input[name=endpoint_ids]');
if (!group || !leaves.length) return;
const checked = [...leaves].filter(b => b.checked).length;
group.checked = checked === leaves.length;
group.indeterminate = checked > 0 && checked < leaves.length;
});
}
document.querySelectorAll('.tree').forEach(tree => {
tree.addEventListener('change', e => {
if (e.target.classList.contains('group-box')) {
const details = e.target.closest('details');
details.querySelectorAll('input[name=endpoint_ids]').forEach(b => { b.checked = e.target.checked; });
}
refreshGroups(tree);
});
refreshGroups(tree);
});
</script>
{% endblock %}