From f8ca77c6129b9eb35d6b3a447ec950f5fb1ccf1f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 19 Jun 2026 19:06:44 -0700 Subject: [PATCH 1/2] feat(pii): gate data retention PII redaction behind feature flag --- .../[id]/data-retention/route.ts | 18 +++ .../components/data-retention-settings.tsx | 148 +++++++++--------- apps/sim/lib/api/contracts/organization.ts | 1 + apps/sim/lib/core/config/env.ts | 1 + .../sim/lib/core/config/feature-flags.test.ts | 2 + apps/sim/lib/core/config/feature-flags.ts | 7 + apps/sim/lib/logs/execution/logger.ts | 5 +- 7 files changed, 108 insertions(+), 74 deletions(-) diff --git a/apps/sim/app/api/organizations/[id]/data-retention/route.ts b/apps/sim/app/api/organizations/[id]/data-retention/route.ts index e67d229df1a..32526896d79 100644 --- a/apps/sim/app/api/organizations/[id]/data-retention/route.ts +++ b/apps/sim/app/api/organizations/[id]/data-retention/route.ts @@ -14,6 +14,7 @@ import { getSession } from '@/lib/auth' import { CLEANUP_CONFIG } from '@/lib/billing/cleanup-dispatcher' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('DataRetentionAPI') @@ -76,6 +77,10 @@ export const GET = withRouteHandler( } const isEnterprise = !isBillingEnabled || (await isOrganizationOnEnterprisePlan(organizationId)) + const piiRedactionEnabled = await isFeatureEnabled('pii-redaction', { + userId: session.user.id, + orgId: organizationId, + }) const configured = normalizeConfigured(org.dataRetentionSettings) const defaults = enterpriseDefaults() @@ -86,6 +91,7 @@ export const GET = withRouteHandler( defaults, configured, effective: isEnterprise ? configured : defaults, + piiRedactionEnabled, }, }) } @@ -154,6 +160,11 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) } + const piiRedactionEnabled = await isFeatureEnabled('pii-redaction', { + userId: session.user.id, + orgId: organizationId, + }) + const current = normalizeConfigured(currentOrg.dataRetentionSettings) const merged: DataRetentionSettings = { ...current } if (body.logRetentionHours !== undefined) { @@ -166,6 +177,12 @@ export const PUT = withRouteHandler( merged.taskCleanupHours = body.taskCleanupHours } if (body.piiRedaction !== undefined) { + if (!piiRedactionEnabled) { + return NextResponse.json( + { error: 'PII redaction is not enabled for this organization' }, + { status: 403 } + ) + } merged.piiRedaction = body.piiRedaction } @@ -203,6 +220,7 @@ export const PUT = withRouteHandler( defaults, configured, effective: configured, + piiRedactionEnabled, }, }) } diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx index d1ef5aa1ad6..bca54112d5e 100644 --- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx +++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx @@ -492,89 +492,91 @@ export function DataRetentionSettings() { - -
-
-
- - Default · all workspaces - - {!defaultRule && ( - - Add redaction - + {data?.piiRedactionEnabled && ( + +
+
+
+ + Default · all workspaces + + {!defaultRule && ( + + Add redaction + + )} +
+ {defaultRule && ( +
+ + {entitySummary(defaultRule.entityTypes)} + +
+ Edit + removeRule(defaultRule.id)} + disabled={updateMutation.isPending} + > + Delete + +
+
)}
{defaultRule && ( -
- - {entitySummary(defaultRule.entityTypes)} - -
- Edit +
+
+ + Workspace overrides + removeRule(defaultRule.id)} - disabled={updateMutation.isPending} + leftIcon={Plus} + onClick={openAddOverride} + disabled={freeWorkspaces.length === 0} > - Delete + Add override
+ {overrideRules.length === 0 ? ( +

+ No overrides — every workspace uses the default. +

+ ) : ( +
+ {overrideRules.map((rule) => ( +
+
+ + {workspaceName(rule.workspaceId as string)} + + + {entitySummary(rule.entityTypes)} + +
+
+ openEditOverride(rule)}>Edit + removeRule(rule.id)} + disabled={updateMutation.isPending} + > + Delete + +
+
+ ))} + + Workspaces not listed use the default. + +
+ )}
)}
- {defaultRule && ( -
-
- - Workspace overrides - - - Add override - -
- {overrideRules.length === 0 ? ( -

- No overrides — every workspace uses the default. -

- ) : ( -
- {overrideRules.map((rule) => ( -
-
- - {workspaceName(rule.workspaceId as string)} - - - {entitySummary(rule.entityTypes)} - -
-
- openEditOverride(rule)}>Edit - removeRule(rule.id)} - disabled={updateMutation.isPending} - > - Delete - -
-
- ))} - - Workspaces not listed use the default. - -
- )} -
- )} -
- + + )}
{modalDraft && ( diff --git a/apps/sim/lib/api/contracts/organization.ts b/apps/sim/lib/api/contracts/organization.ts index 8ba84cfc4e2..4993e95901c 100644 --- a/apps/sim/lib/api/contracts/organization.ts +++ b/apps/sim/lib/api/contracts/organization.ts @@ -129,6 +129,7 @@ const organizationDataRetentionDataSchema = z.object({ defaults: organizationRetentionValuesSchema, configured: organizationRetentionValuesSchema, effective: organizationRetentionValuesSchema, + piiRedactionEnabled: z.boolean(), }) export type OrganizationDataRetention = z.output diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 27d810ed73b..09c2e4fe51c 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -73,6 +73,7 @@ export const env = createEnv({ 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 TABLES_FRACTIONAL_ORDERING: z.boolean().optional(), // Order table rows by fractional order_key (O(1) insert/delete) instead of integer position 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 + 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 // Table feature limits (per plan). Apply when billing is disabled (free tier defaults) or for billed plans. FREE_TABLES_LIMIT: z.number().optional(), // Max user tables per workspace on free tier (default: 5) diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts index ac5fa766a87..a38f4340634 100644 --- a/apps/sim/lib/core/config/feature-flags.test.ts +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -62,6 +62,7 @@ describe('getFeatureFlags', () => { // All registered flags should be present, disabled (env vars unset in test env) expect(flags['tables-fractional-ordering']).toEqual({ enabled: false }) expect(flags['mothership-beta']).toEqual({ enabled: false }) + expect(flags['pii-redaction']).toEqual({ enabled: false }) expect(mockFetch).not.toHaveBeenCalled() }) @@ -88,6 +89,7 @@ describe('getFeatureFlags', () => { const flags = await getFeatureFlags() expect(flags['tables-fractional-ordering']).toEqual({ enabled: false }) expect(flags['mothership-beta']).toEqual({ enabled: false }) + expect(flags['pii-redaction']).toEqual({ enabled: false }) }) it('degrades gracefully on a malformed document', async () => { diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 85107d74550..989024f47bf 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -82,6 +82,13 @@ const FEATURE_FLAGS = { 'enabled:true for global rollout rather than per-user targeting.', fallback: 'TABLE_SNAPSHOT_CACHE', }, + 'pii-redaction': { + description: + 'Redact PII from workflow logs via configurable Data Retention rules (Presidio at the ' + + 'logger persist choke point) and expose the Data Retention config surfaces. Gate by org ' + + 'for staged rollout.', + fallback: 'PII_REDACTION', + }, } satisfies Record /** diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 9a77a0ec427..c3145060648 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -29,6 +29,7 @@ import { import { resolveEffectivePiiRedaction } from '@/lib/billing/retention' import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { redactApiKeys } from '@/lib/core/security/redaction' import { filterForDisplay } from '@/lib/core/utils/display-filters' import { @@ -602,13 +603,15 @@ export class ExecutionLogger implements IExecutionLoggerService { if (!workspaceId) return payload const [row] = await db - .select({ orgSettings: organization.dataRetentionSettings }) + .select({ orgId: organization.id, orgSettings: organization.dataRetentionSettings }) .from(workspace) .leftJoin(organization, eq(organization.id, workspace.organizationId)) .where(eq(workspace.id, workspaceId)) .limit(1) if (!row) return payload + if (!(await isFeatureEnabled('pii-redaction', { orgId: row.orgId }))) return payload + // Rules are only writable by enterprise orgs (route-gated), so an enabled // rule already implies entitlement. We deliberately do NOT re-check // `isWorkspaceOnEnterprisePlan` here: it returns false on transient lookup From ebdd7739dc7fc8dc78514718fbc61912d844b681 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 19 Jun 2026 19:12:18 -0700 Subject: [PATCH 2/2] fix(pii): evaluate pii-redaction flag globally with no org/user context --- .../app/api/organizations/[id]/data-retention/route.ts | 10 ++-------- apps/sim/lib/core/config/feature-flags.ts | 5 +++-- apps/sim/lib/logs/execution/logger.ts | 6 +++--- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/api/organizations/[id]/data-retention/route.ts b/apps/sim/app/api/organizations/[id]/data-retention/route.ts index 32526896d79..37fbbaabb94 100644 --- a/apps/sim/app/api/organizations/[id]/data-retention/route.ts +++ b/apps/sim/app/api/organizations/[id]/data-retention/route.ts @@ -77,10 +77,7 @@ export const GET = withRouteHandler( } const isEnterprise = !isBillingEnabled || (await isOrganizationOnEnterprisePlan(organizationId)) - const piiRedactionEnabled = await isFeatureEnabled('pii-redaction', { - userId: session.user.id, - orgId: organizationId, - }) + const piiRedactionEnabled = await isFeatureEnabled('pii-redaction') const configured = normalizeConfigured(org.dataRetentionSettings) const defaults = enterpriseDefaults() @@ -160,10 +157,7 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) } - const piiRedactionEnabled = await isFeatureEnabled('pii-redaction', { - userId: session.user.id, - orgId: organizationId, - }) + const piiRedactionEnabled = await isFeatureEnabled('pii-redaction') const current = normalizeConfigured(currentOrg.dataRetentionSettings) const merged: DataRetentionSettings = { ...current } diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 989024f47bf..6fa8ec0ebe8 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -85,8 +85,9 @@ const FEATURE_FLAGS = { 'pii-redaction': { description: 'Redact PII from workflow logs via configurable Data Retention rules (Presidio at the ' + - 'logger persist choke point) and expose the Data Retention config surfaces. Gate by org ' + - 'for staged rollout.', + 'logger persist choke point) and expose the Data Retention config surfaces. Global on/off ' + + 'only — evaluated without user/org context so the persist path and config routes always ' + + 'agree.', fallback: 'PII_REDACTION', }, } satisfies Record diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index c3145060648..9a531b72934 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -602,16 +602,16 @@ export class ExecutionLogger implements IExecutionLoggerService { ): Promise { if (!workspaceId) return payload + if (!(await isFeatureEnabled('pii-redaction'))) return payload + const [row] = await db - .select({ orgId: organization.id, orgSettings: organization.dataRetentionSettings }) + .select({ orgSettings: organization.dataRetentionSettings }) .from(workspace) .leftJoin(organization, eq(organization.id, workspace.organizationId)) .where(eq(workspace.id, workspaceId)) .limit(1) if (!row) return payload - if (!(await isFeatureEnabled('pii-redaction', { orgId: row.orgId }))) return payload - // Rules are only writable by enterprise orgs (route-gated), so an enabled // rule already implies entitlement. We deliberately do NOT re-check // `isWorkspaceOnEnterprisePlan` here: it returns false on transient lookup