From cbf112b0f82bca65e528857e218fb6a8f11551d4 Mon Sep 17 00:00:00 2001 From: Nitsu45 Date: Fri, 23 Oct 2026 08:46:24 +0200 Subject: [PATCH] Add recoverable encrypted integration keys and web management --- README.md | 2 + docs/integration-keys.md | 45 +++++++++++ docs/linux-deployment.md | 4 + .../test-results/integration-keys-20261023.md | 33 ++++++++ pyproject.toml | 2 +- src/viano_bug_analysis/api/app.py | 25 +++++- src/viano_bug_analysis/api/keys.py | 79 +++++++++++++++++++ src/viano_bug_analysis/web/case-detail.js | 2 + src/viano_bug_analysis/web/dashboard.css | 1 + src/viano_bug_analysis/web/dashboard.js | 1 + src/viano_bug_analysis/web/index.html | 5 +- .../web/integration-keys.js | 39 +++++++++ tests/dashboard-smoke.mjs | 12 +++ tests/test_integration_keys.py | 40 ++++++++++ 14 files changed, 285 insertions(+), 5 deletions(-) create mode 100644 docs/integration-keys.md create mode 100644 docs/test-results/integration-keys-20261023.md create mode 100644 src/viano_bug_analysis/api/keys.py create mode 100644 src/viano_bug_analysis/web/integration-keys.js create mode 100644 tests/test_integration_keys.py diff --git a/README.md b/README.md index 41053bc..40b13ff 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/integration-keys.md b/docs/integration-keys.md new file mode 100644 index 0000000..787d300 --- /dev/null +++ b/docs/integration-keys.md @@ -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 ` 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 `/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 + `/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. diff --git a/docs/linux-deployment.md b/docs/linux-deployment.md index bf84c4a..787acd8 100644 --- a/docs/linux-deployment.md +++ b/docs/linux-deployment.md @@ -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 diff --git a/docs/test-results/integration-keys-20261023.md b/docs/test-results/integration-keys-20261023.md new file mode 100644 index 0000000..c637142 --- /dev/null +++ b/docs/test-results/integration-keys-20261023.md @@ -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`. diff --git a/pyproject.toml b/pyproject.toml index 2026734..e0f5495 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/viano_bug_analysis/api/app.py b/src/viano_bug_analysis/api/app.py index b4650a8..b206810 100644 --- a/src/viano_bug_analysis/api/app.py +++ b/src/viano_bug_analysis/api/app.py @@ -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']), diff --git a/src/viano_bug_analysis/api/keys.py b/src/viano_bug_analysis/api/keys.py new file mode 100644 index 0000000..53e500d --- /dev/null +++ b/src/viano_bug_analysis/api/keys.py @@ -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 diff --git a/src/viano_bug_analysis/web/case-detail.js b/src/viano_bug_analysis/web/case-detail.js index dcbb482..614e783 100644 --- a/src/viano_bug_analysis/web/case-detail.js +++ b/src/viano_bug_analysis/web/case-detail.js @@ -107,6 +107,8 @@ function syncDetail(){ if(!detailLoading&&(!selectedCase||detailHash!==location.hash||(!location.hash.includes('/revision/')&¤t?.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'; diff --git a/src/viano_bug_analysis/web/dashboard.css b/src/viano_bug_analysis/web/dashboard.css index a1280df..84e4cc7 100644 --- a/src/viano_bug_analysis/web/dashboard.css +++ b/src/viano_bug_analysis/web/dashboard.css @@ -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} diff --git a/src/viano_bug_analysis/web/dashboard.js b/src/viano_bug_analysis/web/dashboard.js index a6f7f7b..9ccaf72 100644 --- a/src/viano_bug_analysis/web/dashboard.js +++ b/src/viano_bug_analysis/web/dashboard.js @@ -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); } diff --git a/src/viano_bug_analysis/web/index.html b/src/viano_bug_analysis/web/index.html index 75a99f3..a3f2c74 100644 --- a/src/viano_bug_analysis/web/index.html +++ b/src/viano_bug_analysis/web/index.html @@ -2,11 +2,11 @@ BugAnalysis · Übersicht - +
BETRIEBSÜBERSICHT

Analyse im Blick.

Aktuelle Arbeit und messbarer Verbrauch.

Warte auf Verbindung
@@ -41,5 +41,6 @@

+
diff --git a/src/viano_bug_analysis/web/integration-keys.js b/src/viano_bug_analysis/web/integration-keys.js new file mode 100644 index 0000000..9fef178 --- /dev/null +++ b/src/viano_bug_analysis/web/integration-keys.js @@ -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;} +}); diff --git a/tests/dashboard-smoke.mjs b/tests/dashboard-smoke.mjs index 3965fa9..42ecf51 100644 --- a/tests/dashboard-smoke.mjs +++ b/tests/dashboard-smoke.mjs @@ -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'); diff --git a/tests/test_integration_keys.py b/tests/test_integration_keys.py new file mode 100644 index 0000000..488e306 --- /dev/null +++ b/tests/test_integration_keys.py @@ -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