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
+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,
}