diff --git a/Dockerfile b/Dockerfile index a765352..2a068b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,6 +33,7 @@ COPY app/backup_to_destination.py /app/backup_to_destination.py COPY app/audit_policy.py /app/audit_policy.py COPY app/state_db.py /app/state_db.py COPY app/access_control.py /app/access_control.py +COPY app/account_policy.py /app/account_policy.py COPY app/audit_store.py /app/audit_store.py COPY app/audit_collector.py /app/audit_collector.py COPY app/trash.py /app/trash.py diff --git a/README.md b/README.md index fd9fe91..09b5f1c 100644 --- a/README.md +++ b/README.md @@ -342,7 +342,6 @@ Open `https:///` after setup. Only members of the group identified The console is intentionally operational and plain: - **Overview**: current capacity totals, active folder count, recent activity, and backup state. -- **File shares**: one selectable tree per active managed folder with its individually assigned users. - **Zugriffsverwaltung**: create/archive/restore Data folders and assign levels 0–3 to existing AD users individually per folder. - **Data usage**: cached recursive size of every top-level `/Data` group folder. - **User usage**: per-user `/Private + /FSLogix` totals with component sizes. diff --git a/app/access_control.py b/app/access_control.py index a3168c1..91a61c3 100644 --- a/app/access_control.py +++ b/app/access_control.py @@ -13,8 +13,10 @@ import uuid try: from . import reconcile_shares as directory + from .account_policy import is_excluded_user except ImportError: import reconcile_shares as directory + from account_policy import is_excluded_user SID_RE = re.compile(r"S-1-5-21-\d+-\d+-\d+-\d+\Z") READ = 0x1200A9 @@ -146,12 +148,14 @@ def migrate(conn): seen = {*seen, group["dn"]} rid = directory.sid_rid(group["sid"]) result = {sid for sid, user in users.items() - if user["primaryRid"] == rid and sid.rsplit("-", 1)[0] == group["sid"].rsplit("-", 1)[0]} + if not is_excluded_user(user["sam"]) and user["primaryRid"] == rid and sid.rsplit("-", 1)[0] == group["sid"].rsplit("-", 1)[0]} for dn in group["members"]: if dn in groups: result.update(members(groups[dn], seen)) elif dn in by_dn: - result.add(by_dn[dn]) + sid = by_dn[dn] + if not is_excluded_user(users[sid]["sam"]): + result.add(sid) else: raise RuntimeError(f"Cannot import unresolved member {dn}; migration will retry") return result @@ -297,22 +301,33 @@ def create_folder_record(conn, name, path): return folder_id +def excluded_user_sids(conn): + # Retain identities internally to recognize old assignments and resolve LDAP + # memberships, including accounts no longer returned by the directory. + return {row["sid"] for row in conn.execute("SELECT sid,sam FROM access_users") + if is_excluded_user(row["sam"])} + + def snapshot(conn, users=None): - user_rows = [dict(row) for row in conn.execute("SELECT * FROM access_users ORDER BY sam COLLATE NOCASE")] + excluded = excluded_user_sids(conn) + user_rows = [dict(row) for row in conn.execute("SELECT * FROM access_users ORDER BY sam COLLATE NOCASE") + if row["sid"] not in excluded] if users is not None: for user in user_rows: user["available"] = user["sid"] in users folders = [] for row in conn.execute("SELECT * FROM shares ORDER BY shareName COLLATE NOCASE"): folders.append({"id": row["objectGUID"], "name": row["shareName"], "active": bool(row["isActive"]), - "permissions": [dict(p) for p in conn.execute("SELECT kind,principalId,level FROM folder_permissions WHERE folderId=? ORDER BY kind,principalId", (row["objectGUID"],))]}) + "permissions": [dict(p) for p in conn.execute("SELECT kind,principalId,level FROM folder_permissions WHERE folderId=? ORDER BY kind,principalId", (row["objectGUID"],)) if p["principalId"] not in excluded]}) return {"users": user_rows, "folders": folders, "fetchedAt": timestamp(), "initialized": initialized(conn)} def effective_levels(conn, folder_id): + excluded = excluded_user_sids(conn) return {row["principalId"]: row["level"] for row in conn.execute( - "SELECT principalId,level FROM folder_permissions WHERE folderId=?", (folder_id,))} + "SELECT principalId,level FROM folder_permissions WHERE folderId=?", (folder_id,)) + if row["principalId"] not in excluded} def descriptor(levels, admin_sid, is_dir=True, top_level=False): @@ -411,7 +426,35 @@ def recover_pending(conn): directory.log("Recovered interrupted admin access update") +def revoke_excluded_permissions(conn): + excluded = excluded_user_sids(conn) + rules = [dict(row) for row in conn.execute("SELECT * FROM folder_permissions") + if row["principalId"] in excluded] + if not rules: + return + for rule in rules: + conn.execute("DELETE FROM folder_permissions WHERE folderId=? AND principalId=?", + (rule["folderId"], rule["principalId"])) + conn.execute("UPDATE shares SET aclSignature='' WHERE objectGUID=?", (rule["folderId"],)) + if any(rule["level"] for rule in rules): + conn.execute("INSERT OR REPLACE INTO access_settings VALUES('pendingExcludedRevocation','1')") + conn.execute("INSERT INTO access_changes(timestamp,actor,action,details) VALUES(?,?,?,?)", + (timestamp(), "system", "revoke-excluded-users", json.dumps(rules))) + + +def close_data_connections(conn, force=False): + pending = conn.execute("SELECT 1 FROM access_settings WHERE key='pendingExcludedRevocation'").fetchone() + if not force and not pending: + return + result = directory.run_command(["smbcontrol", "all", "close-share", "Data"], check=False) + if result.returncode: + raise RuntimeError("Berechtigungen gespeichert; SMB-Verbindungen konnten nicht geschlossen werden. Samba neu starten.") + conn.execute("DELETE FROM access_settings WHERE key='pendingExcludedRevocation'") + conn.commit() + + def sync_permissions(conn, force=False): + revoke_excluded_permissions(conn) admin_sid = os.getenv("DOMAIN_ADMINS_SID", "") root = directory.GROUP_ROOT os.makedirs(root, exist_ok=True) @@ -461,6 +504,7 @@ def apply_change(conn, body, actor, users): cache_users(conn, users) conn.commit() recover_pending(conn) + excluded = excluded_user_sids(conn) pending = {} if action == "create-folder": pending["create"] = os.path.join(directory.GROUP_ROOT, valid_name(body.get("name"))) @@ -509,6 +553,8 @@ def apply_change(conn, body, actor, users): seen.add((kind, principal)) if principal not in users and principal not in old_users: raise ValueError("Unbekannter AD-Benutzer") + if level and principal in excluded: + raise ValueError("Systemkonto kann keine Ordnerberechtigung erhalten") checked.append((folder_id, kind, principal, level)) conn.execute("DELETE FROM folder_permissions WHERE folderId=?", (folder_id,)) conn.executemany("INSERT INTO folder_permissions VALUES(?,?,?,?)", checked) @@ -550,9 +596,7 @@ def apply_change(conn, body, actor, users): conn.commit() raise # Existing handles carry cached access masks; disconnect Data clients. - result = directory.run_command(["smbcontrol", "all", "close-share", "Data"], check=False) - if result.returncode: - raise RuntimeError("Berechtigungen gespeichert; SMB-Verbindungen konnten nicht geschlossen werden. Samba neu starten.") + close_data_connections(conn, force=True) return snapshot(conn, users) diff --git a/app/account_policy.py b/app/account_policy.py new file mode 100644 index 0000000..bf4ae76 --- /dev/null +++ b/app/account_policy.py @@ -0,0 +1,11 @@ +"""Account names excluded from human user administration.""" + + +def account_name(user: str) -> str: + account = user.strip().rsplit("\\", 1)[-1] + return account.split("@", 1)[0] + + +def is_excluded_user(user: str) -> bool: + account = account_name(user).casefold() + return account == "krbtgt" or account.startswith("msol_") diff --git a/app/audit_policy.py b/app/audit_policy.py index ffc1a57..7891c27 100644 --- a/app/audit_policy.py +++ b/app/audit_policy.py @@ -6,6 +6,11 @@ import hashlib import os from typing import Mapping, Optional, Tuple +try: + from .account_policy import account_name +except ImportError: + from account_policy import account_name + AUDIT_ACTIONS = frozenset({"read", "write", "move", "delete"}) OPERATION_ACTIONS = { @@ -41,10 +46,6 @@ def skipped_user_suffixes() -> Tuple[str, ...]: if suffix.strip() ) -def account_name(user: str) -> str: - account = user.strip().rsplit("\\", 1)[-1] - return account.split("@", 1)[0] - def skip_user(user: str) -> bool: account = account_name(user).casefold() return any(account.endswith(suffix) for suffix in skipped_user_suffixes()) diff --git a/app/audit_store.py b/app/audit_store.py index 0d17182..5d11654 100644 --- a/app/audit_store.py +++ b/app/audit_store.py @@ -16,6 +16,7 @@ try: skipped_user_suffixes, ) from .state_db import connect_state_db + from .account_policy import is_excluded_user except ImportError: from audit_policy import ( account_name, @@ -24,6 +25,7 @@ except ImportError: skipped_user_suffixes, ) from state_db import connect_state_db + from account_policy import is_excluded_user AUDIT_SCHEMA = """ @@ -927,7 +929,7 @@ def rollup_facets( actions = distinct("action") return { - "users": distinct("user"), + "users": [user for user in distinct("user") if not is_excluded_user(user)], "shares": distinct("share"), "operations": actions, "actions": actions, diff --git a/app/reconcile_shares.py b/app/reconcile_shares.py index 0695ba0..4088fab 100755 --- a/app/reconcile_shares.py +++ b/app/reconcile_shares.py @@ -17,8 +17,10 @@ from typing import Callable, Dict, Iterable, List, Optional, Set, Tuple try: from .state_db import STATE_DB_PATH, connect_state_db + from .account_policy import is_excluded_user except ImportError: from state_db import STATE_DB_PATH, connect_state_db + from account_policy import is_excluded_user DB_PATH = STATE_DB_PATH @@ -69,7 +71,6 @@ REQUIRED_ENV = ["REALM", "WORKGROUP", "DOMAIN"] ATTR_RE = re.compile(r"^([^:]+)(::?)\s*(.*)$") GROUP_FOLDER_INVALID_RE = re.compile(r"[\\/:*?\"<>|]") PRIVATE_SKIP_EXACT = { - "krbtgt", "administrator", "guest", "gast", @@ -78,7 +79,7 @@ PRIVATE_SKIP_EXACT = { "fileshare_serviceacc", "fileshare_serviceaccount", } -PRIVATE_SKIP_PREFIXES = ("msol_", "fileshare_service", "aad_") +PRIVATE_SKIP_PREFIXES = ("fileshare_service", "aad_") UAC_ACCOUNTDISABLE = 0x0002 UAC_LOCKOUT = 0x0010 AD_NEVER_EXPIRES_VALUES = {0, 9223372036854775807} @@ -1407,7 +1408,7 @@ def list_domain_users(non_login_users: set) -> List[str]: def should_skip_private_user(username: str) -> bool: normalized = username.strip().lower() - if not normalized: + if not normalized or is_excluded_user(username): return True if normalized in PRIVATE_SKIP_EXACT: return True @@ -1717,10 +1718,15 @@ def with_lock() -> bool: import access_control status_progress(10, "database", "Loading admin-managed folders") access_control.migrate(conn) + # Refresh user names only; managed permissions never rediscover AD groups. + users, _ = access_control.read_directory() + access_control.cache_users(conn, users) + conn.commit() access_control.recover_pending(conn) status_progress(32, "data-permissions", "Syncing managed folder permissions") access_control.sync_permissions(conn) conn.commit() + access_control.close_data_connections(conn) log("Synced admin-managed Data folder permissions") finally: conn.close() diff --git a/app/web/app.js b/app/web/app.js index 3593696..d5a9bc2 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -1,6 +1,6 @@ "use strict"; -const state = { session: null, timer: null, groups: null, storage: null, activity: null }; +const state = { session: null, timer: null, storage: null, activity: null }; const loginView = document.querySelector("#login-view"); const appView = document.querySelector("#app-view"); const content = document.querySelector("#content"); @@ -8,6 +8,13 @@ const nav = document.querySelector("#navigation"); const toast = document.querySelector("#toast"); const esc = value => String(value ?? "").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char])); +function actionIcon(action) { + const paths = { + download: '', + restore: '', + }; + return ``; +} const bytes = value => { let number = Number(value || 0); const units = ["B", "kB", "MB", "GB", "TB", "PB"]; @@ -25,7 +32,6 @@ const utcTime = value => { return `${pad(date.getUTCDate())}.${pad(date.getUTCMonth() + 1)}.${date.getUTCFullYear()} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} UTC`; }; const actionLabel = value => ({read: "Lesen", write: "Schreiben", move: "Umbenennen", delete: "Löschen"}[value] || value || "—"); -const nodeTypeLabel = value => ({group: "Gruppe", user: "Benutzer", computer: "Computer", unknown: "Unbekannt"}[value] || value); const backupStateLabel = value => ({starting: "Startet", running: "Läuft", completed: "Abgeschlossen", failed: "Fehlgeschlagen", waiting: "Wartet", disabled: "Deaktiviert"}[value] || value || "Unbekannt"); function backupMessage(data) { const message = String(data.message || ""); @@ -103,7 +109,6 @@ function routeFor(path) { if (path.startsWith("/storage/data")) return "storage-data"; if (path.startsWith("/storage/users")) return "storage-users"; if (path.startsWith("/access")) return "access"; - if (path.startsWith("/shares")) return "shares"; if (path.startsWith("/reconciliation")) return "reconciliation"; if (path.startsWith("/activity/fslogix")) return "activity-fslogix"; if (path.startsWith("/activity")) return "activity"; @@ -119,6 +124,7 @@ async function navigate(path, replace = false) { if (replace && state.currentPath) history.replaceState({}, "", state.currentPath); return; } + if (path === "/shares" || path.startsWith("/shares/")) { path = "/access"; replace = true; } state.accessDirty = false; state.currentPath = path; clearInterval(state.timer); @@ -130,7 +136,6 @@ async function navigate(path, replace = false) { setLoading(); try { if (route === "overview") await renderOverview(); - if (route === "shares") await renderShares(); if (route === "access") await renderAccess(); if (route === "reconciliation") await renderReconciliation(); if (route === "storage-data") await renderStorage("data"); @@ -193,46 +198,6 @@ function usageTable(rows, type) { }).join("")}`; } -function nodeMatches(node, query) { - if (!query) return true; - if (`${node.name} ${node.sam} ${node.type} ${nodeTypeLabel(node.type)}`.toLowerCase().includes(query)) return true; - return (node.members || []).some(child => nodeMatches(child, query)); -} - -function treeNodes(nodes, query = "") { - return nodes.filter(node => nodeMatches(node, query)).map(node => { - const children = treeNodes(node.members || [], query); - const title = `${esc(nodeTypeLabel(node.type))} ${esc(node.name)}${node.sam && node.sam !== node.name ? ` ${esc(node.sam)}` : ""}${node.cycle ? ` ${badge("Zyklus", "warn")}` : ""}`; - return children ? `
${title}${children}
` : `
${title}
`; - }).join(""); -} - -async function renderShares() { - const data = await api("/api/groups"); - state.groups = data; - const groups = data.groups || []; - content.innerHTML = `
` + pageHead("Dateifreigaben", "Verwaltete Ordner und ihre individuellen Benutzerberechtigungen.", `Verzeichnisstand ${esc(utcTime(data.fetchedAt))}`) + ` - ${data.truncated ? `

${badge("Ergebnis gekürzt", "warn")} Mitgliedschaftsliste ist unvollständig.

` : ""} -
-

Datenordner

${groups.length}
    -
    -
    `; - const list = document.querySelector("#group-list"); - const tree = document.querySelector("#tree-panel"); - let selected = groups[0] || null; - let query = ""; - const draw = () => { - const visible = groups.filter(group => `${group.name} ${group.sam} ${group.folder}`.toLowerCase().includes(query) || group.members.some(node => nodeMatches(node, query))); - if (selected && !visible.includes(selected)) selected = visible[0] || null; - list.innerHTML = visible.length ? visible.map(group => `
  • `).join("") : empty("Kein Ordner entspricht dem Filter."); - if (!selected) tree.innerHTML = empty("Datenordner auswählen."); - else tree.innerHTML = `

    ${esc(selected.folder)}

    ${esc(selected.sam)} · ${selected.userCount} wirksame Benutzer
    ${selected.active ? badge("Aktiv") : badge("Archiviert", "warn")}
    ${treeNodes(selected.members, query) || empty("Keine Benutzer mit Zugriff")}
    `; - list.querySelectorAll("button[data-guid]").forEach(button => button.addEventListener("click", () => { selected = groups.find(group => group.guid === button.dataset.guid); draw(); })); - }; - document.querySelector("#group-filter").addEventListener("input", event => { query = event.target.value.trim().toLowerCase(); draw(); }); - draw(); -} - const permissionLabels = ["Kein Zugriff", "Lesen", "Lesen + Ändern", "Lesen + Ändern + Löschen"]; async function renderAccess() { @@ -284,7 +249,7 @@ async function renderAccess() { try { data = await api("/api/access", {method: "POST", body: JSON.stringify(body)}); if (body.action === "create-folder") { - folderFilter = "active"; query = ""; userQuery = ""; userFilter = "all"; + folderFilter = "active"; query = ""; folderId = data.folders.find(item => item.name === body.name.trim())?.id; } if (body.action === "archive-folder") folderFilter = "archived"; @@ -310,11 +275,11 @@ async function renderAccess() { } } - function dialog(title, explanation, label, action, initial = null, danger = false) { + function dialog(title, explanation, label, action, initial = null, danger = false, icon = null) { const modal = document.createElement("dialog"); modal.className = "access-dialog"; modal.setAttribute("aria-label", title); - modal.innerHTML = `

    ${esc(title)}

    ${esc(explanation)}

    ${initial !== null ? `` : ""}
    `; + modal.innerHTML = `

    ${esc(title)}

    ${esc(explanation)}

    ${initial !== null ? `` : ""}
    `; document.body.append(modal); modal.querySelector("[data-cancel]").addEventListener("click", () => modal.close()); modal.addEventListener("close", () => modal.remove()); @@ -335,11 +300,11 @@ async function renderAccess() { root.querySelector("#access-list").innerHTML = items.length ? items.map(item => { const count = item.permissions.filter(rule => rule.level > 0).length; const subtitle = item.active ? `${count} Benutzer` : "Archiviert"; - return ``; + return ``; }).join("") : `
    ${query ? "Keine Treffer. Suchbegriff ändern." : "Keine Ordner in dieser Ansicht."}
    `; root.querySelectorAll("[data-select]").forEach(button => button.addEventListener("click", () => { if (button.dataset.select === folderId || !canLeave()) return; - folderId = button.dataset.select; userQuery = ""; userFilter = "all"; + folderId = button.dataset.select; loadDraft(); drawList(); drawDetail(); })); } @@ -374,7 +339,7 @@ async function renderAccess() { detail.innerHTML = data.folders.length ? '

    Kein Ordner in dieser Ansicht

    Den Ordnerstatus wechseln oder einen neuen Ordner anlegen.

    ' : '

    Ersten Ordner anlegen

    Mit „Neuer Ordner“ beginnen. Anschließend die Rechte für einzelne AD-Benutzer festlegen.

    '; return; } - detail.innerHTML = `

    ${esc(selected.name)}

    ${selected.active ? "Zugriff für jeden AD-Benutzer einzeln festlegen. Ohne Zuweisung kein Zugriff." : "Dieser Ordner ist archiviert und für Benutzer nicht erreichbar. Gespeicherte Rechte gelten nach dem Wiederherstellen."}

    + detail.innerHTML = `

    ${esc(selected.name)}

    ${selected.active ? "Zugriff für jeden AD-Benutzer einzeln festlegen. Ohne Zuweisung kein Zugriff." : "Dieser Ordner ist archiviert und für Benutzer nicht erreichbar. Gespeicherte Rechte gelten nach dem Wiederherstellen."}

    Benutzerrechte

    @@ -388,7 +353,7 @@ async function renderAccess() { detail.querySelector("#access-discard").addEventListener("click", () => { loadDraft(); drawDetail(); }); detail.querySelector("#access-archive").addEventListener("click", () => { if (!canLeave()) return; - dialog(selected.active ? "Ordner archivieren?" : "Ordner wiederherstellen?", selected.active ? `„${selected.name}“ wird für Benutzer ausgeblendet. Alle Dateien bleiben erhalten.` : `„${selected.name}“ wird mit den gespeicherten Rechten wieder freigegeben.`, selected.active ? "Archivieren" : "Wiederherstellen", () => save({action: selected.active ? "archive-folder" : "restore-folder", id: selected.id}), null, selected.active); + dialog(selected.active ? "Ordner archivieren?" : "Ordner wiederherstellen?", selected.active ? `„${selected.name}“ wird für Benutzer ausgeblendet. Alle Dateien bleiben erhalten.` : `„${selected.name}“ wird mit den gespeicherten Rechten wieder freigegeben.`, selected.active ? "Archivieren" : "Wiederherstellen", () => save({action: selected.active ? "archive-folder" : "restore-folder", id: selected.id}), null, selected.active, selected.active ? null : "restore"); }); drawUsers(); updateDirty(); } @@ -403,7 +368,7 @@ async function renderAccess() { if (!canLeave()) { event.target.value = folderFilter; return; } folderFilter = event.target.value; const visible = data.folders.filter(item => folderFilter === "all" || item.active === (folderFilter === "active")); - if (!visible.some(item => item.id === folderId)) { folderId = visible[0]?.id; userQuery = ""; userFilter = "all"; } + if (!visible.some(item => item.id === folderId)) folderId = visible[0]?.id; loadDraft(); drawList(); drawDetail(); }); root.querySelector("#access-create").addEventListener("click", () => { @@ -524,7 +489,7 @@ async function renderTrash() { ${esc(item.path)} ${bytes(item.size)} ${esc(utcTime(item.expiresAt))} -
    Herunterladen
    +
    ${actionIcon("download")}
    `).join(""); } const shown = result.items.length.toLocaleString("de-DE"); diff --git a/app/web/index.html b/app/web/index.html index a778e1e..aa7647d 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -26,7 +26,6 @@
    Dateiserver