Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions e2e/helpers/reset.ts
Original file line number Diff line number Diff line change
@@ -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<T>(fn: (prisma: PrismaClient) => Promise<T>): Promise<T> {
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<void> {
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<void> {
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 } })
})
}
8 changes: 8 additions & 0 deletions e2e/passkey.spec.ts
Original file line number Diff line number Diff line change
@@ -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!" }

Expand All @@ -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.
Expand Down
54 changes: 54 additions & 0 deletions e2e/rbac.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
27 changes: 26 additions & 1 deletion e2e/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions e2e/two-factor.spec.ts
Original file line number Diff line number Diff line change
@@ -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!"
Expand All @@ -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 }) => {
Expand Down
20 changes: 20 additions & 0 deletions src/app/(dashboard)/access/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@ 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()
if (!session) redirect("/login")
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 (
<main className="mx-auto max-w-4xl space-y-6 p-6">
<div>
Expand All @@ -20,6 +28,18 @@ export default async function AccessPage() {
<h2 className="text-sm font-medium text-foreground">Roles</h2>
<RolesManager />
</section>
{canManageUsers && (
<section className="space-y-2">
<h2 className="text-sm font-medium text-foreground">People</h2>
<UserRoleAssignment />
</section>
)}
{canReadAudit && (
<section className="space-y-2">
<h2 className="text-sm font-medium text-foreground">Audit trail</h2>
<AuditTrail />
</section>
)}
</main>
)
}
1 change: 1 addition & 0 deletions src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions src/components/rbac/AuditTrail.tsx
Original file line number Diff line number Diff line change
@@ -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<Entry[]>([])
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 (
<div className="space-y-2">
<ul className="divide-y divide-border/60 rounded-lg border border-border/60 text-xs">
{entries.map((e) => (
<li key={e.id} className="flex items-center justify-between px-3 py-2">
<span className="text-foreground">{e.action}</span>
<span className="text-muted-foreground">
{e.actor?.email ?? "system"} - {new Date(e.createdAt).toLocaleString()}
</span>
</li>
))}
</ul>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{total} event(s)</span>
<div className="flex gap-2">
<button disabled={skip === 0} onClick={() => setSkip((s) => Math.max(0, s - PAGE))} className="disabled:opacity-40">
Prev
</button>
<button disabled={skip + PAGE >= total} onClick={() => setSkip((s) => s + PAGE)} className="disabled:opacity-40">
Next
</button>
</div>
</div>
</div>
)
}
20 changes: 16 additions & 4 deletions src/components/rbac/RolesManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export function RolesManager() {
const [query, setQuery] = useState("")
const [page, setPage] = useState(0)
const [editing, setEditing] = useState<Role | null>(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<Set<string>>(new Set())
const [name, setName] = useState("")
const [error, setError] = useState<string | null>(null)
Expand All @@ -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
Expand All @@ -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()
}
Expand Down Expand Up @@ -156,7 +168,7 @@ export function RolesManager() {
</div>
</div>

{(editing || name || perms.size > 0) && (
{formOpen && (
<div className="space-y-3 rounded-xl border border-border p-3">
<input
placeholder="Role name"
Expand All @@ -167,8 +179,8 @@ export function RolesManager() {
<PermissionEditor selected={perms} onChange={setPerms} />
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex justify-end gap-2">
<button onClick={() => startEdit(null)} className="text-sm text-muted-foreground">
Clear
<button onClick={closeForm} className="text-sm text-muted-foreground">
Cancel
</button>
<button onClick={save} className="rounded-lg bg-primary px-3 py-1.5 text-sm text-primary-foreground">
Save
Expand Down
Loading