Skip to content

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.

Every audit_log row carries one of these values in actor_source:

actor_sourceTrust tierWhat it means
key_boundAuthorityThe 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_unverifiedAssertedThe 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_circleciVerifiedThe 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”.

The forensic difference between key_bound and oidc_github is the difference between:

“Project key vk_abc... triggered a SAFE install of lodash@4.17.21 at 14:02.”

and

“GitHub Actions workflow acme/payments → release.yml triggered by user jdoe on commit 7f3a9c2 (PR #482, ref refs/heads/main) used project key vk_abc... to install lodash@4.17.21 at 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:

  1. 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 the key_bound tier. A SIEM rule that alerts on “key has historical oidc_github activity but suddenly sends key_bound traffic” catches the leak the moment it’s used.

  2. Repo rename / takeover. GitHub’s repository claim is a string — it’s mutable, you can rename a repo and the new owner inherits it. OIDC trust bindings pin on repository_id, GitHub’s immutable numeric ID. A renamed repo’s tokens stop matching your binding the instant the new owner mints one.

  3. Schedule / workflow_dispatch triggers. A pull_request token implies provenance (it was triggered by a PR event). A workflow_dispatch token can be triggered manually by anyone with Actions: write on the repo. The OIDC layer lets you allowlist exactly the event_name values a binding accepts — clamp to pull_request and push, refuse the rest.

A row stamped oidc_github was produced by:

  1. A bearer API key that authenticated the request — the same authority gate as every other request. OIDC never grants access; it only annotates.
  2. 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.
  3. A sub claim that matches an active row in your tenant’s OIDC trust bindings — full string match, no prefix, no wildcards.
  4. A repository_id that matches the binding’s pinned numeric ID.
  5. An event_name that’s either unrestricted (binding’s event_names is []) or strictly allowlisted (binding’s event_names contains the claim).
  6. A signature that verified.
  7. An exp in the future and iat / nbf not 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.

  • 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 sub claim is matched in FULL against the binding. repo:acme/* is not a valid binding today (the column name sub_pattern is forward-looking but the verifier does exact-equal comparison only).

When Upwarden composes the audit row’s actor block, it reads (in priority order):

  1. 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.
  2. 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_source becomes header_unverified so a downstream reader knows the fields are unattested.
  3. Bearer key only — when neither OIDC nor CI headers are present, actor_source = key_bound and 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.

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 JSON POST to /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.