Skip to content

Commit 6fcbd1b

Browse files
committed
feat: add project_id support to ory_json_web_key_set resource
The JWK resource previously required project_slug and project_api_key on the provider, with no way to specify project_id at the resource level. This made it impossible to use with project_id-based workflows. Changes: - Add optional project_id attribute to ory_json_web_key_set (falls back to provider's project_id when not set) - Add ResolveProjectSlug and ProjectClientForProject methods to the client for resolving project_id to slug via the console API - Cache resolved slugs to avoid redundant API calls within a run - Extract shared resolve logic into projectClient helper - Validate import ID format when slash is present - Preserve backward compatibility: fall back to provider's default client when no project_id is configured anywhere - Support composite import format: project_id/set_id - Update acceptance tests, examples, and documentation
1 parent 2c6a7ec commit 6fcbd1b

File tree

7 files changed

+206
-34
lines changed

7 files changed

+206
-34
lines changed

docs/resources/json_web_key_set.md

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ JSON Web Keys are used for signing and encrypting tokens. This resource generate
1313

1414
-> **Plan:** Available on all Ory Network plans.
1515

16-
~> **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.
16+
~> **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.
1717

1818
## Algorithms
1919

@@ -46,20 +46,21 @@ Most configurations only need `use = "sig"`.
4646
## Example Usage
4747

4848
```terraform
49-
# RSA signing key set
49+
# RSA signing key set (project_id from provider config)
5050
resource "ory_json_web_key_set" "signing" {
5151
set_id = "token-signing-keys"
5252
key_id = "rsa-sig-1"
5353
algorithm = "RS256"
5454
use = "sig"
5555
}
5656
57-
# ECDSA signing key set (smaller, faster)
57+
# ECDSA signing key set with explicit project_id
5858
resource "ory_json_web_key_set" "ecdsa_signing" {
59-
set_id = "ecdsa-signing-keys"
60-
key_id = "ec-sig-1"
61-
algorithm = "ES256"
62-
use = "sig"
59+
project_id = var.ory_project_id
60+
set_id = "ecdsa-signing-keys"
61+
key_id = "ec-sig-1"
62+
algorithm = "ES256"
63+
use = "sig"
6364
}
6465
6566
# Encryption key set
@@ -98,9 +99,10 @@ On read, the provider extracts `algorithm`, `use`, and `key_id` from the **first
9899

99100
## Import
100101

101-
Import using the set ID:
102+
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`):
102103

103104
```shell
105+
terraform import ory_json_web_key_set.signing <project-id>/token-signing-keys
104106
terraform import ory_json_web_key_set.signing token-signing-keys
105107
```
106108

@@ -116,6 +118,10 @@ After import, `key_id` is populated from the first key in the set. If the set co
116118
- `set_id` (String) The ID of the JSON Web Key Set.
117119
- `use` (String) The intended use: sig (signature) or enc (encryption).
118120

121+
### Optional
122+
123+
- `project_id` (String) The project ID. If not set, uses the provider's project_id or project_slug.
124+
119125
### Read-Only
120126

121127
- `id` (String) Internal Terraform ID (same as set_id).

examples/resources/ory_json_web_key_set/resource.tf

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
1-
# RSA signing key set
1+
# RSA signing key set (project_id from provider config)
22
resource "ory_json_web_key_set" "signing" {
33
set_id = "token-signing-keys"
44
key_id = "rsa-sig-1"
55
algorithm = "RS256"
66
use = "sig"
77
}
88

9-
# ECDSA signing key set (smaller, faster)
9+
# ECDSA signing key set with explicit project_id
1010
resource "ory_json_web_key_set" "ecdsa_signing" {
11-
set_id = "ecdsa-signing-keys"
12-
key_id = "ec-sig-1"
13-
algorithm = "ES256"
14-
use = "sig"
11+
project_id = var.ory_project_id
12+
set_id = "ecdsa-signing-keys"
13+
key_id = "ec-sig-1"
14+
algorithm = "ES256"
15+
use = "sig"
1516
}
1617

1718
# Encryption key set

internal/client/client.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,11 @@ type OryClient struct {
329329
// immediately after PatchProject. This cache ensures Read operations
330330
// see the latest state after Create/Update.
331331
cachedProjects sync.Map
332+
333+
// cachedSlugs caches project ID -> slug mappings resolved via the console API.
334+
// This avoids redundant API calls when multiple CRUD operations resolve the
335+
// same project_id within a single Terraform run.
336+
cachedSlugs sync.Map
332337
}
333338

334339
// NewOryClient creates a new Ory API client.
@@ -454,6 +459,42 @@ func (c *OryClient) WorkspaceID() string {
454459
return c.config.WorkspaceID
455460
}
456461

462+
// ResolveProjectSlug resolves a project ID to its slug via the console API.
463+
// Results are cached to avoid redundant API calls within a single Terraform run.
464+
func (c *OryClient) ResolveProjectSlug(ctx context.Context, projectID string) (string, error) {
465+
if projectID == "" {
466+
return "", fmt.Errorf("project_id must not be empty")
467+
}
468+
if slug, ok := c.cachedSlugs.Load(projectID); ok {
469+
return slug.(string), nil
470+
}
471+
if c.consoleClient == nil {
472+
return "", fmt.Errorf("console API client not configured: workspace_api_key is required to resolve project_id to slug")
473+
}
474+
project, err := c.GetProject(ctx, projectID)
475+
if err != nil {
476+
return "", fmt.Errorf("resolving project slug for project %s: %w", projectID, err)
477+
}
478+
slug := project.GetSlug()
479+
c.cachedSlugs.Store(projectID, slug)
480+
return slug, nil
481+
}
482+
483+
// ProjectClientForProject returns a project-scoped client for the given project ID.
484+
// It resolves the project slug via the console API and uses the provider's project API key.
485+
func (c *OryClient) ProjectClientForProject(ctx context.Context, projectID string) (*OryClient, error) {
486+
slug, err := c.ResolveProjectSlug(ctx, projectID)
487+
if err != nil {
488+
return nil, err
489+
}
490+
apiKey := c.config.ProjectAPIKey
491+
if apiKey == "" {
492+
return nil, fmt.Errorf("project_api_key is required for project API operations (JWK, OAuth2, etc.): " +
493+
"set it on the provider or via ORY_PROJECT_API_KEY environment variable")
494+
}
495+
return c.WithProjectCredentials(slug, apiKey), nil
496+
}
497+
457498
// WithProjectCredentials returns a new OryClient that uses the given project
458499
// credentials. The returned client shares the console client with the parent
459500
// but has its own isolated project client (lazily initialized).

internal/resources/jwk/resource.go

Lines changed: 109 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"strings"
78

89
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
10+
"github.com/hashicorp/terraform-plugin-framework/diag"
911
"github.com/hashicorp/terraform-plugin-framework/path"
1012
"github.com/hashicorp/terraform-plugin-framework/resource"
1113
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
@@ -38,6 +40,7 @@ type JWKResource struct {
3840
// JWKResourceModel describes the resource data model.
3941
type JWKResourceModel struct {
4042
ID types.String `tfsdk:"id"`
43+
ProjectID types.String `tfsdk:"project_id"`
4144
SetID types.String `tfsdk:"set_id"`
4245
KeyID types.String `tfsdk:"key_id"`
4346
Algorithm types.String `tfsdk:"algorithm"`
@@ -61,10 +64,11 @@ generate and manage custom key sets for your Ory project.
6164
6265
` + "```hcl" + `
6366
resource "ory_json_web_key_set" "signing" {
64-
set_id = "my-signing-keys"
65-
key_id = "sig-key-1"
66-
algorithm = "RS256"
67-
use = "sig"
67+
project_id = var.ory_project_id
68+
set_id = "my-signing-keys"
69+
key_id = "sig-key-1"
70+
algorithm = "RS256"
71+
use = "sig"
6872
}
6973
` + "```" + `
7074
@@ -81,9 +85,10 @@ resource "ory_json_web_key_set" "encryption" {
8185
8286
## Import
8387
84-
JWK sets can be imported using their set ID:
88+
JWK sets can be imported using the format ` + "`project_id/set_id`" + ` or just ` + "`set_id`" + `:
8589
8690
` + "```shell" + `
91+
terraform import ory_json_web_key_set.signing <project-id>/my-signing-keys
8792
terraform import ory_json_web_key_set.signing my-signing-keys
8893
` + "```" + `
8994
`
@@ -100,6 +105,18 @@ func (r *JWKResource) Schema(ctx context.Context, req resource.SchemaRequest, re
100105
stringplanmodifier.UseStateForUnknown(),
101106
},
102107
},
108+
"project_id": schema.StringAttribute{
109+
Description: "The project ID. If not set, uses the provider's project_id or project_slug.",
110+
Optional: true,
111+
Computed: true,
112+
PlanModifiers: []planmodifier.String{
113+
stringplanmodifier.UseStateForUnknown(),
114+
stringplanmodifier.RequiresReplace(),
115+
},
116+
Validators: []validator.String{
117+
stringvalidator.LengthAtLeast(1),
118+
},
119+
},
103120
"set_id": schema.StringAttribute{
104121
Description: "The ID of the JSON Web Key Set.",
105122
Required: true,
@@ -168,13 +185,19 @@ func (r *JWKResource) Create(ctx context.Context, req resource.CreateRequest, re
168185
return
169186
}
170187

188+
projectClient, projectID, diags := r.projectClient(ctx, plan.ProjectID)
189+
resp.Diagnostics.Append(diags...)
190+
if resp.Diagnostics.HasError() {
191+
return
192+
}
193+
171194
body := ory.CreateJsonWebKeySet{
172195
Alg: plan.Algorithm.ValueString(),
173196
Kid: plan.KeyID.ValueString(),
174197
Use: plan.Use.ValueString(),
175198
}
176199

177-
jwks, err := r.client.CreateJsonWebKeySet(ctx, plan.SetID.ValueString(), body)
200+
jwks, err := projectClient.CreateJsonWebKeySet(ctx, plan.SetID.ValueString(), body)
178201
if err != nil {
179202
resp.Diagnostics.AddError(
180203
"Error Creating JSON Web Key Set",
@@ -184,6 +207,9 @@ func (r *JWKResource) Create(ctx context.Context, req resource.CreateRequest, re
184207
}
185208

186209
plan.ID = types.StringValue(plan.SetID.ValueString())
210+
if projectID != "" {
211+
plan.ProjectID = types.StringValue(projectID)
212+
}
187213

188214
// Serialize the keys to JSON
189215
if len(jwks.Keys) > 0 {
@@ -204,9 +230,14 @@ func (r *JWKResource) Read(ctx context.Context, req resource.ReadRequest, resp *
204230
return
205231
}
206232

207-
jwks, err := r.client.GetJsonWebKeySet(ctx, state.SetID.ValueString())
233+
projectClient, projectID, diags := r.projectClient(ctx, state.ProjectID)
234+
resp.Diagnostics.Append(diags...)
235+
if resp.Diagnostics.HasError() {
236+
return
237+
}
238+
239+
jwks, err := projectClient.GetJsonWebKeySet(ctx, state.SetID.ValueString())
208240
if err != nil {
209-
// Check if it's a 404
210241
resp.Diagnostics.AddError(
211242
"Error Reading JSON Web Key Set",
212243
"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 *
219250
return
220251
}
221252

253+
if projectID != "" {
254+
state.ProjectID = types.StringValue(projectID)
255+
}
256+
222257
// Serialize the keys to JSON
223258
keysJSON, err := json.Marshal(jwks)
224259
if err == nil {
@@ -253,7 +288,13 @@ func (r *JWKResource) Delete(ctx context.Context, req resource.DeleteRequest, re
253288
return
254289
}
255290

256-
err := r.client.DeleteJsonWebKeySet(ctx, state.SetID.ValueString())
291+
projectClient, _, diags := r.projectClient(ctx, state.ProjectID)
292+
resp.Diagnostics.Append(diags...)
293+
if resp.Diagnostics.HasError() {
294+
return
295+
}
296+
297+
err := projectClient.DeleteJsonWebKeySet(ctx, state.SetID.ValueString())
257298
if err != nil {
258299
resp.Diagnostics.AddError(
259300
"Error Deleting JSON Web Key Set",
@@ -264,6 +305,63 @@ func (r *JWKResource) Delete(ctx context.Context, req resource.DeleteRequest, re
264305
}
265306

266307
func (r *JWKResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
267-
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("set_id"), req.ID)...)
268-
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), req.ID)...)
308+
// Import ID format: project_id/set_id or just set_id (uses provider's project_id)
309+
id := req.ID
310+
var projectID, setID string
311+
312+
if strings.Contains(id, "/") {
313+
parts := strings.SplitN(id, "/", 2)
314+
projectID = parts[0]
315+
setID = parts[1]
316+
if projectID == "" || setID == "" {
317+
resp.Diagnostics.AddError(
318+
"Invalid Import ID",
319+
fmt.Sprintf("Expected format \"project_id/set_id\" or \"set_id\", got %q.", id),
320+
)
321+
return
322+
}
323+
} else {
324+
setID = id
325+
}
326+
327+
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("set_id"), setID)...)
328+
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), setID)...)
329+
if projectID != "" {
330+
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectID)...)
331+
}
332+
}
333+
334+
// projectClient returns a client scoped to the correct project along with the
335+
// resolved project ID. When a resource-level project_id is set, the slug is
336+
// resolved via the console API and a project-scoped client is returned. When
337+
// only the provider's project_slug+project_api_key are configured (no
338+
// project_id anywhere), the provider's default client is returned as-is so
339+
// existing configurations are not broken.
340+
func (r *JWKResource) projectClient(ctx context.Context, tfProjectID types.String) (*client.OryClient, string, diag.Diagnostics) {
341+
var diags diag.Diagnostics
342+
343+
// Use explicit resource-level project_id first.
344+
if !tfProjectID.IsNull() && !tfProjectID.IsUnknown() && tfProjectID.ValueString() != "" {
345+
pid := tfProjectID.ValueString()
346+
c, err := r.client.ProjectClientForProject(ctx, pid)
347+
if err != nil {
348+
diags.AddError("Error Resolving Project", err.Error())
349+
return nil, "", diags
350+
}
351+
return c, pid, diags
352+
}
353+
354+
// Fall back to provider-level project_id.
355+
if pid := r.client.ProjectID(); pid != "" {
356+
c, err := r.client.ProjectClientForProject(ctx, pid)
357+
if err != nil {
358+
diags.AddError("Error Resolving Project", err.Error())
359+
return nil, "", diags
360+
}
361+
return c, pid, diags
362+
}
363+
364+
// No project_id at all -- the provider may still have project_slug +
365+
// project_api_key configured directly, so fall back to the default client.
366+
return r.client, "", diags
269367
}

0 commit comments

Comments
 (0)