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
+191
View File
@@ -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)});
+307
View File
@@ -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)
+19
View File
@@ -74,6 +74,25 @@ class TrashTests(unittest.TestCase):
self.assertEqual(handle.read(), b"spreadsheet")
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):
with tempfile.TemporaryDirectory() as tmpdir:
env = self.roots(tmpdir)
+16 -48
View File
@@ -299,53 +299,21 @@ class DomainAuthenticationTests(unittest.TestCase):
self.assertNotIn("p@ss word", run.call_args_list[0].args[0])
class DirectoryPrimaryGroupTests(unittest.TestCase):
def test_tree_expands_users_whose_primary_group_is_nested(self):
domain_users_dn = "CN=Domänen-Benutzer,CN=Users,DC=example,DC=com"
frank_dn = "CN=Frank,CN=Users,DC=example,DC=com"
root = {
"objectGUID": "root-guid",
"objectSid": "S-1-5-21-111-222-333-1200",
"samAccountName": "FS_Alle",
"shareName": "Alle",
"distinguishedName": "CN=FS_Alle,CN=Users,DC=example,DC=com",
"memberDns": [domain_users_dn],
"objectClasses": {"group"},
}
domain_users = {
"distinguishedname": [(domain_users_dn, False)],
"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 DirectoryManagedFolderTests(unittest.TestCase):
def test_tree_reads_individual_assignments_without_ad_group_queries(self):
connection = mock.Mock()
payload = {"groups": [{"folder": "Finance", "userCount": 2}], "truncated": False}
with mock.patch.object(web_ui.directory, "open_db", return_value=connection), mock.patch.object(
web_ui.access_control, "ensure_schema"
), mock.patch.object(web_ui.access_control, "report_folders", return_value=payload) as report, mock.patch.object(
web_ui.directory, "fetch_fileshare_groups"
) as ad_groups:
cache = web_ui.DirectoryCache()
self.assertEqual(cache.get(), payload)
self.assertEqual(cache.get(), payload)
report.assert_called_once_with(connection)
connection.close.assert_called_once()
ad_groups.assert_not_called()
class AuditParsingTests(unittest.TestCase):
@@ -1334,7 +1302,7 @@ class WebPresentationTests(unittest.TestCase):
share_config = share_config.split(f"[{next_share}]", 1)[0]
# Audit must wrap recycle so an SMB deletion remains unlinkat in the
# 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:keeptree = yes", share_config)
self.assertIn("recycle:versions = yes", share_config)