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();
|
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
|
* Gate any read access to the Public Relations & Protocol module. Guarded by
|
||||||
* admin and any of the protocol_* roles. Finer-grained mutate / approve
|
* the scoped `protocol.access` permission — the same permission that gates the
|
||||||
* sub-roles are layered on top via local helpers in the routes file.
|
* 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(
|
export async function requireProtocolAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -284,17 +278,7 @@ export async function requireProtocolAccess(
|
|||||||
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
|
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ids = await getEffectiveRoleIds(req.session.userId);
|
const ok = await userHasPermission(req.session.userId, "protocol.access");
|
||||||
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) {
|
if (!ok) {
|
||||||
res.status(403).json({ error: "Forbidden", code: "forbidden" });
|
res.status(403).json({ error: "Forbidden", code: "forbidden" });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -10,10 +10,16 @@ import {
|
|||||||
protocolGiftIssuesTable,
|
protocolGiftIssuesTable,
|
||||||
protocolAuditLogsTable,
|
protocolAuditLogsTable,
|
||||||
rolesTable,
|
rolesTable,
|
||||||
|
permissionsTable,
|
||||||
|
rolePermissionsTable,
|
||||||
usersTable,
|
usersTable,
|
||||||
PROTOCOL_GIFT_KINDS,
|
PROTOCOL_GIFT_KINDS,
|
||||||
} from "@workspace/db";
|
} from "@workspace/db";
|
||||||
import { requireProtocolAccess, getEffectiveRoleIds } from "../middlewares/auth";
|
import {
|
||||||
|
requireProtocolAccess,
|
||||||
|
getEffectiveRoleIds,
|
||||||
|
userHasPermission,
|
||||||
|
} from "../middlewares/auth";
|
||||||
import type { Request, Response, NextFunction } from "express";
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
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.
|
// Scoped protocol permissions. Access to protocol routes is guarded by these
|
||||||
const APPROVE_ROLES = [
|
// permissions (seeded and mapped to the five protocol roles), never by role
|
||||||
"admin",
|
// names — so an admin can delegate any capability by granting the permission.
|
||||||
"protocol_super_admin",
|
const PROTOCOL_PERMS = {
|
||||||
"protocol_pr_manager",
|
// Read + tile visibility. Kept in lockstep with the app_permissions grant so
|
||||||
] as const;
|
// backend usability matches exactly what makes the home-screen tile visible.
|
||||||
// Roles that may create/edit records directly (no approval needed to author).
|
read: "protocol.access",
|
||||||
const MUTATE_ROLES = [
|
// Submit booking / issuance requests (created as "pending").
|
||||||
"admin",
|
request: "protocol.request",
|
||||||
"protocol_super_admin",
|
// Create/edit records directly (no approval needed to author).
|
||||||
"protocol_pr_manager",
|
mutate: "protocol.mutate",
|
||||||
"protocol_officer",
|
// Approve/reject bookings and gift issuances.
|
||||||
] as const;
|
approve: "protocol.approve",
|
||||||
// Roles that may submit booking / issuance requests (created as "pending").
|
// Manage the rooms registry.
|
||||||
const REQUEST_ROLES = [...MUTATE_ROLES, "protocol_requester"] as const;
|
rooms: "protocol.rooms.manage",
|
||||||
// Roles that may manage the rooms registry.
|
// View the audit log.
|
||||||
const MANAGE_ROOMS_ROLES = ["admin", "protocol_super_admin"] as const;
|
audit: "protocol.audit.read",
|
||||||
// Roles that may view the audit log.
|
} as const;
|
||||||
const AUDIT_ROLES = [
|
|
||||||
"admin",
|
|
||||||
"protocol_super_admin",
|
|
||||||
"protocol_pr_manager",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
async function getRoleNamesForUser(userId: number): Promise<Set<string>> {
|
async function getRoleNamesForUser(userId: number): Promise<Set<string>> {
|
||||||
const ids = await getEffectiveRoleIds(userId);
|
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));
|
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 (
|
return async function (
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
@@ -83,8 +105,8 @@ function makeRequireRoles(allowed: ReadonlyArray<string>) {
|
|||||||
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
|
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const names = await getRoleNamesForUser(req.session.userId);
|
const ok = await userHasPermission(req.session.userId, perm);
|
||||||
if (!allowed.some((r) => names.has(r))) {
|
if (!ok) {
|
||||||
res.status(403).json({ error: "Forbidden", code: "forbidden" });
|
res.status(403).json({ error: "Forbidden", code: "forbidden" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -92,11 +114,11 @@ function makeRequireRoles(allowed: ReadonlyArray<string>) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const requireRequest = makeRequireRoles(REQUEST_ROLES);
|
const requireRequest = makeRequirePerm(PROTOCOL_PERMS.request);
|
||||||
const requireMutate = makeRequireRoles(MUTATE_ROLES);
|
const requireMutate = makeRequirePerm(PROTOCOL_PERMS.mutate);
|
||||||
const requireApprove = makeRequireRoles(APPROVE_ROLES);
|
const requireApprove = makeRequirePerm(PROTOCOL_PERMS.approve);
|
||||||
const requireManageRooms = makeRequireRoles(MANAGE_ROOMS_ROLES);
|
const requireManageRooms = makeRequirePerm(PROTOCOL_PERMS.rooms);
|
||||||
const requireAudit = makeRequireRoles(AUDIT_ROLES);
|
const requireAudit = makeRequirePerm(PROTOCOL_PERMS.audit);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Zod schemas
|
// Zod schemas
|
||||||
@@ -301,17 +323,19 @@ router.get(
|
|||||||
requireProtocolAccess,
|
requireProtocolAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = req.session.userId!;
|
const userId = req.session.userId!;
|
||||||
const names = await getRoleNamesForUser(userId);
|
const [names, perms] = await Promise.all([
|
||||||
const has = (list: ReadonlyArray<string>) => list.some((r) => names.has(r));
|
getRoleNamesForUser(userId),
|
||||||
|
getProtocolPermsForUser(userId),
|
||||||
|
]);
|
||||||
res.json({
|
res.json({
|
||||||
userId,
|
userId,
|
||||||
roles: Array.from(names),
|
roles: Array.from(names),
|
||||||
canRead: true,
|
canRead: true,
|
||||||
canRequest: has(REQUEST_ROLES),
|
canRequest: perms.has(PROTOCOL_PERMS.request),
|
||||||
canMutate: has(MUTATE_ROLES),
|
canMutate: perms.has(PROTOCOL_PERMS.mutate),
|
||||||
canApprove: has(APPROVE_ROLES),
|
canApprove: perms.has(PROTOCOL_PERMS.approve),
|
||||||
canManageRooms: has(MANAGE_ROOMS_ROLES),
|
canManageRooms: perms.has(PROTOCOL_PERMS.rooms),
|
||||||
canViewAudit: has(AUDIT_ROLES),
|
canViewAudit: perms.has(PROTOCOL_PERMS.audit),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -480,10 +504,13 @@ router.post(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-approvers submit requests as "pending"; approver roles may create
|
// Non-approvers submit requests as "pending"; users with the approve
|
||||||
// an already-approved booking directly. Either way the slot is checked.
|
// permission may create an already-approved booking directly. Either way
|
||||||
const names = await getRoleNamesForUser(req.session.userId!);
|
// the slot is checked.
|
||||||
const canApprove = APPROVE_ROLES.some((r) => names.has(r));
|
const canApprove = await userHasPermission(
|
||||||
|
req.session.userId!,
|
||||||
|
PROTOCOL_PERMS.approve,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await db.transaction(async (tx) => {
|
const created = await db.transaction(async (tx) => {
|
||||||
|
|||||||
+101
-30
@@ -20,7 +20,7 @@ import {
|
|||||||
executiveMeetingNotificationsTable,
|
executiveMeetingNotificationsTable,
|
||||||
protocolRoomsTable,
|
protocolRoomsTable,
|
||||||
} from "@workspace/db";
|
} from "@workspace/db";
|
||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, inArray, sql } from "drizzle-orm";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -814,46 +814,117 @@ async function main() {
|
|||||||
await db.insert(rolesTable).values(protocolRoles).onConflictDoNothing();
|
await db.insert(rolesTable).values(protocolRoles).onConflictDoNothing();
|
||||||
console.log("Protocol roles created");
|
console.log("Protocol roles created");
|
||||||
|
|
||||||
// Gate the Protocol home-screen icon to admin + the 5 protocol roles.
|
// Scoped protocol permissions. `protocol.access` also gates the home-screen
|
||||||
await db
|
// tile (via app_permissions); the rest guard individual route capabilities.
|
||||||
.insert(permissionsTable)
|
// Route middleware authorizes by these permissions, never by role name, so
|
||||||
.values({
|
// an admin can delegate any capability by granting the permission.
|
||||||
|
const protocolPermissions = [
|
||||||
|
{
|
||||||
name: "protocol.access",
|
name: "protocol.access",
|
||||||
descriptionAr: "الوصول إلى وحدة العلاقات العامة والمراسم",
|
descriptionAr: "الوصول إلى وحدة العلاقات العامة والمراسم",
|
||||||
descriptionEn: "Access the Public Relations & Protocol module",
|
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();
|
.onConflictDoNothing();
|
||||||
|
|
||||||
const [protocolAccessPerm] = await db
|
// Map each protocol permission to the roles that should hold it. Capabilities
|
||||||
.select()
|
// are additive: super admin holds everything, viewer only reads.
|
||||||
.from(permissionsTable)
|
const protocolRolePermMap: Record<string, string[]> = {
|
||||||
.where(eq(permissionsTable.name, "protocol.access"));
|
"protocol.access": [
|
||||||
|
|
||||||
if (protocolAccessPerm) {
|
|
||||||
const protocolRoleNames = [
|
|
||||||
"admin",
|
"admin",
|
||||||
"protocol_super_admin",
|
"protocol_super_admin",
|
||||||
"protocol_pr_manager",
|
"protocol_pr_manager",
|
||||||
"protocol_officer",
|
"protocol_officer",
|
||||||
"protocol_requester",
|
"protocol_requester",
|
||||||
"protocol_viewer",
|
"protocol_viewer",
|
||||||
];
|
],
|
||||||
const allRolesForProtocol = await db.select().from(rolesTable);
|
"protocol.request": [
|
||||||
const protocolRoleRows = allRolesForProtocol.filter((r) =>
|
"admin",
|
||||||
protocolRoleNames.includes(r.name),
|
"protocol_super_admin",
|
||||||
);
|
"protocol_pr_manager",
|
||||||
if (protocolRoleRows.length > 0) {
|
"protocol_officer",
|
||||||
await db
|
"protocol_requester",
|
||||||
.insert(rolePermissionsTable)
|
],
|
||||||
.values(
|
"protocol.mutate": [
|
||||||
protocolRoleRows.map((r) => ({
|
"admin",
|
||||||
roleId: r.id,
|
"protocol_super_admin",
|
||||||
permissionId: protocolAccessPerm.id,
|
"protocol_pr_manager",
|
||||||
})),
|
"protocol_officer",
|
||||||
)
|
],
|
||||||
.onConflictDoNothing();
|
"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
|
const [protocolApp] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(appsTable)
|
.from(appsTable)
|
||||||
@@ -861,7 +932,7 @@ async function main() {
|
|||||||
if (protocolApp) {
|
if (protocolApp) {
|
||||||
await db
|
await db
|
||||||
.insert(appPermissionsTable)
|
.insert(appPermissionsTable)
|
||||||
.values({ appId: protocolApp.id, permissionId: protocolAccessPerm.id })
|
.values({ appId: protocolApp.id, permissionId: protocolAccessPermId })
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
console.log(
|
console.log(
|
||||||
"App permissions set: protocol app restricted to protocol.access permission",
|
"App permissions set: protocol app restricted to protocol.access permission",
|
||||||
|
|||||||
Reference in New Issue
Block a user