Files
TX/artifacts/api-server/src/middlewares/auth.ts
T
Riyadh 874d49c439 Add Public Relations & Protocol module (Task #649)
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.
2026-07-06 09:25:40 +00:00

305 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}