diff --git a/internal/graph/gengql/root_.generated.go b/internal/graph/gengql/root_.generated.go index abc17fdf8..1005e4cc4 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 { @@ -10728,6 +10729,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 @@ -22523,6 +22531,9 @@ input SecretValueInput { "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 { @@ -22681,8 +22692,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 { @@ -27562,6 +27576,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 0755d7cae..fdf1e6399 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)) } 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 d7820470f..e9d8776c4 100644 --- a/internal/graph/schema/secret.graphqls +++ b/internal/graph/schema/secret.graphqls @@ -228,6 +228,9 @@ input SecretValueInput { "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 +389,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/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. """ diff --git a/internal/workload/secret/models.go b/internal/workload/secret/models.go index ce1abfe75..d5c11a53e 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,44 @@ 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") + } + + tmp := ValueEncoding(str) + if !tmp.IsValid() { + return fmt.Errorf("%s is not a valid ValueEncoding", str) + } + + *e = tmp + 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 {