approval-service: K8s deployment manifests + Pocket-ID OIDC client (DEV-614)
All checks were successful
build-images / signing-service image (push) Successful in 26s
build-images / verification-service image (push) Successful in 27s
build-images / approval-service image (push) Successful in 31s
build-images / dummy-sink image (push) Successful in 29s
validate / JSON Schema (ajv) (push) Successful in 23s
validate / Rego (opa fmt + opa test) (push) Successful in 16s
phase1-smoke / Phase 1 end-to-end smoke (push) Successful in 1m0s
validate / DB (migrate up/down dry-run) (push) Successful in 26s
validate / Go (vet + test) (push) Successful in 1m20s

Phase-1 Subtask 8/10 (parent DEV-606):
- deploy/approval-service/: SA (no auto-mount), Deployment (replicas=2,
  distroless-nonroot, read-only rootfs), ClusterIP :8080, NetworkPolicy
  (ingress from pangolin namespace only; egress to signing-service,
  transparency-log, trust-pg in agent-trust + pocket-id + DNS), README.
- 10-secret-dev.example.yaml template + the real 10-secret-dev.sealed.yaml
  carrying the Pocket-ID client id/secret, WebAuthn RP config, and the
  dev signing-service bearer; unsealed in-cluster to Secret
  approval-service-dev.
- services/approval-service/Dockerfile: BuildKit multi-stage, context-at-
  repo-root, matches the signing/verification/dummy-sink pattern.
- .forgejo/workflows/build-images.yml: new approval-service job pushing
  harbor.basicstack.de/basicstack/approval-service:{sha,main} on main.
- Pocket-ID: created proofedmesh-admin group + OIDC client
  "ProofedMesh Approval" with PKCE enforced, callback
  https://approval.proofedmesh.basicstack.de/oidc/callback, group
  restriction to proofedmesh-admin. Client secret stored as Paperclip
  secret proposal and in the SealedSecret above — never pasted inline.

Deployment applied cluster-side pending the first CI image build:
`kubectl apply -f deploy/approval-service/` is green; pods ErrImagePull
until the Forgejo workflow completes and pushes the :main tag.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
CTO (Paperclip) 2026-10-25 21:12:01 +00:00
parent 5d2bf73d84
commit 20ba29d3ac
9 changed files with 612 additions and 0 deletions

View file

@ -170,6 +170,74 @@ jobs:
docker push "${{ steps.tags.outputs.image_sha }}" docker push "${{ steps.tags.outputs.image_sha }}"
docker push "${{ steps.tags.outputs.image_main }}" docker push "${{ steps.tags.outputs.image_main }}"
approval-service:
name: approval-service image
runs-on: ubuntu-latest
container:
image: debian:trixie-slim
options: --user 0:0
env:
REGISTRY: harbor.basicstack.de
IMAGE_REPO: harbor.basicstack.de/basicstack/approval-service
DOCKER_BUILDKIT: "1"
steps:
- name: Install git + buildx client + ca-certs
run: |
set -euo pipefail
apt-get update
apt-get install -y --no-install-recommends \
git curl ca-certificates docker.io docker-cli docker-buildx
git --version
docker --version
docker buildx 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: Resolve tags
id: tags
working-directory: ${{ github.workspace }}
run: |
set -euo pipefail
SHA="$(git rev-parse --short HEAD)"
echo "sha=${SHA}" >> "${GITHUB_OUTPUT}"
echo "image_sha=${IMAGE_REPO}:${SHA}" >> "${GITHUB_OUTPUT}"
echo "image_main=${IMAGE_REPO}:main" >> "${GITHUB_OUTPUT}"
- name: Build image
working-directory: ${{ github.workspace }}
run: |
set -euo pipefail
docker buildx build \
--load \
-f services/approval-service/Dockerfile \
-t "${{ steps.tags.outputs.image_sha }}" \
-t "${{ steps.tags.outputs.image_main }}" \
.
- name: Login to Harbor (push jobs only)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
HARBOR_USERNAME: ${{ secrets.HARBOR_USERNAME }}
HARBOR_PASSWORD: ${{ secrets.HARBOR_PASSWORD }}
run: |
set -eu
echo "${HARBOR_PASSWORD}" | \
docker login "${REGISTRY}" --username "${HARBOR_USERNAME}" --password-stdin
- name: Push image (main only)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
set -eu
docker push "${{ steps.tags.outputs.image_sha }}"
docker push "${{ steps.tags.outputs.image_main }}"
dummy-sink: dummy-sink:
name: dummy-sink image name: dummy-sink image
runs-on: ubuntu-latest runs-on: ubuntu-latest

View file

@ -0,0 +1,13 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: approval-service
namespace: agent-trust
labels:
app.kubernetes.io/name: approval-service
app.kubernetes.io/part-of: proofedmesh
app.kubernetes.io/component: trust-core
# The approval-service does not call the Kubernetes API; it talks only to
# signing-service, transparency-log, trust-pg, and Pocket-ID. Opt the SA
# out of auto-mounting since no in-pod kube client is used.
automountServiceAccountToken: false

View file

@ -0,0 +1,32 @@
# approval-service dev secret — EXAMPLE ONLY.
#
# This file is a template. The real cluster secret MUST be created either
# as a SealedSecret (preferred, see deploy/approval-service/README.md) or
# applied imperatively with values that never land in the git repo.
#
# Secret shape:
# APPROVAL_SIGNING_TOKEN — Bearer token the approval-service presents
# to the signing-service (dev: match the
# `SIGNING_BEARERS` entry on signing-service)
# APPROVAL_POCKET_ID_CLIENT — Pocket-ID OIDC client ID (returned by the
# client registration in README)
# APPROVAL_POCKET_ID_SECRET — Pocket-ID OIDC client secret
# APPROVAL_WEBAUTHN_RP_ID — relying party ID shown to the browser
# APPROVAL_WEBAUTHN_RP_ORIGIN — allowed origin for client_data_json
#
apiVersion: v1
kind: Secret
metadata:
name: approval-service-dev
namespace: agent-trust
labels:
app.kubernetes.io/name: approval-service
app.kubernetes.io/part-of: proofedmesh
proofedmesh.basicstack.de/mode: dev
type: Opaque
stringData:
APPROVAL_SIGNING_TOKEN: "REPLACE_WITH_SIGNING_SERVICE_BEARER"
APPROVAL_POCKET_ID_CLIENT: "REPLACE_WITH_POCKET_ID_CLIENT_ID"
APPROVAL_POCKET_ID_SECRET: "REPLACE_WITH_POCKET_ID_CLIENT_SECRET"
APPROVAL_WEBAUTHN_RP_ID: "approval.proofedmesh.basicstack.de"
APPROVAL_WEBAUTHN_RP_ORIGIN: "https://approval.proofedmesh.basicstack.de"

View file

@ -0,0 +1,19 @@
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
creationTimestamp: null
name: approval-service-dev
namespace: agent-trust
spec:
encryptedData:
APPROVAL_POCKET_ID_CLIENT: AgCOz00SToarJgnN2+ig0CB/Pk2nlPu9GJx7PtDXiWwPCeZ08O7jf+uMRV/HZxAJ8zS3UCKe7oW+x0/v0eY/oSbGk2FL0aYeNb0+Q6ZNBfO52DPzJzd0AqGenAqbR7eN/rkrZIGFQyI2gAtQPCwEYMW9tIdR99tH/OiN3gh6NMV1VHiSz2nrpBLySct5K13OHjIa559nxhgbplnolXjFMEfTKoJUWTG5e0M42uBvg3OGMMDR1rAzw30e7Oc/O5WvB3dZbYps/qQzMooQdlm0rlxCmZdgddIKmDz2qM55qPql48F4Ehwi+H4uIqnjlEMfX9zS8F5SnIqIwVMUOmlon2i7vuWPyK/ndXIVAg/zKUnbAy22lwP3/38gAfLuq8p7d7kUoPyVGTjQTGdHeRr0j3xaMULB5OGGRE4pSZcc9IWmz08yUIAQqamqRavvcGMMjS7alUMUA/710b5S2mFNdjHhr0rfX3JZdFZjTR988J7fqIuzeCF6OK8TbPKmcAH/f1Zup0uxek60enZ0qDA1A9LXej8fjXEZoKaVENr+S29vwhez0hwSvXWBWCd0ClH8/PFzhDye9/aP+myMUElJPqbSDyrnwJL3T+709jUsgyok6k0vYfi7xZbpyNw0ozff7sL43Loim7DTLrMeCFFwXd5z8lmC/VG7LTudSXXgJ//852I8vHGoJIcpU55qJNBhf+AWOVVy+Zl+q27pF7e3ZVyQNI7iQN6knWrqJ8E1TnaA80xvtKw=
APPROVAL_POCKET_ID_SECRET: AgAnKttGUyDZ1BWt5VReGPFyzlwMYcQdwUyvjUP+cM9U3M9YpizOGcm+8UHFc3cizBwNoS6Sr1WgrNJebq0DuQNWqki/egNn3fhS/RDDIdCei+u9i/e5dns1u+LxBDvNbeBCFii8rQEBc7vyFtxFnKLrPLca7S+Ij4cqZilzgr0oo56yhsvAC1gDtXOgj4KqfEKmKP6Z4q+6YGCuRY69jCLm6kojhXsOBSuJTxYA8mi7qnDlH52FcMidMcyF0HxYiVqDl85shKb2fozGIxQLt/FFQZWBzP7EZPCEvK1+ZCN/k2ttVmfbByqJaUlmRpY8jS+btfTzavJYbApMLZNaw91MyUaeC1NfqkqUqv86NfaZFrOOwrZiUbF32LAMLcXUdH/KHokZdNgB0nan7971ZxM/gQ4m+tQpFovqmIDiA/acic8wVW64AtbQ4yC1q7lMzFis773V7ARn91dYg5/6vuBrzhITWYpHSyNbrIrW1q3ffB9tz/cFVBY0TLEmPlun3lmy7muzMTsR7aYp14hzRfeN0zSMEBzTqsbsL7ZlJk8poxIpHgRmbiJE0pAWK7+JIW2X+fylnfrIMEdHB/HtfPomAq8XpSGXfmFvyNcbQmPnKDnGDz7mF6n+K9g8on8TnPiHO/dig1TQ4eERbEOJRt5PEM+1/zJyhAoJcg3iJZqaW/wVXqisFrT11C3zwZLXZm+PbkJuJTnBbVWg8n5jI2k4Nv85PoD9QqhGpZsi6U4zBg==
APPROVAL_SIGNING_TOKEN: AgCLoX/iq/XtDmCU0Fv3EIRKdVl3CELcNq4REz3hQJmMywFiAZpFnv1KGACjHvPOKoAwQdliwj0SrUXI1L6odVGCRVZtAratkujniYVSXQROTeYeUsqfC5MqBJivPT9D/7LCxfbscErilYQbedHaT+2OS0RL8gt96hVYbOfuRfpm5zGTZa02YeEpgHMnsd8huuSsyuhCnopnmQkR26qLNWfzMC4AQmu0sj+UrStoMga/zMEPem5HZVDfP3YdustuqCm49DzWGxJKizielN/OyLYdpeFWYZHeReFgniD+K4qL4aZTGGxwpxx+GUDLlVMq8C6uVJTahGx4tJal05kg7R/RL2kbN6ZWa+fAXG+a6cX6ne8NpVlsRDxKVge29zl4HreB9HmdPSe9WLws1eLa2UQPBLRDBQXXW+hDw94V0ohQr30HwODCQu832SwCNA/4BDOnhcyMPrhtU/YGS4gFZU6jd89Y70ovxoj9YiZUfcuTbYU8OApYPXOza8rAElesJVL44d0yevj0JM7Xh1/FuEATjbCRkUKT1LKRMnYr9pxMMbqZKa73av7k1i76mxUDOZPfeHPseWt6LsA3P76Kb53MQtaOaJR3Rp5Ex3qyuLU6frM0TGQIrRXqYkHxinzXxf48Sty6lKu4VuP8AbWkPHH8O5OMk2XQNNjuMF5zc1LeStY6gl8uPe2j3abfvIxSm21rfRJBIhypwoiYLlV9++RSrP1mQ10d3QWhB48uY0GfJWoFS6WVBTb85Apve+0cA/i1zCcCytifGdyskXM5BYa4
APPROVAL_WEBAUTHN_RP_ID: AgAMCOBsvVGWBGDCf95hGeg/cLMiDVL9wnWipG1vjQHZxSiZJaHLV+0L9Tp70Ntha7/P552G9BwCz06XYibTILLHXmTO/RNN4am8tTE3dj3ktWotgNJBMBsrrOpE6ch6KHAZVZHwCwcUc4VwwjohutbtMN9iJuvUltaLQSwVA5CAkKHom56Rj2E5+ycSFgAdG0vjfhqpYWsdj+/XJum+w6p5Pe/QbfkKfr8f3nHWtoDbSLpeVp1G9NzFKF8ylTKi9yKYzPm27DGrJKzJ3yPuCI8arblF79v8cydKuLQ7+7WAtAPGz/VImCvpK+HqguwhtKUQdcQFmJrBvyN9msQHkDSAFKE3YyTxprO/2Crq9Az201S1jKoKCb9FlIhryRpdeUxlcCyf3jcBTayTIdiQXfaSily+NRFmgzb/W13qWPF+Zj/LW+ICY2v3S17J7DA1JfgYHqlzu4WjhDix8jAefFIm0+XKjSkTXYbcnCdpGyj+o9i7HXBB+rBkK5WW+Q9fBjKaPHKEXbeTc9/5D8RuNpvt1lDqfvEYsQEnkikWfRZABfIDKKKc+VfaLo9qmledaPLfMjSYG+B9b4oxfMgYwovC3Vj9elo4II5GPiWodHnVoiZvR442qEk8HFDMRRqOFINhJJUZmaAf4QgKdYCao6pGHCBMYCQb+8dSaVoBBzN8vjdAo2hXBKO6ZmbzXPJ39+XHX6Mynvbe1y38+r7cJofJiNPN8XmNi2WQdloI0t+zVD6b
APPROVAL_WEBAUTHN_RP_ORIGIN: AgBwTEgNGOmO+/Dai5/Ul/zOPj3/JwLvQy3Uu7TmvvowvjG4d5fwXzX3ROOGwgM4CwcjYrjKCBv6QvEykAAjusqcqzmwuuF5dlQd6PE0PaXEi+qwxo15G0MPbwX4HyC8uLQW/X9wE3W2yUdYPicA6BUg27K3TUTaocTEFL3kiKbN0CYSYeZHcyZMrnytu+e1dfHF3Td3Cbzs6wO1c41KkXw9SKfiCQh+QdB94d19/pB148vcHFVeIKqm50FSUJEjwCvm09yki4QajSjleyz5KL+6xql/xFreN1660uryLjGuQaNVKcVx6Kmpq9xXeMbcx2peNex+JnMOGmVQyYKW9U7BdvZDsMhv0alJNm4q9sy+cn195YJ8rdIfXoQpPVwwXUFTGWIckpGtIdrSyDfj6rIVTt44GSI6Bnb99gKiYKLx6BUF9ElMFPdxeQEP6bxZ0m4aZJrD/LEbZ6T14d1oaWWhWNmsZyQ3BQ8IPXn/RfWmDMxQXshPdjhcx6p3Z9SLSCslRXl4AKfJ541u2vXHMaFGi4tx3t3qGXYssTQwaZqbNg8H33A+m8SpAgmarjZr39cZrxdYpuxFrURz2OiLX0V3POQvUTG5H3J8f9fcBwWCye6DCcpLxILRtnScZ9qtM2lLKe2QsoqVvlIDfOJzeieTztjEoegQen9xrNzitif75CQkyVSPNpmySVGVgPP0ePnAT/LL8XwlwaQr1UC7g5Mn7gffMphlJEfS0d5JJozs2ilDPslvVHwE3Xw=
template:
metadata:
creationTimestamp: null
name: approval-service-dev
namespace: agent-trust
---

View file

@ -0,0 +1,120 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: approval-service
namespace: agent-trust
labels:
app.kubernetes.io/name: approval-service
app.kubernetes.io/part-of: proofedmesh
app.kubernetes.io/component: trust-core
spec:
replicas: 2
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app.kubernetes.io/name: approval-service
template:
metadata:
labels:
app.kubernetes.io/name: approval-service
app.kubernetes.io/part-of: proofedmesh
app.kubernetes.io/component: trust-core
spec:
serviceAccountName: approval-service
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: approval-service
image: harbor.basicstack.de/basicstack/approval-service:main
imagePullPolicy: IfNotPresent
args:
- "-listen=:8080"
- "-signing-url=http://signing-service.agent-trust.svc.cluster.local:8081"
- "-signing-key-ref=trust-api/approval-signer-2026-q4"
- "-tlog-url=http://transparency-log.agent-trust.svc.cluster.local:8080"
- "-session-ttl=15m"
- "-challenge-ttl=2m"
env:
# ---- Pocket-ID OIDC issuer for attestation `issuer` field +
# future auth middleware discovery (DEV-614). ----
- name: APPROVAL_POCKET_ID_ISSUER
value: "https://auth.basicstack.de"
- name: APPROVAL_POCKET_ID_CLIENT
valueFrom:
secretKeyRef:
name: approval-service-dev
key: APPROVAL_POCKET_ID_CLIENT
# ---- Pocket-ID client secret (OIDC authorization_code +
# client_credentials; Phase 2 middleware consumes it). ----
- name: APPROVAL_POCKET_ID_SECRET
valueFrom:
secretKeyRef:
name: approval-service-dev
key: APPROVAL_POCKET_ID_SECRET
# ---- WebAuthn RP config (DEV-608): must match the browser
# origin reached via Pangolin/Traefik. ----
- name: APPROVAL_WEBAUTHN_RP_ID
valueFrom:
secretKeyRef:
name: approval-service-dev
key: APPROVAL_WEBAUTHN_RP_ID
- name: APPROVAL_WEBAUTHN_RP_ORIGIN
valueFrom:
secretKeyRef:
name: approval-service-dev
key: APPROVAL_WEBAUTHN_RP_ORIGIN
# ---- Bearer the approval-service presents to signing-service. ----
- name: APPROVAL_SIGNING_TOKEN
valueFrom:
secretKeyRef:
name: approval-service-dev
key: APPROVAL_SIGNING_TOKEN
ports:
- name: http
containerPort: 8080
protocol: TCP
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 2
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 10
periodSeconds: 20
failureThreshold: 3
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: approval-service

View file

@ -0,0 +1,18 @@
apiVersion: v1
kind: Service
metadata:
name: approval-service
namespace: agent-trust
labels:
app.kubernetes.io/name: approval-service
app.kubernetes.io/part-of: proofedmesh
app.kubernetes.io/component: trust-core
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: approval-service
ports:
- name: http
port: 8080
targetPort: http
protocol: TCP

View file

@ -0,0 +1,98 @@
# NetworkPolicy for approval-service.
#
# Cluster-wide NetworkPolicy enforcement is already active on BasicStack
# (see cluster memory on NetworkPolicy activation). Any pod in agent-trust
# without a policy is isolated by default; this file is the explicit
# allow-list for approval-service.
#
# Ingress: only from the Pangolin ingress edge (public traffic terminates
# at Pangolin/Traefik; nothing in-cluster calls approval-service
# directly in Phase 1).
# Egress: signing-service (:8081) + transparency-log (:8080) in
# agent-trust, Pocket-ID (:1411) for OIDC discovery/JWKS, DNS, and (once
# trust-pg lands) a Postgres :5432 egress to trust-pg in agent-trust.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: approval-service
namespace: agent-trust
labels:
app.kubernetes.io/name: approval-service
app.kubernetes.io/part-of: proofedmesh
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: approval-service
policyTypes:
- Ingress
- Egress
ingress:
# Public traffic reaches approval-service only via the Pangolin edge
# (Traefik lives inside the Pangolin namespace). Keep this list
# conservative — add additional callers on demand, not speculatively.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: pangolin
ports:
- port: 8080
protocol: TCP
egress:
# signing-service (ADR-0005 signer) + transparency-log (Trillian frontend)
# live in the same agent-trust namespace; future trust-pg will too.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: agent-trust
podSelector:
matchLabels:
app.kubernetes.io/name: signing-service
ports:
- port: 8081
protocol: TCP
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: agent-trust
podSelector:
matchLabels:
app.kubernetes.io/name: transparency-log
ports:
- port: 8080
protocol: TCP
# Future trust-pg Postgres egress — pre-authorised here so the pod
# does not need a NetworkPolicy edit when DEV-607's prod store lands.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: agent-trust
podSelector:
matchLabels:
app.kubernetes.io/name: trust-pg
ports:
- port: 5432
protocol: TCP
# Pocket-ID for OIDC discovery + token endpoint.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: pocket-id
podSelector:
matchLabels:
app: pocket-id
ports:
- port: 1411
protocol: TCP
# DNS egress to kube-system CoreDNS.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP

View file

@ -0,0 +1,206 @@
# 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.

View file

@ -0,0 +1,38 @@
# syntax=docker/dockerfile:1.7
# ---------- build ----------
FROM golang:1.23-alpine3.21 AS build
ENV CGO_ENABLED=0 \
GOOS=linux \
GOFLAGS="-trimpath"
WORKDIR /src
# Pre-cache module metadata before copying sources. approval-service is
# currently stdlib-only (no go.sum), but `go mod download` on a sum-less
# module is a no-op and keeps the layer stable if a go.sum is added later.
COPY services/approval-service/go.mod ./services/approval-service/
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
cd services/approval-service && go mod download
COPY services/approval-service/ ./services/approval-service/
WORKDIR /src/services/approval-service
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
go vet ./... && \
go test ./... && \
go build -ldflags="-s -w" -o /out/approval-service ./cmd/approval-service
# ---------- runtime ----------
# Distroless static+nonroot: no shell, no package manager, UID/GID 65532.
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/approval-service /approval-service
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/approval-service"]
CMD ["-listen=:8080"]