(hopefully) faster reconciliation

This commit is contained in:
Ludwig Lehnert
2026-07-03 03:24:43 +00:00
parent 447ccb6856
commit 80076c1bbb
2 changed files with 332 additions and 184 deletions

View File

@@ -3,14 +3,13 @@
import base64 import base64
import datetime as dt import datetime as dt
import fcntl import fcntl
import grp
import os import os
import pwd
import re import re
import sqlite3 import sqlite3
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import time
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Callable, Dict, Iterable, List, Optional, Set, Tuple from typing import Callable, Dict, Iterable, List, Optional, Set, Tuple
@@ -35,17 +34,22 @@ GROUP_SEARCH_ATTRS = [
"cn", "cn",
"distinguishedName", "distinguishedName",
"member", "member",
"memberOf",
"objectClass", "objectClass",
] ]
PRINCIPAL_SEARCH_ATTRS = [ NESTED_GROUP_SEARCH_ATTRS = [
"objectGUID", "objectGUID",
"sAMAccountName", "sAMAccountName",
"distinguishedName", "distinguishedName",
"member", "memberOf",
"objectClass", "objectClass",
] ]
LDAP_DN_LOOKUP_BATCH_SIZE = 25 LDAP_MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941"
SETFACL_ENTRY_CHUNK_SIZE = 40 SETFACL_ENTRY_CHUNK_SIZE = 40
SLOW_COMMAND_LOG_SECONDS = 5.0
AD_COMMAND_TIMEOUT_SECONDS = 120
WINBIND_COMMAND_TIMEOUT_SECONDS = 30
NSS_COMMAND_TIMEOUT_SECONDS = 30
REQUIRED_ENV = ["REALM", "WORKGROUP", "DOMAIN"] REQUIRED_ENV = ["REALM", "WORKGROUP", "DOMAIN"]
ATTR_RE = re.compile(r"^([^:]+)(::?)\s*(.*)$") ATTR_RE = re.compile(r"^([^:]+)(::?)\s*(.*)$")
@@ -69,15 +73,13 @@ MAX_GROUP_FOLDER_NAME = 120
LdapValue = Tuple[str, bool] LdapValue = Tuple[str, bool]
LdapEntry = Dict[str, List[LdapValue]] LdapEntry = Dict[str, List[LdapValue]]
Principal = Dict[str, object] Principal = Dict[str, object]
PrincipalLookup = Callable[[List[str]], Dict[str, Principal]] NestedGroupLookup = Callable[[str], Dict[str, Principal]]
@dataclass @dataclass
class MembershipExpansion: class MembershipExpansion:
group_sams: List[str] = field(default_factory=list) group_sams: List[str] = field(default_factory=list)
terminal_user_count: int = 0
unresolved_dns: List[str] = field(default_factory=list) unresolved_dns: List[str] = field(default_factory=list)
ignored_dns: List[str] = field(default_factory=list)
cycle_paths: List[List[str]] = field(default_factory=list) cycle_paths: List[List[str]] = field(default_factory=list)
@@ -113,11 +115,59 @@ def parse_guid(raw_value: str, is_b64: bool) -> str:
return str(uuid.UUID(candidate)) return str(uuid.UUID(candidate))
def run_command(command: List[str], check: bool = True) -> subprocess.CompletedProcess: def format_duration(seconds: float) -> str:
result = subprocess.run(command, capture_output=True, text=True) if seconds >= 1:
return f"{seconds:.1f}s"
return f"{seconds * 1000:.0f}ms"
def command_text(command: List[str]) -> str:
return " ".join(command)
def completed_timeout(
command: List[str], exc: subprocess.TimeoutExpired
) -> subprocess.CompletedProcess:
stdout = exc.stdout or ""
stderr = exc.stderr or ""
if isinstance(stdout, bytes):
stdout = stdout.decode(errors="replace")
if isinstance(stderr, bytes):
stderr = stderr.decode(errors="replace")
if not stderr:
stderr = f"Command timed out after {exc.timeout}s"
return subprocess.CompletedProcess(command, 124, stdout, stderr)
def run_command(
command: List[str], check: bool = True, timeout: Optional[int] = None
) -> subprocess.CompletedProcess:
started = time.monotonic()
try:
result = subprocess.run(
command, capture_output=True, text=True, timeout=timeout
)
except subprocess.TimeoutExpired as exc:
elapsed = time.monotonic() - started
log(
f"Command timed out after {format_duration(elapsed)}: "
f"{command_text(command)}"
)
result = completed_timeout(command, exc)
if check:
raise RuntimeError(
f"Command timed out ({command_text(command)}): {result.stderr.strip()}"
) from exc
return result
elapsed = time.monotonic() - started
if elapsed >= SLOW_COMMAND_LOG_SECONDS:
log(f"Slow command took {format_duration(elapsed)}: {command_text(command)}")
if check and result.returncode != 0: if check and result.returncode != 0:
raise RuntimeError( raise RuntimeError(
f"Command failed ({' '.join(command)}): {result.stderr.strip() or result.stdout.strip()}" f"Command failed ({command_text(command)}): "
f"{result.stderr.strip() or result.stdout.strip()}"
) )
return result return result
@@ -232,6 +282,7 @@ def parse_principal_from_entry(entry: LdapEntry) -> Optional[Principal]:
"objectGUID": guid, "objectGUID": guid,
"samAccountName": ldap_first(entry, "sAMAccountName") or "", "samAccountName": ldap_first(entry, "sAMAccountName") or "",
"memberDns": ldap_values(entry, "member"), "memberDns": ldap_values(entry, "member"),
"memberOfDns": ldap_values(entry, "memberOf"),
"objectClasses": object_classes, "objectClasses": object_classes,
} }
@@ -260,6 +311,7 @@ def parse_groups_from_ldap_entries(entries: List[LdapEntry]) -> List[Dict[str, o
"shareName": share_name, "shareName": share_name,
"distinguishedName": entry_dn(entry), "distinguishedName": entry_dn(entry),
"memberDns": ldap_values(entry, "member"), "memberDns": ldap_values(entry, "member"),
"memberOfDns": ldap_values(entry, "memberOf"),
"objectClasses": object_classes, "objectClasses": object_classes,
} }
) )
@@ -371,6 +423,7 @@ def search_entries_via_net_ads(
result = run_command( result = run_command(
["net", "ads", "search", "-P", filter_expr, *attributes], ["net", "ads", "search", "-P", filter_expr, *attributes],
check=False, check=False,
timeout=AD_COMMAND_TIMEOUT_SECONDS,
) )
if result.returncode != 0: if result.returncode != 0:
raise RuntimeError( raise RuntimeError(
@@ -417,7 +470,8 @@ def search_entries_via_ldap_bind(
base_dn, base_dn,
filter_expr, filter_expr,
*attributes, *attributes,
] ],
timeout=AD_COMMAND_TIMEOUT_SECONDS,
) )
return parse_ldap_entries(result.stdout) return parse_ldap_entries(result.stdout)
finally: finally:
@@ -449,6 +503,9 @@ def group_to_principal(group: Dict[str, object]) -> Principal:
"objectGUID": str(group.get("objectGUID") or ""), "objectGUID": str(group.get("objectGUID") or ""),
"samAccountName": str(group.get("samAccountName") or ""), "samAccountName": str(group.get("samAccountName") or ""),
"memberDns": [str(dn) for dn in group.get("memberDns", []) if str(dn).strip()], "memberDns": [str(dn) for dn in group.get("memberDns", []) if str(dn).strip()],
"memberOfDns": [
str(dn) for dn in group.get("memberOfDns", []) if str(dn).strip()
],
"objectClasses": {str(value).lower() for value in object_classes}, "objectClasses": {str(value).lower() for value in object_classes},
} }
@@ -474,147 +531,119 @@ def is_user_principal(principal: Principal) -> bool:
) )
def lookup_principals_by_dns(dns: List[str]) -> Dict[str, Principal]: def build_nested_groups_filter(root_dn: str) -> str:
unique_dns: List[str] = [] escaped_root_dn = escape_ldap_filter_value(root_dn)
seen_dns: Set[str] = set() return (
for dn in dns: "(&(objectClass=group)"
key = normalize_dn(dn) f"(memberOf:{LDAP_MATCHING_RULE_IN_CHAIN}:={escaped_root_dn}))"
if not key or key in seen_dns:
continue
seen_dns.add(key)
unique_dns.append(dn)
principals: Dict[str, Principal] = {}
for dn_chunk in chunked(unique_dns, LDAP_DN_LOOKUP_BATCH_SIZE):
entries = search_directory_entries(
build_distinguished_name_filter(dn_chunk), PRINCIPAL_SEARCH_ATTRS
) )
def lookup_nested_group_principals(root_dn: str) -> Dict[str, Principal]:
if not root_dn.strip():
return {}
entries = search_directory_entries(
build_nested_groups_filter(root_dn), NESTED_GROUP_SEARCH_ATTRS
)
principals: Dict[str, Principal] = {}
for entry in entries: for entry in entries:
principal = parse_principal_from_entry(entry) principal = parse_principal_from_entry(entry)
if principal is None: if principal is None or not is_group_principal(principal):
continue continue
key = normalize_dn(str(principal.get("dn") or "")) key = normalize_dn(str(principal.get("dn") or ""))
if key: if key:
principals[key] = principal principals[key] = principal
return principals return principals
def load_principals_for_dns( def find_group_cycle_paths(principals: Dict[str, Principal]) -> List[List[str]]:
dns: List[str], cache: Dict[str, Principal], lookup_func: PrincipalLookup graph: Dict[str, List[str]] = {}
) -> None: for key, principal in principals.items():
missing_dns: List[str] = [] parent_keys = []
seen_dns: Set[str] = set() for parent_dn in principal.get("memberOfDns", []):
for dn in dns: parent_key = normalize_dn(str(parent_dn))
key = normalize_dn(dn) if parent_key in principals:
if not key or key in cache or key in seen_dns: parent_keys.append(parent_key)
graph[key] = sorted(set(parent_keys))
cycle_paths: List[List[str]] = []
cycle_keys_seen: Set[str] = set()
visited: Set[str] = set()
visiting: Set[str] = set()
stack: List[str] = []
def add_cycle(cycle: List[str]) -> None:
labels = [principal_display_name(principals[key]) for key in cycle]
cycle_key = " -> ".join(labels).casefold()
if cycle_key not in cycle_keys_seen:
cycle_keys_seen.add(cycle_key)
cycle_paths.append(labels)
def visit(key: str) -> None:
visiting.add(key)
stack.append(key)
for parent_key in graph.get(key, []):
if parent_key in visiting:
start_index = stack.index(parent_key)
add_cycle(stack[start_index:] + [parent_key])
continue continue
seen_dns.add(key) if parent_key not in visited:
missing_dns.append(dn) visit(parent_key)
stack.pop()
visiting.discard(key)
visited.add(key)
if not missing_dns: for key in sorted(principals):
return if key not in visited:
visit(key)
for lookup_key, principal in lookup_func(missing_dns).items(): return cycle_paths
principal_key = normalize_dn(str(principal.get("dn") or lookup_key))
if principal_key:
cache[principal_key] = principal
def expand_group_membership( def expand_group_membership(
root_group: Dict[str, object], root_group: Dict[str, object],
principal_cache: Optional[Dict[str, Principal]] = None, nested_group_cache: Optional[Dict[str, Dict[str, Principal]]] = None,
lookup_func: PrincipalLookup = lookup_principals_by_dns, lookup_func: NestedGroupLookup = lookup_nested_group_principals,
) -> MembershipExpansion: ) -> MembershipExpansion:
cache = principal_cache if principal_cache is not None else {}
root_principal = group_to_principal(root_group) root_principal = group_to_principal(root_group)
root_key = normalize_dn(str(root_principal.get("dn") or "")) root_dn = str(root_principal.get("dn") or "")
if root_key: root_key = normalize_dn(root_dn)
cache[root_key] = root_principal
expansion = MembershipExpansion() expansion = MembershipExpansion()
group_sams_seen: Set[str] = set() if not root_key:
unresolved_seen: Set[str] = set() expansion.unresolved_dns.append("<missing root group distinguishedName>")
ignored_seen: Set[str] = set() return expansion
user_dns_seen: Set[str] = set()
cycle_paths_seen: Set[str] = set()
visiting: Set[str] = set()
visited: Set[str] = set()
def add_unresolved(dn: str) -> None: cache = nested_group_cache if nested_group_cache is not None else {}
key = normalize_dn(dn) if root_key in cache:
if key and key not in unresolved_seen: nested_groups = cache[root_key]
unresolved_seen.add(key)
expansion.unresolved_dns.append(dn)
def add_ignored(dn: str) -> None:
key = normalize_dn(dn)
if key and key not in ignored_seen:
ignored_seen.add(key)
expansion.ignored_dns.append(dn)
def add_cycle(path: List[str]) -> None:
key = " -> ".join(path).casefold()
if key not in cycle_paths_seen:
cycle_paths_seen.add(key)
expansion.cycle_paths.append(path)
def visit_group(principal: Principal, path: List[str]) -> None:
current_key = normalize_dn(str(principal.get("dn") or ""))
if current_key:
visiting.add(current_key)
member_dns = [
str(dn) for dn in principal.get("memberDns", []) if str(dn).strip()
]
load_principals_for_dns(member_dns, cache, lookup_func)
for member_dn in member_dns:
member_key = normalize_dn(member_dn)
if not member_key:
continue
member = cache.get(member_key)
if member is None:
add_unresolved(member_dn)
continue
member_label = principal_display_name(member)
if member_key in visiting:
add_cycle(path + [member_label])
continue
if is_group_principal(member):
member_sam = str(member.get("samAccountName") or "").strip()
if not member_sam:
add_unresolved(str(member.get("dn") or member_dn))
else: else:
sam_key = member_sam.casefold() nested_groups = lookup_func(root_dn)
if sam_key not in group_sams_seen: cache[root_key] = nested_groups
group_sams_seen.add(sam_key)
expansion.group_sams.append(member_sam)
if member_key not in visited: all_groups = {root_key: root_principal}
visit_group(member, path + [member_label]) all_groups.update(nested_groups)
seen_sams: Set[str] = set()
for key, principal in sorted(
nested_groups.items(),
key=lambda item: principal_display_name(item[1]).casefold(),
):
if key == root_key:
continue continue
sam = str(principal.get("samAccountName") or "").strip()
if is_computer_principal(member): if not sam:
add_ignored(str(member.get("dn") or member_dn)) dn = str(principal.get("dn") or key)
if dn not in expansion.unresolved_dns:
expansion.unresolved_dns.append(dn)
continue continue
sam_key = sam.casefold()
if sam_key not in seen_sams:
seen_sams.add(sam_key)
expansion.group_sams.append(sam)
if is_user_principal(member): expansion.cycle_paths = find_group_cycle_paths(all_groups)
if member_key not in user_dns_seen:
user_dns_seen.add(member_key)
expansion.terminal_user_count += 1
continue
add_ignored(str(member.get("dn") or member_dn))
if current_key:
visiting.discard(current_key)
visited.add(current_key)
visit_group(root_principal, [principal_display_name(root_principal)])
return expansion return expansion
@@ -642,7 +671,7 @@ def fetch_non_login_users() -> set:
"accountExpires", "accountExpires",
"lockoutTime", "lockoutTime",
] ]
result = run_command(command, check=False) result = run_command(command, check=False, timeout=AD_COMMAND_TIMEOUT_SECONDS)
if result.returncode != 0: if result.returncode != 0:
log( log(
"net ads search for account status failed; private folder filtering will use static skip rules only" "net ads search for account status failed; private folder filtering will use static skip rules only"
@@ -814,16 +843,36 @@ def refresh_winbind_cache() -> None:
def resolve_user_uid(qualified_user: str) -> Optional[int]: def resolve_user_uid(qualified_user: str) -> Optional[int]:
result = run_command(
["getent", "passwd", qualified_user],
check=False,
timeout=NSS_COMMAND_TIMEOUT_SECONDS,
)
if result.returncode != 0:
return None
fields = result.stdout.strip().split(":")
if len(fields) < 3:
return None
try: try:
return pwd.getpwnam(qualified_user).pw_uid return int(fields[2])
except KeyError: except ValueError:
return None return None
def resolve_group_gid(qualified_group: str) -> Optional[int]: def resolve_group_gid(qualified_group: str) -> Optional[int]:
result = run_command(
["getent", "group", qualified_group],
check=False,
timeout=NSS_COMMAND_TIMEOUT_SECONDS,
)
if result.returncode != 0:
return None
fields = result.stdout.strip().split(":")
if len(fields) < 3:
return None
try: try:
return grp.getgrnam(qualified_group).gr_gid return int(fields[2])
except KeyError: except ValueError:
return None return None
@@ -862,7 +911,11 @@ def resolve_group_gid_flexible(workgroup: str, group_name: str) -> Optional[int]
def resolve_gid_from_sid(sid: str) -> Optional[int]: def resolve_gid_from_sid(sid: str) -> Optional[int]:
if not sid: if not sid:
return None return None
result = run_command(["wbinfo", "--sid-to-gid", sid], check=False) result = run_command(
["wbinfo", "--sid-to-gid", sid],
check=False,
timeout=WINBIND_COMMAND_TIMEOUT_SECONDS,
)
if result.returncode != 0: if result.returncode != 0:
return None return None
try: try:
@@ -979,12 +1032,15 @@ def enforce_group_tree_permissions(
owner_group_gid: int, owner_group_gid: int,
acl_group_gids: List[int], acl_group_gids: List[int],
admin_gid: Optional[int], admin_gid: Optional[int],
) -> None: ) -> Tuple[int, int]:
dir_count = 1
file_count = 0
apply_group_permissions( apply_group_permissions(
root_path, owner_group_gid, acl_group_gids, admin_gid, is_dir=True root_path, owner_group_gid, acl_group_gids, admin_gid, is_dir=True
) )
for current_root, dirnames, filenames in os.walk(root_path): for current_root, dirnames, filenames in os.walk(root_path):
for dirname in dirnames: for dirname in dirnames:
dir_count += 1
apply_group_permissions( apply_group_permissions(
os.path.join(current_root, dirname), os.path.join(current_root, dirname),
owner_group_gid, owner_group_gid,
@@ -993,6 +1049,7 @@ def enforce_group_tree_permissions(
is_dir=True, is_dir=True,
) )
for filename in filenames: for filename in filenames:
file_count += 1
apply_group_permissions( apply_group_permissions(
os.path.join(current_root, filename), os.path.join(current_root, filename),
owner_group_gid, owner_group_gid,
@@ -1000,12 +1057,23 @@ def enforce_group_tree_permissions(
admin_gid, admin_gid,
is_dir=False, is_dir=False,
) )
return dir_count, file_count
def resolve_user_primary_gid(uid: int) -> Optional[int]: def resolve_user_primary_gid(uid: int) -> Optional[int]:
result = run_command(
["getent", "passwd", str(uid)],
check=False,
timeout=NSS_COMMAND_TIMEOUT_SECONDS,
)
if result.returncode != 0:
return None
fields = result.stdout.strip().split(":")
if len(fields) < 4:
return None
try: try:
return pwd.getpwuid(uid).pw_gid return int(fields[3])
except KeyError: except ValueError:
return None return None
@@ -1033,7 +1101,9 @@ def enforce_private_tree_permissions(
def list_domain_users(non_login_users: set) -> List[str]: def list_domain_users(non_login_users: set) -> List[str]:
result = run_command(["wbinfo", "-u"], check=False) result = run_command(
["wbinfo", "-u"], check=False, timeout=WINBIND_COMMAND_TIMEOUT_SECONDS
)
if result.returncode != 0: if result.returncode != 0:
log("wbinfo -u failed; skipping private directory sync") log("wbinfo -u failed; skipping private directory sync")
return [] return []
@@ -1180,12 +1250,14 @@ def sync_dynamic_directory_permissions(
admin_gid = resolve_gid_from_sid(os.getenv("DOMAIN_ADMINS_SID", "")) admin_gid = resolve_gid_from_sid(os.getenv("DOMAIN_ADMINS_SID", ""))
ad_groups_by_guid = {str(group["objectGUID"]): group for group in ad_groups} ad_groups_by_guid = {str(group["objectGUID"]): group for group in ad_groups}
principal_cache: Dict[str, Principal] = {} nested_group_cache: Dict[str, Dict[str, Principal]] = {}
rows = conn.execute( rows = conn.execute(
"SELECT objectGUID, samAccountName, path FROM shares WHERE isActive = 1" "SELECT objectGUID, samAccountName, path FROM shares WHERE isActive = 1"
).fetchall() ).fetchall()
log(f"Syncing permissions for {len(rows)} active data folder group(s)")
for row in rows: for row in rows:
share_started = time.monotonic()
guid = row["objectGUID"] guid = row["objectGUID"]
sam = row["samAccountName"] sam = row["samAccountName"]
path = row["path"] path = row["path"]
@@ -1194,35 +1266,34 @@ def sync_dynamic_directory_permissions(
log(f"No AD data available for {sam}; leaving existing ACLs") log(f"No AD data available for {sam}; leaving existing ACLs")
continue continue
log(f"Syncing data folder {sam}")
expand_started = time.monotonic()
try: try:
expansion = expand_group_membership(ad_group, principal_cache) expansion = expand_group_membership(ad_group, nested_group_cache)
except Exception as exc: # pylint: disable=broad-except except Exception as exc: # pylint: disable=broad-except
log( log(
f"Unable to expand nested members for {sam}: " f"Unable to expand nested members for {sam}: "
f"{exc}; leaving existing ACLs" f"{exc}; leaving existing ACLs"
) )
continue continue
expand_elapsed = time.monotonic() - expand_started
for cycle_path in expansion.cycle_paths: for cycle_path in expansion.cycle_paths:
log(f"Detected nested group cycle for {sam}: {' -> '.join(cycle_path)}") log(f"Detected nested group cycle for {sam}: {' -> '.join(cycle_path)}")
if expansion.ignored_dns:
log(
f"Ignoring computer/unsupported nested member(s) for {sam}: "
f"{format_dn_list(expansion.ignored_dns)}"
)
if expansion.unresolved_dns: if expansion.unresolved_dns:
log( log(
f"Unable to resolve nested member(s) for {sam}: " f"Unable to resolve nested group(s) for {sam}: "
f"{format_dn_list(expansion.unresolved_dns)}; leaving existing ACLs" f"{format_dn_list(expansion.unresolved_dns)}; leaving existing ACLs"
) )
continue continue
acl_group_names = [sam, *expansion.group_sams] acl_group_names = [sam, *expansion.group_sams]
gid_started = time.monotonic()
acl_group_gids, unresolved_groups = resolve_group_gids_for_acl( acl_group_gids, unresolved_groups = resolve_group_gids_for_acl(
workgroup, acl_group_names workgroup, acl_group_names
) )
gid_elapsed = time.monotonic() - gid_started
if unresolved_groups: if unresolved_groups:
log( log(
f"Unable to resolve GID(s) for {sam}: " f"Unable to resolve GID(s) for {sam}: "
@@ -1231,14 +1302,21 @@ def sync_dynamic_directory_permissions(
continue continue
owner_group_gid = acl_group_gids[0] owner_group_gid = acl_group_gids[0]
if expansion.group_sams or expansion.cycle_paths:
log(
f"Expanded {sam}: {len(expansion.group_sams)} nested group(s), "
f"{expansion.terminal_user_count} terminal user(s)"
)
os.makedirs(path, exist_ok=True) os.makedirs(path, exist_ok=True)
enforce_group_tree_permissions(path, owner_group_gid, acl_group_gids, admin_gid) acl_started = time.monotonic()
dir_count, file_count = enforce_group_tree_permissions(
path, owner_group_gid, acl_group_gids, admin_gid
)
acl_elapsed = time.monotonic() - acl_started
total_elapsed = time.monotonic() - share_started
log(
f"Synced {sam}: nested_groups={len(expansion.group_sams)}, "
f"acl_groups={len(acl_group_gids)}, dirs={dir_count}, files={file_count}, "
f"expand={format_duration(expand_elapsed)}, "
f"gids={format_duration(gid_elapsed)}, "
f"acl={format_duration(acl_elapsed)}, "
f"total={format_duration(total_elapsed)}"
)
os.makedirs(GROUP_ROOT, exist_ok=True) os.makedirs(GROUP_ROOT, exist_ok=True)
os.chown(GROUP_ROOT, 0, 0) os.chown(GROUP_ROOT, 0, 0)
@@ -1261,19 +1339,61 @@ def with_lock() -> bool:
os.makedirs(GROUP_ROOT, exist_ok=True) os.makedirs(GROUP_ROOT, exist_ok=True)
os.makedirs(GROUP_ARCHIVE_ROOT, exist_ok=True) os.makedirs(GROUP_ARCHIVE_ROOT, exist_ok=True)
reconcile_started = time.monotonic()
conn = open_db() conn = open_db()
try: try:
phase_started = time.monotonic()
groups = fetch_fileshare_groups() groups = fetch_fileshare_groups()
log(f"Discovered {len(groups)} data folder group(s) from AD") log(
f"Discovered {len(groups)} data folder group(s) from AD "
f"in {format_duration(time.monotonic() - phase_started)}"
)
log("Reconciling data folder DB")
phase_started = time.monotonic()
reconcile_db(conn, groups) reconcile_db(conn, groups)
log(
f"Reconciled data folder DB in "
f"{format_duration(time.monotonic() - phase_started)}"
)
log("Syncing data folder permissions")
phase_started = time.monotonic()
sync_dynamic_directory_permissions(conn, groups) sync_dynamic_directory_permissions(conn, groups)
log(
f"Synced data folder permissions in "
f"{format_duration(time.monotonic() - phase_started)}"
)
finally: finally:
conn.close() conn.close()
log("Syncing FSLogix root")
phase_started = time.monotonic()
sync_fslogix_directory() sync_fslogix_directory()
log(
f"Synced FSLogix root in "
f"{format_duration(time.monotonic() - phase_started)}"
)
log("Syncing private directories")
phase_started = time.monotonic()
sync_private_directories() sync_private_directories()
log(
f"Synced private directories in "
f"{format_duration(time.monotonic() - phase_started)}"
)
log("Reloading Samba config")
phase_started = time.monotonic()
reload_samba() reload_samba()
log("Reconciliation completed") log(
f"Reloaded Samba config in "
f"{format_duration(time.monotonic() - phase_started)}"
)
log(
f"Reconciliation completed in "
f"{format_duration(time.monotonic() - reconcile_started)}"
)
return True return True
finally: finally:
lock_file.close() lock_file.close()

View File

@@ -14,13 +14,14 @@ USER_3_DN = "CN=Carol,OU=Users,DC=example,DC=com"
COMPUTER_DN = "CN=PC01,OU=Computers,DC=example,DC=com" COMPUTER_DN = "CN=PC01,OU=Computers,DC=example,DC=com"
def principal(dn, sam, classes, members=()): def principal(dn, sam, classes, members=(), member_of=()):
return { return {
"dn": dn, "dn": dn,
"objectGUID": "", "objectGUID": "",
"samAccountName": sam, "samAccountName": sam,
"objectClasses": set(classes), "objectClasses": set(classes),
"memberDns": list(members), "memberDns": list(members),
"memberOfDns": list(member_of),
} }
@@ -75,6 +76,7 @@ sAMAccountName: FS_Data
displayName: Data Folder displayName: Data Folder
distinguishedName: {ROOT_DN} distinguishedName: {ROOT_DN}
member: {GROUP_A_DN} member: {GROUP_A_DN}
memberOf: CN=Parent,OU=Groups,DC=example,DC=com
""" """
@@ -85,6 +87,9 @@ member: {GROUP_A_DN}
self.assertEqual(groups[0]["shareName"], "Data Folder") self.assertEqual(groups[0]["shareName"], "Data Folder")
self.assertEqual(groups[0]["distinguishedName"], ROOT_DN) self.assertEqual(groups[0]["distinguishedName"], ROOT_DN)
self.assertEqual(groups[0]["memberDns"], [GROUP_A_DN]) self.assertEqual(groups[0]["memberDns"], [GROUP_A_DN])
self.assertEqual(
groups[0]["memberOfDns"], ["CN=Parent,OU=Groups,DC=example,DC=com"]
)
self.assertEqual(groups[0]["objectClasses"], {"group"}) self.assertEqual(groups[0]["objectClasses"], {"group"})
def test_distinguished_name_filter_escapes_rfc4515_specials(self): def test_distinguished_name_filter_escapes_rfc4515_specials(self):
@@ -95,46 +100,49 @@ member: {GROUP_A_DN}
r"(distinguishedName=CN=A\2aB\28C\29\5c\5cName,DC=example,DC=com)", r"(distinguishedName=CN=A\2aB\28C\29\5c\5cName,DC=example,DC=com)",
) )
def test_nested_group_filter_uses_recursive_memberof_match(self):
self.assertEqual(
rs.build_nested_groups_filter(ROOT_DN),
"(&(objectClass=group)"
f"(memberOf:{rs.LDAP_MATCHING_RULE_IN_CHAIN}:={ROOT_DN}))",
)
class MembershipExpansionTests(unittest.TestCase): class MembershipExpansionTests(unittest.TestCase):
def lookup_from(self, principals): def lookup_from(self, principals):
indexed = {rs.normalize_dn(value["dn"]): value for value in principals} indexed = {rs.normalize_dn(value["dn"]): value for value in principals}
def lookup(dns): def lookup(root_dn):
self.assertEqual(root_dn, ROOT_DN)
return { return {
rs.normalize_dn(dn): indexed[rs.normalize_dn(dn)] key: value
for dn in dns for key, value in indexed.items()
if rs.normalize_dn(dn) in indexed if key != rs.normalize_dn(root_dn)
} }
return lookup return lookup
def test_recursive_expansion_dedupes_groups_users_and_detects_cycle(self): def test_recursive_expansion_dedupes_groups_and_detects_cycle(self):
root_group = { root_group = {
"objectGUID": "root-guid", "objectGUID": "root-guid",
"samAccountName": "FS_Data", "samAccountName": "FS_Data",
"distinguishedName": ROOT_DN, "distinguishedName": ROOT_DN,
"memberDns": [USER_1_DN, GROUP_A_DN, GROUP_B_DN, COMPUTER_DN], "memberDns": [USER_1_DN, GROUP_A_DN, GROUP_B_DN, COMPUTER_DN],
"memberOfDns": [GROUP_A_DN],
"objectClasses": {"group"}, "objectClasses": {"group"},
} }
lookup = self.lookup_from( lookup = self.lookup_from(
[ [
principal(USER_1_DN, "alice", {"user"}), principal(GROUP_A_DN, "GroupA", {"group"}, member_of=[ROOT_DN]),
principal(USER_2_DN, "bob", {"user"}), principal(GROUP_B_DN, "GroupB", {"group"}, member_of=[GROUP_A_DN]),
principal(USER_3_DN, "carol", {"user"}),
principal(COMPUTER_DN, "PC01$", {"user", "computer"}),
principal(GROUP_A_DN, "GroupA", {"group"}, [USER_2_DN, GROUP_B_DN]),
principal(GROUP_B_DN, "GroupB", {"group"}, [GROUP_A_DN, USER_3_DN]),
] ]
) )
expansion = rs.expand_group_membership(root_group, lookup_func=lookup) expansion = rs.expand_group_membership(root_group, lookup_func=lookup)
self.assertEqual(expansion.group_sams, ["GroupA", "GroupB"]) self.assertEqual(expansion.group_sams, ["GroupA", "GroupB"])
self.assertEqual(expansion.terminal_user_count, 3)
self.assertEqual(expansion.unresolved_dns, []) self.assertEqual(expansion.unresolved_dns, [])
self.assertEqual(expansion.ignored_dns, [COMPUTER_DN]) self.assertEqual(expansion.cycle_paths, [["FS_Data", "GroupA", "FS_Data"]])
self.assertEqual(expansion.cycle_paths, [["FS_Data", "GroupA", "GroupB", "GroupA"]])
def test_self_cycle_is_reported_once(self): def test_self_cycle_is_reported_once(self):
root_group = { root_group = {
@@ -145,27 +153,47 @@ class MembershipExpansionTests(unittest.TestCase):
"objectClasses": {"group"}, "objectClasses": {"group"},
} }
lookup = self.lookup_from( lookup = self.lookup_from(
[principal(GROUP_C_DN, "GroupC", {"group"}, [GROUP_C_DN])] [principal(GROUP_C_DN, "GroupC", {"group"}, member_of=[GROUP_C_DN])]
) )
expansion = rs.expand_group_membership(root_group, lookup_func=lookup) expansion = rs.expand_group_membership(root_group, lookup_func=lookup)
self.assertEqual(expansion.group_sams, ["GroupC"]) self.assertEqual(expansion.group_sams, ["GroupC"])
self.assertEqual(expansion.cycle_paths, [["FS_Data", "GroupC", "GroupC"]]) self.assertEqual(expansion.cycle_paths, [["GroupC", "GroupC"]])
def test_unresolved_member_is_reported(self): def test_user_members_are_not_required_for_expansion(self):
missing_dn = "CN=Missing,OU=Groups,DC=example,DC=com" user_dns = [
f"CN=User{index},OU=Users,DC=example,DC=com" for index in range(1000)
]
root_group = { root_group = {
"objectGUID": "root-guid", "objectGUID": "root-guid",
"samAccountName": "FS_Data", "samAccountName": "FS_Data",
"distinguishedName": ROOT_DN, "distinguishedName": ROOT_DN,
"memberDns": [missing_dn], "memberDns": [*user_dns, GROUP_A_DN],
"objectClasses": {"group"},
}
lookup = self.lookup_from(
[principal(GROUP_A_DN, "GroupA", {"group"}, member_of=[ROOT_DN])]
)
expansion = rs.expand_group_membership(root_group, lookup_func=lookup)
self.assertEqual(expansion.group_sams, ["GroupA"])
self.assertEqual(expansion.unresolved_dns, [])
self.assertEqual(expansion.cycle_paths, [])
def test_missing_root_dn_is_reported(self):
root_group = {
"objectGUID": "root-guid",
"samAccountName": "FS_Data",
"distinguishedName": "",
"memberDns": [],
"objectClasses": {"group"}, "objectClasses": {"group"},
} }
expansion = rs.expand_group_membership(root_group, lookup_func=lambda dns: {}) expansion = rs.expand_group_membership(root_group, lookup_func=lambda root_dn: {})
self.assertEqual(expansion.unresolved_dns, [missing_dn]) self.assertEqual(expansion.unresolved_dns, ["<missing root group distinguishedName>"])
self.assertEqual(expansion.group_sams, []) self.assertEqual(expansion.group_sams, [])