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 <noreply@anthropic.com> * test(entitlements): pin unlock routes as paywall-free recovery paths Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
573feea890
commit
7f24ede6c0
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof import('@/lib/entitlements/has-capability')>()
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<NextResponse | null> {
|
||||
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 })
|
||||
|
||||
Reference in New Issue
Block a user