Skip to content

Commit 9df460f

Browse files
feat(logs): redact PII from workflow logs via configurable rules
Enterprise PII redaction for workflow execution logs, configured under Data Retention as org-scoped rules (each rule picks entity types + which workspaces it applies to). Reuses the guardrails Presidio engine in mask mode at the log-persist choke point, with a check-digit-validated VIN recognizer. Also adds per-workspace data-retention-hours overrides.
1 parent 63a3e6d commit 9df460f

20 files changed

Lines changed: 18089 additions & 103 deletions

File tree

apps/sim/app/api/organizations/[id]/data-retention/route.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,40 @@
11
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { db } from '@sim/db'
3+
import type { DataRetentionSettings } from '@sim/db/schema'
34
import { member, organization } from '@sim/db/schema'
45
import { createLogger } from '@sim/logger'
56
import { and, eq } from 'drizzle-orm'
67
import { type NextRequest, NextResponse } from 'next/server'
7-
import { updateOrganizationDataRetentionContract } from '@/lib/api/contracts/organization'
8+
import {
9+
type OrganizationRetentionValues,
10+
updateOrganizationDataRetentionContract,
11+
} from '@/lib/api/contracts/organization'
812
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
913
import { getSession } from '@/lib/auth'
10-
import {
11-
CLEANUP_CONFIG,
12-
type OrganizationRetentionSettings,
13-
} from '@/lib/billing/cleanup-dispatcher'
14+
import { CLEANUP_CONFIG } from '@/lib/billing/cleanup-dispatcher'
1415
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
1516
import { isBillingEnabled } from '@/lib/core/config/env-flags'
1617
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1718

1819
const logger = createLogger('DataRetentionAPI')
1920

20-
function enterpriseDefaults(): OrganizationRetentionSettings {
21+
function enterpriseDefaults(): OrganizationRetentionValues {
2122
return {
2223
logRetentionHours: CLEANUP_CONFIG['cleanup-logs'].defaults.enterprise,
2324
softDeleteRetentionHours: CLEANUP_CONFIG['cleanup-soft-deletes'].defaults.enterprise,
2425
taskCleanupHours: CLEANUP_CONFIG['cleanup-tasks'].defaults.enterprise,
26+
piiRedaction: null,
2527
}
2628
}
2729

2830
function normalizeConfigured(
29-
settings: Partial<OrganizationRetentionSettings> | null | undefined
30-
): OrganizationRetentionSettings {
31+
settings: DataRetentionSettings | null | undefined
32+
): OrganizationRetentionValues {
3133
return {
3234
logRetentionHours: settings?.logRetentionHours ?? null,
3335
softDeleteRetentionHours: settings?.softDeleteRetentionHours ?? null,
3436
taskCleanupHours: settings?.taskCleanupHours ?? null,
37+
piiRedaction: settings?.piiRedaction?.rules ? { rules: settings.piiRedaction.rules } : null,
3538
}
3639
}
3740

@@ -152,7 +155,7 @@ export const PUT = withRouteHandler(
152155
}
153156

154157
const current = normalizeConfigured(currentOrg.dataRetentionSettings)
155-
const merged: OrganizationRetentionSettings = { ...current }
158+
const merged: DataRetentionSettings = { ...current }
156159
if (body.logRetentionHours !== undefined) {
157160
merged.logRetentionHours = body.logRetentionHours
158161
}
@@ -162,6 +165,9 @@ export const PUT = withRouteHandler(
162165
if (body.taskCleanupHours !== undefined) {
163166
merged.taskCleanupHours = body.taskCleanupHours
164167
}
168+
if (body.piiRedaction !== undefined) {
169+
merged.piiRedaction = body.piiRedaction
170+
}
165171

166172
const [updated] = await db
167173
.update(organization)
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
2+
import { db } from '@sim/db'
3+
import { type DataRetentionSettings, organization, workspace } from '@sim/db/schema'
4+
import { eq } from 'drizzle-orm'
5+
import { type NextRequest, NextResponse } from 'next/server'
6+
import { updateWorkspaceDataRetentionContract } from '@/lib/api/contracts/workspaces'
7+
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
8+
import { getSession } from '@/lib/auth'
9+
import { CLEANUP_CONFIG } from '@/lib/billing/cleanup-dispatcher'
10+
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription'
11+
import { resolveEffectiveRetentionHours } from '@/lib/billing/retention'
12+
import { isBillingEnabled } from '@/lib/core/config/env-flags'
13+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
14+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
15+
16+
interface RetentionValues {
17+
logRetentionHours: number | null
18+
softDeleteRetentionHours: number | null
19+
taskCleanupHours: number | null
20+
}
21+
22+
function enterpriseDefaults(): RetentionValues {
23+
return {
24+
logRetentionHours: CLEANUP_CONFIG['cleanup-logs'].defaults.enterprise,
25+
softDeleteRetentionHours: CLEANUP_CONFIG['cleanup-soft-deletes'].defaults.enterprise,
26+
taskCleanupHours: CLEANUP_CONFIG['cleanup-tasks'].defaults.enterprise,
27+
}
28+
}
29+
30+
function normalize(settings: DataRetentionSettings | null | undefined): RetentionValues {
31+
return {
32+
logRetentionHours: settings?.logRetentionHours ?? null,
33+
softDeleteRetentionHours: settings?.softDeleteRetentionHours ?? null,
34+
taskCleanupHours: settings?.taskCleanupHours ?? null,
35+
}
36+
}
37+
38+
function resolveEffective(
39+
workspaceSettings: DataRetentionSettings | null,
40+
orgSettings: DataRetentionSettings | null
41+
): RetentionValues {
42+
return {
43+
logRetentionHours: resolveEffectiveRetentionHours({
44+
workspaceSettings,
45+
orgSettings,
46+
key: 'logRetentionHours',
47+
fallback: CLEANUP_CONFIG['cleanup-logs'].defaults.enterprise,
48+
}),
49+
softDeleteRetentionHours: resolveEffectiveRetentionHours({
50+
workspaceSettings,
51+
orgSettings,
52+
key: 'softDeleteRetentionHours',
53+
fallback: CLEANUP_CONFIG['cleanup-soft-deletes'].defaults.enterprise,
54+
}),
55+
taskCleanupHours: resolveEffectiveRetentionHours({
56+
workspaceSettings,
57+
orgSettings,
58+
key: 'taskCleanupHours',
59+
fallback: CLEANUP_CONFIG['cleanup-tasks'].defaults.enterprise,
60+
}),
61+
}
62+
}
63+
64+
async function loadWorkspaceSettings(workspaceId: string) {
65+
const [row] = await db
66+
.select({
67+
name: workspace.name,
68+
workspaceSettings: workspace.dataRetentionSettings,
69+
orgSettings: organization.dataRetentionSettings,
70+
})
71+
.from(workspace)
72+
.leftJoin(organization, eq(organization.id, workspace.organizationId))
73+
.where(eq(workspace.id, workspaceId))
74+
.limit(1)
75+
return row
76+
}
77+
78+
/**
79+
* GET /api/workspaces/[id]/data-retention
80+
* Returns the workspace's effective retention settings, the org default it
81+
* inherits from, and its own override. Accessible to any workspace member.
82+
*/
83+
export const GET = withRouteHandler(
84+
async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
85+
const session = await getSession()
86+
if (!session?.user?.id) {
87+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
88+
}
89+
90+
const { id: workspaceId } = await params
91+
92+
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
93+
if (!permission) {
94+
return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 })
95+
}
96+
97+
const row = await loadWorkspaceSettings(workspaceId)
98+
if (!row) {
99+
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
100+
}
101+
102+
const isEnterprise = !isBillingEnabled || (await isWorkspaceOnEnterprisePlan(workspaceId))
103+
const orgConfigured = normalize(row.orgSettings)
104+
const workspaceConfigured = normalize(row.workspaceSettings)
105+
const effective = isEnterprise
106+
? resolveEffective(row.workspaceSettings, row.orgSettings)
107+
: enterpriseDefaults()
108+
109+
return NextResponse.json({
110+
success: true,
111+
data: {
112+
isEnterprise,
113+
defaults: enterpriseDefaults(),
114+
orgConfigured,
115+
workspaceConfigured,
116+
effective,
117+
},
118+
})
119+
}
120+
)
121+
122+
/**
123+
* PUT /api/workspaces/[id]/data-retention
124+
* Updates the workspace's retention override. Requires workspace admin and an
125+
* enterprise plan. Omitted keys defer to the org default at resolution time.
126+
*/
127+
export const PUT = withRouteHandler(
128+
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
129+
const session = await getSession()
130+
if (!session?.user?.id) {
131+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
132+
}
133+
134+
const parsed = await parseRequest(updateWorkspaceDataRetentionContract, request, context, {
135+
validationErrorResponse: (err) => validationErrorResponse(err, 'Invalid request body'),
136+
})
137+
if (!parsed.success) return parsed.response
138+
139+
const workspaceId = parsed.data.params.id
140+
const body = parsed.data.body
141+
142+
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
143+
if (permission !== 'admin') {
144+
return NextResponse.json(
145+
{ error: 'Forbidden - Only workspace admins can update data retention' },
146+
{ status: 403 }
147+
)
148+
}
149+
150+
if (isBillingEnabled && !(await isWorkspaceOnEnterprisePlan(workspaceId))) {
151+
return NextResponse.json(
152+
{ error: 'Data Retention is available on Enterprise plans only' },
153+
{ status: 403 }
154+
)
155+
}
156+
157+
const row = await loadWorkspaceSettings(workspaceId)
158+
if (!row) {
159+
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
160+
}
161+
162+
const current = normalize(row.workspaceSettings)
163+
const merged: DataRetentionSettings = { ...current }
164+
if (body.logRetentionHours !== undefined) merged.logRetentionHours = body.logRetentionHours
165+
if (body.softDeleteRetentionHours !== undefined) {
166+
merged.softDeleteRetentionHours = body.softDeleteRetentionHours
167+
}
168+
if (body.taskCleanupHours !== undefined) merged.taskCleanupHours = body.taskCleanupHours
169+
170+
await db
171+
.update(workspace)
172+
.set({ dataRetentionSettings: merged, updatedAt: new Date() })
173+
.where(eq(workspace.id, workspaceId))
174+
175+
recordAudit({
176+
workspaceId,
177+
actorId: session.user.id,
178+
action: AuditAction.WORKSPACE_UPDATED,
179+
resourceType: AuditResourceType.WORKSPACE,
180+
resourceId: workspaceId,
181+
actorName: session.user.name ?? undefined,
182+
actorEmail: session.user.email ?? undefined,
183+
resourceName: row.name,
184+
description: 'Updated workspace data retention settings',
185+
metadata: { changes: body },
186+
request,
187+
})
188+
189+
return NextResponse.json({
190+
success: true,
191+
data: {
192+
isEnterprise: true,
193+
defaults: enterpriseDefaults(),
194+
orgConfigured: normalize(row.orgSettings),
195+
workspaceConfigured: normalize(merged),
196+
effective: resolveEffective(merged, row.orgSettings),
197+
},
198+
})
199+
}
200+
)

0 commit comments

Comments
 (0)