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\Data` -> `/data/groups/data`
- `\\server\FSLogix` -> `/data/fslogix` - `\\server\FSLogix` -> `/data/fslogix`
- FS_* groups are projected as folders inside the Data share (`/data/groups/data/<groupName>`). - 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 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: - Group folders are name-based while active and moved to archive on deactivation:
- active: `/data/groups/data/<groupName>` - 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. - 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`). - `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. - 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 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. - 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. - 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`; - 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; - an authenticated rsync daemon used by the normal backup implementation;
- the actual file-server image, joined to the dummy domain; - 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: 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: The console is intentionally operational and plain:
- **Overview**: current capacity totals, active group count, recent activity, and backup state. - **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. - **Data usage**: cached recursive size of every top-level `/Data` group folder.
- **User usage**: per-user `/Private + /FSLogix` totals with component sizes. - **User usage**: per-user `/Private + /FSLogix` totals with component sizes.
- **Activity**: dynamic date, user, share, action, result, and path filters with pagination. - **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 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. - 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 ```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 id 'EXAMPLE\alice'
docker compose exec samba getfacl /data/groups/data/<groupName> 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 ### Data folder permissions are incorrect
- Normal reconciliation avoids walking every file when the resolved ACL signature is unchanged. - 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=*))" USER_STATUS_FILTER = "(&(objectClass=user)(!(objectClass=computer))(sAMAccountName=*))"
GROUP_SEARCH_ATTRS = [ GROUP_SEARCH_ATTRS = [
"objectGUID", "objectGUID",
"objectSid",
"sAMAccountName", "sAMAccountName",
"displayName", "displayName",
"name", "name",
@@ -45,6 +46,7 @@ GROUP_SEARCH_ATTRS = [
] ]
NESTED_GROUP_SEARCH_ATTRS = [ NESTED_GROUP_SEARCH_ATTRS = [
"objectGUID", "objectGUID",
"objectSid",
"sAMAccountName", "sAMAccountName",
"distinguishedName", "distinguishedName",
"memberOf", "memberOf",
@@ -89,6 +91,7 @@ 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)
group_sids: Dict[str, str] = field(default_factory=dict)
unresolved_dns: List[str] = field(default_factory=list) unresolved_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)
@@ -125,6 +128,36 @@ def parse_guid(raw_value: str, is_b64: bool) -> str:
return str(uuid.UUID(candidate)) 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: def format_duration(seconds: float) -> str:
if seconds >= 1: if seconds >= 1:
return f"{seconds:.1f}s" return f"{seconds:.1f}s"
@@ -286,10 +319,19 @@ def parse_principal_from_entry(entry: LdapEntry) -> Optional[Principal]:
except ValueError: except ValueError:
guid = "" 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")} object_classes = {value.lower() for value in ldap_values(entry, "objectClass")}
return { return {
"dn": dn, "dn": dn,
"objectGUID": guid, "objectGUID": guid,
"objectSid": sid,
"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"), "memberOfDns": ldap_values(entry, "memberOf"),
@@ -310,6 +352,13 @@ def parse_groups_from_ldap_entries(entries: List[LdapEntry]) -> List[Dict[str, o
continue continue
guid = parse_guid(guid_raw[0].strip(), guid_raw[1]) 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")} object_classes = {value.lower() for value in ldap_values(entry, "objectClass")}
if not object_classes: if not object_classes:
object_classes = {"group"} object_classes = {"group"}
@@ -317,6 +366,7 @@ def parse_groups_from_ldap_entries(entries: List[LdapEntry]) -> List[Dict[str, o
groups.append( groups.append(
{ {
"objectGUID": guid, "objectGUID": guid,
"objectSid": sid,
"samAccountName": sam, "samAccountName": sam,
"shareName": share_name, "shareName": share_name,
"distinguishedName": entry_dn(entry), "distinguishedName": entry_dn(entry),
@@ -511,6 +561,7 @@ def group_to_principal(group: Dict[str, object]) -> Principal:
return { return {
"dn": str(group.get("distinguishedName") or ""), "dn": str(group.get("distinguishedName") or ""),
"objectGUID": str(group.get("objectGUID") or ""), "objectGUID": str(group.get("objectGUID") or ""),
"objectSid": str(group.get("objectSid") 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": [ "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]: def lookup_nested_group_principals(root_dn: str) -> Dict[str, Principal]:
if not root_dn.strip(): if not root_dn.strip():
return {} return {}
@@ -652,6 +713,9 @@ def expand_group_membership(
if sam_key not in seen_sams: if sam_key not in seen_sams:
seen_sams.add(sam_key) seen_sams.add(sam_key)
expansion.group_sams.append(sam) 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) expansion.cycle_paths = find_group_cycle_paths(all_groups)
return expansion return expansion
@@ -956,22 +1020,29 @@ def resolve_group_gids_for_acl(
workgroup: str, workgroup: str,
group_names: List[str], group_names: List[str],
resolver: Callable[[str, str], Optional[int]] = resolve_group_gid_flexible, 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]]: ) -> Tuple[List[int], List[str]]:
gids: List[int] = [] gids: List[int] = []
unresolved: List[str] = [] unresolved: List[str] = []
seen_gids: Set[int] = set() seen_gids: Set[int] = set()
seen_names: Set[str] = 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 = group_name.strip()
name_key = name.casefold() name_key = name.casefold()
if not name or name_key in seen_names: if not name or name_key in seen_names:
continue continue
seen_names.add(name_key) 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: 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 continue
if gid not in seen_gids: if gid not in seen_gids:
seen_gids.add(gid) seen_gids.add(gid)
@@ -1406,9 +1477,18 @@ def sync_dynamic_directory_permissions(
continue continue
acl_group_names = [sam, *expansion.group_sams] 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() 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,
group_sids=acl_group_sids,
) )
gid_elapsed = time.monotonic() - gid_started gid_elapsed = time.monotonic() - gid_started
if unresolved_groups or not acl_group_gids: 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"); 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`; 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 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"); const backupStateLabel = value => ({starting: "Startet", running: "Läuft", completed: "Abgeschlossen", failed: "Fehlgeschlagen", waiting: "Wartet", disabled: "Deaktiviert"}[value] || value || "Unbekannt");
function backupMessage(data) { function backupMessage(data) {
@@ -186,12 +186,12 @@ async function renderShares() {
const data = await api("/api/groups"); const data = await api("/api/groups");
state.groups = data; state.groups = data;
const groups = data.groups || []; 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>` : ""} ${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 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> <article class="panel"><div id="tree-panel"></div></article>
</section>`; </section></div>`;
const list = document.querySelector("#group-list"); const list = document.querySelector("#group-list");
const tree = document.querySelector("#tree-panel"); const tree = document.querySelector("#tree-panel");
let selected = groups[0] || null; let selected = groups[0] || null;
@@ -230,14 +230,14 @@ async function renderStorage(type) {
async function renderActivity() { async function renderActivity() {
const today = new Date(); const today = new Date();
const yesterday = new Date(Date.now() - 86400000); 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"> <section class="panel">
<form id="activity-filter" class="filters"> <form id="activity-filter" class="filters">
<label>Von (UTC)<input name="from" type="date" value="${isoDay(yesterday)}" required></label> <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>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>Benutzer<input name="user" list="users-list" placeholder="Alle Benutzer"></label>
<label>Freigabe<input name="share" list="shares-list" placeholder="Alle Freigaben"></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>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> <label class="wide">Pfad enthält<input name="path" placeholder="Ordner oder Dateiname"></label>
<button type="submit">Filter anwenden</button> <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 { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: .7rem; }
.panel-head h2 { margin: 0; } .panel-head h2 { margin: 0; }
.split { display: grid; grid-template-columns: minmax(260px, .7fr) minmax(380px, 1.3fr); gap: 1rem; } .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 { display: grid; grid-template-columns: repeat(6, minmax(110px, 1fr)); gap: .6rem; margin-bottom: 1rem; align-items: end; }
.filters .wide { grid-column: span 2; } .filters .wide { grid-column: span 2; }
.table-wrap { width: 100%; overflow-x: auto; } .table-wrap { width: 100%; overflow-x: auto; }
@@ -82,6 +88,7 @@ progress { width: 100%; }
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.filters { grid-template-columns: repeat(3, minmax(120px, 1fr)); } .filters { grid-template-columns: repeat(3, minmax(120px, 1fr)); }
.split { grid-template-columns: 1fr; } .split { grid-template-columns: 1fr; }
.shares-split { grid-template-rows: repeat(2, minmax(0, 1fr)); }
} }
@media (max-width: 700px) { @media (max-width: 700px) {
.sidebar { display: none; z-index: 5; } .sidebar { display: none; z-index: 5; }
+72 -5
View File
@@ -438,8 +438,8 @@ def display_name(entry) -> str:
class DirectoryCache: class DirectoryCache:
ATTRS = [ ATTRS = [
"objectGUID", "distinguishedName", "sAMAccountName", "displayName", "cn", "objectGUID", "objectSid", "distinguishedName", "sAMAccountName",
"objectClass", "member", "displayName", "cn", "objectClass", "member", "primaryGroupID",
] ]
def __init__(self): def __init__(self):
@@ -487,6 +487,64 @@ class DirectoryCache:
if "group" in classes: if "group" in classes:
pending.extend(directory.ldap_values(entry, "member")) 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 = {} folder_map = {}
try: try:
conn = connect_state_db(STATE_DB, read_only=True) conn = connect_state_db(STATE_DB, read_only=True)
@@ -521,13 +579,22 @@ class DirectoryCache:
next_ancestors = {*ancestors, key} next_ancestors = {*ancestors, key}
node["members"] = [ node["members"] = [
make_node(child, next_ancestors) 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 return node
group_rows = [] group_rows = []
for root in sorted(roots, key=lambda item: str(item["shareName"]).casefold()): 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_users = set()
flat_groups = set() flat_groups = set()
@@ -555,7 +622,7 @@ class DirectoryCache:
return { return {
"groups": group_rows, "groups": group_rows,
"fetchedAt": now_utc().isoformat(timespec="seconds"), "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 carol "$AD_USER_PASSWORD" Carol Clark
ensure_user dave "$AD_USER_PASSWORD" Dave Davis ensure_user dave "$AD_USER_PASSWORD" Dave Davis
ensure_user eve "$AD_USER_PASSWORD" Eve Evans 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 "$AD_WEB_ADMIN_USER" "$AD_WEB_ADMIN_PASSWORD" Preview Administrator
ensure_user report_svc "$AD_USER_PASSWORD" Report Service 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 Engineering_Leads carol
add_members FS_Engineering 'Engineering_Leads,dave' add_members FS_Engineering 'Engineering_Leads,dave'
add_members FS_Projects 'FS_Finance,FS_Engineering,eve' add_members FS_Projects 'FS_Finance,FS_Engineering,eve'
add_members FS_Projects 'Domain Users'
add_members 'Domain Admins' "$AD_WEB_ADMIN_USER" add_members 'Domain Admins' "$AD_WEB_ADMIN_USER"
add_dns_record() { 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(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") 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", [])) 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") announce("SMB authorization and real share reads/writes")
alice_access = engine_run( alice_access = engine_run(
@@ -242,6 +243,12 @@ def main() -> int:
check_result=False, check_result=False,
) )
check(dave_denied.returncode != 0, "unrelated user Dave can access Finance") 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( admin_access = engine_run(
"exec", CLIENT_CONTAINER, "smbclient", f"//files.{DNS_DOMAIN}/Data", "-m", "SMB3", "exec", CLIENT_CONTAINER, "smbclient", f"//files.{DNS_DOMAIN}/Data", "-m", "SMB3",
"-U", f"{WORKGROUP}\\{ADMIN_USER}%{ADMIN_PASSWORD}", "-c", "cd Finance; ls", "-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" 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 { return {
"dn": dn, "dn": dn,
"objectGUID": "", "objectGUID": "",
"objectSid": sid,
"samAccountName": sam, "samAccountName": sam,
"objectClasses": set(classes), "objectClasses": set(classes),
"memberDns": list(members), "memberDns": list(members),
@@ -40,6 +41,22 @@ class GroupFolderNameTests(unittest.TestCase):
self.assertEqual(rs.sanitize_group_folder_name("Finance."), "Finance") 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): class LdapParsingTests(unittest.TestCase):
def test_parser_keeps_repeated_members_and_unfolds_lines(self): def test_parser_keeps_repeated_members_and_unfolds_lines(self):
display_name = base64.b64encode(b"Data Folder").decode("ascii") 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""" output = f"""
dn: {ROOT_DN} dn: {ROOT_DN}
objectGUID: 550e8400-e29b-41d4-a716-446655440000 objectGUID: 550e8400-e29b-41d4-a716-446655440000
objectSid: S-1-5-21-111-222-333-1001
objectClass: group objectClass: group
sAMAccountName: FS_Data sAMAccountName: FS_Data
displayName: Data Folder displayName: Data Folder
@@ -88,6 +106,7 @@ memberOf: CN=Parent,OU=Groups,DC=example,DC=com
self.assertEqual(len(groups), 1) self.assertEqual(len(groups), 1)
self.assertEqual(groups[0]["samAccountName"], "FS_Data") 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]["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])
@@ -111,6 +130,13 @@ memberOf: CN=Parent,OU=Groups,DC=example,DC=com
f"(memberOf:{rs.LDAP_MATCHING_RULE_IN_CHAIN}:={ROOT_DN}))", 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): class MembershipExpansionTests(unittest.TestCase):
def lookup_from(self, principals): def lookup_from(self, principals):
@@ -137,7 +163,7 @@ class MembershipExpansionTests(unittest.TestCase):
} }
lookup = self.lookup_from( 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]), 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) 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.group_sids,
{"groupa": "S-1-5-21-1-2-3-1101"},
)
self.assertEqual(expansion.unresolved_dns, []) self.assertEqual(expansion.unresolved_dns, [])
self.assertEqual(expansion.cycle_paths, [["FS_Data", "GroupA", "FS_Data"]]) self.assertEqual(expansion.cycle_paths, [["FS_Data", "GroupA", "FS_Data"]])
@@ -202,6 +232,31 @@ class MembershipExpansionTests(unittest.TestCase):
class AclResolutionTests(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): def test_gid_resolution_preserves_order_dedupes_and_reports_missing(self):
mapping = {"FS_Data": 1001, "Nested": 1002} 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]) 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): class AuditParsingTests(unittest.TestCase):
def test_parses_full_audit_record(self): def test_parses_full_audit_record(self):
line = ( line = (
@@ -420,12 +469,18 @@ class WebPresentationTests(unittest.TestCase):
self.assertNotIn("localTime", script) self.assertNotIn("localTime", script)
self.assertIn('class="timestamp"', script) self.assertIn('class="timestamp"', script)
self.assertIn(".timestamp { text-align: left;", css) 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.assertIn("Löschen", script)
self.assertNotIn(">Auflisten<", script) self.assertNotIn(">Auflisten<", script)
self.assertNotIn(">Metadaten<", script) self.assertNotIn(">Metadaten<", script)
self.assertNotIn(">Sitzung<", script) self.assertNotIn(">Sitzung<", script)
self.assertNotIn('name="operation"', 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): def test_samba_audits_only_supported_file_operations(self):
path = os.path.join(os.path.dirname(__file__), "..", "etc", "samba", "smb.conf") path = os.path.join(os.path.dirname(__file__), "..", "etc", "samba", "smb.conf")