.
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"
+ }
+ }
+}