Admin-Reiter Diagnosezugang pro Organisation, Hash an ISO-Build (ADR-0035)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-02 14:18:26 +02:00
1 parent a196fd2ea4
commit ee3f6596c5
5 files changed
+160 -2

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
import secrets
import subprocess
from db import get_database_connection
from encryption import decrypt_psk, encrypt_psk
# Diagnosezugang (02.10.2026, siehe migrations/0006_diagnosezugang.sql):
# root-Zugang auf neu installierten Geraeten einer Organisation, nur vom
# Root-Admin schaltbar und einsehbar. encrypt_psk/decrypt_psk sind
# generische Fernet-Helfer (Name historisch vom WLAN-PSK).
def fetch_diagnosezugang(organization_id: str) -> dict:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT aktiv, passwort_encrypted, updated_at FROM diagnosezugang WHERE organization_id = %s",
(organization_id,),
)
row = cur.fetchone()
if row is None:
return {"aktiv": False, "passwort": None, "updated_at": None}
aktiv, passwort_encrypted, updated_at = row
return {"aktiv": aktiv, "passwort": decrypt_psk(passwort_encrypted), "updated_at": updated_at}
def aktiviere_diagnosezugang(organization_id: str, neues_passwort: bool = False) -> None:
"""
Schaltet den Zugang ein. Ein vorhandenes Passwort bleibt erhalten (sonst
passten frisch installierte und schon vorhandene Geraete nicht mehr
zusammen), ausser neues_passwort=True.
"""
vorhanden = fetch_diagnosezugang(organization_id)
passwort = vorhanden["passwort"]
if neues_passwort or not passwort:
passwort = secrets.token_urlsafe(18)
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO diagnosezugang (organization_id, aktiv, passwort_encrypted, updated_at)
VALUES (%s, TRUE, %s, CURRENT_TIMESTAMP)
ON CONFLICT (organization_id) DO UPDATE
SET aktiv = TRUE,
passwort_encrypted = EXCLUDED.passwort_encrypted,
updated_at = CURRENT_TIMESTAMP
""",
(organization_id, encrypt_psk(passwort)),
)
def deaktiviere_diagnosezugang(organization_id: str) -> None:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE diagnosezugang SET aktiv = FALSE, updated_at = CURRENT_TIMESTAMP WHERE organization_id = %s",
(organization_id,),
)
def root_password_hash_fuer_build(organization_id: str) -> str | None:
"""
SHA-512-crypt-Hash fuer build_customer_iso.sh, oder None wenn der
Zugang aus ist. openssl statt Pythons crypt-Modul (seit 3.13 entfernt);
$6$ versteht jede glibc/libxcrypt der drei Zielsysteme.
"""
zugang = fetch_diagnosezugang(organization_id)
if not zugang["aktiv"] or not zugang["passwort"]:
return None
result = subprocess.run(
["openssl", "passwd", "-6", "-stdin"],
input=zugang["passwort"] + "\n",
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
+19
View File
@@ -0,0 +1,19 @@
BEGIN;
-- Diagnosezugang (Kunden-ISO-Test 02.10.2026, siehe platform-docs/roadmap/
-- kunden-iso-test-2026-10-02.md R7): pro Organisation vom Root-Admin
-- (is_superuser) schaltbarer root-Zugang (lokal + SSH) auf neu
-- installierten Geraeten. Das Zufallspasswort ist ausschliesslich im
-- Admin-Bereich sichtbar - deshalb Fernet-verschluesselt (muss anzeigbar
-- bleiben) statt gehasht; auf die Kunden-ISO gelangt nur der daraus beim
-- Bau erzeugte SHA-512-crypt-Hash. Wirkt nur auf ISOs, die nach dem
-- Einschalten gebaut werden - bereits installierte Geraete behalten, was
-- bei ihrer Installation galt.
CREATE TABLE diagnosezugang (
organization_id UUID PRIMARY KEY,
aktiv BOOLEAN NOT NULL DEFAULT FALSE,
passwort_encrypted BYTEA,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
COMMIT;
+27 -1
View File
@@ -6,6 +6,7 @@ from fastapi_users import exceptions
from anode_client import anode_request
from auth import UserCreate, UserManager, fetch_benutzer_fuer_organisation, get_user_manager, require_admin
from diagnosezugang import aktiviere_diagnosezugang, deaktiviere_diagnosezugang, fetch_diagnosezugang
from templating import templates
@@ -37,7 +38,7 @@ def _fetch_organization(organization_id: str) -> dict | None:
def _render_detail_tab(request: Request, organization_id: str, user: dict, aktiver_tab: str, **kontext):
"""
Gemeinsamer Rahmen für die sechs Organisations-Detail-Reiter (GUI-Umbau
Gemeinsamer Rahmen für die Organisations-Detail-Reiter (GUI-Umbau
11.09.2026: vorher eine einzige lange Seite) - jeder Reiter holt nur
die Daten, die er tatsächlich braucht, statt wie vorher alles auf
einmal (analog zum Installationsmedium-Reiter-Umbau vom Vortag).
@@ -71,6 +72,31 @@ def detail_hostname(request: Request, organization_id: str, user: dict = Depends
)
@router.get("/{organization_id}/diagnosezugang")
def detail_diagnosezugang(request: Request, organization_id: str, user: dict = Depends(require_admin)):
return _render_detail_tab(
request, organization_id, user, "diagnosezugang", diagnosezugang=fetch_diagnosezugang(organization_id)
)
@router.post("/{organization_id}/diagnosezugang")
def diagnosezugang_schalten(
organization_id: str,
aktion: str = Form(...),
user: dict = Depends(require_admin),
):
if aktion == "aktivieren":
aktiviere_diagnosezugang(organization_id)
elif aktion == "neues_passwort":
aktiviere_diagnosezugang(organization_id, neues_passwort=True)
elif aktion == "deaktivieren":
deaktiviere_diagnosezugang(organization_id)
else:
raise HTTPException(status_code=400, detail="Unbekannte Aktion.")
return RedirectResponse(f"/admin/kunden/{organization_id}/diagnosezugang", status_code=303)
@router.get("/{organization_id}/aktivierungscodes")
def detail_aktivierungscodes(request: Request, organization_id: str, user: dict = Depends(require_admin)):
codes_result = anode_request("GET", f"/api/v1/organizations/{organization_id}/activation-codes")
+9 -1
View File
@@ -7,6 +7,7 @@ from fastapi.responses import RedirectResponse, StreamingResponse
from anode_client import anode_request, anode_stream
from auth import get_current_user
from db import get_database_connection
from diagnosezugang import root_password_hash_fuer_build
from encryption import decrypt_psk, encrypt_psk
from templating import templates
@@ -273,7 +274,14 @@ def installationsmedium_erstellen(
build_result = anode_request(
"POST",
f"/api/v1/organizations/{organization_id}/iso-builds",
json={"activation_code": session_id, "wifi_ssid": wifi_ssid, "wifi_psk": psk_fuer_build},
json={
"activation_code": session_id,
"wifi_ssid": wifi_ssid,
"wifi_psk": psk_fuer_build,
# Diagnosezugang (02.10.2026): nur der Hash, und nur wenn der
# Root-Admin ihn fuer diese Organisation eingeschaltet hat.
"root_password_hash": root_password_hash_fuer_build(organization_id),
},
)
if not build_result.get("success"):
+21
View File
@@ -12,6 +12,7 @@
<li><a href="/admin/kunden/{{ organization_id }}/bereitstellungsvorlagen">{% if aktiver_tab == "bereitstellungsvorlagen" %}<strong>Bereitstellungsvorlagen</strong>{% else %}Bereitstellungsvorlagen{% endif %}</a></li>
<li><a href="/admin/kunden/{{ organization_id }}/konten">{% if aktiver_tab == "konten" %}<strong>Kundenkonten</strong>{% else %}Kundenkonten{% endif %}</a></li>
<li><a href="/admin/kunden/{{ organization_id }}/gruppen">{% if aktiver_tab == "gruppen" %}<strong>Gruppen</strong>{% else %}Gruppen{% endif %}</a></li>
<li><a href="/admin/kunden/{{ organization_id }}/diagnosezugang">{% if aktiver_tab == "diagnosezugang" %}<strong>Diagnosezugang</strong>{% else %}Diagnosezugang{% endif %}</a></li>
<li><a href="/admin/flows/{{ organization_id }}">{% if aktiver_tab == "flows" %}<strong>Flows</strong>{% else %}Flows{% endif %}</a></li>
</ul>
</nav>
@@ -75,6 +76,26 @@
</section>
{% endif %}
{% if aktiver_tab == "diagnosezugang" %}
<section>
<p><small>Richtet auf Geräten, die mit einem <strong>danach</strong> erstellten Installationsmedium installiert werden, einen root-Zugang ein (lokal an der Konsole und per SSH). Nur für die Fehlersuche gedacht. Das Passwort ist ausschließlich hier im Admin-Bereich sichtbar, auf das Installationsmedium gelangt nur sein Hash. Ausschalten wirkt nur auf neu erstellte Installationsmedien – bereits installierte Geräte behalten den Zugang bis zur Neuinstallation.</small></p>
{% if diagnosezugang.aktiv %}
<p>Status: <mark>aktiv</mark></p>
<p>Benutzer: <code>root</code><br>Passwort: <code>{{ diagnosezugang.passwort or "— (nicht lesbar)" }}</code></p>
<form method="post" action="/admin/kunden/{{ organization_id }}/diagnosezugang">
<button type="submit" name="aktion" value="deaktivieren" class="secondary">Ausschalten</button>
<button type="submit" name="aktion" value="neues_passwort" class="secondary outline">Neues Passwort erzeugen</button>
</form>
<p><small>Ein neues Passwort gilt nur für danach erstellte Installationsmedien – vorhandene Medien und Geräte behalten das alte.</small></p>
{% else %}
<p>Status: aus</p>
<form method="post" action="/admin/kunden/{{ organization_id }}/diagnosezugang">
<button type="submit" name="aktion" value="aktivieren">Einschalten</button>
</form>
{% endif %}
</section>
{% endif %}
{% if aktiver_tab == "aktivierungscodes" %}
<section>
<figure>