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:
Claude
2026-08-03 14:10:59 +00:00
parent 8f1aba1571
commit cbc7bf2751
21 changed files with 1982 additions and 18 deletions
+6 -2
View File
@@ -318,10 +318,14 @@ KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer/vol
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"YV1DZ8256F2123456"}')
kontroll "okonfigurerad koppling ger 404" "$KOD" "404"
# Leverantören är onåbar i testmiljön — felet rapporteras ärligt, inte tyst
# Bas-URL:en pekar inåt (127.0.0.1) — uppslaget får inte bli en väg in i
# klustret. Anropet ska stoppas innan det görs och rapporteras ärligt.
SVAR=$(curl -s -X POST "$BAS/api/integrationer/generisk_vin/uppslag" \
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"YV1DZ8256F2123456"}')
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer/generisk_vin/uppslag" \
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"YV1DZ8256F2123456"}')
kontroll "onåbar leverantör rapporteras som 502" "$KOD" "502"
kontroll "uppslag mot intern adress avvisas (502)" "$KOD" "502"
kontroll "felet säger varför" "$(echo "$SVAR" | falt '.error.includes("intern adress")')" "true"
STATUS=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \
| falt '.integrationer[0].senaste_status.slice(0,3)')
kontroll "senaste testresultat sparas på kopplingen" "$STATUS" "fel"
+150 -13
View File
@@ -12,11 +12,16 @@
// DATABASE_URL Postgres-anslutning (krävs)
// JWT_SECRET HS256-hemlighet, delas med ai-orkestern (krävs)
// REGISTRERING_OPPEN "false" stänger nya organisationer (default öppen, beta)
// INTEGRATION_NYCKEL 32 byte (hex/base64) — krypterar kundernas leverantörsuppgifter
// TILLATNA_URSPRUNG kommaseparerade ursprung för CORS (utelämnad = "*")
// TILLAT_INTERNA_UPPSLAG "true" tillåter leverantörsuppslag mot privata nät
// ECM_REGLER_FIL / INTEGRATIONER_FIL sökvägar till utbytbar konfiguration
// PORT default 8080
import { createServer } from "node:http";
import crypto, { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { readFileSync } from "node:fs";
import { lookup } from "node:dns/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import pg from "pg";
@@ -50,6 +55,21 @@ const ROLLER = ["tekniker", "arbetsledare", "admin"];
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
// Vilka ursprung som får anropa API:t från en webbläsare. I klusterdriften
// serveras klienten från samma domän som API:t, så listan kan hållas kort.
// TILLATNA_URSPRUNG="https://app.exempel.se,https://demo.exempel.se" —
// utelämnad betyder "*" (öppet), vilket bara hör hemma i utveckling.
const TILLATNA_URSPRUNG = (process.env.TILLATNA_URSPRUNG ?? "")
.split(",")
.map((u) => u.trim())
.filter(Boolean);
function ursprungFor(req) {
const ursprung = req.headers.origin;
if (TILLATNA_URSPRUNG.length === 0) return "*";
return ursprung && TILLATNA_URSPRUNG.includes(ursprung) ? ursprung : TILLATNA_URSPRUNG[0];
}
// Enkel takt-begränsning för den publika beslutsendpointen (per
// delningskod, i minnet). Räcker för en enda pod; bakom flera repliker
// kompletteras den av databasspärren "ett beslut per förslag".
@@ -134,12 +154,60 @@ function leverantorsDef(id) {
return INTEGRATIONER.leverantorer.find((l) => l.id === id);
}
// ---- Delningsfilter: tillåtelselista, inte nekalista ------------------
//
// Vilka händelsetyper som får lämna verkstaden är en integritetsgräns.
// Med en nekalista blir varje NY händelsetyp automatiskt synlig för
// kunden tills någon kommer ihåg att neka den — fel håll att fela åt.
// Här listas i stället uttryckligen vad som får delas; allt annat är
// internt tills det aktivt släpps fram. Testet i delning.test.ts kräver
// att varje händelsetyp i domänmodellen är klassificerad.
export const DELBART_KUND = [
"objekt_identifierat",
"arendetyp_satt",
"felbeskrivning",
"fraga_besvarad",
"kontroll_utford",
"observation",
"matvarde",
"foto",
"video",
"kommentar",
"inaktivitet_forklarad",
"overlamning",
"historik_kontrollerad",
"matarstallning",
"reproducering",
"felorsak",
"atgardsforslag",
"kundbeslut",
"atgard_utford",
"kvalitetskontroll",
"export_skapad",
"arende_avslutat",
];
// Extern partner (försäkringsbolag, tillverkare) ser dessutom hypoteser
// — alltid märkta som ej verifierade.
export const DELBART_PARTNER = [...DELBART_KUND, "hypotes"];
// Aldrig utanför organisationen: arbetsledning, arbetsmaterial och
// underlag som kan läsas som konstateranden.
export const ENDAST_INTERNT = ["kategori_byte", "hypotes", "ai_svar", "ansvarig_satt", "arbetsorder_skannad"];
export function synligaTyper(niva) {
if (niva === "intern") return null; // full insyn — ingen filtrering
return niva === "partner" ? DELBART_PARTNER : DELBART_KUND;
}
// ---- Hjälpare ---------------------------------------------------------
function svara(res, status, kropp) {
res.writeHead(status, {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
// Ursprunget sätts en gång per anrop i hanteraren nedan.
"Access-Control-Allow-Origin": res.ursprung ?? "*",
Vary: "Origin",
"Access-Control-Allow-Headers": "authorization, content-type",
});
res.end(JSON.stringify(kropp));
@@ -177,6 +245,58 @@ async function arendeIOrg(arendeId, organisationId) {
return rader.rowCount > 0;
}
// Adresser som aldrig får nås utifrån ett kundkonfigurerat uppslag:
// loopback, privata nät, link-local (inkl. molnens metadatatjänst),
// CGNAT och IPv6-motsvarigheterna.
export function arPrivatAdress(adress) {
const v4 = adress.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (v4) {
const [a, b] = v4.slice(1).map(Number);
return (
a === 0 || a === 10 || a === 127 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
a >= 224
);
}
const v6 = adress.toLowerCase().replace(/^\[|\]$/g, "");
if (v6 === "::1" || v6 === "::") return true;
// Unika lokala adresser (fc00::/7), link-local (fe80::/10) och
// IPv4-mappade adresser som ::ffff:127.0.0.1.
if (/^f[cd]/.test(v6) || /^fe[89ab]/.test(v6)) return true;
const mappad = v6.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
return mappad ? arPrivatAdress(mappad[1]) : false;
}
// Slår upp värdnamnet och avgör om något av svaren pekar inåt. Namn som
// resolvar till interna adresser fångas också — inte bara IP-literaler.
export async function pekarInat(url, slaUpp = lookup) {
let vard;
try {
vard = new URL(url).hostname;
} catch {
return "ogiltig URL";
}
const bar = vard.replace(/^\[|\]$/g, "");
if (/^[\d.]+$/.test(bar) || bar.includes(":")) {
return arPrivatAdress(bar) ? bar : null;
}
if (bar === "localhost" || bar.endsWith(".localhost") || bar.endsWith(".internal") || bar.endsWith(".local")) {
return bar;
}
try {
const traffar = await slaUpp(bar, { all: true });
const intern = traffar.find((t) => arPrivatAdress(t.address));
return intern ? intern.address : null;
} catch {
// Namnet går inte att slå upp — låt anropet självt misslyckas i
// stället för att påstå något om var det pekar.
return null;
}
}
// Generiskt uppslag mot en leverantör. All variation ligger i registret
// (URL-mall, autentiseringstyp, svarsmappning) — inga leverantörs-
// specifika kodgrenar.
@@ -187,6 +307,16 @@ export async function gorUppslag(def, uppgifter, identifierare, hamtare = fetch)
return { ok: false, fel: "Bas-URL saknas eller är ogiltig." };
}
let url = mall.replace(/\{vin\}/gi, encodeURIComponent(identifierare)).replace(/\{regnr\}/gi, encodeURIComponent(identifierare));
// Bas-URL:en sätts av kundens administratör men anropet görs av vår
// server. Utan spärr blir det en väg in i klustrets interna nät och
// molnets metadatatjänst (169.254.169.254) — tenantens administratör
// är inte infrastrukturens ägare. Interna mål tillåts bara när driften
// uttryckligen öppnat för det (verkstäder med OEM-server på egna nätet).
if (process.env.TILLAT_INTERNA_UPPSLAG !== "true") {
const internt = await pekarInat(url);
if (internt) return { ok: false, fel: `Bas-URL:en pekar på en intern adress (${internt}) och tillåts inte.` };
}
const headers = { Accept: "application/json" };
if (u.auth === "bearer") headers.Authorization = `Bearer ${uppgifter[u.authFalt]}`;
@@ -228,11 +358,13 @@ export function skapaServer() {
}
return createServer(async (req, res) => {
res.ursprung = ursprungFor(req);
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": res.ursprung,
Vary: "Origin",
"Access-Control-Allow-Headers": "authorization, content-type",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
});
return res.end();
}
@@ -246,7 +378,7 @@ export function skapaServer() {
if (req.method === "GET" && vag === "/api/openapi.yaml") {
res.writeHead(200, {
"Content-Type": "application/yaml; charset=utf-8",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": res.ursprung,
});
return res.end(OPENAPI);
}
@@ -335,14 +467,19 @@ export function skapaServer() {
`select id, nummer, skapad from felsokning_arenden where id = $1`,
[arendeId],
);
const bortfiltrerat =
niva === "intern" ? [] : niva === "partner" ? ["kategori_byte", "ai_svar", "ansvarig_satt", "arbetsorder_skannad"] : ["kategori_byte", "hypotes", "ai_svar", "ansvarig_satt", "arbetsorder_skannad"];
const handelser = await pool.query(
`select id, tidpunkt, anvandare, handelse from felsokning_handelser
where arende_id = $1 and not (handelse->>'typ' = any($2))
order by tidpunkt, id`,
[arendeId, bortfiltrerat],
);
const synliga = synligaTyper(niva);
const handelser = synliga
? await pool.query(
`select id, tidpunkt, anvandare, handelse from felsokning_handelser
where arende_id = $1 and handelse->>'typ' = any($2)
order by tidpunkt, id`,
[arendeId, synliga],
)
: await pool.query(
`select id, tidpunkt, anvandare, handelse from felsokning_handelser
where arende_id = $1 order by tidpunkt, id`,
[arendeId],
);
return svara(res, 200, { arende: arende.rows[0], handelser: handelser.rows, niva });
}
@@ -447,7 +584,7 @@ export function skapaServer() {
if (req.method === "GET" && vag === "/api/ecm/regler") {
res.writeHead(200, {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": res.ursprung,
});
return res.end(ECM_REGLER);
}