Skip to content

Commit 60f72af

Browse files
authored
feat: add console API fallback for identity schema data sources (#105)
* feat: add console API fallback for identity schema data sources Add `project_id` attribute to `ory_identity_schema` and `ory_identity_schemas` data sources. When set (or when project_slug / project_api_key are not configured), schemas are read from the project config via the console API using only the workspace key. This unblocks bootstrap workflows where a new project is created and its schemas need to be referenced in the same Terraform run, without requiring project-level API credentials. Closes #104 * fix: address review comments on identity schema data sources - Mark project_id as Optional+Computed to avoid "unexpected new value" - Handle IsUnknown() on project_id with clear diagnostic - Add nil guard on consoleClient in ListIdentitySchemasViaProject - Return errors on malformed base64/JSON schemas instead of silently dropping them - Use empty object for preset schemas so json.Marshal produces "{}" instead of "null" - Add unit tests for invalid base64 and invalid JSON error paths * fix: address second review — resolveProjectID, fail-fast, and test coverage - Replace IsUnknown() error with fallback to provider's project_id, consistent with the organization data source pattern - Fail fast with clear diagnostic when console API path is selected but no project_id is available - Only write project_id to state when a non-empty value was resolved - Add acceptance test for ory_identity_schemas with project_id
1 parent 1e73a97 commit 60f72af

File tree

14 files changed

+496
-9
lines changed

14 files changed

+496
-9
lines changed

docs/data-sources/identity_schema.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ This data source retrieves a specific identity schema from the project, allowing
1515

1616
~> **Note:** Ory may assign hash-based IDs to schemas. Use the `ory_identity_schemas` (plural) data source to discover available schema IDs, or use the `id` output from an `ory_identity_schema` resource.
1717

18+
~> **Tip:** Set `project_id` to look up schemas via the console API (workspace key only). This is useful during project bootstrap when `project_slug` and `project_api_key` are not yet available.
19+
1820
## Example Usage
1921

2022
```terraform
@@ -58,6 +60,12 @@ resource "ory_identity_schema" "employee" {
5860
data "ory_identity_schema" "employee" {
5961
id = ory_identity_schema.employee.id
6062
}
63+
64+
# Look up a schema during project bootstrap (no project_slug/project_api_key needed)
65+
data "ory_identity_schema" "bootstrap" {
66+
id = "preset://username"
67+
project_id = "your-project-uuid"
68+
}
6169
```
6270

6371
<!-- schema generated by tfplugindocs -->
@@ -67,6 +75,10 @@ data "ory_identity_schema" "employee" {
6775

6876
- `id` (String) The ID of the schema to look up. This is the API-assigned ID (which may be a hash) or a preset ID like 'preset://username'.
6977

78+
### Optional
79+
80+
- `project_id` (String) The ID of the project to look up schemas from. If not set, uses the provider's project_id. When set, schemas are read from the project config via the console API (workspace key), which does not require project_slug or project_api_key.
81+
7082
### Read-Only
7183

7284
- `schema` (String) The JSON Schema definition for the identity traits.

docs/data-sources/identity_schemas.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ This data source retrieves all identity schemas configured for the current proje
1313

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

16+
~> **Tip:** Set `project_id` to list schemas via the console API (workspace key only). This is useful during project bootstrap when `project_slug` and `project_api_key` are not yet available.
17+
1618
## Example Usage
1719

1820
```terraform
@@ -27,6 +29,10 @@ output "schemas" {
2729
<!-- schema generated by tfplugindocs -->
2830
## Schema
2931

32+
### Optional
33+
34+
- `project_id` (String) The ID of the project to list schemas from. If not set, uses the provider's project_id. When set, schemas are read from the project config via the console API (workspace key), which does not require project_slug or project_api_key.
35+
3036
### Read-Only
3137

3238
- `schemas` (List of Object) List of identity schemas. Each schema has an `id` and a `schema` (JSON string of the schema content). (see [below for nested schema](#nestedatt--schemas))

examples/data-sources/ory_identity_schema/data-source.tf

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,9 @@ resource "ory_identity_schema" "employee" {
3838
data "ory_identity_schema" "employee" {
3939
id = ory_identity_schema.employee.id
4040
}
41+
42+
# Look up a schema during project bootstrap (no project_slug/project_api_key needed)
43+
data "ory_identity_schema" "bootstrap" {
44+
id = "preset://username"
45+
project_id = "your-project-uuid"
46+
}

internal/acctest/acctest.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,12 @@ func GetTestProject(t *testing.T) *TestProject {
103103
return sharedTestProject
104104
}
105105

106+
// GetTestProjectID returns the project ID of the shared test project.
107+
func GetTestProjectID(t *testing.T) string {
108+
t.Helper()
109+
return GetTestProject(t).ID
110+
}
111+
106112
// initTestProject initializes the test project, either from env vars or by creating a new one.
107113
func initTestProject(t *testing.T) {
108114
// If project credentials are provided, use the pre-created project

internal/client/client.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package client
33
import (
44
"bytes"
55
"context"
6+
"encoding/base64"
67
"encoding/json"
78
"errors"
89
"fmt"
@@ -1299,6 +1300,72 @@ func (c *OryClient) ListIdentitySchemas(ctx context.Context) ([]ory.IdentitySche
12991300
return schemas, nil
13001301
}
13011302

1303+
// HasProjectClient reports whether the project API client is configured.
1304+
func (c *OryClient) HasProjectClient() bool {
1305+
return c.config.ProjectSlug != "" && c.config.ProjectAPIKey != ""
1306+
}
1307+
1308+
// ListIdentitySchemasViaProject extracts identity schemas from the project
1309+
// config using the console API (workspace key). This does not require project
1310+
// API credentials and can be used during project bootstrap.
1311+
func (c *OryClient) ListIdentitySchemasViaProject(ctx context.Context, projectID string) ([]ory.IdentitySchemaContainer, error) {
1312+
if c.consoleClient == nil {
1313+
return nil, fmt.Errorf("console API client not configured: set workspace_api_key to use project_id lookups")
1314+
}
1315+
project, err := c.GetProject(ctx, projectID)
1316+
if err != nil {
1317+
return nil, fmt.Errorf("getting project for schema lookup: %w", err)
1318+
}
1319+
return extractSchemasFromProjectConfig(project)
1320+
}
1321+
1322+
// extractSchemasFromProjectConfig reads the identity schemas array from the
1323+
// project's kratos config and converts each entry into an
1324+
// IdentitySchemaContainer. For base64-encoded schemas the content is decoded
1325+
// inline; preset schemas are returned with an empty schema body.
1326+
func extractSchemasFromProjectConfig(project *ory.Project) ([]ory.IdentitySchemaContainer, error) {
1327+
if project.Services.Identity == nil {
1328+
return nil, nil
1329+
}
1330+
configMap := project.Services.Identity.Config
1331+
if configMap == nil {
1332+
return nil, nil
1333+
}
1334+
identity, _ := configMap["identity"].(map[string]interface{})
1335+
rawSchemas, _ := identity["schemas"].([]interface{})
1336+
1337+
var result []ory.IdentitySchemaContainer
1338+
for _, raw := range rawSchemas {
1339+
s, ok := raw.(map[string]interface{})
1340+
if !ok {
1341+
continue
1342+
}
1343+
id, _ := s["id"].(string)
1344+
rawURL, _ := s["url"].(string)
1345+
1346+
container := ory.IdentitySchemaContainer{Id: id}
1347+
1348+
if strings.HasPrefix(rawURL, "base64://") {
1349+
decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(rawURL, "base64://"))
1350+
if err != nil {
1351+
return nil, fmt.Errorf("decoding base64 schema %q: %w", id, err)
1352+
}
1353+
var schemaObj map[string]interface{}
1354+
if err := json.Unmarshal(decoded, &schemaObj); err != nil {
1355+
return nil, fmt.Errorf("parsing JSON for schema %q: %w", id, err)
1356+
}
1357+
container.Schema = schemaObj
1358+
} else {
1359+
// Preset or URL-based schemas: return an empty object so
1360+
// json.Marshal produces "{}" instead of "null".
1361+
container.Schema = map[string]interface{}{}
1362+
}
1363+
1364+
result = append(result, container)
1365+
}
1366+
return result, nil
1367+
}
1368+
13021369
// Custom Domain (CNAME) operations
13031370
// The Ory SDK does not generate API methods for custom domains,
13041371
// so we use raw HTTP calls against the console API.
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package client
2+
3+
import (
4+
"testing"
5+
6+
ory "github.com/ory/client-go"
7+
)
8+
9+
func TestExtractSchemasFromProjectConfig(t *testing.T) {
10+
t.Run("nil identity service", func(t *testing.T) {
11+
project := &ory.Project{
12+
Services: ory.ProjectServices{},
13+
}
14+
schemas, err := extractSchemasFromProjectConfig(project)
15+
if err != nil {
16+
t.Fatalf("unexpected error: %v", err)
17+
}
18+
if len(schemas) != 0 {
19+
t.Fatalf("expected 0 schemas, got %d", len(schemas))
20+
}
21+
})
22+
23+
t.Run("base64 schema", func(t *testing.T) {
24+
// {"type":"object","properties":{"traits":{"type":"object"}}}
25+
b64 := "eyJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJ0cmFpdHMiOnsidHlwZSI6Im9iamVjdCJ9fX0="
26+
project := &ory.Project{
27+
Services: ory.ProjectServices{
28+
Identity: &ory.ProjectServiceIdentity{
29+
Config: map[string]interface{}{
30+
"identity": map[string]interface{}{
31+
"schemas": []interface{}{
32+
map[string]interface{}{
33+
"id": "test-schema-hash",
34+
"url": "base64://" + b64,
35+
},
36+
},
37+
},
38+
},
39+
},
40+
},
41+
}
42+
schemas, err := extractSchemasFromProjectConfig(project)
43+
if err != nil {
44+
t.Fatalf("unexpected error: %v", err)
45+
}
46+
if len(schemas) != 1 {
47+
t.Fatalf("expected 1 schema, got %d", len(schemas))
48+
}
49+
if schemas[0].GetId() != "test-schema-hash" {
50+
t.Errorf("expected id 'test-schema-hash', got %q", schemas[0].GetId())
51+
}
52+
if schemas[0].Schema == nil {
53+
t.Fatal("expected schema content to be decoded, got nil")
54+
}
55+
if schemas[0].Schema["type"] != "object" {
56+
t.Errorf("expected schema type 'object', got %v", schemas[0].Schema["type"])
57+
}
58+
})
59+
60+
t.Run("preset schema returns empty object", func(t *testing.T) {
61+
project := &ory.Project{
62+
Services: ory.ProjectServices{
63+
Identity: &ory.ProjectServiceIdentity{
64+
Config: map[string]interface{}{
65+
"identity": map[string]interface{}{
66+
"schemas": []interface{}{
67+
map[string]interface{}{
68+
"id": "preset://username",
69+
"url": "preset://username",
70+
},
71+
},
72+
},
73+
},
74+
},
75+
},
76+
}
77+
schemas, err := extractSchemasFromProjectConfig(project)
78+
if err != nil {
79+
t.Fatalf("unexpected error: %v", err)
80+
}
81+
if len(schemas) != 1 {
82+
t.Fatalf("expected 1 schema, got %d", len(schemas))
83+
}
84+
if schemas[0].GetId() != "preset://username" {
85+
t.Errorf("expected id 'preset://username', got %q", schemas[0].GetId())
86+
}
87+
if schemas[0].Schema == nil {
88+
t.Fatal("expected schema to be empty object, got nil")
89+
}
90+
if len(schemas[0].Schema) != 0 {
91+
t.Errorf("expected empty schema object, got %v", schemas[0].Schema)
92+
}
93+
})
94+
95+
t.Run("invalid base64 returns error", func(t *testing.T) {
96+
project := &ory.Project{
97+
Services: ory.ProjectServices{
98+
Identity: &ory.ProjectServiceIdentity{
99+
Config: map[string]interface{}{
100+
"identity": map[string]interface{}{
101+
"schemas": []interface{}{
102+
map[string]interface{}{
103+
"id": "bad-b64",
104+
"url": "base64://!!!not-valid-base64!!!",
105+
},
106+
},
107+
},
108+
},
109+
},
110+
},
111+
}
112+
_, err := extractSchemasFromProjectConfig(project)
113+
if err == nil {
114+
t.Fatal("expected error for invalid base64, got nil")
115+
}
116+
})
117+
118+
t.Run("invalid JSON in base64 returns error", func(t *testing.T) {
119+
// "not json" in base64
120+
project := &ory.Project{
121+
Services: ory.ProjectServices{
122+
Identity: &ory.ProjectServiceIdentity{
123+
Config: map[string]interface{}{
124+
"identity": map[string]interface{}{
125+
"schemas": []interface{}{
126+
map[string]interface{}{
127+
"id": "bad-json",
128+
"url": "base64://bm90IGpzb24=",
129+
},
130+
},
131+
},
132+
},
133+
},
134+
},
135+
}
136+
_, err := extractSchemasFromProjectConfig(project)
137+
if err == nil {
138+
t.Fatal("expected error for invalid JSON, got nil")
139+
}
140+
})
141+
142+
t.Run("multiple schemas", func(t *testing.T) {
143+
b64 := "eyJ0eXBlIjoib2JqZWN0In0=" // {"type":"object"}
144+
project := &ory.Project{
145+
Services: ory.ProjectServices{
146+
Identity: &ory.ProjectServiceIdentity{
147+
Config: map[string]interface{}{
148+
"identity": map[string]interface{}{
149+
"schemas": []interface{}{
150+
map[string]interface{}{
151+
"id": "preset://username",
152+
"url": "preset://username",
153+
},
154+
map[string]interface{}{
155+
"id": "custom-hash-id",
156+
"url": "base64://" + b64,
157+
},
158+
},
159+
},
160+
},
161+
},
162+
},
163+
}
164+
schemas, err := extractSchemasFromProjectConfig(project)
165+
if err != nil {
166+
t.Fatalf("unexpected error: %v", err)
167+
}
168+
if len(schemas) != 2 {
169+
t.Fatalf("expected 2 schemas, got %d", len(schemas))
170+
}
171+
})
172+
}
173+
174+
func TestHasProjectClient(t *testing.T) {
175+
t.Run("configured", func(t *testing.T) {
176+
c := &OryClient{config: OryClientConfig{ProjectSlug: "slug", ProjectAPIKey: "key"}}
177+
if !c.HasProjectClient() {
178+
t.Error("expected HasProjectClient to return true")
179+
}
180+
})
181+
t.Run("missing slug", func(t *testing.T) {
182+
c := &OryClient{config: OryClientConfig{ProjectAPIKey: "key"}}
183+
if c.HasProjectClient() {
184+
t.Error("expected HasProjectClient to return false")
185+
}
186+
})
187+
t.Run("missing key", func(t *testing.T) {
188+
c := &OryClient{config: OryClientConfig{ProjectSlug: "slug"}}
189+
if c.HasProjectClient() {
190+
t.Error("expected HasProjectClient to return false")
191+
}
192+
})
193+
}

0 commit comments

Comments
 (0)