Skip to content

Units and run scope

Tenants, projects, API keys describes the three nested objects your CI authenticates against: a tenant owns policy and audit, a project partitions it, and an API key (vk_…) authenticates each request. That model answers “whose traffic is this, and under what policy?”. It does not answer a question a security lead asks after the fact:

“Show me everything one run of one package pulled — what resolved, what installed, what got blocked.”

This page adds the level that answers it. A unit sits one rung below a project, and a run is a single CI execution attributed to a unit. The pairing gives you a run-scoped audit lens: not the org’s whole package history, but the dependency footprint of one build. This page defines the unit, explains how a run gets mapped to it through verified identity, and is honest about the one thing this lens is not — complete.

A unit is a single independently-buildable package.

  • A repository with exactly one package (one package.json, one pyproject.toml, one Cargo.toml, …) is a single unit. The repo and the unit are the same thing.
  • A monorepo — a repository with more than one package — is many units. Each package is its own unit, resolved and installed on its own dependency graph, blocked or allowed on its own merits.

The distinction matters because a monorepo’s web/ frontend and its services/billing/ backend pull completely different dependency sets, on different ecosystems, on different release cadences. Rolling them up to “the repo” would bury a CRITICAL block in services/billing/ under a wall of unrelated frontend installs. Splitting on the unit keeps each package’s exposure legible.

tenant one customer
└── project one repo / pipeline (the API key's scope)
└── unit one package (monorepo → many units)
└── run one CI execution attributed to that unit

A single-package repo collapses the middle: the project has one unit, and every run maps to it. The model doesn’t change — the monorepo just makes the unit level visible.

A run is mapped to a unit by the verified identity the CI runner presents — the same keyless-OIDC trust layer that stamps CI runner attribution onto every audit row.

When a runner installs through the proxy, the request can carry an OIDC token signed by the CI provider. Upwarden verifies it (see the seven rules) and reads the attested claims — repository, ref, commit, workflow, run_id, and the human or bot that triggered it. Those claims, joined on the run identifier, are what let Upwarden say “these installs belong to this run of this unit” rather than merely “some key installed these packages”.

The precision of the mapping tracks the trust tier of the identity, exactly as it does for attribution:

Identity on the requestRun mapping
Verified (oidc_github / oidc_gitlab / oidc_circleci)The run is pinned to the unit by cryptographically-attested repo + ref + run id. This is the tier the per-unit lens is built for.
Asserted (header_unverified)The unit comes from unverified x-upwarden-ci-* headers — usable for grouping, but the run boundary is taken on faith.
Key-only (key_bound)No run identity. The install is attributed to the project and its key, but there’s nothing to pin it to a specific run — so it doesn’t populate a run-scoped view.

Wiring the runner so its identity is verified is the prerequisite for a trustworthy unit view. That wire-up is covered end-to-end in CI setup — do that first; the per-unit lens is only as sharp as the identity feeding it.

Once a run is mapped to its unit, Upwarden can render a view scoped to that run. The lens is best read as a single sentence:

“Everything that is <unit> — the dependency set this run pulled: every package it resolved and installed, transitively, plus everything the proxy blocked on the way.

Three parts, each load-bearing:

  1. Resolved and installed, transitively. Not just the direct dependencies in the manifest — the flattened graph the package manager actually fetched through the proxy, transitive dependencies included. This is the real install footprint, the set an attacker’s payload would have had to hide inside.
  2. Everything blocked. The packages the proxy refused to serve for this run — the BLOCKED and QUARANTINED decisions. These are the point of the lens. A clean install list tells you what shipped; the blocked list tells you what a supply-chain feed, a cooldown window, a size-jump heuristic, or your own policy kept out. (What each decision means: Verdicts and the scanner.)
  3. One run. The window is a single CI execution, not the project’s lifetime and not the org’s. Two runs of the same unit an hour apart are two lenses; a dependency added between them shows up in the second and not the first.

The value of scoping to the run is that it makes exposure answerable. “Is unit services/billing affected by GHSA-xxxx?” stops being a grep across the whole org’s audit log and becomes “open the last run of that unit and read its list.”

Here is the one claim this lens will never make: that it is complete.

Upwarden is a proxy. It sees a package because a package manager asked it for that package. It observes traffic — and traffic is a lower bound on truth, not the whole of it. Several ordinary things put a real dependency outside what the proxy saw:

  • A dependency served from a local or CI cache never crosses the proxy on that run, so the proxy never observes it.
  • A package manager not yet pointed at the proxy (a tool the runner uses that isn’t wired) pulls its dependencies invisibly.
  • A vendored or committed-in dependency was never fetched at all.
  • A run that partially failed pulled only part of its graph before it stopped.

So the run-scoped view is stated as “most observed” — the dependencies this run pulled through Upwarden, which is most of them for a well-wired unit, and demonstrably a lower bound in general. Every per-unit surface that shows a footprint carries this caveat in-band; see the “most observed” banner on the dashboard page.

The honest framing is a feature, not a hedge. A lower bound you can trust is worth more to an incident responder than a “complete” list that quietly dropped the cached dependency the attacker was counting on.

Nothing on the tenants, projects, keys page changes. The unit and the run are additive:

  • Policy and authority stay at the project and its key. A unit inherits its project’s policy; it doesn’t get its own.
  • The audit row is still the canonical record. The unit lens doesn’t replace the audit log — it’s a grouping over the same rows, sliced by the run identity that CI attribution already stamps on them.
  • The API key is unchanged. A monorepo still authenticates with its project key; the many units inside it are discovered from the run identity, not from many keys.

Read the three pages as one stack: tenants/projects/keys for who and under what policy, CI attribution for which verified run, and this page for what that run’s unit pulled.

  • Dashboard → Units — how to read the per-unit lens: open it, blocked-first, each block traced to its advisory or policy reason, and the “most observed” caveat in place.
  • CI runner attribution — the trust tiers and the OIDC layer that make the run mapping trustworthy.
  • CI setup — wire the runner so its identity is verified; the prerequisite for a sharp unit view.
  • Tenants, projects, API keys — the three levels the unit sits below.