Skip to content

Commit

Permalink
HVS: basic dynamic secrets support
Browse files Browse the repository at this point in the history
Everything but caching dynamic secret responses
  • Loading branch information
tvoran committed Sep 11, 2024
1 parent ae8f8d4 commit fe9158f
Show file tree
Hide file tree
Showing 8 changed files with 460 additions and 11 deletions.
30 changes: 30 additions & 0 deletions api/v1beta1/hcpvaultsecretsapp_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,33 @@ type HCPVaultSecretsAppSpec struct {
// Destination provides configuration necessary for syncing the HCP Vault
// Application secrets to Kubernetes.
Destination Destination `json:"destination"`
// SyncConfig configures sync behavior from HVS to VSO
SyncConfig *HVSSyncConfig `json:"syncConfig,omitempty"`
}

// HVSSyncConfig configures sync behavior from HVS to VSO
type HVSSyncConfig struct {
// Dynamic configures sync behavior for dynamic secrets.
Dynamic *HVSDynamicSyncConfig `json:"dynamic,omitempty"`
}

// HVSDynamicSyncConfig configures sync behavior for HVS dynamic secrets.
type HVSDynamicSyncConfig struct {
// RenewalPercent is the percent out of 100 of a dynamic secret's TTL when
// new secrets are generated. Defaults to 67 percent plus jitter.
// +kubebuilder:default=67
// +kubebuilder:validation:Minimum=0
// +kubebuilder:validation:Maximum=90
RenewalPercent int `json:"renewalPercent,omitempty"`
}

// HVSDynamicStatus defines the observed state of a dynamic secret within an HCP
// Vault Secrets App
type HVSDynamicStatus struct {
Name string `json:"name,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
ExpiresAt string `json:"expiresAt,omitempty"`
TTL string `json:"ttl,omitempty"`
}

// HCPVaultSecretsAppStatus defines the observed state of HCPVaultSecretsApp
Expand All @@ -47,6 +74,9 @@ type HCPVaultSecretsAppStatus struct {
// The SecretMac is also used to detect drift in the Destination Secret's Data.
// If drift is detected the data will be synced to the Destination.
SecretMAC string `json:"secretMAC,omitempty"`
// DynamicSecrets lists the last observed state of any dynamic secrets
// within the HCP Vault Secrets App
DynamicSecrets []HVSDynamicStatus `json:"dynamicSecrets,omitempty"`
}

//+kubebuilder:object:root=true
Expand Down
62 changes: 61 additions & 1 deletion api/v1beta1/zz_generated.deepcopy.go

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

35 changes: 35 additions & 0 deletions chart/crds/secrets.hashicorp.com_hcpvaultsecretsapps.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -244,13 +244,48 @@ spec:
- name
type: object
type: array
syncConfig:
description: SyncConfig configures sync behavior from HVS to VSO
properties:
dynamic:
description: Dynamic configures sync behavior for dynamic secrets.
properties:
renewalPercent:
default: 67
description: |-
RenewalPercent is the percent out of 100 of a dynamic secret's TTL when
new secrets are generated. Defaults to 67 percent plus jitter.
maximum: 90
minimum: 0
type: integer
type: object
type: object
required:
- appName
- destination
type: object
status:
description: HCPVaultSecretsAppStatus defines the observed state of HCPVaultSecretsApp
properties:
dynamicSecrets:
description: |-
DynamicSecrets lists the last observed state of any dynamic secrets
within the HCP Vault Secrets App
items:
description: |-
HVSDynamicStatus defines the observed state of a dynamic secret within an HCP
Vault Secrets App
properties:
createdAt:
type: string
expiresAt:
type: string
name:
type: string
ttl:
type: string
type: object
type: array
lastGeneration:
description: LastGeneration is the Generation of the last reconciled
resource.
Expand Down
35 changes: 35 additions & 0 deletions config/crd/bases/secrets.hashicorp.com_hcpvaultsecretsapps.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -244,13 +244,48 @@ spec:
- name
type: object
type: array
syncConfig:
description: SyncConfig configures sync behavior from HVS to VSO
properties:
dynamic:
description: Dynamic configures sync behavior for dynamic secrets.
properties:
renewalPercent:
default: 67
description: |-
RenewalPercent is the percent out of 100 of a dynamic secret's TTL when
new secrets are generated. Defaults to 67 percent plus jitter.
maximum: 90
minimum: 0
type: integer
type: object
type: object
required:
- appName
- destination
type: object
status:
description: HCPVaultSecretsAppStatus defines the observed state of HCPVaultSecretsApp
properties:
dynamicSecrets:
description: |-
DynamicSecrets lists the last observed state of any dynamic secrets
within the HCP Vault Secrets App
items:
description: |-
HVSDynamicStatus defines the observed state of a dynamic secret within an HCP
Vault Secrets App
properties:
createdAt:
type: string
expiresAt:
type: string
name:
type: string
ttl:
type: string
type: object
type: array
lastGeneration:
description: LastGeneration is the Generation of the last reconciled
resource.
Expand Down
117 changes: 108 additions & 9 deletions controllers/hcpvaultsecretsapp_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

httptransport "github.com/go-openapi/runtime/client"
hvsclient "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service"
"github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/models"
hcpconfig "github.com/hashicorp/hcp-sdk-go/config"
hcpclient "github.com/hashicorp/hcp-sdk-go/httpclient"
"github.com/hashicorp/hcp-sdk-go/profile"
Expand Down Expand Up @@ -40,6 +41,7 @@ const (
headerUserAgent = "User-Agent"

hcpVaultSecretsAppFinalizer = "hcpvaultsecretsapp.secrets.hashicorp.com/finalizer"
defaultDyanmicRenewPercent = 67
)

var userAgent = fmt.Sprintf("vso/%s", version.Version().String())
Expand Down Expand Up @@ -130,25 +132,43 @@ func (r *HCPVaultSecretsAppReconciler) Reconcile(ctx context.Context, req ctrl.R

resp, err := c.OpenAppSecrets(params, nil)
if err != nil {
logger.Error(err, "Get App Secret", "appName", o.Spec.AppName)
logger.Error(err, "Get App Secrets", "appName", o.Spec.AppName)
entry, _ := r.BackOffRegistry.Get(req.NamespacedName)
return ctrl.Result{
RequeueAfter: entry.NextBackOff(),
}, nil
} else {
r.BackOffRegistry.Delete(req.NamespacedName)
}

dynamicSecrets, err := getHVSDynamicSecrets(ctx, c, o.Spec.AppName)
if err != nil {
logger.Error(err, "Get Dynamic Secrets", "appName", o.Spec.AppName)
entry, _ := r.BackOffRegistry.Get(req.NamespacedName)
return ctrl.Result{
RequeueAfter: entry.NextBackOff(),
}, nil
}
// Add the dynamic secrets to the OpenAppSecrets response to be processed
// along with the rest of the App secrets
resp.Payload.Secrets = append(resp.Payload.Secrets, dynamicSecrets...)

// Calculate next requeue time based on whichever comes first between the
// current `requeueAfter` and all the dynamic secret renewal times. Also set
// the dynamic secret statuses while looping through the dynamic secrets.
renewPercent := makeDyanmicRenewPercent(o.Spec.SyncConfig)
o.Status.DynamicSecrets = nil
for _, secret := range dynamicSecrets {
requeueAfter = getNextRequeue(requeueAfter, secret.DynamicInstance, renewPercent)
o.Status.DynamicSecrets = append(o.Status.DynamicSecrets, makeHVSDynamicStatus(secret))
}

// Remove this app from the backoff registry now that we're done with HVS
// API calls
r.BackOffRegistry.Delete(req.NamespacedName)

r.referenceCache.Set(SecretTransformation, req.NamespacedName,
helpers.GetTransformationRefObjKeys(
o.Spec.Destination.Transformation, o.Namespace)...)

if err != nil {
r.Recorder.Eventf(o, corev1.EventTypeWarning, consts.ReasonTransformationError,
"Failed setting up SecretTransformationOption: %s", err)
return ctrl.Result{RequeueAfter: computeHorizonWithJitter(requeueDurationOnError)}, nil
}

data, err := r.SecretDataBuilder.WithHVSAppSecrets(resp, transOption)
if err != nil {
r.Recorder.Eventf(o, corev1.EventTypeWarning, consts.ReasonSecretDataBuilderError,
Expand Down Expand Up @@ -318,3 +338,82 @@ func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) {
func injectRequestInformation(runtime *httptransport.Runtime) {
runtime.Transport = &transport{child: runtime.Transport}
}

// getHVSDynamicSecrets
func getHVSDynamicSecrets(ctx context.Context, c hvsclient.ClientService, appName string) ([]*models.Secrets20231128OpenSecret, error) {
// Fetch the unopened AppSecrets to get the full list of secrets (including
// dynamic)
secretsListParams := &hvsclient.ListAppSecretsParams{
Context: ctx,
AppName: appName,
// Type is currently non-functional, so we have to filter the list
// ourselves
// Type: ptr.To(helpers.HVSSecretTypeDynamic),
}
appSecretsList, err := c.ListAppSecrets(secretsListParams, nil)
if err != nil {
return nil, fmt.Errorf("failed to list app Secrets for app %s: %w", appName, err)
}

dynamicSecrets := make([]*models.Secrets20231128OpenSecret, 0)

if appSecretsList.Payload != nil {
// TODO(tvoran): only fetch/create dynamic secrets that are new or are
// due for rotation
for _, appSecret := range appSecretsList.Payload.Secrets {
if appSecret.Type != helpers.HVSSecretTypeDynamic {
continue
}
secretParams := &hvsclient.OpenAppSecretParams{
Context: ctx,
AppName: appName,
SecretName: appSecret.Name,
}
dynamicResp, err := c.OpenAppSecret(secretParams, nil)
if err != nil {
return nil, fmt.Errorf("failed to get dynamic secret %s in app %s: %w",
appSecret.Name, appName, err)
}
if dynamicResp != nil && dynamicResp.Payload != nil {
dynamicSecrets = append(dynamicSecrets, dynamicResp.Payload.Secret)
}
}
}

return dynamicSecrets, nil
}

func makeDyanmicRenewPercent(syncConfig *secretsv1beta1.HVSSyncConfig) int {
renewPercent := defaultDyanmicRenewPercent
if syncConfig != nil && syncConfig.Dynamic != nil && syncConfig.Dynamic.RenewalPercent != 0 {
renewPercent = syncConfig.Dynamic.RenewalPercent
}
return renewPercent
}

func makeHVSDynamicStatus(secret *models.Secrets20231128OpenSecret) secretsv1beta1.HVSDynamicStatus {
status := secretsv1beta1.HVSDynamicStatus{
Name: secret.Name,
}
if secret.DynamicInstance != nil {
status.CreatedAt = secret.DynamicInstance.CreatedAt.String()
status.ExpiresAt = secret.DynamicInstance.ExpiresAt.String()
status.TTL = secret.DynamicInstance.TTL
}
return status
}

// getNextRequeue compares returns whichever is less between the current
// `requeueAfter` and the `ExpiresAt` of the dynamic secret instance
func getNextRequeue(requeueAfter time.Duration, dynamicInstance *models.Secrets20231128OpenSecretDynamicInstance, renewPercent int) time.Duration {
if dynamicInstance == nil {
return requeueAfter
}
nextRequeue := requeueAfter
next := time.Until(time.Time(dynamicInstance.ExpiresAt))
renewalTime := computeDynamicHorizonWithJitter(next, renewPercent)
if renewalTime < requeueAfter {
nextRequeue = renewalTime
}
return nextRequeue
}
Loading

0 comments on commit fe9158f

Please sign in to comment.