diff --git a/.env.example b/.env.example index dbb0041..2ce63eb 100644 --- a/.env.example +++ b/.env.example @@ -29,7 +29,6 @@ ACME_HTTP_PORT=80 # WEB_JWT_TTL_SECONDS=28800 # WEB_USAGE_SCAN_INTERVAL_SECONDS=900 # WEB_DIRECTORY_CACHE_SECONDS=300 -# WEB_MAX_GROUP_NODES=10000 # WEB_LOGIN_ATTEMPTS_PER_5_MIN=10 # TRASH_RETENTION_DAYS=7 # LDAP_URI=ldaps://example.com diff --git a/Dockerfile b/Dockerfile index 7864df8..a765352 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,7 @@ RUN apt-get update \ libnss-winbind \ libpam-winbind \ python3 \ + python3-samba \ p7zip-full \ rclone \ rsync \ @@ -31,6 +32,7 @@ COPY app/reconcile_shares.py /app/reconcile_shares.py COPY app/backup_to_destination.py /app/backup_to_destination.py COPY app/audit_policy.py /app/audit_policy.py COPY app/state_db.py /app/state_db.py +COPY app/access_control.py /app/access_control.py COPY app/audit_store.py /app/audit_store.py COPY app/audit_collector.py /app/audit_collector.py COPY app/trash.py /app/trash.py diff --git a/README.md b/README.md index 4a4f434..d765845 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,12 @@ This repository provides a production-oriented Samba file server container that - `\\server\Private` -> `/data/private` - `\\server\Data` -> `/data/groups/data` - `\\server\FSLogix` -> `/data/fslogix` -- FS_* groups are projected as folders inside the Data share (`/data/groups/data/`). -- Data folder ACLs expand nested AD group membership recursively, resolve groups by SID, include `primaryGroupID` membership, and detect group cycles. -- Group records, normalized activity events, collector offsets, and web caches share one SQLite database at `/state/shares.db`. -- Group folders are name-based while active and moved to archive on deactivation: - - active: `/data/groups/data/` - - inactive/deleted groups: `/data/groups/archive/` +- Data folders and individual user permissions are managed in the admin web UI. AD remains the source of user identities and authentication. +- Data access uses Windows ACLs stored in Samba’s protected `/state/data-xattrs.tdb`, with separate read, modify, and delete permissions. +- Folder and permission records, normalized activity events, collector offsets, and web caches share one SQLite database at `/state/shares.db`. +- Managed folders are name-based while active and moved to archive through the admin UI: + - active: `/data/groups/data/` + - archived folders: `/data/groups/archive/` - Samba machine trust/key material is persisted in `/var/lib/samba` to survive container recreation. - Container hostname is fixed (`SAMBA_HOSTNAME`) to keep AD computer identity stable. - In bridge-mode Docker networking, startup can publish the host LAN IP in AD DNS with `AD_DNS_IP`/`AD_DNS_NAME` instead of the container bridge IP. @@ -25,7 +25,7 @@ This repository provides a production-oriented Samba file server container that - Samba `full_audit` records successful and failed reads, writes, renames, and deletions on all three shares; FSLogix events are retained in a separate indexed activity stream. - A collector normalizes those four actions and persists them in indexed SQLite tables; activity is never automatically deleted. - Samba retains deleted files for seven days in per-user recycle repositories on the same data volumes. -- A plain HTTPS administration console provides statistics and logs plus narrowly scoped actions for trash downloads/restores, manual backups, and share reconciliation. It also includes a fully client-side Typst PDF report. +- A plain HTTPS administration console manages Data folders and individual user access, and provides statistics, logs, trash downloads/restores, manual backups, and share reconciliation. It also includes a fully client-side Typst PDF report. - 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. - HTTPS certificates are requested from a configured local Smallstep CA and renewed automatically. Pre-issued certificate files are also supported. - Optional remote backups run when `BACKUP_DESTINATION` and `BACKUP_ARCHIVE_PASSWORD` are configured; each active or archived group folder is uploaded as its own encrypted, non-solid 7z archive. @@ -39,12 +39,32 @@ This repository provides a production-oriented Samba file server container that ## Data Folder Lifecycle -The reconciliation script (`/app/reconcile_shares.py`) enforces these rules: +The **Zugriffsverwaltung** page creates top-level Data folders, assigns a permission level to each existing AD user individually per folder. It can archive folders and restore them without deleting their contents. AD accounts and passwords continue to be administered in AD. -1. New matching `FS_*` group -> insert DB row and create `/data/groups/data/`. -2. Group rename while still matching `FS_*` -> rename/update folder path. -3. Group removed or no longer matching `FS_*` -> set `isActive=0` and move folder to `/data/groups/archive/...`. -4. Previously inactive group returns -> set `isActive=1`, move back into `/data/groups/data/...`. +| Level | Access | +| --- | --- | +| 0 | No access; folder hidden | +| 1 | Read only | +| 2 | Read, create, and edit; no delete | +| 3 | Read, create, edit, and delete | + +No assignment means no access. Each user has an independent level per folder; level 0 revokes access. There are no access groups. Domain Admins retain full access. Only admins can rename or delete a top-level managed folder. + +Level 2 blocks renaming and moving files or directories because SMB requires delete permission for those operations. Applications that save by deleting/replacing a file or renaming a temporary file require level 3. Level 2 still permits overwriting a file’s contents in place. + +Saving applies ACL changes recursively when the effective policy changes and disconnects existing Data connections so clients reopen with the new permissions. Changes record the administrator, timestamp, action, and submitted policy in `access_changes`. The five-minute reconciler repairs folder roots and recovers interrupted access updates; `REPAIR_DATA_ACLS=1` forces a full repair. + +### Upgrading from FS_* groups + +On the first startup, existing active `shares` records are retained. Nested and primary-group memberships are expanded into a snapshot of individual user SIDs, each receiving level 3 on its existing folders. Paths and data are retained. An unresolved group or member stops migration with an error so an incomplete import cannot silently remove access. + +After import, AD group renames, membership changes, deletion, and new `FS_*` groups do not change Data folders or access. Manage subsequent changes in the web UI. User assignments are keyed by SID, so renaming an AD account retains its assignments; recreating an account under the same username does not inherit them. + +Installations using the previous app-local group model are upgraded atomically to individual assignments. The highest prior group grant is retained per user and folder, with existing direct assignments taking precedence, including level 0. Archived folder assignments are preserved. Group tables and group mutation actions are removed. + +A new installation starts without assignments. Existing untracked Data directories are adopted with admin-only access. Previously archived folders remain archived and need explicit permissions before users can access them after restoration. + +The Data share uses Samba Windows ACL checks instead of POSIX ACLs. Keep its data volumes private to the container; direct local or NFS access is outside this permission model. The protected TDB store avoids requiring extra container capabilities. After restoring Data to different filesystem inodes, run reconciliation with `REPAIR_DATA_ACLS=1` to rebuild ACL records. Trash restoration applies the current folder policy before exposing the restored file. ## Shared SQLite State Database @@ -52,7 +72,8 @@ The default database path is `/state/shares.db`; `STATE_DB_PATH` can override it The database contains: -- `shares`: AD group-to-folder lifecycle and ACL reconciliation state; +- `shares`: managed folder lifecycle and ACL reconciliation state; +- `access_users`, `folder_permissions`, `access_settings`, and `access_changes`: managed access rules, cached AD identities, migration state, and administrative change history; - `audit_events`: normalized read, write, move, and delete events; - `audit_sources`: Samba log inode/offset checkpoints; - `audit_event_dedup`: bounded, persistent fingerprints for restart-safe main-read and FSLogix-event deduplication; @@ -70,10 +91,9 @@ Standard SQLite does not provide transparent general-purpose compression, so thi - Existing AD DS domain reachable from the Docker host. - Initial admin credentials with rights to create/reset `FileShare_ServiceAccount` during `./setup`. - `FileShare_ServiceAccount` must be allowed to join computers to the domain (`net ads join`) in your AD policy. -- Dynamic group discovery primarily uses machine-account LDAP (`net ads search -P`); join credentials are only used as a fallback LDAP bind path. -- Group naming convention for Data folder eligibility: - - `FS_` -- Folder names use AD group display names (`displayName`, then `name`/`cn` fallback), not pre-2000 (`sAMAccountName`) names. +- Directory reads use machine-account LDAP (`net ads search -P`); join credentials are only used as a fallback LDAP bind path. +- Existing AD users are selectable by the administrator; no AD group naming convention is required for new Data folders. +- Machine-account LDAP reads provide the user list and the one-time legacy membership import. ## DNS Requirements @@ -101,6 +121,7 @@ Kerberos requires close time alignment. - `README.md` - `app/init.sh` - `app/reconcile_shares.py` +- `app/access_control.py` - `app/backup_to_destination.py` - `app/audit_collector.py` - `app/audit_store.py` @@ -180,7 +201,8 @@ The E2E suite verifies: - CA-issued TLS, hostname validation, HSTS, and CSP; - anonymous rejection, bad credentials, valid non-admin rejection, real Domain Admin login, JWT claims, bearer use, cookie flags, tamper rejection, and logout; -- domain trust, group-to-folder mapping, nested and transitive group trees; +- domain trust, one-time legacy folder/membership migration, and individual folder assignments; +- admin-managed membership, all four SMB access levels, hidden-folder behavior, inheritance, ACL-edit rejection, revocation, and archive/restore; - SMB allow/deny behavior and real file operations; - Data, Private, and FSLogix usage aggregation; - high-level `full_audit` ingestion for all four actions, service-account exclusion, filters, facets, and pagination; @@ -193,6 +215,18 @@ The E2E suite verifies: The runner returns non-zero on the first failed assertion, prints bounded logs from every run-scoped service, and always removes its containers, volumes, network, and temporary CA root. Set `DEV_SKIP_BUILD=1` for a fast rerun against existing local images. +### Browser checks for access administration + +`tests/access_ui_smoke.mjs` drives Chromium with Playwright against the real frontend and a simulated API containing representative folders and 80 AD users. It checks individual permission editing, user search and access filters, unsaved-change protection, failed saves and retries, preserved selection, folder creation, archive/restore, layouts from 390 to 1500 pixels wide, and consistency with the existing admin control styles. It writes desktop and mobile screenshots for visual review. + +With Playwright available to Node: + +```bash +node tests/access_ui_smoke.mjs +``` + +`PLAYWRIGHT_MODULE` can point to an external Playwright installation; `PLAYWRIGHT_BROWSERS_PATH` selects its browser cache. `SCREENSHOT_DIR` chooses the screenshot destination; otherwise the runner creates a temporary directory and prints its path. These browser checks use fixture data; `scripts/test-e2e` verifies the real AD/SMB backend. + ## Setup 1. Run interactive setup: @@ -274,12 +308,12 @@ The runner returns non-zero on the first failed assertion, prints bounded logs f - Share: `\\server\Data` - Path: `/data/groups/data` -- Contains one folder per active `FS_*` AD group. -- Root is discoverable as one share, while access to each group folder is enforced via POSIX/ACL group permissions. -- `FS_*` groups may contain other AD groups; reconciliation recursively grants ACLs to nested groups and logs detected cycles. -- Samba blocks SMB-side ACL edits on Data and forces new items to inherit the group folder owner, group, mode, and default ACLs. -- Normal reconciliation refreshes each group folder root; recursive subtree repair runs only when the resolved ACL signature changes, is missing, or `REPAIR_DATA_ACLS=1` is set. -- Dot-prefixed group folder names are allowed and are not hidden over SMB. +- Contains the active top-level folders created or imported into the admin UI. +- Windows ACLs enforce each user’s effective level, including separate delete rights. +- Samba’s `acl_xattr` and `xattr_tdb` modules retain the ACLs in a protected store. Local access to the underlying volumes must remain restricted. +- New files and directories inherit the managed ACL. Users cannot change ACLs or take ownership to grant themselves more access. +- Normal reconciliation refreshes each folder root; recursive repair runs when its effective permission signature changes, is missing, or `REPAIR_DATA_ACLS=1` is set. +- Dot-prefixed folder names are allowed and are not hidden over SMB. - No guest access. ### FSLogix @@ -304,20 +338,21 @@ Open `https:///` after setup. Only members of the group identified The console is intentionally operational and plain: -- **Overview**: current capacity totals, active group count, recent activity, and backup state. -- **File shares**: one selectable tree per active `FS_*` group/folder with recursively expanded user, nested-group, and AD primary-group membership, cycle markers, and a live filter. +- **Overview**: current capacity totals, active folder count, recent activity, and backup state. +- **File shares**: one selectable tree per active managed folder with its individually assigned users. +- **Zugriffsverwaltung**: create/archive/restore Data folders and assign levels 0–3 to existing AD users individually per folder. - **Data usage**: cached recursive size of every top-level `/Data` group folder. - **User usage**: per-user `/Private + /FSLogix` totals with component sizes. - **Activity**: dynamic date, user, share, action, result, and path filters with pagination. - **Trash**: list seven-day recycle entries across all shares, download a retained file, or restore it without overwriting an existing path. - **Share reconciliation**: manually force reconciliation and follow its phase, progress bar, current group, and live output. - **Backups**: manually start a backup and follow live progress, active transfer rows, snapshot name, trigger, and recent output. -- **PDF report**: storage totals and every storage row, complete group/folder membership hierarchies, current backup state, system checks, and TLS certificate data. +- **PDF report**: storage totals and every storage row, individual folder/user assignments, current backup state, system checks, and TLS certificate data. - **System**: domain trust, Samba configuration, TLS certificate, scanner, and activity database health. The PDF report deliberately excludes the activity log and backup log. Its dedicated snapshot endpoint removes those fields before returning data. Typst, its WebAssembly compiler, and the report fonts are shipped with the application; Typst source and PDF bytes are created only in the authenticated browser and are never uploaded to another service. The first export downloads roughly 22 MiB of compiler/font assets, which are then cached as immutable files. The CSP grants only `'wasm-unsafe-eval'` for WebAssembly compilation and does not enable JavaScript `'unsafe-eval'`. -The operational mutation endpoints restore a retained file or start an immediate backup/share reconciliation; all require the same Domain Admin JWT as every protected page. Restore cannot overwrite a live file. The console cannot otherwise edit files, groups, ACL rules, backup schedules, retention, or `BACKUP_AUTO_ENABLED`. Automatic backups can be enabled or disabled only through the environment and therefore require a redeployment/restart. +The mutation endpoints manage Data access (`POST /api/access`), restore retained files, or start an immediate backup/share reconciliation; all require the same Domain Admin JWT as every protected page. Restore cannot overwrite a live file. Backup schedules, retention, and `BACKUP_AUTO_ENABLED` are configured through the environment. Automatic backups can be enabled or disabled only through the environment and therefore require a redeployment/restart. ### Authentication and sessions @@ -398,7 +433,6 @@ Useful optional settings: | `WEB_LOGIN_ATTEMPTS_PER_5_MIN` | `10` | Per-address login attempt limit | | `WEB_USAGE_SCAN_INTERVAL_SECONDS` | `900` | Recursive storage scan interval | | `WEB_DIRECTORY_CACHE_SECONDS` | `300` | AD membership tree cache time | -| `WEB_MAX_GROUP_NODES` | `10000` | Membership expansion safety limit | | `TRASH_RETENTION_DAYS` | `7` | Recycled-file lifetime; cleanup accepts 1 to 365 days | | `STATE_DB_PATH` | `/state/shares.db` | Shared SQLite database for shares, activity, collector offsets, and caches | | `AUDIT_QUERY_MAX_DAYS` | `31` | Largest activity query window | @@ -542,30 +576,18 @@ docker compose exec samba python3 -m json.tool /state/reconcile-status.json docker compose exec samba wbinfo -g ``` -### Data folders not appearing +### Data folders or access missing -- Confirm AD groups match `FS_*`. -- Run manual reconciliation and inspect logs: +- Check **Zugriffsverwaltung**: the folder must be active, and the user must have an individual assignment. +- Level 0 revokes access. Changes to AD `FS_*` membership do not change imported individual assignments. +- Check the reconciliation status and logs if the one-time import or an ACL update fails: ```bash docker compose exec samba python3 /app/reconcile_shares.py docker compose exec samba tail -n 100 /var/log/reconcile.log ``` -### Nested or primary Data group access fails - -- Check reconciliation logs for detected group cycles or unresolved nested members. -- The reconciler resolves group SIDs to GIDs first, so localized names such as `Domänen-Benutzer` do not affect ACL generation. -- Verify the SID mapping, the user's primary/supplementary groups, and the resulting ACL: - - ```bash - docker compose exec samba wbinfo --name-to-sid 'EXAMPLE\Domänen-Benutzer' - docker compose exec samba wbinfo --sid-to-gid S-1-5-21-...-513 - docker compose exec samba id 'EXAMPLE\alice' - docker compose exec samba getfacl /data/groups/data/ - ``` - -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. +- Inspect Windows ACLs with `smbcacls //server/Data '' -U 'DOMAIN\admin'`; `getfacl` does not show the enforced Data permissions. ### Data folder permissions are incorrect @@ -611,5 +633,5 @@ Users whose group is represented only by AD `primaryGroupID` are included automa ## Notes - User data is never automatically deleted. -- Inactive/deleted FS_* groups are moved to `/data/groups/archive`. +- Folders archived in the admin UI are moved to `/data/groups/archive`; AD group deletion does not move folders. - Data and state survive container restarts via named Docker volumes (`/data/*`, `/state`, `/var/lib/samba`). diff --git a/app/access_control.py b/app/access_control.py new file mode 100644 index 0000000..33a3ff5 --- /dev/null +++ b/app/access_control.py @@ -0,0 +1,476 @@ +"""Admin-managed Data folders and individual user permissions, enforced by Samba Windows ACLs.""" + +import contextlib +import datetime as dt +import fcntl +import hashlib +import json +import os +import re +import stat +import uuid + +try: + from . import reconcile_shares as directory +except ImportError: + import reconcile_shares as directory + +SID_RE = re.compile(r"S-1-5-21-\d+-\d+-\d+-\d+\Z") +READ = 0x1200A9 +MODIFY = 0x1201BF # No DELETE, DELETE_CHILD, WRITE_DAC or WRITE_OWNER. +DELETE = 0x10000 +DELETE_CHILD = 0x40 +MASKS = {1: READ, 2: MODIFY, 3: MODIFY | DELETE | DELETE_CHILD} +ATTRS = ["objectGUID", "objectSid", "distinguishedName", "sAMAccountName", + "displayName", "cn", "objectClass", "member", "primaryGroupID"] + + +class AccessConflict(RuntimeError): + pass + + +def timestamp(): + return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds") + + +def ensure_schema(conn): + conn.executescript(""" + CREATE TABLE IF NOT EXISTS access_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS access_users ( + sid TEXT PRIMARY KEY, sam TEXT NOT NULL, name TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS folder_permissions ( + folderId TEXT NOT NULL REFERENCES shares(objectGUID) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind = 'user'), + principalId TEXT NOT NULL, + level INTEGER NOT NULL CHECK(level BETWEEN 0 AND 3), + PRIMARY KEY(folderId, kind, principalId) + ); + CREATE TABLE IF NOT EXISTS access_changes ( + id INTEGER PRIMARY KEY, timestamp TEXT NOT NULL, + actor TEXT NOT NULL, action TEXT NOT NULL, details TEXT NOT NULL + ); + """) + + upgrade_individual_permissions(conn) + + +def upgrade_individual_permissions(conn): + """Flatten the previous local-group model without changing effective access.""" + schema = conn.execute("SELECT sql FROM sqlite_master WHERE name='folder_permissions'").fetchone()[0] + if "'group'" not in schema: + return + conn.execute("BEGIN IMMEDIATE") + try: + # Recheck after obtaining the write lock: another worker may have upgraded. + schema = conn.execute("SELECT sql FROM sqlite_master WHERE name='folder_permissions'").fetchone()[0] + if "'group'" not in schema: + conn.commit() + return + if conn.execute("SELECT 1 FROM folder_permissions p LEFT JOIN access_groups g " + "ON p.principalId=g.id WHERE p.kind='group' AND g.id IS NULL").fetchone(): + raise RuntimeError("Cannot migrate unresolved stored group permission") + conn.execute("CREATE TABLE individual_permissions (" + "folderId TEXT NOT NULL REFERENCES shares(objectGUID) ON DELETE CASCADE," + "kind TEXT NOT NULL CHECK(kind = 'user'), principalId TEXT NOT NULL," + "level INTEGER NOT NULL CHECK(level BETWEEN 0 AND 3)," + "PRIMARY KEY(folderId, kind, principalId))") + # Existing direct assignments take precedence, including explicit level 0. + conn.execute("INSERT INTO individual_permissions SELECT * FROM folder_permissions WHERE kind='user'") + conn.execute("INSERT OR IGNORE INTO individual_permissions " + "SELECT p.folderId,'user',m.userSid,MAX(p.level) FROM folder_permissions p " + "JOIN access_members m ON p.kind='group' AND p.principalId=m.groupId " + "GROUP BY p.folderId,m.userSid") + conn.execute("DROP TABLE folder_permissions") + conn.execute("ALTER TABLE individual_permissions RENAME TO folder_permissions") + conn.execute("DROP TABLE access_members") + conn.execute("DROP TABLE access_groups") + conn.execute("INSERT INTO access_changes(timestamp,actor,action,details) VALUES(?,?,?,?)", + (timestamp(), "system", "migrate-individual-permissions", "{}")) + conn.commit() + except Exception: + conn.rollback() + raise + + +def initialized(conn): + return conn.execute("SELECT 1 FROM access_settings WHERE key='managed'").fetchone() is not None + + +def read_directory(include_groups=False): + entries = directory.search_directory_entries( + "(|(&(objectClass=user)(!(objectClass=computer)))(objectClass=group))" + if include_groups else "(&(objectClass=user)(!(objectClass=computer)))", ATTRS) + users, groups = {}, {} + for entry in entries: + principal = directory.parse_principal_from_entry(entry) + if not principal: + continue + sid = str(principal.get("objectSid") or "") + if not SID_RE.fullmatch(sid): + continue + row = {"sid": sid, "sam": str(principal.get("samAccountName") or ""), + "name": directory.ldap_first(entry, "displayName") or + str(principal.get("samAccountName") or sid), + "dn": directory.normalize_dn(directory.entry_dn(entry)), + "primaryRid": directory.parse_int(directory.ldap_first(entry, "primaryGroupID") or "", -1), + "members": [directory.normalize_dn(dn) for dn in directory.ldap_values(entry, "member")]} + if directory.is_group_principal(principal): + groups[row["dn"]] = row + elif directory.is_user_principal(principal): + users[sid] = row + return users, groups + + +def cache_users(conn, users): + conn.executemany("INSERT INTO access_users(sid,sam,name) VALUES(?,?,?) " + "ON CONFLICT(sid) DO UPDATE SET sam=excluded.sam,name=excluded.name", + [(sid, row["sam"], row["name"]) for sid, row in users.items()]) + + +def migrate(conn): + """Snapshot existing assignments once; never discover new FS_* groups.""" + ensure_schema(conn) + if initialized(conn): + return + rows = conn.execute("SELECT * FROM shares WHERE isActive=1").fetchall() + users, groups = read_directory(include_groups=True) if rows else ({}, {}) + by_sam = {g["sam"].casefold(): g for g in groups.values()} + by_dn = {u["dn"]: sid for sid, u in users.items()} + + def members(group, seen): + if group["dn"] in seen: + return set() + seen = {*seen, group["dn"]} + rid = directory.sid_rid(group["sid"]) + result = {sid for sid, user in users.items() + if user["primaryRid"] == rid and sid.rsplit("-", 1)[0] == group["sid"].rsplit("-", 1)[0]} + for dn in group["members"]: + if dn in groups: + result.update(members(groups[dn], seen)) + elif dn in by_dn: + result.add(by_dn[dn]) + else: + raise RuntimeError(f"Cannot import unresolved member {dn}; migration will retry") + return result + + # Resolve all memberships before mutating persistent state. + imports = [] + for row in rows: + group = by_sam.get(row["samAccountName"].casefold()) + if group is None: + raise RuntimeError(f"Cannot import group {row['samAccountName']}; migration will retry") + imports.append((row, members(group, set()))) + try: + cache_users(conn, users) + for row, sids in imports: + conn.executemany("INSERT INTO folder_permissions VALUES(?,?,?,3)", + [(row["objectGUID"], "user", sid) for sid in sorted(sids)]) + # Adopt any existing untracked folder without granting user access. + root = directory.GROUP_ROOT + if os.path.isdir(root): + known = {os.path.abspath(row[0]) for row in conn.execute("SELECT path FROM shares")} + for entry in os.scandir(root): + if entry.name != ".trash" and entry.is_dir(follow_symlinks=False) and os.path.abspath(entry.path) not in known: + create_folder_record(conn, entry.name, entry.path) + conn.execute("INSERT INTO access_settings VALUES('managed','1')") + conn.execute("UPDATE shares SET aclSignature=''") + conn.commit() + except Exception: + conn.rollback() + raise + + +def valid_name(value): + if not isinstance(value, str): + raise ValueError("Ein Name ist erforderlich") + name = value.strip() + if not name or len(name) > 120 or name in {".", ".."} or name.casefold() == ".trash" or name.endswith((".", " ")) or re.search(r'[\\/:*?"<>|\x00-\x1f\x7f]', name): + raise ValueError("Ungültiger Name") + if name.split(".", 1)[0].upper() in {"CON", "PRN", "AUX", "NUL", *[f"{p}{i}" for p in ("COM", "LPT") for i in range(1, 10)]}: + raise ValueError("Reservierter Name") + return name + + +def create_folder_record(conn, name, path): + folder_id = str(uuid.uuid4()) + conn.execute("INSERT INTO shares(objectGUID,samAccountName,shareName,path,createdAt,lastSeenAt,isActive,aclSignature) VALUES(?,?,?,?,?,?,1,'')", + (folder_id, name, name, path, timestamp(), timestamp())) + return folder_id + + +def snapshot(conn, users=None): + user_rows = [dict(row) for row in conn.execute("SELECT * FROM access_users ORDER BY sam COLLATE NOCASE")] + if users is not None: + for user in user_rows: + user["available"] = user["sid"] in users + folders = [] + for row in conn.execute("SELECT * FROM shares ORDER BY shareName COLLATE NOCASE"): + folders.append({"id": row["objectGUID"], "name": row["shareName"], "active": bool(row["isActive"]), + "permissions": [dict(p) for p in conn.execute("SELECT kind,principalId,level FROM folder_permissions WHERE folderId=? ORDER BY kind,principalId", (row["objectGUID"],))]}) + return {"users": user_rows, "folders": folders, + "fetchedAt": timestamp(), "initialized": initialized(conn)} + + +def effective_levels(conn, folder_id): + return {row["principalId"]: row["level"] for row in conn.execute( + "SELECT principalId,level FROM folder_permissions WHERE folderId=?", (folder_id,))} + + +def descriptor(levels, admin_sid, is_dir=True, top_level=False): + if not SID_RE.fullmatch(admin_sid): + raise RuntimeError("DOMAIN_ADMINS_SID is required for managed folder ACLs") + flags = "OICI" if is_dir else "" + # OWNER RIGHTS suppresses implicit WRITE_DAC for files created by users. + aces = [f"(A;{flags};RC;;;OW)", f"(A;{flags};FA;;;SY)", f"(A;{flags};FA;;;{admin_sid})"] + for sid, level in sorted(levels.items()): + if not SID_RE.fullmatch(sid) or type(level) is not int or level not in range(4): + raise RuntimeError("Invalid stored folder permission") + if level: + mask = MASKS[level] + if top_level: + # Only admins can rename/delete a managed top-level folder. + aces.append(f"(A;;0x{mask & ~DELETE:08x};;;{sid})") + aces.append(f"(A;OICIIO;0x{mask:08x};;;{sid})") + else: + aces.append(f"(A;{flags};0x{mask:08x};;;{sid})") + return f"O:{admin_sid}G:{admin_sid}D:P" + "".join(aces) + + +def pack_descriptor(sddl): + # Samba's own NDR serializer matches the installed server version. + from samba.dcerpc import security, xattr + from samba.ndr import ndr_pack + acl = xattr.NTACL() + acl.version = 1 + acl.info = security.descriptor.from_sddl(sddl, security.dom_sid(os.environ["DOMAIN_ADMINS_SID"].rsplit("-", 1)[0])) + return ndr_pack(acl) + + +def set_acl_fd(fd, blob, is_dir): + info = os.fstat(fd) + if not (stat.S_ISDIR(info.st_mode) if is_dir else stat.S_ISREG(info.st_mode)): + raise RuntimeError("Unsupported managed data entry") + from samba import xattr_tdb + os.makedirs("/state", exist_ok=True) + os.fchown(fd, 0, 0) + xattr_tdb.wrap_setxattr("/state/data-xattrs.tdb", f"/proc/self/fd/{fd}", "security.NTACL", blob) + os.chmod("/state/data-xattrs.tdb", 0o600) + # SMB checks Windows ACLs. Local data volumes must stay container-private. + os.fchmod(fd, 0o777 if is_dir else 0o666) + + +def set_acl(path, blob, is_dir): + # Open without following symlinks; set ACLs/mode through the pinned inode. + flags = os.O_RDONLY | os.O_NOFOLLOW | (os.O_DIRECTORY if is_dir else 0) + fd = os.open(path, flags) + try: + set_acl_fd(fd, blob, is_dir) + finally: + os.close(fd) + + +def prepare_data_restore(conn, relative_path, source_fd): + parts = relative_path.split("/") + row = conn.execute("SELECT * FROM shares WHERE shareName=? AND isActive=1", (parts[0],)).fetchone() + if row is None or len(parts) < 2: + raise ValueError("Wiederherstellung erfordert einen aktiven verwalteten Datenordner") + path = safe_folder_path(row) + admin_sid = os.getenv("DOMAIN_ADMINS_SID", "") + levels = effective_levels(conn, row["objectGUID"]) + set_acl(path, pack_descriptor(descriptor(levels, admin_sid, top_level=True)), True) + for name in parts[1:-1]: + path = os.path.join(path, name) + set_acl(path, pack_descriptor(descriptor(levels, admin_sid)), True) + # Replace the retained ACL before the file is visible at its restored path. + set_acl_fd(source_fd, pack_descriptor(descriptor(levels, admin_sid, False)), False) + + +def safe_folder_path(row): + root = os.path.abspath(directory.GROUP_ROOT if row["isActive"] else directory.GROUP_ARCHIVE_ROOT) + path = os.path.abspath(row["path"]) + if os.path.dirname(path) != root or os.path.islink(path): + raise RuntimeError(f"Unsafe managed folder path: {path}") + return path + + +def recover_pending(conn): + pending = conn.execute("SELECT value FROM access_settings WHERE key='pendingRepair'").fetchone() + if pending is None: + return + details = json.loads(pending[0]) + if details.get("move"): + source, destination = details["move"] + if not os.path.lexists(source) and os.path.lexists(destination): + os.rename(destination, source) + created = details.get("create") + if created and os.path.isdir(created) and not conn.execute("SELECT 1 FROM shares WHERE path=?", (created,)).fetchone(): + # Preserve files from an interrupted creation with admin-only access. + create_folder_record(conn, os.path.basename(created), created) + sync_permissions(conn, force=True) + conn.execute("DELETE FROM access_settings WHERE key='pendingRepair'") + conn.commit() + directory.log("Recovered interrupted admin access update") + + +def sync_permissions(conn, force=False): + admin_sid = os.getenv("DOMAIN_ADMINS_SID", "") + root = directory.GROUP_ROOT + os.makedirs(root, exist_ok=True) + force = force or not os.path.exists("/state/data-xattrs.tdb") + # Read/list only at the share root; no inheritance or top-level creation. + root_sddl = descriptor({}, admin_sid) + "(A;;0x001200a9;;;AU)" + set_acl(root, pack_descriptor(root_sddl), True) + for row in conn.execute("SELECT * FROM shares").fetchall(): + path = safe_folder_path(row) + levels = effective_levels(conn, row["objectGUID"]) if row["isActive"] else {} + top = descriptor(levels, admin_sid, top_level=True) + signature = hashlib.sha256(top.encode()).hexdigest() + os.makedirs(path, exist_ok=True) + set_acl(path, pack_descriptor(top), True) + if force or directory.should_repair_data_acls() or row["aclSignature"] != signature: + blobs = {True: pack_descriptor(descriptor(levels, admin_sid)), False: pack_descriptor(descriptor(levels, admin_sid, False))} + # Never follow symlinks or cross mount boundaries. + root_device = os.stat(path, follow_symlinks=False).st_dev + for current, dirs, files in os.walk(path, followlinks=False): + for name in list(dirs): + child = os.path.join(current, name) + if os.path.islink(child) or os.stat(child, follow_symlinks=False).st_dev != root_device: + raise RuntimeError(f"Unsupported link or mount in managed folder: {child}") + set_acl(child, blobs[True], True) + for name in files: + set_acl(os.path.join(current, name), blobs[False], False) + conn.execute("UPDATE shares SET aclSignature=? WHERE objectGUID=?", (signature, row["objectGUID"])) + + +@contextlib.contextmanager +def mutation_lock(): + os.makedirs(os.path.dirname(directory.LOCK_PATH), exist_ok=True) + with open(directory.LOCK_PATH, "a", encoding="utf-8") as handle: + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as exc: + raise AccessConflict("Freigabenabgleich läuft; bitte erneut versuchen") from exc + yield + + +def apply_change(conn, body, actor, users): + action = body.get("action") + if not isinstance(action, str) or action not in {"create-folder", "set-permissions", "archive-folder", "restore-folder"}: + raise ValueError("Unbekannte Verwaltungsaktion") + if body.get("id") is not None and (not isinstance(body["id"], str) or not body["id"] or len(body["id"]) > 120): + raise ValueError("Ungültige ID") + cache_users(conn, users) + conn.commit() + recover_pending(conn) + pending = {} + if action == "create-folder": + pending["create"] = os.path.join(directory.GROUP_ROOT, valid_name(body.get("name"))) + elif action in {"archive-folder", "restore-folder"}: + row = conn.execute("SELECT * FROM shares WHERE objectGUID=?", (body.get("id"),)).fetchone() + if row is not None: + target = directory.GROUP_ROOT if action == "restore-folder" else directory.GROUP_ARCHIVE_ROOT + pending["move"] = [safe_folder_path(row), os.path.join(target, valid_name(row["shareName"]))] + # Persist recovery intent separately from the policy transaction. A process + # crash cannot leave partially updated ACLs with an unchanged signature. + conn.execute("INSERT INTO access_settings VALUES('pendingRepair',?)", (json.dumps(pending),)) + conn.commit() + conn.execute("BEGIN IMMEDIATE") + created_path = None + moved_paths = None + acl_started = False + try: + if action == "create-folder": + name = valid_name(body.get("name")) + if conn.execute("SELECT 1 FROM shares WHERE shareName=? COLLATE NOCASE", (name,)).fetchone(): + raise ValueError("Ordnername ist bereits vergeben") + path = os.path.join(directory.GROUP_ROOT, name) + os.mkdir(path, 0o700) + created_path = path + create_folder_record(conn, name, path) + elif action in {"set-permissions", "archive-folder", "restore-folder"}: + folder_id = body.get("id") + row = conn.execute("SELECT * FROM shares WHERE objectGUID=?", (folder_id,)).fetchone() + if row is None: + raise ValueError("Ordner nicht gefunden") + if action == "set-permissions": + rules = body.get("permissions") + if not isinstance(rules, list): + raise ValueError("Berechtigungen müssen eine Liste sein") + old_users = {r[0] for r in conn.execute("SELECT principalId FROM folder_permissions WHERE folderId=? AND kind='user'", (folder_id,))} + checked = [] + seen = set() + for rule in rules: + if not isinstance(rule, dict): + raise ValueError("Ungültige Berechtigung") + kind, principal, level = rule.get("kind"), rule.get("principalId"), rule.get("level") + if not isinstance(kind, str) or kind != "user" or not isinstance(principal, str) or type(level) is not int or level not in range(4): + raise ValueError("Ungültige Berechtigung") + if (kind, principal) in seen: + raise ValueError("Doppelte Berechtigung") + seen.add((kind, principal)) + if principal not in users and principal not in old_users: + raise ValueError("Unbekannter AD-Benutzer") + checked.append((folder_id, kind, principal, level)) + conn.execute("DELETE FROM folder_permissions WHERE folderId=?", (folder_id,)) + conn.executemany("INSERT INTO folder_permissions VALUES(?,?,?,?)", checked) + else: + active = action == "restore-folder" + if bool(row["isActive"]) == active: + raise ValueError("Ordner hat diesen Status bereits") + source = safe_folder_path(row) + root = directory.GROUP_ROOT if active else directory.GROUP_ARCHIVE_ROOT + os.makedirs(root, exist_ok=True) + destination = os.path.join(root, row["shareName"]) + if os.path.lexists(destination): + raise AccessConflict("Zielordner existiert bereits") + os.rename(source, destination) + moved_paths = (source, destination) + conn.execute("UPDATE shares SET path=?,isActive=?,aclSignature='' WHERE objectGUID=?", (destination, int(active), folder_id)) + else: + raise ValueError("Unbekannte Verwaltungsaktion") + acl_started = True + sync_permissions(conn) + conn.execute("DELETE FROM access_settings WHERE key='pendingRepair'") + conn.execute("INSERT INTO access_changes(timestamp,actor,action,details) VALUES(?,?,?,?)", (timestamp(), actor, action, json.dumps(body, ensure_ascii=False))) + conn.commit() + except Exception: + conn.rollback() + if moved_paths: + os.rename(moved_paths[1], moved_paths[0]) + if created_path: + os.rmdir(created_path) + # A failed recursive update may have changed some ACLs: restore the old policy. + try: + if acl_started: + sync_permissions(conn, force=True) + conn.execute("DELETE FROM access_settings WHERE key='pendingRepair'") + conn.commit() + except Exception as exc: + directory.log(f"ERROR: Access rollback repair failed: {exc}") + conn.execute("UPDATE shares SET aclSignature=''") + conn.commit() + raise + # Existing handles carry cached access masks; disconnect Data clients. + result = directory.run_command(["smbcontrol", "all", "close-share", "Data"], check=False) + if result.returncode: + raise RuntimeError("Berechtigungen gespeichert; SMB-Verbindungen konnten nicht geschlossen werden. Samba neu starten.") + return snapshot(conn, users) + + +def report_folders(conn): + data = snapshot(conn) + users = {u["sid"]: u for u in data["users"]} + rows = [] + for folder in data["folders"]: + if not folder["active"]: + continue + levels = effective_levels(conn, folder["id"]) + members = [] + for rule in folder["permissions"]: + if rule["kind"] == "user" and rule["level"]: + sid = rule["principalId"] + members.append({"id": sid, "name": users.get(sid, {}).get("name", sid), "sam": users.get(sid, {}).get("sam", sid), "type": "user", "level": rule["level"], "members": []}) + rows.append({"guid": folder["id"], "name": folder["name"], "sam": folder["name"], "folder": folder["name"], "active": True, + "userCount": sum(bool(level) for level in levels.values()), "groupCount": 0, "members": members}) + return {"groups": rows, "fetchedAt": data["fetchedAt"], "truncated": False} diff --git a/app/init.sh b/app/init.sh index 85ab0f9..0565271 100755 --- a/app/init.sh +++ b/app/init.sh @@ -32,7 +32,7 @@ require_vfs_modules() { local missing=0 local module - for module in acl_xattr recycle full_audit; do + for module in acl_xattr xattr_tdb recycle full_audit; do if [[ ! -f "$modules_dir/vfs/${module}.so" ]]; then printf '[init] ERROR: missing VFS module %s at %s/vfs/%s.so\n' "$module" "$modules_dir" "$module" >&2 missing=1 diff --git a/app/reconcile_shares.py b/app/reconcile_shares.py index 624f08e..0695ba0 100755 --- a/app/reconcile_shares.py +++ b/app/reconcile_shares.py @@ -1711,31 +1711,17 @@ def with_lock() -> bool: conn = open_db() try: - status_progress(10, "directory", "Reading data folder groups from AD") - phase_started = time.monotonic() - groups = fetch_fileshare_groups() - log( - f"Discovered {len(groups)} data folder group(s) from AD " - f"in {format_duration(time.monotonic() - phase_started)}" - ) - - status_progress(22, "database", "Reconciling data folder database") - log("Reconciling data folder DB") - phase_started = time.monotonic() - reconcile_db(conn, groups) - log( - f"Reconciled data folder DB in " - f"{format_duration(time.monotonic() - phase_started)}" - ) - - status_progress(32, "data-permissions", "Syncing data folder permissions") - log("Syncing data folder permissions") - phase_started = time.monotonic() - sync_dynamic_directory_permissions(conn, groups) - log( - f"Synced data folder permissions in " - f"{format_duration(time.monotonic() - phase_started)}" - ) + try: + from . import access_control + except ImportError: + import access_control + status_progress(10, "database", "Loading admin-managed folders") + access_control.migrate(conn) + access_control.recover_pending(conn) + status_progress(32, "data-permissions", "Syncing managed folder permissions") + access_control.sync_permissions(conn) + conn.commit() + log("Synced admin-managed Data folder permissions") finally: conn.close() diff --git a/app/trash.py b/app/trash.py index 444e009..c95927c 100644 --- a/app/trash.py +++ b/app/trash.py @@ -303,7 +303,7 @@ def _remove_empty_trash_parents(repository: str, relative_path: str) -> None: current = os.path.dirname(current) -def restore_item(item_id: str) -> Dict[str, object]: +def restore_item(item_id: str, prepare=None) -> Dict[str, object]: share, share_root, relative_path, parts, info = _resolved_item(item_id) original = _original_relative(relative_path) original_parts = _relative_parts(original) @@ -323,6 +323,12 @@ def restore_item(item_id: str) -> Dict[str, object]: ) linked = False try: + if prepare is not None: + source_fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW, dir_fd=source_parent_fd) + try: + prepare(original, source_fd) + finally: + os.close(source_fd) os.link( parts[-1], original_parts[-1], diff --git a/app/web/app.js b/app/web/app.js index b97a53b..3593696 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -76,6 +76,7 @@ function notice(message) { } function showLogin() { + state.accessDirty = false; clearInterval(state.timer); state.session = null; appView.hidden = true; @@ -101,6 +102,7 @@ function badge(text, kind = "") { return `${esc(text function routeFor(path) { if (path.startsWith("/storage/data")) return "storage-data"; if (path.startsWith("/storage/users")) return "storage-users"; + if (path.startsWith("/access")) return "access"; if (path.startsWith("/shares")) return "shares"; if (path.startsWith("/reconciliation")) return "reconciliation"; if (path.startsWith("/activity/fslogix")) return "activity-fslogix"; @@ -113,6 +115,12 @@ function routeFor(path) { } async function navigate(path, replace = false) { + if (state.accessDirty && !window.confirm("Ungespeicherte Änderungen verwerfen?")) { + if (replace && state.currentPath) history.replaceState({}, "", state.currentPath); + return; + } + state.accessDirty = false; + state.currentPath = path; clearInterval(state.timer); state.timer = null; const route = routeFor(path); @@ -123,6 +131,7 @@ async function navigate(path, replace = false) { try { if (route === "overview") await renderOverview(); if (route === "shares") await renderShares(); + if (route === "access") await renderAccess(); if (route === "reconciliation") await renderReconciliation(); if (route === "storage-data") await renderStorage("data"); if (route === "storage-users") await renderStorage("users"); @@ -158,7 +167,7 @@ async function renderOverview() {
Daten${bytes(totals.dataBytes)}
Private + FSLogix${bytes(Number(totals.privateBytes || 0) + Number(totals.fslogixBytes || 0))}
-
Aktive Gruppen${esc(data.activeGroups)}
+
Aktive Datenordner${esc(data.activeGroups)}
Ereignisse · 48 Std.${esc(data.eventCount)}
@@ -178,7 +187,7 @@ async function renderOverview() { function usageTable(rows, type) { if (!rows.length) return empty("Die erste Speicherprüfung ist noch nicht abgeschlossen."); const maximum = Math.max(...rows.map(row => Number(type === "group" ? row.bytes : row.totalBytes)), 1); - return `
${type === "user" ? "" : ""}${rows.map(row => { + return `
${type === "group" ? "Gruppenordner" : "Benutzer"}PrivateFSLogixBelegung
${type === "user" ? "" : ""}${rows.map(row => { const value = Number(type === "group" ? row.bytes : row.totalBytes); return `${type === "user" ? `` : ""}`; }).join("")}
${type === "group" ? "Datenordner" : "Benutzer"}PrivateFSLogixBelegung
${esc(row.name)}${bytes(row.privateBytes)}${bytes(row.fslogixBytes)}${bytes(value)}
`; @@ -202,10 +211,10 @@ 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.", `Verzeichnisstand ${esc(utcTime(data.fetchedAt))}`) + ` - ${data.truncated ? `

${badge("Ergebnis gekürzt", "warn")} WEB_MAX_GROUP_NODES erhöhen, um alle Mitglieder anzuzeigen.

` : ""} + content.innerHTML = `
` + pageHead("Dateifreigaben", "Verwaltete Ordner und ihre individuellen Benutzerberechtigungen.", `Verzeichnisstand ${esc(utcTime(data.fetchedAt))}`) + ` + ${data.truncated ? `

${badge("Ergebnis gekürzt", "warn")} Mitgliedschaftsliste ist unvollständig.

` : ""}
-

Gruppenordner

${groups.length}
    +

    Datenordner

    ${groups.length}
      `; const list = document.querySelector("#group-list"); @@ -215,22 +224,202 @@ async function renderShares() { const draw = () => { const visible = groups.filter(group => `${group.name} ${group.sam} ${group.folder}`.toLowerCase().includes(query) || group.members.some(node => nodeMatches(node, query))); if (selected && !visible.includes(selected)) selected = visible[0] || null; - list.innerHTML = visible.length ? visible.map(group => `
    • `).join("") : empty("Keine Gruppe entspricht dem Filter."); - if (!selected) tree.innerHTML = empty("Gruppenordner auswählen."); - else tree.innerHTML = `

      ${esc(selected.folder)}

      ${esc(selected.sam)} · ${selected.userCount} wirksame Benutzer
      ${selected.active ? badge("Aktiv") : badge("Archiviert", "warn")}
      ${treeNodes(selected.members, query) || empty("Keine direkten Mitglieder")}
      `; + list.innerHTML = visible.length ? visible.map(group => `
    • `).join("") : empty("Kein Ordner entspricht dem Filter."); + if (!selected) tree.innerHTML = empty("Datenordner auswählen."); + else tree.innerHTML = `

      ${esc(selected.folder)}

      ${esc(selected.sam)} · ${selected.userCount} wirksame Benutzer
      ${selected.active ? badge("Aktiv") : badge("Archiviert", "warn")}
      ${treeNodes(selected.members, query) || empty("Keine Benutzer mit Zugriff")}
      `; list.querySelectorAll("button[data-guid]").forEach(button => button.addEventListener("click", () => { selected = groups.find(group => group.guid === button.dataset.guid); draw(); })); }; document.querySelector("#group-filter").addEventListener("input", event => { query = event.target.value.trim().toLowerCase(); draw(); }); draw(); } +const permissionLabels = ["Kein Zugriff", "Lesen", "Lesen + Ändern", "Lesen + Ändern + Löschen"]; + +async function renderAccess() { + let data = await api("/api/access"); + let folderId = data.folders.find(folder => folder.active)?.id || data.folders[0]?.id; + let rules = [], query = "", userQuery = "", userFilter = "all", busy = false; + let folderFilter = data.folders.length && !data.folders.some(item => item.active) ? "archived" : "active"; + const folder = () => data.folders.find(item => item.id === folderId); + const normalizedRules = value => JSON.stringify(value.filter(rule => rule.level > 0).map(rule => [rule.principalId, rule.level]).sort((a, b) => a[0].localeCompare(b[0]))); + const loadDraft = () => { + rules = (folder()?.permissions || []).filter(rule => rule.level > 0).map(rule => ({...rule})); + state.accessDirty = false; + }; + const dirty = () => normalizedRules(rules) !== normalizedRules(folder()?.permissions || []); + const canLeave = () => !dirty() || window.confirm("Ungespeicherte Änderungen verwerfen?"); + const levelFor = sid => rules.find(rule => rule.principalId === sid)?.level || 0; + content.innerHTML = `
      ${pageHead("Zugriffsverwaltung", "Individuelle Benutzerrechte je Datenordner festlegen.")} + +
      + +
      +
      +
      `; + const root = content.querySelector(".access-view"); + const detail = root.querySelector("#access-detail"); + const message = root.querySelector("#access-message"); + + function updateDirty() { + state.accessDirty = dirty(); + const status = detail.querySelector("#access-save-state"); + if (status) status.textContent = busy ? "Änderungen werden angewendet…" : dirty() ? "Ungespeicherte Änderungen" : "Alle Änderungen gespeichert"; + const save = detail.querySelector("#access-save"); + if (save) save.disabled = busy || !dirty() || !folder()?.active; + const discard = detail.querySelector("#access-discard"); + if (discard) discard.hidden = !dirty(); + } + + async function save(body) { + if (busy) return; + busy = true; + message.hidden = true; + root.querySelectorAll("button,input,select").forEach(input => { input.disabled = true; }); + updateDirty(); + try { + data = await api("/api/access", {method: "POST", body: JSON.stringify(body)}); + if (body.action === "create-folder") { + folderFilter = "active"; query = ""; userQuery = ""; userFilter = "all"; + folderId = data.folders.find(item => item.name === body.name.trim())?.id; + } + if (body.action === "archive-folder") folderFilter = "archived"; + if (body.action === "restore-folder") folderFilter = "active"; + if (!root.isConnected) return; + loadDraft(); draw(); + message.className = "access-message success"; + message.textContent = "Gespeichert. Die Änderungen sind wirksam."; + message.hidden = false; + } catch (error) { + if (!root.isConnected) return; + message.className = "access-message failure"; + message.textContent = error.message; + message.hidden = false; + message.scrollIntoView({block: "nearest"}); + } finally { + busy = false; + if (root.isConnected) { + root.querySelectorAll("button,input,select").forEach(input => { input.disabled = false; }); + detail.querySelectorAll("[data-unavailable]").forEach(input => { input.disabled = true; }); + updateDirty(); + } + } + } + + function dialog(title, explanation, label, action, initial = null, danger = false) { + const modal = document.createElement("dialog"); + modal.className = "access-dialog"; + modal.setAttribute("aria-label", title); + modal.innerHTML = `

      ${esc(title)}

      ${esc(explanation)}

      ${initial !== null ? `` : ""}
      `; + document.body.append(modal); + modal.querySelector("[data-cancel]").addEventListener("click", () => modal.close()); + modal.addEventListener("close", () => modal.remove()); + modal.querySelector("form").addEventListener("submit", event => { + event.preventDefault(); + const input = modal.querySelector("input"), name = input?.value.trim(); + if (initial !== null && !name) { input.setCustomValidity("Bitte einen Namen eingeben."); input.reportValidity(); return; } + modal.close(); action(name); + }); + modal.querySelector("input")?.addEventListener("input", event => event.target.setCustomValidity("")); + modal.showModal(); modal.querySelector("input")?.focus(); + } + + function drawList() { + const items = data.folders.filter(item => folderFilter === "all" || item.active === (folderFilter === "active")) + .filter(item => item.name.toLocaleLowerCase("de").includes(query.toLocaleLowerCase("de"))) + .sort((a, b) => a.name.localeCompare(b.name, "de")); + root.querySelector("#access-list").innerHTML = items.length ? items.map(item => { + const count = item.permissions.filter(rule => rule.level > 0).length; + const subtitle = item.active ? `${count} Benutzer` : "Archiviert"; + return ``; + }).join("") : `
      ${query ? "Keine Treffer. Suchbegriff ändern." : "Keine Ordner in dieser Ansicht."}
      `; + root.querySelectorAll("[data-select]").forEach(button => button.addEventListener("click", () => { + if (button.dataset.select === folderId || !canLeave()) return; + folderId = button.dataset.select; userQuery = ""; userFilter = "all"; + loadDraft(); drawList(); drawDetail(); + })); + } + + function drawUsers() { + const users = data.users.filter(item => item.available !== false || (folder()?.permissions || []).some(rule => rule.principalId === item.sid)) + .sort((a, b) => a.name.localeCompare(b.name, "de", {numeric: true}) || a.sam.localeCompare(b.sam, "de")); + const visible = users.filter(item => `${item.sam} ${item.name}`.toLocaleLowerCase("de").includes(userQuery.toLocaleLowerCase("de"))) + .filter(item => userFilter === "all" || (userFilter === "granted" ? levelFor(item.sid) > 0 : levelFor(item.sid) === 0)); + detail.querySelector("#access-users").innerHTML = visible.length ? visible.map(item => `
      ${esc(item.name)}${esc(item.sam)}${item.available === false ? " · Nicht mehr im AD" : ""}
      ${permissionLabels.map((label, level) => ``).join("")}
      `).join("") : '
      Keine Benutzer für diesen Filter.
      '; + detail.querySelectorAll("[data-user-rule]").forEach(button => button.addEventListener("click", () => { + const sid = button.dataset.userRule, level = Number(button.dataset.level); + rules = rules.filter(rule => rule.principalId !== sid); + if (level) rules.push({kind: "user", principalId: sid, level}); + if (userFilter !== "all") drawUsers(); + else { + button.closest(".access-level-options").querySelectorAll("button").forEach(option => option.setAttribute("aria-pressed", String(Number(option.dataset.level) === level))); + updateUserCount(visible.length); + } + updateDirty(); + })); + updateUserCount(visible.length); + } + + function updateUserCount(visibleCount) { + detail.querySelector("#access-user-count").textContent = `${visibleCount} angezeigt · ${rules.filter(rule => rule.level > 0).length} ${folder()?.active ? "mit Zugriff" : "mit gespeicherten Rechten"} · ${dirty() ? "Vorschau der ungespeicherten Rechte" : "Gespeicherte Rechte"}`; + } + + function drawDetail() { + const selected = folder(); + if (!selected) { + detail.innerHTML = data.folders.length ? '

      Kein Ordner in dieser Ansicht

      Den Ordnerstatus wechseln oder einen neuen Ordner anlegen.

      ' : '

      Ersten Ordner anlegen

      Mit „Neuer Ordner“ beginnen. Anschließend die Rechte für einzelne AD-Benutzer festlegen.

      '; + return; + } + detail.innerHTML = `

      ${esc(selected.name)}

      ${selected.active ? "Zugriff für jeden AD-Benutzer einzeln festlegen. Ohne Zuweisung kein Zugriff." : "Dieser Ordner ist archiviert und für Benutzer nicht erreichbar. Gespeicherte Rechte gelten nach dem Wiederherstellen."}

      +

      Benutzerrechte

      +
      +

      +
      AD-Benutzer${permissionLabels.map((label, level) => `${level}${esc(label)}`).join("")}
      +
      +
      `; + detail.querySelector("#access-user-filter").value = userFilter; + detail.querySelector("#access-user-search").addEventListener("input", event => { userQuery = event.target.value; drawUsers(); }); + detail.querySelector("#access-user-filter").addEventListener("change", event => { userFilter = event.target.value; drawUsers(); }); + detail.querySelector("#access-save").addEventListener("click", () => save({action: "set-permissions", id: selected.id, permissions: rules})); + detail.querySelector("#access-discard").addEventListener("click", () => { loadDraft(); drawDetail(); }); + detail.querySelector("#access-archive").addEventListener("click", () => { + if (!canLeave()) return; + dialog(selected.active ? "Ordner archivieren?" : "Ordner wiederherstellen?", selected.active ? `„${selected.name}“ wird für Benutzer ausgeblendet. Alle Dateien bleiben erhalten.` : `„${selected.name}“ wird mit den gespeicherten Rechten wieder freigegeben.`, selected.active ? "Archivieren" : "Wiederherstellen", () => save({action: selected.active ? "archive-folder" : "restore-folder", id: selected.id}), null, selected.active); + }); + drawUsers(); updateDirty(); + } + + function draw() { + root.querySelector("#access-search").value = query; + root.querySelector("#folder-status").value = folderFilter; + drawList(); drawDetail(); + } + root.querySelector("#access-search").addEventListener("input", event => { query = event.target.value; drawList(); }); + root.querySelector("#folder-status").addEventListener("change", event => { + if (!canLeave()) { event.target.value = folderFilter; return; } + folderFilter = event.target.value; + const visible = data.folders.filter(item => folderFilter === "all" || item.active === (folderFilter === "active")); + if (!visible.some(item => item.id === folderId)) { folderId = visible[0]?.id; userQuery = ""; userFilter = "all"; } + loadDraft(); drawList(); drawDetail(); + }); + root.querySelector("#access-create").addEventListener("click", () => { + if (!canLeave()) return; + dialog("Neuen Datenordner anlegen", "Der Ordner startet ohne Benutzerzugriff. Danach die gewünschten AD-Benutzer individuell berechtigen.", "Anlegen", name => save({action: "create-folder", name}), ""); + }); + loadDraft(); draw(); +} + async function renderStorage(type) { const data = await api("/api/storage"); state.storage = data; let query = ""; let sort = "size-desc"; - content.innerHTML = pageHead(type === "data" ? "Datenbelegung" : "Benutzerbelegung", type === "data" ? "Belegter Speicher je aktivem Gruppenordner unter /Data." : "Gemeinsame Belegung von /Private und /FSLogix je Benutzer.", `Geprüft ${esc(utcTime(data.scannedAt))} in ${esc(data.scanSeconds || 0)} s`) + ` -
      `; + content.innerHTML = pageHead(type === "data" ? "Datenbelegung" : "Benutzerbelegung", type === "data" ? "Belegter Speicher je aktivem Datenordner unter /Data." : "Gemeinsame Belegung von /Private und /FSLogix je Benutzer.", `Geprüft ${esc(utcTime(data.scannedAt))} in ${esc(data.scanSeconds || 0)} s`) + ` +
      `; const draw = () => { const source = [...(type === "data" ? data.groups || [] : data.users || [])]; let rows = source.filter(row => row.name.toLowerCase().includes(query)); @@ -512,7 +701,7 @@ function reconciliationMarkup(data) { async function renderReconciliation() { content.innerHTML = pageHead( "Freigabenabgleich", - "Freigaben, Gruppenordner und Berechtigungen sofort mit Active Directory abgleichen.", + "Ordnerrechte anwenden und AD-Benutzerinformationen aktualisieren.", '' ) + '
      '; const button = document.querySelector("#start-reconciliation"); @@ -649,5 +838,8 @@ document.querySelector("#logout").addEventListener("click", async () => { try { document.querySelector("#menu-toggle").addEventListener("click", () => document.querySelector(".sidebar").classList.toggle("open")); nav.addEventListener("click", event => { const link = event.target.closest("a"); if (link) { event.preventDefault(); navigate(link.pathname); } }); window.addEventListener("popstate", () => navigate(location.pathname, true)); +window.addEventListener("beforeunload", event => { + if (state.accessDirty) { event.preventDefault(); event.returnValue = ""; } +}); api("/api/session").then(showApp).catch(showLogin); diff --git a/app/web/index.html b/app/web/index.html index eb27a88..a778e1e 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -27,6 +27,7 @@