From 7f24ede6c0cd5e65b98ceffe3823ca203214ec6a Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:20:26 +0200 Subject: [PATCH] fix(entitlements): close paywall leaks on interactive bank, SKV, and email routes (#910) * fix(entitlements): close paywall leaks on interactive bank, SKV, and email routes The capability gate covered crons, MCP tools, and invoice send, but four interactive server paths still bypassed it ahead of the 2026-07-07 trial cutover: - enable-banking POST /connect and /sync (bank_sync) - skatteverket authorize/validate/draft/lock/submit/spara/las/kontrollera/ skattekonto-sync (skatteverket); unlock and all reads stay free, and the AGI/VAT file download path remains ungated per the manual-filing decision - salary payslip email send (email_send) - recurring-invoice auto-send (email_send); the invoice is still created, only the email is withheld (freeze-and-retain) Co-Authored-By: Claude Opus 4.7 * test(entitlements): pin unlock routes as paywall-free recovery paths Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- DECISIONS.md | 2 + .../payslips/send/__tests__/route.test.ts | 17 +++ .../salary/runs/[id]/payslips/send/route.ts | 6 + .../__tests__/session-expired.test.ts | 4 + .../__tests__/sync-filter.test.ts | 5 + extensions/general/enable-banking/index.ts | 8 ++ .../__tests__/capability-gate.test.ts | 103 ++++++++++++++++++ extensions/general/skatteverket/index.ts | 34 ++++++ lib/invoices/recurring-schedule-service.ts | 12 ++ 9 files changed, 191 insertions(+) create mode 100644 extensions/general/skatteverket/__tests__/capability-gate.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 6025d5b0..d9a1426c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -21,3 +21,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-06] Fastigheter-on-customers (item 3 of #895) deferred to a follow-up issue instead of shipping a quick column: single-default-property vs multi-property registry changes the data model and the ROT prefill UX; needs its own design pass. [2026-07-06] v1 articles endpoint is read-only list (GET) under invoices:read: the #895 ask is "pick articles when composing invoices via API", not article CRUD; linking article_id does not auto-fill line fields (caller copies price/VAT), matching how invoice_items freeze article data at write time. [2026-07-06] Kept two-step potential-match fetch on /transactions instead of single PostgREST embed: prod schema cache has no FK relationship for transactions.potential_supplier_invoice_id (PGRST200; migration 20260225100248 ADD COLUMN IF NOT EXISTS likely skipped the REFERENCES clause because the column pre-existed). Revisit after adding the FK via a new migration. +[2026-07-06] Bolagsverket testbänk E2E as skipped-by-default vitest (BOLAGSVERKET_TESTBANK_E2E=1): needs the IP-bound firewall opening, so it can never run in CI; GUIDE's documented test pnr 190001010106 fails Luhn, 190001010107 is the accepted one. +[2026-07-06] Paywall leak sweep gating choices: SKV unlock (DELETE /declaration/lock) left ungated so a lapsed company can recover a draft it locked while entitled; agi/kontrollera HU/IU gated (direct SKV API interaction = paid, file download stays free); recurring auto-send blocks only the email, invoice creation stays free (freeze-and-retain). diff --git a/app/api/salary/runs/[id]/payslips/send/__tests__/route.test.ts b/app/api/salary/runs/[id]/payslips/send/__tests__/route.test.ts index dc5aeadd..8ff327ee 100644 --- a/app/api/salary/runs/[id]/payslips/send/__tests__/route.test.ts +++ b/app/api/salary/runs/[id]/payslips/send/__tests__/route.test.ts @@ -18,6 +18,9 @@ vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), })) vi.mock('@/lib/email/service', () => ({ getEmailService: vi.fn() })) +vi.mock('@/lib/entitlements/has-capability', () => ({ + requireCapability: vi.fn().mockResolvedValue(null), +})) vi.mock('@/lib/branding/service', () => ({ getBranding: () => ({ appUrl: 'https://app.example.test' }), })) @@ -80,6 +83,20 @@ describe('POST /api/salary/runs/[id]/payslips/send', () => { expect(response.status).toBe(401) }) + it('returns 403 when the company lacks the email_send capability', async () => { + const { requireCapability } = await import('@/lib/entitlements/has-capability') + vi.mocked(requireCapability).mockResolvedValueOnce( + NextResponse.json({ capability_blocked: true }, { status: 403 }), + ) + const { supabase } = createQueuedMockSupabase() + authed(supabase) + mockEmail({ success: true }) + + const request = createMockRequest('/api/salary/runs/run-1/payslips/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'run-1' })) + expect(response.status).toBe(403) + }) + it('returns 404 when the run does not exist', async () => { const { supabase, enqueueMany } = createQueuedMockSupabase() authed(supabase) diff --git a/app/api/salary/runs/[id]/payslips/send/route.ts b/app/api/salary/runs/[id]/payslips/send/route.ts index 0ab15c16..f7ef0eb7 100644 --- a/app/api/salary/runs/[id]/payslips/send/route.ts +++ b/app/api/salary/runs/[id]/payslips/send/route.ts @@ -6,6 +6,8 @@ import { getEmailService } from '@/lib/email/service' import { getBranding } from '@/lib/branding/service' import { rotateLinkForEmployee } from '@/lib/salary/payslips/links' import { buildPayslipLinkEmail } from '@/lib/salary/payslips/email-template' +import { requireCapability } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' ensureInitialized() @@ -22,6 +24,10 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( 'salary_run.payslips_send', async (_request, { supabase, companyId, user, log, requestId }, { params }) => { const { id } = await params + + const blocked = await requireCapability(supabase, companyId, CAPABILITY.email_send) + if (blocked) return blocked + const emailService = getEmailService() const { data: run } = await supabase diff --git a/extensions/general/enable-banking/__tests__/session-expired.test.ts b/extensions/general/enable-banking/__tests__/session-expired.test.ts index 83db2620..87a72388 100644 --- a/extensions/general/enable-banking/__tests__/session-expired.test.ts +++ b/extensions/general/enable-banking/__tests__/session-expired.test.ts @@ -14,6 +14,10 @@ vi.mock('../lib/sync', () => ({ syncAccountTransactions: vi.fn(), })) +vi.mock('@/lib/entitlements/has-capability', () => ({ + requireCapability: vi.fn().mockResolvedValue(null), +})) + import { isSessionExpiredResponse, SessionExpiredError, diff --git a/extensions/general/enable-banking/__tests__/sync-filter.test.ts b/extensions/general/enable-banking/__tests__/sync-filter.test.ts index 293da660..788e88f7 100644 --- a/extensions/general/enable-banking/__tests__/sync-filter.test.ts +++ b/extensions/general/enable-banking/__tests__/sync-filter.test.ts @@ -1,4 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/entitlements/has-capability', () => ({ + requireCapability: vi.fn().mockResolvedValue(null), +})) + import { enableBankingExtension } from '../index' import type { ExtensionContext } from '@/lib/extensions/types' import type { StoredAccount } from '../types' diff --git a/extensions/general/enable-banking/index.ts b/extensions/general/enable-banking/index.ts index b1c36b91..baba10e8 100644 --- a/extensions/general/enable-banking/index.ts +++ b/extensions/general/enable-banking/index.ts @@ -15,6 +15,8 @@ import { DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD, } from '@/lib/reconciliation/bank-reconciliation' import { checkRateLimit } from '@/lib/auth/rate-limit-http' +import { requireCapability } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' import type { StoredAccount } from './types' import type { Transaction } from '@/types' @@ -107,6 +109,9 @@ export const enableBankingExtension: Extension = { } const companyId = ctx.companyId + const blocked = await requireCapability(supabase, companyId, CAPABILITY.bank_sync) + if (blocked) return blocked + const { aspsp_name, aspsp_country, psu_type: explicitPsuType, connection_id: reconnectId } = await request.json() // Reconnect mode: re-authorize an EXISTING connection in place (no @@ -410,6 +415,9 @@ export const enableBankingExtension: Extension = { } const companyId = ctx.companyId + const blocked = await requireCapability(supabase, companyId, CAPABILITY.bank_sync) + if (blocked) return blocked + const rl = await checkRateLimit({ prefix: 'enable-banking:sync', identifier: user.id, diff --git a/extensions/general/skatteverket/__tests__/capability-gate.test.ts b/extensions/general/skatteverket/__tests__/capability-gate.test.ts new file mode 100644 index 00000000..058b229c --- /dev/null +++ b/extensions/general/skatteverket/__tests__/capability-gate.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Force the gate itself to run (no dev bypass), but stub the resolver so we +// control entitlement per test. +vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + requireCapability: vi.fn(), + } +}) + +import { skatteverketExtension } from '../index' +import { requireCapability, capabilityBlockedResponse } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' +import type { ExtensionContext } from '@/lib/extensions/types' + +const GATED: Array<{ method: string; path: string }> = [ + { method: 'GET', path: '/authorize' }, + { method: 'POST', path: '/declaration/validate' }, + { method: 'POST', path: '/declaration/draft' }, + { method: 'PUT', path: '/declaration/lock' }, + { method: 'POST', path: '/agi/submit' }, + { method: 'POST', path: '/agi/spara' }, + { method: 'POST', path: '/agi/las' }, + { method: 'POST', path: '/agi/kontrollera/hu' }, + { method: 'POST', path: '/agi/kontrollera/iu' }, + { method: 'POST', path: '/skattekonto/sync' }, +] + +function makeContext(): ExtensionContext { + return { + userId: 'user-1', + companyId: 'company-1', + extensionId: 'skatteverket', + requestId: 'req_test', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + supabase: { from: vi.fn() } as any, + emit: vi.fn().mockResolvedValue(undefined), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), child: vi.fn() }, + settings: { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + clear: vi.fn().mockResolvedValue(undefined), + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any +} + +describe('skatteverket paywall gate', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it.each(GATED)('$method $path returns 403 capability_blocked when not entitled', async ({ method, path }) => { + vi.mocked(requireCapability).mockResolvedValue( + capabilityBlockedResponse(CAPABILITY.skatteverket), + ) + const route = skatteverketExtension.apiRoutes?.find( + (r) => r.method === method && r.path === path, + ) + expect(route, `${method} ${path} must be registered`).toBeDefined() + + const request = new Request(`https://test.local/api/extensions/ext/skatteverket${path}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: method === 'GET' ? undefined : JSON.stringify({}), + }) + const response = await route!.handler(request, makeContext()) + + expect(response.status).toBe(403) + const body = (await response.json()) as { capability_blocked?: boolean; capability?: string } + expect(body.capability_blocked).toBe(true) + expect(body.capability).toBe(CAPABILITY.skatteverket) + }) + + // Unlock operations stay free: a lapsed company must be able to unlock + // what it locked while entitled (draft recovery, never data hostage). + it.each([ + { method: 'DELETE', path: '/declaration/lock' }, + { method: 'POST', path: '/agi/lasUpp' }, + ])('$method $path (unlock) is NOT paywall-gated', async ({ method, path }) => { + vi.mocked(requireCapability).mockResolvedValue( + capabilityBlockedResponse(CAPABILITY.skatteverket), + ) + const route = skatteverketExtension.apiRoutes?.find( + (r) => r.method === method && r.path === path, + ) + expect(route).toBeDefined() + + const request = new Request( + `https://test.local/api/extensions/ext/skatteverket${path}?period=202606`, + { method }, + ) + const response = await route!.handler(request, makeContext()) + // Fails later (missing params / no tokens) but never with the paywall 403. + if (response.status === 403) { + const body = (await response.json()) as { capability_blocked?: boolean } + expect(body.capability_blocked).not.toBe(true) + } + expect(vi.mocked(requireCapability)).not.toHaveBeenCalled() + }) +}) diff --git a/extensions/general/skatteverket/index.ts b/extensions/general/skatteverket/index.ts index fb286af7..73b2831e 100644 --- a/extensions/general/skatteverket/index.ts +++ b/extensions/general/skatteverket/index.ts @@ -8,6 +8,8 @@ import { AGI_KONTROLLERA_MAX_BYTES, } from '@/lib/salary/agi/kontrollera-schemas' import { TimeoutError } from '@/lib/http/fetch-with-timeout' +import { requireCapability } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' import { buildAuthorizeUrl, exchangeCodeForTokens, generatePkcePair } from './lib/oauth' import { storeTokens, getTokens, deleteTokens, getTokenHealth } from './lib/token-store' import { skvRequest, SkatteverketAuthError, getSkatteverketEnvironment } from './lib/api-client' @@ -109,6 +111,18 @@ import type { VatPeriodType } from '@/types' const AGI_WRITE_ROLES = new Set(['owner', 'admin', 'member']) +/** + * Paywall gate for routes that talk to Skatteverket's API. The declaration + * FILE download is always free (manual filing is never blocked); the direct + * API interaction (connect, validate, draft, lock, submit, sync) is the paid + * convenience. Returns null when entitled, a 403 capability_blocked response + * otherwise. Unlock (DELETE /declaration/lock) is deliberately NOT gated so a + * lapsed company can always recover a draft it locked while entitled. + */ +async function requireSkvCapability(ctx: ExtensionContext): Promise { + return requireCapability(ctx.supabase, ctx.companyId, CAPABILITY.skatteverket) +} + /** * Defense-in-depth RBAC check for AGI write/validate endpoints. Ctx * presence alone (set by middleware) only confirms the user is signed in @@ -161,6 +175,8 @@ export const skatteverketExtension: Extension = { if (!ctx) { return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) } + const blocked = await requireSkvCapability(ctx) + if (blocked) return blocked const state = crypto.randomUUID() const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' @@ -446,6 +462,8 @@ export const skatteverketExtension: Extension = { if (!ctx) { return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) } + const blocked = await requireSkvCapability(ctx) + if (blocked) return blocked try { const { redovisare, redovisningsperiod, momsuppgift } = @@ -492,6 +510,8 @@ export const skatteverketExtension: Extension = { if (!ctx) { return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) } + const blocked = await requireSkvCapability(ctx) + if (blocked) return blocked try { const { redovisare, redovisningsperiod, momsuppgift } = @@ -625,6 +645,8 @@ export const skatteverketExtension: Extension = { if (!ctx) { return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) } + const blocked = await requireSkvCapability(ctx) + if (blocked) return blocked try { const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx) @@ -811,6 +833,8 @@ export const skatteverketExtension: Extension = { if (!ctx) { return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) } + const blocked = await requireSkvCapability(ctx) + if (blocked) return blocked try { const { arbetsgivare, period, salaryRunId, xml } = await loadAGIXml(request, ctx) @@ -934,6 +958,8 @@ export const skatteverketExtension: Extension = { if (!ctx) { return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) } + const blocked = await requireSkvCapability(ctx) + if (blocked) return blocked try { const body = (await request.json()) as { inlamningId?: number; salaryRunId?: string } const inlamningId = Number(body.inlamningId) @@ -1335,6 +1361,8 @@ export const skatteverketExtension: Extension = { if (!ctx) { return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) } + const blocked = await requireSkvCapability(ctx) + if (blocked) return blocked const rbac = await requireAgiWriteRole(ctx) if (rbac) return rbac @@ -1444,6 +1472,8 @@ export const skatteverketExtension: Extension = { if (!ctx) { return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) } + const blocked = await requireSkvCapability(ctx) + if (blocked) return blocked const rbac = await requireAgiWriteRole(ctx) if (rbac) return rbac @@ -1551,6 +1581,8 @@ export const skatteverketExtension: Extension = { path: '/agi/las', handler: async (request: Request, ctx?: ExtensionContext) => { if (!ctx) return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + const blocked = await requireSkvCapability(ctx) + if (blocked) return blocked try { const url = new URL(request.url) const arbetsgivare = url.searchParams.get('arbetsgivare') @@ -1727,6 +1759,8 @@ export const skatteverketExtension: Extension = { if (!ctx) { return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) } + const blocked = await requireSkvCapability(ctx) + if (blocked) return blocked try { const result = await syncSkattekonto(ctx) return NextResponse.json({ data: result }) diff --git a/lib/invoices/recurring-schedule-service.ts b/lib/invoices/recurring-schedule-service.ts index 923bf867..507f089a 100644 --- a/lib/invoices/recurring-schedule-service.ts +++ b/lib/invoices/recurring-schedule-service.ts @@ -21,6 +21,8 @@ import { renderToBuffer } from '@react-pdf/renderer' import { InvoicePDF } from '@/lib/invoices/pdf-template' import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers' import { getEmailService } from '@/lib/email/service' +import { hasCapability } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' import { generateInvoiceEmailHtml, generateInvoiceEmailText, @@ -361,6 +363,16 @@ async function sendInvoiceFromSchedule( }) return false } + // Paywall: email sending is a paid capability. The invoice itself is still + // created (bookkeeping stays free); it just isn't emailed, and the schedule + // surfaces the standard manual-send warning (freeze-and-retain). + if (!(await hasCapability(supabase, companyId, CAPABILITY.email_send))) { + log.warn('company lacks email_send capability; recurring schedule cannot auto-send', { + invoiceId: invoice.id, + companyId, + }) + return false + } if (!invoice.customer.email) { log.warn('customer has no email; recurring schedule cannot auto-send', { invoiceId: invoice.id,