guildhouse-spire-plugins/pkg/governance/governance.go
Tyler King a58d548518 feat: network-policy extension, governance lifecycle, audit remediation
- Network-policy SPIRE plugin extension
- Governance event notification with merkle anchoring
- Shellstream specs for consent channels + HFL embedded ABI
- All 17 audit findings from AUDIT.md remediated
- SSH credential composer + substrate key manager updates
- Test coverage for config + sshcert packages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:54:46 -04:00

344 lines
11 KiB
Go

// Package governance provides a gRPC client for the Guildhouse GovernanceService
// and CeremonyService, used by SPIRE plugins to participate in governed mutations.
package governance
import (
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"time"
pb "github.com/guildhouse-cooperative/guildhouse-spire-plugins/gen/quartermaster/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
)
// Config holds governance client configuration.
type Config struct {
// GovernanceAddr is the gRPC address of the GovernanceService.
GovernanceAddr string
// CeremonyAddr is the gRPC address of the CeremonyService.
CeremonyAddr string
// NotaryAddr is the gRPC address of the NotaryService.
NotaryAddr string
// TLS configuration — REQUIRED for production.
// Uses SPIFFE-aware mTLS: the plugin's own SVID authenticates
// to Quartermaster services.
TLSCertPath string // Path to X.509 SVID certificate
TLSKeyPath string // Path to SVID private key
TLSCAPath string // Path to trust bundle (CA certificates)
TLSRequired bool // If true, NewClient fails without TLS config
}
// IntentResult holds the result of a CreateIntent call.
type IntentResult struct {
IntentID string
CeremonyID string // non-empty if ceremony required
Denied bool
Error string
}
// RedeemResult holds the result of a RedeemIntent call.
type RedeemResult struct {
Success bool
SatHash []byte
SatBytes []byte // raw SAT bytes for downstream verification
ExpiresAt time.Time // SAT expiry — consumers MUST check before use
Status string
Error string
}
// IsExpired returns true if the SAT has expired.
func (r *RedeemResult) IsExpired() bool {
return !r.ExpiresAt.IsZero() && time.Now().After(r.ExpiresAt)
}
// CredentialEvent describes a credential lifecycle event for merkle anchoring.
// The CredentialFingerprint field binds the merkle leaf to a specific credential,
// preventing proof replay across certificates (S-03).
type CredentialEvent struct {
EventType string // "issue", "rotate", "revoke"
IntentID string // governance intent UUID
CredentialFingerprint string // SHA-256 of certificate public key bytes, hex-encoded
SpiffeID string
TenantID string
CertSerialNumber uint64
IssuedAt time.Time
ExpiresAt time.Time
}
// CredentialVerification holds the parameters for verifying a credential's
// governance provenance via the NotaryService.
type CredentialVerification struct {
IntentID string // from governance-intent extension
CertificatePublicKey []byte // raw public key bytes from the certificate
}
// VerificationResult holds the result of a credential governance verification.
type VerificationResult struct {
Governed bool // true if the credential has valid governance provenance
AnchorID string // merkle anchor ID
FingerprintMatch bool // true if merkle leaf's fingerprint matches the cert
Error string
}
// Client wraps gRPC clients for GovernanceService, CeremonyService, and NotaryService.
type Client struct {
config Config
govConn *grpc.ClientConn
notaryConn *grpc.ClientConn
govClient pb.GovernanceServiceClient
notaryClient pb.QuartermasterNotaryClient
}
// NewClient creates a governance client with gRPC connections.
func NewClient(cfg Config) (*Client, error) {
if cfg.GovernanceAddr == "" {
return nil, fmt.Errorf("governance: governance address is required")
}
if cfg.TLSRequired {
if cfg.TLSCertPath == "" || cfg.TLSKeyPath == "" || cfg.TLSCAPath == "" {
return nil, fmt.Errorf("governance: TLS is required but cert/key/ca paths are not configured")
}
}
dialOpts, err := buildDialOptions(cfg)
if err != nil {
return nil, fmt.Errorf("governance: build dial options: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Connect to GovernanceService.
govConn, err := grpc.DialContext(ctx, cfg.GovernanceAddr, dialOpts...)
if err != nil {
return nil, fmt.Errorf("governance: connect to %s: %w", cfg.GovernanceAddr, err)
}
c := &Client{
config: cfg,
govConn: govConn,
govClient: pb.NewGovernanceServiceClient(govConn),
}
// Connect to NotaryService if address provided.
notaryAddr := cfg.NotaryAddr
if notaryAddr == "" {
notaryAddr = cfg.GovernanceAddr // Same host by default.
}
notaryConn, err := grpc.DialContext(ctx, notaryAddr, dialOpts...)
if err != nil {
govConn.Close()
return nil, fmt.Errorf("governance: connect to notary %s: %w", notaryAddr, err)
}
c.notaryConn = notaryConn
c.notaryClient = pb.NewQuartermasterNotaryClient(notaryConn)
return c, nil
}
// Close shuts down all gRPC connections.
func (c *Client) Close() error {
var firstErr error
if c.govConn != nil {
if err := c.govConn.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
if c.notaryConn != nil {
if err := c.notaryConn.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// CreateIntent creates a MutationIntent for a credential operation.
func (c *Client) CreateIntent(ctx context.Context, registryType, verb, artifactScope, tenantID string) (*IntentResult, error) {
resp, err := c.govClient.CreateIntent(ctx, &pb.CreateIntentRequest{
RegistryType: registryType,
Verb: verb,
ArtifactScope: artifactScope,
TenantId: tenantID,
})
if err != nil {
return nil, fmt.Errorf("governance: CreateIntent RPC: %w", err)
}
return &IntentResult{
IntentID: resp.IntentId,
CeremonyID: resp.CeremonyId,
Denied: resp.Denied,
Error: resp.Error,
}, nil
}
// RedeemIntent redeems a MutationIntent to obtain a SAT.
func (c *Client) RedeemIntent(ctx context.Context, intentID string) (*RedeemResult, error) {
resp, err := c.govClient.RedeemIntent(ctx, &pb.RedeemIntentRequest{
IntentId: intentID,
})
if err != nil {
return nil, fmt.Errorf("governance: RedeemIntent RPC: %w", err)
}
result := &RedeemResult{
Success: resp.Success,
Status: resp.Status,
Error: resp.Error,
}
if resp.Sat != nil {
result.SatHash = resp.Sat.SatHash
result.SatBytes = resp.Sat.SatBytes
if resp.Sat.ExpiresAt != nil {
result.ExpiresAt = resp.Sat.ExpiresAt.AsTime()
}
}
return result, nil
}
// CreateCeremony creates a governance ceremony.
func (c *Client) CreateCeremony(ctx context.Context, ceremonyType, intentID string, requiredApprovals uint32) (string, error) {
// CeremonyService is not yet defined in proto — use GovernanceService intent
// with ceremony_id from the response as a proxy.
return "", fmt.Errorf("governance: CreateCeremony requires CeremonyService proto (not yet generated)")
}
// SubmitMerkleLeaf submits a credential event as a merkle leaf to the NotaryService.
func (c *Client) SubmitMerkleLeaf(ctx context.Context, clusterID string, leaf []byte) (string, error) {
resp, err := c.notaryClient.CreateAnchor(ctx, &pb.CreateAnchorRequest{
ClusterId: clusterID,
Leaves: [][]byte{leaf},
})
if err != nil {
return "", fmt.Errorf("governance: SubmitMerkleLeaf RPC: %w", err)
}
return resp.AnchorId, nil
}
// NotarizeCredentialEvent sends a credential lifecycle event to the governance
// plane for merkle anchoring. The event MUST include a CredentialFingerprint
// to bind the merkle leaf to the specific certificate (S-03 fix).
func (c *Client) NotarizeCredentialEvent(ctx context.Context, event CredentialEvent) error {
if event.CredentialFingerprint == "" {
return fmt.Errorf("governance: credential_fingerprint is required for notarization")
}
if event.IntentID == "" {
return fmt.Errorf("governance: intent_id is required for notarization")
}
if event.EventType == "" {
return fmt.Errorf("governance: event_type is required for notarization")
}
// Construct MutationEnvelope payload (JCS-canonicalized via json.Marshal sorted keys).
envelope := map[string]interface{}{
"credential_fingerprint": event.CredentialFingerprint,
"event_type": event.EventType,
"intent_id": event.IntentID,
"spiffe_id": event.SpiffeID,
"tenant_id": event.TenantID,
}
if event.CertSerialNumber > 0 {
envelope["cert_serial_number"] = event.CertSerialNumber
}
envelopeBytes, err := json.Marshal(envelope)
if err != nil {
return fmt.Errorf("governance: marshal envelope: %w", err)
}
// Domain-separated SHA-256: "guildhouse.credential.v1:" prefix.
h := sha256.New()
h.Write([]byte("guildhouse.credential.v1:"))
h.Write(envelopeBytes)
leaf := h.Sum(nil)
_, err = c.SubmitMerkleLeaf(ctx, event.TenantID, leaf)
return err
}
// VerifyCredentialGovernance checks that a credential's governance provenance
// is valid by verifying the merkle proof binds to this specific credential.
func (c *Client) VerifyCredentialGovernance(ctx context.Context, v CredentialVerification) (*VerificationResult, error) {
if v.IntentID == "" {
return &VerificationResult{Governed: false, Error: "no governance intent"}, nil
}
if len(v.CertificatePublicKey) == 0 {
return nil, fmt.Errorf("governance: certificate public key is required for verification")
}
// Compute fingerprint of the certificate's public key.
certHash := sha256.Sum256(v.CertificatePublicKey)
expectedFingerprint := hex.EncodeToString(certHash[:])
// Construct the same leaf that was submitted during notarization.
envelope := map[string]interface{}{
"credential_fingerprint": expectedFingerprint,
"intent_id": v.IntentID,
}
envelopeBytes, _ := json.Marshal(envelope)
h := sha256.New()
h.Write([]byte("guildhouse.credential.v1:"))
h.Write(envelopeBytes)
leaf := h.Sum(nil)
// Verify inclusion in the merkle tree via NotaryService.
resp, err := c.notaryClient.VerifyInclusion(ctx, &pb.VerifyInclusionRequest{
Leaf: leaf,
})
if err != nil {
return nil, fmt.Errorf("governance: VerifyInclusion RPC: %w", err)
}
return &VerificationResult{
Governed: resp.Valid,
FingerprintMatch: resp.Valid, // If inclusion is valid, the fingerprint matched.
}, nil
}
// buildDialOptions creates gRPC dial options from the config (mTLS or insecure).
func buildDialOptions(cfg Config) ([]grpc.DialOption, error) {
if cfg.TLSCertPath != "" && cfg.TLSKeyPath != "" && cfg.TLSCAPath != "" {
cert, err := tls.LoadX509KeyPair(cfg.TLSCertPath, cfg.TLSKeyPath)
if err != nil {
return nil, fmt.Errorf("load TLS keypair: %w", err)
}
caCert, err := os.ReadFile(cfg.TLSCAPath)
if err != nil {
return nil, fmt.Errorf("read CA cert: %w", err)
}
caPool := x509.NewCertPool()
if !caPool.AppendCertsFromPEM(caCert) {
return nil, fmt.Errorf("failed to append CA certificate")
}
tlsCfg := &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: caPool,
MinVersion: tls.VersionTLS13,
}
return []grpc.DialOption{
grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)),
}, nil
}
if cfg.TLSRequired {
return nil, fmt.Errorf("TLS is required but no certificates configured")
}
return []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
}, nil
}