diff --git a/.gitea/workflows/felsokning.yml b/.gitea/workflows/felsokning.yml index b772e4b..d10c1af 100644 --- a/.gitea/workflows/felsokning.yml +++ b/.gitea/workflows/felsokning.yml @@ -66,10 +66,52 @@ jobs: - run: terraform init -backend=false -input=false - run: terraform validate + # Leverantörskedjan. 53 direkta klientberoenden utan SBOM eller + # skanning var ett fynd i revisionen (QUALITET m-5): de flesta + # OEM-baslinjer för leverantörssäkerhet kräver båda. + leverantorskedja: + runs-on: felsokning + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: 22 } + - run: npm ci --no-audit --no-fund + working-directory: felsokning/app + # Kända sårbarheter stoppar bygget från "high" och uppåt. Lägre + # nivåer rapporteras men fäller inte — annars slutar folk läsa. + - run: npm audit --audit-level=high + working-directory: felsokning/app + - run: npm audit + working-directory: felsokning/app + continue-on-error: true + # SBOM i CycloneDX, arkiverad per bygge. Utan den går det inte att + # svara på "påverkas vi av den här sårbarheten" utan att gissa. + - run: npx --yes @cyclonedx/cyclonedx-npm@latest --output-file sbom-klient.json + working-directory: felsokning/app + - run: npm install --no-audit --no-fund && npx --yes @cyclonedx/cyclonedx-npm@latest --output-file ../sbom-plattform.json + working-directory: felsokning/services/plattform + - uses: actions/upload-artifact@v4 + with: + name: sbom-${{ gitea.sha }} + path: | + felsokning/app/sbom-klient.json + felsokning/services/sbom-plattform.json + retention-days: 400 + + # M-5: en säkerhetskopia som aldrig återställts är en förhoppning. + # Larmet visar att backup sker, inte att den går att återställa. + aterstallning: + runs-on: felsokning + steps: + - uses: actions/checkout@v4 + - run: sudo apt-get update && sudo apt-get install -y postgresql + - run: sudo bash aterstallningstest.sh + working-directory: felsokning/services/plattform + # Bygger och publicerar till vårt eget ECR. Bara på main och bara när # kontrollerna ovan gått igenom. publicera: - needs: [test-och-bygg, tjanster, terraform] + needs: [test-och-bygg, tjanster, terraform, leverantorskedja, aterstallning] if: gitea.ref == 'refs/heads/main' runs-on: felsokning permissions: diff --git a/felsokning/app/src/felsokning/__tests__/bildmetadata.test.ts b/felsokning/app/src/felsokning/__tests__/bildmetadata.test.ts new file mode 100644 index 0000000..9e044b8 --- /dev/null +++ b/felsokning/app/src/felsokning/__tests__/bildmetadata.test.ts @@ -0,0 +1,40 @@ +// EXIF-borttagningen är en säkerhetsegenskap, inte en biprodukt. +// +// Omritningen via canvas i skalaNerFoto tar bort all metadata, inklusive +// GPS-position. Egenskapen höll tidigare av en slump (QUALITY-AUDIT m-3): +// ingen hade skrivit ned den, och en ändring i stil med "bevara +// originalkvaliteten" hade tyst börjat publicera var kundens bil stod, i +// en vy som delas med försäkringsbolag och partners. +// +// Testet läser källkoden i stället för att köra funktionen, eftersom det +// som ska skyddas är *vägen*: att bilden går genom en omritning och inte +// vidarebefordras som originalbytes. Ett test som bara körde funktionen i +// jsdom hade inte fångat en ändring till `resolve(await fil.text())`. +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const KÄLLA = readFileSync("src/felsokning/format.ts", "utf8"); + +describe("foton bär ingen metadata vidare", () => { + it("bilden ritas om via canvas — det är det som tar bort EXIF och GPS", () => { + const kropp = KÄLLA.slice(KÄLLA.indexOf("export function skalaNerFoto")); + expect(kropp).toContain("createElement(\"canvas\")"); + expect(kropp).toContain("drawImage"); + // toDataURL på en canvas kan bara producera pixeldata. Returneras + // något annat har omritningen kringgåtts. + expect(kropp).toContain("canvas.toDataURL"); + }); + + it("originalfilens bytes lämnas aldrig vidare orörda", () => { + const kropp = KÄLLA.slice(KÄLLA.indexOf("export function skalaNerFoto")); + // readAsDataURL används för att *läsa in* bilden i ett Image-objekt, + // aldrig som det som returneras. + expect(kropp).not.toMatch(/resolve\(\s*lasare\.result/); + expect(kropp).not.toMatch(/resolve\(\s*await fil\./); + }); + + it("egenskapen är dokumenterad så nästa ändring inte tappar den tyst", () => { + expect(KÄLLA).toMatch(/EXIF/); + expect(KÄLLA).toMatch(/GPS/); + }); +}); diff --git a/felsokning/app/src/felsokning/__tests__/dataskydd.test.ts b/felsokning/app/src/felsokning/__tests__/dataskydd.test.ts new file mode 100644 index 0000000..c3d8b54 --- /dev/null +++ b/felsokning/app/src/felsokning/__tests__/dataskydd.test.ts @@ -0,0 +1,113 @@ +// @vitest-environment node +// Dataskydd, spårbara mätvärden och den egenskap som hittills hållit av +// en slump. +import { describe, expect, it } from "vitest"; +import { + GALLRING_MANADER, + IDENTIFIERANDE, + MASKERAT, + gallringsdatum, + skydda, + skyddaHändelse, + öppna, + öppnaHändelse, +} from "../../../../services/gemensam/personuppgifter.mjs"; +import { kalibreradVid } from "../ecm"; + +const NYCKEL = Buffer.alloc(32, 7); +const ANNAN = Buffer.alloc(32, 9); +const nycklar = (poster: [string, Buffer][]) => new Map(poster); + +describe("C-3 · krypto-shredding gör append-only och radering förenliga", () => { + it("skyddar och öppnar ett värde med rätt nyckel", () => { + const skyddat = skydda("ABC123", "n1", NYCKEL); + expect(skyddat).not.toContain("ABC123"); + expect(öppna(skyddat, nycklar([["n1", NYCKEL]]))).toBe("ABC123"); + }); + + it("en förstörd nyckel maskerar värdet i stället för att kasta", () => { + // Det normala utfallet efter en radering — inte ett fel. Ett kastat + // undantag skulle göra hela ärendet oläsbart, vilket vore precis + // fel: resten av bevisningen ska överleva. + const skyddat = skydda("ABC123", "n1", NYCKEL); + expect(öppna(skyddat, nycklar([]))).toBe(MASKERAT); + }); + + it("fel nyckel avslöjas av autentiseringstaggen", () => { + const skyddat = skydda("ABC123", "n1", NYCKEL); + expect(öppna(skyddat, nycklar([["n1", ANNAN]]))).toBe(MASKERAT); + }); + + it("manipulerad chiffertext maskeras — den öppnas aldrig delvis", () => { + const skyddat = skydda("ABC123", "n1", NYCKEL); + const manipulerat = `${skyddat.slice(0, -4)}AAAA`; + expect(öppna(manipulerat, nycklar([["n1", NYCKEL]]))).toBe(MASKERAT); + }); + + it("registreringsnummer och VIN behandlas som personuppgift", () => { + // Ett fordon pekar i praktiken ut en ägare, och här finns kopplingen. + expect(IDENTIFIERANDE.arbetsorder_skannad).toContain("fordon_regnr"); + expect(IDENTIFIERANDE.arbetsorder_skannad).toContain("fordon_vin"); + expect(IDENTIFIERANDE.objekt_identifierat).toContain("identifierare"); + }); + + it("skyddar arbetsorderns identifierande fält men lämnar resten", () => { + const h = skyddaHändelse( + { + typ: "arbetsorder_skannad", + falt: [ + { id: "kund_namn", varde: "Anders Svensson" }, + { id: "fordon_regnr", varde: "ABC123" }, + { id: "felbeskrivning", varde: "Vibrerar vid 88 km/h" }, + ], + }, + "n1", + NYCKEL, + ); + const värden = Object.fromEntries(h.falt.map((f: { id: string; varde: string }) => [f.id, f.varde])); + expect(värden.kund_namn).not.toContain("Anders"); + expect(värden.fordon_regnr).not.toContain("ABC123"); + // Felbeskrivningen är verksamhetsdata — den ska överleva raderingen, + // annars raderas beviset tillsammans med personuppgiften. + expect(värden.felbeskrivning).toBe("Vibrerar vid 88 km/h"); + }); + + it("bevisningen överlever raderingen — bara identifieringen försvinner", () => { + const skyddad = skyddaHändelse( + { typ: "objekt_identifierat", objekt: { identifierare: "ABC123", beskrivning: "Volvo XC60" } }, + "n1", + NYCKEL, + ); + const efterRadering = öppnaHändelse(skyddad, nycklar([])); + expect(efterRadering.objekt.identifierare).toBe(MASKERAT); + expect(efterRadering.objekt.beskrivning).toBe("Volvo XC60"); + }); + + it("händelser utan identifierande fält passerar orörda", () => { + const h = { typ: "matvarde", beskrivning: "Lufttryck", varde: "2,4" }; + expect(skyddaHändelse(h, "n1", NYCKEL)).toEqual(h); + }); + + it("gallringstiden följer ärendetypen, och okänd typ får standardtiden", () => { + const avslut = "2026-01-15T10:00:00.000Z"; + const garanti = gallringsdatum("Garanti", avslut); + const okand = gallringsdatum("Hittepå", avslut); + expect(garanti.getFullYear()).toBe(2036); + expect(okand.getFullYear()).toBe(2029); + // Reklamation följer konsumentköplagens reklamationsrätt. + expect(GALLRING_MANADER.Reklamation).toBe(36); + }); +}); + +describe("M-1 · mätvärden är spårbara eller nedgraderade", () => { + it("kalibreringen bedöms vid mättillfället, inte i dag", () => { + // Ett instrument vars kalibrering gått ut i efterhand gör inte en + // gammal mätning ogiltig. + expect(kalibreradVid("2026-06-30", "2026-01-15T10:00:00.000Z")).toBe(true); + // Och ett nyligen kalibrerat instrument räddar inte en mätning som + // gjordes när kalibreringen var utgången. + expect(kalibreradVid("2026-06-30", "2026-08-15T10:00:00.000Z")).toBe(false); + expect(kalibreradVid(undefined, "2026-01-15T10:00:00.000Z")).toBe(false); + expect(kalibreradVid("inte-ett-datum", "2026-01-15T10:00:00.000Z")).toBe(false); + }); +}); diff --git a/felsokning/app/src/felsokning/__tests__/ecm.test.ts b/felsokning/app/src/felsokning/__tests__/ecm.test.ts index 027971a..89c643b 100644 --- a/felsokning/app/src/felsokning/__tests__/ecm.test.ts +++ b/felsokning/app/src/felsokning/__tests__/ecm.test.ts @@ -51,13 +51,29 @@ describe("Evidence Engine", () => { expect(evidensNiva(byggArende([]))).toBe("E0"); expect(evidensNiva(byggArende([{ typ: "observation", text: "x" }]))).toBe("E1"); expect(evidensNiva(byggArende([{ typ: "foto", beskrivning: "x", dataUrl: "data:" }]))).toBe("E2"); - expect(evidensNiva(byggArende([{ typ: "matvarde", beskrivning: "U", varde: "12" }]))).toBe("E4"); + // Utan spårbart instrument är ett mätvärde teknikerns observation + // av en siffra, inte en mätning (QUALITY-AUDIT M-1). + expect(evidensNiva(byggArende([{ typ: "matvarde", beskrivning: "U", varde: "12" }]))).toBe("E1"); + expect( + evidensNiva( + byggArende([ + { + typ: "matvarde", + beskrivning: "U", + varde: "12", + matdonId: "m1", + matdonBeteckning: "Fluke 87V", + matdonKalibreradTill: "2099-01-01", + }, + ]), + ), + ).toBe("E4"); expect(evidensNiva(byggArende([{ typ: "arbetsorder_skannad", falt: [] }]))).toBe("E5"); expect( evidensNiva( byggArende([ { typ: "foto", beskrivning: "x", dataUrl: "data:" }, - { typ: "matvarde", beskrivning: "U", varde: "12" }, + { typ: "matvarde", beskrivning: "U", varde: "12", matdonId: "m1", matdonBeteckning: "Fluke 87V", matdonKalibreradTill: "2099-01-01" }, ]), ), ).toBe("E6"); @@ -71,7 +87,7 @@ describe("Evidence Engine", () => { const poster = evidensposter(arende); expect(poster).toHaveLength(2); expect(poster[0].niva).toBe("E2"); - expect(poster[1].sammanfattning).toBe("Obalans = 38 g"); + expect(poster[1].sammanfattning).toBe("Obalans = 38 g (instrument ej angivet)"); expect(poster[0].tekniker).toBe("Anna"); expect(poster[0].hash).toMatch(/^[0-9a-f]{8}$/); // Samma innehåll → samma hash (deterministisk spårbarhet). @@ -343,9 +359,17 @@ describe("Videoevidens (E3) och signerat avslut", () => { expect(evidensposter(medVideo)[0].kategori).toBe("video"); const tvaKallor = byggArende([ { typ: "video", beskrivning: "Motorljud", dataUrl: "data:video/webm;base64,x" }, - { typ: "matvarde", beskrivning: "Oljetryck", varde: "3,1", enhet: "bar" }, + { typ: "matvarde", beskrivning: "Oljetryck", varde: "3,1", enhet: "bar", matdonId: "m1", matdonBeteckning: "Fluke 87V", matdonKalibreradTill: "2099-01-01" }, ]); expect(evidensNiva(tvaKallor)).toBe("E6"); + + // Utan kalibrerat instrument är det inte två oberoende källor utan + // en: video plus en observation av en siffra. + const enKalla = byggArende([ + { typ: "video", beskrivning: "Motorljud", dataUrl: "data:video/webm;base64,x" }, + { typ: "matvarde", beskrivning: "Oljetryck", varde: "3,1", enhet: "bar" }, + ]); + expect(evidensNiva(enKalla)).toBe("E3"); }); it("underlagskällan Video valideras mot loggen i felorsaksanalysen", async () => { diff --git a/felsokning/app/src/felsokning/demo.ts b/felsokning/app/src/felsokning/demo.ts index 88fb58f..2ef4b06 100644 --- a/felsokning/app/src/felsokning/demo.ts +++ b/felsokning/app/src/felsokning/demo.ts @@ -48,6 +48,21 @@ function demoFoto(text: string): string { } } +// Mätdon i demot. Ett mätvärde är E4 bara när det går att visa vad som +// mätte och att instrumentet var kalibrerat vid mättillfället — annars är +// det teknikerns observation av en siffra (QUALITY-AUDIT M-1). Demot ska +// visa den praxis produkten kräver, inte den som fanns innan. +const DACKTRYCKSMATARE = { + matdonId: "demo-tryck-01", + matdonBeteckning: "Däcktrycksmätare PCL DPG7", + matdonKalibreradTill: "2027-03-31", +}; +const BALANSMASKIN = { + matdonId: "demo-balans-01", + matdonBeteckning: "Balanseringsmaskin Hunter Road Force", + matdonKalibreradTill: "2027-01-15", +}; + export function byggDemoArende(nummer: number, anvandare = "Anna"): Arende { const start = Date.now() - 97 * 60000; // 1 tim 37 min sedan const vid = (minuter: number) => new Date(start + minuter * 60000).toISOString(); @@ -90,8 +105,8 @@ export function byggDemoArende(nummer: number, anvandare = "Anna"): Arende { post(22, { typ: "kontroll_utford", stegId: "visuell", kontrollId: "dot", text: "Dokumentera DOT-/tillverkningsdatum", resultat: "Fram: DOT 2318, bak: DOT 2318 — samtliga från vecka 23 2018" }), post(26, { typ: "observation", text: "Höger framdäck visar ojämnt slitage på innerkanten" }), post(26, { typ: "kontroll_utford", stegId: "visuell", kontrollId: "slitage", text: "Kontrollera däckslitage", resultat: "Ojämnt slitage höger fram, övriga ok" }), - post(31, { typ: "matvarde", beskrivning: "Lufttryck vänster fram", varde: "2,4", enhet: "bar" }), - post(32, { typ: "matvarde", beskrivning: "Lufttryck höger fram", varde: "2,1", enhet: "bar" }), + post(31, { typ: "matvarde", beskrivning: "Lufttryck vänster fram", varde: "2,4", enhet: "bar", ...DACKTRYCKSMATARE }), + post(32, { typ: "matvarde", beskrivning: "Lufttryck höger fram", varde: "2,1", enhet: "bar", ...DACKTRYCKSMATARE }), post(33, { typ: "kontroll_utford", stegId: "kontroller", kontrollId: "lufttryck", text: "Kontrollera lufttryck", resultat: "VF 2,4 / HF 2,1 / VB 2,4 / HB 2,4 bar — justerat till 2,4 runtom" }), post(38, { typ: "kontroll_utford", stegId: "kontroller", kontrollId: "hjulmoment", text: "Kontrollera hjulmoment", resultat: "140 Nm samtliga hjul, ok" }), post(40, { typ: "hypotes", text: "Obalans eller kast i höger framhjul", niva: "lag" }), @@ -102,7 +117,7 @@ export function byggDemoArende(nummer: number, anvandare = "Anna"): Arende { post(58, { typ: "kontroll_utford", stegId: "provkorning", kontrollId: "pk_var", text: "Om vibration känns i ratt eller kaross", resultat: "Endast i ratten" }), post(60, { typ: "kategori_byte", kategori: "aktiv_felsokning" }), post(75, { typ: "inaktivitet_forklarad", text: "Lyfte bilen och demonterade höger framhjul för kontroll i balanseringsmaskin.", minuter: 15 }), - post(82, { typ: "matvarde", beskrivning: "Obalans höger framhjul", varde: "38", enhet: "g" }), + post(82, { typ: "matvarde", beskrivning: "Obalans höger framhjul", varde: "38", enhet: "g", ...BALANSMASKIN }), post(83, { typ: "kontroll_utford", stegId: "kontroller", kontrollId: "balansering", text: "Kontrollera hjulbalansering", resultat: "38 g obalans höger fram — en balansvikt saknas. Övriga hjul inom 5 g." }), post(85, { typ: "observation", text: "Spår efter lossnad klistervikt på höger framfälgs insida" }), post(85, { diff --git a/felsokning/app/src/felsokning/domain.ts b/felsokning/app/src/felsokning/domain.ts index dda390c..c90c0e1 100644 --- a/felsokning/app/src/felsokning/domain.ts +++ b/felsokning/app/src/felsokning/domain.ts @@ -96,7 +96,22 @@ export type Handelse = // för evidens. Kontrollen räknas som hanterad men flaggas i brief/rapport. | { typ: "kontroll_utford"; stegId: string; kontrollId: string; text: string; resultat?: string; undantag?: string } | { typ: "observation"; text: string } - | { typ: "matvarde"; beskrivning: string; varde: string; enhet?: string } + | { + typ: "matvarde"; + beskrivning: string; + varde: string; + enhet?: string; + /** + * Vilket mätdon som användes. Ett mätvärde rankas som E4 — hög + * evidens — och utan kalibrerat instrument är det inte lägre + * evidens utan ingen evidens alls (QUALITY-AUDIT M-1). Fältet är + * valfritt i typen därför att loggen är append-only och äldre + * poster saknar det; evidensmotorn nedgraderar dem i stället. + */ + matdonId?: string; + matdonBeteckning?: string; + matdonKalibreradTill?: string; + } | { typ: "hypotes"; text: string; niva: Exclude } | ({ typ: "foto"; beskrivning: string } & Bilaga) // Video med ljud (E3-evidens): för det som låter eller rör sig — diff --git a/felsokning/app/src/felsokning/ecm.ts b/felsokning/app/src/felsokning/ecm.ts index 2729648..fba8ebe 100644 --- a/felsokning/app/src/felsokning/ecm.ts +++ b/felsokning/app/src/felsokning/ecm.ts @@ -62,6 +62,21 @@ export function innehallsHash(innehall: string): string { } // Katalogiserar varje evidenspost i loggen med nivå, kategori och hash. +/** + * Var instrumentet kalibrerat när mätningen gjordes? + * + * Frågan gäller mättillfället, inte i dag. Ett instrument vars + * kalibrering gått ut i efterhand gör inte en gammal mätning ogiltig — + * och ett nyligen kalibrerat instrument räddar inte en mätning som + * gjordes när kalibreringen var utgången. + */ +export function kalibreradVid(kalibreradTill: string | undefined, matt: string): boolean { + if (!kalibreradTill) return false; + const giltigt = Date.parse(kalibreradTill); + const tidpunkt = Date.parse(matt); + return Number.isFinite(giltigt) && Number.isFinite(tidpunkt) && tidpunkt <= giltigt; +} + export function evidensposter(arende: Arende): Evidenspost[] { const poster: Evidenspost[] = []; const lagg = (post: LoggPost, kategori: string, niva: Evidenspost["niva"], sammanfattning: string) => @@ -78,7 +93,25 @@ export function evidensposter(arende: Arende): Evidenspost[] { const h = post.handelse; if (h.typ === "foto") lagg(post, "foto", "E2", h.beskrivning); if (h.typ === "video") lagg(post, "video", "E3", h.beskrivning); - if (h.typ === "matvarde") lagg(post, "mätvärde", "E4", `${h.beskrivning} = ${h.varde}${h.enhet ? ` ${h.enhet}` : ""}`); + if (h.typ === "matvarde") { + // Ett mätvärde är E4 bara när det går att visa vad som mätte och + // att instrumentet var kalibrerat vid mättillfället. Utan det är + // det teknikerns observation av en siffra — E1 (QUALITY-AUDIT M-1). + // + // Nedgraderingen är tyst i loggen men syns i rapporten: att låtsas + // att en okalibrerad mätning väger som en kalibrerad är precis den + // sortens obefogade säkerhet produkten finns för att undvika. + const spårbar = Boolean(h.matdonId) && kalibreradVid(h.matdonKalibreradTill, post.tidpunkt); + const märkning = h.matdonBeteckning + ? `${h.matdonBeteckning}${spårbar ? "" : " — kalibrering saknas eller utgången"}` + : "instrument ej angivet"; + lagg( + post, + "mätvärde", + spårbar ? "E4" : "E1", + `${h.beskrivning} = ${h.varde}${h.enhet ? ` ${h.enhet}` : ""} (${märkning})`, + ); + } if (h.typ === "matarstallning" && !h.undantag) lagg(post, "mätarställning", "E2", `${h.lage === "ingaende" ? "In" : "Ut"}: ${h.varde}`); if (h.typ === "arbetsorder_skannad") lagg(post, "dokument", "E5", `Arbetsorder, ${h.falt.length} fält`); if (h.typ === "observation") lagg(post, "observation", "E1", h.text); diff --git a/felsokning/app/src/felsokning/format.ts b/felsokning/app/src/felsokning/format.ts index 5f34dc5..ebd2893 100644 --- a/felsokning/app/src/felsokning/format.ts +++ b/felsokning/app/src/felsokning/format.ts @@ -8,7 +8,21 @@ export function tidDatum(iso: string): string { return new Date(iso).toLocaleDateString("sv-SE"); } -// Skalar ner ett foto innan det loggas, så att händelseloggen förblir hanterbar. +/** + * Skalar ner ett foto innan det loggas, så att händelseloggen förblir + * hanterbar. + * + * **Omritningen via canvas är också det som tar bort EXIF — inklusive + * GPS-position.** Den egenskapen höll tidigare av en slump + * (QUALITY-AUDIT m-3): ingen hade skrivit ned den, och en framtida + * ändring i stil med "bevara originalkvaliteten" hade tyst börjat + * publicera var kundens bil stod när fotot togs, i en vy som delas med + * försäkringsbolag och partners. + * + * Egenskapen är nu avsiktlig och låst av test. Den som byter ut + * canvas-vägen mot något som bevarar originalbytes måste ta bort + * metadata på annat sätt först. + */ export function skalaNerFoto(fil: File, maxSida = 1024): Promise { return new Promise((resolve, reject) => { const lasare = new FileReader(); diff --git a/felsokning/infra/postgres-init.sql b/felsokning/infra/postgres-init.sql index a4841b6..d46cc5b 100644 --- a/felsokning/infra/postgres-init.sql +++ b/felsokning/infra/postgres-init.sql @@ -144,3 +144,91 @@ create table if not exists delningar ( aterkallad timestamptz ); create index if not exists delningar_arende_idx on delningar (arende_id); + +-- ---- Personuppgifter och gallring ------------------------------------- +-- +-- Append-only och rätten till radering (dataskyddsförordningen art. 17) +-- är inte oförenliga, men de måste förenas medvetet. Krypto-shredding: +-- identifierande fält i loggen krypteras med en nyckel per registrerad, +-- och radering sker genom att nyckeln förstörs. +-- +-- Loggen förblir intakt och hashverifierbar. Det som blir oåtkomligt är +-- identifieringen, inte protokollet över vad som kontrollerades — ett +-- raderat ärende kan fortfarande visa att lufttrycket mättes till +-- 2,4 bar klockan 08:42, bara inte längre vems bil det gällde. +-- +-- Tabellen är det enda stället i schemat där en rad FÅR försvinna, och +-- det är hela poängen med den. +create table if not exists personnycklar ( + id uuid primary key default gen_random_uuid(), + organisation_id uuid not null references organisationer(id), + -- Vad nyckeln skyddar: normalt ett fordon (regnr/VIN) eller en kund. + subjekt text not null, + nyckel bytea not null, + skapad timestamptz not null default now(), + -- Sätts när radering begärts men nyckeln ännu inte förstörts, så att + -- begäran syns i systemet även innan den verkställts. + radering_begard timestamptz, + unique (organisation_id, subjekt) +); + +-- Radering loggas. Att en begäran verkställts måste gå att visa i +-- efterhand, och den loggen får själv inte innehålla personuppgiften. +create table if not exists raderingar ( + id uuid primary key default gen_random_uuid(), + organisation_id uuid not null references organisationer(id), + subjekt_hash text not null, + begard timestamptz not null, + verkstalld timestamptz not null default now(), + begard_av text not null, + antal_arenden integer not null default 0 +); + +-- Gallringsdatum per ärende, satt vid avslut utifrån ärendetypen. +-- Ett ärende utan datum gallras aldrig automatiskt — det försiktiga +-- utfallet, eftersom för tidig gallring inte går att ångra. +alter table felsokning_arenden add column if not exists gallras_efter timestamptz; + +-- Läslogg. Varje skrivning loggades redan; ingen läsning gjorde det. +-- Utan den går det inte att svara på vem som sett en kunds uppgifter, +-- vilket både artikel 32 och en verkstadschef vill kunna få svar på. +create table if not exists atkomstlogg ( + id bigserial primary key, + tidpunkt timestamptz not null default now(), + organisation_id uuid, + anvandare_id uuid, + arende_id text, + vag text not null, + kalla text, + -- Delningskod när åtkomsten skedde via en publik länk. + delningskod text +); +create index if not exists atkomstlogg_arende on atkomstlogg (arende_id, tidpunkt desc); +create index if not exists atkomstlogg_org on atkomstlogg (organisation_id, tidpunkt desc); + +-- Mätdon. Ett mätvärde rankas som hög evidens (E4); utan kalibrerat +-- instrument är det inte lägre evidens utan ingen evidens alls. +create table if not exists matdon ( + id uuid primary key default gen_random_uuid(), + organisation_id uuid not null references organisationer(id), + beteckning text not null, + serienummer text not null, + kalibrerad_till date, + aktiv boolean not null default true, + skapad timestamptz not null default now(), + unique (organisation_id, serienummer) +); + +-- Blindat index över fordonsidentifieraren. +-- +-- En raderingsbegäran gäller ett fordon eller en person, inte ett enskilt +-- ärende — men identifieraren är krypterad, så den går inte att söka på. +-- Indexet är en HMAC av den normaliserade identifieraren med en +-- servernyckel: samma fordon ger alltid samma värde, och värdet går inte +-- att vända tillbaka till ett registreringsnummer utan nyckeln. +-- +-- Det gör radering över hela fordonets historik möjlig utan att lagra +-- identifieraren i klartext någonstans. +alter table felsokning_arenden add column if not exists identifierare_index text; +create index if not exists felsokning_arenden_ident_idx + on felsokning_arenden (organisation_id, identifierare_index); diff --git a/felsokning/services/ai-orkester/server.mjs b/felsokning/services/ai-orkester/server.mjs index 4fcc398..881d49e 100644 --- a/felsokning/services/ai-orkester/server.mjs +++ b/felsokning/services/ai-orkester/server.mjs @@ -12,7 +12,7 @@ // PORT default 8080 import { createServer } from "node:http"; -import { createHmac, timingSafeEqual } from "node:crypto"; +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; import Anthropic from "@anthropic-ai/sdk"; import { avsluta, logga, mätvärde, spårFrån, starta } from "./observation.mjs"; @@ -250,6 +250,17 @@ export function verifieraJwt(token, hemlighet) { } } + +/** + * Kort, stabil identitet för den instruktion som gav svaret: modell, + * effort och systempromptens innehåll. Ändras något av dem ändras + * versionen, och två svar går att jämföra utan att prompten sparas. + */ +function promptversion(konfig) { + const underlag = `${konfig.modell}|${konfig.effort ?? "-"}|${konfig.system}`; + return createHash("sha256").update(underlag).digest("hex").slice(0, 12); +} + function svara(res, status, kropp) { const data = JSON.stringify(kropp); res.writeHead(status, { @@ -306,10 +317,23 @@ export function skapaServer() { const auth = req.headers.authorization ?? ""; const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; - if (!token || !verifieraJwt(token, jwtHemlighet)) { + const anspr = token ? verifieraJwt(token, jwtHemlighet) : null; + if (!anspr) { return svara(res, 401, { error: "Inloggning krävs." }); } + // Organisationen kan ha stängt av modellanropen helt — normalt för + // att den inte kan acceptera överföringen av kund- och fordonsdata + // till modelleverantören (QUALITET C-4). Flaggan bärs i token, så + // beslutet fattas här och inte av klienten. Metodikmotorn fungerar + // ensam; produkten blir mindre hjälpsam, inte obrukbar. + if (anspr.ai === false) { + return svara(res, 403, { + error: "Organisationen har stängt av modellanropen. Metodiken guidar utan dem.", + avstangd: true, + }); + } + let uppgift, prompt, bild; try { ({ uppgift, prompt, bild } = await lasKropp(req)); @@ -380,7 +404,17 @@ export function skapaServer() { } const textBlock = svar.content.find((block) => block.type === "text"); if (!textBlock) return svara(res, 502, { error: "AI-svaret saknade innehåll." }); - return svara(res, 200, { modell: konfig.modell, svar: JSON.parse(textBlock.text) }); + return svara(res, 200, { + modell: konfig.modell, + // Modellen loggades redan; prompten gjorde det inte. Två + // identiska ärenden kunde därmed få olika vägledning utan att + // skillnaden gick att härleda i efterhand (QUALITET m-7). + // Hashen är kort med avsikt — den ska kunna stå i en loggrad och + // svara på "kördes de här två svaren mot samma instruktion?", + // inte återskapa prompten. + promptversion: promptversion(konfig), + svar: JSON.parse(textBlock.text), + }); } catch (fel) { logga("fel", "AI-anropet misslyckades", { uppgift, diff --git a/felsokning/services/gemensam/handelser.mjs b/felsokning/services/gemensam/handelser.mjs index 8bfcb9b..ba10392 100644 --- a/felsokning/services/gemensam/handelser.mjs +++ b/felsokning/services/gemensam/handelser.mjs @@ -33,7 +33,7 @@ export const HÄNDELSESCHEMA = { fraga_besvarad: { stegId: "text", frageId: "text", fraga: "text", svar: "text" }, kontroll_utford: { stegId: "text", kontrollId: "text", text: "text" }, observation: { text: "text" }, - matvarde: { beskrivning: "text", varde: "text" }, + matvarde: { beskrivning: "text", varde: "text" }, // matdonId valfritt — se M-1 hypotes: { text: "text", niva: ["medel", "lag"] }, foto: { beskrivning: "text" }, video: { beskrivning: "text" }, diff --git a/felsokning/services/gemensam/personuppgifter.mjs b/felsokning/services/gemensam/personuppgifter.mjs new file mode 100644 index 0000000..2161202 --- /dev/null +++ b/felsokning/services/gemensam/personuppgifter.mjs @@ -0,0 +1,150 @@ +// Personuppgifter i en append-only-logg. +// +// Revisionen (docs/QUALITY-AUDIT.md, C-3) fann att systemet inte kunde +// uppfylla artikel 17: databastriggern hindrar aktivt radering, och +// kundens namn, telefonnummer, e-post, registreringsnummer och VIN +// ligger i klartext i loggen. +// +// "Append-only" och "radering på begäran" är inte oförenliga, men de +// måste förenas medvetet. Lösningen här är krypto-shredding: +// +// Identifierande fält lagras krypterade med en nyckel som är unik per +// registrerad person. Radering sker genom att nyckeln förstörs. Loggen +// förblir intakt och hashverifierbar — det som blir oåtkomligt är +// identifieringen, inte protokollet över vad som kontrollerades. +// +// Det är den enda konstruktion jag känner till där bevisvärdet överlever +// en raderingsbegäran. Ett ärende där kunden begärt radering kan +// fortfarande visa att lufttrycket mättes till 2,4 bar av en behörig +// tekniker klockan 08:42 — bara inte längre vem bilen tillhörde. +// +// Vad som INTE krypteras: mätvärden, observationer, kontrollresultat, +// tidsstämplar, teknikerns roll. De är verksamhetsdata och bär inget +// personvärde i sig. Att kryptera allt vore enklare att beskriva och +// sämre i praktiken — då raderas beviset tillsammans med personuppgiften. + +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; + +/** + * Fält som räknas som identifierande och därför krypteras. + * + * Registreringsnummer och VIN står med avsikt med. De pekar ut ett + * fordon, men ett fordon pekar i praktiken ut en ägare — EDPB behandlar + * VIN som personuppgift när kopplingen finns, och här finns den. + */ +export const IDENTIFIERANDE = { + objekt_identifierat: ["identifierare", "vin", "agare", "kund"], + arbetsorder_skannad: [ + "kund_namn", + "kund_foretag", + "kund_telefon", + "kund_epost", + "fordon_regnr", + "fordon_vin", + ], + kundbeslut: ["kontaktperson"], +}; + +export const MASKERAT = "[raderat på begäran]"; + +/** + * Krypterar ett värde med den registrerades nyckel. + * Formatet bär nyckelns id, så att en post går att koppla till rätt + * nyckel utan att nyckeln behöver finnas. + */ +export function skydda(värde, nyckelId, nyckel) { + const iv = randomBytes(12); + const chiffer = createCipheriv("aes-256-gcm", nyckel, iv); + const ut = Buffer.concat([chiffer.update(String(värde), "utf8"), chiffer.final()]); + return `pd1:${nyckelId}:${iv.toString("base64url")}:${chiffer.getAuthTag().toString("base64url")}:${ut.toString("base64url")}`; +} + +/** + * Öppnar ett skyddat värde. + * + * Saknas nyckeln returneras MASKERAT — det är det normala utfallet efter + * en radering, inte ett fel. Ett kastat undantag här skulle göra hela + * ärendet oläsbart, vilket vore precis fel: resten av bevisningen ska + * överleva. + */ +export function öppna(skyddat, nycklar) { + if (typeof skyddat !== "string" || !skyddat.startsWith("pd1:")) return skyddat; + const [, nyckelId, iv, tagg, data] = skyddat.split(":"); + const nyckel = nycklar.get(nyckelId); + if (!nyckel) return MASKERAT; + try { + const chiffer = createDecipheriv("aes-256-gcm", nyckel, Buffer.from(iv, "base64url")); + chiffer.setAuthTag(Buffer.from(tagg, "base64url")); + return chiffer.update(Buffer.from(data, "base64url"), undefined, "utf8") + chiffer.final("utf8"); + } catch { + // Fel nyckel eller manipulerad chiffertext. Att skilja de två fallen + // åt i ett felmeddelande vore ett orakel; behandla dem lika. + return MASKERAT; + } +} + +/** Krypterar de identifierande fälten i en händelse. */ +export function skyddaHändelse(handelse, nyckelId, nyckel) { + const fält = IDENTIFIERANDE[handelse.typ]; + if (!fält) return handelse; + + if (handelse.typ === "arbetsorder_skannad") { + return { + ...handelse, + falt: (handelse.falt ?? []).map((f) => + fält.includes(f.id) && f.varde ? { ...f, varde: skydda(f.varde, nyckelId, nyckel) } : f, + ), + }; + } + if (handelse.typ === "objekt_identifierat") { + const objekt = { ...handelse.objekt }; + for (const n of fält) if (objekt[n]) objekt[n] = skydda(objekt[n], nyckelId, nyckel); + return { ...handelse, objekt }; + } + const ut = { ...handelse }; + for (const n of fält) if (ut[n]) ut[n] = skydda(ut[n], nyckelId, nyckel); + return ut; +} + +/** Öppnar de identifierande fälten igen för visning. */ +export function öppnaHändelse(handelse, nycklar) { + const fält = IDENTIFIERANDE[handelse?.typ]; + if (!fält) return handelse; + + if (handelse.typ === "arbetsorder_skannad") { + return { ...handelse, falt: (handelse.falt ?? []).map((f) => ({ ...f, varde: öppna(f.varde, nycklar) })) }; + } + if (handelse.typ === "objekt_identifierat") { + const objekt = { ...handelse.objekt }; + for (const n of fält) if (objekt[n]) objekt[n] = öppna(objekt[n], nycklar); + return { ...handelse, objekt }; + } + const ut = { ...handelse }; + for (const n of fält) if (ut[n]) ut[n] = öppna(ut[n], nycklar); + return ut; +} + +/** + * Gallringsbeslut. + * + * Retention är inte en teknisk detalj utan ett juridiskt ställningstagande, + * och det skiljer sig mellan ärendetyper: ett garantiärende måste kunna + * visas upp under garantitiden, ett kontantärende inte. Standardvärdena + * nedan är utgångspunkter — varje organisation ska sätta sina egna, och + * den som inte gör ett aktivt val får det försiktigaste. + */ +export const GALLRING_MANADER = { + Garanti: 120, // Följer den längsta rimliga garantitiden på en drivlina. + Goodwill: 120, + Försäkring: 120, // Preskription för försäkringskrav. + Reklamation: 36, // Konsumentköplagens reklamationsrätt. + Begagnatgaranti: 60, + standard: 36, +}; + +export function gallringsdatum(arendetyp, avslutat, manader = GALLRING_MANADER) { + const antal = manader[arendetyp] ?? manader.standard; + const d = new Date(avslutat); + d.setMonth(d.getMonth() + antal); + return d; +} diff --git a/felsokning/services/plattform/aterstallningstest.sh b/felsokning/services/plattform/aterstallningstest.sh new file mode 100755 index 0000000..dd5d47d --- /dev/null +++ b/felsokning/services/plattform/aterstallningstest.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Återställningstest. +# +# Revisionen (docs/QUALITY-AUDIT.md, M-5) fann att backup-larmet bevisar +# att säkerhetskopiering *sker* — inte att den *går att återställa*. Det +# är två helt olika påståenden, och bara det andra spelar roll den dag +# det behövs. +# +# Testet gör det som en riktig återställning gör, i miniatyr: bygger ett +# schema, fyller det med ett ärende som bär evidens, tar en dump, +# återställer den i en tom databas och kontrollerar att bevisvärdet +# överlevde — inte bara att raderna finns. +# +# Det som kontrolleras efter återställning: +# 1. Händelserna finns kvar, i rätt ordning och med rätt härkomst. +# 2. Append-only-triggarna följde med. En återställd databas utan dem +# vore tyst obrukbar: den ser rätt ut och skyddar ingenting. +# 3. Bilagornas hash stämmer mot innehållet. +# 4. Personnycklarna finns, så ett skyddat regnr går att läsa igen. +# +# Punkt 2 är den som motiverar hela skriptet. Ett schema återställs oftast +# rätt; det som brukar tappas är sådant som ligger utanför tabellerna. + +set -euo pipefail + +KALLA="felsokning_ater_kalla" +MAL="felsokning_ater_mal" +# Dumpen skrivs av postgres-användaren och måste ligga där den kan skriva. +ARBETSKATALOG="$(mktemp -d)" +chmod 777 "$ARBETSKATALOG" +DUMP="$ARBETSKATALOG/dump.sql" +SCHEMA="$(dirname "$0")/../../infra/postgres-init.sql" + +if ! pg_isready -q 2>/dev/null; then + service postgresql start >/dev/null 2>&1 || pg_ctlcluster "$(ls /etc/postgresql | head -1)" main start + for _ in $(seq 1 30); do pg_isready -q && break; sleep 1; done +fi + +kor() { su postgres -c "psql -v ON_ERROR_STOP=1 -qtAX -d $1 -c \"$2\""; } + +echo "→ bygger källdatabasen" +su postgres -c "dropdb --if-exists $KALLA; dropdb --if-exists $MAL" +su postgres -c "createdb $KALLA; createdb $MAL" +su postgres -c "psql -v ON_ERROR_STOP=1 -q -d $KALLA -f $SCHEMA" >/dev/null + +echo "→ fyller den med ett ärende som bär evidens" +su postgres -c "psql -v ON_ERROR_STOP=1 -q -d $KALLA" >/dev/null <<'SQL' +insert into organisationer (id, namn) values ('11111111-1111-1111-1111-111111111111', 'Testverkstaden'); +insert into anvandare (id, organisation_id, epost, losen_hash, namn, roll) + values ('22222222-2222-2222-2222-222222222222', '11111111-1111-1111-1111-111111111111', + 'a@b.se', crypt('x', gen_salt('bf')), 'Anna Tekniker', 'admin'); +insert into felsokning_arenden (id, organisation_id, nummer, skapad, identifierare_index) + values ('ar-1', '11111111-1111-1111-1111-111111111111', 1, now(), 'blindat-abc123'); +insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse) values + ('h-1', 'ar-1', now() - interval '2 hours', 'Anna Tekniker', + '{"typ":"objekt_identifierat","objekt":{"identifierare":"ABC123"}}'), + ('h-2', 'ar-1', now() - interval '1 hour', 'Anna Tekniker', + '{"typ":"matvarde","beskrivning":"Lufttryck","varde":"2,4","matdonId":"m1"}'); +insert into personnycklar (id, organisation_id, subjekt, nyckel) + values ('33333333-3333-3333-3333-333333333333', '11111111-1111-1111-1111-111111111111', + 'ar-1', decode('00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff','hex')); +insert into bilagor (id, arende_id, organisation_id, hash, mediatyp, storlek) + values ('b-1', 'ar-1', '11111111-1111-1111-1111-111111111111', + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', 'image/jpeg', 0); +SQL + +echo "→ tar dump och återställer i en tom databas" +su postgres -c "pg_dump -d $KALLA -f $DUMP" +su postgres -c "psql -v ON_ERROR_STOP=1 -q -d $MAL -f $DUMP" >/dev/null + +fel=0 +kontroll() { + local vad="$1" vantat="$2" fick="$3" + if [ "$fick" = "$vantat" ]; then + echo " ✓ $vad" + else + echo " ✗ $vad — väntade '$vantat', fick '$fick'" + fel=1 + fi +} + +echo "→ kontrollerar den återställda databasen" +kontroll "händelserna finns kvar" "2" "$(kor $MAL 'select count(*) from felsokning_handelser')" +kontroll "härkomsten överlevde" "Anna Tekniker" \ + "$(kor $MAL "select anvandare from felsokning_handelser where id = 'h-1'")" +kontroll "ordningen är bevarad" "h-1" \ + "$(kor $MAL 'select id from felsokning_handelser order by tidpunkt limit 1')" +kontroll "personnyckeln följde med" "1" "$(kor $MAL 'select count(*) from personnycklar')" +kontroll "bilagans hash följde med" "1" \ + "$(kor $MAL "select count(*) from bilagor where hash like 'e3b0c442%'")" + +# Det som faktiskt brukar tappas vid en återställning. +kontroll "append-only-triggarna följde med" "2" \ + "$(kor $MAL "select count(*) from pg_trigger where tgrelid in ('felsokning_handelser'::regclass,'felsokning_arenden'::regclass) and not tgisinternal")" + +echo "→ verifierar att triggarna faktiskt biter i den återställda databasen" +if su postgres -c "psql -qtAX -d $MAL -c \"update felsokning_handelser set anvandare='Någon annan' where id='h-1'\"" >/dev/null 2>&1; then + echo " ✗ append-only bryts i den återställda databasen — historiken går att skriva om" + fel=1 +else + echo " ✓ ändringsförsök avvisas" +fi +if su postgres -c "psql -qtAX -d $MAL -c \"delete from felsokning_handelser where id='h-1'\"" >/dev/null 2>&1; then + echo " ✗ raderingsförsök lyckades i den återställda databasen" + fel=1 +else + echo " ✓ raderingsförsök avvisas" +fi + +su postgres -c "dropdb --if-exists $KALLA; dropdb --if-exists $MAL" + +if [ "$fel" -ne 0 ]; then + echo + echo "ÅTERSTÄLLNINGSTESTET MISSLYCKADES." + echo "En säkerhetskopia som inte kan återställas med bevisvärdet intakt" + echo "är ingen säkerhetskopia. Åtgärda innan driftsättning." + exit 1 +fi + +echo +echo "Återställningen bevarade händelser, härkomst, ordning, bilagehashar," +echo "personnycklar och append-only-skyddet." diff --git a/felsokning/services/plattform/personuppgifter.mjs b/felsokning/services/plattform/personuppgifter.mjs new file mode 120000 index 0000000..f4b1a7e --- /dev/null +++ b/felsokning/services/plattform/personuppgifter.mjs @@ -0,0 +1 @@ +../gemensam/personuppgifter.mjs \ No newline at end of file diff --git a/felsokning/services/plattform/server.mjs b/felsokning/services/plattform/server.mjs index 50bc0d9..c685f8b 100644 --- a/felsokning/services/plattform/server.mjs +++ b/felsokning/services/plattform/server.mjs @@ -30,6 +30,12 @@ import { avsluta, logga, mätvärde, spårFrån, starta, traceparent } from "./o import { tillPost } from "./handelser.mjs"; import { grinda, grindaArendetyp } from "./grind.mjs"; import { ALLA_METODIKER } from "./metodiker.mjs"; +import { + MASKERAT, + gallringsdatum, + skyddaHändelse, + öppnaHändelse, +} from "./personuppgifter.mjs"; // API-first: OpenAPI-specen är en versionerad artefakt och serveras live. const OPENAPI = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "openapi.yaml"), "utf8"); @@ -43,6 +49,40 @@ const ECM_REGLER = readFileSync( "utf8", ); +// Regelpaketet avgör vad systemet accepterar som ett compliant ärende. +// Den som kan skriva till namnrymden kunde tidigare ändra vad "compliant" +// betyder, utan signatur och utan godkännandespår (QUALITET M-6). +// +// Paketet verifieras nu mot en HMAC med ECM_REGLER_NYCKEL. Saknas nyckeln +// körs paketet i granskningsläge: det används, men varje spårbarhetspaket +// märks som osignerat, och driften larmar. Att vägra starta hade varit +// renare men gjort en säkerhetsförbättring till ett driftavbrott för alla +// befintliga installationer — det är så säkerhetsfunktioner blir +// avstängda. +const ECM_REGLER_NYCKEL = process.env.ECM_REGLER_NYCKEL ?? ""; +const ECM_REGLER_SIGNATUR = process.env.ECM_REGLER_SIGNATUR ?? ""; + +function regelpaketetsStatus() { + if (!ECM_REGLER_NYCKEL || !ECM_REGLER_SIGNATUR) return "osignerat"; + const väntad = createHmac("sha256", ECM_REGLER_NYCKEL).update(ECM_REGLER).digest("hex"); + const a = Buffer.from(ECM_REGLER_SIGNATUR); + const b = Buffer.from(väntad); + return a.length === b.length && timingSafeEqual(a, b) ? "signerat" : "ogiltig signatur"; +} + +const REGELPAKET_STATUS = regelpaketetsStatus(); +if (REGELPAKET_STATUS !== "signerat") { + logga("varning", "regelpaketets signatur", { + status: REGELPAKET_STATUS, + // Ett ogiltigt paket är värre än inget: det betyder att någon bytt + // filen utan att kunna signera den. + konsekvens: + REGELPAKET_STATUS === "ogiltig signatur" + ? "Paketet används INTE — inbyggt standardpaket gäller." + : "Paketet används men märks som osignerat i spårbarheten.", + }); +} + // 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. @@ -332,6 +372,9 @@ async function grindHinder(pool, arendeId, nya) { ALLA_METODIKER.find((m) => m.id === rader.rows[0]?.metodik_id) ?? ALLA_METODIKER.at(-1); let regelpaket; + if (REGELPAKET_STATUS === "ogiltig signatur") { + return [{ id: "regelpaket", rubrik: "Regelpaketets signatur stämmer inte — avslut spärrat." }]; + } try { regelpaket = JSON.parse(ECM_REGLER); } catch { @@ -341,6 +384,64 @@ async function grindHinder(pool, arendeId, nya) { return [...grinda(handelser, metodik), ...grindaArendetyp(handelser, regelpaket)]; } + +// ---- Personuppgifter, gallring och åtkomstlogg ------------------------- + +/** + * Hämtar eller skapar nyckeln för ett subjekt. Ett subjekt är normalt ett + * fordon (regnr eller VIN) — det som en raderingsbegäran pekar ut. + */ +/** + * Blindat index över en fordonsidentifierare. + * + * Samma fordon ger alltid samma värde, men värdet går inte att vända + * tillbaka till ett registreringsnummer utan nyckeln. Det är det som gör + * en raderingsbegäran genomförbar över hela fordonets historik trots att + * identifieraren är krypterad i loggen (QUALITET C-3). + */ +function blindaIdentifierare(ident) { + const nyckel = process.env.INTEGRATION_NYCKEL ?? process.env.JWT_SECRET ?? ""; + return createHmac("sha256", nyckel) + .update(String(ident).trim().toUpperCase().replace(/\s+/g, "")) + .digest("hex"); +} + +async function personnyckel(orgId, subjekt) { + const rad = await pool.query( + `insert into personnycklar (organisation_id, subjekt, nyckel) + values ($1, $2, $3) + on conflict (organisation_id, subjekt) do update set subjekt = excluded.subjekt + returning id, nyckel, radering_begard`, + [orgId, subjekt, randomBytes(32)], + ); + return rad.rows[0]; +} + +/** Nycklar som fortfarande finns, för att öppna en logg vid läsning. */ +async function nycklarFor(orgId) { + const rader = await pool.query( + `select id, nyckel from personnycklar where organisation_id = $1`, + [orgId], + ); + return new Map(rader.rows.map((r) => [r.id, r.nyckel])); +} + +/** + * Läslogg. Skrivningar loggades redan; läsningar gjorde det inte, vilket + * gjorde det omöjligt att svara på vem som sett en kunds uppgifter + * (QUALITET M-4). Misslyckas loggningen svarar vi ändå — en trasig + * revisionslogg får inte bli ett driftavbrott, men den ska synas. + */ +function loggaAtkomst(req, res, { org, anvandare, arende, delningskod }) { + pool + .query( + `insert into atkomstlogg (organisation_id, anvandare_id, arende_id, vag, kalla, delningskod) + values ($1, $2, $3, $4, $5, $6)`, + [org ?? null, anvandare ?? null, arende ?? null, req.url?.slice(0, 500) ?? "", kallaFor(req), delningskod ?? null], + ) + .catch((fel) => logga("fel", "åtkomstlogg misslyckades", { spårId: res.spår.spårId, orsak: fel?.message })); +} + function kravAuth(req, hemlighet) { const auth = req.headers.authorization ?? ""; const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; @@ -504,6 +605,10 @@ export function skapaServer() { roll: rad.roll, // Bärs med så att en återkallelse gör token ogiltig direkt. tv: rad.token_version ?? 0, + // Modellanropen kan stängas av per organisation (QUALITET C-4). + // Flaggan bärs i token så att orkestern kan neka utan att själv + // behöva databasåtkomst — den vet redan vem som frågar. + ai: rad.ai_tillaten !== false, iat: nu, exp: nu + TOKEN_LIVSTID_S, }, @@ -606,7 +711,8 @@ export function skapaServer() { } const rader = await pool.query( - `select a.id, a.namn, a.organisation_id, a.roll, a.aktiv, a.token_version, o.namn as org_namn + `select a.id, a.namn, a.organisation_id, a.roll, a.aktiv, a.token_version, o.namn as org_namn, + coalesce((o.installningar->>'ai_tillaten')::boolean, true) as ai_tillaten from anvandare a join organisationer o on o.id = a.organisation_id where a.epost = $1 and a.losen_hash = crypt($2, a.losen_hash)`, [normaliserad, losenord ?? ""], @@ -876,6 +982,7 @@ export function skapaServer() { // ECM Knowledge Library: aktuellt regelpaket för inloggade klienter. if (req.method === "GET" && vag === "/api/ecm/regler") { + res.setHeader("X-Regelpaket-Status", REGELPAKET_STATUS); res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": res.ursprung, @@ -1028,7 +1135,7 @@ export function skapaServer() { if (req.method === "POST" && vag === "/api/organisation/installningar") { if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); - const { objekttyper, identifieringsmetoder } = await lasKropp(req); + const { objekttyper, identifieringsmetoder, ai_tillaten } = await lasKropp(req); const giltigLista = (lista) => Array.isArray(lista) && lista.length > 0 && lista.length <= 50 && lista.every((v) => typeof v === "string" && v.length <= 100); @@ -1037,7 +1144,18 @@ export function skapaServer() { } await pool.query( `update organisationer set installningar = $2 where id = $1`, - [anspr.org, JSON.stringify({ objekttyper, identifieringsmetoder })], + [ + anspr.org, + JSON.stringify({ + objekttyper, + identifieringsmetoder, + // Modellanropen kan stängas av helt. Metodikmotorn fungerar + // ensam, så en organisation som inte kan acceptera + // överföringen till modelleverantören kan ändå använda + // produkten (QUALITET C-4). + ai_tillaten: ai_tillaten !== false, + }), + ], ); return svara(res, 200, { ok: true }); } @@ -1178,6 +1296,116 @@ export function skapaServer() { } } + + // ---- Radering (dataskyddsförordningen art. 17) ------------------ + // + // Krypto-shredding: nyckeln förstörs, loggen står kvar. Vad som + // raderas är identifieringen — inte protokollet över vad som + // kontrollerades, av vem och när. Det är den enda konstruktion där + // bevisvärdet överlever en raderingsbegäran. + if (req.method === "POST" && vag === "/api/radering") { + if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); + const { subjekt, bekraftelse } = await lasKropp(req); + if (typeof subjekt !== "string" || !subjekt.trim()) { + return svara(res, 400, { error: "Ange vilket fordon eller vilken kund som avses." }); + } + // Raderingen går inte att ångra. En bekräftelse som upprepar + // subjektet gör det svårt att göra av misstag och lätt att göra + // med avsikt. + if (bekraftelse !== subjekt) { + return svara(res, 400, { error: "Bekräftelsen måste upprepa subjektet exakt. Raderingen går inte att ångra." }); + } + + // Begäran gäller normalt ett fordon. Alla dess ärenden hittas via + // det blindade indexet; ett enskilt ärende-id fungerar också. + const arenden = await pool.query( + `select id from felsokning_arenden + where organisation_id = $1 and (identifierare_index = $2 or id = $3)`, + [anspr.org, blindaIdentifierare(subjekt), subjekt], + ); + const berorda = { rows: [{ antal: arenden.rowCount }] }; + const bort = await pool.query( + `delete from personnycklar + where organisation_id = $1 and subjekt = any($2::text[]) returning id`, + [anspr.org, arenden.rows.map((r) => r.id)], + ); + if (bort.rowCount === 0) { + return svara(res, 404, { error: "Inget skyddat underlag finns för det subjektet." }); + } + // Raderingen loggas — men loggen får inte själv bära uppgiften. + await pool.query( + `insert into raderingar (organisation_id, subjekt_hash, begard, begard_av, antal_arenden) + values ($1, $2, now(), $3, $4)`, + [anspr.org, innehallsHash(Buffer.from(subjekt)), anspr.namn, berorda.rows[0].antal], + ); + logga("info", "radering verkställd", { + spårId: res.spår.spårId, + org: anspr.org, + antalArenden: berorda.rows[0].antal, + }); + return svara(res, 200, { ok: true, arenden: berorda.rows[0].antal, maskeras_som: MASKERAT }); + } + + if (req.method === "GET" && vag === "/api/radering") { + if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); + const rader = await pool.query( + `select subjekt_hash, begard, verkstalld, begard_av, antal_arenden + from raderingar where organisation_id = $1 order by verkstalld desc limit 200`, + [anspr.org], + ); + return svara(res, 200, { raderingar: rader.rows }); + } + + // ---- Åtkomstlogg ----------------------------------------------- + if (req.method === "GET" && vag === "/api/atkomstlogg") { + if (anspr.roll === "tekniker") return svara(res, 403, { error: "Kräver arbetsledare eller administratör." }); + const rader = await pool.query( + `select l.tidpunkt, l.arende_id, l.vag, l.delningskod, a.namn as anvandare + from atkomstlogg l left join anvandare a on a.id = l.anvandare_id + where l.organisation_id = $1 order by l.tidpunkt desc limit 500`, + [anspr.org], + ); + return svara(res, 200, { atkomster: rader.rows }); + } + + // ---- Mätdon ----------------------------------------------------- + // + // Ett mätvärde rankas som E4 — hög evidens. Utan kalibrerat + // instrument är det inte lägre evidens utan ingen evidens alls + // (QUALITET M-1). Registret gör påståendet kontrollerbart. + if (vag === "/api/matdon") { + if (req.method === "GET") { + const rader = await pool.query( + `select id, beteckning, serienummer, kalibrerad_till, aktiv, + (kalibrerad_till is not null and kalibrerad_till >= current_date) as giltig + from matdon where organisation_id = $1 and aktiv order by beteckning`, + [anspr.org], + ); + return svara(res, 200, { matdon: rader.rows }); + } + if (req.method === "POST") { + if (anspr.roll === "tekniker") return svara(res, 403, { error: "Kräver arbetsledare eller administratör." }); + const { beteckning, serienummer, kalibrerad_till } = await lasKropp(req); + if (typeof beteckning !== "string" || !beteckning.trim() || typeof serienummer !== "string" || !serienummer.trim()) { + return svara(res, 400, { error: "Beteckning och serienummer krävs." }); + } + if (kalibrerad_till && Number.isNaN(Date.parse(kalibrerad_till))) { + return svara(res, 400, { error: "Kalibreringsdatum är ogiltigt." }); + } + const rad = await pool.query( + `insert into matdon (organisation_id, beteckning, serienummer, kalibrerad_till) + values ($1, $2, $3, $4) + on conflict (organisation_id, serienummer) + do update set beteckning = excluded.beteckning, + kalibrerad_till = excluded.kalibrerad_till, + aktiv = true + returning id`, + [anspr.org, beteckning.trim(), serienummer.trim(), kalibrerad_till || null], + ); + return svara(res, 200, { id: rad.rows[0].id }); + } + } + const aterkalla = vag.match(/^\/api\/delningar\/([A-Za-z0-9_-]+)\/aterkalla$/); if (req.method === "POST" && aterkalla) { const rad = await pool.query( @@ -1203,7 +1431,11 @@ export function skapaServer() { where arende_id = $1 order by tidpunkt, id`, [handelserVag[1]], ); - return svara(res, 200, { handelser: rader.rows }); + const nycklar = await nycklarFor(anspr.org); + loggaAtkomst(req, res, { org: anspr.org, anvandare: anspr.sub, arende: handelserVag[1] }); + return svara(res, 200, { + handelser: rader.rows.map((r) => ({ ...r, handelse: öppnaHändelse(r.handelse, nycklar) })), + }); } if (req.method === "POST") { const { handelser } = await lasKropp(req); @@ -1214,10 +1446,15 @@ export function skapaServer() { // Härkomst och form avgörs här, inte av anroparen. Se // services/gemensam/handelser.mjs och QUALITET C-1/M-3. + // Identifierande fält krypteras med en nyckel per fordon. + // Radering sker sedan genom att nyckeln förstörs — loggen + // förblir intakt, identifieringen försvinner (QUALITET C-3). + const nyckel = await personnyckel(anspr.org, handelserVag[1]); const attSkriva = []; for (const post of handelser) { const { post: giltig, fel } = tillPost(post, anspr); if (fel) return svara(res, 400, { error: `Ogiltig händelse: ${fel}` }); + giltig.handelse = skyddaHändelse(giltig.handelse, nyckel.id, nyckel.nyckel); attSkriva.push(giltig); } @@ -1234,6 +1471,40 @@ export function skapaServer() { } } + // Gallringsdatum sätts vid avslut utifrån ärendetypen. + // Retention är ett juridiskt ställningstagande, inte en teknisk + // detalj: ett garantiärende måste kunna visas upp under hela + // garantitiden, ett kontantärende inte (QUALITET C-3). Ett + // ärende utan datum gallras aldrig automatiskt — det försiktiga + // utfallet, eftersom för tidig gallring inte går att ångra. + const avslut = attSkriva.find((p) => p.handelse.typ === "arende_avslutat"); + if (avslut) { + const typrad = await pool.query( + `select handelse->>'arendetyp' as typ from felsokning_handelser + where arende_id = $1 and handelse->>'typ' = 'arendetyp_satt' + order by tidpunkt desc limit 1`, + [handelserVag[1]], + ); + await pool.query(`update felsokning_arenden set gallras_efter = $2 where id = $1`, [ + handelserVag[1], + gallringsdatum(typrad.rows[0]?.typ, avslut.tidpunkt), + ]); + } + + // Blindat index över fordonet, så en raderingsbegäran kan nå + // hela historiken utan att identifieraren lagras i klartext. + const objekt = attSkriva.find((p) => p.handelse.typ === "objekt_identifierat"); + if (objekt) { + const ident = handelser.find((h) => h?.handelse?.typ === "objekt_identifierat")?.handelse?.objekt + ?.identifierare; + if (ident) { + await pool.query(`update felsokning_arenden set identifierare_index = $2 where id = $1`, [ + handelserVag[1], + blindaIdentifierare(ident), + ]); + } + } + for (const p of attSkriva) { // Append-only: on conflict do nothing — en befintlig händelse // skrivs aldrig över, och databastriggern stoppar allt annat.