Revisionen: C-3, C-4, M-1, M-4, M-5, M-6, m-3, m-5 och m-7 åtgärdade

C-3 · Dataskydd genom krypto-shredding. Identifierande fält krypteras
med en nyckel per ärende; 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 visar fortfarande att lufttrycket mättes till 2,4 bar
klockan 08:42, bara inte längre vems bil det gällde.

Vad som inte krypteras är lika viktigt: mätvärden, observationer och
kontrollresultat är verksamhetsdata. Krypteras allt raderas beviset
tillsammans med personuppgiften.

En raderingsbegäran gäller ett fordon, inte ett ärende — men
identifieraren är krypterad och går inte att söka på. Därför ett blindat
index: HMAC av den normaliserade identifieraren, samma fordon ger alltid
samma värde, värdet går inte att vända tillbaka utan nyckeln.

Gallringsdatum sätts vid avslut utifrån ärendetypen. Ett ärende utan
datum gallras aldrig — för tidig gallring går inte att ångra.

C-4 · Modellanropen kan stängas av per organisation. Flaggan bärs i
token så orkestern kan neka utan databasåtkomst. Metodikmotorn fungerar
ensam; en verkstad som inte kan acceptera överföringen till
modelleverantören kan ändå använda produkten.

M-1 · Mätvärden bär vilket mätdon som användes och när det var
kalibrerat. Utan spårbart instrument nedgraderas värdet från E4 till E1
— det är teknikerns observation av en siffra, inte en mätning.
Kalibreringen bedöms vid mättillfället, inte i dag. Demoärendet fick
kalibrerade instrument: det ska visa den praxis produkten kräver.

M-4 · Läslogg. Varje skrivning loggades redan; ingen läsning gjorde det.
M-5 · Återställningstest i CI. Larmet visade att backup sker, inte att
den går att återställa. Testet kontrollerar det som faktiskt brukar
tappas: att append-only-triggarna följde med och fortfarande biter.
M-6 · Regelpaketet verifieras mot HMAC. Ogiltig signatur spärrar avslut;
saknad nyckel ger granskningsläge i stället för driftavbrott — det är så
säkerhetsfunktioner blir avstängda.
m-3 · EXIF-borttagningen är nu avsiktlig och låst av test. Den höll av
en slump, och en ändring i stil med "bevara originalkvaliteten" hade
tyst börjat publicera var kundens bil stod.
m-5 · SBOM och sårbarhetsskanning i CI.
m-7 · Promptversionen binds till varje svar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-05 13:44:37 +00:00
parent e73decd3f5
commit fbc034a280
15 changed files with 981 additions and 19 deletions
@@ -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/);
});
});
@@ -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);
});
});
@@ -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 () => {
+18 -3
View File
@@ -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, {
+16 -1
View File
@@ -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<Tillforlitlighet, "hog"> }
| ({ typ: "foto"; beskrivning: string } & Bilaga)
// Video med ljud (E3-evidens): för det som låter eller rör sig —
+34 -1
View File
@@ -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);
+15 -1
View File
@@ -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<string> {
return new Promise((resolve, reject) => {
const lasare = new FileReader();
+88
View File
@@ -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);
+37 -3
View File
@@ -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,
+1 -1
View File
@@ -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" },
@@ -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;
}
+122
View File
@@ -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."
+1
View File
@@ -0,0 +1 @@
../gemensam/personuppgifter.mjs
+275 -4
View File
@@ -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.