Skip to content

Commit f70bc06

Browse files
committed
Implement immutable secrets for application credentials
Deleted unused `GetApplicationCredentialFromSecret` function and introduce immutable per-rotation AC secrets with deterministic names, add Keystone-side revocation of unused rotated ACs, and suppress Owns() create events on the secret watch to prevent a race condition caused by stale informer cach and sometimes causing additional AC secret to be created and deleted immediately during rotation. Signed-off-by: Veronika Fisarova <vfisarov@redhat.com> Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent de45f3b commit f70bc06

9 files changed

+670
-154
lines changed

api/bases/keystone.openstack.org_keystoneapplicationcredentials.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,10 @@ spec:
209209
for this ApplicationCredential.
210210
format: int64
211211
type: integer
212+
previousSecretName:
213+
description: PreviousSecretName - name of the previous AC secret.
214+
Only current and previous are protected by finalizer.
215+
type: string
212216
rotationEligibleAt:
213217
description: |-
214218
RotationEligibleAt indicates when rotation becomes eligible (start of grace period window).

api/v1beta1/keystoneapplicationcredential.go

Lines changed: 59 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -18,26 +18,16 @@ package v1beta1
1818

1919
import (
2020
"context"
21-
"errors"
2221
"fmt"
2322

2423
corev1 "k8s.io/api/core/v1"
2524
k8s_errors "k8s.io/apimachinery/pkg/api/errors"
2625
"k8s.io/apimachinery/pkg/types"
2726
"sigs.k8s.io/controller-runtime/pkg/client"
28-
)
29-
30-
// ApplicationCredentialData contains AC ID/Secret extracted from a Secret
31-
// Used by service operators to get AC data from the Secret
32-
type ApplicationCredentialData struct {
33-
ID string
34-
Secret string
35-
}
3627

37-
// GetACSecretName returns the standard AC Secret name for a service
38-
func GetACSecretName(serviceName string) string {
39-
return fmt.Sprintf("ac-%s-secret", serviceName)
40-
}
28+
"github.com/openstack-k8s-operators/lib-common/modules/common/helper"
29+
"github.com/openstack-k8s-operators/lib-common/modules/common/object"
30+
)
4131

4232
// GetACCRName returns the standard AC CR name for a service
4333
func GetACCRName(serviceName string) string {
@@ -51,37 +41,68 @@ const (
5141
ACSecretSecretKey = "AC_SECRET"
5242
)
5343

54-
var (
55-
// ErrACIDMissing indicates AC_ID key missing or empty in the Secret
56-
ErrACIDMissing = errors.New("applicationcredential secret missing AC_ID")
57-
// ErrACSecretMissing indicates AC_SECRET key missing or empty in the Secret
58-
ErrACSecretMissing = errors.New("applicationcredential secret missing AC_SECRET")
59-
)
60-
61-
// GetApplicationCredentialFromSecret fetches and validates AC data from the Secret
62-
func GetApplicationCredentialFromSecret(
44+
// ManageACSecretFinalizer ensures consumerFinalizer is present on the AC secret
45+
// identified by newSecretName and absent from the one identified by
46+
// oldSecretName. It is a no-op when both names are equal.
47+
func ManageACSecretFinalizer(
6348
ctx context.Context,
64-
c client.Client,
49+
h *helper.Helper,
6550
namespace string,
66-
serviceName string,
67-
) (*ApplicationCredentialData, error) {
68-
secret := &corev1.Secret{}
69-
key := types.NamespacedName{Namespace: namespace, Name: GetACSecretName(serviceName)}
70-
if err := c.Get(ctx, key, secret); err != nil {
71-
if k8s_errors.IsNotFound(err) {
72-
return nil, nil
51+
newSecretName string,
52+
oldSecretName string,
53+
consumerFinalizer string,
54+
) error {
55+
if newSecretName == oldSecretName {
56+
return nil
57+
}
58+
59+
var newObj, oldObj client.Object
60+
61+
if newSecretName != "" {
62+
secret := &corev1.Secret{}
63+
key := types.NamespacedName{Name: newSecretName, Namespace: namespace}
64+
if err := h.GetClient().Get(ctx, key, secret); err != nil {
65+
return fmt.Errorf("failed to get new AC secret %s: %w", newSecretName, err)
7366
}
74-
return nil, fmt.Errorf("get applicationcredential secret %s: %w", key, err)
67+
newObj = secret
7568
}
7669

77-
acID, okID := secret.Data[ACIDSecretKey]
78-
if !okID || len(acID) == 0 {
79-
return nil, fmt.Errorf("%w: %s", ErrACIDMissing, key.String())
70+
if oldSecretName != "" {
71+
secret := &corev1.Secret{}
72+
key := types.NamespacedName{Name: oldSecretName, Namespace: namespace}
73+
if err := h.GetClient().Get(ctx, key, secret); err != nil {
74+
if !k8s_errors.IsNotFound(err) {
75+
return fmt.Errorf("failed to get old AC secret %s: %w", oldSecretName, err)
76+
}
77+
} else {
78+
oldObj = secret
79+
}
8080
}
81-
acSecret, okSecret := secret.Data[ACSecretSecretKey]
82-
if !okSecret || len(acSecret) == 0 {
83-
return nil, fmt.Errorf("%w: %s", ErrACSecretMissing, key.String())
81+
82+
return object.ManageConsumerFinalizer(ctx, h, newObj, oldObj, consumerFinalizer)
83+
}
84+
85+
// RemoveACSecretConsumerFinalizer removes consumerFinalizer from the AC secret
86+
// identified by secretName. It is a no-op when secretName is empty or the
87+
// secret no longer exists.
88+
func RemoveACSecretConsumerFinalizer(
89+
ctx context.Context,
90+
h *helper.Helper,
91+
namespace string,
92+
secretName string,
93+
consumerFinalizer string,
94+
) error {
95+
if secretName == "" {
96+
return nil
8497
}
8598

86-
return &ApplicationCredentialData{ID: string(acID), Secret: string(acSecret)}, nil
99+
secret := &corev1.Secret{}
100+
key := types.NamespacedName{Name: secretName, Namespace: namespace}
101+
if err := h.GetClient().Get(ctx, key, secret); err != nil {
102+
if k8s_errors.IsNotFound(err) {
103+
return nil
104+
}
105+
return err
106+
}
107+
return object.RemoveConsumerFinalizer(ctx, h, secret, consumerFinalizer)
87108
}

api/v1beta1/keystoneapplicationcredential_types.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ type KeystoneApplicationCredentialStatus struct {
9191
// SecretName - name of the k8s Secret storing the ApplicationCredential secret
9292
SecretName string `json:"secretName,omitempty"`
9393

94+
// PreviousSecretName - name of the previous AC secret. Only current and previous are protected by finalizer.
95+
PreviousSecretName string `json:"previousSecretName,omitempty"`
96+
9497
// Conditions
9598
Conditions condition.Conditions `json:"conditions,omitempty"`
9699

api/v1beta1/zz_generated.deepcopy.go

Lines changed: 0 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/crd/bases/keystone.openstack.org_keystoneapplicationcredentials.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,10 @@ spec:
209209
for this ApplicationCredential.
210210
format: int64
211211
type: integer
212+
previousSecretName:
213+
description: PreviousSecretName - name of the previous AC secret.
214+
Only current and previous are protected by finalizer.
215+
type: string
212216
rotationEligibleAt:
213217
description: |-
214218
RotationEligibleAt indicates when rotation becomes eligible (start of grace period window).

docs/applicationcredentials.md

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ status:
5353
# ACID - the ID in Keystone for this ApplicationCredential
5454
ACID: "7b23dbac20bc4f048f937415c84bb329"
5555
# SecretName - name of the k8s Secret storing the ApplicationCredential secret
56-
secretName: "ac-barbican-secret"
56+
# Format: ac-<service>-<first5ofACID>-secret
57+
secretName: "ac-barbican-7b23d-secret"
5758
# CreatedAt - timestamp of creation
5859
createdAt: "2025-05-29T09:02:28Z"
5960
# ExpiresAt - time of validity expiration
@@ -120,31 +121,38 @@ the AC controller:
120121
- Includes access rules if specified in the CR
121122

122123
8. Store Secret
123-
- Creates a k8s `Secret` named `ac-barbican-secret`
124+
- Creates a new **immutable** k8s `Secret` with a unique name: `ac-<service>-<first5ofACID>-secret`
125+
- The name includes the first 5 characters of the Keystone AC ID for uniqueness
124126
- Adds `openstack.org/ac-secret-protection` finalizer to the Secret
127+
- Sets owner reference to the AC CR (for garbage collection on CR deletion)
125128

126129
```yaml
127130
apiVersion: v1
128131
kind: Secret
129132
metadata:
130-
name: ac-barbican-secret
133+
name: ac-barbican-7b23d-secret
131134
namespace: openstack
135+
labels:
136+
application-credentials: "true"
137+
application-credential-service: barbican
132138
finalizers:
133139
- openstack.org/ac-secret-protection
140+
ownerReferences:
141+
- apiVersion: keystone.openstack.org/v1beta1
142+
kind: KeystoneApplicationCredential
143+
name: ac-barbican
144+
controller: true
145+
blockOwnerDeletion: true
146+
immutable: true
134147
data:
135148
AC_ID: <base64-of-AC-ID>
136149
AC_SECRET: <base64-of-AC-secret>
137150
```
138151

139152
9. Update CR status
140153
- Sets `.status.ACID`, `.status.secretName`, `.status.createdAt`, `.status.expiresAt`, `.status.rotationEligibleAt`
141-
- Sets `.status.lastRotated` (only during rotation, not initial creation)
154+
- Sets `.status.lastRotated` and emits `ApplicationCredentialRotated` event (only during rotation, not initial creation)
142155
- Marks AC CR ready
143-
- Emits an event for rotation to notify EDPM nodes
144-
145-
10. Requeue for Next Check
146-
- Calculates next reconcile at `expiresAt - gracePeriod`
147-
- If already in grace window, requeues immediately, otherwise requeues after 24 h
148156

149157
AC in Keystone side:
150158
```
@@ -174,15 +182,20 @@ When the next reconcile hits the grace window (`now ≥ expiresAt - gracePeriodD
174182
- Generates a new Keystone AC with a fresh 5-char suffix
175183
- Uses the same roles, unrestricted flag, access rules, and expirationDays
176184
- Does _not_ revoke the old AC, the old credential naturally expires
177-
- Store Updated Secret
178-
- Overwrites the existing `ac-barbican-secret` with the new `AC_ID` and `AC_SECRET`
185+
- Create New Immutable Secret
186+
- Creates a **new** immutable Secret with a unique name (e.g. `ac-barbican-d38dc-secret`)
187+
- The previous Secret (e.g. `ac-barbican-7b23d-secret`) is **retained** — it is not deleted
188+
- Both secrets are owned by the AC CR and will be garbage-collected when the CR is deleted
179189
- Update Status
190+
- Sets `.status.secretName` to the new Secret name
180191
- Replaces `.status.ACID`, `.status.createdAt`, `.status.expiresAt`, and `.status.rotationEligibleAt` with the new values
181192
- Sets `.status.lastRotated` to current timestamp
182193
- Re-marks AC CR ready
183-
- Emits an event to notify EDPM nodes about the rotation
184-
- Requeue
185-
- Schedules the next check at `(newExpiresAt - gracePeriodDays)`
194+
- Emits `ApplicationCredentialRotated` event for EDPM visibility
195+
- Propagation
196+
- The openstack-operator `Owns` the AC CR, so the status change triggers re-reconciliation
197+
- It reads the new `.status.secretName` and updates the service CR's `ApplicationCredentialSecret`
198+
- The service operator detects the spec change and reads credentials from the new Secret
186199

187200
## Manual Rotation
188201

@@ -203,8 +216,8 @@ This triggers seamless rotation with one pod restart and no authentication fallb
203216
ApplicationCredentials in Keystone are **not automatically deleted** by the controller. This design decision prevents disrupting running services, especially EDPM nodes that actively use these credentials.
204217

205218
**Cleanup behavior:**
206-
- **During rotation:** The old AC remains in Keystone and expires naturally based on its `expiresAt` timestamp. The new AC is created with fresh credentials.
207-
- **When AC CR is deleted:** The ApplicationCredential remains in Keystone and continues to be valid until natural expiration.
219+
- **During rotation:** The old AC remains in Keystone and expires naturally based on its `expiresAt` timestamp. The old K8s Secret is also retained (immutable). A new AC and a new immutable Secret are created.
220+
- **When AC CR is deleted:** The controller removes the `openstack.org/ac-secret-protection` finalizer from **all** AC Secrets for the service (found by label), allowing owner-reference garbage collection to delete them. The ApplicationCredential in Keystone remains valid until natural expiration.
208221
- **Manual cleanup:** If immediate cleanup is required, operators can manually delete the AC from Keystone:
209222

210223
```bash
@@ -213,30 +226,23 @@ openstack application credential delete <ac-id>
213226

214227
This approach ensures that deleting the AC CR (intentionally or accidentally) does not cause immediate authentication failures across the control plane and EDPM deployments.
215228

216-
## Client-Side Helper Functions
229+
## Exported API Helpers
217230

218-
Service operators can use these helper functions to consume ApplicationCredential data:
231+
The `keystone-operator/api/v1beta1` package exports the following helpers for use by other operators:
219232

220233
```go
221234
import keystonev1 "github.com/openstack-k8s-operators/keystone-operator/api/v1beta1"
222235
223-
// Get standard AC Secret name for a service
224-
secretName := keystonev1.GetACSecretName("barbican") // Returns "ac-barbican-secret"
225-
226236
// Get standard AC CR name for a service
227237
crName := keystonev1.GetACCRName("barbican") // Returns "ac-barbican"
228238
229-
// Fetch AC data directly from the Secret
230-
acData, err := keystonev1.GetApplicationCredentialFromSecret(
231-
ctx, client, namespace, serviceName)
232-
if err != nil {
233-
// Handle error
234-
}
235-
if acData != nil {
236-
// Use acData.ID and acData.Secret
237-
}
239+
// Secret data keys
240+
keystonev1.ACIDSecretKey // "AC_ID"
241+
keystonev1.ACSecretSecretKey // "AC_SECRET"
238242
```
239243

244+
Service operators read AC data directly from the Secret referenced by the service CR's `ApplicationCredentialSecret` field, using `ACIDSecretKey` and `ACSecretSecretKey` as the data keys.
245+
240246
## Validation Rules
241247

242248
The API includes validation constraints:

0 commit comments

Comments
 (0)