diff --git a/src/app/projects/[projectId]/layout.tsx b/src/app/projects/[projectId]/layout.tsx index 5ebf7d74..4c9c287a 100644 --- a/src/app/projects/[projectId]/layout.tsx +++ b/src/app/projects/[projectId]/layout.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { ProjectsNav, type ProjectsNavItem } from "../components/ProjectsNav"; -import { requireProjectAccess } from "../authz"; +import { requireProjectAccess } from "../guards"; function projectNav(projectId: string): ProjectsNavItem[] { const base = `/projects/${projectId}`; diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index e84d40da..5f72fcd1 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -1,4 +1,4 @@ -import { requireProjectAccess } from "../authz"; +import { requireProjectAccess } from "../guards"; export const metadata = { title: "Project dashboard | Ara", diff --git a/src/app/projects/authz.ts b/src/app/projects/authz.ts deleted file mode 100644 index a9180e85..00000000 --- a/src/app/projects/authz.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { getServerSession } from "next-auth"; -import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; -import { redirect } from "next/navigation"; -import type { Session } from "next-auth"; - -/** - * Temporary authorization seam for the Ara Projects route tree. - * - * TODO(#408): replace the bodies below with calls into - * `src/lib/projects/authz.ts` once that module lands. This file exists only so - * the route shell has a single, obvious place to wire authorization into — - * deliberately *not* a second implementation of the rules. - * - * The visibility rule #408 will enforce: - * - internal users (`@domna.homes`) → every Project - * - client-org members → Projects where - * `project.organisation_id` is their org - * - contractor-org members → Projects where their org is assigned - * to one of the Project's Workstreams - * (`project_workstream_contractor`) - * - * Note that resolving a user to an organisation does not exist yet either — - * that resolution is part of #408, not of this shell. - */ - -/** - * Guards a Projects route: requires a signed-in user and returns the session. - * - * Unauthenticated requests to `/projects/*` are already turned away by - * `src/middleware.ts`; this is the defence-in-depth check for the server - * component itself, matching the `bulk-upload/page.tsx` pattern. - */ -export async function requireProjectsSession(): Promise { - const session = await getServerSession(AuthOptions); - if (!session) redirect("/"); - return session; -} - -/** - * Whether `session`'s user may see the Project `projectId`. - * - * TODO(#408): delegate to the real visibility rule. Until then this only - * asserts a session exists — the shell must not ship its own inlined - * permission logic, so it deliberately does not narrow further. - */ -export async function canViewProject( - session: Session, - projectId: string -): Promise { - void projectId; - return Boolean(session?.user); -} - -/** - * Guards a single-Project route. Throws the caller into a 404 rather than a - * 403 so that Project existence is not leaked to users outside its org. - */ -export async function requireProjectAccess(projectId: string): Promise { - const session = await requireProjectsSession(); - if (!(await canViewProject(session, projectId))) { - // TODO(#408): notFound() once canViewProject can actually return false. - redirect("/projects"); - } - return session; -} diff --git a/src/app/projects/guards.ts b/src/app/projects/guards.ts new file mode 100644 index 00000000..2f6dac3e --- /dev/null +++ b/src/app/projects/guards.ts @@ -0,0 +1,88 @@ +/** + * Route guards for the Ara Projects tree. + * + * This is glue, not policy. It resolves the three things a server component + * cannot get from a pure function — the NextAuth session, the user's + * organisation memberships, and the project's facts — hands them to the real + * guards in `@/lib/projects/authz`, and turns a refusal into a Next.js + * navigation. It contains **no permission rules of its own**; every decision + * below is a call into that module. + * + * Layering: `@/lib/projects/authz` decides (pure, no db), + * `@/app/repositories/projects/authzRepository` loads (Drizzle), this file + * navigates (redirect / notFound). The dependency only ever points that way. + */ +import { getServerSession } from "next-auth"; +import { notFound, redirect } from "next/navigation"; +import type { Session } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { + loadAuthzUserByEmail, + loadProjectAuthzFacts, +} from "@/app/repositories/projects/authzRepository"; +import { + canViewProject, + type AuthzUser, + type ProjectAuthzFacts, +} from "@/lib/projects/authz"; + +/** + * Requires a signed-in user and returns the session. + * + * Unauthenticated requests to `/projects/*` are already turned away by + * `src/middleware.ts`; this is the defence-in-depth check for the server + * component itself, matching the `bulk-upload/page.tsx` pattern. The redirect + * target matches the middleware's (`/`, the sign-in page). + */ +export async function requireProjectsSession(): Promise { + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) redirect("/"); + return session; +} + +/** + * Requires a signed-in user and resolves them to an `AuthzUser` — the shape + * every guard in `@/lib/projects/authz` takes. + * + * A session whose email has no `user` row is treated as no session at all: it + * cannot be authorized against anything, so it goes back to sign-in rather + * than falling through to an empty-membership user that silently sees nothing. + */ +export async function requireProjectsUser(): Promise { + const session = await requireProjectsSession(); + const user = await loadAuthzUserByEmail(session.user.email!); + if (!user) redirect("/"); + return user; +} + +/** + * Parses a `[projectId]` path segment into the bigint PK it addresses. + * + * Returns null for anything that is not a bare non-negative integer, so a junk + * segment becomes a 404 rather than a Drizzle error. + */ +export function parseProjectId(projectId: string): bigint | null { + return /^\d+$/.test(projectId) ? BigInt(projectId) : null; +} + +/** + * Guards a single-Project route, returning the facts the page will need. + * + * A project the user may not see is a 404, not a 403 — Project existence must + * not leak to users outside its organisation, so "no such project" and "not + * yours" are deliberately indistinguishable. + */ +export async function requireProjectAccess(projectId: string): Promise<{ + user: AuthzUser; + project: ProjectAuthzFacts; +}> { + const user = await requireProjectsUser(); + + const id = parseProjectId(projectId); + if (id === null) notFound(); + + const project = await loadProjectAuthzFacts(id); + if (!project || !canViewProject(user, project)) notFound(); + + return { user, project }; +} diff --git a/src/app/projects/layout.tsx b/src/app/projects/layout.tsx index 3bc5808d..276d4065 100644 --- a/src/app/projects/layout.tsx +++ b/src/app/projects/layout.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { ProjectsNav, type ProjectsNavItem } from "./components/ProjectsNav"; -import { requireProjectsSession } from "./authz"; +import { requireProjectsSession } from "./guards"; const TOP_LEVEL_NAV: ProjectsNavItem[] = [ { label: "Projects", href: "/projects", icon: "projects", exact: true }, diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index c0da526a..e1a8b423 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -1,5 +1,5 @@ import { Squares2X2Icon } from "@heroicons/react/24/outline"; -import { requireProjectsSession } from "./authz"; +import { requireProjectsSession } from "./guards"; export const metadata = { title: "Projects | Ara", diff --git a/src/app/repositories/projects/authzRepository.ts b/src/app/repositories/projects/authzRepository.ts index 418f6e55..0b5d08f7 100644 --- a/src/app/repositories/projects/authzRepository.ts +++ b/src/app/repositories/projects/authzRepository.ts @@ -67,6 +67,28 @@ export async function loadAuthzUser(userId: bigint): Promise { return { id: found.id, email: found.email, organisationIds }; } +/** + * Load a user plus their organisation memberships, keyed by email. + * + * Server components and route handlers hold a NextAuth session, and the only + * identifier that session reliably carries is the email: `session.user.dbId` + * is populated by the `jwt` callback at initial sign-in only, so sessions + * issued before that field existed have no id at all. Email is unique on + * `user`, so this is the same row `loadAuthzUser` would return. + */ +export async function loadAuthzUserByEmail( + email: string, +): Promise { + const [found] = await db + .select({ id: user.id, email: user.email }) + .from(user) + .where(eq(user.email, email)) + .limit(1); + + if (!found) return null; + return { ...found, organisationIds: await getUserOrganisations(found.id) }; +} + /** * Load one project's permission-relevant facts, including every organisation * assigned as contractor to any of its workstreams. Returns null when the