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.
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
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.
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.
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.
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).
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.
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.
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.
Client secret
A plain Opaque Secret holding client-secret. The simplest path,
and the one every deployment starts from.
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
- Cloud federation is registration and trust, not authorization — myceliam never creates the policy binding that grants actual permissions. On AWS it will create a referenced IAM Role outright if it's missing (trust policy only); on GCP it never creates the target service account, only grants impersonation on one that already exists.
- AWS trust-policy documents have a hard 2048-character cap that AWS itself won't raise. A role referenced by many namespaces/ServiceAccounts over time can accumulate enough statements to hit it — nothing in myceliam prunes stale entries automatically yet.
- No proactive, expiry-based rotation for operator-generated
signedjwtcerts — rotation only happens if you (or a tool like cert-manager) edit the cert yourself, which the operator then picks up on its next reconcile. - Okta support (activated via
MYCELIAM_OKTA_ORG_URL) is Phase 1: cloud-federation ServiceAccounts only, and it can't back the operator's own identity (MYCELIAM_OPERATOR_PROVIDER=oktaisn't supported — usekeycloak,auth0, orspiffeinstead). An Okta-backed client must also carry exactly one ofaws-access-profile/gcp-access-profile— unlike Keycloak, Okta has no cloud-agnostic issuer to hand a plain client with zero or both cloud profiles. - A multi-value
audclaim needs anazpclaim or AWS rejects the token outright — an AWS behavior, not a myceliam bug, but it's exactly why SPIFFE-identified workloads fetch two tokens instead of one (see SPIFFE above). More precisely: AWS checksazpagainst the IAM OIDC provider's allowed-client list wheneverazpis present, and never looks ataudat all in that case — it only falls back toaud(and enforces the single-value restriction) whenazpis absent, as with SPIFFE JWT-SVIDs. The sameazp-over-audsubstitution also applies inside a role's own trust-policy condition — its:audpolicy-context key resolves toazp's value whenever the token carries one, confirmed live to happen unconditionally, not only for the multi-valued-audcase above (a clean single-string-audAuth0 token still hadazpsubstituted in). Auth0'sazpis its own dynamically-assigned client_id — unrelated to both the configured audience and tomyceliam_service_account— so myceliam's trust-policy writer matches against both possible values rather than assuming either shape upfront, while still separately requiring the identity claim (myceliam_service_account, orazp/cidfor Keycloak/Okta) to match too — this only widens which literal value:audis allowed to be, it doesn't weaken who's allowed to assume the role. - AWS cannot use custom, dynamically-injected claims directly in IAM
trust-policy conditions — only native OIDC claims (
iss/sub/aud/azp) are usable there, confirmed empirically against a real Auth0-issued token whose custommyceliam_service_accountclaim (added by a tenant-wide Auth0 Action) a matching trust-policy condition never matched, even thoughsub/audconditions against the very same token worked fine. GCP's Workload Identity Federation has no such restriction — anassertion.myceliam_service_accountattribute condition works there today with no extra steps. myceliam works around this automatically today via AWS's session-tags-via-AssumeRoleWithWebIdentityfeature (confirmed live end to end, including an explicitDenykeyed on the tag actually blocking an otherwise broadly-permitted call): the same tenant-wide Auth0 Action also emits a claim named exactlyhttps://aws.amazon.com/tagsshaped as{"principal_tags": {"myceliam_service_account": ["sa1"], "myceliam_namespace": ["ns1"], "myceliam_cluster_id": ["id1"], "myceliam_cloud": ["gcp,aws"]}}(each array must hold exactly one string; a genuine multi-element array makes AWS reject the entire token outright withInvalidIdentityToken, confirmed live, no graceful degradation or index selection). Whenever myceliam is handling a provider whose identity claim needs this detour (Auth0 today), it automatically writesaws:RequestTag/myceliam_service_account— not the non-working<issuer>:myceliam_service_accountform — directly in the trust policy's own condition, gating theAssumeRoleWithWebIdentitycall itself (confirmed live both ways: a matching value succeeds, a mismatched one fails outright withAccessDenied), and grants the requiredsts:TagSessionaction alongsidests:AssumeRoleWithWebIdentity. This restores real per-SA discrimination for AWS despite one shared Auth0 Application per namespace, with no manual trust-policy authoring needed at all — myceliam creates the role itself (trust policy only, no permissions attached) if it doesn't already exist, and still never attaches or implies a permissions policy on it. - SPIFFE support assumes you've already stood up SPIRE yourself (server, agents,
spire-controller-manager,spiffe-csi-driver) — myceliam only consumes an existing JWT-SVID issuer, it doesn't provision SPIRE. - The operator must run as a single instance at all times, even during a
rolling deploy. It deliberately runs with no leader election (matching kopf's
--standalonemode), which is safe with one replica — except Kubernetes' default Deployment strategy starts the new pod before stopping the old one, so any redeploy briefly runs two uncoordinated operator processes reconciling the same objects at once. Confirmed live: this raced Auth0's Application find-then-create check, producing two duplicate Applications for the same ServiceAccount. Every shipped Deployment manifest setsstrategy: {type: Recreate}to close this — keep it if you maintain your own copy.
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.