477 lines
23 KiB
Python
477 lines
23 KiB
Python
"""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}
|