From c85556e327a9ed8e41a94087cdf893fab1b8af6c Mon Sep 17 00:00:00 2001 From: nick-gorse Date: Mon, 20 Apr 2026 01:03:25 +0100 Subject: [PATCH] Initial authentik gitops app --- .gitea/template | 12 + .gitea/workflows/validate.yaml | 149 ++++++++++++ .github/workflows/check-sops-sync.yml | 34 +++ .gitignore | 9 + .sops.yaml | 7 + README.md | 224 ++++++++++++++++++ bootstrap/applicationset.yaml | 40 ++++ bootstrap/config.yaml | 3 + manifest/base/deployment.yaml | 84 +++++++ manifest/base/kustomization.yaml | 10 + manifest/base/service.yaml | 17 ++ manifest/base/serviceaccount.yaml | 5 + manifest/base/worker-deployment.yaml | 64 +++++ .../example-kustomization.yaml | 7 + .../example-component/example-test.env | 3 + manifest/overlays/production/database.yaml | 10 + .../overlays/production/ingressroute.yaml | 31 +++ .../overlays/production/kustomization.yaml | 13 + .../overlays/production/secret-generator.yaml | 10 + .../overlays/production/secret.secret.yaml | 24 ++ .../storage/persistentvolume-nfs.yaml | 32 +++ .../storage/persistentvolumeclaim.yaml | 11 + scripts/ci/check-sops-sync | 77 ++++++ scripts/git-hooks/pre-commit | 111 +++++++++ scripts/install-git-hooks | 16 ++ scripts/lib/sops-path-regexes | 38 +++ scripts/lib/sops-sync-lib | 88 +++++++ 27 files changed, 1129 insertions(+) create mode 100644 .gitea/template create mode 100644 .gitea/workflows/validate.yaml create mode 100644 .github/workflows/check-sops-sync.yml create mode 100644 .gitignore create mode 100644 .sops.yaml create mode 100644 README.md create mode 100644 bootstrap/applicationset.yaml create mode 100644 bootstrap/config.yaml create mode 100644 manifest/base/deployment.yaml create mode 100644 manifest/base/kustomization.yaml create mode 100644 manifest/base/service.yaml create mode 100644 manifest/base/serviceaccount.yaml create mode 100644 manifest/base/worker-deployment.yaml create mode 100644 manifest/components/example-component/example-kustomization.yaml create mode 100644 manifest/components/example-component/example-test.env create mode 100644 manifest/overlays/production/database.yaml create mode 100644 manifest/overlays/production/ingressroute.yaml create mode 100644 manifest/overlays/production/kustomization.yaml create mode 100644 manifest/overlays/production/secret-generator.yaml create mode 100644 manifest/overlays/production/secret.secret.yaml create mode 100644 manifest/overlays/production/storage/persistentvolume-nfs.yaml create mode 100644 manifest/overlays/production/storage/persistentvolumeclaim.yaml create mode 100755 scripts/ci/check-sops-sync create mode 100755 scripts/git-hooks/pre-commit create mode 100755 scripts/install-git-hooks create mode 100755 scripts/lib/sops-path-regexes create mode 100644 scripts/lib/sops-sync-lib diff --git a/.gitea/template b/.gitea/template new file mode 100644 index 0000000..7b03144 --- /dev/null +++ b/.gitea/template @@ -0,0 +1,12 @@ +# Files in this list are expanded by Gitea when a new repository is created +# from this template repository via the web UI. +README.md +.sops.yaml +bootstrap/applicationset.yaml +manifest/base/kustomization.yaml +manifest/base/deployment.yaml +manifest/base/service.yaml +manifest/overlays/production/kustomization.yaml +manifest/overlays/production/ingressroute.yaml +manifest/overlays/production/storage/persistentvolumeclaim.yaml +manifest/overlays/production/storage/persistentvolume-nfs.yaml diff --git a/.gitea/workflows/validate.yaml b/.gitea/workflows/validate.yaml new file mode 100644 index 0000000..b099317 --- /dev/null +++ b/.gitea/workflows/validate.yaml @@ -0,0 +1,149 @@ +name: Validate manifests + +on: + push: + pull_request: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install tools + run: | + curl -sL https://github.com/yannh/kubeconform/releases/latest/download/kubeconform-linux-amd64.tar.gz \ + | tar xz -C /usr/local/bin + curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash + curl -fsSL https://github.com/getsops/sops/releases/latest/download/sops-v3.x.linux.amd64 \ + -o /usr/local/bin/sops + chmod +x /usr/local/bin/sops + curl -fsSL https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 \ + -o /usr/local/bin/yq + chmod +x /usr/local/bin/yq + + # Traefik IngressRoute is a CRD, so kubeconform needs an extra schema source. + mkdir -p .ci-schemas/traefik.io + curl -fsSL https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/traefik.io/ingressroute_v1alpha1.json \ + -o .ci-schemas/traefik.io/ingressroute_v1alpha1.json + cp .ci-schemas/traefik.io/ingressroute_v1alpha1.json .ci-schemas/traefik.io/IngressRoute_v1alpha1.json + cp .ci-schemas/traefik.io/ingressroute_v1alpha1.json .ci-schemas/traefik.io/IngressRoute.json + cp .ci-schemas/traefik.io/ingressroute_v1alpha1.json .ci-schemas/traefik.io/ingressroute.json + + # ArgoCD ApplicationSet is also a CRD. It stays dormant until bootstrap + # is enabled, but once enabled we validate it with an explicit schema. + mkdir -p .ci-schemas/argoproj.io + curl -fsSL https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/argoproj.io/applicationset_v1alpha1.json \ + -o .ci-schemas/argoproj.io/applicationset_v1alpha1.json + cp .ci-schemas/argoproj.io/applicationset_v1alpha1.json .ci-schemas/argoproj.io/ApplicationSet_v1alpha1.json + cp .ci-schemas/argoproj.io/applicationset_v1alpha1.json .ci-schemas/argoproj.io/ApplicationSet.json + cp .ci-schemas/argoproj.io/applicationset_v1alpha1.json .ci-schemas/argoproj.io/applicationset.json + + - name: Helm lint + run: | + found=0 + + for parent in helm custom-charts; do + [ -d "$parent" ] || continue + + for chart in "$parent"/*; do + [ -d "$chart" ] || continue + found=1 + echo "Linting $chart" + helm lint "$chart" + done + done + + if [ "$found" -eq 0 ]; then + echo "No Helm charts found" + fi + + - name: kubeconform - raw YAML + run: | + bootstrap_enabled=false + if [ -f bootstrap/config.yaml ] && grep -Eq '^[[:space:]]*enabled:[[:space:]]*true[[:space:]]*$' bootstrap/config.yaml; then + bootstrap_enabled=true + fi + + mapfile -t manifests < <( + find . -type f -name '*.yaml' \ + ! -path './.gitea/*' \ + ! -name '.sops.yaml' \ + ! -name '*.enc.yaml' \ + ! -name '*.secret.yaml' \ + ! -name '*kustomization.yaml' \ + ! -name 'secret-generator.yaml' \ + ! -path './bootstrap/config.yaml' \ + | sort + ) + + if [ "$bootstrap_enabled" != "true" ]; then + filtered=() + for manifest in "${manifests[@]}"; do + [ "$manifest" = "./bootstrap/applicationset.yaml" ] && continue + filtered+=("$manifest") + done + manifests=("${filtered[@]}") + fi + + if [ "${#manifests[@]}" -eq 0 ]; then + echo "No manifests found" + exit 0 + fi + + printf '%s\n' "${manifests[@]}" \ + | xargs kubeconform \ + -strict \ + -kubernetes-version 1.35.0 \ + -schema-location default \ + -schema-location '.ci-schemas/{{.Group}}/{{.ResourceKind}}{{.KindSuffix}}.json' \ + -schema-location '.ci-schemas/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' \ + -summary + + - name: SOPS - check no secret files committed unencrypted + run: | + fail=0 + + while IFS= read -r file; do + if ! grep -q 'sops:' "$file"; then + echo "ERROR: $file appears to be unencrypted" + fail=1 + fi + done < <( + find . -type f \( -name '*.secret.yaml' -o -name '*.enc.yaml' \) | sort + ) + + exit "$fail" + + - name: Check SOPS sync + env: + SOPS_SYNC_HMAC_KEY: ${{ secrets.SOPS_SYNC_HMAC_KEY }} + run: scripts/ci/check-sops-sync + + - name: Apply bootstrap ApplicationSet + env: + KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }} + run: | + if [ "${GITHUB_EVENT_NAME:-}" != "push" ] || [ "${GITHUB_REF:-}" != "refs/heads/main" ]; then + echo "Skipping bootstrap apply: only push events on main may apply" + exit 0 + fi + + if [ ! -f bootstrap/config.yaml ] || ! grep -Eq '^[[:space:]]*enabled:[[:space:]]*true[[:space:]]*$' bootstrap/config.yaml; then + echo "Skipping bootstrap apply: bootstrap/config.yaml is not enabled" + exit 0 + fi + + if [ -z "${KUBECONFIG_B64:-}" ]; then + echo "KUBECONFIG_B64 secret is required to apply bootstrap/applicationset.yaml" + exit 1 + fi + + curl -fsSL https://dl.k8s.io/release/$(curl -fsSL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl \ + -o /usr/local/bin/kubectl + chmod +x /usr/local/bin/kubectl + + mkdir -p "${HOME}/.kube" + printf '%s' "$KUBECONFIG_B64" | base64 -d > "${HOME}/.kube/config" + + kubectl apply -f bootstrap/applicationset.yaml diff --git a/.github/workflows/check-sops-sync.yml b/.github/workflows/check-sops-sync.yml new file mode 100644 index 0000000..f9e8af1 --- /dev/null +++ b/.github/workflows/check-sops-sync.yml @@ -0,0 +1,34 @@ +name: Check SOPS sync + +on: + push: + pull_request: + +jobs: + check-sops-sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install tools + run: | + sudo apt-get update + sudo apt-get install -y openssl + curl -fsSL https://github.com/getsops/sops/releases/latest/download/sops-v3.x.linux.amd64 \ + -o /tmp/sops + sudo install -m 0755 /tmp/sops /usr/local/bin/sops + curl -fsSL https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 \ + -o /tmp/yq + sudo install -m 0755 /tmp/yq /usr/local/bin/yq + + - name: Configure SOPS age key + if: ${{ secrets.SOPS_AGE_KEY != '' }} + run: | + mkdir -p "${HOME}/.config/sops/age" + printf '%s' '${{ secrets.SOPS_AGE_KEY }}' > "${HOME}/.config/sops/age/keys.txt" + chmod 600 "${HOME}/.config/sops/age/keys.txt" + + - name: Check SOPS sync + env: + SOPS_SYNC_HMAC_KEY: ${{ secrets.SOPS_SYNC_HMAC_KEY }} + run: scripts/ci/check-sops-sync diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26ee1c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +.idea/ +.vscode/ +*.swp +*.swo + +# Local plaintext twins for sync-managed secrets. Keep these untracked. +manifest/overlays/**/secret.yaml +manifest/components/**/secret.yaml diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000..76d53dd --- /dev/null +++ b/.sops.yaml @@ -0,0 +1,7 @@ +# Replace the age recipient below with the public key used by ArgoCD / ksops +# in the target environment before storing real secrets in this repository. +creation_rules: + - path_regex: manifest/(overlays|components)/.*/.*\.enc\.yaml$ + age: age1s0gzgh8c00tgnkgxrqyz2nu0k56xvc9ev3jdenkmu90egux9xfmsxvvj43 + - path_regex: manifest/(overlays|components)/.*/secret\.secret\.yaml$ + age: age1s0gzgh8c00tgnkgxrqyz2nu0k56xvc9ev3jdenkmu90egux9xfmsxvvj43 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a736bdf --- /dev/null +++ b/README.md @@ -0,0 +1,224 @@ +# authentik + +Kubernetes deployment source-of-truth repository for `authentik`. + +This template is intended for applications deployed with ArgoCD through an +`ApplicationSet`. It uses a `base/` plus `overlays/` layout, stores runtime +secrets as SOPS-encrypted manifests, and includes a Gitea workflow that checks +Kubernetes YAML syntax on every push and pull request. It also ships with a +dormant bootstrap `ApplicationSet` that only becomes active after an explicit +enable change and a promotion into `main`. + +## Layout + +```text +. +├── .gitea/ +│ ├── template +│ └── workflows/validate.yaml +├── .github/ +│ └── workflows/check-sops-sync.yml +├── .sops.yaml +├── bootstrap/ +│ ├── applicationset.yaml +│ └── config.yaml +├── manifest/ +│ ├── base/ +│ │ ├── deployment.yaml +│ │ ├── kustomization.yaml +│ │ ├── namespace.yaml +│ │ └── service.yaml +│ ├── components/ +│ │ └── example-component/ +│ └── overlays/ +│ └── production/ +│ ├── ingressroute.yaml +│ ├── kustomization.yaml +│ ├── secret-generator.yaml +│ ├── secret.secret.yaml +│ └── storage/ +│ ├── persistentvolumeclaim.yaml +│ └── persistentvolume-nfs.yaml +└── scripts/ + ├── ci/check-sops-sync + ├── git-hooks/pre-commit + ├── install-git-hooks + └── lib/ + ├── sops-path-regexes + └── sops-sync-lib +``` + +## First edits + +Update these files before the first deployment: + +1. `manifest/base/deployment.yaml` + Set the container image, ports, probes, resource requests, and any required + environment variables. +2. `manifest/overlays/production/ingressroute.yaml` + Set the real hostname, entry point names, and middleware references if used. +3. `manifest/overlays/production/storage/persistentvolumeclaim.yaml` + Set the requested size, access mode, and storage class for the environment. +4. `.sops.yaml` + Replace the sample age recipient with the public key that ArgoCD should use + for decryption. +5. `manifest/overlays/production/secret.secret.yaml` + Re-encrypt the placeholder secret values with your own data. + +## Bootstrap activation model + +This template is designed to start on an `init` branch. The intended flow is: + +1. Create the repository from this template with `init` as the initial branch. +2. Tailor the manifests, secrets, storage, and ingress on `init`. +3. Keep `bootstrap/config.yaml` set to `enabled: false` while the repository is + being prepared. +4. Create `main` by merging `init` only when the repository is ready to be part + of the delivery pipeline. +5. Deliberately change `bootstrap/config.yaml` to `enabled: true` on `main` to + allow the workflow to apply the bootstrap `ApplicationSet`. + +The workflow behavior is: + +- `bootstrap/applicationset.yaml` is ignored by validation while bootstrap is disabled +- no cluster apply is attempted unless the workflow runs on `main` +- cluster apply also requires the `KUBECONFIG_B64` Gitea secret + +## Bootstrap files + +- `bootstrap/config.yaml` + Repository-local activation switch. Default is disabled. +- `bootstrap/applicationset.yaml` + ArgoCD `ApplicationSet` manifest template for this repository. + +If the repository is created by copying files with plain Git instead of Gitea's +template expansion, you must replace the `${REPO_NAME...}` placeholders in +`bootstrap/applicationset.yaml` before enabling bootstrap. + +## Storage model + +Storage is overlay-owned, so each environment can request different backing +storage without sharing claims across environments. + +- `manifest/overlays/production/storage/persistentvolumeclaim.yaml` + Default writable application storage. With the default `longhorn` + `storageClassName`, Kubernetes dynamically provisions the backing PV when the + claim is created. +- `manifest/overlays/production/storage/persistentvolume-nfs.yaml` + Optional static PV example for NFS-backed or pre-provisioned shared storage. + This file is not referenced by default. Add it to the overlay kustomization + only when you need a static PV. + +The base deployment mounts the claim at `/data`. + +If storage should be shared between multiple applications in different +repositories, keep those applications in the same namespace and have a single +repository own the PVC resource. The other repositories should reference the +same claim name from their deployments instead of creating a duplicate PVC. + +## Secret workflow + +1. Generate an age key pair. +2. Store the private key where ArgoCD / ksops can read it. +3. Update `.sops.yaml` with the matching public recipient. +4. Re-encrypt `manifest/overlays/production/secret.secret.yaml`. + +Example: + +```bash +age-keygen -o age-key.txt +sops --encrypt --in-place manifest/overlays/production/secret.secret.yaml +``` + +## Repo-managed SOPS sync + +This repo also supports sync-managed secrets using the naming convention: + +```text +foo.yaml -> foo.enc.yaml -> foo.enc.yaml.sync-hmac +``` + +- `foo.yaml` + Local plaintext source. Keep it gitignored and untracked. +- `foo.enc.yaml` + Tracked SOPS-encrypted file. +- `foo.enc.yaml.sync-hmac` + Tracked HMAC sidecar used to prove the encrypted file still matches the last + synced plaintext content. + +Requirements: + +- `SOPS_SYNC_HMAC_KEY` must be set locally for hooks and in CI +- `sops`, `openssl`, and either `yq` or `python3` with `PyYAML` must be available +- CI also needs access to the repo's SOPS decryption mechanism, such as an age key + +Generate a local HMAC key: + +```bash +openssl rand -hex 32 +``` + +Set it for the current shell session: + +```bash +export SOPS_SYNC_HMAC_KEY='' +``` + +Persist it in `zsh`: + +```bash +echo "export SOPS_SYNC_HMAC_KEY=''" >> ~/.zshrc +source ~/.zshrc +``` + +Persist it in `bash`: + +```bash +echo "export SOPS_SYNC_HMAC_KEY=''" >> ~/.bashrc +source ~/.bashrc +``` + +Verify it is set: + +```bash +printenv SOPS_SYNC_HMAC_KEY +``` + +Install the local hook: + +```bash +scripts/install-git-hooks +``` + +Local workflow: + +1. Keep your plaintext secret local-only, for example `manifest/overlays/production/secret.yaml`. +2. Stage either the plaintext file or its `.enc.yaml` twin. +3. On pre-commit, the hook encrypts the plaintext if needed, updates the + `.sync-hmac` sidecar, and stages both tracked files. + +CI behavior: + +- CI fails if a tracked `.enc.yaml` file is not valid SOPS content +- CI fails if the `.sync-hmac` sidecar is missing +- CI fails if decrypted plaintext no longer matches the tracked sidecar HMAC + +Security tradeoffs: + +- The sidecar is an HMAC, not a plain checksum, so the plaintext fingerprint is + keyed and not directly reusable without `SOPS_SYNC_HMAC_KEY` +- Anyone who can decrypt the repo secret files and also access the HMAC key can + recompute sidecars, so protect both inputs appropriately +- Plaintext local files must remain gitignored and never be committed + +## ArgoCD ApplicationSet path + +Point the generated ArgoCD `Application` at: + +```text +manifest/overlays/production +``` + +If your `ApplicationSet` uses repository scanning, this repository is designed +to be the single source of truth for one application, with ArgoCD rendering the +selected overlay. diff --git a/bootstrap/applicationset.yaml b/bootstrap/applicationset.yaml new file mode 100644 index 0000000..2c07d4e --- /dev/null +++ b/bootstrap/applicationset.yaml @@ -0,0 +1,40 @@ +apiVersion: argoproj.io/v1alpha1 +kind: ApplicationSet +metadata: + name: authentik + namespace: argocd +spec: + goTemplate: true + goTemplateOptions: + - missingkey=error + generators: + - list: + elements: + - environment: production + namespace: security + path: manifest/overlays/production + template: + metadata: + name: 'authentik-{{ .environment }}' + labels: + app.kubernetes.io/managed-by: argocd + app.kubernetes.io/name: authentik + spec: + project: default + source: + repoURL: http://gitea-ha-http.apps:3000/olb42/authentik.git + targetRevision: main + path: '{{ .path }}' + plugin: + name: ksops + destination: + server: https://kubernetes.default.svc + namespace: '{{ .namespace }}' + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=false + - ApplyOutOfSyncOnly=true + - ServerSideApply=true diff --git a/bootstrap/config.yaml b/bootstrap/config.yaml new file mode 100644 index 0000000..5e77a0b --- /dev/null +++ b/bootstrap/config.yaml @@ -0,0 +1,3 @@ +enabled: false +target_namespace: argocd +apply_from_branch: main diff --git a/manifest/base/deployment.yaml b/manifest/base/deployment.yaml new file mode 100644 index 0000000..9a84188 --- /dev/null +++ b/manifest/base/deployment.yaml @@ -0,0 +1,84 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: authentik-server + namespace: security + labels: + app: authentik-server +spec: + replicas: 1 + selector: + matchLabels: + app: authentik-server + template: + metadata: + labels: + app: authentik-server + app.kubernetes.io/name: authentik-server + spec: + automountServiceAccountToken: false + securityContext: + runAsUser: 1000 + runAsGroup: 988 + containers: + - name: server + image: ghcr.io/goauthentik/server:2026.2 + imagePullPolicy: IfNotPresent + args: + - server + ports: + - name: http + containerPort: 9000 + - name: https + containerPort: 9443 + env: + - name: AUTHENTIK_LISTEN__HTTP + value: 0.0.0.0:9000 + - name: AUTHENTIK_LISTEN__HTTPS + value: 0.0.0.0:9443 + - name: AUTHENTIK_POSTGRESQL__HOST + value: shared-postgres-rw.data.svc.cluster.local + - name: AUTHENTIK_POSTGRESQL__NAME + value: authentik + - name: AUTHENTIK_POSTGRESQL__USER + value: authentik + - name: AUTHENTIK_POSTGRESQL__PORT + value: "5432" + - name: AUTHENTIK_POSTGRESQL__SSLMODE + value: disable + - name: AUTHENTIK_POSTGRESQL__PASSWORD + value: file:///run/secrets/db_password + - name: AUTHENTIK_SECRET_KEY + value: file:///run/secrets/secret_key + - name: AUTHENTIK_ERROR_REPORTING__ENABLED + value: "false" + volumeMounts: + - name: secrets + mountPath: /run/secrets/db_password + readOnly: true + subPath: db_password + - name: secrets + mountPath: /run/secrets/secret_key + readOnly: true + subPath: secret_key + - name: authentik-data + mountPath: /data + readinessProbe: + httpGet: + path: /-/health/ready/ + port: http + initialDelaySeconds: 15 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /-/health/live/ + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + volumes: + - name: secrets + secret: + secretName: authentik-secrets + - name: authentik-data + persistentVolumeClaim: + claimName: authentik-data diff --git a/manifest/base/kustomization.yaml b/manifest/base/kustomization.yaml new file mode 100644 index 0000000..c719a05 --- /dev/null +++ b/manifest/base/kustomization.yaml @@ -0,0 +1,10 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: security + +resources: + - serviceaccount.yaml + - deployment.yaml + - worker-deployment.yaml + - service.yaml diff --git a/manifest/base/service.yaml b/manifest/base/service.yaml new file mode 100644 index 0000000..eb55595 --- /dev/null +++ b/manifest/base/service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: authentik + namespace: security + labels: + app: authentik-server +spec: + selector: + app: authentik-server + ports: + - name: http + port: 9000 + targetPort: http + - name: https + port: 9443 + targetPort: https diff --git a/manifest/base/serviceaccount.yaml b/manifest/base/serviceaccount.yaml new file mode 100644 index 0000000..f917f64 --- /dev/null +++ b/manifest/base/serviceaccount.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: authentik-worker + namespace: security diff --git a/manifest/base/worker-deployment.yaml b/manifest/base/worker-deployment.yaml new file mode 100644 index 0000000..ec60833 --- /dev/null +++ b/manifest/base/worker-deployment.yaml @@ -0,0 +1,64 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: authentik-worker + namespace: security + labels: + app: authentik-worker +spec: + replicas: 1 + selector: + matchLabels: + app: authentik-worker + template: + metadata: + labels: + app: authentik-worker + spec: + serviceAccountName: authentik-worker + securityContext: + runAsUser: 1000 + runAsGroup: 988 + containers: + - name: worker + image: ghcr.io/goauthentik/server:2026.2 + imagePullPolicy: IfNotPresent + args: + - worker + env: + - name: AUTHENTIK_POSTGRESQL__HOST + value: shared-postgres-rw.data.svc.cluster.local + - name: AUTHENTIK_POSTGRESQL__NAME + value: authentik + - name: AUTHENTIK_POSTGRESQL__USER + value: authentik + - name: AUTHENTIK_POSTGRESQL__PORT + value: "5432" + - name: AUTHENTIK_POSTGRESQL__SSLMODE + value: disable + - name: AUTHENTIK_POSTGRESQL__PASSWORD + value: file:///run/secrets/db_password + - name: AUTHENTIK_SECRET_KEY + value: file:///run/secrets/secret_key + - name: AUTHENTIK_ERROR_REPORTING__ENABLED + value: "false" + - name: AUTHENTIK_WORKER__THREADS + value: "2" + volumeMounts: + - name: secrets + mountPath: /run/secrets/db_password + readOnly: true + subPath: db_password + - name: secrets + mountPath: /run/secrets/secret_key + readOnly: true + subPath: secret_key + - name: authentik-data + mountPath: /data + volumes: + - name: secrets + secret: + secretName: authentik-secrets + - name: authentik-data + persistentVolumeClaim: + claimName: authentik-data diff --git a/manifest/components/example-component/example-kustomization.yaml b/manifest/components/example-component/example-kustomization.yaml new file mode 100644 index 0000000..0b7d08b --- /dev/null +++ b/manifest/components/example-component/example-kustomization.yaml @@ -0,0 +1,7 @@ +# +argocd:skip-file-rendering +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +configMapGenerator: +- files: + - example-test.env \ No newline at end of file diff --git a/manifest/components/example-component/example-test.env b/manifest/components/example-component/example-test.env new file mode 100644 index 0000000..0567ba3 --- /dev/null +++ b/manifest/components/example-component/example-test.env @@ -0,0 +1,3 @@ + # +argocd:skip-file-rendering + + test=works \ No newline at end of file diff --git a/manifest/overlays/production/database.yaml b/manifest/overlays/production/database.yaml new file mode 100644 index 0000000..22d8026 --- /dev/null +++ b/manifest/overlays/production/database.yaml @@ -0,0 +1,10 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: Database +metadata: + name: shared-postgres-authentik-db + namespace: data +spec: + name: authentik + owner: authentik + cluster: + name: shared-postgres diff --git a/manifest/overlays/production/ingressroute.yaml b/manifest/overlays/production/ingressroute.yaml new file mode 100644 index 0000000..bbbc376 --- /dev/null +++ b/manifest/overlays/production/ingressroute.yaml @@ -0,0 +1,31 @@ +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: authentik + namespace: security + annotations: + gethomepage.dev/app: authentik-server + gethomepage.dev/enabled: "false" + gethomepage.dev/group: Security + gethomepage.dev/href: https://auth.olb42.com/ + gethomepage.dev/icon: authentik + gethomepage.dev/name: Authentik + gethomepage.dev/namespace: security + gethomepage.dev/widget.key: "{{HOMEPAGE_VAR_AUTHENTIK_KEY}}" + gethomepage.dev/widget.type: authentik + gethomepage.dev/widget.url: http://authentik.security:9000 + gethomepage.dev/widget.version: "2" +spec: + entryPoints: + - websecure + routes: + - kind: Rule + match: Host(`auth.olb42.com`) + middlewares: + - name: lan-only + namespace: infra + services: + - name: authentik + port: 9000 + tls: + secretName: olb42-wildcard-tls diff --git a/manifest/overlays/production/kustomization.yaml b/manifest/overlays/production/kustomization.yaml new file mode 100644 index 0000000..1ff772d --- /dev/null +++ b/manifest/overlays/production/kustomization.yaml @@ -0,0 +1,13 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: security + +resources: + - ../../base + - database.yaml + - ingressroute.yaml + - storage/persistentvolumeclaim.yaml + +generators: + - secret-generator.yaml diff --git a/manifest/overlays/production/secret-generator.yaml b/manifest/overlays/production/secret-generator.yaml new file mode 100644 index 0000000..f6809df --- /dev/null +++ b/manifest/overlays/production/secret-generator.yaml @@ -0,0 +1,10 @@ +apiVersion: viaduct.ai/v1 +kind: ksops +metadata: + name: authentik-secret-generator + annotations: + config.kubernetes.io/function: | + exec: + path: ksops +files: + - ./secret.secret.yaml diff --git a/manifest/overlays/production/secret.secret.yaml b/manifest/overlays/production/secret.secret.yaml new file mode 100644 index 0000000..0470750 --- /dev/null +++ b/manifest/overlays/production/secret.secret.yaml @@ -0,0 +1,24 @@ +apiVersion: ENC[AES256_GCM,data:nhU=,iv:+igd9RTOMy7mwVDjx05WQIdytoCrWLjcnP2Wq8xBS1s=,tag:61hSvgd/g88pjdhPW6OQsg==,type:str] +kind: ENC[AES256_GCM,data:T9Wrg72s,iv:1+cSHl0Dwwc1ddnw4dQI26r8/aEfMAPpsmz6BxxetrQ=,tag:y8HSeCa9gl7i+rW1Qpzc0Q==,type:str] +metadata: + name: ENC[AES256_GCM,data:VNJIfO/EtW0Pd3xVKG2R1VM=,iv:53UVCYVV07Qdlbq7j9qghT/DHqv+98muUFpapoD7VBs=,tag:hKBPwm7dudPUlRCgp4D9/g==,type:str] + namespace: ENC[AES256_GCM,data:/STezL4E9q4=,iv:mImCvy0lheb1px4+VEm7Z2c6qgQFerRnciPUy+arG2o=,tag:ZJxPwZ7pO6j+8xgHSDK/Og==,type:str] +type: ENC[AES256_GCM,data:ypwwk80m,iv:gbfQojFcmmGCtALVkCcICc4Z4dDXQK8clTGIMkOMOkY=,tag:Y0AskWriaazAlDkNN6Ufuw==,type:str] +stringData: + db_password: ENC[AES256_GCM,data:vx+4cvOPHycDmgp3fhTqsRC6ZjdviJy3svt5Kuhtzg48l8DNlxQpTvLgtdGYZmGS,iv:k84Nwxnuyc3D9ceXuMPhSTwzRrCucyUpJ/b7lrUTBlI=,tag:JOMAcJN9frgVkpeV8pm39A==,type:str] + secret_key: ENC[AES256_GCM,data:ZhH2xkHf/wLXiooCBP+9ZctsGSOMW1de4xz8IGPS0z1GSTReM9h7sSHhw1vJl7iHsr8ne4ehXQYvLd/QcvLVShS2WVDSt1wXcKejnHgRTBk=,iv:xFSp0TRzBl0c4hx6UPv2CdFx9fMQ/oAes8JpWf1Zj7U=,tag:j8jrfilCe2gfyQr1kls4WQ==,type:str] +sops: + age: + - recipient: age1s0gzgh8c00tgnkgxrqyz2nu0k56xvc9ev3jdenkmu90egux9xfmsxvvj43 + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBSdWlWdTRWOWhTNnFocWlI + QTlLNmJtUkZLSUNOdi9yQmNVQkVkQi94WFc4Cno2NjJCeURlc3FZd0hTK0IzUXJq + STZtU0pBZ25SaFJqMFZNaE41a1p0SkEKLS0tIFg2VlphajFlSjdtSFM1NTVZdjZm + SmVrN2pFQ0MzVS9aUUtrVnFUaUd6TzAKksOI8fvGkE8/Qmk8yT7jvvakFWSLDYKa + 7/pcTR0msB9P8zowsfe4pL+BJIObctXpyTRPCyyJg7/vOHqhuUv9Wg== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-04-19T23:31:55Z" + mac: ENC[AES256_GCM,data:sRk5KdZXohSwRWs6jP/jUA1OMBgnxKQu82Wp2w/4q1K0XXe1KRSDyiIIklgdK6JAW5U0291EzKLg8KgTks3YsibteA4L7eFbwFDTXOn7OJwjhKlzTZXqxEjmGHmsKLa7v8Y8nKuJqn2CUrfUuLzyvUaQN2tXfigq/DPC0HyROQs=,iv:zzWBrUcOumbaDArX9xRPzz3J98fA9wAgC1cKafejWT8=,tag:DL9o2cA5Uslq6WWGzlt8QA==,type:str] + unencrypted_suffix: _unencrypted + version: 3.12.2 diff --git a/manifest/overlays/production/storage/persistentvolume-nfs.yaml b/manifest/overlays/production/storage/persistentvolume-nfs.yaml new file mode 100644 index 0000000..ce88611 --- /dev/null +++ b/manifest/overlays/production/storage/persistentvolume-nfs.yaml @@ -0,0 +1,32 @@ +# Optional static PV example for NFS-backed storage. +# This file is not referenced from kustomization.yaml by default because the +# default storage flow uses dynamic provisioning via the Longhorn PVC. +# +# To use this file: +# 1. Add storage/persistentvolume-nfs.yaml to resources in this overlay. +# 2. Update persistentvolumeclaim.yaml to set: +# storageClassName: nfs-shared +# volumeName: app-data +# accessModes: [ReadWriteMany] +# 3. Replace the placeholder NFS server and export path below. +apiVersion: v1 +kind: PersistentVolume +metadata: + name: app-data +spec: + capacity: + storage: 10Gi + accessModes: + - ReadWriteMany + persistentVolumeReclaimPolicy: Retain + claimRef: + apiVersion: v1 + kind: PersistentVolumeClaim + name: app-data + namespace: security + storageClassName: nfs-shared + mountOptions: + - nfsvers=4.1 + nfs: + server: nfs.example.internal + path: /exports/app-data diff --git a/manifest/overlays/production/storage/persistentvolumeclaim.yaml b/manifest/overlays/production/storage/persistentvolumeclaim.yaml new file mode 100644 index 0000000..06845b6 --- /dev/null +++ b/manifest/overlays/production/storage/persistentvolumeclaim.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: authentik-data +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 1Gi diff --git a/scripts/ci/check-sops-sync b/scripts/ci/check-sops-sync new file mode 100755 index 0000000..96f4d62 --- /dev/null +++ b/scripts/ci/check-sops-sync @@ -0,0 +1,77 @@ +#!/usr/bin/env zsh + +set -eu + +for cmd in git sops openssl; do + if ! command -v "${cmd}" >/dev/null 2>&1; then + echo "check-sops-sync: required command missing: ${cmd}" >&2 + exit 1 + fi +done + +if [ -z "${SOPS_SYNC_HMAC_KEY:-}" ]; then + echo "check-sops-sync: SOPS_SYNC_HMAC_KEY is required" >&2 + exit 1 +fi + +repo_root=$(git rev-parse --show-toplevel) +regex_script="${repo_root}/scripts/lib/sops-path-regexes" +lib_script="${repo_root}/scripts/lib/sops-sync-lib" + +if [ ! -x "${regex_script}" ]; then + echo "check-sops-sync: missing helper ${regex_script}" >&2 + exit 1 +fi + +if [ ! -f "${lib_script}" ]; then + echo "check-sops-sync: missing helper ${lib_script}" >&2 + exit 1 +fi + +. "${lib_script}" + +regexes=("${(@f)$("${regex_script}")}") +fail=0 + +while IFS= read -r tracked_path; do + [ -n "${tracked_path}" ] || continue + [[ "${tracked_path}" == *.enc.* ]] || continue + + if ! matches_any_rule "${tracked_path}" "${regexes[@]}"; then + continue + fi + + sidecar_path=$(hmac_sidecar_for_enc "${tracked_path}") + + if ! git ls-files --error-unmatch -- "${sidecar_path}" >/dev/null 2>&1; then + echo "check-sops-sync: missing sync sidecar for ${tracked_path}" >&2 + fail=1 + continue + fi + + if ! is_sops_encrypted_file "${tracked_path}"; then + echo "check-sops-sync: file is not valid SOPS-encrypted content: ${tracked_path}" >&2 + fail=1 + continue + fi + + sidecar_value=$(read_sidecar "${sidecar_path}" || true) + if [ -z "${sidecar_value}" ]; then + echo "check-sops-sync: sidecar is empty: ${sidecar_path}" >&2 + fail=1 + continue + fi + + if ! computed_hmac=$(decrypt_enc_to_plain "${tracked_path}" | compute_hmac_for_stdin); then + echo "check-sops-sync: failed to decrypt ${tracked_path}" >&2 + fail=1 + continue + fi + + if [ "${computed_hmac}" != "${sidecar_value}" ]; then + echo "check-sops-sync: decrypted plaintext HMAC does not match sidecar for ${tracked_path}" >&2 + fail=1 + fi +done < <(git ls-files) + +exit "${fail}" diff --git a/scripts/git-hooks/pre-commit b/scripts/git-hooks/pre-commit new file mode 100755 index 0000000..a3af18e --- /dev/null +++ b/scripts/git-hooks/pre-commit @@ -0,0 +1,111 @@ +#!/usr/bin/env zsh + +set -eu + +for cmd in git sops openssl; do + if ! command -v "${cmd}" >/dev/null 2>&1; then + echo "pre-commit: required command missing: ${cmd}" >&2 + exit 1 + fi +done + +if [ -z "${SOPS_SYNC_HMAC_KEY:-}" ]; then + echo "pre-commit: SOPS_SYNC_HMAC_KEY is required" >&2 + exit 1 +fi + +repo_root=$(git rev-parse --show-toplevel) +regex_script="${repo_root}/scripts/lib/sops-path-regexes" +lib_script="${repo_root}/scripts/lib/sops-sync-lib" + +if [ ! -x "${regex_script}" ]; then + echo "pre-commit: missing helper ${regex_script}" >&2 + exit 1 +fi + +if [ ! -f "${lib_script}" ]; then + echo "pre-commit: missing helper ${lib_script}" >&2 + exit 1 +fi + +. "${lib_script}" + +regexes=("${(@f)$("${regex_script}")}") +typeset -A candidates + +while IFS= read -r staged_path; do + [ -n "${staged_path}" ] || continue + + candidate_enc="" + if [[ "${staged_path}" == *.enc.yaml ]]; then + candidate_enc="${staged_path}" + elif [[ "${staged_path}" == *.yaml ]]; then + if git ls-files --error-unmatch -- "${staged_path}" >/dev/null 2>&1; then + continue + fi + candidate_enc=$(plain_to_enc "${staged_path}") || continue + fi + + [ -n "${candidate_enc}" ] || continue + if matches_any_rule "${candidate_enc}" "${regexes[@]}"; then + candidates["${candidate_enc}"]=1 + fi +done < <(git diff --cached --name-only --diff-filter=ACMR) + +for enc_path in "${(@k)candidates}"; do + plain_path=$(enc_to_plain "${enc_path}") || continue + sidecar_path=$(hmac_sidecar_for_enc "${enc_path}") + + if [ ! -f "${plain_path}" ]; then + continue + fi + + if has_unstaged_changes "${plain_path}"; then + echo "pre-commit: plaintext file has unstaged changes: ${plain_path}" >&2 + echo "pre-commit: stage or revert the plaintext change before committing" >&2 + exit 1 + fi + + current_hmac=$(compute_hmac_for_file "${plain_path}") + tracked_hmac="" + if [ -f "${sidecar_path}" ]; then + tracked_hmac=$(read_sidecar "${sidecar_path}" || true) + fi + + if [ "${current_hmac}" = "${tracked_hmac}" ] && [ -f "${enc_path}" ]; then + continue + fi + + encrypt_plain_to_enc "${plain_path}" "${enc_path}" + write_sidecar "${sidecar_path}" "${current_hmac}" + git add "${enc_path}" "${sidecar_path}" +done + +for enc_path in "${(@k)candidates}"; do + sidecar_path=$(hmac_sidecar_for_enc "${enc_path}") + + if ! git cat-file -e ":${enc_path}" 2>/dev/null; then + echo "pre-commit: staged encrypted file missing: ${enc_path}" >&2 + exit 1 + fi + + if ! git cat-file -e ":${sidecar_path}" 2>/dev/null; then + echo "pre-commit: staged sync sidecar missing: ${sidecar_path}" >&2 + exit 1 + fi + + tmp_enc=$(mktemp) + trap 'rm -f "${tmp_enc}"' EXIT INT TERM + git show ":${enc_path}" > "${tmp_enc}" + + staged_hmac=$(git show ":${sidecar_path}" | tr -d '\r\n') + computed_hmac=$(decrypt_enc_to_plain "${tmp_enc}" | compute_hmac_for_stdin) + + rm -f "${tmp_enc}" + trap - EXIT INT TERM + + if [ "${computed_hmac}" != "${staged_hmac}" ]; then + echo "pre-commit: staged encrypted file and sidecar are out of sync: ${enc_path}" >&2 + exit 1 + fi +done diff --git a/scripts/install-git-hooks b/scripts/install-git-hooks new file mode 100755 index 0000000..43887bf --- /dev/null +++ b/scripts/install-git-hooks @@ -0,0 +1,16 @@ +#!/usr/bin/env zsh + +set -eu + +repo_root=$(git rev-parse --show-toplevel) +hook_path="${repo_root}/.git/hooks/pre-commit" +target="${repo_root}/scripts/git-hooks/pre-commit" + +mkdir -p "${repo_root}/.git/hooks" +cat > "${hook_path}" </dev/null || pwd) +sops_file="${repo_root}/.sops.yaml" + +if [ ! -f "${sops_file}" ]; then + exit 0 +fi + +if command -v yq >/dev/null 2>&1; then + yq -r '.creation_rules[]?.path_regex | select(. != null)' "${sops_file}" + exit 0 +fi + +if command -v python3 >/dev/null 2>&1; then + python3 - "${sops_file}" <<'PY' +import sys + +try: + import yaml +except Exception as exc: + raise SystemExit(f"python3 fallback requires PyYAML: {exc}") + +with open(sys.argv[1], "r", encoding="utf-8") as handle: + data = yaml.safe_load(handle) or {} + +for rule in data.get("creation_rules") or []: + regex = rule.get("path_regex") + if regex: + print(regex) +PY + exit 0 +fi + +echo "Unable to read .sops.yaml path_regex values: install yq or python3 with PyYAML" >&2 +exit 1 diff --git a/scripts/lib/sops-sync-lib b/scripts/lib/sops-sync-lib new file mode 100644 index 0000000..e0cafcd --- /dev/null +++ b/scripts/lib/sops-sync-lib @@ -0,0 +1,88 @@ +#!/usr/bin/env zsh + +matches_any_rule() { + local path="$1" + shift + local regex + for regex in "$@"; do + [[ -n "${regex}" ]] || continue + if [[ "${path}" =~ ${regex} ]]; then + return 0 + fi + done + return 1 +} + +enc_to_plain() { + local enc="$1" + [[ "${enc}" == *.enc.yaml ]] || return 1 + print -r -- "${enc%.enc.yaml}.yaml" +} + +plain_to_enc() { + local plain="$1" + [[ "${plain}" == *.yaml ]] || return 1 + if [[ "${plain}" == *.enc.yaml ]]; then + print -r -- "${plain}" + else + print -r -- "${plain%.yaml}.enc.yaml" + fi +} + +hmac_sidecar_for_enc() { + local enc="$1" + print -r -- "${enc}.sync-hmac" +} + +encrypt_plain_to_enc() { + local plain="$1" + local enc="$2" + sops --encrypt --input-type yaml --output-type yaml --output "${enc}" "${plain}" +} + +decrypt_enc_to_plain() { + local enc="$1" + sops --decrypt "${enc}" +} + +compute_hmac_for_file() { + local path="$1" + openssl dgst -sha256 -hmac "${SOPS_SYNC_HMAC_KEY}" "${path}" | awk '{print $NF}' +} + +compute_hmac_for_stdin() { + openssl dgst -sha256 -hmac "${SOPS_SYNC_HMAC_KEY}" | awk '{print $NF}' +} + +read_sidecar() { + local sidecar="$1" + [ -f "${sidecar}" ] || return 1 + tr -d '\r\n' < "${sidecar}" +} + +write_sidecar() { + local sidecar="$1" + local value="$2" + print -r -- "${value}" > "${sidecar}" +} + +is_sops_encrypted_file() { + local path="$1" + [ -f "${path}" ] || return 1 + grep -q 'sops:' "${path}" && grep -q 'ENC\[' "${path}" +} + +has_unstaged_changes() { + local path="$1" + if git ls-files --error-unmatch -- "${path}" >/dev/null 2>&1; then + git diff --quiet -- "${path}" >/dev/null 2>&1 + return $? + fi + + if git diff --cached --name-only -- "${path}" | grep -Fxq "${path}"; then + git diff --quiet -- "${path}" >/dev/null 2>&1 + return $? + fi + + return 1 +}