617 lines
18 KiB
TypeScript
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;
|