feat(rbac): GDPR export becomes a toggleable clients.gdpr_export permission
Previously the GDPR export trigger + download routes were gated by admin.manage_settings, so sales roles couldn't run a client data export. Per request, make it a dedicated, toggleable permission that's on by default for sales-capable roles and hides the button when withheld. - New RolePermissions leaf clients.gdpr_export (+ PERMISSION_CATALOG entry); strict type forces every role map + fixture to declare it. - Granted true for super_admin / director / sales_manager / sales_agent; false for viewer / residential_partner. - GDPR export POST (trigger) and [exportId] GET (download) re-gated from admin.manage_settings -> clients.gdpr_export. - GdprExportButton visibility now keys off clients.gdpr_export, so toggling it off per-user hides the function entirely. - Migration 0098 backfills the key onto existing role rows (idempotent). Verified end-to-end as a Sales user: trigger (202) -> worker build (ready) -> list (200) -> download (200). 1664 vitest pass; tsc + eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ListResp>({
|
||||
|
||||
@@ -21,7 +21,7 @@ export type PermissionAction<R extends PermissionResource> = 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',
|
||||
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user