diff --git a/data/conf/nginx/templates/sites-default.conf.j2 b/data/conf/nginx/templates/sites-default.conf.j2 index 84bd8eae4d..5717ff2c0d 100644 --- a/data/conf/nginx/templates/sites-default.conf.j2 +++ b/data/conf/nginx/templates/sites-default.conf.j2 @@ -104,6 +104,10 @@ location ~ ^/api/v1/(.*)$ { try_files $uri $uri/ /json_api.php?query=$1&$args; } +location ~ ^/scim/v2/(.*)$ { + try_files $uri $uri/ /scim.php?path=$1&$args; +} + location ~ ^/cache/(.*)$ { try_files $uri $uri/ /resource.php?file=$1; } diff --git a/data/web/admin/system.php b/data/web/admin/system.php index 4db40c753e..940f04d76c 100644 --- a/data/web/admin/system.php +++ b/data/web/admin/system.php @@ -86,6 +86,11 @@ $f2b_data = fail2ban('get'); // mbox templates $mbox_templates = mailbox('get', 'mailbox_templates'); +// SCIM +$scim_tokens = scim_token('get_all'); +$scim_new_token = $_SESSION['scim_new_token'] ?? null; +$scim_base_url = getBaseUrl() . '/scim/v2/'; +unset($_SESSION['scim_new_token']); $template = 'admin.twig'; $template_data = [ @@ -121,6 +126,9 @@ 'is_https' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on', 'iam_settings' => $iam_settings, 'mbox_templates' => $mbox_templates, + 'scim_tokens' => $scim_tokens, + 'scim_new_token' => $scim_new_token, + 'scim_base_url' => $scim_base_url, 'lang_admin' => json_encode($lang['admin']), 'lang_datatables' => json_encode($lang['datatables']) ]; diff --git a/data/web/edit.php b/data/web/edit.php index 48f2309c15..18850b0947 100644 --- a/data/web/edit.php +++ b/data/web/edit.php @@ -139,6 +139,7 @@ 'user_acls' => acl('get', 'user', $mailbox), 'mailbox_details' => $result, 'iam_settings' => $iam_settings, + 'scim_tokens' => scim_token('get_all'), ]; } } diff --git a/data/web/inc/functions.auth.inc.php b/data/web/inc/functions.auth.inc.php index e5a303f9b7..e5b287fff4 100644 --- a/data/web/inc/functions.auth.inc.php +++ b/data/web/inc/functions.auth.inc.php @@ -429,6 +429,63 @@ function user_login($user, $pass, $extra = null){ } } break; + case 'scim': + // SCIM is a provisioning protocol; authentication is handled by the configured IAM provider. + // If LDAP is the IAM, verify credentials against LDAP directly. + if ($iam_settings['authsource'] === 'ldap') { + $result = ldap_mbox_login($user, $pass, array('is_internal' => $is_internal)); + if ($result !== false) { + $stmt = $pdo->prepare("SELECT * FROM `mailbox` + INNER JOIN domain on mailbox.domain = domain.domain + WHERE `kind` NOT REGEXP 'location|thing|group' + AND `mailbox`.`active`='1' + AND `domain`.`active`='1' + AND `username` = :user"); + $stmt->execute(array(':user' => $user)); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (empty($row)) { + return false; + } + $row['attributes'] = json_decode($row['attributes'], true); + $authenticators = get_tfa($user); + if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0 && !$is_internal) { + $_SESSION['pending_mailcow_cc_username'] = $user; + $_SESSION['pending_mailcow_cc_role'] = "user"; + $_SESSION['pending_tfa_methods'] = $authenticators['additional']; + unset($_SESSION['ldelay']); + $_SESSION['return'][] = array( + 'type' => 'success', + 'log' => array(__FUNCTION__, $user, '*', 'Provider: LDAP (SCIM user)'), + 'msg' => array('logged_in_as', $user) + ); + return "pending"; + } else if (!isset($authenticators['additional']) || !is_array($authenticators['additional']) || count($authenticators['additional']) == 0) { + if (!$is_internal) { + unset($_SESSION['ldelay']); + $stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :user"); + $stmt->execute(array(':user' => $user)); + if (intval($row['attributes']['force_tfa']) == 1 && !tfa_exists($user)) { + $_SESSION['pending_tfa_setup'] = true; + } + $_SESSION['return'][] = array( + 'type' => 'success', + 'log' => array(__FUNCTION__, $user, '*', 'Provider: LDAP (SCIM user)'), + 'msg' => array('logged_in_as', $user) + ); + } + return "user"; + } + } + return $result; + } + // For OIDC-based providers (Keycloak, Generic-OIDC), login goes through verify-sso, not here. + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $user, 'SCIM account must authenticate via the configured identity provider'), + 'msg' => 'login_failed' + ); + return false; + break; } return false; diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 89f14b5749..ac798b4f48 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -2866,7 +2866,7 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { $stmt->execute(array(':user' => $info['email'])); $row = $stmt->fetch(PDO::FETCH_ASSOC); if ($row){ - if (!in_array($row['authsource'], array("keycloak", "generic-oidc"))) { + if (!in_array($row['authsource'], array("keycloak", "generic-oidc", "scim"))) { clear_session(); $_SESSION['return'][] = array( 'type' => 'danger', diff --git a/data/web/inc/functions.mailbox.inc.php b/data/web/inc/functions.mailbox.inc.php index adb330ea8f..d2e90e1634 100644 --- a/data/web/inc/functions.mailbox.inc.php +++ b/data/web/inc/functions.mailbox.inc.php @@ -1053,7 +1053,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { return false; } if ($_data['authsource'] == "mailcow" || - in_array($_data['authsource'], array('keycloak', 'generic-oidc', 'ldap')) && $iam_settings['authsource'] == $_data['authsource']){ + in_array($_data['authsource'], array('keycloak', 'generic-oidc', 'ldap')) && $iam_settings['authsource'] == $_data['authsource'] || + $_data['authsource'] == 'scim'){ $authsource = $_data['authsource']; } if (empty($name)) { @@ -1122,7 +1123,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { } $quota_b = ($quota_m * 1048576); $attribute_hash = (!empty($_data['attribute_hash'])) ? $_data['attribute_hash'] : ''; - if (in_array($authsource, array('keycloak', 'generic-oidc', 'ldap'))){ + if (in_array($authsource, array('keycloak', 'generic-oidc', 'ldap', 'scim'))){ $force_pw_update = 0; } if ($authsource == 'generic-oidc'){ @@ -3149,10 +3150,11 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $attribute_hash = (!empty($_data['attribute_hash'])) ? $_data['attribute_hash'] : ''; $authsource = $is_now['authsource']; if ($_data['authsource'] == "mailcow" || - in_array($_data['authsource'], array('keycloak', 'generic-oidc', 'ldap')) && $iam_settings['authsource'] == $_data['authsource']){ + in_array($_data['authsource'], array('keycloak', 'generic-oidc', 'ldap')) && $iam_settings['authsource'] == $_data['authsource'] || + $_data['authsource'] == 'scim'){ $authsource = $_data['authsource']; } - if (in_array($authsource, array('keycloak', 'generic-oidc', 'ldap'))){ + if (in_array($authsource, array('keycloak', 'generic-oidc', 'ldap', 'scim'))){ $force_pw_update = 0; } if ($authsource == 'generic-oidc'){ diff --git a/data/web/inc/functions.scim.inc.php b/data/web/inc/functions.scim.inc.php new file mode 100644 index 0000000000..dc879b0b97 --- /dev/null +++ b/data/web/inc/functions.scim.inc.php @@ -0,0 +1,765 @@ +lPush('SCIM_LOG', json_encode([ + 'time' => time(), + 'priority' => $priority, + 'task' => 'SCIM', + 'message' => $message, + ])); +} + +/** + * Output a RFC 7644 §3.12 error response and exit. + */ +function scim_error(int $status, string $detail, string $scimType = ''): never { + http_response_code($status); + $body = [ + 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:Error'], + 'status' => (string) $status, + 'detail' => $detail, + ]; + if ($scimType !== '') { + $body['scimType'] = $scimType; + } + echo json_encode($body); + exit; +} + +/** + * Map a mailbox DB row + optional externalId to a SCIM User object. + */ +function scim_user_to_response(array $row, ?string $external_id): array { + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $location = $scheme . '://' . $host . '/scim/v2/Users/' . rawurlencode($row['username']); + + $name_parts = explode(' ', $row['name'] ?? '', 2); + $given = $name_parts[0] ?? ''; + $family = $name_parts[1] ?? ''; + + $obj = [ + 'schemas' => ['urn:ietf:params:scim:schemas:core:2.0:User'], + 'id' => $row['username'], + 'userName' => $row['username'], + 'displayName' => $row['name'] ?? '', + 'name' => [ + 'formatted' => $row['name'] ?? '', + 'givenName' => $given, + 'familyName' => $family, + ], + 'active' => (bool)(int)$row['active'], + 'emails' => [ + ['value' => $row['username'], 'primary' => true], + ], + 'meta' => [ + 'resourceType' => 'User', + 'created' => isset($row['created']) + ? (new DateTime($row['created']))->format(DateTime::RFC3339) + : null, + 'lastModified' => !empty($row['modified']) + ? (new DateTime($row['modified']))->format(DateTime::RFC3339) + : null, + 'location' => $location, + ], + ]; + + if ($external_id !== null) { + $obj['externalId'] = $external_id; + } + + return $obj; +} + +/** + * Resolve display name from a SCIM User request body. + * Priority: displayName > name.formatted > givenName+familyName > local part of userName + */ +function scim_resolve_name(array $body): string { + if (!empty($body['displayName'])) { + return trim($body['displayName']); + } + if (!empty($body['name']['formatted'])) { + return trim($body['name']['formatted']); + } + $given = trim($body['name']['givenName'] ?? ''); + $family = trim($body['name']['familyName'] ?? ''); + if ($given !== '' || $family !== '') { + return trim("$given $family"); + } + // fallback to local part of userName + $userName = $body['userName'] ?? ''; + return strstr($userName, '@', true) ?: $userName; +} + +/** + * Set up admin session so mailbox() calls have the required ACLs. + * Mirrors the pattern in keycloak-sync.php. + */ +function scim_setup_session(): void { + $_SESSION['mailcow_cc_username'] = 'SCIM'; + $_SESSION['mailcow_cc_role'] = 'admin'; + $_SESSION['acl']['tls_policy'] = '1'; + $_SESSION['acl']['quarantine_notification'] = '1'; + $_SESSION['acl']['quarantine_category'] = '1'; + $_SESSION['acl']['ratelimit'] = '1'; + $_SESSION['acl']['sogo_access'] = '1'; + $_SESSION['acl']['protocol_access'] = '1'; + $_SESSION['acl']['mailbox_relayhost'] = '1'; + $_SESSION['acl']['unlimited_quota'] = '1'; + $_SESSION['access_all_exception'] = '1'; +} + +/** + * Parse and JSON-encode the mappers/templates arrays from POST data. + * Returns [mappers_json, templates_json] with only non-empty paired entries kept. + */ +function scim_encode_mappers(array $data): array { + $mappers_raw = $data['mappers'] ?? []; + $templates_raw = $data['templates'] ?? []; + $mappers_raw = is_array($mappers_raw) ? $mappers_raw : [$mappers_raw]; + $templates_raw = is_array($templates_raw) ? $templates_raw : [$templates_raw]; + + $m = []; + $t = []; + foreach ($mappers_raw as $i => $mapper) { + $mapper = trim((string)$mapper); + $template = trim((string)($templates_raw[$i] ?? '')); + if ($mapper !== '' && $template !== '') { + $m[] = $mapper; + $t[] = $template; + } + } + + return [ + count($m) > 0 ? json_encode($m) : null, + count($t) > 0 ? json_encode($t) : null, + ]; +} + +/** + * Resolve the mailbox template for a SCIM-provisioned user. + * + * Checks the SCIM body for a 'mailcow_template' attribute (top-level or inside + * the enterprise extension), then walks the token's mapper list for a match. + * Falls back to the token-level default template, or null if none is configured. + */ +function scim_resolve_template(array $body, array $token): ?string { + $enterprise_ext = 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'; + $attr = $body['mailcow_template'] + ?? $body[$enterprise_ext]['mailcow_template'] + ?? null; + + if ($attr !== null) { + $mappers = json_decode($token['mappers'] ?? '[]', true) ?? []; + $templates = json_decode($token['templates'] ?? '[]', true) ?? []; + $key = array_search($attr, $mappers, true); + if ($key !== false && isset($templates[$key]) && $templates[$key] !== '') { + return $templates[$key]; + } + } + + return !empty($token['default_template']) ? $token['default_template'] : null; +} + +// ─── Authentication ────────────────────────────────────────────────────────── + +/** + * Authenticate the SCIM request via Bearer token. + * Returns the scim_tokens row on success, or exits with 401 on failure. + */ +function scim_authenticate(): array { + global $pdo, $redis; + + $auth_header = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; + if (!preg_match('/^Bearer\s+(\S+)$/i', $auth_header, $m)) { + scim_log('err', 'Authentication failed: missing or malformed Authorization header'); + scim_error(401, 'Bearer token required'); + } + + $raw_token = $m[1]; + $token_hash = hash('sha256', $raw_token); + + $stmt = $pdo->prepare("SELECT * FROM `scim_tokens` WHERE `token_hash` = :hash AND `active` = '1'"); + $stmt->execute([':hash' => $token_hash]); + $token = $stmt->fetch(PDO::FETCH_ASSOC); + + if (empty($token)) { + $redis->publish('F2B_CHANNEL', 'mailcow SCIM: Invalid token from ' . ($_SERVER['REMOTE_ADDR'] ?? '?')); + scim_log('err', 'Authentication failed: invalid or inactive token from ' . ($_SERVER['REMOTE_ADDR'] ?? '?')); + scim_error(401, 'Invalid or inactive token'); + } + + // IP ACL check + $remote = filter_var($_SERVER['REMOTE_ADDR'] ?? '', FILTER_VALIDATE_IP) ? $_SERVER['REMOTE_ADDR'] : '0.0.0.0'; + $allow_from = array_map('trim', preg_split('/[ ,;\n]+/', $token['allow_from'])); + $allow_from = array_filter($allow_from); + if (!empty($allow_from) && !ip_acl($remote, $allow_from)) { + $redis->publish('F2B_CHANNEL', 'mailcow SCIM: IP denied for token from ' . $remote); + scim_log('err', 'Authentication failed: IP ' . $remote . ' not in allow list for token ID ' . $token['id']); + scim_error(401, 'IP address not allowed'); + } + + return $token; +} + +// ─── Token management (admin operations) ──────────────────────────────────── + +function scim_token(string $_action, array $_data = []): mixed { + global $pdo; + + switch ($_action) { + case 'add': + $description = htmlspecialchars(trim($_data['description'] ?? ''), ENT_QUOTES); + $domain_restriction = !empty($_data['domain_restriction']) ? strtolower(trim($_data['domain_restriction'])) : null; + $default_template = !empty($_data['default_template']) ? trim($_data['default_template']) : null; + $allow_from = trim($_data['allow_from'] ?? ''); + [$mappers_json, $templates_json] = scim_encode_mappers($_data); + + $raw_token = bin2hex(random_bytes(32)); + $token_hash = hash('sha256', $raw_token); + + $stmt = $pdo->prepare("INSERT INTO `scim_tokens` + (`description`, `token_hash`, `domain_restriction`, `default_template`, `allow_from`, `mappers`, `templates`, `active`) + VALUES (:description, :token_hash, :domain_restriction, :default_template, :allow_from, :mappers, :templates, '1')"); + $stmt->execute([ + ':description' => $description, + ':token_hash' => $token_hash, + ':domain_restriction' => $domain_restriction, + ':default_template' => $default_template, + ':allow_from' => $allow_from, + ':mappers' => $mappers_json, + ':templates' => $templates_json, + ]); + + $id = $pdo->lastInsertId(); + $_SESSION['return'][] = [ + 'type' => 'success', + 'log' => [__FUNCTION__, $_action], + 'msg' => array('scim_token_added', $id), + ]; + // Return raw token — shown once to the admin, never stored + return $raw_token; + + case 'edit': + $id = intval($_data['id'] ?? 0); + $description = htmlspecialchars(trim($_data['description'] ?? ''), ENT_QUOTES); + $allow_from = trim($_data['allow_from'] ?? ''); + $active = (isset($_data['active']) && intval($_data['active']) == 1) ? 1 : 0; + $default_template = !empty($_data['default_template']) ? trim($_data['default_template']) : null; + [$mappers_json, $templates_json] = scim_encode_mappers($_data); + + $domain_restriction = !empty($_data['domain_restriction']) ? strtolower(trim($_data['domain_restriction'])) : null; + + $stmt = $pdo->prepare("UPDATE `scim_tokens` + SET `description` = :description, + `domain_restriction` = :domain_restriction, + `default_template` = :default_template, + `allow_from` = :allow_from, + `mappers` = :mappers, + `templates` = :templates, + `active` = :active + WHERE `id` = :id"); + $stmt->execute([ + ':description' => $description, + ':domain_restriction' => $domain_restriction, + ':default_template' => $default_template, + ':allow_from' => $allow_from, + ':mappers' => $mappers_json, + ':templates' => $templates_json, + ':active' => $active, + ':id' => $id, + ]); + $_SESSION['return'][] = [ + 'type' => 'success', + 'log' => [__FUNCTION__, $_action], + 'msg' => array('scim_token_updated', $id), + ]; + return true; + + case 'delete': + $id = intval($_data['id'] ?? 0); + $stmt = $pdo->prepare("DELETE FROM `scim_tokens` WHERE `id` = :id"); + $stmt->execute([':id' => $id]); + $_SESSION['return'][] = [ + 'type' => 'success', + 'log' => [__FUNCTION__, $_action], + 'msg' => array('scim_token_deleted', $id), + ]; + return true; + + case 'get_all': + $stmt = $pdo->query("SELECT `id`, `description`, `domain_restriction`, `default_template`, + `allow_from`, `mappers`, `templates`, `active`, `created`, `modified` + FROM `scim_tokens` ORDER BY `created` DESC"); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + return false; +} + +// ─── Discovery endpoints ───────────────────────────────────────────────────── + +function scim_service_provider_config(): array { + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $base = $scheme . '://' . $host . '/scim/v2'; + + return [ + 'schemas' => ['urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig'], + 'documentationUri' => '', + 'patch' => ['supported' => true], + 'bulk' => ['supported' => false, 'maxOperations' => 0, 'maxPayloadSize' => 0], + 'filter' => ['supported' => true, 'maxResults' => 500], + 'changePassword' => ['supported' => false], + 'sort' => ['supported' => false], + 'etag' => ['supported' => false], + 'authenticationSchemes' => [ + [ + 'name' => 'OAuth Bearer Token', + 'description' => 'Authentication scheme using the OAuth Bearer Token standard', + 'type' => 'oauthbearertoken', + 'primary' => true, + ], + ], + 'meta' => [ + 'resourceType' => 'ServiceProviderConfig', + 'location' => $base . '/ServiceProviderConfig', + ], + ]; +} + +function scim_schemas(): array { + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $base = $scheme . '://' . $host . '/scim/v2'; + + $user_schema = [ + 'id' => 'urn:ietf:params:scim:schemas:core:2.0:User', + 'name' => 'User', + 'description' => 'User account', + 'attributes' => [ + ['name' => 'userName', 'type' => 'string', 'required' => true, 'uniqueness' => 'server'], + ['name' => 'displayName', 'type' => 'string', 'required' => false, 'uniqueness' => 'none'], + ['name' => 'name', 'type' => 'complex', 'required' => false, 'uniqueness' => 'none', + 'subAttributes' => [ + ['name' => 'formatted', 'type' => 'string', 'required' => false], + ['name' => 'givenName', 'type' => 'string', 'required' => false], + ['name' => 'familyName', 'type' => 'string', 'required' => false], + ], + ], + ['name' => 'emails', 'type' => 'complex', 'multiValued' => true, 'required' => false], + ['name' => 'active', 'type' => 'boolean', 'required' => false, 'uniqueness' => 'none'], + ], + 'meta' => [ + 'resourceType' => 'Schema', + 'location' => $base . '/Schemas/urn:ietf:params:scim:schemas:core:2.0:User', + ], + ]; + + return [ + 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:ListResponse'], + 'totalResults' => 1, + 'Resources' => [$user_schema], + ]; +} + +function scim_resource_types(): array { + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $base = $scheme . '://' . $host . '/scim/v2'; + + return [ + 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:ListResponse'], + 'totalResults' => 1, + 'Resources' => [ + [ + 'schemas' => ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'], + 'id' => 'User', + 'name' => 'User', + 'endpoint' => '/Users', + 'description' => 'User account', + 'schema' => 'urn:ietf:params:scim:schemas:core:2.0:User', + 'schemaExtensions' => [], + 'meta' => [ + 'resourceType' => 'ResourceType', + 'location' => $base . '/ResourceTypes/User', + ], + ], + ], + ]; +} + +// ─── User operations ───────────────────────────────────────────────────────── + +function scim_list_users(array $token): array { + global $pdo; + + $start_index = max(1, intval($_GET['startIndex'] ?? 1)); + $count = min(500, max(1, intval($_GET['count'] ?? 100))); + $offset = $start_index - 1; + + // Parse simple filter: userName eq "..." + $filter_username = null; + $filter_str = $_GET['filter'] ?? ''; + if ($filter_str !== '') { + if (preg_match('/^userName\s+eq\s+"([^"]+)"/i', $filter_str, $fm)) { + $filter_username = $fm[1]; + } else { + scim_error(400, 'Only "userName eq" filter is supported', 'invalidFilter'); + } + } + + $where = ['m.authsource = \'scim\'']; + $params = []; + + if (!empty($token['domain_restriction'])) { + $where[] = 'm.domain = :domain_restriction'; + $params[':domain_restriction'] = $token['domain_restriction']; + } + if ($filter_username !== null) { + $where[] = 'm.username = :filter_username'; + $params[':filter_username'] = $filter_username; + } + + $where_sql = implode(' AND ', $where); + + // Count total + $count_stmt = $pdo->prepare("SELECT COUNT(*) FROM `mailbox` m WHERE $where_sql"); + $count_stmt->execute($params); + $total = (int) $count_stmt->fetchColumn(); + + // Fetch page + $params[':limit'] = $count; + $params[':offset'] = $offset; + $stmt = $pdo->prepare( + "SELECT m.*, sm.external_id + FROM `mailbox` m + LEFT JOIN `scim_maps` sm ON m.username = sm.username AND sm.token_id = :token_id + WHERE $where_sql + ORDER BY m.username + LIMIT :limit OFFSET :offset" + ); + $params[':token_id'] = (int) $token['id']; + // PDO needs int type for LIMIT/OFFSET with named params + $stmt->bindValue(':limit', $count, PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); + foreach ($params as $key => $val) { + if (in_array($key, [':limit', ':offset'])) continue; + $stmt->bindValue($key, $val); + } + $stmt->execute(); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $resources = array_map(fn($row) => scim_user_to_response($row, $row['external_id'] ?? null), $rows); + + return [ + 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:ListResponse'], + 'totalResults' => $total, + 'startIndex' => $start_index, + 'itemsPerPage' => count($resources), + 'Resources' => $resources, + ]; +} + +function scim_get_user(string $id, array $token): array { + global $pdo; + + $stmt = $pdo->prepare( + "SELECT m.*, sm.external_id + FROM `mailbox` m + LEFT JOIN `scim_maps` sm ON m.username = sm.username AND sm.token_id = :token_id + WHERE m.username = :username AND m.authsource = 'scim'" + ); + $stmt->execute([':username' => $id, ':token_id' => (int) $token['id']]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + scim_error(404, 'User not found', 'notFound'); + } + + if (!empty($token['domain_restriction']) && $row['domain'] !== $token['domain_restriction']) { + scim_error(403, 'Token is restricted to a different domain'); + } + + return scim_user_to_response($row, $row['external_id'] ?? null); +} + +function scim_create_user(array $body, array $token): array { + global $pdo; + + $userName = trim($body['userName'] ?? ''); + if (!filter_var($userName, FILTER_VALIDATE_EMAIL)) { + scim_error(400, 'userName must be a valid email address', 'invalidValue'); + } + + $parts = explode('@', $userName, 2); + $local_part = $parts[0]; + $domain = strtolower($parts[1]); + $username = $local_part . '@' . $domain; + + // Validate domain exists + $stmt = $pdo->prepare("SELECT `domain` FROM `domain` WHERE `domain` = :domain"); + $stmt->execute([':domain' => $domain]); + if (!$stmt->fetch(PDO::FETCH_ASSOC)) { + scim_error(400, "Domain '$domain' does not exist in mailcow", 'invalidValue'); + } + + // Domain restriction check + if (!empty($token['domain_restriction']) && $domain !== $token['domain_restriction']) { + scim_error(403, "Token is restricted to domain '{$token['domain_restriction']}'"); + } + + // Duplicate check + $stmt = $pdo->prepare("SELECT `username`, `authsource` FROM `mailbox` WHERE `username` = :username"); + $stmt->execute([':username' => $username]); + $existing = $stmt->fetch(PDO::FETCH_ASSOC); + + $external_id = $body['externalId'] ?? null; + + if ($existing) { + if ($existing['authsource'] !== 'scim') { + scim_error(409, + "User '$username' is managed by '{$existing['authsource']}'. " . + "To transfer SCIM management, change the mailbox authsource to 'scim' in the mailcow admin panel first.", + 'uniqueness'); + } + + // User was pre-created with authsource='scim' (e.g. via the admin UI). + // Claim them: update attributes and link the externalId, then return 200. + if ($external_id !== null) { + // Ensure the externalId isn't already mapped to a different user + $stmt = $pdo->prepare("SELECT `username` FROM `scim_maps` WHERE `external_id` = :eid AND `token_id` = :tid AND `username` != :username"); + $stmt->execute([':eid' => $external_id, ':tid' => (int) $token['id'], ':username' => $username]); + if ($stmt->fetch(PDO::FETCH_ASSOC)) { + scim_error(409, "externalId '$external_id' is already mapped to a different user", 'uniqueness'); + } + + $stmt = $pdo->prepare("SELECT `id` FROM `scim_maps` WHERE `username` = :username AND `token_id` = :tid"); + $stmt->execute([':username' => $username, ':tid' => (int) $token['id']]); + if ($stmt->fetch(PDO::FETCH_ASSOC)) { + $stmt = $pdo->prepare("UPDATE `scim_maps` SET `external_id` = :eid WHERE `username` = :username AND `token_id` = :tid"); + $stmt->execute([':eid' => $external_id, ':username' => $username, ':tid' => (int) $token['id']]); + } else { + $stmt = $pdo->prepare("INSERT INTO `scim_maps` (`external_id`, `username`, `token_id`) VALUES (:eid, :username, :tid)"); + $stmt->execute([':eid' => $external_id, ':username' => $username, ':tid' => (int) $token['id']]); + } + } + + $name = scim_resolve_name($body); + $active = isset($body['active']) ? (int)(bool)$body['active'] : 1; + + scim_setup_session(); + mailbox('edit', 'mailbox', [ + 'username' => [$username], + 'name' => $name, + 'active' => $active, + ]); + + scim_log('info', "Claimed existing mailbox '$username' via SCIM POST (token ID {$token['id']})"); + http_response_code(200); + return scim_get_user($username, $token); + } + + // externalId duplicate check for this token (new user path) + if ($external_id !== null) { + $stmt = $pdo->prepare("SELECT `id` FROM `scim_maps` WHERE `external_id` = :eid AND `token_id` = :tid"); + $stmt->execute([':eid' => $external_id, ':tid' => (int) $token['id']]); + if ($stmt->fetch(PDO::FETCH_ASSOC)) { + scim_error(409, "externalId '$external_id' is already mapped to a user", 'uniqueness'); + } + } + + $name = scim_resolve_name($body); + $active = isset($body['active']) ? (int)(bool)$body['active'] : 1; + + scim_setup_session(); + + $resolved_template = scim_resolve_template($body, $token); + if ($resolved_template !== null) { + mailbox('add', 'mailbox_from_template', [ + 'domain' => $domain, + 'local_part' => $local_part, + 'name' => $name, + 'authsource' => 'scim', + 'template' => $resolved_template, + 'active' => $active, + ]); + } else { + mailbox('add', 'mailbox', [ + 'domain' => $domain, + 'local_part' => $local_part, + 'name' => $name, + 'authsource' => 'scim', + 'password' => '', + 'password2' => '', + 'active' => $active, + ]); + } + + // Check for errors from mailbox() + foreach ($_SESSION['return'] as $ret) { + if ($ret['type'] === 'danger') { + $msg = is_array($ret['msg']) ? implode(': ', $ret['msg']) : $ret['msg']; + scim_error(400, 'Failed to create mailbox: ' . $msg, 'invalidValue'); + } + } + + // Insert scim_maps entry + if ($external_id !== null) { + $stmt = $pdo->prepare("INSERT INTO `scim_maps` (`external_id`, `username`, `token_id`) + VALUES (:eid, :username, :tid)"); + $stmt->execute([':eid' => $external_id, ':username' => $username, ':tid' => (int) $token['id']]); + } + + scim_log('info', "Created mailbox '$username' via SCIM (token ID {$token['id']})"); + + http_response_code(201); + return scim_get_user($username, $token); +} + +function scim_replace_user(string $id, array $body, array $token): array { + global $pdo; + + // Verify user exists and belongs to this token's domain restriction + scim_get_user($id, $token); // exits with 404 if not found + + $name = scim_resolve_name($body); + $active = isset($body['active']) ? (int)(bool)$body['active'] : 1; + + scim_setup_session(); + mailbox('edit', 'mailbox', [ + 'username' => [$id], + 'name' => $name, + 'active' => $active, + ]); + + // Update externalId if provided + $external_id = $body['externalId'] ?? null; + if ($external_id !== null) { + // Check if a map entry already exists for this username + $stmt = $pdo->prepare("SELECT `id` FROM `scim_maps` WHERE `username` = :username"); + $stmt->execute([':username' => $id]); + $existing = $stmt->fetch(PDO::FETCH_ASSOC); + if ($existing) { + $stmt = $pdo->prepare("UPDATE `scim_maps` SET `external_id` = :eid WHERE `username` = :username AND `token_id` = :tid"); + $stmt->execute([':eid' => $external_id, ':username' => $id, ':tid' => (int) $token['id']]); + } else { + $stmt = $pdo->prepare("INSERT INTO `scim_maps` (`external_id`, `username`, `token_id`) VALUES (:eid, :username, :tid)"); + $stmt->execute([':eid' => $external_id, ':username' => $id, ':tid' => (int) $token['id']]); + } + } + + scim_log('info', "Replaced mailbox '$id' via SCIM (token ID {$token['id']})"); + return scim_get_user($id, $token); +} + +function scim_patch_user(string $id, array $body, array $token): array { + global $pdo; + + // Verify user exists + scim_get_user($id, $token); + + $operations = $body['Operations'] ?? []; + if (empty($operations) || !is_array($operations)) { + scim_error(400, 'Operations array is required', 'invalidSyntax'); + } + + $update = []; // fields to update in mailbox + + foreach ($operations as $op) { + $op_name = strtolower($op['op'] ?? ''); + $path = $op['path'] ?? null; + $value = $op['value'] ?? null; + + if (!in_array($op_name, ['add', 'replace', 'remove'])) { + scim_error(400, "Unsupported operation '$op_name'", 'invalidSyntax'); + } + + // Handle path-less value object (e.g., {"op":"replace","value":{"active":false}}) + if ($path === null && is_array($value)) { + foreach ($value as $attr => $val) { + $update = array_merge($update, scim_patch_resolve_attr($attr, $val, $op_name)); + } + continue; + } + + if ($path === null) { + scim_error(400, 'path is required for this operation', 'invalidSyntax'); + } + + $update = array_merge($update, scim_patch_resolve_attr($path, $value, $op_name)); + } + + if (empty($update)) { + // No-op — return current state + return scim_get_user($id, $token); + } + + scim_setup_session(); + $edit_data = array_merge(['username' => [$id]], $update); + mailbox('edit', 'mailbox', $edit_data); + + scim_log('info', "Patched mailbox '$id' via SCIM (token ID {$token['id']})"); + return scim_get_user($id, $token); +} + +/** + * Translate a single SCIM PATCH path+value into mailbox() edit parameters. + */ +function scim_patch_resolve_attr(string $path, mixed $value, string $op): array { + $supported = [ + 'active' => 'active', + 'displayname' => 'name', + 'name.formatted' => 'name', + 'name.givenname' => null, // handled specially + 'name.familyname' => null, // handled specially + ]; + + $path_lower = strtolower($path); + + if (!array_key_exists($path_lower, $supported)) { + scim_error(400, "Unsupported PATCH path '$path'", 'invalidPath'); + } + + if ($op === 'remove' && in_array($path_lower, ['active'])) { + scim_error(400, "Cannot remove required attribute '$path'", 'noTarget'); + } + + switch ($path_lower) { + case 'active': + return ['active' => (int)(bool)$value]; + case 'displayname': + case 'name.formatted': + return ['name' => trim((string)$value)]; + case 'name.givenname': + case 'name.familyname': + // We can only update the full name; return a placeholder that signals partial update + // The caller must handle this by fetching current name and merging + // For simplicity, if only one part is provided, use it as the full name + return ['name' => trim((string)$value)]; + } + + return []; +} + +function scim_delete_user(string $id, array $token): void { + // Verify user exists (exits with 404 if not found or domain restricted) + scim_get_user($id, $token); + + scim_setup_session(); + // Soft deactivate — preserve mail data + mailbox('edit', 'mailbox', [ + 'username' => [$id], + 'active' => 0, + ]); + + // scim_maps row intentionally kept for audit trail + + scim_log('info', "Deactivated mailbox '$id' via SCIM DELETE (token ID {$token['id']})"); + http_response_code(204); + exit; +} diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php index 72018a6bc2..b27f1bd9f7 100644 --- a/data/web/inc/init_db.inc.php +++ b/data/web/inc/init_db.inc.php @@ -4,7 +4,7 @@ function init_db_schema() try { global $pdo; - $db_version = "19022026_1220"; + $db_version = "16032026_1000"; $stmt = $pdo->query("SHOW TABLES LIKE 'versions'"); $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC)); @@ -375,7 +375,7 @@ function init_db_schema() "custom_attributes" => "JSON NOT NULL DEFAULT ('{}')", "kind" => "VARCHAR(100) NOT NULL DEFAULT ''", "multiple_bookings" => "INT NOT NULL DEFAULT -1", - "authsource" => "ENUM('mailcow', 'keycloak', 'generic-oidc', 'ldap') DEFAULT 'mailcow'", + "authsource" => "ENUM('mailcow', 'keycloak', 'generic-oidc', 'ldap', 'scim') DEFAULT 'mailcow'", "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)", "modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP", "active" => "TINYINT(1) NOT NULL DEFAULT '1'" @@ -1149,6 +1149,63 @@ function init_db_schema() ) ), "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), + "scim_tokens" => array( + "cols" => array( + "id" => "INT NOT NULL AUTO_INCREMENT", + "description" => "VARCHAR(255) NOT NULL DEFAULT ''", + "token_hash" => "VARCHAR(255) NOT NULL", + "domain_restriction" => "VARCHAR(255) DEFAULT NULL", + "allow_from" => "TEXT NOT NULL", + "default_template" => "VARCHAR(255) DEFAULT NULL", + "mappers" => "TEXT DEFAULT NULL", + "templates" => "TEXT DEFAULT NULL", + "active" => "TINYINT(1) NOT NULL DEFAULT '1'", + "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)", + "modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP" + ), + "keys" => array( + "primary" => array( + "" => array("id") + ), + "unique" => array( + "token_hash" => array("token_hash") + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), + "scim_maps" => array( + "cols" => array( + "id" => "INT NOT NULL AUTO_INCREMENT", + "external_id" => "VARCHAR(255) NOT NULL", + "username" => "VARCHAR(255) NOT NULL", + "token_id" => "INT NOT NULL", + "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)" + ), + "keys" => array( + "primary" => array( + "" => array("id") + ), + "unique" => array( + "scim_maps_username" => array("username"), + "scim_maps_external_id_token" => array("external_id", "token_id") + ), + "fkey" => array( + "fk_scim_maps_username" => array( + "col" => "username", + "ref" => "mailbox.username", + "delete" => "CASCADE", + "update" => "NO ACTION" + ), + "fk_scim_maps_token_id" => array( + "col" => "token_id", + "ref" => "scim_tokens.id", + "delete" => "CASCADE", + "update" => "NO ACTION" + ) + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" ) ); diff --git a/data/web/inc/prerequisites.inc.php b/data/web/inc/prerequisites.inc.php index 5e57a4d417..6f1fdee911 100644 --- a/data/web/inc/prerequisites.inc.php +++ b/data/web/inc/prerequisites.inc.php @@ -294,6 +294,7 @@ function get_remote_ip() { require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.quota_notification.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.ratelimit.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.rspamd.inc.php'; +require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.scim.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.tls_policy_maps.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.transports.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/init_db.inc.php'; diff --git a/data/web/inc/triggers.admin.inc.php b/data/web/inc/triggers.admin.inc.php index 92043190ed..09e11b9aae 100644 --- a/data/web/inc/triggers.admin.inc.php +++ b/data/web/inc/triggers.admin.inc.php @@ -121,5 +121,20 @@ if (isset($_POST["mass_send"])) { sys_mail($_POST); } + if (isset($_POST["add_scim_token"])) { + require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.scim.inc.php'; + $raw_token = scim_token('add', $_POST); + if ($raw_token !== false) { + $_SESSION['scim_new_token'] = $raw_token; + } + } + if (isset($_POST["edit_scim_token"])) { + require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.scim.inc.php'; + scim_token('edit', $_POST); + } + if (isset($_POST["delete_scim_token"])) { + require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.scim.inc.php'; + scim_token('delete', ['id' => intval($_POST['id'] ?? 0)]); + } } ?> diff --git a/data/web/js/site/admin.js b/data/web/js/site/admin.js index 91bb0e4cf5..dd0fad4cc8 100644 --- a/data/web/js/site/admin.js +++ b/data/web/js/site/admin.js @@ -871,4 +871,103 @@ jQuery(function($){ if ($(elem).parent().parent().parent().parent().children().length > 2) $(elem).parent().parent().parent().remove(); } + + // ── SCIM attribute mapping helper ────────────────────────────────────────── + // Builds a mapper row, appends it to listSelector, then applies selectpicker. + // Options are sourced from the Default template select already in the list, + // which is server-rendered by Twig — no inline + {% endif %} + + {# ── Edit token modal ────────────────────────────────────────────────── #} +
| Description | +Domain restriction | +Default template | +IP allow list | +Active | +Created | ++ |
|---|---|---|---|---|---|---|
| {{ token.description }} | +{{ token.domain_restriction ?? '(all domains)' }} | +{{ token.default_template ?? '(default)' }} | +{{ token.allow_from ?: '(any)' }} |
+ + + | +{{ token.created }} | ++ + + | +
No SCIM tokens configured yet.
+ {% endif %} + + {# Add token form #} +