feat(rbac): enforce admin access checks and document permission model
This commit is contained in:
24
TODO.md
24
TODO.md
@@ -18,9 +18,14 @@ This file is the single source of truth for roadmap and delivery progress.
|
|||||||
|
|
||||||
### MVP1 Gate: Mandatory Before Feature Work
|
### MVP1 Gate: Mandatory Before Feature Work
|
||||||
|
|
||||||
- [ ] [P1] RBAC domain model finalized (roles, permissions, resource scopes)
|
- [x] [P1] RBAC domain model finalized (roles, permissions, resource scopes)
|
||||||
- [ ] [P1] RBAC enforcement at route and action level in admin
|
- [x] [P1] RBAC enforcement at route and action level in admin
|
||||||
- [ ] [P1] Permission matrix documented and tested
|
- [x] [P1] Permission matrix documented and tested
|
||||||
|
- [ ] [P1] Integrate Better Auth core configuration and session wiring
|
||||||
|
- [ ] [P1] Bootstrap first-run owner account creation when users table is empty
|
||||||
|
- [ ] [P1] Enforce invariant: exactly one owner user must always exist
|
||||||
|
- [ ] [P1] Create hidden technical support user by default (non-demotable, non-deletable)
|
||||||
|
- [ ] [P1] Admin registration policy control (allow/deny self-registration for admin panel)
|
||||||
- [ ] [P1] Reusable CRUD base patterns (list/detail/editor/service/repository)
|
- [ ] [P1] Reusable CRUD base patterns (list/detail/editor/service/repository)
|
||||||
- [ ] [P1] Shared CRUD validation strategy (Zod + server-side enforcement)
|
- [ ] [P1] Shared CRUD validation strategy (Zod + server-side enforcement)
|
||||||
- [ ] [P1] Shared error and audit hooks for CRUD mutations
|
- [ ] [P1] Shared error and audit hooks for CRUD mutations
|
||||||
@@ -31,7 +36,7 @@ This file is the single source of truth for roadmap and delivery progress.
|
|||||||
- [x] [P1] App Router + TypeScript + `src/` structure
|
- [x] [P1] App Router + TypeScript + `src/` structure
|
||||||
- [x] [P1] Shared DB access via `@cms/db`
|
- [x] [P1] Shared DB access via `@cms/db`
|
||||||
- [~] [P2] Base admin dashboard shell and roadmap page (`/todo`)
|
- [~] [P2] Base admin dashboard shell and roadmap page (`/todo`)
|
||||||
- [ ] [P1] Authentication and session model (`admin`, `editor`, `manager`)
|
- [~] [P1] Authentication and session model (`admin`, `editor`, `manager`)
|
||||||
- [ ] [P1] Protected admin routes and session handling
|
- [ ] [P1] Protected admin routes and session handling
|
||||||
- [ ] [P1] Core admin IA (pages/media/users/commissions/settings)
|
- [ ] [P1] Core admin IA (pages/media/users/commissions/settings)
|
||||||
|
|
||||||
@@ -50,13 +55,13 @@ This file is the single source of truth for roadmap and delivery progress.
|
|||||||
- [x] [P1] Playwright baseline with web/admin projects
|
- [x] [P1] Playwright baseline with web/admin projects
|
||||||
- [ ] [P1] CI workflow for lint/typecheck/unit/e2e gates
|
- [ ] [P1] CI workflow for lint/typecheck/unit/e2e gates
|
||||||
- [ ] [P1] Test data strategy (seed fixtures + isolated e2e data)
|
- [ ] [P1] Test data strategy (seed fixtures + isolated e2e data)
|
||||||
- [ ] [P1] RBAC policy unit tests and permission regression suite
|
- [~] [P1] RBAC policy unit tests and permission regression suite
|
||||||
- [ ] [P1] CRUD contract tests for shared service patterns
|
- [ ] [P1] CRUD contract tests for shared service patterns
|
||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
|
|
||||||
- [x] [P1] Docs tool baseline added (`docs/` via VitePress)
|
- [x] [P1] Docs tool baseline added (`docs/` via VitePress)
|
||||||
- [ ] [P1] RBAC and permission model documentation in docs site
|
- [x] [P1] RBAC and permission model documentation in docs site
|
||||||
- [ ] [P1] CRUD base patterns documentation and examples
|
- [ ] [P1] CRUD base patterns documentation and examples
|
||||||
- [ ] [P1] Environment and deployment runbook docs (dev/staging/production)
|
- [ ] [P1] Environment and deployment runbook docs (dev/staging/production)
|
||||||
- [ ] [P2] API and domain glossary pages
|
- [ ] [P2] API and domain glossary pages
|
||||||
@@ -92,6 +97,8 @@ This file is the single source of truth for roadmap and delivery progress.
|
|||||||
- [ ] [P1] Media enrichment metadata (alt text, copyright, author, source, tags)
|
- [ ] [P1] Media enrichment metadata (alt text, copyright, author, source, tags)
|
||||||
- [ ] [P1] Media refinement for artworks (medium, dimensions, year, framing, availability)
|
- [ ] [P1] Media refinement for artworks (medium, dimensions, year, framing, availability)
|
||||||
- [ ] [P1] Users management (invite, roles, status)
|
- [ ] [P1] Users management (invite, roles, status)
|
||||||
|
- [ ] [P1] Disable/ban user function and enforcement in auth/session checks
|
||||||
|
- [ ] [P1] Owner/support protection rules in user management actions (cannot delete/demote)
|
||||||
- [ ] [P1] Commissions management (request intake, owner, due date, notes)
|
- [ ] [P1] Commissions management (request intake, owner, due date, notes)
|
||||||
- [ ] [P1] Kanban workflow for commissions (new, scoped, in-progress, review, done)
|
- [ ] [P1] Kanban workflow for commissions (new, scoped, in-progress, review, done)
|
||||||
- [ ] [P1] Header banner management (message, CTA, active window)
|
- [ ] [P1] Header banner management (message, CTA, active window)
|
||||||
@@ -116,6 +123,8 @@ This file is the single source of truth for roadmap and delivery progress.
|
|||||||
|
|
||||||
- [ ] [P1] Unit tests for content schemas and service logic
|
- [ ] [P1] Unit tests for content schemas and service logic
|
||||||
- [ ] [P1] Component tests for admin forms (pages/media/navigation)
|
- [ ] [P1] Component tests for admin forms (pages/media/navigation)
|
||||||
|
- [ ] [P1] Integration tests for owner invariant and hidden support-user protection
|
||||||
|
- [ ] [P1] Integration tests for registration allow/deny behavior
|
||||||
- [ ] [P1] E2E happy paths: create page, publish, see on public app
|
- [ ] [P1] E2E happy paths: create page, publish, see on public app
|
||||||
- [ ] [P1] E2E happy paths: media upload + artwork refinement display
|
- [ ] [P1] E2E happy paths: media upload + artwork refinement display
|
||||||
- [ ] [P1] E2E happy paths: commissions kanban transitions
|
- [ ] [P1] E2E happy paths: commissions kanban transitions
|
||||||
@@ -127,6 +136,9 @@ This file is the single source of truth for roadmap and delivery progress.
|
|||||||
- [ ] [P1] Audit log for key content operations
|
- [ ] [P1] Audit log for key content operations
|
||||||
- [ ] [P2] Revision history for pages/navigation/media metadata
|
- [ ] [P2] Revision history for pages/navigation/media metadata
|
||||||
- [ ] [P1] Permission matrix refinement with granular scopes
|
- [ ] [P1] Permission matrix refinement with granular scopes
|
||||||
|
- [ ] [P1] Verify email pipeline and operational templates (welcome/verify/resend)
|
||||||
|
- [ ] [P1] Forgot password/reset password pipeline and support tooling
|
||||||
|
- [ ] [P2] GUI page to edit role-permission mappings with safety guardrails
|
||||||
- [ ] [P2] Error boundaries and UX fallback states
|
- [ ] [P2] Error boundaries and UX fallback states
|
||||||
|
|
||||||
### Public App
|
### Public App
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
|
import { hasPermission } from "@cms/content/rbac"
|
||||||
import { listPosts } from "@cms/db"
|
import { listPosts } from "@cms/db"
|
||||||
import { Button } from "@cms/ui/button"
|
import { Button } from "@cms/ui/button"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
|
import { resolveRoleFromServerContext } from "@/lib/access"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export default async function AdminHomePage() {
|
export default async function AdminHomePage() {
|
||||||
|
const role = await resolveRoleFromServerContext()
|
||||||
|
|
||||||
|
if (!role || !hasPermission(role, "news:read", "team")) {
|
||||||
|
redirect("/unauthorized?required=news:read&scope=team")
|
||||||
|
}
|
||||||
|
|
||||||
|
const canCreatePost = hasPermission(role, "news:write", "team")
|
||||||
const posts = await listPosts()
|
const posts = await listPosts()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -26,7 +37,7 @@ export default async function AdminHomePage() {
|
|||||||
<section className="rounded-xl border border-neutral-200 p-6">
|
<section className="rounded-xl border border-neutral-200 p-6">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-xl font-medium">Posts</h2>
|
<h2 className="text-xl font-medium">Posts</h2>
|
||||||
<Button>Create post</Button>
|
<Button disabled={!canCreatePost}>Create post</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { readFile } from "node:fs/promises"
|
import { readFile } from "node:fs/promises"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import { hasPermission } from "@cms/content/rbac"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
|
import { resolveRoleFromServerContext } from "@/lib/access"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -401,6 +405,12 @@ function filterButtonClass(active: boolean): string {
|
|||||||
export default async function AdminTodoPage(props: {
|
export default async function AdminTodoPage(props: {
|
||||||
searchParams?: SearchParamsInput | Promise<SearchParamsInput>
|
searchParams?: SearchParamsInput | Promise<SearchParamsInput>
|
||||||
}) {
|
}) {
|
||||||
|
const role = await resolveRoleFromServerContext()
|
||||||
|
|
||||||
|
if (!role || !hasPermission(role, "roadmap:read", "global")) {
|
||||||
|
redirect("/unauthorized?required=roadmap:read&scope=global")
|
||||||
|
}
|
||||||
|
|
||||||
const content = await getTodoMarkdown()
|
const content = await getTodoMarkdown()
|
||||||
const sections = parseTodo(content)
|
const sections = parseTodo(content)
|
||||||
const progress = getProgressCounts(sections)
|
const progress = getProgressCounts(sections)
|
||||||
|
|||||||
57
apps/admin/src/app/unauthorized/page.tsx
Normal file
57
apps/admin/src/app/unauthorized/page.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
type SearchParams = Promise<Record<string, string | string[] | undefined>>
|
||||||
|
|
||||||
|
function getSingleValue(input: string | string[] | undefined): string | undefined {
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
return input[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function UnauthorizedPage({ searchParams }: { searchParams: SearchParams }) {
|
||||||
|
const params = await searchParams
|
||||||
|
|
||||||
|
const required = getSingleValue(params.required)
|
||||||
|
const scope = getSingleValue(params.scope)
|
||||||
|
const reason = getSingleValue(params.reason)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-screen w-full max-w-xl flex-col gap-6 px-6 py-20">
|
||||||
|
<header className="space-y-3">
|
||||||
|
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Admin App</p>
|
||||||
|
<h1 className="text-4xl font-semibold tracking-tight">Access denied</h1>
|
||||||
|
<p className="text-neutral-600">
|
||||||
|
You do not have the required role/permission for this admin route.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-neutral-200 p-5">
|
||||||
|
<dl className="space-y-2 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<dt className="text-neutral-500">Reason</dt>
|
||||||
|
<dd className="font-medium text-neutral-800">{reason ?? "insufficient-permission"}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<dt className="text-neutral-500">Required permission</dt>
|
||||||
|
<dd className="font-medium text-neutral-800">{required ?? "n/a"}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<dt className="text-neutral-500">Required scope</dt>
|
||||||
|
<dd className="font-medium text-neutral-800">{scope ?? "n/a"}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex w-fit rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
|
||||||
|
>
|
||||||
|
Back to dashboard
|
||||||
|
</Link>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
105
apps/admin/src/lib/access.ts
Normal file
105
apps/admin/src/lib/access.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { hasPermission, normalizeRole, type PermissionScope, type Role } from "@cms/content/rbac"
|
||||||
|
import { cookies, headers } from "next/headers"
|
||||||
|
import type { NextRequest } from "next/server"
|
||||||
|
|
||||||
|
type RoutePermission = {
|
||||||
|
permission: Parameters<typeof hasPermission>[1]
|
||||||
|
scope: PermissionScope
|
||||||
|
}
|
||||||
|
|
||||||
|
type GuardRule = {
|
||||||
|
route: RegExp
|
||||||
|
requirement: RoutePermission | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const guardRules: GuardRule[] = [
|
||||||
|
{
|
||||||
|
route: /^\/unauthorized(?:\/|$)/,
|
||||||
|
requirement: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: /^\/todo(?:\/|$)/,
|
||||||
|
requirement: {
|
||||||
|
permission: "roadmap:read",
|
||||||
|
scope: "global",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: /^\/(?:$|\?)/,
|
||||||
|
requirement: {
|
||||||
|
permission: "dashboard:read",
|
||||||
|
scope: "global",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function resolveDefaultRole(): Role | null {
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeRole(process.env.CMS_DEV_ROLE ?? "admin")
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRoleFromRawValue(raw: string | null | undefined): Role | null {
|
||||||
|
return normalizeRole(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRoleFromRequest(request: NextRequest): Role | null {
|
||||||
|
const roleFromCookie = request.cookies.get("cms_role")?.value
|
||||||
|
const roleFromHeader = request.headers.get("x-cms-role")
|
||||||
|
|
||||||
|
const resolved = resolveRoleFromRawValue(roleFromCookie ?? roleFromHeader)
|
||||||
|
|
||||||
|
if (resolved) {
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveDefaultRole()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveRoleFromServerContext(): Promise<Role | null> {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const headerStore = await headers()
|
||||||
|
|
||||||
|
const roleFromCookie = cookieStore.get("cms_role")?.value
|
||||||
|
const roleFromHeader = headerStore.get("x-cms-role")
|
||||||
|
|
||||||
|
const resolved = resolveRoleFromRawValue(roleFromCookie ?? roleFromHeader)
|
||||||
|
|
||||||
|
if (resolved) {
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveDefaultRole()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRequiredPermission(pathname: string): RoutePermission {
|
||||||
|
for (const rule of guardRules) {
|
||||||
|
if (rule.route.test(pathname)) {
|
||||||
|
return (
|
||||||
|
rule.requirement ?? {
|
||||||
|
permission: "dashboard:read",
|
||||||
|
scope: "global",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
permission: "dashboard:read",
|
||||||
|
scope: "global",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAccessRoute(role: Role, pathname: string): boolean {
|
||||||
|
const rule = guardRules.find((item) => item.route.test(pathname))
|
||||||
|
|
||||||
|
if (rule && rule.requirement === null) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const requirement = getRequiredPermission(pathname)
|
||||||
|
|
||||||
|
return hasPermission(role, requirement.permission, requirement.scope)
|
||||||
|
}
|
||||||
37
apps/admin/src/middleware.ts
Normal file
37
apps/admin/src/middleware.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server"
|
||||||
|
|
||||||
|
import { canAccessRoute, getRequiredPermission, resolveRoleFromRequest } from "@/lib/access"
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl
|
||||||
|
|
||||||
|
const role = resolveRoleFromRequest(request)
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
const unauthorizedUrl = request.nextUrl.clone()
|
||||||
|
unauthorizedUrl.pathname = "/unauthorized"
|
||||||
|
unauthorizedUrl.searchParams.set("reason", "missing-role")
|
||||||
|
|
||||||
|
return NextResponse.redirect(unauthorizedUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canAccessRoute(role, pathname)) {
|
||||||
|
const unauthorizedUrl = request.nextUrl.clone()
|
||||||
|
unauthorizedUrl.pathname = "/unauthorized"
|
||||||
|
|
||||||
|
const required = getRequiredPermission(pathname)
|
||||||
|
unauthorizedUrl.searchParams.set("required", required.permission)
|
||||||
|
unauthorizedUrl.searchParams.set("scope", required.scope)
|
||||||
|
|
||||||
|
return NextResponse.redirect(unauthorizedUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = NextResponse.next()
|
||||||
|
response.headers.set("x-cms-role", role)
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ export default defineConfig({
|
|||||||
{ text: "Section Overview", link: "/product-engineering/" },
|
{ text: "Section Overview", link: "/product-engineering/" },
|
||||||
{ text: "Getting Started", link: "/getting-started" },
|
{ text: "Getting Started", link: "/getting-started" },
|
||||||
{ text: "Architecture", link: "/architecture" },
|
{ text: "Architecture", link: "/architecture" },
|
||||||
|
{ text: "RBAC And Permissions", link: "/product-engineering/rbac-permission-model" },
|
||||||
{ text: "Workflow", link: "/workflow" },
|
{ text: "Workflow", link: "/workflow" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ This section covers platform and implementation documentation for engineers and
|
|||||||
|
|
||||||
- [Getting Started](/getting-started)
|
- [Getting Started](/getting-started)
|
||||||
- [Architecture](/architecture)
|
- [Architecture](/architecture)
|
||||||
|
- [RBAC And Permissions](/product-engineering/rbac-permission-model)
|
||||||
- [Workflow](/workflow)
|
- [Workflow](/workflow)
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|||||||
62
docs/product-engineering/rbac-permission-model.md
Normal file
62
docs/product-engineering/rbac-permission-model.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# RBAC And Permission Model
|
||||||
|
|
||||||
|
This document defines the current role model, permission matrix, and scope semantics used by the admin app.
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
- `admin`: full system access
|
||||||
|
- `manager`: broad operational access with selective limitations
|
||||||
|
- `editor`: content-focused access with reduced user-management privileges
|
||||||
|
|
||||||
|
## Permission Scopes
|
||||||
|
|
||||||
|
- `own`: applies to records the user owns
|
||||||
|
- `team`: applies to records within the user's team/org unit
|
||||||
|
- `global`: applies across all records
|
||||||
|
|
||||||
|
Scope hierarchy (higher includes lower):
|
||||||
|
|
||||||
|
- `global` -> `team` -> `own`
|
||||||
|
|
||||||
|
## Permission Matrix Summary
|
||||||
|
|
||||||
|
### Admin
|
||||||
|
|
||||||
|
- All permissions at `global` scope
|
||||||
|
|
||||||
|
### Manager
|
||||||
|
|
||||||
|
- Dashboard and roadmap read: `global`
|
||||||
|
- Pages, navigation, media, commissions, banner, news: `global`
|
||||||
|
- Users: `read` at `global`, `write` at `team`
|
||||||
|
|
||||||
|
### Editor
|
||||||
|
|
||||||
|
- Dashboard: `read` at `global`
|
||||||
|
- Pages/navigation/media/news: mostly `team`
|
||||||
|
- Publish and workflow transitions: mostly `own`
|
||||||
|
- Users and commissions: mostly `own`
|
||||||
|
- Banner: `read` at `global`
|
||||||
|
|
||||||
|
## Enforcement Layers
|
||||||
|
|
||||||
|
- Route-level: `apps/admin/src/middleware.ts`
|
||||||
|
- Action-level: server component checks in admin pages (`/` and `/todo`)
|
||||||
|
- Shared model + checks: `packages/content/src/rbac.ts`
|
||||||
|
|
||||||
|
## Dev Role Fallback
|
||||||
|
|
||||||
|
For local development only:
|
||||||
|
|
||||||
|
- If no role cookie/header is present and environment is not production,
|
||||||
|
role falls back to `CMS_DEV_ROLE` or `admin`.
|
||||||
|
|
||||||
|
Use this only as bootstrap behavior until full auth/session integration is finished.
|
||||||
|
|
||||||
|
## Related Tasks
|
||||||
|
|
||||||
|
See `TODO.md` MVP0 gate items:
|
||||||
|
|
||||||
|
- RBAC domain model finalized
|
||||||
|
- RBAC route/action enforcement
|
||||||
|
- Permission matrix documented and tested
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts",
|
||||||
|
"./rbac": "./src/rbac.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export * from "./rbac"
|
||||||
|
|
||||||
export const postStatusSchema = z.enum(["draft", "published"])
|
export const postStatusSchema = z.enum(["draft", "published"])
|
||||||
|
|
||||||
export const postSchema = z.object({
|
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