Märkesspecifika kopplingar — kunden lägger in sina egna credentials
Verkstaden har redan sina avtal: Volvo-verkstaden har VIDA, VAG-verkstaden har erWin, den fria verkstaden har en fordonsdataleverantör. Kopplingarna konfigureras därför av kunden själv under Inställningar, med sina egna uppgifter — vi tillhandahåller ramen, inte kontot. Uppgifterna når aldrig webbläsaren. De krypteras med AES-256-GCM (INTEGRATION_NYCKEL) innan de skrivs till tabellen integrationer, och API:t returnerar hemliga fält maskerade. Alla uppslag görs av servern. Saknas krypteringsnyckeln sparas ingenting alls — 503 och en förklaring i gränssnittet i stället för klartext i databasen. Endast systemadministratören hanterar uppgifterna; kopplingarna är organisationsknutna som all annan ärendedata. Leverantörer är data, inte kod: URL-mall, autentiseringstyp (bearer/header/basic/query) och svarsmappning beskrivs i services/plattform/integrationer.json, utbytbar via ConfigMap (INTEGRATIONER_FIL). Nya märken läggs till utan att appen byggs om. Varje uppslag skriver senast_testad och senaste_status på kopplingen, så ett utgånget abonnemang syns i inställningarna i stället för att ge tysta tomma svar. Två latenta krascher hittade av klicktestet och åtgärdade: TextFalt och UNDANTAGSORSAKER användes utan import. vite build typkontrollerar inte, så de passerade bygget — därav nya npm-skriptet typkontroll, nu del av verifieringen. Verifierat: 80 vitest-tester, typkontroll, eslint, OpenAPI-validering, integrationstest mot riktig Postgres (rollstyrning, kryptering i vila, maskering, organisationsisolering, fail closed, borttagning) och klickgenomgång mot en körande plattform. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
@@ -4,7 +4,7 @@ FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force
|
||||
COPY server.mjs openapi.yaml ecm-regler.json ./
|
||||
COPY server.mjs openapi.yaml ecm-regler.json integrationer.json ./
|
||||
|
||||
ENV NODE_ENV=production PORT=8080
|
||||
USER node
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"beskrivning": "Register över märkesspecifika kopplingar. Leverantörer är data, inte kod: nya märken/dataleverantörer läggs till här (eller via ConfigMap-mount, INTEGRATIONER_FIL) utan att applikationen byggs om. Fält märkta hemlig=true maskeras alltid i API-svar och lagras krypterat.",
|
||||
"leverantorer": [
|
||||
{
|
||||
"id": "generisk_vin",
|
||||
"namn": "Generisk VIN-tjänst",
|
||||
"beskrivning": "Valfri dataleverantör med VIN-uppslag över HTTP. Ange bas-URL och nyckel enligt leverantörens dokumentation.",
|
||||
"falt": [
|
||||
{ "nyckel": "bas_url", "etikett": "Bas-URL (använd {vin} som platshållare)", "hemlig": false },
|
||||
{ "nyckel": "api_nyckel", "etikett": "API-nyckel", "hemlig": true }
|
||||
],
|
||||
"uppslag": {
|
||||
"urlFalt": "bas_url",
|
||||
"auth": "bearer",
|
||||
"authFalt": "api_nyckel",
|
||||
"svarsfalt": {
|
||||
"marke": "make",
|
||||
"modell": "model",
|
||||
"arsmodell": "year",
|
||||
"motor": "engine",
|
||||
"vaxellada": "transmission"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vag_erwin",
|
||||
"namn": "Volkswagen Group erWin",
|
||||
"beskrivning": "OEM-portal för VW, Audi, Škoda och SEAT. Kräver eget abonnemang hos tillverkaren.",
|
||||
"falt": [
|
||||
{ "nyckel": "bas_url", "etikett": "Bas-URL (använd {vin} som platshållare)", "hemlig": false },
|
||||
{ "nyckel": "anvandarnamn", "etikett": "Användarnamn", "hemlig": false },
|
||||
{ "nyckel": "losenord", "etikett": "Lösenord", "hemlig": true }
|
||||
],
|
||||
"uppslag": {
|
||||
"urlFalt": "bas_url",
|
||||
"auth": "basic",
|
||||
"authFalt": "anvandarnamn",
|
||||
"authFalt2": "losenord",
|
||||
"svarsfalt": {
|
||||
"marke": "brand",
|
||||
"modell": "model",
|
||||
"arsmodell": "modelYear",
|
||||
"motor": "engineCode",
|
||||
"vaxellada": "gearbox"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "volvo_vida",
|
||||
"namn": "Volvo VIDA",
|
||||
"beskrivning": "Volvos verkstadsportal. Kräver eget abonnemang och verkstads-ID.",
|
||||
"falt": [
|
||||
{ "nyckel": "bas_url", "etikett": "Bas-URL (använd {vin} som platshållare)", "hemlig": false },
|
||||
{ "nyckel": "verkstadsid", "etikett": "Verkstads-ID", "hemlig": false },
|
||||
{ "nyckel": "api_nyckel", "etikett": "API-nyckel", "hemlig": true }
|
||||
],
|
||||
"uppslag": {
|
||||
"urlFalt": "bas_url",
|
||||
"auth": "header",
|
||||
"authHeader": "X-Api-Key",
|
||||
"authFalt": "api_nyckel",
|
||||
"svarsfalt": {
|
||||
"marke": "make",
|
||||
"modell": "model",
|
||||
"arsmodell": "year",
|
||||
"motor": "engine",
|
||||
"vaxellada": "transmission"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fordonsregister",
|
||||
"namn": "Fordonsregisteruppslag (regnr → fordon)",
|
||||
"beskrivning": "Nationell fordonsdata via vald leverantör. Uppslag sker på registreringsnummer i stället för VIN.",
|
||||
"nyckeltyp": "regnr",
|
||||
"falt": [
|
||||
{ "nyckel": "bas_url", "etikett": "Bas-URL (använd {regnr} som platshållare)", "hemlig": false },
|
||||
{ "nyckel": "api_nyckel", "etikett": "API-nyckel", "hemlig": true }
|
||||
],
|
||||
"uppslag": {
|
||||
"urlFalt": "bas_url",
|
||||
"auth": "query",
|
||||
"authParam": "key",
|
||||
"authFalt": "api_nyckel",
|
||||
"svarsfalt": {
|
||||
"marke": "make",
|
||||
"modell": "model",
|
||||
"arsmodell": "year",
|
||||
"vin": "vin",
|
||||
"motor": "engine"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -253,6 +253,88 @@ for i in 1 2 3 4 5 6 7; do
|
||||
done
|
||||
kontroll "takt-begränsning stoppar upprepade försök" "$SISTA" "429"
|
||||
|
||||
# 10d. Märkesspecifika kopplingar: kundens egna credentials
|
||||
# Registret är läsbart för alla inloggade (inställningssidan behöver
|
||||
# veta vilka leverantörer som finns) men innehåller inga uppgifter.
|
||||
LEV=$(curl -s "$BAS/api/integrationer/leverantorer" -H "Authorization: Bearer $TOKEN_J")
|
||||
kontroll "leverantörsregistret är läsbart för tekniker" \
|
||||
"$(echo "$LEV" | falt '.leverantorer.some(l=>l.id==="generisk_vin")')" "true"
|
||||
kontroll "registret pekar ut hemliga fält" \
|
||||
"$(echo "$LEV" | falt '.leverantorer.every(l=>l.falt.some(f=>f.hemlig===true))')" "true"
|
||||
|
||||
# Uppgifterna är administratörens ensak
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_J")
|
||||
kontroll "tekniker ser inte kopplingarnas uppgifter" "$KOD" "403"
|
||||
|
||||
# Utan konfigurerad krypteringsnyckel sparas ingenting — fail closed
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"leverantor":"generisk_vin","uppgifter":{"bas_url":"https://x.se/{vin}","api_nyckel":"k"}}')
|
||||
kontroll "utan krypteringsnyckel sparas inga uppgifter" "$KOD" "503"
|
||||
|
||||
# Starta om tjänsten med krypteringsnyckel konfigurerad
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \
|
||||
JWT_SECRET=integrationshemlighet PORT=$APPPORT \
|
||||
INTEGRATION_NYCKEL=$(node -pe "require('crypto').randomBytes(32).toString('hex')") \
|
||||
node server.mjs &
|
||||
SERVER_PID=$!
|
||||
sleep 1
|
||||
|
||||
# Okänd leverantör och ofullständiga uppgifter avvisas
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \
|
||||
-H 'Content-Type: application/json' -d '{"leverantor":"hittepa","uppgifter":{"a":"b"}}')
|
||||
kontroll "okänd leverantör avvisas" "$KOD" "400"
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \
|
||||
-H 'Content-Type: application/json' -d '{"leverantor":"generisk_vin","uppgifter":{"bas_url":"https://x.se/{vin}"}}')
|
||||
kontroll "ofullständiga uppgifter avvisas" "$KOD" "400"
|
||||
|
||||
# Administratören sparar organisationens egna credentials
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"leverantor":"generisk_vin","uppgifter":{"bas_url":"http://127.0.0.1:9/vin/{vin}","api_nyckel":"sk-verkstad-123456"}}')
|
||||
kontroll "administratören kan spara credentials" "$KOD" "200"
|
||||
|
||||
# Hemligheten lämnar aldrig servern i klartext
|
||||
INT=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A")
|
||||
kontroll "krypteringen är konfigurerad" "$(echo "$INT" | falt .krypteringKonfigurerad)" "true"
|
||||
kontroll "hemligt fält maskeras i svaret" "$(echo "$INT" | falt '.integrationer[0].uppgifter.api_nyckel')" "••••3456"
|
||||
kontroll "öppet fält visas som det är" "$(echo "$INT" | falt '.integrationer[0].uppgifter.bas_url')" "http://127.0.0.1:9/vin/{vin}"
|
||||
|
||||
# … och ligger krypterad i databasen
|
||||
RAD=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
|
||||
-tAc "select uppgifter_krypt from integrationer where leverantor='generisk_vin'")
|
||||
case "$RAD" in
|
||||
*sk-verkstad-123456*) echo "✗ uppgifterna ligger i klartext i databasen"; exit 1 ;;
|
||||
*) echo "✓ uppgifterna ligger krypterade i databasen" ;;
|
||||
esac
|
||||
|
||||
# Uppslag: identifieraren valideras, okonfigurerad koppling ger 404
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer/generisk_vin/uppslag" \
|
||||
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"x"}')
|
||||
kontroll "ogiltig identifierare avvisas" "$KOD" "400"
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer/volvo_vida/uppslag" \
|
||||
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"YV1DZ8256F2123456"}')
|
||||
kontroll "okonfigurerad koppling ger 404" "$KOD" "404"
|
||||
|
||||
# Leverantören är onåbar i testmiljön — felet rapporteras ärligt, inte tyst
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer/generisk_vin/uppslag" \
|
||||
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"YV1DZ8256F2123456"}')
|
||||
kontroll "onåbar leverantör rapporteras som 502" "$KOD" "502"
|
||||
STATUS=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \
|
||||
| falt '.integrationer[0].senaste_status.slice(0,3)')
|
||||
kontroll "senaste testresultat sparas på kopplingen" "$STATUS" "fel"
|
||||
|
||||
# Kopplingar är organisationsknutna
|
||||
ANTAL_B=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_B" | falt .integrationer.length)
|
||||
kontroll "org B ser inte org A:s kopplingar" "$ANTAL_B" "0"
|
||||
|
||||
# Borttagning
|
||||
curl -s -X DELETE "$BAS/api/integrationer/generisk_vin" -H "Authorization: Bearer $TOKEN_A" >/dev/null
|
||||
ANTAL=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" | falt .integrationer.length)
|
||||
kontroll "kopplingen kan tas bort" "$ANTAL" "0"
|
||||
|
||||
# 11. API-first: OpenAPI-specen serveras live, utan inloggning
|
||||
SPEC=$(curl -s "$BAS/api/openapi.yaml")
|
||||
case "$SPEC" in
|
||||
|
||||
@@ -26,6 +26,7 @@ tags:
|
||||
- name: Översikt
|
||||
- name: Delning
|
||||
- name: AI
|
||||
- name: Integrationer
|
||||
- name: Drift
|
||||
|
||||
paths:
|
||||
@@ -229,6 +230,185 @@ paths:
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"403": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/integrationer/leverantorer:
|
||||
get:
|
||||
tags: [Integrationer]
|
||||
summary: Registret över märkesspecifika kopplingar
|
||||
description: >
|
||||
Leverantörer är data, inte kod: registret läses ur
|
||||
`integrationer.json` (eller filen i `INTEGRATIONER_FIL`) och kan
|
||||
bytas via ConfigMap utan att applikationen byggs om. Innehåller
|
||||
endast fältdefinitioner — aldrig någon organisations uppgifter.
|
||||
responses:
|
||||
"200":
|
||||
description: Leverantörsdefinitioner.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
version: { type: string }
|
||||
leverantorer:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
namn: { type: string }
|
||||
beskrivning: { type: string }
|
||||
nyckeltyp:
|
||||
type: string
|
||||
enum: [vin, regnr]
|
||||
description: Vad uppslaget sker på. Utelämnat betyder VIN.
|
||||
falt:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
nyckel: { type: string }
|
||||
etikett: { type: string }
|
||||
hemlig:
|
||||
type: boolean
|
||||
description: >
|
||||
Hemliga fält maskeras alltid i svar och
|
||||
visas aldrig igen efter sparande.
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/integrationer:
|
||||
get:
|
||||
tags: [Integrationer]
|
||||
summary: Organisationens konfigurerade kopplingar
|
||||
description: >
|
||||
Kräver rollen `admin`. Uppgifterna lagras krypterade
|
||||
(AES-256-GCM) och returneras alltid maskerade — hemliga värden
|
||||
lämnar aldrig servern i klartext.
|
||||
responses:
|
||||
"200":
|
||||
description: Konfigurerade kopplingar med maskerade uppgifter.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
krypteringKonfigurerad:
|
||||
type: boolean
|
||||
description: Falskt om `INTEGRATION_NYCKEL` saknas — då kan inget sparas.
|
||||
integrationer:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
leverantor: { type: string }
|
||||
namn: { type: string }
|
||||
aktiv: { type: boolean }
|
||||
uppdaterad: { type: string, format: date-time }
|
||||
senast_testad: { type: string, format: date-time, nullable: true }
|
||||
senaste_status: { type: string, nullable: true }
|
||||
uppgifter:
|
||||
type: object
|
||||
additionalProperties: { type: string }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"403": { $ref: "#/components/responses/Fel" }
|
||||
post:
|
||||
tags: [Integrationer]
|
||||
summary: Spara eller uppdatera en kopplings uppgifter
|
||||
description: >
|
||||
Kräver rollen `admin`. Endast leverantörens definierade fält
|
||||
sparas och samtliga måste fyllas i. Uppgifterna krypteras innan
|
||||
de skrivs. Ett sparande nollställer tidigare testresultat.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [leverantor, uppgifter]
|
||||
properties:
|
||||
leverantor: { type: string }
|
||||
aktiv: { type: boolean, default: true }
|
||||
uppgifter:
|
||||
type: object
|
||||
additionalProperties: { type: string }
|
||||
responses:
|
||||
"200": { description: Sparad. }
|
||||
"400": { $ref: "#/components/responses/Fel" }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"403": { $ref: "#/components/responses/Fel" }
|
||||
"503":
|
||||
description: Kryptering är inte konfigurerad (`INTEGRATION_NYCKEL` saknas).
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Fel" }
|
||||
|
||||
/api/integrationer/{leverantor}:
|
||||
delete:
|
||||
tags: [Integrationer]
|
||||
summary: Ta bort en kopplings uppgifter
|
||||
description: Kräver rollen `admin`.
|
||||
parameters:
|
||||
- name: leverantor
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"200": { description: Borttagen. }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"403": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/integrationer/{leverantor}/uppslag:
|
||||
post:
|
||||
tags: [Integrationer]
|
||||
summary: Slå upp ett fordon hos leverantören
|
||||
description: >
|
||||
Anropet görs alltid av servern — kundens leverantörsnycklar når
|
||||
aldrig webbläsaren. Svaret mappas till våra fält enligt
|
||||
registrets `svarsfalt`. Resultatet skrivs som kopplingens
|
||||
senaste teststatus.
|
||||
parameters:
|
||||
- name: leverantor
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [identifierare]
|
||||
properties:
|
||||
identifierare:
|
||||
type: string
|
||||
description: VIN eller registreringsnummer beroende på leverantörens `nyckeltyp`.
|
||||
responses:
|
||||
"200":
|
||||
description: Fordonsuppgifter från leverantören.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
fordon:
|
||||
type: object
|
||||
additionalProperties: { type: string }
|
||||
"400": { $ref: "#/components/responses/Fel" }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"404":
|
||||
description: Kopplingen är inte konfigurerad för organisationen.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Fel" }
|
||||
"502":
|
||||
description: Leverantören svarade med fel eller inga kända fält.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Fel" }
|
||||
"503":
|
||||
description: Kryptering är inte konfigurerad.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Fel" }
|
||||
|
||||
/api/delad/{delningskod}/beslut:
|
||||
post:
|
||||
tags: [Delning]
|
||||
@@ -617,6 +797,10 @@ components:
|
||||
properties:
|
||||
error: { type: string }
|
||||
schemas:
|
||||
Fel:
|
||||
type: object
|
||||
properties:
|
||||
error: { type: string }
|
||||
Roll:
|
||||
type: string
|
||||
enum: [tekniker, arbetsledare, admin]
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
// PORT default 8080
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import crypto, { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import crypto, { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
@@ -33,6 +33,16 @@ const ECM_REGLER = readFileSync(
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// Register över märkesspecifika kopplingar — data, inte kod. Nya
|
||||
// leverantörer läggs till i filen (eller via ConfigMap-mount) utan att
|
||||
// applikationen byggs om.
|
||||
const INTEGRATIONER = JSON.parse(
|
||||
readFileSync(
|
||||
process.env.INTEGRATIONER_FIL ?? join(dirname(fileURLToPath(import.meta.url)), "integrationer.json"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 8080);
|
||||
const MAX_KROPP = 4 * 1024 * 1024;
|
||||
const TOKEN_LIVSTID_S = 12 * 60 * 60;
|
||||
@@ -84,6 +94,46 @@ export function verifieraJwt(token, hemlighet) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Integrationsuppgifter: kryptering i vila ------------------------
|
||||
//
|
||||
// Kundens egna leverantörsnycklar lagras krypterade med AES-256-GCM.
|
||||
// Nyckeln kommer ur INTEGRATION_NYCKEL (32 byte, hex eller base64) och
|
||||
// finns bara i driftens hemlighetshantering. Saknas nyckeln kan
|
||||
// integrationer varken sparas eller användas — fail closed.
|
||||
|
||||
function integrationsNyckel() {
|
||||
const ra = process.env.INTEGRATION_NYCKEL;
|
||||
if (!ra) return null;
|
||||
const buf = /^[0-9a-fA-F]{64}$/.test(ra) ? Buffer.from(ra, "hex") : Buffer.from(ra, "base64");
|
||||
return buf.length === 32 ? buf : null;
|
||||
}
|
||||
|
||||
export function kryptera(klartext, nyckel) {
|
||||
const iv = randomBytes(12);
|
||||
const chiffer = createCipheriv("aes-256-gcm", nyckel, iv);
|
||||
const data = Buffer.concat([chiffer.update(klartext, "utf8"), chiffer.final()]);
|
||||
return `${iv.toString("base64")}.${chiffer.getAuthTag().toString("base64")}.${data.toString("base64")}`;
|
||||
}
|
||||
|
||||
export function dekryptera(paket, nyckel) {
|
||||
const [iv, tagg, data] = paket.split(".");
|
||||
const dechiffer = createDecipheriv("aes-256-gcm", nyckel, Buffer.from(iv, "base64"));
|
||||
dechiffer.setAuthTag(Buffer.from(tagg, "base64"));
|
||||
return Buffer.concat([dechiffer.update(Buffer.from(data, "base64")), dechiffer.final()]).toString("utf8");
|
||||
}
|
||||
|
||||
// Hemliga fält lämnar aldrig servern i klartext — klienten ser bara att
|
||||
// ett värde finns och dess sista tecken.
|
||||
export function maskera(varde) {
|
||||
if (typeof varde !== "string" || varde.length === 0) return "";
|
||||
if (varde.length <= 4) return "••••";
|
||||
return `••••${varde.slice(-4)}`;
|
||||
}
|
||||
|
||||
function leverantorsDef(id) {
|
||||
return INTEGRATIONER.leverantorer.find((l) => l.id === id);
|
||||
}
|
||||
|
||||
// ---- Hjälpare ---------------------------------------------------------
|
||||
|
||||
function svara(res, status, kropp) {
|
||||
@@ -127,6 +177,44 @@ async function arendeIOrg(arendeId, organisationId) {
|
||||
return rader.rowCount > 0;
|
||||
}
|
||||
|
||||
// Generiskt uppslag mot en leverantör. All variation ligger i registret
|
||||
// (URL-mall, autentiseringstyp, svarsmappning) — inga leverantörs-
|
||||
// specifika kodgrenar.
|
||||
export async function gorUppslag(def, uppgifter, identifierare, hamtare = fetch) {
|
||||
const u = def.uppslag ?? {};
|
||||
const mall = uppgifter[u.urlFalt ?? "bas_url"];
|
||||
if (typeof mall !== "string" || !/^https?:\/\//.test(mall)) {
|
||||
return { ok: false, fel: "Bas-URL saknas eller är ogiltig." };
|
||||
}
|
||||
let url = mall.replace(/\{vin\}/gi, encodeURIComponent(identifierare)).replace(/\{regnr\}/gi, encodeURIComponent(identifierare));
|
||||
const headers = { Accept: "application/json" };
|
||||
|
||||
if (u.auth === "bearer") headers.Authorization = `Bearer ${uppgifter[u.authFalt]}`;
|
||||
if (u.auth === "header") headers[u.authHeader ?? "X-Api-Key"] = uppgifter[u.authFalt];
|
||||
if (u.auth === "basic") {
|
||||
const par = `${uppgifter[u.authFalt]}:${uppgifter[u.authFalt2]}`;
|
||||
headers.Authorization = `Basic ${Buffer.from(par).toString("base64")}`;
|
||||
}
|
||||
if (u.auth === "query") {
|
||||
url += `${url.includes("?") ? "&" : "?"}${encodeURIComponent(u.authParam ?? "key")}=${encodeURIComponent(uppgifter[u.authFalt])}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const svarFran = await hamtare(url, { headers, signal: AbortSignal.timeout(10_000) });
|
||||
if (!svarFran.ok) return { ok: false, fel: `Leverantören svarade ${svarFran.status}.` };
|
||||
const data = await svarFran.json();
|
||||
const fordon = {};
|
||||
for (const [vart, deras] of Object.entries(u.svarsfalt ?? {})) {
|
||||
const varde = deras.split(".").reduce((niva, del) => (niva == null ? niva : niva[del]), data);
|
||||
if (varde !== undefined && varde !== null && `${varde}`.trim()) fordon[vart] = `${varde}`.trim();
|
||||
}
|
||||
if (Object.keys(fordon).length === 0) return { ok: false, fel: "Leverantören returnerade inga kända fält." };
|
||||
return { ok: true, fordon };
|
||||
} catch (fel) {
|
||||
return { ok: false, fel: fel?.name === "TimeoutError" ? "Leverantören svarade inte i tid." : "Anropet misslyckades." };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Server -----------------------------------------------------------
|
||||
|
||||
export function skapaServer() {
|
||||
@@ -364,6 +452,135 @@ export function skapaServer() {
|
||||
return res.end(ECM_REGLER);
|
||||
}
|
||||
|
||||
// -- Märkesspecifika kopplingar (integrationer) --
|
||||
// Registret läses av alla inloggade (så inställningssidan kan visa
|
||||
// vilka leverantörer som finns); uppgifterna hanteras endast av
|
||||
// systemadministratören och returneras alltid maskerade.
|
||||
if (req.method === "GET" && vag === "/api/integrationer/leverantorer") {
|
||||
return svara(res, 200, INTEGRATIONER);
|
||||
}
|
||||
|
||||
if (vag === "/api/integrationer") {
|
||||
if (req.method === "GET") {
|
||||
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
|
||||
const nyckel = integrationsNyckel();
|
||||
const rader = await pool.query(
|
||||
`select leverantor, uppgifter_krypt, aktiv, uppdaterad, senast_testad, senaste_status
|
||||
from integrationer where organisation_id = $1 order by leverantor`,
|
||||
[anspr.org],
|
||||
);
|
||||
const integrationer = rader.rows.map((rad) => {
|
||||
const def = leverantorsDef(rad.leverantor);
|
||||
let uppgifter = {};
|
||||
try {
|
||||
if (nyckel) uppgifter = JSON.parse(dekryptera(rad.uppgifter_krypt, nyckel));
|
||||
} catch {
|
||||
// Fel nyckel eller manipulerad rad — visa inga värden.
|
||||
}
|
||||
const maskerade = {};
|
||||
for (const falt of def?.falt ?? []) {
|
||||
const varde = uppgifter[falt.nyckel];
|
||||
maskerade[falt.nyckel] = falt.hemlig ? maskera(varde) : (varde ?? "");
|
||||
}
|
||||
return {
|
||||
leverantor: rad.leverantor,
|
||||
namn: def?.namn ?? rad.leverantor,
|
||||
aktiv: rad.aktiv,
|
||||
uppdaterad: rad.uppdaterad,
|
||||
senast_testad: rad.senast_testad,
|
||||
senaste_status: rad.senaste_status,
|
||||
uppgifter: maskerade,
|
||||
};
|
||||
});
|
||||
return svara(res, 200, { integrationer, krypteringKonfigurerad: !!nyckel });
|
||||
}
|
||||
if (req.method === "POST") {
|
||||
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
|
||||
const nyckel = integrationsNyckel();
|
||||
if (!nyckel) {
|
||||
return svara(res, 503, {
|
||||
error: "Kryptering är inte konfigurerad (INTEGRATION_NYCKEL saknas) — uppgifter kan inte sparas.",
|
||||
});
|
||||
}
|
||||
const { leverantor, uppgifter, aktiv } = await lasKropp(req);
|
||||
const def = leverantorsDef(leverantor);
|
||||
if (!def) return svara(res, 400, { error: "Okänd leverantör." });
|
||||
if (!uppgifter || typeof uppgifter !== "object") {
|
||||
return svara(res, 400, { error: "Uppgifter saknas." });
|
||||
}
|
||||
// Endast leverantörens definierade fält sparas, och varje fält
|
||||
// måste ha ett värde — inga tomma nycklar i vila.
|
||||
const rena = {};
|
||||
for (const falt of def.falt) {
|
||||
const varde = uppgifter[falt.nyckel];
|
||||
if (typeof varde !== "string" || !varde.trim()) {
|
||||
return svara(res, 400, { error: `Fältet "${falt.etikett}" måste fyllas i.` });
|
||||
}
|
||||
if (varde.length > 2000) return svara(res, 400, { error: "Ett värde är för långt." });
|
||||
rena[falt.nyckel] = varde.trim();
|
||||
}
|
||||
await pool.query(
|
||||
`insert into integrationer (organisation_id, leverantor, uppgifter_krypt, aktiv)
|
||||
values ($1, $2, $3, $4)
|
||||
on conflict (organisation_id, leverantor)
|
||||
do update set uppgifter_krypt = excluded.uppgifter_krypt,
|
||||
aktiv = excluded.aktiv,
|
||||
uppdaterad = now(),
|
||||
senast_testad = null,
|
||||
senaste_status = null`,
|
||||
[anspr.org, leverantor, kryptera(JSON.stringify(rena), nyckel), aktiv !== false],
|
||||
);
|
||||
return svara(res, 200, { ok: true });
|
||||
}
|
||||
}
|
||||
|
||||
const integrationVag = vag.match(/^\/api\/integrationer\/([a-z0-9_]+)$/);
|
||||
if (req.method === "DELETE" && integrationVag) {
|
||||
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
|
||||
await pool.query(`delete from integrationer where organisation_id = $1 and leverantor = $2`, [
|
||||
anspr.org,
|
||||
integrationVag[1],
|
||||
]);
|
||||
return svara(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
// Uppslag mot märkesspecifik koppling. Anropet görs alltid av
|
||||
// servern — kundens leverantörsnycklar når aldrig webbläsaren.
|
||||
const uppslagVag = vag.match(/^\/api\/integrationer\/([a-z0-9_]+)\/uppslag$/);
|
||||
if (req.method === "POST" && uppslagVag) {
|
||||
const nyckel = integrationsNyckel();
|
||||
const def = leverantorsDef(uppslagVag[1]);
|
||||
if (!def) return svara(res, 400, { error: "Okänd leverantör." });
|
||||
if (!nyckel) return svara(res, 503, { error: "Kryptering är inte konfigurerad." });
|
||||
|
||||
const { identifierare } = await lasKropp(req);
|
||||
if (typeof identifierare !== "string" || !/^[A-Za-z0-9-]{4,20}$/.test(identifierare.trim())) {
|
||||
return svara(res, 400, { error: "Ogiltig identifierare." });
|
||||
}
|
||||
const rad = await pool.query(
|
||||
`select uppgifter_krypt from integrationer
|
||||
where organisation_id = $1 and leverantor = $2 and aktiv = true`,
|
||||
[anspr.org, uppslagVag[1]],
|
||||
);
|
||||
if (rad.rowCount === 0) return svara(res, 404, { error: "Kopplingen är inte konfigurerad." });
|
||||
|
||||
let uppgifter;
|
||||
try {
|
||||
uppgifter = JSON.parse(dekryptera(rad.rows[0].uppgifter_krypt, nyckel));
|
||||
} catch {
|
||||
return svara(res, 500, { error: "Uppgifterna kunde inte läsas — spara om kopplingen." });
|
||||
}
|
||||
|
||||
const resultat = await gorUppslag(def, uppgifter, identifierare.trim().toUpperCase());
|
||||
await pool.query(
|
||||
`update integrationer set senast_testad = now(), senaste_status = $3
|
||||
where organisation_id = $1 and leverantor = $2`,
|
||||
[anspr.org, uppslagVag[1], resultat.ok ? "ok" : `fel: ${resultat.fel}`.slice(0, 200)],
|
||||
);
|
||||
if (!resultat.ok) return svara(res, 502, { error: resultat.fel });
|
||||
return svara(res, 200, { fordon: resultat.fordon });
|
||||
}
|
||||
|
||||
// Organisationens inställningar: vad som visas när ett ärende
|
||||
// startas (objekttyper, identifieringsmetoder). Alla inloggade
|
||||
// läser; endast systemadministratören ändrar.
|
||||
|
||||
Reference in New Issue
Block a user