# approval-service — deployment Kubernetes manifests for the ADR-0005 approval-service in the `agent-trust` namespace (DEV-608 scope: content-binding + WebAuthn-gated approvals; DEV-614 scope: Kubernetes deployment + Pocket-ID OIDC client registration). The namespace itself is created by [`deploy/trillian/00-namespace.yaml`](../trillian/00-namespace.yaml); this directory assumes that file (or an equivalent) is already applied. ## Layout ``` deploy/approval-service/ ├── 00-serviceaccount.yaml — ServiceAccount (no auto-mount, no kube API calls) ├── 10-secret-dev.example.yaml — TEMPLATE for the dev-mode Secret ├── 20-deployment.yaml — Deployment (non-root, read-only rootfs, distroless) ├── 30-service.yaml — ClusterIP Service on :8080 ├── 40-networkpolicy.yaml — ingress from Pangolin, egress to signing/transparency/pocket-id/dns └── README.md — this file ``` ## Prod wiring (DEV-614) The Deployment reads the following prod flags via env: | Flag | Env | Purpose | | ------------------------------ | ----------------------------------- | -------------------------------------------------------------------------------------------- | | `-signing-url` | `APPROVAL_SIGNING_URL` | signing-service base URL (ADR-0005 DSSE signer). | | `-signing-key-ref` | `APPROVAL_SIGNING_KEY_REF` | Key reference used when signing approval attestations. | | `-tlog-url` | `APPROVAL_TLOG_URL` | transparency-log base URL (Trillian frontend). | | `-session-ttl` | `APPROVAL_SESSION_TTL` | Approval-session lifetime. | | `-challenge-ttl` | `APPROVAL_CHALLENGE_TTL` | Max. time between preview and confirm. | | (env only) | `APPROVAL_POCKET_ID_ISSUER` | Pocket-ID OIDC issuer, embedded as the attestation `issuer` field (Phase 2 auth discovery). | | (env only) | `APPROVAL_POCKET_ID_CLIENT` | Pocket-ID OIDC client ID (DEV-614 registration output). | | (env only) | `APPROVAL_POCKET_ID_SECRET` | Pocket-ID OIDC client secret (Phase 2 middleware consumes it). | | (env only) | `APPROVAL_WEBAUTHN_RP_ID` | WebAuthn relying party ID (public hostname, no scheme). | | (env only) | `APPROVAL_WEBAUTHN_RP_ORIGIN` | WebAuthn allowed origin (scheme + host, no path). | | (env only) | `APPROVAL_SIGNING_TOKEN` | Bearer token presented to signing-service (dev mode). | The Pocket-ID client ID/secret, the WebAuthn RP config, and the signing-service bearer live in **one Secret** `approval-service-dev` so a SealedSecret rotation flow stays uniform. ## Apply order ```bash kubectl apply -f deploy/trillian/00-namespace.yaml # if not yet applied kubectl apply -f deploy/approval-service/00-serviceaccount.yaml # Create the dev Secret separately (see "Secret convention" below) — DO NOT # commit the real values. The .example file is a template, not a secret. kubectl apply -f deploy/approval-service/20-deployment.yaml kubectl apply -f deploy/approval-service/30-service.yaml kubectl apply -f deploy/approval-service/40-networkpolicy.yaml ``` Reach the service in-cluster at: ``` http://approval-service.agent-trust.svc.cluster.local:8080 ``` Health check (also used by Deployment probes): ``` GET /healthz → 200 {"status":"ok"} ``` Approval API (per ADR-0005 §4.3): ``` POST /api/v1/sessions POST /api/v1/approvals/preview POST /api/v1/approvals/confirm ``` Public ingress (`https://approval.proofedmesh.basicstack.de`) is terminated at the Pangolin/Traefik edge and routed to the cluster Service; the IngressRoute itself is managed from the Pangolin controller side and is not included here. ## Pocket-ID OIDC client registration The approval-service authenticates browser users via Pocket-ID (authorization_code with PKCE) and will exchange a client_credentials token with signing-service in Phase 2. One client covers both flows: - **Name**: `ProofedMesh Approval` - **Callback URL**: `https://approval.proofedmesh.basicstack.de/oidc/callback` - **PKCE enabled**: `true` (authorization_code only) - **Group restriction**: `proofedmesh-admin` (members may approve) The client was registered against the production Pocket-ID (`auth.basicstack.de`) via its admin API using the `POCKET_ID_API_KEY` secret (DEV-614). The resulting client ID + secret are stored in the cluster Secret `approval-service-dev` and never committed to git. To re-register from scratch (idempotent via `name` upsert): ```bash curl -sS -X POST \ -H "X-API-Key: $POCKET_ID_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "ProofedMesh Approval", "callbackURLs": ["https://approval.proofedmesh.basicstack.de/oidc/callback"], "isPublic": false, "pkceEnabled": true, "isGroupRestricted": true }' \ https://auth.basicstack.de/api/oidc/clients ``` Allow the `proofedmesh-admin` group on the client: ```bash curl -sS -X PUT \ -H "X-API-Key: $POCKET_ID_API_KEY" \ -H "Content-Type: application/json" \ -d '[""]' \ https://auth.basicstack.de/api/oidc/clients//allowed-user-groups ``` Rotate (or mint the initial) client secret: ```bash curl -sS -X POST \ -H "X-API-Key: $POCKET_ID_API_KEY" \ https://auth.basicstack.de/api/oidc/clients//secret ``` ## K8s secret convention The approval-service never pulls secrets from the pod filesystem or a config map. Every secret value is an **environment variable sourced from one Kubernetes Secret** named `approval-service-`. This keeps the SealedSecret rotation path uniform. ### Dev mode Secret: `approval-service-dev` | Env key | Purpose | | ------------------------------ | ------------------------------------------------------------------- | | `APPROVAL_SIGNING_TOKEN` | Bearer presented to signing-service (dev-static table). | | `APPROVAL_POCKET_ID_CLIENT` | Pocket-ID OIDC client ID (returned by the registration above). | | `APPROVAL_POCKET_ID_SECRET` | Pocket-ID OIDC client secret. | | `APPROVAL_WEBAUTHN_RP_ID` | WebAuthn relying-party ID (public hostname). | | `APPROVAL_WEBAUTHN_RP_ORIGIN` | WebAuthn origin shown to the browser. | Create the Secret for the cluster (never commit the raw value): ```bash kubectl create secret generic approval-service-dev \ -n agent-trust \ --from-literal="APPROVAL_SIGNING_TOKEN=${SIGNING_BEARER}" \ --from-literal="APPROVAL_POCKET_ID_CLIENT=${POCKET_ID_CLIENT}" \ --from-literal="APPROVAL_POCKET_ID_SECRET=${POCKET_ID_SECRET}" \ --from-literal="APPROVAL_WEBAUTHN_RP_ID=approval.proofedmesh.basicstack.de" \ --from-literal="APPROVAL_WEBAUTHN_RP_ORIGIN=https://approval.proofedmesh.basicstack.de" ``` For the GitOps path, convert the above to a SealedSecret with `kubeseal` and commit the sealed form under the same filename as the example: ```bash kubectl create secret generic approval-service-dev -n agent-trust \ --from-literal="APPROVAL_SIGNING_TOKEN=${SIGNING_BEARER}" \ --from-literal="APPROVAL_POCKET_ID_CLIENT=${POCKET_ID_CLIENT}" \ --from-literal="APPROVAL_POCKET_ID_SECRET=${POCKET_ID_SECRET}" \ --from-literal="APPROVAL_WEBAUTHN_RP_ID=approval.proofedmesh.basicstack.de" \ --from-literal="APPROVAL_WEBAUTHN_RP_ORIGIN=https://approval.proofedmesh.basicstack.de" \ --dry-run=client -o yaml | \ kubeseal --controller-namespace=sealed-secrets --format=yaml \ > deploy/approval-service/10-secret-dev.sealed.yaml ``` The `10-secret-dev.example.yaml` file is kept as the shape reference; the real sealed form is `10-secret-dev.sealed.yaml` (not committed until the first real deployment lands). ## Verifying the deployment ```bash # Pod comes up, probe says OK: kubectl -n agent-trust rollout status deploy/approval-service # In-cluster reachability from a throwaway pod: kubectl -n agent-trust run curl --image=curlimages/curl:8 -it --rm --restart=Never -- \ curl -sS http://approval-service:8080/healthz # External reachability through the Pangolin edge: curl -sS https://approval.proofedmesh.basicstack.de/healthz ``` ## Image Images are built by `.forgejo/workflows/build-images.yml` on every push to `main` and pushed to: ``` harbor.basicstack.de/basicstack/approval-service: harbor.basicstack.de/basicstack/approval-service:main (floating) ``` The Deployment currently references `:main`; a GitOps apply will pin it to a `:` once the first image build has run.