signing-service: Pocket-ID JWKS + K8s TokenReview + OPA (DEV-609)
Wires the signing-service's production path for ProofedMesh Phase 1: the service now performs a double identity check (Pocket-ID OIDC JWT + K8s ServiceAccount TokenReview), reconciles both against the trust-api `agents` registry, and asks an OPA/Rego policy whether the caller may sign with the requested key_ref. Code - internal/token/pocket_id.go: OIDC-Discovery-backed JWKS validator with automatic kid rotation (coreos/go-oidc). - internal/token/k8s_tokenreview.go: hand-rolled TokenReview client; reads X-Workload-Token, posts to authentication.k8s.io/v1 via the pod's in-cluster SA token + CA. - internal/registry: Postgres resolver for the agents table + in-memory Static resolver for tests. - internal/policy/opa.go: OPA/rego StructuredDecider; input carries both lookups so the policy can enforce identity consistency AND the key_ref ↔ role mapping. - internal/httpapi/sign.go: new structured flow when Validator+TokenReviewer+Registry+StructuredPolicy are all wired; dev path (static bearers + AllowAll) remains intact. - cmd/signing-service/main.go: -pocket-id-issuer, -pocket-id-audience, -k8s-tokenreview, -registry-dsn, -policy-bundle flags. Policy - policies/sinks/signing_service.rego: identities_agree + key_ref_matches_role rules; agent → key_ref prefix map covers approval-service, testagent-transform, dummy-sink. Deploy - deploy/signing-service/01-rbac.yaml: ClusterRole with tokenreviews.create + binding to the signing-service SA. - deploy/signing-service/20-deployment.yaml: adds POCKET_ID_ISSUER, POCKET_ID_AUDIENCE, TRUST_PG_DSN env, projected SA token volume, OPA policy ConfigMap mount, structured flags. - deploy/signing-service/00-serviceaccount.yaml: disables default auto-mount; a projected volume is used. - deploy/signing-service/README.md: documents prod flags + ConfigMap generation. Tests - httptest-mock Pocket-ID (OIDC discovery + JWKS, kid rotation), StaticTokenReviewer fake, Static registry, real Rego policy. - Happy paths: Approval-Service → trust-api/approval-signer-2026-q4; testagent-transform → trust-api/agent-testagent-transform-2026-q4. - Denies: registry mismatch, wrong key_ref for role, missing / bad workload token, bad Pocket-ID token, kid rotation. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
3a01cc651f
commit
30dd2f8b65
19 changed files with 1862 additions and 107 deletions
|
|
@ -7,4 +7,7 @@ metadata:
|
||||||
app.kubernetes.io/name: signing-service
|
app.kubernetes.io/name: signing-service
|
||||||
app.kubernetes.io/part-of: proofedmesh
|
app.kubernetes.io/part-of: proofedmesh
|
||||||
app.kubernetes.io/component: trust-core
|
app.kubernetes.io/component: trust-core
|
||||||
automountServiceAccountToken: true
|
# The signing-service pod uses an EXPLICIT projected SA token
|
||||||
|
# (deploy/signing-service/20-deployment.yaml) rather than the default
|
||||||
|
# auto-mount, so this SA opts out of auto-mounting.
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
|
|
||||||
40
deploy/signing-service/01-rbac.yaml
Normal file
40
deploy/signing-service/01-rbac.yaml
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# RBAC for the signing-service's K8s TokenReview path (DEV-609).
|
||||||
|
#
|
||||||
|
# The signing-service verifies the caller's workload identity by handing the
|
||||||
|
# `X-Workload-Token` header to the Kubernetes API server as a TokenReview.
|
||||||
|
# That requires create on `tokenreviews.authentication.k8s.io`, which is
|
||||||
|
# NOT part of any default role, so we grant it via a dedicated ClusterRole.
|
||||||
|
#
|
||||||
|
# The ClusterRole is intentionally narrow: `tokenreviews` + `subjectaccessreviews`
|
||||||
|
# so the service can also diagnose "the token authenticated as X, is X allowed
|
||||||
|
# Y?" patterns in Phase 2 without a second RBAC update.
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
name: signing-service-tokenreview
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: signing-service
|
||||||
|
app.kubernetes.io/part-of: proofedmesh
|
||||||
|
app.kubernetes.io/component: trust-core
|
||||||
|
rules:
|
||||||
|
- apiGroups: ["authentication.k8s.io"]
|
||||||
|
resources: ["tokenreviews"]
|
||||||
|
verbs: ["create"]
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRoleBinding
|
||||||
|
metadata:
|
||||||
|
name: signing-service-tokenreview
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: signing-service
|
||||||
|
app.kubernetes.io/part-of: proofedmesh
|
||||||
|
app.kubernetes.io/component: trust-core
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: ClusterRole
|
||||||
|
name: signing-service-tokenreview
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: signing-service
|
||||||
|
namespace: agent-trust
|
||||||
|
|
@ -26,7 +26,12 @@ spec:
|
||||||
app.kubernetes.io/component: trust-core
|
app.kubernetes.io/component: trust-core
|
||||||
spec:
|
spec:
|
||||||
serviceAccountName: signing-service
|
serviceAccountName: signing-service
|
||||||
automountServiceAccountToken: true
|
# The signing-service MUST NOT get the default auto-mounted token
|
||||||
|
# because we need a projected token with an explicit `trust-api`
|
||||||
|
# audience so the pod SA token can be used to call the API server
|
||||||
|
# for TokenReview (the audience on the pod's own SA token is what
|
||||||
|
# the K8s API server will accept as Bearer credential).
|
||||||
|
automountServiceAccountToken: false
|
||||||
securityContext:
|
securityContext:
|
||||||
runAsNonRoot: true
|
runAsNonRoot: true
|
||||||
runAsUser: 65532
|
runAsUser: 65532
|
||||||
|
|
@ -41,22 +46,42 @@ spec:
|
||||||
args:
|
args:
|
||||||
- "-listen=:8081"
|
- "-listen=:8081"
|
||||||
- "-backend=memory"
|
- "-backend=memory"
|
||||||
- "-keys=trust-api/rate-change-signer-2026-q4"
|
- "-keys=trust-api/approval-signer-2026-q4,trust-api/agent-testagent-transform-2026-q4"
|
||||||
- "-keys-from-env"
|
- "-keys-from-env"
|
||||||
- "-bearers=$(SIGNING_BEARERS)"
|
- "-pocket-id-issuer=$(POCKET_ID_ISSUER)"
|
||||||
|
- "-pocket-id-audience=$(POCKET_ID_AUDIENCE)"
|
||||||
|
- "-k8s-tokenreview=on"
|
||||||
|
- "-registry-dsn=$(TRUST_PG_DSN)"
|
||||||
|
- "-policy-bundle=/etc/signing-service/policies/signing_service.rego"
|
||||||
env:
|
env:
|
||||||
# Pulled from the dev Secret below; see deploy/signing-service/README.md
|
# ---- Pocket-ID OIDC discovery (DEV-609) ----
|
||||||
# for the full secret shape and the SealedSecret recipe.
|
- name: POCKET_ID_ISSUER
|
||||||
- name: SIGNING_BEARERS
|
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: signing-service-dev
|
name: signing-service-prod
|
||||||
key: SIGNING_BEARERS
|
key: POCKET_ID_ISSUER
|
||||||
- name: KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4
|
- name: POCKET_ID_AUDIENCE
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: signing-service-dev
|
name: signing-service-prod
|
||||||
key: KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4
|
key: POCKET_ID_AUDIENCE
|
||||||
|
# ---- trust-api agent registry DSN (DEV-609) ----
|
||||||
|
- name: TRUST_PG_DSN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: signing-service-prod
|
||||||
|
key: TRUST_PG_DSN
|
||||||
|
# ---- Signing keys (same shape as dev; swapped to prod Secret) ----
|
||||||
|
- name: KEY_TRUST_API_APPROVAL_SIGNER_2026_Q4
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: signing-service-prod
|
||||||
|
key: KEY_TRUST_API_APPROVAL_SIGNER_2026_Q4
|
||||||
|
- name: KEY_TRUST_API_AGENT_TESTAGENT_TRANSFORM_2026_Q4
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: signing-service-prod
|
||||||
|
key: KEY_TRUST_API_AGENT_TESTAGENT_TRANSFORM_2026_Q4
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 8081
|
containerPort: 8081
|
||||||
|
|
@ -77,17 +102,58 @@ spec:
|
||||||
failureThreshold: 3
|
failureThreshold: 3
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: "50m"
|
cpu: "100m"
|
||||||
memory: "64Mi"
|
memory: "128Mi"
|
||||||
limits:
|
limits:
|
||||||
cpu: "500m"
|
cpu: "1000m"
|
||||||
memory: "256Mi"
|
memory: "512Mi"
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
readOnlyRootFilesystem: true
|
readOnlyRootFilesystem: true
|
||||||
capabilities:
|
capabilities:
|
||||||
drop:
|
drop:
|
||||||
- ALL
|
- ALL
|
||||||
|
volumeMounts:
|
||||||
|
- name: kube-api-token
|
||||||
|
mountPath: /var/run/secrets/kubernetes.io/serviceaccount
|
||||||
|
readOnly: true
|
||||||
|
- name: opa-policy
|
||||||
|
mountPath: /etc/signing-service/policies
|
||||||
|
readOnly: true
|
||||||
|
volumes:
|
||||||
|
# Projected SA token the signing-service presents to the
|
||||||
|
# Kubernetes API server to authorize TokenReview calls. The
|
||||||
|
# projected volume also carries the API server CA, which the
|
||||||
|
# in-cluster TokenReviewer loads from
|
||||||
|
# /var/run/secrets/kubernetes.io/serviceaccount/ca.crt.
|
||||||
|
- name: kube-api-token
|
||||||
|
projected:
|
||||||
|
sources:
|
||||||
|
- serviceAccountToken:
|
||||||
|
path: token
|
||||||
|
# The API server audience — pod-SA tokens addressed to
|
||||||
|
# the api server are what RBAC checks against.
|
||||||
|
audience: "https://kubernetes.default.svc"
|
||||||
|
expirationSeconds: 3600
|
||||||
|
- configMap:
|
||||||
|
name: kube-root-ca.crt
|
||||||
|
items:
|
||||||
|
- key: ca.crt
|
||||||
|
path: ca.crt
|
||||||
|
- downwardAPI:
|
||||||
|
items:
|
||||||
|
- path: namespace
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.namespace
|
||||||
|
# OPA Rego policy source, mounted read-only. The ConfigMap is
|
||||||
|
# created from policies/sinks/signing_service.rego by GitOps
|
||||||
|
# (see deploy/signing-service/README.md).
|
||||||
|
- name: opa-policy
|
||||||
|
configMap:
|
||||||
|
name: signing-service-policy
|
||||||
|
items:
|
||||||
|
- key: signing_service.rego
|
||||||
|
path: signing_service.rego
|
||||||
topologySpreadConstraints:
|
topologySpreadConstraints:
|
||||||
- maxSkew: 1
|
- maxSkew: 1
|
||||||
topologyKey: kubernetes.io/hostname
|
topologyKey: kubernetes.io/hostname
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ directory assumes that file (or an equivalent) is already applied.
|
||||||
|
|
||||||
```
|
```
|
||||||
deploy/signing-service/
|
deploy/signing-service/
|
||||||
├── 00-serviceaccount.yaml — ServiceAccount with projected SA token
|
├── 00-serviceaccount.yaml — ServiceAccount (opts out of auto-mount; a projected volume is used)
|
||||||
|
├── 01-rbac.yaml — ClusterRole + Binding for TokenReview (DEV-609)
|
||||||
├── 10-secret-dev.example.yaml — TEMPLATE for the dev-mode Secret
|
├── 10-secret-dev.example.yaml — TEMPLATE for the dev-mode Secret
|
||||||
├── 20-deployment.yaml — Deployment (non-root, read-only rootfs, distroless)
|
├── 20-deployment.yaml — Deployment (non-root, read-only rootfs, distroless)
|
||||||
├── 30-service.yaml — ClusterIP Service on :8081
|
├── 30-service.yaml — ClusterIP Service on :8081
|
||||||
|
|
@ -17,6 +18,27 @@ deploy/signing-service/
|
||||||
└── README.md — this file
|
└── README.md — this file
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Prod wiring (DEV-609)
|
||||||
|
|
||||||
|
The Deployment reads the following prod flags via env:
|
||||||
|
|
||||||
|
| Flag | Env | Purpose |
|
||||||
|
| ---------------------- | ------------------------ | ----------------------------------------------------------------------------------------- |
|
||||||
|
| `-pocket-id-issuer` | `POCKET_ID_ISSUER` | Pocket-ID OIDC issuer URL — enables JWKS validator (replaces static bearer table). |
|
||||||
|
| `-pocket-id-audience` | `POCKET_ID_AUDIENCE` | Expected `aud` claim on Pocket-ID tokens. |
|
||||||
|
| `-k8s-tokenreview=on` | (flag) | Enables K8s TokenReview on `X-Workload-Token` using the pod's projected SA token. |
|
||||||
|
| `-registry-dsn` | `TRUST_PG_DSN` | Postgres DSN for the trust-api `agents` table (DEV-607). |
|
||||||
|
| `-policy-bundle` | (flag, file path) | Rego file mounted at `/etc/signing-service/policies/signing_service.rego`. |
|
||||||
|
|
||||||
|
The OPA policy source lives at `policies/sinks/signing_service.rego`. Create
|
||||||
|
the ConfigMap it is mounted from before applying the Deployment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n agent-trust create configmap signing-service-policy \
|
||||||
|
--from-file=signing_service.rego=policies/sinks/signing_service.rego \
|
||||||
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
```
|
||||||
|
|
||||||
## Apply order
|
## Apply order
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
74
policies/sinks/signing_service.rego
Normal file
74
policies/sinks/signing_service.rego
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
# METADATA
|
||||||
|
# title: Signing-service policy (ProofedMesh Phase 1)
|
||||||
|
# description: |
|
||||||
|
# Decides whether an authenticated client may obtain a DSSE signature for a
|
||||||
|
# given key_ref. Enforces two independent conditions:
|
||||||
|
#
|
||||||
|
# 1. The Pocket-ID client identity (`sub` claim) and the Kubernetes
|
||||||
|
# ServiceAccount identity (`<ns>/<sa>` from TokenReview) resolve to the
|
||||||
|
# same agent row in the trust-api `agents` table.
|
||||||
|
# 2. The requested `key_ref` is in the set of key_refs this agent's role
|
||||||
|
# is permitted to sign with.
|
||||||
|
#
|
||||||
|
# Both lookups are passed in as `input.registry.by_pocket_id` and
|
||||||
|
# `input.registry.by_k8s`. The signing-service MUST perform both lookups
|
||||||
|
# from the DB and hand the raw rows (or `null` on no match) to OPA — the
|
||||||
|
# policy does not reach out to the DB on its own.
|
||||||
|
# authors:
|
||||||
|
# - CTO Agent <cto@basicstack.de>
|
||||||
|
# related_resources:
|
||||||
|
# - ref: docs/architecture/v1.1.md#user-content-44-identitaet-und-schluessel
|
||||||
|
# - ref: db/trust/migrations/0002_agents.up.sql
|
||||||
|
package proofedmesh.sinks.signing_service
|
||||||
|
|
||||||
|
import rego.v1
|
||||||
|
|
||||||
|
default allow := false
|
||||||
|
|
||||||
|
# Map agent_id → set of allowed key_ref prefixes. Prefixes end at the
|
||||||
|
# quarter boundary so we can rotate the suffix (`-2026-q4`, `-2027-q1`, …)
|
||||||
|
# without changing the policy. Keep this list in sync with the trust-api
|
||||||
|
# agent registry seeds (db/trust/seeds/0001_phase1_agents.sql).
|
||||||
|
agent_key_prefixes := {
|
||||||
|
"agent-approval-service": {"trust-api/approval-signer-"},
|
||||||
|
"agent-testagent-transform": {"trust-api/agent-testagent-transform-"},
|
||||||
|
"agent-dummy-sink": {"trust-api/dummy-sink-signer-"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Both registry lookups must have returned the SAME agent row and that row
|
||||||
|
# must be internally consistent with the identities actually presented by
|
||||||
|
# the caller (Pocket-ID `sub` and the K8s ServiceAccount reported by
|
||||||
|
# TokenReview). Any null, mismatch, or absent field denies.
|
||||||
|
identities_agree if {
|
||||||
|
input.registry.by_pocket_id.agent_id == input.registry.by_k8s.agent_id
|
||||||
|
input.registry.by_pocket_id.pocket_id_client == input.pocket_id_client
|
||||||
|
input.registry.by_pocket_id.k8s_namespace == input.k8s.namespace
|
||||||
|
input.registry.by_pocket_id.k8s_service_account == input.k8s.service_account
|
||||||
|
input.registry.by_k8s.pocket_id_client == input.pocket_id_client
|
||||||
|
input.registry.by_k8s.k8s_namespace == input.k8s.namespace
|
||||||
|
input.registry.by_k8s.k8s_service_account == input.k8s.service_account
|
||||||
|
}
|
||||||
|
|
||||||
|
key_ref_matches_role if {
|
||||||
|
some prefix in agent_key_prefixes[input.registry.by_pocket_id.agent_id]
|
||||||
|
startswith(input.key_ref, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
allow if {
|
||||||
|
identities_agree
|
||||||
|
key_ref_matches_role
|
||||||
|
}
|
||||||
|
|
||||||
|
deny_reason contains msg if {
|
||||||
|
not identities_agree
|
||||||
|
msg := "pocket-id and k8s identities do not resolve to the same registry agent"
|
||||||
|
}
|
||||||
|
|
||||||
|
deny_reason contains msg if {
|
||||||
|
identities_agree
|
||||||
|
not key_ref_matches_role
|
||||||
|
msg := sprintf(
|
||||||
|
"agent %q is not permitted to sign with key_ref %q",
|
||||||
|
[input.registry.by_pocket_id.agent_id, input.key_ref],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -9,17 +9,21 @@ ENV CGO_ENABLED=0 \
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Only the signing-service module is needed. Copy the module files first so
|
# The image reproduces the repo layout under /src so Go module paths and
|
||||||
# the dependency download layer is cacheable independently of source edits.
|
# the OPA policy's relative location (`../../../../policies/sinks/...`
|
||||||
COPY services/signing-service/go.mod ./
|
# in tests) line up both in CI and inside the image.
|
||||||
# go.sum will be copied in when the module gains external deps; today the
|
COPY services/signing-service/go.mod services/signing-service/go.sum ./services/signing-service/
|
||||||
# module is stdlib-only.
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
--mount=type=cache,target=/go/pkg/mod \
|
--mount=type=cache,target=/go/pkg/mod \
|
||||||
go mod download || true
|
cd services/signing-service && go mod download
|
||||||
|
|
||||||
COPY services/signing-service/ ./
|
COPY services/signing-service/ ./services/signing-service/
|
||||||
|
# The OPA Rego policy ships with the image for dev / smoke runs. GitOps
|
||||||
|
# mounts it from a ConfigMap at /etc/signing-service/policies at runtime
|
||||||
|
# and wins over the baked-in copy.
|
||||||
|
COPY policies/sinks/signing_service.rego ./policies/sinks/signing_service.rego
|
||||||
|
|
||||||
|
WORKDIR /src/services/signing-service
|
||||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
--mount=type=cache,target=/go/pkg/mod \
|
--mount=type=cache,target=/go/pkg/mod \
|
||||||
go vet ./... && \
|
go vet ./... && \
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
// Command signing-service exposes POST /api/v1/sign/dsse per ADR-0005.
|
// Command signing-service exposes POST /api/v1/sign/dsse per ADR-0005.
|
||||||
//
|
//
|
||||||
// Dev mode is self-contained: in-memory Ed25519 keyring, AllowAll policy,
|
// Modes
|
||||||
// static bearer-to-subject table. Prod wiring (OpenBao Transit, OPA, Pocket
|
//
|
||||||
// ID JWKS) is in follow-up subtasks; the interfaces are already in place so
|
// - Dev (default): memory signer, AllowAll policy, static bearer table.
|
||||||
// swapping backends does not touch the HTTP layer.
|
// Still used by CI and local integration tests.
|
||||||
|
// - Prod: Pocket-ID JWKS validator, K8s TokenReview-backed workload
|
||||||
|
// identity, trust-api Postgres agent registry, OPA Rego policy. Any
|
||||||
|
// combination of the four may be enabled via flags; main.go flips the
|
||||||
|
// Server composition accordingly.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -23,6 +27,7 @@ import (
|
||||||
|
|
||||||
"basicstack.de/proofedmesh/services/signing-service/internal/httpapi"
|
"basicstack.de/proofedmesh/services/signing-service/internal/httpapi"
|
||||||
"basicstack.de/proofedmesh/services/signing-service/internal/policy"
|
"basicstack.de/proofedmesh/services/signing-service/internal/policy"
|
||||||
|
"basicstack.de/proofedmesh/services/signing-service/internal/registry"
|
||||||
"basicstack.de/proofedmesh/services/signing-service/internal/signer"
|
"basicstack.de/proofedmesh/services/signing-service/internal/signer"
|
||||||
"basicstack.de/proofedmesh/services/signing-service/internal/token"
|
"basicstack.de/proofedmesh/services/signing-service/internal/token"
|
||||||
)
|
)
|
||||||
|
|
@ -35,8 +40,21 @@ func main() {
|
||||||
keysFromEnv := flag.Bool("keys-from-env", false,
|
keysFromEnv := flag.Bool("keys-from-env", false,
|
||||||
"when true (memory backend), read KEY_<slug>=<hex> env for each key_ref instead of generating")
|
"when true (memory backend), read KEY_<slug>=<hex> env for each key_ref instead of generating")
|
||||||
bearers := flag.String("bearers", "dev-token=agent-admin-api",
|
bearers := flag.String("bearers", "dev-token=agent-admin-api",
|
||||||
"comma-separated bearer=subject pairs for the static token validator")
|
"comma-separated bearer=subject pairs for the static token validator (ignored when -pocket-id-issuer is set)")
|
||||||
genDevKeyAndExit := flag.Bool("gen-dev-key", false, "generate one dev Ed25519 key and exit (prints hex)")
|
genDevKeyAndExit := flag.Bool("gen-dev-key", false, "generate one dev Ed25519 key and exit (prints hex)")
|
||||||
|
|
||||||
|
// Prod wiring flags (DEV-609):
|
||||||
|
pocketIDIssuer := flag.String("pocket-id-issuer", "",
|
||||||
|
"Pocket-ID issuer URL for OIDC discovery; when set, the Pocket-ID JWKS validator replaces the static bearer table")
|
||||||
|
pocketIDAudience := flag.String("pocket-id-audience", "",
|
||||||
|
"expected `aud` claim on Pocket-ID tokens; empty disables aud check")
|
||||||
|
k8sTokenReview := flag.String("k8s-tokenreview", "off",
|
||||||
|
"on|off: when on, require X-Workload-Token and perform TokenReview against the Kubernetes API server (via in-cluster SA credentials)")
|
||||||
|
registryDSN := flag.String("registry-dsn", "",
|
||||||
|
"Postgres DSN for the trust-api agent registry (e.g. postgres://signing-service:...@trust-pg.agent-trust/trust?sslmode=verify-full); empty disables the structured policy path")
|
||||||
|
policyBundle := flag.String("policy-bundle", "",
|
||||||
|
"path to the OPA Rego policy file for the signing-service (e.g. /etc/signing-service/policies/signing_service.rego); empty disables the structured policy path")
|
||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
|
@ -51,52 +69,75 @@ func main() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var sgn signer.Signer
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
switch *backend {
|
defer stop()
|
||||||
case "memory":
|
|
||||||
mem := signer.NewInMemory()
|
sgn, err := buildSigner(*backend, *keyRefs, *keysFromEnv, logger)
|
||||||
for _, kr := range splitCSV(*keyRefs) {
|
|
||||||
var priv ed25519.PrivateKey
|
|
||||||
if *keysFromEnv {
|
|
||||||
envName := envNameForKey(kr)
|
|
||||||
raw := os.Getenv(envName)
|
|
||||||
if raw == "" {
|
|
||||||
logger.Error("missing env for key", "env", envName)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
p, err := signer.DecodeHexEd25519Key(raw)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("bad env key", "env", envName, "err", err)
|
logger.Error("signer init", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
priv = p
|
|
||||||
|
var validator token.Validator
|
||||||
|
if *pocketIDIssuer != "" {
|
||||||
|
pv, err := token.NewPocketIDValidator(ctx, *pocketIDIssuer, *pocketIDAudience)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("pocket-id init", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
validator = pv
|
||||||
|
logger.Info("pocket-id validator wired", "issuer", *pocketIDIssuer, "audience", *pocketIDAudience)
|
||||||
} else {
|
} else {
|
||||||
_, p, err := ed25519.GenerateKey(rand.Reader)
|
validator = parseBearers(*bearers)
|
||||||
|
logger.Warn("signing-service: static bearer table in use — DEV ONLY")
|
||||||
|
}
|
||||||
|
|
||||||
|
var reviewer token.TokenReviewer
|
||||||
|
if strings.EqualFold(*k8sTokenReview, "on") {
|
||||||
|
tr, err := token.NewInClusterTokenReviewer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("gen key", "err", err)
|
logger.Error("in-cluster tokenreview init", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
priv = p
|
reviewer = tr
|
||||||
logger.Warn("generated ephemeral dev key — not safe for prod", "key_ref", kr)
|
logger.Info("k8s tokenreview wired")
|
||||||
}
|
}
|
||||||
mem.AddKey(kr, priv)
|
|
||||||
}
|
var reg registry.Resolver
|
||||||
sgn = mem
|
var pgReg *registry.Postgres
|
||||||
case "openbao":
|
if *registryDSN != "" {
|
||||||
logger.Error("openbao backend not wired yet; use -backend=memory for dev")
|
pr, err := registry.NewPostgres(ctx, *registryDSN)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("registry init", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
default:
|
}
|
||||||
fmt.Fprintln(os.Stderr, "unknown -backend; expected memory|openbao")
|
reg = pr
|
||||||
os.Exit(2)
|
pgReg = pr
|
||||||
|
logger.Info("trust-api registry wired")
|
||||||
|
}
|
||||||
|
|
||||||
|
var structured policy.StructuredDecider
|
||||||
|
if *policyBundle != "" {
|
||||||
|
opa, err := policy.NewOPAEvaluator(ctx, *policyBundle)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("opa policy init", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
structured = opa
|
||||||
|
logger.Info("opa policy wired", "bundle", *policyBundle)
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := httpapi.Server{
|
srv := httpapi.Server{
|
||||||
Signer: sgn,
|
Signer: sgn,
|
||||||
|
Validator: validator,
|
||||||
|
TokenReviewer: reviewer,
|
||||||
|
Registry: reg,
|
||||||
|
StructuredPolicy: structured,
|
||||||
Policy: policy.AllowAll{},
|
Policy: policy.AllowAll{},
|
||||||
Validator: parseBearers(*bearers),
|
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
Now: time.Now,
|
Now: time.Now,
|
||||||
}
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
srv.Mount(mux)
|
srv.Mount(mux)
|
||||||
|
|
||||||
|
|
@ -106,18 +147,64 @@ func main() {
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
logger.Warn("signing-service: AllowAll policy and static bearers are DEV ONLY")
|
logger.Info("signing-service listening",
|
||||||
logger.Info("signing-service listening", "addr", *listen, "backend", *backend)
|
"addr", *listen,
|
||||||
|
"backend", *backend,
|
||||||
|
"pocket_id", *pocketIDIssuer != "",
|
||||||
|
"k8s_tokenreview", reviewer != nil,
|
||||||
|
"registry", reg != nil,
|
||||||
|
"opa", structured != nil,
|
||||||
|
)
|
||||||
|
if structured == nil || reg == nil || reviewer == nil {
|
||||||
|
logger.Warn("signing-service: structured double-identity path NOT fully wired",
|
||||||
|
"opa", structured != nil, "registry", reg != nil, "tokenreview", reviewer != nil)
|
||||||
|
}
|
||||||
if err := hsrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := hsrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
logger.Error("http serve", "err", err)
|
logger.Error("http serve", "err", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
stop := make(chan os.Signal, 1)
|
<-ctx.Done()
|
||||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
<-stop
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
defer cancel()
|
||||||
_ = hsrv.Shutdown(ctx)
|
_ = hsrv.Shutdown(shutdownCtx)
|
||||||
|
if pgReg != nil {
|
||||||
|
pgReg.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSigner(backend, keyRefs string, keysFromEnv bool, logger *slog.Logger) (signer.Signer, error) {
|
||||||
|
switch backend {
|
||||||
|
case "memory":
|
||||||
|
mem := signer.NewInMemory()
|
||||||
|
for _, kr := range splitCSV(keyRefs) {
|
||||||
|
var priv ed25519.PrivateKey
|
||||||
|
if keysFromEnv {
|
||||||
|
envName := envNameForKey(kr)
|
||||||
|
raw := os.Getenv(envName)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, fmt.Errorf("missing env %q for key_ref %q", envName, kr)
|
||||||
|
}
|
||||||
|
p, err := signer.DecodeHexEd25519Key(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("bad env key %q: %w", envName, err)
|
||||||
|
}
|
||||||
|
priv = p
|
||||||
|
} else {
|
||||||
|
_, p, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gen key %q: %w", kr, err)
|
||||||
|
}
|
||||||
|
priv = p
|
||||||
|
logger.Warn("generated ephemeral dev key — not safe for prod", "key_ref", kr)
|
||||||
|
}
|
||||||
|
mem.AddKey(kr, priv)
|
||||||
|
}
|
||||||
|
return mem, nil
|
||||||
|
case "openbao":
|
||||||
|
return nil, fmt.Errorf("openbao backend not wired yet; use -backend=memory")
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown -backend %q; expected memory|openbao", backend)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func splitCSV(s string) []string {
|
func splitCSV(s string) []string {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,45 @@
|
||||||
module basicstack.de/proofedmesh/services/signing-service
|
module basicstack.de/proofedmesh/services/signing-service
|
||||||
|
|
||||||
go 1.23
|
go 1.26.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/coreos/go-oidc/v3 v3.21.0
|
||||||
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
|
github.com/open-policy-agent/opa v1.21.4
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||||
|
github.com/gobwas/glob v1.0.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.6 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
|
||||||
|
github.com/lestrrat-go/dsig v1.4.0 // indirect
|
||||||
|
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||||
|
github.com/lestrrat-go/httprc/v3 v3.0.6 // indirect
|
||||||
|
github.com/lestrrat-go/jwx/v3 v3.3.0 // indirect
|
||||||
|
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
|
||||||
|
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect
|
||||||
|
github.com/segmentio/asm v1.2.1 // indirect
|
||||||
|
github.com/sirupsen/logrus v1.10.2 // indirect
|
||||||
|
github.com/tchap/go-patricia/v2 v2.3.3 // indirect
|
||||||
|
github.com/valyala/fastjson v1.6.10 // indirect
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.37 // indirect
|
||||||
|
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||||
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||||
|
github.com/yashtewari/glob-intersection v0.2.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
|
golang.org/x/crypto v0.57.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
|
golang.org/x/text v0.42.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.12 // indirect
|
||||||
|
)
|
||||||
|
|
|
||||||
144
services/signing-service/go.sum
Normal file
144
services/signing-service/go.sum
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||||
|
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||||
|
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
|
||||||
|
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||||
|
github.com/dgraph-io/badger/v4 v4.9.6 h1:IQqMPVGLNCQr1b4Mu8lHkYm/xyqFRsyKaFEtyLi9CCQ=
|
||||||
|
github.com/dgraph-io/badger/v4 v4.9.6/go.mod h1:Xa9dAupjbwAacupWFCpa6YEn9E1PjBXkfZYr2I/8aWg=
|
||||||
|
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
|
||||||
|
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
|
||||||
|
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
|
||||||
|
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
|
||||||
|
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
|
||||||
|
github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0=
|
||||||
|
github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
|
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||||
|
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/gobwas/glob v1.0.0 h1:p+FKbLEIsK1yZ39/OINwFvqNb5oyPY4H8xcy6uYu8dg=
|
||||||
|
github.com/gobwas/glob v1.0.0/go.mod h1:oWCdo522i2P1n/hMXGNWs7yoV4wy/ciZuUIbvKj5rkc=
|
||||||
|
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||||
|
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
|
||||||
|
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||||
|
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||||
|
github.com/lestrrat-go/dsig v1.4.0 h1:g7LUjK8cT74A5DzBXJI5HzsJuLhoYN0Wzj4nuOMIrH8=
|
||||||
|
github.com/lestrrat-go/dsig v1.4.0/go.mod h1:I8Nddg/vN2cUl/h8N7SRRApLnNNeyZPIqLYpvpOtGGo=
|
||||||
|
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
|
||||||
|
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||||
|
github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI=
|
||||||
|
github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
|
||||||
|
github.com/lestrrat-go/jwx/v3 v3.3.0 h1:OXcYvQOQ7cxWzeZ/Q9sYk8ABe/kCSI371WmuACiCT+4=
|
||||||
|
github.com/lestrrat-go/jwx/v3 v3.3.0/go.mod h1:eIJhDcKHBwcgxqv8RiIylV67TVl1wJp/265IAHY1Db8=
|
||||||
|
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
|
||||||
|
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
|
||||||
|
github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM=
|
||||||
|
github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/open-policy-agent/opa v1.21.4 h1:zTVuy8yd8BM715t8WxMzAIkIX4w1x3MMHEv99IGUY2w=
|
||||||
|
github.com/open-policy-agent/opa v1.21.4/go.mod h1:mHDS61rXyiZ+9bLINSK634BcbPZcsZChPvSwFVMj88g=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||||
|
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||||
|
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
|
||||||
|
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
|
||||||
|
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
|
||||||
|
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
||||||
|
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||||
|
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||||
|
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg=
|
||||||
|
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||||
|
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||||
|
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||||
|
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||||
|
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
|
||||||
|
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
|
github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhgwZDDc=
|
||||||
|
github.com/tchap/go-patricia/v2 v2.3.3/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k=
|
||||||
|
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
||||||
|
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
||||||
|
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||||
|
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.37 h1:jbb1Ilv+xBklV6653tKb4oVUupPNTLb5LmrnBKVI12Y=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.37/go.mod h1:9O4Ox6Ngd3Y12bMD3w6i3CRQXh8W1oC1q0m6olCymDM=
|
||||||
|
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
||||||
|
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||||
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
||||||
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||||
|
github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg=
|
||||||
|
github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
|
||||||
|
go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
|
||||||
|
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
|
||||||
|
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
|
||||||
|
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
|
||||||
|
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
|
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
|
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||||
|
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||||
|
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||||
|
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
|
||||||
|
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||||
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
|
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||||
|
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||||
|
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||||
|
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||||
|
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||||
|
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -11,14 +12,47 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"basicstack.de/proofedmesh/services/signing-service/internal/policy"
|
"basicstack.de/proofedmesh/services/signing-service/internal/policy"
|
||||||
|
"basicstack.de/proofedmesh/services/signing-service/internal/registry"
|
||||||
"basicstack.de/proofedmesh/services/signing-service/internal/signer"
|
"basicstack.de/proofedmesh/services/signing-service/internal/signer"
|
||||||
"basicstack.de/proofedmesh/services/signing-service/internal/token"
|
"basicstack.de/proofedmesh/services/signing-service/internal/token"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Server composes the per-request pipeline:
|
||||||
|
//
|
||||||
|
// 1. Parse the Pocket-ID JWT from Authorization and resolve `sub`.
|
||||||
|
// 2. If a TokenReviewer is configured, pull X-Workload-Token and resolve
|
||||||
|
// the authenticated (ns, sa). Omitted TokenReviewer disables the K8s
|
||||||
|
// side of the double-identity check — only useful for dev/CI.
|
||||||
|
// 3. Resolve both identities against the agent registry; feed both rows
|
||||||
|
// to the structured policy. If no structured policy is wired, the
|
||||||
|
// legacy subject+keyRef Decider is used instead.
|
||||||
|
// 4. Sign.
|
||||||
|
//
|
||||||
|
// Fields are plumbed through the struct so main.go can decide at startup
|
||||||
|
// which backends to install.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
Signer signer.Signer
|
Signer signer.Signer
|
||||||
Policy policy.Decider
|
|
||||||
Validator token.Validator
|
Validator token.Validator
|
||||||
|
|
||||||
|
// TokenReviewer is OPTIONAL. When nil, the double-identity path is
|
||||||
|
// skipped and the policy input carries only the Pocket-ID side (the
|
||||||
|
// legacy Decider path is used instead).
|
||||||
|
TokenReviewer token.TokenReviewer
|
||||||
|
|
||||||
|
// Registry is OPTIONAL (and only consulted when both TokenReviewer
|
||||||
|
// and StructuredPolicy are present). The structured policy evaluates
|
||||||
|
// the two lookups it receives via Input.Registry.
|
||||||
|
Registry registry.Resolver
|
||||||
|
|
||||||
|
// StructuredPolicy is OPTIONAL. When present AND Registry/TokenReviewer
|
||||||
|
// are present, the handler uses the full double-identity flow. When
|
||||||
|
// nil, the handler falls back to the subject+keyRef Policy below.
|
||||||
|
StructuredPolicy policy.StructuredDecider
|
||||||
|
|
||||||
|
// Policy is the legacy subject+keyRef decider (AllowAll in dev,
|
||||||
|
// StructuredAdapter for prod wiring).
|
||||||
|
Policy policy.Decider
|
||||||
|
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
@ -89,6 +123,23 @@ func (s Server) handleSign(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decide: structured double-identity path, or legacy subject+keyRef.
|
||||||
|
if s.useStructuredPath() {
|
||||||
|
dec, err := s.decideStructured(ctx, subject, req.KeyRef, r)
|
||||||
|
if err != nil {
|
||||||
|
var statusErr *httpStatusError
|
||||||
|
if errors.As(err, &statusErr) {
|
||||||
|
writeErr(w, statusErr.Status, statusErr.Code, statusErr.Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeErr(w, http.StatusInternalServerError, "policy_eval_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !dec.Allow {
|
||||||
|
writeErr(w, http.StatusForbidden, "signing_denied", dec.Reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
dec, err := s.Policy.Allow(ctx, subject, req.KeyRef)
|
dec, err := s.Policy.Allow(ctx, subject, req.KeyRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, "policy_eval_error", err.Error())
|
writeErr(w, http.StatusInternalServerError, "policy_eval_error", err.Error())
|
||||||
|
|
@ -98,6 +149,7 @@ func (s Server) handleSign(w http.ResponseWriter, r *http.Request) {
|
||||||
writeErr(w, http.StatusForbidden, "signing_denied", dec.Reason)
|
writeErr(w, http.StatusForbidden, "signing_denied", dec.Reason)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
signed, err := signer.SignStatement(ctx, s.Signer, req.KeyRef, req.Statement)
|
signed, err := signer.SignStatement(ctx, s.Signer, req.KeyRef, req.Statement)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -114,7 +166,11 @@ func (s Server) handleSign(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.Logger != nil {
|
if s.Logger != nil {
|
||||||
s.Logger.Info("signed", "subject", subject, "key_ref", req.KeyRef, "content_hash", signed.ContentHash)
|
s.Logger.Info("signed",
|
||||||
|
"subject", subject,
|
||||||
|
"key_ref", req.KeyRef,
|
||||||
|
"content_hash", signed.ContentHash,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
|
@ -129,6 +185,66 @@ func (s Server) handleSign(w http.ResponseWriter, r *http.Request) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s Server) useStructuredPath() bool {
|
||||||
|
return s.StructuredPolicy != nil && s.TokenReviewer != nil && s.Registry != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decideStructured runs the full double-identity flow and hands the two
|
||||||
|
// registry rows + both identities to the structured policy.
|
||||||
|
func (s Server) decideStructured(ctx context.Context, pocketIDSub, keyRef string, r *http.Request) (policy.Decision, error) {
|
||||||
|
workloadToken, err := token.FromWorkloadHeader(r)
|
||||||
|
if err != nil {
|
||||||
|
return policy.Decision{}, &httpStatusError{
|
||||||
|
Status: http.StatusUnauthorized, Code: "unauthorized",
|
||||||
|
Message: "missing X-Workload-Token header",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
k8sID, err := s.TokenReviewer.Review(ctx, workloadToken)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, token.ErrWorkloadAuthFailed) || errors.Is(err, token.ErrNoWorkloadToken) {
|
||||||
|
return policy.Decision{}, &httpStatusError{
|
||||||
|
Status: http.StatusUnauthorized, Code: "unauthorized",
|
||||||
|
Message: fmt.Sprintf("workload token: %v", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return policy.Decision{}, fmt.Errorf("tokenreview: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
input := policy.Input{
|
||||||
|
PocketIDClient: pocketIDSub,
|
||||||
|
K8s: policy.InputK8s{
|
||||||
|
Namespace: k8sID.Namespace,
|
||||||
|
ServiceAccount: k8sID.ServiceAccount,
|
||||||
|
},
|
||||||
|
KeyRef: keyRef,
|
||||||
|
Registry: policy.InputRegistry{},
|
||||||
|
}
|
||||||
|
|
||||||
|
if byP, err := s.Registry.ByPocketID(ctx, pocketIDSub); err == nil {
|
||||||
|
input.Registry.ByPocketID = policy.FromAgent(byP)
|
||||||
|
} else if !errors.Is(err, registry.ErrNotFound) {
|
||||||
|
return policy.Decision{}, fmt.Errorf("registry.ByPocketID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if byK, err := s.Registry.ByK8sIdentity(ctx, k8sID.Namespace, k8sID.ServiceAccount); err == nil {
|
||||||
|
input.Registry.ByK8s = policy.FromAgent(byK)
|
||||||
|
} else if !errors.Is(err, registry.ErrNotFound) {
|
||||||
|
return policy.Decision{}, fmt.Errorf("registry.ByK8sIdentity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.StructuredPolicy.Decide(ctx, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpStatusError lets decideStructured return a 401 (not 500) when the
|
||||||
|
// workload-identity side of the double-check fails.
|
||||||
|
type httpStatusError struct {
|
||||||
|
Status int
|
||||||
|
Code string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *httpStatusError) Error() string { return e.Message }
|
||||||
|
|
||||||
func writeErr(w http.ResponseWriter, status int, code, message string) {
|
func writeErr(w http.ResponseWriter, status int, code, message string) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
|
|
|
||||||
381
services/signing-service/internal/httpapi/sign_prod_test.go
Normal file
381
services/signing-service/internal/httpapi/sign_prod_test.go
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"basicstack.de/proofedmesh/services/signing-service/internal/policy"
|
||||||
|
"basicstack.de/proofedmesh/services/signing-service/internal/registry"
|
||||||
|
"basicstack.de/proofedmesh/services/signing-service/internal/signer"
|
||||||
|
"basicstack.de/proofedmesh/services/signing-service/internal/token"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockPocketID issues RS256 JWTs and serves OIDC discovery + JWKS over
|
||||||
|
// httptest so the signing-service's Pocket-ID validator thinks it is
|
||||||
|
// talking to real Pocket ID. The suite exercises OIDC-Discovery +
|
||||||
|
// JWK-Cache + kid rotation.
|
||||||
|
type mockPocketID struct {
|
||||||
|
srv *httptest.Server
|
||||||
|
keys map[string]*rsa.PrivateKey // active kid → key
|
||||||
|
// activeKIDs returns the order in which keys appear in the JWKS.
|
||||||
|
activeKIDs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockPocketID(t *testing.T) *mockPocketID {
|
||||||
|
t.Helper()
|
||||||
|
m := &mockPocketID{keys: map[string]*rsa.PrivateKey{}}
|
||||||
|
k1, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.keys["k1"] = k1
|
||||||
|
m.activeKIDs = []string{"k1"}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprintf(w, `{
|
||||||
|
"issuer":"%s",
|
||||||
|
"authorization_endpoint":"%s/auth",
|
||||||
|
"token_endpoint":"%s/token",
|
||||||
|
"jwks_uri":"%s/jwks",
|
||||||
|
"id_token_signing_alg_values_supported":["RS256"]
|
||||||
|
}`, m.issuer(), m.issuer(), m.issuer(), m.issuer())
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
var keys []map[string]any
|
||||||
|
for _, kid := range m.activeKIDs {
|
||||||
|
k := m.keys[kid]
|
||||||
|
n := base64.RawURLEncoding.EncodeToString(k.N.Bytes())
|
||||||
|
e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(k.E)).Bytes())
|
||||||
|
keys = append(keys, map[string]any{
|
||||||
|
"kty": "RSA", "use": "sig", "kid": kid, "alg": "RS256", "n": n, "e": e,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"keys": keys})
|
||||||
|
})
|
||||||
|
m.srv = httptest.NewServer(mux)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPocketID) issuer() string { return m.srv.URL }
|
||||||
|
|
||||||
|
func (m *mockPocketID) close() { m.srv.Close() }
|
||||||
|
|
||||||
|
func (m *mockPocketID) rotateAddKey(t *testing.T, kid string) {
|
||||||
|
t.Helper()
|
||||||
|
k, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.keys[kid] = k
|
||||||
|
m.activeKIDs = append(m.activeKIDs, kid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPocketID) signToken(t *testing.T, kid, sub, aud string) string {
|
||||||
|
t.Helper()
|
||||||
|
k, ok := m.keys[kid]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("no key %q", kid)
|
||||||
|
}
|
||||||
|
header := map[string]any{"alg": "RS256", "typ": "JWT", "kid": kid}
|
||||||
|
claims := map[string]any{
|
||||||
|
"iss": m.issuer(),
|
||||||
|
"sub": sub,
|
||||||
|
"aud": aud,
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
"exp": time.Now().Add(10 * time.Minute).Unix(),
|
||||||
|
}
|
||||||
|
hJ, _ := json.Marshal(header)
|
||||||
|
cJ, _ := json.Marshal(claims)
|
||||||
|
signingInput := base64.RawURLEncoding.EncodeToString(hJ) + "." + base64.RawURLEncoding.EncodeToString(cJ)
|
||||||
|
h := sha256.Sum256([]byte(signingInput))
|
||||||
|
sig, err := rsa.SignPKCS1v15(rand.Reader, k, crypto.SHA256, h[:])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- fake TokenReviewer -----
|
||||||
|
|
||||||
|
func newFakeTokenReviewer(table map[string]token.K8sIdentity) token.TokenReviewer {
|
||||||
|
return token.StaticTokenReviewer(table)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- fake registry seeded with Phase-1 Phase 1 agents -----
|
||||||
|
|
||||||
|
func newPhase1Registry() registry.Resolver {
|
||||||
|
return registry.NewStatic([]registry.Agent{
|
||||||
|
{
|
||||||
|
AgentID: "agent-approval-service",
|
||||||
|
PocketIDClient: "agent-approval-service",
|
||||||
|
K8sNamespace: "agent-trust",
|
||||||
|
K8sServiceAccount: "approval-service",
|
||||||
|
AllowedActions: []string{"expense.submit"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AgentID: "agent-testagent-transform",
|
||||||
|
PocketIDClient: "agent-testagent-transform",
|
||||||
|
K8sNamespace: "agent-trust",
|
||||||
|
K8sServiceAccount: "testagent-transform",
|
||||||
|
AllowedActions: []string{"expense.normalize", "expense.map_to_sink"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPolicy(t *testing.T) policy.StructuredDecider {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join("..", "..", "..", "..", "policies", "sinks", "signing_service.rego")
|
||||||
|
opa, err := policy.NewOPAEvaluator(context.Background(), p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load policy: %v", err)
|
||||||
|
}
|
||||||
|
return opa
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- harness wiring everything together -----
|
||||||
|
|
||||||
|
type prodHarness struct {
|
||||||
|
hsrv *httptest.Server
|
||||||
|
mockPID *mockPocketID
|
||||||
|
approvalPub ed25519.PublicKey
|
||||||
|
transformPub ed25519.PublicKey
|
||||||
|
workloadToken map[string]token.K8sIdentity
|
||||||
|
approvalBearer string
|
||||||
|
transformBearer string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProdHarness(t *testing.T) *prodHarness {
|
||||||
|
t.Helper()
|
||||||
|
m := newMockPocketID(t)
|
||||||
|
t.Cleanup(m.close)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
pv, err := token.NewPocketIDValidator(ctx, m.issuer(), "trust-api")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signerBackend := signer.NewInMemory()
|
||||||
|
_, approvalPriv, _ := ed25519.GenerateKey(rand.Reader)
|
||||||
|
signerBackend.AddKey("trust-api/approval-signer-2026-q4", approvalPriv)
|
||||||
|
_, transformPriv, _ := ed25519.GenerateKey(rand.Reader)
|
||||||
|
signerBackend.AddKey("trust-api/agent-testagent-transform-2026-q4", transformPriv)
|
||||||
|
|
||||||
|
wl := map[string]token.K8sIdentity{
|
||||||
|
"wl-approval": {Namespace: "agent-trust", ServiceAccount: "approval-service"},
|
||||||
|
"wl-transform": {Namespace: "agent-trust", ServiceAccount: "testagent-transform"},
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := Server{
|
||||||
|
Signer: signerBackend,
|
||||||
|
Validator: pv,
|
||||||
|
TokenReviewer: newFakeTokenReviewer(wl),
|
||||||
|
Registry: newPhase1Registry(),
|
||||||
|
StructuredPolicy: loadPolicy(t),
|
||||||
|
Policy: policy.AllowAll{}, // unused in the structured path
|
||||||
|
Now: func() time.Time { return time.Date(2026, 10, 25, 20, 0, 0, 0, time.UTC) },
|
||||||
|
}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
srv.Mount(mux)
|
||||||
|
hsrv := httptest.NewServer(mux)
|
||||||
|
t.Cleanup(hsrv.Close)
|
||||||
|
|
||||||
|
return &prodHarness{
|
||||||
|
hsrv: hsrv,
|
||||||
|
mockPID: m,
|
||||||
|
approvalPub: approvalPriv.Public().(ed25519.PublicKey),
|
||||||
|
transformPub: transformPriv.Public().(ed25519.PublicKey),
|
||||||
|
workloadToken: wl,
|
||||||
|
approvalBearer: m.signToken(t, "k1", "agent-approval-service", "trust-api"),
|
||||||
|
transformBearer: m.signToken(t, "k1", "agent-testagent-transform", "trust-api"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sign(h *prodHarness, bearer, workloadToken, body string) (*http.Response, error) {
|
||||||
|
req, _ := http.NewRequest("POST", h.hsrv.URL+"/api/v1/sign/dsse", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||||
|
if workloadToken != "" {
|
||||||
|
req.Header.Set(token.WorkloadTokenHeader, workloadToken)
|
||||||
|
}
|
||||||
|
return http.DefaultClient.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
const approvalStatement = `{
|
||||||
|
"key_ref":"trust-api/approval-signer-2026-q4",
|
||||||
|
"statement":{
|
||||||
|
"_type":"https://in-toto.io/Statement/v1",
|
||||||
|
"subject":[{"name":"approval/APR-1","digest":{"sha256":"abcdef"}}],
|
||||||
|
"predicateType":"https://basicstack.de/attestations/approval/v1",
|
||||||
|
"predicate":{"approval_id":"APR-1"}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
const transformStatement = `{
|
||||||
|
"key_ref":"trust-api/agent-testagent-transform-2026-q4",
|
||||||
|
"statement":{
|
||||||
|
"_type":"https://in-toto.io/Statement/v1",
|
||||||
|
"subject":[{"name":"transform/TR-1","digest":{"sha256":"abcdef"}}],
|
||||||
|
"predicateType":"https://basicstack.de/attestations/transform/v1",
|
||||||
|
"predicate":{"transform_id":"TR-1"}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
// --- happy paths ---
|
||||||
|
|
||||||
|
func TestProd_HappyPath_ApprovalService(t *testing.T) {
|
||||||
|
h := newProdHarness(t)
|
||||||
|
resp, err := sign(h, h.approvalBearer, "wl-approval", approvalStatement)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := jsonBody(resp)
|
||||||
|
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
assertSignatureVerifies(t, resp, h.approvalPub, "application/vnd.in-toto+json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProd_HappyPath_TestAgentTransform(t *testing.T) {
|
||||||
|
h := newProdHarness(t)
|
||||||
|
resp, err := sign(h, h.transformBearer, "wl-transform", transformStatement)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := jsonBody(resp)
|
||||||
|
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
assertSignatureVerifies(t, resp, h.transformPub, "application/vnd.in-toto+json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- denies ---
|
||||||
|
|
||||||
|
func TestProd_DeniesOnRegistryMismatch(t *testing.T) {
|
||||||
|
h := newProdHarness(t)
|
||||||
|
// Pocket-ID says Approval Service, but the X-Workload-Token is the
|
||||||
|
// transform agent's. Registry lookups land on different rows → 403.
|
||||||
|
resp, _ := sign(h, h.approvalBearer, "wl-transform", approvalStatement)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected 403 got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var e errResponse
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&e)
|
||||||
|
if e.Error.Code != "signing_denied" {
|
||||||
|
t.Errorf("expected signing_denied code, got %q (%s)", e.Error.Code, e.Error.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProd_DeniesOnPolicyWrongKeyRef(t *testing.T) {
|
||||||
|
h := newProdHarness(t)
|
||||||
|
// Approval service identity on both sides, but asks for the transform
|
||||||
|
// agent's key_ref. Policy must deny.
|
||||||
|
body := strings.ReplaceAll(approvalStatement,
|
||||||
|
"trust-api/approval-signer-2026-q4",
|
||||||
|
"trust-api/agent-testagent-transform-2026-q4")
|
||||||
|
resp, _ := sign(h, h.approvalBearer, "wl-approval", body)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected 403 got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProd_RejectsMissingWorkloadToken(t *testing.T) {
|
||||||
|
h := newProdHarness(t)
|
||||||
|
resp, _ := sign(h, h.approvalBearer, "", approvalStatement)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected 401 got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProd_RejectsBadWorkloadToken(t *testing.T) {
|
||||||
|
h := newProdHarness(t)
|
||||||
|
resp, _ := sign(h, h.approvalBearer, "wl-does-not-exist", approvalStatement)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected 401 got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProd_RejectsBadPocketIDToken(t *testing.T) {
|
||||||
|
h := newProdHarness(t)
|
||||||
|
resp, _ := sign(h, "not.a.jwt", "wl-approval", approvalStatement)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected 401 got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- kid rotation ---
|
||||||
|
|
||||||
|
func TestProd_PocketID_KidRotation(t *testing.T) {
|
||||||
|
h := newProdHarness(t)
|
||||||
|
// Baseline works with k1.
|
||||||
|
resp, _ := sign(h, h.approvalBearer, "wl-approval", approvalStatement)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("baseline: expected 200 got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// Rotate: new kid k2 appears in JWKS. A token signed with k2 must verify
|
||||||
|
// after the validator refreshes the JWKS (go-oidc does this on kid miss).
|
||||||
|
h.mockPID.rotateAddKey(t, "k2")
|
||||||
|
bearer := h.mockPID.signToken(t, "k2", "agent-approval-service", "trust-api")
|
||||||
|
resp2, _ := sign(h, bearer, "wl-approval", approvalStatement)
|
||||||
|
defer resp2.Body.Close()
|
||||||
|
if resp2.StatusCode != http.StatusOK {
|
||||||
|
b, _ := jsonBody(resp2)
|
||||||
|
t.Fatalf("after rotation: expected 200 got %d: %s", resp2.StatusCode, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func jsonBody(resp *http.Response) (string, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_, err := buf.ReadFrom(resp.Body)
|
||||||
|
return buf.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertSignatureVerifies(t *testing.T, resp *http.Response, pub ed25519.PublicKey, payloadType string) {
|
||||||
|
t.Helper()
|
||||||
|
var out signResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(out.Envelope.Payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode payload: %v", err)
|
||||||
|
}
|
||||||
|
pae := []byte(fmt.Sprintf("DSSEv1 %d %s %d ", len(payloadType), payloadType, len(payload)))
|
||||||
|
pae = append(pae, payload...)
|
||||||
|
sig, err := base64.RawURLEncoding.DecodeString(out.Envelope.Signatures[0].Sig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode sig: %v", err)
|
||||||
|
}
|
||||||
|
if !ed25519.Verify(pub, pae, sig) {
|
||||||
|
t.Fatal("returned signature does not verify")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
192
services/signing-service/internal/policy/opa.go
Normal file
192
services/signing-service/internal/policy/opa.go
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
// OPA-backed policy decider for the signing-service.
|
||||||
|
//
|
||||||
|
// Loads a Rego policy file (default: policies/sinks/signing_service.rego)
|
||||||
|
// at startup, compiles it once with the OPA Go SDK, and evaluates it per
|
||||||
|
// request against the input the HTTP handler assembles from the two
|
||||||
|
// identity lookups + the requested key_ref.
|
||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"basicstack.de/proofedmesh/services/signing-service/internal/registry"
|
||||||
|
"github.com/open-policy-agent/opa/rego"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Input is the structured input the signing-service HTTP handler builds
|
||||||
|
// for every /api/v1/sign/dsse call. It is passed verbatim to Rego under
|
||||||
|
// the top-level `input` document.
|
||||||
|
type Input struct {
|
||||||
|
PocketIDClient string `json:"pocket_id_client"`
|
||||||
|
K8s InputK8s `json:"k8s"`
|
||||||
|
KeyRef string `json:"key_ref"`
|
||||||
|
Registry InputRegistry `json:"registry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InputK8s is the authenticated Kubernetes identity (from TokenReview).
|
||||||
|
type InputK8s struct {
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
ServiceAccount string `json:"service_account"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InputRegistry carries both registry lookups (by pocket id + by k8s).
|
||||||
|
// Each may be nil if the lookup returned ErrNotFound; the policy treats
|
||||||
|
// a nil row as a hard identity failure.
|
||||||
|
type InputRegistry struct {
|
||||||
|
ByPocketID *InputRegistryRow `json:"by_pocket_id"`
|
||||||
|
ByK8s *InputRegistryRow `json:"by_k8s"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InputRegistryRow is a stripped-down `agents` row.
|
||||||
|
type InputRegistryRow struct {
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
PocketIDClient string `json:"pocket_id_client"`
|
||||||
|
K8sNamespace string `json:"k8s_namespace"`
|
||||||
|
K8sServiceAccount string `json:"k8s_service_account"`
|
||||||
|
AllowedActions []string `json:"allowed_actions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromAgent builds a non-nil InputRegistryRow from a registry.Agent.
|
||||||
|
func FromAgent(a registry.Agent) *InputRegistryRow {
|
||||||
|
return &InputRegistryRow{
|
||||||
|
AgentID: a.AgentID,
|
||||||
|
PocketIDClient: a.PocketIDClient,
|
||||||
|
K8sNamespace: a.K8sNamespace,
|
||||||
|
K8sServiceAccount: a.K8sServiceAccount,
|
||||||
|
AllowedActions: a.AllowedActions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StructuredDecider is a lower-level decider interface that policies that
|
||||||
|
// need the full Input (not just subject/keyRef) implement. The signing
|
||||||
|
// handler uses this when a StructuredDecider is wired; otherwise it falls
|
||||||
|
// back to the subject/keyRef Decider.
|
||||||
|
type StructuredDecider interface {
|
||||||
|
Decide(ctx context.Context, in Input) (Decision, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OPAEvaluator is a StructuredDecider backed by an in-process Rego engine.
|
||||||
|
type OPAEvaluator struct {
|
||||||
|
allow rego.PreparedEvalQuery
|
||||||
|
denyReas rego.PreparedEvalQuery
|
||||||
|
modulePkg string
|
||||||
|
src string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOPAEvaluator loads and compiles the Rego policy at path.
|
||||||
|
// The policy package MUST be `proofedmesh.sinks.signing_service` (the
|
||||||
|
// default policy file ships with that package name).
|
||||||
|
func NewOPAEvaluator(ctx context.Context, path string) (*OPAEvaluator, error) {
|
||||||
|
src, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("policy: read rego: %w", err)
|
||||||
|
}
|
||||||
|
return newOPAEvaluatorFromSource(ctx, path, string(src))
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOPAEvaluatorFromSource(ctx context.Context, name, src string) (*OPAEvaluator, error) {
|
||||||
|
pkg := "proofedmesh.sinks.signing_service"
|
||||||
|
allowQ, err := rego.New(
|
||||||
|
rego.Query("data."+pkg+".allow"),
|
||||||
|
rego.Module(name, src),
|
||||||
|
).PrepareForEval(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("policy: prepare allow: %w", err)
|
||||||
|
}
|
||||||
|
denyQ, err := rego.New(
|
||||||
|
rego.Query("data."+pkg+".deny_reason"),
|
||||||
|
rego.Module(name, src),
|
||||||
|
).PrepareForEval(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("policy: prepare deny_reason: %w", err)
|
||||||
|
}
|
||||||
|
return &OPAEvaluator{allow: allowQ, denyReas: denyQ, modulePkg: pkg, src: src}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide evaluates the Rego policy against the given input.
|
||||||
|
func (o *OPAEvaluator) Decide(ctx context.Context, in Input) (Decision, error) {
|
||||||
|
// Rego evaluates `input` as the parsed JSON-like document. Marshal
|
||||||
|
// through json tags to get deterministic snake_case keys.
|
||||||
|
raw, err := toRegoInput(in)
|
||||||
|
if err != nil {
|
||||||
|
return Decision{}, err
|
||||||
|
}
|
||||||
|
rs, err := o.allow.Eval(ctx, rego.EvalInput(raw))
|
||||||
|
if err != nil {
|
||||||
|
return Decision{}, fmt.Errorf("policy: eval allow: %w", err)
|
||||||
|
}
|
||||||
|
allowed := false
|
||||||
|
if len(rs) > 0 && len(rs[0].Expressions) > 0 {
|
||||||
|
if v, ok := rs[0].Expressions[0].Value.(bool); ok {
|
||||||
|
allowed = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allowed {
|
||||||
|
return Decision{Allow: true}, nil
|
||||||
|
}
|
||||||
|
// Collect deny_reason set for a usable 403 message.
|
||||||
|
drs, err := o.denyReas.Eval(ctx, rego.EvalInput(raw))
|
||||||
|
if err != nil {
|
||||||
|
return Decision{}, fmt.Errorf("policy: eval deny_reason: %w", err)
|
||||||
|
}
|
||||||
|
reason := "signing not permitted by policy"
|
||||||
|
if len(drs) > 0 && len(drs[0].Expressions) > 0 {
|
||||||
|
if arr, ok := drs[0].Expressions[0].Value.([]any); ok && len(arr) > 0 {
|
||||||
|
reason = joinReasons(arr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Decision{Allow: false, Reason: reason}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StructuredAdapter wraps a StructuredDecider so it also satisfies the
|
||||||
|
// legacy subject+keyRef Decider contract (used by the HTTP layer's
|
||||||
|
// fallback path and existing tests).
|
||||||
|
type StructuredAdapter struct {
|
||||||
|
Decider StructuredDecider
|
||||||
|
Build func(subject, keyRef string) Input
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a StructuredAdapter) Allow(ctx context.Context, subject, keyRef string) (Decision, error) {
|
||||||
|
if a.Build == nil {
|
||||||
|
return Decision{}, errors.New("policy.StructuredAdapter: Build func is nil")
|
||||||
|
}
|
||||||
|
return a.Decider.Decide(ctx, a.Build(subject, keyRef))
|
||||||
|
}
|
||||||
|
|
||||||
|
func toRegoInput(in Input) (map[string]any, error) {
|
||||||
|
// Round-trip through JSON to collapse to anonymous maps/slices Rego
|
||||||
|
// handles directly, honouring our json tags (including nil-ness of
|
||||||
|
// *InputRegistryRow).
|
||||||
|
b, err := json.Marshal(in)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("policy: marshal input: %w", err)
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.Unmarshal(b, &out); err != nil {
|
||||||
|
return nil, fmt.Errorf("policy: unmarshal input: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinReasons(arr []any) string {
|
||||||
|
// Keep this tiny; policy reasons are small sets of short strings.
|
||||||
|
out := ""
|
||||||
|
for i, v := range arr {
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i > 0 {
|
||||||
|
out += "; "
|
||||||
|
}
|
||||||
|
out += s
|
||||||
|
}
|
||||||
|
if out == "" {
|
||||||
|
return "signing not permitted by policy"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
147
services/signing-service/internal/policy/opa_test.go
Normal file
147
services/signing-service/internal/policy/opa_test.go
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadRealPolicy compiles the committed policies/sinks/signing_service.rego
|
||||||
|
// so the tests fail if we rename the package or break the file.
|
||||||
|
func loadRealPolicy(t *testing.T) *OPAEvaluator {
|
||||||
|
t.Helper()
|
||||||
|
// From services/signing-service, walk up to repo root (two parents),
|
||||||
|
// then into policies/sinks/signing_service.rego.
|
||||||
|
p := filepath.Join("..", "..", "..", "..", "policies", "sinks", "signing_service.rego")
|
||||||
|
opa, err := NewOPAEvaluator(context.Background(), p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load policy: %v", err)
|
||||||
|
}
|
||||||
|
return opa
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeInput() Input {
|
||||||
|
by := InputRegistryRow{
|
||||||
|
AgentID: "agent-approval-service",
|
||||||
|
PocketIDClient: "agent-approval-service",
|
||||||
|
K8sNamespace: "agent-trust",
|
||||||
|
K8sServiceAccount: "approval-service",
|
||||||
|
AllowedActions: []string{"expense.submit"},
|
||||||
|
}
|
||||||
|
return Input{
|
||||||
|
PocketIDClient: "agent-approval-service",
|
||||||
|
K8s: InputK8s{
|
||||||
|
Namespace: "agent-trust",
|
||||||
|
ServiceAccount: "approval-service",
|
||||||
|
},
|
||||||
|
KeyRef: "trust-api/approval-signer-2026-q4",
|
||||||
|
Registry: InputRegistry{
|
||||||
|
ByPocketID: &by,
|
||||||
|
ByK8s: &by,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOPA_Allows_ApprovalService_HappyPath(t *testing.T) {
|
||||||
|
opa := loadRealPolicy(t)
|
||||||
|
dec, err := opa.Decide(context.Background(), makeInput())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decide: %v", err)
|
||||||
|
}
|
||||||
|
if !dec.Allow {
|
||||||
|
t.Fatalf("expected allow, got deny: %q", dec.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOPA_Allows_TestAgentTransform_HappyPath(t *testing.T) {
|
||||||
|
opa := loadRealPolicy(t)
|
||||||
|
by := InputRegistryRow{
|
||||||
|
AgentID: "agent-testagent-transform",
|
||||||
|
PocketIDClient: "agent-testagent-transform",
|
||||||
|
K8sNamespace: "agent-trust",
|
||||||
|
K8sServiceAccount: "testagent-transform",
|
||||||
|
}
|
||||||
|
in := Input{
|
||||||
|
PocketIDClient: "agent-testagent-transform",
|
||||||
|
K8s: InputK8s{Namespace: "agent-trust", ServiceAccount: "testagent-transform"},
|
||||||
|
KeyRef: "trust-api/agent-testagent-transform-2026-q4",
|
||||||
|
Registry: InputRegistry{ByPocketID: &by, ByK8s: &by},
|
||||||
|
}
|
||||||
|
dec, err := opa.Decide(context.Background(), in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decide: %v", err)
|
||||||
|
}
|
||||||
|
if !dec.Allow {
|
||||||
|
t.Fatalf("expected allow, got deny: %q", dec.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOPA_Denies_RegistryMismatch(t *testing.T) {
|
||||||
|
opa := loadRealPolicy(t)
|
||||||
|
in := makeInput()
|
||||||
|
// By-K8s returns a DIFFERENT agent row than By-PocketID — the double
|
||||||
|
// identity check must deny.
|
||||||
|
byK := InputRegistryRow{
|
||||||
|
AgentID: "agent-testagent-transform",
|
||||||
|
PocketIDClient: "agent-testagent-transform",
|
||||||
|
K8sNamespace: "agent-trust",
|
||||||
|
K8sServiceAccount: "testagent-transform",
|
||||||
|
}
|
||||||
|
in.Registry.ByK8s = &byK
|
||||||
|
dec, err := opa.Decide(context.Background(), in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decide: %v", err)
|
||||||
|
}
|
||||||
|
if dec.Allow {
|
||||||
|
t.Fatalf("expected deny on registry mismatch, got allow")
|
||||||
|
}
|
||||||
|
if !strings.Contains(dec.Reason, "do not resolve to the same registry agent") {
|
||||||
|
t.Errorf("unexpected reason: %q", dec.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOPA_Denies_SubjectMismatchFromCaller(t *testing.T) {
|
||||||
|
opa := loadRealPolicy(t)
|
||||||
|
in := makeInput()
|
||||||
|
// Caller presented a different Pocket-ID subject than the one stored
|
||||||
|
// in the registry row — the policy must deny because the row no longer
|
||||||
|
// matches the authenticated identity.
|
||||||
|
in.PocketIDClient = "spoofed-client"
|
||||||
|
dec, err := opa.Decide(context.Background(), in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decide: %v", err)
|
||||||
|
}
|
||||||
|
if dec.Allow {
|
||||||
|
t.Fatalf("expected deny when caller subject mismatches registry, got allow")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOPA_Denies_WrongKeyRefForRole(t *testing.T) {
|
||||||
|
opa := loadRealPolicy(t)
|
||||||
|
in := makeInput()
|
||||||
|
in.KeyRef = "trust-api/agent-testagent-transform-2026-q4" // belongs to other agent
|
||||||
|
dec, err := opa.Decide(context.Background(), in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decide: %v", err)
|
||||||
|
}
|
||||||
|
if dec.Allow {
|
||||||
|
t.Fatalf("expected deny on wrong key_ref, got allow")
|
||||||
|
}
|
||||||
|
if !strings.Contains(dec.Reason, "not permitted to sign") {
|
||||||
|
t.Errorf("unexpected reason: %q", dec.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOPA_Denies_NilRegistryRow(t *testing.T) {
|
||||||
|
opa := loadRealPolicy(t)
|
||||||
|
in := makeInput()
|
||||||
|
in.Registry.ByK8s = nil
|
||||||
|
dec, err := opa.Decide(context.Background(), in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decide: %v", err)
|
||||||
|
}
|
||||||
|
if dec.Allow {
|
||||||
|
t.Fatalf("expected deny on missing registry row, got allow")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,6 @@ package policy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Decision carries both the boolean verdict and (on deny) a human-readable
|
// Decision carries both the boolean verdict and (on deny) a human-readable
|
||||||
|
|
@ -27,11 +26,3 @@ func (AllowAll) Allow(_ context.Context, _, _ string) (Decision, error) {
|
||||||
return Decision{Allow: true}, nil
|
return Decision{Allow: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// OPA is a stub for the real Rego-backed policy evaluator.
|
|
||||||
type OPA struct{}
|
|
||||||
|
|
||||||
var errNotWired = errors.New("policy.OPA: not wired yet (follow-up subtask)")
|
|
||||||
|
|
||||||
func (OPA) Allow(_ context.Context, _, _ string) (Decision, error) {
|
|
||||||
return Decision{}, errNotWired
|
|
||||||
}
|
|
||||||
|
|
|
||||||
125
services/signing-service/internal/registry/registry.go
Normal file
125
services/signing-service/internal/registry/registry.go
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
// Package registry resolves ProofedMesh agent identities against the
|
||||||
|
// trust-api `agents` table (db/trust/migrations/0002_agents.up.sql).
|
||||||
|
//
|
||||||
|
// The signing-service calls this package twice per request — once by
|
||||||
|
// pocket_id_client (from the Pocket-ID JWT `sub` claim) and once by
|
||||||
|
// (k8s_namespace, k8s_service_account) (from a Kubernetes TokenReview).
|
||||||
|
// The results are handed to OPA which denies unless both lookups land on
|
||||||
|
// the same, internally consistent agent row.
|
||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotFound means no agent row matched the given identity. Callers
|
||||||
|
// should treat this as a hard identity failure (403), never a 500.
|
||||||
|
var ErrNotFound = errors.New("registry: agent not found")
|
||||||
|
|
||||||
|
// Agent is the subset of the trust-api `agents` row that the signing
|
||||||
|
// service needs for identity reconciliation and policy evaluation.
|
||||||
|
type Agent struct {
|
||||||
|
AgentID string
|
||||||
|
PocketIDClient string
|
||||||
|
K8sNamespace string
|
||||||
|
K8sServiceAccount string
|
||||||
|
DisplayName string
|
||||||
|
AllowedActions []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolver abstracts the backing store so tests can swap in an in-memory
|
||||||
|
// implementation.
|
||||||
|
type Resolver interface {
|
||||||
|
ByPocketID(ctx context.Context, client string) (Agent, error)
|
||||||
|
ByK8sIdentity(ctx context.Context, namespace, serviceAccount string) (Agent, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static is an in-memory resolver suitable for unit tests and the
|
||||||
|
// never-happens case where we want to pin the signing-service to a
|
||||||
|
// deterministic set of identities.
|
||||||
|
type Static struct {
|
||||||
|
ByPocketIDMap map[string]Agent
|
||||||
|
ByK8sIdentityMap map[string]Agent // key: ns + "/" + sa
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStatic builds a Static resolver from a slice of agents.
|
||||||
|
func NewStatic(agents []Agent) *Static {
|
||||||
|
byP := make(map[string]Agent, len(agents))
|
||||||
|
byK := make(map[string]Agent, len(agents))
|
||||||
|
for _, a := range agents {
|
||||||
|
byP[a.PocketIDClient] = a
|
||||||
|
byK[a.K8sNamespace+"/"+a.K8sServiceAccount] = a
|
||||||
|
}
|
||||||
|
return &Static{ByPocketIDMap: byP, ByK8sIdentityMap: byK}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Static) ByPocketID(_ context.Context, client string) (Agent, error) {
|
||||||
|
a, ok := s.ByPocketIDMap[client]
|
||||||
|
if !ok {
|
||||||
|
return Agent{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Static) ByK8sIdentity(_ context.Context, namespace, serviceAccount string) (Agent, error) {
|
||||||
|
a, ok := s.ByK8sIdentityMap[namespace+"/"+serviceAccount]
|
||||||
|
if !ok {
|
||||||
|
return Agent{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Postgres is the production resolver, backed by the trust-api database.
|
||||||
|
type Postgres struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPostgres opens a connection pool at the given DSN. Caller owns Close.
|
||||||
|
func NewPostgres(ctx context.Context, dsn string) (*Postgres, error) {
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("registry: pgxpool.New: %w", err)
|
||||||
|
}
|
||||||
|
return &Postgres{pool: pool}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the pool.
|
||||||
|
func (p *Postgres) Close() {
|
||||||
|
if p.pool != nil {
|
||||||
|
p.pool.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectCols = `agent_id, pocket_id_client, k8s_namespace, k8s_service_account, display_name, allowed_actions`
|
||||||
|
|
||||||
|
func (p *Postgres) ByPocketID(ctx context.Context, client string) (Agent, error) {
|
||||||
|
row := p.pool.QueryRow(ctx, `SELECT `+selectCols+` FROM agents WHERE pocket_id_client = $1`, client)
|
||||||
|
return scanAgent(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) ByK8sIdentity(ctx context.Context, namespace, serviceAccount string) (Agent, error) {
|
||||||
|
row := p.pool.QueryRow(ctx,
|
||||||
|
`SELECT `+selectCols+` FROM agents WHERE k8s_namespace = $1 AND k8s_service_account = $2`,
|
||||||
|
namespace, serviceAccount)
|
||||||
|
return scanAgent(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
type scannable interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAgent(row scannable) (Agent, error) {
|
||||||
|
var a Agent
|
||||||
|
if err := row.Scan(&a.AgentID, &a.PocketIDClient, &a.K8sNamespace, &a.K8sServiceAccount, &a.DisplayName, &a.AllowedActions); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Agent{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return Agent{}, fmt.Errorf("registry: scan: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
29
services/signing-service/internal/registry/registry_test.go
Normal file
29
services/signing-service/internal/registry/registry_test.go
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStatic_LookupBothWays(t *testing.T) {
|
||||||
|
s := NewStatic([]Agent{
|
||||||
|
{AgentID: "agent-approval-service", PocketIDClient: "agent-approval-service", K8sNamespace: "agent-trust", K8sServiceAccount: "approval-service"},
|
||||||
|
{AgentID: "agent-testagent-transform", PocketIDClient: "agent-testagent-transform", K8sNamespace: "agent-trust", K8sServiceAccount: "testagent-transform"},
|
||||||
|
})
|
||||||
|
ctx := context.Background()
|
||||||
|
got, err := s.ByPocketID(ctx, "agent-approval-service")
|
||||||
|
if err != nil || got.AgentID != "agent-approval-service" {
|
||||||
|
t.Fatalf("ByPocketID: got %+v, err %v", got, err)
|
||||||
|
}
|
||||||
|
got, err = s.ByK8sIdentity(ctx, "agent-trust", "testagent-transform")
|
||||||
|
if err != nil || got.AgentID != "agent-testagent-transform" {
|
||||||
|
t.Fatalf("ByK8sIdentity: got %+v, err %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := s.ByPocketID(ctx, "nope"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.ByK8sIdentity(ctx, "agent-trust", "nope"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
223
services/signing-service/internal/token/k8s_tokenreview.go
Normal file
223
services/signing-service/internal/token/k8s_tokenreview.go
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
// Kubernetes TokenReview-backed workload-identity validator.
|
||||||
|
//
|
||||||
|
// The signing-service receives the workload's projected ServiceAccount
|
||||||
|
// token in the `X-Workload-Token` header. We submit that token to the
|
||||||
|
// cluster via `authentication.k8s.io/v1 TokenReview` using the pod's own
|
||||||
|
// in-cluster credentials, and return the authenticated
|
||||||
|
// `system:serviceaccount:<ns>:<sa>` as (namespace, serviceAccount).
|
||||||
|
//
|
||||||
|
// This file deliberately does not import k8s.io/client-go to keep the
|
||||||
|
// dependency surface narrow; a hand-rolled HTTPS POST against the API
|
||||||
|
// server is all that's required.
|
||||||
|
package token
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// WorkloadTokenHeader is the HTTP header carrying the workload's
|
||||||
|
// projected SA token (not the Authorization header).
|
||||||
|
WorkloadTokenHeader = "X-Workload-Token"
|
||||||
|
|
||||||
|
defaultSATokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
||||||
|
defaultCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNoWorkloadToken is returned when the X-Workload-Token header is
|
||||||
|
// missing. Caller MUST map this to a 401.
|
||||||
|
var ErrNoWorkloadToken = errors.New("tokenreview: missing X-Workload-Token header")
|
||||||
|
|
||||||
|
// ErrWorkloadAuthFailed is returned when TokenReview reports the token is
|
||||||
|
// invalid or unauthenticated. Caller MUST map this to a 401.
|
||||||
|
var ErrWorkloadAuthFailed = errors.New("tokenreview: workload token not authenticated")
|
||||||
|
|
||||||
|
// K8sIdentity is the subset of a TokenReview UserInfo we care about.
|
||||||
|
type K8sIdentity struct {
|
||||||
|
Namespace string
|
||||||
|
ServiceAccount string
|
||||||
|
// UID is the ServiceAccount's UID (useful for audit; may be empty for
|
||||||
|
// non-SA callers which should be rejected before this type is built).
|
||||||
|
UID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenReviewer validates a workload-presented token and returns the
|
||||||
|
// authenticated ServiceAccount identity.
|
||||||
|
type TokenReviewer interface {
|
||||||
|
Review(ctx context.Context, workloadToken string) (K8sIdentity, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromWorkloadHeader extracts the workload token from the X-Workload-Token
|
||||||
|
// header; it does NOT accept Bearer prefixes because this header is
|
||||||
|
// documented as raw-token.
|
||||||
|
func FromWorkloadHeader(r *http.Request) (string, error) {
|
||||||
|
t := strings.TrimSpace(r.Header.Get(WorkloadTokenHeader))
|
||||||
|
if t == "" {
|
||||||
|
return "", ErrNoWorkloadToken
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaticTokenReviewer is a test seam: a fixed table from workload token
|
||||||
|
// → identity. All production paths use K8sTokenReviewer.
|
||||||
|
type StaticTokenReviewer map[string]K8sIdentity
|
||||||
|
|
||||||
|
func (s StaticTokenReviewer) Review(_ context.Context, workloadToken string) (K8sIdentity, error) {
|
||||||
|
id, ok := s[workloadToken]
|
||||||
|
if !ok {
|
||||||
|
return K8sIdentity{}, ErrWorkloadAuthFailed
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// K8sTokenReviewer talks to a Kubernetes API server.
|
||||||
|
type K8sTokenReviewer struct {
|
||||||
|
APIServer string // e.g. "https://kubernetes.default.svc"
|
||||||
|
BearerToken string // pod's own SA token (not the workload token)
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInClusterTokenReviewer loads the pod's SA token and the cluster CA
|
||||||
|
// from the standard in-cluster paths and wires up a TLS-pinned client.
|
||||||
|
// The returned reviewer talks to `https://kubernetes.default.svc`.
|
||||||
|
func NewInClusterTokenReviewer() (*K8sTokenReviewer, error) {
|
||||||
|
return NewTokenReviewerFromPaths(
|
||||||
|
"https://kubernetes.default.svc",
|
||||||
|
defaultSATokenPath,
|
||||||
|
defaultCAPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTokenReviewerFromPaths loads SA token & CA from explicit paths; the
|
||||||
|
// in-cluster helper above is a thin wrapper.
|
||||||
|
func NewTokenReviewerFromPaths(apiServer, tokenPath, caPath string) (*K8sTokenReviewer, error) {
|
||||||
|
tokenBytes, err := os.ReadFile(tokenPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tokenreview: read SA token: %w", err)
|
||||||
|
}
|
||||||
|
caBytes, err := os.ReadFile(caPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tokenreview: read CA: %w", err)
|
||||||
|
}
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
if !pool.AppendCertsFromPEM(caBytes) {
|
||||||
|
return nil, errors.New("tokenreview: CA pem invalid")
|
||||||
|
}
|
||||||
|
hc := &http.Client{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return &K8sTokenReviewer{
|
||||||
|
APIServer: apiServer,
|
||||||
|
BearerToken: strings.TrimSpace(string(tokenBytes)),
|
||||||
|
HTTPClient: hc,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenReviewRequest / tokenReviewResponse mirror just the fields we
|
||||||
|
// read, so we don't pull in client-go's typed meta.
|
||||||
|
type tokenReviewRequest struct {
|
||||||
|
APIVersion string `json:"apiVersion"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Spec tokenReviewRequestSpec `json:"spec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tokenReviewRequestSpec struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tokenReviewResponse struct {
|
||||||
|
Status tokenReviewStatus `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tokenReviewStatus struct {
|
||||||
|
Authenticated bool `json:"authenticated"`
|
||||||
|
User tokenReviewUserInfo `json:"user"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tokenReviewUserInfo struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
UID string `json:"uid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *K8sTokenReviewer) Review(ctx context.Context, workloadToken string) (K8sIdentity, error) {
|
||||||
|
if workloadToken == "" {
|
||||||
|
return K8sIdentity{}, ErrNoWorkloadToken
|
||||||
|
}
|
||||||
|
payload := tokenReviewRequest{
|
||||||
|
APIVersion: "authentication.k8s.io/v1",
|
||||||
|
Kind: "TokenReview",
|
||||||
|
Spec: tokenReviewRequestSpec{Token: workloadToken},
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return K8sIdentity{}, fmt.Errorf("tokenreview: marshal: %w", err)
|
||||||
|
}
|
||||||
|
url := strings.TrimRight(r.APIServer, "/") + "/apis/authentication.k8s.io/v1/tokenreviews"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return K8sIdentity{}, fmt.Errorf("tokenreview: build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+r.BearerToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := r.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return K8sIdentity{}, fmt.Errorf("tokenreview: POST: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return K8sIdentity{}, fmt.Errorf("tokenreview: api server %d: %s", resp.StatusCode, truncate(string(raw), 256))
|
||||||
|
}
|
||||||
|
var out tokenReviewResponse
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return K8sIdentity{}, fmt.Errorf("tokenreview: decode: %w", err)
|
||||||
|
}
|
||||||
|
if !out.Status.Authenticated {
|
||||||
|
if out.Status.Error != "" {
|
||||||
|
return K8sIdentity{}, fmt.Errorf("%w: %s", ErrWorkloadAuthFailed, out.Status.Error)
|
||||||
|
}
|
||||||
|
return K8sIdentity{}, ErrWorkloadAuthFailed
|
||||||
|
}
|
||||||
|
ns, sa, ok := parseSAUsername(out.Status.User.Username)
|
||||||
|
if !ok {
|
||||||
|
return K8sIdentity{}, fmt.Errorf("%w: not a ServiceAccount: %q", ErrWorkloadAuthFailed, out.Status.User.Username)
|
||||||
|
}
|
||||||
|
return K8sIdentity{Namespace: ns, ServiceAccount: sa, UID: out.Status.User.UID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSAUsername extracts (ns, sa) from `system:serviceaccount:<ns>:<sa>`.
|
||||||
|
func parseSAUsername(u string) (string, string, bool) {
|
||||||
|
const prefix = "system:serviceaccount:"
|
||||||
|
if !strings.HasPrefix(u, prefix) {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
rest := u[len(prefix):]
|
||||||
|
i := strings.IndexByte(rest, ':')
|
||||||
|
if i <= 0 || i == len(rest)-1 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return rest[:i], rest[i+1:], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "…"
|
||||||
|
}
|
||||||
76
services/signing-service/internal/token/pocket_id.go
Normal file
76
services/signing-service/internal/token/pocket_id.go
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
// Pocket-ID OIDC-backed Bearer token validator.
|
||||||
|
//
|
||||||
|
// Phase 1 wires this up for the production path: the signing-service
|
||||||
|
// discovers the issuer, caches the JWKS, and auto-refreshes on `kid`
|
||||||
|
// rotation via coreos/go-oidc's RemoteKeySet.
|
||||||
|
package token
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PocketIDValidator verifies a Bearer JWT against a Pocket-ID issuer and
|
||||||
|
// returns the `sub` claim (which is the Pocket-ID client id).
|
||||||
|
type PocketIDValidator struct {
|
||||||
|
verifier *oidc.IDTokenVerifier
|
||||||
|
issuer string
|
||||||
|
audience string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPocketIDValidator performs OIDC discovery and returns a validator.
|
||||||
|
// If audience is non-empty, the verifier enforces the `aud` claim.
|
||||||
|
// SkipClientIDCheck is used when audience is empty because Pocket ID
|
||||||
|
// subject tokens may be addressed to a service name rather than a client.
|
||||||
|
func NewPocketIDValidator(ctx context.Context, issuer, audience string) (*PocketIDValidator, error) {
|
||||||
|
if issuer == "" {
|
||||||
|
return nil, errors.New("token.PocketID: empty issuer")
|
||||||
|
}
|
||||||
|
provider, err := oidc.NewProvider(ctx, issuer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("token.PocketID: oidc discovery for %q: %w", issuer, err)
|
||||||
|
}
|
||||||
|
cfg := &oidc.Config{
|
||||||
|
ClientID: audience,
|
||||||
|
SkipClientIDCheck: audience == "",
|
||||||
|
}
|
||||||
|
v := provider.Verifier(cfg)
|
||||||
|
return &PocketIDValidator{verifier: v, issuer: issuer, audience: audience}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate implements Validator. It parses, verifies and returns `sub`.
|
||||||
|
func (p *PocketIDValidator) Validate(ctx context.Context, bearer string) (string, error) {
|
||||||
|
if bearer == "" {
|
||||||
|
return "", ErrInvalid
|
||||||
|
}
|
||||||
|
idTok, err := p.verifier.Verify(ctx, bearer)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%w: %v", ErrInvalid, err)
|
||||||
|
}
|
||||||
|
if idTok.Subject == "" {
|
||||||
|
return "", fmt.Errorf("%w: missing sub claim", ErrInvalid)
|
||||||
|
}
|
||||||
|
return idTok.Subject, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issuer returns the configured issuer URL (for logs / healthz).
|
||||||
|
func (p *PocketIDValidator) Issuer() string { return p.issuer }
|
||||||
|
|
||||||
|
// NewPocketIDValidatorWithHTTPClient is the same as NewPocketIDValidator
|
||||||
|
// but uses the supplied HTTP client (test seams, custom timeouts).
|
||||||
|
func NewPocketIDValidatorWithHTTPClient(ctx context.Context, issuer, audience string, client *http.Client) (*PocketIDValidator, error) {
|
||||||
|
if client != nil {
|
||||||
|
ctx = oidc.ClientContext(ctx, client)
|
||||||
|
}
|
||||||
|
return NewPocketIDValidator(ctx, issuer, audience)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignerRefreshInterval is advisory — go-oidc refreshes on `kid` miss but
|
||||||
|
// callers who want proactive refresh can wrap this in a timer. We expose
|
||||||
|
// it so cmd/ can decide a cadence.
|
||||||
|
const SignerRefreshInterval = 15 * time.Minute
|
||||||
|
|
@ -32,13 +32,6 @@ func (s Static) Validate(_ context.Context, bearer string) (string, error) {
|
||||||
return sub, nil
|
return sub, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PocketID is a stub for a JWKS-based Pocket ID token verifier.
|
|
||||||
type PocketID struct{}
|
|
||||||
|
|
||||||
func (PocketID) Validate(_ context.Context, _ string) (string, error) {
|
|
||||||
return "", errors.New("token.PocketID: not wired yet (follow-up subtask)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// FromAuthHeader extracts the bearer from an "Authorization: Bearer …" header.
|
// FromAuthHeader extracts the bearer from an "Authorization: Bearer …" header.
|
||||||
func FromAuthHeader(r *http.Request) (string, error) {
|
func FromAuthHeader(r *http.Request) (string, error) {
|
||||||
h := r.Header.Get("Authorization")
|
h := r.Header.Get("Authorization")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue