From c8c26beed239192ff7f965bb2ab75bc4f65237c1 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 4 Mar 2026 18:18:28 +0100 Subject: [PATCH] feat: granular file access audit logging (viewed/opened/downloaded) Replace single FILE_DOWNLOADED action with three granular actions: - FILE_VIEWED: inline preview loaded in the UI - FILE_OPENED: file opened in a new browser tab - FILE_DOWNLOADED: explicit download button clicked Add 'purpose' field to getDownloadUrl input (preview/open/download). All client callers updated to pass the appropriate purpose. Audit page updated with new filter options and color mappings. Co-Authored-By: Claude Opus 4.6 --- src/app/(admin)/admin/audit/page.tsx | 4 ++++ .../admin/projects/bulk-upload/page.tsx | 2 +- .../(applicant)/applicant/documents/page.tsx | 4 ++-- src/components/shared/file-viewer.tsx | 10 ++++----- .../shared/requirement-upload-slot.tsx | 6 +++--- src/server/routers/file.ts | 21 +++++++++++++++---- 6 files changed, 32 insertions(+), 15 deletions(-) diff --git a/src/app/(admin)/admin/audit/page.tsx b/src/app/(admin)/admin/audit/page.tsx index 8815db1..69290c4 100644 --- a/src/app/(admin)/admin/audit/page.tsx +++ b/src/app/(admin)/admin/audit/page.tsx @@ -77,6 +77,8 @@ const ACTION_TYPES = [ 'ROUND_ARCHIVED', 'UPLOAD_FILE', 'DELETE_FILE', + 'FILE_VIEWED', + 'FILE_OPENED', 'FILE_DOWNLOADED', 'BULK_CREATE', 'BULK_UPDATE_STATUS', @@ -171,6 +173,8 @@ const actionColors: Record { try { - const { url } = await utils.file.getDownloadUrl.fetch({ bucket, objectKey }) + const { url } = await utils.file.getDownloadUrl.fetch({ bucket, objectKey, purpose: 'open' as const }) window.open(url, '_blank') } catch { toast.error('Failed to open file. It may have been deleted from storage.') diff --git a/src/app/(applicant)/applicant/documents/page.tsx b/src/app/(applicant)/applicant/documents/page.tsx index eed3343..b1a7880 100644 --- a/src/app/(applicant)/applicant/documents/page.tsx +++ b/src/app/(applicant)/applicant/documents/page.tsx @@ -46,11 +46,11 @@ const fileTypeLabels: Record = { function FileActionButtons({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) { const { data: viewData } = trpc.file.getDownloadUrl.useQuery( - { bucket, objectKey, forDownload: false }, + { bucket, objectKey, forDownload: false, purpose: 'open' as const }, { staleTime: 10 * 60 * 1000 } ) const { data: dlData } = trpc.file.getDownloadUrl.useQuery( - { bucket, objectKey, forDownload: true, fileName }, + { bucket, objectKey, forDownload: true, fileName, purpose: 'download' as const }, { staleTime: 10 * 60 * 1000 } ) const viewUrl = typeof viewData === 'string' ? viewData : viewData?.url diff --git a/src/components/shared/file-viewer.tsx b/src/components/shared/file-viewer.tsx index d35ef42..9baa18f 100644 --- a/src/components/shared/file-viewer.tsx +++ b/src/components/shared/file-viewer.tsx @@ -240,7 +240,7 @@ function FileItem({ file }: { file: ProjectFile }) { const Icon = getFileIcon(file.fileType, file.mimeType) const { data: urlData, isLoading: isLoadingUrl } = trpc.file.getDownloadUrl.useQuery( - { bucket: file.bucket, objectKey: file.objectKey }, + { bucket: file.bucket, objectKey: file.objectKey, purpose: 'preview' as const }, { enabled: showPreview } ) @@ -440,7 +440,7 @@ function VersionDownloadButton({ bucket, objectKey }: { bucket: string; objectKe const [downloading, setDownloading] = useState(false) const { refetch } = trpc.file.getDownloadUrl.useQuery( - { bucket, objectKey }, + { bucket, objectKey, purpose: 'open' as const }, { enabled: false } ) @@ -537,7 +537,7 @@ function FileOpenButton({ file, className, label }: { file: ProjectFile; classNa const [loading, setLoading] = useState(false) const { refetch } = trpc.file.getDownloadUrl.useQuery( - { bucket: file.bucket, objectKey: file.objectKey }, + { bucket: file.bucket, objectKey: file.objectKey, purpose: 'open' as const }, { enabled: false } ) @@ -590,7 +590,7 @@ function FileDownloadButton({ file, className, label }: { file: ProjectFile; cla const [downloading, setDownloading] = useState(false) const { refetch } = trpc.file.getDownloadUrl.useQuery( - { bucket: file.bucket, objectKey: file.objectKey, forDownload: true, fileName: file.fileName }, + { bucket: file.bucket, objectKey: file.objectKey, forDownload: true, fileName: file.fileName, purpose: 'download' as const }, { enabled: false } ) @@ -746,7 +746,7 @@ function CompactFileItem({ file }: { file: ProjectFile }) { const Icon = getFileIcon(file.fileType, file.mimeType) const { refetch } = trpc.file.getDownloadUrl.useQuery( - { bucket: file.bucket, objectKey: file.objectKey }, + { bucket: file.bucket, objectKey: file.objectKey, purpose: 'open' as const }, { enabled: false } ) diff --git a/src/components/shared/requirement-upload-slot.tsx b/src/components/shared/requirement-upload-slot.tsx index cf3aec7..291f618 100644 --- a/src/components/shared/requirement-upload-slot.tsx +++ b/src/components/shared/requirement-upload-slot.tsx @@ -72,7 +72,7 @@ interface RequirementUploadSlotProps { function ViewFileButton({ bucket, objectKey }: { bucket: string; objectKey: string }) { const { data } = trpc.file.getDownloadUrl.useQuery( - { bucket, objectKey, forDownload: false }, + { bucket, objectKey, forDownload: false, purpose: 'open' as const }, { staleTime: 10 * 60 * 1000 } ) const href = typeof data === 'string' ? data : data?.url @@ -87,7 +87,7 @@ function ViewFileButton({ bucket, objectKey }: { bucket: string; objectKey: stri function DownloadFileButton({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) { const { data } = trpc.file.getDownloadUrl.useQuery( - { bucket, objectKey, forDownload: true, fileName }, + { bucket, objectKey, forDownload: true, fileName, purpose: 'download' as const }, { staleTime: 10 * 60 * 1000 } ) const href = typeof data === 'string' ? data : data?.url @@ -229,7 +229,7 @@ export function RequirementUploadSlot({ // Fetch preview URL only when preview is toggled on const { data: previewUrlData, isLoading: isLoadingPreview } = trpc.file.getDownloadUrl.useQuery( - { bucket: existingFile?.bucket || '', objectKey: existingFile?.objectKey || '', forDownload: false }, + { bucket: existingFile?.bucket || '', objectKey: existingFile?.objectKey || '', forDownload: false, purpose: 'preview' as const }, { enabled: showPreview && !!existingFile?.bucket && !!existingFile?.objectKey, staleTime: 10 * 60 * 1000 } ) const previewUrl = typeof previewUrlData === 'string' ? previewUrlData : previewUrlData?.url diff --git a/src/server/routers/file.ts b/src/server/routers/file.ts index 762ef73..3d39cfe 100644 --- a/src/server/routers/file.ts +++ b/src/server/routers/file.ts @@ -17,6 +17,8 @@ export const fileRouter = router({ objectKey: z.string(), forDownload: z.boolean().optional(), fileName: z.string().optional(), + /** Why the URL is being requested — drives audit log granularity. */ + purpose: z.enum(['preview', 'open', 'download']).optional(), }) ) .query(async ({ ctx, input }) => { @@ -124,14 +126,25 @@ export const fileRouter = router({ }) } - // Only log actual downloads, not preview/view URL requests - if (input.forDownload) { + // Log file access with granular action based on purpose + const purpose = input.purpose ?? (input.forDownload ? 'download' : undefined) + if (purpose) { + const actionMap = { + preview: 'FILE_VIEWED', + open: 'FILE_OPENED', + download: 'FILE_DOWNLOADED', + } as const await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, - action: 'FILE_DOWNLOADED', + action: actionMap[purpose], entityType: 'ProjectFile', - detailsJson: { bucket: input.bucket, objectKey: input.objectKey, fileName: input.fileName }, + detailsJson: { + bucket: input.bucket, + objectKey: input.objectKey, + fileName: input.fileName, + purpose, + }, ipAddress: ctx.ip, userAgent: ctx.userAgent, })