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:
2026-10-27 14:28:28 -07:00
parent 2485b5f165
commit c2071da9d8
18 changed files with 2367 additions and 0 deletions
@@ -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")
}
}