diff --git a/TODO.md b/TODO.md index ceffd36..9013166 100644 --- a/TODO.md +++ b/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 -- [ ] [P1] RBAC domain model finalized (roles, permissions, resource scopes) -- [ ] [P1] RBAC enforcement at route and action level in admin -- [ ] [P1] Permission matrix documented and tested +- [x] [P1] RBAC domain model finalized (roles, permissions, resource scopes) +- [x] [P1] RBAC enforcement at route and action level in admin +- [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] Shared CRUD validation strategy (Zod + server-side enforcement) - [ ] [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] Shared DB access via `@cms/db` - [~] [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] 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 - [ ] [P1] CI workflow for lint/typecheck/unit/e2e gates - [ ] [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 ### Documentation - [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] Environment and deployment runbook docs (dev/staging/production) - [ ] [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 refinement for artworks (medium, dimensions, year, framing, availability) - [ ] [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] Kanban workflow for commissions (new, scoped, in-progress, review, done) - [ ] [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] 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: media upload + artwork refinement display - [ ] [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 - [ ] [P2] Revision history for pages/navigation/media metadata - [ ] [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 ### Public App diff --git a/apps/admin/src/app/page.tsx b/apps/admin/src/app/page.tsx index 72d1533..fd076aa 100644 --- a/apps/admin/src/app/page.tsx +++ b/apps/admin/src/app/page.tsx @@ -1,10 +1,21 @@ +import { hasPermission } from "@cms/content/rbac" import { listPosts } from "@cms/db" import { Button } from "@cms/ui/button" import Link from "next/link" +import { redirect } from "next/navigation" + +import { resolveRoleFromServerContext } from "@/lib/access" export const dynamic = "force-dynamic" 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() return ( @@ -26,7 +37,7 @@ export default async function AdminHomePage() {

Posts

- +
diff --git a/apps/admin/src/app/todo/page.tsx b/apps/admin/src/app/todo/page.tsx index b322769..76a54ca 100644 --- a/apps/admin/src/app/todo/page.tsx +++ b/apps/admin/src/app/todo/page.tsx @@ -1,6 +1,10 @@ import { readFile } from "node:fs/promises" import path from "node:path" +import { hasPermission } from "@cms/content/rbac" import Link from "next/link" +import { redirect } from "next/navigation" + +import { resolveRoleFromServerContext } from "@/lib/access" export const dynamic = "force-dynamic" @@ -401,6 +405,12 @@ function filterButtonClass(active: boolean): string { export default async function AdminTodoPage(props: { searchParams?: SearchParamsInput | Promise }) { + const role = await resolveRoleFromServerContext() + + if (!role || !hasPermission(role, "roadmap:read", "global")) { + redirect("/unauthorized?required=roadmap:read&scope=global") + } + const content = await getTodoMarkdown() const sections = parseTodo(content) const progress = getProgressCounts(sections) diff --git a/apps/admin/src/app/unauthorized/page.tsx b/apps/admin/src/app/unauthorized/page.tsx new file mode 100644 index 0000000..88c6b61 --- /dev/null +++ b/apps/admin/src/app/unauthorized/page.tsx @@ -0,0 +1,57 @@ +import Link from "next/link" + +type SearchParams = Promise> + +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 ( +
+
+

Admin App

+

Access denied

+

+ You do not have the required role/permission for this admin route. +

+
+ +
+
+
+
Reason
+
{reason ?? "insufficient-permission"}
+
+ +
+
Required permission
+
{required ?? "n/a"}
+
+ +
+
Required scope
+
{scope ?? "n/a"}
+
+
+
+ + + Back to dashboard + +
+ ) +} diff --git a/apps/admin/src/lib/access.ts b/apps/admin/src/lib/access.ts new file mode 100644 index 0000000..c239790 --- /dev/null +++ b/apps/admin/src/lib/access.ts @@ -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[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 { + 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) +} diff --git a/apps/admin/src/middleware.ts b/apps/admin/src/middleware.ts new file mode 100644 index 0000000..245aaaa --- /dev/null +++ b/apps/admin/src/middleware.ts @@ -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).*)"], +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index cc82bb7..a1fc9f0 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -19,6 +19,7 @@ export default defineConfig({ { text: "Section Overview", link: "/product-engineering/" }, { text: "Getting Started", link: "/getting-started" }, { text: "Architecture", link: "/architecture" }, + { text: "RBAC And Permissions", link: "/product-engineering/rbac-permission-model" }, { text: "Workflow", link: "/workflow" }, ], }, diff --git a/docs/product-engineering/index.md b/docs/product-engineering/index.md index e8034d0..f0df482 100644 --- a/docs/product-engineering/index.md +++ b/docs/product-engineering/index.md @@ -6,6 +6,7 @@ This section covers platform and implementation documentation for engineers and - [Getting Started](/getting-started) - [Architecture](/architecture) +- [RBAC And Permissions](/product-engineering/rbac-permission-model) - [Workflow](/workflow) ## Scope diff --git a/docs/product-engineering/rbac-permission-model.md b/docs/product-engineering/rbac-permission-model.md new file mode 100644 index 0000000..1c426ad --- /dev/null +++ b/docs/product-engineering/rbac-permission-model.md @@ -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 diff --git a/packages/content/package.json b/packages/content/package.json index 60b50ce..67457a1 100644 --- a/packages/content/package.json +++ b/packages/content/package.json @@ -4,7 +4,8 @@ "private": true, "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./rbac": "./src/rbac.ts" }, "scripts": { "build": "tsc -p tsconfig.json", diff --git a/packages/content/src/index.ts b/packages/content/src/index.ts index d051cec..7f2a4e3 100644 --- a/packages/content/src/index.ts +++ b/packages/content/src/index.ts @@ -1,5 +1,7 @@ import { z } from "zod" +export * from "./rbac" + export const postStatusSchema = z.enum(["draft", "published"]) export const postSchema = z.object({ diff --git a/packages/content/src/rbac.test.ts b/packages/content/src/rbac.test.ts new file mode 100644 index 0000000..3a79911 --- /dev/null +++ b/packages/content/src/rbac.test.ts @@ -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) + }) +}) diff --git a/packages/content/src/rbac.ts b/packages/content/src/rbac.ts new file mode 100644 index 0000000..cdb2935 --- /dev/null +++ b/packages/content/src/rbac.ts @@ -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 +export type Permission = z.infer +export type PermissionScope = z.infer + +export type PermissionGrant = { + permission: Permission + scopes: PermissionScope[] +} + +const allPermissions = permissionSchema.options + +const allGlobalGrants: PermissionGrant[] = allPermissions.map((permission) => ({ + permission, + scopes: ["global"], +})) + +export const permissionMatrix: Record = { + 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 = { + 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]) +}