Skip to content

Commit 40ee185

Browse files
authored
internal: Add support for 'sensitive' JSON tag for bundle config fields (#5896)
## Changes Add support for 'sensitive' JSON tag for bundle config fields ## Why This allows us to mark certain fields (for example, secrets.value) as sensitive and let CLI handle masking it in command outputs. Used in #5861 ## Tests Added unit tests. Will be tests end to end with unit tests with UC secrets <!-- If your PR needs to be included in the release notes for next release, add a separate entry in NEXT_CHANGELOG.md as part of your PR. -->
1 parent 51ce3c7 commit 40ee185

20 files changed

Lines changed: 597 additions & 9 deletions

bundle/direct/dstate/state.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import (
1616
"github.com/databricks/cli/bundle/deployplan"
1717
"github.com/databricks/cli/bundle/statemgmt/resourcestate"
1818
"github.com/databricks/cli/internal/build"
19+
"github.com/databricks/cli/libs/dyn"
1920
"github.com/databricks/cli/libs/log"
21+
"github.com/databricks/cli/libs/structs/structwalk"
2022
"github.com/google/uuid"
2123
)
2224

@@ -127,8 +129,10 @@ func (db *DeploymentState) SaveState(key, newID string, state any, dependsOn []d
127129
db.Data.State = make(map[string]ResourceEntry)
128130
}
129131

130-
// don't indent so that every WAL entry remains on a single line
131-
jsonMessage, err := json.Marshal(state)
132+
// Redact sensitive fields before persisting: secrets must not appear on disk
133+
// in plaintext. The original struct is not modified; the plan uses the
134+
// unredacted in-memory value for API calls.
135+
jsonMessage, err := structwalk.RedactSensitiveFields(state, dyn.SensitiveValueRedacted)
132136
if err != nil {
133137
return err
134138
}

libs/dyn/convert/from_typed.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,13 @@ func fromTypedStruct(src reflect.Value, ref dyn.Value, options ...fromTypedOptio
133133
return dyn.InvalidValue, err
134134
}
135135

136+
// If the field carries the bundle:"sensitive" tag and resolved to a string
137+
// (including empty), wrap it as a sensitive value so that all downstream
138+
// serializers (JSON, YAML) redact it automatically.
139+
if info.Sensitive[k] && nv.Kind() == dyn.KindString {
140+
nv = dyn.NewSensitiveValue(nv.MustString(), nv.Locations())
141+
}
142+
136143
// Either if the key was set in the reference, the field is not zero-valued, OR it's forced
137144
if ok || nv.Kind() != dyn.KindNil || isForced {
138145
// If v isZero, it could be because it's a variable reference; so we check that nv is zero as well

libs/dyn/convert/from_typed_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -926,3 +926,55 @@ func TestFromTypedForceSendFieldsEmbedded(t *testing.T) {
926926
assert.Equal(t, dyn.KindNil, field.Kind(), "embedded field should be present due to ForceSendFields")
927927
assert.Equal(t, dyn.V("value"), other)
928928
}
929+
930+
func TestFromTypedSensitiveField(t *testing.T) {
931+
type Tmp struct {
932+
Name string `json:"name"`
933+
Token string `json:"token" bundle:"sensitive"`
934+
}
935+
936+
src := Tmp{
937+
Name: "my-resource",
938+
Token: "super-secret",
939+
}
940+
941+
nv, err := FromTyped(src, dyn.NilValue)
942+
require.NoError(t, err)
943+
944+
name := nv.Get("name")
945+
token := nv.Get("token")
946+
947+
assert.False(t, name.IsSensitive(), "name should not be sensitive")
948+
assert.True(t, token.IsSensitive(), "token field should be sensitive")
949+
950+
// MustString returns the real value.
951+
assert.Equal(t, "super-secret", token.MustString())
952+
// AsAny returns the redaction placeholder.
953+
assert.Equal(t, dyn.SensitiveValueRedacted, token.AsAny())
954+
}
955+
956+
func TestFromTypedSensitiveFieldEmpty(t *testing.T) {
957+
// An empty sensitive string must still be wrapped as sensitive.
958+
type Tmp struct {
959+
Token string `json:"token" bundle:"sensitive"`
960+
}
961+
962+
nv, err := FromTyped(Tmp{Token: ""}, dyn.NilValue)
963+
require.NoError(t, err)
964+
965+
token := nv.Get("token")
966+
// Empty sensitive fields resolve to KindNil when the struct omits them
967+
// (omitempty is the default). When the field IS present (e.g. forced via
968+
// a non-nil pointer or ForceSendFields), it should be sensitive.
969+
// Use a pointer-to-struct to force the zero value through.
970+
src := &Tmp{Token: ""}
971+
nv2, err := FromTyped(src, dyn.NilValue)
972+
require.NoError(t, err)
973+
974+
token2 := nv2.Get("token")
975+
if token2.Kind() == dyn.KindString {
976+
assert.True(t, token2.IsSensitive(), "empty sensitive string should still be sensitive")
977+
assert.Empty(t, token2.MustString())
978+
}
979+
_ = token // non-pointer form may be KindNil, which is fine
980+
}

libs/dyn/convert/normalize.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,15 @@ func (n normalizeOptions) normalizeString(typ reflect.Type, src dyn.Value, path
315315

316316
switch src.Kind() {
317317
case dyn.KindString:
318+
// A sensitive string must be returned as-is: dyn.NewValue(MustString(), ...)
319+
// would construct a plain string and strip the secretString wrapper. Type
320+
// normalization (e.g. coercing a bool "true" to string) cannot apply to a
321+
// sensitive value because its Kind is already KindString, so returning early
322+
// here is correct. Updating the secret value is done via FromTyped, not
323+
// Normalize: FromTyped re-wraps the new value as NewSensitiveValue.
324+
if src.IsSensitive() {
325+
return src, nil
326+
}
318327
return dyn.NewValue(src.MustString(), src.Locations()), nil
319328
case dyn.KindBool:
320329
return dyn.NewValue(strconv.FormatBool(src.MustBool()), src.Locations()), nil

libs/dyn/convert/struct_info.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ type structInfo struct {
2828
// Maps JSON-name of the field to Golang struct name
2929
GolangNames map[string]string
3030

31+
// Sensitive tracks fields tagged `bundle:"sensitive"` by their JSON name.
32+
// Values for these fields should be masked in display output.
33+
Sensitive map[string]bool
34+
3135
// ForceSendFieldsIndex maps the JSON-name of the field to the index path (for
3236
// use with [reflect.Value.FieldByIndex]) of the ForceSendFields slice that
3337
// governs it: the one declared by the struct that also declares the field.
@@ -68,6 +72,7 @@ func buildStructInfo(typ reflect.Type) structInfo {
6872
ForceEmpty: make(map[string]bool),
6973
GolangNames: make(map[string]string),
7074
ForceSendFieldsIndex: make(map[string][]int),
75+
Sensitive: make(map[string]bool),
7176
}
7277

7378
// Queue holds the indexes of the structs to visit.
@@ -134,6 +139,11 @@ func buildStructInfo(typ reflect.Type) structInfo {
134139
}
135140
out.GolangNames[name] = sf.Name
136141

142+
btag := structtag.BundleTag(sf.Tag.Get("bundle"))
143+
if btag.Sensitive() {
144+
out.Sensitive[name] = true
145+
}
146+
137147
// The field is declared directly in this struct, so it is governed by
138148
// this struct's ForceSendFields (if it has one).
139149
if forceSendFieldsIndex != nil {
@@ -176,6 +186,25 @@ func (s *structInfo) FieldValues(v reflect.Value) []FieldValue {
176186
return out
177187
}
178188

189+
// SensitiveFieldNames returns the JSON field names of typ that carry the
190+
// `bundle:"sensitive"` tag. A pointer type is dereferenced before inspection.
191+
// Returns nil for non-struct types. Callers use this to identify fields that
192+
// must be masked in display output (validate -o json, plan -o json) without
193+
// touching the typed values used by the actual deployment pipeline.
194+
func SensitiveFieldNames(typ reflect.Type) map[string]bool {
195+
for typ.Kind() == reflect.Pointer {
196+
typ = typ.Elem()
197+
}
198+
if typ.Kind() != reflect.Struct {
199+
return nil
200+
}
201+
si := getStructInfo(typ)
202+
if len(si.Sensitive) == 0 {
203+
return nil
204+
}
205+
return si.Sensitive
206+
}
207+
179208
// isForceSend reports whether the field named k is listed in the ForceSendFields
180209
// that governs it (see structInfo.ForceSendFieldsIndex).
181210
func (s *structInfo) isForceSend(v reflect.Value, k string) bool {

libs/dyn/convert/struct_info_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
"github.com/databricks/cli/libs/dyn"
88
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
910
)
1011

1112
func TestStructInfoPlain(t *testing.T) {
@@ -226,3 +227,103 @@ func TestStructInfoValueFieldMultiple(t *testing.T) {
226227
getStructInfo(reflect.TypeFor[Tmp]())
227228
})
228229
}
230+
231+
func TestSensitiveFieldNamesPlain(t *testing.T) {
232+
type Tmp struct {
233+
Name string `json:"name"`
234+
Token string `json:"token" bundle:"sensitive"`
235+
}
236+
237+
fields := SensitiveFieldNames(reflect.TypeFor[Tmp]())
238+
assert.True(t, fields["token"])
239+
assert.False(t, fields["name"])
240+
}
241+
242+
func TestSensitiveFieldNamesPointerDereference(t *testing.T) {
243+
type Tmp struct {
244+
Token string `json:"token" bundle:"sensitive"`
245+
}
246+
247+
fields := SensitiveFieldNames(reflect.TypeFor[*Tmp]())
248+
assert.True(t, fields["token"])
249+
}
250+
251+
func TestSensitiveFieldNamesNilForNonStruct(t *testing.T) {
252+
assert.Nil(t, SensitiveFieldNames(reflect.TypeFor[string]()))
253+
}
254+
255+
func TestSensitiveFieldNamesNilWhenNone(t *testing.T) {
256+
type Tmp struct {
257+
Name string `json:"name"`
258+
}
259+
260+
assert.Nil(t, SensitiveFieldNames(reflect.TypeFor[Tmp]()))
261+
}
262+
263+
func TestSensitiveFieldNamesEmbeddedByValue(t *testing.T) {
264+
type Inner struct {
265+
Token string `json:"token" bundle:"sensitive"`
266+
}
267+
268+
type Outer struct {
269+
Name string `json:"name"`
270+
Inner
271+
}
272+
273+
fields := SensitiveFieldNames(reflect.TypeFor[Outer]())
274+
assert.True(t, fields["token"])
275+
assert.False(t, fields["name"])
276+
}
277+
278+
func TestSensitiveFieldNamesEmbeddedByPointer(t *testing.T) {
279+
type Inner struct {
280+
Token string `json:"token" bundle:"sensitive"`
281+
}
282+
283+
type Outer struct {
284+
Name string `json:"name"`
285+
*Inner
286+
}
287+
288+
fields := SensitiveFieldNames(reflect.TypeFor[Outer]())
289+
assert.True(t, fields["token"])
290+
assert.False(t, fields["name"])
291+
}
292+
293+
func TestSensitiveFieldNamesTopLevelPrecedence(t *testing.T) {
294+
// A sensitive field in an embedded struct is shadowed by a non-sensitive
295+
// field of the same JSON name at the top level — top level wins.
296+
type Inner struct {
297+
Token string `json:"token" bundle:"sensitive"`
298+
}
299+
300+
type Outer struct {
301+
Token string `json:"token"` // not sensitive; shadows Inner.Token
302+
Inner
303+
}
304+
305+
fields := SensitiveFieldNames(reflect.TypeFor[Outer]())
306+
assert.False(t, fields["token"])
307+
}
308+
309+
// TestSensitiveFieldNamesUsage demonstrates using SensitiveFieldNames with
310+
// FromTyped: a sensitive field's value round-trips through dyn.Value and the
311+
// caller can check which fields to mask before marshaling.
312+
func TestSensitiveFieldNamesUsage(t *testing.T) {
313+
type Resource struct {
314+
Name string `json:"name"`
315+
Token string `json:"token" bundle:"sensitive"`
316+
}
317+
318+
src := Resource{Name: "my-resource", Token: "s3cr3t"}
319+
v, err := FromTyped(src, dyn.NilValue)
320+
require.NoError(t, err)
321+
322+
fields := SensitiveFieldNames(reflect.TypeFor[Resource]())
323+
assert.True(t, fields["token"], "token should be identified as sensitive")
324+
325+
// Verify the value round-tripped correctly before masking.
326+
tok, err := dyn.GetByPath(v, dyn.NewPath(dyn.Key("token")))
327+
require.NoError(t, err)
328+
assert.Equal(t, "s3cr3t", tok.MustString())
329+
}

libs/dyn/dynvar/resolve.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,18 +153,31 @@ func (r *resolver) resolveRef(ref Ref, seen []string) (dyn.Value, error) {
153153
// of where it is used. This also means that relative path resolution is done
154154
// relative to where a variable is used, not where it is defined.
155155
//
156+
// Preserve sensitivity: NewValue strips the secretString wrapper, so use
157+
// NewSensitiveValue when the resolved value is a sensitive string.
158+
if resolved[0].IsSensitive() {
159+
s, _ := resolved[0].AsString()
160+
return dyn.NewSensitiveValue(s, ref.Value.Locations()), nil
161+
}
156162
return dyn.NewValue(resolved[0].Value(), ref.Value.Locations()), nil
157163
}
158164

159165
// Not pure; perform string interpolation.
166+
// Track whether any resolved value is sensitive; if so, the result is also sensitive.
167+
anySensitive := false
160168
for j := range ref.Matches {
161169
// The value is invalid if resolution returned [ErrSkipResolution].
162170
// We must skip those and leave the original variable reference in place.
163171
if !resolved[j].IsValid() {
164172
continue
165173
}
166174

175+
if resolved[j].IsSensitive() {
176+
anySensitive = true
177+
}
178+
167179
// Try to turn the resolved value into a string.
180+
// Use AsString (not AsAny) to get the real value even for sensitive strings.
168181
s, ok := resolved[j].AsString()
169182
if !ok {
170183
// Only allow primitive types to be converted to string.
@@ -179,7 +192,11 @@ func (r *resolver) resolveRef(ref Ref, seen []string) (dyn.Value, error) {
179192
ref.Str = strings.Replace(ref.Str, ref.Matches[j][0], s, 1)
180193
}
181194

182-
return dyn.NewValue(ref.Str, ref.Value.Locations()), nil
195+
result := dyn.NewValue(ref.Str, ref.Value.Locations())
196+
if anySensitive {
197+
result = result.MarkSensitive()
198+
}
199+
return result, nil
183200
}
184201

185202
func (r *resolver) resolveKey(key string, seen []string) (dyn.Value, error) {

libs/dyn/dynvar/resolve_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,39 @@ func TestResolveMapVariable(t *testing.T) {
371371
assert.Equal(t, "value2", getByPath(t, mapVal, "key2").MustString())
372372
}
373373

374+
func TestResolveSensitivePureSubstitution(t *testing.T) {
375+
// A pure reference to a sensitive value must produce a sensitive result.
376+
in := dyn.V(map[string]dyn.Value{
377+
"secret": dyn.NewSensitiveValue("top-secret", nil),
378+
"ref": dyn.V("${secret}"),
379+
})
380+
381+
out, err := dynvar.Resolve(in, dynvar.DefaultLookup(in))
382+
require.NoError(t, err)
383+
384+
result := getByPath(t, out, "ref")
385+
assert.True(t, result.IsSensitive())
386+
// MustString returns the real value even when sensitive.
387+
assert.Equal(t, "top-secret", result.MustString())
388+
}
389+
390+
func TestResolveSensitiveStringInterpolation(t *testing.T) {
391+
// When any resolved value is sensitive, the interpolated result must also be sensitive.
392+
in := dyn.V(map[string]dyn.Value{
393+
"secret": dyn.NewSensitiveValue("password123", nil),
394+
"prefix": dyn.V("token"),
395+
"ref": dyn.V("${prefix}:${secret}"),
396+
})
397+
398+
out, err := dynvar.Resolve(in, dynvar.DefaultLookup(in))
399+
require.NoError(t, err)
400+
401+
result := getByPath(t, out, "ref")
402+
assert.True(t, result.IsSensitive())
403+
// The real interpolated string is still accessible.
404+
assert.Equal(t, "token:password123", result.MustString())
405+
}
406+
374407
func TestResolveSequenceVariable(t *testing.T) {
375408
in := dyn.V(map[string]dyn.Value{
376409
"seq": dyn.V([]dyn.Value{

libs/dyn/jsonsaver/marshal.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ func (w wrap) MarshalJSON() ([]byte, error) {
3939

4040
// marshalValue recursively writes JSON for a [dyn.Value] to the buffer.
4141
func marshalValue(buf *bytes.Buffer, v dyn.Value) error {
42+
if v.IsSensitive() {
43+
out, err := marshalNoEscape(dyn.SensitiveValueRedacted)
44+
if err != nil {
45+
return err
46+
}
47+
// The encoder writes a trailing newline, so we need to remove it.
48+
out = out[:len(out)-1]
49+
buf.Write(out)
50+
return nil
51+
}
52+
4253
switch v.Kind() {
4354
case dyn.KindString, dyn.KindBool, dyn.KindInt, dyn.KindFloat, dyn.KindTime, dyn.KindNil:
4455
out, err := marshalNoEscape(v.AsAny())

0 commit comments

Comments
 (0)