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
+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.