diff --git a/src/content/authentication/dynamic-oidc-mapping.mdx b/src/content/authentication/dynamic-oidc-mapping.mdx new file mode 100644 index 00000000..7f9db757 --- /dev/null +++ b/src/content/authentication/dynamic-oidc-mapping.mdx @@ -0,0 +1,198 @@ +--- +title: Dynamic OIDC Mapping +description: Map a single OIDC provider configuration to many Cloudsmith service accounts based on a claim value from the incoming JWT. +--- + +import { Note } from '@/components' + +# Dynamic OIDC Mapping + +Cloudsmith's [standard OIDC configuration](/authentication/openid-connect) creates a one-to-one trust relationship between a single provider configuration and a fixed set of service accounts. Authentication succeeds for any token that matches the required claims, and every authenticated request is mapped to the same service account(s). + +This works well at small scale. It does not scale to scenarios where each pipeline, repository, or project needs its own service account. A separate provider configuration per service account quickly produces hundreds (or thousands) of near-identical entries — every one of which differs only by a single claim value and the service account it points to. + +**Dynamic OIDC mapping** condenses these into a single provider configuration. You nominate one claim from the incoming JWT as the _mapping claim_, and Cloudsmith routes each request to a service account based on that claim's value. + +## When to use it + +Use dynamic mapping when **all** of the following are true: + +- You want a dedicated Cloudsmith service account per pipeline, repository, project, or other CI-side scope. +- All of those service accounts authenticate via the same OIDC provider (e.g. all GitHub Actions, all GitLab CI, all Buildkite). +- The CI provider's JWT contains a claim whose value uniquely identifies each scope (e.g. `repository`, `project_path`, `pipeline_slug`). + +Use the **standard (static) configuration** when you only need a small number of providers, or when a single provider should always authenticate as the same service account(s). + +## How it works + +A dynamic configuration adds two fields on top of the standard provider configuration: + +| Field | Description | +| ------------------ | -------------------------------------------------------------------------------------------- | +| `mapping_claim` | The claim in the incoming JWT whose value Cloudsmith will use to choose a service account. | +| `dynamic_mappings` | A list of `claim_value` → `service_account` pairs. | + +`claims` remains the security boundary that gates which tokens are eligible to authenticate at all. `mapping_claim` and `dynamic_mappings` are the routing logic that selects a service account from within that boundary. + +On every authentication request, Cloudsmith: + +1. Verifies the JWT against the provider URL, exactly as it does for a static configuration. +2. Verifies that the JWT contains every claim listed in `claims` (these still act as a gate — typically `iss` and any organization-level scoping). +3. Reads the value of `mapping_claim` from the JWT. +4. Looks that value up in `dynamic_mappings` and, if a match is found, issues a Cloudsmith token for the mapped service account. + +If the value of `mapping_claim` does not match any entry in `dynamic_mappings`, the request is rejected. As with all OIDC token exchange failures, the error returned is deliberately generic and does not indicate which check failed. + +## Examples + +### GitHub Actions + +A token issued by GitHub Actions includes a `repository` claim of the form `owner/repo`. A configuration that gives each repository its own service account looks like this: + +```json +{ + "name": "GitHub Actions", + "provider_url": "https://token.actions.githubusercontent.com", + "enabled": true, + "claims": { + "repository_owner": "my-org" + }, + "mapping_claim": "repository", + "dynamic_mappings": [ + { "claim_value": "my-org/service-a", "service_account": "service-a-ci" }, + { "claim_value": "my-org/service-b", "service_account": "service-b-ci" }, + { "claim_value": "my-org/service-c", "service_account": "service-c-ci" } + ], + "service_accounts": [] +} +``` + +`claims` still scopes the configuration — here, only tokens whose `repository_owner` is `my-org` are eligible at all. `mapping_claim` and `dynamic_mappings` then select the service account from the candidates inside that scope. `service_accounts` is left empty, because the service account is now chosen per request. + +### GitHub Actions (per workflow) + +A GitHub Actions JWT also includes a `workflow` claim, which lets you scope authentication more tightly than per repository. This is useful when a single repository contains workflows that need different levels of access — for example, a release workflow that needs to publish, and a CI workflow that only needs to pull: + +```json +{ + "name": "GitHub Actions (per workflow)", + "provider_url": "https://token.actions.githubusercontent.com", + "enabled": true, + "claims": { + "repository": "my-org/my-repo" + }, + "mapping_claim": "workflow", + "dynamic_mappings": [ + { "claim_value": "release", "service_account": "my-repo-release" }, + { "claim_value": "ci", "service_account": "my-repo-ci" } + ], + "service_accounts": [] +} +``` + +### GitLab CI + +GitLab tokens contain a `sub` claim that combines the project path with the branch reference (`project_path:my-group/my-project:ref_type:branch:ref:main`). For per-project mapping, use the `project_path` claim instead: + +```json +{ + "name": "GitLab CI", + "provider_url": "https://gitlab.com", + "enabled": true, + "claims": { + "namespace_path": "my-group" + }, + "mapping_claim": "project_path", + "dynamic_mappings": [ + { "claim_value": "my-group/service-a", "service_account": "service-a-ci" }, + { "claim_value": "my-group/service-b", "service_account": "service-b-ci" } + ], + "service_accounts": [] +} +``` + +### Buildkite + +Buildkite's JWT includes a `pipeline_slug` claim, which is the most common mapping target: + +```json +{ + "name": "Buildkite", + "provider_url": "https://agent.buildkite.com", + "enabled": true, + "claims": { + "organization_slug": "my-org" + }, + "mapping_claim": "pipeline_slug", + "dynamic_mappings": [ + { "claim_value": "deploy-prod", "service_account": "deploy-prod-ci" }, + { "claim_value": "deploy-staging", "service_account": "deploy-staging-ci" } + ], + "service_accounts": [] +} +``` + +## Configuring via API + + + Dynamic OIDC mapping is currently configurable via the API and the [Cloudsmith Terraform provider](https://registry.terraform.io/providers/cloudsmith-io/cloudsmith/latest/docs/resources/oidc) only. + + +Create a dynamic provider configuration with a `POST` to the OIDC endpoint: + +```bash +curl --request POST \ + --url https://api.cloudsmith.io/orgs//openid-connect/ \ + --header 'X-Api-Key: ' \ + --header 'accept: application/json' \ + --header 'content-type: application/json' \ + --data '{ + "name": "GitHub Actions", + "provider_url": "https://token.actions.githubusercontent.com", + "enabled": true, + "claims": { "repository_owner": "my-org" }, + "mapping_claim": "repository", + "dynamic_mappings": [ + { "claim_value": "my-org/service-a", "service_account": "service-a-ci" }, + { "claim_value": "my-org/service-b", "service_account": "service-b-ci" } + ] + }' +``` + +You can list the dynamic mappings on an existing provider with the [list dynamic mappings endpoint](https://docs.cloudsmith.com/api/openid-connect/dynamic-mappings/list). + +## Configuring via Terraform + +The `cloudsmith_oidc` resource accepts `mapping_claim` and `dynamic_mappings` from provider version `v0.0.63` onwards. A typical pattern is to drive `dynamic_mappings` from a `for_each` over the repositories or pipelines you manage elsewhere in your Terraform configuration: + +```hcl +resource "cloudsmith_oidc" "github_actions" { + namespace = data.cloudsmith_organization.my_org.slug + name = "GitHub Actions" + provider_url = "https://token.actions.githubusercontent.com" + enabled = true + + claims = { + repository_owner = "my-org" + } + + mapping_claim = "repository" + + dynamic "dynamic_mappings" { + for_each = var.repositories + content { + claim_value = "my-org/${dynamic_mappings.value.name}" + service_account = cloudsmith_service.this[dynamic_mappings.value.name].slug + } + } +} +``` + +This collapses what would otherwise be one `cloudsmith_oidc` resource per repository into a single resource whose mappings are generated from the same data structure that provisions the service accounts. + +## Notes and limitations + +- **One mapping claim per provider.** Each provider configuration uses exactly one `mapping_claim`. If you need to route on different claims for different sets of pipelines, create separate provider configurations. +- **Static and dynamic are mutually exclusive on a single configuration.** A provider is either static (uses `service_accounts`) or dynamic (uses `mapping_claim` + `dynamic_mappings`). Set `service_accounts` to an empty array when using dynamic mapping. +- **Claim values match literally.** Each `claim_value` is compared as a literal string against the incoming JWT, except for the trailing-wildcard syntax (`prefix.*`), which is supported in the same way as it is for `claims` values. +- **Client log enrichment.** Client logs currently record only that a service account was used; they do not record the OIDC claim values that were exchanged for that token. \ No newline at end of file diff --git a/src/content/authentication/openid-connect.mdx b/src/content/authentication/openid-connect.mdx index d9d640a2..b290a22d 100644 --- a/src/content/authentication/openid-connect.mdx +++ b/src/content/authentication/openid-connect.mdx @@ -63,6 +63,10 @@ To configure a provider, you must provide: Changes will be applied immediately. + + If you need a single provider configuration to authenticate as many different service accounts based on a claim value, see [Dynamic OIDC Mapping](/authentication/dynamic-oidc-mapping). + + ## Provider Documentation ### Bitbucket Pipelines diff --git a/src/content/menu.json b/src/content/menu.json index 4ee32340..ab7e98d2 100644 --- a/src/content/menu.json +++ b/src/content/menu.json @@ -281,6 +281,10 @@ { "title": "Jenkins", "path": "/authentication/setup-jenkins-to-authenticate-to-cloudsmith-using-oidc" + }, + { + "title": "Dynamic OIDC Mapping", + "path": "/authentication/dynamic-oidc-mapping" } ] } diff --git a/src/lib/highlight/server.ts b/src/lib/highlight/server.ts index caa5e23a..a7979ecf 100644 --- a/src/lib/highlight/server.ts +++ b/src/lib/highlight/server.ts @@ -33,6 +33,7 @@ export async function getHighlighter() { 'scss', 'ruby', 'csv', + 'hcl', ], }); }