- Go 86.7%
- Makefile 6.2%
- Shell 5.4%
- Dockerfile 1.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| api/v1alpha1 | ||
| cmd | ||
| config | ||
| docs | ||
| hack | ||
| internal/controller | ||
| test | ||
| .gitattributes | ||
| .gitignore | ||
| BUGFIXES.md | ||
| CHANGELOG.md | ||
| ci-test.Dockerfile | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| operator.mk | ||
| PROJECT | ||
| QUICKSTART.md | ||
| README.md | ||
| setup.sh | ||
| VERSION | ||
kratos-identity-operator
A Kubernetes operator that manages Ory Kratos identities declaratively. You
describe a user (traits, group memberships, optional POSIX attributes, and the
Secret holding their password) as a KratosIdentity custom resource; the
operator reconciles that desired state against a Kratos instance over its
Admin API — creating, updating, and deleting identities to match the CR, and
re-pushing on password rotation. It is one of the seanfarm operator fleet
(alongside forgejo-, minio-resource-, and openbao-operators) and shares their
reconcile harness.
Custom resources
| Kind | API group | Purpose |
|---|---|---|
KratosIdentity |
identity.kratos.sean.farm/v1alpha1 |
A single Kratos identity. Holds the target Admin API URL, traits (username, email, display name, type, groups, optional POSIX block), the Kratos schema ID, and a reference to the Secret key carrying the password. Status carries the Kratos-assigned identity UUID, a Ready condition, and a phase mirror. |
KratosIdentityList is the list type; SecretKeyRef and PosixAttributes are
embedded spec structs — none are separate CRD kinds. The CRD enforces input
shape at the API server (e.g. email pattern, group/length bounds), and
kratosAdminURL is immutable via a CEL self == oldSelf rule and
runtime-bound to the Kratos Admin Service in the CR's namespace (see
Security).
Architecture
This operator is a thin policy shim over the shared reconcile harness in
libseanfarm-operator/reconcile. The harness owns the entire reconcile
lifecycle — finalizer mechanics, external-client resolution, mapping a handler's
convergence result onto the Ready condition, a reason→requeue taxonomy,
deletion-policy semantics, conflict-safe status writes, secret-watch mapping,
bounded/sanitized status messages, and uniform concurrency with exponential
backoff. The generic entrypoint is reconcile.Run[T, C]; this operator supplies
only the parts that are Kratos-specific.
Ready is structural. The harness — never the handler — sets Ready=true, and
only after Converge reports it actually verified the external state. The
handler reports an Outcome of Converged or Progressing; an error maps to
Degraded. The handler must return Progressing (not Converged) while the
remote is not yet observable, so Ready can never be asserted ahead of reality.
The two pieces of policy this operator provides:
-
Handler (
Converge+Delete). A direct-fit handler — no adapter needed.Convergereads the password Secret, then ensures the identity exists in Kratos and matches the spec (create if absent, otherwise update traits + password in a single PUT). It returnsConvergedonly after a read-back GET confirms the identity is observable — the structural fix for the old "Ready the instant create returned" bug. It also detects out-of-band drift (an identity deleted in Kratos) on the steady-state re-verify and re-provisions, and re-adopts an identity whose prior status write was lost (counted as an orphaned recovery) rather than creating a duplicate.Deleteremoves the identity from Kratos, recovering the ID by email if status lost it. Deletion uses hold until success: a transient Kratos failure retains the finalizer and retries, so deleting the CR cannot leave a live login credential behind with no Kubernetes object left to manage it.
-
Taxonomy. A small table classifying each failure reason.
InvalidSpecis terminal (a spec-level problem; the watch re-triggers on the next edit); every other converge error falls through to the default backoff disposition and returns to controller-runtime for exponential retry (the lib's shared rate limiter).
Notable wiring:
- External client.
Resolvebuilds a token-lesskratosAdminClient— a shared*http.Clientplus the namespace-boundspec.kratosAdminURL. All Kratos API-call metrics live on this client so they fire regardless of caller. ObservedGenerationis bumped only onConverged(theObservedGenOnConvergedOnlypolicy). A not-yet-converged generation stays "unobserved" so the handler's generation-skip fast path can't short-circuit the Progressing loop before the remote is verified.- Secret-rotation fast path. The generation-skip fast path is additionally
guarded by
status.observedSecretResourceVersion. A password rotation does not change the CR generation, so without this guard a rotated password would never reach Kratos; recording the Secret'sresourceVersionmakes a Secret-only change fall through to the PUT, while still doing a cheap verify-GET. - Secret watch. Delegated to the lib's
MapSecretToRequests; the only Kratos-specific part is the per-type ref accessor. A Secret create/update enqueues everyKratosIdentityin that namespace referencing it, so a reconcile that ran before the Secret existed retries immediately instead of waiting out a requeue interval. - Concurrency + backoff. This is the canonical adopter of the lib's uniform
concurrency/backoff knob (
ControllerTuning).MaxConcurrentReconcilesis kept modest on purpose — there is a single shared Kratos Admin API behind every CR — while the exponential backoff schedule is the shared lib default. - Status mirrors + metrics. Via the harness
MirrorStatus/OnStatusWritehooks, the legacystatus.readybool andstatus.phasestring are kept as harness-maintained mirrors of the Conditions (the Conditions are the source of truth), and the same hooks drive the Prometheus phase gauge and reconcile/ status-update metrics.
Security
Condition reasons/messages and Events land on world-readable status surfaces (relative to Secrets) and are persisted to etcd. This operator follows the seanfarm credential-disclosure contract:
- Bounded messages. Condition/Event messages are capped (the lib bounds every
message it writes to a fixed byte limit, on a UTF-8 rune boundary); this
operator additionally caps any non-2xx Kratos response body before it can be
interpolated into an error string. The cap is an availability backstop (etcd
object-size limits) and a disclosure backstop — it bounds, it does not by
itself sanitize. The lib also offers a
SanitizingRecorderfor Event sites; the real protection is generic messages at credential-bearing sites. - Name the Secret, never quote it. A missing-password path emits
SecretNotFoundnaming the Secret — it never echoes the password, and no plaintext password ever reaches status, Events, or logs. - No password material on status.
status.observedSecretResourceVersionis deliberately the Secret's opaqueresourceVersionetag, not a hash of the password. A password hash on a world-readable status would be offline-attackable material; an etag detects rotation without disclosing anything. - Namespace-bound Admin API target. The controller accepts only
http://kratos-admin.<resource-namespace>.svc.cluster.local:4434; it rejects arbitrary and cross-namespace targets asInvalidSpec. The field is also immutable (CELself == oldSelf). The Kratos Admin API is unauthenticated, so these checks prevent a CR author from redirecting the operator—and the password it submits—to an attacker endpoint. - Least-privilege RBAC on Secrets. The operator's Secret access is
read-only (
get;list;watch); it never writes or mutates Secrets.
Build & distribution
- Images are built in-cluster. Operator container images are produced by
Shipwright (buildkit strategy) inside the seanfarm cluster and
cosign-signed — never built on a developer laptop. The scaffolded
docker-build/docker-buildxMakefile targets exist but are not the shipping path. - Forgejo is canonical; codeberg is the backup. Day-to-day pushes go to
Forgejo (
code.sean.farm). AForgejoPushMirrorbacks each repo up tocodeberg.org/someara/<repo>on a short interval, andmake rebuildre-seeds Forgejo from codeberg. Go module fetches resolvecodeberg.org/someara/...through the default GOPROXY. - CI runs in-cluster. Tests, builds, and image bakes run via Argo Workflows / Tekton + Shipwright inside the cluster — not via Actions-style CI pipelines.
Development
-
Red/green TDD. Behavior is driven by tests first; the controller package carries envtest/Ginkgo suites plus focused regression tests for the harness adoption, the generation-skip fast path, password rotation, and secret-watch.
-
Standard targets (from the Makefile):
Target What it does make manifestsRegenerate CRDs + RBAC from kubebuilder markers make generateRegenerate DeepCopymethodsmake fmt/make vetgo fmt/go vetmake testRun the unit + envtest suite with coverage make test-e2eRun e2e tests against a cluster make lint/make lint-fixgolangci-lint (with --fixfor the latter)make build/make runBuild the manager binary / run it against your kubeconfig make install/make uninstallApply / remove the CRDs make deploy/make undeployApply / remove the controller manifests -
How a change ships. Edit → test → push to Forgejo → an in-cluster Shipwright
Build/BuildRunproduces a cosign-signed image → the deployment is rolled by bumping the image pin in the Flux/Crossplane composition that references this operator (in the platform repos, not here). Module/version references to the shared library are managed the same way.
License
Apache-2.0 (per the source-file headers).