From 51733d8b36487a2eedf1a066234e661af3988bc10e8d107a2bcb364a2cd5269d Mon Sep 17 00:00:00 2001 From: "CTO (Paperclip)" Date: Sun, 25 Oct 2026 19:23:20 +0000 Subject: [PATCH] db+ci: Agent Registry schema (agents + agent_keys), Phase-1 seed, migrate up/down CI (DEV-607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 0002_agents / 0003_agent_keys migrations per architecture v1.1 §4.4 (incl. (k8s_namespace, k8s_service_account) uniqueness for the signing service's dual identity check and an agent_id, valid_from index for the verification lookup path) - idempotent seed db/trust/seeds/0001_phase1_agents.sql with the three Phase-1 identities: agent-approval-service, agent-testagent-transform, agent-dummy-sink (namespace agent-trust) - new db CI job in validate.yml: runs postgres:16-alpine, migrate up -> down 2 -> up, applies the seed twice, verifies three agents Co-Authored-By: Paperclip --- .forgejo/workflows/validate.yml | 114 +++++++++++++++++++ db/README.md | 36 ++++-- db/trust/migrations/0002_agents.down.sql | 6 + db/trust/migrations/0002_agents.up.sql | 33 ++++++ db/trust/migrations/0003_agent_keys.down.sql | 7 ++ db/trust/migrations/0003_agent_keys.up.sql | 43 +++++++ db/trust/seeds/0001_phase1_agents.sql | 27 +++++ 7 files changed, 258 insertions(+), 8 deletions(-) create mode 100644 db/trust/migrations/0002_agents.down.sql create mode 100644 db/trust/migrations/0002_agents.up.sql create mode 100644 db/trust/migrations/0003_agent_keys.down.sql create mode 100644 db/trust/migrations/0003_agent_keys.up.sql create mode 100644 db/trust/seeds/0001_phase1_agents.sql diff --git a/.forgejo/workflows/validate.yml b/.forgejo/workflows/validate.yml index 1c2b994..f900acd 100644 --- a/.forgejo/workflows/validate.yml +++ b/.forgejo/workflows/validate.yml @@ -154,3 +154,117 @@ jobs: echo "=== $m ===" (cd "$m" && go vet ./... && go test ./...) done < /tmp/go_modules.txt + + db: + name: DB (migrate up/down dry-run) + runs-on: ubuntu-latest + container: + image: debian:trixie-slim + options: --user 0:0 + env: + # Host-networked job container → localhost = host; use a non-default + # port so parallel jobs do not collide on 5432 if any happen to share + # a runner host. + PGHOST: 127.0.0.1 + PGPORT: "55432" + PGUSER: postgres + PGPASSWORD: postgres + PGDATABASE: trust + DB_URL: postgres://postgres:postgres@127.0.0.1:55432/trust?sslmode=disable + PG_IMAGE: postgres:16-alpine + PG_CONTAINER: pm-ci-pg-${{ github.run_id }}-${{ github.run_attempt }} + steps: + - name: Install git + curl + docker CLI + psql + run: | + set -euo pipefail + apt-get update + apt-get install -y --no-install-recommends \ + git curl ca-certificates docker.io postgresql-client + git --version + docker --version + psql --version + + - name: Clone repository (sha256-aware) + run: | + set -euo pipefail + git clone --depth=1 --branch="${GITHUB_REF_NAME}" \ + "https://x-access-token:${GITHUB_TOKEN}@forgejo.basicstack.de/${GITHUB_REPOSITORY}.git" \ + "${GITHUB_WORKSPACE}" + cd "${GITHUB_WORKSPACE}" + git log -1 --oneline + + - name: Install golang-migrate CLI + run: | + set -euo pipefail + curl -sSL -o /tmp/migrate.tgz \ + https://github.com/golang-migrate/migrate/releases/download/v4.17.1/migrate.linux-amd64.tar.gz + tar -xzf /tmp/migrate.tgz -C /usr/local/bin migrate + migrate -version + + - name: Start Postgres (via docker) + run: | + set -euo pipefail + docker run -d --name "${PG_CONTAINER}" \ + -e POSTGRES_PASSWORD="${PGPASSWORD}" \ + -e POSTGRES_USER="${PGUSER}" \ + -e POSTGRES_DB="${PGDATABASE}" \ + -p "${PGPORT}:5432" \ + "${PG_IMAGE}" + # Wait up to 60s for pg to be ready. + for i in $(seq 1 60); do + if pg_isready -h "${PGHOST}" -p "${PGPORT}" -U "${PGUSER}" -d "${PGDATABASE}" >/dev/null 2>&1; then + echo "postgres ready after ${i}s" + exit 0 + fi + sleep 1 + done + echo "postgres never became ready" >&2 + docker logs "${PG_CONTAINER}" || true + exit 1 + + - name: migrate up (full) + verify tables + working-directory: ${{ github.workspace }} + run: | + set -euo pipefail + migrate -path db/trust/migrations -database "${DB_URL}" up + psql "${DB_URL}" -c '\dt' | tee /tmp/dt-after-up.txt + grep -q ' per_diem_rates ' /tmp/dt-after-up.txt + grep -q ' agents ' /tmp/dt-after-up.txt + grep -q ' agent_keys ' /tmp/dt-after-up.txt + + - name: migrate down 2 + verify only agent tables gone + working-directory: ${{ github.workspace }} + run: | + set -euo pipefail + migrate -path db/trust/migrations -database "${DB_URL}" down 2 + psql "${DB_URL}" -c '\dt' | tee /tmp/dt-after-down.txt + grep -q ' per_diem_rates ' /tmp/dt-after-down.txt + if grep -q ' agents ' /tmp/dt-after-down.txt; then + echo "FAIL: agents still present after migrate down 2" >&2 + exit 1 + fi + if grep -q ' agent_keys ' /tmp/dt-after-down.txt; then + echo "FAIL: agent_keys still present after migrate down 2" >&2 + exit 1 + fi + + - name: migrate up (re-apply) + apply seed + verify three agents + working-directory: ${{ github.workspace }} + run: | + set -euo pipefail + migrate -path db/trust/migrations -database "${DB_URL}" up + psql "${DB_URL}" -v ON_ERROR_STOP=1 -f db/trust/seeds/0001_phase1_agents.sql + # Idempotency: re-run must not error and not duplicate rows. + psql "${DB_URL}" -v ON_ERROR_STOP=1 -f db/trust/seeds/0001_phase1_agents.sql + count=$(psql "${DB_URL}" -At -c \ + "SELECT count(*) FROM agents WHERE agent_id IN ('agent-approval-service','agent-testagent-transform','agent-dummy-sink')") + echo "seeded agents = ${count}" + [ "${count}" = "3" ] + + - name: Postgres logs (always) + if: always() + run: docker logs "${PG_CONTAINER}" || true + + - name: Stop Postgres (always) + if: always() + run: docker rm -f "${PG_CONTAINER}" || true diff --git a/db/README.md b/db/README.md index 3f31997..cada497 100644 --- a/db/README.md +++ b/db/README.md @@ -8,13 +8,26 @@ Alle Postgres-Schemas von ProofedMesh werden mit **[golang-migrate](https://gith db/ ├── README.md — diese Datei ├── trust/ -│ └── migrations/ -│ ├── 0001_per_diem_rates.up.sql -│ └── 0001_per_diem_rates.down.sql +│ ├── migrations/ +│ │ ├── 0001_per_diem_rates.up.sql +│ │ ├── 0001_per_diem_rates.down.sql +│ │ ├── 0002_agents.up.sql +│ │ ├── 0002_agents.down.sql +│ │ ├── 0003_agent_keys.up.sql +│ │ └── 0003_agent_keys.down.sql +│ └── seeds/ +│ └── 0001_phase1_agents.sql └── expense/ └── migrations/ — kommt im Phase-4-Rollout der DB-App ``` +Seeds werden **nicht** von `migrate` versioniert — sie sind idempotentes +Bootstrap-SQL für Phase-1-Fixturen (z.B. die drei Agent-Identitäten) und +werden per `psql -f` oder äquivalentem Kubernetes-Job eingespielt. Jeder +Seed MUSS `ON CONFLICT … DO NOTHING` / `IF NOT EXISTS` verwenden, damit +mehrfaches Einspielen nach einem `migrate down/up`-Zyklus keine Duplikate +produziert. + Jede logische Datenbank bekommt ein eigenes `migrations/`-Verzeichnis. Die Migrationstabelle (`schema_migrations`) wird je Datenbank separat gepflegt. Pro Postgres-Instanz können mehrere logische Schemas liegen — die Migrationen schreiben SQL mit `CREATE SCHEMA IF NOT EXISTS` und `SET search_path`. ## Konventionen @@ -51,9 +64,16 @@ migrate -path db/trust/migrations \ ## CI -Ein eigener CI-Job `db` (noch nicht committed) macht: -1. **Lint** (`sqlfluff` oder `pg_format`). -2. **Dry-run** auf einer wegwerfbaren Postgres-Instanz: `up` → `down` → `up`, muss grün sein. -3. **Keine History-Rewrites:** vorhandene Dateien dürfen in einem PR nicht geändert werden. +Der Job `db` in `.forgejo/workflows/validate.yml` macht pro PR und +`main`-Push: -Kommt in einem separaten Heartbeat. +1. Startet `postgres:16-alpine` als Container. +2. `migrate up` → prüft, dass `per_diem_rates`, `agents` und `agent_keys` + angelegt sind. +3. `migrate down 2` → prüft, dass `agents` und `agent_keys` wieder + entfernt sind, `per_diem_rates` aber bleibt (Trenn-Test). +4. `migrate up` erneut und spielt `db/trust/seeds/0001_phase1_agents.sql` + zweimal ein (Idempotenz-Test, Erwartung: genau 3 Seed-Zeilen). + +Lint (`sqlfluff` / `pg_format`) ist noch nicht aktiviert — kommt in +einem separaten Heartbeat. diff --git a/db/trust/migrations/0002_agents.down.sql b/db/trust/migrations/0002_agents.down.sql new file mode 100644 index 0000000..eef9cd4 --- /dev/null +++ b/db/trust/migrations/0002_agents.down.sql @@ -0,0 +1,6 @@ +-- Rollback for 0002_agents. +-- DESTRUKTIV: entfernt die Agent-Identitäten vollständig. Nur in Prod +-- anwenden, wenn 0003_agent_keys vorher heruntergefahren wurde (FK ins +-- agents(agent_id)) UND ein frischer Dump des trust-Postgres vorliegt. + +DROP TABLE IF EXISTS agents; diff --git a/db/trust/migrations/0002_agents.up.sql b/db/trust/migrations/0002_agents.up.sql new file mode 100644 index 0000000..8c0a6a1 --- /dev/null +++ b/db/trust/migrations/0002_agents.up.sql @@ -0,0 +1,33 @@ +-- Migration: 0002_agents +-- Agent Registry (Identitäten) gemäß docs/architecture/v1.1.md §4.4. +-- +-- Phase 1 nutzt diese Tabelle rein lesend (SELECT) aus Signing Service und +-- Verification Service, um Pocket-ID-Client-IDs und K8s-ServiceAccount-Paare +-- auf stabile agent_ids zu mappen. Eine HTTP-API-Schale folgt in Phase 2. +-- +-- Design-Entscheidungen: +-- * agent_id ist ein stabiler, lesbarer Slug (`agent-`), kein UUID. +-- Er taucht in Attestations (`actor`, `issuer`), DSSE-key_ids, +-- Policy-Dateien und Audit-Logs auf — Lesbarkeit zählt. +-- * (k8s_namespace, k8s_service_account) ist zusätzlich UNIQUE: die +-- doppelte Identitätsprüfung im Signing Service (Pocket-ID + K8s +-- TokenReview) MUSS auf genau einen Agenten mappen. +-- * allowed_actions ist NOT NULL, darf aber eine leere Liste sein; die +-- effektive Autorisierung macht OPA im Signing / Verification Service. + +CREATE TABLE IF NOT EXISTS agents ( + agent_id TEXT PRIMARY KEY + CHECK (agent_id ~ '^[a-z0-9][a-z0-9-]{0,126}[a-z0-9]$'), + pocket_id_client TEXT UNIQUE NOT NULL + CHECK (length(pocket_id_client) BETWEEN 1 AND 256), + k8s_namespace TEXT NOT NULL + CHECK (k8s_namespace ~ '^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$'), + k8s_service_account TEXT NOT NULL + CHECK (k8s_service_account ~ '^[a-z0-9][a-z0-9.-]{0,251}[a-z0-9]$'), + display_name TEXT NOT NULL + CHECK (length(display_name) BETWEEN 1 AND 200), + allowed_actions TEXT[] NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT agents_k8s_identity_unique + UNIQUE (k8s_namespace, k8s_service_account) +); diff --git a/db/trust/migrations/0003_agent_keys.down.sql b/db/trust/migrations/0003_agent_keys.down.sql new file mode 100644 index 0000000..ba00fc8 --- /dev/null +++ b/db/trust/migrations/0003_agent_keys.down.sql @@ -0,0 +1,7 @@ +-- Rollback for 0003_agent_keys. +-- DESTRUKTIV: entfernt die gesamte Schlüsselhistorie aller Agents. Die +-- Signaturen in Trillian bleiben gültig, aber Verification kann sie +-- lokal nicht mehr auflösen. Vor Anwendung IMMER einen Dump des +-- trust-Postgres anfertigen. + +DROP TABLE IF EXISTS agent_keys; diff --git a/db/trust/migrations/0003_agent_keys.up.sql b/db/trust/migrations/0003_agent_keys.up.sql new file mode 100644 index 0000000..411434e --- /dev/null +++ b/db/trust/migrations/0003_agent_keys.up.sql @@ -0,0 +1,43 @@ +-- Migration: 0003_agent_keys +-- Signatur-Schlüsselhistorie je Agent gemäß docs/architecture/v1.1.md §4.4. +-- +-- Design-Entscheidungen (siehe §4.4, TUF-Pattern): +-- * public_key_pem wird als PEM gespeichert, Format (Ed25519/ECDSA/RSA) +-- wird NICHT in der DB erzwungen — die signing-/verification-Services +-- parsen und entscheiden. Rotation Default alle 90 Tage, Retention +-- 1 Jahr → max. ~5 nicht-archivierte Schlüssel pro Agent. +-- * valid_from / valid_to spannen den Autoritätszeitraum. Verifikation +-- alter Attestations wählt den Schlüssel, bei dem issued_at im +-- [valid_from, valid_to] liegt. valid_to ist NOT NULL (bei Ausstellung +-- wird ein geplantes Ablaufdatum gesetzt, auch wenn später rotiert +-- wird). +-- * revoked_at ist eine ausserordentliche Verkürzung (Kompromittierung). +-- Verification muss revoked_at <= issued_at ausschliessen. +-- * archived_at markiert Schlüssel, die weder aktuell noch für Retention +-- verifiziert werden müssen — nur noch Audit-Zeuge. + +CREATE TABLE IF NOT EXISTS agent_keys ( + key_id TEXT PRIMARY KEY + CHECK (length(key_id) BETWEEN 1 AND 256), + agent_id TEXT NOT NULL REFERENCES agents(agent_id), + public_key_pem TEXT NOT NULL + CHECK (position('-----BEGIN ' IN public_key_pem) = 1), + valid_from TIMESTAMPTZ NOT NULL, + valid_to TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + revocation_reason TEXT CHECK (revocation_reason IS NULL + OR length(revocation_reason) BETWEEN 1 AND 500), + archived_at TIMESTAMPTZ, + CONSTRAINT agent_keys_valid_range CHECK (valid_to > valid_from), + CONSTRAINT agent_keys_revocation_consistent + CHECK ((revoked_at IS NULL) = (revocation_reason IS NULL)) +); + +-- Aktive-Schlüssel-Scan je Agent (TUF-Pattern, §4.4). +CREATE INDEX idx_agent_keys_lookup + ON agent_keys (agent_id, archived_at) + WHERE archived_at IS NULL; + +-- Verification-Pfad: finde Schlüssel, der zu einem gegebenen issued_at galt. +CREATE INDEX idx_agent_keys_agent_valid_from + ON agent_keys (agent_id, valid_from); diff --git a/db/trust/seeds/0001_phase1_agents.sql b/db/trust/seeds/0001_phase1_agents.sql new file mode 100644 index 0000000..b0ee7d9 --- /dev/null +++ b/db/trust/seeds/0001_phase1_agents.sql @@ -0,0 +1,27 @@ +-- Seed: Phase-1-Agent-Identitäten (DEV-607). +-- +-- Diese drei Agents sind der minimale Satz für den Phase-1-End-to-End-Smoke: +-- agent-approval-service — signiert Approval-Attestations. +-- agent-testagent-transform — signiert Transform-Attestations. +-- agent-dummy-sink — Mock-Senke (`sink:dummy-sink`), signiert +-- handover-Attestations. +-- +-- Namespace `agent-trust` ist mit deploy/signing-service/ und deploy/trillian/ +-- konsistent. pocket_id_client ist der stabile OIDC-client_id, den Pocket ID +-- für diesen Agent ausstellt — in Phase 1 identisch zum agent_id-Slug, +-- Subtask 7 (Agent-Registry-HTTP-API / Pocket-ID-Provisionierung) kann +-- diesen Wert später per UPDATE angleichen, wenn Pocket ID eine andere +-- client_id erzwingt. +-- +-- Dieser Seed berührt agent_keys NICHT. Die Signatur-Schlüssel landen über +-- die Rotations-Pipeline (`services/signing-service` Admin-Pfad, Phase 2). +-- Idempotent: ON CONFLICT (agent_id) DO NOTHING, damit mehrfaches Einspielen +-- und ein `migrate down 2 && migrate up` den Seed nicht duplizieren. + +INSERT INTO agents + (agent_id, pocket_id_client, k8s_namespace, k8s_service_account, display_name, allowed_actions) +VALUES + ('agent-approval-service', 'agent-approval-service', 'agent-trust', 'approval-service', 'Approval Service', ARRAY['expense.submit']), + ('agent-testagent-transform', 'agent-testagent-transform', 'agent-trust', 'testagent-transform', 'Test Agent (Transform)', ARRAY['expense.normalize', 'expense.map_to_sink']), + ('agent-dummy-sink', 'agent-dummy-sink', 'agent-trust', 'dummy-sink', 'Dummy Sink (sink:dummy-sink)', ARRAY['expense.post']) +ON CONFLICT (agent_id) DO NOTHING;