From 57ed57d2ace757a4e16ab5d502ed912252c77935 Mon Sep 17 00:00:00 2001 From: Tiago Silva Date: Tue, 23 Jul 2024 09:40:05 +0100 Subject: [PATCH] [sec_scan][15] add support for edits to `AccessGraphSettings` via `tctl` (#44055) This PR allows any cluster admin to edit `access_graph_settings` objects via `tctl`. This PR is part of https://github.com/gravitational/access-graph/issues/637. Signed-off-by: Tiago Silva --- api/types/header/convert/legacy/header.go | 18 ++ lib/services/presets.go | 1 + lib/services/resource.go | 2 + .../clusterconfig/accessgraphsettings.go | 148 ++++++++++ .../clusterconfig/accessgraphsettings_test.go | 269 ++++++++++++++++++ tool/tctl/common/collection.go | 18 ++ tool/tctl/common/resource_command.go | 41 +++ 7 files changed, 497 insertions(+) create mode 100644 tool/tctl/common/clusterconfig/accessgraphsettings.go create mode 100644 tool/tctl/common/clusterconfig/accessgraphsettings_test.go diff --git a/api/types/header/convert/legacy/header.go b/api/types/header/convert/legacy/header.go index de607fb64140f..c2bd91ea5350d 100644 --- a/api/types/header/convert/legacy/header.go +++ b/api/types/header/convert/legacy/header.go @@ -17,6 +17,8 @@ limitations under the License. package legacy import ( + "time" + "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/types/header" ) @@ -32,3 +34,19 @@ func FromHeaderMetadata(metadata header.Metadata) types.Metadata { Revision: metadata.Revision, } } + +// ToHeaderMetadata will convert a types.Metadata object to this metadata object. +// TODO: Remove this once we get rid of the old Metadata object. +func ToHeaderMetadata(metadata types.Metadata) header.Metadata { + var expires time.Time + if metadata.Expires != nil { + expires = *metadata.Expires + } + return header.Metadata{ + Name: metadata.Name, + Expires: expires, + Description: metadata.Description, + Labels: metadata.Labels, + Revision: metadata.Revision, + } +} diff --git a/lib/services/presets.go b/lib/services/presets.go index 1ca83f0d6377a..f66b442934f93 100644 --- a/lib/services/presets.go +++ b/lib/services/presets.go @@ -176,6 +176,7 @@ func NewPresetEditorRole() types.Role { types.NewRule(types.KindAccessMonitoringRule, RW()), types.NewRule(types.KindAppServer, RW()), types.NewRule(types.KindVnetConfig, RW()), + types.NewRule(types.KindAccessGraphSettings, RW()), }, }, }, diff --git a/lib/services/resource.go b/lib/services/resource.go index 48ed0a566d506..ba02befb56c85 100644 --- a/lib/services/resource.go +++ b/lib/services/resource.go @@ -237,6 +237,8 @@ func ParseShortcut(in string) (string, error) { return types.KindAccessRequest, nil case types.KindPlugin, types.KindPlugin + "s": return types.KindPlugin, nil + case types.KindAccessGraphSettings, "ags": + return types.KindAccessGraphSettings, nil } return "", trace.BadParameter("unsupported resource: %q - resources should be expressed as 'type/name', for example 'connector/github'", in) } diff --git a/tool/tctl/common/clusterconfig/accessgraphsettings.go b/tool/tctl/common/clusterconfig/accessgraphsettings.go new file mode 100644 index 0000000000000..939b8ae23efc0 --- /dev/null +++ b/tool/tctl/common/clusterconfig/accessgraphsettings.go @@ -0,0 +1,148 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package clusterconfig + +import ( + "github.com/gravitational/trace" + + clusterconfigpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/clusterconfig/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/types/header/convert/legacy" + headerv1 "github.com/gravitational/teleport/api/types/header/convert/v1" + "github.com/gravitational/teleport/lib/utils" +) + +// AccessGraphSettings is a type to represent [clusterconfigpb.AcccessGraphSettings] +// which implements types.Resource and custom YAML (un)marshaling. +// This satisfies the expected YAML format for // the resource, which would be +// hard/impossible to do for the proto resource directly +type AccessGraphSettings struct { + // ResourceHeader is embedded to implement types.Resource + types.ResourceHeader + // Spec is the specification + Spec accessGraphSettingsSpec `json:"spec"` +} + +// accessGraphSettingsSpec holds the AccessGraphSettings properties. +type accessGraphSettingsSpec struct { + SecretsScanConfig string `json:"secrets_scan_config"` +} + +// CheckAndSetDefaults sanity checks AccessGraphSettings fields to catch simple errors, and +// sets default values for all fields with defaults. +func (r *AccessGraphSettings) CheckAndSetDefaults() error { + if err := r.Metadata.CheckAndSetDefaults(); err != nil { + return trace.Wrap(err) + } + if r.Kind == "" { + r.Kind = types.KindAccessGraphSettings + } else if r.Kind != types.KindAccessGraphSettings { + return trace.BadParameter("unexpected resource kind %q, must be %q", r.Kind, types.KindAccessGraphSettings) + } + if r.Version == "" { + r.Version = types.V1 + } else if r.Version != types.V1 { + return trace.BadParameter("unsupported resource version %q, %q is currently the only supported version", r.Version, types.V1) + } + if r.Metadata.Name == "" { + r.Metadata.Name = types.MetaNameAccessGraphSettings + } else if r.Metadata.Name != types.MetaNameAccessGraphSettings { + return trace.BadParameter("access graph settings must have a name %q", types.MetaNameAccessGraphSettings) + } + + if _, err := stringToSecretsScanConfig(r.Spec.SecretsScanConfig); err != nil { + return trace.BadParameter("secrets_scan_config must be one of [enabled, disabled]") + } + + return nil +} + +// UnmarshalAccessGraphSettings parses a [*clusterconfigpb.AccessGraphSettings] in the [AccessGraphSettings] +// format which matches the expected YAML format for Teleport resources, sets default values, and +// converts to [*clusterconfigpb.AccessGraphSettings]. +func UnmarshalAccessGraphSettings(raw []byte) (*clusterconfigpb.AccessGraphSettings, error) { + var resource AccessGraphSettings + if err := utils.FastUnmarshal(raw, &resource); err != nil { + return nil, trace.Wrap(err) + } + if err := resource.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err) + } + rec, err := resourceToProto(&resource) + return rec, trace.Wrap(err) +} + +// ProtoToResource converts a [*clusterconfigpb.AccessGraphSettings] into a [*AccessGraphSettings] which +// implements types.Resource and can be marshaled to YAML or JSON in a +// human-friendly format. +func ProtoToResource(set *clusterconfigpb.AccessGraphSettings) (*AccessGraphSettings, error) { + conf, err := secretsScanConfigToString(set.Spec.SecretsScanConfig) + if err != nil { + return nil, trace.Wrap(err) + } + r := &AccessGraphSettings{ + ResourceHeader: types.ResourceHeader{ + Kind: set.Kind, + Version: set.Version, + Metadata: legacy.FromHeaderMetadata(headerv1.FromMetadataProto(set.Metadata)), + }, + Spec: accessGraphSettingsSpec{ + SecretsScanConfig: conf, + }, + } + return r, nil +} + +func resourceToProto(r *AccessGraphSettings) (*clusterconfigpb.AccessGraphSettings, error) { + secretsScanConfig, err := stringToSecretsScanConfig(r.Spec.SecretsScanConfig) + if err != nil { + return nil, trace.Wrap(err) + } + return &clusterconfigpb.AccessGraphSettings{ + Kind: r.Kind, + SubKind: r.SubKind, + Version: r.Version, + Metadata: headerv1.ToMetadataProto(legacy.ToHeaderMetadata(r.Metadata)), + Spec: &clusterconfigpb.AccessGraphSettingsSpec{ + SecretsScanConfig: secretsScanConfig, + }, + }, nil +} + +func secretsScanConfigToString(secretsScanConfig clusterconfigpb.AccessGraphSecretsScanConfig) (string, error) { + switch secretsScanConfig { + case clusterconfigpb.AccessGraphSecretsScanConfig_ACCESS_GRAPH_SECRETS_SCAN_CONFIG_DISABLED: + return "disabled", nil + case clusterconfigpb.AccessGraphSecretsScanConfig_ACCESS_GRAPH_SECRETS_SCAN_CONFIG_ENABLED: + return "enabled", nil + default: + return "", trace.BadParameter("unexpected secrets scan config %q", secretsScanConfig) + } +} + +func stringToSecretsScanConfig(secretsScanConfig string) (clusterconfigpb.AccessGraphSecretsScanConfig, error) { + switch secretsScanConfig { + case "disabled", "off": + return clusterconfigpb.AccessGraphSecretsScanConfig_ACCESS_GRAPH_SECRETS_SCAN_CONFIG_DISABLED, nil + case "enabled", "on": + return clusterconfigpb.AccessGraphSecretsScanConfig_ACCESS_GRAPH_SECRETS_SCAN_CONFIG_ENABLED, nil + default: + return clusterconfigpb.AccessGraphSecretsScanConfig_ACCESS_GRAPH_SECRETS_SCAN_CONFIG_UNSPECIFIED, trace.BadParameter("secrets scan config must be one of [enabled, disabled]") + } +} diff --git a/tool/tctl/common/clusterconfig/accessgraphsettings_test.go b/tool/tctl/common/clusterconfig/accessgraphsettings_test.go new file mode 100644 index 0000000000000..01a1a7e3f4366 --- /dev/null +++ b/tool/tctl/common/clusterconfig/accessgraphsettings_test.go @@ -0,0 +1,269 @@ +/* + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package clusterconfig + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/testing/protocmp" + kyaml "k8s.io/apimachinery/pkg/util/yaml" + + clusterconfigpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/clusterconfig/v1" + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/services" +) + +func TestUnmarshalAccessGraphSettings(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + desc string + input string + errorContains string + expected *clusterconfigpb.AccessGraphSettings + }{ + { + desc: "disabled", + input: `--- +kind: access_graph_settings +version: v1 +metadata: + name: access-graph-settings +spec: + secrets_scan_config: "disabled" +`, + expected: &clusterconfigpb.AccessGraphSettings{ + Version: types.V1, + Kind: types.KindAccessGraphSettings, + Metadata: &headerv1.Metadata{ + Name: types.MetaNameAccessGraphSettings, + }, + Spec: &clusterconfigpb.AccessGraphSettingsSpec{ + SecretsScanConfig: clusterconfigpb.AccessGraphSecretsScanConfig_ACCESS_GRAPH_SECRETS_SCAN_CONFIG_DISABLED, + }, + }, + }, + { + desc: "off", + input: `--- +kind: access_graph_settings +version: v1 +metadata: + name: access-graph-settings +spec: + secrets_scan_config: "off" +`, + expected: &clusterconfigpb.AccessGraphSettings{ + Version: types.V1, + Kind: types.KindAccessGraphSettings, + Metadata: &headerv1.Metadata{ + Name: types.MetaNameAccessGraphSettings, + }, + Spec: &clusterconfigpb.AccessGraphSettingsSpec{ + SecretsScanConfig: clusterconfigpb.AccessGraphSecretsScanConfig_ACCESS_GRAPH_SECRETS_SCAN_CONFIG_DISABLED, + }, + }, + }, + { + desc: "enabled", + input: `--- +kind: access_graph_settings +version: v1 +metadata: + name: access-graph-settings +spec: + secrets_scan_config: "enabled" +`, + expected: &clusterconfigpb.AccessGraphSettings{ + Version: types.V1, + Kind: types.KindAccessGraphSettings, + Metadata: &headerv1.Metadata{ + Name: types.MetaNameAccessGraphSettings, + }, + Spec: &clusterconfigpb.AccessGraphSettingsSpec{ + SecretsScanConfig: clusterconfigpb.AccessGraphSecretsScanConfig_ACCESS_GRAPH_SECRETS_SCAN_CONFIG_ENABLED, + }, + }, + }, + { + desc: "on", + input: `--- +kind: access_graph_settings +version: v1 +metadata: + name: access-graph-settings +spec: + secrets_scan_config: "on" +`, + expected: &clusterconfigpb.AccessGraphSettings{ + Version: types.V1, + Kind: types.KindAccessGraphSettings, + Metadata: &headerv1.Metadata{ + Name: types.MetaNameAccessGraphSettings, + }, + Spec: &clusterconfigpb.AccessGraphSettingsSpec{ + SecretsScanConfig: clusterconfigpb.AccessGraphSecretsScanConfig_ACCESS_GRAPH_SECRETS_SCAN_CONFIG_ENABLED, + }, + }, + }, + { + desc: "invalid settings", + input: `--- +kind: access_graph_settings +version: v1 +metadata: + name: access-graph-settings +spec: + secrets_scan_config: "invalidasd" +`, + errorContains: "secrets_scan_config must be one of [enabled, disabled]", + }, + { + desc: "wrong name", + input: `--- +kind: access_graph_settings +version: v1 +metadata: + name: access +spec: + secrets_scan_config: "on" +`, + errorContains: "access graph settings must have a name \"access-graph-settings\"", + }, + { + desc: "wrong version", + input: `--- +kind: access_graph_settings +version: v2 +metadata: + name: access-graph-settings +spec: + secrets_scan_config: "on" +`, + errorContains: "unsupported resource version", + }, + } { + t.Run(tc.desc, func(t *testing.T) { + // Mimic tctl resource command by using the same decoder and + // initially unmarshalling into services.UnknownResource + reader := strings.NewReader(tc.input) + decoder := kyaml.NewYAMLOrJSONDecoder(reader, defaults.LookaheadBufSize) + var raw services.UnknownResource + err := decoder.Decode(&raw) + require.NoError(t, err) + require.Equal(t, types.KindAccessGraphSettings, raw.Kind) + + out, err := UnmarshalAccessGraphSettings(raw.Raw) + if tc.errorContains != "" { + require.ErrorContains(t, err, tc.errorContains, "error from UnmarshalAccessGraphSettings does not contain the expected string") + return + } + require.NoError(t, err, "UnmarshalAccessGraphSettings returned unexpected error") + + require.Empty(t, cmp.Diff(tc.expected, out, protocmp.Transform()), "unmarshalled data does not match what was expected") + }) + } +} + +func TestProtoToResource(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + desc string + expected *AccessGraphSettings + errorContains string + input *clusterconfigpb.AccessGraphSettings + }{ + { + desc: "disabled", + expected: &AccessGraphSettings{ + ResourceHeader: types.ResourceHeader{ + Kind: types.KindAccessGraphSettings, + Version: types.V1, + Metadata: types.Metadata{Name: types.MetaNameAccessGraphSettings}, + }, + Spec: accessGraphSettingsSpec{ + SecretsScanConfig: "disabled", + }, + }, + input: &clusterconfigpb.AccessGraphSettings{ + Version: types.V1, + Kind: types.KindAccessGraphSettings, + Metadata: &headerv1.Metadata{ + Name: types.MetaNameAccessGraphSettings, + }, + Spec: &clusterconfigpb.AccessGraphSettingsSpec{ + SecretsScanConfig: clusterconfigpb.AccessGraphSecretsScanConfig_ACCESS_GRAPH_SECRETS_SCAN_CONFIG_DISABLED, + }, + }, + }, + { + desc: "enabled", + expected: &AccessGraphSettings{ + ResourceHeader: types.ResourceHeader{ + Kind: types.KindAccessGraphSettings, + Version: types.V1, + Metadata: types.Metadata{Name: types.MetaNameAccessGraphSettings}, + }, + Spec: accessGraphSettingsSpec{ + SecretsScanConfig: "enabled", + }, + }, + input: &clusterconfigpb.AccessGraphSettings{ + Version: types.V1, + Kind: types.KindAccessGraphSettings, + Metadata: &headerv1.Metadata{ + Name: types.MetaNameAccessGraphSettings, + }, + Spec: &clusterconfigpb.AccessGraphSettingsSpec{ + SecretsScanConfig: clusterconfigpb.AccessGraphSecretsScanConfig_ACCESS_GRAPH_SECRETS_SCAN_CONFIG_ENABLED, + }, + }, + }, + { + desc: "incorrect data", + errorContains: "unexpected secrets scan config", + input: &clusterconfigpb.AccessGraphSettings{ + Version: types.V1, + Kind: types.KindAccessGraphSettings, + Metadata: &headerv1.Metadata{ + Name: types.MetaNameAccessGraphSettings, + }, + Spec: &clusterconfigpb.AccessGraphSettingsSpec{ + SecretsScanConfig: 5, + }, + }, + }, + } { + t.Run(tc.desc, func(t *testing.T) { + + out, err := ProtoToResource(tc.input) + if tc.errorContains != "" { + require.ErrorContains(t, err, tc.errorContains, "error from ProtoToResource does not contain the expected string") + return + } + require.NoError(t, err, "ProtoToResource returned unexpected error") + + require.Empty(t, cmp.Diff(tc.expected, out, protocmp.Transform())) + }) + } +} diff --git a/tool/tctl/common/collection.go b/tool/tctl/common/collection.go index 45f95f699dfa4..838e151b99b9d 100644 --- a/tool/tctl/common/collection.go +++ b/tool/tctl/common/collection.go @@ -49,6 +49,7 @@ import ( "github.com/gravitational/teleport/lib/sshutils" "github.com/gravitational/teleport/lib/utils" "github.com/gravitational/teleport/tool/common" + clusterconfigrec "github.com/gravitational/teleport/tool/tctl/common/clusterconfig" "github.com/gravitational/teleport/tool/tctl/common/databaseobject" "github.com/gravitational/teleport/tool/tctl/common/databaseobjectimportrule" "github.com/gravitational/teleport/tool/tctl/common/loginrule" @@ -1489,6 +1490,23 @@ func (c *vnetConfigCollection) writeText(w io.Writer, verbose bool) error { return trace.Wrap(err) } +type accessGraphSettings struct { + accessGraphSettings *clusterconfigrec.AccessGraphSettings +} + +func (c *accessGraphSettings) resources() []types.Resource { + return []types.Resource{c.accessGraphSettings} +} + +func (c *accessGraphSettings) writeText(w io.Writer, verbose bool) error { + t := asciitable.MakeTable([]string{"SSH Keys Scan"}) + t.AddRow([]string{ + c.accessGraphSettings.Spec.SecretsScanConfig, + }) + _, err := t.AsBuffer().WriteTo(w) + return trace.Wrap(err) +} + type accessRequestCollection struct { accessRequests []types.AccessRequest } diff --git a/tool/tctl/common/resource_command.go b/tool/tctl/common/resource_command.go index ab0a1fbedf038..4cc2e32a90a38 100644 --- a/tool/tctl/common/resource_command.go +++ b/tool/tctl/common/resource_command.go @@ -42,6 +42,7 @@ import ( apiclient "github.com/gravitational/teleport/api/client" "github.com/gravitational/teleport/api/client/proto" apidefaults "github.com/gravitational/teleport/api/defaults" + clusterconfigpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/clusterconfig/v1" crownjewelv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/crownjewel/v1" dbobjectv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/dbobject/v1" dbobjectimportrulev1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/dbobjectimportrule/v1" @@ -65,6 +66,7 @@ import ( "github.com/gravitational/teleport/lib/service/servicecfg" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/utils" + clusterconfigrec "github.com/gravitational/teleport/tool/tctl/common/clusterconfig" "github.com/gravitational/teleport/tool/tctl/common/databaseobject" "github.com/gravitational/teleport/tool/tctl/common/databaseobjectimportrule" "github.com/gravitational/teleport/tool/tctl/common/loginrule" @@ -161,6 +163,7 @@ func (rc *ResourceCommand) Initialize(app *kingpin.Application, config *servicec types.KindAccessMonitoringRule: rc.createAccessMonitoringRule, types.KindCrownJewel: rc.createCrownJewel, types.KindVnetConfig: rc.createVnetConfig, + types.KindAccessGraphSettings: rc.upsertAccessGraphSettings, types.KindPlugin: rc.createPlugin, } rc.UpdateHandlers = map[ResourceKind]ResourceCreateHandler{ @@ -175,6 +178,7 @@ func (rc *ResourceCommand) Initialize(app *kingpin.Application, config *servicec types.KindAccessMonitoringRule: rc.updateAccessMonitoringRule, types.KindCrownJewel: rc.updateCrownJewel, types.KindVnetConfig: rc.updateVnetConfig, + types.KindAccessGraphSettings: rc.updateAccessGraphSettings, types.KindPlugin: rc.updatePlugin, } rc.config = config @@ -2819,6 +2823,16 @@ func (rc *ResourceCommand) getCollection(ctx context.Context, client *authclient startKey = resp.NextKey } return &pluginCollection{plugins: plugins}, nil + case types.KindAccessGraphSettings: + settings, err := client.ClusterConfigClient().GetAccessGraphSettings(ctx, &clusterconfigpb.GetAccessGraphSettingsRequest{}) + if err != nil { + return nil, trace.Wrap(err) + } + rec, err := clusterconfigrec.ProtoToResource(settings) + if err != nil { + return nil, trace.Wrap(err) + } + return &accessGraphSettings{accessGraphSettings: rec}, nil } return nil, trace.BadParameter("getting %q is not supported", rc.ref.String()) } @@ -3137,3 +3151,30 @@ func (rc *ResourceCommand) createPlugin(ctx context.Context, client *authclient. fmt.Printf("plugin %q has been updated\n", item.GetName()) return nil } + +func (rc *ResourceCommand) upsertAccessGraphSettings(ctx context.Context, client *authclient.Client, raw services.UnknownResource) error { + settings, err := clusterconfigrec.UnmarshalAccessGraphSettings(raw.Raw) + if err != nil { + return trace.Wrap(err) + } + + if _, err = client.ClusterConfigClient().UpsertAccessGraphSettings(ctx, &clusterconfigpb.UpsertAccessGraphSettingsRequest{AccessGraphSettings: settings}); err != nil { + return trace.Wrap(err) + } + + fmt.Println("access_graph_settings has been upserted") + return nil +} + +func (rc *ResourceCommand) updateAccessGraphSettings(ctx context.Context, client *authclient.Client, raw services.UnknownResource) error { + settings, err := clusterconfigrec.UnmarshalAccessGraphSettings(raw.Raw) + if err != nil { + return trace.Wrap(err) + } + + if _, err = client.ClusterConfigClient().UpdateAccessGraphSettings(ctx, &clusterconfigpb.UpdateAccessGraphSettingsRequest{AccessGraphSettings: settings}); err != nil { + return trace.Wrap(err) + } + fmt.Println("access_graph_settings has been updated") + return nil +}