feat(rbac): enforce admin access checks and document permission model

This commit is contained in:
2026-02-10 12:16:36 +01:00
parent 4041a4ac4a
commit 947cb0a3d7
13 changed files with 458 additions and 8 deletions

View File

@@ -1,5 +1,7 @@
import { z } from "zod"
export * from "./rbac"
export const postStatusSchema = z.enum(["draft", "published"])
export const postSchema = z.object({

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest"
import { hasPermission, normalizeRole, permissionMatrix } from "./rbac"
describe("rbac model", () => {
it("normalizes valid roles", () => {
expect(normalizeRole("ADMIN")).toBe("admin")
expect(normalizeRole("manager")).toBe("manager")
expect(normalizeRole("unknown")).toBeNull()
})
it("grants admin full access", () => {
expect(hasPermission("admin", "users:manage_roles", "global")).toBe(true)
expect(hasPermission("admin", "news:publish", "global")).toBe(true)
})
it("enforces scope hierarchy", () => {
expect(hasPermission("editor", "news:write", "team")).toBe(true)
expect(hasPermission("editor", "news:write", "global")).toBe(false)
expect(hasPermission("editor", "news:publish", "own")).toBe(true)
})
it("keeps matrix explicit for non-admin roles", () => {
expect(permissionMatrix.editor.length).toBeGreaterThan(0)
expect(permissionMatrix.manager.length).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,124 @@
import { z } from "zod"
export const roleSchema = z.enum(["admin", "editor", "manager"])
export const permissionScopeSchema = z.enum(["own", "team", "global"])
export const permissionSchema = z.enum([
"dashboard:read",
"roadmap:read",
"pages:read",
"pages:write",
"pages:publish",
"navigation:read",
"navigation:write",
"media:read",
"media:write",
"media:refine",
"users:read",
"users:write",
"users:manage_roles",
"commissions:read",
"commissions:write",
"commissions:transition",
"banner:read",
"banner:write",
"news:read",
"news:write",
"news:publish",
])
export type Role = z.infer<typeof roleSchema>
export type Permission = z.infer<typeof permissionSchema>
export type PermissionScope = z.infer<typeof permissionScopeSchema>
export type PermissionGrant = {
permission: Permission
scopes: PermissionScope[]
}
const allPermissions = permissionSchema.options
const allGlobalGrants: PermissionGrant[] = allPermissions.map((permission) => ({
permission,
scopes: ["global"],
}))
export const permissionMatrix: Record<Role, PermissionGrant[]> = {
admin: allGlobalGrants,
manager: [
{ permission: "dashboard:read", scopes: ["global"] },
{ permission: "roadmap:read", scopes: ["global"] },
{ permission: "pages:read", scopes: ["global"] },
{ permission: "pages:write", scopes: ["global"] },
{ permission: "pages:publish", scopes: ["global"] },
{ permission: "navigation:read", scopes: ["global"] },
{ permission: "navigation:write", scopes: ["global"] },
{ permission: "media:read", scopes: ["global"] },
{ permission: "media:write", scopes: ["global"] },
{ permission: "media:refine", scopes: ["global"] },
{ permission: "users:read", scopes: ["global"] },
{ permission: "users:write", scopes: ["team"] },
{ permission: "commissions:read", scopes: ["global"] },
{ permission: "commissions:write", scopes: ["global"] },
{ permission: "commissions:transition", scopes: ["global"] },
{ permission: "banner:read", scopes: ["global"] },
{ permission: "banner:write", scopes: ["global"] },
{ permission: "news:read", scopes: ["global"] },
{ permission: "news:write", scopes: ["global"] },
{ permission: "news:publish", scopes: ["global"] },
],
editor: [
{ permission: "dashboard:read", scopes: ["global"] },
{ permission: "pages:read", scopes: ["team"] },
{ permission: "pages:write", scopes: ["team"] },
{ permission: "pages:publish", scopes: ["own"] },
{ permission: "navigation:read", scopes: ["team"] },
{ permission: "navigation:write", scopes: ["team"] },
{ permission: "media:read", scopes: ["team"] },
{ permission: "media:write", scopes: ["team"] },
{ permission: "media:refine", scopes: ["team"] },
{ permission: "users:read", scopes: ["own"] },
{ permission: "commissions:read", scopes: ["own"] },
{ permission: "commissions:write", scopes: ["own"] },
{ permission: "commissions:transition", scopes: ["own"] },
{ permission: "banner:read", scopes: ["global"] },
{ permission: "news:read", scopes: ["team"] },
{ permission: "news:write", scopes: ["team"] },
{ permission: "news:publish", scopes: ["own"] },
],
}
const scopeWeight: Record<PermissionScope, number> = {
own: 1,
team: 2,
global: 3,
}
export function normalizeRole(input: string | null | undefined): Role | null {
if (!input) {
return null
}
const parsed = roleSchema.safeParse(input.toLowerCase())
if (!parsed.success) {
return null
}
return parsed.data
}
export function hasPermission(
role: Role,
permission: Permission,
scope: PermissionScope = "global",
): boolean {
const grants = permissionMatrix[role]
const grant = grants.find((item) => item.permission === permission)
if (!grant) {
return false
}
return grant.scopes.some((grantedScope) => scopeWeight[grantedScope] >= scopeWeight[scope])
}