Skip to content

Commit c6cd005

Browse files
authored
fix: remove ineffective client-side dedup logic from identity schema resource (#155)
* fix: remove ineffective client-side dedup logic from identity schema resource Remove the findExistingSchemaByContent()-based client-side deduplication from the identity schema Create path. This logic was intended to prevent duplicate schemas when re-applying after a destroy, but it only checked Layer A (project config) while duplicates occur in Layer B (identity_schemas table in the backend). Changes: - Remove client-side dedup logic that skipped PatchProject when content already existed (this was ineffective at preventing backend duplicates) - Simplify post-patch ID resolution to use resolveSchemaID() which does content-based matching solely for finding the canonical hash ID - Add content-based fallback to Read for cases where the API has transformed both the schema ID and URL - Remove TestAccIdentitySchemaResource_deduplication test and dedup.tf.tmpl template - Remove unused errors import Closes #152 * fix: address review feedback on error handling and test coverage - resolveSchemaID: track marshal failures and return a descriptive error when no content match is found and schemas couldn't be compared, instead of silently skipping - WaitForCondition: restore proper error handling — abort on context cancellation, surface non-transient API errors as warnings, and filter out user-provided schemaID from ID-diff detection to only accept canonical hash IDs - Add TestAccIdentitySchemaResource_hashIDResolution test that verifies Create resolves the canonical hash ID and Read survives the API transformation of both ID and URL
1 parent f5ff7ab commit c6cd005

File tree

3 files changed

+76
-211
lines changed

3 files changed

+76
-211
lines changed

internal/resources/identityschema/resource.go

Lines changed: 61 additions & 152 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"context"
55
"encoding/base64"
66
"encoding/json"
7-
"errors"
87
"fmt"
98
"time"
109

@@ -219,66 +218,48 @@ func (r *IdentitySchemaResource) findSchemaByURL(schemas []map[string]interface{
219218
return -1
220219
}
221220

222-
// findExistingSchemaByContent checks all project schemas for one with
223-
// identical JSON content. Returns the schema's canonical (hash-based) ID if
224-
// found, or "". Used to detect and reuse existing schemas, preventing
225-
// duplicates when re-applying after a destroy.
226-
//
227-
// It uses ListIdentitySchemas (Kratos API) when a project client is available
228-
// because the Kratos API returns actual schema content with stable hash IDs.
229-
// The console API path (ListIdentitySchemasViaProject) reads the project config,
230-
// which may store non-base64 URLs after API transformation — making content
231-
// comparison impossible for those schemas.
232-
// Falls back to ListIdentitySchemasViaProject when only a console client is
233-
// available (workspace-only configuration).
234-
func (r *IdentitySchemaResource) findExistingSchemaByContent(ctx context.Context, projectID, schemaJSON string) (string, error) {
221+
// resolveSchemaID resolves the canonical (hash-based) schema ID after a
222+
// PatchProject call. The Ory API may transform the user-provided schema_id
223+
// into a content-hash-based ID. This function uses the Kratos API
224+
// (ListIdentitySchemas) to match by content and find the canonical ID.
225+
// Falls back to ListIdentitySchemasViaProject when only a console client
226+
// is available.
227+
func (r *IdentitySchemaResource) resolveSchemaID(ctx context.Context, projectID, schemaJSON string) (string, error) {
235228
var target interface{}
236229
if err := json.Unmarshal([]byte(schemaJSON), &target); err != nil {
237-
return "", fmt.Errorf("failed to parse schema JSON for content-based lookup: %w", err)
230+
return "", fmt.Errorf("failed to parse schema JSON: %w", err)
238231
}
239232
targetNormalized, err := json.Marshal(target)
240233
if err != nil {
241-
return "", fmt.Errorf("failed to normalize schema JSON for content-based lookup: %w", err)
234+
return "", fmt.Errorf("failed to normalize schema JSON: %w", err)
242235
}
243236

244237
var schemas []ory.IdentitySchemaContainer
245-
for attempt := 0; attempt < helpers.ReadRetryMaxAttempts; attempt++ {
246-
// Prefer the Kratos API (ListIdentitySchemas) because it returns actual
247-
// schema content with canonical hash-based IDs. The console API path
248-
// (ListIdentitySchemasViaProject) reads the project config, which may
249-
// store non-base64 URLs after the API transforms them — making content
250-
// comparison impossible.
251-
if r.client.HasProjectClient() {
252-
schemas, err = r.client.ListIdentitySchemas(ctx)
253-
} else if r.client.HasConsoleClient() {
254-
schemas, err = r.client.ListIdentitySchemasViaProject(ctx, projectID)
255-
} else {
256-
return "", fmt.Errorf("no API client configured for listing identity schemas: set project_api_key or workspace_api_key")
257-
}
258-
if err == nil {
259-
break
260-
}
261-
if attempt < helpers.ReadRetryMaxAttempts-1 {
262-
select {
263-
case <-ctx.Done():
264-
return "", ctx.Err()
265-
case <-time.After(helpers.EventualConsistencyDelay):
266-
}
267-
}
238+
if r.client.HasProjectClient() {
239+
schemas, err = r.client.ListIdentitySchemas(ctx)
240+
} else if r.client.HasConsoleClient() {
241+
schemas, err = r.client.ListIdentitySchemasViaProject(ctx, projectID)
242+
} else {
243+
return "", fmt.Errorf("no API client configured for listing identity schemas: set project_api_key or workspace_api_key")
268244
}
269245
if err != nil {
270-
return "", fmt.Errorf("failed to list identity schemas for content-based lookup: %w", err)
246+
return "", fmt.Errorf("failed to list identity schemas: %w", err)
271247
}
272248

249+
var marshalFailures int
273250
for _, s := range schemas {
274-
storedNormalized, err := json.Marshal(s.GetSchema())
275-
if err != nil {
276-
return "", fmt.Errorf("failed to normalize stored schema %s for content-based lookup: %w", s.GetId(), err)
251+
storedNormalized, marshalErr := json.Marshal(s.GetSchema())
252+
if marshalErr != nil {
253+
marshalFailures++
254+
continue
277255
}
278256
if string(targetNormalized) == string(storedNormalized) {
279257
return s.GetId(), nil
280258
}
281259
}
260+
if marshalFailures > 0 {
261+
return "", fmt.Errorf("no content match found; %d of %d schemas could not be compared due to marshal errors", marshalFailures, len(schemas))
262+
}
282263
return "", nil
283264
}
284265

@@ -303,14 +284,13 @@ func (r *IdentitySchemaResource) Create(ctx context.Context, req resource.Create
303284
return
304285
}
305286

306-
// Get existing schemas and their IDs before the patch
287+
// Get existing schemas before the patch
307288
existingSchemas, err := r.getSchemas(ctx, projectID)
308289
if err != nil {
309290
resp.Diagnostics.AddError("Error Getting Schemas", err.Error())
310291
return
311292
}
312293

313-
// Record pre-patch IDs for the console-only fallback in WaitForCondition.
314294
existingIDs := make(map[string]bool, len(existingSchemas))
315295
for _, s := range existingSchemas {
316296
if id, ok := s["id"].(string); ok {
@@ -322,68 +302,6 @@ func (r *IdentitySchemaResource) Create(ctx context.Context, req resource.Create
322302

323303
existingIndex := r.findSchemaIndex(existingSchemas, schemaID)
324304

325-
// Only perform content-based deduplication when the schema ID does not
326-
// already exist in the project configuration. This avoids an extra
327-
// ListIdentitySchemas call when we're simply replacing an existing schema.
328-
if existingIndex < 0 {
329-
existingID, err := r.findExistingSchemaByContent(ctx, projectID, schemaJSON)
330-
if err != nil {
331-
resp.Diagnostics.AddError("Error Checking for Duplicate Schemas", err.Error())
332-
return
333-
}
334-
if existingID != "" {
335-
// Schema with identical content already exists — adopt it.
336-
// We intentionally do NOT add a new schema entry to the project
337-
// config here because that would create a duplicate (same content,
338-
// different schema_id). The existing schema is already registered
339-
// in the project config under its original ID, and Read will find
340-
// it by the hash-based ID stored in state.
341-
if plan.SetDefault.ValueBool() {
342-
var defaultPatches []ory.JsonPatch
343-
344-
// Ensure the schema is in the project's schema list before
345-
// setting it as default. For a new project, workspace-level
346-
// schemas exist but aren't in the project config yet.
347-
if r.findSchemaIndex(existingSchemas, existingID) < 0 {
348-
if len(existingSchemas) == 0 {
349-
defaultPatches = append(defaultPatches, ory.JsonPatch{
350-
Op: "add",
351-
Path: "/services/identity/config/identity/schemas",
352-
Value: []map[string]string{{
353-
"id": existingID,
354-
"url": schemaURL,
355-
}},
356-
})
357-
} else {
358-
defaultPatches = append(defaultPatches, ory.JsonPatch{
359-
Op: "add",
360-
Path: "/services/identity/config/identity/schemas/-",
361-
Value: map[string]string{
362-
"id": existingID,
363-
"url": schemaURL,
364-
},
365-
})
366-
}
367-
}
368-
369-
defaultPatches = append(defaultPatches, ory.JsonPatch{
370-
Op: "add",
371-
Path: "/services/identity/config/identity/default_schema_id",
372-
Value: existingID,
373-
})
374-
_, patchErr := r.client.PatchProject(ctx, projectID, defaultPatches)
375-
if patchErr != nil {
376-
resp.Diagnostics.AddError("Error Setting Default Schema", patchErr.Error())
377-
return
378-
}
379-
}
380-
381-
plan.ID = types.StringValue(existingID)
382-
plan.ProjectID = types.StringValue(projectID)
383-
resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
384-
return
385-
}
386-
}
387305
if existingIndex >= 0 {
388306
// Replace existing
389307
patches = append(patches, ory.JsonPatch{
@@ -436,40 +354,27 @@ func (r *IdentitySchemaResource) Create(ctx context.Context, req resource.Create
436354
return
437355
}
438356

439-
// Resolve the canonical (hash-based) schema ID by polling after the patch.
440-
// The Ory API initially stores the schema with the user-provided schema_id,
441-
// then asynchronously transforms it to a hash-based ID.
442-
//
443-
// Two strategies are used depending on the configured client:
444-
//
445-
// 1. Project client available: content-based matching via ListIdentitySchemas
446-
// (Kratos API). Deterministic and safe for concurrent Terraform applies.
447-
// Non-retryable errors (e.g., auth failures) abort immediately.
448-
//
449-
// 2. Console client only: the project config stores HTTPS URLs after
450-
// transformation, making schema body decoding impossible. Fall back to
451-
// ID-diff matching (any new schema ID not seen before the patch).
357+
// Resolve the canonical (hash-based) schema ID after the patch.
358+
// The Ory API transforms the user-provided schema_id into a content-hash ID.
359+
// We use multiple strategies to find it:
360+
// 1. Content-based matching via ListIdentitySchemas (most reliable)
361+
// 2. ID-diff detection from the project config (new hash ID not seen before)
452362
var actualID string
453363
waitErr := helpers.WaitForCondition(ctx, func() (bool, error) {
454-
if r.client.HasProjectClient() {
455-
foundID, findErr := r.findExistingSchemaByContent(ctx, projectID, schemaJSON)
456-
if findErr != nil {
457-
if client.IsTransientError(findErr) {
458-
return false, nil // transient (5xx/429) — keep retrying
459-
}
460-
return false, findErr // non-retryable (auth/permission) — fail fast
364+
// Strategy 1: content-based matching via Kratos API
365+
if r.client.HasProjectClient() || r.client.HasConsoleClient() {
366+
foundID, findErr := r.resolveSchemaID(ctx, projectID, schemaJSON)
367+
if findErr != nil && !client.IsTransientError(findErr) {
368+
return false, findErr // non-retryable — fail fast
461369
}
462-
// Only accept once the API has assigned a different (canonical hash) ID.
463-
if foundID != "" && foundID != schemaID {
370+
if foundID != "" {
464371
actualID = foundID
465372
return true, nil
466373
}
467-
return false, nil
468374
}
469375

470-
// Console-only fallback: content matching is unreliable because the project
471-
// config stores HTTPS URLs after transformation. Use ID-diff detection instead:
472-
// any schema ID that wasn't present before the patch is the hash-based ID.
376+
// Strategy 2: check project config for a new hash ID not seen before the patch.
377+
// Exclude the user-provided schemaID to ensure we only accept a canonical hash.
473378
freshSchemas, gsErr := r.getSchemas(ctx, projectID)
474379
if gsErr != nil {
475380
return false, nil //nolint:nilerr
@@ -480,21 +385,19 @@ func (r *IdentitySchemaResource) Create(ctx context.Context, req resource.Create
480385
return true, nil
481386
}
482387
}
388+
483389
return false, nil
484390
})
485-
if errors.Is(waitErr, context.Canceled) || errors.Is(waitErr, context.DeadlineExceeded) {
486-
resp.Diagnostics.AddError("Error Creating Identity Schema",
487-
fmt.Sprintf("context canceled while waiting for schema ID transformation: %v", waitErr))
488-
return
489-
}
490391
if waitErr != nil {
491-
// Non-retryable API error (e.g., auth/permission failure). The schema was
492-
// already created by PatchProject; surface the error so the operator can
493-
// investigate. Fall back to the user-provided ID so Terraform can write
494-
// state — the canonical ID will be corrected on the next refresh/plan.
392+
if ctx.Err() != nil {
393+
resp.Diagnostics.AddError("Error Creating Identity Schema",
394+
fmt.Sprintf("context canceled while waiting for schema ID transformation: %v", waitErr))
395+
return
396+
}
397+
// Non-retryable API error. The schema was already created by PatchProject;
398+
// fall back to user-provided ID so Terraform can write state.
495399
resp.Diagnostics.AddWarning("Schema ID Not Resolved",
496-
fmt.Sprintf("Could not resolve canonical schema ID due to a non-retryable error; "+
497-
"using user-provided ID %q as fallback. "+
400+
fmt.Sprintf("Could not resolve canonical schema ID; using %q as fallback. "+
498401
"The state will be corrected on the next plan/refresh. Reason: %v",
499402
schemaID, waitErr))
500403
}
@@ -539,14 +442,7 @@ func (r *IdentitySchemaResource) Read(ctx context.Context, req resource.ReadRequ
539442
var schemas []map[string]interface{}
540443
var index = -1
541444

542-
// Note: We intentionally do NOT use the PatchProject cache here.
543-
// The cache holds the PatchProject response, which may contain the
544-
// user-provided schema_id before the API transforms it to a hash-based ID.
545-
// Using stale cache data causes Read to store the wrong (user-provided) ID,
546-
// which then fails to match on subsequent refresh cycles once the API has
547-
// applied its hash transformation.
548-
//
549-
// Always call GetProject fresh to get the canonical (post-transformation) IDs.
445+
// Try fresh GetProject with retries for eventual consistency.
550446
{
551447
var err error
552448
for attempt := 0; attempt < helpers.ReadRetryMaxAttempts; attempt++ {
@@ -584,6 +480,19 @@ func (r *IdentitySchemaResource) Read(ctx context.Context, req resource.ReadRequ
584480
}
585481
}
586482

483+
// If not found by ID or URL in the project config, try content-based matching
484+
// via ListIdentitySchemas as a last resort. The project config transforms URLs
485+
// from base64:// to https://, making URL matching impossible after transformation.
486+
if index < 0 && !state.Schema.IsNull() && !state.Schema.IsUnknown() {
487+
if resolvedID, err := r.resolveSchemaID(ctx, projectID, state.Schema.ValueString()); err == nil && resolvedID != "" {
488+
// Found by content — verify it exists in the project config
489+
index = r.findSchemaIndex(schemas, resolvedID)
490+
if index >= 0 {
491+
state.ID = types.StringValue(resolvedID)
492+
}
493+
}
494+
}
495+
587496
if index < 0 {
588497
// List available schemas to help with debugging import issues
589498
var availableIDs []string

internal/resources/identityschema/resource_test.go

Lines changed: 15 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,13 @@ func TestAccIdentitySchemaResource_basic(t *testing.T) {
6262
})
6363
}
6464

65-
func TestAccIdentitySchemaResource_deduplication(t *testing.T) {
65+
// TestAccIdentitySchemaResource_hashIDResolution verifies that Create resolves the
66+
// canonical hash-based ID (not the user-provided schema_id) and that Read can find
67+
// the schema after the API transforms both the ID and URL. This exercises the
68+
// content-based fallback in Read that was added to handle the API transformation.
69+
func TestAccIdentitySchemaResource_hashIDResolution(t *testing.T) {
6670
suffix := time.Now().UnixNano()
67-
// ContentID stays the same across steps so the schema JSON is identical.
68-
// SchemaID changes to prove dedup is based on content, not schema_id.
69-
contentID := fmt.Sprintf("tf-test-dedup-%d", suffix)
70-
schemaID1 := fmt.Sprintf("tf-test-dedup1-%d", suffix)
71-
schemaID2 := fmt.Sprintf("tf-test-dedup2-%d", suffix)
72-
73-
var firstID string
71+
schemaID := fmt.Sprintf("tf-test-hash-%d", suffix)
7472

7573
resource.Test(t, resource.TestCase{
7674
PreCheck: func() {
@@ -79,30 +77,17 @@ func TestAccIdentitySchemaResource_deduplication(t *testing.T) {
7977
},
8078
ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories(),
8179
Steps: []resource.TestStep{
82-
// Step 1: Create a schema and capture its API-assigned ID
83-
{
84-
Config: acctest.LoadTestConfig(t, "testdata/dedup.tf.tmpl", map[string]string{"SchemaID": schemaID1, "ContentID": contentID, "AppURL": testutil.ExampleAppURL}),
85-
Check: resource.ComposeAggregateTestCheckFunc(
86-
resource.TestCheckResourceAttrSet("ory_identity_schema.dedup", "id"),
87-
resource.TestCheckResourceAttrWith("ory_identity_schema.dedup", "id", func(value string) error {
88-
firstID = value
89-
return nil
90-
}),
91-
),
92-
},
93-
// Step 2: Destroy the resource (removes from state, schema stays in Ory)
94-
{
95-
Config: " ", // empty config triggers destroy of previous resources
96-
Destroy: false,
97-
},
98-
// Step 3: Re-create with a DIFFERENT schema_id but SAME content — should reuse the same schema
9980
{
100-
Config: acctest.LoadTestConfig(t, "testdata/dedup.tf.tmpl", map[string]string{"SchemaID": schemaID2, "ContentID": contentID, "AppURL": testutil.ExampleAppURL}),
81+
Config: acctest.LoadTestConfig(t, "testdata/basic.tf.tmpl", map[string]string{"SchemaID": schemaID, "AppURL": testutil.ExampleAppURL}),
10182
Check: resource.ComposeAggregateTestCheckFunc(
102-
resource.TestCheckResourceAttrSet("ory_identity_schema.dedup", "id"),
103-
resource.TestCheckResourceAttrWith("ory_identity_schema.dedup", "id", func(value string) error {
104-
if value != firstID {
105-
return fmt.Errorf("expected deduplication to reuse ID %q, but got new ID %q", firstID, value)
83+
resource.TestCheckResourceAttrSet("ory_identity_schema.test", "id"),
84+
resource.TestCheckResourceAttr("ory_identity_schema.test", "schema_id", schemaID),
85+
// Verify the stored ID is the canonical hash, not the user-provided schema_id.
86+
// This proves Create resolved the hash and Read can find the schema after
87+
// the API transforms the ID and URL.
88+
resource.TestCheckResourceAttrWith("ory_identity_schema.test", "id", func(value string) error {
89+
if value == schemaID {
90+
return fmt.Errorf("expected canonical hash ID, but got user-provided schema_id %q", value)
10691
}
10792
return nil
10893
}),

internal/resources/identityschema/testdata/dedup.tf.tmpl

Lines changed: 0 additions & 29 deletions
This file was deleted.

0 commit comments

Comments
 (0)