From 3670f4caa6594780a2faa0c6f19468a5eb03adfc Mon Sep 17 00:00:00 2001 From: samirgandhi19 <17574913+samir-gandhi@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:34:04 -0600 Subject: [PATCH 1/3] core: Add pingone-go-sdk-v2/management SDK for non-DaVinci PingOne resources pingone-go-client only covers DaVinci, Environments, Connectors, and ConfigurationManagement APIs, so the ~92 open Platform/SSO/Authorize/MFA/ Protect/Verify export-support issues cannot be implemented against it. Add patrickcping/pingone-go-sdk-v2/management (the SDK terraform-provider- pingone itself uses) as a second client on Client, built lazily since it performs an OAuth exchange at construction time rather than on first request. --- cmd/export_outputs_test.go | 12 ++--- go.mod | 9 +++- go.sum | 14 ++++++ internal/platform/pingone/client.go | 48 +++++++++++++++++-- internal/platform/pingone/client_test.go | 60 +++++++++++++----------- 5 files changed, 104 insertions(+), 39 deletions(-) diff --git a/cmd/export_outputs_test.go b/cmd/export_outputs_test.go index b340390..3522e29 100644 --- a/cmd/export_outputs_test.go +++ b/cmd/export_outputs_test.go @@ -16,12 +16,12 @@ import ( func TestParseOutputPath(t *testing.T) { tests := []struct { - name string - raw string - wantOk bool - wantResType string - wantLabel string - wantAttrPath string + name string + raw string + wantOk bool + wantResType string + wantLabel string + wantAttrPath string }{ { name: "valid 3-segment path", diff --git a/go.mod b/go.mod index 19cadee..d5f232a 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,13 @@ module github.com/pingidentity/pingcli-plugin-terraformer -go 1.25.1 +go 1.25.6 require ( github.com/google/uuid v1.6.0 github.com/hashicorp/go-plugin v1.7.0 github.com/hashicorp/hcl/v2 v2.24.0 + github.com/patrickcping/pingone-go-sdk-v2 v0.14.14 + github.com/patrickcping/pingone-go-sdk-v2/management v0.70.0 github.com/pingidentity/pingcli v0.8.0 github.com/pingidentity/pingone-go-client v0.12.0 github.com/spf13/pflag v1.0.10 @@ -35,6 +37,11 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/oklog/run v1.2.0 // indirect + github.com/patrickcping/pingone-go-sdk-v2/authorize v0.8.3 // indirect + github.com/patrickcping/pingone-go-sdk-v2/credentials v0.12.1 // indirect + github.com/patrickcping/pingone-go-sdk-v2/mfa v0.25.1 // indirect + github.com/patrickcping/pingone-go-sdk-v2/risk v0.21.1 // indirect + github.com/patrickcping/pingone-go-sdk-v2/verify v0.11.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/zalando/go-keyring v0.2.8 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect diff --git a/go.sum b/go.sum index 10b09ae..61cd8c1 100644 --- a/go.sum +++ b/go.sum @@ -60,6 +60,20 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= +github.com/patrickcping/pingone-go-sdk-v2 v0.14.14 h1:illFMogbGgYoZUr+kW0OQjbLyTfGEemLmTcX6oIIdx8= +github.com/patrickcping/pingone-go-sdk-v2 v0.14.14/go.mod h1:DhdLzztyNuH03k4h/AvmLjiYdvxHf1EViZBwDMan6BM= +github.com/patrickcping/pingone-go-sdk-v2/authorize v0.8.3 h1:rB2g0Ry+AlvCxN0gxXNb2AQm/1kyB3G47g2H3KRPHVQ= +github.com/patrickcping/pingone-go-sdk-v2/authorize v0.8.3/go.mod h1:Tb8FQPxveYj4QOc4VJa42QccMv2hSLNpTKDUSdWXtVk= +github.com/patrickcping/pingone-go-sdk-v2/credentials v0.12.1 h1:7g4f1UWz85C4Onwg5hfqZPvyWrbqXJ5HlYNeVDRD8Sk= +github.com/patrickcping/pingone-go-sdk-v2/credentials v0.12.1/go.mod h1:SaqdGPTxYXaeVgOCJXDDCK40Avr+zGujG0YJGQTwyJg= +github.com/patrickcping/pingone-go-sdk-v2/management v0.70.0 h1:O+tBQC6AreSSoFAproARMxaxcPL3hDJKJmb7GkVm7Sg= +github.com/patrickcping/pingone-go-sdk-v2/management v0.70.0/go.mod h1:Ld0egUhvuv9SRvTjTgQrzZur6tB+qBTxm4kSfX6uj6Y= +github.com/patrickcping/pingone-go-sdk-v2/mfa v0.25.1 h1:qLwEQfXJwKsStn935tEUyt9L6r7rJRuukmOfVwRX4H0= +github.com/patrickcping/pingone-go-sdk-v2/mfa v0.25.1/go.mod h1:U5xu9EHj1Wvgl2an+qoq8QgScAPYB02bF0CKQmpo08Y= +github.com/patrickcping/pingone-go-sdk-v2/risk v0.21.1 h1:hjXbUFYkKuQEIyu/m8+yDXOkJRIuIB4/LIy1WZ0Zf+U= +github.com/patrickcping/pingone-go-sdk-v2/risk v0.21.1/go.mod h1:3emXaUBwjjZd6znlR4l9z52j8mryoCuzXOajlQH5Biw= +github.com/patrickcping/pingone-go-sdk-v2/verify v0.11.2 h1:BfJGv6gmHzUDNYI/ZyqvdBIQVPlkAKwnjYs/kevD/PE= +github.com/patrickcping/pingone-go-sdk-v2/verify v0.11.2/go.mod h1:qbJfZ1MXPDeouQBsWelraoNfmguOT1/RBJFuAnQvrSA= github.com/pingidentity/pingcli v0.8.0 h1:KmUuzGliAtNlLz/okMRtT0vdRoiSAz3Kb+/FGti6rpA= github.com/pingidentity/pingcli v0.8.0/go.mod h1:3fAj8w0kZtLU6DBLKdCLQnPCN+gbVjysnwGrgsJoSYY= github.com/pingidentity/pingone-go-client v0.12.0 h1:t9L18Qul3UXqqvi0578R7bzys54cEEl+oSJDT6rMdyM= diff --git a/internal/platform/pingone/client.go b/internal/platform/pingone/client.go index 5d846b2..f966e80 100644 --- a/internal/platform/pingone/client.go +++ b/internal/platform/pingone/client.go @@ -12,6 +12,8 @@ import ( "fmt" "github.com/google/uuid" + "github.com/patrickcping/pingone-go-sdk-v2/management" + pingonesdkv2 "github.com/patrickcping/pingone-go-sdk-v2/pingone" "github.com/pingidentity/pingone-go-client/config" "github.com/pingidentity/pingone-go-client/oauth2" "github.com/pingidentity/pingone-go-client/pingone" @@ -28,9 +30,11 @@ var _ clients.APIClient = (*Client)(nil) // Resource-specific list/get logic and custom handlers live in resource_*.go // files. Each file registers everything for its resource via init(). type Client struct { - apiClient *pingone.APIClient - environmentID uuid.UUID - warnings []string + apiClient *pingone.APIClient + managementCfg *pingonesdkv2.Config + managementClient *management.APIClient + environmentID uuid.UUID + warnings []string } // New creates a DaVinci APIClient from a pre-built SDK client and environment ID. @@ -38,6 +42,34 @@ func New(apiClient *pingone.APIClient, environmentID uuid.UUID) *Client { return &Client{apiClient: apiClient, environmentID: environmentID} } +// NewWithManagementClient creates a Client from a pre-built DaVinci SDK +// client and a pre-built management SDK client. Used by tests that need to +// inject a fake management.APIClient without performing a real OAuth +// exchange. +func NewWithManagementClient(apiClient *pingone.APIClient, managementClient *management.APIClient, environmentID uuid.UUID) *Client { + return &Client{apiClient: apiClient, managementClient: managementClient, environmentID: environmentID} +} + +// management lazily builds and caches the management SDK API client. The +// management SDK performs an OAuth token exchange as soon as the client is +// built (unlike the DaVinci SDK, which defers auth to the first request), so +// construction is deferred until a management resource handler actually +// needs it. +func (c *Client) management(ctx context.Context) (*management.APIClient, error) { + if c.managementClient != nil { + return c.managementClient, nil + } + if c.managementCfg == nil { + return nil, fmt.Errorf("management API client not configured") + } + client, err := c.managementCfg.ManagementAPIClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to initialize management API client: %w", err) + } + c.managementClient = client + return client, nil +} + // Platform returns the platform identifier. func (c *Client) Platform() string { return "pingone" } @@ -106,12 +138,20 @@ func NewFromCredentials(ctx context.Context, workerEnvID, exportEnvID, region, c return nil, fmt.Errorf("failed to initialize API client: %w", err) } + regionCode := management.EnumRegionCode(region) + managementCfg := &pingonesdkv2.Config{ + ClientID: &clientID, + ClientSecret: &clientSecret, + EnvironmentID: &workerEnvID, + RegionCode: ®ionCode, + } + envUUID, err := uuid.Parse(exportEnvID) if err != nil { return nil, fmt.Errorf("invalid export environment ID format: %w", err) } - return &Client{apiClient: apiClient, environmentID: envUUID}, nil + return &Client{apiClient: apiClient, managementCfg: managementCfg, environmentID: envUUID}, nil } // ValidRegions returns the list of valid PingOne region codes. diff --git a/internal/platform/pingone/client_test.go b/internal/platform/pingone/client_test.go index b3241cf..6c1fe14 100644 --- a/internal/platform/pingone/client_test.go +++ b/internal/platform/pingone/client_test.go @@ -30,8 +30,12 @@ func TestNewClient(t *testing.T) { func TestNewFromCredentials(t *testing.T) { ctx := context.Background() - // A valid UUID to use as exportEnvID in success cases. + // Valid UUIDs — real PingOne client/environment IDs are UUIDs, and the + // management SDK's Config.Validate() rejects non-UUID-shaped values. validExportEnvID := "00000000-0000-0000-0000-000000000001" + validWorkerEnvID := "00000000-0000-0000-0000-000000000010" + validClientID := "00000000-0000-0000-0000-000000000020" + validClientSecret := "secret-123" tests := []struct { name string @@ -48,96 +52,96 @@ func TestNewFromCredentials(t *testing.T) { workerEnvID: "", exportEnvID: validExportEnvID, region: "NA", - clientID: "client-123", - clientSecret: "secret-123", + clientID: validClientID, + clientSecret: validClientSecret, expectError: true, errorContains: "auth environment ID is required", }, { name: "missing target environment ID", - workerEnvID: "auth-env-123", + workerEnvID: validWorkerEnvID, exportEnvID: "", region: "NA", - clientID: "client-123", - clientSecret: "secret-123", + clientID: validClientID, + clientSecret: validClientSecret, expectError: true, errorContains: "target environment ID is required", }, { name: "missing region", - workerEnvID: "auth-env-123", + workerEnvID: validWorkerEnvID, exportEnvID: validExportEnvID, region: "", - clientID: "client-123", - clientSecret: "secret-123", + clientID: validClientID, + clientSecret: validClientSecret, expectError: true, errorContains: "region is required", }, { name: "invalid region", - workerEnvID: "auth-env-123", + workerEnvID: validWorkerEnvID, exportEnvID: validExportEnvID, region: "XX", - clientID: "client-123", - clientSecret: "secret-123", + clientID: validClientID, + clientSecret: validClientSecret, expectError: true, errorContains: "invalid region: XX", }, { name: "missing client ID", - workerEnvID: "auth-env-123", + workerEnvID: validWorkerEnvID, exportEnvID: validExportEnvID, region: "NA", clientID: "", - clientSecret: "secret-123", + clientSecret: validClientSecret, expectError: true, errorContains: "client ID is required", }, { name: "missing client secret", - workerEnvID: "auth-env-123", + workerEnvID: validWorkerEnvID, exportEnvID: validExportEnvID, region: "NA", - clientID: "client-123", + clientID: validClientID, clientSecret: "", expectError: true, errorContains: "client secret is required", }, { name: "invalid export environment ID format", - workerEnvID: "auth-env-123", + workerEnvID: validWorkerEnvID, exportEnvID: "not-a-uuid", region: "NA", - clientID: "client-123", - clientSecret: "secret-123", + clientID: validClientID, + clientSecret: validClientSecret, expectError: true, errorContains: "invalid export environment ID format", }, { name: "valid credentials", - workerEnvID: "auth-env-123", + workerEnvID: validWorkerEnvID, exportEnvID: validExportEnvID, region: "NA", - clientID: "client-123", - clientSecret: "secret-123", + clientID: validClientID, + clientSecret: validClientSecret, expectError: false, }, { name: "valid credentials AU region", - workerEnvID: "auth-env-123", + workerEnvID: validWorkerEnvID, exportEnvID: "00000000-0000-0000-0000-000000000002", region: "AU", - clientID: "client-123", - clientSecret: "secret-123", + clientID: validClientID, + clientSecret: validClientSecret, expectError: false, }, { name: "valid credentials SG region", - workerEnvID: "auth-env-123", + workerEnvID: validWorkerEnvID, exportEnvID: "00000000-0000-0000-0000-000000000003", region: "SG", - clientID: "client-123", - clientSecret: "secret-123", + clientID: validClientID, + clientSecret: validClientSecret, expectError: false, }, } From b27c625a21f5bd19f48ef26100af064d2d1852f1 Mon Sep 17 00:00:00 2001 From: samirgandhi19 <17574913+samir-gandhi@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:13:17 -0600 Subject: [PATCH 2/3] core: Generalize embed.go glob so new category directories need no edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The go:embed directive listed pingone/base and pingone/davinci explicitly, so every new resource category (sso, platform, mfa, ...) would require editing this file — violating the "new resource requires zero edits to existing files" invariant. A single pingone/*/*.yaml wildcard covers any category subdirectory automatically. --- definitions/embed.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/definitions/embed.go b/definitions/embed.go index 975ee4c..cf64ec5 100644 --- a/definitions/embed.go +++ b/definitions/embed.go @@ -4,8 +4,10 @@ package definitions import "embed" // FS contains all embedded resource definition YAML files. -// The embed directive includes all .yaml files under pingone subdirectories. -// Subdirectories (base/, davinci/, etc.) are organizational only. +// The embed directive includes all .yaml files under any pingone category +// subdirectory (base/, davinci/, sso/, etc.) — subdirectories are +// organizational only. New category directories do not require editing this +// file. // -//go:embed pingone/base/*.yaml pingone/davinci/*.yaml +//go:embed pingone/*/*.yaml var FS embed.FS From 56204e9e09d8469e7dbd9a37323d7d99879a2e2d Mon Sep 17 00:00:00 2001 From: samirgandhi19 <17574913+samir-gandhi@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:40:46 -0600 Subject: [PATCH 3/3] resource/pingone_application: Add export support Adds a schema-driven definition and Go handler for pingone_application, covering OIDC, SAML, External Link, and WS-Fed application types (the four the pingone_application Terraform resource schema actually supports). The management SDK's ReadOneApplication200Response is a discriminated union with 7 possible variants; a custom handler unwraps it via GetActualInstance() and projects the result into a flat struct with one nilable field per supported variant, letting the generic object/ nested_attributes engine emit exactly one *_options block per resource. The 3 built-in PingOne system application types (Admin Console, Portal, Self Service) have no corresponding schema block in the provider and are skipped with a warning (c.AddWarning) rather than exported incorrectly. Also bumps the hardcoded definition-count assertions in internal/schema/integration_test.go and the sorted resource-type list in internal/platform/pingone/dispatch_test.go, both of which track the total number of registered definitions/resources and needed updating for the new pingone_application entry. Co-Authored-By: Claude Sonnet 5 --- .changelog/pr-137.txt | 3 + contributing/RESOURCE_COVERAGE.md | 2 +- definitions/pingone/sso/application.yaml | 699 ++++++++++++++++++ internal/platform/pingone/dispatch_test.go | 1 + .../pingone/resource_sso_application.go | 642 ++++++++++++++++ .../pingone/resource_sso_application_test.go | 298 ++++++++ internal/schema/integration_test.go | 10 +- 7 files changed, 1649 insertions(+), 6 deletions(-) create mode 100644 .changelog/pr-137.txt create mode 100644 definitions/pingone/sso/application.yaml create mode 100644 internal/platform/pingone/resource_sso_application.go create mode 100644 internal/platform/pingone/resource_sso_application_test.go diff --git a/.changelog/pr-137.txt b/.changelog/pr-137.txt new file mode 100644 index 0000000..862ec10 --- /dev/null +++ b/.changelog/pr-137.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +`resource/pingone_application`: Added export support for OIDC, SAML, External Link, and WS-Fed applications. The three built-in PingOne system application types (Admin Console, Portal, Self Service) have no corresponding Terraform schema block in `pingone_application` and are skipped with a warning rather than exported incorrectly. +``` diff --git a/contributing/RESOURCE_COVERAGE.md b/contributing/RESOURCE_COVERAGE.md index d847a62..e0d38f7 100644 --- a/contributing/RESOURCE_COVERAGE.md +++ b/contributing/RESOURCE_COVERAGE.md @@ -56,7 +56,7 @@ Legend: ✅ Supported · ❌ Not yet supported | Resource | Supported | |---|---| -| `pingone_application` | ❌ | +| `pingone_application` | ✅ | | `pingone_application_attribute_mapping` | ❌ | | `pingone_application_flow_policy_assignment` | ❌ | | `pingone_application_resource` | ❌ | diff --git a/definitions/pingone/sso/application.yaml b/definitions/pingone/sso/application.yaml new file mode 100644 index 0000000..772dbdd --- /dev/null +++ b/definitions/pingone/sso/application.yaml @@ -0,0 +1,699 @@ +# PingOne Application (SSO) Resource Definition +# +# Scope: models the 4 application types the pingone_application Terraform +# resource schema actually supports — OIDC, SAML, External Link, WS-Fed. +# The 3 built-in PingOne system application types (Admin Console, Portal, +# Self Service) have no corresponding schema block in the provider and are +# skipped by the Go handler (internal/platform/pingone/resource_sso_application.go) +# with a warning rather than emitting an invalid resource block. +# +# The Go handler unwraps the management SDK's discriminated union +# (ReadOneApplication200Response) and projects the result into a flat +# applicationData struct with source_path field names used below. + +metadata: + platform: pingone + resource_type: pingone_application + api_type: Application + name: PingOne Application + short_name: application + version: "1.0" + +api: + sdk_package: github.com/patrickcping/pingone-go-sdk-v2/management + sdk_type: Application + list_method: ApplicationsApi.ReadAllApplications + get_method: ApplicationsApi.ReadOneApplication + id_field: id + name_field: name + pagination_type: cursor + +attributes: + # ID - computed only + - name: ID + terraform_name: id + type: string + source_path: Id + computed: true + + # Environment ID - required, references environment + - name: EnvironmentID + terraform_name: environment_id + type: string + source_path: EnvironmentId + required: true + references_type: pingone_environment + reference_field: id + + # Name - required string + - name: Name + terraform_name: name + type: string + source_path: Name + required: true + + - name: Description + terraform_name: description + type: string + source_path: Description + + - name: Enabled + terraform_name: enabled + type: bool + source_path: Enabled + + - name: HiddenFromAppPortal + terraform_name: hidden_from_app_portal + type: bool + source_path: HiddenFromAppPortal + + - name: LoginPageUrl + terraform_name: login_page_url + type: string + source_path: LoginPageUrl + + # tags - only meaningful for OIDC applications; conflicts with the other + # 3 options blocks per the provider schema. + - name: Tags + terraform_name: tags + type: set + source_path: Tags + + - name: AccessControlRoleType + terraform_name: access_control_role_type + type: string + source_path: AccessControlRoleType + + - name: AccessControlGroupOptions + terraform_name: access_control_group_options + type: object + source_path: AccessControlGroupOptions + nested_attributes: + - name: Type + terraform_name: type + type: string + source_path: Type + required: true + - name: Groups + terraform_name: groups + type: set + source_path: Groups + required: true + + - name: Icon + terraform_name: icon + type: object + source_path: Icon + nested_attributes: + - name: Id + terraform_name: id + type: string + source_path: Id + required: true + - name: Href + terraform_name: href + type: string + source_path: Href + required: true + + # ── oidc_options ────────────────────────────────────────────────── + - name: OIDC + terraform_name: oidc_options + type: object + source_path: OIDC + nested_attributes: + - name: Type + terraform_name: type + type: string + source_path: Type + required: true + + - name: GrantTypes + terraform_name: grant_types + type: set + source_path: GrantTypes + required: true + + - name: ResponseTypes + terraform_name: response_types + type: set + source_path: ResponseTypes + + - name: TokenEndpointAuthMethod + terraform_name: token_endpoint_auth_method + type: string + source_path: TokenEndpointAuthMethod + required: true + + - name: ClientId + terraform_name: client_id + type: string + source_path: ClientId + computed: true + + - name: HomePageUrl + terraform_name: home_page_url + type: string + source_path: HomePageUrl + + - name: AdditionalRefreshTokenReplayProtectionEnabled + terraform_name: additional_refresh_token_replay_protection_enabled + type: bool + source_path: AdditionalRefreshTokenReplayProtectionEnabled + + - name: AllowWildcardInRedirectUris + terraform_name: allow_wildcard_in_redirect_uris + type: bool + source_path: AllowWildcardInRedirectUris + + - name: CorsSettings + terraform_name: cors_settings + type: object + source_path: CorsSettings + nested_attributes: + - name: Behavior + terraform_name: behavior + type: string + source_path: Behavior + required: true + - name: Origins + terraform_name: origins + type: set + source_path: Origins + + - name: DeviceCustomVerificationUri + terraform_name: device_custom_verification_uri + type: string + source_path: DeviceCustomVerificationUri + + - name: DevicePathId + terraform_name: device_path_id + type: string + source_path: DevicePathId + + - name: DevicePollingInterval + terraform_name: device_polling_interval + type: number + source_path: DevicePollingInterval + + - name: DeviceTimeout + terraform_name: device_timeout + type: number + source_path: DeviceTimeout + + - name: IdpSignoff + terraform_name: idp_signoff + type: bool + source_path: IdpSignoff + + - name: IncludeX5t + terraform_name: include_x5t + type: bool + source_path: IncludeX5t + + - name: InitiateLoginUri + terraform_name: initiate_login_uri + type: string + source_path: InitiateLoginUri + + - name: Jwks + terraform_name: jwks + type: string + source_path: Jwks + + - name: JwksUrl + terraform_name: jwks_url + type: string + source_path: JwksUrl + + - name: MobileApp + terraform_name: mobile_app + type: object + source_path: MobileApp + nested_attributes: + - name: BundleId + terraform_name: bundle_id + type: string + source_path: BundleId + - name: PackageName + terraform_name: package_name + type: string + source_path: PackageName + - name: HuaweiAppId + terraform_name: huawei_app_id + type: string + source_path: HuaweiAppId + - name: HuaweiPackageName + terraform_name: huawei_package_name + type: string + source_path: HuaweiPackageName + - name: PasscodeGracePeriod + terraform_name: passcode_grace_period + type: number + source_path: PasscodeGracePeriod + - name: PasscodeRefreshSeconds + terraform_name: passcode_refresh_seconds + type: number + source_path: PasscodeRefreshSeconds + - name: UniversalAppLink + terraform_name: universal_app_link + type: string + source_path: UniversalAppLink + - name: IntegrityDetection + terraform_name: integrity_detection + type: object + source_path: IntegrityDetection + nested_attributes: + - name: Enabled + terraform_name: enabled + type: bool + source_path: Enabled + - name: ExcludedPlatforms + terraform_name: excluded_platforms + type: set + source_path: ExcludedPlatforms + - name: CacheDuration + terraform_name: cache_duration + type: object + source_path: CacheDuration + nested_attributes: + - name: Amount + terraform_name: amount + type: number + source_path: Amount + required: true + - name: Units + terraform_name: units + type: string + source_path: Units + - name: GooglePlay + terraform_name: google_play + type: object + source_path: GooglePlay + nested_attributes: + - name: VerificationType + terraform_name: verification_type + type: string + source_path: VerificationType + required: true + - name: DecryptionKey + terraform_name: decryption_key + type: string + source_path: DecryptionKey + sensitive: true + - name: VerificationKey + terraform_name: verification_key + type: string + source_path: VerificationKey + sensitive: true + - name: ServiceAccountCredentialsJson + terraform_name: service_account_credentials_json + type: string + source_path: ServiceAccountCredentialsJson + sensitive: true + + - name: OpSessionCheckEnabled + terraform_name: op_session_check_enabled + type: bool + source_path: OpSessionCheckEnabled + + - name: ParRequirement + terraform_name: par_requirement + type: string + source_path: ParRequirement + + - name: ParTimeout + terraform_name: par_timeout + type: number + source_path: ParTimeout + + - name: PkceEnforcement + terraform_name: pkce_enforcement + type: string + source_path: PkceEnforcement + + - name: PostLogoutRedirectUris + terraform_name: post_logout_redirect_uris + type: set + source_path: PostLogoutRedirectUris + + - name: RedirectUris + terraform_name: redirect_uris + type: set + source_path: RedirectUris + + - name: RefreshTokenDuration + terraform_name: refresh_token_duration + type: number + source_path: RefreshTokenDuration + + - name: RefreshTokenRollingDuration + terraform_name: refresh_token_rolling_duration + type: number + source_path: RefreshTokenRollingDuration + + - name: RefreshTokenRollingGracePeriodDuration + terraform_name: refresh_token_rolling_grace_period_duration + type: number + source_path: RefreshTokenRollingGracePeriodDuration + + - name: RefreshTokenType + terraform_name: refresh_token_type + type: string + source_path: RefreshTokenType + + - name: RequestScopesForMultipleResourcesEnabled + terraform_name: request_scopes_for_multiple_resources_enabled + type: bool + source_path: RequestScopesForMultipleResourcesEnabled + + - name: RequireSignedRequestObject + terraform_name: require_signed_request_object + type: bool + source_path: RequireSignedRequestObject + + - name: Signing + terraform_name: signing + type: object + source_path: Signing + nested_attributes: + - name: KeyRotationPolicyId + terraform_name: key_rotation_policy_id + type: string + source_path: KeyRotationPolicyId + references_type: pingone_key_rotation_policy + reference_field: id + + - name: SupportUnsignedRequestObject + terraform_name: support_unsigned_request_object + type: bool + source_path: SupportUnsignedRequestObject + + - name: TargetLinkUri + terraform_name: target_link_uri + type: string + source_path: TargetLinkUri + + # ── saml_options ────────────────────────────────────────────────── + - name: SAML + terraform_name: saml_options + type: object + source_path: SAML + nested_attributes: + - name: AcsUrls + terraform_name: acs_urls + type: set + source_path: AcsUrls + required: true + + - name: AssertionDuration + terraform_name: assertion_duration + type: number + source_path: AssertionDuration + required: true + + - name: SpEntityId + terraform_name: sp_entity_id + type: string + source_path: SpEntityId + required: true + + - name: Type + terraform_name: type + type: string + source_path: Type + + - name: AssertionSignedEnabled + terraform_name: assertion_signed_enabled + type: bool + source_path: AssertionSignedEnabled + + - name: CorsSettings + terraform_name: cors_settings + type: object + source_path: CorsSettings + nested_attributes: + - name: Behavior + terraform_name: behavior + type: string + source_path: Behavior + required: true + - name: Origins + terraform_name: origins + type: set + source_path: Origins + + - name: DefaultTargetUrl + terraform_name: default_target_url + type: string + source_path: DefaultTargetUrl + + - name: EnableRequestedAuthnContext + terraform_name: enable_requested_authn_context + type: bool + source_path: EnableRequestedAuthnContext + + - name: HomePageUrl + terraform_name: home_page_url + type: string + source_path: HomePageUrl + + - name: IdpSigningKey + terraform_name: idp_signing_key + type: object + source_path: IdpSigningKey + nested_attributes: + - name: KeyId + terraform_name: key_id + type: string + source_path: KeyId + required: true + references_type: pingone_key + reference_field: id + - name: Algorithm + terraform_name: algorithm + type: string + source_path: Algorithm + + - name: NameIdFormat + terraform_name: nameid_format + type: string + source_path: NameIdFormat + + - name: ResponseIsSigned + terraform_name: response_is_signed + type: bool + source_path: ResponseIsSigned + + - name: SessionNotOnOrAfterDuration + terraform_name: session_not_on_or_after_duration + type: number + source_path: SessionNotOnOrAfterDuration + + - name: SloBinding + terraform_name: slo_binding + type: string + source_path: SloBinding + + - name: SloEndpoint + terraform_name: slo_endpoint + type: string + source_path: SloEndpoint + + - name: SloResponseEndpoint + terraform_name: slo_response_endpoint + type: string + source_path: SloResponseEndpoint + + - name: SloWindow + terraform_name: slo_window + type: number + source_path: SloWindow + + - name: SpEncryption + terraform_name: sp_encryption + type: object + source_path: SpEncryption + nested_attributes: + - name: Algorithm + terraform_name: algorithm + type: string + source_path: Algorithm + required: true + - name: Certificate + terraform_name: certificate + type: object + source_path: Certificate + required: true + nested_attributes: + - name: Id + terraform_name: id + type: string + source_path: Id + required: true + + - name: SpVerification + terraform_name: sp_verification + type: object + source_path: SpVerification + nested_attributes: + - name: CertificateIds + terraform_name: certificate_ids + type: set + source_path: CertificateIds + required: true + - name: AuthnRequestSigned + terraform_name: authn_request_signed + type: bool + source_path: AuthnRequestSigned + + - name: VirtualServerIdSettings + terraform_name: virtual_server_id_settings + type: object + source_path: VirtualServerIdSettings + nested_attributes: + - name: Enabled + terraform_name: enabled + type: bool + source_path: Enabled + - name: VirtualServerIds + terraform_name: virtual_server_ids + type: list + source_path: VirtualServerIds + nested_attributes: + - name: VsId + terraform_name: vs_id + type: string + source_path: VsId + required: true + - name: Default + terraform_name: default + type: bool + source_path: Default + + # ── external_link_options ───────────────────────────────────────── + - name: ExternalLink + terraform_name: external_link_options + type: object + source_path: ExternalLink + nested_attributes: + - name: HomePageUrl + terraform_name: home_page_url + type: string + source_path: HomePageUrl + required: true + + # ── wsfed_options ───────────────────────────────────────────────── + - name: WSFED + terraform_name: wsfed_options + type: object + source_path: WSFED + nested_attributes: + - name: DomainName + terraform_name: domain_name + type: string + source_path: DomainName + required: true + + - name: ReplyUrl + terraform_name: reply_url + type: string + source_path: ReplyUrl + required: true + + - name: Type + terraform_name: type + type: string + source_path: Type + required: true + + - name: IdpSigningKey + terraform_name: idp_signing_key + type: object + source_path: IdpSigningKey + nested_attributes: + - name: KeyId + terraform_name: key_id + type: string + source_path: KeyId + required: true + references_type: pingone_key + reference_field: id + - name: Algorithm + terraform_name: algorithm + type: string + source_path: Algorithm + required: true + + - name: AudienceRestriction + terraform_name: audience_restriction + type: string + source_path: AudienceRestriction + + - name: CorsSettings + terraform_name: cors_settings + type: object + source_path: CorsSettings + nested_attributes: + - name: Behavior + terraform_name: behavior + type: string + source_path: Behavior + required: true + - name: Origins + terraform_name: origins + type: set + source_path: Origins + + - name: SloEndpoint + terraform_name: slo_endpoint + type: string + source_path: SloEndpoint + + - name: SubjectNameIdentifierFormat + terraform_name: subject_name_identifier_format + type: string + source_path: SubjectNameIdentifierFormat + + - name: Kerberos + terraform_name: kerberos + type: object + source_path: Kerberos + nested_attributes: + - name: Gateways + terraform_name: gateways + type: set + source_path: Gateways + nested_attributes: + - name: Id + terraform_name: id + type: string + source_path: Id + required: true + references_type: pingone_gateway + reference_field: id + - name: Type + terraform_name: type + type: string + source_path: Type + - name: UserType + terraform_name: user_type + type: object + source_path: UserType + nested_attributes: + - name: Id + terraform_name: id + type: string + source_path: Id + +dependencies: + depends_on: + - resource_type: pingone_environment + field_path: EnvironmentId + lookup_field: id + reference_format: "var.pingone_environment_id" + + import_id_format: "{env_id}/{resource_id}" + +variables: + eligible_attributes: [] diff --git a/internal/platform/pingone/dispatch_test.go b/internal/platform/pingone/dispatch_test.go index 8bdf6f5..bf094fe 100644 --- a/internal/platform/pingone/dispatch_test.go +++ b/internal/platform/pingone/dispatch_test.go @@ -13,6 +13,7 @@ import ( func TestSupportedResourceTypes(t *testing.T) { expected := []string{ + "pingone_application", "pingone_davinci_application", "pingone_davinci_application_flow_policy", "pingone_davinci_connector_instance", diff --git a/internal/platform/pingone/resource_sso_application.go b/internal/platform/pingone/resource_sso_application.go new file mode 100644 index 0000000..b04f7c8 --- /dev/null +++ b/internal/platform/pingone/resource_sso_application.go @@ -0,0 +1,642 @@ +package pingone + +import ( + "context" + "fmt" + + "github.com/patrickcping/pingone-go-sdk-v2/management" +) + +// pingone_application is a genuinely complex resource: the management SDK's +// ReadOneApplication200Response (and the EntityArrayEmbedded.Applications list +// item type) is a discriminated union with SEVEN possible variants +// (ApplicationOIDC, ApplicationSAML, ApplicationExternalLink, ApplicationWSFED, +// ApplicationPingOneAdminConsole, ApplicationPingOnePortal, +// ApplicationPingOneSelfService) — exactly one field is non-nil per actual +// application, unwrapped via GetActualInstance(). +// +// Scope decision (see issue #67 and the PR description for full rationale): +// the pingone_application Terraform resource's schema only supports FOUR +// declarable application types — oidc_options, saml_options, +// external_link_options, wsfed_options (confirmed via the provider docs: +// https://registry.terraform.io/providers/pingidentity/pingone/latest/docs/resources/application). +// ApplicationPingOneAdminConsole, ApplicationPingOnePortal, and +// ApplicationPingOneSelfService are built-in PingOne system applications +// that are provisioned automatically per environment and have no +// corresponding nested schema block in the provider — they cannot be +// declaratively created or imported via pingone_application. Rather than +// silently emitting a resource block with no application-type-specific +// attributes (which the provider would then reject with "exactly one of +// ... must be defined"), this handler skips them and records a warning via +// c.AddWarning so the omission is visible to the operator, not silent. +// +// This handler performs the union-unwrapping and projects the result into a +// flat applicationData struct with one nilable pointer per supported variant +// (OIDC, SAML, ExternalLink, WSFED). The YAML definition then models each +// variant as a plain "object" attribute with nested_attributes — no +// type_discriminated_block is needed because type_discriminated_block (as +// implemented in internal/core) only maps a runtime value to a single +// primitive/JSON-encoded key, not to a fully-typed nested attribute tree. +// Because exactly one variant pointer is non-nil, the generic engine's +// existing "object without nested_attributes populated" skip (nil map field) +// naturally emits only the one matching options block. +func init() { + registerResource("pingone_application", resourceHandler{ + list: listSSOApplications, + get: getSSOApplication, + }) +} + +// applicationData is the projection handed to the processor. Field names +// match the source_path values in definitions/pingone/sso/application.yaml. +type applicationData struct { + Id string + EnvironmentId string + Name string + Description *string + Enabled bool + HiddenFromAppPortal *bool + LoginPageUrl *string + Tags []string + AccessControlGroupOptions *accessControlGroupOptionsData + AccessControlRoleType *string + Icon *applicationIconData + + OIDC *oidcOptionsData + SAML *samlOptionsData + ExternalLink *externalLinkOptionsData + WSFED *wsfedOptionsData +} + +type applicationIconData struct { + Id string + Href string +} + +type accessControlGroupOptionsData struct { + Type string + Groups []string +} + +type corsSettingsData struct { + Behavior string + Origins []string +} + +type oidcOptionsData struct { + Type string + GrantTypes []string + ResponseTypes []string + TokenEndpointAuthMethod string + ClientId *string + HomePageUrl *string + AdditionalRefreshTokenReplayProtectionEnabled *bool + AllowWildcardInRedirectUris *bool + CorsSettings *corsSettingsData + DeviceCustomVerificationUri *string + DevicePathId *string + DevicePollingInterval *int32 + DeviceTimeout *int32 + IdpSignoff *bool + IncludeX5t *bool + InitiateLoginUri *string + Jwks *string + JwksUrl *string + MobileApp *mobileAppData + OpSessionCheckEnabled *bool + ParRequirement *string + ParTimeout *int32 + PkceEnforcement *string + PostLogoutRedirectUris []string + RedirectUris []string + RefreshTokenDuration *int32 + RefreshTokenRollingDuration *int32 + RefreshTokenRollingGracePeriodDuration *int32 + RefreshTokenType *string + RequestScopesForMultipleResourcesEnabled *bool + RequireSignedRequestObject *bool + Signing *signingData + SupportUnsignedRequestObject *bool + TargetLinkUri *string +} + +type mobileAppData struct { + BundleId *string + PackageName *string + HuaweiAppId *string + HuaweiPackageName *string + PasscodeGracePeriod *int32 + PasscodeRefreshSeconds *int32 + UniversalAppLink *string + IntegrityDetection *integrityDetectionData +} + +type integrityDetectionData struct { + Enabled *bool + ExcludedPlatforms []string + CacheDuration *cacheDurationData + GooglePlay *googlePlayData +} + +type cacheDurationData struct { + Amount *int32 + Units *string +} + +type googlePlayData struct { + VerificationType *string + DecryptionKey *string + VerificationKey *string + ServiceAccountCredentialsJson *string +} + +type signingData struct { + KeyRotationPolicyId *string +} + +type samlOptionsData struct { + Type *string + AcsUrls []string + AssertionDuration int32 + AssertionSignedEnabled *bool + CorsSettings *corsSettingsData + DefaultTargetUrl *string + EnableRequestedAuthnContext *bool + HomePageUrl *string + IdpSigningKey *idpSigningKeyData + NameIdFormat *string + ResponseIsSigned *bool + SessionNotOnOrAfterDuration *int32 + SloBinding *string + SloEndpoint *string + SloResponseEndpoint *string + SloWindow *int32 + SpEncryption *spEncryptionData + SpEntityId string + SpVerification *spVerificationData + VirtualServerIdSettings *virtualServerIdSettingsData +} + +type idpSigningKeyData struct { + KeyId string + Algorithm *string +} + +type spEncryptionData struct { + Algorithm string + Certificate certificateRefData +} + +type certificateRefData struct { + Id string +} + +type spVerificationData struct { + CertificateIds []string + AuthnRequestSigned *bool +} + +type virtualServerIdSettingsData struct { + Enabled *bool + VirtualServerIds []virtualServerIdEntryData +} + +type virtualServerIdEntryData struct { + VsId string + Default *bool +} + +type externalLinkOptionsData struct { + HomePageUrl string +} + +type wsfedOptionsData struct { + Type string + DomainName string + ReplyUrl string + IdpSigningKey idpSigningKeyData + AudienceRestriction *string + CorsSettings *corsSettingsData + SloEndpoint *string + SubjectNameIdentifierFormat *string + Kerberos *wsfedKerberosData +} + +type wsfedKerberosData struct { + Gateways []wsfedKerberosGatewayData +} + +type wsfedKerberosGatewayData struct { + Id string + Type *string + UserType userTypeRefData +} + +type userTypeRefData struct { + Id *string +} + +// toApplicationData unwraps the discriminated union and projects the actual +// instance into applicationData. Returns (nil, false) for the three PingOne +// system application types that have no corresponding Terraform schema block +// (see the doc comment above) — callers should skip these with a warning +// rather than emit a resource block the provider will reject. +func toApplicationData(actual interface{}) (*applicationData, bool) { + switch v := actual.(type) { + case *management.ApplicationOIDC: + return fromOIDC(v), true + case *management.ApplicationSAML: + return fromSAML(v), true + case *management.ApplicationExternalLink: + return fromExternalLink(v), true + case *management.ApplicationWSFED: + return fromWSFED(v), true + default: + // ApplicationPingOneAdminConsole, ApplicationPingOnePortal, + // ApplicationPingOneSelfService, or nil (unrecognized/empty union). + return nil, false + } +} + +func commonFromBase(id, envId, name string, description *string, enabled bool, hiddenFromAppPortal *bool, loginPageUrl *string, tags []management.EnumApplicationTags, ac *management.ApplicationAccessControl, icon *management.ApplicationIcon) applicationData { + data := applicationData{ + Id: id, + EnvironmentId: envId, + Name: name, + Description: description, + Enabled: enabled, + HiddenFromAppPortal: hiddenFromAppPortal, + LoginPageUrl: loginPageUrl, + } + + if len(tags) > 0 { + data.Tags = make([]string, 0, len(tags)) + for _, t := range tags { + data.Tags = append(data.Tags, string(t)) + } + } + + if ac != nil { + if role, ok := ac.GetRoleOk(); ok && role != nil { + roleType := string(role.GetType()) + data.AccessControlRoleType = &roleType + } + if group, ok := ac.GetGroupOk(); ok && group != nil { + groups := group.GetGroups() + ids := make([]string, 0, len(groups)) + for _, g := range groups { + ids = append(ids, g.GetId()) + } + groupType := string(group.GetType()) + data.AccessControlGroupOptions = &accessControlGroupOptionsData{ + Type: groupType, + Groups: ids, + } + } + } + + if icon != nil { + data.Icon = &applicationIconData{Id: icon.GetId(), Href: icon.GetHref()} + } + + return data +} + +func corsFromSDK(c *management.ApplicationCorsSettings) *corsSettingsData { + if c == nil { + return nil + } + return &corsSettingsData{ + Behavior: string(c.GetBehavior()), + Origins: c.GetOrigins(), + } +} + +func fromOIDC(v *management.ApplicationOIDC) *applicationData { + envId := "" + if env, ok := v.GetEnvironmentOk(); ok && env != nil { + envId = env.GetId() + } + data := commonFromBase(v.GetId(), envId, v.GetName(), v.Description, v.GetEnabled(), v.HiddenFromAppPortal, v.LoginPageUrl, v.Tags, v.AccessControl, v.Icon) + + // Build as nil (not empty non-nil) when the source is empty, so the + // processor's isEmptyValue check (which only treats nil slices as + // "not set") correctly omits the attribute instead of emitting an + // empty [] that would produce a perpetual plan diff. + var grantTypes []string + for _, g := range v.GrantTypes { + grantTypes = append(grantTypes, string(g)) + } + var responseTypes []string + for _, r := range v.ResponseTypes { + responseTypes = append(responseTypes, string(r)) + } + + oidc := &oidcOptionsData{ + Type: string(v.Type), + GrantTypes: grantTypes, + ResponseTypes: responseTypes, + TokenEndpointAuthMethod: string(v.TokenEndpointAuthMethod), + ClientId: v.ClientId, + HomePageUrl: v.HomePageUrl, + AdditionalRefreshTokenReplayProtectionEnabled: v.AdditionalRefreshTokenReplayProtectionEnabled, + AllowWildcardInRedirectUris: v.AllowWildcardInRedirectUris, + CorsSettings: corsFromSDK(v.CorsSettings), + DeviceCustomVerificationUri: v.DeviceCustomVerificationUri, + DevicePathId: v.DevicePathId, + DevicePollingInterval: v.DevicePollingInterval, + DeviceTimeout: v.DeviceTimeout, + IdpSignoff: v.IdpSignoff, + IncludeX5t: v.IncludeX5t, + InitiateLoginUri: v.InitiateLoginUri, + Jwks: v.Jwks, + JwksUrl: v.JwksUrl, + OpSessionCheckEnabled: v.OpSessionCheckEnabled, + PostLogoutRedirectUris: v.PostLogoutRedirectUris, + RedirectUris: v.RedirectUris, + RefreshTokenDuration: v.RefreshTokenDuration, + RefreshTokenRollingDuration: v.RefreshTokenRollingDuration, + RefreshTokenRollingGracePeriodDuration: v.RefreshTokenRollingGracePeriodDuration, + RequestScopesForMultipleResourcesEnabled: v.RequestScopesForMultipleResourcesEnabled, + RequireSignedRequestObject: v.RequireSignedRequestObject, + SupportUnsignedRequestObject: v.SupportUnsignedRequestObject, + TargetLinkUri: v.TargetLinkUri, + ParTimeout: v.ParTimeout, + } + + if v.ParRequirement != nil { + s := string(*v.ParRequirement) + oidc.ParRequirement = &s + } + if v.PkceEnforcement != nil { + s := string(*v.PkceEnforcement) + oidc.PkceEnforcement = &s + } + if v.RefreshTokenType != nil { + s := string(*v.RefreshTokenType) + oidc.RefreshTokenType = &s + } + + if v.Mobile != nil { + m := v.Mobile + mobile := &mobileAppData{ + BundleId: m.BundleId, + PackageName: m.PackageName, + HuaweiAppId: m.HuaweiAppId, + HuaweiPackageName: m.HuaweiPackageName, + UniversalAppLink: m.UriPrefix, + } + if m.PasscodeGracePeriod != nil { + mobile.PasscodeGracePeriod = m.PasscodeGracePeriod + } + if m.PasscodeRefreshDuration != nil { + mobile.PasscodeRefreshSeconds = &m.PasscodeRefreshDuration.Duration + } + if id := m.IntegrityDetection; id != nil { + detect := &integrityDetectionData{} + if id.Mode != nil { + enabled := *id.Mode == management.ENUMENABLEDSTATUS_ENABLED + detect.Enabled = &enabled + } + for _, p := range id.ExcludedPlatforms { + detect.ExcludedPlatforms = append(detect.ExcludedPlatforms, string(p)) + } + if id.CacheDuration != nil { + cd := &cacheDurationData{Amount: id.CacheDuration.Amount} + if id.CacheDuration.Units != nil { + u := string(*id.CacheDuration.Units) + cd.Units = &u + } + detect.CacheDuration = cd + } + if id.GooglePlay != nil { + gp := &googlePlayData{ + DecryptionKey: id.GooglePlay.DecryptionKey, + VerificationKey: id.GooglePlay.VerificationKey, + ServiceAccountCredentialsJson: id.GooglePlay.ServiceAccountCredentials, + } + if id.GooglePlay.VerificationType != nil { + vt := string(*id.GooglePlay.VerificationType) + gp.VerificationType = &vt + } + detect.GooglePlay = gp + } + mobile.IntegrityDetection = detect + } + oidc.MobileApp = mobile + } + + if v.Signing != nil { + id := v.Signing.KeyRotationPolicy.Id + oidc.Signing = &signingData{KeyRotationPolicyId: &id} + } + + data.OIDC = oidc + return &data +} + +func fromSAML(v *management.ApplicationSAML) *applicationData { + envId := "" + if env, ok := v.GetEnvironmentOk(); ok && env != nil { + envId = env.GetId() + } + data := commonFromBase(v.GetId(), envId, v.GetName(), v.Description, v.GetEnabled(), v.HiddenFromAppPortal, v.LoginPageUrl, nil, v.AccessControl, v.Icon) + + saml := &samlOptionsData{ + AcsUrls: v.AcsUrls, + AssertionDuration: v.AssertionDuration, + AssertionSignedEnabled: v.AssertionSigned, + CorsSettings: corsFromSDK(v.CorsSettings), + DefaultTargetUrl: v.DefaultTargetUrl, + EnableRequestedAuthnContext: v.EnableRequestedAuthnContext, + HomePageUrl: v.HomePageUrl, + NameIdFormat: v.NameIdFormat, + ResponseIsSigned: v.ResponseSigned, + SessionNotOnOrAfterDuration: v.SessionNotOnOrAfterDuration, + SloEndpoint: v.SloEndpoint, + SloResponseEndpoint: v.SloResponseEndpoint, + SloWindow: v.SloWindow, + SpEntityId: v.SpEntityId, + } + + if v.SloBinding != nil { + s := string(*v.SloBinding) + saml.SloBinding = &s + } + + typ := string(v.Type) + saml.Type = &typ + + if v.IdpSigning != nil { + key := &idpSigningKeyData{KeyId: v.IdpSigning.Key.Id} + if v.IdpSigning.Algorithm != nil { + a := string(*v.IdpSigning.Algorithm) + key.Algorithm = &a + } + saml.IdpSigningKey = key + } + + if v.SpEncryption != nil { + saml.SpEncryption = &spEncryptionData{ + Algorithm: string(v.SpEncryption.Algorithm), + Certificate: certificateRefData{Id: v.SpEncryption.Certificate.Id}, + } + } + + if v.SpVerification != nil { + ids := make([]string, 0, len(v.SpVerification.Certificates)) + for _, c := range v.SpVerification.Certificates { + ids = append(ids, c.Id) + } + saml.SpVerification = &spVerificationData{ + CertificateIds: ids, + AuthnRequestSigned: v.SpVerification.AuthnRequestSigned, + } + } + + if v.VirtualServerIdSettings != nil { + vs := v.VirtualServerIdSettings + entries := make([]virtualServerIdEntryData, 0, len(vs.VirtualServerIds)) + for _, e := range vs.VirtualServerIds { + entries = append(entries, virtualServerIdEntryData{VsId: e.VsId, Default: e.Default}) + } + saml.VirtualServerIdSettings = &virtualServerIdSettingsData{ + Enabled: vs.Enabled, + VirtualServerIds: entries, + } + } + + data.SAML = saml + return &data +} + +func fromExternalLink(v *management.ApplicationExternalLink) *applicationData { + envId := "" + if env, ok := v.GetEnvironmentOk(); ok && env != nil { + envId = env.GetId() + } + data := commonFromBase(v.GetId(), envId, v.GetName(), v.Description, v.GetEnabled(), v.HiddenFromAppPortal, v.LoginPageUrl, nil, v.AccessControl, v.Icon) + data.ExternalLink = &externalLinkOptionsData{HomePageUrl: v.HomePageUrl} + return &data +} + +func fromWSFED(v *management.ApplicationWSFED) *applicationData { + envId := "" + if env, ok := v.GetEnvironmentOk(); ok && env != nil { + envId = env.GetId() + } + data := commonFromBase(v.GetId(), envId, v.GetName(), v.Description, v.GetEnabled(), v.HiddenFromAppPortal, v.LoginPageUrl, nil, v.AccessControl, v.Icon) + + wsfed := &wsfedOptionsData{ + Type: string(v.Type), + DomainName: v.DomainName, + ReplyUrl: v.ReplyUrl, + IdpSigningKey: idpSigningKeyData{KeyId: v.IdpSigning.Key.Id, Algorithm: strPtr(string(v.IdpSigning.Algorithm))}, + AudienceRestriction: v.AudienceRestriction, + CorsSettings: corsFromSDK(v.CorsSettings), + SloEndpoint: v.SloEndpoint, + } + + if v.SubjectNameIdentifierFormat != nil { + s := string(*v.SubjectNameIdentifierFormat) + wsfed.SubjectNameIdentifierFormat = &s + } + + if v.Kerberos != nil { + gateways := make([]wsfedKerberosGatewayData, 0, len(v.Kerberos.Gateways)) + for _, g := range v.Kerberos.Gateways { + entry := wsfedKerberosGatewayData{Id: g.Id} + typ := string(g.Type) + entry.Type = &typ + if id, ok := g.UserType.GetIdOk(); ok && id != nil { + entry.UserType = userTypeRefData{Id: id} + } + gateways = append(gateways, entry) + } + wsfed.Kerberos = &wsfedKerberosData{Gateways: gateways} + } + + data.WSFED = wsfed + return &data +} + +func strPtr(s string) *string { return &s } + +// listSSOApplications lists all pingone_application resources in the target +// environment, skipping the three built-in PingOne system application types +// (Admin Console, Portal, Self Service) that have no Terraform schema +// representation — see the doc comment above toApplicationData. +func listSSOApplications(ctx context.Context, c *Client, _ string) ([]interface{}, error) { + mgmt, err := c.management(ctx) + if err != nil { + return nil, err + } + + var result []interface{} + iterator := mgmt.ApplicationsApi.ReadAllApplications(ctx, c.environmentID.String()).Execute() + for cursor, err := range iterator { + if err != nil { + return nil, fmt.Errorf("list applications: %w", err) + } + embedded, ok := cursor.EntityArray.GetEmbeddedOk() + if !ok || embedded == nil { + continue + } + apps, ok := embedded.GetApplicationsOk() + if !ok { + continue + } + for i := range apps { + actual := apps[i].GetActualInstance() + if actual == nil { + continue + } + data, ok := toApplicationData(actual) + if !ok { + c.AddWarning(fmt.Sprintf("skipping application %s: PingOne system application types "+ + "(admin console, portal, self service) are not exportable via pingone_application "+ + "(no corresponding Terraform schema block)", describeSkippedApplication(actual))) + continue + } + result = append(result, data) + } + } + return result, nil +} + +// getSSOApplication fetches a single pingone_application by ID. +func getSSOApplication(ctx context.Context, c *Client, _ string, resourceID string) (interface{}, error) { + mgmt, err := c.management(ctx) + if err != nil { + return nil, err + } + resp, _, err := mgmt.ApplicationsApi.ReadOneApplication(ctx, c.environmentID.String(), resourceID).Execute() + if err != nil { + return nil, fmt.Errorf("get application: %w", err) + } + actual := resp.GetActualInstance() + if actual == nil { + return nil, fmt.Errorf("get application %s: empty response", resourceID) + } + data, ok := toApplicationData(actual) + if !ok { + return nil, fmt.Errorf("get application %s: PingOne system application types are not exportable via pingone_application", resourceID) + } + return data, nil +} + +// describeSkippedApplication returns a best-effort identifier for a skipped +// system application, for inclusion in the warning message. +func describeSkippedApplication(actual interface{}) string { + switch v := actual.(type) { + case *management.ApplicationPingOneAdminConsole: + _ = v + return "(PingOne Admin Console)" + case *management.ApplicationPingOnePortal: + return v.GetId() + case *management.ApplicationPingOneSelfService: + return v.GetId() + default: + return "(unknown)" + } +} diff --git a/internal/platform/pingone/resource_sso_application_test.go b/internal/platform/pingone/resource_sso_application_test.go new file mode 100644 index 0000000..e6e2944 --- /dev/null +++ b/internal/platform/pingone/resource_sso_application_test.go @@ -0,0 +1,298 @@ +package pingone + +import ( + "testing" + + "github.com/patrickcping/pingone-go-sdk-v2/management" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ── toApplicationData: union unwrapping ───────────────────────────── + +func TestToApplicationData_OIDC(t *testing.T) { + envId := "env-123" + oidc := management.NewApplicationOIDC(true, "My Web App", management.ENUMAPPLICATIONPROTOCOL_OPENID_CONNECT, management.ENUMAPPLICATIONTYPE_WEB_APP, management.ENUMAPPLICATIONOIDCTOKENAUTHMETHOD_CLIENT_SECRET_BASIC) + oidc.SetId("app-1") + oidc.SetEnvironment(management.ObjectEnvironment{Id: &envId}) + oidc.GrantTypes = []management.EnumApplicationOIDCGrantType{management.ENUMAPPLICATIONOIDCGRANTTYPE_AUTHORIZATION_CODE} + oidc.RedirectUris = []string{"https://example.com/callback"} + + data, ok := toApplicationData(oidc) + require.True(t, ok) + require.NotNil(t, data) + + assert.Equal(t, "app-1", data.Id) + assert.Equal(t, envId, data.EnvironmentId) + assert.Equal(t, "My Web App", data.Name) + assert.True(t, data.Enabled) + require.NotNil(t, data.OIDC) + assert.Equal(t, "WEB_APP", data.OIDC.Type) + assert.Equal(t, []string{"AUTHORIZATION_CODE"}, data.OIDC.GrantTypes) + assert.Equal(t, []string{"https://example.com/callback"}, data.OIDC.RedirectUris) + assert.Equal(t, "CLIENT_SECRET_BASIC", data.OIDC.TokenEndpointAuthMethod) + + // Only OIDC should be populated; the other 3 variants must stay nil so + // the formatter emits exactly one *_options block. + assert.Nil(t, data.SAML) + assert.Nil(t, data.ExternalLink) + assert.Nil(t, data.WSFED) +} + +func TestToApplicationData_OIDC_MobileAppAndSigning(t *testing.T) { + oidc := management.NewApplicationOIDC(true, "Native App", management.ENUMAPPLICATIONPROTOCOL_OPENID_CONNECT, management.ENUMAPPLICATIONTYPE_NATIVE_APP, management.ENUMAPPLICATIONOIDCTOKENAUTHMETHOD_NONE) + bundleID := "com.example.app" + oidc.Mobile = &management.ApplicationOIDCAllOfMobile{BundleId: &bundleID} + oidc.Signing = &management.ApplicationOIDCAllOfSigning{ + KeyRotationPolicy: management.ApplicationOIDCAllOfSigningKeyRotationPolicy{Id: "krp-1"}, + } + + data, ok := toApplicationData(oidc) + require.True(t, ok) + require.NotNil(t, data.OIDC) + require.NotNil(t, data.OIDC.MobileApp) + assert.Equal(t, "com.example.app", *data.OIDC.MobileApp.BundleId) + require.NotNil(t, data.OIDC.Signing) + assert.Equal(t, "krp-1", *data.OIDC.Signing.KeyRotationPolicyId) +} + +func TestToApplicationData_SAML(t *testing.T) { + saml := management.NewApplicationSAML(true, "My SAML App", management.ENUMAPPLICATIONPROTOCOL_SAML, management.ENUMAPPLICATIONTYPE_WEB_APP, []string{"https://sp.example.com/acs"}, 3600, "sp:entity:example") + saml.SetId("app-2") + + data, ok := toApplicationData(saml) + require.True(t, ok) + require.NotNil(t, data) + require.NotNil(t, data.SAML) + + assert.Equal(t, "app-2", data.Id) + assert.Equal(t, []string{"https://sp.example.com/acs"}, data.SAML.AcsUrls) + assert.Equal(t, int32(3600), data.SAML.AssertionDuration) + assert.Equal(t, "sp:entity:example", data.SAML.SpEntityId) + + assert.Nil(t, data.OIDC) + assert.Nil(t, data.ExternalLink) + assert.Nil(t, data.WSFED) +} + +func TestToApplicationData_SAML_NestedBlocks(t *testing.T) { + saml := management.NewApplicationSAML(true, "SAML with signing", management.ENUMAPPLICATIONPROTOCOL_SAML, management.ENUMAPPLICATIONTYPE_WEB_APP, []string{"https://sp.example.com/acs"}, 3600, "sp:entity:example") + saml.IdpSigning = &management.ApplicationSAMLAllOfIdpSigning{ + Key: management.ApplicationSAMLAllOfIdpSigningKey{Id: "key-1"}, + } + saml.SpVerification = &management.ApplicationSAMLAllOfSpVerification{ + Certificates: []management.ApplicationSAMLAllOfSpVerificationCertificates{{Id: "cert-1"}, {Id: "cert-2"}}, + } + saml.SpEncryption = &management.ApplicationSAMLAllOfSpEncryption{ + Algorithm: management.ENUMCERTIFICATEKEYENCRYPTIONALGORITHM_AES_256, + Certificate: management.ApplicationSAMLAllOfSpEncryptionCertificate{Id: "enc-cert-1"}, + } + + data, ok := toApplicationData(saml) + require.True(t, ok) + require.NotNil(t, data.SAML) + + require.NotNil(t, data.SAML.IdpSigningKey) + assert.Equal(t, "key-1", data.SAML.IdpSigningKey.KeyId) + + require.NotNil(t, data.SAML.SpVerification) + assert.Equal(t, []string{"cert-1", "cert-2"}, data.SAML.SpVerification.CertificateIds) + + require.NotNil(t, data.SAML.SpEncryption) + assert.Equal(t, "enc-cert-1", data.SAML.SpEncryption.Certificate.Id) +} + +func TestToApplicationData_ExternalLink(t *testing.T) { + link := management.NewApplicationExternalLink(true, "My Link", management.ENUMAPPLICATIONPROTOCOL_EXTERNAL_LINK, management.ENUMAPPLICATIONTYPE_PORTAL_LINK_APP, "https://example.com") + link.SetId("app-3") + + data, ok := toApplicationData(link) + require.True(t, ok) + require.NotNil(t, data.ExternalLink) + assert.Equal(t, "https://example.com", data.ExternalLink.HomePageUrl) + + assert.Nil(t, data.OIDC) + assert.Nil(t, data.SAML) + assert.Nil(t, data.WSFED) +} + +func TestToApplicationData_WSFED(t *testing.T) { + idpSigning := management.ApplicationWSFEDAllOfIdpSigning{ + Algorithm: management.ENUMAPPLICATIONWSFEDIDPSIGNINGALGORITHM_SHA256WITH_RSA, + Key: management.ApplicationWSFEDAllOfIdpSigningKey{Id: "key-1"}, + } + wsfed := management.NewApplicationWSFED(true, "My WSFed App", management.ENUMAPPLICATIONPROTOCOL_WS_FED, management.ENUMAPPLICATIONTYPE_WEB_APP, "my.domain.com", idpSigning, "https://reply.example.com") + wsfed.SetId("app-4") + + data, ok := toApplicationData(wsfed) + require.True(t, ok) + require.NotNil(t, data.WSFED) + assert.Equal(t, "my.domain.com", data.WSFED.DomainName) + assert.Equal(t, "https://reply.example.com", data.WSFED.ReplyUrl) + assert.Equal(t, "key-1", data.WSFED.IdpSigningKey.KeyId) + require.NotNil(t, data.WSFED.IdpSigningKey.Algorithm) + assert.Equal(t, "SHA256withRSA", *data.WSFED.IdpSigningKey.Algorithm) + + assert.Nil(t, data.OIDC) + assert.Nil(t, data.SAML) + assert.Nil(t, data.ExternalLink) +} + +func TestToApplicationData_WSFED_Kerberos(t *testing.T) { + idpSigning := management.ApplicationWSFEDAllOfIdpSigning{ + Algorithm: management.ENUMAPPLICATIONWSFEDIDPSIGNINGALGORITHM_SHA256WITH_RSA, + Key: management.ApplicationWSFEDAllOfIdpSigningKey{Id: "key-1"}, + } + wsfed := management.NewApplicationWSFED(true, "WSFed with Kerberos", management.ENUMAPPLICATIONPROTOCOL_WS_FED, management.ENUMAPPLICATIONTYPE_WEB_APP, "my.domain.com", idpSigning, "https://reply.example.com") + userTypeID := "ut-1" + wsfed.Kerberos = &management.ApplicationWSFEDAllOfKerberos{ + Gateways: []management.ApplicationWSFEDAllOfKerberosGateways{ + { + Id: "gw-1", + Type: management.ENUMAPPLICATIONWSFEDKERBEROSGATEWAYTYPE_LDAP, + UserType: management.ApplicationWSFEDAllOfKerberosUserType{Id: &userTypeID}, + }, + }, + } + + data, ok := toApplicationData(wsfed) + require.True(t, ok) + require.NotNil(t, data.WSFED.Kerberos) + require.Len(t, data.WSFED.Kerberos.Gateways, 1) + gw := data.WSFED.Kerberos.Gateways[0] + assert.Equal(t, "gw-1", gw.Id) + require.NotNil(t, gw.UserType.Id) + assert.Equal(t, "ut-1", *gw.UserType.Id) +} + +// ── toApplicationData: unsupported system application types ───────── + +func TestToApplicationData_SkipsAdminConsole(t *testing.T) { + adminConsole := management.NewApplicationPingOneAdminConsole() + data, ok := toApplicationData(adminConsole) + assert.False(t, ok) + assert.Nil(t, data) +} + +func TestToApplicationData_SkipsPortal(t *testing.T) { + portal := management.NewApplicationPingOnePortal(true, "PingOne Portal", management.ENUMAPPLICATIONPROTOCOL_OPENID_CONNECT, management.ENUMAPPLICATIONTYPE_PING_ONE_PORTAL, management.ENUMAPPLICATIONOIDCTOKENAUTHMETHOD_NONE, true) + data, ok := toApplicationData(portal) + assert.False(t, ok) + assert.Nil(t, data) +} + +func TestToApplicationData_SkipsSelfService(t *testing.T) { + selfService := management.NewApplicationPingOneSelfService(true, "PingOne Self Service", management.ENUMAPPLICATIONPROTOCOL_OPENID_CONNECT, management.ENUMAPPLICATIONTYPE_PING_ONE_SELF_SERVICE, management.ENUMAPPLICATIONOIDCTOKENAUTHMETHOD_NONE, true) + data, ok := toApplicationData(selfService) + assert.False(t, ok) + assert.Nil(t, data) +} + +// ── toApplicationData: nil / edge cases ────────────────────────────── + +func TestToApplicationData_NilInput(t *testing.T) { + data, ok := toApplicationData(nil) + assert.False(t, ok) + assert.Nil(t, data) +} + +func TestToApplicationData_UnknownType(t *testing.T) { + data, ok := toApplicationData("not an application") + assert.False(t, ok) + assert.Nil(t, data) +} + +// ── describeSkippedApplication ──────────────────────────────────────── + +func TestDescribeSkippedApplication(t *testing.T) { + assert.Equal(t, "(PingOne Admin Console)", describeSkippedApplication(management.NewApplicationPingOneAdminConsole())) + + portal := management.NewApplicationPingOnePortal(true, "Portal", management.ENUMAPPLICATIONPROTOCOL_OPENID_CONNECT, management.ENUMAPPLICATIONTYPE_PING_ONE_PORTAL, management.ENUMAPPLICATIONOIDCTOKENAUTHMETHOD_NONE, true) + portal.SetId("portal-1") + assert.Equal(t, "portal-1", describeSkippedApplication(portal)) + + assert.Equal(t, "(unknown)", describeSkippedApplication("garbage")) +} + +// ── commonFromBase: shared field projection ────────────────────────── + +func TestCommonFromBase_TagsAndAccessControl(t *testing.T) { + tags := []management.EnumApplicationTags{management.ENUMAPPLICATIONTAGS_PING_FED_CONNECTION_INTEGRATION} + groupID := "group-1" + ac := &management.ApplicationAccessControl{ + Group: &management.ApplicationAccessControlGroup{ + Type: management.ENUMAPPLICATIONACCESSCONTROLGROUPTYPE_ANY_GROUP, + Groups: []management.ApplicationAccessControlGroupGroupsInner{{Id: groupID}}, + }, + } + icon := &management.ApplicationIcon{Id: "icon-1", Href: "https://example.com/icon.png"} + + data := commonFromBase("id-1", "env-1", "name-1", nil, true, nil, nil, tags, ac, icon) + + assert.Equal(t, []string{"PING_FED_CONNECTION_INTEGRATION"}, data.Tags) + require.NotNil(t, data.AccessControlGroupOptions) + assert.Equal(t, "ANY_GROUP", data.AccessControlGroupOptions.Type) + assert.Equal(t, []string{groupID}, data.AccessControlGroupOptions.Groups) + require.NotNil(t, data.Icon) + assert.Equal(t, "icon-1", data.Icon.Id) +} + +func TestCommonFromBase_NilAccessControlAndIcon(t *testing.T) { + data := commonFromBase("id-1", "env-1", "name-1", nil, true, nil, nil, nil, nil, nil) + assert.Nil(t, data.AccessControlGroupOptions) + assert.Nil(t, data.AccessControlRoleType) + assert.Nil(t, data.Icon) + assert.Empty(t, data.Tags) +} + +func TestCommonFromBase_RoleAccessControl(t *testing.T) { + ac := &management.ApplicationAccessControl{ + Role: &management.ApplicationAccessControlRole{Type: management.ENUMAPPLICATIONACCESSCONTROLTYPE_ADMIN_USERS_ONLY}, + } + data := commonFromBase("id-1", "env-1", "name-1", nil, true, nil, nil, nil, ac, nil) + require.NotNil(t, data.AccessControlRoleType) + assert.Equal(t, "ADMIN_USERS_ONLY", *data.AccessControlRoleType) + assert.Nil(t, data.AccessControlGroupOptions) +} + +// ── corsFromSDK ─────────────────────────────────────────────────────── + +func TestCorsFromSDK_Nil(t *testing.T) { + assert.Nil(t, corsFromSDK(nil)) +} + +func TestCorsFromSDK_WithOrigins(t *testing.T) { + cors := &management.ApplicationCorsSettings{ + Behavior: management.ENUMAPPLICATIONCORSSETTINGSBEHAVIOR_SPECIFIC_ORIGINS, + Origins: []string{"https://example.com"}, + } + result := corsFromSDK(cors) + require.NotNil(t, result) + assert.Equal(t, "ALLOW_SPECIFIC_ORIGINS", result.Behavior) + assert.Equal(t, []string{"https://example.com"}, result.Origins) +} + +// ── dispatch registration ──────────────────────────────────────────── + +func TestPingOneApplicationRegistered(t *testing.T) { + assert.True(t, isSupported("pingone_application"), "expected pingone_application to be registered") +} + +// TestDaVinciApplicationStillRegistered is a regression guard: adding +// pingone_application (this resource) must not disturb the pre-existing +// pingone_davinci_application registration in resource_application.go. +func TestDaVinciApplicationStillRegistered(t *testing.T) { + assert.True(t, isSupported("pingone_davinci_application"), "expected pingone_davinci_application to remain registered") +} + +func TestSSOAndDaVinciApplicationAreDistinctResourceTypes(t *testing.T) { + ssoHandler, ok := resourceHandlers["pingone_application"] + require.True(t, ok) + davinciHandler, ok := resourceHandlers["pingone_davinci_application"] + require.True(t, ok) + + assert.NotNil(t, ssoHandler.list) + assert.NotNil(t, ssoHandler.get) + assert.NotNil(t, davinciHandler.list) + assert.NotNil(t, davinciHandler.get) +} diff --git a/internal/schema/integration_test.go b/internal/schema/integration_test.go index e3b5282..0f11a2f 100644 --- a/internal/schema/integration_test.go +++ b/internal/schema/integration_test.go @@ -73,8 +73,8 @@ func TestRegistryLoadPlatform(t *testing.T) { err := registry.LoadPlatform("../../definitions", "pingone") require.NoError(t, err) - // Should have loaded all definitions (7 enabled ones, 1 environment is disabled) - assert.Equal(t, 7, registry.Count()) + // Should have loaded all definitions (8 enabled ones, 1 environment is disabled) + assert.Equal(t, 8, registry.Count()) // Get the variable definition def, err := registry.Get("pingone_davinci_variable") @@ -89,8 +89,8 @@ func TestRegistryLoadFromFS(t *testing.T) { err := registry.LoadFromFS(definitions.FS, "pingone") require.NoError(t, err) - // Should have loaded all definitions (7 enabled ones, 1 environment is disabled). - assert.Equal(t, 7, registry.Count()) + // Should have loaded all definitions (8 enabled ones, 1 environment is disabled). + assert.Equal(t, 8, registry.Count()) // Verify a specific definition loaded correctly. def, err := registry.Get("pingone_davinci_variable") @@ -105,7 +105,7 @@ func TestLoaderLoadFromFS(t *testing.T) { // Load from embedded FS. defs, err := loader.LoadFromFS(definitions.FS, "pingone") require.NoError(t, err) - assert.Len(t, defs, 7) + assert.Len(t, defs, 8) // Verify each definition has valid metadata. for _, def := range defs {