Skip to content

test(e2e): drive the oidc-credential chain from formae connect - #696

Draft
JeroenSoeters wants to merge 7 commits into
mainfrom
test/connect-oidc-e2e
Draft

test(e2e): drive the oidc-credential chain from formae connect#696
JeroenSoeters wants to merge 7 commits into
mainfrom
test/connect-oidc-e2e

Conversation

@JeroenSoeters

Copy link
Copy Markdown
Collaborator

What

The oidc-credential e2e proved the token exchange but not how the trust it exchanges against came to exist: the IAM identity provider and the role were hand-provisioned standing resources, and the test asserted on a role name it could only have been told. What formae connect actually provisions was never exercised end to end.

The role is now made by the run that uses it:

  1. a stub control plane on loopback serves the cloud-connection setup and receives the registration;
  2. a stub auth plugin, installed as oidc, stands in for the hosted one the connect gate demands;
  3. formae connect aws --profile-aws provisions the identity provider and the role for real;
  4. the broker mints an RS256 token for the subject the control plane produced, and the echo plugin assumes that role.

A trust policy connect writes wrongly now fails the test, rather than passing beside a correct one somebody made by hand.

What moved out of the fixtures

The role ARN (it does not exist until connect makes it), the subject, and the audience all travel from the run. The subject in particular moves to the grammar the control plane really produces, fai:<tenant>/<installation>, instead of an invented shape production never emits — and which the GCP path cannot parse at all.

The standing forma keeps the issuer, its published keys and the identity provider, and drops the role. The provider stays because it is account-global and shared, and because connect validates and reuses an existing one rather than needing to create it.

A real bug the first live run found

A role created seconds earlier is not immediately assumable: IAM propagates asynchronously and STS answers AccessDenied until it has. The first run against the real account failed on a trust policy that was correct. The exchange now retries inside a window well short of the agent's 60s plugin call deadline, so a genuinely wrong policy still fails — at the end of the window rather than the start.

Testing

Run against the real e2e account from a local worktree:

--- PASS: TestOidcCredential_TokenExchangesForRealCredentials (13.43s)
--- PASS: TestOidcCredential_NoBrokerFailsClosed (1.11s)
--- SKIP: TestOidcCredential_GcpTokenExchangesForRealCredentials

The matrix entry needs the e2e-test shared-config profile the workflow already writes; formae connect provisions with it.

Draft, because of the second commit

The GCP sibling test lands here but is not wired on. provx/gcp resolves its issuer from a compile-time constant rather than the one its caller passes, so connect gcp provisions trust for the production issuer regardless of what the control plane named, and the exchange could never succeed. Fix upstream: platform-engineering-labs/oox#5. Once that merges, this branch takes the dependency bump, the one-line wiring in gcpprovision.go, and the workflow's E2E_GCP_PROJECT line together, and comes out of draft.

The oidc-credential e2e proved the exchange but not how the trust it
exchanges against came to exist. The IAM identity provider and the role
were hand-provisioned standing resources and the test asserted on a role
name it could only have been told, so what `formae connect` actually
provisions was never exercised end to end.

The role is now made by the run that uses it. A stub control plane serves
the cloud-connection setup and receives the registration, a stub auth
plugin stands in for the hosted one the gate demands, and `formae connect
aws --profile-aws` provisions the identity provider and the role for real
before the agent starts. The echo plugin then assumes that role, so a
trust policy connect writes wrongly fails the test instead of passing
beside a correct one somebody made by hand.

Three things the fixtures had pinned now travel from the run:

  - the role ARN, because it does not exist until connect makes it;
  - the subject, which moves to the grammar the control plane really
    produces, `fai:<tenant>/<installation>`, rather than an invented shape
    production never emits and the GCP path cannot even parse;
  - the audience, since each cloud spells it differently.

The exchange retries while IAM propagates. A role created seconds earlier
is not immediately assumable and STS answers AccessDenied until it is,
which failed the first real run against a trust policy that was correct.
The window sits inside the agent's plugin call deadline, so a genuinely
wrong policy still fails, at the end of it rather than the start.

The standing forma keeps the issuer, its published keys and the identity
provider, and drops the role. The provider stays because it is
account-global and shared, and because connect validates and reuses an
existing one rather than needing to create it. The per-run role carries
the suite's own name prefix so the pre-cleanup purge reclaims one a dead
run left behind.
The same property against a different federation mechanism: connect
provisions the workload identity pool, the provider trusting the broker's
issuer, and the project bindings; the broker mints for the provider's own
resource name; Google's STS exchanges that for a federated access token;
and the token reads the project.

The read is not decoration. An exchange proves the provider trusts the
issuer, subject and audience, and only spending its result proves connect
granted the federated principal anything, which is the half a successful
exchange alone leaves unchecked.

Nothing torn down afterwards, deliberately: everything connect creates
here converges rather than accumulates. The pool and provider are fixed
per project and patched in place, and the bindings are set for one
principal that does not change between runs.

It skips until a project is named, and nothing names one yet. provx/gcp
resolves its issuer from a compile-time constant instead of the one its
caller passes, so connect provisions trust for the production issuer no
matter which issuer the control plane named and the exchange could never
succeed. That fix is in flight upstream; wiring the project in belongs
with the dependency bump that carries it.
`formae connect --profile-aws` failed in CI with InvalidClientTokenId.
The profile the workflow writes carries an access key and a secret key
but no session token, and the credentials it is built from are temporary
ones from an assumed role, which AWS refuses without it.

Nothing caught it because nothing had read the profile as a profile. The
credentials action also exports the three values as job environment
variables, the SDK's default chain prefers those over shared config, and
the access-test step passes no --profile — so every other e2e test
authenticates through the environment and the half-written profile is
never consulted. `--profile-aws` resolves through
config.WithSharedConfigProfile, which forces the file and bypasses the
environment, making it the first thing here to depend on those three
lines being complete.
…idated

`connect gcp` read an issuer, held it against the one the control plane
reports, and then dropped it: the call to provx built a workload identity
provider trusting whatever issuer that library had compiled in. The two
agree in production, so the effect was latent there and total everywhere
else — provisioning reported success while establishing trust that could
never accept a token.

provx now takes the issuer as a parameter, as its AWS half always has, so
passing the validated one through is the whole fix. The dependency moves
with it.

This also switches the GCP half of the oidc-credential e2e on. It
federates into its own project rather than the one the GCP plugin's
suites use: the pool and provider ids are fixed per project and provx
refuses to converge a provider trusting a different issuer, so a project
already carrying the production-issuer connection cannot carry a test
issuer as well. Absent the project, the test skips.
…fterwards

The oidc-credential suite provisioned its trust under fixed names: one
subject, one role, one set of GCP bindings, reused by every run. That
made a green result mean less than it appeared to. From the second run
onward the exchange succeeded against trust the first run had left
standing, so a connect that had quietly stopped provisioning, or that
failed to repair state something else had broken, would keep passing. On
AWS the same fixed name also meant one run's teardown deleted a role
another run might still be using, and that a role surviving a crashed run
could be ridden to a pass by a later broken one.

The subject and the role name are now derived from a fingerprint of
FORMAE_TEST_RUN_ID, which the workflow already sets per run and attempt,
falling back to the clock so a developer machine gets the same property.
An exchange that succeeds is therefore attributable to the invocation
under test: the trust it spends did not exist before that invocation
created it.

Per-run subjects make the GCP teardown mandatory rather than optional.
The provisioner grants its principal roles/editor and
roles/resourcemanager.projectIamAdmin, so a fixed subject left one
standing grant behind while a per-run one would deposit a new privileged
principal on every run, permanently, on a project whose issuer signs with
a standing key. The bindings are now revoked in cleanup through the
project IAM policy. The pool and provider are deliberately left alone:
both are shared between installations, and provx's own Delete leaves them
for that reason.

The stub control plane also stops accepting requests the real API would
reject. It now asserts the installation the run addressed rather than
answering for any, refuses unknown fields and trailing content, and reads
the trust coordinates through pointers so an absent field is told apart
from an empty one. That last one retires a false claim in a comment here:
the GCP test said it proved roleArn never crossed the wire, when both an
absent field and "" had been decoding to the same empty string.
The exchange retried every failure for a fixed window measured between
attempts. Both halves were wrong in the same direction: they spent time
that bought nothing and buried the reason.

A wall-clock check between attempts bounds when the last request may
start, not when it may finish, so an attempt begun just inside the window
could run past the agent's plugin call deadline. The failure then arrives
as an operation timeout instead of the recorded exchange error, which
reads as the harness breaking rather than as the exchange refusing. The
window is now a child context, so nothing the loop starts can outlive it.

Retrying every error also meant a settled answer — a malformed ARN, a
rejected token, an expired credential — cost the whole window before
being reported. Only AccessDenied is waited out now, because that is what
STS returns for a role whose trust policy has not propagated yet. It is
also what a genuinely wrong trust policy returns, and the two are not
distinguishable from outside, which is why this is a bounded wait rather
than a poll for readiness.
The revocation treated an empty result as success, which made a principal
string that does not match what Google stores indistinguishable from a
clean teardown. Both paths returned without complaint, so privileged
bindings could survive under a green run — the precise failure the
cleanup exists to prevent, reintroduced inside it.

It runs only once connect has reported the provider it provisioned, so by
then the binding it granted must exist. Finding none is therefore a
defect worth reporting, not a state worth tolerating.
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