import crypto from 'crypto'; import { PassThrough } from 'stream'; import csv from 'csv-parser'; import bcrypt from 'bcrypt'; import type { Transaction } from 'sequelize'; import db from '@/db/models'; import UsersDBApi from '@/db/api/users'; import OrganizationsDBApi from '@/db/api/organizations'; import ValidationError from '@/shared/errors/validation'; import AuthService from '@/services/auth'; import { assertCanAssignUserRole, assertCanCreateUserWithRole, assertCanDeleteUserWithRole, assertCanUpdateUserWithRole, } from '@/services/shared/role-policy'; import { getOrganizationId, hasGlobalAccess } from '@/services/shared/access'; import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles'; import config from '@/shared/config'; import type { AuthenticatedUser, CurrentUser, FileInput } from '@/db/api/types'; /** * A non-global actor may only manage users in its own organization. Cross-tenant * targets are reported as not-found so existence is not leaked. */ function assertSameTenant( currentUser: CurrentUser | undefined, target: AuthenticatedUser, ): void { if (hasGlobalAccess(currentUser)) return; const actorOrg = getOrganizationId(currentUser); if (!actorOrg || actorOrg !== target.organizationId) { throw new ValidationError('iam.errors.userNotFound'); } } type CreateData = Parameters[0]['data']; type UpdateData = Parameters[1]; type ListFilter = Parameters[0]; type BulkRow = Parameters[0][number]; type InvitationCredential = { email: string; temporaryPassword: string; }; interface CreateUserResult { id: string; organizationId: string | null; temporaryPassword?: string; } const TEMPORARY_PASSWORD_BYTES = 18; function generateTemporaryPassword(): string { return crypto.randomBytes(TEMPORARY_PASSWORD_BYTES).toString('base64url'); } async function hashPassword(password: string): Promise { return bcrypt.hash(password, config.bcrypt.saltRounds); } async function setGeneratedTemporaryPassword( data: CreateData | BulkRow, ): Promise { const email = String(data.email ?? ''); const temporaryPassword = generateTemporaryPassword(); data.password = await hashPassword(temporaryPassword); data.emailVerified = true; return { email, temporaryPassword }; } async function hashIncomingPassword(data: UpdateData): Promise { if (typeof data.password === 'string') { data.password = await hashPassword(data.password); } } /** Parses an uploaded CSV buffer into bulk-import rows. */ function parseCsvRows(fileBuffer: Buffer): Promise { const bufferStream = new PassThrough(); const results: BulkRow[] = []; bufferStream.end(Buffer.from(fileBuffer)); return new Promise((resolve, reject) => { bufferStream .pipe(csv()) .on('data', (row: BulkRow) => results.push(row)) .on('end', () => resolve(results)) .on('error', reject); }); } async function normalizeTenantAssignment( data: CreateData | UpdateData, transaction: Transaction, ): Promise { if (data.classId) { const cls = await db.classes.findByPk(data.classId, { attributes: ['id', 'campusId', 'organizationId'], transaction, }); if (!cls) throw new ValidationError('classesNotFound'); data.campusId = cls.campusId; data.organizations = cls.organizationId ?? data.organizations; } if (data.campusId) { const campus = await db.campuses.findByPk(data.campusId, { attributes: ['id', 'organizationId', 'schoolId'], transaction, }); if (!campus) throw new ValidationError('campusesNotFound'); data.schoolId = campus.schoolId; data.organizations = campus.organizationId ?? data.organizations; } if (data.schoolId) { const school = await db.schools.findByPk(data.schoolId, { attributes: ['id', 'organizationId'], transaction, }); if (!school) throw new ValidationError('schoolsNotFound'); data.organizations = school.organizationId ?? data.organizations; } } function normalizeAvatarInput(data: CreateData | UpdateData): void { const rawAvatar = (data as { avatar?: unknown }).avatar; if (rawAvatar === null) { data.avatar = []; return; } if (typeof rawAvatar !== 'string') { return; } if (rawAvatar.length === 0) { data.avatar = []; return; } const privateUrl = rawAvatar; const name = privateUrl.split('/').pop() || 'avatar'; data.avatar = [{ new: true, name, privateUrl, publicUrl: privateUrl }] satisfies FileInput[]; } function assertNoCustomPermissionChanges(data: CreateData | UpdateData): void { if ( data.custom_permissions !== undefined || data.custom_permissions_filter !== undefined ) { throw new ValidationError('auth.forbidden'); } } function assertClassScopedUserCreate( currentUser: CurrentUser | undefined, targetRole: string | null | undefined, data: CreateData, ): void { if (currentUser?.app_role?.scope !== ROLE_SCOPES.CLASS) { return; } if (!currentUser.classId) { throw new ValidationError('auth.forbidden'); } if (targetRole === ROLE_NAMES.STUDENT && data.classId === currentUser.classId) { assertNoCustomPermissionChanges(data); return; } if ( targetRole === ROLE_NAMES.GUARDIAN && data.classId === undefined && data.campusId === undefined && data.schoolId === undefined && (data.organizations === undefined || data.organizations === null) ) { assertNoCustomPermissionChanges(data); return; } throw new ValidationError('auth.forbidden'); } async function isGuardianLinkedToCurrentClass( guardianId: string, classId: string, transaction: Transaction, ): Promise { const directClassLink = await db.guardian_students.findOne({ where: { guardianId }, include: [ { model: db.users, as: 'student', attributes: ['id', 'classId'], where: { classId }, required: true, }, ], transaction, }); if (directClassLink) { return true; } const link = await db.guardian_students.findOne({ where: { guardianId }, include: [ { model: db.users, as: 'student', attributes: ['id'], required: true, include: [ { model: db.class_enrollments, as: 'class_enrollments_student', attributes: ['id'], where: { classId }, required: true, }, ], }, ], transaction, }); return Boolean(link); } async function isStudentInClass( studentId: string, classId: string, transaction: Transaction, ): Promise { const enrollment = await db.class_enrollments.findOne({ where: { studentId, classId }, attributes: ['id'], transaction, }); return Boolean(enrollment); } async function assertClassScopedUserUpdate( currentUser: CurrentUser | undefined, target: AuthenticatedUser, nextRole: string | null | undefined, data: UpdateData, transaction: Transaction, ): Promise { if (currentUser?.app_role?.scope !== ROLE_SCOPES.CLASS) { return; } if (!currentUser.classId) { throw new ValidationError('auth.forbidden'); } const targetRole = target.app_role?.name ?? null; const targetClassId = target.classId ?? null; const requestedClassId = data.classId !== undefined ? data.classId : targetClassId; const targetIsInCurrentClass = targetRole === ROLE_NAMES.STUDENT && ( targetClassId === currentUser.classId || await isStudentInClass(target.id, currentUser.classId, transaction) ); const requestedClassIsAllowed = requestedClassId === null || requestedClassId === currentUser.classId; if ( targetRole === ROLE_NAMES.STUDENT && nextRole === ROLE_NAMES.STUDENT && targetIsInCurrentClass && requestedClassIsAllowed ) { assertNoCustomPermissionChanges(data); return; } if ( targetRole === ROLE_NAMES.GUARDIAN && nextRole === ROLE_NAMES.GUARDIAN && data.classId === undefined && data.campusId === undefined && data.schoolId === undefined && data.organizations === undefined && await isGuardianLinkedToCurrentClass(target.id, currentUser.classId, transaction) ) { assertNoCustomPermissionChanges(data); return; } assertNoCustomPermissionChanges(data); throw new ValidationError('auth.forbidden'); } class UsersService { static async create( data: CreateData, currentUser?: CurrentUser, sendInvitationEmails = true, host?: string, ) { const transaction = await db.sequelize.transaction(); const globalAccess = currentUser?.app_role?.globalAccess ?? false; const email = data.email; const invitationsToSend: InvitationCredential[] = []; let createdId: string; let createdOrganizationId: string | null = null; try { if (email) { const user = await UsersDBApi.findBy({ email }, { transaction }); if (user) { throw new ValidationError('iam.errors.userAlreadyExists'); } // Relational policy: the actor must be allowed to assign this role. if (data.app_role) { const newRole = await db.roles.findByPk(data.app_role, { transaction, }); assertCanCreateUserWithRole(currentUser, newRole?.name ?? null); assertClassScopedUserCreate(currentUser, newRole?.name ?? null, data); // ยง3.4 provisioning: creating an `owner` auto-creates the company and // links the owner to it. The org starts minimal; the owner fills it in. if (newRole?.name === ROLE_NAMES.OWNER && !data.organizations) { const organization = await OrganizationsDBApi.create( {}, { currentUser, transaction }, ); data.organizations = organization.id; createdOrganizationId = organization.id; } } else { assertClassScopedUserCreate(currentUser, null, data); } // Non-global actors create users only within their own organization. if (!globalAccess && !data.organizations) { const actorOrg = getOrganizationId(currentUser); if (actorOrg) data.organizations = actorOrg; } await normalizeTenantAssignment(data, transaction); normalizeAvatarInput(data); const invitation = await setGeneratedTemporaryPassword(data); const created = await UsersDBApi.create({ data }, globalAccess, { currentUser, transaction, }); createdId = created.id; invitationsToSend.push(invitation); } else { throw new ValidationError('iam.errors.emailRequired'); } await transaction.commit(); } catch (error) { await transaction.rollback(); throw error; } if (invitationsToSend.length && sendInvitationEmails && AuthService.isEmailConfigured()) { await Promise.all( invitationsToSend.map((invitation) => AuthService.sendInvitationEmail( invitation.email, invitation.temporaryPassword, host, ), ), ); } const result: CreateUserResult = { id: createdId, organizationId: createdOrganizationId, }; const createdInvitation = invitationsToSend[0]; if (createdInvitation && !AuthService.isEmailConfigured()) { result.temporaryPassword = createdInvitation.temporaryPassword; } return result; } static async createOwnerWithOrganization( data: Omit, currentUser?: CurrentUser, sendInvitationEmails = true, host?: string, ) { const ownerRole = await db.roles.findOne({ where: { name: ROLE_NAMES.OWNER }, }); if (!ownerRole?.id) { throw new ValidationError('iam.errors.roleNotFound'); } return UsersService.create( { ...data, app_role: ownerRole.id, organizations: null, }, currentUser, sendInvitationEmails, host, ); } static async bulkImport( fileBuffer: Buffer, currentUser?: CurrentUser, sendInvitationEmails = true, host?: string, ) { const rows = await parseCsvRows(fileBuffer); const invitationsToSend: InvitationCredential[] = []; const transaction = await db.sequelize.transaction(); try { const hasAllEmails = rows.every((row) => row.email); if (!hasAllEmails) { throw new ValidationError('importer.errors.userEmailMissing'); } for (const row of rows) { if (!row.app_role) continue; const role = await db.roles.findByPk(row.app_role, { transaction }); assertCanCreateUserWithRole(currentUser, role?.name ?? null); } for (const row of rows) { invitationsToSend.push(await setGeneratedTemporaryPassword(row)); } const createdUsers = await UsersDBApi.bulkImport(rows, { transaction, ignoreDuplicates: true, validate: true, currentUser, }); const credentialsByEmail = new Map( invitationsToSend.map((invitation) => [invitation.email, invitation]), ); invitationsToSend.splice( 0, invitationsToSend.length, ...createdUsers .map((user) => credentialsByEmail.get(user.email)) .filter((invitation): invitation is InvitationCredential => Boolean(invitation)), ); await transaction.commit(); } catch (error) { await transaction.rollback(); throw error; } if (invitationsToSend.length && sendInvitationEmails && AuthService.isEmailConfigured()) { await Promise.all( invitationsToSend.map((invitation) => AuthService.sendInvitationEmail( invitation.email, invitation.temporaryPassword, host, ), ), ); } } static async update(data: UpdateData, id: string, currentUser?: CurrentUser) { const transaction = await db.sequelize.transaction(); const globalAccess = currentUser?.app_role?.globalAccess ?? false; try { const users = await UsersDBApi.findBy({ id }, { transaction }); if (!users) { throw new ValidationError('iam.errors.userNotFound'); } // Tenant + relational policy: the target must be in the actor's org, and // the actor must be allowed to manage the target's current role (and the // new role if it is being reassigned). assertSameTenant(currentUser, users); assertCanUpdateUserWithRole(currentUser, users.app_role?.name ?? null); const newRole = data.app_role ? await db.roles.findByPk(data.app_role, { transaction }) : null; if (data.app_role !== undefined) { assertCanAssignUserRole( currentUser, users.app_role?.name ?? null, newRole?.name ?? null, ); } await normalizeTenantAssignment(data, transaction); await assertClassScopedUserUpdate( currentUser, users, data.app_role !== undefined ? newRole?.name ?? null : users.app_role?.name ?? null, data, transaction, ); normalizeAvatarInput(data); await hashIncomingPassword(data); const updatedUser = await UsersDBApi.update(id, data, globalAccess, { currentUser, transaction, }); await transaction.commit(); return updatedUser; } catch (error) { await transaction.rollback(); throw error; } } static async remove(id: string, currentUser?: CurrentUser) { const transaction = await db.sequelize.transaction(); try { if (currentUser?.id === id) { throw new ValidationError('iam.errors.deletingHimself'); } const target = await UsersDBApi.findBy({ id }, { transaction }); if (!target) { throw new ValidationError('iam.errors.userNotFound'); } // Tenant + relational policy: the target must be in the actor's org, and // the actor must be allowed to delete the target's role (e.g. a // superintendent cannot delete an owner or another superintendent). assertSameTenant(currentUser, target); assertCanDeleteUserWithRole(currentUser, target.app_role?.name ?? null); await UsersDBApi.remove(id, { currentUser, transaction }); await transaction.commit(); } catch (error) { await transaction.rollback(); throw error; } } static async deleteByIds(ids: string[], currentUser?: CurrentUser) { const transaction = await db.sequelize.transaction(); try { if (currentUser?.id && ids.includes(currentUser.id)) { throw new ValidationError('iam.errors.deletingHimself'); } for (const id of ids) { const target = await UsersDBApi.findBy({ id }, { transaction }); if (target) { assertSameTenant(currentUser, target); assertCanDeleteUserWithRole( currentUser, target.app_role?.name ?? null, ); } } await UsersDBApi.deleteByIds(ids, { currentUser, transaction }); await transaction.commit(); } catch (error) { await transaction.rollback(); throw error; } } static list( filter: ListFilter, globalAccess: boolean, currentUser?: CurrentUser, ) { return UsersDBApi.findAll(filter, globalAccess, { currentUser }); } static count( filter: ListFilter, globalAccess: boolean, currentUser?: CurrentUser, ) { return UsersDBApi.findAll(filter, globalAccess, { countOnly: true, currentUser, }); } static autocomplete( query: string | undefined, limit: number | undefined, offset: number | undefined, globalAccess: boolean, organizationId?: string, ) { return UsersDBApi.findAllAutocomplete( query, limit, offset, globalAccess, organizationId, ); } static findById(id: string) { return UsersDBApi.findBy({ id }); } } export default UsersService;