CI runner attribution
When a CI runner installs a package through Upwarden, the audit row records what happened (decision, package, version, ecosystem) and who did it. The “who” is the actor — a column on every audit_log row called actor_source that names how confident Upwarden is in the identity behind the request.
This page covers the actor model: the three trust tiers, what each one means, and the OIDC trust layer that lets you upgrade your CI’s attribution from “the key that was used” to “the workflow / repo / commit / human that triggered the run”. The companion how-to walks the wiring step-by-step — see Wire your CI for verified attribution.
Three trust tiers
Section titled “Three trust tiers”Every audit_log row carries one of these values in actor_source:
actor_source | Trust tier | What it means |
|---|---|---|
key_bound | Authority | The bearer API key authenticated. We know which key was used, and the key’s project / team scope is on the row. We do NOT know who’s holding the key (a CI runner, an engineer’s laptop, a copy in a hijacked repo). |
header_unverified | Asserted | The request sent an x-upwarden-ci-* header asserting CI context, but no OIDC token. The fields are taken at face value — fine for log enrichment, not safe for forensic conclusions. |
oidc_github / oidc_gitlab / oidc_circleci | Verified | The request sent an OIDC token signed by the named provider. Every claim (workflow, repo, ref, commit, the human who triggered it) is cryptographically attested. |
The escalation is opt-in per request. A CI runner that never sends an OIDC token stays at key_bound and the audit log captures what it can. A CI runner that wires the OIDC path lifts every row from “we know the key” to “we know the run”.
Why upgrade to verified attribution
Section titled “Why upgrade to verified attribution”The forensic difference between key_bound and oidc_github is the difference between:
“Project key
vk_abc...triggered a SAFE install oflodash@4.17.21at 14:02.”
and
“GitHub Actions workflow
acme/payments → release.ymltriggered by userjdoeon commit7f3a9c2(PR #482, refrefs/heads/main) used project keyvk_abc...to installlodash@4.17.21at 14:02.”
The second row is what your auditors, your incident-response team, and your SOC actually need.
Three concrete attack classes the verified tier closes:
-
Stolen API key. Without OIDC, a leaked
vk_…is silent — the audit log shows the key in use, but not from where. With OIDC, requests that don’t carry a valid token from your bound issuer are still authorized by the key, but they ride thekey_boundtier. A SIEM rule that alerts on “key has historicaloidc_githubactivity but suddenly sendskey_boundtraffic” catches the leak the moment it’s used. -
Repo rename / takeover. GitHub’s
repositoryclaim is a string — it’s mutable, you can rename a repo and the new owner inherits it. OIDC trust bindings pin onrepository_id, GitHub’s immutable numeric ID. A renamed repo’s tokens stop matching your binding the instant the new owner mints one. -
Schedule / workflow_dispatch triggers. A
pull_requesttoken implies provenance (it was triggered by a PR event). Aworkflow_dispatchtoken can be triggered manually by anyone withActions: writeon the repo. The OIDC layer lets you allowlist exactly theevent_namevalues a binding accepts — clamp topull_requestandpush, refuse the rest.
What “verified” actually buys you
Section titled “What “verified” actually buys you”A row stamped oidc_github was produced by:
- A bearer API key that authenticated the request — the same authority gate as every other request. OIDC never grants access; it only annotates.
- A JWT signed by GitHub’s JWKS, verified against the issuer’s pinned audience (
upwarden.io) and one of the algorithm-pinned RSA / ECDSA curves. - A sub claim that matches an active row in your tenant’s OIDC trust bindings — full string match, no prefix, no wildcards.
- A
repository_idthat matches the binding’s pinned numeric ID. - An
event_namethat’s either unrestricted (binding’sevent_namesis[]) or strictly allowlisted (binding’sevent_namescontains the claim). - A signature that verified.
- An
expin the future andiat/nbfnot in the future (60-second clock-skew tolerance).
All seven rules must hold. Any single rule failure → 401 with a structured reject reason; no audit row lands at all. Default-deny by absence: an OIDC token from a tenant with no bindings always rejects.
What “verified” does NOT mean
Section titled “What “verified” does NOT mean”- Not authorization. A token that passes all seven rules still requires the bearer API key for the request to do anything. OIDC adds an identity stamp; it cannot grant access on its own.
- Not multi-tenant. A binding belongs to one tenant. A token verified for tenant A cannot stamp claims onto tenant B’s audit rows.
- Not a substitute for key rotation. If a key leaks, OIDC attribution helps you detect and forensically reconstruct the misuse — but the key still needs rotating. The two controls layer; they don’t replace each other.
- Not a wildcard match. The
subclaim is matched in FULL against the binding.repo:acme/*is not a valid binding today (the column namesub_patternis forward-looking but the verifier does exact-equal comparison only).
The three claim sources, layered
Section titled “The three claim sources, layered”When Upwarden composes the audit row’s actor block, it reads (in priority order):
- OIDC claims — when present and verified, these win. The actor identity is
claims.actor(the human / bot that triggered the run on GitHub Actions); the CI context fields (workflow/run_id/repository/ref/commit/pr_number/event_name) all come from the verified payload. - Asserted CI headers (
x-upwarden-ci-workflow,x-upwarden-ci-run-id, etc.) — when no OIDC token is present, these fill in what they can.actor_sourcebecomesheader_unverifiedso a downstream reader knows the fields are unattested. - Bearer key only — when neither OIDC nor CI headers are present,
actor_source = key_boundand the only identity signal is the key’s scope (project + team).
The same audit row format carries all three tiers. A query that filters WHERE actor_source LIKE 'oidc_%' returns only the verified rows; a query that doesn’t filter returns everything.
The tradeoffs
Section titled “The tradeoffs”OIDC attribution adds two pieces of work to your CI:
- One workflow-level config — request the OIDC token from the CI provider (one YAML line on GitHub Actions, one CI variable on GitLab, …).
- One trust binding in Upwarden per
(repo, ref, event)triple you want to attribute. The binding is a single JSONPOSTto/api/v1/admin/orgs/<slug>/oidc-bindings.
In exchange you get:
- Audit rows that survive credential rotation, key theft, and ownership changes.
- A SIEM filter knob (
actor_source LIKE 'oidc_%') that separates verified from asserted from key-only. - The cryptographic provenance trail compliance frameworks (SOC 2 CC8, ISO 27001 A.9.4) expect for “infrastructure changes triggered by automation”.
For most customers the right cadence is “high-trust pipelines first” — your production deploy workflow, your release / publish workflows, anything that triggers regulated traffic. Lower-trust pipelines (dev branches, draft PRs) can stay on key_bound and use bindings later if it matters.
What’s next
Section titled “What’s next”- Wire your CI for verified attribution — the step-by-step wire-up for GitHub Actions, with GitLab / CircleCI as planned providers.
- OIDC trust bindings — admin API reference —
oidc-bindingsCRUD endpoints with example requests and the reject reason catalog. - Dashboard → Audit log — the
actor_sourceandci_contextcolumns surface on every row. - Querying the audit log — JSON-API recipes including “show me only OIDC-verified rows in the last 30 days”.