From 6603f9f8974521e9a590314cba931b2d672a9bea Mon Sep 17 00:00:00 2001 From: Christian De Leon Date: Tue, 3 Mar 2026 18:24:33 -0500 Subject: [PATCH 1/6] feat(model): add section and field metadata to Item Add ID, SectionID, and FieldType fields to ItemField. Add ItemSection struct and Sections slice to Item. Enrich FromConnectItem and FromSDKItem to populate section metadata from the respective SDK types. This metadata is needed to support Go template rendering where users can reference fields by section (e.g. .Sections.Database.username). --- pkg/onepassword/model/item.go | 50 ++++++++++++++++++++++-- pkg/onepassword/model/item_field.go | 7 +++- pkg/onepassword/model/item_test.go | 59 +++++++++++++++++++++++++++-- 3 files changed, 106 insertions(+), 10 deletions(-) diff --git a/pkg/onepassword/model/item.go b/pkg/onepassword/model/item.go index 119d87dd..8cc5726b 100644 --- a/pkg/onepassword/model/item.go +++ b/pkg/onepassword/model/item.go @@ -14,17 +14,25 @@ type Item struct { Version int Tags []string URLs []ItemURL + Sections []ItemSection Fields []ItemField Files []File CreatedAt time.Time } +// ItemURL represents a URL associated with a 1Password item. type ItemURL struct { URL string Label string Primary bool } +// ItemSection represents a section within a 1Password item. +type ItemSection struct { + ID string + Title string +} + // FromConnectItem populates the Item from a Connect item. func (i *Item) FromConnectItem(item *connect.Item) { i.ID = item.ID @@ -41,10 +49,29 @@ func (i *Item) FromConnectItem(item *connect.Item) { }) } + // Build sections from field references. The Connect SDK stores section + // info on each field rather than as a top-level list. + sectionSeen := make(map[string]bool) for _, field := range item.Fields { + sectionID := "" + if field.Section != nil { + sectionID = field.Section.ID + if !sectionSeen[sectionID] { + sectionSeen[sectionID] = true + title := field.Section.Label + i.Sections = append(i.Sections, ItemSection{ + ID: sectionID, + Title: title, + }) + } + } + i.Fields = append(i.Fields, ItemField{ - Label: field.Label, - Value: field.Value, + ID: field.ID, + Label: field.Label, + Value: field.Value, + SectionID: sectionID, + FieldType: string(field.Type), }) } @@ -76,10 +103,25 @@ func (i *Item) FromSDKItem(item *sdk.Item) { }) } + // Populate sections from the SDK item. + for _, section := range item.Sections { + i.Sections = append(i.Sections, ItemSection{ + ID: section.ID, + Title: section.Title, + }) + } + for _, field := range item.Fields { + sectionID := "" + if field.SectionID != nil { + sectionID = *field.SectionID + } i.Fields = append(i.Fields, ItemField{ - Label: field.Title, - Value: field.Value, + ID: field.ID, + Label: field.Title, + Value: field.Value, + SectionID: sectionID, + FieldType: string(field.FieldType), }) } diff --git a/pkg/onepassword/model/item_field.go b/pkg/onepassword/model/item_field.go index f88a0440..934a5e23 100644 --- a/pkg/onepassword/model/item_field.go +++ b/pkg/onepassword/model/item_field.go @@ -2,6 +2,9 @@ package model // ItemField Representation of a single field on an Item type ItemField struct { - Label string - Value string + ID string + Label string + Value string + SectionID string + FieldType string } diff --git a/pkg/onepassword/model/item_test.go b/pkg/onepassword/model/item_test.go index 8b5b178e..1df1af9d 100644 --- a/pkg/onepassword/model/item_test.go +++ b/pkg/onepassword/model/item_test.go @@ -19,8 +19,32 @@ func TestItem_FromConnectItem(t *testing.T) { Version: 1, Tags: []string{"tag1", "tag2"}, Fields: []*connect.ItemField{ - {Label: "field1", Value: "value1"}, - {Label: "field2", Value: "value2"}, + { + ID: "f1", + Label: "field1", + Value: "value1", + Type: "STRING", + Section: &connect.ItemSection{ + ID: "sec1", + Label: "Section One", + }, + }, + { + ID: "f2", + Label: "field2", + Value: "value2", + Type: "CONCEALED", + Section: &connect.ItemSection{ + ID: "sec1", + Label: "Section One", + }, + }, + { + ID: "f3", + Label: "field3", + Value: "value3", + Type: "STRING", + }, }, Files: []*connect.File{ {ID: "file1", Name: "file1.txt", Size: 1234}, @@ -40,8 +64,20 @@ func TestItem_FromConnectItem(t *testing.T) { for i, field := range connectItem.Fields { require.Equal(t, field.Label, item.Fields[i].Label) require.Equal(t, field.Value, item.Fields[i].Value) + require.Equal(t, field.ID, item.Fields[i].ID) + require.Equal(t, string(field.Type), item.Fields[i].FieldType) } + // Verify sections are built from field references. + require.Len(t, item.Sections, 1) + require.Equal(t, "sec1", item.Sections[0].ID) + require.Equal(t, "Section One", item.Sections[0].Title) + + // Verify section IDs on fields. + require.Equal(t, "sec1", item.Fields[0].SectionID) + require.Equal(t, "sec1", item.Fields[1].SectionID) + require.Equal(t, "", item.Fields[2].SectionID) + for i, file := range connectItem.Files { require.Equal(t, file.ID, item.Files[i].ID) require.Equal(t, file.Name, item.Files[i].Name) @@ -52,14 +88,18 @@ func TestItem_FromConnectItem(t *testing.T) { } func TestItem_FromSDKItem(t *testing.T) { + sec1ID := "sec1" sdkItem := &sdk.Item{ ID: "test-item-id", VaultID: "test-vault-id", Version: 1, Tags: []string{"tag1", "tag2"}, + Sections: []sdk.ItemSection{ + {ID: "sec1", Title: "Section One"}, + }, Fields: []sdk.ItemField{ - {ID: "1", Title: "field1", Value: "value1"}, - {ID: "2", Title: "field2", Value: "value2"}, + {ID: "1", Title: "field1", Value: "value1", SectionID: &sec1ID, FieldType: sdk.ItemFieldTypeText}, + {ID: "2", Title: "field2", Value: "value2", FieldType: sdk.ItemFieldTypeConcealed}, }, Files: []sdk.ItemFile{ {Attributes: sdk.FileAttributes{Name: "file1.txt", Size: 1234}, FieldID: "file1"}, @@ -79,8 +119,19 @@ func TestItem_FromSDKItem(t *testing.T) { for i, field := range sdkItem.Fields { require.Equal(t, field.Title, item.Fields[i].Label) require.Equal(t, field.Value, item.Fields[i].Value) + require.Equal(t, field.ID, item.Fields[i].ID) + require.Equal(t, string(field.FieldType), item.Fields[i].FieldType) } + // Verify sections are populated from SDK item. + require.Len(t, item.Sections, 1) + require.Equal(t, "sec1", item.Sections[0].ID) + require.Equal(t, "Section One", item.Sections[0].Title) + + // Verify section ID on fields. + require.Equal(t, "sec1", item.Fields[0].SectionID) + require.Equal(t, "", item.Fields[1].SectionID) + for i, file := range sdkItem.Files { require.Equal(t, file.Attributes.ID, item.Files[i].ID) require.Equal(t, file.Attributes.Name, item.Files[i].Name) From bcbf8fa0b050d7184812f3bba9b668b7a83f74fb Mon Sep 17 00:00:00 2001 From: Christian De Leon Date: Tue, 3 Mar 2026 18:24:44 -0500 Subject: [PATCH 2/6] feat(api): add SecretTemplate type to OnePasswordItem spec Add SecretTemplate struct with a Data map[string]string field and an optional Template pointer on OnePasswordItemSpec. Regenerate deepcopy methods and CRD manifests. This allows users to define Go template strings per secret key in their OnePasswordItem resources. --- api/v1/onepassworditem_types.go | 17 +++++++++++ api/v1/zz_generated.deepcopy.go | 29 ++++++++++++++++++- .../onepassword.com_onepassworditems.yaml | 15 ++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/api/v1/onepassworditem_types.go b/api/v1/onepassworditem_types.go index 83f738a7..44225d2e 100644 --- a/api/v1/onepassworditem_types.go +++ b/api/v1/onepassworditem_types.go @@ -31,12 +31,29 @@ import ( // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. +// SecretTemplate defines Go templates for generating secret data keys. +// Each key in Data is a secret data key, and its value is a Go template string +// that will be rendered using the 1Password item's fields as context. +type SecretTemplate struct { + // Data is a map of secret data key names to Go template strings. + // Templates can access fields via .Fields (flat map), .Sections (nested by section), + // or .FieldsByID (by field ID). + // +optional + Data map[string]string `json:"data,omitempty"` +} + // OnePasswordItemSpec defines the desired state of OnePasswordItem type OnePasswordItemSpec struct { // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster // Important: Run "make" to regenerate code after modifying this file ItemPath string `json:"itemPath,omitempty"` + + // Template defines Go templates for generating custom secret data. + // When set, the secret data will be generated by rendering the templates + // instead of using the default 1:1 field-to-key mapping. + // +optional + Template *SecretTemplate `json:"template,omitempty"` } type OnePasswordItemConditionType string diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 5328cbc0..5f9b2ffe 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -37,7 +37,7 @@ func (in *OnePasswordItem) DeepCopyInto(out *OnePasswordItem) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) } @@ -110,6 +110,11 @@ func (in *OnePasswordItemList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OnePasswordItemSpec) DeepCopyInto(out *OnePasswordItemSpec) { *out = *in + if in.Template != nil { + in, out := &in.Template, &out.Template + *out = new(SecretTemplate) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OnePasswordItemSpec. @@ -143,3 +148,25 @@ func (in *OnePasswordItemStatus) DeepCopy() *OnePasswordItemStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretTemplate) DeepCopyInto(out *SecretTemplate) { + *out = *in + if in.Data != nil { + in, out := &in.Data, &out.Data + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretTemplate. +func (in *SecretTemplate) DeepCopy() *SecretTemplate { + if in == nil { + return nil + } + out := new(SecretTemplate) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/bases/onepassword.com_onepassworditems.yaml b/config/crd/bases/onepassword.com_onepassworditems.yaml index c0c7f409..2d9a2feb 100644 --- a/config/crd/bases/onepassword.com_onepassworditems.yaml +++ b/config/crd/bases/onepassword.com_onepassworditems.yaml @@ -50,6 +50,21 @@ spec: properties: itemPath: type: string + template: + description: |- + Template defines Go templates for generating custom secret data. + When set, the secret data will be generated by rendering the templates + instead of using the default 1:1 field-to-key mapping. + properties: + data: + additionalProperties: + type: string + description: |- + Data is a map of secret data key names to Go template strings. + Templates can access fields via .Fields (flat map), .Sections (nested by section), + or .FieldsByID (by field ID). + type: object + type: object type: object status: description: OnePasswordItemStatus defines the observed state of OnePasswordItem From 7e62219c40ce93c27ab4092f63ecc72aca25079d Mon Sep 17 00:00:00 2001 From: Christian De Leon Date: Tue, 3 Mar 2026 18:24:50 -0500 Subject: [PATCH 3/6] feat(template): add Go template engine for secret data Add pkg/template with BuildTemplateContext and ProcessTemplate functions. BuildTemplateContext constructs a TemplateContext from a model.Item with three access patterns: .Fields (flat by label), .Sections (nested by section title), and .FieldsByID (by unique field ID). ProcessTemplate parses and executes a Go template string against the context, returning the rendered bytes. --- pkg/template/template.go | 85 +++++++++++++++++ pkg/template/template_test.go | 166 ++++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 pkg/template/template.go create mode 100644 pkg/template/template_test.go diff --git a/pkg/template/template.go b/pkg/template/template.go new file mode 100644 index 00000000..3829235f --- /dev/null +++ b/pkg/template/template.go @@ -0,0 +1,85 @@ +package template + +import ( + "bytes" + "fmt" + "text/template" + + "github.com/1Password/onepassword-operator/pkg/onepassword/model" +) + +// TemplateContext provides data for Go template processing. +type TemplateContext struct { + // Fields is a flat map: field_label -> value + // If duplicate labels exist across sections, the last one wins. + Fields map[string]string + // Sections is nested: section_title -> field_label -> value + // Allows access to fields organized by section. + Sections map[string]map[string]string + // FieldsByID provides precise access: field_id -> value + // Use this when field labels might collide across sections. + FieldsByID map[string]string +} + +// BuildTemplateContext constructs a TemplateContext from a 1Password item. +func BuildTemplateContext(item *model.Item) *TemplateContext { + ctx := &TemplateContext{ + Fields: make(map[string]string), + Sections: make(map[string]map[string]string), + FieldsByID: make(map[string]string), + } + + // Build section map by section ID for efficient lookup + sectionMap := make(map[string]string) // section_id -> section_title + for _, section := range item.Sections { + sectionMap[section.ID] = section.Title + if ctx.Sections[section.Title] == nil { + ctx.Sections[section.Title] = make(map[string]string) + } + } + + // Process all fields + for _, field := range item.Fields { + // Add to flat Fields map (last one wins if duplicate labels) + ctx.Fields[field.Label] = field.Value + + // Add to FieldsByID for precise access + ctx.FieldsByID[field.ID] = field.Value + + // Add to Sections map if field has a section + if field.SectionID != "" { + sectionTitle := sectionMap[field.SectionID] + if sectionTitle == "" { + // Section ID exists but not in sections array, use ID as fallback + sectionTitle = field.SectionID + } + if ctx.Sections[sectionTitle] == nil { + ctx.Sections[sectionTitle] = make(map[string]string) + } + ctx.Sections[sectionTitle][field.Label] = field.Value + } else { + // Field without section - add to a default/empty section + if ctx.Sections[""] == nil { + ctx.Sections[""] = make(map[string]string) + } + ctx.Sections[""][field.Label] = field.Value + } + } + + return ctx +} + +// ProcessTemplate processes a Go template string with the given context. +func ProcessTemplate(tmpl string, ctx *TemplateContext) ([]byte, error) { + t, err := template.New("secret").Parse(tmpl) + if err != nil { + return nil, fmt.Errorf("failed to parse template: %w", err) + } + + var buf bytes.Buffer + if err := t.Execute(&buf, ctx); err != nil { + return nil, fmt.Errorf("failed to execute template: %w", err) + } + + return buf.Bytes(), nil +} diff --git a/pkg/template/template_test.go b/pkg/template/template_test.go new file mode 100644 index 00000000..652f95ff --- /dev/null +++ b/pkg/template/template_test.go @@ -0,0 +1,166 @@ +package template + +import ( + "testing" + + "github.com/1Password/onepassword-operator/pkg/onepassword/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildTemplateContext(t *testing.T) { + item := &model.Item{ + ID: "test-item-id", + VaultID: "test-vault-id", + Fields: []model.ItemField{ + { + ID: "field-1", + Label: "username", + Value: "testuser", + SectionID: "section-1", + FieldType: "STRING", + }, + { + ID: "field-2", + Label: "password", + Value: "testpass", + SectionID: "section-1", + FieldType: "CONCEALED", + }, + { + ID: "field-3", + Label: "api_key", + Value: "key123", + SectionID: "", + FieldType: "CONCEALED", + }, + }, + Sections: []model.ItemSection{ + { + ID: "section-1", + Title: "Credentials", + }, + }, + } + + ctx := BuildTemplateContext(item) + + // Test Fields map + assert.Equal(t, "testuser", ctx.Fields["username"]) + assert.Equal(t, "testpass", ctx.Fields["password"]) + assert.Equal(t, "key123", ctx.Fields["api_key"]) + + // Test FieldsByID map + assert.Equal(t, "testuser", ctx.FieldsByID["field-1"]) + assert.Equal(t, "testpass", ctx.FieldsByID["field-2"]) + assert.Equal(t, "key123", ctx.FieldsByID["field-3"]) + + // Test Sections map + assert.NotNil(t, ctx.Sections["Credentials"]) + assert.Equal(t, "testuser", ctx.Sections["Credentials"]["username"]) + assert.Equal(t, "testpass", ctx.Sections["Credentials"]["password"]) + + // Test default section for fields without section + assert.NotNil(t, ctx.Sections[""]) + assert.Equal(t, "key123", ctx.Sections[""]["api_key"]) +} + +func TestProcessTemplate(t *testing.T) { + ctx := &TemplateContext{ + Fields: map[string]string{ + "username": "testuser", + "password": "testpass", + "endpoint": "https://example.com", + }, + Sections: map[string]map[string]string{ + "Credentials": { + "username": "testuser", + "password": "testpass", + }, + }, + FieldsByID: map[string]string{ + "field-1": "testuser", + "field-2": "testpass", + }, + } + + tests := []struct { + name string + template string + expected string + }{ + { + name: "simple field access", + template: "username: {{ .Fields.username }}", + expected: "username: testuser", + }, + { + name: "multiple fields", + template: "provider: AWS\nusername: {{ .Fields.username }}\npassword: {{ .Fields.password }}", + expected: "provider: AWS\nusername: testuser\npassword: testpass", + }, + { + name: "section access", + template: `user: {{ index .Sections "Credentials" "username" }}`, + expected: "user: testuser", + }, + { + name: "field by ID", + template: `user: {{ index .FieldsByID "field-1" }}`, + expected: "user: testuser", + }, + { + name: "complex template", + template: "endpoint: {{ .Fields.endpoint }}\ncredentials:\n" + + " username: {{ .Fields.username }}\n password: {{ .Fields.password }}", + expected: "endpoint: https://example.com\ncredentials:\n" + + " username: testuser\n password: testpass", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ProcessTemplate(tt.template, ctx) + require.NoError(t, err) + assert.Equal(t, tt.expected, string(result)) + }) + } +} + +func TestProcessTemplate_InvalidTemplate(t *testing.T) { + ctx := &TemplateContext{ + Fields: map[string]string{}, + } + + // Accessing non-existent field will error in Go templates + _, err := ProcessTemplate("{{ .InvalidField }}", ctx) + assert.Error(t, err) // Template execution errors on missing top-level fields + + _, err = ProcessTemplate("{{ .Fields.username }", ctx) + assert.Error(t, err) // Invalid syntax should error +} + +func TestBuildTemplateContext_DuplicateLabels(t *testing.T) { + item := &model.Item{ + Fields: []model.ItemField{ + { + ID: "field-1", + Label: "password", + Value: "first", + }, + { + ID: "field-2", + Label: "password", + Value: "second", + }, + }, + } + + ctx := BuildTemplateContext(item) + + // Last one should win + assert.Equal(t, "second", ctx.Fields["password"]) + // But both should be accessible by ID + assert.Equal(t, "first", ctx.FieldsByID["field-1"]) + assert.Equal(t, "second", ctx.FieldsByID["field-2"]) +} From 088ed6e94bbfab40dbfd00238a59e60db690ab17 Mon Sep 17 00:00:00 2001 From: Christian De Leon Date: Tue, 3 Mar 2026 18:25:02 -0500 Subject: [PATCH 4/6] feat(secrets): integrate template processing into builder and controllers Update BuildKubernetesSecretData to accept a model.Item and optional SecretTemplate instead of decomposed fields/urls/files. When a template is provided, render each key through the Go template engine and return only the templated keys. Fall back to default field/URL/file mapping when no template is set. Thread the SecretTemplate parameter through CreateKubernetesSecretFromItem and BuildKubernetesSecretFromOnePasswordItem. The OnePasswordItem controller extracts the template from the resource spec; the deployment controller and secret update handler pass nil (no template support for annotation-based secrets). Update all existing test call sites and add template-specific unit tests covering multi-key templates, section access, hyphenated keys via index, invalid template handling, nil template fallback, and end-to-end secret creation. Add integration tests for template rendering in the controller test suite. --- internal/controller/deployment_controller.go | 2 +- .../controller/onepassworditem_controller.go | 5 +- .../onepassworditem_controller_test.go | 109 +++++++++ .../kubernetes_secrets_builder.go | 45 +++- .../kubernetes_secrets_builder_test.go | 225 +++++++++++++++++- pkg/onepassword/secret_update_handler.go | 2 +- 6 files changed, 361 insertions(+), 27 deletions(-) diff --git a/internal/controller/deployment_controller.go b/internal/controller/deployment_controller.go index f59e62c3..b3fef249 100644 --- a/internal/controller/deployment_controller.go +++ b/internal/controller/deployment_controller.go @@ -223,5 +223,5 @@ func (r *DeploymentReconciler) handleApplyingDeployment(ctx context.Context, dep UID: deployment.GetUID(), } - return kubeSecrets.CreateKubernetesSecretFromItem(ctx, r.Client, secretName, namespace, item, annotations[op.AutoRestartWorkloadAnnotation], secretLabels, annotations, secretType, ownerRef, r.Config.AllowEmptyValues) + return kubeSecrets.CreateKubernetesSecretFromItem(ctx, r.Client, secretName, namespace, item, annotations[op.AutoRestartWorkloadAnnotation], secretLabels, annotations, secretType, ownerRef, r.Config.AllowEmptyValues, nil) } diff --git a/internal/controller/onepassworditem_controller.go b/internal/controller/onepassworditem_controller.go index 5c8de864..b29ecc8c 100644 --- a/internal/controller/onepassworditem_controller.go +++ b/internal/controller/onepassworditem_controller.go @@ -176,6 +176,9 @@ func (r *OnePasswordItemReconciler) handleOnePasswordItem(ctx context.Context, r return fmt.Errorf("failed to retrieve item: %w", err) } + // Extract template config from spec. + secretTemplate := resource.Spec.Template + // Create owner reference. gvk, err := apiutil.GVKForObject(resource, r.Scheme) if err != nil { @@ -188,7 +191,7 @@ func (r *OnePasswordItemReconciler) handleOnePasswordItem(ctx context.Context, r UID: resource.GetUID(), } - return kubeSecrets.CreateKubernetesSecretFromItem(ctx, r.Client, secretName, resource.Namespace, item, autoRestart, labels, annotations, secretType, ownerRef, r.Config.AllowEmptyValues) + return kubeSecrets.CreateKubernetesSecretFromItem(ctx, r.Client, secretName, resource.Namespace, item, autoRestart, labels, annotations, secretType, ownerRef, r.Config.AllowEmptyValues, secretTemplate) } func (r *OnePasswordItemReconciler) updateStatus(ctx context.Context, resource *onepasswordv1.OnePasswordItem, err error) error { diff --git a/internal/controller/onepassworditem_controller_test.go b/internal/controller/onepassworditem_controller_test.go index 945111ff..5ae05228 100644 --- a/internal/controller/onepassworditem_controller_test.go +++ b/internal/controller/onepassworditem_controller_test.go @@ -334,6 +334,115 @@ var _ = Describe("OnePasswordItem controller", func() { }) }) + Context("Template support", func() { + It("Should create a K8s secret with templated data from a OnePasswordItem", func() { + ctx := context.Background() + spec := onepasswordv1.OnePasswordItemSpec{ + ItemPath: item1.Path, + Template: &onepasswordv1.SecretTemplate{ + Data: map[string]string{ + "config.yaml": "user: {{ .Fields.username }}\npass: {{ .Fields.password }}", + }, + }, + } + + key := types.NamespacedName{ + Name: "templated-secret", + Namespace: namespace, + } + + toCreate := &onepasswordv1.OnePasswordItem{ + ObjectMeta: metav1.ObjectMeta{ + Name: key.Name, + Namespace: key.Namespace, + }, + Spec: spec, + } + + By("Creating a new OnePasswordItem with template") + Expect(k8sClient.Create(ctx, toCreate)).Should(Succeed()) + + created := &onepasswordv1.OnePasswordItem{} + Eventually(func() bool { + err := k8sClient.Get(ctx, key, created) + return err == nil + }, timeout, interval).Should(BeTrue()) + + By("Creating the K8s secret with templated data") + createdSecret := &v1.Secret{} + Eventually(func() bool { + err := k8sClient.Get(ctx, key, createdSecret) + return err == nil + }, timeout, interval).Should(BeTrue()) + + expectedConfig := fmt.Sprintf("user: %s\npass: %s", username, password) + Expect(createdSecret.Data).Should(HaveKeyWithValue("config.yaml", []byte(expectedConfig))) + + By("Ensuring individual fields are NOT present as separate keys") + Expect(createdSecret.Data).ShouldNot(HaveKey("username")) + Expect(createdSecret.Data).ShouldNot(HaveKey("password")) + + By("Deleting the OnePasswordItem successfully") + Eventually(func() error { + f := &onepasswordv1.OnePasswordItem{} + err := k8sClient.Get(ctx, key, f) + if err != nil { + return err + } + return k8sClient.Delete(ctx, f) + }, timeout, interval).Should(Succeed()) + }) + + It("Should create a K8s secret with multiple templated keys", func() { + ctx := context.Background() + spec := onepasswordv1.OnePasswordItemSpec{ + ItemPath: item1.Path, + Template: &onepasswordv1.SecretTemplate{ + Data: map[string]string{ + "DSN": "postgresql://{{ .Fields.username }}:{{ .Fields.password }}@localhost:5432/mydb", + "USER": "{{ .Fields.username }}", + }, + }, + } + + key := types.NamespacedName{ + Name: "multi-template-secret", + Namespace: namespace, + } + + toCreate := &onepasswordv1.OnePasswordItem{ + ObjectMeta: metav1.ObjectMeta{ + Name: key.Name, + Namespace: key.Namespace, + }, + Spec: spec, + } + + By("Creating a new OnePasswordItem with multiple template keys") + Expect(k8sClient.Create(ctx, toCreate)).Should(Succeed()) + + createdSecret := &v1.Secret{} + Eventually(func() bool { + err := k8sClient.Get(ctx, key, createdSecret) + return err == nil + }, timeout, interval).Should(BeTrue()) + + expectedDSN := fmt.Sprintf("postgresql://%s:%s@localhost:5432/mydb", username, password) + Expect(createdSecret.Data).Should(HaveKeyWithValue("DSN", []byte(expectedDSN))) + Expect(createdSecret.Data).Should(HaveKeyWithValue("USER", []byte(username))) + + By("Deleting the OnePasswordItem successfully") + Eventually(func() error { + f := &onepasswordv1.OnePasswordItem{} + err := k8sClient.Get(ctx, key, f) + if err != nil { + return err + } + return k8sClient.Delete(ctx, f) + }, timeout, interval).Should(Succeed()) + }) + }) + Context("Unhappy path", func() { It("Should throw an error if K8s Secret type is changed", func() { ctx := context.Background() diff --git a/pkg/kubernetessecrets/kubernetes_secrets_builder.go b/pkg/kubernetessecrets/kubernetes_secrets_builder.go index 29e68f4c..5833daaa 100644 --- a/pkg/kubernetessecrets/kubernetes_secrets_builder.go +++ b/pkg/kubernetessecrets/kubernetes_secrets_builder.go @@ -8,7 +8,9 @@ import ( "regexp" "strings" + onepasswordv1 "github.com/1Password/onepassword-operator/api/v1" "github.com/1Password/onepassword-operator/pkg/onepassword/model" + "github.com/1Password/onepassword-operator/pkg/template" "github.com/1Password/onepassword-operator/pkg/utils" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -41,6 +43,7 @@ func CreateKubernetesSecretFromItem( secretType string, ownerRef *metav1.OwnerReference, allowEmptyValues bool, + secretTemplate *onepasswordv1.SecretTemplate, ) error { itemVersion := fmt.Sprint(item.Version) if secretAnnotations == nil { @@ -61,7 +64,7 @@ func CreateKubernetesSecretFromItem( // "Opaque" and "" secret types are treated the same by Kubernetes. secret := BuildKubernetesSecretFromOnePasswordItem(secretName, namespace, secretAnnotations, labels, - secretType, *item, ownerRef, allowEmptyValues) + secretType, *item, ownerRef, allowEmptyValues, secretTemplate) currentSecret := &corev1.Secret{} err := kubeClient.Get(ctx, types.NamespacedName{Name: secret.Name, Namespace: secret.Namespace}, currentSecret) @@ -113,6 +116,7 @@ func BuildKubernetesSecretFromOnePasswordItem( item model.Item, ownerRef *metav1.OwnerReference, allowEmptyValues bool, + secretTemplate *onepasswordv1.SecretTemplate, ) *corev1.Secret { var ownerRefs []metav1.OwnerReference if ownerRef != nil { @@ -127,17 +131,33 @@ func BuildKubernetesSecretFromOnePasswordItem( Labels: labels, OwnerReferences: ownerRefs, }, - Data: BuildKubernetesSecretData(item.Fields, item.URLs, item.Files, allowEmptyValues), + Data: BuildKubernetesSecretData(item, allowEmptyValues, secretTemplate), Type: corev1.SecretType(secretType), } } func BuildKubernetesSecretData( - fields []model.ItemField, urls []model.ItemURL, files []model.File, allowEmptyValues bool, + item model.Item, allowEmptyValues bool, secretTemplate *onepasswordv1.SecretTemplate, ) map[string][]byte { + // Template processing: if a template is provided, render it and return. + if secretTemplate != nil && secretTemplate.Data != nil { + secretData := map[string][]byte{} + ctx := template.BuildTemplateContext(&item) + for key, tmplStr := range secretTemplate.Data { + processed, err := template.ProcessTemplate(tmplStr, ctx) + if err != nil { + log.Error(err, fmt.Sprintf("Failed to process template for key %q, skipping", key)) + continue + } + secretData[formatSecretDataName(key)] = processed + } + return secretData + } + + // Default behavior: map fields, URLs, and files to secret data. secretData := map[string][]byte{} - urlsByLabel := processURLsByLabel(urls) + urlsByLabel := processURLsByLabel(item.URLs) for key, url := range urlsByLabel { formattedKey := formatSecretDataName(key) if formattedKey == "" { @@ -154,24 +174,27 @@ func BuildKubernetesSecretData( secretData[formattedKey] = []byte(url.URL) } - for i := 0; i < len(fields); i++ { - key := formatSecretDataName(fields[i].Label) + for i := 0; i < len(item.Fields); i++ { + key := formatSecretDataName(item.Fields[i].Label) if key == "" { - log.Info(fmt.Sprintf("Skipping field with invalid label %q because it must match [-._a-zA-Z0-9]+", fields[i].Label)) + log.Info(fmt.Sprintf( + "Skipping field with invalid label %q because it must match [-._a-zA-Z0-9]+", + item.Fields[i].Label, + )) continue } - if emptyValueIsNotAllowed(allowEmptyValues, fields[i].Value) { + if emptyValueIsNotAllowed(allowEmptyValues, item.Fields[i].Value) { log.Info(fmt.Sprintf( "Skipping field with empty value for label %q (use --allow-empty-values flag to include)", - fields[i].Label, + item.Fields[i].Label, )) continue } - secretData[key] = []byte(fields[i].Value) + secretData[key] = []byte(item.Fields[i].Value) } // populate unpopulated fields from files - for _, file := range files { + for _, file := range item.Files { key := formatSecretDataName(file.Name) if key == "" { log.Info(fmt.Sprintf("Skipping file with invalid name %q because it must match [-._a-zA-Z0-9]+", file.Name)) diff --git a/pkg/kubernetessecrets/kubernetes_secrets_builder_test.go b/pkg/kubernetessecrets/kubernetes_secrets_builder_test.go index 673beada..7571b70b 100644 --- a/pkg/kubernetessecrets/kubernetes_secrets_builder_test.go +++ b/pkg/kubernetessecrets/kubernetes_secrets_builder_test.go @@ -12,6 +12,7 @@ import ( kubeValidate "k8s.io/apimachinery/pkg/util/validation" "sigs.k8s.io/controller-runtime/pkg/client/fake" + onepasswordv1 "github.com/1Password/onepassword-operator/api/v1" "github.com/1Password/onepassword-operator/pkg/onepassword/model" ) @@ -40,7 +41,7 @@ func TestCreateKubernetesSecretFromOnePasswordItem(t *testing.T) { "testAnnotation": "exists", } err := CreateKubernetesSecretFromItem(ctx, kubeClient, secretName, namespace, &item, restartDeploymentAnnotation, - secretLabels, secretAnnotations, secretType, nil, false) + secretLabels, secretAnnotations, secretType, nil, false, nil) if err != nil { t.Errorf("Unexpected error: %v", err) } @@ -79,7 +80,7 @@ func TestKubernetesSecretFromOnePasswordItemOwnerReferences(t *testing.T) { UID: types.UID("test-uid"), } err := CreateKubernetesSecretFromItem(ctx, kubeClient, secretName, namespace, &item, restartDeploymentAnnotation, - secretLabels, secretAnnotations, secretType, ownerRef, false) + secretLabels, secretAnnotations, secretType, ownerRef, false, nil) if err != nil { t.Errorf("Unexpected error: %v", err) } @@ -126,7 +127,7 @@ func TestUpdateKubernetesSecretFromOnePasswordItem(t *testing.T) { } err := CreateKubernetesSecretFromItem(ctx, kubeClient, secretName, namespace, &item, restartDeploymentAnnotation, - secretLabels, secretAnnotations, secretType, nil, false) + secretLabels, secretAnnotations, secretType, nil, false, nil) if err != nil { t.Errorf("Unexpected error: %v", err) @@ -139,7 +140,7 @@ func TestUpdateKubernetesSecretFromOnePasswordItem(t *testing.T) { newItem.VaultID = testVaultUUID newItem.ID = testItemUUID err = CreateKubernetesSecretFromItem(ctx, kubeClient, secretName, namespace, &newItem, restartDeploymentAnnotation, - secretLabels, secretAnnotations, secretType, nil, false) + secretLabels, secretAnnotations, secretType, nil, false, nil) if err != nil { t.Errorf("Unexpected error: %v", err) } @@ -155,7 +156,7 @@ func TestUpdateKubernetesSecretFromOnePasswordItem(t *testing.T) { func TestBuildKubernetesSecretData(t *testing.T) { fields := generateFields(5) - secretData := BuildKubernetesSecretData(fields, nil, nil, false) + secretData := BuildKubernetesSecretData(model.Item{Fields: fields}, false, nil) if len(secretData) != len(fields) { t.Errorf("Unexpected number of secret fields returned. Expected 5, got %v", len(secretData)) } @@ -170,7 +171,7 @@ func TestBuildKubernetesSecretDataWithEmptyValues_Allowed(t *testing.T) { {Label: "empty-field-2", Value: ""}, } - secretData := BuildKubernetesSecretData(fields, nil, nil, true) + secretData := BuildKubernetesSecretData(model.Item{Fields: fields}, true, nil) // Verify all fields are present, including empty ones if len(secretData) != len(fields) { @@ -203,7 +204,7 @@ func TestBuildKubernetesSecretDataWithEmptyValues_Skipped(t *testing.T) { } // Test with allowEmptyValues = false (should skip empty fields) - secretData := BuildKubernetesSecretData(fields, nil, nil, false) + secretData := BuildKubernetesSecretData(model.Item{Fields: fields}, false, nil) // Verify only non-empty fields are present expectedNonEmptyFields := 2 @@ -226,7 +227,7 @@ func TestBuildKubernetesSecretFromOnePasswordItem(t *testing.T) { secretType := "" kubeSecret := BuildKubernetesSecretFromOnePasswordItem( - name, namespace, annotations, labels, secretType, item, nil, false, + name, namespace, annotations, labels, secretType, item, nil, false, nil, ) if kubeSecret.Name != strings.ToLower(name) { t.Errorf("Expected name value: %v but got: %v", name, kubeSecret.Name) @@ -248,7 +249,7 @@ func TestBuildKubernetesSecretDataWithURLs(t *testing.T) { {URL: "https://another.example.com", Label: "website", Primary: false}, } - secretData := BuildKubernetesSecretData(fields, urls, nil, false) + secretData := BuildKubernetesSecretData(model.Item{Fields: fields, URLs: urls}, false, nil) // Should have fields + all URLs (both have different labels) if len(secretData) != 4 { @@ -278,7 +279,7 @@ func TestBuildKubernetesSecretDataWithFieldURLConflict(t *testing.T) { {URL: "https://support.example.com", Label: "support", Primary: false}, } - secretData := BuildKubernetesSecretData(fields, urls, nil, false) + secretData := BuildKubernetesSecretData(model.Item{Fields: fields, URLs: urls}, false, nil) // Should have 2 fields + 1 url if len(secretData) != 3 { @@ -323,7 +324,7 @@ func TestBuildKubernetesSecretData_InvalidLabels(t *testing.T) { files[1].SetContent([]byte("content2")) files[2].SetContent([]byte("content3")) - secretData := BuildKubernetesSecretData(fields, urls, files, false) + secretData := BuildKubernetesSecretData(model.Item{Fields: fields, URLs: urls, Files: files}, false, nil) if len(secretData) != 0 { t.Errorf("Expected 0 keys, got %d: %v", len(secretData), secretData) @@ -353,7 +354,7 @@ func TestBuildKubernetesSecretFixesInvalidLabels(t *testing.T) { } kubeSecret := BuildKubernetesSecretFromOnePasswordItem( - name, namespace, annotations, labels, secretType, item, nil, false, + name, namespace, annotations, labels, secretType, item, nil, false, nil, ) // Assert Secret's meta.name was fixed @@ -391,7 +392,7 @@ func TestCreateKubernetesTLSSecretFromOnePasswordItem(t *testing.T) { } err := CreateKubernetesSecretFromItem(ctx, kubeClient, secretName, namespace, &item, restartDeploymentAnnotation, - secretLabels, secretAnnotations, secretType, nil, false) + secretLabels, secretAnnotations, secretType, nil, false, nil) if err != nil { t.Errorf("Unexpected error: %v", err) } @@ -407,6 +408,204 @@ func TestCreateKubernetesTLSSecretFromOnePasswordItem(t *testing.T) { } } +func TestBuildKubernetesSecretDataWithTemplate(t *testing.T) { + item := model.Item{ + Fields: []model.ItemField{ + {Label: "username", Value: "admin"}, + {Label: "password", Value: "s3cret"}, + }, + } + tmpl := &onepasswordv1.SecretTemplate{ + Data: map[string]string{ + "config.yaml": "user: {{ .Fields.username }}\npass: {{ .Fields.password }}", + }, + } + + secretData := BuildKubernetesSecretData(item, false, tmpl) + + if len(secretData) != 1 { + t.Fatalf("Expected 1 key, got %d", len(secretData)) + } + expected := "user: admin\npass: s3cret" + if string(secretData["config.yaml"]) != expected { + t.Errorf("Expected %q, got %q", expected, string(secretData["config.yaml"])) + } +} + +func TestBuildKubernetesSecretDataWithTemplateMultipleKeys(t *testing.T) { + item := model.Item{ + Fields: []model.ItemField{ + {Label: "host", Value: "db.example.com"}, + {Label: "port", Value: "5432"}, + {Label: "username", Value: "dbuser"}, + {Label: "password", Value: "dbpass"}, + }, + } + tmpl := &onepasswordv1.SecretTemplate{ + Data: map[string]string{ + "DSN": "postgresql://{{ .Fields.username }}:{{ .Fields.password }}@{{ .Fields.host }}:{{ .Fields.port }}/mydb", + "DB_HOST": "{{ .Fields.host }}", + }, + } + + secretData := BuildKubernetesSecretData(item, false, tmpl) + + if len(secretData) != 2 { + t.Fatalf("Expected 2 keys, got %d", len(secretData)) + } + expectedDSN := "postgresql://dbuser:dbpass@db.example.com:5432/mydb" + if string(secretData["DSN"]) != expectedDSN { + t.Errorf("Expected DSN %q, got %q", expectedDSN, string(secretData["DSN"])) + } + if string(secretData["DB_HOST"]) != "db.example.com" { + t.Errorf("Expected DB_HOST %q, got %q", "db.example.com", string(secretData["DB_HOST"])) + } +} + +func TestBuildKubernetesSecretDataWithTemplateInvalidTemplate(t *testing.T) { + item := model.Item{ + Fields: []model.ItemField{ + {Label: "username", Value: "admin"}, + }, + } + tmpl := &onepasswordv1.SecretTemplate{ + Data: map[string]string{ + "good-key": "{{ .Fields.username }}", + "bad-key": "{{ .InvalidSyntax", + }, + } + + secretData := BuildKubernetesSecretData(item, false, tmpl) + + // The valid key should still be rendered; the invalid key should be skipped + if string(secretData["good-key"]) != "admin" { + t.Errorf("Expected good-key to be 'admin', got %q", string(secretData["good-key"])) + } + if _, exists := secretData["bad-key"]; exists { + t.Errorf("Expected bad-key to be skipped due to template error") + } +} + +func TestBuildKubernetesSecretDataWithTemplateNilData(t *testing.T) { + item := model.Item{ + Fields: []model.ItemField{ + {Label: "key0", Value: "value0"}, + }, + } + // Template with nil Data should fall through to default behavior + tmpl := &onepasswordv1.SecretTemplate{} + + secretData := BuildKubernetesSecretData(item, false, tmpl) + + if len(secretData) != 1 { + t.Fatalf("Expected 1 key (default behavior), got %d", len(secretData)) + } + if string(secretData["key0"]) != "value0" { + t.Errorf("Expected 'value0', got %q", string(secretData["key0"])) + } +} + +func TestBuildKubernetesSecretDataWithTemplateSections(t *testing.T) { + item := model.Item{ + Fields: []model.ItemField{ + {Label: "username", Value: "admin", SectionID: "sec1"}, + {Label: "password", Value: "s3cret", SectionID: "sec1"}, + {Label: "apikey", Value: "abc123"}, + }, + Sections: []model.ItemSection{ + {ID: "sec1", Title: "Database"}, + }, + } + tmpl := &onepasswordv1.SecretTemplate{ + Data: map[string]string{ + "db-creds": "{{ .Sections.Database.username }}:{{ .Sections.Database.password }}", + "api": "{{ .Fields.apikey }}", + }, + } + + secretData := BuildKubernetesSecretData(item, false, tmpl) + + if len(secretData) != 2 { + t.Fatalf("Expected 2 keys, got %d", len(secretData)) + } + if string(secretData["db-creds"]) != "admin:s3cret" { + t.Errorf("Expected 'admin:s3cret', got %q", string(secretData["db-creds"])) + } + if string(secretData["api"]) != "abc123" { + t.Errorf("Expected 'abc123', got %q", string(secretData["api"])) + } +} + +func TestBuildKubernetesSecretDataWithTemplateHyphenatedKeys(t *testing.T) { + item := model.Item{ + Fields: []model.ItemField{ + {Label: "api-key", Value: "abc123"}, + {Label: "db-host", Value: "localhost"}, + }, + } + // Hyphenated keys require the `index` function in Go templates + tmpl := &onepasswordv1.SecretTemplate{ + Data: map[string]string{ + "config": `key={{ index .Fields "api-key" }},host={{ index .Fields "db-host" }}`, + }, + } + + secretData := BuildKubernetesSecretData(item, false, tmpl) + + expected := "key=abc123,host=localhost" + if string(secretData["config"]) != expected { + t.Errorf("Expected %q, got %q", expected, string(secretData["config"])) + } +} + +func TestCreateKubernetesSecretFromItemWithTemplate(t *testing.T) { + ctx := context.Background() + secretName := "template-secret" + namespace := testNamespace + + item := model.Item{ + Fields: []model.ItemField{ + {Label: "username", Value: "admin"}, + {Label: "password", Value: "s3cret"}, + }, + Version: 1, + VaultID: testVaultUUID, + ID: testItemUUID, + } + + kubeClient := fake.NewClientBuilder().Build() + tmpl := &onepasswordv1.SecretTemplate{ + Data: map[string]string{ + "config": "user={{ .Fields.username }},pass={{ .Fields.password }}", + }, + } + + err := CreateKubernetesSecretFromItem(ctx, kubeClient, secretName, namespace, &item, + restartDeploymentAnnotation, map[string]string{}, map[string]string{}, "", nil, false, tmpl) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + createdSecret := &corev1.Secret{} + err = kubeClient.Get(ctx, types.NamespacedName{Name: secretName, Namespace: namespace}, createdSecret) + if err != nil { + t.Fatalf("Secret was not created: %v", err) + } + + expected := "user=admin,pass=s3cret" + if string(createdSecret.Data["config"]) != expected { + t.Errorf("Expected %q, got %q", expected, string(createdSecret.Data["config"])) + } + + // When template is used, fields should NOT be in the secret data individually + if _, exists := createdSecret.Data["username"]; exists { + t.Errorf("Individual field 'username' should not exist when template is used") + } + if _, exists := createdSecret.Data["password"]; exists { + t.Errorf("Individual field 'password' should not exist when template is used") + } +} + func compareAnnotationsToItem(annotations map[string]string, item model.Item, t *testing.T) { actualVaultId, actualItemId, err := ParseVaultIdAndItemIdFromPath(annotations[ItemPathAnnotation]) if err != nil { diff --git a/pkg/onepassword/secret_update_handler.go b/pkg/onepassword/secret_update_handler.go index cd7bbff0..5616aabc 100644 --- a/pkg/onepassword/secret_update_handler.go +++ b/pkg/onepassword/secret_update_handler.go @@ -211,7 +211,7 @@ func (h *SecretUpdateHandler) updateKubernetesSecrets(ctx context.Context) ( log.Info(fmt.Sprintf("Updating kubernetes secret '%v'", secret.GetName())) secret.Annotations[VersionAnnotation] = itemVersion secret.Annotations[ItemPathAnnotation] = itemPathString - secret.Data = kubeSecrets.BuildKubernetesSecretData(item.Fields, item.URLs, item.Files, h.config.AllowEmptyValues) + secret.Data = kubeSecrets.BuildKubernetesSecretData(*item, h.config.AllowEmptyValues, nil) log.V(logs.DebugLevel).Info(fmt.Sprintf("New secret path: %v and version: %v", secret.Annotations[ItemPathAnnotation], secret.Annotations[VersionAnnotation], )) From d16d1f7b5ccbb3e205ffa7b4604e856a373b5512 Mon Sep 17 00:00:00 2001 From: Christian De Leon Date: Tue, 3 Mar 2026 18:25:08 -0500 Subject: [PATCH 5/6] docs: add secret template documentation Document the template feature in USAGEGUIDE.md including basic usage, multiple keys, template context reference (.Fields, .Sections, .FieldsByID, index for special characters), and behaviour notes. --- USAGEGUIDE.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/USAGEGUIDE.md b/USAGEGUIDE.md index 7cc205b9..b932aea3 100644 --- a/USAGEGUIDE.md +++ b/USAGEGUIDE.md @@ -16,8 +16,9 @@ 4. [Logging level](#logging-level) 5. [Usage examples](#usage-examples) 6. [How 1Password Items Map to Kubernetes Secrets](#how-1password-items-map-to-kubernetes-secrets) -7. [Configuring Automatic Rolling Restarts of Deployments](#configuring-automatic-rolling-restarts-of-deployments) -8. [Development](#development) +7. [Secret Templates](#secret-templates) +8. [Configuring Automatic Rolling Restarts of Deployments](#configuring-automatic-rolling-restarts-of-deployments) +9. [Development](#development) --- @@ -126,6 +127,76 @@ Titles and field names that include white space and other characters that are no --- +## Secret Templates + +By default, each field in a 1Password item maps directly to a key in the +Kubernetes Secret. **Secret templates** let you transform item data into custom +formats using [Go templates](https://pkg.go.dev/text/template) so that a +single `OnePasswordItem` can produce exactly the secret layout your application +expects. + +### Basic example + +```yaml +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: my-database-config +spec: + itemPath: "vaults/my-vault/items/my-db-item" + template: + data: + DSN: "postgresql://{{ .Fields.username }}:{{ .Fields.password }}@{{ .Fields.host }}:{{ .Fields.port }}/{{ .Fields.database }}" +``` + +Instead of creating a secret with individual keys for `username`, `password`, +`host`, `port`, and `database`, the operator creates a single `DSN` key whose +value is the rendered connection string. + +### Multiple keys + +You can define as many output keys as you need: + +```yaml +spec: + itemPath: "vaults/my-vault/items/my-item" + template: + data: + config.yaml: | + server: + username: {{ .Fields.username }} + password: {{ .Fields.password }} + DB_HOST: "{{ .Fields.host }}" +``` + +### Template context + +The following data is available inside templates: + +| Expression | Description | +|---|---| +| `{{ .Fields.