diff --git a/docs/DRIFT.md b/docs/DRIFT.md index 55f4a46..b04d241 100644 --- a/docs/DRIFT.md +++ b/docs/DRIFT.md @@ -108,6 +108,42 @@ upp dess CRD redan vid plan. I `extern` läge kör ni `infra/postgres-init.sql` mot databasen själva; det är samma fil som integrationstestet kör. +## Bilagor + +Foton, videoklipp och instrumentbilder låg tidigare som data-URL:er inne +i händelserna. Det drabbade allt som läser loggen: synken drog med hela +bildmassan var femtonde sekund, kundvyn likaså, och en säkerhetskopia av +loggen var i praktiken en kopia av alla foton. + +Nu ligger innehållet utanför händelsen och loggen bär en referens med +innehållets SHA-256. **Det stärker bevisvärdet i stället för att försvaga +det**: hashen står i den append-only-skyddade loggen, så en bild som +bytts ut går att upptäcka — tidigare låg bilden i loggen och måste helt +enkelt tros på. Innehållet kontrolleras mot hashen varje gång det lämnas +ut; stämmer det inte svarar tjänsten 409 i stället för att visa bilden. + +Innehållsadresserat, så samma foto som dokumenteras två gånger lagras en +gång. + +| `bilage_lage` | Var innehållet ligger | Använd när | +| --- | --- | --- | +| `databas` (standard) | `bilage_innehall` (bytea) | Fungerar överallt utan konfiguration; bilderna följer med databasens säkerhetskopior | +| `s3` | S3-kompatibel objektlagring (AWS, MinIO, Ceph) | Loggen och bilderna ska växa oberoende av varandra | + +Signeringen mot objektlagringen är egen (SigV4 för PUT och GET) i stället +för molnleverantörens SDK — två operationer motiverar inte tiotals +megabyte beroenden. Den korsverifieras mot botocore i testerna, bit för +bit. + +**Delningsgränsen gäller även bilagor.** En bilaga kan bara hämtas via en +delningslänk om händelsen den hör till är synlig på den nivån; den +skannade arbetsordern nås alltså aldrig via kundlänken. + +Äldre händelser med inbäddad data-URL fortsätter att fungera och kommer +alltid att göra det — loggen är append-only. Lokalt läge, utan +inloggning, bäddar också in: det finns ingen server att ladda upp till, +och dokumentationen får inte gå förlorad för att nätet ligger nere. + ## Åtkomst: spärr och återkallelse En giltig JWT-signatur räcker inte. Varje autentiserat anrop slår upp diff --git a/docs/MVP.md b/docs/MVP.md index f1109dd..50a754d 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -55,6 +55,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st | Instrumentavläsning (visual-first) | ✅ Kameran som universellt gränssnitt: `📷 Instrument` i Dokumentera-panelen fotograferar multimetrar, diagnosskärmar, batteritestare m.m. — bildtolkningen identifierar instrumenttyp och extraherar värden/enheter/felkoder med konfidens per värde; teknikern bekräftar innan något loggas. Originalbilden loggas alltid tillsammans med de strukturerade mätvärdena — strukturerad data ersätter aldrig originalevidensen. Ingen integration mot diagnossystem krävs. | | Utskrift | ✅ Kundrapport och Live Share-vy skrivs ut svart på vitt; interaktiva element döljs automatiskt. Utskriften går genom ECM-kvalitetsgrinden. | | Märkesspecifika kopplingar | ✅ Verkstaden konfigurerar sina egna OEM-/fordonsdataleverantörer under Inställningar med sina egna credentials ([moduler/markesspecifika-kopplingar.md](moduler/markesspecifika-kopplingar.md)): uppgifterna krypteras med AES-256-GCM i vila, returneras alltid maskerade (`••••3456`) och **alla uppslag görs av servern** — leverantörsnycklar når aldrig webbläsaren. Endast systemadministratören hanterar dem, kopplingarna är organisationsknutna och saknas krypteringsnyckeln sparas ingenting alls (fail closed). Leverantörer är data, inte kod: URL-mall, autentiseringstyp (bearer/header/basic/query) och svarsmappning beskrivs i `integrationer.json` (ConfigMap-utbytbar via `INTEGRATIONER_FIL`) — nya märken läggs till utan ombyggnad. Varje uppslag loggar teststatus, så ett utgånget abonnemang syns i inställningarna i stället för att ge tysta tomma svar. Verifierat i integrationstestet (rollstyrning, maskering, kryptering i databasen, organisationsisolering, fail closed). | +| Bilagor | ✅ Foton, video och instrumentbilder ligger **utanför händelsen**; loggen bär en referens med innehållets SHA-256. Det stärker bevisvärdet: hashen står i den append-only-skyddade loggen, så en utbytt bild går att upptäcka — och innehållet kontrolleras mot hashen varje gång det lämnas ut (409 i stället för att visa bilden). Innehållsadresserat, så samma foto lagras en gång. Två lägen: `databas` (bytea, fungerar överallt) och `s3` (AWS/MinIO/Ceph) med egen SigV4-signering som korsverifieras bit för bit mot botocore i testerna. Delningsgränsen gäller även bilagor — den skannade arbetsordern nås aldrig via kundlänken. Äldre händelser med inbäddad data-URL fortsätter fungera för alltid, och lokalt läge bäddar in som förut så dokumentation aldrig går förlorad utan nät. | | Åtkomstkontroll | ✅ **Återkallelse är omedelbar**: varje autentiserat anrop kontrollerar att kontot är aktivt och att token-versionen stämmer, i stället för att en avstängning börjar gälla när token går ut. Administratören stänger av och öppnar konton i användarlistan (kan inte stänga av sig själv, aldrig över organisationsgränsen), och var och en kan logga ut på alla enheter när en telefon tappats bort. **Takt-begränsning på inloggning** ligger i databasen och håller därför bakom flera repliker: 10 försök per konto och 30 per källadress inom 15 minuter, och spärren gäller kontot även vid rätt lösenord. Samtliga gränser verifierade i integrationstestet. | | Infrastruktur som kod | ✅ `infra/terraform` är systemets definition ([README](../infra/terraform/README.md)): `karta.tf` beskriver hela systemet en gång som data — tjänster, portar, routing, hemligheter per tjänst, dataflöden och gränser — och `terraform output karta` skriver ut samma sak i klartext. Namnrymden är stängd med nätverkspolicyer (bara ingress→tjänster, plattform→postgres, HTTPS ut utom privata nät), Postgres kör med säkerhetskontext, hemligheter kan genereras eller komma från en secrets-hanterare. **Databasen har tre lägen** och `databas_lage` saknar standardvärde med flit — valet avgör om det finns säkerhetskopiering: `extern` (managerad Postgres, leverantörens PITR — rekommenderat i produktion), `cnpg` (CloudNativePG i klustret: basbackup 02:30 + WAL-arkivering + failover) och `inbyggd` (en volym, ingen backup, spärrad av en precondition när miljön är produktion). Driftsättning är ett eget CI-flöde som startas för hand med en bildtagg mot en miljö med godkännandekrav, kör plan/apply, skriver ut kartan och rökkontrollerar. Kustomize-/Argo CD-vägen är borttagen. | | Öppet API | ✅ Plattforms-API:t är dokumenterat med OpenAPI 3.0 (`services/plattform/openapi.yaml`) — auth, användare, ärenden/händelser (append-only), översikt, publik delning och AI-orkestern, med scheman för alla händelsetyper. Specen valideras maskinellt, paritetstestas mot serverns rutter och serveras live på `GET /api/openapi.yaml`. | diff --git a/infra/postgres-init.sql b/infra/postgres-init.sql index 99b3c8f..a4841b6 100644 --- a/infra/postgres-init.sql +++ b/infra/postgres-init.sql @@ -79,6 +79,32 @@ create table if not exists felsokning_arenden ( 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() +); + create table if not exists felsokning_handelser ( id text primary key, arende_id text not null references felsokning_arenden(id), diff --git a/infra/terraform/20-databas.tf b/infra/terraform/20-databas.tf index 96f868a..dbd0708 100644 --- a/infra/terraform/20-databas.tf +++ b/infra/terraform/20-databas.tf @@ -50,6 +50,11 @@ resource "terraform_data" "databaskontroll" { error_message = "databas_lage = \"cnpg\" kräver backup_nyckel_id och backup_nyckel." } + precondition { + condition = var.bilage_lage != "s3" || (var.s3.endpoint != "" && var.s3.hink != "" && var.s3.region != "" && var.s3_nyckel_id != "" && var.s3_nyckel != "") + error_message = "bilage_lage = \"s3\" kräver s3.endpoint, s3.hink, s3.region, s3_nyckel_id och s3_nyckel." + } + precondition { condition = !local.inbyggd_databas || var.miljo != "produktion" error_message = "databas_lage = \"inbyggd\" saknar säkerhetskopiering och kan inte användas med miljo = \"produktion\". Välj \"extern\" eller \"cnpg\"." diff --git a/infra/terraform/30-plattform.tf b/infra/terraform/30-plattform.tf index 23ab066..a11e6ec 100644 --- a/infra/terraform/30-plattform.tf +++ b/infra/terraform/30-plattform.tf @@ -111,6 +111,44 @@ resource "kubernetes_deployment_v1" "plattform" { value = var.tillat_interna_uppslag ? "true" : "false" } + # Bilagornas innehåll: i databasen eller i objektlagring. + env { + name = "BILAGE_LAGE" + value = var.bilage_lage + } + + dynamic "env" { + for_each = var.bilage_lage == "s3" ? { + S3_ENDPOINT = var.s3.endpoint + S3_HINK = var.s3.hink + S3_REGION = var.s3.region + S3_PREFIX = var.s3.prefix + } : {} + + content { + name = env.key + value = env.value + } + } + + dynamic "env" { + for_each = var.bilage_lage == "s3" ? { + S3_NYCKEL_ID = "s3-nyckel-id" + S3_NYCKEL = "s3-nyckel" + } : {} + + content { + name = env.key + + value_from { + secret_key_ref { + name = kubernetes_secret_v1.hemligheter.metadata[0].name + key = env.value + } + } + } + } + resources { requests = { cpu = "100m", memory = "128Mi" } limits = { cpu = "1", memory = "512Mi" } diff --git a/infra/terraform/karta.tf b/infra/terraform/karta.tf index 4f52831..04eb372 100644 --- a/infra/terraform/karta.tf +++ b/infra/terraform/karta.tf @@ -34,10 +34,16 @@ locals { utat = ["webbläsaren anropar plattform och orkester direkt över ingressen"] } plattform = { - roll = "Backend. Auth, append-only händelse-API, Live Share, organisationsinställningar, ECM-regelpaket, märkesspecifika kopplingar." - bild = "${var.register}/${local.namn}-plattform:${var.bildtagg}" - hemligt = ["jwt-hemlighet", "postgres-losenord", "integration-nyckel"] - utat = ["postgres:5432", "kundernas leverantörer över internet (spärrat mot privata nät)"] + roll = "Backend. Auth, append-only händelse-API, Live Share, organisationsinställningar, ECM-regelpaket, märkesspecifika kopplingar." + bild = "${var.register}/${local.namn}-plattform:${var.bildtagg}" + hemligt = concat( + ["jwt-hemlighet", "postgres-losenord", "integration-nyckel"], + var.bilage_lage == "s3" ? ["s3-nyckel-id", "s3-nyckel"] : [], + ) + utat = concat( + ["postgres:5432", "kundernas leverantörer över internet (spärrat mot privata nät)"], + var.bilage_lage == "s3" ? ["objektlagringen för bilagor"] : [], + ) } orkester = { roll = "AI-orkestern. Routar per uppgift till Claude, äger systemprompt och svarsschema. Verifierar plattformens JWT." @@ -82,6 +88,8 @@ locals { var.integration_nyckel, random_id.integration_nyckel.hex, ) + "s3-nyckel-id" = var.s3_nyckel_id + "s3-nyckel" = var.s3_nyckel } # ---- Dataflöden ----------------------------------------------------- @@ -96,11 +104,22 @@ locals { "Plattform → kundens leverantör VIN/regnr ut, fordonsuppgifter in", ] + bilagor = { + lage = var.bilage_lage + var = var.bilage_lage == "s3" ? "${var.s3.endpoint}/${var.s3.hink}/${var.s3.prefix}" : "tabellen bilage_innehall" + hur = join(" ", [ + "Innehållet ligger utanför händelsen; loggen bär referensen och innehållets SHA-256.", + "Innehållsadresserat, så samma foto lagras en gång.", + "Hashen kontrolleras vid utlämning — en utbytt bild lämnas inte ut.", + ]) + } + granser = [ "Organisationsgränsen: varje fråga mot ärendedata filtreras på organisation_id i SQL:en, inte i klienten.", "Delningsgränsen: tillåtelselista över händelsetyper per nivå (kund/partner/intern) — nya typer är interna tills de aktivt släpps fram.", "Hemlighetsgränsen: Claude-nyckeln och kundernas leverantörsnycklar finns bara serversidan. Klienten ser maskerade värden.", "Historikgränsen: append-only i både API och databas (triggers). Ingen roll kan ändra eller radera en händelse.", + "Bilagegränsen: en bilaga kan bara hämtas via en delningslänk om händelsen den hör till är synlig på den nivån.", ] # ---- Det som medvetet inte ingår ------------------------------------ @@ -108,7 +127,6 @@ locals { avgransningar = concat( var.databas_lage == "inbyggd" ? ["INGEN SÄKERHETSKOPIERING — läget inbyggd har en volym och inget mer. Endast prov och demo."] : [], [ - "Objektlagring. Foton och video ligger som data-URL:er i händelseloggen, vilket gör volymen stor och tung att säkerhetskopiera.", "Observability. Ingen metrikexport, ingen tracing — bara containerloggar.", "Takt-begränsning på inloggning. Endast den publika beslutsvägen är begränsad, och bara per pod.", "Återkallelse av utfärdade JWT. En token gäller sin livstid ut även om användaren tas bort.", diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf index c83f4aa..43d52d0 100644 --- a/infra/terraform/outputs.tf +++ b/infra/terraform/outputs.tf @@ -49,6 +49,8 @@ output "karta" { ) } + bilagor = local.bilagor + drift = { registrering_öppen = var.registrering_oppen interna_uppslag_tillåtna = var.tillat_interna_uppslag diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index 799015a..e630df4 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -242,6 +242,62 @@ variable "databas_instanser" { default = 3 } +# ---- Bilagor ----------------------------------------------------------- + +variable "bilage_lage" { + description = <<-TEXT + Var foton och videoklipp lagras. + + "databas" bytea i en egen tabell. Fungerar överallt och kräver + ingen konfiguration, men bilderna följer med databasens + säkerhetskopior och gör dem stora. + + "s3" S3-kompatibel objektlagring (AWS, MinIO, Ceph). Loggen + och bilderna växer oberoende av varandra. Kräver + s3-uppgifterna nedan. + + Innehållet är innehållsadresserat i båda lägena: samma foto lagras + en gång, och hashen står i händelseloggen så en utbytt bild går att + upptäcka. + TEXT + type = string + default = "databas" + + validation { + condition = contains(["databas", "s3"], var.bilage_lage) + error_message = "bilage_lage måste vara \"databas\" eller \"s3\"." + } +} + +variable "s3" { + description = "Objektlagring för bilagor. Används bara när bilage_lage = \"s3\"." + type = object({ + endpoint = string + hink = string + region = string + prefix = optional(string, "bilagor") + }) + default = { + endpoint = "" + hink = "" + region = "" + } +} + +variable "s3_nyckel_id" { + description = "Åtkomstnyckel till bilagornas objektlagring." + type = string + sensitive = true + default = "" +} + +variable "s3_nyckel" { + description = "Hemlig nyckel till bilagornas objektlagring." + type = string + sensitive = true + default = "" +} + variable "databas_storlek" { description = "Volymstorlek för händelseloggen. Foton och video ligger inline i loggen." type = string diff --git a/services/plattform/Dockerfile b/services/plattform/Dockerfile index da6bd88..8a9b967 100644 --- a/services/plattform/Dockerfile +++ b/services/plattform/Dockerfile @@ -4,7 +4,7 @@ FROM node:22-alpine WORKDIR /app COPY package.json ./ RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force -COPY server.mjs openapi.yaml ecm-regler.json integrationer.json ./ +COPY server.mjs bilagor.mjs openapi.yaml ecm-regler.json integrationer.json ./ ENV NODE_ENV=production PORT=8080 USER node diff --git a/services/plattform/bilagor.mjs b/services/plattform/bilagor.mjs new file mode 100644 index 0000000..8b5a6e5 --- /dev/null +++ b/services/plattform/bilagor.mjs @@ -0,0 +1,220 @@ +// Bilagor — foton, videoklipp och instrumentbilder. +// +// Tidigare låg de som data-URL:er inne i händelserna. Det gjorde +// händelseloggen tung på ett sätt som drabbade allt som läser den: +// synken drog med hela bildmassan var femtonde sekund, kundvyn likaså, +// och en säkerhetskopia av loggen var i praktiken en kopia av alla foton. +// +// Nu ligger innehållet utanför händelsen och loggen bär en referens med +// SHA-256 av innehållet. Det är inte en försvagning av bevisvärdet utan +// en förstärkning: hashen står i den append-only-skyddade loggen, så en +// bild som bytts ut går att upptäcka. Tidigare låg bilden i loggen och +// måste helt enkelt tros på. +// +// Innehållsadressering ger dedup på köpet — samma foto som dokumenteras +// två gånger lagras en gång. +// +// Två lager, valda med BILAGE_LAGE: +// databas bytea i en egen tabell (standard, fungerar överallt) +// s3 S3-kompatibel objektlagring — loggen och bilderna växer +// då oberoende av varandra + +import { createHash, createHmac } from "node:crypto"; + +export function innehallsHash(buffert) { + return createHash("sha256").update(buffert).digest("hex"); +} + +// Vad som får laddas upp. Listan är avsiktligt kort: det här är +// dokumentation av ett fordon, inte en filserver. +export const TILLATNA_MEDIATYPER = [ + "image/jpeg", + "image/png", + "image/webp", + "video/mp4", + "video/webm", + "video/quicktime", +]; + +export function mediatypGiltig(typ) { + return TILLATNA_MEDIATYPER.includes((typ ?? "").split(";")[0].trim().toLowerCase()); +} + +// ---- AWS Signature Version 4 ------------------------------------------- +// +// Egen implementation i stället för molnleverantörens SDK: tjänsten +// behöver två operationer (PUT och GET av ett objekt) och SDK:t hade +// dragit in tiotals megabyte beroenden i en bild som annars bara har +// pg-drivrutinen. Signeringen korsverifieras mot botocore i testerna. + +const hmac = (nyckel, data) => createHmac("sha256", nyckel).update(data, "utf8").digest(); + +export function signeringsnyckel(hemlighet, datum, region, tjanst) { + const d = hmac(`AWS4${hemlighet}`, datum); + const r = hmac(d, region); + const t = hmac(r, tjanst); + return hmac(t, "aws4_request"); +} + +// Sökvägen kanoniseras INTE här, och det är ett medvetet val. +// +// S3 följer andra URL-kodningsregler än övriga AWS-tjänster, och en +// felgissad regel ger signaturer som ser rimliga ut men avvisas. I +// stället begränsas det som kan hamna i en nyckel (se namnGiltigt) till +// tecken som aldrig behöver kodas: hink och prefix är [a-z0-9.-/] och +// resten av nyckeln är hexadecimal ur innehållshashen. Då finns ingen +// kodningsfråga att gissa fel på. +export const NAMN_MONSTER = /^[a-z0-9][a-z0-9./-]*$/; + +export function namnGiltigt(namn) { + return typeof namn === "string" && namn.length <= 128 && NAMN_MONSTER.test(namn) && !namn.includes(".."); +} + +/** + * Bygger Authorization-huvudet för en S3-förfrågan. + * + * @param metod HTTP-metod, t.ex. "PUT" + * @param url fullständig URL till objektet + * @param nyttolast kroppen (Buffer) — tom Buffer för GET + * @param uppgifter { nyckelId, hemlighet, region } + * @param tidsstampel ISO-basic, t.ex. "20260803T120000Z" + */ +export function signera(metod, url, nyttolast, uppgifter, tidsstampel) { + // host, inte hostname: vid en icke-standardport måste porten med i + // huvudet, annars avvisar självhostad S3 (MinIO, Ceph) signaturen. + const { host, pathname, search } = new URL(url); + const datum = tidsstampel.slice(0, 8); + const nyttolastHash = innehallsHash(nyttolast); + + const huvuden = { + host, + "x-amz-content-sha256": nyttolastHash, + "x-amz-date": tidsstampel, + }; + const signerade = Object.keys(huvuden).sort(); + const kanoniskaHuvuden = signerade.map((n) => `${n}:${huvuden[n].trim()}\n`).join(""); + const signeradeNamn = signerade.join(";"); + + // Frågesträngen ska vara sorterad och kodad. Vi använder inga + // parametrar i dag, men kanoniseringen måste ändå vara rätt. + const parametrar = [...new URLSearchParams(search).entries()] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([n, v]) => `${encodeURIComponent(n)}=${encodeURIComponent(v)}`) + .join("&"); + + const kanoniskForfragan = [ + metod, + pathname, + parametrar, + kanoniskaHuvuden, + signeradeNamn, + nyttolastHash, + ].join("\n"); + + const omfang = `${datum}/${uppgifter.region}/s3/aws4_request`; + const attSignera = [ + "AWS4-HMAC-SHA256", + tidsstampel, + omfang, + innehallsHash(Buffer.from(kanoniskForfragan, "utf8")), + ].join("\n"); + + const signatur = createHmac("sha256", signeringsnyckel(uppgifter.hemlighet, datum, uppgifter.region, "s3")) + .update(attSignera, "utf8") + .digest("hex"); + + return { + ...huvuden, + Authorization: + `AWS4-HMAC-SHA256 Credential=${uppgifter.nyckelId}/${omfang}, ` + + `SignedHeaders=${signeradeNamn}, Signature=${signatur}`, + }; +} + +// ---- Lager -------------------------------------------------------------- + +// Innehållsadresserat: nyckeln ÄR hashen. Två identiska filer blir en. +function objektnyckel(prefix, hash) { + const rent = prefix.replace(/^\/+|\/+$/g, ""); + return `${rent ? `${rent}/` : ""}${hash.slice(0, 2)}/${hash}`; +} + +export function skapaS3Lager(konfig, hamtare = fetch, nu = () => new Date()) { + const prefix = (konfig.prefix ?? "").replace(/^\/+|\/+$/g, ""); + if (!namnGiltigt(konfig.hink) || (prefix !== "" && !namnGiltigt(prefix))) { + throw new Error( + "S3_HINK och S3_PREFIX får bara innehålla a-z, 0-9, punkt, bindestreck och snedstreck.", + ); + } + const bas = konfig.endpoint.replace(/\/$/, ""); + + const url = (hash) => `${bas}/${konfig.hink}/${objektnyckel(prefix, hash)}`; + const stampel = () => nu().toISOString().replace(/[-:]|\.\d{3}/g, ""); + + return { + namn: "s3", + + async spara(hash, data) { + const mal = url(hash); + const svar = await hamtare(mal, { + method: "PUT", + headers: signera("PUT", mal, data, konfig, stampel()), + body: data, + }); + if (!svar.ok) throw new Error(`Objektlagringen svarade ${svar.status} vid skrivning.`); + }, + + async hamta(hash) { + const mal = url(hash); + const svar = await hamtare(mal, { + method: "GET", + headers: signera("GET", mal, Buffer.alloc(0), konfig, stampel()), + }); + if (svar.status === 404) return null; + if (!svar.ok) throw new Error(`Objektlagringen svarade ${svar.status} vid läsning.`); + return Buffer.from(await svar.arrayBuffer()); + }, + }; +} + +export function skapaDatabasLager(pool) { + return { + namn: "databas", + + async spara(hash, data) { + // Innehållsadresserat: finns hashen redan är filen redan sparad. + await pool.query( + `insert into bilage_innehall (hash, data) values ($1, $2) on conflict (hash) do nothing`, + [hash, data], + ); + }, + + async hamta(hash) { + const rad = await pool.query(`select data from bilage_innehall where hash = $1`, [hash]); + return rad.rowCount === 0 ? null : rad.rows[0].data; + }, + }; +} + +// Väljer lager ur miljön. Saknas något som s3-läget kräver failar vi +// hellre vid start än vid första uppladdningen. +export function valjLager(env, pool, hamtare = fetch) { + if ((env.BILAGE_LAGE ?? "databas") !== "s3") return skapaDatabasLager(pool); + + const saknas = ["S3_ENDPOINT", "S3_HINK", "S3_REGION", "S3_NYCKEL_ID", "S3_NYCKEL"].filter((n) => !env[n]); + if (saknas.length > 0) { + throw new Error(`BILAGE_LAGE=s3 kräver ${saknas.join(", ")}.`); + } + + return skapaS3Lager( + { + endpoint: env.S3_ENDPOINT, + hink: env.S3_HINK, + region: env.S3_REGION, + prefix: env.S3_PREFIX ?? "bilagor", + nyckelId: env.S3_NYCKEL_ID, + hemlighet: env.S3_NYCKEL, + }, + hamtare, + ); +} diff --git a/services/plattform/integrationstest.sh b/services/plattform/integrationstest.sh index 908582f..98c9551 100755 --- a/services/plattform/integrationstest.sh +++ b/services/plattform/integrationstest.sh @@ -405,6 +405,70 @@ KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/auth/logga-in" \ -H 'Content-Type: application/json' -d '{"epost":"anna@a.se","losenord":"hemligt123"}') kontroll "andra konton påverkas inte" "$KOD" "200" +# 10f. Bilagor: innehållet ligger utanför händelsen, hashen i loggen +PNG=$(mktemp); printf '\x89PNG\r\n\x1a\nTESTBILD-1' > "$PNG" +SVAR=$(curl -s -X POST "$BAS/api/arenden/arende-test1/bilagor" -H "Authorization: Bearer $TOKEN_A" \ + -H 'Content-Type: image/png' --data-binary "@$PNG") +BIL_ID=$(echo "$SVAR" | falt .id) +BIL_HASH=$(echo "$SVAR" | falt .hash) +kontroll "bilagan får en hash" "${#BIL_HASH}" "64" +kontroll "hashen är innehållets" "$BIL_HASH" "$(sha256sum "$PNG" | cut -d' ' -f1)" + +# Samma innehåll igen: ny referens, men bara ett lagrat innehåll +SVAR2=$(curl -s -X POST "$BAS/api/arenden/arende-test1/bilagor" -H "Authorization: Bearer $TOKEN_A" \ + -H 'Content-Type: image/png' --data-binary "@$PNG") +kontroll "identiskt innehåll ger samma hash" "$(echo "$SVAR2" | falt .hash)" "$BIL_HASH" +ANTAL_INNEHALL=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \ + -tAc "select count(*) from bilage_innehall where hash='$BIL_HASH'") +kontroll "innehållsadresserat — lagras en gång" "$ANTAL_INNEHALL" "1" + +# Hämtning ger tillbaka exakt samma bytes +curl -s "$BAS/api/bilagor/$BIL_ID" -H "Authorization: Bearer $TOKEN_A" -o /tmp/hamtad.png +kontroll "hämtat innehåll är identiskt" "$(sha256sum /tmp/hamtad.png | cut -d' ' -f1)" "$BIL_HASH" + +# Fel mediatyp och tom kropp avvisas +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden/arende-test1/bilagor" \ + -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/pdf' --data-binary "@$PNG") +kontroll "endast bilder och video tas emot" "$KOD" "415" + +# Organisationsgränsen gäller bilagor +KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/bilagor/$BIL_ID" -H "Authorization: Bearer $TOKEN_B") +kontroll "org B kommer inte åt org A:s bilaga" "$KOD" "404" +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden/arende-test1/bilagor" \ + -H "Authorization: Bearer $TOKEN_B" -H 'Content-Type: image/png' --data-binary "@$PNG") +kontroll "org B kan inte ladda upp till org A:s ärende" "$KOD" "404" + +# Manipulerat innehåll upptäcks: hashen i loggen är facit +PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \ + -qc "update bilage_innehall set data = decode('4d414e4950554c45524154', 'hex') where hash='$BIL_HASH'" +KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/bilagor/$BIL_ID" -H "Authorization: Bearer $TOKEN_A") +kontroll "utbytt innehåll upptäcks och lämnas inte ut" "$KOD" "409" +PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \ + -qc "update bilage_innehall set data = pg_read_binary_file('$PNG') where hash='$BIL_HASH'" 2>/dev/null \ + || PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \ + -qc "delete from bilage_innehall where hash='$BIL_HASH'" + +# Delningsfiltret gäller även bilagor: en bilaga som hör till en intern +# händelsetyp får inte hämtas via kundlänken. +BIL2=$(curl -s -X POST "$BAS/api/arenden/arende-test1/bilagor" -H "Authorization: Bearer $TOKEN_A" \ + -H 'Content-Type: image/png' --data-binary "@$PNG" | falt .id) +BIL3=$(curl -s -X POST "$BAS/api/arenden/arende-test1/bilagor" -H "Authorization: Bearer $TOKEN_A" \ + -H 'Content-Type: image/png' --data-binary "@$PNG" | falt .id) +curl -s -X POST "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d "{\"handelser\":[ + {\"id\":\"h-foto\",\"tidpunkt\":\"2026-08-03T08:07:00Z\",\"anvandare\":\"Anna\",\"handelse\":{\"typ\":\"foto\",\"beskrivning\":\"Hjul\",\"bilagaId\":\"$BIL2\"}}, + {\"id\":\"h-ao\",\"tidpunkt\":\"2026-08-03T08:08:00Z\",\"anvandare\":\"Anna\",\"handelse\":{\"typ\":\"arbetsorder_skannad\",\"falt\":[],\"bilagaId\":\"$BIL3\"}} + ]}" >/dev/null +NYKUND=$(curl -s -X POST "$BAS/api/arenden/arende-test1/delningar" -H "Authorization: Bearer $TOKEN_A" \ + -H 'Content-Type: application/json' -d '{"niva":"kund"}' | falt .kod) +KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/delad/$NYKUND/bilagor/$BIL2") +kontroll "kunden når bilagan till ett foto" "$KOD" "200" +KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/delad/$NYKUND/bilagor/$BIL3") +kontroll "kunden når INTE arbetsorderbilden" "$KOD" "404" +KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/delad/$NYKUND/bilagor/$BIL_ID") +kontroll "bilaga utan händelse lämnas inte ut publikt" "$KOD" "404" +rm -f "$PNG" /tmp/hamtad.png + # 11. API-first: OpenAPI-specen serveras live, utan inloggning SPEC=$(curl -s "$BAS/api/openapi.yaml") case "$SPEC" in diff --git a/services/plattform/openapi.yaml b/services/plattform/openapi.yaml index c674b97..2969251 100644 --- a/services/plattform/openapi.yaml +++ b/services/plattform/openapi.yaml @@ -311,6 +311,111 @@ paths: "200": { description: Alla sessioner är avslutade. } "401": { $ref: "#/components/responses/Fel" } + /api/arenden/{arendeId}/bilagor: + post: + tags: [Ärenden] + summary: Ladda upp en bilaga + description: > + Foton, videoklipp och instrumentbilder. Kroppen är råa bytes och + `Content-Type` anger mediatypen — endast bild och video tas emot. + Servern beräknar innehållets SHA-256 och returnerar en referens + som ska läggas i händelsen; **hashen hamnar därmed i den + append-only-skyddade loggen**, så en utbytt bild går att upptäcka. + Innehållsadresserat: samma innehåll lagras en gång. + parameters: + - name: arendeId + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + image/jpeg: { schema: { type: string, format: binary } } + image/png: { schema: { type: string, format: binary } } + image/webp: { schema: { type: string, format: binary } } + video/mp4: { schema: { type: string, format: binary } } + video/webm: { schema: { type: string, format: binary } } + responses: + "200": + description: Referensen att spara i händelsen. + content: + application/json: + schema: + type: object + properties: + id: { type: string } + hash: { type: string, description: "SHA-256 av innehållet, hex." } + mediatyp: { type: string } + storlek: { type: integer } + "400": { $ref: "#/components/responses/Fel" } + "401": { $ref: "#/components/responses/Fel" } + "404": { $ref: "#/components/responses/Fel" } + "413": + description: Bilagan är större än 32 MB. + content: + application/json: + schema: { $ref: "#/components/schemas/Fel" } + "415": + description: Endast bilder och videoklipp tas emot. + content: + application/json: + schema: { $ref: "#/components/schemas/Fel" } + + /api/bilagor/{bilagaId}: + get: + tags: [Ärenden] + summary: Hämta en bilaga + description: > + Organisationsknuten. Innehållet kontrolleras mot hashen innan det + lämnas ut — stämmer det inte svarar tjänsten 409 i stället för att + visa en bild som kan ha bytts ut. + parameters: + - name: bilagaId + in: path + required: true + schema: { type: string } + responses: + "200": + description: Innehållet. + content: + application/octet-stream: + schema: { type: string, format: binary } + "401": { $ref: "#/components/responses/Fel" } + "404": { $ref: "#/components/responses/Fel" } + "409": + description: Innehållet stämmer inte med hashen i loggen. + content: + application/json: + schema: { $ref: "#/components/schemas/Fel" } + + /api/delad/{delningskod}/bilagor/{bilagaId}: + get: + tags: [Delning] + summary: Hämta en bilaga via delningslänk + description: > + Samma filtrering som händelserna: bilagan lämnas bara ut om + händelsen den hör till är synlig på delningens nivå. En bild som + hör till en intern händelsetyp — t.ex. den skannade arbetsordern — + nås alltså aldrig via kundlänken. + security: [] + parameters: + - name: delningskod + in: path + required: true + schema: { type: string } + - name: bilagaId + in: path + required: true + schema: { type: string } + responses: + "200": + description: Innehållet. + content: + application/octet-stream: + schema: { type: string, format: binary } + "404": { $ref: "#/components/responses/Fel" } + "409": { $ref: "#/components/responses/Fel" } + /api/integrationer/leverantorer: get: tags: [Integrationer] diff --git a/services/plattform/server.mjs b/services/plattform/server.mjs index ccc5392..8581683 100644 --- a/services/plattform/server.mjs +++ b/services/plattform/server.mjs @@ -25,6 +25,7 @@ import { lookup } from "node:dns/promises"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import pg from "pg"; +import { innehallsHash, mediatypGiltig, valjLager } from "./bilagor.mjs"; // API-first: OpenAPI-specen är en versionerad artefakt och serveras live. const OPENAPI = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "openapi.yaml"), "utf8"); @@ -50,11 +51,18 @@ const INTEGRATIONER = JSON.parse( const PORT = Number(process.env.PORT ?? 8080); const MAX_KROPP = 4 * 1024 * 1024; +// Bilagor får vara större än en händelse — ett videoklipp med ljud är +// evidens som inte går att skala ned hur långt som helst. +const MAX_BILAGA = 32 * 1024 * 1024; const TOKEN_LIVSTID_S = 12 * 60 * 60; const ROLLER = ["tekniker", "arbetsledare", "admin"]; const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 }); +// Var bilagornas innehåll hamnar. Felkonfigurerat s3-läge failar här, +// vid start, i stället för vid första uppladdningen. +const BILAGELAGER = valjLager(process.env, pool); + // Vilka ursprung som får anropa API:t från en webbläsare. I klusterdriften // serveras klienten från samma domän som API:t, så listan kan hållas kort. // TILLATNA_URSPRUNG="https://app.exempel.se,https://demo.exempel.se" — @@ -267,6 +275,17 @@ async function lasKropp(req) { return JSON.parse(Buffer.concat(bitar).toString("utf8")); } +async function lasBinart(req, tak) { + const bitar = []; + let storlek = 0; + for await (const bit of req) { + storlek += bit.length; + if (storlek > tak) throw new Error("för stor kropp"); + bitar.push(bit); + } + return Buffer.concat(bitar); +} + function nyKod() { const tecken = "abcdefghijklmnopqrstuvwxyz0123456789"; const { randomBytes } = crypto; @@ -404,6 +423,27 @@ export async function gorUppslag(def, uppgifter, identifierare, hamtare = fetch) // ---- Server ----------------------------------------------------------- +// Lämnar ut innehållet — men bara efter att det kontrollerats mot +// hashen i loggen. Stämmer det inte säger vi det rakt ut i stället för +// att visa en bild som kan ha bytts ut. +async function skickaBilaga(res, rad) { + const data = await BILAGELAGER.hamta(rad.hash); + if (!data) return svara(res, 404, { error: "Innehållet saknas i lagringen." }); + if (innehallsHash(data) !== rad.hash) { + console.error("bilaga: innehållet stämmer inte med hashen i loggen", rad.hash); + return svara(res, 409, { error: "Innehållet stämmer inte med det som dokumenterades." }); + } + res.writeHead(200, { + "Content-Type": rad.mediatyp, + "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", + }); + return res.end(data); +} + export function skapaServer() { function loggaIn(res, rad, hemlighet) { const nu = Math.floor(Date.now() / 1000); @@ -566,6 +606,32 @@ export function skapaServer() { return svara(res, 200, { arende: arende.rows[0], handelser: handelser.rows, niva }); } + // Bilaga via delningslänk. Bilden får bara hämtas om den hör till + // en händelse som nivån faktiskt får se — annars vore det en väg + // runt delningsfiltret. + const delatBilaga = vag.match(/^\/api\/delad\/([A-Za-z0-9_-]+)\/bilagor\/([A-Za-z0-9_-]+)$/); + if (req.method === "GET" && delatBilaga) { + const delning = await pool.query( + `select arende_id, niva from delningar where kod = $1 and aterkallad is null`, + [delatBilaga[1]], + ); + if (delning.rowCount === 0) return svara(res, 404, { error: "Bilagan är inte tillgänglig." }); + const synliga = synligaTyper(delning.rows[0].niva); + const rad = await pool.query( + `select b.hash, b.mediatyp from bilagor b + where b.id = $1 and b.arende_id = $2 + and exists ( + select 1 from felsokning_handelser h + where h.arende_id = b.arende_id + and h.handelse->>'bilagaId' = b.id + and ($3::text[] is null or h.handelse->>'typ' = any($3)) + )`, + [delatBilaga[2], delning.rows[0].arende_id, synliga], + ); + if (rad.rowCount === 0) return svara(res, 404, { error: "Bilagan är inte tillgänglig." }); + return skickaBilaga(res, rad.rows[0]); + } + // -- Publikt kundgodkännande (den enda skrivande publika vägen) -- // // Kunden svarar på ett åtgärdsförslag via sin delningslänk. Spärrar: @@ -699,6 +765,50 @@ export function skapaServer() { return svara(res, 200, { ok: true }); } + // -- Bilagor -- + // Innehållet ligger utanför händelsen; loggen bär referensen och + // innehållets hash. Uppladdningen sker före händelsen skrivs, så + // en händelse aldrig pekar på något som inte finns. + const laddaUppVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/bilagor$/); + if (req.method === "POST" && laddaUppVag) { + if (!(await arendeIOrg(laddaUppVag[1], anspr.org))) { + return svara(res, 404, { error: "Ärendet är inte tillgängligt." }); + } + const mediatyp = (req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase(); + if (!mediatypGiltig(mediatyp)) { + return svara(res, 415, { error: "Endast bilder och videoklipp kan laddas upp." }); + } + let data; + try { + data = await lasBinart(req, MAX_BILAGA); + } catch { + return svara(res, 413, { error: `Bilagan är för stor (max ${MAX_BILAGA / 1024 / 1024} MB).` }); + } + if (data.length === 0) return svara(res, 400, { error: "Bilagan är tom." }); + + const hash = innehallsHash(data); + await BILAGELAGER.spara(hash, data); + const id = `bil-${nyKod()}`; + await pool.query( + `insert into bilagor (id, organisation_id, arende_id, hash, mediatyp, storlek, laddad_av) + values ($1, $2, $3, $4, $5, $6, $7)`, + [id, anspr.org, laddaUppVag[1], hash, mediatyp, data.length, anspr.sub], + ); + // Hashen går tillbaka till klienten och hamnar i händelsen — + // därmed står den i den append-only-skyddade loggen. + return svara(res, 200, { id, hash, mediatyp, storlek: data.length }); + } + + const bilagaVag = vag.match(/^\/api\/bilagor\/([A-Za-z0-9_-]+)$/); + if (req.method === "GET" && bilagaVag) { + const rad = await pool.query( + `select hash, mediatyp from bilagor where id = $1 and organisation_id = $2`, + [bilagaVag[1], anspr.org], + ); + if (rad.rowCount === 0) return svara(res, 404, { error: "Bilagan finns inte." }); + return skickaBilaga(res, rad.rows[0]); + } + // ECM Knowledge Library: aktuellt regelpaket för inloggade klienter. if (req.method === "GET" && vag === "/api/ecm/regler") { res.writeHead(200, { diff --git a/src/felsokning/Bilagevisning.tsx b/src/felsokning/Bilagevisning.tsx new file mode 100644 index 0000000..37616fe --- /dev/null +++ b/src/felsokning/Bilagevisning.tsx @@ -0,0 +1,69 @@ +// Visar en bilaga oavsett om innehållet ligger inbäddat i händelsen +// (äldre ärenden, lokalt läge) eller hämtas som referens. + +import { useEffect, useState } from "react"; +import type { Bilaga } from "./domain"; +import { hamtaBilaga } from "./bilagor"; + +function useKalla(bilaga: Bilaga, delningskod?: string): string | null | "laddar" { + const [kalla, setKalla] = useState("laddar"); + const id = bilaga.bilagaId ?? bilaga.dataUrl?.slice(0, 64) ?? ""; + + useEffect(() => { + let aktuell = true; + setKalla("laddar"); + hamtaBilaga(bilaga, delningskod) + .then((url) => aktuell && setKalla(url)) + .catch(() => aktuell && setKalla(null)); + return () => { + aktuell = false; + }; + // Bilagan identifieras av sitt id — objektet självt byts vid varje + // omrendering utan att innehållet ändrats. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, delningskod]); + + return kalla; +} + +function Platshallare({ text }: { text: string }) { + return ( +

+ {text} +

+ ); +} + +export function Bild({ + bilaga, + alt, + className, + delningskod, +}: { + bilaga: Bilaga; + alt: string; + className?: string; + delningskod?: string; +}) { + const kalla = useKalla(bilaga, delningskod); + if (kalla === "laddar") return ; + // Ärligt om att bilden inte gick att hämta, i stället för en trasig + // bildikon som lämnar teknikern i tvivel om vad som dokumenterats. + if (!kalla) return ; + return {alt}; +} + +export function Klipp({ + bilaga, + className, + delningskod, +}: { + bilaga: Bilaga; + className?: string; + delningskod?: string; +}) { + const kalla = useKalla(bilaga, delningskod); + if (kalla === "laddar") return ; + if (!kalla) return ; + return