Add recoverable encrypted integration keys and web management
This commit is contained in:
1 parent
ca4ab48fdc
commit
cbf112b0f8
14 files changed
+285
-5
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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/')&¤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';
|
||||
|
||||
@@ -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}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;}
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user