Add recoverable encrypted integration keys and web management

This commit is contained in:
Nico committed 2026-10-23 08:46:24 +02:00
1 parent ca4ab48fdc
commit cbf112b0f8
14 files changed
+285 -5

No files matched your search

+2
View File
@@ -19,6 +19,8 @@ The [web console](docs/web-console.md) shows active workers, case progress and t
charts, with corrections accounted for separately, and accepts new/return submissions.
The [Linux deployment guide](docs/linux-deployment.md) covers systemd services,
protected credentials, HTTPS and non-billable readiness checks.
The [integration key manager](docs/integration-keys.md) lets signed-in users
create, reveal and revoke project-scoped machine credentials.
Requires Python 3.11 or newer.
+45
View File
@@ -0,0 +1,45 @@
# Integrationsschlüssel
Angemeldete Webnutzer finden unter **API-Schlüssel** eine eigene Verwaltungsseite.
Programmname und mindestens ein Projekt auswählen, dann den Schlüssel erstellen.
**Anzeigen** ruft ihn bei Bedarf erneut ab; **Verbergen** entfernt ihn aus dem
Eingabefeld. **Kopieren** verwendet die Zwischenablage (HTTPS oder localhost).
**Widerrufen** sperrt ihn sofort für nachfolgende API-Anfragen. Bereits laufende
Anfragen werden dadurch nicht abgebrochen. Ein widerrufener Schlüssel wird nicht
mehr angezeigt; für einen Ersatz einen neuen Schlüssel erstellen.
Alle angemeldeten menschlichen Nutzer haben dieselben Verwaltungsrechte.
Maschinenzugänge können weder Schlüssel verwalten noch andere Schlüssel anzeigen.
Webmutationen einschließlich Anzeigen benötigen die vorhandene CSRF-Absicherung.
Das aufrufende Programm sendet `Authorization: Bearer <Schlüssel>` an die
Übergabe-API. Neue Schlüssel erlauben Auftragsübergabe einschließlich Rückgaben
und lesenden Zugriff auf die ausgewählten Projekte. Sie erlauben keine
Betriebssteuerung oder Beantwortung menschlicher Testanfragen. Zugriff auf weitere
Projekte erfordert einen neuen Schlüssel. Bestehende env-basierte Integrationen
bleiben unverändert nutzbar und erscheinen nicht in der SQLite-Schlüsselliste.
## Speicherung und Sicherung
- SQLite unter `<workspace>/runtime/integrations/keys.sqlite3` speichert Metadaten,
SHA-256-Prüfwerte und mit Fernet authentifiziert verschlüsselte Schlüssel.
- Der separate Serverschlüssel entsteht beim ersten Erstellen automatisch unter
`<workspace>/runtime/integration-secrets/master.key`, Dateimodus 0600.
Nutzerkonten und ihre root-verwaltete SQLite-Datei werden nicht verändert.
- Mit `VIANO_INTEGRATION_KEY_FILE` kann die API stattdessen eine separate
Schlüsseldatei verwenden. Vorhandene Dateien müssen einen gültigen Fernet-Key
enthalten und für den API-Dienst lesbar sein; fehlt die Datei beim ersten
Erstellen, muss der Dienst sie im gewählten Verzeichnis anlegen dürfen.
- SQLite und Serverschlüssel getrennt geschützt sichern. Das Laufzeitverzeichnis
enthält damit dauerhaft notwendige Integrationsdaten und darf bei einer
Bereinigung von Sessions nicht pauschal gelöscht werden. Unter Windows passende
Dateisystem-ACLs verwenden; POSIX-Modusbits ersetzen dort keine ACLs.
- Geht der Serverschlüssel verloren oder wird er unpassend ausgetauscht, können
Schlüssel nicht mehr angezeigt werden. Das System ersetzt ihn bei bestehenden
Einträgen nicht stillschweigend. Vorhandene Schlüssel können anhand ihrer
Prüfwerte weiterhin authentifiziert und über die Weboberfläche widerrufen werden.
Zur Wiederherstellung die richtige Schlüsseldatei aus der Sicherung zurückspielen.
Nach einem Update die API-Abhängigkeiten neu installieren (`.[api]` umfasst jetzt
`cryptography`) und den API-Dienst neu starten. Eine Datenbankmigration per Hand
ist nicht nötig. Keine Änderung oder Neueingabe des Gemini-Schlüssels erforderlich.
+4
View File
@@ -26,6 +26,10 @@ API und Worker erhalten unterschiedliche Dateien: Nur der Worker erhält Cloud-
und DB-Zugangsdaten. Die Diensthärtung und EnvironmentFile-Nutzung folgen der
[systemd-Dokumentation](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html).
Die [Webverwaltung für Integrationsschlüssel](integration-keys.md) ergänzt die
env-basierte Maschinenanmeldung. Ihre SQLite und separate Schlüsseldatei müssen
ebenfalls gesichert werden; beide liegen standardmäßig unter der Fallablage.
## Installation
Den vertrauenswürdigen BugAnalysis-Checkout als root unter
@@ -0,0 +1,33 @@
# Webverwaltung für Integrationsschlüssel – 2026-10-23
Ergebnis: eigene Webseite mit Programmname, Projektauswahl, Erstellen,
erneutem Anzeigen, Verbergen, Kopieren und Widerrufen. API-Schlüssel werden
authentifiziert verschlüsselt in separater SQLite gespeichert; die separate
Serverschlüsseldatei wird beim ersten Erstellen angelegt. Bestehende env-Zugänge
bleiben nutzbar. Menschliche Nutzerverwaltung unverändert ausschließlich per CLI.
Lokale Prüfung ohne LLM-Aufrufe:
```text
python -m pytest tests/test_integration_keys.py tests/test_web_auth.py tests/test_deployment.py tests/test_human_web.py tests/test_dashboard.py tests/profiles/vcore/test_intake_api.py -q
43 passed, 1 dependency deprecation warning
node tests/dashboard-smoke.mjs
PASS, console_errors: []
```
Abgedeckt: Session-/CSRF-Pflicht, keine Maschinenverwaltung von Schlüsseln,
Projektbeschränkung, kein Operatorzugriff, kein Klartext in SQLite oder Listen,
Wiederabruf nach neuer Store-Instanz, sofortiger Widerruf auch für bereits laufende
API-Prozesse, fehlende Masterdatei ohne stille Neuerzeugung, bestehende Web-/API-
Regressionen. Echter Headless-Edge-Test für Erstellen/Anzeigen/Verbergen/Widerrufen
und Löschen sichtbarer Schlüsseldaten beim Logout. Kopieren nutzt die native
Clipboard-API; deren Interaktion wurde nicht automatisiert geprüft.
Die Entwicklungsversion der neuen API-Abhängigkeit cryptography wurde lokal
installiert. Keine realen Schlüssel erzeugt oder ausgegeben; Browser nutzt
temporäre isolierte Testdaten, die nach dem Test entfernt werden.
Vor Testbeginn Trials 16.939.129.428 Bytes, Fehlerkorpus 2.471.041 Bytes;
kleine synthetische Testdaten bleiben innerhalb des 20-GB-Budgets.
Keine kostenpflichtigen Modellaufrufe, keine neuen Token-/Cache-/Kostenwerte.
Kein Deployment und kein Push. Anleitung: `docs/integration-keys.md`.
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = ["jsonschema>=4.18,<5"]
[project.optional-dependencies]
test = ["pytest>=8,<10"]
research = ["PyMySQL>=1.1,<2", "zstandard>=0.23,<1"]
api = ["starlette>=0.52.1,<0.53", "uvicorn>=0.35,<1", "python-multipart>=0.0.32,<1"]
api = ["starlette>=0.52.1,<0.53", "uvicorn>=0.35,<1", "python-multipart>=0.0.32,<1", "cryptography>=46,<47"]
api-test = ["httpx>=0.28,<1", "pytest>=8,<10"]
[tool.setuptools.packages.find]
+23 -2
View File
@@ -24,6 +24,7 @@ from .cases import Cases
from .operations import Operations
from .test_requests import TestRequests
from .sessions import Sessions,COOKIE
from .keys import Keys
from viano_bug_analysis.lifecycle.errors import LockBusy
from viano_bug_analysis.lifecycle.errors import LifecycleError
from viano_bug_analysis.package import PackageReadError
@@ -38,6 +39,7 @@ def create_app(config):
test_requests = TestRequests(service)
sessions=Sessions(config)
tokens = config.tokens()
keys=Keys(config)
service.recover()
active = 0
@@ -47,6 +49,8 @@ def create_app(config):
if header.startswith('Bearer ') and header.isascii():
for client, token in tokens:
if hmac.compare_digest(header[7:], token):return client
client=keys.authenticate(header[7:])
if client:return client
raise APIError('API_UNAUTHORIZED',401)
return sessions.authenticate(request)
@@ -85,6 +89,21 @@ def create_app(config):
async def health(request):
return JSONResponse({'status': 'OK'})
async def integration_keys(request):
# Machine credentials can never manage or reveal credentials.
if request.headers.get('authorization'):raise APIError('AUTH_SESSION_REQUIRED',403)
await run_in_threadpool(sessions.authenticate,request)
identifier=request.path_params.get('identifier')
if identifier:
action=request.path_params['action']
if action not in {'reveal','revoke'}:raise APIError('API_NOT_FOUND',404)
result=await run_in_threadpool(keys.reveal if action=='reveal' else keys.revoke,identifier)
elif request.method=='GET':result={'keys':await run_in_threadpool(keys.listing)}
else:
data=await small_json(request)
result=await run_in_threadpool(keys.create,data,request.state.session_info['username'])
return JSONResponse(result,headers={'Cache-Control':'no-store'})
async def profiles(request):
client = authenticate(request)
return JSONResponse({'projects': [{'project_id': p.id, 'profile_id': p.profile,
@@ -199,11 +218,11 @@ def create_app(config):
async def web(request):
from starlette.responses import Response
name = request.path_params.get('asset', 'index.html')
if name not in {'index.html', 'dashboard.js', 'dashboard.css', 'case-detail.js','operations.js','test-requests.js'}:
if name not in {'index.html', 'dashboard.js', 'dashboard.css', 'case-detail.js','operations.js','test-requests.js','integration-keys.js'}:
raise APIError('API_NOT_FOUND', 404)
data = files('viano_bug_analysis.web').joinpath(name).read_bytes()
response = Response(data, media_type={'index.html': 'text/html', 'dashboard.js': 'text/javascript',
'dashboard.css': 'text/css', 'case-detail.js':'text/javascript','operations.js':'text/javascript','test-requests.js':'text/javascript'}[name])
'dashboard.css': 'text/css', 'case-detail.js':'text/javascript','operations.js':'text/javascript','test-requests.js':'text/javascript','integration-keys.js':'text/javascript'}[name])
response.headers.update({'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'Cache-Control': 'no-store'})
return response
@@ -277,6 +296,8 @@ def create_app(config):
app = Starlette(routes=[Route('/', web), Route('/assets/{asset}', web),
Route('/v1/auth/{action}',auth,methods=['GET','POST']),
Route('/v1/integration-keys',integration_keys,methods=['GET','POST']),
Route('/v1/integration-keys/{identifier}/{action}',integration_keys,methods=['POST']),
Route('/v1/overview', overview), Route('/healthz', health), Route('/v1/profiles', profiles),
Route('/v1/operations',operation,methods=['GET','POST']),
Route('/v1/test-requests',tests),Route('/v1/test-requests/{handle}',tests,methods=['POST']),
+79
View File
@@ -0,0 +1,79 @@
"""Recoverable integration credentials, separate from human accounts."""
import hashlib
import json
import os
from pathlib import Path
import secrets
import time
from cryptography.fernet import Fernet, InvalidToken
from .config import APIError, Client, directory
from .users import database
class Keys:
def __init__(self,config):
self.config=config
root=directory(config.workspace/'runtime/integrations')
self.path=root/'keys.sqlite3'
self.key_path=Path(os.environ.get('VIANO_INTEGRATION_KEY_FILE',str(config.workspace/'runtime/integration-secrets/master.key')))
if not self.path.exists():
try:
fd=os.open(self.path,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600);os.close(fd)
except FileExistsError:pass
with database(self.path) as db:
db.execute('CREATE TABLE IF NOT EXISTS keys (id TEXT PRIMARY KEY,name TEXT NOT NULL,projects TEXT NOT NULL,digest TEXT UNIQUE NOT NULL,encrypted TEXT NOT NULL,created REAL NOT NULL,creator TEXT NOT NULL,revoked REAL)')
def cipher(self):
directory(self.key_path.parent)
with database(self.path) as db:
db.execute('BEGIN IMMEDIATE')
if not self.key_path.exists():
if db.execute('SELECT 1 FROM keys LIMIT 1').fetchone():raise APIError('API_KEY_MASTER_MISSING',503)
fd=os.open(self.key_path,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600)
with os.fdopen(fd,'wb') as stream:stream.write(Fernet.generate_key())
if self.key_path.is_symlink():raise APIError('API_KEY_STORAGE',503)
try:return Fernet(self.key_path.read_bytes().strip())
except ValueError:raise APIError('API_KEY_MASTER_INVALID',503) from None
def listing(self):
with database(self.path) as db:
return [{'id':r['id'],'name':r['name'],'projects':json.loads(r['projects']),
'created_at':r['created'],'created_by':r['creator'],'revoked_at':r['revoked']}
for r in db.execute('SELECT * FROM keys ORDER BY created DESC,id')]
def create(self,data,creator):
if (not isinstance(data,dict) or set(data)!={'name','projects'} or not isinstance(data['name'],str)
or not 1<=len(data['name'].strip())<=100 or any(ord(c)<32 for c in data['name'])
or not isinstance(data['projects'],list) or not data['projects']
or any(not isinstance(p,str) for p in data['projects'])
or not set(data['projects'])<={p.id for p in self.config.projects}):
raise APIError('API_KEY_INVALID',422)
cipher=self.cipher();identifier=secrets.token_hex(16);token=secrets.token_urlsafe(32)
with database(self.path) as db:
db.execute('INSERT INTO keys VALUES (?,?,?,?,?,?,?,NULL)',(identifier,data['name'].strip(),
json.dumps(sorted(set(data['projects']))),hashlib.sha256(token.encode()).hexdigest(),
cipher.encrypt(token.encode()).decode(),time.time(),creator))
return {'status':'CREATED','id':identifier}
def reveal(self,identifier):
with database(self.path) as db:row=db.execute('SELECT encrypted FROM keys WHERE id=? AND revoked IS NULL',(identifier,)).fetchone()
if not row:raise APIError('API_KEY_NOT_FOUND',404)
try:return {'key':self.cipher().decrypt(row['encrypted'].encode()).decode()}
except InvalidToken:raise APIError('API_KEY_MASTER_INVALID',503) from None
def revoke(self,identifier):
with database(self.path) as db:
if not db.execute('UPDATE keys SET revoked=COALESCE(revoked,?) WHERE id=?',(time.time(),identifier)).rowcount:
raise APIError('API_KEY_NOT_FOUND',404)
return {'status':'REVOKED'}
def authenticate(self,token):
if not token.isascii() or not 32<=len(token)<=256:return None
with database(self.path) as db:
row=db.execute('SELECT id,projects FROM keys WHERE digest=? AND revoked IS NULL',
(hashlib.sha256(token.encode()).hexdigest(),)).fetchone()
if row:
projects=tuple(p for p in json.loads(row['projects']) if p in {x.id for x in self.config.projects})
if projects:return Client('key-'+row['id'],'DATABASE_KEY',projects)
return None
@@ -107,6 +107,8 @@ function syncDetail(){
if(!detailLoading&&(!selectedCase||detailHash!==location.hash||(!location.hash.includes('/revision/')&&current?.package_generation!==selectedCase.package_generation)))loadDetail(location.hash);
}
function route(){
if(typeof keysRoute==='function'&&keysRoute())return;
if($('keys-page')){$('keys-page').hidden=true;clearKeys();}
if(typeof testRoute==='function'&&testRoute())return;
if($('test-page'))$('test-page').hidden=true;
const detail=location.hash.startsWith('#case/'),results=location.hash==='#results',submissionPage=location.hash==='#submit';
+1
View File
@@ -3,3 +3,4 @@
.worker-case span:last-child{white-space:nowrap;flex-shrink:0}
.case-link{color:#4f61c7;font-weight:650;text-decoration:none}.case-link:hover{text-decoration:underline}.detail-panel>summary{font-size:18px;font-weight:650;cursor:pointer}.detail-body{margin-top:20px}.semantic-text{color:#45516a;white-space:pre-wrap;overflow-wrap:anywhere;margin:5px 0 12px}.semantic-fields{display:grid;grid-template-columns:minmax(130px,190px) minmax(0,1fr);gap:12px 22px;margin:0}.semantic-fields dt{font-size:12px;color:#7c8599;font-weight:600}.semantic-fields dd{margin:0;min-width:0}.semantic-fields .semantic-fields{grid-template-columns:130px minmax(0,1fr)}.semantic-list{display:grid;gap:10px}.semantic-item{border-left:2px solid #e6eaf6;padding-left:13px}.statement small{display:block;color:#9299a8}.reference-list{color:#7785ae;font-size:11px;overflow-wrap:anywhere}.hypothesis-card{border:1px solid #e6e9f1;border-radius:9px;padding:20px;margin-bottom:16px}.hypothesis-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}.hypothesis-heading h3{font-size:16px;margin:0}.hypothesis-heading .badge{flex-shrink:0}.hypothesis-card .detail-panel{margin:16px 0 0;padding:14px;box-shadow:none}.hypothesis-card .detail-panel>summary{font-size:13px}.evidence-columns{display:grid;grid-template-columns:1fr 1fr;gap:26px;margin-top:18px}.evidence-columns h4{font-size:12px;color:#68758c}.evidence-columns>div{background:#f7f8fc;padding:16px;border-radius:7px;min-width:0}.detail-controls{display:flex;align-items:center;gap:16px}.detail-controls label{margin:0;min-width:170px}.detail-controls button{margin-top:20px}.history-note{background:#f1f3fd;color:#6472ae;padding:12px;border-radius:6px;font-size:12px}.assignment-status{font-weight:650;color:#34436a}.human-document{white-space:pre-wrap;overflow-wrap:anywhere;font:13px/1.7 system-ui}.next-actions p{color:#5f6d85}#case-detail .panel{scroll-margin-top:20px}@media(max-width:900px){.semantic-fields,.semantic-fields .semantic-fields{grid-template-columns:1fr;gap:4px}.semantic-fields dd{margin-bottom:12px}.detail-controls{flex-wrap:wrap}}@media(max-width:700px){.hypothesis-heading{align-items:flex-start;flex-direction:column}.evidence-columns{grid-template-columns:1fr}.detail-controls{width:100%}.detail-controls button{margin-top:0}.hypothesis-card{padding:14px}.rail{flex-wrap:wrap}.rail nav{flex-wrap:wrap}}
#key-projects{border:1px solid var(--line);border-radius:8px;padding:12px;margin-bottom:14px}#key-projects label{display:flex;align-items:center;gap:10px;margin:8px 0}#key-projects input{width:auto;margin:0}#key-list button{margin:10px 8px 0 0}#key-list input{font-family:monospace}
+1
View File
@@ -30,6 +30,7 @@ function logout(remote=true) {
if(typeof clearDetails==='function')clearDetails();
if(typeof clearOperations==='function')clearOperations();
if(typeof clearTests==='function')clearTests();
if(typeof clearKeys==='function')clearKeys();
['active-count','token-count','correction-count','finished-count'].forEach(id=>$(id).textContent='—');
$('pie').getContext('2d').clearRect(0,0,360,360);
}
+3 -2
View File
@@ -2,11 +2,11 @@
<html lang="de">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>BugAnalysis · Übersicht</title><link rel="stylesheet" href="/assets/dashboard.css">
<script src="/assets/dashboard.js" defer></script>
<script src="/assets/dashboard.js" defer></script><script src="/assets/integration-keys.js" defer></script>
<script src="/assets/case-detail.js" defer></script><script src="/assets/operations.js" defer></script><script src="/assets/test-requests.js" defer></script></head>
<body>
<aside class="rail"><a class="brand" href="/">V<span>BA</span><small>BugAnalysis</small></a>
<nav aria-label="Navigation"><a href="#overview" class="selected">Übersicht</a><a href="#cases">Laufende Fälle</a><a href="#results">Ergebnisse</a><a href="#tests">Testanfragen</a><a href="#submit">Auftrag übergeben</a></nav>
<nav aria-label="Navigation"><a href="#overview" class="selected">Übersicht</a><a href="#cases">Laufende Fälle</a><a href="#results">Ergebnisse</a><a href="#tests">Testanfragen</a><a href="#submit">Auftrag übergeben</a><a href="#keys">API-Schlüssel</a></nav>
<div class="rail-bottom">ANALYSEBETRIEB<br><span id="connection">Nicht verbunden</span><button id="logout" type="button" hidden>Abmelden</button></div></aside>
<main id="overview">
<header><div><div class="eyebrow">BETRIEBSÜBERSICHT</div><h1>Analyse im Blick.</h1><p>Aktuelle Arbeit und messbarer Verbrauch.</p></div><div class="live"><span class="dot"></span><span id="updated">Warte auf Verbindung</span></div></header>
@@ -41,5 +41,6 @@
<div class="form-grid" id="new-files"><label>wowdiag<input id="wowdiag" type="file"></label><label>wowsrc (optional)<input id="wowsrc" type="file"></label></div>
<div id="return-files" hidden><label>Abgeschlossene Case-ZIP<input id="case-package" type="file" accept=".zip"></label><label>Ergebnis<select id="outcome"><option value="patch_failed">Patch behebt den Fehler nicht</option><option value="validation_failed">Validierung fehlgeschlagen</option><option value="new_evidence">Neue Belege</option><option value="review_feedback">Review-Rückmeldung</option></select></label></div>
<label>Zusätzliche Anhänge<input id="attachments" type="file" multiple></label><button id="send" type="submit">Auftrag einreichen</button><p id="receipt" role="status"></p></form></section>
<section class="panel" id="keys-page" hidden><div class="eyebrow">INTEGRATIONEN</div><h2>API-Schlüssel</h2><p>Für Auftragsübergabe und lesenden Zugriff auf ausgewählte Projekte. Keine Betriebssteuerung oder Nutzerverwaltung.</p><form id="key-form"><label>Programmname<input id="key-name" maxlength="100" required placeholder="Erfassungsworker"></label><fieldset id="key-projects"><legend>Freigegebene Projekte</legend></fieldset><button type="submit">Schlüssel erstellen</button></form><p id="key-status" role="status"></p><div id="key-list"></div></section>
<footer>Verbrauch aller erhaltenen, freigegebenen Akten und Revisionen. Keine Google-Abrechnung. Fehlende Messwerte werden kenntlich gemacht.</footer>
</div></main></body></html>
@@ -0,0 +1,39 @@
'use strict';
let keysBusy=false;
function clearKeys(){ $('key-list').replaceChildren();$('key-projects').replaceChildren();$('key-status').textContent='';$('key-name').value=''; }
function keysRoute(){
if(location.hash!=='#keys')return false;
['overview-page','results','case-detail','submit','test-page'].forEach(id=>$(id).hidden=true);
$('keys-page').hidden=false;
document.querySelector('header h1').textContent='Programme verbinden.';
document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('selected',a.hash==='#keys'));
if(token)refreshKeys();return true;
}
async function refreshKeys(){
const generation=session;
try{
const [data,profiles]=await Promise.all([api('/v1/integration-keys'),api('/v1/profiles')]);
if(generation!==session||location.hash!=='#keys')return;
$('key-list').replaceChildren();$('key-projects').replaceChildren(text('legend','Freigegebene Projekte'));
profiles.projects.forEach(p=>{const label=text('label','');const box=document.createElement('input');box.type='checkbox';box.value=p.project_id;box.name='key-project';label.append(box,document.createTextNode(p.project_id));$('key-projects').append(label);});
if(!data.keys.length)$('key-list').append(text('p','Noch keine Integrationsschlüssel vorhanden.'));
data.keys.forEach(k=>{
const row=text('article','','hypothesis-card');row.append(text('h3',k.name),text('p',`${k.projects.join(', ')} · ${new Date(k.created_at*1000).toLocaleString('de-DE')} · ${k.created_by}`));
if(k.revoked_at){row.append(text('p','Widerrufen'));$('key-list').append(row);return;}
const value=document.createElement('input');value.type='password';value.readOnly=true;value.placeholder='Schlüssel verborgen';value.setAttribute('aria-label',`API-Schlüssel für ${k.name}`);value.autocomplete='off';
const show=text('button','Anzeigen'),copy=text('button','Kopieren'),revoke=text('button','Widerrufen');
async function reveal(){const data=await api(`/v1/integration-keys/${k.id}/reveal`,{method:'POST'});if(generation!==session||location.hash!=='#keys')return null;return data.key;}
show.onclick=async()=>{show.disabled=true;try{if(value.value){value.value='';value.type='password';show.textContent='Anzeigen';}else{const key=await reveal();if(key){value.value=key;value.type='text';show.textContent='Verbergen';}}}catch(e){if(generation===session)error(e.message);}finally{show.disabled=false;}};
copy.onclick=async()=>{copy.disabled=true;try{const key=await reveal();if(key){await navigator.clipboard.writeText(key);$('key-status').textContent='Schlüssel kopiert.';}}catch(e){if(generation===session)error('Kopieren nicht möglich. Schlüssel anzeigen und manuell kopieren.');}finally{copy.disabled=false;}};
revoke.onclick=async()=>{if(!confirm(`Schlüssel für ${k.name} widerrufen?`))return;revoke.disabled=true;try{await api(`/v1/integration-keys/${k.id}/revoke`,{method:'POST'});if(generation===session)await refreshKeys();}catch(e){if(generation===session)error(e.message);}finally{revoke.disabled=false;}};
row.append(value,show,copy,revoke);$('key-list').append(row);
});
}catch(e){if(generation===session)error(`Schlüsselverwaltung: ${e.message}`);}
}
$('key-form').addEventListener('submit',async event=>{
event.preventDefault();if(keysBusy)return;const generation=session;keysBusy=true;const button=event.submitter;button.disabled=true;
try{const projects=Array.from(document.querySelectorAll('input[name="key-project"]:checked'),x=>x.value);
await api('/v1/integration-keys',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:$('key-name').value,projects})});
if(generation!==session)return;$('key-name').value='';await refreshKeys();$('key-status').textContent='Schlüssel erstellt. Über Anzeigen oder Kopieren abrufbar.';
}catch(e){if(generation===session)error(e.message);}finally{keysBusy=false;button.disabled=false;}
});
+12
View File
@@ -79,7 +79,19 @@ try {
await evaluate("location.hash='#overview'");await pause(100);
await evaluate("document.getElementById('kind').value='case_return';document.getElementById('kind').dispatchEvent(new Event('change'));");
if(!await evaluate("document.getElementById('new-files').hidden&&!document.getElementById('return-files').hidden"))throw new Error('Return form not selected');
await evaluate("location.hash='#keys'");
await until(()=>evaluate("!document.getElementById('keys-page').hidden&&document.querySelector('input[name=\"key-project\"]')"));
await evaluate("document.getElementById('key-name').value='Smoke integration';document.querySelector('input[name=\"key-project\"]').checked=true;document.querySelector('#key-form button').click()");
await until(()=>evaluate("document.getElementById('key-list').textContent.includes('Smoke integration')"));
await evaluate("Array.from(document.querySelectorAll('#key-list button')).find(b=>b.textContent==='Anzeigen').click()");
await until(()=>evaluate("document.querySelector('#key-list input').value.length>32"));
await writeFile(path.join(output,'integration-keys.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await evaluate("Array.from(document.querySelectorAll('#key-list button')).find(b=>b.textContent==='Verbergen').click()");
if(await evaluate("document.querySelector('#key-list input').value!==''"))throw new Error('Hidden key retained in input');
await evaluate("window.confirm=()=>true;Array.from(document.querySelectorAll('#key-list button')).find(b=>b.textContent==='Widerrufen').click()");
await until(()=>evaluate("document.getElementById('key-list').textContent.includes('Widerrufen')"));
await evaluate("document.getElementById('logout').click()");
if(!await evaluate("document.getElementById('key-list').childElementCount===0"))throw new Error('Logout retained keys');
if(!await evaluate("document.getElementById('content').hidden&&!document.getElementById('login').hidden&&document.getElementById('token-count').textContent==='—'"))throw new Error('Logout retained visible case data');
if(!await evaluate("document.getElementById('case-detail').childElementCount===0&&document.getElementById('result-rows').childElementCount===0"))throw new Error('Logout retained detail data');
if(!await evaluate("document.getElementById('operation-events').childElementCount===0"))throw new Error('Logout retained audit');
+40
View File
@@ -0,0 +1,40 @@
from starlette.testclient import TestClient
from viano_bug_analysis.api.app import create_app
from viano_bug_analysis.api.config import APIConfig,Project
from viano_bug_analysis.api.keys import Keys
def test_web_keys_reveal_auth_scope_revoke_and_restart(tmp_path):
config=APIConfig(tmp_path,(Project('one','wow','vcore'),Project('two','wow','vcore')),())
app=create_app(config);app.state.sessions.users.manage('create_user','alice','password')
client=TestClient(app)
assert client.get('/v1/integration-keys').status_code==401
login=client.post('/v1/auth/login',json={'username':'alice','password':'password'}).json()
headers={'X-CSRF-Token':login['csrf']}
payload={'name':'Capture worker','projects':['one']}
assert client.post('/v1/integration-keys',json=payload).status_code==403
assert client.post('/v1/integration-keys',headers=headers,json={**payload,'projects':['unknown']}).status_code==422
result=client.post('/v1/integration-keys',headers=headers,json=payload)
assert result.status_code==200
identifier=result.json()['id'];base='/v1/integration-keys/'+identifier
assert client.post(base+'/reveal').status_code==403
response=client.post(base+'/reveal',headers=headers)
assert response.headers['cache-control']=='no-store'
key=response.json()['key']
assert client.post(base+'/reveal',headers=headers).json()['key']==key
listing=client.get('/v1/integration-keys').json()
assert key not in str(listing)
assert key.encode() not in (tmp_path/'runtime/integrations/keys.sqlite3').read_bytes()
machine=TestClient(create_app(config));bearer={'Authorization':'Bearer '+key}
assert machine.get('/v1/profiles',headers=bearer).json()['projects']==[{'project_id':'one','profile_id':'vcore','adapter':'vcore'}]
assert machine.get('/v1/integration-keys',headers=bearer).status_code==403
assert machine.post(base+'/reveal',headers=bearer).status_code==403
assert machine.post('/v1/operations',headers=bearer,json={'action':'pause','project_id':'one'}).status_code==403
assert Keys(config).reveal(identifier)['key']==key
(tmp_path/'runtime/integration-secrets/master.key').unlink()
assert client.post(base+'/reveal',headers=headers).status_code==503
# Missing master must not silently generate a replacement; authentication can still use digest.
assert not (tmp_path/'runtime/integration-secrets/master.key').exists()
assert client.post(base+'/revoke',headers=headers).status_code==200
assert machine.get('/v1/profiles',headers=bearer).status_code==401
assert client.post(base+'/reveal',headers=headers).status_code==404