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
3 changes: 3 additions & 0 deletions .changelog/pr-161.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:enhancement
`resource/pingone_davinci_variable`: Secret variables (`data_type = "secret"`) now export with a `value` block referencing a generated Terraform variable (`sensitive = true`) instead of being exported without a value. The generated variable's tfvars entry is empty — set it manually before applying; Terraform plan fails on an empty secret by design (the provider requires a non-empty `secret_string`), matching connector `clientSecret` handling. Non-secret variables are unchanged. Re-export your configuration to pick up the new blocks.
```
15 changes: 14 additions & 1 deletion contributing/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,8 @@ Some Terraform provider attributes expect a block with exactly one type-specific

The processor reads the runtime type of the extracted value, looks up the block key in `type_key_map`, and emits the appropriate single-key map. Values for keys listed in `json_encode_keys` are `json.Marshal`'d and emitted as `RawHCLValue`.

`skip_conditions` suppresses the whole block when matched (OR'd). A suppressed block can be re-emitted by a `conditional_defaults` rule — used by the DaVinci variable definition, where the API returns no readable value for `data_type = "secret"`, so the definition suppresses the block and re-emits it with the provider-required `secret_string` key holding a placeholder that the variable extractor swaps for a `var.X` reference.

#### Conditional Defaults

Post-processing attribute overrides based on the state of other processed attributes:
Expand All @@ -307,7 +309,7 @@ conditional_defaults:
value: false
```

Evaluated after all attributes are processed and before formatting.
Evaluated after all attributes are processed and before formatting. Rules apply in declaration order — a rule that emits an attribute (e.g. the secret `value` block) must be declared before rules whose conditions reference that attribute's emptiness.

### Schema Registry

Expand Down Expand Up @@ -895,6 +897,7 @@ The `VariableExtractor` (`internal/variables/extractor.go`) evaluates schema-dri
- Applies `variables.eligible_attributes` rules for naming and descriptions
- Handles `type_discriminated_block` value unwrapping for tfvars output
- Custom transforms can also produce variables via `TransformResultWithVariables`
- Applies rule-level `is_secret` / `sensitive` flags and their conditional forms (`is_secret_when_all`, `sensitive_when_all`) — a secret variable renders with `sensitive = true` and its tfvars entry stays empty
- **Variable names are sanitized via `utils.SanitizeVariableName()` to ensure valid Terraform identifiers**

### HCL Template Character Escaping in tfvars
Expand Down Expand Up @@ -1027,6 +1030,16 @@ variables:
- attribute_path: value
variable_prefix: davinci_variable_
is_secret: false
# Conditional flags — secretness/sensitivity that depends on resource
# data. Uses the same condition vocabulary as conditional_defaults.
sensitive_when_all:
- attribute_equals:
name: data_type
value: secret
is_secret_when_all:
- attribute_equals:
name: data_type
value: secret
description: "Value for DaVinci variable {name}"
```

Expand Down
35 changes: 32 additions & 3 deletions definitions/pingone/davinci/variable.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,10 @@ attributes:

# Value - optional, variable-eligible
# Uses type_discriminated_block to map Go runtime type to Terraform block key.
# Skip conditions suppress the block for secret data types.
# Skip conditions suppress the block for secret data types (the API returns
# no readable value); conditional_defaults below re-emits it with the
# provider-required secret_string key so secret variables export like other
# types, backed by an empty sensitive Terraform variable.
- name: Value
terraform_name: value
type: type_discriminated_block
Expand Down Expand Up @@ -130,9 +133,24 @@ dependencies:
import_id_format: "{env_id}/{resource_id}"

# Post-processing attribute overrides
# When no value will be written and mutable is false, the Terraform provider
# requires mutable = true. This replaces the Go handleVariableMutableOverride handler.
# Secret variables: the API never returns a readable value (suppressed by the
# skip condition above), so emit the block with the provider-required
# secret_string key. The string placeholder is replaced by the variable
# extractor with a var.X reference (see variables.eligible_attributes below).
# This rule must precede the mutable rule so secret variables keep their
# API-provided mutable value instead of being forced to true.
conditional_defaults:
- target_attribute: value
set_value:
secret_string: "REPLACED_BY_VARIABLE_EXTRACTOR"
when_all:
- attribute_empty: value
- attribute_equals:
name: data_type
value: secret

# When no value will be written and mutable is false, the Terraform provider
# requires mutable = true. This replaces the Go handleVariableMutableOverride handler.
- target_attribute: mutable
set_value: true
when_all:
Expand All @@ -146,4 +164,15 @@ variables:
- attribute_path: value
variable_prefix: davinci_variable_
is_secret: false
# Secret variables: value becomes sensitive, and its tfvars entry stays
# empty (the API value is not exportable). Conditional flags — applies
# only when data_type is secret, leaving other variable types untouched.
sensitive_when_all:
- attribute_equals:
name: data_type
value: secret
is_secret_when_all:
- attribute_equals:
name: data_type
value: secret
description: "Value for DaVinci variable {name}"
67 changes: 25 additions & 42 deletions internal/core/processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -1067,57 +1067,40 @@ func unwrapUnionStruct(val reflect.Value) (interface{}, bool) {
// evaluateConditionalDefaults applies declarative post-processing overrides
// to the processed attributes. Each rule's WhenAll conditions must all be true
// for the override to apply.
//
// SetValue values that are maps are deep-copied per resource: the parsed YAML
// rule is shared across every resource of a type, and assigning the map
// directly would alias one underlying map into every Attributes map — later
// per-resource mutations (e.g. the variable extractor's ResolvedReference
// injection into a single-key block) would then leak across resources.
func (p *Processor) evaluateConditionalDefaults(attrs map[string]interface{}, defaults []schema.ConditionalDefault) {
for _, cd := range defaults {
if p.allConditionsMet(attrs, cd.WhenAll) {
attrs[cd.TargetAttribute] = cd.SetValue
if schema.EvaluateConditions(attrs, cd.WhenAll) {
attrs[cd.TargetAttribute] = deepCopyValue(cd.SetValue)
}
}
}

// allConditionsMet checks whether all conditions in a ConditionalDefault rule
// are satisfied by the current attribute map.
func (p *Processor) allConditionsMet(attrs map[string]interface{}, conditions []schema.DefaultCondition) bool {
for _, cond := range conditions {
if cond.AttributeEmpty != "" {
val, exists := attrs[cond.AttributeEmpty]
if exists && val != nil {
if s, ok := val.(string); ok && s == "" {
// empty string counts as empty
} else {
return false
}
}
// nil or not-present counts as empty — condition passes
}

if cond.AttributeEquals != nil {
val, exists := attrs[cond.AttributeEquals.Name]
if !exists {
return false
}
if !valuesEqual(val, cond.AttributeEquals.Value) {
return false
}
// deepCopyValue recursively copies maps and slices so a shared schema-derived
// value can be safely mutated per resource. Scalars and type-asserted special
// values are returned unchanged.
func deepCopyValue(v interface{}) interface{} {
switch typed := v.(type) {
case map[string]interface{}:
copied := make(map[string]interface{}, len(typed))
for k, val := range typed {
copied[k] = deepCopyValue(val)
}
}
return true
}

// valuesEqual compares two values for equality, handling type coercion between
// bool and interface{} from YAML unmarshaling.
func valuesEqual(a, b interface{}) bool {
if a == b {
return true
}
// YAML unmarshals booleans as bool, but processor may store them differently.
if ab, ok := a.(bool); ok {
if bb, ok := b.(bool); ok {
return ab == bb
return copied
case []interface{}:
copied := make([]interface{}, len(typed))
for i, val := range typed {
copied[i] = deepCopyValue(val)
}
return copied
default:
return v
}
// Compare as strings for mixed-type edge cases.
return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
}

// ProcessResourceList processes a list of API resources
Expand Down
82 changes: 82 additions & 0 deletions internal/core/processor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,88 @@ func TestProcessorConditionalDefaults_NoOverrideWhenMutableTrue(t *testing.T) {
assert.Equal(t, true, result.Attributes["mutable"])
}

// secretVariableDef builds a DaVinci-variable-shaped definition: a
// type_discriminated_block value with the secret skip condition, a mutable
// bool, a data_type string, and the two conditional_defaults rules from
// definitions/pingone/davinci/variable.yaml in the same order (secret value
// emission must precede the mutable override).
func secretVariableDef() *schema.ResourceDefinition {
def := tdbDef([]schema.SkipCondition{
{SourceField: "DataType", Equals: "secret"},
})
def.Attributes = append(def.Attributes,
schema.AttributeDefinition{Name: "DataType", TerraformName: "data_type", Type: "string", SourcePath: "DataType"},
)
def.ConditionalDefaults = []schema.ConditionalDefault{
{
TargetAttribute: "value",
SetValue: map[string]interface{}{"secret_string": "REPLACED_BY_VARIABLE_EXTRACTOR"},
WhenAll: []schema.DefaultCondition{
{AttributeEmpty: "value"},
{AttributeEquals: &schema.AttributeValueCondition{Name: "data_type", Value: "secret"}},
},
},
{
TargetAttribute: "mutable",
SetValue: true,
WhenAll: []schema.DefaultCondition{
{AttributeEmpty: "value"},
{AttributeEquals: &schema.AttributeValueCondition{Name: "mutable", Value: false}},
},
},
}
return def
}

func TestProcessorConditionalDefaults_SecretValueBlockEmitted(t *testing.T) {
registry := schema.NewRegistry()
require.NoError(t, registry.Register(secretVariableDef()))
p := core.NewProcessor(registry)

// Secret variable: API returns no readable value (nil) — skip condition
// suppresses the tdb block, then the conditional default re-emits it with
// the provider-required secret_string key.
mock := &MockTDB{ID: "1", Name: "v", DataType: "secret", Mutable: true, Value: nil}
result, err := p.ProcessResource("test_tdb", mock)
require.NoError(t, err)

m, ok := result.Attributes["value"].(map[string]interface{})
require.True(t, ok, "secret variable must have a value block")
assert.Equal(t, "REPLACED_BY_VARIABLE_EXTRACTOR", m["secret_string"])
}

func TestProcessorConditionalDefaults_SecretMutablePassesThrough(t *testing.T) {
registry := schema.NewRegistry()
require.NoError(t, registry.Register(secretVariableDef()))
p := core.NewProcessor(registry)

// With the value block emitted for secrets, the mutable override must not
// fire — the API-provided mutable value passes through. Rule ordering in
// the YAML (secret rule before mutable rule) is what makes this hold.
mock := &MockTDB{ID: "1", Name: "v", DataType: "secret", Mutable: false, Value: nil}
result, err := p.ProcessResource("test_tdb", mock)
require.NoError(t, err)
assert.Equal(t, false, result.Attributes["mutable"], "secret variable keeps API-provided mutable")
assert.Contains(t, result.Attributes, "value")
}

func TestProcessorConditionalDefaults_SecretRuleNotFiredForStringType(t *testing.T) {
registry := schema.NewRegistry()
require.NoError(t, registry.Register(secretVariableDef()))
p := core.NewProcessor(registry)

// Plain string variable: tdb emits {"string": ...}, the secret rule does
// not fire (data_type mismatch), and the mutable rule still applies when
// the value is absent.
mock := &MockTDB{ID: "1", Name: "v", DataType: "string", Mutable: false, Value: "hello"}
result, err := p.ProcessResource("test_tdb", mock)
require.NoError(t, err)

m := result.Attributes["value"].(map[string]interface{})
assert.Equal(t, "hello", m["string"])
assert.Nil(t, m["secret_string"], "secret_string key must not leak into non-secret variables")
}

// ── Choice wrapper unwrapping tests ──────────────────────────────

// MockChoiceWrapper mimics PingOne SDK choice types like
Expand Down
52 changes: 52 additions & 0 deletions internal/formatters/hcl/formatter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -969,6 +969,58 @@ func TestFormatter_Format_TypeDiscriminatedBlockVariableReference(t *testing.T)
"variable references inside type_discriminated_block must render as var.X")
}

// Secret DaVinci variables export a value block keyed secret_string (the
// provider-required key) whose value is a sensitive Terraform variable
// reference. See definitions/pingone/davinci/variable.yaml conditional_defaults.
func TestFormatter_Format_TypeDiscriminatedBlock_SecretStringKey(t *testing.T) {
f := hclformatter.NewFormatter()

def := &schema.ResourceDefinition{
Metadata: schema.ResourceMetadata{
ResourceType: "pingone_davinci_variable",
},
Attributes: []schema.AttributeDefinition{
{Name: "ID", TerraformName: "id", Type: "string", Computed: true},
{Name: "Name", TerraformName: "name", Type: "string"},
{Name: "DataType", TerraformName: "data_type", Type: "string"},
{
Name: "Value",
TerraformName: "value",
Type: "type_discriminated_block",
TypeDiscriminatedBlock: &schema.TypeDiscriminatedBlockConfig{
TypeKeyMap: map[string]string{
"string": "string",
},
},
},
},
}

data := &core.ResourceData{
Name: "secret-a",
ID: "var-123",
Attributes: map[string]interface{}{
"id": "var-123",
"name": "secret-a",
"data_type": "secret",
"value": map[string]interface{}{
"secret_string": core.ResolvedReference{
IsVariable: true,
VariableName: "davinci_variable_secret_a_value",
},
},
},
}

output, err := f.Format(data, def, hclformatter.FormatOptions{})
require.NoError(t, err)

assert.Contains(t, output, "secret_string = var.davinci_variable_secret_a_value",
"secret_string must render as an unquoted var.X reference")
assert.NotContains(t, output, `"secret_string"`,
"secret_string must not be rendered as a quoted JSON-style key")
}

// ── nil_value: keep_empty on collection types formatter tests ────

// TestFormatter_Format_SetWithNestedAttributesKeepEmpty verifies that an empty
Expand Down
33 changes: 33 additions & 0 deletions internal/formatters/tfjson/formatter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -946,3 +946,36 @@ func TestFormat_TypeDiscriminatedBlock_ResolvedVariableReference(t *testing.T) {
assert.Equal(t, "${var.davinci_variable_test_value}", value["string"],
"variable references inside type_discriminated_block must render as ${var.X}")
}

// Secret DaVinci variables export a value block keyed secret_string (the
// provider-required key) whose value is a sensitive Terraform variable
// reference. See definitions/pingone/davinci/variable.yaml conditional_defaults.
func TestFormat_TypeDiscriminatedBlock_SecretStringKey(t *testing.T) {
f := NewFormatter()
def := baseDef(schema.AttributeDefinition{
Name: "Value",
TerraformName: "value",
Type: "type_discriminated_block",
TypeDiscriminatedBlock: &schema.TypeDiscriminatedBlockConfig{
TypeKeyMap: map[string]string{
"string": "string",
},
},
})
data := baseData("res1", "id1", map[string]interface{}{
"value": map[string]interface{}{
"secret_string": core.ResolvedReference{
IsVariable: true,
VariableName: "davinci_variable_secret_a_value",
},
},
})
out, err := f.Format(data, def, FormatOptions{})
require.NoError(t, err)

attrs := unmarshalResource(t, out, "test_resource", "pingcli__res1")
value, ok := attrs["value"].(map[string]interface{})
require.True(t, ok, "value should be a map object")
assert.Equal(t, "${var.davinci_variable_secret_a_value}", value["secret_string"],
"secret_string must render as ${var.X} in tfjson output")
}
Loading
Loading