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
0 commit comments