10 KiB
Users Backend
Purpose
users is the identity slice: it manages user accounts, their assigned role (app_role),
per-user custom permissions, organization membership, optional avatar files, and the
email-action tokens used for verification and password reset. The slice is hand-written: creating
a user (or bulk-importing users) provisions a generated temporary password and triggers an
invitation email with the login URL and credentials.
Slice Files (by layer)
- Route:
src/routes/users.ts(CRUD plusbulk-import,count,autocomplete,deleteByIds; appliescheckCrudPermissions('users')to every route). - Controller:
src/api/controllers/users.controller.ts(resolves the UI host from the requestRefererfor invitation links; handles CSV export and file upload). - Service (BLL):
src/services/users.ts(invitation + bulk-import workflow; duplicate-email and self-delete guards; delegates email sending toAuthService). - Repository (DAL):
src/db/api/users.ts(also exposes auth helpers:findBy,findProfileById,createFromAuth,updatePassword, token generation/lookup,markEmailVerified). - Model:
src/db/models/users.ts. - Shared used:
services/auth.ts(AuthService.sendInvitationEmail),db/api/shared/repository.ts,db/api/file.ts(replaceRelationFiles),db/utils.ts,shared/config.ts(config.roles,config.providers, bcrypt settings),shared/constants/roles.ts(ROLE_NAMES),shared/constants/auth.ts(EMAIL_ACTION_TOKEN_BYTES,EMAIL_ACTION_TOKEN_TTL_MS),shared/constants/database.ts(BULK_IMPORT_TIMESTAMP_STEP_MS),shared/constants/pagination.ts(resolvePagination),shared/csv.ts(toCsv),middlewares/upload.ts(processFile),middlewares/check-permissions.ts,shared/errors/validation.ts.
API
All routes are mounted under /api/users and require JWT authentication (src/index.ts). Every
route passes checkCrudPermissions('users'), requiring the permission ${METHOD}_USERS
(see permissions.md); the middleware's self-access bypass also lets a user act on their own
record when req.params.id/req.body.id equals their own id.
POST /api/users->200{ id, organizationId, temporaryPassword? }. Request body:{ data: <UserInput> }. Creates the user then sends an invitation email when mailer is configured. If mailer is not configured, the response includes the generatedtemporaryPasswordexactly once so the creator can copy it and deliver it manually.POST /api/users/bulk-import->200true. Multipart CSV upload; every row must carry anemail. Missing file raisesValidationError('importer.errors.invalidFileEmpty').PUT /api/users/:id->200true. The controller callsService.update(req.body.data, req.body.id, ...)(readsreq.body.id, notreq.params.id).DELETE /api/users/:id->200true.POST /api/users/deleteByIds->200true. Request body:{ data: string[] }.GET /api/users->200{ rows, count }. When?filetype=csv, responds with a CSV attachment of fieldsid, firstName, lastName, phoneNumber, email.GET /api/users/count->200{ rows: [], count }.GET /api/users/autocomplete->200array of{ id, label }.GET /api/users/:id->200, a single eager-loaded user record (role + permissions, custom permissions, organization).
Access Rules
- CRUD is gated by
checkCrudPermissions('users')(${METHOD}_USERS), or a matching custom per-user permission, or the self-access bypass. removeadds explicit service-level guards beyond the permission check:- A user cannot delete themselves:
currentUser.id === idraisesValidationError('iam.errors.deletingHimself'). - Only roles named
config.roles.adminorconfig.roles.super_adminmay delete a user; otherwiseValidationError('errors.forbidden.message').
- A user cannot delete themselves:
createrejects a duplicate email (iam.errors.userAlreadyExists) and a missing email (iam.errors.emailRequired).- Teacher users are seeded with
CREATE_USERS/UPDATE_USERSandCREATE_GUARDIAN_STUDENTSso they can manage student and guardian accounts from/my-class. Service-level role policy limits that access to thestudentandguardiantarget roles. Class-scope guards treat a student as in the teacher's class when eitherusers.classIdmatches or an activeclass_enrollmentsrow links the student to that class. Requested studentclassIdvalues must stay unset or match the teacher's own class. Guardian updates require an existingguardian_studentslink to a student in the teacher's class, using the same direct-class or enrollment-backed membership rule. Class-scoped user management cannot add custom permissions or permission exclusions. - Guardian-student links are handled by
guardian_students. Teachers also receiveCREATE_GUARDIAN_STUDENTS; the link service verifies class-scoped actors only linkguardianusers tostudentusers in their own class, including students whose class membership is stored only inclass_enrollments.
Tenant Scope
findAllscopes tocurrentUser.organizationIdonly when the user has a loadedorganizationsassociation and anorganizationId.globalAccessusers (currentUser.app_role.globalAccess) have the org constraint removed and read across organizations.- On
create, organization membership is set fromdata.organizationsviasetOrganizations. - On
createwithclassId, the service resolves the class and stamps the user'scampusIdand organization from the class's campus. - On
update, role/org/custom-permission associations are only changed when their respective fields are present in the input.
Data Contract
Model columns (src/db/models/users.ts): id (UUID PK), firstName, lastName, phoneNumber
(text), email (text, allowNull: false), disabled (boolean, default false), password
(text), emailVerified (boolean, default false), emailVerificationToken +
emailVerificationTokenExpiresAt, passwordResetToken + passwordResetTokenExpiresAt,
provider (text), organizationId, createdById, updatedById,
createdAt, updatedAt, deletedAt (paranoid soft-delete). A beforeCreate/beforeUpdate
hook trims email/firstName/lastName; for non-local OAuth providers beforeCreate forces
emailVerified = true and generates a random bcrypt password when none is supplied.
Associations: belongsToMany permissions as custom_permissions (and custom_permissions_filter
for list filtering) through usersCustom_permissionsPermissions; hasMany messages as
messages_sent_by; belongsTo roles as app_role; belongsTo organizations as
organizations; belongsTo classes as class; hasMany class_enrollments as
class_enrollments_student for enrollment-backed student class display and scope checks;
hasMany file as avatar; belongsTo users as createdBy/updatedBy.
On create/bulkImport, the service generates a random temporary password, hashes it before
storing it, and marks the account emailVerified = true so the invited internal user can sign in
immediately. A user created without an explicit app_role has no role and falls back to the
guest role until one is assigned (roles are assigned explicitly by the provisioning flow). The
service layer (services/users.ts) also enforces the relational role policy
(assertCanManageUserWithRole), a same-organization guard (assertSameTenant) for non-global
actors, and auto-creates the company when an owner is created (§3.3/§3.4).
List filters (findAll): id, firstName, lastName, phoneNumber, email, provider
(ILIKE); createdAtRange; disabled, emailVerified; app_role (join on id or name, |-separated);
campusId (direct campus users plus class-scoped users whose class belongs to the campus);
classId (direct class-scoped users plus students enrolled through class_enrollments);
organizations (|-separated ids); custom_permissions (join on id or name); plus
field/sort (default createdAt desc) and limit/page.
Behavior / Notes
- Invitation workflow: before persistence, the service generates a temporary password with
crypto.randomBytes(...).toString('base64url'), stores only the bcrypt hash, and retains the plaintext value only long enough to sendAuthService.sendInvitationEmail(...)after commit. The email contains the login URL, the user's email, and the temporary password. This is accepted for the internal platform workflow; users are expected to change the password from their profile. IfEmailSender.isConfiguredis false, the same plaintext temporary password is returned once fromPOST /api/users/POST /api/users/owner-with-organizationinstead of relying on email. It is still not stored outside the bcrypt hash. - Bulk import: parses the uploaded CSV (
csv-parser); requires every row to carry anemail(elseValidationError('importer.errors.userEmailMissing'));bulkCreates withignoreDuplicates: trueand staggeredcreatedAt(BULK_IMPORT_TIMESTAMP_STEP_MS); attaches avatar files per row. Invitation emails are sent only for rows returned by the repository as created, so duplicate/ignored CSV rows do not receive unsaved temporary passwords. - All mutations run inside a manual Sequelize transaction (commit on success, rollback on error).
findByreturns a per-requestAuthenticatedUser(role + its permissions, custom permissions, organization) used by authentication/authorization;findProfileByIdreturns the trimmed profile DTO forGET /me.
Tests
src/services/users.test.tscovers temporary-password provisioning, invite delivery for created users only, password hashing on admin updates, and teacher create/update access for student and guardian accounts, including enrollment-backed students that do not have a directusers.classId.src/services/guardian_students.test.tscovers class-scoped guardian-student linking for enrollment-backed students and rejects links outside the teacher's class.src/services/shared/role-policy.test.tscovers the role-management matrix, including teacher limits to student and guardian accounts.src/db/seeders/user-roles.test.tscovers the seeded teacher roster-management permission grants.
Related
- Backend slices:
permissions.md(the${METHOD}_USERSgate and thecustom_permissions/app_role.permissionsmodel consumed bycheck-permissions.ts); therolesentity (app_role; a user created without a role falls back toguest). - Frontend / auth:
auth-profile.md(the profile DTO produced byfindProfileById, plus the invitation/password-reset email flow shared withAuthService).