feat(rbac): enforce admin access checks and document permission model
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export * from "./rbac"
|
||||
|
||||
export const postStatusSchema = z.enum(["draft", "published"])
|
||||
|
||||
export const postSchema = z.object({
|
||||
|
||||
27
packages/content/src/rbac.test.ts
Normal file
27
packages/content/src/rbac.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
124
packages/content/src/rbac.ts
Normal file
124
packages/content/src/rbac.ts
Normal 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])
|
||||
}
|
||||
Reference in New Issue
Block a user