more fine grained access control
This commit is contained in:
@@ -29,7 +29,6 @@ ACME_HTTP_PORT=80
|
|||||||
# WEB_JWT_TTL_SECONDS=28800
|
# WEB_JWT_TTL_SECONDS=28800
|
||||||
# WEB_USAGE_SCAN_INTERVAL_SECONDS=900
|
# WEB_USAGE_SCAN_INTERVAL_SECONDS=900
|
||||||
# WEB_DIRECTORY_CACHE_SECONDS=300
|
# WEB_DIRECTORY_CACHE_SECONDS=300
|
||||||
# WEB_MAX_GROUP_NODES=10000
|
|
||||||
# WEB_LOGIN_ATTEMPTS_PER_5_MIN=10
|
# WEB_LOGIN_ATTEMPTS_PER_5_MIN=10
|
||||||
# TRASH_RETENTION_DAYS=7
|
# TRASH_RETENTION_DAYS=7
|
||||||
# LDAP_URI=ldaps://example.com
|
# LDAP_URI=ldaps://example.com
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ RUN apt-get update \
|
|||||||
libnss-winbind \
|
libnss-winbind \
|
||||||
libpam-winbind \
|
libpam-winbind \
|
||||||
python3 \
|
python3 \
|
||||||
|
python3-samba \
|
||||||
p7zip-full \
|
p7zip-full \
|
||||||
rclone \
|
rclone \
|
||||||
rsync \
|
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/backup_to_destination.py /app/backup_to_destination.py
|
||||||
COPY app/audit_policy.py /app/audit_policy.py
|
COPY app/audit_policy.py /app/audit_policy.py
|
||||||
COPY app/state_db.py /app/state_db.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_store.py /app/audit_store.py
|
||||||
COPY app/audit_collector.py /app/audit_collector.py
|
COPY app/audit_collector.py /app/audit_collector.py
|
||||||
COPY app/trash.py /app/trash.py
|
COPY app/trash.py /app/trash.py
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ This repository provides a production-oriented Samba file server container that
|
|||||||
- `\\server\Private` -> `/data/private`
|
- `\\server\Private` -> `/data/private`
|
||||||
- `\\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>`).
|
- Data folders and individual user permissions are managed in the admin web UI. AD remains the source of user identities and authentication.
|
||||||
- Data folder ACLs expand nested AD group membership recursively, resolve groups by SID, include `primaryGroupID` membership, and detect group cycles.
|
- Data access uses Windows ACLs stored in Samba’s protected `/state/data-xattrs.tdb`, with separate read, modify, and delete permissions.
|
||||||
- Group records, normalized activity events, collector offsets, and web caches share one SQLite database at `/state/shares.db`.
|
- Folder and permission 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:
|
- Managed folders are name-based while active and moved to archive through the admin UI:
|
||||||
- active: `/data/groups/data/<groupName>`
|
- active: `/data/groups/data/<folderName>`
|
||||||
- inactive/deleted groups: `/data/groups/archive/<groupName>`
|
- archived folders: `/data/groups/archive/<folderName>`
|
||||||
- Samba machine trust/key material is persisted in `/var/lib/samba` to survive container recreation.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
## 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/<groupName>`.
|
| Level | Access |
|
||||||
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/...`.
|
| 0 | No access; folder hidden |
|
||||||
4. Previously inactive group returns -> set `isActive=1`, move back into `/data/groups/data/...`.
|
| 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
|
## 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:
|
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_events`: normalized read, write, move, and delete events;
|
||||||
- `audit_sources`: Samba log inode/offset checkpoints;
|
- `audit_sources`: Samba log inode/offset checkpoints;
|
||||||
- `audit_event_dedup`: bounded, persistent fingerprints for restart-safe main-read and FSLogix-event deduplication;
|
- `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.
|
- Existing AD DS domain reachable from the Docker host.
|
||||||
- Initial admin credentials with rights to create/reset `FileShare_ServiceAccount` during `./setup`.
|
- 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.
|
- `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.
|
- Directory reads use 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:
|
- Existing AD users are selectable by the administrator; no AD group naming convention is required for new Data folders.
|
||||||
- `FS_<Anything>`
|
- Machine-account LDAP reads provide the user list and the one-time legacy membership import.
|
||||||
- Folder names use AD group display names (`displayName`, then `name`/`cn` fallback), not pre-2000 (`sAMAccountName`) names.
|
|
||||||
|
|
||||||
## DNS Requirements
|
## DNS Requirements
|
||||||
|
|
||||||
@@ -101,6 +121,7 @@ Kerberos requires close time alignment.
|
|||||||
- `README.md`
|
- `README.md`
|
||||||
- `app/init.sh`
|
- `app/init.sh`
|
||||||
- `app/reconcile_shares.py`
|
- `app/reconcile_shares.py`
|
||||||
|
- `app/access_control.py`
|
||||||
- `app/backup_to_destination.py`
|
- `app/backup_to_destination.py`
|
||||||
- `app/audit_collector.py`
|
- `app/audit_collector.py`
|
||||||
- `app/audit_store.py`
|
- `app/audit_store.py`
|
||||||
@@ -180,7 +201,8 @@ The E2E suite verifies:
|
|||||||
|
|
||||||
- CA-issued TLS, hostname validation, HSTS, and CSP;
|
- 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;
|
- 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;
|
- SMB allow/deny behavior and real file operations;
|
||||||
- Data, Private, and FSLogix usage aggregation;
|
- Data, Private, and FSLogix usage aggregation;
|
||||||
- high-level `full_audit` ingestion for all four actions, service-account exclusion, filters, facets, and pagination;
|
- 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.
|
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
|
## Setup
|
||||||
|
|
||||||
1. Run interactive 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`
|
- Share: `\\server\Data`
|
||||||
- Path: `/data/groups/data`
|
- Path: `/data/groups/data`
|
||||||
- Contains one folder per active `FS_*` AD group.
|
- Contains the active top-level folders created or imported into the admin UI.
|
||||||
- Root is discoverable as one share, while access to each group folder is enforced via POSIX/ACL group permissions.
|
- Windows ACLs enforce each user’s effective level, including separate delete rights.
|
||||||
- `FS_*` groups may contain other AD groups; reconciliation recursively grants ACLs to nested groups and logs detected cycles.
|
- Samba’s `acl_xattr` and `xattr_tdb` modules retain the ACLs in a protected store. Local access to the underlying volumes must remain restricted.
|
||||||
- Samba blocks SMB-side ACL edits on Data and forces new items to inherit the group folder owner, group, mode, and default ACLs.
|
- New files and directories inherit the managed ACL. Users cannot change ACLs or take ownership to grant themselves more access.
|
||||||
- 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.
|
- 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 group folder names are allowed and are not hidden over SMB.
|
- Dot-prefixed folder names are allowed and are not hidden over SMB.
|
||||||
- No guest access.
|
- No guest access.
|
||||||
|
|
||||||
### FSLogix
|
### FSLogix
|
||||||
@@ -304,20 +338,21 @@ 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 folder 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.
|
- **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.
|
- **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.
|
||||||
- **Trash**: list seven-day recycle entries across all shares, download a retained file, or restore it without overwriting an existing path.
|
- **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.
|
- **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.
|
- **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.
|
- **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 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
|
### Authentication and sessions
|
||||||
|
|
||||||
@@ -398,7 +433,6 @@ Useful optional settings:
|
|||||||
| `WEB_LOGIN_ATTEMPTS_PER_5_MIN` | `10` | Per-address login attempt limit |
|
| `WEB_LOGIN_ATTEMPTS_PER_5_MIN` | `10` | Per-address login attempt limit |
|
||||||
| `WEB_USAGE_SCAN_INTERVAL_SECONDS` | `900` | Recursive storage scan interval |
|
| `WEB_USAGE_SCAN_INTERVAL_SECONDS` | `900` | Recursive storage scan interval |
|
||||||
| `WEB_DIRECTORY_CACHE_SECONDS` | `300` | AD membership tree cache time |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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
|
docker compose exec samba wbinfo -g
|
||||||
```
|
```
|
||||||
|
|
||||||
### Data folders not appearing
|
### Data folders or access missing
|
||||||
|
|
||||||
- Confirm AD groups match `FS_*`.
|
- Check **Zugriffsverwaltung**: the folder must be active, and the user must have an individual assignment.
|
||||||
- Run manual reconciliation and inspect logs:
|
- 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
|
```bash
|
||||||
docker compose exec samba python3 /app/reconcile_shares.py
|
docker compose exec samba python3 /app/reconcile_shares.py
|
||||||
docker compose exec samba tail -n 100 /var/log/reconcile.log
|
docker compose exec samba tail -n 100 /var/log/reconcile.log
|
||||||
```
|
```
|
||||||
|
|
||||||
### Nested or primary Data group access fails
|
- Inspect Windows ACLs with `smbcacls //server/Data '<folder>' -U 'DOMAIN\admin'`; `getfacl` does not show the enforced Data permissions.
|
||||||
|
|
||||||
- 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/<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
|
||||||
|
|
||||||
@@ -611,5 +633,5 @@ Users whose group is represented only by AD `primaryGroupID` are included automa
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- User data is never automatically deleted.
|
- 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`).
|
- Data and state survive container restarts via named Docker volumes (`/data/*`, `/state`, `/var/lib/samba`).
|
||||||
|
|||||||
@@ -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}
|
||||||
+1
-1
@@ -32,7 +32,7 @@ require_vfs_modules() {
|
|||||||
|
|
||||||
local missing=0
|
local missing=0
|
||||||
local module
|
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
|
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
|
printf '[init] ERROR: missing VFS module %s at %s/vfs/%s.so\n' "$module" "$modules_dir" "$module" >&2
|
||||||
missing=1
|
missing=1
|
||||||
|
|||||||
+11
-25
@@ -1711,31 +1711,17 @@ def with_lock() -> bool:
|
|||||||
|
|
||||||
conn = open_db()
|
conn = open_db()
|
||||||
try:
|
try:
|
||||||
status_progress(10, "directory", "Reading data folder groups from AD")
|
try:
|
||||||
phase_started = time.monotonic()
|
from . import access_control
|
||||||
groups = fetch_fileshare_groups()
|
except ImportError:
|
||||||
log(
|
import access_control
|
||||||
f"Discovered {len(groups)} data folder group(s) from AD "
|
status_progress(10, "database", "Loading admin-managed folders")
|
||||||
f"in {format_duration(time.monotonic() - phase_started)}"
|
access_control.migrate(conn)
|
||||||
)
|
access_control.recover_pending(conn)
|
||||||
|
status_progress(32, "data-permissions", "Syncing managed folder permissions")
|
||||||
status_progress(22, "database", "Reconciling data folder database")
|
access_control.sync_permissions(conn)
|
||||||
log("Reconciling data folder DB")
|
conn.commit()
|
||||||
phase_started = time.monotonic()
|
log("Synced admin-managed Data folder permissions")
|
||||||
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)}"
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -303,7 +303,7 @@ def _remove_empty_trash_parents(repository: str, relative_path: str) -> None:
|
|||||||
current = os.path.dirname(current)
|
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)
|
share, share_root, relative_path, parts, info = _resolved_item(item_id)
|
||||||
original = _original_relative(relative_path)
|
original = _original_relative(relative_path)
|
||||||
original_parts = _relative_parts(original)
|
original_parts = _relative_parts(original)
|
||||||
@@ -323,6 +323,12 @@ def restore_item(item_id: str) -> Dict[str, object]:
|
|||||||
)
|
)
|
||||||
linked = False
|
linked = False
|
||||||
try:
|
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(
|
os.link(
|
||||||
parts[-1],
|
parts[-1],
|
||||||
original_parts[-1],
|
original_parts[-1],
|
||||||
|
|||||||
+203
-11
@@ -76,6 +76,7 @@ function notice(message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showLogin() {
|
function showLogin() {
|
||||||
|
state.accessDirty = false;
|
||||||
clearInterval(state.timer);
|
clearInterval(state.timer);
|
||||||
state.session = null;
|
state.session = null;
|
||||||
appView.hidden = true;
|
appView.hidden = true;
|
||||||
@@ -101,6 +102,7 @@ function badge(text, kind = "") { return `<span class="badge ${kind}">${esc(text
|
|||||||
function routeFor(path) {
|
function routeFor(path) {
|
||||||
if (path.startsWith("/storage/data")) return "storage-data";
|
if (path.startsWith("/storage/data")) return "storage-data";
|
||||||
if (path.startsWith("/storage/users")) return "storage-users";
|
if (path.startsWith("/storage/users")) return "storage-users";
|
||||||
|
if (path.startsWith("/access")) return "access";
|
||||||
if (path.startsWith("/shares")) return "shares";
|
if (path.startsWith("/shares")) return "shares";
|
||||||
if (path.startsWith("/reconciliation")) return "reconciliation";
|
if (path.startsWith("/reconciliation")) return "reconciliation";
|
||||||
if (path.startsWith("/activity/fslogix")) return "activity-fslogix";
|
if (path.startsWith("/activity/fslogix")) return "activity-fslogix";
|
||||||
@@ -113,6 +115,12 @@ function routeFor(path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function navigate(path, replace = false) {
|
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);
|
clearInterval(state.timer);
|
||||||
state.timer = null;
|
state.timer = null;
|
||||||
const route = routeFor(path);
|
const route = routeFor(path);
|
||||||
@@ -123,6 +131,7 @@ async function navigate(path, replace = false) {
|
|||||||
try {
|
try {
|
||||||
if (route === "overview") await renderOverview();
|
if (route === "overview") await renderOverview();
|
||||||
if (route === "shares") await renderShares();
|
if (route === "shares") await renderShares();
|
||||||
|
if (route === "access") await renderAccess();
|
||||||
if (route === "reconciliation") await renderReconciliation();
|
if (route === "reconciliation") await renderReconciliation();
|
||||||
if (route === "storage-data") await renderStorage("data");
|
if (route === "storage-data") await renderStorage("data");
|
||||||
if (route === "storage-users") await renderStorage("users");
|
if (route === "storage-users") await renderStorage("users");
|
||||||
@@ -158,7 +167,7 @@ async function renderOverview() {
|
|||||||
<section class="cards">
|
<section class="cards">
|
||||||
<article class="card"><span class="label">Daten</span><span class="value">${bytes(totals.dataBytes)}</span></article>
|
<article class="card"><span class="label">Daten</span><span class="value">${bytes(totals.dataBytes)}</span></article>
|
||||||
<article class="card"><span class="label">Private + FSLogix</span><span class="value">${bytes(Number(totals.privateBytes || 0) + Number(totals.fslogixBytes || 0))}</span></article>
|
<article class="card"><span class="label">Private + FSLogix</span><span class="value">${bytes(Number(totals.privateBytes || 0) + Number(totals.fslogixBytes || 0))}</span></article>
|
||||||
<article class="card"><span class="label">Aktive Gruppen</span><span class="value">${esc(data.activeGroups)}</span></article>
|
<article class="card"><span class="label">Aktive Datenordner</span><span class="value">${esc(data.activeGroups)}</span></article>
|
||||||
<article class="card"><span class="label">Ereignisse · 48 Std.</span><span class="value">${esc(data.eventCount)}</span></article>
|
<article class="card"><span class="label">Ereignisse · 48 Std.</span><span class="value">${esc(data.eventCount)}</span></article>
|
||||||
</section>
|
</section>
|
||||||
<section class="split">
|
<section class="split">
|
||||||
@@ -178,7 +187,7 @@ async function renderOverview() {
|
|||||||
function usageTable(rows, type) {
|
function usageTable(rows, type) {
|
||||||
if (!rows.length) return empty("Die erste Speicherprüfung ist noch nicht abgeschlossen.");
|
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);
|
const maximum = Math.max(...rows.map(row => Number(type === "group" ? row.bytes : row.totalBytes)), 1);
|
||||||
return `<div class="table-wrap"><table><thead><tr><th>${type === "group" ? "Gruppenordner" : "Benutzer"}</th>${type === "user" ? "<th>Private</th><th>FSLogix</th>" : ""}<th>Belegung</th><th></th></tr></thead><tbody>${rows.map(row => {
|
return `<div class="table-wrap"><table><thead><tr><th>${type === "group" ? "Datenordner" : "Benutzer"}</th>${type === "user" ? "<th>Private</th><th>FSLogix</th>" : ""}<th>Belegung</th><th></th></tr></thead><tbody>${rows.map(row => {
|
||||||
const value = Number(type === "group" ? row.bytes : row.totalBytes);
|
const value = Number(type === "group" ? row.bytes : row.totalBytes);
|
||||||
return `<tr><td><strong>${esc(row.name)}</strong></td>${type === "user" ? `<td class="numeric">${bytes(row.privateBytes)}</td><td class="numeric">${bytes(row.fslogixBytes)}</td>` : ""}<td class="numeric">${bytes(value)}</td><td class="usage-bar"><progress max="${maximum}" value="${value}"></progress></td></tr>`;
|
return `<tr><td><strong>${esc(row.name)}</strong></td>${type === "user" ? `<td class="numeric">${bytes(row.privateBytes)}</td><td class="numeric">${bytes(row.fslogixBytes)}</td>` : ""}<td class="numeric">${bytes(value)}</td><td class="usage-bar"><progress max="${maximum}" value="${value}"></progress></td></tr>`;
|
||||||
}).join("")}</tbody></table></div>`;
|
}).join("")}</tbody></table></div>`;
|
||||||
@@ -202,10 +211,10 @@ 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 = `<div class="shares-view">` + 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", "Verwaltete Ordner und ihre individuellen Benutzerberechtigungen.", `<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")} Mitgliedschaftsliste ist unvollständig.</p>` : ""}
|
||||||
<section class="split shares-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>Datenordner</h2><span>${groups.length}</span></div><input id="group-filter" type="search" placeholder="Ordner oder Benutzer 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></div>`;
|
</section></div>`;
|
||||||
const list = document.querySelector("#group-list");
|
const list = document.querySelector("#group-list");
|
||||||
@@ -215,22 +224,202 @@ async function renderShares() {
|
|||||||
const draw = () => {
|
const draw = () => {
|
||||||
const visible = groups.filter(group => `${group.name} ${group.sam} ${group.folder}`.toLowerCase().includes(query) || group.members.some(node => nodeMatches(node, query)));
|
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;
|
if (selected && !visible.includes(selected)) selected = visible[0] || null;
|
||||||
list.innerHTML = visible.length ? visible.map(group => `<li><button class="select-row ${group === selected ? "active" : ""}" data-guid="${esc(group.guid)}"><span><strong>${esc(group.folder)}</strong><br><span class="muted">${esc(group.sam)}</span></span><span>${group.userCount} Benutzer<br>${group.groupCount} Gruppen</span></button></li>`).join("") : empty("Keine Gruppe entspricht dem Filter.");
|
list.innerHTML = visible.length ? visible.map(group => `<li><button class="select-row ${group === selected ? "active" : ""}" data-guid="${esc(group.guid)}"><span><strong>${esc(group.folder)}</strong><br><span class="muted">${esc(group.sam)}</span></span><span>${group.userCount} Benutzer</span></button></li>`).join("") : empty("Kein Ordner entspricht dem Filter.");
|
||||||
if (!selected) tree.innerHTML = empty("Gruppenordner auswählen.");
|
if (!selected) tree.innerHTML = empty("Datenordner auswählen.");
|
||||||
else tree.innerHTML = `<div class="panel-head"><div><h2>${esc(selected.folder)}</h2><span class="muted">${esc(selected.sam)} · ${selected.userCount} wirksame Benutzer</span></div>${selected.active ? badge("Aktiv") : badge("Archiviert", "warn")}</div><div class="tree">${treeNodes(selected.members, query) || empty("Keine direkten Mitglieder")}</div>`;
|
else tree.innerHTML = `<div class="panel-head"><div><h2>${esc(selected.folder)}</h2><span class="muted">${esc(selected.sam)} · ${selected.userCount} wirksame Benutzer</span></div>${selected.active ? badge("Aktiv") : badge("Archiviert", "warn")}</div><div class="tree">${treeNodes(selected.members, query) || empty("Keine Benutzer mit Zugriff")}</div>`;
|
||||||
list.querySelectorAll("button[data-guid]").forEach(button => button.addEventListener("click", () => { selected = groups.find(group => group.guid === button.dataset.guid); draw(); }));
|
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(); });
|
document.querySelector("#group-filter").addEventListener("input", event => { query = event.target.value.trim().toLowerCase(); draw(); });
|
||||||
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 = `<div class="access-view">${pageHead("Zugriffsverwaltung", "Individuelle Benutzerrechte je Datenordner festlegen.")}
|
||||||
|
<div id="access-message" role="status" hidden></div>
|
||||||
|
<div class="access-workspace">
|
||||||
|
<aside class="access-browser" aria-label="Ordnerauswahl">
|
||||||
|
<div class="access-browser-head"><h2>Datenordner</h2><button type="button" id="access-create">Neuer Ordner</button></div>
|
||||||
|
<label class="access-search"><span class="sr-only">Ordner suchen</span><input id="access-search" type="search" placeholder="Ordner suchen"></label>
|
||||||
|
<label class="access-status-filter"><span class="sr-only">Ordnerstatus</span><select id="folder-status"><option value="active">Aktive Ordner</option><option value="archived">Archivierte Ordner</option><option value="all">Alle Ordner</option></select></label>
|
||||||
|
<div id="access-list" class="access-list"></div>
|
||||||
|
</aside>
|
||||||
|
<section id="access-detail" class="access-detail" aria-labelledby="access-folder-title"></section>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
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 = `<form><h2>${esc(title)}</h2><p>${esc(explanation)}</p>${initial !== null ? `<label>Name<input value="${esc(initial)}" maxlength="120" required autofocus></label>` : ""}<div class="access-dialog-actions"><button type="button" data-cancel>Abbrechen</button><button type="submit" class="${danger ? "access-danger" : ""}">${esc(label)}</button></div></form>`;
|
||||||
|
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 `<button type="button" class="select-row access-list-item ${item.id === folderId ? "active" : ""}" data-select="${esc(item.id)}" aria-pressed="${item.id === folderId}"><strong>${esc(item.name)}</strong><small>${esc(subtitle)}</small></button>`;
|
||||||
|
}).join("") : `<div class="access-empty">${query ? "Keine Treffer. Suchbegriff ändern." : "Keine Ordner in dieser Ansicht."}</div>`;
|
||||||
|
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 => `<div class="access-matrix-row"><div><strong>${esc(item.name)}</strong><small>${esc(item.sam)}${item.available === false ? " · Nicht mehr im AD" : ""}</small></div><div class="access-level-options" role="group" aria-label="Zugriff für ${esc(item.name)}">${permissionLabels.map((label, level) => `<button type="button" data-user-rule="${esc(item.sid)}" data-level="${level}" aria-pressed="${levelFor(item.sid) === level}" aria-label="${esc(item.name)}: ${level} · ${esc(label)}" ${folder()?.active ? "" : "disabled data-unavailable"}><span class="access-level-number">${level}</span><span class="access-level-caption">${esc(label)}</span></button>`).join("")}</div></div>`).join("") : '<div class="access-empty">Keine Benutzer für diesen Filter.</div>';
|
||||||
|
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 ? '<div class="access-empty"><h2 id="access-folder-title">Kein Ordner in dieser Ansicht</h2><p>Den Ordnerstatus wechseln oder einen neuen Ordner anlegen.</p></div>' : '<div class="access-empty"><h2 id="access-folder-title">Ersten Ordner anlegen</h2><p>Mit „Neuer Ordner“ beginnen. Anschließend die Rechte für einzelne AD-Benutzer festlegen.</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
detail.innerHTML = `<header class="access-detail-head"><div><h2 id="access-folder-title">${esc(selected.name)}</h2><p>${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."}</p></div><button type="button" id="access-archive" class="link-button">${selected.active ? "Archivieren" : "Wiederherstellen"}</button></header>
|
||||||
|
<section class="access-section"><div class="access-section-head"><h3>Benutzerrechte</h3></div>
|
||||||
|
<div class="access-filter-row"><label><span class="sr-only">Benutzer suchen</span><input id="access-user-search" type="search" placeholder="Name oder Benutzername suchen" value="${esc(userQuery)}"></label><label><span class="sr-only">Zugriff filtern</span><select id="access-user-filter"><option value="all">Alle Benutzer</option><option value="granted">${selected.active ? "Mit Zugriff" : "Mit gespeicherten Rechten"}</option><option value="hidden">${selected.active ? "Ohne Zugriff" : "Ohne gespeicherte Rechte"}</option></select></label></div>
|
||||||
|
<p id="access-user-count" class="muted" role="status"></p>
|
||||||
|
<div class="access-matrix-head"><span>AD-Benutzer</span>${permissionLabels.map((label, level) => `<span><b>${level}</b>${esc(label)}</span>`).join("")}</div>
|
||||||
|
<div id="access-users" class="access-users"></div>
|
||||||
|
</section><footer class="access-savebar"><span id="access-save-state"></span><div><button type="button" id="access-discard" hidden>Verwerfen</button><button type="button" id="access-save">Rechte anwenden</button></div></footer>`;
|
||||||
|
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) {
|
async function renderStorage(type) {
|
||||||
const data = await api("/api/storage");
|
const data = await api("/api/storage");
|
||||||
state.storage = data;
|
state.storage = data;
|
||||||
let query = "";
|
let query = "";
|
||||||
let sort = "size-desc";
|
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.", `<span class="muted">Geprüft ${esc(utcTime(data.scannedAt))} in ${esc(data.scanSeconds || 0)} s</span>`) + `
|
content.innerHTML = pageHead(type === "data" ? "Datenbelegung" : "Benutzerbelegung", type === "data" ? "Belegter Speicher je aktivem Datenordner unter /Data." : "Gemeinsame Belegung von /Private und /FSLogix je Benutzer.", `<span class="muted">Geprüft ${esc(utcTime(data.scannedAt))} in ${esc(data.scanSeconds || 0)} s</span>`) + `
|
||||||
<section class="panel"><div class="toolbar"><input id="storage-filter" type="search" placeholder="${type === "data" ? "Gruppenordner" : "Benutzer"} filtern"><select id="storage-sort"><option value="size-desc">Größte zuerst</option><option value="size-asc">Kleinste zuerst</option><option value="name">Name</option></select></div><div id="storage-table"></div></section>`;
|
<section class="panel"><div class="toolbar"><input id="storage-filter" type="search" placeholder="${type === "data" ? "Datenordner" : "Benutzer"} filtern"><select id="storage-sort"><option value="size-desc">Größte zuerst</option><option value="size-asc">Kleinste zuerst</option><option value="name">Name</option></select></div><div id="storage-table"></div></section>`;
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
const source = [...(type === "data" ? data.groups || [] : data.users || [])];
|
const source = [...(type === "data" ? data.groups || [] : data.users || [])];
|
||||||
let rows = source.filter(row => row.name.toLowerCase().includes(query));
|
let rows = source.filter(row => row.name.toLowerCase().includes(query));
|
||||||
@@ -512,7 +701,7 @@ function reconciliationMarkup(data) {
|
|||||||
async function renderReconciliation() {
|
async function renderReconciliation() {
|
||||||
content.innerHTML = pageHead(
|
content.innerHTML = pageHead(
|
||||||
"Freigabenabgleich",
|
"Freigabenabgleich",
|
||||||
"Freigaben, Gruppenordner und Berechtigungen sofort mit Active Directory abgleichen.",
|
"Ordnerrechte anwenden und AD-Benutzerinformationen aktualisieren.",
|
||||||
'<button id="start-reconciliation" type="button">Freigaben jetzt abgleichen</button>'
|
'<button id="start-reconciliation" type="button">Freigaben jetzt abgleichen</button>'
|
||||||
) + '<div id="reconciliation-body"></div>';
|
) + '<div id="reconciliation-body"></div>';
|
||||||
const button = document.querySelector("#start-reconciliation");
|
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"));
|
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); } });
|
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("popstate", () => navigate(location.pathname, true));
|
||||||
|
window.addEventListener("beforeunload", event => {
|
||||||
|
if (state.accessDirty) { event.preventDefault(); event.returnValue = ""; }
|
||||||
|
});
|
||||||
|
|
||||||
api("/api/session").then(showApp).catch(showLogin);
|
api("/api/session").then(showApp).catch(showLogin);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
<nav id="navigation" aria-label="Hauptnavigation">
|
<nav id="navigation" aria-label="Hauptnavigation">
|
||||||
<a href="/overview" data-route="overview">Übersicht</a>
|
<a href="/overview" data-route="overview">Übersicht</a>
|
||||||
<a href="/shares" data-route="shares">Dateifreigaben</a>
|
<a href="/shares" data-route="shares">Dateifreigaben</a>
|
||||||
|
<a href="/access" data-route="access">Zugriffsverwaltung</a>
|
||||||
<a href="/reconciliation" data-route="reconciliation">Freigabenabgleich</a>
|
<a href="/reconciliation" data-route="reconciliation">Freigabenabgleich</a>
|
||||||
<a href="/storage/data" data-route="storage-data">Datenbelegung</a>
|
<a href="/storage/data" data-route="storage-data">Datenbelegung</a>
|
||||||
<a href="/storage/users" data-route="storage-users">Benutzerbelegung</a>
|
<a href="/storage/users" data-route="storage-users">Benutzerbelegung</a>
|
||||||
|
|||||||
+6
-8
@@ -184,7 +184,7 @@ export function buildReportSource(data) {
|
|||||||
["Server", system.hostname || "—"],
|
["Server", system.hostname || "—"],
|
||||||
["Erstellt", formatUtc(generatedAt)],
|
["Erstellt", formatUtc(generatedAt)],
|
||||||
["Verzeichnisstand", formatUtc(groupsSnapshot.fetchedAt)],
|
["Verzeichnisstand", formatUtc(groupsSnapshot.fetchedAt)],
|
||||||
["Dateifreigabegruppen", String(groups.length)],
|
["Datenordner", String(groups.length)],
|
||||||
["Verzeichnisabfrage gekürzt", groupsSnapshot.truncated ? "Ja" : "Nein"],
|
["Verzeichnisabfrage gekürzt", groupsSnapshot.truncated ? "Ja" : "Nein"],
|
||||||
["Speicherstand", formatUtc(storage.scannedAt)],
|
["Speicherstand", formatUtc(storage.scannedAt)],
|
||||||
["Serverzeit", formatUtc(system.serverTime)],
|
["Serverzeit", formatUtc(system.serverTime)],
|
||||||
@@ -203,12 +203,12 @@ export function buildReportSource(data) {
|
|||||||
["Prüfdauer", formatDecimal(storage.scanSeconds, 3) + " s"],
|
["Prüfdauer", formatDecimal(storage.scanSeconds, 3) + " s"],
|
||||||
]),
|
]),
|
||||||
"",
|
"",
|
||||||
"== Datenbelegung je Gruppenordner",
|
"== Datenbelegung je Datenordner",
|
||||||
(storage.groups || []).length
|
(storage.groups || []).length
|
||||||
? reportTable(
|
? reportTable(
|
||||||
["1fr", "auto"],
|
["1fr", "auto"],
|
||||||
["left", "right"],
|
["left", "right"],
|
||||||
["Gruppenordner", "Belegung"],
|
["Datenordner", "Belegung"],
|
||||||
storage.groups.map(row => [row.name || "—", formatBytes(row.bytes)])
|
storage.groups.map(row => [row.name || "—", formatBytes(row.bytes)])
|
||||||
)
|
)
|
||||||
: "Noch keine Speicherprüfung vorhanden.",
|
: "Noch keine Speicherprüfung vorhanden.",
|
||||||
@@ -228,10 +228,10 @@ export function buildReportSource(data) {
|
|||||||
)
|
)
|
||||||
: "Noch keine Speicherprüfung vorhanden.",
|
: "Noch keine Speicherprüfung vorhanden.",
|
||||||
"",
|
"",
|
||||||
"= Dateifreigaben und Mitgliedschaften",
|
"= Dateifreigaben und Benutzerrechte",
|
||||||
groupsSnapshot.truncated
|
groupsSnapshot.truncated
|
||||||
? "#text(weight: \"semibold\")[Hinweis: Die Verzeichnisabfrage wurde gekürzt; die nachfolgende Hierarchie ist nicht vollständig.]"
|
? "#text(weight: \"semibold\")[Hinweis: Die Verzeichnisabfrage wurde gekürzt; die nachfolgende Hierarchie ist nicht vollständig.]"
|
||||||
: "Die nachfolgenden Hierarchien zeigen alle wirksamen verschachtelten Mitgliedschaften.",
|
: "Die nachfolgenden Listen zeigen die individuell berechtigten Benutzer je Ordner.",
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
@@ -240,14 +240,12 @@ export function buildReportSource(data) {
|
|||||||
parts.push(keyValueTable([
|
parts.push(keyValueTable([
|
||||||
["Freigabename", group.name || "—"],
|
["Freigabename", group.name || "—"],
|
||||||
["Ordner", group.folder || "—"],
|
["Ordner", group.folder || "—"],
|
||||||
["Ordnergruppe", group.sam || "—"],
|
|
||||||
["Objekt-GUID", group.guid || "—"],
|
["Objekt-GUID", group.guid || "—"],
|
||||||
["Status", group.active ? "Aktiv" : "Archiviert"],
|
["Status", group.active ? "Aktiv" : "Archiviert"],
|
||||||
["Wirksame Benutzer", String(group.userCount || 0)],
|
["Wirksame Benutzer", String(group.userCount || 0)],
|
||||||
["Enthaltene Gruppen", String(group.groupCount || 0)],
|
|
||||||
]));
|
]));
|
||||||
const rows = treeRows(group.members || []);
|
const rows = treeRows(group.members || []);
|
||||||
parts.push(rows.length ? rows.join("\n") : "Keine direkten Mitglieder.");
|
parts.push(rows.length ? rows.join("\n") : "Keine Benutzer mit Zugriff.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const backupState = backupLabel(backup.state, backup.enabled);
|
const backupState = backupLabel(backup.state, backup.enabled);
|
||||||
|
|||||||
@@ -100,3 +100,75 @@ progress { width: 100%; }
|
|||||||
.filters .wide { grid-column: span 1; }
|
.filters .wide { grid-column: span 1; }
|
||||||
.page-head { display: block; }
|
.page-head { display: block; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Access administration uses the shared admin controls, colors and typography. */
|
||||||
|
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||||
|
.access-message { padding: .7rem; margin-bottom: 1rem; border: 1px solid #aaa; }
|
||||||
|
.access-message.failure { color: #900; }
|
||||||
|
.access-workspace { display: grid; grid-template-columns: 260px minmax(0,1fr); gap: 1rem; align-items: start; }
|
||||||
|
.access-browser, .access-detail { min-width: 0; border: 1px solid #aaa; background: #fff; }
|
||||||
|
.access-browser { overflow: hidden; }
|
||||||
|
.access-browser-head { display: grid; gap: .7rem; padding: .8rem; }
|
||||||
|
.access-browser-head h2 { margin: 0; }
|
||||||
|
.access-browser-head button { width: 100%; }
|
||||||
|
.access-search, .access-status-filter { margin: 0 .8rem .65rem; }
|
||||||
|
.access-list { border-top: 1px solid #ccc; max-height: 60vh; overflow-y: auto; }
|
||||||
|
.access-list .access-list-item { grid-template-columns: minmax(0,1fr) auto; align-items: center; padding: .5rem .7rem; white-space: normal; }
|
||||||
|
.access-list-item strong { font-weight: inherit; overflow-wrap: anywhere; }
|
||||||
|
.access-list-item small { color: #555; font-size: .85rem; font-weight: normal; text-align: right; }
|
||||||
|
.access-detail-head { display: flex; gap: 1rem; justify-content: space-between; align-items: start; padding: .8rem; }
|
||||||
|
.access-detail-head h2 { margin: 0 0 .3rem; overflow-wrap: anywhere; }
|
||||||
|
.access-detail-head p { color: #666; font-size: .9rem; margin: 0; max-width: 42rem; }
|
||||||
|
.access-section { padding: .8rem; }
|
||||||
|
.access-section + .access-section { border-top: 1px solid #ccc; }
|
||||||
|
.access-section-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: .7rem; }
|
||||||
|
.access-section-head h3 { margin: 0; }
|
||||||
|
.access-section-head p { font-size: .85rem; margin: .3rem 0 0; }
|
||||||
|
.access-matrix-head { display: grid; grid-template-columns: minmax(150px,1.3fr) repeat(4,minmax(0,1fr)); align-items: end; font-size: .85rem; color: #555; padding-bottom: .5rem; gap: .3rem; }
|
||||||
|
.access-matrix-head > span:not(:first-child) { text-align: center; }
|
||||||
|
.access-matrix-head b { display: block; font-size: .9rem; margin-bottom: .15rem; color: #111; }
|
||||||
|
.access-matrix-row { display: grid; grid-template-columns: minmax(150px,1.3fr) minmax(0,4fr); align-items: center; gap: .3rem; padding: .5rem 0; border-top: 1px solid #ccc; }
|
||||||
|
.access-matrix-row strong { overflow-wrap: anywhere; }
|
||||||
|
.access-matrix-row small { display: block; font-size: .85rem; color: #666; margin-top: .2rem; overflow-wrap: anywhere; }
|
||||||
|
.access-users { max-height: 28rem; overflow-y: auto; }
|
||||||
|
.access-level-options { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: .3rem; }
|
||||||
|
.access-level-options button { padding: .4rem .25rem; }
|
||||||
|
.access-level-options button[aria-pressed="true"] { background: #ddd; border-color: #111; font-weight: bold; }
|
||||||
|
.access-level-number { font-size: 1rem; }
|
||||||
|
.access-level-caption { display: none; white-space: normal; }
|
||||||
|
.access-savebar { display: flex; align-items: center; justify-content: space-between; gap: .75rem; border-top: 1px solid #aaa; padding: .8rem; }
|
||||||
|
.access-savebar > span { color: #666; font-size: .85rem; }
|
||||||
|
.access-savebar > div { display: flex; gap: .5rem; }
|
||||||
|
.access-filter-row { display: grid; grid-template-columns: minmax(0,1fr) minmax(170px,.5fr); gap: .6rem; margin-bottom: .65rem; }
|
||||||
|
.access-empty { padding: .8rem; color: #666; font-size: .9rem; }
|
||||||
|
.access-empty h2 { color: #111; margin-bottom: .5rem; }
|
||||||
|
.access-empty p { margin: 0; }
|
||||||
|
.access-dialog { width: min(480px,calc(100vw - 2rem)); max-height: calc(100dvh - 3rem); border: 1px solid #aaa; padding: 1rem; color: #111; background: #fff; }
|
||||||
|
.access-dialog::backdrop { background: #0006; }
|
||||||
|
.access-dialog p { font-size: .9rem; }
|
||||||
|
.access-dialog-actions { display: flex; gap: .5rem; justify-content: flex-end; margin-top: 1rem; }
|
||||||
|
.access-danger { color: #900; }
|
||||||
|
@media (max-width: 1250px) {
|
||||||
|
.access-workspace { grid-template-columns: 220px minmax(0,1fr); gap: .8rem; }
|
||||||
|
.access-matrix-head { display: none; }
|
||||||
|
.access-matrix-row { grid-template-columns: 1fr; gap: .5rem; padding: .7rem 0; }
|
||||||
|
.access-level-caption { display: block; font-size: .75rem; margin-top: .2rem; }
|
||||||
|
}
|
||||||
|
@media (max-width: 950px) {
|
||||||
|
.access-workspace { grid-template-columns: 1fr; }
|
||||||
|
.access-browser-head { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.access-browser-head button { width: auto; }
|
||||||
|
.access-list { max-height: 12rem; }
|
||||||
|
.access-detail-head { flex-wrap: wrap; }
|
||||||
|
}
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.access-filter-row { grid-template-columns: 1fr; }
|
||||||
|
.access-section-head { align-items: start; flex-wrap: wrap; gap: .6rem; }
|
||||||
|
.access-savebar { flex-direction: column; align-items: stretch; }
|
||||||
|
.access-savebar > div { justify-content: flex-end; }
|
||||||
|
.access-level-options { gap: .2rem; }
|
||||||
|
.access-level-options button { padding: .4rem .1rem; }
|
||||||
|
.access-level-caption { font-size: .72rem; }
|
||||||
|
}
|
||||||
|
.content:focus { outline: none; }
|
||||||
|
|||||||
+60
-187
@@ -27,10 +27,10 @@ from typing import Dict, List, Optional, Tuple
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from app import reconcile_shares as directory
|
from app import reconcile_shares as directory
|
||||||
from app import trash
|
from app import trash, access_control
|
||||||
except ImportError: # Container execution uses /app as the import root.
|
except ImportError: # Container execution uses /app as the import root.
|
||||||
import reconcile_shares as directory
|
import reconcile_shares as directory
|
||||||
import trash
|
import trash, access_control
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app.audit_store import (
|
from app.audit_store import (
|
||||||
@@ -577,25 +577,9 @@ class UsageScanner:
|
|||||||
self.stop.wait(self.interval)
|
self.stop.wait(self.interval)
|
||||||
|
|
||||||
|
|
||||||
def display_name(entry) -> str:
|
|
||||||
return (
|
|
||||||
directory.ldap_first(entry, "displayName")
|
|
||||||
or directory.ldap_first(entry, "sAMAccountName")
|
|
||||||
or directory.ldap_first(entry, "cn")
|
|
||||||
or directory.entry_dn(entry).split(",", 1)[0].removeprefix("CN=")
|
|
||||||
or "Unbekannt"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DirectoryCache:
|
class DirectoryCache:
|
||||||
ATTRS = [
|
|
||||||
"objectGUID", "objectSid", "distinguishedName", "sAMAccountName",
|
|
||||||
"displayName", "cn", "objectClass", "member", "primaryGroupID",
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.ttl = env_int("WEB_DIRECTORY_CACHE_SECONDS", 300, 30, 3600)
|
self.ttl = env_int("WEB_DIRECTORY_CACHE_SECONDS", 300, 30, 3600)
|
||||||
self.max_nodes = env_int("WEB_MAX_GROUP_NODES", 10000, 100, 100000)
|
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
self.cached_at = 0.0
|
self.cached_at = 0.0
|
||||||
self.value: Dict[str, object] = {"groups": [], "fetchedAt": None}
|
self.value: Dict[str, object] = {"groups": [], "fetchedAt": None}
|
||||||
@@ -609,173 +593,12 @@ class DirectoryCache:
|
|||||||
return self.value
|
return self.value
|
||||||
|
|
||||||
def fetch(self) -> Dict[str, object]:
|
def fetch(self) -> Dict[str, object]:
|
||||||
roots = directory.fetch_fileshare_groups()
|
conn = directory.open_db()
|
||||||
entries: Dict[str, object] = {}
|
|
||||||
pending = deque()
|
|
||||||
for root in roots:
|
|
||||||
for dn in root.get("memberDns", []):
|
|
||||||
pending.append(str(dn))
|
|
||||||
requested = set()
|
|
||||||
while pending and len(entries) < self.max_nodes:
|
|
||||||
batch = []
|
|
||||||
while pending and len(batch) < 100:
|
|
||||||
dn = pending.popleft()
|
|
||||||
key = directory.normalize_dn(dn)
|
|
||||||
if not key or key in requested:
|
|
||||||
continue
|
|
||||||
requested.add(key)
|
|
||||||
batch.append(dn)
|
|
||||||
if not batch:
|
|
||||||
continue
|
|
||||||
for entry in directory.search_directory_entries(
|
|
||||||
directory.build_distinguished_name_filter(batch), self.ATTRS
|
|
||||||
):
|
|
||||||
key = directory.normalize_dn(directory.entry_dn(entry))
|
|
||||||
if not key:
|
|
||||||
continue
|
|
||||||
entries[key] = entry
|
|
||||||
classes = {value.lower() for value in directory.ldap_values(entry, "objectClass")}
|
|
||||||
if "group" in classes:
|
|
||||||
pending.extend(directory.ldap_values(entry, "member"))
|
|
||||||
|
|
||||||
group_keys_by_primary_rid: Dict[int, List[str]] = {}
|
|
||||||
for root in roots:
|
|
||||||
root_key = directory.normalize_dn(str(root.get("distinguishedName") or ""))
|
|
||||||
rid = directory.sid_rid(str(root.get("objectSid") or ""))
|
|
||||||
if root_key and rid is not None:
|
|
||||||
group_keys_by_primary_rid.setdefault(rid, []).append(root_key)
|
|
||||||
for key, entry in entries.items():
|
|
||||||
classes = {
|
|
||||||
value.lower()
|
|
||||||
for value in directory.ldap_values(entry, "objectClass")
|
|
||||||
}
|
|
||||||
if "group" not in classes:
|
|
||||||
continue
|
|
||||||
principal = directory.parse_principal_from_entry(entry)
|
|
||||||
rid = directory.sid_rid(
|
|
||||||
str(principal.get("objectSid") or "") if principal else ""
|
|
||||||
)
|
|
||||||
if rid is not None:
|
|
||||||
group_keys_by_primary_rid.setdefault(rid, []).append(key)
|
|
||||||
|
|
||||||
primary_members: Dict[str, List[str]] = {}
|
|
||||||
primary_members_truncated = False
|
|
||||||
if group_keys_by_primary_rid and len(entries) < self.max_nodes:
|
|
||||||
primary_entries = directory.search_directory_entries(
|
|
||||||
directory.build_primary_group_users_filter(
|
|
||||||
group_keys_by_primary_rid.keys()
|
|
||||||
),
|
|
||||||
self.ATTRS,
|
|
||||||
)
|
|
||||||
for entry in primary_entries:
|
|
||||||
key = directory.normalize_dn(directory.entry_dn(entry))
|
|
||||||
rid = directory.parse_int(
|
|
||||||
directory.ldap_first(entry, "primaryGroupID") or "",
|
|
||||||
-1,
|
|
||||||
)
|
|
||||||
if not key or rid not in group_keys_by_primary_rid:
|
|
||||||
continue
|
|
||||||
if key not in entries and len(entries) >= self.max_nodes:
|
|
||||||
primary_members_truncated = True
|
|
||||||
continue
|
|
||||||
entries[key] = entry
|
|
||||||
for group_key in group_keys_by_primary_rid[rid]:
|
|
||||||
primary_members.setdefault(group_key, []).append(
|
|
||||||
directory.entry_dn(entry)
|
|
||||||
)
|
|
||||||
elif group_keys_by_primary_rid:
|
|
||||||
primary_members_truncated = True
|
|
||||||
|
|
||||||
def effective_members(group_key: str, explicit_dns) -> List[str]:
|
|
||||||
result = []
|
|
||||||
seen = set()
|
|
||||||
for dn in [*explicit_dns, *primary_members.get(group_key, [])]:
|
|
||||||
key = directory.normalize_dn(str(dn))
|
|
||||||
if key and key not in seen:
|
|
||||||
seen.add(key)
|
|
||||||
result.append(str(dn))
|
|
||||||
return result
|
|
||||||
|
|
||||||
folder_map = {}
|
|
||||||
try:
|
try:
|
||||||
conn = connect_state_db(STATE_DB, read_only=True)
|
access_control.ensure_schema(conn)
|
||||||
try:
|
return access_control.report_folders(conn)
|
||||||
folder_map = {
|
finally:
|
||||||
row[0]: {"folder": os.path.basename(row[1]), "active": bool(row[2])}
|
conn.close()
|
||||||
for row in conn.execute("SELECT objectGUID, path, isActive FROM shares")
|
|
||||||
}
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
except sqlite3.Error:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def make_node(dn: str, ancestors: set) -> Dict[str, object]:
|
|
||||||
key = directory.normalize_dn(dn)
|
|
||||||
entry = entries.get(key)
|
|
||||||
if entry is None:
|
|
||||||
return {"id": dn, "name": dn, "sam": "", "type": "unknown", "members": []}
|
|
||||||
classes = {value.lower() for value in directory.ldap_values(entry, "objectClass")}
|
|
||||||
node_type = "group" if "group" in classes else "computer" if "computer" in classes else "user"
|
|
||||||
node = {
|
|
||||||
"id": directory.entry_dn(entry),
|
|
||||||
"name": display_name(entry),
|
|
||||||
"sam": directory.ldap_first(entry, "sAMAccountName") or "",
|
|
||||||
"type": node_type,
|
|
||||||
"members": [],
|
|
||||||
}
|
|
||||||
if key in ancestors:
|
|
||||||
node["cycle"] = True
|
|
||||||
return node
|
|
||||||
if node_type == "group":
|
|
||||||
next_ancestors = {*ancestors, key}
|
|
||||||
node["members"] = [
|
|
||||||
make_node(child, next_ancestors)
|
|
||||||
for child in effective_members(
|
|
||||||
key,
|
|
||||||
directory.ldap_values(entry, "member"),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
return node
|
|
||||||
|
|
||||||
group_rows = []
|
|
||||||
for root in sorted(roots, key=lambda item: str(item["shareName"]).casefold()):
|
|
||||||
root_key = directory.normalize_dn(
|
|
||||||
str(root.get("distinguishedName") or "")
|
|
||||||
)
|
|
||||||
members = [
|
|
||||||
make_node(dn, set())
|
|
||||||
for dn in effective_members(root_key, root.get("memberDns", []))
|
|
||||||
]
|
|
||||||
flat_users = set()
|
|
||||||
flat_groups = set()
|
|
||||||
|
|
||||||
def count_nodes(nodes):
|
|
||||||
for node in nodes:
|
|
||||||
target = flat_groups if node["type"] == "group" else flat_users if node["type"] == "user" else None
|
|
||||||
if target is not None:
|
|
||||||
target.add(str(node.get("sam") or node["id"]).casefold())
|
|
||||||
count_nodes(node.get("members", []))
|
|
||||||
|
|
||||||
count_nodes(members)
|
|
||||||
folder = folder_map.get(str(root["objectGUID"]), {})
|
|
||||||
group_rows.append(
|
|
||||||
{
|
|
||||||
"guid": root["objectGUID"],
|
|
||||||
"name": root["shareName"],
|
|
||||||
"sam": root["samAccountName"],
|
|
||||||
"folder": folder.get("folder", root["shareName"]),
|
|
||||||
"active": folder.get("active", True),
|
|
||||||
"userCount": len(flat_users),
|
|
||||||
"groupCount": len(flat_groups),
|
|
||||||
"members": members,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"groups": group_rows,
|
|
||||||
"fetchedAt": now_utc().isoformat(timespec="seconds"),
|
|
||||||
"truncated": bool(pending) or primary_members_truncated,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1021,12 +844,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_error_json(HTTPStatus.UNAUTHORIZED, "Anmeldung erforderlich")
|
self.send_error_json(HTTPStatus.UNAUTHORIZED, "Anmeldung erforderlich")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def read_json_body(self) -> Dict[str, object]:
|
def read_json_body(self, maximum: int = 16384) -> Dict[str, object]:
|
||||||
try:
|
try:
|
||||||
length = int(self.headers.get("Content-Length", "0"))
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError("Ungültige Anfragelänge") from exc
|
raise ValueError("Ungültige Anfragelänge") from exc
|
||||||
if length <= 0 or length > 16384:
|
if length <= 0 or length > maximum:
|
||||||
raise ValueError("Ungültiger Anfrageinhalt")
|
raise ValueError("Ungültiger Anfrageinhalt")
|
||||||
try:
|
try:
|
||||||
value = json.loads(self.rfile.read(length))
|
value = json.loads(self.rfile.read(length))
|
||||||
@@ -1101,13 +924,53 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
cookie = f"{JWT_COOKIE}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Strict"
|
cookie = f"{JWT_COOKIE}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Strict"
|
||||||
self.send_json({"ok": True}, cookie=cookie)
|
self.send_json({"ok": True}, cookie=cookie)
|
||||||
return
|
return
|
||||||
|
if parsed.path == "/api/access":
|
||||||
|
user = self.require_user()
|
||||||
|
if user is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
body = self.read_json_body(1024 * 1024)
|
||||||
|
with access_control.mutation_lock():
|
||||||
|
conn = directory.open_db()
|
||||||
|
try:
|
||||||
|
access_control.migrate(conn)
|
||||||
|
users, _ = access_control.read_directory()
|
||||||
|
result = access_control.apply_change(conn, body, str(user["sub"]), users)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
with APP.directory.lock:
|
||||||
|
APP.directory.cached_at = 0
|
||||||
|
log(f"{user['sub']} changed folder access: {body.get('action')}")
|
||||||
|
self.send_json(result)
|
||||||
|
except access_control.AccessConflict as exc:
|
||||||
|
self.send_error_json(HTTPStatus.CONFLICT, str(exc))
|
||||||
|
except (ValueError, sqlite3.IntegrityError) as exc:
|
||||||
|
self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
|
||||||
|
except (OSError, RuntimeError) as exc:
|
||||||
|
log(f"Access management failed: {exc}")
|
||||||
|
self.send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
||||||
|
return
|
||||||
|
|
||||||
if parsed.path == "/api/trash/restore":
|
if parsed.path == "/api/trash/restore":
|
||||||
user = self.require_user()
|
user = self.require_user()
|
||||||
if user is None:
|
if user is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
body = self.read_json_body()
|
body = self.read_json_body()
|
||||||
result = trash.restore_item(str(body.get("id", "")))
|
item_id = str(body.get("id", ""))
|
||||||
|
share, _ = trash.decode_item_id(item_id)
|
||||||
|
if share == "Data":
|
||||||
|
with access_control.mutation_lock():
|
||||||
|
conn = directory.open_db()
|
||||||
|
try:
|
||||||
|
result = trash.restore_item(item_id, prepare=lambda path, fd: access_control.prepare_data_restore(conn, path, fd))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
else:
|
||||||
|
result = trash.restore_item(item_id)
|
||||||
|
except access_control.AccessConflict as exc:
|
||||||
|
self.send_error_json(HTTPStatus.CONFLICT, str(exc))
|
||||||
|
return
|
||||||
except FileExistsError:
|
except FileExistsError:
|
||||||
self.send_error_json(
|
self.send_error_json(
|
||||||
HTTPStatus.CONFLICT,
|
HTTPStatus.CONFLICT,
|
||||||
@@ -1174,6 +1037,16 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json({"user": user["sub"], "expiresAt": user["exp"]})
|
self.send_json({"user": user["sub"], "expiresAt": user["exp"]})
|
||||||
elif path == "/api/overview":
|
elif path == "/api/overview":
|
||||||
self.send_json(APP.overview())
|
self.send_json(APP.overview())
|
||||||
|
elif path == "/api/access":
|
||||||
|
conn = directory.open_db()
|
||||||
|
try:
|
||||||
|
access_control.ensure_schema(conn)
|
||||||
|
users, _ = access_control.read_directory()
|
||||||
|
access_control.cache_users(conn, users)
|
||||||
|
conn.commit()
|
||||||
|
self.send_json(access_control.snapshot(conn, users))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
elif path == "/api/groups":
|
elif path == "/api/groups":
|
||||||
self.send_json(APP.directory.get())
|
self.send_json(APP.directory.get())
|
||||||
elif path == "/api/storage":
|
elif path == "/api/storage":
|
||||||
|
|||||||
+85
-2
@@ -283,11 +283,11 @@ def main() -> int:
|
|||||||
check({"Finance", "Engineering", "Projects"}.issubset(groups), f"seed groups missing: {sorted(groups)}")
|
check({"Finance", "Engineering", "Projects"}.issubset(groups), f"seed groups missing: {sorted(groups)}")
|
||||||
check(groups["Finance"]["folder"] == "Finance", "Finance group/folder mapping is wrong")
|
check(groups["Finance"]["folder"] == "Finance", "Finance group/folder mapping is wrong")
|
||||||
finance_nodes = flatten_members(groups["Finance"].get("members", []))
|
finance_nodes = flatten_members(groups["Finance"].get("members", []))
|
||||||
check(any(kind == "group" and sam == "Finance_Analysts" for kind, sam, _ in finance_nodes), "nested Finance Analysts group missing")
|
check(all(kind == "user" for kind, _, _ in finance_nodes), "Finance still contains group assignments")
|
||||||
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", "frank"}.issubset({sam for kind, sam, _ in project_nodes if kind == "user"}), "transitive or primary-group 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")
|
check(all(kind == "user" for kind, _, _ in project_nodes), "Projects still contains group assignments")
|
||||||
|
|
||||||
announce("SMB authorization and real share reads/writes")
|
announce("SMB authorization and real share reads/writes")
|
||||||
alice_access = engine_run(
|
alice_access = engine_run(
|
||||||
@@ -720,6 +720,89 @@ fi
|
|||||||
"report backup status differs from the backup API",
|
"report backup status differs from the backup API",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
announce("individual AD-user folder assignments and all four SMB permission levels")
|
||||||
|
check(http("/api/access").status == 401, "anonymous access-policy listing accepted")
|
||||||
|
check(http("/api/access", method="POST", value={"action": "create-folder", "name": "Unauthorized"}).status == 401, "anonymous access-policy mutation accepted")
|
||||||
|
access = http("/api/access", token=token).json()
|
||||||
|
user_sids = {u["sam"]: u["sid"] for u in access["users"]}
|
||||||
|
|
||||||
|
def change(value):
|
||||||
|
response = eventually("access-policy update", lambda: http("/api/access", method="POST", value=value, token=token), lambda r: r.status != 409, timeout=45)
|
||||||
|
check(response.status == 200, f"policy update failed: {response.body!r}")
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
created = change({"action": "create-folder", "name": "Permissions"})
|
||||||
|
folder_id = next(f["id"] for f in created["folders"] if f["name"] == "Permissions")
|
||||||
|
check("groups" not in access, "access API still advertises groups")
|
||||||
|
rejected = http("/api/access", method="POST", value={"action": "save-group", "name": "Editors", "members": [user_sids["alice"]]}, token=token)
|
||||||
|
check(rejected.status == 400, "group creation accepted")
|
||||||
|
rejected = http("/api/access", method="POST", value={"action": "set-permissions", "id": folder_id,
|
||||||
|
"permissions": [{"kind": "group", "principalId": "legacy", "level": 3}]}, token=token)
|
||||||
|
check(rejected.status == 400, "group folder assignment accepted")
|
||||||
|
|
||||||
|
def rules(level):
|
||||||
|
return change({"action": "set-permissions", "id": folder_id,
|
||||||
|
"permissions": [{"kind": "user", "principalId": user_sids["alice"], "level": level}]})
|
||||||
|
|
||||||
|
def smb(command, user="alice"):
|
||||||
|
result = engine_run("exec", CLIENT_CONTAINER, "smbclient", f"//files.{DNS_DOMAIN}/Data", "-m", "SMB3",
|
||||||
|
"-U", f"{WORKGROUP}\\{user}%{ADMIN_PASSWORD if user == ADMIN_USER else USER_PASSWORD}", "-c", command, check_result=False)
|
||||||
|
return result.returncode, result.stdout + result.stderr
|
||||||
|
|
||||||
|
# The SMB client sometimes exits zero on individual denied operations, so inspect status output.
|
||||||
|
def allowed(command, user="alice"):
|
||||||
|
code, output = smb(command, user)
|
||||||
|
check(code == 0 and "NT_STATUS_" not in output, f"operation unexpectedly failed: {command}: {output}")
|
||||||
|
|
||||||
|
def denied(command):
|
||||||
|
code, output = smb(command)
|
||||||
|
check(code != 0 or "NT_STATUS_ACCESS_DENIED" in output, f"operation unexpectedly succeeded: {command}: {output}")
|
||||||
|
|
||||||
|
rules(0)
|
||||||
|
denied("cd Permissions; ls")
|
||||||
|
_, listing = smb("ls")
|
||||||
|
check("Permissions" not in listing, "level 0 folder is visible")
|
||||||
|
allowed("cd Permissions; put /tmp/live-note.txt existing.txt", ADMIN_USER)
|
||||||
|
rules(1)
|
||||||
|
allowed("cd Permissions; get existing.txt /tmp/permission-read.txt")
|
||||||
|
denied("cd Permissions; put /tmp/live-note.txt blocked.txt")
|
||||||
|
denied("cd Permissions; del existing.txt")
|
||||||
|
rules(2)
|
||||||
|
allowed("cd Permissions; put /tmp/live-note.txt created.txt")
|
||||||
|
allowed("cd Permissions; put /tmp/live-note.txt existing.txt")
|
||||||
|
allowed("cd Permissions; mkdir Child")
|
||||||
|
allowed("cd Permissions; cd Child; put /tmp/live-note.txt inherited.txt")
|
||||||
|
denied("cd Permissions; del existing.txt")
|
||||||
|
denied("cd Permissions; del created.txt")
|
||||||
|
denied("cd Permissions; rename created.txt renamed.txt")
|
||||||
|
denied("cd Permissions; rmdir Child")
|
||||||
|
denied("cd Permissions; cd Child; del inherited.txt")
|
||||||
|
# Users must not promote themselves using SMB ACL changes, even on their own new files.
|
||||||
|
acl_edit = engine_run("exec", CLIENT_CONTAINER, "smbcacls", f"//files.{DNS_DOMAIN}/Data", "Permissions/created.txt",
|
||||||
|
"-U", f"{WORKGROUP}\\alice%{USER_PASSWORD}", "--add", f"ACL:{WORKGROUP}\\alice:ALLOWED/0x0/FULL", check_result=False)
|
||||||
|
check(acl_edit.returncode != 0, "level 2 user can change ACLs on a created file")
|
||||||
|
rules(3)
|
||||||
|
allowed("cd Permissions; rename created.txt renamed.txt")
|
||||||
|
allowed("cd Permissions; del renamed.txt")
|
||||||
|
allowed("cd Permissions; cd Child; del inherited.txt")
|
||||||
|
allowed("cd Permissions; rmdir Child")
|
||||||
|
# Assigning Bob does not grant Alice access; zero explicitly revokes Alice.
|
||||||
|
change({"action": "set-permissions", "id": folder_id, "permissions": [
|
||||||
|
{"kind": "user", "principalId": user_sids["bob"], "level": 3},
|
||||||
|
{"kind": "user", "principalId": user_sids["alice"], "level": 0}]})
|
||||||
|
denied("cd Permissions; ls")
|
||||||
|
allowed("cd Permissions; ls", "bob")
|
||||||
|
change({"action": "archive-folder", "id": folder_id})
|
||||||
|
code, output = smb("cd Permissions; ls", "bob")
|
||||||
|
check(code != 0 or "NT_STATUS_" in output, "archived folder remains accessible")
|
||||||
|
change({"action": "restore-folder", "id": folder_id})
|
||||||
|
allowed("cd Permissions; ls", "bob")
|
||||||
|
change({"action": "set-permissions", "id": folder_id, "permissions": []})
|
||||||
|
code, output = smb("cd Permissions; ls", "bob")
|
||||||
|
check(code != 0 or "NT_STATUS_ACCESS_DENIED" in output, "removed individual assignment still grants access")
|
||||||
|
invalid = http("/api/access", method="POST", value={"action": "create-folder", "name": "../escape"}, token=token)
|
||||||
|
check(invalid.status == 400, "unsafe folder path accepted")
|
||||||
|
|
||||||
logout = http("/api/logout", method="POST", value={}, token=token)
|
logout = http("/api/logout", method="POST", value={}, token=token)
|
||||||
check(logout.status == 200 and "Max-Age=0" in logout.headers.get("Set-Cookie", ""), "logout did not clear session cookie")
|
check(logout.status == 200 and "Max-Age=0" in logout.headers.get("Set-Cookie", ""), "logout did not clear session cookie")
|
||||||
|
|
||||||
|
|||||||
@@ -30,3 +30,13 @@ dd if=/dev/zero of=/data/fslogix/alice_S-1-5-21-111-222-333-1101/profile-data.bi
|
|||||||
|
|
||||||
|
|
||||||
printf '[preview-seed] Seeded group, private, and FSLogix data.\n'
|
printf '[preview-seed] Seeded group, private, and FSLogix data.\n'
|
||||||
|
|
||||||
|
# Seed the old state layout to exercise the one-time migration on startup.
|
||||||
|
python3 - <<'PYSEED'
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect('/state/shares.db')
|
||||||
|
conn.execute("CREATE TABLE shares (objectGUID TEXT PRIMARY KEY, samAccountName TEXT NOT NULL, shareName TEXT NOT NULL, path TEXT NOT NULL, createdAt TEXT NOT NULL, lastSeenAt TEXT NOT NULL, isActive INTEGER NOT NULL DEFAULT 1)")
|
||||||
|
for name in ('Finance', 'Engineering', 'Projects'):
|
||||||
|
conn.execute("INSERT INTO shares VALUES(?,?,?,?,?,?,1)", (name, 'FS_' + name, name, '/data/groups/data/' + name, '2026-01-01', '2026-01-01'))
|
||||||
|
conn.commit()
|
||||||
|
PYSEED
|
||||||
|
|||||||
+5
-12
@@ -67,7 +67,7 @@
|
|||||||
read only = no
|
read only = no
|
||||||
browseable = yes
|
browseable = yes
|
||||||
guest ok = no
|
guest ok = no
|
||||||
vfs objects = acl_xattr full_audit recycle
|
vfs objects = acl_xattr xattr_tdb full_audit recycle
|
||||||
recycle:repository = .trash/%U
|
recycle:repository = .trash/%U
|
||||||
recycle:keeptree = yes
|
recycle:keeptree = yes
|
||||||
recycle:versions = yes
|
recycle:versions = yes
|
||||||
@@ -82,18 +82,11 @@
|
|||||||
full_audit:failure = pread pread_recv read sendfile offload_read_recv pwrite pwrite_recv write recvfile offload_write_recv renameat unlinkat
|
full_audit:failure = pread pread_recv read sendfile offload_read_recv pwrite pwrite_recv write recvfile offload_write_recv renameat unlinkat
|
||||||
full_audit:syslog = false
|
full_audit:syslog = false
|
||||||
valid users = @"${DOMAIN_USERS_GROUP}"
|
valid users = @"${DOMAIN_USERS_GROUP}"
|
||||||
create mask = 0660
|
xattr_tdb:file = /state/data-xattrs.tdb
|
||||||
directory mask = 2770
|
acl_xattr:ignore system acls = yes
|
||||||
force create mode = 0660
|
acl_xattr:default acl style = windows
|
||||||
force directory mode = 2770
|
|
||||||
security mask = 0660
|
|
||||||
directory security mask = 0770
|
|
||||||
force security mode = 0660
|
|
||||||
force directory security mode = 0770
|
|
||||||
inherit permissions = yes
|
|
||||||
inherit acls = yes
|
|
||||||
inherit owner = yes
|
inherit owner = yes
|
||||||
nt acl support = no
|
nt acl support = yes
|
||||||
hide dot files = no
|
hide dot files = no
|
||||||
hide unreadable = yes
|
hide unreadable = yes
|
||||||
access based share enum = yes
|
access based share enum = yes
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// Run with an installed Playwright package. PLAYWRIGHT_MODULE can point to an external installation.
|
||||||
|
// SCREENSHOT_DIR optionally selects where the browser captures are written.
|
||||||
|
import fs from "node:fs";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import {createRequire} from "node:module";
|
||||||
|
import {fileURLToPath} from "node:url";
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const {chromium} = require(process.env.PLAYWRIGHT_MODULE || "playwright");
|
||||||
|
const root = fileURLToPath(new URL("../app/web/", import.meta.url));
|
||||||
|
const out = process.env.SCREENSHOT_DIR || fs.mkdtempSync(path.join(os.tmpdir(), "adfs-ui-review-"));
|
||||||
|
fs.mkdirSync(out, {recursive: true});
|
||||||
|
const data={initialized:true,fetchedAt:new Date().toISOString(),users:[
|
||||||
|
{sid:'alice',sam:'alice',name:'Alice Schneider',available:true},
|
||||||
|
{sid:'bob',sam:'bob',name:'Robert Müller',available:true},
|
||||||
|
{sid:'carol',sam:'carol',name:'Carol Fischer',available:true},
|
||||||
|
...Array.from({length:77},(_,i)=>({sid:'user-'+i,sam:'mitarbeiter.'+String(i+1).padStart(2,'0'),name:'Mitarbeiter '+(i+1),available:true}))
|
||||||
|
],folders:[
|
||||||
|
{id:'invoices',name:'Rechnungen',active:true,permissions:[{kind:'user',principalId:'alice',level:2},{kind:'user',principalId:'bob',level:1},{kind:'user',principalId:'carol',level:0}]},
|
||||||
|
{id:'projects',name:'Projektunterlagen',active:true,permissions:[{kind:'user',principalId:'carol',level:3}]},
|
||||||
|
{id:'archive',name:'Abschluss 2024',active:false,permissions:[]}
|
||||||
|
]};
|
||||||
|
let nextId=0, failSave=false; const requests=[],errors=[];
|
||||||
|
(async()=>{
|
||||||
|
const browser=await chromium.launch({headless:true});
|
||||||
|
const page=await browser.newPage({viewport:{width:1500,height:1050}});
|
||||||
|
page.on('pageerror',e=>errors.push(e.message));
|
||||||
|
await page.route('http://adfs.test/**',async route=>{
|
||||||
|
const url=new URL(route.request().url());let body,type='application/json',status=200;
|
||||||
|
if(url.pathname==='/api/session')body={user:'EXAMPLE\\admin',expiresAt:9999999999};
|
||||||
|
else if(url.pathname==='/api/access'){
|
||||||
|
if(route.request().method()==='POST'){
|
||||||
|
const change=route.request().postDataJSON();requests.push(change);
|
||||||
|
if(failSave){failSave=false;return route.fulfill({status:409,contentType:type,body:JSON.stringify({error:'Freigabenabgleich läuft; bitte erneut versuchen'})});}
|
||||||
|
if(change.action==='set-permissions')data.folders.find(f=>f.id===change.id).permissions=change.permissions;
|
||||||
|
if(change.action==='create-folder')data.folders.push({id:'folder-'+(++nextId),name:change.name,active:true,permissions:[]});
|
||||||
|
if(change.action==='archive-folder'||change.action==='restore-folder')data.folders.find(f=>f.id===change.id).active=change.action==='restore-folder';
|
||||||
|
}body=data;
|
||||||
|
} else if(url.pathname==='/assets/app.js'){body=fs.readFileSync(root+'/app.js');type='text/javascript';}
|
||||||
|
else if(url.pathname==='/assets/styles.css'){body=fs.readFileSync(root+'/styles.css');type='text/css';}
|
||||||
|
else {body=fs.readFileSync(root+'/index.html');type='text/html';}
|
||||||
|
await route.fulfill({status,contentType:type,body:Buffer.isBuffer(body)?body:JSON.stringify(body)});
|
||||||
|
});
|
||||||
|
const settled=()=>page.waitForFunction(()=>document.querySelector('#access-message')?.textContent==='Gespeichert. Die Änderungen sind wirksam.'&&!document.querySelector('#access-create').disabled);
|
||||||
|
const shot=async name=>page.screenshot({path:out+'/'+name+'.png',fullPage:true});
|
||||||
|
const checkSharedStyle=async()=>{
|
||||||
|
const differences=await page.evaluate(()=>{
|
||||||
|
const mismatches=[];
|
||||||
|
const compare=(actual,reference,properties)=>{
|
||||||
|
const a=getComputedStyle(document.querySelector(actual)),b=getComputedStyle(document.querySelector(reference));
|
||||||
|
for(const property of properties)if(a[property]!==b[property])mismatches.push(`${actual}: ${property} ${a[property]} != ${b[property]}`);
|
||||||
|
};
|
||||||
|
const controls=['fontFamily','fontSize','color','backgroundColor','borderTopColor','borderTopWidth','borderTopStyle','borderRadius','boxShadow','padding'];
|
||||||
|
compare('#access-create','#login-form button',controls);
|
||||||
|
compare('#access-search','#login-form input[name=username]',controls);
|
||||||
|
const listReference=document.createElement('button');
|
||||||
|
listReference.id='shared-list-reference';listReference.className='select-row active';
|
||||||
|
document.body.append(listReference);
|
||||||
|
compare('.access-list-item.active','#shared-list-reference',['backgroundColor','fontWeight']);
|
||||||
|
listReference.remove();
|
||||||
|
if(document.querySelector('.access-level-options'))compare('.access-level-options button[aria-pressed="true"]','nav a.active',['backgroundColor','fontWeight']);
|
||||||
|
if(document.querySelector('.access-dialog')){
|
||||||
|
compare('.access-dialog input','#login-form input[name=username]',controls);
|
||||||
|
compare('.access-dialog button[type=submit]','#login-form button',controls);
|
||||||
|
}
|
||||||
|
return mismatches;
|
||||||
|
});
|
||||||
|
assert.deepEqual(differences,[],'Access controls differ from the established admin styles');
|
||||||
|
};
|
||||||
|
await page.goto('http://adfs.test/access');await page.locator('[data-user-rule]').first().waitFor();
|
||||||
|
assert.equal(await page.locator('[data-tab=groups], [data-group-rule]').count(),0);
|
||||||
|
assert.doesNotMatch(await page.locator('#content').innerText(),/Gruppe/);
|
||||||
|
assert.equal(await page.locator('#access-users .access-matrix-row').count(),80);
|
||||||
|
await checkSharedStyle();
|
||||||
|
await shot('01-desktop-benutzerrechte');
|
||||||
|
assert.equal(await page.locator('#access-save').isDisabled(),true);
|
||||||
|
await page.locator('#access-user-search').fill('alice');
|
||||||
|
await page.locator('[data-user-rule=alice][data-level="3"]').click();
|
||||||
|
assert.equal(await page.locator('#access-save').isEnabled(),true);
|
||||||
|
await page.locator('#access-user-search').fill('bob');
|
||||||
|
await page.locator('[data-user-rule=bob][data-level="2"]').click();
|
||||||
|
await page.locator('#access-user-search').fill('');
|
||||||
|
await page.selectOption('#access-user-filter','granted');
|
||||||
|
assert.equal(await page.locator('#access-users .access-matrix-row').count(),2);
|
||||||
|
assert.equal(await page.locator('[data-user-rule=alice][data-level="3"]').getAttribute('aria-pressed'),'true');
|
||||||
|
assert.match(await page.locator('#access-user-count').innerText(),/ungespeicherten/);
|
||||||
|
await shot('02-desktop-mit-zugriff');
|
||||||
|
let dialogs=0;
|
||||||
|
page.on('dialog',async d=>{dialogs++;await d.dismiss();});
|
||||||
|
await page.locator('[data-select=projects]').click();
|
||||||
|
assert.match(await page.locator('.access-detail-head h2').innerText(),/Rechnungen/);
|
||||||
|
assert.equal(dialogs,1);
|
||||||
|
await page.selectOption('#folder-status','archived');
|
||||||
|
assert.equal(await page.locator('#folder-status').inputValue(),'active');
|
||||||
|
assert.equal(dialogs,2);
|
||||||
|
await page.locator('a[data-route="storage-data"]').click();
|
||||||
|
assert.equal(new URL(page.url()).pathname,'/access');
|
||||||
|
assert.equal(dialogs,3);
|
||||||
|
failSave=true;await page.locator('#access-save').click();
|
||||||
|
await page.locator('.access-message.failure').waitFor();
|
||||||
|
assert.equal(await page.locator('[data-user-rule=alice][data-level="3"]').getAttribute('aria-pressed'),'true');
|
||||||
|
assert.equal(await page.locator('#access-save').isEnabled(),true);
|
||||||
|
await shot('03-desktop-fehler-erhaelt-entwurf');
|
||||||
|
await page.locator('#access-save').click();await settled();
|
||||||
|
assert.match(await page.locator('.access-detail-head h2').innerText(),/Rechnungen/);
|
||||||
|
assert.equal(await page.locator('#access-user-filter').inputValue(),'granted');
|
||||||
|
assert.equal(requests.at(-1).permissions.find(r=>r.principalId==='alice').level,3);
|
||||||
|
assert.ok(requests.at(-1).permissions.every(r=>r.kind==='user'));
|
||||||
|
await page.locator('[data-user-rule=bob][data-level="0"]').click();
|
||||||
|
assert.equal(await page.locator('#access-users .access-matrix-row').count(),1);
|
||||||
|
await page.selectOption('#access-user-filter','hidden');
|
||||||
|
assert.equal(await page.locator('[data-user-rule=bob][data-level="0"]').getAttribute('aria-pressed'),'true');
|
||||||
|
await page.locator('#access-discard').click();
|
||||||
|
await page.selectOption('#access-user-filter','granted');
|
||||||
|
assert.equal(await page.locator('[data-user-rule=bob][data-level="2"]').getAttribute('aria-pressed'),'true');
|
||||||
|
assert.equal(await page.locator('#access-save').isDisabled(),true);
|
||||||
|
await page.locator('[data-select=projects]').click();
|
||||||
|
await page.locator('#access-user-search').fill('alice');
|
||||||
|
assert.equal(await page.locator('[data-user-rule=alice][data-level="0"]').getAttribute('aria-pressed'),'true');
|
||||||
|
await page.locator('[data-user-rule=alice][data-level="1"]').click();
|
||||||
|
await page.locator('#access-save').click();await settled();
|
||||||
|
await page.locator('[data-select=invoices]').click();
|
||||||
|
assert.equal(await page.locator('[data-user-rule=alice][data-level="3"]').getAttribute('aria-pressed'),'true');
|
||||||
|
await page.locator('#access-create').click();
|
||||||
|
await page.locator('.access-dialog input').fill('Neue Ablage');
|
||||||
|
await checkSharedStyle();await shot('04-desktop-ordner-anlegen');
|
||||||
|
await page.locator('.access-dialog button[type=submit]').click();await settled();
|
||||||
|
assert.match(await page.locator('.access-detail-head h2').innerText(),/Neue Ablage/);
|
||||||
|
assert.equal(await page.locator('#access-users button[aria-pressed=true][data-level="0"]').count(),80);
|
||||||
|
await page.locator('#access-archive').click();
|
||||||
|
await page.locator('.access-dialog button[type=submit]').click();await settled();
|
||||||
|
assert.equal(await page.locator('#folder-status').inputValue(),'archived');
|
||||||
|
assert.equal(await page.locator('[data-user-rule]').first().isDisabled(),true);
|
||||||
|
await page.locator('#access-archive').click();
|
||||||
|
await page.locator('.access-dialog button[type=submit]').click();await settled();
|
||||||
|
assert.equal(await page.locator('#folder-status').inputValue(),'active');
|
||||||
|
await page.locator('[data-select=invoices]').click();
|
||||||
|
await page.selectOption('#access-user-filter','granted');
|
||||||
|
for(const width of [1200,900,390]){
|
||||||
|
await page.setViewportSize({width,height:width===390?844:1000});
|
||||||
|
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false,'Horizontal overflow at '+width);
|
||||||
|
assert.equal(await page.evaluate(()=>{
|
||||||
|
const section=document.querySelector('#access-detail .access-section');
|
||||||
|
const footer=document.querySelector('.access-savebar');
|
||||||
|
return footer.getBoundingClientRect().top >= section.getBoundingClientRect().bottom-1;
|
||||||
|
}),true,'Save controls cover form content at '+width);
|
||||||
|
await checkSharedStyle();
|
||||||
|
await shot('05-benutzerrechte-'+width);
|
||||||
|
}
|
||||||
|
await page.selectOption('#access-user-filter','all');
|
||||||
|
await page.locator('#access-user-search').fill('mitarbeiter.77');
|
||||||
|
assert.equal(await page.locator('#access-users .access-matrix-row').count(),1);
|
||||||
|
await page.locator('[data-user-rule=user-76][data-level="1"]').click();
|
||||||
|
await page.locator('#access-user-search').fill('');
|
||||||
|
await page.selectOption('#access-user-filter','granted');
|
||||||
|
assert.equal(await page.locator('[data-user-rule=user-76][data-level="1"]').getAttribute('aria-pressed'),'true');
|
||||||
|
await shot('06-mobile-benutzersuche');
|
||||||
|
await page.locator('#access-discard').click();
|
||||||
|
// Cached users who left AD stay visible only while they have an assignment.
|
||||||
|
data.users.push({sid:'departed',sam:'ehemalig',name:'Ehemaliger Benutzer',available:false},
|
||||||
|
{sid:'unassigned',sam:'unzugeordnet',name:'Nicht zugeordnet',available:false});
|
||||||
|
data.folders.find(f=>f.id==='invoices').permissions.push({kind:'user',principalId:'departed',level:1});
|
||||||
|
await page.setViewportSize({width:1500,height:1050});
|
||||||
|
await page.goto('http://adfs.test/access');
|
||||||
|
await page.locator('[data-user-rule=departed]').first().waitFor();
|
||||||
|
assert.equal(await page.locator('[data-user-rule=unassigned]').count(),0);
|
||||||
|
await page.locator('#access-user-search').fill('ehemalig');
|
||||||
|
assert.match(await page.locator('#access-users').innerText(),/Nicht mehr im AD/);
|
||||||
|
await page.locator('[data-user-rule=departed][data-level="0"]').click();
|
||||||
|
await page.locator('#access-save').click();await settled();
|
||||||
|
assert.equal(await page.locator('[data-user-rule=departed]').count(),0);
|
||||||
|
// An installation with only archived folders opens the matching list.
|
||||||
|
data.folders.forEach(f=>f.active=false);
|
||||||
|
await page.goto('http://adfs.test/access');
|
||||||
|
await page.locator('#access-archive').waitFor();
|
||||||
|
assert.equal(await page.locator('#folder-status').inputValue(),'archived');
|
||||||
|
assert.equal(await page.locator('[data-select].active').count(),1);
|
||||||
|
assert.equal(await page.locator('[data-user-rule]').first().isDisabled(),true);
|
||||||
|
assert.equal(await page.locator('#access-save').isDisabled(),true);
|
||||||
|
data.folders.length=0;
|
||||||
|
await page.setViewportSize({width:1500,height:1050});
|
||||||
|
await page.goto('http://adfs.test/access');
|
||||||
|
await page.locator('#access-create').waitFor();
|
||||||
|
assert.match(await page.locator('#access-detail').innerText(),/Ersten Ordner anlegen/);
|
||||||
|
await shot('07-desktop-leere-installation');
|
||||||
|
assert.deepEqual(errors,[]);
|
||||||
|
console.log('PASS: individual permissions, no groups, user search/filter persistence, unsaved-change protection, failure/retry, independent folders, create/archive/restore, desktop/mobile layout, shared admin styling.');
|
||||||
|
console.log('Screenshots: '+out);
|
||||||
|
await browser.close();
|
||||||
|
})().catch(error=>{console.error(error);process.exit(1)});
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import sqlite3
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from app import access_control as access
|
||||||
|
from app import reconcile_shares as directory
|
||||||
|
|
||||||
|
ADMIN = 'S-1-5-21-1-2-3-512'
|
||||||
|
ALICE = 'S-1-5-21-1-2-3-1100'
|
||||||
|
BOB = 'S-1-5-21-1-2-3-1101'
|
||||||
|
USERS = {ALICE: {'sam': 'alice', 'name': 'Alice'}, BOB: {'sam': 'bob', 'name': 'Bob'}}
|
||||||
|
|
||||||
|
|
||||||
|
class ManagedAccessTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
root = self.tmp.name
|
||||||
|
self.patches = [mock.patch.object(directory, 'DB_PATH', os.path.join(root, 'state.db')),
|
||||||
|
mock.patch.object(directory, 'GROUP_ROOT', os.path.join(root, 'data')),
|
||||||
|
mock.patch.object(directory, 'GROUP_ARCHIVE_ROOT', os.path.join(root, 'archive')),
|
||||||
|
mock.patch.object(directory, 'LOCK_PATH', os.path.join(root, 'reconcile.lock'))]
|
||||||
|
for patch in self.patches:
|
||||||
|
patch.start()
|
||||||
|
self.addCleanup(patch.stop)
|
||||||
|
os.makedirs(directory.GROUP_ROOT)
|
||||||
|
self.conn = directory.open_db()
|
||||||
|
self.addCleanup(self.conn.close)
|
||||||
|
access.ensure_schema(self.conn)
|
||||||
|
|
||||||
|
def change(self, body):
|
||||||
|
with mock.patch.object(access, 'sync_permissions'), mock.patch.object(directory, 'run_command', return_value=mock.Mock(returncode=0)):
|
||||||
|
return access.apply_change(self.conn, body, 'EXAMPLE\\admin', USERS)
|
||||||
|
|
||||||
|
def folder(self, name='Finance'):
|
||||||
|
data = self.change({'action': 'create-folder', 'name': name})
|
||||||
|
return next(row['id'] for row in data['folders'] if row['name'] == name)
|
||||||
|
|
||||||
|
def test_individual_user_assignments_are_independent_including_zero(self):
|
||||||
|
folder = self.folder()
|
||||||
|
other = self.folder('Projects')
|
||||||
|
self.change({'action': 'set-permissions', 'id': folder, 'permissions': [
|
||||||
|
{'kind': 'user', 'principalId': ALICE, 'level': 0},
|
||||||
|
{'kind': 'user', 'principalId': BOB, 'level': 2}]})
|
||||||
|
self.change({'action': 'set-permissions', 'id': other, 'permissions': [
|
||||||
|
{'kind': 'user', 'principalId': ALICE, 'level': 3}]})
|
||||||
|
self.assertEqual(access.effective_levels(self.conn, folder), {ALICE: 0, BOB: 2})
|
||||||
|
self.assertEqual(access.effective_levels(self.conn, other), {ALICE: 3})
|
||||||
|
self.change({'action': 'set-permissions', 'id': folder, 'permissions': []})
|
||||||
|
self.assertEqual(access.effective_levels(self.conn, folder), {})
|
||||||
|
self.assertEqual(access.effective_levels(self.conn, other), {ALICE: 3})
|
||||||
|
|
||||||
|
def test_group_actions_and_assignments_are_rejected(self):
|
||||||
|
folder = self.folder()
|
||||||
|
for action in ['save-group', 'delete-group']:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.change({'action': action, 'id': 'legacy', 'name': 'Editors', 'members': [ALICE]})
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.change({'action': 'set-permissions', 'id': folder, 'permissions': [
|
||||||
|
{'kind': 'group', 'principalId': 'legacy', 'level': 3}]})
|
||||||
|
with self.assertRaises(sqlite3.IntegrityError):
|
||||||
|
self.conn.execute('INSERT INTO folder_permissions VALUES(?,?,?,?)', (folder, 'group', 'legacy', 3))
|
||||||
|
self.conn.rollback()
|
||||||
|
self.assertNotIn('groups', access.snapshot(self.conn))
|
||||||
|
self.assertIsNone(self.conn.execute("SELECT 1 FROM sqlite_master WHERE name='access_groups'").fetchone())
|
||||||
|
|
||||||
|
def legacy_schema(self):
|
||||||
|
self.conn.executescript("""
|
||||||
|
DROP TABLE folder_permissions;
|
||||||
|
CREATE TABLE folder_permissions (
|
||||||
|
folderId TEXT NOT NULL REFERENCES shares(objectGUID) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL CHECK(kind IN ('user','group')), principalId TEXT NOT NULL,
|
||||||
|
level INTEGER NOT NULL CHECK(level BETWEEN 0 AND 3),
|
||||||
|
PRIMARY KEY(folderId,kind,principalId));
|
||||||
|
CREATE TABLE access_groups (id TEXT PRIMARY KEY,name TEXT NOT NULL UNIQUE);
|
||||||
|
CREATE TABLE access_members (groupId TEXT NOT NULL REFERENCES access_groups(id) ON DELETE CASCADE,
|
||||||
|
userSid TEXT NOT NULL,PRIMARY KEY(groupId,userSid));
|
||||||
|
INSERT INTO access_settings VALUES('managed','1');
|
||||||
|
INSERT INTO access_groups VALUES('editors','Editors'),('readers','Readers'),('empty','Empty');
|
||||||
|
""")
|
||||||
|
|
||||||
|
def test_existing_local_groups_flatten_once_preserving_overrides_and_archived_rules(self):
|
||||||
|
folder = self.folder()
|
||||||
|
archived = self.folder('Archived')
|
||||||
|
self.change({'action': 'archive-folder', 'id': archived})
|
||||||
|
self.legacy_schema()
|
||||||
|
self.conn.executemany('INSERT INTO access_members VALUES(?,?)',
|
||||||
|
[('editors', ALICE), ('editors', BOB), ('readers', ALICE), ('readers', BOB)])
|
||||||
|
self.conn.executemany('INSERT INTO folder_permissions VALUES(?,?,?,?)', [
|
||||||
|
(folder, 'group', 'editors', 2), (folder, 'group', 'readers', 3),
|
||||||
|
(folder, 'user', ALICE, 0), (folder, 'group', 'empty', 3),
|
||||||
|
(archived, 'group', 'editors', 2), (archived, 'user', BOB, 1)])
|
||||||
|
self.conn.commit()
|
||||||
|
with mock.patch.object(access, 'read_directory') as read:
|
||||||
|
access.migrate(self.conn)
|
||||||
|
access.migrate(self.conn)
|
||||||
|
read.assert_not_called()
|
||||||
|
self.assertEqual(access.effective_levels(self.conn, folder), {ALICE: 0, BOB: 3})
|
||||||
|
self.assertEqual(access.effective_levels(self.conn, archived), {ALICE: 2, BOB: 1})
|
||||||
|
self.assertEqual(self.conn.execute("SELECT COUNT(*) FROM access_changes WHERE action='migrate-individual-permissions'").fetchone()[0], 1)
|
||||||
|
for table in ['access_groups', 'access_members']:
|
||||||
|
self.assertIsNone(self.conn.execute('SELECT 1 FROM sqlite_master WHERE name=?', (table,)).fetchone())
|
||||||
|
with self.assertRaises(sqlite3.IntegrityError):
|
||||||
|
self.conn.execute('INSERT INTO folder_permissions VALUES(?,?,?,?)', (folder, 'group', 'old', 3))
|
||||||
|
self.conn.rollback()
|
||||||
|
self.change({'action': 'restore-folder', 'id': archived})
|
||||||
|
self.assertEqual(access.effective_levels(self.conn, archived), {ALICE: 2, BOB: 1})
|
||||||
|
|
||||||
|
def test_unresolved_stored_group_aborts_upgrade_without_losing_rules(self):
|
||||||
|
folder = self.folder()
|
||||||
|
self.legacy_schema()
|
||||||
|
self.conn.execute('INSERT INTO folder_permissions VALUES(?,?,?,?)', (folder, 'group', 'missing', 3))
|
||||||
|
self.conn.commit()
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
access.ensure_schema(self.conn)
|
||||||
|
self.assertEqual(self.conn.execute('SELECT principalId FROM folder_permissions').fetchone()[0], 'missing')
|
||||||
|
self.assertIsNotNone(self.conn.execute("SELECT 1 FROM sqlite_master WHERE name='access_groups'").fetchone())
|
||||||
|
self.assertIsNone(self.conn.execute("SELECT 1 FROM sqlite_master WHERE name='individual_permissions'").fetchone())
|
||||||
|
|
||||||
|
def test_report_contains_only_individual_users_with_positive_access(self):
|
||||||
|
folder = self.folder()
|
||||||
|
self.change({'action': 'set-permissions', 'id': folder, 'permissions': [
|
||||||
|
{'kind': 'user', 'principalId': ALICE, 'level': 0},
|
||||||
|
{'kind': 'user', 'principalId': BOB, 'level': 2}]})
|
||||||
|
row = access.report_folders(self.conn)['groups'][0]
|
||||||
|
self.assertEqual(row['userCount'], 1)
|
||||||
|
self.assertEqual(row['groupCount'], 0)
|
||||||
|
self.assertEqual([(node['id'], node['level']) for node in row['members']], [(BOB, 2)])
|
||||||
|
|
||||||
|
def test_invalid_permission_update_retains_existing_policy(self):
|
||||||
|
folder = self.folder()
|
||||||
|
original = [{'kind': 'user', 'principalId': ALICE, 'level': 1}]
|
||||||
|
self.change({'action': 'set-permissions', 'id': folder, 'permissions': original})
|
||||||
|
for invalid in [True, -1, 4, '2', None]:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.change({'action': 'set-permissions', 'id': folder, 'permissions': [
|
||||||
|
{'kind': 'user', 'principalId': ALICE, 'level': invalid}]})
|
||||||
|
self.assertEqual(access.effective_levels(self.conn, folder), {ALICE: 1})
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.change({'action': 'set-permissions', 'id': folder, 'permissions': original * 2})
|
||||||
|
|
||||||
|
def test_unknown_user_rejected_and_unavailable_existing_assignment_removable(self):
|
||||||
|
folder = self.folder()
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.change({'action': 'set-permissions', 'id': folder, 'permissions': [
|
||||||
|
{'kind': 'user', 'principalId': 'S-1-5-21-1-2-3-9999', 'level': 3}]})
|
||||||
|
self.change({'action': 'set-permissions', 'id': folder, 'permissions': [
|
||||||
|
{'kind': 'user', 'principalId': ALICE, 'level': 3}]})
|
||||||
|
with mock.patch.object(access, 'sync_permissions'), mock.patch.object(directory, 'run_command', return_value=mock.Mock(returncode=0)):
|
||||||
|
access.apply_change(self.conn, {'action': 'set-permissions', 'id': folder, 'permissions': [
|
||||||
|
{'kind': 'user', 'principalId': ALICE, 'level': 0}]}, 'admin', {})
|
||||||
|
self.assertEqual(access.effective_levels(self.conn, folder), {ALICE: 0})
|
||||||
|
|
||||||
|
def test_folder_archive_restore_preserves_data_and_rules(self):
|
||||||
|
folder = self.folder()
|
||||||
|
source = os.path.join(directory.GROUP_ROOT, 'Finance')
|
||||||
|
with open(os.path.join(source, 'report.txt'), 'w') as handle:
|
||||||
|
handle.write('retained')
|
||||||
|
self.change({'action': 'set-permissions', 'id': folder, 'permissions': [
|
||||||
|
{'kind': 'user', 'principalId': ALICE, 'level': 3}]})
|
||||||
|
self.change({'action': 'archive-folder', 'id': folder})
|
||||||
|
self.assertFalse(os.path.exists(source))
|
||||||
|
self.change({'action': 'restore-folder', 'id': folder})
|
||||||
|
with open(os.path.join(source, 'report.txt')) as handle:
|
||||||
|
self.assertEqual(handle.read(), 'retained')
|
||||||
|
self.assertEqual(access.effective_levels(self.conn, folder), {ALICE: 3})
|
||||||
|
|
||||||
|
def test_folder_names_cannot_escape_or_collide_case_insensitively(self):
|
||||||
|
for name in ['../escape', '.', '..', '.trash', '.TRASH', 'a/b', 'a\\b', 'bad\0name', 'NUL', 'COM1.txt', '']:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.change({'action': 'create-folder', 'name': name})
|
||||||
|
self.folder()
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.folder('FINANCE')
|
||||||
|
|
||||||
|
def test_acl_failure_rolls_back_policy_and_repairs_old_acl(self):
|
||||||
|
folder = self.folder()
|
||||||
|
self.change({'action': 'set-permissions', 'id': folder, 'permissions': [
|
||||||
|
{'kind': 'user', 'principalId': ALICE, 'level': 3}]})
|
||||||
|
with mock.patch.object(access, 'sync_permissions', side_effect=[OSError('failed ACL'), None]) as sync:
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
access.apply_change(self.conn, {'action': 'set-permissions', 'id': folder, 'permissions': []}, 'admin', USERS)
|
||||||
|
self.assertEqual(access.effective_levels(self.conn, folder), {ALICE: 3})
|
||||||
|
self.assertTrue(sync.call_args.kwargs['force'])
|
||||||
|
|
||||||
|
def test_created_folder_removed_if_acl_application_fails(self):
|
||||||
|
with mock.patch.object(access, 'sync_permissions', side_effect=[OSError('failed ACL'), None]):
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
access.apply_change(self.conn, {'action': 'create-folder', 'name': 'Failed'}, 'admin', USERS)
|
||||||
|
self.assertFalse(os.path.exists(os.path.join(directory.GROUP_ROOT, 'Failed')))
|
||||||
|
self.assertEqual(access.snapshot(self.conn)['folders'], [])
|
||||||
|
|
||||||
|
def test_access_change_records_actor_and_disconnects_cached_handles(self):
|
||||||
|
with mock.patch.object(access, 'sync_permissions'), mock.patch.object(directory, 'run_command', return_value=mock.Mock(returncode=0)) as run:
|
||||||
|
access.apply_change(self.conn, {'action': 'create-folder', 'name': 'Readers'}, 'EXAMPLE\\admin', USERS)
|
||||||
|
run.assert_called_once_with(['smbcontrol', 'all', 'close-share', 'Data'], check=False)
|
||||||
|
row = self.conn.execute('SELECT actor,action FROM access_changes').fetchone()
|
||||||
|
self.assertEqual(tuple(row), ('EXAMPLE\\admin', 'create-folder'))
|
||||||
|
|
||||||
|
def test_migration_snapshots_nested_and_primary_members_once(self):
|
||||||
|
folder = self.folder()
|
||||||
|
self.conn.execute("UPDATE shares SET samAccountName='FS_Finance' WHERE objectGUID=?", (folder,))
|
||||||
|
self.conn.commit()
|
||||||
|
users = {ALICE: {**USERS[ALICE], 'dn': 'alice', 'primaryRid': 513},
|
||||||
|
BOB: {**USERS[BOB], 'dn': 'bob', 'primaryRid': 513}}
|
||||||
|
groups = {'finance': {'dn': 'finance', 'sam': 'FS_Finance', 'sid': 'S-1-5-21-1-2-3-1200', 'members': ['nested']},
|
||||||
|
'nested': {'dn': 'nested', 'sam': 'Nested', 'sid': 'S-1-5-21-1-2-3-513', 'members': ['finance']}}
|
||||||
|
with mock.patch.object(access, 'read_directory', return_value=(users, groups)) as read:
|
||||||
|
access.migrate(self.conn)
|
||||||
|
access.migrate(self.conn)
|
||||||
|
read.assert_called_once_with(include_groups=True)
|
||||||
|
self.assertEqual(access.effective_levels(self.conn, folder), {ALICE: 3, BOB: 3})
|
||||||
|
self.assertTrue(all(rule['kind'] == 'user' for rule in access.snapshot(self.conn)['folders'][0]['permissions']))
|
||||||
|
self.assertNotIn('groups', access.snapshot(self.conn))
|
||||||
|
|
||||||
|
def test_failed_migration_never_marks_initialized(self):
|
||||||
|
folder = self.folder()
|
||||||
|
with mock.patch.object(access, 'read_directory', return_value=({}, {})):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
access.migrate(self.conn)
|
||||||
|
self.assertFalse(access.initialized(self.conn))
|
||||||
|
self.assertEqual(access.effective_levels(self.conn, folder), {})
|
||||||
|
|
||||||
|
def test_new_install_does_not_discover_fs_groups(self):
|
||||||
|
with mock.patch.object(access, 'read_directory') as read:
|
||||||
|
access.migrate(self.conn)
|
||||||
|
read.assert_not_called()
|
||||||
|
self.assertTrue(access.initialized(self.conn))
|
||||||
|
|
||||||
|
def test_untracked_existing_folders_adopted_without_user_access(self):
|
||||||
|
os.mkdir(os.path.join(directory.GROUP_ROOT, 'Existing'))
|
||||||
|
os.mkdir(os.path.join(directory.GROUP_ROOT, '.trash'))
|
||||||
|
access.migrate(self.conn)
|
||||||
|
folders = access.snapshot(self.conn)['folders']
|
||||||
|
self.assertEqual([folder['name'] for folder in folders], ['Existing'])
|
||||||
|
self.assertEqual(access.effective_levels(self.conn, folders[0]['id']), {})
|
||||||
|
|
||||||
|
def test_interrupted_acl_update_forces_repair_before_clearing_marker(self):
|
||||||
|
self.conn.execute("INSERT INTO access_settings VALUES('pendingRepair','{}')")
|
||||||
|
self.conn.commit()
|
||||||
|
with mock.patch.object(access, 'sync_permissions') as sync:
|
||||||
|
access.recover_pending(self.conn)
|
||||||
|
sync.assert_called_once_with(self.conn, force=True)
|
||||||
|
self.assertIsNone(self.conn.execute("SELECT 1 FROM access_settings WHERE key='pendingRepair'").fetchone())
|
||||||
|
|
||||||
|
def test_interrupted_archive_move_is_reversed_before_repair(self):
|
||||||
|
folder = self.folder()
|
||||||
|
source = os.path.join(directory.GROUP_ROOT, 'Finance')
|
||||||
|
destination = os.path.join(directory.GROUP_ARCHIVE_ROOT, 'Finance')
|
||||||
|
os.makedirs(directory.GROUP_ARCHIVE_ROOT)
|
||||||
|
with open(os.path.join(source, 'report.txt'), 'w') as handle:
|
||||||
|
handle.write('data')
|
||||||
|
self.conn.execute("INSERT INTO access_settings VALUES('pendingRepair',?)", (json.dumps({'move': [source, destination]}),))
|
||||||
|
self.conn.commit()
|
||||||
|
os.rename(source, destination)
|
||||||
|
with mock.patch.object(access, 'sync_permissions'):
|
||||||
|
access.recover_pending(self.conn)
|
||||||
|
self.assertTrue(os.path.isfile(os.path.join(source, 'report.txt')))
|
||||||
|
self.assertFalse(os.path.exists(destination))
|
||||||
|
self.assertEqual(self.conn.execute('SELECT path FROM shares WHERE objectGUID=?', (folder,)).fetchone()[0], source)
|
||||||
|
|
||||||
|
def test_interrupted_creation_preserves_data_as_admin_only_folder(self):
|
||||||
|
path = os.path.join(directory.GROUP_ROOT, 'Interrupted')
|
||||||
|
os.mkdir(path)
|
||||||
|
with open(os.path.join(path, 'report.txt'), 'w') as handle:
|
||||||
|
handle.write('data')
|
||||||
|
self.conn.execute("INSERT INTO access_settings VALUES('pendingRepair',?)", (json.dumps({'create': path}),))
|
||||||
|
self.conn.commit()
|
||||||
|
with mock.patch.object(access, 'sync_permissions'):
|
||||||
|
access.recover_pending(self.conn)
|
||||||
|
folder = access.snapshot(self.conn)['folders'][0]
|
||||||
|
self.assertEqual(folder['name'], 'Interrupted')
|
||||||
|
self.assertEqual(folder['permissions'], [])
|
||||||
|
self.assertTrue(os.path.isfile(os.path.join(path, 'report.txt')))
|
||||||
|
|
||||||
|
def test_failed_crash_repair_retains_marker_for_next_retry(self):
|
||||||
|
self.conn.execute("INSERT INTO access_settings VALUES('pendingRepair','{}')")
|
||||||
|
self.conn.commit()
|
||||||
|
with mock.patch.object(access, 'sync_permissions', side_effect=OSError('failed repair')):
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
access.recover_pending(self.conn)
|
||||||
|
self.assertIsNotNone(self.conn.execute("SELECT 1 FROM access_settings WHERE key='pendingRepair'").fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
class WindowsDescriptorTests(unittest.TestCase):
|
||||||
|
def test_modify_excludes_both_delete_rights_and_acl_ownership_changes(self):
|
||||||
|
mask = access.MASKS[2]
|
||||||
|
self.assertEqual(mask & (access.DELETE | access.DELETE_CHILD | 0x40000 | 0x80000), 0)
|
||||||
|
self.assertTrue(mask & 2) # FILE_WRITE_DATA / FILE_ADD_FILE.
|
||||||
|
self.assertTrue(access.MASKS[3] & access.DELETE)
|
||||||
|
self.assertTrue(access.MASKS[3] & access.DELETE_CHILD)
|
||||||
|
self.assertEqual(access.MASKS[1] & 6, 0)
|
||||||
|
|
||||||
|
def test_hidden_has_no_allow_and_creator_cannot_change_permissions(self):
|
||||||
|
sddl = access.descriptor({ALICE: 0, BOB: 2}, ADMIN)
|
||||||
|
self.assertNotIn(ALICE, sddl)
|
||||||
|
self.assertIn('(A;OICI;RC;;;OW)', sddl)
|
||||||
|
self.assertNotIn(';;;CO)', sddl)
|
||||||
|
self.assertIn('D:P', sddl)
|
||||||
|
|
||||||
|
def test_top_level_root_cannot_be_deleted_by_non_admins(self):
|
||||||
|
sddl = access.descriptor({ALICE: 3}, ADMIN, top_level=True)
|
||||||
|
self.assertIn(f'(A;;0x001201ff;;;{ALICE})', sddl)
|
||||||
|
self.assertIn(f'(A;OICIIO;0x001301ff;;;{ALICE})', sddl)
|
||||||
@@ -74,6 +74,25 @@ class TrashTests(unittest.TestCase):
|
|||||||
self.assertEqual(handle.read(), b"spreadsheet")
|
self.assertEqual(handle.read(), b"spreadsheet")
|
||||||
self.assertEqual(os.stat(destination).st_ino, before.st_ino)
|
self.assertEqual(os.stat(destination).st_ino, before.st_ino)
|
||||||
|
|
||||||
|
def test_restore_prepares_current_acl_before_destination_becomes_visible(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
env = self.roots(tmpdir)
|
||||||
|
with mock.patch.dict(os.environ, env):
|
||||||
|
trash.ensure_trash_roots()
|
||||||
|
source = self.recycled_file(env["GROUP_ROOT"], "alice/Finance/report.txt")
|
||||||
|
destination = os.path.join(env["GROUP_ROOT"], "Finance", "report.txt")
|
||||||
|
called = []
|
||||||
|
|
||||||
|
def prepare(path, fd):
|
||||||
|
self.assertEqual(path, "Finance/report.txt")
|
||||||
|
self.assertFalse(os.path.exists(destination))
|
||||||
|
self.assertEqual(os.fstat(fd).st_ino, os.stat(source).st_ino)
|
||||||
|
called.append(True)
|
||||||
|
|
||||||
|
trash.restore_item(trash.encode_item_id("Data", "alice/Finance/report.txt"), prepare=prepare)
|
||||||
|
self.assertEqual(called, [True])
|
||||||
|
self.assertTrue(os.path.exists(destination))
|
||||||
|
|
||||||
def test_restore_never_overwrites_existing_file(self):
|
def test_restore_never_overwrites_existing_file(self):
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
env = self.roots(tmpdir)
|
env = self.roots(tmpdir)
|
||||||
|
|||||||
+16
-48
@@ -299,53 +299,21 @@ 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):
|
class DirectoryManagedFolderTests(unittest.TestCase):
|
||||||
def test_tree_expands_users_whose_primary_group_is_nested(self):
|
def test_tree_reads_individual_assignments_without_ad_group_queries(self):
|
||||||
domain_users_dn = "CN=Domänen-Benutzer,CN=Users,DC=example,DC=com"
|
connection = mock.Mock()
|
||||||
frank_dn = "CN=Frank,CN=Users,DC=example,DC=com"
|
payload = {"groups": [{"folder": "Finance", "userCount": 2}], "truncated": False}
|
||||||
root = {
|
with mock.patch.object(web_ui.directory, "open_db", return_value=connection), mock.patch.object(
|
||||||
"objectGUID": "root-guid",
|
web_ui.access_control, "ensure_schema"
|
||||||
"objectSid": "S-1-5-21-111-222-333-1200",
|
), mock.patch.object(web_ui.access_control, "report_folders", return_value=payload) as report, mock.patch.object(
|
||||||
"samAccountName": "FS_Alle",
|
web_ui.directory, "fetch_fileshare_groups"
|
||||||
"shareName": "Alle",
|
) as ad_groups:
|
||||||
"distinguishedName": "CN=FS_Alle,CN=Users,DC=example,DC=com",
|
cache = web_ui.DirectoryCache()
|
||||||
"memberDns": [domain_users_dn],
|
self.assertEqual(cache.get(), payload)
|
||||||
"objectClasses": {"group"},
|
self.assertEqual(cache.get(), payload)
|
||||||
}
|
report.assert_called_once_with(connection)
|
||||||
domain_users = {
|
connection.close.assert_called_once()
|
||||||
"distinguishedname": [(domain_users_dn, False)],
|
ad_groups.assert_not_called()
|
||||||
"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):
|
||||||
@@ -1334,7 +1302,7 @@ class WebPresentationTests(unittest.TestCase):
|
|||||||
share_config = share_config.split(f"[{next_share}]", 1)[0]
|
share_config = share_config.split(f"[{next_share}]", 1)[0]
|
||||||
# Audit must wrap recycle so an SMB deletion remains unlinkat in the
|
# Audit must wrap recycle so an SMB deletion remains unlinkat in the
|
||||||
# activity log instead of becoming the recycle module's renameat.
|
# activity log instead of becoming the recycle module's renameat.
|
||||||
self.assertIn("vfs objects = acl_xattr full_audit recycle", share_config)
|
self.assertIn("vfs objects = acl_xattr xattr_tdb full_audit recycle" if share == "Data" else "vfs objects = acl_xattr full_audit recycle", share_config)
|
||||||
self.assertIn("recycle:repository = .trash/%U", share_config)
|
self.assertIn("recycle:repository = .trash/%U", share_config)
|
||||||
self.assertIn("recycle:keeptree = yes", share_config)
|
self.assertIn("recycle:keeptree = yes", share_config)
|
||||||
self.assertIn("recycle:versions = yes", share_config)
|
self.assertIn("recycle:versions = yes", share_config)
|
||||||
|
|||||||
Reference in New Issue
Block a user