Skip to content

feat: polymorphic auth with OIDC federation on the target config - #179

Merged
JeroenSoeters merged 13 commits into
mainfrom
feat/oidc-auth
Aug 22, 2026
Merged

feat: polymorphic auth with OIDC federation on the target config#179
JeroenSoeters merged 13 commits into
mainfrom
feat/oidc-auth

Conversation

@JeroenSoeters

@JeroenSoeters JeroenSoeters commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds federated (OIDC) authentication as a second option alongside the existing
default credential chain on the AWS target config.

  • Schema: Auth is now a discriminated sum on the target config —
    DefaultChainAuth (env vars, shared config, IMDS/IRSA, optionally pinned to
    a shared-config profile) or OidcAuth (roleArn only; issuer, key and
    token endpoint all belong to the paired broker, not the target). The legacy
    flat profile field is deprecated but unchanged; setting both auth and
    profile is rejected at eval.
  • Go dispatch: Config.ToAwsConfig keeps its signature and dispatches on
    DefaultChain | Oidc; an unknown discriminator errors. effectiveAuth
    resolves flat profile and the new auth block into one value, mirroring
    the Pkl-level mutual-exclusion constraint for callers that bypass the
    schema.
  • Bounded-refresh adapter + keyed cache: an oidcCredentialsProvider
    exchanges the broker-issued identity token for STS credentials by assuming
    OidcAuth.roleArn. The refresh deliberately runs on its own bounded
    context, derived from the operation's ctx with context.WithoutCancel plus
    a 30s deadline: that keeps the request-scoped values the token source needs
    to reach the right broker, while not pretending to honour caller
    cancellation. Caller cancellation is honoured by aws.CredentialsCache's
    own select around the refresh, not by propagation into it — the cache
    already suppresses cancellation on the refresh context it hands down, so
    propagating it here would be an illusion. Credentials are cached per
    (region, roleArn, sha256(rawAuth)) key, scoped to the owning Plugin
    instance (OidcDeps) — never process-global.
  • OidcAware threading: Plugin now implements plugin.OidcAware;
    SetOidcTokenSource wires the broker-backed token source onto the plugin
    instance, and all 6 per-operation call sites (consolidated; across
    Create/Update/Status/Delete/Read/List) thread it onto the parsed
    Config via WithOidcDeps before use. A plugin instance with no broker
    paired (or an old agent that never calls SetOidcTokenSource) flows through
    unchanged: OidcAuth targets fail closed with an explicit error rather than
    ever falling back to ambient credentials.

Flat-path compatibility

A target that declares only the legacy flat profile (or nothing at all)
evaluates byte-identical old schema vs. new — verified by a dual-install
eval diff of the same forma against the pre-change and post-change schema.
The flat path continues to authenticate exactly as before; the only visible
change is a single deprecation warning, logged at most once per plugin
process, pointing at auth = new DefaultChainAuth { profile = ... }.

One-time metadata update, not drift

Adding the Auth field hint to the schema is a change formae's schema-change
bookkeeping records against every existing target's stored metadata,
regardless of whether that target's declared configuration changed. Expect
exactly one resource-inert target-metadata update per target on the first
reconcile after upgrading to a formae agent + aws plugin pair that carries
this change — no cloud resource is read, created, updated, or destroyed by
it, and it does not recur. Called out here and in the CHANGELOG so it isn't
mistaken for drift.

Dependency: cannot merge before formae PR #661

OidcAuth requires agent-side support that has not landed yet:

  • minFormaeVersion = "0.89.0" in formae-plugin.pkl names the formae
    release expected to carry the oidc-credential broker this plugin depends
    on; the comment beside it says to confirm the real number at release time
    rather than trust the dev-line guess made now.
  • The SDK surface this plugin builds against (plugin.OidcAware,
    plugin.OidcTokenSource, the credential-broker wiring) lives on formae PR
    #661, which has not merged. This PR must not merge before it.

go.mod currently carries pre-merge pins that must be re-pinned to real
tags before this PR merges
(release-path follow-up work, not done here):

  • github.com/platform-engineering-labs/formae/pkg/plugin
    v0.4.2-0.20260821030446-6aa3db2765ec (pseudo-version at the
    credential-broker branch head)
  • github.com/platform-engineering-labs/formae/pkg/credential
    v0.0.0-20260821030437-d18cdc5fa355 (indirect, pseudo-version, same branch)
  • replace ergo.services/ergo => github.com/JeroenSoeters/ergo v1.999.320-pel.6 — a fork revision required transitively by the two pins
    above

(The ergo.services/actor/statemachine replace is unchanged from main and
needs no re-pin.)

Both go.mod pin comments already flag themselves for re-pinning before
merge.

Deprecation policy

Flat profile keeps working with no forced migration. It will be removed at
a posted future major version, not in this release and not on any date fixed
today.

Migrating flat profile to an auth block is non-destructive. profile
now carries @formae.ConfigFieldHint { createOnly = false }, matching
auth. Without it the migration drops the top-level Profile key from the
rendered config, the agent classifies an unhinted key change as immutable,
and the target is replaced — destroying and recreating every resource on
it. With both fields hinted mutable, the rewrite is an ordinary target
update.

The hints only take effect on an agent at or above the release that carries
them. An older agent still plans a replace. The infrastructure repo's own
flat-target migration must therefore run only after its agents are on that
release or newer
, and the CHANGELOG says so.

Out of scope (tracked separately)

  • Installation wiring for pairing a plugin with a broker (own track, after
    infra PR fix(elbv2): mark Action.forwardConfig as hasProviderDefault #48)
  • Conformance test changes (deliberately untouched by design)
  • The infrastructure repo's own flat-target migration (follow-up PR after
    release)
  • Azure/GCP equivalents
  • Adding a formae binary to this repo's CI (eval fixtures currently run
    local-only; follow-up if cheap)

Test plan

  • make verify-schema
  • make verify-examples
  • go test -tags=unit -race ./...
  • go vet ./...
  • make lint
  • make build
  • Re-pin pkg/plugin/pkg/credential/ergo-fork to real tags once formae
    PR #661 merges, before merging this PR
  • Confirm minFormaeVersion = "0.89.0" still matches the release that
    actually ships the broker, at release time

Config now carries the nested Auth JSON block alongside the flat
Region/Profile fields the Pkl schema renders it beside. effectiveAuth
resolves the two into one discriminated block: an explicit Auth wins,
an absent/null/whitespace-only Auth synthesises a DefaultChain from the
flat Profile, and setting both is rejected in Go too, mirroring the
Pkl-level constraint for callers that bypass the schema.

ToAwsConfig keeps its signature and dispatches on DefaultChain | Oidc;
an unknown discriminator errors. The Oidc arm requires a non-nil
OidcDeps.Source and fails closed rather than ever falling back to
ambient credentials; the actual STS/web-identity credential
composition is a follow-up and currently returns a clear
not-yet-implemented error.

OidcDeps is owned per plugin instance (threaded via WithOidcDeps), not
process-global, so its warn-once deprecation notice and future
credentials cache stay instance-scoped and test order stays
independent. Config values with no OidcDeps at all still see the
flat-profile deprecation warning once per process via a package-level
fallback.
OidcDeps.caches has no reader until the follow-up credential-composition
work lands, so golangci-lint's unused check flags it. The field's shape
is mandated by the auth plan and gains its consumer next, so suppress
rather than delete it.
Plugin now implements plugin.OidcAware: SetOidcTokenSource wires an
OidcDeps (backed by the production STS factory) onto the plugin instance,
and every FromTargetConfig call site threads it onto the parsed Config via
WithOidcDeps before the config is used. A nil deps value (no broker paired,
or an old agent) flows through unchanged: WithOidcDeps(nil) is a no-op and
Oidc auth keeps failing closed rather than falling back to ambient
credentials.

Also documents NewOidcDeps as the required constructor (a bare OidcDeps{}
literal with Source set panics on first Oidc use, since stsFactory is left
nil), and notes why oidcCacheKey omits the token source's identity.
CHANGELOG: polymorphic auth (DefaultChainAuth | OidcAuth), the flat
profile deprecation (works unchanged, warns once per process), the
OidcAuth agent-broker requirement, and the one-time target-metadata
update existing targets see on first reconcile after upgrade.

formae-plugin.pkl: note beside minFormaeVersion that 0.89.0 must be
the release that ships the oidc-credential broker, to be confirmed
at release time rather than assumed from the current dev line.
Migrating a target from the flat profile to an auth block removes the
top-level Profile key from the rendered config. Profile carried no
config field hint, so the agent classified that as an immutable change
and planned a target replace, which destroys and recreates every
resource on the target.

Hinting profile mutable, as auth already is, makes the deprecation's own
migration an ordinary target update.
Give the AssumeRoleWithWebIdentity exchange a fixed role session name so
the assumed-role principal is attributable in the customer's CloudTrail
instead of a random string.

Read the STS client factory into a local and fall back to the production
one when it is nil, so an OidcDeps built as a bare literal works without
a write that would race a concurrent reader of the same struct.
Say that moving a target from flat profile to an auth block is a normal
update because both fields are mutable, and that an agent older than the
release carrying those hints plans a target replace instead, so the
migration has to follow the upgrade.

Note that the OIDC token exchange runs on a default-configured STS
client, so AWS_USE_FIPS_ENDPOINT, AWS_ENDPOINT_URL_STS and custom CA
bundles are not honoured on it.
The previous pins pointed at a formae feature branch head, which is not
reachable from main. Both now resolve from main: pkg/plugin at the commit
that gives its own credential requirement a resolvable version, and
pkg/credential at the commit on main that carries the module. The stale
pre-merge notes are gone; the ergo fork replace stays, since a replace is
not transitive and both modules need the -pel.6 revision.
@JeroenSoeters
JeroenSoeters marked this pull request as ready for review August 22, 2026 01:40
@JeroenSoeters
JeroenSoeters merged commit 31dd210 into main Aug 22, 2026
8 checks passed
@JeroenSoeters
JeroenSoeters deleted the feat/oidc-auth branch August 22, 2026 01:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant