mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-08-10 16:26:19 -04:00
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
187 lines
5.8 KiB
PHP
187 lines
5.8 KiB
PHP
<?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(),
|
|
]));
|
|
}
|