diff --git a/.vale/styles/config/vocabularies/DependencyTrack/accept.txt b/.vale/styles/config/vocabularies/DependencyTrack/accept.txt
index d297cf5e..c8c452e7 100644
--- a/.vale/styles/config/vocabularies/DependencyTrack/accept.txt
+++ b/.vale/styles/config/vocabularies/DependencyTrack/accept.txt
@@ -138,6 +138,7 @@ Webex
[Mm]isconfiguration
[Mm]isconfigured
[Mm]ixeway
+[Mm]ulticast
[Nn]amespaced?
[Nn]amespaces
[Nn]amespacing
@@ -181,6 +182,8 @@ cutover
eDirectory
keysets?
keytool
+kubeadm
+kubelet
npm
px
sAMAccountName
diff --git a/docs/assets/images/guides/administration/configuring-workload-identity-federation/create-binding.png b/docs/assets/images/guides/administration/configuring-workload-identity-federation/create-binding.png
new file mode 100644
index 00000000..0e107aab
Binary files /dev/null and b/docs/assets/images/guides/administration/configuring-workload-identity-federation/create-binding.png differ
diff --git a/docs/assets/images/guides/administration/configuring-workload-identity-federation/create-provider.png b/docs/assets/images/guides/administration/configuring-workload-identity-federation/create-provider.png
new file mode 100644
index 00000000..375eda56
Binary files /dev/null and b/docs/assets/images/guides/administration/configuring-workload-identity-federation/create-provider.png differ
diff --git a/docs/concepts/.pages b/docs/concepts/.pages
index 91150b96..846b3335 100644
--- a/docs/concepts/.pages
+++ b/docs/concepts/.pages
@@ -4,6 +4,7 @@ nav:
- About projects: projects.md
- About tags: tags.md
- About access control: access-control.md
+ - About workload identity federation: workload-identity-federation.md
- About vulnerability data sources: about-vulnerability-data-sources.md
- About KEV data sources: about-kev-data-sources.md
- About vulnerability findings: vulnerability-findings.md
diff --git a/docs/concepts/access-control.md b/docs/concepts/access-control.md
index 0c7ff6cf..e3481ad0 100644
--- a/docs/concepts/access-control.md
+++ b/docs/concepts/access-control.md
@@ -18,7 +18,7 @@ Four types of users exist:
| Managed User | A local account created and managed within Dependency-Track. |
| LDAP User | Authenticated via an external LDAP directory. See [Configuring LDAP](../guides/administration/configuring-ldap.md). |
| OIDC User | Authenticated via an OpenID Connect identity provider. See [Configuring OIDC](../guides/administration/configuring-oidc.md). |
-| Service Account | A non-human account for automation. Authenticates with its own API keys. Available in 5.2.0 and later. See [Service accounts](#service-accounts). |
+| Service Account | A non-human account for automation. Authenticates with its own API keys, or through workload identity federation. Available in 5.2.0 and later. See [Service accounts](#service-accounts). |
All user types share the same permission model. The authentication mechanism determines
how the system verifies a user's identity, not what they can access.
@@ -33,10 +33,19 @@ A service account is the identity of a CI pipeline, scanner, script, or other
automated tool. Like any other user, it holds permissions directly or through
team membership, and appears in team member lists.
-A service account can't log in. It authenticates with API keys that it owns.
-Each request made with such a key acts as the service account, so audit records
-show its username. Suspending a service account stops all its API keys from working
-until you lift the suspension. Deleting it also deletes its API keys.
+A service account can't log in. It authenticates in one of two ways:
+
+* **Workload identity federation.** The workload exchanges a short-lived token
+ issued by its own platform, such as GitHub Actions, GitLab CI, Kubernetes, or
+ SPIFFE, for a session of the service account. See
+ [About workload identity federation](workload-identity-federation.md).
+* **API keys** that the service account owns. Each request made with such a key
+ acts as the service account.
+
+Either way, audit records show the service account's username. Suspending a service
+account stops all its API keys and blocks new token exchanges, until you lift the
+suspension. It also ends sessions created through federation at once. Deleting the
+account also deletes its API keys and its workload identity bindings.
Service account usernames always start with `svc:`, for example `svc:ci-pipeline`.
No other user can have a username with that prefix. This prevents a service account
@@ -49,6 +58,13 @@ Compared to team API keys, service accounts:
* Give each automated tool a distinct identity, even when tools share a team.
* Take permissions directly, so narrowing one tool's permissions doesn't need a dedicated team.
* Support suspension, which blocks access without deleting API keys.
+* Can authenticate without any stored credential, through workload identity federation.
+
+!!! tip "The intended path for automation"
+
+ Service accounts combined with [workload identity federation](workload-identity-federation.md)
+ are the strategic replacement for teams that exist only to hold a long-lived API key.
+ Team API keys remain supported. Start new integrations on service accounts.
## Teams
@@ -65,8 +81,9 @@ exceptions that don't justify a dedicated team.
Team API keys belong to a team and carry the same permissions as that team. They authenticate
automated access (CI/CD pipelines, integrations) without associating requests with a
-specific identity. In 5.2.0 and later, [service accounts](#service-accounts) offer an
-alternative with their own identity and permissions.
+specific identity. In 5.2.0 and later, [service accounts](#service-accounts) supersede them:
+they carry their own identity and permissions, and can authenticate without a stored
+credential at all.
Deleting a team also deletes its API keys and any project access assignments
made through it. It keeps projects, user accounts, and API keys owned by service
@@ -205,5 +222,7 @@ and holders of `PORTFOLIO_ACCESS_CONTROL_BYPASS` until one is assigned.
* [Permissions reference](../reference/permissions.md) for the full permissions table
and default teams.
+* [About workload identity federation](workload-identity-federation.md) for how
+ service accounts authenticate without stored credentials.
* [About projects](projects.md) for how Dependency-Track models
projects, hierarchies, and collection projects.
diff --git a/docs/concepts/workload-identity-federation.md b/docs/concepts/workload-identity-federation.md
new file mode 100644
index 00000000..bb7d1d53
--- /dev/null
+++ b/docs/concepts/workload-identity-federation.md
@@ -0,0 +1,101 @@
+# About workload identity federation
+
+!!! note "Available in 5.2.0 and later"
+
+ Earlier versions authenticate automation with team API keys only.
+
+Workload identity federation lets a workload authenticate to Dependency-Track with a token
+that its own platform issued, instead of an API key that someone created and stored.
+A GitHub Actions job, a GitLab CI job, a Kubernetes pod, or a SPIFFE workload already holds a short-lived,
+signed token that states its identity. Dependency-Track verifies that token and,
+if it matches a rule an administrator configured, hands back a session for a [service account](access-control.md#service-accounts).
+
+The workload stores no Dependency-Track credential.
+
+## Why it exists
+
+An API key is a static secret with a long life. Someone creates it, copies it into a CI system,
+and has to remember to delete it when it leaks and to rotate it before it expires.
+Nothing about the key says which pipeline uses it, and a key that escapes the pipeline keeps
+working from anywhere until a human notices.
+
+A platform token has none of those properties. It exists for minutes, it names the exact repository,
+branch, namespace, or workload that received it, and nobody can mint one outside that context.
+
+Service accounts and workload identity federation together are the intended replacement for
+automation that runs on team API keys:
+
+* The **service account** gives the automation an identity of its own, with its own permissions and its own audit trail.
+* **Workload identity federation** gives that identity a credential nobody has to store.
+
+## How an exchange works
+
+```mermaid
+sequenceDiagram
+ participant W as Workload
(CI job, k8s pod)
+ participant P as Platform
(GitHub, GitLab, Kubernetes, SPIRE)
+ participant D as Dependency-Track
+
+ W->>P: Request a token for audience X
+ P-->>W: Signed token (sub, aud, exp, ...)
+ W->>D: Exchange token, naming a provider and a service account
+ D->>P: Fetch the issuer's public signing keys
+ D->>D: Verify signature, issuer, audience, expiry
+ D->>D: Match subject and condition against the bindings
of that service account
+ D-->>W: Session token for the service account
+ W->>D: Upload a BOM with the session token
+```
+
+The workload names both the provider and the service account it wants to act as.
+
+## Providers and bindings
+
+A **workload identity provider** says which issuer to trust. It holds the issuer,
+the audience that tokens must carry, where the issuer's public signing keys come from,
+and how long the sessions it creates last. Providers are instance-wide and only administrators manage them.
+
+A **workload identity binding** says which subject of that provider may act as which service account.
+It belongs to a service account, names a provider, and matches a subject, either exactly or by prefix.
+An optional condition, written in [CEL](../reference/cel-expressions.md),
+narrows the match further by looking at any claim in the token.
+
+Bindings can't point at a human user.
+
+### Subjects and conditions
+
+A binding needs a subject even when it carries a condition, and the reason is the shape of shared issuers.
+GitHub and GitLab.com issue tokens to anyone with an account, and let the requester choose the audience.
+The audience isolates nothing there. Only the subject names the organization,
+so a binding that matched on the condition alone would be open to every other tenant of that platform.
+
+Conditions exist because platforms don't put everything into the subject.
+[GitLab CI](https://docs.gitlab.com/ci/secrets/id_token_authentication/), for example,
+keeps the deployment environment in a separate claim, out of reach of a subject prefix.
+
+A condition is part of the security boundary, in that one that matches more tokens than intended
+lets more workloads act as the service account.
+
+## The resulting session
+
+The exchange returns the same kind of opaque session token that the login endpoints issue,
+carrying all permissions of the service account.
+
+The session lasts for the provider's configured lifetime, which can't exceed 24 hours.
+The subject token proves the workload's identity at the moment of the exchange,
+and short platform tokens make that gap deliberate.
+
+[GitLab CI](https://docs.gitlab.com/ci/secrets/id_token_authentication/) and
+[SPIRE](https://spiffe.io/docs/latest/deploying/spire_server/) issue tokens that expire after five minutes by default,
+and a CI job that uploads a BOM and waits for its analysis takes longer than that.
+
+A session outlives the token it came from, so deleting a binding or a provider doesn't end
+sessions already created. Suspending the service account does, at once.
+
+## Further reading
+
+* [Configuring workload identity federation](../guides/administration/configuring-workload-identity-federation.md)
+ for the procedure and per-platform configuration.
+* [Workload identity reference](../reference/workload-identity.md) for subject matching
+ rules, the condition environment, and token requirements.
+* [About access control](access-control.md#service-accounts) for how service accounts relate
+ to users, teams, and permissions.
diff --git a/docs/guides/administration/.pages b/docs/guides/administration/.pages
index bed2ae90..4e004853 100644
--- a/docs/guides/administration/.pages
+++ b/docs/guides/administration/.pages
@@ -16,6 +16,7 @@ nav:
- configuring-observability.md
- configuring-ldap.md
- configuring-oidc.md
+ - configuring-workload-identity-federation.md
- managing-notification-templates.md
- debugging-notifications.md
- configuring-project-retention.md
diff --git a/docs/guides/administration/configuring-workload-identity-federation.md b/docs/guides/administration/configuring-workload-identity-federation.md
new file mode 100644
index 00000000..699bd771
--- /dev/null
+++ b/docs/guides/administration/configuring-workload-identity-federation.md
@@ -0,0 +1,364 @@
+# Configuring workload identity federation
+
+!!! note "Available in 5.2.0 and later"
+
+ Earlier versions authenticate automation with team API keys only.
+
+Workload identity federation lets a CI job, a Kubernetes pod, or any other workload exchange a token
+issued by its own platform for a Dependency-Track session, so that the workload stores no
+API key. For the model behind it, see
+[About workload identity federation](../../concepts/workload-identity-federation.md).
+
+Creating providers and bindings requires the `ACCESS_MANAGEMENT` permission, or its fine-grained variants.
+
+## Before you start
+
+Collect the following from your platform:
+
+* The **issuer**, for OpenID Connect providers. For SPIFFE, the **trust domain** instead.
+* Whether Dependency-Track can reach the issuer's discovery document over HTTPS.
+ If it can't, you need the issuer's public signing keys as a JSON Web Key Set.
+* The exact `sub` claim of the tokens the workload receives. Print one during a trial run
+ rather than guessing, because platforms change the format.
+
+Pick an **audience** that's unique to this Dependency-Track instance, such as its URL.
+The same audience goes into every provider and into every token request the workload makes.
+
+## Create a service account
+
+Under **Administration > Access Management > Service Accounts**, create the account the
+workload acts as and grant it only the permissions the workload needs,
+such as `BOM_UPLOAD` and `PROJECT_CREATION_UPLOAD`.
+
+Give the workload its own account rather than reusing one.
+The username appears in audit records, and suspending it cuts off exactly one workload.
+
+Don't create an API key for it.
+
+## Register a workload identity provider
+
+Go to **Administration > Access Management > Workload Identity Providers** and select **Create**.
+
+
+
+Fill in the form:
+
+| Field | What to enter |
+|:---------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------|
+| **Name** | A short name such as `github-actions`. Workloads send this name when they exchange a token, and it can't change later. |
+| **Type** | **OpenID Connect** for CI systems, cloud platforms, and Kubernetes. **SPIFFE** for SPIRE and other SPIFFE implementations. It can't change later. |
+| **Issuer** | The exact value of the token's `iss` claim. For SPIFFE, the bare trust domain, such as `example.org`. |
+| **Audience** | The value the token's `aud` claim must contain. |
+| **Signing Keys** | Where the public keys come from. See [Choose a key source](#choose-a-key-source). |
+| **Session Lifetime** | How long sessions from this provider last, in seconds. Between 60 and 86400, and 3600 by default. |
+
+Dependency-Track fetches the keys while saving, and refuses the provider if that fails.
+
+Register the same issuer twice if two groups of workloads need different session lifetimes or different audiences.
+A binding has no lifetime of its own.
+
+### Choose a key source
+
+**Discover from the issuer's OpenID Connect configuration** is the default for OpenID Connect
+providers, and the right choice whenever Dependency-Track can reach the issuer.
+It resolves the key set from the issuer's [discovery document](../../reference/workload-identity.md#key-fetching).
+
+**Fetch from a JWKS URL** skips discovery. Use it for SPIFFE bundle endpoints,
+and for issuers whose discovery document isn't reachable but whose key set is.
+
+**Provide a JWK Set** stores the keys in Dependency-Track and fetches nothing.
+Use it for issuers Dependency-Track can't reach at all, mainly self-managed Kubernetes clusters.
+Inline keys are never refreshed, so update the provider whenever the issuer rotates its keys.
+
+Fetched URLs must use `https`. An issuer inside your network works, subject to the
+[outbound fetch constraints](../../reference/workload-identity.md#outbound-fetch-constraints).
+
+## Bind a subject to the service account
+
+Go back to **Administration > Access Management > Service Accounts**,
+expand the service account, and select the plus icon under **Workload Identity Bindings**.
+
+
+
+| Field | What to enter |
+|:--------------|:------------------------------------------------------------------------------------------------------------------------------------|
+| **Provider** | The provider you registered. |
+| **Subject** | The exact `sub` claim to accept, or a prefix of one. See [subject matching](../../reference/workload-identity.md#subject-matching). |
+| **Condition** | Optional [CEL](../../reference/cel-expressions.md) expression over the token's claims, available as the `claims` map. |
+
+On a shared issuer, make the subject prefix include at least your organization.
+See [Subjects and conditions](../../concepts/workload-identity-federation.md#subjects-and-conditions).
+
+A service account can hold more than one binding. Dependency-Track uses the first that
+matches, in [matching order](../../reference/workload-identity.md#matching-order).
+
+To revoke access, delete the binding. Sessions already issued stay valid until they expire.
+To cut those off too, suspend the service account.
+
+## Exchange the token in the workload
+
+The workload posts its platform token to `/api/v2/oauth/token`, a [RFC 8693](https://www.rfc-editor.org/rfc/rfc8693)
+token exchange endpoint. The request needs no authentication of its own.
+
+```bash
+ACCESS_TOKEN=$(curl -sSf -X POST \
+ "https://dependency-track.example.com/api/v2/oauth/token" \
+ -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
+ -d "subject_token_type=urn:ietf:params:oauth:token-type:jwt" \
+ -d "subject_token=${PLATFORM_TOKEN}" \
+ -d "workload_identity_provider=github-actions" \
+ -d "service_account=ci-pipeline" \
+ | jq -r .access_token)
+```
+
+Use the result as a bearer token:
+
+```bash
+curl -sSf -X POST "https://dependency-track.example.com/api/v1/bom" \
+ -H "Authorization: Bearer ${ACCESS_TOKEN}" \
+ -F "project=${PROJECT_UUID}" \
+ -F "bom=@bom.json"
+```
+
+Exchange once per job and reuse the session for the rest of it. The session lives for the provider's session lifetime,
+and no endpoint renews it. For the full parameter list and the error responses,
+see the operations tagged `OAuth` in the [REST API v2 reference](../../reference/api/v2.md).
+
+## Platform examples
+
+Replace `https://dependency-track.example.com` with your instance URL, and `acme-inc` with your organization.
+
+
+### GitHub Actions
+
+
+GitHub Actions issues OpenID Connect ID tokens from a single issuer shared by every account
+on GitHub.com, so the subject prefix has to carry your organization.
+GitHub's [OIDC reference](https://docs.github.com/en/actions/reference/security/oidc) lists
+the claims and their current formats.
+
+Provider:
+
+| Field | Value |
+|:-------------|:--------------------------------------------------------|
+| Name | `github-actions` |
+| Type | OpenID Connect |
+| Issuer | `https://token.actions.githubusercontent.com` |
+| Audience | `https://dependency-track.example.com` |
+| Signing Keys | Discover from the issuer's OpenID Connect configuration |
+
+Binding, for every repository in the organization:
+
+| Field | Value |
+|:----------|:------------------|
+| Subject | `repo:acme-inc/*` |
+| Condition | (none) |
+
+!!! warning "Two subject formats exist"
+
+ Repositories created after 2026-07-15, and older ones that opt in,
+ use an [immutable subject](https://docs.github.com/en/actions/reference/security/oidc#immutable-subject-claims)
+ that carries numeric IDs, such as `repo:acme-inc@123456/app@456789:ref:refs/heads/main`.
+ A prefix of `repo:acme-inc/*` doesn't match it. Use `repo:acme-inc@123456/*` for those repositories,
+ and add a second binding if your organization has both formats in use.
+
+The job needs the `id-token: write` permission, and requests the token for your audience:
+
+```yaml title=".github/workflows/upload-bom.yml"
+jobs:
+ upload-bom:
+ runs-on: ubuntu-latest
+ permissions:
+ id-token: write
+ contents: read
+ steps:
+ - name: Upload BOM
+ env:
+ DT_URL: https://dependency-track.example.com
+ run: |
+ PLATFORM_TOKEN=$(curl -sSf \
+ -H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
+ "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${DT_URL}" | jq -r .value)
+ # Exchange PLATFORM_TOKEN as shown above.
+```
+
+`ACTIONS_ID_TOKEN_REQUEST_URL` already carries a query string, which is why the audience is preceded by `&`.
+
+### GitLab CI
+
+GitLab CI issues ID tokens from the URL of the GitLab instance.
+On GitLab.com every account shares that issuer, so the subject prefix has to carry your group.
+GitLab's [ID token authentication](https://docs.gitlab.com/ci/secrets/id_token_authentication/) guide
+lists the claims and their current formats.
+
+Provider:
+
+| Field | Value |
+|:-------------|:--------------------------------------------------------|
+| Name | `gitlab` |
+| Type | OpenID Connect |
+| Issuer | `https://gitlab.com`, or the URL of your instance |
+| Audience | `https://dependency-track.example.com` |
+| Signing Keys | Discover from the issuer's OpenID Connect configuration |
+
+The job declares the token and the audience it wants:
+
+```yaml title=".gitlab-ci.yml"
+upload-bom:
+ id_tokens:
+ DT_TOKEN:
+ aud: https://dependency-track.example.com
+ script:
+ - ./upload-bom.sh # Exchanges $DT_TOKEN as shown in "Exchange the token in the workload".
+```
+
+#### Matching on the environment requires a condition
+
+The GitLab subject is `project_path:{group}/{project}:ref_type:{type}:ref:{branch}`.
+It only names the project and the git ref. The deployment environment lives in separate claims,
+so a rule such as "only the job that deploys to production" **doesn't fit into a subject at all**.
+Write it as a condition:
+
+| Field | Value |
+|:----------|:-------------------------------------------------------------------------------|
+| Subject | `project_path:acme-inc/*` |
+| Condition | `claims.environment == "production" && claims.environment_protected == "true"` |
+
+!!! note
+
+ GitLab encodes the boolean-looking claims `environment_protected` and `ref_protected` as the
+ strings `"true"` and `"false"`, so `claims.environment_protected == true` never matches.
+
+The `environment` claim only appears on jobs that declare an `environment:`. A condition that
+reads a missing claim raises an error, and a binding whose condition errors doesn't match,
+so this binding rejects every job that isn't a deployment.
+
+### Kubernetes
+
+A pod authenticates with a [projected service account token][k8s-sa].
+The subject is `system:serviceaccount:{namespace}:{name}`.
+
+A cluster set up with `kubeadm` issues tokens from the cluster-internal issuer
+`https://kubernetes.default.svc.cluster.local`. That name only resolves inside the cluster,
+so Dependency-Track can't run discovery against it. Store the keys inline instead:
+
+```shell
+kubectl get --raw /openid/v1/jwks
+```
+
+Provider:
+
+| Field | Value |
+|:-------------|:------------------------------------------------------|
+| Name | `k8s-prod` |
+| Type | OpenID Connect |
+| Issuer | The cluster's issuer, exactly as it appears in `iss` |
+| Audience | `https://dependency-track.example.com` |
+| Signing Keys | Provide a JWK Set, pasting the output of that command |
+
+Where Dependency-Track can reach the cluster's discovery document, as with many managed offerings,
+use the cluster's issuer URL with discovery and skip the manual key set.
+
+Binding, for one service account:
+
+| Field | Value |
+|:--------|:----------------------------------------|
+| Subject | `system:serviceaccount:ci:bom-uploader` |
+
+The pod asks for a token with the right audience through a projected volume:
+
+```yaml
+spec:
+ serviceAccountName: bom-uploader
+ containers:
+ - name: upload-bom
+ image: acme-inc/upload-bom:1.0.0
+ volumeMounts:
+ - name: dt-token
+ mountPath: /var/run/secrets/dependency-track
+ readOnly: true
+ volumes:
+ - name: dt-token
+ projected:
+ sources:
+ - serviceAccountToken:
+ audience: https://dependency-track.example.com
+ expirationSeconds: 3600
+ path: token
+```
+
+The container reads the token from `/var/run/secrets/dependency-track/token` and sends it as `subject_token`.
+The kubelet refreshes the file, so read it right before each exchange.
+
+### SPIFFE and SPIRE
+
+SPIFFE workloads present [JWT-SVIDs][spiffe-jwt-svid], which may omit the `iss` claim and
+carry their trust domain in the subject instead. Register them with type **SPIFFE**.
+
+Provider:
+
+| Field | Value |
+|:-------------|:----------------------------------------------------------------------|
+| Name | `spire` |
+| Type | SPIFFE |
+| Issuer | `example.org`, the bare trust domain without `spiffe://` |
+| Audience | `https://dependency-track.example.com` |
+| Signing Keys | Fetch from a JWKS URL, pointing at the trust domain's bundle endpoint |
+
+Only bundle endpoints with a publicly trusted certificate work,
+which is the `https_web` profile of [SPIFFE federation][spiffe-federation].
+For a trust domain without such an endpoint, export the bundle and store it inline:
+
+```shell
+spire-server bundle show -format spiffe
+```
+
+Binding:
+
+| Field | Value |
+|:----------|:----------------------------|
+| Subject | `spiffe://example.org/ci/*` |
+| Condition | (none) |
+
+A SPIFFE prefix must end with `/`. Changing the trust domain of the provider stops every one of
+its bindings from matching, so update their subjects afterwards.
+
+The workload fetches its JWT-SVID for the audience from the [SPIRE agent][spire-agent]:
+
+```shell
+spire-agent api fetch jwt -audience https://dependency-track.example.com
+```
+
+## Verify the setup
+
+Run the exchange once from the workload, then confirm the identity that its session carries:
+
+```bash
+curl -sSf "https://dependency-track.example.com/api/v1/user/self" \
+ -H "Authorization: Bearer ${ACCESS_TOKEN}"
+```
+
+The `username` in the response is the service account, prefixed with `svc:`.
+
+The binding's **Last used** timestamp updates on every successful exchange,
+which is the quickest way to tell whether a binding is the one that matched.
+
+## Troubleshooting
+
+The exchange endpoint returns the same `invalid_request` error whatever went wrong,
+so it never names the failing check. The API server log records every refused exchange as a security event,
+including the token's claims when the token verified but no binding matched.
+
+## See also
+
+* [About workload identity federation](../../concepts/workload-identity-federation.md) for
+ the model behind providers, bindings, and sessions.
+* [Workload identity reference](../../reference/workload-identity.md) for subject matching
+ rules, the condition environment, and token requirements.
+* [REST API v2 reference](../../reference/api/v2.md) for request and response schemas.
+* [Permissions](../../reference/permissions.md) for what to grant the service account.
+
+[k8s-sa]: https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/
+[spiffe-federation]: https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Federation.md
+[spiffe-jwt-svid]: https://github.com/spiffe/spiffe/blob/main/standards/JWT-SVID.md
+[spire-agent]: https://spiffe.io/docs/latest/deploying/spire_agent/
diff --git a/docs/guides/upgrading/v5.2.0.md b/docs/guides/upgrading/v5.2.0.md
index 6d51e476..1b30dacb 100644
--- a/docs/guides/upgrading/v5.2.0.md
+++ b/docs/guides/upgrading/v5.2.0.md
@@ -1,8 +1,8 @@
# Upgrading to v5.2.0
-* **Service accounts reserve the `svc:` username prefix**. This release adds [service accounts][access-control-svc],
- a user type for CI pipelines, scripts, and other automation. Their usernames start with `svc:`,
- and no other user can have a username with that prefix, in any letter case.
+* **Service accounts reserve the `svc:` username prefix**. This release adds
+ [service accounts][access-control-svc]. Existing users whose username starts with `svc:`,
+ in any letter case, collide with that reservation.
If such a user exists, the database migration fails and its error message lists the affected users.
To find them before you upgrade, run this query against the database:
diff --git a/docs/includes/abbreviations.md b/docs/includes/abbreviations.md
index 88dcf7da..6d1f23d3 100644
--- a/docs/includes/abbreviations.md
+++ b/docs/includes/abbreviations.md
@@ -10,6 +10,8 @@
*[GHSA]: GitHub Security Advisory
*[IdP]: Identity Provider
*[JVM]: Java Virtual Machine
+*[JWKS]: JSON Web Key Set
+*[JWT]: JSON Web Token
*[KEK]: Key Encryption Key
*[KEV]: Known Exploited Vulnerabilities
*[LDAP]: Lightweight Directory Access Protocol
@@ -23,5 +25,7 @@
*[SARIF]: Static Analysis Results Interchange Format
*[SBOM]: Software Bill of Materials
*[SLO]: Service Level Objectives
+*[SPIFFE]: Secure Production Identity Framework for Everyone
+*[SVID]: SPIFFE Verifiable Identity Document
*[VDR]: Vulnerability Disclosure Report
*[VEX]: Vulnerability Exploitability eXchange
diff --git a/docs/reference/.pages b/docs/reference/.pages
index deeb1273..4d34fe76 100644
--- a/docs/reference/.pages
+++ b/docs/reference/.pages
@@ -14,6 +14,7 @@ nav:
- CEL expressions: cel-expressions.md
- Access control:
- Permissions: permissions.md
+ - Workload identity: workload-identity.md
- Integrations:
- Badges: badges.md
- File formats: file-formats.md
diff --git a/docs/reference/cel-expressions.md b/docs/reference/cel-expressions.md
index 2b89caa7..58d98044 100644
--- a/docs/reference/cel-expressions.md
+++ b/docs/reference/cel-expressions.md
@@ -1,6 +1,6 @@
# CEL Expressions
-Dependency-Track uses the [Common Expression Language] (CEL) in two places:
+Dependency-Track uses the [Common Expression Language] (CEL) in three places:
* **Policy conditions**, evaluated against components or vulnerabilities to drive
policy violations and analyses. See
@@ -8,12 +8,16 @@ Dependency-Track uses the [Common Expression Language] (CEL) in two places:
* **Notification filters**, evaluated against notification subjects to control
which notifications are dispatched. See
[Filter expressions](notifications/filter-expressions.md).
+* **Workload identity binding conditions**, evaluated against the claims of a
+ verified platform token to decide whether a workload may act as a service
+ account. Available in 5.2.0 and later. See
+ [Workload identity](workload-identity.md#condition-environment).
-The two contexts share the syntax described on this page, but expose **different
+The contexts share the syntax described on this page, but expose **different
variables, types, and custom functions**. The custom functions documented for
policies (for example `depends_on`, `spdx_expr_allows`) are not available to
-notification filters, and the variables differ entirely. Refer to the
-context-specific page for the available inputs and functions.
+notification filters or binding conditions, and the variables differ entirely.
+Refer to the context-specific page for the available inputs and functions.
## Syntax
@@ -33,7 +37,8 @@ Both contexts have access to the [standard definitions] of the CEL specification
The policy context registers additional custom functions (`depends_on`, `spdx_expr_allows`,
and so on); see [Condition expressions](policies/condition-expressions.md#function-reference).
-Notification filters do not register any custom functions.
+Notification filters and workload identity binding conditions do not register any custom
+functions.
## Optional field checking
@@ -46,8 +51,8 @@ before accessing it:
has(obj.field) && obj.field == "value"
```
-The pattern applies in both contexts. The exact root variables (`component`, `subject`,
-and so on) are documented on the context-specific reference pages linked above.
+The `has()` macro works in all three contexts. The exact root variables (`component`,
+`subject`, `claims`, and so on) are documented on the context-specific reference pages.
[C-style languages]: https://en.wikipedia.org/wiki/List_of_C-family_programming_languages
[CEL strings extension]: https://github.com/google/cel-spec/blob/master/doc/extensions/strings.md
diff --git a/docs/reference/permissions.md b/docs/reference/permissions.md
index 189db1d7..73eafdcd 100644
--- a/docs/reference/permissions.md
+++ b/docs/reference/permissions.md
@@ -64,11 +64,11 @@ and [Managing project versions](../guides/user/managing-project-versions.md).
| Permission | Description |
|:------------------------------|:--------------------------------------------------------------------|
-| `ACCESS_MANAGEMENT` | Manage users, service accounts, teams, permissions, and API keys. |
-| `ACCESS_MANAGEMENT_CREATE` | Create users, service accounts, teams, and API keys. |
-| `ACCESS_MANAGEMENT_READ` | Read users, service accounts, teams, and API keys. |
-| `ACCESS_MANAGEMENT_UPDATE` | Update users, service accounts, teams, and API keys. |
-| `ACCESS_MANAGEMENT_DELETE` | Delete users, service accounts, teams, and API keys. |
+| `ACCESS_MANAGEMENT` | Manage users, service accounts, teams, permissions, API keys, and workload identity. |
+| `ACCESS_MANAGEMENT_CREATE` | Create users, service accounts, teams, API keys, workload identity providers, and bindings. |
+| `ACCESS_MANAGEMENT_READ` | Read users, service accounts, teams, API keys, workload identity providers, and bindings. |
+| `ACCESS_MANAGEMENT_UPDATE` | Update users, service accounts, teams, API keys, and workload identity providers. |
+| `ACCESS_MANAGEMENT_DELETE` | Delete users, service accounts, teams, API keys, workload identity providers, and bindings. |
### System configuration
diff --git a/docs/reference/workload-identity.md b/docs/reference/workload-identity.md
new file mode 100644
index 00000000..232bcde0
--- /dev/null
+++ b/docs/reference/workload-identity.md
@@ -0,0 +1,125 @@
+# Workload identity
+
+!!! note "Available in 5.2.0 and later"
+
+ Earlier versions authenticate automation with team API keys only.
+
+For the model, see [About workload identity federation](../concepts/workload-identity-federation.md).
+For the procedure, see [Configuring workload identity federation](../guides/administration/configuring-workload-identity-federation.md).
+
+Field constraints, request bodies, responses, and the permission each operation requires are
+in the [REST API v2 reference](api/v2.md), under the operations tagged `Workload Identity Providers`, `OAuth`,
+and the workload identity binding operations tagged `Service Accounts`.
+This page describes the behavior behind those operations.
+
+## Provider types
+
+| Type | `iss` claim | `sub` claim | Key source |
+|:---------|:---------------------------------|:-------------------------------------------|:------------------------------------|
+| `OIDC` | Must equal the provider's issuer | Any value | Discovery, a key set URL, or inline |
+| `SPIFFE` | Ignored | Must start with `spiffe:///` | A bundle endpoint URL, or inline |
+
+The issuer of a `SPIFFE` provider is a bare trust domain, such as `example.org`.
+A key set URL on a `SPIFFE` provider is the trust domain's bundle endpoint,
+which must use the `https_web` profile of SPIFFE federation.
+
+## Key fetching
+
+Discovery reads `/.well-known/openid-configuration`, requires the document to name the same issuer,
+and stores the `jwks_uri` it names. Changing the issuer of an `OIDC` provider that fetches its keys from
+a URL re-runs discovery and replaces the stored URL, unless the update names a key source itself.
+
+Dependency-Track caches fetched key sets and rate-limits how often it refreshes them per provider.
+When a refresh fails, it keeps using the last fetched key set for up to one hour.
+
+### Outbound fetch constraints
+
+Dependency-Track fetches remote documents when you save a provider,
+and during an exchange when a key is missing from the cache. Every fetch obeys the following:
+
+* Only `https` URLs, without user information.
+* After resolving the hostname, Dependency-Track refuses loopback, link-local, wildcard,
+ and multicast addresses. Private ranges stay allowed. When a configured HTTP proxy handles the destination,
+ Dependency-Track skips the address check, because the proxy resolves the name.
+* Dependency-Track doesn't follow redirects.
+* A response must arrive complete within 10 seconds, and must not exceed 1 MiB.
+* Response bodies never appear in API responses or logs.
+
+## Subject matching
+
+A subject is either an exact value or a prefix followed by `*`.
+The prefix must be non-empty and must end with `:` or `/`.
+
+| Subject | Matches |
+|:----------------------------------------|:--------------------------------------------------|
+| `repo:acme-inc/app:ref:refs/heads/main` | That exact `sub` claim. |
+| `repo:acme-inc/*` | Every `sub` starting with `repo:acme-inc/`. |
+| `repo:acme-inc/app*` | Rejected. The prefix doesn't end with `:` or `/`. |
+| `spiffe://example.org/ci/*` | Every SPIFFE ID under `spiffe://example.org/ci/`. |
+| `spiffe://example.org/ci*` | Rejected. A SPIFFE prefix must end with `/`. |
+
+For `SPIFFE` providers, the subject must lie within the provider's trust domain.
+
+To match a prefix that ends mid-segment, use an exact subject prefix plus a condition,
+such as `claims.sub.startsWith("repo:acme-inc/app-")`.
+
+## Condition environment
+
+Conditions are [CEL](cel-expressions.md) expressions.
+Dependency-Track compiles and type-checks them when you save the binding.
+They see one variable:
+
+| Variable | Type | Contents |
+|:---------|:-------------------|:------------------------------------------|
+| `claims` | `map(string, dyn)` | The claims of the verified subject token. |
+
+The [CEL strings extension](https://github.com/google/cel-spec/blob/master/doc/extensions/strings.md) is available.
+Dependency-Track registers no custom functions here.
+
+An expression must return a boolean. Because claim values are dynamic,
+`claims.admin` alone doesn't compile. Write `claims.admin == true` instead.
+
+At exchange time, a condition that raises an error, for example because a claim is absent, doesn't match.
+Guard optional claims with `has(claims.name)` where the binding should still match tokens that omit them.
+Claim values keep their JSON types, and platforms that encode booleans as strings need string comparisons.
+
+```js
+claims.ref == "refs/heads/main"
+
+claims.environment == "production" && claims.environment_protected == "true"
+
+claims.sub.startsWith("repo:acme-inc/app-")
+```
+
+## Matching order
+
+Dependency-Track evaluates only the bindings of the service account that the request names,
+and only those belonging to the named provider. It tries them from oldest to newest and uses
+the first whose subject and condition both match. Duplicate and overlapping bindings do no harm,
+because the resulting session is the same either way.
+
+## Subject token requirements
+
+A subject token must be a signed JWT that meets these requirements:
+
+| Property | Value |
+|:--------------------|:---------------------------------------------------------------------------------------|
+| Header `typ` | `JWT`, `JOSE`, `at+jwt`, or absent. |
+| Signature algorithm | `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `ES256`, `ES384`, or `ES512`. |
+| Required claims | `exp` and `sub`. |
+| `aud` | Must contain the provider's audience. |
+| `iss` | Must equal the provider's issuer for `OIDC` providers. Ignored for `SPIFFE` providers. |
+
+Dependency-Track refuses symmetric and unsigned tokens. It accepts keys marked for `jwt-svid` use,
+as SPIFFE bundles mark them, alongside keys marked for signature use.
+
+## Audit events
+
+The API server logs the following as security events:
+
+* Creating, updating, and deleting providers and bindings.
+* Successful exchanges, naming the provider, the service account, the matched binding,
+ the token's subject, and its `jti` if it has one.
+* Refused exchanges. When the token verified but no binding matched, the log also carries the token's claims.
+
+Subject tokens are never logged.