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