From 5fe5194349d2346d7f9de65384b226ea205a5e6c Mon Sep 17 00:00:00 2001 From: Replit Agent Date: Mon, 6 Jul 2026 09:54:30 +0000 Subject: [PATCH] Task #649: Public Relations & Protocol module (bookings, external meetings, gifts) Separate additive module, fully independent from executive-meetings. All tables prefixed protocol_, routes under /protocol, Arabic-first RTL UI. This session: addressed code-review rejection by switching protocol authorization from hardcoded role-name checks to scoped permission checks. - auth.ts: requireProtocolAccess now guards on the "protocol.access" permission (same permission that gates the home-screen tile via app_permissions), so backend read access aligns with tile visibility. - protocol.ts: replaced role-name helpers with makeRequirePerm + PROTOCOL_PERMS map (protocol.access / request / mutate / approve / rooms.manage / audit.read). Booking auto-approve and /protocol/me capabilities now derive from permissions. - seed.ts: seeds all 6 scoped permissions and maps them to admin + the 5 protocol roles (access:6, request:5, mutate:4, approve:3, audit:3, rooms:2 role grants). Verified: typecheck clean (tx-os, api-server protocol/auth, scripts), seed applied, DB mappings confirmed, /api/protocol/me returns 401 unauth. Architect review PASS. Deviations: none from spec. Frontend already consumed capability flags from /me, so no frontend changes were needed for the permission switch. --- artifacts/api-server/src/middlewares/auth.ts | 30 +---- artifacts/api-server/src/routes/protocol.ts | 115 +++++++++------- scripts/src/seed.ts | 131 ++++++++++++++----- 3 files changed, 179 insertions(+), 97 deletions(-) diff --git a/artifacts/api-server/src/middlewares/auth.ts b/artifacts/api-server/src/middlewares/auth.ts index 5449ac33..a5051f48 100644 --- a/artifacts/api-server/src/middlewares/auth.ts +++ b/artifacts/api-server/src/middlewares/auth.ts @@ -253,19 +253,13 @@ export async function requireExecutiveAccess( next(); } -const PROTOCOL_READ_ROLES: ReadonlyArray = [ - "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. + * Gate any read access to the Public Relations & Protocol module. Guarded by + * the scoped `protocol.access` permission — the same permission that gates the + * home-screen tile (via app_permissions) — so backend usability aligns exactly + * with the visible permission grant. Finer-grained request / mutate / approve / + * rooms / audit capabilities are layered on top via scoped permissions in the + * routes file. */ export async function requireProtocolAccess( req: Request, @@ -284,17 +278,7 @@ export async function requireProtocolAccess( 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)); + const ok = await userHasPermission(req.session.userId, "protocol.access"); if (!ok) { res.status(403).json({ error: "Forbidden", code: "forbidden" }); return; diff --git a/artifacts/api-server/src/routes/protocol.ts b/artifacts/api-server/src/routes/protocol.ts index 623a7500..8cfd61cd 100644 --- a/artifacts/api-server/src/routes/protocol.ts +++ b/artifacts/api-server/src/routes/protocol.ts @@ -10,10 +10,16 @@ import { protocolGiftIssuesTable, protocolAuditLogsTable, rolesTable, + permissionsTable, + rolePermissionsTable, usersTable, PROTOCOL_GIFT_KINDS, } from "@workspace/db"; -import { requireProtocolAccess, getEffectiveRoleIds } from "../middlewares/auth"; +import { + requireProtocolAccess, + getEffectiveRoleIds, + userHasPermission, +} from "../middlewares/auth"; import type { Request, Response, NextFunction } from "express"; const router: IRouter = Router(); @@ -27,31 +33,26 @@ router.param("id", (req, res, next, value) => { }); // --------------------------------------------------------------------------- -// Role helpers +// Permission helpers // --------------------------------------------------------------------------- -// Roles that may approve/reject bookings and gift issuances. -const APPROVE_ROLES = [ - "admin", - "protocol_super_admin", - "protocol_pr_manager", -] as const; -// Roles that may create/edit records directly (no approval needed to author). -const MUTATE_ROLES = [ - "admin", - "protocol_super_admin", - "protocol_pr_manager", - "protocol_officer", -] as const; -// Roles that may submit booking / issuance requests (created as "pending"). -const REQUEST_ROLES = [...MUTATE_ROLES, "protocol_requester"] as const; -// Roles that may manage the rooms registry. -const MANAGE_ROOMS_ROLES = ["admin", "protocol_super_admin"] as const; -// Roles that may view the audit log. -const AUDIT_ROLES = [ - "admin", - "protocol_super_admin", - "protocol_pr_manager", -] as const; +// Scoped protocol permissions. Access to protocol routes is guarded by these +// permissions (seeded and mapped to the five protocol roles), never by role +// names — so an admin can delegate any capability by granting the permission. +const PROTOCOL_PERMS = { + // Read + tile visibility. Kept in lockstep with the app_permissions grant so + // backend usability matches exactly what makes the home-screen tile visible. + read: "protocol.access", + // Submit booking / issuance requests (created as "pending"). + request: "protocol.request", + // Create/edit records directly (no approval needed to author). + mutate: "protocol.mutate", + // Approve/reject bookings and gift issuances. + approve: "protocol.approve", + // Manage the rooms registry. + rooms: "protocol.rooms.manage", + // View the audit log. + audit: "protocol.audit.read", +} as const; async function getRoleNamesForUser(userId: number): Promise> { const ids = await getEffectiveRoleIds(userId); @@ -63,7 +64,28 @@ async function getRoleNamesForUser(userId: number): Promise> { return new Set(rows.map((r) => r.name)); } -function makeRequireRoles(allowed: ReadonlyArray) { +// Effective protocol permission names held by the user (via any effective +// role). One query, used to compute the capabilities payload for /me. +async function getProtocolPermsForUser(userId: number): Promise> { + const ids = await getEffectiveRoleIds(userId); + if (ids.length === 0) return new Set(); + const rows = await db + .select({ name: permissionsTable.name }) + .from(rolePermissionsTable) + .innerJoin( + permissionsTable, + eq(rolePermissionsTable.permissionId, permissionsTable.id), + ) + .where( + and( + inArray(rolePermissionsTable.roleId, ids), + inArray(permissionsTable.name, Object.values(PROTOCOL_PERMS)), + ), + ); + return new Set(rows.map((r) => r.name)); +} + +function makeRequirePerm(perm: string) { return async function ( req: Request, res: Response, @@ -83,8 +105,8 @@ function makeRequireRoles(allowed: ReadonlyArray) { res.status(401).json({ error: "Unauthorized", code: "unauthorized" }); return; } - const names = await getRoleNamesForUser(req.session.userId); - if (!allowed.some((r) => names.has(r))) { + const ok = await userHasPermission(req.session.userId, perm); + if (!ok) { res.status(403).json({ error: "Forbidden", code: "forbidden" }); return; } @@ -92,11 +114,11 @@ function makeRequireRoles(allowed: ReadonlyArray) { }; } -const requireRequest = makeRequireRoles(REQUEST_ROLES); -const requireMutate = makeRequireRoles(MUTATE_ROLES); -const requireApprove = makeRequireRoles(APPROVE_ROLES); -const requireManageRooms = makeRequireRoles(MANAGE_ROOMS_ROLES); -const requireAudit = makeRequireRoles(AUDIT_ROLES); +const requireRequest = makeRequirePerm(PROTOCOL_PERMS.request); +const requireMutate = makeRequirePerm(PROTOCOL_PERMS.mutate); +const requireApprove = makeRequirePerm(PROTOCOL_PERMS.approve); +const requireManageRooms = makeRequirePerm(PROTOCOL_PERMS.rooms); +const requireAudit = makeRequirePerm(PROTOCOL_PERMS.audit); // --------------------------------------------------------------------------- // Zod schemas @@ -301,17 +323,19 @@ router.get( requireProtocolAccess, async (req: Request, res: Response) => { const userId = req.session.userId!; - const names = await getRoleNamesForUser(userId); - const has = (list: ReadonlyArray) => list.some((r) => names.has(r)); + const [names, perms] = await Promise.all([ + getRoleNamesForUser(userId), + getProtocolPermsForUser(userId), + ]); res.json({ userId, roles: Array.from(names), canRead: true, - canRequest: has(REQUEST_ROLES), - canMutate: has(MUTATE_ROLES), - canApprove: has(APPROVE_ROLES), - canManageRooms: has(MANAGE_ROOMS_ROLES), - canViewAudit: has(AUDIT_ROLES), + canRequest: perms.has(PROTOCOL_PERMS.request), + canMutate: perms.has(PROTOCOL_PERMS.mutate), + canApprove: perms.has(PROTOCOL_PERMS.approve), + canManageRooms: perms.has(PROTOCOL_PERMS.rooms), + canViewAudit: perms.has(PROTOCOL_PERMS.audit), }); }, ); @@ -480,10 +504,13 @@ router.post( return; } - // Non-approvers submit requests as "pending"; approver roles may create - // an already-approved booking directly. Either way the slot is checked. - const names = await getRoleNamesForUser(req.session.userId!); - const canApprove = APPROVE_ROLES.some((r) => names.has(r)); + // Non-approvers submit requests as "pending"; users with the approve + // permission may create an already-approved booking directly. Either way + // the slot is checked. + const canApprove = await userHasPermission( + req.session.userId!, + PROTOCOL_PERMS.approve, + ); try { const created = await db.transaction(async (tx) => { diff --git a/scripts/src/seed.ts b/scripts/src/seed.ts index ea7f0e4e..85536a8f 100644 --- a/scripts/src/seed.ts +++ b/scripts/src/seed.ts @@ -20,7 +20,7 @@ import { executiveMeetingNotificationsTable, protocolRoomsTable, } from "@workspace/db"; -import { eq, sql } from "drizzle-orm"; +import { eq, inArray, sql } from "drizzle-orm"; import bcrypt from "bcryptjs"; async function main() { @@ -814,46 +814,117 @@ async function main() { await db.insert(rolesTable).values(protocolRoles).onConflictDoNothing(); console.log("Protocol roles created"); - // Gate the Protocol home-screen icon to admin + the 5 protocol roles. - await db - .insert(permissionsTable) - .values({ + // Scoped protocol permissions. `protocol.access` also gates the home-screen + // tile (via app_permissions); the rest guard individual route capabilities. + // Route middleware authorizes by these permissions, never by role name, so + // an admin can delegate any capability by granting the permission. + const protocolPermissions = [ + { name: "protocol.access", descriptionAr: "الوصول إلى وحدة العلاقات العامة والمراسم", descriptionEn: "Access the Public Relations & Protocol module", - }) + }, + { + name: "protocol.request", + descriptionAr: "تقديم طلبات الحجز وإصدار الدروع", + descriptionEn: "Submit protocol booking and gift-issue requests", + }, + { + name: "protocol.mutate", + descriptionAr: "إنشاء وتعديل سجلات العلاقات العامة والمراسم", + descriptionEn: "Create and edit protocol records", + }, + { + name: "protocol.approve", + descriptionAr: "اعتماد أو رفض الحجوزات وإصدارات الدروع", + descriptionEn: "Approve or reject protocol bookings and gift issues", + }, + { + name: "protocol.rooms.manage", + descriptionAr: "إدارة سجل القاعات", + descriptionEn: "Manage the protocol rooms registry", + }, + { + name: "protocol.audit.read", + descriptionAr: "عرض سجل تدقيق العلاقات العامة والمراسم", + descriptionEn: "View the protocol audit log", + }, + ]; + await db + .insert(permissionsTable) + .values(protocolPermissions) .onConflictDoNothing(); - const [protocolAccessPerm] = await db - .select() - .from(permissionsTable) - .where(eq(permissionsTable.name, "protocol.access")); - - if (protocolAccessPerm) { - const protocolRoleNames = [ + // Map each protocol permission to the roles that should hold it. Capabilities + // are additive: super admin holds everything, viewer only reads. + const protocolRolePermMap: Record = { + "protocol.access": [ "admin", "protocol_super_admin", "protocol_pr_manager", "protocol_officer", "protocol_requester", "protocol_viewer", - ]; - const allRolesForProtocol = await db.select().from(rolesTable); - const protocolRoleRows = allRolesForProtocol.filter((r) => - protocolRoleNames.includes(r.name), - ); - if (protocolRoleRows.length > 0) { - await db - .insert(rolePermissionsTable) - .values( - protocolRoleRows.map((r) => ({ - roleId: r.id, - permissionId: protocolAccessPerm.id, - })), - ) - .onConflictDoNothing(); - } + ], + "protocol.request": [ + "admin", + "protocol_super_admin", + "protocol_pr_manager", + "protocol_officer", + "protocol_requester", + ], + "protocol.mutate": [ + "admin", + "protocol_super_admin", + "protocol_pr_manager", + "protocol_officer", + ], + "protocol.approve": [ + "admin", + "protocol_super_admin", + "protocol_pr_manager", + ], + "protocol.rooms.manage": ["admin", "protocol_super_admin"], + "protocol.audit.read": [ + "admin", + "protocol_super_admin", + "protocol_pr_manager", + ], + }; + const allRolesForProtocol = await db.select().from(rolesTable); + const roleIdByName = new Map(allRolesForProtocol.map((r) => [r.name, r.id])); + const allProtocolPerms = await db + .select() + .from(permissionsTable) + .where( + inArray( + permissionsTable.name, + protocolPermissions.map((p) => p.name), + ), + ); + const permIdByName = new Map(allProtocolPerms.map((p) => [p.name, p.id])); + + const rolePermValues: { roleId: number; permissionId: number }[] = []; + for (const [permName, roleNames] of Object.entries(protocolRolePermMap)) { + const permId = permIdByName.get(permName); + if (permId === undefined) continue; + for (const roleName of roleNames) { + const roleId = roleIdByName.get(roleName); + if (roleId === undefined) continue; + rolePermValues.push({ roleId, permissionId: permId }); + } + } + if (rolePermValues.length > 0) { + await db + .insert(rolePermissionsTable) + .values(rolePermValues) + .onConflictDoNothing(); + } + console.log("Protocol scoped permissions mapped to roles"); + + const protocolAccessPermId = permIdByName.get("protocol.access"); + if (protocolAccessPermId !== undefined) { const [protocolApp] = await db .select() .from(appsTable) @@ -861,7 +932,7 @@ async function main() { if (protocolApp) { await db .insert(appPermissionsTable) - .values({ appId: protocolApp.id, permissionId: protocolAccessPerm.id }) + .values({ appId: protocolApp.id, permissionId: protocolAccessPermId }) .onConflictDoNothing(); console.log( "App permissions set: protocol app restricted to protocol.access permission",