myceliam

A Kubernetes-native identity federation control plane: it connects workload identities — from sources such as SPIFFE and OIDC — to cloud IAM in environments such as AWS and GCP, and declaratively keeps that trust in sync.

early-stage — exercised end-to-end against real Keycloak, Auth0, SPIRE, AWS, and GCP, not yet hardened for production

Watch the intro


Why "myceliam"?

myceliam serves as an integration layer between identity providers and cloud providers.

As a Kubernetes-native solution, myceliam takes a unique, Kubernetes-centric approach to identity federation. It understands and preserves the logical boundaries defined by Kubernetes namespaces and ServiceAccounts, and extends those boundaries into external identity and cloud platforms.

Within an identity provider, myceliam maps Kubernetes namespaces to realms and ServiceAccounts to clients within those realms. At the cloud-provider layer, it translates these same Kubernetes identity constructs into identity/workload identity providers and trusted identity relationships.

In this way, myceliam continuously reconciles Kubernetes-native identity constructs with their corresponding representations across identity and cloud providers, keeping the entire federation model aligned.

Named after mycelium — the underground network of fungal threads that connects otherwise-separate organisms and exchanges nutrients between them, the root network beneath the visible mushroom. That's the role this operator plays. Kubernetes, an identity provider, and a cloud account each already know how to trust something — Kubernetes trusts its own ServiceAccount tokens, Keycloak or SPIRE issue JWTs they'll vouch for, AWS and GCP accept federated tokens from an IdP they trust — but nothing connects those three trust relationships into one chain by default. A ServiceAccount in namespace foo and an IAM role in some AWS account have no idea the other exists until something wires them together. myceliam is that wiring: label a ServiceAccount, and its Kubernetes identity extends outward, unbroken, as far as an AwsAccessProfile/GcpAccessProfile says it should reach.

The conventional alternative is a long-lived credential sitting in a Secret — an AWS access key, a GCP service-account JSON key — minted somewhere, distributed somehow, rotated by someone, and a liability at every one of those steps. AWS and GCP have both had a federate-instead-of-mint answer to this for years (AssumeRoleWithWebIdentity, Workload Identity Federation), but using it by hand means standing up an OIDC provider per identity source, keeping its JWKS in sync, and writing IAM trust conditions per workload — enough friction that the static-key path usually wins by default, not because it's actually better. myceliam exists to make the federated path the easy one, kept in sync on every reconcile, so there's no longer a reason to reach for a static key at all.


The short version

The core idea

myceliam turns Kubernetes Namespaces and ServiceAccounts into the source of truth for identity — everywhere that identity needs to reach.

Kubernetes already knows who your workloads are. myceliam is what makes the rest of the world agree.

One label on a ServiceAccount, and its identity extends outward into every cloud it's trusted to reach.

The Argo CD analogy

Argo CD reconciles Git into Kubernetes. myceliam reconciles Kubernetes into the cloud.

If Argo CD is GitOps for your cluster, myceliam is GitOps for your workload identity.

Same idea as GitOps, opposite direction: instead of pulling desired state in, myceliam pushes it out.

The networking analogy

A TCP handshake opens a connection between two hosts. myceliam opens one between a ServiceAccount and the cloud.

Delete the ServiceAccount, or drop its labels, and myceliam tears that connection down — no lingering trust, same as a closed socket.

It only works end to end, like QoS or MTU on a network — one hop that doesn't honor the setting, and the whole path breaks no matter how correct everything else is.

The boundary

myceliam builds the bridge of trust — what an identity can do once it crosses is still up to your cloud's own IAM.

Registration, not authorization: myceliam earns the trust, your IAM policies still decide what happens next.


Design principles

Every cluster is independently addressable

A cluster ID is folded into every realm name and GCP provider ID the operator creates, and stamped as an ownership attribute on every Keycloak realm/client it touches. Two clusters sharing the same Keycloak/AWS/GCP backends can have identically named namespaces without colliding — and if one cluster ever finds a realm stamped with a different cluster's ID, it refuses to touch it rather than silently adopting it.

Isolation that actually carries through

Each namespace gets its own Keycloak realm (or SPIFFE ID hierarchy) and its own per-namespace, per-cloud federation audiences. A workload in one namespace can't present a token another namespace's cloud trust would accept.

Audiences are only as shared as they're safe to be

On AWS, every ServiceAccount gets its own audience — sharing one across a namespace's SAs would let any SA's token satisfy every other SA's trust condition. On GCP the audience only needs to be namespace-shared, since GCP's real identity narrowing comes from google.subject bindings, not aud — so a shared audience there costs nothing. Auth0's own model currently shares one Application/audience per namespace and leans on a custom claim instead — which holds for GCP's attribute conditions directly, but needs one more step on AWS, since AWS trust policies can't see custom claims directly (see Known limitations). The fix: the same Action also maps the claim into an AWS session tag instead of a raw trust-policy condition — confirmed working live, and implemented today.

No static cloud credentials

AWS temporary credentials and GCP access tokens are obtained via federation at request time, by the workload itself — never stored as a Secret with a static key, and never proxied through the operator.

Declarative, Kubernetes-native

ServiceAccount labels plus namespace-scoped CRDs are the entire interface — no manual setup in the Keycloak admin console or a cloud IAM console, beyond each cloud's one-time bootstrap trust for the operator's own identity.

Registration and trust, not authorization

myceliam establishes trust — realms/clients, IAM OIDC providers, Workload Identity providers — and, AWS only, creates a referenced IAM role outright if it doesn't exist yet (trust policy only, no permissions attached). It never creates a GCP service account, and never attaches or implies the policy binding that grants actual permissions once federated on either cloud. That stays with whoever owns the account/project.

Put another way: Kubernetes isn't just where the operator happens to run — a CRD is the desired-state declaration, and the reconcile loop is the whole mechanism, not a one-time setup script.

Kubernetes CRD
      ↓
   myceliam
      ↓
Federation reconciliation
      ↓
   Cloud IAM

Add a target to an AwsAccessProfile/GcpAccessProfile, and myceliam creates the trust relationship. Remove it, and myceliam removes it. If something drifts — a trust policy edited by hand, a secret rotated directly in Keycloak — the next reconcile puts it back. There's no separate "run this once" step to remember.

How it works

Label a ServiceAccount with myceliam.io/autoidp=true plus a myceliam.io/client-type label, and the operator provisions an identity for it. A SPIFFE-identified workload skips the client-type label entirely — its identity already comes from SPIRE (see below).

secret

Creates a client (Keycloak, Okta, or Auth0 — see below) authenticated with a client secret. The client ID, secret, and issuer are written to a Secret named <sa-name>-secret-<provider>-credentials.

signedjwt

Configures the client for private_key_jwt auth instead, using a JWKS derived from a cert in a paired Secret — either one you provide (e.g. via cert-manager) or one the operator generates for you if it doesn't exist yet. Editing the cert later is picked up and re-uploaded automatically. client_id (and, for Auth0, its own assigned kid) live in a separate <sa-name>-jwt-<provider>-metadata Secret, so the credentials Secret itself stays a pure kubernetes.io/tls object.

spiffe

No client-type label, no realm/tenant/client/Secret at all — just the spiffe-jwt-issuer/spiffe-id annotations. The workload gets its identity from SPIRE directly; myceliam only registers the cloud-side trust.

The operator can run multiple identity providers active at once — Keycloak, Okta, and Auth0 are each active whenever their own config env vars are present, not a single global choice. A ServiceAccount picks which one via myceliam.io/oidc-provider — optional when exactly one provider is active (inferred automatically), required whenever more than one is.

The operator re-reconciles every labeled ServiceAccount on a timer (default 300s) to catch drift made directly against the provider or a cloud console, and deleting the ServiceAccount deletes its client — the realm/tenant itself stays, since other ServiceAccounts in the namespace may still use it.

SPIFFE-based clients

A SPIFFE-identified workload gets a JWT-SVID from its local SPIRE agent over the SPIFFE Workload API, entirely outside myceliam's involvement. myceliam's only role is registering the AWS IdP / GCP Workload Identity provider needed to accept that token — a SPIFFE-identified ServiceAccount with no cloud-access label reconciles to a no-op.

Here's a real, easy-to-miss gotcha this project ran into directly: AWS rejects a JWT outright when its aud claim has more than one value and no azp claim disambiguates it. Keycloak always stamps azp on every token it issues, so a Keycloak-backed workload can request every audience it needs in one combined token. SPIFFE JWT-SVIDs never carry azp at all — so a SPIFFE-identified workload that needs both AWS and GCP has to fetch two separate, single-audience JWT-SVIDs, one per cloud, rather than one combined token. GCP alone would have tolerated a combined SVID; AWS won't, and there's no way around it from the client side — only around it, by fetching twice.

Cloud IdP federation

A ServiceAccount opts into federation via myceliam.io/aws-access-profile / myceliam.io/gcp-access-profile labels, each naming an AwsAccessProfile / GcpAccessProfile custom resource that lists target accounts/projects and the roles or service accounts within them a namespace's ServiceAccounts may federate into:

apiVersion: myceliam.io/v1
kind: AwsAccessProfile
metadata:
  namespace: demo
  name: awsap-demo
accounts:
  "123456789012":
    - my-app-role

There's no per-SA role selection within a profile — every ServiceAccount referencing a given profile gets identical access to everything it lists; split into multiple profiles for finer granularity.

For each (profile, ServiceAccount) pair, the operator grants access to an audience-scoped resource — the exact mechanism depends on the workload's oidc-provider. Keycloak gets a per-(cloud, SA) client scope carrying an Audience mapper: a unique aws-<namespace>-<sa> value per ServiceAccount for AWS, a namespace-shared gcp-<namespace> value for GCP. Okta/Auth0 instead share one Authorization Server / Resource Server (+ Client Grant) per (cloud, cluster, namespace) — <cloud>-<cluster-id>-<namespace> — since neither has Keycloak's cheap per-client-scope primitive; an Access Policy or set of Client Grants, not the audience itself, is what scopes access per SA there. Either way, the operator registers the namespace's realm/tenant (or, for SPIFFE ServiceAccounts, the SA's own SPIFFE issuer) as an IAM OIDC provider / Workload Identity provider in every listed account/project, with a separate GCP Workload Identity provider per (cloud kind, oidc-provider) — a Keycloak-backed and an Auth0-backed workload in the same namespace never share one provider or issuer — and grants trust on every role/service-account listed.

Each listed role/service-account is expected to already exist — except on AWS, where a listed role that doesn't exist yet is created outright, with the web-identity trust statement as its entire initial trust policy and no permissions attached. This is AWS-only: GCP service accounts are never created by myceliam, only granted impersonation on ones a project owner already provisioned — GCP has no equivalent to AWS's cheap "create a role that can trust something but do nothing yet."

Tearing a ServiceAccount down always removes its own scope/grant and trust grant immediately; the shared IdP/provider resource itself only goes away once no sibling ServiceAccount in the namespace still references the same profile — except for Auth0's two namespace-scoped Resource Servers, which also get force-deleted outright the moment the namespace itself is torn down, regardless of any individual SA's own cleanup history. Unlike Keycloak's realm (one object whose deletion cascades everything under it), Auth0 has no such container, so relying solely on each SA noticing "I'm the last reference" could otherwise leave a Resource Server orphaned forever if that per-SA cleanup ever didn't run to completion — confirmed live.

This is registration and trust, not authorization — the IAM/GCP policy binding that grants actual permissions is left to account/project owners either way.

The operator's own identity

Everything above needs the operator itself to call an Admin/Management API and get its own AWS/GCP credentials to register IdPs/providers in the first place. Which provider backs the operator's own identity (MYCELIAM_OPERATOR_PROVIDER: keycloak, auth0, or spiffe — Okta can't back it yet) is fully decoupled from which provider(s) are active for workloads above; a Keycloak-only-for-workloads deployment can still bootstrap its own cloud credentials from SPIRE, or vice versa. When Keycloak backs the operator, everything traces back to one Keycloak client, distinct from any client it creates on behalf of a workload. Unlike the keypairs it generates for signedjwt workloads, the operator never generates a credential for itself — it only reads whichever of two well-known Secrets an admin has already placed in its own namespace. If both exist, the keypair wins.

Option A

Client secret

A plain Opaque Secret holding client-secret. The simplest path, and the one every deployment starts from.

Option B

Signed-JWT keypair

A plain kubernetes.io/tls Secret — tls.crt/ tls.key only, nothing else, so it stays compatible with cert-manager or any other standard TLS rotation tooling. Requires the admin to have already uploaded that exact certificate to the client's Credentials tab in Keycloak (single-certificate "Signed Jwt" mode — no kid needed: confirmed empirically that including one breaks it with "Unable to load public key", since Keycloak's single-certificate mode does its own key lookup with nothing to disambiguate).

Advanced: bootstrap the operator's own cloud credentials from SPIRE instead

MYCELIAM_OPERATOR_PROVIDER=spiffe lets the operator fetch its own JWT-SVID for AWS/GCP federation instead of using Keycloak for that part — independent of how it still talks to Keycloak's Admin API for workload provisioning. Two things have to exist first, and neither is something the operator can set up for itself (same chicken-and-egg reasoning as the signed-JWT credential above):

  • A separate AWS OIDC IdP + IAM role trust update (and/or GCP WLI provider), trusting your SPIRE deployment's issuer for the operator's own bootstrap identity specifically — distinct from whatever trust already exists for individual namespace/SA federation.
  • A SPIRE registration entry for the operator's own pod — without it, SPIRE has nothing to issue, and the fetch fails with PermissionDenied: no identity issued.
Advanced: use Auth0 for the operator's own identity instead of Keycloak

When Auth0 is active (MYCELIAM_AUTH0_DOMAIN set), the same myceliam-auth0-secret/myceliam-auth0-keypair credential plays both roles, same as Keycloak's: it calls the Management API to provision workload Applications/Resource Servers/Client Grants/Actions, and — unlike Okta — can also be exchanged for the operator's own AWS/GCP credentials. These are two separate audiences on the same Auth0 Application, not two credentials: Management API calls always target Auth0's fixed, reserved https://{domain}/api/v2/ audience, while the operator's own cloud-federation token targets MYCELIAM_AUTH0_OPERATOR_AUDIENCE — a Resource Server the operator provisions for itself the first time it's needed. Nothing to pre-create in the Auth0 dashboard: just pick a unique identifier string and configure that same value on whatever pre-existing AWS IAM OIDC IdP / GCP WLI provider is meant to trust the operator's own bootstrap identity.

One accepted tradeoff worth knowing: since AWS keys its AssumeRoleWithWebIdentity check off azp (the Auth0 Application's client_id) rather than aud, and azp is the same for every token this one Application mints regardless of which audience was requested, its Management API token is just as valid for AssumeRoleWithWebIdentity as its cloud-federation token — the two credentials aren't independently revocable. Only the operator pod ever holds this credential, and compromising it already means full Auth0 tenant admin access regardless of AWS, so the added blast radius is bounded rather than new.

Known limitations


Where this is headed

Everything above describes what's implemented today: four identity sources in (Keycloak, Okta, Auth0, SPIRE), two clouds out (AWS, GCP), with access expressed entirely through AwsAccessProfile/GcpAccessProfile. The point of the project is broader than either pairing, though: myceliam is meant to be an orchestrator — a plug-in point where identity providers on one side and cloud providers on the other can each grow independently, without either side having to know about the other. myceliam doesn't issue or replace a workload's identity, and that's staying fixed — it consumes an identity that already exists and declaratively federates it outward, wherever that identity needs to reach.

Authorization itself is deliberately not myceliam's problem to solve — each cloud already has a mature, native answer to "what is this identity allowed to do": an IAM role and its attached policies on AWS, a service account and its granted IAM roles on GCP. myceliam's job stops at establishing trust with those pre-existing resources — what an identity can actually do once federated is entirely up to whatever policy is already attached on the cloud side. That's also why a pluggable authorization-engine layer (OPA, OpenFGA, Cedar) isn't on this roadmap: native cloud IAM already is that layer.

The relationship a workload has with the cloud side is already many-to-many in the small — one ServiceAccount can federate into several AWS accounts and several GCP projects at once, and several ServiceAccounts can share the same target. The direction is to widen that same graph on both axes, not replace it.

SPIFFE JWT-SVID
       │
       ▼
  payment-api
       │
       ├──── AWS Account A
       ├──── AWS Account B
       ├──── GCP Project X
       ├──── Azure Tenant Y
       └──── OCI Compartment Z

Identity providers already share one interface (oidc.Provider) that Keycloak, Okta, and Auth0 all implement — adding a new one is writing a new implementation, not restructuring what's there. Cloud providers are parallel packages today rather than a currently-unified interface; tightening that into the same kind of pluggable shape as the identity side is part of the work ahead of adding a third.

Cloud providers

Azure and Oracle Cloud Infrastructure (OCI), alongside AWS/GCP already implemented.

Identity providers

Widening Okta beyond Phase 1's cloud-federation-only scope, alongside Keycloak/SPIFFE/Okta/Auth0 already implemented.

Neither list changes the shape of what's here already — access-profile CRDs stay the interface, ServiceAccount labels stay how a workload opts in, and the reconcile loop stays the mechanism. Growing either list is additive, not a redesign.