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:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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."
|
||||
@@ -0,0 +1 @@
|
||||
../gemensam/personuppgifter.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.
|
||||
|
||||
Reference in New Issue
Block a user