fix(documents): read attachments with service client so colleague uploads open (#1207)

The documents bucket SELECT policy only covers the uploader's own folder
(documents/{uid}/...), but document_attachments rows are company-scoped.
Every surface that touched storage with the user-bound client therefore
failed for attachments uploaded by another member of the same company
(colleague uploads, email-inbox ingest attributed to the company creator):

- GET /api/documents/:id 500ed with "Failed to create download URL", so
  viewing a bilaga on a verifikat or supplier invoice was broken for
  every member except the uploader (support case: Odin Aero, where all
  40 documents live in the owner's folder and the second member could
  open none of them).
- GET /api/documents/:id/integrity 500ed the same way.
- POST /api/documents/:id/verify failed the storage download.
- invoice-inbox retry-extraction could not download the attachment.
- cloud-backup user-triggered syncs silently dropped colleague-uploaded
  documents from the Drive archive (manifest rows flipped to 'error').

Fix: authorize on the user client (RLS + explicit company filter, plus
the membership check where present), then do the storage read with the
service-role client. This is the pattern the inline proxy route and the
v1 download route already use; these five call sites were left behind.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-26 12:34:11 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 6d9846b1e7
commit 968161b42b
11 changed files with 152 additions and 53 deletions
@@ -12,6 +12,14 @@ vi.mock('@/lib/reports/archive-readme', () => ({
buildDriveFolderReadme: vi.fn().mockReturnValue('README TEXT'),
}))
// performSync builds archives with a service-role client (the documents
// bucket SELECT policy is per-uploader-folder, so a user-bound client would
// drop colleague-uploaded files from the backup). The archive generators are
// mocked above, so a bare stub is enough here.
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn(() => ({ storage: { from: vi.fn() } })),
}))
vi.mock('@/lib/branding/service', () => ({
getBranding: () => ({ appName: 'Accounted', appUrl: 'https://app.test' }),
}))
+12 -2
View File
@@ -7,6 +7,7 @@ import {
} from '@/lib/reports/full-archive-export'
import { buildDriveFolderReadme } from '@/lib/reports/archive-readme'
import { getBranding } from '@/lib/branding/service'
import { createServiceClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
getOAuthEnv,
@@ -126,6 +127,15 @@ interface PeriodRow {
export async function performSync(params: PerformSyncParams): Promise<PerformSyncResult> {
const { supabase, companyId, userId, origin } = params
// Archive generation reads the private `documents` bucket, whose SELECT
// policy only covers the uploader's own folder. User-triggered syncs pass
// a user-bound client, which would silently drop every colleague-uploaded
// document from the backup (manifest rows flip to 'error'). The service
// client is used for archive generation only; authorization happened at
// the extension dispatcher (or the cron), and every archive query filters
// by the explicit companyId.
const archiveClient = createServiceClient()
const connection = await loadExtensionData<GoogleDriveConnection>(
supabase,
companyId,
@@ -322,7 +332,7 @@ export async function performSync(params: PerformSyncParams): Promise<PerformSyn
].join('|'),
includeDocuments,
generate: () =>
generateFullArchive(supabase, companyId, {
generateFullArchive(archiveClient, companyId, {
scope: 'period',
period_id: period.id,
include_documents: includeDocuments,
@@ -355,7 +365,7 @@ export async function performSync(params: PerformSyncParams): Promise<PerformSyn
].join('|'),
includeDocuments: baseDecision.includeDocuments,
generate: () =>
generateBaseDataArchive(supabase, companyId, {
generateBaseDataArchive(archiveClient, companyId, {
include_documents: baseDecision.includeDocuments,
}),
})