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
@@ -27,6 +27,17 @@ vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
// The GET route signs with the service-role client: the storage SELECT
|
||||
// policy only covers the uploader's own folder, so a company member viewing
|
||||
// a colleague's upload cannot sign with their own client.
|
||||
const createSignedUrlMock = vi.fn()
|
||||
const serviceStorageFromMock = vi.fn(() => ({ createSignedUrl: createSignedUrlMock }))
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: () => ({
|
||||
storage: { from: serviceStorageFromMock },
|
||||
}),
|
||||
}))
|
||||
|
||||
import { GET, DELETE } from '../route'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { NextResponse } from 'next/server'
|
||||
@@ -40,6 +51,10 @@ beforeEach(() => {
|
||||
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
|
||||
// Reset write-permission mock to default ok
|
||||
vi.mocked(requireWritePermission).mockResolvedValue({ ok: true })
|
||||
createSignedUrlMock.mockResolvedValue({
|
||||
data: { signedUrl: 'https://example.com/signed' },
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
function makeReq(method: 'GET' | 'DELETE' = 'DELETE') {
|
||||
@@ -69,9 +84,7 @@ describe('GET /api/documents/[id]', () => {
|
||||
|
||||
it('returns 500 when the signed URL cannot be created', async () => {
|
||||
enqueue({ data: makeDocumentAttachment({ id: 'doc-1' }), error: null })
|
||||
mockSupabase.storage.from.mockReturnValueOnce({
|
||||
createSignedUrl: vi.fn().mockResolvedValue({ data: null, error: { message: 'boom' } }),
|
||||
} as never)
|
||||
createSignedUrlMock.mockResolvedValue({ data: null, error: { message: 'boom' } })
|
||||
|
||||
const res = await GET(makeReq('GET'), createMockRouteParams({ id: 'doc-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
||||
@@ -100,9 +113,8 @@ describe('GET /api/documents/[id]', () => {
|
||||
expect(body.data.id).toBe('doc-1')
|
||||
expect(body.data.download_url).toBe('https://example.com/signed')
|
||||
|
||||
expect(mockSupabase.storage.from).toHaveBeenCalledWith('documents')
|
||||
const storageBucket = mockSupabase.storage.from.mock.results[0]?.value
|
||||
expect(storageBucket.createSignedUrl).toHaveBeenCalledWith('documents/user-1/kvitto.pdf', 3600)
|
||||
expect(serviceStorageFromMock).toHaveBeenCalledWith('documents')
|
||||
expect(createSignedUrlMock).toHaveBeenCalledWith('documents/user-1/kvitto.pdf', 3600)
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce()
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
@@ -113,6 +125,33 @@ describe('GET /api/documents/[id]', () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('signs attachments stored under another company member folder', async () => {
|
||||
// Regression: the storage SELECT policy is per-uploader-folder, so signing
|
||||
// with the user-bound client failed for every colleague-uploaded document
|
||||
// ("Failed to create download URL"). The service client must sign after
|
||||
// the company-scoped row fetch has authorized access.
|
||||
const row = makeDocumentAttachment({
|
||||
id: 'doc-2',
|
||||
file_name: 'leverantorsfaktura.pdf',
|
||||
storage_path: 'documents/other-member/leverantorsfaktura.pdf',
|
||||
})
|
||||
enqueue({ data: row, error: null })
|
||||
|
||||
const res = await GET(makeReq('GET'), createMockRouteParams({ id: 'doc-2' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { download_url: string }
|
||||
}>(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.download_url).toBe('https://example.com/signed')
|
||||
expect(createSignedUrlMock).toHaveBeenCalledWith(
|
||||
'documents/other-member/leverantorsfaktura.pdf',
|
||||
3600,
|
||||
)
|
||||
// The user-bound client must not be used for signing at all.
|
||||
expect(mockSupabase.storage.from).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/documents/[id]', () => {
|
||||
|
||||
@@ -11,8 +11,14 @@ vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: vi.fn(),
|
||||
}))
|
||||
|
||||
// The storage download goes through the service-role client: the storage
|
||||
// SELECT policy only covers the uploader's own folder, so the user-scoped
|
||||
// client cannot read colleague-uploaded files within the same company.
|
||||
const serviceDownloadMock = vi.fn()
|
||||
const serviceStorageFromMock = vi.fn(() => ({ download: serviceDownloadMock }))
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
createServiceClient: () => ({ storage: { from: serviceStorageFromMock } }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/documents/document-service', () => ({
|
||||
@@ -106,7 +112,7 @@ describe('GET /api/documents/[id]/integrity', () => {
|
||||
const { status, body } = await parseJsonResponse<{ data: { valid: boolean } }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.valid).toBe(true)
|
||||
expect(mockSupabase.storage.from).not.toHaveBeenCalled()
|
||||
expect(serviceStorageFromMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns { valid: true } when the bytes match the declared mime type', async () => {
|
||||
@@ -123,9 +129,7 @@ describe('GET /api/documents/[id]/integrity', () => {
|
||||
|
||||
const pdfBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37])
|
||||
const blob = new Blob([pdfBytes], { type: 'application/pdf' })
|
||||
mockSupabase.storage.from.mockReturnValue({
|
||||
download: vi.fn().mockResolvedValue({ data: blob, error: null }),
|
||||
} as never)
|
||||
serviceDownloadMock.mockResolvedValue({ data: blob, error: null })
|
||||
|
||||
vi.mocked(validateDocumentMagicBytes).mockReturnValue(null)
|
||||
|
||||
@@ -149,9 +153,7 @@ describe('GET /api/documents/[id]/integrity', () => {
|
||||
enqueue({ data: { company_id: companyId }, error: null })
|
||||
|
||||
const blob = new Blob([new Uint8Array([0x00, 0x00, 0x00, 0x00])])
|
||||
mockSupabase.storage.from.mockReturnValue({
|
||||
download: vi.fn().mockResolvedValue({ data: blob, error: null }),
|
||||
} as never)
|
||||
serviceDownloadMock.mockResolvedValue({ data: blob, error: null })
|
||||
|
||||
vi.mocked(validateDocumentMagicBytes).mockReturnValue('Internal /storage/v1/object error 42')
|
||||
|
||||
@@ -177,12 +179,10 @@ describe('GET /api/documents/[id]/integrity', () => {
|
||||
})
|
||||
enqueue({ data: { company_id: companyId }, error: null })
|
||||
|
||||
mockSupabase.storage.from.mockReturnValue({
|
||||
download: vi.fn().mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'storage internal: bucket=documents object=secret/path.pdf' },
|
||||
}),
|
||||
} as never)
|
||||
serviceDownloadMock.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'storage internal: bucket=documents object=secret/path.pdf' },
|
||||
})
|
||||
|
||||
const res = await GET(req(), createMockRouteParams({ id: validDocId }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
||||
@@ -191,17 +191,22 @@ describe('GET /api/documents/[id]/integrity', () => {
|
||||
expect(JSON.stringify(body)).not.toContain('secret/path.pdf')
|
||||
})
|
||||
|
||||
it('does not import the service-role supabase client', async () => {
|
||||
// The download must go through the per-request user-scoped client so
|
||||
// RLS on storage.objects can act as defense-in-depth. Statically
|
||||
// verifying the source is the cleanest check: runtime mocking of the
|
||||
// service-client export would not catch a future regression where
|
||||
// someone added an import but conditionally used it.
|
||||
it('authorizes on the user-scoped client before the service-role download', async () => {
|
||||
// The storage SELECT policy on the documents bucket only covers the
|
||||
// uploader's own folder, so the download must use the service-role
|
||||
// client or colleague-uploaded files 500 for every other company
|
||||
// member. The compensating control is that both the row fetch and the
|
||||
// explicit membership check run on the user-scoped client first.
|
||||
// Source-level check: the queued mock collapses chained calls, so
|
||||
// runtime introspection cannot distinguish which client ran the fetch.
|
||||
const fs = await import('node:fs/promises')
|
||||
const path = await import('node:path')
|
||||
const routePath = path.resolve(__dirname, '../route.ts')
|
||||
const source = await fs.readFile(routePath, 'utf8')
|
||||
expect(source).not.toMatch(/createServiceClient(\b|NoCookies)/)
|
||||
const membershipIdx = source.indexOf("from('company_members')")
|
||||
const downloadIdx = source.indexOf('createServiceClient()')
|
||||
expect(membershipIdx).toBeGreaterThan(-1)
|
||||
expect(downloadIdx).toBeGreaterThan(membershipIdx)
|
||||
})
|
||||
|
||||
it('filters the document lookup to the current version', async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { validateDocumentMagicBytes } from '@/lib/core/documents/document-service'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
@@ -74,11 +75,14 @@ export async function GET(
|
||||
return NextResponse.json({ data: { valid: true } })
|
||||
}
|
||||
|
||||
// Use the user-scoped supabase client so the storage download is subject
|
||||
// to RLS on storage.objects, not just the application-layer membership
|
||||
// check above. A logic bug in the membership check would still be
|
||||
// arrested at the storage layer.
|
||||
const { data: blob, error: downloadError } = await supabase.storage
|
||||
// Download via the service-role client: the storage SELECT policy only
|
||||
// covers the uploader's own folder (documents/{uid}/...), so the
|
||||
// user-scoped client cannot read colleague-uploaded files even within
|
||||
// the same company. The document_attachments RLS fetch plus the explicit
|
||||
// membership check above are the authorization (same model as the
|
||||
// inline proxy route).
|
||||
const serviceClient = createServiceClient()
|
||||
const { data: blob, error: downloadError } = await serviceClient.storage
|
||||
.from('documents')
|
||||
.download(doc.storage_path)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { deleteDocument } from '@/lib/core/documents/document-service'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
@@ -34,8 +35,17 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
// not race Vercel function suspension) and never rejects (the bus
|
||||
// settles handlers via Promise.allSettled), so it cannot fail this
|
||||
// Promise.all.
|
||||
//
|
||||
// Sign with the service-role client: the storage SELECT policy only
|
||||
// covers the uploader's own folder (documents/{uid}/...), while
|
||||
// document_attachments rows are company-scoped. Signing with the
|
||||
// user-bound client fails for every attachment uploaded by another
|
||||
// member of the same company. The row fetch above (RLS + explicit
|
||||
// company filter) is the authorization, mirroring the inline proxy
|
||||
// route.
|
||||
const serviceClient = createServiceClient()
|
||||
const [signResult] = await Promise.all([
|
||||
supabase.storage.from('documents').createSignedUrl(doc.storage_path, 3600),
|
||||
serviceClient.storage.from('documents').createSignedUrl(doc.storage_path, 3600),
|
||||
eventBus.emit({
|
||||
type: 'document.accessed',
|
||||
payload: {
|
||||
|
||||
Reference in New Issue
Block a user