diff --git a/docs/resources/json_web_key_set.md b/docs/resources/json_web_key_set.md index 0d9bcba..2d8072d 100644 --- a/docs/resources/json_web_key_set.md +++ b/docs/resources/json_web_key_set.md @@ -13,7 +13,7 @@ JSON Web Keys are used for signing and encrypting tokens. This resource generate -> **Plan:** Available on all Ory Network plans. -~> **Note:** This resource is **immutable**. Any change to `set_id`, `key_id`, `algorithm`, or `use` will destroy the existing key set and create a new one. Private keys in the old set will be permanently lost. +~> **Note:** This resource is **immutable**. Any change to `project_id`, `set_id`, `key_id`, `algorithm`, or `use` will destroy the existing key set and create a new one. Private keys in the old set will be permanently lost. ## Algorithms @@ -46,7 +46,7 @@ Most configurations only need `use = "sig"`. ## Example Usage ```terraform -# RSA signing key set +# RSA signing key set (project_id from provider config) resource "ory_json_web_key_set" "signing" { set_id = "token-signing-keys" key_id = "rsa-sig-1" @@ -54,12 +54,13 @@ resource "ory_json_web_key_set" "signing" { use = "sig" } -# ECDSA signing key set (smaller, faster) +# ECDSA signing key set with explicit project_id resource "ory_json_web_key_set" "ecdsa_signing" { - set_id = "ecdsa-signing-keys" - key_id = "ec-sig-1" - algorithm = "ES256" - use = "sig" + project_id = var.ory_project_id + set_id = "ecdsa-signing-keys" + key_id = "ec-sig-1" + algorithm = "ES256" + use = "sig" } # Encryption key set @@ -98,9 +99,10 @@ On read, the provider extracts `algorithm`, `use`, and `key_id` from the **first ## Import -Import using the set ID: +Import using the format `project_id/set_id` or just `set_id` (uses the provider's configured project context, either `project_id` or `project_slug`): ```shell +terraform import ory_json_web_key_set.signing /token-signing-keys terraform import ory_json_web_key_set.signing token-signing-keys ``` @@ -116,6 +118,10 @@ After import, `key_id` is populated from the first key in the set. If the set co - `set_id` (String) The ID of the JSON Web Key Set. - `use` (String) The intended use: sig (signature) or enc (encryption). +### Optional + +- `project_id` (String) The project ID. If not set, uses the provider's project_id or project_slug. + ### Read-Only - `id` (String) Internal Terraform ID (same as set_id). diff --git a/examples/resources/ory_json_web_key_set/resource.tf b/examples/resources/ory_json_web_key_set/resource.tf index bdb3cae..24ef912 100644 --- a/examples/resources/ory_json_web_key_set/resource.tf +++ b/examples/resources/ory_json_web_key_set/resource.tf @@ -1,4 +1,4 @@ -# RSA signing key set +# RSA signing key set (project_id from provider config) resource "ory_json_web_key_set" "signing" { set_id = "token-signing-keys" key_id = "rsa-sig-1" @@ -6,12 +6,13 @@ resource "ory_json_web_key_set" "signing" { use = "sig" } -# ECDSA signing key set (smaller, faster) +# ECDSA signing key set with explicit project_id resource "ory_json_web_key_set" "ecdsa_signing" { - set_id = "ecdsa-signing-keys" - key_id = "ec-sig-1" - algorithm = "ES256" - use = "sig" + project_id = var.ory_project_id + set_id = "ecdsa-signing-keys" + key_id = "ec-sig-1" + algorithm = "ES256" + use = "sig" } # Encryption key set diff --git a/internal/client/client.go b/internal/client/client.go index b99a1cf..7b8f783 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -329,6 +329,11 @@ type OryClient struct { // immediately after PatchProject. This cache ensures Read operations // see the latest state after Create/Update. cachedProjects sync.Map + + // cachedSlugs caches project ID -> slug mappings resolved via the console API. + // This avoids redundant API calls when multiple CRUD operations resolve the + // same project_id within a single Terraform run. + cachedSlugs sync.Map } // NewOryClient creates a new Ory API client. @@ -454,6 +459,42 @@ func (c *OryClient) WorkspaceID() string { return c.config.WorkspaceID } +// ResolveProjectSlug resolves a project ID to its slug via the console API. +// Results are cached to avoid redundant API calls within a single Terraform run. +func (c *OryClient) ResolveProjectSlug(ctx context.Context, projectID string) (string, error) { + if projectID == "" { + return "", fmt.Errorf("project_id must not be empty") + } + if slug, ok := c.cachedSlugs.Load(projectID); ok { + return slug.(string), nil + } + if c.consoleClient == nil { + return "", fmt.Errorf("console API client not configured: workspace_api_key is required to resolve project_id to slug") + } + project, err := c.GetProject(ctx, projectID) + if err != nil { + return "", fmt.Errorf("resolving project slug for project %s: %w", projectID, err) + } + slug := project.GetSlug() + c.cachedSlugs.Store(projectID, slug) + return slug, nil +} + +// ProjectClientForProject returns a project-scoped client for the given project ID. +// It resolves the project slug via the console API and uses the provider's project API key. +func (c *OryClient) ProjectClientForProject(ctx context.Context, projectID string) (*OryClient, error) { + slug, err := c.ResolveProjectSlug(ctx, projectID) + if err != nil { + return nil, err + } + apiKey := c.config.ProjectAPIKey + if apiKey == "" { + return nil, fmt.Errorf("project_api_key is required for project API operations (JWK, OAuth2, etc.): " + + "set it on the provider or via ORY_PROJECT_API_KEY environment variable") + } + return c.WithProjectCredentials(slug, apiKey), nil +} + // WithProjectCredentials returns a new OryClient that uses the given project // credentials. The returned client shares the console client with the parent // but has its own isolated project client (lazily initialized). diff --git a/internal/resources/jwk/resource.go b/internal/resources/jwk/resource.go index ea2964e..2ab5b04 100644 --- a/internal/resources/jwk/resource.go +++ b/internal/resources/jwk/resource.go @@ -4,8 +4,10 @@ import ( "context" "encoding/json" "fmt" + "strings" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" @@ -38,6 +40,7 @@ type JWKResource struct { // JWKResourceModel describes the resource data model. type JWKResourceModel struct { ID types.String `tfsdk:"id"` + ProjectID types.String `tfsdk:"project_id"` SetID types.String `tfsdk:"set_id"` KeyID types.String `tfsdk:"key_id"` Algorithm types.String `tfsdk:"algorithm"` @@ -61,10 +64,11 @@ generate and manage custom key sets for your Ory project. ` + "```hcl" + ` resource "ory_json_web_key_set" "signing" { - set_id = "my-signing-keys" - key_id = "sig-key-1" - algorithm = "RS256" - use = "sig" + project_id = var.ory_project_id + set_id = "my-signing-keys" + key_id = "sig-key-1" + algorithm = "RS256" + use = "sig" } ` + "```" + ` @@ -81,9 +85,10 @@ resource "ory_json_web_key_set" "encryption" { ## Import -JWK sets can be imported using their set ID: +JWK sets can be imported using the format ` + "`project_id/set_id`" + ` or just ` + "`set_id`" + `: ` + "```shell" + ` +terraform import ory_json_web_key_set.signing /my-signing-keys terraform import ory_json_web_key_set.signing my-signing-keys ` + "```" + ` ` @@ -100,6 +105,18 @@ func (r *JWKResource) Schema(ctx context.Context, req resource.SchemaRequest, re stringplanmodifier.UseStateForUnknown(), }, }, + "project_id": schema.StringAttribute{ + Description: "The project ID. If not set, uses the provider's project_id or project_slug.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, "set_id": schema.StringAttribute{ Description: "The ID of the JSON Web Key Set.", Required: true, @@ -168,13 +185,19 @@ func (r *JWKResource) Create(ctx context.Context, req resource.CreateRequest, re return } + projectClient, projectID, diags := r.projectClient(ctx, plan.ProjectID) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + body := ory.CreateJsonWebKeySet{ Alg: plan.Algorithm.ValueString(), Kid: plan.KeyID.ValueString(), Use: plan.Use.ValueString(), } - jwks, err := r.client.CreateJsonWebKeySet(ctx, plan.SetID.ValueString(), body) + jwks, err := projectClient.CreateJsonWebKeySet(ctx, plan.SetID.ValueString(), body) if err != nil { resp.Diagnostics.AddError( "Error Creating JSON Web Key Set", @@ -184,6 +207,9 @@ func (r *JWKResource) Create(ctx context.Context, req resource.CreateRequest, re } plan.ID = types.StringValue(plan.SetID.ValueString()) + if projectID != "" { + plan.ProjectID = types.StringValue(projectID) + } // Serialize the keys to JSON if len(jwks.Keys) > 0 { @@ -204,9 +230,14 @@ func (r *JWKResource) Read(ctx context.Context, req resource.ReadRequest, resp * return } - jwks, err := r.client.GetJsonWebKeySet(ctx, state.SetID.ValueString()) + projectClient, projectID, diags := r.projectClient(ctx, state.ProjectID) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + jwks, err := projectClient.GetJsonWebKeySet(ctx, state.SetID.ValueString()) if err != nil { - // Check if it's a 404 resp.Diagnostics.AddError( "Error Reading JSON Web Key Set", "Could not read JWK set "+state.SetID.ValueString()+": "+err.Error(), @@ -219,6 +250,10 @@ func (r *JWKResource) Read(ctx context.Context, req resource.ReadRequest, resp * return } + if projectID != "" { + state.ProjectID = types.StringValue(projectID) + } + // Serialize the keys to JSON keysJSON, err := json.Marshal(jwks) if err == nil { @@ -253,7 +288,13 @@ func (r *JWKResource) Delete(ctx context.Context, req resource.DeleteRequest, re return } - err := r.client.DeleteJsonWebKeySet(ctx, state.SetID.ValueString()) + projectClient, _, diags := r.projectClient(ctx, state.ProjectID) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + err := projectClient.DeleteJsonWebKeySet(ctx, state.SetID.ValueString()) if err != nil { resp.Diagnostics.AddError( "Error Deleting JSON Web Key Set", @@ -264,6 +305,71 @@ func (r *JWKResource) Delete(ctx context.Context, req resource.DeleteRequest, re } func (r *JWKResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("set_id"), req.ID)...) - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), req.ID)...) + // Import ID format: project_id/set_id or just set_id (uses provider's project_id) + id := req.ID + var projectID, setID string + + if strings.Contains(id, "/") { + parts := strings.SplitN(id, "/", 2) + projectID = parts[0] + setID = parts[1] + if projectID == "" || setID == "" { + resp.Diagnostics.AddError( + "Invalid Import ID", + fmt.Sprintf("Expected format \"project_id/set_id\" or \"set_id\", got %q.", id), + ) + return + } + } else { + setID = id + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("set_id"), setID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), setID)...) + if projectID != "" { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectID)...) + } +} + +// projectClient returns a client scoped to the correct project along with the +// resolved project ID. When a resource-level project_id is set, the slug is +// resolved via the console API and a project-scoped client is returned. When +// only the provider's project_slug+project_api_key are configured (no +// project_id anywhere), the provider's default client is returned as-is so +// existing configurations are not broken. +func (r *JWKResource) projectClient(ctx context.Context, tfProjectID types.String) (*client.OryClient, string, diag.Diagnostics) { + var diags diag.Diagnostics + + // Use explicit resource-level project_id first. + if !tfProjectID.IsNull() && !tfProjectID.IsUnknown() && tfProjectID.ValueString() != "" { + pid := tfProjectID.ValueString() + c, err := r.client.ProjectClientForProject(ctx, pid) + if err != nil { + diags.AddError( + fmt.Sprintf("Error Resolving Project %q", pid), + fmt.Sprintf("Could not resolve project_id %q to a project slug: %s. "+ + "Ensure the project_id is valid and that workspace_api_key is configured on the provider.", pid, err.Error()), + ) + return nil, "", diags + } + return c, pid, diags + } + + // Fall back to provider-level project_id. + if pid := r.client.ProjectID(); pid != "" { + c, err := r.client.ProjectClientForProject(ctx, pid) + if err != nil { + diags.AddError( + fmt.Sprintf("Error Resolving Provider Project %q", pid), + fmt.Sprintf("Could not resolve provider project_id %q to a project slug: %s. "+ + "Ensure the project_id is valid and that workspace_api_key is configured on the provider.", pid, err.Error()), + ) + return nil, "", diags + } + return c, pid, diags + } + + // No project_id at all -- the provider may still have project_slug + + // project_api_key configured directly, so fall back to the default client. + return r.client, "", diags } diff --git a/internal/resources/jwk/resource_test.go b/internal/resources/jwk/resource_test.go index f37ee1b..e202c9c 100644 --- a/internal/resources/jwk/resource_test.go +++ b/internal/resources/jwk/resource_test.go @@ -3,23 +3,50 @@ package jwk_test import ( + "fmt" + "os" "testing" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/ory/terraform-provider-ory/internal/acctest" ) +func importStateJWKID(s *terraform.State) (string, error) { + rs, ok := s.RootModule().Resources["ory_json_web_key_set.test"] + if !ok { + return "", fmt.Errorf("resource not found: ory_json_web_key_set.test") + } + projectID := rs.Primary.Attributes["project_id"] + if projectID == "" { + return "", fmt.Errorf("project_id is empty in state; cannot build composite import ID") + } + setID := rs.Primary.Attributes["set_id"] + if setID == "" { + return "", fmt.Errorf("set_id is empty in state; cannot build composite import ID") + } + return fmt.Sprintf("%s/%s", projectID, setID), nil +} + func TestAccJWKResource_basic(t *testing.T) { + projectID := os.Getenv("ORY_PROJECT_ID") + if projectID == "" { + t.Skip("ORY_PROJECT_ID must be set for JWK acceptance tests") + } + resource.Test(t, resource.TestCase{ PreCheck: func() { acctest.AccPreCheck(t) }, ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories(), Steps: []resource.TestStep{ // Create and Read { - Config: acctest.LoadTestConfig(t, "testdata/basic.tf.tmpl", nil), + Config: acctest.LoadTestConfig(t, "testdata/basic.tf.tmpl", map[string]string{ + "ProjectID": projectID, + }), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttrSet("ory_json_web_key_set.test", "id"), + resource.TestCheckResourceAttr("ory_json_web_key_set.test", "project_id", projectID), resource.TestCheckResourceAttr("ory_json_web_key_set.test", "set_id", "tf-test-jwks"), resource.TestCheckResourceAttr("ory_json_web_key_set.test", "key_id", "tf-test-key"), resource.TestCheckResourceAttr("ory_json_web_key_set.test", "algorithm", "RS256"), @@ -27,11 +54,11 @@ func TestAccJWKResource_basic(t *testing.T) { resource.TestCheckResourceAttrSet("ory_json_web_key_set.test", "keys"), ), }, - // ImportState + // ImportState using composite ID: project_id/set_id { ResourceName: "ory_json_web_key_set.test", ImportState: true, - ImportStateId: "tf-test-jwks", + ImportStateIdFunc: importStateJWKID, ImportStateVerify: true, }, }, diff --git a/internal/resources/jwk/testdata/basic.tf.tmpl b/internal/resources/jwk/testdata/basic.tf.tmpl index a0b89a6..58ce8b2 100644 --- a/internal/resources/jwk/testdata/basic.tf.tmpl +++ b/internal/resources/jwk/testdata/basic.tf.tmpl @@ -1,6 +1,7 @@ resource "ory_json_web_key_set" "test" { - set_id = "tf-test-jwks" - key_id = "tf-test-key" - algorithm = "RS256" - use = "sig" + project_id = "[[ .ProjectID ]]" + set_id = "tf-test-jwks" + key_id = "tf-test-key" + algorithm = "RS256" + use = "sig" } diff --git a/templates/resources/json_web_key_set.md.tmpl b/templates/resources/json_web_key_set.md.tmpl index d07d2f9..8f14574 100644 --- a/templates/resources/json_web_key_set.md.tmpl +++ b/templates/resources/json_web_key_set.md.tmpl @@ -13,7 +13,7 @@ JSON Web Keys are used for signing and encrypting tokens. This resource generate -> **Plan:** Available on all Ory Network plans. -~> **Note:** This resource is **immutable**. Any change to `set_id`, `key_id`, `algorithm`, or `use` will destroy the existing key set and create a new one. Private keys in the old set will be permanently lost. +~> **Note:** This resource is **immutable**. Any change to `project_id`, `set_id`, `key_id`, `algorithm`, or `use` will destroy the existing key set and create a new one. Private keys in the old set will be permanently lost. ## Algorithms @@ -70,9 +70,10 @@ On read, the provider extracts `algorithm`, `use`, and `key_id` from the **first ## Import -Import using the set ID: +Import using the format `project_id/set_id` or just `set_id` (uses the provider's configured project context, either `project_id` or `project_slug`): ```shell +terraform import ory_json_web_key_set.signing /token-signing-keys terraform import ory_json_web_key_set.signing token-signing-keys ```