Skip to content

Commit b19296c

Browse files
refactor(logs): single-scope PII rules with most-specific-wins resolution
Each rule now targets one scope — all workspaces (workspaceId: null) or a single workspace — with workspaceId unique across rules. Resolution is most-specific-wins (a workspace's own rule overrides the all rule), not union; an empty specific rule exempts that workspace. Matches Access Control's resolveWorkspaceGroup precedence. UI 'Applies to' becomes a single-select; Add rule disables when all scopes are taken.
1 parent a5ca0f4 commit b19296c

5 files changed

Lines changed: 129 additions & 71 deletions

File tree

apps/sim/ee/data-retention/components/data-retention-settings.tsx

Lines changed: 44 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { Plus } from 'lucide-react'
88
import {
99
Checkbox,
1010
Chip,
11-
ChipDropdown,
1211
ChipInput,
1312
ChipModal,
1413
ChipModalBody,
@@ -35,6 +34,9 @@ import { useWorkspacesQuery } from '@/hooks/queries/workspace'
3534

3635
const logger = createLogger('DataRetentionSettings')
3736

37+
/** Sentinel ChipSelect value representing the all-workspaces scope (`workspaceId: null`). */
38+
const ALL_WORKSPACES = '__all__'
39+
3840
const DAY_OPTIONS = [
3941
{ value: '1', label: '1 day' },
4042
{ value: '3', label: '3 days' },
@@ -54,8 +56,8 @@ interface RuleDraft {
5456
id: string
5557
name: string
5658
entityTypes: string[]
57-
appliesToAllWorkspaces: boolean
58-
workspaceIds: string[]
59+
/** null = all workspaces; otherwise the single targeted workspace. */
60+
workspaceId: string | null
5961
}
6062

6163
function hoursToDisplayDays(hours: number | null): string {
@@ -72,8 +74,7 @@ function normalizeRule(rule: RuleDraft): string {
7274
return JSON.stringify({
7375
name: rule.name.trim(),
7476
entityTypes: [...rule.entityTypes].sort(),
75-
appliesToAllWorkspaces: rule.appliesToAllWorkspaces,
76-
workspaceIds: rule.appliesToAllWorkspaces ? [] : [...rule.workspaceIds].sort(),
77+
workspaceId: rule.workspaceId,
7778
})
7879
}
7980

@@ -82,12 +83,6 @@ function entitySummary(rule: RuleDraft): string {
8283
return `${rule.entityTypes.length} entity type${rule.entityTypes.length === 1 ? '' : 's'}`
8384
}
8485

85-
function workspaceSummary(rule: RuleDraft): string {
86-
if (rule.appliesToAllWorkspaces) return 'All workspaces'
87-
const n = rule.workspaceIds.length
88-
return `${n} workspace${n === 1 ? '' : 's'}`
89-
}
90-
9186
interface RetentionSelectProps {
9287
value: string
9388
onChange: (value: string) => void
@@ -189,7 +184,8 @@ interface RuleModalProps {
189184
draft: RuleDraft
190185
isNew: boolean
191186
isSaving: boolean
192-
workspaceOptions: { value: string; label: string }[]
187+
/** Selectable scopes for this rule (excludes scopes taken by other rules). */
188+
scopeOptions: { value: string; label: string }[]
193189
onChange: (draft: RuleDraft) => void
194190
onClose: () => void
195191
onSave: () => void
@@ -199,7 +195,7 @@ function RuleModal({
199195
draft,
200196
isNew,
201197
isSaving,
202-
workspaceOptions,
198+
scopeOptions,
203199
onChange,
204200
onClose,
205201
onSave,
@@ -218,22 +214,12 @@ function RuleModal({
218214
placeholder='e.g., Mask customer contact info'
219215
/>
220216
<ChipModalField type='custom' title='Applies to'>
221-
<ChipDropdown
222-
multiple
223-
searchable
224-
value={draft.workspaceIds}
225-
onChange={(workspaceIds) =>
226-
onChange({
227-
...draft,
228-
workspaceIds,
229-
appliesToAllWorkspaces: workspaceIds.length === 0,
230-
})
217+
<ChipSelect
218+
value={draft.workspaceId ?? ALL_WORKSPACES}
219+
onChange={(value) =>
220+
onChange({ ...draft, workspaceId: value === ALL_WORKSPACES ? null : value })
231221
}
232-
options={workspaceOptions}
233-
allLabel='All workspaces'
234-
placeholder='All workspaces'
235-
searchPlaceholder='Search workspaces'
236-
matchTriggerWidth={false}
222+
options={scopeOptions}
237223
align='start'
238224
/>
239225
</ChipModalField>
@@ -305,8 +291,7 @@ export function DataRetentionSettings() {
305291
id: r.id,
306292
name: r.name ?? '',
307293
entityTypes: r.entityTypes,
308-
appliesToAllWorkspaces: r.appliesToAllWorkspaces,
309-
workspaceIds: r.workspaceIds,
294+
workspaceId: r.workspaceId,
310295
}))
311296
)
312297
formInitializedRef.current = true
@@ -318,6 +303,27 @@ export function DataRetentionSettings() {
318303
modalOriginal !== null &&
319304
normalizeRule(modalDraft) !== normalizeRule(modalOriginal)
320305

306+
// Scope availability: at most one all-workspaces rule and one rule per workspace.
307+
const allScopeTaken = rules.some((r) => r.workspaceId === null)
308+
const takenWorkspaceIds = new Set(
309+
rules.flatMap((r) => (r.workspaceId === null ? [] : [r.workspaceId]))
310+
)
311+
const freeWorkspaces = workspaceOptions.filter((w) => !takenWorkspaceIds.has(w.value))
312+
const hasAvailableScope = !allScopeTaken || freeWorkspaces.length > 0
313+
314+
/** Scopes selectable for `draft` — excludes scopes taken by OTHER rules. */
315+
function scopeOptionsForDraft(draft: RuleDraft): { value: string; label: string }[] {
316+
const others = rules.filter((r) => r.id !== draft.id)
317+
const otherAll = others.some((r) => r.workspaceId === null)
318+
const otherWs = new Set(others.flatMap((r) => (r.workspaceId === null ? [] : [r.workspaceId])))
319+
const options: { value: string; label: string }[] = []
320+
if (!otherAll) options.push({ value: ALL_WORKSPACES, label: 'All workspaces' })
321+
for (const w of workspaceOptions) {
322+
if (!otherWs.has(w.value)) options.push(w)
323+
}
324+
return options
325+
}
326+
321327
async function persistRules(nextRules: RuleDraft[]) {
322328
if (!orgId) return
323329
await updateMutation.mutateAsync({
@@ -328,8 +334,7 @@ export function DataRetentionSettings() {
328334
id: r.id,
329335
name: r.name.trim() || undefined,
330336
entityTypes: r.entityTypes,
331-
appliesToAllWorkspaces: r.appliesToAllWorkspaces,
332-
workspaceIds: r.appliesToAllWorkspaces ? [] : r.workspaceIds,
337+
workspaceId: r.workspaceId,
333338
})),
334339
},
335340
},
@@ -342,8 +347,7 @@ export function DataRetentionSettings() {
342347
id: generateId(),
343348
name: '',
344349
entityTypes: [],
345-
appliesToAllWorkspaces: true,
346-
workspaceIds: [],
350+
workspaceId: allScopeTaken ? (freeWorkspaces[0]?.value ?? null) : null,
347351
}
348352
setModalIsNew(true)
349353
setModalOriginal(blank)
@@ -506,10 +510,10 @@ export function DataRetentionSettings() {
506510
{rule.name.trim() || 'Untitled rule'}
507511
</span>
508512
<span className='truncate text-[var(--text-muted)] text-caption'>
509-
{entitySummary(rule)} ·{' '}
510-
{rule.appliesToAllWorkspaces || rule.workspaceIds.length !== 1
511-
? workspaceSummary(rule)
512-
: workspaceName(rule.workspaceIds[0])}
513+
{rule.workspaceId === null
514+
? 'All workspaces'
515+
: workspaceName(rule.workspaceId)}{' '}
516+
· {entitySummary(rule)}
513517
</span>
514518
</div>
515519
<div className='flex flex-shrink-0 items-center gap-2'>
@@ -526,7 +530,7 @@ export function DataRetentionSettings() {
526530
</div>
527531
)}
528532
<div>
529-
<Chip leftIcon={Plus} onClick={openAddRule}>
533+
<Chip leftIcon={Plus} onClick={openAddRule} disabled={!hasAvailableScope}>
530534
Add rule
531535
</Chip>
532536
</div>
@@ -539,7 +543,7 @@ export function DataRetentionSettings() {
539543
draft={modalDraft}
540544
isNew={modalIsNew}
541545
isSaving={updateMutation.isPending}
542-
workspaceOptions={workspaceOptions}
546+
scopeOptions={scopeOptionsForDraft(modalDraft)}
543547
onChange={setModalDraft}
544548
onClose={requestCloseModal}
545549
onSave={saveModalRule}

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,21 +85,21 @@ export const userFileSchema = z
8585
})
8686
.passthrough()
8787

88-
/** A single PII redaction rule targeting a set of workspaces. */
88+
/** A single PII redaction rule targeting one scope (all workspaces, or one). */
8989
export const piiRedactionRuleSchema = z.object({
9090
id: z.string().min(1),
9191
name: z.string().max(100).optional(),
92-
/** Presidio entity types to mask. Empty = redact all detected PII. */
92+
/** Presidio entity types to mask. Empty = redact nothing for this scope. */
9393
entityTypes: z.array(z.string().min(1, 'Entity type cannot be empty')).max(100),
94-
appliesToAllWorkspaces: z.boolean(),
95-
workspaceIds: z.array(z.string().min(1)).max(1000),
94+
/** null = all workspaces; otherwise the single targeted workspace. */
95+
workspaceId: z.string().min(1).nullable(),
9696
})
9797

9898
export type PiiRedactionRule = z.output<typeof piiRedactionRuleSchema>
9999

100100
/** Enterprise PII redaction policy applied to workflow logs on persist. */
101101
export const piiRedactionSettingsSchema = z.object({
102-
rules: z.array(piiRedactionRuleSchema).max(100),
102+
rules: z.array(piiRedactionRuleSchema).max(1000),
103103
})
104104

105105
export type PiiRedactionSettings = z.output<typeof piiRedactionSettingsSchema>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import type { DataRetentionSettings, PiiRedactionRule } from '@sim/db/schema'
5+
import { describe, expect, it } from 'vitest'
6+
import { resolveEffectivePiiRedaction } from '@/lib/billing/retention'
7+
8+
function settings(rules: PiiRedactionRule[]): DataRetentionSettings {
9+
return { piiRedaction: { rules } }
10+
}
11+
12+
describe('resolveEffectivePiiRedaction', () => {
13+
const allRule: PiiRedactionRule = {
14+
id: 'r-all',
15+
entityTypes: ['EMAIL_ADDRESS', 'PHONE_NUMBER'],
16+
workspaceId: null,
17+
}
18+
19+
it('applies the all-workspaces rule when the workspace has no specific rule', () => {
20+
const result = resolveEffectivePiiRedaction({
21+
orgSettings: settings([allRule]),
22+
workspaceId: 'ws-1',
23+
})
24+
expect(result).toEqual({ enabled: true, entityTypes: ['EMAIL_ADDRESS', 'PHONE_NUMBER'] })
25+
})
26+
27+
it('lets a workspace-specific rule override the all rule', () => {
28+
const result = resolveEffectivePiiRedaction({
29+
orgSettings: settings([allRule, { id: 'r-1', entityTypes: ['US_SSN'], workspaceId: 'ws-1' }]),
30+
workspaceId: 'ws-1',
31+
})
32+
expect(result).toEqual({ enabled: true, entityTypes: ['US_SSN'] })
33+
})
34+
35+
it('exempts a workspace when its specific rule has no entity types', () => {
36+
const result = resolveEffectivePiiRedaction({
37+
orgSettings: settings([allRule, { id: 'r-1', entityTypes: [], workspaceId: 'ws-1' }]),
38+
workspaceId: 'ws-1',
39+
})
40+
expect(result).toEqual({ enabled: false, entityTypes: [] })
41+
})
42+
43+
it('is disabled when no rule matches and there is no all rule', () => {
44+
const result = resolveEffectivePiiRedaction({
45+
orgSettings: settings([{ id: 'r-1', entityTypes: ['US_SSN'], workspaceId: 'ws-2' }]),
46+
workspaceId: 'ws-1',
47+
})
48+
expect(result).toEqual({ enabled: false, entityTypes: [] })
49+
})
50+
51+
it('is disabled when there are no rules', () => {
52+
expect(
53+
resolveEffectivePiiRedaction({ orgSettings: settings([]), workspaceId: 'ws-1' })
54+
).toEqual({ enabled: false, entityTypes: [] })
55+
expect(resolveEffectivePiiRedaction({ orgSettings: null, workspaceId: 'ws-1' })).toEqual({
56+
enabled: false,
57+
entityTypes: [],
58+
})
59+
})
60+
})

apps/sim/lib/billing/retention.ts

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@ export const DEFAULT_PII_REDACTION: EffectivePiiRedaction = {
1313

1414
/**
1515
* Resolve the effective PII redaction policy for a workspace from the org-level
16-
* rules list: the entity types of every rule targeting the workspace are
17-
* unioned. A rule with no entity types selected redacts nothing (it contributes
18-
* nothing to the union), so an empty effective set means "redact nothing" —
19-
* never "redact everything". Defensive about the loosely-typed JSON column.
16+
* rules list, most-specific-wins (never unioned): the workspace's own rule takes
17+
* precedence over the all-workspaces rule (`workspaceId: null`). A resolved rule
18+
* with no entity types redacts nothing — so a workspace-specific empty rule
19+
* exempts that workspace, overriding the all rule. Defensive about the
20+
* loosely-typed JSON column.
2021
*/
2122
export function resolveEffectivePiiRedaction(params: {
2223
orgSettings: DataRetentionSettings | null | undefined
@@ -25,19 +26,13 @@ export function resolveEffectivePiiRedaction(params: {
2526
const rules = params.orgSettings?.piiRedaction?.rules
2627
if (!Array.isArray(rules) || rules.length === 0) return DEFAULT_PII_REDACTION
2728

28-
const applicable = rules.filter(
29-
(rule) =>
30-
rule?.appliesToAllWorkspaces === true ||
31-
(Array.isArray(rule?.workspaceIds) && rule.workspaceIds.includes(params.workspaceId))
32-
)
29+
const rule =
30+
rules.find((r) => r?.workspaceId === params.workspaceId) ??
31+
rules.find((r) => r?.workspaceId == null)
3332

34-
const union = new Set<string>()
35-
for (const rule of applicable) {
36-
if (!Array.isArray(rule.entityTypes)) continue
37-
for (const t of rule.entityTypes) {
38-
if (typeof t === 'string') union.add(t)
39-
}
40-
}
41-
if (union.size === 0) return DEFAULT_PII_REDACTION
42-
return { enabled: true, entityTypes: [...union] }
33+
const types = Array.isArray(rule?.entityTypes)
34+
? rule.entityTypes.filter((t): t is string => typeof t === 'string')
35+
: []
36+
if (types.length === 0) return DEFAULT_PII_REDACTION
37+
return { enabled: true, entityTypes: types }
4338
}

packages/db/schema.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,19 +1065,18 @@ export const chat = pgTable(
10651065

10661066
/**
10671067
* A single PII redaction rule. Lives in the org-level
1068-
* {@link DataRetentionSettings.piiRedaction} rules list. A workflow log is
1069-
* redacted on persist if any rule targets its workspace; the applicable rules'
1070-
* entity types are unioned (an empty `entityTypes` means "redact all").
1068+
* {@link DataRetentionSettings.piiRedaction} rules list. Each rule targets one
1069+
* scope — all workspaces (`workspaceId: null`) or a single workspace — and
1070+
* `workspaceId` is unique across rules. Resolution is most-specific-wins: a
1071+
* workspace's own rule overrides the all-workspaces rule (never unioned).
10711072
*/
10721073
export interface PiiRedactionRule {
10731074
id: string
10741075
name?: string
1075-
/** Presidio entity types to mask. Empty = redact all detected PII. */
1076+
/** Presidio entity types to mask. Empty = redact nothing for this scope. */
10761077
entityTypes: string[]
1077-
/** When true the rule covers every workspace in the org. */
1078-
appliesToAllWorkspaces: boolean
1079-
/** Targeted workspace ids when `appliesToAllWorkspaces` is false. */
1080-
workspaceIds: string[]
1078+
/** `null` = all workspaces; otherwise the single targeted workspace. */
1079+
workspaceId: string | null
10811080
}
10821081

10831082
/**

0 commit comments

Comments
 (0)