874d49c439
New, fully separate app module "العلاقات العامة والمراسم / Public Relations and Protocol". Does not touch executive-meetings; all new tables are prefixed protocol_ and are additive-only, routes under /protocol. Includes: - 6 protocol_ tables (rooms, room bookings, external meetings, gifts, gift issues, audit log) in lib/db. - API routes + role-scoped middleware (protocol_super_admin, pr_manager, officer, requester, viewer) with requireProtocolAccess gating. - Room-booking conflict prevention (overlap rule newStart<existingEnd AND newEnd>existingStart vs pending/approved) with AR error message. - Gifts/shields (دروع) registry + issuance with stock tracking. - Bilingual AR/EN frontend page (tabs + dialogs) at /protocol, app tile, i18n keys, and seed data (roles, permission, default rooms). Concurrency/security hardening from code review: - All role guards now validate users.isActive (inactive-user bypass fix). - Per-room SELECT ... FOR UPDATE lock serializes booking check-then-write. - Approve paths re-read inside the transaction and use state-conditional updates (WHERE ... AND status='pending' RETURNING). - Gift issuance claims the issue conditionally, then does an atomic conditional stock decrement; failure rolls back the claim. Verified: drizzle push (6 tables), seed, frontend + api-server typecheck (only pre-existing errors in untouched files), unauth gating returns 401, conflict predicate confirmed. Architect review PASS.
305 lines
8.3 KiB
TypeScript
305 lines
8.3 KiB
TypeScript
import { type Request, type Response, type NextFunction } from "express";
|
||
import { db } from "@workspace/db";
|
||
import {
|
||
usersTable,
|
||
userRolesTable,
|
||
rolesTable,
|
||
rolePermissionsTable,
|
||
permissionsTable,
|
||
groupRolesTable,
|
||
userGroupsTable,
|
||
} from "@workspace/db";
|
||
import { eq, and, inArray, or } from "drizzle-orm";
|
||
|
||
/**
|
||
* Effective roles for a user = direct user_roles ∪ roles attached to any of
|
||
* the user's groups via group_roles. Returns distinct role names.
|
||
*/
|
||
export async function getEffectiveRoleIds(userId: number): Promise<number[]> {
|
||
const rows = await db
|
||
.select({ roleId: rolesTable.id })
|
||
.from(rolesTable)
|
||
.where(
|
||
or(
|
||
inArray(
|
||
rolesTable.id,
|
||
db
|
||
.select({ rid: userRolesTable.roleId })
|
||
.from(userRolesTable)
|
||
.where(eq(userRolesTable.userId, userId)),
|
||
),
|
||
inArray(
|
||
rolesTable.id,
|
||
db
|
||
.select({ rid: groupRolesTable.roleId })
|
||
.from(groupRolesTable)
|
||
.innerJoin(
|
||
userGroupsTable,
|
||
eq(userGroupsTable.groupId, groupRolesTable.groupId),
|
||
)
|
||
.where(eq(userGroupsTable.userId, userId)),
|
||
),
|
||
),
|
||
);
|
||
return Array.from(new Set(rows.map((r) => r.roleId)));
|
||
}
|
||
|
||
declare module "express-session" {
|
||
interface SessionData {
|
||
userId: number;
|
||
}
|
||
}
|
||
|
||
export async function requireAuth(
|
||
req: Request,
|
||
res: Response,
|
||
next: NextFunction,
|
||
): Promise<void> {
|
||
if (!req.session.userId) {
|
||
res.status(401).json({ error: "Unauthorized" });
|
||
return;
|
||
}
|
||
|
||
const [user] = await db
|
||
.select()
|
||
.from(usersTable)
|
||
.where(eq(usersTable.id, req.session.userId));
|
||
|
||
if (!user || !user.isActive) {
|
||
res.status(401).json({ error: "Unauthorized" });
|
||
return;
|
||
}
|
||
|
||
next();
|
||
}
|
||
|
||
export async function requireAdmin(
|
||
req: Request,
|
||
res: Response,
|
||
next: NextFunction,
|
||
): Promise<void> {
|
||
if (!req.session.userId) {
|
||
res.status(401).json({ error: "Unauthorized" });
|
||
return;
|
||
}
|
||
|
||
const [user] = await db
|
||
.select({ isActive: usersTable.isActive })
|
||
.from(usersTable)
|
||
.where(eq(usersTable.id, req.session.userId));
|
||
if (!user || !user.isActive) {
|
||
res.status(401).json({ error: "Unauthorized" });
|
||
return;
|
||
}
|
||
|
||
const effectiveIds = await getEffectiveRoleIds(req.session.userId);
|
||
let isAdmin = false;
|
||
if (effectiveIds.length > 0) {
|
||
const adminRows = await db
|
||
.select({ id: rolesTable.id })
|
||
.from(rolesTable)
|
||
.where(and(inArray(rolesTable.id, effectiveIds), eq(rolesTable.name, "admin")));
|
||
isAdmin = adminRows.length > 0;
|
||
}
|
||
|
||
if (!isAdmin) {
|
||
res.status(403).json({ error: "Forbidden" });
|
||
return;
|
||
}
|
||
|
||
next();
|
||
}
|
||
|
||
export function requirePermission(name: string) {
|
||
return async function (
|
||
req: Request,
|
||
res: Response,
|
||
next: NextFunction,
|
||
): Promise<void> {
|
||
if (!req.session.userId) {
|
||
res.status(401).json({ error: "Unauthorized" });
|
||
return;
|
||
}
|
||
|
||
const [user] = await db
|
||
.select({ isActive: usersTable.isActive })
|
||
.from(usersTable)
|
||
.where(eq(usersTable.id, req.session.userId));
|
||
if (!user || !user.isActive) {
|
||
res.status(401).json({ error: "Unauthorized" });
|
||
return;
|
||
}
|
||
|
||
const effectiveIds = await getEffectiveRoleIds(req.session.userId);
|
||
if (effectiveIds.length === 0) {
|
||
res.status(403).json({ error: "Forbidden" });
|
||
return;
|
||
}
|
||
const rows = await db
|
||
.select({ permName: permissionsTable.name })
|
||
.from(rolePermissionsTable)
|
||
.innerJoin(
|
||
permissionsTable,
|
||
eq(rolePermissionsTable.permissionId, permissionsTable.id),
|
||
)
|
||
.where(
|
||
and(
|
||
inArray(rolePermissionsTable.roleId, effectiveIds),
|
||
eq(permissionsTable.name, name),
|
||
),
|
||
);
|
||
|
||
if (rows.length === 0) {
|
||
res.status(403).json({ error: "Forbidden" });
|
||
return;
|
||
}
|
||
|
||
next();
|
||
};
|
||
}
|
||
|
||
export async function userHasPermission(
|
||
userId: number,
|
||
name: string,
|
||
): Promise<boolean> {
|
||
const effectiveIds = await getEffectiveRoleIds(userId);
|
||
if (effectiveIds.length === 0) return false;
|
||
const rows = await db
|
||
.select({ id: rolePermissionsTable.permissionId })
|
||
.from(rolePermissionsTable)
|
||
.innerJoin(
|
||
permissionsTable,
|
||
eq(rolePermissionsTable.permissionId, permissionsTable.id),
|
||
)
|
||
.where(
|
||
and(
|
||
inArray(rolePermissionsTable.roleId, effectiveIds),
|
||
eq(permissionsTable.name, name),
|
||
),
|
||
);
|
||
return rows.length > 0;
|
||
}
|
||
|
||
export async function getUserRoles(userId: number): Promise<string[]> {
|
||
const effectiveIds = await getEffectiveRoleIds(userId);
|
||
if (effectiveIds.length === 0) return [];
|
||
const roles = await db
|
||
.select({ roleName: rolesTable.name })
|
||
.from(rolesTable)
|
||
.where(inArray(rolesTable.id, effectiveIds));
|
||
return Array.from(new Set(roles.map((r) => r.roleName)));
|
||
}
|
||
|
||
// Roles assigned directly to the user (user_roles table only), excluding any
|
||
// roles inherited via group membership. The admin Users page contrasts this
|
||
// with the effective set so a toggle for an inherited-only role can be
|
||
// disabled — otherwise removing the direct row (which doesn't exist) is a
|
||
// no-op and the Switch silently flips back on after refetch.
|
||
export async function getDirectUserRoles(userId: number): Promise<string[]> {
|
||
const rows = await db
|
||
.select({ roleName: rolesTable.name })
|
||
.from(userRolesTable)
|
||
.innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id))
|
||
.where(eq(userRolesTable.userId, userId));
|
||
return Array.from(new Set(rows.map((r) => r.roleName)));
|
||
}
|
||
|
||
const EXECUTIVE_READ_ROLES: ReadonlyArray<string> = [
|
||
"admin",
|
||
"executive_ceo",
|
||
"executive_office_manager",
|
||
"executive_coord_lead",
|
||
"executive_coordinator",
|
||
"executive_viewer",
|
||
];
|
||
|
||
/**
|
||
* Gate any read access to the Executive Meetings module. Allows admin and
|
||
* any of the executive_* roles. Mutate / approve / audit-view sub-roles
|
||
* are layered on top via local helpers in the routes file.
|
||
*/
|
||
export async function requireExecutiveAccess(
|
||
req: Request,
|
||
res: Response,
|
||
next: NextFunction,
|
||
): Promise<void> {
|
||
if (!req.session.userId) {
|
||
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
|
||
return;
|
||
}
|
||
const [user] = await db
|
||
.select({ isActive: usersTable.isActive })
|
||
.from(usersTable)
|
||
.where(eq(usersTable.id, req.session.userId));
|
||
if (!user || !user.isActive) {
|
||
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
|
||
return;
|
||
}
|
||
const ids = await getEffectiveRoleIds(req.session.userId);
|
||
if (ids.length === 0) {
|
||
res.status(403).json({ error: "Forbidden", code: "forbidden" });
|
||
return;
|
||
}
|
||
const rows = await db
|
||
.select({ name: rolesTable.name })
|
||
.from(rolesTable)
|
||
.where(inArray(rolesTable.id, ids));
|
||
const names = new Set(rows.map((r) => r.name));
|
||
const ok = EXECUTIVE_READ_ROLES.some((r) => names.has(r));
|
||
if (!ok) {
|
||
res.status(403).json({ error: "Forbidden", code: "forbidden" });
|
||
return;
|
||
}
|
||
next();
|
||
}
|
||
|
||
const PROTOCOL_READ_ROLES: ReadonlyArray<string> = [
|
||
"admin",
|
||
"protocol_super_admin",
|
||
"protocol_pr_manager",
|
||
"protocol_officer",
|
||
"protocol_requester",
|
||
"protocol_viewer",
|
||
];
|
||
|
||
/**
|
||
* Gate any read access to the Public Relations & Protocol module. Allows
|
||
* admin and any of the protocol_* roles. Finer-grained mutate / approve
|
||
* sub-roles are layered on top via local helpers in the routes file.
|
||
*/
|
||
export async function requireProtocolAccess(
|
||
req: Request,
|
||
res: Response,
|
||
next: NextFunction,
|
||
): Promise<void> {
|
||
if (!req.session.userId) {
|
||
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
|
||
return;
|
||
}
|
||
const [user] = await db
|
||
.select({ isActive: usersTable.isActive })
|
||
.from(usersTable)
|
||
.where(eq(usersTable.id, req.session.userId));
|
||
if (!user || !user.isActive) {
|
||
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
|
||
return;
|
||
}
|
||
const ids = await getEffectiveRoleIds(req.session.userId);
|
||
if (ids.length === 0) {
|
||
res.status(403).json({ error: "Forbidden", code: "forbidden" });
|
||
return;
|
||
}
|
||
const rows = await db
|
||
.select({ name: rolesTable.name })
|
||
.from(rolesTable)
|
||
.where(inArray(rolesTable.id, ids));
|
||
const names = new Set(rows.map((r) => r.name));
|
||
const ok = PROTOCOL_READ_ROLES.some((r) => names.has(r));
|
||
if (!ok) {
|
||
res.status(403).json({ error: "Forbidden", code: "forbidden" });
|
||
return;
|
||
}
|
||
next();
|
||
}
|
||
|