more fine grained access control

This commit is contained in:
Ludwig Lehnert
2026-10-02 16:16:41 +00:00
parent d618957b68
commit abe79dae96
19 changed files with 1541 additions and 343 deletions
+476
View File
@@ -0,0 +1,476 @@
"""Admin-managed Data folders and individual user permissions, enforced by Samba Windows ACLs."""
import contextlib
import datetime as dt
import fcntl
import hashlib
import json
import os
import re
import stat
import uuid
try:
from . import reconcile_shares as directory
except ImportError:
import reconcile_shares as directory
SID_RE = re.compile(r"S-1-5-21-\d+-\d+-\d+-\d+\Z")
READ = 0x1200A9
MODIFY = 0x1201BF # No DELETE, DELETE_CHILD, WRITE_DAC or WRITE_OWNER.
DELETE = 0x10000
DELETE_CHILD = 0x40
MASKS = {1: READ, 2: MODIFY, 3: MODIFY | DELETE | DELETE_CHILD}
ATTRS = ["objectGUID", "objectSid", "distinguishedName", "sAMAccountName",
"displayName", "cn", "objectClass", "member", "primaryGroupID"]
class AccessConflict(RuntimeError):
pass
def timestamp():
return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
def ensure_schema(conn):
conn.executescript("""
CREATE TABLE IF NOT EXISTS access_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS access_users (
sid TEXT PRIMARY KEY, sam TEXT NOT NULL, name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS folder_permissions (
folderId TEXT NOT NULL REFERENCES shares(objectGUID) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK(kind = 'user'),
principalId TEXT NOT NULL,
level INTEGER NOT NULL CHECK(level BETWEEN 0 AND 3),
PRIMARY KEY(folderId, kind, principalId)
);
CREATE TABLE IF NOT EXISTS access_changes (
id INTEGER PRIMARY KEY, timestamp TEXT NOT NULL,
actor TEXT NOT NULL, action TEXT NOT NULL, details TEXT NOT NULL
);
""")
upgrade_individual_permissions(conn)
def upgrade_individual_permissions(conn):
"""Flatten the previous local-group model without changing effective access."""
schema = conn.execute("SELECT sql FROM sqlite_master WHERE name='folder_permissions'").fetchone()[0]
if "'group'" not in schema:
return
conn.execute("BEGIN IMMEDIATE")
try:
# Recheck after obtaining the write lock: another worker may have upgraded.
schema = conn.execute("SELECT sql FROM sqlite_master WHERE name='folder_permissions'").fetchone()[0]
if "'group'" not in schema:
conn.commit()
return
if conn.execute("SELECT 1 FROM folder_permissions p LEFT JOIN access_groups g "
"ON p.principalId=g.id WHERE p.kind='group' AND g.id IS NULL").fetchone():
raise RuntimeError("Cannot migrate unresolved stored group permission")
conn.execute("CREATE TABLE individual_permissions ("
"folderId TEXT NOT NULL REFERENCES shares(objectGUID) ON DELETE CASCADE,"
"kind TEXT NOT NULL CHECK(kind = 'user'), principalId TEXT NOT NULL,"
"level INTEGER NOT NULL CHECK(level BETWEEN 0 AND 3),"
"PRIMARY KEY(folderId, kind, principalId))")
# Existing direct assignments take precedence, including explicit level 0.
conn.execute("INSERT INTO individual_permissions SELECT * FROM folder_permissions WHERE kind='user'")
conn.execute("INSERT OR IGNORE INTO individual_permissions "
"SELECT p.folderId,'user',m.userSid,MAX(p.level) FROM folder_permissions p "
"JOIN access_members m ON p.kind='group' AND p.principalId=m.groupId "
"GROUP BY p.folderId,m.userSid")
conn.execute("DROP TABLE folder_permissions")
conn.execute("ALTER TABLE individual_permissions RENAME TO folder_permissions")
conn.execute("DROP TABLE access_members")
conn.execute("DROP TABLE access_groups")
conn.execute("INSERT INTO access_changes(timestamp,actor,action,details) VALUES(?,?,?,?)",
(timestamp(), "system", "migrate-individual-permissions", "{}"))
conn.commit()
except Exception:
conn.rollback()
raise
def initialized(conn):
return conn.execute("SELECT 1 FROM access_settings WHERE key='managed'").fetchone() is not None
def read_directory(include_groups=False):
entries = directory.search_directory_entries(
"(|(&(objectClass=user)(!(objectClass=computer)))(objectClass=group))"
if include_groups else "(&(objectClass=user)(!(objectClass=computer)))", ATTRS)
users, groups = {}, {}
for entry in entries:
principal = directory.parse_principal_from_entry(entry)
if not principal:
continue
sid = str(principal.get("objectSid") or "")
if not SID_RE.fullmatch(sid):
continue
row = {"sid": sid, "sam": str(principal.get("samAccountName") or ""),
"name": directory.ldap_first(entry, "displayName") or
str(principal.get("samAccountName") or sid),
"dn": directory.normalize_dn(directory.entry_dn(entry)),
"primaryRid": directory.parse_int(directory.ldap_first(entry, "primaryGroupID") or "", -1),
"members": [directory.normalize_dn(dn) for dn in directory.ldap_values(entry, "member")]}
if directory.is_group_principal(principal):
groups[row["dn"]] = row
elif directory.is_user_principal(principal):
users[sid] = row
return users, groups
def cache_users(conn, users):
conn.executemany("INSERT INTO access_users(sid,sam,name) VALUES(?,?,?) "
"ON CONFLICT(sid) DO UPDATE SET sam=excluded.sam,name=excluded.name",
[(sid, row["sam"], row["name"]) for sid, row in users.items()])
def migrate(conn):
"""Snapshot existing assignments once; never discover new FS_* groups."""
ensure_schema(conn)
if initialized(conn):
return
rows = conn.execute("SELECT * FROM shares WHERE isActive=1").fetchall()
users, groups = read_directory(include_groups=True) if rows else ({}, {})
by_sam = {g["sam"].casefold(): g for g in groups.values()}
by_dn = {u["dn"]: sid for sid, u in users.items()}
def members(group, seen):
if group["dn"] in seen:
return set()
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]}
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])
else:
raise RuntimeError(f"Cannot import unresolved member {dn}; migration will retry")
return result
# Resolve all memberships before mutating persistent state.
imports = []
for row in rows:
group = by_sam.get(row["samAccountName"].casefold())
if group is None:
raise RuntimeError(f"Cannot import group {row['samAccountName']}; migration will retry")
imports.append((row, members(group, set())))
try:
cache_users(conn, users)
for row, sids in imports:
conn.executemany("INSERT INTO folder_permissions VALUES(?,?,?,3)",
[(row["objectGUID"], "user", sid) for sid in sorted(sids)])
# Adopt any existing untracked folder without granting user access.
root = directory.GROUP_ROOT
if os.path.isdir(root):
known = {os.path.abspath(row[0]) for row in conn.execute("SELECT path FROM shares")}
for entry in os.scandir(root):
if entry.name != ".trash" and entry.is_dir(follow_symlinks=False) and os.path.abspath(entry.path) not in known:
create_folder_record(conn, entry.name, entry.path)
conn.execute("INSERT INTO access_settings VALUES('managed','1')")
conn.execute("UPDATE shares SET aclSignature=''")
conn.commit()
except Exception:
conn.rollback()
raise
def valid_name(value):
if not isinstance(value, str):
raise ValueError("Ein Name ist erforderlich")
name = value.strip()
if not name or len(name) > 120 or name in {".", ".."} or name.casefold() == ".trash" or name.endswith((".", " ")) or re.search(r'[\\/:*?"<>|\x00-\x1f\x7f]', name):
raise ValueError("Ungültiger Name")
if name.split(".", 1)[0].upper() in {"CON", "PRN", "AUX", "NUL", *[f"{p}{i}" for p in ("COM", "LPT") for i in range(1, 10)]}:
raise ValueError("Reservierter Name")
return name
def create_folder_record(conn, name, path):
folder_id = str(uuid.uuid4())
conn.execute("INSERT INTO shares(objectGUID,samAccountName,shareName,path,createdAt,lastSeenAt,isActive,aclSignature) VALUES(?,?,?,?,?,?,1,'')",
(folder_id, name, name, path, timestamp(), timestamp()))
return folder_id
def snapshot(conn, users=None):
user_rows = [dict(row) for row in conn.execute("SELECT * FROM access_users ORDER BY sam COLLATE NOCASE")]
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"],))]})
return {"users": user_rows, "folders": folders,
"fetchedAt": timestamp(), "initialized": initialized(conn)}
def effective_levels(conn, folder_id):
return {row["principalId"]: row["level"] for row in conn.execute(
"SELECT principalId,level FROM folder_permissions WHERE folderId=?", (folder_id,))}
def descriptor(levels, admin_sid, is_dir=True, top_level=False):
if not SID_RE.fullmatch(admin_sid):
raise RuntimeError("DOMAIN_ADMINS_SID is required for managed folder ACLs")
flags = "OICI" if is_dir else ""
# OWNER RIGHTS suppresses implicit WRITE_DAC for files created by users.
aces = [f"(A;{flags};RC;;;OW)", f"(A;{flags};FA;;;SY)", f"(A;{flags};FA;;;{admin_sid})"]
for sid, level in sorted(levels.items()):
if not SID_RE.fullmatch(sid) or type(level) is not int or level not in range(4):
raise RuntimeError("Invalid stored folder permission")
if level:
mask = MASKS[level]
if top_level:
# Only admins can rename/delete a managed top-level folder.
aces.append(f"(A;;0x{mask & ~DELETE:08x};;;{sid})")
aces.append(f"(A;OICIIO;0x{mask:08x};;;{sid})")
else:
aces.append(f"(A;{flags};0x{mask:08x};;;{sid})")
return f"O:{admin_sid}G:{admin_sid}D:P" + "".join(aces)
def pack_descriptor(sddl):
# Samba's own NDR serializer matches the installed server version.
from samba.dcerpc import security, xattr
from samba.ndr import ndr_pack
acl = xattr.NTACL()
acl.version = 1
acl.info = security.descriptor.from_sddl(sddl, security.dom_sid(os.environ["DOMAIN_ADMINS_SID"].rsplit("-", 1)[0]))
return ndr_pack(acl)
def set_acl_fd(fd, blob, is_dir):
info = os.fstat(fd)
if not (stat.S_ISDIR(info.st_mode) if is_dir else stat.S_ISREG(info.st_mode)):
raise RuntimeError("Unsupported managed data entry")
from samba import xattr_tdb
os.makedirs("/state", exist_ok=True)
os.fchown(fd, 0, 0)
xattr_tdb.wrap_setxattr("/state/data-xattrs.tdb", f"/proc/self/fd/{fd}", "security.NTACL", blob)
os.chmod("/state/data-xattrs.tdb", 0o600)
# SMB checks Windows ACLs. Local data volumes must stay container-private.
os.fchmod(fd, 0o777 if is_dir else 0o666)
def set_acl(path, blob, is_dir):
# Open without following symlinks; set ACLs/mode through the pinned inode.
flags = os.O_RDONLY | os.O_NOFOLLOW | (os.O_DIRECTORY if is_dir else 0)
fd = os.open(path, flags)
try:
set_acl_fd(fd, blob, is_dir)
finally:
os.close(fd)
def prepare_data_restore(conn, relative_path, source_fd):
parts = relative_path.split("/")
row = conn.execute("SELECT * FROM shares WHERE shareName=? AND isActive=1", (parts[0],)).fetchone()
if row is None or len(parts) < 2:
raise ValueError("Wiederherstellung erfordert einen aktiven verwalteten Datenordner")
path = safe_folder_path(row)
admin_sid = os.getenv("DOMAIN_ADMINS_SID", "")
levels = effective_levels(conn, row["objectGUID"])
set_acl(path, pack_descriptor(descriptor(levels, admin_sid, top_level=True)), True)
for name in parts[1:-1]:
path = os.path.join(path, name)
set_acl(path, pack_descriptor(descriptor(levels, admin_sid)), True)
# Replace the retained ACL before the file is visible at its restored path.
set_acl_fd(source_fd, pack_descriptor(descriptor(levels, admin_sid, False)), False)
def safe_folder_path(row):
root = os.path.abspath(directory.GROUP_ROOT if row["isActive"] else directory.GROUP_ARCHIVE_ROOT)
path = os.path.abspath(row["path"])
if os.path.dirname(path) != root or os.path.islink(path):
raise RuntimeError(f"Unsafe managed folder path: {path}")
return path
def recover_pending(conn):
pending = conn.execute("SELECT value FROM access_settings WHERE key='pendingRepair'").fetchone()
if pending is None:
return
details = json.loads(pending[0])
if details.get("move"):
source, destination = details["move"]
if not os.path.lexists(source) and os.path.lexists(destination):
os.rename(destination, source)
created = details.get("create")
if created and os.path.isdir(created) and not conn.execute("SELECT 1 FROM shares WHERE path=?", (created,)).fetchone():
# Preserve files from an interrupted creation with admin-only access.
create_folder_record(conn, os.path.basename(created), created)
sync_permissions(conn, force=True)
conn.execute("DELETE FROM access_settings WHERE key='pendingRepair'")
conn.commit()
directory.log("Recovered interrupted admin access update")
def sync_permissions(conn, force=False):
admin_sid = os.getenv("DOMAIN_ADMINS_SID", "")
root = directory.GROUP_ROOT
os.makedirs(root, exist_ok=True)
force = force or not os.path.exists("/state/data-xattrs.tdb")
# Read/list only at the share root; no inheritance or top-level creation.
root_sddl = descriptor({}, admin_sid) + "(A;;0x001200a9;;;AU)"
set_acl(root, pack_descriptor(root_sddl), True)
for row in conn.execute("SELECT * FROM shares").fetchall():
path = safe_folder_path(row)
levels = effective_levels(conn, row["objectGUID"]) if row["isActive"] else {}
top = descriptor(levels, admin_sid, top_level=True)
signature = hashlib.sha256(top.encode()).hexdigest()
os.makedirs(path, exist_ok=True)
set_acl(path, pack_descriptor(top), True)
if force or directory.should_repair_data_acls() or row["aclSignature"] != signature:
blobs = {True: pack_descriptor(descriptor(levels, admin_sid)), False: pack_descriptor(descriptor(levels, admin_sid, False))}
# Never follow symlinks or cross mount boundaries.
root_device = os.stat(path, follow_symlinks=False).st_dev
for current, dirs, files in os.walk(path, followlinks=False):
for name in list(dirs):
child = os.path.join(current, name)
if os.path.islink(child) or os.stat(child, follow_symlinks=False).st_dev != root_device:
raise RuntimeError(f"Unsupported link or mount in managed folder: {child}")
set_acl(child, blobs[True], True)
for name in files:
set_acl(os.path.join(current, name), blobs[False], False)
conn.execute("UPDATE shares SET aclSignature=? WHERE objectGUID=?", (signature, row["objectGUID"]))
@contextlib.contextmanager
def mutation_lock():
os.makedirs(os.path.dirname(directory.LOCK_PATH), exist_ok=True)
with open(directory.LOCK_PATH, "a", encoding="utf-8") as handle:
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
raise AccessConflict("Freigabenabgleich läuft; bitte erneut versuchen") from exc
yield
def apply_change(conn, body, actor, users):
action = body.get("action")
if not isinstance(action, str) or action not in {"create-folder", "set-permissions", "archive-folder", "restore-folder"}:
raise ValueError("Unbekannte Verwaltungsaktion")
if body.get("id") is not None and (not isinstance(body["id"], str) or not body["id"] or len(body["id"]) > 120):
raise ValueError("Ungültige ID")
cache_users(conn, users)
conn.commit()
recover_pending(conn)
pending = {}
if action == "create-folder":
pending["create"] = os.path.join(directory.GROUP_ROOT, valid_name(body.get("name")))
elif action in {"archive-folder", "restore-folder"}:
row = conn.execute("SELECT * FROM shares WHERE objectGUID=?", (body.get("id"),)).fetchone()
if row is not None:
target = directory.GROUP_ROOT if action == "restore-folder" else directory.GROUP_ARCHIVE_ROOT
pending["move"] = [safe_folder_path(row), os.path.join(target, valid_name(row["shareName"]))]
# Persist recovery intent separately from the policy transaction. A process
# crash cannot leave partially updated ACLs with an unchanged signature.
conn.execute("INSERT INTO access_settings VALUES('pendingRepair',?)", (json.dumps(pending),))
conn.commit()
conn.execute("BEGIN IMMEDIATE")
created_path = None
moved_paths = None
acl_started = False
try:
if action == "create-folder":
name = valid_name(body.get("name"))
if conn.execute("SELECT 1 FROM shares WHERE shareName=? COLLATE NOCASE", (name,)).fetchone():
raise ValueError("Ordnername ist bereits vergeben")
path = os.path.join(directory.GROUP_ROOT, name)
os.mkdir(path, 0o700)
created_path = path
create_folder_record(conn, name, path)
elif action in {"set-permissions", "archive-folder", "restore-folder"}:
folder_id = body.get("id")
row = conn.execute("SELECT * FROM shares WHERE objectGUID=?", (folder_id,)).fetchone()
if row is None:
raise ValueError("Ordner nicht gefunden")
if action == "set-permissions":
rules = body.get("permissions")
if not isinstance(rules, list):
raise ValueError("Berechtigungen müssen eine Liste sein")
old_users = {r[0] for r in conn.execute("SELECT principalId FROM folder_permissions WHERE folderId=? AND kind='user'", (folder_id,))}
checked = []
seen = set()
for rule in rules:
if not isinstance(rule, dict):
raise ValueError("Ungültige Berechtigung")
kind, principal, level = rule.get("kind"), rule.get("principalId"), rule.get("level")
if not isinstance(kind, str) or kind != "user" or not isinstance(principal, str) or type(level) is not int or level not in range(4):
raise ValueError("Ungültige Berechtigung")
if (kind, principal) in seen:
raise ValueError("Doppelte Berechtigung")
seen.add((kind, principal))
if principal not in users and principal not in old_users:
raise ValueError("Unbekannter AD-Benutzer")
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)
else:
active = action == "restore-folder"
if bool(row["isActive"]) == active:
raise ValueError("Ordner hat diesen Status bereits")
source = safe_folder_path(row)
root = directory.GROUP_ROOT if active else directory.GROUP_ARCHIVE_ROOT
os.makedirs(root, exist_ok=True)
destination = os.path.join(root, row["shareName"])
if os.path.lexists(destination):
raise AccessConflict("Zielordner existiert bereits")
os.rename(source, destination)
moved_paths = (source, destination)
conn.execute("UPDATE shares SET path=?,isActive=?,aclSignature='' WHERE objectGUID=?", (destination, int(active), folder_id))
else:
raise ValueError("Unbekannte Verwaltungsaktion")
acl_started = True
sync_permissions(conn)
conn.execute("DELETE FROM access_settings WHERE key='pendingRepair'")
conn.execute("INSERT INTO access_changes(timestamp,actor,action,details) VALUES(?,?,?,?)", (timestamp(), actor, action, json.dumps(body, ensure_ascii=False)))
conn.commit()
except Exception:
conn.rollback()
if moved_paths:
os.rename(moved_paths[1], moved_paths[0])
if created_path:
os.rmdir(created_path)
# A failed recursive update may have changed some ACLs: restore the old policy.
try:
if acl_started:
sync_permissions(conn, force=True)
conn.execute("DELETE FROM access_settings WHERE key='pendingRepair'")
conn.commit()
except Exception as exc:
directory.log(f"ERROR: Access rollback repair failed: {exc}")
conn.execute("UPDATE shares SET aclSignature=''")
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.")
return snapshot(conn, users)
def report_folders(conn):
data = snapshot(conn)
users = {u["sid"]: u for u in data["users"]}
rows = []
for folder in data["folders"]:
if not folder["active"]:
continue
levels = effective_levels(conn, folder["id"])
members = []
for rule in folder["permissions"]:
if rule["kind"] == "user" and rule["level"]:
sid = rule["principalId"]
members.append({"id": sid, "name": users.get(sid, {}).get("name", sid), "sam": users.get(sid, {}).get("sam", sid), "type": "user", "level": rule["level"], "members": []})
rows.append({"guid": folder["id"], "name": folder["name"], "sam": folder["name"], "folder": folder["name"], "active": True,
"userCount": sum(bool(level) for level in levels.values()), "groupCount": 0, "members": members})
return {"groups": rows, "fetchedAt": data["fetchedAt"], "truncated": False}
+1 -1
View File
@@ -32,7 +32,7 @@ require_vfs_modules() {
local missing=0
local module
for module in acl_xattr recycle full_audit; do
for module in acl_xattr xattr_tdb recycle full_audit; do
if [[ ! -f "$modules_dir/vfs/${module}.so" ]]; then
printf '[init] ERROR: missing VFS module %s at %s/vfs/%s.so\n' "$module" "$modules_dir" "$module" >&2
missing=1
+11 -25
View File
@@ -1711,31 +1711,17 @@ def with_lock() -> bool:
conn = open_db()
try:
status_progress(10, "directory", "Reading data folder groups from AD")
phase_started = time.monotonic()
groups = fetch_fileshare_groups()
log(
f"Discovered {len(groups)} data folder group(s) from AD "
f"in {format_duration(time.monotonic() - phase_started)}"
)
status_progress(22, "database", "Reconciling data folder database")
log("Reconciling data folder DB")
phase_started = time.monotonic()
reconcile_db(conn, groups)
log(
f"Reconciled data folder DB in "
f"{format_duration(time.monotonic() - phase_started)}"
)
status_progress(32, "data-permissions", "Syncing data folder permissions")
log("Syncing data folder permissions")
phase_started = time.monotonic()
sync_dynamic_directory_permissions(conn, groups)
log(
f"Synced data folder permissions in "
f"{format_duration(time.monotonic() - phase_started)}"
)
try:
from . import access_control
except ImportError:
import access_control
status_progress(10, "database", "Loading admin-managed folders")
access_control.migrate(conn)
access_control.recover_pending(conn)
status_progress(32, "data-permissions", "Syncing managed folder permissions")
access_control.sync_permissions(conn)
conn.commit()
log("Synced admin-managed Data folder permissions")
finally:
conn.close()
+7 -1
View File
@@ -303,7 +303,7 @@ def _remove_empty_trash_parents(repository: str, relative_path: str) -> None:
current = os.path.dirname(current)
def restore_item(item_id: str) -> Dict[str, object]:
def restore_item(item_id: str, prepare=None) -> Dict[str, object]:
share, share_root, relative_path, parts, info = _resolved_item(item_id)
original = _original_relative(relative_path)
original_parts = _relative_parts(original)
@@ -323,6 +323,12 @@ def restore_item(item_id: str) -> Dict[str, object]:
)
linked = False
try:
if prepare is not None:
source_fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW, dir_fd=source_parent_fd)
try:
prepare(original, source_fd)
finally:
os.close(source_fd)
os.link(
parts[-1],
original_parts[-1],
+203 -11
View File
@@ -76,6 +76,7 @@ function notice(message) {
}
function showLogin() {
state.accessDirty = false;
clearInterval(state.timer);
state.session = null;
appView.hidden = true;
@@ -101,6 +102,7 @@ function badge(text, kind = "") { return `<span class="badge ${kind}">${esc(text
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";
@@ -113,6 +115,12 @@ function routeFor(path) {
}
async function navigate(path, replace = false) {
if (state.accessDirty && !window.confirm("Ungespeicherte Änderungen verwerfen?")) {
if (replace && state.currentPath) history.replaceState({}, "", state.currentPath);
return;
}
state.accessDirty = false;
state.currentPath = path;
clearInterval(state.timer);
state.timer = null;
const route = routeFor(path);
@@ -123,6 +131,7 @@ async function navigate(path, replace = false) {
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");
if (route === "storage-users") await renderStorage("users");
@@ -158,7 +167,7 @@ async function renderOverview() {
<section class="cards">
<article class="card"><span class="label">Daten</span><span class="value">${bytes(totals.dataBytes)}</span></article>
<article class="card"><span class="label">Private + FSLogix</span><span class="value">${bytes(Number(totals.privateBytes || 0) + Number(totals.fslogixBytes || 0))}</span></article>
<article class="card"><span class="label">Aktive Gruppen</span><span class="value">${esc(data.activeGroups)}</span></article>
<article class="card"><span class="label">Aktive Datenordner</span><span class="value">${esc(data.activeGroups)}</span></article>
<article class="card"><span class="label">Ereignisse · 48 Std.</span><span class="value">${esc(data.eventCount)}</span></article>
</section>
<section class="split">
@@ -178,7 +187,7 @@ async function renderOverview() {
function usageTable(rows, type) {
if (!rows.length) return empty("Die erste Speicherprüfung ist noch nicht abgeschlossen.");
const maximum = Math.max(...rows.map(row => Number(type === "group" ? row.bytes : row.totalBytes)), 1);
return `<div class="table-wrap"><table><thead><tr><th>${type === "group" ? "Gruppenordner" : "Benutzer"}</th>${type === "user" ? "<th>Private</th><th>FSLogix</th>" : ""}<th>Belegung</th><th></th></tr></thead><tbody>${rows.map(row => {
return `<div class="table-wrap"><table><thead><tr><th>${type === "group" ? "Datenordner" : "Benutzer"}</th>${type === "user" ? "<th>Private</th><th>FSLogix</th>" : ""}<th>Belegung</th><th></th></tr></thead><tbody>${rows.map(row => {
const value = Number(type === "group" ? row.bytes : row.totalBytes);
return `<tr><td><strong>${esc(row.name)}</strong></td>${type === "user" ? `<td class="numeric">${bytes(row.privateBytes)}</td><td class="numeric">${bytes(row.fslogixBytes)}</td>` : ""}<td class="numeric">${bytes(value)}</td><td class="usage-bar"><progress max="${maximum}" value="${value}"></progress></td></tr>`;
}).join("")}</tbody></table></div>`;
@@ -202,10 +211,10 @@ async function renderShares() {
const data = await api("/api/groups");
state.groups = data;
const groups = data.groups || [];
content.innerHTML = `<div class="shares-view">` + pageHead("Dateifreigaben", "FS_*-Ordnergruppen und ihre wirksamen verschachtelten Mitgliedschaften.", `<span class="muted">Verzeichnisstand ${esc(utcTime(data.fetchedAt))}</span>`) + `
${data.truncated ? `<p>${badge("Ergebnis gekürzt", "warn")} WEB_MAX_GROUP_NODES erhöhen, um alle Mitglieder anzuzeigen.</p>` : ""}
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>Gruppenordner</h2><span>${groups.length}</span></div><input id="group-filter" type="search" placeholder="Gruppen oder Mitglieder filtern"><ul id="group-list" class="list"></ul></article>
<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");
@@ -215,22 +224,202 @@ async function renderShares() {
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<br>${group.groupCount} Gruppen</span></button></li>`).join("") : empty("Keine Gruppe entspricht dem Filter.");
if (!selected) tree.innerHTML = empty("Gruppenordner 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 direkten Mitglieder")}</div>`;
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"];
async function renderAccess() {
let data = await api("/api/access");
let folderId = data.folders.find(folder => folder.active)?.id || data.folders[0]?.id;
let rules = [], query = "", userQuery = "", userFilter = "all", busy = false;
let folderFilter = data.folders.length && !data.folders.some(item => item.active) ? "archived" : "active";
const folder = () => data.folders.find(item => item.id === folderId);
const normalizedRules = value => JSON.stringify(value.filter(rule => rule.level > 0).map(rule => [rule.principalId, rule.level]).sort((a, b) => a[0].localeCompare(b[0])));
const loadDraft = () => {
rules = (folder()?.permissions || []).filter(rule => rule.level > 0).map(rule => ({...rule}));
state.accessDirty = false;
};
const dirty = () => normalizedRules(rules) !== normalizedRules(folder()?.permissions || []);
const canLeave = () => !dirty() || window.confirm("Ungespeicherte Änderungen verwerfen?");
const levelFor = sid => rules.find(rule => rule.principalId === sid)?.level || 0;
content.innerHTML = `<div class="access-view">${pageHead("Zugriffsverwaltung", "Individuelle Benutzerrechte je Datenordner festlegen.")}
<div id="access-message" role="status" hidden></div>
<div class="access-workspace">
<aside class="access-browser" aria-label="Ordnerauswahl">
<div class="access-browser-head"><h2>Datenordner</h2><button type="button" id="access-create">Neuer Ordner</button></div>
<label class="access-search"><span class="sr-only">Ordner suchen</span><input id="access-search" type="search" placeholder="Ordner suchen"></label>
<label class="access-status-filter"><span class="sr-only">Ordnerstatus</span><select id="folder-status"><option value="active">Aktive Ordner</option><option value="archived">Archivierte Ordner</option><option value="all">Alle Ordner</option></select></label>
<div id="access-list" class="access-list"></div>
</aside>
<section id="access-detail" class="access-detail" aria-labelledby="access-folder-title"></section>
</div>
</div>`;
const root = content.querySelector(".access-view");
const detail = root.querySelector("#access-detail");
const message = root.querySelector("#access-message");
function updateDirty() {
state.accessDirty = dirty();
const status = detail.querySelector("#access-save-state");
if (status) status.textContent = busy ? "Änderungen werden angewendet…" : dirty() ? "Ungespeicherte Änderungen" : "Alle Änderungen gespeichert";
const save = detail.querySelector("#access-save");
if (save) save.disabled = busy || !dirty() || !folder()?.active;
const discard = detail.querySelector("#access-discard");
if (discard) discard.hidden = !dirty();
}
async function save(body) {
if (busy) return;
busy = true;
message.hidden = true;
root.querySelectorAll("button,input,select").forEach(input => { input.disabled = true; });
updateDirty();
try {
data = await api("/api/access", {method: "POST", body: JSON.stringify(body)});
if (body.action === "create-folder") {
folderFilter = "active"; query = ""; userQuery = ""; userFilter = "all";
folderId = data.folders.find(item => item.name === body.name.trim())?.id;
}
if (body.action === "archive-folder") folderFilter = "archived";
if (body.action === "restore-folder") folderFilter = "active";
if (!root.isConnected) return;
loadDraft(); draw();
message.className = "access-message success";
message.textContent = "Gespeichert. Die Änderungen sind wirksam.";
message.hidden = false;
} catch (error) {
if (!root.isConnected) return;
message.className = "access-message failure";
message.textContent = error.message;
message.hidden = false;
message.scrollIntoView({block: "nearest"});
} finally {
busy = false;
if (root.isConnected) {
root.querySelectorAll("button,input,select").forEach(input => { input.disabled = false; });
detail.querySelectorAll("[data-unavailable]").forEach(input => { input.disabled = true; });
updateDirty();
}
}
}
function dialog(title, explanation, label, action, initial = null, danger = false) {
const modal = document.createElement("dialog");
modal.className = "access-dialog";
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>`;
document.body.append(modal);
modal.querySelector("[data-cancel]").addEventListener("click", () => modal.close());
modal.addEventListener("close", () => modal.remove());
modal.querySelector("form").addEventListener("submit", event => {
event.preventDefault();
const input = modal.querySelector("input"), name = input?.value.trim();
if (initial !== null && !name) { input.setCustomValidity("Bitte einen Namen eingeben."); input.reportValidity(); return; }
modal.close(); action(name);
});
modal.querySelector("input")?.addEventListener("input", event => event.target.setCustomValidity(""));
modal.showModal(); modal.querySelector("input")?.focus();
}
function drawList() {
const items = data.folders.filter(item => folderFilter === "all" || item.active === (folderFilter === "active"))
.filter(item => item.name.toLocaleLowerCase("de").includes(query.toLocaleLowerCase("de")))
.sort((a, b) => a.name.localeCompare(b.name, "de"));
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 `<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>`;
}).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", () => {
if (button.dataset.select === folderId || !canLeave()) return;
folderId = button.dataset.select; userQuery = ""; userFilter = "all";
loadDraft(); drawList(); drawDetail();
}));
}
function drawUsers() {
const users = data.users.filter(item => item.available !== false || (folder()?.permissions || []).some(rule => rule.principalId === item.sid))
.sort((a, b) => a.name.localeCompare(b.name, "de", {numeric: true}) || a.sam.localeCompare(b.sam, "de"));
const visible = users.filter(item => `${item.sam} ${item.name}`.toLocaleLowerCase("de").includes(userQuery.toLocaleLowerCase("de")))
.filter(item => userFilter === "all" || (userFilter === "granted" ? levelFor(item.sid) > 0 : levelFor(item.sid) === 0));
detail.querySelector("#access-users").innerHTML = visible.length ? visible.map(item => `<div class="access-matrix-row"><div><strong>${esc(item.name)}</strong><small>${esc(item.sam)}${item.available === false ? " · Nicht mehr im AD" : ""}</small></div><div class="access-level-options" role="group" aria-label="Zugriff für ${esc(item.name)}">${permissionLabels.map((label, level) => `<button type="button" data-user-rule="${esc(item.sid)}" data-level="${level}" aria-pressed="${levelFor(item.sid) === level}" aria-label="${esc(item.name)}: ${level} · ${esc(label)}" ${folder()?.active ? "" : "disabled data-unavailable"}><span class="access-level-number">${level}</span><span class="access-level-caption">${esc(label)}</span></button>`).join("")}</div></div>`).join("") : '<div class="access-empty">Keine Benutzer für diesen Filter.</div>';
detail.querySelectorAll("[data-user-rule]").forEach(button => button.addEventListener("click", () => {
const sid = button.dataset.userRule, level = Number(button.dataset.level);
rules = rules.filter(rule => rule.principalId !== sid);
if (level) rules.push({kind: "user", principalId: sid, level});
if (userFilter !== "all") drawUsers();
else {
button.closest(".access-level-options").querySelectorAll("button").forEach(option => option.setAttribute("aria-pressed", String(Number(option.dataset.level) === level)));
updateUserCount(visible.length);
}
updateDirty();
}));
updateUserCount(visible.length);
}
function updateUserCount(visibleCount) {
detail.querySelector("#access-user-count").textContent = `${visibleCount} angezeigt · ${rules.filter(rule => rule.level > 0).length} ${folder()?.active ? "mit Zugriff" : "mit gespeicherten Rechten"} · ${dirty() ? "Vorschau der ungespeicherten Rechte" : "Gespeicherte Rechte"}`;
}
function drawDetail() {
const selected = folder();
if (!selected) {
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;
}
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>
<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>
<p id="access-user-count" class="muted" role="status"></p>
<div class="access-matrix-head"><span>AD-Benutzer</span>${permissionLabels.map((label, level) => `<span><b>${level}</b>${esc(label)}</span>`).join("")}</div>
<div id="access-users" class="access-users"></div>
</section><footer class="access-savebar"><span id="access-save-state"></span><div><button type="button" id="access-discard" hidden>Verwerfen</button><button type="button" id="access-save">Rechte anwenden</button></div></footer>`;
detail.querySelector("#access-user-filter").value = userFilter;
detail.querySelector("#access-user-search").addEventListener("input", event => { userQuery = event.target.value; drawUsers(); });
detail.querySelector("#access-user-filter").addEventListener("change", event => { userFilter = event.target.value; drawUsers(); });
detail.querySelector("#access-save").addEventListener("click", () => save({action: "set-permissions", id: selected.id, permissions: rules}));
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);
});
drawUsers(); updateDirty();
}
function draw() {
root.querySelector("#access-search").value = query;
root.querySelector("#folder-status").value = folderFilter;
drawList(); drawDetail();
}
root.querySelector("#access-search").addEventListener("input", event => { query = event.target.value; drawList(); });
root.querySelector("#folder-status").addEventListener("change", event => {
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"; }
loadDraft(); drawList(); drawDetail();
});
root.querySelector("#access-create").addEventListener("click", () => {
if (!canLeave()) return;
dialog("Neuen Datenordner anlegen", "Der Ordner startet ohne Benutzerzugriff. Danach die gewünschten AD-Benutzer individuell berechtigen.", "Anlegen", name => save({action: "create-folder", name}), "");
});
loadDraft(); draw();
}
async function renderStorage(type) {
const data = await api("/api/storage");
state.storage = data;
let query = "";
let sort = "size-desc";
content.innerHTML = pageHead(type === "data" ? "Datenbelegung" : "Benutzerbelegung", type === "data" ? "Belegter Speicher je aktivem Gruppenordner unter /Data." : "Gemeinsame Belegung von /Private und /FSLogix je Benutzer.", `<span class="muted">Geprüft ${esc(utcTime(data.scannedAt))} in ${esc(data.scanSeconds || 0)} s</span>`) + `
<section class="panel"><div class="toolbar"><input id="storage-filter" type="search" placeholder="${type === "data" ? "Gruppenordner" : "Benutzer"} filtern"><select id="storage-sort"><option value="size-desc">Größte zuerst</option><option value="size-asc">Kleinste zuerst</option><option value="name">Name</option></select></div><div id="storage-table"></div></section>`;
content.innerHTML = pageHead(type === "data" ? "Datenbelegung" : "Benutzerbelegung", type === "data" ? "Belegter Speicher je aktivem Datenordner unter /Data." : "Gemeinsame Belegung von /Private und /FSLogix je Benutzer.", `<span class="muted">Geprüft ${esc(utcTime(data.scannedAt))} in ${esc(data.scanSeconds || 0)} s</span>`) + `
<section class="panel"><div class="toolbar"><input id="storage-filter" type="search" placeholder="${type === "data" ? "Datenordner" : "Benutzer"} filtern"><select id="storage-sort"><option value="size-desc">Größte zuerst</option><option value="size-asc">Kleinste zuerst</option><option value="name">Name</option></select></div><div id="storage-table"></div></section>`;
const draw = () => {
const source = [...(type === "data" ? data.groups || [] : data.users || [])];
let rows = source.filter(row => row.name.toLowerCase().includes(query));
@@ -512,7 +701,7 @@ function reconciliationMarkup(data) {
async function renderReconciliation() {
content.innerHTML = pageHead(
"Freigabenabgleich",
"Freigaben, Gruppenordner und Berechtigungen sofort mit Active Directory abgleichen.",
"Ordnerrechte anwenden und AD-Benutzerinformationen aktualisieren.",
'<button id="start-reconciliation" type="button">Freigaben jetzt abgleichen</button>'
) + '<div id="reconciliation-body"></div>';
const button = document.querySelector("#start-reconciliation");
@@ -649,5 +838,8 @@ document.querySelector("#logout").addEventListener("click", async () => { try {
document.querySelector("#menu-toggle").addEventListener("click", () => document.querySelector(".sidebar").classList.toggle("open"));
nav.addEventListener("click", event => { const link = event.target.closest("a"); if (link) { event.preventDefault(); navigate(link.pathname); } });
window.addEventListener("popstate", () => navigate(location.pathname, true));
window.addEventListener("beforeunload", event => {
if (state.accessDirty) { event.preventDefault(); event.returnValue = ""; }
});
api("/api/session").then(showApp).catch(showLogin);
+1
View File
@@ -27,6 +27,7 @@
<nav id="navigation" aria-label="Hauptnavigation">
<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="/reconciliation" data-route="reconciliation">Freigabenabgleich</a>
<a href="/storage/data" data-route="storage-data">Datenbelegung</a>
<a href="/storage/users" data-route="storage-users">Benutzerbelegung</a>
+6 -8
View File
@@ -184,7 +184,7 @@ export function buildReportSource(data) {
["Server", system.hostname || "—"],
["Erstellt", formatUtc(generatedAt)],
["Verzeichnisstand", formatUtc(groupsSnapshot.fetchedAt)],
["Dateifreigabegruppen", String(groups.length)],
["Datenordner", String(groups.length)],
["Verzeichnisabfrage gekürzt", groupsSnapshot.truncated ? "Ja" : "Nein"],
["Speicherstand", formatUtc(storage.scannedAt)],
["Serverzeit", formatUtc(system.serverTime)],
@@ -203,12 +203,12 @@ export function buildReportSource(data) {
["Prüfdauer", formatDecimal(storage.scanSeconds, 3) + " s"],
]),
"",
"== Datenbelegung je Gruppenordner",
"== Datenbelegung je Datenordner",
(storage.groups || []).length
? reportTable(
["1fr", "auto"],
["left", "right"],
["Gruppenordner", "Belegung"],
["Datenordner", "Belegung"],
storage.groups.map(row => [row.name || "—", formatBytes(row.bytes)])
)
: "Noch keine Speicherprüfung vorhanden.",
@@ -228,10 +228,10 @@ export function buildReportSource(data) {
)
: "Noch keine Speicherprüfung vorhanden.",
"",
"= Dateifreigaben und Mitgliedschaften",
"= Dateifreigaben und Benutzerrechte",
groupsSnapshot.truncated
? "#text(weight: \"semibold\")[Hinweis: Die Verzeichnisabfrage wurde gekürzt; die nachfolgende Hierarchie ist nicht vollständig.]"
: "Die nachfolgenden Hierarchien zeigen alle wirksamen verschachtelten Mitgliedschaften.",
: "Die nachfolgenden Listen zeigen die individuell berechtigten Benutzer je Ordner.",
];
for (const group of groups) {
@@ -240,14 +240,12 @@ export function buildReportSource(data) {
parts.push(keyValueTable([
["Freigabename", group.name || "—"],
["Ordner", group.folder || "—"],
["Ordnergruppe", group.sam || "—"],
["Objekt-GUID", group.guid || "—"],
["Status", group.active ? "Aktiv" : "Archiviert"],
["Wirksame Benutzer", String(group.userCount || 0)],
["Enthaltene Gruppen", String(group.groupCount || 0)],
]));
const rows = treeRows(group.members || []);
parts.push(rows.length ? rows.join("\n") : "Keine direkten Mitglieder.");
parts.push(rows.length ? rows.join("\n") : "Keine Benutzer mit Zugriff.");
}
const backupState = backupLabel(backup.state, backup.enabled);
+72
View File
@@ -100,3 +100,75 @@ progress { width: 100%; }
.filters .wide { grid-column: span 1; }
.page-head { display: block; }
}
/* Access administration uses the shared admin controls, colors and typography. */
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
.access-message { padding: .7rem; margin-bottom: 1rem; border: 1px solid #aaa; }
.access-message.failure { color: #900; }
.access-workspace { display: grid; grid-template-columns: 260px minmax(0,1fr); gap: 1rem; align-items: start; }
.access-browser, .access-detail { min-width: 0; border: 1px solid #aaa; background: #fff; }
.access-browser { overflow: hidden; }
.access-browser-head { display: grid; gap: .7rem; padding: .8rem; }
.access-browser-head h2 { margin: 0; }
.access-browser-head button { width: 100%; }
.access-search, .access-status-filter { margin: 0 .8rem .65rem; }
.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-item strong { font-weight: inherit; overflow-wrap: anywhere; }
.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 h2 { margin: 0 0 .3rem; overflow-wrap: anywhere; }
.access-detail-head p { color: #666; font-size: .9rem; margin: 0; max-width: 42rem; }
.access-section { padding: .8rem; }
.access-section + .access-section { border-top: 1px solid #ccc; }
.access-section-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: .7rem; }
.access-section-head h3 { margin: 0; }
.access-section-head p { font-size: .85rem; margin: .3rem 0 0; }
.access-matrix-head { display: grid; grid-template-columns: minmax(150px,1.3fr) repeat(4,minmax(0,1fr)); align-items: end; font-size: .85rem; color: #555; padding-bottom: .5rem; gap: .3rem; }
.access-matrix-head > span:not(:first-child) { text-align: center; }
.access-matrix-head b { display: block; font-size: .9rem; margin-bottom: .15rem; color: #111; }
.access-matrix-row { display: grid; grid-template-columns: minmax(150px,1.3fr) minmax(0,4fr); align-items: center; gap: .3rem; padding: .5rem 0; border-top: 1px solid #ccc; }
.access-matrix-row strong { overflow-wrap: anywhere; }
.access-matrix-row small { display: block; font-size: .85rem; color: #666; margin-top: .2rem; overflow-wrap: anywhere; }
.access-users { max-height: 28rem; overflow-y: auto; }
.access-level-options { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: .3rem; }
.access-level-options button { padding: .4rem .25rem; }
.access-level-options button[aria-pressed="true"] { background: #ddd; border-color: #111; font-weight: bold; }
.access-level-number { font-size: 1rem; }
.access-level-caption { display: none; white-space: normal; }
.access-savebar { display: flex; align-items: center; justify-content: space-between; gap: .75rem; border-top: 1px solid #aaa; padding: .8rem; }
.access-savebar > span { color: #666; font-size: .85rem; }
.access-savebar > div { display: flex; gap: .5rem; }
.access-filter-row { display: grid; grid-template-columns: minmax(0,1fr) minmax(170px,.5fr); gap: .6rem; margin-bottom: .65rem; }
.access-empty { padding: .8rem; color: #666; font-size: .9rem; }
.access-empty h2 { color: #111; margin-bottom: .5rem; }
.access-empty p { margin: 0; }
.access-dialog { width: min(480px,calc(100vw - 2rem)); max-height: calc(100dvh - 3rem); border: 1px solid #aaa; padding: 1rem; color: #111; background: #fff; }
.access-dialog::backdrop { background: #0006; }
.access-dialog p { font-size: .9rem; }
.access-dialog-actions { display: flex; gap: .5rem; justify-content: flex-end; margin-top: 1rem; }
.access-danger { color: #900; }
@media (max-width: 1250px) {
.access-workspace { grid-template-columns: 220px minmax(0,1fr); gap: .8rem; }
.access-matrix-head { display: none; }
.access-matrix-row { grid-template-columns: 1fr; gap: .5rem; padding: .7rem 0; }
.access-level-caption { display: block; font-size: .75rem; margin-top: .2rem; }
}
@media (max-width: 950px) {
.access-workspace { grid-template-columns: 1fr; }
.access-browser-head { display: flex; align-items: center; justify-content: space-between; }
.access-browser-head button { width: auto; }
.access-list { max-height: 12rem; }
.access-detail-head { flex-wrap: wrap; }
}
@media (max-width: 500px) {
.access-filter-row { grid-template-columns: 1fr; }
.access-section-head { align-items: start; flex-wrap: wrap; gap: .6rem; }
.access-savebar { flex-direction: column; align-items: stretch; }
.access-savebar > div { justify-content: flex-end; }
.access-level-options { gap: .2rem; }
.access-level-options button { padding: .4rem .1rem; }
.access-level-caption { font-size: .72rem; }
}
.content:focus { outline: none; }
+60 -187
View File
@@ -27,10 +27,10 @@ from typing import Dict, List, Optional, Tuple
try:
from app import reconcile_shares as directory
from app import trash
from app import trash, access_control
except ImportError: # Container execution uses /app as the import root.
import reconcile_shares as directory
import trash
import trash, access_control
try:
from app.audit_store import (
@@ -577,25 +577,9 @@ class UsageScanner:
self.stop.wait(self.interval)
def display_name(entry) -> str:
return (
directory.ldap_first(entry, "displayName")
or directory.ldap_first(entry, "sAMAccountName")
or directory.ldap_first(entry, "cn")
or directory.entry_dn(entry).split(",", 1)[0].removeprefix("CN=")
or "Unbekannt"
)
class DirectoryCache:
ATTRS = [
"objectGUID", "objectSid", "distinguishedName", "sAMAccountName",
"displayName", "cn", "objectClass", "member", "primaryGroupID",
]
def __init__(self):
self.ttl = env_int("WEB_DIRECTORY_CACHE_SECONDS", 300, 30, 3600)
self.max_nodes = env_int("WEB_MAX_GROUP_NODES", 10000, 100, 100000)
self.lock = threading.Lock()
self.cached_at = 0.0
self.value: Dict[str, object] = {"groups": [], "fetchedAt": None}
@@ -609,173 +593,12 @@ class DirectoryCache:
return self.value
def fetch(self) -> Dict[str, object]:
roots = directory.fetch_fileshare_groups()
entries: Dict[str, object] = {}
pending = deque()
for root in roots:
for dn in root.get("memberDns", []):
pending.append(str(dn))
requested = set()
while pending and len(entries) < self.max_nodes:
batch = []
while pending and len(batch) < 100:
dn = pending.popleft()
key = directory.normalize_dn(dn)
if not key or key in requested:
continue
requested.add(key)
batch.append(dn)
if not batch:
continue
for entry in directory.search_directory_entries(
directory.build_distinguished_name_filter(batch), self.ATTRS
):
key = directory.normalize_dn(directory.entry_dn(entry))
if not key:
continue
entries[key] = entry
classes = {value.lower() for value in directory.ldap_values(entry, "objectClass")}
if "group" in classes:
pending.extend(directory.ldap_values(entry, "member"))
group_keys_by_primary_rid: Dict[int, List[str]] = {}
for root in roots:
root_key = directory.normalize_dn(str(root.get("distinguishedName") or ""))
rid = directory.sid_rid(str(root.get("objectSid") or ""))
if root_key and rid is not None:
group_keys_by_primary_rid.setdefault(rid, []).append(root_key)
for key, entry in entries.items():
classes = {
value.lower()
for value in directory.ldap_values(entry, "objectClass")
}
if "group" not in classes:
continue
principal = directory.parse_principal_from_entry(entry)
rid = directory.sid_rid(
str(principal.get("objectSid") or "") if principal else ""
)
if rid is not None:
group_keys_by_primary_rid.setdefault(rid, []).append(key)
primary_members: Dict[str, List[str]] = {}
primary_members_truncated = False
if group_keys_by_primary_rid and len(entries) < self.max_nodes:
primary_entries = directory.search_directory_entries(
directory.build_primary_group_users_filter(
group_keys_by_primary_rid.keys()
),
self.ATTRS,
)
for entry in primary_entries:
key = directory.normalize_dn(directory.entry_dn(entry))
rid = directory.parse_int(
directory.ldap_first(entry, "primaryGroupID") or "",
-1,
)
if not key or rid not in group_keys_by_primary_rid:
continue
if key not in entries and len(entries) >= self.max_nodes:
primary_members_truncated = True
continue
entries[key] = entry
for group_key in group_keys_by_primary_rid[rid]:
primary_members.setdefault(group_key, []).append(
directory.entry_dn(entry)
)
elif group_keys_by_primary_rid:
primary_members_truncated = True
def effective_members(group_key: str, explicit_dns) -> List[str]:
result = []
seen = set()
for dn in [*explicit_dns, *primary_members.get(group_key, [])]:
key = directory.normalize_dn(str(dn))
if key and key not in seen:
seen.add(key)
result.append(str(dn))
return result
folder_map = {}
conn = directory.open_db()
try:
conn = connect_state_db(STATE_DB, read_only=True)
try:
folder_map = {
row[0]: {"folder": os.path.basename(row[1]), "active": bool(row[2])}
for row in conn.execute("SELECT objectGUID, path, isActive FROM shares")
}
finally:
conn.close()
except sqlite3.Error:
pass
def make_node(dn: str, ancestors: set) -> Dict[str, object]:
key = directory.normalize_dn(dn)
entry = entries.get(key)
if entry is None:
return {"id": dn, "name": dn, "sam": "", "type": "unknown", "members": []}
classes = {value.lower() for value in directory.ldap_values(entry, "objectClass")}
node_type = "group" if "group" in classes else "computer" if "computer" in classes else "user"
node = {
"id": directory.entry_dn(entry),
"name": display_name(entry),
"sam": directory.ldap_first(entry, "sAMAccountName") or "",
"type": node_type,
"members": [],
}
if key in ancestors:
node["cycle"] = True
return node
if node_type == "group":
next_ancestors = {*ancestors, key}
node["members"] = [
make_node(child, next_ancestors)
for child in effective_members(
key,
directory.ldap_values(entry, "member"),
)
]
return node
group_rows = []
for root in sorted(roots, key=lambda item: str(item["shareName"]).casefold()):
root_key = directory.normalize_dn(
str(root.get("distinguishedName") or "")
)
members = [
make_node(dn, set())
for dn in effective_members(root_key, root.get("memberDns", []))
]
flat_users = set()
flat_groups = set()
def count_nodes(nodes):
for node in nodes:
target = flat_groups if node["type"] == "group" else flat_users if node["type"] == "user" else None
if target is not None:
target.add(str(node.get("sam") or node["id"]).casefold())
count_nodes(node.get("members", []))
count_nodes(members)
folder = folder_map.get(str(root["objectGUID"]), {})
group_rows.append(
{
"guid": root["objectGUID"],
"name": root["shareName"],
"sam": root["samAccountName"],
"folder": folder.get("folder", root["shareName"]),
"active": folder.get("active", True),
"userCount": len(flat_users),
"groupCount": len(flat_groups),
"members": members,
}
)
return {
"groups": group_rows,
"fetchedAt": now_utc().isoformat(timespec="seconds"),
"truncated": bool(pending) or primary_members_truncated,
}
access_control.ensure_schema(conn)
return access_control.report_folders(conn)
finally:
conn.close()
@@ -1021,12 +844,12 @@ class Handler(BaseHTTPRequestHandler):
self.send_error_json(HTTPStatus.UNAUTHORIZED, "Anmeldung erforderlich")
return value
def read_json_body(self) -> Dict[str, object]:
def read_json_body(self, maximum: int = 16384) -> Dict[str, object]:
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError as exc:
raise ValueError("Ungültige Anfragelänge") from exc
if length <= 0 or length > 16384:
if length <= 0 or length > maximum:
raise ValueError("Ungültiger Anfrageinhalt")
try:
value = json.loads(self.rfile.read(length))
@@ -1101,13 +924,53 @@ class Handler(BaseHTTPRequestHandler):
cookie = f"{JWT_COOKIE}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Strict"
self.send_json({"ok": True}, cookie=cookie)
return
if parsed.path == "/api/access":
user = self.require_user()
if user is None:
return
try:
body = self.read_json_body(1024 * 1024)
with access_control.mutation_lock():
conn = directory.open_db()
try:
access_control.migrate(conn)
users, _ = access_control.read_directory()
result = access_control.apply_change(conn, body, str(user["sub"]), users)
finally:
conn.close()
with APP.directory.lock:
APP.directory.cached_at = 0
log(f"{user['sub']} changed folder access: {body.get('action')}")
self.send_json(result)
except access_control.AccessConflict as exc:
self.send_error_json(HTTPStatus.CONFLICT, str(exc))
except (ValueError, sqlite3.IntegrityError) as exc:
self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
except (OSError, RuntimeError) as exc:
log(f"Access management failed: {exc}")
self.send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
return
if parsed.path == "/api/trash/restore":
user = self.require_user()
if user is None:
return
try:
body = self.read_json_body()
result = trash.restore_item(str(body.get("id", "")))
item_id = str(body.get("id", ""))
share, _ = trash.decode_item_id(item_id)
if share == "Data":
with access_control.mutation_lock():
conn = directory.open_db()
try:
result = trash.restore_item(item_id, prepare=lambda path, fd: access_control.prepare_data_restore(conn, path, fd))
finally:
conn.close()
else:
result = trash.restore_item(item_id)
except access_control.AccessConflict as exc:
self.send_error_json(HTTPStatus.CONFLICT, str(exc))
return
except FileExistsError:
self.send_error_json(
HTTPStatus.CONFLICT,
@@ -1174,6 +1037,16 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"user": user["sub"], "expiresAt": user["exp"]})
elif path == "/api/overview":
self.send_json(APP.overview())
elif path == "/api/access":
conn = directory.open_db()
try:
access_control.ensure_schema(conn)
users, _ = access_control.read_directory()
access_control.cache_users(conn, users)
conn.commit()
self.send_json(access_control.snapshot(conn, users))
finally:
conn.close()
elif path == "/api/groups":
self.send_json(APP.directory.get())
elif path == "/api/storage":