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
13 changes: 13 additions & 0 deletions api/v1/bitwardensecret_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,19 @@ type BitwardenSecretSpec struct {
// +kubebuilder:validation:Optional
// +kubebuilder:default=true
OnlyMappedSecrets bool `json:"onlyMappedSecrets"`
// ProjectIds, when specified, restricts the Kubernetes Secret to only include secrets from the specified projects.
// When empty or unset, all secrets accessible by the machine account in the organization are included.
// +kubebuilder:Optional
ProjectIds []string `json:"projectIds,omitempty"`
// UseKeyMapping, when true, automatically uses the secret key from Bitwarden Secrets Manager as the Kubernetes secret key instead of the UUID.
// When false or unset, the UUID is used as the key. This can be combined with SecretMap for additional custom mappings.
// Note: Keys are sanitized to comply with Kubernetes requirements (only alphanumerics, dash, underscore, period allowed).
// Invalid characters are replaced with underscores. A warning is logged when sanitization occurs.
// Duplicate keys (after sanitization) will log a warning and the last secret will be used.
// Defaults to false for backward compatibility.
// +kubebuilder:validation:Optional
// +kubebuilder:default=false
UseKeyMapping bool `json:"useKeyMapping"`
}

type AuthToken struct {
Expand Down
5 changes: 5 additions & 0 deletions api/v1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions config/crd/bases/k8s.bitwarden.com_bitwardensecrets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,26 @@ spec:
organizationId:
description: The organization ID for your organization
type: string
projectIds:
description: |-
ProjectIds, when specified, restricts the Kubernetes Secret to only include secrets from the specified projects.
When empty or unset, all secrets accessible by the machine account in the organization are included.
items:
type: string
type: array
secretName:
description: The name of the secret for the
type: string
useKeyMapping:
default: false
description: |-
UseKeyMapping, when true, automatically uses the secret key from Secrets Manager as the Kubernetes secret key instead of the UUID.
When false or unset, the UUID is used as the key. This can be combined with SecretMap for additional custom mappings.
Note: Keys are sanitized to comply with Kubernetes requirements (only alphanumerics, dash, underscore, period allowed).
Invalid characters are replaced with underscores. A warning is logged when sanitization occurs.
Duplicate keys (after sanitization) will log a warning and the last secret will be used.
Defaults to false for backward compatibility.
type: boolean
required:
- authToken
- organizationId
Expand Down
9 changes: 9 additions & 0 deletions config/samples/k8s_v1_bitwardensecret.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ spec:
secretKeyName: test__secret__1
- bwSecretId: 9f66ccaf-998e-4e5d-9294-b155012db579
secretKeyName: test__secret__2
# Optional: Filter secrets by project IDs
# projectIds:
# - "550e8400-e29b-41d4-a716-446655440000"
# - "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
# Optional: Automatically use secret keys from Bitwarden Secrets Manager instead of UUIDs as K8s secret keys
# This can be combined with explicit map entries above for additional customization
# Note: Keys are sanitized for Kubernetes compliance (invalid characters replaced with underscores)
# Note: Duplicate keys (after sanitization) will log warnings and the last secret will be used
# useKeyMapping: true
authToken:
secretName: bw-auth-token
secretKey: token
85 changes: 75 additions & 10 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, secretKeys, err := r.PullSecretManagerSecretDeltas(logger, orgId, authToken, lastSync.Time, bwSecret.Spec.ProjectIds)

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 @@ -180,7 +180,7 @@ func (r *BitwardenSecretReconciler) Reconcile(ctx context.Context, req ctrl.Requ
}
k8sSecret.ObjectMeta.Labels[LabelBwSecret] = string(bwSecret.UID)

ApplySecretMap(secrets, bwSecret, k8sSecret)
ApplySecretMap(logger, secrets, secretKeys, bwSecret, k8sSecret)

err = r.SetK8sSecretAnnotations(bwSecret, k8sSecret)

Expand Down Expand Up @@ -269,42 +269,61 @@ func (r *BitwardenSecretReconciler) LogCompletion(logger logr.Logger, ctx contex
// 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 third returned value is a mapping of secret IDs to their keys from Secrets Manager
func (r *BitwardenSecretReconciler) PullSecretManagerSecretDeltas(logger logr.Logger, orgId string, authToken string, lastSync time.Time, projectIds []string) (bool, map[string][]byte, map[string]string, error) {
bitwardenClient, err := r.BitwardenClientFactory.GetBitwardenClient()
if err != nil {
logger.Error(err, "Failed to create client")
return false, nil, err
return false, nil, nil, err
}

err = bitwardenClient.AccessTokenLogin(authToken, &r.StatePath)
if err != nil {
logger.Error(err, "Failed to authenticate")
return false, nil, err
return false, nil, nil, err
}

secrets := map[string][]byte{}
secretKeys := map[string]string{}

smSecretResponse, err := bitwardenClient.Secrets().Sync(orgId, &lastSync)

if err != nil {
logger.Error(err, "Failed to get secrets since last sync.")
return false, nil, err
return false, nil, nil, err
}

if smSecretResponse == nil {
logger.Info("No secret response from Bitwarden")
return false, nil, nil
return false, nil, nil, nil
}

smSecretVals := smSecretResponse.Secrets

// Build a map of project IDs for filtering if specified
projectFilter := make(map[string]bool)
if len(projectIds) > 0 {
for _, projectId := range projectIds {
projectFilter[projectId] = true
}
}

for _, smSecretVal := range smSecretVals {
// Filter by project if projectIds are specified
if len(projectFilter) > 0 {
// Only include secrets that belong to one of the specified projects
if smSecretVal.ProjectID == nil || !projectFilter[*smSecretVal.ProjectID] {
continue
}
}

secrets[smSecretVal.ID] = []byte(smSecretVal.Value)
secretKeys[smSecretVal.ID] = smSecretVal.Key
}

defer bitwardenClient.Close()

return smSecretResponse.HasChanges, secrets, nil
return smSecretResponse.HasChanges, secrets, secretKeys, nil
}

func CreateK8sSecret(bwSecret *operatorsv1.BitwardenSecret) *corev1.Secret {
Expand All @@ -321,7 +340,7 @@ func CreateK8sSecret(bwSecret *operatorsv1.BitwardenSecret) *corev1.Secret {
return secret
}

func ApplySecretMap(secrets map[string][]byte, bwSecret *operatorsv1.BitwardenSecret, k8sSecret *corev1.Secret) {
func ApplySecretMap(logger logr.Logger, secrets map[string][]byte, secretKeys map[string]string, bwSecret *operatorsv1.BitwardenSecret, k8sSecret *corev1.Secret) {
k8sSecret.Data = make(map[string][]byte)

//If we are doing a straight up synch with no map, dump them across and return
Expand All @@ -330,21 +349,67 @@ func ApplySecretMap(secrets map[string][]byte, bwSecret *operatorsv1.BitwardenSe
return
}

// Track seen keys for duplicate detection when using UseKeyMapping
seenKeys := make(map[string]bool)

for key, secret := range secrets {
mapping, isThere := FindSecretMapByBwSecretId(&bwSecret.Spec, key) //see if this particular secret is in the map
if bwSecret.Spec.OnlyMappedSecrets && !isThere {
continue //Not in map and we're only synching mapped secrets, so move on.
}

targetKey := key //defaulting to BwSecretId
targetKey := key //defaulting to BwSecretId (UUID)

// Priority order for key selection:
// 1. Explicit SecretMap mapping (highest priority)
// 2. UseKeyMapping (use secret key from Secrets Manager)
// 3. Default to UUID
if isThere {
targetKey = mapping.SecretKeyName //Found in map, so set the target key to the alias
} else if bwSecret.Spec.UseKeyMapping {
if secretKey, ok := secretKeys[key]; ok && secretKey != "" {
// Sanitize the key
sanitizedKey := sanitizeSecretKey(secretKey)

// Check for duplicate keys after sanitization
if seenKeys[sanitizedKey] {
logger.Info("Warning: Duplicate secret key detected after sanitization. The last secret with this key will be used.", "originalKey", secretKey, "sanitizedKey", sanitizedKey, "secretId", key)
}
seenKeys[sanitizedKey] = true

// Warn if the key needed sanitization
if sanitizedKey != secretKey {
logger.Info("Warning: Secret key contains characters not allowed by Kubernetes. It has been sanitized.", "originalKey", secretKey, "sanitizedKey", sanitizedKey, "secretId", key)
}

targetKey = sanitizedKey //Use the sanitize key from Secrets Manager
}
}

k8sSecret.Data[targetKey] = secret
}
}

// sanitizeSecretKey converts a secret key to be POSIX-compliant
// This function replaces any invalid character with an underscore
func sanitizeSecretKey(s string) string {
if len(s) == 0 {
return s
}

result := make([]rune, 0, len(s))
for _, c := range s {
// Kubernetes allows: alphanumerics, dash, underscore, and period
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' {
result = append(result, c)
} else {
// Replace invalid characters with underscore
result = append(result, '_')
}
}
return string(result)
}

// FindSecretMapByBwSecretId returns the SecretMap entry with the specified BwSecretId, if found.
func FindSecretMapByBwSecretId(spec *operatorsv1.BitwardenSecretSpec, bwSecretId string) (operatorsv1.SecretMap, bool) {
if spec.SecretMap == nil {
Expand Down