Dagger
Search

identity

the identity provider.

── WHY THIS MODULE EXISTS ──────────────────────────────────────────────────
Across the four repos, identity is 18 native GitHub Actions steps:
`google-github-actions/auth` ×9, `actions/create-github-app-token` ×8 and
`google-github-actions/get-secretmanager-secrets` ×1, plus ~40 lines of bash
adapting the credential file into something a container can consume. None of
those 18 steps is one of the four legitimate reasons for a YAML step
(approval, runner topology, trigger surface, the runner's own API — see
`org-gitops/docs/daggerverse-ci-contract.md` §1): they are all HTTP calls that
happen to run outside the container that needs the result.

── WHY IT REMOVES THE `credential_source.file` PROBLEM ─────────────────────
`google-github-actions/auth` does not hand back a token by default. It writes
an `external_account` credential JSON and exports its path, and that JSON comes
in two shapes:

· the `url` form — the endpoint and its bearer are INSIDE the JSON, so
passing the JSON by value is enough;
· the `file` form — it points at a path on the RUNNER, which a container
does not have, so the JSON by value is a credential
that resolves to nothing.

`pacha/app` pays for that ambiguity with a "Adapt GCP credentials for the
Dagger container" step whose only job is to `jq` the credential, refuse the
`file` form outright, and copy the file somewhere the container can mount
(see `withSopsAuth` in `app/.github/dagger/src/index.ts`, which then has to
choose between `GOOGLE_CREDENTIALS` and `GOOGLE_APPLICATION_CREDENTIALS`
because `buildAndroid` already owns the latter for Firebase).

Going straight to an OAuth access token deletes the whole problem: there is no
credential file, no `credential_source`, no shape to detect, and no auth
library needed inside the container — the token is one HTTP header.

── THE THREE LEGS, AND WHY THEY ARE NAMED IN EVERY ERROR ───────────────────
`gcpAccessToken` is three chained HTTP calls:

1. OIDC — GET the runner's token endpoint for a JWT with the right audience
2. STS — POST `sts.googleapis.com/v1/token` to exchange that JWT for a
federated token (this is where Workload Identity Federation
actually decides whether to trust the repo)
3. IAM — POST `iamcredentials.googleapis.com` `generateAccessToken` to
impersonate the service account

All three can fail with "permission denied", and they fail for completely
different reasons: a missing `id-token: write`, an attribute condition that
does not match the repo, and a missing `roles/iam.workloadIdentityUser` on the
service account, in that order. An error that does not say WHICH leg failed is
why people give up and go back to the official action, so every error here
names its leg and what to check.

── SECRETS NEVER BECOME OUTPUT ─────────────────────────────────────────────
No token is ever written to stdout, to an error message, or to a file that gets
exported. Every network call runs inside a container that writes its result to
`/out/token`, and only that file crosses back — straight into `dag.setSecret`.
The three legs of `gcpAccessToken` run in ONE exec, so the intermediate OIDC
JWT and federated token never leave the container at all.

The exec's own argv IS visible in Dagger's logs, so no secret is ever
interpolated into a script: they arrive as `withSecretVariable` /
`withMountedSecret` and are read at runtime. Where a token has to become a
request header, it goes through a `curl -K` config file rather than argv.

When something must be shown for diagnosis, this module shows the token's
SHAPE (length, prefix class, which JSON keys came back) or the OIDC token's
`aud`/`sub` claims — never a value. `oidcClaims` exists precisely so that
debugging a Workload Identity attribute condition never needs the token
itself.

── COMPLEX ARGUMENTS TRAVEL AS JSON STRINGS ────────────────────────────────
Same convention as every module here: structural types cross a Dagger module
boundary poorly, so non-scalars are JSON strings, documented on the function,
parsed and validated on entry. Lists that are genuinely flat (scopes,
repositories, secret names) travel as comma-separated strings instead, because
that is what the YAML they replace already used.

── WHY THE SECRET MANAGER FUNCTIONS ARE `gcpSecret` AND NOT `secret` ───────
MEASURED, not styled. A `@func()` named `secret` shadows the CLI's resolution
of `Address.secret`, which is what `--flag=env:NAME` and `--flag=file:PATH`
go through. With it present, EVERY Secret-typed argument in the whole module
became uninvokable:

$ dagger call github-app-jwt --private-key=file:/tmp/key.pem …
✘ address(value: "file:/tmp/key.pem"): Address!
✘ .secret: Secret! ERROR
Error: missing required argument: "accessToken"

— the CLI resolved `.secret` to `Identity.secret(accessToken, …)` and asked for
ITS arguments. `tsc` is happy, `dagger functions` lists everything, and the
failure names a parameter that belongs to a function the caller never
mentioned. Same family as the org-wide "no public parameter with a digit
followed by a letter" rule (contract §4.6): a name that compiles, lists, and
cannot be called. Do not name a function after a core Dagger type's field.

── CACHE ───────────────────────────────────────────────────────────────────
Every function that talks to the network takes a REQUIR

Installation

dagger install github.com/wildbitca/daggerverse/identity@v0.1.0

Entrypoint

Return Type
Identity
Example
dagger -m github.com/wildbitca/daggerverse/identity@03fc8e57966e67996d137c98319e0bf632238468 call \
func (m *MyModule) Example() *dagger.Identity  {
	return dag.
			Identity()
}
@function
def example() -> dagger.Identity:
	return (
		dag.identity()
	)
@func()
example(): Identity {
	return dag
		.identity()
}

Types

Identity 🔗

gcpAccessToken() 🔗

GitHub OIDC → GCP STS → an impersonated service-account access token.

Replaces google-github-actions/auth with token_format: access_token, and removes the credential-file adaptation that token_format omitted forces (see the module header). Returns a Secret holding the raw OAuth token: pass it to withSecretVariable("GCP_TOKEN", …) and it becomes one Authorization: Bearer header. No auth library is needed in the container.

The three legs run in a SINGLE exec, so the OIDC JWT and the federated token never cross back into the module — only the final access token does.

── WHAT THE CALLER MUST HAVE ───────────────────────────────────────────── The job needs permissions: id-token: write. GitHub then exports ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN into THAT job only, which is why they are arguments and not something this module can discover:

dagger call gcp-access-token
–request-url=env:ACTIONS_ID_TOKEN_REQUEST_URL
–request-token=env:ACTIONS_ID_TOKEN_REQUEST_TOKEN
–workload-identity-provider=projects/945676640602/locations/global/workloadIdentityPools/ghactions/providers/ghactions
–service-account=sops-decrypt@wildbit-pacha-dev.iam.gserviceaccount.com
–cache-bust=“$GITHUB_RUN_ID”

Return Type
Secret !
Arguments
NameTypeDefault ValueDescription
requestUrlSecret !-

ACTIONS_ID_TOKEN_REQUEST_URL, as a Secret. Not secret in the cryptographic sense, but it is paired with the request token and there is no reason for it to be the one value that lands in a log.

requestTokenSecret !-

ACTIONS_ID_TOKEN_REQUEST_TOKEN, as a Secret. This one genuinely is a bearer credential.

workloadIdentityProviderString !-

projects/<number>/locations/<loc>/workloadIdentityPools/<pool>/providers/<provider>

serviceAccountString !-

the service account to impersonate, …@….iam.gserviceaccount.com

cacheBustString !-

MUST vary per run — use $GITHUB_RUN_ID. Without it Dagger replays a previous exec and returns a token minted for a run that already finished.

scopesString !""

comma-separated OAuth scopes for the FINAL token. Default cloud-platform, which is what the SA can do and nothing more — narrowing here does not add a permission, it only removes one.

lifetimeString !"3600s"

<seconds>s, max 3600s unless the org policy constraints/iam.allowServiceAccountCredentialLifetimeExtension allows more. Default 3600s.

audienceString !""

override the OIDC audience. Empty derives https://iam.googleapis.com/<provider>, which is the default google-github-actions/auth uses and what the provider accepts unless allowedAudiences was set.

Example
dagger -m github.com/wildbitca/daggerverse/identity@03fc8e57966e67996d137c98319e0bf632238468 call \
 gcp-access-token --request-url env:MYSECRET --request-token env:MYSECRET --workload-identity-provider string --service-account string --cache-bust string --scopes string --lifetime string --audience string
func (m *MyModule) Example(requestUrl *dagger.Secret, requestToken *dagger.Secret, workloadIdentityProvider string, serviceAccount string, cacheBust string, scopes string, lifetime string, audience string) *dagger.Secret  {
	return dag.
			Identity().
			Gcpaccesstoken(requestUrl, requestToken, workloadIdentityProvider, serviceAccount, cacheBust, scopes, lifetime, audience)
}
@function
def example(requesturl: dagger.Secret, requesttoken: dagger.Secret, workloadidentityprovider: str, serviceaccount: str, cachebust: str, scopes: str, lifetime: str, audience: str) -> dagger.Secret:
	return (
		dag.identity()
		.gcpaccesstoken(requesturl, requesttoken, workloadidentityprovider, serviceaccount, cachebust, scopes, lifetime, audience)
	)
@func()
example(requestUrl: Secret, requestToken: Secret, workloadIdentityProvider: string, serviceAccount: string, cacheBust: string, scopes: string, lifetime: string, audience: string): Secret {
	return dag
		.identity()
		.gcpAccessToken(requestUrl, requestToken, workloadIdentityProvider, serviceAccount, cacheBust, scopes, lifetime, audience)
}

oidcClaims() 🔗

The claims of the runner’s OIDC token — for debugging, never a credential.

Returns JSON with iss, aud, sub, exp, iat and the GitHub-specific claims a Workload Identity attribute condition is usually written against (repository, repository_owner, ref, workflow, environment, job_workflow_ref, actor). The token itself is NEVER returned and never leaves the container.

This exists because a WIF attribute condition that does not match fails at the STS leg with a message that does not say which claim disagreed. With this you can read the claim and compare it to the condition, without ever printing a bearer token to a CI log where it lives for 90 days.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
requestUrlSecret !-No description provided
requestTokenSecret !-No description provided
cacheBustString !-

MUST vary per run — see gcpAccessToken.

audienceString !""

the audience to request. Empty asks for the runner’s default, which is the repository URL and is NOT what GCP expects — pass https://iam.googleapis.com/<provider> to see the real thing.

Example
dagger -m github.com/wildbitca/daggerverse/identity@03fc8e57966e67996d137c98319e0bf632238468 call \
 oidc-claims --request-url env:MYSECRET --request-token env:MYSECRET --cache-bust string --audience string
func (m *MyModule) Example(ctx context.Context, requestUrl *dagger.Secret, requestToken *dagger.Secret, cacheBust string, audience string) string  {
	return dag.
			Identity().
			Oidcclaims(ctx, requestUrl, requestToken, cacheBust, audience)
}
@function
async def example(requesturl: dagger.Secret, requesttoken: dagger.Secret, cachebust: str, audience: str) -> str:
	return await (
		dag.identity()
		.oidcclaims(requesturl, requesttoken, cachebust, audience)
	)
@func()
async example(requestUrl: Secret, requestToken: Secret, cacheBust: string, audience: string): Promise<string> {
	return dag
		.identity()
		.oidcClaims(requestUrl, requestToken, cacheBust, audience)
}

githubAppToken() 🔗

A GitHub App installation access token, scoped to named repositories.

Replaces actions/create-github-app-token@v1. Three calls in one exec: sign an RS256 JWT with the App private key, resolve the installation for owner, then POST for an installation token narrowed to repositories.

── BOTH AXES ARE REQUIRED, AND NEITHER HAS A DEFAULT ──────────────────── An installation token is scoped on TWO independent axes, and leaving either one out widens it:

· WHICH REPOSITORIES — repositories, already passed by all eight call sites today. Omitted, the token covers every repository the App is installed on. · WHICH PERMISSIONS — permissions. Omitted, the token carries the App’s ENTIRE permission set on those repositories. GitHub’s REST reference, verbatim: “If permissions is not specified, the installation access token will have all of the permissions that were granted to the app.” Scoping to a SUBSET is the documented purpose of the parameter, bounded by “the installation access token cannot be granted permissions that the app was not granted”.

The second is the one that has no counterpart in the YAML being replaced. ADR-0003 §4.5 (org-gitops, decided 2026-08-21) measured its consequence and accepted it at that scope: wildbit-ci-cd holds Deployments: Read and write so that it can BE the custom deployment protection rule that replaced the Slack gate, and actions/create-github-app-token mints tokens with all of the App’s permissions unless narrowed with permission-*. Stated in the ADR without hedging: any of the pipelines can call the review endpoint with the token it already mints and release its own deployment — GitHub’s “Apps can only review their own custom deployment protection rules” does not stop it, because the App IS the gate. The ADR counted 12 call sites in 8 repos: one direct self-approval (avasambench, a monorepo) and three cross- approvals inside pacha. Narrowing all of them was written down as outcome 1 and deferred as “hygiene, not protection”, with the reason it was deferred being that “a future omission silently reopens the hole”.

So permissions is REQUIRED here, with no default at all — not even a narrow one. A default is precisely the future omission the ADR named: it would be a choice nobody makes, appearing in no diff and no review. Required means every call site states its own permission set, and deployments can only ever appear because somebody typed it.

Verified 2026-09-05 across the four repos: eight of eight call sites pass no permission-* today, so all eight currently carry the full App set, Deployments: Read and write included. What they actually need:

{“contents”:“read”} check-shared-pins.sh (app, services, web) and the two sibling actions/checkout of pacha-api (app) {“contents”:“write”} the pacha-ops digest bump (services ×2, web ×1), which is git clone + git push origin HEAD:main — a direct push, so no pull_requests

None of the eight needs deployments. Porting them therefore closes ADR-0003 outcome 1 as a side effect, at no extra cost, because these call sites are being rewritten anyway. This module does not BAN deployments — an org-wide module cannot know that no future caller is a legitimate gate — but asking for it is now a visible word in a diff instead of silent inheritance, and it is called out on stderr when requested.

The JWT is iat = now - 60, exp = now + 480 — a 540-second span. Backdated because a runner clock a few seconds ahead of GitHub’s makes an iat in the future, and GitHub answers that with a 401 whose whole text is “‘Issued at’ claim (‘iat’) is in the future”. Not the obvious exp = now + 540, which lands exp - iat on exactly 600 and therefore exactly on GitHub’s limit: whether a 600-second span is inside or outside “maximum 10 minutes” is a boundary nobody should be discovering from a 401 in CI.

Return Type
Secret !
Arguments
NameTypeDefault ValueDescription
appIdString !-

the App ID (vars.WILDBIT_CI_APP_ID). The App’s client id also works as iss; the numeric id is what the pipelines use.

privateKeySecret !-

the App private key, PEM. Mounted as a file, never an env var and never interpolated into the script.

ownerString !-

the organisation (or user) the App is installed on, e.g. wildbitca.

repositoriesString !-

comma-separated repository NAMES without the owner, e.g. pacha-api or pacha,pacha-site. Required; * is rejected.

permissionsString !-

REQUIRED JSON object, e.g. {"contents":"read"}. Keys are GitHub’s permission names, values read, write or admin. {} is rejected — it reads as “no opinion” and is the one thing this argument exists to prevent. What GitHub does with an OVER-request (a permission the App was never granted) is not documented and is unverified here: do not rely on it erroring, and do not rely on it being dropped. The direction that matters is documented and is the other one — see above.

cacheBustString !-

MUST vary per run — see gcpAccessToken.

Example
dagger -m github.com/wildbitca/daggerverse/identity@03fc8e57966e67996d137c98319e0bf632238468 call \
 github-app-token --app-id string --private-key env:MYSECRET --owner string --repositories string --permissions string --cache-bust string
func (m *MyModule) Example(appId string, privateKey *dagger.Secret, owner string, repositories string, permissions string, cacheBust string) *dagger.Secret  {
	return dag.
			Identity().
			Githubapptoken(appId, privateKey, owner, repositories, permissions, cacheBust)
}
@function
def example(appid: str, privatekey: dagger.Secret, owner: str, repositories: str, permissions: str, cachebust: str) -> dagger.Secret:
	return (
		dag.identity()
		.githubapptoken(appid, privatekey, owner, repositories, permissions, cachebust)
	)
@func()
example(appId: string, privateKey: Secret, owner: string, repositories: string, permissions: string, cacheBust: string): Secret {
	return dag
		.identity()
		.githubAppToken(appId, privateKey, owner, repositories, permissions, cacheBust)
}

githubAppJwt() 🔗

Sign the App JWT and stop — the first leg of githubAppToken on its own.

Nothing is called over the network, so this works with any RSA key pair and is how the signing path is tested without an App. Returns the JWT as a Secret: it is a bearer credential for the App itself, not a debugging string, and it is not returned in plaintext for the same reason gcpAccessToken does not return one.

To inspect it, export the secret and decode the first two segments — the header and the payload are not the signature.

Return Type
Secret !
Arguments
NameTypeDefault ValueDescription
appIdString !-No description provided
privateKeySecret !-No description provided
cacheBustString !-No description provided
Example
dagger -m github.com/wildbitca/daggerverse/identity@03fc8e57966e67996d137c98319e0bf632238468 call \
 github-app-jwt --app-id string --private-key env:MYSECRET --cache-bust string
func (m *MyModule) Example(appId string, privateKey *dagger.Secret, cacheBust string) *dagger.Secret  {
	return dag.
			Identity().
			Githubappjwt(appId, privateKey, cacheBust)
}
@function
def example(appid: str, privatekey: dagger.Secret, cachebust: str) -> dagger.Secret:
	return (
		dag.identity()
		.githubappjwt(appid, privatekey, cachebust)
	)
@func()
example(appId: string, privateKey: Secret, cacheBust: string): Secret {
	return dag
		.identity()
		.githubAppJwt(appId, privateKey, cacheBust)
}

gcpSecret() 🔗

One secret out of GCP Secret Manager, as a Dagger Secret.

Replaces google-github-actions/get-secretmanager-secrets. The value never becomes a step output, never reaches GITHUB_OUTPUT and never depends on Actions’ add-mask having been called in time.

Takes the access token from gcpAccessToken, which must carry the cloud-platform scope and whose service account needs roles/secretmanager.secretAccessor on the secret — a project-level grant where a per-secret one would do is a finding, not a shortcut.

TEXT secrets only. The payload comes back base64 and is decoded byte-exact inside the container, but it crosses back into the module as a UTF-8 string, so a binary payload (a keystore, a .p12) would be mangled. Mount those as a file from a bucket instead.

Return Type
Secret !
Arguments
NameTypeDefault ValueDescription
accessTokenSecret !-No description provided
projectString !-

project id or number that OWNS the secret — not necessarily the project the service account lives in.

nameString !-

the secret id, e.g. internal-api-secret.

cacheBustString !-

MUST vary per run — see gcpAccessToken. A rotated secret read from cache is a run authenticating with the previous value.

versionString !"latest"

latest (default) or a numeric version. Pinning a version is the only way a rotation cannot change what a run does.

Example
dagger -m github.com/wildbitca/daggerverse/identity@03fc8e57966e67996d137c98319e0bf632238468 call \
 gcp-secret --access-token env:MYSECRET --project string --name string --cache-bust string --version string
func (m *MyModule) Example(accessToken *dagger.Secret, project string, name string, cacheBust string, version string) *dagger.Secret  {
	return dag.
			Identity().
			Gcpsecret(accessToken, project, name, cacheBust, version)
}
@function
def example(accesstoken: dagger.Secret, project: str, name: str, cachebust: str, version: str) -> dagger.Secret:
	return (
		dag.identity()
		.gcpsecret(accesstoken, project, name, cachebust, version)
	)
@func()
example(accessToken: Secret, project: string, name: string, cacheBust: string, version: string): Secret {
	return dag
		.identity()
		.gcpSecret(accessToken, project, name, cacheBust, version)
}

gcpSecrets() 🔗

Several secrets from GCP Secret Manager in one exec, in the ORDER requested.

The returned list matches names element for element — that is the contract, and it is why an empty entry in names throws instead of being skipped: a list that silently loses an element shifts every secret after it by one, and every one of them is still a perfectly valid Secret.

Return Type
[Secret ! ] !
Arguments
NameTypeDefault ValueDescription
accessTokenSecret !-No description provided
projectString !-No description provided
namesString !-

comma-separated, each name or name/version, mirroring the key:project/secret/version syntax of the action this replaces. e.g. internal-api-secret,stream-key/4

cacheBustString !-No description provided
versionString !"latest"No description provided
Example
dagger -m github.com/wildbitca/daggerverse/identity@03fc8e57966e67996d137c98319e0bf632238468 call \
 gcp-secrets --access-token env:MYSECRET --project string --names string --cache-bust string --version string
func (m *MyModule) Example(accessToken *dagger.Secret, project string, names string, cacheBust string, version string) []*dagger.Secret  {
	return dag.
			Identity().
			Gcpsecrets(accessToken, project, names, cacheBust, version)
}
@function
def example(accesstoken: dagger.Secret, project: str, names: str, cachebust: str, version: str) -> List[dagger.Secret]:
	return (
		dag.identity()
		.gcpsecrets(accesstoken, project, names, cachebust, version)
	)
@func()
example(accessToken: Secret, project: string, names: string, cacheBust: string, version: string): Secret[] {
	return dag
		.identity()
		.gcpSecrets(accessToken, project, names, cacheBust, version)
}