b6212b8729
client_users.email has a UNIQUE constraint. SSO users who already have a CaveauAI account share the same email address, causing provision to fail with "already belongs to another workspace". Fix: SSO-provisioned client_users rows use dbn-sso-{client_id}@dbn.tools.internal as the owner email so they never collide with real account rows. The real email stays on clients.contact_email for display purposes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
259 lines
9.4 KiB
PHP
259 lines
9.4 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* CorpusProvision — idempotent per-SSO-user tenant provisioning for /dashboard/.
|
|
*
|
|
* The dashboard treats every SSO user as their own CaveauAI client tenant so
|
|
* that the existing client_id-based row filtering is enough to keep one user's
|
|
* documents from leaking to another. Provisioning is lazy: the first time the
|
|
* SSO user hits the dashboard, we create:
|
|
*
|
|
* - one row in `clients` (slug `dbn-user-<sso_uid>`, plan `sandbox`,
|
|
* dbn_sso_uid = sso_uid, fresh api_key hash)
|
|
* - one row in `client_users` (role owner, random password hash — login is
|
|
* always via SSO bridge, never password)
|
|
* - one row in `client_corpora` (slug `default`, is_default=1)
|
|
*
|
|
* CaveauAI sessions (which already have client_id + client_user_id set on the
|
|
* tools session) are returned as-is without touching the DB.
|
|
*
|
|
* Output shape:
|
|
* [
|
|
* 'client_id' => int,
|
|
* 'client_user_id' => int,
|
|
* 'corpus_id' => int,
|
|
* 'created' => bool, // true the first time, false on every call after
|
|
* ]
|
|
*/
|
|
final class CorpusProvision
|
|
{
|
|
public static function ensureForSsoUser(int $ssoUid, string $email, string $displayName): array
|
|
{
|
|
if ($ssoUid <= 0) {
|
|
throw new DbnToolsHttpException('SSO user id is required.', 400, 'missing_sso_uid');
|
|
}
|
|
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
throw new DbnToolsHttpException('A valid email is required.', 400, 'invalid_email');
|
|
}
|
|
|
|
dbnToolsBootCaveau();
|
|
$db = getDb();
|
|
|
|
$existing = self::lookupBySso($db, $ssoUid);
|
|
if ($existing !== null) {
|
|
$corpusId = self::ensureDefaultCorpus($db, $existing['client_id'], $email);
|
|
return [
|
|
'client_id' => $existing['client_id'],
|
|
'client_user_id' => $existing['client_user_id'],
|
|
'corpus_id' => $corpusId,
|
|
'created' => false,
|
|
];
|
|
}
|
|
|
|
$db->beginTransaction();
|
|
try {
|
|
$clientId = self::createClient($db, $ssoUid, $email, $displayName);
|
|
$clientUserId = self::createOwnerUser($db, $clientId, $email, $displayName);
|
|
$corpusId = self::ensureDefaultCorpus($db, $clientId, $email);
|
|
self::subscribeIncludedPackages($db, $clientId);
|
|
$db->commit();
|
|
} catch (Throwable $e) {
|
|
$db->rollBack();
|
|
throw new DbnToolsHttpException(
|
|
'Could not provision dashboard tenant: ' . $e->getMessage(),
|
|
500,
|
|
'provision_failed'
|
|
);
|
|
}
|
|
|
|
return [
|
|
'client_id' => $clientId,
|
|
'client_user_id' => $clientUserId,
|
|
'corpus_id' => $corpusId,
|
|
'created' => true,
|
|
];
|
|
}
|
|
|
|
public static function ensureForCaveauSession(int $clientId, string $email): array
|
|
{
|
|
if ($clientId <= 0) {
|
|
throw new DbnToolsHttpException('Caveau client_id is required.', 400, 'missing_client_id');
|
|
}
|
|
dbnToolsBootCaveau();
|
|
$db = getDb();
|
|
$corpusId = self::ensureDefaultCorpus($db, $clientId, $email);
|
|
return [
|
|
'client_id' => $clientId,
|
|
'client_user_id' => 0,
|
|
'corpus_id' => $corpusId,
|
|
'created' => false,
|
|
];
|
|
}
|
|
|
|
private static function lookupBySso(PDO $db, int $ssoUid): ?array
|
|
{
|
|
$stmt = $db->prepare('SELECT id FROM clients WHERE dbn_sso_uid = ? LIMIT 1');
|
|
$stmt->execute([$ssoUid]);
|
|
$clientId = (int)($stmt->fetchColumn() ?: 0);
|
|
if ($clientId === 0) {
|
|
return null;
|
|
}
|
|
$stmt = $db->prepare(
|
|
"SELECT id FROM client_users
|
|
WHERE client_id = ? AND role = 'owner' AND is_active = 1
|
|
ORDER BY id ASC LIMIT 1"
|
|
);
|
|
$stmt->execute([$clientId]);
|
|
$userId = (int)($stmt->fetchColumn() ?: 0);
|
|
return ['client_id' => $clientId, 'client_user_id' => $userId];
|
|
}
|
|
|
|
private static function createClient(PDO $db, int $ssoUid, string $email, string $displayName): int
|
|
{
|
|
require_once dbnToolsAiPortalRoot() . '/platform/includes/client_auth.php';
|
|
$apiKey = generateApiKey();
|
|
|
|
$slug = self::uniqueSlug($db, 'dbn-user-' . $ssoUid);
|
|
$name = $displayName !== '' ? $displayName : ('Dashboard ' . $email);
|
|
|
|
$stmt = $db->prepare("
|
|
INSERT INTO clients (
|
|
dbn_sso_uid, slug, name, contact_email, country, timezone,
|
|
plan, embedding_tier,
|
|
api_key_hash, api_key_prefix,
|
|
monthly_query_limit, monthly_document_limit, monthly_user_limit,
|
|
is_active, plan_source, subscription_status
|
|
) VALUES (
|
|
?, ?, ?, ?, 'NO', 'Europe/Oslo',
|
|
'sandbox', 'standard',
|
|
?, ?,
|
|
500, 50, 1,
|
|
1, 'signup', 'none'
|
|
)
|
|
");
|
|
$stmt->execute([
|
|
$ssoUid,
|
|
$slug,
|
|
$name,
|
|
$email,
|
|
$apiKey['hash'],
|
|
$apiKey['prefix'],
|
|
]);
|
|
return (int)$db->lastInsertId();
|
|
}
|
|
|
|
private static function createOwnerUser(PDO $db, int $clientId, string $email, string $displayName): int
|
|
{
|
|
// client_users.email has a UNIQUE constraint, so an SSO-provisioned workspace
|
|
// uses a synthetic internal email to avoid conflicting with any real CaveauAI
|
|
// account that may share the same address. The real email lives on clients.contact_email.
|
|
$ssoEmail = self::ssoInternalEmail($db, $clientId);
|
|
|
|
$stmt = $db->prepare('SELECT id FROM client_users WHERE email = ? AND client_id = ? LIMIT 1');
|
|
$stmt->execute([$ssoEmail, $clientId]);
|
|
$existingId = (int)($stmt->fetchColumn() ?: 0);
|
|
if ($existingId > 0) {
|
|
return $existingId;
|
|
}
|
|
|
|
$usernameBase = preg_replace('/[^a-z0-9._-]+/', '', strtolower(strstr($email, '@', true) ?: 'owner'));
|
|
$usernameBase = $usernameBase !== '' ? $usernameBase : 'owner';
|
|
$username = self::uniqueUsername($db, $usernameBase);
|
|
|
|
$stmt = $db->prepare("
|
|
INSERT INTO client_users
|
|
(client_id, username, email, display_name, password_hash, role, is_active)
|
|
VALUES (?, ?, ?, ?, ?, 'owner', 1)
|
|
");
|
|
$stmt->execute([
|
|
$clientId,
|
|
$username,
|
|
$ssoEmail,
|
|
$displayName !== '' ? $displayName : null,
|
|
password_hash(bin2hex(random_bytes(16)), PASSWORD_DEFAULT),
|
|
]);
|
|
return (int)$db->lastInsertId();
|
|
}
|
|
|
|
private static function ssoInternalEmail(PDO $db, int $clientId): string
|
|
{
|
|
// Derive a stable internal-only email from the client_id so it is unique
|
|
// even when the same real address already exists for another workspace.
|
|
return 'dbn-sso-' . $clientId . '@dbn.tools.internal';
|
|
}
|
|
|
|
private static function ensureDefaultCorpus(PDO $db, int $clientId, string $email): int
|
|
{
|
|
$stmt = $db->prepare(
|
|
'SELECT id FROM client_corpora
|
|
WHERE client_id = ? AND is_default = 1
|
|
ORDER BY id ASC LIMIT 1'
|
|
);
|
|
$stmt->execute([$clientId]);
|
|
$id = (int)($stmt->fetchColumn() ?: 0);
|
|
if ($id > 0) {
|
|
return $id;
|
|
}
|
|
|
|
$stmt = $db->prepare("
|
|
INSERT INTO client_corpora (client_id, name, slug, description, is_default)
|
|
VALUES (?, ?, 'default', ?, 1)
|
|
");
|
|
$stmt->execute([
|
|
$clientId,
|
|
'Min korpus',
|
|
'Default personal corpus for ' . $email,
|
|
]);
|
|
return (int)$db->lastInsertId();
|
|
}
|
|
|
|
private static function subscribeIncludedPackages(PDO $db, int $clientId): void
|
|
{
|
|
$packageSlug = dbnToolsRequiredPackageSlug();
|
|
$stmt = $db->prepare('SELECT id FROM corpus_packages WHERE slug = ? AND is_active = 1 LIMIT 1');
|
|
$stmt->execute([$packageSlug]);
|
|
$packageId = (int)($stmt->fetchColumn() ?: 0);
|
|
if ($packageId === 0) {
|
|
return;
|
|
}
|
|
$db->prepare(
|
|
"INSERT IGNORE INTO client_corpus_subscriptions
|
|
(client_id, package_id, is_active, source, subscribed_at)
|
|
VALUES (?, ?, 1, 'dbn_dashboard', NOW())"
|
|
)->execute([$clientId, $packageId]);
|
|
}
|
|
|
|
private static function uniqueSlug(PDO $db, string $base): string
|
|
{
|
|
$base = preg_replace('/[^a-z0-9-]+/', '-', strtolower($base)) ?: 'dbn-user';
|
|
$base = trim($base, '-');
|
|
$stmt = $db->prepare('SELECT id FROM clients WHERE slug = ? LIMIT 1');
|
|
$slug = $base;
|
|
$suffix = 2;
|
|
while (true) {
|
|
$stmt->execute([$slug]);
|
|
if (!$stmt->fetch(PDO::FETCH_ASSOC)) {
|
|
return $slug;
|
|
}
|
|
$slug = $base . '-' . $suffix;
|
|
$suffix++;
|
|
}
|
|
}
|
|
|
|
private static function uniqueUsername(PDO $db, string $base): string
|
|
{
|
|
$stmt = $db->prepare('SELECT id FROM client_users WHERE username = ? LIMIT 1');
|
|
$username = $base;
|
|
$suffix = 2;
|
|
while (true) {
|
|
$stmt->execute([$username]);
|
|
if (!$stmt->fetch(PDO::FETCH_ASSOC)) {
|
|
return $username;
|
|
}
|
|
$username = $base . $suffix;
|
|
$suffix++;
|
|
}
|
|
}
|
|
}
|