TL;DR: During the GitLab migration, the OIDC trust policy was written to let merge-request pipelines run tofu plan by matching a sub claim of the form …:ref_type:mr:ref:…. The first real merge-request pipeline got a flat 403 from AWS. The bug was not in the policy’s syntax. It was that GitLab’s OIDC token has no mr ref_type at all, so the policy was matching a claim that could never exist. You cannot fix that in IAM.
A 403 on the first real run
The OIDC post covered the handshake: GitLab CI mints a signed token, AWS exchanges it for short-lived credentials against a role whose trust policy names the pipeline. During the GitLab migration I wired exactly that up for the infra repo, including a trust policy condition meant to let merge-request pipelines run a plan.
The first merge request that should have triggered tofu-plan did not run it. The job failed, and the error from AWS was a flat AccessDenied. A 403.
The instinct, and why it wastes an afternoon
The instinct on an IAM 403 is immediate and almost always right: the policy is wrong, so go and edit the policy. Tighten the condition. Loosen the condition. Check the wildcard. Re-read the sub pattern character by character.
All of that was wasted, and it was wasted for a reason that took me far too long to see. The trust policy was not matching the wrong value. It was matching a value that does not exist. No amount of editing a condition makes it match a thing that is never present.
What is actually in the token
GitLab’s OIDC token has a sub claim that encodes the pipeline’s context, and part of that encoding is a ref_type. I had assumed ref_type could be branch, tag, or mr, because a pipeline can certainly be a branch pipeline, a tag pipeline, or a merge-request pipeline. So the trust policy, for the plan job, matched a sub containing ref_type:mr.
That assumption was wrong. GitLab’s ref_type is branch or tag. That is the entire set. There is no mr.
A merge-request pipeline does not run against a merge-request ref. It runs against the source branch. So its token’s sub carries ref_type:branch, like any other branch pipeline. The trust policy condition asked for ref_type:mr, GitLab never puts mr in a token, the condition was therefore never true, and every merge-request pipeline got a 403. Forever, until the policy stopped asking for a claim that is not real.
The fix, and the lesson worth more than the fix
The fix is small once it is visible: match ref_type:branch and narrow it down by branch name or project path instead. An afternoon of policy edits, and the actual change is one word.
The lesson is the part worth keeping. When an OIDC trust fails, the useful question is never “is my policy clever enough.” It is “what is actually in the token.” An OIDC trust policy can only ever match the claims the identity provider genuinely asserts, and the gap between what a provider asserts and what you assumed it asserts is precisely where this class of bug lives.
So the move, when an OIDC handshake 403s, is to get hold of a real token and decode it. Look at the actual sub, the actual claims, the actual values. Match what is there. A 403 that survives every sensible edit to the policy is usually not a policy that is too loose or too strict. It is a policy matching a claim that was never going to be in the token.
Stepping back
I wired an OIDC trust policy to let merge-request pipelines plan, by matching a sub claim with ref_type:mr. The first real merge request got a 403, and no edit to the policy fixed it, because GitLab’s ref_type is only ever branch or tag. A merge-request pipeline runs on a branch ref, so the mr value the policy demanded was never in any token.
The fix was one word. The habit it left behind is the valuable bit: when an OIDC trust fails, stop editing the policy and go and read a real token. A trust policy can only match what the provider actually asserts, and “what I assumed it asserts” is where the 403 was hiding the whole time.