audit: 33-agent comprehensive audit + critical fixes
Full team audit run, all reports verbatim in docs/AUDIT-2026-05-12.md (5900+ lines, 30+ critical findings). Already-fixed this commit: - permission-overrides PUT: self-target block + RolePermissions allow-list + cross-tenant guard - /api/auth/resolve-identifier: rate-limit + synthetic miss-email kill enumeration - admin email-change: rotates account.accountId + revokes sessions - middleware: token-gated email confirm/cancel routes whitelisted - NAV_CATALOG: 10 dead-link sweeps to existing /admin/<x> targets Feature work landing same commit: optional username sign-in (migration 0054), per-user permission overrides (0055) with three-state matrix tabbed inside UserForm, user disable button, role + outcome + stage label normalisation across the platform, admin email-change with auto-notification template. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
260
src/app/api/v1/admin/users/[id]/permission-overrides/route.ts
Normal file
260
src/app/api/v1/admin/users/[id]/permission-overrides/route.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* GET / PUT per-user permission overrides for the current port.
|
||||
*
|
||||
* GET returns the effective baseline (role + port-role-overrides + residential
|
||||
* toggle) AND the current user-specific override map so the UI can render
|
||||
* three states per leaf: inherit, force-grant, force-deny.
|
||||
*
|
||||
* PUT accepts a Partial<RolePermissions> map (use null at a leaf to clear an
|
||||
* override) and upserts it onto user_permission_overrides for (userId, portId).
|
||||
* Permission `admin.manage_users` is required — same gate as the user-edit
|
||||
* drawer that hosts the matrix.
|
||||
*/
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
portRoleOverrides,
|
||||
roles,
|
||||
userPermissionOverrides,
|
||||
userPortRoles,
|
||||
userProfiles,
|
||||
type RolePermissions,
|
||||
} from '@/lib/db/schema';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse, ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Mirrors `RolePermissions` in src/lib/db/schema/users.ts. Used as the
|
||||
* allow-list for the PUT body so a client can't write arbitrary keys
|
||||
* that the resolver would happily merge into the effective permission
|
||||
* map. Keep this in sync when RolePermissions gains a leaf.
|
||||
*/
|
||||
const ALLOWED_RESOURCE_ACTIONS: Record<string, Set<string>> = {
|
||||
clients: new Set(['view', 'create', 'edit', 'delete', 'merge', 'export']),
|
||||
interests: new Set([
|
||||
'view',
|
||||
'create',
|
||||
'edit',
|
||||
'delete',
|
||||
'change_stage',
|
||||
'override_stage',
|
||||
'generate_eoi',
|
||||
'export',
|
||||
]),
|
||||
berths: new Set(['view', 'edit', 'import', 'manage_waiting_list']),
|
||||
documents: new Set([
|
||||
'view',
|
||||
'create',
|
||||
'edit',
|
||||
'send_for_signing',
|
||||
'upload_signed',
|
||||
'delete',
|
||||
'manage_folders',
|
||||
]),
|
||||
expenses: new Set(['view', 'create', 'edit', 'delete', 'export', 'scan_receipt']),
|
||||
invoices: new Set(['view', 'create', 'edit', 'delete', 'send', 'record_payment', 'export']),
|
||||
files: new Set(['view', 'upload', 'edit', 'delete', 'manage_folders']),
|
||||
email: new Set(['view', 'send', 'configure_account']),
|
||||
reminders: new Set(['view_own', 'view_all', 'create', 'edit_own', 'edit_all', 'assign_others']),
|
||||
calendar: new Set(['connect', 'view_events']),
|
||||
reports: new Set(['view_dashboard', 'view_analytics', 'export']),
|
||||
document_templates: new Set(['view', 'generate', 'manage']),
|
||||
yachts: new Set(['view', 'create', 'edit', 'delete', 'transfer']),
|
||||
companies: new Set(['view', 'create', 'edit', 'delete']),
|
||||
memberships: new Set(['view', 'manage']),
|
||||
reservations: new Set(['view', 'create', 'activate', 'cancel']),
|
||||
admin: new Set([
|
||||
'manage_users',
|
||||
'view_audit_log',
|
||||
'manage_settings',
|
||||
'manage_webhooks',
|
||||
'manage_reports',
|
||||
'manage_custom_fields',
|
||||
'manage_forms',
|
||||
'manage_tags',
|
||||
'system_backup',
|
||||
'permanently_delete_clients',
|
||||
]),
|
||||
residential_clients: new Set(['view', 'create', 'edit', 'delete']),
|
||||
residential_interests: new Set(['view', 'create', 'edit', 'delete', 'change_stage']),
|
||||
};
|
||||
|
||||
const updateOverridesSchema = z.object({
|
||||
/** Partial<RolePermissions> — passthrough JSON. Validated structurally
|
||||
* by limiting depth + leaf type below. */
|
||||
overrides: z.record(z.string(), z.record(z.string(), z.boolean())).default({}),
|
||||
});
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
|
||||
try {
|
||||
const targetUserId = params.id!;
|
||||
const portId = ctx.portId;
|
||||
|
||||
const profile = await db.query.userProfiles.findFirst({
|
||||
where: eq(userProfiles.userId, targetUserId),
|
||||
});
|
||||
if (!profile) throw new NotFoundError('User');
|
||||
|
||||
// Resolve the role's baseline + port-role override (super-admin
|
||||
// edge-case: no role row, so the matrix is empty / not editable).
|
||||
let baseline: RolePermissions | null = null;
|
||||
if (!profile.isSuperAdmin) {
|
||||
const portRole = await db.query.userPortRoles.findFirst({
|
||||
where: and(
|
||||
eq(userPortRoles.userId, targetUserId),
|
||||
eq(userPortRoles.portId, portId),
|
||||
),
|
||||
});
|
||||
if (portRole) {
|
||||
const role = await db.query.roles.findFirst({
|
||||
where: eq(roles.id, portRole.roleId),
|
||||
});
|
||||
baseline = (role?.permissions as RolePermissions | null) ?? null;
|
||||
|
||||
const portOverride = await db.query.portRoleOverrides.findFirst({
|
||||
where: and(
|
||||
eq(portRoleOverrides.portId, portId),
|
||||
eq(portRoleOverrides.roleId, portRole.roleId),
|
||||
),
|
||||
});
|
||||
if (baseline && portOverride?.permissionOverrides) {
|
||||
// Cheap structural merge — same shape as helpers.ts's deepMerge.
|
||||
baseline = mergePerms(baseline, portOverride.permissionOverrides);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userOverride = await db.query.userPermissionOverrides.findFirst({
|
||||
where: and(
|
||||
eq(userPermissionOverrides.userId, targetUserId),
|
||||
eq(userPermissionOverrides.portId, portId),
|
||||
),
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
baseline,
|
||||
overrides: userOverride?.permissionOverrides ?? {},
|
||||
isSuperAdmin: profile.isSuperAdmin,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const PUT = withAuth(
|
||||
withPermission('admin', 'manage_users', async (req, ctx, params) => {
|
||||
try {
|
||||
const targetUserId = params.id!;
|
||||
const portId = ctx.portId;
|
||||
|
||||
// CRITICAL: refuse self-target. Without this an admin with
|
||||
// admin.manage_users can grant themselves every permission leaf
|
||||
// (incl. permanently_delete_clients, system_backup, etc.) and the
|
||||
// override layer in withAuth resolves it on the very next request.
|
||||
if (targetUserId === ctx.userId) {
|
||||
throw new ForbiddenError('You cannot edit your own permission overrides.');
|
||||
}
|
||||
|
||||
// Reject overrides for users that aren't actually assigned to this
|
||||
// port — prevents cross-tenant pollution where an admin in port A
|
||||
// writes a row keyed on (userIdFromPortB, portA). The withAuth
|
||||
// resolver scopes lookups to the caller's port so the row would
|
||||
// never apply, but it still consumes a unique slot and confuses
|
||||
// future audits.
|
||||
const targetPortRole = await db.query.userPortRoles.findFirst({
|
||||
where: and(
|
||||
eq(userPortRoles.userId, targetUserId),
|
||||
eq(userPortRoles.portId, portId),
|
||||
),
|
||||
});
|
||||
if (!targetPortRole) {
|
||||
throw new NotFoundError('User not assigned to this port');
|
||||
}
|
||||
|
||||
const { overrides } = await parseBody(req, updateOverridesSchema);
|
||||
|
||||
// Strip anything outside the canonical RolePermissions allow-list.
|
||||
// The Zod schema only enforces shape (string → string → boolean);
|
||||
// here we drop unknown resources/actions so a malicious client
|
||||
// can't seed garbage keys that a future resolver might accidentally
|
||||
// honour.
|
||||
const sanitized: Record<string, Record<string, boolean>> = {};
|
||||
for (const [resource, actions] of Object.entries(overrides)) {
|
||||
const allowed = ALLOWED_RESOURCE_ACTIONS[resource];
|
||||
if (!allowed) continue;
|
||||
const cleaned: Record<string, boolean> = {};
|
||||
for (const [action, value] of Object.entries(actions)) {
|
||||
if (!allowed.has(action)) continue;
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new ValidationError(
|
||||
`permission override for ${resource}.${action} must be true or false`,
|
||||
);
|
||||
}
|
||||
cleaned[action] = value;
|
||||
}
|
||||
if (Object.keys(cleaned).length > 0) sanitized[resource] = cleaned;
|
||||
}
|
||||
|
||||
const existing = await db.query.userPermissionOverrides.findFirst({
|
||||
where: and(
|
||||
eq(userPermissionOverrides.userId, targetUserId),
|
||||
eq(userPermissionOverrides.portId, portId),
|
||||
),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(userPermissionOverrides)
|
||||
.set({ permissionOverrides: sanitized, updatedAt: new Date() })
|
||||
.where(eq(userPermissionOverrides.id, existing.id));
|
||||
} else {
|
||||
await db.insert(userPermissionOverrides).values({
|
||||
userId: targetUserId,
|
||||
portId,
|
||||
permissionOverrides: sanitized,
|
||||
});
|
||||
}
|
||||
|
||||
void createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'user',
|
||||
entityId: targetUserId,
|
||||
oldValue: { permissionOverrides: existing?.permissionOverrides ?? {} },
|
||||
newValue: { permissionOverrides: sanitized },
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: { overrides: sanitized } });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
/** Local shallow-merge by resource (matches the RolePermissions shape:
|
||||
* one-level-deep map of resource → action map). Same semantics the
|
||||
* withAuth resolver uses; copied here to avoid pulling that file into
|
||||
* a route module. */
|
||||
function mergePerms(
|
||||
base: RolePermissions,
|
||||
patch: Partial<RolePermissions> | Record<string, Record<string, boolean>>,
|
||||
): RolePermissions {
|
||||
const out = { ...(base as unknown as Record<string, Record<string, boolean>>) };
|
||||
for (const [resource, actions] of Object.entries(patch)) {
|
||||
if (!actions) continue;
|
||||
out[resource] = { ...(out[resource] ?? {}), ...(actions as Record<string, boolean>) };
|
||||
}
|
||||
return out as unknown as RolePermissions;
|
||||
}
|
||||
Reference in New Issue
Block a user