mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user