fix(auth): harden admin gate, X-Port-Id, portal JWT, saved-views

- Add server-side `<admin>/layout.tsx` that redirects non-super-admins to
  `/[portSlug]/dashboard`. Closes the gap where any authed user could
  guess the URL and reach Users / Roles / Audit Log / Backup.
- `withAuth` super-admin branch now 404s when the requested portId does
  not match a real port row, preventing a compromised super-admin
  session from operating against a fabricated portId.
- Portal JWTs now carry `aud: 'portal'` + `iss: 'pn-crm'` claims and
  `verifyPortalToken` requires both, so a portal token can no longer be
  replayed against the CRM session path or vice versa. In-flight tokens
  (≤24h) will be invalidated once on deploy.
- `saved-views/[id]` PATCH and DELETE now do an explicit ownership
  check before the service call, returning 403 instead of relying on
  the service's internal userId filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-02 23:00:42 +02:00
parent a767652d74
commit 6af2ac9680
4 changed files with 79 additions and 2 deletions

View File

@@ -0,0 +1,36 @@
import { redirect } from 'next/navigation';
import { headers } from 'next/headers';
import { eq } from 'drizzle-orm';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { userProfiles } from '@/lib/db/schema/users';
/**
* Guard: only super-admins (isSuperAdmin === true in user_profiles) may access
* any page under /[portSlug]/admin. Everyone else is redirected to their dashboard.
*/
export default async function AdminLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
redirect('/login');
}
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, session.user.id),
});
if (!profile?.isSuperAdmin) {
redirect(`/${portSlug}/dashboard`);
}
return <>{children}</>;
}

View File

@@ -1,14 +1,37 @@
import { and, eq } from 'drizzle-orm';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers'; import { withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers'; import { parseBody } from '@/lib/api/route-helpers';
import { db } from '@/lib/db';
import { savedViews } from '@/lib/db/schema';
import { errorResponse } from '@/lib/errors'; import { errorResponse } from '@/lib/errors';
import { savedViewsService } from '@/lib/services/saved-views.service'; import { savedViewsService } from '@/lib/services/saved-views.service';
import { updateSavedViewSchema } from '@/lib/validators/saved-views'; import { updateSavedViewSchema } from '@/lib/validators/saved-views';
/** Resolves the view and enforces ownership before mutating. */
async function assertViewOwner(
id: string,
portId: string,
userId: string,
): Promise<NextResponse | null> {
const view = await db.query.savedViews.findFirst({
where: and(eq(savedViews.id, id), eq(savedViews.portId, portId)),
});
if (!view) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (view.userId !== userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return null;
}
export const PATCH = withAuth(async (req, ctx, params) => { export const PATCH = withAuth(async (req, ctx, params) => {
try { try {
const id = params.id ?? ''; const id = params.id ?? '';
const denied = await assertViewOwner(id, ctx.portId, ctx.userId);
if (denied) return denied;
const body = await parseBody(req, updateSavedViewSchema); const body = await parseBody(req, updateSavedViewSchema);
const view = await savedViewsService.update(ctx.portId, ctx.userId, id, body); const view = await savedViewsService.update(ctx.portId, ctx.userId, id, body);
return NextResponse.json({ data: view }); return NextResponse.json({ data: view });
@@ -20,6 +43,8 @@ export const PATCH = withAuth(async (req, ctx, params) => {
export const DELETE = withAuth(async (_req, ctx, params) => { export const DELETE = withAuth(async (_req, ctx, params) => {
try { try {
const id = params.id ?? ''; const id = params.id ?? '';
const denied = await assertViewOwner(id, ctx.portId, ctx.userId);
if (denied) return denied;
await savedViewsService.delete(ctx.portId, ctx.userId, id); await savedViewsService.delete(ctx.portId, ctx.userId, id);
return NextResponse.json({ data: null }, { status: 200 }); return NextResponse.json({ data: null }, { status: 200 });
} catch (error) { } catch (error) {

View File

@@ -181,10 +181,15 @@ export function withAuth(
} }
} else if (profile.isSuperAdmin && portId) { } else if (profile.isSuperAdmin && portId) {
// Super admin still needs portSlug for response context. // Super admin still needs portSlug for response context.
// We also validate the portId actually exists — a super-admin session
// must not be able to operate against a fabricated portId.
const port = await db.query.ports.findFirst({ const port = await db.query.ports.findFirst({
where: eq(ports.id, portId), where: eq(ports.id, portId),
}); });
portSlug = port?.slug ?? ''; if (!port) {
return NextResponse.json({ error: 'Port not found' }, { status: 404 });
}
portSlug = port.slug;
} }
const ctx: AuthContext = { const ctx: AuthContext = {

View File

@@ -4,6 +4,12 @@ import { cookies } from 'next/headers';
const PORTAL_SECRET = new TextEncoder().encode(process.env.BETTER_AUTH_SECRET); const PORTAL_SECRET = new TextEncoder().encode(process.env.BETTER_AUTH_SECRET);
export const PORTAL_COOKIE = 'portal_session'; export const PORTAL_COOKIE = 'portal_session';
// BREAKING CHANGE (intentional): tokens issued before this change lack aud/iss
// and will be rejected by verifyPortalToken. Portal tokens are 24h-lived so
// existing sessions will be invalidated on deploy. Users simply re-login.
const PORTAL_AUD = 'portal';
const PORTAL_ISS = 'pn-crm';
export interface PortalSession { export interface PortalSession {
clientId: string; clientId: string;
portId: string; portId: string;
@@ -13,6 +19,8 @@ export interface PortalSession {
export async function createPortalToken(session: PortalSession): Promise<string> { export async function createPortalToken(session: PortalSession): Promise<string> {
return new SignJWT(session as unknown as Record<string, unknown>) return new SignJWT(session as unknown as Record<string, unknown>)
.setProtectedHeader({ alg: 'HS256' }) .setProtectedHeader({ alg: 'HS256' })
.setAudience(PORTAL_AUD)
.setIssuer(PORTAL_ISS)
.setExpirationTime('24h') .setExpirationTime('24h')
.setIssuedAt() .setIssuedAt()
.sign(PORTAL_SECRET); .sign(PORTAL_SECRET);
@@ -20,7 +28,10 @@ export async function createPortalToken(session: PortalSession): Promise<string>
export async function verifyPortalToken(token: string): Promise<PortalSession | null> { export async function verifyPortalToken(token: string): Promise<PortalSession | null> {
try { try {
const { payload } = await jwtVerify(token, PORTAL_SECRET); const { payload } = await jwtVerify(token, PORTAL_SECRET, {
audience: PORTAL_AUD,
issuer: PORTAL_ISS,
});
return payload as unknown as PortalSession; return payload as unknown as PortalSession;
} catch { } catch {
return null; return null;