Version 2.8.0: Rechtekonzept -- eine Zeile je Funktion, Administrator heisst Verwaltung

This commit is contained in:
alientim committed 2026-09-12 13:27:46 +02:00
1 parent c3c6eae72d
commit f6b0bbaad1
47 files changed
+1366 -304

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-license"
version = "2.7.7"
version = "2.8.0"
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
requires-python = ">=3.11"
dependencies = [
@@ -180,7 +180,7 @@ def _reencrypt_secrets(conn: Any, keystore: Any) -> int:
def _status_pills(user: Any) -> list[dict[str, str]]:
if not user.is_authenticated or not user.has_any(("licenses.view",)):
if not user.is_authenticated or not user.has_any(("licenses.read",)):
return []
from tesm_core.extension import db
@@ -24,14 +24,14 @@ def _allowed_categories() -> list[transfer.Category]:
for category in transfer.CATEGORIES:
if category.admin_only and not current_user.is_admin:
continue
if category.sensitive and not current_user.has("backup.secrets"):
if category.sensitive and not current_user.has("backup_export.secrets"):
continue
allowed.append(category)
return allowed
@bp.get("/")
@permission_required("backup.view", "backup.export", "backup.execute", mode="any")
@permission_required("backup_export.read", "backup_import.read", mode="any")
def overview():
conn = db()
preview = None
@@ -50,13 +50,13 @@ def overview():
preview=preview,
min_passphrase=transfer.MIN_PASSPHRASE_LENGTH,
preview_ttl=transfer.PREVIEW_TTL_MINUTES,
can_secrets=current_user.has("backup.secrets"),
can_secrets=current_user.has("backup_export.secrets"),
signing_key_path=str(core().registry["master_key_path"]),
)
@bp.post("/export")
@permission_required("backup.export")
@permission_required("backup_export.export")
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
def export():
conn = db()
@@ -95,7 +95,7 @@ def export():
@bp.post("/import/preview")
@permission_required("backup.execute")
@permission_required("backup_import.execute")
@reauth_required
def import_preview():
conn = db()
@@ -132,7 +132,7 @@ def import_preview():
@bp.post("/import/apply")
@permission_required("backup.execute")
@permission_required("backup_import.execute")
@reauth_required
def import_apply():
extension = core()
@@ -191,7 +191,7 @@ def import_apply():
@bp.post("/import/discard")
@permission_required("backup.execute")
@permission_required("backup_import.execute")
def import_discard():
token = session.pop(PREVIEW_SESSION_KEY, "")
if token:
@@ -29,13 +29,13 @@ def _form() -> dict[str, str]:
@bp.get("/")
@permission_required("customers.view")
@permission_required("customers.read")
def overview():
return render_template("license/customers.html", customers=issuance.list_customers(db()))
@bp.get("/<int:customer_id>")
@permission_required("customers.view")
@permission_required("customers.read")
def detail(customer_id: int):
conn = db()
customer = issuance.get_customer(conn, customer_id)
@@ -48,7 +48,7 @@ def detail(customer_id: int):
]
reported = (
reporting.reported_for_customer(conn, customer_id)
if current_user.has("reports.view")
if current_user.has("reports.read")
else {"customers": [], "source_license_id": ""}
)
return render_template(
@@ -83,7 +83,7 @@ def create():
@bp.post("/<int:customer_id>")
@permission_required("customers.edit")
@permission_required("customers.write")
def update(customer_id: int):
try:
with transaction() as conn:
@@ -21,7 +21,7 @@ bp = Blueprint("dashboard", __name__)
def index():
conn = db()
extension = core()
if not current_user.has("licenses.view"):
if not current_user.has("licenses.read"):
# Leere Huelle statt 403: die Startseite soll fuer jeden erreichbar
# bleiben, auch fuer ein Konto ohne Lizenzrechte.
return render_template("license/dashboard.html", summary=None, sources=[])
@@ -30,7 +30,7 @@ def index():
return render_template(
"license/dashboard.html",
summary=summary,
sources=reporting.reporting_sources(conn) if current_user.has("reports.view") else [],
sources=reporting.reporting_sources(conn) if current_user.has("reports.read") else [],
own_role=(extension.license.state.role if extension.license else "master"),
type_labels=dict(),
)
@@ -42,7 +42,7 @@ bp = Blueprint("licenses", __name__, url_prefix="/licenses")
@bp.get("/")
@permission_required("licenses.view")
@permission_required("licenses.read")
def overview():
conn = db()
rows = [
@@ -65,7 +65,7 @@ def overview():
@bp.get("/<license_id>")
@permission_required("licenses.view")
@permission_required("licenses.read")
def detail(license_id: str):
conn = db()
row = issuance.get_license(conn, license_id)
@@ -100,7 +100,7 @@ def detail(license_id: str):
@bp.post("/from-ticket/<ticket_id>")
@permission_required("licenses.create")
@permission_required("license_issue.execute")
def issue(ticket_id: str):
conn = db()
as_trial = request.form.get("as_trial") == "1"
@@ -182,7 +182,7 @@ def download(license_id: str):
@bp.post("/<license_id>/resign")
@permission_required("licenses.edit")
@permission_required("license_renew.execute")
@reauth_required
def resign(license_id: str):
"""Signiert eine neue Revision -- z. B. nach einer Modulerweiterung.
@@ -241,7 +241,7 @@ def resign(license_id: str):
@bp.post("/<license_id>/revoke")
@permission_required("licenses.edit")
@permission_required("license_revoke.execute")
def revoke(license_id: str):
conn = db()
try:
@@ -369,7 +369,7 @@ def send_mail(license_id: str):
@bp.route("/offline-code", methods=["GET", "POST"])
@permission_required("licenses.execute")
@permission_required("license_activation.execute")
def manual_code():
"""Verarbeitet einen Offline-Code des Kunden.
@@ -429,7 +429,7 @@ def manual_code():
@bp.get("/sub-servers")
@permission_required("reports.view")
@permission_required("reports.read")
def reports():
conn = db()
sources = reporting.reporting_sources(conn)
@@ -441,7 +441,7 @@ def reports():
@bp.get("/sub-servers/<license_id>")
@permission_required("reports.view")
@permission_required("reports.read")
def report_detail(license_id: str):
conn = db()
row = issuance.get_license(conn, license_id)
@@ -42,7 +42,7 @@ MAX_UPLOAD_BYTES = 256 * 1024
@bp.get("/")
@permission_required("settings_license.view")
@permission_required("settings_license.read")
def overview():
extension = core()
conn = db()
@@ -69,7 +69,7 @@ def overview():
@bp.post("/install")
@permission_required("settings_license.edit")
@permission_required("settings_license.write")
def upload():
extension = core()
manager = extension.license
@@ -130,26 +130,26 @@ def _handshake(action: str):
@bp.post("/activate")
@permission_required("settings_license.edit")
@permission_required("settings_license.write")
def activate():
return _handshake("activate")
@bp.post("/deactivate")
@permission_required("settings_license.edit")
@permission_required("settings_license.write")
@reauth_required
def deactivate():
return _handshake("deactivate")
@bp.post("/heartbeat")
@permission_required("settings_license.edit")
@permission_required("settings_license.write")
def heartbeat():
return _handshake("heartbeat")
@bp.post("/offline-response")
@permission_required("settings_license.edit")
@permission_required("settings_license.write")
def offline_response():
manager = core().license
if manager is None:
@@ -167,7 +167,7 @@ def offline_response():
@bp.get("/signing-key")
@permission_required("settings_license.view")
@permission_required("settings_license.read")
def signing_key_info():
"""Zeigt den oeffentlichen Signaturschluessel -- zum Pinnen beim Kunden.
@@ -35,7 +35,7 @@ LOGO_MAGIC = {
@bp.get("/system")
@permission_required("settings_system.view")
@permission_required("settings_system.read")
def system():
extension = core()
conn = db()
@@ -85,7 +85,7 @@ def _timezone_choices() -> list[str]:
@bp.post("/system")
@permission_required("settings_system.edit")
@permission_required("settings_system.write")
def system_save():
extension = core()
conn = db()
@@ -179,7 +179,7 @@ def set_timezone():
@bp.get("/vendor")
@permission_required("settings_vendor.view")
@permission_required("settings_vendor.read")
def vendor():
extension = core()
conn = db()
@@ -201,7 +201,7 @@ def vendor():
@bp.post("/vendor")
@permission_required("settings_vendor.edit")
@permission_required("settings_vendor.write")
def vendor_save():
extension = core()
conn = db()
@@ -251,7 +251,7 @@ def vendor_save():
@bp.post("/vendor/remove-logo")
@permission_required("settings_vendor.edit")
@permission_required("settings_vendor.write")
def vendor_logo_remove():
with transaction() as conn:
core().settings.set(conn, "vendor_logo_base64", "", actor=current_user.username)
@@ -265,7 +265,7 @@ def vendor_logo_remove():
@bp.get("/email")
@permission_required("settings_mail.view")
@permission_required("settings_mail.read")
def mail_settings():
extension = core()
conn = db()
@@ -280,7 +280,7 @@ def mail_settings():
@bp.post("/email")
@permission_required("settings_mail.edit")
@permission_required("settings_mail.write")
def mail_save():
extension = core()
conn = db()
@@ -25,7 +25,7 @@ bp = Blueprint("tickets", __name__, url_prefix="/tickets")
@bp.get("/")
@permission_required("tickets.view")
@permission_required("tickets.read")
def overview():
conn = db()
tickets = issuance.list_tickets(conn)
@@ -113,7 +113,7 @@ def _endpoint_missing() -> str:
@bp.get("/<ticket_id>")
@permission_required("tickets.view")
@permission_required("tickets.read")
def detail(ticket_id: str):
conn = db()
ticket = issuance.get_ticket(conn, ticket_id)
@@ -150,7 +150,7 @@ def detail(ticket_id: str):
@bp.post("/<ticket_id>")
@permission_required("tickets.edit")
@permission_required("tickets.write")
def update(ticket_id: str):
conn = db()
if issuance.get_ticket(conn, ticket_id) is None:
@@ -187,7 +187,7 @@ def update(ticket_id: str):
@bp.post("/<ticket_id>/lock")
@permission_required("tickets.edit")
@permission_required("tickets.write")
def toggle_block(ticket_id: str):
conn = db()
ticket = issuance.get_ticket(conn, ticket_id)
@@ -34,35 +34,35 @@ NAVIGATION = NavTree(
label="Kunden",
icon="building",
endpoint="customers.overview",
permissions=("customers.view",),
permissions=("customers.read",),
),
NavItem(
key="tickets",
label="Tickets",
icon="ticket",
endpoint="tickets.overview",
permissions=("tickets.view",),
permissions=("tickets.read",),
),
NavItem(
key="licenses",
label="Lizenzen",
icon="shield",
endpoint="licenses.overview",
permissions=("licenses.view",),
permissions=("licenses.read",),
),
NavItem(
key="offline_code",
label="Offline-Aktivierung",
icon="transfer",
endpoint="licenses.manual_code",
permissions=("licenses.execute",),
permissions=("license_activation.execute",),
),
NavItem(
key="reports",
label="Sub-Lizenzserver",
icon="share",
endpoint="licenses.reports",
permissions=("reports.view",),
permissions=("reports.read",),
condition=_is_master,
),
NavItem(
@@ -75,21 +75,21 @@ NAVIGATION = NavTree(
label="Laufend",
icon="terminal",
endpoint="logs.live",
permissions=("logs_live.view",),
permissions=("logs_live.read",),
),
NavItem(
key="logs_history",
label="Verlauf",
icon="clock",
endpoint="logs.history",
permissions=("logs_history.view", "logs_history.export"),
permissions=("logs_history.read", "logs_history.export"),
),
NavItem(
key="logs_audit",
label="Audit",
icon="history",
endpoint="logs.audit_view",
permissions=("logs_audit.view", "logs_audit.export"),
permissions=("logs_audit.read", "logs_audit.export"),
),
),
),
@@ -103,84 +103,84 @@ NAVIGATION = NavTree(
label="Benutzer",
icon="users",
endpoint="admin.users",
permissions=("users.view",),
permissions=("users.read",),
),
NavItem(
key="groups",
label="Gruppen und Rechte",
icon="shield-users",
endpoint="admin.groups",
permissions=("groups.view",),
permissions=("groups.read",),
),
NavItem(
key="settings_vendor",
label="Anbieterdaten",
icon="building",
endpoint="settings.vendor",
permissions=("settings_vendor.view",),
permissions=("settings_vendor.read",),
),
NavItem(
key="settings_mail",
label="E-Mail-Versand",
icon="mail",
endpoint="settings.mail_settings",
permissions=("settings_mail.view",),
permissions=("settings_mail.read",),
),
NavItem(
key="settings_system",
label="System",
icon="sliders",
endpoint="settings.system",
permissions=("settings_system.view",),
permissions=("settings_system.read",),
),
NavItem(
key="settings_network",
label="Netzwerk",
icon="network",
endpoint="hostsettings.network",
permissions=("settings_network.view",),
permissions=("settings_network.read",),
),
NavItem(
key="settings_web",
label="Webserver und TLS",
icon="server",
endpoint="hostsettings.web",
permissions=("settings_web.view",),
permissions=("settings_web.read",),
),
NavItem(
key="settings_directory",
label="Verzeichnisdienst",
icon="users",
endpoint="hostsettings.directory_settings",
permissions=("settings_directory.view",),
permissions=("settings_directory.read",),
),
NavItem(
key="settings_license",
label="Eigene Lizenz",
icon="key",
endpoint="own_license.overview",
permissions=("settings_license.view",),
permissions=("settings_license.read",),
),
NavItem(
key="backup",
label="Sicherung",
icon="transfer",
endpoint="backup.overview",
permissions=("backup.view", "backup.export", "backup.execute"),
permissions=("backup_export.read", "backup_import.read"),
),
NavItem(
key="trash",
label="Papierkorb",
icon="trash",
endpoint="trash.overview",
permissions=("trash.view",),
permissions=("trash.read",),
),
NavItem(
key="diagnostics",
label="Diagnose",
icon="info",
endpoint="diagnostics.overview",
permissions=("diagnostics.view",),
permissions=("diagnostics.read",),
),
),
),
+118 -37
View File
@@ -28,7 +28,7 @@ A = Action
CUSTOMERS = Resource(
key="customers",
label="Kunden",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Stammdaten der Kundeninstanzen",
action_hints={A.DELETE: "Nur möglich, solange kein Ticket am Kunden hängt."},
)
@@ -36,32 +36,71 @@ CUSTOMERS = Resource(
TICKETS = Resource(
key="tickets",
label="Tickets",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Dauerhafte Ansprüche auf eine Lizenz",
action_hints={
A.EDIT: "Parameter ändern und Ticket sperren/entsperren. Wirkt erst auf die "
A.WRITE: "Parameter ändern und Ticket sperren/entsperren. Wirkt erst auf die "
"nächste Ausstellung – bereits ausgestellte Lizenzen bleiben unverändert.",
A.DELETE: "Nur möglich, wenn keine offene Lizenz mehr existiert.",
},
)
#: Aus einer Zeile wurden fuenf. "Aendern" hiess hier **widerrufen oder neu
#: signieren** -- zwei Eingriffe mit entgegengesetzter Wirkung unter einem
#: Haken: der eine verlaengert einem Kunden die Lizenz, der andere nimmt sie
#: ihm. Wer eine Laufzeit verlaengern durfte, konnte damit jede Lizenz
#: abschalten. Genau das soll die Matrix trennen koennen.
LICENSES = Resource(
key="licenses",
label="Lizenzen",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE, A.EXPORT),
hint="Konkrete, signierte Lizenzdateien",
actions=(A.READ, A.DELETE, A.EXPORT),
hint="Ausgestellte Lizenzen: Laufzeit, Zustand, Datei",
action_hints={
A.CREATE: "Aus einem Ticket eine Lizenz ausstellen.",
A.EDIT: "Lizenz widerrufen oder eine neue Revision signieren.",
A.EXECUTE: "Offline-Aktivierungscodes verarbeiten.",
A.DELETE: "Eintrag entfernen (nur ohne offene Aktivierung).",
A.EXPORT: "Lizenzdatei herunterladen oder per E-Mail versenden.",
},
)
LICENSE_ISSUE = Resource(
key="license_issue",
parent="licenses",
label="Lizenz ausstellen",
actions=(A.READ, A.EXECUTE),
hint="Aus einem Ticket eine neue Lizenz signieren",
action_hints={A.EXECUTE: "Ausstellen und signieren."},
)
LICENSE_RENEW = Resource(
key="license_renew",
parent="licenses",
label="Lizenz erneuern",
actions=(A.READ, A.EXECUTE),
hint="Neue Revision einer bestehenden Lizenz signieren",
action_hints={A.EXECUTE: "Erneuern – die alte Revision verliert ihre Gültigkeit."},
)
LICENSE_REVOKE = Resource(
key="license_revoke",
parent="licenses",
label="Lizenz widerrufen",
actions=(A.READ, A.EXECUTE),
hint="Eine ausgestellte Lizenz ungültig machen",
action_hints={A.EXECUTE: "Widerrufen. Der Kunde verliert den Zugang."},
)
LICENSE_ACTIVATION = Resource(
key="license_activation",
parent="licenses",
label="Aktivierung von Hand",
actions=(A.READ, A.EXECUTE),
hint="Offline-Codes für Kunden ohne Internetzugang",
action_hints={A.EXECUTE: "Code prüfen und Antwortcode erzeugen."},
)
PORTAL = Resource(
key="portal",
label="Kundenportal",
actions=(A.VIEW, A.EXECUTE),
actions=(A.READ, A.EXECUTE),
hint="Zugangslinks für Kunden",
action_hints={A.EXECUTE: "Zugangslink erzeugen und versenden."},
)
@@ -69,14 +108,14 @@ PORTAL = Resource(
REPORTS = Resource(
key="reports",
label="Berichte der Sub-Lizenzserver",
actions=(A.VIEW,),
actions=(A.READ,),
hint="Von untergeordneten Lizenzservern gemeldete Kunden, Tickets und Lizenzen",
)
MAIL = Resource(
key="settings_mail",
label="E-Mail-Versand",
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS),
actions=(A.READ, A.WRITE, A.EXECUTE, A.SECRETS),
hint="Microsoft Graph (App-only)",
action_hints={
A.EXECUTE: "Verbindungstest ausführen.",
@@ -87,7 +126,7 @@ MAIL = Resource(
VENDOR = Resource(
key="settings_vendor",
label="Anbieterdaten",
actions=(A.VIEW, A.EDIT),
actions=(A.READ, A.WRITE),
hint="Name, Kontakt, Logo und öffentlicher Endpunkt – reisen in jeder Lizenz mit",
)
@@ -96,7 +135,17 @@ LICENSING_AREA = Area(
label="Lizenzierung",
icon="key",
hint="Kunden, Tickets, Lizenzen. Ohne diesen Zugang bleibt der gesamte Bereich verborgen.",
resources=(CUSTOMERS, TICKETS, LICENSES, PORTAL, REPORTS),
resources=(
CUSTOMERS,
TICKETS,
LICENSES,
LICENSE_ISSUE,
LICENSE_RENEW,
LICENSE_REVOKE,
LICENSE_ACTIVATION,
PORTAL,
REPORTS,
),
)
PERMISSIONS = PermissionTree(
@@ -108,12 +157,38 @@ 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, ...] = (
RolePreset(
key="viewer",
name="Benutzer",
description="Standardgruppe: nur das laufende Protokoll lesen.",
grants=(("logs_live", (A.VIEW,)),),
grants=(("logs_live", (A.READ,)),),
is_default=True,
),
RolePreset(
@@ -123,10 +198,13 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
grants=(
("customers", "*"),
("tickets", "*"),
("licenses", (A.VIEW, A.CREATE, A.EDIT, A.EXPORT)),
("licenses", (A.READ, A.EXPORT)),
("license_issue", "*"),
("license_renew", "*"),
("license_revoke", "*"),
("portal", "*"),
("logs_live", (A.VIEW,)),
("logs_audit", (A.VIEW,)),
("logs_live", (A.READ,)),
("logs_audit", (A.READ,)),
),
),
RolePreset(
@@ -134,11 +212,12 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
name="Support",
description="Lesen und Offline-Aktivierungen verarbeiten.",
grants=(
("customers", (A.VIEW,)),
("tickets", (A.VIEW,)),
("licenses", (A.VIEW, A.EXECUTE)),
("reports", (A.VIEW,)),
("logs_live", (A.VIEW,)),
("customers", (A.READ,)),
("tickets", (A.READ,)),
("licenses", (A.READ,)),
("license_activation", "*"),
("reports", (A.READ,)),
("logs_live", (A.READ,)),
),
),
RolePreset(
@@ -146,16 +225,16 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
name="Revision",
description="Nur lesen – inklusive Änderungsprotokoll und Diagnose.",
grants=(
("customers", (A.VIEW,)),
("tickets", (A.VIEW,)),
("licenses", (A.VIEW,)),
("reports", (A.VIEW,)),
("users", (A.VIEW,)),
("groups", (A.VIEW,)),
("logs_live", (A.VIEW,)),
("logs_history", (A.VIEW, A.EXPORT)),
("logs_audit", (A.VIEW, A.EXPORT)),
("diagnostics", (A.VIEW,)),
("customers", (A.READ,)),
("tickets", (A.READ,)),
("licenses", (A.READ,)),
("reports", (A.READ,)),
("users", (A.READ,)),
("groups", (A.READ,)),
("logs_live", (A.READ,)),
("logs_history", (A.READ, A.EXPORT)),
("logs_audit", (A.READ, A.EXPORT)),
("diagnostics", (A.READ,)),
),
),
RolePreset(
@@ -166,18 +245,20 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
("users", "*"),
("groups", "*"),
("settings_system", "*"),
("settings_nav", "*"),
("settings_license", "*"),
("settings_directory", (A.VIEW, A.EDIT, A.EXECUTE)),
("settings_directory", (A.READ, A.WRITE, A.EXECUTE)),
("settings_web", "*"),
("settings_network", "*"),
("settings_mail", (A.VIEW, A.EDIT, A.EXECUTE)),
("settings_mail", (A.READ, A.WRITE, A.EXECUTE)),
("settings_vendor", "*"),
("backup", (A.VIEW, A.EXECUTE, A.EXPORT)),
("backup", (A.READ, A.EXECUTE, A.EXPORT)),
("trash", "*"),
("diagnostics", (A.VIEW,)),
("logs_audit", (A.VIEW,)),
("diagnostics", (A.READ,)),
("logs_audit", (A.READ,)),
),
),
FUNKTIONEN_VOLLZUGRIFF,
)
__all__ = ["PERMISSIONS", "ROLE_PRESETS"]
@@ -210,7 +210,21 @@ CREATE INDEX IF NOT EXISTS idx_licenses_revision ON licenses(revision_at);
"""
def uebergang_funktionsgruppe(conn) -> None: # type: ignore[no-untyped-def]
"""Wie in TESM: das Kennzeichen heisst ab 2.8.0 "Verwaltung".
Ohne diesen Schritt koennte ein bestehender Administrator nach dem Update
keine Lizenz mehr ausstellen -- und wuerde das fuer einen Fehler halten.
"""
from tesm_core.rbac import service as rbac
from .permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
rbac.funktionsgruppe_fuer_administratoren(conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF)
LICENSE_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1, allow_integrity_error=True),
Migration(version=2, name="revision-timestamp", sql=V2),
Migration(version=3, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe),
)
@@ -24,6 +24,7 @@ import sqlite3
from typing import Any, Callable, Iterable
from tesm_core.db import Database
from tesm_core.rbac.legacy_permissions import umgeschrieben
from tesm_core.transfer import (
MIN_PASSPHRASE_LENGTH,
PREVIEW_TTL_MINUTES,
@@ -153,6 +154,10 @@ def apply_import(
zusammengefuehrt; Tickets und Lizenzen ueber ihre ID, weil die zugleich
kryptographisch relevant ist (Self-Service-Link bzw. signierte Lizenz-ID).
"""
#: Merkt, ob das Paket alte Rechteschluessel mitbringt -- dann stammt es
#: von vor 2.8.0, und ein Administrator konnte dort noch alles.
alte_fassung: set[bool] = set()
wanted = {key for key in categories if key in CATEGORY_BY_KEY}
report: dict[str, dict[str, int]] = {}
stamp = _stamp()
@@ -332,9 +337,16 @@ def apply_import(
group_id = int(existing["id"])
bump("groups", "updated")
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
# Wie in TESM: eine Sicherung aus 2.7.x nennt customers.view und
# tickets.edit. Unbekannte Schluessel wirft sanitize() still weg --
# die wiederhergestellte Gruppe waere leer, ohne Meldung.
mitgebracht = [str(recht) for recht in entry.get("permissions", []) or []]
neue = umgeschrieben(mitgebracht)
if sorted(set(mitgebracht)) != neue:
alte_fassung.add(True)
conn.executemany(
"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:
@@ -393,6 +405,18 @@ def apply_import(
set_setting(str(key), value)
bump("settings", "updated")
if alte_fassung and "users" in wanted:
# Wie die Migration: aus einer Sicherung von vor 2.8.0 kommen
# Administratoren, die alles konnten. Ohne die Gruppe koennten sie
# nach dem Einlesen keine Lizenz mehr ausstellen.
from tesm_core.rbac import service as rbac
from ..permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
rbac.funktionsgruppe_fuer_administratoren(
conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF
)
return report
@@ -171,22 +171,22 @@
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{{ field('name', 'Firmenname', value=customer.name, required=True,
disabled=not current_user.has('customers.edit')) }}
disabled=not current_user.has('customers.write')) }}
{{ field('contact_email', 'E-Mail', value=customer.contact_email, type='email',
disabled=not current_user.has('customers.edit')) }}
disabled=not current_user.has('customers.write')) }}
{{ field('contact_person', 'Ansprechpartner', value=customer.contact_person,
disabled=not current_user.has('customers.edit')) }}
disabled=not current_user.has('customers.write')) }}
{{ field('contact_phone', 'Telefon', value=customer.contact_phone,
disabled=not current_user.has('customers.edit')) }}
disabled=not current_user.has('customers.write')) }}
<div class="form-grid">
{{ field('street', 'Strasse', value=customer.street,
disabled=not current_user.has('customers.edit')) }}
disabled=not current_user.has('customers.write')) }}
{{ field('house_number', 'Nr.', value=customer.house_number,
disabled=not current_user.has('customers.edit')) }}
disabled=not current_user.has('customers.write')) }}
{{ field('postal_code', 'PLZ', value=customer.postal_code,
disabled=not current_user.has('customers.edit')) }}
disabled=not current_user.has('customers.write')) }}
{{ field('city', 'Ort', value=customer.city,
disabled=not current_user.has('customers.edit')) }}
disabled=not current_user.has('customers.write')) }}
</div>
{{ textarea_field('notes', 'Notizen', value=customer.notes, rows=3,
extra_class='field--full') }}
@@ -200,7 +200,7 @@
{{ icon('copy', size=13) }}</button></dd>
</dl>
</div>
{% if current_user.has('customers.edit') %}
{% if current_user.has('customers.write') %}
<div class="card__footer modal__footer--split">
{% if current_user.has('customers.delete') %}
<button class="btn btn--sm btn--ghost danger-text" type="submit"
@@ -101,7 +101,7 @@
</div>
</section>
{% if current_user.has('licenses.edit') and license.status != 'revoked' %}
{% if current_user.has('license_renew.execute') and license.status != 'revoked' %}
<section class="card">
<div class="card__header">
<div class="flex-1">
@@ -204,13 +204,13 @@
</section>
{% endif %}
{% if current_user.has('licenses.edit') or current_user.has('licenses.delete') %}
{% if current_user.has('license_revoke.execute') or current_user.has('licenses.delete') %}
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('alert', size=17) }} Lebenszyklus</div></div>
</div>
<div class="card__body stack stack--tight">
{% if current_user.has('licenses.edit') and license.status != 'revoked' %}
{% if current_user.has('license_revoke.execute') and license.status != 'revoked' %}
<button class="btn btn--sm btn--danger btn--block" type="button"
data-dialog-open="revoke-dialog">Widerrufen</button>
<p class="fs-xs faint">
@@ -239,7 +239,7 @@
{% endblock %}
{% block dialogs %}
{% if current_user.has('licenses.edit') and license.status != 'revoked' %}
{% if current_user.has('license_revoke.execute') and license.status != 'revoked' %}
{% call modal('revoke-dialog', 'Lizenz widerrufen', license.customer_name, 'narrow') %}
<form method="post" action="{{ url_for('licenses.revoke', license_id=license.license_id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -6,7 +6,7 @@
{% block topbar_extra %}
{{ search_box('licenses-table', 'Kunde, Lizenz-ID, Host ...') }}
{% if current_user.has('licenses.execute') %}
{% if current_user.has('license_activation.execute') %}
<a class="btn btn--sm" href="{{ url_for('licenses.manual_code') }}">
{{ icon('transfer', size=15) }} Offline-Code
</a>
@@ -89,7 +89,7 @@
</dd>
</dl>
</div>
{% if current_user.has('settings_license.edit') %}
{% if current_user.has('settings_license.write') %}
<div class="card__footer">
{% if not state.activated %}
<form method="post" action="{{ url_for('own_license.activate') }}" data-no-guard>
@@ -151,7 +151,7 @@
</section>
{% endif %}
{% if current_user.has('settings_license.edit') %}
{% if current_user.has('settings_license.write') %}
<section class="card">
<div class="card__header">
<div class="flex-1">
@@ -33,16 +33,16 @@ bewusst keine echte Nachricht.', 'info') }}
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{{ checkbox('graph_enabled', 'E-Mail-Versand aktiv', checked=values.graph_enabled,
disabled=not current_user.has('settings_mail.edit')) }}
disabled=not current_user.has('settings_mail.write')) }}
<div class="form-grid">
{{ field('graph_tenant_id', 'Verzeichnis-ID (Tenant)', value=values.graph_tenant_id,
disabled=not current_user.has('settings_mail.edit')) }}
disabled=not current_user.has('settings_mail.write')) }}
{{ field('graph_client_id', 'Anwendungs-ID (Client)', value=values.graph_client_id,
disabled=not current_user.has('settings_mail.edit')) }}
disabled=not current_user.has('settings_mail.write')) }}
{{ field('graph_sender', 'Absender-Postfach', value=values.graph_sender, type='email',
disabled=not current_user.has('settings_mail.edit')) }}
disabled=not current_user.has('settings_mail.write')) }}
{{ field('graph_reply_to', 'Antwortadresse', value=values.graph_reply_to, type='email',
disabled=not current_user.has('settings_mail.edit')) }}
disabled=not current_user.has('settings_mail.write')) }}
</div>
<fieldset class="fieldset">
<legend>Client-Secret {% if has_secret %}{{ pill('gesetzt', 'online') }}{% else %}{{ pill('fehlt', 'warning') }}{% endif %}</legend>
@@ -55,7 +55,7 @@ bewusst keine echte Nachricht.', 'info') }}
{% endif %}
</fieldset>
</div>
{% if current_user.has('settings_mail.edit') %}
{% if current_user.has('settings_mail.write') %}
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
{% endif %}
</form>
@@ -19,16 +19,16 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
<div class="card__body stack">
<div class="form-grid">
{{ field('vendor_name', 'Anbietername', value=values.vendor_name,
disabled=not current_user.has('settings_vendor.edit')) }}
disabled=not current_user.has('settings_vendor.write')) }}
{{ field('vendor_email', 'E-Mail', value=values.vendor_email, type='email',
disabled=not current_user.has('settings_vendor.edit')) }}
disabled=not current_user.has('settings_vendor.write')) }}
{{ field('vendor_phone', 'Telefon', value=values.vendor_phone,
disabled=not current_user.has('settings_vendor.edit')) }}
disabled=not current_user.has('settings_vendor.write')) }}
</div>
{{ textarea_field('vendor_address', 'Adresse', value=values.vendor_address, rows=3) }}
{{ field('master_endpoint', 'Öffentlicher Endpunkt', value=values.master_endpoint,
placeholder='https://lizenz.firma.de',
disabled=not current_user.has('settings_vendor.edit'),
disabled=not current_user.has('settings_vendor.write'),
hint_text='Basis-URL, unter der Kundeninstanzen diesen Server erreichen. Wird in
jede Lizenz eingebettet und für alle kundengerichteten Links verwendet -- statt
des Host-Headers der aktüllen Anfrage, der bei internen Aufrufen falsch wäre.',
@@ -43,14 +43,14 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
<p class="faint fs-sm">Kein Logo hinterlegt.</p>
{% endif %}
<input class="input" type="file" name="vendor_logo" accept="image/png,image/jpeg,image/svg+xml"
{% if not current_user.has('settings_vendor.edit') %}disabled{% endif %}>
{% if not current_user.has('settings_vendor.write') %}disabled{% endif %}>
<span class="field__hint">
Ein zu großes Logo wird abgelehnt, die übrigen Angaben werden trotzdem gespeichert.
</span>
</div>
</fieldset>
</div>
{% if current_user.has('settings_vendor.edit') %}
{% if current_user.has('settings_vendor.write') %}
<div class="card__footer modal__footer--split">
{% if values.vendor_logo_base64 %}
<button class="btn btn--sm btn--ghost" type="submit"
@@ -41,7 +41,7 @@
</div>
</div>
<div class="card__body row row--tight">
{% if current_user.has('licenses.create') %}
{% if current_user.has('license_issue.execute') %}
<form method="post" action="{{ url_for('licenses.issue', ticket_id=ticket.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -163,7 +163,7 @@
<label class="field">
<span class="field__label">Lizenztyp</span>
<select class="select" name="type" id="ticket-type"
{% if not current_user.has('tickets.edit') %}disabled{% endif %}>
{% if not current_user.has('tickets.write') %}disabled{% endif %}>
{% for value in types %}
<option value="{{ value }}" {% if value == ticket.type %}selected{% endif %}>
{{ type_labels.get(value, value) }}
@@ -178,15 +178,15 @@
{% for module in modules %}
{{ checkbox('modules', module_labels[module], value=module,
checked=module in ticket_modules,
disabled=not current_user.has('tickets.edit')) }}
disabled=not current_user.has('tickets.write')) }}
{% endfor %}
</div>
</fieldset>
{% endif %}
{{ field('valid_days', 'Laufzeit (Tage)', value=ticket.valid_days, type='number',
disabled=not current_user.has('tickets.edit')) }}
disabled=not current_user.has('tickets.write')) }}
{{ checkbox('lifetime', 'Unbefristet', checked=ticket.lifetime,
disabled=not current_user.has('tickets.edit')) }}
disabled=not current_user.has('tickets.write')) }}
{{ textarea_field('notes', 'Interne Notiz', value=ticket.notes, rows=2) }}
<div class="divider"></div>
<dl class="kv fs-xs">
@@ -195,7 +195,7 @@
<dt>Angelegt</dt><dd>{{ ticket.created_at | dt }} von {{ ticket.created_by or '-' }}</dd>
</dl>
</div>
{% if current_user.has('tickets.edit') %}
{% if current_user.has('tickets.write') %}
<div class="card__footer">
<button class="btn btn--primary" type="submit">Speichern</button>
</div>
@@ -203,13 +203,13 @@
</form>
</section>
{% if current_user.has('tickets.edit') or current_user.has('tickets.delete') %}
{% if current_user.has('tickets.write') or current_user.has('tickets.delete') %}
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Sperre und Löschung</div></div>
</div>
<div class="card__body stack stack--tight">
{% if current_user.has('tickets.edit') %}
{% if current_user.has('tickets.write') %}
<form method="post" action="{{ url_for('tickets.toggle_block', ticket_id=ticket.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">