Bilagor ut ur händelseloggen — referens och hash i stället för inbäddat
Foton, video och instrumentbilder låg 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 försvagar inte bevisvärdet utan stärker 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. Två lägen: databas (bytea, fungerar överallt utan konfiguration) och s3 (AWS, MinIO, Ceph). Signeringen är egen — SigV4 för PUT och GET — i stället för molnleverantörens SDK, eftersom två operationer inte motiverar tiotals megabyte beroenden i en bild som annars bara har pg-drivrutinen. Den korsverifieras bit för bit mot botocore i testerna. Det avslöjade en riktig bugg direkt: host-huvudet saknade portnummer, vilket hade fungerat mot AWS men avvisats av all självhostad S3. Kodningen av objektnycklar kanoniseras medvetet inte. S3 följer andra URL-regler än övriga AWS-tjänster och en felgissad regel ger signaturer som ser rimliga ut men avvisas. I stället begränsas hink och prefix till tecken som aldrig behöver kodas — då finns ingen regel att gissa fel på. Delningsgränsen gäller även bilagor: en bilaga lämnas bara ut via en delningslänk om händelsen den hör till är synlig på den nivån, så den skannade arbetsordern nås aldrig via kundlänken. Uppladdningen sker på ett enda ställe — sidans egna skicka() flyttar innehållet innan händelsen skrivs, så ingen panel behövde ändras. Misslyckas det, eller saknas server som i lokalt läge, bäddas det in precis som förut. Dokumentationen får aldrig gå förlorad för att nätet ligger nere, och äldre händelser med inbäddad data-URL fortsätter fungera för alltid eftersom loggen är append-only. Verifierat: 96 vitest-tester (varav 9 nya för signering och innehållsadressering), typkontroll, eslint, OpenAPI-validering, terraform fmt och referenskontroll, samt integrationstest mot riktig Postgres med 13 nya kontroller — bland annat att ett manipulerat innehåll upptäcks och inte lämnas ut, och att kundlänken når fotot men inte arbetsordern. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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\"."
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -49,6 +49,8 @@ output "karta" {
|
||||
)
|
||||
}
|
||||
|
||||
bilagor = local.bilagor
|
||||
|
||||
drift = {
|
||||
registrering_öppen = var.registrering_oppen
|
||||
interna_uppslag_tillåtna = var.tillat_interna_uppslag
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user