mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-10-29 04:33:25 +00:00
[Web][Dovecot][SOGo][DockerApi] Add mailbox groups, shared mailboxes and identity provider sync
This commit is contained in:
82 files changed
+6488
-1027
No files matched your search
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Identity\Controller;
|
||||
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Identity\Service\StatusService;
|
||||
use PDOException;
|
||||
|
||||
/**
|
||||
* HTTP API adapter for json_api.php (routes `identity-provider/sync-status`, `identity-provider-sync`,
|
||||
* `identity-link`). Messages are logged as ['identity_provider', action, object, data].
|
||||
*/
|
||||
class IdentityApiController
|
||||
{
|
||||
public function __construct(private StatusService $status, private ApiResponse $response)
|
||||
{
|
||||
}
|
||||
|
||||
/** GET identity-provider/sync-status */
|
||||
public function getStatus(): array
|
||||
{
|
||||
try {
|
||||
return $this->status->status();
|
||||
} catch (UserError) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** POST edit/identity-provider-sync: attr mode = preview|run|force */
|
||||
public function requestRun(array $attr): bool
|
||||
{
|
||||
return $this->handle('edit', 'sync', $attr, function (array $log) use ($attr): bool {
|
||||
$this->status->requestRun($attr['mode'] ?? 'run');
|
||||
return $this->response->success('iam_sync_requested', $log);
|
||||
});
|
||||
}
|
||||
|
||||
/** GET identity-link/<username> */
|
||||
public function getLink(string $username): array
|
||||
{
|
||||
return $this->status->link($username) ?? [];
|
||||
}
|
||||
|
||||
/** POST delete/identity-link: usernames */
|
||||
public function unlink(array $items): bool
|
||||
{
|
||||
return $this->handle('delete', 'link', $items, function (array $log) use ($items): bool {
|
||||
foreach ($items as $username) {
|
||||
$this->status->unlink((string)$username);
|
||||
$this->response->success(['iam_link_removed', htmlspecialchars((string)$username)], $log);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private function handle(string $action, string $object, mixed $data, callable $work): bool
|
||||
{
|
||||
$log = ['identity_provider', $action, $object, $data];
|
||||
try {
|
||||
$result = $work($log);
|
||||
} catch (UserError $e) {
|
||||
$result = $this->response->error($e, $log);
|
||||
} catch (PDOException $e) {
|
||||
$result = $this->response->danger(['mysql_error', $e->getMessage()], $log);
|
||||
}
|
||||
$this->response->flush();
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Identity\Directory;
|
||||
|
||||
use Mailcow\Identity\Model\Listing;
|
||||
|
||||
/**
|
||||
* Lists users (and groups) of an IdP for the sync. Implementations: KeycloakDirectory, LdapDirectory.
|
||||
*/
|
||||
interface Directory
|
||||
{
|
||||
/**
|
||||
* The complete listing. Anything that could make it incomplete (HTTP or LDAP errors, size limits, ranged
|
||||
* attributes) throws, because the sync treats users that are not listed as removed.
|
||||
*
|
||||
* @throws DirectoryException
|
||||
*/
|
||||
public function listAll(bool $withGroups): Listing;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Identity\Directory;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* The IdP could not be listed completely; the sync run stops without writing anything.
|
||||
*/
|
||||
class DirectoryException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Identity\Directory;
|
||||
|
||||
use Mailcow\Identity\Http\KeycloakClient;
|
||||
use Mailcow\Identity\Model\DirectoryGroup;
|
||||
use Mailcow\Identity\Model\DirectoryUser;
|
||||
use Mailcow\Identity\Model\Listing;
|
||||
use Mailcow\Identity\Model\Settings;
|
||||
|
||||
/**
|
||||
* Keycloak realm users and the groups selected by their path. Only direct group members count.
|
||||
*/
|
||||
class KeycloakDirectory implements Directory
|
||||
{
|
||||
private const PAGE = 100;
|
||||
|
||||
public function __construct(private KeycloakClient $client, private Settings $settings)
|
||||
{
|
||||
}
|
||||
|
||||
public function listAll(bool $withGroups): Listing
|
||||
{
|
||||
$users = [];
|
||||
foreach ($this->paged('/users', ['briefRepresentation' => 'false']) as $row) {
|
||||
if (isset($row['id'])) {
|
||||
$user = self::user($row);
|
||||
$users[$user->id] = $user;
|
||||
}
|
||||
}
|
||||
return new Listing($users, $withGroups ? $this->groups($users) : null);
|
||||
}
|
||||
|
||||
/** A user of the admin REST API */
|
||||
public static function user(array $row): DirectoryUser
|
||||
{
|
||||
$email = (isset($row['email']) && $row['email'] !== '') ? strtolower((string)$row['email']) : null;
|
||||
return new DirectoryUser((string)$row['id'], $email, trim(($row['firstName'] ?? '') . ' ' . ($row['lastName'] ?? '')),
|
||||
!empty($row['enabled']), array_map('strval', (array)($row['attributes']['mailcow_template'] ?? [])));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, DirectoryUser> $users
|
||||
* @return array<string, DirectoryGroup> selected groups, members limited to listed users
|
||||
*/
|
||||
private function groups(array $users): array
|
||||
{
|
||||
$groups = [];
|
||||
foreach ($this->paged('/groups', ['briefRepresentation' => 'false']) as $row) {
|
||||
$this->collect($row, $groups);
|
||||
}
|
||||
foreach ($groups as $id => $group) {
|
||||
foreach ($this->paged('/groups/' . rawurlencode((string)$id) . '/members', ['briefRepresentation' => 'true']) as $member) {
|
||||
if (isset($member['id'], $users[$member['id']])) {
|
||||
$group->memberIds[] = (string)$member['id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/** Adds the group and its subgroups that match the configured paths (Keycloak >= 23 lists children separately) */
|
||||
private function collect(array $row, array &$groups): void
|
||||
{
|
||||
$path = (string)($row['path'] ?? '/' . ($row['name'] ?? ''));
|
||||
if ($this->selected($path)) {
|
||||
$mail = $row['attributes']['mail'][0] ?? null;
|
||||
$groups[(string)$row['id']] = new DirectoryGroup((string)$row['id'], (string)($row['name'] ?? $path),
|
||||
is_string($mail) && $mail !== '' ? strtolower($mail) : null);
|
||||
}
|
||||
$children = $row['subGroups'] ?? [];
|
||||
if (empty($children) && (int)($row['subGroupCount'] ?? 0) > 0) {
|
||||
$children = $this->paged('/groups/' . rawurlencode((string)$row['id']) . '/children', ['briefRepresentation' => 'false']);
|
||||
}
|
||||
foreach ($children as $child) {
|
||||
$this->collect($child, $groups);
|
||||
}
|
||||
}
|
||||
|
||||
/** '/a' exactly, '/a/*' every group below /a, '/*' every group */
|
||||
private function selected(string $path): bool
|
||||
{
|
||||
foreach ($this->settings->groupPaths as $pattern) {
|
||||
if ($pattern === $path || (str_ends_with($pattern, '/*') && str_starts_with($path, substr($pattern, 0, -1)))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return array[] all rows of a paged list endpoint */
|
||||
private function paged(string $path, array $query): array
|
||||
{
|
||||
$rows = [];
|
||||
for ($first = 0; ; $first += self::PAGE) {
|
||||
$page = array_values($this->client->get($path, $query + ['first' => $first, 'max' => self::PAGE]));
|
||||
$rows = array_merge($rows, $page);
|
||||
if (count($page) < self::PAGE) {
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Identity\Directory;
|
||||
|
||||
use LdapRecord\Connection;
|
||||
use Mailcow\Identity\Model\DirectoryGroup;
|
||||
use Mailcow\Identity\Model\DirectoryUser;
|
||||
use Mailcow\Identity\Model\Listing;
|
||||
use Mailcow\Identity\Model\Settings;
|
||||
|
||||
/**
|
||||
* LDAP / Active Directory users and the groups matching the group filter.
|
||||
*
|
||||
* Searches run on the raw connection with paged results and check the result code of every page, because
|
||||
* LdapRecord's paginator stops silently on size limits. Group members come from memberOf (default), from the
|
||||
* member attribute of the groups, or from the AD in-chain rule (nested groups).
|
||||
*/
|
||||
class LdapDirectory implements Directory
|
||||
{
|
||||
private const PAGE = 500;
|
||||
private const ID_ATTRIBUTES = ['objectguid', 'entryuuid', 'nsuniqueid', 'ipauniqueid'];
|
||||
private const AD_IN_CHAIN = '1.2.840.113556.1.4.1941';
|
||||
/** userAccountControl: ACCOUNTDISABLE */
|
||||
private const UAC_DISABLED = 0x2;
|
||||
|
||||
public function __construct(private ?Connection $connection, private Settings $settings)
|
||||
{
|
||||
}
|
||||
|
||||
public function listAll(bool $withGroups): Listing
|
||||
{
|
||||
$s = $this->settings;
|
||||
$userFilter = $this->userFilter();
|
||||
$disabled = [];
|
||||
if ($s->disabledFilter !== '') {
|
||||
foreach ($this->search($s->basedn, '(&' . $userFilter . self::wrap($s->disabledFilter) . ')', ['dn']) as $entry) {
|
||||
$disabled[strtolower((string)$entry['dn'])] = true;
|
||||
}
|
||||
}
|
||||
$attributes = array_merge([$s->usernameField, 'displayname', 'cn', 'useraccountcontrol'],
|
||||
($s->attributeField !== '') ? [$s->attributeField] : [],
|
||||
self::idAttributes($s->idAttribute),
|
||||
($withGroups && $s->groupMemberMode === 'memberof') ? ['memberof'] : []);
|
||||
$users = [];
|
||||
$memberOf = [];
|
||||
foreach ($this->search($s->basedn, $userFilter, array_values(array_unique($attributes))) as $entry) {
|
||||
$dn = strtolower((string)$entry['dn']);
|
||||
$email = self::first($entry, $s->usernameField);
|
||||
$uac = self::first($entry, 'useraccountcontrol');
|
||||
$user = new DirectoryUser(self::entryId($entry, $s->idAttribute),
|
||||
($email === null) ? null : strtolower($email),
|
||||
self::first($entry, 'displayname') ?? self::first($entry, 'cn') ?? '',
|
||||
!isset($disabled[$dn]) && !($uac !== null && ((int)$uac & self::UAC_DISABLED)),
|
||||
($s->attributeField !== '') ? self::values($entry, $s->attributeField) : [],
|
||||
$dn);
|
||||
$users[$user->id] = $user;
|
||||
$memberOf[$user->id] = array_map('strtolower', self::values($entry, 'memberof'));
|
||||
}
|
||||
return new Listing($users, $withGroups ? $this->groups($users, $memberOf, $userFilter) : null);
|
||||
}
|
||||
|
||||
/** Attributes entryId() reads */
|
||||
public static function idAttributes(string $configured): array
|
||||
{
|
||||
return ($configured !== '') ? [$configured] : self::ID_ATTRIBUTES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable id of an entry: the configured attribute, else objectGUID (as GUID string), entryUUID, nsUniqueId,
|
||||
* ipaUniqueID, else the DN. Also used at login, with an entry of an LdapRecord raw query.
|
||||
*/
|
||||
public static function entryId(array $entry, string $configured = ''): string
|
||||
{
|
||||
foreach (self::idAttributes($configured) as $attribute) {
|
||||
$value = self::first($entry, $attribute);
|
||||
if ($value !== null && $value !== '') {
|
||||
return ($attribute === 'objectguid' && strlen($value) === 16) ? self::guid($value) : strtolower($value);
|
||||
}
|
||||
}
|
||||
return 'dn:' . strtolower((string)(self::first($entry, 'dn') ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, DirectoryUser> $users
|
||||
* @param array<string, string[]> $memberOf user id => lowercase group DNs
|
||||
* @return array<string, DirectoryGroup>
|
||||
*/
|
||||
private function groups(array $users, array $memberOf, string $userFilter): array
|
||||
{
|
||||
$s = $this->settings;
|
||||
if ($s->groupFilter === '') {
|
||||
throw new DirectoryException('The LDAP group filter is empty');
|
||||
}
|
||||
$userIds = [];
|
||||
foreach ($users as $user) {
|
||||
$userIds[$user->dn] = $user->id;
|
||||
}
|
||||
$attributes = array_merge([$s->groupNameAttribute, 'mail'], self::ID_ATTRIBUTES,
|
||||
($s->groupMemberMode === 'member') ? [$s->groupMemberAttribute] : []);
|
||||
$groups = [];
|
||||
$groupIds = [];
|
||||
foreach ($this->search(($s->groupBasedn !== '') ? $s->groupBasedn : $s->basedn, self::wrap($s->groupFilter),
|
||||
array_values(array_unique($attributes))) as $entry) {
|
||||
$dn = strtolower((string)$entry['dn']);
|
||||
$mail = self::first($entry, 'mail');
|
||||
$group = new DirectoryGroup(self::entryId($entry), self::first($entry, $s->groupNameAttribute) ?? $dn,
|
||||
($mail === null) ? null : strtolower($mail));
|
||||
if ($s->groupMemberMode === 'member') {
|
||||
foreach (array_keys($entry) as $key) {
|
||||
// AD returns large member lists in ranges; a partial list would remove members
|
||||
if (is_string($key) && str_starts_with($key, $s->groupMemberAttribute . ';range=')) {
|
||||
throw new DirectoryException('LDAP returned a ranged member list for ' . $dn . ', use the memberOf or AD mode');
|
||||
}
|
||||
}
|
||||
foreach (self::values($entry, $s->groupMemberAttribute) as $memberDn) {
|
||||
if (isset($userIds[strtolower($memberDn)])) {
|
||||
$group->memberIds[] = $userIds[strtolower($memberDn)];
|
||||
}
|
||||
}
|
||||
}
|
||||
$groups[$group->id] = $group;
|
||||
$groupIds[$dn] = $group->id;
|
||||
}
|
||||
if ($s->groupMemberMode === 'memberof') {
|
||||
foreach ($memberOf as $userId => $dns) {
|
||||
foreach ($dns as $dn) {
|
||||
if (isset($groupIds[$dn])) {
|
||||
$groups[$groupIds[$dn]]->memberIds[] = (string)$userId;
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($s->groupMemberMode === 'ad_chain') {
|
||||
foreach ($groupIds as $dn => $groupId) {
|
||||
$filter = '(&' . $userFilter . '(memberOf:' . self::AD_IN_CHAIN . ':=' . self::escape($dn) . '))';
|
||||
foreach ($this->search($s->basedn, $filter, ['dn']) as $entry) {
|
||||
$userDn = strtolower((string)$entry['dn']);
|
||||
if (isset($userIds[$userDn])) {
|
||||
$groups[$groupId]->memberIds[] = $userIds[$userDn];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($groups as $group) {
|
||||
$group->memberIds = array_values(array_unique($group->memberIds));
|
||||
}
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/** Users of the configured filter that have the username attribute */
|
||||
private function userFilter(): string
|
||||
{
|
||||
$filter = ($this->settings->filter !== '') ? self::wrap($this->settings->filter) : '(objectClass=*)';
|
||||
return '(&' . $filter . '(' . $this->settings->usernameField . '=*))';
|
||||
}
|
||||
|
||||
/**
|
||||
* Paged search that fails instead of returning a partial result.
|
||||
*
|
||||
* @return array[] entries as returned by ldap_get_entries()
|
||||
* @throws DirectoryException
|
||||
*/
|
||||
protected function search(string $base, string $filter, array $attributes): array
|
||||
{
|
||||
if ($this->connection === null) {
|
||||
throw new DirectoryException('No connection to the LDAP server');
|
||||
}
|
||||
$link = $this->connection->getLdapConnection()->getConnection();
|
||||
$entries = [];
|
||||
$cookie = '';
|
||||
$paged = true;
|
||||
while (true) {
|
||||
$controls = $paged ? [['oid' => LDAP_CONTROL_PAGEDRESULTS, 'iscritical' => true,
|
||||
'value' => ['size' => self::PAGE, 'cookie' => $cookie]]] : [];
|
||||
$result = @ldap_search($link, $base, $filter, $attributes, 0, 0, 0, LDAP_DEREF_NEVER, $controls);
|
||||
if ($result === false) {
|
||||
throw new DirectoryException('LDAP search failed: ' . ldap_error($link));
|
||||
}
|
||||
$responseControls = [];
|
||||
if (!ldap_parse_result($link, $result, $code, $matched, $message, $referrals, $responseControls)) {
|
||||
throw new DirectoryException('LDAP result could not be read: ' . ldap_error($link));
|
||||
}
|
||||
// 12 = unavailableCriticalExtension: no paging on this server, search once without it
|
||||
if ($code === 12 && $paged && $cookie === '') {
|
||||
$paged = false;
|
||||
continue;
|
||||
}
|
||||
if ($code !== 0) {
|
||||
throw new DirectoryException('LDAP search incomplete (code ' . $code . '): ' . (($message !== '') ? $message : ldap_err2str($code)));
|
||||
}
|
||||
$page = ldap_get_entries($link, $result);
|
||||
for ($i = 0; $i < (int)($page['count'] ?? 0); $i++) {
|
||||
$entries[] = $page[$i];
|
||||
}
|
||||
$cookie = $paged ? (string)($responseControls[LDAP_CONTROL_PAGEDRESULTS]['value']['cookie'] ?? '') : '';
|
||||
if ($cookie === '') {
|
||||
return $entries;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** First value of an attribute: ldap_get_entries() format or LdapRecord attributes */
|
||||
private static function first(array $entry, string $attribute): ?string
|
||||
{
|
||||
$values = self::values($entry, $attribute);
|
||||
return $values[0] ?? null;
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
private static function values(array $entry, string $attribute): array
|
||||
{
|
||||
$raw = $entry[$attribute] ?? $entry[strtolower($attribute)] ?? null;
|
||||
if (!is_array($raw)) {
|
||||
return is_scalar($raw) ? [(string)$raw] : [];
|
||||
}
|
||||
unset($raw['count']);
|
||||
return array_values(array_map('strval', array_filter($raw, 'is_scalar')));
|
||||
}
|
||||
|
||||
private static function wrap(string $filter): string
|
||||
{
|
||||
return str_starts_with($filter, '(') ? $filter : '(' . $filter . ')';
|
||||
}
|
||||
|
||||
/** RFC 4515 value escaping */
|
||||
private static function escape(string $value): string
|
||||
{
|
||||
return str_replace(['\\', '*', '(', ')', "\0"], ['\\5c', '\\2a', '\\28', '\\29', '\\00'], $value);
|
||||
}
|
||||
|
||||
/** Binary objectGUID as the usual GUID string */
|
||||
private static function guid(string $binary): string
|
||||
{
|
||||
$hex = bin2hex($binary);
|
||||
return substr($hex, 6, 2) . substr($hex, 4, 2) . substr($hex, 2, 2) . substr($hex, 0, 2) . '-'
|
||||
. substr($hex, 10, 2) . substr($hex, 8, 2) . '-' . substr($hex, 14, 2) . substr($hex, 12, 2) . '-'
|
||||
. substr($hex, 16, 4) . '-' . substr($hex, 20, 12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Identity\Http;
|
||||
|
||||
use Mailcow\Identity\Directory\DirectoryException;
|
||||
|
||||
/**
|
||||
* Admin REST API of a Keycloak realm with the client_credentials token of the mailcow client
|
||||
* (service account roles: view-users, query-groups). The HTTP call sits in send() so tests can replace it.
|
||||
*/
|
||||
class KeycloakClient
|
||||
{
|
||||
private ?string $token = null;
|
||||
|
||||
public function __construct(private string $serverUrl, private string $realm, private string $clientId,
|
||||
private string $clientSecret, private bool $ignoreSslError = false)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* GET below /admin/realms/<realm>, decoded JSON. A rejected token is renewed once.
|
||||
*
|
||||
* @throws DirectoryException
|
||||
*/
|
||||
public function get(string $path, array $query = []): array
|
||||
{
|
||||
$url = $this->serverUrl . '/admin/realms/' . rawurlencode($this->realm) . $path . (empty($query) ? '' : '?' . http_build_query($query));
|
||||
for ($attempt = 0; $attempt < 2; $attempt++) {
|
||||
$this->token ??= $this->fetchToken();
|
||||
[$code, $body] = $this->send('GET', $url, ['Authorization: Bearer ' . $this->token, 'Accept: application/json'], null);
|
||||
if ($code === 401 && $attempt === 0) {
|
||||
$this->token = null;
|
||||
continue;
|
||||
}
|
||||
if ($code !== 200) {
|
||||
throw new DirectoryException('Keycloak returned HTTP ' . $code . ' for ' . $path);
|
||||
}
|
||||
$data = json_decode($body, true);
|
||||
if (!is_array($data)) {
|
||||
throw new DirectoryException('Keycloak returned invalid JSON for ' . $path);
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
throw new DirectoryException('Keycloak rejected the access token of the mailcow client');
|
||||
}
|
||||
|
||||
/** @throws DirectoryException */
|
||||
private function fetchToken(): string
|
||||
{
|
||||
[$code, $body] = $this->send('POST', $this->serverUrl . '/realms/' . rawurlencode($this->realm) . '/protocol/openid-connect/token',
|
||||
['Content-Type: application/x-www-form-urlencoded'],
|
||||
http_build_query(['grant_type' => 'client_credentials', 'client_id' => $this->clientId, 'client_secret' => $this->clientSecret]));
|
||||
$data = json_decode($body, true);
|
||||
if ($code !== 200 || !is_array($data) || empty($data['access_token'])) {
|
||||
throw new DirectoryException('Keycloak token request failed (HTTP ' . $code . ')');
|
||||
}
|
||||
return (string)$data['access_token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: int, 1: string} HTTP status and body
|
||||
* @throws DirectoryException on transport errors
|
||||
*/
|
||||
protected function send(string $method, string $url, array $headers, ?string $body): array
|
||||
{
|
||||
$curl = curl_init($url);
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
]);
|
||||
if ($method === 'POST') {
|
||||
curl_setopt($curl, CURLOPT_POST, true);
|
||||
curl_setopt($curl, CURLOPT_POSTFIELDS, (string)$body);
|
||||
}
|
||||
if ($this->ignoreSslError) {
|
||||
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 0);
|
||||
}
|
||||
$response = curl_exec($curl);
|
||||
$code = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($curl);
|
||||
curl_close($curl);
|
||||
if ($response === false) {
|
||||
throw new DirectoryException('Keycloak request failed: ' . $error);
|
||||
}
|
||||
return [$code, (string)$response];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Identity;
|
||||
|
||||
use Closure;
|
||||
use LdapRecord\Connection;
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Identity\Controller\IdentityApiController;
|
||||
use Mailcow\Identity\Directory\Directory;
|
||||
use Mailcow\Identity\Directory\KeycloakDirectory;
|
||||
use Mailcow\Identity\Directory\LdapDirectory;
|
||||
use Mailcow\Identity\Http\KeycloakClient;
|
||||
use Mailcow\Identity\Legacy\LegacyAccounts;
|
||||
use Mailcow\Identity\Model\Settings;
|
||||
use Mailcow\Identity\Repository\IdentityRepository;
|
||||
use Mailcow\Identity\Repository\RunRepository;
|
||||
use Mailcow\Identity\Service\LinkService;
|
||||
use Mailcow\Identity\Service\StatusService;
|
||||
use Mailcow\Identity\Service\SyncPlanner;
|
||||
use Mailcow\Identity\Service\SyncService;
|
||||
use Mailcow\MailboxGroup\MailboxGroupModule;
|
||||
use Mailcow\Sogo\SogoModule;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Composition root of the identity module (IdP links, sync, offboarding).
|
||||
*
|
||||
* IdentityModule::create($pdo, Context::system(), identity_provider('get'), fn() => identity_provider('init'))->sync->runDue(); // cron
|
||||
* IdentityModule::fromGlobals()->api->getLink($username); // edit page
|
||||
*
|
||||
* Logins use the links through Mailcow\Auth (AuthModule::fromGlobals()->idp).
|
||||
*/
|
||||
class IdentityModule
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Settings $settings,
|
||||
public readonly SyncService $sync,
|
||||
public readonly LinkService $links,
|
||||
public readonly StatusService $status,
|
||||
public readonly IdentityApiController $api
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $settings identity_provider('get')
|
||||
* @param ?Closure $ldap returns the bound LDAP connection (identity_provider('init')), called when a run starts
|
||||
*/
|
||||
public static function create(PDO $pdo, Context $context, array $settings, ?Closure $ldap = null): self
|
||||
{
|
||||
$config = Settings::fromArray($settings);
|
||||
$repo = new IdentityRepository($pdo);
|
||||
$runs = new RunRepository($pdo);
|
||||
$groups = MailboxGroupModule::create($pdo, Context::system());
|
||||
$status = new StatusService($config, $repo, $runs, $context);
|
||||
$directory = fn(): ?Directory => self::directory($config, $ldap);
|
||||
return new self($config,
|
||||
new SyncService($config, $directory, $repo, $runs, new SyncPlanner(), $groups->idp, $groups->templates, new LegacyAccounts(),
|
||||
SogoModule::create()->sogo),
|
||||
new LinkService($repo), $status, new IdentityApiController($status, new ApiResponse()));
|
||||
}
|
||||
|
||||
/** Legacy entry point for procedural callers: reads $pdo, $iam_settings and $_SESSION; $context overrides the session caller */
|
||||
public static function fromGlobals(?Context $context = null): self
|
||||
{
|
||||
$settings = $GLOBALS['iam_settings'] ?? null;
|
||||
return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? []),
|
||||
is_array($settings) ? $settings : identity_provider('get'), fn(): mixed => $GLOBALS['iam_provider'] ?? null);
|
||||
}
|
||||
|
||||
private static function directory(Settings $settings, ?Closure $ldap): ?Directory
|
||||
{
|
||||
if ($settings->provider === Settings::KEYCLOAK) {
|
||||
return new KeycloakDirectory(new KeycloakClient($settings->serverUrl, $settings->realm, $settings->clientId,
|
||||
$settings->clientSecret, $settings->ignoreSslError), $settings);
|
||||
}
|
||||
if ($settings->provider === Settings::LDAP) {
|
||||
$connection = ($ldap === null) ? null : $ldap();
|
||||
return new LdapDirectory(($connection instanceof Connection) ? $connection : null, $settings);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Identity\Legacy;
|
||||
|
||||
use Mailcow\MailboxGroup\Legacy\LegacyMailbox;
|
||||
|
||||
/**
|
||||
* Bridge to the procedural mailbox code for the IdP sync: creating mailboxes, applying templates.
|
||||
*/
|
||||
class LegacyAccounts extends LegacyMailbox
|
||||
{
|
||||
/**
|
||||
* Creates a mailbox of the IdP from a template, like the IdP login does.
|
||||
*
|
||||
* @return ?string null on success, else the message key of the legacy error
|
||||
*/
|
||||
public function createMailbox(string $email, string $name, string $authsource, string $template): ?string
|
||||
{
|
||||
return $this->asSystem(fn(): mixed => $this->callCreate($email, $name, $authsource, $template));
|
||||
}
|
||||
|
||||
protected function callCreate(string $email, string $name, string $authsource, string $template): mixed
|
||||
{
|
||||
[$local, $domain] = explode('@', $email, 2);
|
||||
return mailbox('add', 'mailbox_from_template', ['domain' => $domain, 'local_part' => $local, 'name' => $name,
|
||||
'authsource' => $authsource, 'template' => $template]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Identity\Model;
|
||||
|
||||
/**
|
||||
* A mailbox with the authsource of the configured IdP and its link (table `identity_link`).
|
||||
*/
|
||||
class Account
|
||||
{
|
||||
/**
|
||||
* @param int $active mailbox.active: 1 active, 2 login disabled (still receives mail), 0 inactive
|
||||
* @param bool $idpDisabled the sync set active = 2, so it may undo it
|
||||
* @param ?string $missingSince first complete run that did not list the user
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $username,
|
||||
public readonly string $name,
|
||||
public readonly int $active,
|
||||
public readonly ?string $externalId = null,
|
||||
public readonly bool $idpDisabled = false,
|
||||
public readonly ?string $missingSince = null,
|
||||
public readonly string $attributeHash = ''
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Identity\Model;
|
||||
|
||||
/**
|
||||
* A selected IdP group with the ids of its member users.
|
||||
*/
|
||||
class DirectoryGroup
|
||||
{
|
||||
/**
|
||||
* @param ?string $mail group address in the IdP, decides the domain of an auto-created group
|
||||
* @param string[] $memberIds ids of the member users (DirectoryUser::$id)
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $id,
|
||||
public readonly string $name,
|
||||
public readonly ?string $mail = null,
|
||||
public array $memberIds = []
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Identity\Model;
|
||||
|
||||
/**
|
||||
* A user as listed by the IdP. `id` is stable across renames (Keycloak user id, LDAP objectGUID/entryUUID/...).
|
||||
*/
|
||||
class DirectoryUser
|
||||
{
|
||||
/**
|
||||
* @param ?string $email lowercase, null if the user has none
|
||||
* @param string[] $templateValues values of the template attribute (legacy mapping)
|
||||
* @param string $dn LDAP only, lowercase, used to resolve group members
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $id,
|
||||
public readonly ?string $email,
|
||||
public readonly string $name,
|
||||
public readonly bool $enabled,
|
||||
public readonly array $templateValues = [],
|
||||
public readonly string $dn = ''
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Identity\Model;
|
||||
|
||||
/**
|
||||
* A complete listing of the IdP. Directories never return partial listings, they throw DirectoryException,
|
||||
* so a user missing here is really gone (or out of the filter).
|
||||
*/
|
||||
class Listing
|
||||
{
|
||||
/**
|
||||
* @param array<string, DirectoryUser> $users by id
|
||||
* @param ?array<string, DirectoryGroup> $groups by id, null when groups were not listed
|
||||
*/
|
||||
public function __construct(public readonly array $users, public readonly ?array $groups = null)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Identity\Model;
|
||||
|
||||
/**
|
||||
* Identity provider settings (table `identity_provider`, key/value, see identity_provider('get')) with defaults.
|
||||
*/
|
||||
class Settings
|
||||
{
|
||||
public const KEYCLOAK = 'keycloak';
|
||||
public const LDAP = 'ldap';
|
||||
public const GENERIC_OIDC = 'generic-oidc';
|
||||
public const MEMBER_MODES = ['memberof', 'member', 'ad_chain'];
|
||||
|
||||
public string $provider = '';
|
||||
public bool $periodicSync = false;
|
||||
public bool $importUsers = false;
|
||||
/** Minutes between scheduled runs */
|
||||
public int $syncInterval = 15;
|
||||
/** Users disabled or removed in the IdP get login disabled (mailbox.active = 2) */
|
||||
public bool $offboarding = false;
|
||||
/** Max. share (percent) of active IdP mailboxes one run may deactivate, at least 5 mailboxes */
|
||||
public int $offboardingLimit = 10;
|
||||
public bool $groupSync = false;
|
||||
public bool $groupAutocreate = true;
|
||||
public string $groupDefaultDomain = '';
|
||||
public string $defaultTemplate = '';
|
||||
/** @var string[] attribute value per mapping, keys aligned with $templates */
|
||||
public array $mappers = [];
|
||||
/** @var string[] */
|
||||
public array $templates = [];
|
||||
public string $serverUrl = '';
|
||||
public string $realm = '';
|
||||
public string $clientId = '';
|
||||
public string $clientSecret = '';
|
||||
public bool $ignoreSslError = false;
|
||||
/** @var string[] Keycloak group paths: '/path', '/path/*' (subtree) or '/*' (all) */
|
||||
public array $groupPaths = [];
|
||||
public string $basedn = '';
|
||||
public string $filter = '';
|
||||
public string $usernameField = 'mail';
|
||||
public string $attributeField = '';
|
||||
/** LDAP attribute with the stable user id, '' = objectGUID, entryUUID, nsUniqueId, ipaUniqueID, else the DN */
|
||||
public string $idAttribute = '';
|
||||
/** LDAP filter matching disabled users (besides AD userAccountControl) */
|
||||
public string $disabledFilter = '';
|
||||
public string $groupBasedn = '';
|
||||
public string $groupFilter = '';
|
||||
public string $groupNameAttribute = 'cn';
|
||||
public string $groupMemberMode = 'memberof';
|
||||
public string $groupMemberAttribute = 'member';
|
||||
|
||||
public static function fromArray(array $s): self
|
||||
{
|
||||
$settings = new self();
|
||||
$settings->provider = (string)($s['authsource'] ?? '');
|
||||
$settings->periodicSync = self::flag($s['periodic_sync'] ?? 0);
|
||||
$settings->importUsers = self::flag($s['import_users'] ?? 0);
|
||||
$settings->syncInterval = max(1, (int)($s['sync_interval'] ?? 15));
|
||||
$settings->offboarding = self::flag($s['offboarding'] ?? 0);
|
||||
$settings->offboardingLimit = min(100, max(0, (int)($s['offboarding_limit'] ?? 10)));
|
||||
$settings->groupSync = self::flag($s['group_sync'] ?? 0);
|
||||
$settings->groupAutocreate = self::flag($s['group_autocreate'] ?? 1);
|
||||
$settings->groupDefaultDomain = strtolower(trim((string)($s['group_default_domain'] ?? '')));
|
||||
$settings->defaultTemplate = (string)($s['default_template'] ?? '');
|
||||
$settings->mappers = array_map('strval', (array)($s['mappers'] ?? []));
|
||||
$settings->templates = array_map('strval', (array)($s['templates'] ?? []));
|
||||
$settings->serverUrl = rtrim((string)($s['server_url'] ?? ''), '/');
|
||||
$settings->realm = (string)($s['realm'] ?? '');
|
||||
$settings->clientId = (string)($s['client_id'] ?? '');
|
||||
$settings->clientSecret = (string)($s['client_secret'] ?? '');
|
||||
$settings->ignoreSslError = self::flag($s['ignore_ssl_error'] ?? 0);
|
||||
$settings->groupPaths = array_values(array_filter(array_map('trim', explode(',', (string)($s['group_paths'] ?? ''))), 'strlen'));
|
||||
$settings->basedn = (string)($s['basedn'] ?? '');
|
||||
$settings->filter = trim((string)($s['filter'] ?? ''));
|
||||
$settings->usernameField = strtolower((string)(($s['username_field'] ?? '') ?: 'mail'));
|
||||
$settings->attributeField = strtolower(trim((string)($s['attribute_field'] ?? '')));
|
||||
$settings->idAttribute = strtolower(trim((string)($s['id_attribute'] ?? '')));
|
||||
$settings->disabledFilter = trim((string)($s['disabled_filter'] ?? ''));
|
||||
$settings->groupBasedn = trim((string)($s['group_basedn'] ?? ''));
|
||||
$settings->groupFilter = trim((string)($s['group_filter'] ?? ''));
|
||||
$settings->groupNameAttribute = strtolower((string)(($s['group_name_attribute'] ?? '') ?: 'cn'));
|
||||
$mode = (string)($s['group_member_mode'] ?? 'memberof');
|
||||
$settings->groupMemberMode = in_array($mode, self::MEMBER_MODES, true) ? $mode : 'memberof';
|
||||
$settings->groupMemberAttribute = strtolower((string)(($s['group_member_attribute'] ?? '') ?: 'member'));
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/** Only Keycloak and LDAP can be listed by the sync */
|
||||
public function canSync(): bool
|
||||
{
|
||||
return in_array($this->provider, [self::KEYCLOAK, self::LDAP], true);
|
||||
}
|
||||
|
||||
public function syncEnabled(): bool
|
||||
{
|
||||
return $this->canSync() && ($this->periodicSync || $this->importUsers || $this->offboarding || $this->groupSync);
|
||||
}
|
||||
|
||||
/** Template of the first mapping (in configured order) matching one of the values, null if none */
|
||||
public function mappedTemplate(array $values): ?string
|
||||
{
|
||||
$values = array_map('strval', array_filter($values, 'is_scalar'));
|
||||
foreach ($this->mappers as $key => $mapper) {
|
||||
if (in_array($mapper, $values, true) && ($this->templates[$key] ?? '') !== '') {
|
||||
return $this->templates[$key];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function flag(mixed $value): bool
|
||||
{
|
||||
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Identity\Model;
|
||||
|
||||
/**
|
||||
* What mailcow knows before a sync run, read by SyncService and planned against by SyncPlanner.
|
||||
*/
|
||||
class State
|
||||
{
|
||||
/**
|
||||
* @param array<string, Account> $accounts mailboxes with the provider as authsource, by username
|
||||
* @param array<string, string> $mailboxes username => authsource of every mailbox
|
||||
* @param string[] $domains mailcow domains
|
||||
* @param array[] $groups mailbox groups {id, domain, name, idp_ref, template, priority,
|
||||
* active, members: username => source}
|
||||
* @param array<string, ?string> $effectiveTemplates username => effective group template
|
||||
* @param array<string, string> $templateHashes template name => attribute hash
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly array $accounts,
|
||||
public readonly array $mailboxes = [],
|
||||
public readonly array $domains = [],
|
||||
public readonly array $groups = [],
|
||||
public readonly array $effectiveTemplates = [],
|
||||
public readonly array $templateHashes = []
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Identity\Model;
|
||||
|
||||
/**
|
||||
* What one sync run changes. Built by SyncPlanner from a listing and the current state, applied by SyncService.
|
||||
*/
|
||||
class SyncPlan
|
||||
{
|
||||
/** Details per kind kept in the stored summary */
|
||||
private const DETAILS = 50;
|
||||
|
||||
/** @var array<string, string> username => external id (adopted by email or relinked) */
|
||||
public array $links = [];
|
||||
/** @var array[] {email, name, template, id}: mailboxes to create */
|
||||
public array $creates = [];
|
||||
/** @var string[] usernames set to login disabled (active = 2) */
|
||||
public array $deactivate = [];
|
||||
/** @var string[] usernames the sync had disabled and the IdP enabled again (active = 1) */
|
||||
public array $restore = [];
|
||||
/** @var string[] usernames whose idp_disabled marker is dropped, active stays (an admin changed it) */
|
||||
public array $release = [];
|
||||
/** @var string[] usernames not listed for the first time (missing_since is set) */
|
||||
public array $missing = [];
|
||||
/** @var string[] usernames listed again (missing_since is cleared) */
|
||||
public array $found = [];
|
||||
/** @var array[] {username, name, template}: existing mailboxes whose name/template is synced; template = legacy mapping */
|
||||
public array $updates = [];
|
||||
/** @var array<string, array> username => {name, template}: IdP name and legacy mapping of every listed mailbox */
|
||||
public array $profiles = [];
|
||||
/** @var array[] {ref, name, domain, mail}: groups to create for IdP groups */
|
||||
public array $groupCreates = [];
|
||||
/** @var array<int, string> group id => new name */
|
||||
public array $groupRenames = [];
|
||||
/** @var array<string, string[]> group ref => usernames of its IdP members */
|
||||
public array $memberships = [];
|
||||
/** @var int[] linked groups no longer listed; their IdP members are removed */
|
||||
public array $orphanedGroups = [];
|
||||
/** Number of IdP memberships the plan removes (for the guard) */
|
||||
public int $membershipRemovals = 0;
|
||||
/** @var string[] situations an admin has to resolve */
|
||||
public array $conflicts = [];
|
||||
/** Users without email, not in a mailcow domain or without template (not created) */
|
||||
public int $skipped = 0;
|
||||
/** The guard stopped deactivations and membership removals */
|
||||
public bool $blocked = false;
|
||||
|
||||
/** Stored with the run and shown in the UI */
|
||||
public function summary(): array
|
||||
{
|
||||
$summary = ['counts' => [], 'details' => []];
|
||||
foreach (['links' => 'links', 'creates' => 'creates', 'deactivate' => 'deactivate', 'restore' => 'restore',
|
||||
'release' => 'release', 'missing' => 'missing', 'found' => 'found', 'updates' => 'updates', 'group_creates' => 'groupCreates',
|
||||
'group_renames' => 'groupRenames', 'memberships' => 'memberships', 'orphaned_groups' => 'orphanedGroups',
|
||||
'conflicts' => 'conflicts'] as $key => $kind) {
|
||||
$summary['counts'][$key] = count($this->$kind);
|
||||
}
|
||||
$summary['counts']['membership_removals'] = $this->membershipRemovals;
|
||||
$summary['counts']['skipped'] = $this->skipped;
|
||||
$summary['details'] = [
|
||||
'creates' => array_slice(array_column($this->creates, 'email'), 0, self::DETAILS),
|
||||
'deactivate' => array_slice($this->deactivate, 0, self::DETAILS),
|
||||
'restore' => array_slice($this->restore, 0, self::DETAILS),
|
||||
'missing' => array_slice($this->missing, 0, self::DETAILS),
|
||||
'group_creates' => array_slice(array_column($this->groupCreates, 'name'), 0, self::DETAILS),
|
||||
'conflicts' => array_slice($this->conflicts, 0, self::DETAILS),
|
||||
];
|
||||
$summary['blocked'] = $this->blocked;
|
||||
return $summary;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Identity\Repository;
|
||||
|
||||
use Mailcow\Core\Repository;
|
||||
use Mailcow\Identity\Model\Account;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* SQL for IdP mailboxes and their links (table `identity_link`).
|
||||
*/
|
||||
class IdentityRepository extends Repository
|
||||
{
|
||||
/** @return array<string, Account> mailboxes with the provider as authsource, by username */
|
||||
public function accounts(string $provider): array
|
||||
{
|
||||
$rows = $this->query("SELECT m.`username`, m.`name`, m.`active`,
|
||||
JSON_UNQUOTE(JSON_EXTRACT(m.`attributes`, '$.attribute_hash')) AS `attribute_hash`,
|
||||
l.`external_id`, l.`idp_disabled`, l.`missing_since`
|
||||
FROM `mailbox` m
|
||||
LEFT JOIN `identity_link` l ON l.`username` = m.`username` AND l.`provider` = :p1
|
||||
WHERE m.`authsource` = :p2 AND m.`kind` = ''", ['p1' => $provider, 'p2' => $provider])->fetchAll(PDO::FETCH_ASSOC);
|
||||
$accounts = [];
|
||||
foreach ($rows as $row) {
|
||||
$accounts[(string)$row['username']] = new Account((string)$row['username'], (string)$row['name'], (int)$row['active'],
|
||||
isset($row['external_id']) ? (string)$row['external_id'] : null, (int)($row['idp_disabled'] ?? 0) === 1,
|
||||
isset($row['missing_since']) ? (string)$row['missing_since'] : null, (string)($row['attribute_hash'] ?? ''));
|
||||
}
|
||||
return $accounts;
|
||||
}
|
||||
|
||||
/** @return array{accounts: int, linked: int, idp_disabled: int, missing: int} mailboxes of the provider */
|
||||
public function stats(string $provider): array
|
||||
{
|
||||
$row = $this->query("SELECT COUNT(*) AS `accounts`, COUNT(l.`external_id`) AS `linked`,
|
||||
COALESCE(SUM(l.`idp_disabled`), 0) AS `idp_disabled`, COUNT(l.`missing_since`) AS `missing`
|
||||
FROM `mailbox` m LEFT JOIN `identity_link` l ON l.`username` = m.`username` AND l.`provider` = :p1
|
||||
WHERE m.`authsource` = :p2 AND m.`kind` = ''", ['p1' => $provider, 'p2' => $provider])->fetch(PDO::FETCH_ASSOC);
|
||||
return array_map('intval', $row);
|
||||
}
|
||||
|
||||
/** @return array<string, string> username => authsource of every mailbox (incl. resources) */
|
||||
public function mailboxes(): array
|
||||
{
|
||||
return $this->query("SELECT `username`, `authsource` FROM `mailbox`")->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
}
|
||||
|
||||
/** @return string[] mailcow domains (no alias domains) */
|
||||
public function domains(): array
|
||||
{
|
||||
return $this->query("SELECT `domain` FROM `domain`")->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
|
||||
/** @return array<string, string> template name => hash as stored by mailbox_from_template in attributes.attribute_hash */
|
||||
public function templateHashes(): array
|
||||
{
|
||||
$hashes = [];
|
||||
foreach ($this->query("SELECT `template`, `attributes` FROM `templates` WHERE `type` = 'mailbox'")->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$hashes[(string)$row['template']] = sha1(json_encode($row['attributes']));
|
||||
}
|
||||
return $hashes;
|
||||
}
|
||||
|
||||
/** @return ?array{provider: string, external_id: string, idp_disabled: int, missing_since: ?string} */
|
||||
public function findLink(string $username): ?array
|
||||
{
|
||||
$row = $this->query("SELECT `provider`, `external_id`, `idp_disabled`, `missing_since` FROM `identity_link` WHERE `username` = :username",
|
||||
['username' => $username])->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/** Mailbox linked to the IdP user; links of mailboxes that no longer use the provider do not count */
|
||||
public function usernameOf(string $provider, string $externalId): ?string
|
||||
{
|
||||
$username = $this->query("SELECT l.`username` FROM `identity_link` l
|
||||
JOIN `mailbox` m ON m.`username` = l.`username` AND m.`authsource` = l.`provider`
|
||||
WHERE l.`provider` = :provider AND l.`external_id` = :id", ['provider' => $provider, 'id' => $externalId])->fetchColumn();
|
||||
return ($username === false) ? null : (string)$username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Links the mailbox to the IdP user; an older link of the mailbox is replaced. Callers make sure the IdP user
|
||||
* is not linked to another mailbox of the provider (usernameOf()); stale links of the id are dropped.
|
||||
*/
|
||||
public function link(string $username, string $provider, string $externalId): void
|
||||
{
|
||||
$this->transaction(function () use ($username, $provider, $externalId): void {
|
||||
$this->query("DELETE FROM `identity_link` WHERE `provider` = :provider AND `external_id` = :id AND `username` != :username",
|
||||
['provider' => $provider, 'id' => $externalId, 'username' => $username]);
|
||||
$this->query("INSERT INTO `identity_link` (`username`, `provider`, `external_id`) VALUES (:username, :provider, :id)
|
||||
ON DUPLICATE KEY UPDATE `provider` = VALUES(`provider`), `external_id` = VALUES(`external_id`), `missing_since` = NULL",
|
||||
['username' => $username, 'provider' => $provider, 'id' => $externalId]);
|
||||
});
|
||||
}
|
||||
|
||||
public function unlink(string $username): void
|
||||
{
|
||||
$this->query("DELETE FROM `identity_link` WHERE `username` = :username", ['username' => $username]);
|
||||
}
|
||||
|
||||
/** Marks the IdP mailbox as not listed (now) or listed again; unlinked mailboxes get a link row without id */
|
||||
public function setMissing(string $username, string $provider, bool $missing): void
|
||||
{
|
||||
$this->query("INSERT INTO `identity_link` (`username`, `provider`, `missing_since`) VALUES (:username, :provider, "
|
||||
. ($missing ? "NOW()" : "NULL") . ") ON DUPLICATE KEY UPDATE `missing_since` = VALUES(`missing_since`)",
|
||||
['username' => $username, 'provider' => $provider]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets mailbox.active like the mailbox edit does (incl. the mailbox's own alias row) and records whether the
|
||||
* IdP sync caused it.
|
||||
*/
|
||||
public function setActive(string $username, string $provider, int $active, bool $idpDisabled): void
|
||||
{
|
||||
$this->transaction(function () use ($username, $provider, $active, $idpDisabled): void {
|
||||
$this->query("UPDATE `mailbox` SET `active` = :active WHERE `username` = :username", ['active' => $active, 'username' => $username]);
|
||||
$this->query("UPDATE `alias` SET `active` = :active WHERE `address` = :username", ['active' => $active, 'username' => $username]);
|
||||
$this->setIdpDisabled($username, $provider, $idpDisabled);
|
||||
});
|
||||
}
|
||||
|
||||
public function setIdpDisabled(string $username, string $provider, bool $idpDisabled): void
|
||||
{
|
||||
$this->query("INSERT INTO `identity_link` (`username`, `provider`, `idp_disabled`) VALUES (:username, :provider, :flag)
|
||||
ON DUPLICATE KEY UPDATE `idp_disabled` = VALUES(`idp_disabled`)",
|
||||
['username' => $username, 'provider' => $provider, 'flag' => $idpDisabled ? 1 : 0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Identity\Repository;
|
||||
|
||||
use Mailcow\Core\Repository;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Sync runs (table `identity_sync_run`): requests from the UI (status pending), the run itself and its summary.
|
||||
*/
|
||||
class RunRepository extends Repository
|
||||
{
|
||||
private const KEEP = 30;
|
||||
|
||||
/** Queues a run for the cron; an identical pending request is reused */
|
||||
public function request(string $provider, string $mode, string $requestedBy): int
|
||||
{
|
||||
$id = $this->query("SELECT `id` FROM `identity_sync_run` WHERE `status` = 'pending' AND `mode` = :mode",
|
||||
['mode' => $mode])->fetchColumn();
|
||||
if ($id !== false) {
|
||||
return (int)$id;
|
||||
}
|
||||
return $this->insertRow('identity_sync_run', ['provider' => $provider, 'mode' => $mode, 'status' => 'pending',
|
||||
'requested_by' => $requestedBy]);
|
||||
}
|
||||
|
||||
/** @return ?array oldest pending request */
|
||||
public function nextPending(): ?array
|
||||
{
|
||||
$row = $this->query("SELECT * FROM `identity_sync_run` WHERE `status` = 'pending' ORDER BY `id` LIMIT 1")->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/** A scheduled run (not requested by anyone) */
|
||||
public function create(string $provider, string $mode): int
|
||||
{
|
||||
return $this->insertRow('identity_sync_run', ['provider' => $provider, 'mode' => $mode, 'status' => 'pending', 'requested_by' => '']);
|
||||
}
|
||||
|
||||
public function start(int $id): void
|
||||
{
|
||||
$this->query("UPDATE `identity_sync_run` SET `status` = 'running', `started` = NOW() WHERE `id` = :id", ['id' => $id]);
|
||||
}
|
||||
|
||||
public function finish(int $id, string $status, array $summary): void
|
||||
{
|
||||
$this->query("UPDATE `identity_sync_run` SET `status` = :status, `finished` = NOW(), `summary` = :summary WHERE `id` = :id",
|
||||
['status' => $status, 'summary' => json_encode($summary), 'id' => $id]);
|
||||
$this->query("DELETE FROM `identity_sync_run` WHERE `id` NOT IN (SELECT `id` FROM (SELECT `id` FROM `identity_sync_run`
|
||||
ORDER BY `id` DESC LIMIT " . self::KEEP . ") AS `keep`)");
|
||||
}
|
||||
|
||||
/** Seconds since the last scheduled run started, null if there was none */
|
||||
public function secondsSinceScheduled(): ?int
|
||||
{
|
||||
$seconds = $this->query("SELECT TIMESTAMPDIFF(SECOND, MAX(`started`), NOW()) FROM `identity_sync_run`
|
||||
WHERE `requested_by` = '' AND `mode` = 'run'")->fetchColumn();
|
||||
return ($seconds === false || $seconds === null) ? null : (int)$seconds;
|
||||
}
|
||||
|
||||
/** Runs left in status running by a crashed cron are closed, so they do not hide new runs */
|
||||
public function failStale(int $minutes): void
|
||||
{
|
||||
$this->query("UPDATE `identity_sync_run` SET `status` = 'error', `finished` = NOW(),
|
||||
`summary` = JSON_OBJECT('error', 'The sync run did not finish')
|
||||
WHERE `status` = 'running' AND `started` < NOW() - INTERVAL :minutes MINUTE", ['minutes' => $minutes]);
|
||||
}
|
||||
|
||||
/** @return array[] newest first, summary decoded */
|
||||
public function recent(int $limit = 10): array
|
||||
{
|
||||
$rows = $this->query("SELECT * FROM `identity_sync_run` ORDER BY `id` DESC LIMIT " . max(1, $limit))->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as &$row) {
|
||||
$row['id'] = (int)$row['id'];
|
||||
$row['summary'] = ($row['summary'] === null) ? null : json_decode((string)$row['summary'], true);
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Identity\Service;
|
||||
|
||||
use Mailcow\Identity\Repository\IdentityRepository;
|
||||
|
||||
/**
|
||||
* Links of mailboxes to IdP users, checked at IdP logins (REST, LDAP bind, OIDC). Mailboxes are found by email,
|
||||
* so the IdP user id has to match the link: an address taken over by another IdP user must not open the old
|
||||
* mailbox. Unlinked mailboxes are linked on their first login.
|
||||
*/
|
||||
class LinkService
|
||||
{
|
||||
public function __construct(private IdentityRepository $repo)
|
||||
{
|
||||
}
|
||||
|
||||
/** The IdP user may log in to the mailbox; links the mailbox when it has no link yet */
|
||||
public function verify(string $provider, string $username, string $externalId): bool
|
||||
{
|
||||
if ($externalId === '') {
|
||||
return false;
|
||||
}
|
||||
$link = $this->repo->findLink($username);
|
||||
if ($link !== null && $link['provider'] === $provider && $link['external_id'] !== null) {
|
||||
return hash_equals((string)$link['external_id'], $externalId);
|
||||
}
|
||||
if (!$this->mayCreate($provider, $username, $externalId)) {
|
||||
return false;
|
||||
}
|
||||
$this->repo->link($username, $provider, $externalId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The IdP user owns no other mailbox (checked before a mailbox is created on login) */
|
||||
public function mayCreate(string $provider, string $username, string $externalId): bool
|
||||
{
|
||||
$owner = ($externalId === '') ? null : $this->repo->usernameOf($provider, $externalId);
|
||||
return $externalId !== '' && ($owner === null || $owner === $username);
|
||||
}
|
||||
|
||||
/** Links a mailbox created on login */
|
||||
public function link(string $provider, string $username, string $externalId): void
|
||||
{
|
||||
if ($externalId !== '') {
|
||||
$this->repo->link($username, $provider, $externalId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Identity\Service;
|
||||
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\Input;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Identity\Model\Settings;
|
||||
use Mailcow\Identity\Repository\IdentityRepository;
|
||||
use Mailcow\Identity\Repository\RunRepository;
|
||||
|
||||
/**
|
||||
* Admin use cases of the sync: status, run requests (executed by the cron) and links of mailboxes.
|
||||
* Admins only, read-only API keys may read. Domain admins may read the link of mailboxes they manage.
|
||||
*/
|
||||
class StatusService
|
||||
{
|
||||
public const MODES = ['preview', 'run', 'force'];
|
||||
|
||||
public function __construct(private Settings $settings, private IdentityRepository $repo, private RunRepository $runs,
|
||||
private Context $context)
|
||||
{
|
||||
}
|
||||
|
||||
/** @throws AccessDenied */
|
||||
public function status(): array
|
||||
{
|
||||
$this->context->requireAdmin(false);
|
||||
return [
|
||||
'provider' => $this->settings->provider,
|
||||
'can_sync' => $this->settings->canSync(),
|
||||
'sync_enabled' => $this->settings->syncEnabled(),
|
||||
'counts' => $this->repo->stats($this->settings->provider),
|
||||
'runs' => $this->runs->recent(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a run for the cron: preview (dry run), run, or force (run without the change limit).
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
public function requestRun(mixed $mode): int
|
||||
{
|
||||
$this->context->requireAdmin();
|
||||
$mode = Input::text($mode);
|
||||
if (!in_array($mode, self::MODES, true)) {
|
||||
throw new UserError('iam_sync_mode_invalid');
|
||||
}
|
||||
if (!$this->settings->canSync()) {
|
||||
throw new UserError('iam_sync_unavailable');
|
||||
}
|
||||
return $this->runs->request($this->settings->provider, $mode, (string)$this->context->username);
|
||||
}
|
||||
|
||||
/** Link of a mailbox for the mailbox edit page, null if none or not allowed */
|
||||
public function link(string $username): ?array
|
||||
{
|
||||
$username = strtolower(trim($username));
|
||||
if (!($this->context->hasAdminRights() || ($this->context->isDomainAdmin() && $this->context->canAccessMailbox($username)))) {
|
||||
return null;
|
||||
}
|
||||
return $this->repo->findLink($username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the link, the mailbox is linked again by email (next run or login). Resolves conflicts, e.g. when an
|
||||
* address was given to another IdP user.
|
||||
*
|
||||
* @throws AccessDenied
|
||||
*/
|
||||
public function unlink(string $username): void
|
||||
{
|
||||
$this->context->requireAdmin();
|
||||
$this->repo->unlink(strtolower(trim($username)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Identity\Service;
|
||||
|
||||
use Mailcow\Identity\Model\Account;
|
||||
use Mailcow\Identity\Model\DirectoryGroup;
|
||||
use Mailcow\Identity\Model\DirectoryUser;
|
||||
use Mailcow\Identity\Model\Listing;
|
||||
use Mailcow\Identity\Model\Settings;
|
||||
use Mailcow\Identity\Model\State;
|
||||
use Mailcow\Identity\Model\SyncPlan;
|
||||
|
||||
/**
|
||||
* Decides what a sync run changes, without side effects.
|
||||
*
|
||||
* Users: linked mailboxes are found by IdP user id, unlinked ones are adopted by email. A mailbox linked to
|
||||
* another IdP user, or an IdP user whose email moved away from its mailbox, is a conflict; nothing is
|
||||
* relinked or created for it.
|
||||
* Offboarding: a user disabled in the IdP, or not listed by two consecutive complete runs, gets login disabled
|
||||
* (active = 2), once per IdP disable. Only those mailboxes are activated again when the IdP enables
|
||||
* the user; manual changes of active always win.
|
||||
* Groups: IdP groups map to mailbox groups by idp_ref; unknown ones are created in the domain of their mail,
|
||||
* else in the default group domain. Linked users that are not listed keep their IdP memberships.
|
||||
* Guard: more deactivations or membership removals than max(5, limit % of active IdP mailboxes resp. IdP
|
||||
* memberships) block the run unless forced.
|
||||
*/
|
||||
class SyncPlanner
|
||||
{
|
||||
private const GUARD_MIN = 5;
|
||||
|
||||
public function plan(Settings $settings, Listing $listing, State $state, bool $force = false): SyncPlan
|
||||
{
|
||||
$plan = new SyncPlan();
|
||||
$owners = [];
|
||||
foreach ($state->accounts as $username => $account) {
|
||||
if ($account->externalId !== null) {
|
||||
$owners[$account->externalId] = (string)$username;
|
||||
}
|
||||
}
|
||||
/** @var array<string, DirectoryUser> $matched username => listed user */
|
||||
$matched = [];
|
||||
/** @var array<string, DirectoryUser> $candidates email => user to create, template still open */
|
||||
$candidates = [];
|
||||
$duplicates = [];
|
||||
foreach ($listing->users as $user) {
|
||||
if (isset($owners[$user->id])) {
|
||||
$username = $owners[$user->id];
|
||||
if ($user->email !== null && $user->email !== $username) {
|
||||
self::conflict($plan, $username . ' is linked to an IdP user whose email is now ' . $user->email);
|
||||
}
|
||||
$matched[$username] = $user;
|
||||
} elseif ($user->email === null) {
|
||||
$plan->skipped++;
|
||||
} elseif (isset($state->accounts[$user->email])) {
|
||||
$account = $state->accounts[$user->email];
|
||||
if ($account->externalId !== null || isset($plan->links[$user->email])) {
|
||||
self::conflict($plan, $user->email . ' is linked to another IdP user');
|
||||
continue;
|
||||
}
|
||||
$plan->links[$user->email] = $user->id;
|
||||
$matched[$user->email] = $user;
|
||||
} elseif (isset($state->mailboxes[$user->email]) || !$settings->importUsers || !$user->enabled
|
||||
|| !in_array(substr((string)strrchr($user->email, '@'), 1), $state->domains, true)) {
|
||||
// other authsource, import off, disabled or no mailcow domain
|
||||
$plan->skipped++;
|
||||
} elseif (isset($candidates[$user->email]) || isset($duplicates[$user->email])) {
|
||||
self::conflict($plan, 'Several IdP users have the email ' . $user->email);
|
||||
unset($candidates[$user->email]);
|
||||
$duplicates[$user->email] = true;
|
||||
} else {
|
||||
$candidates[$user->email] = $user;
|
||||
}
|
||||
}
|
||||
$groups = ($settings->groupSync && $listing->groups !== null) ? $listing->groups : null;
|
||||
$this->planCreates($settings, $candidates, $groups ?? [], $state, $plan);
|
||||
$usernames = [];
|
||||
foreach ($matched as $username => $user) {
|
||||
$usernames[$user->id] = (string)$username;
|
||||
}
|
||||
foreach ($plan->creates as $create) {
|
||||
$usernames[$create['id']] = $create['email'];
|
||||
}
|
||||
if ($groups !== null) {
|
||||
$this->planGroups($settings, $groups, $state, $usernames, $matched, $plan);
|
||||
}
|
||||
$this->planStates($settings, $state, $matched, $plan);
|
||||
if ($settings->periodicSync) {
|
||||
$this->planUpdates($settings, $state, $matched, $plan);
|
||||
}
|
||||
$active = count(array_filter($state->accounts, fn(Account $a): bool => $a->active === 1));
|
||||
$memberships = array_sum(array_map(fn(array $g): int => count(self::idpMembers($g)), $state->groups));
|
||||
if (!$force && (count($plan->deactivate) > self::limit($active, $settings->offboardingLimit)
|
||||
|| $plan->membershipRemovals > self::limit($memberships, $settings->offboardingLimit))) {
|
||||
$plan->blocked = true;
|
||||
}
|
||||
return $plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, DirectoryGroup> $groups
|
||||
* @param array<string, string> $usernames user id => username of matched and new users
|
||||
*/
|
||||
private function planGroups(Settings $settings, array $groups, State $state, array $usernames, array $matched, SyncPlan $plan): void
|
||||
{
|
||||
$linked = [];
|
||||
$names = [];
|
||||
foreach ($state->groups as $group) {
|
||||
if ($group['idp_ref'] !== null) {
|
||||
$linked[(string)$group['idp_ref']] = $group;
|
||||
}
|
||||
$names[$group['domain']][mb_strtolower($group['name'])] = (int)$group['id'];
|
||||
}
|
||||
foreach ($groups as $ref => $directoryGroup) {
|
||||
$ref = (string)$ref;
|
||||
$members = [];
|
||||
foreach ($directoryGroup->memberIds as $id) {
|
||||
if (isset($usernames[$id])) {
|
||||
$members[] = $usernames[$id];
|
||||
}
|
||||
}
|
||||
$current = $linked[$ref] ?? null;
|
||||
if ($current === null) {
|
||||
if (!$settings->groupAutocreate) {
|
||||
continue;
|
||||
}
|
||||
$domain = self::groupDomain($settings, $directoryGroup, $state->domains);
|
||||
if ($domain === null) {
|
||||
self::conflict($plan, 'IdP group ' . $directoryGroup->name . ' has no mailcow domain');
|
||||
continue;
|
||||
}
|
||||
if (isset($names[$domain][mb_strtolower($directoryGroup->name)])) {
|
||||
self::conflict($plan, 'A group ' . $directoryGroup->name . ' exists in ' . $domain . ' and is not linked to the IdP group');
|
||||
continue;
|
||||
}
|
||||
$names[$domain][mb_strtolower($directoryGroup->name)] = 0;
|
||||
$plan->groupCreates[] = ['ref' => $ref, 'name' => $directoryGroup->name, 'domain' => $domain, 'mail' => $directoryGroup->mail];
|
||||
$plan->memberships[$ref] = self::sorted($members);
|
||||
continue;
|
||||
}
|
||||
$idpMembers = self::idpMembers($current);
|
||||
// Linked users missing in the listing keep their memberships until they are listed again
|
||||
foreach ($idpMembers as $username) {
|
||||
if (isset($state->accounts[$username]) && !isset($matched[$username])) {
|
||||
$members[] = $username;
|
||||
}
|
||||
}
|
||||
$members = self::sorted($members);
|
||||
$removed = array_diff($idpMembers, $members);
|
||||
if (!empty($removed) || !empty(array_diff($members, array_map('strval', array_keys($current['members']))))) {
|
||||
$plan->memberships[$ref] = $members;
|
||||
$plan->membershipRemovals += count($removed);
|
||||
}
|
||||
if ($directoryGroup->name !== $current['name']) {
|
||||
$taken = $names[$current['domain']][mb_strtolower($directoryGroup->name)] ?? null;
|
||||
if ($taken !== null && $taken !== (int)$current['id']) {
|
||||
self::conflict($plan, 'IdP group ' . $current['name'] . ' was renamed to ' . $directoryGroup->name . ', which exists in ' . $current['domain']);
|
||||
} else {
|
||||
$plan->groupRenames[(int)$current['id']] = $directoryGroup->name;
|
||||
$names[$current['domain']][mb_strtolower($directoryGroup->name)] = (int)$current['id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($linked as $ref => $group) {
|
||||
if (!isset($groups[$ref])) {
|
||||
$plan->orphanedGroups[] = (int)$group['id'];
|
||||
$plan->membershipRemovals += count(self::idpMembers($group));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Template of a new mailbox: the template of its highest-priority linked group (as in TemplateService), else
|
||||
* the attribute mapping, else the default template.
|
||||
*
|
||||
* @param array<string, DirectoryUser> $candidates
|
||||
* @param array<string, DirectoryGroup> $groups listed groups, [] without group sync
|
||||
*/
|
||||
private function planCreates(Settings $settings, array $candidates, array $groups, State $state, SyncPlan $plan): void
|
||||
{
|
||||
$linked = [];
|
||||
foreach ($state->groups as $group) {
|
||||
if ($group['idp_ref'] !== null && $group['template'] !== null && $group['active'] === 1) {
|
||||
$linked[(string)$group['idp_ref']] = $group;
|
||||
}
|
||||
}
|
||||
$groupsOf = [];
|
||||
foreach ($groups as $ref => $directoryGroup) {
|
||||
if (isset($linked[(string)$ref])) {
|
||||
foreach ($directoryGroup->memberIds as $id) {
|
||||
$groupsOf[$id][] = $linked[(string)$ref];
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($candidates as $email => $user) {
|
||||
$best = null;
|
||||
foreach ($groupsOf[$user->id] ?? [] as $group) {
|
||||
if ($best === null || $group['priority'] > $best['priority'] || ($group['priority'] === $best['priority'] && $group['id'] < $best['id'])) {
|
||||
$best = $group;
|
||||
}
|
||||
}
|
||||
$template = $best['template'] ?? $settings->mappedTemplate($user->templateValues)
|
||||
?? (($settings->defaultTemplate !== '') ? $settings->defaultTemplate : null);
|
||||
if ($template === null) {
|
||||
self::conflict($plan, 'No template matches ' . $email);
|
||||
continue;
|
||||
}
|
||||
$plan->creates[] = ['email' => (string)$email, 'name' => self::name($user->name, ''), 'template' => $template, 'id' => $user->id];
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, DirectoryUser> $matched */
|
||||
private function planStates(Settings $settings, State $state, array $matched, SyncPlan $plan): void
|
||||
{
|
||||
foreach ($state->accounts as $username => $account) {
|
||||
$username = (string)$username;
|
||||
$user = $matched[$username] ?? null;
|
||||
if ($user === null && $account->missingSince === null) {
|
||||
$plan->missing[] = $username;
|
||||
continue;
|
||||
}
|
||||
if ($user !== null && $account->missingSince !== null) {
|
||||
$plan->found[] = $username;
|
||||
}
|
||||
if (!$settings->offboarding) {
|
||||
continue;
|
||||
}
|
||||
// Second complete run without the user counts as removed from the IdP
|
||||
$disabled = ($user === null) || !$user->enabled;
|
||||
if ($disabled && !$account->idpDisabled && $account->active === 1) {
|
||||
$plan->deactivate[] = $username;
|
||||
} elseif (!$disabled && $account->idpDisabled) {
|
||||
if ($account->active === 2) {
|
||||
$plan->restore[] = $username;
|
||||
} else {
|
||||
$plan->release[] = $username;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Name and template of linked mailboxes, like the IdP login does. The planned template is the legacy mapping;
|
||||
* SyncService applies the effective template again after the group changes of the run.
|
||||
*
|
||||
* @param array<string, DirectoryUser> $matched
|
||||
*/
|
||||
private function planUpdates(Settings $settings, State $state, array $matched, SyncPlan $plan): void
|
||||
{
|
||||
foreach ($matched as $username => $user) {
|
||||
$account = $state->accounts[$username];
|
||||
$legacy = $settings->mappedTemplate($user->templateValues);
|
||||
$plan->profiles[$username] = ['name' => self::name($user->name, $account->name), 'template' => $legacy];
|
||||
$template = $state->effectiveTemplates[$username] ?? $legacy;
|
||||
if ($template === null) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($state->templateHashes[$template])) {
|
||||
self::conflict($plan, 'Template ' . $template . ' does not exist');
|
||||
continue;
|
||||
}
|
||||
$name = self::name($user->name, $account->name);
|
||||
if ($name !== $account->name || $state->templateHashes[$template] !== $account->attributeHash) {
|
||||
$plan->updates[] = ['username' => (string)$username, 'name' => $name, 'template' => $legacy];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Domain for a new group: the domain of its mail if it is a mailcow domain, else the default group domain */
|
||||
private static function groupDomain(Settings $settings, DirectoryGroup $group, array $domains): ?string
|
||||
{
|
||||
$domain = ($group->mail !== null) ? substr((string)strrchr($group->mail, '@'), 1) : '';
|
||||
if (in_array($domain, $domains, true)) {
|
||||
return $domain;
|
||||
}
|
||||
return in_array($settings->groupDefaultDomain, $domains, true) ? $settings->groupDefaultDomain : null;
|
||||
}
|
||||
|
||||
/** Mailbox name as mailbox_from_template stores it; an empty IdP name keeps the current one */
|
||||
private static function name(string $name, string $current): string
|
||||
{
|
||||
$name = ltrim(rtrim(trim($name), '>'), '<');
|
||||
return ($name === '') ? $current : $name;
|
||||
}
|
||||
|
||||
private static function limit(int $base, int $percent): int
|
||||
{
|
||||
return max(self::GUARD_MIN, (int)ceil($base * $percent / 100));
|
||||
}
|
||||
|
||||
/** @return string[] members of a state group with source idp */
|
||||
private static function idpMembers(array $group): array
|
||||
{
|
||||
return array_map('strval', array_keys(array_filter($group['members'], fn(string $source): bool => $source === 'idp')));
|
||||
}
|
||||
|
||||
private static function sorted(array $usernames): array
|
||||
{
|
||||
$usernames = array_values(array_unique(array_map('strval', $usernames)));
|
||||
sort($usernames);
|
||||
return $usernames;
|
||||
}
|
||||
|
||||
private static function conflict(SyncPlan $plan, string $message): void
|
||||
{
|
||||
if (!in_array($message, $plan->conflicts, true)) {
|
||||
$plan->conflicts[] = $message;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Identity\Service;
|
||||
|
||||
use Closure;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Identity\Directory\Directory;
|
||||
use Mailcow\Identity\Directory\DirectoryException;
|
||||
use Mailcow\Identity\Legacy\LegacyAccounts;
|
||||
use Mailcow\Identity\Model\Settings;
|
||||
use Mailcow\Identity\Model\State;
|
||||
use Mailcow\Identity\Model\SyncPlan;
|
||||
use Mailcow\Identity\Repository\IdentityRepository;
|
||||
use Mailcow\Identity\Repository\RunRepository;
|
||||
use Mailcow\MailboxGroup\Service\IdpSyncService;
|
||||
use Mailcow\MailboxGroup\Service\TemplateService;
|
||||
use Mailcow\Sogo\Service\SogoService;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Sync runs of the IdP (cron only): list the directory, plan, apply. A listing error aborts the run before
|
||||
* anything is written; a blocked or preview run writes nothing but the run summary.
|
||||
* The directory is created only when a run is due, so idle cron calls never connect to the IdP.
|
||||
*/
|
||||
class SyncService
|
||||
{
|
||||
/** A run still `running` after this many minutes crashed */
|
||||
private const STALE_MINUTES = 60;
|
||||
/** Errors kept in the run summary */
|
||||
private const DETAILS = 50;
|
||||
|
||||
public function __construct(
|
||||
private Settings $settings,
|
||||
/** @var Closure(): ?Directory */
|
||||
private Closure $directory,
|
||||
private IdentityRepository $repo,
|
||||
private RunRepository $runs,
|
||||
private SyncPlanner $planner,
|
||||
private IdpSyncService $groups,
|
||||
private TemplateService $templates,
|
||||
private LegacyAccounts $legacy,
|
||||
private SogoService $sogo
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the oldest requested run, else a scheduled run when the sync interval is over.
|
||||
*
|
||||
* @return ?array the finished run (id, mode, status, summary), null if nothing was due
|
||||
*/
|
||||
public function runDue(): ?array
|
||||
{
|
||||
$this->runs->failStale(self::STALE_MINUTES);
|
||||
$run = $this->runs->nextPending();
|
||||
if ($run === null) {
|
||||
if (!$this->settings->syncEnabled()) {
|
||||
return null;
|
||||
}
|
||||
$since = $this->runs->secondsSinceScheduled();
|
||||
if ($since !== null && $since < $this->settings->syncInterval * 60) {
|
||||
return null;
|
||||
}
|
||||
$run = ['id' => $this->runs->create($this->settings->provider, 'run'), 'mode' => 'run'];
|
||||
}
|
||||
return $this->execute((int)$run['id'], (string)$run['mode']);
|
||||
}
|
||||
|
||||
/** @return array{id: int, mode: string, status: string, summary: array} */
|
||||
public function execute(int $id, string $mode): array
|
||||
{
|
||||
$this->runs->start($id);
|
||||
try {
|
||||
$directory = $this->settings->canSync() ? ($this->directory)() : null;
|
||||
if ($directory === null) {
|
||||
throw new DirectoryException('The identity provider cannot be synced');
|
||||
}
|
||||
$listing = $directory->listAll($this->settings->groupSync);
|
||||
$plan = $this->planner->plan($this->settings, $listing, $this->state(), $mode === 'force');
|
||||
$summary = $plan->summary();
|
||||
if ($mode === 'preview') {
|
||||
$status = 'ok';
|
||||
} elseif ($plan->blocked) {
|
||||
$status = 'blocked';
|
||||
} else {
|
||||
[$errors, $updated] = $this->apply($plan);
|
||||
$summary['counts']['updates'] = $updated;
|
||||
$summary['counts']['errors'] = count($errors);
|
||||
$summary['errors'] = array_slice($errors, 0, self::DETAILS);
|
||||
$status = 'ok';
|
||||
}
|
||||
} catch (DirectoryException $e) {
|
||||
$status = 'error';
|
||||
$summary = ['error' => $e->getMessage()];
|
||||
} catch (Throwable $e) {
|
||||
$this->runs->finish($id, 'error', ['error' => 'The sync run failed: ' . $e->getMessage(), 'mode' => $mode]);
|
||||
throw $e;
|
||||
}
|
||||
$summary['mode'] = $mode;
|
||||
$this->runs->finish($id, $status, $summary);
|
||||
return ['id' => $id, 'mode' => $mode, 'status' => $status, 'summary' => $summary];
|
||||
}
|
||||
|
||||
private function state(): State
|
||||
{
|
||||
$accounts = $this->repo->accounts($this->settings->provider);
|
||||
return new State($accounts, $this->repo->mailboxes(), $this->repo->domains(),
|
||||
$this->settings->groupSync ? $this->groups->groups() : [],
|
||||
$this->settings->periodicSync ? $this->templates->effective(array_keys($accounts)) : [],
|
||||
$this->repo->templateHashes());
|
||||
}
|
||||
|
||||
/** @return array{0: string[], 1: int} errors of single changes (the rest is still applied), updated mailboxes */
|
||||
private function apply(SyncPlan $plan): array
|
||||
{
|
||||
$provider = $this->settings->provider;
|
||||
$errors = [];
|
||||
foreach ($plan->links as $username => $externalId) {
|
||||
$this->repo->link((string)$username, $provider, $externalId);
|
||||
}
|
||||
foreach ($plan->found as $username) {
|
||||
$this->repo->setMissing($username, $provider, false);
|
||||
}
|
||||
foreach ($plan->missing as $username) {
|
||||
$this->repo->setMissing($username, $provider, true);
|
||||
}
|
||||
foreach ($plan->creates as $create) {
|
||||
$error = $this->legacy->createMailbox($create['email'], $create['name'], $provider, $create['template']);
|
||||
if ($error !== null) {
|
||||
$errors[] = $create['email'] . ': ' . $error;
|
||||
continue;
|
||||
}
|
||||
$this->repo->link($create['email'], $provider, $create['id']);
|
||||
}
|
||||
foreach ($plan->deactivate as $username) {
|
||||
$this->repo->setActive($username, $provider, 2, true);
|
||||
}
|
||||
foreach ($plan->restore as $username) {
|
||||
$this->repo->setActive($username, $provider, 1, false);
|
||||
}
|
||||
foreach ($plan->release as $username) {
|
||||
$this->repo->setIdpDisabled($username, $provider, false);
|
||||
}
|
||||
$this->sogo->refreshStaticView(array_merge($plan->deactivate, $plan->restore));
|
||||
$changed = [];
|
||||
$errors = array_merge($errors, $this->applyGroups($plan, $changed));
|
||||
[$updateErrors, $updated] = $this->applyUpdates($plan, $changed);
|
||||
return [array_merge($errors, $updateErrors), $updated];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $changed collects the members that joined or left a group
|
||||
* @return string[]
|
||||
*/
|
||||
private function applyGroups(SyncPlan $plan, array &$changed): array
|
||||
{
|
||||
$errors = [];
|
||||
foreach ($plan->groupCreates as $create) {
|
||||
try {
|
||||
foreach ($this->groups->createFromIdp($create['ref'], $create['name'], $create['domain'], $create['mail']) as $warning) {
|
||||
$errors[] = $create['name'] . ': ' . $warning[0];
|
||||
}
|
||||
} catch (UserError $e) {
|
||||
$errors[] = $create['name'] . ': ' . $e->key;
|
||||
}
|
||||
}
|
||||
foreach ($plan->groupRenames as $id => $name) {
|
||||
try {
|
||||
$this->groups->renameFromIdp((int)$id, $name);
|
||||
} catch (UserError $e) {
|
||||
$errors[] = $name . ': ' . $e->key;
|
||||
}
|
||||
}
|
||||
foreach ($plan->memberships as $ref => $usernames) {
|
||||
$group = $this->groups->findByIdpRef((string)$ref);
|
||||
if ($group !== null) {
|
||||
$this->groupResult($group->name, $this->groups->syncGroupMembers((int)$group->id, $usernames), $errors, $changed);
|
||||
}
|
||||
}
|
||||
foreach ($plan->orphanedGroups as $id) {
|
||||
$this->groupResult((string)$id, $this->groups->syncGroupMembers($id, []), $errors, $changed);
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Name and template updates after the group changes: planned updates and members that joined or left a group
|
||||
* in this run (e.g. the legacy mapping applies again after leaving a template group). The effective template
|
||||
* is resolved and compared again.
|
||||
*
|
||||
* @return array{0: string[], 1: int} errors, updated mailboxes
|
||||
*/
|
||||
private function applyUpdates(SyncPlan $plan, array $changed): array
|
||||
{
|
||||
$candidates = array_values(array_unique(array_merge(array_column($plan->updates, 'username'),
|
||||
array_intersect($changed, array_keys($plan->profiles)))));
|
||||
if (empty($candidates)) {
|
||||
return [[], 0];
|
||||
}
|
||||
$errors = [];
|
||||
$updated = 0;
|
||||
$accounts = $this->repo->accounts($this->settings->provider);
|
||||
$hashes = $this->repo->templateHashes();
|
||||
$effective = $this->templates->effective($candidates);
|
||||
foreach ($candidates as $username) {
|
||||
$profile = $plan->profiles[$username] ?? null;
|
||||
$account = $accounts[$username] ?? null;
|
||||
$template = $effective[$username] ?? $profile['template'] ?? null;
|
||||
if ($profile === null || $account === null || $template === null
|
||||
|| ($profile['name'] === $account->name && ($hashes[$template] ?? null) === $account->attributeHash)) {
|
||||
continue;
|
||||
}
|
||||
$error = $this->legacy->applyTemplate($username, $profile['name'], $template);
|
||||
if ($error !== null) {
|
||||
$errors[] = $username . ': ' . $error;
|
||||
} else {
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
return [$errors, $updated];
|
||||
}
|
||||
|
||||
private function groupResult(string $group, array $result, array &$errors, array &$changed): void
|
||||
{
|
||||
foreach ($result['warnings'] as $warning) {
|
||||
$errors[] = $group . ': ' . $warning[0];
|
||||
}
|
||||
array_push($changed, ...$result['added'], ...$result['removed']);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user