SIDs instead of names for groups

This commit is contained in:
Ludwig Lehnert
2026-08-01 05:11:17 +00:00
parent fdd5649198
commit 69eacc14f3
9 changed files with 303 additions and 26 deletions
+57 -2
View File
@@ -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}
+56 -1
View File
@@ -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")