Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions docs/resources/json_web_key_set.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -46,20 +46,21 @@ 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"
algorithm = "RS256"
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
Expand Down Expand Up @@ -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 <project-id>/token-signing-keys
terraform import ory_json_web_key_set.signing token-signing-keys
```

Expand All @@ -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).
Expand Down
13 changes: 7 additions & 6 deletions examples/resources/ory_json_web_key_set/resource.tf
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
# 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"
algorithm = "RS256"
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
Expand Down
41 changes: 41 additions & 0 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand Down
128 changes: 117 additions & 11 deletions internal/resources/jwk/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"`
Expand All @@ -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"
}
` + "```" + `

Expand All @@ -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 <project-id>/my-signing-keys
terraform import ory_json_web_key_set.signing my-signing-keys
` + "```" + `
`
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand All @@ -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 {
Expand All @@ -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(),
Expand All @@ -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 {
Expand Down Expand Up @@ -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",
Expand All @@ -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
}
Loading