Skip to content

Commit 06de89d

Browse files
committed
fix(certificate): give owner_role_name a declarative clear path
owner_role_name only ever supported setting a new owner: an explicit owner_role_name = "" fell into the same code path as a real role name, sending {"NewRoleName":""} to PUT /Certificates/{id}/Owner. Per the verified live Command behavior (Swagger: "If removing the owner, leave both empty"; confirmed against a v25.5 lab -- HTTP 204, subsequent GET shows OwnerRoleId/OwnerRoleName null), the correct clear payload omits both NewRoleId and NewRoleName entirely. - ownerChangeRequestForPlan builds {} (both fields nil) for an explicit "" plan value, relying on api.OwnerRequest's existing `omitempty` pointer fields -- no go-client change needed. - keepStringSentinel (attribute_contract.go) gives owner_role_name sentinel stability on Read: when the server reports no owner and prior state held the "" clear sentinel, state keeps "" instead of collapsing to null, so config-vs-state stays settled instead of producing a permanent "" -> null -> "" diff. A real owner appearing out-of-band still surfaces as drift over a stale sentinel. - Documented the omit/clear contract on the schema description and regenerated docs. certificateOwnerRoleChanged already refused to fire on an undeclared (Null/Unknown) plan value, and Update's post-apply state already echoed the plan value verbatim via knownStringFromPlan, so both continue to behave correctly for the clear case with no change needed there.
1 parent 73cd698 commit 06de89d

4 files changed

Lines changed: 174 additions & 16 deletions

File tree

docs/resources/certificate.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,9 @@ Note: This parameter is considered deprecated as for Keyfactor Command v25.1.0
324324
- `organizational_unit` (String) Subject organizational unit (OU) of the certificate
325325
- `owner_role_name` (String) A string containing the name of the security role assigned as the certificate owner. This name must match the existing name of the security role.
326326

327+
> [!NOTE]
328+
> **Attribute contract**: omitting `owner_role_name` from config leaves ownership unmanaged -- Terraform never sends a clearing value, and drift from an out-of-band owner change is still surfaced on plan/refresh. Declaring an explicit empty string (`owner_role_name = ""`) is a declarative "clear the owner" sentinel: Terraform sends a PUT with no role identifier, which Keyfactor Command interprets as removing the certificate's owner.
329+
327330
Expanded Change Owner Permission: A user who holds the Certificates > Expanded Change Owner permission can set the certificate owner to any role within the permission sets they are a member of. This permission setting overrides the Certificates > Collections > Change Owner permission (both Global and Collection-level) if both are set.
328331

329332
Collections > Change Owner Permission:

keyfactor/attribute_contract.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/hashicorp/terraform-plugin-framework/diag"
88
"github.com/hashicorp/terraform-plugin-framework/path"
99
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
10+
"github.com/hashicorp/terraform-plugin-framework/types"
1011
"github.com/hashicorp/terraform-plugin-go/tftypes"
1112
)
1213

@@ -153,6 +154,33 @@ func getConfigAttributeValue(ctx context.Context, config tfsdk.Config, p path.Pa
153154
return v, diags
154155
}
155156

157+
// keepStringSentinel implements sentinel stability (attribute contract item
158+
// 4) for a single Optional+Computed string attribute whose empty string ""
159+
// is a declarative "clear" sentinel (as opposed to Null, which means
160+
// "undeclared/unmanaged") -- e.g. certificate's owner_role_name. The wire
161+
// protocol for these attributes typically cannot distinguish "the server
162+
// genuinely reports nothing" from "the practitioner declared an explicit
163+
// clear": both surface as serverValue == "".
164+
//
165+
// When serverValue is empty AND prior (the previous state value on Read, or
166+
// the plan value when constructing post-apply state) was itself the ""
167+
// sentinel, this returns the "" sentinel (Known, non-null) rather than
168+
// collapsing to Null. Without this, an Optional+Computed attribute plans the
169+
// declared "" again on every subsequent plan (config still says ""), while
170+
// state kept flipping to Null -- a permanent spurious `"" -> null -> ""`
171+
// diff. When serverValue is non-empty, that is a genuine value (freshly set,
172+
// or changed out-of-band) and must always be surfaced, never masked by a
173+
// stale sentinel.
174+
func keepStringSentinel(serverValue string, prior types.String) types.String {
175+
if serverValue != "" {
176+
return types.String{Value: serverValue}
177+
}
178+
if !prior.Null && !prior.Unknown && prior.Value == "" {
179+
return types.String{Value: ""}
180+
}
181+
return types.String{Null: true}
182+
}
183+
156184
// nullValueOfType builds a null attr.Value sharing the same underlying type
157185
// as an existing (possibly Unknown) attr.Value, generically -- works for any
158186
// attr.Type via the ValueFromTerraform/TerraformType round-trip, so this

keyfactor/resource_keyfactor_certificate.go

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -410,10 +410,15 @@ func (r resourceCommandCertificateType) GetSchema(_ context.Context) (tfsdk.Sche
410410
Computed: true,
411411
Description: "Optional owner role name. " +
412412
"This is required if the certificate template being used requires an owner role to be set during" +
413-
" enrollment. Only compatible with Keyfactor Command versions v12.3.0 and later.",
413+
" enrollment. Only compatible with Keyfactor Command versions v12.3.0 and later. " +
414+
"Omitting this attribute leaves ownership unmanaged/preserved (server-side changes are not corrected); " +
415+
"an explicit empty string (\"\") declaratively clears the certificate's owner.",
414416
MarkdownDescription: `
415417
A string containing the name of the security role assigned as the certificate owner. This name must match the existing name of the security role.
416418
419+
> [!NOTE]
420+
> **Attribute contract**: omitting ` + "`owner_role_name`" + ` from config leaves ownership unmanaged -- Terraform never sends a clearing value, and drift from an out-of-band owner change is still surfaced on plan/refresh. Declaring an explicit empty string (` + "`owner_role_name = \"\"`" + `) is a declarative "clear the owner" sentinel: Terraform sends a PUT with no role identifier, which Keyfactor Command interprets as removing the certificate's owner.
421+
417422
Expanded Change Owner Permission: A user who holds the Certificates > Expanded Change Owner permission can set the certificate owner to any role within the permission sets they are a member of. This permission setting overrides the Certificates > Collections > Change Owner permission (both Global and Collection-level) if both are set.
418423
419424
Collections > Change Owner Permission:
@@ -1210,9 +1215,9 @@ func (r resourceCommandCertificate) Read(
12101215
enrollmentPatternName = state.EnrollmentPattern.Value
12111216
}
12121217

1213-
var ownerRoleName string
1214-
if certGetResp != nil && certGetResp.OwnerRoleName != "" {
1215-
ownerRoleName = certGetResp.OwnerRoleName
1218+
var serverOwnerRoleName string
1219+
if certGetResp != nil {
1220+
serverOwnerRoleName = certGetResp.OwnerRoleName
12161221
}
12171222
tflog.Debug(ctx, "Creating state object for certificate.")
12181223
result := CommandCertificate{
@@ -1262,10 +1267,10 @@ func (r resourceCommandCertificate) Read(
12621267
Value: certGetResp != nil && strings.Contains(strings.ToLower(certGetResp.CertStateString), "pending"),
12631268
},
12641269
RenewalConfig: renewalConfig,
1265-
OwnerRoleName: types.String{
1266-
Value: ownerRoleName,
1267-
Null: isNullString(ownerRoleName),
1268-
},
1270+
// Sentinel stability (attribute contract item 4): keep the "" clear
1271+
// sentinel if the server reports no owner AND prior state itself held
1272+
// the sentinel; otherwise write server truth (drift-visible).
1273+
OwnerRoleName: keepStringSentinel(serverOwnerRoleName, state.OwnerRoleName),
12691274
EnrollmentPattern: state.EnrollmentPattern, // This may be mutated below
12701275
CertificateTemplate: state.CertificateTemplate, // This may be mutated below
12711276
NotBefore: types.String{
@@ -1430,6 +1435,34 @@ func certificateOwnerRoleChanged(plan, state CommandCertificate) bool {
14301435
return plan.OwnerRoleName.Value != state.OwnerRoleName.Value
14311436
}
14321437

1438+
// ownerChangeRequestForPlan builds the PUT /Certificates/{id}/Owner payload
1439+
// for a declared owner_role_name plan value. Only called when
1440+
// certificateOwnerRoleChanged reports a change, so ownerRoleName is always a
1441+
// Known plan value here (never Null/Unknown).
1442+
//
1443+
// An explicit empty string ("") is the declarative "clear ownership"
1444+
// sentinel (attribute contract item 3): per the Keyfactor Command API (PUT
1445+
// /Certificates/{id}/Owner Swagger doc: "If removing the owner, leave both
1446+
// empty"), a request with both NewRoleId and NewRoleName unset clears the
1447+
// certificate's owner -- verified live against a v25.5 lab: HTTP 204,
1448+
// subsequent GET shows OwnerRoleId/OwnerRoleName null. api.OwnerRequest's
1449+
// fields are `omitempty` pointers, so &api.OwnerRequest{} serializes to
1450+
// exactly `{}` (a non-nil pointer to an empty string, by contrast, is NOT
1451+
// omitted by encoding/json -- omitempty only checks for a nil pointer -- so
1452+
// this must be a genuinely nil field, not `NewRoleName: &""`).
1453+
//
1454+
// A non-empty value may be either a numeric role ID or a role name; try
1455+
// parsing as an int first and fall back to treating it as a name.
1456+
func ownerChangeRequestForPlan(ownerRoleName string) *api.OwnerRequest {
1457+
if ownerRoleName == "" {
1458+
return &api.OwnerRequest{}
1459+
}
1460+
if ownerInt, convErr := strconv.Atoi(ownerRoleName); convErr == nil {
1461+
return &api.OwnerRequest{NewRoleId: &ownerInt}
1462+
}
1463+
return &api.OwnerRequest{NewRoleName: &ownerRoleName}
1464+
}
1465+
14331466
func (r resourceCommandCertificate) Update(
14341467
ctx context.Context,
14351468
request tfsdk.UpdateResourceRequest,
@@ -1567,14 +1600,7 @@ func (r resourceCommandCertificate) Update(
15671600
// Check if ownerrolename has changed
15681601
if certificateOwnerRoleChanged(plan, state) {
15691602
tflog.Debug(ctx, "OwnerRoleName has changed, updating certificate owner role.")
1570-
// Check if rolename is an integer ID or string name
1571-
ownerInt, convErr := strconv.Atoi(plan.OwnerRoleName.Value)
1572-
ownerRequest := &api.OwnerRequest{}
1573-
if convErr != nil {
1574-
ownerRequest.NewRoleName = &plan.OwnerRoleName.Value
1575-
} else {
1576-
ownerRequest.NewRoleId = &ownerInt
1577-
}
1603+
ownerRequest := ownerChangeRequestForPlan(plan.OwnerRoleName.Value)
15781604

15791605
oErr := r.p.client.ChangeCertificateOwnerRole(certificateID, ownerRequest)
15801606
if oErr != nil {

keyfactor/resource_keyfactor_certificate_unit_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ package keyfactor
22

33
import (
44
"context"
5+
"fmt"
6+
"io"
57
"net/http"
68
"net/http/httptest"
9+
"strings"
710
"testing"
811

912
"github.com/Keyfactor/keyfactor-go-client/v3/api"
@@ -212,3 +215,101 @@ func TestUnitKeyfactorCertificateResource_OwnerRoleNameIsComputed(t *testing.T)
212215
t.Errorf("owner_role_name: expected UseStateForUnknown plan modifier, but none was found (modifiers: %+v)", attr.PlanModifiers)
213216
}
214217
}
218+
219+
// TestUnitCertificateOwnerRoleClearSendsEmptyPayload is a regression test for
220+
// declaratively clearing owner_role_name (G1). Per the verified live Command
221+
// v25.5 behavior (Swagger doc: "If removing the owner, leave both empty" --
222+
// confirmed with a real PUT: HTTP 204, subsequent GET shows
223+
// OwnerRoleId/OwnerRoleName null), the wire payload for clearing ownership
224+
// must omit BOTH NewRoleId and NewRoleName -- i.e. `{}`.
225+
//
226+
// Before this fix, ownerChangeRequestForPlan("") reproduced the historical
227+
// inline Update() logic: strconv.Atoi("") fails, so it fell into the
228+
// "treat as name" branch and sent {"NewRoleName":""} -- a non-empty pointer
229+
// to an empty string is NOT omitted by Go's `omitempty` (only a nil pointer
230+
// is), so this is NOT the verified clear payload and is an unverified,
231+
// possibly-rejected wire shape.
232+
//
233+
// This drives the real HTTP call through api.Client.ChangeCertificateOwnerRole
234+
// so the assertion exercises actual JSON-over-the-wire shaping, not just the
235+
// in-memory struct.
236+
func TestUnitCertificateOwnerRoleClearSendsEmptyPayload(t *testing.T) {
237+
const certID = 845070
238+
239+
var capturedBody string
240+
var sawRequest bool
241+
mux := http.NewServeMux()
242+
mux.HandleFunc(fmt.Sprintf("/KeyfactorAPI/Certificates/%d/Owner", certID), func(w http.ResponseWriter, r *http.Request) {
243+
sawRequest = true
244+
body, _ := io.ReadAll(r.Body)
245+
capturedBody = strings.TrimSpace(string(body))
246+
w.WriteHeader(http.StatusNoContent)
247+
})
248+
server := httptest.NewTLSServer(mux)
249+
defer server.Close()
250+
251+
client := newCertUpdateMockClient(server)
252+
253+
state := CommandCertificate{OwnerRoleName: types.String{Value: "Administrator"}}
254+
plan := CommandCertificate{OwnerRoleName: types.String{Value: ""}}
255+
256+
if !certificateOwnerRoleChanged(plan, state) {
257+
t.Fatalf("expected certificateOwnerRoleChanged to report a change when explicitly clearing a declared owner")
258+
}
259+
260+
ownerRequest := ownerChangeRequestForPlan(plan.OwnerRoleName.Value)
261+
if err := client.ChangeCertificateOwnerRole(certID, ownerRequest); err != nil {
262+
t.Fatalf("ChangeCertificateOwnerRole returned error: %v", err)
263+
}
264+
265+
if !sawRequest {
266+
t.Fatalf("expected a PUT to /Certificates/%d/Owner, got none", certID)
267+
}
268+
if capturedBody != "{}" {
269+
t.Fatalf("expected the verified clear payload {} (both NewRoleId/NewRoleName omitted), got %q", capturedBody)
270+
}
271+
}
272+
273+
// TestUnitCertificateOwnerRoleClearNoopWhenAlreadyEmpty verifies that
274+
// declaring owner_role_name = "" against a certificate that already has no
275+
// owner (prior state Null, server-value "") is a no-op: certificateOwnerRoleChanged
276+
// must not report a change, so Update never calls the owner endpoint at all.
277+
func TestUnitCertificateOwnerRoleClearNoopWhenAlreadyEmpty(t *testing.T) {
278+
state := CommandCertificate{OwnerRoleName: types.String{Null: true}}
279+
plan := CommandCertificate{OwnerRoleName: types.String{Value: ""}}
280+
281+
if certificateOwnerRoleChanged(plan, state) {
282+
t.Fatalf("expected no owner role change when clearing an already-empty owner, but certificateOwnerRoleChanged returned true")
283+
}
284+
}
285+
286+
// TestUnitCertificateReadKeepsOwnerClearSentinel is a regression test for
287+
// sentinel stability (attribute contract item 4) on owner_role_name Read.
288+
// When the server reports no owner (serverValue == "") AND the prior state
289+
// itself declared the "" clear sentinel, Read must keep "" (Known,
290+
// non-null) rather than collapsing to Null -- otherwise the very next plan
291+
// would show a spurious `"" -> null -> ""` diff forever, since config still
292+
// declares owner_role_name = "".
293+
//
294+
// On the unfixed keepStringSentinel (which unconditionally applies
295+
// isNullString), an empty serverValue always collapses to Null regardless of
296+
// the prior sentinel, so this is red before the fix.
297+
func TestUnitCertificateReadKeepsOwnerClearSentinel(t *testing.T) {
298+
prior := types.String{Value: ""}
299+
got := keepStringSentinel("", prior)
300+
if got.Null || got.Value != "" {
301+
t.Fatalf("expected the \"\" clear sentinel to be preserved (Value=\"\", Null=false), got %+v", got)
302+
}
303+
}
304+
305+
// TestUnitCertificateReadSurfacesOwnerDriftOverSentinel verifies the other
306+
// half of sentinel stability: if a REAL owner appears out-of-band (server
307+
// reports a non-empty value) while prior state held the "" clear sentinel,
308+
// Read must surface that as drift -- never mask it with the stale sentinel.
309+
func TestUnitCertificateReadSurfacesOwnerDriftOverSentinel(t *testing.T) {
310+
prior := types.String{Value: ""}
311+
got := keepStringSentinel("SomeRole", prior)
312+
if got.Null || got.Value != "SomeRole" {
313+
t.Fatalf("expected server-side drift to be surfaced (Value=\"SomeRole\", Null=false), got %+v", got)
314+
}
315+
}

0 commit comments

Comments
 (0)