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:
1 parent
a196fd2ea4
commit
ee3f6596c5
5 files changed
+160
-2
No files matched your search
@@ -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()
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user