Skip to content

Commit 28c0b6a

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 28c0b6a

File tree

7 files changed

+194
-34
lines changed

7 files changed

+194
-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 provider's project_id):
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.
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: 38 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,39 @@ 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 slug, ok := c.cachedSlugs.Load(projectID); ok {
466+
return slug.(string), nil
467+
}
468+
if c.consoleClient == nil {
469+
return "", fmt.Errorf("console API client not configured: workspace_api_key is required to resolve project_id to slug")
470+
}
471+
project, err := c.GetProject(ctx, projectID)
472+
if err != nil {
473+
return "", fmt.Errorf("resolving project slug for project %s: %w", projectID, err)
474+
}
475+
slug := project.GetSlug()
476+
c.cachedSlugs.Store(projectID, slug)
477+
return slug, nil
478+
}
479+
480+
// ProjectClientForProject returns a project-scoped client for the given project ID.
481+
// It resolves the project slug via the console API and uses the provider's project API key.
482+
func (c *OryClient) ProjectClientForProject(ctx context.Context, projectID string) (*OryClient, error) {
483+
slug, err := c.ResolveProjectSlug(ctx, projectID)
484+
if err != nil {
485+
return nil, err
486+
}
487+
apiKey := c.config.ProjectAPIKey
488+
if apiKey == "" {
489+
return nil, fmt.Errorf("project_api_key is required for project API operations (JWK, OAuth2, etc.): " +
490+
"set it on the provider or via ORY_PROJECT_API_KEY environment variable")
491+
}
492+
return c.WithProjectCredentials(slug, apiKey), nil
493+
}
494+
457495
// WithProjectCredentials returns a new OryClient that uses the given project
458496
// credentials. The returned client shares the console client with the parent
459497
// but has its own isolated project client (lazily initialized).

internal/resources/jwk/resource.go

Lines changed: 106 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,15 @@ 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.",
110+
Optional: true,
111+
Computed: true,
112+
PlanModifiers: []planmodifier.String{
113+
stringplanmodifier.UseStateForUnknown(),
114+
stringplanmodifier.RequiresReplace(),
115+
},
116+
},
103117
"set_id": schema.StringAttribute{
104118
Description: "The ID of the JSON Web Key Set.",
105119
Required: true,
@@ -168,13 +182,19 @@ func (r *JWKResource) Create(ctx context.Context, req resource.CreateRequest, re
168182
return
169183
}
170184

185+
projectClient, projectID, diags := r.projectClient(ctx, plan.ProjectID)
186+
resp.Diagnostics.Append(diags...)
187+
if resp.Diagnostics.HasError() {
188+
return
189+
}
190+
171191
body := ory.CreateJsonWebKeySet{
172192
Alg: plan.Algorithm.ValueString(),
173193
Kid: plan.KeyID.ValueString(),
174194
Use: plan.Use.ValueString(),
175195
}
176196

177-
jwks, err := r.client.CreateJsonWebKeySet(ctx, plan.SetID.ValueString(), body)
197+
jwks, err := projectClient.CreateJsonWebKeySet(ctx, plan.SetID.ValueString(), body)
178198
if err != nil {
179199
resp.Diagnostics.AddError(
180200
"Error Creating JSON Web Key Set",
@@ -184,6 +204,9 @@ func (r *JWKResource) Create(ctx context.Context, req resource.CreateRequest, re
184204
}
185205

186206
plan.ID = types.StringValue(plan.SetID.ValueString())
207+
if projectID != "" {
208+
plan.ProjectID = types.StringValue(projectID)
209+
}
187210

188211
// Serialize the keys to JSON
189212
if len(jwks.Keys) > 0 {
@@ -204,9 +227,14 @@ func (r *JWKResource) Read(ctx context.Context, req resource.ReadRequest, resp *
204227
return
205228
}
206229

207-
jwks, err := r.client.GetJsonWebKeySet(ctx, state.SetID.ValueString())
230+
projectClient, projectID, diags := r.projectClient(ctx, state.ProjectID)
231+
resp.Diagnostics.Append(diags...)
232+
if resp.Diagnostics.HasError() {
233+
return
234+
}
235+
236+
jwks, err := projectClient.GetJsonWebKeySet(ctx, state.SetID.ValueString())
208237
if err != nil {
209-
// Check if it's a 404
210238
resp.Diagnostics.AddError(
211239
"Error Reading JSON Web Key Set",
212240
"Could not read JWK set "+state.SetID.ValueString()+": "+err.Error(),
@@ -219,6 +247,10 @@ func (r *JWKResource) Read(ctx context.Context, req resource.ReadRequest, resp *
219247
return
220248
}
221249

250+
if projectID != "" {
251+
state.ProjectID = types.StringValue(projectID)
252+
}
253+
222254
// Serialize the keys to JSON
223255
keysJSON, err := json.Marshal(jwks)
224256
if err == nil {
@@ -253,7 +285,13 @@ func (r *JWKResource) Delete(ctx context.Context, req resource.DeleteRequest, re
253285
return
254286
}
255287

256-
err := r.client.DeleteJsonWebKeySet(ctx, state.SetID.ValueString())
288+
projectClient, _, diags := r.projectClient(ctx, state.ProjectID)
289+
resp.Diagnostics.Append(diags...)
290+
if resp.Diagnostics.HasError() {
291+
return
292+
}
293+
294+
err := projectClient.DeleteJsonWebKeySet(ctx, state.SetID.ValueString())
257295
if err != nil {
258296
resp.Diagnostics.AddError(
259297
"Error Deleting JSON Web Key Set",
@@ -264,6 +302,63 @@ func (r *JWKResource) Delete(ctx context.Context, req resource.DeleteRequest, re
264302
}
265303

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

internal/resources/jwk/resource_test.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,53 @@
33
package jwk_test
44

55
import (
6+
"fmt"
7+
"os"
68
"testing"
79

810
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
11+
"github.com/hashicorp/terraform-plugin-testing/terraform"
912

1013
"github.com/ory/terraform-provider-ory/internal/acctest"
1114
)
1215

16+
func importStateJWKID(s *terraform.State) (string, error) {
17+
rs, ok := s.RootModule().Resources["ory_json_web_key_set.test"]
18+
if !ok {
19+
return "", fmt.Errorf("resource not found: ory_json_web_key_set.test")
20+
}
21+
projectID := rs.Primary.Attributes["project_id"]
22+
setID := rs.Primary.Attributes["set_id"]
23+
return fmt.Sprintf("%s/%s", projectID, setID), nil
24+
}
25+
1326
func TestAccJWKResource_basic(t *testing.T) {
27+
projectID := os.Getenv("ORY_PROJECT_ID")
28+
1429
resource.Test(t, resource.TestCase{
1530
PreCheck: func() { acctest.AccPreCheck(t) },
1631
ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories(),
1732
Steps: []resource.TestStep{
1833
// Create and Read
1934
{
20-
Config: acctest.LoadTestConfig(t, "testdata/basic.tf.tmpl", nil),
35+
Config: acctest.LoadTestConfig(t, "testdata/basic.tf.tmpl", map[string]string{
36+
"ProjectID": projectID,
37+
}),
2138
Check: resource.ComposeAggregateTestCheckFunc(
2239
resource.TestCheckResourceAttrSet("ory_json_web_key_set.test", "id"),
40+
resource.TestCheckResourceAttr("ory_json_web_key_set.test", "project_id", projectID),
2341
resource.TestCheckResourceAttr("ory_json_web_key_set.test", "set_id", "tf-test-jwks"),
2442
resource.TestCheckResourceAttr("ory_json_web_key_set.test", "key_id", "tf-test-key"),
2543
resource.TestCheckResourceAttr("ory_json_web_key_set.test", "algorithm", "RS256"),
2644
resource.TestCheckResourceAttr("ory_json_web_key_set.test", "use", "sig"),
2745
resource.TestCheckResourceAttrSet("ory_json_web_key_set.test", "keys"),
2846
),
2947
},
30-
// ImportState
48+
// ImportState using composite ID: project_id/set_id
3149
{
3250
ResourceName: "ory_json_web_key_set.test",
3351
ImportState: true,
34-
ImportStateId: "tf-test-jwks",
52+
ImportStateIdFunc: importStateJWKID,
3553
ImportStateVerify: true,
3654
},
3755
},

0 commit comments

Comments
 (0)