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
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user