207 lines
9.2 KiB
Markdown
207 lines
9.2 KiB
Markdown
|
|
# 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 '["<proofedmesh-admin-group-id>"]' \
|
||
|
|
https://auth.basicstack.de/api/oidc/clients/<client-id>/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/<client-id>/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-<mode>`. 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:<git-sha>
|
||
|
|
harbor.basicstack.de/basicstack/approval-service:main (floating)
|
||
|
|
```
|
||
|
|
|
||
|
|
The Deployment currently references `:main`; a GitOps apply will pin it to a
|
||
|
|
`:<git-sha>` once the first image build has run.
|