350 lines
12 KiB
TypeScript
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', {});
|
|
},
|
|
};
|