> ## Documentation Index
> Fetch the complete documentation index at: https://docs.knoxcall.com/llms.txt
> Use this file to discover all available pages before exploring further.

# HCP Terraform

> Authenticate a Terraform run to KnoxCall with zero stored secrets, using HCP Terraform workload identity and RFC 8693 token exchange

# HCP Terraform Workload Identity

An HCP Terraform run can exchange its own workload identity token for a short-lived KnoxCall access token — no KnoxCall API key stored as a workspace variable, and nothing to rotate.

HCP Terraform is one of the three issuers KnoxCall knows by name, so a binding here is matched on `sub` rather than falling back to a generic rule. `sub` carries the organisation, project, workspace **and run phase**, which is what makes the strongest recommendation on this page possible: a `plan` can be given different access from an `apply`.

<Note>
  Unlike GitHub Actions, Google Cloud or AWS, the KnoxCall SDKs do **not** auto-detect an HCP
  Terraform run — the token is handed to you in an environment variable, so you pass it to the
  exchange yourself. Everything on this page uses that variable directly.
</Note>

## 1. Turn on workload identity in the workspace

Set a workspace variable — **Workspace → Variables → Environment variable**:

| Variable                         | Value          |
| -------------------------------- | -------------- |
| `TFC_WORKLOAD_IDENTITY_AUDIENCE` | `knoxcall:api` |

Once it is set, every plan and apply gets a `TFC_WORKLOAD_IDENTITY_TOKEN` variable in its run environment containing a signed OIDC token for that audience.

If the workspace already uses workload identity for a cloud provider, do not overwrite the existing audience — add a second one instead. `TFC_WORKLOAD_IDENTITY_AUDIENCE_KNOXCALL` produces `TFC_WORKLOAD_IDENTITY_TOKEN_KNOXCALL` alongside it, and the tag may contain only letters, numbers and underscores.

## 2. Register the binding in KnoxCall

Dashboard → **Settings → API → Workload Identity**, or call the admin API. The `/admin/*` routes are served on the admin host and authenticated by your logged-in admin/owner session — a session JWT plus the `X-Tenant-ID` header — not an API key against `api.knoxcall.com`.

<Note>
  Creating a binding requires a **recent step-up verification** (passkey, TOTP or emailed code)
  within the last 5 minutes: a binding is a trust that lets an external workload mint tenant
  tokens, so it carries the same bar as creating an OAuth client. A `curl` carrying only a
  session JWT answers `403 {"requires_step_up": true}` — verify in the Dashboard, then replay
  inside the window. Each verification is single-use. Listing and revoking bindings need no
  verification; containment is never gated.
</Note>

```bash theme={"dark"}
curl -X POST https://admin.knoxcall.com/admin/oauth/workload-bindings \
  -H "Authorization: Bearer $SESSION_JWT" \
  -H "X-Tenant-ID: $TENANT_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "oauth_client_id": "<your_oauth_client_id>",
    "name": "tfc-platform-prod-apply",
    "issuer": "https://app.terraform.io",
    "audience": "knoxcall:api",
    "attribute_conditions": {
      "sub": "organization:acme:project:platform:workspace:prod:run_phase:apply"
    },
    "allowed_scopes": ["routes:write", "secrets:read"],
    "access_token_ttl_seconds": 3600
  }'
```

**`attribute_conditions` is required**, and for this issuer it must pin `sub`. Without it the binding would describe every workspace in every organisation on `app.terraform.io`, not yours.

**`allowed_scopes` is required and may not be empty.** An empty allow-list is not "no access" — a token with no scopes is treated as *unnarrowed*, so it would reach every endpoint your tenant has. Creating one is refused with `400 invalid_allowed_scopes`.

## 3. Exchange the token inside the run

```hcl theme={"dark"}
data "http" "knoxcall_token" {
  url    = "https://api.knoxcall.com/oauth/token"
  method = "POST"

  request_headers = {
    Content-Type = "application/x-www-form-urlencoded"
  }

  request_body = join("&", [
    "grant_type=urn:ietf:params:oauth:grant-type:token-exchange",
    "subject_token_type=urn:ietf:params:oauth:token-type:id_token",
    "subject_token=${urlencode(var.tfc_workload_identity_token)}",
  ])
}
```

…with the token passed in as a variable:

```hcl theme={"dark"}
variable "tfc_workload_identity_token" {
  type      = string
  sensitive = true
  # Populated from the run environment: TF_VAR_tfc_workload_identity_token,
  # or read the TFC_WORKLOAD_IDENTITY_TOKEN environment variable in a wrapper.
}
```

Or, from a `local-exec` / CI wrapper around the run:

```bash theme={"dark"}
KNOX_TOKEN=$(curl -sS https://api.knoxcall.com/oauth/token \
  -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  -d subject_token_type=urn:ietf:params:oauth:token-type:id_token \
  -d subject_token="$TFC_WORKLOAD_IDENTITY_TOKEN" | jq -r .access_token)
```

The response is a standard OAuth token response; `access_token` is a `kc_` token valid for the binding's TTL, capped at twice the remaining life of the Terraform token.

## What's in the token

| Claim                         | Example                                                             | Use for                                                                |
| ----------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `sub`                         | `organization:acme:project:platform:workspace:prod:run_phase:apply` | **Required.** Organisation, project, workspace and phase in one string |
| `terraform_organization_name` | `acme`                                                              | Readability in a rule that also pins `sub`                             |
| `terraform_project_name`      | `platform`                                                          | As above                                                               |
| `terraform_workspace_name`    | `prod`                                                              | As above                                                               |
| `terraform_full_workspace`    | `organization:acme:project:platform:workspace:prod`                 | The workspace without the run phase                                    |
| `terraform_run_id`            | `run-CZcmD7eagjhyX0vN`                                              | Never pin this — it changes every run                                  |
| `terraform_run_phase`         | `apply`                                                             | `plan` or `apply`                                                      |

A module test run carries a different `sub` shape — `organization:{ORG}:module:{MODULE}:operation:test_run` — and always reports `terraform_run_phase: plan`.

## Recommendations

* **Separate the phases.** Pin `run_phase:apply` on the binding that carries write scopes, and register a second, read-only binding for `run_phase:plan` if plans need KnoxCall access at all. A speculative plan can be triggered by a pull request from someone who cannot merge it; without the phase in the rule, that plan mints the same token your apply does.
* **One binding per workspace**, not one per organisation. `sub` names the workspace, so a per-workspace rule costs nothing and makes the exchange history per-workspace too.
* Keep `access_token_ttl_seconds` near the length of a real apply. KnoxCall already caps the minted token at twice the remaining life of the Terraform token, so a long TTL mostly does nothing.

## Troubleshooting

* **`invalid_grant: no binding matched the subject_token's claims`** — the `sub` in the rule is not the `sub` in the token. Decode the token in a run to compare:

  ```bash theme={"dark"}
  echo "$TFC_WORKLOAD_IDENTITY_TOKEN" | cut -d. -f2 \
    | base64 -d 2>/dev/null | jq .
  ```

  The usual causes are a renamed workspace, a project the workspace was moved into, and a rule written for `apply` while the failure is on `plan`.
* **`invalid_request: Unsupported audience`** — `TFC_WORKLOAD_IDENTITY_AUDIENCE` is not `knoxcall:api`. The audience is baked into the token at issue time, so it must match before the run starts.
* **`invalid_grant: subject_token has already been exchanged`** — each token is single-use. A retried apply needs the token from its own run; re-using a captured one is exactly what that refusal exists to stop.
* **`403 {"requires_step_up": true}` when creating the binding** — see the note in step 2.
