weebo-authentik

Migrate from Terraform

Adopt the Authentik objects an existing Terraform module manages into weebo-authentik CRDs, without recreating or disrupting them.

This operator was built to replace a hand-maintained Terraform module. The crates/importer tool reads your live Authentik instance and emits ready-to-adopt CR YAML, so the objects Terraform created keep their identity and are taken over in place — never destroyed and recreated.

How adoption works (read this first)

The reconcilers never adopt an existing Authentik object by matching on its name. A CR takes over a pre-existing object in exactly one way: its status.authentikId is already set to that object's Authentik primary key. The importer pre-populates status.authentikId on every CR it generates — that is the whole point of the tool.

kubectl apply -f silently drops status. Kubernetes strips the status subresource on create/update, so applying the generated files is only half the job. Let the importer do it (--apply, step 4): each CR is created paused (authentik.weebo.io/import-pending), its status.authentikId seeded, then released to the operator. If you apply the files yourself instead, patch each object's status subresource separately. If you skip it, the first reconcile sees no authentikId, tries attempt-create-first, and collides with the very object you meant to adopt (a AuthentikObjectAlreadyExists error). The collision is non-destructive — attempt-create-first never overwrites or deletes the existing object — but the CR stays Errored until you supply the id.

Prerequisites

  • The operator is installed and healthy (Release to your cluster) and an AuthentikInstance is connected (Connect an AuthentikInstance). The importer does not create the AuthentikInstance — it's user-authored — but its generated AuthentikApplications reference one by name via --instance-ref.
  • A read-access Authentik API token and network reachability to the Authentik API from wherever you run the importer.
  • A Rust toolchain to run the importer. It is a one-shot migration tool and is not shipped in the operator image — run it from a checkout with cargo run -p importer (this repo uses mise to provide the toolchain; mise install then the command below).
  • kubectl 1.24+ (for --subresource=status) and yq for the status-patch loop.

Migrate namespace by namespace

AuthentikApplication/AuthentikAccessPolicy are namespaced, so migration is done one target namespace at a time (matching the Terraform module's per-team layout). Cluster-scoped kinds (AuthentikGroup/AuthentikUser/AuthentikBrand/AuthentikOutpost) are regenerated on every run regardless of --namespace — import them once and reuse.

1. Run the importer

AUTHENTIK_TOKEN=<read-token> cargo run -p importer -- \
  --authentik-url https://login.example.com \
  --instance-ref main \
  --namespace team-a \
  --out import-output/team-a
FlagMeaning
--authentik-urlBase web URL of the Authentik instance to read from — same shape as AuthentikInstance.spec.url; /api/v3 is appended for you.
--authentik-tokenAPI token (or the AUTHENTIK_TOKEN env var, as above).
--instance-refName of the AuthentikInstance CR generated AuthentikApplications should point at (spec.instanceRef).
--namespaceNamespace stamped on every generated AuthentikApplication/AuthentikAccessPolicy.
--applicationsOptional comma-separated slug allow-list; defaults to every application.
--outOutput directory — one YAML file per generated CR (default import-output).

The tool writes one <kind>-<name>.yaml per object and prints a reminder about the status-patch step below.

2. Review the generated CRs

Skim the output before applying anything. A few expected behaviors:

  • metadata.name is slugified to a valid RFC 1123 name (lowercased, non-alphanumerics collapsed to -), while spec.name / spec.username / domains keep the exact original value. Names that slugify to the same string will collide — rename in that case.
  • Every file carries a status.authentikId. That is intentional (it's what step 4 applies).
  • AuthentikBrand.spec.defaultApplicationRef is deliberately left unset — that cross-namespace reference isn't resolved by the operator yet. Wire it by hand later if you relied on it.

3. Clear the admission webhook for the target namespace

The webhook enforces a namespace allow-list for AuthentikApplication/AuthentikAccessPolicy, defaulting to deny once any AuthentikNamespacePolicy exists in the cluster. Two cases:

  • No AuthentikNamespacePolicy anywhere yet → everything is allowed; you can apply the imported Applications directly. (Add policies after migration; see the allow-list guide.)

  • Policies already exist → the target namespace must be allow-listed first, or the webhook will reject the create:

    apiVersion: authentik.weebo.io/v1alpha1
    kind: AuthentikNamespacePolicy # cluster-scoped — no metadata.namespace
    metadata:
      name: allow-team-a
    spec:
      rules:
        - namespaces: ["team-a"]
          allowedKinds: ["AuthentikApplication", "AuthentikAccessPolicy"]
          effect: Allow

    See the allow-list guide for the full rule semantics (deny-wins, default-deny).

Cluster-scoped kinds (Group/User/Brand/Outpost/Instance) are not gated by the webhook, so they can be applied at any time.

4. Adopt the CRs

Re-run the importer with --apply (same arguments, current kube context): it writes the files again, then adopts each CR — groups first, namespaced kinds last — creating it paused (authentik.weebo.io/import-pending, the controllers leave it alone), seeding its status.authentikId, and only then releasing it. The operator therefore never sees a CR without the id of the object it stands for: no AuthentikObjectAlreadyExists, no duplicate.

importer --authentik-url https://login.example.com --instance-ref main \
  --namespace team-a --out import-output/team-a --apply
# Wrote CRDs to import-output/team-a and adopted them: 42 adopted, 0 already bound, 0 failed.

It is safe to re-run: a CR already bound to its object is skipped, one left paused by an interrupted run is finished. A CR that already exists in the cluster but is bound to nothing yet is refused — the operator may be creating its object right now; delete it, or pass --force to take it over.

Without --apply, kubectl apply -f the files, then re-attach each id with kubectl patch <kind> <name> --subresource=status --type merge -p '{"status":{"authentikId":"<id>"}}' before the first reconcile — a reconcile in between reports a harmless, non-destructive AuthentikObjectAlreadyExists until the id is there.

5. Verify adoption

# Every managed object should reach Ready=True and carry the id you patched:
kubectl get authentikgroups,authentikusers,authentikbrands,authentikoutposts -A \
  -o custom-columns=KIND:.kind,NAME:.metadata.name,ID:.status.authentikId,READY:.status.conditions[0].status
kubectl -n team-a get authentikapplications,authentikaccesspolicies \
  -o custom-columns=NAME:.metadata.name,ID:.status.authentikId,READY:.status.conditions[0].status

Confirm each object is Ready=True with the expected authentikId, and spot-check in the Authentik admin UI that nothing was duplicated or recreated. Repeat steps 1-5 for the next namespace.

Decommission Terraform (do not destroy)

Once the operator is confirmed authoritative and at parity, stop Terraform from managing those objects without deleting them:

Never run terraform destroy (or terraform apply after deleting the resources from config) against the objects you just adopted — that would delete the real Authentik objects the CRs now own. Use terraform state rm so Terraform forgets them instead.

  1. Freeze Terraform for the migrated scope while you run the import and verification (pause its CI/CD apply), so the two systems can't fight over the same objects mid-migration.

  2. For each adopted object, remove it from Terraform state:

    terraform state rm 'authentik_group.team_a'
    terraform state rm 'authentik_application.dashboard'
    # ...one per adopted resource
  3. Delete the corresponding resource blocks from the .tf files and run terraform plan — it should show no changes (Terraform no longer knows about them, the operator owns them now).

  4. Once every namespace is migrated and terraform plan is clean, retire the module.

Caveats and limitations

  • Importer is source-only. It isn't in the operator image; run it via cargo run -p importer from a checkout.
  • Cluster-scoped kinds regenerate each run. Group/User/Brand/Outpost are emitted on every invocation regardless of --namespace — import them once; re-running is idempotent as long as you don't re-apply stale copies.
  • defaultApplicationRef on brands is not resolved by the operator yet, so the importer leaves it unset.
  • Single instance for cluster-scoped kinds. Group/User/Brand/Outpost resolve against the one AuthentikInstance in the cluster and error (AmbiguousDefaultInstance) if there's more than one — see Connect an AuthentikInstance → Multiple instances.
  • Rollback. Nothing here deletes Authentik objects, so a botched import is recoverable: fix the CRs (or kubectl delete them before their finalizers matter — while Terraform still owns the objects), and re-run. Keep Terraform as the source of truth until step 5 passes.

Known migration blockers

Three gaps surfaced while planning the rollout. The migration order harbor → s3 → argo → che → vault is chosen deliberately around them: harbor and s3 have no in-repo consumer of AUTHENTIK_URL, so they prove the Vault plumbing end-to-end with zero consumer changes before any of the below matter.

1. Per-app AUTHENTIK_URL issuer — fixed

Previously the operator wrote AUTHENTIK_URL as the instance base URL (configuration.base_path), not the per-application OIDC issuer. Only two consumers read that key — argocd's oidc.config issuer (auth-secret.yaml) and 1.pulu-init/Taskfile.yaml's OIDC_ISSUER_URL — so the order above sequences them (argo, che) after the consumer-free apps.

The operator now writes the real per-app issuer <web-base>/application/o/<application-slug>/. If you adopted apps under an earlier build, re-reconcile them (any spec touch) so the corrected AUTHENTIK_URL is rewritten to their secret targets before argo/che cut over.

2. Vault Kubernetes-auth role must bind the operator's SA — open (external)

The operator's ServiceAccount is authentik-weebo-authentik, but the auth Vault role only binds ["authentik", "default"]. Until the role also lists the operator SA, its Kubernetes-auth login is rejected. This lives in a different module and nothing needs it until the vault phase, so it is documented here, not applied. The exact hunk in 2.terra/vault/terra-map/auth-base.tf:

 resource "vault_kubernetes_auth_backend_role" "authentik" {
   # ...
   bound_service_account_names = [
     "authentik",
     "default",
+    "authentik-weebo-authentik",
   ]
 }

3. Selecting a Vault CA cert — fixed

The operator's Vault client was built from the address alone (no caProvider, no skip-verify), and the chart exposes extraEnv but no extraVolumes, so there was no clean way to point $VAULT_CACERT at openbao-tls. Rather than depend on podAnnotations + bank-vaults inject-certs (a route that was never confirmed — verify before relying on it), the operator now reads the CA PEM directly from a Secret via secretStore.vault.caSecretRef — see Connect an AuthentikInstance → Trusting a private Vault CA. No mounted volume or VAULT_CACERT wiring required.

On this page