feat(auth): implement API key authentication and management

Introduce a new API key authentication mechanism to support service-to-service
communication (e.g., Airflow, CI/CD) without browser-based JWT login.

- Add `APIKey` model and `APIKeyPrincipal` dependency for RBAC.
- Implement `require_api_key_or_jwt` dependency with environment scoping.
- Add admin CRUD endpoints for API key lifecycle management.
- Add API key management UI in System Settings.
- Update maintenance API to support explicit `environment_id` and API key auth.
- Add i18n support for API keys and connection settings.
- Include Python and Bash usage examples in the `examples/` directory.
- Update technical specifications and documentation.
This commit is contained in:
2026-05-25 11:35:27 +03:00
parent 31680a1bc9
commit 320f82ab95
39 changed files with 3359 additions and 707 deletions

View File

@@ -0,0 +1,407 @@
<!-- #region ApiKeysTab [C:3] [TYPE Component] [SEMANTICS settings, api_keys, admin, crud] -->
<!-- @BRIEF API Key management tab — list, generate (one-time reveal), and revoke API keys. -->
<!-- @LAYER UI -->
<!-- @RELATION DEPENDS_ON -> [ApiModule] -->
<!-- @RELATION DEPENDS_ON -> [I18nModule] -->
<!-- @RELATION DEPENDS_ON -> [ToastsModule] -->
<!-- @UX_STATE Loading -> Skeleton rows, generate button disabled -->
<!-- @UX_STATE Loaded -> Keys table with revoke buttons, generate form collapsed -->
<!-- @UX_STATE Generating -> Spinner on generate button -->
<!-- @UX_STATE Revoking -> Confirm dialog visible -->
<!-- @UX_STATE Revealing -> Warning panel with raw key and copy button -->
<!-- @UX_FEEDBACK Toast on generate success/error, revoke success/error -->
<!-- @UX_RECOVERY Retry button on load error -->
<!-- @UX_REACTIVITY Props -> $props(), LocalState -> $state(keys, isLoading, error) -->
<script>
import { onMount } from 'svelte';
import { t } from '$lib/i18n';
import { api } from '$lib/api.js';
import { addToast } from '$lib/toasts';
let keys = $state([]);
let environments = $state([]);
let isLoading = $state(true);
let error = $state(null);
let showGenerateForm = $state(false);
let isGenerating = $state(false);
let revokingId = $state(null);
let revealedKey = $state(null);
// Generate form state
let formName = $state('');
let formEnvironmentId = $state('');
let formPermissions = $state([]);
let formExpiresAt = $state('');
const AVAILABLE_PERMISSIONS = [
'maintenance:start',
'maintenance:end',
'maintenance:end_all',
];
const PERMISSION_LABELS = {
'maintenance:start': 'maintenance:start',
'maintenance:end': 'maintenance:end',
'maintenance:end_all': 'maintenance:end_all',
};
onMount(() => {
loadKeys();
loadEnvironments();
});
async function loadKeys() {
isLoading = true;
error = null;
try {
keys = await api.listApiKeys();
} catch (err) {
error = err.message || 'Failed to load API keys';
console.error('[ApiKeysTab] Failed to load keys:', err);
} finally {
isLoading = false;
}
}
async function loadEnvironments() {
try {
environments = await api.getEnvironmentsList();
} catch (err) {
// Non-critical — environments are optional
console.error('[ApiKeysTab] Failed to load environments:', err);
}
}
function togglePermission(perm) {
if (formPermissions.includes(perm)) {
formPermissions = formPermissions.filter(p => p !== perm);
} else {
formPermissions = [...formPermissions, perm];
}
}
function resetForm() {
formName = '';
formEnvironmentId = '';
formPermissions = [];
formExpiresAt = '';
showGenerateForm = false;
}
async function handleGenerate() {
if (!formName.trim()) {
addToast('Name is required', 'error');
return;
}
if (formPermissions.length === 0) {
addToast('At least one permission is required', 'error');
return;
}
isGenerating = true;
try {
const payload = {
name: formName.trim(),
permissions: formPermissions,
environment_id: formEnvironmentId || null,
expires_at: formExpiresAt ? new Date(formExpiresAt).toISOString() : null,
};
const result = await api.createApiKey(payload);
revealedKey = result;
keys = [{
id: result.id,
name: result.name,
prefix: result.prefix,
environment_id: result.environment_id,
permissions: result.permissions,
active: result.active,
created_at: result.created_at,
expires_at: result.expires_at,
last_used_at: null,
}, ...keys];
resetForm();
addToast('API key generated successfully', 'success');
} catch (err) {
addToast(err.message || 'Failed to generate API key', 'error');
} finally {
isGenerating = false;
}
}
async function handleRevoke(keyId) {
revokingId = keyId;
try {
await api.revokeApiKey(keyId);
keys = keys.map(k => k.id === keyId ? { ...k, active: false } : k);
addToast('API key revoked', 'success');
} catch (err) {
addToast(err.message || 'Failed to revoke API key', 'error');
} finally {
revokingId = null;
}
}
function confirmRevoke(key) {
if (confirm('All tools using this key will get 401. Are you sure?')) {
handleRevoke(key.id);
}
}
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
addToast('Copied!', 'success');
} catch {
addToast('Failed to copy', 'error');
}
}
function dismissReveal() {
revealedKey = null;
}
function formatDate(dateStr) {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString();
}
function getStatusDot(active) {
return active
? 'bg-green-500'
: 'bg-red-500';
}
</script>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-bold text-gray-900">{$t.api_keys?.title || 'API Keys'}</h2>
<p class="text-sm text-gray-500 mt-1">
{$t.api_keys?.description || 'Manage API keys for service-to-service authentication.'}
</p>
</div>
<button
class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50 text-sm"
onclick={() => { showGenerateForm = !showGenerateForm; }}
disabled={isLoading}
>
{$t.api_keys?.generate?.title || 'Generate New API Key'}
</button>
</div>
<!-- Generate Form -->
{#if showGenerateForm}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 space-y-4">
<h3 class="font-semibold text-gray-900">{$t.api_keys?.generate?.title || 'Generate New API Key'}</h3>
<!-- Name -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{$t.api_keys?.generate?.name || 'Key Name'} *
</label>
<input
type="text"
bind:value={formName}
placeholder={$t.api_keys?.generate?.name_placeholder || 'e.g. CI/CD Pipeline'}
class="block w-full max-w-md px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<!-- Environment -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{$t.api_keys?.generate?.environment || 'Environment'}
</label>
<select
bind:value={formEnvironmentId}
class="block w-full max-w-md px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
<option value="">{$t.api_keys?.generate?.environment_all || 'All Environments'}</option>
{#each environments as env (env.id)}
<option value={env.id}>{env.name}</option>
{/each}
</select>
</div>
<!-- Permissions -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{$t.api_keys?.generate?.permissions || 'Permissions'} *
</label>
<div class="space-y-1">
{#each AVAILABLE_PERMISSIONS as perm}
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={formPermissions.includes(perm)}
onchange={() => togglePermission(perm)}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span class="font-mono text-xs">{perm}</span>
</label>
{/each}
</div>
</div>
<!-- Expires At -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{$t.api_keys?.generate?.expires_at || 'Expires At (optional)'}
</label>
<input
type="date"
bind:value={formExpiresAt}
class="block w-full max-w-md px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<!-- Buttons -->
<div class="flex gap-2 pt-2">
<button
class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50 text-sm flex items-center gap-2"
onclick={handleGenerate}
disabled={isGenerating}
>
{#if isGenerating}
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
{/if}
{$t.api_keys?.generate?.generate || 'Generate Key'}
</button>
<button
class="border border-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-50 text-sm"
onclick={resetForm}
>
{$t.api_keys?.generate?.cancel || 'Cancel'}
</button>
</div>
</div>
{/if}
<!-- Revealed Key Panel -->
{#if revealedKey}
<div class="bg-amber-50 border border-amber-400 rounded-lg p-4 space-y-3">
<div class="flex items-start gap-2">
<span class="text-amber-600 text-lg" aria-hidden="true">⚠️</span>
<div>
<p class="text-sm font-medium text-amber-800">
{$t.api_keys?.reveal?.warning || 'This is the ONLY time the key will be shown. Copy it now.'}
</p>
<pre class="mt-2 p-3 bg-amber-100 rounded text-sm font-mono break-all select-all">{revealedKey.raw_key}</pre>
</div>
</div>
<div class="flex gap-2">
<button
class="bg-amber-600 text-white px-4 py-2 rounded-md hover:bg-amber-700 text-sm"
onclick={() => copyToClipboard(revealedKey.raw_key)}
>
{$t.api_keys?.reveal?.copy || 'Copy to Clipboard'}
</button>
<button
class="border border-amber-300 text-amber-700 px-4 py-2 rounded-md hover:bg-amber-100 text-sm"
onclick={dismissReveal}
>
{$t.api_keys?.reveal?.dismiss || 'Dismiss'}
</button>
</div>
</div>
{/if}
<!-- Error State -->
{#if error}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded flex items-center justify-between">
<span class="text-sm">{error}</span>
<button
class="px-3 py-1 bg-red-600 text-white rounded text-sm hover:bg-red-700"
onclick={loadKeys}
>
Retry
</button>
</div>
{:else if isLoading}
<!-- Loading State -->
<div class="space-y-2">
{#each [1, 2, 3] as _}
<div class="h-12 animate-pulse bg-gray-200 rounded"></div>
{/each}
</div>
{:else if keys.length === 0}
<!-- Empty State -->
<div class="text-center py-8 text-gray-500 text-sm border-2 border-dashed border-gray-200 rounded-lg">
{$t.api_keys?.empty || 'No API keys configured.'}
</div>
{:else}
<!-- Keys Table -->
<div class="overflow-x-auto border border-gray-200 rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.api_keys?.table?.name || 'Name'}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.api_keys?.table?.prefix || 'Prefix'}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.api_keys?.table?.environment || 'Environment'}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.api_keys?.table?.permissions || 'Permissions'}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.api_keys?.table?.status || 'Status'}
</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.api_keys?.table?.actions || 'Actions'}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each keys as key (key.id)}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-medium text-gray-900">{key.name}</td>
<td class="px-4 py-3 text-sm font-mono text-gray-500">{key.prefix}...</td>
<td class="px-4 py-3 text-sm text-gray-500">{key.environment_id || '—'}</td>
<td class="px-4 py-3 text-sm text-gray-500">
<div class="flex flex-wrap gap-1">
{#each key.permissions as perm}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">{perm}</span>
{/each}
</div>
</td>
<td class="px-4 py-3 text-sm">
<span class="flex items-center gap-1.5">
<span class="inline-block w-2.5 h-2.5 rounded-full {getStatusDot(key.active)}" aria-hidden="true"></span>
{key.active
? ($t.api_keys?.status?.active || 'Active')
: ($t.api_keys?.status?.revoked || 'Revoked')}
</span>
</td>
<td class="px-4 py-3 text-sm text-right">
{#if key.active}
<button
class="text-red-600 hover:text-red-800 disabled:opacity-50 text-xs font-medium"
onclick={() => confirmRevoke(key)}
disabled={revokingId === key.id}
>
{#if revokingId === key.id}
<span class="inline-block animate-pulse">Revoking...</span>
{:else}
{$t.api_keys?.revoke?.revoke || 'Revoke'}
{/if}
</button>
{:else}
<span class="text-gray-400 text-xs"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- #endregion ApiKeysTab -->