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,
}),
})
@@ -23,6 +23,16 @@ vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
return { ...actual, hasCapability: vi.fn().mockResolvedValue(true) }
})
// The attachment download runs on the service-role client (the storage
// SELECT policy is per-uploader-folder, and inbox documents are attributed
// to the company creator, not the caller).
const serviceDownloadMock = vi.fn()
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => ({
storage: { from: vi.fn(() => ({ download: serviceDownloadMock })) },
}),
}))
import { extractInvoiceFields } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox'
import { hasCapability } from '@/lib/entitlements/has-capability'
@@ -155,11 +165,9 @@ describe('POST /items/:id/retry-extraction', () => {
})
enqueue({ data: null, error: null }) // inbox update on success
supabase.storage.from = vi.fn().mockReturnValue({
download: vi.fn().mockResolvedValue({
data: new Blob([new Uint8Array([1, 2, 3])], { type: 'application/pdf' }),
error: null,
}),
serviceDownloadMock.mockResolvedValue({
data: new Blob([new Uint8Array([1, 2, 3])], { type: 'application/pdf' }),
error: null,
})
vi.mocked(extractInvoiceFields).mockResolvedValueOnce(EXTRACTION_SUCCESS as never)
@@ -183,11 +191,9 @@ describe('POST /items/:id/retry-extraction', () => {
})
enqueue({ data: null, error: null }) // error-state update
supabase.storage.from = vi.fn().mockReturnValue({
download: vi.fn().mockResolvedValue({
data: new Blob([new Uint8Array([1])], { type: 'application/pdf' }),
error: null,
}),
serviceDownloadMock.mockResolvedValue({
data: new Blob([new Uint8Array([1])], { type: 'application/pdf' }),
error: null,
})
vi.mocked(extractInvoiceFields).mockRejectedValueOnce(new Error('pdfjs blew up'))
+7 -1
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'
import { z } from 'zod'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { createServiceClient } from '@/lib/supabase/server'
import { extractInvoiceFields, ExtractionSchema, emptyResult } from './lib/extract-invoice-fields'
import {
verifyInboundWebhook,
@@ -1179,7 +1180,12 @@ export const invoiceInboxExtension: Extension = {
return NextResponse.json({ error: 'Bilagan kunde inte hittas.' }, { status: 404 })
}
const { data: blob, error: dlError } = await ctx.supabase.storage
// Download via the service-role client: the storage SELECT policy
// only covers the uploader's own folder, and inbox documents are
// attributed to the company creator, so ctx.supabase (user-bound)
// cannot read them for other members. The company-scoped row fetch
// above is the authorization.
const { data: blob, error: dlError } = await createServiceClient().storage
.from('documents')
.download(doc.storage_path)