Add SQLite web users, privileged account CLI and session editing claims

This commit is contained in:
Nico committed 2026-10-22 18:56:59 +02:00
1 parent 9fc5c1805c
commit 203e765992
18 files changed
+629 -60

No files matched your search

+8 -3
View File
@@ -3,6 +3,11 @@ workspace = "../service-data/service"
host = "127.0.0.1"
port = 8080
[auth]
session_seconds = 3600
# Production Linux: root-owned file, read-only for the service group.
# users_database = "/etc/viano-bug-analysis/users.sqlite3"
[[projects]]
id = "viano-core"
type = "wow"
@@ -21,10 +26,10 @@ id = "wow-capture-worker"
token_env = "VBA_INTAKE_TOKEN"
projects = ["viano-core"]
# Separate operator identity/token. Submission clients remain non-operators.
# Optional privileged machine integration, not a human login.
# [[clients]]
# id = "human-operator"
# id = "automation-operator"
# token_env = "VBA_OPERATOR_TOKEN"
# projects = ["viano-core"]
# operator = true
# tester = true
# Human accounts are created only by the privileged create_user console command.
+40
View File
@@ -0,0 +1,40 @@
# Benutzeranmeldung und Bearbeitungssperren – 2026-10-22
Umfang: SQLite-Benutzer mit gesalzenen scrypt-Passworthashes, getrennte
Sitzungs-/Sperrendatenbank, Cookie-Anmeldung, CSRF-Schutz, Abmeldung,
Konsolenbefehle create_user/delete_user/change_pw_user mit Privilegienprüfung.
Alle Webbenutzer besitzen dieselben Rechte für alle Projekte. Keine Webverwaltung
von Benutzern und keine Benutzerklassen. Bearer-Tokens bleiben für Maschinen.
Konsole: Linux-root/sudo oder Windows-elevierte Administratorberechtigung.
Die Privilegienprüfung wurde mit simuliertem unprivilegiertem Aufrufer getestet;
Benutzerbefehle mit temporärer Datenbank unter kontrollierter Testfreigabe geprüft.
Keine realen Benutzerkonten wurden im Dienst-Workspace erzeugt.
Prüfungen:
- 57 lokale Tests bestanden: API, Benutzer/Sitzungen, menschliches Feedback,
Dashboard, Worker und vcore-Übergabe.
- Gleichzeitige SQLite-Übernahmen aus zwei Threads/Sitzungen: genau ein Gewinner.
- HTTP: ohne Bearbeiten kein Feedback, fremde Sitzung weder Übernahme noch Antwort.
- Abmeldung/Sitzungsablauf geben Sperren frei; Passwortwechsel/Löschung sperren
alte Sitzungen. Falsches altes Passwort wird abgewiesen.
- Alle Webbenutzer sehen alle Projekte/Betriebsaktionen. Fehlender CSRF-Header
wird abgewiesen; fremder Login-Origin wird abgewiesen; Anmeldeversuche begrenzt.
- Beide Feedbackausgänge bleiben möglich; unveränderte Budgets/Revision und
idempotente Antworten erhalten. Maschinen-Bearer können menschliche Antworten
nicht ohne Benutzersitzung bestätigen.
- Echter Headless-Edge mit ausschließlich temporärem Testbenutzer: normale
Anmeldung, UI-Projektsteuerung, Testanfrage zuerst schreibgeschützt, Bearbeiten
aktiviert Formular, Navigation/Diagramme/Mobilansicht/Logout: PASS.
Keine JS-Ausnahmen. Die erwartete 401 beim Prüfen einer noch nicht bestehenden
Sitzung wird separat vom Browserfehlercheck ausgenommen.
Testdaten vor Ausführung: Trials 16.939.129.428 Bytes, kleine Fixtures/temporäre
Dateien mit Reserve innerhalb des 20-GB-Budgets. Keine kostenpflichtigen
LLM-Anfragen, keine Änderung an Analysebudgets oder der Designspezifikation.
Deployment: Benutzerdatei in root-/administratorgeschütztem Verzeichnis mit
passenden Dienst-Leserechten betreiben; SQLite allein ersetzt keine OS-Dateirechte.
Native Linux-Dateirechte/Chown und reale Windows-UAC/NTFS-ACLs sind beim Server-
Deployment zu prüfen. Die API öffnet die Benutzerdatei ausschließlich read-only.
+103
View File
@@ -0,0 +1,103 @@
# Benutzeranmeldung und exklusive Bearbeitung
Webbenutzer melden sich mit Benutzername und Passwort an. Alle Benutzer besitzen
dieselben Rechte für alle Projekte. Es gibt weder Administrator-/Normalbenutzer-
Klassen noch eine Benutzerverwaltung im Web. Bestehende Bearer-Tokens bleiben
ausschließlich für externe API-Integrationen nutzbar und behalten deren bisherigen
Zugriffsgrenzen. Fehlende Token-Umgebungsvariablen deaktivieren die jeweilige
Integration; die Benutzeranmeldung benötigt keine Token-Zufallsgenerierung.
## Konsolenverwaltung
Die Befehle erfordern unter Linux effektive root-Rechte (normalerweise `sudo`),
unter Windows eine als Administrator gestartete Konsole. Sie benötigen keinen
Gemini-Schlüssel und senden keine Modellanfrage.
```bash
sudo .venv/bin/python -m viano_bug_analysis.api create_user nico --config config/api.local.toml --service-group buganalysis
sudo .venv/bin/python -m viano_bug_analysis.api change_pw_user nico --config config/api.local.toml
sudo .venv/bin/python -m viano_bug_analysis.api delete_user nico --config config/api.local.toml
```
Ohne Passwortargumente erfolgt eine verdeckte Abfrage. Die gewünschten expliziten
Argumentformen funktionieren ebenfalls:
```text
create_user NAME PASSWORT --config DATEI
delete_user NAME --config DATEI
change_pw_user NAME ALTES_PASSWORT NEUES_PASSWORT --config DATEI
```
Explizite Passwortargumente können in Shellhistorie/Prozessargumenten auftauchen;
für den normalen Betrieb die verdeckte Eingabe verwenden. Benutzernamen bestehen
aus 1–64 ASCII-Buchstaben/Ziffern sowie Punkt, Unterstrich oder Bindestrich und
beginnen mit einem Buchstaben oder einer Ziffer. Namen sind nicht case-sensitiv.
Beim Passwortwechsel wird das alte Passwort geprüft. Passwortwechsel und Löschung
entwerten bestehende Sitzungen und deren Bearbeitungssperren ohne API-Neustart.
Windows, in einer Administrator-PowerShell:
```powershell
.\.venv\Scripts\python.exe -m viano_bug_analysis.api create_user nico --config config/api.local.toml
```
## SQLite-Dateien und Betriebssystemrechte
Benutzer liegen standardmäßig in `<workspace>/auth/users.sqlite3`. Die API öffnet
diese Datei ausschließlich schreibgeschützt. Gesalzene scrypt-Hashes ersetzen
Klartextpasswörter. Sitzungen, Anmeldebegrenzung und Bearbeitungssperren liegen
separat in `<workspace>/runtime/web-sessions/sessions.sqlite3`; Sitzungstokens
werden dort nur als Hash gespeichert.
Produktionskonfiguration, Pfade relativ zur Konfigurationsdatei oder absolut:
```toml
[auth]
users_database = "/etc/viano-bug-analysis/users.sqlite3"
session_seconds = 3600
```
Die Benutzerdatei und ihr Verzeichnis gehören unter Linux root; CLI-Befehle setzen
0640 bzw. 0750. `--service-group buganalysis` erlaubt dem dedizierten Dienstkonto
das Lesen, nicht die Benutzerverwaltung. Verzeichnisvorfahren dürfen nicht durch
unvertrauenswürdige Benutzer austauschbar sein. Der Dienst benötigt Schreibzugriff
auf seinen Workspace und die Sitzungsdatei. Unter Windows entsprechend geschützte
NTFS-Verzeichnisse/ACLs verwenden: Administratoren verwalten die Benutzerdatei,
das Dienstkonto liest sie. Die Konsolenbefehle prüfen auch dort die erhöhte
Administratorberechtigung. Wer Programmcode oder Authentifizierungsdateien selbst
ändern kann, liegt außerhalb der Web-Zugriffsgrenze; deshalb sind diese OS-Rechte
Bestandteil der Servereinrichtung.
SQLite benötigt keinen MariaDB-Dienst. Für einen Umzug die Benutzerdatei und die
Projekt-/Workspace-Daten sichern; die separate Sitzungsdatei kann bei gestoppter
API bewusst verworfen werden, um alle Sitzungen und Sperren zu beenden.
## Sitzungen und Bearbeiten
Die Anmeldung erzeugt ein HttpOnly-/SameSite-Strict-Cookie; unter HTTPS zusätzlich
Secure. Entfernte Zugriffe über HTTPS betreiben. Mutationen benötigen den an die
Sitzung gebundenen CSRF-Header. Die Oberfläche hält nur diesen CSRF-Wert im
Arbeitsspeicher; Passwörter und Sitzungstokens werden nicht im Local Storage
gespeichert. Sitzungen gelten standardmäßig absolut eine Stunde, konfigurierbar
von 60 Sekunden bis 24 Stunden; regelmäßige Seitenaktualisierungen verlängern sie
nicht endlos. Nach einem Neuladen kann eine gültige Sitzung weiterverwendet werden.
Anmeldeversuche werden serverseitig zeitlich begrenzt.
Eine Testanfrage lässt sich zunächst nur ansehen. Erst **Bearbeiten** übernimmt
die Anfrage atomar für die aktuelle Sitzung. Eine zweite Sitzung erhält
`API_TEST_CLAIMED` und eine verständliche Meldung. Erst dann erscheinen Eingabefelder.
Die Serverprüfung verlangt dieselbe Sperre auch beim Absenden; das Ausblenden
von Feldern allein ist nicht die Zugriffskontrolle. Identische Wiederholungen
bleiben idempotent, abweichende zweite Antworten werden zurückgewiesen.
**Bearbeitung freigeben**, Abmeldung oder Sitzungsablauf machen die Anfrage für
andere Sitzungen verfügbar. Der Ablauf wird bei erneuter Übernahme anhand der
serverseitigen Zeit geprüft; es wird kein Hintergrundtimer benötigt. Zwei Tabs
derselben Sitzung gehören derselben Bearbeitung; zwei getrennte Sitzungen auch
desselben Benutzers konkurrieren um die Sperre.
HTTP: `POST /v1/auth/login`, `GET /v1/auth/session`, `POST /v1/auth/logout`.
Testübernahme/-freigabe: `POST /v1/test-requests/{key}/claim` mit
`{request_id,analysis_revision,action:"edit"|"release"}`. Feedback bestätigt wie
bisher die konkrete Anfrage und führt dieselbe Analyse-Revision fort. Bearer-
Clients können keine menschliche Bearbeitungssperre oder Antwort übernehmen.
+15 -20
View File
@@ -6,10 +6,12 @@ Die Betriebsübersicht wird durch die bestehende Übergabe-API ausgeliefert:
.\.venv\Scripts\python.exe -m viano_bug_analysis.api serve --config config/api.local.toml
```
Anschließend `http://127.0.0.1:8080/` öffnen und mit einem freigegebenen **API-Token**
anmelden. Dies ist nicht der Gemini-Schlüssel. Der Token bleibt ausschließlich im
Arbeitsspeicher des Tabs, nicht in Cookies, Local Storage oder URLs. Für entfernte
Zugriffe HTTPS verwenden. Der Analyseworker läuft unabhängig davon wie unter
Anschließend `http://127.0.0.1:8080/` öffnen und mit **Benutzername und Passwort**
anmelden. Benutzer werden ausschließlich über privilegierte Konsolenbefehle
angelegt, geändert und gelöscht, siehe [Benutzerverwaltung](user-accounts.md).
Alle Webbenutzer haben dieselben Rechte für alle Projekte. Die unten beschriebenen
Client-/Projektberechtigungen gelten weiterhin für externe API-Integrationen.
Für entfernte Zugriffe HTTPS verwenden. Der Analyseworker läuft unabhängig davon wie unter
[Analyseworker](analysis-worker.md) beschrieben. Das Öffnen der Übersicht löst
keine Modellanfragen aus.
@@ -93,7 +95,8 @@ Fortsetzung angefordert werden; Budgets werden dabei nicht zurückgesetzt.
## Betriebssteuerung
Clients erhalten standardmäßig keine Betreiberrechte. Ein eigener Client mit
Alle angemeldeten Webbenutzer können die Betriebssteuerung verwenden. Externe
API-Clients erhalten standardmäßig keine Betreiberrechte. Ein eigener Client mit
eigenem Token und `operator = true` in der API-Konfiguration erhält Aktionen für
seine freigegebenen Projekte (Beispiel in `config/api.example.toml`). API nach einer
Rechteänderung neu starten. Der Gemini-Schlüssel ist dafür nicht geeignet.
@@ -140,24 +143,15 @@ Beobachtungen und „Test durchgeführt“ oder „Nicht durchführbar“. Die a
Bestätigung ist erforderlich. Textdaten bleiben untrusted observations, keine
auszuführenden Befehle. Diese erste Oberfläche nimmt Text, keine Dateianhänge an.
Clients brauchen `tester = true` oder Betreiberrechte und Projektzugriff, um eine
Antwort zu bestätigen. Ein reiner Uploadclient erhält diese Rechte nicht automatisch.
Beispiel eines separaten menschlichen Clients in der API-Konfiguration:
```toml
[[clients]]
id = "human-dev"
token_env = "VBA_HUMAN_TOKEN"
projects = ["viano-core"]
tester = true
```
Eigenen API-Token über diese Umgebungsvariable bereitstellen und API neu starten.
Menschliche Antworten erfordern eine angemeldete Benutzersitzung und die exklusive
Übernahme über **Bearbeiten**, siehe [Benutzeranmeldung](user-accounts.md).
Es gibt keine gesonderten Testerrollen für Webbenutzer. Menschliche Zugänge
ausschließlich mit `create_user` anlegen.
Die bestätigende Identität und Zeit stammen vom Server, nicht aus frei editierbaren
Formularfeldern. `GET /v1/test-requests` liefert ausschließlich freigegebene Projekte;
`POST /v1/test-requests/{case-key}` bestätigt die konkrete Anfrage/Revision.
Kollisionen, veraltete Anfragen und widersprüchliche Wiederholungen werden abgewiesen.
Eine identische erneute Übermittlung desselben Clients bleibt ohne doppelte Wirkung.
Eine identische erneute Übermittlung derselben Sitzung bleibt ohne doppelte Wirkung.
Eine bestätigte Antwort setzt `AWAITING_TEST_FEEDBACK` über den vorhandenen
Coordinator auf `INVESTIGATING_HYPOTHESES` zurück. Revision, Budget und ursprüngliche
@@ -185,7 +179,8 @@ LLM-Aufrufe aus.
## Datenschutz und Betrieb
`GET /v1/overview` benötigt einen Bearer-Token und liefert nur freigegebene Projekte.
`GET /v1/overview` benötigt eine Benutzersitzung oder einen Maschinen-Bearer-Token.
Webbenutzer sehen alle Projekte; Maschinenclients nur ihre freigegebenen Projekte.
Prompts, vollständige Modellantworten, lokale Pfade und Zugangsdaten werden nicht
an den Browser übergeben. Fallnamen werden als Text dargestellt. Die Seite lädt
keine externen Fonts, Chartbibliotheken oder Analysedienste; Diagramme werden lokal
+34 -1
View File
@@ -1,15 +1,46 @@
import argparse
import json
import getpass
import os
import sqlite3
from .config import APIConfig, APIError
def main():
parser = argparse.ArgumentParser(description='Authenticated BugAnalysis intake; no analysis worker')
parser.add_argument('command', choices=['check', 'serve'])
parser.add_argument('command', choices=['check', 'serve','create_user','delete_user','change_pw_user'])
parser.add_argument('name',nargs='?')
parser.add_argument('password',nargs='?')
parser.add_argument('new_password',nargs='?')
parser.add_argument('--service-group',help='Linux group allowed to read the root-owned users database')
parser.add_argument('--config', required=True)
args = parser.parse_args()
try:
config = APIConfig.load(args.config)
if args.command in {'create_user','delete_user','change_pw_user'}:
from .users import Users,require_privileged
require_privileged()
gid=None
if args.service_group and os.name!='nt':
import grp
gid=grp.getgrnam(args.service_group).gr_gid
if not args.name:raise APIError('AUTH_USER_INVALID')
if args.command=='delete_user' and (args.password or args.new_password):raise APIError('AUTH_ARGUMENTS')
if args.command=='create_user' and args.new_password:raise APIError('AUTH_ARGUMENTS')
users=Users(config.users_database or config.workspace/'auth/users.sqlite3')
if args.command=='create_user':
users.manage(args.command,args.name,args.password if args.password is not None else getpass.getpass('Password: '))
elif args.command=='change_pw_user':
old=args.password if args.password is not None else getpass.getpass('Old password: ')
new=args.new_password if args.new_password is not None else getpass.getpass('New password: ')
users.manage(args.command,args.name,new,old)
else:users.manage(args.command,args.name)
if os.name!='nt':
os.chown(users.path,0,gid if gid is not None else -1)
os.chown(users.path.parent,0,gid if gid is not None else -1)
os.chmod(users.path,0o640);os.chmod(users.path.parent,0o750)
print(json.dumps({'status':'OK','command':args.command,'username':args.name}))
return 0
config.tokens()
if args.command == 'check':
print(json.dumps({'status': 'OK', 'projects': [p.id for p in config.projects]}))
@@ -21,6 +52,8 @@ def main():
except APIError as error:
print(json.dumps({'status': 'ERROR', 'code': error.code}))
return 1
except (OSError,sqlite3.Error,KeyError):
print(json.dumps({'status':'ERROR','code':'AUTH_STORAGE'}));return 1
if __name__ == '__main__':
+60 -8
View File
@@ -2,6 +2,7 @@ import asyncio
import functools
import hmac
import json
import sqlite3
import shutil
import tempfile
from pathlib import Path
@@ -22,6 +23,7 @@ from .dashboard import Dashboard
from .cases import Cases
from .operations import Operations
from .test_requests import TestRequests
from .sessions import Sessions,COOKIE
from viano_bug_analysis.lifecycle.errors import LockBusy
from viano_bug_analysis.lifecycle.errors import LifecycleError
from viano_bug_analysis.package import PackageReadError
@@ -34,18 +36,51 @@ def create_app(config):
cases = Cases(service)
operations = Operations(service)
test_requests = TestRequests(service)
sessions=Sessions(config)
tokens = config.tokens()
service.recover()
active = 0
def authenticate(request):
header = request.headers.get('authorization', '')
if not header.startswith('Bearer ') or not header.isascii():
raise APIError('API_UNAUTHORIZED', 401)
for client, token in tokens:
if hmac.compare_digest(header[7:], token):
return client
raise APIError('API_UNAUTHORIZED', 401)
if header:
if header.startswith('Bearer ') and header.isascii():
for client, token in tokens:
if hmac.compare_digest(header[7:], token):return client
raise APIError('API_UNAUTHORIZED',401)
return sessions.authenticate(request)
async def small_json(request,maximum=4096):
if request.headers.get('content-type','').split(';')[0]!='application/json':
raise APIError('API_CONTENT_TYPE',415)
body=bytearray()
async for chunk in request.stream():
body.extend(chunk)
if len(body)>maximum:raise APIError('API_BODY_LIMIT',413)
try:return json.loads(body)
except ValueError:raise APIError('API_BAD_REQUEST',422) from None
async def auth(request):
origin=request.headers.get('origin')
if origin and origin!=str(request.base_url).rstrip('/'):
raise APIError('AUTH_ORIGIN',403)
action=request.path_params['action']
if action=='login' and request.method=='POST':
data=await small_json(request,16384)
if not isinstance(data,dict) or set(data)!={'username','password'}:raise APIError('AUTH_LOGIN_INVALID',401)
token,info=await run_in_threadpool(sessions.login,data['username'],data['password'],request.client.host if request.client else 'unknown')
response=JSONResponse(info,headers={'Cache-Control':'no-store'})
response.set_cookie(COOKIE,token,max_age=config.session_seconds,httponly=True,
secure=request.url.scheme=='https',samesite='strict',path='/')
return response
if action not in {'session','logout'} or (action=='session' and request.method!='GET') or (action=='logout' and request.method!='POST'):
raise APIError('API_NOT_FOUND',404)
await run_in_threadpool(sessions.authenticate,request)
info=request.state.session_info
if action=='logout':
await run_in_threadpool(sessions.logout,request.state.session_id)
response=JSONResponse({'status':'OK'},headers={'Cache-Control':'no-store'});response.delete_cookie(COOKIE,path='/');return response
return JSONResponse({'username':info['username'],'csrf':info['csrf'],'expires_at':info['expires']},headers={'Cache-Control':'no-store'})
async def health(request):
return JSONResponse({'status': 'OK'})
@@ -127,6 +162,8 @@ def create_app(config):
if request.method=='GET':
result=await run_in_threadpool(test_requests.listing,client)
else:
identifier=getattr(request.state,'session_id',None)
if not identifier:raise APIError('AUTH_SESSION_REQUIRED',403)
if not (client.operator or client.tester):
raise APIError('API_FORBIDDEN',403)
if request.headers.get('content-type','').split(';')[0]!='application/json':
@@ -140,7 +177,19 @@ def create_app(config):
data=json.loads(body)
except ValueError:
raise APIError('API_TEST_FEEDBACK_INVALID',422) from None
result=await run_in_threadpool(test_requests.answer,client,request.path_params['handle'],data)
handle=request.path_params['handle']
if (not isinstance(data,dict) or not isinstance(data.get('request_id'),str)
or type(data.get('analysis_revision')) is not int):
raise APIError('API_TEST_FEEDBACK_INVALID',422)
if request.url.path.endswith('/claim'):
if set(data)!={'request_id','analysis_revision','action'} or not isinstance(data['action'],str) or data['action'] not in {'edit','release'}:
raise APIError('API_TEST_FEEDBACK_INVALID',422)
await run_in_threadpool(test_requests.pending,client,handle,data['request_id'],data['analysis_revision'])
await run_in_threadpool(functools.partial(sessions.claim,identifier,handle,data['request_id'],data['analysis_revision'],release=data['action']=='release'))
result={'status':'RELEASED' if data['action']=='release' else 'EDITING'}
else:
await run_in_threadpool(functools.partial(sessions.claim,identifier,handle,data['request_id'],data['analysis_revision'],check=True))
result=await run_in_threadpool(test_requests.answer,client,handle,data)
except LockBusy:
raise APIError('API_TEST_BUSY',409) from None
except (LifecycleError,PackageReadError) as exc:
@@ -227,14 +276,17 @@ def create_app(config):
return JSONResponse({'status': 'ERROR', 'code': 'API_STORAGE_ERROR'}, status_code=503)
app = Starlette(routes=[Route('/', web), Route('/assets/{asset}', web),
Route('/v1/auth/{action}',auth,methods=['GET','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']),
Route('/v1/test-requests/{handle}/claim',tests,methods=['POST']),
Route('/v1/cases/{handle}', case_detail), Route('/v1/cases/{handle}/package', case_package),
Route('/v1/schema', contract), Route('/v1/submissions', submit, methods=['POST']),
Route('/v1/submissions/{submission}', status), Route('/v1/submissions/{submission}/package', package)],
exception_handlers={APIError: error, HTTPException: http_error, OSError: storage_error})
exception_handlers={APIError: error, HTTPException: http_error, OSError: storage_error,sqlite3.Error:storage_error})
app.state.intake = service
app.state.sessions=sessions
return BodyLimit(app, config.limits.max_body_bytes)
+14 -5
View File
@@ -78,13 +78,16 @@ class APIConfig:
host: str = '127.0.0.1'
port: int = 8080
limits: IntakeLimits = field(default_factory=IntakeLimits)
users_database: Path | None = None
session_seconds: int = 3600
def __post_init__(self):
ids = {p.id for p in self.projects}
if (not ids or len(ids) != len(self.projects) or not self.clients
if (not ids or len(ids) != len(self.projects)
or len({c.id for c in self.clients}) != len(self.clients)
or type(self.port) is not int or not 0 < self.port < 65536
or not isinstance(self.host, str) or not self.host):
or not isinstance(self.host, str) or not self.host
or type(self.session_seconds) is not int or not 60<=self.session_seconds<=86400):
raise APIError('API_CONFIG', 500)
for c in self.clients:
if (type(c.operator) is not bool or type(c.tester) is not bool or not isinstance(c.id, str) or not IDENTIFIER.fullmatch(c.id)
@@ -97,6 +100,8 @@ class APIConfig:
result = []
for client in self.clients:
token = os.environ.get(client.token_env, '')
if not token:
continue # Machine integrations are optional; web users need no token env.
if not re.fullmatch(r'[A-Za-z0-9_-]{32,256}', token):
raise APIError('API_CREDENTIALS', 500)
if any(token == existing for _, existing in result):
@@ -109,7 +114,7 @@ class APIConfig:
try:
path = Path(path).resolve()
data = tomllib.loads(path.read_text(encoding='utf-8'))
if set(data) - {'api', 'projects', 'clients', 'limits'}:
if set(data) - {'api', 'projects', 'clients', 'limits','auth'}:
raise ValueError()
api = data['api']
if set(api) - {'workspace', 'host', 'port'}:
@@ -127,9 +132,13 @@ class APIConfig:
raise ValueError()
resolved[key] = target
projects.append(Project(**row, settings=resolved))
clients = [Client(**(dict(c) | {'projects': tuple(c['projects'])})) for c in data['clients']]
clients = [Client(**(dict(c) | {'projects': tuple(c['projects'])})) for c in data.get('clients',[])]
auth=data.get('auth',{})
if set(auth)-{'users_database','session_seconds'}:raise ValueError()
return cls(workspace=(path.parent / api['workspace']).resolve(),
projects=tuple(projects), clients=tuple(clients), host=api.get('host', '127.0.0.1'),
port=api.get('port', 8080), limits=IntakeLimits(**data.get('limits', {})))
port=api.get('port', 8080), limits=IntakeLimits(**data.get('limits', {})),
users_database=(path.parent/auth['users_database']).resolve() if 'users_database' in auth else None,
session_seconds=auth.get('session_seconds',3600))
except (OSError, ValueError, TypeError, KeyError):
raise APIError('API_CONFIG', 500) from None
+97
View File
@@ -0,0 +1,97 @@
import hashlib
import hmac
import os
import secrets
import time
from .config import APIError,Client,directory
from .users import Users,database
COOKIE='vba_session'
class Sessions:
def __init__(self,config,*,clock=time.time):
self.config=config;self.clock=clock
self.users=Users(config.users_database or config.workspace/'auth/users.sqlite3')
root=directory(config.workspace/'runtime/web-sessions')
self.path=root/'sessions.sqlite3'
if not self.path.exists():
try:
descriptor=os.open(self.path,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600);os.close(descriptor)
except FileExistsError:pass
with database(self.path) as db:
db.executescript('''
CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, username TEXT NOT NULL, version TEXT NOT NULL, csrf TEXT NOT NULL, expires REAL NOT NULL);
CREATE TABLE IF NOT EXISTS claims (handle TEXT NOT NULL, request_id TEXT NOT NULL, revision INTEGER NOT NULL, session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, PRIMARY KEY(handle,request_id,revision));
CREATE TABLE IF NOT EXISTS attempts (id TEXT PRIMARY KEY, count INTEGER NOT NULL, until REAL NOT NULL);
''')
@staticmethod
def digest(value):return hashlib.sha256(value.encode()).hexdigest()
def _prune(self,db):
db.execute('DELETE FROM sessions WHERE expires<=?',(self.clock(),))
# Password changes/deletion invalidate sessions and their editing claims.
for row in db.execute('SELECT id,username,version FROM sessions').fetchall():
user=self.users.get(row['username'])
if not user or user['version']!=row['version']:
db.execute('DELETE FROM sessions WHERE id=?',(row['id'],))
def login(self,name,password,remote):
if not isinstance(name,str) or not isinstance(password,str) or not 1<=len(name)<=64 or not 1<=len(password)<=1024:
raise APIError('AUTH_LOGIN_INVALID',401)
now=self.clock()
with database(self.path) as db:
db.execute('BEGIN IMMEDIATE')
db.execute('DELETE FROM attempts WHERE until<=?',(now,))
keys=[('user:'+name.casefold(),10),('ip:'+remote,30)]
for value,maximum in keys:
identifier=self.digest(value)
row=db.execute('SELECT count FROM attempts WHERE id=?',(identifier,)).fetchone()
if row and row['count']>=maximum:raise APIError('AUTH_LOGIN_LIMIT',429)
for value,_ in keys:
db.execute('INSERT INTO attempts VALUES (?,1,?) ON CONFLICT(id) DO UPDATE SET count=count+1',
(self.digest(value),now+900))
user=self.users.verify(name,password)
if not user:raise APIError('AUTH_LOGIN_INVALID',401)
token=secrets.token_urlsafe(32);csrf=secrets.token_urlsafe(32);expires=now+self.config.session_seconds
with database(self.path) as db:
db.execute('BEGIN IMMEDIATE');self._prune(db)
db.execute('DELETE FROM attempts WHERE id=?',(self.digest('user:'+name.casefold()),))
db.execute('INSERT INTO sessions VALUES (?,?,?,?,?)',(self.digest(token),user['name'],user['version'],csrf,expires))
return token,{'username':user['name'],'csrf':csrf,'expires_at':expires}
def authenticate(self,request):
token=request.cookies.get(COOKIE,'')
if not token or len(token)>128:raise APIError('API_UNAUTHORIZED',401)
identifier=self.digest(token)
with database(self.path) as db:
row=db.execute('SELECT * FROM sessions WHERE id=? AND expires>?',(identifier,self.clock())).fetchone()
user=self.users.get(row['username']) if row else None
if not user or user['version']!=row['version']:raise APIError('API_UNAUTHORIZED',401)
if request.method not in {'GET','HEAD','OPTIONS'}:
csrf=request.headers.get('x-csrf-token','')
if not csrf.isascii() or not hmac.compare_digest(csrf,row['csrf']):
raise APIError('AUTH_CSRF',403)
request.state.session_id=identifier
request.state.session_info=dict(row)
# Human accounts deliberately have the same complete web permissions.
return Client('user-'+user['name'],'WEB_SESSION',tuple(p.id for p in self.config.projects),operator=True,tester=True)
def logout(self,identifier):
with database(self.path) as db:db.execute('DELETE FROM sessions WHERE id=?',(identifier,))
def claim(self,identifier,handle,request_id,revision,*,check=False,release=False):
with database(self.path) as db:
db.execute('BEGIN IMMEDIATE');self._prune(db)
if not db.execute('SELECT 1 FROM sessions WHERE id=?',(identifier,)).fetchone():
raise APIError('API_UNAUTHORIZED',401)
identity=(handle,request_id,revision)
row=db.execute('SELECT session_id FROM claims WHERE handle=? AND request_id=? AND revision=?',identity).fetchone()
if row and row['session_id']!=identifier:raise APIError('API_TEST_CLAIMED',409)
if check and not row:raise APIError('API_TEST_CLAIM_REQUIRED',409)
if release:
db.execute('DELETE FROM claims WHERE handle=? AND request_id=? AND revision=? AND session_id=?',identity+(identifier,))
elif not row:
db.execute('INSERT INTO claims VALUES (?,?,?,?)',identity+(identifier,))
@@ -23,6 +23,13 @@ class TestRequests:
return None
return value
def pending(self,client,handle,request_id,revision):
with PackageReader(self.cases.head(handle,client)) as reader:
value=self.request(reader)
if (reader.manifest['state']!='BLOCKED' or reader.manifest['routing']['reason']!='AWAITING_TEST_FEEDBACK'
or not value or (value['request_id'],value['analysis_revision'])!=(request_id,revision)):
raise APIError('API_TEST_REQUEST_STALE',409)
def listing(self,client):
rows=[];errors=0
for folder in self.service.workspace.cases.glob('*/*'):
+84
View File
@@ -0,0 +1,84 @@
"""Portable password database. Only privileged console commands mutate users."""
import hashlib
import hmac
import os
import re
import secrets
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from .config import APIError,directory
@contextmanager
def database(path, *, readonly=False):
path=Path(path).absolute()
for part in [*path.parents,path]:
if part.is_symlink() or (part.exists() and getattr(part.lstat(),'st_file_attributes',0)&0x400):
raise APIError('AUTH_UNSAFE_STORAGE',500)
connection=sqlite3.connect(path.as_uri()+'?mode=ro' if readonly else str(path),uri=readonly,timeout=5)
connection.row_factory=sqlite3.Row
connection.execute('PRAGMA foreign_keys=ON')
try:
with connection:
yield connection
finally:
connection.close()
def password_hash(password,salt):
return hashlib.scrypt(password.encode('utf-8'),salt=salt,n=32768,r=8,p=1,maxmem=64*1024*1024).hex()
class Users:
def __init__(self,path):
self.path=Path(path)
def get(self,name):
if not self.path.exists():return None
with database(self.path,readonly=True) as db:
row=db.execute('SELECT * FROM users WHERE name=? COLLATE NOCASE',(name,)).fetchone()
return dict(row) if row else None
def verify(self,name,password):
row=self.get(name)
actual=password_hash(password,bytes.fromhex(row['salt']) if row else b'\0'*16)
return row if row and hmac.compare_digest(actual,row['password_hash']) else None
def manage(self,action,name,password=None,old_password=None):
if not isinstance(name,str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,63}',name):
raise APIError('AUTH_USER_INVALID',400)
if action!='delete_user' and (not isinstance(password,str) or not 1<=len(password)<=1024):
raise APIError('AUTH_PASSWORD_INVALID',400)
if action=='change_pw_user' and (not isinstance(old_password,str) or len(old_password)>1024):
raise APIError('AUTH_PASSWORD_INVALID',400)
directory(self.path.parent)
if not self.path.exists():
descriptor=os.open(self.path,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o640);os.close(descriptor)
with database(self.path) as db:
db.execute('CREATE TABLE IF NOT EXISTS users (name TEXT PRIMARY KEY COLLATE NOCASE, salt TEXT NOT NULL, password_hash TEXT NOT NULL, version TEXT NOT NULL)')
db.execute('BEGIN IMMEDIATE')
row=db.execute('SELECT * FROM users WHERE name=? COLLATE NOCASE',(name,)).fetchone()
if action=='create_user' and row:raise APIError('AUTH_USER_EXISTS',409)
if action!='create_user' and not row:raise APIError('AUTH_USER_NOT_FOUND',404)
if action=='delete_user':
db.execute('DELETE FROM users WHERE name=? COLLATE NOCASE',(name,));return
if action=='change_pw_user' and not hmac.compare_digest(password_hash(old_password,bytes.fromhex(row['salt'])),row['password_hash']):
raise APIError('AUTH_OLD_PASSWORD',403)
salt=secrets.token_bytes(16)
digest=password_hash(password,salt)
if action=='create_user':
db.execute('INSERT INTO users VALUES (?,?,?,?)',(name,salt.hex(),digest,secrets.token_hex(16)))
else:
db.execute('UPDATE users SET salt=?,password_hash=?,version=? WHERE name=? COLLATE NOCASE',
(salt.hex(),digest,secrets.token_hex(16),name))
def require_privileged():
if os.name=='nt':
import ctypes
allowed=bool(ctypes.windll.shell32.IsUserAnAdmin())
else:
allowed=os.geteuid()==0
if not allowed:raise APIError('AUTH_PRIVILEGED_REQUIRED',403)
+3 -3
View File
@@ -39,8 +39,8 @@ function renderArchive(){
function clearDetails(){selectedCase=null;detailHash=null;detailLoading=false;$('result-search').value='';}
async function downloadCase(data,button){
const generation=session,credential=token;button.disabled=true;error();
try{const response=await fetch(`/v1/cases/${data.key}/package`,{headers:{Authorization:`Bearer ${credential}`},cache:'no-store'});
if(!response.ok){const issue=await response.json();if(response.status===401&&credential===token)logout();throw new Error(issue.code||`HTTP ${response.status}`);}
try{const response=await fetch(`/v1/cases/${data.key}/package`,{credentials:'same-origin',cache:'no-store'});
if(!response.ok){const issue=await response.json();if(response.status===401&&credential===token&&generation===session)logout(false);throw new Error(issue.code||`HTTP ${response.status}`);}
const blob=await response.blob();if(generation!==session)return;
const url=URL.createObjectURL(blob),link=document.createElement('a');link.href=url;link.download=`${data.case_id.replace(/[^A-Za-z0-9_.-]/g,'_')}-Fallakte.zip`;document.body.append(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),30000);
}catch(e){if(generation===session)error(`Download fehlgeschlagen: ${e.message}`);}finally{button.disabled=false;}
@@ -110,7 +110,7 @@ function route(){
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';
$('overview-page').hidden=detail||results||submissionPage;$('results').hidden=!results;$('case-detail').hidden=!detail;$('submit').hidden=detail||results;
$('overview-page').hidden=detail||results||submissionPage;$('results').hidden=!results;$('case-detail').hidden=!detail;$('submit').hidden=!submissionPage;
document.querySelector('header h1').textContent=detail?'Die Fallakte.':results?'Abgeschlossene Fälle.':submissionPage?'Auftrag übergeben.':'Analyse im Blick.';
document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('selected',detail?a.hash==='#cases':a.hash===(results?'#results':submissionPage?'#submit':location.hash==='#cases'?'#cases':'#overview')));
if(detail)loadDetail(location.hash);if(results)renderArchive();
+22 -12
View File
@@ -5,13 +5,14 @@ const names = {NEW:'Eingang',VALIDATED:'Angenommen',BUILDING_CASE:'Fallaufbau',I
const finished = new Set(['OUTPUT_READY','FAILED_TO_ANALYZE','HANDED_OFF']);
const number = n => new Intl.NumberFormat('de-DE').format(n);
let token = '', timer = null, overview = null, projects = [], sending = false, submission = null, session = 0;
let csrfToken='',logoutPending=Promise.resolve(),loginBusy=false;
const text = (tag, value, cls) => { const e=document.createElement(tag); e.textContent=value; if(cls)e.className=cls; return e; };
function error(message='') { $('error').hidden=!message; $('error').textContent=message; }
async function api(path, options={}) {
const credential=token;
const response=await fetch(path,{...options,headers:{...options.headers,Authorization:`Bearer ${credential}`},cache:'no-store'});
const credential=token,generation=session;
const response=await fetch(path,{...options,headers:{...options.headers,...(csrfToken?{'X-CSRF-Token':csrfToken}:{})},credentials:'same-origin',cache:'no-store'});
const data=await response.json();
if(!response.ok) { if(response.status===401 && credential===token) logout(); throw new Error(data.code || `HTTP ${response.status}`); }
if(!response.ok) { if(response.status===401 && credential===token&&generation===session&&path!=='/v1/auth/login') logout(false); throw new Error(data.code || `HTTP ${response.status}`); }
return data;
}
function progress(value, label) {
@@ -19,8 +20,9 @@ function progress(value, label) {
outer.setAttribute('aria-label',label); outer.setAttribute('aria-valuemin','0'); outer.setAttribute('aria-valuemax','100'); outer.setAttribute('aria-valuenow',String(value));
const inner=text('div','','progress-fill'); inner.style.width=`${value}%`; outer.append(inner); return outer;
}
function logout() {
session++;token=''; clearTimeout(timer); $('token').value=''; $('content').hidden=true; $('login').hidden=false;
function logout(remote=true) {
if(remote&&token)logoutPending=fetch('/v1/auth/logout',{method:'POST',headers:{'X-CSRF-Token':csrfToken},credentials:'same-origin'}).catch(()=>{});
session++;token='';csrfToken=''; clearTimeout(timer); $('password').value=''; $('content').hidden=true; $('login').hidden=false;
$('logout').hidden=true; $('connection').textContent='Nicht verbunden'; document.body.classList.remove('connected');
$('updated').textContent='Warte auf Verbindung'; overview=null;projects=[];submission=null;
$('submit-form').reset();$('receipt').textContent='';$('new-files').hidden=false;$('return-files').hidden=true;
@@ -113,16 +115,24 @@ async function refresh() {
} catch(e) {if(generation===session)error(`Aktualisierung fehlgeschlagen: ${e.message}`);}
if(token && generation===session)timer=setTimeout(refresh,4000);
}
$('login-form').addEventListener('submit',async e=>{
e.preventDefault();const generation=++session;token=$('token').value.trim();error();
try {const response=await api('/v1/profiles');if(generation!==session)return;projects=response.projects;
async function connected(info,generation){
if(generation!==session)return;token='session';csrfToken=info.csrf;
const response=await api('/v1/profiles');if(generation!==session)return;projects=response.projects;
$('project').replaceChildren();$('project-filter').replaceChildren(new Option('Alle freigegebenen Projekte',''));$('result-project').replaceChildren(new Option('Alle freigegebenen Projekte',''));
projects.forEach(p=>{$('project').add(new Option(p.project_id,p.project_id));$('project-filter').add(new Option(p.project_id,p.project_id));$('result-project').add(new Option(p.project_id,p.project_id));});
$('login').hidden=true;$('content').hidden=false;$('logout').hidden=false;$('token').value='';
$('connection').textContent='Verbunden';document.body.classList.add('connected');if(typeof route==='function')route();await refresh();
} catch(e) {if(generation===session){token='';error(`Verbindung fehlgeschlagen: ${e.message}`);}else if(!token)error('Verbindung fehlgeschlagen: API-Zugangsschlüssel prüfen.');}
$('login').hidden=true;$('content').hidden=false;$('logout').hidden=false;$('password').value='';
$('connection').textContent=info.username;document.body.classList.add('connected');if(typeof route==='function')route();await refresh();
}
$('login-form').addEventListener('submit',async e=>{
e.preventDefault();if(loginBusy)return;loginBusy=true;const generation=++session;error();
const password=$('password').value;$('password').value='';
try{await logoutPending;const info=await api('/v1/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:$('username').value,password})});await connected(info,generation);
}catch(e){if(generation===session){token='';error(`Anmeldung fehlgeschlagen: ${e.message}`);}}finally{loginBusy=false;}
});
$('logout').addEventListener('click',logout);$('project-filter').addEventListener('change',render);
$('logout').addEventListener('click',()=>logout());$('project-filter').addEventListener('change',render);
fetch('/v1/auth/session',{credentials:'same-origin',cache:'no-store'}).then(async response=>{
if(response.ok&&session===0){const info=await response.json();await connected(info,0);}
}).catch(()=>{});
$('kind').addEventListener('change',()=>{$('new-files').hidden=$('kind').value!=='new_case';$('return-files').hidden=$('kind').value!=='case_return';});
$('submit-form').addEventListener('submit',async e=>{
e.preventDefault();if(sending)return;error();
+2 -3
View File
@@ -10,8 +10,7 @@
<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>
<section class="login panel" id="login"><div><h2>Mit BugAnalysis verbinden</h2><p>API-Zugangsschlüssel eingeben. Er bleibt nur im Arbeitsspeicher dieses Tabs.</p></div>
<form id="login-form"><label for="token">API-Zugangsschlüssel</label><div class="inline"><input id="token" type="password" autocomplete="off" required placeholder="Zugangsschlüssel"><button type="submit">Verbinden</button></div></form></section>
<section class="login panel" id="login"><div><h2>Bei BugAnalysis anmelden</h2><p>Mit Benutzername und Passwort anmelden.</p></div><form id="login-form"><label>Benutzername<input id="username" autocomplete="username" required maxlength="64"></label><label>Passwort<input id="password" type="password" autocomplete="current-password" required maxlength="1024"></label><button type="submit">Anmelden</button></form></section>
<p id="error" class="error" role="alert" hidden></p>
<div id="content" hidden>
<section class="panel" id="test-page" hidden><h2>Offene Testanfragen</h2><p class="hint">Beobachtungen ergänzen und die laufende Untersuchung fortsetzen.</p><div class="detail-controls"><label>Projekt<select id="test-project"></select></label><label>Sortierung<select id="test-sort"><option value="oldest">Älteste zuerst</option><option value="newest">Neueste zuerst</option><option value="project">Projekt, dann älteste zuerst</option></select></label></div><div id="test-list"></div><div id="test-answer"></div></section><div id="overview-page">
@@ -35,7 +34,7 @@
<div class="form-grid"><label>Projekt<select id="result-project"><option value="">Alle freigegebenen Projekte</option></select></label><label>Fall suchen<input id="result-search" type="search" placeholder="Bug-ID oder Fallname"></label></div>
<div class="table-wrap"><table><thead><tr><th>Fall</th><th>Projekt</th><th>Ergebnis</th><th>Revision</th><th>Aktualisiert</th></tr></thead><tbody id="result-rows"></tbody></table></div></section>
<section id="case-detail" aria-live="polite" hidden></section>
<section class="panel" id="submit"><div class="section-head"><div><div class="eyebrow">EINGANG</div><h2>Auftrag übergeben</h2><p>BugAnalysis erstellt und ergänzt die Fallakte.</p></div></div>
<section class="panel" id="submit" hidden><div class="section-head"><div><div class="eyebrow">EINGANG</div><h2>Auftrag übergeben</h2><p>BugAnalysis erstellt und ergänzt die Fallakte.</p></div></div>
<form id="submit-form"><div class="form-grid"><label>Auftragstyp<select id="kind"><option value="new_case">Neue Analyse</option><option value="case_return">Abgeschlossene Akte zurückgeben</option></select></label>
<label>Projekt<select id="project" required></select></label><label>Bug-ID<input id="case-id" required placeholder="BUG-000020" pattern="[A-Za-z0-9][A-Za-z0-9_.-]{0,127}"></label><label>Fallname / Rückmeldung<input id="title" required maxlength="240"></label></div>
<label>Beobachtungen (eine pro Zeile)<textarea id="observations" required rows="3"></textarea></label>
@@ -36,6 +36,16 @@ function openTest(row){
for(const [field,label] of [['environment','Angeforderte Umgebung'],['prerequisites','Voraussetzungen'],['steps','Testschritte'],['requested_observations','Benötigte Beobachtungen']])root.append(panel(label,semantic(request[field])));
root.append(text('p',`Angefragter Termin: ${new Date(request.deadline).toLocaleString('de-DE')}. Ein abgelaufener Termin ersetzt keine Antwort.`,'hint'));
if(!testData.can_answer){root.append(text('p','Zum Beantworten werden Tester- oder Betreiberrechte benötigt.','hint'));return;}
const edit=text('button','Bearbeiten');edit.type='button';root.append(edit);
edit.addEventListener('click',async()=>{edit.disabled=true;const generation=session;
try{await api('/v1/test-requests/'+row.key+'/claim',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'edit',request_id:request.request_id,analysis_revision:request.analysis_revision})});
if(generation!==session||answerKey!==row.key)return;edit.remove();editTest(row,root);
}catch(e){if(generation===session)error(e.message==='API_TEST_CLAIMED'?'Diese Anfrage wird bereits in einer anderen Sitzung bearbeitet.':`Bearbeitung nicht möglich: ${e.message}`);}finally{edit.disabled=false;}});
root.scrollIntoView({behavior:'smooth',block:'start'});
}
function editTest(row,root){
const request=row.request;
root.append(text('p','Die Bearbeitung ist für deine aktuelle Sitzung reserviert.','hint'));
const form=document.createElement('form');form.className='semantic-list';
function field(label,element){const l=text('label',label);l.append(element);form.append(l);return element;}
const outcome=document.createElement('select');outcome.add(new Option('Test durchgeführt','completed'));outcome.add(new Option('Nicht durchführbar','unable_to_test'));field('Ergebnis',outcome);
@@ -43,6 +53,10 @@ function openTest(row){
const observations=field('Beobachtungen bzw. Grund, warum der Test nicht möglich ist',document.createElement('textarea'));observations.required=true;observations.rows=7;observations.maxLength=12000;
const confirm=document.createElement('input');confirm.type='checkbox';confirm.required=true;field('Ich bestätige diese Angaben als meine Test-Rückmeldung.',confirm);
const button=text('button','Antwort bestätigen und Untersuchung fortsetzen');button.type='submit';form.append(button);root.append(form);
const release=text('button','Bearbeitung freigeben');release.type='button';form.append(release);
release.addEventListener('click',async()=>{const generation=session;release.disabled=true;
try{await api('/v1/test-requests/'+row.key+'/claim',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'release',request_id:request.request_id,analysis_revision:request.analysis_revision})});if(generation===session)openTest(row);
}catch(e){if(generation===session)error(e.message);}finally{release.disabled=false;}});
form.addEventListener('submit',async event=>{event.preventDefault();if(answerBusy)return;const generation=session;answerBusy=true;button.disabled=true;
try{await api('/v1/test-requests/'+row.key,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request_id:request.request_id,analysis_revision:request.analysis_revision,outcome:outcome.value,environment:environment.value,observations:[observations.value],confirmed:confirm.checked})});
if(generation!==session)return;root.replaceChildren(text('p','Antwort gespeichert. Der Worker kann die Untersuchung fortsetzen.'));answerKey=null;await refreshTests();
+9 -3
View File
@@ -24,15 +24,18 @@ try {
let next=0;const pending=new Map();
ws.onmessage=e=>{const m=JSON.parse(e.data);if(m.id){const p=pending.get(m.id);pending.delete(m.id);if(!p)return;m.error?p.reject(new Error(m.error.message)):p.resolve(m.result);}
if(m.method==='Runtime.exceptionThrown')errors.push(m.params.exceptionDetails.text);
if(m.method==='Log.entryAdded'&&m.params.entry.level==='error')errors.push(m.params.entry.text);};
if(m.method==='Log.entryAdded'&&m.params.entry.level==='error'&&!(m.params.entry.url?.endsWith('/v1/auth/session')&&m.params.entry.text.includes('401')))errors.push(m.params.entry.text);};
const send=(method,params={})=>new Promise((resolve,reject)=>{const id=++next;pending.set(id,{resolve,reject});ws.send(JSON.stringify({id,method,params}));});
const evaluate=async expression=>{const r=await send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw new Error(r.exceptionDetails.text);return r.result.value;};
await send('Runtime.enable');await send('Log.enable');await send('Page.enable');
await send('Emulation.setDeviceMetricsOverride',{width:1440,height:1200,deviceScaleFactor:1,mobile:false});
await send('Page.navigate',{url:`http://127.0.0.1:${port}/`});
await until(()=>evaluate("document.readyState === 'complete' && typeof render === 'function'"));
await evaluate("document.getElementById('token').value='ui_fixture_"+'x'.repeat(40)+"';document.getElementById('login-form').dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));");
await evaluate("document.getElementById('username').value='preview';document.getElementById('password').value='ui-fixture-password';document.getElementById('login-form').dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));");
await until(()=>evaluate("document.getElementById('token-count').textContent==='35.300'"));
if(await evaluate("document.cookie.includes('vba_session')"))throw new Error('Session cookie is readable by JavaScript');
await send('Page.reload');
await until(()=>evaluate("session===0&&token==='session'&&document.getElementById('token-count').textContent==='35.300'"));
await until(()=>evaluate("document.querySelector('#operation-projects button')"));
await evaluate("document.querySelector('#operation-projects button').click()");
await until(()=>evaluate("document.getElementById('operation-projects').textContent.includes('Neue Fälle pausiert')"));
@@ -55,7 +58,10 @@ try {
const sorting=await evaluate("(()=>{const original=testData.requests;const row=original[0];testData.requests=[{...row,case_id:'NEW',received_at:'2098-01-01T00:00:00Z'},{...row,project_id:'aaa',case_id:'OLD',received_at:'2000-01-01T00:00:00Z'},row];const first=()=>document.querySelector('#test-list tbody tr').textContent;document.getElementById('test-sort').value='oldest';renderTests();const oldest=first().includes('OLD');document.getElementById('test-sort').value='newest';renderTests();const newest=first().includes('NEW');document.getElementById('test-sort').value='project';renderTests();const project=first().includes('aaa');document.getElementById('test-project').value='viano-core';renderTests();const filtered=document.querySelectorAll('#test-list tbody tr').length===2;testData.requests=original;document.getElementById('test-project').value='';renderTests();return oldest&&newest&&project&&filtered;})()");
if(!sorting)throw new Error('Test request sorting/filtering failed');
await evaluate("document.getElementById('test-sort').value='project';document.getElementById('test-sort').dispatchEvent(new Event('change'));document.querySelector('#test-list button').click()");
if(!await evaluate("document.querySelector('#test-answer textarea')&&document.getElementById('test-answer').textContent.includes('Sichtbarkeit und Build')"))throw new Error('Human test answer form missing');
if(await evaluate("!!document.querySelector('#test-answer textarea')"))throw new Error('Form editable without a claim');
await evaluate("Array.from(document.querySelectorAll('#test-answer button')).find(b=>b.textContent==='Bearbeiten').click()");
await until(()=>evaluate("document.querySelector('#test-answer textarea')"));
if(!await evaluate("document.getElementById('test-answer').textContent.includes('Sichtbarkeit und Build')"))throw new Error('Human test answer form missing');
if(await evaluate('document.documentElement.scrollWidth>innerWidth'))throw new Error('Test page mobile overflow');
await writeFile(path.join(output,'test-requests-mobile.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await evaluate("location.hash='#overview'");await pause(100);
+15 -2
View File
@@ -21,9 +21,13 @@ def test_web_human_feedback_auth_scope_replay_and_resume(hypotheses,monkeypatch,
monkeypatch.setenv('OTHER_TOKEN','other_'+'x'*40)
config=APIConfig(store.workspace.root,(Project('p','wow','vcore'),Project('other','wow','vcore')),
(Client('tester','TESTER_TOKEN',('p',),tester=True),Client('capture','CAPTURE_TOKEN',('p',)),Client('other','OTHER_TOKEN',('other',),tester=True)))
client=TestClient(create_app(config));auth={'Authorization':'Bearer tester_'+'x'*40}
app=create_app(config);client=TestClient(app)
app.state.sessions.users.manage('create_user','alice','test-password')
url='/v1/test-requests';handle=case_key(*('case-A','stable-package'))
assert client.get(url).status_code==401
login=client.post('/v1/auth/login',json={'username':'alice','password':'test-password'})
assert login.status_code==200
auth={'X-CSRF-Token':login.json()['csrf']}
rows=client.get(url,headers=auth).json()
assert rows['can_answer'] and rows['requests'][0]['project_id']=='p'
answer={'request_id':request['request_id'],'analysis_revision':1,'outcome':outcome,
@@ -31,7 +35,16 @@ def test_web_human_feedback_auth_scope_replay_and_resume(hypotheses,monkeypatch,
assert client.post(url+'/'+handle,headers={'Authorization':'Bearer capture_'+'x'*40},json=answer).status_code==403
other={'Authorization':'Bearer other_'+'x'*40}
assert client.get(url,headers=other).json()['requests']==[]
assert client.post(url+'/'+handle,headers=other,json=answer).status_code==404
assert client.post(url+'/'+handle,headers=other,json=answer).status_code==403
claim={'request_id':request['request_id'],'analysis_revision':1,'action':'edit'}
assert client.post(url+'/'+handle,headers=auth,json=answer).status_code==409
assert client.post(url+'/'+handle+'/claim',headers=auth,json=claim).status_code==200
app.state.sessions.users.manage('create_user','bob','second-password')
second=TestClient(app)
info=second.post('/v1/auth/login',json={'username':'bob','password':'second-password'}).json()
second_auth={'X-CSRF-Token':info['csrf']}
assert second.post(url+'/'+handle+'/claim',headers=second_auth,json=claim).status_code==409
assert second.post(url+'/'+handle,headers=second_auth,json=answer).status_code==409
assert client.post(url+'/'+handle,headers=auth,json=answer|{'request_id':'wrong'}).status_code==409
assert client.post(url+'/'+handle,headers=auth,json=answer|{'confirmed':False}).status_code==422
response=client.post(url+'/'+handle,headers=auth,json=answer)
+100
View File
@@ -0,0 +1,100 @@
import json
import sys
from types import SimpleNamespace
from concurrent.futures import ThreadPoolExecutor
import pytest
from starlette.testclient import TestClient
from viano_bug_analysis.api import APIConfig,Project,APIError
from viano_bug_analysis.api.app import create_app
from viano_bug_analysis.api.users import Users
@pytest.fixture
def auth_app(tmp_path):
config=APIConfig(tmp_path/'service',(Project('p','wow','vcore'),Project('second','wow','vcore')),())
app=create_app(config)
app.state.sessions.users.manage('create_user','alice','first password')
app.state.sessions.users.manage('create_user','bob','other password')
return app
def login(client,name='alice',password='first password'):
response=client.post('/v1/auth/login',json={'username':name,'password':password})
assert response.status_code==200,response.text
return {'X-CSRF-Token':response.json()['csrf']}
def test_login_csrf_permissions_password_change_and_delete(auth_app):
client=TestClient(auth_app)
assert client.post('/v1/auth/login',json={'username':'alice','password':'wrong'}).status_code==401
assert client.post('/v1/auth/login',headers={'Origin':'https://evil.example'},json={'username':'alice','password':'first password'}).status_code==403
auth=login(client)
assert 'httponly' in client.post('/v1/auth/login',json={'username':'alice','password':'first password'}).headers['set-cookie'].lower()
auth={'X-CSRF-Token':client.get('/v1/auth/session').json()['csrf']}
assert {p['project_id'] for p in client.get('/v1/profiles').json()['projects']}=={'p','second'}
assert client.get('/v1/operations').json()['operator']
command={'action':'pause','project_id':'p'}
assert client.post('/v1/operations',json=command).status_code==403
assert client.post('/v1/operations',headers=auth,json=command).status_code==200
with pytest.raises(APIError):auth_app.state.sessions.users.manage('change_pw_user','alice','new password','wrong')
auth_app.state.sessions.users.manage('change_pw_user','alice','new password','first password')
assert client.get('/v1/profiles').status_code==401
auth=login(client,password='new password')
auth_app.state.sessions.users.manage('delete_user','alice')
assert client.get('/v1/auth/session').status_code==401
def test_claim_is_atomic_session_bound_and_released_on_logout_or_expiry(auth_app):
sessions=auth_app.state.sessions
now=[1000.0];sessions.clock=lambda:now[0]
first,second=TestClient(auth_app),TestClient(auth_app)
first_auth,second_auth=login(first),login(second,'bob','other password')
def sid(client):return sessions.digest(client.cookies['vba_session'])
identifiers=[sid(first),sid(second)]
def acquire(identifier):
try:sessions.claim(identifier,'case','request',1);return 'won'
except APIError as error:return error.code
with ThreadPoolExecutor(max_workers=2) as pool:
results=list(pool.map(acquire,identifiers))
assert sorted(results)==['API_TEST_CLAIMED','won']
winner=results.index('won');loser=1-winner
clients=[first,second];headers=[first_auth,second_auth]
assert clients[winner].post('/v1/auth/logout',headers=headers[winner]).status_code==200
sessions.claim(identifiers[loser],'case','request',1)
sessions.claim(identifiers[loser],'case','request',1,check=True)
now[0]+=auth_app.state.sessions.config.session_seconds+1
assert clients[loser].get('/v1/auth/session').status_code==401
login(first)
sessions.claim(sid(first),'case','request',1)
def test_login_rate_limit_persists_across_requests(auth_app):
client=TestClient(auth_app)
for _ in range(10):
assert client.post('/v1/auth/login',json={'username':'missing','password':'wrong'}).status_code==401
assert client.post('/v1/auth/login',json={'username':'missing','password':'wrong'}).status_code==429
def test_console_privilege_guard_and_commands(tmp_path,monkeypatch,capsys):
from viano_bug_analysis.api.__main__ import main
import viano_bug_analysis.api.users as module
import viano_bug_analysis.api.__main__ as cli
configuration=tmp_path/'api.toml'
configuration.write_text('[api]\nworkspace="service"\n[[projects]]\nid="p"\ntype="wow"\nprofile="vcore"\n')
monkeypatch.setattr(module,'os',SimpleNamespace(name='posix',geteuid=lambda:1000))
monkeypatch.setattr(sys,'argv',['api','create_user','alice','private-password','--config',str(configuration)])
assert main()==1
output=capsys.readouterr().out
assert 'AUTH_PRIVILEGED_REQUIRED' in output and 'private-password' not in output
monkeypatch.undo()
monkeypatch.setattr(module,'require_privileged',lambda:None)
if hasattr(cli.os,'chown'):monkeypatch.setattr(cli.os,'chown',lambda *args:None)
monkeypatch.setattr(sys,'argv',['api','create_user','alice','private-password','--config',str(configuration)])
assert main()==0
users=Users(tmp_path/'service/auth/users.sqlite3')
assert users.verify('alice','private-password')
monkeypatch.setattr(sys,'argv',['api','change_pw_user','alice','private-password','new-password','--config',str(configuration)])
assert main()==0 and users.verify('alice','new-password')
monkeypatch.setattr(sys,'argv',['api','delete_user','alice','--config',str(configuration)])
assert main()==0 and users.get('alice') is None
+2
View File
@@ -103,6 +103,8 @@ if __name__ == '__main__':
details={'operation':'async-test-wait','request_ref':ref},additions={ref:json_bytes(request)})
config=APIConfig(root,(Project('viano-core','wow','vcore'),),
(Client('preview','VBA_UI_TEST_TOKEN',('viano-core',),operator=True),))
from viano_bug_analysis.api.users import Users
Users(root/'auth/users.sqlite3').manage('create_user','preview','ui-fixture-password')
os.environ['VBA_UI_TEST_TOKEN']=TOKEN
dashboard.read_status=lambda _: {'observer':'LIVE','service_id':'fixture-worker'}
operations.read_status=dashboard.read_status