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:
CTO (Paperclip) 2026-10-25 19:42:40 +00:00
parent 3a01cc651f
commit 30dd2f8b65
19 changed files with 1862 additions and 107 deletions

View file

@ -7,4 +7,7 @@ metadata:
app.kubernetes.io/name: signing-service
app.kubernetes.io/part-of: proofedmesh
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

View 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

View file

@ -26,7 +26,12 @@ spec:
app.kubernetes.io/component: trust-core
spec:
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:
runAsNonRoot: true
runAsUser: 65532
@ -41,22 +46,42 @@ spec:
args:
- "-listen=:8081"
- "-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"
- "-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:
# Pulled from the dev Secret below; see deploy/signing-service/README.md
# for the full secret shape and the SealedSecret recipe.
- name: SIGNING_BEARERS
# ---- Pocket-ID OIDC discovery (DEV-609) ----
- name: POCKET_ID_ISSUER
valueFrom:
secretKeyRef:
name: signing-service-dev
key: SIGNING_BEARERS
- name: KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4
name: signing-service-prod
key: POCKET_ID_ISSUER
- name: POCKET_ID_AUDIENCE
valueFrom:
secretKeyRef:
name: signing-service-dev
key: KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4
name: signing-service-prod
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:
- name: http
containerPort: 8081
@ -77,17 +102,58 @@ spec:
failureThreshold: 3
resources:
requests:
cpu: "50m"
memory: "64Mi"
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
cpu: "1000m"
memory: "512Mi"
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- 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:
- maxSkew: 1
topologyKey: kubernetes.io/hostname

View file

@ -9,7 +9,8 @@ directory assumes that file (or an equivalent) is already applied.
```
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
├── 20-deployment.yaml — Deployment (non-root, read-only rootfs, distroless)
├── 30-service.yaml — ClusterIP Service on :8081
@ -17,6 +18,27 @@ deploy/signing-service/
└── 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
```bash

View 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],
)
}

View file

@ -9,17 +9,21 @@ ENV CGO_ENABLED=0 \
WORKDIR /src
# Only the signing-service module is needed. Copy the module files first so
# the dependency download layer is cacheable independently of source edits.
COPY services/signing-service/go.mod ./
# go.sum will be copied in when the module gains external deps; today the
# module is stdlib-only.
# The image reproduces the repo layout under /src so Go module paths and
# the OPA policy's relative location (`../../../../policies/sinks/...`
# in tests) line up both in CI and inside the image.
COPY services/signing-service/go.mod services/signing-service/go.sum ./services/signing-service/
RUN --mount=type=cache,target=/root/.cache/go-build \
--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 \
--mount=type=cache,target=/go/pkg/mod \
go vet ./... && \

View file

@ -1,9 +1,13 @@
// Command signing-service exposes POST /api/v1/sign/dsse per ADR-0005.
//
// Dev mode is self-contained: in-memory Ed25519 keyring, AllowAll policy,
// 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
// swapping backends does not touch the HTTP layer.
// Modes
//
// - Dev (default): memory signer, AllowAll policy, static bearer table.
// 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
import (
@ -23,6 +27,7 @@ import (
"basicstack.de/proofedmesh/services/signing-service/internal/httpapi"
"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"
)
@ -35,8 +40,21 @@ func main() {
keysFromEnv := flag.Bool("keys-from-env", false,
"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",
"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)")
// 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()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
@ -51,52 +69,75 @@ func main() {
return
}
var sgn signer.Signer
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 == "" {
logger.Error("missing env for key", "env", envName)
os.Exit(1)
}
p, err := signer.DecodeHexEd25519Key(raw)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
sgn, err := buildSigner(*backend, *keyRefs, *keysFromEnv, logger)
if err != nil {
logger.Error("bad env key", "env", envName, "err", err)
logger.Error("signer init", "err", err)
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 {
_, 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 {
logger.Error("gen key", "err", err)
logger.Error("in-cluster tokenreview init", "err", err)
os.Exit(1)
}
priv = p
logger.Warn("generated ephemeral dev key — not safe for prod", "key_ref", kr)
reviewer = tr
logger.Info("k8s tokenreview wired")
}
mem.AddKey(kr, priv)
}
sgn = mem
case "openbao":
logger.Error("openbao backend not wired yet; use -backend=memory for dev")
var reg registry.Resolver
var pgReg *registry.Postgres
if *registryDSN != "" {
pr, err := registry.NewPostgres(ctx, *registryDSN)
if err != nil {
logger.Error("registry init", "err", err)
os.Exit(1)
default:
fmt.Fprintln(os.Stderr, "unknown -backend; expected memory|openbao")
os.Exit(2)
}
reg = pr
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{
Signer: sgn,
Validator: validator,
TokenReviewer: reviewer,
Registry: reg,
StructuredPolicy: structured,
Policy: policy.AllowAll{},
Validator: parseBearers(*bearers),
Logger: logger,
Now: time.Now,
}
mux := http.NewServeMux()
srv.Mount(mux)
@ -106,18 +147,64 @@ func main() {
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
logger.Warn("signing-service: AllowAll policy and static bearers are DEV ONLY")
logger.Info("signing-service listening", "addr", *listen, "backend", *backend)
logger.Info("signing-service listening",
"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 {
logger.Error("http serve", "err", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
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 {

View file

@ -1,3 +1,45 @@
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
)

View 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=

View file

@ -3,6 +3,7 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"fmt"
@ -11,14 +12,47 @@ import (
"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"
)
// 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 {
Signer signer.Signer
Policy policy.Decider
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
Now func() time.Time
}
@ -89,6 +123,23 @@ func (s Server) handleSign(w http.ResponseWriter, r *http.Request) {
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)
if err != nil {
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)
return
}
}
signed, err := signer.SignStatement(ctx, s.Signer, req.KeyRef, req.Statement)
if err != nil {
@ -114,7 +166,11 @@ func (s Server) handleSign(w http.ResponseWriter, r *http.Request) {
}
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()
@ -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) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)

View 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")
}
}

View 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
}

View 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")
}
}

View file

@ -5,7 +5,6 @@ package policy
import (
"context"
"errors"
)
// 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
}
// 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
}

View 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
}

View 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)
}
}

View 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] + "…"
}

View 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

View file

@ -32,13 +32,6 @@ func (s Static) Validate(_ context.Context, bearer string) (string, error) {
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.
func FromAuthHeader(r *http.Request) (string, error) {
h := r.Header.Get("Authorization")