more fine grained access control

This commit is contained in:
Ludwig Lehnert
2026-10-02 16:16:41 +00:00
parent d618957b68
commit abe79dae96
19 changed files with 1541 additions and 343 deletions
+85 -2
View File
@@ -283,11 +283,11 @@ def main() -> int:
check({"Finance", "Engineering", "Projects"}.issubset(groups), f"seed groups missing: {sorted(groups)}")
check(groups["Finance"]["folder"] == "Finance", "Finance group/folder mapping is wrong")
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")
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(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")
alice_access = engine_run(
@@ -720,6 +720,89 @@ fi
"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)
check(logout.status == 200 and "Max-Age=0" in logout.headers.get("Set-Cookie", ""), "logout did not clear session cookie")
+10
View File
@@ -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'
# 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