diff --git a/src/app/api/v1/clients/[id]/gdpr-export/[exportId]/route.ts b/src/app/api/v1/clients/[id]/gdpr-export/[exportId]/route.ts index 3899ae23..ba40aba7 100644 --- a/src/app/api/v1/clients/[id]/gdpr-export/[exportId]/route.ts +++ b/src/app/api/v1/clients/[id]/gdpr-export/[exportId]/route.ts @@ -16,8 +16,8 @@ import { createAuditLog } from '@/lib/audit'; */ export const GET = withAuth( withPermission( - 'admin', - 'manage_settings', + 'clients', + 'gdpr_export', withRateLimit('exports', async (req, ctx, params) => { try { const url = await getExportDownloadUrl(params.exportId!, ctx.portId); diff --git a/src/app/api/v1/clients/[id]/gdpr-export/route.ts b/src/app/api/v1/clients/[id]/gdpr-export/route.ts index e3a8e199..f9ed772c 100644 --- a/src/app/api/v1/clients/[id]/gdpr-export/route.ts +++ b/src/app/api/v1/clients/[id]/gdpr-export/route.ts @@ -26,8 +26,8 @@ export const GET = withAuth( export const POST = withAuth( withPermission( - 'admin', - 'manage_settings', + 'clients', + 'gdpr_export', withRateLimit('exports', async (req, ctx, params) => { try { const body = await parseBody(req, requestSchema); diff --git a/src/components/clients/gdpr-export-button.tsx b/src/components/clients/gdpr-export-button.tsx index 739dada5..33db4711 100644 --- a/src/components/clients/gdpr-export-button.tsx +++ b/src/components/clients/gdpr-export-button.tsx @@ -63,7 +63,7 @@ export function GdprExportButton({ const [emailToClient, setEmailToClient] = useState(false); const [emailOverride, setEmailOverride] = useState(''); - const allowed = isSuperAdmin || can('admin', 'manage_settings'); + const allowed = isSuperAdmin || can('clients', 'gdpr_export'); const queryKey = ['gdpr-exports', clientId]; const { data, isLoading } = useQuery({ diff --git a/src/lib/auth/permissions.ts b/src/lib/auth/permissions.ts index e2ef5d57..e39dc97e 100644 --- a/src/lib/auth/permissions.ts +++ b/src/lib/auth/permissions.ts @@ -21,7 +21,7 @@ export type PermissionAction = keyof RolePermissio * (audit finding L23). */ export const PERMISSION_CATALOG = { - clients: ['view', 'create', 'edit', 'delete', 'merge', 'export'], + clients: ['view', 'create', 'edit', 'delete', 'merge', 'export', 'gdpr_export'], interests: [ 'view', 'create', diff --git a/src/lib/db/migrations/0098_clients_gdpr_export_permission.sql b/src/lib/db/migrations/0098_clients_gdpr_export_permission.sql new file mode 100644 index 00000000..f78486da --- /dev/null +++ b/src/lib/db/migrations/0098_clients_gdpr_export_permission.sql @@ -0,0 +1,23 @@ +-- New toggleable permission: clients.gdpr_export (trigger + download a client's +-- GDPR data export). Previously the export routes were gated by +-- admin.manage_settings, which sales roles lack. This grants it to the +-- sales-capable system roles by default and makes it an explicit (off) toggle +-- everywhere else, so admins can withhold it per-user (which hides the button). +-- +-- Existing role rows store permissions as jsonb, so editing the seed/role maps +-- alone won't reach them — this backfills the key. Idempotent. + +-- Sales-capable system roles get it ON by default. +UPDATE roles +SET permissions = jsonb_set(permissions, '{clients,gdpr_export}', 'true'::jsonb, true), + updated_at = now() +WHERE name IN ('super_admin', 'director', 'sales_manager', 'sales_agent') + AND permissions ? 'clients'; + +-- Every other role that has a clients block but not the key yet defaults to OFF, +-- so the permission surfaces as an explicit toggle in the matrix. +UPDATE roles +SET permissions = jsonb_set(permissions, '{clients,gdpr_export}', 'false'::jsonb, true), + updated_at = now() +WHERE permissions ? 'clients' + AND NOT (permissions -> 'clients' ? 'gdpr_export'); diff --git a/src/lib/db/schema/users.ts b/src/lib/db/schema/users.ts index 7b7b0c37..b673f61c 100644 --- a/src/lib/db/schema/users.ts +++ b/src/lib/db/schema/users.ts @@ -11,6 +11,9 @@ export type RolePermissions = { delete: boolean; merge: boolean; export: boolean; + /** Trigger + download a GDPR data export for a client. Toggleable so it + * can be hidden from a user (e.g. a sales rep) when withheld. */ + gdpr_export: boolean; }; interests: { view: boolean; diff --git a/src/lib/db/seed-permissions.ts b/src/lib/db/seed-permissions.ts index 5b2e71bc..2ecccde5 100644 --- a/src/lib/db/seed-permissions.ts +++ b/src/lib/db/seed-permissions.ts @@ -12,7 +12,15 @@ import type { RolePermissions } from './schema/users'; export const ALL_PERMISSIONS: RolePermissions = { - clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true }, + clients: { + view: true, + create: true, + edit: true, + delete: true, + merge: true, + export: true, + gdpr_export: true, + }, interests: { view: true, create: true, @@ -104,7 +112,15 @@ export const ALL_PERMISSIONS: RolePermissions = { // reference the sales map directly. export const SALES_MANAGER_PERMISSIONS: RolePermissions = { - clients: { view: true, create: true, edit: true, delete: false, merge: true, export: true }, + clients: { + view: true, + create: true, + edit: true, + delete: false, + merge: true, + export: true, + gdpr_export: true, + }, interests: { view: true, create: true, @@ -196,7 +212,15 @@ export const SALES_MANAGER_PERMISSIONS: RolePermissions = { export const DIRECTOR_PERMISSIONS: RolePermissions = SALES_MANAGER_PERMISSIONS; export const SALES_AGENT_PERMISSIONS: RolePermissions = { - clients: { view: true, create: true, edit: true, delete: false, merge: false, export: true }, + clients: { + view: true, + create: true, + edit: true, + delete: false, + merge: false, + export: true, + gdpr_export: true, + }, interests: { view: true, create: true, @@ -283,7 +307,15 @@ export const SALES_AGENT_PERMISSIONS: RolePermissions = { }; export const VIEWER_PERMISSIONS: RolePermissions = { - clients: { view: true, create: false, edit: false, delete: false, merge: false, export: false }, + clients: { + view: true, + create: false, + edit: false, + delete: false, + merge: false, + export: false, + gdpr_export: false, + }, interests: { view: true, create: false, @@ -379,7 +411,15 @@ export const VIEWER_PERMISSIONS: RolePermissions = { // inquiries on the marina's behalf. Sees only the residential pages and // nothing else; can't see marina clients, yachts, berths, EOIs, etc. export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = { - clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false }, + clients: { + view: false, + create: false, + edit: false, + delete: false, + merge: false, + export: false, + gdpr_export: false, + }, interests: { view: false, create: false, diff --git a/tests/helpers/factories.ts b/tests/helpers/factories.ts index cf622730..d282d8d1 100644 --- a/tests/helpers/factories.ts +++ b/tests/helpers/factories.ts @@ -302,7 +302,15 @@ import type { RolePermissions } from '@/lib/db/schema/users'; /** Full permissions - every action allowed. */ export function makeFullPermissions(): RolePermissions { return { - clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true }, + clients: { + view: true, + create: true, + edit: true, + delete: true, + merge: true, + export: true, + gdpr_export: true, + }, interests: { view: true, create: true, @@ -392,7 +400,15 @@ export function makeFullPermissions(): RolePermissions { /** Read-only viewer permissions - no create/update/delete. */ export function makeViewerPermissions(): RolePermissions { return { - clients: { view: true, create: false, edit: false, delete: false, merge: false, export: false }, + clients: { + view: true, + create: false, + edit: false, + delete: false, + merge: false, + export: false, + gdpr_export: false, + }, interests: { view: true, create: false, @@ -482,7 +498,15 @@ export function makeViewerPermissions(): RolePermissions { /** Sales agent permissions - own clients/interests, no admin. */ export function makeSalesAgentPermissions(): RolePermissions { return { - clients: { view: true, create: true, edit: true, delete: false, merge: false, export: false }, + clients: { + view: true, + create: true, + edit: true, + delete: false, + merge: false, + export: false, + gdpr_export: true, + }, interests: { view: true, create: true, @@ -572,7 +596,15 @@ export function makeSalesAgentPermissions(): RolePermissions { /** Sales manager - can do most things, limited admin. */ export function makeSalesManagerPermissions(): RolePermissions { return { - clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true }, + clients: { + view: true, + create: true, + edit: true, + delete: true, + merge: true, + export: true, + gdpr_export: true, + }, interests: { view: true, create: true, diff --git a/tests/integration/permission-matrix.test.ts b/tests/integration/permission-matrix.test.ts index 374d49ad..b1c511bd 100644 --- a/tests/integration/permission-matrix.test.ts +++ b/tests/integration/permission-matrix.test.ts @@ -99,6 +99,10 @@ describe('Permission Matrix - viewer', () => { expect(await checkPermission(ctx, 'clients', 'create')).toBe(403); }); + it('cannot run a GDPR export', async () => { + expect(await checkPermission(ctx, 'clients', 'gdpr_export')).toBe(403); + }); + it('cannot update clients', async () => { expect(await checkPermission(ctx, 'clients', 'edit')).toBe(403); }); @@ -177,6 +181,10 @@ describe('Permission Matrix - sales_manager', () => { } }); + it('can run a GDPR export (clients.gdpr_export)', async () => { + expect(await checkPermission(ctx, 'clients', 'gdpr_export')).toBe(200); + }); + it('can view audit log', async () => { expect(await checkPermission(ctx, 'admin', 'view_audit_log')).toBe(200); });