feat(connect): Skatteverket data calls through the connector with a per-company canary (#2209)
CONNECT_SKV_CANARY_COMPANIES mirrors the bank canary: the listed companies' user-token Skatteverket calls (skattekonto, moms, AGI) go through the connector's data proxy while this installation still has its own credentials and keeps refreshing the tokens on its own OAuth client. Callers without a company id (OAuth start, token exchange, environment reporting) keep the plain rule: own credentials win. This is how hosted moves its Skatteverket traffic to Connect a few companies at a time before dropping its own keys. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
This commit is contained in:
co-authored by
Jakob Wennberg
parent
65bd675f43
commit
d900fea1a8
@@ -115,6 +115,9 @@ RECEIPT_HUNT_COMPANY_IDS=
|
||||
# even while this installation has its own Enable Banking credentials. Lets an
|
||||
# installation move upstream by upstream, a few companies at a time.
|
||||
# CONNECT_BANK_CANARY_COMPANIES=
|
||||
# Same switch for Skatteverket: listed companies send their user-token data
|
||||
# calls (skattekonto, moms, AGI) through the connector's data proxy.
|
||||
# CONNECT_SKV_CANARY_COMPANIES=
|
||||
# Outbound email (invoices, reminders). Pick one provider. Resend is what
|
||||
# hosted runs; SMTP is for self-hosts that want mail on their own relay.
|
||||
# EMAIL_PROVIDER=resend|smtp # optional; RESEND_API_KEY wins, else SMTP_HOST
|
||||
|
||||
@@ -1522,4 +1522,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-02] Nyckeltal "Resultat per månad" shows the exact per-month figures as an always-on list under the bars (#2198), not behind an "Anpassa" toggle: a preference would touch the type, the PUT schema, the strict preferences-body validator, the dialog and its tests for a switch nobody turns off. Per-bar compact labels are conditional on a glyph-width fit rule and fall back to the single latest label, so they never overlap. Left alone: the monthly path counts only posted entries while the year-total path also counts reversed originals (pinned as intended in tests/pg/kpi-report-aggregates-rpc.pg.test.ts), so a same-year storno makes the sum of months differ from Nettoresultat; visible as numbers now, founder call whether to align the two.
|
||||
[2026-09-03] Kundorder PGRST201 fixed by hinting the three sales_order_items embeds AND by teaching scripts/checks/ambiguous-embed.mjs to parse composite FOREIGN KEY (a, b) constraints, instead of a bespoke source-scan test: the guard is the sanctioned owner of this class (decision 2026-09-01) and it missed the pair only because its parser was single-column; with the fix it derives the same 17 pairs prod reports and flags all three shipped sites on main.
|
||||
[2026-09-03] Email-change double-submit gate is a dedicated per-user claim table + SECURITY DEFINER RPC (migration 20260903083000), not idempotency_keys and not an advisory lock: idempotency_keys requires a company_id the account-level route does not have, and a transaction-scoped advisory lock cannot cover the GoTrue call that happens outside the transaction.
|
||||
[2026-09-03] Skattekonto through Connect = the existing data proxy plus CONNECT_SKV_CANARY_COMPANIES, not a separate sync operation: the provider logic is two GETs and the dedup keys stay on the ledger; system (certificate) auth is still not brokered because hosted has no certificate configured, so every hosted skattekonto read is a user-token call the proxy already carries.
|
||||
[2026-09-03] Old-address social identities are unlinked by a BEFORE UPDATE trigger on auth.users (migration 20260903110000), not by the /auth/callback done path: the callback never runs for a completing click from a browser without a session, and admin-side changes bypass it entirely; the trigger covers every path and keeps the email identity, password and BankID intact.
|
||||
|
||||
@@ -392,7 +392,7 @@ SKATTEVERKET_SKATTEKONTO_API_BASE_URL=https://api.skatteverket.se/beskattning/sk
|
||||
|
||||
Set all five base URLs: every default points at Skatteverket's test environment, which only accepts a test BankID, so a production client with a missing URL fails at login. `SKATTEVERKET_DISABLED=true` is the emergency kill switch: every Skatteverket API call fails closed until you remove it (the BankID login itself is not blocked, only what follows it). There is no dual-key rotation for the token encryption key: changing it makes every stored token undecryptable, and every user reconnects with BankID. The `SKATTEVERKET_SYSTEM_*` variables and `SKATTEVERKET_OMBUD_ORG_NUMBER` belong to Accounted's hosted ombud certificate and stay unset on a self-host. Setting either `SKATTEVERKET_OAUTH2_CLIENT_ID` or `SKATTEVERKET_APIGW_CLIENT_ID` switches the Skatteverket upstream out of connector mode.
|
||||
|
||||
**Bank sync as a connector operation.** In connector mode the bank paging, the booked-only filter and the normalization run on the hosted service (`POST /api/connect/bank/sync`); the instance sends the session id it holds and receives the rows plus the raw provider pages it archives, and keeps computing its own stored transaction keys, so nothing about dedup changes. `CONNECT_BANK_CANARY_COMPANIES` (comma-separated company ids) routes only those companies through the connector while own credentials remain set, which is how an installation moves a few companies at a time.
|
||||
**Bank sync as a connector operation.** In connector mode the bank paging, the booked-only filter and the normalization run on the hosted service (`POST /api/connect/bank/sync`); the instance sends the session id it holds and receives the rows plus the raw provider pages it archives, and keeps computing its own stored transaction keys, so nothing about dedup changes. `CONNECT_BANK_CANARY_COMPANIES` (comma-separated company ids) routes only those companies through the connector while own credentials remain set, which is how an installation moves a few companies at a time. `CONNECT_SKV_CANARY_COMPANIES` does the same for Skatteverket: the listed companies' user-token data calls (skattekonto, moms, AGI) go through the connector's data proxy while the installation's own OAuth client still refreshes the tokens.
|
||||
|
||||
**Connector mode, for comparison.** With a key you set `GNUBOK_CONNECTOR_KEY` and, if you are not on the default hosted origin, `GNUBOK_CONNECT_URL` (https only; plain http is accepted for loopback only, and an invalid URL disables the connector with a warning in the log). Skatteverket in connector mode still needs `SKATTEVERKET_ENABLED=true` and `SKATTEVERKET_TOKEN_ENCRYPTION_KEY`: the BankID tokens are stored in your database, so the encryption key stays operator-side. Leave every other Enable Banking and Skatteverket variable unset.
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ const ENV_VARS = [
|
||||
'SKATTEVERKET_AGD_PERIOD_API_BASE_URL',
|
||||
'GNUBOK_CONNECTOR_KEY',
|
||||
'GNUBOK_CONNECT_URL',
|
||||
'CONNECT_SKV_CANARY_COMPANIES',
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -316,6 +317,32 @@ describe('skvRequestWithAuth: connector mode is OFF with own credentials (direct
|
||||
expect(headers['X-Connector-Upstream-Content-Type']).toBeUndefined()
|
||||
expect(JSON.stringify(headers)).not.toContain('gnubok_ck_')
|
||||
})
|
||||
|
||||
it('CONNECT_SKV_CANARY_COMPANIES reroutes only the listed company through the proxy', async () => {
|
||||
// Hosted moving to Connect upstream by upstream: a listed company's data
|
||||
// calls go through the proxy (no gateway credentials, user Bearer in the
|
||||
// upstream-auth header); every other company stays on the direct path.
|
||||
process.env.SKATTEVERKET_APIGW_CLIENT_ID = 'gw-id'
|
||||
process.env.SKATTEVERKET_APIGW_CLIENT_SECRET = 'gw-secret'
|
||||
process.env.SKATTEVERKET_API_BASE_URL = 'https://api.test.example/moms'
|
||||
process.env.CONNECT_SKV_CANARY_COMPANIES = 'comp-1'
|
||||
const fetchMock = mockFetchStatus(200, '{"ok":true}')
|
||||
|
||||
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/deklarationer')
|
||||
const [canaryUrl, canaryInit] = lastFetchCall(fetchMock)
|
||||
expect(canaryUrl).toBe('https://app.hosted.example/api/connect/skv/api/moms/deklarationer')
|
||||
const canaryHeaders = canaryInit.headers as Record<string, string>
|
||||
expect(canaryHeaders['Authorization']).toBe('Bearer gnubok_ck_test')
|
||||
expect(canaryHeaders['X-Connector-Upstream-Authorization']).toBe('Bearer user-skv-token')
|
||||
expect(canaryHeaders['Client_Id']).toBeUndefined()
|
||||
expect(canaryHeaders['Client_Secret']).toBeUndefined()
|
||||
|
||||
await skvRequest(fakeSupabase, 'user-1', 'comp-2', 'GET', '/deklarationer')
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
const [directUrl, directInit] = fetchMock.mock.calls[1] as unknown as [string, RequestInit]
|
||||
expect(directUrl).toBe('https://api.test.example/moms/deklarationer')
|
||||
expect((directInit.headers as Record<string, string>)['Client_Id']).toBe('gw-id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('skvRequestWithAuth: system mode is never brokered', () => {
|
||||
|
||||
@@ -419,7 +419,11 @@ export async function skvRequestWithAuth(
|
||||
// System (CCG) auth is deliberately NOT brokered: background ombud reads
|
||||
// are a hosted-only feature and stay on the direct path, where a
|
||||
// credential-less self-host fails with SYSTEM_AUTH_FAILED.
|
||||
const connector = auth.mode === 'user' ? skatteverketConnectorMode() : null
|
||||
// The company id lets CONNECT_SKV_CANARY_COMPANIES route a few companies
|
||||
// through the connector while this installation still has own credentials
|
||||
// (hosted moving to Connect upstream by upstream); token refresh stays on
|
||||
// whichever path the installation as a whole is on.
|
||||
const connector = auth.mode === 'user' ? skatteverketConnectorMode(auth.companyId) : null
|
||||
const effectiveBase = options?.baseUrl || getApiBaseUrl()
|
||||
let url: string
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
hasOwnSkatteverketCredentials,
|
||||
} from '../upstreams'
|
||||
|
||||
const ENV = ['GNUBOK_CONNECTOR_KEY', 'GNUBOK_CONNECT_URL', 'ENABLE_BANKING_PRIVATE_KEY', 'ENABLE_BANKING_APP_ID', 'ENABLE_BANKING_PRIVATE_KEY_PRODUCTION', 'ENABLE_BANKING_APP_ID_PRODUCTION', 'SKATTEVERKET_OAUTH2_CLIENT_ID', 'SKATTEVERKET_APIGW_CLIENT_ID', 'QVALIA_API_KEY', 'QVALIA_PARTNER_REG_NO', 'CONNECT_BANK_CANARY_COMPANIES'] as const
|
||||
const ENV = ['GNUBOK_CONNECTOR_KEY', 'GNUBOK_CONNECT_URL', 'ENABLE_BANKING_PRIVATE_KEY', 'ENABLE_BANKING_APP_ID', 'ENABLE_BANKING_PRIVATE_KEY_PRODUCTION', 'ENABLE_BANKING_APP_ID_PRODUCTION', 'SKATTEVERKET_OAUTH2_CLIENT_ID', 'SKATTEVERKET_APIGW_CLIENT_ID', 'QVALIA_API_KEY', 'QVALIA_PARTNER_REG_NO', 'CONNECT_BANK_CANARY_COMPANIES', 'CONNECT_SKV_CANARY_COMPANIES'] as const
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
function clear() {
|
||||
@@ -91,3 +91,32 @@ describe('bank canary companies', () => {
|
||||
expect(bankConnectorMode('c-1')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('skatteverket canary companies', () => {
|
||||
it('routes only the listed companies through the connector while own credentials exist', () => {
|
||||
clear()
|
||||
vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_x')
|
||||
vi.stubEnv('SKATTEVERKET_OAUTH2_CLIENT_ID', 'client')
|
||||
vi.stubEnv('SKATTEVERKET_APIGW_CLIENT_ID', 'gw')
|
||||
vi.stubEnv('CONNECT_SKV_CANARY_COMPANIES', 'c-1, c-2')
|
||||
expect(skatteverketConnectorMode()).toBeNull()
|
||||
expect(skatteverketConnectorMode('c-9')).toBeNull()
|
||||
expect(skatteverketConnectorMode('c-1')).toEqual({ baseUrl: 'https://connect.accounted.se/api/connect/skv', key: 'gnubok_ck_x' })
|
||||
expect(skatteverketConnectorMode('c-2')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not let the bank list leak into Skatteverket routing', () => {
|
||||
clear()
|
||||
vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_x')
|
||||
vi.stubEnv('SKATTEVERKET_OAUTH2_CLIENT_ID', 'client')
|
||||
vi.stubEnv('CONNECT_BANK_CANARY_COMPANIES', 'c-1')
|
||||
expect(skatteverketConnectorMode('c-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores the canary list without a connector key', () => {
|
||||
clear()
|
||||
vi.stubEnv('SKATTEVERKET_OAUTH2_CLIENT_ID', 'client')
|
||||
vi.stubEnv('CONNECT_SKV_CANARY_COMPANIES', 'c-1')
|
||||
expect(skatteverketConnectorMode('c-1')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,29 +35,41 @@ export interface ConnectorUpstream {
|
||||
}
|
||||
|
||||
/**
|
||||
* Company ids that use the connector for bank sync even though this
|
||||
* installation has its own Enable Banking credentials: the canary switch for
|
||||
* moving an installation upstream by upstream (hosted Accounted moves its
|
||||
* bank sync to Connect a few companies at a time before dropping its own
|
||||
* keys). Comma-separated. Ignored without a connector key.
|
||||
* Company ids that use the connector for an upstream even though this
|
||||
* installation has its own credentials for it: the canary switch for moving
|
||||
* an installation upstream by upstream (hosted Accounted moves its bank sync
|
||||
* and its Skatteverket traffic to Connect a few companies at a time before
|
||||
* dropping its own keys). Comma-separated. Ignored without a connector key.
|
||||
*/
|
||||
function bankCanaryCompanies(): Set<string> {
|
||||
const raw = process.env.CONNECT_BANK_CANARY_COMPANIES?.trim()
|
||||
type CanaryEnv = 'CONNECT_BANK_CANARY_COMPANIES' | 'CONNECT_SKV_CANARY_COMPANIES'
|
||||
|
||||
function canaryCompanies(envName: CanaryEnv): Set<string> {
|
||||
const raw = process.env[envName]?.trim()
|
||||
if (!raw) return new Set()
|
||||
return new Set(raw.split(',').map((v) => v.trim()).filter(Boolean))
|
||||
}
|
||||
|
||||
function isCanary(envName: CanaryEnv, companyId: string | undefined): boolean {
|
||||
return Boolean(companyId && canaryCompanies(envName).has(companyId))
|
||||
}
|
||||
|
||||
export function bankConnectorMode(companyId?: string): ConnectorUpstream | null {
|
||||
const cfg = getConnectorConfig()
|
||||
if (!cfg) return null
|
||||
if (hasOwnEnableBankingCredentials() && !(companyId && bankCanaryCompanies().has(companyId))) return null
|
||||
if (hasOwnEnableBankingCredentials() && !isCanary('CONNECT_BANK_CANARY_COMPANIES', companyId)) return null
|
||||
return { baseUrl: `${cfg.baseUrl}/api/connect/bank`, key: cfg.key }
|
||||
}
|
||||
|
||||
export function skatteverketConnectorMode(): ConnectorUpstream | null {
|
||||
if (hasOwnSkatteverketCredentials()) return null
|
||||
/**
|
||||
* Skatteverket data calls for a company. Callers without a company (OAuth
|
||||
* start and token exchange, environment reporting) pass nothing and get the
|
||||
* plain rule: own credentials win. The canary applies to user-token data
|
||||
* reads and writes only, which is what carries a company id.
|
||||
*/
|
||||
export function skatteverketConnectorMode(companyId?: string): ConnectorUpstream | null {
|
||||
const cfg = getConnectorConfig()
|
||||
if (!cfg) return null
|
||||
if (hasOwnSkatteverketCredentials() && !isCanary('CONNECT_SKV_CANARY_COMPANIES', companyId)) return null
|
||||
return { baseUrl: `${cfg.baseUrl}/api/connect/skv`, key: cfg.key }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user