SIDs instead of names for groups
This commit is contained in:
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user