Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions apps/sim/app/api/guardrails/mask-batch/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockCheckInternalAuth, mockMaskPIIBatch } = vi.hoisted(() => ({
mockCheckInternalAuth: vi.fn(),
mockMaskPIIBatch: vi.fn(),
}))

vi.mock('@/lib/auth/hybrid', () => ({
checkInternalAuth: mockCheckInternalAuth,
}))

vi.mock('@/lib/guardrails/validate_pii', () => ({
maskPIIBatch: mockMaskPIIBatch,
}))

import { POST } from '@/app/api/guardrails/mask-batch/route'

describe('POST /api/guardrails/mask-batch', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckInternalAuth.mockResolvedValue({ success: true })
mockMaskPIIBatch.mockImplementation(async (texts: string[]) => texts.map((t) => `M(${t})`))
})

it('returns 401 without internal auth', async () => {
mockCheckInternalAuth.mockResolvedValue({
success: false,
error: 'Internal authentication required',
})

const res = await POST(
createMockRequest('POST', { texts: ['a@b.com'], entityTypes: ['EMAIL_ADDRESS'] })
)

expect(res.status).toBe(401)
expect(mockMaskPIIBatch).not.toHaveBeenCalled()
})

it('masks the batch in-process and preserves order', async () => {
const res = await POST(
createMockRequest('POST', {
texts: ['a@b.com', 'hello'],
entityTypes: ['EMAIL_ADDRESS'],
language: 'en',
})
)

expect(res.status).toBe(200)
const json = await res.json()
expect(json.masked).toEqual(['M(a@b.com)', 'M(hello)'])
expect(mockMaskPIIBatch).toHaveBeenCalledWith(['a@b.com', 'hello'], ['EMAIL_ADDRESS'], 'en')
})

it('rejects an invalid body with 400', async () => {
const res = await POST(createMockRequest('POST', { texts: 'not-an-array', entityTypes: [] }))

expect(res.status).toBe(400)
expect(mockMaskPIIBatch).not.toHaveBeenCalled()
})
})
45 changes: 45 additions & 0 deletions apps/sim/app/api/guardrails/mask-batch/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { guardrailsMaskBatchContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { maskPIIBatch } from '@/lib/guardrails/validate_pii'

const logger = createLogger('GuardrailsMaskBatchAPI')

/**
* Internal batch PII masking. The log-redaction persist path runs in both the
* Next.js server and the trigger.dev runtime, but the Presidio sidecars live only
* in the app task — so redaction calls this endpoint server-to-server (internal
* JWT) to keep Presidio centralized here.
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
const auth = await checkInternalAuth(request, { requireWorkflowId: false })
if (!auth.success) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const parsed = await parseRequest(guardrailsMaskBatchContract, request, {})
if (!parsed.success) return parsed.response

const { texts, entityTypes, language } = parsed.data.body

try {
const masked = await maskPIIBatch(texts, entityTypes, language)
logger.info('Masked PII batch', { count: texts.length })
return NextResponse.json({ masked })
} catch (error) {
// An unreachable/misconfigured Presidio sidecar makes maskPIIBatch throw; fail
// loudly here (the caller scrubs to REDACTION_FAILED, so PII is never leaked).
logger.error('PII batch masking failed', {
error: getErrorMessage(error),
count: texts.length,
})
return NextResponse.json(
{ error: getErrorMessage(error, 'PII masking failed') },
{ status: 500 }
)
}
})
10 changes: 9 additions & 1 deletion apps/sim/app/api/organizations/[id]/data-retention/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ 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'
import { coercePiiLanguage } from '@/lib/guardrails/pii-entities'

const logger = createLogger('DataRetentionAPI')

Expand All @@ -35,7 +36,14 @@ function normalizeConfigured(
logRetentionHours: settings?.logRetentionHours ?? null,
softDeleteRetentionHours: settings?.softDeleteRetentionHours ?? null,
taskCleanupHours: settings?.taskCleanupHours ?? null,
piiRedaction: settings?.piiRedaction?.rules ? { rules: settings.piiRedaction.rules } : null,
piiRedaction: settings?.piiRedaction?.rules
? {
rules: settings.piiRedaction.rules.map((rule) => ({
...rule,
language: coercePiiLanguage(rule.language),
})),
}
: null,
}
}

Expand Down
77 changes: 11 additions & 66 deletions apps/sim/blocks/blocks/guardrails.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ShieldCheckIcon } from '@/components/icons'
import { PII_ENTITY_GROUPS, PII_LANGUAGES } from '@/lib/guardrails/pii-entities'
import type { BlockConfig } from '@/blocks/types'
import {
getModelOptions,
Expand Down Expand Up @@ -170,65 +171,15 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes,
title: 'PII Types to Detect',
type: 'grouped-checkbox-list',
maxHeight: 400,
options: [
// Common PII types
{ label: 'Person name', id: 'PERSON', group: 'Common' },
{ label: 'Email address', id: 'EMAIL_ADDRESS', group: 'Common' },
{ label: 'Phone number', id: 'PHONE_NUMBER', group: 'Common' },
{ label: 'Location', id: 'LOCATION', group: 'Common' },
{ label: 'Date or time', id: 'DATE_TIME', group: 'Common' },
{ label: 'IP address', id: 'IP_ADDRESS', group: 'Common' },
{ label: 'URL', id: 'URL', group: 'Common' },
{ label: 'Credit card number', id: 'CREDIT_CARD', group: 'Common' },
{ label: 'International bank account number (IBAN)', id: 'IBAN_CODE', group: 'Common' },
{ label: 'Cryptocurrency wallet address', id: 'CRYPTO', group: 'Common' },
{ label: 'Medical license number', id: 'MEDICAL_LICENSE', group: 'Common' },
{ label: 'Nationality / religion / political group', id: 'NRP', group: 'Common' },

// USA
{ label: 'US bank account number', id: 'US_BANK_NUMBER', group: 'USA' },
{ label: 'US driver license number', id: 'US_DRIVER_LICENSE', group: 'USA' },
{
label: 'US individual taxpayer identification number (ITIN)',
id: 'US_ITIN',
group: 'USA',
},
{ label: 'US passport number', id: 'US_PASSPORT', group: 'USA' },
{ label: 'US Social Security number', id: 'US_SSN', group: 'USA' },

// UK
{ label: 'UK National Insurance number', id: 'UK_NINO', group: 'UK' },
{ label: 'UK NHS number', id: 'UK_NHS', group: 'UK' },

// Spain
{ label: 'Spanish NIF number', id: 'ES_NIF', group: 'Spain' },
{ label: 'Spanish NIE number', id: 'ES_NIE', group: 'Spain' },

// Italy
{ label: 'Italian fiscal code', id: 'IT_FISCAL_CODE', group: 'Italy' },
{ label: 'Italian driver license', id: 'IT_DRIVER_LICENSE', group: 'Italy' },
{ label: 'Italian identity card', id: 'IT_IDENTITY_CARD', group: 'Italy' },
{ label: 'Italian passport', id: 'IT_PASSPORT', group: 'Italy' },

// Poland
{ label: 'Polish PESEL', id: 'PL_PESEL', group: 'Poland' },

// Singapore
{ label: 'Singapore NRIC/FIN', id: 'SG_NRIC_FIN', group: 'Singapore' },

// Australia
{ label: 'Australian business number (ABN)', id: 'AU_ABN', group: 'Australia' },
{ label: 'Australian company number (ACN)', id: 'AU_ACN', group: 'Australia' },
{ label: 'Australian tax file number (TFN)', id: 'AU_TFN', group: 'Australia' },
{ label: 'Australian Medicare number', id: 'AU_MEDICARE', group: 'Australia' },

// India
{ label: 'Indian Aadhaar', id: 'IN_AADHAAR', group: 'India' },
{ label: 'Indian PAN', id: 'IN_PAN', group: 'India' },
{ label: 'Indian vehicle registration', id: 'IN_VEHICLE_REGISTRATION', group: 'India' },
{ label: 'Indian voter number', id: 'IN_VOTER', group: 'India' },
{ label: 'Indian passport', id: 'IN_PASSPORT', group: 'India' },
],
// Driven by the shared catalog (includes VIN and custom recognizers) so the
// block and the Data Retention settings never drift.
options: PII_ENTITY_GROUPS.flatMap((group) =>
group.entities.map((entity) => ({
label: entity.label,
id: entity.value,
group: group.label,
}))
),
condition: {
field: 'validationType',
value: ['pii'],
Expand All @@ -255,13 +206,7 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes,
id: 'piiLanguage',
title: 'Language',
type: 'dropdown',
options: [
{ label: 'English', id: 'en' },
{ label: 'Spanish', id: 'es' },
{ label: 'Italian', id: 'it' },
{ label: 'Polish', id: 'pl' },
{ label: 'Finnish', id: 'fi' },
],
options: PII_LANGUAGES.map((language) => ({ label: language.label, id: language.value })),
defaultValue: 'en',
condition: {
field: 'validationType',
Expand Down
38 changes: 35 additions & 3 deletions apps/sim/ee/data-retention/components/data-retention-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ import {
} from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { PII_ENTITY_GROUPS, SUPPORTED_PII_ENTITIES } from '@/lib/guardrails/pii-entities'
import {
DEFAULT_PII_LANGUAGE,
PII_ENTITY_GROUPS,
PII_LANGUAGES,
type PIILanguage,
SUPPORTED_PII_ENTITIES,
} from '@/lib/guardrails/pii-entities'
import { getUserRole } from '@/lib/workspaces/organization/utils'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { InfoNote } from '@/ee/components/info-note'
Expand Down Expand Up @@ -59,6 +65,7 @@ interface RuleDraft {
id: string
entityTypes: string[]
workspaceId: string | null
language: PIILanguage
}

function hoursToDisplayDays(hours: number | null): string {
Expand All @@ -75,6 +82,7 @@ function normalizeRule(rule: RuleDraft): string {
return JSON.stringify({
entityTypes: [...rule.entityTypes].sort(),
workspaceId: rule.workspaceId,
language: rule.language,
})
}

Expand Down Expand Up @@ -227,6 +235,18 @@ function RuleModal({
onChange={(entityTypes) => onChange({ ...draft, entityTypes })}
/>
</ChipModalField>
<ChipModalField
type='custom'
title='Language'
hint='Detection runs with this language’s recognizers — match it to your log content.'
>
<ChipSelect
value={draft.language}
onChange={(language) => onChange({ ...draft, language: language as PIILanguage })}
options={PII_LANGUAGES.map((l) => ({ value: l.value, label: l.label }))}
align='start'
/>
</ChipModalField>
</ChipModalBody>
<ChipModalFooter
onCancel={onClose}
Expand Down Expand Up @@ -291,6 +311,7 @@ export function DataRetentionSettings() {
id: r.id,
entityTypes: r.entityTypes,
workspaceId: r.workspaceId,
language: r.language ?? DEFAULT_PII_LANGUAGE,
}))
)
hydratedOrgRef.current = orgId
Expand Down Expand Up @@ -327,6 +348,7 @@ export function DataRetentionSettings() {
id: r.id,
entityTypes: r.entityTypes,
workspaceId: r.workspaceId,
language: r.language,
})),
},
},
Expand All @@ -335,7 +357,12 @@ export function DataRetentionSettings() {
}

function openEditDefault() {
const rule: RuleDraft = defaultRule ?? { id: generateId(), entityTypes: [], workspaceId: null }
const rule: RuleDraft = defaultRule ?? {
id: generateId(),
entityTypes: [],
workspaceId: null,
language: DEFAULT_PII_LANGUAGE,
}
setModalIsNew(defaultRule === null)
setModalOriginal(rule)
setModalDraft({ ...rule })
Expand All @@ -344,7 +371,12 @@ export function DataRetentionSettings() {
function openAddOverride() {
const workspaceId = freeWorkspaces[0]?.value
if (!workspaceId) return
const blank: RuleDraft = { id: generateId(), entityTypes: [], workspaceId }
const blank: RuleDraft = {
id: generateId(),
entityTypes: [],
workspaceId,
language: DEFAULT_PII_LANGUAGE,
}
setModalIsNew(true)
setModalOriginal(blank)
setModalDraft(blank)
Expand Down
28 changes: 28 additions & 0 deletions apps/sim/lib/api/contracts/hotspots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,34 @@ export const guardrailsValidateContract = defineRouteContract({
},
})

const guardrailsMaskBatchBodySchema = z.object({
texts: z.array(z.string()).max(100_000),
entityTypes: z.array(z.string().min(1, 'Entity type cannot be empty')).max(200),
language: z.string().min(1).max(20).optional(),
})

const guardrailsMaskBatchResponseSchema = z.object({
masked: z.array(z.string()),
})

/**
* Internal batch PII masking. Called server-to-server (internal JWT) from the
* log-redaction persist path so Presidio always runs in the app container,
* including for async executions that persist inside the trigger.dev runtime.
*/
export const guardrailsMaskBatchContract = defineRouteContract({
method: 'POST',
path: '/api/guardrails/mask-batch',
body: guardrailsMaskBatchBodySchema,
response: {
mode: 'json',
schema: guardrailsMaskBatchResponseSchema,
},
})

export type GuardrailsMaskBatchBody = z.input<typeof guardrailsMaskBatchBodySchema>
export type GuardrailsMaskBatchResult = z.output<typeof guardrailsMaskBatchResponseSchema>

const chatMessageSchema = z.object({
role: z.enum(['user', 'assistant', 'system']),
content: z.string(),
Expand Down
3 changes: 3 additions & 0 deletions apps/sim/lib/api/contracts/primitives.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { PII_LANGUAGE_CODES } from '@/lib/guardrails/pii-entities'

export const unknownRecordSchema = z.record(z.string(), z.unknown())

Expand Down Expand Up @@ -93,6 +94,8 @@ export const piiRedactionRuleSchema = z.object({
entityTypes: z.array(z.string().min(1, 'Entity type cannot be empty')).max(100),
/** null = all workspaces; otherwise the single targeted workspace. */
workspaceId: z.string().min(1).nullable(),
/** Language whose Presidio recognizers apply; defaults to English. */
language: z.enum(PII_LANGUAGE_CODES).optional(),
})

export type PiiRedactionRule = z.output<typeof piiRedactionRuleSchema>
Expand Down
Loading
Loading