617 lines
18 KiB
TypeScript

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<typeof UsersDBApi.create>[0]['data'];
type UpdateData = Parameters<typeof UsersDBApi.update>[1];
type ListFilter = Parameters<typeof UsersDBApi.findAll>[0];
type BulkRow = Parameters<typeof UsersDBApi.bulkImport>[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<string> {
return bcrypt.hash(password, config.bcrypt.saltRounds);
}
async function setGeneratedTemporaryPassword(
data: CreateData | BulkRow,
): Promise<InvitationCredential> {
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<void> {
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<BulkRow[]> {
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<void> {
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<boolean> {
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<boolean> {
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<void> {
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<CreateData, 'app_role' | 'organizations'>,
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;