more fine grained access control
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user