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
+1
View File
@@ -384,3 +384,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-25] Declined the review suggestion to 200-ack the Resend delivery webhook when RESEND_DELIVERY_WEBHOOK_SECRET is unset; kept 503. The endpoint is only ever called because an operator pointed Resend at it, so a missing secret at that moment is a live misconfiguration: Svix retry then endpoint-disable is a visible signal, whereas a silent 200 loses every delivery outcome with only a log line. The "optional" wording in docs/WHITELABEL.md describes not wiring the webhook at all, not wiring it half way.
[2026-07-25] Reverted the settings panel-sheet redesign on bug/resend-and-invoices back to main: Emil prefers the settings UI as it stands on main. The routed sheet, the sheet/ primitives (SettingsMasterDetail, SettingsAccordion, SettingsFieldRow), the *Subsections.tsx decompositions, the cold-load sheet and the settings_sheet i18n namespace were removed; every app/(dashboard)/settings/* page, components/settings/** file and MainContainer scroll exception now matches origin/main byte for byte. Unrelated branch work (invoice delivery outcomes, Stripe feed-only, article currency/deactivation, PDF logo) is untouched.
[2026-07-25] Settings UI on bug/resend-and-invoices now comes from feat/settings-fonster-redesign (dbae8792, Jakob) instead of the panel-sheet work reverted earlier the same day: Emil chose the Fonster concept (flat hairline rows, help behind "?", sticky dirty-only save bar, 920x680 modal, switches instead of checkboxes). Applied as a patch rather than a merge because the redesign branch forks from b5e3c476 and merging would have dragged that older main in; every file applied cleanly since no settings file changed on main since that fork point. The 10 settings_payments keys the redesign still carries (needs_review_*, reason_*, sync_done_description/transactions) were deliberately NOT restored: the Stripe feed-only commit on this branch deleted both them and their call sites.
[2026-07-26] Fixed cross-user attachment access (Odin Aero support case) at the call sites with service-role clients after company-scoped authorization, instead of rewriting the documents bucket storage policy to be company-scoped like sie-files got in 20260416120000: the documents path layout (documents/{userId}/...) carries no company_id, so a company-scoped policy needs a per-object join against document_attachments on every storage op, and the authorize-then-service-client pattern was already the established model (inline proxy route, v1 download route, MCP tools). Sweep found and fixed the same defect in the metadata/sign route, the integrity probe, verifyIntegrity, invoice-inbox retry-extraction, and cloud-backup archive generation. Known leftover, deliberately unfixed: deleteDocument and the upload-failure cleanups call storage remove() with a user-bound client, which silently no-ops (no DELETE policy, WORM), orphaning storage objects; harmless for compliance, needs a separate decision on whether files should ever be hard-deleted.
+45 -6
View File
@@ -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 () => {
+9 -5
View File
@@ -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)
+11 -1
View File
@@ -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: {
@@ -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)
@@ -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)