diff --git a/README.md b/README.md index 0fb5c88..23da7a4 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ This repository provides a production-oriented Samba file server container that - `\\server\Data` -> `/data/groups/data` - `\\server\FSLogix` -> `/data/fslogix` - FS_* groups are projected as folders inside the Data share (`/data/groups/data/`). -- Data folder ACLs expand nested AD group membership recursively and detect group cycles. +- Data folder ACLs expand nested AD group membership recursively, resolve groups by SID, include `primaryGroupID` membership, and detect group cycles. - Group records, normalized activity events, collector offsets, and web caches share one SQLite database at `/state/shares.db`. - Group folders are name-based while active and moved to archive on deactivation: - active: `/data/groups/data/` @@ -22,7 +22,7 @@ This repository provides a production-oriented Samba file server container that - Setup prompts for well-known authorization groups by SID (`DOMAIN_USERS_SID`, `DOMAIN_ADMINS_SID`) to avoid localized group names. - `FSLOGIX_GROUP_SID` controls who can access the default FSLogix share (defaults to `DOMAIN_USERS_SID`). - Startup resolves those SIDs to NSS group names via winbind, then uses those resolved groups in Samba `valid users` rules. -- Samba `full_audit` is restricted to successful and failed reads, writes, renames/moves, and deletions. +- Samba `full_audit` is restricted to successful and failed reads, writes, renames, and deletions. - A collector normalizes those four actions and persists them in indexed SQLite tables; activity is never automatically deleted. - A read-only HTTPS web console provides group membership trees, storage usage, searchable activity, live backup progress, and system health. - Web sign-in validates the submitted username/password with Kerberos, permits only users whose winbind group SID set contains `DOMAIN_ADMINS_SID`, and issues an expiring JWT in a Secure, HttpOnly, SameSite=Strict cookie. The browser does not use NTLM/SPNEGO or Kerberos negotiation. @@ -120,7 +120,7 @@ The launcher builds the current application and starts an isolated, run-scoped n - a real Smallstep CA whose ACME provisioner issues the web UI certificate for `files.localhost`; - an authenticated rsync daemon used by the normal backup implementation; - the actual file-server image, joined to the dummy domain; -- a continuous SMB client that exercises reads, writes, renames/moves, and deletions, including activity from an excluded dummy service account. +- a continuous SMB client that exercises reads, writes, renames, and deletions, including activity from an excluded dummy service account. The preview starts with group, Private, and FSLogix data. The client keeps current activity moving, while a real backup runs immediately and repeats in the background. Open the URL and use the credentials printed by the launcher. Defaults are: @@ -282,7 +282,7 @@ Open `https:///` after setup. Only members of the group identified The console is intentionally operational and plain: - **Overview**: current capacity totals, active group count, recent activity, and backup state. -- **File shares**: one selectable tree per active `FS_*` group/folder with recursively expanded user and nested-group membership, cycle markers, and a live filter. +- **File shares**: one selectable tree per active `FS_*` group/folder with recursively expanded user, nested-group, and AD primary-group membership, cycle markers, and a live filter. - **Data usage**: cached recursive size of every top-level `/Data` group folder. - **User usage**: per-user `/Private + /FSLogix` totals with component sizes. - **Activity**: dynamic date, user, share, action, result, and path filters with pagination. @@ -507,17 +507,21 @@ docker compose exec samba python3 -m json.tool /state/backup-status.json docker compose exec samba tail -n 100 /var/log/reconcile.log ``` -### Nested Data group access fails +### Nested or primary Data group access fails - Check reconciliation logs for detected group cycles or unresolved nested members. -- Verify winbind can resolve every nested group to a local GID: +- The reconciler resolves group SIDs to GIDs first, so localized names such as `Domänen-Benutzer` do not affect ACL generation. +- Verify the SID mapping, the user's primary/supplementary groups, and the resulting ACL: ```bash - docker compose exec samba getent group 'EXAMPLE\NestedGroup' + docker compose exec samba wbinfo --name-to-sid 'EXAMPLE\Domänen-Benutzer' + docker compose exec samba wbinfo --sid-to-gid S-1-5-21-...-513 docker compose exec samba id 'EXAMPLE\alice' docker compose exec samba getfacl /data/groups/data/ ``` +Users whose group is represented only by AD `primaryGroupID` are included automatically; they do not need to appear in the group's LDAP `member` attribute. + ### Data folder permissions are incorrect - Normal reconciliation avoids walking every file when the resolved ACL signature is unchanged. diff --git a/app/reconcile_shares.py b/app/reconcile_shares.py index c981650..d0be667 100755 --- a/app/reconcile_shares.py +++ b/app/reconcile_shares.py @@ -34,6 +34,7 @@ GROUP_TITLE_ATTRS = ("displayname", "name", "cn") USER_STATUS_FILTER = "(&(objectClass=user)(!(objectClass=computer))(sAMAccountName=*))" GROUP_SEARCH_ATTRS = [ "objectGUID", + "objectSid", "sAMAccountName", "displayName", "name", @@ -45,6 +46,7 @@ GROUP_SEARCH_ATTRS = [ ] NESTED_GROUP_SEARCH_ATTRS = [ "objectGUID", + "objectSid", "sAMAccountName", "distinguishedName", "memberOf", @@ -89,6 +91,7 @@ NestedGroupLookup = Callable[[str], Dict[str, Principal]] @dataclass class MembershipExpansion: group_sams: List[str] = field(default_factory=list) + group_sids: Dict[str, str] = field(default_factory=dict) unresolved_dns: List[str] = field(default_factory=list) cycle_paths: List[List[str]] = field(default_factory=list) @@ -125,6 +128,36 @@ def parse_guid(raw_value: str, is_b64: bool) -> str: return str(uuid.UUID(candidate)) +def parse_sid(raw_value: str, is_b64: bool) -> str: + if not is_b64: + candidate = raw_value.strip() + if re.fullmatch(r"S-\d+(?:-\d+)+", candidate, re.IGNORECASE): + return candidate.upper() + raise ValueError("objectSid is not a textual SID") + + raw = base64.b64decode(raw_value) + if len(raw) < 8: + raise ValueError("objectSid is too short") + revision = raw[0] + subauthority_count = raw[1] + expected_length = 8 + subauthority_count * 4 + if len(raw) != expected_length: + raise ValueError("objectSid has invalid binary length") + authority = int.from_bytes(raw[2:8], "big") + subauthorities = [ + int.from_bytes(raw[offset : offset + 4], "little") + for offset in range(8, expected_length, 4) + ] + return "-".join([f"S-{revision}", str(authority), *map(str, subauthorities)]) + + +def sid_rid(sid: str) -> Optional[int]: + try: + return int(sid.rsplit("-", 1)[1]) + except (AttributeError, IndexError, ValueError): + return None + + def format_duration(seconds: float) -> str: if seconds >= 1: return f"{seconds:.1f}s" @@ -286,10 +319,19 @@ def parse_principal_from_entry(entry: LdapEntry) -> Optional[Principal]: except ValueError: guid = "" + sid = "" + sid_raw = ldap_first_raw(entry, "objectSid") + if sid_raw is not None: + try: + sid = parse_sid(sid_raw[0].strip(), sid_raw[1]) + except ValueError: + sid = "" + object_classes = {value.lower() for value in ldap_values(entry, "objectClass")} return { "dn": dn, "objectGUID": guid, + "objectSid": sid, "samAccountName": ldap_first(entry, "sAMAccountName") or "", "memberDns": ldap_values(entry, "member"), "memberOfDns": ldap_values(entry, "memberOf"), @@ -310,6 +352,13 @@ def parse_groups_from_ldap_entries(entries: List[LdapEntry]) -> List[Dict[str, o continue guid = parse_guid(guid_raw[0].strip(), guid_raw[1]) + sid = "" + sid_raw = ldap_first_raw(entry, "objectSid") + if sid_raw is not None: + try: + sid = parse_sid(sid_raw[0].strip(), sid_raw[1]) + except ValueError: + sid = "" object_classes = {value.lower() for value in ldap_values(entry, "objectClass")} if not object_classes: object_classes = {"group"} @@ -317,6 +366,7 @@ def parse_groups_from_ldap_entries(entries: List[LdapEntry]) -> List[Dict[str, o groups.append( { "objectGUID": guid, + "objectSid": sid, "samAccountName": sam, "shareName": share_name, "distinguishedName": entry_dn(entry), @@ -511,6 +561,7 @@ def group_to_principal(group: Dict[str, object]) -> Principal: return { "dn": str(group.get("distinguishedName") or ""), "objectGUID": str(group.get("objectGUID") or ""), + "objectSid": str(group.get("objectSid") or ""), "samAccountName": str(group.get("samAccountName") or ""), "memberDns": [str(dn) for dn in group.get("memberDns", []) if str(dn).strip()], "memberOfDns": [ @@ -549,6 +600,16 @@ def build_nested_groups_filter(root_dn: str) -> str: ) +def build_primary_group_users_filter(rids: Iterable[int]) -> str: + values = sorted({int(rid) for rid in rids if int(rid) >= 0}) + if not values: + raise ValueError("At least one primary group RID is required") + rid_filter = "".join(f"(primaryGroupID={rid})" for rid in values) + if len(values) > 1: + rid_filter = f"(|{rid_filter})" + return f"(&(objectClass=user)(!(objectClass=computer)){rid_filter})" + + def lookup_nested_group_principals(root_dn: str) -> Dict[str, Principal]: if not root_dn.strip(): return {} @@ -652,6 +713,9 @@ def expand_group_membership( if sam_key not in seen_sams: seen_sams.add(sam_key) expansion.group_sams.append(sam) + sid = str(principal.get("objectSid") or "").strip() + if sid: + expansion.group_sids[sam_key] = sid expansion.cycle_paths = find_group_cycle_paths(all_groups) return expansion @@ -956,22 +1020,29 @@ def resolve_group_gids_for_acl( workgroup: str, group_names: List[str], resolver: Callable[[str, str], Optional[int]] = resolve_group_gid_flexible, + *, + group_sids: Optional[List[str]] = None, + sid_resolver: Callable[[str], Optional[int]] = resolve_gid_from_sid, ) -> Tuple[List[int], List[str]]: gids: List[int] = [] unresolved: List[str] = [] seen_gids: Set[int] = set() seen_names: Set[str] = set() + sids = group_sids or [] - for group_name in group_names: + for index, group_name in enumerate(group_names): name = group_name.strip() name_key = name.casefold() if not name or name_key in seen_names: continue seen_names.add(name_key) - gid = resolver(workgroup, name) + sid = sids[index].strip() if index < len(sids) else "" + gid = sid_resolver(sid) if sid else None if gid is None: - unresolved.append(name) + gid = resolver(workgroup, name) + if gid is None: + unresolved.append(f"{name} ({sid})" if sid else name) continue if gid not in seen_gids: seen_gids.add(gid) @@ -1406,9 +1477,18 @@ def sync_dynamic_directory_permissions( continue acl_group_names = [sam, *expansion.group_sams] + acl_group_sids = [ + str(ad_group.get("objectSid") or ""), + *[ + expansion.group_sids.get(group_name.casefold(), "") + for group_name in expansion.group_sams + ], + ] gid_started = time.monotonic() acl_group_gids, unresolved_groups = resolve_group_gids_for_acl( - workgroup, acl_group_names + workgroup, + acl_group_names, + group_sids=acl_group_sids, ) gid_elapsed = time.monotonic() - gid_started if unresolved_groups or not acl_group_gids: diff --git a/app/web/app.js b/app/web/app.js index 69aa8ad..4eb84d0 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -24,7 +24,7 @@ const utcTime = value => { const pad = number => String(number).padStart(2, "0"); return `${pad(date.getUTCDate())}.${pad(date.getUTCMonth() + 1)}.${date.getUTCFullYear()} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} UTC`; }; -const actionLabel = value => ({read: "Lesen", write: "Schreiben", move: "Umbenennen/Verschieben", delete: "Löschen"}[value] || value || "—"); +const actionLabel = value => ({read: "Lesen", write: "Schreiben", move: "Umbenennen", delete: "Löschen"}[value] || value || "—"); const nodeTypeLabel = value => ({group: "Gruppe", user: "Benutzer", computer: "Computer", unknown: "Unbekannt"}[value] || value); const backupStateLabel = value => ({starting: "Startet", running: "Läuft", completed: "Abgeschlossen", failed: "Fehlgeschlagen", waiting: "Wartet", disabled: "Deaktiviert"}[value] || value || "Unbekannt"); function backupMessage(data) { @@ -186,12 +186,12 @@ async function renderShares() { const data = await api("/api/groups"); state.groups = data; const groups = data.groups || []; - content.innerHTML = pageHead("Dateifreigaben", "FS_*-Ordnergruppen und ihre wirksamen verschachtelten Mitgliedschaften.", `Verzeichnisstand ${esc(utcTime(data.fetchedAt))}`) + ` + content.innerHTML = `
` + pageHead("Dateifreigaben", "FS_*-Ordnergruppen und ihre wirksamen verschachtelten Mitgliedschaften.", `Verzeichnisstand ${esc(utcTime(data.fetchedAt))}`) + ` ${data.truncated ? `

${badge("Ergebnis gekürzt", "warn")} WEB_MAX_GROUP_NODES erhöhen, um alle Mitglieder anzuzeigen.

` : ""} -
+

Gruppenordner

${groups.length}
    -
    `; +
    `; const list = document.querySelector("#group-list"); const tree = document.querySelector("#tree-panel"); let selected = groups[0] || null; @@ -230,14 +230,14 @@ async function renderStorage(type) { async function renderActivity() { const today = new Date(); const yesterday = new Date(Date.now() - 86400000); - content.innerHTML = pageHead("Aktivitätsprotokoll", "Lese-, Schreib-, Umbenennungs-/Verschiebe- und Löschvorgänge nach Datum, Identität, Freigabe, Ergebnis oder Pfad durchsuchen.") + ` + content.innerHTML = pageHead("Aktivitätsprotokoll", "Lese-, Schreib-, Umbenennungs- und Löschvorgänge nach Datum, Identität, Freigabe, Ergebnis oder Pfad durchsuchen.") + `
    - + diff --git a/app/web/styles.css b/app/web/styles.css index 234cc78..5df86c8 100644 --- a/app/web/styles.css +++ b/app/web/styles.css @@ -44,6 +44,12 @@ nav a.active { font-weight: bold; background: #ddd; } .panel-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: .7rem; } .panel-head h2 { margin: 0; } .split { display: grid; grid-template-columns: minmax(260px, .7fr) minmax(380px, 1.3fr); gap: 1rem; } +.shares-view { display: flex; height: calc(100vh - 4.5rem); height: calc(100dvh - 4.5rem); min-height: 0; flex-direction: column; } +.shares-split { min-height: 0; flex: 1; } +.shares-split > .panel { display: flex; min-height: 0; margin-bottom: 0; flex-direction: column; } +.shares-split .list { min-height: 0; flex: 1; overflow-y: auto; } +.shares-split #tree-panel { display: flex; height: 100%; min-height: 0; flex-direction: column; } +.shares-split .tree { min-height: 0; max-height: none; flex: 1; overflow-y: auto; } .filters { display: grid; grid-template-columns: repeat(6, minmax(110px, 1fr)); gap: .6rem; margin-bottom: 1rem; align-items: end; } .filters .wide { grid-column: span 2; } .table-wrap { width: 100%; overflow-x: auto; } @@ -82,6 +88,7 @@ progress { width: 100%; } .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } .filters { grid-template-columns: repeat(3, minmax(120px, 1fr)); } .split { grid-template-columns: 1fr; } + .shares-split { grid-template-rows: repeat(2, minmax(0, 1fr)); } } @media (max-width: 700px) { .sidebar { display: none; z-index: 5; } diff --git a/app/web_ui.py b/app/web_ui.py index 8e79e35..726c9c8 100644 --- a/app/web_ui.py +++ b/app/web_ui.py @@ -438,8 +438,8 @@ def display_name(entry) -> str: class DirectoryCache: ATTRS = [ - "objectGUID", "distinguishedName", "sAMAccountName", "displayName", "cn", - "objectClass", "member", + "objectGUID", "objectSid", "distinguishedName", "sAMAccountName", + "displayName", "cn", "objectClass", "member", "primaryGroupID", ] def __init__(self): @@ -487,6 +487,64 @@ class DirectoryCache: 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 = {} try: conn = connect_state_db(STATE_DB, read_only=True) @@ -521,13 +579,22 @@ class DirectoryCache: next_ancestors = {*ancestors, key} node["members"] = [ make_node(child, next_ancestors) - for child in directory.ldap_values(entry, "member") + 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()): - members = [make_node(str(dn), set()) for dn in root.get("memberDns", [])] + 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() @@ -555,7 +622,7 @@ class DirectoryCache: return { "groups": group_rows, "fetchedAt": now_utc().isoformat(timespec="seconds"), - "truncated": bool(pending), + "truncated": bool(pending) or primary_members_truncated, } diff --git a/dev/ad-entrypoint.sh b/dev/ad-entrypoint.sh index 165e2c3..f74eeb6 100755 --- a/dev/ad-entrypoint.sh +++ b/dev/ad-entrypoint.sh @@ -97,6 +97,7 @@ ensure_user bob "$AD_USER_PASSWORD" Bob Brown ensure_user carol "$AD_USER_PASSWORD" Carol Clark ensure_user dave "$AD_USER_PASSWORD" Dave Davis ensure_user eve "$AD_USER_PASSWORD" Eve Evans +ensure_user frank "$AD_USER_PASSWORD" Frank Foster ensure_user "$AD_WEB_ADMIN_USER" "$AD_WEB_ADMIN_PASSWORD" Preview Administrator ensure_user report_svc "$AD_USER_PASSWORD" Report Service @@ -115,6 +116,7 @@ add_members FS_Finance 'Finance_Analysts,bob,report_svc' add_members Engineering_Leads carol add_members FS_Engineering 'Engineering_Leads,dave' add_members FS_Projects 'FS_Finance,FS_Engineering,eve' +add_members FS_Projects 'Domain Users' add_members 'Domain Admins' "$AD_WEB_ADMIN_USER" add_dns_record() { diff --git a/dev/e2e.py b/dev/e2e.py index c4d0500..56dcbd9 100755 --- a/dev/e2e.py +++ b/dev/e2e.py @@ -227,7 +227,8 @@ def main() -> int: check(any(kind == "group" and sam == "Finance_Analysts" for kind, sam, _ in finance_nodes), "nested Finance Analysts group missing") check({"alice", "bob"}.issubset({sam for kind, sam, _ in finance_nodes if kind == "user"}), "Finance users missing") project_nodes = flatten_members(groups["Projects"].get("members", [])) - check({"alice", "bob", "carol", "dave", "eve"}.issubset({sam for kind, sam, _ in project_nodes if kind == "user"}), "transitive Project membership is incomplete") + check({"alice", "bob", "carol", "dave", "eve", "frank"}.issubset({sam for kind, sam, _ in project_nodes if kind == "user"}), "transitive or primary-group Project membership is incomplete") + check(any(kind == "group" and sam == "Domain Users" for kind, sam, _ in project_nodes), "nested Domain Users group missing") announce("SMB authorization and real share reads/writes") alice_access = engine_run( @@ -242,6 +243,12 @@ def main() -> int: check_result=False, ) check(dave_denied.returncode != 0, "unrelated user Dave can access Finance") + frank_projects = engine_run( + "exec", CLIENT_CONTAINER, "smbclient", f"//files.{DNS_DOMAIN}/Data", "-m", "SMB3", + "-U", f"{WORKGROUP}\\frank%{USER_PASSWORD}", "-c", "cd Projects; ls", + check_result=False, + ) + check(frank_projects.returncode == 0, "primary Domain Users membership did not grant Projects access") admin_access = engine_run( "exec", CLIENT_CONTAINER, "smbclient", f"//files.{DNS_DOMAIN}/Data", "-m", "SMB3", "-U", f"{WORKGROUP}\\{ADMIN_USER}%{ADMIN_PASSWORD}", "-c", "cd Finance; ls", diff --git a/tests/test_reconcile_shares.py b/tests/test_reconcile_shares.py index b87786b..fc29dc9 100644 --- a/tests/test_reconcile_shares.py +++ b/tests/test_reconcile_shares.py @@ -18,10 +18,11 @@ USER_3_DN = "CN=Carol,OU=Users,DC=example,DC=com" COMPUTER_DN = "CN=PC01,OU=Computers,DC=example,DC=com" -def principal(dn, sam, classes, members=(), member_of=()): +def principal(dn, sam, classes, members=(), member_of=(), sid=""): return { "dn": dn, "objectGUID": "", + "objectSid": sid, "samAccountName": sam, "objectClasses": set(classes), "memberDns": list(members), @@ -40,6 +41,22 @@ class GroupFolderNameTests(unittest.TestCase): self.assertEqual(rs.sanitize_group_folder_name("Finance."), "Finance") +class SidParsingTests(unittest.TestCase): + def test_parses_textual_and_binary_group_sid(self): + expected = "S-1-5-21-111-222-333-513" + raw = bytes((1, 5)) + (5).to_bytes(6, "big") + b"".join( + value.to_bytes(4, "little") + for value in (21, 111, 222, 333, 513) + ) + + self.assertEqual(rs.parse_sid(expected.lower(), False), expected) + self.assertEqual( + rs.parse_sid(base64.b64encode(raw).decode("ascii"), True), + expected, + ) + self.assertEqual(rs.sid_rid(expected), 513) + + class LdapParsingTests(unittest.TestCase): def test_parser_keeps_repeated_members_and_unfolds_lines(self): display_name = base64.b64encode(b"Data Folder").decode("ascii") @@ -75,6 +92,7 @@ member;range=2-2: CN=Ranged,OU=Groups,DC=example,DC=com output = f""" dn: {ROOT_DN} objectGUID: 550e8400-e29b-41d4-a716-446655440000 +objectSid: S-1-5-21-111-222-333-1001 objectClass: group sAMAccountName: FS_Data displayName: Data Folder @@ -88,6 +106,7 @@ memberOf: CN=Parent,OU=Groups,DC=example,DC=com self.assertEqual(len(groups), 1) self.assertEqual(groups[0]["samAccountName"], "FS_Data") + self.assertEqual(groups[0]["objectSid"], "S-1-5-21-111-222-333-1001") self.assertEqual(groups[0]["shareName"], "Data Folder") self.assertEqual(groups[0]["distinguishedName"], ROOT_DN) self.assertEqual(groups[0]["memberDns"], [GROUP_A_DN]) @@ -111,6 +130,13 @@ memberOf: CN=Parent,OU=Groups,DC=example,DC=com f"(memberOf:{rs.LDAP_MATCHING_RULE_IN_CHAIN}:={ROOT_DN}))", ) + def test_primary_group_filter_is_numeric_and_deduplicated(self): + self.assertEqual( + rs.build_primary_group_users_filter([513, 513, 515]), + "(&(objectClass=user)(!(objectClass=computer))" + "(|(primaryGroupID=513)(primaryGroupID=515)))", + ) + class MembershipExpansionTests(unittest.TestCase): def lookup_from(self, principals): @@ -137,7 +163,7 @@ class MembershipExpansionTests(unittest.TestCase): } lookup = self.lookup_from( [ - principal(GROUP_A_DN, "GroupA", {"group"}, member_of=[ROOT_DN]), + principal(GROUP_A_DN, "GroupA", {"group"}, member_of=[ROOT_DN], sid="S-1-5-21-1-2-3-1101"), principal(GROUP_B_DN, "GroupB", {"group"}, member_of=[GROUP_A_DN]), ] ) @@ -145,6 +171,10 @@ class MembershipExpansionTests(unittest.TestCase): expansion = rs.expand_group_membership(root_group, lookup_func=lookup) self.assertEqual(expansion.group_sams, ["GroupA", "GroupB"]) + self.assertEqual( + expansion.group_sids, + {"groupa": "S-1-5-21-1-2-3-1101"}, + ) self.assertEqual(expansion.unresolved_dns, []) self.assertEqual(expansion.cycle_paths, [["FS_Data", "GroupA", "FS_Data"]]) @@ -202,6 +232,31 @@ class MembershipExpansionTests(unittest.TestCase): class AclResolutionTests(unittest.TestCase): + def test_sid_resolution_handles_localized_primary_group_name(self): + names_seen = [] + + def name_resolver(workgroup, group_name): + names_seen.append((workgroup, group_name)) + return None + + gids, unresolved = rs.resolve_group_gids_for_acl( + "BEISPIEL", + ["FS_Daten", "Domänen-Benutzer"], + name_resolver, + group_sids=[ + "S-1-5-21-111-222-333-1200", + "S-1-5-21-111-222-333-513", + ], + sid_resolver=lambda sid: { + "S-1-5-21-111-222-333-1200": 11200, + "S-1-5-21-111-222-333-513": 10513, + }.get(sid), + ) + + self.assertEqual(gids, [11200, 10513]) + self.assertEqual(unresolved, []) + self.assertEqual(names_seen, []) + def test_gid_resolution_preserves_order_dedupes_and_reports_missing(self): mapping = {"FS_Data": 1001, "Nested": 1002} diff --git a/tests/test_web_ui.py b/tests/test_web_ui.py index 4190677..8d92596 100644 --- a/tests/test_web_ui.py +++ b/tests/test_web_ui.py @@ -65,6 +65,55 @@ class DomainAuthenticationTests(unittest.TestCase): self.assertNotIn("p@ss word", run.call_args_list[0].args[0]) +class DirectoryPrimaryGroupTests(unittest.TestCase): + def test_tree_expands_users_whose_primary_group_is_nested(self): + domain_users_dn = "CN=Domänen-Benutzer,CN=Users,DC=example,DC=com" + frank_dn = "CN=Frank,CN=Users,DC=example,DC=com" + root = { + "objectGUID": "root-guid", + "objectSid": "S-1-5-21-111-222-333-1200", + "samAccountName": "FS_Alle", + "shareName": "Alle", + "distinguishedName": "CN=FS_Alle,CN=Users,DC=example,DC=com", + "memberDns": [domain_users_dn], + "objectClasses": {"group"}, + } + domain_users = { + "distinguishedname": [(domain_users_dn, False)], + "objectsid": [("S-1-5-21-111-222-333-513", False)], + "samaccountname": [("Domänen-Benutzer", False)], + "displayname": [("Domänen-Benutzer", False)], + "objectclass": [("group", False)], + } + frank = { + "distinguishedname": [(frank_dn, False)], + "samaccountname": [("frank", False)], + "displayname": [("Frank", False)], + "primarygroupid": [("513", False)], + "objectclass": [("user", False)], + } + + with tempfile.TemporaryDirectory() as tmpdir, mock.patch.object( + web_ui, "STATE_DB", os.path.join(tmpdir, "missing.db") + ), mock.patch.object( + web_ui.directory, "fetch_fileshare_groups", return_value=[root] + ), mock.patch.object( + web_ui.directory, + "search_directory_entries", + side_effect=[[domain_users], [frank]], + ): + result = web_ui.DirectoryCache().fetch() + + nested_group = result["groups"][0]["members"][0] + self.assertEqual(nested_group["sam"], "Domänen-Benutzer") + self.assertEqual( + [member["sam"] for member in nested_group["members"]], + ["frank"], + ) + self.assertEqual(result["groups"][0]["userCount"], 1) + self.assertFalse(result["truncated"]) + + class AuditParsingTests(unittest.TestCase): def test_parses_full_audit_record(self): line = ( @@ -420,12 +469,18 @@ class WebPresentationTests(unittest.TestCase): self.assertNotIn("localTime", script) self.assertIn('class="timestamp"', script) self.assertIn(".timestamp { text-align: left;", css) - self.assertIn("Umbenennen/Verschieben", script) + self.assertIn("Umbenennen", script) + self.assertNotIn("Umbenennen/Verschieben", script) self.assertIn("Löschen", script) self.assertNotIn(">Auflisten<", script) self.assertNotIn(">Metadaten<", script) self.assertNotIn(">Sitzung<", script) self.assertNotIn('name="operation"', script) + self.assertIn('class="shares-view"', script) + self.assertIn('class="split shares-split"', script) + self.assertIn(".shares-view { display: flex; height: calc(100vh - 4.5rem);", css) + self.assertIn(".shares-split .list", css) + self.assertIn(".shares-split .tree", css) def test_samba_audits_only_supported_file_operations(self): path = os.path.join(os.path.dirname(__file__), "..", "etc", "samba", "smb.conf")