ProofedMesh/services/signing-service/internal/policy/opa.go

193 lines
6.1 KiB
Go
Raw Normal View History

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