Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/data-sources/identity_schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ This data source retrieves a specific identity schema from the project, allowing

~> **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.

~> **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.

## Example Usage

```terraform
Expand Down Expand Up @@ -58,6 +60,12 @@ resource "ory_identity_schema" "employee" {
data "ory_identity_schema" "employee" {
id = ory_identity_schema.employee.id
}

# Look up a schema during project bootstrap (no project_slug/project_api_key needed)
data "ory_identity_schema" "bootstrap" {
id = "preset://username"
project_id = "your-project-uuid"
}
```

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

- `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'.

### Optional

- `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.

### Read-Only

- `schema` (String) The JSON Schema definition for the identity traits.
6 changes: 6 additions & 0 deletions docs/data-sources/identity_schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ This data source retrieves all identity schemas configured for the current proje

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

~> **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.

## Example Usage

```terraform
Expand All @@ -27,6 +29,10 @@ output "schemas" {
<!-- schema generated by tfplugindocs -->
## Schema

### Optional

- `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.

### Read-Only

- `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))
Expand Down
6 changes: 6 additions & 0 deletions examples/data-sources/ory_identity_schema/data-source.tf
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,9 @@ resource "ory_identity_schema" "employee" {
data "ory_identity_schema" "employee" {
id = ory_identity_schema.employee.id
}

# Look up a schema during project bootstrap (no project_slug/project_api_key needed)
data "ory_identity_schema" "bootstrap" {
id = "preset://username"
project_id = "your-project-uuid"
}
6 changes: 6 additions & 0 deletions internal/acctest/acctest.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ func GetTestProject(t *testing.T) *TestProject {
return sharedTestProject
}

// GetTestProjectID returns the project ID of the shared test project.
func GetTestProjectID(t *testing.T) string {
t.Helper()
return GetTestProject(t).ID
}

// initTestProject initializes the test project, either from env vars or by creating a new one.
func initTestProject(t *testing.T) {
// If project credentials are provided, use the pre-created project
Expand Down
67 changes: 67 additions & 0 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package client
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -1299,6 +1300,72 @@ func (c *OryClient) ListIdentitySchemas(ctx context.Context) ([]ory.IdentitySche
return schemas, nil
}

// HasProjectClient reports whether the project API client is configured.
func (c *OryClient) HasProjectClient() bool {
return c.config.ProjectSlug != "" && c.config.ProjectAPIKey != ""
}

// ListIdentitySchemasViaProject extracts identity schemas from the project
// config using the console API (workspace key). This does not require project
// API credentials and can be used during project bootstrap.
func (c *OryClient) ListIdentitySchemasViaProject(ctx context.Context, projectID string) ([]ory.IdentitySchemaContainer, error) {
if c.consoleClient == nil {
return nil, fmt.Errorf("console API client not configured: set workspace_api_key to use project_id lookups")
}
project, err := c.GetProject(ctx, projectID)
if err != nil {
return nil, fmt.Errorf("getting project for schema lookup: %w", err)
}
return extractSchemasFromProjectConfig(project)
}

// extractSchemasFromProjectConfig reads the identity schemas array from the
// project's kratos config and converts each entry into an
// IdentitySchemaContainer. For base64-encoded schemas the content is decoded
// inline; preset schemas are returned with an empty schema body.
func extractSchemasFromProjectConfig(project *ory.Project) ([]ory.IdentitySchemaContainer, error) {
if project.Services.Identity == nil {
return nil, nil
}
configMap := project.Services.Identity.Config
if configMap == nil {
return nil, nil
}
identity, _ := configMap["identity"].(map[string]interface{})
rawSchemas, _ := identity["schemas"].([]interface{})

var result []ory.IdentitySchemaContainer
for _, raw := range rawSchemas {
s, ok := raw.(map[string]interface{})
if !ok {
continue
}
id, _ := s["id"].(string)
rawURL, _ := s["url"].(string)

container := ory.IdentitySchemaContainer{Id: id}

if strings.HasPrefix(rawURL, "base64://") {
decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(rawURL, "base64://"))
if err != nil {
return nil, fmt.Errorf("decoding base64 schema %q: %w", id, err)
}
var schemaObj map[string]interface{}
if err := json.Unmarshal(decoded, &schemaObj); err != nil {
return nil, fmt.Errorf("parsing JSON for schema %q: %w", id, err)
}
container.Schema = schemaObj
} else {
// Preset or URL-based schemas: return an empty object so
// json.Marshal produces "{}" instead of "null".
container.Schema = map[string]interface{}{}
}

result = append(result, container)
}
return result, nil
}

// Custom Domain (CNAME) operations
// The Ory SDK does not generate API methods for custom domains,
// so we use raw HTTP calls against the console API.
Expand Down
193 changes: 193 additions & 0 deletions internal/client/extract_schemas_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package client

import (
"testing"

ory "github.com/ory/client-go"
)

func TestExtractSchemasFromProjectConfig(t *testing.T) {
t.Run("nil identity service", func(t *testing.T) {
project := &ory.Project{
Services: ory.ProjectServices{},
}
schemas, err := extractSchemasFromProjectConfig(project)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(schemas) != 0 {
t.Fatalf("expected 0 schemas, got %d", len(schemas))
}
})

t.Run("base64 schema", func(t *testing.T) {
// {"type":"object","properties":{"traits":{"type":"object"}}}
b64 := "eyJ0eXBlIjoib2JqZWN0IiwicHJvcGVydGllcyI6eyJ0cmFpdHMiOnsidHlwZSI6Im9iamVjdCJ9fX0="
project := &ory.Project{
Services: ory.ProjectServices{
Identity: &ory.ProjectServiceIdentity{
Config: map[string]interface{}{
"identity": map[string]interface{}{
"schemas": []interface{}{
map[string]interface{}{
"id": "test-schema-hash",
"url": "base64://" + b64,
},
},
},
},
},
},
}
schemas, err := extractSchemasFromProjectConfig(project)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(schemas) != 1 {
t.Fatalf("expected 1 schema, got %d", len(schemas))
}
if schemas[0].GetId() != "test-schema-hash" {
t.Errorf("expected id 'test-schema-hash', got %q", schemas[0].GetId())
}
if schemas[0].Schema == nil {
t.Fatal("expected schema content to be decoded, got nil")
}
if schemas[0].Schema["type"] != "object" {
t.Errorf("expected schema type 'object', got %v", schemas[0].Schema["type"])
}
})

t.Run("preset schema returns empty object", func(t *testing.T) {
project := &ory.Project{
Services: ory.ProjectServices{
Identity: &ory.ProjectServiceIdentity{
Config: map[string]interface{}{
"identity": map[string]interface{}{
"schemas": []interface{}{
map[string]interface{}{
"id": "preset://username",
"url": "preset://username",
},
},
},
},
},
},
}
schemas, err := extractSchemasFromProjectConfig(project)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(schemas) != 1 {
t.Fatalf("expected 1 schema, got %d", len(schemas))
}
if schemas[0].GetId() != "preset://username" {
t.Errorf("expected id 'preset://username', got %q", schemas[0].GetId())
}
if schemas[0].Schema == nil {
t.Fatal("expected schema to be empty object, got nil")
}
if len(schemas[0].Schema) != 0 {
t.Errorf("expected empty schema object, got %v", schemas[0].Schema)
}
})

t.Run("invalid base64 returns error", func(t *testing.T) {
project := &ory.Project{
Services: ory.ProjectServices{
Identity: &ory.ProjectServiceIdentity{
Config: map[string]interface{}{
"identity": map[string]interface{}{
"schemas": []interface{}{
map[string]interface{}{
"id": "bad-b64",
"url": "base64://!!!not-valid-base64!!!",
},
},
},
},
},
},
}
_, err := extractSchemasFromProjectConfig(project)
if err == nil {
t.Fatal("expected error for invalid base64, got nil")
}
})

t.Run("invalid JSON in base64 returns error", func(t *testing.T) {
// "not json" in base64
project := &ory.Project{
Services: ory.ProjectServices{
Identity: &ory.ProjectServiceIdentity{
Config: map[string]interface{}{
"identity": map[string]interface{}{
"schemas": []interface{}{
map[string]interface{}{
"id": "bad-json",
"url": "base64://bm90IGpzb24=",
},
},
},
},
},
},
}
_, err := extractSchemasFromProjectConfig(project)
if err == nil {
t.Fatal("expected error for invalid JSON, got nil")
}
})

t.Run("multiple schemas", func(t *testing.T) {
b64 := "eyJ0eXBlIjoib2JqZWN0In0=" // {"type":"object"}
project := &ory.Project{
Services: ory.ProjectServices{
Identity: &ory.ProjectServiceIdentity{
Config: map[string]interface{}{
"identity": map[string]interface{}{
"schemas": []interface{}{
map[string]interface{}{
"id": "preset://username",
"url": "preset://username",
},
map[string]interface{}{
"id": "custom-hash-id",
"url": "base64://" + b64,
},
},
},
},
},
},
}
schemas, err := extractSchemasFromProjectConfig(project)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(schemas) != 2 {
t.Fatalf("expected 2 schemas, got %d", len(schemas))
}
})
}

func TestHasProjectClient(t *testing.T) {
t.Run("configured", func(t *testing.T) {
c := &OryClient{config: OryClientConfig{ProjectSlug: "slug", ProjectAPIKey: "key"}}
if !c.HasProjectClient() {
t.Error("expected HasProjectClient to return true")
}
})
t.Run("missing slug", func(t *testing.T) {
c := &OryClient{config: OryClientConfig{ProjectAPIKey: "key"}}
if c.HasProjectClient() {
t.Error("expected HasProjectClient to return false")
}
})
t.Run("missing key", func(t *testing.T) {
c := &OryClient{config: OryClientConfig{ProjectSlug: "slug"}}
if c.HasProjectClient() {
t.Error("expected HasProjectClient to return false")
}
})
}
Loading