Compare commits

...
5 Commits
63 changed files with 2266 additions and 404 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm" name = "tesm"
version = "2.7.8" version = "2.8.4"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+3 -3
View File
@@ -19,7 +19,7 @@ from tesm_core.extension import core
from tesm_core.views import trash as trash_views from tesm_core.views import trash as trash_views
from .navigation import NAVIGATION from .navigation import NAVIGATION
from .permissions import PERMISSIONS, ROLE_PRESETS from .permissions import DHCP_PAGE, PERMISSIONS, ROLE_PRESETS
from .schema import TESM_MIGRATIONS from .schema import TESM_MIGRATIONS
from .settings_spec import TESM_SETTINGS from .settings_spec import TESM_SETTINGS
@@ -205,7 +205,7 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
# Timer bis zum naechsten Durchlauf, mit Knopf fuer "sofort pruefen". Der # Timer bis zum naechsten Durchlauf, mit Knopf fuer "sofort pruefen". Der
# Knopf gehoert in die Pille, weil er genau die Angabe daneben zuruecksetzt. # Knopf gehoert in die Pille, weil er genau die Angabe daneben zuruecksetzt.
if user.has_any(("devices.view", "monitoring.view")): if user.has_any(("devices.read", "monitoring.read")):
intervall = int(extension.settings.get(verbindung, "monitor_interval_seconds") or 300) intervall = int(extension.settings.get(verbindung, "monitor_interval_seconds") or 300)
faellig = monitor.next_check_at(verbindung, intervall) faellig = monitor.next_check_at(verbindung, intervall)
if faellig is not None: if faellig is not None:
@@ -237,7 +237,7 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
} }
) )
if user.has_any(("dhcp.view", "dhcp.edit")) and extension.license is not None: if user.has_any(DHCP_PAGE) and extension.license is not None:
if extension.license.module_active("dhcp") and extension.sysops is not None: if extension.license.module_active("dhcp") and extension.sysops is not None:
state = extension.sysops.service_state(kea.SERVICE_UNIT) state = extension.sysops.service_state(kea.SERVICE_UNIT)
if state["installed"] == "ja": if state["installed"] == "ja":
+8 -8
View File
@@ -37,14 +37,14 @@ def _allowed_categories() -> list[transfer.Category]:
for category in transfer.CATEGORIES: for category in transfer.CATEGORIES:
if category.admin_only and not current_user.is_admin: if category.admin_only and not current_user.is_admin:
continue continue
if category.sensitive and not current_user.has("backup.secrets"): if category.sensitive and not current_user.has("backup_export.secrets"):
continue continue
allowed.append(category) allowed.append(category)
return allowed return allowed
@bp.get("/") @bp.get("/")
@permission_required("backup.view", "backup.export", "backup.execute", mode="any") @permission_required("backup_export.read", "backup_import.read", mode="any")
def overview(): def overview():
extension = core() extension = core()
conn = db() conn = db()
@@ -72,12 +72,12 @@ def overview():
min_passphrase=transfer.MIN_PASSPHRASE_LENGTH, min_passphrase=transfer.MIN_PASSPHRASE_LENGTH,
preview_ttl=transfer.PREVIEW_TTL_MINUTES, preview_ttl=transfer.PREVIEW_TTL_MINUTES,
license_hint=license_hint, license_hint=license_hint,
can_secrets=current_user.has("backup.secrets"), can_secrets=current_user.has("backup_export.secrets"),
) )
@bp.post("/export") @bp.post("/export")
@permission_required("backup.export") @permission_required("backup_export.export")
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS). # Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
def export(): def export():
extension = core() extension = core()
@@ -96,7 +96,7 @@ def export():
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
include_secrets = ( include_secrets = (
request.form.get("include_secrets") == "1" and current_user.has("backup.secrets") request.form.get("include_secrets") == "1" and current_user.has("backup_export.secrets")
) )
passphrase = request.form.get("passphrase") or "" passphrase = request.form.get("passphrase") or ""
repeat = request.form.get("passphrase_repeat") or "" repeat = request.form.get("passphrase_repeat") or ""
@@ -130,7 +130,7 @@ def export():
@bp.post("/import/preview") @bp.post("/import/preview")
@permission_required("backup.execute") @permission_required("backup_import.execute")
@reauth_required @reauth_required
def import_preview(): def import_preview():
conn = db() conn = db()
@@ -162,7 +162,7 @@ def import_preview():
@bp.post("/import/apply") @bp.post("/import/apply")
@permission_required("backup.execute") @permission_required("backup_import.execute")
@reauth_required @reauth_required
def import_apply(): def import_apply():
extension = core() extension = core()
@@ -226,7 +226,7 @@ def import_apply():
@bp.post("/import/discard") @bp.post("/import/discard")
@permission_required("backup.execute") @permission_required("backup_import.execute")
def import_discard(): def import_discard():
token = session.pop(PREVIEW_SESSION_KEY, "") token = session.pop(PREVIEW_SESSION_KEY, "")
if token: if token:
+2 -2
View File
@@ -29,7 +29,7 @@ def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str
@bp.get("/") @bp.get("/")
@permission_required("credentials.view") @permission_required("credentials.read")
def overview(): def overview():
conn = db() conn = db()
return render_template( return render_template(
@@ -62,7 +62,7 @@ def create():
@bp.post("/<int:credential_id>") @bp.post("/<int:credential_id>")
@permission_required("credentials.edit") @permission_required("credentials.write")
def update(credential_id: int): def update(credential_id: int):
try: try:
with transaction() as conn: with transaction() as conn:
+6 -2
View File
@@ -43,7 +43,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
counts = monitor.dashboard_counts(conn) counts = monitor.dashboard_counts(conn)
rows = inventory.list_devices(conn, only_active=not authenticated) rows = inventory.list_devices(conn, only_active=not authenticated)
detailed = authenticated and current_user.has("devices.view") detailed = authenticated and current_user.has("devices.read")
tiles: list[dict[str, Any]] = [] tiles: list[dict[str, Any]] = []
for row in rows: for row in rows:
entry: dict[str, Any] = { entry: dict[str, Any] = {
@@ -67,8 +67,12 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
"credential_category": str(row["credential_category"] or ""), "credential_category": str(row["credential_category"] or ""),
# Nicht nur PoE: ein Geraet kann auch ueber SSH oder RPC # Nicht nur PoE: ein Geraet kann auch ueber SSH oder RPC
# neu startbar sein. Die Kachel zeigt den bevorzugten Weg. # neu startbar sein. Die Kachel zeigt den bevorzugten Weg.
# Mit Rechtefilter: die Kachel zeigt nur Wege, die
# dieser Benutzer auch ausloesen darf.
"restart_methods": restart.available( "restart_methods": restart.available(
row, maintenance_active=_maintenance_active() row,
maintenance_active=_maintenance_active(),
darf=current_user.has,
), ),
} }
) )
+17 -9
View File
@@ -60,7 +60,7 @@ def _form_values() -> dict[str, Any]:
@bp.get("/") @bp.get("/")
@permission_required("devices.view") @permission_required("devices.read")
def overview(): def overview():
conn = db() conn = db()
devices = inventory.list_devices(conn) devices = inventory.list_devices(conn)
@@ -138,7 +138,7 @@ def create():
@bp.post("/<int:device_id>") @bp.post("/<int:device_id>")
@permission_required("devices.edit") @permission_required("devices.write")
def update(device_id: int): def update(device_id: int):
try: try:
with transaction() as conn: with transaction() as conn:
@@ -162,7 +162,7 @@ def update(device_id: int):
@bp.post("/<int:device_id>/active") @bp.post("/<int:device_id>/active")
@permission_required("devices.edit") @permission_required("devices.write")
def toggle_active(device_id: int): def toggle_active(device_id: int):
active = request.form.get("active") == "1" active = request.form.get("active") == "1"
with transaction() as conn: with transaction() as conn:
@@ -194,7 +194,12 @@ def delete(device_id: int):
@bp.post("/<int:device_id>/restart") @bp.post("/<int:device_id>/restart")
@permission_required("devices.execute") # Die Seite darf sehen, wer Clients sehen darf; welcher Weg offensteht,
# entscheidet das Recht des Weges -- geprueft in restart.resolve() ueber
# "darf". Ein Weg, den der Benutzer nicht ausloesen darf, existiert fuer ihn
# nicht, und die Antwort ist dieselbe wie bei einem technisch unmoeglichen:
# "dieser Weg steht nicht zur Verfuegung".
@permission_required("devices.read")
def restart_device(device_id: int): def restart_device(device_id: int):
"""Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde. """Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde.
@@ -214,9 +219,12 @@ def restart_device(device_id: int):
dict(device), dict(device),
(request.form.get("method") or "").strip(), (request.form.get("method") or "").strip(),
maintenance_active=maintenance_active, maintenance_active=maintenance_active,
darf=current_user.has,
) )
if method is None: if method is None:
options = restart.available(dict(device), maintenance_active=maintenance_active) options = restart.available(
dict(device), maintenance_active=maintenance_active, darf=current_user.has
)
if options: if options:
flash( flash(
"Der gewählte Weg steht für dieses Gerät nicht zur Verfügung. " "Der gewählte Weg steht für dieses Gerät nicht zur Verfügung. "
@@ -326,7 +334,7 @@ def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # typ
@bp.get("/<int:device_id>") @bp.get("/<int:device_id>")
@permission_required("devices.view") @permission_required("devices.read")
def detail(device_id: int): def detail(device_id: int):
conn = db() conn = db()
device = inventory.get_device(conn, device_id) device = inventory.get_device(conn, device_id)
@@ -361,7 +369,7 @@ def detail(device_id: int):
@bp.post("/<int:device_id>/host-key/probe") @bp.post("/<int:device_id>/host-key/probe")
@permission_required("devices.edit") @permission_required("devices.write")
def probe_host_key(device_id: int): def probe_host_key(device_id: int):
"""Liest den Host-Schluessel des Clients, ohne sich anzumelden. """Liest den Host-Schluessel des Clients, ohne sich anzumelden.
@@ -391,7 +399,7 @@ def probe_host_key(device_id: int):
@bp.post("/<int:device_id>/host-key/trust") @bp.post("/<int:device_id>/host-key/trust")
@permission_required("devices.edit") @permission_required("devices.write")
@reauth_required @reauth_required
def trust_host_key(device_id: int): def trust_host_key(device_id: int):
conn = db() conn = db()
@@ -425,7 +433,7 @@ def trust_host_key(device_id: int):
@bp.post("/<int:device_id>/host-key/forget") @bp.post("/<int:device_id>/host-key/forget")
@permission_required("devices.edit") @permission_required("devices.write")
def forget_host_key(device_id: int): def forget_host_key(device_id: int):
conn = db() conn = db()
device = inventory.get_device(conn, device_id) device = inventory.get_device(conn, device_id)
+14 -13
View File
@@ -21,6 +21,7 @@ from tesm_core.db import Database
from tesm_core.extension import core, db, transaction from tesm_core.extension import core, db, transaction
from ..services import kea from ..services import kea
from ..permissions import DHCP_PAGE
bp = Blueprint("dhcp", __name__, url_prefix="/dhcp") bp = Blueprint("dhcp", __name__, url_prefix="/dhcp")
@@ -53,7 +54,7 @@ def _settings_values() -> dict[str, Any]:
@bp.get("/") @bp.get("/")
@permission_required("dhcp.view", "dhcp.edit", "dhcp.execute", mode="any") @permission_required(*DHCP_PAGE, mode="any")
def overview(): def overview():
extension = core() extension = core()
conn = db() conn = db()
@@ -106,7 +107,7 @@ def overview():
@bp.post("/settings") @bp.post("/settings")
@permission_required("dhcp.edit") @permission_required("dhcp_subnets.write")
def save_settings(): def save_settings():
extension = core() extension = core()
conn = db() conn = db()
@@ -123,7 +124,7 @@ def save_settings():
@bp.post("/subnet") @bp.post("/subnet")
@permission_required("dhcp.edit") @permission_required("dhcp_subnets.create")
def add_subnet(): def add_subnet():
extension = core() extension = core()
conn = db() conn = db()
@@ -159,7 +160,7 @@ def add_subnet():
@bp.post("/subnet/<int:subnet_id>") @bp.post("/subnet/<int:subnet_id>")
@permission_required("dhcp.edit") @permission_required("dhcp_subnets.write")
def update_subnet(subnet_id: int): def update_subnet(subnet_id: int):
extension = core() extension = core()
conn = db() conn = db()
@@ -217,7 +218,7 @@ def update_subnet(subnet_id: int):
@bp.post("/subnet/<int:subnet_id>/delete") @bp.post("/subnet/<int:subnet_id>/delete")
@permission_required("dhcp.edit") @permission_required("dhcp_subnets.delete")
def delete_subnet(subnet_id: int): def delete_subnet(subnet_id: int):
with transaction() as conn: with transaction() as conn:
conn.execute("DELETE FROM dhcp_subnets WHERE id=?", (subnet_id,)) conn.execute("DELETE FROM dhcp_subnets WHERE id=?", (subnet_id,))
@@ -232,7 +233,7 @@ def delete_subnet(subnet_id: int):
@bp.post("/option/value") @bp.post("/option/value")
@permission_required("dhcp.edit") @permission_required("dhcp_options.write")
def set_option_value(): def set_option_value():
conn = db() conn = db()
option_id = int(request.form.get("option_id") or 0) option_id = int(request.form.get("option_id") or 0)
@@ -271,7 +272,7 @@ def _device_option_values(conn) -> dict[int, list[dict[str, Any]]]: # type: ign
@bp.post("/option/device") @bp.post("/option/device")
@permission_required("dhcp.edit") @permission_required("dhcp_options.write")
def set_device_option_value(): def set_device_option_value():
"""Setzt eine Option fuer ein einzelnes Geraet. """Setzt eine Option fuer ein einzelnes Geraet.
@@ -319,7 +320,7 @@ def set_device_option_value():
@bp.post("/option/new") @bp.post("/option/new")
@permission_required("dhcp.edit") @permission_required("dhcp_options.write")
def add_custom_option(): def add_custom_option():
conn = db() conn = db()
try: try:
@@ -350,7 +351,7 @@ def add_custom_option():
@bp.post("/option/<int:option_id>/delete") @bp.post("/option/<int:option_id>/delete")
@permission_required("dhcp.edit") @permission_required("dhcp_options.write")
def delete_custom_option(option_id: int): def delete_custom_option(option_id: int):
conn = db() conn = db()
option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,)) option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,))
@@ -375,7 +376,7 @@ def delete_custom_option(option_id: int):
@bp.post("/reservation") @bp.post("/reservation")
@permission_required("dhcp.edit") @permission_required("dhcp_reservations.create")
def add_reservation(): def add_reservation():
conn = db() conn = db()
mac = kea.normalize_mac(request.form.get("mac", "")) mac = kea.normalize_mac(request.form.get("mac", ""))
@@ -447,7 +448,7 @@ def add_reservation():
@bp.post("/reservation/<int:reservation_id>/delete") @bp.post("/reservation/<int:reservation_id>/delete")
@permission_required("dhcp.edit") @permission_required("dhcp_reservations.delete")
def delete_reservation(reservation_id: int): def delete_reservation(reservation_id: int):
with transaction() as conn: with transaction() as conn:
conn.execute("DELETE FROM dhcp_reservations WHERE id=?", (reservation_id,)) conn.execute("DELETE FROM dhcp_reservations WHERE id=?", (reservation_id,))
@@ -515,13 +516,13 @@ def _write_and_redirect():
@bp.post("/write") @bp.post("/write")
@permission_required("dhcp.edit") @permission_required("dhcp_config.execute")
def write_config(): def write_config():
return _write_and_redirect() return _write_and_redirect()
@bp.post("/service/<action>") @bp.post("/service/<action>")
@permission_required("dhcp.execute") @permission_required("dhcp_service.execute")
def service_action(action: str): def service_action(action: str):
allowed = {"install", "start", "stop"} allowed = {"install", "start", "stop"}
if action not in allowed: if action not in allowed:
+16 -11
View File
@@ -84,7 +84,7 @@ def _mount_or_404(token: str) -> service.Mount:
@bp.get("/") @bp.get("/")
@permission_required("fileshare.view") @permission_required("fileshare.read")
def browse(): def browse():
conn = db() conn = db()
mounts = service.doppelte_zusammenfassen( mounts = service.doppelte_zusammenfassen(
@@ -148,7 +148,7 @@ def download(token: str):
@bp.get("/<token>/preview") @bp.get("/<token>/preview")
@permission_required("fileshare.view") @permission_required("fileshare.read")
def preview(token: str): def preview(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
relative = request.args.get("path") or "" relative = request.args.get("path") or ""
@@ -221,7 +221,7 @@ def upload(token: str):
@bp.get("/<token>/signature") @bp.get("/<token>/signature")
@permission_required("fileshare.view") @permission_required("fileshare.read")
def signature_state(token: str): def signature_state(token: str):
"""Passt die Datei noch zu ihrem Eintrag im Aenderungsprotokoll? """Passt die Datei noch zu ihrem Eintrag im Aenderungsprotokoll?
@@ -237,7 +237,7 @@ def signature_state(token: str):
@bp.get("/<token>/folders") @bp.get("/<token>/folders")
@permission_required("fileshare.view") @permission_required("fileshare.read")
def folders(token: str): def folders(token: str):
"""Die Ordner **dieser** Freigabe als Vorschlagsliste. """Die Ordner **dieser** Freigabe als Vorschlagsliste.
@@ -299,7 +299,7 @@ def save_form(token: str):
# Formularfelder, ist sie weiter veraenderbar -- dafuer braucht es # Formularfelder, ist sie weiter veraenderbar -- dafuer braucht es
# "PDF-Formulare: Bearbeiten". Ein umgebogenes Feld im Browser hilft damit # "PDF-Formulare: Bearbeiten". Ein umgebogenes Feld im Browser hilft damit
# niemandem weiter. # niemandem weiter.
if service.hat_formularfelder(daten) and not current_user.has("fileshare_pdf.edit"): if service.hat_formularfelder(daten) and not current_user.has("fileshare_pdf.write"):
return { return {
"error": "Sie dürfen ausgefüllte Formulare nur abgeschlossen speichern " "error": "Sie dürfen ausgefüllte Formulare nur abgeschlossen speichern "
"(Felder gesperrt). Bitte im Auswahlfeld 'Abgeschlossen' wählen." "(Felder gesperrt). Bitte im Auswahlfeld 'Abgeschlossen' wählen."
@@ -351,6 +351,11 @@ def save_form(token: str):
wohin = ziel.relative_to(ziel_mount.path.resolve()).as_posix() wohin = ziel.relative_to(ziel_mount.path.resolve()).as_posix()
pruefsumme = hashlib.sha256(daten).hexdigest() pruefsumme = hashlib.sha256(daten).hexdigest()
unterschriften = _unterschriften(request.form.get("unterschriften") or "") unterschriften = _unterschriften(request.form.get("unterschriften") or "")
# Unterschreiben ist kein Aendern: am Ende steht eine Fassung, die niemand
# mehr bearbeiten kann, mit Namen im Dokument und Pruefsumme im Protokoll.
# Wer Dateien ablegen darf, darf das nicht automatisch.
if unterschriften and not current_user.has("fileshare_signature.execute"):
return {"error": "Zum Unterschreiben fehlt die Berechtigung."}, 403
# Zwei Angaben, die man nicht verwechseln darf, und deshalb stehen beide # Zwei Angaben, die man nicht verwechseln darf, und deshalb stehen beide
# da: der Name **in** der Unterschrift ist eine Angabe des # da: der Name **in** der Unterschrift ist eine Angabe des
@@ -412,7 +417,7 @@ def make_directory(token: str):
@bp.post("/<token>/rename") @bp.post("/<token>/rename")
@permission_required("fileshare.edit") @permission_required("fileshare.write")
def rename(token: str): def rename(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
relative = request.form.get("path") or "" relative = request.form.get("path") or ""
@@ -446,7 +451,7 @@ def delete(token: str):
@bp.post("/<token>/release") @bp.post("/<token>/release")
@permission_required("fileshare.view") @permission_required("fileshare.read")
def release(token: str): def release(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
with transaction() as conn: with transaction() as conn:
@@ -462,7 +467,7 @@ def release(token: str):
@bp.get("/mappings") @bp.get("/mappings")
@permission_required("fileshare.edit") @permission_required("fileshare_mappings.read")
def mappings(): def mappings():
from tesm_core.auth import directory from tesm_core.auth import directory
@@ -476,7 +481,7 @@ def mappings():
@bp.post("/mappings") @bp.post("/mappings")
@permission_required("fileshare.edit") @permission_required("fileshare_mappings.create")
def mapping_add(): def mapping_add():
try: try:
with transaction() as conn: with transaction() as conn:
@@ -506,7 +511,7 @@ def mapping_add():
@bp.post("/mappings/<int:mapping_id>") @bp.post("/mappings/<int:mapping_id>")
@permission_required("fileshare.edit") @permission_required("fileshare_mappings.write")
def mapping_update(mapping_id: int): def mapping_update(mapping_id: int):
try: try:
with transaction() as conn: with transaction() as conn:
@@ -536,7 +541,7 @@ def mapping_update(mapping_id: int):
@bp.post("/mappings/<int:mapping_id>/delete") @bp.post("/mappings/<int:mapping_id>/delete")
@permission_required("fileshare.edit") @permission_required("fileshare_mappings.delete")
def mapping_delete(mapping_id: int): def mapping_delete(mapping_id: int):
with transaction() as conn: with transaction() as conn:
entfernt = service.get_mapping(conn, mapping_id) entfernt = service.get_mapping(conn, mapping_id)
+11 -7
View File
@@ -36,8 +36,12 @@ bp = Blueprint("hostkeys", __name__, url_prefix="/monitoring/host-keys")
KIND_DEVICE = "client" KIND_DEVICE = "client"
KIND_SWITCH = "switch" KIND_SWITCH = "switch"
#: Welches Recht welche Art von Ziel freigibt. #: Freigeben ist eine eigene Zeile: es war das Recht fuer Stammdaten
EDIT_RIGHT = {KIND_DEVICE: "devices.edit", KIND_SWITCH: "switches.edit"} #: ("Gerät ändern"), obwohl es der Vorgang ist, mit dem man bestaetigt, dass
#: ein Geraet wirklich das ist, fuer das es sich ausgibt. Die Art des Ziels
#: entscheidet nicht mehr mit -- Schluessel ist Schluessel.
FREIGABE_RECHT = "hostkeys.execute"
EDIT_RIGHT = {KIND_DEVICE: FREIGABE_RECHT, KIND_SWITCH: FREIGABE_RECHT}
def _audit(action: str, target: str, detail: str = "", severity: str = "info") -> None: def _audit(action: str, target: str, detail: str = "", severity: str = "info") -> None:
@@ -112,7 +116,7 @@ def _orphans(conn: Any, targets: list[dict[str, Any]]) -> list[dict[str, Any]]:
@bp.get("/") @bp.get("/")
@permission_required("devices.view", "switches.view", mode="any") @permission_required("hostkeys.read")
def overview(): def overview():
conn = db() conn = db()
targets = _targets(conn) targets = _targets(conn)
@@ -143,7 +147,7 @@ def _require(kind: str) -> None:
@bp.post("/<kind>/<int:target_id>/probe") @bp.post("/<kind>/<int:target_id>/probe")
@permission_required("devices.view", "switches.view", mode="any") @permission_required("hostkeys.read")
def probe(kind: str, target_id: int): def probe(kind: str, target_id: int):
conn = db() conn = db()
_require(kind) _require(kind)
@@ -170,7 +174,7 @@ def probe(kind: str, target_id: int):
@bp.post("/<kind>/<int:target_id>/trust") @bp.post("/<kind>/<int:target_id>/trust")
@permission_required("devices.view", "switches.view", mode="any") @permission_required("hostkeys.read")
@reauth_required @reauth_required
def trust(kind: str, target_id: int): def trust(kind: str, target_id: int):
conn = db() conn = db()
@@ -204,7 +208,7 @@ def trust(kind: str, target_id: int):
@bp.post("/<kind>/<int:target_id>/forget") @bp.post("/<kind>/<int:target_id>/forget")
@permission_required("devices.view", "switches.view", mode="any") @permission_required("hostkeys.read")
def forget(kind: str, target_id: int): def forget(kind: str, target_id: int):
conn = db() conn = db()
_require(kind) _require(kind)
@@ -223,7 +227,7 @@ def forget(kind: str, target_id: int):
@bp.post("/orphaned/forget") @bp.post("/orphaned/forget")
@permission_required("devices.edit", "switches.edit", mode="any") @permission_required("hostkeys.execute")
def forget_orphan(): def forget_orphan():
"""Entfernt eine Freigabe, zu der es kein Ziel mehr gibt.""" """Entfernt eine Freigabe, zu der es kein Ziel mehr gibt."""
host = (request.form.get("host") or "").strip() host = (request.form.get("host") or "").strip()
+7 -7
View File
@@ -43,7 +43,7 @@ def _audit(action: str, target: str = "", detail: str = "", severity: str = "inf
@bp.get("/") @bp.get("/")
@permission_required("settings_license.view") @permission_required("settings_license.read")
def overview(): def overview():
extension = core() extension = core()
conn = db() conn = db()
@@ -70,7 +70,7 @@ def overview():
@bp.post("/install") @bp.post("/install")
@permission_required("settings_license.edit") @permission_required("settings_license.write")
def upload(): def upload():
extension = core() extension = core()
manager = extension.license manager = extension.license
@@ -111,13 +111,13 @@ def upload():
@bp.post("/activate") @bp.post("/activate")
@permission_required("settings_license.edit") @permission_required("settings_license.write")
def activate(): def activate():
return _handshake("activate") return _handshake("activate")
@bp.post("/deactivate") @bp.post("/deactivate")
@permission_required("settings_license.edit") @permission_required("settings_license.write")
@reauth_required @reauth_required
def deactivate(): def deactivate():
"""Gibt die Lizenz frei, damit sie auf einer anderen Maschine genutzt werden kann. """Gibt die Lizenz frei, damit sie auf einer anderen Maschine genutzt werden kann.
@@ -129,7 +129,7 @@ def deactivate():
@bp.post("/heartbeat") @bp.post("/heartbeat")
@permission_required("settings_license.edit") @permission_required("settings_license.write")
def heartbeat(): def heartbeat():
return _handshake("heartbeat") return _handshake("heartbeat")
@@ -159,7 +159,7 @@ def _handshake(action: str):
@bp.post("/offline-response") @bp.post("/offline-response")
@permission_required("settings_license.edit") @permission_required("settings_license.write")
def offline_response(): def offline_response():
extension = core() extension = core()
manager = extension.license manager = extension.license
@@ -178,7 +178,7 @@ def offline_response():
@bp.post("/server-certificate") @bp.post("/server-certificate")
@permission_required("settings_license.edit") @permission_required("settings_license.write")
def server_certificate(): def server_certificate():
"""Hinterlegt das Zertifikat des Lizenzservers. """Hinterlegt das Zertifikat des Lizenzservers.
+2 -2
View File
@@ -19,7 +19,7 @@ MAX_TAIL_LINES = 2000
@bp.get("/dhcp") @bp.get("/dhcp")
@permission_required("logs_dhcp.view") @permission_required("logs_dhcp.read")
def dhcp(): def dhcp():
extension = core() extension = core()
if extension.license is not None and not extension.license.module_active("dhcp"): if extension.license is not None and not extension.license.module_active("dhcp"):
@@ -39,7 +39,7 @@ def dhcp():
@bp.get("/restarts") @bp.get("/restarts")
@permission_required("devices.view") @permission_required("devices.read")
def restarts(): def restarts():
"""Alle Neustarts -- ueber den PoE-Port und per Befehl aus der Wartung. """Alle Neustarts -- ueber den PoE-Port und per Befehl aus der Wartung.
+8 -7
View File
@@ -23,6 +23,7 @@ from tesm_core.auth.service import client_ip
from tesm_core.db import Database from tesm_core.db import Database
from tesm_core.extension import core, db, transaction from tesm_core.extension import core, db, transaction
from ..permissions import SERVICING_PAGE
from ..services import inventory, maintenance as service, ssh, windowsops, monitor from ..services import inventory, maintenance as service, ssh, windowsops, monitor
bp = Blueprint("maintenance", __name__, url_prefix="/maintenance") bp = Blueprint("maintenance", __name__, url_prefix="/maintenance")
@@ -75,7 +76,7 @@ def _query_devices(conn) -> list[dict]: # type: ignore[no-untyped-def]
@bp.get("/") @bp.get("/")
@permission_required("maintenance.view", "maintenance.execute", mode="any") @permission_required(*SERVICING_PAGE, mode="any")
def overview(): def overview():
conn = db() conn = db()
return render_template( return render_template(
@@ -106,7 +107,7 @@ def _windows_tool_missing(conn) -> bool: # type: ignore[no-untyped-def]
@bp.post("/install-package") @bp.post("/install-package")
@permission_required("maintenance.execute") @permission_required("servicing_tool.execute")
def install_windows_tool(): def install_windows_tool():
"""Installiert das Paket, das der Fehlermeldung nach fehlt. """Installiert das Paket, das der Fehlermeldung nach fehlt.
@@ -139,7 +140,7 @@ def install_windows_tool():
@bp.get("/status") @bp.get("/status")
@permission_required("maintenance.view", "maintenance.execute", mode="any") @permission_required(*SERVICING_PAGE, mode="any")
def status(): def status():
"""Zustand der Auftraege -- aus der Datenbank, nicht aus dem Prozessspeicher.""" """Zustand der Auftraege -- aus der Datenbank, nicht aus dem Prozessspeicher."""
conn = db() conn = db()
@@ -166,7 +167,7 @@ def status():
@bp.get("/job/<job_id>") @bp.get("/job/<job_id>")
@permission_required("maintenance.view", "maintenance.execute", mode="any") @permission_required(*SERVICING_PAGE, mode="any")
def job_detail(job_id: str): def job_detail(job_id: str):
conn = db() conn = db()
job = service.get_job(conn, job_id) job = service.get_job(conn, job_id)
@@ -176,7 +177,7 @@ def job_detail(job_id: str):
@bp.get("/job/<job_id>/output") @bp.get("/job/<job_id>/output")
@permission_required("maintenance.view", "maintenance.execute", mode="any") @permission_required(*SERVICING_PAGE, mode="any")
@fragment @fragment
def job_output(job_id: str): def job_output(job_id: str):
conn = db() conn = db()
@@ -277,14 +278,14 @@ def _start(kind: str) -> None:
@bp.post("/update") @bp.post("/update")
@permission_required("maintenance.execute") @permission_required("servicing_update.execute")
def start_update(): def start_update():
_start(service.JOB_KIND_UPDATE) _start(service.JOB_KIND_UPDATE)
return redirect(url_for("maintenance.overview")) return redirect(url_for("maintenance.overview"))
@bp.post("/restart") @bp.post("/restart")
@permission_required("maintenance.execute") @permission_required("servicing_reboot.execute")
def start_reboot(): def start_reboot():
_start(service.JOB_KIND_REBOOT) _start(service.JOB_KIND_REBOOT)
return redirect(url_for("maintenance.overview")) return redirect(url_for("maintenance.overview"))
+2 -2
View File
@@ -65,7 +65,7 @@ def _timezone_choices() -> list[str]:
@bp.get("/system") @bp.get("/system")
@permission_required("settings_system.view") @permission_required("settings_system.read")
def system(): def system():
extension = core() extension = core()
conn = db() conn = db()
@@ -103,7 +103,7 @@ def system():
@bp.post("/system") @bp.post("/system")
@permission_required("settings_system.edit") @permission_required("settings_system.write")
def system_save(): def system_save():
extension = core() extension = core()
conn = db() conn = db()
+7 -7
View File
@@ -47,7 +47,7 @@ def _form_values() -> dict[str, Any]:
@bp.get("/") @bp.get("/")
@permission_required("switches.view") @permission_required("switches.read")
def overview(): def overview():
conn = db() conn = db()
return render_template( return render_template(
@@ -79,7 +79,7 @@ def create():
@bp.post("/<int:switch_id>") @bp.post("/<int:switch_id>")
@permission_required("switches.edit") @permission_required("switches.write")
def update(switch_id: int): def update(switch_id: int):
try: try:
with transaction() as conn: with transaction() as conn:
@@ -115,7 +115,7 @@ def delete(switch_id: int):
@bp.get("/<int:switch_id>") @bp.get("/<int:switch_id>")
@permission_required("switches.view") @permission_required("switches.read")
def detail(switch_id: int): def detail(switch_id: int):
conn = db() conn = db()
switch = inventory.get_switch(conn, switch_id) switch = inventory.get_switch(conn, switch_id)
@@ -152,7 +152,7 @@ def detail(switch_id: int):
@bp.post("/<int:switch_id>/host-key/probe") @bp.post("/<int:switch_id>/host-key/probe")
@permission_required("switches.edit") @permission_required("switches.write")
def probe_host_key(switch_id: int): def probe_host_key(switch_id: int):
conn = db() conn = db()
switch = inventory.get_switch(conn, switch_id) switch = inventory.get_switch(conn, switch_id)
@@ -177,7 +177,7 @@ def probe_host_key(switch_id: int):
@bp.post("/<int:switch_id>/host-key/trust") @bp.post("/<int:switch_id>/host-key/trust")
@permission_required("switches.edit") @permission_required("switches.write")
@reauth_required @reauth_required
def trust_host_key(switch_id: int): def trust_host_key(switch_id: int):
"""Gibt einen Host-Schluessel ausdruecklich frei. """Gibt einen Host-Schluessel ausdruecklich frei.
@@ -216,7 +216,7 @@ def trust_host_key(switch_id: int):
@bp.post("/<int:switch_id>/host-key/forget") @bp.post("/<int:switch_id>/host-key/forget")
@permission_required("switches.edit") @permission_required("switches.write")
def forget_host_key(switch_id: int): def forget_host_key(switch_id: int):
conn = db() conn = db()
switch = inventory.get_switch(conn, switch_id) switch = inventory.get_switch(conn, switch_id)
@@ -235,7 +235,7 @@ def forget_host_key(switch_id: int):
@bp.post("/<int:switch_id>/command") @bp.post("/<int:switch_id>/command")
@permission_required("switches.execute") @permission_required("switch_console.execute")
@reauth_required @reauth_required
def run_command(switch_id: int): def run_command(switch_id: int):
"""Fuehrt ein Kommando aus der Positivliste aus. """Fuehrt ein Kommando aus der Positivliste aus.
+29 -4
View File
@@ -12,7 +12,7 @@ Zwei Ziele, ein Ablauf:
* **Switch** -- ``switches.execute``. Interaktive ArubaOS-Sitzung ueber * **Switch** -- ``switches.execute``. Interaktive ArubaOS-Sitzung ueber
:class:`~tesm.services.switchcli.SwitchCli`. :class:`~tesm.services.switchcli.SwitchCli`.
* **Client** -- ``maintenance.execute`` und das lizenzpflichtige Modul * **Client** -- ``device_console.execute`` und das lizenzpflichtige Modul
``maintenance``. Ein Kommando je ``exec``-Kanal ueber ``maintenance``. Ein Kommando je ``exec``-Kanal ueber
:class:`~tesm.services.shellcli.ShellSession`. :class:`~tesm.services.shellcli.ShellSession`.
@@ -167,7 +167,7 @@ def _render_console(target: Target): # type: ignore[no-untyped-def]
@bp.get("/monitoring/switches/<int:switch_id>/console") @bp.get("/monitoring/switches/<int:switch_id>/console")
@permission_required("switches.execute") @permission_required("switch_console.execute")
@reauth_required @reauth_required
def switch_console(switch_id: int): def switch_console(switch_id: int):
target = _load_target(db(), KIND_SWITCH, switch_id) target = _load_target(db(), KIND_SWITCH, switch_id)
@@ -177,7 +177,7 @@ def switch_console(switch_id: int):
@bp.get("/monitoring/clients/<int:device_id>/console") @bp.get("/monitoring/clients/<int:device_id>/console")
@permission_required("maintenance.execute") @permission_required("device_console.execute")
@reauth_required @reauth_required
def device_console(device_id: int): def device_console(device_id: int):
if not _client_module_active(): if not _client_module_active():
@@ -211,7 +211,17 @@ def init_app(app) -> None: # type: ignore[no-untyped-def]
def _send(ws, kind: str, text: str) -> None: # type: ignore[no-untyped-def] def _send(ws, kind: str, text: str) -> None: # type: ignore[no-untyped-def]
"""Schickt eine Nachricht. Ein geschlossener Socket ist kein Fehler.
Bricht der Browser die Sitzung ab, schlaegt das Senden fehl -- und die
Ausnahme daraus verdeckte bisher den eigentlichen Grund, warum die
Sitzung endete: im Protokoll stand ein Stapelverlauf aus ``ws.send``,
nicht die Meldung des Switches.
"""
try:
ws.send(json.dumps({"type": kind, "data": text})) ws.send(json.dumps({"type": kind, "data": text}))
except Exception: # noqa: BLE001 - der Browser ist weg, das ist keine Stoerung
logger.debug("Konsolennachricht nicht mehr zustellbar (%s)", kind)
def _transcript_name(label: str) -> str: def _transcript_name(label: str) -> str:
@@ -220,12 +230,23 @@ def _transcript_name(label: str) -> str:
def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-untyped-def] def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-untyped-def]
"""Eine Konsolensitzung, von der Berechtigung bis zum Abmelden.
Jede Stufe schickt eine Zeile in die Oberflaeche. Das ist keine Zierde:
eine Konsole, die nichts sagt, laesst offen, ob die Berechtigung fehlt,
die Zugangsdaten, die Verbindung oder die Antwort des Geraets -- und genau
diese Frage stand im Betrieb im Raum. Sichtbar sind die Zeilen erst
nuetzlich, wenn sie **vor** dem Warten kommen, nicht danach.
"""
extension = core() extension = core()
conn = db() conn = db()
_send(ws, "info", "Sitzung wird geöffnet …")
# -- Berechtigungen: dieselben Pruefungen wie in der HTTP-Route. Ein # -- Berechtigungen: dieselben Pruefungen wie in der HTTP-Route. Ein
# WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich. # WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich.
required = "switches.execute" if kind == KIND_SWITCH else "maintenance.execute" required = (
"switch_console.execute" if kind == KIND_SWITCH else "device_console.execute"
)
if not current_user.is_authenticated or not current_user.has(required): if not current_user.is_authenticated or not current_user.has(required):
_send(ws, "error", "Keine Berechtigung für diese Konsole.") _send(ws, "error", "Keine Berechtigung für diese Konsole.")
return return
@@ -253,6 +274,7 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
/ f"{stamp}-{_transcript_name(current_user.username)}-{_transcript_name(target.label)}.log" / f"{stamp}-{_transcript_name(current_user.username)}-{_transcript_name(target.label)}.log"
) )
_send(ws, "info", f"Ziel: {target.label} ({target.host}:{target.port})")
try: try:
username, password = inventory.credential_secret( username, password = inventory.credential_secret(
conn, extension.keystore, target.credential_id conn, extension.keystore, target.credential_id
@@ -260,6 +282,7 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
except inventory.ValidationError as exc: except inventory.ValidationError as exc:
_send(ws, "error", str(exc)) _send(ws, "error", str(exc))
return return
_send(ws, "info", f"Anmeldung als {username} …")
with extension.database.transaction(conn): with extension.database.transaction(conn):
audit.log( audit.log(
@@ -341,6 +364,7 @@ def _run_switch_session( # type: ignore[no-untyped-def]
ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
) -> int: ) -> int:
commands = 0 commands = 0
_send(ws, "info", "Warte auf die Eingabeaufforderung des Switches …")
with switchcli.SwitchCli( with switchcli.SwitchCli(
conn, host=target.host, port=target.port, username=username, password=password conn, host=target.host, port=target.port, username=username, password=password
) as cli: ) as cli:
@@ -373,6 +397,7 @@ def _run_client_session( # type: ignore[no-untyped-def]
ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
) -> int: ) -> int:
commands = 0 commands = 0
_send(ws, "info", "Verbindung wird aufgebaut …")
with shellcli.ShellSession( with shellcli.ShellSession(
conn, host=target.host, port=target.port, username=username, password=password conn, host=target.host, port=target.port, username=username, password=password
) as session: ) as session:
+124
View File
@@ -216,6 +216,114 @@ def cmd_web_setup(args: argparse.Namespace) -> int:
return run_web_setup(app, extension, args) return run_web_setup(app, extension, args)
def cmd_switch_test(args: argparse.Namespace) -> int:
"""Prueft Stufe fuer Stufe, wo eine Switch-Sitzung haengt.
Warum ein eigener Befehl und nicht die Konsole im Browser: dort liegen
zwischen dem Switch und dem Auge ein WebSocket, ein Reverse Proxy und
JavaScript. Wenn nichts ankommt, ist die erste Frage, *welche* dieser
Schichten schweigt. Hier gibt es keine davon -- und jede Stufe sagt, was
sie bekommen hat, notfalls als rohe Bytes.
Liest nur, ausser mit ``--zyklus``: dann wird der Port wirklich
geschaltet.
"""
from .services import inventory, switchcli
from .services.ssh import SshError
from tesm_core.db import Database
from tesm_core.extension import core
app = create_app()
erweiterung = core(app)
def sagen(stufe: str, text: str = "") -> None:
print(f" {stufe:<28} {text}".rstrip())
with app.app_context():
with erweiterung.database.session() as conn:
zeile = Database.one(
conn,
"SELECT id, hostname, ip, ssh_port, credential_id FROM switches "
"WHERE (hostname=? COLLATE NOCASE OR ip=?) AND deleted_at IS NULL",
(args.switch, args.switch),
)
if zeile is None:
print(f"Kein Switch mit Name oder Adresse {args.switch!r}.")
print("Vorhanden:")
for andere in Database.all(
conn, "SELECT hostname, ip FROM switches WHERE deleted_at IS NULL"
):
print(f" {andere['hostname']} ({andere['ip']})")
return 2
host = str(zeile["ip"])
ssh_port = int(zeile["ssh_port"] or 22)
print(f"Switch {zeile['hostname']} ({host}:{ssh_port})")
print()
if not zeile["credential_id"]:
sagen("Zugangsdaten", "FEHLT -- dem Switch sind keine zugeordnet")
return 1
try:
benutzer, passwort = inventory.credential_secret(
conn, erweiterung.keystore, int(zeile["credential_id"])
)
except Exception as fehler: # noqa: BLE001
sagen("Zugangsdaten", f"nicht lesbar: {fehler}")
return 1
sagen("Zugangsdaten", f"{benutzer} (Passwort {len(passwort)} Zeichen)")
from .services import ssh as ssh_modul
for record in ssh_modul.all_known_keys(conn, host, ssh_port):
sagen("Freigegebener Schluessel", f"{record.key_type} {record.fingerprint}")
try:
sitzung = switchcli.SwitchCli(
conn,
host=host,
port=ssh_port,
username=benutzer,
password=passwort,
)
with sitzung as cli:
sagen("Anmeldung", "erfolgreich")
erstes = cli.full_transcript
sagen("Erste Antwort", f"{len(erstes)} Zeichen")
print()
print("--- was der Switch nach der Anmeldung geschickt hat ---")
print(erstes if erstes.strip() else "(nichts)")
print("--- Ende ---")
print()
if args.port:
ausgabe = cli.send(
f"show power-over-ethernet {args.port}", tolerate_error=True
)
sagen("Zustandsabfrage", f"{len(ausgabe)} Zeichen")
print("--- show power-over-ethernet ---")
print(ausgabe)
print("--- Ende ---")
sagen("Erkannter Zustand", cli.poe_state(args.port))
if args.zyklus:
if not args.port:
print("Fuer --zyklus braucht es --port.")
return 2
ergebnis = cli.cycle_poe(args.port)
sagen("Portzyklus", "erfolgreich" if ergebnis.ok else ergebnis.error)
for schritt in ergebnis.steps:
sagen("", schritt)
if not ergebnis.ok:
return 1
except SshError as fehler:
sagen("FEHLGESCHLAGEN", str(fehler))
return 1
print()
print("Fertig.")
return 0
def cmd_convert_legacy(args: argparse.Namespace) -> int: def cmd_convert_legacy(args: argparse.Namespace) -> int:
"""Uebersetzt einen Export des alten TESM in das neue Format. """Uebersetzt einen Export des alten TESM in das neue Format.
@@ -418,6 +526,22 @@ def build_parser() -> argparse.ArgumentParser:
check = sub.add_parser("check-now", help="Einen Überwachungsdurchlauf sofort ausführen") check = sub.add_parser("check-now", help="Einen Überwachungsdurchlauf sofort ausführen")
check.set_defaults(func=cmd_check_now) check.set_defaults(func=cmd_check_now)
pruefung = sub.add_parser(
"switch-test",
help="Eine Switch-Sitzung Stufe fuer Stufe pruefen",
description=(
"Meldet sich am Switch an und zeigt, was er schickt -- ohne Browser, "
"ohne WebSocket, ohne Reverse Proxy. Liest nur; mit --zyklus wird der "
"Port wirklich geschaltet."
),
)
pruefung.add_argument("switch", help="Name oder IP des Switches, wie in TESM hinterlegt")
pruefung.add_argument("--port", help="Portbezeichnung, z. B. 1/8 -- fragt den Zustand ab")
pruefung.add_argument(
"--zyklus", action="store_true", help="PoE wirklich aus- und wieder einschalten"
)
pruefung.set_defaults(func=cmd_switch_test)
legacy = sub.add_parser( legacy = sub.add_parser(
"convert-legacy", "convert-legacy",
help="Export des alten TESM in das neue Format übersetzen", help="Export des alten TESM in das neue Format übersetzen",
+26 -24
View File
@@ -13,6 +13,8 @@ from typing import Any
from tesm_core.extension import core from tesm_core.extension import core
from tesm_core.nav import NavItem, NavTree from tesm_core.nav import NavItem, NavTree
from .permissions import DHCP_PAGE, SERVICING_PAGE
def _module(name: str): def _module(name: str):
"""Bedingung: Vollmodul lizenziert?""" """Bedingung: Vollmodul lizenziert?"""
@@ -36,7 +38,7 @@ def _has_fileshare_mounts(user: Any) -> bool:
return False return False
if not _module("fileshare")(user): if not _module("fileshare")(user):
return False return False
if user.has("fileshare.edit"): if user.has("fileshare.write"):
return True return True
from .services import fileshare from .services import fileshare
@@ -56,28 +58,28 @@ NAVIGATION = NavTree(
label="Clients", label="Clients",
icon="cpu", icon="cpu",
endpoint="devices.overview", endpoint="devices.overview",
permissions=("devices.view",), permissions=("devices.read",),
), ),
NavItem( NavItem(
key="switches", key="switches",
label="Switche", label="Switche",
icon="switch", icon="switch",
endpoint="switches.overview", endpoint="switches.overview",
permissions=("switches.view",), permissions=("switches.read",),
), ),
NavItem( NavItem(
key="credentials", key="credentials",
label="Zugangsdaten", label="Zugangsdaten",
icon="key", icon="key",
endpoint="credentials.overview", endpoint="credentials.overview",
permissions=("credentials.view",), permissions=("credentials.read",),
), ),
NavItem( NavItem(
key="hostkeys", key="hostkeys",
label="Host-Schlüssel", label="Host-Schlüssel",
icon="shield", icon="shield",
endpoint="hostkeys.overview", endpoint="hostkeys.overview",
permissions=("devices.view", "switches.view"), permissions=("devices.read", "switches.read"),
), ),
), ),
), ),
@@ -86,7 +88,7 @@ NAVIGATION = NavTree(
label="Wartung", label="Wartung",
icon="wrench", icon="wrench",
endpoint="maintenance.overview", endpoint="maintenance.overview",
permissions=("maintenance.view", "maintenance.execute"), permissions=SERVICING_PAGE,
condition=_module("maintenance"), condition=_module("maintenance"),
), ),
NavItem( NavItem(
@@ -94,7 +96,7 @@ NAVIGATION = NavTree(
label="DHCP-Server", label="DHCP-Server",
icon="network", icon="network",
endpoint="dhcp.overview", endpoint="dhcp.overview",
permissions=("dhcp.view", "dhcp.edit", "dhcp.execute"), permissions=DHCP_PAGE,
condition=_module("dhcp"), condition=_module("dhcp"),
), ),
NavItem( NavItem(
@@ -108,14 +110,14 @@ NAVIGATION = NavTree(
label="Meine Freigaben", label="Meine Freigaben",
icon="folder", icon="folder",
endpoint="fileshare.browse", endpoint="fileshare.browse",
permissions=("fileshare.view",), permissions=("fileshare.read",),
), ),
NavItem( NavItem(
key="fileshare_mappings", key="fileshare_mappings",
label="Zuordnungen", label="Zuordnungen",
icon="link", icon="link",
endpoint="fileshare.mappings", endpoint="fileshare.mappings",
permissions=("fileshare.edit",), permissions=("fileshare.write",),
), ),
), ),
), ),
@@ -129,28 +131,28 @@ NAVIGATION = NavTree(
label="Laufend", label="Laufend",
icon="terminal", icon="terminal",
endpoint="logs.live", endpoint="logs.live",
permissions=("logs_live.view",), permissions=("logs_live.read",),
), ),
NavItem( NavItem(
key="logs_history", key="logs_history",
label="Verlauf", label="Verlauf",
icon="clock", icon="clock",
endpoint="logs.history", endpoint="logs.history",
permissions=("logs_history.view", "logs_history.export"), permissions=("logs_history.read", "logs_history.export"),
), ),
NavItem( NavItem(
key="logs_audit", key="logs_audit",
label="Audit", label="Audit",
icon="history", icon="history",
endpoint="logs.audit_view", endpoint="logs.audit_view",
permissions=("logs_audit.view", "logs_audit.export"), permissions=("logs_audit.read", "logs_audit.export"),
), ),
NavItem( NavItem(
key="logs_dhcp", key="logs_dhcp",
label="DHCP", label="DHCP",
icon="network", icon="network",
endpoint="tesmlogs.dhcp", endpoint="tesmlogs.dhcp",
permissions=("logs_dhcp.view",), permissions=("logs_dhcp.read",),
condition=_module("dhcp"), condition=_module("dhcp"),
), ),
NavItem( NavItem(
@@ -158,7 +160,7 @@ NAVIGATION = NavTree(
label="Neustarts", label="Neustarts",
icon="power", icon="power",
endpoint="tesmlogs.restarts", endpoint="tesmlogs.restarts",
permissions=("devices.view",), permissions=("devices.read",),
), ),
), ),
), ),
@@ -172,42 +174,42 @@ NAVIGATION = NavTree(
label="Benutzer", label="Benutzer",
icon="users", icon="users",
endpoint="admin.users", endpoint="admin.users",
permissions=("users.view",), permissions=("users.read",),
), ),
NavItem( NavItem(
key="groups", key="groups",
label="Gruppen und Rechte", label="Gruppen und Rechte",
icon="shield-users", icon="shield-users",
endpoint="admin.groups", endpoint="admin.groups",
permissions=("groups.view",), permissions=("groups.read",),
), ),
NavItem( NavItem(
key="settings_system", key="settings_system",
label="System", label="System",
icon="sliders", icon="sliders",
endpoint="settings.system", endpoint="settings.system",
permissions=("settings_system.view",), permissions=("settings_system.read",),
), ),
NavItem( NavItem(
key="settings_network", key="settings_network",
label="Netzwerk", label="Netzwerk",
icon="network", icon="network",
endpoint="hostsettings.network", endpoint="hostsettings.network",
permissions=("settings_network.view",), permissions=("settings_network.read",),
), ),
NavItem( NavItem(
key="settings_web", key="settings_web",
label="Webserver und TLS", label="Webserver und TLS",
icon="server", icon="server",
endpoint="hostsettings.web", endpoint="hostsettings.web",
permissions=("settings_web.view",), permissions=("settings_web.read",),
), ),
NavItem( NavItem(
key="settings_directory", key="settings_directory",
label="Verzeichnisdienst", label="Verzeichnisdienst",
icon="users", icon="users",
endpoint="hostsettings.directory_settings", endpoint="hostsettings.directory_settings",
permissions=("settings_directory.view",), permissions=("settings_directory.read",),
), ),
NavItem( NavItem(
key="settings_license", key="settings_license",
@@ -216,28 +218,28 @@ NAVIGATION = NavTree(
endpoint="license.overview", endpoint="license.overview",
# Bewusst immer erreichbar, auch ohne gueltige Lizenz -- # Bewusst immer erreichbar, auch ohne gueltige Lizenz --
# sonst liesse sich eine neue Lizenz nie einspielen. # sonst liesse sich eine neue Lizenz nie einspielen.
permissions=("settings_license.view",), permissions=("settings_license.read",),
), ),
NavItem( NavItem(
key="backup", key="backup",
label="Sicherung", label="Sicherung",
icon="transfer", icon="transfer",
endpoint="backup.overview", endpoint="backup.overview",
permissions=("backup.view", "backup.export", "backup.execute"), permissions=("backup_export.read", "backup_import.read"),
), ),
NavItem( NavItem(
key="trash", key="trash",
label="Papierkorb", label="Papierkorb",
icon="trash", icon="trash",
endpoint="trash.overview", endpoint="trash.overview",
permissions=("trash.view",), permissions=("trash.read",),
), ),
NavItem( NavItem(
key="diagnostics", key="diagnostics",
label="Diagnose", label="Diagnose",
icon="info", icon="info",
endpoint="diagnostics.overview", endpoint="diagnostics.overview",
permissions=("diagnostics.view",), permissions=("diagnostics.read",),
), ),
), ),
), ),
+309 -67
View File
@@ -30,67 +30,219 @@ A = Action
DEVICES = Resource( DEVICES = Resource(
key="devices", key="devices",
label="Clients", label="Clients",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE), actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Überwachte PoE-Endgeräte", hint="Stammdaten überwachter PoE-Endgeräte: Name, MAC, IP, Switch und Port",
action_hints={ action_hints={
A.EXECUTE: "PoE-Neustart eines Geräts manuell auslösen. Der automatische "
"Neustart bei Ausfall ist davon unabhängig und läuft immer.",
A.DELETE: "Gerät in den Papierkorb legen.", A.DELETE: "Gerät in den Papierkorb legen.",
}, },
) )
#: Drei Wege, drei Rechte -- bisher hingen alle an ``devices.execute``.
#:
#: Wer jemandem den PoE-Neustart erlauben wollte (der Weg, der auch bei einem
#: haengenden Geraet noch wirkt und nichts Laufendes unterbricht), erlaubte ihm
#: damit zugleich, ein arbeitendes System ueber SSH herunterzufahren. Das sind
#: drei verschiedene Eingriffe mit drei verschiedenen Folgen -- also drei
#: Zeilen. Die Reihenfolge ist die der Rangfolge in ``services.restart``.
DEVICE_RESTART_POE = Resource(
key="device_restart_poe",
parent="devices",
label="Clients: Neustart über PoE",
actions=(A.READ, A.EXECUTE),
hint="Portzyklus am Switch – wirkt in Sekunden, auch wenn das Gerät hängt",
action_hints={
A.READ: "Den Knopf sehen.",
A.EXECUTE: "Neustart auslösen. Bewusst nicht lizenzpflichtig: ein hängendes "
"Gerät soll man auch ohne gültige Lizenz von Hand neu starten können.",
},
)
DEVICE_RESTART_SSH = Resource(
key="device_restart_ssh",
parent="devices",
label="Clients: Neustart über SSH",
actions=(A.READ, A.EXECUTE),
hint="Sauberes Herunterfahren eines Linux-Geräts (Modul Wartung)",
action_hints={
A.EXECUTE: "Neustartauftrag anlegen. Dauert Minuten und setzt ein "
"antwortendes Gerät samt freigegebenem Host-Schlüssel voraus.",
},
)
DEVICE_RESTART_RPC = Resource(
key="device_restart_rpc",
parent="devices",
label="Clients: Neustart über RPC",
actions=(A.READ, A.EXECUTE),
hint="Windows-Gerät ohne SSH und ohne Host-Schlüssel (Modul Wartung)",
action_hints={A.EXECUTE: "Neustartauftrag anlegen (wie 'shutdown /m')."},
)
#: Die Client-Konsole lief unter ``maintenance.execute`` mit -- demselben Recht
#: wie die Paketaktualisierung. Eine Sitzung auf einem Geraet ist aber etwas
#: anderes als ein Auftrag, den der Server abarbeitet: hier tippt ein Mensch.
DEVICE_CONSOLE = Resource(
key="device_console",
parent="devices",
label="Clients: Konsole",
actions=(A.READ, A.EXECUTE),
hint="Browser-Terminal auf einem Client (Modul Wartung)",
action_hints={
A.EXECUTE: "Sitzung öffnen. Verlangt eine frische Passwortbestätigung; "
"jede Sitzung wird vollständig mitgeschrieben.",
},
)
SWITCHES = Resource( SWITCHES = Resource(
key="switches", key="switches",
label="Switche", label="Switche",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE), actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Aruba-Switche und ihre Erreichbarkeit", hint="Aruba-Switche, ihre Erreichbarkeit und Ports",
)
SWITCH_CONSOLE = Resource(
key="switch_console",
parent="switches",
label="Switche: Konsole",
actions=(A.READ, A.EXECUTE),
hint="Kommandos auf dem Switch ausführen",
action_hints={ action_hints={
A.EXECUTE: "Browser-Terminal öffnen und Kommandos auf dem Switch ausführen. " A.EXECUTE: "Sitzung öffnen. Verlangt eine frische Passwortbestätigung; "
"Verlangt zusätzlich eine frische Passwortbestätigung; jede Sitzung wird " "jede Sitzung wird vollständig mitgeschrieben.",
"vollständig protokolliert.", },
)
#: Einen geaenderten Host-Schluessel freizugeben hing an "Gerät ändern" bzw.
#: "Switch ändern" -- also an einem Recht fuer Stammdaten. Es ist aber der
#: Vorgang, mit dem man sagt: "dieses Geraet ist wirklich das, fuer das es sich
#: ausgibt". Wer Namen und Standort pflegt, muss das nicht entscheiden duerfen.
HOSTKEYS = Resource(
key="hostkeys",
label="Host-Schlüssel",
actions=(A.READ, A.EXECUTE),
hint="Ein Gerät meldet sich mit einem anderen SSH-Schlüssel als beim letzten Mal",
action_hints={
A.READ: "Offene und freigegebene Schlüssel sehen.",
A.EXECUTE: "Einen geänderten Schlüssel freigeben.",
}, },
) )
CREDENTIALS = Resource( CREDENTIALS = Resource(
key="credentials", key="credentials",
label="Zugangsdaten", label="Zugangsdaten",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.SECRETS), actions=(A.READ, A.CREATE, A.WRITE, A.DELETE, A.SECRETS),
hint="Wiederverwendbare SSH-Logins", hint="Wiederverwendbare SSH-Logins",
action_hints={ action_hints={
A.VIEW: "Namen und Benutzer sehen – nicht das Passwort.", A.READ: "Namen und Benutzer sehen – nicht das Passwort.",
A.SECRETS: "Passwort im Klartext anzeigen. Verlangt eine frische " A.SECRETS: "Passwort im Klartext anzeigen. Verlangt eine frische "
"Passwortbestätigung und wird protokolliert.", "Passwortbestätigung und wird protokolliert.",
}, },
) )
MAINTENANCE = Resource( #: Aus einer Zeile ("Aktualisierung und Neustart") wurden drei. Sie deckte
key="maintenance", #: vier Funktionen ab: Pakete aktualisieren, reihenweise neu starten, das
# Nicht "Wartung": so heisst der Bereich. Die Zeile sagt, was sie erlaubt. #: Windows-Werkzeug installieren -- und die Client-Konsole, die jetzt bei den
label="Aktualisierung und Neustart", #: Clients steht, wo sie hingehoert.
actions=(A.VIEW, A.EXECUTE), SERVICING_UPDATE = Resource(
hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)", key="servicing_update",
action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewählten Geräten starten."}, label="Paketaktualisierung",
actions=(A.READ, A.EXECUTE),
hint="Pakete auf ausgewählten Geräten aktualisieren (lizenzpflichtiges Modul)",
action_hints={A.EXECUTE: "Auftrag starten."},
) )
DHCP = Resource( SERVICING_REBOOT = Resource(
key="dhcp", key="servicing_reboot",
label="DHCP-Server", label="Neustart als Auftrag",
actions=(A.VIEW, A.EDIT, A.EXECUTE), actions=(A.READ, A.EXECUTE),
hint="DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)", hint="Reihenweise Neustarts mit Wartezeit und Rückmeldung",
action_hints={A.EXECUTE: "Auftrag starten."},
)
SERVICING_TOOL = Resource(
key="servicing_tool",
label="Windows-Werkzeug installieren",
actions=(A.READ, A.EXECUTE),
hint="Voraussetzung für den RPC-Weg auf einem Windows-Gerät",
action_hints={A.EXECUTE: "Installation auslösen."},
)
#: Die Wartungsseite zeigt alle drei Aufgaben. Sie oeffnet, wer **eine** davon
#: sehen darf -- und zeigt dann nur die. Als Konstante, damit die vier Routen
#: dieselbe Antwort geben; einzeln getippt liefen sie irgendwann auseinander.
SERVICING_PAGE: tuple[str, ...] = (
"servicing_update.read",
"servicing_reboot.read",
"servicing_tool.read",
)
#: Elf Funktionen hingen an ``dhcp.edit``: Grundeinstellungen, Subnetze,
#: Optionen, Geraeteoptionen, eigene Optionen, Reservierungen und das
#: Schreiben der Dienstkonfiguration. Wer eine Reservierung eintragen sollte,
#: durfte damit auch Subnetze loeschen und die Konfiguration ueberschreiben.
DHCP_SUBNETS = Resource(
key="dhcp_subnets",
label="DHCP: Subnetze",
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Bereiche, Gateway, DNS, Reihenfolge (lizenzpflichtiges Modul)",
)
DHCP_OPTIONS = Resource(
key="dhcp_options",
parent="dhcp_subnets",
label="DHCP: Optionen",
actions=(A.READ, A.WRITE),
hint="Globale Optionen, Optionen je Gerät und eigene Optionen",
action_hints={A.WRITE: "Werte setzen, eigene Optionen anlegen und entfernen."},
)
DHCP_RESERVATIONS = Resource(
key="dhcp_reservations",
parent="dhcp_subnets",
label="DHCP: Reservierungen",
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Feste Adressen zu einer MAC",
)
DHCP_CONFIG = Resource(
key="dhcp_config",
parent="dhcp_subnets",
label="DHCP: Konfiguration schreiben",
actions=(A.READ, A.EXECUTE),
hint="Den bearbeiteten Stand in die Dienstkonfiguration übernehmen",
action_hints={ action_hints={
A.EDIT: "Konfiguration ändern und schreiben.", A.EXECUTE: "Schreiben und neu laden. Bis dahin ändert sich am laufenden "
A.EXECUTE: "Dienst installieren, starten oder stoppen.", "Dienst nichts – Bearbeiten und Wirksamwerden sind zwei Schritte.",
}, },
) )
DHCP_SERVICE = Resource(
key="dhcp_service",
parent="dhcp_subnets",
label="DHCP: Dienst",
actions=(A.READ, A.EXECUTE),
hint="Kea installieren, starten, stoppen",
action_hints={A.EXECUTE: "Dienstzustand ändern."},
)
#: Die DHCP-Seite zeigt alle Abschnitte; jeder Abschnitt haengt an seiner
#: eigenen Zeile. Geoeffnet wird sie, wer einen davon sehen darf.
DHCP_PAGE: tuple[str, ...] = (
"dhcp_subnets.read",
"dhcp_options.read",
"dhcp_reservations.read",
"dhcp_config.read",
"dhcp_service.read",
)
FILESHARE = Resource( FILESHARE = Resource(
key="fileshare", key="fileshare",
label="Dateifreigaben", label="Dateifreigaben",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXPORT), actions=(A.READ, A.CREATE, A.WRITE, A.DELETE, A.EXPORT),
hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)", hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)",
action_hints={ action_hints={
A.CREATE: "Ordner anlegen und Dateien hochladen.", A.CREATE: "Ordner anlegen und Dateien hochladen.",
A.EDIT: "Umbenennen und verschieben.", A.WRITE: "Umbenennen und verschieben.",
# Genau die Bedeutung aus der Legende: Daten aus dem System # Genau die Bedeutung aus der Legende: Daten aus dem System
# heraustragen. Ansehen und Mitnehmen sind zweierlei -- eine Vorschau # heraustragen. Ansehen und Mitnehmen sind zweierlei -- eine Vorschau
# bleibt im Haus, eine heruntergeladene Datei nicht. # bleibt im Haus, eine heruntergeladene Datei nicht.
@@ -109,20 +261,47 @@ FILESHARE = Resource(
#: soll deshalb nicht automatisch veraenderbare Formulare ablegen duerfen. #: soll deshalb nicht automatisch veraenderbare Formulare ablegen duerfen.
FILESHARE_PDF = Resource( FILESHARE_PDF = Resource(
key="fileshare_pdf", key="fileshare_pdf",
parent="fileshare",
label="PDF-Formulare", label="PDF-Formulare",
actions=(A.VIEW, A.CREATE, A.EDIT), actions=(A.READ, A.CREATE, A.WRITE),
hint="Formulare in der Vorschau ausfüllen und in der Freigabe ablegen", hint="Formulare in der Vorschau ausfüllen und in der Freigabe ablegen",
action_hints={ action_hints={
A.VIEW: "In der Vorschau ausfüllen (ohne Speichern).", A.READ: "In der Vorschau ausfüllen (ohne Speichern).",
A.CREATE: "Ausgefüllt speichern – abgeschlossen, Felder gesperrt.", A.CREATE: "Ausgefüllt speichern – abgeschlossen, Felder gesperrt.",
A.EDIT: "Ausgefüllt speichern – weiter ausfüllbar, inklusive Bild.", A.WRITE: "Ausgefüllt speichern – weiter ausfüllbar, inklusive Bild.",
}, },
) )
#: Unterschreiben ist kein Aendern: am Ende steht eine Fassung, die niemand
#: mehr bearbeiten kann, mit Namen und Zeitpunkt im Dokument und einer
#: Pruefsumme im Aenderungsprotokoll. Wer Dateien umbenennen darf, soll das
#: nicht automatisch duerfen.
FILESHARE_SIGNATURE = Resource(
key="fileshare_signature",
parent="fileshare",
label="Unterschriften",
actions=(A.READ, A.EXECUTE),
hint="Mit dem Finger unterschreiben; das Ergebnis ist unveränderbar",
action_hints={
A.READ: "Unterschriebene Dateien öffnen und die Prüfung sehen.",
A.EXECUTE: "Unterschreiben und abgeschlossen ablegen.",
},
)
#: Welche Verzeichnisfreigabe unter welchem Namen erscheint, ist Administration
#: und kein Dateibetrieb -- es lief unter demselben Recht wie "umbenennen".
FILESHARE_MAPPINGS = Resource(
key="fileshare_mappings",
parent="fileshare",
label="Freigabe-Zuordnungen",
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Verzeichnisgruppe, UNC-Pfad und Anzeigename einer Freigabe",
)
LOGS_DHCP = Resource( LOGS_DHCP = Resource(
key="logs_dhcp", key="logs_dhcp",
label="DHCP-Protokoll", label="DHCP-Protokoll",
actions=(A.VIEW,), actions=(A.READ,),
) )
MONITORING_AREA = Area( MONITORING_AREA = Area(
@@ -131,7 +310,17 @@ MONITORING_AREA = Area(
icon="cpu", icon="cpu",
hint="Geräte, Switche und Zugangsdaten. Ohne diesen Zugang bleibt der " hint="Geräte, Switche und Zugangsdaten. Ohne diesen Zugang bleibt der "
"gesamte Bereich verborgen.", "gesamte Bereich verborgen.",
resources=(DEVICES, SWITCHES, CREDENTIALS), resources=(
DEVICES,
DEVICE_RESTART_POE,
DEVICE_RESTART_SSH,
DEVICE_RESTART_RPC,
DEVICE_CONSOLE,
SWITCHES,
SWITCH_CONSOLE,
HOSTKEYS,
CREDENTIALS,
),
) )
#: Wartung ueberwacht nichts, sie greift ein -- und sie steht seit 2.5.0 als #: Wartung ueberwacht nichts, sie greift ein -- und sie steht seit 2.5.0 als
@@ -144,21 +333,21 @@ MAINTENANCE_AREA = Area(
icon="wrench", icon="wrench",
hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich " hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich "
"verborgen – auch wenn einzelne Rechte darunter gesetzt sind.", "verborgen – auch wenn einzelne Rechte darunter gesetzt sind.",
resources=(MAINTENANCE,), resources=(SERVICING_UPDATE, SERVICING_REBOOT, SERVICING_TOOL),
) )
NETWORK_AREA = Area( NETWORK_AREA = Area(
key="network", key="network",
label="Netzwerkdienste", label="Netzwerkdienste",
icon="network", icon="network",
resources=(DHCP,), resources=(DHCP_SUBNETS, DHCP_OPTIONS, DHCP_RESERVATIONS, DHCP_CONFIG, DHCP_SERVICE),
) )
FILES_AREA = Area( FILES_AREA = Area(
key="files", key="files",
label="Dateifreigaben", label="Dateifreigaben",
icon="folder", icon="folder",
resources=(FILESHARE, FILESHARE_PDF), resources=(FILESHARE, FILESHARE_PDF, FILESHARE_SIGNATURE, FILESHARE_MAPPINGS),
) )
PERMISSIONS = PermissionTree( PERMISSIONS = PermissionTree(
@@ -173,15 +362,41 @@ PERMISSIONS = PermissionTree(
) )
#: Alle Funktionsrechte ausserhalb der Verwaltung, in einer Gruppe.
#:
#: Seit 2.8.0 heisst das Administratorkennzeichen "Verwaltung" und nicht
#: "alles": Geraete, Neustarts, DHCP und Freigaben kommen ueber Gruppen. Diese
#: Vorlage ist die Gruppe, die man sich dafuer zuweist -- und die Migration
#: gibt sie bestehenden Administratoren, damit nach dem Update nichts fehlt,
#: was vorher ging. Entfernen kann man sie mit einem Klick.
#:
#: Die Liste entsteht aus dem Baum und nicht aus Tippen: eine neue Zeile in der
#: Matrix ist sonst genau die, die hier vergessen wird.
FUNKTIONEN_VOLLZUGRIFF = RolePreset(
key="functions",
name="Funktionen (Vollzugriff)",
description=(
"Alle Rechte ausserhalb der Verwaltung. Gedacht fuer Administratoren, "
"die nicht nur einstellen, sondern auch bedienen."
),
grants=tuple(
(resource.key, "*")
for area in PERMISSIONS.areas
if area.key != PermissionTree.VERWALTUNGSBEREICH
for resource in area.resources
),
)
ROLE_PRESETS: tuple[RolePreset, ...] = ( ROLE_PRESETS: tuple[RolePreset, ...] = (
RolePreset( RolePreset(
key="viewer", key="viewer",
name="Benutzer", name="Benutzer",
description="Standardgruppe: Geräte und Switche ansehen, laufendes Protokoll lesen.", description="Standardgruppe: Geräte und Switche ansehen, laufendes Protokoll lesen.",
grants=( grants=(
("devices", (A.VIEW,)), ("devices", (A.READ,)),
("switches", (A.VIEW,)), ("switches", (A.READ,)),
("logs_live", (A.VIEW,)), ("logs_live", (A.READ,)),
), ),
is_default=True, is_default=True,
), ),
@@ -191,12 +406,20 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
description="Geräte und Switche pflegen, Neustarts auslösen, Wartung fahren.", description="Geräte und Switche pflegen, Neustarts auslösen, Wartung fahren.",
grants=( grants=(
("devices", "*"), ("devices", "*"),
("switches", (A.VIEW, A.CREATE, A.EDIT, A.EXECUTE)), ("device_restart_poe", "*"),
("credentials", (A.VIEW,)), ("device_restart_ssh", "*"),
("maintenance", "*"), ("device_restart_rpc", "*"),
("logs_live", (A.VIEW,)), ("device_console", "*"),
("logs_history", (A.VIEW,)), ("switches", (A.READ, A.CREATE, A.WRITE)),
("trash", (A.VIEW, A.EDIT)), ("switch_console", "*"),
("hostkeys", "*"),
("credentials", (A.READ,)),
("servicing_update", "*"),
("servicing_reboot", "*"),
("servicing_tool", "*"),
("logs_live", (A.READ,)),
("logs_history", (A.READ,)),
("trash", (A.READ, A.WRITE)),
), ),
), ),
RolePreset( RolePreset(
@@ -204,9 +427,13 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
name="Support", name="Support",
description="Erste Hilfe am Telefon: Status sehen, PoE-Neustart auslösen.", description="Erste Hilfe am Telefon: Status sehen, PoE-Neustart auslösen.",
grants=( grants=(
("devices", (A.VIEW, A.EXECUTE)), ("devices", (A.READ,)),
("switches", (A.VIEW,)), # Nur der PoE-Weg: der wirkt auch bei einem haengenden Geraet und
("logs_live", (A.VIEW,)), # unterbricht nichts Laufendes. SSH und RPC fahren ein arbeitendes
# System herunter -- das gehoert nicht in die erste Hilfe.
("device_restart_poe", "*"),
("switches", (A.READ,)),
("logs_live", (A.READ,)),
), ),
), ),
RolePreset( RolePreset(
@@ -214,11 +441,15 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
name="Netzwerk", name="Netzwerk",
description="DHCP und Host-Netzwerkeinstellungen.", description="DHCP und Host-Netzwerkeinstellungen.",
grants=( grants=(
("dhcp", "*"), ("dhcp_subnets", "*"),
("dhcp_options", "*"),
("dhcp_reservations", "*"),
("dhcp_config", "*"),
("dhcp_service", "*"),
("settings_network", "*"), ("settings_network", "*"),
("logs_dhcp", (A.VIEW,)), ("logs_dhcp", (A.READ,)),
("devices", (A.VIEW,)), ("devices", (A.READ,)),
("switches", (A.VIEW,)), ("switches", (A.READ,)),
), ),
), ),
RolePreset( RolePreset(
@@ -226,17 +457,19 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
name="Revision", name="Revision",
description="Nur lesen – inklusive Änderungsprotokoll und Diagnose.", description="Nur lesen – inklusive Änderungsprotokoll und Diagnose.",
grants=( grants=(
("devices", (A.VIEW,)), ("devices", (A.READ,)),
("switches", (A.VIEW,)), ("switches", (A.READ,)),
("credentials", (A.VIEW,)), ("credentials", (A.READ,)),
("dhcp", (A.VIEW,)), ("dhcp_subnets", (A.READ,)),
("users", (A.VIEW,)), ("dhcp_options", (A.READ,)),
("groups", (A.VIEW,)), ("dhcp_reservations", (A.READ,)),
("logs_live", (A.VIEW,)), ("users", (A.READ,)),
("logs_history", (A.VIEW, A.EXPORT)), ("groups", (A.READ,)),
("logs_audit", (A.VIEW, A.EXPORT)), ("logs_live", (A.READ,)),
("diagnostics", (A.VIEW,)), ("logs_history", (A.READ, A.EXPORT)),
("trash", (A.VIEW,)), ("logs_audit", (A.READ, A.EXPORT)),
("diagnostics", (A.READ,)),
("trash", (A.READ,)),
), ),
), ),
RolePreset( RolePreset(
@@ -247,16 +480,25 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
("users", "*"), ("users", "*"),
("groups", "*"), ("groups", "*"),
("settings_system", "*"), ("settings_system", "*"),
("settings_nav", "*"),
("settings_license", "*"), ("settings_license", "*"),
("settings_directory", (A.VIEW, A.EDIT, A.EXECUTE)), ("settings_directory", (A.READ, A.WRITE, A.EXECUTE)),
("settings_web", "*"), ("settings_web", "*"),
("settings_network", "*"), ("settings_network", "*"),
("backup", (A.VIEW, A.EXECUTE, A.EXPORT)), ("backup_export", (A.READ, A.EXPORT)),
("backup_import", "*"),
("trash", "*"), ("trash", "*"),
("diagnostics", (A.VIEW,)), ("diagnostics", (A.READ,)),
("logs_audit", (A.VIEW,)), ("logs_audit", (A.READ,)),
), ),
), ),
FUNKTIONEN_VOLLZUGRIFF,
) )
__all__ = ["PERMISSIONS", "ROLE_PRESETS", "LOGS_DHCP"] __all__ = [
"PERMISSIONS",
"ROLE_PRESETS",
"LOGS_DHCP",
"SERVICING_PAGE",
"DHCP_PAGE",
]
+43 -10
View File
@@ -257,19 +257,19 @@ CREATE INDEX IF NOT EXISTS idx_restart_events_method ON restart_events(method, t
V4 = """ V4 = """
INSERT OR IGNORE INTO group_permissions (group_id, permission) INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare.export' FROM group_permissions SELECT group_id, 'fileshare.export' FROM group_permissions
WHERE permission = 'fileshare.view'; WHERE permission = 'fileshare.read';
INSERT OR IGNORE INTO group_permissions (group_id, permission) INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare_pdf.view' FROM group_permissions SELECT group_id, 'fileshare_pdf.read' FROM group_permissions
WHERE permission = 'fileshare.view'; WHERE permission = 'fileshare.read';
INSERT OR IGNORE INTO group_permissions (group_id, permission) INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare_pdf.create' FROM group_permissions SELECT group_id, 'fileshare_pdf.create' FROM group_permissions
WHERE permission = 'fileshare.create'; WHERE permission = 'fileshare.create';
INSERT OR IGNORE INTO group_permissions (group_id, permission) INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare_pdf.edit' FROM group_permissions SELECT group_id, 'fileshare_pdf.write' FROM group_permissions
WHERE permission = 'fileshare.edit'; WHERE permission = 'fileshare.write';
""" """
@@ -297,13 +297,21 @@ DELETE FROM settings WHERE key = 'terminal_enabled';
# Bewusst nicht "alle, die ein Wartungsrecht haben": wer den Bereich # Bewusst nicht "alle, die ein Wartungsrecht haben": wer den Bereich
# Ueberwachung nicht hatte, kam auch vorher nicht an die Wartung. Ein Update # Ueberwachung nicht hatte, kam auch vorher nicht an die Wartung. Ein Update
# darf Zugang erhalten, aber keinen neuen schaffen. # darf Zugang erhalten, aber keinen neuen schaffen.
#
# Die Bedingung nennt absichtlich keine festen Schluessel, sondern Praefixe:
# Wer von einer Fassung vor diesem Schritt direkt auf 2.8 geht, hat vorher die
# Migrationen des Kerns durchlaufen (core laeuft vor der Anwendung) -- die
# Rechte heissen dann schon "read"/"write" und die Wartungszeilen "servicing_".
# Mit ausgeschriebenen Schluesseln fand dieser Schritt in genau diesem Fall
# nichts und der Bereich waere doch weg. Ein Ueberrest ist die Zeile nicht:
# eine angewendete Migration laeuft nie wieder.
V6 = """ V6 = """
INSERT OR IGNORE INTO group_permissions (group_id, permission) INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT DISTINCT group_id, 'servicing.view' SELECT DISTINCT group_id, 'servicing.read'
FROM group_permissions FROM group_permissions
WHERE permission IN ('maintenance.view', 'maintenance.execute') WHERE (permission LIKE 'maintenance.%' OR permission LIKE 'servicing%')
AND group_id IN ( AND group_id IN (
SELECT group_id FROM group_permissions WHERE permission = 'monitoring.view' SELECT group_id FROM group_permissions WHERE permission = 'monitoring.read'
); );
""" """
@@ -371,12 +379,37 @@ def uebernehme_signaturen(conn) -> None: # type: ignore[no-untyped-def]
) )
def uebergang_funktionsgruppe(conn) -> None: # type: ignore[no-untyped-def]
"""Bestehende Administratoren behalten ihre Funktionen -- ueber eine Gruppe.
Steht hier und nicht im Kern, weil erst die Anwendung weiss, welche Zeilen
ausserhalb der Verwaltung es gibt. Laeuft nach den Migrationen des Kerns
(die Rechte heissen dann schon read/write und sind aufgeteilt).
"""
from tesm_core.rbac import service as rbac
from .permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
rbac.funktionsgruppe_fuer_administratoren(conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF)
TESM_MIGRATIONS: tuple[Migration, ...] = ( TESM_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1), Migration(version=1, name="baseline", sql=V1),
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options), Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
Migration(version=3, name="restart-events", sql=V3), Migration(version=3, name="restart-events", sql=V3),
Migration(version=4, name="fileshare-pdf-rechte", sql=V4), Migration(
version=4,
name="fileshare-pdf-rechte",
sql=V4,
geaendert_weil="Die Rechteschluessel heissen seit 2.8.0 read/write. Auf einer frischen Installation laufen die Migrationen des Kerns zuerst, also muss dieser Schritt die neuen Namen setzen -- mit den alten blieben sie ungueltig.",
),
Migration(version=5, name="terminal-getrennt", sql=V5), Migration(version=5, name="terminal-getrennt", sql=V5),
Migration(version=6, name="wartung-eigener-bereich", sql=V6), Migration(
version=6,
name="wartung-eigener-bereich",
sql=V6,
geaendert_weil="Die Rechteschluessel heissen seit 2.8.0 read/write. Auf einer frischen Installation laufen die Migrationen des Kerns zuerst, also muss dieser Schritt die neuen Namen setzen -- mit den alten blieben sie ungueltig.",
),
Migration(version=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen), Migration(version=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen),
Migration(version=8, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe),
) )
+274 -13
View File
@@ -16,10 +16,20 @@ eingelesen. Ein eigener Schreibpfad fuer Altdaten waere eine zweite Stelle, an
der Fremdeingaben in die Datenbank gelangen -- und die zweite Stelle ist die, der Fremdeingaben in die Datenbank gelangen -- und die zweite Stelle ist die,
die niemand pflegt. die niemand pflegt.
Uebersetzt werden Clients, Switche, Zugangsdaten und die DHCP-Konfiguration. Uebersetzt werden Clients, Switche, Zugangsdaten, die DHCP-Konfiguration, die
Was in der Datei sonst noch steht, wird nicht stillschweigend verworfen, Freigabe-Zuordnungen und die Anbindung an das Verzeichnis (Server, Transport,
sondern im Bericht benannt -- damit sichtbar bleibt, was von Hand nachzuziehen Basis-DN, Anmeldeattribut, Bind-Konto samt Passwort, und die Zuordnung
ist. "diese AD-Gruppe wird Administrator"). Was in der Datei sonst noch steht, wird nicht
stillschweigend verworfen, sondern im Bericht benannt -- damit sichtbar bleibt,
was von Hand nachzuziehen ist.
Die Freigaben standen bis 2.8.0 auf der Liste "von Hand nachtragen", obwohl es
dafuer keinen Grund gab: der Vorgaenger fuehrt sie in
``ldap_fileshare_mappings`` mit denselben vier Angaben, die diese Anwendung in
``fileshare_mappings`` braucht (Verzeichnisgruppe, deren Name, Beschriftung,
UNC-Pfad), und einen Import dafuer gibt es hier ohnehin. Was sich uebernehmen
laesst, soll man nicht abtippen muessen -- ein UNC-Pfad von Hand ist ein
Tippfehler, der erst auffaellt, wenn jemand die Freigabe braucht.
""" """
from __future__ import annotations from __future__ import annotations
@@ -36,7 +46,7 @@ LEGACY_KDF_ITERATIONS = 390000
LEGACY_APP_TAG = "tesm" LEGACY_APP_TAG = "tesm"
#: Diese Abschnitte werden uebersetzt. #: Diese Abschnitte werden uebersetzt.
SUPPORTED = ("credentials", "switches", "devices", "dhcp") SUPPORTED = ("credentials", "switches", "devices", "dhcp", "ldap")
#: Was der alte Export sonst enthaelt. Wird gemeldet, nicht uebersetzt. #: Was der alte Export sonst enthaelt. Wird gemeldet, nicht uebersetzt.
KNOWN_UNSUPPORTED: dict[str, str] = { KNOWN_UNSUPPORTED: dict[str, str] = {
@@ -46,10 +56,9 @@ KNOWN_UNSUPPORTED: dict[str, str] = {
"Zweck. Konten neu anlegen." "Zweck. Konten neu anlegen."
), ),
"groups": "Gruppen und Rechte: die Rechteschlüssel wurden neu geschnitten.", "groups": "Gruppen und Rechte: die Rechteschlüssel wurden neu geschnitten.",
"ldap": ( # Der Abschnitt "ldap" wird uebersetzt (siehe _verzeichnis) -- was dabei
"Verzeichnisdienst: Einstellungen, Gruppen- und Freigabe-Zuordnungen von " # nicht mitkommt, benennt die Uebersetzung einzeln und genauer, als eine
"Hand übertragen." # pauschale Zeile hier es koennte.
),
"nginx": "Webserver: Domain, Ports und Zertifikat von Hand übertragen.", "nginx": "Webserver: Domain, Ports und Zertifikat von Hand übertragen.",
"logs": "Auditlog: bleibt bewusst beim alten System.", "logs": "Auditlog: bleibt bewusst beim alten System.",
"known_hosts": ( "known_hosts": (
@@ -272,6 +281,168 @@ def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> l
return ergebnis return ergebnis
def _verzeichnis(data: Any, report: Report) -> dict[str, Any]:
"""Die Anbindung an das Verzeichnis -- so weit sie sich abbilden laesst.
Zwei Stellen sind **nicht** 1:1, und beide werden benannt statt geraten:
* **Transport.** Der Vorgaenger kannte ``ldap_use_ssl`` (an/aus). Aus "an"
wird LDAPS. Aus "aus" wird **StartTLS auf demselben Port** und nicht
"unverschluesselt": diese Anwendung verlangt fuer eine unverschluesselte
Verbindung einen ausdruecklichen Schalter, weil das Passwort dabei im
Klartext ueber das Netz geht. Diesen Schalter setzt eine Uebernahme
nicht fuer jemanden -- ein Active Directory beantwortet StartTLS auf 389
ohnehin, und wenn nicht, sagt der Verbindungstest es sofort.
* **Standard- und Anmeldegruppe.** Es gibt sie hier nicht als
Einstellung: die Standardgruppe ist ein Kennzeichen an der Gruppe
selbst, und eine erzwungene Anmeldegruppe kennt diese Anwendung nicht.
Waren sie gesetzt, steht es im Bericht.
"""
if not isinstance(data, dict):
return {}
alt = {str(k): v for k, v in (data.get("settings") or {}).items()}
ergebnis: dict[str, Any] = {}
einstellungen: dict[str, Any] = {}
def ja(schluessel: str) -> bool:
return str(alt.get(schluessel) or "").strip().lower() in {"1", "true", "yes", "on"}
server = str(alt.get("ldap_server") or "").strip()
if server:
einstellungen["ldap_host"] = server
if str(alt.get("ldap_port") or "").strip().isdigit():
einstellungen["ldap_port"] = int(str(alt["ldap_port"]).strip())
if "ldap_enabled" in alt:
einstellungen["ldap_enabled"] = ja("ldap_enabled")
if str(alt.get("ldap_base_dn") or "").strip():
einstellungen["ldap_base_dn"] = str(alt["ldap_base_dn"]).strip()
if str(alt.get("ldap_user_filter_attr") or "").strip():
einstellungen["ldap_user_attribute"] = str(alt["ldap_user_filter_attr"]).strip()
if "ldap_use_ssl" in alt:
if ja("ldap_use_ssl"):
einstellungen["ldap_security"] = "ldaps"
else:
einstellungen["ldap_security"] = "starttls"
report.note(
"Der Vorgänger hat unverschlüsselt verbunden. Übernommen als StartTLS auf "
"demselben Port – eine unverschlüsselte Verbindung verlangt hier einen "
"ausdrücklichen Schalter, den eine Übernahme nicht für Sie setzt. Bitte den "
"Verbindungstest ausführen."
)
if ja("ldap_tls_skip_verify"):
einstellungen["ldap_verify_cert"] = False
report.note(
"Die Prüfung des Serverzertifikats war abgeschaltet und bleibt es – sonst "
"scheitert die Anmeldung nach dem Upgrade. Damit schützt die Verschlüsselung "
"nur gegen Mitlesen, nicht gegen einen Man-in-the-Middle: bitte ein "
"CA-Zertifikat hinterlegen und die Prüfung einschalten."
)
if einstellungen:
ergebnis["settings"] = einstellungen
for schluessel, text in (
(
"ldap_default_group",
"Standardgruppe {wert!r}: hier ist das ein Kennzeichen an der Gruppe selbst "
"(Verwaltung -> Gruppen). Bitte dort setzen.",
),
(
"ldap_required_login_group",
"Erzwungene Anmeldegruppe {wert!r}: diese Anwendung kennt sie nicht. Wer sich "
"anmelden darf, entscheidet die Gruppen-Zuordnung.",
),
):
wert = str(alt.get(schluessel) or "").strip()
if wert:
report.note(text.format(wert=wert))
benutzer = str(data.get("bind_username") or alt.get("ldap_bind_dn") or "").strip()
passwort = str(data.get("bind_password") or "")
if benutzer:
ergebnis["bind_username"] = benutzer
ergebnis["bind_password"] = passwort
if not passwort:
report.note(
f"Bind-Konto {benutzer!r} übernommen, aber ohne Passwort – es stand nicht in "
"der Datei. Unter Verwaltung -> Verzeichnisdienst nachtragen."
)
zuordnungen: list[dict[str, Any]] = []
offen: list[str] = []
for eintrag in _liste(data.get("group_mappings")):
dn = str(eintrag.get("ad_group_dn") or "").strip()
if not dn:
continue
name = str(eintrag.get("ad_group_name") or "").strip() or dn
ziel = str(
eintrag.get("app_group_name") or eintrag.get("app_group_id") or ""
).strip()
# Der Vorgaenger kannte den Sonderwert "admin" (bzw. "Admin" im
# Export): diese AD-Gruppe macht Administratoren. Genau dafuer gibt es
# hier seit 2.8.0 ein Ziel -- das Administratorkennzeichen.
if ziel.lower() == "admin":
zuordnungen.append(
{"ad_group_dn": dn, "ad_group_name": name, "grants_admin": True}
)
continue
if ziel:
offen.append(f"{name} -> {ziel}")
if zuordnungen:
ergebnis["group_mappings"] = zuordnungen
if offen:
report.note(
"Diese Gruppen-Zuordnungen zeigen auf lokale Gruppen, die nicht mit übernommen "
"werden (die Rechteschlüssel sind neu geschnitten): "
+ ", ".join(sorted(offen))
+ ". Nach dem Anlegen der Gruppen unter Verwaltung -> Verzeichnisdienst "
"nachtragen."
)
return ergebnis
def _freigaben(data: Any, report: Report) -> list[dict[str, Any]]:
"""Freigabe-Zuordnungen aus dem Abschnitt "ldap" des alten Exports.
Der Vorgaenger legt sie unter ``ldap.fileshare_mappings`` ab; beim Lesen
einer Installation stehen sie unmittelbar in der Tabelle. Beides landet
hier, damit es nur eine Stelle gibt, die die Felder umbenennt.
"""
if isinstance(data, dict):
eintraege = _liste(data.get("fileshare_mappings"))
else:
eintraege = _liste(data)
ergebnis: list[dict[str, Any]] = []
ohne_pfad = 0
for eintrag in eintraege:
# "share_label"/"share_unc" heissen hier "label"/"unc" -- dieselbe
# Angabe, kuerzerer Name, weil die Tabelle schon "fileshare" heisst.
unc = str(eintrag.get("share_unc") or eintrag.get("unc") or "").strip()
gruppe = str(eintrag.get("ad_group_dn") or "").strip()
if not unc or not gruppe:
ohne_pfad += 1
continue
beschriftung = str(eintrag.get("share_label") or eintrag.get("label") or "").strip()
ergebnis.append(
{
"ad_group_dn": gruppe,
"ad_group_name": str(eintrag.get("ad_group_name") or "").strip() or gruppe,
# Ohne Beschriftung der letzte Teil des Pfades -- besser als
# eine Freigabe ohne Namen. Beide Trenner: der Vorgaenger
# speichert "//server/share", getippt wird "\server\share".
"label": beschriftung
or unc.replace(chr(92), "/").rstrip("/").rsplit("/", 1)[-1],
"unc": unc,
}
)
if ohne_pfad:
report.note(
f"{ohne_pfad} Freigabe-Zuordnung(en) ohne Verzeichnisgruppe oder UNC-Pfad "
"übersprungen."
)
return ergebnis
def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]: def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
"""DHCP-Teil uebersetzen. Rueckgabe: ``(dhcp, settings)``.""" """DHCP-Teil uebersetzen. Rueckgabe: ``(dhcp, settings)``."""
if not isinstance(data, dict): if not isinstance(data, dict):
@@ -382,6 +553,14 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
if devices: if devices:
payload["devices"] = devices payload["devices"] = devices
freigaben = _freigaben(legacy.get("ldap") or legacy.get("fileshare"), report)
if freigaben:
payload["fileshare"] = freigaben
verzeichnis = _verzeichnis(legacy.get("ldap"), report)
if verzeichnis:
payload["directory"] = verzeichnis
dhcp, settings = _dhcp(legacy.get("dhcp"), report) dhcp, settings = _dhcp(legacy.get("dhcp"), report)
if dhcp: if dhcp:
payload["dhcp"] = dhcp payload["dhcp"] = dhcp
@@ -417,8 +596,8 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
if not payload: if not payload:
raise LegacyTransferError( raise LegacyTransferError(
"Die Datei enthält keine Clients, Switche, Zugangsdaten oder DHCP-Daten. " "Die Datei enthält keine Clients, Switche, Zugangsdaten, DHCP-Daten oder "
"Beim Export im alten TESM diese Kategorien mitwählen." "Freigaben. Beim Export im alten TESM diese Kategorien mitwählen."
) )
return payload, report return payload, report
@@ -617,8 +796,9 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
continue continue
payload[abschnitt] = [dict(row) for row in conn.execute(sql).fetchall()] payload[abschnitt] = [dict(row) for row in conn.execute(sql).fetchall()]
entschluesseln_bind = _entschluessler(root, report)
if "credentials" in vorhanden: if "credentials" in vorhanden:
entschluesseln = _entschluessler(root, report) entschluesseln = entschluesseln_bind
payload["credentials"] = [ payload["credentials"] = [
{ {
"name": row["name"], "name": row["name"],
@@ -634,6 +814,80 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
else: else:
report.note("Tabelle 'credentials' fehlt in der alten Datenbank.") report.note("Tabelle 'credentials' fehlt in der alten Datenbank.")
# Der Abschnitt "ldap" -- genau so aufgebaut wie im alten Export, damit
# die Uebersetzung beide Wege gleich liest.
ldap: dict[str, Any] = {}
if "ldap_fileshare_mappings" in vorhanden:
freigaben = [
dict(row)
for row in conn.execute(
"SELECT ad_group_dn, ad_group_name, share_label, share_unc "
"FROM ldap_fileshare_mappings"
).fetchall()
]
if freigaben:
ldap["fileshare_mappings"] = freigaben
else:
report.note("Tabelle 'ldap_fileshare_mappings' fehlt in der alten Datenbank.")
if "settings" in vorhanden:
# Nur anlegen, wenn etwas drinsteht: ein leerer Abschnitt sieht im
# Bericht wie eine Uebernahme aus und ist keine.
ldap_einstellungen = {
str(row["key"]): str(row["value"])
for row in conn.execute(
"SELECT key, value FROM settings WHERE key LIKE 'ldap%'"
).fetchall()
}
if ldap_einstellungen:
ldap["settings"] = ldap_einstellungen
if "ldap_group_mappings" in vorhanden:
# Der Sonderwert "admin" steht in derselben Spalte wie eine
# Gruppen-Id; der Name dahinter ist das, was ueber die
# Systemgrenze traegt.
gruppen_da = "groups" in vorhanden
zuordnungen = [
{
"ad_group_dn": str(row["ad_group_dn"]),
"ad_group_name": str(row["ad_group_name"]),
"app_group_name": str(row["ziel"] or ""),
}
for row in conn.execute(
"SELECT m.ad_group_dn, m.ad_group_name, CASE "
" WHEN m.app_group_id = 'admin' THEN 'admin' "
+ (" ELSE g.name " if gruppen_da else " ELSE NULL ")
+ "END AS ziel FROM ldap_group_mappings m"
+ (
" LEFT JOIN groups g ON g.id = m.app_group_id"
if gruppen_da
else ""
)
).fetchall()
]
if zuordnungen:
ldap["group_mappings"] = zuordnungen
# Das Bind-Konto: erst das Dienstkonto (so liest es auch der alte
# Export), sonst die beiden Einstellungen aus aelteren Fassungen.
if "service_accounts" in vorhanden:
konto = _eine(
conn,
"SELECT username, password FROM service_accounts WHERE purpose='ldap_bind'",
)
if konto is not None:
ldap["bind_username"] = str(konto["username"] or "")
ldap["bind_password"] = entschluesseln_bind(str(konto["password"] or ""))
if not ldap.get("bind_username"):
aus_einstellungen = (ldap.get("settings") or {}).get("ldap_bind_dn") or ""
if aus_einstellungen:
ldap["bind_username"] = str(aus_einstellungen)
ldap["bind_password"] = entschluesseln_bind(
str((ldap.get("settings") or {}).get("ldap_bind_password") or "")
)
if ldap:
payload["ldap"] = ldap
dhcp = _alt_dhcp(conn, vorhanden) dhcp = _alt_dhcp(conn, vorhanden)
if dhcp: if dhcp:
payload["dhcp"] = dhcp payload["dhcp"] = dhcp
@@ -644,9 +898,16 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
if not payload: if not payload:
raise LegacyTransferError( raise LegacyTransferError(
f"In {root} stehen keine übernehmbaren Daten (Clients, Switche, " f"In {root} stehen keine übernehmbaren Daten (Clients, Switche, "
"Zugangsdaten, DHCP)." "Zugangsdaten, DHCP, Freigaben)."
) )
for abschnitt, inhalt in payload.items(): for abschnitt, inhalt in payload.items():
if abschnitt == "ldap" and isinstance(inhalt, dict):
report.took("Freigaben", len(_liste(inhalt.get("fileshare_mappings"))))
report.took("Verzeichniseinstellungen", len(inhalt.get("settings") or {}))
report.took("Gruppen-Zuordnungen", len(_liste(inhalt.get("group_mappings"))))
if inhalt.get("bind_username"):
report.took("Bind-Konto", 1)
continue
report.took(abschnitt, len(inhalt) if isinstance(inhalt, list) else 1) report.took(abschnitt, len(inhalt) if isinstance(inhalt, list) else 1)
return payload, report return payload, report
+46 -7
View File
@@ -22,7 +22,7 @@ etwa bei einem Geraet, das schreibend auf eine Platte zugreift.
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any, Callable
METHOD_POE = "poe" METHOD_POE = "poe"
METHOD_SSH = "ssh" METHOD_SSH = "ssh"
@@ -40,6 +40,16 @@ class Method:
#: Braucht dieser Weg das lizenzpflichtige Modul "Wartung"? #: Braucht dieser Weg das lizenzpflichtige Modul "Wartung"?
licensed: bool = False licensed: bool = False
@property
def recht(self) -> str:
"""Das Recht, das genau diesen Weg freigibt.
Am Weg und nicht in der Route: die Liste der Wege wird an vier Stellen
gebraucht (Dashboard, Geraeteliste, Detailseite, Route), und ein Recht,
das man an vier Stellen von Hand dazuschreibt, fehlt an der fuenften.
"""
return f"device_restart_{self.key}.execute"
@property @property
def is_poe(self) -> bool: def is_poe(self) -> bool:
return self.key == METHOD_POE return self.key == METHOD_POE
@@ -78,12 +88,22 @@ DEFINITIONS: dict[str, Method] = {
} }
def available(device: Any, *, maintenance_active: bool = True) -> list[Method]: def available(
device: Any,
*,
maintenance_active: bool = True,
darf: Callable[[str], bool] | None = None,
) -> list[Method]:
"""Alle Wege, die bei diesem Geraet tatsaechlich zu etwas fuehren. """Alle Wege, die bei diesem Geraet tatsaechlich zu etwas fuehren.
Ein Weg, der nur *fast* geht -- Switch zugeordnet, aber kein Port --, gilt Ein Weg, der nur *fast* geht -- Switch zugeordnet, aber kein Port --, gilt
als nicht verfuegbar. Ein Knopf, der verlaesslich nichts bewirkt, ist als nicht verfuegbar. Ein Knopf, der verlaesslich nichts bewirkt, ist
schlimmer als kein Knopf. schlimmer als kein Knopf.
``darf`` filtert zusaetzlich nach dem Recht des jeweiligen Weges (in der
Anwendung ``current_user.has``). Damit verschwindet der Knopf fuer den, der
ihn nicht druecken darf -- und zwar an allen Stellen zugleich, weil alle
dieselbe Liste benutzen. Ohne ``darf`` ist die Antwort rein technisch.
""" """
def field(name: str) -> Any: def field(name: str) -> Any:
@@ -102,7 +122,10 @@ def available(device: Any, *, maintenance_active: bool = True) -> list[Method]:
# der PoE-Weg, und der heisst dort "Einschalten": er schaltet den Port # der PoE-Weg, und der heisst dort "Einschalten": er schaltet den Port
# wieder ein, statt ein laufendes System neu zu starten. # wieder ein, statt ein laufendes System neu zu starten.
if str(field("state") or "") == "offline": if str(field("state") or "") == "offline":
return [DEFINITIONS[POWER_ON]] if hat_poe else [] einschalten = [DEFINITIONS[POWER_ON]] if hat_poe else []
if darf is not None:
einschalten = [entry for entry in einschalten if darf(entry.recht)]
return einschalten
category = str(field("credential_category") or "") category = str(field("credential_category") or "")
has_login = bool(field("credential_id")) has_login = bool(field("credential_id"))
@@ -112,23 +135,39 @@ def available(device: Any, *, maintenance_active: bool = True) -> list[Method]:
elif category == "linux": elif category == "linux":
methods.append(DEFINITIONS[METHOD_SSH]) methods.append(DEFINITIONS[METHOD_SSH])
if darf is not None:
methods = [entry for entry in methods if darf(entry.recht)]
return sorted(methods, key=lambda entry: PREFERENCE.index(entry.key)) return sorted(methods, key=lambda entry: PREFERENCE.index(entry.key))
def preferred(device: Any, *, maintenance_active: bool = True) -> Method | None: def preferred(
device: Any,
*,
maintenance_active: bool = True,
darf: Callable[[str], bool] | None = None,
) -> Method | None:
"""Der Weg, der vorausgewaehlt wird -- oder ``None``, wenn es keinen gibt.""" """Der Weg, der vorausgewaehlt wird -- oder ``None``, wenn es keinen gibt."""
options = available(device, maintenance_active=maintenance_active) options = available(device, maintenance_active=maintenance_active, darf=darf)
return options[0] if options else None return options[0] if options else None
def resolve(device: Any, requested: str, *, maintenance_active: bool = True) -> Method | None: def resolve(
device: Any,
requested: str,
*,
maintenance_active: bool = True,
darf: Callable[[str], bool] | None = None,
) -> Method | None:
"""Prueft eine Anforderung gegen das, was tatsaechlich moeglich ist. """Prueft eine Anforderung gegen das, was tatsaechlich moeglich ist.
Die Oberflaeche darf vorschlagen; entscheiden tut der Server. Ein Weg, der Die Oberflaeche darf vorschlagen; entscheiden tut der Server. Ein Weg, der
fuer dieses Geraet nicht offensteht, wird nicht "irgendwie" ersetzt -- fuer dieses Geraet nicht offensteht, wird nicht "irgendwie" ersetzt --
sonst startet jemand ein Geraet anders neu, als er dachte. sonst startet jemand ein Geraet anders neu, als er dachte.
""" """
options = {entry.key: entry for entry in available(device, maintenance_active=maintenance_active)} options = {
entry.key: entry
for entry in available(device, maintenance_active=maintenance_active, darf=darf)
}
if not options: if not options:
return None return None
if not requested: if not requested:
+95 -1
View File
@@ -38,6 +38,12 @@ ERROR_RE = re.compile(
r"(invalid input|unknown command|incomplete|ambiguous|not supported|% *error)", re.IGNORECASE r"(invalid input|unknown command|incomplete|ambiguous|not supported|% *error)", re.IGNORECASE
) )
#: Der Weg nach draussen -- zweimal "exit" fuehrt aus jedem Modus in den
#: privilegierten zurueck, das dritte beendet die Sitzung. Genau so macht es
#: das bewaehrte Skript der Vorgaengerfassung.
ABMELDUNG = ("exit", "exit", "exit")
ABMELDE_PAUSE = 0.15
READ_CHUNK = 65536 READ_CHUNK = 65536
DEFAULT_COMMAND_TIMEOUT = 20 DEFAULT_COMMAND_TIMEOUT = 20
POE_SETTLE_SECONDS = 2 POE_SETTLE_SECONDS = 2
@@ -104,7 +110,42 @@ class SwitchCli:
self.close() self.close()
def close(self) -> None: def close(self) -> None:
for closer in (self._channel, self._transport): """Meldet sich ab und legt dann erst auf.
Der entscheidende Teil, und der einzige, den das ``expect``-Skript der
Fassung 1.2.8 hatte und diese Fassung nicht: ``exit`` bis heraus aus
dem Konfigurationsmodus, dann ``exit``, und auf die Frage
"Do you want to log out (y/n)?" ein ``y``.
Warum das mehr ist als Hoeflichkeit: ArubaOS erlaubt nur eine Handvoll
gleichzeitiger SSH-Sitzungen (``show ip ssh``, Vorgabe fuenf) und
raeumt eine weggebrochene Verbindung **nicht sofort** ab. Wer die
Verbindung nur fallen laesst, belegt den Platz weiter. Nach ein paar
Vorgaengen -- ein Ueberwachungsdurchlauf mit mehreren Geraeten am
selben Switch reicht -- sind alle Plaetze belegt. Danach kommt eine
neue Sitzung noch durch Anmeldung und Authentisierung, bekommt aber
**keine Shell**: kein Prompt, keine Antwort, nichts. Im Betrieb sieht
das aus wie ein stummer Switch, und der Portzyklus passiert nicht.
Fehler werden hier geschluckt: eine Abmeldung, die nicht klappt, darf
das Ergebnis des Vorgangs nicht umwerfen -- aufgelegt wird ohnehin.
"""
kanal = self._channel
if kanal is not None:
try:
for zeile in ABMELDUNG:
kanal.send(zeile + "\r\n")
# Kurz warten, damit der Switch die Zeile verarbeitet --
# auf die Antwort zu warten wuerde beim Abmelden nur
# Zeitlimits sammeln.
time.sleep(ABMELDE_PAUSE)
# "Do you want to log out (y/n)?"
kanal.send("y\r\n")
time.sleep(ABMELDE_PAUSE)
except Exception: # noqa: BLE001 - die Verbindung ist ohnehin am Ende
logger.debug("Abmeldung von %s nicht mehr moeglich", self.host)
for closer in (kanal, self._transport):
try: try:
if closer is not None: if closer is not None:
closer.close() closer.close()
@@ -116,9 +157,26 @@ class SwitchCli:
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def _read_until_prompt(self, *, timeout: int, initial: bool = False) -> str: def _read_until_prompt(self, *, timeout: int, initial: bool = False) -> str:
"""Liest bis zum Prompt. Ohne Prompt ist es ein Fehler, keine Antwort.
Bis 2.8.2 lief diese Schleife nach dem Zeitlimit einfach aus und gab
zurueck, was bis dahin gekommen war -- oft nichts. Der Aufrufer sah
eine leere Antwort und machte weiter: ``configure terminal`` ins
Nichts, ``no power-over-ethernet`` ins Nichts, und weil die
Zustandsabfrage dann "unknown" ergab und "unknown" als
"nicht widersprochen" durchging, meldete TESM am Ende **Erfolg** --
waehrend am Switch nichts geschaltet wurde. Genau das war im Betrieb
zu sehen: der Client blieb durchgehend erreichbar, das Protokoll sagte
"durchgefuehrt".
Ein Kommando ohne Prompt ist ein Kommando ohne Antwort. Das ist ein
Fehlschlag und wird als solcher gemeldet -- mit dem, was zuletzt
hereinkam, denn daran erkennt man, wo die Sitzung steht.
"""
assert self._channel is not None assert self._channel is not None
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
collected = b"" collected = b""
prompt_gesehen = False
while time.monotonic() < deadline: while time.monotonic() < deadline:
if self._channel.recv_ready(): if self._channel.recv_ready():
chunk = self._channel.recv(READ_CHUNK) chunk = self._channel.recv(READ_CHUNK)
@@ -130,6 +188,7 @@ class SwitchCli:
collected = PAGER_RE.sub(b"", collected) collected = PAGER_RE.sub(b"", collected)
continue continue
if PROMPT_RE.search(collected[-200:]): if PROMPT_RE.search(collected[-200:]):
prompt_gesehen = True
break break
# Der Login-Banner mancher Geraete verlangt eine Bestaetigung. # Der Login-Banner mancher Geraete verlangt eine Bestaetigung.
if initial and collected.rstrip().endswith(b"continue"): if initial and collected.rstrip().endswith(b"continue"):
@@ -138,6 +197,19 @@ class SwitchCli:
time.sleep(0.05) time.sleep(0.05)
text = collected.decode("utf-8", errors="replace") text = collected.decode("utf-8", errors="replace")
self.transcript.append(text) self.transcript.append(text)
if not prompt_gesehen:
letzte = " ".join(text.split())[-200:]
if initial:
raise SshError(
f"{self.host} hat sich nach der Anmeldung nicht gemeldet "
f"(kein Prompt innerhalb von {timeout}s)."
+ (f" Zuletzt kam: {letzte}" if letzte else " Es kam nichts.")
)
raise SshError(
f"{self.host} hat auf das Kommando nicht geantwortet "
f"(kein Prompt innerhalb von {timeout}s)."
+ (f" Zuletzt kam: {letzte}" if letzte else " Es kam nichts.")
)
return text return text
def send( def send(
@@ -212,6 +284,17 @@ class SwitchCli:
) )
result.transcript = self.full_transcript result.transcript = self.full_transcript
return result return result
if before == "enabled" and off_state == "unknown":
# Vorher lesbar, nachher nicht: dann ist nicht die Firmware
# unbekannt, sondern die Sitzung aus dem Tritt. Das als Erfolg
# zu zaehlen war der Fehler, der einen Neustart meldete, den
# es nicht gab.
result.error = (
"Der Zustand des Ports ließ sich nach dem Abschalten nicht mehr "
"lesen – vorher ging es. Der Portzyklus ist damit nicht belegt."
)
result.transcript = self.full_transcript
return result
result.add(f"Zustand im ausgeschalteten Zustand: {off_state}") result.add(f"Zustand im ausgeschalteten Zustand: {off_state}")
self.set_poe(port, enabled=True) self.set_poe(port, enabled=True)
@@ -224,6 +307,17 @@ class SwitchCli:
result.transcript = self.full_transcript result.transcript = self.full_transcript
return result return result
if before == "unknown" and off_state == "unknown" and after == "unknown":
# Kein einziger Zustand lesbar: die Kommandos hat der Switch
# zwar mit einem Prompt bestaetigt (sonst waere die Sitzung
# oben abgebrochen), aber belegt ist der Zyklus nicht. Das
# gehoert in die Meldung -- "durchgefuehrt" waere zu viel
# gesagt.
result.add(
"Der Zustand war nicht lesbar – die Kommandos hat der Switch "
"angenommen, bestätigt ist der Zyklus damit nicht."
)
result.ok = True result.ok = True
except SshError as exc: except SshError as exc:
result.error = str(exc) result.error = str(exc)
+155 -4
View File
@@ -14,6 +14,7 @@ from typing import Any, Callable, Iterable
from tesm_core.db import Database from tesm_core.db import Database
from tesm_core.keystore import Keystore from tesm_core.keystore import Keystore
from tesm_core.rbac.legacy_permissions import umgeschrieben
from tesm_core.transfer import ( from tesm_core.transfer import (
MIN_PASSPHRASE_LENGTH, MIN_PASSPHRASE_LENGTH,
PREVIEW_TTL_MINUTES, PREVIEW_TTL_MINUTES,
@@ -61,6 +62,14 @@ CATEGORIES: tuple[Category, ...] = (
), ),
Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"), Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"),
Category("fileshare", "Freigabe-Zuordnungen", "Verzeichnisgruppe zu UNC-Pfad"), Category("fileshare", "Freigabe-Zuordnungen", "Verzeichnisgruppe zu UNC-Pfad"),
Category(
"directory",
"Verzeichnisdienst",
"LDAP-Anbindung, Bind-Konto und Gruppen-Zuordnungen. Das Passwort des "
"Bind-Kontos nur mit \"Geheimnisse einschliessen\".",
admin_only=True,
sensitive=True,
),
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True), Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True), Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True),
Category("settings", "Einstellungen", "Laufzeiteinstellungen der Anwendung", admin_only=True), Category("settings", "Einstellungen", "Laufzeiteinstellungen der Anwendung", admin_only=True),
@@ -185,6 +194,49 @@ def build_export(
} }
for row in Database.all(conn, "SELECT * FROM groups WHERE deleted_at IS NULL") for row in Database.all(conn, "SELECT * FROM groups WHERE deleted_at IS NULL")
] ]
if "directory" in wanted:
# Die Anbindung an das Verzeichnis war das einzige Stueck Konfiguration,
# das man nach einer Wiederherstellung von Hand nachbauen musste --
# inklusive Bind-Passwort, das dabei jemand aus einem Kennwortspeicher
# holen muss. Es gehoert zur Anlage wie die Netzwerkeinstellung.
from tesm_core.auth import directory as verzeichnis
bind_benutzer, bind_passwort = verzeichnis.bind_credentials(conn)
gesetzt = {
str(row["key"]): str(row["value"])
for row in Database.all(conn, "SELECT key, value FROM settings")
}
payload["directory"] = {
# Nur die Schluessel der Anbindung, und nur die, die wirklich
# gesetzt sind: ein Vorgabewert in der Datei sieht aus wie eine
# Entscheidung und ueberschreibt beim Einlesen eine echte.
"settings": {
spec.key: gesetzt[spec.key] for spec in verzeichnis.SETTINGS
if spec.key in gesetzt
},
"bind_username": bind_benutzer,
# Ohne "Geheimnisse einschliessen" reist das Passwort nicht mit.
# Der Benutzername schon: er ist keine Geheimnis, und ohne ihn
# muesste man die halbe Anbindung doch wieder tippen.
"bind_password": bind_passwort if include_secrets else "",
# Die Zuordnung zeigt auf eine Gruppe **oder** auf das
# Administratorkennzeichen. Ueber eine Systemgrenze hinweg ist eine
# Gruppen-Id nichts wert -- der Name ist es.
"group_mappings": [
{
"ad_group_dn": str(row["ad_group_dn"]),
"ad_group_name": str(row["ad_group_name"]),
"group_name": str(row["group_name"] or ""),
"grants_admin": bool(row["grants_admin"]),
}
for row in Database.all(
conn,
"SELECT m.ad_group_dn, m.ad_group_name, m.grants_admin, g.name AS group_name "
"FROM ldap_group_mappings m "
"LEFT JOIN groups g ON g.id = m.app_group_id AND g.deleted_at IS NULL",
)
],
}
if "settings" in wanted: if "settings" in wanted:
payload["settings"] = { payload["settings"] = {
str(row["key"]): str(row["value"]) str(row["key"]): str(row["value"])
@@ -254,6 +306,9 @@ def apply_import(
""" """
from .inventory import ValidationError, aad_for_credential, normalize_mac from .inventory import ValidationError, aad_for_credential, normalize_mac
#: Merkt, ob das Paket alte Rechteschluessel mitbringt (siehe unten).
alte_fassung: set[bool] = set()
def mac_or_none(value: Any) -> str | None: def mac_or_none(value: Any) -> str | None:
"""MAC normalisieren, ohne den ganzen Import zu sprengen. """MAC normalisieren, ohne den ganzen Import zu sprengen.
@@ -530,19 +585,33 @@ def apply_import(
if "fileshare" in wanted: if "fileshare" in wanted:
for entry in payload.get("fileshare", []): for entry in payload.get("fileshare", []):
gruppe = str(entry.get("ad_group_dn") or "")
pfad = str(entry.get("unc") or "")
if not gruppe or not pfad:
bump("fileshare", "skipped")
continue
# Vorher nachsehen, damit der Bericht die Wahrheit sagt: bisher
# stand dort auch bei einer frischen Installation "2 aktualisiert",
# obwohl zwei Zuordnungen **angelegt** wurden. Wer einen Import
# prueft, liest genau diese Zahlen.
vorhanden = Database.value(
conn,
"SELECT 1 FROM fileshare_mappings WHERE ad_group_dn=? AND unc=? LIMIT 1",
(gruppe, pfad),
)
conn.execute( conn.execute(
"INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) " "INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO UPDATE SET " "VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO UPDATE SET "
"label=excluded.label, ad_group_name=excluded.ad_group_name", "label=excluded.label, ad_group_name=excluded.ad_group_name",
( (
str(entry.get("ad_group_dn") or ""), gruppe,
str(entry.get("ad_group_name") or ""), str(entry.get("ad_group_name") or ""),
str(entry.get("label") or ""), str(entry.get("label") or ""),
str(entry.get("unc") or ""), pfad,
stamp, stamp,
), ),
) )
bump("fileshare", "updated") bump("fileshare", "updated" if vorhanden else "created")
if "groups" in wanted: if "groups" in wanted:
for entry in payload.get("groups", []): for entry in payload.get("groups", []):
@@ -574,9 +643,21 @@ def apply_import(
group_id = int(existing["id"]) group_id = int(existing["id"])
bump("groups", "updated") bump("groups", "updated")
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,)) conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
# Eine Sicherung aus 2.7.x nennt devices.view und devices.edit.
# Ohne die Umschreibung landen lauter unbekannte Schluessel in der
# Gruppe, und sanitize() wirft Unbekanntes still weg: die
# wiederhergestellte Gruppe waere leer, ohne eine Zeile Meldung.
mitgebracht = [str(recht) for recht in entry.get("permissions", []) or []]
neue = umgeschrieben(mitgebracht)
if sorted(set(mitgebracht)) != neue:
# Das Paket kennt die alten Schluessel -- es stammt also von
# vor 2.8.0. Ein Versionsfeld gibt es im Umschlag nicht, aber
# die Schluessel selbst sind das verlaesslichere Kennzeichen:
# sie stehen genau dann drin, wenn die Sicherung aelter ist.
alte_fassung.add(True)
conn.executemany( conn.executemany(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)", "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
[(group_id, str(permission)) for permission in entry.get("permissions", [])], [(group_id, permission) for permission in neue],
) )
if "users" in wanted: if "users" in wanted:
@@ -674,6 +755,63 @@ def apply_import(
) )
bump("signatures", "created") bump("signatures", "created")
if "directory" in wanted:
from tesm_core.auth import directory as verzeichnis
abschnitt = payload.get("directory") or {}
bekannt = {spec.key for spec in verzeichnis.SETTINGS}
for key, value in (abschnitt.get("settings") or {}).items():
if str(key) not in bekannt:
# Ein fremder Schluessel in diesem Abschnitt gehoert nicht hier
# hin; die Kategorie "Einstellungen" nimmt alles andere.
bump("directory", "skipped")
continue
set_setting(str(key), value)
bump("directory", "updated")
benutzer = str(abschnitt.get("bind_username") or "").strip()
passwort = str(abschnitt.get("bind_password") or "")
if benutzer:
# Ohne Passwort bleibt ein vorhandenes stehen -- genau dafuer nimmt
# store_bind_credentials None. Eine Sicherung ohne Geheimnisse darf
# das Passwort nicht loeschen.
verzeichnis.store_bind_credentials(conn, benutzer, passwort or None)
bump("directory", "updated")
for eintrag in abschnitt.get("group_mappings") or []:
dn = str(eintrag.get("ad_group_dn") or "").strip()
if not dn:
bump("directory", "skipped")
continue
kennzeichen = bool(eintrag.get("grants_admin"))
gruppe = None
if not kennzeichen:
name = str(eintrag.get("group_name") or "").strip()
gruppe = Database.value(
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (name,)
)
if gruppe is None:
# Eine Zuordnung auf eine Gruppe, die es hier nicht gibt,
# waere eine Zuordnung ins Leere: beim naechsten Login
# bekaeme das Konto nichts, und niemand wuesste warum.
bump("directory", "skipped")
continue
conn.execute(
"INSERT INTO ldap_group_mappings "
"(ad_group_dn, ad_group_name, app_group_id, grants_admin, created_at) "
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn) DO UPDATE SET "
"ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id, "
"grants_admin=excluded.grants_admin",
(
dn,
str(eintrag.get("ad_group_name") or dn),
int(gruppe) if gruppe is not None else None,
int(kennzeichen),
stamp,
),
)
bump("directory", "created")
if "settings" in wanted: if "settings" in wanted:
for key, value in (payload.get("settings") or {}).items(): for key, value in (payload.get("settings") or {}).items():
if str(key).startswith("license_"): if str(key).startswith("license_"):
@@ -683,4 +821,17 @@ def apply_import(
set_setting(str(key), value) set_setting(str(key), value)
bump("settings", "updated") bump("settings", "updated")
if alte_fassung and "users" in wanted:
# Dieselbe Ueberlegung wie in der Migration: in der Welt, aus der
# diese Sicherung kommt, konnte ein Administrator alles. Ohne die
# Gruppe fehlt ihm nach dem Einlesen die halbe Anwendung -- und er
# haelt das fuer einen Fehler der Wiederherstellung.
from tesm_core.rbac import service as rbac
from ..permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
rbac.funktionsgruppe_fuer_administratoren(
conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF
)
return report return report
@@ -84,7 +84,7 @@
<a class="btn btn--sm btn--ghost" href="{{ url_for('devices.detail', device_id=tile.id) }}"> <a class="btn btn--sm btn--ghost" href="{{ url_for('devices.detail', device_id=tile.id) }}">
Details Details
</a> </a>
{% if current_user.has('devices.execute') and tile.restart_methods %} {% if tile.restart_methods %}
{% set method = tile.restart_methods[0] %} {% set method = tile.restart_methods[0] %}
<form method="post" data-no-guard <form method="post" data-no-guard
action="{{ url_for('devices.restart_device', device_id=tile.id) }}"> action="{{ url_for('devices.restart_device', device_id=tile.id) }}">
@@ -99,7 +99,7 @@
</button> </button>
</form> </form>
{% endif %} {% endif %}
{% if current_user.has('devices.edit') and tile.state == 'disabled' %} {% if current_user.has('devices.write') and tile.state == 'disabled' %}
<form method="post" action="{{ url_for('devices.toggle_active', device_id=tile.id) }}" <form method="post" action="{{ url_for('devices.toggle_active', device_id=tile.id) }}"
data-no-guard> data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -79,7 +79,7 @@ davon abhängt, welche Geräte auf der Wartungsseite erscheinen.', 'info') }}
data-confirm-style="primary">{{ icon('eye', size=15) }}</button> data-confirm-style="primary">{{ icon('eye', size=15) }}</button>
</form> </form>
{% endif %} {% endif %}
{% if current_user.has('credentials.edit') %} {% if current_user.has('credentials.write') %}
<button class="btn btn--sm btn--ghost" type="button" <button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="credential-edit-{{ entry.id }}" aria-label="Bearbeiten"> data-dialog-open="credential-edit-{{ entry.id }}" aria-label="Bearbeiten">
{{ icon('pencil', size=15) }} {{ icon('pencil', size=15) }}
@@ -121,7 +121,7 @@ davon abhängt, welche Geräte auf der Wartungsseite erscheinen.', 'info') }}
</form> </form>
{% endcall %} {% endcall %}
{% endif %} {% endif %}
{% if current_user.has('credentials.edit') %} {% if current_user.has('credentials.write') %}
{% for entry in credentials %} {% for entry in credentials %}
{% call modal('credential-edit-' ~ entry.id, entry.name) %} {% call modal('credential-edit-' ~ entry.id, entry.name) %}
<form method="post" action="{{ url_for('credentials.update', credential_id=entry.id) }}"> <form method="post" action="{{ url_for('credentials.update', credential_id=entry.id) }}">
@@ -11,12 +11,12 @@
{% endblock %} {% endblock %}
{% block topbar_extra %} {% block topbar_extra %}
{% if terminal_enabled and current_user.has('maintenance.execute') and device.credential_id %} {% if terminal_enabled and current_user.has('device_console.execute') and device.credential_id %}
<a class="btn btn--sm" href="{{ url_for('terminal.device_console', device_id=device.id) }}"> <a class="btn btn--sm" href="{{ url_for('terminal.device_console', device_id=device.id) }}">
{{ icon('terminal', size=15) }} Konsole {{ icon('terminal', size=15) }} Konsole
</a> </a>
{% endif %} {% endif %}
{% if current_user.has('devices.execute') and restart_methods %} {% if restart_methods %}
{% for method in restart_methods %} {% for method in restart_methods %}
<form method="post" style="display:inline" <form method="post" style="display:inline"
action="{{ url_for('devices.restart_device', device_id=device.id) }}" data-no-guard> action="{{ url_for('devices.restart_device', device_id=device.id) }}" data-no-guard>
@@ -138,7 +138,7 @@
Ohne freigegebenen Schlüssel verweigern Wartung und Konsole die Verbindung. Ohne freigegebenen Schlüssel verweigern Wartung und Konsole die Verbindung.
</div> </div>
</div> </div>
{% if current_user.has('devices.edit') %} {% if current_user.has('devices.write') %}
<form method="post" action="{{ url_for('devices.probe_host_key', device_id=device.id) }}" <form method="post" action="{{ url_for('devices.probe_host_key', device_id=device.id) }}"
data-no-guard> data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -151,7 +151,7 @@
<div class="card__body stack"> <div class="card__body stack">
{% set probed_type = request.args.get('probed_type') %} {% set probed_type = request.args.get('probed_type') %}
{% set probed_fingerprint = request.args.get('probed_fingerprint') %} {% set probed_fingerprint = request.args.get('probed_fingerprint') %}
{% if probed_type and probed_fingerprint and current_user.has('devices.edit') %} {% if probed_type and probed_fingerprint and current_user.has('devices.write') %}
<div class="alert alert--warning"> <div class="alert alert--warning">
{{ icon('alert', size=18) }} {{ icon('alert', size=18) }}
<div class="alert__body"> <div class="alert__body">
@@ -182,7 +182,7 @@
<dd class="mono fs-xs break-all">{{ key.fingerprint }}</dd> <dd class="mono fs-xs break-all">{{ key.fingerprint }}</dd>
{% endfor %} {% endfor %}
</dl> </dl>
{% if current_user.has('devices.edit') %} {% if current_user.has('devices.write') %}
<form method="post" <form method="post"
action="{{ url_for('devices.forget_host_key', device_id=device.id) }}" action="{{ url_for('devices.forget_host_key', device_id=device.id) }}"
data-no-guard> data-no-guard>
@@ -206,7 +206,7 @@
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Stammdaten</div></div> <div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Stammdaten</div></div>
{% if current_user.has('devices.edit') %} {% if current_user.has('devices.write') %}
<a class="btn btn--sm" href="{{ url_for('devices.overview') }}">Bearbeiten</a> <a class="btn btn--sm" href="{{ url_for('devices.overview') }}">Bearbeiten</a>
{% endif %} {% endif %}
</div> </div>
@@ -232,7 +232,7 @@
</dl> </dl>
{% if device.notes %}<p class="fs-sm muted mt-3">{{ device.notes }}</p>{% endif %} {% if device.notes %}<p class="fs-sm muted mt-3">{{ device.notes }}</p>{% endif %}
</div> </div>
{% if current_user.has('devices.edit') %} {% if current_user.has('devices.write') %}
<div class="card__footer"> <div class="card__footer">
<form method="post" action="{{ url_for('devices.toggle_active', device_id=device.id) }}" data-no-guard> <form method="post" action="{{ url_for('devices.toggle_active', device_id=device.id) }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -213,10 +213,10 @@
{% endif %} {% endif %}
</td> </td>
<td class="actions"> <td class="actions">
{% if current_user.has('devices.execute') %} {% if restart_methods.get(device.id) %}
{{ restart_control(device, restart_methods.get(device.id, []), compact=True) }} {{ restart_control(device, restart_methods.get(device.id, []), compact=True) }}
{% endif %} {% endif %}
{% if current_user.has('devices.edit') %} {% if current_user.has('devices.write') %}
<button class="btn btn--sm btn--ghost" type="button" <button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="device-edit-{{ device.id }}" aria-label="Bearbeiten"> data-dialog-open="device-edit-{{ device.id }}" aria-label="Bearbeiten">
{{ icon('pencil', size=15) }} {{ icon('pencil', size=15) }}
@@ -263,7 +263,7 @@ nicht -- damit kein Neustart vorgetäuscht wird, der nichts bewirkt.', 'info') }
{% endcall %} {% endcall %}
{% endif %} {% endif %}
{% if current_user.has('devices.edit') %} {% if current_user.has('devices.write') %}
{% for device in devices %} {% for device in devices %}
{% call modal('device-edit-' ~ device.id, device.name, device.mac, 'wide') %} {% call modal('device-edit-' ~ device.id, device.name, device.mac, 'wide') %}
<form method="post" action="{{ url_for('devices.update', device_id=device.id) }}"> <form method="post" action="{{ url_for('devices.update', device_id=device.id) }}">
+11 -11
View File
@@ -27,7 +27,7 @@
<div class="stat"> <div class="stat">
<span class="stat__label">Reservierungen</span> <span class="stat__label">Reservierungen</span>
<span class="stat__value">{{ reservations | length }}</span> <span class="stat__value">{{ reservations | length }}</span>
<span class="stat__hint">automatisch + manüll</span> <span class="stat__hint">automatisch + manuell</span>
</div> </div>
<div class="stat"> <div class="stat">
<span class="stat__label">Leases</span> <span class="stat__label">Leases</span>
@@ -39,7 +39,7 @@
</div> </div>
</div> </div>
{% if current_user.has('dhcp.execute') %} {% if current_user.has('dhcp_service.execute') %}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
@@ -89,7 +89,7 @@
<td class="mono fs-sm">{{ subnet.dns or '&ndash;' | safe }}</td> <td class="mono fs-sm">{{ subnet.dns or '&ndash;' | safe }}</td>
<td>{% if subnet.enabled %}{{ pill('aktiv', 'online') }}{% else %}{{ pill('pausiert', '') }}{% endif %}</td> <td>{% if subnet.enabled %}{{ pill('aktiv', 'online') }}{% else %}{{ pill('pausiert', '') }}{% endif %}</td>
<td class="actions"> <td class="actions">
{% if current_user.has('dhcp.edit') %} {% if current_user.has('dhcp_subnets.write') %}
<form method="post" action="{{ url_for('dhcp.delete_subnet', subnet_id=subnet.id) }}" <form method="post" action="{{ url_for('dhcp.delete_subnet', subnet_id=subnet.id) }}"
style="display:inline" data-no-guard> style="display:inline" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -109,7 +109,7 @@
{{ empty_state('Kein Subnetz', 'Ohne Subnetz startet Kea nicht.', 'network') }} {{ empty_state('Kein Subnetz', 'Ohne Subnetz startet Kea nicht.', 'network') }}
{% endif %} {% endif %}
{% if current_user.has('dhcp.edit') %} {% if current_user.has('dhcp_subnets.create') %}
<form method="post" action="{{ url_for('dhcp.add_subnet') }}"> <form method="post" action="{{ url_for('dhcp.add_subnet') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body"> <div class="card__body">
@@ -135,7 +135,7 @@
</section> </section>
{# -- Globale Einstellungen --------------------------------------------- #} {# -- Globale Einstellungen --------------------------------------------- #}
{% if current_user.has('dhcp.edit') %} {% if current_user.has('dhcp_subnets.write') %}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('sliders', size=17) }} Globale Einstellungen</div></div> <div class="flex-1"><div class="card__title">{{ icon('sliders', size=17) }} Globale Einstellungen</div></div>
@@ -183,7 +183,7 @@
</td> </td>
<td class="fs-xs mono">{{ option.type }}</td> <td class="fs-xs mono">{{ option.type }}</td>
<td> <td>
{% if current_user.has('dhcp.edit') %} {% if current_user.has('dhcp_options.write') %}
<form method="post" action="{{ url_for('dhcp.set_option_value') }}" class="row row--tight" <form method="post" action="{{ url_for('dhcp.set_option_value') }}" class="row row--tight"
data-no-guard> data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -197,7 +197,7 @@
{% endif %} {% endif %}
</td> </td>
<td class="actions"> <td class="actions">
{% if current_user.has('dhcp.edit') and not option.is_standard %} {% if current_user.has('dhcp_options.write') and not option.is_standard %}
<form method="post" action="{{ url_for('dhcp.delete_custom_option', option_id=option.id) }}" <form method="post" action="{{ url_for('dhcp.delete_custom_option', option_id=option.id) }}"
data-no-guard> data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -213,7 +213,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% if current_user.has('dhcp.edit') %} {% if current_user.has('dhcp_options.write') %}
<form method="post" action="{{ url_for('dhcp.add_custom_option') }}"> <form method="post" action="{{ url_for('dhcp.add_custom_option') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body"> <div class="card__body">
@@ -339,7 +339,7 @@
{% endif %} {% endif %}
</td> </td>
<td class="actions"> <td class="actions">
{% if reservation.source == 'manüll' and current_user.has('dhcp.edit') %} {% if reservation.source == 'manuell' and current_user.has('dhcp_reservations.delete') %}
<form method="post" data-no-guard <form method="post" data-no-guard
action="{{ url_for('dhcp.delete_reservation', reservation_id=reservation.reservation_id) }}"> action="{{ url_for('dhcp.delete_reservation', reservation_id=reservation.reservation_id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -355,7 +355,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% if current_user.has('dhcp.edit') %} {% if current_user.has('dhcp_reservations.create') %}
<form method="post" action="{{ url_for('dhcp.add_reservation') }}"> <form method="post" action="{{ url_for('dhcp.add_reservation') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body"> <div class="card__body">
@@ -434,7 +434,7 @@
{{ icon('chevron-down', size=16) }} {{ icon('chevron-down', size=16) }}
</summary> </summary>
<pre class="log-view">{{ preview }}</pre> <pre class="log-view">{{ preview }}</pre>
{% if current_user.has('dhcp.edit') %} {% if current_user.has('dhcp_config.execute') %}
<div class="card__footer"> <div class="card__footer">
<form method="post" action="{{ url_for('dhcp.write_config') }}" data-no-guard> <form method="post" action="{{ url_for('dhcp.write_config') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -5,7 +5,7 @@
{% block page_sub %}{{ mounts | length }} eingebundene Freigabe(n){% endblock %} {% block page_sub %}{{ mounts | length }} eingebundene Freigabe(n){% endblock %}
{% block topbar_extra %} {% block topbar_extra %}
{% if current_user.has('fileshare.edit') %} {% if current_user.has('fileshare.write') %}
<a class="btn btn--sm" href="{{ url_for('fileshare.mappings') }}"> <a class="btn btn--sm" href="{{ url_for('fileshare.mappings') }}">
{{ icon('settings', size=15) }} Zuordnungen {{ icon('settings', size=15) }} Zuordnungen
</a> </a>
@@ -152,7 +152,7 @@
{% if current_user.has('fileshare.export') %}data-fill-Download="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"{% endif %} {% if current_user.has('fileshare.export') %}data-fill-Download="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"{% endif %}
aria-label="Vorschau">{{ icon('eye', size=15) }}</button> aria-label="Vorschau">{{ icon('eye', size=15) }}</button>
{% endif %} {% endif %}
{% if current_user.has('fileshare.edit') %} {% if current_user.has('fileshare.write') %}
<button class="btn btn--sm btn--ghost" type="button" <button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="file-rename" data-dialog-open="file-rename"
data-fill-Pfad="{{ entry.relative }}" data-fill-Pfad="{{ entry.relative }}"
@@ -237,7 +237,7 @@
<div class="modal__body modal__body--flush" data-viewer <div class="modal__body modal__body--flush" data-viewer
{# Ohne das Recht zum Ausfuellen zeigt die Vorschau das Dokument so, {# Ohne das Recht zum Ausfuellen zeigt die Vorschau das Dokument so,
wie es ist -- mit seinen eigenen Marken, ohne Eingabefelder. #} wie es ist -- mit seinen eigenen Marken, ohne Eingabefelder. #}
data-viewer-formulare="{{ '1' if current_user.has('fileshare_pdf.view') else '' }}" data-viewer-formulare="{{ '1' if current_user.has('fileshare_pdf.read') else '' }}"
data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/pdf.min.js') }}" data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/pdf.min.js') }}"
data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}" data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}"
data-viewer-konto="{{ current_user.username }}" data-viewer-konto="{{ current_user.username }}"
@@ -330,7 +330,7 @@
PDF-Formulare/Bearbeiten, "abgeschlossen" an Anlegen. Wer nur PDF-Formulare/Bearbeiten, "abgeschlossen" an Anlegen. Wer nur
anlegen darf, sieht eine Zeile statt einer Auswahl. #} anlegen darf, sieht eine Zeile statt einer Auswahl. #}
<select class="select" data-viewer-modus> <select class="select" data-viewer-modus>
{% if current_user.has('fileshare_pdf.edit') %} {% if current_user.has('fileshare_pdf.write') %}
<option value="formular">Formular – weiter ausfüllbar</option> <option value="formular">Formular – weiter ausfüllbar</option>
{% endif %} {% endif %}
<option value="fertig">Abgeschlossen – Felder gesperrt</option> <option value="fertig">Abgeschlossen – Felder gesperrt</option>
@@ -374,10 +374,16 @@
{{ icon('download', size=15) }} Herunterladen {{ icon('download', size=15) }} Herunterladen
</a> </a>
{% endif %} {% endif %}
{% if current_user.has('fileshare_pdf.create') %} {# Unterschreiben ist eine eigene Zeile der Matrix: wer ausgefuellte
Formulare ablegen darf, darf damit nicht unterschreiben. Der Server
weist es ohnehin ab -- aber ein Knopf, der immer in eine Meldung
laeuft, ist keiner. #}
{% if current_user.has('fileshare_signature.execute') %}
<button class="btn btn--sm" type="button" data-viewer-unterschreiben hidden> <button class="btn btn--sm" type="button" data-viewer-unterschreiben hidden>
{{ icon('pencil', size=15) }} Unterschreiben {{ icon('pencil', size=15) }} Unterschreiben
</button> </button>
{% endif %}
{% if current_user.has('fileshare_pdf.create') %}
<button class="btn btn--sm" type="button" data-viewer-speichern hidden <button class="btn btn--sm" type="button" data-viewer-speichern hidden
data-url="{{ url_for('fileshare.save_form', token=token) }}"> data-url="{{ url_for('fileshare.save_form', token=token) }}">
{{ icon('save', size=15) }} Ausgefüllt speichern {{ icon('save', size=15) }} Ausgefüllt speichern
@@ -388,7 +394,7 @@
{% endcall %} {% endcall %}
{% endif %} {% endif %}
{% if listing and current_user.has('fileshare.edit') %} {% if listing and current_user.has('fileshare.write') %}
{% call modal('file-rename', 'Umbenennen', '', 'narrow') %} {% call modal('file-rename', 'Umbenennen', '', 'narrow') %}
<form method="post" action="{{ url_for('fileshare.rename', token=token) }}"> <form method="post" action="{{ url_for('fileshare.rename', token=token) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -33,7 +33,7 @@ Einbindungen.', 'info') }}
<td class="fs-sm">{{ entry.ad_group_name }} <td class="fs-sm">{{ entry.ad_group_name }}
<div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td> <div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td>
<td class="actions"> <td class="actions">
{% if current_user.has('fileshare.edit') %} {% if current_user.has('fileshare.write') %}
<button class="btn btn--sm btn--ghost" type="button" <button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="mapping-edit-{{ entry.id }}" data-dialog-open="mapping-edit-{{ entry.id }}"
aria-label="Bearbeiten">{{ icon('pencil', size=15) }}</button> aria-label="Bearbeiten">{{ icon('pencil', size=15) }}</button>
@@ -80,7 +80,7 @@ Einbindungen.', 'info') }}
{# Bearbeiten statt neu anlegen: bei einem Tippfehler im UNC-Pfad oder im {# Bearbeiten statt neu anlegen: bei einem Tippfehler im UNC-Pfad oder im
Anzeigenamen soll man ihn korrigieren können. Sonst legt man daneben einen Anzeigenamen soll man ihn korrigieren können. Sonst legt man daneben einen
zweiten Eintrag an und vergisst, den ersten zu löschen. #} zweiten Eintrag an und vergisst, den ersten zu löschen. #}
{% if current_user.has('fileshare.edit') %} {% if current_user.has('fileshare.write') %}
{% for entry in mappings %} {% for entry in mappings %}
{% call modal('mapping-edit-' ~ entry.id, entry.label, 'Zuordnung ändern') %} {% call modal('mapping-edit-' ~ entry.id, entry.label, 'Zuordnung ändern') %}
<form method="post" action="{{ url_for('fileshare.mapping_update', mapping_id=entry.id) }}"> <form method="post" action="{{ url_for('fileshare.mapping_update', mapping_id=entry.id) }}">
@@ -160,7 +160,7 @@ wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff au
<td class="mono fs-xs break-all">{{ row.fingerprint }}</td> <td class="mono fs-xs break-all">{{ row.fingerprint }}</td>
<td class="fs-sm">{{ row.added_at | dt }} <span class="faint">von {{ row.added_by }}</span></td> <td class="fs-sm">{{ row.added_at | dt }} <span class="faint">von {{ row.added_by }}</span></td>
<td> <td>
{% if current_user.has('devices.edit') or current_user.has('switches.edit') %} {% if current_user.has('devices.write') or current_user.has('switches.write') %}
<form method="post" action="{{ url_for('hostkeys.forget_orphan') }}" data-no-guard> <form method="post" action="{{ url_for('hostkeys.forget_orphan') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="host" value="{{ row.host }}"> <input type="hidden" name="host" value="{{ row.host }}">
@@ -76,7 +76,7 @@
</dd> </dd>
</dl> </dl>
</div> </div>
{% if current_user.has('settings_license.edit') %} {% if current_user.has('settings_license.write') %}
<div class="card__footer"> <div class="card__footer">
{% if not state.activated %} {% if not state.activated %}
<form method="post" action="{{ url_for('license.activate') }}" data-no-guard> <form method="post" action="{{ url_for('license.activate') }}" data-no-guard>
@@ -155,7 +155,7 @@
</section> </section>
{% endif %} {% endif %}
{% if current_user.has('settings_license.edit') %} {% if current_user.has('settings_license.write') %}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
@@ -190,7 +190,7 @@
</section> </section>
{% endif %} {% endif %}
{% if current_user.has('settings_license.edit') %} {% if current_user.has('settings_license.write') %}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
@@ -5,7 +5,7 @@
{% block topbar_extra %}{{ search_box('restart-table', 'Gerät, Ergebnis, Methode ...') }}{% endblock %} {% block topbar_extra %}{{ search_box('restart-table', 'Gerät, Ergebnis, Methode ...') }}{% endblock %}
{% block content %} {% block content %}
{{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Auslöser festgehalten -- automatisch {{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Auslöser festgehalten -- automatisch
wie manüll, über den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung. wie manuell, über den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung.
Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }} Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
<section class="card"> <section class="card">
@@ -54,7 +54,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<span>Alle auswählen</span> <span>Alle auswählen</span>
</label> </label>
<span class="table-toolbar__spacer"></span> <span class="table-toolbar__spacer"></span>
{% if current_user.has('maintenance.execute') %} {% if current_user.has('servicing_update.execute') %}
<button class="btn btn--sm btn--primary" type="submit" <button class="btn btn--sm btn--primary" type="submit"
formaction="{{ url_for('maintenance.start_update') }}" formaction="{{ url_for('maintenance.start_update') }}"
title="Nur für Linux-Clients -- Windows-Geräte werden dabei übersprungen." title="Nur für Linux-Clients -- Windows-Geräte werden dabei übersprungen."
@@ -91,7 +91,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<label class="check"> <label class="check">
<input type="checkbox" name="device_ids" value="{{ device.id }}" <input type="checkbox" name="device_ids" value="{{ device.id }}"
class="device-select" data-category="{{ device.category }}" class="device-select" data-category="{{ device.category }}"
{% if not current_user.has('maintenance.execute') %}disabled{% endif %}> {% if not current_user.has('servicing_update.execute') %}disabled{% endif %}>
<span class="sr-only">{{ device.name }} auswählen</span> <span class="sr-only">{{ device.name }} auswählen</span>
</label> </label>
</td> </td>
@@ -121,7 +121,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
</td> </td>
<td class="nowrap"> <td class="nowrap">
{% if terminal_enabled and device.ssh_managed {% if terminal_enabled and device.ssh_managed
and current_user.has('maintenance.execute') %} and current_user.has('servicing_update.execute') %}
<a class="btn btn--sm btn--ghost" <a class="btn btn--sm btn--ghost"
href="{{ url_for('terminal.device_console', device_id=device.id) }}"> href="{{ url_for('terminal.device_console', device_id=device.id) }}">
{{ icon('terminal', size=14) }} Konsole {{ icon('terminal', size=14) }} Konsole
@@ -41,7 +41,7 @@
</div> </div>
{{ checkbox('monitor_auto_restart', 'Automatischen PoE-Neustart bei Ausfall aktivieren', {{ checkbox('monitor_auto_restart', 'Automatischen PoE-Neustart bei Ausfall aktivieren',
checked=values.monitor_auto_restart, checked=values.monitor_auto_restart,
hint_text='Zusätzlich lizenzpflichtig. Der manülle Knopf im Dashboard hint_text='Zusätzlich lizenzpflichtig. Der manuelle Knopf im Dashboard
funktioniert unabhängig davon.') }} funktioniert unabhängig davon.') }}
{{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar', {{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
checked=values.dashboard_public, checked=values.dashboard_public,
@@ -11,7 +11,7 @@
{% endblock %} {% endblock %}
{% block topbar_extra %} {% block topbar_extra %}
{% if terminal_enabled and current_user.has('switches.execute') and host_keys %} {% if terminal_enabled and current_user.has('switch_console.execute') and host_keys %}
<a class="btn btn--sm btn--primary" href="{{ url_for('terminal.switch_console', switch_id=switch.id) }}"> <a class="btn btn--sm btn--primary" href="{{ url_for('terminal.switch_console', switch_id=switch.id) }}">
{{ icon('terminal', size=15) }} Konsole {{ icon('terminal', size=15) }} Konsole
</a> </a>
@@ -32,7 +32,7 @@
Ohne freigegebenen Schlüssel verweigert die Anwendung jeden Zugriff auf diesen Switch. Ohne freigegebenen Schlüssel verweigert die Anwendung jeden Zugriff auf diesen Switch.
</div> </div>
</div> </div>
{% if current_user.has('switches.edit') %} {% if current_user.has('switches.write') %}
<form method="post" action="{{ url_for('switches.probe_host_key', switch_id=switch.id) }}" <form method="post" action="{{ url_for('switches.probe_host_key', switch_id=switch.id) }}"
data-no-guard> data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -53,7 +53,7 @@
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle (Konsolenzugang am Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle (Konsolenzugang am
Switch, Inventardokumentation), bevor Sie ihn freigeben. Switch, Inventardokumentation), bevor Sie ihn freigeben.
</p> </p>
{% if current_user.has('switches.edit') %} {% if current_user.has('switches.write') %}
<form method="post" class="mt-2" <form method="post" class="mt-2"
action="{{ url_for('switches.trust_host_key', switch_id=switch.id) }}"> action="{{ url_for('switches.trust_host_key', switch_id=switch.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -78,7 +78,7 @@
</dd> </dd>
{% endfor %} {% endfor %}
</dl> </dl>
{% if current_user.has('switches.edit') %} {% if current_user.has('switches.write') %}
<form method="post" action="{{ url_for('switches.forget_host_key', switch_id=switch.id) }}" <form method="post" action="{{ url_for('switches.forget_host_key', switch_id=switch.id) }}"
data-no-guard> data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -95,7 +95,7 @@
</div> </div>
</section> </section>
{% if current_user.has('switches.execute') %} {% if current_user.has('switch_console.execute') %}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
@@ -83,13 +83,13 @@ stillschweigend übernommen.', 'info', 'Host-Schlüssel') }}
{% endif %} {% endif %}
</td> </td>
<td class="actions"> <td class="actions">
{% if terminal_enabled and current_user.has('switches.execute') and switch.host_key_known %} {% if terminal_enabled and current_user.has('switch_console.execute') and switch.host_key_known %}
<a class="btn btn--sm btn--ghost" title="Konsole" <a class="btn btn--sm btn--ghost" title="Konsole"
href="{{ url_for('terminal.switch_console', switch_id=switch.id) }}"> href="{{ url_for('terminal.switch_console', switch_id=switch.id) }}">
{{ icon('terminal', size=15) }} {{ icon('terminal', size=15) }}
</a> </a>
{% endif %} {% endif %}
{% if current_user.has('switches.edit') %} {% if current_user.has('switches.write') %}
<button class="btn btn--sm btn--ghost" type="button" <button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="switch-edit-{{ switch.id }}" aria-label="Bearbeiten"> data-dialog-open="switch-edit-{{ switch.id }}" aria-label="Bearbeiten">
{{ icon('pencil', size=15) }} {{ icon('pencil', size=15) }}
@@ -135,7 +135,7 @@ stillschweigend übernommen.', 'info', 'Host-Schlüssel') }}
</form> </form>
{% endcall %} {% endcall %}
{% endif %} {% endif %}
{% if current_user.has('switches.edit') %} {% if current_user.has('switches.write') %}
{% for switch in switches %} {% for switch in switches %}
{% call modal('switch-edit-' ~ switch.id, switch.hostname, switch.ip) %} {% call modal('switch-edit-' ~ switch.id, switch.hostname, switch.ip) %}
<form method="post" action="{{ url_for('switches.update', switch_id=switch.id) }}"> <form method="post" action="{{ url_for('switches.update', switch_id=switch.id) }}">
+5
View File
@@ -11,6 +11,7 @@ Was geschützt wird, womit, und wo bewusst Grenzen gezogen sind.
| Speicherung | Argon2id (argon2-cffi) | | Speicherung | Argon2id (argon2-cffi) |
| Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt | | Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
| Zweiter Faktor | TOTP, optional erzwingbar für Administratoren | | Zweiter Faktor | TOTP, optional erzwingbar für Administratoren |
| Rechte | Eine Zeile je Funktion; das Administratorkennzeichen gibt den Verwaltungsbereich, nicht alles |
| Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen | | Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
| Sperre je Adresse | zusätzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst lässt sich die Kontosperre umgehen, indem man viele Konten je einmal probiert | | Sperre je Adresse | zusätzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst lässt sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
| Verzeichnisdienst | LDAP nur über TLS, Zertifikat wird geprüft. Ohne gültiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet | | Verzeichnisdienst | LDAP nur über TLS, Zertifikat wird geprüft. Ohne gültiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
@@ -171,6 +172,10 @@ braucht der Dienst kein `--workers 1` mehr.
* **Kein CSRF-Freibrief für cookie-autorisierte Routen.** * **Kein CSRF-Freibrief für cookie-autorisierte Routen.**
* **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend". * **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend".
* **Keine Rechteausweitung**, auch nicht für Administratoren untereinander. * **Keine Rechteausweitung**, auch nicht für Administratoren untereinander.
Ein echter Administrator darf sich Funktionsrechte über eine Gruppe geben --
das ist gewollt und hinterlässt eine Zeile im Änderungsprotokoll. Was es
nicht gibt, ist der stille Weg: ein Kennzeichen, das ohne Eintrag alles
aufschließt.
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung * **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
nicht brechen. nicht brechen.
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt. * **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" name = "tesm-core"
version = "2.7.8" version = "2.8.1"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
@@ -423,18 +423,24 @@ def _apply_group_mappings(
zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping
haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg. haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg.
""" """
if not group_dns:
return
mappings = Database.all( mappings = Database.all(
conn, "SELECT ad_group_dn, app_group_id FROM ldap_group_mappings" conn, "SELECT ad_group_dn, app_group_id, grants_admin FROM ldap_group_mappings"
) )
if not mappings: if not mappings:
return return
mapped_group_ids = {int(row["app_group_id"]) for row in mappings}
lowered = {dn.lower() for dn in group_dns} lowered = {dn.lower() for dn in group_dns}
# Das Kennzeichen zuerst -- es haengt an keiner Gruppe und muss auch dann
# ausgewertet werden, wenn das Konto in keiner zugeordneten Gruppe steht.
_kennzeichen_setzen(conn, user_id, mappings, lowered)
if not group_dns:
return
gruppenzuordnungen = [row for row in mappings if row["app_group_id"] is not None]
if not gruppenzuordnungen:
return
mapped_group_ids = {int(row["app_group_id"]) for row in gruppenzuordnungen}
wanted = { wanted = {
int(row["app_group_id"]) int(row["app_group_id"])
for row in mappings for row in gruppenzuordnungen
if str(row["ad_group_dn"]).lower() in lowered if str(row["ad_group_dn"]).lower() in lowered
} }
@@ -452,6 +458,71 @@ def _apply_group_mappings(
) )
def _kennzeichen_setzen(
conn: sqlite3.Connection,
user_id: int,
mappings: list[dict[str, Any]],
gruppen: set[str],
) -> None:
"""Setzt oder entzieht das Administratorkennzeichen nach dem Verzeichnis.
Wirkung wie bei Gruppen: **ersetzend**. Wer aus der zugeordneten
AD-Gruppe herausfaellt, verliert das Kennzeichen bei der naechsten
Anmeldung -- sonst waere ein Austritt im Verzeichnis eine Aenderung, die
hier nie ankommt.
Zwei Grenzen, beide absichtlich:
* Nur **Verzeichniskonten**. Ein lokales Konto (etwa das, das
``tesm-admin create-admin`` anlegt) bleibt unberuehrt -- es ist der
Notausgang, wenn das Verzeichnis nicht erreichbar ist.
* Nie den letzten entsperrten Administrator herunterstufen. Ein
Tippfehler im DN wuerde sonst beim naechsten Login die Anlage
verschliessen, und niemand koennte ihn korrigieren.
"""
zuordnungen = [row for row in mappings if int(row["grants_admin"] or 0) == 1]
if not zuordnungen:
return
soll = any(str(row["ad_group_dn"]).lower() in gruppen for row in zuordnungen)
zeile = Database.one(
conn, "SELECT is_admin, auth_source FROM users WHERE id=?", (user_id,)
)
if zeile is None or str(zeile["auth_source"]) != "ldap":
return
ist = bool(zeile["is_admin"])
if ist == soll:
return
if not soll:
uebrig = Database.value(
conn,
"SELECT COUNT(*) FROM users WHERE is_admin=1 AND is_locked=0 "
"AND deleted_at IS NULL AND id<>?",
(user_id,),
default=0,
)
if not uebrig:
logger.warning(
"Administratorkennzeichen bleibt bei Benutzer %s: es waere das letzte.",
user_id,
)
return
conn.execute(
"UPDATE users SET is_admin=?, updated_at=? WHERE id=?", (int(soll), _now(), user_id)
)
audit.log(
conn,
actor="system",
action="auth.admin_flag_changed",
target=str(user_id),
detail=(
"Administrator: "
+ ("nein -> ja" if soll else "ja -> nein")
+ " (aus der Verzeichniszuordnung)"
),
severity="warning",
)
def _user_group_ids(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]: def _user_group_ids(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]:
return Database.all(conn, "SELECT group_id AS id FROM user_groups WHERE user_id=?", (user_id,)) return Database.all(conn, "SELECT group_id AS id FROM user_groups WHERE user_id=?", (user_id,))
@@ -77,25 +77,40 @@ class User:
@property @property
def effective_permissions(self) -> frozenset[str]: def effective_permissions(self) -> frozenset[str]:
"""Rechte nach Anwendung der Bereichs-Kill-Switches.""" """Rechte nach Anwendung der Bereichs-Kill-Switches.
Ein echter Administrator hat den Verwaltungsbereich immer dabei -- auch
ohne Gruppe und ohne dass jemand ihn angehakt haette. Sonst koennte
sich das letzte Administratorkonto aus der eigenen Anlage aussperren,
indem es seine Gruppe verliert.
"""
if self._effective is None: if self._effective is None:
tree = self._tree tree = self._tree
self._effective = ( if tree is None:
frozenset(tree.effective(self.granted)) if tree is not None else frozenset() self._effective = frozenset()
) else:
eigene = set(tree.effective(self.granted))
if self.is_admin:
eigene |= set(tree.verwaltungsrechte())
self._effective = frozenset(eigene)
return self._effective return self._effective
def has(self, *permissions: str) -> bool: def has(self, *permissions: str) -> bool:
"""True, wenn **alle** angegebenen Rechte greifen. Admins duerfen immer alles.""" """True, wenn **alle** angegebenen Rechte greifen.
if self.is_admin:
return True Das Administratorkennzeichen gibt seit 2.8.0 **den Verwaltungsbereich**
frei und nicht mehr jede Pruefung: Benutzer, Gruppen, Einstellungen,
Sicherung, Papierkorb, Diagnose. Funktionsrechte -- Geraete, Neustarts,
DHCP, Freigaben -- kommen ueber Gruppen, und die darf sich ein
Administrator selbst zuweisen. Der Unterschied ist nicht, *ob* er sie
bekommen kann, sondern dass es dann eine Zeile im
Aenderungsprotokoll gibt und in der Matrix zu sehen ist.
"""
if not permissions: if not permissions:
return True return True
return all(permission in self.effective_permissions for permission in permissions) return all(permission in self.effective_permissions for permission in permissions)
def has_any(self, permissions: Iterable[str]) -> bool: def has_any(self, permissions: Iterable[str]) -> bool:
if self.is_admin:
return True
effective = self.effective_permissions effective = self.effective_permissions
return any(permission in effective for permission in permissions) return any(permission in effective for permission in permissions)
+144 -2
View File
@@ -141,11 +141,16 @@ CREATE TABLE IF NOT EXISTS service_accounts (
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
-- Eine Verzeichnisgruppe zeigt entweder auf eine lokale Gruppe **oder** auf
-- das Administratorkennzeichen (grants_admin). Darum ist app_group_id
-- nullbar: "Domänen-Admins" soll echte Administratoren erzeugen koennen und
-- nicht nur Mitglieder der Gruppe "Administration".
CREATE TABLE IF NOT EXISTS ldap_group_mappings ( CREATE TABLE IF NOT EXISTS ldap_group_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
ad_group_dn TEXT NOT NULL UNIQUE, ad_group_dn TEXT NOT NULL UNIQUE,
ad_group_name TEXT NOT NULL, ad_group_name TEXT NOT NULL,
app_group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, app_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
grants_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
""" """
@@ -186,8 +191,145 @@ CREATE INDEX IF NOT EXISTS idx_jobs_kind_state ON jobs(kind, state);
CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at); CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
""" """
def benenne_rechte_um(conn) -> None: # type: ignore[no-untyped-def]
"""Schreibt bestehende Gruppenrechte auf das neue Vokabular um.
``devices.view`` wird ``devices.read``, ``devices.edit`` wird
``devices.write``. Ohne diesen Schritt stehen in den Gruppen nur noch
Schluessel, die der Rechtebaum nicht kennt -- und
:meth:`PermissionTree.sanitize` wirft Unbekanntes **still** weg. Nach dem
Update waeren alle Gruppen leer und niemand kaeme mehr an seine Seiten:
kein Fehler, den man sieht, sondern einer, den man erst merkt, wenn jemand
anruft.
Im Kern und nicht in den Anwendungen, weil die Tabelle beiden gehoert.
Idempotent: was schon neu heisst, wird nicht angefasst.
"""
from .rbac.legacy_permissions import UMBENANNTE_AKTIONEN
for alt, neu in UMBENANNTE_AKTIONEN.items():
# Erst die Dubletten weg, die beim Umschreiben entstehen wuerden: haette
# eine Gruppe (aus welchem Grund auch immer) beide Schluessel, brach der
# eindeutige Index. Lieber vorher aufraeumen als in der Migration
# scheitern.
conn.execute(
"DELETE FROM group_permissions WHERE permission LIKE ? AND EXISTS ("
" SELECT 1 FROM group_permissions AS andere"
" WHERE andere.group_id = group_permissions.group_id"
" AND andere.permission = substr(group_permissions.permission, 1,"
" length(group_permissions.permission) - ?) || ?)",
(f"%.{alt}", len(alt), neu),
)
conn.execute(
"UPDATE group_permissions"
" SET permission = substr(permission, 1, length(permission) - ?) || ?"
" WHERE permission LIKE ?",
(len(alt), neu, f"%.{alt}"),
)
def zuordnung_auf_kennzeichen(conn) -> None: # type: ignore[no-untyped-def]
"""Erlaubt einer Verzeichnisgruppe, das Administratorkennzeichen zu setzen.
Bisher konnte eine Zuordnung nur auf eine lokale Gruppe zeigen. "Domänen-
Admins" landete deshalb in der Gruppe *Administration* -- und wurde damit
gerade **kein** echter Administrator: die Gruppe pflegt Benutzer und
Rechte, hat aber kein Kennzeichen und kann sich keines geben.
Zwei Aenderungen an der Tabelle, und SQLite kann Spalten nicht
umdefinieren: also neu anlegen und umkopieren. ``app_group_id`` wird
nullbar (eine Zuordnung auf das Kennzeichen zeigt auf keine Gruppe), und
``grants_admin`` sagt, dass genau das gemeint ist.
"""
spalten = {
str(zeile[1]) for zeile in conn.execute("PRAGMA table_info(ldap_group_mappings)")
}
if "grants_admin" in spalten:
return
conn.execute(
"""
CREATE TABLE ldap_group_mappings_neu (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ad_group_dn TEXT NOT NULL UNIQUE,
ad_group_name TEXT NOT NULL,
app_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
grants_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)
"""
)
conn.execute(
"INSERT INTO ldap_group_mappings_neu "
"(id, ad_group_dn, ad_group_name, app_group_id, grants_admin, created_at) "
"SELECT id, ad_group_dn, ad_group_name, app_group_id, 0, created_at "
" FROM ldap_group_mappings"
)
conn.execute("DROP TABLE ldap_group_mappings")
conn.execute("ALTER TABLE ldap_group_mappings_neu RENAME TO ldap_group_mappings")
def teile_rechte_auf(conn) -> None: # type: ignore[no-untyped-def]
"""Verteilt ein altes Sammelrecht auf die Zeilen, die daraus wurden.
Additiv: wer ``dhcp.edit`` hatte, bekommt alle fuenf DHCP-Zeilen, und das
alte Recht verschwindet. Ohne diesen Schritt stuende in der Gruppe ein
Schluessel, den der Baum nicht kennt -- und weg waere er still, denn genau
das tut :meth:`PermissionTree.sanitize` mit Unbekanntem.
Laeuft nach ``benenne_rechte_um``: die Tabelle nennt beide Schreibweisen,
wo es darauf ankommt, und ein zweiter Lauf findet nichts mehr zu tun.
"""
from .rbac.legacy_permissions import (
ERWEITERTE_RECHTE,
GETEILTE_RECHTE,
neue_schluessel,
)
def gruppen_mit(recht: str) -> list[int]:
return [
int(zeile[0])
for zeile in conn.execute(
"SELECT DISTINCT group_id FROM group_permissions WHERE permission=?",
(recht,),
).fetchall()
]
# Geteilt: das alte Recht gibt es nicht mehr, also ersetzen.
for altes, neue in GETEILTE_RECHTE.items():
gruppen = gruppen_mit(altes)
if not gruppen:
continue
conn.executemany(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
[(gid, recht) for gid in gruppen for recht in neue],
)
conn.execute("DELETE FROM group_permissions WHERE permission=?", (altes,))
# Erweitert: das Recht bleibt und bekommt dazu, was bisher an ihm hing.
# Loeschen waere hier falsch -- es ist keines von gestern.
for recht, dazu in ERWEITERTE_RECHTE.items():
gruppen = gruppen_mit(recht)
if not gruppen:
continue
conn.executemany(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
[(gid, weiteres) for gid in gruppen for weiteres in dazu],
)
assert neue_schluessel # in derselben Tabelle nachgeschlagen wie beim Import
CORE_MIGRATIONS: tuple[Migration, ...] = ( CORE_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1_BASELINE), Migration(
version=1,
name="baseline",
sql=V1_BASELINE,
geaendert_weil=(
"Das Grundschema legt ldap_group_mappings jetzt mit grants_admin und nullbarer app_group_id an -- eine neue Installation soll nicht erst core/6 brauchen, um die Tabelle wieder umzubauen."
),
),
Migration(version=2, name="trusted-issuer-keys", sql=V2_TRUSTED_KEYS), Migration(version=2, name="trusted-issuer-keys", sql=V2_TRUSTED_KEYS),
Migration(version=3, name="jobs", sql=V3_JOBS), Migration(version=3, name="jobs", sql=V3_JOBS),
Migration(version=4, name="rechte-read-write", fn=benenne_rechte_um),
Migration(version=5, name="rechte-je-funktion", fn=teile_rechte_auf),
Migration(version=6, name="ldap-kennzeichen", fn=zuordnung_auf_kennzeichen),
) )
@@ -50,6 +50,12 @@ class Migration:
sql: str = "" sql: str = ""
fn: MigrationFn | None = None fn: MigrationFn | None = None
allow_integrity_error: bool = False allow_integrity_error: bool = False
#: Begruendung, falls der Text einer bereits angewendeten Migration
#: absichtlich geaendert wurde -- etwa weil eine frische Installation
#: mit der neuen Schreibweise starten muss. Ohne Begruendung warnt der
#: Lauf, und das soll er auch: ein stiller Umbau einer angewendeten
#: Migration ist fast immer ein Versehen.
geaendert_weil: str = ""
tags: tuple[str, ...] = field(default=()) tags: tuple[str, ...] = field(default=())
@property @property
@@ -175,6 +181,22 @@ class MigrationRunner:
record = known.get(migration.version) record = known.get(migration.version)
if record is not None: if record is not None:
if record["checksum"] != migration.checksum: if record["checksum"] != migration.checksum:
if migration.geaendert_weil:
# Mit Begruendung ist es eine Notiz, keine Warnung:
# manchmal muss der Text einer laengst angewendeten
# Migration mit, damit eine **frische** Installation
# denselben Stand erreicht. Wer das Protokoll liest,
# soll den Unterschied zu einem Versehen sehen.
logger.info(
"Migration %s/%s (%s) wurde absichtlich geändert: %s "
"Auf dieser Installation ist sie längst angewendet und "
"wird nicht erneut ausgeführt.",
component,
migration.version,
migration.name,
migration.geaendert_weil,
)
else:
logger.warning( logger.warning(
"Migration %s/%s (%s) wurde nachträglich geändert " "Migration %s/%s (%s) wurde nachträglich geändert "
"(Prüfsumme %s != %s). Bereits angewendete Migrationen werden " "(Prüfsumme %s != %s). Bereits angewendete Migrationen werden "
+47 -23
View File
@@ -16,10 +16,10 @@ A = Action
USERS = Resource( USERS = Resource(
key="users", key="users",
label="Benutzer", label="Benutzer",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE), actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Konten anlegen, sperren, Gruppen zuordnen", hint="Konten anlegen, sperren, Gruppen zuordnen",
action_hints={ action_hints={
A.EDIT: "Stammdaten, Sperre und Gruppenzuordnung ändern.", A.WRITE: "Stammdaten, Sperre und Gruppenzuordnung ändern.",
A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).", A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).",
}, },
) )
@@ -27,32 +27,43 @@ USERS = Resource(
GROUPS = Resource( GROUPS = Resource(
key="groups", key="groups",
label="Gruppen und Rechte", label="Gruppen und Rechte",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE), actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Rechtematrix pflegen", hint="Rechtematrix pflegen",
action_hints={ action_hints={
A.EDIT: "Rechte vergeben – nur Rechte, die man selbst besitzt.", A.WRITE: "Rechte vergeben – nur Rechte, die man selbst besitzt.",
}, },
) )
SETTINGS_SYSTEM = Resource( SETTINGS_SYSTEM = Resource(
key="settings_system", key="settings_system",
label="Systemeinstellungen", label="Systemeinstellungen",
actions=(A.VIEW, A.EDIT, A.EXECUTE), actions=(A.READ, A.WRITE, A.EXECUTE),
hint="Hostname, Zeitzone, Protokollrotation, Navigation", hint="Hostname, Zeitzone, Protokollrotation, Sitzungsdauer",
action_hints={A.EXECUTE: "Systemaktionen auslösen (Dienste, Netzwerk übernehmen)."}, action_hints={A.EXECUTE: "Systemaktionen auslösen (Dienste, Netzwerk übernehmen)."},
) )
#: Die Reihenfolge des Menues lief unter "Systemaktionen auslösen" mit -- also
#: unter demselben Recht wie das Aendern des Hostnamens. Es ist aber das
#: harmloseste, was man in der Verwaltung tun kann, und das haeufigste.
SETTINGS_NAV = Resource(
key="settings_nav",
parent="settings_system",
label="Navigation",
actions=(A.READ, A.WRITE),
hint="Reihenfolge der Menüpunkte und ihrer Unterpunkte",
)
SETTINGS_LICENSE = Resource( SETTINGS_LICENSE = Resource(
key="settings_license", key="settings_license",
label="Lizenz", label="Lizenz",
actions=(A.VIEW, A.EDIT), actions=(A.READ, A.WRITE),
hint="Lizenzdatei einspielen, aktivieren, deaktivieren", hint="Lizenzdatei einspielen, aktivieren, deaktivieren",
) )
SETTINGS_DIRECTORY = Resource( SETTINGS_DIRECTORY = Resource(
key="settings_directory", key="settings_directory",
label="Verzeichnisdienst (LDAP/AD)", label="Verzeichnisdienst (LDAP/AD)",
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS), actions=(A.READ, A.WRITE, A.EXECUTE, A.SECRETS),
hint="Anbindung und Gruppen-Zuordnung", hint="Anbindung und Gruppen-Zuordnung",
action_hints={ action_hints={
A.EXECUTE: "Verbindungstest ausführen.", A.EXECUTE: "Verbindungstest ausführen.",
@@ -63,7 +74,7 @@ SETTINGS_DIRECTORY = Resource(
SETTINGS_WEB = Resource( SETTINGS_WEB = Resource(
key="settings_web", key="settings_web",
label="Webserver und TLS", label="Webserver und TLS",
actions=(A.VIEW, A.EDIT, A.EXECUTE), actions=(A.READ, A.WRITE, A.EXECUTE),
hint="Domain, Zertifikat, HSTS", hint="Domain, Zertifikat, HSTS",
action_hints={A.EXECUTE: "Konfiguration übernehmen, Zertifikat anfordern."}, action_hints={A.EXECUTE: "Konfiguration übernehmen, Zertifikat anfordern."},
) )
@@ -71,29 +82,40 @@ SETTINGS_WEB = Resource(
SETTINGS_NETWORK = Resource( SETTINGS_NETWORK = Resource(
key="settings_network", key="settings_network",
label="Netzwerkeinstellungen", label="Netzwerkeinstellungen",
actions=(A.VIEW, A.EDIT), actions=(A.READ, A.WRITE),
hint="IP-Adresse, Gateway, DNS des Hosts", hint="IP-Adresse, Gateway, DNS des Hosts",
) )
BACKUP = Resource( #: Sichern und Einspielen sind zwei Zeilen, weil es zwei Vorgaenge mit sehr
key="backup", #: verschiedenen Folgen sind: eine Sicherung zu erzeugen aendert nichts, eine
label="Sicherung und Übertragung", #: einzuspielen aendert alles. Sie liefen unter einem Recht, und wer eine
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS), #: Sicherung ziehen durfte, durfte damit auch eine fremde einspielen.
hint="Verschlüsselter Export und Import", BACKUP_EXPORT = Resource(
key="backup_export",
label="Sicherung erstellen",
actions=(A.READ, A.EXPORT, A.SECRETS),
hint="Verschlüsselter Export des Datenstands",
action_hints={ action_hints={
A.EXECUTE: "Eine Sicherung einspielen.",
A.EXPORT: "Sicherung erzeugen und herunterladen.", A.EXPORT: "Sicherung erzeugen und herunterladen.",
A.SECRETS: "Zugangsdaten in die Sicherung einschließen.", A.SECRETS: "Zugangsdaten in die Sicherung einschließen.",
}, },
) )
BACKUP_IMPORT = Resource(
key="backup_import",
label="Sicherung einspielen",
actions=(A.READ, A.EXECUTE),
hint="Prüfen, übernehmen, verwerfen",
action_hints={A.EXECUTE: "Eine geprüfte Sicherung anwenden."},
)
TRASH = Resource( TRASH = Resource(
key="trash", key="trash",
label="Papierkorb", label="Papierkorb",
actions=(A.VIEW, A.EDIT, A.DELETE), actions=(A.READ, A.WRITE, A.DELETE),
hint="Gelöschtes wiederherstellen oder endgültig entfernen", hint="Gelöschtes wiederherstellen oder endgültig entfernen",
action_hints={ action_hints={
A.EDIT: "Einträge wiederherstellen.", A.WRITE: "Einträge wiederherstellen.",
A.DELETE: "Einträge endgültig entfernen.", A.DELETE: "Einträge endgültig entfernen.",
}, },
) )
@@ -101,20 +123,20 @@ TRASH = Resource(
DIAGNOSTICS = Resource( DIAGNOSTICS = Resource(
key="diagnostics", key="diagnostics",
label="Diagnose", label="Diagnose",
actions=(A.VIEW,), actions=(A.READ,),
hint="Schemastand, Protokollintegrität, Schlüsselrechte", hint="Schemastand, Protokollintegrität, Schlüsselrechte",
) )
LOGS_LIVE = Resource( LOGS_LIVE = Resource(
key="logs_live", key="logs_live",
label="Laufendes Protokoll", label="Laufendes Protokoll",
actions=(A.VIEW,), actions=(A.READ,),
) )
LOGS_HISTORY = Resource( LOGS_HISTORY = Resource(
key="logs_history", key="logs_history",
label="Protokollverlauf", label="Protokollverlauf",
actions=(A.VIEW, A.DELETE, A.EXPORT), actions=(A.READ, A.DELETE, A.EXPORT),
hint="Rotierte und archivierte Protokolle", hint="Rotierte und archivierte Protokolle",
action_hints={A.DELETE: "Archivierte Tage vom Server entfernen."}, action_hints={A.DELETE: "Archivierte Tage vom Server entfernen."},
) )
@@ -122,7 +144,7 @@ LOGS_HISTORY = Resource(
LOGS_AUDIT = Resource( LOGS_AUDIT = Resource(
key="logs_audit", key="logs_audit",
label="Änderungsprotokoll", label="Änderungsprotokoll",
actions=(A.VIEW, A.EXPORT), actions=(A.READ, A.EXPORT),
hint="Wer hat wann was geändert", hint="Wer hat wann was geändert",
) )
@@ -139,11 +161,13 @@ def administration_area(*extra: Resource) -> Area:
USERS, USERS,
GROUPS, GROUPS,
SETTINGS_SYSTEM, SETTINGS_SYSTEM,
SETTINGS_NAV,
SETTINGS_LICENSE, SETTINGS_LICENSE,
SETTINGS_DIRECTORY, SETTINGS_DIRECTORY,
SETTINGS_WEB, SETTINGS_WEB,
SETTINGS_NETWORK, SETTINGS_NETWORK,
BACKUP, BACKUP_EXPORT,
BACKUP_IMPORT,
TRASH, TRASH,
DIAGNOSTICS, DIAGNOSTICS,
*extra, *extra,
@@ -0,0 +1,171 @@
"""Alte Rechteschluessel auf neue -- an einer Stelle, nicht an dreien.
Mit 2.8.0 heisst das Aktionsvokabular durchgehend wie das, was es tut:
``view`` wurde ``read``, ``edit`` wurde ``write``. Danach steht in der Matrix
"Write" und im Aenderungsprotokoll ``write``, nicht zweierlei.
Drei Stellen brauchen die Zuordnung, und darum steht sie hier und nicht dort:
* die **Migration**, die bestehende Gruppenzeilen umschreibt,
* der **Import**, dem eine Sicherung aus 2.7.x untergeschoben wird -- ohne die
Zuordnung landen dort lauter unbekannte Schluessel, und
:meth:`PermissionTree.sanitize` wirft Unbekanntes absichtlich **still** weg.
Aus einer Sicherung wuerden Gruppen ohne Rechte.
* der **Test**, der prueft, dass kein alter Schluessel mehr im Code steht.
Warum nicht dreimal dieselbe Ersetzung tippen: weil die dritte Kopie beim
naechsten Umbau vergessen wird. Genau so entstand das Muster bei den Adressen
(:mod:`tesm_core.legacy_urls`).
"""
from __future__ import annotations
#: Aktion alt -> neu. Die Ressource bleibt, wie sie heisst.
UMBENANNTE_AKTIONEN: dict[str, str] = {
"view": "read",
"edit": "write",
}
#: Ein altes Recht, das in mehrere neue zerfaellt -- ein Recht je Funktion.
#:
#: Die Aufteilung ist **additiv**: wer das alte Recht hatte, bekommt alle
#: neuen. Niemand verliert etwas; ab jetzt laesst sich jedes Stueck einzeln
#: vergeben. Der umgekehrte Weg waere auch denkbar gewesen -- nur das
#: Harmloseste uebernehmen --, waere aber ein Update, das Leuten Rechte
#: wegnimmt, ohne dass jemand es entschieden hat.
#:
#: Die Schluessel stehen fuer beide Anwendungen in einer Tabelle. Auf einer
#: TESM-Datenbank tut die Zeile fuer ``licenses.write`` nichts, weil sie dort
#: in keiner Gruppe steht -- und umgekehrt. Zwei getrennte Tabellen waeren
#: zwei Stellen, die man pflegen muss.
GETEILTE_RECHTE: dict[str, tuple[str, ...]] = {
# Drei Neustartwege mit drei verschiedenen Folgen.
"devices.execute": (
"device_restart_poe.read",
"device_restart_poe.execute",
"device_restart_ssh.read",
"device_restart_ssh.execute",
"device_restart_rpc.read",
"device_restart_rpc.execute",
),
# Die Switch-Konsole war "Switch ausfuehren".
"switches.execute": ("switch_console.read", "switch_console.execute"),
# Vier Funktionen: aktualisieren, neu starten, Werkzeug installieren --
# und die Client-Konsole, die jetzt bei den Clients steht.
"maintenance.read": (
"servicing_update.read",
"servicing_reboot.read",
"servicing_tool.read",
"device_console.read",
),
"maintenance.execute": (
"servicing_update.read",
"servicing_update.execute",
"servicing_reboot.read",
"servicing_reboot.execute",
"servicing_tool.read",
"servicing_tool.execute",
"device_console.read",
"device_console.execute",
),
# Elf Funktionen an einem Recht.
"dhcp.read": (
"dhcp_subnets.read",
"dhcp_options.read",
"dhcp_reservations.read",
"dhcp_config.read",
"dhcp_service.read",
),
"dhcp.write": (
"dhcp_subnets.read",
"dhcp_subnets.create",
"dhcp_subnets.write",
"dhcp_subnets.delete",
"dhcp_options.read",
"dhcp_options.write",
"dhcp_reservations.read",
"dhcp_reservations.create",
"dhcp_reservations.write",
"dhcp_reservations.delete",
"dhcp_config.read",
"dhcp_config.execute",
),
"dhcp.execute": ("dhcp_service.read", "dhcp_service.execute"),
# Sichern und Einspielen.
"backup.read": ("backup_export.read", "backup_import.read"),
"backup.export": ("backup_export.read", "backup_export.export"),
"backup.secrets": ("backup_export.read", "backup_export.secrets"),
"backup.execute": ("backup_import.read", "backup_import.execute"),
# Lizenzserver: "aendern" hiess widerrufen **oder** erneuern -- zwei
# Eingriffe mit entgegengesetzter Wirkung unter einem Haken.
"licenses.create": ("license_issue.read", "license_issue.execute"),
"licenses.write": (
"license_renew.read",
"license_renew.execute",
"license_revoke.read",
"license_revoke.execute",
),
"licenses.execute": ("license_activation.read", "license_activation.execute"),
}
#: Ein Recht, das **bleibt** und bisher noch etwas anderes mitgab.
#:
#: Der Unterschied zu :data:`GETEILTE_RECHTE` ist keine Formsache: dort ist der
#: alte Schluessel verschwunden und muss ersetzt werden, hier gilt er weiter
#: und bekommt nur Gesellschaft. Beides in eine Tabelle zu werfen hiesse, in
#: der Migration ein Recht zu loeschen, das es noch gibt -- und im Test nicht
#: mehr pruefen zu koennen, welcher Fall gemeint war.
ERWEITERTE_RECHTE: dict[str, tuple[str, ...]] = {
# Die Navigationsreihenfolge lief unter "Systemaktionen ausloesen" mit.
"settings_system.execute": ("settings_nav.read", "settings_nav.write"),
# Einen Host-Schluessel freizugeben hing an den Stammdaten.
"devices.write": ("hostkeys.read", "hostkeys.execute"),
"switches.write": ("hostkeys.read", "hostkeys.execute"),
# Umbenennen war auch "Freigaben zuordnen".
"fileshare.write": (
"fileshare_mappings.read",
"fileshare_mappings.create",
"fileshare_mappings.write",
"fileshare_mappings.delete",
),
# Ausgefuellt ablegen war auch "unterschreiben".
"fileshare_pdf.create": ("fileshare_signature.read", "fileshare_signature.execute"),
}
def neue_schluessel(alt: str) -> tuple[str, ...]:
"""Was aus einem alten Rechteschluessel wird -- eins oder mehrere.
Unbekanntes kommt unveraendert zurueck: ob ein Schluessel gilt, entscheidet
der Rechtebaum, nicht diese Tabelle. Sie benennt nur um.
"""
if alt in GETEILTE_RECHTE:
return GETEILTE_RECHTE[alt]
ressource, punkt, aktion = alt.rpartition(".")
if not punkt:
return (alt,)
neu = UMBENANNTE_AKTIONEN.get(aktion)
selbst = f"{ressource}.{neu}" if neu else alt
# Ein Recht, das bleibt, nimmt mit, was es bisher mitgab -- sonst verliert
# jemand beim Update genau die Funktion, die vorher an diesem Haken hing.
return (selbst, *ERWEITERTE_RECHTE.get(selbst, ()))
def umgeschrieben(rechte: object) -> list[str]:
"""Eine ganze Liste umschreiben, ohne Dubletten und in fester Reihenfolge."""
if not isinstance(rechte, (list, tuple, set, frozenset)):
return []
ergebnis: set[str] = set()
for recht in rechte:
ergebnis.update(neue_schluessel(str(recht)))
return sorted(ergebnis)
__all__ = [
"UMBENANNTE_AKTIONEN",
"GETEILTE_RECHTE",
"ERWEITERTE_RECHTE",
"neue_schluessel",
"umgeschrieben",
]
+119 -28
View File
@@ -1,6 +1,14 @@
"""Rechtemodell: Bereiche, Ressourcen, Aktionen. """Rechtemodell: Bereiche, Ressourcen, Aktionen.
Das Vorgaengermodell kannte fuenf Spalten (R/W/E/D/X), wobei ``E`` ("Aendern") Das Vokabular heisst seit 2.8.0 durchgehend wie das, was es tut: **Read,
Create, Write, Delete, Execute, Export, Secrets**. Bis dahin hiessen zwei
Aktionen anders (``view``, ``edit``), waehrend die Matrix "Bearbeiten" zeigte
und die Kurzfassung "U" -- dieselbe Aktion in drei Schreibweisen. Der
Kill-Switch eines Bereichs heisst entsprechend ``<bereich>.read``.
:mod:`tesm_core.rbac.legacy_permissions` schreibt alte Schluessel um; die
Migration ``core/4`` tut das fuer bestehende Gruppen.
Das Modell vor 2.0 kannte fuenf Spalten (R/W/E/D/X), wobei ``E`` ("Aendern")
das Loeschen **mit abdeckte** und ``D``/``X`` je nach Zeile etwas voellig anderes das Loeschen **mit abdeckte** und ``D``/``X`` je nach Zeile etwas voellig anderes
bedeuteten (Dashboard-Neustart bzw. Export). Das war kompakt, aber: bedeuteten (Dashboard-Neustart bzw. Export). Das war kompakt, aber:
@@ -15,7 +23,7 @@ Aktionen eine Ressource ueberhaupt kennt, entscheidet die Ressource; die Matrix
rendert nur die Spalten, die im jeweiligen Bereich wirklich vorkommen. rendert nur die Spalten, die im jeweiligen Bereich wirklich vorkommen.
Beibehalten wird der bewaehrte **Kill-Switch**: jeder Bereich hat ein Beibehalten wird der bewaehrte **Kill-Switch**: jeder Bereich hat ein
``<bereich>.view``-Recht. Ohne dieses greift kein einziges Recht innerhalb des ``<bereich>.read``-Recht. Ohne dieses greift kein einziges Recht innerhalb des
Bereichs -- unabhaengig davon, was einzeln angehakt ist. Das erlaubt es, einer Bereichs -- unabhaengig davon, was einzeln angehakt ist. Das erlaubt es, einer
Gruppe einen ganzen Bereich mit einem Klick zu entziehen, ohne die fein Gruppe einen ganzen Bereich mit einem Klick zu entziehen, ohne die fein
eingestellten Einzelrechte zu verlieren. eingestellten Einzelrechte zu verlieren.
@@ -29,28 +37,55 @@ from typing import Iterable, Mapping, Sequence
class Action(str, Enum): class Action(str, Enum):
"""Aktionsvokabular. Bedeutung ist ueberall dieselbe -- keine Ausnahmen.""" """Aktionsvokabular. Bedeutung ist ueberall dieselbe -- keine Ausnahmen.
VIEW = "view" Die Namen sind die englischen, weil sie in jedem Rechtesystem dieselben
sind und keiner Uebersetzung bedarf: wer ``write`` liest, weiss es. Zwei
hiessen bis 2.7.x anders (``view``, ``edit``) -- in der Matrix stand
"Bearbeiten" und im Protokoll ``edit``, also zweierlei fuer dasselbe.
:mod:`tesm_core.rbac.legacy_permissions` schreibt die alten Schluessel um.
"""
READ = "read"
CREATE = "create" CREATE = "create"
EDIT = "edit" WRITE = "write"
DELETE = "delete" DELETE = "delete"
EXECUTE = "execute" EXECUTE = "execute"
EXPORT = "export" EXPORT = "export"
SECRETS = "secrets" SECRETS = "secrets"
#: Kuerzel und Erklaerung je Aktion -- Kopfzeile der Rechtematrix. #: Kuerzel, englischer Name, deutsche Beschriftung, Erklaerung.
ACTION_META: dict[Action, tuple[str, str, str]] = { #:
Action.VIEW: ("R", "Ansehen", "Datensätze und Seiten dieses Bereichs lesen."), #: Beides hat seinen Ort: die Matrix fuehrt die Spalte unter ihrem englischen
Action.CREATE: ("C", "Anlegen", "Neue Datensätze erstellen."), #: Namen (das ist das Vokabular, und es steht so auch im Schluessel), die
Action.EDIT: ("U", "Bearbeiten", "Bestehende Datensätze ändern – ohne Löschen."), #: Kleinschrift darunter und jeder Satz in der Oberflaeche bleiben deutsch.
Action.DELETE: ("D", "Loeschen", "Datensätze löschen bzw. endgültig entfernen."), ACTION_META: dict[Action, tuple[str, str, str, str]] = {
Action.EXECUTE: ("X", "Ausfuehren", "Aktionen auslösen (Neustart, Dienste, Jobs, Import)."), Action.READ: ("R", "Read", "ansehen", "Datensätze und Seiten dieses Bereichs ansehen."),
Action.EXPORT: ("E", "Exportieren", "Daten aus dem System heraustragen (Datei-Download)."), Action.CREATE: ("C", "Create", "anlegen", "Neue Datensätze anlegen."),
Action.WRITE: (
"W",
"Write",
"ändern",
"Bestehende Datensätze ändern – ohne Anlegen, ohne Löschen.",
),
Action.DELETE: ("D", "Delete", "löschen", "Datensätze löschen bzw. endgültig entfernen."),
Action.EXECUTE: (
"X",
"Execute",
"auslösen",
"Aktionen auslösen (Neustart, Dienste, Aufträge, Import).",
),
Action.EXPORT: (
"E",
"Export",
"mitnehmen",
"Daten aus dem System heraustragen (Datei-Download).",
),
Action.SECRETS: ( Action.SECRETS: (
"S", "S",
"Geheimnisse", "Secrets",
"Klartext",
"Passwörter und Schlüssel im Klartext sehen oder mitnehmen.", "Passwörter und Schlüssel im Klartext sehen oder mitnehmen.",
), ),
} }
@@ -58,9 +93,12 @@ ACTION_META: dict[Action, tuple[str, str, str]] = {
#: Aktionen, die zusaetzlich eine frische Passworteingabe verlangen (sudo-Modus). #: Aktionen, die zusaetzlich eine frische Passworteingabe verlangen (sudo-Modus).
SENSITIVE_ACTIONS: frozenset[Action] = frozenset({Action.SECRETS, Action.EXPORT}) SENSITIVE_ACTIONS: frozenset[Action] = frozenset({Action.SECRETS, Action.EXPORT})
#: ``view`` ist Voraussetzung fuer alles andere in derselben Zeile. #: ``read`` ist Voraussetzung fuer alles andere in derselben Zeile. Umgekehrt
#: gilt nichts: wer anlegen darf, darf damit nicht aendern, und wer aendern
#: darf, nicht anlegen. Das ist die Trennung, die man in einer
#: Verwaltungsanwendung am haeufigsten braucht.
IMPLIED_BY: dict[Action, tuple[Action, ...]] = { IMPLIED_BY: dict[Action, tuple[Action, ...]] = {
action: (Action.VIEW,) for action in Action if action is not Action.VIEW action: (Action.READ,) for action in Action if action is not Action.READ
} }
@@ -74,12 +112,33 @@ class Resource:
hint: str = "" hint: str = ""
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint). #: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
action_hints: Mapping[Action, str] = field(default_factory=dict) action_hints: Mapping[Action, str] = field(default_factory=dict)
#: Zu welcher Zeile diese gehoert -- rein fuer die Darstellung.
#:
#: Aus einer Zeile "Clients" sind sechs geworden (Stammdaten, drei
#: Neustartwege, Konsole, Host-Schluessel). Untereinander und alle gleich
#: eingerueckt liest man sie als sechs unabhaengige Dinge; eingerueckt
#: unter ihrer Hauptzeile sieht man, dass sie zusammengehoeren. Auf die
#: Rechte hat das keine Wirkung: jede Zeile bleibt einzeln vergebbar, und
#: die Hauptzeile ist **nicht** Voraussetzung fuer ihre Unterzeilen.
parent: str = ""
#: Aktionen, die hier **keine** frische Passwortbestaetigung verlangen, #: Aktionen, die hier **keine** frische Passwortbestaetigung verlangen,
#: obwohl sie in SENSITIVE_ACTIONS stehen. Bewusst hier und nicht als #: obwohl sie in SENSITIVE_ACTIONS stehen. Bewusst hier und nicht als
#: stilles Weglassen in der Route: eine Ausnahme von einer #: stilles Weglassen in der Route: eine Ausnahme von einer
#: Sicherheitsregel gehoert dorthin, wo man sie sieht und begruenden muss. #: Sicherheitsregel gehoert dorthin, wo man sie sieht und begruenden muss.
ohne_wiederanmeldung: tuple[Action, ...] = () ohne_wiederanmeldung: tuple[Action, ...] = ()
@property
def short_label(self) -> str:
"""Beschriftung ohne den Namen der Hauptzeile.
Unter "Clients" steht "Neustart über PoE" und nicht "Clients: Neustart
über PoE" -- das Wort stand sonst siebenmal untereinander. Ausserhalb
der Matrix (Protokoll, Meldungen) bleibt der vollstaendige Name.
"""
if self.parent and ": " in self.label:
return self.label.split(": ", 1)[1]
return self.label
def permission(self, action: Action) -> str: def permission(self, action: Action) -> str:
return f"{self.key}.{action.value}" return f"{self.key}.{action.value}"
@@ -101,8 +160,9 @@ class Area:
hint: str = "" hint: str = ""
@property @property
def view_key(self) -> str: def read_key(self) -> str:
return f"{self.key}.view" """Der Kill-Switch des Bereichs. Ohne ihn greift keine Zeile darunter."""
return f"{self.key}.read"
def columns(self) -> tuple[Action, ...]: def columns(self) -> tuple[Action, ...]:
"""Nur die Aktionen, die in diesem Bereich tatsaechlich vorkommen.""" """Nur die Aktionen, die in diesem Bereich tatsaechlich vorkommen."""
@@ -128,6 +188,7 @@ class PermissionTree:
self._parent: dict[str, str] = {} self._parent: dict[str, str] = {}
self._all: list[str] = [] self._all: list[str] = []
self._resources: dict[str, tuple[Area, Resource]] = {} self._resources: dict[str, tuple[Area, Resource]] = {}
self._verwaltung: frozenset[str] | None = None
seen_keys: set[str] = set() seen_keys: set[str] = set()
for area in self.areas: for area in self.areas:
@@ -135,30 +196,57 @@ class PermissionTree:
raise ValueError(f"Doppelter Bereichsschlüssel: {area.key}") raise ValueError(f"Doppelter Bereichsschlüssel: {area.key}")
seen_keys.add(area.key) seen_keys.add(area.key)
self._by_key[area.key] = area self._by_key[area.key] = area
self._all.append(area.view_key) self._all.append(area.read_key)
for resource in area.resources: for resource in area.resources:
if resource.key in seen_keys: if resource.key in seen_keys:
raise ValueError(f"Doppelter Ressourcenschlüssel: {resource.key}") raise ValueError(f"Doppelter Ressourcenschlüssel: {resource.key}")
seen_keys.add(resource.key) seen_keys.add(resource.key)
if Action.VIEW not in resource.actions: if Action.READ not in resource.actions:
raise ValueError( raise ValueError(
f"Ressource {resource.key} muss die Aktion 'view' anbieten – " f"Ressource {resource.key} muss die Aktion 'read' anbieten – "
"sie ist Voraussetzung für jede andere Aktion." "sie ist Voraussetzung für jede andere Aktion."
) )
self._resources[resource.key] = (area, resource) self._resources[resource.key] = (area, resource)
for permission in resource.permissions(): for permission in resource.permissions():
self._all.append(permission) self._all.append(permission)
self._parent[permission] = area.view_key self._parent[permission] = area.read_key
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def all_keys(self) -> tuple[str, ...]: def all_keys(self) -> tuple[str, ...]:
return tuple(self._all) return tuple(self._all)
#: Der Bereich, den ein echter Administrator immer ganz hat.
#:
#: Nicht "alles": das Kennzeichen ``is_admin`` gab bisher jede Pruefung
#: frei, auch Geraete, DHCP und Dateifreigaben. Ein Administrator ist aber
#: jemand, der die **Anlage** verwaltet -- Benutzer, Rechte,
#: Einstellungen --, und nicht automatisch jemand, der Maschinen neu
#: startet oder in Freigaben liest. Diese Rechte holt er sich ueber eine
#: Gruppe, und dann steht es im Aenderungsprotokoll.
VERWALTUNGSBEREICH = "administration"
def verwaltungsrechte(self) -> frozenset[str]:
"""Alle Rechte des Verwaltungsbereichs, samt seinem Kill-Switch.
Einmal gerechnet und gemerkt: die Antwort haengt nur am Baum, und der
entsteht beim Import.
"""
if self._verwaltung is None:
bereich = self._by_key.get(self.VERWALTUNGSBEREICH)
if bereich is None:
self._verwaltung = frozenset()
else:
rechte = {bereich.read_key}
for resource in bereich.resources:
rechte.update(resource.permissions())
self._verwaltung = frozenset(rechte)
return self._verwaltung
def area(self, key: str) -> Area | None: def area(self, key: str) -> Area | None:
return self._by_key.get(key) return self._by_key.get(key)
def parent_view_key(self, permission: str) -> str | None: def parent_read_key(self, permission: str) -> str | None:
return self._parent.get(permission) return self._parent.get(permission)
def resource(self, key: str) -> tuple[Area, Resource] | None: def resource(self, key: str) -> tuple[Area, Resource] | None:
@@ -185,7 +273,7 @@ class PermissionTree:
def label_for(self, permission: str) -> str: def label_for(self, permission: str) -> str:
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs.""" """Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
if permission.endswith(".view") and permission[:-5] in self._by_key: if permission.endswith(".read") and permission[:-5] in self._by_key:
return f"Bereich {self._by_key[permission[:-5]].label}: Zugang" return f"Bereich {self._by_key[permission[:-5]].label}: Zugang"
resource_key, _, action_value = permission.rpartition(".") resource_key, _, action_value = permission.rpartition(".")
entry = self._resources.get(resource_key) entry = self._resources.get(resource_key)
@@ -196,7 +284,10 @@ class PermissionTree:
action = Action(action_value) action = Action(action_value)
except ValueError: except ValueError:
return permission return permission
return f"{resource.label}: {ACTION_META[action][1]}" # Die deutsche Beschriftung, nicht der englische Name: dieser Text
# landet in Saetzen ("Sie koennen kein Recht vergeben ...") und im
# Aenderungsprotokoll, und die sind deutsch.
return f"{resource.label}: {ACTION_META[action][2]}"
def sanitize(self, permissions: Iterable[str]) -> list[str]: def sanitize(self, permissions: Iterable[str]) -> list[str]:
"""Wirft unbekannte Rechte weg und ergaenzt implizierte. """Wirft unbekannte Rechte weg und ergaenzt implizierte.
@@ -204,7 +295,7 @@ class PermissionTree:
Regeln: Regeln:
* Ein Recht, das nicht im Baum steht, wird verworfen (kein stilles * Ein Recht, das nicht im Baum steht, wird verworfen (kein stilles
Weiterschleppen von Altrechten nach einem Refactoring). Weiterschleppen von Altrechten nach einem Refactoring).
* Jede Aktion impliziert ``view`` derselben Zeile. * Jede Aktion impliziert ``read`` derselben Zeile -- aber nur diese.
* Existiert irgendein Recht eines Bereichs, wird der Kill-Switch des * Existiert irgendein Recht eines Bereichs, wird der Kill-Switch des
Bereichs mitgesetzt -- sonst waere die Auswahl wirkungslos und der Bereichs mitgesetzt -- sonst waere die Auswahl wirkungslos und der
Bearbeiter wuerde sich wundern. Bearbeiter wuerde sich wundern.
@@ -224,13 +315,13 @@ class PermissionTree:
result.add(candidate) result.add(candidate)
for area in self.areas: for area in self.areas:
if any(p in result for resource in area.resources for p in resource.permissions()): if any(p in result for resource in area.resources for p in resource.permissions()):
result.add(area.view_key) result.add(area.read_key)
return sorted(result) return sorted(result)
def effective(self, permissions: Iterable[str]) -> set[str]: def effective(self, permissions: Iterable[str]) -> set[str]:
"""Rechte, die nach Anwendung der Kill-Switches tatsaechlich greifen.""" """Rechte, die nach Anwendung der Kill-Switches tatsaechlich greifen."""
granted = set(permissions) granted = set(permissions)
active = {key for key in granted if key.endswith(".view") and key[:-5] in self._by_key} active = {key for key in granted if key.endswith(".read") and key[:-5] in self._by_key}
return { return {
key key
for key in granted for key in granted
@@ -29,7 +29,7 @@ from typing import Any, Iterable, Sequence
from ..db import Database from ..db import Database
from ..security import passwords from ..security import passwords
from .model import Action, PermissionTree, RolePreset from .model import ACTION_META, Action, PermissionTree, RolePreset
class PermissionDenied(Exception): class PermissionDenied(Exception):
@@ -124,7 +124,20 @@ def group_permissions(conn: sqlite3.Connection, group_id: int) -> list[str]:
def guard_no_amplification( def guard_no_amplification(
actor: Any, tree: PermissionTree, added: Iterable[str], *, what: str = "Recht" actor: Any, tree: PermissionTree, added: Iterable[str], *, what: str = "Recht"
) -> None: ) -> None:
"""Verhindert, dass jemand Rechte vergibt, die er selbst nicht besitzt.""" """Verhindert, dass jemand Rechte vergibt, die er selbst nicht besitzt.
Ein echter Administrator darf es -- und das ist seit 2.8.0 die bewusst
offene Tuer: sein Kennzeichen gibt ihm den Verwaltungsbereich, die
Funktionen holt er sich ueber eine Gruppe, auch fuer sich selbst. Der
Unterschied zu vorher ist nicht, *ob* er an die Funktionen kommt, sondern
dass es dann eine Zeile im Aenderungsprotokoll gibt und in der Matrix zu
sehen ist, statt unsichtbar im Kennzeichen zu stecken.
Fuer alle anderen bleibt die Regel dicht -- auch fuer die Gruppe
"Administration", die Benutzer und Gruppen pflegt: sie kann keiner Gruppe
ein Recht geben, das sie nicht selbst hat, und sich damit nicht ueber eine
neue Gruppe hochstufen.
"""
if getattr(actor, "is_admin", False): if getattr(actor, "is_admin", False):
return return
missing = sorted(p for p in added if not actor.has(p)) missing = sorted(p for p in added if not actor.has(p))
@@ -315,6 +328,49 @@ def ensure_presets(
return created return created
def funktionsgruppe_fuer_administratoren(
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
) -> int:
"""Legt die Vollzugriffsgruppe an und gibt sie allen Administratoren.
Der Uebergang zu 2.8.0: bis dahin gab das Kennzeichen ``is_admin`` jede
Pruefung frei. Danach gibt es den Verwaltungsbereich, und alles andere
kommt ueber Gruppen. Ohne diesen Schritt fehlt einem bestehenden
Administrator nach dem Update die halbe Anwendung -- und er haelt das fuer
einen Fehler, nicht fuer eine Absicht.
Auf einer frischen Installation gibt es beim Migrieren noch kein Konto;
dann entsteht nur die Gruppe, und wer sie will, weist sie sich zu. Genau so
ist die neue Trennung gedacht.
Gibt die Nummer der Gruppe zurueck.
"""
zeile = Database.one(
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,)
)
if zeile is None:
cursor = conn.execute(
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
"created_at, updated_at) VALUES (?,?,0,0,?,?,?)",
(preset.name, preset.description, preset.key, _now(), _now()),
)
gruppe = int(cursor.lastrowid or 0)
_write_permissions(conn, gruppe, tree.expand_preset(preset))
else:
gruppe = int(zeile["id"])
conn.executemany(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
[
(int(row["id"]), gruppe)
for row in Database.all(
conn, "SELECT id FROM users WHERE is_admin=1 AND deleted_at IS NULL"
)
],
)
return gruppe
def default_group_id(conn: sqlite3.Connection) -> int | None: def default_group_id(conn: sqlite3.Connection) -> int | None:
value = Database.value( value = Database.value(
conn, "SELECT id FROM groups WHERE is_default=1 AND deleted_at IS NULL ORDER BY id LIMIT 1" conn, "SELECT id FROM groups WHERE is_default=1 AND deleted_at IS NULL ORDER BY id LIMIT 1"
@@ -683,7 +739,7 @@ def matrix_state(
granted = set(permissions) granted = set(permissions)
state: dict[str, dict[str, bool]] = {} state: dict[str, dict[str, bool]] = {}
for area in tree.areas: for area in tree.areas:
state[area.key] = {"view": area.view_key in granted} state[area.key] = {"read": area.read_key in granted}
for resource in area.resources: for resource in area.resources:
state[resource.key] = { state[resource.key] = {
action.value: resource.permission(action) in granted for action in resource.actions action.value: resource.permission(action) in granted for action in resource.actions
@@ -696,7 +752,7 @@ def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> l
granted = set(permissions) granted = set(permissions)
lines: list[str] = [] lines: list[str] = []
for area in tree.areas: for area in tree.areas:
if area.view_key not in granted: if area.read_key not in granted:
continue continue
parts: list[str] = [] parts: list[str] = []
for resource in area.resources: for resource in area.resources:
@@ -712,12 +768,9 @@ def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> l
return lines return lines
#: Abgeleitet und nicht abgeschrieben: die Buchstaben stehen in ACTION_META.
#: Die zweite Liste hier hatte "U" fuer Bearbeiten, waehrend die Matrix "W"
#: zeigte -- dieselbe Aktion, zwei Kuerzel, je nachdem wo man hinsah.
ACTION_LETTERS: dict[Action, str] = { ACTION_LETTERS: dict[Action, str] = {
Action.VIEW: "R", action: meta[0] for action, meta in ACTION_META.items()
Action.CREATE: "C",
Action.EDIT: "U",
Action.DELETE: "D",
Action.EXECUTE: "X",
Action.EXPORT: "E",
Action.SECRETS: "S",
} }
@@ -58,6 +58,30 @@ class SettingSpec:
return str(raw) return str(raw)
def serialize(self, value: Any) -> str: def serialize(self, value: Any) -> str:
"""Ein Wert als Text fuer die Datenbank.
Der Wert kann als Python-Wert kommen (aus einem Formular, aus dem
Code) -- oder als die Zeichenkette, die schon in der Datenbank stand.
Genau so liest eine **Sicherung** ihn wieder ein, und genau dort lag
ein stiller Fehler: ``serialize("0")`` ergab ``"1"``, weil jede nicht
leere Zeichenkette wahr ist. Aus jedem abgeschalteten Schalter wurde
beim Einspielen ein eingeschalteter -- auch aus
``ldap_allow_insecure``. Ein JSON-Feld wurde ein zweites Mal kodiert,
und aus der Navigationsreihenfolge damit eine Zeichenkette.
Deshalb: eine Zeichenkette erst lesen. Was hier herauskommt, muss
durch ``serialize`` unveraendert hindurchgehen -- sonst ueberlebt kein
Wert eine Sicherung.
"""
if isinstance(value, str):
if self.kind == "bool":
value = self.parse(value)
elif self.kind == "json":
try:
value = json.loads(value)
except ValueError:
# Kein JSON-Text, also ein Wert: dann wird er kodiert.
pass
if self.kind == "bool": if self.kind == "bool":
return "1" if value else "0" return "1" if value else "0"
if self.kind == "json": if self.kind == "json":
@@ -1010,7 +1010,25 @@
text-align: center; text-align: center;
} }
.matrix thead th:first-child { text-align: left; } .matrix thead th:first-child { text-align: left; }
.matrix td:not(:first-child), .matrix th:not(:first-child) { width: 62px; text-align: center; } /* Kuerzel, englischer Name, deutsche Beschriftung -- untereinander. Nur das
Kuerzel allein war eine Spalte, die man nachschlagen musste; die Legende
steht unter einer Tabelle, die man dafuer erst zu Ende scrollt. */
.matrix__spalte { display: flex; flex-direction: column; align-items: center; line-height: 1.15; }
.matrix__spalte b {
font-family: var(--font-mono);
font-size: var(--fs-sm);
color: var(--text);
font-weight: 650;
}
.matrix__spalte span { font-size: var(--fs-xs); color: var(--text-muted); text-transform: none; }
.matrix__spalte small {
font-size: 10px;
color: var(--text-faint);
text-transform: none;
letter-spacing: 0;
font-weight: 400;
}
.matrix td:not(:first-child), .matrix th:not(:first-child) { width: 72px; text-align: center; }
.matrix tr.matrix__area > td { .matrix tr.matrix__area > td {
background: var(--surface-2); background: var(--surface-2);
font-weight: 640; font-weight: 640;
@@ -1036,6 +1054,46 @@
} }
.matrix__resource-label { display: flex; flex-direction: column; gap: 1px; } .matrix__resource-label { display: flex; flex-direction: column; gap: 1px; }
.matrix__resource-label small { color: var(--text-faint); font-size: var(--fs-xs); } .matrix__resource-label small { color: var(--text-faint); font-size: var(--fs-xs); }
.matrix__zeile { display: flex; align-items: baseline; gap: var(--sp-2); }
.matrix__zeile b { font-weight: 600; }
/* Unterzeile: gehoert zur Zeile darueber. Ohne Einrueckung liest man sechs
unabhaengige Dinge, wo sechs Teile eines Ganzen stehen. */
.matrix tr.matrix__unterzeile > td:first-child { padding-left: var(--sp-6); }
.matrix tr.matrix__unterzeile > td:first-child .matrix__zeile b { font-weight: 500; }
/* Was eine Aktion in dieser Zeile bedeutet -- in der Zeile, nicht im
Titelattribut: mit dem Finger gibt es kein "hover". */
.matrix__bedeutung { color: var(--text-faint); font-size: var(--fs-xs); }
.matrix__bedeutung b {
font-family: var(--font-mono);
color: var(--text-muted);
font-weight: 500;
}
.matrix__alle {
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
border-radius: var(--r-sm);
font-size: 10px;
line-height: 1;
padding: 2px 4px;
cursor: pointer;
}
.matrix__alle:hover { color: var(--accent); border-color: var(--accent); }
.matrix__filter {
display: flex;
align-items: baseline;
gap: var(--sp-3);
margin-bottom: var(--sp-3);
flex-wrap: wrap;
}
.matrix__filter-hint { color: var(--text-faint); font-size: var(--fs-xs); }
/* Eine ausgefilterte Zeile ist versteckt, nicht entfernt: ihre Kaestchen
bleiben im Formular, sonst loescht ein Filter beim Speichern Rechte. */
.matrix tr[hidden] { display: none; }
.matrix__legend { .matrix__legend {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -440,6 +440,56 @@ function initPermissionMatrix() {
}); });
}; };
/* -- Ganze Zeile, ganze Spalte, Filter -----------------------------
Bei 37 Zeilen und bis zu sieben Spalten je Bereich sind das ueber
zweihundert Kaestchen. Ohne diese drei Hilfen setzt man oben den
Bereichsschalter und laesst es dabei -- und die feine Aufteilung
haette nichts gebracht. */
// Nur was man sieht: eine vom Filter ausgeblendete Zeile darf ein
// Sammelknopf nicht mitnehmen. Sonst setzt man "alle Write" waehrend nach
// "Neustart" gefiltert ist und aendert dabei Zeilen, die gar nicht auf dem
// Schirm stehen -- und merkt es erst nach dem Speichern.
const sichtbar = (box) => !box.disabled && !box.closest("tr").hidden;
const felder = (bereich) =>
$$('input[type="checkbox"][name][data-action]', bereich).filter(sichtbar);
table.addEventListener("click", (event) => {
const ziel = event.target;
if (!(ziel instanceof HTMLElement)) return;
const zeilenknopf = ziel.closest("[data-matrix-row]");
if (zeilenknopf) {
event.preventDefault();
const row = zeilenknopf.closest("tr");
if (!row) return;
const kaestchen = felder(row);
// Erst wenn alles gesetzt ist, nimmt der Knopf weg -- sonst waere
// ein zweiter Klick auf eine halb gesetzte Zeile ein Ruecksprung.
const alleGesetzt = kaestchen.length > 0 && kaestchen.every((box) => box.checked);
kaestchen.forEach((box) => {
box.checked = !alleGesetzt;
box.dispatchEvent(new Event("change", { bubbles: true }));
});
return;
}
const spaltenknopf = ziel.closest("[data-matrix-column]");
if (spaltenknopf) {
event.preventDefault();
const aktion = spaltenknopf.dataset.matrixColumn;
const bereich = spaltenknopf.dataset.area;
const kaestchen = $$(
`tr[data-area="${bereich}"] input[data-action="${aktion}"][name]`,
table
).filter(sichtbar);
const alleGesetzt = kaestchen.length > 0 && kaestchen.every((box) => box.checked);
kaestchen.forEach((box) => {
box.checked = !alleGesetzt;
box.dispatchEvent(new Event("change", { bubbles: true }));
});
}
});
$$("[data-area-switch]", table).forEach((master) => { $$("[data-area-switch]", table).forEach((master) => {
const areaKey = master.dataset.areaSwitch; const areaKey = master.dataset.areaSwitch;
syncArea(areaKey); syncArea(areaKey);
@@ -451,8 +501,12 @@ function initPermissionMatrix() {
if (!(box instanceof HTMLInputElement) || !box.dataset.action) return; if (!(box instanceof HTMLInputElement) || !box.dataset.action) return;
const row = box.closest("tr"); const row = box.closest("tr");
if (!row) return; if (!row) return;
const viewBox = $('input[data-action="view"]', row); // "read", nicht "view": die Aktion heisst seit 2.8.0 wie das, was sie
if (box.dataset.action === "view") { // tut. Mit dem alten Namen fand diese Zeile das Kaestchen nicht mehr --
// die Matrix haette sich weiter bedienen lassen und dabei die Regel
// "jede Aktion setzt Ansehen voraus" stillschweigend fallen gelassen.
const viewBox = $('input[data-action="read"]', row);
if (box.dataset.action === "read") {
// "Ansehen" abwaehlen entfernt alles andere dieser Zeile. // "Ansehen" abwaehlen entfernt alles andere dieser Zeile.
if (!box.checked) { if (!box.checked) {
$$('input[type="checkbox"][name]', row).forEach((other) => { $$('input[type="checkbox"][name]', row).forEach((other) => {
@@ -469,6 +523,30 @@ function initPermissionMatrix() {
master.dispatchEvent(new Event("change")); master.dispatchEvent(new Event("change"));
} }
}); });
const suche = $("[data-matrix-filter]");
const zaehler = $("[data-matrix-count]");
if (suche) {
const zeilen = $$("tr[data-suchtext]", table);
const filtern = () => {
const begriff = suche.value.trim().toLowerCase();
let sichtbar = 0;
zeilen.forEach((row) => {
const passt = !begriff || (row.dataset.suchtext || "").includes(begriff);
// "hidden" und nicht aus dem Formular nehmen: ein Filter, der
// Kaestchen entfernt, loescht beim Speichern Rechte.
row.hidden = !passt;
if (passt) sichtbar += 1;
});
if (zaehler) {
zaehler.textContent = begriff
? `${sichtbar} von ${zeilen.length} Zeilen`
: `${zeilen.length} Zeilen`;
}
};
suche.addEventListener("input", filtern);
filtern();
}
}); });
} }
@@ -47,9 +47,16 @@
</div> </div>
</div> </div>
<div class="card__body"> <div class="card__body">
{# Auch fuer einen Administrator die Liste zeigen: sein Kennzeichen gibt
den Verwaltungsbereich, der Rest kommt aus Gruppen -- und genau das
soll er hier ablesen koennen. "Darf alles" war bis 2.7.x richtig und
haette danach das Gegenteil dessen behauptet, was gilt. #}
{% if current_user.is_admin %} {% if current_user.is_admin %}
{{ alert('Sie sind Administrator und dürfen unabhängig von Gruppen alles.', 'warning') }} {{ alert('Als Administrator haben Sie den Verwaltungsbereich immer –
{% elif permissions %} Benutzer, Gruppen, Einstellungen, Sicherung, Papierkorb, Diagnose.
Alles Weitere steht unten und kommt aus Ihren Gruppen.', 'info') }}
{% endif %}
{% if permissions %}
<div class="row row--tight"> <div class="row row--tight">
{% for permission in permissions %} {% for permission in permissions %}
<span class="badge" title="{{ permission }}">{{ tree.label_for(permission) }}</span> <span class="badge" title="{{ permission }}">{{ tree.label_for(permission) }}</span>
@@ -23,7 +23,7 @@
Name und Rechte sind gesperrt. Anders als im Vorgänger werden die Rechte Name und Rechte sind gesperrt. Anders als im Vorgänger werden die Rechte
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt &ndash; <strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt &ndash;
Anpassungen bleiben erhalten. Anpassungen bleiben erhalten.
{% if current_user.has('groups.edit') %} {% if current_user.has('groups.write') %}
<a class="btn btn--sm mt-2" href="{{ url_for('admin.group_detail', group_id=group.id, unlocked=1) }}"> <a class="btn btn--sm mt-2" href="{{ url_for('admin.group_detail', group_id=group.id, unlocked=1) }}">
{{ icon('unlock', size=14) }} Rechte freischalten {{ icon('unlock', size=14) }} Rechte freischalten
</a> </a>
@@ -94,7 +94,7 @@
<div class="card__title">{{ icon('users', size=17) }} Mitglieder</div> <div class="card__title">{{ icon('users', size=17) }} Mitglieder</div>
<div class="card__sub">Zuordnung erfolgt in der Benutzerverwaltung</div> <div class="card__sub">Zuordnung erfolgt in der Benutzerverwaltung</div>
</div> </div>
{% if current_user.has('users.view') %} {% if current_user.has('users.read') %}
<a class="btn btn--sm" href="{{ url_for('admin.users') }}">Benutzerverwaltung</a> <a class="btn btn--sm" href="{{ url_for('admin.users') }}">Benutzerverwaltung</a>
{% endif %} {% endif %}
</div> </div>
@@ -162,8 +162,25 @@
<dt>{{ label }}</dt><dd>{{ value if value not in (None, '') else '&ndash;' | safe }}</dd> <dt>{{ label }}</dt><dd>{{ value if value not in (None, '') else '&ndash;' | safe }}</dd>
{%- endmacro %} {%- endmacro %}
{#- Rechtematrix: einmal geschrieben, in beiden Anwendungen benutzt -#} {#- Rechtematrix: einmal geschrieben, in beiden Anwendungen benutzt.
Seit 2.8.0 hat jede Funktion ihre eigene Zeile -- 37 statt 21. Das ist der
Sinn der Sache, macht die Tabelle aber lang, und eine lange Tabelle
verleitet dazu, oben den Bereichsschalter zu setzen und fertig. Drei
Hilfen dagegen: Unterzeilen stehen eingerueckt unter ihrer Hauptzeile, die
Funktion steht in Kleinschrift daneben, und ein Filterfeld blendet aus,
wonach man nicht sucht. -#}
{% macro permission_matrix(tree, action_meta, state, disabled=False, field_prefix='permissions') -%} {% macro permission_matrix(tree, action_meta, state, disabled=False, field_prefix='permissions') -%}
{% if not disabled %}
<div class="matrix__filter">
<label class="field field--inline">
<span class="field__label">Zeile suchen</span>
<input type="search" class="input input--sm" data-matrix-filter
placeholder="z. B. Neustart, DHCP, Sicherung" autocomplete="off">
</label>
<span class="matrix__filter-hint" data-matrix-count></span>
</div>
{% endif %}
<div class="table-wrap"> <div class="table-wrap">
<table class="matrix" data-behavior="permission-matrix"> <table class="matrix" data-behavior="permission-matrix">
{% for area in tree.areas %} {% for area in tree.areas %}
@@ -172,8 +189,20 @@
<tr> <tr>
<th style="min-width:240px">{{ area.label }}</th> <th style="min-width:240px">{{ area.label }}</th>
{% for action in columns %} {% for action in columns %}
<th title="{{ action_meta[action][1] }} &ndash; {{ action_meta[action][2] }}"> <th title="{{ action_meta[action][1] }} ({{ action_meta[action][2] }}) &ndash;
{{ action_meta[action][0] }} {{ action_meta[action][3] }}">
<span class="matrix__spalte">
<b>{{ action_meta[action][0] }}</b>
<span>{{ action_meta[action][1] }}</span>
<small>{{ action_meta[action][2] }}</small>
{% if not disabled %}
<button type="button" class="matrix__alle" data-matrix-column="{{ action.value }}"
data-area="{{ area.key }}" title="Ganze Spalte in diesem Bereich"
aria-label="Alle {{ action_meta[action][1] }}-Rechte dieses Bereichs umschalten">
alle
</button>
{% endif %}
</span>
</th> </th>
{% endfor %} {% endfor %}
</tr> </tr>
@@ -182,9 +211,9 @@
<tr class="matrix__area"> <tr class="matrix__area">
<td class="matrix__killswitch"> <td class="matrix__killswitch">
<label class="check"> <label class="check">
<input type="checkbox" name="{{ field_prefix }}" value="{{ area.view_key }}" <input type="checkbox" name="{{ field_prefix }}" value="{{ area.read_key }}"
data-area-switch="{{ area.key }}" data-area-switch="{{ area.key }}"
{% if state.get(area.key, {}).get('view') %}checked{% endif %} {% if state.get(area.key, {}).get('read') %}checked{% endif %}
{% if disabled %}disabled{% endif %}> {% if disabled %}disabled{% endif %}>
<span>Zugang zum Bereich {{ area.label }}{% if area.hint %} {{ hint(area.hint) }}{% endif %}</span> <span>Zugang zum Bereich {{ area.label }}{% if area.hint %} {{ hint(area.hint) }}{% endif %}</span>
</label> </label>
@@ -192,23 +221,48 @@
{% for action in columns %}<td></td>{% endfor %} {% for action in columns %}<td></td>{% endfor %}
</tr> </tr>
{% for resource in area.resources %} {% for resource in area.resources %}
<tr data-area="{{ area.key }}"> <tr data-area="{{ area.key }}"
data-suchtext="{{ (resource.label ~ ' ' ~ resource.hint) | lower }}"
{% if resource.parent %}class="matrix__unterzeile"{% endif %}>
<td> <td>
<span class="matrix__resource-label"> <span class="matrix__resource-label">
{{ resource.label }} <span class="matrix__zeile">
<b>{{ resource.short_label }}</b>
{% if not disabled %}
{# Ganze Zeile: bei 37 Zeilen mit bis zu sieben Spalten
sind das ueber 200 Kaestchen. Wer einer Rolle eine
Funktion ganz geben will, soll nicht siebenmal
klicken. Hinter der Beschriftung, nicht davor -- vorn
gelesen ergab "alle Clients" einen falschen Satz. #}
<button type="button" class="matrix__alle" data-matrix-row
title="Ganze Zeile an- oder abwählen"
aria-label="Alle Rechte dieser Zeile umschalten">alle</button>
{% endif %}
</span>
{% if resource.hint %}<small>{{ resource.hint }}</small>{% endif %} {% if resource.hint %}<small>{{ resource.hint }}</small>{% endif %}
{# Was eine Aktion in dieser Zeile bedeutet, steht in der
Zeile -- nicht in einem Titelattribut, das man nur mit der
Maus findet, und nicht in einer Legende unter einer
Tabelle, die man dafuer erst zu Ende scrollt. #}
{% for action in resource.actions if resource.action_hints.get(action) %}
<small class="matrix__bedeutung">
<b>{{ action_meta[action][0] }}</b>
{{ action_meta[action][1] }} &ndash;
{{ resource.action_hints.get(action) }}
</small>
{% endfor %}
</span> </span>
</td> </td>
{% for action in columns %} {% for action in columns %}
<td> <td>
{% if resource.has(action) %} {% if resource.has(action) %}
<label class="check" title="{{ resource.action_hints.get(action) or action_meta[action][2] }}"> <label class="check" title="{{ resource.action_hints.get(action) or action_meta[action][3] }}">
<input type="checkbox" name="{{ field_prefix }}" <input type="checkbox" name="{{ field_prefix }}"
value="{{ resource.permission(action) }}" value="{{ resource.permission(action) }}"
data-action="{{ action.value }}" data-action="{{ action.value }}"
{% if state.get(resource.key, {}).get(action.value) %}checked{% endif %} {% if state.get(resource.key, {}).get(action.value) %}checked{% endif %}
{% if disabled %}disabled{% endif %}> {% if disabled %}disabled{% endif %}>
<span class="sr-only">{{ resource.label }} {{ action_meta[action][1] }}</span> <span class="sr-only">{{ resource.label }} {{ action_meta[action][2] }}</span>
</label> </label>
{% else %} {% else %}
{# Bewusst *kein* Eingabefeld: ein deaktiviertes Kästchen hat das {# Bewusst *kein* Eingabefeld: ein deaktiviertes Kästchen hat das
@@ -229,7 +283,7 @@
</div> </div>
<div class="matrix__legend mt-3"> <div class="matrix__legend mt-3">
{% for action, meta in action_meta.items() %} {% for action, meta in action_meta.items() %}
<span><b>{{ meta[0] }}</b> {{ meta[1] }} &ndash; {{ meta[2] }}</span> <span><b>{{ meta[0] }}</b> {{ meta[1] }} ({{ meta[2] }}) &ndash; {{ meta[3] }}</span>
{% endfor %} {% endfor %}
<span><span class="matrix__na"></span> Für diese Zeile nicht vorgesehen &ndash; <span><span class="matrix__na"></span> Für diese Zeile nicht vorgesehen &ndash;
die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span> die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span>
@@ -119,7 +119,13 @@ niemals hier gespeichert.', 'info') }}
<tr> <tr>
<td class="fs-sm">{{ entry.ad_group_name }} <td class="fs-sm">{{ entry.ad_group_name }}
<div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td> <div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td>
<td>{{ pill(entry.group_name or 'unbekannt', 'accent') }}</td> <td>
{% if entry.grants_admin %}
{{ pill('Administrator (Kennzeichen)', 'warning') }}
{% else %}
{{ pill(entry.group_name or 'unbekannt', 'accent') }}
{% endif %}
</td>
<td class="actions"> <td class="actions">
<form method="post" data-no-guard <form method="post" data-no-guard
action="{{ url_for('hostsettings.directory_mapping_delete', mapping_id=entry.id) }}"> action="{{ url_for('hostsettings.directory_mapping_delete', mapping_id=entry.id) }}">
@@ -140,22 +146,35 @@ niemals hier gespeichert.', 'info') }}
'Ohne Zuordnung erhalten Verzeichniskonten nur die Standardgruppe.', 'shield-users') }} 'Ohne Zuordnung erhalten Verzeichniskonten nur die Standardgruppe.', 'shield-users') }}
{% endif %} {% endif %}
{% if current_user.has('settings_directory.edit') %} {% if current_user.has('settings_directory.write') %}
<form method="post" action="{{ url_for('hostsettings.directory_mapping_add') }}"> <form method="post" action="{{ url_for('hostsettings.directory_mapping_add') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body"> <div class="card__body">
<div class="form-grid"> <div class="form-grid">
{{ group_picker('ad_group_name', 'ad_group_dn', 'mapping', required=True) }} {{ group_picker('ad_group_name', 'ad_group_dn', 'mapping', required=True) }}
{# Ohne Ziel ist die Zuordnung sinnlos -- deshalb Pflichtfeld. #} {# Ohne Ziel ist die Zuordnung sinnlos -- deshalb Pflichtfeld.
Das Kennzeichen steht mit in derselben Liste, weil es dieselbe
Frage beantwortet: "was wird dieses Konto hier?". Vorher gab es
nur Gruppen, und "Domänen-Admins" landete deshalb in der Gruppe
*Administration* -- die Benutzer und Rechte pflegt, aber kein
Administratorkennzeichen hat und sich keines geben kann. #}
<label class="field"> <label class="field">
<span class="field__label"> <span class="field__label">
Gruppe in dieser Anwendung<span class="req" aria-hidden="true">*</span> Ziel in dieser Anwendung<span class="req" aria-hidden="true">*</span>
</span> </span>
<select class="select" name="app_group_id" required> <select class="select" name="ziel" required>
{% for group in groups %} {% for group in groups %}
<option value="{{ group.id }}">{{ group.name }}</option> <option value="gruppe:{{ group.id }}">{{ group.name }}</option>
{% endfor %} {% endfor %}
<option value="administrator">Administrator (Kennzeichen)</option>
</select> </select>
<span class="field__hint">
„Administrator (Kennzeichen)“ macht Mitglieder dieser Verzeichnisgruppe zu
echten Administratoren: sie haben immer den ganzen Verwaltungsbereich.
Funktionsrechte kommen weiter über Gruppen. Wer aus der Verzeichnisgruppe
austritt, verliert das Kennzeichen bei der nächsten Anmeldung.
</span>
</label> </label>
</div> </div>
</div> </div>
@@ -58,7 +58,7 @@ zurückgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
{% endif %} {% endif %}
</section> </section>
{% if current_user.has('settings_network.edit') and interfaces %} {% if current_user.has('settings_network.write') and interfaces %}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
@@ -41,7 +41,7 @@ gültige, aktivierte Lizenz. Abgelaufene Einträge werden beim Aufruf dieser Sei
<td class="muted fs-sm">{{ entry.detail }}</td> <td class="muted fs-sm">{{ entry.detail }}</td>
<td class="fs-sm"><span data-relative-time="{{ entry.deleted_at }}">{{ entry.deleted_at | dt }}</span></td> <td class="fs-sm"><span data-relative-time="{{ entry.deleted_at }}">{{ entry.deleted_at | dt }}</span></td>
<td class="actions"> <td class="actions">
{% if current_user.has('trash.edit') %} {% if current_user.has('trash.write') %}
<form method="post" style="display:inline" data-no-guard <form method="post" style="display:inline" data-no-guard
action="{{ url_for('trash.restore', kind=section.key, ident=entry.id) }}"> action="{{ url_for('trash.restore', kind=section.key, ident=entry.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -79,7 +79,7 @@
{% else %}<span class="faint">noch nie</span>{% endif %} {% else %}<span class="faint">noch nie</span>{% endif %}
</td> </td>
<td class="actions"> <td class="actions">
{% if current_user.has('users.edit') %} {% if current_user.has('users.write') %}
<button class="btn btn--sm btn--ghost" type="button" <button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="user-edit-{{ user.id }}" aria-label="Bearbeiten"> data-dialog-open="user-edit-{{ user.id }}" aria-label="Bearbeiten">
{{ icon('pencil', size=15) }} {{ icon('pencil', size=15) }}
@@ -137,7 +137,18 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
</div> </div>
</fieldset> </fieldset>
{% if current_user.is_admin %} {% if current_user.is_admin %}
{{ checkbox('is_admin', 'Administrator (darf alles, unabhängig von Gruppen)') }} {# Das Kennzeichen heisst seit 2.8.0 "Verwaltung" und nicht "alles":
Geraete, Neustarts, DHCP und Freigaben kommen ueber Gruppen.
Stand hier weiter "darf alles", waere die Beschriftung eine
falsche Zusage -- und niemand wuerde die Gruppe dazugeben. #}
{{ checkbox('is_admin',
'Administrator – hat immer den ganzen Verwaltungsbereich') }}
<p class="fs-xs faint">
Benutzer, Gruppen, Einstellungen, Sicherung, Papierkorb und Diagnose gelten
für ein Administratorkonto immer, auch ohne Gruppe. Alles andere – Geräte,
Neustarts, DHCP, Dateifreigaben, Protokolle – kommt über Gruppen; die
Gruppe „Funktionen (Vollzugriff)“ gibt es dafür schon.
</p>
{% endif %} {% endif %}
{{ checkbox('must_change_password', 'Passwortwechsel bei der ersten Anmeldung erzwingen', checked=True) }} {{ checkbox('must_change_password', 'Passwortwechsel bei der ersten Anmeldung erzwingen', checked=True) }}
</div> </div>
@@ -180,7 +191,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if current_user.has('users.edit') %} {% if current_user.has('users.write') %}
{% for user in users %} {% for user in users %}
{% call modal('user-edit-' ~ user.id, 'Konto ' ~ user.username, {% call modal('user-edit-' ~ user.id, 'Konto ' ~ user.username,
'Verzeichniskonto' if user.auth_source == 'ldap' else 'Lokales Konto') %} 'Verzeichniskonto' if user.auth_source == 'ldap' else 'Lokales Konto') %}
@@ -211,7 +222,8 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
</fieldset> </fieldset>
<div class="stack stack--tight"> <div class="stack stack--tight">
{% if current_user.is_admin and user.id != current_user.id %} {% if current_user.is_admin and user.id != current_user.id %}
{{ checkbox('is_admin', 'Administrator', checked=user.is_admin) }} {{ checkbox('is_admin', 'Administrator – hat immer den ganzen Verwaltungsbereich',
checked=user.is_admin) }}
{% elif user.is_admin %} {% elif user.is_admin %}
{{ pill('Administrator', 'accent') }} {{ pill('Administrator', 'accent') }}
{% endif %} {% endif %}
+1 -1
View File
@@ -326,7 +326,7 @@ def audit_detail(text: Any) -> Markup:
if not roh or not MITSCHRIFT.search(roh): if not roh or not MITSCHRIFT.search(roh):
return Markup(escape(roh)) return Markup(escape(roh))
try: try:
erlaubt = bool(current_user.is_authenticated and current_user.has("logs_history.view")) erlaubt = bool(current_user.is_authenticated and current_user.has("logs_history.read"))
except Exception: # noqa: BLE001 - ausserhalb einer Anfrage except Exception: # noqa: BLE001 - ausserhalb einer Anfrage
erlaubt = False erlaubt = False
if not erlaubt: if not erlaubt:
@@ -48,7 +48,7 @@ def _int_list(name: str) -> list[int]:
@bp.get("/users") @bp.get("/users")
@permission_required("users.view") @permission_required("users.read")
def users(): def users():
conn = db() conn = db()
return render_template( return render_template(
@@ -95,7 +95,7 @@ def user_create():
@bp.post("/users/<int:user_id>") @bp.post("/users/<int:user_id>")
@permission_required("users.edit") @permission_required("users.write")
def user_update(user_id: int): def user_update(user_id: int):
conn = db() conn = db()
try: try:
@@ -164,7 +164,7 @@ def user_delete(user_id: int):
@bp.post("/users/<int:user_id>/end-sessions") @bp.post("/users/<int:user_id>/end-sessions")
@permission_required("users.edit") @permission_required("users.write")
def user_revoke_sessions(user_id: int): def user_revoke_sessions(user_id: int):
conn = db() conn = db()
target = rbac.get_user(conn, user_id) target = rbac.get_user(conn, user_id)
@@ -239,7 +239,7 @@ def directory_adopt():
@bp.get("/groups") @bp.get("/groups")
@permission_required("groups.view") @permission_required("groups.read")
def groups(): def groups():
conn = db() conn = db()
tree = core().permissions tree = core().permissions
@@ -255,7 +255,7 @@ def groups():
@bp.get("/groups/<int:group_id>") @bp.get("/groups/<int:group_id>")
@permission_required("groups.view") @permission_required("groups.read")
def group_detail(group_id: int): def group_detail(group_id: int):
conn = db() conn = db()
group = rbac.get_group(conn, group_id) group = rbac.get_group(conn, group_id)
@@ -274,7 +274,7 @@ def group_detail(group_id: int):
"WHERE ug.group_id=? AND u.deleted_at IS NULL ORDER BY u.username COLLATE NOCASE", "WHERE ug.group_id=? AND u.deleted_at IS NULL ORDER BY u.username COLLATE NOCASE",
(group_id,), (group_id,),
), ),
can_edit=current_user.has("groups.edit"), can_edit=current_user.has("groups.write"),
unlocked=request.args.get("unlocked") == "1", unlocked=request.args.get("unlocked") == "1",
) )
@@ -302,7 +302,7 @@ def group_create():
@bp.post("/groups/<int:group_id>") @bp.post("/groups/<int:group_id>")
@permission_required("groups.edit") @permission_required("groups.write")
def group_update(group_id: int): def group_update(group_id: int):
conn = db() conn = db()
try: try:
@@ -28,7 +28,7 @@ bp = Blueprint("diagnostics", __name__, url_prefix="/administration/diagnostics"
@bp.get("/") @bp.get("/")
@permission_required("diagnostics.view") @permission_required("diagnostics.read")
def overview(): def overview():
extension = core() extension = core()
conn = db() conn = db()
@@ -65,7 +65,7 @@ def _license_hint(feature: str) -> str:
@bp.get("/network") @bp.get("/network")
@permission_required("settings_network.view") @permission_required("settings_network.read")
def network(): def network():
extension = core() extension = core()
sysops = extension.sysops sysops = extension.sysops
@@ -81,7 +81,7 @@ def network():
@bp.post("/network") @bp.post("/network")
@permission_required("settings_network.edit") @permission_required("settings_network.write")
def network_apply(): def network_apply():
extension = core() extension = core()
sysops = extension.sysops sysops = extension.sysops
@@ -148,7 +148,7 @@ def network_apply():
@bp.post("/network/confirm") @bp.post("/network/confirm")
@permission_required("settings_network.edit") @permission_required("settings_network.write")
def network_confirm(): def network_confirm():
if core().registry.pop(PENDING_NETWORK_KEY, None) is None: if core().registry.pop(PENDING_NETWORK_KEY, None) is None:
flash("Es liegt keine Änderung zur Bestätigung vor.", "info") flash("Es liegt keine Änderung zur Bestätigung vor.", "info")
@@ -222,7 +222,7 @@ def _web_config() -> webserver.WebConfig:
@bp.get("/web") @bp.get("/web")
@permission_required("settings_web.view") @permission_required("settings_web.read")
def web(): def web():
extension = core() extension = core()
config = _web_config() config = _web_config()
@@ -241,7 +241,7 @@ def web():
@bp.post("/web") @bp.post("/web")
@permission_required("settings_web.edit") @permission_required("settings_web.write")
def web_save(): def web_save():
extension = core() extension = core()
conn = db() conn = db()
@@ -415,7 +415,7 @@ def web_self_signed():
@bp.get("/directory") @bp.get("/directory")
@permission_required("settings_directory.view") @permission_required("settings_directory.read")
def directory_settings(): def directory_settings():
extension = core() extension = core()
conn = db() conn = db()
@@ -436,7 +436,7 @@ def directory_settings():
@bp.post("/directory") @bp.post("/directory")
@permission_required("settings_directory.edit") @permission_required("settings_directory.write")
def directory_save(): def directory_save():
problem = _license_hint("directory") problem = _license_hint("directory")
if problem: if problem:
@@ -490,7 +490,7 @@ def directory_test():
@bp.get("/directory/group-search") @bp.get("/directory/group-search")
@permission_required("settings_directory.edit", "fileshare.edit", mode="any") @permission_required("settings_directory.write", "fileshare.write", mode="any")
def directory_group_search(): def directory_group_search():
"""Sucht Gruppen im Verzeichnis -- fuer die Auswahlfelder. """Sucht Gruppen im Verzeichnis -- fuer die Auswahlfelder.
@@ -505,21 +505,30 @@ def directory_group_search():
@bp.post("/directory/mapping") @bp.post("/directory/mapping")
@permission_required("settings_directory.edit") @permission_required("settings_directory.write")
def directory_mapping_add(): def directory_mapping_add():
conn = db() conn = db()
dn = (request.form.get("ad_group_dn") or "").strip() dn = (request.form.get("ad_group_dn") or "").strip()
name = (request.form.get("ad_group_name") or "").strip() name = (request.form.get("ad_group_name") or "").strip()
group_id = request.form.get("app_group_id") # Ein Feld, zwei Arten von Ziel: "gruppe:<id>" oder "administrator". Zwei
if not dn or not group_id: # Felder waeren zwei Zustaende, von denen einer immer falsch ist.
flash("Verzeichnisgruppe und Zielgruppe sind beide erforderlich.", "danger") ziel = (request.form.get("ziel") or request.form.get("app_group_id") or "").strip()
kennzeichen = ziel == "administrator"
gruppe: int | None = None
if not kennzeichen:
nummer = ziel.split(":", 1)[1] if ziel.startswith("gruppe:") else ziel
gruppe = int(nummer) if nummer.isdigit() else None
if not dn or (gruppe is None and not kennzeichen):
flash("Verzeichnisgruppe und Ziel sind beide erforderlich.", "danger")
return redirect(url_for("hostsettings.directory_settings")) return redirect(url_for("hostsettings.directory_settings"))
with transaction(): with transaction():
conn.execute( conn.execute(
"INSERT INTO ldap_group_mappings (ad_group_dn, ad_group_name, app_group_id, created_at) " "INSERT INTO ldap_group_mappings "
"VALUES (?,?,?,datetime('now')) ON CONFLICT(ad_group_dn) DO UPDATE SET " "(ad_group_dn, ad_group_name, app_group_id, grants_admin, created_at) "
"ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id", "VALUES (?,?,?,?,datetime('now')) ON CONFLICT(ad_group_dn) DO UPDATE SET "
(dn, name or dn, int(group_id)), "ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id, "
"grants_admin=excluded.grants_admin",
(dn, name or dn, gruppe, int(kennzeichen)),
) )
audit.log( audit.log(
conn, conn,
@@ -527,15 +536,22 @@ def directory_mapping_add():
actor_id=current_user.id, actor_id=current_user.id,
action="settings.directory_mapping_added", action="settings.directory_mapping_added",
target=name or dn, target=name or dn,
detail=(
"Ziel: Administrator (Kennzeichen)"
if kennzeichen
else f"Ziel: Gruppe {gruppe}"
),
ip=client_ip(), ip=client_ip(),
severity="notice", # Eine Zuordnung, die Administratoren erzeugt, ist mehr als eine
# Notiz wert: sie entscheidet, wer die Anlage verwaltet.
severity="warning" if kennzeichen else "notice",
) )
flash("Zuordnung gespeichert.", "success") flash("Zuordnung gespeichert.", "success")
return redirect(url_for("hostsettings.directory_settings")) return redirect(url_for("hostsettings.directory_settings"))
@bp.post("/directory/mapping/<int:mapping_id>/delete") @bp.post("/directory/mapping/<int:mapping_id>/delete")
@permission_required("settings_directory.edit") @permission_required("settings_directory.write")
def directory_mapping_delete(mapping_id: int): def directory_mapping_delete(mapping_id: int):
with transaction() as conn: with transaction() as conn:
conn.execute("DELETE FROM ldap_group_mappings WHERE id=?", (mapping_id,)) conn.execute("DELETE FROM ldap_group_mappings WHERE id=?", (mapping_id,))
@@ -39,7 +39,7 @@ def _live_log():
@bp.get("/live") @bp.get("/live")
@permission_required("logs_live.view") @permission_required("logs_live.read")
def live(): def live():
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES) lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
needle = (request.args.get("q") or "").strip() needle = (request.args.get("q") or "").strip()
@@ -53,7 +53,7 @@ def live():
@bp.get("/live/content") @bp.get("/live/content")
@permission_required("logs_live.view") @permission_required("logs_live.read")
@fragment @fragment
def live_fragment(): def live_fragment():
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES) lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
@@ -64,7 +64,7 @@ def live_fragment():
@bp.get("/history") @bp.get("/history")
@permission_required("logs_history.view", "logs_history.export", mode="any") @permission_required("logs_history.read", "logs_history.export", mode="any")
def history(): def history():
extension = core() extension = core()
rotated = [] rotated = []
@@ -83,7 +83,7 @@ def history():
@bp.get("/history/file/<path:name>") @bp.get("/history/file/<path:name>")
@permission_required("logs_history.view") @permission_required("logs_history.read")
def history_file(name: str): def history_file(name: str):
extension = core() extension = core()
target = (extension.config.log_dir / name).resolve() target = (extension.config.log_dir / name).resolve()
@@ -102,7 +102,7 @@ def history_file(name: str):
@bp.get("/history/archive/<day>") @bp.get("/history/archive/<day>")
@permission_required("logs_history.view") @permission_required("logs_history.read")
def history_archive(day: str): def history_archive(day: str):
if len(day) != 10 or day.count("-") != 2: if len(day) != 10 or day.count("-") != 2:
abort(404) abort(404)
@@ -177,7 +177,7 @@ def history_purge():
@bp.get("/audit") @bp.get("/audit")
@permission_required("logs_audit.view", "logs_audit.export", mode="any") @permission_required("logs_audit.read", "logs_audit.export", mode="any")
def audit_view(): def audit_view():
conn = db() conn = db()
page = max(1, int(request.args.get("page") or 1)) page = max(1, int(request.args.get("page") or 1))
@@ -146,7 +146,7 @@ def _purge_expired(conn: sqlite3.Connection) -> int:
@bp.get("/") @bp.get("/")
@permission_required("trash.view") @permission_required("trash.read")
def overview(): def overview():
conn = db() conn = db()
removed = 0 removed = 0
@@ -192,7 +192,7 @@ def _provider_or_404(key: str) -> TrashProvider:
@bp.post("/<kind>/<ident>/restore") @bp.post("/<kind>/<ident>/restore")
@permission_required("trash.edit") @permission_required("trash.write")
def restore(kind: str, ident: str): def restore(kind: str, ident: str):
extension = core() extension = core()
if extension.license is not None and not extension.license.feature_allowed("trash_restore"): if extension.license is not None and not extension.license.feature_allowed("trash_restore"):