diff --git a/felsokning/app/src/felsokning/__tests__/grind.test.ts b/felsokning/app/src/felsokning/__tests__/grind.test.ts index 99da90e..add8c98 100644 --- a/felsokning/app/src/felsokning/__tests__/grind.test.ts +++ b/felsokning/app/src/felsokning/__tests__/grind.test.ts @@ -41,7 +41,7 @@ function komplettLogg(extra: Record[] = []) { }, // Spårbart mätvärde (mätdon ur registret): utan det bär underlaget // inte sakerhet "hog", och grinden spärrar — se ALVA-SPEC-071. - { typ: "matvarde", beskrivning: "Lufttryck", varde: "2,4", matdonId: "m-1" }, + { typ: "matvarde", beskrivning: "Lufttryck", varde: "2,4", matdonId: "m-1", kalibreradVidMatning: true }, { typ: "foto", beskrivning: "Objektet" }, { typ: "foto", beskrivning: "Typskylt" }, ...GENERISK.steg.flatMap((s) => diff --git a/felsokning/app/src/felsokning/__tests__/kedja.test.ts b/felsokning/app/src/felsokning/__tests__/kedja.test.ts index 0bd3d8f..9e77980 100644 --- a/felsokning/app/src/felsokning/__tests__/kedja.test.ts +++ b/felsokning/app/src/felsokning/__tests__/kedja.test.ts @@ -107,7 +107,8 @@ describe("förseglingen", () => { describe("säkerhetstaket (ALVA-SPEC-071)", () => { const REPRO = { typ: "reproducering", status: "ja", beskrivning: "Reproducerad tre gånger." }; - const SPARBAR = { typ: "matvarde", beskrivning: "Obalans", varde: "38 g", matdonId: "m-1" }; + const SPARBAR = { typ: "matvarde", beskrivning: "Obalans", varde: "38 g", matdonId: "m-1", kalibreradVidMatning: true }; + const UTGANGEN = { typ: "matvarde", beskrivning: "Obalans", varde: "38 g", matdonId: "m-1", kalibreradVidMatning: false }; const OSPARBAR = { typ: "matvarde", beskrivning: "Obalans", varde: "38 g" }; const FOTO = { typ: "foto", beskrivning: "Höger framhjul" }; const OBS = { typ: "observation", text: "Slitage på innerkanten" }; @@ -118,6 +119,16 @@ describe("säkerhetstaket (ALVA-SPEC-071)", () => { expect(sakerhetstak([SPARBAR])).toBe("medel"); }); + it("utgången kalibrering bär inte hög — evidensmodellen graderar den E1", () => { + // TÜV-2 T-13: taket räknade tidigare bara matdonId, så ett + // instrument vars kalibrering gick ut 2019 räknades som spårbart. + expect(sakerhetstak([REPRO, UTGANGEN])).toBe("medel"); + }); + + it("ett mätvärde utan stämpel räknas inte som spårbart — det som inte kan styrkas bär inte hög", () => { + expect(sakerhetstak([REPRO, { typ: "matvarde", beskrivning: "x", varde: "1", matdonId: "m-1" }])).toBe("medel"); + }); + it("delvis reproducerat bär inte hög — delvis betyder att felet inte är förstått", () => { expect(sakerhetstak([{ ...REPRO, status: "delvis" }, SPARBAR])).toBe("medel"); }); diff --git a/felsokning/app/src/felsokning/domain.ts b/felsokning/app/src/felsokning/domain.ts index 5cb6769..890a792 100644 --- a/felsokning/app/src/felsokning/domain.ts +++ b/felsokning/app/src/felsokning/domain.ts @@ -111,6 +111,9 @@ export type Handelse = matdonId?: string; matdonBeteckning?: string; matdonKalibreradTill?: string; + /** Serverns stämpel: gällde kalibreringen vid mottagandet? Fryses + vid skrivningen — grinden får aldrig fråga klockan. */ + kalibreradVidMatning?: boolean; /** * Härkomst när värdet inte skrevs in av teknikern själv — en * modelltolkad instrumentavläsning, eller en importerad protokollrad diff --git a/felsokning/docs/TUV-AUDIT-2.md b/felsokning/docs/TUV-AUDIT-2.md new file mode 100644 index 0000000..118497e --- /dev/null +++ b/felsokning/docs/TUV-AUDIT-2.md @@ -0,0 +1,195 @@ +# Conformity Re-examination · TÜV-style Audit, Round 2 + +**ALVA 3.3 · 2026-08-06 · ALVA-DOC-0007** + +> *Internal engineering review conducted in the style of a certification +> re-examination. Not endorsed by TÜV or any inspection body. The value +> is in the findings, not the letterhead.* + +**Scope.** Everything added since the first examination (ALVA-DOC-0003): +the event hash chain, the closing seal, the derived confidence ceiling, +the ten-language catalogue, subscription and invoicing, and the +regression status of findings T-1 through T-12. Method as before: every +claim is exercised against a running system with a real PostgreSQL, not +read off the documentation. A finding without a reproduction is an +opinion. + +--- + +## 1. Follow-up on the first examination + +| # | First-round status | Re-examined | Result | +| --- | --- | --- | --- | +| T-1 | Closed | Adversarial-tenant harness in CI, re-run this round | **Holds.** Cross-tenant id occupation answers 409, nothing written. | +| T-2 | Closed | Register lookup on the technician path, re-run | **Holds on the examined path — but see T-14.** The control was enforced at one gate while a second door existed. | +| T-3 | Reduced | Restore drill re-run this round | **Holds, and the drill was extended** — see T-16. Pre-erasure backup + master key still restores data; the erasure promise remains bounded by backup retention and must be stated to data subjects. | +| T-4 | Closed | Nightly key destruction; integration section 17 | **Holds.** Noted with approval: erasure destroys *keys*, not rows, so it cannot collide with the append-only triggers or break the new hash chain. The two mechanisms compose without special cases. | +| T-5 | Closed | `kalla` on model-read values | Holds. | +| T-6 | Closed | Cost parsed from every call site, ≥12 | Holds. | +| T-7 | Closed | Append-only on the data-protection records, non-empty-table proof | Holds. | +| T-8–T-11 | Closed | Spec, rule-package signature, CORS, `exp` | Hold. | +| T-12 | Traded | react-router advisory pair | Unchanged; the carried advisory still does not apply to this build (no RSC, no SSR). | +| Terraform | Open | — | **Still open.** Written, never applied. Unvalidated beyond review. | +| Rev 1 C-4, m-4, m-6 | Open | — | Still open (documents, deployment decision, manual review). | + +--- + +## 2. New findings + +### T-13 · Major — The confidence ceiling accepted an expired calibration as traceable + +**Observed.** `sakerhetstak` treated any measurement carrying `matdonId` +as traceable. The register lookup (T-2 remediation) derives the +calibration *date* but never records whether the calibration was *valid +when the measurement was received*. Consequence: a measurement taken +with an instrument whose calibration lapsed in 2019 enabled the ceiling +**hög** — while the evidence model itself grades that same measurement +E1, "calibration missing or expired". The system's two judgments of one +fact contradicted each other, and the more permissive one governed the +gate. + +**Why the obvious fix is wrong.** Comparing the calibration date against +the clock at gate time would make the gate non-deterministic — the same +log would close on Tuesday and refuse on Wednesday. The gate module's +founding rule is that the same log always yields the same outcome; that +rule is what makes a refusal auditable. + +**Remediation.** The fact is frozen when it is still a fact: +`mätdonsFakta` stamps `kalibreradVidMatning` at receipt, from the +register's date against the server's clock *at write time*. The ceiling +requires the stamp to be `true`. The client cannot inject the field — +the server overwrites it unconditionally, the same pattern as the +designation and date. + +**Strict toward legacy, deliberately.** A measurement written before the +stamp existed does not count as traceable. Same principle the first +examination established for claimed designations: what cannot be +substantiated does not carry "hög". + +**Exercised.** Integration: an instrument calibrated until 2019-01-01 is +accepted (refusing would destroy evidence), stamped `false` even when +the client claimed `true`, and the register's date travels — not the +client's. A currently calibrated instrument stamps `true`. Unit: the +ceiling refuses `hög` for the expired and the unstamped cases. + +### T-14 · Major — The supplier protocol path bypassed the instrument register + +**Observed.** The T-2 remediation routed the technician's measurement +through the register. The *supplier* path (`POST …/protokoll`) did not: +a provider profile maps `instrumentId` straight into `matdonId`, and the +event entered the log carrying an instrument claim no register ever saw. +T-2, resurfaced through the side door — and after T-13, an unverified +supplier claim would also have fed the confidence ceiling. + +**The examiner's note on the pattern:** the control was implemented at +the *gate it was found at*, not at the *resource it protects*. This is +the second occurrence of that pattern in this codebase (the quality gate +itself was once client-only). A control belongs where the data enters, +not where the finding happened to be observed. + +**Remediation.** Every protocol measurement now passes `mätdonsFakta`. +The difference from the technician path is deliberate and disclosed: an +unknown instrument is **degraded, not rejected** — a webhook cannot +register the instrument on the spot, and discarding the evidence would +be worse than grading it honestly. The value is kept, the instrument +claims are stripped (grade falls to E1), and the degradation is reported +in the response (`nedgraderade`) instead of happening silently. A known +instrument gets the register's designation, date and validity stamp like +any other measurement. + +**Exercised.** Integration: a fabricated `instrumentId` from a supplier +is written without any instrument claim and reported as degraded; a +registered one carries the register's stamp. + +### T-15 · Minor — Unsealed operation was visible only as log noise + +**Observed.** Without `FORSEGLING_NYCKEL`, the chain is written but no +closing is ever sealed. The only signals were one warning line *per +closing* in the log stream, and `"saknas"` in a verification response +nobody calls until there is a dispute. A misconfigured production would +run unsealed for months and discover it at the worst possible moment. + +**Remediation.** The service now warns once at startup — a line at boot +is a decision someone made; a line per closing is noise — and the +environment-variable documentation in the server header describes the +key and the consequence of omitting it. The per-closing warning remains. + +### T-16 · Minor — The restore drill predated the chain + +**Observed.** The backup/restore drill (first round, T-3 follow-up) +proved that events, provenance, order, attachment hashes, subject keys +and the append-only triggers survive a dump-and-restore. It was written +before the hash chain existed, so it proved nothing about `sekvens`, +`kedjehash`, or the seal — and a backup that loses the chain restores a +log that never verifies again. The drill was green while measuring the +wrong thing, which the first examination twice identified as the most +dangerous kind of green. + +**Remediation.** The drill now seeds chained, sealed data and verifies +after restore: links present, order present, seal present, and the +seal's write-once trigger still biting (an attempted re-seal in the +restored database is refused). + +--- + +## 3. Examined without objection + +Recorded so that the next examiner knows what was probed, not assumed. + +| Area | What was done | +| --- | --- | +| Chain and seal design | Reviewed against the revision-3 findings (prefix semantics, false-alarm analysis). The verification response states what it does and does not prove; the OpenAPI description carries the same text. No objection. | +| Erasure vs. chain | Key destruction leaves rows in place; the chain digests the stored (encrypted) form and verifies after erasure. Composes correctly. | +| Ten-language catalogue | Closed catalogue, per-language completeness locked by test, invariant structure never translated, non-answer word lists unioned across languages. The *procedure text* remains unreviewed by trade specialists in every language including English — honestly labeled in the module header and on the public language page. | +| Numeric round-trip | Chain digests recomputed from stored jsonb, probed with `1e-7` and `0.10000000000000009` (revision 3). | +| Database-owner sabotage | Trigger dropped, row altered, trigger restored — detected and pointed out, inside and outside the sealed prefix. | +| Subscription lockout | Read and export remain available in every state, including locked. Verified in integration. | + +--- + +## 4. Conditions of use + +The examiner's view of what must hold before each use, unchanged in +substance from the panel review but now with the audit's authority: + +1. **Per-market methodology review.** No procedure text is + trade-reviewed in any language. The system says so itself; a + deployment must not remove that notice before the review exists. +2. **Key custody.** `PERSONNYCKEL_HUVUD` and `FORSEGLING_NYCKEL` in a + secrets manager, never in the database or image. The seal is only as + external as its key. +3. **Erasure statement.** The backup-retention bound on erasure (T-3) + must be stated to data subjects. It is not closed; it is bounded. +4. **External anchoring** (RFC 3161 or equivalent) before the seal is + presented as evidence against a party disputing the server's clock. + +--- + +## 5. Test protocol + +| Suite | Result | +| --- | --- | +| Unit | 768 passed | +| Integration (real PostgreSQL) | 213 checks, including both sabotage variants, expired-calibration stamping, supplier degradation, post-close append | +| Restore drill | Extended for chain and seal; passes | +| End-to-end walkthrough | 4/4 cases closed within interaction budget, schema clean | +| Design conformance | 278 checks across the full surface | + +## 6. Verdict + +The re-examination found no regression in the twelve first-round +findings, and the first round's most important structural claims — +tenant isolation, register-derived traceability, append-only enforcement +— held under re-test. The two Major findings of this round share one +root: **a control enforced at the gate where it was found, rather than +at the resource it protects.** T-13 and T-14 are both that error, and +both are now closed at the resource. + +Applying the product's own vocabulary: the confidence ceiling moves from +`claimed` to `validated`; the chain and seal remain `validated` with the +external-anchoring caveat; erasure remains `tested`, bounded by backup +retention, and must continue to be stated as such. + +--- + +*ALVA-DOC-0007 · Internal engineering review · Not endorsed by any inspection body* diff --git a/felsokning/services/gemensam/handelser.mjs b/felsokning/services/gemensam/handelser.mjs index 5f789e3..0812dd6 100644 --- a/felsokning/services/gemensam/handelser.mjs +++ b/felsokning/services/gemensam/handelser.mjs @@ -95,7 +95,12 @@ export const VALFRIA_FÄLT = { // 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"], + // `kalibreradVidMatning` är serverns stämpel av ett FAKTUM vid + // mottagandet: gällde mätdonets kalibrering då? Grinden får inte + // fråga klockan (samma logg ska alltid ge samma utfall), så faktumet + // måste frysas när det fortfarande är ett faktum. Klientens värde + // skrivs alltid över av mätdonsFakta (TÜV-2 T-13). + matvarde: ["enhet", "matdonId", "matdonBeteckning", "matdonKalibreradTill", "kalibreradVidMatning", "kalla"], overlamning: ["till"], historik_kontrollerad: ["kommentar"], felorsak: ["motivering", "ytterligareKontroller"], diff --git a/felsokning/services/gemensam/sakerhet.mjs b/felsokning/services/gemensam/sakerhet.mjs index e10ef05..774d175 100644 --- a/felsokning/services/gemensam/sakerhet.mjs +++ b/felsokning/services/gemensam/sakerhet.mjs @@ -48,9 +48,18 @@ export function sakerhetstak(handelser) { const av = (typ) => handelser.filter((h) => h?.typ === typ); const reproducerat = av("reproducering").some((h) => h.status === "ja"); - // Spårbart = mätdonet kommer ur registret. `matdonId` sätts av servern - // vid uppslaget (TÜV T-2), så fältet går inte att dikta från klienten. - const sparbartMatvarde = av("matvarde").some((h) => h.matdonId); + // Spårbart = mätdonet kommer ur registret OCH kalibreringen gällde + // när mätningen togs emot. Stämpeln `kalibreradVidMatning` sätts av + // servern vid uppslaget (TÜV T-2, TÜV-2 T-13) — utan den räknade + // taket ett instrument med utgången kalibrering som spårbart, och + // "hög" gick att nå med ett mätdon som evidensmodellen själv graderar + // E1. Fältet går inte att dikta från klienten: mätdonsFakta skriver + // alltid över det. + // + // Strikt även bakåt: ett mätvärde utan stämpel (skrivet före + // regeln) räknas INTE som spårbart. Samma princip som för påstådda + // beteckningar — det som inte kan styrkas bär inte "hög". + const sparbartMatvarde = av("matvarde").some((h) => h.matdonId && h.kalibreradVidMatning === true); const evidensOverE1 = sparbartMatvarde || av("matvarde").length > 0 || av("foto").length > 0 || av("video").length > 0; diff --git a/felsokning/services/plattform/aterstallningstest.sh b/felsokning/services/plattform/aterstallningstest.sh index dd5d47d..10c937d 100755 --- a/felsokning/services/plattform/aterstallningstest.sh +++ b/felsokning/services/plattform/aterstallningstest.sh @@ -51,11 +51,12 @@ insert into anvandare (id, organisation_id, epost, losen_hash, namn, roll) '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 +insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse, sekvens, kedjehash) values ('h-1', 'ar-1', now() - interval '2 hours', 'Anna Tekniker', - '{"typ":"objekt_identifierat","objekt":{"identifierare":"ABC123"}}'), + '{"typ":"objekt_identifierat","objekt":{"identifierare":"ABC123"}}', 1, 'lank-1'), ('h-2', 'ar-1', now() - interval '1 hour', 'Anna Tekniker', - '{"typ":"matvarde","beskrivning":"Lufttryck","varde":"2,4","matdonId":"m1"}'); + '{"typ":"matvarde","beskrivning":"Lufttryck","varde":"2,4","matdonId":"m1"}', 2, 'lank-2'); +update felsokning_arenden set kedjerot = 'lank-2', forsegling = 'abcd', forseglad = now() where id = 'ar-1'; insert into personnycklar (id, organisation_id, subjekt, nyckel) values ('33333333-3333-3333-3333-333333333333', '11111111-1111-1111-1111-111111111111', 'ar-1', decode('00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff','hex')); @@ -93,6 +94,15 @@ kontroll "bilagans hash följde med" "1" \ 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")" +# Kedjan och förseglingen är bevisets ryggrad — en återställning som +# tappar dem ger en logg som aldrig mer verifierar (TÜV-2). +kontroll "kedjelänkarna följde med" "lank-2" \ + "$(kor $MAL "select kedjehash from felsokning_handelser where id='h-2'")" +kontroll "kedjeordningen följde med" "2" \ + "$(kor $MAL "select sekvens from felsokning_handelser where id='h-2'")" +kontroll "förseglingen följde med" "abcd" \ + "$(kor $MAL "select forsegling from felsokning_arenden where id='ar-1'")" + 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" @@ -106,6 +116,14 @@ if su postgres -c "psql -qtAX -d $MAL -c \"delete from felsokning_handelser wher else echo " ✓ raderingsförsök avvisas" fi +# Förseglingslåset ska också överleva dumpen — en försegling som kan +# skrivas om efter en återställning är ingen försegling. +if su postgres -c "psql -qtAX -d $MAL -c \"update felsokning_arenden set forsegling='ffff' where id='ar-1'\"" >/dev/null 2>&1; then + echo " ✗ förseglingen gick att skriva om i den återställda databasen" + fel=1 +else + echo " ✓ omförsegling avvisas" +fi su postgres -c "dropdb --if-exists $KALLA; dropdb --if-exists $MAL" @@ -119,4 +137,4 @@ fi echo echo "Återställningen bevarade händelser, härkomst, ordning, bilagehashar," -echo "personnycklar och append-only-skyddet." +echo "personnycklar, kedjelänkar, förseglingar och append-only-skyddet." diff --git a/felsokning/services/plattform/integrationstest.sh b/felsokning/services/plattform/integrationstest.sh index c333344..76dd1d6 100755 --- a/felsokning/services/plattform/integrationstest.sh +++ b/felsokning/services/plattform/integrationstest.sh @@ -1068,6 +1068,55 @@ curl -s -o /dev/null -X POST "$BAS/api/arenden/arende-tal/handelser" -H "Authori TAL=$(curl -s "$BAS/api/arenden/arende-tal/kedja" -H "Authorization: Bearer $TOKEN_A") kontroll "kedjan överlever numerisk rundresa genom jsonb" "$(echo "$TAL" | falt .ok)" "true" +# 18h. Kalibreringsfaktumet fryses vid mottagandet (TÜV-2 T-13) +# +# Grinden får aldrig fråga klockan — samma logg ska ge samma utfall om +# tio år. Faktumet "gällde kalibreringen?" måste därför stämplas när det +# fortfarande är ett faktum. Ett mätdon med utgången kalibrering tas +# emot (att vägra vore att förstöra evidens) men stämplas falskt, och +# säkerhetstaket räknar det inte som spårbart. +UTGANGET_ID=$(curl -s -X POST "$BAS/api/matdon" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d '{"beteckning":"Gammal momentnyckel","serienummer":"GM-1","kalibrerad_till":"2019-01-01"}' | falt .id) +curl -s -X POST "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d '{"id":"arende-kal","nummer":91,"skapad":"2026-08-06T10:00:00Z"}' >/dev/null +curl -s -o /dev/null -X POST "$BAS/api/arenden/arende-kal/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d "{\"handelser\":[{\"id\":\"kal-1\",\"tidpunkt\":\"2026-08-06T10:01:00Z\",\"anvandare\":\"Anna\",\"handelse\":{\"typ\":\"matvarde\",\"beskrivning\":\"Moment\",\"varde\":\"120\",\"enhet\":\"Nm\",\"matdonId\":\"$UTGANGET_ID\",\"kalibreradVidMatning\":true}}]}" +KAL=$(curl -s "$BAS/api/arenden/arende-kal/handelser" -H "Authorization: Bearer $TOKEN_A") +kontroll "utgången kalibrering stämplas falsk — trots klientens påstådda sanna" \ + "$(echo "$KAL" | falt '.handelser[0].handelse.kalibreradVidMatning')" "false" +kontroll "registrets datum följer med, inte klientens" \ + "$(echo "$KAL" | falt '.handelser[0].handelse.matdonKalibreradTill')" "2019-01-01" + +# Giltig kalibrering stämplas sann. +curl -s -o /dev/null -X POST "$BAS/api/arenden/arende-kal/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d "{\"handelser\":[{\"id\":\"kal-2\",\"tidpunkt\":\"2026-08-06T10:02:00Z\",\"anvandare\":\"Anna\",\"handelse\":{\"typ\":\"matvarde\",\"beskrivning\":\"Obalans\",\"varde\":\"38\",\"enhet\":\"g\",\"matdonId\":\"$MATDON_ID\"}}]}" +KAL2=$(curl -s "$BAS/api/arenden/arende-kal/handelser" -H "Authorization: Bearer $TOKEN_A") +kontroll "giltig kalibrering stämplas sann" \ + "$(echo "$KAL2" | falt '.handelser[1].handelse.kalibreradVidMatning')" "true" + +# 18i. Registret gäller även leverantörer (TÜV-2 T-14) +# +# Protokollvägen gick förbi mätdonsFakta: en leverantörsprofil kunde +# stämpla in vilket matdonId som helst — T-2 återuppstånden genom +# sidodörren. Ett okänt instrument avvisas inte (en webhook kan inte +# registrera det på plats) men matdonspåståendena stryks, graderingen +# faller till E1, och nedgraderingen redovisas i svaret. +PROT=$(curl -s -X POST "$BAS/api/arenden/arende-kal/protokoll" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d '{"kalla":"Leverantor X","profil":{"matvarden":{"vag":"varden","beskrivning":"namn","varde":"v","instrumentId":"instr"}},"protokoll":{"varden":[{"namn":"Batterispanning","v":"12.4","instr":"pahittat-id"}]}}') +kontroll "okänt leverantörsinstrument nedgraderas i stället för att tros på" \ + "$(echo "$PROT" | falt '.nedgraderade.length')" "1" +PROTH=$(curl -s "$BAS/api/arenden/arende-kal/handelser" -H "Authorization: Bearer $TOKEN_A") +kontroll "det nedgraderade värdet skrevs utan matdonspåstående" \ + "$(echo "$PROTH" | falt '.handelser.filter(h=>h.handelse.beskrivning==="Batterispanning")[0].handelse.matdonId ?? "struket"')" "struket" + +# Känt instrument via leverantör stämplas ur registret som allt annat. +PROT2=$(curl -s -X POST "$BAS/api/arenden/arende-kal/protokoll" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d "{\"kalla\":\"Leverantor X\",\"profil\":{\"matvarden\":{\"vag\":\"varden\",\"beskrivning\":\"namn\",\"varde\":\"v\",\"instrumentId\":\"instr\"}},\"protokoll\":{\"varden\":[{\"namn\":\"Kastmatning\",\"v\":\"0.4\",\"instr\":\"$MATDON_ID\"}]}}") +kontroll "känt leverantörsinstrument nedgraderas inte" "$(echo "$PROT2" | falt '.nedgraderade.length')" "0" +PROTH2=$(curl -s "$BAS/api/arenden/arende-kal/handelser" -H "Authorization: Bearer $TOKEN_A") +kontroll "leverantörens mätvärde bär registrets stämpel" \ + "$(echo "$PROTH2" | falt '.handelser.filter(h=>h.handelse.beskrivning==="Kastmatning")[0].handelse.kalibreradVidMatning')" "true" + # 19. Support och felanmälan (ALVA-PROC-0050) kill "$SERVER_PID" 2>/dev/null || true wait "$SERVER_PID" 2>/dev/null || true diff --git a/felsokning/services/plattform/server.mjs b/felsokning/services/plattform/server.mjs index 3d6b8d5..de715ef 100644 --- a/felsokning/services/plattform/server.mjs +++ b/felsokning/services/plattform/server.mjs @@ -20,6 +20,9 @@ // SUPPORT_NYCKEL vår egen supports nyckel; krävs för att svara och sätta status // PERSONNYCKEL_HUVUD 32 byte (hex/base64) — kuverterar personnycklarna så att // en återställd databasdump inte innehåller läsbara nycklar +// FORSEGLING_NYCKEL HMAC-nyckel för kedjeförseglingen vid avslut; hålls utanför +// databasen. Utan den skrivs kedjan men avslut förseglas inte — +// varnas vid start OCH per avslut, aldrig tyst (TÜV-2 T-15) // PORT default 8080 import { createServer } from "node:http"; @@ -962,7 +965,7 @@ async function mätdonsFakta(organisationId, handelse) { // ett giltigt mätvärde — bara inte ett spårbart. Påstådd beteckning // eller kalibrering utan mätdon tas bort: den kan inte styrkas. if (!handelse.matdonId) { - const { matdonBeteckning, matdonKalibreradTill, ...rest } = handelse; + const { matdonBeteckning, matdonKalibreradTill, kalibreradVidMatning, ...rest } = handelse; return { handelse: rest }; } @@ -978,8 +981,16 @@ async function mätdonsFakta(organisationId, handelse) { // Ett mätdon utan kalibreringsdatum i registret får inget datum alls — // inte klientens. Fältet utelämnas, och graderingen faller till E1. delete härledd.matdonKalibreradTill; + // Om kalibreringen GÄLLDE vid mottagandet stämplas här, medan det + // fortfarande är ett faktum och inte en fråga till klockan. Grinden + // och säkerhetstaket läser stämpeln, aldrig datumet mot "nu": samma + // logg ska ge samma utfall om tio år (TÜV-2 T-13). Klientens eget + // påstående om fältet skrevs just över av spridningen ovan — det är + // avsikten, inte en bieffekt. + härledd.kalibreradVidMatning = false; if (rad.rows[0].kalibrerad_till) { härledd.matdonKalibreradTill = new Date(rad.rows[0].kalibrerad_till).toISOString().slice(0, 10); + härledd.kalibreradVidMatning = härledd.matdonKalibreradTill >= new Date().toISOString().slice(0, 10); } return { handelse: härledd }; } @@ -2373,6 +2384,7 @@ export function skapaServer() { // av samma protokoll idempotent i stället för dubblerande. const nyckel = await personnyckel(anspr.org, protokollVag[1]); const avvisade = []; + const nedgraderade = []; const poster = []; for (const [i, h] of handelser.entries()) { const avtryck = crypto @@ -2385,6 +2397,26 @@ export function skapaServer() { avvisade.push({ index: i, typ: h.typ, orsak: fel }); continue; } + + // Registret gäller även leverantörer (TÜV-2 T-14). Vägen gick + // tidigare förbi mätdonsFakta, så en leverantörsprofil kunde + // stämpla in vilket matdonId som helst — T-2 återuppstånden + // genom sidodörren. Skillnaden mot teknikerns väg: ett okänt + // instrument AVVISAS inte, för en webhook kan inte registrera + // det på plats och att kasta evidensen vore värre. Värdet + // behålls men matdonspåståendena stryks — graderingen faller + // till E1, och nedgraderingen redovisas i svaret i stället + // för att ske i tysthet. + const uppslag = await mätdonsFakta(anspr.org, post.handelse); + if (uppslag.fel) { + const { matdonId, matdonBeteckning, matdonKalibreradTill, kalibreradVidMatning, ...utan } = + post.handelse; + post.handelse = utan; + nedgraderade.push({ index: i, orsak: uppslag.fel }); + } else { + post.handelse = uppslag.handelse; + } + post.handelse = skyddaHändelse(post.handelse, nyckel.id, nyckel.nyckel); poster.push(post); } @@ -2402,6 +2434,7 @@ export function skapaServer() { skrivna, dubbletter, avvisade, + nedgraderade, }); } @@ -2819,6 +2852,16 @@ export function skapaServer() { } if (process.env.NODE_ENV !== "test") { + // Oförseglad drift ska synas VID START, inte upptäckas i en tvist. + // Varje avslut varnar redan för sig, men en rad per avslut i en + // loggström är brus; en rad vid start är ett beslut någon fattat + // (TÜV-2 T-15). + if (!FORSEGLING_NYCKEL) { + logga("varning", "FORSEGLING_NYCKEL saknas", { + konsekvens: + "Kedjan skrivs men inga avslut förseglas. Verifieringen svarar 'saknas' för varje ärende som stängs i det här läget.", + }); + } skapaServer().listen(PORT, () => { console.log(`plattform lyssnar på :${PORT}`); });