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:
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+95
-24
@@ -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",
|
||||
];
|
||||
],
|
||||
"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 protocolRoleRows = allRolesForProtocol.filter((r) =>
|
||||
protocolRoleNames.includes(r.name),
|
||||
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),
|
||||
),
|
||||
);
|
||||
if (protocolRoleRows.length > 0) {
|
||||
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(
|
||||
protocolRoleRows.map((r) => ({
|
||||
roleId: r.id,
|
||||
permissionId: protocolAccessPerm.id,
|
||||
})),
|
||||
)
|
||||
.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",
|
||||
|
||||
Reference in New Issue
Block a user