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
+11 -7
View File
@@ -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/<groupName>`).
- 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/<groupName>`
@@ -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://<WEB_HOSTNAME>/` 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/<groupName>
```
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.
+84 -4
View File
@@ -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:
+6 -6
View File
@@ -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.", `<span class="muted">Verzeichnisstand ${esc(utcTime(data.fetchedAt))}</span>`) + `
content.innerHTML = `<div class="shares-view">` + pageHead("Dateifreigaben", "FS_*-Ordnergruppen und ihre wirksamen verschachtelten Mitgliedschaften.", `<span class="muted">Verzeichnisstand ${esc(utcTime(data.fetchedAt))}</span>`) + `
${data.truncated ? `<p>${badge("Ergebnis gekürzt", "warn")} WEB_MAX_GROUP_NODES erhöhen, um alle Mitglieder anzuzeigen.</p>` : ""}
<section class="split">
<section class="split shares-split">
<article class="panel"><div class="panel-head"><h2>Gruppenordner</h2><span>${groups.length}</span></div><input id="group-filter" type="search" placeholder="Gruppen oder Mitglieder filtern"><ul id="group-list" class="list"></ul></article>
<article class="panel"><div id="tree-panel"></div></article>
</section>`;
</section></div>`;
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.") + `
<section class="panel">
<form id="activity-filter" class="filters">
<label>Von (UTC)<input name="from" type="date" value="${isoDay(yesterday)}" required></label>
<label>Bis (UTC)<input name="to" type="date" value="${isoDay(today)}" required></label>
<label>Benutzer<input name="user" list="users-list" placeholder="Alle Benutzer"></label>
<label>Freigabe<input name="share" list="shares-list" placeholder="Alle Freigaben"></label>
<label>Aktion<select name="action"><option value="">Alle Aktionen</option><option value="read">Lesen</option><option value="write">Schreiben</option><option value="move">Umbenennen/Verschieben</option><option value="delete">Löschen</option></select></label>
<label>Aktion<select name="action"><option value="">Alle Aktionen</option><option value="read">Lesen</option><option value="write">Schreiben</option><option value="move">Umbenennen</option><option value="delete">Löschen</option></select></label>
<label>Ergebnis<select name="result"><option value="">Alle Ergebnisse</option><option value="OK">Erfolgreich</option><option value="fail">Fehlgeschlagen</option></select></label>
<label class="wide">Pfad enthält<input name="path" placeholder="Ordner oder Dateiname"></label>
<button type="submit">Filter anwenden</button>
+7
View File
@@ -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; }
+72 -5
View File
@@ -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,
}
+2
View File
@@ -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() {
+8 -1
View File
@@ -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",
+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")