more fine grained access control

This commit is contained in:
Ludwig Lehnert
2026-10-02 16:16:41 +00:00
parent d618957b68
commit abe79dae96
19 changed files with 1541 additions and 343 deletions
+476
View File
@@ -0,0 +1,476 @@
"""Admin-managed Data folders and individual user permissions, enforced by Samba Windows ACLs."""
import contextlib
import datetime as dt
import fcntl
import hashlib
import json
import os
import re
import stat
import uuid
try:
from . import reconcile_shares as directory
except ImportError:
import reconcile_shares as directory
SID_RE = re.compile(r"S-1-5-21-\d+-\d+-\d+-\d+\Z")
READ = 0x1200A9
MODIFY = 0x1201BF # No DELETE, DELETE_CHILD, WRITE_DAC or WRITE_OWNER.
DELETE = 0x10000
DELETE_CHILD = 0x40
MASKS = {1: READ, 2: MODIFY, 3: MODIFY | DELETE | DELETE_CHILD}
ATTRS = ["objectGUID", "objectSid", "distinguishedName", "sAMAccountName",
"displayName", "cn", "objectClass", "member", "primaryGroupID"]
class AccessConflict(RuntimeError):
pass
def timestamp():
return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
def ensure_schema(conn):
conn.executescript("""
CREATE TABLE IF NOT EXISTS access_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS access_users (
sid TEXT PRIMARY KEY, sam TEXT NOT NULL, name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS folder_permissions (
folderId TEXT NOT NULL REFERENCES shares(objectGUID) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK(kind = 'user'),
principalId TEXT NOT NULL,
level INTEGER NOT NULL CHECK(level BETWEEN 0 AND 3),
PRIMARY KEY(folderId, kind, principalId)
);
CREATE TABLE IF NOT EXISTS access_changes (
id INTEGER PRIMARY KEY, timestamp TEXT NOT NULL,
actor TEXT NOT NULL, action TEXT NOT NULL, details TEXT NOT NULL
);
""")
upgrade_individual_permissions(conn)
def upgrade_individual_permissions(conn):
"""Flatten the previous local-group model without changing effective access."""
schema = conn.execute("SELECT sql FROM sqlite_master WHERE name='folder_permissions'").fetchone()[0]
if "'group'" not in schema:
return
conn.execute("BEGIN IMMEDIATE")
try:
# Recheck after obtaining the write lock: another worker may have upgraded.
schema = conn.execute("SELECT sql FROM sqlite_master WHERE name='folder_permissions'").fetchone()[0]
if "'group'" not in schema:
conn.commit()
return
if conn.execute("SELECT 1 FROM folder_permissions p LEFT JOIN access_groups g "
"ON p.principalId=g.id WHERE p.kind='group' AND g.id IS NULL").fetchone():
raise RuntimeError("Cannot migrate unresolved stored group permission")
conn.execute("CREATE TABLE individual_permissions ("
"folderId TEXT NOT NULL REFERENCES shares(objectGUID) ON DELETE CASCADE,"
"kind TEXT NOT NULL CHECK(kind = 'user'), principalId TEXT NOT NULL,"
"level INTEGER NOT NULL CHECK(level BETWEEN 0 AND 3),"
"PRIMARY KEY(folderId, kind, principalId))")
# Existing direct assignments take precedence, including explicit level 0.
conn.execute("INSERT INTO individual_permissions SELECT * FROM folder_permissions WHERE kind='user'")
conn.execute("INSERT OR IGNORE INTO individual_permissions "
"SELECT p.folderId,'user',m.userSid,MAX(p.level) FROM folder_permissions p "
"JOIN access_members m ON p.kind='group' AND p.principalId=m.groupId "
"GROUP BY p.folderId,m.userSid")
conn.execute("DROP TABLE folder_permissions")
conn.execute("ALTER TABLE individual_permissions RENAME TO folder_permissions")
conn.execute("DROP TABLE access_members")
conn.execute("DROP TABLE access_groups")
conn.execute("INSERT INTO access_changes(timestamp,actor,action,details) VALUES(?,?,?,?)",
(timestamp(), "system", "migrate-individual-permissions", "{}"))
conn.commit()
except Exception:
conn.rollback()
raise
def initialized(conn):
return conn.execute("SELECT 1 FROM access_settings WHERE key='managed'").fetchone() is not None
def read_directory(include_groups=False):
entries = directory.search_directory_entries(
"(|(&(objectClass=user)(!(objectClass=computer)))(objectClass=group))"
if include_groups else "(&(objectClass=user)(!(objectClass=computer)))", ATTRS)
users, groups = {}, {}
for entry in entries:
principal = directory.parse_principal_from_entry(entry)
if not principal:
continue
sid = str(principal.get("objectSid") or "")
if not SID_RE.fullmatch(sid):
continue
row = {"sid": sid, "sam": str(principal.get("samAccountName") or ""),
"name": directory.ldap_first(entry, "displayName") or
str(principal.get("samAccountName") or sid),
"dn": directory.normalize_dn(directory.entry_dn(entry)),
"primaryRid": directory.parse_int(directory.ldap_first(entry, "primaryGroupID") or "", -1),
"members": [directory.normalize_dn(dn) for dn in directory.ldap_values(entry, "member")]}
if directory.is_group_principal(principal):
groups[row["dn"]] = row
elif directory.is_user_principal(principal):
users[sid] = row
return users, groups
def cache_users(conn, users):
conn.executemany("INSERT INTO access_users(sid,sam,name) VALUES(?,?,?) "
"ON CONFLICT(sid) DO UPDATE SET sam=excluded.sam,name=excluded.name",
[(sid, row["sam"], row["name"]) for sid, row in users.items()])
def migrate(conn):
"""Snapshot existing assignments once; never discover new FS_* groups."""
ensure_schema(conn)
if initialized(conn):
return
rows = conn.execute("SELECT * FROM shares WHERE isActive=1").fetchall()
users, groups = read_directory(include_groups=True) if rows else ({}, {})
by_sam = {g["sam"].casefold(): g for g in groups.values()}
by_dn = {u["dn"]: sid for sid, u in users.items()}
def members(group, seen):
if group["dn"] in seen:
return set()
seen = {*seen, group["dn"]}
rid = directory.sid_rid(group["sid"])
result = {sid for sid, user in users.items()
if user["primaryRid"] == rid and sid.rsplit("-", 1)[0] == group["sid"].rsplit("-", 1)[0]}
for dn in group["members"]:
if dn in groups:
result.update(members(groups[dn], seen))
elif dn in by_dn:
result.add(by_dn[dn])
else:
raise RuntimeError(f"Cannot import unresolved member {dn}; migration will retry")
return result
# Resolve all memberships before mutating persistent state.
imports = []
for row in rows:
group = by_sam.get(row["samAccountName"].casefold())
if group is None:
raise RuntimeError(f"Cannot import group {row['samAccountName']}; migration will retry")
imports.append((row, members(group, set())))
try:
cache_users(conn, users)
for row, sids in imports:
conn.executemany("INSERT INTO folder_permissions VALUES(?,?,?,3)",
[(row["objectGUID"], "user", sid) for sid in sorted(sids)])
# Adopt any existing untracked folder without granting user access.
root = directory.GROUP_ROOT
if os.path.isdir(root):
known = {os.path.abspath(row[0]) for row in conn.execute("SELECT path FROM shares")}
for entry in os.scandir(root):
if entry.name != ".trash" and entry.is_dir(follow_symlinks=False) and os.path.abspath(entry.path) not in known:
create_folder_record(conn, entry.name, entry.path)
conn.execute("INSERT INTO access_settings VALUES('managed','1')")
conn.execute("UPDATE shares SET aclSignature=''")
conn.commit()
except Exception:
conn.rollback()
raise
def valid_name(value):
if not isinstance(value, str):
raise ValueError("Ein Name ist erforderlich")
name = value.strip()
if not name or len(name) > 120 or name in {".", ".."} or name.casefold() == ".trash" or name.endswith((".", " ")) or re.search(r'[\\/:*?"<>|\x00-\x1f\x7f]', name):
raise ValueError("Ungültiger Name")
if name.split(".", 1)[0].upper() in {"CON", "PRN", "AUX", "NUL", *[f"{p}{i}" for p in ("COM", "LPT") for i in range(1, 10)]}:
raise ValueError("Reservierter Name")
return name
def create_folder_record(conn, name, path):
folder_id = str(uuid.uuid4())
conn.execute("INSERT INTO shares(objectGUID,samAccountName,shareName,path,createdAt,lastSeenAt,isActive,aclSignature) VALUES(?,?,?,?,?,?,1,'')",
(folder_id, name, name, path, timestamp(), timestamp()))
return folder_id
def snapshot(conn, users=None):
user_rows = [dict(row) for row in conn.execute("SELECT * FROM access_users ORDER BY sam COLLATE NOCASE")]
if users is not None:
for user in user_rows:
user["available"] = user["sid"] in users
folders = []
for row in conn.execute("SELECT * FROM shares ORDER BY shareName COLLATE NOCASE"):
folders.append({"id": row["objectGUID"], "name": row["shareName"], "active": bool(row["isActive"]),
"permissions": [dict(p) for p in conn.execute("SELECT kind,principalId,level FROM folder_permissions WHERE folderId=? ORDER BY kind,principalId", (row["objectGUID"],))]})
return {"users": user_rows, "folders": folders,
"fetchedAt": timestamp(), "initialized": initialized(conn)}
def effective_levels(conn, folder_id):
return {row["principalId"]: row["level"] for row in conn.execute(
"SELECT principalId,level FROM folder_permissions WHERE folderId=?", (folder_id,))}
def descriptor(levels, admin_sid, is_dir=True, top_level=False):
if not SID_RE.fullmatch(admin_sid):
raise RuntimeError("DOMAIN_ADMINS_SID is required for managed folder ACLs")
flags = "OICI" if is_dir else ""
# OWNER RIGHTS suppresses implicit WRITE_DAC for files created by users.
aces = [f"(A;{flags};RC;;;OW)", f"(A;{flags};FA;;;SY)", f"(A;{flags};FA;;;{admin_sid})"]
for sid, level in sorted(levels.items()):
if not SID_RE.fullmatch(sid) or type(level) is not int or level not in range(4):
raise RuntimeError("Invalid stored folder permission")
if level:
mask = MASKS[level]
if top_level:
# Only admins can rename/delete a managed top-level folder.
aces.append(f"(A;;0x{mask & ~DELETE:08x};;;{sid})")
aces.append(f"(A;OICIIO;0x{mask:08x};;;{sid})")
else:
aces.append(f"(A;{flags};0x{mask:08x};;;{sid})")
return f"O:{admin_sid}G:{admin_sid}D:P" + "".join(aces)
def pack_descriptor(sddl):
# Samba's own NDR serializer matches the installed server version.
from samba.dcerpc import security, xattr
from samba.ndr import ndr_pack
acl = xattr.NTACL()
acl.version = 1
acl.info = security.descriptor.from_sddl(sddl, security.dom_sid(os.environ["DOMAIN_ADMINS_SID"].rsplit("-", 1)[0]))
return ndr_pack(acl)
def set_acl_fd(fd, blob, is_dir):
info = os.fstat(fd)
if not (stat.S_ISDIR(info.st_mode) if is_dir else stat.S_ISREG(info.st_mode)):
raise RuntimeError("Unsupported managed data entry")
from samba import xattr_tdb
os.makedirs("/state", exist_ok=True)
os.fchown(fd, 0, 0)
xattr_tdb.wrap_setxattr("/state/data-xattrs.tdb", f"/proc/self/fd/{fd}", "security.NTACL", blob)
os.chmod("/state/data-xattrs.tdb", 0o600)
# SMB checks Windows ACLs. Local data volumes must stay container-private.
os.fchmod(fd, 0o777 if is_dir else 0o666)
def set_acl(path, blob, is_dir):
# Open without following symlinks; set ACLs/mode through the pinned inode.
flags = os.O_RDONLY | os.O_NOFOLLOW | (os.O_DIRECTORY if is_dir else 0)
fd = os.open(path, flags)
try:
set_acl_fd(fd, blob, is_dir)
finally:
os.close(fd)
def prepare_data_restore(conn, relative_path, source_fd):
parts = relative_path.split("/")
row = conn.execute("SELECT * FROM shares WHERE shareName=? AND isActive=1", (parts[0],)).fetchone()
if row is None or len(parts) < 2:
raise ValueError("Wiederherstellung erfordert einen aktiven verwalteten Datenordner")
path = safe_folder_path(row)
admin_sid = os.getenv("DOMAIN_ADMINS_SID", "")
levels = effective_levels(conn, row["objectGUID"])
set_acl(path, pack_descriptor(descriptor(levels, admin_sid, top_level=True)), True)
for name in parts[1:-1]:
path = os.path.join(path, name)
set_acl(path, pack_descriptor(descriptor(levels, admin_sid)), True)
# Replace the retained ACL before the file is visible at its restored path.
set_acl_fd(source_fd, pack_descriptor(descriptor(levels, admin_sid, False)), False)
def safe_folder_path(row):
root = os.path.abspath(directory.GROUP_ROOT if row["isActive"] else directory.GROUP_ARCHIVE_ROOT)
path = os.path.abspath(row["path"])
if os.path.dirname(path) != root or os.path.islink(path):
raise RuntimeError(f"Unsafe managed folder path: {path}")
return path
def recover_pending(conn):
pending = conn.execute("SELECT value FROM access_settings WHERE key='pendingRepair'").fetchone()
if pending is None:
return
details = json.loads(pending[0])
if details.get("move"):
source, destination = details["move"]
if not os.path.lexists(source) and os.path.lexists(destination):
os.rename(destination, source)
created = details.get("create")
if created and os.path.isdir(created) and not conn.execute("SELECT 1 FROM shares WHERE path=?", (created,)).fetchone():
# Preserve files from an interrupted creation with admin-only access.
create_folder_record(conn, os.path.basename(created), created)
sync_permissions(conn, force=True)
conn.execute("DELETE FROM access_settings WHERE key='pendingRepair'")
conn.commit()
directory.log("Recovered interrupted admin access update")
def sync_permissions(conn, force=False):
admin_sid = os.getenv("DOMAIN_ADMINS_SID", "")
root = directory.GROUP_ROOT
os.makedirs(root, exist_ok=True)
force = force or not os.path.exists("/state/data-xattrs.tdb")
# Read/list only at the share root; no inheritance or top-level creation.
root_sddl = descriptor({}, admin_sid) + "(A;;0x001200a9;;;AU)"
set_acl(root, pack_descriptor(root_sddl), True)
for row in conn.execute("SELECT * FROM shares").fetchall():
path = safe_folder_path(row)
levels = effective_levels(conn, row["objectGUID"]) if row["isActive"] else {}
top = descriptor(levels, admin_sid, top_level=True)
signature = hashlib.sha256(top.encode()).hexdigest()
os.makedirs(path, exist_ok=True)
set_acl(path, pack_descriptor(top), True)
if force or directory.should_repair_data_acls() or row["aclSignature"] != signature:
blobs = {True: pack_descriptor(descriptor(levels, admin_sid)), False: pack_descriptor(descriptor(levels, admin_sid, False))}
# Never follow symlinks or cross mount boundaries.
root_device = os.stat(path, follow_symlinks=False).st_dev
for current, dirs, files in os.walk(path, followlinks=False):
for name in list(dirs):
child = os.path.join(current, name)
if os.path.islink(child) or os.stat(child, follow_symlinks=False).st_dev != root_device:
raise RuntimeError(f"Unsupported link or mount in managed folder: {child}")
set_acl(child, blobs[True], True)
for name in files:
set_acl(os.path.join(current, name), blobs[False], False)
conn.execute("UPDATE shares SET aclSignature=? WHERE objectGUID=?", (signature, row["objectGUID"]))
@contextlib.contextmanager
def mutation_lock():
os.makedirs(os.path.dirname(directory.LOCK_PATH), exist_ok=True)
with open(directory.LOCK_PATH, "a", encoding="utf-8") as handle:
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
raise AccessConflict("Freigabenabgleich läuft; bitte erneut versuchen") from exc
yield
def apply_change(conn, body, actor, users):
action = body.get("action")
if not isinstance(action, str) or action not in {"create-folder", "set-permissions", "archive-folder", "restore-folder"}:
raise ValueError("Unbekannte Verwaltungsaktion")
if body.get("id") is not None and (not isinstance(body["id"], str) or not body["id"] or len(body["id"]) > 120):
raise ValueError("Ungültige ID")
cache_users(conn, users)
conn.commit()
recover_pending(conn)
pending = {}
if action == "create-folder":
pending["create"] = os.path.join(directory.GROUP_ROOT, valid_name(body.get("name")))
elif action in {"archive-folder", "restore-folder"}:
row = conn.execute("SELECT * FROM shares WHERE objectGUID=?", (body.get("id"),)).fetchone()
if row is not None:
target = directory.GROUP_ROOT if action == "restore-folder" else directory.GROUP_ARCHIVE_ROOT
pending["move"] = [safe_folder_path(row), os.path.join(target, valid_name(row["shareName"]))]
# Persist recovery intent separately from the policy transaction. A process
# crash cannot leave partially updated ACLs with an unchanged signature.
conn.execute("INSERT INTO access_settings VALUES('pendingRepair',?)", (json.dumps(pending),))
conn.commit()
conn.execute("BEGIN IMMEDIATE")
created_path = None
moved_paths = None
acl_started = False
try:
if action == "create-folder":
name = valid_name(body.get("name"))
if conn.execute("SELECT 1 FROM shares WHERE shareName=? COLLATE NOCASE", (name,)).fetchone():
raise ValueError("Ordnername ist bereits vergeben")
path = os.path.join(directory.GROUP_ROOT, name)
os.mkdir(path, 0o700)
created_path = path
create_folder_record(conn, name, path)
elif action in {"set-permissions", "archive-folder", "restore-folder"}:
folder_id = body.get("id")
row = conn.execute("SELECT * FROM shares WHERE objectGUID=?", (folder_id,)).fetchone()
if row is None:
raise ValueError("Ordner nicht gefunden")
if action == "set-permissions":
rules = body.get("permissions")
if not isinstance(rules, list):
raise ValueError("Berechtigungen müssen eine Liste sein")
old_users = {r[0] for r in conn.execute("SELECT principalId FROM folder_permissions WHERE folderId=? AND kind='user'", (folder_id,))}
checked = []
seen = set()
for rule in rules:
if not isinstance(rule, dict):
raise ValueError("Ungültige Berechtigung")
kind, principal, level = rule.get("kind"), rule.get("principalId"), rule.get("level")
if not isinstance(kind, str) or kind != "user" or not isinstance(principal, str) or type(level) is not int or level not in range(4):
raise ValueError("Ungültige Berechtigung")
if (kind, principal) in seen:
raise ValueError("Doppelte Berechtigung")
seen.add((kind, principal))
if principal not in users and principal not in old_users:
raise ValueError("Unbekannter AD-Benutzer")
checked.append((folder_id, kind, principal, level))
conn.execute("DELETE FROM folder_permissions WHERE folderId=?", (folder_id,))
conn.executemany("INSERT INTO folder_permissions VALUES(?,?,?,?)", checked)
else:
active = action == "restore-folder"
if bool(row["isActive"]) == active:
raise ValueError("Ordner hat diesen Status bereits")
source = safe_folder_path(row)
root = directory.GROUP_ROOT if active else directory.GROUP_ARCHIVE_ROOT
os.makedirs(root, exist_ok=True)
destination = os.path.join(root, row["shareName"])
if os.path.lexists(destination):
raise AccessConflict("Zielordner existiert bereits")
os.rename(source, destination)
moved_paths = (source, destination)
conn.execute("UPDATE shares SET path=?,isActive=?,aclSignature='' WHERE objectGUID=?", (destination, int(active), folder_id))
else:
raise ValueError("Unbekannte Verwaltungsaktion")
acl_started = True
sync_permissions(conn)
conn.execute("DELETE FROM access_settings WHERE key='pendingRepair'")
conn.execute("INSERT INTO access_changes(timestamp,actor,action,details) VALUES(?,?,?,?)", (timestamp(), actor, action, json.dumps(body, ensure_ascii=False)))
conn.commit()
except Exception:
conn.rollback()
if moved_paths:
os.rename(moved_paths[1], moved_paths[0])
if created_path:
os.rmdir(created_path)
# A failed recursive update may have changed some ACLs: restore the old policy.
try:
if acl_started:
sync_permissions(conn, force=True)
conn.execute("DELETE FROM access_settings WHERE key='pendingRepair'")
conn.commit()
except Exception as exc:
directory.log(f"ERROR: Access rollback repair failed: {exc}")
conn.execute("UPDATE shares SET aclSignature=''")
conn.commit()
raise
# Existing handles carry cached access masks; disconnect Data clients.
result = directory.run_command(["smbcontrol", "all", "close-share", "Data"], check=False)
if result.returncode:
raise RuntimeError("Berechtigungen gespeichert; SMB-Verbindungen konnten nicht geschlossen werden. Samba neu starten.")
return snapshot(conn, users)
def report_folders(conn):
data = snapshot(conn)
users = {u["sid"]: u for u in data["users"]}
rows = []
for folder in data["folders"]:
if not folder["active"]:
continue
levels = effective_levels(conn, folder["id"])
members = []
for rule in folder["permissions"]:
if rule["kind"] == "user" and rule["level"]:
sid = rule["principalId"]
members.append({"id": sid, "name": users.get(sid, {}).get("name", sid), "sam": users.get(sid, {}).get("sam", sid), "type": "user", "level": rule["level"], "members": []})
rows.append({"guid": folder["id"], "name": folder["name"], "sam": folder["name"], "folder": folder["name"], "active": True,
"userCount": sum(bool(level) for level in levels.values()), "groupCount": 0, "members": members})
return {"groups": rows, "fetchedAt": data["fetchedAt"], "truncated": False}