diff --git a/docs/resources/json_web_key_set.md b/docs/resources/json_web_key_set.md index 0d9bcba..5b005a8 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 provider's project_id): ```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. + ### 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..37ed76a 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -454,6 +454,35 @@ func (c *OryClient) WorkspaceID() string { return c.config.WorkspaceID } +// ResolveProjectSlug resolves a project ID to its slug via the console API. +// This allows resources to accept project_id and auto-resolve the slug needed +// for project API operations (e.g., JWK, OAuth2). +func (c *OryClient) ResolveProjectSlug(ctx context.Context, projectID string) (string, error) { + 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) + } + return project.GetSlug(), 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..77df227 100644 --- a/internal/resources/jwk/resource.go +++ b/internal/resources/jwk/resource.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/path" @@ -38,6 +39,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 +63,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 +84,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 +104,15 @@ 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.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + }, "set_id": schema.StringAttribute{ Description: "The ID of the JSON Web Key Set.", Required: true, @@ -168,13 +181,23 @@ func (r *JWKResource) Create(ctx context.Context, req resource.CreateRequest, re return } + projectID := r.resolveProjectID(plan.ProjectID) + projectClient, err := r.resolveProjectClient(ctx, projectID) + if err != nil { + resp.Diagnostics.AddError( + "Error Resolving Project", + err.Error(), + ) + 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,7 @@ func (r *JWKResource) Create(ctx context.Context, req resource.CreateRequest, re } plan.ID = types.StringValue(plan.SetID.ValueString()) + plan.ProjectID = types.StringValue(projectID) // Serialize the keys to JSON if len(jwks.Keys) > 0 { @@ -204,9 +228,18 @@ func (r *JWKResource) Read(ctx context.Context, req resource.ReadRequest, resp * return } - jwks, err := r.client.GetJsonWebKeySet(ctx, state.SetID.ValueString()) + projectID := r.resolveProjectID(state.ProjectID) + projectClient, err := r.resolveProjectClient(ctx, projectID) + if err != nil { + resp.Diagnostics.AddError( + "Error Resolving Project", + err.Error(), + ) + 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 +252,8 @@ func (r *JWKResource) Read(ctx context.Context, req resource.ReadRequest, resp * return } + state.ProjectID = types.StringValue(projectID) + // Serialize the keys to JSON keysJSON, err := json.Marshal(jwks) if err == nil { @@ -253,7 +288,17 @@ func (r *JWKResource) Delete(ctx context.Context, req resource.DeleteRequest, re return } - err := r.client.DeleteJsonWebKeySet(ctx, state.SetID.ValueString()) + projectID := r.resolveProjectID(state.ProjectID) + projectClient, err := r.resolveProjectClient(ctx, projectID) + if err != nil { + resp.Diagnostics.AddError( + "Error Resolving Project", + err.Error(), + ) + return + } + + err = projectClient.DeleteJsonWebKeySet(ctx, state.SetID.ValueString()) if err != nil { resp.Diagnostics.AddError( "Error Deleting JSON Web Key Set", @@ -264,6 +309,39 @@ 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] + } 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)...) + } +} + +// resolveProjectID returns the project ID from the resource attribute or falls back to the provider's project_id. +func (r *JWKResource) resolveProjectID(tfProjectID types.String) string { + if !tfProjectID.IsNull() && !tfProjectID.IsUnknown() { + return tfProjectID.ValueString() + } + return r.client.ProjectID() +} + +// resolveProjectClient returns a client configured for the given project. +// If project_id is provided, it resolves the slug via the console API. +// Otherwise, it falls back to the provider's project credentials. +func (r *JWKResource) resolveProjectClient(ctx context.Context, projectID string) (*client.OryClient, error) { + if projectID != "" { + return r.client.ProjectClientForProject(ctx, projectID) + } + return r.client, nil } diff --git a/internal/resources/jwk/resource_test.go b/internal/resources/jwk/resource_test.go index f37ee1b..9cd3ade 100644 --- a/internal/resources/jwk/resource_test.go +++ b/internal/resources/jwk/resource_test.go @@ -3,23 +3,41 @@ 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"] + setID := rs.Primary.Attributes["set_id"] + return fmt.Sprintf("%s/%s", projectID, setID), nil +} + func TestAccJWKResource_basic(t *testing.T) { + projectID := os.Getenv("ORY_PROJECT_ID") + 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 +45,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..8bf5477 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 provider's project_id): ```shell +terraform import ory_json_web_key_set.signing /token-signing-keys terraform import ory_json_web_key_set.signing token-signing-keys ```