From 20ba29d3aca308f9fd4b6d50c1cad8094498bc6feac17c8a4996639c3cdc4898 Mon Sep 17 00:00:00 2001 From: "CTO (Paperclip)" Date: Sun, 25 Oct 2026 21:12:01 +0000 Subject: [PATCH] approval-service: K8s deployment manifests + Pocket-ID OIDC client (DEV-614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .forgejo/workflows/build-images.yml | 68 ++++++ .../approval-service/00-serviceaccount.yaml | 13 ++ .../10-secret-dev.example.yaml | 32 +++ .../10-secret-dev.sealed.yaml | 19 ++ deploy/approval-service/20-deployment.yaml | 120 ++++++++++ deploy/approval-service/30-service.yaml | 18 ++ deploy/approval-service/40-networkpolicy.yaml | 98 +++++++++ deploy/approval-service/README.md | 206 ++++++++++++++++++ services/approval-service/Dockerfile | 38 ++++ 9 files changed, 612 insertions(+) create mode 100644 deploy/approval-service/00-serviceaccount.yaml create mode 100644 deploy/approval-service/10-secret-dev.example.yaml create mode 100644 deploy/approval-service/10-secret-dev.sealed.yaml create mode 100644 deploy/approval-service/20-deployment.yaml create mode 100644 deploy/approval-service/30-service.yaml create mode 100644 deploy/approval-service/40-networkpolicy.yaml create mode 100644 deploy/approval-service/README.md create mode 100644 services/approval-service/Dockerfile diff --git a/.forgejo/workflows/build-images.yml b/.forgejo/workflows/build-images.yml index c7fcebb..7831bc5 100644 --- a/.forgejo/workflows/build-images.yml +++ b/.forgejo/workflows/build-images.yml @@ -170,6 +170,74 @@ jobs: docker push "${{ steps.tags.outputs.image_sha }}" 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: name: dummy-sink image runs-on: ubuntu-latest diff --git a/deploy/approval-service/00-serviceaccount.yaml b/deploy/approval-service/00-serviceaccount.yaml new file mode 100644 index 0000000..28399b0 --- /dev/null +++ b/deploy/approval-service/00-serviceaccount.yaml @@ -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 diff --git a/deploy/approval-service/10-secret-dev.example.yaml b/deploy/approval-service/10-secret-dev.example.yaml new file mode 100644 index 0000000..2c59daa --- /dev/null +++ b/deploy/approval-service/10-secret-dev.example.yaml @@ -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" diff --git a/deploy/approval-service/10-secret-dev.sealed.yaml b/deploy/approval-service/10-secret-dev.sealed.yaml new file mode 100644 index 0000000..80b898f --- /dev/null +++ b/deploy/approval-service/10-secret-dev.sealed.yaml @@ -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 +--- diff --git a/deploy/approval-service/20-deployment.yaml b/deploy/approval-service/20-deployment.yaml new file mode 100644 index 0000000..3e9110d --- /dev/null +++ b/deploy/approval-service/20-deployment.yaml @@ -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 diff --git a/deploy/approval-service/30-service.yaml b/deploy/approval-service/30-service.yaml new file mode 100644 index 0000000..5a89deb --- /dev/null +++ b/deploy/approval-service/30-service.yaml @@ -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 diff --git a/deploy/approval-service/40-networkpolicy.yaml b/deploy/approval-service/40-networkpolicy.yaml new file mode 100644 index 0000000..74b12ab --- /dev/null +++ b/deploy/approval-service/40-networkpolicy.yaml @@ -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 diff --git a/deploy/approval-service/README.md b/deploy/approval-service/README.md new file mode 100644 index 0000000..2c8d1d1 --- /dev/null +++ b/deploy/approval-service/README.md @@ -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 '[""]' \ + 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. diff --git a/services/approval-service/Dockerfile b/services/approval-service/Dockerfile new file mode 100644 index 0000000..91f3d23 --- /dev/null +++ b/services/approval-service/Dockerfile @@ -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"]