db+ci: Agent Registry schema (agents + agent_keys), Phase-1 seed, migrate up/down CI (DEV-607)
Some checks failed
build-images / signing-service image (push) Failing after 14s
validate / JSON Schema (ajv) (push) Successful in 19s
validate / Rego (opa fmt + opa test) (push) Successful in 14s
validate / DB (migrate up/down dry-run) (push) Failing after 18s
validate / Go (vet + test) (push) Successful in 34s

- 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 <noreply@paperclip.ing>
This commit is contained in:
CTO (Paperclip) 2026-10-25 19:23:20 +00:00
parent 951f22f80e
commit 51733d8b36
7 changed files with 258 additions and 8 deletions

View file

@ -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

View file

@ -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.

View file

@ -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;

View file

@ -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-<rolle>`), 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)
);

View file

@ -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;

View file

@ -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);

View file

@ -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;