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:
co-authored by
Claude Fable 5
parent
6d9846b1e7
commit
968161b42b
@@ -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' }),
|
||||
}))
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user