2026-10-25 18:02:44 +00:00
|
|
|
// Package httpapi exposes the signing-service's single endpoint:
|
|
|
|
|
// POST /api/v1/sign/dsse per ADR-0005.
|
|
|
|
|
package httpapi
|
|
|
|
|
|
|
|
|
|
import (
|
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>
2026-10-25 19:42:40 +00:00
|
|
|
"context"
|
2026-10-25 18:02:44 +00:00
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"net/http"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"basicstack.de/proofedmesh/services/signing-service/internal/policy"
|
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>
2026-10-25 19:42:40 +00:00
|
|
|
"basicstack.de/proofedmesh/services/signing-service/internal/registry"
|
2026-10-25 18:02:44 +00:00
|
|
|
"basicstack.de/proofedmesh/services/signing-service/internal/signer"
|
|
|
|
|
"basicstack.de/proofedmesh/services/signing-service/internal/token"
|
|
|
|
|
)
|
|
|
|
|
|
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>
2026-10-25 19:42:40 +00:00
|
|
|
// 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.
|
2026-10-25 18:02:44 +00:00
|
|
|
type Server struct {
|
|
|
|
|
Signer signer.Signer
|
|
|
|
|
Validator token.Validator
|
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>
2026-10-25 19:42:40 +00:00
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
Now func() time.Time
|
2026-10-25 18:02:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s Server) Mount(mux *http.ServeMux) {
|
|
|
|
|
mux.HandleFunc("POST /api/v1/sign/dsse", s.handleSign)
|
|
|
|
|
mux.HandleFunc("GET /healthz", s.handleHealth)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type signRequest struct {
|
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>
2026-10-25 19:42:40 +00:00
|
|
|
KeyRef string `json:"key_ref"`
|
2026-10-25 18:02:44 +00:00
|
|
|
Statement signer.Statement `json:"statement"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type signResponse struct {
|
|
|
|
|
Envelope signer.Envelope `json:"envelope"`
|
|
|
|
|
ContentHash string `json:"content_hash"`
|
|
|
|
|
SignedAt time.Time `json:"signed_at"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type errResponse struct {
|
|
|
|
|
Error errBody `json:"error"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type errBody struct {
|
|
|
|
|
Code string `json:"code"`
|
|
|
|
|
Message string `json:"message"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s Server) handleSign(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
ctx := r.Context()
|
|
|
|
|
|
|
|
|
|
bearer, err := token.FromAuthHeader(r)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeErr(w, http.StatusUnauthorized, "unauthorized", "missing or malformed Authorization header")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
subject, err := s.Validator.Validate(ctx, bearer)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeErr(w, http.StatusUnauthorized, "unauthorized", err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var req signRequest
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("decode body: %v", err))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if req.KeyRef == "" {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "key_ref is required")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if req.Statement == nil {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "statement is required")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if t, _ := req.Statement["_type"].(string); t != "https://in-toto.io/Statement/v1" {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, "bad_request", `statement._type must be "https://in-toto.io/Statement/v1"`)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if pt, _ := req.Statement["predicateType"].(string); pt == "" {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "statement.predicateType is required")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-10-25 19:42:40 +00:00
|
|
|
// 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)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeErr(w, http.StatusInternalServerError, "policy_eval_error", err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if !dec.Allow {
|
|
|
|
|
writeErr(w, http.StatusForbidden, "signing_denied", dec.Reason)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-25 18:02:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
signed, err := signer.SignStatement(ctx, s.Signer, req.KeyRef, req.Statement)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, signer.ErrUnknownKey) {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, "unknown_key", err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if errors.Is(err, signer.ErrNotWired) {
|
|
|
|
|
writeErr(w, http.StatusServiceUnavailable, "signer_unavailable", err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeErr(w, http.StatusInternalServerError, "sign_failed", err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if s.Logger != nil {
|
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>
2026-10-25 19:42:40 +00:00
|
|
|
s.Logger.Info("signed",
|
|
|
|
|
"subject", subject,
|
|
|
|
|
"key_ref", req.KeyRef,
|
|
|
|
|
"content_hash", signed.ContentHash,
|
|
|
|
|
)
|
2026-10-25 18:02:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
now := time.Now().UTC()
|
|
|
|
|
if s.Now != nil {
|
|
|
|
|
now = s.Now()
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
_ = json.NewEncoder(w).Encode(signResponse{
|
|
|
|
|
Envelope: signed.Envelope,
|
|
|
|
|
ContentHash: signed.ContentHash,
|
|
|
|
|
SignedAt: now,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-10-25 19:42:40 +00:00
|
|
|
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 }
|
|
|
|
|
|
2026-10-25 18:02:44 +00:00
|
|
|
func writeErr(w http.ResponseWriter, status int, code, message string) {
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
w.WriteHeader(status)
|
|
|
|
|
_ = json.NewEncoder(w).Encode(errResponse{Error: errBody{Code: code, Message: message}})
|
|
|
|
|
}
|