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
@@ -43,8 +43,13 @@ function makeClient(storageOverrides: Record<string, unknown> = {}) {
}
}
// verifyIntegrity downloads via the service-role client (the storage SELECT
// policy is per-uploader-folder); tests set this override to control the
// downloaded bytes.
let serviceClientOverride: ReturnType<typeof makeClient> | null = null
vi.mock('@/lib/auth/api-keys', () => ({
createServiceClientNoCookies: vi.fn(() => makeClient()),
createServiceClientNoCookies: vi.fn(() => serviceClientOverride ?? makeClient()),
}))
import {
@@ -66,6 +71,7 @@ beforeEach(() => {
_resetBucketVerified()
resultIdx = 0
results = []
serviceClientOverride = null
})
describe('validateDocumentMagicBytes: application/xhtml+xml', () => {
@@ -298,15 +304,15 @@ describe('verifyIntegrity', () => {
{ data: { storage_path: 'docs/test.pdf', sha256_hash: expectedHash }, error: null },
]
// Create a client with matching download content
const supabase = makeClient({
// The download runs on the service-role client; give it matching bytes.
serviceClientOverride = makeClient({
download: vi.fn().mockResolvedValue({
data: new Blob([content]),
error: null,
}),
})
const result = await verifyIntegrity(supabase as never, 'user-1', 'doc-1')
const result = await verifyIntegrity(makeClient() as never, 'user-1', 'doc-1')
expect(result.valid).toBe(true)
expect(result.storedHash).toBe(expectedHash)
expect(result.computedHash).toBe(expectedHash)
@@ -317,14 +323,14 @@ describe('verifyIntegrity', () => {
{ data: { storage_path: 'docs/test.pdf', sha256_hash: 'stored-hash-abc' }, error: null },
]
const supabase = makeClient({
serviceClientOverride = makeClient({
download: vi.fn().mockResolvedValue({
data: new Blob(['different content']),
error: null,
}),
})
const result = await verifyIntegrity(supabase as never, 'user-1', 'doc-1')
const result = await verifyIntegrity(makeClient() as never, 'user-1', 'doc-1')
expect(result.valid).toBe(false)
expect(result.storedHash).toBe('stored-hash-abc')
expect(result.computedHash).not.toBe('stored-hash-abc')
+6 -2
View File
@@ -488,8 +488,12 @@ export async function verifyIntegrity(
throw new Error('Document not found')
}
// Download file from storage
const { data: fileData, 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 a caller-bound
// client cannot read colleague-uploaded files. The company-filtered row
// fetch above (RLS on document_attachments) is the authorization.
const serviceClient = createServiceClientNoCookies()
const { data: fileData, error: downloadError } = await serviceClient.storage
.from('documents')
.download(doc.storage_path)