Make Audit logs write to and read from the DB (not vaporware) (#19)

* Add database schema for audit logs

* update audit log code to read from the DB rather than mocks

* use UUID not text for id

* backend endpoint for writing audit logs

* add stubs for provider whitelist update

* wip on sending audit logs

* remove oldValue field

* remove references to oldValue

* more

* change toast

* move querying to the backend

* fix fetch

* base URL

* use a server-side function rather than endpoint for audit log creation

* remove old_value

* remove route for querying audit logs

* don't take over date parsing

* simplify, review feedback
This commit is contained in:
Canyon Robins 2025-05-16 14:56:44 -07:00 committed by GitHub
parent 502425f739
commit f228542f27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 819 additions and 288 deletions

View file

@ -1,7 +1,7 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
out: './migrations',
out: './src/db/migrations',
schema: './src/db/schema.ts',
dialect: 'postgresql',
dbCredentials: { url: process.env.DATABASE_URL! },

64
src/actions/auditLogs.ts Normal file
View file

@ -0,0 +1,64 @@
'use server';
import { eq, gte, and, desc } from 'drizzle-orm';
import { db } from '@/db';
import { auditLogs } from '@/db/schema';
import { logger } from '@/lib/server/logger';
import { AuditLogType } from '@/types/auditLogs';
/**
* getAuditLogs
*
* Server action to retrieve audit logs for an organization
*/
export const getAuditLogs = async ({
orgId,
limit = 100,
nRecentDays,
}: {
orgId?: string | null;
limit?: number;
nRecentDays?: number;
}): Promise<AuditLogType[]> => {
if (!orgId) {
return [];
}
try {
if (isNaN(limit) || limit <= 0) {
throw new Error('Limit must be a positive number');
}
if (nRecentDays === undefined) {
return await db
.select()
.from(auditLogs)
.where(eq(auditLogs.organizationId, orgId))
.orderBy(desc(auditLogs.createdAt))
.limit(limit);
} else {
if (isNaN(nRecentDays) || nRecentDays <= 0) {
throw new Error('nRecentDays must be a positive number');
}
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - nRecentDays);
return await db
.select()
.from(auditLogs)
.where(
and(
eq(auditLogs.organizationId, orgId),
gte(auditLogs.createdAt, cutoff),
),
)
.orderBy(desc(auditLogs.createdAt))
.limit(limit);
}
} catch (error) {
logger.error({
event: 'audit_log_fetch_error',
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
};

View file

@ -1,6 +1,8 @@
'use client';
import { useState } from 'react';
import { useOrganization } from '@clerk/nextjs';
import { useQuery } from '@tanstack/react-query';
import {
Button,
@ -12,18 +14,28 @@ import {
import { TitleBar } from '@/components/dashboard/TitleBar';
import { AuditLogDetails } from '@/components/dashboard/AuditLogDetails';
import { AuditLogEntry } from '@/components/dashboard/AuditLogEntry';
import type { AuditLog } from '@/components/dashboard/mockAuditLogs';
import { getFilteredLogs } from '@/components/dashboard/mockAuditLogs';
import type { AuditLogType } from '@/types/auditLogs';
import { getAuditLogs } from '@/actions/auditLogs';
type TimePeriod = '7' | '30' | '90';
const AuditLogsPage = () => {
const [timePeriod, setTimePeriod] = useState<TimePeriod>('7');
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
const [selectedLog, setSelectedLog] = useState<AuditLogType | null>(null);
const { organization } = useOrganization();
const logs = getFilteredLogs(Number(timePeriod));
const { data: logs = [], isLoading } = useQuery({
queryKey: ['auditLogs', organization?.id, Number(timePeriod)],
queryFn: async () =>
await getAuditLogs({
orgId: organization?.id,
limit: 20,
nRecentDays: Number(timePeriod),
}),
enabled: !!organization?.id,
});
const handleLogClick = (log: AuditLog) => setSelectedLog(log);
const handleLogClick = (log: AuditLogType) => setSelectedLog(log);
const handleCloseDrawer = () => setSelectedLog(null);
return (
@ -63,8 +75,12 @@ const AuditLogsPage = () => {
</Button>
</div>
<div className="space-y-1">
{logs.length > 0 ? (
logs.map((log: AuditLog) => (
{isLoading ? (
<div className="py-8 text-center">
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
) : logs.length > 0 ? (
logs.map((log: AuditLogType) => (
<AuditLogEntry key={log.id} log={log} onClick={handleLogClick} />
))
) : (

View file

@ -0,0 +1,92 @@
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { AuditLogTargetType } from '@/types/auditLogs';
import { logger } from '@/lib/server/logger';
import { createAuditLog } from '@/lib/server/auditLogs';
// Allow all providers update schema
const allowAllProvidersSchema = z.object({
allowAllProviders: z.boolean(),
policyVersion: z.number().int().positive(),
});
export type AllowAllProvidersRequest = z.infer<typeof allowAllProvidersSchema>;
export type AllowAllProvidersResponse = {
success: boolean;
message?: string;
error?: string;
};
// Note: I didn't really put any thought into the API as this is just stubs for the audit logs to work.
// Consider merging this with the other provider-whitelist endpoints.
// Please refactor!
export async function POST(
request: NextRequest,
): Promise<NextResponse<AllowAllProvidersResponse>> {
try {
const { userId, orgId } = await auth();
if (!userId) {
return NextResponse.json(
{ success: false, error: 'Unauthorized: User required' },
{ status: 401 },
);
}
if (!orgId) {
return NextResponse.json(
{ success: false, error: 'Unauthorized: Organization required' },
{ status: 401 },
);
}
const body = await request.json();
const result = allowAllProvidersSchema.safeParse(body);
if (!result.success) {
logger.warn({
event: 'provider_whitelist_allow_all_providers_validation_failed',
errors: result.error.format(),
});
return NextResponse.json(
{ success: false, error: 'Invalid request data' },
{ status: 400 },
);
}
const data = result.data;
// TODO: persist data to the database
const auditLogData = {
userId,
organizationId: orgId,
targetType: AuditLogTargetType.PROVIDER_WHITELIST,
targetId: 'allow-all-providers',
newValue: { allowAllProviders: data.allowAllProviders },
description: `${data.allowAllProviders ? 'Enabled' : 'Disabled'} all providers`,
};
await createAuditLog(auditLogData);
return NextResponse.json({
success: true,
message: 'Allow all providers setting updated successfully',
});
} catch (error) {
logger.error({
event: 'provider_whitelist_allow_all_providers_update_error',
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json(
{
success: false,
error:
error instanceof Error
? error.message
: 'An unexpected error occurred',
},
{ status: 500 },
);
}
}

View file

@ -0,0 +1,95 @@
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { AuditLogTargetType } from '@/types/auditLogs';
import { logger } from '@/lib/server/logger';
import { createAuditLog } from '@/lib/server/auditLogs';
// Model toggle update schema
const modelToggleSchema = z.object({
providerId: z.string().min(1, 'Provider ID is required'),
modelId: z.string().min(1, 'Model ID is required'),
enabled: z.boolean(),
policyVersion: z.number().int().positive(),
});
export type ModelToggleRequest = z.infer<typeof modelToggleSchema>;
export type ModelToggleResponse = {
success: boolean;
message?: string;
error?: string;
};
// Note: I didn't really put any thought into the API as this is just stubs for the audit logs to work.
// Consider merging this with the other provider-whitelist endpoints.
// Please refactor!
export async function POST(
request: NextRequest,
): Promise<NextResponse<ModelToggleResponse>> {
try {
const { userId, orgId } = await auth();
if (!userId) {
return NextResponse.json(
{ success: false, error: 'Unauthorized: User required' },
{ status: 401 },
);
}
if (!orgId) {
return NextResponse.json(
{ success: false, error: 'Unauthorized: Organization required' },
{ status: 401 },
);
}
const body = await request.json();
const result = modelToggleSchema.safeParse(body);
if (!result.success) {
logger.warn({
event: 'model_toggle_validation_failed',
errors: result.error.format(),
});
return NextResponse.json(
{ success: false, error: 'Invalid request data' },
{ status: 400 },
);
}
const data = result.data;
// TODO: persist data to the database
const auditLogData = {
userId,
organizationId: orgId,
targetType: AuditLogTargetType.PROVIDER_WHITELIST,
targetId: `${data.providerId}:${data.modelId}`,
newValue: { enabled: data.enabled },
description: `${data.enabled ? 'Enabled' : 'Disabled'} model ${data.modelId} for provider ${data.providerId}`,
};
await createAuditLog(auditLogData);
// Return success response
return NextResponse.json({
success: true,
message: 'Model status updated successfully',
});
} catch (error) {
logger.error({
event: 'model_toggle_update_error',
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json(
{
success: false,
error:
error instanceof Error
? error.message
: 'An unexpected error occurred',
},
{ status: 500 },
);
}
}

View file

@ -0,0 +1,93 @@
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { AuditLogTargetType } from '@/types/auditLogs';
import { logger } from '@/lib/server/logger';
import { createAuditLog } from '@/lib/server/auditLogs';
// Provider toggle update schema
const providerToggleSchema = z.object({
providerId: z.string().min(1, 'Provider ID is required'),
enabled: z.boolean(),
policyVersion: z.number().int().positive(),
});
export type ProviderToggleRequest = z.infer<typeof providerToggleSchema>;
export type ProviderToggleResponse = {
success: boolean;
message?: string;
error?: string;
};
// Note: I didn't really put any thought into the API as this is just stubs for the audit logs to work.
// Consider merging this with the other provider-whitelist endpoints.
// Please refactor!
export async function POST(
request: NextRequest,
): Promise<NextResponse<ProviderToggleResponse>> {
try {
const { userId, orgId } = await auth();
if (!userId) {
return NextResponse.json(
{ success: false, error: 'Unauthorized: User required' },
{ status: 401 },
);
}
if (!orgId) {
return NextResponse.json(
{ success: false, error: 'Unauthorized: Organization required' },
{ status: 401 },
);
}
const body = await request.json();
const result = providerToggleSchema.safeParse(body);
if (!result.success) {
logger.warn({
event: 'provider_toggle_validation_failed',
errors: result.error.format(),
});
return NextResponse.json(
{ success: false, error: 'Invalid request data' },
{ status: 400 },
);
}
const data = result.data;
// TODO: persist data to the database
const auditLogData = {
userId,
organizationId: orgId,
targetType: AuditLogTargetType.PROVIDER_WHITELIST,
targetId: data.providerId,
newValue: { enabled: data.enabled },
description: `${data.enabled ? 'Enabled' : 'Disabled'} provider ${data.providerId}`,
};
await createAuditLog(auditLogData);
return NextResponse.json({
success: true,
message: 'Provider status updated successfully',
});
} catch (error) {
logger.error({
event: 'provider_toggle_update_error',
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json(
{
success: false,
error:
error instanceof Error
? error.message
: 'An unexpected error occurred',
},
{ status: 500 },
);
}
}

View file

@ -2,6 +2,8 @@
import Link from 'next/link';
import React, { useState } from 'react';
import { useOrganization } from '@clerk/nextjs';
import { useQuery } from '@tanstack/react-query';
import {
Drawer,
@ -12,14 +14,21 @@ import {
import { AuditLogDetails } from './AuditLogDetails';
import { AuditLogEntry } from './AuditLogEntry';
import type { AuditLog } from './mockAuditLogs';
import { mockAuditLogs } from './mockAuditLogs';
import type { AuditLogType } from '@/types/auditLogs';
import { getAuditLogs } from '@/actions/auditLogs';
export function AuditLogCard() {
const logs = mockAuditLogs.slice(0, 5);
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
const [selectedLog, setSelectedLog] = useState<AuditLogType | null>(null);
const { organization } = useOrganization();
const handleLogClick = (log: AuditLog) => setSelectedLog(log);
const { data: logs = [], isLoading } = useQuery({
queryKey: ['auditLogs', organization?.id, 5],
queryFn: async () =>
await getAuditLogs({ orgId: organization?.id, limit: 5 }),
enabled: !!organization?.id,
});
const handleLogClick = (log: AuditLogType) => setSelectedLog(log);
const handleCloseDrawer = () => setSelectedLog(null);
return (
@ -33,8 +42,12 @@ export function AuditLogCard() {
{/* Log entries */}
<div className="space-y-1">
{logs.length > 0 ? (
logs.map((log: AuditLog) => (
{isLoading ? (
<div className="py-8 text-center">
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
) : logs.length > 0 ? (
logs.map((log: AuditLogType) => (
<AuditLogEntry key={log.id} log={log} onClick={handleLogClick} />
))
) : (

View file

@ -4,10 +4,10 @@ import { ArrowRight, Calendar, Clock, User } from 'lucide-react';
import Link from 'next/link';
import React from 'react';
import type { AuditLog } from './mockAuditLogs';
import type { AuditLogType } from '@/types/auditLogs';
type AuditLogDetailsProps = {
log: AuditLog;
log: AuditLogType;
};
const formatValue = (value: unknown): React.ReactNode => {
@ -48,19 +48,19 @@ export function AuditLogDetails({ log }: AuditLogDetailsProps) {
<div className="space-y-4">
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<Calendar className="size-4" />
<span>{log.timestamp.toLocaleDateString()}</span>
<span>{log.createdAt.toLocaleDateString()}</span>
<Clock className="ml-2 size-4" />
<span>{log.timestamp.toLocaleTimeString()}</span>
<span>{log.createdAt.toLocaleTimeString()}</span>
</div>
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<User className="size-4" />
<span>{log.user}</span>
<span>{log.userId}</span>
</div>
{log.path && (
{log.targetId && (
<Link
href={log.path}
href={log.targetId}
className="mt-2 inline-flex items-center text-sm font-medium text-primary hover:underline"
>
View in settings
@ -76,19 +76,10 @@ export function AuditLogDetails({ log }: AuditLogDetailsProps) {
<div className="space-y-4">
<div className="space-y-2">
<h4 className="text-xs font-medium uppercase text-muted-foreground">
Before
New Value
</h4>
<div className="rounded-md bg-muted p-3">
{formatValue(log.details.before)}
</div>
</div>
<div className="space-y-2">
<h4 className="text-xs font-medium uppercase text-muted-foreground">
After
</h4>
<div className="rounded-md bg-muted p-3">
{formatValue(log.details.after)}
{formatValue(log.newValue)}
</div>
</div>
</div>

View file

@ -5,21 +5,21 @@ import React from 'react';
import { cn } from '@/lib/utils';
import type { AuditLog, AuditLogType } from './mockAuditLogs';
import { getFormattedTime } from './mockAuditLogs';
import { getFormattedTime } from '../../lib/dateUtils';
import { type AuditLogType, AuditLogTargetType } from '@/types/auditLogs';
type AuditLogEntryProps = {
log: AuditLog;
onClick: (log: AuditLog) => void;
log: AuditLogType;
onClick: (log: AuditLogType) => void;
};
const getIconByType = (type: AuditLogType) => {
const getIconByType = (type: AuditLogTargetType) => {
switch (type) {
case 'provider_whitelist':
case AuditLogTargetType.PROVIDER_WHITELIST:
return <Settings className="size-4 text-purple-500" />;
case 'default_parameters':
case AuditLogTargetType.DEFAULT_PARAMETERS:
return <Sliders className="size-4 text-green-500" />;
case 'member_change':
case AuditLogTargetType.MEMBER_CHANGE:
return <Users className="size-4 text-amber-500" />;
default:
return <Settings className="size-4 text-gray-500" />;
@ -36,71 +36,13 @@ export function AuditLogEntry({ log, onClick }: AuditLogEntryProps) {
)}
>
<div className="flex items-start gap-3">
<div className="mt-0.5 shrink-0">{getIconByType(log.type)}</div>
<div className="mt-0.5 shrink-0">{getIconByType(log.targetType)}</div>
<div className="flex-1 space-y-1">
<p className="text-sm text-foreground">{log.description}</p>
{/* Summary of changes */}
<div className="mt-1 text-xs text-muted-foreground">
{log.type === 'provider_whitelist' && (
<span>
{(() => {
const before = log.details.before as string[];
const after = log.details.after as string[];
if (Array.isArray(before) && Array.isArray(after)) {
if (after.length > before.length) {
const added = after.filter((p) => !before.includes(p));
return `Added: ${added.join(', ')}`;
} else {
const removed = before.filter((p) => !after.includes(p));
return `Removed: ${removed.join(', ')}`;
}
}
return '';
})()}
</span>
)}
{log.type === 'default_parameters' && (
<span>
{(() => {
const before = log.details.before as Record<string, unknown>;
const after = log.details.after as Record<string, unknown>;
if (
before &&
after &&
typeof before === 'object' &&
typeof after === 'object'
) {
return Object.entries(after)
.filter(([key, val]) => before[key] !== val)
.map(([key, val]) => `${key}: ${before[key]}${val}`)
.join(', ');
}
return '';
})()}
</span>
)}
{log.type === 'member_change' && (
<span>
{(() => {
if (!log.details.before) {
return 'New member added';
}
if (!log.details.after) {
return 'Member removed';
}
return 'Role updated';
})()}
</span>
)}
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">{log.user}</p>
<p className="text-xs text-muted-foreground">{log.userId}</p>
<p className="text-xs text-muted-foreground">
{getFormattedTime(log.timestamp)}
{getFormattedTime(log.createdAt)}
</p>
</div>
</div>

View file

@ -4,6 +4,7 @@ import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { Badge, Checkbox, Label } from '@/components/ui';
import { toast } from 'sonner';
const initialProviders = [
{
@ -53,23 +54,134 @@ const ProviderWhitelistPage = () => {
const [providers, setProviders] = useState(initialProviders);
const [policyVersion] = useState(1);
const [allowAllProviders, setAllowAllProviders] = useState(true);
const [isUpdating, setIsUpdating] = useState(false);
// API functions to send provider whitelist data to backend
// Note: I'm not sure that it actually makes sense to have three separate endpoints here,
// and I didn't really put any thought into their API as they're just stubs for the audit logs to work.
// Please refactor!
const updateAllowAllProvidersAPI = async (allowAllProviders: boolean) => {
setIsUpdating(true);
try {
const response = await fetch(
'/api/provider-whitelist/allow-all-providers',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
allowAllProviders,
policyVersion,
}),
},
);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
console.log('Allow all providers setting updated successfully', {
allowAllProviders,
});
} catch (error) {
console.error('Failed to update global settings:', error);
toast.error(
'Failed to update allow all providers setting. Please try again.',
);
} finally {
setIsUpdating(false);
}
};
const updateProviderAPI = async (providerId: string, enabled: boolean) => {
setIsUpdating(true);
try {
const response = await fetch('/api/provider-whitelist/providers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
providerId,
enabled,
policyVersion,
}),
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
console.log('Provider status updated successfully', {
providerId,
enabled,
});
} catch (error) {
console.error('Failed to update provider status:', error);
toast.error('Failed to update provider status. Please try again.');
} finally {
setIsUpdating(false);
}
};
const updateModelAPI = async (
providerId: string,
modelId: string,
enabled: boolean,
) => {
setIsUpdating(true);
try {
const response = await fetch('/api/provider-whitelist/models', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
providerId,
modelId,
enabled,
policyVersion,
}),
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
console.log('Model status updated successfully', {
providerId,
modelId,
enabled,
});
} catch (error) {
console.error('Failed to update model status:', error);
toast.error('Failed to update model status. Please try again.');
} finally {
setIsUpdating(false);
}
};
const toggleAllowAllProviders = () => {
const newAllowAllProviders = !allowAllProviders;
setAllowAllProviders(newAllowAllProviders);
let updatedProviders = providers;
if (newAllowAllProviders) {
setProviders(
providers.map((provider) => ({
...provider,
// Enable all providers and their models
updatedProviders = providers.map((provider) => ({
...provider,
enabled: true,
models: provider.models.map((model) => ({
...model,
enabled: true,
models: provider.models.map((model) => ({
...model,
enabled: true,
})),
})),
);
}));
setProviders(updatedProviders);
}
// Call API to update backend with the changed allow all providers setting
updateAllowAllProvidersAPI(newAllowAllProviders);
};
const toggleProvider = (providerId: string) => {
@ -77,22 +189,31 @@ const ProviderWhitelistPage = () => {
return;
}
setProviders(
providers.map((provider) => {
if (provider.id === providerId) {
const newEnabled = !provider.enabled;
return {
...provider,
enabled: newEnabled,
models: provider.models.map((model) => ({
...model,
enabled: newEnabled ? model.enabled : false,
})),
};
}
return provider;
}),
);
// Find the provider to get its current state
const provider = providers.find((p) => p.id === providerId);
if (!provider) return;
const newEnabled = !provider.enabled;
const updatedProviders = providers.map((provider) => {
if (provider.id === providerId) {
return {
...provider,
enabled: newEnabled,
// If provider is disabled, disable all its models
models: provider.models.map((model) => ({
...model,
enabled: newEnabled ? model.enabled : false,
})),
};
}
return provider;
});
setProviders(updatedProviders);
// Call API to update backend with the changed provider status
updateProviderAPI(providerId, newEnabled);
};
const toggleModel = (providerId: string, modelId: string) => {
@ -100,22 +221,34 @@ const ProviderWhitelistPage = () => {
return;
}
setProviders(
providers.map((provider) => {
if (provider.id === providerId) {
return {
...provider,
models: provider.models.map((model) => {
if (model.id === modelId) {
return { ...model, enabled: !model.enabled };
}
return model;
}),
};
}
return provider;
}),
);
// Find the model to get its current state
const provider = providers.find((p) => p.id === providerId);
if (!provider) return;
const model = provider.models.find((m) => m.id === modelId);
if (!model) return;
const newEnabled = !model.enabled;
const updatedProviders = providers.map((provider) => {
if (provider.id === providerId) {
return {
...provider,
models: provider.models.map((model) => {
if (model.id === modelId) {
return { ...model, enabled: newEnabled };
}
return model;
}),
};
}
return provider;
});
setProviders(updatedProviders);
// Call API to update backend with the changed model status
updateModelAPI(providerId, modelId, newEnabled);
};
return (
@ -195,7 +328,9 @@ const ProviderWhitelistPage = () => {
{`Policy v${policyVersion}`}
</Badge>
<span className="text-xs text-muted-foreground">
Changes will be pushed to SSE stream within 30 seconds
{isUpdating
? 'Updating provider whitelist...'
: 'Changes will be pushed to SSE stream within 30 seconds'}
</span>
</div>
</div>

View file

@ -1,144 +0,0 @@
export type AuditLogType =
| 'provider_whitelist'
| 'default_parameters'
| 'member_change';
export type AuditLogDetails = {
before: unknown;
after: unknown;
};
export type AuditLog = {
id: string;
type: AuditLogType;
description: string;
timestamp: Date;
user: string;
details: AuditLogDetails;
path?: string;
};
export const mockAuditLogs: AuditLog[] = [
{
id: '1',
type: 'provider_whitelist',
description: 'Added OpenAI to provider whitelist',
timestamp: new Date(2025, 4, 10, 14, 30),
user: 'John Doe',
details: {
before: ['Anthropic', 'Cohere'],
after: ['Anthropic', 'Cohere', 'OpenAI'],
},
path: '/dashboard/organization-profile/provider-whitelist',
},
{
id: '2',
type: 'default_parameters',
description: 'Updated default temperature parameter',
timestamp: new Date(2025, 4, 9, 11, 15),
user: 'Jane Smith',
details: {
before: { temperature: 0.7 },
after: { temperature: 0.9 },
},
path: '/dashboard/organization-profile/default-parameters',
},
{
id: '3',
type: 'member_change',
description: 'Changed role for Alex Johnson from Member to Admin',
timestamp: new Date(2025, 4, 8, 9, 45),
user: 'Sarah Williams',
details: {
before: { role: 'Member' },
after: { role: 'Admin' },
},
path: '/dashboard/organization-profile/organization-members',
},
{
id: '4',
type: 'provider_whitelist',
description: 'Removed Claude from provider whitelist',
timestamp: new Date(2025, 4, 7, 16, 20),
user: 'Michael Brown',
details: {
before: ['OpenAI', 'Claude', 'Cohere'],
after: ['OpenAI', 'Cohere'],
},
path: '/dashboard/organization-profile/provider-whitelist',
},
{
id: '5',
type: 'default_parameters',
description: 'Updated max tokens parameter',
timestamp: new Date(2025, 4, 6, 13, 10),
user: 'Emily Davis',
details: {
before: { max_tokens: 1000 },
after: { max_tokens: 2000 },
},
path: '/dashboard/organization-profile/default-parameters',
},
{
id: '6',
type: 'member_change',
description: 'Added new member David Wilson',
timestamp: new Date(2025, 4, 5, 10, 30),
user: 'John Doe',
details: {
before: null,
after: {
name: 'David Wilson',
email: 'david@example.com',
role: 'Member',
},
},
path: '/dashboard/organization-profile/organization-members',
},
{
id: '7',
type: 'member_change',
description: 'Removed member Lisa Taylor',
timestamp: new Date(2025, 4, 4, 15, 45),
user: 'Sarah Williams',
details: {
before: {
name: 'Lisa Taylor',
email: 'lisa@example.com',
role: 'Member',
},
after: null,
},
path: '/dashboard/organization-profile/organization-members',
},
];
export const getFilteredLogs = (days: number): AuditLog[] => {
const now = new Date();
const cutoff = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
return mockAuditLogs.filter((log) => log.timestamp >= cutoff);
};
export const getFormattedTime = (date: Date): string => {
const now = new Date();
const diffInHours = Math.floor(
(now.getTime() - date.getTime()) / (1000 * 60 * 60),
);
if (diffInHours < 24) {
return diffInHours === 0
? 'Just now'
: diffInHours === 1
? '1 hour ago'
: `${diffInHours} hours ago`;
}
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 7) {
return diffInDays === 1 ? 'Yesterday' : `${diffInDays} days ago`;
}
return date.toLocaleDateString();
};

View file

@ -0,0 +1,10 @@
CREATE TABLE "audit_logs" (
"id" uuid PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(),
"user_id" text NOT NULL,
"organization_id" text NOT NULL,
"target_type" integer NOT NULL,
"target_id" text NOT NULL,
"new_value" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"description" text NOT NULL
);

View file

@ -0,0 +1,81 @@
{
"id": "6600a193-62ff-4475-9533-eb1dafc7339e",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.audit_logs": {
"name": "audit_logs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"target_type": {
"name": "target_type",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"target_id": {
"name": "target_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"new_value": {
"name": "new_value",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1747347845063,
"tag": "0000_daffy_psylocke",
"breakpoints": true
}
]
}

View file

@ -1 +1,22 @@
// TODO
import {
pgTable,
text,
timestamp,
integer,
jsonb,
uuid,
} from 'drizzle-orm/pg-core';
// AuditLogType
export const auditLogs = pgTable('audit_logs', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
organizationId: text('organization_id').notNull(),
targetType: integer('target_type').notNull(), // AuditLogTargetType
targetId: text('target_id').notNull(),
newValue: jsonb('new_value').notNull(),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
description: text('description').notNull(),
});

23
src/lib/dateUtils.ts Normal file
View file

@ -0,0 +1,23 @@
// Format time for display - this is a client-side utility function
export function getFormattedTime(date: Date): string {
const now = new Date();
const diffInHours = Math.floor(
(now.getTime() - date.getTime()) / (1000 * 60 * 60),
);
if (diffInHours < 24) {
return diffInHours === 0
? 'Just now'
: diffInHours === 1
? '1 hour ago'
: `${diffInHours} hours ago`;
}
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 7) {
return diffInDays === 1 ? 'Yesterday' : `${diffInDays} days ago`;
}
return date.toLocaleDateString();
}

View file

@ -0,0 +1,50 @@
import { db } from '@/db';
import { auditLogs } from '@/db/schema';
import { auditLogSchema } from '@/types/auditLogs';
import { logger } from '@/lib/server/logger';
import type { z } from 'zod';
export type AuditLogCreateRequest = z.infer<typeof auditLogSchema>;
/**
* Server-side function to create audit logs in the database
* @param request The audit log data to create
* @returns Object containing success status and created log ID
*/
export async function createAuditLog(request: AuditLogCreateRequest): Promise<{
success: boolean;
error?: string | Record<string, unknown>;
}> {
try {
await db.insert(auditLogs).values({
userId: request.userId,
organizationId: request.organizationId,
targetType: request.targetType,
targetId: request.targetId,
newValue: request.newValue,
description: request.description,
// id and createdAt will be set automatically by the database
});
logger.info({
event: 'audit_log_created',
userId: request.userId,
organizationId: request.organizationId,
targetType: request.targetType,
});
return {
success: true,
};
} catch (error) {
logger.error({
event: 'audit_log_creation_error',
error: error instanceof Error ? error.message : 'Unknown error',
});
return {
success: false,
error:
error instanceof Error ? error.message : 'An unexpected error occurred',
};
}
}

36
src/types/auditLogs.ts Normal file
View file

@ -0,0 +1,36 @@
import { z } from 'zod';
/**
* Client-safe type definitions for audit logs
* These types mirror the database schema but are safe to import in client components
*/
export enum AuditLogTargetType {
PROVIDER_WHITELIST = 1,
DEFAULT_PARAMETERS = 2,
MEMBER_CHANGE = 3, // TODO: Currently no logs of this type are collected
}
export interface AuditLogType {
id: string;
userId: string;
organizationId: string;
targetType: AuditLogTargetType;
targetId: string;
newValue: unknown; // JSONB in the database
createdAt: Date;
description: string;
}
// Validation schema for audit log creation
export const auditLogSchema = z.object({
userId: z.string().min(1, 'User ID is required'),
// You should only create audit logs for the authenticated organization
organizationId: z.string().min(1, 'Organization ID is required'),
targetType: z.nativeEnum(AuditLogTargetType, {
errorMap: () => ({ message: 'Target type must be a valid enum value' }),
}),
targetId: z.string().min(1, 'Target ID is required'),
newValue: z.any().refine((val) => val !== undefined, 'New value is required'),
description: z.string().min(1, 'Description is required'),
});