From 667bca0f4458bd8a70c17a14af65694f38b6166e Mon Sep 17 00:00:00 2001 From: rock Date: Thu, 3 Sep 2026 23:37:24 -0700 Subject: [PATCH] feat: automated secret rotation controller - ExternalSecret syncs age key from Vault to pod - CRD defines rotation schedule for each secret - Controller watches CRD, rotates on schedule: * Call provider API (Authentik/Forgejo/MinIO) for new secret * Update k8s Secret * Update .enc.yaml via sops (uses age key from Vault) * Git commit and push - Vault is source of truth for age key (never on disk) - Examples: minio-oidc (90d), portfolio-agent (90d), forgejo-token (90d), minio-root (180d) --- k8s/apps/secret-rotation-controller/crd.yaml | 144 ++++++++++++++++++ .../deployment.yaml | 92 +++++++++++ .../kustomization.yaml | 15 ++ k8s/apps/secret-rotation-controller/rbac.yaml | 53 +++++++ 4 files changed, 304 insertions(+) create mode 100644 k8s/apps/secret-rotation-controller/crd.yaml create mode 100644 k8s/apps/secret-rotation-controller/deployment.yaml create mode 100644 k8s/apps/secret-rotation-controller/kustomization.yaml create mode 100644 k8s/apps/secret-rotation-controller/rbac.yaml diff --git a/k8s/apps/secret-rotation-controller/crd.yaml b/k8s/apps/secret-rotation-controller/crd.yaml new file mode 100644 index 0000000..583e466 --- /dev/null +++ b/k8s/apps/secret-rotation-controller/crd.yaml @@ -0,0 +1,144 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: secretrotations.homelab.riotpiao.com +spec: + group: homelab.riotpiao.com + names: + kind: SecretRotation + plural: secretrotations + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + metadata: + type: object + spec: + type: object + required: + - provider + - rotationInterval + properties: + # External system: authentik | forgejo | minio | vault + provider: + type: string + enum: [authentik, forgejo, minio, vault] + + # How often to rotate (hours) + rotationInterval: + type: integer + minimum: 24 + + # Application ID in external system + appId: + type: string + + # k8s Secret to update (name, namespace, key) + secretRef: + type: object + required: [name, namespace] + properties: + name: + type: string + namespace: + type: string + key: + type: string + description: "Secret key to update (e.g., MINIO_IDENTITY_OPENID_CLIENT_SECRET)" + + # Path to git file that holds the secret (for .enc.yaml files) + gitPath: + type: string + description: "Path in homelab repo to .enc.yaml file" + + # Ansible template values to substitute + templateValues: + type: object + additionalProperties: + type: string + + status: + type: object + properties: + lastRotationTime: + type: string + format: date-time + nextRotationTime: + type: string + format: date-time + lastRotationStatus: + type: string + enum: [Success, Failed, Pending] + lastRotationError: + type: string + lastCommitHash: + type: string + +--- +# Example usage: +apiVersion: homelab.riotpiao.com/v1 +kind: SecretRotation +metadata: + name: minio-oidc + namespace: secret-rotation +spec: + provider: authentik + rotationInterval: 2160 # 90 days in hours + appId: minio + secretRef: + name: minio-oidc + namespace: storage + key: MINIO_IDENTITY_OPENID_CLIENT_SECRET + gitPath: k8s/argocd/secrets/minio-oidc.enc.yaml + +--- +apiVersion: homelab.riotpiao.com/v1 +kind: SecretRotation +metadata: + name: portfolio-agent-oidc + namespace: secret-rotation +spec: + provider: authentik + rotationInterval: 2160 + appId: portfolio-agent + secretRef: + name: portfolio-agent-oidc + namespace: portfolio + key: CLIENT_SECRET + gitPath: k8s/argocd/secrets/portfolio-agent-oidc.enc.yaml + +--- +apiVersion: homelab.riotpiao.com/v1 +kind: SecretRotation +metadata: + name: forgejo-registry-token + namespace: secret-rotation +spec: + provider: forgejo + rotationInterval: 2160 + appId: rock/riotpiao.com + secretRef: + name: forgejo-registry-secret + namespace: kube-system + key: REGISTRY_TOKEN + gitPath: k8s/argocd/secrets/forgejo-registry-secret.enc.yaml + +--- +apiVersion: homelab.riotpiao.com/v1 +kind: SecretRotation +metadata: + name: minio-root-credentials + namespace: secret-rotation +spec: + provider: minio + rotationInterval: 4320 # 180 days in hours + appId: root + secretRef: + name: minio-creds + namespace: storage + gitPath: k8s/argocd/secrets/minio-secrets.enc.yaml diff --git a/k8s/apps/secret-rotation-controller/deployment.yaml b/k8s/apps/secret-rotation-controller/deployment.yaml new file mode 100644 index 0000000..c615218 --- /dev/null +++ b/k8s/apps/secret-rotation-controller/deployment.yaml @@ -0,0 +1,92 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: secret-rotation-controller + namespace: secret-rotation +spec: + replicas: 1 + selector: + matchLabels: + app: secret-rotation-controller + template: + metadata: + labels: + app: secret-rotation-controller + spec: + serviceAccountName: secret-rotation-controller + containers: + - name: controller + image: secret-rotation-controller:latest + imagePullPolicy: IfNotPresent + env: + # SOPS reads age key from this file + - name: SOPS_AGE_KEY_FILE + value: /etc/sops/age/private-key.txt + + # Vault auth (token in projected volume) + - name: VAULT_ADDR + value: http://vault.vault.svc.cluster.local:8200 + - name: VAULT_TOKEN_FILE + value: /var/run/secrets/vault/token + + # Authentik + - name: AUTHENTIK_URL + value: http://authentik-server.iam.svc.cluster.local + - name: AUTHENTIK_BOOTSTRAP_TOKEN + valueFrom: + secretKeyRef: + name: authentik-bootstrap + key: token + + # Git + - name: GIT_REPO + value: https://forgejo.riotpiao.com/rock/homelab.git + - name: GIT_AUTHOR_EMAIL + value: secret-rotation@homelab.local + - name: GIT_AUTHOR_NAME + value: Secret Rotation Controller + - name: FORGEJO_TOKEN + valueFrom: + secretKeyRef: + name: forgejo-registry-secret + key: REGISTRY_TOKEN + + volumeMounts: + # Age key from ExternalSecret (synced from Vault) + - name: age-key + mountPath: /etc/sops/age + readOnly: true + + # Vault auth token (projected) + - name: vault-token + mountPath: /var/run/secrets/vault + readOnly: true + + # Temp working dir + - name: tmp + mountPath: /tmp + + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + + volumes: + - name: age-key + secret: + secretName: sops-age-key + defaultMode: 0400 + + - name: vault-token + projected: + sources: + - serviceAccountToken: + path: token + audience: vault + expirationSeconds: 3600 + + - name: tmp + emptyDir: {} diff --git a/k8s/apps/secret-rotation-controller/kustomization.yaml b/k8s/apps/secret-rotation-controller/kustomization.yaml new file mode 100644 index 0000000..f17953a --- /dev/null +++ b/k8s/apps/secret-rotation-controller/kustomization.yaml @@ -0,0 +1,15 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: secret-rotation + +resources: + - rbac.yaml + - crd.yaml + - external-secret.yaml + - deployment.yaml + +commonLabels: + app.kubernetes.io/name: secret-rotation-controller + app.kubernetes.io/component: automation + managed-by: argocd diff --git a/k8s/apps/secret-rotation-controller/rbac.yaml b/k8s/apps/secret-rotation-controller/rbac.yaml new file mode 100644 index 0000000..f5c09df --- /dev/null +++ b/k8s/apps/secret-rotation-controller/rbac.yaml @@ -0,0 +1,53 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: secret-rotation-controller + namespace: secret-rotation + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: secret-rotation-controller +rules: +# Read SecretRotation CRDs +- apiGroups: ["homelab.riotpiao.com"] + resources: ["secretrotations"] + verbs: ["get", "list", "watch"] + +# Update status +- apiGroups: ["homelab.riotpiao.com"] + resources: ["secretrotations/status"] + verbs: ["get", "patch", "update"] + +# Read k8s secrets that will be rotated +- apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list"] + +# For recording events +- apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: secret-rotation-controller +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: secret-rotation-controller +subjects: +- kind: ServiceAccount + name: secret-rotation-controller + namespace: secret-rotation + +--- +apiVersion: v1 +kind: Namespace +metadata: + name: secret-rotation + labels: + kubernetes.io/metadata.name: secret-rotation