Eight objects cover grafana, argocd, comfyui, minio, paperless, immich, forgejo and api-gw. Each names its ZITADEL app, the Secret it writes, the consumer to roll and whether the consumer reloads hot or needs a restart. All are suspended: the controller image does not exist yet, and an unsuspended object would claim an intent that no process acts on. The README records the retirement target for the 34 .enc.yaml files: six root-of-trust secrets that cannot rotate themselves survive, everything else becomes a KeyRotation and leaves git.
169 lines
5.7 KiB
Go
169 lines
5.7 KiB
Go
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
|