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:
36
src/app/(dashboard)/[portSlug]/admin/layout.tsx
Normal file
36
src/app/(dashboard)/[portSlug]/admin/layout.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user