diff --git a/e2e/helpers/reset.ts b/e2e/helpers/reset.ts new file mode 100644 index 0000000..7acfa4a --- /dev/null +++ b/e2e/helpers/reset.ts @@ -0,0 +1,41 @@ +import { PrismaClient } from "@prisma/client" +import { PrismaPg } from "@prisma/adapter-pg" + +// The auth specs build state they cannot undo from the outside: enrolling a +// second factor writes a TwoFactor row, registering a passkey writes a credential +// bound to a CDP virtual authenticator that dies with the browser. CI never +// notices, its database is created fresh per run, but a local database keeps the +// leftovers and the next run fails: enable returns 401 on an already-enrolled +// user, and passkey sign-in hangs waiting for a credential no live authenticator +// holds. +// +// These run in beforeAll rather than a teardown on purpose. A crashed or +// interrupted run never reaches its teardown, so cleaning up front is what +// actually makes a rerun deterministic. + +async function withPrisma(fn: (prisma: PrismaClient) => Promise): Promise { + const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! }) + const prisma = new PrismaClient({ adapter }) + try { + return await fn(prisma) + } finally { + await prisma.$disconnect() + } +} + +export async function resetTwoFactorEnrollment(email: string): Promise { + await withPrisma(async (prisma) => { + const user = await prisma.user.findUnique({ where: { email }, select: { id: true } }) + if (!user) return + await prisma.twoFactor.deleteMany({ where: { userId: user.id } }) + await prisma.user.update({ where: { id: user.id }, data: { twoFactorEnabled: false } }) + }) +} + +export async function resetPasskeys(email: string): Promise { + await withPrisma(async (prisma) => { + const user = await prisma.user.findUnique({ where: { email }, select: { id: true } }) + if (!user) return + await prisma.passkey.deleteMany({ where: { userId: user.id } }) + }) +} diff --git a/e2e/passkey.spec.ts b/e2e/passkey.spec.ts index 4b12fc4..4feca27 100644 --- a/e2e/passkey.spec.ts +++ b/e2e/passkey.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from "@playwright/test" import { setAllowedMethods } from "./helpers/totp" import { addVirtualAuthenticator, tryGeneratePasskeyOptions } from "./helpers/passkey" +import { resetPasskeys } from "./helpers/reset" const PASSKEY_USER = { email: "passkey@datashield.local", password: "ChangeMe123!" } @@ -10,6 +11,13 @@ const PASSKEY_USER = { email: "passkey@datashield.local", password: "ChangeMe123 // race on a shared allowedAuthMethods across workers. test.describe.configure({ mode: "serial" }) +// Drop credentials left by an earlier local run. They are bound to a virtual +// authenticator that no longer exists, so sign-in would hang waiting on a +// credential nothing can satisfy. +test.beforeAll(async () => { + await resetPasskeys(PASSKEY_USER.email) +}) + // The policy gate must refuse passkey registration server-side, not merely hide // the setup card: with PASSKEY removed, the register-options endpoint (a GET // behind a fresh session) returns 403. diff --git a/e2e/rbac.spec.ts b/e2e/rbac.spec.ts new file mode 100644 index 0000000..b82730e --- /dev/null +++ b/e2e/rbac.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from "@playwright/test" + +const ADMIN = { email: "admin@datashield.local", password: "ChangeMe123!" } +const MEMBER = { email: "member@datashield.local", password: "ChangeMe123!" } + +test.describe.configure({ mode: "serial" }) + +async function login(page: import("@playwright/test").Page, u: { email: string; password: string }) { + await page.goto("/login") + await page.getByLabel("Email").fill(u.email) + await page.getByLabel("Password").fill(u.password) + await page.getByRole("button", { name: "Sign in", exact: true }).click() + await page.waitForURL("**/dashboard") +} + +// A Viewer (roles:read only, no roles:manage) can open Access and see roles but +// gets no "New role" mutation power server-side. Guards the read gate. +// Uses page.request, not the isolated request fixture, so the call carries the +// browser context cookies and reaches the permission check rather than 401. +test("a viewer can view roles but not create one", async ({ page }) => { + await login(page, MEMBER) + await page.goto("/access") + await expect(page.getByRole("heading", { name: "Access management" })).toBeVisible() + + // Direct API create must be forbidden for a viewer. + const res = await page.request.post("/api/roles", { + headers: { "Content-Type": "application/json" }, + data: { name: "Sneaky", permissions: [] }, + }) + expect(res.status()).toBe(403) +}) + +// An admin creates a plain role through the UI. The crown-jewel step-up path is +// covered at the API level by the Task 8/9 integration tests. +// The name is unique per run and deleted afterwards: a fixed name would survive +// in the dev database and make a rerun pass on the leftover row while the POST +// silently returned 409. +test("admin creates a role through the management UI", async ({ page }) => { + const roleName = `Playbook Author ${Date.now()}` + await login(page, ADMIN) + await page.goto("/access") + + await page.getByRole("button", { name: "New role" }).click() + await page.getByPlaceholder("Role name").fill(roleName) + await page.getByRole("checkbox").first().check() + await page.getByRole("button", { name: "Save" }).click() + await expect(page.getByText(roleName)).toBeVisible() + + const list = await (await page.request.get("/api/roles")).json() + const created = (list.roles as { id: string; name: string }[]).find((r) => r.name === roleName) + expect(created).toBeTruthy() + const del = await page.request.delete(`/api/roles/${created!.id}`) + expect(del.ok()).toBeTruthy() +}) diff --git a/e2e/seed.ts b/e2e/seed.ts index cd88283..9f5497b 100644 --- a/e2e/seed.ts +++ b/e2e/seed.ts @@ -2,7 +2,7 @@ import { PrismaClient } from "@prisma/client" import { PrismaPg } from "@prisma/adapter-pg" import bcrypt from "bcryptjs" import { seedPresetsForCompany, resolvePresetRoleId } from "@/lib/rbac/seed-roles" -import { ADMINISTRATOR } from "@/lib/rbac/presets" +import { ADMINISTRATOR, VIEWER_ROLE } from "@/lib/rbac/presets" // E2E fixture: one employee so a fresh instance counts as set up // (the dashboard redirects empty workspaces to /setup), plus a dedicated @@ -17,6 +17,12 @@ const MFA_PASSWORD = "ChangeMe123!" const PASSKEY_EMAIL = "passkey@datashield.local" const PASSKEY_PASSWORD = "ChangeMe123!" +const MANAGER_EMAIL = "manager@datashield.local" +const MANAGER_PASSWORD = "ChangeMe123!" + +const MEMBER_EMAIL = "member@datashield.local" +const MEMBER_PASSWORD = "ChangeMe123!" + // Sets (or resets) the credential-provider password for a user. Better Auth // stores it on a `credential` account row, so upsert that row rather than the // user. @@ -62,6 +68,25 @@ async function main() { }) await setPassword(mfaUser.id, MFA_PASSWORD) + // RBAC fixtures in the shared company: a manager holding users:manage and + // roles:read but NOT roles:manage, and a plain read-only member. The rbac + // spec uses them to assert the read gate from the outside. + const managerRoleId = await resolvePresetRoleId(prisma, company.id, "Security Manager") + const manager = await prisma.user.upsert({ + where: { email: MANAGER_EMAIL }, + update: {}, + create: { email: MANAGER_EMAIL, name: "Manager", roleId: managerRoleId, companyId: company.id }, + }) + await setPassword(manager.id, MANAGER_PASSWORD) + + const viewerRoleId = await resolvePresetRoleId(prisma, company.id, VIEWER_ROLE) + const member = await prisma.user.upsert({ + where: { email: MEMBER_EMAIL }, + update: {}, + create: { email: MEMBER_EMAIL, name: "Member", roleId: viewerRoleId, companyId: company.id }, + }) + await setPassword(member.id, MEMBER_PASSWORD) + // Passkey fixture: its own company so the passkey spec can flip PASSKEY in and // out of allowedAuthMethods without racing the two-factor spec, which mutates // the shared datashield.dev policy. Seeded with PASSKEY allowed so enrollment diff --git a/e2e/two-factor.spec.ts b/e2e/two-factor.spec.ts index 6214fbf..c327c37 100644 --- a/e2e/two-factor.spec.ts +++ b/e2e/two-factor.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from "@playwright/test" import { enrollTwoFactor, totpCode, setAllowedMethods, tryEnableTotp } from "./helpers/totp" import { latestEmailOtp, trySendEmailOtp } from "./helpers/email-otp" +import { resetTwoFactorEnrollment } from "./helpers/reset" const EMAIL = "mfa@datashield.local" const PASSWORD = "ChangeMe123!" @@ -11,6 +12,13 @@ const ADMIN = { email: "admin@datashield.local", password: "ChangeMe123!" } // gate, so it stays safe to run in parallel. test.describe.configure({ mode: "serial" }) +// Drop any enrollment left by an earlier local run, otherwise the enable call +// below returns 401 on an already-enrolled user. The serial chain then builds +// its own state: the second test enrolls, the last two rely on it. +test.beforeAll(async () => { + await resetTwoFactorEnrollment(EMAIL) +}) + // Guards the "decorative policy" fix: enrolling a method the company has not // allowed must be refused server-side, not merely hidden in the UI. test("company can forbid a method it has not allowed", async ({ request }) => { diff --git a/src/app/(dashboard)/access/page.tsx b/src/app/(dashboard)/access/page.tsx index 973ff0a..6129129 100644 --- a/src/app/(dashboard)/access/page.tsx +++ b/src/app/(dashboard)/access/page.tsx @@ -3,6 +3,8 @@ import { getSession } from "@/lib/auth/session" import { prisma } from "@/lib/prisma" import { getUserPermissions, authorize } from "@/lib/rbac/authorize" import { RolesManager } from "@/components/rbac/RolesManager" +import { UserRoleAssignment } from "@/components/rbac/UserRoleAssignment" +import { AuditTrail } from "@/components/rbac/AuditTrail" export default async function AccessPage() { const session = await getSession() @@ -10,6 +12,12 @@ export default async function AccessPage() { const perms = await getUserPermissions(prisma, session.user.roleId ?? null) if (!authorize(perms, "roles:read")) redirect("/dashboard") + // users:manage, not users:read: the section exists only to reassign roles, and + // READ_ONLY grants every ":read" permission, so gating on read would show a + // Viewer a dropdown the server refuses on every change. + const canManageUsers = authorize(perms, "users:manage") + const canReadAudit = authorize(perms, "audit:read") + return (
@@ -20,6 +28,18 @@ export default async function AccessPage() {

Roles

+ {canManageUsers && ( +
+

People

+ +
+ )} + {canReadAudit && ( +
+

Audit trail

+ +
+ )}
) } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 19567ec..281017e 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -27,6 +27,7 @@ const navItems = [ { href: "/data-sources", label: "Data Sources", icon: Database }, { href: "/data-api", label: "Data API", icon: KeyRound }, { href: "/notifications", label: "Notifications", icon: Send }, + { href: "/access", label: "Access", icon: ShieldCheck }, ] // Layers (within the aside stacking context): labels z-10 sit UNDER the rail diff --git a/src/components/rbac/AuditTrail.tsx b/src/components/rbac/AuditTrail.tsx new file mode 100644 index 0000000..a51a218 --- /dev/null +++ b/src/components/rbac/AuditTrail.tsx @@ -0,0 +1,57 @@ +"use client" + +import { useEffect, useState } from "react" + +type Entry = { + id: string + action: string + targetType: string + targetId: string | null + createdAt: string + actor: { email: string } | null +} + +const PAGE = 20 + +export function AuditTrail() { + const [entries, setEntries] = useState([]) + const [total, setTotal] = useState(0) + const [skip, setSkip] = useState(0) + + useEffect(() => { + void (async () => { + const res = await fetch(`/api/audit?take=${PAGE}&skip=${skip}`) + if (res.ok) { + const data = (await res.json()) as { entries: Entry[]; total: number } + setEntries(data.entries) + setTotal(data.total) + } + })() + }, [skip]) + + return ( +
+
    + {entries.map((e) => ( +
  • + {e.action} + + {e.actor?.email ?? "system"} - {new Date(e.createdAt).toLocaleString()} + +
  • + ))} +
+
+ {total} event(s) +
+ + +
+
+
+ ) +} diff --git a/src/components/rbac/RolesManager.tsx b/src/components/rbac/RolesManager.tsx index 9a3f923..4110d48 100644 --- a/src/components/rbac/RolesManager.tsx +++ b/src/components/rbac/RolesManager.tsx @@ -20,6 +20,9 @@ export function RolesManager() { const [query, setQuery] = useState("") const [page, setPage] = useState(0) const [editing, setEditing] = useState(null) + // Explicit, because a new role starts with no name and no permissions, so + // the form's own fields cannot tell "closed" from "creating". + const [formOpen, setFormOpen] = useState(false) const [perms, setPerms] = useState>(new Set()) const [name, setName] = useState("") const [error, setError] = useState(null) @@ -44,6 +47,15 @@ export function RolesManager() { setName(role?.name ?? "") setPerms(new Set(role?.permissions ?? [])) setError(null) + setFormOpen(true) + } + + function closeForm() { + setEditing(null) + setName("") + setPerms(new Set()) + setError(null) + setFormOpen(false) } // Runs a mutation; on STEP_UP_REQUIRED it stashes the retry and opens the @@ -63,7 +75,7 @@ export function RolesManager() { setError(((await res.json().catch(() => ({}))) as { error?: string }).error ?? "Failed") return } - setEditing(null) + closeForm() setStepUpRetry(null) await load() } @@ -156,7 +168,7 @@ export function RolesManager() { - {(editing || name || perms.size > 0) && ( + {formOpen && (
{error &&

{error}

}
-