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,239 @@
|
||||
{% 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 & 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>/<service-slug>/<path></code> with the header <code>X-API-Key: <key></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 %}
|
||||
Reference in New Issue
Block a user