chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@sim/audit",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"bun": ">=1.2.13",
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "biome check --write --unsafe .",
|
||||
"lint:check": "biome check .",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome format .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sim/db": "workspace:*",
|
||||
"@sim/logger": "workspace:*",
|
||||
"@sim/utils": "workspace:*",
|
||||
"drizzle-orm": "^0.45.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sim/testing": "workspace:*",
|
||||
"@sim/tsconfig": "workspace:*",
|
||||
"@types/node": "24.2.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { recordAudit } from './log'
|
||||
export type { AuditActionType, AuditResourceTypeValue } from './types'
|
||||
export { AuditAction, AuditResourceType } from './types'
|
||||
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
auditMock,
|
||||
dbChainMock,
|
||||
dbChainMockFns,
|
||||
requestUtilsMockFns,
|
||||
resetDbChainMock,
|
||||
} from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
...dbChainMock,
|
||||
auditLog: { id: 'id', workspaceId: 'workspace_id' },
|
||||
user: { id: 'id', name: 'name', email: 'email' },
|
||||
}))
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
or: vi.fn(),
|
||||
sql: vi.fn(),
|
||||
}))
|
||||
vi.mock('@sim/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@sim/utils/id', () => ({
|
||||
generateId: () => 'test-uuid-123',
|
||||
generateShortId: () => 'test-id-123',
|
||||
isValidUuid: (v: string) =>
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v),
|
||||
}))
|
||||
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from './index'
|
||||
|
||||
const flush = () => sleep(10)
|
||||
|
||||
describe('AuditAction', () => {
|
||||
it('contains all expected action categories', () => {
|
||||
expect(AuditAction.WORKFLOW_CREATED).toBe('workflow.created')
|
||||
expect(AuditAction.MEMBER_INVITED).toBe('member.invited')
|
||||
expect(AuditAction.API_KEY_CREATED).toBe('api_key.created')
|
||||
expect(AuditAction.ORGANIZATION_CREATED).toBe('organization.created')
|
||||
})
|
||||
|
||||
it('has unique values for every key', () => {
|
||||
const values = Object.values(AuditAction)
|
||||
const unique = new Set(values)
|
||||
expect(unique.size).toBe(values.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AuditResourceType', () => {
|
||||
it('contains all expected resource types', () => {
|
||||
expect(AuditResourceType.WORKFLOW).toBe('workflow')
|
||||
expect(AuditResourceType.WORKSPACE).toBe('workspace')
|
||||
expect(AuditResourceType.API_KEY).toBe('api_key')
|
||||
expect(AuditResourceType.MCP_SERVER).toBe('mcp_server')
|
||||
})
|
||||
|
||||
it('has unique values for every key', () => {
|
||||
const values = Object.values(AuditResourceType)
|
||||
const unique = new Set(values)
|
||||
expect(unique.size).toBe(values.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('recordAudit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
requestUtilsMockFns.mockGetClientIp.mockImplementation(
|
||||
(request: { headers: { get(name: string): string | null } }) =>
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
request.headers.get('x-real-ip')?.trim() ||
|
||||
'unknown'
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('inserts an audit log entry with all required fields', async () => {
|
||||
recordAudit({
|
||||
workspaceId: 'ws-1',
|
||||
actorId: 'user-1',
|
||||
actorName: 'Test User',
|
||||
actorEmail: 'test@example.com',
|
||||
action: AuditAction.WORKFLOW_CREATED,
|
||||
resourceType: AuditResourceType.WORKFLOW,
|
||||
resourceId: 'wf-1',
|
||||
})
|
||||
|
||||
await flush()
|
||||
|
||||
expect(dbChainMockFns.insert).toHaveBeenCalledTimes(1)
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'test-id-123',
|
||||
workspaceId: 'ws-1',
|
||||
actorId: 'user-1',
|
||||
action: 'workflow.created',
|
||||
resourceType: 'workflow',
|
||||
resourceId: 'wf-1',
|
||||
metadata: {},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('includes optional denormalized fields when provided', async () => {
|
||||
recordAudit({
|
||||
workspaceId: 'ws-1',
|
||||
actorId: 'user-1',
|
||||
action: AuditAction.FOLDER_CREATED,
|
||||
resourceType: AuditResourceType.FOLDER,
|
||||
resourceId: 'folder-1',
|
||||
actorName: 'Waleed',
|
||||
actorEmail: 'waleed@example.com',
|
||||
resourceName: 'My Folder',
|
||||
description: 'Created folder "My Folder"',
|
||||
})
|
||||
|
||||
await flush()
|
||||
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actorName: 'Waleed',
|
||||
actorEmail: 'waleed@example.com',
|
||||
resourceName: 'My Folder',
|
||||
description: 'Created folder "My Folder"',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('extracts IP address from x-forwarded-for header', async () => {
|
||||
const request = new Request('https://example.com', {
|
||||
headers: {
|
||||
'x-forwarded-for': '1.2.3.4, 5.6.7.8',
|
||||
'user-agent': 'TestAgent/1.0',
|
||||
},
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId: 'ws-1',
|
||||
actorId: 'user-1',
|
||||
actorName: 'Test',
|
||||
actorEmail: 'test@test.com',
|
||||
action: AuditAction.MEMBER_INVITED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
request,
|
||||
})
|
||||
|
||||
await flush()
|
||||
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ipAddress: '1.2.3.4',
|
||||
userAgent: 'TestAgent/1.0',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to x-real-ip when x-forwarded-for is absent', async () => {
|
||||
const request = new Request('https://example.com', {
|
||||
headers: { 'x-real-ip': '10.0.0.1' },
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId: 'ws-1',
|
||||
actorId: 'user-1',
|
||||
actorName: 'Test',
|
||||
actorEmail: 'test@test.com',
|
||||
action: AuditAction.API_KEY_CREATED,
|
||||
resourceType: AuditResourceType.API_KEY,
|
||||
request,
|
||||
})
|
||||
|
||||
await flush()
|
||||
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ipAddress: '10.0.0.1',
|
||||
userAgent: undefined,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults metadata to empty object when not provided', async () => {
|
||||
recordAudit({
|
||||
workspaceId: 'ws-1',
|
||||
actorId: 'user-1',
|
||||
actorName: 'Test',
|
||||
actorEmail: 'test@test.com',
|
||||
action: AuditAction.ENVIRONMENT_UPDATED,
|
||||
resourceType: AuditResourceType.ENVIRONMENT,
|
||||
})
|
||||
|
||||
await flush()
|
||||
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ metadata: {} }))
|
||||
})
|
||||
|
||||
it('passes through metadata when provided', async () => {
|
||||
recordAudit({
|
||||
workspaceId: 'ws-1',
|
||||
actorId: 'user-1',
|
||||
actorName: 'Test',
|
||||
actorEmail: 'test@test.com',
|
||||
action: AuditAction.WEBHOOK_CREATED,
|
||||
resourceType: AuditResourceType.WEBHOOK,
|
||||
metadata: { provider: 'github', workflowId: 'wf-1' },
|
||||
})
|
||||
|
||||
await flush()
|
||||
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: { provider: 'github', workflowId: 'wf-1' },
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not throw when the database insert fails', async () => {
|
||||
dbChainMockFns.values.mockImplementation(() => Promise.reject(new Error('DB connection lost')))
|
||||
|
||||
expect(() => {
|
||||
recordAudit({
|
||||
workspaceId: 'ws-1',
|
||||
actorId: 'user-1',
|
||||
actorName: 'Test',
|
||||
actorEmail: 'test@test.com',
|
||||
action: AuditAction.WORKFLOW_DELETED,
|
||||
resourceType: AuditResourceType.WORKFLOW,
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
await flush()
|
||||
})
|
||||
|
||||
it('does not block — returns void synchronously', () => {
|
||||
const result = recordAudit({
|
||||
workspaceId: 'ws-1',
|
||||
actorId: 'user-1',
|
||||
actorName: 'Test',
|
||||
actorEmail: 'test@test.com',
|
||||
action: AuditAction.CHAT_DEPLOYED,
|
||||
resourceType: AuditResourceType.CHAT,
|
||||
})
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('lazy actor resolution', () => {
|
||||
it('looks up user when actorName and actorEmail are both undefined', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValue([
|
||||
{ name: 'Resolved Name', email: 'resolved@example.com' },
|
||||
])
|
||||
|
||||
recordAudit({
|
||||
workspaceId: 'ws-1',
|
||||
actorId: 'user-1',
|
||||
action: AuditAction.DOCUMENT_UPLOADED,
|
||||
resourceType: AuditResourceType.DOCUMENT,
|
||||
resourceId: 'doc-1',
|
||||
})
|
||||
|
||||
await flush()
|
||||
|
||||
expect(dbChainMockFns.select).toHaveBeenCalledTimes(1)
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actorName: 'Resolved Name',
|
||||
actorEmail: 'resolved@example.com',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('skips lookup when actorName is provided (even if null)', async () => {
|
||||
recordAudit({
|
||||
workspaceId: 'ws-1',
|
||||
actorId: 'user-1',
|
||||
actorName: null,
|
||||
actorEmail: null,
|
||||
action: AuditAction.DOCUMENT_UPLOADED,
|
||||
resourceType: AuditResourceType.DOCUMENT,
|
||||
})
|
||||
|
||||
await flush()
|
||||
|
||||
expect(dbChainMockFns.select).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips lookup when actorName and actorEmail are provided', async () => {
|
||||
recordAudit({
|
||||
workspaceId: 'ws-1',
|
||||
actorId: 'user-1',
|
||||
actorName: 'Already Known',
|
||||
actorEmail: 'known@example.com',
|
||||
action: AuditAction.WORKFLOW_CREATED,
|
||||
resourceType: AuditResourceType.WORKFLOW,
|
||||
})
|
||||
|
||||
await flush()
|
||||
|
||||
expect(dbChainMockFns.select).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actorName: 'Already Known',
|
||||
actorEmail: 'known@example.com',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('nulls the actor FK when the lookup throws so the insert cannot FK-violate', async () => {
|
||||
dbChainMockFns.limit.mockRejectedValue(new Error('DB down'))
|
||||
|
||||
recordAudit({
|
||||
workspaceId: 'ws-1',
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.KNOWLEDGE_BASE_CREATED,
|
||||
resourceType: AuditResourceType.KNOWLEDGE_BASE,
|
||||
})
|
||||
|
||||
await flush()
|
||||
|
||||
expect(dbChainMockFns.select).toHaveBeenCalledTimes(1)
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actorId: null,
|
||||
actorName: 'Admin API',
|
||||
actorEmail: undefined,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('nulls the actor FK and labels it System when the user is not found', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValue([])
|
||||
|
||||
recordAudit({
|
||||
workspaceId: 'ws-1',
|
||||
actorId: 'deleted-user',
|
||||
action: AuditAction.WORKFLOW_DELETED,
|
||||
resourceType: AuditResourceType.WORKFLOW,
|
||||
})
|
||||
|
||||
await flush()
|
||||
|
||||
expect(dbChainMockFns.select).toHaveBeenCalledTimes(1)
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actorId: null,
|
||||
actorName: 'System',
|
||||
actorEmail: undefined,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('labels the admin-api system actor while nulling its FK', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValue([])
|
||||
|
||||
recordAudit({
|
||||
workspaceId: 'ws-1',
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.WORKFLOW_DELETED,
|
||||
resourceType: AuditResourceType.WORKFLOW,
|
||||
})
|
||||
|
||||
await flush()
|
||||
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actorId: null,
|
||||
actorName: 'Admin API',
|
||||
actorEmail: undefined,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('auditMock sync', () => {
|
||||
it('has the same AuditAction keys as the source', () => {
|
||||
const sourceKeys = Object.keys(AuditAction).sort()
|
||||
const mockKeys = Object.keys(auditMock.AuditAction).sort()
|
||||
expect(mockKeys).toEqual(sourceKeys)
|
||||
})
|
||||
|
||||
it('has the same AuditAction values as the source', () => {
|
||||
const mockActions = auditMock.AuditAction as Record<string, string>
|
||||
for (const key of Object.keys(AuditAction)) {
|
||||
expect(mockActions[key]).toBe(AuditAction[key as keyof typeof AuditAction])
|
||||
}
|
||||
})
|
||||
|
||||
it('has the same AuditResourceType keys as the source', () => {
|
||||
const sourceKeys = Object.keys(AuditResourceType).sort()
|
||||
const mockKeys = Object.keys(auditMock.AuditResourceType).sort()
|
||||
expect(mockKeys).toEqual(sourceKeys)
|
||||
})
|
||||
|
||||
it('has the same AuditResourceType values as the source', () => {
|
||||
const mockResourceTypes = auditMock.AuditResourceType as Record<string, string>
|
||||
for (const key of Object.keys(AuditResourceType)) {
|
||||
expect(mockResourceTypes[key]).toBe(AuditResourceType[key as keyof typeof AuditResourceType])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { auditLog, db, user } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateShortId } from '@sim/utils/id'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { AuditActionType, AuditResourceTypeValue } from './types'
|
||||
|
||||
const logger = createLogger('AuditLog')
|
||||
|
||||
interface AuditLogParams {
|
||||
workspaceId?: string | null
|
||||
/**
|
||||
* The acting user's id (FK to `user.id`). Pass `null` for genuinely
|
||||
* actor-less events such as anonymous public-share access — the row is then
|
||||
* persisted with a null actor and the forensic context (ip/user-agent,
|
||||
* metadata) carries the trail instead.
|
||||
*/
|
||||
actorId: string | null
|
||||
action: AuditActionType
|
||||
resourceType: AuditResourceTypeValue
|
||||
resourceId?: string
|
||||
actorName?: string | null
|
||||
actorEmail?: string | null
|
||||
resourceName?: string
|
||||
description?: string
|
||||
metadata?: Record<string, unknown>
|
||||
request?: { headers: { get(name: string): string | null } }
|
||||
}
|
||||
|
||||
function getClientIp(request: { headers: { get(name: string): string | null } }): string {
|
||||
return (
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
request.headers.get('x-real-ip')?.trim() ||
|
||||
'unknown'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget audit log write. Never throws; failures are logged.
|
||||
* Resolves actorName/actorEmail from the user table when both are omitted.
|
||||
*/
|
||||
export function recordAudit(params: AuditLogParams): void {
|
||||
insertAuditLog(params).catch((error) => {
|
||||
logger.error('Failed to record audit log', { error, action: params.action })
|
||||
})
|
||||
}
|
||||
|
||||
async function insertAuditLog(params: AuditLogParams): Promise<void> {
|
||||
const ipAddress = params.request ? getClientIp(params.request) : undefined
|
||||
const userAgent = params.request?.headers.get('user-agent') ?? undefined
|
||||
|
||||
let { actorName, actorEmail } = params
|
||||
|
||||
/**
|
||||
* `actorId` is a FK to `user.id`. System actors (e.g. the shared `'admin-api'`
|
||||
* key) have no user row, so we persist a null FK with a readable label instead
|
||||
* of letting the insert fail. When the caller already supplies actorName/Email
|
||||
* we trust the id is a real user and skip the lookup.
|
||||
*/
|
||||
let actorId: string | null = params.actorId
|
||||
|
||||
if (actorName === undefined && actorEmail === undefined && actorId) {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select({ name: user.name, email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.id, actorId))
|
||||
.limit(1)
|
||||
if (row) {
|
||||
actorName = row.name ?? undefined
|
||||
actorEmail = row.email ?? undefined
|
||||
} else {
|
||||
actorName = actorId === 'admin-api' ? 'Admin API' : 'System'
|
||||
actorId = null
|
||||
}
|
||||
} catch (error) {
|
||||
// Couldn't confirm the user exists — null the FK so the insert can't violate
|
||||
// it (system actor like 'admin-api', or a deleted user); the label remains.
|
||||
logger.warn('Failed to resolve actor info', { error, actorId })
|
||||
actorName = actorId === 'admin-api' ? 'Admin API' : 'System'
|
||||
actorId = null
|
||||
}
|
||||
}
|
||||
|
||||
await db.insert(auditLog).values({
|
||||
id: generateShortId(),
|
||||
workspaceId: params.workspaceId || null,
|
||||
actorId,
|
||||
action: params.action,
|
||||
resourceType: params.resourceType,
|
||||
resourceId: params.resourceId,
|
||||
actorName: actorName ?? undefined,
|
||||
actorEmail: actorEmail ?? undefined,
|
||||
resourceName: params.resourceName,
|
||||
description: params.description,
|
||||
metadata: params.metadata ?? {},
|
||||
ipAddress,
|
||||
userAgent,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* All auditable actions in the platform, grouped by resource type.
|
||||
*/
|
||||
export const AuditAction = {
|
||||
// API Keys
|
||||
API_KEY_CREATED: 'api_key.created',
|
||||
API_KEY_UPDATED: 'api_key.updated',
|
||||
API_KEY_REVOKED: 'api_key.revoked',
|
||||
PERSONAL_API_KEY_CREATED: 'personal_api_key.created',
|
||||
PERSONAL_API_KEY_REVOKED: 'personal_api_key.revoked',
|
||||
|
||||
// BYOK Keys
|
||||
BYOK_KEY_CREATED: 'byok_key.created',
|
||||
BYOK_KEY_UPDATED: 'byok_key.updated',
|
||||
BYOK_KEY_DELETED: 'byok_key.deleted',
|
||||
|
||||
// Chat
|
||||
CHAT_DEPLOYED: 'chat.deployed',
|
||||
CHAT_UPDATED: 'chat.updated',
|
||||
CHAT_DELETED: 'chat.deleted',
|
||||
|
||||
// Custom Blocks (deploy-as-block)
|
||||
CUSTOM_BLOCK_PUBLISHED: 'custom_block.published',
|
||||
CUSTOM_BLOCK_UPDATED: 'custom_block.updated',
|
||||
CUSTOM_BLOCK_DELETED: 'custom_block.deleted',
|
||||
|
||||
// Custom Tools
|
||||
CUSTOM_TOOL_CREATED: 'custom_tool.created',
|
||||
CUSTOM_TOOL_UPDATED: 'custom_tool.updated',
|
||||
CUSTOM_TOOL_DELETED: 'custom_tool.deleted',
|
||||
|
||||
// Data Drains
|
||||
DATA_DRAIN_CREATED: 'data_drain.created',
|
||||
DATA_DRAIN_UPDATED: 'data_drain.updated',
|
||||
DATA_DRAIN_DELETED: 'data_drain.deleted',
|
||||
DATA_DRAIN_RAN: 'data_drain.ran',
|
||||
DATA_DRAIN_TESTED: 'data_drain.tested',
|
||||
|
||||
// Billing
|
||||
CREDIT_PURCHASED: 'credit.purchased',
|
||||
CREDIT_ISSUED: 'credit.issued',
|
||||
INVOICE_PAYMENT_SUCCEEDED: 'invoice.payment_succeeded',
|
||||
INVOICE_PAYMENT_FAILED: 'invoice.payment_failed',
|
||||
OVERAGE_BILLED: 'billing.overage_billed',
|
||||
CHARGE_DISPUTE_OPENED: 'charge.dispute_opened',
|
||||
CHARGE_DISPUTE_CLOSED: 'charge.dispute_closed',
|
||||
|
||||
// Subscriptions
|
||||
SUBSCRIPTION_CREATED: 'subscription.created',
|
||||
SUBSCRIPTION_CANCELLED: 'subscription.cancelled',
|
||||
SUBSCRIPTION_TRANSFERRED: 'subscription.transferred',
|
||||
ENTERPRISE_SUBSCRIPTION_PROVISIONED: 'subscription.enterprise_provisioned',
|
||||
|
||||
// Connector Documents
|
||||
CONNECTOR_DOCUMENT_RESTORED: 'connector_document.restored',
|
||||
CONNECTOR_DOCUMENT_EXCLUDED: 'connector_document.excluded',
|
||||
|
||||
// Documents
|
||||
DOCUMENT_UPLOADED: 'document.uploaded',
|
||||
DOCUMENT_UPDATED: 'document.updated',
|
||||
DOCUMENT_DELETED: 'document.deleted',
|
||||
|
||||
// Environment
|
||||
ENVIRONMENT_UPDATED: 'environment.updated',
|
||||
ENVIRONMENT_DELETED: 'environment.deleted',
|
||||
|
||||
// Files
|
||||
FILE_UPLOADED: 'file.uploaded',
|
||||
FILE_UPDATED: 'file.updated',
|
||||
FILE_DELETED: 'file.deleted',
|
||||
FILE_RESTORED: 'file.restored',
|
||||
FILE_MOVED: 'file.moved',
|
||||
FILE_SHARED: 'file.shared',
|
||||
FILE_SHARE_DISABLED: 'file.share_disabled',
|
||||
FILE_DOWNLOADED: 'file.downloaded',
|
||||
|
||||
// Folders
|
||||
FOLDER_CREATED: 'folder.created',
|
||||
FOLDER_UPDATED: 'folder.updated',
|
||||
FOLDER_DELETED: 'folder.deleted',
|
||||
FOLDER_MOVED: 'folder.moved',
|
||||
FOLDER_DUPLICATED: 'folder.duplicated',
|
||||
FOLDER_RESTORED: 'folder.restored',
|
||||
|
||||
// Invitations
|
||||
INVITATION_ACCEPTED: 'invitation.accepted',
|
||||
INVITATION_REJECTED: 'invitation.rejected',
|
||||
INVITATION_RESENT: 'invitation.resent',
|
||||
INVITATION_REVOKED: 'invitation.revoked',
|
||||
INVITATION_UPDATED: 'invitation.updated',
|
||||
|
||||
// Knowledge Base Connectors
|
||||
CONNECTOR_CREATED: 'connector.created',
|
||||
CONNECTOR_UPDATED: 'connector.updated',
|
||||
CONNECTOR_DELETED: 'connector.deleted',
|
||||
CONNECTOR_SYNCED: 'connector.synced',
|
||||
|
||||
// Knowledge Bases
|
||||
KNOWLEDGE_BASE_CREATED: 'knowledge_base.created',
|
||||
KNOWLEDGE_BASE_UPDATED: 'knowledge_base.updated',
|
||||
KNOWLEDGE_BASE_DELETED: 'knowledge_base.deleted',
|
||||
KNOWLEDGE_BASE_RESTORED: 'knowledge_base.restored',
|
||||
|
||||
// MCP Servers
|
||||
MCP_SERVER_ADDED: 'mcp_server.added',
|
||||
MCP_SERVER_UPDATED: 'mcp_server.updated',
|
||||
MCP_SERVER_REMOVED: 'mcp_server.removed',
|
||||
|
||||
// Members
|
||||
MEMBER_INVITED: 'member.invited',
|
||||
MEMBER_ADDED: 'member.added',
|
||||
MEMBER_REMOVED: 'member.removed',
|
||||
MEMBER_ROLE_CHANGED: 'member.role_changed',
|
||||
|
||||
// OAuth / Credentials
|
||||
OAUTH_DISCONNECTED: 'oauth.disconnected',
|
||||
CREDENTIAL_CREATED: 'credential.created',
|
||||
CREDENTIAL_UPDATED: 'credential.updated',
|
||||
CREDENTIAL_RENAMED: 'credential.renamed',
|
||||
CREDENTIAL_RECONNECTED: 'credential.reconnected',
|
||||
CREDENTIAL_DELETED: 'credential.deleted',
|
||||
CREDENTIAL_ACCESSED: 'credential.accessed',
|
||||
CREDENTIAL_MEMBER_ADDED: 'credential_member.added',
|
||||
CREDENTIAL_MEMBER_REMOVED: 'credential_member.removed',
|
||||
CREDENTIAL_MEMBER_ROLE_CHANGED: 'credential_member.role_changed',
|
||||
|
||||
// Password
|
||||
PASSWORD_RESET_REQUESTED: 'password.reset_requested',
|
||||
PASSWORD_RESET: 'password.reset',
|
||||
|
||||
// Organizations
|
||||
ORGANIZATION_CREATED: 'organization.created',
|
||||
ORGANIZATION_UPDATED: 'organization.updated',
|
||||
ORG_MEMBER_ADDED: 'org_member.added',
|
||||
ORG_MEMBER_REMOVED: 'org_member.removed',
|
||||
ORG_MEMBER_ROLE_CHANGED: 'org_member.role_changed',
|
||||
ORG_MEMBER_USAGE_LIMIT_CHANGED: 'org_member.usage_limit_changed',
|
||||
ORG_INVITATION_CREATED: 'org_invitation.created',
|
||||
ORG_INVITATION_UPDATED: 'org_invitation.updated',
|
||||
ORG_INVITATION_ACCEPTED: 'org_invitation.accepted',
|
||||
ORG_INVITATION_REJECTED: 'org_invitation.rejected',
|
||||
ORG_INVITATION_CANCELLED: 'org_invitation.cancelled',
|
||||
ORG_INVITATION_REVOKED: 'org_invitation.revoked',
|
||||
ORG_INVITATION_RESENT: 'org_invitation.resent',
|
||||
ORG_SEAT_PROVISIONED: 'org_seat.provisioned',
|
||||
ORG_SEAT_DEPROVISIONED: 'org_seat.deprovisioned',
|
||||
ORG_PLAN_CONVERTED: 'org_plan.converted',
|
||||
|
||||
// Permission Groups
|
||||
PERMISSION_GROUP_CREATED: 'permission_group.created',
|
||||
PERMISSION_GROUP_UPDATED: 'permission_group.updated',
|
||||
PERMISSION_GROUP_DELETED: 'permission_group.deleted',
|
||||
PERMISSION_GROUP_MEMBER_ADDED: 'permission_group_member.added',
|
||||
PERMISSION_GROUP_MEMBER_REMOVED: 'permission_group_member.removed',
|
||||
|
||||
// Skills
|
||||
SKILL_CREATED: 'skill.created',
|
||||
SKILL_UPDATED: 'skill.updated',
|
||||
SKILL_DELETED: 'skill.deleted',
|
||||
|
||||
// Schedules
|
||||
SCHEDULE_CREATED: 'schedule.created',
|
||||
SCHEDULE_UPDATED: 'schedule.updated',
|
||||
SCHEDULE_DELETED: 'schedule.deleted',
|
||||
|
||||
// Tables
|
||||
TABLE_CREATED: 'table.created',
|
||||
TABLE_UPDATED: 'table.updated',
|
||||
TABLE_DELETED: 'table.deleted',
|
||||
TABLE_RESTORED: 'table.restored',
|
||||
TABLE_EXPORTED: 'table.exported',
|
||||
|
||||
// Webhooks
|
||||
WEBHOOK_CREATED: 'webhook.created',
|
||||
WEBHOOK_DELETED: 'webhook.deleted',
|
||||
|
||||
// Workflows
|
||||
WORKFLOW_CREATED: 'workflow.created',
|
||||
WORKFLOW_DELETED: 'workflow.deleted',
|
||||
WORKFLOW_RESTORED: 'workflow.restored',
|
||||
WORKFLOW_DEPLOYED: 'workflow.deployed',
|
||||
WORKFLOW_UNDEPLOYED: 'workflow.undeployed',
|
||||
WORKFLOW_DUPLICATED: 'workflow.duplicated',
|
||||
WORKFLOW_DEPLOYMENT_ACTIVATED: 'workflow.deployment_activated',
|
||||
WORKFLOW_DEPLOYMENT_REVERTED: 'workflow.deployment_reverted',
|
||||
WORKFLOW_LOCKED: 'workflow.locked',
|
||||
WORKFLOW_UNLOCKED: 'workflow.unlocked',
|
||||
WORKFLOW_VARIABLES_UPDATED: 'workflow.variables_updated',
|
||||
WORKFLOW_PUBLIC_API_TOGGLED: 'workflow.public_api_toggled',
|
||||
WORKFLOW_EXPORTED: 'workflow.exported',
|
||||
|
||||
// Workspaces
|
||||
WORKSPACE_CREATED: 'workspace.created',
|
||||
WORKSPACE_UPDATED: 'workspace.updated',
|
||||
WORKSPACE_DELETED: 'workspace.deleted',
|
||||
WORKSPACE_DUPLICATED: 'workspace.duplicated',
|
||||
WORKSPACE_FORKED: 'workspace.forked',
|
||||
WORKSPACE_FORK_PROMOTED: 'workspace.fork_promoted',
|
||||
WORKSPACE_FORK_ROLLED_BACK: 'workspace.fork_rolled_back',
|
||||
WORKSPACE_FORK_UNLINKED: 'workspace.fork_unlinked',
|
||||
WORKSPACE_EXPORTED: 'workspace.exported',
|
||||
} as const
|
||||
|
||||
export type AuditActionType = (typeof AuditAction)[keyof typeof AuditAction]
|
||||
|
||||
/**
|
||||
* All resource types that can appear in audit log entries.
|
||||
*/
|
||||
export const AuditResourceType = {
|
||||
API_KEY: 'api_key',
|
||||
BILLING: 'billing',
|
||||
BYOK_KEY: 'byok_key',
|
||||
CHAT: 'chat',
|
||||
CONNECTOR: 'connector',
|
||||
CREDENTIAL: 'credential',
|
||||
CUSTOM_BLOCK: 'custom_block',
|
||||
CUSTOM_TOOL: 'custom_tool',
|
||||
DATA_DRAIN: 'data_drain',
|
||||
DOCUMENT: 'document',
|
||||
ENVIRONMENT: 'environment',
|
||||
FILE: 'file',
|
||||
FOLDER: 'folder',
|
||||
KNOWLEDGE_BASE: 'knowledge_base',
|
||||
MCP_SERVER: 'mcp_server',
|
||||
OAUTH: 'oauth',
|
||||
ORGANIZATION: 'organization',
|
||||
PASSWORD: 'password',
|
||||
PERMISSION_GROUP: 'permission_group',
|
||||
SCHEDULE: 'schedule',
|
||||
SKILL: 'skill',
|
||||
SUBSCRIPTION: 'subscription',
|
||||
TABLE: 'table',
|
||||
WEBHOOK: 'webhook',
|
||||
WORKFLOW: 'workflow',
|
||||
WORKSPACE: 'workspace',
|
||||
} as const
|
||||
|
||||
export type AuditResourceTypeValue = (typeof AuditResourceType)[keyof typeof AuditResourceType]
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "@sim/tsconfig/library.json",
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user