ui rehaul

This commit is contained in:
Ludwig Lehnert
2026-10-03 09:44:29 +00:00
parent a47542f156
commit 3075d951ba
17 changed files with 428 additions and 102 deletions
+1
View File
@@ -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/audit_policy.py /app/audit_policy.py
COPY app/state_db.py /app/state_db.py COPY app/state_db.py /app/state_db.py
COPY app/access_control.py /app/access_control.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_store.py /app/audit_store.py
COPY app/audit_collector.py /app/audit_collector.py COPY app/audit_collector.py /app/audit_collector.py
COPY app/trash.py /app/trash.py COPY app/trash.py /app/trash.py
-1
View File
@@ -342,7 +342,6 @@ Open `https://<WEB_HOSTNAME>/` after setup. Only members of the group identified
The console is intentionally operational and plain: The console is intentionally operational and plain:
- **Overview**: current capacity totals, active folder count, recent activity, and backup state. - **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. - **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. - **Data usage**: cached recursive size of every top-level `/Data` group folder.
- **User usage**: per-user `/Private + /FSLogix` totals with component sizes. - **User usage**: per-user `/Private + /FSLogix` totals with component sizes.
+52 -8
View File
@@ -13,8 +13,10 @@ import uuid
try: try:
from . import reconcile_shares as directory from . import reconcile_shares as directory
from .account_policy import is_excluded_user
except ImportError: except ImportError:
import reconcile_shares as directory 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") SID_RE = re.compile(r"S-1-5-21-\d+-\d+-\d+-\d+\Z")
READ = 0x1200A9 READ = 0x1200A9
@@ -146,12 +148,14 @@ def migrate(conn):
seen = {*seen, group["dn"]} seen = {*seen, group["dn"]}
rid = directory.sid_rid(group["sid"]) rid = directory.sid_rid(group["sid"])
result = {sid for sid, user in users.items() 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"]: for dn in group["members"]:
if dn in groups: if dn in groups:
result.update(members(groups[dn], seen)) result.update(members(groups[dn], seen))
elif dn in by_dn: 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: else:
raise RuntimeError(f"Cannot import unresolved member {dn}; migration will retry") raise RuntimeError(f"Cannot import unresolved member {dn}; migration will retry")
return result return result
@@ -297,22 +301,33 @@ def create_folder_record(conn, name, path):
return folder_id 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): 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: if users is not None:
for user in user_rows: for user in user_rows:
user["available"] = user["sid"] in users user["available"] = user["sid"] in users
folders = [] folders = []
for row in conn.execute("SELECT * FROM shares ORDER BY shareName COLLATE NOCASE"): 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"]), 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, return {"users": user_rows, "folders": folders,
"fetchedAt": timestamp(), "initialized": initialized(conn)} "fetchedAt": timestamp(), "initialized": initialized(conn)}
def effective_levels(conn, folder_id): def effective_levels(conn, folder_id):
excluded = excluded_user_sids(conn)
return {row["principalId"]: row["level"] for row in conn.execute( 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): 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") 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): def sync_permissions(conn, force=False):
revoke_excluded_permissions(conn)
admin_sid = os.getenv("DOMAIN_ADMINS_SID", "") admin_sid = os.getenv("DOMAIN_ADMINS_SID", "")
root = directory.GROUP_ROOT root = directory.GROUP_ROOT
os.makedirs(root, exist_ok=True) os.makedirs(root, exist_ok=True)
@@ -461,6 +504,7 @@ def apply_change(conn, body, actor, users):
cache_users(conn, users) cache_users(conn, users)
conn.commit() conn.commit()
recover_pending(conn) recover_pending(conn)
excluded = excluded_user_sids(conn)
pending = {} pending = {}
if action == "create-folder": if action == "create-folder":
pending["create"] = os.path.join(directory.GROUP_ROOT, valid_name(body.get("name"))) 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)) seen.add((kind, principal))
if principal not in users and principal not in old_users: if principal not in users and principal not in old_users:
raise ValueError("Unbekannter AD-Benutzer") 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)) checked.append((folder_id, kind, principal, level))
conn.execute("DELETE FROM folder_permissions WHERE folderId=?", (folder_id,)) conn.execute("DELETE FROM folder_permissions WHERE folderId=?", (folder_id,))
conn.executemany("INSERT INTO folder_permissions VALUES(?,?,?,?)", checked) conn.executemany("INSERT INTO folder_permissions VALUES(?,?,?,?)", checked)
@@ -550,9 +596,7 @@ def apply_change(conn, body, actor, users):
conn.commit() conn.commit()
raise raise
# Existing handles carry cached access masks; disconnect Data clients. # Existing handles carry cached access masks; disconnect Data clients.
result = directory.run_command(["smbcontrol", "all", "close-share", "Data"], check=False) close_data_connections(conn, force=True)
if result.returncode:
raise RuntimeError("Berechtigungen gespeichert; SMB-Verbindungen konnten nicht geschlossen werden. Samba neu starten.")
return snapshot(conn, users) return snapshot(conn, users)
+11
View File
@@ -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_")
+5 -4
View File
@@ -6,6 +6,11 @@ import hashlib
import os import os
from typing import Mapping, Optional, Tuple 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"}) AUDIT_ACTIONS = frozenset({"read", "write", "move", "delete"})
OPERATION_ACTIONS = { OPERATION_ACTIONS = {
@@ -41,10 +46,6 @@ def skipped_user_suffixes() -> Tuple[str, ...]:
if suffix.strip() 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: def skip_user(user: str) -> bool:
account = account_name(user).casefold() account = account_name(user).casefold()
return any(account.endswith(suffix) for suffix in skipped_user_suffixes()) return any(account.endswith(suffix) for suffix in skipped_user_suffixes())
+3 -1
View File
@@ -16,6 +16,7 @@ try:
skipped_user_suffixes, skipped_user_suffixes,
) )
from .state_db import connect_state_db from .state_db import connect_state_db
from .account_policy import is_excluded_user
except ImportError: except ImportError:
from audit_policy import ( from audit_policy import (
account_name, account_name,
@@ -24,6 +25,7 @@ except ImportError:
skipped_user_suffixes, skipped_user_suffixes,
) )
from state_db import connect_state_db from state_db import connect_state_db
from account_policy import is_excluded_user
AUDIT_SCHEMA = """ AUDIT_SCHEMA = """
@@ -927,7 +929,7 @@ def rollup_facets(
actions = distinct("action") actions = distinct("action")
return { return {
"users": distinct("user"), "users": [user for user in distinct("user") if not is_excluded_user(user)],
"shares": distinct("share"), "shares": distinct("share"),
"operations": actions, "operations": actions,
"actions": actions, "actions": actions,
+9 -3
View File
@@ -17,8 +17,10 @@ from typing import Callable, Dict, Iterable, List, Optional, Set, Tuple
try: try:
from .state_db import STATE_DB_PATH, connect_state_db from .state_db import STATE_DB_PATH, connect_state_db
from .account_policy import is_excluded_user
except ImportError: except ImportError:
from state_db import STATE_DB_PATH, connect_state_db from state_db import STATE_DB_PATH, connect_state_db
from account_policy import is_excluded_user
DB_PATH = STATE_DB_PATH DB_PATH = STATE_DB_PATH
@@ -69,7 +71,6 @@ REQUIRED_ENV = ["REALM", "WORKGROUP", "DOMAIN"]
ATTR_RE = re.compile(r"^([^:]+)(::?)\s*(.*)$") ATTR_RE = re.compile(r"^([^:]+)(::?)\s*(.*)$")
GROUP_FOLDER_INVALID_RE = re.compile(r"[\\/:*?\"<>|]") GROUP_FOLDER_INVALID_RE = re.compile(r"[\\/:*?\"<>|]")
PRIVATE_SKIP_EXACT = { PRIVATE_SKIP_EXACT = {
"krbtgt",
"administrator", "administrator",
"guest", "guest",
"gast", "gast",
@@ -78,7 +79,7 @@ PRIVATE_SKIP_EXACT = {
"fileshare_serviceacc", "fileshare_serviceacc",
"fileshare_serviceaccount", "fileshare_serviceaccount",
} }
PRIVATE_SKIP_PREFIXES = ("msol_", "fileshare_service", "aad_") PRIVATE_SKIP_PREFIXES = ("fileshare_service", "aad_")
UAC_ACCOUNTDISABLE = 0x0002 UAC_ACCOUNTDISABLE = 0x0002
UAC_LOCKOUT = 0x0010 UAC_LOCKOUT = 0x0010
AD_NEVER_EXPIRES_VALUES = {0, 9223372036854775807} 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: def should_skip_private_user(username: str) -> bool:
normalized = username.strip().lower() normalized = username.strip().lower()
if not normalized: if not normalized or is_excluded_user(username):
return True return True
if normalized in PRIVATE_SKIP_EXACT: if normalized in PRIVATE_SKIP_EXACT:
return True return True
@@ -1717,10 +1718,15 @@ def with_lock() -> bool:
import access_control import access_control
status_progress(10, "database", "Loading admin-managed folders") status_progress(10, "database", "Loading admin-managed folders")
access_control.migrate(conn) 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) access_control.recover_pending(conn)
status_progress(32, "data-permissions", "Syncing managed folder permissions") status_progress(32, "data-permissions", "Syncing managed folder permissions")
access_control.sync_permissions(conn) access_control.sync_permissions(conn)
conn.commit() conn.commit()
access_control.close_data_connections(conn)
log("Synced admin-managed Data folder permissions") log("Synced admin-managed Data folder permissions")
finally: finally:
conn.close() conn.close()
+18 -53
View File
@@ -1,6 +1,6 @@
"use strict"; "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 loginView = document.querySelector("#login-view");
const appView = document.querySelector("#app-view"); const appView = document.querySelector("#app-view");
const content = document.querySelector("#content"); const content = document.querySelector("#content");
@@ -8,6 +8,13 @@ const nav = document.querySelector("#navigation");
const toast = document.querySelector("#toast"); const toast = document.querySelector("#toast");
const esc = value => String(value ?? "").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char])); const esc = value => String(value ?? "").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char]));
function actionIcon(action) {
const paths = {
download: '<path d="M12 3v12m-5-5 5 5 5-5"/><path d="M5 16v4h14v-4"/>',
restore: '<path d="M3 11a9 9 0 1 1 2.6 7"/><path d="M3 4v7h7"/>',
};
return `<svg class="action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${paths[action]}</svg>`;
}
const bytes = value => { const bytes = value => {
let number = Number(value || 0); let number = Number(value || 0);
const units = ["B", "kB", "MB", "GB", "TB", "PB"]; 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`; 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 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"); const backupStateLabel = value => ({starting: "Startet", running: "Läuft", completed: "Abgeschlossen", failed: "Fehlgeschlagen", waiting: "Wartet", disabled: "Deaktiviert"}[value] || value || "Unbekannt");
function backupMessage(data) { function backupMessage(data) {
const message = String(data.message || ""); const message = String(data.message || "");
@@ -103,7 +109,6 @@ function routeFor(path) {
if (path.startsWith("/storage/data")) return "storage-data"; if (path.startsWith("/storage/data")) return "storage-data";
if (path.startsWith("/storage/users")) return "storage-users"; if (path.startsWith("/storage/users")) return "storage-users";
if (path.startsWith("/access")) return "access"; if (path.startsWith("/access")) return "access";
if (path.startsWith("/shares")) return "shares";
if (path.startsWith("/reconciliation")) return "reconciliation"; if (path.startsWith("/reconciliation")) return "reconciliation";
if (path.startsWith("/activity/fslogix")) return "activity-fslogix"; if (path.startsWith("/activity/fslogix")) return "activity-fslogix";
if (path.startsWith("/activity")) return "activity"; if (path.startsWith("/activity")) return "activity";
@@ -119,6 +124,7 @@ async function navigate(path, replace = false) {
if (replace && state.currentPath) history.replaceState({}, "", state.currentPath); if (replace && state.currentPath) history.replaceState({}, "", state.currentPath);
return; return;
} }
if (path === "/shares" || path.startsWith("/shares/")) { path = "/access"; replace = true; }
state.accessDirty = false; state.accessDirty = false;
state.currentPath = path; state.currentPath = path;
clearInterval(state.timer); clearInterval(state.timer);
@@ -130,7 +136,6 @@ async function navigate(path, replace = false) {
setLoading(); setLoading();
try { try {
if (route === "overview") await renderOverview(); if (route === "overview") await renderOverview();
if (route === "shares") await renderShares();
if (route === "access") await renderAccess(); if (route === "access") await renderAccess();
if (route === "reconciliation") await renderReconciliation(); if (route === "reconciliation") await renderReconciliation();
if (route === "storage-data") await renderStorage("data"); if (route === "storage-data") await renderStorage("data");
@@ -193,46 +198,6 @@ function usageTable(rows, type) {
}).join("")}</tbody></table></div>`; }).join("")}</tbody></table></div>`;
} }
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 = `<span class="kind">${esc(nodeTypeLabel(node.type))}</span> <strong>${esc(node.name)}</strong>${node.sam && node.sam !== node.name ? ` <span class="muted">${esc(node.sam)}</span>` : ""}${node.cycle ? ` ${badge("Zyklus", "warn")}` : ""}`;
return children ? `<details ${query ? "open" : ""}><summary>${title}</summary>${children}</details>` : `<div class="leaf">${title}</div>`;
}).join("");
}
async function renderShares() {
const data = await api("/api/groups");
state.groups = data;
const groups = data.groups || [];
content.innerHTML = `<div class="shares-view">` + pageHead("Dateifreigaben", "Verwaltete Ordner und ihre individuellen Benutzerberechtigungen.", `<span class="muted">Verzeichnisstand ${esc(utcTime(data.fetchedAt))}</span>`) + `
${data.truncated ? `<p>${badge("Ergebnis gekürzt", "warn")} Mitgliedschaftsliste ist unvollständig.</p>` : ""}
<section class="split shares-split">
<article class="panel"><div class="panel-head"><h2>Datenordner</h2><span>${groups.length}</span></div><input id="group-filter" type="search" placeholder="Ordner oder Benutzer filtern"><ul id="group-list" class="list"></ul></article>
<article class="panel"><div id="tree-panel"></div></article>
</section></div>`;
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 => `<li><button class="select-row ${group === selected ? "active" : ""}" data-guid="${esc(group.guid)}"><span><strong>${esc(group.folder)}</strong><br><span class="muted">${esc(group.sam)}</span></span><span>${group.userCount} Benutzer</span></button></li>`).join("") : empty("Kein Ordner entspricht dem Filter.");
if (!selected) tree.innerHTML = empty("Datenordner auswählen.");
else tree.innerHTML = `<div class="panel-head"><div><h2>${esc(selected.folder)}</h2><span class="muted">${esc(selected.sam)} · ${selected.userCount} wirksame Benutzer</span></div>${selected.active ? badge("Aktiv") : badge("Archiviert", "warn")}</div><div class="tree">${treeNodes(selected.members, query) || empty("Keine Benutzer mit Zugriff")}</div>`;
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"]; const permissionLabels = ["Kein Zugriff", "Lesen", "Lesen + Ändern", "Lesen + Ändern + Löschen"];
async function renderAccess() { async function renderAccess() {
@@ -284,7 +249,7 @@ async function renderAccess() {
try { try {
data = await api("/api/access", {method: "POST", body: JSON.stringify(body)}); data = await api("/api/access", {method: "POST", body: JSON.stringify(body)});
if (body.action === "create-folder") { 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; folderId = data.folders.find(item => item.name === body.name.trim())?.id;
} }
if (body.action === "archive-folder") folderFilter = "archived"; 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"); const modal = document.createElement("dialog");
modal.className = "access-dialog"; modal.className = "access-dialog";
modal.setAttribute("aria-label", title); modal.setAttribute("aria-label", title);
modal.innerHTML = `<form><h2>${esc(title)}</h2><p>${esc(explanation)}</p>${initial !== null ? `<label>Name<input value="${esc(initial)}" maxlength="120" required autofocus></label>` : ""}<div class="access-dialog-actions"><button type="button" data-cancel>Abbrechen</button><button type="submit" class="${danger ? "access-danger" : ""}">${esc(label)}</button></div></form>`; modal.innerHTML = `<form><h2>${esc(title)}</h2><p>${esc(explanation)}</p>${initial !== null ? `<label>Name<input value="${esc(initial)}" maxlength="120" required autofocus></label>` : ""}<div class="access-dialog-actions"><button type="button" data-cancel>Abbrechen</button><button type="submit" class="${danger ? "access-danger" : ""} ${icon ? "icon-button" : ""}"${icon ? ` aria-label="${esc(label)}" title="${esc(label)}"` : ""}>${icon ? actionIcon(icon) : esc(label)}</button></div></form>`;
document.body.append(modal); document.body.append(modal);
modal.querySelector("[data-cancel]").addEventListener("click", () => modal.close()); modal.querySelector("[data-cancel]").addEventListener("click", () => modal.close());
modal.addEventListener("close", () => modal.remove()); modal.addEventListener("close", () => modal.remove());
@@ -335,11 +300,11 @@ async function renderAccess() {
root.querySelector("#access-list").innerHTML = items.length ? items.map(item => { root.querySelector("#access-list").innerHTML = items.length ? items.map(item => {
const count = item.permissions.filter(rule => rule.level > 0).length; const count = item.permissions.filter(rule => rule.level > 0).length;
const subtitle = item.active ? `${count} Benutzer` : "Archiviert"; const subtitle = item.active ? `${count} Benutzer` : "Archiviert";
return `<button type="button" class="select-row access-list-item ${item.id === folderId ? "active" : ""}" data-select="${esc(item.id)}" aria-pressed="${item.id === folderId}"><strong>${esc(item.name)}</strong><small>${esc(subtitle)}</small></button>`; return `<button type="button" class="select-row access-list-item ${item.id === folderId ? "active" : ""}" data-select="${esc(item.id)}" aria-pressed="${item.id === folderId}"><strong title="${esc(item.name)}">${esc(item.name)}</strong><small>${esc(subtitle)}</small></button>`;
}).join("") : `<div class="access-empty">${query ? "Keine Treffer. Suchbegriff ändern." : "Keine Ordner in dieser Ansicht."}</div>`; }).join("") : `<div class="access-empty">${query ? "Keine Treffer. Suchbegriff ändern." : "Keine Ordner in dieser Ansicht."}</div>`;
root.querySelectorAll("[data-select]").forEach(button => button.addEventListener("click", () => { root.querySelectorAll("[data-select]").forEach(button => button.addEventListener("click", () => {
if (button.dataset.select === folderId || !canLeave()) return; if (button.dataset.select === folderId || !canLeave()) return;
folderId = button.dataset.select; userQuery = ""; userFilter = "all"; folderId = button.dataset.select;
loadDraft(); drawList(); drawDetail(); loadDraft(); drawList(); drawDetail();
})); }));
} }
@@ -374,7 +339,7 @@ async function renderAccess() {
detail.innerHTML = data.folders.length ? '<div class="access-empty"><h2 id="access-folder-title">Kein Ordner in dieser Ansicht</h2><p>Den Ordnerstatus wechseln oder einen neuen Ordner anlegen.</p></div>' : '<div class="access-empty"><h2 id="access-folder-title">Ersten Ordner anlegen</h2><p>Mit „Neuer Ordner“ beginnen. Anschließend die Rechte für einzelne AD-Benutzer festlegen.</p></div>'; detail.innerHTML = data.folders.length ? '<div class="access-empty"><h2 id="access-folder-title">Kein Ordner in dieser Ansicht</h2><p>Den Ordnerstatus wechseln oder einen neuen Ordner anlegen.</p></div>' : '<div class="access-empty"><h2 id="access-folder-title">Ersten Ordner anlegen</h2><p>Mit „Neuer Ordner“ beginnen. Anschließend die Rechte für einzelne AD-Benutzer festlegen.</p></div>';
return; return;
} }
detail.innerHTML = `<header class="access-detail-head"><div><h2 id="access-folder-title">${esc(selected.name)}</h2><p>${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."}</p></div><button type="button" id="access-archive" class="link-button">${selected.active ? "Archivieren" : "Wiederherstellen"}</button></header> detail.innerHTML = `<header class="access-detail-head"><div><h2 id="access-folder-title">${esc(selected.name)}</h2><p>${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."}</p></div><button type="button" id="access-archive" class="${selected.active ? "link-button" : "icon-button"}"${selected.active ? "" : ' aria-label="Wiederherstellen" title="Wiederherstellen"'}>${selected.active ? "Archivieren" : actionIcon("restore")}</button></header>
<section class="access-section"><div class="access-section-head"><h3>Benutzerrechte</h3></div> <section class="access-section"><div class="access-section-head"><h3>Benutzerrechte</h3></div>
<div class="access-filter-row"><label><span class="sr-only">Benutzer suchen</span><input id="access-user-search" type="search" placeholder="Name oder Benutzername suchen" value="${esc(userQuery)}"></label><label><span class="sr-only">Zugriff filtern</span><select id="access-user-filter"><option value="all">Alle Benutzer</option><option value="granted">${selected.active ? "Mit Zugriff" : "Mit gespeicherten Rechten"}</option><option value="hidden">${selected.active ? "Ohne Zugriff" : "Ohne gespeicherte Rechte"}</option></select></label></div> <div class="access-filter-row"><label><span class="sr-only">Benutzer suchen</span><input id="access-user-search" type="search" placeholder="Name oder Benutzername suchen" value="${esc(userQuery)}"></label><label><span class="sr-only">Zugriff filtern</span><select id="access-user-filter"><option value="all">Alle Benutzer</option><option value="granted">${selected.active ? "Mit Zugriff" : "Mit gespeicherten Rechten"}</option><option value="hidden">${selected.active ? "Ohne Zugriff" : "Ohne gespeicherte Rechte"}</option></select></label></div>
<p id="access-user-count" class="muted" role="status"></p> <p id="access-user-count" class="muted" role="status"></p>
@@ -388,7 +353,7 @@ async function renderAccess() {
detail.querySelector("#access-discard").addEventListener("click", () => { loadDraft(); drawDetail(); }); detail.querySelector("#access-discard").addEventListener("click", () => { loadDraft(); drawDetail(); });
detail.querySelector("#access-archive").addEventListener("click", () => { detail.querySelector("#access-archive").addEventListener("click", () => {
if (!canLeave()) return; 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(); drawUsers(); updateDirty();
} }
@@ -403,7 +368,7 @@ async function renderAccess() {
if (!canLeave()) { event.target.value = folderFilter; return; } if (!canLeave()) { event.target.value = folderFilter; return; }
folderFilter = event.target.value; folderFilter = event.target.value;
const visible = data.folders.filter(item => folderFilter === "all" || item.active === (folderFilter === "active")); 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(); loadDraft(); drawList(); drawDetail();
}); });
root.querySelector("#access-create").addEventListener("click", () => { root.querySelector("#access-create").addEventListener("click", () => {
@@ -524,7 +489,7 @@ async function renderTrash() {
<td class="path">${esc(item.path)}</td> <td class="path">${esc(item.path)}</td>
<td class="numeric">${bytes(item.size)}</td> <td class="numeric">${bytes(item.size)}</td>
<td class="timestamp">${esc(utcTime(item.expiresAt))}</td> <td class="timestamp">${esc(utcTime(item.expiresAt))}</td>
<td><div class="row-actions"><a class="button" href="/api/trash/download?id=${encodeURIComponent(item.id)}" download>Herunterladen</a><button type="button" data-restore="${esc(item.id)}">Wiederherstellen</button></div></td> <td><div class="row-actions"><a class="button icon-button" href="/api/trash/download?id=${encodeURIComponent(item.id)}" download aria-label="Herunterladen" title="Herunterladen">${actionIcon("download")}</a><button type="button" class="icon-button" data-restore="${esc(item.id)}" aria-label="Wiederherstellen" title="Wiederherstellen">${actionIcon("restore")}</button></div></td>
</tr>`).join(""); </tr>`).join("");
} }
const shown = result.items.length.toLocaleString("de-DE"); const shown = result.items.length.toLocaleString("de-DE");
-1
View File
@@ -26,7 +26,6 @@
<div class="brand">Dateiserver</div> <div class="brand">Dateiserver</div>
<nav id="navigation" aria-label="Hauptnavigation"> <nav id="navigation" aria-label="Hauptnavigation">
<a href="/overview" data-route="overview">Übersicht</a> <a href="/overview" data-route="overview">Übersicht</a>
<a href="/shares" data-route="shares">Dateifreigaben</a>
<a href="/access" data-route="access">Zugriffsverwaltung</a> <a href="/access" data-route="access">Zugriffsverwaltung</a>
<a href="/reconciliation" data-route="reconciliation">Freigabenabgleich</a> <a href="/reconciliation" data-route="reconciliation">Freigabenabgleich</a>
<a href="/storage/data" data-route="storage-data">Datenbelegung</a> <a href="/storage/data" data-route="storage-data">Datenbelegung</a>
+3 -15
View File
@@ -9,6 +9,8 @@ body { margin: 0; min-height: 100vh; }
a { color: #0645ad; } a { color: #0645ad; }
button, input, select { font: inherit; } button, input, select { font: inherit; }
button, .button { display: inline-block; padding: .4rem .7rem; border: 1px solid #777; color: #111; background: #eee; cursor: pointer; text-decoration: none; white-space: nowrap; } button, .button { display: inline-block; padding: .4rem .7rem; border: 1px solid #777; color: #111; background: #eee; cursor: pointer; text-decoration: none; white-space: nowrap; }
button.icon-button, .button.icon-button { display: inline-flex; align-items: center; justify-content: center; min-width: 2.4rem; min-height: 2.4rem; padding: .4rem; flex-shrink: 0; }
.action-icon { display: block; width: 20px; height: 20px; }
button:disabled { color: #777; cursor: wait; } button:disabled { color: #777; cursor: wait; }
input, select { width: 100%; padding: .4rem; border: 1px solid #999; background: #fff; } input, select { width: 100%; padding: .4rem; border: 1px solid #999; background: #fff; }
button:focus, input:focus, select:focus, a:focus { outline: 2px solid #0645ad; outline-offset: 1px; } button:focus, input:focus, select:focus, a:focus { outline: 2px solid #0645ad; outline-offset: 1px; }
@@ -44,12 +46,6 @@ nav a.active { font-weight: bold; background: #ddd; }
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: .7rem; } .panel-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: .7rem; }
.panel-head h2 { margin: 0; } .panel-head h2 { margin: 0; }
.split { display: grid; grid-template-columns: minmax(260px, .7fr) minmax(380px, 1.3fr); gap: 1rem; } .split { display: grid; grid-template-columns: minmax(260px, .7fr) minmax(380px, 1.3fr); gap: 1rem; }
.shares-view { display: flex; height: calc(100vh - 4.5rem); height: calc(100dvh - 4.5rem); min-height: 0; flex-direction: column; }
.shares-split { min-height: 0; flex: 1; }
.shares-split > .panel { display: flex; min-height: 0; margin-bottom: 0; flex-direction: column; }
.shares-split .list { min-height: 0; flex: 1; overflow-y: auto; }
.shares-split #tree-panel { display: flex; height: 100%; min-height: 0; flex-direction: column; }
.shares-split .tree { min-height: 0; max-height: none; flex: 1; overflow-y: auto; }
.filters { display: grid; grid-template-columns: repeat(6, minmax(110px, 1fr)); gap: .6rem; margin-bottom: 1rem; align-items: end; } .filters { display: grid; grid-template-columns: repeat(6, minmax(110px, 1fr)); gap: .6rem; margin-bottom: 1rem; align-items: end; }
.filters .wide { grid-column: span 2; } .filters .wide { grid-column: span 2; }
.table-wrap { width: 100%; overflow-x: auto; } .table-wrap { width: 100%; overflow-x: auto; }
@@ -65,16 +61,9 @@ th, td { padding: .5rem; border-bottom: 1px solid #ccc; vertical-align: top; }
.toolbar { display: flex; flex-wrap: wrap; gap: .5rem; } .toolbar { display: flex; flex-wrap: wrap; gap: .5rem; }
.toolbar input { max-width: 340px; } .toolbar input { max-width: 340px; }
.row-actions { display: flex; flex-wrap: wrap; gap: .4rem; } .row-actions { display: flex; flex-wrap: wrap; gap: .4rem; }
.list { margin: 0; padding: 0; list-style: none; }
.select-row { width: 100%; display: grid; grid-template-columns: 1fr auto; gap: .5rem; border: 0; border-bottom: 1px solid #ccc; background: #fff; text-align: left; } .select-row { width: 100%; display: grid; grid-template-columns: 1fr auto; gap: .5rem; border: 0; border-bottom: 1px solid #ccc; background: #fff; text-align: left; }
.select-row.active { font-weight: bold; background: #eee; } .select-row.active { font-weight: bold; background: #eee; }
.select-row span:last-child { color: #555; font-size: .85rem; } .select-row span:last-child { color: #555; font-size: .85rem; }
.tree { max-height: 65vh; overflow: auto; }
.tree details { margin-left: 1rem; padding-left: .5rem; border-left: 1px solid #bbb; }
.tree > details { margin-left: 0; border-left: 0; }
.tree summary, .tree .leaf { padding: .2rem 0; }
.tree .leaf { margin-left: 1.5rem; }
.tree .kind { display: inline-block; min-width: 4.5rem; color: #555; }
.empty { padding: 1rem 0; color: #666; } .empty { padding: 1rem 0; color: #666; }
progress { width: 100%; } progress { width: 100%; }
.usage-bar { min-width: 160px; } .usage-bar { min-width: 160px; }
@@ -89,7 +78,6 @@ progress { width: 100%; }
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.filters { grid-template-columns: repeat(3, minmax(120px, 1fr)); } .filters { grid-template-columns: repeat(3, minmax(120px, 1fr)); }
.split { grid-template-columns: 1fr; } .split { grid-template-columns: 1fr; }
.shares-split { grid-template-rows: repeat(2, minmax(0, 1fr)); }
} }
@media (max-width: 700px) { @media (max-width: 700px) {
.sidebar { display: none; z-index: 5; } .sidebar { display: none; z-index: 5; }
@@ -115,7 +103,7 @@ progress { width: 100%; }
.access-search, .access-status-filter { margin: 0 .8rem .65rem; } .access-search, .access-status-filter { margin: 0 .8rem .65rem; }
.access-list { border-top: 1px solid #ccc; max-height: 60vh; overflow-y: auto; } .access-list { border-top: 1px solid #ccc; max-height: 60vh; overflow-y: auto; }
.access-list .access-list-item { grid-template-columns: minmax(0,1fr) auto; align-items: center; padding: .5rem .7rem; white-space: normal; } .access-list .access-list-item { grid-template-columns: minmax(0,1fr) auto; align-items: center; padding: .5rem .7rem; white-space: normal; }
.access-list-item strong { font-weight: inherit; overflow-wrap: anywhere; } .access-list-item strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: inherit; }
.access-list-item small { color: #555; font-size: .85rem; font-weight: normal; text-align: right; } .access-list-item small { color: #555; font-size: .85rem; font-weight: normal; text-align: right; }
.access-detail-head { display: flex; gap: 1rem; justify-content: space-between; align-items: start; padding: .8rem; } .access-detail-head { display: flex; gap: 1rem; justify-content: space-between; align-items: start; padding: .8rem; }
.access-detail-head h2 { margin: 0 0 .3rem; overflow-wrap: anywhere; } .access-detail-head h2 { margin: 0 0 .3rem; overflow-wrap: anywhere; }
+6 -2
View File
@@ -28,9 +28,11 @@ from typing import Dict, List, Optional, Tuple
try: try:
from app import reconcile_shares as directory from app import reconcile_shares as directory
from app import trash, access_control from app import trash, access_control
from app.account_policy import is_excluded_user
except ImportError: # Container execution uses /app as the import root. except ImportError: # Container execution uses /app as the import root.
import reconcile_shares as directory import reconcile_shares as directory
import trash, access_control import trash, access_control
from account_policy import is_excluded_user
try: try:
from app.audit_store import ( from app.audit_store import (
@@ -520,7 +522,9 @@ class UsageScanner:
def snapshot(self) -> Dict[str, object]: def snapshot(self) -> Dict[str, object]:
with self.lock: with self.lock:
return json.loads(json.dumps(self.data)) value = json.loads(json.dumps(self.data))
value["users"] = [row for row in value.get("users", []) if not is_excluded_user(str(row["name"]))]
return value
def scan(self) -> Dict[str, object]: def scan(self) -> Dict[str, object]:
started = now_utc() started = now_utc()
@@ -556,7 +560,7 @@ class UsageScanner:
"scannedAt": now_utc().isoformat(timespec="seconds"), "scannedAt": now_utc().isoformat(timespec="seconds"),
"scanSeconds": round((now_utc() - started).total_seconds(), 3), "scanSeconds": round((now_utc() - started).total_seconds(), 3),
"groups": groups, "groups": groups,
"users": user_rows, "users": [row for row in user_rows if not is_excluded_user(str(row["name"]))],
"totals": { "totals": {
"dataBytes": sum(int(row["bytes"]) for row in groups), "dataBytes": sum(int(row["bytes"]) for row in groups),
"privateBytes": sum(int(row["privateBytes"]) for row in user_rows), "privateBytes": sum(int(row["privateBytes"]) for row in user_rows),
+3 -2
View File
@@ -118,6 +118,7 @@ ensure_user eve "$AD_USER_PASSWORD" Eve Evans
ensure_user frank "$AD_USER_PASSWORD" Frank Foster ensure_user frank "$AD_USER_PASSWORD" Frank Foster
ensure_user "$AD_WEB_ADMIN_USER" "$AD_WEB_ADMIN_PASSWORD" Preview Administrator ensure_user "$AD_WEB_ADMIN_USER" "$AD_WEB_ADMIN_PASSWORD" Preview Administrator
ensure_user report_svc "$AD_USER_PASSWORD" Report Service ensure_user report_svc "$AD_USER_PASSWORD" Report Service
ensure_user MSOL_sync "$AD_USER_PASSWORD" Directory Sync
for group in Finance_Analysts Engineering_Leads FS_Finance FS_Engineering FS_Projects; do for group in Finance_Analysts Engineering_Leads FS_Finance FS_Engineering FS_Projects; do
ensure_group "$group" ensure_group "$group"
@@ -129,8 +130,8 @@ set_display_name FS_Finance Finance
set_display_name FS_Engineering Engineering set_display_name FS_Engineering Engineering
set_display_name FS_Projects Projects set_display_name FS_Projects Projects
add_members Finance_Analysts alice add_members Finance_Analysts 'alice,MSOL_sync'
add_members FS_Finance 'Finance_Analysts,bob,report_svc' add_members FS_Finance 'Finance_Analysts,bob,report_svc,krbtgt'
add_members Engineering_Leads carol add_members Engineering_Leads carol
add_members FS_Engineering 'Engineering_Leads,dave' add_members FS_Engineering 'Engineering_Leads,dave'
add_members FS_Projects 'FS_Finance,FS_Engineering,eve' add_members FS_Projects 'FS_Finance,FS_Engineering,eve'
+62
View File
@@ -289,6 +289,14 @@ def main() -> int:
check({"alice", "bob", "carol", "dave", "eve", "frank"}.issubset({sam for kind, sam, _ in project_nodes if kind == "user"}), "transitive or primary-group Project membership is incomplete") check({"alice", "bob", "carol", "dave", "eve", "frank"}.issubset({sam for kind, sam, _ in project_nodes if kind == "user"}), "transitive or primary-group Project membership is incomplete")
check(all(kind == "user" for kind, _, _ in project_nodes), "Projects still contains group assignments") check(all(kind == "user" for kind, _, _ in project_nodes), "Projects still contains group assignments")
check(not {'msol_sync', 'krbtgt'} & {sam.casefold() for _, sam, _ in finance_nodes + project_nodes},
"service identities were imported as folder users")
service_denied = engine_run(
"exec", CLIENT_CONTAINER, "smbclient", f"//files.{DNS_DOMAIN}/Data", "-m", "SMB3",
"-U", f"{WORKGROUP}\\MSOL_sync%{USER_PASSWORD}", "-c", "cd Finance; ls", check_result=False)
check(service_denied.returncode != 0 or "NT_STATUS_ACCESS_DENIED" in service_denied.stdout,
"excluded service account received migrated access")
announce("legacy GUID-path migration preserves file hashes and inodes") announce("legacy GUID-path migration preserves file hashes and inodes")
engine_run("exec", FILES_CONTAINER, "python3", "-c", """ engine_run("exec", FILES_CONTAINER, "python3", "-c", """
import hashlib, json import hashlib, json
@@ -753,6 +761,15 @@ fi
"permissions": [{"kind": "group", "principalId": "legacy", "level": 3}]}, token=token) "permissions": [{"kind": "group", "principalId": "legacy", "level": 3}]}, token=token)
check(rejected.status == 400, "group folder assignment accepted") check(rejected.status == 400, "group folder assignment accepted")
announce("MSOL_* and krbtgt cannot be selected or granted folder access")
check(not {'msol_sync', 'krbtgt'} & {name.casefold() for name in user_sids}, "excluded accounts appear in access UI data")
for name in ('MSOL_sync', 'krbtgt'):
sid = engine_run("exec", FILES_CONTAINER, "wbinfo", "--name-to-sid", f"{WORKGROUP}\\{name}").stdout.split()[0]
rejected = eventually("excluded-account grant", lambda: http("/api/access", method="POST", value={
'action': 'set-permissions', 'id': folder_id,
'permissions': [{'kind': 'user', 'principalId': sid, 'level': 3}]}, token=token), lambda r: r.status != 409, timeout=45)
check(rejected.status == 400, f"excluded account {name} accepted a direct grant")
def rules(level): def rules(level):
return change({"action": "set-permissions", "id": folder_id, return change({"action": "set-permissions", "id": folder_id,
"permissions": [{"kind": "user", "principalId": user_sids["alice"], "level": level}]}) "permissions": [{"kind": "user", "principalId": user_sids["alice"], "level": level}]})
@@ -816,6 +833,51 @@ fi
invalid = http("/api/access", method="POST", value={"action": "create-folder", "name": "../escape"}, token=token) invalid = http("/api/access", method="POST", value={"action": "create-folder", "name": "../escape"}, token=token)
check(invalid.status == 400, "unsafe folder path accepted") check(invalid.status == 400, "unsafe folder path accepted")
announce("previously stored system-account grant revoked without deleting data")
# Reproduce a policy and ACL from a version that allowed MSOL accounts.
previous_service_file = json.loads(engine_run("exec", FILES_CONTAINER, "python3", "-c", """
import hashlib, json, os, sys
sys.path.insert(0, '/app')
import access_control as access
import reconcile_shares as directory
folder_id=sys.argv[1]
os.environ['DOMAIN_ADMINS_SID']=sys.argv[2]+'-512'
sid=directory.run_command(['wbinfo','--name-to-sid','MSOL_sync']).stdout.split()[0]
with access.mutation_lock():
conn=directory.open_db()
row=conn.execute('SELECT * FROM shares WHERE objectGUID=?',(folder_id,)).fetchone()
retained=os.path.join(row['path'],'retained-system-grant.bin')
with open(retained,'xb') as handle:
handle.write(b'previously granted data must survive')
top=access.descriptor({sid:3},os.environ['DOMAIN_ADMINS_SID'],top_level=True)
access.set_acl(row['path'],access.pack_descriptor(top),True)
access.set_acl(retained,access.pack_descriptor(access.descriptor({sid:3},os.environ['DOMAIN_ADMINS_SID'],False)),False)
conn.execute('INSERT INTO folder_permissions VALUES(?,?,?,?)',(folder_id,'user',sid,3))
conn.execute('UPDATE shares SET aclSignature=? WHERE objectGUID=?',(hashlib.sha256(top.encode()).hexdigest(),folder_id))
conn.commit()
conn.close()
with open(retained,'rb') as handle:
print(json.dumps({'sha256':hashlib.sha256(handle.read()).hexdigest(),'inode':os.stat(retained).st_ino}))
""", folder_id, DOMAIN_SID).stdout)
allowed('cd Permissions; get retained-system-grant.bin /tmp/old-system-grant.bin', 'MSOL_sync')
engine_run('exec', FILES_CONTAINER, 'bash', '-lc',
'source /app/runtime.env && exec python3 /app/reconcile_shares.py')
code, output = smb('cd Permissions; get retained-system-grant.bin /tmp/revoked-system-grant.bin', 'MSOL_sync')
check(code != 0 or 'NT_STATUS_ACCESS_DENIED' in output, 'stored system-account grant still permits SMB reads')
actual_service_file = json.loads(engine_run('exec', FILES_CONTAINER, 'python3', '-c', """
import hashlib, json, os, sys
sys.path.insert(0,'/app')
import reconcile_shares as directory
conn=directory.open_db()
assert not conn.execute('SELECT 1 FROM folder_permissions WHERE folderId=?',(sys.argv[1],)).fetchone()
assert not conn.execute("SELECT 1 FROM access_settings WHERE key='pendingExcludedRevocation'").fetchone()
conn.close()
path='/data/groups/data/Permissions/retained-system-grant.bin'
with open(path,'rb') as handle:
print(json.dumps({'sha256':hashlib.sha256(handle.read()).hexdigest(),'inode':os.stat(path).st_ino}))
""", folder_id).stdout)
check(actual_service_file == previous_service_file, 'system-account revocation changed file contents or inode')
announce("already-migrated GUID path survives container restart with all data and rights") announce("already-migrated GUID path survives container restart with all data and rights")
created = change({"action": "create-folder", "name": "Startup recovery"}) created = change({"action": "create-folder", "name": "Startup recovery"})
repair_id = next(f["id"] for f in created["folders"] if f["name"] == "Startup recovery") repair_id = next(f["id"] for f in created["folders"] if f["name"] == "Startup recovery")
+109 -7
View File
@@ -18,10 +18,14 @@ const data={initialized:true,fetchedAt:new Date().toISOString(),users:[
...Array.from({length:77},(_,i)=>({sid:'user-'+i,sam:'mitarbeiter.'+String(i+1).padStart(2,'0'),name:'Mitarbeiter '+(i+1),available:true})) ...Array.from({length:77},(_,i)=>({sid:'user-'+i,sam:'mitarbeiter.'+String(i+1).padStart(2,'0'),name:'Mitarbeiter '+(i+1),available:true}))
],folders:[ ],folders:[
{id:'invoices',name:'Rechnungen',active:true,permissions:[{kind:'user',principalId:'alice',level:2},{kind:'user',principalId:'bob',level:1},{kind:'user',principalId:'carol',level:0}]}, {id:'invoices',name:'Rechnungen',active:true,permissions:[{kind:'user',principalId:'alice',level:2},{kind:'user',principalId:'bob',level:1},{kind:'user',principalId:'carol',level:0}]},
{id:'projects',name:'Projektunterlagen',active:true,permissions:[{kind:'user',principalId:'carol',level:3}]}, {id:'projects',name:'Projektunterlagen und technische Dokumentation internationaler Niederlassungen für das laufende Geschäftsjahr',active:true,permissions:[{kind:'user',principalId:'carol',level:3}]},
{id:'archive',name:'Abschluss 2024',active:false,permissions:[]} {id:'archive',name:'Abschluss 2024',active:false,permissions:[]}
]}; ]};
let nextId=0, failSave=false; const requests=[],errors=[]; const trashItems=[
{id:'data/report',share:'Data',path:'Finance/Budget.xlsx',size:14336,deletedAt:'2026-10-03T09:20:00Z',deletedBy:'EXAMPLE\\alice',expiresAt:'2026-10-10T09:20:00Z'},
{id:'private/note',share:'Private',path:'Notizen.txt',size:1536,deletedAt:'2026-10-03T08:45:00Z',deletedBy:'EXAMPLE\\bob',expiresAt:'2026-10-10T08:45:00Z'}
];
let nextId=0, failSave=false; const requests=[],errors=[],restores=[];
(async()=>{ (async()=>{
const browser=await chromium.launch({headless:true}); const browser=await chromium.launch({headless:true});
const page=await browser.newPage({viewport:{width:1500,height:1050}}); const page=await browser.newPage({viewport:{width:1500,height:1050}});
@@ -37,6 +41,11 @@ let nextId=0, failSave=false; const requests=[],errors=[];
if(change.action==='create-folder')data.folders.push({id:'folder-'+(++nextId),name:change.name,active:true,permissions:[]}); if(change.action==='create-folder')data.folders.push({id:'folder-'+(++nextId),name:change.name,active:true,permissions:[]});
if(change.action==='archive-folder'||change.action==='restore-folder')data.folders.find(f=>f.id===change.id).active=change.action==='restore-folder'; if(change.action==='archive-folder'||change.action==='restore-folder')data.folders.find(f=>f.id===change.id).active=change.action==='restore-folder';
}body=data; }body=data;
} else if(url.pathname==='/api/trash')body={items:trashItems,matched:trashItems.length,retentionDays:7,truncated:false};
else if(url.pathname==='/api/trash/restore'){
const change=route.request().postDataJSON();restores.push(change);
const item=trashItems.find(item=>item.id===change.id);
trashItems.splice(trashItems.indexOf(item),1);body={share:item.share,path:item.path};
} else if(url.pathname==='/assets/app.js'){body=fs.readFileSync(root+'/app.js');type='text/javascript';} } else if(url.pathname==='/assets/app.js'){body=fs.readFileSync(root+'/app.js');type='text/javascript';}
else if(url.pathname==='/assets/styles.css'){body=fs.readFileSync(root+'/styles.css');type='text/css';} else if(url.pathname==='/assets/styles.css'){body=fs.readFileSync(root+'/styles.css');type='text/css';}
else {body=fs.readFileSync(root+'/index.html');type='text/html';} else {body=fs.readFileSync(root+'/index.html');type='text/html';}
@@ -44,6 +53,16 @@ let nextId=0, failSave=false; const requests=[],errors=[];
}); });
const settled=()=>page.waitForFunction(()=>document.querySelector('#access-message')?.textContent==='Gespeichert. Die Änderungen sind wirksam.'&&!document.querySelector('#access-create').disabled); const settled=()=>page.waitForFunction(()=>document.querySelector('#access-message')?.textContent==='Gespeichert. Die Änderungen sind wirksam.'&&!document.querySelector('#access-create').disabled);
const shot=async name=>page.screenshot({path:out+'/'+name+'.png',fullPage:true}); const shot=async name=>page.screenshot({path:out+'/'+name+'.png',fullPage:true});
const checkShareNameEllipsis=async()=>{
const longName=page.locator('[data-select=projects] strong');
assert.equal(await longName.getAttribute('title'),data.folders.find(f=>f.id==='projects').name);
assert.equal(await longName.evaluate(element=>element.scrollWidth>element.clientWidth),true,'Long share name is not clipped');
const heights=await page.evaluate(()=>({
long:document.querySelector('[data-select=projects]').getBoundingClientRect().height,
short:document.querySelector('[data-select=invoices]').getBoundingClientRect().height
}));
assert.ok(Math.abs(heights.long-heights.short)<=1,'Long share name wraps and increases row height');
};
const checkSharedStyle=async()=>{ const checkSharedStyle=async()=>{
const differences=await page.evaluate(()=>{ const differences=await page.evaluate(()=>{
const mismatches=[]; const mismatches=[];
@@ -68,12 +87,49 @@ let nextId=0, failSave=false; const requests=[],errors=[];
}); });
assert.deepEqual(differences,[],'Access controls differ from the established admin styles'); assert.deepEqual(differences,[],'Access controls differ from the established admin styles');
}; };
await page.goto('http://adfs.test/access');await page.locator('[data-user-rule]').first().waitFor(); await page.goto('http://adfs.test/shares');await page.locator('[data-user-rule]').first().waitFor();
assert.equal(new URL(page.url()).pathname,'/access');
assert.equal(await page.locator('nav a[href="/shares"]').count(),0);
assert.equal(await page.locator('[data-tab=groups], [data-group-rule]').count(),0); assert.equal(await page.locator('[data-tab=groups], [data-group-rule]').count(),0);
assert.doesNotMatch(await page.locator('#content').innerText(),/Gruppe/); assert.doesNotMatch(await page.locator('#content').innerText(),/Gruppe/);
assert.equal(await page.locator('#access-users .access-matrix-row').count(),80); assert.equal(await page.locator('#access-users .access-matrix-row').count(),80);
await checkSharedStyle(); await checkSharedStyle();
await checkShareNameEllipsis();
await shot('01-desktop-benutzerrechte'); await shot('01-desktop-benutzerrechte');
// Filters remain global to the access page while each folder loads its own rights.
await page.locator('#access-search').fill('r');
await page.selectOption('#folder-status','all');
for(const filter of ['granted','hidden','all']){
await page.locator('#access-user-search').fill('alice');
await page.selectOption('#access-user-filter',filter);
await page.locator('[data-select=projects]').click();
assert.equal(await page.locator('#access-user-search').inputValue(),'alice');
assert.equal(await page.locator('#access-user-filter').inputValue(),filter);
assert.equal(await page.locator('#access-search').inputValue(),'r');
assert.equal(await page.locator('#folder-status').inputValue(),'all');
assert.equal(await page.locator('#access-users .access-matrix-row').count(),filter==='granted'?0:1);
if(filter==='granted'){
assert.match(await page.locator('#access-users').innerText(),/Keine Benutzer/);
await shot('08-folder-switch-retains-empty-filter');
}
await page.locator('[data-select=invoices]').click();
assert.equal(await page.locator('#access-user-search').inputValue(),'alice');
assert.equal(await page.locator('#access-user-filter').inputValue(),filter);
assert.equal(await page.locator('#access-users .access-matrix-row').count(),filter==='hidden'?0:1);
}
await page.locator('#access-search').fill('');
await page.selectOption('#access-user-filter','granted');
await page.selectOption('#folder-status','archived');
assert.equal(await page.locator('#access-user-search').inputValue(),'alice');
assert.equal(await page.locator('#access-user-filter').inputValue(),'granted');
assert.equal(await page.locator('#access-users .access-matrix-row').count(),0);
await page.selectOption('#folder-status','active');
assert.equal(await page.locator('#access-user-search').inputValue(),'alice');
assert.equal(await page.locator('#access-user-filter').inputValue(),'granted');
assert.equal(await page.locator('#access-users .access-matrix-row').count(),1);
await shot('09-folder-switch-retains-matching-filter');
await page.locator('#access-user-search').fill('');
await page.selectOption('#access-user-filter','all');
assert.equal(await page.locator('#access-save').isDisabled(),true); assert.equal(await page.locator('#access-save').isDisabled(),true);
await page.locator('#access-user-search').fill('alice'); await page.locator('#access-user-search').fill('alice');
await page.locator('[data-user-rule=alice][data-level="3"]').click(); await page.locator('[data-user-rule=alice][data-level="3"]').click();
@@ -115,25 +171,43 @@ let nextId=0, failSave=false; const requests=[],errors=[];
await page.selectOption('#access-user-filter','granted'); await page.selectOption('#access-user-filter','granted');
assert.equal(await page.locator('[data-user-rule=bob][data-level="2"]').getAttribute('aria-pressed'),'true'); assert.equal(await page.locator('[data-user-rule=bob][data-level="2"]').getAttribute('aria-pressed'),'true');
assert.equal(await page.locator('#access-save').isDisabled(),true); assert.equal(await page.locator('#access-save').isDisabled(),true);
await page.locator('[data-select=projects]').click(); await page.selectOption('#access-user-filter','hidden');
await page.locator('#access-user-search').fill('alice'); await page.locator('#access-user-search').fill('alice');
await page.locator('[data-select=projects]').click();
assert.equal(await page.locator('#access-user-search').inputValue(),'alice');
assert.equal(await page.locator('#access-user-filter').inputValue(),'hidden');
assert.equal(await page.locator('[data-user-rule=alice][data-level="0"]').getAttribute('aria-pressed'),'true'); assert.equal(await page.locator('[data-user-rule=alice][data-level="0"]').getAttribute('aria-pressed'),'true');
await page.locator('[data-user-rule=alice][data-level="1"]').click(); await page.locator('[data-user-rule=alice][data-level="1"]').click();
await page.locator('#access-save').click();await settled(); await page.locator('#access-save').click();await settled();
await page.locator('[data-select=invoices]').click(); await page.locator('[data-select=invoices]').click();
assert.equal(await page.locator('#access-user-search').inputValue(),'alice');
assert.equal(await page.locator('#access-user-filter').inputValue(),'hidden');
assert.equal(await page.locator('#access-users .access-matrix-row').count(),0);
await page.selectOption('#access-user-filter','granted');
assert.equal(await page.locator('[data-user-rule=alice][data-level="3"]').getAttribute('aria-pressed'),'true'); assert.equal(await page.locator('[data-user-rule=alice][data-level="3"]').getAttribute('aria-pressed'),'true');
await page.locator('#access-create').click(); await page.locator('#access-create').click();
await page.locator('.access-dialog input').fill('Neue Ablage'); await page.locator('.access-dialog input').fill('Neue Ablage');
await checkSharedStyle();await shot('04-desktop-ordner-anlegen'); await checkSharedStyle();await shot('04-desktop-ordner-anlegen');
await page.locator('.access-dialog button[type=submit]').click();await settled(); await page.locator('.access-dialog button[type=submit]').click();await settled();
assert.match(await page.locator('.access-detail-head h2').innerText(),/Neue Ablage/); assert.match(await page.locator('.access-detail-head h2').innerText(),/Neue Ablage/);
assert.equal(await page.locator('#access-user-search').inputValue(),'alice');
assert.equal(await page.locator('#access-user-filter').inputValue(),'granted');
assert.equal(await page.locator('#access-users .access-matrix-row').count(),0);
await page.locator('#access-user-search').fill('');
await page.selectOption('#access-user-filter','all');
assert.equal(await page.locator('#access-users button[aria-pressed=true][data-level="0"]').count(),80); assert.equal(await page.locator('#access-users button[aria-pressed=true][data-level="0"]').count(),80);
await page.locator('#access-archive').click(); await page.locator('#access-archive').click();
await page.locator('.access-dialog button[type=submit]').click();await settled(); await page.locator('.access-dialog button[type=submit]').click();await settled();
assert.equal(await page.locator('#folder-status').inputValue(),'archived'); assert.equal(await page.locator('#folder-status').inputValue(),'archived');
assert.equal(await page.locator('[data-user-rule]').first().isDisabled(),true); assert.equal(await page.locator('[data-user-rule]').first().isDisabled(),true);
await page.locator('#access-archive').click(); const folderRestore=page.getByRole('button',{name:'Wiederherstellen',exact:true});
await page.locator('.access-dialog button[type=submit]').click();await settled(); assert.equal(await folderRestore.getAttribute('title'),'Wiederherstellen');
assert.equal(await folderRestore.innerText(),'');
await folderRestore.locator('svg').click();
const confirmRestore=page.locator('.access-dialog').getByRole('button',{name:'Wiederherstellen',exact:true});
assert.equal(await confirmRestore.innerText(),'');
await shot('10-folder-restore-icon');
await confirmRestore.locator('svg').click();await settled();
assert.equal(await page.locator('#folder-status').inputValue(),'active'); assert.equal(await page.locator('#folder-status').inputValue(),'active');
await page.locator('[data-select=invoices]').click(); await page.locator('[data-select=invoices]').click();
await page.selectOption('#access-user-filter','granted'); await page.selectOption('#access-user-filter','granted');
@@ -146,6 +220,7 @@ let nextId=0, failSave=false; const requests=[],errors=[];
return footer.getBoundingClientRect().top >= section.getBoundingClientRect().bottom-1; return footer.getBoundingClientRect().top >= section.getBoundingClientRect().bottom-1;
}),true,'Save controls cover form content at '+width); }),true,'Save controls cover form content at '+width);
await checkSharedStyle(); await checkSharedStyle();
await checkShareNameEllipsis();
await shot('05-benutzerrechte-'+width); await shot('05-benutzerrechte-'+width);
} }
await page.selectOption('#access-user-filter','all'); await page.selectOption('#access-user-filter','all');
@@ -184,8 +259,35 @@ let nextId=0, failSave=false; const requests=[],errors=[];
await page.locator('#access-create').waitFor(); await page.locator('#access-create').waitFor();
assert.match(await page.locator('#access-detail').innerText(),/Ersten Ordner anlegen/); assert.match(await page.locator('#access-detail').innerText(),/Ersten Ordner anlegen/);
await shot('07-desktop-leere-installation'); await shot('07-desktop-leere-installation');
// Icon controls keep accessible names and activate their original actions.
await page.goto('http://adfs.test/trash');
await page.locator('#trash-rows .row-actions').first().waitFor();
for(const [role,name] of [['link','Herunterladen'],['button','Wiederherstellen']]){
const control=page.getByRole(role,{name,exact:true}).first();
assert.equal(await control.getAttribute('title'),name);
assert.equal(await control.innerText(),'');
assert.equal(await control.locator('svg').getAttribute('aria-hidden'),'true');
}
await shot('11-trash-action-icons-desktop');
const downloadStarted=page.waitForEvent('download');
await page.getByRole('link',{name:'Herunterladen',exact:true}).first().locator('svg').click();
const download=await downloadStarted;
assert.equal(download.url(),'http://adfs.test/api/trash/download?id=data%2Freport');
// Downloads leave page interception; verify the destination and cancel the mocked transfer.
await download.cancel();
await page.getByRole('button',{name:'Wiederherstellen',exact:true}).first().locator('svg').click();
assert.equal(restores.length,0,'Cancelling restoration should not submit');
page.removeAllListeners('dialog');
page.on('dialog',dialog=>dialog.accept());
await page.getByRole('button',{name:'Wiederherstellen',exact:true}).first().locator('svg').click();
await page.waitForFunction(()=>document.querySelectorAll('#trash-rows .row-actions').length===1);
assert.deepEqual(restores,[{id:'data/report'}]);
await page.setViewportSize({width:390,height:844});
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false,'Trash actions cause page overflow');
await page.locator('.table-wrap').evaluate(table=>{table.scrollLeft=table.scrollWidth;});
await shot('12-trash-action-icons-mobile');
assert.deepEqual(errors,[]); assert.deepEqual(errors,[]);
console.log('PASS: individual permissions, no groups, user search/filter persistence, unsaved-change protection, failure/retry, independent folders, create/archive/restore, desktop/mobile layout, shared admin styling.'); console.log('PASS: individual permissions, no groups, user search/filter persistence, unsaved-change protection, failure/retry, independent folders, create/archive/restore, desktop/mobile layout, shared admin styling, retired shares route, restore/download icons and actions.');
console.log('Screenshots: '+out); console.log('Screenshots: '+out);
await browser.close(); await browser.close();
})().catch(error=>{console.error(error);process.exit(1)}); })().catch(error=>{console.error(error);process.exit(1)});
+84
View File
@@ -39,6 +39,90 @@ class ManagedAccessTests(unittest.TestCase):
data = self.change({'action': 'create-folder', 'name': name}) data = self.change({'action': 'create-folder', 'name': name})
return next(row['id'] for row in data['folders'] if row['name'] == name) return next(row['id'] for row in data['folders'] if row['name'] == name)
def test_excluded_users_hidden_in_snapshots_reports_and_effective_permissions(self):
folder = self.folder()
banned = {ALICE: {'sam': 'MSOL_sync', 'name': 'Sync'},
BOB: {'sam': 'KrBtGt', 'name': 'Ticket Service'}}
access.cache_users(self.conn, banned)
self.conn.executemany('INSERT INTO folder_permissions VALUES(?,?,?,?)',
[(folder, 'user', ALICE, 3), (folder, 'user', BOB, 2)])
self.conn.commit()
self.assertEqual(access.snapshot(self.conn, banned)['users'], [])
self.assertEqual(access.snapshot(self.conn)['folders'][0]['permissions'], [])
self.assertEqual(access.effective_levels(self.conn, folder), {})
report = access.report_folders(self.conn)['groups'][0]
self.assertEqual(report['members'], [])
self.assertEqual(report['userCount'], 0)
def test_excluded_users_cannot_receive_new_or_old_positive_grants(self):
folder = self.folder()
banned = {ALICE: {'sam': 'mSoL_sync', 'name': 'Sync'},
BOB: {'sam': 'KRBTGT', 'name': 'Ticket Service'}}
access.cache_users(self.conn, banned)
self.conn.execute('INSERT INTO folder_permissions VALUES(?,?,?,?)', (folder, 'user', ALICE, 3))
self.conn.commit()
with mock.patch.object(access, 'sync_permissions'), mock.patch.object(directory, 'run_command', return_value=mock.Mock(returncode=0)):
# Both current-directory identities and the unavailable-user fallback
# must reject positive grants; removing a legacy grant remains possible.
for users, sid in [(banned, ALICE), (banned, BOB), ({}, ALICE)]:
for level in (1, 2, 3):
with self.subTest(users=users, sid=sid, level=level):
with self.assertRaisesRegex(ValueError, 'Systemkonto'):
access.apply_change(self.conn, {'action': 'set-permissions', 'id': folder,
'permissions': [{'kind': 'user', 'principalId': sid, 'level': level}]}, 'admin', users)
access.apply_change(self.conn, {'action': 'set-permissions', 'id': folder,
'permissions': [{'kind': 'user', 'principalId': ALICE, 'level': 0}]}, 'admin', {})
self.assertEqual(access.effective_levels(self.conn, folder), {})
def test_sync_revokes_old_grants_recursively_preserving_data_and_retries_disconnect(self):
folder = self.folder()
nested = os.path.join(directory.GROUP_ROOT, 'Finance', 'nested')
os.mkdir(nested)
path = os.path.join(nested, 'retained.bin')
with open(path, 'wb') as handle:
handle.write(b'production data must survive')
inode = os.stat(path).st_ino
access.cache_users(self.conn, {ALICE: {'sam': 'MSOL_sync', 'name': 'Sync'}})
self.conn.executemany('INSERT INTO folder_permissions VALUES(?,?,?,?)',
[(folder, 'user', ALICE, 3), (folder, 'user', BOB, 2)])
self.conn.commit()
with mock.patch.dict(os.environ, {'DOMAIN_ADMINS_SID': ADMIN}), mock.patch.object(access, 'pack_descriptor', side_effect=lambda s: s), mock.patch.object(access, 'set_acl') as set_acl:
access.sync_permissions(self.conn)
self.conn.commit()
self.assertEqual(access.effective_levels(self.conn, folder), {BOB: 2})
self.assertIsNone(self.conn.execute('SELECT 1 FROM folder_permissions WHERE principalId=?', (ALICE,)).fetchone())
self.assertEqual(self.conn.execute("SELECT COUNT(*) FROM access_changes WHERE action='revoke-excluded-users'").fetchone()[0], 1)
self.assertTrue(any(call.args[0] == path for call in set_acl.call_args_list))
self.assertTrue(all(ALICE not in call.args[1] for call in set_acl.call_args_list))
with mock.patch.object(directory, 'run_command', side_effect=[mock.Mock(returncode=1), mock.Mock(returncode=0)]) as run:
with self.assertRaises(RuntimeError):
access.close_data_connections(self.conn)
self.assertIsNotNone(self.conn.execute("SELECT 1 FROM access_settings WHERE key='pendingExcludedRevocation'").fetchone())
access.close_data_connections(self.conn)
access.close_data_connections(self.conn)
self.assertEqual(run.call_count, 2)
self.assertIsNone(self.conn.execute("SELECT 1 FROM access_settings WHERE key='pendingExcludedRevocation'").fetchone())
with open(path, 'rb') as handle:
self.assertEqual(handle.read(), b'production data must survive')
self.assertEqual(os.stat(path).st_ino, inode)
def test_migration_resolves_excluded_nested_and_primary_members_without_grants(self):
folder = self.folder()
self.conn.execute("UPDATE shares SET samAccountName='FS_Finance' WHERE objectGUID=?", (folder,))
self.conn.commit()
ticket = 'S-1-5-21-1-2-3-502'
users = {ALICE: {**USERS[ALICE], 'dn': 'alice', 'primaryRid': 513},
BOB: {'sam': 'MSOL_sync', 'name': 'Sync', 'dn': 'sync', 'primaryRid': 1200},
ticket: {'sam': 'krbtgt', 'name': 'Ticket', 'dn': 'ticket', 'primaryRid': 513}}
groups = {'finance': {'dn': 'finance', 'sam': 'FS_Finance', 'sid': 'S-1-5-21-1-2-3-1200', 'members': ['sync', 'nested']},
'nested': {'dn': 'nested', 'sam': 'Nested', 'sid': 'S-1-5-21-1-2-3-513', 'members': ['finance', 'ticket']}}
with mock.patch.object(access, 'read_directory', return_value=(users, groups)):
access.migrate(self.conn)
self.assertTrue(access.initialized(self.conn))
self.assertEqual(access.effective_levels(self.conn, folder), {ALICE: 3})
self.assertEqual([row['principalId'] for row in self.conn.execute('SELECT principalId FROM folder_permissions')], [ALICE])
self.assertEqual([row['sam'] for row in access.snapshot(self.conn)['users']], ['alice'])
def test_individual_user_assignments_are_independent_including_zero(self): def test_individual_user_assignments_are_independent_including_zero(self):
folder = self.folder() folder = self.folder()
other = self.folder('Projects') other = self.folder('Projects')
+16
View File
@@ -0,0 +1,16 @@
import unittest
from app.account_policy import is_excluded_user
class AccountPolicyTests(unittest.TestCase):
def test_excludes_exact_krbtgt_and_msol_prefix_case_insensitively(self):
for name in ("krbtgt", "KRBTGT", "MSOL_", "mSoL_123", "DOMAIN\\MSOL_sync",
"krbtgt@example.test", " DOMAIN\\KrBtGt "):
with self.subTest(name=name):
self.assertTrue(is_excluded_user(name))
def test_keeps_human_and_other_service_accounts(self):
for name in ("alice", "Administrator", "report_svc", "MsolSupport", "krbtgt_support", ""):
with self.subTest(name=name):
self.assertFalse(is_excluded_user(name))
+46 -5
View File
@@ -646,6 +646,20 @@ class AuditQueryTests(unittest.TestCase):
"source": "log.pc01", "source": "log.pc01",
} }
def test_excluded_accounts_removed_from_user_choices_but_audit_records_retained(self):
with tempfile.TemporaryDirectory() as tmpdir:
today = dt.datetime.now(dt.timezone.utc).date()
database = os.path.join(tmpdir, 'state.db')
store = audit_store.AuditStore(database)
names = ['alice', 'DOMAIN\\MSOL_sync', 'KrBtGt@example.test']
store.append_batch([self.make_event(f'{today}T12:00:0{index}+00:00', name)
for index, name in enumerate(names)], {}, set())
store.close()
with mock.patch.object(web_ui, 'STATE_DB', database):
result = web_ui.query_audit({'from': [str(today)], 'to': [str(today)], 'facets': ['1']})
self.assertEqual(result['facets']['users'], ['alice'])
self.assertEqual({row['user'] for row in result['events']}, set(names))
def test_queries_indexed_events_with_filters_and_keyset_cursor(self): def test_queries_indexed_events_with_filters_and_keyset_cursor(self):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
today = dt.datetime.now(dt.timezone.utc).date() today = dt.datetime.now(dt.timezone.utc).date()
@@ -1214,11 +1228,9 @@ class WebPresentationTests(unittest.TestCase):
self.assertNotIn(">Metadaten<", script) self.assertNotIn(">Metadaten<", script)
self.assertNotIn(">Sitzung<", script) self.assertNotIn(">Sitzung<", script)
self.assertNotIn('name="operation"', script) self.assertNotIn('name="operation"', script)
self.assertIn('class="shares-view"', script) self.assertNotIn('href="/shares"', html)
self.assertIn('class="split shares-split"', script) self.assertNotIn('Dateifreigaben', html)
self.assertIn(".shares-view { display: flex; height: calc(100vh - 4.5rem);", css) self.assertNotIn('renderShares', script)
self.assertIn(".shares-split .list", css)
self.assertIn(".shares-split .tree", css)
def test_admin_actions_are_german_and_backup_automatic_setting_is_env_only(self): def test_admin_actions_are_german_and_backup_automatic_setting_is_env_only(self):
script = self.asset("app.js") script = self.asset("app.js")
@@ -1361,6 +1373,35 @@ class TlsSummaryTests(unittest.TestCase):
class UsageScannerTests(unittest.TestCase): class UsageScannerTests(unittest.TestCase):
def test_excluded_user_storage_rows_hidden_without_losing_files_or_totals(self):
with tempfile.TemporaryDirectory() as tmpdir:
roots = {name: os.path.join(tmpdir, name) for name in ('GROUP_ROOT', 'PRIVATE_ROOT', 'FSLOGIX_ROOT')}
for path in roots.values():
os.mkdir(path)
for name in ('alice', 'MSOL_sync', 'KrBtGt'):
path = os.path.join(roots['PRIVATE_ROOT'], name)
os.mkdir(path)
with open(os.path.join(path, 'retained.txt'), 'wb') as handle:
handle.write(b'123')
profile = os.path.join(roots['FSLOGIX_ROOT'], 'sync-profile')
os.mkdir(profile)
with open(os.path.join(profile, 'retained.vhdx'), 'wb') as handle:
handle.write(b'12345')
with mock.patch.dict(os.environ, roots), mock.patch.object(web_ui, 'STATE_DB', os.path.join(tmpdir, 'state.db')), mock.patch.object(web_ui, 'fslogix_username', return_value='MSOL_sync'):
scanner = web_ui.UsageScanner()
value = scanner.scan()
self.assertEqual([row['name'] for row in value['users']], ['alice'])
self.assertEqual(value['totals']['privateBytes'], 9)
self.assertEqual(value['totals']['fslogixBytes'], 5)
# Old persisted cache entries are filtered before reaching any page/report.
scanner.data['users'].append({'name': 'DOMAIN\\krbtgt', 'totalBytes': 3})
self.assertEqual([row['name'] for row in scanner.snapshot()['users']], ['alice'])
self.assertEqual(scanner.snapshot()['totals'], value['totals'])
for name in ('MSOL_sync', 'KrBtGt'):
with open(os.path.join(roots['PRIVATE_ROOT'], name, 'retained.txt'), 'rb') as handle:
self.assertEqual(handle.read(), b'123')
self.assertTrue(os.path.isfile(os.path.join(profile, 'retained.vhdx')))
def test_aggregates_private_and_fslogix_by_user(self): def test_aggregates_private_and_fslogix_by_user(self):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
group_root = os.path.join(tmpdir, "data") group_root = os.path.join(tmpdir, "data")