[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:
Lorenzo Moscati 2026-03-16 18:52:10 +01:00
parent 8b456a33e7
commit 2597618718
No known key found for this signature in database
GPG key ID: A1DEF4AE088FB06D
12 changed files with 1263 additions and 8 deletions

View file

@ -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;
}

View file

@ -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'])
];

View file

@ -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;

View file

@ -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',

View file

@ -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'){

View 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;
}

View file

@ -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"
)
);

View file

@ -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
View 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(),
]));
}

View file

@ -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' %}

View 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 &rsaquo; Configuration &rsaquo; Access &rsaquo; 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>

View file

@ -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>