diff --git a/.gitignore b/.gitignore index a547bf3..ce6cf51 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,12 @@ dist-ssr *.njsproj *.sln *.sw? + +# Terraform: tillståndet innehåller hemligheter, tfvars innehåller nycklar. +.terraform/ +.terraform.lock.hcl +*.tfstate +*.tfstate.* +*.tfvars +!*.tfvars.exempel +crash.log diff --git a/docs/DRIFT.md b/docs/DRIFT.md index 02bc017..d1deaf2 100644 --- a/docs/DRIFT.md +++ b/docs/DRIFT.md @@ -22,10 +22,43 @@ flowchart LR | `plattform` | Självhostad backend (`services/plattform`): **multi-tenant** — registrering skapar organisation + systemadministratör, admin hanterar användare (tekniker/arbetsledare/admin), all ärendedata organisationsisolerad. Inloggning (bcrypt via pgcrypto, HS256-JWT med roll + org i anspråken), append-only händelse-API, publik delningsendpoint | Deployment + Service + HPA + PDB | | `ai-orkester` | AI-orkestern (`services/ai-orkester`): fyra uppgifter routade till Sonnet 5 / Opus 5 / Haiku 4.5 — verifierar plattformens JWT (delad hemlighet) | Deployment + Service + HPA + PDB | | `postgres` | Händelselogg + användare; **append-only garanterat med databastriggers** — historik kan inte ändras eller raderas oavsett roll | StatefulSet + PVC (10 Gi). Produktion: CloudNativePG-operatorn för backup/failover/PITR | -| Hemligheter | `anthropic-api-key`, `jwt-secret` (delas av plattform + orkester), `postgres-losenord`, `integration-nyckel` (krypterar kundernas märkesspecifika credentials) | Secret `felsokning-hemligheter` — aldrig i bilder eller manifest | +| Hemligheter | `anthropic-api-key`, `jwt-secret` (delas av plattform + orkester), `postgres-losenord`, `integration-nyckel` (krypterar kundernas märkesspecifika credentials) | +| Miljöflaggor | `TILLATNA_URSPRUNG` (CORS-lista; utelämnad = `*`), `TILLAT_INTERNA_UPPSLAG` (`true` tillåter leverantörsuppslag mot privata nät), `REGISTRERING_OPPEN`, `ECM_REGLER_FIL`, `INTEGRATIONER_FIL` | Secret `felsokning-hemligheter` — aldrig i bilder eller manifest | **Klienten har två driftlägen**, valda vid bygget: med `VITE_PLATTFORM_URL` går inloggning, synk, Live Share och AI mot klustret (helt självhostat); utan den används Supabase-läget (edge-funktion + managerad Postgres/Auth) som tidigare. Samma händelsemodell, samma orkester — låst av paritetstester. +## Infrastrukturen som kod + +`infra/terraform` är systemets definition — läs [README:n där](../infra/terraform/README.md). +Börja i `karta.tf`: hela systemet beskrivet en gång som data (tjänster, +portar, routing, hemligheter, dataflöden, gränser). `terraform output +karta` skriver ut samma sak i klartext. + +`infra/k8s` + `infra/overlays` + `infra/gitops` beskriver samma system i +kustomize, synkat av Argo CD. **Kör inte båda mot samma kluster** — Argo +CD:s `selfHeal` återställer det Terraform ändrar och `prune` tar bort det +Terraform skapar. Terraform-vägen har dessutom nätverkspolicyer och +säkerhetskontext på databasen, vilket kustomize-vägen saknar. + +## Nätverksgränser + +Terraform-vägen stänger namnrymden och öppnar bara de faktiska flödena: + +| Från | Till | Varför | +| --- | --- | --- | +| ingress-kontrollern | web, plattform, ai-orkester :8080 | den enda vägen in | +| plattform | postgres :5432 | händelseloggen | +| plattform | internet :443 utom privata nät | kundernas leverantörer | +| ai-orkester | internet :443 utom privata nät | Claude | +| web, postgres | — | ringer ingenting | + +Undantagen för privata nät (10/8, 172.16/12, 192.168/16, 169.254/16, +127/8, 100.64/10) är samma gräns som koden själv upprätthåller i +`pekarInat` — två oberoende spärrar mot att ett kundkonfigurerat uppslag +används för att nå klustrets insida eller molnets metadatatjänst. +Kräver en CNI som tillämpar NetworkPolicy; annars är reglerna +dokumentation, inte skydd. + ## Driftsätta ```sh @@ -55,7 +88,9 @@ curl https://app.exempel.se/halsa # → {"status":"ok"} (plattformen) curl https://app.exempel.se/api/openapi.yaml # API-first: hela API-specen ``` -Byt domän och cert-issuer i `infra/k8s/ingress.yaml`. Att skapa nya organisationer är öppet i beta — stäng med `REGISTRERING_OPPEN=false` på plattformens Deployment; användare inom en organisation skapas alltid av dess systemadministratör. +Med Terraform i stället: `cd infra/terraform && terraform apply -var bildtagg=`. + +Byt domän och cert-issuer i `infra/k8s/ingress.yaml` (eller `var.doman` i Terraform). Att skapa nya organisationer är öppet i beta — stäng med `REGISTRERING_OPPEN=false` på plattformens Deployment; användare inom en organisation skapas alltid av dess systemadministratör. ## Märkesspecifika kopplingar diff --git a/docs/MVP.md b/docs/MVP.md index c230444..ce09b31 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -41,7 +41,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st | Multi-tenant & roller | ✅ I självhostat läge: registrering skapar organisation + systemadministratör; admin hanterar användare (tekniker/arbetsledare/admin) via UI; all ärendedata organisationsisolerad i API:t; roll + organisation i JWT:n. **Arbetsledarvy** (`/felsokning/oversikt`): organisationens alla ärenden med status, deltagande tekniker och statistik (pågående/avslutade/ledtid) — härlett ur händelseloggen; ärenden kan hämtas till enheten med konfliktfri flätning. **Felorsaksstatistik** (flottdata): orsakskategorierna ur alla felorsaksanalyser aggregeras per organisation och visas som stapelöversikt i arbetsledarvyn. **Ansvarig tekniker** per ärende härleds ur loggen (skapare → överlämning → omfördelning) och arbetsledaren kan omfördela pågående ärenden — loggat som den organisationsinterna händelsen `ansvarig_satt`, aldrig synlig i kund-/partnerdelningar. Integrationstestat mot riktig Postgres (isolering, rollstyrning, append-only, översiktens behörighet och härledningar). | | Backend & synk | ✅ Databas-migration (`supabase/migrations/20260802230000_guidad_felsokning.sql`): ärenden + händelser med RLS, append-only även i databasen (inga update/delete-rättigheter). Synklager i klienten: konfliktfri ihopflätning av händelser per id (testad), push av lokala + pull av kollegors händelser var 15:e sekund. Utan inloggning arbetar appen i lokalt läge; status visas i ärendehuvudet. | | Metodiker | ✅ Tre: vibration, elsystem/strömförsörjning (relä-exemplet ur visionen) och generisk — vald automatiskt utifrån felbeskrivningen. | -| Live Share | ✅ Skrivskyddad livevy per ärende (`/felsokning/dela/:id`): status ✔/🔄/⏳, bilder, mätvärdestabell, tidslinje, rekommenderat nästa steg. Uppdateras automatiskt, interna poster filtreras bort. Publik delningssida (`/felsokning/delad/:kod`) läser via `hamta_delat_arende` utan inloggning och pollar för liveuppdatering; "Kopiera delningslänk" finns i rapportfliken. **Behörighetsnivåer**: återkallbara delningslänkar per nivå — kund (det kunddelbara), extern partner (även hypoteser, märkta ej verifierade), intern (full insyn) — med serverstyrd filtrering, hanterade från rapportfliken i självhostat läge. | +| Live Share | ✅ **Delningsgränsen är en tillåtelselista**: händelsetyper räknas upp per nivå (kund/partner/intern) i stället för att nekas en och en, så en ny händelsetyp är intern tills någon aktivt släpper fram den — låst av ett test som kräver att varje typ i domänmodellen är klassificerad. Skrivskyddad livevy per ärende (`/felsokning/dela/:id`): status ✔/🔄/⏳, bilder, mätvärdestabell, tidslinje, rekommenderat nästa steg. Uppdateras automatiskt, interna poster filtreras bort. Publik delningssida (`/felsokning/delad/:kod`) läser via `hamta_delat_arende` utan inloggning och pollar för liveuppdatering; "Kopiera delningslänk" finns i rapportfliken. **Behörighetsnivåer**: återkallbara delningslänkar per nivå — kund (det kunddelbara), extern partner (även hypoteser, märkta ej verifierade), intern (full insyn) — med serverstyrd filtrering, hanterade från rapportfliken i självhostat läge. | | Dashboard | ✅ Enligt direktivet: räknare och filter för Alla/Pågående/Klara plus Starta nytt ärende. | | Ärendestart via arbetsorder | ✅ Primärvägen när ett ärende startas: fota arbetsorderns framsida — orkesterns dokumenttolkning (Claude Sonnet 5, vision) läser kund-, fordons- och verkstadsuppgifter oavsett layout och sätter konfidens per fält. 🟢 ≥95 % godkänns automatiskt, 🟡 80–95 % markeras för genomläsning, 🔴 <80 % kräver aktiv bekräftelse — teknikern granskar bara osäkra fält. Visuell granskning med dokumentet bredvid fälten (klick markerar ungefärlig position), sedan skapas hela ärendet med ett tryck. Tolkningen loggas som organisationsintern händelse (`arbetsorder_skannad`) och delas aldrig i kund-/partnervyer. Manuell inmatning finns kvar som andrahandsväg; i lokalt läge visas en tydligt märkt demo-tolkning. Inloggade användare tillfrågas aldrig om namn — kontot vet redan. | | Inställningar | ✅ Systemadministratören väljer vilka objekttyper och identifieringsmetoder som visas när ett ärende startas (`/felsokning/installningar`). På plattformen gäller valet hela organisationen (sparas på organisationen, endast admin får ändra — verifierat i integrationstestet); i lokalt läge gäller valet enheten. Okända värden filtreras och tomma listor faller tillbaka till standard. | @@ -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). | +| 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. Kustomize-/Argo CD-vägen finns kvar men de två ska inte köras mot samma kluster — dokumenterat med rekommendation och bytesväg. | | Ö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`. | ## Arkitekturprinciper i koden diff --git a/infra/k8s/ingress.yaml b/infra/k8s/ingress.yaml index eb17d80..10fb694 100644 --- a/infra/k8s/ingress.yaml +++ b/infra/k8s/ingress.yaml @@ -7,6 +7,11 @@ metadata: namespace: guidad-felsokning annotations: cert-manager.io/cluster-issuer: letsencrypt # ersätt med er issuer + # Teknikern dokumenterar med foto och video som följer med + # händelseloggen. Standardgränsen (1 MB) skulle avvisa + # dokumentationen vid dörren medan tjänsten själv tar 4 MB. + nginx.ingress.kubernetes.io/proxy-body-size: 8m + nginx.ingress.kubernetes.io/proxy-read-timeout: "120" # AI-svar kan ta tid spec: ingressClassName: nginx tls: diff --git a/infra/terraform/10-namnrymd.tf b/infra/terraform/10-namnrymd.tf new file mode 100644 index 0000000..de2373a --- /dev/null +++ b/infra/terraform/10-namnrymd.tf @@ -0,0 +1,38 @@ +# Namnrymden och den enda hemligheten. + +resource "kubernetes_namespace_v1" "denna" { + metadata { + name = var.namnrymd + labels = local.etiketter + } +} + +# En Secret, men varje tjänst monterar bara sina egna nycklar — se +# hemligt-fältet per tjänst i karta.tf. +# +# Vill ni hellre ha hemligheterna utanför Terraform-tillståndet: byt den +# här resursen mot ett ExternalSecret och peka manifesten på samma namn. +resource "kubernetes_secret_v1" "hemligheter" { + metadata { + name = "felsokning-hemligheter" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = local.etiketter + } + + type = "Opaque" + data = local.hemligheter +} + +# Databasschemat körs vid databasens första start. Samma fil används av +# integrationstestet, så schemat kan aldrig glida isär från det som testas. +resource "kubernetes_config_map_v1" "postgres_init" { + metadata { + name = "postgres-init" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = local.etiketter + } + + data = { + "init.sql" = file("${path.module}/../k8s/postgres-init.sql") + } +} diff --git a/infra/terraform/20-databas.tf b/infra/terraform/20-databas.tf new file mode 100644 index 0000000..c28d825 --- /dev/null +++ b/infra/terraform/20-databas.tf @@ -0,0 +1,164 @@ +# Postgres — händelseloggen. +# +# Append-only garanteras av triggers i schemat, inte av att API:t låter +# bli att skriva. Det gör historiken oantastlig även för den som har +# databasåtkomst med rätt roll. +# +# En StatefulSet med en PVC är INTE säkerhetskopiering. Innan skarp drift: +# byt mot CloudNativePG-operatorn (basbackup + WAL-arkivering + PITR + +# failover) och peka plattformens DATABASE_URL på dess tjänst. + +resource "kubernetes_stateful_set_v1" "postgres" { + metadata { + name = "postgres" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = merge(local.etiketter, { app = "postgres" }) + } + + spec { + service_name = "postgres" + replicas = 1 + + selector { + match_labels = { app = "postgres" } + } + + template { + metadata { + labels = merge(local.etiketter, { app = "postgres" }) + } + + spec { + automount_service_account_token = false + + security_context { + run_as_non_root = true + run_as_user = 70 # postgres i alpine-bilden + fs_group = 70 + + seccomp_profile { + type = "RuntimeDefault" + } + } + + container { + name = "postgres" + image = local.tjanster.postgres.bild + + port { + container_port = 5432 + } + + env { + name = "POSTGRES_DB" + value = "felsokning" + } + + env { + name = "POSTGRES_USER" + value = "plattform" + } + + env { + name = "POSTGRES_PASSWORD" + + value_from { + secret_key_ref { + name = kubernetes_secret_v1.hemligheter.metadata[0].name + key = "postgres-losenord" + } + } + } + + env { + name = "PGDATA" + value = "/var/lib/postgresql/data/pgdata" + } + + volume_mount { + name = "data" + mount_path = "/var/lib/postgresql/data" + } + + volume_mount { + name = "init" + mount_path = "/docker-entrypoint-initdb.d" + } + + resources { + requests = { cpu = "250m", memory = "512Mi" } + limits = { cpu = "2", memory = "2Gi" } + } + + readiness_probe { + exec { + command = ["pg_isready", "-U", "plattform", "-d", "felsokning"] + } + + initial_delay_seconds = 5 + period_seconds = 10 + } + + # Startprob i stället för liveness under uppstart: en stor + # återställning får ta tid utan att poden dödas i loop. + startup_probe { + exec { + command = ["pg_isready", "-U", "plattform", "-d", "felsokning"] + } + + period_seconds = 10 + failure_threshold = 30 + } + + security_context { + allow_privilege_escalation = false + + capabilities { + drop = ["ALL"] + } + } + } + + volume { + name = "init" + + config_map { + name = kubernetes_config_map_v1.postgres_init.metadata[0].name + } + } + } + } + + volume_claim_template { + metadata { + name = "data" + } + + spec { + access_modes = ["ReadWriteOnce"] + storage_class_name = var.lagringsklass != "" ? var.lagringsklass : null + + resources { + requests = { storage = var.databas_storlek } + } + } + } + } +} + +resource "kubernetes_service_v1" "postgres" { + metadata { + name = "postgres" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = merge(local.etiketter, { app = "postgres" }) + } + + spec { + selector = { app = "postgres" } + + port { + port = 5432 + target_port = 5432 + } + } +} diff --git a/infra/terraform/30-plattform.tf b/infra/terraform/30-plattform.tf new file mode 100644 index 0000000..00572a0 --- /dev/null +++ b/infra/terraform/30-plattform.tf @@ -0,0 +1,209 @@ +# Plattformstjänsten — systemets mitt. +# +# Äger: inloggning (bcrypt via pgcrypto, HS256-JWT med roll + organisation +# i anspråken), append-only händelse-API, Live Share med serverstyrd +# filtrering, organisationsinställningar, ECM-regelpaketet och de +# märkesspecifika kopplingarna. +# +# Ser tre hemligheter och inga fler: JWT-hemligheten (delas med +# orkestern), databaslösenordet och krypteringsnyckeln för kundernas +# leverantörsuppgifter. + +resource "kubernetes_deployment_v1" "plattform" { + metadata { + name = "plattform" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = merge(local.etiketter, { app = "plattform" }) + } + + spec { + replicas = var.repliker.plattform + + selector { + match_labels = { app = "plattform" } + } + + template { + metadata { + labels = merge(local.etiketter, { app = "plattform" }) + } + + spec { + automount_service_account_token = false + + security_context { + run_as_non_root = true + + seccomp_profile { + type = "RuntimeDefault" + } + } + + container { + name = "plattform" + image = local.tjanster.plattform.bild + + port { + container_port = local.port_container + } + + env { + name = "JWT_SECRET" + + value_from { + secret_key_ref { + name = kubernetes_secret_v1.hemligheter.metadata[0].name + key = "jwt-secret" + } + } + } + + # Krypterar kundernas leverantörsuppgifter i vila. Saknas den + # sparas ingenting alls — tjänsten failar closed i stället för + # att lagra i klartext. + env { + name = "INTEGRATION_NYCKEL" + + value_from { + secret_key_ref { + name = kubernetes_secret_v1.hemligheter.metadata[0].name + key = "integration-nyckel" + } + } + } + + env { + name = "POSTGRES_LOSENORD" + + value_from { + secret_key_ref { + name = kubernetes_secret_v1.hemligheter.metadata[0].name + key = "postgres-losenord" + } + } + } + + env { + name = "DATABASE_URL" + value = "postgresql://plattform:$(POSTGRES_LOSENORD)@postgres:5432/felsokning" + } + + # Klienten serveras från samma domän som API:t, så CORS behöver + # inte vara öppet. + env { + name = "TILLATNA_URSPRUNG" + value = "https://${var.doman}" + } + + env { + name = "REGISTRERING_OPPEN" + value = var.registrering_oppen ? "true" : "false" + } + + env { + name = "TILLAT_INTERNA_UPPSLAG" + value = var.tillat_interna_uppslag ? "true" : "false" + } + + resources { + requests = { cpu = "100m", memory = "128Mi" } + limits = { cpu = "1", memory = "512Mi" } + } + + readiness_probe { + http_get { + path = "/halsa" + port = local.port_container + } + + initial_delay_seconds = 3 + period_seconds = 10 + } + + liveness_probe { + http_get { + path = "/halsa" + port = local.port_container + } + + period_seconds = 15 + } + + security_context { + allow_privilege_escalation = false + read_only_root_filesystem = true + + capabilities { + drop = ["ALL"] + } + } + } + } + } + } +} + +resource "kubernetes_service_v1" "plattform" { + metadata { + name = "plattform" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = merge(local.etiketter, { app = "plattform" }) + } + + spec { + selector = { app = "plattform" } + + port { + port = local.port_tjanst + target_port = local.port_container + } + } +} + +resource "kubernetes_horizontal_pod_autoscaler_v2" "plattform" { + metadata { + name = "plattform" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = local.etiketter + } + + spec { + min_replicas = var.repliker.plattform + max_replicas = var.max_repliker.plattform + + scale_target_ref { + api_version = "apps/v1" + kind = "Deployment" + name = kubernetes_deployment_v1.plattform.metadata[0].name + } + + metric { + type = "Resource" + + resource { + name = "cpu" + + target { + type = "Utilization" + average_utilization = 70 + } + } + } + } +} + +resource "kubernetes_pod_disruption_budget_v1" "plattform" { + metadata { + name = "plattform" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = local.etiketter + } + + spec { + min_available = 1 + + selector { + match_labels = { app = "plattform" } + } + } +} diff --git a/infra/terraform/40-orkester.tf b/infra/terraform/40-orkester.tf new file mode 100644 index 0000000..943bfe6 --- /dev/null +++ b/infra/terraform/40-orkester.tf @@ -0,0 +1,174 @@ +# AI-orkestern. +# +# Egen tjänst av två skäl: Claude-nyckeln ska bara finnas på ett ställe, +# och AI-anrop har helt annan latens- och skalningsprofil än +# händelse-API:t. Den skalar därför separat och kan gå ned utan att +# felsökningen slutar fungera — metodiken är deterministisk och klarar +# sig utan modellsvar. +# +# Orkestern äger modellval, systemprompt och svarsschema. Klienten +# skickar bara uppgiftstyp och underlag. Den verifierar plattformens JWT +# med samma delade hemlighet — den litar aldrig på anroparen. + +resource "kubernetes_deployment_v1" "orkester" { + metadata { + name = "ai-orkester" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = merge(local.etiketter, { app = "ai-orkester" }) + } + + spec { + replicas = var.repliker.orkester + + selector { + match_labels = { app = "ai-orkester" } + } + + template { + metadata { + labels = merge(local.etiketter, { app = "ai-orkester" }) + } + + spec { + automount_service_account_token = false + + security_context { + run_as_non_root = true + + seccomp_profile { + type = "RuntimeDefault" + } + } + + container { + name = "ai-orkester" + image = local.tjanster.orkester.bild + + port { + container_port = local.port_container + } + + env { + name = "ANTHROPIC_API_KEY" + + value_from { + secret_key_ref { + name = kubernetes_secret_v1.hemligheter.metadata[0].name + key = "anthropic-api-key" + } + } + } + + env { + name = "JWT_SECRET" + + value_from { + secret_key_ref { + name = kubernetes_secret_v1.hemligheter.metadata[0].name + key = "jwt-secret" + } + } + } + + resources { + requests = { cpu = "100m", memory = "128Mi" } + limits = { cpu = "1", memory = "512Mi" } + } + + readiness_probe { + http_get { + path = "/halsa" + port = local.port_container + } + + initial_delay_seconds = 3 + period_seconds = 10 + } + + liveness_probe { + http_get { + path = "/halsa" + port = local.port_container + } + + period_seconds = 15 + } + + security_context { + allow_privilege_escalation = false + read_only_root_filesystem = true + + capabilities { + drop = ["ALL"] + } + } + } + } + } + } +} + +resource "kubernetes_service_v1" "orkester" { + metadata { + name = "ai-orkester" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = merge(local.etiketter, { app = "ai-orkester" }) + } + + spec { + selector = { app = "ai-orkester" } + + port { + port = local.port_tjanst + target_port = local.port_container + } + } +} + +resource "kubernetes_horizontal_pod_autoscaler_v2" "orkester" { + metadata { + name = "ai-orkester" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = local.etiketter + } + + spec { + min_replicas = var.repliker.orkester + max_replicas = var.max_repliker.orkester + + scale_target_ref { + api_version = "apps/v1" + kind = "Deployment" + name = kubernetes_deployment_v1.orkester.metadata[0].name + } + + metric { + type = "Resource" + + resource { + name = "cpu" + + target { + type = "Utilization" + average_utilization = 70 + } + } + } + } +} + +resource "kubernetes_pod_disruption_budget_v1" "orkester" { + metadata { + name = "ai-orkester" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = local.etiketter + } + + spec { + min_available = 1 + + selector { + match_labels = { app = "ai-orkester" } + } + } +} diff --git a/infra/terraform/50-web.tf b/infra/terraform/50-web.tf new file mode 100644 index 0000000..b7eed16 --- /dev/null +++ b/infra/terraform/50-web.tf @@ -0,0 +1,170 @@ +# Klienten. +# +# Statisk SPA bakom oprivilegierad nginx. Innehåller ingen hemlighet — +# men API-adressen bakas in vid bygget (Vite), så bilden är miljöbunden. +# Det är därför bildtaggen måste vara samma git-SHA som byggdes mot den +# här domänen; en bild från en annan miljö pekar på fel API. +# +# nginx-unprivileged behöver skriva till sina temp-kataloger, därför +# emptyDir-monteringar i stället för skrivbart rotfilsystem. + +resource "kubernetes_deployment_v1" "web" { + metadata { + name = "web" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = merge(local.etiketter, { app = "web" }) + } + + spec { + replicas = var.repliker.web + + selector { + match_labels = { app = "web" } + } + + template { + metadata { + labels = merge(local.etiketter, { app = "web" }) + } + + spec { + automount_service_account_token = false + + security_context { + run_as_non_root = true + + seccomp_profile { + type = "RuntimeDefault" + } + } + + container { + name = "web" + image = local.tjanster.web.bild + + port { + container_port = local.port_container + } + + volume_mount { + name = "cache" + mount_path = "/var/cache/nginx" + } + + volume_mount { + name = "run" + mount_path = "/tmp" + } + + resources { + requests = { cpu = "50m", memory = "64Mi" } + limits = { cpu = "250m", memory = "128Mi" } + } + + readiness_probe { + http_get { + path = "/" + port = local.port_container + } + + initial_delay_seconds = 3 + period_seconds = 10 + } + + liveness_probe { + http_get { + path = "/" + port = local.port_container + } + + period_seconds = 15 + } + + security_context { + allow_privilege_escalation = false + read_only_root_filesystem = true + + capabilities { + drop = ["ALL"] + } + } + } + + volume { + name = "cache" + empty_dir {} + } + + volume { + name = "run" + empty_dir {} + } + } + } + } +} + +resource "kubernetes_service_v1" "web" { + metadata { + name = "web" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = merge(local.etiketter, { app = "web" }) + } + + spec { + selector = { app = "web" } + + port { + port = local.port_tjanst + target_port = local.port_container + } + } +} + +resource "kubernetes_horizontal_pod_autoscaler_v2" "web" { + metadata { + name = "web" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = local.etiketter + } + + spec { + min_replicas = var.repliker.web + max_replicas = var.max_repliker.web + + scale_target_ref { + api_version = "apps/v1" + kind = "Deployment" + name = kubernetes_deployment_v1.web.metadata[0].name + } + + metric { + type = "Resource" + + resource { + name = "cpu" + + target { + type = "Utilization" + average_utilization = 70 + } + } + } + } +} + +resource "kubernetes_pod_disruption_budget_v1" "web" { + metadata { + name = "web" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = local.etiketter + } + + spec { + min_available = 1 + + selector { + match_labels = { app = "web" } + } + } +} diff --git a/infra/terraform/60-ingress.tf b/infra/terraform/60-ingress.tf new file mode 100644 index 0000000..094bc13 --- /dev/null +++ b/infra/terraform/60-ingress.tf @@ -0,0 +1,61 @@ +# Trafiken utifrån och in. En domän, fyra prefix — se local.routing. +# +# Kroppsgränsen är inte kosmetisk: teknikern dokumenterar med foto och +# video som följer med händelseloggen. Ingressens standardgräns (1 MB) +# skulle avvisa dokumentationen redan vid dörren, medan tjänsten själv +# accepterar 4 MB. Gränsen sätts därför uttryckligen och med marginal. + +resource "kubernetes_ingress_v1" "denna" { + metadata { + name = local.namn + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = local.etiketter + + annotations = { + "cert-manager.io/cluster-issuer" = var.cert_issuer + "nginx.ingress.kubernetes.io/proxy-body-size" = "${var.max_kropp_mb}m" + "nginx.ingress.kubernetes.io/proxy-read-timeout" = "120" # AI-svar kan ta tid + "nginx.ingress.kubernetes.io/ssl-redirect" = "true" + } + } + + spec { + ingress_class_name = var.ingress_klass + + tls { + hosts = [var.doman] + secret_name = "${local.namn}-tls" + } + + rule { + host = var.doman + + http { + dynamic "path" { + for_each = local.routing + + content { + path = path.value.prefix + path_type = "Prefix" + + backend { + service { + name = path.value.till + + port { + number = local.port_tjanst + } + } + } + } + } + } + } + } + + depends_on = [ + kubernetes_service_v1.web, + kubernetes_service_v1.plattform, + kubernetes_service_v1.orkester, + ] +} diff --git a/infra/terraform/70-natverk.tf b/infra/terraform/70-natverk.tf new file mode 100644 index 0000000..3c2b728 --- /dev/null +++ b/infra/terraform/70-natverk.tf @@ -0,0 +1,241 @@ +# Vem får prata med vem. +# +# Utan nätverkspolicyer är ett Kubernetes-kluster platt: vilken pod som +# helst når databasen på 5432. Här stängs allt först, och bara de +# faktiska flödena öppnas — samma flöden som står i local.dataflode. +# +# Kräver en CNI som tillämpar NetworkPolicy (Cilium, Calico, Antrea). +# Med en CNI som inte gör det är de här reglerna dokumentation, inte +# skydd — kontrollera det innan ni litar på dem. + +# Grundregel: ingen inkommande trafik alls i namnrymden. +resource "kubernetes_network_policy_v1" "neka_allt_in" { + metadata { + name = "neka-allt-inkommande" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = local.etiketter + } + + spec { + pod_selector {} + policy_types = ["Ingress"] + } +} + +# Databasen: bara plattformstjänsten, bara 5432. +resource "kubernetes_network_policy_v1" "postgres_in" { + metadata { + name = "postgres-endast-fran-plattform" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = local.etiketter + } + + spec { + pod_selector { + match_labels = { app = "postgres" } + } + + policy_types = ["Ingress"] + + ingress { + from { + pod_selector { + match_labels = { app = "plattform" } + } + } + + ports { + port = "5432" + protocol = "TCP" + } + } + } +} + +# De tre webbtjänsterna: bara från ingress-kontrollern. +resource "kubernetes_network_policy_v1" "tjanster_in" { + for_each = toset(["web", "plattform", "ai-orkester"]) + + metadata { + name = "${each.key}-endast-fran-ingress" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = local.etiketter + } + + spec { + pod_selector { + match_labels = { app = each.key } + } + + policy_types = ["Ingress"] + + ingress { + from { + namespace_selector { + match_labels = { + "kubernetes.io/metadata.name" = var.ingress_namnrymd + } + } + } + + ports { + port = tostring(local.port_container) + protocol = "TCP" + } + } + } +} + +# Utgående: DNS åt alla, i övrigt bara det som faktiskt behövs. +resource "kubernetes_network_policy_v1" "dns_ut" { + metadata { + name = "dns-ut" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = local.etiketter + } + + spec { + pod_selector {} + policy_types = ["Egress"] + + egress { + ports { + port = "53" + protocol = "UDP" + } + + ports { + port = "53" + protocol = "TCP" + } + } + } +} + +# Klienten är statisk och ringer ingenting från podden. +resource "kubernetes_network_policy_v1" "web_ut" { + metadata { + name = "web-inget-utgaende" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = local.etiketter + } + + spec { + pod_selector { + match_labels = { app = "web" } + } + + policy_types = ["Egress"] + # Ingen egress-regel = inget utgående (utöver DNS-policyn ovan). + } +} + +# Databasen ringer heller ingenting. +resource "kubernetes_network_policy_v1" "postgres_ut" { + metadata { + name = "postgres-inget-utgaende" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = local.etiketter + } + + spec { + pod_selector { + match_labels = { app = "postgres" } + } + + policy_types = ["Egress"] + } +} + +# Plattformen: databasen internt, och HTTPS ut till kundernas +# leverantörer. Klustrets och molnets interna adresser är undantagna — +# samma gräns som koden själv upprätthåller (pekarInat), här en gång till +# på nätverksnivå. Två oberoende spärrar för samma sak. +resource "kubernetes_network_policy_v1" "plattform_ut" { + metadata { + name = "plattform-utgaende" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = local.etiketter + } + + spec { + pod_selector { + match_labels = { app = "plattform" } + } + + policy_types = ["Egress"] + + egress { + to { + pod_selector { + match_labels = { app = "postgres" } + } + } + + ports { + port = "5432" + protocol = "TCP" + } + } + + egress { + to { + ip_block { + cidr = "0.0.0.0/0" + + except = [ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "169.254.0.0/16", # molnets metadatatjänst + "127.0.0.0/8", + "100.64.0.0/10", + ] + } + } + + ports { + port = "443" + protocol = "TCP" + } + } + } +} + +# Orkestern: bara HTTPS ut till Claude. +resource "kubernetes_network_policy_v1" "orkester_ut" { + metadata { + name = "ai-orkester-utgaende" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = local.etiketter + } + + spec { + pod_selector { + match_labels = { app = "ai-orkester" } + } + + policy_types = ["Egress"] + + egress { + to { + ip_block { + cidr = "0.0.0.0/0" + + except = [ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "169.254.0.0/16", + "127.0.0.0/8", + ] + } + } + + ports { + port = "443" + protocol = "TCP" + } + } + } +} diff --git a/infra/terraform/README.md b/infra/terraform/README.md new file mode 100644 index 0000000..2feef86 --- /dev/null +++ b/infra/terraform/README.md @@ -0,0 +1,99 @@ +# Infrastrukturen + +Hela systemet i Terraform. Filerna är numrerade i den ordning de är +begripliga att läsa: + +| Fil | Vad | +| --- | --- | +| `versions.tf` | Leverantörer och tillståndsbackend | +| `variables.tf` | Allt som skiljer en installation från en annan | +| `karta.tf` | **Systemet som data** — tjänster, portar, routing, hemligheter, dataflöden, gränser | +| `10-namnrymd.tf` | Namnrymd, hemligheten, databasschemat | +| `20-databas.tf` | Postgres — händelseloggen | +| `30-plattform.tf` | Backend: auth, händelse-API, delning, integrationer | +| `40-orkester.tf` | AI-orkestern | +| `50-web.tf` | Klienten | +| `60-ingress.tf` | Trafiken utifrån | +| `70-natverk.tf` | Nätverkspolicyer — vem får prata med vem | +| `outputs.tf` | Kartan utskriven | + +Börja i `karta.tf`. Den beskriver systemet en gång, som data; resten av +filerna läser därifrån i stället för att upprepa portar och namn. + +## Kom igång + +```sh +cp terraform.tfvars.exempel terraform.tfvars # fyll i domän, register, nycklar +terraform init +terraform plan +terraform apply +terraform output karta # hela systemet i klartext +``` + +`terraform output karta` svarar på frågorna "vad kör var", "vem ser +vilken hemlighet", "vad pratar med vad" och "vad ingår inte" — utan att +någon behöver läsa .tf-filerna. + +## Kontroll utan kluster + +```sh +terraform fmt -check -diff # formatering +terraform init -backend=false # hämtar leverantörer +terraform validate # typer och referenser +``` + +`terraform validate` kräver att leverantörerna hämtats från +registry.terraform.io. + +## Förhållandet till kustomize och Argo CD + +`infra/k8s` + `infra/overlays` + `infra/gitops` beskriver **samma system** +i kustomize, synkat av Argo CD. Det är en historisk parallell väg. + +**Välj en.** Kör båda mot samma kluster och de motarbetar varandra: Argo +CD:s `selfHeal` återställer det Terraform just ändrat, och `prune` tar +bort det Terraform skapat. + +Rekommendationen är Terraform, av tre skäl: + +1. Nätverkspolicyer, genererade hemligheter och (senare) molnresurser + som databas, DNS och objektlagring hör hemma här. +2. Kartan blir läsbar — `karta.tf` och `terraform output` finns inte i + kustomize-varianten. +3. Kustomize-vägen saknar i dag nätverkspolicyer och kör Postgres utan + säkerhetskontext. + +Byter ni: ta bort Argo CD-applikationen (`kubectl -n argocd delete +application guidad-felsokning`) **innan** första `terraform apply`, och +importera befintliga resurser med `terraform import` om ni vill undvika +omstart. Behåller ni Argo CD i stället: applicera nätverkspolicyerna och +ingressens kroppsgräns därifrån också. + +Bildtaggen kommer från publiceringsflödet oavsett väg — kör +`terraform apply -var bildtagg=` i stället för att låta CI +skriva i git. + +## Det som medvetet inte ingår + +Står också i `terraform output karta` under `avgränsningar`: + +- **Säkerhetskopiering.** En StatefulSet med en PVC är inte backup. Sätt + CloudNativePG (basbackup, WAL-arkivering, PITR, failover) innan skarp + drift och peka `DATABASE_URL` på dess tjänst. +- **Objektlagring.** Foton och video ligger som data-URL:er i + händelseloggen. Det gör loggen till systemets enda sanningskälla, men + också stor och tung att säkerhetskopiera. +- **Observability.** Ingen metrikexport, ingen tracing. +- **Takt-begränsning på inloggning.** Bara den publika beslutsvägen är + begränsad, och bara per pod. +- **Återkallelse av JWT.** En utfärdad token gäller sina 12 timmar ut + även om användaren tagits bort. + +## Krav på klustret + +- En CNI som tillämpar NetworkPolicy (Cilium, Calico, Antrea). Utan det + är `70-natverk.tf` dokumentation, inte skydd. +- ingress-nginx, med kontrollern i namnrymden `var.ingress_namnrymd`. +- cert-manager med en ClusterIssuer. +- En metrics-server för autoskalningen. +- En StorageClass som klarar `ReadWriteOnce`. diff --git a/infra/terraform/karta.tf b/infra/terraform/karta.tf new file mode 100644 index 0000000..17a3c34 --- /dev/null +++ b/infra/terraform/karta.tf @@ -0,0 +1,127 @@ +# Kartan — hela systemet beskrivet som data. +# +# Resursfilerna läser härifrån i stället för att upprepa portar, namn och +# gränser. Det gör den här filen till ett ställe där man kan läsa av vad +# systemet faktiskt består av, och `terraform output karta` skriver ut +# samma sak i klartext. + +locals { + namn = "guidad-felsokning" + + etiketter = { + "app.kubernetes.io/part-of" = local.namn + "app.kubernetes.io/managed-by" = "terraform" + "guidad-felsokning/miljo" = var.miljo + } + + # Alla tjänster lyssnar internt på 8080 och exponeras som port 80 i + # klustret. Databasen är den enda som inte följer mönstret. + port_container = 8080 + port_tjanst = 80 + + # ---- Tjänsterna ----------------------------------------------------- + # + # roll vad den gör + # bild containerbilden + # hemligt vilka hemligheter den får se (allt annat är den blind för) + # utat vem den ringer + + tjanster = { + web = { + roll = "Klienten. Statisk SPA bakom oprivilegierad nginx. Innehåller ingen hemlighet — API-adressen bakas in vid bygget." + bild = "${var.register}/${local.namn}-web:${var.bildtagg}" + hemligt = [] + 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)"] + } + orkester = { + roll = "AI-orkestern. Routar per uppgift till Claude, äger systemprompt och svarsschema. Verifierar plattformens JWT." + bild = "${var.register}/${local.namn}-ai-orkester:${var.bildtagg}" + hemligt = ["anthropic-api-nyckel", "jwt-hemlighet"] + utat = ["api.anthropic.com"] + } + postgres = { + roll = "Händelseloggen — systemets enda sanningskälla. Append-only garanteras av databastriggers, inte bara av API:t." + bild = "postgres:17-alpine" + hemligt = ["postgres-losenord"] + utat = [] + } + } + + # ---- Routing -------------------------------------------------------- + # + # Längsta prefix vinner. Ordningen här är den ordning ingressen får + # reglerna i. + + routing = [ + { prefix = "/api/ai", till = "ai-orkester", varfor = "AI-anrop går till orkestern, aldrig via plattformen" }, + { prefix = "/api", till = "plattform", varfor = "allt övrigt API" }, + { prefix = "/halsa", till = "plattform", varfor = "hälsokontroll" }, + { prefix = "/", till = "web", varfor = "klienten" }, + ] + + # ---- Hemligheterna -------------------------------------------------- + # + # En enda Secret, men varje tjänst monterar bara sina egna nycklar. + + hemligheter = { + "anthropic-api-key" = var.anthropic_api_nyckel + "jwt-secret" = coalesce(var.jwt_hemlighet, random_password.jwt.result) + "postgres-losenord" = coalesce(var.postgres_losenord, random_password.postgres.result) + "integration-nyckel" = coalesce( + var.integration_nyckel, + random_id.integration_nyckel.hex, + ) + } + + databas_url = "postgresql://plattform:${local.hemligheter["postgres-losenord"]}@postgres:5432/felsokning" + + # ---- Dataflöden ----------------------------------------------------- + # + # Det som är värt att förstå innan man ändrar något: vad som rör sig + # var, och var gränserna går. + + dataflode = [ + "Tekniker → web → ingress → plattform → postgres all ärendedata, append-only", + "Tekniker → web → ingress → orkester → Claude underlag ut, klassificerade svar in", + "Kund → ingress → plattform → postgres Live Share: bara tillåtna händelsetyper", + "Plattform → kundens leverantör VIN/regnr ut, fordonsuppgifter in", + ] + + 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.", + ] + + # ---- Det som medvetet inte ingår ------------------------------------ + + avgransningar = [ + "Säkerhetskopiering av databasen. StatefulSet + PVC är inte backup — sätt CloudNativePG eller motsvarande innan skarp drift.", + "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.", + ] +} + +# Genereras bara när motsvarande variabel lämnats tom. +resource "random_password" "jwt" { + length = 64 + special = false +} + +resource "random_password" "postgres" { + length = 32 + special = false +} + +resource "random_id" "integration_nyckel" { + byte_length = 32 +} diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf new file mode 100644 index 0000000..a965470 --- /dev/null +++ b/infra/terraform/outputs.tf @@ -0,0 +1,70 @@ +# Kartan utskriven. +# +# terraform output karta hela systemet i klartext +# terraform output -json karta samma sak maskinläsbart + +output "karta" { + description = "Hela systemet: tjänster, routing, hemligheter, dataflöden och gränser." + + value = { + miljo = var.miljo + domän = "https://${var.doman}" + namnrymd = var.namnrymd + bildtagg = var.bildtagg + + tjanster = { + for namn, t in local.tjanster : namn => { + gör = t.roll + bild = t.bild + ser_hemlighet = length(t.hemligt) > 0 ? join(", ", t.hemligt) : "inga" + ringer = length(t.utat) > 0 ? join("; ", t.utat) : "ingenting" + } + } + + routing = [ + for r in local.routing : "https://${var.doman}${r.prefix} → ${r.till} (${r.varfor})" + ] + + dataflöden = local.dataflode + gränser = local.granser + + databas = { + motor = "PostgreSQL 17" + volym = var.databas_storlek + säkerhet = "append-only via triggers — historik kan inte ändras eller raderas av någon roll" + backup = "INGEN i den här definitionen — se avgränsningar" + } + + drift = { + registrering_öppen = var.registrering_oppen + interna_uppslag_tillåtna = var.tillat_interna_uppslag + max_kropp = "${var.max_kropp_mb} MB (ingress) / 4 MB (tjänst)" + cors = "https://${var.doman}" + } + + avgränsningar = local.avgransningar + } +} + +output "endpoints" { + description = "Adresser att kontrollera efter driftsättning." + + value = { + klient = "https://${var.doman}/felsokning" + hälsa = "https://${var.doman}/halsa" + api_spec = "https://${var.doman}/api/openapi.yaml" + ai = "https://${var.doman}/api/ai" + delningslänk = "https://${var.doman}/felsokning/delad/" + } +} + +output "genererade_hemligheter" { + description = "Hemligheter Terraform genererat (tomma om ni satt egna). Finns i tillståndet — skydda det." + sensitive = true + + value = { + jwt_hemlighet = var.jwt_hemlighet == "" ? random_password.jwt.result : "(egen)" + postgres_losenord = var.postgres_losenord == "" ? random_password.postgres.result : "(egen)" + integration_nyckel = var.integration_nyckel == "" ? random_id.integration_nyckel.hex : "(egen)" + } +} diff --git a/infra/terraform/terraform.tfvars.exempel b/infra/terraform/terraform.tfvars.exempel new file mode 100644 index 0000000..b6359dd --- /dev/null +++ b/infra/terraform/terraform.tfvars.exempel @@ -0,0 +1,37 @@ +# Kopiera till terraform.tfvars och fyll i. Checka aldrig in riktiga +# nycklar — .gitignore utesluter *.tfvars. + +doman = "app.exempel.se" +register = "ghcr.io/min-organisation" +bildtagg = "ersatt-med-git-sha" + +miljo = "produktion" + +# Claude-nyckeln ägs av plattformen och når aldrig klienten. +# Hämta hellre från en secrets-hanterare än att skriva den här: +# export TF_VAR_anthropic_api_nyckel="$(vault kv get -field=key ...)" +anthropic_api_nyckel = "sk-ant-…" + +# Lämnas dessa tomma genererar Terraform dem vid första körningen och +# behåller dem i tillståndet. Byts integration_nyckel måste alla +# märkesspecifika kopplingar sparas om. +jwt_hemlighet = "" +postgres_losenord = "" +integration_nyckel = "" + +# Stäng självregistrering i produktion — annars kan vem som helst skapa +# en organisation. +registrering_oppen = false + +# Öppnar en väg från en tenant-administratör in i klustrets interna +# adresser. Sätt bara true om verkstaden har en OEM-server på eget nät. +tillat_interna_uppslag = false + +databas_storlek = "50Gi" +# lagringsklass = "premium-rwo" + +repliker = { + web = 2 + plattform = 2 + orkester = 2 +} diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf new file mode 100644 index 0000000..8b6ea58 --- /dev/null +++ b/infra/terraform/variables.tf @@ -0,0 +1,182 @@ +# Allt som skiljer en installation från en annan. Inget annat ska +# behöva ändras i .tf-filerna för att driftsätta i en ny miljö. + +# ---- Kluster ----------------------------------------------------------- + +variable "kubeconfig" { + description = "Sökväg till kubeconfig." + type = string + default = "~/.kube/config" +} + +variable "kube_context" { + description = "Kubernetes-kontext att driftsätta i. Tom = kubeconfigens aktuella." + type = string + default = "" +} + +variable "namnrymd" { + description = "Namnrymd för hela systemet." + type = string + default = "guidad-felsokning" +} + +variable "miljo" { + description = "Miljöns namn (produktion, test, demo). Sätts som label på allt." + type = string + default = "produktion" +} + +# ---- Domän och certifikat --------------------------------------------- + +variable "doman" { + description = "Domänen klienten och API:t nås på, t.ex. app.exempel.se." + type = string +} + +variable "cert_issuer" { + description = "cert-manager ClusterIssuer som utfärdar TLS-certifikatet." + type = string + default = "letsencrypt" +} + +variable "ingress_klass" { + description = "IngressClass. Nätverkspolicyn öppnar för ingress-kontrollerns namnrymd nedan." + type = string + default = "nginx" +} + +variable "ingress_namnrymd" { + description = "Namnrymd där ingress-kontrollern kör — behövs av nätverkspolicyn." + type = string + default = "ingress-nginx" +} + +# ---- Bilder ------------------------------------------------------------ + +variable "register" { + description = "Containerregister, t.ex. ghcr.io/min-organisation." + type = string +} + +variable "bildtagg" { + description = <<-TEXT + Taggen alla tre bilderna körs med — normalt git-SHA:t från + publiceringsflödet. "latest" duger för prov men gör en rullning + omöjlig att spåra. + TEXT + type = string + + validation { + condition = var.bildtagg != "" + error_message = "Ange en bildtagg — helst git-SHA:t, aldrig tomt." + } +} + +# ---- Hemligheter ------------------------------------------------------- +# +# Sätts helst via en secrets-hanterare (External Secrets, Vault, molnets +# secret manager) i stället för tfvars. Lämnas de tomma genererar +# Terraform slumpvärden vid första körningen och behåller dem i +# tillståndet — bekvämt för prov, men då måste tillståndet skyddas +# därefter. + +variable "anthropic_api_nyckel" { + description = "Claude API-nyckel. Ägs av plattformen, når aldrig klienten." + type = string + sensitive = true +} + +variable "jwt_hemlighet" { + description = "HS256-hemlighet. Delas av plattformen och orkestern. Tom = genereras." + type = string + sensitive = true + default = "" +} + +variable "postgres_losenord" { + description = "Databaslösenord. Tom = genereras." + type = string + sensitive = true + default = "" +} + +variable "integration_nyckel" { + description = <<-TEXT + 32 byte hex som krypterar kundernas leverantörsuppgifter i vila. + Tom = genereras. Byts nyckeln måste alla kopplingar sparas om — + tjänsten visar då inga värden i stället för att gissa. + TEXT + type = string + sensitive = true + default = "" +} + +# ---- Drift ------------------------------------------------------------- + +variable "registrering_oppen" { + description = "Om vem som helst får skapa en ny organisation. Stäng i produktion." + type = bool + default = false +} + +variable "tillat_interna_uppslag" { + description = <<-TEXT + Tillåter märkesspecifika uppslag mot privata nät. Behövs bara när + verkstaden har en OEM-server på sitt eget nät. Öppnar samtidigt en + väg från en tenant-administratör in i klustrets interna adresser — + lämna av om ni inte vet att ni behöver det. + TEXT + type = bool + default = false +} + +variable "max_kropp_mb" { + description = <<-TEXT + Största tillåtna anropskropp. Foton och videoklipp följer med i + händelseloggen, så ingressens gräns måste minst motsvara tjänstens + egen (4 MB) — annars avvisas dokumentationen redan vid dörren. + TEXT + type = number + default = 8 +} + +variable "repliker" { + description = "Grundantal repliker per tjänst (autoskalning tar över uppåt)." + type = object({ + web = number + plattform = number + orkester = number + }) + default = { + web = 2 + plattform = 2 + orkester = 2 + } +} + +variable "max_repliker" { + description = "Tak för autoskalningen per tjänst." + type = object({ + web = number + plattform = number + orkester = number + }) + default = { + web = 10 + plattform = 10 + orkester = 10 + } +} + +variable "databas_storlek" { + description = "Volymstorlek för händelseloggen. Foton och video ligger inline i loggen." + type = string + default = "50Gi" +} + +variable "lagringsklass" { + description = "StorageClass för databasvolymen. Tom = klustrets standard." + type = string + default = "" +} diff --git a/infra/terraform/versions.tf b/infra/terraform/versions.tf new file mode 100644 index 0000000..4bd122f --- /dev/null +++ b/infra/terraform/versions.tf @@ -0,0 +1,47 @@ +# Guidad Felsökning — infrastrukturens definition. +# +# Läsordning för den som vill förstå hela systemet: +# versions.tf det här — leverantörer och tillstånd +# variables.tf alla rattar som finns att vrida på +# karta.tf locals: hela systemet som data (tjänster, portar, +# routing, hemligheter, dataflöden) — kartans källa +# 10-namnrymd.tf namnrymd + hemligheter +# 20-databas.tf Postgres: händelseloggen +# 30-plattform.tf backend: auth, händelse-API, delning, integrationer +# 40-orkester.tf AI-orkestern: modellrouting mot Claude +# 50-web.tf klienten +# 60-ingress.tf trafiken utifrån och in +# 70-natverk.tf nätverkspolicyer: vem får prata med vem +# outputs.tf kartan utskriven — kör `terraform output karta` + +terraform { + required_version = ">= 1.6" + + required_providers { + kubernetes = { + source = "hashicorp/kubernetes" + version = "~> 2.35" + } + random = { + source = "hashicorp/random" + version = "~> 3.6" + } + } + + # Tillståndet innehåller hemligheter (JWT-hemlighet, databaslösenord, + # krypteringsnyckeln för kundernas leverantörsuppgifter). Lägg det i en + # backend med kryptering och åtkomststyrning — aldrig lokalt i git. + # + # backend "s3" { + # bucket = "guidad-felsokning-tfstate" + # key = "produktion/terraform.tfstate" + # region = "eu-north-1" + # encrypt = true + # use_lockfile = true + # } +} + +provider "kubernetes" { + config_path = var.kubeconfig + config_context = var.kube_context +} diff --git a/services/plattform/integrationstest.sh b/services/plattform/integrationstest.sh index cd411e4..424144b 100755 --- a/services/plattform/integrationstest.sh +++ b/services/plattform/integrationstest.sh @@ -318,10 +318,14 @@ KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer/vol -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"YV1DZ8256F2123456"}') kontroll "okonfigurerad koppling ger 404" "$KOD" "404" -# Leverantören är onåbar i testmiljön — felet rapporteras ärligt, inte tyst +# Bas-URL:en pekar inåt (127.0.0.1) — uppslaget får inte bli en väg in i +# klustret. Anropet ska stoppas innan det görs och rapporteras ärligt. +SVAR=$(curl -s -X POST "$BAS/api/integrationer/generisk_vin/uppslag" \ + -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"YV1DZ8256F2123456"}') KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer/generisk_vin/uppslag" \ -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"YV1DZ8256F2123456"}') -kontroll "onåbar leverantör rapporteras som 502" "$KOD" "502" +kontroll "uppslag mot intern adress avvisas (502)" "$KOD" "502" +kontroll "felet säger varför" "$(echo "$SVAR" | falt '.error.includes("intern adress")')" "true" STATUS=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \ | falt '.integrationer[0].senaste_status.slice(0,3)') kontroll "senaste testresultat sparas på kopplingen" "$STATUS" "fel" diff --git a/services/plattform/server.mjs b/services/plattform/server.mjs index cc5a05d..acc1bc5 100644 --- a/services/plattform/server.mjs +++ b/services/plattform/server.mjs @@ -12,11 +12,16 @@ // DATABASE_URL Postgres-anslutning (krävs) // JWT_SECRET HS256-hemlighet, delas med ai-orkestern (krävs) // REGISTRERING_OPPEN "false" stänger nya organisationer (default öppen, beta) +// INTEGRATION_NYCKEL 32 byte (hex/base64) — krypterar kundernas leverantörsuppgifter +// TILLATNA_URSPRUNG kommaseparerade ursprung för CORS (utelämnad = "*") +// TILLAT_INTERNA_UPPSLAG "true" tillåter leverantörsuppslag mot privata nät +// ECM_REGLER_FIL / INTEGRATIONER_FIL sökvägar till utbytbar konfiguration // PORT default 8080 import { createServer } from "node:http"; import crypto, { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { readFileSync } from "node:fs"; +import { lookup } from "node:dns/promises"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import pg from "pg"; @@ -50,6 +55,21 @@ const ROLLER = ["tekniker", "arbetsledare", "admin"]; const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 }); +// 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" — +// utelämnad betyder "*" (öppet), vilket bara hör hemma i utveckling. +const TILLATNA_URSPRUNG = (process.env.TILLATNA_URSPRUNG ?? "") + .split(",") + .map((u) => u.trim()) + .filter(Boolean); + +function ursprungFor(req) { + const ursprung = req.headers.origin; + if (TILLATNA_URSPRUNG.length === 0) return "*"; + return ursprung && TILLATNA_URSPRUNG.includes(ursprung) ? ursprung : TILLATNA_URSPRUNG[0]; +} + // Enkel takt-begränsning för den publika beslutsendpointen (per // delningskod, i minnet). Räcker för en enda pod; bakom flera repliker // kompletteras den av databasspärren "ett beslut per förslag". @@ -134,12 +154,60 @@ function leverantorsDef(id) { return INTEGRATIONER.leverantorer.find((l) => l.id === id); } +// ---- Delningsfilter: tillåtelselista, inte nekalista ------------------ +// +// Vilka händelsetyper som får lämna verkstaden är en integritetsgräns. +// Med en nekalista blir varje NY händelsetyp automatiskt synlig för +// kunden tills någon kommer ihåg att neka den — fel håll att fela åt. +// Här listas i stället uttryckligen vad som får delas; allt annat är +// internt tills det aktivt släpps fram. Testet i delning.test.ts kräver +// att varje händelsetyp i domänmodellen är klassificerad. +export const DELBART_KUND = [ + "objekt_identifierat", + "arendetyp_satt", + "felbeskrivning", + "fraga_besvarad", + "kontroll_utford", + "observation", + "matvarde", + "foto", + "video", + "kommentar", + "inaktivitet_forklarad", + "overlamning", + "historik_kontrollerad", + "matarstallning", + "reproducering", + "felorsak", + "atgardsforslag", + "kundbeslut", + "atgard_utford", + "kvalitetskontroll", + "export_skapad", + "arende_avslutat", +]; + +// Extern partner (försäkringsbolag, tillverkare) ser dessutom hypoteser +// — alltid märkta som ej verifierade. +export const DELBART_PARTNER = [...DELBART_KUND, "hypotes"]; + +// Aldrig utanför organisationen: arbetsledning, arbetsmaterial och +// underlag som kan läsas som konstateranden. +export const ENDAST_INTERNT = ["kategori_byte", "hypotes", "ai_svar", "ansvarig_satt", "arbetsorder_skannad"]; + +export function synligaTyper(niva) { + if (niva === "intern") return null; // full insyn — ingen filtrering + return niva === "partner" ? DELBART_PARTNER : DELBART_KUND; +} + // ---- Hjälpare --------------------------------------------------------- function svara(res, status, kropp) { res.writeHead(status, { "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*", + // Ursprunget sätts en gång per anrop i hanteraren nedan. + "Access-Control-Allow-Origin": res.ursprung ?? "*", + Vary: "Origin", "Access-Control-Allow-Headers": "authorization, content-type", }); res.end(JSON.stringify(kropp)); @@ -177,6 +245,58 @@ async function arendeIOrg(arendeId, organisationId) { return rader.rowCount > 0; } +// Adresser som aldrig får nås utifrån ett kundkonfigurerat uppslag: +// loopback, privata nät, link-local (inkl. molnens metadatatjänst), +// CGNAT och IPv6-motsvarigheterna. +export function arPrivatAdress(adress) { + const v4 = adress.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (v4) { + const [a, b] = v4.slice(1).map(Number); + return ( + a === 0 || a === 10 || a === 127 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + a >= 224 + ); + } + const v6 = adress.toLowerCase().replace(/^\[|\]$/g, ""); + if (v6 === "::1" || v6 === "::") return true; + // Unika lokala adresser (fc00::/7), link-local (fe80::/10) och + // IPv4-mappade adresser som ::ffff:127.0.0.1. + if (/^f[cd]/.test(v6) || /^fe[89ab]/.test(v6)) return true; + const mappad = v6.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); + return mappad ? arPrivatAdress(mappad[1]) : false; +} + +// Slår upp värdnamnet och avgör om något av svaren pekar inåt. Namn som +// resolvar till interna adresser fångas också — inte bara IP-literaler. +export async function pekarInat(url, slaUpp = lookup) { + let vard; + try { + vard = new URL(url).hostname; + } catch { + return "ogiltig URL"; + } + const bar = vard.replace(/^\[|\]$/g, ""); + if (/^[\d.]+$/.test(bar) || bar.includes(":")) { + return arPrivatAdress(bar) ? bar : null; + } + if (bar === "localhost" || bar.endsWith(".localhost") || bar.endsWith(".internal") || bar.endsWith(".local")) { + return bar; + } + try { + const traffar = await slaUpp(bar, { all: true }); + const intern = traffar.find((t) => arPrivatAdress(t.address)); + return intern ? intern.address : null; + } catch { + // Namnet går inte att slå upp — låt anropet självt misslyckas i + // stället för att påstå något om var det pekar. + return null; + } +} + // Generiskt uppslag mot en leverantör. All variation ligger i registret // (URL-mall, autentiseringstyp, svarsmappning) — inga leverantörs- // specifika kodgrenar. @@ -187,6 +307,16 @@ export async function gorUppslag(def, uppgifter, identifierare, hamtare = fetch) return { ok: false, fel: "Bas-URL saknas eller är ogiltig." }; } let url = mall.replace(/\{vin\}/gi, encodeURIComponent(identifierare)).replace(/\{regnr\}/gi, encodeURIComponent(identifierare)); + + // Bas-URL:en sätts av kundens administratör men anropet görs av vår + // server. Utan spärr blir det en väg in i klustrets interna nät och + // molnets metadatatjänst (169.254.169.254) — tenantens administratör + // är inte infrastrukturens ägare. Interna mål tillåts bara när driften + // uttryckligen öppnat för det (verkstäder med OEM-server på egna nätet). + if (process.env.TILLAT_INTERNA_UPPSLAG !== "true") { + const internt = await pekarInat(url); + if (internt) return { ok: false, fel: `Bas-URL:en pekar på en intern adress (${internt}) och tillåts inte.` }; + } const headers = { Accept: "application/json" }; if (u.auth === "bearer") headers.Authorization = `Bearer ${uppgifter[u.authFalt]}`; @@ -228,11 +358,13 @@ export function skapaServer() { } return createServer(async (req, res) => { + res.ursprung = ursprungFor(req); if (req.method === "OPTIONS") { res.writeHead(204, { - "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Origin": res.ursprung, + Vary: "Origin", "Access-Control-Allow-Headers": "authorization, content-type", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", }); return res.end(); } @@ -246,7 +378,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": "*", + "Access-Control-Allow-Origin": res.ursprung, }); return res.end(OPENAPI); } @@ -335,14 +467,19 @@ export function skapaServer() { `select id, nummer, skapad from felsokning_arenden where id = $1`, [arendeId], ); - const bortfiltrerat = - niva === "intern" ? [] : niva === "partner" ? ["kategori_byte", "ai_svar", "ansvarig_satt", "arbetsorder_skannad"] : ["kategori_byte", "hypotes", "ai_svar", "ansvarig_satt", "arbetsorder_skannad"]; - const handelser = await pool.query( - `select id, tidpunkt, anvandare, handelse from felsokning_handelser - where arende_id = $1 and not (handelse->>'typ' = any($2)) - order by tidpunkt, id`, - [arendeId, bortfiltrerat], - ); + const synliga = synligaTyper(niva); + const handelser = synliga + ? await pool.query( + `select id, tidpunkt, anvandare, handelse from felsokning_handelser + where arende_id = $1 and handelse->>'typ' = any($2) + order by tidpunkt, id`, + [arendeId, synliga], + ) + : await pool.query( + `select id, tidpunkt, anvandare, handelse from felsokning_handelser + where arende_id = $1 order by tidpunkt, id`, + [arendeId], + ); return svara(res, 200, { arende: arende.rows[0], handelser: handelser.rows, niva }); } @@ -447,7 +584,7 @@ export function skapaServer() { if (req.method === "GET" && vag === "/api/ecm/regler") { res.writeHead(200, { "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Origin": res.ursprung, }); return res.end(ECM_REGLER); } diff --git a/src/felsokning/__tests__/delning.test.ts b/src/felsokning/__tests__/delning.test.ts new file mode 100644 index 0000000..1c2c396 --- /dev/null +++ b/src/felsokning/__tests__/delning.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment node +// Delningsgränsen är en integritetsgräns. Testerna här låser två saker: +// att den är en tillåtelselista (nya händelsetyper är interna tills +// någon aktivt släpper fram dem) och att uppslag mot kundkonfigurerade +// URL:er inte kan användas för att nå klustrets insida. +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { + DELBART_KUND, + DELBART_PARTNER, + ENDAST_INTERNT, + arPrivatAdress, + gorUppslag, + pekarInat, + synligaTyper, +} from "../../../services/plattform/server.mjs"; + +// Alla händelsetyper i domänmodellen — sanningen om vad som kan hamna +// i loggen. +const HANDELSETYPER = [ + ...new Set( + [...readFileSync("src/felsokning/domain.ts", "utf8").matchAll(/\btyp:\s*"([a-z_]+)"/g)].map((m) => m[1]), + ), +]; + +describe("delningsfiltret är en tillåtelselista", () => { + it("varje händelsetyp i domänmodellen är klassificerad", () => { + // Fångar den verkliga risken: någon lägger till en händelsetyp och + // glömmer bestämma om kunden ska se den. Då faller testet i stället + // för att typen tyst dyker upp i kundens delningslänk. + const klassificerade = new Set([...DELBART_KUND, ...ENDAST_INTERNT]); + const oklassificerade = HANDELSETYPER.filter((t) => !klassificerade.has(t)); + expect(oklassificerade, `oklassificerade händelsetyper: ${oklassificerade.join(", ")}`).toEqual([]); + }); + + it("hittar faktiskt domänens händelsetyper", () => { + // Skyddar testet ovan mot att bli meningslöst om regexen slutar träffa. + expect(HANDELSETYPER.length).toBeGreaterThan(20); + expect(HANDELSETYPER).toContain("felorsak"); + expect(HANDELSETYPER).toContain("ai_svar"); + }); + + it("arbetsmaterial och arbetsledning lämnar aldrig organisationen", () => { + for (const intern of ENDAST_INTERNT) { + expect(DELBART_KUND, intern).not.toContain(intern); + } + // Hypoteser är det enda partnern ser utöver kundnivån. + expect(DELBART_PARTNER).toContain("hypotes"); + expect(DELBART_KUND).not.toContain("hypotes"); + for (const bara of ["kategori_byte", "ai_svar", "ansvarig_satt", "arbetsorder_skannad"]) { + expect(DELBART_PARTNER, bara).not.toContain(bara); + } + }); + + it("internnivån filtrerar inte alls", () => { + expect(synligaTyper("intern")).toBeNull(); + expect(synligaTyper("kund")).toEqual(DELBART_KUND); + expect(synligaTyper("partner")).toEqual(DELBART_PARTNER); + }); +}); + +describe("uppslag kan inte riktas mot klustrets insida", () => { + it("känner igen privata, loopback- och link-local-adresser", () => { + for (const intern of [ + "127.0.0.1", + "10.4.2.9", + "172.16.0.1", + "172.31.255.255", + "192.168.1.1", + "169.254.169.254", // molnens metadatatjänst + "100.64.0.1", // CGNAT + "0.0.0.0", + "::1", + "fd00::1", + "fe80::1", + "::ffff:127.0.0.1", + ]) { + expect(arPrivatAdress(intern), intern).toBe(true); + } + for (const extern of ["8.8.8.8", "51.12.3.4", "172.32.0.1", "192.169.0.1", "2606:4700::1111"]) { + expect(arPrivatAdress(extern), extern).toBe(false); + } + }); + + it("stoppar både IP-literaler och namn som resolvar inåt", async () => { + expect(await pekarInat("http://169.254.169.254/latest/meta-data/")).toBe("169.254.169.254"); + expect(await pekarInat("http://localhost:8080/x")).toBe("localhost"); + // Klustrets egna tjänstenamn slutar på .local och stoppas på namnet, + // utan att ens behöva slås upp. + expect(await pekarInat("http://postgres.guidad-felsokning.svc.cluster.local/")).toBe( + "postgres.guidad-felsokning.svc.cluster.local", + ); + // Namn som slår upp till en privat adress fångas via uppslaget. + const falskUppslagare = async () => [{ address: "10.0.0.5", family: 4 }]; + expect(await pekarInat("https://kund.exempel.se/", falskUppslagare)).toBe("10.0.0.5"); + const publikUppslagare = async () => [{ address: "93.184.216.34", family: 4 }]; + expect(await pekarInat("https://kund.exempel.se/", publikUppslagare)).toBeNull(); + }); + + it("uppslaget avvisar en intern bas-URL innan något anrop görs", async () => { + let anropades = false; + const def = { uppslag: { urlFalt: "bas_url", auth: "bearer", authFalt: "n", svarsfalt: { marke: "make" } } }; + const svar = await gorUppslag( + def, + { bas_url: "http://169.254.169.254/{vin}", n: "x" }, + "YV1DZ8256F2123456", + async () => { + anropades = true; + return { ok: true, json: async () => ({ make: "Volvo" }) }; + }, + ); + expect(svar.ok).toBe(false); + expect(svar.fel).toContain("intern adress"); + expect(anropades, "anropet får inte göras alls").toBe(false); + }); +}); diff --git a/supabase/migrations/20260803140000_delning_tillatelselista.sql b/supabase/migrations/20260803140000_delning_tillatelselista.sql new file mode 100644 index 0000000..1283243 --- /dev/null +++ b/supabase/migrations/20260803140000_delning_tillatelselista.sql @@ -0,0 +1,38 @@ +-- Delningsgränsen görs om från nekalista till tillåtelselista. +-- +-- Tidigare filtrerade hamta_delat_arende bort en uppräknad mängd interna +-- händelsetyper. Varje ny händelsetyp blev därmed automatiskt synlig för +-- kunden tills någon kom ihåg att neka den — fel håll att fela åt på en +-- integritetsgräns. Nu räknas i stället upp vad som FÅR delas; allt annat +-- är internt tills det aktivt släpps fram. +-- +-- Listan speglar DELBART_KUND i services/plattform/server.mjs, som låses +-- av testet src/felsokning/__tests__/delning.test.ts. + +create or replace function public.hamta_delat_arende(kod text) +returns jsonb +language sql +security definer +set search_path = public +stable +as $$ + select jsonb_build_object( + 'arende', to_jsonb(a) - 'skapad_av', + 'handelser', coalesce(( + select jsonb_agg(to_jsonb(h) order by h.tidpunkt, h.id) + from felsokning_handelser h + where h.arende_id = a.id + and h.handelse->>'typ' in ( + 'objekt_identifierat', 'arendetyp_satt', 'felbeskrivning', 'fraga_besvarad', + 'kontroll_utford', 'observation', 'matvarde', 'foto', 'video', 'kommentar', + 'inaktivitet_forklarad', 'overlamning', 'historik_kontrollerad', 'matarstallning', + 'reproducering', 'felorsak', 'atgardsforslag', 'kundbeslut', 'atgard_utford', + 'kvalitetskontroll', 'export_skapad', 'arende_avslutat' + ) + ), '[]'::jsonb) + ) + from felsokning_arenden a + where a.delningskod = kod and a.delningskod is not null; +$$; + +grant execute on function public.hamta_delat_arende(text) to anon, authenticated;