mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-08-10 16:26:19 -04:00
[Web] Add SCIM 2.0 provider for IdP user provisioning
Implements a SCIM 2.0 (RFC 7643/7644) server endpoint so any Identity Provider — Keycloak, Entra ID, Okta, or any LDAP/OIDC IdP — can push user lifecycle events to mailcow in real time, independently of whichever login protocol is configured. ## Protocol support - Full Users CRUD: POST, GET, PUT, PATCH (RFC 7644 §3.5.2), DELETE - SCIM DELETE is a soft-deactivate (active=0); mail data is never removed - Filtering: filter=userName eq "..." on list endpoint - Pagination: startIndex / count - Discovery: ServiceProviderConfig, Schemas, ResourceTypes - Groups: out of scope ## Authentication & token management Bearer tokens are generated in the admin UI (System > Configuration > Access > SCIM). The raw token is shown once at creation and never stored; only its SHA-256 hash is kept. Each token supports: - Optional domain restriction (limits which mailboxes the token can manage) - Optional mailbox template (applied on user creation) - Optional IP allow-list / skip-IP-check flag - Active/inactive toggle ## Database schema Two new tables added via the existing init_db migration mechanism: - scim_tokens: stores token metadata and hashed credentials - scim_maps: maps IdP externalId values to mailcow usernames per token The mailbox.authsource ENUM is extended with 'scim'. ## Authsource & login design SCIM is a provisioning protocol, not an authentication protocol. mailbox.authsource='scim' records who manages the user; login is handled by the globally configured IAM provider: - Keycloak / Generic-OIDC: SCIM users pass through the existing verify-sso OIDC flow (identity_provider 'verify-sso' case). - LDAP: SCIM users authenticate via ldap_mbox_login(), with full TFA support, matching the behaviour of authsource='ldap' users. - No IAM configured: SCIM users cannot log in; the admin UI shows a warning on the SCIM configuration tab. Attempting a password login as a SCIM user when an OIDC provider is configured returns a clear error directing the user to their IdP. ## Claiming pre-existing users A SCIM POST for a user who already has authsource='scim' (e.g. set manually by the admin to prepare a migration) is treated as a claim: attributes are updated, scim_maps is upserted, and 200 is returned. A SCIM POST for a user managed by a different authsource returns 409 with an actionable message explaining how to transfer ownership. ## Admin UI - New SCIM tab under System > Configuration > Access (alongside Identity Provider settings) - Token table with active toggle and delete; one-time raw token modal - Mailbox edit form gains a SCIM authsource option, shown only when SCIM tokens exist (or the mailbox is already set to SCIM) - Contextual warning when no external IdP is configured for login
This commit is contained in:
parent
8b456a33e7
commit
2597618718
12 changed files with 1263 additions and 8 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,6 +86,12 @@ $cors_settings['allowed_methods'] = explode(", ", $cors_settings['allowed_method
|
|||
$f2b_data = fail2ban('get');
|
||||
// mbox templates
|
||||
$mbox_templates = mailbox('get', 'mailbox_templates');
|
||||
// SCIM
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.scim.inc.php';
|
||||
$scim_tokens = scim_token('get_all');
|
||||
$scim_new_token = $_SESSION['scim_new_token'] ?? null;
|
||||
$scim_base_url = 'https://' . getenv('MAILCOW_HOSTNAME') . '/scim/v2/';
|
||||
unset($_SESSION['scim_new_token']);
|
||||
|
||||
$template = 'admin.twig';
|
||||
$template_data = [
|
||||
|
|
@ -121,6 +127,9 @@ $template_data = [
|
|||
'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'])
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -2448,7 +2448,11 @@ function getBaseURL($protocol = null) {
|
|||
}
|
||||
|
||||
if (!isset($protocol)) {
|
||||
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
||||
if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
|
||||
$protocol = 'https';
|
||||
} else {
|
||||
$protocol = 'http';
|
||||
}
|
||||
}
|
||||
$base_url = $protocol . '://' . $host;
|
||||
|
||||
|
|
@ -2914,7 +2918,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',
|
||||
|
|
|
|||
|
|
@ -1056,7 +1056,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)) {
|
||||
|
|
@ -1125,7 +1126,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'){
|
||||
|
|
@ -3152,10 +3153,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'){
|
||||
|
|
|
|||
737
data/web/inc/functions.scim.inc.php
Normal file
737
data/web/inc/functions.scim.inc.php
Normal file
|
|
@ -0,0 +1,737 @@
|
|||
<?php
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function scim_log(string $priority, string $message): void {
|
||||
global $redis;
|
||||
$redis->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';
|
||||
}
|
||||
|
||||
// ─── 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
|
||||
if (!(int)$token['skip_ip_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;
|
||||
$template = !empty($_data['template']) ? trim($_data['template']) : null;
|
||||
$allow_from = trim($_data['allow_from'] ?? '');
|
||||
$skip_ip_check = (isset($_data['skip_ip_check']) && intval($_data['skip_ip_check']) == 1) ? 1 : 0;
|
||||
|
||||
// Validate domain_restriction if provided
|
||||
if ($domain_restriction !== null) {
|
||||
$stmt = $pdo->prepare("SELECT `domain` FROM `domain` WHERE `domain` = :domain");
|
||||
$stmt->execute([':domain' => $domain_restriction]);
|
||||
if (!$stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$_SESSION['return'][] = [
|
||||
'type' => 'danger',
|
||||
'log' => [__FUNCTION__, $_action],
|
||||
'msg' => 'scim_domain_not_found',
|
||||
];
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$raw_token = bin2hex(random_bytes(32));
|
||||
$token_hash = hash('sha256', $raw_token);
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO `scim_tokens`
|
||||
(`description`, `token_hash`, `domain_restriction`, `template`, `allow_from`, `skip_ip_check`, `active`)
|
||||
VALUES (:description, :token_hash, :domain_restriction, :template, :allow_from, :skip_ip_check, '1')");
|
||||
$stmt->execute([
|
||||
':description' => $description,
|
||||
':token_hash' => $token_hash,
|
||||
':domain_restriction' => $domain_restriction,
|
||||
':template' => $template,
|
||||
':allow_from' => $allow_from,
|
||||
':skip_ip_check' => $skip_ip_check,
|
||||
]);
|
||||
|
||||
$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'] ?? '');
|
||||
$skip_ip_check = (isset($_data['skip_ip_check']) && intval($_data['skip_ip_check']) == 1) ? 1 : 0;
|
||||
$active = (isset($_data['active']) && intval($_data['active']) == 1) ? 1 : 0;
|
||||
$template = !empty($_data['template']) ? trim($_data['template']) : null;
|
||||
|
||||
$domain_restriction = !empty($_data['domain_restriction']) ? strtolower(trim($_data['domain_restriction'])) : null;
|
||||
if ($domain_restriction !== null) {
|
||||
$stmt = $pdo->prepare("SELECT `domain` FROM `domain` WHERE `domain` = :domain");
|
||||
$stmt->execute([':domain' => $domain_restriction]);
|
||||
if (!$stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$_SESSION['return'][] = [
|
||||
'type' => 'danger',
|
||||
'log' => [__FUNCTION__, $_action],
|
||||
'msg' => 'scim_domain_not_found',
|
||||
];
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("UPDATE `scim_tokens`
|
||||
SET `description` = :description,
|
||||
`domain_restriction` = :domain_restriction,
|
||||
`template` = :template,
|
||||
`allow_from` = :allow_from,
|
||||
`skip_ip_check` = :skip_ip_check,
|
||||
`active` = :active
|
||||
WHERE `id` = :id");
|
||||
$stmt->execute([
|
||||
':description' => $description,
|
||||
':domain_restriction' => $domain_restriction,
|
||||
':template' => $template,
|
||||
':allow_from' => $allow_from,
|
||||
':skip_ip_check' => $skip_ip_check,
|
||||
':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`, `template`,
|
||||
`allow_from`, `skip_ip_check`, `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();
|
||||
|
||||
if (!empty($token['template'])) {
|
||||
mailbox('add', 'mailbox_from_template', [
|
||||
'domain' => $domain,
|
||||
'local_part' => $local_part,
|
||||
'name' => $name,
|
||||
'authsource' => 'scim',
|
||||
'template' => $token['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;
|
||||
}
|
||||
|
|
@ -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,62 @@ 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",
|
||||
"template" => "VARCHAR(255) DEFAULT NULL",
|
||||
"allow_from" => "TEXT NOT NULL",
|
||||
"skip_ip_check" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"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"
|
||||
)
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -121,5 +121,20 @@ if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == "admi
|
|||
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)]);
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
|
|
|||
187
data/web/scim.php
Normal file
187
data/web/scim.php
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<?php
|
||||
|
||||
// Block browser-initiated requests
|
||||
if (isset($_SERVER['HTTP_SEC_FETCH_DEST']) && $_SERVER['HTTP_SEC_FETCH_DEST'] === 'document') {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Always respond with SCIM content type
|
||||
header('Content-Type: application/scim+json');
|
||||
|
||||
// ─── Minimal bootstrap (mirrors keycloak-sync.php pattern) ──────────────────
|
||||
|
||||
require_once __DIR__ . '/inc/vars.inc.php';
|
||||
if (file_exists(__DIR__ . '/inc/vars.local.inc.php')) {
|
||||
include_once __DIR__ . '/inc/vars.local.inc.php';
|
||||
}
|
||||
require_once __DIR__ . '/inc/lib/vendor/autoload.php';
|
||||
|
||||
// Init database
|
||||
$dsn = $database_type . ':unix_socket=' . $database_sock . ';dbname=' . $database_name;
|
||||
$opt = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
];
|
||||
try {
|
||||
$pdo = new PDO($dsn, $database_user, $database_pass, $opt);
|
||||
} catch (PDOException $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'schemas' => ['urn:ietf:params:scim:api:messages:2.0:Error'],
|
||||
'status' => '500',
|
||||
'detail' => 'Database connection failed',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Init Redis
|
||||
$redis = new Redis();
|
||||
try {
|
||||
if (!empty(getenv('REDIS_SLAVEOF_IP'))) {
|
||||
$redis->connect(getenv('REDIS_SLAVEOF_IP'), getenv('REDIS_SLAVEOF_PORT'));
|
||||
} else {
|
||||
$redis->connect('redis-mailcow', 6379);
|
||||
}
|
||||
$redis->auth(getenv('REDISPASS'));
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'schemas' => ['urn:ietf:params:scim:api:messages:2.0:Error'],
|
||||
'status' => '500',
|
||||
'detail' => 'Cache connection failed',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Start session so mailbox() can use $_SESSION
|
||||
session_name('MAILCOW_SCIM');
|
||||
session_start();
|
||||
|
||||
// Load required functions
|
||||
require_once __DIR__ . '/inc/functions.inc.php';
|
||||
require_once __DIR__ . '/inc/functions.auth.inc.php';
|
||||
require_once __DIR__ . '/inc/functions.mailbox.inc.php';
|
||||
require_once __DIR__ . '/inc/functions.ratelimit.inc.php';
|
||||
require_once __DIR__ . '/inc/functions.acl.inc.php';
|
||||
require_once __DIR__ . '/inc/functions.scim.inc.php';
|
||||
|
||||
// ─── Authentication ──────────────────────────────────────────────────────────
|
||||
|
||||
$scim_token = scim_authenticate();
|
||||
|
||||
// ─── Routing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$path = trim($_GET['path'] ?? '', '/');
|
||||
// Normalize empty path
|
||||
if ($path === '') {
|
||||
http_response_code(404);
|
||||
echo json_encode([
|
||||
'schemas' => ['urn:ietf:params:scim:api:messages:2.0:Error'],
|
||||
'status' => '404',
|
||||
'detail' => 'Not found',
|
||||
'scimType'=> 'notFound',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Split path into segments
|
||||
$segments = explode('/', $path, 2);
|
||||
$resource = $segments[0];
|
||||
$resource_id = isset($segments[1]) ? rawurldecode($segments[1]) : null;
|
||||
|
||||
// Log the request
|
||||
$redis->lPush('SCIM_LOG', json_encode([
|
||||
'time' => time(),
|
||||
'priority' => 'info',
|
||||
'task' => 'SCIM',
|
||||
'message' => $method . ' /scim/v2/' . $path . ' from ' . ($_SERVER['REMOTE_ADDR'] ?? '?') . ' (token ID ' . $scim_token['id'] . ')',
|
||||
]));
|
||||
|
||||
// Reset session return buffer
|
||||
$_SESSION['return'] = [];
|
||||
|
||||
try {
|
||||
// Handle OPTIONS (CORS preflight)
|
||||
if ($method === 'OPTIONS') {
|
||||
header('Allow: GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Read JSON body for mutating methods
|
||||
$body = [];
|
||||
if (in_array($method, ['POST', 'PUT', 'PATCH'])) {
|
||||
$raw = file_get_contents('php://input');
|
||||
$body = json_decode($raw, true) ?? [];
|
||||
}
|
||||
|
||||
// Dispatch
|
||||
switch ($resource) {
|
||||
case 'ServiceProviderConfig':
|
||||
if ($method !== 'GET') { http_response_code(405); exit; }
|
||||
echo json_encode(scim_service_provider_config());
|
||||
break;
|
||||
|
||||
case 'Schemas':
|
||||
if ($method !== 'GET') { http_response_code(405); exit; }
|
||||
echo json_encode(scim_schemas());
|
||||
break;
|
||||
|
||||
case 'ResourceTypes':
|
||||
if ($method !== 'GET') { http_response_code(405); exit; }
|
||||
echo json_encode(scim_resource_types());
|
||||
break;
|
||||
|
||||
case 'Users':
|
||||
if ($resource_id === null) {
|
||||
// Collection endpoints
|
||||
if ($method === 'GET') {
|
||||
echo json_encode(scim_list_users($scim_token));
|
||||
} elseif ($method === 'POST') {
|
||||
echo json_encode(scim_create_user($body, $scim_token));
|
||||
} else {
|
||||
http_response_code(405);
|
||||
}
|
||||
} else {
|
||||
// Individual resource endpoints
|
||||
if ($method === 'GET') {
|
||||
echo json_encode(scim_get_user($resource_id, $scim_token));
|
||||
} elseif ($method === 'PUT') {
|
||||
echo json_encode(scim_replace_user($resource_id, $body, $scim_token));
|
||||
} elseif ($method === 'PATCH') {
|
||||
echo json_encode(scim_patch_user($resource_id, $body, $scim_token));
|
||||
} elseif ($method === 'DELETE') {
|
||||
scim_delete_user($resource_id, $scim_token);
|
||||
} else {
|
||||
http_response_code(405);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(404);
|
||||
echo json_encode([
|
||||
'schemas' => ['urn:ietf:params:scim:api:messages:2.0:Error'],
|
||||
'status' => '404',
|
||||
'detail' => "Resource type '$resource' not found",
|
||||
'scimType' => 'notFound',
|
||||
]);
|
||||
break;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'schemas' => ['urn:ietf:params:scim:api:messages:2.0:Error'],
|
||||
'status' => '500',
|
||||
'detail' => 'Internal server error',
|
||||
]);
|
||||
$redis->lPush('SCIM_LOG', json_encode([
|
||||
'time' => time(),
|
||||
'priority' => 'err',
|
||||
'task' => 'SCIM',
|
||||
'message' => 'Uncaught exception: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine(),
|
||||
]));
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
<ul class="dropdown-menu">
|
||||
<li><button class="dropdown-item active" data-bs-target="#tab-config-admins" aria-selected="false" aria-controls="tab-config-admins" role="tab" data-bs-toggle="tab">{{ lang.admin.admins }}</button></li>
|
||||
<li><button class="dropdown-item" data-bs-target="#tab-config-identity-provider" aria-selected="false" aria-controls="tab-config-identity-provider" role="tab" data-bs-toggle="tab">Identity Provider</button></li>
|
||||
<li><button class="dropdown-item" data-bs-target="#tab-config-scim" aria-selected="false" aria-controls="tab-config-scim" role="tab" data-bs-toggle="tab">SCIM</button></li>
|
||||
<!-- <li><button class="dropdown-item" data-bs-target="#tab-config-ldap-admins" aria-controls="tab-config-ldap-admins" role="tab" data-bs-toggle="tab">{{ lang.admin.admins_ldap }}</button></li> -->
|
||||
<li><button class="dropdown-item" data-bs-target="#tab-config-oauth2" aria-selected="false" aria-controls="tab-config-oauth2" role="tab" data-bs-toggle="tab">{{ lang.admin.oauth2_apps }}</button></li>
|
||||
<li><button class="dropdown-item" data-bs-target="#tab-config-rspamd" aria-selected="false" aria-controls="tab-config-rspamd" role="tab" data-bs-toggle="tab">Rspamd UI</button></li>
|
||||
|
|
@ -42,6 +43,7 @@
|
|||
<div class="tab-content" style="padding-top:20px">
|
||||
{% include 'admin/tab-config-admins.twig' %}
|
||||
{% include 'admin/tab-config-identity-provider.twig' %}
|
||||
{% include 'admin/tab-config-scim.twig' %}
|
||||
{# {% include 'admin/tab-ldap.twig' %} #}
|
||||
{% include 'admin/tab-config-oauth2.twig' %}
|
||||
{% include 'admin/tab-config-rspamd.twig' %}
|
||||
|
|
|
|||
179
data/web/templates/admin/tab-config-scim.twig
Normal file
179
data/web/templates/admin/tab-config-scim.twig
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
<div role="tabpanel" class="tab-pane fade" id="tab-config-scim" role="tabpanel" aria-labelledby="tab-config-scim">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex fs-5">
|
||||
<button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-scim" data-bs-toggle="collapse" aria-controls="collapse-tab-config-scim">
|
||||
SCIM
|
||||
</button>
|
||||
<span class="d-none d-md-block">SCIM</span>
|
||||
</div>
|
||||
<div id="collapse-tab-config-scim" class="card-body collapse" data-bs-parent="#admin-content">
|
||||
|
||||
<p class="offset-sm-3 mb-4">
|
||||
SCIM 2.0 (RFC 7644) allows Identity Providers (Keycloak, Entra ID, Okta, etc.) to push user
|
||||
provisioning events to mailcow in real time. Generate a Bearer token below and configure your
|
||||
IdP to use <code>{{ scim_base_url }}</code>
|
||||
as the SCIM base URL.
|
||||
</p>
|
||||
|
||||
{% if iam_settings.authsource != 'keycloak' and iam_settings.authsource != 'generic-oidc' and iam_settings.authsource != 'ldap' %}
|
||||
<div class="alert alert-warning offset-sm-3 col-sm-9 mb-4">
|
||||
<strong>No external identity provider configured.</strong>
|
||||
SCIM-provisioned users will not be able to log in until a Keycloak, Generic-OIDC, or LDAP provider
|
||||
is configured under <em>System › Configuration › Access › Identity Provider</em>.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# One-time raw token flash modal #}
|
||||
{% if scim_new_token %}
|
||||
<div class="modal fade" id="scimNewTokenModal" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">SCIM Token Created</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-danger"><strong>Copy this token now — it will not be shown again.</strong></p>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control font-monospace" id="scim_raw_token_display" readonly value="{{ scim_new_token }}">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="navigator.clipboard.writeText(document.getElementById('scim_raw_token_display').value)">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">I have copied the token</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var el = document.getElementById('scimNewTokenModal');
|
||||
if (el) { new bootstrap.Modal(el).show(); }
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{# Existing tokens table #}
|
||||
{% if scim_tokens %}
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-striped table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Domain restriction</th>
|
||||
<th>Template</th>
|
||||
<th>IP allow list</th>
|
||||
<th>Active</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for token in scim_tokens %}
|
||||
<tr>
|
||||
<td>{{ token.description }}</td>
|
||||
<td>{{ token.domain_restriction ?? '(all domains)' }}</td>
|
||||
<td>{{ token.template ?? '(default)' }}</td>
|
||||
<td>
|
||||
{% if token.skip_ip_check %}
|
||||
<em>Any</em>
|
||||
{% else %}
|
||||
<code>{{ token.allow_from ?: '(any)' }}</code>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="edit_scim_token" value="1">
|
||||
<input type="hidden" name="id" value="{{ token.id }}">
|
||||
<input type="hidden" name="description" value="{{ token.description }}">
|
||||
<input type="hidden" name="domain_restriction" value="{{ token.domain_restriction }}">
|
||||
<input type="hidden" name="template" value="{{ token.template }}">
|
||||
<input type="hidden" name="allow_from" value="{{ token.allow_from }}">
|
||||
<input type="hidden" name="skip_ip_check" value="{{ token.skip_ip_check }}">
|
||||
<input type="hidden" name="active" value="{{ token.active ? 0 : 1 }}">
|
||||
<button type="submit" class="btn btn-xs btn-{{ token.active ? 'success' : 'secondary' }}">
|
||||
{{ token.active ? 'Active' : 'Inactive' }}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>{{ token.created }}</td>
|
||||
<td>
|
||||
<form method="post" class="d-inline" onsubmit="return confirm('Delete this SCIM token?')">
|
||||
<input type="hidden" name="delete_scim_token" value="1">
|
||||
<input type="hidden" name="id" value="{{ token.id }}">
|
||||
<button type="submit" class="btn btn-xs btn-danger"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted offset-sm-3 mb-4">No SCIM tokens configured yet.</p>
|
||||
{% endif %}
|
||||
|
||||
{# Add token form #}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Add SCIM Token</div>
|
||||
<div class="card-body">
|
||||
<form method="post" autocomplete="off">
|
||||
<input type="hidden" name="add_scim_token" value="1">
|
||||
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||
<label class="control-label">Description</label>
|
||||
</div>
|
||||
<div class="col-12 col-md-9 col-lg-4">
|
||||
<input type="text" class="form-control" name="description" placeholder="e.g. Keycloak production" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||
<label class="control-label">Domain restriction <small class="text-muted">(optional)</small></label>
|
||||
</div>
|
||||
<div class="col-12 col-md-9 col-lg-4">
|
||||
<input type="text" class="form-control" name="domain_restriction" placeholder="example.com (leave blank for all domains)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||
<label class="control-label">Mailbox template <small class="text-muted">(optional)</small></label>
|
||||
</div>
|
||||
<div class="col-12 col-md-9 col-lg-4">
|
||||
<select class="form-control" name="template">
|
||||
<option value="">(Use system defaults)</option>
|
||||
{% for tmpl in mbox_templates %}
|
||||
<option value="{{ tmpl.template }}">{{ tmpl.template }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||
<label class="control-label">IP allow list <small class="text-muted">(optional)</small></label>
|
||||
</div>
|
||||
<div class="col-12 col-md-9 col-lg-4">
|
||||
<input type="text" class="form-control" name="allow_from" placeholder="192.168.1.0/24, 10.0.0.1">
|
||||
<div class="form-check mt-1">
|
||||
<input class="form-check-input" type="checkbox" name="skip_ip_check" value="1" id="scim_skip_ip_check">
|
||||
<label class="form-check-label" for="scim_skip_ip_check">Allow from any IP</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-9 offset-md-3">
|
||||
<button type="submit" class="btn btn-success"><i class="bi bi-plus-lg"></i> Generate token</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -43,6 +43,9 @@
|
|||
{% if iam_settings.authsource == 'ldap' %}
|
||||
<option value="ldap" {% if result.authsource == "ldap" %}selected{% endif %}>LDAP</option>
|
||||
{% endif %}
|
||||
{% if scim_tokens is not empty or result.authsource == "scim" %}
|
||||
<option value="scim" {% if result.authsource == "scim" %}selected{% endif %}>SCIM</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue