1ba5aaaefa
Fyra av granskningens fynd åtgärdade, i bevisvärdesordning. HASHKEDJAN (ALVA-SPEC-070). Triggrar skyddar loggen mot applikationen, inte mot den som äger databasen — det var granskningens allvarligaste invändning mot ett system vars hela värde är bevisvärde. Varje händelse bär nu en hash av sitt innehåll och föregående händelses hash, beräknad av servern vid insättningen. All skrivning går genom en enda kedjande funktion; en händelse vid sidan av kedjan är ett hål i beviset, så den bekväma vägen förbi finns inte. Digest tas över den LAGRADE händelsen, efter kryptering: verifieringen ska kunna räkna om den ur databasen för all framtid, och krypto-shredding förstör nycklar, inte rader, så kedjan överlever en radering. Radlås per ärende hindrar att två samtidiga batchar forkar kedjan — en falsk larmande verifiering avfärdas snart som trasig, och då är den värdelös. Integrationstestet provar hotmodellen ordagrant: triggern släpps, en rad ändras med full databasbehörighet, triggern återskapas. Verifieringen pekar ut raden — inte bara att något är fel, utan vilken. FÖRSEGLINGEN. Avslut skriver kedjans rot och en HMAC med en nyckel som aldrig finns i databasen, i samma transaktion som avslutshändelsen. Den som räknar om hela kedjan efter sin ändring stoppas av att förseglingen inte går att räkna om utan nyckeln. Engångs: triggern vägrar ändra en satt försegling. Svaret säger vad det bevisar och inte — innehållet är oförändrat sedan mottagandet, ingenting om tiden före, ingenting om sanningshalten. Den texten följer med in i varje rapport som citerar svaret, för det är precis den skillnad en motpartsjurist annars hittar. SIGNATUREN. Fältet hette signatur men var teknikerns egen text — det inbjöd en jurist att tro något som inte gällde. Det skrivs nu ur verifierad token som övriga härkomstfält och intygar exakt vad det kan intyga: vem som var inloggad när avslutet togs emot. SÄKERHETSNIVÅN (ALVA-SPEC-071). Var teknikerns fria val — ett självskattat värde som ser ut som en mätning. Nu ett tak härlett ur underlaget: hög kräver reproducerat symptom OCH spårbart mätvärde ur mätdonsregistret; enbart observationer bär inte ens medel. Teknikern kan sänka men aldrig höja — asymmetrin är poängen, ärlig osäkerhet är information. Grinden spärrar påståenden över taket på alla tio språken, och gränssnittet visar taket medan arbetet pågår i stället för att spara beskedet till avslutsknappen. "Delvis reproducerat" bär inte hög: delvis är ett annat ord för att felet inte är förstått. Taket bet direkt i två av våra egna testfixturer som påstod hög utan spårbart mätdon — vilket är regeln som fungerar, inte testet som är fel. Genomgången avslöjade följdkravet: vid medel/låg kräver panelen att teknikern anger vilka ytterligare kontroller som skulle stärka bedömningen, och det fältet fylls nu i som en tekniker skulle. Kvar ur granskningens lista, medvetet: extern förankring (RFC 3161), klienthashat foto vid upptagning, gränsvärden som data, OIDC/SAML. 766 tester, 200 integrationskontroller mot riktig Postgres — inklusive sabotage som databasägare — genomgång 4/4, portalspärr, typkontroll, lint och artefaktmätning gröna. Utgåva 3.3, API-specen uppdaterad, åtgärderna bokförda i panelrapportens bilaga A. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
211 lines
9.3 KiB
JavaScript
211 lines
9.3 KiB
JavaScript
// Händelsevalidering vid API-gränsen.
|
||
//
|
||
// Bakgrund: revisionen (docs/QUALITY-AUDIT.md, C-1 och M-3) fann att servern
|
||
// bara kontrollerade att `anvandare` var *en sträng* och `tidpunkt` *sanningsvärd*
|
||
// — inte att de var sanna — och att `handelse` togs emot som godtycklig JSON.
|
||
//
|
||
// Två skilda problem med samma botemedel:
|
||
//
|
||
// Härkomst Vem som utförde arbetet och när är det som gör loggen till en
|
||
// beviskedja. Sätts de av anroparen är loggen en signerad behållare
|
||
// för overifierade påståenden. Båda härleds nu ur den verifierade
|
||
// token respektive serverns klocka.
|
||
//
|
||
// Form Loggen är append-only. En felaktig post kan aldrig rättas, bara
|
||
// kommenteras. Därför måste formen kontrolleras *innan* skrivning —
|
||
// efteråt är det för sent för alltid.
|
||
//
|
||
// Klientens klocka kastas inte bort. Vid offline-arbete är den det enda som
|
||
// finns, så den bevaras som `registrerad_tidpunkt` bredvid serverns
|
||
// mottagningstid. Glappet blir synligt i stället för osynligt.
|
||
|
||
/** Fält som aldrig får komma från klienten. */
|
||
export const SERVERÄGDA_FÄLT = ["anvandare", "tidpunkt"];
|
||
|
||
// Varje händelsetyp med sina obligatoriska fält. Listan är avsiktligt
|
||
// fullständig: en typ som inte står här avvisas, eftersom en okänd typ i en
|
||
// append-only-logg är permanent skräp.
|
||
export const HÄNDELSESCHEMA = {
|
||
objekt_identifierat: { objekt: "objekt" },
|
||
arbetsorder_skannad: { falt: "lista" },
|
||
felbeskrivning: { text: "text" },
|
||
arendetyp_satt: { arendetyp: "text" },
|
||
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" }, // matdonId valfritt — se M-1
|
||
hypotes: { text: "text", niva: ["medel", "lag"] },
|
||
foto: { beskrivning: "text" },
|
||
video: { beskrivning: "text" },
|
||
matarstallning: { lage: ["ingaende", "utgaende"], varde: "text" },
|
||
historik_kontrollerad: { kontrollerad: "boolean" },
|
||
reproducering: { status: ["ja", "delvis", "nej"], beskrivning: "text" },
|
||
felorsak: {
|
||
avvikelse: "text",
|
||
orsaker: "lista",
|
||
underlag: "lista",
|
||
sakerhet: ["hog", "medel", "lag"],
|
||
atgard: "text",
|
||
},
|
||
atgardsforslag: { beskrivning: "text" },
|
||
kundbeslut: { beslut: ["godkant", "avbojt", "delvis"], kanal: "text" },
|
||
atgard_utford: { beskrivning: "text", utford: "boolean" },
|
||
kvalitetskontroll: {
|
||
resultat: ["symptomet_borta", "kvarstar", "delvis", "ej_verifierbar"],
|
||
beskrivning: "text",
|
||
},
|
||
kommentar: { text: "text" },
|
||
kategori_byte: { kategori: "text" },
|
||
inaktivitet_forklarad: { text: "text", minuter: "tal" },
|
||
overlamning: { fran: "text" },
|
||
ansvarig_satt: { ansvarig: "text" },
|
||
ai_svar: { rader: "lista", nastaSteg: "text", modell: "text" },
|
||
export_skapad: { format: "text", version: "tal" },
|
||
// ALVA-RULE-200: teknikerns varför. Fälten valideras utöver formen i
|
||
// services/gemensam/motivering.mjs — här kontrolleras bara att de finns.
|
||
slutsats: { motivering: "text", uteslutet: "text", kvarstaende: "text" },
|
||
arende_avslutat: {},
|
||
};
|
||
|
||
// ---- Valfria fält (QUALITY-AUDIT-2 · C-5) ------------------------------
|
||
//
|
||
// Schemat kontrollerade tidigare bara att de OBLIGATORISKA fälten fanns.
|
||
// Det itererade schemats nycklar, aldrig händelsens, så vilket okänt fält
|
||
// som helst passerade och sparades ordagrant.
|
||
//
|
||
// Två garantier vilade på motsatsen. Krypto-shreddingen skyddar en fast
|
||
// lista av fältNAMN: ett registreringsnummer på en vanlig observation
|
||
// kom aldrig in på listan, krypterades aldrig, och överlevde därför att
|
||
// nyckeln förstördes — medan raderingskvittot ändå sa att subjektet var
|
||
// raderat. Och delningsfiltret arbetar på typnivå, inte fältnivå, så
|
||
// samma fält gick ut i kundens delningslänk.
|
||
//
|
||
// Därför är listan nedan uttömmande och avvisningen hård. Ett fält som
|
||
// inte står här eller bland de obligatoriska finns inte, och en anropare
|
||
// som tror sig ha sparat något får aldrig veta att det gick bra.
|
||
export const VALFRIA_FÄLT = {
|
||
// Bilagefälten ärvs via intersektion med Bilaga i domänmodellen.
|
||
arbetsorder_skannad: ["bilagaId", "bilagaHash", "dataUrl"],
|
||
foto: ["bilagaId", "bilagaHash", "dataUrl"],
|
||
video: ["bilagaId", "bilagaHash", "dataUrl"],
|
||
matarstallning: ["undantag", "bilagaId", "bilagaHash", "dataUrl"],
|
||
|
||
kontroll_utford: ["resultat", "undantag"],
|
||
// `kalla` bär härkomsten för värden som kommit in via ett
|
||
// diagnosprotokoll (ALVA-SPEC-020). Fältet måste vara deklarerat:
|
||
// integrationen fungerade tidigare bara därför att schemat var öppet.
|
||
observation: ["kalla"],
|
||
matvarde: ["enhet", "matdonId", "matdonBeteckning", "matdonKalibreradTill", "kalla"],
|
||
overlamning: ["till"],
|
||
historik_kontrollerad: ["kommentar"],
|
||
felorsak: ["motivering", "ytterligareKontroller"],
|
||
atgardsforslag: ["uppskattadKostnad", "uppskattadTid"],
|
||
kundbeslut: ["kommentar", "kontaktperson"],
|
||
atgard_utford: ["delar", "motivering"],
|
||
slutsats: ["atgardsval", "orsakFastställd"],
|
||
// Versionen som gällde VID AVSLUTET. Ritningsstämpeln läste den ur
|
||
// den aktuella konstanten, så ett ärende stängt under 1.0 visade 3.1 —
|
||
// precis det stämpelns egen beskrivning säger att den inte ska göra.
|
||
arende_avslutat: ["signatur", "plattformsversion"],
|
||
};
|
||
|
||
/**
|
||
* Fält som systemet självt sätter efter valideringen.
|
||
*
|
||
* `anvandarId` skrivs av tillPost ur den verifierade token, och
|
||
* `registrerad_tidpunkt` bevarar klientens klocka vid offline-arbete.
|
||
* Bägge måste passera när en redan skriven händelse valideras om.
|
||
*/
|
||
export const SYSTEMFÄLT = ["typ", "anvandarId", "registrerad_tidpunkt"];
|
||
|
||
// Hypotesen får aldrig anta hög tillförlitlighet — samma regel som
|
||
// typsystemet upprätthåller i klienten, upprepad här därför att servern inte
|
||
// kan lita på att klienten är vår.
|
||
const GILTIG = {
|
||
text: (v) => typeof v === "string" && v.length > 0 && v.length <= 20000,
|
||
tal: (v) => typeof v === "number" && Number.isFinite(v),
|
||
boolean: (v) => typeof v === "boolean",
|
||
lista: (v) => Array.isArray(v) && v.length <= 500,
|
||
objekt: (v) => v !== null && typeof v === "object" && !Array.isArray(v),
|
||
};
|
||
|
||
function fältFel(typ, nyckel, regel, värde) {
|
||
if (Array.isArray(regel)) {
|
||
return regel.includes(värde) ? null : `${typ}.${nyckel} måste vara en av: ${regel.join(", ")}`;
|
||
}
|
||
return GILTIG[regel](värde) ? null : `${typ}.${nyckel} har fel form (väntade ${regel})`;
|
||
}
|
||
|
||
/**
|
||
* Validerar en händelses form. Returnerar null när den duger, annars en
|
||
* förklaring avsedd att läsas av en människa som felsöker en integration.
|
||
*/
|
||
export function granskaHändelse(handelse) {
|
||
if (handelse === null || typeof handelse !== "object" || Array.isArray(handelse)) {
|
||
return "handelse måste vara ett objekt";
|
||
}
|
||
const schema = HÄNDELSESCHEMA[handelse.typ];
|
||
if (!schema) {
|
||
return `okänd händelsetyp: ${JSON.stringify(handelse.typ)}`;
|
||
}
|
||
for (const [nyckel, regel] of Object.entries(schema)) {
|
||
const fel = fältFel(handelse.typ, nyckel, regel, handelse[nyckel]);
|
||
if (fel) return fel;
|
||
}
|
||
|
||
// C-5: schemat är stängt. Allt som inte är deklarerat avvisas — annars
|
||
// kan personuppgifter hängas på en vanlig händelse, undgå krypteringen
|
||
// och följa med ut i delningslänken.
|
||
const tillåtna = new Set([
|
||
...SYSTEMFÄLT,
|
||
...Object.keys(schema),
|
||
...(VALFRIA_FÄLT[handelse.typ] ?? []),
|
||
]);
|
||
const okända = Object.keys(handelse).filter((n) => !tillåtna.has(n));
|
||
if (okända.length > 0) {
|
||
return `${handelse.typ}: okända fält avvisas (${okända.join(", ")})`;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Bygger den post som faktiskt skrivs.
|
||
*
|
||
* Härkomsten kommer härifrån och ingen annanstans: `anvandare` ur den
|
||
* verifierade token, `tidpunkt` ur serverns klocka. Klientens tidsstämpel
|
||
* bevaras separat när den finns, eftersom den är det enda som finns vid
|
||
* offline-arbete — men den avgör aldrig när något anses ha skett.
|
||
*/
|
||
export function tillPost(post, anspr, nu = new Date()) {
|
||
const fel = granskaHändelse(post?.handelse);
|
||
if (fel) return { fel };
|
||
if (typeof post.id !== "string" || !/^[A-Za-z0-9_:.-]{1,128}$/.test(post.id)) {
|
||
return { fel: "id saknas eller har otillåtna tecken" };
|
||
}
|
||
|
||
const klient = typeof post.tidpunkt === "string" ? post.tidpunkt : null;
|
||
const klientTid = klient && !Number.isNaN(Date.parse(klient)) ? klient : null;
|
||
|
||
return {
|
||
post: {
|
||
id: post.id,
|
||
tidpunkt: nu.toISOString(),
|
||
anvandare: anspr.namn,
|
||
handelse: {
|
||
...post.handelse,
|
||
// Fälten nedan går inte att sätta utifrån; skrivs de över här är det
|
||
// just poängen.
|
||
anvandarId: anspr.sub,
|
||
// Signaturen vid avslut var teknikerns egen text — ett fält som
|
||
// HETER signatur men inte var en (panelgranskningen, Volvo-sätet).
|
||
// Nu skrivs den ur verifierad token, precis som `anvandare`.
|
||
// Vad den intygar är därmed exakt vad den kan intyga: vem som var
|
||
// inloggad när avslutet togs emot. Kedjans försegling intygar
|
||
// resten — att loggen är den som förelåg då.
|
||
...(post.handelse.typ === "arende_avslutat" ? { signatur: anspr.namn } : {}),
|
||
...(klientTid && klientTid !== nu.toISOString() ? { registrerad_tidpunkt: klientTid } : {}),
|
||
},
|
||
},
|
||
};
|
||
}
|