Files
homelab/k8s/apps/key-rotation-controller/controller/internal/reconcile/providers.go
T

169 lines
5.7 KiB
Go
Raw Normal View History

package reconcile
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
iamv1 "github.com/riotpiao/key-rotation-controller/api"
"github.com/riotpiao/key-rotation-controller/internal/zitadel"
)
// MachineKeyProvider rotates a service user's private key.
//
// ZITADEL keeps many keys per service user, and each assertion names its key with
// `kid`. A new key can be created while the deployed key stays valid, so this
// provider retires the old key only after the new one proves itself. Nothing in
// the window is invalid.
type MachineKeyProvider struct {
// Client talks to the instance that owns the service user.
Client *zitadel.Client
// AuthClientID is the API app the controller authenticates as.
AuthClientID string
// KeyIDFile and KeyFile name the Secret keys, defaulting to key-id and key.
KeyIDFile string
KeyFile string
}
// Name implements Provider.
func (p *MachineKeyProvider) Name() string { return iamv1.ProviderZitadelMachineKey }
// Strategy implements Provider. Machine keys overlap.
func (p *MachineKeyProvider) Strategy() string { return iamv1.StrategyOverlap }
// Prepare creates a new key. The currently deployed key remains valid.
func (p *MachineKeyProvider) Prepare(ctx context.Context, kr *iamv1.KeyRotation) (*Credential, error) {
userID := kr.Spec.Target.MachineUserID
if userID == "" {
return nil, fmt.Errorf("spec.target.machineUserID is required")
}
key, err := p.Client.CreateMachineKey(userID)
if err != nil {
return nil, fmt.Errorf("creating a machine key: %w", err)
}
fields := map[string][]byte{
p.keyField(kr): []byte(key.Key),
p.keyIDField(kr): []byte(key.KeyID),
}
return &Credential{ID: key.KeyID, Fields: fields}, nil
}
// Verify exchanges a token with the new key. A key that cannot obtain a token is
// not deployed as a working credential, whatever the API said.
func (p *MachineKeyProvider) Verify(ctx context.Context, kr *iamv1.KeyRotation, cred *Credential) error {
keyPEM := cred.Fields[p.keyField(kr)]
keyID := string(cred.Fields[p.keyIDField(kr)])
return p.Client.ExchangeMachineKey(
p.AuthClientID,
kr.Spec.Target.MachineUserID,
keyID,
keyPEM,
kr.Spec.Verify.AudienceProject,
)
}
// Retire deletes the superseded key.
func (p *MachineKeyProvider) Retire(ctx context.Context, kr *iamv1.KeyRotation, credentialID string) error {
return p.Client.DeleteMachineKey(kr.Spec.Target.MachineUserID, credentialID)
}
func (p *MachineKeyProvider) keyField(kr *iamv1.KeyRotation) string {
if key, ok := kr.Spec.Target.SecretKeys["key"]; ok && key != "" {
return key
}
return "key"
}
func (p *MachineKeyProvider) keyIDField(kr *iamv1.KeyRotation) string {
if key, ok := kr.Spec.Target.SecretKeys["key-id"]; ok && key != "" {
return key
}
return "key-id"
}
// OIDCClientProvider rotates an app's client secret.
//
// ZITADEL stores exactly one secret per app, so creating the new secret kills the
// old one. The strategy is atomic: publish immediately, roll the consumer, verify,
// and if verification fails, put the previous value back.
type OIDCClientProvider struct {
Client *zitadel.Client
// ClientIDFunc maps a KeyRotation to the app's client id, which the
// introspection endpoint needs.
ClientIDFunc func(kr *iamv1.KeyRotation) string
// SecretKey names the Secret key that holds the secret.
SecretKey string
}
// Name implements Provider.
func (p *OIDCClientProvider) Name() string { return iamv1.ProviderZitadelOIDCClient }
// Strategy implements Provider. Client secrets cannot overlap.
func (p *OIDCClientProvider) Strategy() string { return iamv1.StrategyAtomic }
// Prepare regenerates the client secret.
func (p *OIDCClientProvider) Prepare(ctx context.Context, kr *iamv1.KeyRotation) (*Credential, error) {
projectID := kr.Spec.Target.ZitadelProjectID
appID := kr.Spec.Target.ZitadelAppID
if projectID == "" || appID == "" {
return nil, fmt.Errorf("spec.target.zitadelProjectID and zitadelAppID are required")
}
secret, err := p.Client.GenerateClientSecret(projectID, appID)
if err != nil {
return nil, fmt.Errorf("generating a client secret: %w", err)
}
field := p.secretField(kr)
// The credential id is the generation marker, not a ZITADEL identifier: the
// API returns only the value.
return &Credential{
ID: "secret-" + shortHash(secret),
Fields: map[string][]byte{field: []byte(secret)},
}, nil
}
// Verify checks the credential against the introspection endpoint.
func (p *OIDCClientProvider) Verify(ctx context.Context, kr *iamv1.KeyRotation, cred *Credential) error {
if !kr.Spec.Verify.Introspect {
return nil
}
clientID := ""
if p.ClientIDFunc != nil {
clientID = p.ClientIDFunc(kr)
}
if clientID == "" {
return fmt.Errorf("no client id configured for introspection")
}
return p.Client.Introspect(clientID, string(cred.Fields[p.secretField(kr)]))
}
// Retire is a no-op: an atomic provider has nothing to retire, because the
// provider already invalidated the previous value.
func (p *OIDCClientProvider) Retire(ctx context.Context, kr *iamv1.KeyRotation, credentialID string) error {
return nil
}
func (p *OIDCClientProvider) secretField(kr *iamv1.KeyRotation) string {
if p.SecretKey != "" {
return p.SecretKey
}
if key, ok := kr.Spec.Target.SecretKeys["secret"]; ok && key != "" {
return key
}
return "secret"
}
// shortHash gives a stable, non-reversible marker for a secret so status can
// record which generation is live without storing the value.
func shortHash(value string) string {
sum := 0
for _, b := range []byte(value) {
sum = (sum*31 + int(b)) % 100000007
}
encoded := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%d", sum)))
return encoded
}
// unusedJSON keeps the import set honest if a provider needs raw JSON later.
var _ = json.Marshal