Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8741169259 |
@@ -1,47 +0,0 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,78 @@
|
||||
# 0pt workload identity — målbild, nuläge och övergång
|
||||
|
||||
Status: **design klar, provisionering blockerad** (2026-11-07).
|
||||
Ägare: CL0UD (identitet) / 0pt (konsument).
|
||||
|
||||
## 1. Nuläge
|
||||
|
||||
0pt autentiserar maskin-till-maskin med en **delad hemlighet**:
|
||||
|
||||
- `X-Internal-Token` mot `INTERNAL_TOKEN` (samma värde i engine och edge).
|
||||
- Tenant-auktoritet kommer från serverkonfigurationen `INTERNAL_SERVICE_TENANTS`
|
||||
(se `src/auth/internal-service-tenant.ts`), aldrig från en klientheader.
|
||||
- Tom allowlist ⇒ ingen tenant-auktoritet ⇒ tenant-scopade skrivningar 403.
|
||||
|
||||
Det här är säkert men grovkornigt: en enda hemlighet representerar hela
|
||||
engine-processen, och varje ny konsument kräver en manuell allowlist-post.
|
||||
|
||||
## 2. Målbild (CL0UD workload identity)
|
||||
|
||||
CL0UD äger redan maskineriet:
|
||||
|
||||
- `POST /account/service-accounts` skapar en service account (identitet).
|
||||
- `POST /account/service-accounts/{id}/workload-token` mints en **kortlivad**
|
||||
(10 min default, 1 h max) workload-token vars `capabilities` är en delmängd av
|
||||
service accountens `platform.cl0ud_service_account_scopes.allowedCapabilities`.
|
||||
- `@siax/platform-core` `auth/workload.ts` är verifieringsadaptern; publika
|
||||
nycklar via JWKS.
|
||||
- `packages/capability-registry/src/workload-identities.ts` deklarerar
|
||||
arbetsbelastningsnamn och `aud:*`-audiences. Modulen är uttryckligen **inte**
|
||||
provisionerad och **inte** inkopplad i utfärdande eller verifiering än.
|
||||
|
||||
För 0pt innebär det:
|
||||
|
||||
| Egenskap | Idag | Mål |
|
||||
|---|---|---|
|
||||
| Credential | delad `INTERNAL_TOKEN` | CL0UD-workload-token, `aud:0pt`, 10 min |
|
||||
| Tenant | serverkonfigurerad allowlist | `tenant_id`-claim i verifierad token |
|
||||
| Capabilities | route-lista i middleware | `capabilities`-claim (t.ex. `0pt.canonical.write`) |
|
||||
| Rotation | manuell | automatisk (kortlivad token) |
|
||||
| Spårbarhet | delad hemlighet | per-arbetsbelastning (`0pt-engine`, `site0-pipeline`) |
|
||||
|
||||
## 3. Integrationsdesign (när provisionering är möjlig)
|
||||
|
||||
1. **Verifiering före route-logik.** Middleware accepterar
|
||||
`Authorization: Bearer <workload-token>` för `INTERNAL_SERVICE_ROUTES` om
|
||||
signaturen verifieras mot CL0UD JWKS, `aud` innehåller `0pt` och `exp` är
|
||||
giltig. Ingen klientheader får bidra till tenant.
|
||||
2. **Tenant från claim, inte från allowlist.** `tenant_id` i den verifierade
|
||||
tokenen sätts som `x-0pt-verified-tenant`; `INTERNAL_SERVICE_TENANTS`
|
||||
används endast för legacy `X-Internal-Token`.
|
||||
3. **Capabilities per route.** Route-metadata (`0pt.canonical.write`,
|
||||
`0pt.quality.run`) jämförs med tokenens `capabilities`. Saknad capability ⇒
|
||||
403, inte 401 (identiteten är giltig, behörigheten räcker inte).
|
||||
4. **Dual-accept under övergång.** Båda credentialtyperna accepteras tills
|
||||
engine och site0-pipeline mintar workload-tokens; därefter tas
|
||||
`X-Internal-Token` bort i en egen release med negativt test.
|
||||
5. **Negativa tester i samma svit som idag**
|
||||
(`tests/auth/internal-token-tenant-scope.test.ts`): fel `aud` ⇒ 401, utgången
|
||||
token ⇒ 401, tenant utanför claim ⇒ 403, saknad capability ⇒ 403.
|
||||
|
||||
## 4. Blockerare
|
||||
|
||||
- `/account/service-accounts` svarar **307 → /login** utan operator-session;
|
||||
ingen giltig `SIAX_TOKEN` finns i Infisical-miljön.
|
||||
- Zitadel admin-PAT (`/opt/siax-access/zitadel-admin-pat`) är invalid
|
||||
(AUTH-7fs1e), så nya machine users kan inte mintas via det flödet.
|
||||
- `workload-identities.ts` är deklaration-only: ingen identitet är
|
||||
`provisioned: true`.
|
||||
|
||||
Åtgärd kräver en operatörsinloggning i CL0UD-konsolen (eller en ny giltig
|
||||
admin-PAT) och är därför **EXTERNALLY_BLOCKED** från agentsidan.
|
||||
|
||||
## 5. Vad som inte ska göras
|
||||
|
||||
- Ingen egen JWT-signering i 0pt.
|
||||
- Ingen parallell identitetsplattform.
|
||||
- Ingen klientheader som bär tenant.
|
||||
- Ingen långlivad delad hemlighet som "tillfällig" lösning utan slutdatum.
|
||||
@@ -13,7 +13,7 @@ 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";
|
||||
TO 0pt_app;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
@@ -30,11 +30,6 @@ 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}`;
|
||||
}
|
||||
@@ -221,11 +216,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: iso(r.observed_at),
|
||||
valid_from: r.valid_from ? iso(r.valid_from) : undefined,
|
||||
valid_to: r.valid_to ? iso(r.valid_to) : 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,
|
||||
confidence: r.confidence === null ? undefined : Number(r.confidence),
|
||||
quality_status: r.quality_status, status: r.status, created_at: iso(r.created_at),
|
||||
quality_status: r.quality_status, status: r.status, created_at: String(r.created_at),
|
||||
})) as CanonicalFact[];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
operationFingerprint,
|
||||
operationIdentity,
|
||||
runIdempotentOperation,
|
||||
type IdempotencyStore,
|
||||
} from "../../packages/siax-access/src/idempotency";
|
||||
import { operationFingerprint, operationIdentity, runIdempotentOperation } from "../../packages/siax-access/src/idempotency";
|
||||
|
||||
describe("@siax/access universal idempotency", () => {
|
||||
it("binds every result-affecting input into the fingerprint", () => {
|
||||
@@ -23,7 +18,7 @@ describe("@siax/access universal idempotency", () => {
|
||||
});
|
||||
it("replays a stored receipt verbatim on the exact fingerprint", async () => {
|
||||
const stored = { x: 1 };
|
||||
const store: IdempotencyStore<unknown, { x: number }> = {
|
||||
const store: any = {
|
||||
start: async () => false,
|
||||
save: async () => {},
|
||||
receipt: async () => stored,
|
||||
@@ -37,7 +32,7 @@ describe("@siax/access universal idempotency", () => {
|
||||
});
|
||||
it("claims once and saves only after the dispatch", async () => {
|
||||
const order: string[] = [];
|
||||
const store: IdempotencyStore<unknown, { x: number }> = {
|
||||
const store: any = {
|
||||
start: async () => { order.push("start"); return true; },
|
||||
save: async () => { order.push("save"); },
|
||||
receipt: async () => { order.push("receipt"); return undefined; },
|
||||
@@ -52,7 +47,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: IdempotencyStore<unknown, { x: number }> = {
|
||||
const store: any = {
|
||||
start: async () => { throw new Error("db down"); },
|
||||
save: async () => {},
|
||||
receipt: async () => { throw new Error("db down"); },
|
||||
@@ -66,7 +61,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: IdempotencyStore<unknown, { x: number }> = { start: async () => true, save: async () => { throw new Error("journal write failed"); }, receipt: async () => undefined };
|
||||
const store: any = { 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",
|
||||
|
||||
Reference in New Issue
Block a user