Härdning av hela TÜV-revisionen
Tio av tolv fynd stängda, två reducerade med skälen utskrivna. Varje
stängning prövas av integrationssviten mot riktig Postgres — 144
kontroller, upp från 100.
T-1 Händelsenyckeln är nu (arende_id, id). Det finns inget delat
namnrum kvar att ockupera, så attacken saknar yta i stället för att
vara mildrad. Inom ett ärende bedöms en kollision på klientens
avtryck — en hash av det kanoniserade innehåll klienten skickade,
taget före serverns egna fält och före krypteringen, eftersom
varken raden eller nyttolasten går att jämföra. Identiskt innehåll
är fortfarande idempotent; samma id med annat innehåll ger 409 och
skriver ingenting alls, inte heller resten av satsen. Samma attack
en nivå upp — ärende-id är lika förutsägbart — stängs separat: ett
id som ägs av en annan organisation ger 409 i stället för att tyst
låta bli, vilket tidigare lämnade offrets ärende oskapat och varje
senare synk svarande 404.
T-2 Mätdonet slås upp i registret. Beteckning och kalibrering HÄRLEDS
därifrån och skriver över det klienten skickade. Okänt mätdon ger
400. Utgånget avvisas inte — mätningen gjordes — men registrets
datum följer med, så graderingen faller på registrets uppgift. Ett
mätvärde utan mätdon får sina påstådda uppgifter borttagna.
T-3 Reducerad, inte stängd. Personnycklarna kuverteras under en
huvudnyckel utanför databasen. En återställd dump ger nycklar som
inte öppnas — verifierat genom att starta om utan huvudnyckeln. Vad
som återstår står utskrivet: en backup tagen FÖRE en radering, plus
huvudnyckeln, återställer fortfarande uppgifterna.
Därtill: gallringen verkställs nu av ett eget jobb och grupperar på det
blindade fordonsindexet så att en delad nyckel inte gallras för tidigt
(T-4) · AI-avlästa mätvärden bär härkomst (T-5) · bcrypt kostnad 12
(T-6) · åtkomstloggen och raderingsregistret är append-only, med en
smalare regel för personnycklarna som måste kunna förstöras (T-7) ·
åtkomstloggen dokumenterad (T-8) · ett externt regelpaket utan signatur
spärrar avslut (T-9) · CORS faller inte längre öppet (T-10) · exp krävs
i token (T-11) · react-router 7 (T-12, med den kvarvarande avvikelsen
motiverad).
Den motspelande hyresgästen som revisionen efterlyste finns nu som
testform och körs i CI.
---- Vad härdningen själv avslöjade -----------------------------------
Två av rättelserna var kortvarigt fel på samma sätt som fynden, och
bägge fångades bara av att jag försökte bevisa dem:
T-7-testet var grönt mot en TOM tabell. En radnivåtrigger har inga
rader att fyra på, så delete lyckades och kontrollen mätte ingenting.
T-6-testet påstod anropet, inte kostnaden. toContain("gen_salt('bf')")
hade accepterat kostnad 6 för evigt — och gjorde det, så länge det
fanns.
Bägge är mönstret revisionen namngav: en kontroll som är riktig i sina
egna termer och oprövad vid sin gräns. Det gäller tester lika mycket som
kod.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
// Gallring (ALVA · TÜV T-4).
|
||||
//
|
||||
// ---- Varför den här filen finns ---------------------------------------
|
||||
//
|
||||
// `gallras_efter` räknades fram vid avslut, utifrån ärendetypen, och
|
||||
// skrevs till ärenderaden. Sedan hände ingenting. En sökning genom hela
|
||||
// repot visade att INGENTING läste kolumnen — ingen körning, inget
|
||||
// schemalagt jobb, ingen fråga, inget CI-steg. Lagringsbegränsningen var
|
||||
// en nedskriven avsikt, inte en verkställd kontroll.
|
||||
//
|
||||
// Det gjorde också att ett annat fel fick leva: triggern som förbjöd all
|
||||
// update gjorde det omöjligt att ens SKRIVA kolumnen, och ingen märkte
|
||||
// det, därför att ingenting längre ned konsumerade värdet.
|
||||
//
|
||||
// ---- Vad gallring är här ----------------------------------------------
|
||||
//
|
||||
// Inte radering av ärenden. Loggen är append-only och ska förbli det —
|
||||
// protokollet över vad som kontrollerades har ett värde långt efter att
|
||||
// identifieringen har det. Gallring är att förstöra personnyckeln, precis
|
||||
// som en raderingsbegäran gör. Kvar blir ett ärende som fortfarande kan
|
||||
// visa att lufttrycket mättes till 2,4 bar klockan 08:42, bara inte
|
||||
// längre vems bil det gällde.
|
||||
//
|
||||
// ---- Försiktighetsregeln ----------------------------------------------
|
||||
//
|
||||
// En nyckel förstörs bara när SAMTLIGA ärenden som delar den har passerat
|
||||
// sitt gallringsdatum. Ett fordon som varit inne fem gånger har fem
|
||||
// ärenden och en nyckel; att gallra på det äldsta hade gjort de fyra
|
||||
// senaste oläsbara i förtid. Ett ärende utan gallringsdatum — öppet, eller
|
||||
// avslutat innan datum sattes — räknas som ännu inte passerat, eftersom
|
||||
// för tidig gallring inte går att ångra.
|
||||
|
||||
import pg from "pg";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Hittar och förstör nycklar vars samtliga ärenden har passerat sin tid.
|
||||
*
|
||||
* @param pool pg.Pool
|
||||
* @param nu tidpunkt att jämföra mot; injicerad för testbarhet
|
||||
* @param torrkor när true räknas allt fram men ingenting förstörs
|
||||
*/
|
||||
export async function gallra(pool, { nu = new Date(), torrkor = false } = {}) {
|
||||
// Ett subjekt i personnycklar är ett ärende-id (se personnyckel() i
|
||||
// server.mjs). Nyckeln kan delas av flera ärenden på samma fordon via
|
||||
// det blindade indexet, så gruppen måste bildas på indexet — inte på
|
||||
// subjektet — annars gallras en delad nyckel för tidigt.
|
||||
const kandidater = await pool.query(
|
||||
`with nyckelns_arenden as (
|
||||
select n.id as nyckel_id,
|
||||
n.organisation_id,
|
||||
a.gallras_efter,
|
||||
coalesce(a.identifierare_index, a.id) as grupp
|
||||
from personnycklar n
|
||||
join felsokning_arenden a
|
||||
on a.organisation_id = n.organisation_id and a.id = n.subjekt
|
||||
),
|
||||
samma_fordon as (
|
||||
select k.nyckel_id, k.organisation_id, b.gallras_efter
|
||||
from nyckelns_arenden k
|
||||
join felsokning_arenden b
|
||||
on b.organisation_id = k.organisation_id
|
||||
and coalesce(b.identifierare_index, b.id) = k.grupp
|
||||
)
|
||||
select nyckel_id, organisation_id, count(*)::int as antal_arenden
|
||||
from samma_fordon
|
||||
group by nyckel_id, organisation_id
|
||||
having bool_and(gallras_efter is not null and gallras_efter <= $1)`,
|
||||
[nu],
|
||||
);
|
||||
|
||||
if (kandidater.rowCount === 0 || torrkor) {
|
||||
return { forstorda: 0, arenden: 0, kandidater: kandidater.rowCount, torrkor };
|
||||
}
|
||||
|
||||
let forstorda = 0;
|
||||
let arenden = 0;
|
||||
for (const rad of kandidater.rows) {
|
||||
const klient = await pool.connect();
|
||||
try {
|
||||
await klient.query("begin");
|
||||
const bort = await klient.query(`delete from personnycklar where id = $1 returning id`, [rad.nyckel_id]);
|
||||
if (bort.rowCount > 0) {
|
||||
// Gallringen skrivs i samma register som en begärd radering, med
|
||||
// ett hashat subjekt: registret får inte självt bära uppgiften
|
||||
// det bevisar att någon gjort oåtkomlig.
|
||||
await klient.query(
|
||||
`insert into raderingar (organisation_id, subjekt_hash, begard, begard_av, antal_arenden)
|
||||
values ($1, $2, $3, $4, $5)`,
|
||||
[
|
||||
rad.organisation_id,
|
||||
createHash("sha256").update(`gallring:${rad.nyckel_id}`).digest("hex"),
|
||||
nu,
|
||||
"Gallring (ALVA-PROC-0040)",
|
||||
rad.antal_arenden,
|
||||
],
|
||||
);
|
||||
forstorda += 1;
|
||||
arenden += rad.antal_arenden;
|
||||
}
|
||||
await klient.query("commit");
|
||||
} catch (fel) {
|
||||
await klient.query("rollback").catch(() => {});
|
||||
throw fel;
|
||||
} finally {
|
||||
klient.release();
|
||||
}
|
||||
}
|
||||
|
||||
return { forstorda, arenden, kandidater: kandidater.rowCount, torrkor };
|
||||
}
|
||||
|
||||
// Körs som eget jobb (CronJob i klustret), inte i webbtjänstens process:
|
||||
// en gallring som avbryts av en omstart mitt i ska kunna köras om, och
|
||||
// den ska synas som en egen körning i driften.
|
||||
//
|
||||
// node gallring.mjs verkställer
|
||||
// node gallring.mjs --torrkor räknar bara
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 2 });
|
||||
try {
|
||||
const utfall = await gallra(pool, { torrkor: process.argv.includes("--torrkor") });
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
nivå: "info",
|
||||
meddelande: "gallring",
|
||||
tid: new Date().toISOString(),
|
||||
...utfall,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
@@ -636,4 +636,216 @@ LUCKOR=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d f
|
||||
-tAc "select (select max(nummer) from fakturor) - (select count(*) from fakturor)")
|
||||
kontroll "nummerserien är utan luckor" "$LUCKOR" "0"
|
||||
|
||||
# 13. Motspelande hyresgäst (TÜV T-1)
|
||||
#
|
||||
# Revisionens huvudfynd: händelse-id sattes av klienten och var
|
||||
# förutsägbart, primärnyckeln var global, och insert skedde med
|
||||
# `on conflict do nothing`. Org B kunde ockupera id på SITT EGET ärende
|
||||
# som org A snart skulle använda — och org A:s händelse föll tyst bort
|
||||
# med statuskod 200.
|
||||
#
|
||||
# Ingen befintlig svit kunde se det: varje test kör en organisation, eller
|
||||
# två isolerade. Ingen modellerar den enas skrivningar som indata till den
|
||||
# andras misslyckande. Det gör den här.
|
||||
|
||||
IDN="mshhkoq9-0 mshhkoq9-1 mshhkoq9-2"
|
||||
FORSTA=$(echo "$IDN" | cut -d' ' -f1)
|
||||
|
||||
curl -s -X POST "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_B" -H 'Content-Type: application/json' \
|
||||
-d '{"id":"arende-motspel-b","nummer":90,"skapad":"2026-08-06T08:00:00Z"}' >/dev/null
|
||||
curl -s -X POST "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
|
||||
-d '{"id":"arende-motspel-a","nummer":91,"skapad":"2026-08-06T08:00:00Z"}' >/dev/null
|
||||
|
||||
# Org B ockuperar id:na på sitt eget ärende.
|
||||
POSTER=$(node -pe "
|
||||
JSON.stringify({ handelser: '$IDN'.split(' ').map((id) => ({
|
||||
id, tidpunkt: '2026-08-06T08:01:00Z', anvandare: 'Bo',
|
||||
handelse: { typ: 'observation', text: 'Ockuperad.' } })) })
|
||||
")
|
||||
curl -s -X POST "$BAS/api/arenden/arende-motspel-b/handelser" -H "Authorization: Bearer $TOKEN_B" \
|
||||
-H 'Content-Type: application/json' -d "$POSTER" >/dev/null
|
||||
|
||||
# Org A dokumenterar en felorsak med exakt samma id.
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden/arende-motspel-a/handelser" \
|
||||
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
|
||||
-d "{\"handelser\":[{\"id\":\"$FORSTA\",\"tidpunkt\":\"2026-08-06T08:02:00Z\",\"anvandare\":\"Anna\",
|
||||
\"handelse\":{\"typ\":\"felorsak\",\"avvikelse\":\"Bromsledning skavd mot chassi.\",
|
||||
\"orsaker\":[\"Montagefel\"],\"underlag\":[\"Foto\"],\"sakerhet\":\"hog\",
|
||||
\"atgard\":\"Byt ledning och satt ny klammer.\"}}]}")
|
||||
kontroll "org A:s skrivning lyckas trots ockuperat id" "$KOD" "200"
|
||||
ANTAL=$(curl -s "$BAS/api/arenden/arende-motspel-a/handelser" -H "Authorization: Bearer $TOKEN_A" | falt .handelser.length)
|
||||
kontroll "org A:s felorsak finns i loggen" "$ANTAL" "1"
|
||||
ANTAL_B=$(curl -s "$BAS/api/arenden/arende-motspel-b/handelser" -H "Authorization: Bearer $TOKEN_B" | falt .handelser.length)
|
||||
kontroll "org B:s egna poster är orörda" "$ANTAL_B" "3"
|
||||
|
||||
# Samma id, samma innehåll, en gång till: en ofarlig omsändning ska förbli tyst.
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden/arende-motspel-b/handelser" \
|
||||
-H "Authorization: Bearer $TOKEN_B" -H 'Content-Type: application/json' -d "$POSTER")
|
||||
kontroll "identisk omsandning ar fortfarande idempotent" "$KOD" "200"
|
||||
|
||||
# Samma id, ANNAT innehåll: ska falla högt i stället för att tigas ihjäl.
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden/arende-motspel-b/handelser" \
|
||||
-H "Authorization: Bearer $TOKEN_B" -H 'Content-Type: application/json' \
|
||||
-d "{\"handelser\":[{\"id\":\"$FORSTA\",\"tidpunkt\":\"2026-08-06T08:03:00Z\",\"anvandare\":\"Bo\",
|
||||
\"handelse\":{\"typ\":\"observation\",\"text\":\"NAGOT HELT ANNAT\"}}]}")
|
||||
kontroll "samma id med annat innehall ger 409" "$KOD" "409"
|
||||
ANTAL_B=$(curl -s "$BAS/api/arenden/arende-motspel-b/handelser" -H "Authorization: Bearer $TOKEN_B" | falt .handelser.length)
|
||||
kontroll "ingenting skrevs vid 409" "$ANTAL_B" "3"
|
||||
|
||||
# Samma attack en niva upp: arende-id ar lika forutsagbart.
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_A" \
|
||||
-H 'Content-Type: application/json' -d '{"id":"arende-motspel-b","nummer":92,"skapad":"2026-08-06T09:00:00Z"}')
|
||||
kontroll "arende-id upptaget av annan org ger 409" "$KOD" "409"
|
||||
|
||||
# 14. Matkedjan slas upp i registret (TÜV T-2)
|
||||
#
|
||||
# Evidensgraden E4 kraver att det gar att visa VAD som matte och att
|
||||
# instrumentet var kalibrerat. Bagge falten kom fran klienten och lagrades
|
||||
# oprovade — en organisation utan ett enda registrerat matdon kunde skicka
|
||||
# kalibrering 2099-12-31 och fa E4.
|
||||
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden/arende-motspel-a/handelser" \
|
||||
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
|
||||
-d '{"handelser":[{"id":"m-falsk","tidpunkt":"2026-08-06T08:10:00Z","anvandare":"Anna",
|
||||
"handelse":{"typ":"matvarde","beskrivning":"Kokpunkt","varde":"238","enhet":"C",
|
||||
"matdonId":"finns-inte-i-registret","matdonBeteckning":"Kalibrerad testare 9000",
|
||||
"matdonKalibreradTill":"2099-12-31"}}]}')
|
||||
kontroll "okant matdon avvisas" "$KOD" "400"
|
||||
|
||||
# Ett riktigt matdon, med ett kalibreringsdatum registret ager.
|
||||
MATDON=$(curl -s -X POST "$BAS/api/matdon" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
|
||||
-d '{"beteckning":"Bromsvatsketestare BT-2","serienummer":"SN-4471","kalibrerad_till":"2027-06-30"}' | falt .id)
|
||||
curl -s -X POST "$BAS/api/arenden/arende-motspel-a/handelser" -H "Authorization: Bearer $TOKEN_A" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"handelser\":[{\"id\":\"m-akta\",\"tidpunkt\":\"2026-08-06T08:11:00Z\",\"anvandare\":\"Anna\",
|
||||
\"handelse\":{\"typ\":\"matvarde\",\"beskrivning\":\"Kokpunkt\",\"varde\":\"238\",\"enhet\":\"C\",
|
||||
\"matdonId\":\"$MATDON\",\"matdonBeteckning\":\"PAHITTAD BETECKNING\",
|
||||
\"matdonKalibreradTill\":\"2099-12-31\"}}]}" >/dev/null
|
||||
|
||||
HAMTAT=$(curl -s "$BAS/api/arenden/arende-motspel-a/handelser" -H "Authorization: Bearer $TOKEN_A")
|
||||
kontroll "beteckningen kommer fran registret" \
|
||||
"$(echo "$HAMTAT" | falt '.handelser.find(h=>h.id==="m-akta").handelse.matdonBeteckning')" "Bromsvatsketestare BT-2"
|
||||
kontroll "kalibreringen kommer fran registret" \
|
||||
"$(echo "$HAMTAT" | falt '.handelser.find(h=>h.id==="m-akta").handelse.matdonKalibreradTill')" "2027-06-30"
|
||||
|
||||
# Ett matvarde utan matdon far inte bara pasta en beteckning heller.
|
||||
curl -s -X POST "$BAS/api/arenden/arende-motspel-a/handelser" -H "Authorization: Bearer $TOKEN_A" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"handelser":[{"id":"m-utan","tidpunkt":"2026-08-06T08:12:00Z","anvandare":"Anna",
|
||||
"handelse":{"typ":"matvarde","beskrivning":"Tryck","varde":"2.4","enhet":"bar",
|
||||
"matdonBeteckning":"PASTADD","matdonKalibreradTill":"2099-12-31"}}]}' >/dev/null
|
||||
HAMTAT=$(curl -s "$BAS/api/arenden/arende-motspel-a/handelser" -H "Authorization: Bearer $TOKEN_A")
|
||||
kontroll "pastadd kalibrering utan matdon tas bort" \
|
||||
"$(echo "$HAMTAT" | falt '.handelser.find(h=>h.id==="m-utan").handelse.matdonKalibreradTill ?? "borta"')" "borta"
|
||||
|
||||
# 15. Underlagen om atkomst och radering ar append-only (TÜV T-7)
|
||||
#
|
||||
# Radniva-triggern fyrar per RAD. En tom tabell later darfor `delete`
|
||||
# lyckas utan att skyddet ens provats — forsta versionen av det har testet
|
||||
# var gron pa raderingar just darfor, och bevisade ingenting. Bagge
|
||||
# tabellerna maste ha innehall innan de provas.
|
||||
PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning -qc \
|
||||
"insert into raderingar (organisation_id, subjekt_hash, begard, begard_av, antal_arenden)
|
||||
select id, 'prov', now(), 'Integrationstest', 0 from organisationer limit 1"
|
||||
for TABELL in atkomstlogg raderingar; do
|
||||
RADER=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
|
||||
-tAc "select count(*) from $TABELL")
|
||||
if [ "$RADER" = "0" ]; then echo "✗ $TABELL ar tom — provet sager ingenting"; exit 1; fi
|
||||
if PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
|
||||
-qc "delete from $TABELL" 2>/dev/null; then
|
||||
echo "✗ $TABELL gar att radera"; exit 1
|
||||
else
|
||||
echo "✓ $TABELL gar inte att radera"
|
||||
fi
|
||||
done
|
||||
if PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
|
||||
-qc "update personnycklar set subjekt = 'annat'" 2>/dev/null; then
|
||||
echo "✗ personnyckelns subjekt gar att flytta"; exit 1
|
||||
else
|
||||
echo "✓ personnyckelns tillhorighet kan inte andras"
|
||||
fi
|
||||
# Men raderingen sjalv maste fungera — det ar vad radering ÄR.
|
||||
PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
|
||||
-qc "delete from personnycklar where subjekt = 'arende-motspel-b'" >/dev/null
|
||||
echo "✓ personnycklar gar fortfarande att forstora"
|
||||
|
||||
# 16. Utgangstid kravs i token (TÜV T-11)
|
||||
EVIG=$(node -e "
|
||||
const c = require('crypto');
|
||||
const b = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
||||
const h = b({ alg: 'HS256', typ: 'JWT' });
|
||||
const p = b({ sub: '00000000-0000-0000-0000-000000000000', org: '0', roll: 'admin', tv: 0 });
|
||||
const s = c.createHmac('sha256', 'integrationshemlighet').update(h + '.' + p).digest('base64url');
|
||||
console.log(h + '.' + p + '.' + s);
|
||||
")
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden" -H "Authorization: Bearer $EVIG")
|
||||
kontroll "token utan utgangstid avvisas" "$KOD" "401"
|
||||
|
||||
# 17. Gallringen verkstalls (TÜV T-4)
|
||||
NYCKLAR_FORE=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
|
||||
-tAc "select count(*) from personnycklar")
|
||||
PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
|
||||
-qc "update felsokning_arenden set gallras_efter = now() - interval '1 day' where id = 'arende-motspel-a'"
|
||||
DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \
|
||||
node gallring.mjs > /tmp/gallring.json
|
||||
FORSTORDA=$(cat /tmp/gallring.json | falt .forstorda)
|
||||
kontroll "gallringen forstorde nyckeln for det passerade arendet" "$FORSTORDA" "1"
|
||||
NYCKLAR_EFTER=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
|
||||
-tAc "select count(*) from personnycklar")
|
||||
kontroll "en nyckel farre finns kvar" "$NYCKLAR_EFTER" "$((NYCKLAR_FORE - 1))"
|
||||
SPAR=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
|
||||
-tAc "select count(*) from raderingar where begard_av like 'Gallring%'")
|
||||
kontroll "gallringen lamnade spar i raderingsregistret" "$SPAR" "1"
|
||||
# Ett arende som INTE passerat far inte roras.
|
||||
DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \
|
||||
node gallring.mjs > /tmp/gallring2.json
|
||||
kontroll "andra korningen gallrar ingenting" "$(cat /tmp/gallring2.json | falt .forstorda)" "0"
|
||||
|
||||
# 18. Kuvertkryptering av personnycklarna (TÜV T-3)
|
||||
#
|
||||
# Nycklarna lag i klartext i samma databas som chiffertexten de skyddade,
|
||||
# och aterstallningstestet slog fast att de foljer med en aterstallning.
|
||||
# Kuverterade under en huvudnyckel utanfor databasen ger en aterstalld
|
||||
# dump nycklar som inte gar att oppna utan den.
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
HUVUD=$(node -pe "require('crypto').randomBytes(32).toString('hex')")
|
||||
DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \
|
||||
JWT_SECRET=integrationshemlighet PORT=$APPPORT \
|
||||
INTEGRATION_NYCKEL=$(node -pe "require('crypto').randomBytes(32).toString('hex')") \
|
||||
PERSONNYCKEL_HUVUD=$HUVUD \
|
||||
node server.mjs &
|
||||
SERVER_PID=$!
|
||||
sleep 1
|
||||
|
||||
curl -s -X POST "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
|
||||
-d '{"id":"arende-kuvert","nummer":93,"skapad":"2026-08-06T10:00:00Z"}' >/dev/null
|
||||
curl -s -X POST "$BAS/api/arenden/arende-kuvert/handelser" -H "Authorization: Bearer $TOKEN_A" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"handelser":[{"id":"k1","tidpunkt":"2026-08-06T10:01:00Z","anvandare":"Anna",
|
||||
"handelse":{"typ":"objekt_identifierat","objekt":{"typ":"Personbil","identifierare":"KUV123",
|
||||
"identifieringsmetod":"Regnr","beskrivning":"VW Golf"}}}]}' >/dev/null
|
||||
|
||||
LAGRAD=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
|
||||
-tAc "select encode(nyckel, 'escape') from personnycklar where subjekt = 'arende-kuvert'")
|
||||
case "$LAGRAD" in
|
||||
v1.*) echo "✓ nyckeln ligger kuverterad i databasen" ;;
|
||||
*) echo "✗ nyckeln ligger i klartext: ${LAGRAD:0:12}"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Med huvudnyckeln gar identifieraren fortfarande att lasa.
|
||||
IDENT=$(curl -s "$BAS/api/arenden/arende-kuvert/handelser" -H "Authorization: Bearer $TOKEN_A" \
|
||||
| falt '.handelser[0].handelse.objekt.identifierare')
|
||||
kontroll "identifieraren gar att lasa med huvudnyckeln" "$IDENT" "KUV123"
|
||||
|
||||
# Utan huvudnyckeln — en aterstalld dump pa en annan maskin — gar den inte.
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \
|
||||
JWT_SECRET=integrationshemlighet PORT=$APPPORT node server.mjs >/dev/null 2>&1 &
|
||||
SERVER_PID=$!
|
||||
sleep 1
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden/arende-kuvert/handelser" -H "Authorization: Bearer $TOKEN_A")
|
||||
kontroll "utan huvudnyckel lamnas inget ut" "$KOD" "500"
|
||||
|
||||
echo "Integrationstest: allt grönt"
|
||||
|
||||
@@ -9,8 +9,13 @@ info:
|
||||
|
||||
**Händelseloggen är append-only.** Det finns inga update- eller
|
||||
delete-operationer — historik kan aldrig ändras eller raderas, vilket
|
||||
även garanteras av databastriggers. En händelse med redan känt `id`
|
||||
ignoreras tyst (idempotent synk).
|
||||
även garanteras av databastriggers.
|
||||
|
||||
**Händelse-id är skopade till ärendet.** Nyckeln är `(arende_id, id)`,
|
||||
inte `id` ensamt. En omsändning av samma händelse med samma innehåll är
|
||||
idempotent och svarar `200`. Samma id med ANNAT innehåll svarar `409`
|
||||
och skriver ingenting — varken det som redan står i loggen eller resten
|
||||
av satsen.
|
||||
|
||||
**Multi-tenant.** All ärendedata är organisationsknuten. Ett ärende i
|
||||
en annan organisation ger `404`, som om det inte fanns.
|
||||
@@ -1187,6 +1192,38 @@ paths:
|
||||
"200": { description: Sparat }
|
||||
"403": { description: Kräver arbetsledare eller administratör }
|
||||
|
||||
/api/atkomstlogg:
|
||||
get:
|
||||
tags: [Drift]
|
||||
summary: Åtkomstlogg — vem som läst vilket ärende
|
||||
description: >
|
||||
Kräver `arbetsledare` eller `admin`, och visar endast den egna
|
||||
organisationen. Loggen omfattar även läsningar via publika
|
||||
delningslänkar, med koden angiven. Tabellen är append-only: posterna
|
||||
kan varken ändras eller raderas, eftersom de utgör bevisningen för
|
||||
att åtkomststyrningen fungerade.
|
||||
security: [{ bearerAuth: [] }]
|
||||
responses:
|
||||
"200":
|
||||
description: De 500 senaste åtkomsterna.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
atkomster:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
tidpunkt: { type: string, format: date-time }
|
||||
arende_id: { type: string, nullable: true }
|
||||
vag: { type: string }
|
||||
anvandare: { type: string, nullable: true }
|
||||
delningskod: { type: string, nullable: true }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"403": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
# ---- Fakturering (ALVA-PROC-0001) ------------------------------------
|
||||
#
|
||||
# Två parter, inte en. Organisationen LÄSER sina fakturor; utfärdaren
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
// TILLAT_INTERNA_UPPSLAG "true" tillåter leverantörsuppslag mot privata nät
|
||||
// ECM_REGLER_FIL / INTEGRATIONER_FIL sökvägar till utbytbar konfiguration
|
||||
// FAKTURERING_NYCKEL utfärdarens nyckel; utan den kan ingen faktura utfärdas
|
||||
// PERSONNYCKEL_HUVUD 32 byte (hex/base64) — kuverterar personnycklarna så att
|
||||
// en återställd databasdump inte innehåller läsbara nycklar
|
||||
// PORT default 8080
|
||||
|
||||
import { createServer } from "node:http";
|
||||
@@ -49,6 +51,7 @@ const OPENAPI = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "open
|
||||
// appkod. Uppdateras genom att byta filen (ECM_REGLER_FIL kan peka på en
|
||||
// ConfigMap-mount i klustret) och starta om tjänsten; klienterna hämtar
|
||||
// det nya paketet vid nästa inloggning/sidladdning.
|
||||
const ECM_REGLER_EGEN_FIL = !!process.env.ECM_REGLER_FIL;
|
||||
const ECM_REGLER = readFileSync(
|
||||
process.env.ECM_REGLER_FIL ?? join(dirname(fileURLToPath(import.meta.url)), "ecm-regler.json"),
|
||||
"utf8",
|
||||
@@ -68,6 +71,13 @@ const ECM_REGLER_NYCKEL = process.env.ECM_REGLER_NYCKEL ?? "";
|
||||
const ECM_REGLER_SIGNATUR = process.env.ECM_REGLER_SIGNATUR ?? "";
|
||||
|
||||
function regelpaketetsStatus() {
|
||||
// Ett EXTERNT paket måste vara signerat (TÜV T-9). Det inbyggda
|
||||
// standardpaketet följer med bilden och behöver ingen signatur — det
|
||||
// som behöver en är det någon har monterat in, för det är precis vad
|
||||
// angreppet innebär. Tidigare gällde samma milda utfall för bägge, så
|
||||
// en installation som aldrig konfigurerade signering fick ingen
|
||||
// integritetskontroll alls och ingen felsignal heller.
|
||||
if (ECM_REGLER_EGEN_FIL && (!ECM_REGLER_NYCKEL || !ECM_REGLER_SIGNATUR)) return "osignerat externt";
|
||||
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);
|
||||
@@ -84,7 +94,9 @@ if (REGELPAKET_STATUS !== "signerat") {
|
||||
konsekvens:
|
||||
REGELPAKET_STATUS === "ogiltig signatur"
|
||||
? "Paketet används INTE — inbyggt standardpaket gäller."
|
||||
: "Paketet används men märks som osignerat i spårbarheten.",
|
||||
: REGELPAKET_STATUS === "osignerat externt"
|
||||
? "Ett externt paket saknar signatur — avslut är spärrat tills det signeras."
|
||||
: "Paketet används men märks som osignerat i spårbarheten.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,7 +167,12 @@ const TILLATNA_URSPRUNG = (process.env.TILLATNA_URSPRUNG ?? "")
|
||||
|
||||
function ursprungFor(req) {
|
||||
const ursprung = req.headers.origin;
|
||||
if (TILLATNA_URSPRUNG.length === 0) return "*";
|
||||
// Tom lista betydde tidigare "*" — alltså öppet som standard (TÜV T-10).
|
||||
// Nu betyder den samma ursprung: inget CORS-huvud alls, vilket är vad
|
||||
// klusterdriften behöver, eftersom klienten serveras från samma domän.
|
||||
// Vill någon öppna får den skriva "*" och mena det.
|
||||
if (TILLATNA_URSPRUNG.length === 0) return null;
|
||||
if (TILLATNA_URSPRUNG.includes("*")) return "*";
|
||||
return ursprung && TILLATNA_URSPRUNG.includes(ursprung) ? ursprung : TILLATNA_URSPRUNG[0];
|
||||
}
|
||||
|
||||
@@ -239,7 +256,12 @@ export function verifieraJwt(token, hemlighet) {
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||
try {
|
||||
const anspr = JSON.parse(Buffer.from(delar[1], "base64url").toString("utf8"));
|
||||
if (typeof anspr.exp === "number" && anspr.exp * 1000 < Date.now()) return null;
|
||||
// Utgångstid KRÄVS (TÜV T-11). Tidigare kontrollerades exp bara när
|
||||
// det fanns, vilket gjorde en token utan exp evig. Alla tokens som
|
||||
// utfärdas idag bär en — men den som en gång slipper igenom utan
|
||||
// skulle aldrig sluta gälla, och det är inte ett fel man upptäcker.
|
||||
if (typeof anspr.exp !== "number") return null;
|
||||
if (anspr.exp * 1000 < Date.now()) return null;
|
||||
return anspr;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -345,12 +367,24 @@ export function synligaTyper(niva) {
|
||||
|
||||
// ---- Hjälpare ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* CORS-huvuden, eller inga alls.
|
||||
*
|
||||
* `res.ursprung` är null när ingen ursprungslista är konfigurerad, och då
|
||||
* ska huvudet UTELÄMNAS — inte sättas till "*" och inte till strängen
|
||||
* "null". Utan den här hjälparen fanns beslutet på sex ställen, och det
|
||||
* räckte att ett av dem föll tillbaka på "*" för att TÜV T-10 skulle vara
|
||||
* öppen igen.
|
||||
*/
|
||||
function korsHuvuden(res) {
|
||||
return res.ursprung ? { "Access-Control-Allow-Origin": res.ursprung, Vary: "Origin" } : {};
|
||||
}
|
||||
|
||||
function svara(res, status, kropp) {
|
||||
res.writeHead(status, {
|
||||
"Content-Type": "application/json",
|
||||
// Ursprunget sätts en gång per anrop i hanteraren nedan.
|
||||
"Access-Control-Allow-Origin": res.ursprung ?? "*",
|
||||
Vary: "Origin",
|
||||
...korsHuvuden(res),
|
||||
"Access-Control-Allow-Headers": "authorization, content-type",
|
||||
});
|
||||
res.end(JSON.stringify(kropp));
|
||||
@@ -414,6 +448,15 @@ async function grindHinder(pool, arendeId, nya) {
|
||||
if (REGELPAKET_STATUS === "ogiltig signatur") {
|
||||
return [{ id: "regelpaket", rubrik: "Regelpaketets signatur stämmer inte — avslut spärrat." }];
|
||||
}
|
||||
if (REGELPAKET_STATUS === "osignerat externt") {
|
||||
return [
|
||||
{
|
||||
id: "regelpaket",
|
||||
rubrik: "Ett externt regelpaket används utan signatur — avslut spärrat.",
|
||||
detalj: "Sätt ECM_REGLER_NYCKEL och ECM_REGLER_SIGNATUR, eller ta bort ECM_REGLER_FIL.",
|
||||
},
|
||||
];
|
||||
}
|
||||
try {
|
||||
regelpaket = JSON.parse(ECM_REGLER);
|
||||
} catch {
|
||||
@@ -445,15 +488,69 @@ function blindaIdentifierare(ident) {
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
// ---- Kuvertkryptering av personnycklarna (TÜV T-3) --------------------
|
||||
//
|
||||
// Nycklarna låg i klartext i samma databas, samma kluster och samma
|
||||
// backuper som den chiffertext de skyddade. Krypto-shredding blev då att
|
||||
// radera en rad bredvid de data den skulle göra oåtkomliga — och
|
||||
// återställningstestet slog uttryckligen fast att nycklarna följer med en
|
||||
// återställning. Testet har rätt för katastrofåterställning; det är samma
|
||||
// faktum läst åt andra hållet som bröt raderingslöftet.
|
||||
//
|
||||
// Nycklarna lagras nu KUVERTERADE under en huvudnyckel som inte finns i
|
||||
// databasen. En återställd dump ger nycklar som inte går att öppna utan
|
||||
// den, vilket flyttar raderingsgarantin från "en rad är borta" till "en
|
||||
// nyckel utanför databasen krävs".
|
||||
//
|
||||
// VAD SOM ÅTERSTÅR, uttryckligen: en backup tagen FÖRE en radering, plus
|
||||
// huvudnyckeln, återställer fortfarande uppgifterna. Att stänga det helt
|
||||
// kräver en nyckel per subjekt i en KMS där förstörelsen är
|
||||
// oåterkallelig. Kuverteringen är förberedelsen för det — därför ligger
|
||||
// in- och uppackningen i egna funktioner och inte inline.
|
||||
const PERSONNYCKEL_HUVUD = (() => {
|
||||
const rå = process.env.PERSONNYCKEL_HUVUD ?? "";
|
||||
if (!rå) return null;
|
||||
const b = /^[0-9a-fA-F]{64}$/.test(rå) ? Buffer.from(rå, "hex") : Buffer.from(rå, "base64");
|
||||
if (b.length !== 32) throw new Error("PERSONNYCKEL_HUVUD måste vara 32 byte (hex eller base64).");
|
||||
return b;
|
||||
})();
|
||||
|
||||
/** Kuvertformat: v1.<iv>.<tagg>.<chiffertext>, allt base64url. */
|
||||
function kuvertera(nyckel) {
|
||||
if (!PERSONNYCKEL_HUVUD) return nyckel;
|
||||
const iv = randomBytes(12);
|
||||
const c = createCipheriv("aes-256-gcm", PERSONNYCKEL_HUVUD, iv);
|
||||
const ut = Buffer.concat([c.update(nyckel), c.final()]);
|
||||
return Buffer.from(
|
||||
`v1.${iv.toString("base64url")}.${c.getAuthTag().toString("base64url")}.${ut.toString("base64url")}`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
function oppnaKuvert(lagrad) {
|
||||
const text = Buffer.from(lagrad).toString("utf8");
|
||||
// En nyckel som lagrades före kuverteringen är råa byte. Den läses som
|
||||
// förut — annars hade uppgraderingen gjort all befintlig historik
|
||||
// oläsbar, vilket är samma skada som en felaktig radering.
|
||||
if (!text.startsWith("v1.")) return Buffer.from(lagrad);
|
||||
if (!PERSONNYCKEL_HUVUD) {
|
||||
throw new Error("Personnycklarna är kuverterade men PERSONNYCKEL_HUVUD saknas.");
|
||||
}
|
||||
const [, iv, tagg, data] = text.split(".");
|
||||
const d = createDecipheriv("aes-256-gcm", PERSONNYCKEL_HUVUD, Buffer.from(iv, "base64url"));
|
||||
d.setAuthTag(Buffer.from(tagg, "base64url"));
|
||||
return Buffer.concat([d.update(Buffer.from(data, "base64url")), d.final()]);
|
||||
}
|
||||
|
||||
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)],
|
||||
[orgId, subjekt, kuvertera(randomBytes(32))],
|
||||
);
|
||||
return rad.rows[0];
|
||||
return { ...rad.rows[0], nyckel: oppnaKuvert(rad.rows[0].nyckel) };
|
||||
}
|
||||
|
||||
/** Nycklar som fortfarande finns, för att öppna en logg vid läsning. */
|
||||
@@ -462,7 +559,7 @@ async function nycklarFor(orgId) {
|
||||
`select id, nyckel from personnycklar where organisation_id = $1`,
|
||||
[orgId],
|
||||
);
|
||||
return new Map(rader.rows.map((r) => [r.id, r.nyckel]));
|
||||
return new Map(rader.rows.map((r) => [r.id, oppnaKuvert(r.nyckel)]));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -588,6 +685,66 @@ async function fakturamedStatus(id) {
|
||||
return { ...rad.rows[0], status: fakturastatus(rad.rows[0].handelser) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Kanonisk JSON: nycklar i bokstavsordning, hela vägen ned.
|
||||
*
|
||||
* Två anrop med samma innehåll men olika fältordning är samma händelse,
|
||||
* och ska ge samma avtryck. JSON.stringify ensam bevarar
|
||||
* insättningsordningen och hade gjort ordningen till en skillnad.
|
||||
*/
|
||||
function kanoniskt(värde) {
|
||||
if (Array.isArray(värde)) return `[${värde.map(kanoniskt).join(",")}]`;
|
||||
if (värde && typeof värde === "object") {
|
||||
return `{${Object.keys(värde)
|
||||
.sort()
|
||||
.map((n) => `${JSON.stringify(n)}:${kanoniskt(värde[n])}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(värde ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mätdonets fakta hämtas ur registret, inte ur anropet (TÜV T-2).
|
||||
*
|
||||
* Evidensgraden E4 kräver att det går att visa VAD som mätte och att
|
||||
* instrumentet var kalibrerat. Bägge fälten kom tidigare från klienten
|
||||
* och lagrades oprövade — en organisation utan ett enda registrerat
|
||||
* mätdon kunde skicka kalibrering 2099-12-31 och få E4.
|
||||
*
|
||||
* Ett okänt mätdon avvisas. Ett UTGÅNGET avvisas inte: mätningen gjordes,
|
||||
* och att vägra spara den vore att radera evidens. I stället följer
|
||||
* registrets datum med, och graderingen faller till E1 på registrets
|
||||
* uppgift i stället för på klientens.
|
||||
*/
|
||||
async function mätdonsFakta(organisationId, handelse) {
|
||||
if (handelse?.typ !== "matvarde") return { handelse };
|
||||
|
||||
// Utan angivet mätdon är det teknikerns avläsning av en siffra. Det är
|
||||
// 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;
|
||||
return { handelse: rest };
|
||||
}
|
||||
|
||||
const rad = await pool.query(
|
||||
`select beteckning, kalibrerad_till from matdon
|
||||
where organisation_id = $1 and id::text = $2 and aktiv`,
|
||||
[organisationId, String(handelse.matdonId)],
|
||||
);
|
||||
if (rad.rowCount === 0) {
|
||||
return { fel: `Okänt mätdon: ${handelse.matdonId}. Registrera instrumentet innan mätvärdet sparas.` };
|
||||
}
|
||||
const härledd = { ...handelse, matdonBeteckning: rad.rows[0].beteckning };
|
||||
// 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;
|
||||
if (rad.rows[0].kalibrerad_till) {
|
||||
härledd.matdonKalibreradTill = new Date(rad.rows[0].kalibrerad_till).toISOString().slice(0, 10);
|
||||
}
|
||||
return { handelse: härledd };
|
||||
}
|
||||
|
||||
// Verifierar att ärendet tillhör användarens organisation.
|
||||
async function arendeIOrg(arendeId, organisationId) {
|
||||
const rader = await pool.query(
|
||||
@@ -714,8 +871,7 @@ async function skickaBilaga(res, rad) {
|
||||
"Content-Length": data.length,
|
||||
// Innehållsadresserat — samma id ger alltid samma bytes.
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"Access-Control-Allow-Origin": res.ursprung ?? "*",
|
||||
Vary: "Origin",
|
||||
...korsHuvuden(res),
|
||||
});
|
||||
return res.end(data);
|
||||
}
|
||||
@@ -763,8 +919,7 @@ export function skapaServer() {
|
||||
});
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204, {
|
||||
"Access-Control-Allow-Origin": res.ursprung,
|
||||
Vary: "Origin",
|
||||
...korsHuvuden(res),
|
||||
"Access-Control-Allow-Headers": "authorization, content-type",
|
||||
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
||||
});
|
||||
@@ -780,7 +935,7 @@ export function skapaServer() {
|
||||
if (req.method === "GET" && vag === "/api/openapi.yaml") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/yaml; charset=utf-8",
|
||||
"Access-Control-Allow-Origin": res.ursprung,
|
||||
...korsHuvuden(res),
|
||||
});
|
||||
return res.end(OPENAPI);
|
||||
}
|
||||
@@ -808,7 +963,7 @@ export function skapaServer() {
|
||||
);
|
||||
const rad = await klientDb.query(
|
||||
`insert into anvandare (organisation_id, epost, losen_hash, namn, roll)
|
||||
values ($1, lower($2), crypt($3, gen_salt('bf')), $4, 'admin')
|
||||
values ($1, lower($2), crypt($3, gen_salt('bf', 12)), $4, 'admin')
|
||||
on conflict (epost) do nothing
|
||||
returning id, namn, organisation_id, roll, token_version`,
|
||||
[org.rows[0].id, epost.trim(), losenord, namn.trim()],
|
||||
@@ -1199,7 +1354,7 @@ export function skapaServer() {
|
||||
}
|
||||
const rad = await pool.query(
|
||||
`insert into anvandare (organisation_id, epost, losen_hash, namn, roll)
|
||||
values ($1, lower($2), crypt($3, gen_salt('bf')), $4, $5)
|
||||
values ($1, lower($2), crypt($3, gen_salt('bf', 12)), $4, $5)
|
||||
on conflict (epost) do nothing
|
||||
returning id, epost, namn, roll, aktiv`,
|
||||
[anspr.org, epost.trim(), losenord, namn.trim(), roll],
|
||||
@@ -1291,7 +1446,7 @@ export function skapaServer() {
|
||||
res.setHeader("X-Regelpaket-Status", REGELPAKET_STATUS);
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": res.ursprung,
|
||||
...korsHuvuden(res),
|
||||
});
|
||||
return res.end(ECM_REGLER);
|
||||
}
|
||||
@@ -1567,11 +1722,31 @@ export function skapaServer() {
|
||||
if (typeof id !== "string" || typeof nummer !== "number" || !skapad) {
|
||||
return svara(res, 400, { error: "Ogiltigt ärende." });
|
||||
}
|
||||
await pool.query(
|
||||
// Ärende-id sätts av klienten och är lika förutsägbart som
|
||||
// händelse-id var (TÜV T-1, samma attack en nivå upp). Ärenderaden
|
||||
// kan inte skopas per organisation utan att främmande nycklar från
|
||||
// händelser och bilagor blir sammansatta, så gränsen dras här i
|
||||
// stället: ett id som redan tillhör en ANNAN organisation är ett
|
||||
// fel, inte en tyst icke-händelse. Utan det här blev org A:s
|
||||
// ärende aldrig skapat och varje efterföljande synk svarade 404.
|
||||
const skapat = await pool.query(
|
||||
`insert into felsokning_arenden (id, organisation_id, nummer, skapad, delningskod, metodik_id, skapad_av)
|
||||
values ($1, $2, $3, $4, $5, $6, $7) on conflict (id) do nothing`,
|
||||
values ($1, $2, $3, $4, $5, $6, $7) on conflict (id) do nothing returning id`,
|
||||
[id, anspr.org, nummer, skapad, delningskod ?? null, metodikId ?? null, anspr.sub],
|
||||
);
|
||||
if (skapat.rowCount === 0) {
|
||||
const agare = await pool.query(`select organisation_id from felsokning_arenden where id = $1`, [id]);
|
||||
if (agare.rows[0]?.organisation_id !== anspr.org) {
|
||||
logga("varning", "ärende-id upptaget av annan organisation", {
|
||||
arende: id,
|
||||
organisation: anspr.org,
|
||||
anvandare: anspr.sub,
|
||||
});
|
||||
return svara(res, 409, {
|
||||
error: "Ärendets identitet är upptagen. Ärendet skapades inte — välj ett nytt id.",
|
||||
});
|
||||
}
|
||||
}
|
||||
return svara(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
@@ -1779,7 +1954,7 @@ export function skapaServer() {
|
||||
for (const post of poster) {
|
||||
const r = await klientDb.query(
|
||||
`insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse)
|
||||
values ($1, $2, $3, $4, $5) on conflict (id) do nothing`,
|
||||
values ($1, $2, $3, $4, $5) on conflict (arende_id, id) do nothing`,
|
||||
[post.id, protokollVag[1], post.tidpunkt, post.anvandare, post.handelse],
|
||||
);
|
||||
if (r.rowCount === 0) dubbletter += 1;
|
||||
@@ -1960,6 +2135,17 @@ export function skapaServer() {
|
||||
for (const post of handelser) {
|
||||
const { post: giltig, fel } = tillPost(post, anspr);
|
||||
if (fel) return svara(res, 400, { error: `Ogiltig händelse: ${fel}` });
|
||||
|
||||
// Mätdonet slås upp i registret i stället för att tros på
|
||||
// (TÜV T-2). Beteckning och kalibrering HÄRLEDS därifrån —
|
||||
// annars var E4 ett påstående klienten gjorde om sig själv.
|
||||
const uppslag = await mätdonsFakta(anspr.org, giltig.handelse);
|
||||
if (uppslag.fel) return svara(res, 400, { error: uppslag.fel });
|
||||
giltig.handelse = uppslag.handelse;
|
||||
|
||||
// Avtrycket tas av det klienten skickade, före serverns fält
|
||||
// och före krypteringen — se kollisionsprövningen nedan.
|
||||
giltig.klientdigest = innehallsHash(kanoniskt(post.handelse));
|
||||
giltig.handelse = skyddaHändelse(giltig.handelse, nyckel.id, nyckel.nyckel);
|
||||
attSkriva.push(giltig);
|
||||
}
|
||||
@@ -2011,18 +2197,56 @@ export function skapaServer() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Kollisioner: tyst bara när de är ofarliga (TÜV T-1) ----
|
||||
//
|
||||
// Nyckeln är numera skopad till ärendet, så en annan organisation
|
||||
// kan inte längre ockupera id. Kvar finns den ofarliga
|
||||
// omsändningen — och den inte fullt så ofarliga: samma id, annat
|
||||
// innehåll. Skillnaden avgörs på klientens avtryck, eftersom
|
||||
// varken raden eller nyttolasten går att jämföra (serverns
|
||||
// tidsstämpel skiljer sig, chiffertexten är ny varje gång).
|
||||
//
|
||||
// Hela satsen prövas före första skrivningen. En delvis skriven
|
||||
// batch som sedan avvisas vore värre än båda utfallen.
|
||||
const befintliga = await pool.query(
|
||||
`select id, klientdigest from felsokning_handelser where arende_id = $1 and id = any($2)`,
|
||||
[handelserVag[1], attSkriva.map((p) => p.id)],
|
||||
);
|
||||
const krockar = [];
|
||||
for (const rad of befintliga.rows) {
|
||||
res.spann.spår.kollisioner = (res.spann.spår.kollisioner ?? 0) + 1;
|
||||
const ny = attSkriva.find((p) => p.id === rad.id);
|
||||
// Saknat avtryck = raden skrevs före den här kolumnen fanns.
|
||||
// Då kan innehållet inte bedömas, och tystnad är det försiktiga
|
||||
// utfallet: append-only skyddar raden som redan står där.
|
||||
if (rad.klientdigest && rad.klientdigest !== ny.klientdigest) {
|
||||
krockar.push({ id: rad.id, typ: ny.handelse.typ });
|
||||
}
|
||||
}
|
||||
if (krockar.length > 0) {
|
||||
logga("varning", "händelse-id återanvänt med annat innehåll", {
|
||||
arende: handelserVag[1],
|
||||
organisation: anspr.org,
|
||||
anvandare: anspr.sub,
|
||||
antal: krockar.length,
|
||||
});
|
||||
return svara(res, 409, {
|
||||
error:
|
||||
"Ett eller flera händelse-id finns redan i ärendet med annat innehåll. " +
|
||||
"Ingenting skrevs — loggen kan inte skrivas över.",
|
||||
krockar,
|
||||
});
|
||||
}
|
||||
|
||||
for (const p of attSkriva) {
|
||||
// Append-only: on conflict do nothing — en befintlig händelse
|
||||
// skrivs aldrig över, och databastriggern stoppar allt annat.
|
||||
// Kollisionen räknas: ett id som redan finns är antingen en
|
||||
// ofarlig omsändning eller ett försök att blockera en framtida
|
||||
// post, och skillnaden syns bara om den mäts (QUALITET m-2).
|
||||
const skrivet = await pool.query(
|
||||
`insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse)
|
||||
values ($1, $2, $3, $4, $5) on conflict (id) do nothing`,
|
||||
[p.id, handelserVag[1], p.tidpunkt, p.anvandare, p.handelse],
|
||||
// Här är kollisionen redan bedömd ofarlig ovan.
|
||||
await pool.query(
|
||||
`insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse, klientdigest)
|
||||
values ($1, $2, $3, $4, $5, $6) on conflict (arende_id, id) do nothing`,
|
||||
[p.id, handelserVag[1], p.tidpunkt, p.anvandare, p.handelse, p.klientdigest],
|
||||
);
|
||||
if (skrivet.rowCount === 0) res.spann.spår.kollisioner = (res.spann.spår.kollisioner ?? 0) + 1;
|
||||
}
|
||||
// Utgående integrationer underrättas efter att loggen skrivits,
|
||||
// aldrig före: en mottagare ska aldrig kunna se en händelse som
|
||||
|
||||
Reference in New Issue
Block a user