diff --git a/docs/openapi.json b/docs/openapi.json index ff2bab8..9047a6c 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -4028,7 +4028,7 @@ ], "parameters": [ { - "name": "id", + "name": "keyId", "in": "query", "required": true, "description": "Key id.", @@ -4066,7 +4066,51 @@ "post": { "operationId": "postKeysRevoke", "summary": "Revoke a key, keeping the record.", - "description": "Owner **session** only — no API key can hold a role, so no key reaches this.\n\nBody fields:\n- `id` — Key id.", + "description": "Owner **session** only — no API key can hold a role, so no key reaches this.\n\nBody fields:\n- `keyId` — Key id.", + "tags": [ + "keys" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "description": "Success." + }, + "400": { + "description": "Malformed request, or a missing confirmation." + }, + "401": { + "description": "No usable credential." + }, + "403": { + "description": "Authenticated, but not permitted — the body names what was needed." + }, + "404": { + "description": "No such server, or no such route." + }, + "409": { + "description": "Conflicts with the current state (running server, name taken, …)." + }, + "429": { + "description": "Rate limited. `Retry-After` says for how long." + } + } + } + }, + "/api/v1/keys/disabled": { + "post": { + "operationId": "postKeysDisabled", + "summary": "Switch a key off or back on. Unlike revoking, this is reversible.", + "description": "Owner **session** only — no API key can hold a role, so no key reaches this.\n\nBody fields:\n- `keyId` — Key id.\n- `disabled` — true to switch off, false to switch back on.\n\nA revoked key cannot be switched back on; that answer is 409.", "tags": [ "keys" ], diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index e35be95..48841ad 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -467,6 +467,16 @@ export function registerIpc(): void { }) return created }) + H(IPC.apiKeyDisabled, (_e, keyId: string, disabled: boolean) => { + const k = apikeys.setKeyDisabled(keyId, disabled) + audit.record({ + source: 'panel', + action: disabled ? 'apikey.disable' : 'apikey.enable', + actor: 'operator', + target: k.label + }) + return k + }) H(IPC.apiKeyRevoke, (_e, keyId: string) => { const k = apikeys.revokeKey(keyId) audit.record({ source: 'panel', action: 'apikey.revoke', actor: 'operator', target: k.label }) diff --git a/src/main/smoke.ts b/src/main/smoke.ts index 07235fb..b83bbd2 100644 --- a/src/main/smoke.ts +++ b/src/main/smoke.ts @@ -68,6 +68,7 @@ import { CRATE_CSS } from '@shared/crateUi' import { openApiDocument } from '@shared/openapi' import { clampGrace, deliveryDecision, queueReason, HOLD_REASONS } from '@shared/delivery' import { API_PREFIX, API_ROUTES } from '@shared/apiSurface' +import { usageSamples, API_KEY_HEADER } from '@shared/apiUsage' import { MODERATION_ACTIONS, WORLD_ACTIONS } from '@shared/ops' import { removeServer } from './core/serverRegistry' import * as sf from './core/serverFiles' @@ -6724,6 +6725,102 @@ export async function runWebSmoke(): Promise { if (r.status !== 403) return fail('approving without settings expected 403, got ' + r.status) } + // ---- a key can be switched off and back on (#EK) ---- + { + const k2 = apikeys.createKey({ label: 'smoke_toggle', scopes: ['view'], servers: 'all' }) + const probe = '/api/servers/' + id + if ((await kget(probe, k2.secret)).status !== 200) return fail('a fresh key could not read') + + // Drive the call from the route table rather than from what the + // handler happens to read. Those two disagreed until #142 — the doc + // said `id`, the server read `keyId` — and an integrator who followed + // the doc exactly got a 404 from revoke and a 200 from a delete that + // deleted nothing. A test that spells the field itself would have + // stayed green through all of it. + const doc = API_ROUTES.find((rt) => rt.path === '/keys/disabled' && rt.method === 'POST') + const idField = Object.keys(doc?.body ?? {}).find((f) => f !== 'disabled') + if (!idField) return fail('the disable route documents no key field') + + // Owner session only. A key must never be able to switch keys off: + // that is the same escalation that keeps key minting off the API. + const byKey = await kpost('/api/keys/disabled', { [idField]: k2.key.id, disabled: true }, superKey.secret) + if (byKey.status !== 403) return fail('an API key could disable a key: ' + byKey.status) + + const viaHttp = await post('/api/keys/disabled', { [idField]: k2.key.id, disabled: true }, ot) + if (viaHttp.status !== 200) { + return fail('owner disable over HTTP: ' + viaHttp.status + ' ' + (await viaHttp.text())) + } + if (!((await viaHttp.json()) as { disabled?: boolean }).disabled) { + return fail('the route did not report it off') + } + + // Disabling is checked by `isKeyUsable`, which is the single answer to + // "may this key be used" — a switch honoured in some places and not + // others is worse than no switch. + const off = await kget(probe, k2.secret) + if (off.status !== 401) return fail('a disabled key still worked: ' + off.status) + + // ...and reversible, unlike revoke. That is the whole reason it is a + // separate flag: pausing an integration must not require destroying + // its credential. + apikeys.setKeyDisabled(k2.key.id, false) + if ((await kget(probe, k2.secret)).status !== 200) return fail('a re-enabled key did not work') + + // A revoked key cannot be quietly resurrected by the reversible one — + // over HTTP too, where the answer is 409 rather than a thrown string. + apikeys.revokeKey(k2.key.id) + let threw = false + try { + apikeys.setKeyDisabled(k2.key.id, false) + } catch { + threw = true + } + if (!threw) return fail('enabling resurrected a revoked key') + const undead = await post('/api/keys/disabled', { [idField]: k2.key.id, disabled: false }, ot) + if (undead.status !== 409) return fail('reviving a revoked key over HTTP: ' + undead.status) + if ((await kget(probe, k2.secret)).status !== 401) return fail('a revoked key still worked') + apikeys.deleteKey(k2.key.id) + + // The usage samples are what an operator follows to make a first + // request, so they have to name the header the server actually reads + // and carry no fake secret. + const samples = usageSamples({ baseUrl: 'http://127.0.0.1:8080' }) + if (samples.length < 3) return fail('too few usage samples') + for (const s of samples) { + if (!s.code.includes(API_KEY_HEADER)) return fail(s.lang + ' does not send the key header') + // Not `API_PREFIX + '/servers'`: two of the three samples put the + // prefix in a BASE constant and append the path at the call site. + if (!s.code.includes(API_PREFIX)) return fail(s.lang + ' does not use the versioned prefix') + if (!s.code.includes('/servers')) return fail(s.lang + ' does not call a real route') + if (!s.code.includes('PASTE_YOUR_KEY_HERE')) { + return fail(s.lang + ' has something that looks like a real key in it') + } + } + + // The panel serves this function as source, via `.toString()`. Reading + // the source for a forbidden identifier would not catch the bug: the + // bundler *renames* module bindings, so the dead reference is not + // called `API_PREFIX` by the time it reaches the page. Run it the way + // the page does instead — with no scope around it at all — which is + // the only thing that turns the ReferenceError into a failure here. + let detached: typeof usageSamples + try { + detached = new Function('return (' + usageSamples.toString() + ')')() as typeof usageSamples + } catch (e) { + return fail('usageSamples could not even be re-parsed: ' + String(e)) + } + try { + const outside = detached({ baseUrl: 'http://127.0.0.1:8080' }) + if (JSON.stringify(outside) !== JSON.stringify(samples)) { + return fail('usageSamples gives the page a different answer than the app') + } + } catch (e) { + return fail('usageSamples leans on module scope it will not have in the page: ' + String(e)) + } + const withKey = usageSamples({ baseUrl: 'http://x', key: 'msms_abc.def' }) + if (!withKey[0].code.includes('msms_abc.def')) return fail('a supplied key did not reach the sample') + } + // ---- the bridge plugin (#103) ---- { const bBase = '/api/servers/' + id + '/bridge' diff --git a/src/main/web/apikeys.ts b/src/main/web/apikeys.ts index 9ca8f35..48bfdf6 100644 --- a/src/main/web/apikeys.ts +++ b/src/main/web/apikeys.ts @@ -73,6 +73,7 @@ const view = (k: StoredKey): ApiKeyView => ({ lastUsedAt: k.lastUsedAt, expiresAt: k.expiresAt, revoked: k.revoked, + disabled: k.disabled, canAudit: k.canAudit }) @@ -132,6 +133,23 @@ export function revokeKey(id: string): ApiKeyView { return view(k) } +/** + * Switch a key off, or back on. + * + * Reversible, unlike `revokeKey`. A revoked key stays revoked — that is the + * point of revocation — so this refuses rather than quietly resurrecting one. + */ +export function setKeyDisabled(id: string, disabled: boolean): ApiKeyView { + load() + const k = keys.find((x) => x.id === id) + if (!k) throw new Error('key-not-found') + if (k.revoked) throw new Error('key-revoked') + if (disabled) k.disabled = true + else delete k.disabled + save() + return view(k) +} + export function deleteKey(id: string): void { load() keys = keys.filter((x) => x.id !== id) diff --git a/src/main/web/panelHtml.ts b/src/main/web/panelHtml.ts index 2d463f6..b3b7ee7 100644 --- a/src/main/web/panelHtml.ts +++ b/src/main/web/panelHtml.ts @@ -4,6 +4,8 @@ import { CRATE_CSS, CRATE_JS, CRATE_MODAL_HTML } from '@shared/crateUi' import { STORE_CSS, STORE_JS, STORE_MODAL_HTML, CRATE_ICON_SVG } from '@shared/storeUi' import { avatarUrl } from '@shared/profile' import { iconSvg, STRUCTURE_ICONS } from '@shared/mapIcons' +import { usageSamples, API_KEY_HEADER, USAGE_NOTES } from '@shared/apiUsage' +import { API_PREFIX } from '@shared/apiSurface' import { MAP_CSS, MAP_HTML, MAP_JS } from '@shared/mapUi' export function getPanelHtml(): string { return ` @@ -894,6 +896,12 @@ var avatarUrl=${avatarUrl.toString()}; in all three surfaces (#136). Self-contained, like every embedded helper. */ /* Named exactly as the shared module names it: iconSvg is embedded by stringifying it and reads the table through this identifier. */ +/* Self-contained by construction: it reads API_PREFIX through a name the page + defines, and calls nothing. See #116. */ +var API_PREFIX=${JSON.stringify(API_PREFIX)}; +var API_KEY_HEADER=${JSON.stringify(API_KEY_HEADER)}; +var API_USAGE_NOTES=${JSON.stringify(USAGE_NOTES)}; +var apiUsageSamples=${usageSamples.toString()}; var STRUCTURE_ICONS=${JSON.stringify(STRUCTURE_ICONS)}; var MAP_ICONS=STRUCTURE_ICONS; function mapIconFor(kind){return STRUCTURE_ICONS[kind]||STRUCTURE_ICONS.other} @@ -1142,7 +1150,10 @@ function loadKeys(){renderKeyScopes(); if(r.status===403){el.innerHTML='
Owner access required.
';return} if(!r.ok){el.innerHTML='
Could not load API keys.
';return} renderKeys(r.body.keys||[])})} -function keyState(k){if(k.revoked)return 'revoked';if(k.expiresAt&&k.expiresAt<=Date.now())return 'expired';return 'active'} +/* Disabled is its own state, not a kind of revoked: one is reversible and the + other is what you do to a key you think has leaked (#EK). */ +function keyState(k){if(k.revoked)return 'revoked';if(k.disabled)return 'disabled'; + if(k.expiresAt&&k.expiresAt<=Date.now())return 'expired';return 'active'} function renderKeys(keys){var el=document.getElementById('keysList'); if(!keys.length){el.innerHTML='
No API keys yet.
';return} el.innerHTML='
'+keys.map(function(k){var st=keyState(k); @@ -1152,8 +1163,31 @@ function renderKeys(keys){var el=document.getElementById('keysList'); '
'+esc((k.scopes||[]).join(', ')||'no permissions')+' · '+esc(where)+ (k.expiresAt?' · expires '+new Date(k.expiresAt).toLocaleDateString():'')+ (k.lastUsedAt?' · last used '+new Date(k.lastUsedAt).toLocaleString():' · never used')+'
'+ + ''+ + (k.revoked?'':'')+ (k.revoked?'':'')+ - ''}).join('')+''} + ''+ + ''}).join('')+''} +/* Disable is reversible; revoke is not. Two buttons because they are two + different decisions. */ +function toggleKey(id,off){ + api('/api/keys/disabled',{method:'POST',body:JSON.stringify({keyId:id,disabled:off})}).then(function(r){ + if(!r.ok){alert(r.body&&r.body.error==='key-revoked'?'That key is revoked — issue a new one.':'Could not change the key.');return} + loadKeys()})} +/* The samples an operator needs to make a first request. The real secret is + gone after creation, so these carry a clearly marked placeholder rather than + something that looks like a key and is not. */ +function showKeyUsage(id){ + var box=document.getElementById('ku_'+id);if(!box)return; + if(!box.classList.contains('hidden')){box.classList.add('hidden');return} + box.classList.remove('hidden'); + var s=apiUsageSamples({baseUrl:location.origin}); + box.innerHTML='
'+ + API_USAGE_NOTES.map(function(n){return esc(n)}).join('
')+'
'+ + s.map(function(x){return '
'+esc(x.lang)+''+ + '
'+esc(x.code)+'
'}).join('')} function createKey(){var label=document.getElementById('kLabel').value.trim();if(!label){alert('Give the key a label.');return} var scopes=KEY_SCOPES.filter(function(s){return kScopeSel[s]}); var all=document.getElementById('kAll').checked; diff --git a/src/main/web/server.ts b/src/main/web/server.ts index 700cb9b..d2a9169 100644 --- a/src/main/web/server.ts +++ b/src/main/web/server.ts @@ -2260,6 +2260,26 @@ async function handlePanel(req: IncomingMessage, res: ServerResponse): Promise ({}))) as { keyId?: string; disabled?: boolean } + try { + const k = apikeys.setKeyDisabled(b.keyId ?? '', !!b.disabled) + audit.record({ + source: 'webpanel', + action: b.disabled ? 'apikey.disable' : 'apikey.enable', + actor: user.username, + target: k.label, + ok: true, + ip + }) + return sendJson(res, 200, k) + } catch (e) { + const why = String(e).includes('revoked') ? 'key-revoked' : 'key-not-found' + return sendJson(res, why === 'key-revoked' ? 409 : 404, { error: why }) + } + } if (path === '/api/keys' && method === 'DELETE') { const keyId = url.searchParams.get('keyId') ?? '' apikeys.deleteKey(keyId) diff --git a/src/preload/index.ts b/src/preload/index.ts index 6fa310d..3be99a9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -82,6 +82,7 @@ const api: MsmsApi = { installBridge: (id) => ipcRenderer.invoke(IPC.bridgeInstall, id), mapTiles: (id, dim, chunks, marks) => ipcRenderer.invoke(IPC.mapTiles, id, dim, chunks, marks), clearMapCache: () => ipcRenderer.invoke(IPC.mapCacheClear), + setApiKeyDisabled: (id, disabled) => ipcRenderer.invoke(IPC.apiKeyDisabled, id, disabled), listJava: (refresh) => ipcRenderer.invoke(IPC.javaList, refresh), resolveJava: (override) => ipcRenderer.invoke(IPC.javaResolve, override), diff --git a/src/renderer/src/locales/en.ts b/src/renderer/src/locales/en.ts index 9e1e973..beb5adb 100644 --- a/src/renderer/src/locales/en.ts +++ b/src/renderer/src/locales/en.ts @@ -908,6 +908,10 @@ export default { keySecretWarn: 'Copy this now. It is stored hashed and will never be shown again.', keyCopy: 'Copy', keyCopied: 'Key copied', + keyDisabled: 'disabled', + keyDisable: 'Switch this key off (reversible)', + keyEnable: 'Switch this key back on', + keyHowTo: 'How to use this key', keyRevoke: 'Revoke', keyRevoked: 'revoked', keyActive: 'active', diff --git a/src/renderer/src/locales/tr.ts b/src/renderer/src/locales/tr.ts index a7c9f97..52b236b 100644 --- a/src/renderer/src/locales/tr.ts +++ b/src/renderer/src/locales/tr.ts @@ -913,6 +913,10 @@ const tr: typeof en = { 'Şimdi kopyala. Anahtar özetlenerek saklanır ve bir daha asla gösterilmez.', keyCopy: 'Kopyala', keyCopied: 'Anahtar kopyalandı', + keyDisabled: 'devre dışı', + keyDisable: 'Bu anahtarı kapat (geri alınabilir)', + keyEnable: 'Bu anahtarı tekrar aç', + keyHowTo: 'Bu anahtar nasıl kullanılır', keyRevoke: 'İptal et', keyRevoked: 'iptal edildi', keyActive: 'etkin', diff --git a/src/renderer/src/views/WebPanelView.tsx b/src/renderer/src/views/WebPanelView.tsx index bbe44fa..b42a9c5 100644 --- a/src/renderer/src/views/WebPanelView.tsx +++ b/src/renderer/src/views/WebPanelView.tsx @@ -11,12 +11,16 @@ import { X, Ban, Copy, - Terminal + Terminal, + BookOpen, + Pause, + Play } from 'lucide-react' import { useStore } from '../store' import { SCOPES } from '@shared/web' import { effectiveScopes } from '@shared/rbac' import { isKeyUsable } from '@shared/apikeys' +import { usageSamples, USAGE_NOTES } from '@shared/apiUsage' import type { RoleDef } from '@shared/rbac' import type { ApiKeyView, KeyServers } from '@shared/apikeys' import type { Scope, WebRole, WebStatus, WebUserView } from '@shared/web' @@ -50,6 +54,7 @@ export function WebPanelView(): JSX.Element { // ---- API keys (#48) ---- const [keys, setKeys] = useState([]) + const [usageFor, setUsageFor] = useState(null) const [keyLabel, setKeyLabel] = useState('') const [keyScopes, setKeyScopes] = useState(['view']) const [keyAllServers, setKeyAllServers] = useState(true) @@ -504,9 +509,11 @@ export function WebPanelView(): JSX.Element { {k.revoked ? t('web.keyRevoked') - : usable - ? t('web.keyActive') - : t('web.keyExpired')} + : k.disabled + ? t('web.keyDisabled') + : usable + ? t('web.keyActive') + : t('web.keyExpired')}
@@ -523,6 +530,30 @@ export function WebPanelView(): JSX.Element { : ` · ${t('web.keyNeverUsed')}`}
+ {/* A key nobody can work out how to send is a key that does + nothing — the route list says which scope each call needs + and never how to make one. */} + + {/* Reversible. Revoke, below, is not — pausing an integration + and destroying a leaked credential are different acts. */} + {!k.revoked && ( + + )} {!k.revoked && (