Skip to content

Commit 437d2bb

Browse files
fix(data-retention): reject enabled stage with no entity types; empty = off everywhere
1 parent 31f2e3f commit 437d2bb

4 files changed

Lines changed: 57 additions & 23 deletions

File tree

apps/sim/lib/api/contracts/data-retention.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,32 @@ describe('piiRedactionRuleSchema', () => {
106106
expect(result.success).toBe(false)
107107
})
108108

109+
it('rejects an enabled stage with no entity types (redact-all is not expressible)', () => {
110+
const result = piiRedactionRuleSchema.safeParse({
111+
id: 'r-1',
112+
workspaceId: null,
113+
stages: {
114+
input: stage(true, []),
115+
blockOutputs: stage(false, []),
116+
logs: stage(false, []),
117+
},
118+
})
119+
expect(result.success).toBe(false)
120+
})
121+
122+
it('accepts a disabled stage with no entity types (off)', () => {
123+
const result = piiRedactionRuleSchema.safeParse({
124+
id: 'r-1',
125+
workspaceId: null,
126+
stages: {
127+
input: stage(false, []),
128+
blockOutputs: stage(false, []),
129+
logs: stage(true, ['PERSON']),
130+
},
131+
})
132+
expect(result.success).toBe(true)
133+
})
134+
109135
it('rejects an unsupported stage language', () => {
110136
const result = piiRedactionRuleSchema.safeParse({
111137
id: 'r-1',

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

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -113,14 +113,24 @@ export const userFileSchema = z
113113
})
114114
.passthrough()
115115

116-
/** Per-stage redaction policy: which entity types to mask, in which language. */
117-
export const piiStagePolicySchema = z.object({
118-
enabled: z.boolean(),
119-
/** Presidio entity types to mask. Empty (or disabled) = redact nothing. */
120-
entityTypes: z.array(z.string().min(1, 'Entity type cannot be empty')).max(100),
121-
/** Language whose Presidio recognizers apply; defaults to English. */
122-
language: z.enum(PII_LANGUAGE_CODES).optional(),
123-
})
116+
/**
117+
* Per-stage redaction policy: which entity types to mask, in which language. An
118+
* enabled stage must name at least one entity type — "redact all" is not an
119+
* expressible policy, so `enabled: true` with an empty list (which would resolve
120+
* to off and silently skip masking) is rejected at the boundary.
121+
*/
122+
export const piiStagePolicySchema = z
123+
.object({
124+
enabled: z.boolean(),
125+
/** Presidio entity types to mask. Disabled stages may be empty. */
126+
entityTypes: z.array(z.string().min(1, 'Entity type cannot be empty')).max(100),
127+
/** Language whose Presidio recognizers apply; defaults to English. */
128+
language: z.enum(PII_LANGUAGE_CODES).optional(),
129+
})
130+
.refine((stage) => !stage.enabled || stage.entityTypes.length > 0, {
131+
message: 'An enabled redaction stage must select at least one entity type.',
132+
path: ['entityTypes'],
133+
})
124134

125135
export type PiiStagePolicy = z.output<typeof piiStagePolicySchema>
126136

apps/sim/lib/billing/retention.test.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ describe('resolveEffectivePiiRedaction', () => {
108108
})
109109
})
110110

111-
it('keeps an enabled stage active (redact all) when it has no entity types, and disables a disabled stage', () => {
111+
it('disables a stage that is enabled but has no entity types (empty = off)', () => {
112112
const result = resolveEffectivePiiRedaction({
113113
orgSettings: settings([
114114
{
@@ -123,9 +123,7 @@ describe('resolveEffectivePiiRedaction', () => {
123123
]),
124124
workspaceId: 'ws-1',
125125
})
126-
// enabled + empty entityTypes = redact ALL detected PII (not disabled).
127-
expect(result.input).toEqual({ enabled: true, entityTypes: [], language: 'en' })
128-
// disabled stage stays off regardless of its entity types.
126+
expect(result.input).toEqual(DISABLED)
129127
expect(result.blockOutputs).toEqual(DISABLED)
130128
expect(result.logs).toEqual({ enabled: true, entityTypes: ['PERSON'], language: 'en' })
131129
})

apps/sim/lib/billing/retention.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,18 @@ function sanitizeEntityTypes(value: unknown): string[] {
3838
}
3939

4040
/**
41-
* Expand a stored stage policy into its effective form. A disabled stage redacts
42-
* nothing. An ENABLED stage with no entity types redacts ALL detected PII — the
43-
* masking layer omits `entities` from the Presidio request — so it stays active;
44-
* treating enabled-but-empty as disabled would let an explicit "redact all" save
45-
* silently skip masking (fail-open).
41+
* Expand a stored stage policy into its effective form. A stage redacts nothing
42+
* unless it is enabled AND names at least one entity type. "Redact all" is not an
43+
* expressible policy (the checkbox UI has no such control, and the contract
44+
* rejects enabled-with-no-types), so an empty entity list always means "off" —
45+
* consistent across the UI, the contract, and the masking layer.
4646
*/
4747
function toEffectiveStage(policy: PiiStagePolicy | undefined): EffectivePiiStage {
48-
if (!policy?.enabled) return DISABLED_STAGE
48+
const types = sanitizeEntityTypes(policy?.entityTypes)
49+
if (!policy?.enabled || types.length === 0) return DISABLED_STAGE
4950
return {
5051
enabled: true,
51-
entityTypes: sanitizeEntityTypes(policy.entityTypes),
52+
entityTypes: types,
5253
language: coercePiiLanguage(policy.language) ?? DEFAULT_PII_LANGUAGE,
5354
}
5455
}
@@ -60,10 +61,9 @@ function toEffectiveStage(policy: PiiStagePolicy | undefined): EffectivePiiStage
6061
* selection is whole-rule; the selected rule is then expanded into three stages.
6162
*
6263
* Back-compat: a legacy rule with no `stages` is treated exactly as it was before
63-
* — logs-only, masking its flat `entityTypes` (input/blockOutputs disabled); an
64-
* empty flat `entityTypes` redacts nothing (the workspace-exemption shape). For
65-
* per-stage rules an enabled stage with no entity types redacts ALL detected PII
66-
* (see {@link toEffectiveStage}). Defensive about the loosely-typed JSON column.
64+
* — logs-only, masking its flat `entityTypes` (input/blockOutputs disabled). A
65+
* resolved stage with no entity types redacts nothing (an empty list is the
66+
* workspace-exemption / off shape). Defensive about the loosely-typed JSON column.
6767
*/
6868
export function resolveEffectivePiiRedaction(params: {
6969
orgSettings: DataRetentionSettings | null | undefined

0 commit comments

Comments
 (0)