diff --git a/.compliance/ropa.yaml b/.compliance/ropa.yaml index 5f4e8ed7..de68b919 100644 --- a/.compliance/ropa.yaml +++ b/.compliance/ropa.yaml @@ -125,3 +125,112 @@ processing_activities: - tls_1_3_to_skatteverket - bankid_signing_required_for_filing - immutable_audit_log + + - id: psd2.cash_account_mirror + name: PSD2-konton speglas till cash_accounts + purpose: >- + När en bank-anslutning via Enable Banking returnerar kontolista efter + lyckad PSD2-consent kopieras kontometadata (IBAN, valuta, kontonamn, + external_uid) till cash_accounts så att avstämning, motkontoresolver + och __PRIMARY_SEK__-sentinel kan rutta verifikat utan att läsa JSONB + från bank_connections.accounts_data vid varje fråga. + lawful_basis: art_6_1_b # contract (PSD2 consent + bookkeeping service) + special_category_basis: null + controller: gnubok-tenant + processor: anthropic-na + data_subjects: + - business_owner + data_categories: + - user.financial.bank_account # IBAN + - user.contact # account name (kontotitel) + recipients: + - name: Supabase + country: EU + role: processor + international_transfers: + applicable: false + mechanism: null + note: EU-only processor; no third-country transfer. + retention: + duration: consent_lifetime + basis: psd2_consent + stored_in: + - cash_accounts + - bank_connections.accounts_data + security_measures: + - rls_company_scoped + - data_minimization_no_balance_on_callback + - failure_emitted_to_event_log + + - id: transactions.counterparty_iban + name: Motpartens IBAN på transaktioner + purpose: >- + Spara motpartens IBAN på transaktionsraden så att own-account-detector + kan identifiera överföringar mellan företagets egna konton (1930 → 1932 + etc.) och bokföra båda benen automatiskt istället för att felbokföra + utflödet som extern kostnad. Krävs även för payment-matchning mot + leverantörsfakturor. + lawful_basis: art_6_1_b # contract (bookkeeping service) + special_category_basis: null + controller: gnubok-tenant + processor: anthropic-na + data_subjects: + - business_owner + - counterparty + data_categories: + - user.financial.bank_account # counterparty IBAN + recipients: + - name: Supabase + country: EU + role: processor + international_transfers: + applicable: false + mechanism: null + note: EU-only processor. + retention: + duration: 7y + basis: bfl_7_kap + stored_in: + - transactions.counterparty_iban + security_measures: + - rls_company_scoped + - immutable_after_post + + - id: skattekonto.drift_alert + name: Skattekonto-drift via e-post + purpose: >- + Underrätta företagets kontaktadress när det cachade Skatteverket-saldot + avviker från GL 1630 utöver konfigurerad tolerans (> 1 SEK), så + bokföraren kan granska skattekonto-raderna. E-postmeddelandet + innehåller ingen finansiell siffra utan en länk till autentiserad + dashboard; mottagaren valideras mot company_members innan utskick. + lawful_basis: art_6_1_f # legitimate interest (bookkeeping accuracy) + special_category_basis: null + controller: gnubok-tenant + processor: resend + data_subjects: + - business_owner + - company_member + data_categories: + - user.contact.email + recipients: + - name: Resend + country: US + role: processor + international_transfers: + applicable: true + mechanism: scc_2021_c2p + note: >- + Resend (US) — SCC Module 2 (controller-to-processor). Outbound + payload limited to ett notifieringsmail utan finansiella belopp; + TIA dokumenterad i .compliance/tia/resend.md. + retention: + duration: 30d + basis: event_log_ttl + stored_in: + - event_log + security_measures: + - recipient_membership_check_before_send + - no_financial_figures_in_body + - tls_to_resend + - rls_company_scoped diff --git a/app/api/cash-accounts/route.ts b/app/api/cash-accounts/route.ts new file mode 100644 index 00000000..54cb6854 --- /dev/null +++ b/app/api/cash-accounts/route.ts @@ -0,0 +1,33 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { getActiveCompanyId } from '@/lib/company/context' +import { listForCompany } from '@/lib/cash-accounts/service' + +/** + * GET /api/cash-accounts + * + * Returns the active company's cash accounts (cash_accounts table). Used by the + * reconciliation CashAccountSelector (Item 5) and any other surface that needs + * the canonical list of routable cash accounts. UI panels that just display PSD2 + * connection state may still read bank_connections.accounts_data until that + * column is dropped in a follow-up migration. + * + * Query params: + * - enabled_only=true → only accounts with enabled=true (default returns all) + */ +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const companyId = await getActiveCompanyId(supabase, user.id) + if (!companyId) { + return NextResponse.json({ error: 'No company context' }, { status: 400 }) + } + + const url = new URL(request.url) + const enabledOnly = url.searchParams.get('enabled_only') === 'true' + + const accounts = await listForCompany(supabase, companyId, { enabledOnly }) + return NextResponse.json({ data: accounts }) +} diff --git a/app/api/extensions/enable-banking/callback/route.ts b/app/api/extensions/enable-banking/callback/route.ts index ad984e3a..795f253b 100644 --- a/app/api/extensions/enable-banking/callback/route.ts +++ b/app/api/extensions/enable-banking/callback/route.ts @@ -3,6 +3,21 @@ import { NextResponse } from 'next/server' import { createSession, type AccountInfo } from '@/extensions/general/enable-banking/lib/api-client' import type { StoredAccount } from '@/extensions/general/enable-banking/types' import { eventBus } from '@/lib/events/bus' +import { upsertFromPsd2 } from '@/lib/cash-accounts/service' + +// Suggested BAS account per currency. Mirrors the AccountPickerDialog defaults +// (SEK→1930, EUR→1932, USD→1933, GBP→1934). The user can re-map in the picker +// after this callback redirects them. +const CURRENCY_DEFAULTS: Record = { + SEK: '1930', + EUR: '1932', + USD: '1933', + GBP: '1934', +} + +function defaultLedgerForCurrency(currency: string): string { + return CURRENCY_DEFAULTS[currency.toUpperCase()] ?? '1930' +} /** * GET /api/extensions/enable-banking/callback @@ -170,6 +185,56 @@ export async function GET(request: Request) { throw new Error(`Failed to update connection: ${updateError.message}`) } + // Mirror each PSD2 account into cash_accounts so routing decisions read from + // the canonical entity table. The user picks a ledger_account in the + // AccountPickerDialog after this redirect; until then we route SEK→1930, + // EUR→1932, USD→1933, GBP→1934 by convention. + for (const account of accountsMetadata) { + const targetLedger = defaultLedgerForCurrency(account.currency) + try { + await upsertFromPsd2(supabase, updatedConnection.company_id, { + bank_connection_id: updatedConnection.id, + external_uid: account.uid, + currency: account.currency, + ledger_account: targetLedger, + iban: account.iban ?? null, + name: account.name ?? null, + enabled: account.enabled ?? true, + }) + } catch (cashErr) { + const reason = cashErr instanceof Error ? cashErr.message : String(cashErr) + console.error('[enable-banking] Failed to mirror cash_account on callback', { + connectionId: updatedConnection.id, + uid: account.uid, + error: reason, + }) + // Persist the failure to event_log so a security review can see that + // a PSD2 account returned by the bank was not mirrored into our + // routing table — otherwise this is only visible in console output + // (ASVS V16 / ISO 27001 A.8.15 / SOC 2 CC7.2). + try { + await eventBus.emit({ + type: 'bank_connection.cash_account_mirror_failed', + payload: { + connectionId: updatedConnection.id, + bankName: updatedConnection.bank_name ?? null, + accountUid: account.uid, + ledgerAccount: targetLedger, + currency: account.currency, + reason, + userId: updatedConnection.user_id, + companyId: updatedConnection.company_id, + }, + }) + } catch (emitError) { + console.error('[enable-banking] Failed to emit cash_account_mirror_failed event', { + connectionId: updatedConnection.id, + error: emitError instanceof Error ? emitError.message : String(emitError), + }) + } + } + } + // Audit trail: PSD2 consent has been exchanged and account metadata stored. // ASVS V16 requires this transition to be logged as a security event; emit // here so the event_log handler persists it (30-day TTL). diff --git a/app/api/extensions/skatteverket/skattekonto/drift/route.ts b/app/api/extensions/skatteverket/skattekonto/drift/route.ts new file mode 100644 index 00000000..7b0b6736 --- /dev/null +++ b/app/api/extensions/skatteverket/skattekonto/drift/route.ts @@ -0,0 +1,44 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' +import { createExtensionContext } from '@/lib/extensions/context-factory' +import { computeSkattekontoDrift } from '@/extensions/general/skatteverket/lib/skattekonto-drift' +import { createLogger } from '@/lib/logger' + +ensureInitialized() + +const log = createLogger('skattekonto-drift-route') + +/** + * GET /api/extensions/skatteverket/skattekonto/drift + * + * Returns the current SKV saldo vs GL 1630 drift snapshot for the active + * company. Backs the dashboard SkattekontoDriftTile. Returns null when no + * snapshot exists yet (fresh company, never synced). + * + * Access is recorded through the structured logger (Sentry / Vercel logs) + * because the response carries sensitive GL drift figures. Persisting every + * dashboard tile poll into event_log would be too noisy — the structured + * log line gives an auditable record without overrunning the 30-day event + * log retention (SOC 2 CC8.1, ISO 27001 A.8.15). + */ +export async function GET(_request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const companyId = await requireCompanyId(supabase, user.id) + const ctx = createExtensionContext(supabase, user.id, companyId, 'skatteverket') + + const drift = await computeSkattekontoDrift(ctx) + log.info('skattekonto drift snapshot accessed', { + userId: user.id, + companyId, + hasDrift: drift !== null, + }) + return NextResponse.json({ data: drift }) +} diff --git a/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts b/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts index fe21181a..1a9a795b 100644 --- a/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts +++ b/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts @@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init' import { verifyCronSecret } from '@/lib/auth/cron' import { createExtensionContext } from '@/lib/extensions/context-factory' import { syncSkattekonto, SKATTEKONTO_LAST_SYNCED_AT_KEY } from '@/extensions/general/skatteverket/lib/skattekonto-sync' +import { computeSkattekontoDrift, maybeAlertDrift } from '@/extensions/general/skatteverket/lib/skattekonto-drift' import { SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client' import { SkatteverketSkattekontoError } from '@/extensions/general/skatteverket/lib/skattekonto-client' @@ -115,6 +116,19 @@ export async function GET(request: Request) { const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket') const syncResult = await syncSkattekonto(ctx) + // Drift check: compare the fresh SKV saldo against GL 1630 sum. Emits + // `skattekonto.drift_detected` when |drift| > tolerance and not throttled. + try { + const drift = await computeSkattekontoDrift(ctx) + if (drift) await maybeAlertDrift(ctx, drift) + } catch (driftErr) { + console.error('[skattekonto-sync-cron] Drift check failed', { + userId, + companyId, + message: driftErr instanceof Error ? driftErr.message : String(driftErr), + }) + } + results.push({ userId, companyId, diff --git a/app/api/mcp-oauth/authorize/route.ts b/app/api/mcp-oauth/authorize/route.ts index 4eb946e0..78574e5d 100644 --- a/app/api/mcp-oauth/authorize/route.ts +++ b/app/api/mcp-oauth/authorize/route.ts @@ -5,7 +5,14 @@ import { createAuthCode } from '@/lib/auth/oauth-codes' import { requireCompanyId } from '@/lib/company/context' import { getBranding } from '@/lib/branding/service' import { isAllowedRedirectUri } from '@/lib/auth/oauth-allowlist' -import { API_KEY_SCOPES, type ApiKeyScope } from '@/lib/auth/api-keys' +import { + ALL_SCOPES, + API_KEY_SCOPES, + DEFAULT_OAUTH_SCOPES, + SCOPE_GROUPS, + validateScopes, + type ApiKeyScope, +} from '@/lib/auth/api-keys' /** * OAuth 2.0 Authorization Endpoint. @@ -23,20 +30,21 @@ type ScopeParseResult = /** * Parse the OAuth `scope` query param (RFC 6749 §3.3 — space-delimited list) - * into the subset of API_KEY_SCOPES that the user actually granted. + * into the subset of API_KEY_SCOPES the client is asking for. Used to drive + * pre-checked defaults on the consent UI; the user's actual grant comes from + * their checkbox selection. * * Returns: - * - { ok, scopes: undefined } when no scope param was supplied — the token - * endpoint will fall back to DEFAULT_OAUTH_SCOPES (read-only, GDPR - * Art.25(2) data-protection-by-default). + * - { ok, scopes: undefined } when no scope param was supplied — the consent + * UI pre-checks DEFAULT_OAUTH_SCOPES (read-only, GDPR Art. 25(2)). * - { ok, scopes: [...] } when at least one valid scope was requested. * - { invalid_scope } when a scope param was supplied but every value was - * unknown — refusing the request is safer than silently widening the - * grant to ALL_SCOPES (V10.2.6). + * unknown — refusing the request is safer than silently dropping it back + * to defaults the caller didn't ask for (V10.2.6). * * The bare `mcp` marker is treated as "no granular scopes" and accepted for * backwards compatibility with Claude's connector — it falls through to - * `undefined` so the default-OAuth fallback applies. + * `undefined` so the read-only defaults apply. */ function parseRequestedScopes(scopeParam: string | null): ScopeParseResult { if (!scopeParam) return { kind: 'ok', scopes: undefined } @@ -179,12 +187,27 @@ export async function GET(request: Request) { const appNameLower = escapeHtml(getBranding().appName.toLowerCase()) + // CSP nonce for the inline consent UI controls. A nonce-bound script-src + // makes the inline block executable while keeping the rest of the page + // immune to script injection — without this the consent page is + // incompatible with a strict CSP and counts as unsafe-inline (ASVS V3.3, + // SOC 2 CC6.1). The nonce is regenerated per response. + const cspNonce = crypto.randomBytes(16).toString('base64') + // Bind the requested scope to the consent display. The HMAC signature is // verified on POST so a tampered form submission cannot widen the grant // beyond what the user actually saw (V10.3.1). const scopeBindingValue = scopeParam ?? '' const scopeBindingSignature = signScopeBinding(scopeBindingValue) + // The grant ceiling is the set of scopes the client requested, or + // DEFAULT_OAUTH_SCOPES when the client passed no scope param. Pre-checks + // everything in the ceiling. The POST handler enforces the same ceiling + // server-side so a tampered form can't widen the grant past what the + // client actually asked for (RFC 6749 §3.3, SOC 2 CC6.3). + const grantCeiling = new Set(parsed.scopes ?? DEFAULT_OAUTH_SCOPES) + const scopeCheckboxesHtml = renderScopeCheckboxes(grantCeiling, grantCeiling) + // Render consent page const html = ` @@ -195,15 +218,26 @@ export async function GET(request: Request) { Anslut MCP-klient — ${appNameLower}