guildhouse-spire-plugins/cmd/governance-notifier/plugin.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

113 lines
4.1 KiB
Go

package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"time"
"github.com/guildhouse-cooperative/guildhouse-spire-plugins/pkg/config"
"github.com/guildhouse-cooperative/guildhouse-spire-plugins/pkg/governance"
)
// GovernanceNotifier implements the SPIRE Notifier plugin interface.
//
// SPIRE Server calls Notify() on credential lifecycle events. This plugin
// bridges those events into the Guildhouse governance framework:
//
// 1. Credential issued → CreateIntent(registry_type="credential", verb="issue")
// 2. Credential rotated → CreateIntent(registry_type="credential", verb="rotate")
// 3. Credential revoked → CreateIntent(registry_type="credential", verb="revoke")
//
// For each event, the plugin also constructs a MutationEnvelope containing
// the event payload (JCS-canonicalized) and submits the SHA-256 hash as a
// merkle leaf to the NotaryService for audit anchoring.
//
// See specs/credential-governance.md for the full specification.
type GovernanceNotifier struct {
govClient *governance.Client
config *config.PluginConfig
}
// Configure initializes the notifier with plugin configuration.
func (n *GovernanceNotifier) Configure(pluginConfig *config.PluginConfig) error {
govClient, err := governance.NewClient(governance.Config{
GovernanceAddr: pluginConfig.GovernanceAddr,
CeremonyAddr: pluginConfig.CeremonyAddr,
NotaryAddr: pluginConfig.NotaryAddr,
})
if err != nil {
return fmt.Errorf("governance-notifier: client: %w", err)
}
n.govClient = govClient
n.config = pluginConfig
return nil
}
// NotifyCredentialIssued is called when SPIRE issues a new credential.
func (n *GovernanceNotifier) NotifyCredentialIssued(ctx context.Context, spiffeID, tenantID string, certPublicKey []byte, serialNumber uint64) {
n.handleEvent(ctx, "issue", spiffeID, tenantID, certPublicKey, serialNumber)
}
// NotifyCredentialRotated is called when SPIRE rotates a credential.
func (n *GovernanceNotifier) NotifyCredentialRotated(ctx context.Context, spiffeID, tenantID string, certPublicKey []byte, serialNumber uint64) {
n.handleEvent(ctx, "rotate", spiffeID, tenantID, certPublicKey, serialNumber)
}
// NotifyCredentialRevoked is called when a credential is revoked.
func (n *GovernanceNotifier) NotifyCredentialRevoked(ctx context.Context, spiffeID, tenantID string, certPublicKey []byte, serialNumber uint64) {
n.handleEvent(ctx, "revoke", spiffeID, tenantID, certPublicKey, serialNumber)
}
func (n *GovernanceNotifier) handleEvent(ctx context.Context, verb, spiffeID, tenantID string, certPublicKey []byte, serialNumber uint64) {
// Step 1: Create governance intent.
intent, err := n.govClient.CreateIntent(ctx, "credential", verb, spiffeID, tenantID)
if err != nil {
log.Printf("governance-notifier: CreateIntent failed for %s/%s: %v", verb, spiffeID, err)
return
}
if intent.Denied {
log.Printf("governance-notifier: intent denied for %s/%s: %s", verb, spiffeID, intent.Error)
return
}
// Step 2: Compute credential fingerprint (SHA-256 of public key bytes).
fingerprint := ""
if len(certPublicKey) > 0 {
h := sha256.Sum256(certPublicKey)
fingerprint = hex.EncodeToString(h[:])
}
// Step 3: Construct MutationEnvelope payload and submit merkle leaf.
envelope := map[string]interface{}{
"event_type": verb,
"intent_id": intent.IntentID,
"spiffe_id": spiffeID,
"tenant_id": tenantID,
"credential_fingerprint": fingerprint,
"serial_number": serialNumber,
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"cluster_id": n.config.ClusterID,
}
// JCS-canonicalized JSON (sorted keys via json.Marshal).
envelopeBytes, err := json.Marshal(envelope)
if err != nil {
log.Printf("governance-notifier: marshal envelope: %v", err)
return
}
leafHash := sha256.Sum256(envelopeBytes)
anchorID, err := n.govClient.SubmitMerkleLeaf(ctx, n.config.ClusterID, leafHash[:])
if err != nil {
log.Printf("governance-notifier: submit merkle leaf: %v", err)
return
}
log.Printf("governance-notifier: %s event for %s anchored as %s (intent=%s)",
verb, spiffeID, anchorID, intent.IntentID)
}