mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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:
parent
fa4f3f3ab0
commit
2ba36470df
7 changed files with 114 additions and 69 deletions
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { requireProjectAccess } from "../authz";
|
||||
import { requireProjectAccess } from "../guards";
|
||||
|
||||
export const metadata = {
|
||||
title: "Project dashboard | Ara",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
88
src/app/projects/guards.ts
Normal file
88
src/app/projects/guards.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue