Files
alva/felsokning/services/gemensam/personuppgifter.mjs
T
Claude 8aae3a2a97 Regelverk, ärendetyper och säkerhetssvar på engelska — med arv där data byter språk
ecm.ts bar regelverkets rådtexter: undantagsorsaker, orsakskategorier,
underlagskällor, evidensgrader och de meningar som säger vad som fattas.
Cirka hundra strängar, nu på engelska tillsammans med serverns
ecm-regler.json — klienten och regelpaketet måste säga samma sak, annars
är det inte samma regel.

Tre av dem var inte text utan DATA, och där gjordes bytet med arv i
stället för rakt av.

ÄRENDETYPEN ligger i loggen och slår upp regelpaketets extra krav. Ett
gammalt garantiärende vars typ heter "Garanti" hade inte matchat
"Warranty", fått noll extra krav, och släppts igenom grinden med FÄRRE
krav än när det öppnades — utan att någonting i gränssnittet visade det.
ARENDETYP_ARV normaliserar typen vid uppslaget, aldrig i loggen.

GALLRINGSTIDEN slås upp på samma nyckel, och det var värre. Ett
garantiärende hade fallit ur tabellen och tagit standardtiden 36 månader
i stället för 120 — alltså raderat garantiunderlaget sju år i förtid,
tyst och oåterkalleligt. Ett uppslag som missar ska ge det försiktigaste
utfallet; här gav det det farligaste. Två tester låser det.

SÄKERHETSSPÄRREN avgörs av en textsträng. När knappen bytte från "Ja"
till "Yes" gick klient och server isär: klienten skrev "Yes", servern
jämförde mot "Ja". Utfallet blev fail-closed — högvoltsärenden gick inte
att avsluta alls — vilket är rätt riktning för ett fel att falla åt, men
det var en slump och inte en konstruktion. `arJakande` känner igen ett ja
på alla språken. Att en säkerhetsspärr avgörs av fritext är i sig felet;
rätt lösning är ett booleskt fält, och att arvet står synligt i koden är
en påminnelse om det.

Pre-diagnostikens kvitteringar känns igen på sin text och har fått samma
behandling: gamla ärenden hade annars visat en ogjord kontroll för arbete
som faktiskt utförts, och teknikern hade kvitterat en gång till — två
kvitteringar för samma sak i en append-only logg.

Genomgången drev gränssnittet på svenska knappnamn och gick sönder på
rätt sätt: den hittade "+ Dokumentera felorsak", "Registrera besked" och
kundkanalerna "Telefon"/"E-post", som alla missats av de tidigare
svepen. Fyra av fyra ärenden avslutas igen.

609 tester, 186 integrationskontroller mot riktig Postgres, genomgång,
portalspärr, typkontroll och lint gröna. Mutationsprövat: utan
ARENDETYP_ARV faller två tester, utan `arJakande` ett.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
2026-08-06 18:05:19 +00:00

163 lines
6.4 KiB
JavaScript

// 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";
import { arendetypNu } from "./sprak/index.mjs";
/**
* 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 = {
Warranty: 120, // Följer den längsta rimliga garantitiden på en drivlina.
Goodwill: 120,
Insurance: 120, // Preskription för försäkringskrav.
Complaint: 36, // Konsumentköplagens reklamationsrätt.
"Used-vehicle warranty": 60,
standard: 36,
};
/**
* Gallringsdatum för ett avslutat ärende.
*
* Ärendetypen normaliseras genom ARENDETYP_ARV innan den slås upp. Det
* är inte en artighet mot gamla data utan en spärr: när typerna bytte
* språk hade ett garantiärende vars logg säger "Garanti" fallit ur
* tabellen och tagit standardvärdet 36 månader i stället för 120 —
* alltså raderat garantiunderlaget sju år för tidigt, tyst, och
* oåterkalleligt. Ett uppslag som missar ska ge det FÖRSIKTIGASTE
* utfallet; här gav det det farligaste.
*/
export function gallringsdatum(arendetyp, avslutat, manader = GALLRING_MANADER) {
const antal = manader[arendetypNu(arendetyp)] ?? manader.standard;
const d = new Date(avslutat);
d.setMonth(d.getMonth() + antal);
return d;
}