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.
This commit is contained in:
Replit Agent
2026-07-06 09:54:30 +00:00
parent 5e460920c7
commit 5fe5194349
3 changed files with 179 additions and 97 deletions
+7 -23
View File
@@ -253,19 +253,13 @@ export async function requireExecutiveAccess(
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.
* 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;
+71 -44
View File
@@ -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<Set<string>> {
const ids = await getEffectiveRoleIds(userId);
@@ -63,7 +64,28 @@ async function getRoleNamesForUser(userId: number): Promise<Set<string>> {
return new Set(rows.map((r) => r.name));
}
function makeRequireRoles(allowed: ReadonlyArray<string>) {
// 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<Set<string>> {
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<string>) {
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<string>) {
};
}
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<string>) => 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) => {
+101 -30
View File
@@ -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<string, string[]> = {
"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",