diff --git a/README.md b/README.md index 2a70932..4b8cb95 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ This repository provides a production-oriented Samba file server container that - Samba runs in ADS mode with `winbind` identity mapping. - Static shares: - `\\server\Private` -> `/data/private` - - `\\server\Public` -> `/data/public` + - `\\server\Shared` -> `/data/public` - Dynamic shares are generated from AD groups matching `FileShare_*` or `FS_*` and written to `/etc/samba/generated/shares.conf`. - Dynamic share records are persisted in SQLite at `/state/shares.db`. - Backing storage is GUID-based and stable across group rename: @@ -132,20 +132,22 @@ Kerberos requires close time alignment. - Per-user path: `/data/private/` - Script ensures user directories exist and assigns ownership through winbind identity resolution. - Root `/data/private` is enforced read/execute-only (`0555`) to prevent folder creation directly under `\\server\Private`. -- SMB-side permission changes on `\\server\Private` are blocked (`nt acl support = no` and security masks set to `0000`). +- SMB-side ACL changes on `\\server\Private` are blocked (`nt acl support = no`). - Auto-creation skips well-known/service/non-login accounts (disabled, locked, or expired). +- Each private user tree is reconciled recursively to homogeneous permissions (dirs `0700`, files `0600`, user/admin ACLs). - Permissions: - owner user: full control - Domain Admins: ACL full control - mode: `700` - `hide unreadable = yes` + ACLs enforce that users only see their own folder. -### Public +### Shared -- Share: `\\server\Public` +- Share: `\\server\Shared` - Path: `/data/public` - Read/write for authenticated users in configurable `PUBLIC_GROUP_SID` (default: `DOMAIN_USERS_SID`, resolved through winbind). - No guest access. +- Permissions are reconciled recursively so all descendants remain homogeneous (dirs `2770`, files `0660`, shared group/admin ACLs). ### Dynamic Group Shares @@ -155,6 +157,7 @@ Kerberos requires close time alignment. - Share exposure generated in `/etc/samba/generated/shares.conf` - Dynamic share names are validated for SMB compatibility and deduplicated case-insensitively. - Group membership changes are refreshed continuously via winbind cache updates (`winbind cache time = 60`) and Samba config reload during reconciliation. +- Dynamic share trees are reconciled recursively so all descendants keep homogeneous permissions. ## Useful Commands diff --git a/app/reconcile_shares.py b/app/reconcile_shares.py index 72fb609..e52295b 100755 --- a/app/reconcile_shares.py +++ b/app/reconcile_shares.py @@ -540,38 +540,28 @@ def resolve_gid_from_sid(sid: str) -> Optional[int]: return None -def set_acl(path: str, user_uid: int, admin_gid: Optional[int]) -> None: - run_command(["setfacl", "-b", path], check=False) - acl_entries = [f"u:{user_uid}:rwx", f"d:u:{user_uid}:rwx"] - if admin_gid is not None: - acl_entries.extend([f"g:{admin_gid}:rwx", f"d:g:{admin_gid}:rwx"]) - - result = run_command( - ["setfacl", "-m", ",".join(acl_entries), path], - check=False, - ) - if result.returncode != 0: - log( - f"setfacl failed for {path}: {result.stderr.strip() or result.stdout.strip()}" - ) - - -def set_group_acl(path: str, group_gid: int) -> None: - acl_entries = [f"g:{group_gid}:rwx", f"d:g:{group_gid}:rwx"] - result = run_command(["setfacl", "-m", ",".join(acl_entries), path], check=False) - if result.returncode != 0: - log( - f"setfacl failed for {path}: {result.stderr.strip() or result.stdout.strip()}" - ) - - -def set_group_acl_with_admin( - path: str, group_gid: int, admin_gid: Optional[int] +def apply_group_permissions( + path: str, group_gid: 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-" + + os.chown(path, 0, group_gid) + os.chmod(path, mode) run_command(["setfacl", "-b", path], check=False) - acl_entries = [f"g:{group_gid}:rwx", f"d:g:{group_gid}:rwx"] + + acl_entries = [f"g:{group_gid}:{group_perms}"] if admin_gid is not None: - acl_entries.extend([f"g:{admin_gid}:rwx", f"d:g:{admin_gid}:rwx"]) + acl_entries.append(f"g:{admin_gid}:{group_perms}") + + if is_dir: + acl_entries.append(f"d:g:{group_gid}:rwx") + if admin_gid is not None: + acl_entries.append(f"d:g:{admin_gid}:rwx") + result = run_command(["setfacl", "-m", ",".join(acl_entries), path], check=False) if result.returncode != 0: log( @@ -579,6 +569,80 @@ def set_group_acl_with_admin( ) +def apply_private_permissions( + path: str, user_uid: int, user_gid: int, admin_gid: Optional[int], is_dir: bool +) -> None: + if os.path.islink(path): + return + + mode = 0o700 if is_dir else 0o600 + user_perms = "rwx" if is_dir else "rw-" + + os.chown(path, user_uid, user_gid) + os.chmod(path, mode) + run_command(["setfacl", "-b", path], check=False) + + acl_entries = [f"u:{user_uid}:{user_perms}"] + if admin_gid is not None: + acl_entries.append(f"g:{admin_gid}:{user_perms}") + + if is_dir: + acl_entries.append(f"d:u:{user_uid}:rwx") + if admin_gid is not None: + acl_entries.append(f"d:g:{admin_gid}:rwx") + + result = run_command(["setfacl", "-m", ",".join(acl_entries), path], check=False) + if result.returncode != 0: + log( + f"setfacl failed for {path}: {result.stderr.strip() or result.stdout.strip()}" + ) + + +def enforce_group_tree_permissions( + root_path: str, group_gid: int, admin_gid: Optional[int] +) -> None: + apply_group_permissions(root_path, group_gid, admin_gid, is_dir=True) + for current_root, dirnames, filenames in os.walk(root_path): + for dirname in dirnames: + apply_group_permissions( + os.path.join(current_root, dirname), group_gid, admin_gid, is_dir=True + ) + for filename in filenames: + apply_group_permissions( + os.path.join(current_root, filename), group_gid, admin_gid, is_dir=False + ) + + +def resolve_user_primary_gid(uid: int) -> Optional[int]: + try: + return pwd.getpwuid(uid).pw_gid + except KeyError: + return None + + +def enforce_private_tree_permissions( + root_path: str, user_uid: int, user_gid: int, admin_gid: Optional[int] +) -> None: + apply_private_permissions(root_path, user_uid, user_gid, admin_gid, is_dir=True) + for current_root, dirnames, filenames in os.walk(root_path): + for dirname in dirnames: + apply_private_permissions( + os.path.join(current_root, dirname), + user_uid, + user_gid, + admin_gid, + is_dir=True, + ) + for filename in filenames: + apply_private_permissions( + os.path.join(current_root, filename), + user_uid, + user_gid, + admin_gid, + is_dir=False, + ) + + def list_domain_users(non_login_users: set) -> List[str]: result = run_command(["wbinfo", "-u"], check=False) if result.returncode != 0: @@ -644,15 +708,12 @@ def sync_public_directory() -> None: gid = resolve_gid_from_sid(public_group_sid) if gid is not None: - os.chown(PUBLIC_ROOT, 0, gid) - run_command(["setfacl", "-b", PUBLIC_ROOT], check=False) - set_group_acl(PUBLIC_ROOT, gid) + admin_gid = resolve_gid_from_sid(os.getenv("DOMAIN_ADMINS_SID", "")) + enforce_group_tree_permissions(PUBLIC_ROOT, gid, admin_gid) else: group_display = qualified_group or public_group_sid or "" log(f"Unable to resolve GID for {group_display}; public ACLs unchanged") - os.chmod(PUBLIC_ROOT, 0o2770) - def sync_private_directories() -> None: workgroup = os.environ["WORKGROUP"] @@ -676,11 +737,16 @@ def sync_private_directories() -> None: log(f"Unable to resolve UID for {username}, skipping private folder") continue + user_gid = resolve_user_primary_gid(uid) + if user_gid is None: + log( + f"Unable to resolve primary GID for {username}, skipping private folder" + ) + continue + user_path = os.path.join(PRIVATE_ROOT, username) os.makedirs(user_path, exist_ok=True) - os.chown(user_path, uid, -1) - os.chmod(user_path, 0o700) - set_acl(user_path, uid, admin_gid) + enforce_private_tree_permissions(user_path, uid, user_gid, admin_gid) def sync_dynamic_directory_permissions(conn: sqlite3.Connection) -> None: @@ -706,8 +772,7 @@ def sync_dynamic_directory_permissions(conn: sqlite3.Connection) -> None: log(f"Unable to resolve GID for {sam}; leaving existing ACLs") continue - os.chown(path, 0, gid) - set_group_acl_with_admin(path, gid, admin_gid) + enforce_group_tree_permissions(path, gid, admin_gid) def with_lock() -> bool: diff --git a/etc/samba/smb.conf b/etc/samba/smb.conf index 6a9f0db..c087cd7 100644 --- a/etc/samba/smb.conf +++ b/etc/samba/smb.conf @@ -55,7 +55,7 @@ ea support = yes nt acl support = no -[Public] +[Shared] path = /data/public read only = no browseable = yes