Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,56 @@ Our operator is designed to look for the creation of a custom resource called a
- **spec.organizationId**: The Bitwarden organization ID you are pulling Secrets Manager data from
- **spec.secretName**: The name of the Kubernetes secret that will be created and injected with Secrets Manager data.
- **spec.authToken**: The name of a secret inside of the Kubernetes namespace that the BitwardenSecrets object is being deployed into that contains the Secrets Manager machine account authorization token being used to access secrets.
- **spec.useSecretNames** (optional): When set to `true`, uses secret names from Bitwarden Secrets Manager as Kubernetes secret keys instead of UUIDs. Default: `false`.

Secrets Manager does not guarantee unique secret names across projects, so by default secrets will be created with the Secrets Manager secret UUID used as the key. To make your generated secret easier to use, you can create a map of Bitwarden Secret IDs to Kubernetes secret keys. The generated secret will replace the Bitwarden Secret IDs with the mapped friendly name you provide. Below are the map settings available:
#### Secret Key Naming

By default, secrets are created with the Secrets Manager secret UUID used as the key.

**Option 1: Use Secret Names**

Set `useSecretNames: true` to use the secret names from Bitwarden as Kubernetes secret keys:

```yaml
spec:
useSecretNames: true
```
When enabled:
- Secret names from Bitwarden Secrets Manager will be used as Kubernetes secret keys
- Secret names should be POSIX-compliant for best environment variable compatibility:
- Should start with a letter (`a-z`, `A-Z`) or underscore (`_`)
- Should contain only letters, digits (`0-9`), and underscores (`_`)
- Warnings will be logged for non-compliant names (e.g., names with dashes, dots, or starting with digits)
- Secret names **must be unique** across all accessible secrets (duplicates will cause sync failure)

**Note:** While Kubernetes accepts various characters in Secret keys, the operator warns about non-POSIX-compliant names that may not work optimally as environment variables. The secrets will still sync, but you may encounter issues when using them in certain contexts.

Example result:
```yaml
Data:
DATABASE_PASSWORD: <value>
API_KEY: <value>
REDIS_URL: <value>
```

**Option 2: Use UUID Keys with Mapping (Default)**

By default, secrets use UUIDs as keys. To make your generated secret easier to use, you can create a map of Bitwarden Secret IDs to Kubernetes secret keys. The generated secret will replace the Bitwarden Secret IDs with the mapped friendly name you provide. Below are the map settings available:

- **bwSecretId**: This is the UUID of the secret in Secrets Manager. This can found under the secret name in the Secrets Manager web portal or by using the [Bitwarden Secrets Manager CLI](https://github.com/bitwarden/sdk/releases).
- **secretKeyName**: The resulting key inside the Kubernetes secret that replaces the UUID

Example:
```yaml
spec:
map:
- bwSecretId: e30f88bd-9e9c-42ae-83b7-b155012da672
secretKeyName: DATABASE_PASSWORD
- bwSecretId: 9f66ccaf-998e-4e5d-9294-b155012db579
secretKeyName: API_KEY
```

Note that the custom mapping is made available on the generated secret for informational purposes in the `k8s.bitwarden.com/custom-map` annotation.

#### Creating a BitwardenSecret object
Expand Down
8 changes: 8 additions & 0 deletions api/v1/bitwardensecret_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ type BitwardenSecretSpec struct {
// +kubebuilder:validation:Optional
// +kubebuilder:default=true
OnlyMappedSecrets bool `json:"onlyMappedSecrets"`
// UseSecretNames, when true, uses the secret names from Bitwarden Secrets Manager as Kubernetes secret keys.
// When false or unset (default), uses secret UUIDs as keys (preserving backward compatibility).
// When enabled, warnings are logged for non-POSIX-compliant names (e.g., containing dashes, dots, or starting with digits).
// Secret names must be unique across all accessible secrets - duplicates will cause synchronization failure.
// Defaults to false.
// +kubebuilder:validation:Optional
// +kubebuilder:default=false
UseSecretNames bool `json:"useSecretNames,omitempty"`
}

type AuthToken struct {
Expand Down
9 changes: 9 additions & 0 deletions config/crd/bases/k8s.bitwarden.com_bitwardensecrets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ spec:
secretName:
description: The name of the secret for the
type: string
useSecretNames:
default: false
description: |-
UseSecretNames, when true, uses the secret names from Bitwarden Secrets Manager as Kubernetes secret keys.
When false or unset (default), uses secret UUIDs as keys (preserving backward compatibility).
When enabled, secret names must be POSIX-compliant (start with letter/underscore, contain only alphanumeric/underscore)
and must be unique across all accessible secrets. Validation errors will prevent secret synchronization.
Defaults to false.
type: boolean
required:
- authToken
- organizationId
Expand Down
8 changes: 8 additions & 0 deletions config/samples/k8s_v1_bitwardensecret.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ metadata:
spec:
organizationId: "a08a8157-129e-4002-bab4-b118014ca9c7"
secretName: bw-sample-secret

# Optional: Use secret names from Bitwarden as Kubernetes secret keys
# When enabled, secret names must be unique. Secret names that are not
# POSIX-compliant will trigger a warning, but will be set on a best-effort
# basis.
# Default: false (uses UUIDs as keys for backward compatibility)
# useSecretNames: true

# map: []
map:
- bwSecretId: e30f88bd-9e9c-42ae-83b7-b155012da672
Expand Down
90 changes: 85 additions & 5 deletions internal/controller/bitwardensecret_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ func (r *BitwardenSecretReconciler) Reconcile(ctx context.Context, req ctrl.Requ

//Get the secrets from the Bitwarden API based on lastSync and organizationId
//This will also indicate if the Bitwarden secret needs to be refreshed
refresh, secrets, err := r.PullSecretManagerSecretDeltas(logger, orgId, authToken, lastSync.Time)
refresh, secrets, err := r.PullSecretManagerSecretDeltas(logger, orgId, authToken, lastSync.Time, bwSecret.Spec.UseSecretNames)

if err != nil {
logErr := r.LogError(logger, ctx, bwSecret, err, fmt.Sprintf("Error pulling Secret Manager secrets from API => API: %s -- Identity: %s -- State: %s -- OrgId: %s ", r.BitwardenClientFactory.GetApiUrl(), r.BitwardenClientFactory.GetIdentityApiUrl(), r.StatePath, orgId))
Expand Down Expand Up @@ -266,10 +266,41 @@ func (r *BitwardenSecretReconciler) LogCompletion(logger logr.Logger, ctx contex
return nil
}

// ValidateSecretKeyName validates that a secret key name is POSIX-compliant.
// POSIX-compliant names are recommended for maximum compatibility:
// - Must start with a letter (a-z, A-Z) or underscore (_)
// - Can only contain letters, digits (0-9), and underscores
// - Cannot be empty
func ValidateSecretKeyName(key string) error {
if key == "" {
return fmt.Errorf("secret key cannot be empty")
}

// Check first character
firstChar := key[0]
if !((firstChar >= 'a' && firstChar <= 'z') ||
(firstChar >= 'A' && firstChar <= 'Z') ||
firstChar == '_') {
return fmt.Errorf("secret key '%s' must start with a letter or underscore", key)
}

// Check remaining characters
for i, char := range key {
if !((char >= 'a' && char <= 'z') ||
(char >= 'A' && char <= 'Z') ||
(char >= '0' && char <= '9') ||
char == '_') {
return fmt.Errorf("secret key '%s' contains invalid character '%c' at position %d (only alphanumeric and underscore allowed)", key, char, i)
}
}

return nil
}

// This function will determine if any secrets have been updated and return all secrets assigned to the machine account if so.
// First returned value is a boolean stating if something changed or not.
// The second returned value is a mapping of secret IDs and their values from Secrets Manager
func (r *BitwardenSecretReconciler) PullSecretManagerSecretDeltas(logger logr.Logger, orgId string, authToken string, lastSync time.Time) (bool, map[string][]byte, error) {
// The second returned value is a mapping of secret IDs (or names if useSecretNames is true) and their values from Secrets Manager
func (r *BitwardenSecretReconciler) PullSecretManagerSecretDeltas(logger logr.Logger, orgId string, authToken string, lastSync time.Time, useSecretNames bool) (bool, map[string][]byte, error) {
bitwardenClient, err := r.BitwardenClientFactory.GetBitwardenClient()
if err != nil {
logger.Error(err, "Failed to create client")
Expand Down Expand Up @@ -298,12 +329,61 @@ func (r *BitwardenSecretReconciler) PullSecretManagerSecretDeltas(logger logr.Lo

smSecretVals := smSecretResponse.Secrets

// Use UUIDs as keys
if !useSecretNames {
for _, smSecretVal := range smSecretVals {
secrets[smSecretVal.ID] = []byte(smSecretVal.Value)
}
defer bitwardenClient.Close()
return smSecretResponse.HasChanges, secrets, nil
}

// Use secret names with validation and duplicate detection
seenKeys := make(map[string][]string) // Track duplicates: key -> []secretIDs

// First pass: validate POSIX compliance (warn) and detect duplicates (error)
for _, smSecretVal := range smSecretVals {
secrets[smSecretVal.ID] = []byte(smSecretVal.Value)
secretKey := smSecretVal.Key

// Validate POSIX compliance
if err := ValidateSecretKeyName(secretKey); err != nil {
logger.Info("Secret name is not POSIX-compliant and may not work as an environment variable",
"secretId", smSecretVal.ID,
"secretKey", secretKey,
"warning", err.Error())
}

// Track for duplicate detection
seenKeys[secretKey] = append(seenKeys[secretKey], smSecretVal.ID)
}

defer bitwardenClient.Close()
// Check for duplicates
var duplicates []string
for key, ids := range seenKeys {
if len(ids) > 1 {
duplicates = append(duplicates,
fmt.Sprintf("'%s' (IDs: %v)", key, ids))
}
}

// Fail if duplicates found
if len(duplicates) > 0 {
errMsg := "Duplicate secret key names detected:\n"
for _, dup := range duplicates {
errMsg += fmt.Sprintf(" - %s\n", dup)
}
errMsg += "\nMultiple secrets with the same name. Use unique names for secrets or disable useSecretNames."

defer bitwardenClient.Close()
return false, nil, fmt.Errorf(errMsg)
}

// Second pass: build the secrets map using names
for _, smSecretVal := range smSecretVals {
secrets[smSecretVal.Key] = []byte(smSecretVal.Value)
}

defer bitwardenClient.Close()
return smSecretResponse.HasChanges, secrets, nil
}

Expand Down
86 changes: 85 additions & 1 deletion internal/controller/test/reconciler_edge_cases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ var _ = Describe("BitwardenSecret Reconciler - Edge Case Tests", Ordered, func()
for i := range largeNumOfSecrets {
identifier := sdk.SecretIdentifierResponse{
ID: uuid.NewString(),
Key: uuid.NewString(),
Key: fmt.Sprintf("secret_%d", i), // Use secret name
Copy link
Contributor

Choose a reason for hiding this comment

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

Could we make a new test for the useSecretName functionality and add that, rather than replacing this one?

Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
Key: fmt.Sprintf("secret_%d", i), // Use secret name
Key: uuid.NewString(),

OrganizationID: fixture.OrgId,
}
largeSecretMap = append(largeSecretMap, operatorsv1.SecretMap{
Expand Down Expand Up @@ -233,4 +233,88 @@ var _ = Describe("BitwardenSecret Reconciler - Edge Case Tests", Ordered, func()
Expect(condition.Status).To(Equal(metav1.ConditionTrue))
Expect(updatedBwSecret.Status.LastSuccessfulSyncTime.Time).NotTo(BeZero())
})

It("should successfully sync using secret names (useSecretNames mode)", func() {
// Configure mocks with secret names instead of UUIDs
secretNamesData := []sdk.SecretResponse{}
for i := range 10 {
identifier := sdk.SecretIdentifierResponse{
ID: uuid.NewString(),
Key: fmt.Sprintf("secret_%d", i), // Use secret names
OrganizationID: fixture.OrgId,
}
projectId := uuid.NewString()
secretNamesData = append(secretNamesData, sdk.SecretResponse{
CreationDate: time.Now().String(),
ID: identifier.ID,
Key: identifier.Key,
Note: uuid.NewString(),
OrganizationID: fixture.OrgId,
ProjectID: &projectId,
RevisionDate: time.Now().String(),
Value: uuid.NewString(),
})
}
secretNamesResponse := sdk.SecretsSyncResponse{
HasChanges: true,
Secrets: secretNamesData,
}

fixture.SetupDefaultCtrlMocks(false, &secretNamesResponse)

_, err := fixture.CreateDefaultAuthSecret(namespace)
Expect(err).NotTo(HaveOccurred())

// Create BitwardenSecret with useSecretNames enabled and no SecretMap
bwSecret, err := fixture.CreateBitwardenSecret(
testutils.BitwardenSecretName,
namespace,
fixture.OrgId,
testutils.SynchronizedSecretName,
testutils.AuthSecretName,
testutils.AuthSecretKey,
[]operatorsv1.SecretMap{}, // No SecretMap needed with useSecretNames
false, // OnlyMappedSecrets
)
Expect(err).NotTo(HaveOccurred())
Expect(bwSecret).NotTo(BeNil())

// Enable useSecretNames
bwSecret.Spec.UseSecretNames = true
err = fixture.K8sClient.Update(fixture.Ctx, bwSecret)
Expect(err).NotTo(HaveOccurred())

// Trigger reconciliation
req := reconcile.Request{NamespacedName: types.NamespacedName{Name: testutils.BitwardenSecretName, Namespace: namespace}}
result, err := fixture.Reconciler.Reconcile(fixture.Ctx, req)

// Verify reconciliation succeeded
Expect(err).NotTo(HaveOccurred())
Expect(result.RequeueAfter).To(Equal(time.Duration(fixture.Reconciler.RefreshIntervalSeconds) * time.Second))

Eventually(func(g Gomega) {
// Verify created Kubernetes secret
createdTargetSecret := &corev1.Secret{}
g.Expect(fixture.K8sClient.Get(fixture.Ctx, types.NamespacedName{Name: testutils.SynchronizedSecretName, Namespace: namespace}, createdTargetSecret)).Should(Succeed())

// Check secret metadata and type
g.Expect(createdTargetSecret.Labels[controller.LabelBwSecret]).To(Equal(string(bwSecret.UID)))
g.Expect(createdTargetSecret.Type).To(Equal(corev1.SecretTypeOpaque))

// Verify all secrets are synced using their names as keys
g.Expect(len(createdTargetSecret.Data)).To(Equal(10))
for i := range 10 {
expectedKey := fmt.Sprintf("secret_%d", i)
g.Expect(createdTargetSecret.Data).To(HaveKey(expectedKey))
}

// Verify BitwardenSecret status
updatedBwSecret := &operatorsv1.BitwardenSecret{}
g.Expect(fixture.K8sClient.Get(fixture.Ctx, types.NamespacedName{Name: testutils.BitwardenSecretName, Namespace: namespace}, updatedBwSecret)).Should(Succeed())
condition := apimeta.FindStatusCondition(updatedBwSecret.Status.Conditions, "SuccessfulSync")
g.Expect(condition).NotTo(BeNil())
g.Expect(condition.Status).To(Equal(metav1.ConditionTrue))
g.Expect(updatedBwSecret.Status.LastSuccessfulSyncTime.Time).NotTo(BeZero())
})
})
})
2 changes: 1 addition & 1 deletion internal/controller/test/testutils/fixture.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func (f *TestFixture) setup(t *testing.T, runner *EnvTestRunner) {
for secretCount := range ExpectedNumOfSecrets {
identifier := sdk.SecretIdentifierResponse{
ID: uuid.NewString(),
Key: uuid.NewString(),
Key: fmt.Sprintf("secret_%d", secretCount), // Use secret name
}

//build a map mapping each Identifier to an human readable name based on index
Expand Down
Loading