> ## Documentation Index
> Fetch the complete documentation index at: https://preloop.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Cloud access with OIDC

> Verify that AWS, Google Cloud, Azure, or Vault trusts Preloop's OIDC issuer before migrated jobs need it.

Workflows that migrate from GitHub Actions keep their `id-token: write` steps, but the token they receive is minted by Preloop: a different issuer and a different signing key. Each cloud must be told to trust that issuer before the first deploy job runs. `preloop ci oidc check` proves the trust exists — it does not infer it from configuration.

## What the check does

For a repository, ref, and optional environment, the control plane:

1. Mints an OIDC token with the claims a real job on that repository would receive: `iss`, `sub`, `aud`, `repository`, `repository_owner`, `ref`, `event_name`, `environment`, `org_id`, and the immutable-subject form when the repository has one.

2. Spends the token once against the provider's token exchange:

   | Provider | Exchange |
   | - | - |
   | AWS | `sts:AssumeRoleWithWebIdentity` for the role ARN |
   | Google Cloud | `https://sts.googleapis.com/v1/token`, then `generateAccessToken` when a service account is named |
   | Azure | `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token` with `client_assertion` |
   | Vault | `POST {address}/v1/auth/{path}/login` with `role` and `jwt` |

3. Reports `trusted`, `rejected`, or `error`, with the provider's sanitized reason. The token and any credentials the provider returned are discarded immediately and never appear in the response.

The exchange runs in the **control plane**, not in a probe job on a runner. A runner microVM is provisioned in Preloop's network, not the customer's, so a probe job would have the same reachability as the control plane while additionally handing a live Preloop OIDC token to a job environment and its log pipeline. Keeping the exchange in the control plane means the token only ever travels to the provider it was minted for.

:::note
A failed connection to your Vault address is an `error`, not a `rejected`: the trust may be fine, but the control plane cannot reach the server. Publish Vault on an address the control plane can route to, or run the check from a network that can.
:::

## Run the check

```sh theme={null}
preloop ci oidc check --repo acme/api
```

The command reads `.github/workflows`, takes every cloud trust relationship it finds together with any literal provider inputs (`role-to-assume`, `workload_identity_provider`, `client-id`, `url`, …), and checks each provider:

```text theme={null}
PROVIDER  STATUS    SUBJECT
aws       trusted   repo:acme/api:ref:refs/heads/main
  issuer:   https://oidc.ci.example.com
  audience: sts.amazonaws.com
  org_id:   acme

gcp       rejected  repo:acme/api:environment:production
  issuer:   https://oidc.ci.example.com
  audience: //iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/preloop
  org_id:   acme
  detail:   invalid_grant: The audience is not allowed for this provider
```

Flags:

| Flag | Purpose |
| - | - |
| `--repo OWNER/REPO` | Repository; detected from `origin` when omitted. |
| `--provider aws\|gcp\|azure\|vault` | Check only this provider (repeatable). Defaults to every provider the workflows use. |
| `--ref REF` | Ref the simulated job runs on. Defaults to the repository's default branch. |
| `--environment NAME` | Check the `repo:OWNER/REPO:environment:NAME` subject. When the workflows pin exactly one environment, it is used automatically. |
| `--json` | One JSON document with every verdict and trust snippet. |
| `--role-arn`, `--region`, `--workload-identity-provider`, `--service-account`, `--client-id`, `--tenant-id`, `--subscription-id`, `--vault-address`, `--vault-role`, `--vault-path`, `--audience` | Supply an input the workflow computes (`${{ vars.ROLE }}`). |

The command exits non-zero when any provider is not `trusted`, so it can gate a migration pipeline. Checks are rate-limited per repository (five per minute per control-plane node).

## Trust condition

Always condition on the full `sub`. A policy that matches only `repo:OWNER/REPO:*` trusts a repository name, and a name is only meaningful when the tenant that minted the token provably owns it. Add `org_id` **only where the provider supports claim conditions**: GCP attribute conditions and Vault `bound_claims` accept it; Azure and AWS cannot use it (see each section), so their tenant binding comes from the per-org issuer path or an exact subject.

| Claim | Example | Use |
| - | - | - |
| `iss` | `https://oidc.ci.example.com` | Register this issuer with the cloud. |
| `aud` | `sts.amazonaws.com`, provider resource, `api://AzureADTokenExchange`, or the Vault role's bound audience | Pin it; never accept any audience. |
| `sub` | `repo:acme/api:ref:refs/heads/main` (`:environment:prod`, `:pull_request`, or `refs/tags/…`) | Pin the repository and the ref/environment the job uses. |
| `org_id` | `acme` | The Preloop tenant. `[oidc] organization` when set, otherwise the run's namespace (`default` on a local single-tenant engine). Usable in GCP attribute conditions, Vault `bound_claims`, and Preloop's own Store/ABAC decisions — **not** an AWS IAM condition key and not matchable by an Azure federated credential. |

## AWS

```sh theme={null}
aws iam create-open-id-connect-provider \
  --url https://oidc.ci.example.com \
  --client-id-list sts.amazonaws.com
```

The role's trust policy needs `sts:AssumeRoleWithWebIdentity` and conditions keyed by the issuer host:

```json theme={null}
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {
      "Federated": "arn:aws:iam::123456789012:oidc-provider/oidc.ci.example.com"
    },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "oidc.ci.example.com:aud": "sts.amazonaws.com",
        "oidc.ci.example.com:sub": "repo:acme/api:ref:refs/heads/main"
      }
    }
  }]
}
```

Use `StringLike` with `repo:acme/api:ref:refs/heads/*` only when every branch of the repository should deploy. A custom IAM OIDC provider exposes only `sub`, `aud`, `amr` and `oaud` as condition keys; a `…:org_id` condition makes every `AssumeRoleWithWebIdentity` deny. `org_id` belongs in Preloop's Store/ABAC decisions, not in an IAM trust policy.

## Google Cloud

The workload identity provider must use Preloop as its issuer, allow the provider resource as an audience, and map `assertion.sub` and `assertion.org_id`:

```sh theme={null}
gcloud iam workload-identity-pools providers update-oidc preloop \
  --location=global --workload-identity-pool=preloop \
  --issuer-uri="https://oidc.ci.example.com" \
  --allowed-audiences="//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/preloop/providers/preloop" \
  --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.org_id=assertion.org_id"

gcloud iam service-accounts add-iam-policy-binding deploy@example.iam.gserviceaccount.com \
  --role roles/iam.workloadIdentityUser \
  --member "principalSet://iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/preloop/attribute.repository/acme/api"
```

To bind the tenant as well, use `--member "principal://iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/preloop/subject/…"` with the exact subject, or an `attribute.org_id` principalSet.

## Azure

```sh theme={null}
az ad app federated-credential create --id <client-id> --parameters '{
  "name": "preloop-acme-api",
  "issuer": "https://oidc.ci.example.com",
  "subject": "repo:acme/api:ref:refs/heads/main",
  "audiences": ["api://AzureADTokenExchange"]
}'
```

Entra federated credentials match issuer, subject, and audiences only — there is no slot for `org_id`. Keep the subject exact. The hosted issuer path is per organization (`https://oidc.preloop.dev/o/<org-id>`), so this restriction stays tenant-safe there; a single-tenant deployment that shares an issuer between tenants must not rely on subject alone.

## HashiCorp Vault

```sh theme={null}
vault write auth/jwt/config \
  oidc_discovery_url="https://oidc.ci.example.com" \
  default_role="deploy"

vault write auth/jwt/role/deploy \
  role_type="jwt" \
  user_claim="sub" \
  bound_audiences="https://github.com/acme" \
  bound_claims='{"sub":"repo:acme/api:ref:refs/heads/main","org_id":"acme"}' \
  token_policies="deploy"
```

`bound_audiences` must equal the audience the token carries. When the workflow's `hashicorp/vault-action` step does not set `jwtGithubAudience`, Preloop uses the GitHub-style default `https://github.com/<owner>`; pass `--audience` to check another value.

## Configuration

`[oidc] organization` binds the control plane's tenant into every token as `org_id`:

```toml theme={null}
[oidc]
organization = "acme"
```

A hosted control plane that serves several organizations from one issuer must derive `org_id` from the authenticated organization, not from this node-wide setting. Until it does, a trust policy that conditions on `sub` alone cannot distinguish two tenants whose submitted repository names collide.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.