diff --git a/anthropic_workloads/.terraform-docs.yml b/anthropic_workloads/.terraform-docs.yml new file mode 100644 index 00000000..a0ed216c --- /dev/null +++ b/anthropic_workloads/.terraform-docs.yml @@ -0,0 +1,6 @@ +content: |- + {{ .Header }} + + {{ .Inputs }} + + {{ .Outputs }} diff --git a/anthropic_workloads/README.md b/anthropic_workloads/README.md new file mode 100644 index 00000000..02904be1 --- /dev/null +++ b/anthropic_workloads/README.md @@ -0,0 +1,21 @@ + +# anthropic\_workloads + +Service accounts and federation rules that let workloads call the Anthropic API with their own OIDC token instead of an API key. + +Issuers and workspaces are created elsewhere and passed in by ID. The provider must authenticate with an `org:admin` token. + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [application](#input\_application) | The name of the application (tenant app\_code). Every workload name must start with it. | `string` | n/a | yes | +| [audience](#input\_audience) | Audience the identity token must carry. | `string` | `"https://api.anthropic.com"` | no | +| [workloads](#input\_workloads) | Workloads that call the Anthropic API with their own OIDC token. The key is the name of the
service account and federation rule, in the form --.
claims are the exact claims the identity token must carry. |
map(object({
issuer_id = string
workspace_id = string
claims = map(string)
}))
| `{}` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [workloads](#output\_workloads) | Federation rule and service account IDs that each workload exchanges its OIDC token against. | + diff --git a/anthropic_workloads/examples/example1.tf b/anthropic_workloads/examples/example1.tf new file mode 100644 index 00000000..018ac9a1 --- /dev/null +++ b/anthropic_workloads/examples/example1.tf @@ -0,0 +1,39 @@ +module "anthropic_workloads" { + source = "github.com/mozilla/terraform-modules//anthropic_workloads?ref=anthropic_workloads-0.1.0" + application = "example" + + workloads = { + # GitHub Actions job on the main branch. + "example-prod-api" = { + issuer_id = "fdis_example_github" + workspace_id = "wrkspc_example" + claims = { + repository = "example-org/example-repo" + ref = "refs/heads/main" + } + } + + # Google service account. sub is the service account's numeric unique ID. + "example-prod-worker" = { + issuer_id = "fdis_example_google" + workspace_id = "wrkspc_example" + claims = { + sub = "111111111111111111111" + email = "worker@example-project.iam.gserviceaccount.com" + } + } + + # Workload with a SPIFFE identity. + "example-prod-agent" = { + issuer_id = "fdis_example_spiffe" + workspace_id = "wrkspc_example" + claims = { + sub = "spiffe://example.org/ns/example/sa/agent" + } + } + } +} + +output "workloads" { + value = module.anthropic_workloads.workloads +} diff --git a/anthropic_workloads/main.tf b/anthropic_workloads/main.tf new file mode 100644 index 00000000..a099971f --- /dev/null +++ b/anthropic_workloads/main.tf @@ -0,0 +1,33 @@ +/** + * # anthropic_workloads + * + * Service accounts and federation rules that let workloads call the Anthropic API with their own OIDC token instead of an API key. + * + * Issuers and workspaces are created elsewhere and passed in by ID. The provider must authenticate with an `org:admin` token. + */ + +resource "anthropic_service_account" "workloads" { + for_each = var.workloads + name = each.key + organization_role = "developer" +} + +resource "anthropic_service_account_workspace" "workloads" { + for_each = var.workloads + service_account_id = anthropic_service_account.workloads[each.key].id + workspace_id = each.value.workspace_id + workspace_role = "workspace_user" +} + +resource "anthropic_federation_rule" "workloads" { + for_each = var.workloads + name = each.key + issuer_id = each.value.issuer_id + service_account_id = anthropic_service_account.workloads[each.key].id + oauth_scope = "workspace:inference" + workspace_ids = [each.value.workspace_id] + match = { + audience = var.audience + claims = each.value.claims + } +} diff --git a/anthropic_workloads/outputs.tf b/anthropic_workloads/outputs.tf new file mode 100644 index 00000000..ecfe7123 --- /dev/null +++ b/anthropic_workloads/outputs.tf @@ -0,0 +1,10 @@ +output "workloads" { + description = "Federation rule and service account IDs that each workload exchanges its OIDC token against." + value = { + for name, w in var.workloads : name => { + federation_rule_id = anthropic_federation_rule.workloads[name].id + service_account_id = anthropic_service_account.workloads[name].id + workspace_id = w.workspace_id + } + } +} diff --git a/anthropic_workloads/tests/anthropic_workloads.tftest.hcl b/anthropic_workloads/tests/anthropic_workloads.tftest.hcl new file mode 100644 index 00000000..395ffd62 --- /dev/null +++ b/anthropic_workloads/tests/anthropic_workloads.tftest.hcl @@ -0,0 +1,92 @@ +mock_provider "anthropic" {} + +variables { + application = "app" +} + +run "creates_account_rule_and_link_per_workload" { + command = plan + + variables { + workloads = { + "app-dev-api" = { + issuer_id = "fdis_example" + workspace_id = "wrkspc_example" + claims = { sub = "spiffe://example/api" } + } + } + } + + assert { + condition = anthropic_service_account.workloads["app-dev-api"].organization_role == "developer" + error_message = "Service accounts must have the developer organization role." + } + + assert { + condition = anthropic_federation_rule.workloads["app-dev-api"].oauth_scope == "workspace:inference" + error_message = "Rules must grant workspace:inference only." + } + + assert { + condition = anthropic_federation_rule.workloads["app-dev-api"].match.audience == "https://api.anthropic.com" + error_message = "Rules must match the default audience." + } + + assert { + condition = anthropic_federation_rule.workloads["app-dev-api"].match.claims == tomap({ sub = "spiffe://example/api" }) + error_message = "Rules must match the given claims exactly." + } + + assert { + condition = anthropic_service_account_workspace.workloads["app-dev-api"].workspace_id == "wrkspc_example" + error_message = "Service account must be a member of the workload's workspace." + } +} + +run "name_must_start_with_application" { + command = plan + + variables { + workloads = { + "other-dev-api" = { + issuer_id = "fdis_example" + workspace_id = "wrkspc_example" + claims = { sub = "x" } + } + } + } + + expect_failures = [var.workloads] +} + +run "name_must_be_lowercase" { + command = plan + + variables { + workloads = { + "app-Dev-api" = { + issuer_id = "fdis_example" + workspace_id = "wrkspc_example" + claims = { sub = "x" } + } + } + } + + expect_failures = [var.workloads] +} + +run "claims_are_required" { + command = plan + + variables { + workloads = { + "app-dev-api" = { + issuer_id = "fdis_example" + workspace_id = "wrkspc_example" + claims = {} + } + } + } + + expect_failures = [var.workloads] +} diff --git a/anthropic_workloads/variables.tf b/anthropic_workloads/variables.tf new file mode 100644 index 00000000..78892587 --- /dev/null +++ b/anthropic_workloads/variables.tf @@ -0,0 +1,39 @@ +variable "application" { + description = "The name of the application (tenant app_code). Every workload name must start with it." + type = string +} + +variable "workloads" { + description = <<-EOT + Workloads that call the Anthropic API with their own OIDC token. The key is the name of the + service account and federation rule, in the form --. + claims are the exact claims the identity token must carry. + EOT + type = map(object({ + issuer_id = string + workspace_id = string + claims = map(string) + })) + default = {} + + validation { + condition = alltrue([for name in keys(var.workloads) : can(regex("^[a-z0-9-]{1,255}$", name))]) + error_message = "Workload names use lowercase letters, numbers, and hyphens only." + } + + validation { + condition = alltrue([for name in keys(var.workloads) : startswith(name, "${var.application}-")]) + error_message = "Workload names must start with the application name followed by a hyphen." + } + + validation { + condition = alltrue([for w in values(var.workloads) : length(w.claims) > 0]) + error_message = "Each workload needs at least one claim to match." + } +} + +variable "audience" { + description = "Audience the identity token must carry." + type = string + default = "https://api.anthropic.com" +} diff --git a/anthropic_workloads/versions.tf b/anthropic_workloads/versions.tf new file mode 100644 index 00000000..349564b3 --- /dev/null +++ b/anthropic_workloads/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.9" + + required_providers { + anthropic = { + source = "mozilla/anthropic" + version = ">= 0.1.0, < 1.0.0" + } + } +}