Something went wrong. Try again.
Mirrored from GitHub github.com/roostorg/coop
Something went wrong. Try again.
17 kB · 515 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516/* eslint-disable max-lines */import crypto from 'node:crypto';import { type Kysely } from 'kysely';
import type { Dependencies } from '../../iocContainer/index.js';import { inject } from '../../iocContainer/utils.js';import { makeNotFoundError, makeUnauthorizedError,} from '../../utils/errors.js';import { makeKyselyTransactionWithRetry } from '../../utils/kyselyTransactionWithRetry.js';import { asyncRandomBytes } from '../../utils/misc.js';import { HOUR_MS } from '../../utils/time.js';import { CoopEmailAddress } from '../sendEmailService/sendEmailService.js';import type { MrtChartConfig } from './dbTypes.js';import type { UserManagementPg } from './index.js';import { UserPermission, type Invoker, type UserRole,} from './permissioning.js';import { deleteSessionsForUser } from './sessionPersistence.js';import { hashPassword } from './utils.js';
class UserManagementService { constructor( private readonly pgQuery: Kysely<UserManagementPg>, private readonly sendEmail: Dependencies['sendEmail'], private readonly configService: Dependencies['ConfigService'], ) {}
// Returns null for orgs without seeded role rows; the read path falls // back to UserPermissionsForRole defaults. async #lookupSystemRoleId(opts: { orgId: string; role: UserRole; }): Promise<string | null> { const row = await this.pgQuery .selectFrom('public.roles') .select('id') .where('org_id', '=', opts.orgId) .where('key', '=', opts.role) .where('is_system', '=', true) .executeTakeFirst(); return row?.id ?? null; }
async getUserInterfaceSettings(opts: { userId: string; orgId: string }) { const { userId, orgId } = opts; const row = await this.pgQuery .selectFrom('user_management_service.user_interface_settings') .selectAll() .where('user_id', '=', userId) .executeTakeFirst();
if ( row && row.moderator_safety_grayscale != null && row.moderator_safety_blur_level != null && row.moderator_safety_mute_video != null && row.moderator_safety_sepia != null ) { // If all the user's settings have been set, just return them return { moderatorSafetyGrayscale: row.moderator_safety_grayscale, moderatorSafetyBlurLevel: row.moderator_safety_blur_level, moderatorSafetyMuteVideo: row.moderator_safety_mute_video, moderatorSafetySepia: row.moderator_safety_sepia, mrtChartConfigurations: row.mrt_chart_configurations ?? [], }; }
// Otherwise, merge the user's settings with the org's default settings, // prioritizing the user's settings over the defaults. const orgDefaults = await this.getOrgDefaultUserInterfaceSettings(orgId); return { moderatorSafetyGrayscale: row?.moderator_safety_grayscale ?? orgDefaults.moderatorSafetyGrayscale, moderatorSafetySepia: row?.moderator_safety_sepia ?? orgDefaults.moderatorSafetySepia, moderatorSafetyBlurLevel: row?.moderator_safety_blur_level ?? orgDefaults.moderatorSafetyBlurLevel, moderatorSafetyMuteVideo: row?.moderator_safety_mute_video ?? orgDefaults.moderatorSafetyMuteVideo, mrtChartConfigurations: row?.mrt_chart_configurations ?? [], }; }
async getInviteUserToken(opts: { token: string }) { const { token } = opts; const tokenRow = await this.pgQuery .selectFrom('public.invite_user_tokens') .selectAll() .where('token', '=', token) .executeTakeFirst();
if (tokenRow == null) { return null; }
const orgSettings = await this.pgQuery .selectFrom('public.org_settings') .select('saml_enabled') .where('org_id', '=', tokenRow.org_id) .executeTakeFirst();
return { ...tokenRow, orgId: tokenRow.org_id, createdAt: tokenRow.created_at, samlEnabled: orgSettings?.saml_enabled ?? false, }; }
async createInviteUserToken(opts: { email: string; role: UserRole; orgId: string; }) { const { email, role, orgId } = opts;
const roleId = await this.#lookupSystemRoleId({ orgId, role });
const token = (await asyncRandomBytes(32)).toString('hex'); await this.pgQuery .insertInto('public.invite_user_tokens') .values({ token, email, role, role_id: roleId, org_id: orgId }) .execute(); return token; }
async getPendingInvites( orgId: string, ): Promise< Array<{ id: string; email: string; role: UserRole; createdAt: string }> > { const result = await this.pgQuery .selectFrom('public.invite_user_tokens') .selectAll() .where('org_id', '=', orgId) .orderBy('created_at', 'desc') .execute();
return result.map((row) => ({ id: row.id, email: row.email, role: row.role, createdAt: row.created_at.toISOString(), })); }
async deleteInvite(inviteId: string, orgId: string): Promise<boolean> { const result = await this.pgQuery .deleteFrom('public.invite_user_tokens') .where('id', '=', inviteId) .where('org_id', '=', orgId) .execute();
return result.length > 0; }
async upsertUserInterfaceSettings(input: { userId: string; userInterfaceSettings: { moderatorSafetySettings?: { moderatorSafetyMuteVideo: boolean; moderatorSafetyGrayscale: boolean; moderatorSafetyBlurLevel: number; moderatorSafetySepia: boolean; }; mrtChartConfigurations?: readonly MrtChartConfig[]; }; }) { const { userId, userInterfaceSettings } = input; const { moderatorSafetySettings, mrtChartConfigurations } = userInterfaceSettings;
const dbFormattedInterfaceSettings = { ...(moderatorSafetySettings ? { moderator_safety_grayscale: moderatorSafetySettings.moderatorSafetyGrayscale, moderator_safety_sepia: moderatorSafetySettings.moderatorSafetySepia, moderator_safety_blur_level: moderatorSafetySettings.moderatorSafetyBlurLevel, moderator_safety_mute_video: moderatorSafetySettings.moderatorSafetyMuteVideo, } : {}), ...(mrtChartConfigurations ? { mrt_chart_configurations: [...mrtChartConfigurations], } : {}), };
let query = this.pgQuery .insertInto('user_management_service.user_interface_settings') .values({ user_id: userId, ...dbFormattedInterfaceSettings, });
// Only add onConflict if there are fields to update if (Object.keys(dbFormattedInterfaceSettings).length > 0) { query = query.onConflict((oc) => oc.column('user_id').doUpdateSet({ ...dbFormattedInterfaceSettings, }), ); } else { // If no update fields, just do nothing on conflict query = query.onConflict((oc) => oc.column('user_id').doNothing()); }
return query.returningAll().execute(); }
async getOrgDefaultUserInterfaceSettings(orgId: string) { const row = await this.pgQuery .selectFrom('user_management_service.org_default_user_interface_settings') .selectAll() .where('org_id', '=', orgId) // Every org should have a default interface settings row .executeTakeFirstOrThrow();
return { moderatorSafetyGrayscale: row.moderator_safety_grayscale, moderatorSafetySepia: row.moderator_safety_sepia, moderatorSafetyBlurLevel: row.moderator_safety_blur_level, moderatorSafetyMuteVideo: row.moderator_safety_mute_video, }; }
async upsertOrgDefaultUserInterfaceSettings(opts: { orgId: string; // If you don't provide these values, they will be set to the default values // configured on the pg table definition moderatorSafetyGrayscale?: boolean; moderatorSafetySepia?: boolean; moderatorSafetyBlurLevel?: number; moderatorSafetyMuteVideo?: boolean; }) { const { orgId, moderatorSafetyGrayscale, moderatorSafetySepia, moderatorSafetyBlurLevel, moderatorSafetyMuteVideo, } = opts; const updateFields = { ...(moderatorSafetyGrayscale !== undefined ? { moderator_safety_grayscale: moderatorSafetyGrayscale } : {}), ...(moderatorSafetySepia !== undefined ? { moderator_safety_sepia: moderatorSafetySepia } : {}), ...(moderatorSafetyBlurLevel !== undefined ? { moderator_safety_blur_level: moderatorSafetyBlurLevel } : {}), ...(moderatorSafetyMuteVideo !== undefined ? { moderator_safety_mute_video: moderatorSafetyMuteVideo } : {}), };
let query = this.pgQuery .insertInto('user_management_service.org_default_user_interface_settings') .values([ { org_id: orgId, moderator_safety_grayscale: moderatorSafetyGrayscale, moderator_safety_sepia: moderatorSafetySepia, moderator_safety_blur_level: moderatorSafetyBlurLevel, moderator_safety_mute_video: moderatorSafetyMuteVideo, }, ]);
// Only add onConflict if there are fields to update if (Object.keys(updateFields).length > 0) { query = query.onConflict((oc) => // Explicitly check for undefined because these values are booleans and // numbers, so they can be falsey oc.column('org_id').doUpdateSet(updateFields), ); } else { // If no update fields, just do nothing on conflict query = query.onConflict((oc) => oc.column('org_id').doNothing()); }
await query.execute(); }
async updateUserRole(input: { userId: string; newRole: UserRole; orgId: string; invoker: Invoker; }): Promise<void> { const { userId, newRole, orgId, invoker } = input; if (orgId !== invoker.orgId) { throw makeUnauthorizedError( 'User does not have permission to change roles in another org', { shouldErrorSpan: true }, ); }
if (!invoker.permissions.includes(UserPermission.MANAGE_USERS)) { throw makeUnauthorizedError( 'User does not have permission to change roles', { shouldErrorSpan: true }, ); }
const newRoleId = await this.#lookupSystemRoleId({ orgId: invoker.orgId, role: newRole, });
const result = await this.pgQuery .updateTable('public.users') .set({ role: newRole, role_id: newRoleId }) .where('id', '=', userId) .where('org_id', '=', invoker.orgId) .executeTakeFirst();
if (result.numUpdatedRows === 0n) { throw makeNotFoundError('User not found', { shouldErrorSpan: true }); } }
/** * NB: this function is a no-op if the email does not exist in the database. * However, in that case, we do return earlier than if the email had been * found, so callers should NOT await this function, and return to the end * user before the call completes in order to prevent timing attacks. */ async sendPasswordResetEmail(opts: { email: string }) { const { email } = opts;
const existingUser = await this.pgQuery .selectFrom('public.users') .select(['id as userId', 'org_id as orgId']) .where('email', '=', email) .executeTakeFirst();
if (existingUser == null) { return; }
const { userId, orgId } = existingUser; const token = await this.#createPasswordResetToken({ userId, orgId });
const url = new URL(`${this.configService.uiUrl}/reset_password/` + token); const msg = { to: email, from: CoopEmailAddress.NoReply, subject: '[Coop] Reset your password', html: `You recently indicated that you forgot your Coop password. Click on <a href='${url.href}'>this link</a> to create a new password. The link expires in 1 hour, so please make sure to sign up soon. <br /><br /> Best,<br /> Coop Support Team`, };
await this.sendEmail(msg); }
/** * Generate a password reset token for a specific user (for admin use). * Returns the raw token that can be shared with the user. * Also sends an email to the user using the standard password reset flow. */ async generatePasswordResetTokenForUser(opts: { userId: string; invokerOrgId: string; }) { const { userId, invokerOrgId } = opts;
const existingUser = await this.pgQuery .selectFrom('public.users') .select(['email', 'org_id as orgId']) .where('id', '=', userId) .executeTakeFirst();
if (existingUser == null) { throw makeNotFoundError('User not found', { shouldErrorSpan: true }); }
// Security check: ensure admin can only reset passwords for users in their own org if (existingUser.orgId !== invokerOrgId) { throw makeUnauthorizedError( 'You can only reset passwords for users in your organization', { shouldErrorSpan: true }, ); }
const { email } = existingUser;
const token = await this.#createPasswordResetToken({ userId, orgId: existingUser.orgId, });
// Send email using the standard flow (will be no-op if SendGrid not configured) const url = new URL(`${this.configService.uiUrl}/reset_password/` + token); const msg = { to: email, from: CoopEmailAddress.NoReply, subject: '[Coop] Reset your password', html: `Your organization administrator has initiated a password reset for your account. Click on <a href='${url.href}'>this link</a> to create a new password. The link expires in 1 hour, so please make sure to reset your password soon. <br /><br /> Best,<br /> Coop Support Team`, };
await this.sendEmail(msg);
return token; }
async #createPasswordResetToken(opts: { userId: string; orgId: string }) { const { userId, orgId } = opts; const token = (await asyncRandomBytes(32)).toString('hex'); const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
// delete tokens for the user await this.pgQuery .deleteFrom('user_management_service.password_reset_tokens') .where('user_id', '=', userId) .execute();
// create new token await this.pgQuery .insertInto('user_management_service.password_reset_tokens') .values({ user_id: userId, org_id: orgId, hashed_token: hashedToken, created_at: new Date(), }) .execute();
return token; }
/** * NB: we use a hashed token instead of comparing the token directly to * prevent timing attacks (by 'removing' any attempts at direct manipulation * of the input via the hash function). If we were not hashing the token, we * would be vulnerable to timing attacks because an attacker could measure the * time to compare the token and use that information to iteratively guess the * token. */ async resetPasswordForToken(opts: { token: string; newPassword: string }) { const { token, newPassword } = opts;
const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
// Step 1: Validate token const fetchedToken = await this.pgQuery .selectFrom('user_management_service.password_reset_tokens') .selectAll() .where('hashed_token', '=', hashedToken) .executeTakeFirst();
if (fetchedToken == null) { return; }
// NB: Tokens expire one hour after creation if (Date.now() - new Date(fetchedToken.created_at).getTime() > HOUR_MS) { return; }
const hashedPassword = await hashPassword(newPassword); // Atomic: a committed password change must not leave the user's sessions // or reset tokens alive. await makeKyselyTransactionWithRetry(this.pgQuery)(async (trx) => { await trx .updateTable('public.users') .set({ password: hashedPassword }) .where('id', '=', fetchedToken.user_id) .execute(); await deleteSessionsForUser(trx, fetchedToken.user_id); await trx .deleteFrom('user_management_service.password_reset_tokens') .where('user_id', '=', fetchedToken.user_id) .execute(); }); }
async getUsersForOrg(orgId: string) { return this.pgQuery .selectFrom('public.users') .select([ 'id', 'email', 'first_name as firstName', 'last_name as lastName', 'role', ]) .where('org_id', '=', orgId) .where('rejected_by_admin', '=', false) .where('approved_by_admin', '=', true) .execute(); }}
export default inject( ['KyselyPg', 'sendEmail', 'ConfigService'], UserManagementService,);export { type UserManagementService };