[MM-70188] Convert the os_platform session attribute to a select field (#37901)

This commit is contained in:
Devin Binnie
2026-08-10 16:03:20 -04:00
committed by GitHub
parent 9cf617a14b
commit a6d008c5c2
4 changed files with 184 additions and 3 deletions
+48
View File
@@ -938,6 +938,51 @@ func mergeBoardsStatusColors(attrs model.StringInterface, colorByName map[string
return out
}
// syncSessionAttributeOptions replaces the persisted select options on an
// already-seeded session attribute field with the ones the schema declares.
// Each existing option's ID is reused by name so IDs stay stable across
// restarts, while a field the schema has converted from text to select picks
// up its options for the first time. Options are dropped entirely for fields
// the schema declares without them, so a select → text conversion doesn't
// leave the UI rendering a stale dropdown.
func syncSessionAttributeOptions(current, expected *model.PropertyField) error {
expectedOptions, ok := expected.Attrs[model.PropertyFieldAttributeOptions]
if !ok {
delete(current.Attrs, model.PropertyFieldAttributeOptions)
return nil
}
idByName := make(map[string]string)
if raw, found := current.Attrs[model.PropertyFieldAttributeOptions]; found {
currentOptions, err := model.NewPropertyOptionsFromFieldAttrs[*model.PluginPropertyOption](raw)
if err != nil {
return fmt.Errorf("failed to read persisted options: %w", err)
}
for _, option := range currentOptions {
idByName[option.GetName()] = option.GetID()
}
}
options, err := model.NewPropertyOptionsFromFieldAttrs[*model.PluginPropertyOption](expectedOptions)
if err != nil {
return fmt.Errorf("failed to read schema options: %w", err)
}
// Written back as []any of map[string]any, the canonical attrs shape every
// downstream reader of attrs["options"] expects.
merged := make([]any, len(options))
for i, option := range options {
id := option.GetID()
if existingID, found := idByName[option.GetName()]; found {
id = existingID
}
merged[i] = map[string]any{"id": id, "name": option.GetName()}
}
current.Attrs[model.PropertyFieldAttributeOptions] = merged
return nil
}
// seedSessionAttributeFields idempotently seeds the built-in session attribute property fields.
func (s *Server) seedSessionAttributeFields(groupID string) error {
existing, err := s.propertyService.SearchPropertyFields(nil, groupID, model.PropertyFieldSearchOpts{PerPage: 100})
@@ -958,6 +1003,9 @@ func (s *Server) seedSessionAttributeFields(groupID string) error {
current.Type = expected.Type
current.Attrs["platforms"] = expected.Attrs["platforms"]
current.Attrs[model.SAAttrDisplayName] = expected.Attrs[model.SAAttrDisplayName]
if err := syncSessionAttributeOptions(current, expected); err != nil {
return fmt.Errorf("failed to sync options for session attribute field %q: %w", expected.Name, err)
}
current.ObjectType = expected.ObjectType
current.TargetType = expected.TargetType
current.Protected = expected.Protected
+99
View File
@@ -362,6 +362,53 @@ func TestCPADisplayNameBackfill_BackfillsProtectedSourceOnlyField(t *testing.T)
require.Equal(t, "true", data.Value)
}
var expectedOSPlatformOptions = []string{"macos", "windows", "linux", "ios", "android"}
func sessionAttributeFieldByName(t *testing.T, th *TestHelper, groupID, name string) *model.PropertyField {
t.Helper()
fields, appErr := th.App.SearchPropertyFields(th.Context, groupID, model.PropertyFieldSearchOpts{PerPage: 100})
require.Nil(t, appErr)
for _, field := range fields {
if field.Name == name {
return field
}
}
require.FailNowf(t, "session attribute field not found", "field %q was not seeded", name)
return nil
}
func sessionAttributeOptions(t *testing.T, field *model.PropertyField) model.PropertyOptions[*model.PluginPropertyOption] {
t.Helper()
options, err := model.NewPropertyOptionsFromFieldAttrs[*model.PluginPropertyOption](field.Attrs[model.PropertyFieldAttributeOptions])
require.NoError(t, err)
return options
}
func sessionAttributeOptionNames(t *testing.T, field *model.PropertyField) []string {
t.Helper()
options := sessionAttributeOptions(t, field)
names := make([]string, 0, len(options))
for _, option := range options {
names = append(names, option.GetName())
}
return names
}
func sessionAttributeOptionIDsByName(t *testing.T, field *model.PropertyField) map[string]string {
t.Helper()
options := sessionAttributeOptions(t, field)
idsByName := make(map[string]string, len(options))
for _, option := range options {
idsByName[option.GetName()] = option.GetID()
}
return idsByName
}
func TestDoSetupSessionAttributesProperties(t *testing.T) {
expectedFieldCount := len(model.SessionAttributeSystemFields("group-id"))
@@ -413,6 +460,58 @@ func TestDoSetupSessionAttributesProperties(t *testing.T) {
)
})
t.Run("os_platform seeds as a select with the known platform values", func(t *testing.T) {
th := Setup(t)
group, appErr := th.App.GetPropertyGroup(th.Context, model.SessionAttributesPropertyGroupName)
require.Nil(t, appErr)
field := sessionAttributeFieldByName(t, th, group.ID, model.SessionAttributesPropertyFieldOSPlatform)
require.Equal(t, model.PropertyFieldTypeSelect, field.Type)
require.Equal(t, expectedOSPlatformOptions, sessionAttributeOptionNames(t, field))
require.True(t, model.IsValidSessionAttributeValue(field, "windows"))
require.False(t, model.IsValidSessionAttributeValue(field, "darwin"))
})
t.Run("converts a legacy text os_platform field into a select", func(t *testing.T) {
th := Setup(t)
group, appErr := th.App.GetPropertyGroup(th.Context, model.SessionAttributesPropertyGroupName)
require.Nil(t, appErr)
// Restore the pre-conversion shape a server upgrade would find: free
// text with no options. Written with a nil request context so
// SessionAttributesHook treats it as a system caller, the same way the
// seed itself does.
field := sessionAttributeFieldByName(t, th, group.ID, model.SessionAttributesPropertyFieldOSPlatform)
field.Type = model.PropertyFieldTypeText
delete(field.Attrs, model.PropertyFieldAttributeOptions)
_, _, _, err := th.Server.propertyService.UpdatePropertyFields(nil, group.ID, []*model.PropertyField{field})
require.NoError(t, err)
require.NoError(t, th.Server.doSetupSessionAttributesProperties())
converted := sessionAttributeFieldByName(t, th, group.ID, model.SessionAttributesPropertyFieldOSPlatform)
require.Equal(t, model.PropertyFieldTypeSelect, converted.Type)
require.Equal(t, expectedOSPlatformOptions, sessionAttributeOptionNames(t, converted))
require.True(t, model.IsValidSessionAttributeValue(converted, "windows"))
})
t.Run("re-seeding preserves select option IDs", func(t *testing.T) {
th := Setup(t)
group, appErr := th.App.GetPropertyGroup(th.Context, model.SessionAttributesPropertyGroupName)
require.Nil(t, appErr)
before := sessionAttributeOptionIDsByName(t, sessionAttributeFieldByName(t, th, group.ID, model.SessionAttributesPropertyFieldOSPlatform))
require.Len(t, before, len(expectedOSPlatformOptions))
require.NoError(t, th.Server.doSetupSessionAttributesProperties())
after := sessionAttributeOptionIDsByName(t, sessionAttributeFieldByName(t, th, group.ID, model.SessionAttributesPropertyFieldOSPlatform))
require.Equal(t, before, after, "re-seeding must not regenerate option IDs")
})
t.Run("re-running is idempotent", func(t *testing.T) {
th := Setup(t)
+9 -1
View File
@@ -257,7 +257,15 @@ func SessionAttributeSystemFields(groupID string) []*PropertyField {
sessionAttributeField(groupID, SessionAttributesPropertyFieldMDMEnrolled, SessionAttributesDisplayNameMDMEnrolled, PropertyFieldTypeSelect, clientsOnly, SessionAttributeDefaultTTLPosture, SessionAttributeDefaultGracePosture, boolSelectOptions),
sessionAttributeField(groupID, SessionAttributesPropertyFieldJailbreakDetected, SessionAttributesDisplayNameJailbreakDetected, PropertyFieldTypeSelect, mobileOnly, SessionAttributeDefaultTTLPosture, SessionAttributeDefaultGracePosture, boolSelectOptions),
sessionAttributeField(groupID, SessionAttributesPropertyFieldOSPlatform, SessionAttributesDisplayNameOSPlatform, PropertyFieldTypeText, clientsOnly, SessionAttributeDefaultTTLPosture, SessionAttributeDefaultGracePosture, nil),
sessionAttributeField(groupID, SessionAttributesPropertyFieldOSPlatform, SessionAttributesDisplayNameOSPlatform, PropertyFieldTypeSelect, clientsOnly, SessionAttributeDefaultTTLPosture, SessionAttributeDefaultGracePosture, StringInterface{
PropertyFieldAttributeOptions: []map[string]string{
{"name": "macos"},
{"name": "windows"},
{"name": "linux"},
{"name": "ios"},
{"name": "android"},
},
}),
sessionAttributeField(groupID, SessionAttributesPropertyFieldOSVersion, SessionAttributesDisplayNameOSVersion, PropertyFieldTypeText, clientsOnly, SessionAttributeDefaultTTLPosture, SessionAttributeDefaultGracePosture, nil),
sessionAttributeField(groupID, SessionAttributesPropertyFieldClientVersion, SessionAttributesDisplayNameClientVersion, PropertyFieldTypeText, clientsOnly, SessionAttributeDefaultTTLPosture, SessionAttributeDefaultGracePosture, nil),
+28 -2
View File
@@ -63,6 +63,32 @@ func TestSessionAttributeSystemFieldsDisplayNames(t *testing.T) {
}
}
func TestSessionAttributeSystemFieldsOSPlatform(t *testing.T) {
var field *PropertyField
for _, f := range SessionAttributeSystemFields("group-id") {
if f.Name == SessionAttributesPropertyFieldOSPlatform {
field = f
break
}
}
require.NotNil(t, field)
require.Equal(t, PropertyFieldTypeSelect, field.Type)
options, err := NewPropertyOptionsFromFieldAttrs[*PluginPropertyOption](field.Attrs[PropertyFieldAttributeOptions])
require.NoError(t, err)
names := make([]string, 0, len(options))
for _, option := range options {
names = append(names, option.GetName())
}
assert.Equal(t, []string{"macos", "windows", "linux", "ios", "android"}, names)
for _, name := range names {
assert.True(t, IsValidSessionAttributeValue(field, name), "%q must be an accepted value", name)
}
assert.False(t, IsValidSessionAttributeValue(field, "darwin"))
}
func TestSAFieldEnabledForPlatform(t *testing.T) {
field := &PropertyField{
Name: SessionAttributesPropertyFieldVPNActive,
@@ -87,7 +113,7 @@ func TestSAFieldEnabledForPlatform(t *testing.T) {
func TestIsValidSessionAttributeValue(t *testing.T) {
textField := &PropertyField{
Name: SessionAttributesPropertyFieldOSPlatform,
Name: SessionAttributesPropertyFieldOSVersion,
Type: PropertyFieldTypeText,
}
selectField := &PropertyField{
@@ -112,7 +138,7 @@ func TestIsValidSessionAttributeValue(t *testing.T) {
}
t.Run("text accepts string", func(t *testing.T) {
assert.True(t, IsValidSessionAttributeValue(textField, "linux"))
assert.True(t, IsValidSessionAttributeValue(textField, "15.1"))
})
t.Run("text rejects non-string", func(t *testing.T) {