feat(rbac): hide unauthorized menu sections and enforce route guards
This commit is contained in:
103
frontend/src/lib/auth/__tests__/permissions.test.js
Normal file
103
frontend/src/lib/auth/__tests__/permissions.test.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// [DEF:frontend.src.lib.auth.__tests__.permissions:Module]
|
||||
// @TIER: STANDARD
|
||||
// @SEMANTICS: tests, auth, permissions, rbac
|
||||
// @PURPOSE: Verifies frontend RBAC permission parsing and access checks.
|
||||
// @LAYER: UI (Tests)
|
||||
// @RELATION: TESTS -> frontend/src/lib/auth/permissions.js
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
normalizePermissionRequirement,
|
||||
isAdminUser,
|
||||
hasPermission,
|
||||
} from "../permissions.js";
|
||||
|
||||
describe("auth.permissions", () => {
|
||||
it("normalizes resource-only requirement with default READ action", () => {
|
||||
expect(normalizePermissionRequirement("admin:settings")).toEqual({
|
||||
resource: "admin:settings",
|
||||
action: "READ",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes explicit resource:action requirement", () => {
|
||||
expect(normalizePermissionRequirement("admin:settings:write")).toEqual({
|
||||
resource: "admin:settings",
|
||||
action: "WRITE",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects admin role case-insensitively", () => {
|
||||
const user = {
|
||||
roles: [{ name: "ADMIN" }],
|
||||
};
|
||||
expect(isAdminUser(user)).toBe(true);
|
||||
});
|
||||
|
||||
it("denies when user is absent and permission is required", () => {
|
||||
expect(hasPermission(null, "tasks", "READ")).toBe(false);
|
||||
});
|
||||
|
||||
it("grants when permission object matches resource and action", () => {
|
||||
const user = {
|
||||
roles: [
|
||||
{
|
||||
name: "Operator",
|
||||
permissions: [{ resource: "tasks", action: "READ" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(hasPermission(user, "tasks", "READ")).toBe(true);
|
||||
});
|
||||
|
||||
it("grants when requirement is provided as resource:action", () => {
|
||||
const user = {
|
||||
roles: [
|
||||
{
|
||||
name: "Operator",
|
||||
permissions: [{ resource: "admin:settings", action: "READ" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(hasPermission(user, "admin:settings:READ")).toBe(true);
|
||||
});
|
||||
|
||||
it("grants when string permission entry matches", () => {
|
||||
const user = {
|
||||
roles: [
|
||||
{
|
||||
name: "Operator",
|
||||
permissions: ["plugin:migration:READ"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(hasPermission(user, "plugin:migration", "READ")).toBe(true);
|
||||
});
|
||||
|
||||
it("denies when action does not match", () => {
|
||||
const user = {
|
||||
roles: [
|
||||
{
|
||||
name: "Operator",
|
||||
permissions: [{ resource: "tasks", action: "READ" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(hasPermission(user, "tasks", "WRITE")).toBe(false);
|
||||
});
|
||||
|
||||
it("always grants for admin role regardless of explicit permissions", () => {
|
||||
const adminUser = {
|
||||
roles: [{ name: "Admin", permissions: [] }],
|
||||
};
|
||||
|
||||
expect(hasPermission(adminUser, "admin:users", "READ")).toBe(true);
|
||||
expect(hasPermission(adminUser, "plugin:migration", "EXECUTE")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// [/DEF:frontend.src.lib.auth.__tests__.permissions:Module]
|
||||
103
frontend/src/lib/auth/permissions.js
Normal file
103
frontend/src/lib/auth/permissions.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// [DEF:frontend.src.lib.auth.permissions:Module]
|
||||
// @TIER: STANDARD
|
||||
// @SEMANTICS: auth, permissions, rbac, roles
|
||||
// @PURPOSE: Shared frontend RBAC utilities for route guards and menu visibility.
|
||||
// @LAYER: Domain
|
||||
// @RELATION: USED_BY -> frontend.src.components.auth.ProtectedRoute
|
||||
// @RELATION: USED_BY -> frontend.src.lib.components.layout.Sidebar
|
||||
// @INVARIANT: Admin role always bypasses explicit permission checks.
|
||||
|
||||
const KNOWN_ACTIONS = new Set(["READ", "WRITE", "EXECUTE", "DELETE"]);
|
||||
|
||||
function normalizeAction(action, fallback = "READ") {
|
||||
const normalized = String(action || "").trim().toUpperCase();
|
||||
if (!normalized) return fallback;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// [DEF:normalizePermissionRequirement:Function]
|
||||
// @PURPOSE: Convert permission requirement string to canonical resource/action tuple.
|
||||
// @PRE: Permission can be "resource" or "resource:ACTION" where resource itself may contain ":".
|
||||
// @POST: Returns normalized object with action in uppercase.
|
||||
export function normalizePermissionRequirement(permission, defaultAction = "READ") {
|
||||
const fallbackAction = normalizeAction(defaultAction, "READ");
|
||||
const rawPermission = String(permission || "").trim();
|
||||
|
||||
if (!rawPermission) {
|
||||
return { resource: null, action: fallbackAction };
|
||||
}
|
||||
|
||||
const parts = rawPermission.split(":");
|
||||
if (parts.length > 1) {
|
||||
const tail = normalizeAction(parts[parts.length - 1], fallbackAction);
|
||||
if (KNOWN_ACTIONS.has(tail)) {
|
||||
const resource = parts.slice(0, -1).join(":");
|
||||
return { resource, action: tail };
|
||||
}
|
||||
}
|
||||
|
||||
return { resource: rawPermission, action: fallbackAction };
|
||||
}
|
||||
// [/DEF:normalizePermissionRequirement:Function]
|
||||
|
||||
// [DEF:isAdminUser:Function]
|
||||
// @PURPOSE: Determine whether user has Admin role.
|
||||
// @PRE: user can be null or partially populated.
|
||||
// @POST: Returns true when at least one role name equals "Admin" (case-insensitive).
|
||||
export function isAdminUser(user) {
|
||||
const roles = Array.isArray(user?.roles) ? user.roles : [];
|
||||
return roles.some(
|
||||
(role) => String(role?.name || "").trim().toLowerCase() === "admin",
|
||||
);
|
||||
}
|
||||
// [/DEF:isAdminUser:Function]
|
||||
|
||||
// [DEF:hasPermission:Function]
|
||||
// @PURPOSE: Check if user has a required resource/action permission.
|
||||
// @PRE: user contains roles with permissions from /api/auth/me payload.
|
||||
// @POST: Returns true when requirement is empty, user is admin, or matching permission exists.
|
||||
export function hasPermission(user, requirement, action = "READ") {
|
||||
if (!requirement) return true;
|
||||
if (!user) return false;
|
||||
if (isAdminUser(user)) return true;
|
||||
|
||||
const { resource, action: requiredAction } = normalizePermissionRequirement(
|
||||
requirement,
|
||||
action,
|
||||
);
|
||||
if (!resource) return true;
|
||||
|
||||
const roles = Array.isArray(user.roles) ? user.roles : [];
|
||||
for (const role of roles) {
|
||||
const permissions = Array.isArray(role?.permissions) ? role.permissions : [];
|
||||
for (const permission of permissions) {
|
||||
if (typeof permission === "string") {
|
||||
const normalized = normalizePermissionRequirement(
|
||||
permission,
|
||||
requiredAction,
|
||||
);
|
||||
if (
|
||||
normalized.resource === resource &&
|
||||
normalized.action === requiredAction
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const permissionResource = String(permission?.resource || "").trim();
|
||||
const permissionAction = normalizeAction(permission?.action, "");
|
||||
if (
|
||||
permissionResource === resource &&
|
||||
permissionAction === requiredAction
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
// [/DEF:hasPermission:Function]
|
||||
|
||||
// [/DEF:frontend.src.lib.auth.permissions:Module]
|
||||
Reference in New Issue
Block a user