Skip to content

Commit f8ca77c

Browse files
feat(pii): gate data retention PII redaction behind feature flag
1 parent 7349bf4 commit f8ca77c

7 files changed

Lines changed: 108 additions & 74 deletions

File tree

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

Lines changed: 18 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,10 @@ export const GET = withRouteHandler(
7677
}
7778

7879
const isEnterprise = !isBillingEnabled || (await isOrganizationOnEnterprisePlan(organizationId))
80+
const piiRedactionEnabled = await isFeatureEnabled('pii-redaction', {
81+
userId: session.user.id,
82+
orgId: organizationId,
83+
})
7984
const configured = normalizeConfigured(org.dataRetentionSettings)
8085
const defaults = enterpriseDefaults()
8186

@@ -86,6 +91,7 @@ export const GET = withRouteHandler(
8691
defaults,
8792
configured,
8893
effective: isEnterprise ? configured : defaults,
94+
piiRedactionEnabled,
8995
},
9096
})
9197
}
@@ -154,6 +160,11 @@ export const PUT = withRouteHandler(
154160
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
155161
}
156162

163+
const piiRedactionEnabled = await isFeatureEnabled('pii-redaction', {
164+
userId: session.user.id,
165+
orgId: organizationId,
166+
})
167+
157168
const current = normalizeConfigured(currentOrg.dataRetentionSettings)
158169
const merged: DataRetentionSettings = { ...current }
159170
if (body.logRetentionHours !== undefined) {
@@ -166,6 +177,12 @@ export const PUT = withRouteHandler(
166177
merged.taskCleanupHours = body.taskCleanupHours
167178
}
168179
if (body.piiRedaction !== undefined) {
180+
if (!piiRedactionEnabled) {
181+
return NextResponse.json(
182+
{ error: 'PII redaction is not enabled for this organization' },
183+
{ status: 403 }
184+
)
185+
}
169186
merged.piiRedaction = body.piiRedaction
170187
}
171188

@@ -203,6 +220,7 @@ export const PUT = withRouteHandler(
203220
defaults,
204221
configured,
205222
effective: configured,
223+
piiRedactionEnabled,
206224
},
207225
})
208226
}

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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ 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. Gate by org ' +
89+
'for staged rollout.',
90+
fallback: 'PII_REDACTION',
91+
},
8592
} satisfies Record<string, FeatureFlagDefinition>
8693

8794
/**

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

Lines changed: 4 additions & 1 deletion
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 {
@@ -602,13 +603,15 @@ export class ExecutionLogger implements IExecutionLoggerService {
602603
if (!workspaceId) return payload
603604

604605
const [row] = await db
605-
.select({ orgSettings: organization.dataRetentionSettings })
606+
.select({ orgId: organization.id, orgSettings: organization.dataRetentionSettings })
606607
.from(workspace)
607608
.leftJoin(organization, eq(organization.id, workspace.organizationId))
608609
.where(eq(workspace.id, workspaceId))
609610
.limit(1)
610611
if (!row) return payload
611612

613+
if (!(await isFeatureEnabled('pii-redaction', { orgId: row.orgId }))) return payload
614+
612615
// Rules are only writable by enterprise orgs (route-gated), so an enabled
613616
// rule already implies entitlement. We deliberately do NOT re-check
614617
// `isWorkspaceOnEnterprisePlan` here: it returns false on transient lookup

0 commit comments

Comments
 (0)