Organisationslagret: multi-tenant med roller, integrationstestat

Master Prompts tenant- och rollmodell implementerad i den självhostade
plattformen:

- Registrering skapar organisation + systemadministratör (transaktion);
  admin skapar övriga användare (tekniker/arbetsledare/admin) i sin
  organisation via nytt UI och POST /api/anvandare. Roll + organisation
  ligger i JWT-anspråken och verifieras alltid på servern.
- All ärendedata organisationsknuten: ärenden skapas i användarens
  organisation, händelse-API:t verifierar tillhörighet per anrop och
  GET /api/arenden listar bara egna organisationens ärenden.
- Schemat flyttat till infra/k8s/postgres-init.sql och genereras in som
  ConfigMap via kustomize — samma fil används av integrationstestet.
  Nya tabeller: organisationer; anvandare med organisation_id + roll
  (check-constraint); arenden med organisation_id + index.
- Integrationstest (services/plattform/integrationstest.sh) mot riktig
  Postgres, kört grönt lokalt och tillagt i CI: registrering, synk,
  idempotens (händelser skrivs aldrig över), append-only-triggern,
  organisationsisolering (404 över gränsen), publik delning med
  filtrering, rollstyrning (tekniker nekas användarhantering) och
  delad arbetsyta inom organisationen.
- Klienten: registreringsformulär med organisationsfält, kontopanel
  med roll och organisation, admin-panel för användarhantering.

Verifierat: integrationstestets 12 kontroller gröna mot Postgres 16,
28 vitest-tester gröna, produktionsbygge ok, YAML validerad,
Playwright-röktest grönt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-03 07:04:32 +00:00
parent 7c24186cc5
commit b069c9a12f
11 changed files with 553 additions and 164 deletions
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# Integrationstest för plattformstjänsten mot en riktig Postgres.
# Kör hela flödet: registrering (org + admin), synk av händelser,
# idempotens, append-only-triggern, organisationsisolering, publik
# delning med filtrering samt rollstyrd användarhantering.
#
# Krav: postgres (initdb/pg_ctl), node, curl. Kör: bash integrationstest.sh
set -euo pipefail
cd "$(dirname "$0")"
PGPORT=5499
APPPORT=8399
BAS="http://127.0.0.1:$APPPORT"
DATADIR=$(mktemp -d)
PGBIN=$(ls -d /usr/lib/postgresql/*/bin | head -1)
stada() {
kill "$SERVER_PID" 2>/dev/null || true
su postgres -c "$PGBIN/pg_ctl -D '$DATADIR' stop -m immediate" >/dev/null 2>&1 || true
rm -rf "$DATADIR"
}
trap stada EXIT
# ---- Postgres upp ----
chown postgres "$DATADIR"
su postgres -c "$PGBIN/initdb -D '$DATADIR' -A trust" >/dev/null
su postgres -c "$PGBIN/pg_ctl -D '$DATADIR' -o '-p $PGPORT -k /tmp -c listen_addresses=127.0.0.1' -l '$DATADIR/logg' start" >/dev/null
su postgres -c "$PGBIN/psql -h /tmp -p $PGPORT -d postgres -qc \"create role plattform login password 'test'\""
su postgres -c "$PGBIN/psql -h /tmp -p $PGPORT -d postgres -qc 'create database felsokning owner plattform'"
PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning -q -f ../../infra/k8s/postgres-init.sql
# ---- Tjänsten upp ----
DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \
JWT_SECRET=integrationshemlighet PORT=$APPPORT node server.mjs &
SERVER_PID=$!
sleep 1
falt() { node -pe "JSON.parse(require('fs').readFileSync(0,'utf8'))$1"; }
kontroll() { # kontroll <namn> <faktiskt> <forvantat>
if [ "$2" = "$3" ]; then echo "$1"; else echo "$1: fick '$2', väntade '$3'"; exit 1; fi
}
# 1. Registrera organisation A (admin)
SVAR=$(curl -s -X POST "$BAS/api/auth/registrera" -H 'Content-Type: application/json' \
-d '{"epost":"anna@a.se","losenord":"hemligt123","namn":"Anna","organisation":"Verkstad A"}')
TOKEN_A=$(echo "$SVAR" | falt .token); kontroll "registrering ger admin" "$(echo "$SVAR" | falt .roll)" "admin"
kontroll "organisationsnamn" "$(echo "$SVAR" | falt .organisation)" "Verkstad A"
# 2. Skapa ärende + händelser (inkl. internt kategoribyte)
curl -s -X POST "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"id":"arende-test1","nummer":1,"skapad":"2026-08-03T08:00:00Z","delningskod":"delkod123"}' >/dev/null
curl -s -X POST "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"handelser":[
{"id":"h1","tidpunkt":"2026-08-03T08:01:00Z","anvandare":"Anna","handelse":{"typ":"felbeskrivning","text":"Startar inte"}},
{"id":"h2","tidpunkt":"2026-08-03T08:02:00Z","anvandare":"Anna","handelse":{"typ":"kategori_byte","kategori":"provkorning"}}
]}' >/dev/null
ANTAL=$(curl -s "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" | falt .handelser.length)
kontroll "händelser sparade" "$ANTAL" "2"
# 3. Idempotens: samma push igen ändrar ingenting
curl -s -X POST "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"handelser":[{"id":"h1","tidpunkt":"2026-08-03T08:01:00Z","anvandare":"Anna","handelse":{"typ":"felbeskrivning","text":"ÄNDRAD"}}]}' >/dev/null
TEXT=$(curl -s "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" | falt '.handelser[0].handelse.text')
kontroll "händelser skrivs aldrig över" "$TEXT" "Startar inte"
# 4. Append-only-triggern stoppar direkta ändringar i databasen
if PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-qc "update felsokning_handelser set anvandare='hackad' where id='h1'" 2>/dev/null; then
echo "✗ append-only-trigger saknas"; exit 1
else
echo "✓ databastriggern avvisar update"
fi
# 5. Organisationsisolering: org B ser inte org A:s ärenden
TOKEN_B=$(curl -s -X POST "$BAS/api/auth/registrera" -H 'Content-Type: application/json' \
-d '{"epost":"bo@b.se","losenord":"hemligt123","namn":"Bo","organisation":"Verkstad B"}' | falt .token)
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_B")
kontroll "org B nekas org A:s händelser" "$KOD" "404"
ANTAL_B=$(curl -s "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_B" | falt .arenden.length)
kontroll "org B:s ärendelista är tom" "$ANTAL_B" "0"
# 6. Publik delning: utan inloggning, interna poster filtrerade
DELAT=$(curl -s "$BAS/api/delad/delkod123")
kontroll "delning nås utan inloggning" "$(echo "$DELAT" | falt .handelser.length)" "1"
kontroll "kategoribyte filtreras ur delning" "$(echo "$DELAT" | falt '.handelser[0].handelse.typ')" "felbeskrivning"
# 7. Rollstyrning: admin skapar tekniker; tekniker får inte hantera användare
curl -s -X POST "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"epost":"johan@a.se","losenord":"hemligt123","namn":"Johan","roll":"tekniker"}' >/dev/null
TOKEN_J=$(curl -s -X POST "$BAS/api/auth/logga-in" -H 'Content-Type: application/json' \
-d '{"epost":"johan@a.se","losenord":"hemligt123"}' | falt .token)
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_J" \
-H 'Content-Type: application/json' -d '{"epost":"x@a.se","losenord":"hemligt123","namn":"X","roll":"admin"}')
kontroll "tekniker nekas användarhantering" "$KOD" "403"
ANTAL_ANV=$(curl -s "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_A" | falt .anvandare.length)
kontroll "org A har två användare" "$ANTAL_ANV" "2"
# 8. Tekniker i org A når ärendet (delad arbetsyta inom organisationen)
ANTAL=$(curl -s "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_J" | falt .handelser.length)
kontroll "tekniker i samma org når ärendet" "$ANTAL" "2"
echo "Integrationstest: allt grönt"
+133 -59
View File
@@ -1,15 +1,17 @@
// Plattformstjänsten — självhostad backend för Guidad Felsökning.
//
// Ersätter Supabase i klustret: egen inloggning (HS256-JWT, lösenord
// hashade med pgcrypto/bcrypt i Postgres), händelse-API för synken och
// publik delningsendpoint för Live Share. Händelseloggen är append-only
// även i databasen (triggers) — den här tjänsten exponerar medvetet inga
// update/delete-operationer.
// Multi-tenant: varje organisation är en egen tenant. Registrering skapar
// en organisation med en systemadministratör; admin skapar övriga
// användare (tekniker/arbetsledare) i sin organisation. All ärendedata
// är organisationsknuten — API:t släpper aldrig data över gränsen.
//
// Händelseloggen är append-only även i databasen (triggers) — den här
// tjänsten exponerar medvetet inga update/delete-operationer.
//
// Miljövariabler:
// DATABASE_URL Postgres-anslutning (krävs)
// JWT_SECRET HS256-hemlighet, delas med ai-orkestern (krävs)
// REGISTRERING_OPPEN "false" stänger självregistrering (default öppen, beta)
// REGISTRERING_OPPEN "false" stänger nya organisationer (default öppen, beta)
// PORT default 8080
import { createServer } from "node:http";
@@ -19,6 +21,7 @@ import pg from "pg";
const PORT = Number(process.env.PORT ?? 8080);
const MAX_KROPP = 4 * 1024 * 1024;
const TOKEN_LIVSTID_S = 12 * 60 * 60;
const ROLLER = ["tekniker", "arbetsledare", "admin"];
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
@@ -77,9 +80,27 @@ function kravAuth(req, hemlighet) {
return token ? verifieraJwt(token, hemlighet) : null;
}
// Verifierar att ärendet tillhör användarens organisation.
async function arendeIOrg(arendeId, organisationId) {
const rader = await pool.query(
`select 1 from felsokning_arenden where id = $1 and organisation_id = $2`,
[arendeId, organisationId],
);
return rader.rowCount > 0;
}
// ---- Server -----------------------------------------------------------
export function skapaServer() {
function loggaIn(res, rad, hemlighet) {
const nu = Math.floor(Date.now() / 1000);
const token = skapaJwt(
{ sub: rad.id, namn: rad.namn, org: rad.organisation_id, roll: rad.roll, iat: nu, exp: nu + TOKEN_LIVSTID_S },
hemlighet,
);
return svara(res, 200, { token, namn: rad.namn, roll: rad.roll, organisation: rad.org_namn });
}
return createServer(async (req, res) => {
if (req.method === "OPTIONS") {
res.writeHead(204, {
@@ -100,31 +121,51 @@ export function skapaServer() {
const hemlighet = process.env.JWT_SECRET;
if (!hemlighet) return svara(res, 503, { error: "Tjänsten är inte konfigurerad." });
// -- Auth (öppna endpoints) --
// -- Registrering: skapar organisation + systemadministratör --
if (req.method === "POST" && vag === "/api/auth/registrera") {
if (process.env.REGISTRERING_OPPEN === "false") {
return svara(res, 403, { error: "Registrering är stängd." });
return svara(res, 403, { error: "Registrering är stängd — kontakta er administratör." });
}
const { epost, losenord, namn } = await lasKropp(req);
if (!epost?.includes("@") || !losenord || losenord.length < 8 || !namn?.trim()) {
return svara(res, 400, { error: "Ogiltig e-post, namn eller för kort lösenord (minst 8 tecken)." });
const { epost, losenord, namn, organisation } = await lasKropp(req);
if (!epost?.includes("@") || !losenord || losenord.length < 8 || !namn?.trim() || !organisation?.trim()) {
return svara(res, 400, {
error: "Ange organisation, namn, e-post och lösenord (minst 8 tecken).",
});
}
const klientDb = await pool.connect();
try {
await klientDb.query("begin");
const org = await klientDb.query(
`insert into organisationer (namn) values ($1) returning id, namn`,
[organisation.trim()],
);
const rad = await klientDb.query(
`insert into anvandare (organisation_id, epost, losen_hash, namn, roll)
values ($1, lower($2), crypt($3, gen_salt('bf')), $4, 'admin')
on conflict (epost) do nothing
returning id, namn, organisation_id, roll`,
[org.rows[0].id, epost.trim(), losenord, namn.trim()],
);
if (rad.rowCount === 0) {
await klientDb.query("rollback");
return svara(res, 409, { error: "E-postadressen är redan registrerad." });
}
await klientDb.query("commit");
return loggaIn(res, { ...rad.rows[0], org_namn: org.rows[0].namn }, hemlighet);
} catch (fel) {
await klientDb.query("rollback");
throw fel;
} finally {
klientDb.release();
}
const rader = await pool.query(
`insert into anvandare (epost, losen_hash, namn)
values (lower($1), crypt($2, gen_salt('bf')), $3)
on conflict (epost) do nothing
returning id, namn`,
[epost.trim(), losenord, namn.trim()],
);
if (rader.rowCount === 0) return svara(res, 409, { error: "E-postadressen är redan registrerad." });
return loggaIn(res, rader.rows[0], hemlighet);
}
if (req.method === "POST" && vag === "/api/auth/logga-in") {
const { epost, losenord } = await lasKropp(req);
const rader = await pool.query(
`select id, namn from anvandare
where epost = lower($1) and losen_hash = crypt($2, losen_hash)`,
`select a.id, a.namn, a.organisation_id, a.roll, o.namn as org_namn
from anvandare a join organisationer o on o.id = a.organisation_id
where a.epost = lower($1) and a.losen_hash = crypt($2, a.losen_hash)`,
[epost ?? "", losenord ?? ""],
);
if (rader.rowCount === 0) return svara(res, 401, { error: "Fel e-post eller lösenord." });
@@ -148,9 +189,45 @@ export function skapaServer() {
return svara(res, 200, { arende: arende.rows[0], handelser: handelser.rows });
}
// -- Skyddade endpoints --
// -- Skyddade endpoints (organisationsknutna) --
const anspr = kravAuth(req, hemlighet);
if (!anspr) return svara(res, 401, { error: "Inloggning krävs." });
if (!anspr?.org) return svara(res, 401, { error: "Inloggning krävs." });
// Användarhantering: endast systemadministratör, endast egen org.
if (vag === "/api/anvandare") {
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
if (req.method === "GET") {
const rader = await pool.query(
`select id, epost, namn, roll from anvandare where organisation_id = $1 order by namn`,
[anspr.org],
);
return svara(res, 200, { anvandare: rader.rows });
}
if (req.method === "POST") {
const { epost, losenord, namn, roll } = await lasKropp(req);
if (!epost?.includes("@") || !losenord || losenord.length < 8 || !namn?.trim() || !ROLLER.includes(roll)) {
return svara(res, 400, { error: "Ange namn, e-post, roll och lösenord (minst 8 tecken)." });
}
const rad = await pool.query(
`insert into anvandare (organisation_id, epost, losen_hash, namn, roll)
values ($1, lower($2), crypt($3, gen_salt('bf')), $4, $5)
on conflict (epost) do nothing
returning id, epost, namn, roll`,
[anspr.org, epost.trim(), losenord, namn.trim(), roll],
);
if (rad.rowCount === 0) return svara(res, 409, { error: "E-postadressen är redan registrerad." });
return svara(res, 200, rad.rows[0]);
}
}
if (req.method === "GET" && vag === "/api/arenden") {
const rader = await pool.query(
`select id, nummer, skapad, delningskod, metodik_id from felsokning_arenden
where organisation_id = $1 order by skapad desc limit 200`,
[anspr.org],
);
return svara(res, 200, { arenden: rader.rows });
}
if (req.method === "POST" && vag === "/api/arenden") {
const { id, nummer, skapad, delningskod, metodikId } = await lasKropp(req);
@@ -158,40 +235,46 @@ export function skapaServer() {
return svara(res, 400, { error: "Ogiltigt ärende." });
}
await pool.query(
`insert into felsokning_arenden (id, nummer, skapad, delningskod, metodik_id, skapad_av)
values ($1, $2, $3, $4, $5, $6) on conflict (id) do nothing`,
[id, nummer, skapad, delningskod ?? null, metodikId ?? null, anspr.sub],
`insert into felsokning_arenden (id, organisation_id, nummer, skapad, delningskod, metodik_id, skapad_av)
values ($1, $2, $3, $4, $5, $6, $7) on conflict (id) do nothing`,
[id, anspr.org, nummer, skapad, delningskod ?? null, metodikId ?? null, anspr.sub],
);
return svara(res, 200, { ok: true });
}
const handelserVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/handelser$/);
if (handelserVag && req.method === "GET") {
const rader = await pool.query(
`select id, tidpunkt, anvandare, handelse from felsokning_handelser
where arende_id = $1 order by tidpunkt, id`,
[handelserVag[1]],
);
return svara(res, 200, { handelser: rader.rows });
}
if (handelserVag && req.method === "POST") {
const { handelser } = await lasKropp(req);
if (!Array.isArray(handelser) || handelser.length > 500) {
return svara(res, 400, { error: "Ogiltig händelselista." });
if (handelserVag) {
// Organisationsgränsen: ärendet måste tillhöra användarens org.
if (!(await arendeIOrg(handelserVag[1], anspr.org))) {
return svara(res, 404, { error: "Ärendet är inte tillgängligt." });
}
for (const post of handelser) {
if (typeof post?.id !== "string" || !post.tidpunkt || typeof post.anvandare !== "string" || !post.handelse) {
return svara(res, 400, { error: "Ogiltig händelse." });
}
// Append-only: on conflict do nothing — en befintlig händelse
// skrivs aldrig över, och databastriggern stoppar allt annat.
await pool.query(
`insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse)
values ($1, $2, $3, $4, $5) on conflict (id) do nothing`,
[post.id, handelserVag[1], post.tidpunkt, post.anvandare, post.handelse],
if (req.method === "GET") {
const rader = await pool.query(
`select id, tidpunkt, anvandare, handelse from felsokning_handelser
where arende_id = $1 order by tidpunkt, id`,
[handelserVag[1]],
);
return svara(res, 200, { handelser: rader.rows });
}
if (req.method === "POST") {
const { handelser } = await lasKropp(req);
if (!Array.isArray(handelser) || handelser.length > 500) {
return svara(res, 400, { error: "Ogiltig händelselista." });
}
for (const post of handelser) {
if (typeof post?.id !== "string" || !post.tidpunkt || typeof post.anvandare !== "string" || !post.handelse) {
return svara(res, 400, { error: "Ogiltig händelse." });
}
// Append-only: on conflict do nothing — en befintlig händelse
// skrivs aldrig över, och databastriggern stoppar allt annat.
await pool.query(
`insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse)
values ($1, $2, $3, $4, $5) on conflict (id) do nothing`,
[post.id, handelserVag[1], post.tidpunkt, post.anvandare, post.handelse],
);
}
return svara(res, 200, { ok: true });
}
return svara(res, 200, { ok: true });
}
return svara(res, 404, { error: "Okänd resurs." });
@@ -200,15 +283,6 @@ export function skapaServer() {
return svara(res, 500, { error: "Förfrågan misslyckades." });
}
});
function loggaIn(res, anvandare, hemlighet) {
const nu = Math.floor(Date.now() / 1000);
const token = skapaJwt(
{ sub: anvandare.id, namn: anvandare.namn, iat: nu, exp: nu + TOKEN_LIVSTID_S },
hemlighet,
);
return svara(res, 200, { token, namn: anvandare.namn });
}
}
if (process.env.NODE_ENV !== "test") {