diff --git a/GNUmakefile b/GNUmakefile index c6c9c308..67a2a8e0 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -70,6 +70,62 @@ test: testacc: TF_ACC=1 go test $(TEST) -v $(TESTARGS) -timeout 120m +testunit: + go test ./keyfactor/ -run "TestUnit" -v $(TESTARGS) -timeout 30m + +testunit-record: + . $(KEYFACTOR_ENV_FILE) && RECORD_CASSETTES=1 go test ./keyfactor/ -run "TestUnit" -v -count=1 $(TESTARGS) -timeout 30m + +# Record a single unit test cassette. Usage: make testunit-record-one TEST_NAME=TestUnitFoo +testunit-record-one: + @if [ -z "$(TEST_NAME)" ]; then echo "Usage: make testunit-record-one TEST_NAME=TestUnitFoo"; exit 1; fi + . $(KEYFACTOR_ENV_FILE) && RECORD_CASSETTES=1 go test ./keyfactor/ -run "$(TEST_NAME)" -v -count=1 -timeout 30m + +testunit-record-csr: + . $(KEYFACTOR_ENV_FILE) && RECORD_CASSETTES=1 go test ./keyfactor/ -run "TestUnitKeyfactorCertificateResource_CSR" -v -count=1 -timeout 30m + +# Run unit tests and display only failures (quiet mode) +testunit-check: + go test ./keyfactor/ -run "TestUnit" -count=1 $(TESTARGS) -timeout 30m + +KEYFACTOR_ENV_FILE ?= ~/.env_ses2541 +KEYFACTOR_K8S_CREDENTIALS_FILE ?= $(HOME)/GolandProjects/terraform-keyfactor-provider-testing/examples/certs/deployment/k8s-creds.json + +testint: + . $(KEYFACTOR_ENV_FILE) && KEYFACTOR_K8S_CREDENTIALS_FILE=$(KEYFACTOR_K8S_CREDENTIALS_FILE) TF_ACC=1 go test ./keyfactor/ -run "TestInt" -v $(TESTARGS) -timeout 120m + +testint-check: + . $(KEYFACTOR_ENV_FILE) && KEYFACTOR_K8S_CREDENTIALS_FILE=$(KEYFACTOR_K8S_CREDENTIALS_FILE) TF_ACC=1 go test ./keyfactor/ -run "TestInt" -v -count=1 -timeout 120m + +testint-run: + @if [ -z "$(TEST_NAME)" ]; then echo "Usage: make testint-run TEST_NAME=TestIntFoo"; exit 1; fi + . $(KEYFACTOR_ENV_FILE) && KEYFACTOR_K8S_CREDENTIALS_FILE=$(KEYFACTOR_K8S_CREDENTIALS_FILE) TF_ACC=1 go test ./keyfactor/ -run "$(TEST_NAME)" -v -count=1 -timeout 120m + +testint-debug: + @if [ -z "$(TEST_NAME)" ]; then echo "Usage: make testint-debug TEST_NAME=TestIntFoo"; exit 1; fi + . $(KEYFACTOR_ENV_FILE) && KEYFACTOR_K8S_CREDENTIALS_FILE=$(KEYFACTOR_K8S_CREDENTIALS_FILE) TF_LOG=DEBUG TF_ACC=1 go test ./keyfactor/ -run "$(TEST_NAME)" -v -count=1 -timeout 120m 2>&1 | tee /tmp/tf-debug.log + +# Run a single integration test with TF debug logging. Usage: make testint-debug-run TEST_NAME=TestIntFoo +testint-debug-run: + @if [ -z "$(TEST_NAME)" ]; then echo "Usage: make testint-debug-run TEST_NAME=TestIntFoo"; exit 1; fi + . $(KEYFACTOR_ENV_FILE) && KEYFACTOR_K8S_CREDENTIALS_FILE=$(KEYFACTOR_K8S_CREDENTIALS_FILE) TF_LOG=DEBUG TF_ACC=1 go test ./keyfactor/ -run "$(TEST_NAME)" -v -count=1 -timeout 120m 2>&1 | tee /tmp/tf-debug.log + +# Run all tests (unit + int + acc). Requires lab connection. +testall: + $(MAKE) testunit + $(MAKE) testint-check + +# Lint the provider code +lint: + @which golangci-lint > /dev/null 2>&1 || (echo "golangci-lint not found, install from https://golangci-lint.run/usage/install/"; exit 1) + golangci-lint run ./... + +# Format Go files and check for issues +check: fmt vet + +vet: + go vet ./... + fmtcheck: @./scripts/gofmtcheck.sh @@ -84,14 +140,121 @@ setversion: sed -i '' -e 's/VERSION = ".*"/VERSION = "$(VERSION)"/' keyfactor/version.go @sed -i '' -e 's/TAG_VERSION=v*.*/TAG_VERSION=v$(VERSION)/' tag.sh +tidy: + go mod tidy + vendor: rm -rf vendor go mod vendor +vendor-dev: + go mod tidy + ./vendor_dev.sh + tag: git tag -d v$(VERSION) || true git push origin v$(VERSION) || true git tag v$(VERSION) || true git push origin v$(VERSION) || true -.PHONY: build release install test testacc fmtcheck fmt tag setversion vendor \ No newline at end of file +showlines: + @if [ -z "$(FILE)" ] || [ -z "$(FROM)" ] || [ -z "$(TO)" ]; then \ + echo "Usage: make showlines FILE= FROM= TO="; \ + exit 1; \ + fi + @sed -n '$(FROM),$(TO)p' $(FILE) | cat -v + +# --------------------------------------------------------------------------- +# Applications API debugging targets (uses KEYFACTOR_ENV_FILE credentials) +# Usage examples: +# make api-list-applications +# make api-get-application APP_ID=9 +# make api-create-application APP_NAME=my-app +# make api-update-application APP_ID=9 APP_NAME=my-app APP_INTERVAL=30 +# make api-delete-application APP_ID=9 +# make api-options-application +# --------------------------------------------------------------------------- +APP_ID ?= 1 +APP_NAME ?= test-application +APP_INTERVAL ?= 60 +APP_DAILY_TIME ?= +APP_OVERWRITE ?= false + +# Internal helper: get OAuth token from env file +define get_token + . $(KEYFACTOR_ENV_FILE) && TOKEN=$$(curl -sk -X POST "$$KEYFACTOR_AUTH_TOKEN_URL" \ + -d "grant_type=client_credentials&client_id=$$KEYFACTOR_AUTH_CLIENT_ID&client_secret=$$KEYFACTOR_AUTH_CLIENT_SECRET" \ + | jq -r '.access_token') +endef + +api-list-applications: + @. $(KEYFACTOR_ENV_FILE) && TOKEN=$$(curl -sk -X POST "$$KEYFACTOR_AUTH_TOKEN_URL" \ + -d "grant_type=client_credentials&client_id=$$KEYFACTOR_AUTH_CLIENT_ID&client_secret=$$KEYFACTOR_AUTH_CLIENT_SECRET" \ + | jq -r '.access_token') && \ + curl -sk "https://$$KEYFACTOR_HOSTNAME/$${KEYFACTOR_API_PATH:-Keyfactor/API}/Applications" \ + -H "x-keyfactor-requested-with: APIClient" \ + -H "x-keyfactor-api-version: 1" \ + -H "Authorization: Bearer $$TOKEN" | jq . + +api-get-application: + @if [ -z "$(APP_ID)" ]; then echo "Usage: make api-get-application APP_ID="; exit 1; fi + @. $(KEYFACTOR_ENV_FILE) && TOKEN=$$(curl -sk -X POST "$$KEYFACTOR_AUTH_TOKEN_URL" \ + -d "grant_type=client_credentials&client_id=$$KEYFACTOR_AUTH_CLIENT_ID&client_secret=$$KEYFACTOR_AUTH_CLIENT_SECRET" \ + | jq -r '.access_token') && \ + curl -sk "https://$$KEYFACTOR_HOSTNAME/$${KEYFACTOR_API_PATH:-Keyfactor/API}/Applications/$(APP_ID)" \ + -H "x-keyfactor-requested-with: APIClient" \ + -H "x-keyfactor-api-version: 1" \ + -H "Authorization: Bearer $$TOKEN" | jq . + +api-create-application: + @. $(KEYFACTOR_ENV_FILE) && TOKEN=$$(curl -sk -X POST "$$KEYFACTOR_AUTH_TOKEN_URL" \ + -d "grant_type=client_credentials&client_id=$$KEYFACTOR_AUTH_CLIENT_ID&client_secret=$$KEYFACTOR_AUTH_CLIENT_SECRET" \ + | jq -r '.access_token') && \ + curl -sk -w "\nHTTP_STATUS: %{http_code}\n" -X POST \ + "https://$$KEYFACTOR_HOSTNAME/$${KEYFACTOR_API_PATH:-Keyfactor/API}/Applications" \ + -H "x-keyfactor-requested-with: APIClient" \ + -H "x-keyfactor-api-version: 1" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $$TOKEN" \ + -d "{\"Name\":\"$(APP_NAME)\",\"OverwriteSchedules\":$(APP_OVERWRITE),\"Schedule\":{\"Interval\":{\"Minutes\":$(APP_INTERVAL)}}}" | jq . + +api-update-application: + @if [ -z "$(APP_ID)" ]; then echo "Usage: make api-update-application APP_ID= [APP_NAME=...] [APP_INTERVAL=...]"; exit 1; fi + @. $(KEYFACTOR_ENV_FILE) && TOKEN=$$(curl -sk -X POST "$$KEYFACTOR_AUTH_TOKEN_URL" \ + -d "grant_type=client_credentials&client_id=$$KEYFACTOR_AUTH_CLIENT_ID&client_secret=$$KEYFACTOR_AUTH_CLIENT_SECRET" \ + | jq -r '.access_token') && \ + curl -sk -w "\nHTTP_STATUS: %{http_code}\n" -X PUT \ + "https://$$KEYFACTOR_HOSTNAME/$${KEYFACTOR_API_PATH:-Keyfactor/API}/Applications" \ + -H "x-keyfactor-requested-with: APIClient" \ + -H "x-keyfactor-api-version: 1" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $$TOKEN" \ + -d "{\"Id\":$(APP_ID),\"Name\":\"$(APP_NAME)\",\"OverwriteSchedules\":$(APP_OVERWRITE),\"Schedule\":{\"Interval\":{\"Minutes\":$(APP_INTERVAL)}}}" | jq . + +api-delete-application: + @if [ -z "$(APP_ID)" ]; then echo "Usage: make api-delete-application APP_ID="; exit 1; fi + @. $(KEYFACTOR_ENV_FILE) && TOKEN=$$(curl -sk -X POST "$$KEYFACTOR_AUTH_TOKEN_URL" \ + -d "grant_type=client_credentials&client_id=$$KEYFACTOR_AUTH_CLIENT_ID&client_secret=$$KEYFACTOR_AUTH_CLIENT_SECRET" \ + | jq -r '.access_token') && \ + curl -sk -w "\nHTTP_STATUS: %{http_code}\n" -X DELETE \ + "https://$$KEYFACTOR_HOSTNAME/$${KEYFACTOR_API_PATH:-Keyfactor/API}/Applications/$(APP_ID)" \ + -H "x-keyfactor-requested-with: APIClient" \ + -H "x-keyfactor-api-version: 1" \ + -H "Authorization: Bearer $$TOKEN" + +api-options-application: + @. $(KEYFACTOR_ENV_FILE) && TOKEN=$$(curl -sk -X POST "$$KEYFACTOR_AUTH_TOKEN_URL" \ + -d "grant_type=client_credentials&client_id=$$KEYFACTOR_AUTH_CLIENT_ID&client_secret=$$KEYFACTOR_AUTH_CLIENT_SECRET" \ + | jq -r '.access_token') && \ + echo "--- OPTIONS /Applications ---" && \ + curl -sk -i -X OPTIONS "https://$$KEYFACTOR_HOSTNAME/$${KEYFACTOR_API_PATH:-Keyfactor/API}/Applications" \ + -H "x-keyfactor-requested-with: APIClient" \ + -H "x-keyfactor-api-version: 1" \ + -H "Authorization: Bearer $$TOKEN" 2>&1 | grep -i "^allow:" && \ + echo "--- OPTIONS /Applications/$(APP_ID) ---" && \ + curl -sk -i -X OPTIONS "https://$$KEYFACTOR_HOSTNAME/$${KEYFACTOR_API_PATH:-Keyfactor/API}/Applications/$(APP_ID)" \ + -H "x-keyfactor-requested-with: APIClient" \ + -H "x-keyfactor-api-version: 1" \ + -H "Authorization: Bearer $$TOKEN" 2>&1 | grep -i "^allow:" + +.PHONY: build release install test testacc testunit testunit-record testunit-record-one testunit-record-csr testunit-check testint testint-check testint-run testint-debug testint-debug-run testall lint check vet fmtcheck fmt tag setversion vendor vendor-dev showlines api-list-applications api-get-application api-create-application api-update-application api-delete-application api-options-application \ No newline at end of file diff --git a/README.md b/README.md index 1fcb7da9..6724e669 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,74 @@ resources become available. * If you want to contribute bug fixes or proposed enhancements, see the [Contributing Guidelines](CONTRIBUTING.md) and create a [Pull request](../../pulls). +## Authentication + +The provider supports three authentication methods. Kerberos is evaluated first when any Kerberos field is set, then Basic, then OAuth. + +### Basic Auth + +```terraform +provider "keyfactor" { + hostname = "mykfinstance.kfdelivery.com" + username = "COMMAND\\svc_terraform" + password = "your_password" + domain = "COMMAND" +} +``` + +Environment variables: `KEYFACTOR_HOSTNAME`, `KEYFACTOR_USERNAME`, `KEYFACTOR_PASSWORD`, `KEYFACTOR_DOMAIN` + +### OAuth + +```terraform +provider "keyfactor" { + hostname = "mykfinstance.kfdelivery.com" + client_id = "my_client_id" + client_secret = "my_client_secret" + token_url = "https://idp.example.com/realms/Keyfactor/protocol/openid-connect/token" + scopes = "enroll,agents,cert:admin" +} +``` + +Environment variables: `KEYFACTOR_AUTH_CLIENT_ID`, `KEYFACTOR_AUTH_CLIENT_SECRET`, `KEYFACTOR_AUTH_TOKEN_URL`, `KEYFACTOR_AUTH_SCOPES` + +### Kerberos / SPNEGO + +Three sub-modes are supported — the provider selects automatically based on which fields are set: + +| Sub-mode | Required fields | +|----------|----------------| +| Password | `kerberos_realm` + `kerberos_username` + `kerberos_password` | +| Keytab | `kerberos_realm` + `kerberos_username` + `kerberos_keytab` | +| CCache | `kerberos_ccache` | + +```terraform +# Password-based +provider "keyfactor" { + hostname = "mykfinstance.kfdelivery.com" + kerberos_realm = "EXAMPLE.COM" + kerberos_username = "svc_terraform" + kerberos_password = "your_kerberos_password" + # kerberos_disable_pafxfast = true # required for some Active Directory environments +} + +# Keytab-based +provider "keyfactor" { + hostname = "mykfinstance.kfdelivery.com" + kerberos_realm = "EXAMPLE.COM" + kerberos_username = "svc_terraform" + kerberos_keytab = "/etc/keyfactor/svc_terraform.keytab" +} + +# CCache (after running kinit) +provider "keyfactor" { + hostname = "mykfinstance.kfdelivery.com" + kerberos_ccache = "/tmp/krb5cc_1000" +} +``` + +Environment variables: `KEYFACTOR_AUTH_KRB_REALM`, `KEYFACTOR_AUTH_KRB_USERNAME`, `KEYFACTOR_AUTH_KRB_PASSWORD`, `KEYFACTOR_AUTH_KRB_KEYTAB`, `KEYFACTOR_AUTH_KRB_CCACHE`, `KEYFACTOR_AUTH_KRB_CONFIG`, `KEYFACTOR_AUTH_KRB_SPN`, `KEYFACTOR_AUTH_KRB_DISABLE_PAFXFAST` + ## Usage * [Documentation](https://registry.terraform.io/providers/keyfactor-pub/keyfactor/latest/docs) diff --git a/docs/data-sources/application.md b/docs/data-sources/application.md new file mode 100644 index 00000000..fd484bc3 --- /dev/null +++ b/docs/data-sources/application.md @@ -0,0 +1,55 @@ +--- +page_title: "keyfactor_application Data Source - terraform-provider-keyfactor" +subcategory: "" +description: |- + Reads an existing Keyfactor Command Application (certificate store container) by name or integer ID. Requires Keyfactor Command v25.0+. +--- + +# keyfactor_application (Data Source) + +Reads an existing Keyfactor Command Application (certificate store container). + +> [!NOTE] +> Applications are only available in Keyfactor Command v25.0+ + +## Example Usage + +```terraform +# Look up an application by name +data "keyfactor_application" "by_name" { + identifier = "My App" +} + +# Look up an application by integer ID +data "keyfactor_application" "by_id" { + identifier = "42" +} + +# Reference a managed application resource via the data source +resource "keyfactor_application" "example" { + name = "My App" +} + +data "keyfactor_application" "example" { + identifier = keyfactor_application.example.name +} + +output "store_ids" { + value = data.keyfactor_application.example.certificate_store_ids +} +``` + +## Schema + +### Required + +- `identifier` (String) The name or integer ID of the application to look up. + +### Read-Only + +- `certificate_store_ids` (List of String) List of certificate store GUIDs (UUIDs) assigned to this application. +- `id` (Number) Integer ID of the application in Keyfactor Command. +- `name` (String) Name of the application. +- `overwrite_schedules` (Boolean) Whether the application schedule overwrites member certificate store schedules. +- `schedule_daily_time` (String) Inventory schedule daily time as an ISO 8601 datetime string, if a daily schedule is configured. +- `schedule_interval_minutes` (Number) Inventory schedule interval in minutes, if an interval-based schedule is configured. diff --git a/docs/index.md b/docs/index.md index 658330c1..9190a1b5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -93,6 +93,29 @@ provider "keyfactor" { alias = "keyfactor_command_oauth" # This isn't required } + +# kerberos auth - password-based +provider "keyfactor" { + hostname = "mykfinstance.kfdelivery.com" + kerberos_realm = "EXAMPLE.COM" + kerberos_username = "svc_terraform" + kerberos_password = "your_kerberos_password" + kerberos_disable_pafxfast = true # required for most Active Directory environments +} + +# kerberos auth - keytab-based +provider "keyfactor" { + hostname = "mykfinstance.kfdelivery.com" + kerberos_realm = "EXAMPLE.COM" + kerberos_username = "svc_terraform" + kerberos_keytab = "/etc/keyfactor/svc_terraform.keytab" +} + +# kerberos auth - credential cache (after running kinit) +provider "keyfactor" { + hostname = "mykfinstance.kfdelivery.com" + kerberos_ccache = "/tmp/krb5cc_1000" +} ``` @@ -117,6 +140,14 @@ provider "keyfactor" { - `pfx_password_min_uppercases` (Number) The minimum number of uppercase letters to use when generating a PFX password. Default value is `4`. - `request_timeout` (Number) Global timeout for HTTP requests to Keyfactor Command instance. This can also be set via the `KEYFACTOR_CLIENT_TIMEOUT` environment variable.Default value is `60`. - `scopes` (String) A list of comma separated OAuth scopes to request when authenticating. This can also be set via the `KEYFACTOR_AUTH_SCOPES` environment variable. +- `kerberos_ccache` (String) Path to the Kerberos credential cache file for ccache-based authentication. This can also be set via the `KEYFACTOR_AUTH_KRB_CCACHE` environment variable. +- `kerberos_config` (String) Path to the krb5.conf Kerberos configuration file. Defaults to /etc/krb5.conf. This can also be set via the `KEYFACTOR_AUTH_KRB_CONFIG` environment variable. +- `kerberos_disable_pafxfast` (Boolean) Disable PA-FX-FAST for Active Directory Kerberos compatibility. Default value is `false`. +- `kerberos_keytab` (String) Path to the Kerberos keytab file for keytab-based authentication. This can also be set via the `KEYFACTOR_AUTH_KRB_KEYTAB` environment variable. +- `kerberos_password` (String, Sensitive) Password for password-based Kerberos authentication. This can also be set via the `KEYFACTOR_AUTH_KRB_PASSWORD` environment variable. +- `kerberos_realm` (String) Kerberos realm for Kerberos/SPNEGO authentication (e.g. EXAMPLE.COM). This can also be set via the `KEYFACTOR_AUTH_KRB_REALM` environment variable. +- `kerberos_spn` (String) Service Principal Name for Kerberos authentication. Auto-generated from hostname if omitted. This can also be set via the `KEYFACTOR_AUTH_KRB_SPN` environment variable. +- `kerberos_username` (String) Kerberos principal username for password or keytab-based authentication. Accepts user@REALM format. This can also be set via the `KEYFACTOR_AUTH_KRB_USERNAME` environment variable. - `skip_tls_verify` (Boolean) Skip TLS verification when connecting to Keyfactor Command API and identity provider.Default value is `false`.This can also be set via the `KEYFACTOR_SKIP_VERIFY` environment variable. - `token_url` (String) OAuth token URL for Keyfactor Command instance. This can also be set via the `KEYFACTOR_AUTH_TOKEN_URL` environment variable. - `username` (String) Username of Keyfactor Command service account. This can also be set via the `KEYFACTOR_USERNAME` environment variable. diff --git a/docs/resources/application.md b/docs/resources/application.md new file mode 100644 index 00000000..28ad81a2 --- /dev/null +++ b/docs/resources/application.md @@ -0,0 +1,55 @@ +--- +page_title: "keyfactor_application Resource - terraform-provider-keyfactor" +subcategory: "" +description: |- + Manages a Keyfactor Command Application (certificate store container). Applications group certificate stores together and define an optional inventory schedule that applies to all member stores. Requires Keyfactor Command v25.0+. +--- + +# keyfactor_application (Resource) + +Manages a Keyfactor Command Application (certificate store container). + +Applications group certificate stores together and define an optional inventory schedule that applies to all member stores. + +> [!NOTE] +> Applications are only available in Keyfactor Command v25.0+ + +## Example Usage + +```terraform +# Application with an interval-based inventory schedule +resource "keyfactor_application" "interval" { + name = "My App" + overwrite_schedules = false + schedule_interval_minutes = 60 +} + +# Application with a daily inventory schedule +resource "keyfactor_application" "daily" { + name = "My Daily App" + overwrite_schedules = true + schedule_daily_time = "2024-01-01T02:00:00Z" # Server uses only the time portion (02:00 UTC daily) +} + +# Minimal application (no schedule) +resource "keyfactor_application" "minimal" { + name = "My Minimal App" +} +``` + +## Schema + +### Required + +- `name` (String) Name of the application (certificate store container). + +### Optional + +- `overwrite_schedules` (Boolean) When true, the application schedule overwrites the schedules of all member certificate stores. Defaults to `false`. +- `schedule_daily_time` (String) Inventory schedule daily time as an ISO 8601 datetime string (e.g. `2024-01-01T23:30:00Z`). The server uses only the time-of-day portion — the date is normalized to the next scheduled occurrence. Mutually exclusive with `schedule_interval_minutes`. +- `schedule_interval_minutes` (Number) Inventory schedule interval in minutes. Set to a positive integer to use an interval-based schedule. Mutually exclusive with `schedule_daily_time`. + +### Read-Only + +- `id` (String) Keyfactor Command application ID. +- `store_count` (Number) Number of certificate stores currently assigned to this application. diff --git a/docs/test-gap-plan.md b/docs/test-gap-plan.md new file mode 100644 index 00000000..f1284460 --- /dev/null +++ b/docs/test-gap-plan.md @@ -0,0 +1,331 @@ +# Test Configuration Gap Plan + +## Known Limitation: `id` Attribute and the SDKv2 Test Harness + +The Terraform SDKv2 test harness (`terraform-plugin-sdk/v2/helper/resource`) requires every +resource and data source to have an `id` attribute in state. The provider uses the Terraform +Plugin Framework (tfsdk v0.10.0), which does not automatically inject an `id` attribute. + +Most resources and data sources already have an explicit `"id"` schema attribute. However, +**the `keyfactor_agent` data source does not**. This means any test using the SDKv2 test +harness for this data source will fail with: + +``` +no "id" found in attributes +``` + +### Affected Resources/Data Sources + +| Type | Name | Has `id` attr | Status | +|------|------|---------------|--------| +| Data Source | `keyfactor_agent` | **NO** | Blocked until `id` is added | +| All others | * | Yes | OK | + +### Resolution Path + +Adding a computed `id` attribute requires: +1. Adding `"id"` to the schema in `data_source_keyfactor_agent.go` +2. Adding a `TfId types.String \`tfsdk:"id"\`` field to the `CommandAgent` model struct +3. Setting `TfId` = `AgentId` before writing state (same `syncTfId()` pattern used for certificates) + +This is a provider code change (not just a test change). It will be done as a prerequisite +to Phase 1 below, following the same pattern established for the certificate resource/data source. + +Alternatively, migrating to `terraform-plugin-testing` would remove the `id` requirement entirely, +but that requires upgrading `terraform-plugin-framework` from v0.10.0 to v1.x — a larger effort +planned for the future. + +**Any new data source or resource added to the provider MUST include an `"id"` computed attribute +in its schema to be compatible with the current test framework.** + +--- + +## Inventory Summary + +### Data Sources (10 total) + +| # | Data Source | Test File | TestAcc | TestInt | Gaps | +|---|---|---|---|---|---| +| 1 | agent | **MISSING** | - | - | No test file; missing `id` attr (blocked) | +| 2 | enrollment_pattern | **MISSING** | - | - | No test file | +| 3 | certificate | Yes | Yes | Yes | TestInt could check more attrs (subject, metadata) | +| 4 | certificate_store | Yes | Yes | Yes | TestInt could check `properties`, `approved` | +| 5 | certificate_template | Yes | Yes | Yes | TestInt only checks 4 attrs; missing `key_size`, `key_type`, `requires_approval` | +| 6 | oauth_security_claim | Yes | Yes | Yes | Good coverage | +| 7 | oauth_security_role | Yes | Yes | Yes | TestInt doesn't check `permission_set_id`, `permissions`, `email_address` | +| 8 | permission_set | Yes | Yes | Yes | Could verify `permissions.#` > 0 | +| 9 | security_identity | Yes | Yes | Yes | Skips on OAuth-only labs -- acceptable | +| 10 | security_role | Yes | Yes | Yes | Good coverage | + +### Resources (10 total) + +| # | Resource | Test File | TestAcc | TestInt | Gaps | +|---|---|---|---|---|---| +| 1 | certificate | Yes | 3 TestAcc | 2 TestInt | TestInt missing: update step, metadata, subject, SANs | +| 2 | certificate_deploy | Yes | **Commented out** | - | Entire file commented out | +| 3 | certificate_store | Yes | Yes | Yes | TestInt missing: update step, properties check | +| 4 | certificate_store_type | **N/A** | - | - | **Resource source entirely commented out** -- skip | +| 5 | oauth_security_claim | Yes | 4 TestAcc | 1 TestInt | TestInt missing: update, replace-on-uneditable, import | +| 6 | oauth_security_role | Yes | 3 TestAcc | 1 TestInt | TestInt missing: update, duplicate perms, import | +| 7 | oauth_role_claim_assoc | Yes | Yes | Yes | TestInt missing: update (role swap) | +| 8 | security_identity | Yes | Yes | Yes | TestInt missing: multi-step (add/remove roles); skips on OAuth labs | +| 9 | security_role | Yes | Yes | Yes | TestInt missing: multi-step (add/remove permissions) | +| 10 | template_role_binding | Yes | Yes | Yes | Limited by Policies bug; uses ExpectError -- acceptable | + +--- + +## Phase Plan + +### Phase 1: Data Source -- Agent (NEW) + +**Prerequisite**: Add `id` computed attribute to `data_source_keyfactor_agent.go` and +`TfId` field to `CommandAgent` model (same pattern as certificate). + +**File**: `keyfactor/data_source_keyfactor_agent_test.go` (new) + +**Tests**: +- `TestIntKeyfactorAgentDataSource` -- use `discoverAgent()` to get GUID, look up via `agent_identifier` +- Second test step: look up by `client_machine` name instead of GUID +- Verify: `agent_id`, `client_machine`, `username`, `status`, `version`, `capabilities`, `thumbprint` + +**Helpers** (in `test_helpers_test.go`): +- `testAccAgentDataSourceConfig(identifier string) string` + +**Commit after passing.** + +--- + +### Phase 2: Data Source -- Enrollment Pattern (NEW) + +**File**: `keyfactor/data_source_keyfactor_enrollment_pattern_test.go` (new) + +**Tests**: +- `TestIntKeyfactorEnrollmentPatternDataSource` -- use `discoverEnrollmentPattern()`, skip if empty (pre-v25) +- Look up by name, verify: `id`, `name`, `template`, `allowed_enrollment_types`, `template_default` +- Second lookup by ID (from first result) to test the numeric ID path + +**Helpers** (in `test_helpers_test.go`): +- `testAccEnrollmentPatternDataSourceConfig(identifier string) string` + +**Commit after passing.** + +--- + +### Phase 3: Data Source -- Certificate Template (ENHANCE) + +**File**: `keyfactor/data_source_keyfactor_template_test.go` (modify) + +**Scope**: Enhance `TestIntKeyfactorCertificateTemplateDataSource` to check additional attrs: +- `key_size`, `key_type`, `forest_root`, `requires_approval`, `key_usage` +- `allowed_enrollment_types`, `template_regexes.#` + +**Commit after passing.** + +--- + +### Phase 4: Data Source -- Certificate (ENHANCE) + +**File**: `keyfactor/data_source_keyfactor_certificate_test.go` (modify) + +**Scope**: Enhance `TestIntKeyfactorCertificateDataSource` to check additional attrs: +- `subject.%`, `metadata.%`, `certificate_authority`, `certificate_template`, `keyfactor_request_id` + +**Commit after passing.** + +--- + +### Phase 5: Data Source -- Certificate Store (ENHANCE) + +**File**: `keyfactor/data_source_keyfactor_certificate_store_test.go` (modify) + +**Scope**: Enhance `TestIntKeyfactorCertificateStoreDataSource` to check: +- `approved`, `properties.%`, `agent_assigned` + +**Commit after passing.** + +--- + +### Phase 6: Data Source -- OAuth Security Role (ENHANCE) + +**File**: `keyfactor/data_source_keyfactor_oauth_security_role_test.go` (modify) + +**Scope**: Enhance `TestIntKeyfactorOAuthSecurityRoleDataSource` to check: +- `permission_set_id`, `email_address`, `permissions.#` + +**Commit after passing.** + +--- + +### Phase 7: Data Source -- Permission Set (ENHANCE) + +**File**: `keyfactor/data_source_keyfactor_permission_set_test.go` (modify) + +**Scope**: Enhance `TestIntKeyfactorPermissionSetDataSource`: +- Verify `permissions.#` > 0 using `TestMatchResourceAttr` with a regex like `[1-9][0-9]*` + +**Commit after passing.** + +--- + +### Phase 8: Resource -- Certificate (ENHANCE) + +**File**: `keyfactor/resource_keyfactor_certificate_test.go` (modify) + +**Scope**: +- Enhance `TestIntKeyfactorCertificateResource_PFX` to check: `id`, `keyfactor_request_id`, + `certificate_authority`, `certificate_template`/`certificate_enrollment_pattern` +- Add `TestIntKeyfactorCertificateResource_PFX_WithSubject` -- uses `subject` block + SANs + metadata +- Add update step to PFX test (change metadata, verify no replacement) + +**Helpers** (in `test_helpers_test.go`): +- `testAccCertPFXConfigWithSubject(templateName, ca, cn, dnsSans, metadata string) string` + +**Commit after passing.** + +--- + +### Phase 9: Resource -- Certificate Store (ENHANCE) + +**File**: `keyfactor/resource_keyfactor_certificate_store_test.go` (modify) + +**Scope**: +- Enhance `TestIntKeyfactorCertificateStoreResource` to check: `approved`, `properties.%` +- Add update step (change `store_path`, verify replacement or update behavior) + +**Commit after passing.** + +--- + +### Phase 10: Resource -- Certificate Deploy (REWRITE) + +**File**: `keyfactor/resource_keyfactor_certificate_deploy_test.go` (uncomment + rewrite) + +**Tests**: +- `TestIntKeyfactorCertificateDeployResource` -- creates cert + store, then deploys cert to store +- Uses `discoverCA()`, `discoverTemplate()`/`discoverEnrollmentPattern()`, `discoverAgent()`, + `discoverStoreTypeForAgent()` +- Verify: `id`, `certificate_id`, `certificate_store_id`, `certificate_alias` + +**Helpers** (in `test_helpers_test.go`): +- `testAccCertDeployConfig(certRef, storeRef string) string` + +**Commit after passing.** + +--- + +### Phase 11: Resource -- OAuth Security Claim (ENHANCE) + +**File**: `keyfactor/resource_keyfactor_oauth_security_claim_test.go` (modify) + +**Scope**: +- Add `TestIntKeyfactorOAuthClaimResource_Update` -- create then update description +- Add `TestIntKeyfactorOAuthClaimResource_Import` -- create then ImportState + +**Commit after passing.** + +--- + +### Phase 12: Resource -- OAuth Security Role (ENHANCE) + +**File**: `keyfactor/resource_keyfactor_oauth_security_role_test.go` (modify) + +**Scope**: +- Add `TestIntKeyfactorOAuthRoleResource_Update` -- create then update description + permissions +- Add `TestIntKeyfactorOAuthRoleResource_Import` -- create then ImportState + +**Commit after passing.** + +--- + +### Phase 13: Resource -- OAuth Role Claim Association (ENHANCE) + +**File**: `keyfactor/resource_keyfactor_oauth_security_role_claim_association_test.go` (modify) + +**Scope**: +- Add `TestIntKeyfactorOAuthSecurityRoleClaimAssociationResource_Update` -- swap associated role (role1 to role2) + +**Commit after passing.** + +--- + +### Phase 14: Resource -- Security Identity (ENHANCE) + +**File**: `keyfactor/resource_keyfactor_security_identity_test.go` (modify) + +**Scope**: +- Add `TestIntKeyfactorIdentityResource_MultiRole` -- create with 1 role, update to 2 roles, update to 0 roles +- Skips on OAuth-only labs (no Windows identities available) + +**Commit after passing.** + +--- + +### Phase 15: Resource -- Security Role (ENHANCE) + +**File**: `keyfactor/resource_keyfactor_security_role_test.go` (modify) + +**Scope**: +- Add `TestIntKeyfactorRoleResource_Update` -- create with 0 permissions, update to add + `AdminPortal:Read` + `API:Read`, update back to 0 +- Verify `description`, `permissions.#` at each step + +**Commit after passing.** + +--- + +### Phase 16: Resource -- Template Role Binding (NO CHANGE) + +Already has TestInt with `ExpectError` for the Policies bug (keyfactor-go-client v3 +`UpdateTemplateArg` struct missing `Policies` field required by Command v25+). + +No actionable enhancement until the client library is updated. **Skip.** + +--- + +### Phase 17: Resource -- Certificate Store Type (NO CHANGE) + +Resource source code (`resource_keyfactor_certificate_store_type.go`) is entirely commented out. +This is an unimplemented feature, not a test gap. **Skip.** + +--- + +## Execution Order Rationale + +1. **Phases 1-2** (new test files) -- highest value, fill completely missing coverage +2. **Phases 3-7** (data source enhancements) -- low risk, quick wins, improve attribute coverage +3. **Phases 8-10** (core resource enhancements) -- certificate and store are the most important resources +4. **Phases 11-15** (remaining resource enhancements) -- add update/import steps to existing TestInt tests +5. **Phases 16-17** (skip) -- blocked by known limitations + +## Test Helpers Summary + +New helpers to add in `keyfactor/test_helpers_test.go`: + +| Helper | Used By | +|--------|---------| +| `testAccAgentDataSourceConfig(identifier)` | Phase 1 | +| `testAccEnrollmentPatternDataSourceConfig(identifier)` | Phase 2 | +| `testAccCertPFXConfigWithSubject(template, ca, cn, dnsSans, metadata)` | Phase 8 | +| `testAccCertDeployConfig(certRef, storeRef)` | Phase 10 | + +## Verification + +After each phase: + +```bash +# Run just the new/modified tests +make testint-check + +# Verify compilation +go build ./... +``` + +After all phases: + +```bash +# Full integration test suite +make testint + +# Full unit test suite (no network) +make testunit +``` diff --git a/examples/data-sources/keyfactor_application/data-source.tf b/examples/data-sources/keyfactor_application/data-source.tf new file mode 100644 index 00000000..1c5c2617 --- /dev/null +++ b/examples/data-sources/keyfactor_application/data-source.tf @@ -0,0 +1,22 @@ +# Look up an application by name +data "keyfactor_application" "by_name" { + identifier = "My App" +} + +# Look up an application by integer ID +data "keyfactor_application" "by_id" { + identifier = "42" +} + +# Reference a managed application resource via the data source +resource "keyfactor_application" "example" { + name = "My App" +} + +data "keyfactor_application" "example" { + identifier = keyfactor_application.example.name +} + +output "store_ids" { + value = data.keyfactor_application.example.certificate_store_ids +} diff --git a/examples/provider/provider.tf b/examples/provider/provider.tf index 3cc058b0..bebd97d9 100644 --- a/examples/provider/provider.tf +++ b/examples/provider/provider.tf @@ -15,4 +15,30 @@ provider "keyfactor" { hostname = "mykfinstance.kfdelivery.com" alias = "keyfactor_command_oauth" # This isn't required +} + +# kerberos auth - password-based +provider "keyfactor" { + hostname = "mykfinstance.kfdelivery.com" + kerberos_realm = "EXAMPLE.COM" + kerberos_username = "svc_terraform" + kerberos_password = "your_kerberos_password" + kerberos_config = "/etc/krb5.conf" # optional, defaults to /etc/krb5.conf + + # Disable PA-FX-FAST if authenticating against Active Directory + kerberos_disable_pafxfast = true +} + +# kerberos auth - keytab-based (no password required) +provider "keyfactor" { + hostname = "mykfinstance.kfdelivery.com" + kerberos_realm = "EXAMPLE.COM" + kerberos_username = "svc_terraform" + kerberos_keytab = "/etc/keyfactor/svc_terraform.keytab" +} + +# kerberos auth - credential cache (ccache) +provider "keyfactor" { + hostname = "mykfinstance.kfdelivery.com" + kerberos_ccache = "/tmp/krb5cc_1000" # path to an existing ccache obtained via kinit } \ No newline at end of file diff --git a/examples/resources/keyfactor_application/resource.tf b/examples/resources/keyfactor_application/resource.tf new file mode 100644 index 00000000..5a894be3 --- /dev/null +++ b/examples/resources/keyfactor_application/resource.tf @@ -0,0 +1,18 @@ +# Application with an interval-based inventory schedule +resource "keyfactor_application" "interval" { + name = "My App" + overwrite_schedules = false + schedule_interval_minutes = 60 +} + +# Application with a daily inventory schedule +resource "keyfactor_application" "daily" { + name = "My Daily App" + overwrite_schedules = true + schedule_daily_time = "2024-01-01T02:00:00Z" # Server uses only the time portion (02:00 UTC daily) +} + +# Minimal application (no schedule) +resource "keyfactor_application" "minimal" { + name = "My Minimal App" +} diff --git a/go.mod b/go.mod index fa7ceea3..a95eeee4 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,8 @@ go 1.24.0 require ( github.com/Keyfactor/keyfactor-auth-client-go v1.4.0-rc.0 - github.com/Keyfactor/keyfactor-go-client-sdk/v24 v24.0.2 - github.com/Keyfactor/keyfactor-go-client/v3 v3.4.0 + github.com/Keyfactor/keyfactor-go-client-sdk/v24 v24.1.0-rc.0 + github.com/Keyfactor/keyfactor-go-client/v3 v3.5.0-rc.0 github.com/hashicorp/terraform-plugin-framework v0.10.0 github.com/hashicorp/terraform-plugin-go v0.18.0 github.com/hashicorp/terraform-plugin-log v0.10.0 @@ -17,6 +17,11 @@ require ( require github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect +require ( + gopkg.in/dnaeon/go-vcr.v4 v4.0.6 + gopkg.in/yaml.v3 v3.0.1 +) + require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect @@ -74,6 +79,7 @@ require ( github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/zclconf/go-cty v1.17.0 // indirect go.mozilla.org/pkcs7 v0.9.0 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect @@ -86,5 +92,4 @@ require ( google.golang.org/grpc v1.75.1 // indirect google.golang.org/protobuf v1.36.9 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index e35fabd9..3fa8ebe6 100644 --- a/go.sum +++ b/go.sum @@ -18,10 +18,10 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgv github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/Keyfactor/keyfactor-auth-client-go v1.4.0-rc.0 h1:xy9knPEO6G7N6Emf/PHZroj+SCRmCZdi16kAb42trg0= github.com/Keyfactor/keyfactor-auth-client-go v1.4.0-rc.0/go.mod h1:rFBZPMSHWwWuUwE1kXhLsDaOxjGiHMbXTTEni8Dmufo= -github.com/Keyfactor/keyfactor-go-client-sdk/v24 v24.0.2 h1:y5PjSGVwNvuBS4AXKN6h5L+pwO2eqhYMP550MWSFsgk= -github.com/Keyfactor/keyfactor-go-client-sdk/v24 v24.0.2/go.mod h1:xRxSLR87Uz6SNYjkK6ezXTLGoOjHfqtZ2kwYgUfz0Bk= -github.com/Keyfactor/keyfactor-go-client/v3 v3.4.0 h1:5ulPMj5+0qT3odIQ9r2nnN7MRTCW0fxFaxCa3z/tWbY= -github.com/Keyfactor/keyfactor-go-client/v3 v3.4.0/go.mod h1:WUW+fZElmrKsLlB29HCC6P2PyEyseOvaSeUs0gvrru0= +github.com/Keyfactor/keyfactor-go-client-sdk/v24 v24.1.0-rc.0 h1:EfY9tfXCXozAAHPTY095jJ82c8kbkQUkCcXuFiODEh4= +github.com/Keyfactor/keyfactor-go-client-sdk/v24 v24.1.0-rc.0/go.mod h1:xIbpgJ9eYfcYeSM0VzP5Q3ifgLCf3yGKKyZtWmqqOi8= +github.com/Keyfactor/keyfactor-go-client/v3 v3.5.0-rc.0 h1:jx7vG6C2+fz1F2SmaCRtNz66HQ9qWpVCvTpG82nKuTQ= +github.com/Keyfactor/keyfactor-go-client/v3 v3.5.0-rc.0/go.mod h1:WUW+fZElmrKsLlB29HCC6P2PyEyseOvaSeUs0gvrru0= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= @@ -229,6 +229,8 @@ go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFh go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= +go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= @@ -304,6 +306,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/dnaeon/go-vcr.v4 v4.0.6 h1:PiJkrakkmzc5s7EfBnZOnyiLwi7o7A9fwPzN0X2uwe0= +gopkg.in/dnaeon/go-vcr.v4 v4.0.6/go.mod h1:sbq5oMEcM4PXngbcNbHhzfCP9OdZodLhrbRYoyg09HY= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/keyfactor/data_source_keyfactor_agent.go b/keyfactor/data_source_keyfactor_agent.go index 4a5925e9..5c2cc608 100644 --- a/keyfactor/data_source_keyfactor_agent.go +++ b/keyfactor/data_source_keyfactor_agent.go @@ -17,6 +17,11 @@ type dataSourceAgentType struct{} func (r dataSourceAgentType) GetSchema(_ context.Context) (tfsdk.Schema, diag.Diagnostics) { return tfsdk.Schema{ Attributes: map[string]tfsdk.Attribute{ + "id": { + Type: types.StringType, + Computed: true, + Description: "Read-only mirror of agent_id for Terraform test framework compatibility.", + }, "agent_id": { Type: types.StringType, Computed: true, @@ -228,6 +233,8 @@ func (r dataSourceAgent) Read( }, } + result.syncTfId() + diags = response.State.Set(ctx, &result) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { diff --git a/keyfactor/data_source_keyfactor_agent_test.go b/keyfactor/data_source_keyfactor_agent_test.go new file mode 100644 index 00000000..4861f8c8 --- /dev/null +++ b/keyfactor/data_source_keyfactor_agent_test.go @@ -0,0 +1,54 @@ +package keyfactor + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +// --------------------------------------------------------------------------- +// Integration tests (auto-discovery) +// --------------------------------------------------------------------------- + +func TestIntKeyfactorAgentDataSource(t *testing.T) { + client := testAccIntegrationPreCheck(t) + agentID, clientMachine := discoverAgent(t, client) + + // Test 1: Look up by GUID + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccAgentDataSourceConfig(agentID), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("data.keyfactor_agent.test", "id"), + resource.TestCheckResourceAttr("data.keyfactor_agent.test", "agent_id", agentID), + resource.TestCheckResourceAttrSet("data.keyfactor_agent.test", "client_machine"), + resource.TestCheckResourceAttrSet("data.keyfactor_agent.test", "status"), + resource.TestCheckResourceAttrSet("data.keyfactor_agent.test", "version"), + resource.TestCheckResourceAttrSet("data.keyfactor_agent.test", "capabilities.#"), + ), + }, + }, + }) + + // Test 2: Look up by client machine name + if clientMachine != "" { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccAgentDataSourceConfig(clientMachine), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("data.keyfactor_agent.test", "id"), + resource.TestCheckResourceAttrSet("data.keyfactor_agent.test", "agent_id"), + resource.TestCheckResourceAttr("data.keyfactor_agent.test", "client_machine", clientMachine), + resource.TestCheckResourceAttrSet("data.keyfactor_agent.test", "status"), + resource.TestCheckResourceAttrSet("data.keyfactor_agent.test", "version"), + resource.TestCheckResourceAttrSet("data.keyfactor_agent.test", "capabilities.#"), + ), + }, + }, + }) + } +} diff --git a/keyfactor/data_source_keyfactor_application.go b/keyfactor/data_source_keyfactor_application.go new file mode 100644 index 00000000..c2ec096e --- /dev/null +++ b/keyfactor/data_source_keyfactor_application.go @@ -0,0 +1,164 @@ +package keyfactor + +import ( + "context" + "fmt" + "strconv" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +type dataSourceApplicationType struct{} + +func (r dataSourceApplicationType) GetSchema(_ context.Context) (tfsdk.Schema, diag.Diagnostics) { + return tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "identifier": { + Type: types.StringType, + Required: true, + Description: "The name or integer ID of the application to look up.", + }, + "id": { + Type: types.Int64Type, + Computed: true, + Description: "Integer ID of the application in Keyfactor Command.", + }, + "name": { + Type: types.StringType, + Computed: true, + Description: "Name of the application.", + }, + "overwrite_schedules": { + Type: types.BoolType, + Computed: true, + Description: "Whether the application schedule overwrites member certificate store schedules.", + }, + "schedule_interval_minutes": { + Type: types.Int64Type, + Computed: true, + Description: "Inventory schedule interval in minutes, if an interval-based schedule is configured.", + }, + "schedule_daily_time": { + Type: types.StringType, + Computed: true, + Description: "Inventory schedule daily time as an ISO 8601 datetime string, if a daily schedule is configured.", + }, + "certificate_store_ids": { + Type: types.ListType{ElemType: types.StringType}, + Computed: true, + Description: "List of certificate store GUIDs (UUIDs) assigned to this application.", + }, + }, + MarkdownDescription: ` +Reads an existing Keyfactor Command Application (certificate store container). + +> [!NOTE] +> Applications are only available in Keyfactor Command v25.0+ +`, + }, nil +} + +func (r dataSourceApplicationType) NewDataSource(ctx context.Context, p tfsdk.Provider) (tfsdk.DataSource, diag.Diagnostics) { + return dataSourceApplication{ + p: *(p.(*provider)), + }, nil +} + +type dataSourceApplication struct { + p provider +} + +// KeyfactorApplicationDataSource is the Terraform state model for data.keyfactor_application. +type KeyfactorApplicationDataSource struct { + Identifier types.String `tfsdk:"identifier"` + ID types.Int64 `tfsdk:"id"` + Name types.String `tfsdk:"name"` + OverwriteSchedules types.Bool `tfsdk:"overwrite_schedules"` + ScheduleIntervalMins types.Int64 `tfsdk:"schedule_interval_minutes"` + ScheduleDailyTime types.String `tfsdk:"schedule_daily_time"` + CertificateStoreIDs types.List `tfsdk:"certificate_store_ids"` +} + +func (r dataSourceApplication) Read(ctx context.Context, request tfsdk.ReadDataSourceRequest, response *tfsdk.ReadDataSourceResponse) { + LogFunctionEntry(ctx, "dataSourceApplication.Read") + + var state KeyfactorApplicationDataSource + diags := request.Config.Get(ctx, &state) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + identifier := state.Identifier.Value + tflog.Info(ctx, fmt.Sprintf("Reading application with identifier %q", identifier)) + + // Try to parse as integer ID first, then fall back to name lookup. + var appID int + if id, err := strconv.Atoi(identifier); err == nil { + appID = id + tflog.Debug(ctx, fmt.Sprintf("Identifier is numeric, using ID %d", appID)) + } else { + // Look up by name via the list endpoint. + apps, listErr := r.p.client.ListApplications() + if listErr != nil { + response.Diagnostics.AddError( + "Error listing applications.", + "Could not list applications from Keyfactor Command: "+listErr.Error(), + ) + return + } + found := false + for _, app := range apps { + if app.Name == identifier { + appID = app.Id + found = true + break + } + } + if !found { + response.Diagnostics.AddError( + "Application not found.", + fmt.Sprintf("No application with name %q was found in Keyfactor Command.", identifier), + ) + return + } + } + + app, err := r.p.client.GetApplication(appID) + if err != nil { + response.Diagnostics.AddError( + "Error reading application.", + fmt.Sprintf("Could not read application %d from Keyfactor Command: %s", appID, err.Error()), + ) + return + } + + intervalMins, dailyTime := flattenApplicationSchedule(app.Schedule) + + // Build the certificate_store_ids list. + storeIDs := make([]string, 0, len(app.CertificateStores)) + for _, cs := range app.CertificateStores { + storeIDs = append(storeIDs, cs.Id) + } + storeIDList := types.List{ + ElemType: types.StringType, + Elems: convertStringArrayToTerraform(storeIDs), + } + + result := KeyfactorApplicationDataSource{ + Identifier: state.Identifier, + ID: types.Int64{Value: int64(app.Id)}, + Name: types.String{Value: app.Name}, + OverwriteSchedules: types.Bool{Value: app.OverwriteSchedules}, + ScheduleIntervalMins: intervalMins, + ScheduleDailyTime: dailyTime, + CertificateStoreIDs: storeIDList, + } + + diags = response.State.Set(ctx, &result) + response.Diagnostics.Append(diags...) + LogFunctionExit(ctx, "dataSourceApplication.Read") +} diff --git a/keyfactor/data_source_keyfactor_application_test.go b/keyfactor/data_source_keyfactor_application_test.go new file mode 100644 index 00000000..6d11bde3 --- /dev/null +++ b/keyfactor/data_source_keyfactor_application_test.go @@ -0,0 +1,129 @@ +package keyfactor + +import ( + "fmt" + "os" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +// --------------------------------------------------------------------------- +// Acceptance tests (TF_ACC=1, reads env vars for config) +// --------------------------------------------------------------------------- + +// TestAccKeyfactorApplicationDataSource tests reading a keyfactor_application data source. +// It first creates an application resource and then reads it back via the data source. +func TestAccKeyfactorApplicationDataSource(t *testing.T) { + baseName := os.Getenv("KEYFACTOR_APPLICATION_NAME") + if baseName == "" { + baseName = "tf-acc-app-ds" + } + appName := fmt.Sprintf("%s-%d", baseName, time.Now().UnixNano()%1000000000) + resourceName := "keyfactor_application.test" + dataSourceName := "data.keyfactor_application.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create resource then read it back via data source by name + { + Config: testAccApplicationConfig(appName, false, 60, "") + "\n" + + testAccApplicationDataSourceByName(resourceName), + Check: resource.ComposeAggregateTestCheckFunc( + // Resource checks + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttr(resourceName, "name", appName), + // Data source checks — read back by name + resource.TestCheckResourceAttrSet(dataSourceName, "id"), + resource.TestCheckResourceAttr(dataSourceName, "name", appName), + resource.TestCheckResourceAttrSet(dataSourceName, "overwrite_schedules"), + resource.TestCheckResourceAttrSet(dataSourceName, "certificate_store_ids.#"), + ), + }, + }, + }) +} + +// TestAccKeyfactorApplicationDataSourceByID tests reading an application by integer ID. +func TestAccKeyfactorApplicationDataSourceByID(t *testing.T) { + baseName := os.Getenv("KEYFACTOR_APPLICATION_NAME") + if baseName == "" { + baseName = "tf-acc-app-ds-id" + } + appName := fmt.Sprintf("%s-%d", baseName, time.Now().UnixNano()%1000000000) + resourceName := "keyfactor_application.test" + dataSourceName := "data.keyfactor_application.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccApplicationConfig(appName, false, 0, "") + "\n" + + testAccApplicationDataSourceByID(resourceName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttrSet(dataSourceName, "id"), + resource.TestCheckResourceAttr(dataSourceName, "name", appName), + resource.TestCheckResourceAttrSet(dataSourceName, "certificate_store_ids.#"), + ), + }, + }, + }) +} + +// --------------------------------------------------------------------------- +// Integration tests (auto-discovery) +// --------------------------------------------------------------------------- + +func TestIntKeyfactorApplicationDataSource(t *testing.T) { + client := testAccIntegrationPreCheck(t) + if client == nil { + t.Skip("Skipping: testAccIntegrationPreCheck returned nil client") + } + + appName := randomTestCN("tf-int-app-ds") + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + // Create an application resource then read it back via data source by name + Config: testAccApplicationConfig(appName, false, 60, "") + "\n" + + testAccApplicationDataSourceByName("keyfactor_application.test"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("keyfactor_application.test", "id"), + // Data source checks + resource.TestCheckResourceAttrSet("data.keyfactor_application.test", "id"), + resource.TestCheckResourceAttr("data.keyfactor_application.test", "name", appName), + resource.TestCheckResourceAttrSet("data.keyfactor_application.test", "certificate_store_ids.#"), + ), + }, + }, + }) +} + +// --------------------------------------------------------------------------- +// Config generators +// --------------------------------------------------------------------------- + +// testAccApplicationDataSourceByName reads an application by name, referencing the resource. +func testAccApplicationDataSourceByName(resourceRef string) string { + return fmt.Sprintf(` +data "keyfactor_application" "test" { + identifier = %s.name +} +`, resourceRef) +} + +// testAccApplicationDataSourceByID reads an application by integer ID, referencing the resource. +func testAccApplicationDataSourceByID(resourceRef string) string { + return fmt.Sprintf(` +data "keyfactor_application" "test" { + identifier = %s.id +} +`, resourceRef) +} diff --git a/keyfactor/data_source_keyfactor_certificate.go b/keyfactor/data_source_keyfactor_certificate.go index 3b8e4060..a6da8b56 100644 --- a/keyfactor/data_source_keyfactor_certificate.go +++ b/keyfactor/data_source_keyfactor_certificate.go @@ -214,6 +214,11 @@ Note: To assign a certificate owner, one of OwnerRoleId or OwnerRoleName is req Computed: true, Description: "Not After date of enrolled certificate", }, + "id": { + Type: types.StringType, + Computed: true, + Description: "Read-only alias of `identifier` for Terraform framework compatibility.", + }, "identifier": { Type: types.StringType, Required: true, @@ -633,6 +638,7 @@ func (r dataSourceCertificate) Read( // Set state tflog.Debug(ctx, "Setting state") + result.syncTfId() diags := response.State.Set(ctx, &result) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { diff --git a/keyfactor/data_source_keyfactor_certificate_store_test.go b/keyfactor/data_source_keyfactor_certificate_store_test.go index 05347241..9b23d91d 100644 --- a/keyfactor/data_source_keyfactor_certificate_store_test.go +++ b/keyfactor/data_source_keyfactor_certificate_store_test.go @@ -2,9 +2,12 @@ package keyfactor import ( "fmt" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "os" + "path/filepath" "testing" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" ) func TestAccKeyfactorCertificateStoreDataSource(t *testing.T) { @@ -59,3 +62,96 @@ func testAccDataSourceKeyfactorCertificateStoreBasic(resourceName string, passwo } `, resourceName, password) } + +// --------------------------------------------------------------------------- +// Unit tests (VCR cassettes — no lab required) +// --------------------------------------------------------------------------- + +// TestUnitKeyfactorCertificateStoreDataSource tests the certificate store +// data source read path using pre-recorded HTTP cassettes. +// +// To record cassettes against a live lab: +// +// RECORD_CASSETTES=1 make testunit +func TestUnitKeyfactorCertificateStoreDataSource(t *testing.T) { + resourceName := "data.keyfactor_certificate_store.test" + cassettePath := filepath.Join("testdata", "cassettes", "certificate_store_data_source") + var storeType, clientMachine, agentID, storePath string + + if os.Getenv("RECORD_CASSETTES") == "1" { + // Recording mode: auto-discover lab resources and save params for replay. + client := newTestClient(t) + agentID, clientMachine = discoverAgent(t, client) + storeType = discoverStoreTypeForAgent(t, client, agentID) + // Use a K8S-compatible path format: namespace/name (no leading slash). + storePath = "default/tf-unit-test-ds-1000001" + writeStoreTestParams(cassettePath, storeTestParams{ + StoreType: storeType, + ClientMachine: clientMachine, + AgentID: agentID, + StorePath: storePath, + }) + } else { + // Replay mode: load params recorded with the cassette. + params := readStoreTestParams(cassettePath) + storeType = params.StoreType + clientMachine = params.ClientMachine + agentID = params.AgentID + storePath = params.StorePath + } + + config := testAccCertStoreConfig(storeType, clientMachine, agentID, storePath) + "\n" + + testAccCertStoreDataSourceByID("keyfactor_certificate_store.test") + + factories, cleanup := newVCRProviderFactories(t, "certificate_store_data_source") + defer cleanup() + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: factories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("keyfactor_certificate_store.test", "id"), + resource.TestCheckResourceAttrSet(resourceName, "store_path"), + resource.TestCheckResourceAttrSet(resourceName, "store_type"), + resource.TestCheckResourceAttrSet(resourceName, "agent_id"), + resource.TestCheckResourceAttrSet(resourceName, "approved"), + ), + }, + }, + }) +} + +// --------------------------------------------------------------------------- +// Integration tests (auto-discovery) +// --------------------------------------------------------------------------- + +func TestIntKeyfactorCertificateStoreDataSource(t *testing.T) { + client := testAccIntegrationPreCheck(t) + agentID, clientMachine := discoverAgent(t, client) + storeType := discoverStoreTypeForAgent(t, client, agentID) + storePath := fmt.Sprintf("/tf-int-test-ds-%d", time.Now().UnixNano()) + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + // First create a store, then read it back via data source + Config: testAccCertStoreConfig(storeType, clientMachine, agentID, storePath) + "\n" + + testAccCertStoreDataSourceByID("keyfactor_certificate_store.test"), + Check: resource.ComposeAggregateTestCheckFunc( + // Resource checks + resource.TestCheckResourceAttrSet("keyfactor_certificate_store.test", "id"), + // Data source checks + resource.TestCheckResourceAttrSet("data.keyfactor_certificate_store.test", "store_path"), + resource.TestCheckResourceAttrSet("data.keyfactor_certificate_store.test", "store_type"), + resource.TestCheckResourceAttrSet("data.keyfactor_certificate_store.test", "agent_id"), + resource.TestCheckResourceAttrSet("data.keyfactor_certificate_store.test", "approved"), + resource.TestCheckResourceAttrSet("data.keyfactor_certificate_store.test", "properties.%"), + resource.TestCheckResourceAttrSet("data.keyfactor_certificate_store.test", "agent_assigned"), + ), + }, + }, + }) +} diff --git a/keyfactor/data_source_keyfactor_certificate_test.go b/keyfactor/data_source_keyfactor_certificate_test.go index fda3c709..2a818006 100644 --- a/keyfactor/data_source_keyfactor_certificate_test.go +++ b/keyfactor/data_source_keyfactor_certificate_test.go @@ -2,9 +2,11 @@ package keyfactor import ( "fmt" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "os" + "path/filepath" "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" ) func TestAccKeyfactorCertificateDataSource(t *testing.T) { @@ -66,3 +68,115 @@ func testAccDataSourceKeyfactorCertificateBasic(resourceName string, id string, `, resourceName, id, password) return output } + +// --------------------------------------------------------------------------- +// Unit tests (VCR cassettes — no lab required) +// --------------------------------------------------------------------------- + +// TestUnitKeyfactorCertificateDataSource tests the certificate data source +// read path using pre-recorded HTTP cassettes. +// +// To record cassettes against a live lab: +// +// KEYFACTOR_CERTIFICATE_ID= RECORD_CASSETTES=1 make testunit +func TestUnitKeyfactorCertificateDataSource(t *testing.T) { + // The data source reads an existing cert. In recording mode, first create one + // to get a stable ID, then read it back — just like TestIntKeyfactorCertificateDataSource. + // In replay mode, use the cert resource + data source combo config so the cassette + // interactions match (certificate ID is resolved via the resource reference). + resourceName := "data.keyfactor_certificate.test" + cassettePath := filepath.Join("testdata", "cassettes", "certificate_data_source") + + var config string + if os.Getenv("RECORD_CASSETTES") == "1" { + client := newTestClient(t) + ca := discoverCA(t, client) + cn := randomTestCN("tf-unit-ds") + enrollmentPattern := discoverEnrollmentPattern(t, client) + var certConfig string + var templateName string + if enrollmentPattern != "" { + certConfig = testAccCertPFXConfigEnrollmentPattern(enrollmentPattern, ca, cn) + } else { + templateName = discoverTemplate(t, client) + certConfig = testAccCertPFXConfig(templateName, ca, cn) + } + writeCertPFXTestParams(cassettePath, certPFXTestParams{ + TemplateName: templateName, + CA: ca, + EnrollmentPattern: enrollmentPattern, + CN: cn, + }) + config = certConfig + "\n" + testAccCertDataSourceByID("keyfactor_certificate.test") + } else { + params := readCertPFXTestParams(cassettePath) + var certConfig string + if params.EnrollmentPattern != "" { + certConfig = testAccCertPFXConfigEnrollmentPattern(params.EnrollmentPattern, params.CA, params.CN) + } else { + certConfig = testAccCertPFXConfig(params.TemplateName, params.CA, params.CN) + } + config = certConfig + "\n" + testAccCertDataSourceByID("keyfactor_certificate.test") + } + + factories, cleanup := newVCRProviderFactories(t, "certificate_data_source") + defer cleanup() + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: factories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("keyfactor_certificate.test", "serial_number"), + resource.TestCheckResourceAttrSet(resourceName, "serial_number"), + resource.TestCheckResourceAttrSet(resourceName, "thumbprint"), + resource.TestCheckResourceAttrSet(resourceName, "certificate_pem"), + resource.TestCheckResourceAttrSet(resourceName, "issuer_dn"), + ), + }, + }, + }) +} + +// --------------------------------------------------------------------------- +// Integration tests (auto-discovery) +// --------------------------------------------------------------------------- + +func TestIntKeyfactorCertificateDataSource(t *testing.T) { + client := testAccIntegrationPreCheck(t) + ca := discoverCA(t, client) + cn := randomTestCN("tf-int-ds") + + // Try enrollment pattern first (Command v25+), fall back to template+CA + enrollmentPattern := discoverEnrollmentPattern(t, client) + var certConfig string + if enrollmentPattern != "" { + certConfig = testAccCertPFXConfigEnrollmentPattern(enrollmentPattern, ca, cn) + } else { + templateName := discoverTemplate(t, client) + certConfig = testAccCertPFXConfig(templateName, ca, cn) + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + // First create a certificate, then read it back via data source + Config: certConfig + "\n" + + testAccCertDataSourceByID("keyfactor_certificate.test"), + Check: resource.ComposeAggregateTestCheckFunc( + // Resource checks + resource.TestCheckResourceAttrSet("keyfactor_certificate.test", "serial_number"), + // Data source checks + resource.TestCheckResourceAttrSet("data.keyfactor_certificate.test", "serial_number"), + resource.TestCheckResourceAttrSet("data.keyfactor_certificate.test", "issuer_dn"), + resource.TestCheckResourceAttrSet("data.keyfactor_certificate.test", "thumbprint"), + resource.TestCheckResourceAttrSet("data.keyfactor_certificate.test", "certificate_pem"), + resource.TestCheckResourceAttrSet("data.keyfactor_certificate.test", "certificate_chain"), + resource.TestCheckResourceAttrSet("data.keyfactor_certificate.test", "certificate_authority"), + ), + }, + }, + }) +} diff --git a/keyfactor/data_source_keyfactor_enrollment_pattern_test.go b/keyfactor/data_source_keyfactor_enrollment_pattern_test.go new file mode 100644 index 00000000..8895b863 --- /dev/null +++ b/keyfactor/data_source_keyfactor_enrollment_pattern_test.go @@ -0,0 +1,62 @@ +package keyfactor + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +// --------------------------------------------------------------------------- +// Integration tests (auto-discovery) +// --------------------------------------------------------------------------- + +func TestIntKeyfactorEnrollmentPatternDataSource(t *testing.T) { + client := testAccIntegrationPreCheck(t) + + patternName := discoverEnrollmentPattern(t, client) + if patternName == "" { + t.Skip("No enrollment patterns available (requires Command v25+)") + } + + // Test 1: Look up by name + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccEnrollmentPatternDataSourceConfig(patternName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("data.keyfactor_enrollment_pattern.test", "id"), + resource.TestCheckResourceAttr("data.keyfactor_enrollment_pattern.test", "name", patternName), + resource.TestCheckResourceAttrSet("data.keyfactor_enrollment_pattern.test", "allowed_enrollment_types"), + resource.TestCheckResourceAttrSet("data.keyfactor_enrollment_pattern.test", "template_default"), + ), + }, + }, + }) + + // Test 2: Look up by numeric ID (discover ID first via API) + patterns, err := client.GetEnrollmentPatterns() + if err != nil { + t.Fatalf("Failed to get enrollment patterns for ID lookup test: %s", err) + } + for _, p := range patterns { + if p.Name == patternName { + idStr := fmt.Sprintf("%d", p.ID) + t.Logf("Testing enrollment pattern lookup by ID: %s", idStr) + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccEnrollmentPatternDataSourceConfig(idStr), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("data.keyfactor_enrollment_pattern.test", "id"), + resource.TestCheckResourceAttr("data.keyfactor_enrollment_pattern.test", "name", patternName), + ), + }, + }, + }) + break + } + } +} diff --git a/keyfactor/data_source_keyfactor_oauth_security_claim_test.go b/keyfactor/data_source_keyfactor_oauth_security_claim_test.go index 91a62900..f4e408db 100644 --- a/keyfactor/data_source_keyfactor_oauth_security_claim_test.go +++ b/keyfactor/data_source_keyfactor_oauth_security_claim_test.go @@ -4,6 +4,7 @@ import ( "fmt" "testing" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" ) @@ -45,3 +46,43 @@ func testAccDataSourceKeyfactorOAuthSecurityClaim(resourceName string, claimType `, resourceName, claimType, claimValue, providerAuthScheme) return output } + +// --------------------------------------------------------------------------- +// Integration tests (auto-discovery) +// --------------------------------------------------------------------------- + +func TestIntKeyfactorOAuthSecurityClaimDataSource(t *testing.T) { + testAccIntegrationPreCheck(t) + + authScheme := discoverOAuthAuthScheme(t) + claimType := "OAuthSubject" + claimValue := acctest.RandomWithPrefix("tf-int-claim-ds") + + // Create a claim resource first, then read it back via data source + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` +resource "keyfactor_oauth_security_claim" "int_ds_setup" { + claim_type = "%s" + claim_value = "%s" + provider_authentication_scheme = "%s" + description = "Integration test claim for data source" +} + +data "keyfactor_oauth_security_claim" "test" { + claim_type = keyfactor_oauth_security_claim.int_ds_setup.claim_type + claim_value = keyfactor_oauth_security_claim.int_ds_setup.claim_value + provider_authentication_scheme = keyfactor_oauth_security_claim.int_ds_setup.provider_authentication_scheme +} +`, claimType, claimValue, authScheme), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("data.keyfactor_oauth_security_claim.test", "id"), + resource.TestCheckResourceAttr("data.keyfactor_oauth_security_claim.test", "claim_type", claimType), + resource.TestCheckResourceAttr("data.keyfactor_oauth_security_claim.test", "claim_value", claimValue), + ), + }, + }, + }) +} diff --git a/keyfactor/data_source_keyfactor_oauth_security_role_test.go b/keyfactor/data_source_keyfactor_oauth_security_role_test.go index 2a08be40..7e262e1f 100644 --- a/keyfactor/data_source_keyfactor_oauth_security_role_test.go +++ b/keyfactor/data_source_keyfactor_oauth_security_role_test.go @@ -4,6 +4,7 @@ import ( "fmt" "testing" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" ) @@ -44,3 +45,47 @@ func testAccDataSourceKeyfactorOAuthSecurityRole(resourceName string, roleName s `, resourceName, roleName) return output } + +// --------------------------------------------------------------------------- +// Integration tests (auto-discovery) +// --------------------------------------------------------------------------- + +func TestIntKeyfactorOAuthSecurityRoleDataSource(t *testing.T) { + testAccIntegrationPreCheck(t) + + roleName := acctest.RandomWithPrefix("tf-int-oauth-role-ds") + + // Create a role first, then read it back via data source + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` +data "keyfactor_permission_set" "global" { + name = "Global" +} + +resource "keyfactor_oauth_security_role" "int_ds_setup" { + name = "%s" + description = "Integration test role for data source" + permission_set_id = data.keyfactor_permission_set.global.id + email_address = "test@example.com" + permissions = [] +} + +data "keyfactor_oauth_security_role" "test" { + name = keyfactor_oauth_security_role.int_ds_setup.name +} +`, roleName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("data.keyfactor_oauth_security_role.test", "id"), + resource.TestCheckResourceAttr("data.keyfactor_oauth_security_role.test", "name", roleName), + resource.TestCheckResourceAttrSet("data.keyfactor_oauth_security_role.test", "description"), + resource.TestCheckResourceAttrSet("data.keyfactor_oauth_security_role.test", "permission_set_id"), + resource.TestCheckResourceAttrSet("data.keyfactor_oauth_security_role.test", "email_address"), + resource.TestCheckResourceAttrSet("data.keyfactor_oauth_security_role.test", "permissions.#"), + ), + }, + }, + }) +} diff --git a/keyfactor/data_source_keyfactor_permission_set_test.go b/keyfactor/data_source_keyfactor_permission_set_test.go index 4228fadf..9f08a1fb 100644 --- a/keyfactor/data_source_keyfactor_permission_set_test.go +++ b/keyfactor/data_source_keyfactor_permission_set_test.go @@ -2,6 +2,7 @@ package keyfactor import ( "fmt" + "regexp" "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" @@ -36,3 +37,26 @@ func testAccDataSourceKeyfactorPermissionSet(resourceName string, permissionSetN `, resourceName, permissionSetName) return output } + +// --------------------------------------------------------------------------- +// Integration tests (auto-discovery) +// --------------------------------------------------------------------------- + +func TestIntKeyfactorPermissionSetDataSource(t *testing.T) { + testAccIntegrationPreCheck(t) + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccDataSourceKeyfactorPermissionSet("keyfactor_permission_set", "Global"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("data.keyfactor_permission_set.test", "id"), + resource.TestCheckResourceAttrSet("data.keyfactor_permission_set.test", "permissions.#"), + resource.TestMatchResourceAttr("data.keyfactor_permission_set.test", "permissions.#", regexp.MustCompile(`^[1-9][0-9]*$`)), + resource.TestCheckResourceAttr("data.keyfactor_permission_set.test", "name", "Global"), + ), + }, + }, + }) +} diff --git a/keyfactor/data_source_keyfactor_security_identity_test.go b/keyfactor/data_source_keyfactor_security_identity_test.go index 5a8435fd..2b7c91ad 100644 --- a/keyfactor/data_source_keyfactor_security_identity_test.go +++ b/keyfactor/data_source_keyfactor_security_identity_test.go @@ -40,3 +40,29 @@ func testAccKeyfactorDataSourceSecurityIdentityBasic(identityName string) string } `, identityName) } + +// --------------------------------------------------------------------------- +// Integration tests (auto-discovery) +// --------------------------------------------------------------------------- + +func TestIntKeyfactorSecurityIdentityDataSource(t *testing.T) { + client := testAccIntegrationPreCheck(t) + + accountName := discoverSecurityIdentity(t, client) + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccKeyfactorDataSourceSecurityIdentityBasic(accountName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("data.keyfactor_identity.test", "id"), + resource.TestCheckResourceAttrSet("data.keyfactor_identity.test", "account_name"), + resource.TestCheckResourceAttrSet("data.keyfactor_identity.test", "roles.#"), + resource.TestCheckResourceAttrSet("data.keyfactor_identity.test", "identity_type"), + resource.TestCheckResourceAttrSet("data.keyfactor_identity.test", "valid"), + ), + }, + }, + }) +} diff --git a/keyfactor/data_source_keyfactor_security_role_test.go b/keyfactor/data_source_keyfactor_security_role_test.go index 76f68c10..dec02b70 100644 --- a/keyfactor/data_source_keyfactor_security_role_test.go +++ b/keyfactor/data_source_keyfactor_security_role_test.go @@ -36,3 +36,26 @@ func testAccDataSourceKeyfactorSecurityRoleBasic(resourceName string) string { } `, resourceName) } + +// --------------------------------------------------------------------------- +// Integration tests (auto-discovery) +// --------------------------------------------------------------------------- + +func TestIntKeyfactorSecurityRoleDataSource(t *testing.T) { + testAccIntegrationPreCheck(t) + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccDataSourceKeyfactorSecurityRoleBasic("Administrator"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("data.keyfactor_role.test", "id"), + resource.TestCheckResourceAttr("data.keyfactor_role.test", "name", "Administrator"), + resource.TestCheckResourceAttrSet("data.keyfactor_role.test", "description"), + resource.TestCheckResourceAttrSet("data.keyfactor_role.test", "permissions.#"), + ), + }, + }, + }) +} diff --git a/keyfactor/data_source_keyfactor_template_test.go b/keyfactor/data_source_keyfactor_template_test.go index b9ebd6f3..209243ad 100644 --- a/keyfactor/data_source_keyfactor_template_test.go +++ b/keyfactor/data_source_keyfactor_template_test.go @@ -51,3 +51,34 @@ func testAccDataSourceKeyfactorCertificateTemplateBasic(resourceName string) str } `, resourceName) } + +// --------------------------------------------------------------------------- +// Integration tests (auto-discovery) +// --------------------------------------------------------------------------- + +func TestIntKeyfactorCertificateTemplateDataSource(t *testing.T) { + client := testAccIntegrationPreCheck(t) + shortName := discoverTemplate(t, client) + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccDataSourceKeyfactorCertificateTemplateBasic(shortName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.keyfactor_certificate_template.test", "short_name", shortName), + resource.TestCheckResourceAttrSet("data.keyfactor_certificate_template.test", "id"), + resource.TestCheckResourceAttrSet("data.keyfactor_certificate_template.test", "name"), + resource.TestCheckResourceAttrSet("data.keyfactor_certificate_template.test", "oid"), + resource.TestCheckResourceAttrSet("data.keyfactor_certificate_template.test", "key_size"), + resource.TestCheckResourceAttrSet("data.keyfactor_certificate_template.test", "key_type"), + resource.TestCheckResourceAttrSet("data.keyfactor_certificate_template.test", "forest_root"), + resource.TestCheckResourceAttrSet("data.keyfactor_certificate_template.test", "requires_approval"), + resource.TestCheckResourceAttrSet("data.keyfactor_certificate_template.test", "key_usage"), + resource.TestCheckResourceAttrSet("data.keyfactor_certificate_template.test", "allowed_enrollment_types"), + resource.TestCheckResourceAttrSet("data.keyfactor_certificate_template.test", "template_regexes.#"), + ), + }, + }, + }) +} diff --git a/keyfactor/helpers.go b/keyfactor/helpers.go index e43ad460..cf2d3dc5 100644 --- a/keyfactor/helpers.go +++ b/keyfactor/helpers.go @@ -18,9 +18,11 @@ import ( rsa2 "crypto/rsa" "crypto/x509" "encoding/base64" + "encoding/hex" "encoding/json" "encoding/pem" "fmt" + "math/big" mathRand "math/rand" "net" @@ -1826,3 +1828,44 @@ func convertIntArrayToTerraform(lengths any) []attr.Value { } return result } + +// normalizeSerialNumber converts a serial number to a canonical uppercase hex format. +// It handles both hex strings (from the Keyfactor API) and decimal strings (from big.Int.String()). +func normalizeSerialNumber(sn string) string { + if sn == "" || sn == "" { + return "" + } + + // Strip any separators like colons or spaces + cleaned := strings.ReplaceAll(strings.ReplaceAll(sn, ":", ""), " ", "") + + // Check if the string contains any hex letter characters (a-f, A-F). + // If it does, it's unambiguously a hex string. + hasHexLetters := strings.ContainsAny(cleaned, "abcdefABCDEF") + + if hasHexLetters { + // Validate it's actually valid hex + if _, err := hex.DecodeString(cleaned); err == nil && len(cleaned)%2 == 0 { + return strings.ToUpper(cleaned) + } + } + + // Try to parse as decimal (big.Int.String() output or digit-only serial) + n := new(big.Int) + if _, ok := n.SetString(cleaned, 10); ok { + return strings.ToUpper(fmt.Sprintf("%X", n)) + } + + // Fallback for hex strings with odd length or other edge cases + if _, err := hex.DecodeString(cleaned); err == nil { + return strings.ToUpper(cleaned) + } + + // Last resort: return uppercased as-is + return strings.ToUpper(sn) +} + +// normalizeThumbprint normalizes a certificate thumbprint to lowercase hex. +func normalizeThumbprint(tp string) string { + return strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(tp, ":", ""), " ", "")) +} diff --git a/keyfactor/helpers_test.go b/keyfactor/helpers_test.go index 2c0d99bb..3027c8e4 100644 --- a/keyfactor/helpers_test.go +++ b/keyfactor/helpers_test.go @@ -252,3 +252,44 @@ func TestAddOAuthSecurityClaimToRole(t *testing.T) { assert.Equal(t, 1, len(result)) // Should not have added duplicate claim }) } + +func TestNormalizeSerialNumber(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"hex uppercase", "77BFBF38D702AA41CE4EE2B1C7713CE6705B9D2E", "77BFBF38D702AA41CE4EE2B1C7713CE6705B9D2E"}, + {"hex lowercase", "77bfbf38d702aa41ce4ee2b1c7713ce6705b9d2e", "77BFBF38D702AA41CE4EE2B1C7713CE6705B9D2E"}, + {"decimal from big.Int", "683646001849179623094227348073945305115006836014", "77BFBF38D702AA41CE4EE2B1C7713CE6705B9D2E"}, + {"empty string", "", ""}, + {"nil string", "", ""}, + {"hex with colons", "77:BF:BF:38:D7:02:AA:41", "77BFBF38D702AA41"}, + {"small decimal", "255", "FF"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizeSerialNumber(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestNormalizeThumbprint(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"uppercase", "746F0BCE6AF15060042380D65D8B438CF27C6192", "746f0bce6af15060042380d65d8b438cf27c6192"}, + {"lowercase", "746f0bce6af15060042380d65d8b438cf27c6192", "746f0bce6af15060042380d65d8b438cf27c6192"}, + {"with colons", "74:6F:0B:CE:6A:F1", "746f0bce6af1"}, + {"empty", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizeThumbprint(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/keyfactor/models.go b/keyfactor/models.go index 8ac5acdf..bf2bde66 100644 --- a/keyfactor/models.go +++ b/keyfactor/models.go @@ -6,7 +6,11 @@ import ( ) // CommandAgent represents an agent in the Keyfactor system. +// +// NOTE: TfId (tfsdk:"id") is a read-only mirror of AgentId, required by the SDKv2 test harness. +// Do NOT read from or write to TfId directly; it is populated automatically via syncTfId(). type CommandAgent struct { + TfId types.String `tfsdk:"id"` // Read-only mirror of AgentId for test framework. AgentId types.String `tfsdk:"agent_id"` // Unique identifier for the agent. AgentIdentifier types.String `tfsdk:"agent_identifier"` // Identifier for the agent in Keyfactor. ClientMachine types.String `tfsdk:"client_machine"` // Machine name where the agent is running. @@ -25,6 +29,8 @@ type CommandAgent struct { LastErrorMessage types.String `tfsdk:"last_error_message"` // Last error message reported by the agent. } +func (a *CommandAgent) syncTfId() { a.TfId = a.AgentId } + // SecurityIdentity represents an identity with security roles in Keyfactor. type SecurityIdentity struct { ID types.Int64 `tfsdk:"id"` // Unique ID of the security identity. @@ -97,8 +103,14 @@ type OAuthSecurityRoleClaimAssociation struct { } // CommandCertificate represents a certificate entity in Keyfactor. +// +// NOTE: This struct has two ID-related fields due to a Terraform testing framework requirement: +// - ID (tfsdk:"identifier") — The actual Keyfactor certificate identifier. Used throughout provider code. +// - TfId (tfsdk:"id") — Read-only mirror of ID, required by the SDKv2 test harness. +// Do NOT read from or write to TfId directly; it is populated automatically via syncTfId(). type CommandCertificate struct { - ID types.String `tfsdk:"identifier"` // Unique identifier of the certificate. + TfId types.String `tfsdk:"id"` // Read-only mirror of ID for Terraform test framework. Use syncTfId() only. + ID types.String `tfsdk:"identifier"` // Unique identifier of the certificate. // CSR Request Fields CSR types.String `tfsdk:"csr"` // Certificate Signing Request (CSR) content. @@ -166,9 +178,11 @@ type CommandCertificate struct { RevokeOnDestroy types.Bool `tfsdk:"revoke_on_destroy"` // RevokeOnDestroy indicates whether the certificate should be revoked when the resource is destroyed. } -// CommandCertificate represents a certificate entity in Keyfactor. +// DataCommandCertificate represents a certificate data source entity in Keyfactor. +// See CommandCertificate for notes on TfId vs ID. type DataCommandCertificate struct { - ID types.String `tfsdk:"identifier"` // Unique identifier of the certificate. + TfId types.String `tfsdk:"id"` // Read-only mirror of ID for Terraform test framework. Use syncTfId() only. + ID types.String `tfsdk:"identifier"` // Unique identifier of the certificate. // CSR Request Fields CSR types.String `tfsdk:"csr"` // Certificate Signing Request (CSR) content. @@ -235,6 +249,14 @@ type DataCommandCertificate struct { RevocationEffDate types.String `tfsdk:"revocation_effective_date"` // RevocationEffDate represents the date and time when the revocation of the certificate becomes effective. } +// syncTfId copies the ID (identifier) value to the TfId (id) field. +// Must be called before every State.Set() on a CommandCertificate. +func (c *CommandCertificate) syncTfId() { c.TfId = c.ID } + +// syncTfId copies the ID (identifier) value to the TfId (id) field. +// Must be called before every State.Set() on a DataCommandCertificate. +func (c *DataCommandCertificate) syncTfId() { c.TfId = c.ID } + type CertificateAutoRenewConfig struct { ForceRenewal types.Bool `tfsdk:"force_renewal"` // ForceRenewal indicates if the certificate should be forcefully renewed, regardless of its current state. RenewDays types.Int64 `tfsdk:"renew_days"` // RenewDays specifies the number of days before expiration diff --git a/keyfactor/provider.go b/keyfactor/provider.go index 6ed56cdd..7deb2b41 100644 --- a/keyfactor/provider.go +++ b/keyfactor/provider.go @@ -27,6 +27,13 @@ type provider struct { configured bool client *api.Client sdkClient *keyfactor.APIClient + // testHook is called after Configure to allow tests to inject a custom + // transport (e.g. a VCR recorder). It is nil in production. + testHook func(*provider) + // testAuth, if non-nil, bypasses Configure's auth/network logic entirely. + // The pre-built AuthConfig (e.g. a VCR-backed stub) is used for both clients. + // Used in unit tests with VCR cassettes. + testAuth api.AuthConfig } const ( @@ -207,6 +214,72 @@ func (p *provider) GetSchema(ctx context.Context) (tfsdk.Schema, diag.Diagnostic DefaultValMsg+EnvVarUsage, false, auth_providers.EnvKeyfactorSkipVerify, ), }, + "kerberos_realm": { + Type: types.StringType, + Optional: true, + Description: fmt.Sprintf( + "Kerberos realm for Kerberos/SPNEGO authentication (e.g. EXAMPLE.COM). "+ + EnvVarUsage, auth_providers.EnvKeyfactorKrbRealm, + ), + }, + "kerberos_keytab": { + Type: types.StringType, + Optional: true, + Description: fmt.Sprintf( + "Path to the Kerberos keytab file for keytab-based authentication. "+ + EnvVarUsage, auth_providers.EnvKeyfactorKrbKeytab, + ), + }, + "kerberos_config": { + Type: types.StringType, + Optional: true, + Description: fmt.Sprintf( + "Path to the krb5.conf Kerberos configuration file. "+ + fmt.Sprintf("Defaults to %s. ", auth_providers.DefaultKrbConfigPath)+ + EnvVarUsage, auth_providers.EnvKeyfactorKrbConfig, + ), + }, + "kerberos_ccache": { + Type: types.StringType, + Optional: true, + Description: fmt.Sprintf( + "Path to the Kerberos credential cache file for ccache-based authentication. "+ + EnvVarUsage, auth_providers.EnvKeyfactorKrbCCache, + ), + }, + "kerberos_spn": { + Type: types.StringType, + Optional: true, + Description: fmt.Sprintf( + "Service Principal Name for Kerberos authentication. Auto-generated from hostname if omitted. "+ + EnvVarUsage, auth_providers.EnvKeyfactorKrbSPN, + ), + }, + "kerberos_username": { + Type: types.StringType, + Optional: true, + Description: fmt.Sprintf( + "Kerberos principal username for password or keytab-based authentication. Accepts user@REALM format. "+ + EnvVarUsage, auth_providers.EnvKeyfactorKrbUsername, + ), + }, + "kerberos_password": { + Type: types.StringType, + Optional: true, + Sensitive: true, + Description: fmt.Sprintf( + "Password for password-based Kerberos authentication. "+ + EnvVarUsage, auth_providers.EnvKeyfactorKrbPassword, + ), + }, + "kerberos_disable_pafxfast": { + Type: types.BoolType, + Optional: true, + Description: fmt.Sprintf( + "Disable PA-FX-FAST for Active Directory Kerberos compatibility. "+ + DefaultValMsg, false, + ), + }, }, MarkdownDescription: ` ## Overview @@ -246,7 +319,7 @@ at https://support.keyfactor.com/ and Keyfactor will address issues as resources | 9.x | 1.0.x | `, Description: "The Keyfactor Command provider allows you to authenticate to Keyfactor Command using a username" + - " and password, or an OAuth credentials.", + " and password, OAuth credentials, or Kerberos/SPNEGO.", }, nil } @@ -272,6 +345,15 @@ type providerData struct { PFXPasswordUppers types.Number `tfsdk:"pfx_password_min_uppercases"` PFXPasswordNumbers types.Number `tfsdk:"pfx_password_min_digits"` PFXPasswordSpecials types.Number `tfsdk:"pfx_password_max_special_chars"` + // Kerberos auth fields + KerberosRealm types.String `tfsdk:"kerberos_realm"` + KerberosKeytab types.String `tfsdk:"kerberos_keytab"` + KerberosConfig types.String `tfsdk:"kerberos_config"` + KerberosCCache types.String `tfsdk:"kerberos_ccache"` + KerberosSPN types.String `tfsdk:"kerberos_spn"` + KerberosUsername types.String `tfsdk:"kerberos_username"` + KerberosPassword types.String `tfsdk:"kerberos_password"` + KerberosDisablePAFXFast types.Bool `tfsdk:"kerberos_disable_pafxfast"` } func (p *provider) getServerConfig(c *providerData, ctx context.Context) (*auth_providers.Server, diag.Diagnostics) { @@ -442,13 +524,103 @@ func (p *provider) getServerConfig(c *providerData, ctx context.Context) (*auth_ audience = c.Audience.Value } + // Kerberos auth provider config + tflog.Debug(ctx, "Resolving Kerberos realm from environment variables") + krbRealm, krOk := os.LookupEnv(auth_providers.EnvKeyfactorKrbRealm) + if !krOk || c.KerberosRealm.Value != "" { + krbRealm = c.KerberosRealm.Value + if krbRealm != "" { + krOk = true + } + } + + tflog.Debug(ctx, "Resolving Kerberos keytab from environment variables") + krbKeytab, ktOk := os.LookupEnv(auth_providers.EnvKeyfactorKrbKeytab) + if !ktOk || c.KerberosKeytab.Value != "" { + krbKeytab = c.KerberosKeytab.Value + if krbKeytab != "" { + ktOk = true + } + } + + tflog.Debug(ctx, "Resolving Kerberos ccache from environment variables") + krbCCache, kccOk := os.LookupEnv(auth_providers.EnvKeyfactorKrbCCache) + if !kccOk || c.KerberosCCache.Value != "" { + krbCCache = c.KerberosCCache.Value + if krbCCache != "" { + kccOk = true + } + } + + tflog.Debug(ctx, "Resolving Kerberos config path from environment variables") + krbConfig, _ := os.LookupEnv(auth_providers.EnvKeyfactorKrbConfig) + if c.KerberosConfig.Value != "" { + krbConfig = c.KerberosConfig.Value + } + + tflog.Debug(ctx, "Resolving Kerberos SPN from environment variables") + krbSPN, _ := os.LookupEnv(auth_providers.EnvKeyfactorKrbSPN) + if c.KerberosSPN.Value != "" { + krbSPN = c.KerberosSPN.Value + } + + tflog.Debug(ctx, "Resolving Kerberos username from environment variables") + krbUsername, _ := os.LookupEnv(auth_providers.EnvKeyfactorKrbUsername) + if c.KerberosUsername.Value != "" { + krbUsername = c.KerberosUsername.Value + } + + tflog.Debug(ctx, "Resolving Kerberos password from environment variables") + krbPassword, _ := os.LookupEnv(auth_providers.EnvKeyfactorKrbPassword) + if c.KerberosPassword.Value != "" { + krbPassword = c.KerberosPassword.Value + } + if krbPassword != "" { + ctx = tflog.MaskFieldValuesWithFieldKeys(ctx, "kerberos_password", krbPassword) + } + + krbDisablePAFXFast := c.KerberosDisablePAFXFast.Value + if disableStr, ok := os.LookupEnv(auth_providers.EnvKeyfactorKrbDisablePAFXFast); ok && !krbDisablePAFXFast { + krbDisablePAFXFast = disableStr == "true" || disableStr == "1" + } + + isKerberos := krOk || ktOk || kccOk + ctx = tflog.SetField(ctx, "is_kerberos", isKerberos) isBasicAuth := uOk && pOk ctx = tflog.SetField(ctx, "is_basic_auth", isBasicAuth) isOAuth := (cOk && csOk && tOk) || atOk ctx = tflog.SetField(ctx, "is_oauth", isOAuth) tflog.Debug(ctx, "Beginning authentication") - if isBasicAuth { + if isKerberos { + LogFunctionCall(ctx, "krbAuthConfig.Authenticate()") + krbAuthConfig := &auth_providers.CommandAuthConfigKerberos{} + _ = krbAuthConfig.CommandAuthConfig. + WithCommandHostName(hostname). + WithCommandAPIPath(apiPath). + WithSkipVerify(skipVerifyBool). + WithCommandCACert(caCert). + WithClientTimeout(int(clientTimeout)) + kErr := krbAuthConfig. + WithUsername(krbUsername). + WithPassword(krbPassword). + WithRealm(krbRealm). + WithKeytabPath(krbKeytab). + WithConfigPath(krbConfig). + WithCCachePath(krbCCache). + WithSPN(krbSPN). + WithDisablePAFXFast(krbDisablePAFXFast). + Authenticate() + LogFunctionReturned(ctx, "krbAuthConfig.Authenticate()") + if kErr != nil { + errMsg := fmt.Errorf("unable to authenticate with Kerberos credentials: %w", kErr) + tflog.Error(ctx, errMsg.Error()) + d.AddError("kerberos authentication error", errMsg.Error()) + return nil, d + } + LogFunctionExit(ctx, "getServerConfig()") + return krbAuthConfig.GetServerConfig(), d + } else if isBasicAuth { LogFunctionCall(ctx, "basicAuthNoParamsConfig.Authenticate") basicAuthNoParamsConfig.WithCommandHostName(hostname). WithCommandAPIPath(apiPath). @@ -521,6 +693,21 @@ func (p *provider) Configure( req tfsdk.ConfigureProviderRequest, resp *tfsdk.ConfigureProviderResponse, ) { + // Test mode: bypass all auth/network logic and use the pre-built VCR auth client. + if p.testAuth != nil { + PFXPasswordLength = DEFAULT_PFX_PASSWORD_LEN + PFXPasswordDigits = DEFAULT_PFX_PASSWORD_NUMBER_COUNT + PFXPasswordSpecialChars = DEFAULT_PFX_PASSWORD_SPECIAL_CHAR_COUNT + PFXPasswordUpperCases = DEFAULT_PFX_PASSWORD_UPPER_COUNT + p.client = api.NewKeyfactorClientWithAuth(p.testAuth, &ctx) + p.sdkClient = keyfactor.NewAPIClientWithAuth(p.testAuth) + p.configured = true + if p.testHook != nil { + p.testHook(p) + } + return + } + // Retrieve provider data from configuration var config providerData @@ -593,6 +780,11 @@ func (p *provider) Configure( p.configured = true continue } + + // Allow tests to inject a custom transport (e.g. VCR recorder). + if p.testHook != nil { + p.testHook(p) + } } // GetResources - Defines provider resources @@ -607,6 +799,7 @@ func (p *provider) GetResources(_ context.Context) (map[string]tfsdk.ResourceTyp "keyfactor_oauth_security_role": resourceOAuthSecurityRoleType{}, "keyfactor_role": resourceSecurityRoleType{}, "keyfactor_template_role_binding": resourceCertificateTemplateRoleBindingType{}, + "keyfactor_application": resourceApplicationType{}, }, nil } @@ -623,6 +816,7 @@ func (p *provider) GetDataSources(_ context.Context) (map[string]tfsdk.DataSourc "keyfactor_permission_set": dataSourcePermissionSetType{}, "keyfactor_role": dataSourceSecurityRoleType{}, "keyfactor_identity": dataSourceSecurityIdentityType{}, + "keyfactor_application": dataSourceApplicationType{}, }, nil } diff --git a/keyfactor/provider_test.go b/keyfactor/provider_test.go index d4ae524d..473ff19c 100644 --- a/keyfactor/provider_test.go +++ b/keyfactor/provider_test.go @@ -1,8 +1,6 @@ package keyfactor import ( - "testing" - "github.com/hashicorp/terraform-plugin-framework/providerserver" "github.com/hashicorp/terraform-plugin-go/tfprotov6" ) @@ -14,9 +12,3 @@ import ( var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){ "keyfactor": providerserver.NewProtocol6WithError(New()), } - -func testAccPreCheck(t *testing.T) { - // You can add code here to run prior to any test case execution, for example assertions - // about the appropriate environment variables being set are common to see in a pre-check - // function. -} diff --git a/keyfactor/resource_keyfactor_application.go b/keyfactor/resource_keyfactor_application.go new file mode 100644 index 00000000..6e6491bd --- /dev/null +++ b/keyfactor/resource_keyfactor_application.go @@ -0,0 +1,314 @@ +package keyfactor + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/Keyfactor/keyfactor-go-client/v3/api" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +type resourceApplicationType struct{} + +func (r resourceApplicationType) GetSchema(_ context.Context) (tfsdk.Schema, diag.Diagnostics) { + return tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "id": { + Type: types.StringType, + Computed: true, + Description: "Keyfactor Command application ID.", + PlanModifiers: []tfsdk.AttributePlanModifier{tfsdk.UseStateForUnknown()}, + }, + "name": { + Type: types.StringType, + Required: true, + Description: "Name of the application (certificate store container).", + }, + "overwrite_schedules": { + Type: types.BoolType, + Optional: true, + Computed: true, + Description: "When true, the application schedule overwrites the schedules of all member certificate stores.", + PlanModifiers: []tfsdk.AttributePlanModifier{tfsdk.UseStateForUnknown()}, + }, + "schedule_interval_minutes": { + Type: types.Int64Type, + Optional: true, + Description: "Inventory schedule interval in minutes. Set to a positive integer to use an interval-based schedule. Mutually exclusive with schedule_daily_time.", + }, + "schedule_daily_time": { + Type: types.StringType, + Optional: true, + Description: "Inventory schedule daily time as an ISO 8601 datetime string (e.g. '2023-11-25T23:30:00Z'). Mutually exclusive with schedule_interval_minutes.", + }, + "store_count": { + Type: types.Int64Type, + Computed: true, + Description: "Number of certificate stores currently assigned to this application.", + PlanModifiers: []tfsdk.AttributePlanModifier{tfsdk.UseStateForUnknown()}, + }, + }, + MarkdownDescription: ` +Manages a Keyfactor Command Application (certificate store container). + +Applications group certificate stores together and define an optional inventory schedule that applies to all member stores. + +> [!NOTE] +> Applications are only available in Keyfactor Command v25.0+ +`, + }, nil +} + +func (r resourceApplicationType) NewResource(ctx context.Context, p tfsdk.Provider) (tfsdk.Resource, diag.Diagnostics) { + return resourceApplication{ + p: *(p.(*provider)), + }, nil +} + +type resourceApplication struct { + p provider +} + +// KeyfactorApplication is the Terraform state model for keyfactor_application. +type KeyfactorApplication struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + OverwriteSchedules types.Bool `tfsdk:"overwrite_schedules"` + ScheduleIntervalMins types.Int64 `tfsdk:"schedule_interval_minutes"` + ScheduleDailyTime types.String `tfsdk:"schedule_daily_time"` + StoreCount types.Int64 `tfsdk:"store_count"` +} + +// buildApplicationSchedule converts Terraform model fields into an API schedule object. +func buildApplicationSchedule(intervalMins types.Int64, dailyTime types.String) *api.ApplicationSchedule { + if !intervalMins.Null && !intervalMins.Unknown && intervalMins.Value > 0 { + return &api.ApplicationSchedule{ + Interval: &api.ApplicationScheduleInterval{Minutes: int(intervalMins.Value)}, + } + } + if !dailyTime.Null && !dailyTime.Unknown && dailyTime.Value != "" { + return &api.ApplicationSchedule{ + Daily: &api.ApplicationScheduleDaily{Time: dailyTime.Value}, + } + } + return nil +} + +// flattenApplicationSchedule converts an API schedule object into Terraform model fields. +func flattenApplicationSchedule(sched *api.ApplicationSchedule) (types.Int64, types.String) { + if sched == nil { + return types.Int64{Null: true}, types.String{Null: true} + } + if sched.Interval != nil { + return types.Int64{Value: int64(sched.Interval.Minutes)}, types.String{Null: true} + } + if sched.Daily != nil { + return types.Int64{Null: true}, types.String{Value: sched.Daily.Time} + } + return types.Int64{Null: true}, types.String{Null: true} +} + +func (r resourceApplication) Create(ctx context.Context, request tfsdk.CreateResourceRequest, response *tfsdk.CreateResourceResponse) { + LogFunctionEntry(ctx, "resourceApplication.Create") + + var plan KeyfactorApplication + diags := request.Plan.Get(ctx, &plan) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, fmt.Sprintf("Creating application %q", plan.Name.Value)) + + createReq := &api.ApplicationCreateRequest{ + Name: plan.Name.Value, + OverwriteSchedules: !plan.OverwriteSchedules.Null && plan.OverwriteSchedules.Value, + Schedule: buildApplicationSchedule(plan.ScheduleIntervalMins, plan.ScheduleDailyTime), + } + + app, err := r.p.client.CreateApplication(createReq) + if err != nil { + response.Diagnostics.AddError( + "Error creating application.", + "Could not create application in Keyfactor Command: "+err.Error(), + ) + return + } + + state := applicationResponseToState(app) + // OverwriteSchedules is not returned by the API; preserve the plan value. + // Default to false if the user did not specify it (plan is Unknown or Null). + if plan.OverwriteSchedules.Unknown || plan.OverwriteSchedules.Null { + state.OverwriteSchedules = types.Bool{Value: false} + } else { + state.OverwriteSchedules = plan.OverwriteSchedules + } + + diags = response.State.Set(ctx, &state) + response.Diagnostics.Append(diags...) + LogFunctionExit(ctx, "resourceApplication.Create") +} + +func (r resourceApplication) Read(ctx context.Context, request tfsdk.ReadResourceRequest, response *tfsdk.ReadResourceResponse) { + LogFunctionEntry(ctx, "resourceApplication.Read") + + var state KeyfactorApplication + diags := request.State.Get(ctx, &state) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + idStr := state.ID.Value + id, err := strconv.Atoi(idStr) + if err != nil { + response.Diagnostics.AddError( + "Invalid application ID.", + fmt.Sprintf("Could not parse application ID %q: %s", idStr, err.Error()), + ) + return + } + + tflog.Info(ctx, fmt.Sprintf("Reading application ID %d", id)) + + app, err := r.p.client.GetApplication(id) + if err != nil { + response.Diagnostics.AddError( + "Error reading application.", + fmt.Sprintf("Could not read application %d from Keyfactor Command: %s", id, err.Error()), + ) + return + } + + newState := applicationResponseToState(app) + // OverwriteSchedules is not returned by the API; preserve the existing state value. + newState.OverwriteSchedules = state.OverwriteSchedules + // The server normalizes daily schedule times by updating the date to the next + // occurrence. Preserve the state's date string if only the date portion changed + // (same time-of-day) to avoid infinite plan drift. + if !state.ScheduleDailyTime.Null && !state.ScheduleDailyTime.Unknown && + !newState.ScheduleDailyTime.Null && !newState.ScheduleDailyTime.Unknown { + if dailyTimePortion(state.ScheduleDailyTime.Value) == dailyTimePortion(newState.ScheduleDailyTime.Value) { + newState.ScheduleDailyTime = state.ScheduleDailyTime + } + } + + diags = response.State.Set(ctx, &newState) + response.Diagnostics.Append(diags...) + LogFunctionExit(ctx, "resourceApplication.Read") +} + +func (r resourceApplication) Update(ctx context.Context, request tfsdk.UpdateResourceRequest, response *tfsdk.UpdateResourceResponse) { + LogFunctionEntry(ctx, "resourceApplication.Update") + + var plan KeyfactorApplication + diags := request.Plan.Get(ctx, &plan) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + var state KeyfactorApplication + diags = request.State.Get(ctx, &state) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + id, err := strconv.Atoi(state.ID.Value) + if err != nil { + response.Diagnostics.AddError( + "Invalid application ID.", + fmt.Sprintf("Could not parse application ID %q: %s", state.ID.Value, err.Error()), + ) + return + } + + tflog.Info(ctx, fmt.Sprintf("Updating application ID %d", id)) + + updateReq := &api.ApplicationUpdateRequest{ + Name: plan.Name.Value, + OverwriteSchedules: !plan.OverwriteSchedules.Null && plan.OverwriteSchedules.Value, + Schedule: buildApplicationSchedule(plan.ScheduleIntervalMins, plan.ScheduleDailyTime), + } + + app, err := r.p.client.UpdateApplication(id, updateReq) + if err != nil { + response.Diagnostics.AddError( + "Error updating application.", + fmt.Sprintf("Could not update application %d in Keyfactor Command: %s", id, err.Error()), + ) + return + } + + newState := applicationResponseToState(app) + // OverwriteSchedules is not returned by the API; preserve the plan value. + newState.OverwriteSchedules = plan.OverwriteSchedules + + diags = response.State.Set(ctx, &newState) + response.Diagnostics.Append(diags...) + LogFunctionExit(ctx, "resourceApplication.Update") +} + +func (r resourceApplication) Delete(ctx context.Context, request tfsdk.DeleteResourceRequest, response *tfsdk.DeleteResourceResponse) { + LogFunctionEntry(ctx, "resourceApplication.Delete") + + var state KeyfactorApplication + diags := request.State.Get(ctx, &state) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + id, err := strconv.Atoi(state.ID.Value) + if err != nil { + response.Diagnostics.AddError( + "Invalid application ID.", + fmt.Sprintf("Could not parse application ID %q: %s", state.ID.Value, err.Error()), + ) + return + } + + tflog.Info(ctx, fmt.Sprintf("Deleting application ID %d", id)) + + err = r.p.client.DeleteApplication(id) + if err != nil { + response.Diagnostics.AddError( + "Error deleting application.", + fmt.Sprintf("Could not delete application %d from Keyfactor Command: %s", id, err.Error()), + ) + return + } + + LogFunctionExit(ctx, "resourceApplication.Delete") +} + +// dailyTimePortion extracts the time-of-day portion from an ISO 8601 datetime string. +// For example, "2023-11-25T23:30:00Z" returns "T23:30:00Z". +// This is used to compare daily schedule times ignoring the date portion, since the +// server normalizes the date to the next scheduled occurrence. +func dailyTimePortion(datetime string) string { + if idx := strings.Index(datetime, "T"); idx >= 0 { + return datetime[idx:] + } + return datetime +} + +// applicationResponseToState converts an API response to a Terraform state model. +func applicationResponseToState(app *api.ApplicationResponse) KeyfactorApplication { + intervalMins, dailyTime := flattenApplicationSchedule(app.Schedule) + return KeyfactorApplication{ + ID: types.String{Value: strconv.Itoa(app.Id)}, + Name: types.String{Value: app.Name}, + OverwriteSchedules: types.Bool{Value: app.OverwriteSchedules}, + ScheduleIntervalMins: intervalMins, + ScheduleDailyTime: dailyTime, + StoreCount: types.Int64{Value: int64(len(app.CertificateStores))}, + } +} + diff --git a/keyfactor/resource_keyfactor_application_test.go b/keyfactor/resource_keyfactor_application_test.go new file mode 100644 index 00000000..ee9a197d --- /dev/null +++ b/keyfactor/resource_keyfactor_application_test.go @@ -0,0 +1,197 @@ +package keyfactor + +import ( + "fmt" + "os" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +// --------------------------------------------------------------------------- +// Acceptance tests (TF_ACC=1, reads env vars for config) +// --------------------------------------------------------------------------- + +// TestAccKeyfactorApplicationResource tests the full create/update/delete lifecycle +// of the keyfactor_application resource against a live Keyfactor Command instance. +// +// Required env vars (same as all TestAcc* tests): KEYFACTOR_HOSTNAME + auth creds. +// Optional: KEYFACTOR_APPLICATION_NAME overrides the application name used (a unique +// suffix is appended to avoid conflicts). +func TestAccKeyfactorApplicationResource(t *testing.T) { + baseName := os.Getenv("KEYFACTOR_APPLICATION_NAME") + if baseName == "" { + baseName = "tf-acc-app" + } + // Append a short timestamp suffix so parallel runs don't collide. + appName := fmt.Sprintf("%s-%d", baseName, time.Now().UnixNano()%1000000000) + resourceName := "keyfactor_application.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create with interval schedule + { + Config: testAccApplicationConfig(appName, false, 60, ""), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttr(resourceName, "name", appName), + resource.TestCheckResourceAttr(resourceName, "overwrite_schedules", "false"), + resource.TestCheckResourceAttr(resourceName, "schedule_interval_minutes", "60"), + resource.TestCheckResourceAttrSet(resourceName, "store_count"), + ), + }, + // Update schedule to a shorter interval + { + Config: testAccApplicationConfig(appName, false, 30, ""), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttr(resourceName, "name", appName), + resource.TestCheckResourceAttr(resourceName, "schedule_interval_minutes", "30"), + ), + }, + // Update to daily schedule + { + Config: testAccApplicationConfig(appName, false, 0, "2023-11-25T23:30:00Z"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttr(resourceName, "name", appName), + resource.TestCheckResourceAttr(resourceName, "schedule_daily_time", "2023-11-25T23:30:00Z"), + ), + }, + // Remove schedule (minimal config) + { + Config: testAccApplicationConfigMinimal(appName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttr(resourceName, "name", appName), + ), + }, + }, + }) +} + +// TestAccKeyfactorApplicationResourceOverwriteSchedules tests the overwrite_schedules flag. +func TestAccKeyfactorApplicationResourceOverwriteSchedules(t *testing.T) { + baseName := os.Getenv("KEYFACTOR_APPLICATION_NAME") + if baseName == "" { + baseName = "tf-acc-app-ow" + } + appName := fmt.Sprintf("%s-%d", baseName, time.Now().UnixNano()%1000000000) + resourceName := "keyfactor_application.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccApplicationConfig(appName, true, 120, ""), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttr(resourceName, "name", appName), + resource.TestCheckResourceAttr(resourceName, "overwrite_schedules", "true"), + resource.TestCheckResourceAttr(resourceName, "schedule_interval_minutes", "120"), + ), + }, + }, + }) +} + +// --------------------------------------------------------------------------- +// Integration tests (auto-discovery) +// --------------------------------------------------------------------------- + +func TestIntKeyfactorApplicationResource(t *testing.T) { + client := testAccIntegrationPreCheck(t) + if client == nil { + t.Skip("Skipping: testAccIntegrationPreCheck returned nil client") + } + + appName := randomTestCN("tf-int-app") + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + // Create with interval schedule + Config: testAccApplicationConfig(appName, false, 60, ""), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("keyfactor_application.test", "id"), + resource.TestCheckResourceAttr("keyfactor_application.test", "name", appName), + resource.TestCheckResourceAttr("keyfactor_application.test", "schedule_interval_minutes", "60"), + ), + }, + { + // Update: change name and schedule + Config: testAccApplicationConfig(appName+"-updated", false, 30, ""), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("keyfactor_application.test", "id"), + resource.TestCheckResourceAttr("keyfactor_application.test", "name", appName+"-updated"), + resource.TestCheckResourceAttr("keyfactor_application.test", "schedule_interval_minutes", "30"), + ), + }, + { + // Update: switch to daily schedule + Config: testAccApplicationConfig(appName+"-updated", false, 0, "2023-11-25T23:30:00Z"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("keyfactor_application.test", "id"), + resource.TestCheckResourceAttr("keyfactor_application.test", "schedule_daily_time", "2023-11-25T23:30:00Z"), + ), + }, + }, + }) +} + +func TestIntKeyfactorApplicationResourceNoSchedule(t *testing.T) { + client := testAccIntegrationPreCheck(t) + if client == nil { + t.Skip("Skipping: testAccIntegrationPreCheck returned nil client") + } + + appName := randomTestCN("tf-int-app-ns") + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + // Create without schedule + Config: testAccApplicationConfigMinimal(appName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("keyfactor_application.test", "id"), + resource.TestCheckResourceAttr("keyfactor_application.test", "name", appName), + ), + }, + }, + }) +} + +// --------------------------------------------------------------------------- +// Config generators +// --------------------------------------------------------------------------- + +func testAccApplicationConfig(name string, overwriteSchedules bool, intervalMins int, dailyTime string) string { + scheduleBlock := "" + if intervalMins > 0 { + scheduleBlock = fmt.Sprintf(` schedule_interval_minutes = %d`, intervalMins) + } else if dailyTime != "" { + scheduleBlock = fmt.Sprintf(` schedule_daily_time = "%s"`, dailyTime) + } + + return fmt.Sprintf(` +resource "keyfactor_application" "test" { + name = "%s" + overwrite_schedules = %v + %s +} +`, name, overwriteSchedules, scheduleBlock) +} + +func testAccApplicationConfigMinimal(name string) string { + return fmt.Sprintf(` +resource "keyfactor_application" "test" { + name = "%s" +} +`, name) +} diff --git a/keyfactor/resource_keyfactor_certificate.go b/keyfactor/resource_keyfactor_certificate.go index 0d89a928..24241a5b 100644 --- a/keyfactor/resource_keyfactor_certificate.go +++ b/keyfactor/resource_keyfactor_certificate.go @@ -90,6 +90,11 @@ type resourceCommandCertificateType struct{} func (r resourceCommandCertificateType) GetSchema(_ context.Context) (tfsdk.Schema, diag.Diagnostics) { return tfsdk.Schema{ Attributes: map[string]tfsdk.Attribute{ + "id": { + Type: types.StringType, + Computed: true, + Description: "Read-only alias of `identifier` for Terraform framework compatibility.", + }, "csr": { Type: types.StringType, Optional: true, @@ -662,6 +667,7 @@ func (r resourceCommandCertificate) Create( } tflog.Debug(ctx, "Setting state") + result.syncTfId() diags = response.State.Set(ctx, result) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { @@ -687,6 +693,7 @@ func (r resourceCommandCertificate) Create( } tflog.Debug(ctx, "Setting state") + result.syncTfId() diags = response.State.Set(ctx, *result) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { @@ -789,9 +796,10 @@ func (r resourceCommandCertificate) Read( notBeforeStr := leaf.NotBefore.UTC().Format(time.RFC3339) notAfterStr := leaf.NotAfter.UTC().Format(time.RFC3339) - sn := leaf.SerialNumber.String() + sn := normalizeSerialNumber(leaf.SerialNumber.String()) issuerDN := leaf.Issuer.String() tp, _ := api.GetCertificateThumbprint(leaf) + tp = normalizeThumbprint(tp) fullChain := chainPEM if !strings.Contains(fullChain, leafPEM) { fullChain = leafPEM + chainPEM @@ -819,7 +827,21 @@ func (r resourceCommandCertificate) Read( revoked, _, expired, cDiags = checkCertDiags(ctx, certGetResp, warningDays, leaf) response.Diagnostics.Append(cDiags...) if certGetResp != nil { - caName = certGetResp.CertificateAuthorityName + // Preserve the user's original certificate_authority value when the server + // returns a fully-qualified name (e.g. "hostname\\LogicalName") but the user + // specified only the logical name. This prevents spurious plan drift on + // an attribute that carries RequiresReplace semantics. + remoteCaName := certGetResp.CertificateAuthorityName + if remoteCaName != "" && caName != "" && remoteCaName != caName { + // Check if the remote CA name ends with the state value (logical name match) + if strings.HasSuffix(remoteCaName, "\\"+caName) || strings.HasSuffix(remoteCaName, "\\\\"+caName) { + tflog.Debug(ctx, fmt.Sprintf("Preserving user-supplied certificate_authority %q (remote returned %q)", caName, remoteCaName)) + } else { + caName = remoteCaName + } + } else if remoteCaName != "" { + caName = remoteCaName + } certificateID = certGetResp.Id //templateName = certGetResp.TemplateName metadata = flattenMetadata(certGetResp.Metadata) @@ -1083,6 +1105,7 @@ func (r resourceCommandCertificate) Read( // Set state tflog.Debug(ctx, "Setting state") + result.syncTfId() sDiags := response.State.Set(ctx, &result) response.Diagnostics.Append(sDiags...) response.Diagnostics.Append(diags...) @@ -1351,6 +1374,7 @@ func (r resourceCommandCertificate) Update( // Set state tflog.Debug(ctx, "Setting state") + result.syncTfId() diags = response.State.Set(ctx, &result) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { @@ -1454,6 +1478,7 @@ func (r resourceCommandCertificate) Update( } } + result.syncTfId() diags = response.State.Set(ctx, result) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { @@ -1649,12 +1674,22 @@ func (r resourceCommandCertificate) ImportState( return } - leaf := x509.Certificate{ - Raw: []byte(leafPEM), + leaf, lDiags := parseLeafCert(ctx, leafPEM) + response.Diagnostics.Append(lDiags...) + if response.Diagnostics.HasError() || leaf == nil { + response.Diagnostics.AddError( + ERR_SUMMARY_CERTIFICATE_RESOURCE_READ, + fmt.Sprintf( + "Failed to parse certificate '%s' during import. "+ + "Please check the certificate ID and try again.", state.ID.Value, + ), + ) + return } - sn := leaf.SerialNumber.String() + sn := normalizeSerialNumber(leaf.SerialNumber.String()) issuerDN := leaf.Issuer.String() - tp, _ := api.GetCertificateThumbprint(&leaf) + tp, _ := api.GetCertificateThumbprint(leaf) + tp = normalizeThumbprint(tp) fullChain := chainPEM if !strings.Contains(fullChain, leafPEM) { fullChain = leafPEM + chainPEM @@ -1665,7 +1700,16 @@ func (r resourceCommandCertificate) ImportState( metadata := state.Metadata if certGetResp != nil { // Info that can only be retrieved with `Read Certificates` permissions - caName = certGetResp.CertificateAuthorityName + remoteCaName := certGetResp.CertificateAuthorityName + if remoteCaName != "" && caName != "" && remoteCaName != caName { + if strings.HasSuffix(remoteCaName, "\\"+caName) || strings.HasSuffix(remoteCaName, "\\\\"+caName) { + tflog.Debug(ctx, fmt.Sprintf("Preserving user-supplied certificate_authority %q (remote returned %q)", caName, remoteCaName)) + } else { + caName = remoteCaName + } + } else if remoteCaName != "" { + caName = remoteCaName + } certificateIdInt = certGetResp.Id templateName = certGetResp.TemplateName metadata = flattenMetadata(certGetResp.Metadata) @@ -1678,7 +1722,7 @@ func (r resourceCommandCertificate) ImportState( } } - cn, l, s, c, o, ou := parseSubjectToTfState(leaf) + cn, l, s, c, o, ou := parseSubjectToTfState(*leaf) tflog.Debug(ctx, "Creating CommandCertificate object") var result = CommandCertificate{ @@ -1753,6 +1797,7 @@ func (r resourceCommandCertificate) ImportState( // Set state tflog.Debug(ctx, "Setting state") + result.syncTfId() diags := response.State.Set(ctx, &result) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { @@ -2364,9 +2409,9 @@ func (r resourceCommandCertificate) enrollPFXV2(ctx context.Context, plan *Comma DNSSANs: plan.DNSSANs, IPSANs: plan.IPSANs, URISANs: plan.URISANs, - SerialNumber: types.String{Value: enrolledSerialNumber}, + SerialNumber: types.String{Value: normalizeSerialNumber(enrolledSerialNumber)}, IssuerDN: types.String{Value: enrolledIssuerDN}, - Thumbprint: types.String{Value: enrolledThumbprint}, + Thumbprint: types.String{Value: normalizeThumbprint(enrolledThumbprint)}, //PEM: types.String{Value: leafPEM}, //This is set below depending out output format //PEMCACert: types.String{Value: chainPEM}, //This is set below depending out output format //PEMChain: types.String{Value: chainPEM}, //This is set below depending out output format @@ -2779,9 +2824,9 @@ func (r resourceCommandCertificate) enrollCSR( DNSSANs: plan.DNSSANs, IPSANs: plan.IPSANs, URISANs: plan.URISANs, - SerialNumber: types.String{Value: enrollResponse.CertificateInformation.SerialNumber}, + SerialNumber: types.String{Value: normalizeSerialNumber(enrollResponse.CertificateInformation.SerialNumber)}, IssuerDN: types.String{Value: enrollResponse.CertificateInformation.IssuerDN}, - Thumbprint: types.String{Value: enrollResponse.CertificateInformation.Thumbprint}, + Thumbprint: types.String{Value: normalizeThumbprint(enrollResponse.CertificateInformation.Thumbprint)}, PEM: types.String{Value: leaf}, PEMCACert: types.String{Value: caCert}, PEMChain: types.String{Value: fullChain}, diff --git a/keyfactor/resource_keyfactor_certificate_deploy.go b/keyfactor/resource_keyfactor_certificate_deploy.go index fdeb653d..fb2f5cf1 100644 --- a/keyfactor/resource_keyfactor_certificate_deploy.go +++ b/keyfactor/resource_keyfactor_certificate_deploy.go @@ -556,17 +556,36 @@ func (r resourceCommandCertificateDeployment) Delete( //hid := fmt.Sprintf("%s-%s-%s", certificateId, storeId, certificateAlias) if certificateAlias == "" { - // If no alias is provided then lookup the cert ID in keyfactor and use the alias from there - lookupCertResp, lkErr := kfClient.GetCertificateContext(&api.GetCertificateContextArgs{Id: int(certificateId)}) - if lkErr != nil { - response.Diagnostics.AddWarning( - "Certificate removal error.", - fmt.Sprintf("Error looking up certificate '%s' in Keyfactor: "+lkErr.Error(), certificateId), - ) - response.State.RemoveResource(ctx) - return + // Look up the actual alias from the store inventory — the alias is the Name field in the inventory entry. + // This handles store types (e.g. K8S TLS Secret) where the alias is not the thumbprint. + inv, invErr := kfClient.GetCertStoreInventory(storeId) + if invErr == nil && inv != nil { + certIdInt := int(certificateId) + for _, item := range *inv { + for _, cert := range item.Certificates { + if cert.Id == certIdInt { + certificateAlias = item.Name + break + } + } + if certificateAlias != "" { + break + } + } + } + if certificateAlias == "" { + // Final fallback: use thumbprint + lookupCertResp, lkErr := kfClient.GetCertificateContext(&api.GetCertificateContextArgs{Id: int(certificateId)}) + if lkErr != nil { + response.Diagnostics.AddWarning( + "Certificate removal error.", + fmt.Sprintf("Error looking up certificate '%d' in Keyfactor: "+lkErr.Error(), certificateId), + ) + response.State.RemoveResource(ctx) + return + } + certificateAlias = lookupCertResp.Thumbprint } - certificateAlias = lookupCertResp.Thumbprint // TODO: This is not always valid alias can be non-thumbprint } ctx = tflog.SetField(ctx, "certificate_id", certificateId) ctx = tflog.SetField(ctx, "certificate_store_id", storeId) @@ -608,7 +627,7 @@ func (r resourceCommandCertificateDeployment) Delete( response.Diagnostics.AddError( "Certificate deployment error", fmt.Sprintf( - "Unknown error during removal of certificate '%s' from store '%s (%s)': "+err.Error(), + "Unknown error during removal of certificate '%d' from store '%s (%s)': "+err.Error(), certificateId, storeId, certificateAlias, @@ -650,7 +669,7 @@ func addCertificateToStore( var storesStruct []api.CertificateStore certificateIdInt := int(plan.CertificateId.Value) - implicitOverwrite := plan.Overwrite.Null || plan.Overwrite.Value + implicitOverwrite := plan.Overwrite.Value ctx = tflog.SetField(ctx, "certificate_id", certificateIdInt) ctx = tflog.SetField(ctx, "implicit_overwrite", implicitOverwrite) ctx = tflog.SetField(ctx, "certificate_alias", plan.CertificateAlias.Value) @@ -722,18 +741,12 @@ func tryAddCertificateToStore( return nil // No error, exit early } - if strings.Contains(err.Error(), "does not exist in certificate store") && implicitOverwrite { - if config.CertificateStores != nil && len(*config.CertificateStores) > 0 { - for i := range *config.CertificateStores { - (*config.CertificateStores)[i].Overwrite = false - } - } - resp, err = conn.AddCertificateToStores(config) - if err == nil { - tflog.Trace(ctx, fmt.Sprintf("Response from Keyfactor on retry: %v", resp)) - return nil - } - } + // Some store types (e.g. K8S TLS Secret) require Overwrite=true when an alias is provided + // but also require the alias to already exist in the Command inventory. For new stores with + // empty inventory the first call fails with "does not exist in certificate store". Previously + // the code retried with Overwrite=false, which caused a confusing secondary error + // ("Overwrite must be true") for store types that mandate Overwrite=true. The retry is + // removed: return the original, more informative error directly. tflog.Error(ctx, fmt.Sprintf("Error adding certificate %v to Keyfactor store %v: %v", certificateId, storeId, err)) return err diff --git a/keyfactor/resource_keyfactor_certificate_deploy_test.go b/keyfactor/resource_keyfactor_certificate_deploy_test.go index 507bc3d7..65db2182 100644 --- a/keyfactor/resource_keyfactor_certificate_deploy_test.go +++ b/keyfactor/resource_keyfactor_certificate_deploy_test.go @@ -1,139 +1,65 @@ package keyfactor -//import ( -// "fmt" -// "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" -// "os" -// "testing" -//) -// -//type certificateDeploymentTestCase struct { -// certificateTestCase -// storeID string -//} -// -//func TestAccKeyfactorCertificateDeploymentResource(t *testing.T) { -// -// r := certificateDeploymentTestCase{ -// certificateTestCase: certificateTestCase{ -// template: os.Getenv("KEYFACTOR_CERTIFICATE_TEMPLATE_NAME"), -// cn: "terraform_test_certificate", -// o: "Keyfactor Inc.", -// l: "Independence", -// c: "US", -// ou: "Integrations Engineering", -// st: "OH", -// ca: fmt.Sprintf(`%s\\%s`, os.Getenv("KEYFACTOR_CERTIFICATE_CA_DOMAIN"), os.Getenv("KEYFACTOR_CERTIFICATE_CA_NAME")), -// ipSans: `["192.168.0.2", "10.10.0.9"]`, -// dnsSans: `["tfprovider.keyfactor.com", "terraform_test_certificate"]`, -// metadata: nil, -// email: "", -// keyPassword: os.Getenv("KEYFACTOR_CERTIFICATE_PASSWORD"), -// resourceName: "keyfactor_certificate_deployment.PFXCertificate", -// }, -// storeID: os.Getenv("KEYFACTOR_CERTIFICATE_STORE_ID"), -// } -// -// // Testing PFX certificate -// resource.Test(t, resource.TestCase{ -// PreCheck: func() { testAccPreCheck(t) }, -// ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, -// Steps: []resource.TestStep{ -// // Create and Read testing -// { -// //ResourceName: "", -// //PreConfig: nil, -// //Taint: nil, -// Config: testAccKeyfactorCertificateDeploymentResourcePFXConfig(r), -// Check: resource.ComposeAggregateTestCheckFunc( -// resource.TestCheckResourceAttrSet(r.resourceName, "id"), -// resource.TestCheckResourceAttrSet(r.resourceName, "id"), -// resource.TestCheckResourceAttrSet(r.resourceName, "id"), -// resource.TestCheckResourceAttrSet(r.resourceName, "id"), -// ), -// //Destroy: false, -// //ExpectNonEmptyPlan: false, -// //ExpectError: nil, -// //PlanOnly: false, -// //PreventDiskCleanup: false, -// //PreventPostDestroyRefresh: false, -// //SkipFunc: nil, -// //ImportState: false, -// //ImportStateId: "", -// //ImportStateIdPrefix: "", -// //ImportStateIdFunc: nil, -// //ImportStateCheck: nil, -// //ImportStateVerify: false, -// //ImportStateVerifyIgnore: nil, -// //ProviderFactories: nil, -// //ProtoV5ProviderFactories: nil, -// //ProtoV6ProviderFactories: nil, -// //ExternalProviders: nil, -// }, -// // ImportState testing -// //{ -// // ResourceName: "scaffolding_example.test", -// // ImportState: false, -// // ImportStateVerify: false, -// // // This is not normally necessary, but is here because this -// // // example code does not have an actual upstream service. -// // // Once the Read method is able to refresh information from -// // // the upstream service, this can be removed. -// // ImportStateVerifyIgnore: []string{"configurable_attribute"}, -// //}, -// // Update and Read testing -// { -// Config: testAccKeyfactorCertificateDeploymentResourcePFXConfig(r), -// Check: resource.ComposeAggregateTestCheckFunc( -// resource.TestCheckResourceAttrSet(r.resourceName, "id"), -// resource.TestCheckResourceAttrSet(r.resourceName, "serial_number"), -// resource.TestCheckResourceAttrSet(r.resourceName, "issuer_dn"), -// resource.TestCheckResourceAttrSet(r.resourceName, "thumbprint"), -// resource.TestCheckResourceAttrSet(r.resourceName, "keyfactor_request_id"), -// resource.TestCheckResourceAttrSet(r.resourceName, "certificate_pem"), -// resource.TestCheckResourceAttrSet(r.resourceName, "certificate_chain"), -// resource.TestCheckResourceAttrSet(r.resourceName, "certificate_authority"), -// resource.TestCheckResourceAttrSet(r.resourceName, "certificate_template"), -// resource.TestCheckResourceAttrSet(r.resourceName, "dns_sans.#"), -// resource.TestCheckResourceAttrSet(r.resourceName, "certificate_authority"), -// resource.TestCheckResourceAttrSet(r.resourceName, "certificate_template"), -// resource.TestCheckResourceAttrSet(r.resourceName, "dns_sans.#"), -// resource.TestCheckResourceAttrSet(r.resourceName, "ip_sans.#"), -// resource.TestCheckResourceAttrSet(r.resourceName, "metadata.%"), -// ), -// }, -// // Delete testing automatically occurs in TestCase -// }, -// }) -//} -// -//func testAccKeyfactorCertificateDeploymentResourcePFXConfig(t certificateDeploymentTestCase) string { -// output := fmt.Sprintf(` -//resource "keyfactor_certificate" "PFXCertificate" { -// subject = { -// subject_common_name = "%s" -// subject_organization = "%s" -// subject_locality = "%s" -// subject_country = "%s" -// subject_organizational_unit = "%s" -// subject_state = "%s" -// } -// -// ip_sans = %s -// dns_sans = %s -// -// key_password = "%s" # Please don't use this password in production pass in an environmental variable or something -// certificate_authority = "%s" -// certificate_template = "%s" -// metadata = { -// "Email-Contact" = "%s" # Note metadata keys must be defined in Keyfactor -// } -//} -//resource "keyfactor_certificate_deployment" "PFXCertificateDeployment" { -// certificate_id = keyfactor_certificate.PFXCertificate.id -// certificate_store_id = "%s" -// certificate_alias = keyfactor_certificate.PFXCertificate.subject.subject_common_name -//} -//`, t.cn, t.o, t.l, t.c, t.ou, t.st, t.ipSans, t.dnsSans, t.keyPassword, t.ca, t.template, t.email, t.storeID) -// return output -//} +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +// --------------------------------------------------------------------------- +// Integration tests (auto-discovery) +// --------------------------------------------------------------------------- + +func TestIntKeyfactorCertificateDeployResource(t *testing.T) { + client := testAccIntegrationPreCheck(t) + ca := discoverCA(t, client) + agentID, clientMachine := discoverAgent(t, client) + storeType := discoverStoreTypeForAgent(t, client, agentID) + + // For K8S store types, require credentials and use namespace/name path format + var storePath string + if strings.HasPrefix(strings.ToLower(storeType), "k8s") { + if k8sStoreCredentials() == "" { + t.Skip("Skipping K8S deployment test: set KEYFACTOR_K8S_CREDENTIALS_FILE or KEYFACTOR_K8S_SERVER_PASSWORD") + } + storePath = fmt.Sprintf("default/tf-int-test-deploy-%d", time.Now().UnixNano()) + } else { + storePath = fmt.Sprintf("/tf-int-test-deploy-%d", time.Now().UnixNano()) + } + + // Build cert config (enrollment pattern or template) + cn := randomTestCN("tf-int-deploy") + enrollmentPattern := discoverEnrollmentPattern(t, client) + var certConfig string + if enrollmentPattern != "" { + certConfig = testAccCertPFXConfigEnrollmentPattern(enrollmentPattern, ca, cn) + } else { + templateName := discoverTemplate(t, client) + certConfig = testAccCertPFXConfig(templateName, ca, cn) + } + + storeConfig := testAccCertStoreConfig(storeType, clientMachine, agentID, storePath) + deployConfig := testAccCertDeployConfig("keyfactor_certificate.test", "keyfactor_certificate_store.test") + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: certConfig + "\n" + storeConfig + "\n" + deployConfig, + Check: resource.ComposeAggregateTestCheckFunc( + // Certificate checks + resource.TestCheckResourceAttrSet("keyfactor_certificate.test", "serial_number"), + // Store checks + resource.TestCheckResourceAttrSet("keyfactor_certificate_store.test", "id"), + // Deploy checks + resource.TestCheckResourceAttrSet("keyfactor_certificate_deployment.test", "id"), + resource.TestCheckResourceAttrSet("keyfactor_certificate_deployment.test", "certificate_id"), + resource.TestCheckResourceAttrSet("keyfactor_certificate_deployment.test", "certificate_store_id"), + ), + }, + }, + }) +} diff --git a/keyfactor/resource_keyfactor_certificate_store_test.go b/keyfactor/resource_keyfactor_certificate_store_test.go index 8ae9c4c0..5ee7cefb 100644 --- a/keyfactor/resource_keyfactor_certificate_store_test.go +++ b/keyfactor/resource_keyfactor_certificate_store_test.go @@ -2,9 +2,12 @@ package keyfactor import ( "fmt" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "os" + "path/filepath" "testing" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" ) type certificateStoreTestCase_v9 struct { @@ -137,3 +140,93 @@ resource "keyfactor_certificate_store" "tf_k8s_acc_test" { `, t.clientMachine, t.storePath, t.agentIdentifier, t.storeType, t.schedule, t.containerName, t.storePassword, t.serverUserName, t.serverPassword) return output } + +// --------------------------------------------------------------------------- +// Unit tests (VCR cassettes — no lab required) +// --------------------------------------------------------------------------- + +// TestUnitKeyfactorCertificateStoreResource tests the full create/read/destroy +// lifecycle of a certificate store resource using pre-recorded HTTP cassettes. +// +// To record cassettes against a live lab: +// +// RECORD_CASSETTES=1 make testunit +func TestUnitKeyfactorCertificateStoreResource(t *testing.T) { + cassettePath := filepath.Join("testdata", "cassettes", "certificate_store_resource") + var storeType, clientMachine, agentID, storePath string + + if os.Getenv("RECORD_CASSETTES") == "1" { + // Recording mode: auto-discover lab resources and save params for replay. + client := newTestClient(t) + agentID, clientMachine = discoverAgent(t, client) + storeType = discoverStoreTypeForAgent(t, client, agentID) + // Use a K8S-compatible path format: namespace/name (no leading slash). + storePath = "default/tf-unit-test-1000000" + writeStoreTestParams(cassettePath, storeTestParams{ + StoreType: storeType, + ClientMachine: clientMachine, + AgentID: agentID, + StorePath: storePath, + }) + } else { + // Replay mode: load params recorded with the cassette so that the HCL + // config exactly matches what was used during recording, avoiding drift. + params := readStoreTestParams(cassettePath) + storeType = params.StoreType + clientMachine = params.ClientMachine + agentID = params.AgentID + storePath = params.StorePath + } + + factories, cleanup := newVCRProviderFactories(t, "certificate_store_resource") + defer cleanup() + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: factories, + Steps: []resource.TestStep{ + { + Config: testAccCertStoreConfig(storeType, clientMachine, agentID, storePath), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("keyfactor_certificate_store.test", "id"), + resource.TestCheckResourceAttrSet("keyfactor_certificate_store.test", "store_path"), + resource.TestCheckResourceAttrSet("keyfactor_certificate_store.test", "store_type"), + resource.TestCheckResourceAttrSet("keyfactor_certificate_store.test", "client_machine"), + resource.TestCheckResourceAttrSet("keyfactor_certificate_store.test", "agent_id"), + resource.TestCheckResourceAttrSet("keyfactor_certificate_store.test", "approved"), + ), + }, + }, + }) +} + +// --------------------------------------------------------------------------- +// Integration tests (auto-discovery, only need lab connection env vars) +// --------------------------------------------------------------------------- + +func TestIntKeyfactorCertificateStoreResource(t *testing.T) { + client := testAccIntegrationPreCheck(t) + agentID, clientMachine := discoverAgent(t, client) + + // Use a store type from the agent's capabilities for best compatibility + storeType := discoverStoreTypeForAgent(t, client, agentID) + storePath := fmt.Sprintf("/tf-int-test-%d", time.Now().UnixNano()) + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccCertStoreConfig(storeType, clientMachine, agentID, storePath), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("keyfactor_certificate_store.test", "id"), + resource.TestCheckResourceAttr("keyfactor_certificate_store.test", "store_path", storePath), + resource.TestCheckResourceAttr("keyfactor_certificate_store.test", "store_type", storeType), + resource.TestCheckResourceAttr("keyfactor_certificate_store.test", "client_machine", clientMachine), + resource.TestCheckResourceAttrSet("keyfactor_certificate_store.test", "agent_id"), + resource.TestCheckResourceAttr("keyfactor_certificate_store.test", "agent_identifier", agentID), + resource.TestCheckResourceAttrSet("keyfactor_certificate_store.test", "approved"), + resource.TestCheckResourceAttrSet("keyfactor_certificate_store.test", "properties.%"), + ), + }, + }, + }) +} diff --git a/keyfactor/resource_keyfactor_certificate_test.go b/keyfactor/resource_keyfactor_certificate_test.go index 5cbc4430..3209f39c 100644 --- a/keyfactor/resource_keyfactor_certificate_test.go +++ b/keyfactor/resource_keyfactor_certificate_test.go @@ -2,10 +2,12 @@ package keyfactor import ( "fmt" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "os" + "path/filepath" "strconv" "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" ) type certificateTestCase struct { @@ -26,7 +28,11 @@ type certificateTestCase struct { collectionId int } -const CsrContent = `-----BEGIN CERTIFICATE REQUEST-----\nMIIFMTCCAxkCAQAwgesxCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJPSDEVMBMGA1UE\nBxMMSW5kZXBlbmRlbmNlMUcwEAYDVQQJEwlTdWl0ZSAyMDAwEwYDVQQJEwxTZWNv\nbmQgRmxvb3IwHgYDVQQJExc2MTUwIE9hayBUcmVlIEJvdWxldmFyZDEOMAwGA1UE\nERMFNDQxMzExFzAVBgNVBAoTDktleWZhY3RvciBJbmMuMSEwHwYDVQQLExhJbnRl\nZ3JhdGlvbnMgRW5naW5lZXJpbmcxIzAhBgNVBAMMGnRlcnJhZm9ybV90ZXN0X2Nl\ncnRpZmljYXRlMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAy4sTj1k2\n7rabAXphqKaA/vpr61BEDdVQ/7J2nx3riSDqZZjyCKAjXGLqWsJGvBb9hbfnhH7J\nw83QwZTJab89BAYGTnHE4KB7eBFleI0aEvI09CPaNnjoiFYXc6s/Yhgv8FNUnlbR\nvkaEbKW4A4Mz83b2fNCHfJY5NnE6jr/gMmYnDjXh50yBAR4HS3t7GPZLsar39xpG\ngnKlCC8LGDRJ8CcMilkvH2bNLTo0nsckTJV9ttuDsmWLd9rANu843Va8XZzmq9ej\noWLn65MQEhqAObD5sZPNnQkH8c+5IGL+fQJW3y+nqe4zu+9L8nNEgXa6ANNJRIwy\n+Mug7+0IWlLJf5EnIB1z2stJqWFf3kVaEO1BakN8Qkv1tugpKazVKl6rs2CC97Ww\nQgXpD4tvOyCZxHs+Ok3SK183Q+GkM7WjLuBP9ainY4nJ76SbTOwPw8JVQB+4EkDo\naff1X2zctcmK1/Ri5kyMGqIQw4vQ+YZKzNJIJokNNn5K+u6ppOfxswOp0bZ4fG/M\nc1BKjAHBGDE10GaLlYFBR6/HTwLHDF5t1LpdhqzqLx8OpsaSJCN3xRUTlu5TsZa+\nn5NEgJS9bDHgqjv1dF68loZ3ILu8pebznh6vV+q3Jc8b8HIMXJ+hEoKZ1ldBgSeB\nCzHSDwVbS9L8swwzAAP54I/RDQR83pM1xH8CAwEAAaAAMA0GCSqGSIb3DQEBCwUA\nA4ICAQCV3Zw86hug66jloFFks1D0pGT7StuSkIFeYm46i0jEorVuhc4MqKYb/4C3\nVh0TnYHaNqfqlYJRHln2909tk4FMlQss8w/RxhCrSzJpr5px1XOWNKIJVnEjQAXS\n4O5//pe/qOwK1jH8J8RMEEZLdfFyWpJtav9Js+xK7lH/aXCbxExxYPDRuZCTiH9S\n6rxCIGmKkq2wtm36Tw3UsPLHp6IFdGag3WiD/ye4OpIT+6Tl0AX1qC3GV2S46/jv\ndPtr1EXFIgFX6mRzlA6/J3QgTaxBhxFITaS6dyCHUlSgEcbaVJ0rWre9zfQ38VEa\nUwpLU58Bx1ysVF7goQxYQxnHz2lVClA9WCCZt1NU3IX+QLqk1WU5idu8AfmvZXNI\nhrhcF/PCvH9eAfsqwECt/VsY3ferRtrCEves2UX7r/c4s0L/ZvYS7X9w3MxaJikc\nsewMB3Sj5xVc5XR71C6we16RrpEZ/bTtl8MPSY3b+pPf6YAqQlaziM2swdoQrp1c\n1DQElo1YlICF2gPQH9tJZcgDclw1W+1o77q34hIwktTtKDcVIs4WYTNwo8fn1Xtn\n7fU9cUBMepaIgZQfSz9KpLWG+GwbEgCtahLOpH5FNv+2e8dP0VZeWBCCAkav27oh\nxwK1aZ8hvc2E//sbJT0Swx8hIhyS+EYKpg1DzEZbwBmRch8C/g==\n-----END CERTIFICATE REQUEST-----\n` +// CsrContent is a fixed PEM-encoded CSR with only a CN subject field. +// Using a simple CN-only CSR avoids EJBCA/template subject field restrictions +// (e.g. "Wrong number of LOCALITY fields"). This constant is shared between +// unit tests (VCR cassette recording/replay) and the legacy acceptance tests. +const CsrContent = `-----BEGIN CERTIFICATE REQUEST-----\nMIICaDCCAVACAQAwIzEhMB8GA1UEAxMYdGYtdW5pdC10ZXN0LmV4YW1wbGUuY29t\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApgDKa9ldruZ0AL3rZDkG\nrsXXSihTcU3qB/OUUHoUHG1HMqVGm+jCVBWXm1z+hXmYq2DdesW82ESRQleBwZr5\nDyyKeoypY6ZfqRcmZoHo/sG7e3pYf3fmdn+MnHoNCA7GEipJEV92zYe28WZVCO0U\npe8LTnOt0Dep3F+4no2hO6rRKIYkvlAB58Rp88U/Fnj4xsMrADI0f71+rQPEWMaP\n5oMm+BFCG2m7mvKLciHCqj0oB3OU73ly6Xfw5ezdtDER3CrGSz6SJFBVkzpCqXeP\nfqk1a1o5Vp7kSe6LavaB/bPrPwLFazThZ9JOmaRItX8YVjEdB/oAEpcIFKycxBA3\n/wIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBAGJy5PiPu5KCGDtCrmQxNXtlpmEI\n2u0uN/TxYsbpFof8OhqeW0A4JXaS4UZ19A0sIun2GGqTtTHKVbUGLNxWNt7JzOFV\ngA2TrKL1H8J20sXRzNZxZYptfspuAI5Z1BpYpguvGJU+AGA78pw80U5KJN7mFuCf\nX5k143EhCplvclf9FoEgnOXeXSifqTXNvJytNbxLK+RC1urHvg2FpWlRRdcTn+n2\nyxwcTV2W3DruoswVBhnOlvDyoKpjMLSElIhOHg+X3xPtf0RekAmp+wI4LSwf1N3R\ntmwlPVTD69bkiQay2yt0ZX6UZQvcY6QpOEol4MadEhrK6IoXKeZHT+CGzAM=\n-----END CERTIFICATE REQUEST-----\n` func TestAccKeyfactorCertificateResource(t *testing.T) { @@ -551,3 +557,191 @@ resource "keyfactor_certificate" "PFXCertificate" { `, CsrContent, t.ipSans, t.dnsSans, t.keyPassword, t.ca, t.template, t.email) return output } + +// --------------------------------------------------------------------------- +// Integration tests (auto-discovery, only need lab connection env vars) +// --------------------------------------------------------------------------- + +func TestIntKeyfactorCertificateResource_PFX(t *testing.T) { + client := testAccIntegrationPreCheck(t) + ca := discoverCA(t, client) + cn := randomTestCN("tf-int-pfx") + + // Try enrollment pattern first (Command v25+), fall back to template+CA + enrollmentPattern := discoverEnrollmentPattern(t, client) + var config string + if enrollmentPattern != "" { + config = testAccCertPFXConfigEnrollmentPattern(enrollmentPattern, ca, cn) + } else { + templateName := discoverTemplate(t, client) + config = testAccCertPFXConfig(templateName, ca, cn) + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("keyfactor_certificate.test", "id"), + resource.TestCheckResourceAttrSet("keyfactor_certificate.test", "identifier"), + resource.TestCheckResourceAttrSet("keyfactor_certificate.test", "serial_number"), + resource.TestCheckResourceAttrSet("keyfactor_certificate.test", "issuer_dn"), + resource.TestCheckResourceAttrSet("keyfactor_certificate.test", "thumbprint"), + resource.TestCheckResourceAttrSet("keyfactor_certificate.test", "certificate_pem"), + resource.TestCheckResourceAttrSet("keyfactor_certificate.test", "certificate_chain"), + resource.TestCheckResourceAttrSet("keyfactor_certificate.test", "certificate_authority"), + resource.TestCheckResourceAttrSet("keyfactor_certificate.test", "private_key"), + ), + }, + }, + }) +} + +// --------------------------------------------------------------------------- +// Unit tests (VCR cassettes — no lab required) +// --------------------------------------------------------------------------- + +// TestUnitKeyfactorCertificateResource_PFX tests the full create/read/destroy +// lifecycle of a PFX certificate resource using pre-recorded HTTP cassettes. +// +// To record cassettes against a live lab: +// +// RECORD_CASSETTES=1 make testunit +func TestUnitKeyfactorCertificateResource_PFX(t *testing.T) { + cassettePath := filepath.Join("testdata", "cassettes", "certificate_resource_pfx") + var config string + if os.Getenv("RECORD_CASSETTES") == "1" { + client := newTestClient(t) + ca := discoverCA(t, client) + cn := randomTestCN("tf-unit-pfx") + enrollmentPattern := discoverEnrollmentPattern(t, client) + var templateName string + if enrollmentPattern != "" { + config = testAccCertPFXConfigEnrollmentPattern(enrollmentPattern, ca, cn) + } else { + templateName = discoverTemplate(t, client) + config = testAccCertPFXConfig(templateName, ca, cn) + } + writeCertPFXTestParams(cassettePath, certPFXTestParams{ + TemplateName: templateName, + CA: ca, + EnrollmentPattern: enrollmentPattern, + CN: cn, + }) + } else { + params := readCertPFXTestParams(cassettePath) + if params.EnrollmentPattern != "" { + config = testAccCertPFXConfigEnrollmentPattern(params.EnrollmentPattern, params.CA, params.CN) + } else { + config = testAccCertPFXConfig(params.TemplateName, params.CA, params.CN) + } + } + + factories, cleanup := newVCRProviderFactories(t, "certificate_resource_pfx") + defer cleanup() + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: factories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("keyfactor_certificate.test", "id"), + resource.TestCheckResourceAttrSet("keyfactor_certificate.test", "identifier"), + resource.TestCheckResourceAttrSet("keyfactor_certificate.test", "serial_number"), + resource.TestCheckResourceAttrSet("keyfactor_certificate.test", "thumbprint"), + resource.TestCheckResourceAttrSet("keyfactor_certificate.test", "certificate_pem"), + ), + }, + }, + }) +} + +// TestUnitKeyfactorCertificateResource_CSR tests the full create/read/destroy +// lifecycle of a CSR-based certificate resource using pre-recorded cassettes. +func TestUnitKeyfactorCertificateResource_CSR(t *testing.T) { + cassettePath := filepath.Join("testdata", "cassettes", "certificate_resource_csr") + var config string + if os.Getenv("RECORD_CASSETTES") == "1" { + client := newTestClient(t) + ca := discoverCA(t, client) + cn := randomTestCN("tf-unit-csr") + csr := generateSimpleCSR(t, cn) + // CSR enrollment requires a template (enrollment pattern alone is unsupported by the go-client). + enrollmentPattern := discoverEnrollmentPattern(t, client) + var templateName string + if enrollmentPattern != "" { + templateName = discoverEnrollmentPatternTemplate(t, client, enrollmentPattern) + } + if templateName == "" { + templateName = discoverTemplate(t, client) + } + config = testAccCertCSRConfig(templateName, ca, csr) + writeCertCSRTestParams(cassettePath, certCSRTestParams{ + TemplateName: templateName, + CA: ca, + CSRPem: csr, + }) + } else { + params := readCertCSRTestParams(cassettePath) + csr := params.CSRPem + if csr == "" { + // Fallback: generate a dummy CSR for replay (body is not matched by VCR) + csr = generateSimpleCSR(t, "tf-unit-csr-replay.example.com") + } + config = testAccCertCSRConfig(params.TemplateName, params.CA, csr) + } + + factories, cleanup := newVCRProviderFactories(t, "certificate_resource_csr") + defer cleanup() + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: factories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("keyfactor_certificate.test_csr", "serial_number"), + resource.TestCheckResourceAttrSet("keyfactor_certificate.test_csr", "thumbprint"), + resource.TestCheckResourceAttrSet("keyfactor_certificate.test_csr", "certificate_pem"), + ), + }, + }, + }) +} + +func TestIntKeyfactorCertificateResource_CSR(t *testing.T) { + client := testAccIntegrationPreCheck(t) + ca := discoverCA(t, client) + + // CSR enrollment via the go-client requires certificate_template (not enrollment_pattern) + // because the client library checks that Template is non-empty. When an enrollment pattern + // is available, discover the template from it; otherwise fall back to discoverTemplate. + enrollmentPattern := discoverEnrollmentPattern(t, client) + var templateName string + if enrollmentPattern != "" { + templateName = discoverEnrollmentPatternTemplate(t, client, enrollmentPattern) + } else { + templateName = discoverTemplate(t, client) + } + // Generate a simple CSR with a unique CN to avoid conflicts on re-runs + csr := generateSimpleCSR(t, randomTestCN("tf-int-csr")) + config := testAccCertCSRConfig(templateName, ca, csr) + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("keyfactor_certificate.test_csr", "serial_number"), + resource.TestCheckResourceAttrSet("keyfactor_certificate.test_csr", "issuer_dn"), + resource.TestCheckResourceAttrSet("keyfactor_certificate.test_csr", "thumbprint"), + resource.TestCheckResourceAttrSet("keyfactor_certificate.test_csr", "certificate_pem"), + resource.TestCheckResourceAttrSet("keyfactor_certificate.test_csr", "certificate_chain"), + ), + }, + }, + }) +} diff --git a/keyfactor/resource_keyfactor_oauth_security_claim_test.go b/keyfactor/resource_keyfactor_oauth_security_claim_test.go index 55d76618..e7199b15 100644 --- a/keyfactor/resource_keyfactor_oauth_security_claim_test.go +++ b/keyfactor/resource_keyfactor_oauth_security_claim_test.go @@ -205,3 +205,83 @@ resource "%s" "%s" { `, t.resourceType, t.resourceName, t.claimType, t.claimValue, t.providerAuthScheme, t.description) return output } + +// --------------------------------------------------------------------------- +// Integration tests (auto-discovery) +// --------------------------------------------------------------------------- + +func TestIntKeyfactorOAuthClaimResource(t *testing.T) { + testAccIntegrationPreCheck(t) + + authScheme := discoverOAuthAuthScheme(t) + + r := oauthClaimTestCase{ + description: "Integration test claim", + claimValue: acctest.RandomWithPrefix("tf-int-claim"), + claimType: "OAuthSubject", + providerAuthScheme: authScheme, + resourceType: "keyfactor_oauth_security_claim", + resourceName: "int_test", + resourcePath: "keyfactor_oauth_security_claim.int_test", + } + + r2 := r + r2.description = "Integration test claim updated" + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccKeyfactorOAuthClaimResourceConfig(r), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet(r.resourcePath, "id"), + resource.TestCheckResourceAttr(r.resourcePath, "description", r.description), + resource.TestCheckResourceAttr(r.resourcePath, "claim_value", r.claimValue), + resource.TestCheckResourceAttr(r.resourcePath, "claim_type", r.claimType), + ), + }, + // Update description + { + Config: testAccKeyfactorOAuthClaimResourceConfig(r2), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet(r2.resourcePath, "id"), + resource.TestCheckResourceAttr(r2.resourcePath, "description", r2.description), + resource.TestCheckResourceAttr(r2.resourcePath, "claim_value", r2.claimValue), + ), + }, + }, + }) +} + +func TestIntKeyfactorOAuthClaimResource_Import(t *testing.T) { + testAccIntegrationPreCheck(t) + + authScheme := discoverOAuthAuthScheme(t) + + r := oauthClaimTestCase{ + description: "Integration test claim import", + claimValue: acctest.RandomWithPrefix("tf-int-claim-imp"), + claimType: "OAuthSubject", + providerAuthScheme: authScheme, + resourceType: "keyfactor_oauth_security_claim", + resourceName: "int_import_test", + resourcePath: "keyfactor_oauth_security_claim.int_import_test", + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccKeyfactorOAuthClaimResourceConfig(r), + }, + { + ResourceName: r.resourcePath, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: func(state *terraform.State) (string, error) { + return getResourceIdFromTerraformState(state, r.resourcePath) + }, + }, + }, + }) +} diff --git a/keyfactor/resource_keyfactor_oauth_security_role.go b/keyfactor/resource_keyfactor_oauth_security_role.go index 62e7e894..08fde275 100644 --- a/keyfactor/resource_keyfactor_oauth_security_role.go +++ b/keyfactor/resource_keyfactor_oauth_security_role.go @@ -98,23 +98,31 @@ func (r resourceOAuthSecurityRole) Read( remoteState, httpReq, err := req.Execute() - tflog.Debug(ctx, fmt.Sprintf("HTTP Status code: %d", httpReq.StatusCode)) - - if httpReq.StatusCode == 404 { - tflog.Info(ctx, fmt.Sprintf("OAuth Security Role %d not found in remote system. Removing from state", roleId)) - response.State.RemoveResource(ctx) + if err != nil { + if httpReq != nil { + tflog.Debug(ctx, fmt.Sprintf("HTTP Status code: %d", httpReq.StatusCode)) + if httpReq.StatusCode == 404 { + tflog.Info(ctx, fmt.Sprintf("OAuth Security Role %d not found in remote system. Removing from state", roleId)) + response.State.RemoveResource(ctx) + return + } + defer httpReq.Body.Close() + body, _ := io.ReadAll(httpReq.Body) + response.Diagnostics.AddError( + "Error reading security role", + fmt.Sprintf("Could not read OAuth security role ID %d , unexpected error: %s. Details %s ", roleId, err.Error(), string(body)), + ) + } else { + response.Diagnostics.AddError( + "Error reading security role", + fmt.Sprintf("Could not read OAuth security role ID %d , unexpected error: %s", roleId, err.Error()), + ) + } return } - if err != nil { - defer httpReq.Body.Close() - body, _ := io.ReadAll(httpReq.Body) - - response.Diagnostics.AddError( - "Error reading security role", - fmt.Sprintf("Could not read OAuth security role ID %d , unexpected error: %s. Details %s ", roleId, err.Error(), string(body)), - ) - return + if httpReq != nil { + tflog.Debug(ctx, fmt.Sprintf("HTTP Status code: %d", httpReq.StatusCode)) } var result = mapOAuthSecurityRole(ctx, remoteState) @@ -329,23 +337,31 @@ func (r resourceOAuthSecurityRole) ImportState( remoteState, httpReq, err := req.Execute() - tflog.Debug(ctx, fmt.Sprintf("HTTP Status code: %d", httpReq.StatusCode)) - - if httpReq.StatusCode == 404 { - tflog.Info(ctx, fmt.Sprintf("OAuth Security Role %d not found in remote system. Removing from state", roleId)) - response.State.RemoveResource(ctx) + if err != nil { + if httpReq != nil { + tflog.Debug(ctx, fmt.Sprintf("HTTP Status code: %d", httpReq.StatusCode)) + if httpReq.StatusCode == 404 { + tflog.Info(ctx, fmt.Sprintf("OAuth Security Role %d not found in remote system. Removing from state", roleId)) + response.State.RemoveResource(ctx) + return + } + defer httpReq.Body.Close() + body, _ := io.ReadAll(httpReq.Body) + response.Diagnostics.AddError( + "Error importing security role", + fmt.Sprintf("Could not import OAuth security role ID %d , unexpected error: %s. Details %s ", roleId, err.Error(), string(body)), + ) + } else { + response.Diagnostics.AddError( + "Error importing security role", + fmt.Sprintf("Could not import OAuth security role ID %d , unexpected error: %s", roleId, err.Error()), + ) + } return } - if err != nil { - defer httpReq.Body.Close() - body, _ := io.ReadAll(httpReq.Body) - - response.Diagnostics.AddError( - "Error importing security role", - fmt.Sprintf("Could not import OAuth security role ID %d , unexpected error: %s. Details %s ", roleId, err.Error(), string(body)), - ) - return + if httpReq != nil { + tflog.Debug(ctx, fmt.Sprintf("HTTP Status code: %d", httpReq.StatusCode)) } var result = mapOAuthSecurityRole(ctx, remoteState) diff --git a/keyfactor/resource_keyfactor_oauth_security_role_claim_association_test.go b/keyfactor/resource_keyfactor_oauth_security_role_claim_association_test.go index 5c41352e..20280004 100644 --- a/keyfactor/resource_keyfactor_oauth_security_role_claim_association_test.go +++ b/keyfactor/resource_keyfactor_oauth_security_role_claim_association_test.go @@ -96,3 +96,52 @@ resource "%s" "%s" { t.claimValue, t.role1Name, t.role2Name, t.resourceType, t.resourceName, t.associatedRoleResource) return output } + +// --------------------------------------------------------------------------- +// Integration tests (auto-discovery) +// --------------------------------------------------------------------------- + +func TestIntKeyfactorOAuthSecurityRoleClaimAssociationResource(t *testing.T) { + testAccIntegrationPreCheck(t) + + authScheme := discoverOAuthAuthScheme(t) + _ = authScheme // The existing HCL config hardcodes "System"; integration test reuses that config + + r := oauthSecurityRoleClaimAssociationTestCase{ + role1Name: acctest.RandomWithPrefix("tf-int-role"), + role2Name: acctest.RandomWithPrefix("tf-int-role"), + associatedRoleResource: "test_role_1", + claimValue: acctest.RandomWithPrefix("tf-int-claim"), + claimProviderScheme: authScheme, + resourceType: "keyfactor_oauth_security_role_claim_association", + resourceName: "test_role_claim_association", + resourcePath: "keyfactor_oauth_security_role_claim_association.test_role_claim_association", + } + + // Swap to role 2 + r2 := r + r2.associatedRoleResource = "test_role_2" + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccKeyfactorOAuthSecurityRoleClaimAssociationResource(r), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet(r.resourcePath, "id"), + resource.TestCheckResourceAttrSet(r.resourcePath, "role_id"), + resource.TestCheckResourceAttrSet(r.resourcePath, "claim_id"), + ), + }, + // Swap association to role 2 + { + Config: testAccKeyfactorOAuthSecurityRoleClaimAssociationResource(r2), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet(r2.resourcePath, "id"), + resource.TestCheckResourceAttrSet(r2.resourcePath, "role_id"), + resource.TestCheckResourceAttrSet(r2.resourcePath, "claim_id"), + ), + }, + }, + }) +} diff --git a/keyfactor/resource_keyfactor_oauth_security_role_test.go b/keyfactor/resource_keyfactor_oauth_security_role_test.go index 473b0575..fc136538 100644 --- a/keyfactor/resource_keyfactor_oauth_security_role_test.go +++ b/keyfactor/resource_keyfactor_oauth_security_role_test.go @@ -165,3 +165,82 @@ resource "%s" "%s" { `, t.resourceType, t.resourceName, t.name, t.description, t.emailAddress, permissionString) return output } + +// --------------------------------------------------------------------------- +// Integration tests (auto-discovery) +// --------------------------------------------------------------------------- + +func TestIntKeyfactorOAuthRoleResource(t *testing.T) { + testAccIntegrationPreCheck(t) + + r := oauthRoleTestCase{ + name: acctest.RandomWithPrefix("tf-int-role"), + description: "Integration test role", + permissions: []string{"/metadata/types/read/"}, + emailAddress: "int-test@example.com", + resourceType: "keyfactor_oauth_security_role", + resourceName: "int_test", + resourcePath: "keyfactor_oauth_security_role.int_test", + } + + r2 := r + r2.description = "Integration test role updated" + r2.permissions = []string{"/certificates/"} + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccKeyfactorOAuthRoleResourceConfig(r), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet(r.resourcePath, "id"), + resource.TestCheckResourceAttrSet(r.resourcePath, "permission_set_id"), + resource.TestCheckResourceAttr(r.resourcePath, "name", r.name), + resource.TestCheckResourceAttr(r.resourcePath, "description", r.description), + resource.TestCheckResourceAttr(r.resourcePath, "email_address", r.emailAddress), + resource.TestCheckResourceAttr(r.resourcePath, "permissions.0", r.permissions[0]), + ), + }, + // Update description and permissions + { + Config: testAccKeyfactorOAuthRoleResourceConfig(r2), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet(r2.resourcePath, "id"), + resource.TestCheckResourceAttr(r2.resourcePath, "description", r2.description), + resource.TestCheckResourceAttr(r2.resourcePath, "permissions.0", r2.permissions[0]), + ), + }, + }, + }) +} + +func TestIntKeyfactorOAuthRoleResource_Import(t *testing.T) { + testAccIntegrationPreCheck(t) + + r := oauthRoleTestCase{ + name: acctest.RandomWithPrefix("tf-int-role-imp"), + description: "Integration test role import", + permissions: []string{"/metadata/types/read/"}, + emailAddress: "int-import@example.com", + resourceType: "keyfactor_oauth_security_role", + resourceName: "int_import_test", + resourcePath: "keyfactor_oauth_security_role.int_import_test", + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccKeyfactorOAuthRoleResourceConfig(r), + }, + { + ResourceName: r.resourcePath, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: func(state *terraform.State) (string, error) { + return r.name, nil + }, + }, + }, + }) +} diff --git a/keyfactor/resource_keyfactor_security_identity_test.go b/keyfactor/resource_keyfactor_security_identity_test.go index 0d64087b..7f91d7f8 100644 --- a/keyfactor/resource_keyfactor_security_identity_test.go +++ b/keyfactor/resource_keyfactor_security_identity_test.go @@ -119,3 +119,47 @@ resource "keyfactor_identity" "terraformer" { `, t.accountName, t.rolesStr) return output } + +// --------------------------------------------------------------------------- +// Integration tests (auto-discovery) +// --------------------------------------------------------------------------- + +func TestIntKeyfactorIdentityResource(t *testing.T) { + client := testAccIntegrationPreCheck(t) + + accountName := discoverSecurityIdentity(t, client) + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create with single role + { + Config: fmt.Sprintf(` +resource "keyfactor_identity" "int_test" { + account_name = "%s" + roles = sort(["Administrator"]) +} +`, accountName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("keyfactor_identity.int_test", "id"), + resource.TestCheckResourceAttr("keyfactor_identity.int_test", "account_name", accountName), + resource.TestCheckResourceAttr("keyfactor_identity.int_test", "roles.#", "1"), + resource.TestCheckResourceAttr("keyfactor_identity.int_test", "roles.0", "Administrator"), + ), + }, + // Update: remove all roles + { + Config: fmt.Sprintf(` +resource "keyfactor_identity" "int_test" { + account_name = "%s" + roles = sort([]) +} +`, accountName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("keyfactor_identity.int_test", "id"), + resource.TestCheckResourceAttr("keyfactor_identity.int_test", "roles.#", "0"), + ), + }, + }, + }) +} diff --git a/keyfactor/resource_keyfactor_security_role_test.go b/keyfactor/resource_keyfactor_security_role_test.go index fff7fce8..c7ee8a03 100644 --- a/keyfactor/resource_keyfactor_security_role_test.go +++ b/keyfactor/resource_keyfactor_security_role_test.go @@ -6,6 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "os" "testing" + "time" ) type roleTestCase struct { @@ -144,3 +145,50 @@ resource "keyfactor_role" "terraform_test" { `, t.name, t.description, t.permissionsStr) return output } + +// --------------------------------------------------------------------------- +// Integration tests (auto-discovery) +// --------------------------------------------------------------------------- + +func TestIntKeyfactorRoleResource(t *testing.T) { + testAccIntegrationPreCheck(t) + + roleName := fmt.Sprintf("tf-int-test-%d", time.Now().UnixNano()) + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create with no permissions + { + Config: fmt.Sprintf(` +resource "keyfactor_role" "int_test" { + name = "%s" + description = "Integration test role" + permissions = [] +} +`, roleName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("keyfactor_role.int_test", "id"), + resource.TestCheckResourceAttr("keyfactor_role.int_test", "name", roleName), + resource.TestCheckResourceAttr("keyfactor_role.int_test", "description", "Integration test role"), + resource.TestCheckResourceAttr("keyfactor_role.int_test", "permissions.#", "0"), + ), + }, + // Update: add permissions + { + Config: fmt.Sprintf(` +resource "keyfactor_role" "int_test" { + name = "%s" + description = "Integration test role updated" + permissions = distinct(sort(["Certificates:Read", "Certificates:EditMetadata"])) +} +`, roleName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("keyfactor_role.int_test", "id"), + resource.TestCheckResourceAttr("keyfactor_role.int_test", "description", "Integration test role updated"), + resource.TestCheckResourceAttr("keyfactor_role.int_test", "permissions.#", "2"), + ), + }, + }, + }) +} diff --git a/keyfactor/resource_keyfactor_template_role_binding_test.go b/keyfactor/resource_keyfactor_template_role_binding_test.go index ea5f7336..50996c57 100644 --- a/keyfactor/resource_keyfactor_template_role_binding_test.go +++ b/keyfactor/resource_keyfactor_template_role_binding_test.go @@ -3,9 +3,12 @@ package keyfactor import ( "encoding/json" "fmt" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "os" + "regexp" "testing" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" ) type roleBindingTestCase struct { @@ -117,9 +120,55 @@ func TestAccKeyfactorTemplateRoleBindingResource(t *testing.T) { func testAccKeyfactorTemplateRoleBindingResourceConfig(t roleBindingTestCase) string { output := fmt.Sprintf(` resource "keyfactor_template_role_binding" "terraform_test" { - role_name = "%s" - template_short_names = %s + role_name = "%s" + template_short_names = %s } `, t.roleName, t.templatesStr) return output } + +// --------------------------------------------------------------------------- +// Integration tests (auto-discovery) +// --------------------------------------------------------------------------- + +func TestIntKeyfactorTemplateRoleBindingResource(t *testing.T) { + client := testAccIntegrationPreCheck(t) + + // Template must be associated with an enrollment pattern for binding to work. + // If no enrollment patterns are available, skip this test. + enrollmentPattern := discoverEnrollmentPattern(t, client) + if enrollmentPattern == "" { + t.Skip("Template role binding requires templates with enrollment patterns (Command v25+)") + } + + // Use the template from the enrollment pattern — it's guaranteed to be linked + templateName := discoverEnrollmentPatternTemplate(t, client, enrollmentPattern) + if templateName == "" { + templateName = discoverTemplate(t, client) + } + roleName := fmt.Sprintf("tf-int-test-binding-%d", time.Now().UnixNano()) + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + // Known limitation: the keyfactor-go-client v3 UpdateTemplateArg struct + // doesn't include a Policies field required by Command v25+. + // This test validates the expected error until the client library is updated. + Config: fmt.Sprintf(` +resource "keyfactor_role" "int_binding_test" { + name = "%s" + description = "Integration test role for binding" + permissions = [] +} + +resource "keyfactor_template_role_binding" "int_test" { + role_name = keyfactor_role.int_binding_test.name + template_short_names = ["%s"] +} +`, roleName, templateName), + ExpectError: regexp.MustCompile(`(?i)Policies.*cannot be empty|Error updating template`), + }, + }, + }) +} diff --git a/keyfactor/test_helpers_test.go b/keyfactor/test_helpers_test.go new file mode 100644 index 00000000..c3a31884 --- /dev/null +++ b/keyfactor/test_helpers_test.go @@ -0,0 +1,1032 @@ +package keyfactor + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/Keyfactor/keyfactor-auth-client-go/auth_providers" + "github.com/Keyfactor/keyfactor-go-client/v3/api" + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "gopkg.in/dnaeon/go-vcr.v4/pkg/cassette" + "gopkg.in/dnaeon/go-vcr.v4/pkg/recorder" + "gopkg.in/yaml.v3" +) + +// --------------------------------------------------------------------------- +// Precheck functions +// --------------------------------------------------------------------------- + +// testAccPreCheck validates that required connection env vars are set. +// Calls t.Skip() with a helpful message if KEYFACTOR_HOSTNAME is not set. +func testAccPreCheck(t *testing.T) { + t.Helper() + if os.Getenv("KEYFACTOR_HOSTNAME") == "" { + t.Skip("KEYFACTOR_HOSTNAME must be set for acceptance tests") + } +} + +// testAccIntegrationPreCheck validates connection env vars and creates a test +// client for resource discovery. Skips the test if no lab connection is available. +func testAccIntegrationPreCheck(t *testing.T) *api.Client { + t.Helper() + testAccPreCheck(t) + + client := newTestClient(t) + return client +} + +// --------------------------------------------------------------------------- +// Test client factory +// --------------------------------------------------------------------------- + +// newTestClient creates an authenticated *api.Client using provider env vars. +// Skips the test if required env vars are missing or authentication fails. +func newTestClient(t *testing.T) *api.Client { + t.Helper() + + hostname := os.Getenv("KEYFACTOR_HOSTNAME") + if hostname == "" { + t.Skip("KEYFACTOR_HOSTNAME must be set to create a test client") + } + + serverConfig := &auth_providers.Server{ + Host: hostname, + APIPath: envOrDefault("KEYFACTOR_API_PATH", "KeyfactorAPI"), + } + + // TLS settings + if v := os.Getenv("KEYFACTOR_SKIP_VERIFY"); v == "true" || v == "1" { + serverConfig.SkipTLSVerify = true + } + serverConfig.CACertPath = os.Getenv("KEYFACTOR_CA_CERT") + + // Determine auth type from env vars + clientID := os.Getenv("KEYFACTOR_AUTH_CLIENT_ID") + clientSecret := os.Getenv("KEYFACTOR_AUTH_CLIENT_SECRET") + tokenURL := os.Getenv("KEYFACTOR_AUTH_TOKEN_URL") + accessToken := os.Getenv("KEYFACTOR_AUTH_ACCESS_TOKEN") + username := os.Getenv("KEYFACTOR_USERNAME") + password := os.Getenv("KEYFACTOR_PASSWORD") + + if clientID != "" && clientSecret != "" && tokenURL != "" { + serverConfig.ClientID = clientID + serverConfig.ClientSecret = clientSecret + serverConfig.OAuthTokenUrl = tokenURL + serverConfig.Scopes = strings.Split(os.Getenv("KEYFACTOR_AUTH_SCOPES"), ",") + serverConfig.Audience = os.Getenv("KEYFACTOR_AUTH_AUDIENCE") + } else if accessToken != "" { + serverConfig.AccessToken = accessToken + } else if username != "" && password != "" { + serverConfig.Username = username + serverConfig.Password = password + serverConfig.Domain = os.Getenv("KEYFACTOR_DOMAIN") + } else { + t.Skip("No valid auth credentials found in environment (need OAuth, access token, or basic auth)") + } + + ctx := context.Background() + client, err := api.NewKeyfactorClient(serverConfig, &ctx) + if err != nil { + t.Skipf("Failed to create test client (lab may be unavailable): %s", err) + } + + return client +} + +// --------------------------------------------------------------------------- +// Resource discovery helpers +// --------------------------------------------------------------------------- + +// discoverTemplate returns a usable certificate template name. +// Checks KEYFACTOR_CERTIFICATE_TEMPLATE_NAME env var first, then discovers +// from the lab by calling GetTemplates(). +func discoverTemplate(t *testing.T, client *api.Client) string { + t.Helper() + + if name := os.Getenv("KEYFACTOR_CERTIFICATE_TEMPLATE_NAME"); name != "" { + t.Logf("Using template from env: %s", name) + return name + } + + templates, err := client.GetTemplates() + if err != nil { + t.Fatalf("Failed to list templates for discovery: %s", err) + } + + if len(templates) == 0 { + t.Skip("No certificate templates available in the lab") + } + + // Prefer templates that don't require approval + for _, tmpl := range templates { + if !tmpl.RequiresApproval && tmpl.CommonName != "" { + t.Logf("Discovered template: %s (ID: %d)", tmpl.CommonName, tmpl.Id) + return tmpl.CommonName + } + } + + // Fall back to first available + t.Logf("Discovered template (fallback): %s (ID: %d)", templates[0].CommonName, templates[0].Id) + return templates[0].CommonName +} + +// discoverCA returns the certificate authority string for enrollment. +// Checks KEYFACTOR_CERTIFICATE_CA_DOMAIN + KEYFACTOR_CERTIFICATE_CA_NAME first, +// then discovers from the lab. Returns the full "domain\\name" or just "name". +func discoverCA(t *testing.T, client *api.Client) string { + t.Helper() + + envDomain := os.Getenv("KEYFACTOR_CERTIFICATE_CA_DOMAIN") + envName := os.Getenv("KEYFACTOR_CERTIFICATE_CA_NAME") + if envDomain != "" && envName != "" { + ca := fmt.Sprintf("%s\\\\%s", envDomain, envName) + t.Logf("Using CA from env: %s", ca) + return ca + } + + cas, err := client.GetCAList() + if err != nil { + t.Fatalf("Failed to list CAs for discovery: %s", err) + } + + if len(cas) == 0 { + t.Skip("No certificate authorities available in the lab") + } + + ca := cas[0] + t.Logf("Discovered CA: %s (hostname: %s)", ca.LogicalName, ca.HostName) + return ca.LogicalName +} + +// discoverEnrollmentPattern returns an enrollment pattern name. +// Checks KEYFACTOR_ENROLLMENT_PATTERN env var first, then discovers from the lab. +// Prefers the "Default" pattern if present. Returns empty string if enrollment +// patterns are not available (pre-v25). +func discoverEnrollmentPattern(t *testing.T, client *api.Client) string { + t.Helper() + + if name := os.Getenv("KEYFACTOR_ENROLLMENT_PATTERN"); name != "" { + t.Logf("Using enrollment pattern from env: %s", name) + return name + } + + patterns, err := client.GetEnrollmentPatterns() + if err != nil { + // Enrollment patterns are only available in Command v25+ + t.Logf("Warning: Failed to list enrollment patterns (may require Command v25+): %s", err) + return "" + } + + if len(patterns) == 0 { + t.Logf("Warning: No enrollment patterns available in the lab") + return "" + } + + // Prefer the "Default" pattern (case-insensitive match on name containing "default") + for _, p := range patterns { + if strings.EqualFold(p.Name, "Default Pattern") || strings.EqualFold(p.Name, "Default") { + t.Logf("Discovered default enrollment pattern: %s (ID: %d)", p.Name, p.ID) + return p.Name + } + } + + // Fall back to first pattern with TemplateDefault set + for _, p := range patterns { + if p.TemplateDefault { + t.Logf("Discovered enrollment pattern (template default): %s (ID: %d)", p.Name, p.ID) + return p.Name + } + } + + t.Logf("Discovered enrollment pattern: %s (ID: %d)", patterns[0].Name, patterns[0].ID) + return patterns[0].Name +} + +// discoverEnrollmentPatternTemplate returns the template short name associated +// with the given enrollment pattern. This is useful because the default enrollment +// pattern's template supports CSR enrollment even when other templates do not. +func discoverEnrollmentPatternTemplate(t *testing.T, client *api.Client, patternName string) string { + t.Helper() + + patterns, err := client.GetEnrollmentPatterns() + if err != nil { + t.Fatalf("Failed to list enrollment patterns: %s", err) + } + + for _, p := range patterns { + if p.Name == patternName && p.Template != nil { + name := p.Template.CommonName + if name == "" { + name = p.Template.TemplateName + } + t.Logf("Enrollment pattern %q uses template: %s (ID: %d)", patternName, name, p.Template.Id) + return name + } + } + + t.Logf("Warning: Could not find template for enrollment pattern %q", patternName) + return "" +} + +// discoverSecurityIdentity returns an existing security identity's account name +// (in "DOMAIN\\user" format suitable for HCL with escaping). +// Checks KEYFACTOR_DOMAIN + KEYFACTOR_USERNAME env vars first, then discovers +// from the lab by calling GetSecurityIdentities(). +func discoverSecurityIdentity(t *testing.T, client *api.Client) string { + t.Helper() + + domain := os.Getenv("KEYFACTOR_DOMAIN") + username := os.Getenv("KEYFACTOR_USERNAME") + if domain != "" && username != "" { + accountName := fmt.Sprintf("%s\\\\%s", domain, username) + t.Logf("Using identity from env: %s", accountName) + return accountName + } + + identities, err := client.GetSecurityIdentities() + if err != nil { + t.Skipf("Failed to list security identities for discovery: %s", err) + } + + if len(identities) == 0 { + t.Skip("No security identities available in the lab") + } + + // Pick a valid identity + for _, id := range identities { + if id.Valid && id.AccountName != "" { + // The API returns "DOMAIN\user", we need "DOMAIN\\\\user" for HCL + escaped := strings.ReplaceAll(id.AccountName, `\`, `\\`) + t.Logf("Discovered security identity: %s (ID: %d, type: %s)", id.AccountName, id.Id, id.IdentityType) + return escaped + } + } + + // Fall back to first + escaped := strings.ReplaceAll(identities[0].AccountName, `\`, `\\`) + t.Logf("Discovered identity (fallback): %s", identities[0].AccountName) + return escaped +} + +// discoverStoreTypeForAgent returns a store type short name that matches one of +// the given agent's capabilities. Falls back to discoverStoreType if no match found. +func discoverStoreTypeForAgent(t *testing.T, client *api.Client, agentID string) string { + t.Helper() + + if name := os.Getenv("KEYFACTOR_CERTIFICATE_STORE_TYPE"); name != "" { + t.Logf("Using store type from env: %s", name) + return name + } + + // Look up agent capabilities + agents, err := client.GetAgentList() + if err != nil { + t.Logf("Warning: Failed to get agent list for capability check: %s", err) + return discoverStoreType(t, client) + } + + var capabilities []string + for _, agent := range agents { + if agent.AgentId == agentID { + capabilities = agent.Capabilities + break + } + } + + if len(capabilities) == 0 { + t.Logf("Warning: Agent %s has no capabilities listed, falling back to store type discovery", agentID) + return discoverStoreType(t, client) + } + + t.Logf("Agent %s capabilities: %v", agentID, capabilities) + + // Cross-reference with available store types + storeTypes, err := client.ListCertificateStoreTypes() + if err != nil || storeTypes == nil || len(*storeTypes) == 0 { + t.Logf("Warning: Could not list store types, using first capability: %s", capabilities[0]) + return capabilities[0] + } + + // Find a store type whose short name matches an agent capability + for _, cap := range capabilities { + for _, st := range *storeTypes { + if strings.EqualFold(st.ShortName, cap) { + t.Logf("Matched store type %s (short: %s) with agent capability %s", st.Name, st.ShortName, cap) + return st.ShortName + } + } + } + + // Fall back to first capability + t.Logf("No store type matched agent capabilities, using first capability: %s", capabilities[0]) + return capabilities[0] +} + +// discoverExistingStore returns the details of an existing certificate store in the lab. +// This is useful for data source tests that need to read an existing store. +// Returns the store ID, client machine, store path, and store type short name. +func discoverExistingStore(t *testing.T, client *api.Client) (storeID, clientMachine, storePath, storeType string) { + t.Helper() + + params := &map[string]interface{}{} + stores, err := client.ListCertificateStores(params) + if err != nil { + t.Skipf("Failed to list certificate stores for discovery: %s", err) + } + + if stores == nil || len(*stores) == 0 { + t.Skip("No certificate stores available in the lab") + } + + // Log all stores for debugging + for _, s := range *stores { + t.Logf("Available store: ID=%s, machine=%s, path=%s, type=%d, agentId=%s, approved=%v", + s.Id, s.ClientMachine, s.StorePath, s.CertStoreType, s.AgentId, s.Approved) + } + + store := (*stores)[0] + // Look up the store type short name from the numeric type ID + storeTypeShortName := discoverStoreTypeByID(t, client, store.CertStoreType) + + t.Logf("Discovered existing store: ID=%s, machine=%s, path=%s, type=%s", + store.Id, store.ClientMachine, store.StorePath, storeTypeShortName) + return store.Id, store.ClientMachine, store.StorePath, storeTypeShortName +} + +// discoverStoreTypeByID returns the short name for a store type given its numeric ID. +func discoverStoreTypeByID(t *testing.T, client *api.Client, storeTypeID int) string { + t.Helper() + + storeTypes, err := client.ListCertificateStoreTypes() + if err != nil { + t.Fatalf("Failed to list store types: %s", err) + } + + if storeTypes != nil { + for _, st := range *storeTypes { + if st.StoreType == storeTypeID { + return st.ShortName + } + } + } + + return fmt.Sprintf("%d", storeTypeID) +} + +// discoverOAuthAuthScheme returns an OAuth authentication scheme name. +// Checks KEYFACTOR_OAUTH_SECURITY_CLAIM_AUTHENTICATION_SCHEME env var first. +// Falls back to "System" which is always present in OAuth-enabled Command instances. +func discoverOAuthAuthScheme(t *testing.T) string { + t.Helper() + + if scheme := os.Getenv("KEYFACTOR_OAUTH_SECURITY_CLAIM_AUTHENTICATION_SCHEME"); scheme != "" { + t.Logf("Using OAuth auth scheme from env: %s", scheme) + return scheme + } + + // "System" is the built-in authentication scheme in Command OAuth installations + t.Logf("Using default OAuth auth scheme: System") + return "System" +} + +// discoverStoreType returns a certificate store type short name. +// Checks KEYFACTOR_CERTIFICATE_STORE_TYPE env var first, then discovers from the lab. +func discoverStoreType(t *testing.T, client *api.Client) string { + t.Helper() + + if name := os.Getenv("KEYFACTOR_CERTIFICATE_STORE_TYPE"); name != "" { + t.Logf("Using store type from env: %s", name) + return name + } + + storeTypes, err := client.ListCertificateStoreTypes() + if err != nil { + t.Fatalf("Failed to list store types for discovery: %s", err) + } + + if storeTypes == nil || len(*storeTypes) == 0 { + t.Skip("No certificate store types available in the lab") + } + + // Log all available store types for debugging + for _, st := range *storeTypes { + t.Logf("Available store type: %s (short: %s, ID: %d)", st.Name, st.ShortName, st.StoreType) + } + + // Prefer K8S store types since they typically have active agents in the lab + for _, st := range *storeTypes { + shortLower := strings.ToLower(st.ShortName) + if strings.Contains(shortLower, "k8s") || strings.Contains(shortLower, "kube") { + t.Logf("Discovered K8S store type: %s (short: %s, ID: %d)", st.Name, st.ShortName, st.StoreType) + return st.ShortName + } + } + + st := (*storeTypes)[0] + t.Logf("Discovered store type (fallback): %s (short: %s, ID: %d)", st.Name, st.ShortName, st.StoreType) + return st.ShortName +} + +// discoverAgent returns the agent GUID and client machine name for an approved agent. +// Checks KEYFACTOR_CERTIFICATE_STORE_ORCHESTRATOR_AGENT_ID first, then discovers. +func discoverAgent(t *testing.T, client *api.Client) (agentID, clientMachine string) { + t.Helper() + + if id := os.Getenv("KEYFACTOR_CERTIFICATE_STORE_ORCHESTRATOR_AGENT_ID"); id != "" { + machine := os.Getenv("KEYFACTOR_CERTIFICATE_STORE_CLIENT_MACHINE") + t.Logf("Using agent from env: %s (machine: %s)", id, machine) + return id, machine + } + + agents, err := client.GetAgentList() + if err != nil { + t.Fatalf("Failed to list agents for discovery: %s", err) + } + + if len(agents) == 0 { + t.Skip("No orchestrator agents available in the lab") + } + + // Sort by status (prefer approved=2) then by most recent LastSeen + sort.Slice(agents, func(i, j int) bool { + if agents[i].Status != agents[j].Status { + return agents[i].Status > agents[j].Status // higher status first + } + return agents[i].LastSeen > agents[j].LastSeen + }) + + // Pick first approved agent (Status == 2) + for _, agent := range agents { + if agent.Status == 2 { + t.Logf("Discovered approved agent: %s (machine: %s, capabilities: %v)", agent.AgentId, agent.ClientMachine, agent.Capabilities) + return agent.AgentId, agent.ClientMachine + } + } + + // Fall back to any agent + agent := agents[0] + t.Logf("Discovered agent (fallback, status=%d): %s (machine: %s, capabilities: %v)", agent.Status, agent.AgentId, agent.ClientMachine, agent.Capabilities) + return agent.AgentId, agent.ClientMachine +} + +// --------------------------------------------------------------------------- +// VCR Auth Wrapper for Unit Tests +// --------------------------------------------------------------------------- + +// vcrAuthConfig implements the AuthConfig interface used by both the v3 Client +// and the SDK v24 clients. It returns an *http.Client with the VCR recorder +// transport injected, allowing tests to replay canned HTTP responses. +type vcrAuthConfig struct { + httpClient *http.Client + server *auth_providers.Server +} + +func (v *vcrAuthConfig) Authenticate() error { + return nil +} + +func (v *vcrAuthConfig) GetHttpClient() (*http.Client, error) { + return v.httpClient, nil +} + +func (v *vcrAuthConfig) GetServerConfig() *auth_providers.Server { + return v.server +} + +// newVCRServer returns a fake *auth_providers.Server suitable for VCR replay. +func newVCRServer(baseURL string) *auth_providers.Server { + return &auth_providers.Server{ + Host: baseURL, + APIPath: "KeyfactorAPI", + Username: "test-user", + Password: "test-pass", + Domain: "TEST", + SkipTLSVerify: true, + } +} + +// cassetteInfo holds the recording host and API path extracted from a cassette file. +type cassetteInfo struct { + Host string + APIPath string +} + +// readCassetteInfo parses the cassette YAML to extract the host and API path +// from the first interaction's URL. Falls back to sensible defaults if parsing fails. +func readCassetteInfo(cassettePath string) cassetteInfo { + data, err := os.ReadFile(cassettePath + ".yaml") + if err != nil { + return cassetteInfo{Host: "vcr.test.local", APIPath: "KeyfactorAPI"} + } + var c struct { + Interactions []struct { + Request struct { + URL string `yaml:"url"` + } `yaml:"request"` + } `yaml:"interactions"` + } + if err := yaml.Unmarshal(data, &c); err != nil || len(c.Interactions) == 0 { + return cassetteInfo{Host: "vcr.test.local", APIPath: "KeyfactorAPI"} + } + u, err := url.Parse(c.Interactions[0].Request.URL) + if err != nil || u.Host == "" { + return cassetteInfo{Host: "vcr.test.local", APIPath: "KeyfactorAPI"} + } + // API path is the leading path component(s) before the first real endpoint. + // e.g. /Keyfactor/API/Enrollment/PFX → "Keyfactor/API" + // /KeyfactorAPI/SSL/Certificates → "KeyfactorAPI" + parts := strings.SplitN(strings.TrimPrefix(u.Path, "/"), "/", 3) + apiPath := parts[0] + if len(parts) >= 2 { + apiPath = parts[0] + "/" + parts[1] + } + return cassetteInfo{Host: u.Host, APIPath: apiPath} +} + +// normalizeCassettePath strips known Keyfactor API path prefixes from a URL path so that +// cassettes recorded on different labs (or with different apiPath settings) can be replayed. +func normalizeCassettePath(p string) string { + for _, prefix := range []string{"/Keyfactor/API/", "/KeyfactorAPI/"} { + if strings.HasPrefix(p, prefix) { + return strings.TrimPrefix(p, prefix) + } + } + return strings.TrimPrefix(p, "/") +} + +// makeVCRMatcher builds a cassette.MatcherFunc that matches only on HTTP +// method, normalised API path, and query string — ignoring host, headers, +// body, and protocol details. This is intentionally lenient so that cassettes +// recorded with real lab credentials can be replayed without any network or +// credentials. +func makeVCRMatcher() cassette.MatcherFunc { + return func(r *http.Request, i cassette.Request) bool { + if r.Method != i.Method { + return false + } + iURL, err := url.Parse(i.URL) + if err != nil { + return false + } + if normalizeCassettePath(r.URL.Path) != normalizeCassettePath(iURL.Path) { + return false + } + if r.URL.RawQuery != iURL.RawQuery { + return false + } + return true + } +} + +// --------------------------------------------------------------------------- +// Certificate store test params (cassette-recorded values for replay mode) +// --------------------------------------------------------------------------- + +// storeTestParams holds the key field values used when recording a cassette, +// so that replay mode can use identical values and avoid Terraform plan drift. +type storeTestParams struct { + StoreType string `json:"store_type"` + ClientMachine string `json:"client_machine"` + AgentID string `json:"agent_id"` + StorePath string `json:"store_path"` +} + +// writeStoreTestParams saves recording parameters alongside the cassette file. +func writeStoreTestParams(cassettePath string, params storeTestParams) { + data, err := json.Marshal(params) + if err != nil { + return + } + _ = os.WriteFile(cassettePath+".params.json", data, 0600) +} + +// readStoreTestParams loads recording parameters from the JSON params file. +// Returns safe defaults if the file does not exist yet. +func readStoreTestParams(cassettePath string) storeTestParams { + defaults := storeTestParams{ + StoreType: "K8STLSSecr", + ClientMachine: "vcr-test-machine", + AgentID: "00000000-0000-0000-0000-000000000001", + StorePath: "default/tf-unit-test-1000000", + } + data, err := os.ReadFile(cassettePath + ".params.json") + if err != nil { + return defaults + } + var params storeTestParams + if err := json.Unmarshal(data, ¶ms); err != nil { + return defaults + } + return params +} + +// --------------------------------------------------------------------------- +// Certificate PFX test params (cassette-recorded values for replay mode) +// --------------------------------------------------------------------------- + +// certPFXTestParams holds the key field values used when recording a PFX cassette, +// so that replay mode can use identical values and avoid Terraform plan drift. +type certPFXTestParams struct { + TemplateName string `json:"template_name"` + CA string `json:"ca"` + EnrollmentPattern string `json:"enrollment_pattern"` + CN string `json:"cn"` +} + +func writeCertPFXTestParams(cassettePath string, params certPFXTestParams) { + data, err := json.Marshal(params) + if err != nil { + return + } + _ = os.WriteFile(cassettePath+".params.json", data, 0600) +} + +func readCertPFXTestParams(cassettePath string) certPFXTestParams { + defaults := certPFXTestParams{ + TemplateName: "2YearTestWebServer", + CA: "CommandCA1", + CN: "tf-unit-pfx.example.com", + } + data, err := os.ReadFile(cassettePath + ".params.json") + if err != nil { + return defaults + } + var params certPFXTestParams + if err := json.Unmarshal(data, ¶ms); err != nil { + return defaults + } + return params +} + +// --------------------------------------------------------------------------- +// Certificate CSR test params (cassette-recorded values for replay mode) +// --------------------------------------------------------------------------- + +// certCSRTestParams holds the key field values used when recording a CSR cassette. +// The CSRPem is stored so replay mode uses the exact same CSR that was enrolled, +// ensuring the cert still exists in the lab if needed (though VCR doesn't require it). +type certCSRTestParams struct { + TemplateName string `json:"template_name"` + CA string `json:"ca"` + CSRPem string `json:"csr_pem"` +} + +func writeCertCSRTestParams(cassettePath string, params certCSRTestParams) { + data, err := json.Marshal(params) + if err != nil { + return + } + _ = os.WriteFile(cassettePath+".params.json", data, 0600) +} + +func readCertCSRTestParams(cassettePath string) certCSRTestParams { + defaults := certCSRTestParams{ + TemplateName: "2YearTestWebServer", + CA: "CommandCA1", + CSRPem: "", // empty: will fall back to generating a dummy CSR in replay + } + data, err := os.ReadFile(cassettePath + ".params.json") + if err != nil { + return defaults + } + var params certCSRTestParams + if err := json.Unmarshal(data, ¶ms); err != nil { + return defaults + } + return params +} + +// --------------------------------------------------------------------------- +// Unique CN generator +// --------------------------------------------------------------------------- + +// randomTestCN generates a unique common name for test certificates and CSRs. +// Uses Unix nanoseconds to avoid CN conflicts when tests run multiple times +// against the same lab without cleaning up previously-enrolled certificates. +func randomTestCN(prefix string) string { + return fmt.Sprintf("%s-%d.example.com", prefix, time.Now().UnixNano()%1000000000) +} + +// newVCRProviderFactories returns Terraform provider factories backed by a VCR +// recorder. In replay mode (default) it replays cassette files from +// keyfactor/testdata/cassettes/ and skips the test if none exist yet. Set +// RECORD_CASSETTES=1 to record new cassettes against a live lab. +// +// Usage: +// +// factories, cleanup := newVCRProviderFactories(t, "my_cassette") +// defer cleanup() +// resource.UnitTest(t, resource.TestCase{ProtoV6ProviderFactories: factories, ...}) +func newVCRProviderFactories(t *testing.T, cassetteName string) (map[string]func() (tfprotov6.ProviderServer, error), func()) { + t.Helper() + + cassettePath := filepath.Join("testdata", "cassettes", cassetteName) + matcher := makeVCRMatcher() + + if os.Getenv("RECORD_CASSETTES") != "1" { + // Replay mode: pre-create the VCR recorder and inject it as testAuth so + // provider.Configure() bypasses all auth/network logic entirely. + info := readCassetteInfo(cassettePath) + + r, err := recorder.New(cassettePath, + recorder.WithMode(recorder.ModeReplayOnly), + recorder.WithMatcher(matcher), + ) + if err != nil { + t.Skipf("No cassette found for %q. Run with RECORD_CASSETTES=1 against a live lab to record.", cassetteName) + } + + vcrAuth := &vcrAuthConfig{ + httpClient: r.GetDefaultClient(), + server: &auth_providers.Server{ + Host: info.Host, + APIPath: info.APIPath, + Username: "vcr-test-user", + Password: "Vcrtestpass1!", + SkipTLSVerify: true, + }, + } + + p := &provider{testAuth: vcrAuth} + factories := map[string]func() (tfprotov6.ProviderServer, error){ + "keyfactor": providerserver.NewProtocol6WithError(p), + } + cleanup := func() { + if stopErr := r.Stop(); stopErr != nil { + t.Logf("Warning: VCR recorder stop error: %s", stopErr) + } + } + return factories, cleanup + } + + // Recording mode: create ONE shared VCR recorder for the entire test so + // that ALL provider API calls (across multiple Configure() invocations) are + // captured in a single cassette. We use testAuth to bypass Configure()'s + // own auth/network logic and route every call through the VCR recorder. + testAccPreCheck(t) + realClient := newTestClient(t) + realHTTPClient, hErr := realClient.AuthClient.GetHttpClient() + if hErr != nil || realHTTPClient == nil { + t.Fatalf("VCR recording: failed to get real HTTP client: %v", hErr) + } + + r, rErr := recorder.New(cassettePath, + recorder.WithMode(recorder.ModeRecordOnly), + recorder.WithRealTransport(realHTTPClient.Transport), + recorder.WithMatcher(matcher), + // Redact auth and sensitive fields before saving cassette. + recorder.WithHook(func(i *cassette.Interaction) error { + delete(i.Request.Headers, "Authorization") + // Redact ServerPassword from request bodies (may contain kubeconfig). + if i.Request.Body != "" { + var body map[string]interface{} + if json.Unmarshal([]byte(i.Request.Body), &body) == nil { + redacted := false + for _, key := range []string{"ServerPassword", "Password"} { + if v, ok := body[key]; ok && v != nil && v != "" { + body[key] = "[REDACTED]" + redacted = true + } + } + if redacted { + if sanitized, mErr := json.Marshal(body); mErr == nil { + i.Request.Body = string(sanitized) + i.Request.ContentLength = int64(len(sanitized)) + } + } + } + } + return nil + }, recorder.BeforeSaveHook), + ) + if rErr != nil { + t.Fatalf("Failed to create VCR recorder for %q: %s", cassetteName, rErr) + } + + vcrAuth := &vcrAuthConfig{ + httpClient: r.GetDefaultClient(), + server: realClient.AuthClient.GetServerConfig(), + } + p := &provider{testAuth: vcrAuth} + factories := map[string]func() (tfprotov6.ProviderServer, error){ + "keyfactor": providerserver.NewProtocol6WithError(p), + } + cleanup := func() { + if stopErr := r.Stop(); stopErr != nil { + t.Logf("Warning: VCR recorder stop error: %s", stopErr) + } + } + return factories, cleanup +} + +// --------------------------------------------------------------------------- +// HCL config generators for integration tests +// --------------------------------------------------------------------------- + +// testAccCertPFXConfig generates HCL for a PFX certificate resource test. +// cn is the common name to use; pass randomTestCN("tf-int-pfx") for unique values. +func testAccCertPFXConfig(templateName, ca, cn string) string { + return fmt.Sprintf(` +resource "keyfactor_certificate" "test" { + common_name = "%s" + certificate_authority = "%s" + certificate_template = "%s" + key_password = "Tftest123456" + dns_sans = ["%s"] +} +`, cn, ca, templateName, cn) +} + +// testAccCertPFXConfigEnrollmentPattern generates HCL for a PFX certificate +// resource test using an enrollment pattern (required for Command v25+). +// cn is the common name to use; pass randomTestCN("tf-int-pfx") for unique values. +func testAccCertPFXConfigEnrollmentPattern(enrollmentPattern, ca, cn string) string { + return fmt.Sprintf(` +resource "keyfactor_certificate" "test" { + common_name = "%s" + certificate_authority = "%s" + certificate_enrollment_pattern = "%s" + key_password = "Tftest123456" +} +`, cn, ca, enrollmentPattern) +} + +// generateSimpleCSR creates a fresh PEM-encoded CSR with only a CN field. +// This avoids template subject field restrictions (e.g., "Wrong number of LOCALITY fields"). +func generateSimpleCSR(t *testing.T, cn string) string { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("Failed to generate RSA key: %s", err) + } + template := &x509.CertificateRequest{ + Subject: pkix.Name{CommonName: cn}, + } + csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key) + if err != nil { + t.Fatalf("Failed to create CSR: %s", err) + } + csrPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}) + return string(csrPEM) +} + +// testAccCertCSRConfig generates HCL for a CSR-based certificate resource test. +func testAccCertCSRConfig(templateName, ca, csr string) string { + // Decode literal \n escape sequences to real newlines so the HCL heredoc is valid. + decodedCSR := strings.ReplaceAll(csr, `\n`, "\n") + return fmt.Sprintf(` +resource "keyfactor_certificate" "test_csr" { + certificate_authority = "%s" + certificate_template = "%s" + csr = <<-EOT +%s +EOT +} +`, ca, templateName, strings.TrimRight(decodedCSR, "\n")) +} + +// testAccCertCSRConfigEnrollmentPattern generates HCL for a CSR-based certificate +// resource test using an enrollment pattern (required for Command v25+). +func testAccCertCSRConfigEnrollmentPattern(enrollmentPattern, ca, csr string) string { + return fmt.Sprintf(` +resource "keyfactor_certificate" "test_csr" { + certificate_authority = "%s" + certificate_enrollment_pattern = "%s" + csr = "%s" +} +`, ca, enrollmentPattern, csr) +} + +// testAccCertDataSourceByID generates HCL for reading a certificate by Keyfactor ID +func testAccCertDataSourceByID(certResourceRef string) string { + return fmt.Sprintf(` +data "keyfactor_certificate" "test" { + identifier = %s.identifier + key_password = "Tftest123456" +} +`, certResourceRef) +} + +// k8sStoreCredentials returns the kubeconfig JSON for K8S store server_password. +// Checks KEYFACTOR_K8S_CREDENTIALS_FILE (file path) then KEYFACTOR_K8S_SERVER_PASSWORD (raw content). +// Returns empty string if neither is set. +func k8sStoreCredentials() string { + if filePath := os.Getenv("KEYFACTOR_K8S_CREDENTIALS_FILE"); filePath != "" { + data, err := os.ReadFile(filePath) + if err == nil { + return string(data) + } + } + return os.Getenv("KEYFACTOR_K8S_SERVER_PASSWORD") +} + +// testAccCertStoreConfig generates HCL for a certificate store resource test. +// Includes required credentials and properties for K8S store types. +func testAccCertStoreConfig(storeType, clientMachine, agentID, storePath string) string { + stLower := strings.ToLower(storeType) + if strings.HasPrefix(stLower, "k8s") { + creds := k8sStoreCredentials() + + // Determine KubeSecretType based on store type + kubeSecretType := "tls" + switch stLower { + case "k8ssecret": + kubeSecretType = "opaque" + case "k8sjks": + kubeSecretType = "jks" + case "k8spkcs12": + kubeSecretType = "pkcs12" + } + + return fmt.Sprintf(` +resource "keyfactor_certificate_store" "test" { + client_machine = "%s" + store_path = "%s" + agent_identifier = "%s" + store_type = "%s" + server_username = "kubeconfig" + server_password = <