feat(iam): define a KeyRotation for every consumer except poimen-memory
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.
This commit was merged in pull request #84.
This commit is contained in:
@@ -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.
|
||||
@@ -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:<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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user