From f353d829cd1464b0f19a168c0286c1967c2b8744 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Thu, 28 May 2026 22:20:41 +0530 Subject: [PATCH 1/4] oidc dynamic credential support --- CHANGELOG.md | 10 +- README.md | 2 +- docs/api/index.md | 5 + docs/api/oidc-configurations.md | 188 +++++++++ docs/scenarios/oidc-dynamic-credentials.md | 341 +++++++++++++++ examples/oidc_configurations.py | 222 ++++++++++ src/pytfe/client.py | 12 + src/pytfe/errors.py | 8 + src/pytfe/models/__init__.py | 27 ++ src/pytfe/models/oidc_configuration.py | 242 +++++++++++ src/pytfe/resources/oidc_configurations.py | 259 ++++++++++++ tests/units/test_oidc_configurations.py | 466 +++++++++++++++++++++ 12 files changed, 1780 insertions(+), 2 deletions(-) create mode 100644 docs/api/oidc-configurations.md create mode 100644 docs/scenarios/oidc-dynamic-credentials.md create mode 100644 examples/oidc_configurations.py create mode 100644 src/pytfe/models/oidc_configuration.py create mode 100644 src/pytfe/resources/oidc_configurations.py create mode 100644 tests/units/test_oidc_configurations.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a7c26f8..a59287f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Unreleased - +# v1.1.0 + +### HYOK OIDC Configurations +* Added aws_oidc_configurations, azure_oidc_configurations, gcp_oidc_configurations, and vault_oidc_configurations resources with create, read, update, and delete methods for Hold-Your-Own-Key OIDC configuration records. All four hit a single polymorphic HCP endpoint (POST /organizations/{org}/oidc-configurations for create, /oidc-configurations/{id} for read/update/delete) dispatched by JSON:API data.type, matching the structure used by go-tfe and the terraform-tfe provider. +* Added typed models per provider: AWSOIDCConfiguration / AzureOIDCConfiguration / GCPOIDCConfiguration / VaultOIDCConfiguration plus matching CreateOptions and UpdateOptions for each. +* Azure / GCP / Vault UpdateOptions are fully partial — only supplied fields are sent on the wire. AWSOIDCConfigurationUpdateOptions REQUIRES role_arn because the AWS resource has exactly one updatable attribute, matching go-tfe's AWSOIDCConfigurationUpdateOptions.valid() behaviour (ErrRequiredRoleARN). Constructing AWSOIDCConfigurationUpdateOptions() with no arguments now raises a pydantic ValidationError at construction time instead of silently sending an empty PATCH whose server-side behaviour was never verified. +* AWSOIDCConfigurationCreateOptions and AWSOIDCConfigurationUpdateOptions both reject empty-string role_arn values via a non-empty field validator, mirroring go-tfe's local validation. +* Added InvalidOIDCConfigurationIDError typed exception. +* These resources require HYOK / Premium entitlement on the organization; calls against a non-HYOK org return NotFound. The SDK manages only the HCP-side configuration record — the cloud-side trust resources (IAM role, Azure federated credential, GCP workload identity pool, Vault JWT auth method) still need to be provisioned separately. # Released # v1.0.0 diff --git a/README.md b/README.md index 072071b2..6c2fc07b 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ and upstream HCP Terraform API docs. |---|---| | Configure the SDK | [Authentication](./docs/authentication.md), [Pagination](./docs/pagination.md), [Logging](./docs/LOGGING.md) | | API guides | [API index](./docs/api/index.md), [Workspaces](./docs/api/workspaces.md), [Runs/plans/applies](./docs/api/runs-plans-applies.md), [State versions](./docs/api/state-versions.md) | -| Scenario guides | [API-driven run](./docs/scenarios/api-driven-run.md), [State management](./docs/scenarios/state-management.md), [Migrate workspaces and state](./docs/scenarios/migrate-workspaces-and-state.md), [Team access onboarding](./docs/scenarios/team-access-onboarding.md), [No-code provisioning](./docs/scenarios/no-code-provisioning.md) | +| Scenario guides | [API-driven run](./docs/scenarios/api-driven-run.md), [State management](./docs/scenarios/state-management.md), [Migrate workspaces and state](./docs/scenarios/migrate-workspaces-and-state.md), [Team access onboarding](./docs/scenarios/team-access-onboarding.md), [No-code provisioning](./docs/scenarios/no-code-provisioning.md), [OIDC dynamic credentials](./docs/scenarios/oidc-dynamic-credentials.md) | | Operations guides | [Troubleshooting](./docs/troubleshooting.md), [Errors](./docs/errors.md), [Terraform Enterprise](./docs/terraform-enterprise.md) | | Contribute to the SDK | [CONTRIBUTING](./docs/CONTRIBUTING.md), [ITERATORS](./docs/ITERATORS.md), [MODELS](./docs/MODELS.md), [RESOURCE](./docs/RESOURCE.md) | diff --git a/docs/api/index.md b/docs/api/index.md index f61ea95e..424d838b 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -82,6 +82,10 @@ column. | `client.agent_tokens` | `AgentTokens` | `list`, `read`, `create`, `delete` | [agent.py](../../examples/agent.py) | [Agent tokens](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/agent-tokens) | | `client.registry_modules` | `RegistryModules` | `list`, `read`, `create`, `update`, `delete`, version and upload helpers | [registry_module.py](../../examples/registry_module.py) | [Registry modules](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/modules) | | `client.no_code_modules` | `NoCodeModules` | `create`, `read`, `update`, `delete`, `read_variables`, `create_workspace`, `upgrade_workspace`, `read_workspace_upgrade`, `confirm_workspace_upgrade` | [no_code_provisioning.py](../../examples/no_code_provisioning.py) | [No-code provisioning](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/no-code-provisioning) | +| `client.aws_oidc_configurations` | `AWSOIDCConfigurations` | `create`, `read`, `update`, `delete` | [oidc_configurations.py](../../examples/oidc_configurations.py) | [AWS OIDC](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/hold-your-own-key/oidc-configurations/aws) | +| `client.azure_oidc_configurations` | `AzureOIDCConfigurations` | `create`, `read`, `update`, `delete` | [oidc_configurations.py](../../examples/oidc_configurations.py) | [Azure OIDC](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/hold-your-own-key/oidc-configurations/azure) | +| `client.gcp_oidc_configurations` | `GCPOIDCConfigurations` | `create`, `read`, `update`, `delete` | [oidc_configurations.py](../../examples/oidc_configurations.py) | [GCP OIDC](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/hold-your-own-key/oidc-configurations/gcp) | +| `client.vault_oidc_configurations` | `VaultOIDCConfigurations` | `create`, `read`, `update`, `delete` | [oidc_configurations.py](../../examples/oidc_configurations.py) | [Vault OIDC](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/hold-your-own-key/oidc-configurations/vault) | | `client.registry_providers` | `RegistryProviders` | `list`, `read`, `create`, `delete` | [registry_provider.py](../../examples/registry_provider.py) | [Registry providers](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/providers) | | `client.registry_provider_versions` | `RegistryProviderVersions` | `list`, `read`, `create`, `delete` | [registry_provider_version.py](../../examples/registry_provider_version.py) | [Registry providers](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/providers) | | `client.registry_provider_platforms` | `RegistryProviderPlatforms` | `list`, `read`, `create`, `delete` | [registry_provider_platform.py](../../examples/registry_provider_platform.py) | [Registry providers](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/providers) | @@ -105,3 +109,4 @@ column. - [policies.md](policies.md) - [run-tasks.md](run-tasks.md) - [no-code-provisioning.md](no-code-provisioning.md) +- [oidc-configurations.md](oidc-configurations.md) diff --git a/docs/api/oidc-configurations.md b/docs/api/oidc-configurations.md new file mode 100644 index 00000000..98db30cc --- /dev/null +++ b/docs/api/oidc-configurations.md @@ -0,0 +1,188 @@ +# HYOK OIDC configurations + +Hold-Your-Own-Key (HYOK) OIDC configurations let HCP Terraform federate to +AWS, Azure, GCP, or Vault without storing a static credential. pyTFE exposes +one service per provider: + +- `client.aws_oidc_configurations` +- `client.azure_oidc_configurations` +- `client.gcp_oidc_configurations` +- `client.vault_oidc_configurations` + +Upstream docs: + +- AWS: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/hold-your-own-key/oidc-configurations/aws +- Azure: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/hold-your-own-key/oidc-configurations/azure +- GCP: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/hold-your-own-key/oidc-configurations/gcp +- Vault: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/hold-your-own-key/oidc-configurations/vault + +Example: [oidc_configurations.py](../../examples/oidc_configurations.py) + +## What these resources do (and do not) manage + +These services manage the **HCP Terraform-side configuration record** only. +They do **not** provision the cloud-side trust resources: + +| The SDK creates | You still need to provision separately | +|---|---| +| AWS OIDC configuration record (role ARN, organization) | IAM OIDC provider for HCP Terraform; IAM role and trust policy | +| Azure OIDC configuration record (client/subscription/tenant IDs) | Azure AD app registration; service principal; federated credential | +| GCP OIDC configuration record (SA email, project number, workload provider name) | Workload Identity Federation pool/provider; service account IAM bindings | +| Vault OIDC configuration record (address, role, auth path, namespace) | Vault JWT auth method; role; policies | + +These configurations require **HYOK / Premium entitlement** on the +organization. Calls against a non-HYOK org return `404` or `403`. + +For per-workspace dynamic credentials (not HYOK), see +[scenarios/oidc-dynamic-credentials.md](../scenarios/oidc-dynamic-credentials.md) +— that's still done via `client.variables` or `client.variable_sets`. + +## Shared HTTP shape + +All four providers hit the same endpoints; the provider is determined by the +JSON:API `data.type` string in the body, not the URL: + +| Operation | Method | Path | +|---|---|---| +| Create | `POST` | `/api/v2/organizations/{org}/oidc-configurations` | +| Read | `GET` | `/api/v2/oidc-configurations/{id}` | +| Update | `PATCH` | `/api/v2/oidc-configurations/{id}` | +| Delete | `DELETE` | `/api/v2/oidc-configurations/{id}` | + +`data.type` values per provider: + +| Provider | `data.type` | +|---|---| +| AWS | `aws-oidc-configurations` | +| Azure | `azure-oidc-configurations` | +| GCP | `gcp-oidc-configurations` | +| Vault | `vault-oidc-configurations` | + +## AWS + +| Method | Purpose | +|---|---| +| `client.aws_oidc_configurations.create(organization, options)` | Register an IAM role ARN for OIDC federation. | +| `client.aws_oidc_configurations.read(oidc_configuration_id)` | Read configuration. | +| `client.aws_oidc_configurations.update(oidc_configuration_id, options)` | Update role ARN. | +| `client.aws_oidc_configurations.delete(oidc_configuration_id)` | Delete the configuration. | + +```python +from pytfe import TFEClient +from pytfe.models import AWSOIDCConfigurationCreateOptions + +client = TFEClient() + +aws = client.aws_oidc_configurations.create( + "my-organization", + AWSOIDCConfigurationCreateOptions( + role_arn="arn:aws:iam::123456789012:role/hcp-terraform", + ), +) +print(aws.id, aws.role_arn) +``` + +## Azure + +| Method | Purpose | +|---|---| +| `client.azure_oidc_configurations.create(organization, options)` | Register Azure AD app/subscription/tenant. | +| `client.azure_oidc_configurations.read(oidc_configuration_id)` | Read configuration. | +| `client.azure_oidc_configurations.update(oidc_configuration_id, options)` | Update one or more IDs. | +| `client.azure_oidc_configurations.delete(oidc_configuration_id)` | Delete the configuration. | + +```python +from pytfe.models import AzureOIDCConfigurationCreateOptions + +azure = client.azure_oidc_configurations.create( + "my-organization", + AzureOIDCConfigurationCreateOptions( + client_id="00000000-0000-0000-0000-000000000000", + subscription_id="11111111-1111-1111-1111-111111111111", + tenant_id="22222222-2222-2222-2222-222222222222", + ), +) +``` + +All three of `client_id`, `subscription_id`, `tenant_id` are required on +create. Update accepts any subset; unset fields are not touched. + +## GCP + +| Method | Purpose | +|---|---| +| `client.gcp_oidc_configurations.create(organization, options)` | Register the service account + workload provider. | +| `client.gcp_oidc_configurations.read(oidc_configuration_id)` | Read configuration. | +| `client.gcp_oidc_configurations.update(oidc_configuration_id, options)` | Update SA email, project number, or provider name. | +| `client.gcp_oidc_configurations.delete(oidc_configuration_id)` | Delete the configuration. | + +```python +from pytfe.models import GCPOIDCConfigurationCreateOptions + +gcp = client.gcp_oidc_configurations.create( + "my-organization", + GCPOIDCConfigurationCreateOptions( + service_account_email="tfc@my-project.iam.gserviceaccount.com", + project_number="123456789012", + workload_provider_name=( + "projects/123456789012/locations/global/" + "workloadIdentityPools/hcp/providers/hcp-terraform" + ), + ), +) +``` + +## Vault + +Vault has the most non-obvious field mappings — the Python names differ from +the wire names: + +| Python field | Wire name | Required on create | +|---|---|---| +| `address` | `address` | yes | +| `role_name` | `role` | yes | +| `namespace` | `namespace` | no | +| `jwt_auth_path` | `auth-path` | no | +| `tls_ca_certificate` | `encoded-cacert` | no | + +| Method | Purpose | +|---|---| +| `client.vault_oidc_configurations.create(organization, options)` | Register Vault address + role. | +| `client.vault_oidc_configurations.read(oidc_configuration_id)` | Read configuration. | +| `client.vault_oidc_configurations.update(oidc_configuration_id, options)` | Update any field. | +| `client.vault_oidc_configurations.delete(oidc_configuration_id)` | Delete the configuration. | + +```python +from pytfe.models import VaultOIDCConfigurationCreateOptions + +vault = client.vault_oidc_configurations.create( + "my-organization", + VaultOIDCConfigurationCreateOptions( + address="https://vault.example.com", + role_name="hcp-terraform", + namespace="admin", + jwt_auth_path="jwt", + tls_ca_certificate="-----BEGIN CERTIFICATE-----\n...", + ), +) +``` + +## Token requirements + +Write endpoints require an organization, owner, or HYOK admin token. See +HCP's HYOK docs for the exact permission model. Read endpoints follow the +same permission rules as other organization-scoped reads. + +## Operational notes + +- **Update is partial.** Pass only the fields you want to change. Unset + fields are not sent on the wire, so the server keeps the existing value. +- **Plan rotations carefully.** Updating the IAM role ARN or service account + email mid-flight will interrupt any in-progress runs that rely on the + federated credential. +- **Configurations are per-organization.** If you have multiple HCP + Terraform organizations sharing a cloud account, each needs its own + configuration record (and a distinct trust policy/federated credential on + the cloud side). +- **HYOK is required.** Without the entitlement these endpoints return + `404`. The SDK will surface that as `pytfe.errors.NotFound`. diff --git a/docs/scenarios/oidc-dynamic-credentials.md b/docs/scenarios/oidc-dynamic-credentials.md new file mode 100644 index 00000000..a839e1cc --- /dev/null +++ b/docs/scenarios/oidc-dynamic-credentials.md @@ -0,0 +1,341 @@ +# Scenario: OIDC dynamic credentials + +There are **two distinct ways** HCP Terraform federates to cloud providers +via OIDC. They're often confused because they share the same trust model +(OIDC token exchange) but are configured through different APIs. + +| Approach | Scope | Configured via | When to use | +|---|---|---|---| +| **HYOK OIDC configurations** | Organization-wide; one trust record per provider | `client.aws_oidc_configurations`, `client.azure_oidc_configurations`, `client.gcp_oidc_configurations`, `client.vault_oidc_configurations` | You have HYOK / Premium entitlement and want one shared org-level trust | +| **Per-workspace dynamic provider credentials** | Per workspace (or variable set) | `client.variables` or `client.variable_sets` with `TFC_*_PROVIDER_AUTH` env vars | Standard HCP Terraform tier; per-workspace or per-environment isolation | + +This scenario walks both paths. Pick the one that matches your tier and your +trust model. + +Upstream concept docs: + +- HYOK OIDC configurations: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/hold-your-own-key/oidc-configurations +- Dynamic provider credentials (per-workspace): https://developer.hashicorp.com/terraform/cloud-docs/dynamic-provider-credentials + +API references in this repo: + +- [api/oidc-configurations.md](../api/oidc-configurations.md) +- [api/variables-and-variable-sets.md](../api/variables-and-variable-sets.md) + +## Prerequisites + +```bash +export TFE_TOKEN="your-api-token" +export TFE_ADDRESS="https://app.terraform.io" +export TFE_ORG="my-organization" +``` + +For the **HYOK path**: you must have HYOK / Premium enabled on the +organization. Otherwise the OIDC configuration endpoints return `404`. + +For the **per-workspace path**: standard HCP Terraform is sufficient. + +## Path A — HYOK org-level OIDC configuration + +A single record per provider per organization. All workspaces in the org +share it. + +### AWS + +```python +from pytfe import TFEClient +from pytfe.models import AWSOIDCConfigurationCreateOptions + +client = TFEClient() + +aws = client.aws_oidc_configurations.create( + "my-organization", + AWSOIDCConfigurationCreateOptions( + role_arn="arn:aws:iam::123456789012:role/hcp-terraform", + ), +) +print("oidc id:", aws.id) +``` + +The HCP-side record is now in place. You still need on the AWS side: + +- An IAM OIDC identity provider for HCP Terraform's issuer URL. +- An IAM role with a trust policy that lets that OIDC provider assume it. +- The IAM role's permissions for whatever Terraform will manage. + +Use the AWS CLI, Terraform AWS provider, or CDK — not this SDK — to +provision those. The SDK only stores the ARN. + +### Azure / GCP / Vault + +Same shape, different fields. See +[api/oidc-configurations.md](../api/oidc-configurations.md) for the per- +provider field list. + +```python +from pytfe.models import ( + AzureOIDCConfigurationCreateOptions, + GCPOIDCConfigurationCreateOptions, + VaultOIDCConfigurationCreateOptions, +) + +azure = client.azure_oidc_configurations.create( + "my-organization", + AzureOIDCConfigurationCreateOptions( + client_id="00000000-0000-0000-0000-000000000000", + subscription_id="11111111-1111-1111-1111-111111111111", + tenant_id="22222222-2222-2222-2222-222222222222", + ), +) + +gcp = client.gcp_oidc_configurations.create( + "my-organization", + GCPOIDCConfigurationCreateOptions( + service_account_email="tfc@my-project.iam.gserviceaccount.com", + project_number="123456789012", + workload_provider_name="projects/123456789012/locations/global/workloadIdentityPools/hcp/providers/hcp-terraform", + ), +) + +vault = client.vault_oidc_configurations.create( + "my-organization", + VaultOIDCConfigurationCreateOptions( + address="https://vault.example.com", + role_name="hcp-terraform", + ), +) +``` + +### Updating + +Updates are partial — pass only the fields you want to change. Unset +fields are left untouched on the server. + +```python +from pytfe.models import AWSOIDCConfigurationUpdateOptions + +client.aws_oidc_configurations.update( + aws.id, + AWSOIDCConfigurationUpdateOptions( + role_arn="arn:aws:iam::123456789012:role/hcp-terraform-v2", + ), +) +``` + +### Rotation pattern + +Rotating the underlying cloud-side role/principal is a two-step dance to +avoid breaking in-progress runs: + +1. Create the new IAM role / app registration / SA / Vault role on the + cloud side. Wait until it's healthy. +2. `update(...)` the HCP OIDC configuration to point at the new identity. +3. Once existing runs have drained, delete the old cloud-side identity. + +If you delete the cloud-side identity before step 2 completes, queued runs +will fail with auth errors. Drift the changes in that order. + +## Path B — per-workspace dynamic provider credentials + +For standard HCP Terraform tiers (no HYOK), or when you want per-workspace +isolation, configure dynamic credentials via **environment variables** on +the workspace or a shared variable set. The exact env var names are +documented per-provider in HCP's dynamic provider credentials docs. + +### Example: AWS dynamic credentials on a workspace + +```python +from pytfe.models import CategoryType, VariableCreateOptions + +workspace_id = "ws-abc123" + +# Tells HCP Terraform to mint an OIDC token and assume this role for +# AWS provider calls in this workspace. +client.variables.create( + workspace_id, + VariableCreateOptions( + key="TFC_AWS_PROVIDER_AUTH", + value="true", + category=CategoryType.ENV, + ), +) +client.variables.create( + workspace_id, + VariableCreateOptions( + key="TFC_AWS_RUN_ROLE_ARN", + value="arn:aws:iam::123456789012:role/hcp-terraform-workspace", + category=CategoryType.ENV, + ), +) +``` + +### Verified-working AWS trust policy + IAM policy + +The pyTFE side above (setting `TFC_AWS_PROVIDER_AUTH` + `TFC_AWS_RUN_ROLE_ARN`) +was end-to-end verified against the live AWS + HCP Terraform APIs: a t3.micro +was created and destroyed in ap-south-1 using a federated session derived +from these exact two env vars. The AWS-side trust policy and minimum IAM +policy that worked are shown below — drop these into your AWS account +(via Terraform, CLI, or boto3) and the SDK code above will work as-is. + +**OIDC provider** (one per AWS account per HCP issuer): + +``` +URL: https://app.terraform.io +Audience: aws.workload.identity +``` + +**Trust policy on the IAM role** (`AssumeRolePolicyDocument`): + +```json +{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": { + "Federated": "arn:aws:iam:::oidc-provider/app.terraform.io" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "app.terraform.io:aud": "aws.workload.identity" + }, + "StringLike": { + "app.terraform.io:sub": + "organization::project:*:workspace::run_phase:*" + } + } + }] +} +``` + +The `sub` claim format HCP issues is +`organization::project::workspace::run_phase:`. +Using `StringLike` with `project:*` and `run_phase:*` lets the role be +assumed during plan, apply, or refresh phases without pinning the project +slug; `workspace:` stays exact so only the intended +workspace can assume the role. + +**Minimum EC2 policy** to provision a single instance in a default VPC +(the read permissions look excessive but the Terraform AWS provider +hydrates several VPC/network data sources on every plan): + +```json +{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": [ + "ec2:DescribeImages", + "ec2:DescribeVpcs", + "ec2:DescribeVpcAttribute", + "ec2:DescribeVpcClassicLink", + "ec2:DescribeVpcClassicLinkDnsSupport", + "ec2:DescribeSubnets", + "ec2:DescribeNetworkInterfaces", + "ec2:DescribeNetworkAcls", + "ec2:DescribeRouteTables", + "ec2:DescribeSecurityGroups", + "ec2:DescribeAvailabilityZones", + "ec2:DescribeAccountAttributes", + "ec2:DescribeDhcpOptions", + "ec2:DescribeInstances", + "ec2:DescribeInstanceAttribute", + "ec2:DescribeInstanceStatus", + "ec2:DescribeInstanceTypes", + "ec2:DescribeInstanceCreditSpecifications", + "ec2:DescribeVolumes", + "ec2:DescribeTags", + "ec2:RunInstances", + "ec2:TerminateInstances", + "ec2:CreateTags", + "ec2:DeleteTags" + ], + "Resource": "*" + }] +} +``` + +**Gotcha worth calling out:** an obvious-looking minimum policy with just +`ec2:DescribeVpcs`, `RunInstances`, `TerminateInstances`, `CreateTags` +will fail during plan with `UnauthorizedOperation: ec2:DescribeVpcAttribute` +when the `aws_vpc.default` data source tries to read `enableDnsHostnames`. +The provider also reads `DescribeNetworkInterfaces`/`DescribeSecurityGroups` +when planning even a bare `aws_instance` resource. Include the full +`Describe*` set above and the first run will succeed cleanly. + +This setup mirrors the +[HashiCorp blog](https://www.hashicorp.com/en/blog/access-aws-from-hcp-terraform-with-oidc-federation) +end-to-end. The blog walks through the AWS-side trust resources using the +Terraform AWS provider; the policies above are the literal JSON +equivalents. + +### Example: same setup via a variable set across many workspaces + +```python +from pytfe.models import ( + CategoryType, + VariableSetCreateOptions, + VariableSetVariableCreateOptions, +) + +varset = client.variable_sets.create( + "my-organization", + VariableSetCreateOptions( + name="aws-dynamic-creds", + description="Shared AWS OIDC dynamic credentials", + global_=False, + ), +) + +for key, value in [ + ("TFC_AWS_PROVIDER_AUTH", "true"), + ("TFC_AWS_RUN_ROLE_ARN", "arn:aws:iam::123456789012:role/hcp-terraform-shared"), +]: + client.variable_set_variables.create( + varset.id, + VariableSetVariableCreateOptions( + key=key, + value=value, + category=CategoryType.ENV, + ), + ) + +# Then apply to specific workspaces — see scenarios/manage-workspace-variables.md +``` + +The env var names differ per provider: + +- AWS: `TFC_AWS_PROVIDER_AUTH`, `TFC_AWS_RUN_ROLE_ARN`, etc. +- Azure: `TFC_AZURE_PROVIDER_AUTH`, `TFC_AZURE_RUN_CLIENT_ID`, etc. +- GCP: `TFC_GCP_PROVIDER_AUTH`, `TFC_GCP_RUN_SERVICE_ACCOUNT_EMAIL`, etc. +- Vault: `TFC_VAULT_PROVIDER_AUTH`, `TFC_VAULT_RUN_ROLE`, etc. + +See the upstream dynamic-credentials docs for the full per-provider env +var list — it changes occasionally as new auth modes ship. + +## Choosing between A and B + +- **Use A (HYOK)** when: + - You have Premium / HYOK entitlement. + - You want a single auditable record of "what role/principal HCP + Terraform federates to" per provider per organization. + - You're standardising on org-wide trust. + +- **Use B (per-workspace)** when: + - You're on standard HCP Terraform. + - You need different roles per workspace (e.g. dev/staging/prod isolation). + - You want per-team or per-project trust scopes via variable sets. + +It's common to use **B** even with HYOK available, to scope individual +workspaces to least-privilege roles while keeping HYOK as the broader +fallback. + +## Operational notes + +- Neither approach provisions the cloud-side trust resources. Use the + cloud provider's SDK or Terraform itself for that. +- Test OIDC trust end-to-end with a no-op plan before relying on it. A + misconfigured trust policy on the cloud side will fail every run. +- Treat the cloud-side role / app / SA as security-critical. Audit who + can change its trust policy. diff --git a/examples/oidc_configurations.py b/examples/oidc_configurations.py new file mode 100644 index 00000000..f57db609 --- /dev/null +++ b/examples/oidc_configurations.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Example: HYOK OIDC configurations (AWS / Azure / GCP / Vault). + +Demonstrates the create/read/update/delete lifecycle for an HCP Terraform +HYOK OIDC configuration record. Pick which provider to exercise via +``TFE_OIDC_PROVIDER`` (one of: ``aws``, ``azure``, ``gcp``, ``vault``). + +These resources require HYOK / Premium entitlement on the organization. +If you see 404 from create, the org doesn't have HYOK enabled — this is +expected on most sandbox orgs. + +This example does NOT provision the cloud-side trust resources (IAM role, +Azure app registration, GCP workload identity pool, Vault JWT auth method). +See docs/scenarios/oidc-dynamic-credentials.md for the full picture. + +Environment variables: + + TFE_TOKEN user, team, or org token (HYOK admin permission) + TFE_ADDRESS HCP Terraform / Terraform Enterprise URL + TFE_ORG organization with HYOK enabled + TFE_OIDC_PROVIDER aws | azure | gcp | vault (default: aws) + + # AWS only: + TFE_OIDC_AWS_ROLE_ARN IAM role ARN HCP Terraform should assume + + # Azure only: + TFE_OIDC_AZURE_CLIENT_ID + TFE_OIDC_AZURE_SUBSCRIPTION_ID + TFE_OIDC_AZURE_TENANT_ID + + # GCP only: + TFE_OIDC_GCP_SERVICE_ACCOUNT_EMAIL + TFE_OIDC_GCP_PROJECT_NUMBER + TFE_OIDC_GCP_WORKLOAD_PROVIDER_NAME + + # Vault only: + TFE_OIDC_VAULT_ADDRESS + TFE_OIDC_VAULT_ROLE + TFE_OIDC_VAULT_NAMESPACE (optional) + TFE_OIDC_VAULT_AUTH_PATH (optional, defaults to "jwt" server-side) + +The script creates, reads, updates, then deletes the configuration. +""" + +from __future__ import annotations + +import os +import sys + +from pytfe.client import TFEClient +from pytfe.errors import NotFound, TFEError +from pytfe.models import ( + AWSOIDCConfigurationCreateOptions, + AWSOIDCConfigurationUpdateOptions, + AzureOIDCConfigurationCreateOptions, + AzureOIDCConfigurationUpdateOptions, + GCPOIDCConfigurationCreateOptions, + GCPOIDCConfigurationUpdateOptions, + VaultOIDCConfigurationCreateOptions, + VaultOIDCConfigurationUpdateOptions, +) + + +def banner(s: str) -> None: + print() + print("=" * 64) + print(s) + print("=" * 64) + + +def _aws(client: TFEClient, org: str) -> None: + banner("AWS OIDC configuration") + role_arn = os.environ["TFE_OIDC_AWS_ROLE_ARN"] + created = client.aws_oidc_configurations.create( + org, AWSOIDCConfigurationCreateOptions(role_arn=role_arn) + ) + print(f" created: id={created.id} role_arn={created.role_arn}") + + read = client.aws_oidc_configurations.read(created.id) + print(f" read: id={read.id} role_arn={read.role_arn}") + + updated = client.aws_oidc_configurations.update( + created.id, + AWSOIDCConfigurationUpdateOptions(role_arn=role_arn), + ) + print(f" update (no-op): role_arn={updated.role_arn}") + + client.aws_oidc_configurations.delete(created.id) + print(f" deleted: {created.id}") + + +def _azure(client: TFEClient, org: str) -> None: + banner("Azure OIDC configuration") + created = client.azure_oidc_configurations.create( + org, + AzureOIDCConfigurationCreateOptions( + client_id=os.environ["TFE_OIDC_AZURE_CLIENT_ID"], + subscription_id=os.environ["TFE_OIDC_AZURE_SUBSCRIPTION_ID"], + tenant_id=os.environ["TFE_OIDC_AZURE_TENANT_ID"], + ), + ) + print(f" created: id={created.id} client_id={created.client_id}") + + read = client.azure_oidc_configurations.read(created.id) + print( + f" read: id={read.id} subscription_id={read.subscription_id} tenant_id={read.tenant_id}" + ) + + # Partial update — change only client_id. + updated = client.azure_oidc_configurations.update( + created.id, + AzureOIDCConfigurationUpdateOptions( + client_id=os.environ["TFE_OIDC_AZURE_CLIENT_ID"] + ), + ) + print(f" update (partial): client_id={updated.client_id}") + + client.azure_oidc_configurations.delete(created.id) + print(f" deleted: {created.id}") + + +def _gcp(client: TFEClient, org: str) -> None: + banner("GCP OIDC configuration") + created = client.gcp_oidc_configurations.create( + org, + GCPOIDCConfigurationCreateOptions( + service_account_email=os.environ["TFE_OIDC_GCP_SERVICE_ACCOUNT_EMAIL"], + project_number=os.environ["TFE_OIDC_GCP_PROJECT_NUMBER"], + workload_provider_name=os.environ["TFE_OIDC_GCP_WORKLOAD_PROVIDER_NAME"], + ), + ) + print(f" created: id={created.id} sa={created.service_account_email}") + + read = client.gcp_oidc_configurations.read(created.id) + print( + f" read: project_number={read.project_number} workload_provider_name={read.workload_provider_name}" + ) + + updated = client.gcp_oidc_configurations.update( + created.id, + GCPOIDCConfigurationUpdateOptions( + workload_provider_name=os.environ["TFE_OIDC_GCP_WORKLOAD_PROVIDER_NAME"] + ), + ) + print( + f" update (partial): workload_provider_name={updated.workload_provider_name}" + ) + + client.gcp_oidc_configurations.delete(created.id) + print(f" deleted: {created.id}") + + +def _vault(client: TFEClient, org: str) -> None: + banner("Vault OIDC configuration") + namespace = os.environ.get("TFE_OIDC_VAULT_NAMESPACE") + auth_path = os.environ.get("TFE_OIDC_VAULT_AUTH_PATH") + created = client.vault_oidc_configurations.create( + org, + VaultOIDCConfigurationCreateOptions( + address=os.environ["TFE_OIDC_VAULT_ADDRESS"], + role_name=os.environ["TFE_OIDC_VAULT_ROLE"], + namespace=namespace, + jwt_auth_path=auth_path, + ), + ) + print( + f" created: id={created.id} address={created.address} role={created.role_name}" + ) + + read = client.vault_oidc_configurations.read(created.id) + print(f" read: namespace={read.namespace} auth_path={read.jwt_auth_path}") + + updated = client.vault_oidc_configurations.update( + created.id, + VaultOIDCConfigurationUpdateOptions(namespace=namespace), + ) + print(f" update (partial): namespace={updated.namespace}") + + client.vault_oidc_configurations.delete(created.id) + print(f" deleted: {created.id}") + + +DISPATCH = { + "aws": _aws, + "azure": _azure, + "gcp": _gcp, + "vault": _vault, +} + + +def main() -> int: + org = os.environ["TFE_ORG"] + provider = os.environ.get("TFE_OIDC_PROVIDER", "aws").lower() + + if provider not in DISPATCH: + print( + f"unknown TFE_OIDC_PROVIDER='{provider}'; must be one of {list(DISPATCH)}" + ) + return 2 + + client = TFEClient() + + try: + DISPATCH[provider](client, org) + return 0 + except NotFound: + print( + "\nGot 404 from the OIDC endpoint. " + "This usually means the organization does not have HYOK / Premium " + "entitlement enabled — try a Premium org or skip these resources." + ) + return 1 + except TFEError as exc: + print(f"\nTFE error: {exc}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 63548716..79614a2e 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -15,6 +15,12 @@ from .resources.notification_configuration import NotificationConfigurations from .resources.oauth_client import OAuthClients from .resources.oauth_token import OAuthTokens +from .resources.oidc_configurations import ( + AWSOIDCConfigurations, + AzureOIDCConfigurations, + GCPOIDCConfigurations, + VaultOIDCConfigurations, +) from .resources.organization_audit_configuration import OrganizationAuditConfigurations from .resources.organization_membership import OrganizationMemberships from .resources.organization_tags import OrganizationTags @@ -110,6 +116,12 @@ def __init__(self, config: TFEConfig | None = None): self.workspace_run_tasks = WorkspaceRunTasks(self._transport) self.registry_modules = RegistryModules(self._transport) self.no_code_modules = NoCodeModules(self._transport) + + # HYOK OIDC configurations (AWS / Azure / GCP / Vault) + self.aws_oidc_configurations = AWSOIDCConfigurations(self._transport) + self.azure_oidc_configurations = AzureOIDCConfigurations(self._transport) + self.gcp_oidc_configurations = GCPOIDCConfigurations(self._transport) + self.vault_oidc_configurations = VaultOIDCConfigurations(self._transport) self.registry_providers = RegistryProviders(self._transport) self.registry_provider_versions = RegistryProviderVersions(self._transport) self.registry_provider_platforms = RegistryProviderPlatforms(self._transport) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index dc870dae..4241399b 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -739,3 +739,11 @@ class RequiredRegistryModuleIDError(RequiredFieldMissing): def __init__(self, message: str = "registry module ID is required"): super().__init__(message) + + +# OIDC configuration errors +class InvalidOIDCConfigurationIDError(InvalidValues): + """Raised when an invalid OIDC configuration ID is provided.""" + + def __init__(self, message: str = "invalid value for OIDC configuration ID"): + super().__init__(message) diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index 6ca910dd..d5158c63 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -119,6 +119,20 @@ OAuthTokenListOptions, OAuthTokenUpdateOptions, ) +from .oidc_configuration import ( + AWSOIDCConfiguration, + AWSOIDCConfigurationCreateOptions, + AWSOIDCConfigurationUpdateOptions, + AzureOIDCConfiguration, + AzureOIDCConfigurationCreateOptions, + AzureOIDCConfigurationUpdateOptions, + GCPOIDCConfiguration, + GCPOIDCConfigurationCreateOptions, + GCPOIDCConfigurationUpdateOptions, + VaultOIDCConfiguration, + VaultOIDCConfigurationCreateOptions, + VaultOIDCConfigurationUpdateOptions, +) # Organization / Project from .organization import ( @@ -536,6 +550,19 @@ # ── Public surface ──────────────────────────────────────────────────────────── __all__ = [ + # HYOK OIDC configurations + "AWSOIDCConfiguration", + "AWSOIDCConfigurationCreateOptions", + "AWSOIDCConfigurationUpdateOptions", + "AzureOIDCConfiguration", + "AzureOIDCConfigurationCreateOptions", + "AzureOIDCConfigurationUpdateOptions", + "GCPOIDCConfiguration", + "GCPOIDCConfigurationCreateOptions", + "GCPOIDCConfigurationUpdateOptions", + "VaultOIDCConfiguration", + "VaultOIDCConfigurationCreateOptions", + "VaultOIDCConfigurationUpdateOptions", # No-code provisioning "NoCodeModule", "NoCodeModuleCreateOptions", diff --git a/src/pytfe/models/oidc_configuration.py b/src/pytfe/models/oidc_configuration.py new file mode 100644 index 00000000..12bec9be --- /dev/null +++ b/src/pytfe/models/oidc_configuration.py @@ -0,0 +1,242 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Models for HCP Terraform HYOK OIDC configurations. + +Four provider types share the same wire endpoint +(``/organizations/{org}/oidc-configurations`` for create, +``/oidc-configurations/{id}`` for read/update/delete) and are polymorphically +dispatched by the JSON:API ``data.type`` string: + +- ``aws-oidc-configurations`` +- ``azure-oidc-configurations`` +- ``gcp-oidc-configurations`` +- ``vault-oidc-configurations`` + +These models manage the HCP Terraform *configuration record* only — they +don't provision the cloud-side IAM/service-principal/workload-identity +resources. Use the cloud provider's own SDK/IaC for that. See +``docs/scenarios/oidc-dynamic-credentials.md`` for how this fits with +workspace dynamic provider credentials. +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from .organization import Organization + + +def _non_empty_role_arn(value: str) -> str: + """AWS role ARNs must be non-empty strings. + + go-tfe's ``AWSOIDCConfigurationUpdateOptions.valid()`` rejects empty + role ARNs locally with ``ErrRequiredRoleARN``. We mirror that for both + create and update so callers get a clear pydantic error at construction + time instead of an opaque server-side 422 (or worse, an accepted but + malformed config record). + """ + if not value or not value.strip(): + raise ValueError("role_arn must be a non-empty string") + return value + + +# --------------------------------------------------------------------------- +# AWS +# --------------------------------------------------------------------------- + + +class AWSOIDCConfiguration(BaseModel): + """An AWS OIDC configuration record on HCP Terraform.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str | None = None + role_arn: str | None = Field(default=None, alias="role-arn") + + # Relationships + organization: Organization | None = None + + +class AWSOIDCConfigurationCreateOptions(BaseModel): + """Options for creating an AWS OIDC configuration.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + role_arn: str = Field( + ..., + alias="role-arn", + description="ARN of the IAM role HCP Terraform will assume", + ) + + _validate_role_arn = field_validator("role_arn")(_non_empty_role_arn) + + +class AWSOIDCConfigurationUpdateOptions(BaseModel): + """Options for updating an AWS OIDC configuration. + + Unlike Azure/GCP/Vault — whose update options are fully partial — + ``role_arn`` is REQUIRED here. The AWS resource has exactly one + updatable attribute, so an update with no fields is meaningless; + go-tfe's ``AWSOIDCConfigurationUpdateOptions.valid()`` rejects the + empty case locally with ``ErrRequiredRoleARN`` and we mirror that + behaviour. + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + role_arn: str = Field(..., alias="role-arn") + + _validate_role_arn = field_validator("role_arn")(_non_empty_role_arn) + + +# --------------------------------------------------------------------------- +# Azure +# --------------------------------------------------------------------------- + + +class AzureOIDCConfiguration(BaseModel): + """An Azure OIDC configuration record on HCP Terraform.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str | None = None + client_id: str | None = Field(default=None, alias="client-id") + subscription_id: str | None = Field(default=None, alias="subscription-id") + tenant_id: str | None = Field(default=None, alias="tenant-id") + + organization: Organization | None = None + + +class AzureOIDCConfigurationCreateOptions(BaseModel): + """Options for creating an Azure OIDC configuration.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + client_id: str = Field(..., alias="client-id") + subscription_id: str = Field(..., alias="subscription-id") + tenant_id: str = Field(..., alias="tenant-id") + + +class AzureOIDCConfigurationUpdateOptions(BaseModel): + """Options for updating an Azure OIDC configuration.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + client_id: str | None = Field(default=None, alias="client-id") + subscription_id: str | None = Field(default=None, alias="subscription-id") + tenant_id: str | None = Field(default=None, alias="tenant-id") + + +# --------------------------------------------------------------------------- +# GCP +# --------------------------------------------------------------------------- + + +class GCPOIDCConfiguration(BaseModel): + """A GCP OIDC configuration record on HCP Terraform.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str | None = None + service_account_email: str | None = Field( + default=None, alias="service-account-email" + ) + project_number: str | None = Field(default=None, alias="project-number") + workload_provider_name: str | None = Field( + default=None, alias="workload-provider-name" + ) + + organization: Organization | None = None + + +class GCPOIDCConfigurationCreateOptions(BaseModel): + """Options for creating a GCP OIDC configuration.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + service_account_email: str = Field(..., alias="service-account-email") + project_number: str = Field(..., alias="project-number") + workload_provider_name: str = Field(..., alias="workload-provider-name") + + +class GCPOIDCConfigurationUpdateOptions(BaseModel): + """Options for updating a GCP OIDC configuration.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + service_account_email: str | None = Field( + default=None, alias="service-account-email" + ) + project_number: str | None = Field(default=None, alias="project-number") + workload_provider_name: str | None = Field( + default=None, alias="workload-provider-name" + ) + + +# --------------------------------------------------------------------------- +# Vault +# --------------------------------------------------------------------------- + + +class VaultOIDCConfiguration(BaseModel): + """A Vault OIDC configuration record on HCP Terraform. + + Field-name mappings: + - ``role_name`` <-> wire ``role`` + - ``jwt_auth_path`` <-> wire ``auth-path`` + - ``tls_ca_certificate`` <-> wire ``encoded-cacert`` + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str | None = None + address: str | None = None + role_name: str | None = Field(default=None, alias="role") + namespace: str | None = None + jwt_auth_path: str | None = Field(default=None, alias="auth-path") + tls_ca_certificate: str | None = Field(default=None, alias="encoded-cacert") + + organization: Organization | None = None + + +class VaultOIDCConfigurationCreateOptions(BaseModel): + """Options for creating a Vault OIDC configuration.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + address: str = Field( + ..., description="Vault address (e.g. https://vault.example.com)" + ) + role_name: str = Field(..., alias="role") + namespace: str | None = None + jwt_auth_path: str | None = Field(default=None, alias="auth-path") + tls_ca_certificate: str | None = Field(default=None, alias="encoded-cacert") + + +class VaultOIDCConfigurationUpdateOptions(BaseModel): + """Options for updating a Vault OIDC configuration.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + address: str | None = None + role_name: str | None = Field(default=None, alias="role") + namespace: str | None = None + jwt_auth_path: str | None = Field(default=None, alias="auth-path") + tls_ca_certificate: str | None = Field(default=None, alias="encoded-cacert") + + +__all__ = [ + "AWSOIDCConfiguration", + "AWSOIDCConfigurationCreateOptions", + "AWSOIDCConfigurationUpdateOptions", + "AzureOIDCConfiguration", + "AzureOIDCConfigurationCreateOptions", + "AzureOIDCConfigurationUpdateOptions", + "GCPOIDCConfiguration", + "GCPOIDCConfigurationCreateOptions", + "GCPOIDCConfigurationUpdateOptions", + "VaultOIDCConfiguration", + "VaultOIDCConfigurationCreateOptions", + "VaultOIDCConfigurationUpdateOptions", +] diff --git a/src/pytfe/resources/oidc_configurations.py b/src/pytfe/resources/oidc_configurations.py new file mode 100644 index 00000000..3c38e772 --- /dev/null +++ b/src/pytfe/resources/oidc_configurations.py @@ -0,0 +1,259 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""HCP Terraform HYOK OIDC configuration resources. + +All four provider types (AWS, Azure, GCP, Vault) share a single HCP endpoint +group: + +- ``POST /api/v2/organizations/{org}/oidc-configurations`` +- ``GET /api/v2/oidc-configurations/{id}`` +- ``PATCH /api/v2/oidc-configurations/{id}`` +- ``DELETE /api/v2/oidc-configurations/{id}`` + +Provider type is encoded in the JSON:API ``data.type`` string +(``aws-oidc-configurations``, ``azure-oidc-configurations``, +``gcp-oidc-configurations``, ``vault-oidc-configurations``). Each provider +gets its own service class for ergonomic IDE autocomplete and typed +options/response models; internally they share build/parse helpers. + +These resources require Hold Your Own Key (HYOK) entitlement on the +organization. Calls against an org without HYOK will return ``404`` or +``403`` from the server. +""" + +from __future__ import annotations + +from typing import Any, Generic, TypeVar + +from pydantic import BaseModel + +from ..errors import InvalidOIDCConfigurationIDError, InvalidOrgError +from ..models.oidc_configuration import ( + AWSOIDCConfiguration, + AWSOIDCConfigurationCreateOptions, + AWSOIDCConfigurationUpdateOptions, + AzureOIDCConfiguration, + AzureOIDCConfigurationCreateOptions, + AzureOIDCConfigurationUpdateOptions, + GCPOIDCConfiguration, + GCPOIDCConfigurationCreateOptions, + GCPOIDCConfigurationUpdateOptions, + VaultOIDCConfiguration, + VaultOIDCConfigurationCreateOptions, + VaultOIDCConfigurationUpdateOptions, +) +from ..models.organization import Organization +from ..utils import valid_string_id +from ._base import _Service + +_AWS_TYPE = "aws-oidc-configurations" +_AZURE_TYPE = "azure-oidc-configurations" +_GCP_TYPE = "gcp-oidc-configurations" +_VAULT_TYPE = "vault-oidc-configurations" + + +# Pydantic config / response models share BaseModel — pick typevars so the +# generic helpers below can be statically typed without losing the concrete +# provider type at the call site. +R = TypeVar("R", bound=BaseModel) # response model +T = TypeVar("T", bound=BaseModel) # local parse() typevar + + +def _build_payload(type_str: str, options: BaseModel) -> dict[str, Any]: + """Build a JSON:API request body for create/update. + + Emits the wire-aliased keys (e.g. ``role-arn`` not ``role_arn``) and + omits ``None`` fields so partial updates don't clobber unset values. + """ + attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") + return {"data": {"type": type_str, "attributes": attrs}} + + +def _parse_response(data: dict[str, Any], model: type[T]) -> T: + """Parse a JSON:API ``data`` block into a typed response model. + + The model's own field aliases handle attribute name mapping; we only + need to lift the ``organization`` relationship into the parsed object + so callers can reach ``config.organization.id`` without traversing the + JSON:API envelope themselves. + """ + attrs = data.get("attributes") or {} + relationships = data.get("relationships") or {} + + parsed = model.model_validate({"id": data.get("id"), **attrs}) + + org_data = (relationships.get("organization") or {}).get("data") + if org_data and org_data.get("id") and hasattr(parsed, "organization"): + parsed.organization = Organization.model_construct(id=org_data["id"]) + + return parsed + + +class _OIDCConfigurationsBase(_Service, Generic[R]): + """Internal: shared CRUD plumbing for all four provider services. + + Subclasses set ``_type`` (wire-format JSON:API type string) and the + response model class; the four public methods are otherwise identical. + + Parametrising on the response model means each subclass's + ``_create/_read/_update`` returns its concrete provider type, not + ``Any`` — so callers and mypy both see the right shape. + """ + + _type: str + _response_model: type[R] + + def _create(self, organization: str, options: BaseModel) -> R: + if not valid_string_id(organization): + raise InvalidOrgError() + body = _build_payload(self._type, options) + r = self.t.request( + "POST", + f"/api/v2/organizations/{organization}/oidc-configurations", + json_body=body, + ) + return _parse_response(r.json()["data"], self._response_model) + + def _read(self, oidc_configuration_id: str) -> R: + if not valid_string_id(oidc_configuration_id): + raise InvalidOIDCConfigurationIDError() + r = self.t.request( + "GET", f"/api/v2/oidc-configurations/{oidc_configuration_id}" + ) + return _parse_response(r.json()["data"], self._response_model) + + def _update(self, oidc_configuration_id: str, options: BaseModel) -> R: + if not valid_string_id(oidc_configuration_id): + raise InvalidOIDCConfigurationIDError() + body = _build_payload(self._type, options) + r = self.t.request( + "PATCH", + f"/api/v2/oidc-configurations/{oidc_configuration_id}", + json_body=body, + ) + return _parse_response(r.json()["data"], self._response_model) + + def _delete(self, oidc_configuration_id: str) -> None: + if not valid_string_id(oidc_configuration_id): + raise InvalidOIDCConfigurationIDError() + self.t.request("DELETE", f"/api/v2/oidc-configurations/{oidc_configuration_id}") + + +class AWSOIDCConfigurations(_OIDCConfigurationsBase[AWSOIDCConfiguration]): + """Manage AWS OIDC configurations. + + Stores the IAM role ARN that HCP Terraform should assume via OIDC + federation. Does not create the AWS-side IAM role or trust policy. + """ + + _type = _AWS_TYPE + _response_model = AWSOIDCConfiguration + + def create( + self, organization: str, options: AWSOIDCConfigurationCreateOptions + ) -> AWSOIDCConfiguration: + return self._create(organization, options) + + def read(self, oidc_configuration_id: str) -> AWSOIDCConfiguration: + return self._read(oidc_configuration_id) + + def update( + self, + oidc_configuration_id: str, + options: AWSOIDCConfigurationUpdateOptions, + ) -> AWSOIDCConfiguration: + return self._update(oidc_configuration_id, options) + + def delete(self, oidc_configuration_id: str) -> None: + self._delete(oidc_configuration_id) + + +class AzureOIDCConfigurations(_OIDCConfigurationsBase[AzureOIDCConfiguration]): + """Manage Azure OIDC configurations. + + Stores the Azure AD application/subscription/tenant identifiers HCP + Terraform federates against. Does not create the Azure-side app + registration, service principal, or federated credential. + """ + + _type = _AZURE_TYPE + _response_model = AzureOIDCConfiguration + + def create( + self, organization: str, options: AzureOIDCConfigurationCreateOptions + ) -> AzureOIDCConfiguration: + return self._create(organization, options) + + def read(self, oidc_configuration_id: str) -> AzureOIDCConfiguration: + return self._read(oidc_configuration_id) + + def update( + self, + oidc_configuration_id: str, + options: AzureOIDCConfigurationUpdateOptions, + ) -> AzureOIDCConfiguration: + return self._update(oidc_configuration_id, options) + + def delete(self, oidc_configuration_id: str) -> None: + self._delete(oidc_configuration_id) + + +class GCPOIDCConfigurations(_OIDCConfigurationsBase[GCPOIDCConfiguration]): + """Manage GCP OIDC configurations. + + Stores the GCP service account email and Workload Identity Federation + provider that HCP Terraform impersonates. Does not create the GCP-side + workload identity pool, provider, or service-account IAM bindings. + """ + + _type = _GCP_TYPE + _response_model = GCPOIDCConfiguration + + def create( + self, organization: str, options: GCPOIDCConfigurationCreateOptions + ) -> GCPOIDCConfiguration: + return self._create(organization, options) + + def read(self, oidc_configuration_id: str) -> GCPOIDCConfiguration: + return self._read(oidc_configuration_id) + + def update( + self, + oidc_configuration_id: str, + options: GCPOIDCConfigurationUpdateOptions, + ) -> GCPOIDCConfiguration: + return self._update(oidc_configuration_id, options) + + def delete(self, oidc_configuration_id: str) -> None: + self._delete(oidc_configuration_id) + + +class VaultOIDCConfigurations(_OIDCConfigurationsBase[VaultOIDCConfiguration]): + """Manage Vault OIDC configurations. + + Stores the Vault address, JWT auth path, and role HCP Terraform + authenticates against. Does not create the Vault-side JWT auth method, + role, or policies. + """ + + _type = _VAULT_TYPE + _response_model = VaultOIDCConfiguration + + def create( + self, organization: str, options: VaultOIDCConfigurationCreateOptions + ) -> VaultOIDCConfiguration: + return self._create(organization, options) + + def read(self, oidc_configuration_id: str) -> VaultOIDCConfiguration: + return self._read(oidc_configuration_id) + + def update( + self, + oidc_configuration_id: str, + options: VaultOIDCConfigurationUpdateOptions, + ) -> VaultOIDCConfiguration: + return self._update(oidc_configuration_id, options) + + def delete(self, oidc_configuration_id: str) -> None: + self._delete(oidc_configuration_id) diff --git a/tests/units/test_oidc_configurations.py b/tests/units/test_oidc_configurations.py new file mode 100644 index 00000000..67c75e81 --- /dev/null +++ b/tests/units/test_oidc_configurations.py @@ -0,0 +1,466 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for HYOK OIDC configuration resources. + +All four provider services share the same internal CRUD plumbing, so tests +focus on what's distinct per provider: payload `data.type`, the +hyphen-aliased attribute set, and the polymorphic URL that's the same for +every provider. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import Mock + +import pytest + +from pytfe.errors import InvalidOIDCConfigurationIDError, InvalidOrgError +from pytfe.models.oidc_configuration import ( + AWSOIDCConfigurationCreateOptions, + AWSOIDCConfigurationUpdateOptions, + AzureOIDCConfigurationCreateOptions, + AzureOIDCConfigurationUpdateOptions, + GCPOIDCConfigurationCreateOptions, + VaultOIDCConfigurationCreateOptions, + VaultOIDCConfigurationUpdateOptions, +) +from pytfe.resources.oidc_configurations import ( + AWSOIDCConfigurations, + AzureOIDCConfigurations, + GCPOIDCConfigurations, + VaultOIDCConfigurations, +) + + +def _resp(body: Any) -> Mock: + r = Mock() + r.json.return_value = body + return r + + +def _envelope( + *, + oidc_id: str, + type_str: str, + attributes: dict[str, Any], + org_id: str = "my-org", +) -> dict[str, Any]: + return { + "data": { + "id": oidc_id, + "type": type_str, + "attributes": attributes, + "relationships": { + "organization": { + "data": {"type": "organizations", "id": org_id}, + }, + }, + } + } + + +# --------------------------------------------------------------------------- +# AWS +# --------------------------------------------------------------------------- + + +class TestAWSOIDCConfigurations: + def setup_method(self) -> None: + self.transport = Mock() + self.service = AWSOIDCConfigurations(self.transport) + + def test_create_payload_and_url(self) -> None: + self.transport.request.return_value = _resp( + _envelope( + oidc_id="oidc-aws-1", + type_str="aws-oidc-configurations", + attributes={"role-arn": "arn:aws:iam::111:role/tfc"}, + ) + ) + + result = self.service.create( + "my-org", + AWSOIDCConfigurationCreateOptions(role_arn="arn:aws:iam::111:role/tfc"), + ) + + method, path = self.transport.request.call_args.args + body = self.transport.request.call_args.kwargs["json_body"] + assert method == "POST" + assert path == "/api/v2/organizations/my-org/oidc-configurations" + assert body == { + "data": { + "type": "aws-oidc-configurations", + "attributes": {"role-arn": "arn:aws:iam::111:role/tfc"}, + } + } + assert result.id == "oidc-aws-1" + assert result.role_arn == "arn:aws:iam::111:role/tfc" + assert result.organization is not None + assert result.organization.id == "my-org" + + def test_read_url_and_parse(self) -> None: + self.transport.request.return_value = _resp( + _envelope( + oidc_id="oidc-aws-1", + type_str="aws-oidc-configurations", + attributes={"role-arn": "arn:aws:iam::111:role/tfc"}, + ) + ) + result = self.service.read("oidc-aws-1") + method, path = self.transport.request.call_args.args + assert method == "GET" + assert path == "/api/v2/oidc-configurations/oidc-aws-1" + assert result.role_arn == "arn:aws:iam::111:role/tfc" + + def test_update_omits_none_fields(self) -> None: + self.transport.request.return_value = _resp( + _envelope( + oidc_id="oidc-aws-1", + type_str="aws-oidc-configurations", + attributes={"role-arn": "arn:aws:iam::111:role/new"}, + ) + ) + self.service.update( + "oidc-aws-1", + AWSOIDCConfigurationUpdateOptions(role_arn="arn:aws:iam::111:role/new"), + ) + method, path = self.transport.request.call_args.args + body = self.transport.request.call_args.kwargs["json_body"] + assert method == "PATCH" + assert path == "/api/v2/oidc-configurations/oidc-aws-1" + assert body["data"]["type"] == "aws-oidc-configurations" + assert body["data"]["attributes"] == {"role-arn": "arn:aws:iam::111:role/new"} + + def test_update_requires_role_arn(self) -> None: + # AWS update has exactly one updatable field; constructing the + # options without a role_arn is a local error — matches go-tfe's + # ErrRequiredRoleARN behaviour. + import pydantic + + with pytest.raises(pydantic.ValidationError): + AWSOIDCConfigurationUpdateOptions() # type: ignore[call-arg] + + def test_update_rejects_empty_role_arn(self) -> None: + # Pydantic-level non-empty validator; defends against + # AWSOIDCConfigurationUpdateOptions(role_arn="") sneaking through + # to the server. + import pydantic + + with pytest.raises(pydantic.ValidationError): + AWSOIDCConfigurationUpdateOptions(role_arn="") + + def test_create_rejects_empty_role_arn(self) -> None: + # Same validator is shared between create and update options. + import pydantic + + with pytest.raises(pydantic.ValidationError): + AWSOIDCConfigurationCreateOptions(role_arn="") + + def test_delete_url(self) -> None: + self.transport.request.return_value = _resp({}) + self.service.delete("oidc-aws-1") + method, path = self.transport.request.call_args.args + assert method == "DELETE" + assert path == "/api/v2/oidc-configurations/oidc-aws-1" + + def test_invalid_org_on_create(self) -> None: + with pytest.raises(InvalidOrgError): + self.service.create( + "", + AWSOIDCConfigurationCreateOptions(role_arn="arn:aws:iam::111:role/x"), + ) + + def test_invalid_id_on_read(self) -> None: + with pytest.raises(InvalidOIDCConfigurationIDError): + self.service.read("") + + def test_invalid_id_on_update(self) -> None: + with pytest.raises(InvalidOIDCConfigurationIDError): + self.service.update( + "", + AWSOIDCConfigurationUpdateOptions(role_arn="arn:aws:iam::111:role/x"), + ) + + def test_invalid_id_on_delete(self) -> None: + with pytest.raises(InvalidOIDCConfigurationIDError): + self.service.delete("") + + +# --------------------------------------------------------------------------- +# Azure +# --------------------------------------------------------------------------- + + +class TestAzureOIDCConfigurations: + def setup_method(self) -> None: + self.transport = Mock() + self.service = AzureOIDCConfigurations(self.transport) + + def test_create_payload(self) -> None: + self.transport.request.return_value = _resp( + _envelope( + oidc_id="oidc-azure-1", + type_str="azure-oidc-configurations", + attributes={ + "client-id": "client-uuid", + "subscription-id": "sub-uuid", + "tenant-id": "tenant-uuid", + }, + ) + ) + result = self.service.create( + "my-org", + AzureOIDCConfigurationCreateOptions( + client_id="client-uuid", + subscription_id="sub-uuid", + tenant_id="tenant-uuid", + ), + ) + body = self.transport.request.call_args.kwargs["json_body"] + assert body == { + "data": { + "type": "azure-oidc-configurations", + "attributes": { + "client-id": "client-uuid", + "subscription-id": "sub-uuid", + "tenant-id": "tenant-uuid", + }, + } + } + assert result.client_id == "client-uuid" + assert result.subscription_id == "sub-uuid" + assert result.tenant_id == "tenant-uuid" + + def test_update_partial_payload(self) -> None: + self.transport.request.return_value = _resp( + _envelope( + oidc_id="oidc-azure-1", + type_str="azure-oidc-configurations", + attributes={ + "client-id": "new-client-uuid", + "subscription-id": "sub-uuid", + "tenant-id": "tenant-uuid", + }, + ) + ) + self.service.update( + "oidc-azure-1", + AzureOIDCConfigurationUpdateOptions(client_id="new-client-uuid"), + ) + body = self.transport.request.call_args.kwargs["json_body"] + # Only client_id was set; subscription/tenant must be omitted. + assert body["data"]["attributes"] == {"client-id": "new-client-uuid"} + + +# --------------------------------------------------------------------------- +# GCP +# --------------------------------------------------------------------------- + + +class TestGCPOIDCConfigurations: + def setup_method(self) -> None: + self.transport = Mock() + self.service = GCPOIDCConfigurations(self.transport) + + def test_create_payload(self) -> None: + self.transport.request.return_value = _resp( + _envelope( + oidc_id="oidc-gcp-1", + type_str="gcp-oidc-configurations", + attributes={ + "service-account-email": "sa@p.iam.gserviceaccount.com", + "project-number": "123456789", + "workload-provider-name": "projects/123/locations/global/workloadIdentityPools/p/providers/x", + }, + ) + ) + result = self.service.create( + "my-org", + GCPOIDCConfigurationCreateOptions( + service_account_email="sa@p.iam.gserviceaccount.com", + project_number="123456789", + workload_provider_name="projects/123/locations/global/workloadIdentityPools/p/providers/x", + ), + ) + body = self.transport.request.call_args.kwargs["json_body"] + assert body["data"]["type"] == "gcp-oidc-configurations" + assert body["data"]["attributes"] == { + "service-account-email": "sa@p.iam.gserviceaccount.com", + "project-number": "123456789", + "workload-provider-name": "projects/123/locations/global/workloadIdentityPools/p/providers/x", + } + assert result.service_account_email == "sa@p.iam.gserviceaccount.com" + assert result.project_number == "123456789" + assert ( + result.workload_provider_name + == "projects/123/locations/global/workloadIdentityPools/p/providers/x" + ) + + +# --------------------------------------------------------------------------- +# Vault +# --------------------------------------------------------------------------- + + +class TestVaultOIDCConfigurations: + def setup_method(self) -> None: + self.transport = Mock() + self.service = VaultOIDCConfigurations(self.transport) + + def test_create_payload_uses_wire_aliases(self) -> None: + # Vault has the most non-obvious mappings: + # role_name -> "role" + # jwt_auth_path -> "auth-path" + # tls_ca_certificate -> "encoded-cacert" + # If any of these regress, federation fails silently on the cluster + # side, so test the exact wire shape. + self.transport.request.return_value = _resp( + _envelope( + oidc_id="oidc-vault-1", + type_str="vault-oidc-configurations", + attributes={ + "address": "https://vault.example.com", + "role": "terraform", + "namespace": "admin", + "auth-path": "jwt", + "encoded-cacert": "-----BEGIN CERT-----", + }, + ) + ) + result = self.service.create( + "my-org", + VaultOIDCConfigurationCreateOptions( + address="https://vault.example.com", + role_name="terraform", + namespace="admin", + jwt_auth_path="jwt", + tls_ca_certificate="-----BEGIN CERT-----", + ), + ) + body = self.transport.request.call_args.kwargs["json_body"] + assert body["data"]["type"] == "vault-oidc-configurations" + assert body["data"]["attributes"] == { + "address": "https://vault.example.com", + "role": "terraform", + "namespace": "admin", + "auth-path": "jwt", + "encoded-cacert": "-----BEGIN CERT-----", + } + # Parsed model uses Python field names. + assert result.role_name == "terraform" + assert result.jwt_auth_path == "jwt" + assert result.tls_ca_certificate == "-----BEGIN CERT-----" + + def test_create_minimum_payload(self) -> None: + self.transport.request.return_value = _resp( + _envelope( + oidc_id="oidc-vault-1", + type_str="vault-oidc-configurations", + attributes={ + "address": "https://vault.example.com", + "role": "tf", + }, + ) + ) + self.service.create( + "my-org", + VaultOIDCConfigurationCreateOptions( + address="https://vault.example.com", role_name="tf" + ), + ) + body = self.transport.request.call_args.kwargs["json_body"] + assert body["data"]["attributes"] == { + "address": "https://vault.example.com", + "role": "tf", + } + # namespace/auth-path/encoded-cacert omitted, not sent as null. + assert "namespace" not in body["data"]["attributes"] + assert "auth-path" not in body["data"]["attributes"] + assert "encoded-cacert" not in body["data"]["attributes"] + + def test_update_namespace_only(self) -> None: + self.transport.request.return_value = _resp( + _envelope( + oidc_id="oidc-vault-1", + type_str="vault-oidc-configurations", + attributes={ + "address": "https://vault.example.com", + "role": "tf", + "namespace": "production", + }, + ) + ) + self.service.update( + "oidc-vault-1", + VaultOIDCConfigurationUpdateOptions(namespace="production"), + ) + body = self.transport.request.call_args.kwargs["json_body"] + assert body["data"]["attributes"] == {"namespace": "production"} + + +# --------------------------------------------------------------------------- +# Cross-provider: confirm all four hit the same polymorphic URLs +# --------------------------------------------------------------------------- + + +class TestOIDCPolymorphicURLs: + """The HCP API uses one URL for create and one URL pattern for + read/update/delete across all four providers — only `data.type` + distinguishes them. Regress-protect that all four services agree.""" + + @pytest.mark.parametrize( + "service_cls,options_factory,expected_type", + [ + ( + AWSOIDCConfigurations, + lambda: AWSOIDCConfigurationCreateOptions( + role_arn="arn:aws:iam::1:role/r" + ), + "aws-oidc-configurations", + ), + ( + AzureOIDCConfigurations, + lambda: AzureOIDCConfigurationCreateOptions( + client_id="c", subscription_id="s", tenant_id="t" + ), + "azure-oidc-configurations", + ), + ( + GCPOIDCConfigurations, + lambda: GCPOIDCConfigurationCreateOptions( + service_account_email="sa@p", + project_number="1", + workload_provider_name="w", + ), + "gcp-oidc-configurations", + ), + ( + VaultOIDCConfigurations, + lambda: VaultOIDCConfigurationCreateOptions( + address="https://v", role_name="r" + ), + "vault-oidc-configurations", + ), + ], + ) + def test_create_url_is_polymorphic( + self, + service_cls: type, + options_factory: Any, + expected_type: str, + ) -> None: + transport = Mock() + transport.request.return_value = _resp( + _envelope( + oidc_id="x", type_str=expected_type, attributes={}, org_id="my-org" + ) + ) + service = service_cls(transport) + service.create("my-org", options_factory()) + + _, path = transport.request.call_args.args + body = transport.request.call_args.kwargs["json_body"] + assert path == "/api/v2/organizations/my-org/oidc-configurations" + assert body["data"]["type"] == expected_type From b834ef0af166dc44927e61a8bbbdcb2a41be9450 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Thu, 28 May 2026 23:09:39 +0530 Subject: [PATCH 2/4] add oidc_setup script for aws --- docs/scenarios/oidc-dynamic-credentials.md | 76 +++ examples/oidc_aws_e2e.py | 555 ++++++++++++++++++++ examples/oidc_setup.py | 584 +++++++++++++++++++++ src/pytfe/models/oidc_configuration.py | 19 +- tests/units/test_oidc_configurations.py | 3 +- 5 files changed, 1218 insertions(+), 19 deletions(-) create mode 100644 examples/oidc_aws_e2e.py create mode 100644 examples/oidc_setup.py diff --git a/docs/scenarios/oidc-dynamic-credentials.md b/docs/scenarios/oidc-dynamic-credentials.md index a839e1cc..ef2c1f3f 100644 --- a/docs/scenarios/oidc-dynamic-credentials.md +++ b/docs/scenarios/oidc-dynamic-credentials.md @@ -270,6 +270,82 @@ end-to-end. The blog walks through the AWS-side trust resources using the Terraform AWS provider; the policies above are the literal JSON equivalents. +### Reference script: bulk OIDC setup across many workspaces + +[`examples/oidc_setup.py`](../../examples/oidc_setup.py) is a reference +script for configuring OIDC federation across a list of workspaces in one +invocation. Treat it as a worked example you can run as-is or adapt; it +isn't part of the SDK's public API. + +It supports two modes: + +- **Managed-IAM** (default): the script provisions a per-workspace IAM + role scoped to that workspace's OIDC `sub` claim, optionally attaches + AWS-managed or inline permissions, and sets `TFC_AWS_PROVIDER_AUTH` + + `TFC_AWS_RUN_ROLE_ARN` on each workspace. The IAM OIDC provider is + account-global and is created once / reused across runs. + +- **Bring-your-own-role** (`--use-existing-role `): the script makes + no AWS API calls. It just sets the OIDC env vars on each workspace + pointing at a role ARN you manage elsewhere (e.g. with the Terraform + AWS provider). Useful when your IAM is owned by a separate team or + pipeline. + +```bash +export TFE_TOKEN= + +# Managed-IAM: +export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... AWS_SESSION_TOKEN=... +python examples/oidc_setup.py \ + --cloud aws \ + --org my-org \ + --workspaces prod-app,staging-app,dev-app \ + --attach-managed-policy arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess + +# Bring-your-own-role (no AWS credentials needed): +python examples/oidc_setup.py \ + --cloud aws \ + --org my-org \ + --workspaces prod-app,staging-app,dev-app \ + --use-existing-role arn:aws:iam::111122223333:role/my-tfc-role +``` + +The script is idempotent (safe to re-run), reports per-workspace status, +and exits non-zero if any workspace failed. Other flags worth knowing: +`--skip-identity-provider`, `--create-missing` (create the HCP workspace +if missing instead of failing), `--remove-static-aws-creds` (clean up +any old `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / +`AWS_SESSION_TOKEN` env vars on the workspace), `--inline-policy`. The +`--cloud` flag only accepts `aws` today; the flag exists so other +providers can be added without breaking callers. + +### Runnable end-to-end example + +A complete, self-contained script that does the full setup (AWS OIDC +provider + IAM role + trust policy + EC2 policy + HCP workspace + env vars ++ Terraform upload + plan/apply + verify + destroy) is at +[`examples/oidc_aws_e2e.py`](../../examples/oidc_aws_e2e.py). + +You only need to provide the workspace name; everything else has a sane +default: + +```bash +export TFE_TOKEN= # user or team token +export TFE_ORG= +export AWS_ACCESS_KEY_ID= +export AWS_SECRET_ACCESS_KEY= +export AWS_SESSION_TOKEN= # if using STS session credentials +export OIDC_WORKSPACE_NAME=my-app-prod # the only project-specific input + +python examples/oidc_aws_e2e.py +``` + +The script is idempotent — safe to re-run, every AWS and HCP resource is +created only if missing, and trust + IAM policies are refreshed in place. +By default it terminates the test EC2 at the end (`OIDC_DESTROY_AFTER_VERIFY` +defaults to `true`) but always keeps the workspace, IAM role, and OIDC +provider so you can reuse the same setup for real Terraform code. + ### Example: same setup via a variable set across many workspaces ```python diff --git a/examples/oidc_aws_e2e.py b/examples/oidc_aws_e2e.py new file mode 100644 index 00000000..e7236acf --- /dev/null +++ b/examples/oidc_aws_e2e.py @@ -0,0 +1,555 @@ +#!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Runnable end-to-end example: secret-less AWS access via OIDC federation. + +Implements the workflow from the HashiCorp blog +(https://www.hashicorp.com/en/blog/access-aws-from-hcp-terraform-with-oidc-federation) +using pyTFE + boto3. Verified live against AWS + HCP Terraform. + +What this script does, in order: + + AWS side (provisioned with the credentials in your shell): + 1. Ensure an IAM OIDC provider exists for ``https://app.terraform.io``. + 2. Ensure an IAM role with a trust policy scoped to a single HCP + workspace, and a least-privilege EC2 policy for the test. + + HCP side (provisioned via pyTFE): + 3. Ensure the target workspace exists. + 4. Set ``TFC_AWS_PROVIDER_AUTH`` and ``TFC_AWS_RUN_ROLE_ARN`` on the + workspace. + 5. Upload a tiny Terraform configuration that launches a single + t3.micro in the AWS region's default VPC. + 6. Trigger a run; wait through plan + apply. + + Verification: + 7. Use boto3 to confirm the EC2 instance is running and tagged. + 8. If ``OIDC_DESTROY_AFTER_VERIFY=true`` (default), queue a destroy + run via pyTFE so the cleanup itself exercises the OIDC trust the + other direction. + +What it intentionally LEAVES in place: + - The HCP workspace, env vars, IAM role, and OIDC provider — so you + can reuse the same setup for other Terraform code without redoing + the trust dance. + +Environment variables: + + Required: + TFE_TOKEN user or team HCP Terraform token + TFE_ORG HCP Terraform organization name + AWS_ACCESS_KEY_ID AWS sandbox credentials + AWS_SECRET_ACCESS_KEY + AWS_SESSION_TOKEN (only needed for STS session credentials) + + Optional: + TFE_ADDRESS default: https://app.terraform.io + OIDC_WORKSPACE_NAME default: pytfe-oidc-aws-e2e + OIDC_AWS_REGION default: ap-south-1 + OIDC_IAM_ROLE_NAME default: -role + OIDC_INSTANCE_TYPE default: t3.micro + OIDC_DESTROY_AFTER_VERIFY "true" / "false", default: true + +Run: + + export TFE_TOKEN=... + export TFE_ORG=my-org + export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... AWS_SESSION_TOKEN=... + export OIDC_WORKSPACE_NAME=my-app-prod + python examples/oidc_aws_e2e.py + +Re-runs are idempotent: every AWS and HCP resource is created only if +missing, and the IAM trust + EC2 policies are refreshed in place. +""" + +from __future__ import annotations + +import hashlib +import io +import json +import os +import socket +import ssl +import sys +import tarfile +import time +import traceback + +import boto3 +from botocore.exceptions import ClientError + +from pytfe import TFEClient +from pytfe.errors import TFEError +from pytfe.models import ( + CategoryType, + ConfigurationVersion, + ConfigurationVersionCreateOptions, + RunCreateOptions, + VariableCreateOptions, + Workspace, + WorkspaceCreateOptions, +) + +# ---- Configuration (env-driven, no defaults that leak identifiers) ---- + +HCP_ORG = os.environ["TFE_ORG"] +WORKSPACE_NAME = os.environ.get("OIDC_WORKSPACE_NAME", "pytfe-oidc-aws-e2e") +AWS_REGION = os.environ.get("OIDC_AWS_REGION", "ap-south-1") +IAM_ROLE_NAME = os.environ.get("OIDC_IAM_ROLE_NAME", f"{WORKSPACE_NAME}-role") +INSTANCE_TYPE = os.environ.get("OIDC_INSTANCE_TYPE", "t3.micro") +DESTROY_AFTER_VERIFY = os.environ.get( + "OIDC_DESTROY_AFTER_VERIFY", "true" +).strip().lower() in ("1", "true", "yes") + +OIDC_PROVIDER_URL = "app.terraform.io" +OIDC_AUDIENCE = "aws.workload.identity" + +# Tag applied to the test instance so we can find/verify it later. +INSTANCE_NAME_TAG = WORKSPACE_NAME + + +# ---- Terraform code uploaded to the workspace ---- + +TERRAFORM_MAIN_TF = f"""\ +terraform {{ + required_providers {{ + aws = {{ + source = "hashicorp/aws" + version = "~> 5.0" + }} + }} +}} + +provider "aws" {{ + region = "{AWS_REGION}" +}} + +data "aws_vpc" "default" {{ + default = true +}} + +data "aws_subnets" "default" {{ + filter {{ + name = "vpc-id" + values = [data.aws_vpc.default.id] + }} +}} + +data "aws_ami" "al2023" {{ + most_recent = true + owners = ["amazon"] + filter {{ + name = "name" + values = ["al2023-ami-*-x86_64"] + }} + filter {{ + name = "architecture" + values = ["x86_64"] + }} +}} + +resource "aws_instance" "test" {{ + ami = data.aws_ami.al2023.id + instance_type = "{INSTANCE_TYPE}" + subnet_id = tolist(data.aws_subnets.default.ids)[0] + + tags = {{ + Name = "{INSTANCE_NAME_TAG}" + Purpose = "pyTFE OIDC federation example" + }} +}} + +output "instance_id" {{ + value = aws_instance.test.id +}} +""".encode() + + +# ---- Helpers ---- + + +def banner(s: str) -> None: + print() + print("=" * 72) + print(s) + print("=" * 72) + + +def get_app_terraform_thumbprint() -> str: + """Fetch the leaf cert SHA1 thumbprint for app.terraform.io. + + AWS no longer strictly enforces this thumbprint for IdPs backed by + Amazon Trust Services CAs (since July 2023), but the API still + requires the field. We pass the real leaf thumbprint for correctness. + """ + ctx = ssl.create_default_context() + with socket.create_connection((OIDC_PROVIDER_URL, 443), timeout=10) as sock: + with ctx.wrap_socket(sock, server_hostname=OIDC_PROVIDER_URL) as ssock: + cert = ssock.getpeercert(binary_form=True) + return hashlib.sha1(cert).hexdigest() # noqa: S324 (intentional: AWS API expects SHA1) + + +def ensure_oidc_provider(iam) -> str: + """Create or reuse the app.terraform.io OIDC provider. Returns ARN.""" + expected_url = f"https://{OIDC_PROVIDER_URL}" + for p in iam.list_open_id_connect_providers()["OpenIDConnectProviderList"]: + d = iam.get_open_id_connect_provider(OpenIDConnectProviderArn=p["Arn"]) + if f"https://{d['Url']}" == expected_url: + print(f" reusing OIDC provider: {p['Arn']}") + return p["Arn"] + + thumbprint = get_app_terraform_thumbprint() + resp = iam.create_open_id_connect_provider( + Url=expected_url, + ClientIDList=[OIDC_AUDIENCE], + ThumbprintList=[thumbprint], + ) + arn = resp["OpenIDConnectProviderArn"] + print(f" created OIDC provider: {arn}") + return arn + + +def ensure_iam_role(iam, oidc_provider_arn: str) -> str: + """Create or reuse the IAM role; refresh trust policy + inline EC2 policy.""" + trust = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Federated": oidc_provider_arn}, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + f"{OIDC_PROVIDER_URL}:aud": OIDC_AUDIENCE, + }, + "StringLike": { + # sub claim format: + # organization::project::workspace::run_phase: + # Wildcards on project + run_phase keep this simple + # while pinning to a single workspace. + f"{OIDC_PROVIDER_URL}:sub": ( + f"organization:{HCP_ORG}" + f":project:*" + f":workspace:{WORKSPACE_NAME}" + f":run_phase:*" + ), + }, + }, + } + ], + } + + try: + existing = iam.get_role(RoleName=IAM_ROLE_NAME) + arn = existing["Role"]["Arn"] + iam.update_assume_role_policy( + RoleName=IAM_ROLE_NAME, PolicyDocument=json.dumps(trust) + ) + print(f" reusing IAM role (trust refreshed): {arn}") + except ClientError as e: + if e.response["Error"]["Code"] != "NoSuchEntity": + raise + resp = iam.create_role( + RoleName=IAM_ROLE_NAME, + AssumeRolePolicyDocument=json.dumps(trust), + Description=( + "OIDC federation role for pyTFE workspace - managed by " + "examples/oidc_aws_e2e.py" + ), + ) + arn = resp["Role"]["Arn"] + print(f" created IAM role: {arn}") + + # Tight inline policy: EC2 reads the AWS provider needs at plan time + + # RunInstances/TerminateInstances/CreateTags for our test resource. + # Drop ec2:DescribeVpcAttribute and the plan errors with + # UnauthorizedOperation; the others are pulled in by the network/subnet + # data sources Terraform hydrates during plan. + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ec2:DescribeImages", + "ec2:DescribeVpcs", + "ec2:DescribeVpcAttribute", + "ec2:DescribeVpcClassicLink", + "ec2:DescribeVpcClassicLinkDnsSupport", + "ec2:DescribeSubnets", + "ec2:DescribeNetworkInterfaces", + "ec2:DescribeNetworkAcls", + "ec2:DescribeRouteTables", + "ec2:DescribeSecurityGroups", + "ec2:DescribeAvailabilityZones", + "ec2:DescribeAccountAttributes", + "ec2:DescribeDhcpOptions", + "ec2:DescribeInstances", + "ec2:DescribeInstanceAttribute", + "ec2:DescribeInstanceStatus", + "ec2:DescribeInstanceTypes", + "ec2:DescribeInstanceCreditSpecifications", + "ec2:DescribeVolumes", + "ec2:DescribeTags", + "ec2:RunInstances", + "ec2:TerminateInstances", + "ec2:CreateTags", + "ec2:DeleteTags", + ], + "Resource": "*", + } + ], + } + iam.put_role_policy( + RoleName=IAM_ROLE_NAME, + PolicyName=f"{IAM_ROLE_NAME}-ec2", + PolicyDocument=json.dumps(policy), + ) + return arn + + +def ensure_workspace(client: TFEClient) -> Workspace: + try: + ws = client.workspaces.read(WORKSPACE_NAME, organization=HCP_ORG) + print(f" reusing workspace: {ws.id}") + return ws + except TFEError: + ws = client.workspaces.create( + HCP_ORG, + WorkspaceCreateOptions( + name=WORKSPACE_NAME, + description="Created by pyTFE OIDC AWS end-to-end example.", + auto_apply=False, + ), + ) + print(f" created workspace: {ws.id}") + return ws + + +def upsert_env_var( + client: TFEClient, + workspace_id: str, + key: str, + value: str, + *, + sensitive: bool, +) -> None: + for v in client.variables.list(workspace_id): + if v.key == key: + client.variables.delete(workspace_id, v.id) + break + client.variables.create( + workspace_id, + VariableCreateOptions( + key=key, + value=value, + category=CategoryType.ENV, + sensitive=sensitive, + ), + ) + print(f" set {key} = {'(sensitive)' if sensitive else value}") + + +def make_tarball(files: dict[str, bytes]) -> io.BytesIO: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + for name, data in files.items(): + info = tarfile.TarInfo(name=name) + info.size = len(data) + info.mtime = int(time.time()) + tar.addfile(info, io.BytesIO(data)) + buf.seek(0) + return buf + + +TERMINAL_BAD = {"errored", "canceled", "discarded", "force_canceled"} +TERMINAL_GOOD = {"applied", "planned_and_finished"} +TERMINAL = TERMINAL_BAD | TERMINAL_GOOD + + +def wait_for_run( + client: TFEClient, run_id: str, label: str, timeout_s: int = 900 +) -> str: + deadline = time.time() + timeout_s + last = "" + while time.time() < deadline: + run = client.runs.read(run_id) + status = run.status.value if run.status else "" + if status != last: + print(f" [{label}] status: {status}") + last = status + if status in TERMINAL: + return status + time.sleep(5) + raise TimeoutError( + f"{label}: run {run_id} did not reach terminal state within {timeout_s}s" + ) + + +def find_test_instance(ec2) -> dict | None: + resp = ec2.describe_instances( + Filters=[ + {"Name": "tag:Name", "Values": [INSTANCE_NAME_TAG]}, + {"Name": "instance-state-name", "Values": ["pending", "running"]}, + ] + ) + for r in resp["Reservations"]: + for i in r["Instances"]: + return i + return None + + +# ---- Main flow ---- + + +def main() -> int: + boto3.setup_default_session(region_name=AWS_REGION) + iam = boto3.client("iam") + ec2 = boto3.client("ec2", region_name=AWS_REGION) + sts = boto3.client("sts") + + print(f"Caller identity: {sts.get_caller_identity()['Arn']}") + print(f"HCP org: {HCP_ORG}") + print(f"Workspace: {WORKSPACE_NAME}") + print(f"AWS region: {AWS_REGION}") + print(f"IAM role: {IAM_ROLE_NAME}") + print(f"Instance type: {INSTANCE_TYPE}") + print(f"Destroy at end: {DESTROY_AFTER_VERIFY}") + + client = TFEClient() + + try: + banner("1. AWS: ensure OIDC provider for app.terraform.io") + oidc_arn = ensure_oidc_provider(iam) + + banner("2. AWS: ensure IAM role + trust policy + EC2 policy") + role_arn = ensure_iam_role(iam, oidc_arn) + + banner("3. HCP: ensure workspace") + workspace = ensure_workspace(client) + + banner("4. HCP: set TFC_AWS_PROVIDER_AUTH + TFC_AWS_RUN_ROLE_ARN") + upsert_env_var( + client, workspace.id, "TFC_AWS_PROVIDER_AUTH", "true", sensitive=False + ) + upsert_env_var( + client, workspace.id, "TFC_AWS_RUN_ROLE_ARN", role_arn, sensitive=True + ) + + banner("5. HCP: upload Terraform configuration") + cv = client.configuration_versions.create( + workspace.id, + ConfigurationVersionCreateOptions(auto_queue_runs=False), + ) + if not cv.upload_url: + raise RuntimeError("configuration version missing upload URL") + client.configuration_versions.upload_tar_gzip( + cv.upload_url, make_tarball({"main.tf": TERRAFORM_MAIN_TF}) + ) + for _ in range(30): + cv = client.configuration_versions.read(cv.id) + if cv.status and cv.status.value == "uploaded": + break + time.sleep(2) + print(f" configuration version: {cv.id} (status={cv.status})") + + banner("6. HCP: queue run, plan, apply (OIDC federates here)") + run = client.runs.create( + RunCreateOptions( + workspace=Workspace(id=workspace.id), + configuration_version=ConfigurationVersion(id=cv.id), + message="pyTFE OIDC AWS end-to-end example - create", + ) + ) + print(f" run: {run.id}") + # If the workspace doesn't auto-apply, confirm the plan once it's ready. + # If it does auto-apply, the run will progress straight to `applied`. + end_status = wait_for_run(client, run.id, label="create") + if end_status not in TERMINAL_GOOD: + # Try to confirm a planned-but-not-applied run. + run = client.runs.read(run.id) + if run.status and run.status.value in ( + "planned", + "planned_and_saved", + "cost_estimated", + "policy_checked", + ): + client.runs.apply(run.id) + end_status = wait_for_run(client, run.id, label="apply") + if end_status not in TERMINAL_GOOD: + raise RuntimeError(f"create run did not succeed: status={end_status}") + + banner("7. AWS: verify EC2 instance exists (proves OIDC federation worked)") + # Brief settle window for AWS API consistency on the tag filter. + instance = None + for _ in range(20): + instance = find_test_instance(ec2) + if instance: + break + time.sleep(3) + if not instance: + raise RuntimeError( + f"expected one running instance with tag Name={INSTANCE_NAME_TAG}, found none" + ) + print(f" instance id: {instance['InstanceId']}") + print(f" state: {instance['State']['Name']}") + print(f" instance type: {instance['InstanceType']}") + print(f" az: {instance['Placement']['AvailabilityZone']}") + print(f" vpc: {instance['VpcId']}") + + if not DESTROY_AFTER_VERIFY: + banner("DONE (skipping destroy per OIDC_DESTROY_AFTER_VERIFY=false)") + print(f" Workspace KEPT: {workspace.id} ({WORKSPACE_NAME})") + print(f" IAM role KEPT: {role_arn}") + print(f" OIDC provider KEPT: {oidc_arn}") + print(f" EC2 instance KEPT: {instance['InstanceId']}") + return 0 + + banner("8. HCP: queue destroy run (re-exercises OIDC the other way)") + destroy_run = client.runs.create( + RunCreateOptions( + workspace=Workspace(id=workspace.id), + message="pyTFE OIDC AWS end-to-end example - destroy", + is_destroy=True, + ) + ) + print(f" run: {destroy_run.id}") + end_status = wait_for_run(client, destroy_run.id, label="destroy") + if end_status not in TERMINAL_GOOD: + destroy_run = client.runs.read(destroy_run.id) + if destroy_run.status and destroy_run.status.value in ( + "planned", + "planned_and_saved", + ): + client.runs.apply(destroy_run.id) + end_status = wait_for_run(client, destroy_run.id, label="destroy-apply") + if end_status not in TERMINAL_GOOD: + raise RuntimeError(f"destroy run did not succeed: status={end_status}") + + banner("9. AWS: confirm EC2 instance terminated") + instance_id = instance["InstanceId"] + for attempt in range(30): + resp = ec2.describe_instances(InstanceIds=[instance_id]) + state = resp["Reservations"][0]["Instances"][0]["State"]["Name"] + print(f" attempt {attempt + 1}: {instance_id} -> {state}") + if state == "terminated": + break + time.sleep(5) + + banner("SUCCESS: end-to-end OIDC federation verified via pyTFE") + print(f" Workspace KEPT: {workspace.id} ({WORKSPACE_NAME})") + print(f" IAM role KEPT: {role_arn}") + print(f" OIDC provider KEPT: {oidc_arn}") + print(" EC2 instance: terminated (cleanup successful)") + return 0 + + except Exception: + traceback.print_exc() + print() + print("!!! FAILURE — leaving all resources in place for inspection !!!") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/oidc_setup.py b/examples/oidc_setup.py new file mode 100644 index 00000000..dc437464 --- /dev/null +++ b/examples/oidc_setup.py @@ -0,0 +1,584 @@ +#!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Reference example: configure HCP Terraform OIDC federation across one +or more workspaces. + +This is a worked example you can run as-is or adapt to your own +automation. It shows two distinct shapes of the same workflow: + + (A) Managed-IAM mode (default): the script provisions a per-workspace + IAM role for you and points the workspace at it. + + (B) Bring-your-own-role mode (--use-existing-role ): the script + makes no AWS API calls; it just sets the OIDC environment variables + on each workspace pointing at the role ARN you already manage + elsewhere (Terraform, CDK, Pulumi, console, ...). + +Per workspace, the script: + 1. Reads the workspace (or creates it if --create-missing). + 2. In managed-IAM mode: ensures a workspace-scoped IAM role with a + trust policy bound to that workspace's OIDC `sub` claim, and + optionally attaches AWS-managed or inline permissions to it. + 3. Sets TFC_AWS_PROVIDER_AUTH=true and TFC_AWS_RUN_ROLE_ARN= on + the workspace. + 4. Optionally removes any pre-existing static AWS_* credentials on the + workspace (only with --remove-static-aws-creds). + +In managed-IAM mode, the IAM OIDC provider for app.terraform.io is +account-global — one per AWS account — so it is created once and reused +on subsequent runs. Pass --skip-identity-provider to assume it already +exists. --use-existing-role implies that (no AWS calls happen at all). + +Tokens (sensitive) come from environment variables: + TFE_TOKEN (always) + AWS_ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY, + AWS_SESSION_TOKEN (managed-IAM mode only) + +Everything else is a CLI flag. + +Examples: + + # Managed-IAM mode — script provisions the role. + python examples/oidc_setup.py \\ + --cloud aws --org my-org \\ + --workspaces prod-app,staging-app \\ + --attach-managed-policy arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess + + # Bring-your-own-role — script only updates workspace env vars. + python examples/oidc_setup.py \\ + --cloud aws --org my-org \\ + --workspaces prod-app,staging-app \\ + --use-existing-role arn:aws:iam::111122223333:role/my-tfc-role + +Re-runs are idempotent. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import socket +import ssl +import sys +from dataclasses import dataclass, field +from pathlib import Path + +import boto3 +from botocore.exceptions import ClientError + +from pytfe import TFEClient +from pytfe.errors import TFEError +from pytfe.models import ( + CategoryType, + VariableCreateOptions, + Workspace, + WorkspaceCreateOptions, +) + +OIDC_PROVIDER_URL = "app.terraform.io" +OIDC_AUDIENCE = "aws.workload.identity" +STATIC_AWS_VARS = ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN") + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +@dataclass +class Args: + cloud: str + org: str + workspaces: list[str] + aws_region: str + role_name_template: str + skip_identity_provider: bool + create_missing: bool + remove_static_aws_creds: bool + use_existing_role: str | None = None + attach_managed_policies: list[str] = field(default_factory=list) + inline_policy_path: Path | None = None + + +def parse_args(argv: list[str] | None = None) -> Args: + p = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p.add_argument( + "--cloud", + choices=("aws",), + required=True, + help="Cloud to set up. Only 'aws' is supported today; flag exists " + "so azure/gcp/vault can be added later without breaking callers.", + ) + p.add_argument( + "--org", + default=os.environ.get("TFE_ORG"), + help="HCP Terraform organization (default: $TFE_ORG).", + ) + p.add_argument( + "--workspaces", + required=True, + help="Comma-separated workspace names. e.g. 'prod-app,staging-app,dev-app'.", + ) + p.add_argument( + "--aws-region", + default="us-east-1", + help="AWS region for the boto3 clients. The OIDC provider and IAM " + "role are global so region mostly affects what the EC2/etc. role " + "is used for in downstream Terraform code. (default: us-east-1)", + ) + p.add_argument( + "--role-name-template", + default="tfc-{workspace}-oidc", + help="Format string for the IAM role name; '{workspace}' is " + "replaced with each workspace name. Must contain '{workspace}' " + "(per-workspace roles are required for least-privilege isolation). " + "(default: tfc-{workspace}-oidc)", + ) + p.add_argument( + "--skip-identity-provider", + action="store_true", + help="Don't create or check the IAM OIDC provider for " + "app.terraform.io. Use this if you've already provisioned it via " + "Terraform or another tool. By default the script idempotently " + "creates it if missing and reuses it if present.", + ) + p.add_argument( + "--create-missing", + action="store_true", + help="Create the HCP workspace if it doesn't already exist. " + "Default behaviour fails on a missing workspace so you don't " + "accidentally provision new workspaces in production orgs.", + ) + p.add_argument( + "--remove-static-aws-creds", + action="store_true", + help="Remove any pre-existing AWS_ACCESS_KEY_ID / " + "AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN environment variables " + "on the workspace. This is the whole point of switching to OIDC; " + "leaving them in place lets the AWS provider keep using the " + "static creds instead of the OIDC token. Opt in explicitly to " + "avoid surprising deletions.", + ) + p.add_argument( + "--attach-managed-policy", + action="append", + default=[], + metavar="ARN", + help="AWS-managed (or customer-managed) policy ARN to attach to " + "each workspace's role. Repeatable. e.g. --attach-managed-policy " + "arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess", + ) + p.add_argument( + "--inline-policy", + type=Path, + default=None, + metavar="FILE", + help="Path to a JSON file containing an inline IAM policy " + "document. The same policy is attached to every workspace's role " + "under the name '-inline'.", + ) + p.add_argument( + "--use-existing-role", + default=None, + metavar="ARN", + help="Bring-your-own-role mode. Skip all IAM provisioning and set " + "the provided role ARN as TFC_AWS_RUN_ROLE_ARN on every listed " + "workspace. Use this when the OIDC provider, IAM role, trust " + "policy and permissions are already managed elsewhere (e.g. by " + "your own Terraform or CDK code). This flag implies " + "--skip-identity-provider, disables --role-name-template, and is " + "mutually exclusive with --attach-managed-policy and " + "--inline-policy. AWS credentials are not required in the " + "environment in this mode.", + ) + + ns = p.parse_args(argv) + + if not ns.org: + p.error("--org is required (or set $TFE_ORG).") + + workspaces = [w.strip() for w in ns.workspaces.split(",") if w.strip()] + if not workspaces: + p.error("--workspaces must list at least one non-empty name.") + + if ns.use_existing_role: + # Cheap shape check: catches typos without trying to be a full + # ARN validator. Real validation happens server-side when the + # workspace runs Terraform and tries to assume the role. + if ( + not ns.use_existing_role.startswith("arn:") + or ":role/" not in ns.use_existing_role + ): + p.error( + f"--use-existing-role must be a full IAM role ARN " + f"(arn:aws:iam:::role/); got {ns.use_existing_role!r}" + ) + if ns.attach_managed_policy or ns.inline_policy: + p.error( + "--use-existing-role is mutually exclusive with " + "--attach-managed-policy / --inline-policy: the script " + "doesn't manage permissions on roles it didn't create." + ) + else: + if "{workspace}" not in ns.role_name_template: + p.error( + "--role-name-template must contain the '{workspace}' placeholder " + "(per-workspace IAM isolation). For a shared role across many " + "workspaces, use --use-existing-role instead." + ) + + if ns.inline_policy and not ns.inline_policy.is_file(): + p.error(f"--inline-policy path not found: {ns.inline_policy}") + + return Args( + cloud=ns.cloud, + org=ns.org, + workspaces=workspaces, + aws_region=ns.aws_region, + role_name_template=ns.role_name_template, + skip_identity_provider=ns.skip_identity_provider, + create_missing=ns.create_missing, + remove_static_aws_creds=ns.remove_static_aws_creds, + use_existing_role=ns.use_existing_role, + attach_managed_policies=ns.attach_managed_policy, + inline_policy_path=ns.inline_policy, + ) + + +# --------------------------------------------------------------------------- +# AWS helpers +# --------------------------------------------------------------------------- + + +def _terraform_thumbprint() -> str: + ctx = ssl.create_default_context() + with socket.create_connection((OIDC_PROVIDER_URL, 443), timeout=10) as sock: + with ctx.wrap_socket(sock, server_hostname=OIDC_PROVIDER_URL) as ssock: + cert = ssock.getpeercert(binary_form=True) + # AWS API expects SHA1; this is not used as a security primitive (since + # July 2023 AWS validates the certificate chain, not the thumbprint). + return hashlib.sha1(cert).hexdigest() # noqa: S324 + + +def ensure_identity_provider(iam) -> tuple[str, bool]: + """Return (OIDC provider ARN, was_created). Idempotent: reuses any + existing provider for the same URL rather than recreating it. + + The IAM OIDC provider for app.terraform.io is an AWS-account-global + resource — only one can exist per issuer URL per account — so on the + second and subsequent script invocations this just returns the ARN + of the already-existing provider. + """ + expected_url = f"https://{OIDC_PROVIDER_URL}" + for p in iam.list_open_id_connect_providers()["OpenIDConnectProviderList"]: + d = iam.get_open_id_connect_provider(OpenIDConnectProviderArn=p["Arn"]) + if f"https://{d['Url']}" == expected_url: + return p["Arn"], False + resp = iam.create_open_id_connect_provider( + Url=expected_url, + ClientIDList=[OIDC_AUDIENCE], + ThumbprintList=[_terraform_thumbprint()], + ) + return resp["OpenIDConnectProviderArn"], True + + +def lookup_identity_provider_arn(iam) -> str: + """Return the existing OIDC provider ARN; error if missing.""" + expected_url = f"https://{OIDC_PROVIDER_URL}" + for p in iam.list_open_id_connect_providers()["OpenIDConnectProviderList"]: + d = iam.get_open_id_connect_provider(OpenIDConnectProviderArn=p["Arn"]) + if f"https://{d['Url']}" == expected_url: + return p["Arn"] + raise RuntimeError( + f"No IAM OIDC provider found for {expected_url}. Either drop " + "--skip-identity-provider so the script creates one, or " + "provision it out of band first." + ) + + +def trust_policy_for(oidc_provider_arn: str, org: str, workspace_name: str) -> dict: + """Trust policy that lets ONLY the named workspace assume the role.""" + return { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Federated": oidc_provider_arn}, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + f"{OIDC_PROVIDER_URL}:aud": OIDC_AUDIENCE, + }, + "StringLike": { + # HCP issues sub as: + # organization::project::workspace::run_phase: + # Wildcard project + run_phase so plan/apply/refresh + # all work and the project slug doesn't have to be + # pinned. Workspace stays exact so this role is + # locked to one workspace only. + f"{OIDC_PROVIDER_URL}:sub": ( + f"organization:{org}" + f":project:*" + f":workspace:{workspace_name}" + f":run_phase:*" + ), + }, + }, + } + ], + } + + +def ensure_role( + iam, + role_name: str, + oidc_provider_arn: str, + org: str, + workspace_name: str, +) -> tuple[str, bool]: + """Create or update the IAM role. Returns (arn, was_created).""" + trust = trust_policy_for(oidc_provider_arn, org, workspace_name) + try: + existing = iam.get_role(RoleName=role_name) + iam.update_assume_role_policy( + RoleName=role_name, PolicyDocument=json.dumps(trust) + ) + return existing["Role"]["Arn"], False + except ClientError as e: + if e.response["Error"]["Code"] != "NoSuchEntity": + raise + resp = iam.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=json.dumps(trust), + Description=( + f"OIDC federation role for HCP Terraform workspace '{workspace_name}' " + "- managed by pyTFE oidc_setup.py" + ), + ) + return resp["Role"]["Arn"], True + + +def sync_role_policies( + iam, role_name: str, managed_arns: list[str], inline_doc: dict | None +) -> None: + """Attach the requested managed policies and (optionally) write an inline policy. + + Existing policies that aren't in the requested set are left alone — the + script doesn't aggressively detach things it didn't add, to avoid + surprising removals on shared roles. + """ + if managed_arns: + attached = { + p["PolicyArn"] + for p in iam.list_attached_role_policies(RoleName=role_name)[ + "AttachedPolicies" + ] + } + for arn in managed_arns: + if arn not in attached: + iam.attach_role_policy(RoleName=role_name, PolicyArn=arn) + + if inline_doc is not None: + iam.put_role_policy( + RoleName=role_name, + PolicyName=f"{role_name}-inline", + PolicyDocument=json.dumps(inline_doc), + ) + + +# --------------------------------------------------------------------------- +# HCP helpers +# --------------------------------------------------------------------------- + + +def get_or_create_workspace( + client: TFEClient, org: str, name: str, *, create_missing: bool +) -> Workspace: + try: + return client.workspaces.read(name, organization=org) + except TFEError: + if not create_missing: + raise RuntimeError( + f"workspace '{name}' not found in org '{org}'. " + "Pass --create-missing to create it, or check the name." + ) from None + return client.workspaces.create( + org, WorkspaceCreateOptions(name=name, auto_apply=False) + ) + + +def upsert_env_var( + client: TFEClient, workspace_id: str, key: str, value: str, *, sensitive: bool +) -> str: + """Replace if present, create if not. Returns 'created' or 'updated'.""" + for v in client.variables.list(workspace_id): + if v.key == key: + client.variables.delete(workspace_id, v.id) + client.variables.create( + workspace_id, + VariableCreateOptions( + key=key, + value=value, + category=CategoryType.ENV, + sensitive=sensitive, + ), + ) + return "updated" + client.variables.create( + workspace_id, + VariableCreateOptions( + key=key, value=value, category=CategoryType.ENV, sensitive=sensitive + ), + ) + return "created" + + +def remove_static_aws_creds(client: TFEClient, workspace_id: str) -> list[str]: + """Delete AWS_ACCESS_KEY_ID/SECRET/SESSION_TOKEN env vars. Returns removed keys.""" + removed = [] + for v in list(client.variables.list(workspace_id)): + if v.category == CategoryType.ENV and v.key in STATIC_AWS_VARS and v.id: + client.variables.delete(workspace_id, v.id) + removed.append(v.key) + return removed + + +# --------------------------------------------------------------------------- +# Main flow +# --------------------------------------------------------------------------- + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + + print(f"HCP org: {args.org}") + print(f"Workspaces: {', '.join(args.workspaces)}") + + if args.use_existing_role: + # Bring-your-own-role: skip every boto3 call. + iam = None + oidc_arn = None + inline_doc = None + print("Mode: bring-your-own-role") + print(f"Existing role: {args.use_existing_role}") + print() + else: + print("Mode: managed-IAM") + print(f"AWS region: {args.aws_region}") + print(f"Role template: {args.role_name_template}") + + boto3.setup_default_session(region_name=args.aws_region) + iam = boto3.client("iam") + sts = boto3.client("sts") + print(f"Caller identity: {sts.get_caller_identity()['Arn']}") + print() + + if args.skip_identity_provider: + oidc_arn = lookup_identity_provider_arn(iam) + print( + f"IDP: reusing existing OIDC provider (--skip-identity-provider): {oidc_arn}" + ) + else: + oidc_arn, idp_created = ensure_identity_provider(iam) + if idp_created: + print( + f"IDP: CREATED new OIDC provider for app.terraform.io: {oidc_arn}" + ) + else: + print( + f"IDP: reused existing OIDC provider for app.terraform.io: {oidc_arn}" + ) + print() + + inline_doc = None + if args.inline_policy_path: + inline_doc = json.loads(args.inline_policy_path.read_text()) + print(f"Inline policy loaded from {args.inline_policy_path}") + print() + + client = TFEClient() + failures: list[tuple[str, str]] = [] + + for ws_name in args.workspaces: + print(f"--- {ws_name} ---") + try: + ws = get_or_create_workspace( + client, args.org, ws_name, create_missing=args.create_missing + ) + print(f" workspace: {ws.id}") + + if args.use_existing_role: + role_arn = args.use_existing_role + print(f" IAM role: {role_arn} (external)") + else: + role_name = args.role_name_template.format(workspace=ws_name) + # IAM names are limited to 64 chars. + if len(role_name) > 64: + raise RuntimeError( + f"derived role name '{role_name}' is {len(role_name)} chars; " + "IAM limit is 64. Use a shorter --role-name-template." + ) + role_arn, created = ensure_role( + iam, role_name, oidc_arn, args.org, ws_name + ) + action = "created" if created else "trust refreshed" + print(f" IAM role: {role_arn} ({action})") + + if args.attach_managed_policies or inline_doc is not None: + sync_role_policies( + iam, role_name, args.attach_managed_policies, inline_doc + ) + for arn in args.attach_managed_policies: + print(f" managed: {arn}") + if inline_doc is not None: + print(f" inline: {role_name}-inline") + else: + print( + " WARNING: no policies attached. Role can be assumed but " + "has no AWS permissions. Pass --attach-managed-policy or " + "--inline-policy." + ) + + for key, value, sensitive in [ + ("TFC_AWS_PROVIDER_AUTH", "true", False), + ("TFC_AWS_RUN_ROLE_ARN", role_arn, True), + ]: + action = upsert_env_var(client, ws.id, key, value, sensitive=sensitive) + shown = "(sensitive)" if sensitive else value + print(f" env var: {key} = {shown} ({action})") + + if args.remove_static_aws_creds: + removed = remove_static_aws_creds(client, ws.id) + if removed: + print(f" removed static: {', '.join(removed)}") + else: + print(" removed static: (none present)") + + except Exception as exc: # noqa: BLE001 + print(f" FAILED: {type(exc).__name__}: {exc}") + failures.append((ws_name, str(exc))) + print() + + # ---- Summary ---- + print("=" * 64) + print( + f"OIDC setup: {len(args.workspaces) - len(failures)} of " + f"{len(args.workspaces)} workspace(s) succeeded" + ) + if failures: + for name, msg in failures: + print(f" FAILED {name}: {msg}") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/pytfe/models/oidc_configuration.py b/src/pytfe/models/oidc_configuration.py index 12bec9be..f8ae6e02 100644 --- a/src/pytfe/models/oidc_configuration.py +++ b/src/pytfe/models/oidc_configuration.py @@ -28,14 +28,7 @@ def _non_empty_role_arn(value: str) -> str: - """AWS role ARNs must be non-empty strings. - - go-tfe's ``AWSOIDCConfigurationUpdateOptions.valid()`` rejects empty - role ARNs locally with ``ErrRequiredRoleARN``. We mirror that for both - create and update so callers get a clear pydantic error at construction - time instead of an opaque server-side 422 (or worse, an accepted but - malformed config record). - """ + """AWS role ARNs must be non-empty strings.""" if not value or not value.strip(): raise ValueError("role_arn must be a non-empty string") return value @@ -73,15 +66,7 @@ class AWSOIDCConfigurationCreateOptions(BaseModel): class AWSOIDCConfigurationUpdateOptions(BaseModel): - """Options for updating an AWS OIDC configuration. - - Unlike Azure/GCP/Vault — whose update options are fully partial — - ``role_arn`` is REQUIRED here. The AWS resource has exactly one - updatable attribute, so an update with no fields is meaningless; - go-tfe's ``AWSOIDCConfigurationUpdateOptions.valid()`` rejects the - empty case locally with ``ErrRequiredRoleARN`` and we mirror that - behaviour. - """ + """Options for updating an AWS OIDC configuration.""" model_config = ConfigDict(populate_by_name=True, validate_by_name=True) diff --git a/tests/units/test_oidc_configurations.py b/tests/units/test_oidc_configurations.py index 67c75e81..3684af2b 100644 --- a/tests/units/test_oidc_configurations.py +++ b/tests/units/test_oidc_configurations.py @@ -135,8 +135,7 @@ def test_update_omits_none_fields(self) -> None: def test_update_requires_role_arn(self) -> None: # AWS update has exactly one updatable field; constructing the - # options without a role_arn is a local error — matches go-tfe's - # ErrRequiredRoleARN behaviour. + # options without a role_arn is a local error import pydantic with pytest.raises(pydantic.ValidationError): From 588bfc1eab9b051dbeeabd33ea199f44c2af5e46 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Fri, 29 May 2026 00:10:19 +0530 Subject: [PATCH 3/4] fix codeql security error --- examples/oidc_aws_e2e.py | 26 +++++++------------------- examples/oidc_setup.py | 30 ++++++++++++++++-------------- 2 files changed, 23 insertions(+), 33 deletions(-) diff --git a/examples/oidc_aws_e2e.py b/examples/oidc_aws_e2e.py index e7236acf..668e91aa 100644 --- a/examples/oidc_aws_e2e.py +++ b/examples/oidc_aws_e2e.py @@ -65,12 +65,9 @@ from __future__ import annotations -import hashlib import io import json import os -import socket -import ssl import sys import tarfile import time @@ -105,6 +102,12 @@ OIDC_PROVIDER_URL = "app.terraform.io" OIDC_AUDIENCE = "aws.workload.identity" +# Placeholder thumbprint for IAM's CreateOpenIDConnectProvider. AWS no +# longer validates this field for providers backed by Amazon Trust Services +# CAs (app.terraform.io is one) — any 40-char hex string is accepted. +# See: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc_verify-thumbprint.html +OIDC_PROVIDER_THUMBPRINT_PLACEHOLDER = "0" * 40 + # Tag applied to the test instance so we can find/verify it later. INSTANCE_NAME_TAG = WORKSPACE_NAME @@ -176,20 +179,6 @@ def banner(s: str) -> None: print("=" * 72) -def get_app_terraform_thumbprint() -> str: - """Fetch the leaf cert SHA1 thumbprint for app.terraform.io. - - AWS no longer strictly enforces this thumbprint for IdPs backed by - Amazon Trust Services CAs (since July 2023), but the API still - requires the field. We pass the real leaf thumbprint for correctness. - """ - ctx = ssl.create_default_context() - with socket.create_connection((OIDC_PROVIDER_URL, 443), timeout=10) as sock: - with ctx.wrap_socket(sock, server_hostname=OIDC_PROVIDER_URL) as ssock: - cert = ssock.getpeercert(binary_form=True) - return hashlib.sha1(cert).hexdigest() # noqa: S324 (intentional: AWS API expects SHA1) - - def ensure_oidc_provider(iam) -> str: """Create or reuse the app.terraform.io OIDC provider. Returns ARN.""" expected_url = f"https://{OIDC_PROVIDER_URL}" @@ -199,11 +188,10 @@ def ensure_oidc_provider(iam) -> str: print(f" reusing OIDC provider: {p['Arn']}") return p["Arn"] - thumbprint = get_app_terraform_thumbprint() resp = iam.create_open_id_connect_provider( Url=expected_url, ClientIDList=[OIDC_AUDIENCE], - ThumbprintList=[thumbprint], + ThumbprintList=[OIDC_PROVIDER_THUMBPRINT_PLACEHOLDER], ) arn = resp["OpenIDConnectProviderArn"] print(f" created OIDC provider: {arn}") diff --git a/examples/oidc_setup.py b/examples/oidc_setup.py index dc437464..07d13fce 100644 --- a/examples/oidc_setup.py +++ b/examples/oidc_setup.py @@ -59,11 +59,8 @@ from __future__ import annotations import argparse -import hashlib import json import os -import socket -import ssl import sys from dataclasses import dataclass, field from pathlib import Path @@ -84,6 +81,21 @@ OIDC_AUDIENCE = "aws.workload.identity" STATIC_AWS_VARS = ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN") +# Placeholder thumbprint sent to AWS when creating the OIDC provider. AWS's +# `CreateOpenIDConnectProvider` accepts a `ThumbprintList` parameter that +# was historically expected to be the SHA1 hash of the provider's TLS +# certificate. Since July 2023 AWS no longer validates this value for +# providers backed by Amazon Trust Services CAs (which app.terraform.io +# is) — the cert chain is validated at runtime against the ATS root CAs. +# Any 40-char hex string is accepted in the field. +# See: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc_verify-thumbprint.html +# +# Sending a placeholder removes the need for this script to make a TLS +# connection to app.terraform.io and SHA1-hash the leaf cert — both of +# which trip CodeQL's py/insecure-protocol and py/weak-sensitive-data-hashing +# rules even though neither is a security concern in this context. +OIDC_PROVIDER_THUMBPRINT_PLACEHOLDER = "0" * 40 + # --------------------------------------------------------------------------- # CLI @@ -258,16 +270,6 @@ def parse_args(argv: list[str] | None = None) -> Args: # --------------------------------------------------------------------------- -def _terraform_thumbprint() -> str: - ctx = ssl.create_default_context() - with socket.create_connection((OIDC_PROVIDER_URL, 443), timeout=10) as sock: - with ctx.wrap_socket(sock, server_hostname=OIDC_PROVIDER_URL) as ssock: - cert = ssock.getpeercert(binary_form=True) - # AWS API expects SHA1; this is not used as a security primitive (since - # July 2023 AWS validates the certificate chain, not the thumbprint). - return hashlib.sha1(cert).hexdigest() # noqa: S324 - - def ensure_identity_provider(iam) -> tuple[str, bool]: """Return (OIDC provider ARN, was_created). Idempotent: reuses any existing provider for the same URL rather than recreating it. @@ -285,7 +287,7 @@ def ensure_identity_provider(iam) -> tuple[str, bool]: resp = iam.create_open_id_connect_provider( Url=expected_url, ClientIDList=[OIDC_AUDIENCE], - ThumbprintList=[_terraform_thumbprint()], + ThumbprintList=[OIDC_PROVIDER_THUMBPRINT_PLACEHOLDER], ) return resp["OpenIDConnectProviderArn"], True From 953b57bd4a55f1c2cee0bb9ccbed6e50a0306e8b Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Mon, 15 Jun 2026 15:11:32 +0530 Subject: [PATCH 4/4] fix lints --- src/pytfe/errors.py | 3 +++ src/pytfe/models/__init__.py | 1 + 2 files changed, 4 insertions(+) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index fa8c3fb3..7e4842f3 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -746,6 +746,9 @@ class InvalidOIDCConfigurationIDError(InvalidValues): """Raised when an invalid OIDC configuration ID is provided.""" def __init__(self, message: str = "invalid value for OIDC configuration ID"): + super().__init__(message) + + # Admin SAML/SCIM + GitHub App installation errors class InvalidSAMLProviderTypeError(InvalidValues): """Raised when an unrecognised SAML provider type is supplied.""" diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index 8b68ea0e..cb4a432a 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -193,6 +193,7 @@ VaultOIDCConfiguration, VaultOIDCConfigurationCreateOptions, VaultOIDCConfigurationUpdateOptions, +) from .org_token_ttl_policy import ( DEFAULT_MAX_TTL_MS, OrgTokenTTLPolicy,