more fine grained access control (1)
This commit is contained in:
@@ -56,7 +56,9 @@ Saving applies ACL changes recursively when the effective policy changes and dis
|
|||||||
|
|
||||||
### Upgrading from FS_* groups
|
### 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.
|
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. Folder identities and data are retained. An unresolved group or member stops migration with an error so an incomplete import cannot silently remove access.
|
||||||
|
|
||||||
|
Legacy GUID directories stored as `/data/groups/<objectGUID>` are moved into `/data/groups/data/<folderName>` (or `archive` for inactive records) before ACL reconciliation. Existing target directories are preserved; collisions receive a unique folder name. Moves use an atomic no-overwrite rename on the existing volume and retain file inodes and contents. A committed move journal recovers interruptions between filesystem and database updates. This layout repair also runs when the access migration was already marked complete, preserving saved individual permissions and avoiding another AD import. Missing or ambiguous paths stop recovery without creating empty replacements or deleting either path.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -202,7 +204,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, one-time legacy folder/membership migration, and individual folder assignments;
|
- 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;
|
- legacy GUID-path migration with file hash/inode checks, and real container restart after an already-completed access migration;
|
||||||
|
- individual user assignments, 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;
|
||||||
@@ -600,6 +603,19 @@ docker compose exec samba python3 -m json.tool /state/reconcile-status.json
|
|||||||
|
|
||||||
- After a successful repair, later cron runs return to root-only Data ACL refreshes unless group ACLs change again.
|
- After a successful repair, later cron runs return to root-only Data ACL refreshes unless group ACLs change again.
|
||||||
|
|
||||||
|
### Startup fails with `Unsafe managed folder path: /data/groups/<GUID>`
|
||||||
|
|
||||||
|
This indicates a legacy GUID directory still referenced by an already-migrated database. Update the application code/image and restart using the existing volumes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose stop samba
|
||||||
|
docker compose build samba
|
||||||
|
docker compose up -d --no-deps samba
|
||||||
|
docker compose logs --tail=100 samba
|
||||||
|
```
|
||||||
|
|
||||||
|
Startup repairs stored legacy paths without reimporting AD membership. It logs `Migrated legacy folder without overwriting data` for each completed move. Keep `/state` and all data volumes; do not remove volumes or reset the `managed` migration marker. If recovery reports a missing source or conflicting source/destination, both data paths remain untouched for inspection.
|
||||||
|
|
||||||
### `acl_xattr.so` or `full_audit.so` module load error
|
### `acl_xattr.so` or `full_audit.so` module load error
|
||||||
|
|
||||||
- If logs show `Error loading module .../vfs/acl_xattr.so` (or `full_audit.so`), your running image is missing Samba VFS modules.
|
- If logs show `Error loading module .../vfs/acl_xattr.so` (or `full_audit.so`), your running image is missing Samba VFS modules.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Admin-managed Data folders and individual user permissions, enforced by Samba Windows ACLs."""
|
"""Admin-managed Data folders and individual user permissions, enforced by Samba Windows ACLs."""
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import ctypes
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import fcntl
|
import fcntl
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -132,6 +133,7 @@ def migrate(conn):
|
|||||||
"""Snapshot existing assignments once; never discover new FS_* groups."""
|
"""Snapshot existing assignments once; never discover new FS_* groups."""
|
||||||
ensure_schema(conn)
|
ensure_schema(conn)
|
||||||
if initialized(conn):
|
if initialized(conn):
|
||||||
|
migrate_folder_layout(conn)
|
||||||
return
|
return
|
||||||
rows = conn.execute("SELECT * FROM shares WHERE isActive=1").fetchall()
|
rows = conn.execute("SELECT * FROM shares WHERE isActive=1").fetchall()
|
||||||
users, groups = read_directory(include_groups=True) if rows else ({}, {})
|
users, groups = read_directory(include_groups=True) if rows else ({}, {})
|
||||||
@@ -179,6 +181,102 @@ def migrate(conn):
|
|||||||
except Exception:
|
except Exception:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
raise
|
raise
|
||||||
|
migrate_folder_layout(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def rename_without_overwrite(source, destination):
|
||||||
|
"""Atomically move a directory on Linux, refusing any existing destination."""
|
||||||
|
libc = ctypes.CDLL(None, use_errno=True)
|
||||||
|
rename = getattr(libc, "renameat2", None)
|
||||||
|
if rename is None:
|
||||||
|
raise RuntimeError("Atomic no-overwrite folder migration is unavailable")
|
||||||
|
rename.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
|
||||||
|
rename.restype = ctypes.c_int
|
||||||
|
# AT_FDCWD=-100, RENAME_NOREPLACE=1. Never fall back to copy/remove or replace.
|
||||||
|
if rename(-100, os.fsencode(source), -100, os.fsencode(destination), 1) != 0:
|
||||||
|
error = ctypes.get_errno()
|
||||||
|
raise OSError(error, os.strerror(error), source, None, destination)
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_folder_path(row, source):
|
||||||
|
"""Only accept the old /data/groups/<objectGUID> layout, never arbitrary paths."""
|
||||||
|
base = os.path.dirname(os.path.abspath(directory.GROUP_ROOT))
|
||||||
|
try:
|
||||||
|
same_guid = uuid.UUID(os.path.basename(source)) == uuid.UUID(row["objectGUID"])
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
same_guid = False
|
||||||
|
if os.path.dirname(source) != base or not same_guid or os.path.islink(base) or os.path.islink(source):
|
||||||
|
raise RuntimeError(f"Unsafe legacy folder path: {source}")
|
||||||
|
|
||||||
|
|
||||||
|
def finish_folder_layout(conn, move):
|
||||||
|
row = conn.execute("SELECT * FROM shares WHERE objectGUID=?", (move["folderId"],)).fetchone()
|
||||||
|
if row is None or os.path.abspath(row["path"]) != move["source"]:
|
||||||
|
raise RuntimeError("Folder layout recovery does not match stored folder; all paths retained")
|
||||||
|
source, destination = move["source"], move["destination"]
|
||||||
|
legacy_folder_path(row, source)
|
||||||
|
root = os.path.abspath(directory.GROUP_ROOT if row["isActive"] else directory.GROUP_ARCHIVE_ROOT)
|
||||||
|
if os.path.islink(root) or os.path.dirname(destination) != root or os.path.basename(destination) != valid_name(move["name"]) or os.path.islink(destination):
|
||||||
|
raise RuntimeError("Unsafe folder layout recovery destination")
|
||||||
|
if os.path.lexists(source):
|
||||||
|
if not os.path.isdir(source):
|
||||||
|
raise RuntimeError(f"Legacy folder is not a directory: {source}")
|
||||||
|
if os.path.lexists(destination):
|
||||||
|
raise RuntimeError(f"Folder layout conflict; both paths retained: {source} -> {destination}")
|
||||||
|
rename_without_overwrite(source, destination)
|
||||||
|
elif not os.path.isdir(destination):
|
||||||
|
raise RuntimeError(f"Folder layout recovery cannot find source or destination; no empty folder created: {source} -> {destination}")
|
||||||
|
# The move intent was committed before rename. If startup died after rename,
|
||||||
|
# the retained destination is adopted here without repeating the AD import.
|
||||||
|
try:
|
||||||
|
conn.execute("UPDATE shares SET path=?,shareName=?,aclSignature='' WHERE objectGUID=?",
|
||||||
|
(destination, move["name"], row["objectGUID"]))
|
||||||
|
conn.execute("INSERT INTO access_changes(timestamp,actor,action,details) VALUES(?,?,?,?)",
|
||||||
|
(timestamp(), "system", "migrate-folder-layout", json.dumps(move)))
|
||||||
|
conn.execute("DELETE FROM access_settings WHERE key='pendingFolderLayout'")
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
directory.log(f"Migrated legacy folder without overwriting data: {source} -> {destination}")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_folder_layout(conn):
|
||||||
|
"""Repair old GUID paths even after the access migration has been committed."""
|
||||||
|
pending = conn.execute("SELECT value FROM access_settings WHERE key='pendingFolderLayout'").fetchone()
|
||||||
|
if pending:
|
||||||
|
finish_folder_layout(conn, json.loads(pending[0]))
|
||||||
|
rows = conn.execute("SELECT * FROM shares ORDER BY objectGUID").fetchall()
|
||||||
|
legacy = []
|
||||||
|
for row in rows:
|
||||||
|
root = os.path.abspath(directory.GROUP_ROOT if row["isActive"] else directory.GROUP_ARCHIVE_ROOT)
|
||||||
|
if os.path.islink(root):
|
||||||
|
raise RuntimeError(f"Unsafe managed folder root: {root}")
|
||||||
|
source = os.path.abspath(row["path"])
|
||||||
|
if os.path.dirname(source) == root:
|
||||||
|
safe_folder_path(row)
|
||||||
|
continue
|
||||||
|
legacy_folder_path(row, source)
|
||||||
|
if not os.path.isdir(source):
|
||||||
|
raise RuntimeError(f"Legacy folder is missing; no empty folder created: {source}")
|
||||||
|
legacy.append(row)
|
||||||
|
# Validate every legacy path before moving any of them.
|
||||||
|
for row in legacy:
|
||||||
|
root = os.path.abspath(directory.GROUP_ROOT if row["isActive"] else directory.GROUP_ARCHIVE_ROOT)
|
||||||
|
os.makedirs(root, exist_ok=True)
|
||||||
|
used = {entry.name.casefold() for entry in os.scandir(root)}
|
||||||
|
used.update(other["shareName"].casefold() for other in conn.execute("SELECT shareName FROM shares WHERE objectGUID<>?", (row["objectGUID"],)))
|
||||||
|
preferred = directory.sanitize_group_folder_name(row["shareName"])
|
||||||
|
try:
|
||||||
|
valid_name(preferred)
|
||||||
|
except ValueError:
|
||||||
|
preferred = row["objectGUID"]
|
||||||
|
name = directory.choose_group_folder_name(preferred, row["samAccountName"], row["objectGUID"], used)
|
||||||
|
move = {"folderId": row["objectGUID"], "source": os.path.abspath(row["path"]),
|
||||||
|
"destination": os.path.join(root, name), "name": name}
|
||||||
|
conn.execute("INSERT INTO access_settings VALUES('pendingFolderLayout',?)", (json.dumps(move),))
|
||||||
|
conn.commit()
|
||||||
|
finish_folder_layout(conn, move)
|
||||||
|
|
||||||
|
|
||||||
def valid_name(value):
|
def valid_name(value):
|
||||||
|
|||||||
+61
@@ -289,6 +289,19 @@ def main() -> int:
|
|||||||
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(all(kind == "user" for kind, _, _ in project_nodes), "Projects still contains group assignments")
|
check(all(kind == "user" for kind, _, _ in project_nodes), "Projects still contains group assignments")
|
||||||
|
|
||||||
|
announce("legacy GUID-path migration preserves file hashes and inodes")
|
||||||
|
engine_run("exec", FILES_CONTAINER, "python3", "-c", """
|
||||||
|
import hashlib, json
|
||||||
|
from pathlib import Path
|
||||||
|
manifest=json.loads(Path('/state/preview-legacy-manifest.json').read_text())
|
||||||
|
assert manifest
|
||||||
|
for relative, previous in manifest.items():
|
||||||
|
entry=Path('/data/groups/data')/relative
|
||||||
|
assert entry.is_file(), relative
|
||||||
|
assert hashlib.sha256(entry.read_bytes()).hexdigest()==previous['sha256'], relative
|
||||||
|
assert entry.stat().st_ino==previous['inode'], relative
|
||||||
|
""")
|
||||||
|
|
||||||
announce("SMB authorization and real share reads/writes")
|
announce("SMB authorization and real share reads/writes")
|
||||||
alice_access = engine_run(
|
alice_access = engine_run(
|
||||||
"exec", CLIENT_CONTAINER, "smbclient", f"//files.{DNS_DOMAIN}/Data", "-m", "SMB3",
|
"exec", CLIENT_CONTAINER, "smbclient", f"//files.{DNS_DOMAIN}/Data", "-m", "SMB3",
|
||||||
@@ -803,6 +816,54 @@ fi
|
|||||||
invalid = http("/api/access", method="POST", value={"action": "create-folder", "name": "../escape"}, token=token)
|
invalid = http("/api/access", method="POST", value={"action": "create-folder", "name": "../escape"}, token=token)
|
||||||
check(invalid.status == 400, "unsafe folder path accepted")
|
check(invalid.status == 400, "unsafe folder path accepted")
|
||||||
|
|
||||||
|
announce("already-migrated GUID path survives container restart with all data and rights")
|
||||||
|
created = change({"action": "create-folder", "name": "Startup recovery"})
|
||||||
|
repair_id = next(f["id"] for f in created["folders"] if f["name"] == "Startup recovery")
|
||||||
|
repair_rules = [{"kind": "user", "principalId": user_sids["alice"], "level": 2},
|
||||||
|
{"kind": "user", "principalId": user_sids["bob"], "level": 0}]
|
||||||
|
change({"action": "set-permissions", "id": repair_id, "permissions": repair_rules})
|
||||||
|
allowed('cd "Startup recovery"; put /tmp/live-note.txt retained.txt', ADMIN_USER)
|
||||||
|
# Reproduce the production failure: the access marker is already committed,
|
||||||
|
# but the folder and stored path still use /data/groups/<GUID>.
|
||||||
|
previous = json.loads(engine_run("exec", FILES_CONTAINER, "python3", "-c", """
|
||||||
|
import hashlib, json, os, sys
|
||||||
|
sys.path.insert(0,'/app')
|
||||||
|
import access_control as access
|
||||||
|
import reconcile_shares as directory
|
||||||
|
folder_id=sys.argv[1]
|
||||||
|
with access.mutation_lock():
|
||||||
|
conn=directory.open_db()
|
||||||
|
row=conn.execute('SELECT * FROM shares WHERE objectGUID=?',(folder_id,)).fetchone()
|
||||||
|
assert access.initialized(conn)
|
||||||
|
original=row['path']
|
||||||
|
retained=os.path.join(original,'retained.txt')
|
||||||
|
with open(retained,'rb') as handle:
|
||||||
|
previous={'sha256':hashlib.sha256(handle.read()).hexdigest(),'inode':os.stat(retained).st_ino}
|
||||||
|
legacy=os.path.join(os.path.dirname(directory.GROUP_ROOT),folder_id)
|
||||||
|
access.rename_without_overwrite(original,legacy)
|
||||||
|
conn.execute('UPDATE shares SET path=? WHERE objectGUID=?',(legacy,folder_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(json.dumps(previous))
|
||||||
|
""", repair_id).stdout)
|
||||||
|
engine_run("restart", FILES_CONTAINER)
|
||||||
|
eventually("file server startup after legacy path repair", lambda: http("/healthz"), lambda response: response.status == 200, timeout=120)
|
||||||
|
repaired = http("/api/access", token=token).json()
|
||||||
|
restored_folder = next(f for f in repaired["folders"] if f["id"] == repair_id)
|
||||||
|
check(restored_folder["name"] == "Startup recovery", "startup repair changed a noncolliding folder name")
|
||||||
|
check(sorted(restored_folder["permissions"], key=lambda r:r['principalId']) == sorted(repair_rules, key=lambda r:r['principalId']), "startup repair changed individual permissions")
|
||||||
|
actual = json.loads(engine_run("exec", FILES_CONTAINER, "python3", "-c", """
|
||||||
|
import hashlib, json, os
|
||||||
|
path='/data/groups/data/Startup recovery/retained.txt'
|
||||||
|
with open(path,'rb') as handle:
|
||||||
|
print(json.dumps({'sha256':hashlib.sha256(handle.read()).hexdigest(),'inode':os.stat(path).st_ino}))
|
||||||
|
""").stdout)
|
||||||
|
check(actual == previous, "startup repair did not preserve file contents and inode")
|
||||||
|
allowed('cd "Startup recovery"; get retained.txt /tmp/recovered-read.txt')
|
||||||
|
denied('cd "Startup recovery"; del retained.txt')
|
||||||
|
code, output = smb('cd "Startup recovery"; ls', 'bob')
|
||||||
|
check(code != 0 or 'NT_STATUS_ACCESS_DENIED' in output, 'startup recovery lost explicit level zero')
|
||||||
|
|
||||||
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")
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -33,10 +33,26 @@ printf '[preview-seed] Seeded group, private, and FSLogix data.\n'
|
|||||||
|
|
||||||
# Seed the old state layout to exercise the one-time migration on startup.
|
# Seed the old state layout to exercise the one-time migration on startup.
|
||||||
python3 - <<'PYSEED'
|
python3 - <<'PYSEED'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
conn = sqlite3.connect('/state/shares.db')
|
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)")
|
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)")
|
||||||
|
manifest = {}
|
||||||
for name in ('Finance', 'Engineering', 'Projects'):
|
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'))
|
guid = str(uuid.uuid5(uuid.NAMESPACE_DNS, 'preview.FS_' + name))
|
||||||
|
original = Path('/data/groups/data') / name
|
||||||
|
legacy = Path('/data/groups') / guid
|
||||||
|
for entry in original.rglob('*'):
|
||||||
|
if entry.is_file():
|
||||||
|
relative = str(Path(name) / entry.relative_to(original))
|
||||||
|
manifest[relative] = {'sha256': hashlib.sha256(entry.read_bytes()).hexdigest(), 'inode': entry.stat().st_ino}
|
||||||
|
os.rename(original, legacy)
|
||||||
|
conn.execute("INSERT INTO shares VALUES(?,?,?,?,?,?,1)", (guid, 'FS_' + name, name, str(legacy), '2026-01-01', '2026-01-01'))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
Path('/state/preview-legacy-manifest.json').write_text(json.dumps(manifest))
|
||||||
PYSEED
|
PYSEED
|
||||||
|
|||||||
@@ -130,6 +130,170 @@ class ManagedAccessTests(unittest.TestCase):
|
|||||||
self.assertEqual(row['groupCount'], 0)
|
self.assertEqual(row['groupCount'], 0)
|
||||||
self.assertEqual([(node['id'], node['level']) for node in row['members']], [(BOB, 2)])
|
self.assertEqual([(node['id'], node['level']) for node in row['members']], [(BOB, 2)])
|
||||||
|
|
||||||
|
def legacy_folder(self, active=True, name='Finance'):
|
||||||
|
guid = 'dc537393-6882-4b94-bc08-e24966623d62'
|
||||||
|
path = os.path.join(os.path.dirname(directory.GROUP_ROOT), guid)
|
||||||
|
os.makedirs(os.path.join(path, 'Reports'))
|
||||||
|
with open(os.path.join(path, 'Reports', 'retained.bin'), 'wb') as handle:
|
||||||
|
handle.write(b'production data\x00\xff\n')
|
||||||
|
self.conn.execute("INSERT INTO shares VALUES(?,?,?,?,?,?,?,'old-signature')",
|
||||||
|
(guid, 'FS_Finance', name, path, '2026-01-01', '2026-01-01', int(active)))
|
||||||
|
self.conn.commit()
|
||||||
|
return guid, path
|
||||||
|
|
||||||
|
def mark_managed_with_rules(self, folder):
|
||||||
|
self.conn.execute("INSERT INTO access_settings VALUES('managed','1')")
|
||||||
|
access.cache_users(self.conn, USERS)
|
||||||
|
self.conn.executemany('INSERT INTO folder_permissions VALUES(?,?,?,?)',
|
||||||
|
[(folder, 'user', ALICE, 2), (folder, 'user', BOB, 0)])
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def assert_retained_data(self, folder, original_inode):
|
||||||
|
row = self.conn.execute('SELECT * FROM shares WHERE objectGUID=?', (folder,)).fetchone()
|
||||||
|
path = os.path.join(row['path'], 'Reports', 'retained.bin')
|
||||||
|
with open(path, 'rb') as handle:
|
||||||
|
self.assertEqual(handle.read(), b'production data\x00\xff\n')
|
||||||
|
self.assertEqual(os.stat(path).st_ino, original_inode)
|
||||||
|
access.safe_folder_path(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
def test_already_migrated_guid_path_repaired_without_reimport_or_lost_data(self):
|
||||||
|
folder, source = self.legacy_folder()
|
||||||
|
self.mark_managed_with_rules(folder)
|
||||||
|
inode = os.stat(os.path.join(source, 'Reports', 'retained.bin')).st_ino
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'Unsafe managed folder path'):
|
||||||
|
access.safe_folder_path(self.conn.execute('SELECT * FROM shares').fetchone())
|
||||||
|
with mock.patch.object(access, 'read_directory') as ad:
|
||||||
|
access.migrate(self.conn)
|
||||||
|
access.migrate(self.conn)
|
||||||
|
ad.assert_not_called()
|
||||||
|
row = self.assert_retained_data(folder, inode)
|
||||||
|
self.assertEqual(row['path'], os.path.join(directory.GROUP_ROOT, 'Finance'))
|
||||||
|
self.assertEqual(row['shareName'], 'Finance')
|
||||||
|
self.assertEqual(row['aclSignature'], '')
|
||||||
|
self.assertEqual(access.effective_levels(self.conn, folder), {ALICE: 2, BOB: 0})
|
||||||
|
self.assertEqual(self.conn.execute("SELECT COUNT(*) FROM access_changes WHERE action='migrate-folder-layout'").fetchone()[0], 1)
|
||||||
|
self.assertIsNone(self.conn.execute("SELECT 1 FROM access_settings WHERE key='pendingFolderLayout'").fetchone())
|
||||||
|
|
||||||
|
def test_initial_ad_import_also_moves_legacy_guid_data(self):
|
||||||
|
folder, source = self.legacy_folder()
|
||||||
|
inode = os.stat(os.path.join(source, 'Reports', 'retained.bin')).st_ino
|
||||||
|
users = {ALICE: {**USERS[ALICE], 'dn': 'alice', 'primaryRid': 513}}
|
||||||
|
groups = {'finance': {'dn': 'finance', 'sam': 'FS_Finance', 'sid': 'S-1-5-21-1-2-3-1200', 'members': ['alice']}}
|
||||||
|
with mock.patch.object(access, 'read_directory', return_value=(users, groups)):
|
||||||
|
access.migrate(self.conn)
|
||||||
|
self.assert_retained_data(folder, inode)
|
||||||
|
self.assertEqual(access.effective_levels(self.conn, folder), {ALICE: 3})
|
||||||
|
|
||||||
|
def test_legacy_archived_data_moves_to_archive_and_stays_archived(self):
|
||||||
|
folder, source = self.legacy_folder(active=False)
|
||||||
|
self.mark_managed_with_rules(folder)
|
||||||
|
inode = os.stat(os.path.join(source, 'Reports', 'retained.bin')).st_ino
|
||||||
|
access.migrate(self.conn)
|
||||||
|
row = self.assert_retained_data(folder, inode)
|
||||||
|
self.assertFalse(row['isActive'])
|
||||||
|
self.assertEqual(row['path'], os.path.join(directory.GROUP_ARCHIVE_ROOT, 'Finance'))
|
||||||
|
self.assertEqual(access.effective_levels(self.conn, folder), {ALICE: 2, BOB: 0})
|
||||||
|
|
||||||
|
def test_layout_collision_preserves_both_directory_contents(self):
|
||||||
|
folder, source = self.legacy_folder()
|
||||||
|
self.mark_managed_with_rules(folder)
|
||||||
|
inode = os.stat(os.path.join(source, 'Reports', 'retained.bin')).st_ino
|
||||||
|
existing = os.path.join(directory.GROUP_ROOT, 'FINANCE')
|
||||||
|
os.mkdir(existing)
|
||||||
|
with open(os.path.join(existing, 'other.txt'), 'w') as handle:
|
||||||
|
handle.write('unrelated data')
|
||||||
|
access.migrate(self.conn)
|
||||||
|
row = self.assert_retained_data(folder, inode)
|
||||||
|
self.assertEqual(row['shareName'], 'Finance_dc537393')
|
||||||
|
with open(os.path.join(existing, 'other.txt')) as handle:
|
||||||
|
self.assertEqual(handle.read(), 'unrelated data')
|
||||||
|
|
||||||
|
def test_atomic_rename_refuses_existing_empty_destination(self):
|
||||||
|
folder, source = self.legacy_folder()
|
||||||
|
destination = os.path.join(directory.GROUP_ROOT, 'Finance')
|
||||||
|
os.mkdir(destination)
|
||||||
|
with self.assertRaises(FileExistsError):
|
||||||
|
access.rename_without_overwrite(source, destination)
|
||||||
|
self.assertTrue(os.path.isfile(os.path.join(source, 'Reports', 'retained.bin')))
|
||||||
|
self.assertTrue(os.path.isdir(destination))
|
||||||
|
|
||||||
|
def test_layout_retry_after_failure_before_move_keeps_data_and_rules(self):
|
||||||
|
folder, source = self.legacy_folder()
|
||||||
|
self.mark_managed_with_rules(folder)
|
||||||
|
inode = os.stat(os.path.join(source, 'Reports', 'retained.bin')).st_ino
|
||||||
|
with mock.patch.object(access, 'rename_without_overwrite', side_effect=OSError('interrupted before rename')):
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
access.migrate(self.conn)
|
||||||
|
self.assertTrue(os.path.isfile(os.path.join(source, 'Reports', 'retained.bin')))
|
||||||
|
self.assertIsNotNone(self.conn.execute("SELECT 1 FROM access_settings WHERE key='pendingFolderLayout'").fetchone())
|
||||||
|
self.assertEqual(access.effective_levels(self.conn, folder), {ALICE: 2, BOB: 0})
|
||||||
|
access.migrate(self.conn)
|
||||||
|
self.assert_retained_data(folder, inode)
|
||||||
|
|
||||||
|
def test_layout_retry_after_crash_between_rename_and_db_update(self):
|
||||||
|
folder, source = self.legacy_folder()
|
||||||
|
self.mark_managed_with_rules(folder)
|
||||||
|
inode = os.stat(os.path.join(source, 'Reports', 'retained.bin')).st_ino
|
||||||
|
rename = access.rename_without_overwrite
|
||||||
|
def move_then_crash(old, new):
|
||||||
|
rename(old, new)
|
||||||
|
raise RuntimeError('crashed after rename')
|
||||||
|
with mock.patch.object(access, 'rename_without_overwrite', side_effect=move_then_crash):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
access.migrate(self.conn)
|
||||||
|
self.assertFalse(os.path.lexists(source))
|
||||||
|
self.assertEqual(self.conn.execute('SELECT path FROM shares').fetchone()[0], source)
|
||||||
|
# Reopen SQLite like a new container process; recovery uses the durable journal.
|
||||||
|
reopened = directory.open_db()
|
||||||
|
try:
|
||||||
|
access.migrate(reopened)
|
||||||
|
finally:
|
||||||
|
reopened.close()
|
||||||
|
self.assert_retained_data(folder, inode)
|
||||||
|
self.assertEqual(access.effective_levels(self.conn, folder), {ALICE: 2, BOB: 0})
|
||||||
|
|
||||||
|
def test_layout_recovery_ambiguous_collision_retains_both_paths(self):
|
||||||
|
folder, source = self.legacy_folder()
|
||||||
|
self.mark_managed_with_rules(folder)
|
||||||
|
with mock.patch.object(access, 'rename_without_overwrite', side_effect=OSError('interrupted')):
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
access.migrate(self.conn)
|
||||||
|
pending = json.loads(self.conn.execute("SELECT value FROM access_settings WHERE key='pendingFolderLayout'").fetchone()[0])
|
||||||
|
os.mkdir(pending['destination'])
|
||||||
|
with open(os.path.join(pending['destination'], 'other.txt'), 'w') as handle:
|
||||||
|
handle.write('other data')
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'both paths retained'):
|
||||||
|
access.migrate(self.conn)
|
||||||
|
self.assertTrue(os.path.isfile(os.path.join(source, 'Reports', 'retained.bin')))
|
||||||
|
with open(os.path.join(pending['destination'], 'other.txt')) as handle:
|
||||||
|
self.assertEqual(handle.read(), 'other data')
|
||||||
|
self.assertIsNotNone(self.conn.execute("SELECT 1 FROM access_settings WHERE key='pendingFolderLayout'").fetchone())
|
||||||
|
|
||||||
|
def test_missing_legacy_source_does_not_create_empty_replacement(self):
|
||||||
|
folder, source = self.legacy_folder()
|
||||||
|
self.mark_managed_with_rules(folder)
|
||||||
|
os.rename(source, source + '.retained')
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'Legacy folder is missing'):
|
||||||
|
access.migrate(self.conn)
|
||||||
|
self.assertFalse(os.path.lexists(os.path.join(directory.GROUP_ROOT, 'Finance')))
|
||||||
|
self.assertTrue(os.path.isfile(os.path.join(source + '.retained', 'Reports', 'retained.bin')))
|
||||||
|
self.assertEqual(self.conn.execute('SELECT path FROM shares').fetchone()[0], source)
|
||||||
|
|
||||||
|
def test_layout_repair_rejects_symlinks_and_unregistered_paths(self):
|
||||||
|
folder, source = self.legacy_folder()
|
||||||
|
self.mark_managed_with_rules(folder)
|
||||||
|
os.rename(source, source + '.retained')
|
||||||
|
os.symlink(source + '.retained', source)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'Unsafe legacy folder path'):
|
||||||
|
access.migrate(self.conn)
|
||||||
|
self.assertTrue(os.path.islink(source))
|
||||||
|
self.assertTrue(os.path.isfile(os.path.join(source + '.retained', 'Reports', 'retained.bin')))
|
||||||
|
self.conn.execute('UPDATE shares SET path=?', (source + '.retained',))
|
||||||
|
self.conn.commit()
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'Unsafe legacy folder path'):
|
||||||
|
access.migrate(self.conn)
|
||||||
|
|
||||||
def test_invalid_permission_update_retains_existing_policy(self):
|
def test_invalid_permission_update_retains_existing_policy(self):
|
||||||
folder = self.folder()
|
folder = self.folder()
|
||||||
original = [{'kind': 'user', 'principalId': ALICE, 'level': 1}]
|
original = [{'kind': 'user', 'principalId': ALICE, 'level': 1}]
|
||||||
|
|||||||
Reference in New Issue
Block a user