For the complete documentation index, see llms.txt.

Workload identities and trust in Chainguard Checks

Learn how a Chainguard Checks check gets short-lived Google Cloud or Chainguard credentials through a workload identity, how to limit which pull requests get one, and how to revoke access.
  13 min read

A check that must authenticate to Google Cloud or to Chainguard, such as to read a Cloud Storage bucket or to pull private images from cgr.dev, can use a workload identity: short-lived credentials that Chainguard Checks gives the check while it runs. This page explains how workload identities work, how to declare and set one up, how to limit which pull requests get one, and how to revoke access.

How workload identities work

A check’s VM holds no long-lived credentials. When a check uses a workload identity, Chainguard Checks issues the check an OpenID Connect (OIDC) token that lasts five minutes, renews the token while the check runs, and keeps the real credentials outside the VM:

ProviderWhat the check’s VM holds
Google CloudGOOGLE_APPLICATION_CREDENTIALS, set to /run/chainguard/identities/<identity>/adc.json, a credential configuration that Google Cloud client libraries use without further setup. The tokens that code in the VM can read are sealed stand-ins. Chainguard Checks swaps in the real tokens only on requests to sts.googleapis.com and other googleapis.com subdomains, such as storage.googleapis.com.
ChainguardNothing. Chainguard Checks adds a credential to the check’s requests to each Chainguard host the identity lists, so the check doesn’t sign in or need a credential file.

Code in the check can’t read the real tokens, but it can use them: each request it sends to those hosts carries the identity’s access. If that access lets the code create a credential, such as a Google Cloud service account key, that credential works outside the check until you delete it. Grant each identity only the access its checks need.

What code can use depends on where it runs:

Where code runsCredentials it can use
A check on a pull requestThe identity the check names, if identities.yaml on the pull request’s base branch allows it for that check and branch. If your default branch has a trust section, the pull request must also reach the identity’s ring.
A check after a change mergesThe identity the check names, if identities.yaml on your default branch allows it for that check and branch.
A Chainguard Workspaces session, including the checks that run-ci runs in itThe session’s own credentials, as described in Manage Chainguard Workspaces session credentials. Sessions don’t get workload identities, so run-ci skips checks that use one.

Declare an identity

You declare workload identities in .chainguard/ci/identities.yaml, which works with either file layout. The file has the following top-level keys:

KeyRequiredDescription
versionYesThe format version. Must be 1.
identitiesYesA map from each identity’s name to its definition. Refer to Identity keys.
trustNoWhich pull requests get identities, decided by who opened them. Refer to Limit which pull requests get an identity.

Chainguard Checks refuses keys it doesn’t recognize. To check the file before you merge it, run chainctl checks validate ., as described in Validate your configuration offline.

The following file declares an identity named gcs-read that the integration check in .chainguard/ci.yaml can use on pull requests into main:

.chainguard/ci/identities.yaml
version: 1
identities:
  gcs-read:
    description: Read test fixtures from Cloud Storage.
    checks:
      - ci.checks.integration
    audiences:
      - https://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/checks/providers/checks
    gcp:
      provider: projects/123456789012/locations/global/workloadIdentityPools/checks/providers/checks
      audience: https://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/checks/providers/checks
    allow:
      - run: presubmit
        target_ref: refs/heads/main

A check uses an identity by naming it with identity. For a Google Cloud identity, the check’s network.egress must list sts.googleapis.com, where client libraries exchange the identity’s token, and each Google API host the check calls:

.chainguard/ci.yaml
version: 1
environment:
  packages: [go]
checks:
  integration:
    cmd: go test -tags=integration ./...
    identity: gcs-read
    network:
      egress:
        - proxy.golang.org
        - sum.golang.org
        - sts.googleapis.com
        - storage.googleapis.com

Merge an identity’s definition before you use it. Chainguard Checks reads definitions from the base branch, so on the pull request that adds gcs-read, the integration check can’t get the identity and fails.

A check that uses an identity can’t set GOOGLE_APPLICATION_CREDENTIALS in its vars or in its environment’s vars. If it does, the whole run fails.

Identity keys

Each identity has the following keys:

KeyRequiredDescription
descriptionNoA description of the identity.
checksYesA list of selectors for the checks that can use the identity.
audiencesYesA list of the audiences that the identity’s tokens can carry. It must include gcp.audience and each Chainguard host’s exchange_audience.
allowYesA list of the runs that can get the identity.
gcpNoGoogle Cloud settings, both required: provider, the workload identity pool provider’s resource name, in the form projects/<project-number>/locations/global/workloadIdentityPools/<pool>/providers/<provider>, and audience, an audience that the provider accepts.
chainguardNoA map from each Chainguard host to its settings. Refer to Chainguard hosts.
ringWith a trust sectionWhich pull requests get the identity. Refer to Limit which pull requests get an identity.

An identity needs gcp, chainguard, or both to give a check any credentials. Identity names are 1 to 48 lowercase letters, digits, and -, and start with a letter.

Each checks entry has the form <file>.checks.<check>. <file> is a check file’s name without .yaml, such as go for .chainguard/ci/go.yaml, or ci in the single-file layout. <check> is a check’s name in that file. Either part can be * to match any file or any check, but only as the whole part, so go.checks.test-* is an error. Quote a selector that starts with *, such as "*.checks.lint", because YAML reads a leading * as an alias.

Each allow entry names a kind of run and its branch:

runRef keyRuns it allows
presubmittarget_ref, such as refs/heads/mainPull requests whose base branch is that branch.
postsubmitref, such as refs/heads/mainRuns after a change merges to that branch. Chainguard Checks runs these only on your default branch.

Each ref must be a full ref that starts with refs/.

Chainguard hosts

Each key under chainguard is a Chainguard host, and its value has the following keys, all required:

KeyDescription
identityThe ID of an assumable identity in your Chainguard organization that trusts this workload identity’s tokens. Refer to Set up the provider.
exchange_audienceThe audience that the assumable identity accepts. It must also be in the identity’s audiences.
roleThe most access the check gets on the host, from the following table. The assumable identity’s role bindings must also grant that access.
scopesA list of group IDs, such as your organization’s ID. Only the assumable identity’s role bindings on these groups, or on the groups that contain them, apply.

Each host accepts the following roles:

HostroleWhat the check can do
cgr.devregistry.pullPull container images.
cgr.devregistry.pushPull and push container images.
apk.cgr.devapk.pullDownload packages.
libraries.cgr.devlibraries.javascript.pullDownload JavaScript libraries from Chainguard Libraries.
libraries.cgr.devlibraries.python.pullDownload Python libraries from Chainguard Libraries.
console-api.enforce.devconsole.viewerList groups, identities, roles, role bindings, and identity providers in the Chainguard API.

A check that uses a Chainguard host must list it in network.egress, along with any host that the service redirects downloads to.

Set up the provider

A definition in identities.yaml grants no access by itself. The provider must also trust the identity’s tokens and grant them access. Chainguard Checks issues each token with the following claims:

ClaimValue
isshttps://issuer.enforce.dev
subchainguard:<repository-id>:<identity>, where <repository-id> is a 32-character hexadecimal ID that Chainguard assigns to your GitHub repository, and <identity> is the identity’s name.
audThe identity’s gcp.audience, or a Chainguard host’s exchange_audience.
refFor a run after merging, the ref it runs on, such as refs/heads/main. Tokens for pull requests have no ref claim.

Every Chainguard Checks customer’s tokens come from the same issuer, and any repository can ask for any audience, so the subject is what sets your identities apart from everyone else’s. Match the exact subject on the provider:

  • For Google Cloud, add an OIDC provider to a workload identity pool, with the issuer https://issuer.enforce.dev and the attribute mapping google.subject=assertion.sub. Grant IAM roles to the principal principal://iam.googleapis.com/projects/<project-number>/locations/global/workloadIdentityPools/<pool>/subject/chainguard:<repository-id>:<identity>, never to the whole pool. To refuse other repositories’ tokens outright, also give the provider an attribute condition such as assertion.sub.startsWith('chainguard:<repository-id>:').
  • For Chainguard, create an assumable identity in your organization that matches the issuer, the exact subject, and the exchange_audience, and bind it to a role that grants the access the check needs.

chainctl doesn’t show your repository’s ID. During the beta, contact your Chainguard account team to set up workload identities and get the ID, or contact Chainguard if you signed up yourself.

Which runs get an identity

Chainguard Checks reads each part of an identity’s use from a different place:

PartWhere Chainguard Checks reads it
The identity’s definition in identities.yamlFor a pull request, its base branch, at the commit GitHub merges the pull request into. For a run after merging, your default branch’s latest commit.
The trust section and each identity’s ringYour default branch’s latest commit, for every pull request.
The check’s identity key, its commands, and its network.egressThe commit Chainguard Checks runs, which for a pull request includes the pull request’s changes.

A pull request can’t add an identity, or widen which checks, branches, or people get one, until it merges. It can change what a check that already gets an identity runs, and which hosts that check can reach. If identities.yaml is invalid on the base branch or on your default branch, no check gets an identity until you fix it.

Without a trust section on your default branch, a check that requests an identity gets it on every open pull request whose base branch matches the identity’s allow rule, including drafts and pull requests from forks, whoever opened them. Those pull requests’ commands run with the identity’s access. Grant a presubmit identity only what an untrusted pull request may do, such as read-only access, or limit which pull requests get it with a trust section.

Limit which pull requests get an identity

To give identities only to pull requests opened by people you trust, add a trust section to .chainguard/ci/identities.yaml on your default branch, and give each identity a ring:

.chainguard/ci/identities.yaml
version: 1
trust:
  groups:
    maintainers:
      github:
        user: {octocat: 583231}
    bots:
      github:
        app: {dependabot: 49699333}
  subrings:
    trusted:
      authors: [maintainers, bots]
  github_token: 1.trusted
identities:
  gcs-read:
    ring: 1.trusted
    # ...the rest of the identity's definition

With this file, the gcs-read identity goes only to pull requests opened by octocat or by Dependabot, and to runs after merging.

The trust section has the following keys:

KeyRequiredDescription
groupsYesA map from each group’s name to the GitHub accounts in it. Refer to Groups.
subringsYesA map from each subring’s name to its authors, a list of groups. A pull request meets a subring when the account that opened it is in one of those groups.
github_tokenYesThe ring for a read-only GitHub token, in the same form as ring. During the beta, checks don’t get this token, but the key is still required.
cacheNoWhich rings share build caches. Refer to Set up cache pools.

Rings

Chainguard Checks places each run in a ring, based on the trust section on your default branch:

RingRuns in the ring
2Runs after merging, on your default branch.
1.<subring>Pull requests opened by an account in one of the subring’s authors groups, such as 1.trusted for pull requests that octocat or Dependabot opens in the earlier example.
0Every other pull request.

Ring 2 is above every subring, and every subring is above ring 0. The trust section uses rings in the following ways:

  • An identity’s ring is the lowest ring that gets the identity. With ring: 1.trusted, pull requests in ring 1.trusted and runs after merging get it. With ring: 2, only runs after merging get it, and with ring: 0, every run does, the same as without a trust section.
  • github_token sets the lowest ring that gets a GitHub token, in the same way.
  • A cache pool lists the exact rings whose runs share it. A run whose ring isn’t in any pool gets no cache.

A ring only narrows an identity’s allow rules, so a run still needs a matching allow rule to get the identity.

Only the account that opened the pull request counts. Who pushed its commits, who reviewed it, and whether its branch is in a fork don’t change the result.

Because Chainguard Checks reads the trust section and every ring from your default branch, a pull request can’t change its own trust, even a pull request into another branch. An identity that your default branch’s file doesn’t define gets ring 2, so a pull request into another branch never gets an identity that only that branch defines.

When the file has a trust section, every identity in it needs a ring, and every group must be in at least one subring’s authors. Without a trust section, ring is an error. Group and subring names follow the same rules as identity names.

Groups

Each group has exactly one source under github:

SourceExampleMatches
org{github: {org: acme}}Members of the GitHub organization.
team{github: {team: acme/maintainers}}Members of the team, including members of its child teams.
user{github: {user: {octocat: 583231}}}The listed GitHub users.
app{github: {app: {dependabot: 49699333}}}Pull requests that the listed GitHub Apps open.

To trust specific people, list them in a user group and pin each one’s ID, as in the earlier example. Chainguard Checks reads organization and team members from GitHub through the Chainguard App, and can take up to three minutes to see a change to them.

For user and app, map each login to its numeric GitHub account ID. Chainguard Checks matches the ID, and the login is only a label. To find a user’s ID, run the following command, replacing $LOGIN with the user’s GitHub login:

gh api "/users/$LOGIN" --jq .id

An app opens pull requests as its bot account, <slug>[bot]. To find that account’s ID, replace $SLUG with the app’s slug, such as dependabot:

gh api "/users/$SLUG%5Bbot%5D" --jq .id

You can also list logins without IDs, such as user: [octocat]. Chainguard Checks then trusts whoever holds each login when it decides, so pin IDs instead.

When a pull request doesn’t get an identity

On a pull request that doesn’t reach an identity’s ring, a check that requests the identity fails before its commands run, and Chainguard Checks doesn’t retry it. The check’s result on GitHub shows Could not be executed, and its output includes the reason, such as the following:

trust policy places gcs-read at ring 1.trusted, which this pull request does not reach: its opener is in none of subring trusted's authors groups

If the check is blocking, the summary check fails too, with a title such as 1 check(s) could not be executed.

To keep these failures from blocking pull requests opened by people outside your trust section, make the check advisory, or skip it on those pull requests with a condition. A condition only decides whether the check runs. A pull request can remove a condition that would skip a check, so a condition doesn’t keep an identity from a pull request. The trust section does. A condition on context.github.pull_request.head_repository also skips pull requests that people you trust open from their own forks.

Revoke access

To take access back, change the place that grants it:

To stopChangeWhen it takes effect
A check from getting an identityRemove the identity, the check’s selector, or the allow rule from identities.yaml on the base branch, or on your default branch for runs after merging.New runs, once the change merges. A running check loses the identity at its next renewal, within about five minutes.
Pull requests from some people from getting identitiesIn the trust section on your default branch, remove them from a group, or move identities to a higher ring.New runs, once the change merges. A running check loses the identity at a later renewal, within about 10 minutes. Chainguard Checks can take up to three minutes to see a change to an organization’s or team’s members on GitHub.
Every use of an identity, including runs after mergingOn the provider, remove the roles granted to the identity’s subject, or remove the provider’s trust in it, such as by disabling the Google Cloud workload identity pool provider or deleting the Chainguard assumable identity.The provider refuses new token exchanges. A Google Cloud access token that a check already has can keep working until it expires, for up to an hour.

Chainguard Workspaces sessions don’t use workload identities. To revoke what a session can reach, refer to Review and revoke a session’s credentials and Your Chainguard identity in a session.

Last updated: 2026-10-13 14:50