Skip to content

Commit 3b78436

Browse files
feat(pii): gate data retention PII redaction behind feature flag (#5144)
* feat(pii): gate data retention PII redaction behind feature flag * fix(pii): evaluate pii-redaction flag globally with no org/user context
1 parent ecbe191 commit 3b78436

7 files changed

Lines changed: 102 additions & 73 deletions

File tree

apps/sim/app/api/organizations/[id]/data-retention/route.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { getSession } from '@/lib/auth'
1414
import { CLEANUP_CONFIG } from '@/lib/billing/cleanup-dispatcher'
1515
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
1616
import { isBillingEnabled } from '@/lib/core/config/env-flags'
17+
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
1718
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1819

1920
const logger = createLogger('DataRetentionAPI')
@@ -76,6 +77,7 @@ export const GET = withRouteHandler(
7677
}
7778

7879
const isEnterprise = !isBillingEnabled || (await isOrganizationOnEnterprisePlan(organizationId))
80+
const piiRedactionEnabled = await isFeatureEnabled('pii-redaction')
7981
const configured = normalizeConfigured(org.dataRetentionSettings)
8082
const defaults = enterpriseDefaults()
8183

@@ -86,6 +88,7 @@ export const GET = withRouteHandler(
8688
defaults,
8789
configured,
8890
effective: isEnterprise ? configured : defaults,
91+
piiRedactionEnabled,
8992
},
9093
})
9194
}
@@ -154,6 +157,8 @@ export const PUT = withRouteHandler(
154157
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
155158
}
156159

160+
const piiRedactionEnabled = await isFeatureEnabled('pii-redaction')
161+
157162
const current = normalizeConfigured(currentOrg.dataRetentionSettings)
158163
const merged: DataRetentionSettings = { ...current }
159164
if (body.logRetentionHours !== undefined) {
@@ -166,6 +171,12 @@ export const PUT = withRouteHandler(
166171
merged.taskCleanupHours = body.taskCleanupHours
167172
}
168173
if (body.piiRedaction !== undefined) {
174+
if (!piiRedactionEnabled) {
175+
return NextResponse.json(
176+
{ error: 'PII redaction is not enabled for this organization' },
177+
{ status: 403 }
178+
)
179+
}
169180
merged.piiRedaction = body.piiRedaction
170181
}
171182

@@ -203,6 +214,7 @@ export const PUT = withRouteHandler(
203214
defaults,
204215
configured,
205216
effective: configured,
217+
piiRedactionEnabled,
206218
},
207219
})
208220
}

apps/sim/ee/data-retention/components/data-retention-settings.tsx

Lines changed: 75 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -492,89 +492,91 @@ export function DataRetentionSettings() {
492492
</SettingRow>
493493
</div>
494494
</SettingsSection>
495-
<SettingsSection label='PII Redaction'>
496-
<div className='flex flex-col gap-6'>
497-
<div className='flex flex-col gap-2'>
498-
<div className='flex items-center justify-between gap-3'>
499-
<span className='font-medium text-[var(--text-muted)] text-small'>
500-
Default · all workspaces
501-
</span>
502-
{!defaultRule && (
503-
<Chip leftIcon={Plus} onClick={openEditDefault}>
504-
Add redaction
505-
</Chip>
495+
{data?.piiRedactionEnabled && (
496+
<SettingsSection label='PII Redaction'>
497+
<div className='flex flex-col gap-6'>
498+
<div className='flex flex-col gap-2'>
499+
<div className='flex items-center justify-between gap-3'>
500+
<span className='font-medium text-[var(--text-muted)] text-small'>
501+
Default · all workspaces
502+
</span>
503+
{!defaultRule && (
504+
<Chip leftIcon={Plus} onClick={openEditDefault}>
505+
Add redaction
506+
</Chip>
507+
)}
508+
</div>
509+
{defaultRule && (
510+
<div className='flex items-center justify-between gap-3 rounded-lg border border-[var(--border-1)] px-3 py-2'>
511+
<span className='truncate text-[var(--text-body)] text-small'>
512+
{entitySummary(defaultRule.entityTypes)}
513+
</span>
514+
<div className='flex flex-shrink-0 items-center gap-2'>
515+
<Chip onClick={openEditDefault}>Edit</Chip>
516+
<Chip
517+
onClick={() => removeRule(defaultRule.id)}
518+
disabled={updateMutation.isPending}
519+
>
520+
Delete
521+
</Chip>
522+
</div>
523+
</div>
506524
)}
507525
</div>
508526
{defaultRule && (
509-
<div className='flex items-center justify-between gap-3 rounded-lg border border-[var(--border-1)] px-3 py-2'>
510-
<span className='truncate text-[var(--text-body)] text-small'>
511-
{entitySummary(defaultRule.entityTypes)}
512-
</span>
513-
<div className='flex flex-shrink-0 items-center gap-2'>
514-
<Chip onClick={openEditDefault}>Edit</Chip>
527+
<div className='flex flex-col gap-2'>
528+
<div className='flex items-center justify-between gap-3'>
529+
<span className='font-medium text-[var(--text-muted)] text-small'>
530+
Workspace overrides
531+
</span>
515532
<Chip
516-
onClick={() => removeRule(defaultRule.id)}
517-
disabled={updateMutation.isPending}
533+
leftIcon={Plus}
534+
onClick={openAddOverride}
535+
disabled={freeWorkspaces.length === 0}
518536
>
519-
Delete
537+
Add override
520538
</Chip>
521539
</div>
540+
{overrideRules.length === 0 ? (
541+
<p className='text-[var(--text-muted)] text-caption'>
542+
No overrides — every workspace uses the default.
543+
</p>
544+
) : (
545+
<div className='flex flex-col gap-2'>
546+
{overrideRules.map((rule) => (
547+
<div
548+
key={rule.id}
549+
className='flex items-center justify-between gap-3 rounded-lg border border-[var(--border-1)] px-3 py-2'
550+
>
551+
<div className='flex min-w-0 flex-col'>
552+
<span className='truncate text-[var(--text-body)] text-small'>
553+
{workspaceName(rule.workspaceId as string)}
554+
</span>
555+
<span className='truncate text-[var(--text-muted)] text-caption'>
556+
{entitySummary(rule.entityTypes)}
557+
</span>
558+
</div>
559+
<div className='flex flex-shrink-0 items-center gap-2'>
560+
<Chip onClick={() => openEditOverride(rule)}>Edit</Chip>
561+
<Chip
562+
onClick={() => removeRule(rule.id)}
563+
disabled={updateMutation.isPending}
564+
>
565+
Delete
566+
</Chip>
567+
</div>
568+
</div>
569+
))}
570+
<span className='text-[var(--text-muted)] text-caption'>
571+
Workspaces not listed use the default.
572+
</span>
573+
</div>
574+
)}
522575
</div>
523576
)}
524577
</div>
525-
{defaultRule && (
526-
<div className='flex flex-col gap-2'>
527-
<div className='flex items-center justify-between gap-3'>
528-
<span className='font-medium text-[var(--text-muted)] text-small'>
529-
Workspace overrides
530-
</span>
531-
<Chip
532-
leftIcon={Plus}
533-
onClick={openAddOverride}
534-
disabled={freeWorkspaces.length === 0}
535-
>
536-
Add override
537-
</Chip>
538-
</div>
539-
{overrideRules.length === 0 ? (
540-
<p className='text-[var(--text-muted)] text-caption'>
541-
No overrides — every workspace uses the default.
542-
</p>
543-
) : (
544-
<div className='flex flex-col gap-2'>
545-
{overrideRules.map((rule) => (
546-
<div
547-
key={rule.id}
548-
className='flex items-center justify-between gap-3 rounded-lg border border-[var(--border-1)] px-3 py-2'
549-
>
550-
<div className='flex min-w-0 flex-col'>
551-
<span className='truncate text-[var(--text-body)] text-small'>
552-
{workspaceName(rule.workspaceId as string)}
553-
</span>
554-
<span className='truncate text-[var(--text-muted)] text-caption'>
555-
{entitySummary(rule.entityTypes)}
556-
</span>
557-
</div>
558-
<div className='flex flex-shrink-0 items-center gap-2'>
559-
<Chip onClick={() => openEditOverride(rule)}>Edit</Chip>
560-
<Chip
561-
onClick={() => removeRule(rule.id)}
562-
disabled={updateMutation.isPending}
563-
>
564-
Delete
565-
</Chip>
566-
</div>
567-
</div>
568-
))}
569-
<span className='text-[var(--text-muted)] text-caption'>
570-
Workspaces not listed use the default.
571-
</span>
572-
</div>
573-
)}
574-
</div>
575-
)}
576-
</div>
577-
</SettingsSection>
578+
</SettingsSection>
579+
)}
578580
</div>
579581
</div>
580582
{modalDraft && (

apps/sim/lib/api/contracts/organization.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ const organizationDataRetentionDataSchema = z.object({
129129
defaults: organizationRetentionValuesSchema,
130130
configured: organizationRetentionValuesSchema,
131131
effective: organizationRetentionValuesSchema,
132+
piiRedactionEnabled: z.boolean(),
132133
})
133134

134135
export type OrganizationDataRetention = z.output<typeof organizationDataRetentionDataSchema>

apps/sim/lib/core/config/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export const env = createEnv({
7373
FREE_API_DEPLOYMENT_GATE_ENABLED: z.boolean().optional(), // Block free-plan accounts from programmatic execution (API/MCP/A2A/generic webhooks/chat embeds). Requires BILLING_ENABLED. Off by default for dark rollout
7474
TABLES_FRACTIONAL_ORDERING: z.boolean().optional(), // Order table rows by fractional order_key (O(1) insert/delete) instead of integer position
7575
TABLE_SNAPSHOT_CACHE: z.boolean().optional(), // Mount tables into sandboxes by reference via a version-keyed CSV snapshot in object storage instead of draining the whole table into web-process heap
76+
PII_REDACTION: z.boolean().optional(), // Redact PII from workflow logs via configurable Data Retention rules (Presidio at the logger persist choke point) and expose the Data Retention config UI
7677

7778
// Table feature limits (per plan). Apply when billing is disabled (free tier defaults) or for billed plans.
7879
FREE_TABLES_LIMIT: z.number().optional(), // Max user tables per workspace on free tier (default: 5)

apps/sim/lib/core/config/feature-flags.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ describe('getFeatureFlags', () => {
6262
// All registered flags should be present, disabled (env vars unset in test env)
6363
expect(flags['tables-fractional-ordering']).toEqual({ enabled: false })
6464
expect(flags['mothership-beta']).toEqual({ enabled: false })
65+
expect(flags['pii-redaction']).toEqual({ enabled: false })
6566
expect(mockFetch).not.toHaveBeenCalled()
6667
})
6768

@@ -88,6 +89,7 @@ describe('getFeatureFlags', () => {
8889
const flags = await getFeatureFlags()
8990
expect(flags['tables-fractional-ordering']).toEqual({ enabled: false })
9091
expect(flags['mothership-beta']).toEqual({ enabled: false })
92+
expect(flags['pii-redaction']).toEqual({ enabled: false })
9193
})
9294

9395
it('degrades gracefully on a malformed document', async () => {

apps/sim/lib/core/config/feature-flags.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,14 @@ const FEATURE_FLAGS = {
8282
'enabled:true for global rollout rather than per-user targeting.',
8383
fallback: 'TABLE_SNAPSHOT_CACHE',
8484
},
85+
'pii-redaction': {
86+
description:
87+
'Redact PII from workflow logs via configurable Data Retention rules (Presidio at the ' +
88+
'logger persist choke point) and expose the Data Retention config surfaces. Global on/off ' +
89+
'only — evaluated without user/org context so the persist path and config routes always ' +
90+
'agree.',
91+
fallback: 'PII_REDACTION',
92+
},
8593
} satisfies Record<string, FeatureFlagDefinition>
8694

8795
/**

apps/sim/lib/logs/execution/logger.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
import { resolveEffectivePiiRedaction } from '@/lib/billing/retention'
3030
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
3131
import { isBillingEnabled } from '@/lib/core/config/env-flags'
32+
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
3233
import { redactApiKeys } from '@/lib/core/security/redaction'
3334
import { filterForDisplay } from '@/lib/core/utils/display-filters'
3435
import {
@@ -601,6 +602,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
601602
): Promise<RedactablePayload> {
602603
if (!workspaceId) return payload
603604

605+
if (!(await isFeatureEnabled('pii-redaction'))) return payload
606+
604607
const [row] = await db
605608
.select({ orgSettings: organization.dataRetentionSettings })
606609
.from(workspace)

0 commit comments

Comments
 (0)