From ab166a355ceadfad0c4ad4364558c2584cf517d0 Mon Sep 17 00:00:00 2001 From: Johnny Fredheim Horvi Date: Thu, 19 Mar 2026 13:09:36 +0100 Subject: [PATCH 1/3] feat: add binary secret value support via ValueEncoding enum Add ValueEncoding enum (PLAIN_TEXT/BASE64) to GraphQL schema for secret values. Binary values are base64-encoded before storage and decoded on read. Detection uses utf8.Valid() on raw Kubernetes data to determine encoding on the read path. Updates both read paths (GetSecretValues, ViewSecretValues) and write paths (AddSecretValue, UpdateSecretValue) to handle encoding. --- internal/graph/gengql/root_.generated.go | 31 +++- internal/graph/gengql/secret.generated.go | 75 ++++++++- internal/graph/schema/secret.graphqls | 19 ++- internal/workload/secret/models.go | 46 +++++- internal/workload/secret/queries.go | 179 ++++++++++++++++++++-- 5 files changed, 327 insertions(+), 23 deletions(-) diff --git a/internal/graph/gengql/root_.generated.go b/internal/graph/gengql/root_.generated.go index 25c7fdce6..874afbfbe 100644 --- a/internal/graph/gengql/root_.generated.go +++ b/internal/graph/gengql/root_.generated.go @@ -1856,8 +1856,9 @@ type ComplexityRoot struct { } SecretValue struct { - Name func(childComplexity int) int - Value func(childComplexity int) int + Encoding func(childComplexity int) int + Name func(childComplexity int) int + Value func(childComplexity int) int } SecretValueAddedActivityLogEntry struct { @@ -10727,6 +10728,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.SecretEdge.Node(childComplexity), true + case "SecretValue.encoding": + if e.ComplexityRoot.SecretValue.Encoding == nil { + break + } + + return e.ComplexityRoot.SecretValue.Encoding(childComplexity), true + case "SecretValue.name": if e.ComplexityRoot.SecretValue.Name == nil { break @@ -22509,12 +22517,26 @@ type TeamInventoryCountSecrets { total: Int! } +""" +Encoding of a secret or config value. +""" +enum ValueEncoding { + "The value is plain text (UTF-8)." + PLAIN_TEXT + + "The value is Base64-encoded binary data." + BASE64 +} + input SecretValueInput { "The name of the secret value." name: String! "The secret value to set." value: String! + + "Encoding of the value. Defaults to PLAIN_TEXT. Use BASE64 for binary data (certificates, keystores, etc.)." + encoding: ValueEncoding = PLAIN_TEXT } input CreateSecretInput { @@ -22673,8 +22695,11 @@ type SecretValue { "The name of the secret value." name: String! - "The secret value itself." + "The secret value itself. When encoding is BASE64, the value is Base64-encoded binary data." value: String! + + "Encoding of the value. PLAIN_TEXT for UTF-8 text, BASE64 for binary data." + encoding: ValueEncoding! } extend enum ActivityLogEntryResourceType { diff --git a/internal/graph/gengql/secret.generated.go b/internal/graph/gengql/secret.generated.go index 0755d7cae..b28bde92a 100644 --- a/internal/graph/gengql/secret.generated.go +++ b/internal/graph/gengql/secret.generated.go @@ -1702,6 +1702,35 @@ func (ec *executionContext) fieldContext_SecretValue_value(_ context.Context, fi return fc, nil } +func (ec *executionContext) _SecretValue_encoding(ctx context.Context, field graphql.CollectedField, obj *secret.SecretValue) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_SecretValue_encoding, + func(ctx context.Context) (any, error) { + return obj.Encoding, nil + }, + nil, + ec.marshalNValueEncoding2githubᚗcomᚋnaisᚋapiᚋinternalᚋworkloadᚋsecretᚐValueEncoding, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_SecretValue_encoding(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SecretValue", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type ValueEncoding does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _SecretValueAddedActivityLogEntry_id(ctx context.Context, field graphql.CollectedField, obj *secret.SecretValueAddedActivityLogEntry) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -2990,6 +3019,8 @@ func (ec *executionContext) fieldContext_ViewSecretValuesPayload_values(_ contex return ec.fieldContext_SecretValue_name(ctx, field) case "value": return ec.fieldContext_SecretValue_value(ctx, field) + case "encoding": + return ec.fieldContext_SecretValue_encoding(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SecretValue", field.Name) }, @@ -3248,7 +3279,11 @@ func (ec *executionContext) unmarshalInputSecretValueInput(ctx context.Context, asMap[k] = v } - fieldsInOrder := [...]string{"name", "value"} + if _, present := asMap["encoding"]; !present { + asMap["encoding"] = "PLAIN_TEXT" + } + + fieldsInOrder := [...]string{"name", "value", "encoding"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -3269,6 +3304,13 @@ func (ec *executionContext) unmarshalInputSecretValueInput(ctx context.Context, return it, err } it.Value = data + case "encoding": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("encoding")) + data, err := ec.unmarshalOValueEncoding2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋworkloadᚋsecretᚐValueEncoding(ctx, v) + if err != nil { + return it, err + } + it.Encoding = data } } return it, nil @@ -4112,6 +4154,11 @@ func (ec *executionContext) _SecretValue(ctx context.Context, sel ast.SelectionS if out.Values[i] == graphql.Null { out.Invalids++ } + case "encoding": + out.Values[i] = ec._SecretValue_encoding(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -4967,6 +5014,16 @@ func (ec *executionContext) marshalNUpdateSecretValuePayload2ᚖgithubᚗcomᚋn return ec._UpdateSecretValuePayload(ctx, sel, v) } +func (ec *executionContext) unmarshalNValueEncoding2githubᚗcomᚋnaisᚋapiᚋinternalᚋworkloadᚋsecretᚐValueEncoding(ctx context.Context, v any) (secret.ValueEncoding, error) { + var res secret.ValueEncoding + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNValueEncoding2githubᚗcomᚋnaisᚋapiᚋinternalᚋworkloadᚋsecretᚐValueEncoding(ctx context.Context, sel ast.SelectionSet, v secret.ValueEncoding) graphql.Marshaler { + return v +} + func (ec *executionContext) unmarshalNViewSecretValuesInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋworkloadᚋsecretᚐViewSecretValuesInput(ctx context.Context, v any) (secret.ViewSecretValuesInput, error) { res, err := ec.unmarshalInputViewSecretValuesInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) @@ -5009,4 +5066,20 @@ func (ec *executionContext) unmarshalOSecretOrder2ᚖgithubᚗcomᚋnaisᚋapi return &res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) unmarshalOValueEncoding2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋworkloadᚋsecretᚐValueEncoding(ctx context.Context, v any) (*secret.ValueEncoding, error) { + if v == nil { + return nil, nil + } + var res = new(secret.ValueEncoding) + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOValueEncoding2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋworkloadᚋsecretᚐValueEncoding(ctx context.Context, sel ast.SelectionSet, v *secret.ValueEncoding) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return v +} + // endregion ***************************** type.gotpl ***************************** diff --git a/internal/graph/schema/secret.graphqls b/internal/graph/schema/secret.graphqls index d7820470f..b239442d3 100644 --- a/internal/graph/schema/secret.graphqls +++ b/internal/graph/schema/secret.graphqls @@ -222,12 +222,26 @@ type TeamInventoryCountSecrets { total: Int! } +""" +Encoding of a secret or config value. +""" +enum ValueEncoding { + "The value is plain text (UTF-8)." + PLAIN_TEXT + + "The value is Base64-encoded binary data." + BASE64 +} + input SecretValueInput { "The name of the secret value." name: String! "The secret value to set." value: String! + + "Encoding of the value. Defaults to PLAIN_TEXT. Use BASE64 for binary data (certificates, keystores, etc.)." + encoding: ValueEncoding = PLAIN_TEXT } input CreateSecretInput { @@ -386,8 +400,11 @@ type SecretValue { "The name of the secret value." name: String! - "The secret value itself." + "The secret value itself. When encoding is BASE64, the value is Base64-encoded binary data." value: String! + + "Encoding of the value. PLAIN_TEXT for UTF-8 text, BASE64 for binary data." + encoding: ValueEncoding! } extend enum ActivityLogEntryResourceType { diff --git a/internal/workload/secret/models.go b/internal/workload/secret/models.go index ce1abfe75..2d7cff2c7 100644 --- a/internal/workload/secret/models.go +++ b/internal/workload/secret/models.go @@ -139,8 +139,9 @@ func secretFromAPIResponse(o *unstructured.Unstructured, environmentName string) } type SecretValue struct { - Name string `json:"name"` - Value string `json:"value"` + Name string `json:"name"` + Value string `json:"value"` + Encoding ValueEncoding `json:"encoding"` } type DeleteSecretInput struct { @@ -154,8 +155,9 @@ type DeleteSecretPayload struct { } type SecretValueInput struct { - Name string `json:"name"` - Value string `json:"value"` + Name string `json:"name"` + Value string `json:"value"` + Encoding *ValueEncoding `json:"encoding"` } type AddSecretValueInput struct { @@ -242,6 +244,42 @@ type ViewSecretValuesPayload struct { // IsActivityLogger implements the ActivityLogger interface. func (Secret) IsActivityLogger() {} +type ValueEncoding string + +const ( + ValueEncodingPlainText ValueEncoding = "PLAIN_TEXT" + ValueEncodingBase64 ValueEncoding = "BASE64" +) + +func (e ValueEncoding) IsValid() bool { + switch e { + case ValueEncodingPlainText, ValueEncodingBase64: + return true + } + return false +} + +func (e ValueEncoding) String() string { + return string(e) +} + +func (e *ValueEncoding) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ValueEncoding(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ValueEncoding", str) + } + return nil +} + +func (e ValueEncoding) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + type TeamInventoryCountSecrets struct { Total int `json:"total"` } diff --git a/internal/workload/secret/queries.go b/internal/workload/secret/queries.go index 49d3de0f2..44add98b0 100644 --- a/internal/workload/secret/queries.go +++ b/internal/workload/secret/queries.go @@ -25,6 +25,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/google/uuid" "github.com/nais/api/internal/activitylog" @@ -131,6 +132,8 @@ func GetSecretValues(ctx context.Context, teamSlug slug.Slug, environmentName, n return nil, err } + binaryKeys := getBinaryKeys(u) + vars := make([]*SecretValue, 0, len(data)) for k, v := range data { val, err := base64.StdEncoding.DecodeString(v) @@ -138,10 +141,7 @@ func GetSecretValues(ctx context.Context, teamSlug slug.Slug, environmentName, n return nil, err } - vars = append(vars, &SecretValue{ - Name: k, - Value: string(val), - }) + vars = append(vars, decodeValueFromStorage(k, val, binaryKeys)) } slices.SortFunc(vars, func(a, b *SecretValue) int { @@ -236,19 +236,29 @@ func AddSecretValue(ctx context.Context, teamSlug slug.Slug, environment, secret // Use JSON Patch to add the new key without reading existing values actor := authz.ActorFromContext(ctx) - encodedValue := base64.StdEncoding.EncodeToString([]byte(valueToAdd.Value)) + encodedValue, err := encodeValueForStorage(valueToAdd) + if err != nil { + return nil, err + } + + // Track binary encoding in annotation for round-trip fidelity + isBinary := valueToAdd.Encoding != nil && *valueToAdd.Encoding == ValueEncodingBase64 + extra := map[string]string{ + annotationBinaryKeys: updatedBinaryKeysAnnotation(obj, valueToAdd.Name, isBinary), + } + mergedAnnotations := mergeAnnotations(obj, actor.User.Identity(), extra) var patch []map[string]any if !dataExists || data == nil { // If /data doesn't exist, we need to create it first patch = []map[string]any{ {"op": "add", "path": "/data", "value": map[string]any{valueToAdd.Name: encodedValue}}, - {"op": "replace", "path": "/metadata/annotations", "value": annotations(actor.User.Identity())}, + {"op": "replace", "path": "/metadata/annotations", "value": mergedAnnotations}, } } else { patch = []map[string]any{ {"op": "add", "path": "/data/" + escapeJSONPointer(valueToAdd.Name), "value": encodedValue}, - {"op": "replace", "path": "/metadata/annotations", "value": annotations(actor.User.Identity())}, + {"op": "replace", "path": "/metadata/annotations", "value": mergedAnnotations}, } } @@ -314,11 +324,21 @@ func UpdateSecretValue(ctx context.Context, teamSlug slug.Slug, environment, sec // Use JSON Patch to update the key without reading other values actor := authz.ActorFromContext(ctx) - encodedValue := base64.StdEncoding.EncodeToString([]byte(valueToUpdate.Value)) + encodedValue, err := encodeValueForStorage(valueToUpdate) + if err != nil { + return nil, err + } + + // Track binary encoding in annotation for round-trip fidelity + isBinary := valueToUpdate.Encoding != nil && *valueToUpdate.Encoding == ValueEncodingBase64 + extra := map[string]string{ + annotationBinaryKeys: updatedBinaryKeysAnnotation(obj, valueToUpdate.Name, isBinary), + } + mergedAnnotations := mergeAnnotations(obj, actor.User.Identity(), extra) patch := []map[string]any{ {"op": "replace", "path": "/data/" + escapeJSONPointer(valueToUpdate.Name), "value": encodedValue}, - {"op": "replace", "path": "/metadata/annotations", "value": annotations(actor.User.Identity())}, + {"op": "replace", "path": "/metadata/annotations", "value": mergedAnnotations}, } patchBytes, err := json.Marshal(patch) @@ -380,9 +400,15 @@ func RemoveSecretValue(ctx context.Context, teamSlug slug.Slug, environment, sec // Use JSON Patch to remove the key without reading values actor := authz.ActorFromContext(ctx) + // Remove the key from binary-keys annotation if present + extra := map[string]string{ + annotationBinaryKeys: updatedBinaryKeysAnnotation(obj, valueName, false), + } + mergedAnnotations := mergeAnnotations(obj, actor.User.Identity(), extra) + patch := []map[string]any{ {"op": "remove", "path": "/data/" + escapeJSONPointer(valueName)}, - {"op": "replace", "path": "/metadata/annotations", "value": annotations(actor.User.Identity())}, + {"op": "replace", "path": "/metadata/annotations", "value": mergedAnnotations}, } patchBytes, err := json.Marshal(patch) @@ -471,6 +497,132 @@ func validateSecretValue(value *SecretValueInput) error { return nil } +const annotationBinaryKeys = "nais.io/binary-keys" + +// encodeValueForStorage prepares a secret value for storage in Kubernetes. +// When encoding is BASE64, the input value is already base64-encoded by the client (e.g. CLI), +// so we validate and pass it through directly (Kubernetes data field expects base64). +// When encoding is PLAIN_TEXT (or unset), we base64-encode the string value for Kubernetes storage. +func encodeValueForStorage(input *SecretValueInput) (string, error) { + encoding := ValueEncodingPlainText + if input.Encoding != nil { + encoding = *input.Encoding + } + + switch encoding { + case ValueEncodingBase64: + // Validate that the value is valid base64 + if _, err := base64.StdEncoding.DecodeString(input.Value); err != nil { + return "", fmt.Errorf("value is not valid base64: %w", err) + } + // The value is already base64-encoded, which is what Kubernetes data field expects + return input.Value, nil + case ValueEncodingPlainText: + return base64.StdEncoding.EncodeToString([]byte(input.Value)), nil + default: + return "", fmt.Errorf("unsupported encoding: %s", encoding) + } +} + +// getBinaryKeys parses the nais.io/binary-keys annotation from a secret. +// Returns a set of key names that are stored as binary (BASE64). +func getBinaryKeys(obj *unstructured.Unstructured) map[string]bool { + ann := obj.GetAnnotations() + if ann == nil { + return nil + } + raw, ok := ann[annotationBinaryKeys] + if !ok || raw == "" { + return nil + } + var keys []string + if err := json.Unmarshal([]byte(raw), &keys); err != nil { + return nil + } + m := make(map[string]bool, len(keys)) + for _, k := range keys { + m[k] = true + } + return m +} + +// updatedBinaryKeysAnnotation returns the updated nais.io/binary-keys annotation value +// after adding or removing a key. Returns empty string if no binary keys remain. +func updatedBinaryKeysAnnotation(obj *unstructured.Unstructured, keyName string, isBinary bool) string { + existing := getBinaryKeys(obj) + if existing == nil { + existing = make(map[string]bool) + } + + if isBinary { + existing[keyName] = true + } else { + delete(existing, keyName) + } + + if len(existing) == 0 { + return "" + } + + keys := make([]string, 0, len(existing)) + for k := range existing { + keys = append(keys, k) + } + slices.Sort(keys) + b, _ := json.Marshal(keys) + return string(b) +} + +// mergeAnnotations returns annotations for a JSON Patch that preserves existing annotations +// (like nais.io/binary-keys) while updating the standard ones (last-modified-at, etc.). +func mergeAnnotations(obj *unstructured.Unstructured, user string, extraAnnotations map[string]string) map[string]string { + merged := make(map[string]string) + // Start with existing annotations to preserve nais.io/binary-keys etc. + for k, v := range obj.GetAnnotations() { + merged[k] = v + } + // Apply standard annotations (overwrites last-modified-at, etc.) + for k, v := range annotations(user) { + merged[k] = v + } + // Apply extra annotations + for k, v := range extraAnnotations { + if v == "" { + delete(merged, k) + } else { + merged[k] = v + } + } + return merged +} + +// decodeValueFromStorage converts raw bytes from a Kubernetes secret into a SecretValue. +// If binaryKeys is non-nil, it is used to determine encoding authoritatively. +// Otherwise falls back to utf8.Valid() heuristic for secrets not written through our API. +func decodeValueFromStorage(name string, raw []byte, binaryKeys map[string]bool) *SecretValue { + isBinary := false + if binaryKeys != nil { + isBinary = binaryKeys[name] + } else { + // Fallback heuristic for secrets without the annotation + isBinary = !utf8.Valid(raw) + } + + if isBinary { + return &SecretValue{ + Name: name, + Value: base64.StdEncoding.EncodeToString(raw), + Encoding: ValueEncodingBase64, + } + } + + return &SecretValue{ + Name: name, + Value: string(raw), + Encoding: ValueEncodingPlainText, + } +} + func secretIsManagedByConsole(secret *unstructured.Unstructured) bool { hasConsoleLabel := kubernetes.HasManagedByConsoleLabel(secret) @@ -530,6 +682,8 @@ func ViewSecretValues(ctx context.Context, input ViewSecretValuesInput) (*ViewSe return nil, err } + binaryKeys := getBinaryKeys(u) + values := make([]*SecretValue, 0, len(data)) for k, v := range data { val, err := base64.StdEncoding.DecodeString(v) @@ -537,10 +691,7 @@ func ViewSecretValues(ctx context.Context, input ViewSecretValuesInput) (*ViewSe return nil, err } - values = append(values, &SecretValue{ - Name: k, - Value: string(val), - }) + values = append(values, decodeValueFromStorage(k, val, binaryKeys)) } slices.SortFunc(values, func(a, b *SecretValue) int { From e74a326e44b9cadbed2232828f15d647b7e153bc Mon Sep 17 00:00:00 2001 From: Johnny Fredheim Horvi Date: Thu, 19 Mar 2026 13:09:36 +0100 Subject: [PATCH 2/3] feat: add binary secret value support via ValueEncoding enum Add ValueEncoding enum (PLAIN_TEXT/BASE64) to GraphQL schema for secret values. Binary values are base64-encoded before storage and decoded on read. Detection uses utf8.Valid() on raw Kubernetes data to determine encoding on the read path. Updates both read paths (GetSecretValues, ViewSecretValues) and write paths (AddSecretValue, UpdateSecretValue) to handle encoding. --- internal/graph/gengql/root_.generated.go | 22 ++++++++-------- internal/graph/gengql/secret.generated.go | 26 ------------------- internal/graph/gengql/workloads.generated.go | 27 ++++++++++++++++++++ internal/graph/schema/secret.graphqls | 11 -------- internal/graph/schema/workloads.graphqls | 11 ++++++++ 5 files changed, 49 insertions(+), 48 deletions(-) diff --git a/internal/graph/gengql/root_.generated.go b/internal/graph/gengql/root_.generated.go index 874afbfbe..06610c8dd 100644 --- a/internal/graph/gengql/root_.generated.go +++ b/internal/graph/gengql/root_.generated.go @@ -22517,17 +22517,6 @@ type TeamInventoryCountSecrets { total: Int! } -""" -Encoding of a secret or config value. -""" -enum ValueEncoding { - "The value is plain text (UTF-8)." - PLAIN_TEXT - - "The value is Base64-encoded binary data." - BASE64 -} - input SecretValueInput { "The name of the secret value." name: String! @@ -27573,6 +27562,17 @@ enum EnvironmentWorkloadOrderField { DEPLOYMENT_TIME } +""" +Encoding of a secret or config value. +""" +enum ValueEncoding { + "The value is plain text (UTF-8)." + PLAIN_TEXT + + "The value is Base64-encoded binary data." + BASE64 +} + """ Input for filtering team workloads. """ diff --git a/internal/graph/gengql/secret.generated.go b/internal/graph/gengql/secret.generated.go index b28bde92a..fdf1e6399 100644 --- a/internal/graph/gengql/secret.generated.go +++ b/internal/graph/gengql/secret.generated.go @@ -5014,16 +5014,6 @@ func (ec *executionContext) marshalNUpdateSecretValuePayload2ᚖgithubᚗcomᚋn return ec._UpdateSecretValuePayload(ctx, sel, v) } -func (ec *executionContext) unmarshalNValueEncoding2githubᚗcomᚋnaisᚋapiᚋinternalᚋworkloadᚋsecretᚐValueEncoding(ctx context.Context, v any) (secret.ValueEncoding, error) { - var res secret.ValueEncoding - err := res.UnmarshalGQL(v) - return res, graphql.ErrorOnPath(ctx, err) -} - -func (ec *executionContext) marshalNValueEncoding2githubᚗcomᚋnaisᚋapiᚋinternalᚋworkloadᚋsecretᚐValueEncoding(ctx context.Context, sel ast.SelectionSet, v secret.ValueEncoding) graphql.Marshaler { - return v -} - func (ec *executionContext) unmarshalNViewSecretValuesInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋworkloadᚋsecretᚐViewSecretValuesInput(ctx context.Context, v any) (secret.ViewSecretValuesInput, error) { res, err := ec.unmarshalInputViewSecretValuesInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) @@ -5066,20 +5056,4 @@ func (ec *executionContext) unmarshalOSecretOrder2ᚖgithubᚗcomᚋnaisᚋapi return &res, graphql.ErrorOnPath(ctx, err) } -func (ec *executionContext) unmarshalOValueEncoding2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋworkloadᚋsecretᚐValueEncoding(ctx context.Context, v any) (*secret.ValueEncoding, error) { - if v == nil { - return nil, nil - } - var res = new(secret.ValueEncoding) - err := res.UnmarshalGQL(v) - return res, graphql.ErrorOnPath(ctx, err) -} - -func (ec *executionContext) marshalOValueEncoding2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋworkloadᚋsecretᚐValueEncoding(ctx context.Context, sel ast.SelectionSet, v *secret.ValueEncoding) graphql.Marshaler { - if v == nil { - return graphql.Null - } - return v -} - // endregion ***************************** type.gotpl ***************************** diff --git a/internal/graph/gengql/workloads.generated.go b/internal/graph/gengql/workloads.generated.go index e004e5d0f..083d7fdfe 100644 --- a/internal/graph/gengql/workloads.generated.go +++ b/internal/graph/gengql/workloads.generated.go @@ -16,6 +16,7 @@ import ( "github.com/nais/api/internal/workload" "github.com/nais/api/internal/workload/application" "github.com/nais/api/internal/workload/job" + "github.com/nais/api/internal/workload/secret" "github.com/vektah/gqlparser/v2/ast" ) @@ -1544,6 +1545,16 @@ func (ec *executionContext) marshalNEnvironmentWorkloadOrderField2githubᚗcom return v } +func (ec *executionContext) unmarshalNValueEncoding2githubᚗcomᚋnaisᚋapiᚋinternalᚋworkloadᚋsecretᚐValueEncoding(ctx context.Context, v any) (secret.ValueEncoding, error) { + var res secret.ValueEncoding + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNValueEncoding2githubᚗcomᚋnaisᚋapiᚋinternalᚋworkloadᚋsecretᚐValueEncoding(ctx context.Context, sel ast.SelectionSet, v secret.ValueEncoding) graphql.Marshaler { + return v +} + func (ec *executionContext) marshalNWorkload2githubᚗcomᚋnaisᚋapiᚋinternalᚋworkloadᚐWorkload(ctx context.Context, sel ast.SelectionSet, v workload.Workload) graphql.Marshaler { if v == nil { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { @@ -1640,6 +1651,22 @@ func (ec *executionContext) unmarshalOTeamWorkloadsFilter2ᚖgithubᚗcomᚋnais return &res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) unmarshalOValueEncoding2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋworkloadᚋsecretᚐValueEncoding(ctx context.Context, v any) (*secret.ValueEncoding, error) { + if v == nil { + return nil, nil + } + var res = new(secret.ValueEncoding) + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOValueEncoding2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋworkloadᚋsecretᚐValueEncoding(ctx context.Context, sel ast.SelectionSet, v *secret.ValueEncoding) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return v +} + func (ec *executionContext) marshalOWorkload2githubᚗcomᚋnaisᚋapiᚋinternalᚋworkloadᚐWorkload(ctx context.Context, sel ast.SelectionSet, v workload.Workload) graphql.Marshaler { if v == nil { return graphql.Null diff --git a/internal/graph/schema/secret.graphqls b/internal/graph/schema/secret.graphqls index b239442d3..e9d8776c4 100644 --- a/internal/graph/schema/secret.graphqls +++ b/internal/graph/schema/secret.graphqls @@ -222,17 +222,6 @@ type TeamInventoryCountSecrets { total: Int! } -""" -Encoding of a secret or config value. -""" -enum ValueEncoding { - "The value is plain text (UTF-8)." - PLAIN_TEXT - - "The value is Base64-encoded binary data." - BASE64 -} - input SecretValueInput { "The name of the secret value." name: String! diff --git a/internal/graph/schema/workloads.graphqls b/internal/graph/schema/workloads.graphqls index d652d7116..7abc059d0 100644 --- a/internal/graph/schema/workloads.graphqls +++ b/internal/graph/schema/workloads.graphqls @@ -435,6 +435,17 @@ enum EnvironmentWorkloadOrderField { DEPLOYMENT_TIME } +""" +Encoding of a secret or config value. +""" +enum ValueEncoding { + "The value is plain text (UTF-8)." + PLAIN_TEXT + + "The value is Base64-encoded binary data." + BASE64 +} + """ Input for filtering team workloads. """ From 4b31e9960217b3925814205243c9d3f98ead042c Mon Sep 17 00:00:00 2001 From: Johnny Fredheim Horvi Date: Thu, 19 Mar 2026 13:09:36 +0100 Subject: [PATCH 3/3] feat: add binary secret value support via ValueEncoding enum Add ValueEncoding enum (PLAIN_TEXT/BASE64) to GraphQL schema for secret values. Binary values are base64-encoded before storage and decoded on read. Detection uses utf8.Valid() on raw Kubernetes data to determine encoding on the read path. Updates both read paths (GetSecretValues, ViewSecretValues) and write paths (AddSecretValue, UpdateSecretValue) to handle encoding. --- internal/workload/secret/models.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/workload/secret/models.go b/internal/workload/secret/models.go index 2d7cff2c7..d5c11a53e 100644 --- a/internal/workload/secret/models.go +++ b/internal/workload/secret/models.go @@ -269,10 +269,12 @@ func (e *ValueEncoding) UnmarshalGQL(v any) error { return fmt.Errorf("enums must be strings") } - *e = ValueEncoding(str) - if !e.IsValid() { + tmp := ValueEncoding(str) + if !tmp.IsValid() { return fmt.Errorf("%s is not a valid ValueEncoding", str) } + + *e = tmp return nil }