Skip to content

fix: paginate template lookup for keyfactor_template_role_binding#173

Draft
spbsoluble wants to merge 68 commits into
v2from
fix/template-role-binding-pagination
Draft

fix: paginate template lookup for keyfactor_template_role_binding#173
spbsoluble wants to merge 68 commits into
v2from
fix/template-role-binding-pagination

Conversation

@spbsoluble

@spbsoluble spbsoluble commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

Fixes #172. Bundles #174 (cert Update nil-deref fix) and #175 (cert-store container-clearing fix), each merged into this branch. #177 has since merged into this branch too — a resource-wide audit for the same "omitted vs. explicitly-cleared" bug class as #175, plus #178, #179, and #180 (all merged into #177 in turn). See "Provider-wide inconsistent-state audit" below.

Features

None — this release is fixes plus internal/dependency work.

Fixes

Certificate Stores

  • keyfactor_certificate_store container/application-clearing bug (Bug: keyfactor_certificate_store update clears container/application assignment when not declared in config #175): Update no longer silently clears an existing container/application assignment when application_name/container_name is not declared in config. A resolved container ID of 0 was previously omitted from the update request entirely, which Command interprets as "unassign" — destroying a real out-of-band assignment before Terraform's own consistency check could even flag it. container_id is now preserved whenever prior state shows a real assignment and the plan gives no explicit name (an explicit empty-string name still clears it, unchanged). lookupContainerNameByID also now retries via the list endpoint before falling back to a hint, reducing spurious container_name/application_name nulling from a single transient/permission-scoped lookup failure. Regression tests added covering both the preserve-on-undeclared and explicit-clear-still-works paths.
  • keyfactor_certificate_store no longer sends an explicit empty InventorySchedule object when the config doesn't declare one; Update always re-resolves the container/application name by ID after a successful update instead of trusting a locally-resolved name; Create/Update no longer crash the provider with a nil-pointer dereference when the agent identifier lookup returns no matching agent

Provider-wide inconsistent-state audit (#177, #178, #179, #180)

A provider-wide audit for the same bug class as #175 — an omitted attribute and an explicitly-cleared attribute being treated as the same thing, which could silently clear real server-side state or crash the provider — found and fixed issues across 12 resources:

  • fix: nil-pointer-dereference crashes eliminated in keyfactor_certificate_deploy, keyfactor_oauth_security_role_claim_association, and OAuth claim role-mapping, on certain error responses from Command
  • fix: keyfactor_application no longer silently drops an existing schedule on an unrelated update when schedule_* attributes are omitted from config
  • fix: keyfactor_certificate_authority no longer silently clears scan schedules or allowed_requesters on an unrelated update
  • fix: keyfactor_certificate_store_type explicit false/empty values for local_store, server_required, power_shell, and blueprint_allowed are no longer dropped from requests or misread as false
  • fix: keyfactor_certificate_template explicit empty lists (template_regexes, template_defaults, enrollment_fields, metadata_fields) now actually clear server-side; 4 Command v25+ attributes no longer drift on refresh
  • fix: keyfactor_pam_provider_type explicit empty display_name is now preserved instead of falling back to a server default; a null data_type no longer reads as a misleading concrete value
  • fix: keyfactor_pam_provider an explicit param_values clear now reaches the update request instead of being silently ignored
  • fix: keyfactor_security_identity no longer strips existing role assignments on an unrelated update when roles is omitted from config; Read now detects role changes made outside Terraform instead of showing stale data. roles is now Optional+Computed, which also fixes a Provider produced inconsistent result after apply error on updates to identities that already have roles assigned
  • fix: keyfactor_security_role no longer clears a role's permissions on an unrelated update when permissions is omitted from config — this previously happened two ways: an explicit clear being sent by mistake, and (even after that fix) Command's update endpoint treating a merely-missing field the same as an explicit clear. Read now detects permission/description changes made outside Terraform instead of showing stale data, and permissions is now Optional+Computed, fixing a Provider produced inconsistent result after apply error on updates to roles that already have permissions assigned
  • fix: keyfactor_template_role_binding role attach/detach no longer risks clearing unrelated template settings; Read now detects role changes made outside Terraform and surfaces an error instead of failing silently when template data can't be read
  • fix: keyfactor_template_role_binding role attach/detach no longer fails with 'Policies' cannot be empty on templates linked to an enrollment pattern (keyfactor_template_role_binding: attach/detach fails with "'Policies' cannot be empty" — UpdateTemplateArg has no Policies field #180) — Command's update endpoint is full-replace and was clearing the template's key-algorithm policy whenever it wasn't resent
  • fix: keyfactor_certificate owner_role_name no longer causes a spurious ownership change (and drift) when left unset in config
  • fix: OAuth security claims missing a provider sub-object in Command's response now surface a warning instead of silently clearing the claim's provider association on the next update

Chore / Internal

Verification

Pending (before GA v2.9.1)

…ates

The keyfactor-go-client GetTemplates() fix (Keyfactor/keyfactor-go-client#55)
makes the client paginate GET /Templates with ?PageReturned&ReturnLimit.
The VCR matcher compares the request query string exactly, so every
cassette that recorded a no-query GET /Templates had to be re-recorded:

- template_role_binding_resource{,_import}: re-recorded (+ params)
- certificate_template_data_source / resource / resource_update: re-recorded

Adds an opt-in replayable-interactions VCR variant
(newVCRProviderFactoriesReplayable) used only by the read-only
certificate-template data-source unit test. The SDK refresh cycle reads
the same template by id more times in replay than the record run
captures; consuming-mode replay (the default) then fails with
"requested interaction not found". Replayable mode stays opt-in so
stateful/polling cassettes keep consuming ordered interactions.

NOTE: requires a keyfactor-go-client release containing
Keyfactor/keyfactor-go-client#55 and a corresponding go.mod bump before
this is mergeable / CI-green.

Refs #172
Pulls in the paginated GetTemplates() fix
(Keyfactor/keyfactor-go-client#55), so keyfactor_template_role_binding
resolves templates that sort beyond the first 50 on the instance.

RC pending the final v3.5.6 release.

Refs #172
rc.1 adds the security/compliance audit hardening to GetTemplates
(max-page DoS guard, per-iteration body close, audit logging) on top
of the pagination fix. Pagination semantics unchanged; provider unit
suite re-validated green against rc.1.

RC pending the final v3.5.6 release.

Refs #172
…ling

TestIntKeyfactorCertificateResource_SANs and
TestIntKeyfactorCertificateAuthorityResourceUpdate fail only due to lab
constraints, not provider bugs. Handle each in-test so an unexpected
failure still fails:

- SANs: add ErrorCheck(skipOnKnownLabConstraint) for the EJBCA
  "Wrong number of DNSNAME fields in Subject Alternative Name" error.
- CA update: the blocked-deletion error surfaces at the SDK's post-test
  destroy (a defer that bypasses ErrorCheck), so probe the Certificates
  endpoint up front and skip with a warning when the CA has issued certs
  (or the probe is inconclusive).
- skipOnKnownLabConstraint: normalize whitespace before matching, since
  Terraform wraps long error messages across indented lines.

Verified against the ses2541 lab: both now SKIP (warn), not FAIL.

Refs #172
Add v2.9.1 CHANGELOG section (template_role_binding template lookup now
pages past the first 50 templates via the paginated GetTemplates in
keyfactor-go-client v3.5.6) and set version to 2.9.1-rc.0.

Refs #172
… Update (#174)

* fix(certificate): guard against nil GetCertificateContext response in Update

The Update path called GetCertificateContext and, when it returned
(nil, err), only logged a warning via hasAPIErrors before dereferencing
certGetResp.ContentBytes — causing a SIGSEGV nil-pointer dereference
during terraform apply (observed when flipping certificate_format to
PFX on an in-place update).

The Read path was hardened with certGetResp != nil guards in v2.9.0,
but Update was missed. Update now returns a clean diagnostic error and
bails out when the certificate GET fails or returns a nil response,
before any dereference. All subsequent certGetResp accesses in Update
were already nil-guarded.

Adds TestUnitKeyfactorCertificateResource_UpdateNilGetResponse, an
httptest-backed regression test that drives Update with a failing
certificate GET and asserts a diagnostic error is surfaced instead of
a panic.

* test(certificate): tighten Update nil-deref regression test + update-scoped diagnostic

Add ERR_SUMMARY_CERTIFICATE_RESOURCE_UPDATE and use it in the defensive
certGetResp == nil guard in Update, so the update path no longer borrows
the read-path diagnostic summary.

Correct the regression test's docstring/comments to describe what is
actually exercised (GET 500 -> hasAPIErrors early return, not the
format-change/parseLeafCert path) and note the certGetResp == nil guard
is belt-and-suspenders for a (nil, nil) response not reproducible via the
httptest harness. Trim the plan to only the fields load-bearing before
the early return.
…with derefOrEmpty

getStringType(...).Value appeared at 7 call sites (helpers.go and 6 in
resource_keyfactor_oauth_security_role_claim_association.go) purely to
dereference a *string to a plain Go string for a non-nullable request DTO
field, discarding the types.String/Null flag getStringType exists to
carry. Using a Terraform-state conversion helper for that obscures intent
and adds an unnecessary intermediate types.String allocation.

Added derefOrEmpty(v *string) string next to getStringType in helpers.go
and replaced all 7 call sites. No behavior change: nil still maps to "",
non-nil still passes the value through unchanged.

Added TestUnitDerefOrEmpty for direct coverage.
certUpdateMockAuthConfig (resource_keyfactor_certificate_unit_test.go),
certDeployMockAuthConfig (resource_keyfactor_certificate_deploy_unit_test.go),
and oauthRoleClaimAssocMockAuthConfig
(resource_keyfactor_oauth_security_role_claim_association_unit_test.go)
were three near-identical httptest-backed mock api.AuthConfig
implementations, each with its own GetServerConfig/GetHttpClient/
Authenticate/GetCommandVersion bodies. This was already flagged as
deferred debt in this repo's own CHANGELOG.md "Pending" section.

Extracted one shared mockAuthConfig type into test_helpers_test.go
(alongside the existing VCR-backed vcrAuthConfig, which follows the same
"implements the AuthConfig interface used by both the v3 Client and the
SDK v24 clients" pattern). The only real difference between the three
original mocks was Host format: api.Client wants a scheme-prefixed URL
plus an explicit APIPath, while the SDK v24 clients' prepareRequest sets
the scheme itself and wants a bare "host:port" Host. mockAuthConfig takes
an apiPath and a stripScheme flag to cover both, via two constructors
(newCertAPIMockAuthConfig, newSDKMockAuthConfig).

All three call sites (newCertUpdateMockClient, newCertDeployMockClient,
newOAuthRoleClaimAssocMockClient) now build the shared type instead of
their own. oauthRoleClaimAssocUnreachableAuthConfig, a different mock in
the claim-association test file simulating a transport-level failure, is
untouched -- it is not one of the three duplicates this cleanup targets.
No behavior change; covered by the full existing test suite for all three
files (every test in them exercises the mock client).

Also removes the "extract shared httptest mock helper" item from
CHANGELOG.md's "Pending" section now that it is done.
Folds the 7 verified code-review findings from this triage pass into the
existing v2.9.1 CHANGELOG entry, following this release's established
style: the keyfactor_security_identity/keyfactor_security_role bullets
are extended in place (same resources, same bug class, same unreleased
version) rather than duplicated; keyfactor_template_role_binding and the
new OAuth claim warning get their own additions. The three cleanup items
(enum-pointer dedup, derefOrEmpty, shared httptest mock) are logged under
Chore/Internal.

Removes "extract the shared httptest mock helper" from the Pending
section now that it has landed, and adds a Pending note flagging
keyfactor_certificate_authority's remaining unaudited Optional+Computed
pointer fields as the same bug class, since this pass touched that
resource's enum converters without auditing the rest of it.
…mmand PUT is full-replace)

Command's PUT /Security/Roles is a full-replace endpoint, not a merge
patch: omitting the "Permissions" key from the request body entirely
(not just sending null) still clears the role's permissions
server-side. Confirmed live against a real Command instance while
validating PR #179 end-to-end. buildSecurityRoleUpdateArg now resends
state.Permissions explicitly whenever the config-declared value is
Null/Unknown, instead of omitting the field and relying on the
(incorrect) assumption that absence means "leave unchanged".

Adds TestUnitKeyfactorSecurityRoleResource_UpdateOmittingPermissionsPreservesThem,
a REAL Terraform Core plan/apply/refresh cycle test (resource.UnitTest +
ProtoV6ProviderFactories) closing the fix's Core-cycle coverage gap: the
prior regression tests for this bug class all called Update()/Read()
directly with hand-built tfsdk.Plan/State/Config values, so they could
not catch a "Provider produced inconsistent result after apply" error,
which only Terraform Core's own plan/apply diffing surfaces. The new
test is backed by a hand-built httptest mock server rather than a VCR
cassette, since the VCR matcher intentionally ignores request bodies
and so could never assert what the provider actually sent on the PUT
call. Confirmed this test fails against the pre-fix
buildSecurityRoleUpdateArg (3-argument form, no state fallback) with
exactly "Provider produced inconsistent result after apply:
.permissions: element 0 has vanished", and passes against the fix.
…9 fixes

Adds three purpose-built Terraform CLI demo directories (following the
existing terraform/oauth_security_demo/ convention) that validate recent
resource_keyfactor_security_role.go / resource_keyfactor_security_identity.go
fixes using the actual terraform binary against a live lab, one level up
from this repo's Go-level resource.UnitTest coverage:

- terraform/security_identity_demo/: proves PR179's keyfactor_identity
  `roles` Optional+Computed fix. `make security-identity-demo-omit-update`
  applies with roles declared, then again with roles omitted from config
  (an unrelated Update isn't even needed here -- the roles diff alone
  triggers Update on this resource, since account_name is RequiresReplace
  and there's no other user-settable attribute). Requires an AD-backed lab
  for Create (see the target's NOTE).

- terraform/security_role_permissions_demo/: proves PR179's keyfactor_role
  `permissions` Optional+Computed fix, and the follow-up
  buildSecurityRoleUpdateArg fix (resend state.Permissions explicitly
  instead of omitting the field, since Command's PUT /Security/Roles is a
  full replace). `make security-role-permissions-demo-omit-update` applies
  with permissions declared, applies again with description changed and
  permissions omitted, then cross-checks via a direct Command REST call
  that permissions are still intact server-side afterward. Ran this against
  int25-4-1.kftestlab.com while validating PR #179: clean apply, no
  inconsistent-result crash, permissions confirmed preserved server-side.

- terraform/security_role_demo/: proves the separate, already-merged PR178
  fix (out-of-band permission drift detection on Read) via
  `make security-role-demo-oob-drift`. This demo predates PR179 but was
  never committed; adding it now alongside its siblings for completeness.

GNUmakefile gains top-level wrappers for all three
(security-identity-demo*, security-role-permissions-demo*,
security-role-demo*), matching the existing oauth-security-demo*
convention.
…sources

Extends the real-terraform-CLI lab validation to every resource touched by
PR #179, per direct request. Adds three more demo directories alongside
the existing security_identity_demo/security_role_permissions_demo:

- terraform/pam_provider_type_demo/: full lifecycle (plan, apply, import,
  reconcile, drift-check, destroy) for keyfactor_pam_provider_type,
  proving the enumPtrToTfInt64 refactor of pamParameterDataTypePtrToTfInt64
  didn't regress the real create/read/import round-trip for
  parameters[].data_type. Ran clean against int25-4-1.kftestlab.com:
  0 drift after import, both data_type values (1, 2) round-tripped
  correctly.

- terraform/template_role_binding_demo/: same lifecycle shape for
  keyfactor_template_role_binding, proving the Read()-swallowed-error fix.
  Documents a KNOWN BLOCKER discovered while validating it: `apply` fails
  against every template in this lab with "'Policies' cannot be empty" --
  keyfactor-go-client/v3's UpdateTemplateArg has no Policies field at all,
  and buildTemplateRoleBindingUpdateArg never sets one. This looks like a
  genuine, separate SDK/Command-API-version gap unrelated to PR179's
  Read()-only fix; flagging for separate triage rather than fixing here.

- terraform/certificate_authority_demo/: deliberately import-only (not
  full CRUD) for keyfactor_certificate_authority. There is exactly one
  real CA in this lab; fabricating a second to freely create/destroy
  risked confusing its PKI setup for no real benefit, since the
  enumPtrToTfInt64 refactor (enrollmentTypePtrToTfInt64,
  keyRetentionPtrToTfInt64, cleanupTimeUnitsPtrToTfInt64) only touches how
  Read() converts already-fetched values. Imported the lab's real CA
  (`Sub-CA`, id 1) cleanly; confirmed via `terraform show` that
  allowed_enrollment_types (0) and key_retention ("Indefinite") round-trip
  correctly post-import, and time_after_expiration_units correctly reads
  as null (matching the server's null TimeAfterExpirationUnits) --
  exercising all three refactored converters' nil and non-nil paths
  against real data. Did not run a reconcile `apply` against this shared,
  non-disposable CA connection.

Also re-ran keyfactor_role's demo through the FULL lifecycle this time
(previously only the targeted 2-step regression proof had been run):
plan → apply → import-all → reconcile → drift-check (no changes) →
destroy, all clean. Ditto for keyfactor_oauth_security_role /
keyfactor_oauth_security_role_claim_association via the existing
oauth_security_demo, including its lab-update step (exercising
mapOAuthSecurityClaimsFromRole's real Update() code path): plan → apply →
update → import-all → reconcile → drift-check (no changes) → destroy,
all clean.

Remaining gap: keyfactor_identity's full lifecycle requires an AD-backed
lab account (the default lab rejects Create with HTTP 400 for any
account_name); not run here pending a decision on which lab/account to
use.

GNUmakefile gains top-level wrappers for the three new demos, matching
the existing convention.
…covery

Backfill three gaps in the v2.9.1 changelog that landed on this branch
via PR #178/#179 but weren't yet reflected: the security_role Read
drift-detection fix, the security_role full-replace Update fix
(Command's PUT /Security/Roles clears permissions when the field is
merely omitted, not just explicitly nulled), and the new
terraform/*_demo real-CLI lab validation infrastructure. Also add a
known-gap entry for template_role_binding's TemplatePolicy full-replace
issue (#180, blocked on keyfactor-go-client#57) and update the GA
dependency gate to reflect #55/#56 merging and #57 joining the same
v3.5.6 GA cut.
The terraform/*_demo/ lab-validation directories added while validating
PR178/PR179 (security_identity_demo, security_role_permissions_demo,
security_role_demo, pam_provider_type_demo, template_role_binding_demo,
certificate_authority_demo) are useful for local validation but not
worth the size they add to an already-large PR. Untracked them (files
remain on disk, untracked) and reverted the matching GNUmakefile
targets. Also reformats the v2.9.1 changelog's audit-findings section
to match the rest of the file's concise, user-facing style instead of
an internal commit-narrative style, and removes the changelog bullet
that documented the now-untracked demo infrastructure as shipped.
…3.5.6

v3.5.6 GA (Keyfactor/keyfactor-go-client#55, #56, #57) includes the
GetTemplates pagination fix, the store-type/EntryPassword omitempty
fixes, and the TemplatePolicy full-replace fix (issue #180) that
previously required an RC pin. Verified vendor-free (rm -rf vendor,
go build/test -mod=mod against the real published module): clean
build, full TestUnit suite green (0 failures).
keyfactor_template_role_binding attach/detach fails on every template
linked to an enrollment pattern with "'Policies' cannot be empty"
(#180). Command's UpdateTemplate is a full-replace PUT: the request's
TemplatePolicy carries the template's PrimaryKeyAlgorithms /
AlternativeKeyAlgorithms, and Command derives its internal "Policies"
set from that object. buildTemplateRoleBindingUpdateArg had no way to
populate it because api.UpdateTemplateArg had no TemplatePolicy field
at all, so every attach/detach silently omitted it and Command rejected
the update outright for any enrollment-pattern-linked template.

Confirmed live against a Command 25.4.1 instance: the previous request
shape reproduces the error on an enrollment-pattern-linked template;
round-tripping TemplatePolicy from the prior GetTemplate response
succeeds. Templates with no policy configured (nil TemplatePolicy)
correctly stay nil rather than fabricating one.

Requires the corresponding keyfactor-go-client fix (adds
GetTemplateResponse.TemplatePolicy / UpdateTemplateArg.TemplatePolicy)
to be published before this builds against a released SDK version.

Adds a red/green regression test:
TestUnitTemplateRoleBindingUpdateArgPreservesTemplatePolicy.
…it-triage

fix: resolve 29 inconsistent-state findings from resource-wide audit
…ner API probes

Add api-update-template and api-template-schema-diff (mirroring api-update-ca /
api-ca-schema-diff) to verify the Templates PUT wire type for KeyUsage, and
api-set-cert-owner / api-clear-cert-owner to verify the Certificates Owner
clear payload. Used to live-verify four Command v25.5 API behaviors against
the lab ahead of the go-client KeyUsage type fix and CA schedule/owner work.
buildTemplateRoleBindingUpdateArg never carried KeyUsage from GetTemplate
onto the UpdateTemplate request, so every role attach/detach silently
reset the template's KeyUsage bitmask to 0 on Command even though this
resource does not manage KeyUsage at all. Command's UpdateTemplate is a
full replacement (PR #180
context), so any omitted field is cleared server-side.

Pin keyfactor-go-client/v3 v3.6.0-rc.0 (Keyfactor/keyfactor-go-client#58),
which changes UpdateTemplateArg.KeyUsage from *bool to *int to match
Command's v25.5 wire format (int bitmask on both GET and PUT; a JSON
boolean is rejected with HTTP 400). Use the non-collapsing pointer
pattern already established in this function so a KeyUsage of 0 round-
trips instead of being dropped by omitempty.

TestUnitTemplateRoleBindingUpdatePreservesKeyUsage reproduces the wipe
end-to-end against a mock Command server (GET returns KeyUsage 160,
capture the PUT body) and fails on the unfixed code with the PUT body
carrying no KeyUsage field at all.
Read() rebuilt the roles state unconditionally from the server's
canonical role names (identity.Roles), even when the declared role set
was semantically identical to the server's -- only spelled differently
in case, or declared by numeric role ID instead of name. That rewrote
a practitioner's declared casing/ID-form to Command's canonical name on
every Read, manufacturing a diff that no apply could ever resolve.

Add identityRolesResultForRead, mirroring permissionsResultForUpdate's
order-vs-drift invariant (resource_keyfactor_security_role.go): if the
prior state's role list bijectively matches the server's role list
(numeric role ID, or case-insensitive name), return the prior state
verbatim; otherwise return the server's canonical names so real
out-of-band role drift still surfaces. Create/Update are unaffected --
they already write verbatim-from-plan.

TestUnitSecurityIdentityReadKeepsDeclaredRoleSpelling and
TestUnitSecurityIdentityReadNumericIdMatchesServerRole reproduce the
bug against a mock Command server and fail on the unfixed code (Read
overwrites "administrators"/"5" with the server's "Administrators").
TestUnitSecurityIdentityReadSurfacesRealDrift is the negative-case
companion, asserting a genuinely different role set still surfaces as
drift.

Filed #183 for the separate,
pre-existing `[^\w]` regex mangling bug in Create/Update's role name
lookup (out of scope here).
Introduce keyfactor/attribute_contract.go, standardizing the
"server-managed unless declared" pattern this provider already applies
ad hoc in several resources (template_role_binding's KeyUsage,
security_role's Permissions, security_identity's Roles) into two
reusable primitives:

- declaredInConfig(v attr.Value): the single predicate for "did the
  practitioner declare this attribute", always keyed on request.Config
  (never request.Plan, since Optional+Computed plan modifiers rewrite
  Plan to carry forward state on omission).
- pairedVariantModifier / pairedWith(sibling): a plan modifier for
  mutually-exclusive attribute pairs (e.g. an interval-based vs. a
  daily-time-based schedule variant), replacing UseStateForUnknown on
  each half of such a pair. Modify order: (1) plan already known ->
  leave it; (2) sibling declared in Config -> plan Null, so a variant
  switch produces a truthful diff instead of resurrecting the
  superseded attribute's prior value; (3) neither declared -> fall back
  to useStateOrNullModifier semantics (copy state forward, stay Unknown
  on Create).

Generalizes useStateOrNullModifier (resource_keyfactor_certificate_template.go)
and formatDependentModifier (resource_keyfactor_certificate.go), and
works generically across attr.Value types (types.Int64, types.String,
...) via the attr.Type ValueFromTerraform/TerraformType round-trip
rather than a type switch.

This lands the shared mechanism ahead of its first real consumer
(certificate_authority's preserveCAUpdateFields, next commit) and the
mutually-exclusive schedule variant work planned for a follow-on PR.
… plan

Rework preserveCAUpdateFields to take the request's Config alongside
Plan/State and key each attribute's declared-ness on
declaredInConfig(config.X) (attribute_contract.go) instead of
plan.X.Null. The scan/threshold schedule intervals and
allowed_requesters are Optional+Computed with a UseStateForUnknown
plan modifier, so an undeclared attribute's Plan value is normally
already resolved to the prior state by the time this function runs --
checking plan.X.Null only catches the narrow case where the incoming
plan value is literally Null, not Unknown (a distinct state for
types.Int64/types.List), and would miss any future plan modifier (e.g.
the mutually-exclusive schedule variant handling planned for a
follow-on PR) that resolves an undeclared attribute to something other
than a bare Null.

Update() now fetches request.Config alongside Plan/State and passes it
through, mirroring security_role's Update
(resource_keyfactor_security_role.go).

TestUnitCAUpdatePreservesScanScheduleWhenPlanIsUnknownNotNull
demonstrates the gap the config-keyed check closes: an
undeclared-in-config schedule whose incoming plan value is Unknown
(not Null) is still correctly preserved from state, where the old
plan.Null-keyed check would have let it fall through and clear
server-side. Existing preserveCAUpdateFields tests are updated to pass
a config argument representing "undeclared" alongside plan/state.
Per live verification against Command v25.5 (V4 in the attribute
contract investigation): GET /CertificateAuthority returns both
UseAllowedRequesters and AllowedRequesters with real, current values --
this is no longer a write-only field the provider must echo back from
plan/state.

Remove the AllowedRequesters echo from preserveSecrets. caResponseToState
already maps the server's list into state; the echo ran AFTER that
mapping and unconditionally overwrote it with the stale prior
plan/state value whenever one was known, so Read (and Create/Update's
post-response reconciliation) could never detect a role added or
removed from the allowed-requester list out-of-band.

preserveCAUpdateFields (Update-only, full-replace PUT reconciliation)
is unaffected: it still preserves the prior state's list onto the plan
when config leaves allowed_requesters undeclared, since Command's CA
PUT remains a full replacement that clears an omitted field. With the
config-keyed check from the previous commit, an explicitly-declared
empty list ([]) is still treated as declared and sent through as a real
clear -- confirmed via the SDK's ToMap()-based MarshalJSON, which
serializes an explicit empty slice as "AllowedRequesters":[] rather
than omitting it (distinct from a nil slice, which is omitted).

Update the attribute description to document the v25.5+ read
requirement and the omit/declare/clear contract. Updated doc comments
on preserveSecrets and preserveCAUpdateFields to match.

TestUnitCAReadSurfacesAllowedRequestersDrift reproduces the fixed
behavior directly against caResponseToState + preserveSecrets.
TestUnitCAUpdateExplicitEmptyAllowedRequestersIsSent covers the clear
path. Existing TestUnitCAUpdatePreservesAllowedRequesters and
TestUnitCAUpdatePostImportAllowedRequestersOmitted keep their original
intent (undeclared preserves prior state) under the config-keyed check.

@spbsoluble spbsoluble left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High-effort review of the audit rollup (same methodology as the PR #182 review: per-resource inventory of the preserve/omitted-vs-cleared changes, schema/Read-path drift survey, and adversarial hunt for the bug classes confirmed on #182). Overall verdict: the audit substantially IMPROVES drift detection (Read paths now write server truth for role permissions, identity roles, template attachments, store-type flags) and most resources already expose explicit-empty clear sentinels. Three correctness findings were confirmed — all three are now fixed by commits on this branch (details inline). Also fixed on this branch as part of the same contract work: allowed_requesters server-truth on Read (1970154, drift now visible + [] clears, verified live on Command v25.5) and declarative schedule clear sentinels landing via the stacked #182.

Pre-existing, out of scope, filed separately: #183 (security_identity role lookup strips non-word characters, breaking role names with spaces).

allowedEnrollmentTypes := template.AllowedEnrollmentTypes
rfcEnforcement := template.RFCEnforcement
requiresApproval := template.RequiresApproval
keyUsage := template.KeyUsage

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONFIRMED, wipe class — fixed by c6c70e5 + Keyfactor/keyfactor-go-client#58] buildTemplateRoleBindingUpdateArg's doc comment promised a faithful round-trip of every GetTemplate field so the full-replace PUT /Templates wouldn't wipe unmanaged settings, but it omitted KeyUsage — every role attach/detach sent a body with no KeyUsage key.

Root cause was upstream: the go-client modeled UpdateTemplateArg.KeyUsage as *bool while Command's wire format is an int bitmask (verified against the live v25.5 swagger: TemplateUpdateRequest.KeyUsage is integer/int32; a boolean payload is rejected with HTTP 400 "Unexpected character encountered while parsing value: t. Path 'KeyUsage'"). Fixed as *int in keyfactor-go-client v3.6.0-rc.0 (pinned here) and round-tripped non-collapsingly at this line. Regression test: TestUnitTemplateRoleBindingUpdatePreservesKeyUsage (red before the fix: PUT body carried no KeyUsage key).

Lab nuance worth recording: on an EJBCA-connector template the server ignored attempts to change KeyUsage (connector-derived), so omission did not visibly wipe there — the test therefore asserts round-trip faithfulness of the PUT body rather than omission-wipes-to-zero.

AccountName: types.String{Value: identity.AccountName},
IdentityType: types.String{Value: identity.IdentityType},
Roles: types.List{Elems: validRoles, ElemType: types.StringType},
Roles: identityRolesResultForRead(state.Roles, identity.Roles),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONFIRMED, perpetual-diff — fixed by 0e1c1be] The audit changed Read to rebuild roles from the server's canonical role.Name (good: real drift becomes visible), but Create/Update still write the user's config strings verbatim. Any case difference — or numeric IDs, which the schema description explicitly invited — produced an unresolvable loop: apply writes ["administrators"], refresh rewrites ["Administrators"], plan shows a diff forever.

Fix mirrors permissionsResultForUpdate's order-preservation contract: identityRolesResultForRead keeps the declared spelling when prior state is semantically equal to the server set (bijective match by numeric ID or case-insensitive name) and writes server-canonical names only on real drift. Regression tests: TestUnitSecurityIdentityReadKeepsDeclaredRoleSpelling, TestUnitSecurityIdentityReadNumericIdMatchesServerRole (both red pre-fix), TestUnitSecurityIdentityReadSurfacesRealDrift.

// Null and there is nothing to preserve; in that case the field remains
// omitted from the request, relying on Command to leave an omitted
// AllowedRequesters unchanged.
func preserveCAUpdateFields(plan *KeyfactorCertificateAuthority, config, state KeyfactorCertificateAuthority) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONFIRMED, contract inconsistency — fixed by e4e1a43] preserveCAUpdateFields keyed "did the user declare this attribute" on the PLAN value being Null, while the equivalent logic in security_role (buildSecurityRoleUpdateArg) and security_identity (identityRolesDeclared) correctly keys on request.Config. Plan-keyed checks are unsound here: plan modifiers rewrite the plan (UseStateForUnknown resurrects prior state as Known, and an Unknown plan value slips past a Null check entirely), so the preserve guard could both fire when it shouldn't and miss when it should — the exact bug class that broke the schedule variant switch on #182.

Now config-keyed via the shared declaredInConfig helper (keyfactor/attribute_contract.go, added on this branch), with Update() reading request.Config. New test TestUnitCAUpdatePreservesScanScheduleWhenPlanIsUnknownNotNull covers the Unknown-plan gap the old check missed. The paired-variant plan modifier that completes this fix for the schedule attribute pairs lands with the stacked #182.

…ontract fixes

Adds v2.9.1 entries for the six commits since the #180 rollup: template_role_binding
KeyUsage round-trip (go-client v3.6.0-rc.0 KeyUsage *bool->*int fix), security_identity
Read preserving declared role spelling (follow-up #183 filed), the shared
attribute_contract.go helpers, certificate_authority's config-keyed preserveCAUpdateFields,
and allowed_requesters now surfacing server truth on Read.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant