changemaker.lite/api/src/services/user-provisioning/rocketchat.provisioner.ts

131 lines
4.2 KiB
TypeScript

import { createHmac } from 'crypto';
import { Prisma } from '@prisma/client';
import { prisma } from '../../config/database';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
import { rocketchatClient } from '../rocketchat.client';
import type { ServiceProvisioner, ProvisionerConfig, ProvisionResult, CMUser } from './provisioner.interface';
const ROLE_MAP: Record<string, string[]> = {
SUPER_ADMIN: ['admin'],
INFLUENCE_ADMIN: ['moderator'],
MAP_ADMIN: ['moderator'],
USER: ['user'],
TEMP: ['user'],
};
/** Deterministic password — never exposed to users, only used for RC internal auth */
function generateRCPassword(userId: string): string {
return createHmac('sha256', env.JWT_ACCESS_SECRET)
.update(`rc:${userId}`)
.digest('hex');
}
/** Safe username from email with collision avoidance suffix */
function generateUsername(email: string, suffix = 0): string {
const base = email.split('@')[0].toLowerCase().replace(/[^a-z0-9._-]/g, '');
return suffix > 0 ? `${base}${suffix}` : base;
}
class RocketChatProvisioner implements ServiceProvisioner {
readonly config: ProvisionerConfig = {
serviceKey: 'rocketchat',
displayName: 'Rocket.Chat',
featureFlag: 'enableChat',
permissionsKey: '_rcUserId',
roleMap: ROLE_MAP,
excludeRoles: [], // TEMP users get 'user' role, still provisioned for chat
};
async isAvailable(): Promise<boolean> {
return rocketchatClient.healthCheck();
}
async provision(user: CMUser): Promise<ProvisionResult> {
try {
// Check if already provisioned
let rcUser = await rocketchatClient.findUserByEmail(user.email);
if (!rcUser) {
// Create with collision-safe username
let username = generateUsername(user.email);
let suffix = 0;
const maxAttempts = 5;
while (suffix < maxAttempts) {
try {
rcUser = await rocketchatClient.createUser({
email: user.email,
name: user.name || user.email.split('@')[0],
username,
password: generateRCPassword(user.id),
roles: ROLE_MAP[user.role] || ['user'],
});
break;
} catch (err) {
if (err instanceof Error && err.message.includes('already in use')) {
suffix++;
username = generateUsername(user.email, suffix);
} else {
throw err;
}
}
}
if (!rcUser) {
return { success: false, error: 'Failed to create RC user after retries' };
}
logger.info(`RC provisioner: created user ${rcUser._id} for ${user.email}`);
}
// Persist RC user ID in permissions
const permissions = (user.permissions as Record<string, unknown>) || {};
await prisma.user.update({
where: { id: user.id },
data: {
permissions: {
...permissions,
[this.config.permissionsKey]: rcUser._id,
} as unknown as Prisma.InputJsonValue,
},
});
return { success: true, serviceUserId: rcUser._id };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.error(`RC provisioner: provision failed for ${user.email}: ${msg}`);
return { success: false, error: msg };
}
}
async syncUser(user: CMUser, serviceUserId: string): Promise<void> {
const rcRoles = ROLE_MAP[user.role] || ['user'];
await rocketchatClient.updateUser(serviceUserId, {
name: user.name || user.email.split('@')[0],
roles: rcRoles,
});
}
async deactivate(serviceUserId: string): Promise<void> {
await rocketchatClient.setUserActive(serviceUserId, false);
}
async getAuthToken(user: CMUser, serviceUserId: string): Promise<string | null> {
try {
// Sync roles on every auth token request
await this.syncUser(user, serviceUserId).catch(err => {
logger.warn('RC role sync failed during auth, continuing:', err);
});
const tokenData = await rocketchatClient.createUserToken(serviceUserId);
return tokenData.authToken;
} catch (err) {
logger.error('RC provisioner: getAuthToken failed:', err);
return null;
}
}
}
export const rocketchatProvisioner = new RocketChatProvisioner();