diff --git a/k8s/apps/key-rotation-controller/README.md b/k8s/apps/key-rotation-controller/README.md new file mode 100644 index 0000000..7214fb0 --- /dev/null +++ b/k8s/apps/key-rotation-controller/README.md @@ -0,0 +1,50 @@ +# Credential rotation + +One `KeyRotation` object describes one credential. The controller creates the value, +writes the Secret, proves the new value works, then retires the old one. + +## Why the state order is the safety property + +``` +Prepare -> Publish -> Verify -> Retire +``` + +`Retire` runs only for a provider whose old credential still works after the publish +step, and only after `Verify` passes. A failure before that point leaves the working +credential untouched. The hand-run rotation that broke Grafana did the opposite: it +regenerated the secret and never re-pointed the consumer. + +## Two strategies, decided by the provider, not by preference + +| Provider | Overlap in ZITADEL | Strategy | Retire | +|---|---|---|---| +| `zitadel-machine-key` | many keys per service user | `overlap` | after the new key obtains a token | +| `zitadel-oidc-client` | one secret per app | `atomic` | nothing to retire | + +## The single master credential + +The controller authenticates with one machine key, mounted from Secret +`iam/key-rotation-controller`. That key is the only credential the cluster cannot +rotate by itself, so it is the only one that stays encrypted in git. + +Every other credential is created by the controller, which means the 34 `.enc.yaml` +files under `k8s/argocd/secrets/` are migration debt, not a design. The retirement +target is one root-of-trust file per thing that cannot rotate itself: + +| Survives (root of trust) | Why it cannot rotate | +|---|---| +| ZITADEL instance masterkey | encrypts the whole ZITADEL database | +| Vault unseal keys | unsealing needs a human-held key | +| Talos repair config | recovery path, must work when the cluster does not | +| homelab CA | signs the wildcard certificate | +| controller bootstrap key | chicken and egg: it creates every other key | +| etcd defrag credentials | needed by a job that runs outside the controller | + +Everything else — Grafana, Argo CD, Forgejo, MinIO, Paperless, Immich, ComfyUI, +api-gw, Gotify — is a KeyRotation object, and its secret leaves git. + +## Status + +The CRD is installed. Eight `KeyRotation` objects exist for the consumers above and +all are `suspend: true`: the controller image does not exist yet, so no process acts +on them. poimen-memory is excluded from this migration on purpose. diff --git a/k8s/apps/key-rotation-controller/controller/api/types.go b/k8s/apps/key-rotation-controller/controller/api/types.go new file mode 100644 index 0000000..aabbe8a --- /dev/null +++ b/k8s/apps/key-rotation-controller/controller/api/types.go @@ -0,0 +1,254 @@ +// Package api declares the KeyRotation custom resource. +// +// One resource describes one credential and how to rotate it. The controller +// never stores the credential in git: it writes a Kubernetes Secret and records +// only the credential's identifier in status. +package api + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// Group is the API group for homelab platform resources. +const Group = "iam.homelab.riotpiao.com" + +// Version is the served version. +const Version = "v1" + +// Provider names. Each provider uses one external API to create and retire +// credentials, and each declares whether a rotation can overlap. +const ( + // ProviderZitadelMachineKey creates a machine key while the previous key + // stays valid. True zero downtime. + ProviderZitadelMachineKey = "zitadel-machine-key" + // ProviderZitadelOIDCClient regenerates an app client secret. ZITADEL keeps + // one secret per app, so the old one dies the moment the new one exists. + ProviderZitadelOIDCClient = "zitadel-oidc-client" +) + +// Strategy names. +const ( + // StrategyOverlap keeps the old credential valid until the new one is proven. + StrategyOverlap = "overlap" + // StrategyAtomic replaces the credential in place. A short window exists. + StrategyAtomic = "atomic" +) + +// Reload names tell the controller how the consumer picks up a new value. +const ( + // ReloadHot means the consumer watches the mounted file. + ReloadHot = "hot" + // ReloadRestart means the consumer reads the value at start, so it must roll. + ReloadRestart = "restart" +) + +// Phase names for status. +const ( + PhasePending = "Pending" + PhasePreparing = "Preparing" + PhasePropagating = "Propagating" + PhaseVerifying = "Verifying" + PhaseCompleting = "Completing" + PhaseSucceeded = "Succeeded" + PhaseFailed = "Failed" + PhaseSuspended = "Suspended" +) + +// ConsumerRef names a workload that reads the rotated credential. +type ConsumerRef struct { + // Kind is Deployment or StatefulSet. + Kind string `json:"kind"` + // Name is the workload name. + Name string `json:"name"` + // Namespace is where the workload runs. + Namespace string `json:"namespace"` +} + +// RotationTarget is the credential's source and destination. +type RotationTarget struct { + // ZitadelProjectID is the project that owns the app or service user. + ZitadelProjectID string `json:"zitadelProjectID,omitempty"` + // ZitadelAppID is the OIDC app id, for the client-secret provider. + ZitadelAppID string `json:"zitadelAppID,omitempty"` + // MachineUserID is the service user id, for the machine-key provider. + MachineUserID string `json:"machineUserID,omitempty"` + // SecretName is the Secret the controller writes. + SecretName string `json:"secretName"` + // SecretNamespace is where that Secret lives. + SecretNamespace string `json:"secretNamespace"` + // SecretKeys maps a logical field name to the Secret key that holds it. + // For a machine key: key -> key, key-id -> key-id. + SecretKeys map[string]string `json:"secretKeys,omitempty"` + // Consumers are the workloads that must pick the new value up. + Consumers []ConsumerRef `json:"consumers,omitempty"` + // Reload is hot or restart. Empty means restart, the safe default. + Reload string `json:"reload,omitempty"` +} + +// VerifySpec is the proof the controller demands before it retires the old +// credential. +type VerifySpec struct { + // Introspect checks client_id and client_secret against the introspection + // endpoint. + Introspect bool `json:"introspect,omitempty"` + // TokenExchange exchanges a token with the new machine key. + TokenExchange bool `json:"tokenExchange,omitempty"` + // AudienceProject, when set, is added to the token request as + // urn:zitadel:iam:org:project:id::aud. + AudienceProject string `json:"audienceProject,omitempty"` +} + +// KeyRotationSpec is the desired state. +type KeyRotationSpec struct { + // Provider selects the external API. + Provider string `json:"provider"` + // Strategy is overlap or atomic. + Strategy string `json:"strategy"` + // IntervalHours is how often to rotate. Minimum 24. + IntervalHours int32 `json:"intervalHours"` + // Suspend stops automatic rotation without deleting the resource. + Suspend bool `json:"suspend,omitempty"` + // Target names the credential and its consumers. + Target RotationTarget `json:"target"` + // Verify names the checks that must pass. + Verify VerifySpec `json:"verify,omitempty"` +} + +// KeyRotationStatus is the observed state. +type KeyRotationStatus struct { + Phase string `json:"phase,omitempty"` + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + LastRotation *metav1.Time `json:"lastRotation,omitempty"` + NextRotation *metav1.Time `json:"nextRotation,omitempty"` + ActiveCredentialID string `json:"activeCredentialID,omitempty"` + PreviousCredentialID string `json:"previousCredentialID,omitempty"` + LastError string `json:"lastError,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// KeyRotation is one credential under management. +type KeyRotation struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec KeyRotationSpec `json:"spec,omitempty"` + Status KeyRotationStatus `json:"status,omitempty"` +} + +// KeyRotationList is a list of KeyRotation. +type KeyRotationList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []KeyRotation `json:"items"` +} + +// SchemeGroupVersion is the group and version for this type. +var SchemeGroupVersion = schema.GroupVersion{Group: Group, Version: Version} + +// SchemeBuilder registers the types with a scheme. +var SchemeBuilder = runtime.NewSchemeBuilder(func(s *runtime.Scheme) error { + s.AddKnownTypes(SchemeGroupVersion, &KeyRotation{}, &KeyRotationList{}) + metav1.AddToGroupVersion(s, SchemeGroupVersion) + return nil +}) + +// AddToScheme registers this package's types. +var AddToScheme = SchemeBuilder.AddToScheme + +// DeepCopyInto copies the receiver into out. +func (in *KeyRotation) DeepCopyInto(out *KeyRotation) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy makes a deep copy. +func (in *KeyRotation) DeepCopy() *KeyRotation { + if in == nil { + return nil + } + out := new(KeyRotation) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject satisfies runtime.Object. +func (in *KeyRotation) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto copies the receiver into out. +func (in *KeyRotationSpec) DeepCopyInto(out *KeyRotationSpec) { + *out = *in + in.Target.DeepCopyInto(&out.Target) +} + +// DeepCopyInto copies the receiver into out. +func (in *RotationTarget) DeepCopyInto(out *RotationTarget) { + *out = *in + if in.SecretKeys != nil { + out.SecretKeys = make(map[string]string, len(in.SecretKeys)) + for k, v := range in.SecretKeys { + out.SecretKeys[k] = v + } + } + if in.Consumers != nil { + out.Consumers = make([]ConsumerRef, len(in.Consumers)) + copy(out.Consumers, in.Consumers) + } +} + +// DeepCopyInto copies the receiver into out. +func (in *KeyRotationStatus) DeepCopyInto(out *KeyRotationStatus) { + *out = *in + if in.LastRotation != nil { + t := *in.LastRotation + out.LastRotation = &t + } + if in.NextRotation != nil { + t := *in.NextRotation + out.NextRotation = &t + } + if in.Conditions != nil { + out.Conditions = make([]metav1.Condition, len(in.Conditions)) + copy(out.Conditions, in.Conditions) + } +} + +// DeepCopyInto copies the receiver into out. +func (in *KeyRotationList) DeepCopyInto(out *KeyRotationList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + out.Items = make([]KeyRotation, len(in.Items)) + for i := range in.Items { + in.Items[i].DeepCopyInto(&out.Items[i]) + } + } +} + +// DeepCopy makes a deep copy. +func (in *KeyRotationList) DeepCopy() *KeyRotationList { + if in == nil { + return nil + } + out := new(KeyRotationList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject satisfies runtime.Object. +func (in *KeyRotationList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} diff --git a/k8s/apps/key-rotation-controller/controller/go.mod b/k8s/apps/key-rotation-controller/controller/go.mod new file mode 100644 index 0000000..dca240a --- /dev/null +++ b/k8s/apps/key-rotation-controller/controller/go.mod @@ -0,0 +1,71 @@ +module github.com/riotpiao/key-rotation-controller + +go 1.26.0 + +require ( + k8s.io/api v0.37.1 + k8s.io/apimachinery v0.37.1 + k8s.io/client-go v0.37.0 + sigs.k8s.io/controller-runtime v0.25.2 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-openapi/jsonreference v1.0.0 // indirect + github.com/go-openapi/swag v0.27.1 // indirect + github.com/go-openapi/swag/cmdutils v0.27.1 // indirect + github.com/go-openapi/swag/conv v0.27.1 // indirect + github.com/go-openapi/swag/fileutils v0.27.1 // indirect + github.com/go-openapi/swag/jsonutils v0.27.1 // indirect + github.com/go-openapi/swag/loading v0.27.1 // indirect + github.com/go-openapi/swag/mangling v0.27.1 // indirect + github.com/go-openapi/swag/netutils v0.27.1 // indirect + github.com/go-openapi/swag/pools v0.27.1 // indirect + github.com/go-openapi/swag/stringutils v0.27.1 // indirect + github.com/go-openapi/swag/typeutils v0.27.1 // indirect + github.com/go-openapi/swag/yamlutils v0.27.1 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.24.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/time v0.15.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/apiextensions-apiserver v0.37.0 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect + k8s.io/utils v0.0.0-20260626114624-be93311217bd // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/k8s/apps/key-rotation-controller/controller/go.sum b/k8s/apps/key-rotation-controller/controller/go.sum new file mode 100644 index 0000000..247c97c --- /dev/null +++ b/k8s/apps/key-rotation-controller/controller/go.sum @@ -0,0 +1,133 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= +github.com/go-openapi/swag v0.27.1 h1:VotvOLWW8q/EAxB0YdsBBGC8XYyeL1YwBj2ungAGPNg= +github.com/go-openapi/swag v0.27.1/go.mod h1:GTkJPwHfhJp6MWr4/rCh64HVI3Ofu+tcsbfjfHmTxpE= +github.com/go-openapi/swag/cmdutils v0.27.1 h1:I7sYqaWVl5mq0NEmNQkAmFDyNin9ufvMX/p2zwtQaOE= +github.com/go-openapi/swag/cmdutils v0.27.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.27.1 h1:8wi9ZG+olmY1wXphl93EWniPtbSPkXM/feH7FgjsvrU= +github.com/go-openapi/swag/conv v0.27.1/go.mod h1:QbqMivkpKhC3g1B1GGGOJ6ANewI3S62dbzYu3Duowqs= +github.com/go-openapi/swag/fileutils v0.27.1 h1:QQqBSoi5mW4XpU85nS0mLcA+zAE6vLzrb0QkmLKf9oM= +github.com/go-openapi/swag/fileutils v0.27.1/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= +github.com/go-openapi/swag/jsonutils v0.27.1 h1:SVgK3i4USzCU5mibOOS/l4ea2h9UQXy7J7RNLTjuXjU= +github.com/go-openapi/swag/jsonutils v0.27.1/go.mod h1:tdlEpZqdcQ17uj6J4YdK9vd8It5qWMwjWXOs0tjpRlk= +github.com/go-openapi/swag/loading v0.27.1 h1:/DxUgDXKbBX4bcn7r9uEXfJyzN5XpiJmZplzQTjrRCY= +github.com/go-openapi/swag/loading v0.27.1/go.mod h1:jvGh3iA2+zyUUycB5fgJWzeHnhrpvGnJJM0RVE9ZShE= +github.com/go-openapi/swag/mangling v0.27.1 h1:yC9D0HyUE8gbP+BfmGx9+AA89ikwZTMjESK3OnnoaqA= +github.com/go-openapi/swag/mangling v0.27.1/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= +github.com/go-openapi/swag/netutils v0.27.1 h1:mICMFoS82F5TZ4Zy3cqmcQk+BFeCp3Uyq3Np7GI0/qU= +github.com/go-openapi/swag/netutils v0.27.1/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= +github.com/go-openapi/swag/pools v0.27.1 h1:9LeadcMyb2GJCbXX5hVQDbZ2Lq9TL4dCs/nx1j5DO0E= +github.com/go-openapi/swag/pools v0.27.1/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.27.1 h1:ZXePZ0r2p1qSjo8tD3Un4vFj8+FqlCkczxDrJIhYUp8= +github.com/go-openapi/swag/stringutils v0.27.1/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.27.1 h1:KSTdFlfnse4r6dP9IrEnwMldjE+zs71UeEB3//PtVXc= +github.com/go-openapi/swag/typeutils v0.27.1/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.27.1 h1:ftxv6xvXb1E3zohUc+okZ9nSqNb9StQX/FXnKZ98sQA= +github.com/go-openapi/swag/yamlutils v0.27.1/go.mod h1:bnxFIB1qewGRiZHypXGZ3fNgf13/0HfRgnS/iZBDrOo= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI= +github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI= +github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +k8s.io/api v0.37.1 h1:l6N77U7tjwB5L056bgrBTJIEdevac/naBZ3iSvDNfpM= +k8s.io/api v0.37.1/go.mod h1:zSlbB1YpJ1YQlFVQy20UYll81UJSJJUMLhkhvg6Z78M= +k8s.io/apiextensions-apiserver v0.37.0 h1:zRMQ3+/LIE5oZ0tVvXwYHC+dIkSP5cjNWju7AZU1LOI= +k8s.io/apiextensions-apiserver v0.37.0/go.mod h1:HU0PfSBwchHL5iDau6jjt9zU6ryWkDDlaVUiq91NK80= +k8s.io/apimachinery v0.37.1 h1:hGCYyvKHCwtwMitj2vU4vYx0Z16N9GyZk9BBnz0wDAE= +k8s.io/apimachinery v0.37.1/go.mod h1:jF84AyUi/IRIXRot5f+lm6MpxoWI+F1XgjaMmwCdTFw= +k8s.io/client-go v0.37.0 h1:nsN31fy8wBySuZ+QRnKmrjRSQLOG2rvoGN0tKd12zhQ= +k8s.io/client-go v0.37.0/go.mod h1:FcGqw+Ll/gNQiq+nPGY1Oyt9y7SgDh1d3MW3RFDEbn0= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I= +k8s.io/utils v0.0.0-20260626114624-be93311217bd h1:Ea7fgQ5we8Y9T0OX5o0dAHzQOBRI07D/dEYRaB9ZZEs= +k8s.io/utils v0.0.0-20260626114624-be93311217bd/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +sigs.k8s.io/controller-runtime v0.25.2 h1:bEkK3PVOIVK9X8QWLGVhJgmFc++47vfT6wakSzAOLsQ= +sigs.k8s.io/controller-runtime v0.25.2/go.mod h1:4QqLdT6z/L6Olj8JJCtvztid4/fnIiYsfaTFScegctc= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q= +sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/k8s/apps/key-rotation-controller/controller/internal/reconcile/providers.go b/k8s/apps/key-rotation-controller/controller/internal/reconcile/providers.go new file mode 100644 index 0000000..84b5ee6 --- /dev/null +++ b/k8s/apps/key-rotation-controller/controller/internal/reconcile/providers.go @@ -0,0 +1,168 @@ +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 diff --git a/k8s/apps/key-rotation-controller/controller/internal/reconcile/reconciler.go b/k8s/apps/key-rotation-controller/controller/internal/reconcile/reconciler.go new file mode 100644 index 0000000..d3773a6 --- /dev/null +++ b/k8s/apps/key-rotation-controller/controller/internal/reconcile/reconciler.go @@ -0,0 +1,391 @@ +// Package reconcile turns a KeyRotation object into a rotated credential. +// +// The state machine is identical for every provider. Only three calls differ: +// create, verify, retire. That is what makes one controller cover machine keys, +// client secrets and, later, the token-style providers. +// +// Order is the safety property: +// +// Prepare create the new credential +// Publish write the Secret, roll the consumer when it cannot reload +// Verify prove the new credential works against the provider +// Retire delete the old credential, only for overlapping providers +// +// A failure before Retire leaves the old credential in place, so a broken +// rotation never removes the thing that currently works. +package reconcile + +import ( + "context" + "fmt" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + iamv1 "github.com/riotpiao/key-rotation-controller/api" +) + +// Credential is fresh credential material, before it is written to a Secret. +type Credential struct { + // ID identifies the credential at the provider, so it can be retired later. + ID string + // Fields maps a Secret key to its value. + Fields map[string][]byte +} + +// Provider creates, verifies and retires one class of credential. +type Provider interface { + // Name is the provider name from the spec. + Name() string + // Strategy is overlap or atomic. Atomic providers replace in place. + Strategy() string + // Prepare creates a new credential at the provider. + Prepare(ctx context.Context, kr *iamv1.KeyRotation) (*Credential, error) + // Verify proves the credential works, using the provider's own check. + Verify(ctx context.Context, kr *iamv1.KeyRotation, cred *Credential) error + // Retire deletes a superseded credential. It must be safe to call twice. + Retire(ctx context.Context, kr *iamv1.KeyRotation, credentialID string) error +} + +// Reconciler rotates credentials for KeyRotation objects. +type Reconciler struct { + client.Client + // Providers keyed by spec.provider. + Providers map[string]Provider + // Now is injectable so scheduling is testable. + Now func() time.Time + // RolloutTimeout bounds the wait for a consumer to become available. + RolloutTimeout time.Duration +} + +// previousKeySuffix keeps the superseded value beside the current one, so an +// atomic provider can be rolled back without storing a secret in the object. +const previousKeySuffix = ".previous" + +// RequeueAfter is how long to wait before checking a resource again. +func (r *Reconciler) RequeueAfter(kr *iamv1.KeyRotation) time.Duration { + interval := time.Duration(kr.Spec.IntervalHours) * time.Hour + if interval <= 0 { + interval = 30 * 24 * time.Hour + } + if kr.Status.LastRotation == nil || kr.Status.ActiveCredentialID == "" { + return 0 // rotate now + } + due := kr.Status.LastRotation.Add(interval) + wait := due.Sub(r.now()) + if wait < 0 { + return 0 + } + return wait +} + +// Reconcile performs one rotation when the resource is due. +func (r *Reconciler) Reconcile(ctx context.Context, req types.NamespacedName) (time.Duration, error) { + logger := log.FromContext(ctx).WithValues("keyrotation", req.String()) + + var kr iamv1.KeyRotation + if err := r.Get(ctx, req, &kr); err != nil { + if apierrors.IsNotFound(err) { + return 0, nil + } + return 0, err + } + + if kr.Spec.Suspend { + return 0, r.setPhase(ctx, &kr, iamv1.PhaseSuspended, "") + } + + provider := r.Providers[kr.Spec.Provider] + if provider == nil { + return 0, r.setPhase(ctx, &kr, iamv1.PhaseFailed, + fmt.Sprintf("no provider named %q", kr.Spec.Provider)) + } + + wait := r.RequeueAfter(&kr) + if wait > 0 { + return wait, nil + } + + logger.Info("rotating credential", "provider", kr.Spec.Provider, "strategy", kr.Spec.Strategy) + + previousID := kr.Status.ActiveCredentialID + previousFields := r.readCurrentSecret(ctx, &kr) + + // Prepare + if err := r.setPhase(ctx, &kr, iamv1.PhasePreparing, ""); err != nil { + return 0, err + } + cred, err := provider.Prepare(ctx, &kr) + if err != nil { + return r.fail(ctx, &kr, iamv1.PhasePreparing, err) + } + + // Publish. The previous value stays beside the new one until verification + // passes, so an atomic rotation can be undone. + if err := r.setPhase(ctx, &kr, iamv1.PhasePropagating, ""); err != nil { + return 0, err + } + if err := r.writeSecret(ctx, &kr, cred, previousFields); err != nil { + return r.fail(ctx, &kr, iamv1.PhasePropagating, err) + } + if kr.Spec.Target.Reload != iamv1.ReloadHot { + if err := r.rollConsumers(ctx, &kr, cred.ID); err != nil { + return r.fail(ctx, &kr, iamv1.PhasePropagating, err) + } + } + + // Verify + if err := r.setPhase(ctx, &kr, iamv1.PhaseVerifying, ""); err != nil { + return 0, err + } + if err := provider.Verify(ctx, &kr, cred); err != nil { + // Put the previous value back and roll again, then report the failure. + r.restorePrevious(ctx, &kr, previousFields) + return r.fail(ctx, &kr, iamv1.PhaseVerifying, err) + } + + // Complete. Retire only when the provider can overlap, because retiring an + // atomic credential would delete the one now in use. + if err := r.setPhase(ctx, &kr, iamv1.PhaseCompleting, ""); err != nil { + return 0, err + } + if provider.Strategy() == iamv1.StrategyOverlap && previousID != "" && previousID != cred.ID { + if err := provider.Retire(ctx, &kr, previousID); err != nil { + logger.Error(err, "retiring the previous credential failed; it stays valid", "credential", previousID) + } + } + r.clearPrevious(ctx, &kr) + + now := metav1.NewTime(r.now()) + kr.Status.LastRotation = &now + kr.Status.ActiveCredentialID = cred.ID + kr.Status.PreviousCredentialID = previousID + kr.Status.LastError = "" + kr.Status.ObservedGeneration = kr.Generation + if err := r.Status().Update(ctx, &kr); err != nil { + return 0, err + } + if err := r.setPhase(ctx, &kr, iamv1.PhaseSucceeded, ""); err != nil { + return 0, err + } + logger.Info("rotation complete", "credential", cred.ID, "previous", previousID) + + return r.RequeueAfter(&kr), nil +} + +func (r *Reconciler) now() time.Time { + if r.Now != nil { + return r.Now() + } + return time.Now() +} + +func (r *Reconciler) setPhase(ctx context.Context, kr *iamv1.KeyRotation, phase, message string) error { + kr.Status.Phase = phase + kr.Status.LastError = message + kr.Status.ObservedGeneration = kr.Generation + if err := r.Status().Update(ctx, kr); err != nil && !apierrors.IsConflict(err) { + return err + } + return nil +} + +func (r *Reconciler) fail(ctx context.Context, kr *iamv1.KeyRotation, phase string, cause error) (time.Duration, error) { + kr.Status.Phase = iamv1.PhaseFailed + kr.Status.LastError = fmt.Sprintf("%s: %v", phase, cause) + _ = r.Status().Update(ctx, kr) + // Retry soon, but not in a tight loop. + return 5 * time.Minute, nil +} + +// secretData assembles the Secret payload: the new values, plus the superseded +// values under a shadow key so an atomic rotation can be undone. +func secretData(kr *iamv1.KeyRotation, cred *Credential, previous map[string][]byte) map[string][]byte { + data := make(map[string][]byte, len(cred.Fields)*2) + for logical, value := range cred.Fields { + key := secretKeyFor(kr, logical) + data[key] = value + if old, ok := previous[key]; ok && len(old) > 0 { + data[key+previousKeySuffix] = old + } + } + return data +} + +// secretKeyFor maps a logical field name to the Secret key, defaulting to the +// same name so a simple credential needs no mapping. +func secretKeyFor(kr *iamv1.KeyRotation, logical string) string { + if key, ok := kr.Spec.Target.SecretKeys[logical]; ok && key != "" { + return key + } + return logical +} + +func (r *Reconciler) readCurrentSecret(ctx context.Context, kr *iamv1.KeyRotation) map[string][]byte { + var secret corev1.Secret + err := r.Get(ctx, types.NamespacedName{ + Namespace: kr.Spec.Target.SecretNamespace, + Name: kr.Spec.Target.SecretName, + }, &secret) + if err != nil { + return nil + } + return secret.Data +} + +func (r *Reconciler) writeSecret(ctx context.Context, kr *iamv1.KeyRotation, cred *Credential, previous map[string][]byte) error { + data := secretData(kr, cred, previous) + + var secret corev1.Secret + key := types.NamespacedName{Namespace: kr.Spec.Target.SecretNamespace, Name: kr.Spec.Target.SecretName} + err := r.Get(ctx, key, &secret) + if apierrors.IsNotFound(err) { + secret = corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: key.Name, + Namespace: key.Namespace, + Labels: map[string]string{"app.kubernetes.io/managed-by": "key-rotation-controller"}, + }, + Type: corev1.SecretTypeOpaque, + Data: data, + } + return r.Create(ctx, &secret) + } + if err != nil { + return err + } + secret.Data = data + return r.Update(ctx, &secret) +} + +// restorePrevious puts the shadow values back in place after a failed check. +func (r *Reconciler) restorePrevious(ctx context.Context, kr *iamv1.KeyRotation, previous map[string][]byte) { + if len(previous) == 0 { + return + } + var secret corev1.Secret + key := types.NamespacedName{Namespace: kr.Spec.Target.SecretNamespace, Name: kr.Spec.Target.SecretName} + if err := r.Get(ctx, key, &secret); err != nil { + return + } + // Restore every shadowed value over the current one. + for k, v := range secret.Data { + base := k + if len(k) > len(previousKeySuffix) && k[len(k)-len(previousKeySuffix):] == previousKeySuffix { + base = k[:len(k)-len(previousKeySuffix)] + secret.Data[base] = v + } + } + _ = r.Update(ctx, &secret) + _ = r.rollConsumers(ctx, kr, "rollback") +} + +// clearPrevious removes the shadow values once verification passed. +func (r *Reconciler) clearPrevious(ctx context.Context, kr *iamv1.KeyRotation) { + var secret corev1.Secret + key := types.NamespacedName{Namespace: kr.Spec.Target.SecretNamespace, Name: kr.Spec.Target.SecretName} + if err := r.Get(ctx, key, &secret); err != nil { + return + } + changed := false + for k := range secret.Data { + if len(k) > len(previousKeySuffix) && k[len(k)-len(previousKeySuffix):] == previousKeySuffix { + delete(secret.Data, k) + changed = true + } + } + if changed { + _ = r.Update(ctx, &secret) + } +} + +// rollConsumers restarts each consumer so it reads the new value. +func (r *Reconciler) rollConsumers(ctx context.Context, kr *iamv1.KeyRotation, revision string) error { + for _, consumer := range kr.Spec.Target.Consumers { + ns := consumer.Namespace + if ns == "" { + ns = kr.Namespace + } + switch consumer.Kind { + case "Deployment": + var dep appsv1.Deployment + if err := r.Get(ctx, types.NamespacedName{Namespace: ns, Name: consumer.Name}, &dep); err != nil { + return fmt.Errorf("consumer %s/%s: %w", ns, consumer.Name, err) + } + base := dep.DeepCopy() + if dep.Spec.Template.Annotations == nil { + dep.Spec.Template.Annotations = map[string]string{} + } + dep.Spec.Template.Annotations["iam.homelab.riotpiao.com/credential-revision"] = revision + if err := r.Patch(ctx, &dep, client.MergeFrom(base)); err != nil { + return fmt.Errorf("rolling %s/%s: %w", ns, consumer.Name, err) + } + case "StatefulSet": + var sts appsv1.StatefulSet + if err := r.Get(ctx, types.NamespacedName{Namespace: ns, Name: consumer.Name}, &sts); err != nil { + return fmt.Errorf("consumer %s/%s: %w", ns, consumer.Name, err) + } + base := sts.DeepCopy() + if sts.Spec.Template.Annotations == nil { + sts.Spec.Template.Annotations = map[string]string{} + } + sts.Spec.Template.Annotations["iam.homelab.riotpiao.com/credential-revision"] = revision + if err := r.Patch(ctx, &sts, client.MergeFrom(base)); err != nil { + return fmt.Errorf("rolling %s/%s: %w", ns, consumer.Name, err) + } + default: + return fmt.Errorf("consumer kind %q is not supported", consumer.Kind) + } + } + return r.waitForConsumers(ctx, kr) +} + +// waitForConsumers blocks until every consumer reports all replicas available. +func (r *Reconciler) waitForConsumers(ctx context.Context, kr *iamv1.KeyRotation) error { + deadline := r.now().Add(r.rolloutTimeout()) + for { + ready := true + for _, consumer := range kr.Spec.Target.Consumers { + ns := consumer.Namespace + if ns == "" { + ns = kr.Namespace + } + var dep appsv1.Deployment + if err := r.Get(ctx, types.NamespacedName{Namespace: ns, Name: consumer.Name}, &dep); err != nil { + continue + } + desired := int32(1) + if dep.Spec.Replicas != nil { + desired = *dep.Spec.Replicas + } + if dep.Status.UpdatedReplicas < desired || dep.Status.AvailableReplicas < desired { + ready = false + break + } + } + if ready { + return nil + } + if r.now().After(deadline) { + return fmt.Errorf("consumer did not become available within %s", r.rolloutTimeout()) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + } +} + +func (r *Reconciler) rolloutTimeout() time.Duration { + if r.RolloutTimeout > 0 { + return r.RolloutTimeout + } + return 3 * time.Minute +} diff --git a/k8s/apps/key-rotation-controller/controller/internal/reconcile/reconciler_test.go b/k8s/apps/key-rotation-controller/controller/internal/reconcile/reconciler_test.go new file mode 100644 index 0000000..5a51e4d --- /dev/null +++ b/k8s/apps/key-rotation-controller/controller/internal/reconcile/reconciler_test.go @@ -0,0 +1,155 @@ +package reconcile + +import ( + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + iamv1 "github.com/riotpiao/key-rotation-controller/api" +) + +func rotation(last *metav1.Time, activeID string, hours int32) *iamv1.KeyRotation { + return &iamv1.KeyRotation{ + Spec: iamv1.KeyRotationSpec{ + Provider: iamv1.ProviderZitadelMachineKey, + Strategy: iamv1.StrategyOverlap, + IntervalHours: hours, + }, + Status: iamv1.KeyRotationStatus{ + LastRotation: last, + ActiveCredentialID: activeID, + }, + } +} + +func TestRequeueAfterRotatesImmediatelyWhenNeverRotated(t *testing.T) { + r := &Reconciler{Now: func() time.Time { return time.Unix(1_700_000_000, 0) }} + if got := r.RequeueAfter(rotation(nil, "", 24)); got != 0 { + t.Fatalf("a resource with no rotation history must rotate now, got %s", got) + } + if got := r.RequeueAfter(rotation(nil, "key-1", 24)); got != 0 { + t.Fatalf("a resource with no lastRotation must rotate now, got %s", got) + } +} + +func TestRequeueAfterWaitsForTheInterval(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + last := metav1.NewTime(now.Add(-6 * time.Hour)) + r := &Reconciler{Now: func() time.Time { return now }} + + got := r.RequeueAfter(rotation(&last, "key-1", 24)) + want := 18 * time.Hour + if got != want { + t.Fatalf("expected %s until the next rotation, got %s", want, got) + } +} + +func TestRequeueAfterRotatesWhenOverdue(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + last := metav1.NewTime(now.Add(-30 * time.Hour)) + r := &Reconciler{Now: func() time.Time { return now }} + + if got := r.RequeueAfter(rotation(&last, "key-1", 24)); got != 0 { + t.Fatalf("an overdue resource must rotate now, got %s", got) + } +} + +func TestRequeueAfterFallsBackWhenNoInterval(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + last := metav1.NewTime(now) + r := &Reconciler{Now: func() time.Time { return now }} + + got := r.RequeueAfter(rotation(&last, "key-1", 0)) + if got <= 0 { + t.Fatalf("a zero interval must fall back to a default, got %s", got) + } +} + +func TestSecretDataKeepsThePreviousValueUnderAShadowKey(t *testing.T) { + kr := &iamv1.KeyRotation{Spec: iamv1.KeyRotationSpec{ + Target: iamv1.RotationTarget{ + SecretKeys: map[string]string{"key": "jwt-key", "key-id": "jwt-key-id"}, + }, + }} + cred := &Credential{ID: "k2", Fields: map[string][]byte{ + "key": []byte("PEM-2"), + "key-id": []byte("k2"), + }} + previous := map[string][]byte{ + "jwt-key": []byte("PEM-1"), + "jwt-key-id": []byte("k1"), + } + + data := secretData(kr, cred, previous) + + if string(data["jwt-key"]) != "PEM-2" { + t.Fatalf("current value missing, got %q", data["jwt-key"]) + } + if string(data["jwt-key"+previousKeySuffix]) != "PEM-1" { + t.Fatalf("previous value must be kept for rollback, got %q", data["jwt-key"+previousKeySuffix]) + } + if string(data["jwt-key-id"+previousKeySuffix]) != "k1" { + t.Fatalf("previous key id must be kept for rollback") + } +} + +func TestSecretDataDefaultsToLogicalNames(t *testing.T) { + kr := &iamv1.KeyRotation{} + cred := &Credential{ID: "k1", Fields: map[string][]byte{"secret": []byte("s3cret")}} + + data := secretData(kr, cred, nil) + + if string(data["secret"]) != "s3cret" { + t.Fatalf("an unmapped field must keep its logical name, got %v", data) + } + if _, shadow := data["secret"+previousKeySuffix]; shadow { + t.Fatalf("no previous value existed, so no shadow key may be written") + } +} + +func TestSecretKeyForPrefersTheMapping(t *testing.T) { + kr := &iamv1.KeyRotation{Spec: iamv1.KeyRotationSpec{ + Target: iamv1.RotationTarget{ + SecretKeys: map[string]string{"secret": "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET"}, + }, + }} + if got := secretKeyFor(kr, "secret"); got != "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET" { + t.Fatalf("mapping not honoured, got %q", got) + } + if got := secretKeyFor(kr, "key-id"); got != "key-id" { + t.Fatalf("unmapped field must default to its own name, got %q", got) + } +} + +func TestMachineKeyProviderFieldsRespectTheMapping(t *testing.T) { + p := &MachineKeyProvider{} + kr := &iamv1.KeyRotation{Spec: iamv1.KeyRotationSpec{ + Target: iamv1.RotationTarget{ + SecretKeys: map[string]string{"key": "private-key", "key-id": "kid"}, + }, + }} + if p.keyField(kr) != "private-key" || p.keyIDField(kr) != "kid" { + t.Fatalf("provider must read the mapped Secret keys") + } + if p.Strategy() != iamv1.StrategyOverlap { + t.Fatalf("machine keys must be an overlapping provider") + } + if (&OIDCClientProvider{}).Strategy() != iamv1.StrategyAtomic { + t.Fatalf("client secrets cannot overlap and must be atomic") + } +} + +func TestShortHashIsStableAndDoesNotLeakTheValue(t *testing.T) { + a := shortHash("super-secret-value") + b := shortHash("super-secret-value") + if a != b { + t.Fatalf("the marker must be stable for the same value") + } + if a == "super-secret-value" { + t.Fatalf("the marker must not be the value itself") + } + if shortHash("other") == a { + t.Fatalf("different values must produce different markers") + } +} diff --git a/k8s/apps/key-rotation-controller/controller/internal/zitadel/client.go b/k8s/apps/key-rotation-controller/controller/internal/zitadel/client.go new file mode 100644 index 0000000..0f32e98 --- /dev/null +++ b/k8s/apps/key-rotation-controller/controller/internal/zitadel/client.go @@ -0,0 +1,308 @@ +package zitadel + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// Config holds everything the controller needs to call ZITADEL. +type Config struct { + // Issuer is the instance URL, for example https://zitadel.riotpiao.com. + Issuer string + // ClientID is the API app that this controller authenticates as. + ClientID string + // UserID is the machine user the assertion acts for. Empty means ClientID. + UserID string + // KeyID and PrivateKeyPEM identify the controller's own machine key. + KeyID string + PrivateKeyPEM []byte + // HTTPClient is optional; a sane default is used when nil. + HTTPClient *http.Client +} + +// Client is a thin ZITADEL management client. +type Client struct { + cfg Config + http *http.Client + mu sync.Mutex + token string + until time.Time +} + +// New builds a client. +func New(cfg Config) *Client { + client := cfg.HTTPClient + if client == nil { + client = &http.Client{Timeout: 20 * time.Second} + } + return &Client{cfg: cfg, http: client} +} + +// AccessToken returns a cached access token, minting one when needed. +func (c *Client) AccessToken() (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.token != "" && time.Now().Before(c.until) { + return c.token, nil + } + + assertion, err := SignAssertion(c.cfg.PrivateKeyPEM, c.cfg.KeyID, c.cfg.ClientID, c.cfg.UserID, c.cfg.Issuer, time.Now()) + if err != nil { + return "", err + } + + form := url.Values{} + form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") + form.Set("assertion", assertion) + form.Set("scope", "openid") + + req, err := http.NewRequest(http.MethodPost, strings.TrimRight(c.cfg.Issuer, "/")+"/oauth/v2/token", strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.http.Do(req) + if err != nil { + return "", fmt.Errorf("token request failed: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var parsed struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return "", fmt.Errorf("token response did not parse: %w", err) + } + if parsed.AccessToken == "" { + return "", fmt.Errorf("token response carried no access_token") + } + + lifetime := parsed.ExpiresIn + if lifetime <= 0 { + lifetime = 300 + } + c.token = parsed.AccessToken + // Refresh at 80 % of the lifetime so a slow call never presents an expired token. + c.until = time.Now().Add(time.Duration(lifetime) * time.Second * 4 / 5) + return c.token, nil +} + +// projectAppPath builds the management path for one OIDC app under a project. +func (c *Client) projectAppPath(projectID, appID string) string { + return fmt.Sprintf("/management/v1/projects/%s/apps/%s", projectID, appID) +} + +// MachineKey is one registered key for a service user. +type MachineKey struct { + KeyID string `json:"keyId"` + Expiration string `json:"expirationDate,omitempty"` + // Details holds the PEM bundle when the key was just created. + Details string `json:"keyDetails,omitempty"` +} + +// NewMachineKeyJSON is the JSON document ZITADEL returns for a new JSON key. +type NewMachineKeyJSON struct { + Type string `json:"type"` + KeyID string `json:"keyId"` + Key string `json:"key"` + UserID string `json:"userId"` + ExpireAt string `json:"expirationDate,omitempty"` +} + +// CreateMachineKey adds a key to a service user and returns its material. +// +// ZITADEL keeps many keys per user, so this never invalidates the key that is +// currently deployed. That is the whole reason the machine-key provider can +// rotate with no gap. +func (c *Client) CreateMachineKey(userID string) (*NewMachineKeyJSON, error) { + payload := map[string]any{"type": "KEY_TYPE_JSON"} + var out struct { + KeyID string `json:"keyId"` + KeyDetails string `json:"keyDetails"` + } + if err := c.post("/management/v1/users/"+userID+"/keys", payload, &out); err != nil { + return nil, err + } + if out.KeyDetails == "" { + return nil, fmt.Errorf("create machine key returned no key material") + } + + raw, err := base64Decode(out.KeyDetails) + if err != nil { + return nil, fmt.Errorf("key material is not base64: %w", err) + } + + var key NewMachineKeyJSON + if err := json.Unmarshal(raw, &key); err != nil { + return nil, fmt.Errorf("key material is not the expected JSON: %w", err) + } + return &key, nil +} + +// DeleteMachineKey removes one key. Called only after the new key is proven. +func (c *Client) DeleteMachineKey(userID, keyID string) error { + return c.delete("/management/v1/users/" + userID + "/keys/" + keyID) +} + +// GenerateClientSecret replaces the client secret of an OIDC app. +// +// ZITADEL stores exactly one secret per app, so the previous secret stops working +// at this call. The caller must publish the new value immediately. +func (c *Client) GenerateClientSecret(projectID, appID string) (string, error) { + var out struct { + ClientSecret string `json:"clientSecret"` + } + path := c.projectAppPath(projectID, appID) + "/oidc_config/_generate_client_secret" + if err := c.post(path, map[string]any{}, &out); err != nil { + return "", err + } + if out.ClientSecret == "" { + return "", fmt.Errorf("generate client secret returned an empty value") + } + return out.ClientSecret, nil +} + +// Introspect checks a client credential against the token endpoint. +// +// A successful call returns an object with `active`. The credentials are valid +// when the endpoint answers 200, whatever `active` says, because `active` +// describes the token rather than the client. +func (c *Client) Introspect(clientID, clientSecret string) error { + form := url.Values{} + form.Set("token", "probe") + + req, err := http.NewRequest(http.MethodPost, strings.TrimRight(c.cfg.Issuer, "/")+"/oauth/v2/introspect", strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(clientID, clientSecret) + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("introspection request failed: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("introspection returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + return nil +} + +// ExchangeMachineKey obtains a token with a specific key, proving that key works. +func (c *Client) ExchangeMachineKey(clientID, userID, keyID string, keyPEM []byte, audienceProject string) error { + assertion, err := SignAssertion(keyPEM, keyID, clientID, userID, c.cfg.Issuer, time.Now()) + if err != nil { + return err + } + + scope := "openid" + if audienceProject != "" { + scope += " urn:zitadel:iam:org:project:id:" + audienceProject + ":aud" + } + + form := url.Values{} + form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") + form.Set("assertion", assertion) + form.Set("scope", scope) + + req, err := http.NewRequest(http.MethodPost, strings.TrimRight(c.cfg.Issuer, "/")+"/oauth/v2/token", strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("key exchange failed: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("key exchange returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + return nil +} + +func (c *Client) do(method, path string, payload any, out any) error { + token, err := c.AccessToken() + if err != nil { + return err + } + + var body io.Reader + if payload != nil { + encoded, err := json.Marshal(payload) + if err != nil { + return err + } + body = bytes.NewReader(encoded) + } + + req, err := http.NewRequest(method, strings.TrimRight(c.cfg.Issuer, "/")+path, body) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("%s %s returned %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(raw))) + } + if out != nil && len(raw) > 0 { + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("%s %s response did not parse: %w", method, path, err) + } + } + return nil +} + +func (c *Client) post(path string, payload, out any) error { + return c.do(http.MethodPost, path, payload, out) +} + +func (c *Client) delete(path string) error { + return c.do(http.MethodDelete, path, nil, nil) +} + +func base64Decode(s string) ([]byte, error) { + if raw, err := base64StdDecode(s); err == nil { + return raw, nil + } + return base64URLDecode(s) +} + +func base64StdDecode(s string) ([]byte, error) { + return base64.StdEncoding.DecodeString(s) +} + +func base64URLDecode(s string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(s) +} diff --git a/k8s/apps/key-rotation-controller/controller/internal/zitadel/jwt.go b/k8s/apps/key-rotation-controller/controller/internal/zitadel/jwt.go new file mode 100644 index 0000000..6c7a4aa --- /dev/null +++ b/k8s/apps/key-rotation-controller/controller/internal/zitadel/jwt.go @@ -0,0 +1,89 @@ +// Package zitadel talks to the ZITADEL management API with the controller's own +// machine key. +// +// The controller needs a credential to rotate credentials. It signs a short +// assertion with its private key, exchanges it for an access token, and reuses +// that token until shortly before it expires. +package zitadel + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "time" +) + +// assertionLifetime is deliberately short. The assertion proves one request. +const assertionLifetime = 60 * time.Second + +// newAssertionClaims builds the claim set for a machine-key assertion. +// +// ZITADEL expects `iss` to name the caller, `sub` to name the user it acts as, +// and `aud` to equal the issuer of the instance. +func newAssertionClaims(clientID, userID, issuer string, now time.Time) map[string]any { + if userID == "" { + userID = clientID + } + return map[string]any{ + "iss": clientID, + "sub": userID, + "aud": issuer, + "iat": now.Unix(), + "exp": now.Add(assertionLifetime).Unix(), + } +} + +func base64URL(b []byte) string { + return base64.RawURLEncoding.EncodeToString(b) +} + +// SignAssertion produces a signed RS256 JWT for the jwt-bearer grant. +// +// The header carries `kid`, so ZITADEL selects the matching registered key. That +// is what makes overlapping keys possible: two keys can be valid at once and the +// assertion says which one it used. +func SignAssertion(privateKeyPEM []byte, keyID, clientID, userID, issuer string, now time.Time) (string, error) { + block, _ := pem.Decode(privateKeyPEM) + if block == nil { + return "", fmt.Errorf("private key is not PEM encoded") + } + + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + // ZITADEL exports PKCS#1 keys as well. + if rsaKey, rsaErr := x509.ParsePKCS1PrivateKey(block.Bytes); rsaErr == nil { + key = rsaKey + } else { + return "", fmt.Errorf("private key does not parse: %w", err) + } + } + rsaKey, ok := key.(*rsa.PrivateKey) + if !ok { + return "", fmt.Errorf("private key is not RSA") + } + + header := map[string]any{"alg": "RS256", "typ": "JWT", "kid": keyID} + headerJSON, err := json.Marshal(header) + if err != nil { + return "", err + } + claimsJSON, err := json.Marshal(newAssertionClaims(clientID, userID, issuer, now)) + if err != nil { + return "", err + } + + signingInput := base64URL(headerJSON) + "." + base64URL(claimsJSON) + digest := sha256.Sum256([]byte(signingInput)) + signature, err := rsa.SignPKCS1v15(rand.Reader, rsaKey, crypto.SHA256, digest[:]) + if err != nil { + return "", fmt.Errorf("cannot sign the assertion: %w", err) + } + + return signingInput + "." + base64URL(signature), nil +} diff --git a/k8s/apps/key-rotation-controller/controller/internal/zitadel/jwt_test.go b/k8s/apps/key-rotation-controller/controller/internal/zitadel/jwt_test.go new file mode 100644 index 0000000..7dbe31a --- /dev/null +++ b/k8s/apps/key-rotation-controller/controller/internal/zitadel/jwt_test.go @@ -0,0 +1,90 @@ +package zitadel + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "strings" + "testing" + "time" +) + +func newKey(t *testing.T) ([]byte, *rsa.PublicKey) { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("cannot generate a key: %v", err) + } + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + return pemBytes, &key.PublicKey +} + +func decodeSegment(t *testing.T, segment string, out any) { + t.Helper() + raw, err := base64.RawURLEncoding.DecodeString(segment) + if err != nil { + t.Fatalf("segment is not base64url: %v", err) + } + if err := json.Unmarshal(raw, out); err != nil { + t.Fatalf("segment is not JSON: %v", err) + } +} + +func TestSignAssertionCarriesTheKeyIDAndIssuer(t *testing.T) { + pemBytes, _ := newKey(t) + now := time.Unix(1_700_000_000, 0) + + token, err := SignAssertion(pemBytes, "key-42", "client-1", "user-9", "https://zitadel.riotpiao.com", now) + if err != nil { + t.Fatalf("signing failed: %v", err) + } + if strings.Count(token, ".") != 2 { + t.Fatalf("a JWT has three segments, got %q", token) + } + + parts := strings.Split(token, ".") + var header map[string]any + decodeSegment(t, parts[0], &header) + if header["kid"] != "key-42" { + t.Fatalf("the header must name the key id so ZITADEL can pick the key, got %v", header["kid"]) + } + if header["alg"] != "RS256" { + t.Fatalf("expected RS256, got %v", header["alg"]) + } + + var claims map[string]any + decodeSegment(t, parts[1], &claims) + if claims["iss"] != "client-1" || claims["sub"] != "user-9" { + t.Fatalf("iss and sub are wrong: %v / %v", claims["iss"], claims["sub"]) + } + if claims["aud"] != "https://zitadel.riotpiao.com" { + t.Fatalf("aud must equal the issuer, got %v", claims["aud"]) + } + exp := int64(claims["exp"].(float64)) + iat := int64(claims["iat"].(float64)) + if exp-iat != 60 { + t.Fatalf("the assertion must live 60 seconds, got %d", exp-iat) + } +} + +func TestSignAssertionRejectsGarbage(t *testing.T) { + if _, err := SignAssertion([]byte("not a key"), "k", "c", "u", "https://i", time.Now()); err == nil { + t.Fatalf("a non-PEM key must be rejected") + } +} + +func TestSignAssertionDefaultsSubjectToClientID(t *testing.T) { + pemBytes, _ := newKey(t) + token, err := SignAssertion(pemBytes, "k1", "client-1", "", "https://i", time.Unix(1, 0)) + if err != nil { + t.Fatalf("signing failed: %v", err) + } + var claims map[string]any + decodeSegment(t, strings.Split(token, ".")[1], &claims) + if claims["sub"] != "client-1" { + t.Fatalf("an empty user id must fall back to the client id, got %v", claims["sub"]) + } +} diff --git a/k8s/apps/key-rotation-controller/controller/main.go b/k8s/apps/key-rotation-controller/controller/main.go new file mode 100644 index 0000000..7192fef --- /dev/null +++ b/k8s/apps/key-rotation-controller/controller/main.go @@ -0,0 +1,152 @@ +// Command key-rotation-controller rotates credentials declared by KeyRotation +// objects. +// +// The controller authenticates to ZITADEL with its own machine key, read from a +// mounted Secret. That key is the single bootstrap credential: everything else is +// created by this process and never committed to git. +package main + +import ( + "context" + "flag" + "fmt" + "net/http" + "os" + "time" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + ctrlreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/source" + + iamv1 "github.com/riotpiao/key-rotation-controller/api" + keyreconcile "github.com/riotpiao/key-rotation-controller/internal/reconcile" + "github.com/riotpiao/key-rotation-controller/internal/zitadel" +) + +func main() { + var ( + metricsAddr = flag.String("metrics-bind-address", ":8080", "address for the metrics endpoint") + healthAddr = flag.String("health-probe-bind-address", ":8081", "address for probes") + interval = flag.Duration("scan-interval", 10*time.Minute, "how often to re-check every resource") + ) + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseDevMode(false))) + logger := ctrl.Log.WithName("setup") + + scheme := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(scheme); err != nil { + logger.Error(err, "cannot register the Kubernetes types") + os.Exit(1) + } + if err := iamv1.AddToScheme(scheme); err != nil { + logger.Error(err, "cannot register the KeyRotation types") + os.Exit(1) + } + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{BindAddress: *metricsAddr}, + HealthProbeBindAddress: *healthAddr, + LeaderElection: true, + LeaderElectionID: "key-rotation-controller.iam.homelab.riotpiao.com", + }) + if err != nil { + logger.Error(err, "cannot create the manager") + os.Exit(1) + } + + zitadelClient, err := buildZitadelClient() + if err != nil { + logger.Error(err, "cannot build the ZITADEL client") + os.Exit(1) + } + + reconciler := &keyreconcile.Reconciler{ + Client: mgr.GetClient(), + Providers: map[string]keyreconcile.Provider{ + iamv1.ProviderZitadelMachineKey: &keyreconcile.MachineKeyProvider{ + Client: zitadelClient, + AuthClientID: os.Getenv("ZITADEL_CLIENT_ID"), + }, + iamv1.ProviderZitadelOIDCClient: &keyreconcile.OIDCClientProvider{ + Client: zitadelClient, + }, + }, + } + + c, err := controller.New("keyrotation", mgr, controller.Options{ + Reconciler: ctrlreconcile.Func(func(ctx context.Context, req ctrlreconcile.Request) (ctrlreconcile.Result, error) { + wait, err := reconciler.Reconcile(ctx, types.NamespacedName(req.NamespacedName)) + if err != nil { + return ctrlreconcile.Result{}, err + } + if wait <= 0 { + wait = *interval + } + return ctrlreconcile.Result{RequeueAfter: wait}, nil + }), + }) + if err != nil { + logger.Error(err, "cannot create the controller") + os.Exit(1) + } + + if err := c.Watch(source.Kind(mgr.GetCache(), client.Object(&iamv1.KeyRotation{}), &handler.EnqueueRequestForObject{})); err != nil { + logger.Error(err, "cannot watch KeyRotation objects") + os.Exit(1) + } + + if err := mgr.AddHealthzCheck("healthz", func(*http.Request) error { return nil }); err != nil { + logger.Error(err, "cannot add the health check") + os.Exit(1) + } + + logger.Info("starting the key rotation controller") + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + logger.Error(err, "manager stopped") + os.Exit(1) + } +} + +// buildZitadelClient reads the controller's own credential from the environment. +// +// These are the only values the controller cannot create for itself. They arrive +// from one Secret, created once when the cluster is bootstrapped. +func buildZitadelClient() (*zitadel.Client, error) { + issuer := os.Getenv("ZITADEL_ISSUER") + clientID := os.Getenv("ZITADEL_CLIENT_ID") + userID := os.Getenv("ZITADEL_USER_ID") + keyID := os.Getenv("ZITADEL_KEY_ID") + keyFile := envOr("ZITADEL_KEY_FILE", "/var/run/keys/controller/key") + + if issuer == "" || clientID == "" || keyID == "" { + return nil, fmt.Errorf("ZITADEL_ISSUER, ZITADEL_CLIENT_ID and ZITADEL_KEY_ID are required") + } + pem, err := os.ReadFile(keyFile) + if err != nil { + return nil, fmt.Errorf("cannot read the controller key: %w", err) + } + return zitadel.New(zitadel.Config{ + Issuer: issuer, + ClientID: clientID, + UserID: userID, + KeyID: keyID, + PrivateKeyPEM: pem, + }), nil +} + +func envOr(name, fallback string) string { + if v := os.Getenv(name); v != "" { + return v + } + return fallback +} diff --git a/k8s/apps/key-rotation-controller/crd.yaml b/k8s/apps/key-rotation-controller/crd.yaml new file mode 100644 index 0000000..d36b07a --- /dev/null +++ b/k8s/apps/key-rotation-controller/crd.yaml @@ -0,0 +1,121 @@ +# KeyRotation declares one credential and how to rotate it. +# +# The resource never holds a secret value. It names where the value goes and how +# to prove the new value works. The controller writes the Secret. +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: keyrotations.iam.homelab.riotpiao.com +spec: + group: iam.homelab.riotpiao.com + scope: Namespaced + names: + kind: KeyRotation + plural: keyrotations + singular: keyrotation + shortNames: [kr] + versions: + - name: v1 + served: true + storage: true + additionalPrinterColumns: + - {name: Provider, type: string, jsonPath: .spec.provider} + - {name: Strategy, type: string, jsonPath: .spec.strategy} + - {name: Phase, type: string, jsonPath: .status.phase} + - {name: Last, type: date, jsonPath: .status.lastRotation} + - {name: Next, type: date, jsonPath: .status.nextRotation} + subresources: + status: {} + schema: + openAPIV3Schema: + type: object + required: [spec] + properties: + spec: + type: object + required: [provider, strategy, intervalHours, target] + properties: + provider: + type: string + enum: [zitadel-machine-key, zitadel-oidc-client] + description: >- + Which external API creates the credential. + strategy: + type: string + enum: [overlap, atomic] + description: >- + overlap keeps the old credential valid until the new one is + proven. atomic replaces it in place, because the provider + allows only one value. + intervalHours: + type: integer + minimum: 1 + description: How often to rotate. + suspend: + type: boolean + description: Stop automatic rotation without deleting the resource. + target: + type: object + required: [secretName, secretNamespace] + properties: + zitadelProjectID: {type: string} + zitadelAppID: {type: string} + machineUserID: {type: string} + secretName: {type: string} + secretNamespace: {type: string} + secretKeys: + type: object + additionalProperties: {type: string} + description: >- + Logical field name to Secret key. Unmapped fields keep + their logical name. + reload: + type: string + enum: [hot, restart] + description: >- + hot means the consumer watches the file. restart rolls the + consumer so it reads the new value at start. + consumers: + type: array + items: + type: object + required: [kind, name, namespace] + properties: + kind: {type: string, enum: [Deployment, StatefulSet]} + name: {type: string} + namespace: {type: string} + verify: + type: object + properties: + introspect: + type: boolean + description: Check client id and secret against introspection. + tokenExchange: + type: boolean + description: Exchange a token with the new machine key. + audienceProject: + type: string + description: Project id added to the token audience scope. + status: + type: object + properties: + phase: + type: string + enum: [Pending, Preparing, Propagating, Verifying, Completing, Succeeded, Failed, Suspended] + observedGeneration: {type: integer, format: int64} + lastRotation: {type: string, format: date-time} + nextRotation: {type: string, format: date-time} + activeCredentialID: {type: string} + previousCredentialID: {type: string} + lastError: {type: string} + conditions: + type: array + items: + type: object + required: [type, status, reason, lastTransitionTime] + properties: + type: {type: string} + status: {type: string} + reason: {type: string} + message: {type: string} + lastTransitionTime: {type: string, format: date-time} diff --git a/k8s/apps/key-rotation-controller/deployment.yaml b/k8s/apps/key-rotation-controller/deployment.yaml new file mode 100644 index 0000000..d966df3 --- /dev/null +++ b/k8s/apps/key-rotation-controller/deployment.yaml @@ -0,0 +1,66 @@ +# The controller. Its own machine key is the single bootstrap credential: it is +# created once and mounted here, and this process creates every other credential. +# +# The image must be built and pushed before this Deployment can start. Until then +# the object stays Pending, which is why no Argo CD Application is wired yet. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: key-rotation-controller + namespace: iam + labels: + app: key-rotation-controller +spec: + replicas: 1 + selector: + matchLabels: + app: key-rotation-controller + template: + metadata: + labels: + app: key-rotation-controller + spec: + serviceAccountName: key-rotation-controller + securityContext: + runAsNonRoot: true + runAsUser: 65534 + seccompProfile: + type: RuntimeDefault + containers: + - name: controller + image: forgejo.riotpiao.com/riotpiao-poimen/key-rotation-controller:0.1.0 + imagePullPolicy: IfNotPresent + args: + - --scan-interval=10m + env: + - {name: ZITADEL_ISSUER, value: "https://zitadel.riotpiao.com"} + - name: ZITADEL_CLIENT_ID + valueFrom: {secretKeyRef: {name: key-rotation-controller, key: client-id}} + - name: ZITADEL_USER_ID + valueFrom: {secretKeyRef: {name: key-rotation-controller, key: user-id}} + - name: ZITADEL_KEY_ID + valueFrom: {secretKeyRef: {name: key-rotation-controller, key: key-id}} + - {name: ZITADEL_KEY_FILE, value: "/var/run/keys/controller/key"} + ports: + - {name: metrics, containerPort: 8080} + - {name: health, containerPort: 8081} + readinessProbe: + httpGet: {path: /healthz, port: health} + initialDelaySeconds: 5 + livenessProbe: + httpGet: {path: /healthz, port: health} + initialDelaySeconds: 15 + volumeMounts: + - {name: controller-key, mountPath: /var/run/keys/controller, readOnly: true} + securityContext: + allowPrivilegeEscalation: false + capabilities: {drop: ["ALL"]} + resources: + requests: {cpu: 20m, memory: 64Mi} + limits: {cpu: 500m, memory: 256Mi} + volumes: + - name: controller-key + secret: + secretName: key-rotation-controller + items: + - {key: key, path: key} diff --git a/k8s/apps/key-rotation-controller/example-machine-key.yaml b/k8s/apps/key-rotation-controller/example-machine-key.yaml new file mode 100644 index 0000000..3db48e0 --- /dev/null +++ b/k8s/apps/key-rotation-controller/example-machine-key.yaml @@ -0,0 +1,28 @@ +# Example: rotate a service user's machine key with no gap. +# +# Machine keys overlap in ZITADEL, so the old key stays valid until the new one has +# obtained a token. Nothing in the window fails. +# +# Apply only after the controller runs and the machine user exists. +apiVersion: iam.homelab.riotpiao.com/v1 +kind: KeyRotation +metadata: + name: example-machine-key + namespace: iam +spec: + provider: zitadel-machine-key + strategy: overlap + intervalHours: 720 # 30 days + suspend: true # flip to false once the pilot is proven + target: + machineUserID: "REPLACE_WITH_SERVICE_USER_ID" + secretName: example-jwt + secretNamespace: iam + secretKeys: + key: key + key-id: key-id + reload: hot # the consumer watches the file, so no restart + consumers: [] + verify: + tokenExchange: true + audienceProject: "396757796898471937" diff --git a/k8s/apps/key-rotation-controller/example-oidc-client.yaml b/k8s/apps/key-rotation-controller/example-oidc-client.yaml new file mode 100644 index 0000000..47c4435 --- /dev/null +++ b/k8s/apps/key-rotation-controller/example-oidc-client.yaml @@ -0,0 +1,27 @@ +# Example: rotate an OIDC app's client secret. +# +# ZITADEL keeps one secret per app, so the old value dies at the generate call. +# The strategy is atomic: publish, roll the consumer, verify, and roll back if the +# introspection check fails. +apiVersion: iam.homelab.riotpiao.com/v1 +kind: KeyRotation +metadata: + name: example-grafana-client + namespace: iam +spec: + provider: zitadel-oidc-client + strategy: atomic + intervalHours: 2160 # 90 days + suspend: true + target: + zitadelProjectID: "396757796898471937" + zitadelAppID: "REPLACE_WITH_APP_ID" + secretName: grafana-oidc + secretNamespace: logging + secretKeys: + secret: GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET + reload: restart # Grafana reads the value at start + consumers: + - {kind: Deployment, name: grafana, namespace: logging} + verify: + introspect: true diff --git a/k8s/apps/key-rotation-controller/kustomization.yaml b/k8s/apps/key-rotation-controller/kustomization.yaml new file mode 100644 index 0000000..e220582 --- /dev/null +++ b/k8s/apps/key-rotation-controller/kustomization.yaml @@ -0,0 +1,10 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +# No Argo CD Application is wired to this directory yet: the controller image must +# exist first. Add the app once the image is pushed, so Argo never reports a +# Deployment that cannot pull. +resources: + - crd.yaml + - rbac.yaml + - deployment.yaml + - rotations.yaml diff --git a/k8s/apps/key-rotation-controller/rbac.yaml b/k8s/apps/key-rotation-controller/rbac.yaml new file mode 100644 index 0000000..d22ac12 --- /dev/null +++ b/k8s/apps/key-rotation-controller/rbac.yaml @@ -0,0 +1,43 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: key-rotation-controller + namespace: iam +--- +# Cluster-wide because a credential and its consumer live in different namespaces: +# the Secret in one, the workload in another. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: key-rotation-controller +rules: + - apiGroups: ["iam.homelab.riotpiao.com"] + resources: ["keyrotations"] + verbs: ["get", "list", "watch"] + - apiGroups: ["iam.homelab.riotpiao.com"] + resources: ["keyrotations/status"] + verbs: ["get", "update", "patch"] + # The controller writes the credential it just created. + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "create", "update", "patch"] + # Rolling a consumer that cannot hot reload. + - apiGroups: ["apps"] + resources: ["deployments", "statefulsets"] + verbs: ["get", "list", "watch", "patch"] + - apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: key-rotation-controller +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: key-rotation-controller +subjects: + - kind: ServiceAccount + name: key-rotation-controller + namespace: iam diff --git a/k8s/apps/key-rotation-controller/rotations.yaml b/k8s/apps/key-rotation-controller/rotations.yaml new file mode 100644 index 0000000..c0c6d11 --- /dev/null +++ b/k8s/apps/key-rotation-controller/rotations.yaml @@ -0,0 +1,211 @@ +# Every credential this cluster owns, described as a KeyRotation. +# +# One object per consumer. The controller creates the value, writes the Secret and +# proves the new value works before it retires the old one. Nothing here holds a +# secret, so this file is safe in git. +# +# suspend: true everywhere on purpose. The controller image does not exist yet, and +# an unsuspended object would claim an intent no process is acting on. Clear the flag +# per object as its pilot passes. +# +# poimen-memory is absent deliberately: it is in use and excluded from this migration. +--- +apiVersion: iam.homelab.riotpiao.com/v1 +kind: KeyRotation +metadata: + name: grafana + namespace: iam + labels: + app.kubernetes.io/part-of: key-rotation +spec: + provider: zitadel-oidc-client + strategy: atomic + intervalHours: 2160 + suspend: true + target: + zitadelProjectID: "396757796898471937" + zitadelAppID: "396757801965191169" + secretName: grafana-oidc + secretNamespace: logging + secretKeys: + secret: GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET + reload: restart + consumers: + - {kind: Deployment, name: grafana, namespace: logging} + verify: + introspect: true +--- +apiVersion: iam.homelab.riotpiao.com/v1 +kind: KeyRotation +metadata: + name: argocd + namespace: iam + labels: + app.kubernetes.io/part-of: key-rotation +spec: + provider: zitadel-oidc-client + strategy: atomic + intervalHours: 2160 + suspend: true + target: + zitadelProjectID: "396757796898471937" + zitadelAppID: "396757801210282367" + secretName: argocd-oidc + secretNamespace: argocd + secretKeys: + secret: oidc.clientSecret + reload: restart + consumers: + - {kind: Deployment, name: argocd-server, namespace: argocd} + verify: + introspect: true +--- +apiVersion: iam.homelab.riotpiao.com/v1 +kind: KeyRotation +metadata: + name: comfyui + namespace: iam + labels: + app.kubernetes.io/part-of: key-rotation +spec: + provider: zitadel-oidc-client + strategy: atomic + intervalHours: 2160 + suspend: true + target: + zitadelProjectID: "396757796898471937" + zitadelAppID: "397015744010781070" + secretName: comfyui-oauth2-proxy + secretNamespace: comfyui + secretKeys: + secret: client-secret + reload: restart + consumers: + - {kind: Deployment, name: comfyui-oauth2-proxy, namespace: comfyui} + verify: + introspect: true +--- +apiVersion: iam.homelab.riotpiao.com/v1 +kind: KeyRotation +metadata: + name: minio + namespace: iam + labels: + app.kubernetes.io/part-of: key-rotation +spec: + provider: zitadel-oidc-client + strategy: atomic + intervalHours: 2160 + suspend: true + target: + zitadelProjectID: "396757796898471937" + zitadelAppID: "396757803257036801" + secretName: minio-oidc + secretNamespace: minio + secretKeys: + secret: client-secret + reload: hot + consumers: + - {kind: Deployment, name: minio, namespace: minio} + verify: + introspect: true +--- +apiVersion: iam.homelab.riotpiao.com/v1 +kind: KeyRotation +metadata: + name: paperless + namespace: iam + labels: + app.kubernetes.io/part-of: key-rotation +spec: + provider: zitadel-oidc-client + strategy: atomic + intervalHours: 2160 + suspend: true + target: + zitadelProjectID: "396757796898471937" + zitadelAppID: "396757803559027071" + secretName: paperless-oidc + secretNamespace: paperless + secretKeys: + secret: client-secret + reload: restart + consumers: + - {kind: Deployment, name: paperless, namespace: paperless} + verify: + introspect: true +--- +apiVersion: iam.homelab.riotpiao.com/v1 +kind: KeyRotation +metadata: + name: immich + namespace: iam + labels: + app.kubernetes.io/part-of: key-rotation +spec: + provider: zitadel-oidc-client + strategy: atomic + intervalHours: 2160 + suspend: true + target: + zitadelProjectID: "396757796898471937" + zitadelAppID: "396757802602791295" + secretName: immich-oidc + secretNamespace: immich + secretKeys: + secret: client-secret + reload: restart + consumers: + - {kind: Deployment, name: immich-server, namespace: immich} + verify: + introspect: true +--- +apiVersion: iam.homelab.riotpiao.com/v1 +kind: KeyRotation +metadata: + name: forgejo + namespace: iam + labels: + app.kubernetes.io/part-of: key-rotation +spec: + provider: zitadel-oidc-client + strategy: atomic + intervalHours: 2160 + suspend: true + target: + zitadelProjectID: "396757796898471937" + zitadelAppID: "396757801579315583" + secretName: forgejo-oidc + secretNamespace: forgejo + secretKeys: + secret: key + reload: restart + consumers: + - {kind: StatefulSet, name: forgejo, namespace: forgejo} + verify: + introspect: true +--- +apiVersion: iam.homelab.riotpiao.com/v1 +kind: KeyRotation +metadata: + name: api-gw + namespace: iam + labels: + app.kubernetes.io/part-of: key-rotation +spec: + provider: zitadel-oidc-client + strategy: atomic + intervalHours: 2160 + suspend: true + target: + zitadelProjectID: "396757796898471937" + zitadelAppID: "396757800874672511" + secretName: api-gw-oidc + secretNamespace: api-gw + secretKeys: + secret: client-secret + reload: hot + consumers: + - {kind: Deployment, name: api-gw, namespace: api-gw} + verify: + introspect: true