Compare commits

...
Author SHA1 Message Date
admin f5600a25b9 Merge pull request 'fix(manifest): provenRestore=true (restore-drill 2026-09-29)' (#507) from fix/prod-restore-2026-09-29 into main
masterplan-lock / check (push) Successful in 5s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 13s
CI (SIAX Cloud) / security (push) Successful in 14s
Security Scan (SIAX Cloud) / security (push) Successful in 25s
CI (SIAX Cloud) / sonar (push) Successful in 1m3s
CI (SIAX Cloud) / integration (push) Successful in 1m14s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 41s
Deploy to Coolify / deploy (push) Successful in 1m21s
CI (SIAX Cloud) / quality (push) Successful in 10m41s
2026-11-08 03:58:42 +00:00
admin d85f5ae559 Merge branch 'main' into fix/prod-restore-2026-09-29
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 4s
masterplan-lock / check (push) Successful in 9s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 11s
CI (SIAX Cloud) / security (pull_request) Successful in 15s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 20s
CI (SIAX Cloud) / integration (pull_request) Successful in 46s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m18s
2026-11-08 03:58:30 +00:00
admin 246589bde8 Merge pull request 'fix(signing): cosign-pubkey + SBOM + deployment/env-poster (9aef3cd97f)' (#506) from fix/prod-evidence-2026-09-29 into main
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / quality (push) Canceled after 16s
CI (SIAX Cloud) / integration (push) Canceled after 16s
CI (SIAX Cloud) / security (push) Canceled after 16s
CI (SIAX Cloud) / sonar (push) Canceled after 16s
CI (SIAX Cloud) / Component conformance (blocking) (push) Canceled after 16s
Security Scan (SIAX Cloud) / security (push) Canceled after 17s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m39s
Deploy to Coolify / deploy (push) Successful in 1m42s
2026-11-08 03:58:27 +00:00
siax-bot fdba72bd35 fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-9aef3cd97f
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 7s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 20s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 25s
CI (SIAX Cloud) / quality (pull_request) Successful in 43s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m19s
2026-11-08 01:30:28 +01:00
admin 9aef3cd97f Merge pull request 'fix(manifest): provenRestore=true (restore-drill 2026-09-29)' (#505) from fix/prod-restore-2026-09-29 into main
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 10s
CI (SIAX Cloud) / security (push) Successful in 15s
Security Scan (SIAX Cloud) / security (push) Successful in 18s
CI (SIAX Cloud) / integration (push) Successful in 27s
CI (SIAX Cloud) / sonar (push) Successful in 1m11s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m25s
Deploy to Coolify / deploy (push) Successful in 1m37s
CI (SIAX Cloud) / quality (push) Successful in 10m15s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 9s
masterplan-lock / check (push) Successful in 11s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 14s
CI (SIAX Cloud) / security (pull_request) Successful in 21s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 40s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m47s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m0s
2026-11-07 23:21:36 +00:00
admin 34ef39e5f5 Merge branch 'main' into fix/prod-restore-2026-09-29
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 12s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 15s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 24s
CI (SIAX Cloud) / security (pull_request) Successful in 26s
masterplan-lock / check (pull_request) Successful in 30s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m52s
CI (SIAX Cloud) / quality (pull_request) Successful in 11m18s
2026-11-07 23:21:24 +00:00
admin 43ec05ade2 Merge pull request 'fix(signing): cosign-pubkey + SBOM + deployment/env-poster (a0e701efc8)' (#504) from fix/prod-evidence-2026-09-29 into main
CI (SIAX Cloud) / quality (push) Canceled after 14s
CI (SIAX Cloud) / integration (push) Canceled after 13s
CI (SIAX Cloud) / security (push) Canceled after 12s
CI (SIAX Cloud) / sonar (push) Canceled after 12s
CI (SIAX Cloud) / Component conformance (blocking) (push) Canceled after 12s
Security Scan (SIAX Cloud) / security (push) Canceled after 13s
masterplan-lock / check (push) Successful in 14s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m45s
Deploy to Coolify / deploy (push) Successful in 1m50s
2026-11-07 23:21:21 +00:00
siax-bot be6b92fb82 fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-a0e701efc8
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 8s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 8s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / security (pull_request) Successful in 32s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m9s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m28s
2026-11-07 21:30:19 +01:00
admin a0e701efc8 Merge pull request 'fix(manifest): provenRestore=true (restore-drill 2026-09-29)' (#493) from fix/prod-restore-2026-09-29 into main
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 9s
Security Scan (SIAX Cloud) / security (push) Successful in 20s
CI (SIAX Cloud) / security (push) Successful in 24s
CI (SIAX Cloud) / sonar (push) Successful in 1m13s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 3m44s
Deploy to Coolify / deploy (push) Successful in 3m34s
CI (SIAX Cloud) / integration (push) Successful in 10m0s
CI (SIAX Cloud) / quality (push) Successful in 10m1s
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 4s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 8s
CI (SIAX Cloud) / sonar (pull_request) Skipped
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / security (pull_request) Successful in 30s
CI (SIAX Cloud) / quality (pull_request) Successful in 42s
CI (SIAX Cloud) / integration (pull_request) Successful in 59s
2026-11-07 19:10:33 +00:00
admin 48fbe6b184 Merge branch 'main' into fix/prod-restore-2026-09-29
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 6s
masterplan-lock / check (pull_request) Successful in 6s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 11s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 24s
CI (SIAX Cloud) / security (pull_request) Successful in 26s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m55s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m10s
2026-11-07 19:10:22 +00:00
admin b90cded2d6 Merge pull request 'fix(signing): cosign-pubkey + SBOM + deployment/env-poster (b2499ace79)' (#500) from fix/prod-evidence-2026-09-29 into main
masterplan-lock / check (push) Successful in 6s
CI (SIAX Cloud) / quality (push) Canceled after 16s
CI (SIAX Cloud) / integration (push) Canceled after 16s
CI (SIAX Cloud) / security (push) Canceled after 16s
CI (SIAX Cloud) / sonar (push) Canceled after 16s
CI (SIAX Cloud) / Component conformance (blocking) (push) Canceled after 16s
Security Scan (SIAX Cloud) / security (push) Canceled after 16s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m26s
Deploy to Coolify / deploy (push) Successful in 1m47s
2026-11-07 19:10:17 +00:00
admin aba909c29a Merge branch 'main' into fix/prod-evidence-2026-09-29
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 10s
masterplan-lock / check (pull_request) Successful in 9s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 13s
CI (SIAX Cloud) / security (pull_request) Successful in 20s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 49s
CI (SIAX Cloud) / quality (pull_request) Successful in 54s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m6s
2026-11-07 19:10:04 +00:00
admin e00c7b0fc5 Merge pull request 'docs(runbook): legacy-instansaudit server5 (0pt + L0RE)' (#501) from docs/legacy-instances-20261107 into main
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 8s
CI (SIAX Cloud) / security (push) Successful in 16s
Security Scan (SIAX Cloud) / security (push) Successful in 15s
CI (SIAX Cloud) / quality (push) Successful in 42s
CI (SIAX Cloud) / sonar (push) Successful in 56s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 2m25s
Deploy to Coolify / deploy (push) Successful in 2m17s
CI (SIAX Cloud) / integration (push) Successful in 10m2s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 5s
masterplan-lock / check (push) Successful in 7s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 9s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 21s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m45s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m26s
2026-11-07 17:34:12 +00:00
admin de76f3795b Merge pull request 'fix(canonical): RFC3339-serialisering av timestamptz' (#498) from fix/canonical-iso-timestamps-20261107 into main
CI (SIAX Cloud) / quality (push) Canceled after 5s
CI (SIAX Cloud) / integration (push) Canceled after 5s
CI (SIAX Cloud) / security (push) Canceled after 5s
CI (SIAX Cloud) / sonar (push) Canceled after 5s
CI (SIAX Cloud) / Component conformance (blocking) (push) Canceled after 4s
masterplan-lock / check (push) Successful in 5s
Security Scan (SIAX Cloud) / security (push) Canceled after 5s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m1s
Deploy to Coolify / deploy (push) Successful in 1m12s
2026-11-07 17:34:07 +00:00
admin 3421748f07 Merge branch 'main' into docs/legacy-instances-20261107
CI (SIAX Cloud) / sonar (pull_request) Skipped
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 13s
masterplan-lock / check (pull_request) Successful in 13s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 27s
masterplan-lock / check (push) Successful in 34s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m18s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m29s
2026-11-07 17:19:41 +00:00
admin 9ac681ee27 Merge branch 'main' into fix/canonical-iso-timestamps-20261107
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 10s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 20s
CI (SIAX Cloud) / security (pull_request) Successful in 22s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 23s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m0s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m27s
2026-11-07 17:19:39 +00:00
admin cdbb10b8e3 Merge pull request 'test(lint): typa idempotency-store (fixar quality-gaten som blockerar alla PR)' (#503) from fix/lint-siax-access-idempotency into main
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / security (push) Successful in 23s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 26s
Security Scan (SIAX Cloud) / security (push) Successful in 37s
CI (SIAX Cloud) / sonar (push) Successful in 1m22s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 2m48s
Deploy to Coolify / deploy (push) Successful in 2m55s
CI (SIAX Cloud) / integration (push) Successful in 9m58s
CI (SIAX Cloud) / quality (push) Successful in 10m34s
2026-11-07 17:19:35 +00:00
SIAX Agent 6bbbcf75d4 test(lint): typa idempotency-store i stället för any (blockerar quality-gaten)
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 7s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 15s
CI (SIAX Cloud) / security (pull_request) Successful in 21s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m7s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m22s
tests/siax-access/idempotency.test.ts hade fyra @typescript-eslint/no-explicit-any
vilket fick quality-jobbet att exita 1 (133 testfiler gröna men lint röd) och
därmed blockerade alla PR:er. Använder exporterade IdempotencyStore<unknown,
{x:number}>; eslint rent, 7/7 tester gröna.
2026-11-07 18:15:04 +01:00
admin 58dca92d9f Merge pull request 'fix(sql): 023 grants för canonical-tabellerna till app-rollen' (#499) from fix/canonical-grants-20261107 into main
masterplan-lock / check (push) Successful in 9s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 15s
CI (SIAX Cloud) / security (push) Successful in 18s
Security Scan (SIAX Cloud) / security (push) Successful in 22s
CI (SIAX Cloud) / sonar (push) Successful in 1m7s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 2m6s
Deploy to Coolify / deploy (push) Successful in 2m9s
CI (SIAX Cloud) / quality (push) Canceled after 7m32s
CI (SIAX Cloud) / integration (push) Canceled after 7m32s
2026-11-07 17:12:03 +00:00
admin 7ca7b98fb2 Merge branch 'main' into docs/legacy-instances-20261107
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
masterplan-lock / check (push) Successful in 26s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 29s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 34s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m30s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m52s
2026-11-07 17:07:45 +00:00
admin b269a05538 Merge branch 'main' into fix/canonical-grants-20261107
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 4s
masterplan-lock / check (push) Successful in 8s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 12s
CI (SIAX Cloud) / security (pull_request) Successful in 19s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m28s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 1m30s
CI (SIAX Cloud) / quality (pull_request) Successful in 2m15s
2026-11-07 17:07:44 +00:00
admin 1b7c6c2de9 Merge branch 'main' into fix/canonical-iso-timestamps-20261107
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 11s
masterplan-lock / check (pull_request) Successful in 9s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 14s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 19s
CI (SIAX Cloud) / security (pull_request) Successful in 1m37s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m38s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m10s
2026-11-07 17:07:42 +00:00
SIAX Agent b7627a3a31 docs(runbook): legacy-instansaudit server5 (0pt + L0RE) och disposition
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 3s
masterplan-lock / check (pull_request) Successful in 3s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m12s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m46s
0pt legacy: 32 kanoniska facts exporterade och återinförda via API (replay
duplicates=32), dump till server11, api/web stoppade med restart=no, pg kvar
för forensik. L0RE-orphan refererad till PR #485. Ingen databas/volymer raderade.
2026-11-07 17:37:33 +01:00
SIAX Agent afd0b4ec46 fix(canonical): serialisera timestamptz som RFC3339 (ISO-8601)
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 5s
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / security (pull_request) Successful in 25s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 54s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m11s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m1s
queryFacts använde String(Date) -> 'Sat Nov 07 2026 15:00:00 GMT+0000 (...)',
vilket L0RE:s canonical-projection avvisar ('must be RFC3339 date-time').
Inför iso()-helper: Date -> toISOString(), annars String(). Gäller
observed_at, valid_from, valid_to, created_at.
2026-11-07 17:30:40 +01:00
SIAX Agent 4dbabd14ac fix(sql): 023 grants for canonical tables to app-role 0pt_app
masterplan-lock / check (push) Successful in 7s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 6s
CI (SIAX Cloud) / security (pull_request) Successful in 19s
CI (SIAX Cloud) / integration (pull_request) Successful in 45s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 1m7s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m7s
022 skapade tabellerna som 0pt_edge; appen kör som 0pt_app och fick
'permission denied'. Övriga 0pt-tabeller har explicita grants (inga default
ACL), så kanoniska tabeller behöver samma. Idempotent och rollskyddad; RLS
(FORCE) gäller fortfarande för 0pt_app.
2026-11-07 17:29:03 +01:00
admin b2499ace79 Merge pull request #497: idempotent 022 canonical facts
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 10s
CI (SIAX Cloud) / security (push) Successful in 19s
Security Scan (SIAX Cloud) / security (push) Successful in 19s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m11s
Deploy to Coolify / deploy (push) Successful in 1m14s
CI (SIAX Cloud) / sonar (push) Successful in 1m15s
CI (SIAX Cloud) / quality (push) Successful in 10m1s
CI (SIAX Cloud) / integration (push) Successful in 10m5s
Retry-safe RLS policies (DROP POLICY IF EXISTS) + header fix.
2026-11-07 16:28:21 +00:00
SIAX Agent 0213459f33 fix(sql): 022 canonical facts blir idempotent (DROP POLICY IF EXISTS)
masterplan-lock / check (push) Successful in 5s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 4s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 15s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 21s
CI (SIAX Cloud) / security (pull_request) Successful in 48s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m47s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m39s
CREATE POLICY saknade skydd mot omkörning, så en retry efter partiell körning
skulle fastna på 'policy already exists'. Tabellerna använder redan
CREATE TABLE/INDEX IF NOT EXISTS; policynivån görs nu likvärdig. Rubriken
rättad från 020 till 022.

Verifierat på scratch-databas: två körningar i rad OK, RLS ENABLE+FORCE,
3 policies, 11 index, cross-tenant INSERT nekad och tenant B ser 0 rader.
2026-11-07 17:24:56 +01:00
admin c91d2aaca6 Merge pull request #494: 0pt internal-service tenant isolation
masterplan-lock / check (push) Successful in 9s
Security Scan (SIAX Cloud) / security (push) Successful in 32s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 57s
CI (SIAX Cloud) / security (push) Successful in 58s
CI (SIAX Cloud) / sonar (push) Successful in 1m15s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m44s
Deploy to Coolify / deploy (push) Successful in 1m59s
CI (SIAX Cloud) / integration (push) Successful in 9m48s
CI (SIAX Cloud) / quality (push) Successful in 10m22s
Server-configured INTERNAL_SERVICE_TENANTS; forged verified headers stripped; bridge-run guarded; single-tenant rule until workload-identity binding exists.
2026-11-07 15:55:11 +00:00
SIAX Agent 1a581fde29 fix(auth): en (1) tenant per intern serviceprincipal — multi-tenant nekas
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 8s
CI (SIAX Cloud) / security (pull_request) Successful in 35s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 44s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m48s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m14s
Den delade X-Internal-Token identifierar EN tjänsteprocess, inte flera
oberoende principaler. En allowlist med flera tenants skulle låta varje
tokeninnehavare välja mellan dem — samma klientstyrda auktoritet som guarden
ska ta bort. Multi-tenant-tjänsteåtkomst kräver verifierad
workload-identity-binding (CL0UD-delegation); tills den finns failar en
ambig konfiguration stängt (401) och ingen identitet forwardas.

Tester: multi-tenant nekas utan side effect, en tenant fungerar, 75/75 i
auth+ cross-tenant-sviterna, tsc ren.
2026-11-07 16:39:48 +01:00
admin bfdc5d1326 Merge pull request 'ci: add blocking component conformance gate' (#496) from ci/design-gate-20261107 into main
Security Scan (SIAX Cloud) / security (push) Successful in 21s
masterplan-lock / check (push) Successful in 1m34s
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 1m39s
CI (SIAX Cloud) / security (push) Successful in 1m55s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 2m8s
CI (SIAX Cloud) / sonar (push) Successful in 2m47s
Deploy to Coolify / deploy (push) Successful in 2m50s
CI (SIAX Cloud) / integration (push) Successful in 11m23s
CI (SIAX Cloud) / quality (push) Successful in 11m48s
2026-11-07 15:32:45 +00:00
admin 831781f19d ci: add blocking component conformance gate
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 7s
CI (SIAX Cloud) / security (pull_request) Successful in 15s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 20s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 30s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m51s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m21s
2026-11-07 15:21:31 +00:00
SIAX Agent b97ad0b2f4 fix(auth): bind internal-service tenant authority to server config
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 4s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / integration (pull_request) Successful in 36s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m24s
The first revision of this fix copied a CLIENT header (X-0pt-Internal-Tenant)
or a single INTERNAL_TENANT_ID into x-0pt-verified-tenant. That is not an
authorization source: any holder of the shared internal token could still name
any tenant, leaving the original cross-tenant write reachable through a
different header.

- INTERNAL_SERVICE_TENANTS (server env) is now the only tenant authority for
  the X-Internal-Token principal; empty => no authority (fail closed, 403).
- Middleware strips inbound x-0pt-verified-{tenant,sub} and the service scope
  header on every path, including the dev escape hatch.
- x-0pt-verified-service-tenants carries the allowlist; the tenant guard checks
  membership and denies anything outside it.
- POST /api/0pt/quality/bridge-run now runs the same tenant guard as
  canonical/facts (it auto-provisions tenant-scoped rules).
- 16 new regression tests; auth + cross-tenant + canonical + bridge suites
  87/87; tsc --noEmit clean.
2026-11-07 15:32:51 +01:00
admin 849e7ae802 fix(auth): internal-token requests får verifierad tenant (P0 tenant-isolering)
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / security (pull_request) Successful in 15s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 43s
CI (SIAX Cloud) / integration (pull_request) Successful in 10m3s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m15s
Bevisat: POST /api/0pt/canonical/facts med X-Internal-Token och tenant_id
999999999999999999 returnerade 201 och skrev data i annan tenant. Orsak:
internal-token-grenen returnerade next() utan x-0pt-verified-tenant, så
assertTenantMatchesVerified blev no-op.

Fix: INTERNAL_TENANT_ID (eller X-0pt-Internal-Tenant) sätts som verifierad
tenant, vilket gör guard-enforcement identisk med verifierad Bearer. Varnar
om variabeln saknas.
2026-11-07 14:55:59 +01:00
siax-bot 70444a369e fix(signing): cosign-pubkey + SBOM + deployment/env-poster för prod-imagen prod-ef84832cdb
masterplan-lock / check (push) Successful in 5s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / security (pull_request) Successful in 20s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 43s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m42s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m54s
2026-11-03 17:30:19 +01:00
20 changed files with 551 additions and 73 deletions
+10
View File
@@ -58,6 +58,16 @@ DEV_LOGIN_KEY=
# Shared token for internal/service-to-service calls; sent as X-Internal-Token
# on every engineFetch() call (lib/engine.ts).
INTERNAL_TOKEN=change-me-internal-token
# Tenant authority for the internal-token principal. Comma-separated allowlist
# of tenants this deployment's engine is entitled to write for, e.g.
# "379976258701823235". This is the ONLY source of tenant authority for
# X-Internal-Token: a request header may never name a tenant. Empty/unset means
# the service principal has no tenant authority and tenant-scoped writes
# (canonical facts, quality bridge runs) are denied with 403. Exactly ONE
# tenant is supported: the shared token identifies one service process, not a
# set of principals. A multi-tenant list is refused (401) until a verified
# workload-identity binding exists.
INTERNAL_SERVICE_TENANTS=
# ---------------------------------------------------------------------------
# Zitadel / OIDC Bearer verification (Wave 2 hardening) -- wired in code today
+19
View File
@@ -309,3 +309,22 @@ jobs:
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
docker cp . "$cid":/usr/src
docker start -a "$cid"
design-conformance:
name: Component conformance (blocking)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Checkout canonical siax-standard (SSOT)
uses: actions/checkout@v4
with:
repository: admin/siax-standard
token: ${{ secrets.SIAX_CI_TOKEN }}
path: siax-standard-ssot
- name: Component conformance (blocking)
run: |
node --input-type=module -e "import('./siax-standard-ssot/packages/doctor/src/commands/component-conformance.mjs').then(m=>{process.exit(m.run(['--repo', process.env.GITHUB_WORKSPACE, '--allow-path-prefix', 'siax-standard-ssot/', '--fail-on=block']))})"
+6
View File
@@ -26,6 +26,7 @@ import { parseBody, qualityBridgeRunSchema } from "@/src/validation/0pt-schemas"
import { qualityService } from "@/src/services/quality";
import { ensureBaselineRules } from "@/src/quality/quality-bridge";
import { siaxErrorResponse } from "@/src/http/siax-error-response";
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
export async function POST(request: NextRequest) {
try {
@@ -35,6 +36,11 @@ export async function POST(request: NextRequest) {
return NextResponse.json(__parsed.body, { status: __parsed.status });
}
const body = __parsed.data;
// Tenant-scoped write (auto-provisions baseline rules + stores a run):
// the internal service principal must stay inside its server-configured
// tenant allowlist, never a tenant named by the caller.
const guard = assertTenantMatchesVerified(request, body.tenant_id);
if (guard) return guard;
await ensureBaselineRules(qualityService, {
tenant_id: body.tenant_id,
+47
View File
@@ -0,0 +1,47 @@
# Legacy instance audit — 0pt (server5) and L0RE (server5) — 2026-11-07
Canonical runtimes: **0pt on server6** (Coolify app `shxd6lepxuxfsjsp0ath3z1g`,
`0pt.app`) and **L0RE on server9** (`l0re.siax.io`). This document records the
older instances on server5 and their disposition. It is not production
documentation for either product.
## 1. 0pt legacy (server5)
| Item | Value |
|---|---|
| Containers | `0pt-api` (`0pt-api:c46ebbf`, created 2026-11-05), `0pt-web` (`:18080`) |
| Database | `0pt-pg`, user/db `opt`, 11 MB |
| Exposure | `0pt-web` only on `100.124.7.55:18080`; no DNS record, no reverse proxy |
| Consumers | none found (`/etc/siax`, `/opt/siax`, site0 and L0RE deploy configs) |
| Unique data | 32 canonical facts (30 GLEIF web observations + `body_type` YDL405 + `passmark.find_prime_numbers.v10`) |
**Data reconciliation:** all 32 facts were exported and re-imported through the
canonical API (`POST /api/0pt/canonical/facts`) into the server6 deployment.
Replay returned `duplicates: 32`, so the import is idempotent. Production now
holds 34 facts (32 migrated + 2 verification observations) and 4 entities.
**Backup:** `server5:/var/backups/0pt-legacy/opt_legacy_20261107.dump.zst`
(custom format, 32 KB) + offsite `server11:/home/backup/0pt/`.
**Action taken:** `docker update --restart=no` + `docker stop` for `0pt-api` and
`0pt-web`. `0pt-pg` left running for forensics/rollback. `0pt.app` verified 200
after the stop (served by server6).
**Full removal (requires owner decision):** snapshot/destroy `0pt-pg` volume
after the retention window, remove the legacy compose project, revoke the old
`INTERNAL_TOKEN` if it was ever stored on server5.
## 2. L0RE orphan (server5)
Documented separately in `admin/l0re` PR #485
(`docs/runbook/l0re-orphan-decommission-20261107.md`): API stopped with
`restart=no`, dump + restore proof (8 propositions/62 assertions), no unique
legitimate data (pilot facts already re-projected to production). `l0re-pg` and
`l0re-opensearch` remain for forensics.
## 3. Single-runtime rule
One primary runtime per logical service. A container image of a product running
outside its registered Coolify app is a finding, not a fallback. Both server5
instances were reachable only from the host, which is why the split survived
until this audit.
+65 -4
View File
@@ -10,6 +10,10 @@ import { NextResponse, type NextRequest } from 'next/server';
import { assertAuthConfigSafe, resolveAuthContext } from '@/src/auth/context';
import { SESSION_COOKIE, verifySession } from '@/lib/auth';
import { verifyInternalServiceToken } from '@/src/auth/internal-token';
import {
SERVICE_TENANT_SCOPE_HEADER,
resolveInternalServiceTenants,
} from '@/src/auth/internal-service-tenant';
assertAuthConfigSafe();
@@ -35,13 +39,68 @@ const INTERNAL_SERVICE_ROUTES: RegExp[] = [
];
let _authDiagCount = 0;
let internalScopeWarned = false;
/**
* Headers that only middleware may set. Anything a client sends under these
* names is a forgery attempt and is removed before forwarding — including on
* the internal-token path, which previously passed the original header map
* through unchanged.
*/
const VERIFIED_HEADERS = [
'x-0pt-verified-tenant',
'x-0pt-verified-sub',
SERVICE_TENANT_SCOPE_HEADER,
] as const;
function stripVerifiedHeaders(req: NextRequest): Headers {
const forwarded = new Headers(req.headers);
for (const name of VERIFIED_HEADERS) forwarded.delete(name);
return forwarded;
}
export async function middleware(req: NextRequest): Promise<NextResponse> {
const path = req.nextUrl.pathname;
if (EXEMPT_PATTERNS.some((re) => re.test(path))) return NextResponse.next();
if (INTERNAL_SERVICE_ROUTES.some((re) => re.test(path)) && verifyInternalServiceToken(req.headers)) {
return NextResponse.next();
// P0 tenant isolation (2026-11-07): X-Internal-Token proves the service —
// not a tenant. Tenant authority is SERVER CONFIG (INTERNAL_SERVICE_TENANTS),
// never a client header: an inbound `X-0pt-Internal-Tenant` is ignored and
// stripped. With no configured tenants the principal has no tenant
// authority and tenant-scoped writes fail closed with 403.
const forwarded = stripVerifiedHeaders(req);
const tenants = resolveInternalServiceTenants();
if (tenants.length > 1) {
// The shared X-Internal-Token identifies ONE service process, not a set
// of independently authenticated principals. A multi-tenant allowlist
// would let any holder of that shared token choose between all listed
// tenants, which is exactly the client-controlled authority this guard
// exists to remove. Multi-tenant service access requires a verified
// workload-identity binding (CL0UD delegation contract); until that
// exists, an ambiguous configuration fails closed.
console.error(
'[0pt-auth] INTERNAL_SERVICE_TENANTS lists more than one tenant: refusing the internal request until workload-identity binding exists',
);
return NextResponse.json(
{
error: 'OPT_UNAUTHENTICATED',
message:
'internal service tenant scope is ambiguous: configure exactly one tenant until a verified workload-identity binding exists',
},
{ status: 401 },
);
}
forwarded.set(SERVICE_TENANT_SCOPE_HEADER, tenants.join(','));
if (tenants.length === 1) {
forwarded.set('x-0pt-verified-tenant', tenants[0]);
} else if (tenants.length === 0 && !internalScopeWarned) {
internalScopeWarned = true;
console.warn(
'[0pt-auth] INTERNAL_SERVICE_TENANTS is unset: the internal service principal has no tenant authority; tenant-scoped writes will be denied',
);
}
return NextResponse.next({ request: { headers: forwarded } });
}
if (_authDiagCount < 5) {
@@ -65,7 +124,7 @@ export async function middleware(req: NextRequest): Promise<NextResponse> {
typeof session.exp === 'number' &&
session.exp > Date.now()
) {
const forwarded = new Headers(req.headers);
const forwarded = stripVerifiedHeaders(req);
forwarded.set('x-0pt-verified-sub', session.sub);
forwarded.set('x-0pt-verified-tenant', session.tenantId);
return NextResponse.next({ request: { headers: forwarded } });
@@ -86,11 +145,13 @@ export async function middleware(req: NextRequest): Promise<NextResponse> {
const principal = decision.ctx.principal;
if (principal) {
const forwarded = new Headers(req.headers);
const forwarded = stripVerifiedHeaders(req);
forwarded.set('x-0pt-verified-sub', principal.sub);
forwarded.set('x-0pt-verified-tenant', principal.tenantId);
return NextResponse.next({ request: { headers: forwarded } });
}
return NextResponse.next();
// No verified principal (explicit AUTH_ENFORCE=false development mode).
// Never forward a client-named verified header, even in dev.
return NextResponse.next({ request: { headers: stripVerifiedHeaders(req) } });
}
@@ -1,12 +0,0 @@
{
"id": "pe-artifact-0pt-13e156ec7e6b",
"repo": "0pt",
"evidenceType": "artifact-digest",
"result": "pass",
"verifiedAt": "2026-11-04T10:53:16Z",
"verifiedBy": "docker image inspect RepoDigests (real registry digest)",
"proofRef": "docker://server6 image shxd6lepxuxfsjsp0ath3z1g:13e156ec7e6be534de9c28d28ec2daff0355ca7f image@sha256:657bc7e2ed3425436b18680138d7c792ac879db4c49a505d1ea0305699d89089",
"sourceSha": "13e156ec7e6be534de9c28d28ec2daff0355ca7f",
"digest": "sha256:657bc7e2ed3425436b18680138d7c792ac879db4c49a505d1ea0305699d89089",
"tool": "docker"
}
@@ -1,10 +0,0 @@
{
"id": "pe-ci-0pt-13e156ec7e6b",
"repo": "0pt",
"evidenceType": "deploy-log",
"result": "pass",
"verifiedAt": "2026-11-04T10:53:16Z",
"verifiedBy": "Coolify deployment record (application_deployment_queues, status=finished)",
"proofRef": "coolify://server6/shxd6lepxuxfsjsp0ath3z1g finished 2026-11-04 05:42:18",
"sourceSha": "13e156ec7e6be534de9c28d28ec2daff0355ca7f"
}
@@ -1,11 +0,0 @@
{
"id": "pe-running-0pt-13e156ec7e6b",
"repo": "0pt",
"evidenceType": "running-digest",
"result": "pass",
"verifiedAt": "2026-11-04T10:53:16Z",
"verifiedBy": "docker inspect (running container image digest)",
"proofRef": "docker://server6 container/shxd6lepxuxfsjsp0ath3z1g-20261104T093337 image@sha256:657bc7e2ed3425436b18680138d7c792ac879db4c49a505d1ea0305699d89089",
"sourceSha": "13e156ec7e6be534de9c28d28ec2daff0355ca7f",
"digest": "sha256:657bc7e2ed3425436b18680138d7c792ac879db4c49a505d1ea0305699d89089"
}
@@ -1,12 +0,0 @@
{
"id": "pe-sbom-0pt-13e156ec7e6b",
"repo": "0pt",
"evidenceType": "sbom",
"result": "pass",
"verifiedAt": "2026-11-04T09:29:06Z",
"verifiedBy": "syft (estate-kampanj issue #76, skannad vid deployad commit)",
"proofRef": "siax/sbom/sbom-0pt-13e156ec7e6b.json",
"sourceSha": "13e156ec7e6be534de9c28d28ec2daff0355ca7f",
"tool": "syft",
"sbomFormat": "cyclonedx-json"
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,4 +1,4 @@
# 0pt — cosign-signeringskedjan (2026-09-29)
Prod-imagen git.cloud.siax.io/sax3l/0pt:prod-8b5e23aa9b (sha256:889753b7e5a9db3e1c870eeb38f450cb0f20498e1025edb28153233e0b06f46d) är cosign-signerad
Prod-imagen git.cloud.siax.io/sax3l/0pt:prod-9aef3cd97f (sha256:eab6227b702f61145e0279ee1958760389e4ecf68b24323daa467abeefb94f32) är cosign-signerad
med nyckelparet vars pubkey ligger här. Privata nyckeln raderad efter signering.
+2 -2
View File
@@ -1,4 +1,4 @@
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPRG9iG1pjIW548KoIduQPD/B3Yb
tg5hPmy7o4feVQsdDnW38VtemVBQ4I+goytV9X4XkrFiweKmfqEMhGyPgg==
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEklcBOL7pw+7YVwUTrOHAxNTUmG7r
qNYIEFFgJqdeYff9ZB0ntkIWKu/bN9Dhzyk8cuGOmkIHocyPIH25CXrP1Q==
-----END PUBLIC KEY-----
@@ -1,10 +0,0 @@
{
"$schema": "https://git.siax.io/sax3l/siax-standard/raw/branch/main/schema/siax.source-bundle.schema.json",
"id": "sb-0pt-13e156ec7e6b",
"repo": "0pt",
"commitSha": "13e156ec7e6be534de9c28d28ec2daff0355ca7f",
"branch": "main",
"buildId": "coolify:0pt",
"producedAt": "2026-11-04T01:26:48Z",
"artifactId": "shxd6lepxuxfsjsp0ath3z1g@sha256:657bc7e2ed3425436b18680138d7c792ac879db4c49a505d1ea0305699d89089"
}
+4 -1
View File
@@ -1,4 +1,4 @@
-- 020_canonical_facts.sql — 0pt canonical structured observations (X-800 / Scrape).
-- 022_canonical_facts.sql — 0pt canonical structured observations (X-800 / Scrape).
--
-- 0pt owns normalized OBSERVED facts (what a source said, when, from which
-- artifact). L0RE owns epistemic propositions/assertions and projections; it
@@ -82,18 +82,21 @@ CREATE INDEX IF NOT EXISTS idx_canonical_relations_object
ALTER TABLE opt_canonical_entities ENABLE ROW LEVEL SECURITY;
ALTER TABLE opt_canonical_entities FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS opt_canonical_entities_tenant ON opt_canonical_entities;
CREATE POLICY opt_canonical_entities_tenant ON opt_canonical_entities
USING (tenant_id = current_setting('app.opt_canonical_tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.opt_canonical_tenant_id', true));
ALTER TABLE opt_canonical_facts ENABLE ROW LEVEL SECURITY;
ALTER TABLE opt_canonical_facts FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS opt_canonical_facts_tenant ON opt_canonical_facts;
CREATE POLICY opt_canonical_facts_tenant ON opt_canonical_facts
USING (tenant_id = current_setting('app.opt_canonical_tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.opt_canonical_tenant_id', true));
ALTER TABLE opt_canonical_relations ENABLE ROW LEVEL SECURITY;
ALTER TABLE opt_canonical_relations FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS opt_canonical_relations_tenant ON opt_canonical_relations;
CREATE POLICY opt_canonical_relations_tenant ON opt_canonical_relations
USING (tenant_id = current_setting('app.opt_canonical_tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.opt_canonical_tenant_id', true));
+19
View File
@@ -0,0 +1,19 @@
-- 023_canonical_grants.sql — app-role privileges for 022 canonical tables.
--
-- Tables created by a manual psql migration are owned by the migrating role
-- (0pt_edge); the running application connects as 0pt_app. Existing 0pt tables
-- carry explicit grants rather than default ACLs, so the canonical tables need
-- the same treatment. RLS still applies to 0pt_app (FORCE is on); this only
-- grants the statement privileges, not row visibility.
--
-- Idempotent and role-guarded: no-op in environments without the app role.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '0pt_app') THEN
GRANT SELECT, INSERT, UPDATE, DELETE
ON opt_canonical_entities, opt_canonical_facts, opt_canonical_relations
TO "0pt_app";
END IF;
END
$$;
+74
View File
@@ -0,0 +1,74 @@
/**
* `src/auth/internal-service-tenant.ts` — server-side tenant scope for the
* machine-to-machine principal authenticated by `X-Internal-Token`.
*
* WHY THIS EXISTS (P0, 2026-11-07)
* --------------------------------
* `X-Internal-Token` proves that the caller is the SIAX engine process. It
* does NOT prove which tenant that process may act for. The first attempt at
* fixing the "internal token bypasses the verified-tenant guard" hole copied a
* CLIENT HEADER (`X-0pt-Internal-Tenant`) or fell back to a single
* `INTERNAL_TENANT_ID` env var:
*
* const internalTenant =
* req.headers.get('x-0pt-internal-tenant') ?? process.env.INTERNAL_TENANT_ID;
*
* That is not an authorization source. Any holder of the shared internal token
* could still name any tenant in a request header, which is exactly the
* cross-tenant write the fix was supposed to stop (tenant_id
* 999999999999999999 returned 201 before).
*
* The correct model mirrors a verified Bearer token: the principal's tenant
* authority is a SERVER-SIDE fact, never a request field.
*
* - `INTERNAL_SERVICE_TENANTS` is a comma-separated allowlist configured on
* the 0pt deployment (env/secret), i.e. "the tenants this engine instance
* is entitled to write for".
* - When the allowlist is empty, the internal principal has NO tenant
* authority: tenant-scoped writes fail closed (403). Routes that do not
* carry a tenant (evidence outbox dispatch, ER candidates, search
* definitions) are unaffected.
* - A single allowlisted tenant is ALSO placed in `x-0pt-verified-tenant`
* by middleware so existing single-tenant checks keep working; two or more
* are carried in `x-0pt-verified-service-tenants` and the tenant guard
* checks membership.
* - Any inbound value for either header is stripped by middleware before
* forwarding, so a client can never forge tenant authority.
*
* No client header is read here. `X-0pt-Internal-Tenant` is deliberately not a
* contract.
*/
/** Env var: comma-separated tenants this internal service principal may assert. */
export const INTERNAL_SERVICE_TENANTS_VAR = 'INTERNAL_SERVICE_TENANTS';
/**
* Request header installed by middleware.ts ONLY (never accepted from a
* client, and stripped from every inbound request). Carries the full
* server-configured service tenant scope; may be the empty string, which means
* "service principal without tenant authority".
*/
export const SERVICE_TENANT_SCOPE_HEADER = 'x-0pt-verified-service-tenants';
/** Parse a comma-separated tenant allowlist (trimmed, de-duplicated, ordered). */
export function parseServiceTenants(value: string | undefined | null): string[] {
if (!value) return [];
const tenants: string[] = [];
for (const part of value.split(',')) {
const tenant = part.trim();
if (tenant && !tenants.includes(tenant)) tenants.push(tenant);
}
return tenants;
}
/** Read the configured service tenant allowlist from the environment. */
export function resolveInternalServiceTenants(
env: { INTERNAL_SERVICE_TENANTS?: string } = process.env,
): string[] {
return parseServiceTenants(env.INTERNAL_SERVICE_TENANTS);
}
/** Read the scope middleware installed on the request (already stripped+set). */
export function readServiceTenantScope(headers: { get(name: string): string | null | undefined }): string[] {
return parseServiceTenants(headers.get(SERVICE_TENANT_SCOPE_HEADER));
}
+21
View File
@@ -29,6 +29,7 @@
* authz hole without changing every route's request contract).
*/
import { NextResponse, type NextRequest } from "next/server";
import { readServiceTenantScope } from "@/src/auth/internal-service-tenant";
export const VERIFIED_TENANT_HEADER = "x-0pt-verified-tenant";
@@ -52,6 +53,26 @@ export function assertTenantMatchesVerified(
tenantId: string | null | undefined,
): NextResponse | null {
const verified = request.headers.get(VERIFIED_TENANT_HEADER);
// Internal service principal: tenant authority is the SERVER-CONFIGURED
// allowlist middleware installed (never a client header). The scope header is
// present even when the allowlist is empty, which means "no tenant
// authority" -> fail closed. Routes that do not take a tenant never call
// this guard, so evidence dispatch / ER / search definitions keep working.
if (request.headers.get("x-0pt-verified-service-tenants") !== null) {
if (!tenantId) return null; // handlers already 400 on a missing tenant_id separately
const scope = readServiceTenantScope(request.headers);
if (!scope.includes(tenantId)) {
return NextResponse.json(
{
error: "OPT_ACCESS_DENIED",
message: "tenant_id is outside the internal service principal's configured tenant scope",
details: { requested_tenant: tenantId, service_tenant_scope: scope },
},
{ status: 403 },
);
}
return null;
}
if (!verified) return null;
if (!tenantId) return null; // handlers already 400 on a missing tenant_id separately
if (tenantId !== verified) {
@@ -30,6 +30,11 @@ export interface CanonicalFactRepository {
queryFacts(query: CanonicalQuery): Promise<CanonicalFact[]>;
}
/** PostgreSQL timestamptz arrives as Date; the API contract is RFC3339/ISO-8601. */
function iso(value: unknown): string {
return value instanceof Date ? value.toISOString() : String(value);
}
export function entityKey(e: CanonicalEntityInput): string {
return `${e.entity_type}\u0000${e.identity_namespace}\u0000${e.identity_value}`;
}
@@ -216,11 +221,11 @@ class PostgresCanonicalFactRepository implements CanonicalFactRepository {
source_record_id: r.source_record_id ?? undefined,
st0re_artifact_id: r.st0re_artifact_id ?? undefined,
aud0_evidence_ref: r.aud0_evidence_ref ?? undefined, run_ref: r.run_ref ?? undefined,
extractor_version: r.extractor_version, observed_at: String(r.observed_at),
valid_from: r.valid_from ? String(r.valid_from) : undefined,
valid_to: r.valid_to ? String(r.valid_to) : undefined,
extractor_version: r.extractor_version, observed_at: iso(r.observed_at),
valid_from: r.valid_from ? iso(r.valid_from) : undefined,
valid_to: r.valid_to ? iso(r.valid_to) : undefined,
confidence: r.confidence === null ? undefined : Number(r.confidence),
quality_status: r.quality_status, status: r.status, created_at: String(r.created_at),
quality_status: r.quality_status, status: r.status, created_at: iso(r.created_at),
})) as CanonicalFact[];
});
}
@@ -0,0 +1,262 @@
/**
* P0 regression (2026-11-07): tenant authority for the machine-to-machine
* (`X-Internal-Token`) principal is SERVER CONFIGURATION, never a client
* header.
*
* The first attempt at this fix read `X-0pt-Internal-Tenant` (or a single
* `INTERNAL_TENANT_ID`) and copied it into `x-0pt-verified-tenant`. Any holder
* of the shared internal token could then still name any tenant, so the
* original cross-tenant write (tenant_id 999999999999999999 -> 201) remained
* possible through a different header. These tests pin the corrected model:
*
* - `INTERNAL_SERVICE_TENANTS` (server env) is the only source of tenant
* authority for the service principal.
* - No configured scope => tenant-scoped writes are denied (fail closed).
* - Client-supplied `x-0pt-internal-tenant`, `x-0pt-verified-tenant` and
* `x-0pt-verified-service-tenants` are ignored and stripped.
* - Bearer/session principals are unchanged.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { NextRequest } from 'next/server';
import { middleware } from '../../middleware';
import {
parseServiceTenants,
resolveInternalServiceTenants,
SERVICE_TENANT_SCOPE_HEADER,
} from '@/src/auth/internal-service-tenant';
import { assertTenantMatchesVerified } from '@/src/http/tenant-guard';
import { POST as canonicalPOST } from '@/app/api/0pt/canonical/facts/route';
import { POST as bridgePOST } from '@/app/api/0pt/quality/bridge-run/route';
import { POST as evidenceDispatchPOST } from '@/app/api/0pt/internal/evidence-outbox/dispatch/route';
const INTERNAL_TOKEN = 'engine-shared-secret';
const SCOPED_ROUTE = '/api/0pt/canonical/facts';
function req(path: string, headers: Record<string, string> = {}): NextRequest {
return new NextRequest(new URL(path, 'http://localhost'), { headers });
}
/** Headers middleware forwarded to the route handler (Next stores them here). */
function forwarded(res: { headers: { get(name: string): string | null } }, name: string): string | null {
return res.headers.get(`x-middleware-request-${name.toLowerCase()}`);
}
function guardRequest(headers: Record<string, string>): NextRequest {
return new NextRequest(new URL(SCOPED_ROUTE, 'http://localhost'), {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
});
}
describe('parseServiceTenants / resolveInternalServiceTenants', () => {
it('splits, trims and de-duplicates the allowlist', () => {
expect(parseServiceTenants(' t_a , t_b ,, t_a ')).toEqual(['t_a', 't_b']);
});
it('an unset/empty allowlist means no tenant authority', () => {
expect(parseServiceTenants(undefined)).toEqual([]);
expect(parseServiceTenants('')).toEqual([]);
expect(resolveInternalServiceTenants({})).toEqual([]);
});
});
describe('middleware: internal-token tenant authority comes from server config', () => {
const saved = {
auth: process.env.AUTH_ENFORCE,
token: process.env.INTERNAL_TOKEN,
tenants: process.env.INTERNAL_SERVICE_TENANTS,
node: process.env.NODE_ENV,
};
beforeEach(() => {
delete process.env.AUTH_ENFORCE; // default posture: enforced
process.env.INTERNAL_TOKEN = INTERNAL_TOKEN;
delete process.env.INTERNAL_SERVICE_TENANTS;
});
afterEach(() => {
const values: Record<string, string | undefined> = {
AUTH_ENFORCE: saved.auth,
INTERNAL_TOKEN: saved.token,
INTERNAL_SERVICE_TENANTS: saved.tenants,
NODE_ENV: saved.node,
};
for (const [key, value] of Object.entries(values)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
it('single allowlisted tenant is installed as the verified tenant', async () => {
process.env.INTERNAL_SERVICE_TENANTS = 't_a';
const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': INTERNAL_TOKEN }));
expect(res.status).toBe(200);
expect(forwarded(res, 'x-0pt-verified-tenant')).toBe('t_a');
expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBe('t_a');
});
it('REFUSES an ambiguous multi-tenant allowlist (no workload-identity binding yet)', async () => {
process.env.INTERNAL_SERVICE_TENANTS = 't_a,t_b';
const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': INTERNAL_TOKEN }));
expect(res.status).toBe(401);
const body = await res.json();
expect(body.message).toContain('ambiguous');
expect(forwarded(res, 'x-0pt-verified-tenant')).toBeNull();
expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBeNull();
});
it('a single allowlist entry is still forwarded as the service scope', async () => {
process.env.INTERNAL_SERVICE_TENANTS = 't_a';
const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': INTERNAL_TOKEN }));
expect(res.status).toBe(200);
expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBe('t_a');
});
it('with no configured scope the service principal has no tenant authority (empty scope header)', async () => {
const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': INTERNAL_TOKEN }));
expect(res.status).toBe(200);
expect(forwarded(res, 'x-0pt-verified-tenant')).toBeNull();
expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBe('');
});
it('IGNORES a client-supplied X-0pt-Internal-Tenant (not an authority source)', async () => {
process.env.INTERNAL_SERVICE_TENANTS = 't_a';
const res = await middleware(
req(SCOPED_ROUTE, {
'x-internal-token': INTERNAL_TOKEN,
'x-0pt-internal-tenant': 't_victim',
}),
);
expect(forwarded(res, 'x-0pt-verified-tenant')).toBe('t_a');
});
it('STRIPS a forged x-0pt-verified-tenant and forged scope header', async () => {
process.env.INTERNAL_SERVICE_TENANTS = 't_a';
const res = await middleware(
req(SCOPED_ROUTE, {
'x-internal-token': INTERNAL_TOKEN,
'x-0pt-verified-tenant': 't_victim',
'x-0pt-verified-sub': 'user_victim',
'x-0pt-verified-service-tenants': 't_victim',
}),
);
expect(forwarded(res, 'x-0pt-verified-tenant')).toBe('t_a');
expect(forwarded(res, 'x-0pt-verified-sub')).toBeNull();
expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBe('t_a');
});
it('strips forged verified headers on the unauthenticated development path too', async () => {
process.env.AUTH_ENFORCE = 'false';
const res = await middleware(req('/api/0pt/documents/capture', { 'x-0pt-verified-tenant': 't_victim' }));
expect(forwarded(res, 'x-0pt-verified-tenant')).toBeNull();
});
it('an invalid internal token still falls through to 401', async () => {
const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': 'wrong' }));
expect(res.status).toBe(401);
});
});
describe('assertTenantMatchesVerified: service scope is enforced fail-closed', () => {
it('no service scope header behaves exactly as before (Bearer verified tenant)', () => {
const same = assertTenantMatchesVerified(
guardRequest({ 'x-0pt-verified-tenant': 't_a' }),
't_a',
);
expect(same).toBeNull();
const cross = assertTenantMatchesVerified(
guardRequest({ 'x-0pt-verified-tenant': 't_a' }),
't_victim',
);
expect(cross?.status).toBe(403);
});
it('allows a tenant inside the configured service scope', () => {
const res = assertTenantMatchesVerified(
guardRequest({ [SERVICE_TENANT_SCOPE_HEADER]: 't_a,t_b' }),
't_b',
);
expect(res).toBeNull();
});
it('DENIES a tenant outside the configured service scope (403)', async () => {
const res = assertTenantMatchesVerified(
guardRequest({ [SERVICE_TENANT_SCOPE_HEADER]: 't_a' }),
't_victim',
);
expect(res?.status).toBe(403);
const body = await res!.json();
expect(body.error).toBe('OPT_ACCESS_DENIED');
expect(body.details.service_tenant_scope).toEqual(['t_a']);
});
it('DENIES every tenant when the configured scope is empty (fail closed, even if a verified tenant was also set)', () => {
const res = assertTenantMatchesVerified(
guardRequest({
[SERVICE_TENANT_SCOPE_HEADER]: '',
'x-0pt-verified-tenant': '999999999999999999',
}),
'999999999999999999',
);
expect(res?.status).toBe(403);
});
});
describe('route handlers: internal service cannot write a foreign tenant', () => {
it('canonical facts: cross-tenant write is 403 before the service runs', async () => {
const res = await canonicalPOST(
new NextRequest(new URL(SCOPED_ROUTE, 'http://localhost'), {
method: 'POST',
headers: { 'content-type': 'application/json', [SERVICE_TENANT_SCOPE_HEADER]: 't_a' },
body: JSON.stringify({
tenant_id: 't_victim',
entities: [],
facts: [],
relations: [],
}),
}),
);
expect(res.status).toBe(403);
});
it('quality bridge-run: cross-tenant run is 403 before rules are provisioned', async () => {
const res = await bridgePOST(
new NextRequest(new URL('/api/0pt/quality/bridge-run', 'http://localhost'), {
method: 'POST',
headers: { 'content-type': 'application/json', [SERVICE_TENANT_SCOPE_HEADER]: 't_a' },
body: JSON.stringify({
tenant_id: 't_victim',
project_id: 'p1',
dataset_id: 'ds_1',
rows: [],
}),
}),
);
expect(res.status).toBe(403);
});
it('evidence-outbox dispatch needs no tenant and is unchanged by the scope', async () => {
// No DB in this test env: the route authenticates first, then reports a
// dependency error -- anything except 401/403 proves the scope did not
// turn a tenant-less machine route into a denial.
const previous = process.env.INTERNAL_TOKEN;
process.env.INTERNAL_TOKEN = INTERNAL_TOKEN;
try {
const res = await evidenceDispatchPOST(
new NextRequest(new URL('/api/0pt/internal/evidence-outbox/dispatch', 'http://localhost'), {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-internal-token': INTERNAL_TOKEN,
[SERVICE_TENANT_SCOPE_HEADER]: '',
},
body: JSON.stringify({ limit: 1 }),
}),
);
expect([401, 403]).not.toContain(res.status);
} finally {
if (previous === undefined) delete process.env.INTERNAL_TOKEN;
else process.env.INTERNAL_TOKEN = previous;
}
});
});
+10 -5
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { operationFingerprint, operationIdentity, runIdempotentOperation } from "../../packages/siax-access/src/idempotency";
import {
operationFingerprint,
operationIdentity,
runIdempotentOperation,
type IdempotencyStore,
} from "../../packages/siax-access/src/idempotency";
describe("@siax/access universal idempotency", () => {
it("binds every result-affecting input into the fingerprint", () => {
@@ -18,7 +23,7 @@ describe("@siax/access universal idempotency", () => {
});
it("replays a stored receipt verbatim on the exact fingerprint", async () => {
const stored = { x: 1 };
const store: any = {
const store: IdempotencyStore<unknown, { x: number }> = {
start: async () => false,
save: async () => {},
receipt: async () => stored,
@@ -32,7 +37,7 @@ describe("@siax/access universal idempotency", () => {
});
it("claims once and saves only after the dispatch", async () => {
const order: string[] = [];
const store: any = {
const store: IdempotencyStore<unknown, { x: number }> = {
start: async () => { order.push("start"); return true; },
save: async () => { order.push("save"); },
receipt: async () => { order.push("receipt"); return undefined; },
@@ -47,7 +52,7 @@ describe("@siax/access universal idempotency", () => {
expect(order).toEqual(["start", "dispatch", "save"]);
});
it("reports a store error as conflict, never as a fresh start", async () => {
const store: any = {
const store: IdempotencyStore<unknown, { x: number }> = {
start: async () => { throw new Error("db down"); },
save: async () => {},
receipt: async () => { throw new Error("db down"); },
@@ -61,7 +66,7 @@ describe("@siax/access universal idempotency", () => {
expect(dispatched).toBe(0);
});
it("does not rerun the dispatch when start succeeds but save later fails", async () => {
const store: any = { start: async () => true, save: async () => { throw new Error("journal write failed"); }, receipt: async () => undefined };
const store: IdempotencyStore<unknown, { x: number }> = { start: async () => true, save: async () => { throw new Error("journal write failed"); }, receipt: async () => undefined };
let dispatched = 0;
const outcome = await runIdempotentOperation({
store, identity: {}, fingerprint: "fp",