(hopefully) faster reconciliation (1)

This commit is contained in:
Ludwig Lehnert
2026-07-03 03:48:21 +00:00
parent 80076c1bbb
commit fb7a69d24c
4 changed files with 449 additions and 68 deletions

View File

@@ -169,6 +169,8 @@ Kerberos requires close time alignment.
- Contains one folder per active `FS_*` AD group. - Contains one folder per active `FS_*` AD group.
- Root is discoverable as one share, while access to each group folder is enforced via POSIX/ACL group permissions. - Root is discoverable as one share, while access to each group folder is enforced via POSIX/ACL group permissions.
- `FS_*` groups may contain other AD groups; reconciliation recursively grants ACLs to nested groups and logs detected cycles. - `FS_*` groups may contain other AD groups; reconciliation recursively grants ACLs to nested groups and logs detected cycles.
- Samba blocks SMB-side ACL edits on Data and forces new items to inherit the group folder owner, group, mode, and default ACLs.
- Normal reconciliation refreshes each group folder root; recursive subtree repair runs only when the resolved ACL signature changes, is missing, or `REPAIR_DATA_ACLS=1` is set.
- Dot-prefixed group folder names are allowed and are not hidden over SMB. - Dot-prefixed group folder names are allowed and are not hidden over SMB.
- No guest access. - No guest access.
@@ -295,6 +297,17 @@ docker compose exec samba sh -lc 'tail -n 200 /var/log/backup.log'
docker compose exec samba getfacl /data/groups/data/<groupName> docker compose exec samba getfacl /data/groups/data/<groupName>
``` ```
### Data folder permissions are incorrect
- Normal reconciliation avoids walking every file when the resolved ACL signature is unchanged.
- Force a recursive repair for all active Data group folders with:
```bash
docker compose exec samba sh -lc 'REPAIR_DATA_ACLS=1 python3 /app/reconcile_shares.py'
```
- After a successful repair, later cron runs return to root-only Data ACL refreshes unless group ACLs change again.
### `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.

View File

@@ -3,6 +3,7 @@
import base64 import base64
import datetime as dt import datetime as dt
import fcntl import fcntl
import json
import os import os
import re import re
import sqlite3 import sqlite3
@@ -46,10 +47,14 @@ NESTED_GROUP_SEARCH_ATTRS = [
] ]
LDAP_MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941" LDAP_MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941"
SETFACL_ENTRY_CHUNK_SIZE = 40 SETFACL_ENTRY_CHUNK_SIZE = 40
SETFACL_PATH_CHUNK_SIZE = 100
DATA_ACL_SIGNATURE_VERSION = 1
DATA_ACL_REPAIR_ENV = "REPAIR_DATA_ACLS"
SLOW_COMMAND_LOG_SECONDS = 5.0 SLOW_COMMAND_LOG_SECONDS = 5.0
AD_COMMAND_TIMEOUT_SECONDS = 120 AD_COMMAND_TIMEOUT_SECONDS = 120
WINBIND_COMMAND_TIMEOUT_SECONDS = 30 WINBIND_COMMAND_TIMEOUT_SECONDS = 30
NSS_COMMAND_TIMEOUT_SECONDS = 30 NSS_COMMAND_TIMEOUT_SECONDS = 30
TRUE_ENV_VALUES = {"1", "true", "yes", "on"}
REQUIRED_ENV = ["REALM", "WORKGROUP", "DOMAIN"] REQUIRED_ENV = ["REALM", "WORKGROUP", "DOMAIN"]
ATTR_RE = re.compile(r"^([^:]+)(::?)\s*(.*)$") ATTR_RE = re.compile(r"^([^:]+)(::?)\s*(.*)$")
@@ -703,6 +708,17 @@ def fetch_non_login_users() -> set:
return blocked return blocked
def ensure_share_db_schema(conn: sqlite3.Connection) -> None:
columns = {
str(row["name"])
for row in conn.execute("PRAGMA table_info(shares)").fetchall()
}
if "aclSignature" not in columns:
conn.execute(
"ALTER TABLE shares ADD COLUMN aclSignature TEXT NOT NULL DEFAULT ''"
)
def open_db() -> sqlite3.Connection: def open_db() -> sqlite3.Connection:
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
@@ -716,10 +732,12 @@ def open_db() -> sqlite3.Connection:
path TEXT NOT NULL, path TEXT NOT NULL,
createdAt TIMESTAMP NOT NULL, createdAt TIMESTAMP NOT NULL,
lastSeenAt TIMESTAMP NOT NULL, lastSeenAt TIMESTAMP NOT NULL,
isActive INTEGER NOT NULL isActive INTEGER NOT NULL,
aclSignature TEXT NOT NULL DEFAULT ''
) )
""" """
) )
ensure_share_db_schema(conn)
conn.commit() conn.commit()
return conn return conn
@@ -773,6 +791,7 @@ def reconcile_db(conn: sqlite3.Connection, ad_groups: List[Dict[str, object]]) -
continue continue
existing_path = row["path"] existing_path = row["path"]
reset_acl_signature = not row["isActive"] or existing_path != path
if existing_path != path: if existing_path != path:
if os.path.exists(existing_path): if os.path.exists(existing_path):
final_path = next_available_path(path) final_path = next_available_path(path)
@@ -789,10 +808,11 @@ def reconcile_db(conn: sqlite3.Connection, ad_groups: List[Dict[str, object]]) -
shareName = ?, shareName = ?,
path = ?, path = ?,
lastSeenAt = ?, lastSeenAt = ?,
isActive = 1 isActive = 1,
aclSignature = CASE WHEN ? THEN '' ELSE aclSignature END
WHERE objectGUID = ? WHERE objectGUID = ?
""", """,
(sam, folder_name, path, timestamp, guid), (sam, folder_name, path, timestamp, 1 if reset_acl_signature else 0, guid),
) )
active_rows = conn.execute( active_rows = conn.execute(
@@ -821,7 +841,8 @@ def reconcile_db(conn: sqlite3.Connection, ad_groups: List[Dict[str, object]]) -
UPDATE shares UPDATE shares
SET isActive = 0, SET isActive = 0,
path = ?, path = ?,
lastSeenAt = ? lastSeenAt = ?,
aclSignature = ''
WHERE objectGUID = ? WHERE objectGUID = ?
""", """,
(archive_path, timestamp, guid), (archive_path, timestamp, guid),
@@ -837,7 +858,11 @@ def reload_samba() -> None:
def refresh_winbind_cache() -> None: def refresh_winbind_cache() -> None:
result = run_command(["net", "cache", "flush"], check=False) result = run_command(
["net", "cache", "flush"],
check=False,
timeout=WINBIND_COMMAND_TIMEOUT_SECONDS,
)
if result.returncode != 0: if result.returncode != 0:
log("net cache flush failed; group membership updates may be delayed") log("net cache flush failed; group membership updates may be delayed")
@@ -952,29 +977,9 @@ def resolve_group_gids_for_acl(
return gids, unresolved return gids, unresolved
def apply_setfacl_entries(path: str, acl_entries: List[str]) -> None: def group_acl_gids(
for acl_chunk in chunked(acl_entries, SETFACL_ENTRY_CHUNK_SIZE): owner_group_gid: int, acl_group_gids: List[int], admin_gid: Optional[int]
result = run_command(["setfacl", "-m", ",".join(acl_chunk), path], check=False) ) -> List[int]:
if result.returncode != 0:
log(
f"setfacl failed for {path}: "
f"{result.stderr.strip() or result.stdout.strip()}"
)
return
def apply_group_permissions(
path: str,
owner_group_gid: int,
acl_group_gids: List[int],
admin_gid: Optional[int],
is_dir: bool,
) -> None:
if os.path.islink(path):
return
mode = 0o2770 if is_dir else 0o660
group_perms = "rwx" if is_dir else "rw-"
acl_gids: List[int] = [] acl_gids: List[int] = []
seen_gids: Set[int] = set() seen_gids: Set[int] = set()
for gid in [owner_group_gid, *acl_group_gids]: for gid in [owner_group_gid, *acl_group_gids]:
@@ -983,10 +988,17 @@ def apply_group_permissions(
acl_gids.append(gid) acl_gids.append(gid)
if admin_gid is not None and admin_gid not in seen_gids: if admin_gid is not None and admin_gid not in seen_gids:
acl_gids.append(admin_gid) acl_gids.append(admin_gid)
return acl_gids
os.chown(path, 0, owner_group_gid)
os.chmod(path, mode) def group_acl_entries(
run_command(["setfacl", "-b", path], check=False) owner_group_gid: int,
acl_group_gids: List[int],
admin_gid: Optional[int],
is_dir: bool,
) -> List[str]:
group_perms = "rwx" if is_dir else "rw-"
acl_gids = group_acl_gids(owner_group_gid, acl_group_gids, admin_gid)
acl_entries = [f"g:{gid}:{group_perms}" for gid in acl_gids] acl_entries = [f"g:{gid}:{group_perms}" for gid in acl_gids]
acl_entries.append(f"m:{group_perms}") acl_entries.append(f"m:{group_perms}")
@@ -995,7 +1007,72 @@ def apply_group_permissions(
acl_entries.extend(f"d:g:{gid}:rwx" for gid in acl_gids) acl_entries.extend(f"d:g:{gid}:rwx" for gid in acl_gids)
acl_entries.append("d:m:rwx") acl_entries.append("d:m:rwx")
apply_setfacl_entries(path, acl_entries) return acl_entries
def apply_setfacl_to_paths(paths: List[str], args: List[str]) -> bool:
if not paths:
return True
ok = True
for path_chunk in chunked(paths, SETFACL_PATH_CHUNK_SIZE):
result = run_command(["setfacl", *args, *path_chunk], check=False)
if result.returncode != 0:
ok = False
log(
f"setfacl failed for {len(path_chunk)} path(s) starting at "
f"{path_chunk[0]}: {result.stderr.strip() or result.stdout.strip()}"
)
return ok
def clear_setfacl_entries(paths: List[str]) -> bool:
return apply_setfacl_to_paths(paths, ["-b"])
def apply_setfacl_entries_to_paths(paths: List[str], acl_entries: List[str]) -> bool:
ok = True
for acl_chunk in chunked(acl_entries, SETFACL_ENTRY_CHUNK_SIZE):
if not apply_setfacl_to_paths(paths, ["-m", ",".join(acl_chunk)]):
ok = False
return ok
def apply_setfacl_entries(path: str, acl_entries: List[str]) -> bool:
return apply_setfacl_entries_to_paths([path], acl_entries)
def set_group_ownership_and_mode(
paths: List[str], owner_group_gid: int, is_dir: bool
) -> int:
mode = 0o2770 if is_dir else 0o660
changed = 0
for path in paths:
if os.path.islink(path):
continue
os.chown(path, 0, owner_group_gid)
os.chmod(path, mode)
changed += 1
return changed
def apply_group_permissions(
path: str,
owner_group_gid: int,
acl_group_gids: List[int],
admin_gid: Optional[int],
is_dir: bool,
) -> bool:
if os.path.islink(path):
return True
set_group_ownership_and_mode([path], owner_group_gid, is_dir)
ok = clear_setfacl_entries([path])
if not apply_setfacl_entries(
path, group_acl_entries(owner_group_gid, acl_group_gids, admin_gid, is_dir)
):
ok = False
return ok
def apply_private_permissions( def apply_private_permissions(
@@ -1027,37 +1104,45 @@ def apply_private_permissions(
) )
def collect_group_tree_paths(root_path: str) -> Tuple[List[str], List[str]]:
dir_paths = [] if os.path.islink(root_path) else [root_path]
file_paths: List[str] = []
for current_root, dirnames, filenames in os.walk(root_path):
for dirname in dirnames:
path = os.path.join(current_root, dirname)
if not os.path.islink(path):
dir_paths.append(path)
for filename in filenames:
path = os.path.join(current_root, filename)
if not os.path.islink(path):
file_paths.append(path)
return dir_paths, file_paths
def enforce_group_tree_permissions( def enforce_group_tree_permissions(
root_path: str, root_path: str,
owner_group_gid: int, owner_group_gid: int,
acl_group_gids: List[int], acl_group_gids: List[int],
admin_gid: Optional[int], admin_gid: Optional[int],
) -> Tuple[int, int]: ) -> Tuple[int, int, bool]:
dir_count = 1 dir_paths, file_paths = collect_group_tree_paths(root_path)
file_count = 0
apply_group_permissions( set_group_ownership_and_mode(dir_paths, owner_group_gid, is_dir=True)
root_path, owner_group_gid, acl_group_gids, admin_gid, is_dir=True set_group_ownership_and_mode(file_paths, owner_group_gid, is_dir=False)
)
for current_root, dirnames, filenames in os.walk(root_path): ok = clear_setfacl_entries([*dir_paths, *file_paths])
for dirname in dirnames: if not apply_setfacl_entries_to_paths(
dir_count += 1 dir_paths, group_acl_entries(owner_group_gid, acl_group_gids, admin_gid, True)
apply_group_permissions( ):
os.path.join(current_root, dirname), ok = False
owner_group_gid, if not apply_setfacl_entries_to_paths(
acl_group_gids, file_paths, group_acl_entries(owner_group_gid, acl_group_gids, admin_gid, False)
admin_gid, ):
is_dir=True, ok = False
)
for filename in filenames: return len(dir_paths), len(file_paths), ok
file_count += 1
apply_group_permissions(
os.path.join(current_root, filename),
owner_group_gid,
acl_group_gids,
admin_gid,
is_dir=False,
)
return dir_count, file_count
def resolve_user_primary_gid(uid: int) -> Optional[int]: def resolve_user_primary_gid(uid: int) -> Optional[int]:
@@ -1238,6 +1323,31 @@ def format_dn_list(dns: List[str], limit: int = 3) -> str:
return ", ".join(shown) + suffix return ", ".join(shown) + suffix
def should_repair_data_acls() -> bool:
return os.getenv(DATA_ACL_REPAIR_ENV, "").strip().lower() in TRUE_ENV_VALUES
def build_data_acl_signature(
owner_group_gid: int, acl_group_gids: List[int], admin_gid: Optional[int]
) -> str:
payload = {
"version": DATA_ACL_SIGNATURE_VERSION,
"ownerGroupGid": owner_group_gid,
"aclGroupGids": sorted(group_acl_gids(owner_group_gid, acl_group_gids, admin_gid)),
"adminGid": admin_gid,
}
return json.dumps(payload, sort_keys=True, separators=(",", ":"))
def update_data_acl_signature(
conn: sqlite3.Connection, object_guid: str, acl_signature: str
) -> None:
conn.execute(
"UPDATE shares SET aclSignature = ? WHERE objectGUID = ?",
(acl_signature, object_guid),
)
def sync_dynamic_directory_permissions( def sync_dynamic_directory_permissions(
conn: sqlite3.Connection, ad_groups: List[Dict[str, object]] conn: sqlite3.Connection, ad_groups: List[Dict[str, object]]
) -> None: ) -> None:
@@ -1251,11 +1361,15 @@ def sync_dynamic_directory_permissions(
ad_groups_by_guid = {str(group["objectGUID"]): group for group in ad_groups} ad_groups_by_guid = {str(group["objectGUID"]): group for group in ad_groups}
nested_group_cache: Dict[str, Dict[str, Principal]] = {} nested_group_cache: Dict[str, Dict[str, Principal]] = {}
force_recursive_repair = should_repair_data_acls()
rows = conn.execute( rows = conn.execute(
"SELECT objectGUID, samAccountName, path FROM shares WHERE isActive = 1" "SELECT objectGUID, samAccountName, path, aclSignature FROM shares WHERE isActive = 1"
).fetchall() ).fetchall()
log(f"Syncing permissions for {len(rows)} active data folder group(s)") log(f"Syncing permissions for {len(rows)} active data folder group(s)")
if force_recursive_repair:
log(f"Data ACL recursive repair is forced by {DATA_ACL_REPAIR_ENV}")
for row in rows: for row in rows:
share_started = time.monotonic() share_started = time.monotonic()
guid = row["objectGUID"] guid = row["objectGUID"]
@@ -1294,23 +1408,52 @@ def sync_dynamic_directory_permissions(
workgroup, acl_group_names workgroup, acl_group_names
) )
gid_elapsed = time.monotonic() - gid_started gid_elapsed = time.monotonic() - gid_started
if unresolved_groups: if unresolved_groups or not acl_group_gids:
log( missing = ", ".join(unresolved_groups) or sam
f"Unable to resolve GID(s) for {sam}: " log(f"Unable to resolve GID(s) for {sam}: {missing}; leaving existing ACLs")
f"{', '.join(unresolved_groups)}; leaving existing ACLs"
)
continue continue
owner_group_gid = acl_group_gids[0] owner_group_gid = acl_group_gids[0]
acl_signature = build_data_acl_signature(
owner_group_gid, acl_group_gids, admin_gid
)
stored_acl_signature = row["aclSignature"] or ""
if force_recursive_repair:
sync_mode = "forced-repair"
elif stored_acl_signature != acl_signature:
sync_mode = "changed-acl-repair"
else:
sync_mode = "root-only"
os.makedirs(path, exist_ok=True) os.makedirs(path, exist_ok=True)
acl_started = time.monotonic() acl_started = time.monotonic()
dir_count, file_count = enforce_group_tree_permissions( if sync_mode == "root-only":
path, owner_group_gid, acl_group_gids, admin_gid dir_count = 1
) file_count = 0
acl_ok = apply_group_permissions(
path, owner_group_gid, acl_group_gids, admin_gid, is_dir=True
)
else:
dir_count, file_count, acl_ok = enforce_group_tree_permissions(
path, owner_group_gid, acl_group_gids, admin_gid
)
if acl_ok:
update_data_acl_signature(conn, guid, acl_signature)
else:
log(
f"ACL repair for {sam} did not complete; "
"stored ACL signature left unchanged"
)
acl_elapsed = time.monotonic() - acl_started acl_elapsed = time.monotonic() - acl_started
if sync_mode == "root-only" and not acl_ok:
log(f"Root ACL refresh for {sam} did not complete")
total_elapsed = time.monotonic() - share_started total_elapsed = time.monotonic() - share_started
status = "ok" if acl_ok else "incomplete"
log( log(
f"Synced {sam}: nested_groups={len(expansion.group_sams)}, " f"Synced {sam}: mode={sync_mode}, status={status}, "
f"nested_groups={len(expansion.group_sams)}, "
f"acl_groups={len(acl_group_gids)}, dirs={dir_count}, files={file_count}, " f"acl_groups={len(acl_group_gids)}, dirs={dir_count}, files={file_count}, "
f"expand={format_duration(expand_elapsed)}, " f"expand={format_duration(expand_elapsed)}, "
f"gids={format_duration(gid_elapsed)}, " f"gids={format_duration(gid_elapsed)}, "
@@ -1318,6 +1461,7 @@ def sync_dynamic_directory_permissions(
f"total={format_duration(total_elapsed)}" f"total={format_duration(total_elapsed)}"
) )
conn.commit()
os.makedirs(GROUP_ROOT, exist_ok=True) os.makedirs(GROUP_ROOT, exist_ok=True)
os.chown(GROUP_ROOT, 0, 0) os.chown(GROUP_ROOT, 0, 0)
run_command(["setfacl", "-b", GROUP_ROOT], check=False) run_command(["setfacl", "-b", GROUP_ROOT], check=False)
@@ -1340,6 +1484,14 @@ def with_lock() -> bool:
os.makedirs(GROUP_ARCHIVE_ROOT, exist_ok=True) os.makedirs(GROUP_ARCHIVE_ROOT, exist_ok=True)
reconcile_started = time.monotonic() reconcile_started = time.monotonic()
log("Refreshing winbind cache")
phase_started = time.monotonic()
refresh_winbind_cache()
log(
f"Refreshed winbind cache in "
f"{format_duration(time.monotonic() - phase_started)}"
)
conn = open_db() conn = open_db()
try: try:
phase_started = time.monotonic() phase_started = time.monotonic()

View File

@@ -66,7 +66,16 @@
valid users = @"${DOMAIN_USERS_GROUP}" valid users = @"${DOMAIN_USERS_GROUP}"
create mask = 0660 create mask = 0660
directory mask = 2770 directory mask = 2770
force create mode = 0660
force directory mode = 2770
security mask = 0660
directory security mask = 0770
force security mode = 0660
force directory security mode = 0770
inherit permissions = yes inherit permissions = yes
inherit acls = yes
inherit owner = yes
nt acl support = no
hide dot files = no hide dot files = no
hide unreadable = yes hide unreadable = yes
access based share enum = yes access based share enum = yes

View File

@@ -1,5 +1,9 @@
import base64 import base64
import os
import sqlite3
import tempfile
import unittest import unittest
from unittest import mock
from app import reconcile_shares as rs from app import reconcile_shares as rs
@@ -213,5 +217,208 @@ class AclResolutionTests(unittest.TestCase):
self.assertEqual(unresolved, ["Missing"]) self.assertEqual(unresolved, ["Missing"])
class DataAclSyncTests(unittest.TestCase):
def make_conn(self, share_path, acl_signature=""):
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.execute(
"""
CREATE TABLE shares (
objectGUID TEXT PRIMARY KEY,
samAccountName TEXT NOT NULL,
shareName TEXT NOT NULL,
path TEXT NOT NULL,
createdAt TIMESTAMP NOT NULL,
lastSeenAt TIMESTAMP NOT NULL,
isActive INTEGER NOT NULL,
aclSignature TEXT NOT NULL DEFAULT ''
)
"""
)
conn.execute(
"""
INSERT INTO shares (objectGUID, samAccountName, shareName, path, createdAt, lastSeenAt, isActive, aclSignature)
VALUES ('guid', 'FS_Data', 'Data', ?, 'now', 'now', 1, ?)
""",
(share_path, acl_signature),
)
conn.commit()
return conn
def ad_group(self):
return {
"objectGUID": "guid",
"samAccountName": "FS_Data",
"distinguishedName": ROOT_DN,
"memberDns": [],
"memberOfDns": [],
"objectClasses": {"group"},
}
def fetch_signature(self, conn):
row = conn.execute(
"SELECT aclSignature FROM shares WHERE objectGUID = 'guid'"
).fetchone()
return row["aclSignature"]
def sync_patches(self, tempdir, env=None):
env_values = {"WORKGROUP": "EXAMPLE"}
if env:
env_values.update(env)
return (
mock.patch.dict(os.environ, env_values, clear=True),
mock.patch.object(rs, "GROUP_ROOT", os.path.join(tempdir, "root")),
mock.patch.object(
rs,
"expand_group_membership",
return_value=rs.MembershipExpansion(group_sams=["Nested"]),
),
mock.patch.object(
rs,
"resolve_group_gids_for_acl",
return_value=([1001, 1002], []),
),
mock.patch.object(rs.os, "chown"),
mock.patch.object(rs.os, "chmod"),
mock.patch.object(
rs,
"run_command",
return_value=rs.subprocess.CompletedProcess([], 0, "", ""),
),
)
def test_data_acl_signature_is_stable_and_deduped(self):
self.assertEqual(
rs.build_data_acl_signature(20, [30, 20, 30], 10),
rs.build_data_acl_signature(20, [20, 30], 10),
)
self.assertNotEqual(
rs.build_data_acl_signature(20, [20, 30], 10),
rs.build_data_acl_signature(20, [20, 30], None),
)
def test_repair_env_truthy(self):
for value in ("1", "true", "YES", "on"):
with mock.patch.dict(os.environ, {rs.DATA_ACL_REPAIR_ENV: value}, clear=True):
self.assertTrue(rs.should_repair_data_acls())
with mock.patch.dict(os.environ, {rs.DATA_ACL_REPAIR_ENV: "0"}, clear=True):
self.assertFalse(rs.should_repair_data_acls())
def test_open_db_migrates_acl_signature_column(self):
with tempfile.TemporaryDirectory() as tempdir:
db_path = os.path.join(tempdir, "shares.db")
conn = sqlite3.connect(db_path)
conn.execute(
"""
CREATE TABLE shares (
objectGUID TEXT PRIMARY KEY,
samAccountName TEXT NOT NULL,
shareName TEXT NOT NULL,
path TEXT NOT NULL,
createdAt TIMESTAMP NOT NULL,
lastSeenAt TIMESTAMP NOT NULL,
isActive INTEGER NOT NULL
)
"""
)
conn.execute(
"""
INSERT INTO shares (objectGUID, samAccountName, shareName, path, createdAt, lastSeenAt, isActive)
VALUES ('guid', 'FS_Data', 'Data', '/tmp/data', 'now', 'now', 1)
"""
)
conn.commit()
conn.close()
with mock.patch.object(rs, "DB_PATH", db_path):
migrated = rs.open_db()
try:
columns = {
row["name"]
for row in migrated.execute("PRAGMA table_info(shares)")
}
row = migrated.execute(
"SELECT aclSignature FROM shares WHERE objectGUID = 'guid'"
).fetchone()
finally:
migrated.close()
self.assertIn("aclSignature", columns)
self.assertEqual(row["aclSignature"], "")
def test_sync_data_permissions_uses_root_only_when_signature_matches(self):
expected_signature = rs.build_data_acl_signature(1001, [1001, 1002], None)
with tempfile.TemporaryDirectory() as tempdir:
share_path = os.path.join(tempdir, "share")
os.makedirs(share_path)
conn = self.make_conn(share_path, expected_signature)
patches = self.sync_patches(tempdir)
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5], patches[6], \
mock.patch.object(rs, "apply_group_permissions", return_value=True) as apply_root, \
mock.patch.object(rs, "enforce_group_tree_permissions") as enforce_tree:
rs.sync_dynamic_directory_permissions(conn, [self.ad_group()])
apply_root.assert_called_once_with(
share_path, 1001, [1001, 1002], None, is_dir=True
)
enforce_tree.assert_not_called()
self.assertEqual(self.fetch_signature(conn), expected_signature)
conn.close()
def test_sync_data_permissions_repairs_and_stores_signature_when_changed(self):
expected_signature = rs.build_data_acl_signature(1001, [1001, 1002], None)
with tempfile.TemporaryDirectory() as tempdir:
share_path = os.path.join(tempdir, "share")
os.makedirs(share_path)
conn = self.make_conn(share_path, "old")
patches = self.sync_patches(tempdir)
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5], patches[6], \
mock.patch.object(rs, "apply_group_permissions") as apply_root, \
mock.patch.object(
rs, "enforce_group_tree_permissions", return_value=(3, 4, True)
) as enforce_tree:
rs.sync_dynamic_directory_permissions(conn, [self.ad_group()])
apply_root.assert_not_called()
enforce_tree.assert_called_once_with(share_path, 1001, [1001, 1002], None)
self.assertEqual(self.fetch_signature(conn), expected_signature)
conn.close()
def test_sync_data_permissions_keeps_old_signature_after_failed_repair(self):
with tempfile.TemporaryDirectory() as tempdir:
share_path = os.path.join(tempdir, "share")
os.makedirs(share_path)
conn = self.make_conn(share_path, "old")
patches = self.sync_patches(tempdir)
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5], patches[6], \
mock.patch.object(
rs, "enforce_group_tree_permissions", return_value=(3, 4, False)
):
rs.sync_dynamic_directory_permissions(conn, [self.ad_group()])
self.assertEqual(self.fetch_signature(conn), "old")
conn.close()
def test_sync_data_permissions_force_repairs_when_signature_matches(self):
expected_signature = rs.build_data_acl_signature(1001, [1001, 1002], None)
with tempfile.TemporaryDirectory() as tempdir:
share_path = os.path.join(tempdir, "share")
os.makedirs(share_path)
conn = self.make_conn(share_path, expected_signature)
patches = self.sync_patches(tempdir, {rs.DATA_ACL_REPAIR_ENV: "1"})
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5], patches[6], \
mock.patch.object(rs, "apply_group_permissions") as apply_root, \
mock.patch.object(
rs, "enforce_group_tree_permissions", return_value=(3, 4, True)
) as enforce_tree:
rs.sync_dynamic_directory_permissions(conn, [self.ad_group()])
apply_root.assert_not_called()
enforce_tree.assert_called_once_with(share_path, 1001, [1001, 1002], None)
self.assertEqual(self.fetch_signature(conn), expected_signature)
conn.close()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()