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 { withAuth } from '@/lib/api/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 { savedViewsService } from '@/lib/services/saved-views.service';
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) => {
try {
const id = params.id ?? '';
const denied = await assertViewOwner(id, ctx.portId, ctx.userId);
if (denied) return denied;
const body = await parseBody(req, updateSavedViewSchema);
const view = await savedViewsService.update(ctx.portId, ctx.userId, id, body);
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) => {
try {
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);
return NextResponse.json({ data: null }, { status: 200 });
} catch (error) {