refactor(projects): delete the authz stub, guard via the real lib

`src/app/projects/authz.ts` was a placeholder from the route-shell work
(#406): session checks only, with a TODO(#408) to replace its bodies once
the real rules landed. #408 has landed, so it goes.

The real lib (`src/lib/projects/authz.ts`) is deliberately pure — it takes
project facts explicitly and cannot read a session, hit the database, or
redirect. So the four route files do not import it directly; they import a
thin glue layer, `src/app/projects/guards.ts`, which resolves the session
and the facts, delegates every decision to the lib, and turns a refusal
into a Next.js navigation. It holds no permission rules of its own.

Two behaviour notes:

- The redirect-on-no-session behaviour is preserved as-is: no session (or
  a session whose email has no `user` row) still redirects to `/`, matching
  `src/middleware.ts`.
- Denial of a specific project now calls `notFound()` instead of
  redirecting to `/projects`, which is what the stub's own TODO asked for.
  It was redirecting only because `canViewProject` could never return
  false; now it can, and a 404 is what keeps Project existence from
  leaking to users outside its organisation.

`loadAuthzUserByEmail` joins the repository because the session carries no
usable id — `session.user.dbId` is set by the `jwt` callback at initial
sign-in only, so sessions issued before that field existed lack it. Email
is unique on `user`, so it resolves the same row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-21 16:39:35 +00:00
parent fa4f3f3ab0
commit 2ba36470df
7 changed files with 114 additions and 69 deletions

View file

@ -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}`;

View file

@ -1,4 +1,4 @@
import { requireProjectAccess } from "../authz";
import { requireProjectAccess } from "../guards";
export const metadata = {
title: "Project dashboard | Ara",

View file

@ -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<Session> {
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<boolean> {
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<Session> {
const session = await requireProjectsSession();
if (!(await canViewProject(session, projectId))) {
// TODO(#408): notFound() once canViewProject can actually return false.
redirect("/projects");
}
return session;
}

View file

@ -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<Session> {
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<AuthzUser> {
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 };
}

View file

@ -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 },

View file

@ -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",

View file

@ -67,6 +67,28 @@ export async function loadAuthzUser(userId: bigint): Promise<AuthzUser | null> {
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<AuthzUser | null> {
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