Genomgång av flöden och infrastruktur — infrakarta i Terraform
Infrastrukturen får en definition som går att läsa: infra/terraform, där karta.tf beskriver hela systemet en gång som data — tjänster, portar, routing, vilken tjänst som ser vilken hemlighet, dataflöden och gränser. Resten av filerna läser därifrån i stället för att upprepa namn och portar, och `terraform output karta` skriver ut samma innehåll i klartext direkt ur definitionen. Filerna är numrerade i läsordning. Genomgången hittade sex saker som är åtgärdade här: Uppslaget mot märkesspecifika kopplingar kunde riktas inåt. Bas-URL:en sätts av kundens administratör men anropet görs av vår server — 169.254.169.254 eller ett internt tjänstenamn hade nått molnets metadatatjänst respektive klustrets insida, och svaret kommit tillbaka mappat genom svarsfälten. En tenant-administratör är inte infrastrukturens ägare. Nu stoppas IP-literaler, namn som resolvar till privata adresser och .local/.internal innan något anrop görs, och nätverkspolicyn undantar samma nät. TILLAT_INTERNA_UPPSLAG öppnar för verkstäder som har OEM-servern på eget nät. Delningsfiltret var en nekalista, alltså blev varje ny händelsetyp automatiskt synlig i kundens delningslänk 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 per nivå, och ett test kräver att varje händelsetyp i domänmodellen är klassificerad. Samma ändring i Supabase-funktionen via ny migration. Beteendet i dag är oförändrat; det är riktningen som vänts. Ingressen saknade kroppsgräns och hade därmed nginx standard på 1 MB medan tjänsten tar 4 MB — foto- och videodokumentation hade avvisats i produktion men aldrig i testerna. Satt till 8 MB i båda vägarna. Vidare: nätverkspolicyer som stänger namnrymden och bara öppnar de faktiska flödena, CORS-lista via TILLATNA_URSPRUNG i stället för "*", och säkerhetskontext + startprob på databaspodden. Kustomize-/Argo CD-vägen finns kvar men ska inte köras mot samma kluster som Terraform — selfHeal och prune motarbetar terraform apply. Val och bytesväg dokumenterade. Verifierat: 87 vitest-tester, typkontroll, eslint, OpenAPI-validering, terraform fmt och integrationstest mot riktig Postgres inklusive den nya spärren. terraform validate kunde inte köras här — registry.terraform.io är blockerad av sessionens egress-policy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
@@ -22,3 +22,12 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# Terraform: tillståndet innehåller hemligheter, tfvars innehåller nycklar.
|
||||||
|
.terraform/
|
||||||
|
.terraform.lock.hcl
|
||||||
|
*.tfstate
|
||||||
|
*.tfstate.*
|
||||||
|
*.tfvars
|
||||||
|
!*.tfvars.exempel
|
||||||
|
crash.log
|
||||||
|
|||||||
+37
-2
@@ -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 |
|
| `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 |
|
| `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 |
|
| `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.
|
**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
|
## Driftsätta
|
||||||
|
|
||||||
```sh
|
```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
|
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=<git-sha>`.
|
||||||
|
|
||||||
|
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
|
## Märkesspecifika kopplingar
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -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). |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| Ä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. |
|
| 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. |
|
| 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. |
|
| 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). |
|
| 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`. |
|
| Ö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
|
## Arkitekturprinciper i koden
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ metadata:
|
|||||||
namespace: guidad-felsokning
|
namespace: guidad-felsokning
|
||||||
annotations:
|
annotations:
|
||||||
cert-manager.io/cluster-issuer: letsencrypt # ersätt med er issuer
|
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:
|
spec:
|
||||||
ingressClassName: nginx
|
ingressClassName: nginx
|
||||||
tls:
|
tls:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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=<git-sha>` 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`.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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/<kod>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 = ""
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"}')
|
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"YV1DZ8256F2123456"}')
|
||||||
kontroll "okonfigurerad koppling ger 404" "$KOD" "404"
|
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" \
|
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"}')
|
-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" \
|
STATUS=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \
|
||||||
| falt '.integrationer[0].senaste_status.slice(0,3)')
|
| falt '.integrationer[0].senaste_status.slice(0,3)')
|
||||||
kontroll "senaste testresultat sparas på kopplingen" "$STATUS" "fel"
|
kontroll "senaste testresultat sparas på kopplingen" "$STATUS" "fel"
|
||||||
|
|||||||
+150
-13
@@ -12,11 +12,16 @@
|
|||||||
// DATABASE_URL Postgres-anslutning (krävs)
|
// DATABASE_URL Postgres-anslutning (krävs)
|
||||||
// JWT_SECRET HS256-hemlighet, delas med ai-orkestern (krävs)
|
// JWT_SECRET HS256-hemlighet, delas med ai-orkestern (krävs)
|
||||||
// REGISTRERING_OPPEN "false" stänger nya organisationer (default öppen, beta)
|
// 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
|
// PORT default 8080
|
||||||
|
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import crypto, { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
import crypto, { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
|
import { lookup } from "node:dns/promises";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import pg from "pg";
|
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 });
|
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
|
// Enkel takt-begränsning för den publika beslutsendpointen (per
|
||||||
// delningskod, i minnet). Räcker för en enda pod; bakom flera repliker
|
// delningskod, i minnet). Räcker för en enda pod; bakom flera repliker
|
||||||
// kompletteras den av databasspärren "ett beslut per förslag".
|
// 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);
|
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 ---------------------------------------------------------
|
// ---- Hjälpare ---------------------------------------------------------
|
||||||
|
|
||||||
function svara(res, status, kropp) {
|
function svara(res, status, kropp) {
|
||||||
res.writeHead(status, {
|
res.writeHead(status, {
|
||||||
"Content-Type": "application/json",
|
"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",
|
"Access-Control-Allow-Headers": "authorization, content-type",
|
||||||
});
|
});
|
||||||
res.end(JSON.stringify(kropp));
|
res.end(JSON.stringify(kropp));
|
||||||
@@ -177,6 +245,58 @@ async function arendeIOrg(arendeId, organisationId) {
|
|||||||
return rader.rowCount > 0;
|
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
|
// Generiskt uppslag mot en leverantör. All variation ligger i registret
|
||||||
// (URL-mall, autentiseringstyp, svarsmappning) — inga leverantörs-
|
// (URL-mall, autentiseringstyp, svarsmappning) — inga leverantörs-
|
||||||
// specifika kodgrenar.
|
// 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." };
|
return { ok: false, fel: "Bas-URL saknas eller är ogiltig." };
|
||||||
}
|
}
|
||||||
let url = mall.replace(/\{vin\}/gi, encodeURIComponent(identifierare)).replace(/\{regnr\}/gi, encodeURIComponent(identifierare));
|
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" };
|
const headers = { Accept: "application/json" };
|
||||||
|
|
||||||
if (u.auth === "bearer") headers.Authorization = `Bearer ${uppgifter[u.authFalt]}`;
|
if (u.auth === "bearer") headers.Authorization = `Bearer ${uppgifter[u.authFalt]}`;
|
||||||
@@ -228,11 +358,13 @@ export function skapaServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return createServer(async (req, res) => {
|
return createServer(async (req, res) => {
|
||||||
|
res.ursprung = ursprungFor(req);
|
||||||
if (req.method === "OPTIONS") {
|
if (req.method === "OPTIONS") {
|
||||||
res.writeHead(204, {
|
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-Headers": "authorization, content-type",
|
||||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
||||||
});
|
});
|
||||||
return res.end();
|
return res.end();
|
||||||
}
|
}
|
||||||
@@ -246,7 +378,7 @@ export function skapaServer() {
|
|||||||
if (req.method === "GET" && vag === "/api/openapi.yaml") {
|
if (req.method === "GET" && vag === "/api/openapi.yaml") {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
"Content-Type": "application/yaml; charset=utf-8",
|
"Content-Type": "application/yaml; charset=utf-8",
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": res.ursprung,
|
||||||
});
|
});
|
||||||
return res.end(OPENAPI);
|
return res.end(OPENAPI);
|
||||||
}
|
}
|
||||||
@@ -335,14 +467,19 @@ export function skapaServer() {
|
|||||||
`select id, nummer, skapad from felsokning_arenden where id = $1`,
|
`select id, nummer, skapad from felsokning_arenden where id = $1`,
|
||||||
[arendeId],
|
[arendeId],
|
||||||
);
|
);
|
||||||
const bortfiltrerat =
|
const synliga = synligaTyper(niva);
|
||||||
niva === "intern" ? [] : niva === "partner" ? ["kategori_byte", "ai_svar", "ansvarig_satt", "arbetsorder_skannad"] : ["kategori_byte", "hypotes", "ai_svar", "ansvarig_satt", "arbetsorder_skannad"];
|
const handelser = synliga
|
||||||
const handelser = await pool.query(
|
? await pool.query(
|
||||||
`select id, tidpunkt, anvandare, handelse from felsokning_handelser
|
`select id, tidpunkt, anvandare, handelse from felsokning_handelser
|
||||||
where arende_id = $1 and not (handelse->>'typ' = any($2))
|
where arende_id = $1 and handelse->>'typ' = any($2)
|
||||||
order by tidpunkt, id`,
|
order by tidpunkt, id`,
|
||||||
[arendeId, bortfiltrerat],
|
[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 });
|
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") {
|
if (req.method === "GET" && vag === "/api/ecm/regler") {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": res.ursprung,
|
||||||
});
|
});
|
||||||
return res.end(ECM_REGLER);
|
return res.end(ECM_REGLER);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user