40227-vm/backend/src/db/seeders/20200430130760-user-roles.ts
2026-06-22 19:03:37 +02:00

350 lines
12 KiB
TypeScript

import { v4 as uuid } from 'uuid';
import { QueryTypes, type CreationAttributes, type QueryInterface } from 'sequelize';
import {
ROLE_DEFINITIONS,
ROLE_NAMES,
type RoleName,
} from '@/shared/constants/roles';
import { SEED_ALL_USERS } from '@/shared/constants/seed-fixtures';
import {
MODULE_READ_ALL_STAFF,
MODULE_READ_INSTRUCTIONAL,
MODULE_READ_PARENT_COMM,
MODULE_READ_EXTERNAL,
MODULE_READ_DIRECTOR,
MODULE_ACTIONS,
MODULE_PERMISSIONS,
} from '@/shared/constants/product-permissions';
import type { Roles } from '@/db/models/roles';
import type { Permissions } from '@/db/models/permissions';
/**
* Seeds the first-class roles (Workstream 3 §3.1), the permission catalog,
* the role→permission preset matrix (§3.2), and assigns roles to the seeded
* users. Pre-launch, this fully replaces the old generated role set.
*
* Enforcement model:
* - `super_admin` keeps the global-scope bypass for platform recovery/admin.
* - `system_admin` keeps global scope reach but is permission-driven like the
* tenant roles, so the role receives an explicit full permission matrix.
* - `owner` / `superintendent` / `principal` / `director` get full-scope
* baseline permissions, constrained at runtime by scope/tenant rules.
* - `office_manager` / `teacher` / `support_staff` get read-only entity access.
* - `student` / `guardian` / `guest` get no entity CRUD permissions; their
* access comes from explicit product-feature permissions.
*/
export const PERMISSION_ENTITIES = [
'users', 'roles', 'permissions', 'organizations', 'schools', 'campuses',
'academic_years', 'grades', 'subjects',
'classes', 'class_enrollments', 'class_subjects', 'guardian_students', 'timetables',
'timetable_periods', 'attendance_sessions', 'attendance_records',
'assessments', 'assessment_results', 'messages',
'message_recipients', 'policy_documents',
];
export const CRUD_VERBS = ['CREATE', 'READ', 'UPDATE', 'DELETE'] as const;
export const EXTRA_PERMISSIONS = ['READ_API_DOCS', 'CREATE_SEARCH'] as const;
export const FULL_ACCESS_EXCLUDED_FOR_ALL = ['READ_PARENT_COMM'] as const;
export const FULL_ACCESS_EXCLUDED_FOR_NON_CAMPUS_STAFF = [
'ACK_POLICY',
] as const;
/** Roles granted every permission (full CRUD within their tenant/scope). */
export const FULL_ACCESS_ROLES: readonly RoleName[] = [
ROLE_NAMES.OWNER,
ROLE_NAMES.SUPERINTENDENT,
// School head: full access, constrained to the school by tenant scoping.
ROLE_NAMES.PRINCIPAL,
ROLE_NAMES.DIRECTOR,
];
/** Roles granted read-only access to tenant resources. */
export const READ_ONLY_ROLES: readonly RoleName[] = [
// Registrar: the Principal's read-only/audit assistant (school-wide visibility).
ROLE_NAMES.REGISTRAR,
ROLE_NAMES.OFFICE_MANAGER,
ROLE_NAMES.TEACHER,
ROLE_NAMES.SUPPORT_STAFF,
];
/** External (non-staff) roles. */
export const EXTERNAL_ROLES: readonly RoleName[] = [
ROLE_NAMES.STUDENT,
ROLE_NAMES.GUARDIAN,
];
// Product module/page permissions (§3.2) come from the shared single source
// `shared/constants/product-permissions.ts`, which the feature routes also use
// to enforce them — so granted names and checked names never drift.
/**
* Per-role product-feature grants for the non-global, non-full-access roles.
* `office_manager` excludes instructional tools but includes parent comms and may fill
* attendance; `teacher` includes instructional tools + parent comms; both
* `teacher` and `support_staff` take quizzes / leave read receipts;
* `student` gets external pages; `guardian` gets external pages plus parent comms.
*/
export const MODULE_PERMISSIONS_BY_ROLE: Partial<Record<RoleName, readonly string[]>> = {
// Registrar: read every product surface across the school for audit, and
// complete required staff safety training, but no operational write actions.
[ROLE_NAMES.REGISTRAR]: [
...MODULE_READ_ALL_STAFF,
...MODULE_READ_INSTRUCTIONAL,
...MODULE_READ_EXTERNAL,
...MODULE_READ_DIRECTOR,
'READ_STAFF_ATTENDANCE_REPORTS',
'READ_SAFETY_QUIZ_REPORTS',
'READ_PERSONALITY_REPORTS',
'READ_ZONE_CHECKIN_REPORTS',
'READ_POLICY_ACKNOWLEDGMENT_REPORTS',
'TAKE_QUIZ',
'ZONE_CHECKIN',
'READ_AUDIO_FILES',
],
[ROLE_NAMES.OFFICE_MANAGER]: [
...MODULE_READ_ALL_STAFF,
...MODULE_READ_PARENT_COMM,
...MODULE_READ_EXTERNAL,
...MODULE_ACTIONS,
'MANAGE_ESA_FUNDING_CONTENT',
'READ_AUDIO_FILES', 'MANAGE_AUDIO_FILES',
],
[ROLE_NAMES.TEACHER]: [
...MODULE_READ_ALL_STAFF,
...MODULE_READ_INSTRUCTIONAL,
...MODULE_READ_PARENT_COMM,
...MODULE_READ_EXTERNAL,
// Teacher fills their class's attendance (rolls up to campus/school/org).
'FILL_ATTENDANCE',
'TAKE_QUIZ', 'ACK_READ_RECEIPT', 'ACK_POLICY', 'ZONE_CHECKIN',
'READ_AUDIO_FILES', 'MANAGE_AUDIO_FILES',
],
[ROLE_NAMES.SUPPORT_STAFF]: [
...MODULE_READ_ALL_STAFF,
...MODULE_READ_INSTRUCTIONAL,
...MODULE_READ_EXTERNAL,
'TAKE_QUIZ', 'ACK_READ_RECEIPT', 'ACK_POLICY', 'ZONE_CHECKIN',
'READ_AUDIO_FILES',
],
[ROLE_NAMES.STUDENT]: [...MODULE_READ_EXTERNAL],
// Guardians communicate with school staff via Parent Communication / Messages.
[ROLE_NAMES.GUARDIAN]: [...MODULE_READ_EXTERNAL, ...MODULE_READ_PARENT_COMM],
};
export function buildEntityPermissionNames(): readonly string[] {
const names: string[] = [];
for (const entity of PERMISSION_ENTITIES) {
for (const verb of CRUD_VERBS) {
names.push(`${verb}_${entity.toUpperCase()}`);
}
}
return names;
}
function uniquePermissionNames(names: readonly string[]): readonly string[] {
return [...new Set(names)];
}
export function buildSeededPermissionNamesForRole(role: RoleName): readonly string[] {
const entityPermissionNames = buildEntityPermissionNames();
const allPermissionNames = [
...entityPermissionNames,
...EXTRA_PERMISSIONS,
...MODULE_PERMISSIONS,
];
if (role === ROLE_NAMES.SYSTEM_ADMIN) {
return uniquePermissionNames(allPermissionNames);
}
if (FULL_ACCESS_ROLES.includes(role)) {
const excluded = new Set<string>(FULL_ACCESS_EXCLUDED_FOR_ALL);
if (role !== ROLE_NAMES.DIRECTOR) {
for (const name of FULL_ACCESS_EXCLUDED_FOR_NON_CAMPUS_STAFF) {
excluded.add(name);
}
}
return uniquePermissionNames(allPermissionNames.filter((name) => !excluded.has(name)));
}
if (READ_ONLY_ROLES.includes(role)) {
return uniquePermissionNames([
...entityPermissionNames.filter((name) => name.startsWith('READ_')),
...(MODULE_PERMISSIONS_BY_ROLE[role] ?? []),
]);
}
return uniquePermissionNames(MODULE_PERMISSIONS_BY_ROLE[role] ?? []);
}
export default {
async up(queryInterface: QueryInterface) {
const createdAt = new Date();
const updatedAt = new Date();
const roleId = new Map<string, string>();
const permId = new Map<string, string>();
// 1. Roles.
const existingRoles = await queryInterface.sequelize.query<{
id: string;
name: string;
}>(
`SELECT id, name FROM roles WHERE name IN (:names)`,
{
replacements: { names: ROLE_DEFINITIONS.map((role) => role.name) },
type: QueryTypes.SELECT,
},
);
for (const role of existingRoles) {
roleId.set(role.name, role.id);
}
const roleRows: CreationAttributes<Roles>[] = ROLE_DEFINITIONS.map(
(role) => {
const id = roleId.get(role.name) ?? uuid();
roleId.set(role.name, id);
return {
id,
name: role.name,
scope: role.scope,
globalAccess: role.globalAccess,
createdAt,
updatedAt,
};
},
).filter((role) => !existingRoles.some((existing) => existing.id === role.id));
if (roleRows.length > 0) {
await queryInterface.bulkInsert('roles', roleRows);
}
// 2. Permissions (entity CRUD + extras + product module/page perms).
const entityPermissionNames = [...buildEntityPermissionNames()];
const allPermissionNames = uniquePermissionNames([
...entityPermissionNames,
...EXTRA_PERMISSIONS,
...MODULE_PERMISSIONS,
]);
const existingPermissions = await queryInterface.sequelize.query<{
id: string;
name: string;
}>(
`SELECT id, name FROM permissions WHERE name IN (:names)`,
{
replacements: { names: allPermissionNames },
type: QueryTypes.SELECT,
},
);
for (const permission of existingPermissions) {
permId.set(permission.name, permission.id);
}
const permissionRows: CreationAttributes<Permissions>[] =
allPermissionNames.map((name) => {
const id = permId.get(name) ?? uuid();
permId.set(name, id);
return { id, name, createdAt, updatedAt };
}).filter(
(permission) =>
!existingPermissions.some((existing) => existing.id === permission.id),
);
if (permissionRows.length > 0) {
await queryInterface.bulkInsert('permissions', permissionRows);
}
// 3. Role → permission matrix.
const links: Array<{
createdAt: Date;
updatedAt: Date;
roles_permissionsId: string;
permissionId: string;
}> = [];
const grant = (role: RoleName, names: readonly string[]): void => {
const rid = roleId.get(role);
if (!rid) return;
for (const name of names) {
const pid = permId.get(name);
if (pid) {
links.push({
createdAt,
updatedAt,
roles_permissionsId: rid,
permissionId: pid,
});
}
}
};
for (const role of [
ROLE_NAMES.SYSTEM_ADMIN,
...FULL_ACCESS_ROLES,
...READ_ONLY_ROLES,
...EXTERNAL_ROLES,
]) {
grant(role, buildSeededPermissionNamesForRole(role));
}
// Workstream 11: the office_manager also manages policy documents; director
// receives the same writes through the seeded full-scope permission matrix,
// while teacher/support stay read-only.
grant(ROLE_NAMES.OFFICE_MANAGER, [
'CREATE_POLICY_DOCUMENTS',
'UPDATE_POLICY_DOCUMENTS',
'DELETE_POLICY_DOCUMENTS',
]);
const roleIds = [...roleId.values()];
const permissionIds = [...permId.values()];
const existingLinks = await queryInterface.sequelize.query<{
roles_permissionsId: string;
permissionId: string;
}>(
`SELECT "roles_permissionsId", "permissionId"
FROM "rolesPermissionsPermissions"
WHERE "roles_permissionsId" IN (:roleIds)
AND "permissionId" IN (:permissionIds)`,
{
replacements: { roleIds, permissionIds },
type: QueryTypes.SELECT,
},
);
const existingLinkKeys = new Set(
existingLinks.map(
(link) => `${link.roles_permissionsId}:${link.permissionId}`,
),
);
const missingLinkKeys = new Set<string>();
const missingLinks = links.filter((link) => {
const key = `${link.roles_permissionsId}:${link.permissionId}`;
if (existingLinkKeys.has(key) || missingLinkKeys.has(key)) {
return false;
}
missingLinkKeys.add(key);
return true;
});
if (missingLinks.length > 0) {
await queryInterface.bulkInsert('rolesPermissionsPermissions', missingLinks);
}
// 4. Assign roles to the seeded fixture users (by id — robust to the
// configured super-admin email).
for (const fixture of SEED_ALL_USERS) {
const rid = roleId.get(fixture.role);
if (!rid) continue;
await queryInterface.sequelize.query(
`UPDATE "users" SET "app_roleId"=:rid WHERE "id"=:id`,
{ replacements: { rid, id: fixture.id } },
);
}
},
async down(queryInterface: QueryInterface) {
await queryInterface.bulkDelete('rolesPermissionsPermissions', {});
await queryInterface.bulkDelete('permissions', {});
await queryInterface.bulkDelete('roles', {});
},
};