Fix/compliance and invoices (#433)

* feat(invoices): implement inline membership checks for invoice-number RPCs and enhance error handling

* fix(invoices): enhance unpaid amount calculation to support currency-specific rounding

* fix(api-client): handle response.text() error for 403 status in skvRequest
This commit is contained in:
Mattsson
2026-05-10 19:09:57 +02:00
committed by GitHub
parent 1876b3b402
commit 75043632de
6 changed files with 342 additions and 29 deletions
+6 -1
View File
@@ -124,7 +124,12 @@ export default function InvoicesPage() {
unpaid: invoices.filter(isOutstandingReceivable).length,
unpaidAmount: invoices
.filter(isOutstandingReceivable)
.reduce((sum, i) => sum + Number(i.total_sek || i.total), 0),
.reduce((sum, i) => {
if (i.currency === 'SEK') {
return sum + getDisplayTotal({ total: Number(i.total), currency: 'SEK' }, { ore_rounding: oreRounding }).displayed
}
return sum + Number(i.total_sek || i.total)
}, 0),
overdue: invoices.filter((i) => i.status === 'overdue' && !i.credited_invoice_id).length,
}
+26 -6
View File
@@ -1,25 +1,45 @@
import crypto from 'crypto'
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getActiveCompanyId } from '@/lib/company/context'
import { createLogger } from '@/lib/logger'
const log = createLogger('sandbox:seed')
/**
* POST /api/sandbox/seed
* Seeds demo data for an anonymous sandbox user.
* Only callable by anonymous users (is_anonymous === true).
*
* Defense-in-depth: also requires SANDBOX_ENABLED=true. The anonymous-user
* check is the primary control; the env guard exists so that if anonymous
* sign-in is ever turned on accidentally in a production environment, this
* destructive seed endpoint stays inert until an operator explicitly opts in.
*/
export async function POST() {
// Per-request logger so seed-failure entries are correlatable in the SIEM.
// Cannot reuse withRouteContext here — it requires an active company, but
// the sandbox seed runs *before* a company exists for the user.
const requestId = `req_${crypto.randomUUID()}`
const log = createLogger('sandbox:seed', { requestId })
if (process.env.SANDBOX_ENABLED !== 'true') {
return NextResponse.json(
{ error: 'Sandbox is not enabled in this environment', requestId },
{ status: 403 },
)
}
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
return NextResponse.json({ error: 'Unauthorized', requestId }, { status: 401 })
}
if (!user.is_anonymous) {
return NextResponse.json({ error: 'Sandbox is only available for anonymous users' }, { status: 403 })
return NextResponse.json(
{ error: 'Sandbox is only available for anonymous users', requestId },
{ status: 403 },
)
}
// Anonymous users start with no company. Create one before seeding.
@@ -39,7 +59,7 @@ export async function POST() {
if (companyError || !newCompanyId) {
log.error('failed to create sandbox company', { error: companyError, userId: user.id })
return NextResponse.json(
{ error: 'Failed to create sandbox company' },
{ error: 'Failed to create sandbox company', requestId },
{ status: 500 }
)
}
@@ -552,7 +572,7 @@ export async function POST() {
} catch (err) {
log.error('failed to seed sandbox data', { error: err, userId: user.id, companyId })
return NextResponse.json(
{ error: 'Failed to seed sandbox data' },
{ error: 'Failed to seed sandbox data', requestId },
{ status: 500 }
)
}
@@ -55,7 +55,7 @@ describe('skvRequest — error mapping', () => {
}
})
it('maps 401 with body text → SESSION_EXPIRED and includes body', async () => {
it('maps 401 with body text → SESSION_EXPIRED with a clean Swedish message (no body leak)', async () => {
mockFetchStatus(401, 'token expired')
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
@@ -63,7 +63,10 @@ describe('skvRequest — error mapping', () => {
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('SESSION_EXPIRED')
expect((e as SkatteverketAuthError).message).toContain('token expired')
expect((e as SkatteverketAuthError).message).toMatch(/Sessionen har gått ut/)
// Audit V16.1: the raw response body must NOT be concatenated into the
// user-facing message — that information stays in server-side logs.
expect((e as SkatteverketAuthError).message).not.toContain('token expired')
}
})
@@ -1,9 +1,27 @@
import crypto from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { refreshAccessToken } from './oauth'
import { getTokens, storeTokens, deleteTokens } from './token-store'
import type { SkatteverketTokens } from '../types'
const log = createLogger('skatteverket-api-client')
// Cap diagnostic-body logging at 200 chars and redact any Bearer token
// patterns. The audit (V16.1 / A.8.15) flagged that raw 401/403 bodies were
// being concatenated into user-facing error messages and written to logs
// without redaction. Diagnostic data still belongs in server-side logs, but
// not in unbounded form and not in anything that reaches the user.
const MAX_LOG_BODY_LEN = 200
const BEARER_PATTERN = /\bBearer\s+[A-Za-z0-9\-._~+/=]+/gi
function safeBodyForLog(body: string): string {
const redacted = body.replace(BEARER_PATTERN, 'Bearer [REDACTED]')
return redacted.length > MAX_LOG_BODY_LEN
? redacted.slice(0, MAX_LOG_BODY_LEN) + '…'
: redacted
}
/**
* Skatteverket API client.
*
@@ -235,15 +253,17 @@ export async function skvRequest(
skvHeaders[k] = v
}
})
console.error('[skatteverket] 401 from API', { url, body: text, headers: skvHeaders })
// Diagnostic detail (headers, body) belongs in server-side logs only —
// not in user-facing error messages. The structured logger redacts
// sensitive keys and we further cap body length and strip Bearer tokens
// so the diagnostic is bounded.
log.error('401 from Skatteverket API', {
url,
statusCode: 401,
body: safeBodyForLog(text),
headers: skvHeaders,
})
// (A) Surface SKV's WWW-Authenticate verbatim — when the body is empty
// this header is usually the only diagnostic SKV gives us. Carry both
// header and body into every thrown message below.
const headerSuffix = Object.keys(skvHeaders).length > 0
? ` Headers: ${JSON.stringify(skvHeaders)}`
: ''
const bodySuffix = text ? ` Svar: ${text}` : ''
const lower = text.toLowerCase()
// OAuth's standard insufficient_scope marker. SKV sometimes emits this
@@ -258,8 +278,7 @@ export async function skvRequest(
throw new SkatteverketAuthError(
'Anslutningen mot Skatteverket saknar nödvändig behörighet för denna ' +
'tjänst. Koppla bort och anslut igen via Inställningar → Skatteverket ' +
'för att förnya tokenen med rätt scope.' +
headerSuffix + bodySuffix,
'för att förnya tokenen med rätt scope.',
'MISSING_SCOPE'
)
}
@@ -277,13 +296,12 @@ export async function skvRequest(
try {
await deleteTokens(supabase, userId)
} catch (cleanupErr) {
console.error('[skatteverket] failed to clear revoked token row', cleanupErr)
log.error('failed to clear revoked token row', cleanupErr as Error, { userId })
}
throw new SkatteverketAuthError(
'Skatteverket har återkallat anslutningen. Detta händer t.ex. om ' +
'BankID-sessionen avslutats eller om en ny anslutning gjorts från ' +
'en annan enhet. Anslut igen med BankID för att fortsätta.' +
headerSuffix + bodySuffix,
'en annan enhet. Anslut igen med BankID för att fortsätta.',
'TOKEN_REVOKED'
)
}
@@ -303,9 +321,7 @@ export async function skvRequest(
throw new SkatteverketAuthError(
'Skatteverkets API-gateway nekade anropet. Kontrollera att din ' +
'APIGW-klient (SKATTEVERKET_APIGW_CLIENT_ID) har prenumeration på ' +
'denna tjänst i Utvecklarportalen.' +
headerSuffix +
` Svar från Skatteverket: ${text || '(tomt svar)'}`,
'denna tjänst i Utvecklarportalen.',
'ACCESS_DENIED'
)
}
@@ -337,19 +353,26 @@ export async function skvRequest(
`inte prenumeration på tjänsten "${apiHint}" i Utvecklarportalen, ` +
'eller den lagrade tokenen saknar rätt scope. Kontrollera ' +
'prenumerationen, koppla annars bort och anslut igen via ' +
'Inställningar → Skatteverket.' + headerSuffix,
'Inställningar → Skatteverket.',
'ACCESS_DENIED'
)
}
throw new SkatteverketAuthError(
`Sessionen har gått ut. Logga in med BankID igen.${headerSuffix}${bodySuffix}`,
'Sessionen har gått ut. Logga in med BankID igen.',
'SESSION_EXPIRED'
)
}
if (response.status === 403) {
const text = await response.text()
const text = await response.text().catch(() => '')
// Same diagnostic-vs-user-message split as the 401 path: log the body
// server-side, surface only the actionable Swedish guidance.
log.error('403 from Skatteverket API', {
url,
statusCode: 403,
body: safeBodyForLog(text),
})
// Missing scope on the access token — fires when an existing connection
// pre-dates an extension that needed a new scope (the AGI/`agd` rollout
// is the canonical example). The user has to disconnect + reconnect to
@@ -375,7 +398,7 @@ export async function skvRequest(
)
}
throw new SkatteverketAuthError(
`Åtkomst nekad av Skatteverket (403): ${text}`,
'Åtkomst nekad av Skatteverket (403). Kontakta support om problemet kvarstår.',
'ACCESS_DENIED'
)
}
@@ -0,0 +1,134 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool, withUserContext } from '@/tests/pg/setup'
import { seedCompany } from '@/tests/pg/fixtures'
// Authorization tests for the invoice-number RPCs added in
// 20260510140000_harden_invoice_number_rpcs.sql. The functions are
// SECURITY DEFINER, so they bypass caller RLS. The migration added an inline
// auth.uid() membership check as defense-in-depth; these tests prove it.
async function ensureCompanySettings(params: {
userId: string
companyId: string
invoicePrefix?: string
nextInvoiceNumber?: number
}): Promise<void> {
await getPool().query(
`INSERT INTO public.company_settings
(user_id, company_id, invoice_prefix, next_invoice_number)
VALUES ($1, $2, $3, $4)
ON CONFLICT (company_id) DO UPDATE
SET invoice_prefix = EXCLUDED.invoice_prefix,
next_invoice_number = EXCLUDED.next_invoice_number`,
[params.userId, params.companyId, params.invoicePrefix ?? 'F', params.nextInvoiceNumber ?? 1],
)
}
async function insertDraftInvoice(params: {
userId: string
companyId: string
}): Promise<string> {
const customerId = randomUUID()
await getPool().query(
`INSERT INTO public.customers (id, user_id, company_id, name)
VALUES ($1, $2, $3, 'Test Customer')`,
[customerId, params.userId, params.companyId],
)
const invoiceId = randomUUID()
await getPool().query(
`INSERT INTO public.invoices
(id, user_id, company_id, customer_id, invoice_number, document_type,
invoice_date, due_date, currency, subtotal, vat_amount, total,
vat_treatment, vat_rate, moms_ruta, status)
VALUES ($1, $2, $3, $4, NULL, 'invoice',
'2026-04-27', '2026-05-27', 'SEK', 1000, 250, 1250,
'standard_25', 25, '10', 'draft')`,
[invoiceId, params.userId, params.companyId, customerId],
)
return invoiceId
}
describe('invoice-number RPCs — authorization (defense in depth)', () => {
it('peek_next_invoice_number raises when caller is not a member of the target company', async () => {
const intruder = await seedCompany()
const target = await seedCompany()
await ensureCompanySettings({ userId: target.userId, companyId: target.companyId })
await expect(
withUserContext(intruder.userId, async (client) => {
await client.query('SELECT public.peek_next_invoice_number($1, $2)', [
target.companyId,
'invoice',
])
}),
).rejects.toThrow(/unauthorized/)
})
it('peek_next_invoice_number succeeds for a member of the target company', async () => {
const { userId, companyId } = await seedCompany()
await ensureCompanySettings({ userId, companyId, invoicePrefix: 'F', nextInvoiceNumber: 7 })
const preview = await withUserContext(userId, async (client) => {
const { rows } = await client.query<{ peek_next_invoice_number: string }>(
'SELECT public.peek_next_invoice_number($1, $2)',
[companyId, 'invoice'],
)
return rows[0]!.peek_next_invoice_number
})
expect(preview).toBe('F007')
})
it('generate_invoice_number raises when caller is not a member of the target company', async () => {
const intruder = await seedCompany()
const target = await seedCompany()
await ensureCompanySettings({ userId: target.userId, companyId: target.companyId })
const invoiceId = await insertDraftInvoice({
userId: target.userId,
companyId: target.companyId,
})
await expect(
withUserContext(intruder.userId, async (client) => {
await client.query('SELECT public.generate_invoice_number($1, $2, $3)', [
target.companyId,
invoiceId,
'invoice',
])
}),
).rejects.toThrow(/unauthorized/)
})
it('generate_invoice_number succeeds for a member of the target company', async () => {
const { userId, companyId } = await seedCompany()
await ensureCompanySettings({ userId, companyId, invoicePrefix: 'G', nextInvoiceNumber: 42 })
const invoiceId = await insertDraftInvoice({ userId, companyId })
const assigned = await withUserContext(userId, async (client) => {
const { rows } = await client.query<{ generate_invoice_number: string }>(
'SELECT public.generate_invoice_number($1, $2, $3)',
[companyId, invoiceId, 'invoice'],
)
return rows[0]!.generate_invoice_number
})
expect(assigned).toBe('G042')
})
it('superuser (no JWT context) bypasses the membership check', async () => {
// Service role / cron / pg-real seed paths run without a JWT. The
// membership check is intentionally skipped when auth.uid() IS NULL —
// calling the RPC directly on the pool (no withUserContext) must still
// succeed.
const { userId, companyId } = await seedCompany()
await ensureCompanySettings({ userId, companyId, invoicePrefix: 'F', nextInvoiceNumber: 1 })
const { rows } = await getPool().query<{ peek_next_invoice_number: string }>(
'SELECT public.peek_next_invoice_number($1, $2)',
[companyId, 'invoice'],
)
expect(rows[0]!.peek_next_invoice_number).toBe('F001')
})
})
@@ -0,0 +1,128 @@
-- Harden invoice-number RPCs with inline membership check + empty search_path.
--
-- Compliance hardening triggered by external audit (OWASP V8.2.1, SOC 2 CC6.1,
-- ISO 27001 A.8.28).
--
-- generate_invoice_number and peek_next_invoice_number are SECURITY DEFINER,
-- which means they execute with elevated privileges and bypass the caller's
-- RLS. They are reached from the /api/invoices/next-number route, which
-- already validates company membership at the application layer
-- (lib/company/context.ts → withRouteContext). This migration adds two layers
-- of defense-in-depth at the DB layer:
--
-- 1. Inline membership check using auth.uid() against public.company_members.
-- Skipped when auth.uid() is NULL (service role / cron / pg-real
-- superuser tests that bypass JWT context). This mirrors how Supabase's
-- service role bypasses RLS by design.
--
-- 2. SET search_path = '' (was 'public'). All table/function references
-- below are now schema-qualified so they cannot be hijacked by an
-- object created in another schema later on the search path.
-- pg_catalog is searched implicitly so built-in functions (LPAD,
-- GREATEST, COALESCE, length, now) still resolve without qualification.
--
-- Behaviour for the LPAD / proforma / GREATEST(3, length) logic is preserved
-- verbatim from 20260510130000_invoice_number_no_truncate.sql.
CREATE OR REPLACE FUNCTION public.generate_invoice_number(
p_company_id uuid,
p_invoice_id uuid,
p_document_type text DEFAULT 'invoice'
)
RETURNS text
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $function$
DECLARE
v_existing text;
v_prefix text;
v_number integer;
v_final text;
BEGIN
-- Defense-in-depth: refuse to operate on companies the caller is not a
-- member of. NULL auth.uid() (service role / cron / superuser) is allowed
-- through; those code paths are trusted and need to operate across tenants.
IF auth.uid() IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM public.company_members
WHERE user_id = auth.uid() AND company_id = p_company_id
) THEN
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id
USING ERRCODE = '42501';
END IF;
SELECT invoice_number INTO v_existing
FROM public.invoices
WHERE id = p_invoice_id AND company_id = p_company_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Invoice % not found in company %', p_invoice_id, p_company_id;
END IF;
IF v_existing IS NOT NULL THEN
RETURN v_existing;
END IF;
UPDATE public.company_settings
SET next_invoice_number = next_invoice_number + 1,
updated_at = now()
WHERE company_id = p_company_id
RETURNING invoice_prefix, next_invoice_number - 1
INTO v_prefix, v_number;
IF v_number IS NULL THEN
RAISE EXCEPTION 'Company settings not found for company %', p_company_id;
END IF;
v_final := CASE
WHEN p_document_type = 'proforma' THEN 'PF-'
ELSE COALESCE(v_prefix, '')
END || LPAD(v_number::text, GREATEST(3, length(v_number::text)), '0');
UPDATE public.invoices
SET invoice_number = v_final
WHERE id = p_invoice_id AND company_id = p_company_id;
RETURN v_final;
END;
$function$;
CREATE OR REPLACE FUNCTION public.peek_next_invoice_number(
p_company_id uuid,
p_document_type text DEFAULT 'invoice'
)
RETURNS text
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $function$
DECLARE
v_prefix text;
v_number integer;
BEGIN
IF auth.uid() IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM public.company_members
WHERE user_id = auth.uid() AND company_id = p_company_id
) THEN
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id
USING ERRCODE = '42501';
END IF;
SELECT invoice_prefix, next_invoice_number INTO v_prefix, v_number
FROM public.company_settings
WHERE company_id = p_company_id;
IF v_number IS NULL THEN
RETURN NULL;
END IF;
RETURN CASE
WHEN p_document_type = 'proforma' THEN 'PF-'
ELSE COALESCE(v_prefix, '')
END || LPAD(v_number::text, GREATEST(3, length(v_number::text)), '0');
END;
$function$;
NOTIFY pgrst, 'reload schema';