Files
alva/felsokning/infra/postgres-init.sql
T
Claude b147694e31 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
2026-08-06 13:05:16 +00:00

426 lines
18 KiB
PL/PgSQL

-- Guidad Felsökning: schema för den självhostade plattformen.
-- Multi-tenant: varje organisation är en egen tenant — all ärendedata är
-- organisationsknuten och API:t släpper aldrig data över organisationsgränsen.
-- Append-only garanteras med triggers: historik kan inte ändras eller
-- raderas oavsett vilken roll som ansluter.
create extension if not exists pgcrypto;
create table if not exists organisationer (
id uuid primary key default gen_random_uuid(),
namn text not null,
-- Vad som visas när ett ärende startas (objekttyper,
-- identifieringsmetoder) — sätts av systemadministratören.
installningar jsonb not null default '{}'::jsonb,
skapad timestamptz not null default now()
);
alter table organisationer add column if not exists installningar jsonb not null default '{}'::jsonb;
-- Märkesspecifika kopplingar per organisation. Uppgifterna lagras
-- krypterade (AES-256-GCM, nyckel ur INTEGRATION_NYCKEL) och lämnar
-- aldrig servern i klartext — klienten ser bara maskerade värden.
create table if not exists integrationer (
organisation_id uuid not null references organisationer(id),
leverantor text not null,
uppgifter_krypt text not null,
aktiv boolean not null default true,
skapad timestamptz not null default now(),
uppdaterad timestamptz not null default now(),
senast_testad timestamptz,
senaste_status text,
primary key (organisation_id, leverantor)
);
create table if not exists anvandare (
id uuid primary key default gen_random_uuid(),
organisation_id uuid not null references organisationer(id),
epost text unique not null,
losen_hash text not null,
namn text not null,
roll text not null default 'tekniker'
check (roll in ('tekniker', 'arbetsledare', 'admin')),
-- En avaktiverad användare kan varken logga in eller använda en token
-- som redan är utfärdad.
aktiv boolean not null default true,
-- Räknare för återkallelse. Utfärdade tokens bär sitt värde; höjs det
-- slutar alla tidigare utfärdade att gälla omedelbart. Loggen rörs
-- inte — historiken är fortfarande knuten till personen.
token_version integer not null default 0,
skapad timestamptz not null default now()
);
alter table anvandare add column if not exists aktiv boolean not null default true;
alter table anvandare add column if not exists token_version integer not null default 0;
-- Inloggningsförsök: underlag för takt-begränsning som fungerar bakom
-- flera repliker (den i minnet gör det inte). Inget lösenord lagras —
-- bara att ett försök skedde och om det lyckades.
create table if not exists inloggningsforsok (
id bigserial primary key,
epost text not null,
kalla text not null default '',
lyckades boolean not null,
tidpunkt timestamptz not null default now()
);
create index if not exists inloggningsforsok_epost_idx
on inloggningsforsok (epost, tidpunkt desc);
create index if not exists inloggningsforsok_kalla_idx
on inloggningsforsok (kalla, tidpunkt desc);
create table if not exists felsokning_arenden (
id text primary key,
organisation_id uuid not null references organisationer(id),
nummer integer not null,
skapad timestamptz not null,
delningskod text unique,
metodik_id text,
skapad_av uuid references anvandare(id),
insatt timestamptz not null default now()
);
create index if not exists felsokning_arenden_org_idx
on felsokning_arenden (organisation_id, skapad desc);
-- Bilagor: foton, video och instrumentbilder. Innehållet ligger utanför
-- händelsen; loggen bär en referens och innehållets SHA-256. Hashen står
-- i den append-only-skyddade loggen, så en utbytt bild går att upptäcka —
-- starkare bevisvärde än när bilden låg inbäddad och måste tros på.
create table if not exists bilagor (
id text primary key,
organisation_id uuid not null references organisationer(id),
arende_id text not null,
hash text not null,
mediatyp text not null,
storlek integer not null,
laddad_av uuid references anvandare(id),
skapad timestamptz not null default now()
);
create index if not exists bilagor_arende_idx on bilagor (arende_id);
create index if not exists bilagor_hash_idx on bilagor (hash);
-- Själva bytesen när BILAGE_LAGE=databas. Innehållsadresserat: samma
-- foto som dokumenteras två gånger lagras en gång. I s3-läget är den
-- här tabellen tom och innehållet ligger i objektlagringen.
create table if not exists bilage_innehall (
hash text primary key,
data bytea not null,
skapad timestamptz not null default now()
);
-- ---- Händelseloggen -----------------------------------------------------
--
-- Nyckeln är (arende_id, id) och INTE id ensamt. Skillnaden är hela
-- TÜV-revisionens T-1.
--
-- Klienten sätter id, och gjorde det som Date.now() + en räknare — alltså
-- förutsägbart. Med en global primärnyckel och `on conflict do nothing`
-- kunde en organisation skriva de id:n en ANNAN organisation snart skulle
-- använda, till sitt eget ärende, och den andras händelse föll tyst bort.
-- Servern svarade 200. Teknikern såg händelsen på skärmen, ärendet
-- stängdes, och felorsaken fanns inte i loggen.
--
-- Append-only skyddade det som skrevs. Ingenting skyddade det som
-- hindrades från att skrivas. Med nyckeln skopad till ärendet finns det
-- inget delat namnrum kvar att ockupera.
create table if not exists felsokning_handelser (
id text not null,
arende_id text not null references felsokning_arenden(id),
tidpunkt timestamptz not null,
anvandare text not null,
handelse jsonb not null,
insatt timestamptz not null default now(),
primary key (arende_id, id)
);
create index if not exists felsokning_handelser_arende_idx
on felsokning_handelser (arende_id, tidpunkt);
-- Migrering av en befintlig installation: den gamla nyckeln på enbart id
-- byts mot den skopade. Kollisioner mellan ärenden kan inte uppstå vid
-- bytet — den gamla nyckeln var strängare — så bytet är alltid möjligt.
do $$
begin
if exists (
select 1 from pg_constraint c
join pg_class t on t.oid = c.conrelid
where t.relname = 'felsokning_handelser' and c.contype = 'p'
and (select count(*) from unnest(c.conkey)) = 1
) then
alter table felsokning_handelser drop constraint felsokning_handelser_pkey;
alter table felsokning_handelser add primary key (arende_id, id);
end if;
end $$;
create or replace function forbjud_andring() returns trigger
language plpgsql as $$
begin
raise exception 'Loggen är append-only — historik kan inte ändras eller raderas';
end $$;
drop trigger if exists handelser_append_only on felsokning_handelser;
create trigger handelser_append_only
before update or delete on felsokning_handelser
for each row execute function forbjud_andring();
-- Ärenderaden är inte en logg utan ett omslag, och två av dess kolumner
-- är HÄRLEDDA av systemet efteråt: gallringsdatumet sätts vid avslut
-- utifrån ärendetypen, och det blindade fordonsindexet skrivs när
-- objektet identifieras.
--
-- Den tidigare triggern förbjöd all update och gjorde därmed avslut
-- omöjligt: servern försökte sätta gallras_efter, databasen sa nej, och
-- hela synken föll med 500. Kvalitetsgrinden hade redan godkänt ärendet
-- — det var lagringen som vägrade, efteråt, av ett skäl som inte hade
-- med ärendet att göra.
--
-- Skyddet är därför kolumnvis i stället för totalt: identitet, tillhörighet
-- och ursprung kan inte ändras, radering är fortfarande omöjlig, men de
-- fält systemet självt härleder får skrivas.
create or replace function skydda_arende() returns trigger
language plpgsql as $$
begin
if TG_OP = 'DELETE' then
raise exception 'Ärenden kan inte raderas';
end if;
if (new.id, new.organisation_id, new.nummer, new.skapad, new.delningskod,
new.metodik_id, new.skapad_av, new.insatt)
is distinct from
(old.id, old.organisation_id, old.nummer, old.skapad, old.delningskod,
old.metodik_id, old.skapad_av, old.insatt) then
raise exception 'Ärendets historik kan inte ändras — endast härledda fält får sättas';
end if;
return new;
end $$;
drop trigger if exists arenden_append_only on felsokning_arenden;
create trigger arenden_append_only
before update or delete on felsokning_arenden
for each row execute function skydda_arende();
-- Live Share-delningar: återkallbara länkar med behörighetsnivå.
-- (Åtkomststyrning, inte journal — därför ingen append-only-trigger:
-- återkallelse sätter aterkallad-tidpunkten.)
create table if not exists delningar (
kod text primary key,
arende_id text not null references felsokning_arenden(id),
niva text not null check (niva in ('kund', 'partner', 'intern')),
skapad_av uuid references anvandare(id),
skapad timestamptz not null default now(),
aterkallad timestamptz
);
create index if not exists delningar_arende_idx on delningar (arende_id);
-- ---- Personuppgifter och gallring -------------------------------------
--
-- Append-only och rätten till radering (dataskyddsförordningen art. 17)
-- är inte oförenliga, men de måste förenas medvetet. Krypto-shredding:
-- identifierande fält i loggen krypteras med en nyckel per registrerad,
-- och 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 kan fortfarande visa att lufttrycket mättes till
-- 2,4 bar klockan 08:42, bara inte längre vems bil det gällde.
--
-- Tabellen är det enda stället i schemat där en rad FÅR försvinna, och
-- det är hela poängen med den.
create table if not exists personnycklar (
id uuid primary key default gen_random_uuid(),
organisation_id uuid not null references organisationer(id),
-- Vad nyckeln skyddar: normalt ett fordon (regnr/VIN) eller en kund.
subjekt text not null,
nyckel bytea not null,
skapad timestamptz not null default now(),
-- Sätts när radering begärts men nyckeln ännu inte förstörts, så att
-- begäran syns i systemet även innan den verkställts.
radering_begard timestamptz,
unique (organisation_id, subjekt)
);
-- Radering loggas. Att en begäran verkställts måste gå att visa i
-- efterhand, och den loggen får själv inte innehålla personuppgiften.
create table if not exists raderingar (
id uuid primary key default gen_random_uuid(),
organisation_id uuid not null references organisationer(id),
subjekt_hash text not null,
begard timestamptz not null,
verkstalld timestamptz not null default now(),
begard_av text not null,
antal_arenden integer not null default 0
);
-- Gallringsdatum per ärende, satt vid avslut utifrån ärendetypen.
-- Ett ärende utan datum gallras aldrig automatiskt — det försiktiga
-- utfallet, eftersom för tidig gallring inte går att ångra.
alter table felsokning_arenden add column if not exists gallras_efter timestamptz;
-- Läslogg. Varje skrivning loggades redan; ingen läsning gjorde det.
-- Utan den går det inte att svara på vem som sett en kunds uppgifter,
-- vilket både artikel 32 och en verkstadschef vill kunna få svar på.
create table if not exists atkomstlogg (
id bigserial primary key,
tidpunkt timestamptz not null default now(),
organisation_id uuid,
anvandare_id uuid,
arende_id text,
vag text not null,
kalla text,
-- Delningskod när åtkomsten skedde via en publik länk.
delningskod text
);
create index if not exists atkomstlogg_arende on atkomstlogg (arende_id, tidpunkt desc);
create index if not exists atkomstlogg_org on atkomstlogg (organisation_id, tidpunkt desc);
-- Mätdon. Ett mätvärde rankas som hög evidens (E4); utan kalibrerat
-- instrument är det inte lägre evidens utan ingen evidens alls.
create table if not exists matdon (
id uuid primary key default gen_random_uuid(),
organisation_id uuid not null references organisationer(id),
beteckning text not null,
serienummer text not null,
kalibrerad_till date,
aktiv boolean not null default true,
skapad timestamptz not null default now(),
unique (organisation_id, serienummer)
);
-- Blindat index över fordonsidentifieraren.
--
-- En raderingsbegäran gäller ett fordon eller en person, inte ett enskilt
-- ärende — men identifieraren är krypterad, så den går inte att söka på.
-- Indexet är en HMAC av den normaliserade identifieraren med en
-- servernyckel: samma fordon ger alltid samma värde, och värdet går inte
-- att vända tillbaka till ett registreringsnummer utan nyckeln.
--
-- Det gör radering över hela fordonets historik möjlig utan att lagra
-- identifieraren i klartext någonstans.
alter table felsokning_arenden add column if not exists identifierare_index text;
create index if not exists felsokning_arenden_ident_idx
on felsokning_arenden (organisation_id, identifierare_index);
-- Utgående integrationer. En prenumeration per mottagare och
-- händelsetyp — ett videooffertsystem bryr sig inte om varje mätvärde.
create table if not exists prenumerationer (
id uuid primary key default gen_random_uuid(),
organisation_id uuid not null references organisationer(id),
namn text not null,
url text not null,
hemlighet_krypt text not null,
handelser text[] not null default '{}',
aktiv boolean not null default true,
skapad timestamptz not null default now(),
senast_levererad timestamptz,
senaste_status text
);
create index if not exists prenumerationer_org on prenumerationer (organisation_id) where aktiv;
-- ---- Fakturering (ALVA-PROC-0001) --------------------------------------
--
-- En utfärdad faktura ändras aldrig. Det är inte en ambition utan en
-- egenskap i schemat: samma append-only-trigger som skyddar
-- händelseloggen skyddar fakturaraden.
--
-- Det får en följd som är lätt att missa. "Betald" kan då inte vara en
-- kolumn som uppdateras — en betalning är en HÄNDELSE som inträffar
-- efter utfärdandet, och statusen är en projektion av de händelserna.
-- Precis som ärendets tillstånd inte lagras utan härleds ur loggen.
--
-- Beloppet lagras som det räknades fram, i öre, tillsammans med hela
-- underlaget. Skulle prislistan ändras nästa år står den gamla fakturan
-- kvar oförändrad, med de rader och de priser som faktiskt gällde.
create table if not exists fakturor (
id uuid primary key default gen_random_uuid(),
organisation_id uuid not null references organisationer(id),
-- Löpnumret är gemensamt för hela installationen och utan luckor:
-- ALVA är utfärdaren, organisationerna är mottagare. Se
-- nästaFakturanummer() i server.mjs för hur luckor undviks.
nummer bigint not null unique,
beteckning text not null unique,
utfardad date not null,
forfaller date not null,
valuta text not null,
totalt bigint not null,
-- Krediterar en tidigare faktura. Null för en vanlig faktura.
krediterar uuid references fakturor(id),
-- Hela det härledda dokumentet, fruset vid utfärdandet: rader,
-- underlag, à-priser, moms. Den som läser den om två år ska inte
-- behöva systemet för att förstå den.
dokument jsonb not null,
skapad timestamptz not null default now()
);
create index if not exists fakturor_org_idx on fakturor (organisation_id, nummer desc);
drop trigger if exists fakturor_append_only on fakturor;
create trigger fakturor_append_only
before update or delete on fakturor
for each row execute function forbjud_andring();
-- Vad som hänt med en utfärdad faktura. Append-only av samma skäl som
-- allt annat som utgör underlag: en registrerad betalning som kan
-- backas bort tyst är inte ett underlag, den är en anteckning.
create table if not exists fakturahandelser (
id uuid primary key default gen_random_uuid(),
faktura_id uuid not null references fakturor(id),
typ text not null check (typ in ('betald', 'krediterad')),
intraffade date not null,
-- Betalningsreferens, kreditorsak. Fritext, men aldrig tom.
uppgift text not null,
registrerad_av text not null,
insatt timestamptz not null default now()
);
create index if not exists fakturahandelser_faktura_idx
on fakturahandelser (faktura_id, insatt);
drop trigger if exists fakturahandelser_append_only on fakturahandelser;
create trigger fakturahandelser_append_only
before update or delete on fakturahandelser
for each row execute function forbjud_andring();
-- ---- Underlagen om åtkomst och radering (TÜV T-7) ----------------------
--
-- Fyra tabeller bar append-only-skydd: loggen, ärendet, fakturorna och
-- fakturahändelserna. Utelämnandena var påfallande — det var just de
-- tabeller som UTGÖR bevisningen om dataskydd som gick att skriva om.
--
-- Åtkomstloggen är beviset för att åtkomststyrningen fungerade. Den är
-- det första en granskare ber om efter en incident, och den enda handling
-- som kunde ändras av den som hade motiv att göra det.
drop trigger if exists atkomstlogg_append_only on atkomstlogg;
create trigger atkomstlogg_append_only
before update or delete on atkomstlogg
for each row execute function forbjud_andring();
-- Beviset för att en radering verkställdes fick inte självt kunna raderas.
drop trigger if exists raderingar_append_only on raderingar;
create trigger raderingar_append_only
before update or delete on raderingar
for each row execute function forbjud_andring();
-- Personnycklarna behöver radering — det är vad radering ÄR — så här går
-- det inte att förbjuda. Regeln är i stället smalare: nyckeln och
-- raderingsbegäran får ändras, identiteten och tillhörigheten inte.
-- Annars kunde en nyckel flyttas till ett annat subjekt och en radering
-- träffa fel person.
create or replace function skydda_personnyckel() returns trigger
language plpgsql as $$
begin
if TG_OP = 'DELETE' then
return old;
end if;
if (new.id, new.organisation_id, new.subjekt, new.skapad)
is distinct from
(old.id, old.organisation_id, old.subjekt, old.skapad) then
raise exception 'En personnyckels identitet och tillhörighet kan inte ändras';
end if;
return new;
end $$;
drop trigger if exists personnycklar_skydd on personnycklar;
create trigger personnycklar_skydd
before update or delete on personnycklar
for each row execute function skydda_personnyckel();
-- Avtryck av det klienten faktiskt skickade, före serverns egna fält och
-- före krypteringen (TÜV T-1). Utan det går en kollision inte att bedöma:
-- serverns tidsstämpel skiljer sig alltid vid en omsändning, och
-- krypteringen ger ny chiffertext för samma klartext, så varken raden
-- eller nyttolasten kan jämföras. Avtrycket kan.
alter table felsokning_handelser add column if not exists klientdigest text;