[Web] Groups: limit changes of domain admins to their domains

This commit is contained in:
FreddleSpl0it committed 2026-11-02 10:01:43 +01:00
1 parent 4b7ebab8f3
commit 7c73199bde
4 files changed
+61 -6

No files matched your search

@@ -42,6 +42,8 @@ class Group
public array $sendAs = []; public array $sendAs = [];
/** @var array[] {owner, rights, subscribe, state, last_error}: members may open these mailboxes (ACL group=mcg_<id>) */ /** @var array[] {owner, rights, subscribe, state, last_error}: members may open these mailboxes (ACL group=mcg_<id>) */
public array $imapGrants = []; public array $imapGrants = [];
/** @var string[] owners of removed IMAP grants whose ACL Dovecot still has, not part of the grants to write */
public array $removingImapOwners = [];
/** @var array[] {owner, type, folder, access, subscribe, state, last_error}: SOGo folders shared with the members */ /** @var array[] {owner, type, folder, access, subscribe, state, last_error}: SOGo folders shared with the members */
public array $sogoGrants = []; public array $sogoGrants = [];
/** @var array[] warnings of the last write (msg values), not stored */ /** @var array[] warnings of the last write (msg values), not stored */
@@ -501,6 +501,8 @@ class GroupRepository extends Repository
$group->imapGrants = array_map(fn(array $grant): array => array_merge($grant, ['subscribe' => (int)$grant['subscribe']]), $group->imapGrants = array_map(fn(array $grant): array => array_merge($grant, ['subscribe' => (int)$grant['subscribe']]),
$this->query("SELECT `owner`, `rights`, `subscribe`, `state`, `last_error` FROM `mailbox_group_imap_grant` $this->query("SELECT `owner`, `rights`, `subscribe`, `state`, `last_error` FROM `mailbox_group_imap_grant`
WHERE `group_id` = :id AND `state` != 'removing' ORDER BY `owner`", $id)->fetchAll(PDO::FETCH_ASSOC)); WHERE `group_id` = :id AND `state` != 'removing' ORDER BY `owner`", $id)->fetchAll(PDO::FETCH_ASSOC));
$group->removingImapOwners = $this->query("SELECT `owner` FROM `mailbox_group_imap_grant` WHERE `group_id` = :id AND `state` = 'removing'",
$id)->fetchAll(PDO::FETCH_COLUMN);
$group->sogoGrants = array_map(fn(array $grant): array => array_merge($grant, ['subscribe' => (int)$grant['subscribe']]), $group->sogoGrants = array_map(fn(array $grant): array => array_merge($grant, ['subscribe' => (int)$grant['subscribe']]),
$this->query("SELECT `owner`, `folder_type` AS `type`, `folder`, `access`, `subscribe`, `state`, `last_error` $this->query("SELECT `owner`, `folder_type` AS `type`, `folder`, `access`, `subscribe`, `state`, `last_error`
FROM `mailbox_group_sogo_grant` WHERE `group_id` = :id ORDER BY `owner`, `folder_type`, `folder`", $id)->fetchAll(PDO::FETCH_ASSOC)); FROM `mailbox_group_sogo_grant` WHERE `group_id` = :id ORDER BY `owner`, `folder_type`, `folder`", $id)->fetchAll(PDO::FETCH_ASSOC));
@@ -79,6 +79,12 @@ class GroupService
return false; return false;
} }
} }
// A removed grant still opens the mailbox until Dovecot dropped its ACL
foreach ($group->removingImapOwners as $owner) {
if (!$this->context->canAccessMailbox((string)$owner)) {
return false;
}
}
return true; return true;
} }
@@ -113,7 +119,7 @@ class GroupService
$current = $this->editable($id); $current = $this->editable($id);
$group = clone $current; $group = clone $current;
$this->apply($group, $input, $current); $this->apply($group, $input, $current);
$snapshot = $this->effects->snapshot(array_merge($current->usernames(), $group->usernames())); $snapshot = $this->effects->snapshot($this->inScope(array_merge($current->usernames(), $group->usernames())));
$sogo = $this->repo->update($group); $sogo = $this->repo->update($group);
$group->warnings = array_merge($this->sizeWarnings($group), $this->effects->apply($sogo, $snapshot), $group->warnings = array_merge($this->sizeWarnings($group), $this->effects->apply($sogo, $snapshot),
$this->sharing->applyImap((int)$group->id), $this->sharing->applySogo((int)$group->id)); $this->sharing->applyImap((int)$group->id), $this->sharing->applySogo((int)$group->id));
@@ -132,7 +138,7 @@ class GroupService
{ {
$this->requireEditor(); $this->requireEditor();
$group = $this->editable($id); $group = $this->editable($id);
$snapshot = $this->effects->snapshot($group->usernames()); $snapshot = $this->effects->snapshot($this->inScope($group->usernames()));
$this->sharing->removeImap($group); $this->sharing->removeImap($group);
$sogo = $this->repo->delete($id); $sogo = $this->repo->delete($id);
$group->warnings = $this->effects->apply($sogo, $snapshot); $group->warnings = $this->effects->apply($sogo, $snapshot);
@@ -150,13 +156,30 @@ class GroupService
if (!$own && !($this->canRead() && $this->context->canAccessMailbox($username))) { if (!$own && !($this->canRead() && $this->context->canAccessMailbox($username))) {
return []; return [];
} }
$groups = $this->lookup->membershipsOf($username);
// Domain admins see the mailbox's groups in their domains, not those of other domains
if (!$own && !$this->context->hasAdminRights()) {
$groups = array_values(array_filter($groups, fn(array $group): bool => $this->context->canAccessDomain((string)$group['domain'])));
}
return [ return [
'groups' => $this->lookup->membershipsOf($username), 'groups' => $groups,
'template' => $this->templates->effective([$username])[$username] ?? null, 'template' => $this->templates->effective([$username])[$username] ?? null,
'send_as' => $this->lookup->sendAsOf($username), 'send_as' => $this->lookup->sendAsOf($username),
]; ];
} }
/**
* Members whose templates a change of the caller may apply: a domain admin never changes mailboxes outside
* his domains, their new effective template applies with the next admin action or IdP login.
*/
private function inScope(array $usernames): array
{
if ($this->context->hasAdminRights()) {
return $usernames;
}
return array_values(array_filter($usernames, fn(string $username): bool => $this->context->canAccessMailbox($username)));
}
/** @throws AccessDenied when the caller cannot see or edit the group */ /** @throws AccessDenied when the caller cannot see or edit the group */
private function editable(int $id): Group private function editable(int $id): Group
{ {
@@ -301,6 +301,31 @@ class GroupServiceTest extends TestCase
$this->assertTrue($this->service(self::admin())->canEdit($group)); $this->assertTrue($this->service(self::admin())->canEdit($group));
} }
public function testRemovedGrantsCountUntilDovecotDroppedThem(): void
{
$group = self::stored();
$group->removingImapOwners = ['c@other.org'];
$this->assertFalse($this->service(self::domainAdmin())->canEdit($group));
$this->assertTrue($this->service(self::admin())->canEdit($group));
}
public function testDomainAdminChangesOnlyApplyTemplatesInHisDomains(): void
{
$this->repo->method('findVisible')->willReturn(self::stored([], ['a@example.org' => 'manual', 'c@other.org' => 'manual']));
$this->repo->method('update')->willReturn([]);
$this->repo->method('delete')->willReturn([]);
$effects = $this->createMock(ChangeEffects::class);
$effects->method('apply')->willReturn([]);
// before and after lists of update() may repeat a member, only the set counts
$effects->expects($this->exactly(2))->method('snapshot')
->with($this->callback(fn(array $usernames): bool => array_values(array_unique($usernames)) === ['a@example.org']))->willReturn([]);
$service = new GroupService($this->repo, $this->lookup, $this->templates, $effects, $this->sharing, self::domainAdmin());
$service->update(7, ['active' => 0]);
$service->delete(7);
}
public function testDeleteRemovesTheImapAclsFirst(): void public function testDeleteRemovesTheImapAclsFirst(): void
{ {
$group = self::stored(); $group = self::stored();
@@ -346,13 +371,16 @@ class GroupServiceTest extends TestCase
public function testMembershipsAreVisibleToTheMailboxAndItsManagers(): void public function testMembershipsAreVisibleToTheMailboxAndItsManagers(): void
{ {
$this->lookup->method('membershipsOf')->willReturn([['id' => 7, 'name' => 'Sales']]); $sales = ['id' => 7, 'domain' => 'example.org', 'name' => 'Sales'];
$foreign = ['id' => 8, 'domain' => 'other.org', 'name' => 'Other'];
$this->lookup->method('membershipsOf')->willReturn([$sales, $foreign]);
$this->lookup->method('sendAsOf')->willReturn(['sales@example.org']); $this->lookup->method('sendAsOf')->willReturn(['sales@example.org']);
$this->templates->method('effective')->willReturn(['a@example.org' => 'Staff']); $this->templates->method('effective')->willReturn(['a@example.org' => 'Staff']);
$expected = ['groups' => [['id' => 7, 'name' => 'Sales']], 'template' => 'Staff', 'send_as' => ['sales@example.org']]; $expected = ['groups' => [$sales, $foreign], 'template' => 'Staff', 'send_as' => ['sales@example.org']];
$this->assertSame($expected, $this->service(FakeContext::user('a@example.org', []))->membershipsOf('A@example.org')); $this->assertSame($expected, $this->service(FakeContext::user('a@example.org', []))->membershipsOf('A@example.org'));
$this->assertSame($expected, $this->service(self::domainAdmin())->membershipsOf('a@example.org')); // Groups of other domains stay hidden from a domain admin
$this->assertSame(['groups' => [$sales]] + $expected, $this->service(self::domainAdmin())->membershipsOf('a@example.org'));
$this->assertSame([], $this->service(FakeContext::user('b@example.org', []))->membershipsOf('a@example.org')); $this->assertSame([], $this->service(FakeContext::user('b@example.org', []))->membershipsOf('a@example.org'));
$this->assertSame([], $this->service(self::domainAdmin())->membershipsOf('c@other.org')); $this->assertSame([], $this->service(self::domainAdmin())->membershipsOf('c@other.org'));
} }