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
This commit is contained in:
Theodore Li
2026-06-19 22:18:00 -04:00
committed by GitHub
parent ecbe1919d8
commit 3b78436ae3
7 changed files with 103 additions and 74 deletions
@@ -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,7 @@ export const GET = withRouteHandler(
}
const isEnterprise = !isBillingEnabled || (await isOrganizationOnEnterprisePlan(organizationId))
const piiRedactionEnabled = await isFeatureEnabled('pii-redaction')
const configured = normalizeConfigured(org.dataRetentionSettings)
const defaults = enterpriseDefaults()
@@ -86,6 +88,7 @@ export const GET = withRouteHandler(
defaults,
configured,
effective: isEnterprise ? configured : defaults,
piiRedactionEnabled,
},
})
}
@@ -154,6 +157,8 @@ export const PUT = withRouteHandler(
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
const piiRedactionEnabled = await isFeatureEnabled('pii-redaction')
const current = normalizeConfigured(currentOrg.dataRetentionSettings)
const merged: DataRetentionSettings = { ...current }
if (body.logRetentionHours !== undefined) {
@@ -166,6 +171,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 +214,7 @@ export const PUT = withRouteHandler(
defaults,
configured,
effective: configured,
piiRedactionEnabled,
},
})
}
@@ -492,89 +492,91 @@ export function DataRetentionSettings() {
</SettingRow>
</div>
</SettingsSection>
<SettingsSection label='PII Redaction'>
<div className='flex flex-col gap-6'>
<div className='flex flex-col gap-2'>
<div className='flex items-center justify-between gap-3'>
<span className='font-medium text-[var(--text-muted)] text-small'>
Default · all workspaces
</span>
{!defaultRule && (
<Chip leftIcon={Plus} onClick={openEditDefault}>
Add redaction
</Chip>
)}
</div>
{defaultRule && (
<div className='flex items-center justify-between gap-3 rounded-lg border border-[var(--border-1)] px-3 py-2'>
<span className='truncate text-[var(--text-body)] text-small'>
{entitySummary(defaultRule.entityTypes)}
</span>
<div className='flex flex-shrink-0 items-center gap-2'>
<Chip onClick={openEditDefault}>Edit</Chip>
<Chip
onClick={() => removeRule(defaultRule.id)}
disabled={updateMutation.isPending}
>
Delete
</Chip>
</div>
</div>
)}
</div>
{defaultRule && (
{data?.piiRedactionEnabled && (
<SettingsSection label='PII Redaction'>
<div className='flex flex-col gap-6'>
<div className='flex flex-col gap-2'>
<div className='flex items-center justify-between gap-3'>
<span className='font-medium text-[var(--text-muted)] text-small'>
Workspace overrides
Default · all workspaces
</span>
<Chip
leftIcon={Plus}
onClick={openAddOverride}
disabled={freeWorkspaces.length === 0}
>
Add override
</Chip>
{!defaultRule && (
<Chip leftIcon={Plus} onClick={openEditDefault}>
Add redaction
</Chip>
)}
</div>
{overrideRules.length === 0 ? (
<p className='text-[var(--text-muted)] text-caption'>
No overrides — every workspace uses the default.
</p>
) : (
<div className='flex flex-col gap-2'>
{overrideRules.map((rule) => (
<div
key={rule.id}
className='flex items-center justify-between gap-3 rounded-lg border border-[var(--border-1)] px-3 py-2'
>
<div className='flex min-w-0 flex-col'>
<span className='truncate text-[var(--text-body)] text-small'>
{workspaceName(rule.workspaceId as string)}
</span>
<span className='truncate text-[var(--text-muted)] text-caption'>
{entitySummary(rule.entityTypes)}
</span>
</div>
<div className='flex flex-shrink-0 items-center gap-2'>
<Chip onClick={() => openEditOverride(rule)}>Edit</Chip>
<Chip
onClick={() => removeRule(rule.id)}
disabled={updateMutation.isPending}
>
Delete
</Chip>
</div>
</div>
))}
<span className='text-[var(--text-muted)] text-caption'>
Workspaces not listed use the default.
{defaultRule && (
<div className='flex items-center justify-between gap-3 rounded-lg border border-[var(--border-1)] px-3 py-2'>
<span className='truncate text-[var(--text-body)] text-small'>
{entitySummary(defaultRule.entityTypes)}
</span>
<div className='flex flex-shrink-0 items-center gap-2'>
<Chip onClick={openEditDefault}>Edit</Chip>
<Chip
onClick={() => removeRule(defaultRule.id)}
disabled={updateMutation.isPending}
>
Delete
</Chip>
</div>
</div>
)}
</div>
)}
</div>
</SettingsSection>
{defaultRule && (
<div className='flex flex-col gap-2'>
<div className='flex items-center justify-between gap-3'>
<span className='font-medium text-[var(--text-muted)] text-small'>
Workspace overrides
</span>
<Chip
leftIcon={Plus}
onClick={openAddOverride}
disabled={freeWorkspaces.length === 0}
>
Add override
</Chip>
</div>
{overrideRules.length === 0 ? (
<p className='text-[var(--text-muted)] text-caption'>
No overrides — every workspace uses the default.
</p>
) : (
<div className='flex flex-col gap-2'>
{overrideRules.map((rule) => (
<div
key={rule.id}
className='flex items-center justify-between gap-3 rounded-lg border border-[var(--border-1)] px-3 py-2'
>
<div className='flex min-w-0 flex-col'>
<span className='truncate text-[var(--text-body)] text-small'>
{workspaceName(rule.workspaceId as string)}
</span>
<span className='truncate text-[var(--text-muted)] text-caption'>
{entitySummary(rule.entityTypes)}
</span>
</div>
<div className='flex flex-shrink-0 items-center gap-2'>
<Chip onClick={() => openEditOverride(rule)}>Edit</Chip>
<Chip
onClick={() => removeRule(rule.id)}
disabled={updateMutation.isPending}
>
Delete
</Chip>
</div>
</div>
))}
<span className='text-[var(--text-muted)] text-caption'>
Workspaces not listed use the default.
</span>
</div>
)}
</div>
)}
</div>
</SettingsSection>
)}
</div>
</div>
{modalDraft && (
@@ -129,6 +129,7 @@ const organizationDataRetentionDataSchema = z.object({
defaults: organizationRetentionValuesSchema,
configured: organizationRetentionValuesSchema,
effective: organizationRetentionValuesSchema,
piiRedactionEnabled: z.boolean(),
})
export type OrganizationDataRetention = z.output<typeof organizationDataRetentionDataSchema>
+1
View File
@@ -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)
@@ -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 () => {
@@ -82,6 +82,14 @@ 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. Global on/off ' +
'only — evaluated without user/org context so the persist path and config routes always ' +
'agree.',
fallback: 'PII_REDACTION',
},
} satisfies Record<string, FeatureFlagDefinition>
/**
+3
View File
@@ -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 {
@@ -601,6 +602,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
): Promise<RedactablePayload> {
if (!workspaceId) return payload
if (!(await isFeatureEnabled('pii-redaction'))) return payload
const [row] = await db
.select({ orgSettings: organization.dataRetentionSettings })
.from(workspace)