diff --git a/internal/featureflags/temporary.go b/internal/featureflags/temporary.go index 59c9ce29f..1e0620af0 100644 --- a/internal/featureflags/temporary.go +++ b/internal/featureflags/temporary.go @@ -33,6 +33,9 @@ const ( // Segments toggles whether segment configurations are downloaded and / or deployed. // Introduced: v2.18.0 Segments FeatureFlag = "MONACO_FEAT_SEGMENTS" + // ServiceUsers toggles whether account service users configurations are downloaded and / or deployed. + // Introduced: v2.18.0 + ServiceUsers FeatureFlag = "MONACO_FEAT_SERVICE_USERS" ) // temporaryDefaultValues defines temporary feature flags and their default values. @@ -44,4 +47,5 @@ var temporaryDefaultValues = map[FeatureFlag]defaultValue{ OpenPipeline: true, IgnoreSkippedConfigs: false, Segments: false, + ServiceUsers: false, } diff --git a/pkg/account/resources.go b/pkg/account/resources.go index 364a7fab3..3cba1d473 100644 --- a/pkg/account/resources.go +++ b/pkg/account/resources.go @@ -28,16 +28,19 @@ func NewAccountManagementResources() *Resources { } type ( - PolicyId = string - GroupId = string - UserId = string - PolicyLevel = any // either PolicyLevelAccount or PolicyLevelEnvironment is allowed + PolicyId = string + GroupId = string + UserId = string + ServiceUserId = string + PolicyLevel = any // either PolicyLevelAccount or PolicyLevelEnvironment is allowed Resources struct { - Policies map[PolicyId]Policy - Groups map[GroupId]Group - Users map[UserId]User + Policies map[PolicyId]Policy + Groups map[GroupId]Group + Users map[UserId]User + ServiceUsers map[ServiceUserId]ServiceUser } + Policy struct { ID string Name string @@ -46,9 +49,11 @@ type ( Policy string OriginObjectID string } + PolicyLevelAccount struct { Type string } + PolicyLevelEnvironment struct { Type string Environment string @@ -64,15 +69,18 @@ type ( ManagementZone []ManagementZone OriginObjectID string } + Account struct { Permissions []string Policies []Ref } + Environment struct { Name string Permissions []string Policies []Ref } + ManagementZone struct { Environment string ManagementZone string @@ -83,6 +91,14 @@ type ( Email secret.Email Groups []Ref } + + ServiceUser struct { + Name string + Description string + Groups []Ref + OriginObjectID string + } + Reference struct { Id string } diff --git a/pkg/persistence/account/internal/types/types.go b/pkg/persistence/account/internal/types/types.go index 5febb3d34..76ea11d47 100644 --- a/pkg/persistence/account/internal/types/types.go +++ b/pkg/persistence/account/internal/types/types.go @@ -18,10 +18,12 @@ package types import ( "fmt" - jsonutils "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/json" - "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/secret" + "github.com/invopop/jsonschema" "github.com/mitchellh/mapstructure" + + jsonutils "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/json" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/secret" ) const ( @@ -32,15 +34,19 @@ const ( type ( Resources struct { - Policies map[string]Policy - Groups map[string]Group - Users map[string]User + Policies map[string]Policy + Groups map[string]Group + Users map[string]User + ServiceUsers map[string]ServiceUser } + File struct { - Policies []Policy `yaml:"policies,omitempty" json:"policies,omitempty" jsonschema:"description=Policies to configure for this account."` - Groups []Group `yaml:"groups,omitempty" json:"groups,omitempty" jsonschema:"description=Groups to configure for this account."` - Users []User `yaml:"users,omitempty" json:"users,omitempty" jsonschema:"description=Users to configure for this account."` + Policies []Policy `yaml:"policies,omitempty" json:"policies,omitempty" jsonschema:"description=Policies to configure for this account."` + Groups []Group `yaml:"groups,omitempty" json:"groups,omitempty" jsonschema:"description=Groups to configure for this account."` + Users []User `yaml:"users,omitempty" json:"users,omitempty" jsonschema:"description=Users to configure for this account."` + ServiceUsers []ServiceUser `yaml:"service-users,omitempty" json:"serviceUsers,omitempty" jsonschema:"description=Service users to configure for this account."` } + Policy struct { ID string `yaml:"id" json:"id" jsonschema:"required,description=A unique identifier of this policy configuration - this can be freely defined, used by monaco."` Name string `yaml:"name" json:"name" jsonschema:"required,description=The name of this policy."` @@ -49,10 +55,12 @@ type ( Policy string `yaml:"policy" json:"policy" jsonschema:"required,description=The policy definition."` OriginObjectID string `yaml:"originObjectId,omitempty" json:"originObjectId,omitempty" jsonschema:"description=The identifier of the policy this config originated from - this is filled when downloading, but can also be set to tie a config to a specific object."` } + PolicyLevel struct { Type string `yaml:"type" json:"type" jsonschema:"required,enum=account,enum=environment,description=This defines which level this policy applies to - either the whole 'account' or a specific 'environment'. For environment level, the 'environment' field needs to contain the environment ID."` Environment string `yaml:"environment,omitempty" json:"environment,omitempty" jsonschema:"The ID of the environment this policy applies to. Required if type is 'environment'."` } + Group struct { ID string `yaml:"id" json:"id" jsonschema:"required,description=A unique identifier of this group configuration - this can be freely defined, used by monaco."` Name string `yaml:"name" json:"name" jsonschema:"required,description=The name of this group."` @@ -66,25 +74,36 @@ type ( ManagementZone []ManagementZone `yaml:"managementZones,omitempty" json:"managementZones,omitempty" jsonschema:"description=ManagementZone level permissions that apply to users in this group."` OriginObjectID string `yaml:"originObjectId,omitempty" json:"originObjectId,omitempty" jsonschema:"description=The identifier of the group this config originated from - this is filled when downloading, but can also be set to tie a config to a specific object."` } + Account struct { Permissions []string `yaml:"permissions,omitempty" json:"permissions,omitempty" jsonschema:"description=Permissions for the whole account."` Policies ReferenceSlice `yaml:"policies,omitempty" json:"policies,omitempty" jsonschema:"description=Policies for the whole account."` } + Environment struct { Name string `yaml:"environment" json:"environment" jsonschema:"required,description=Name/identifier of the environment."` Permissions []string `yaml:"permissions,omitempty" json:"permissions,omitempty" jsonschema:"description=Permissions for this environment."` Policies ReferenceSlice `yaml:"policies,omitempty" json:"policies,omitempty" jsonschema:"description=Policies for this environment."` } + ManagementZone struct { Environment string `yaml:"environment" json:"environment" jsonschema:"required,description=Name/identifier of the environment the management zone is in."` ManagementZone string `yaml:"managementZone" json:"managementZone" jsonschema:"required,description=Identifier of the management zone."` Permissions []string `yaml:"permissions" json:"permissions" jsonschema:"required,description=Permissions for this management zone."` } + User struct { Email secret.Email `yaml:"email" json:"email" jsonschema:"required,description=Email address of this user."` Groups ReferenceSlice `yaml:"groups,omitempty" json:"groups,omitempty" jsonschema:"description=Groups this user is part of - either defined by name directly or as a reference to a group configuration."` } + ServiceUser struct { + Name string `yaml:"name" json:"name" jsonschema:"required,description=The name of this service user."` + Description string `yaml:"description,omitempty" json:"description,omitempty" jsonschema:"A description of this service user."` + Groups ReferenceSlice `yaml:"groups,omitempty" json:"groups,omitempty" jsonschema:"description=Groups this user is part of - either defined by name directly or as a reference to a group configuration."` + OriginObjectID string `yaml:"originObjectId,omitempty" json:"originObjectId,omitempty" jsonschema:"description=The identifier of the service user this config originated from - this is filled when downloading, but can also be set to tie a config to a specific object."` + } + Reference struct { Type string `yaml:"type" json:"type" mapstructure:"type" jsonschema:"enum=reference"` Id string `yaml:"id" json:"id" mapstructure:"id" jsonschema:"description=The 'id' of the account configuration being referenced."` @@ -149,7 +168,8 @@ func (_ ReferenceSlice) JSONSchema() *jsonschema.Schema { } const ( - KeyUsers string = "users" - KeyGroups string = "groups" - KeyPolicies string = "policies" + KeyUsers string = "users" + KeyServiceUsers string = "service-users" + KeyGroups string = "groups" + KeyPolicies string = "policies" ) diff --git a/pkg/persistence/account/loader/load.go b/pkg/persistence/account/loader/load.go index 209324dfc..167f83fec 100644 --- a/pkg/persistence/account/loader/load.go +++ b/pkg/persistence/account/loader/load.go @@ -22,6 +22,7 @@ import ( "github.com/spf13/afero" "gopkg.in/yaml.v2" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/featureflags" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/files" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/log" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/log/field" @@ -55,14 +56,15 @@ func HasAnyAccountKeyDefined(m map[string]any) bool { return false } - return m[persistence.KeyUsers] != nil || m[persistence.KeyGroups] != nil || m[persistence.KeyPolicies] != nil + return m[persistence.KeyUsers] != nil || m[persistence.KeyServiceUsers] != nil || m[persistence.KeyGroups] != nil || m[persistence.KeyPolicies] != nil } func findAndLoadResources(fs afero.Fs, rootPath string) (*persistence.Resources, error) { resources := persistence.Resources{ - Policies: make(map[string]persistence.Policy), - Groups: make(map[string]persistence.Group), - Users: make(map[string]persistence.User), + Policies: make(map[string]persistence.Policy), + Groups: make(map[string]persistence.Group), + Users: make(map[string]persistence.User), + ServiceUsers: make(map[string]persistence.ServiceUser), } yamlFilePaths, err := files.FindYamlFiles(fs, rootPath) @@ -153,5 +155,14 @@ func addResourcesFromFile(res persistence.Resources, file persistence.File) erro res.Users[u.Email.Value()] = u } + if featureflags.ServiceUsers.Enabled() { + for _, su := range file.ServiceUsers { + if _, exists := res.ServiceUsers[su.Name]; exists { + return fmt.Errorf("found duplicate service user with name %q", su.Name) + } + res.ServiceUsers[su.Name] = su + } + } + return nil } diff --git a/pkg/persistence/account/loader/load_test.go b/pkg/persistence/account/loader/load_test.go index 6011d5646..fab620013 100644 --- a/pkg/persistence/account/loader/load_test.go +++ b/pkg/persistence/account/loader/load_test.go @@ -18,34 +18,63 @@ package loader import ( - "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/account" + "testing" + "github.com/spf13/afero" "github.com/stretchr/testify/assert" - "golang.org/x/exp/maps" - "testing" + + "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/featureflags" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/account" ) func TestLoad(t *testing.T) { + + var assertGroupLoadedValidFunc = func(t *testing.T, g account.Group) { + assert.Len(t, g.Account.Policies, 1) + assert.Len(t, g.Account.Permissions, 1) + assert.Len(t, g.Environment, 1) + assert.Len(t, g.Environment[0].Policies, 2) + assert.Len(t, g.Environment[0].Permissions, 1) + assert.Len(t, g.ManagementZone, 1) + assert.Len(t, g.ManagementZone[0].Permissions, 1) + } + t.Run("Load single file", func(t *testing.T) { + t.Setenv(featureflags.ServiceUsers.EnvName(), "true") loaded, err := Load(afero.NewOsFs(), "testdata/valid.yaml") assert.NoError(t, err) + assert.Len(t, loaded.Users, 1) - _, exists := loaded.Users["monaco@dynatrace.com"] - assert.True(t, exists, "expected user to exist: monaco@dynatrace.com") + assert.Contains(t, loaded.Users, "monaco@dynatrace.com", "expected user to exist: monaco@dynatrace.com") + assert.Len(t, loaded.Groups, 1) - _, exists = loaded.Groups["my-group"] + g, exists := loaded.Groups["my-group"] assert.True(t, exists, "expected group to exist: my-group") + assertGroupLoadedValidFunc(t, g) + assert.Len(t, loaded.Policies, 1) - _, exists = loaded.Policies["my-policy"] - assert.True(t, exists, "expected policy to exist: my-policy") - assert.Len(t, maps.Values(loaded.Groups)[0].Account.Policies, 1) - assert.Len(t, maps.Values(loaded.Groups)[0].Account.Permissions, 1) - assert.Len(t, maps.Values(loaded.Groups)[0].Environment, 1) - assert.Len(t, maps.Values(loaded.Groups)[0].Environment[0].Policies, 2) - assert.Len(t, maps.Values(loaded.Groups)[0].Environment[0].Permissions, 1) - assert.Len(t, maps.Values(loaded.Groups)[0].ManagementZone, 1) - assert.Len(t, maps.Values(loaded.Groups)[0].ManagementZone[0].Permissions, 1) + assert.Contains(t, loaded.Policies, "my-policy", "expected policy to exist: my-policy") + + assert.Len(t, loaded.ServiceUsers, 1) + assert.Contains(t, loaded.ServiceUsers, "Service User 1", "expected service user to exist: Service User 1") + }) + + t.Run("Load single file - service user feature flag disabled", func(t *testing.T) { + t.Setenv(featureflags.ServiceUsers.EnvName(), "false") + loaded, err := Load(afero.NewOsFs(), "testdata/valid.yaml") + assert.NoError(t, err) + + assert.Len(t, loaded.Users, 1) + assert.Contains(t, loaded.Users, "monaco@dynatrace.com", "expected user to exist: monaco@dynatrace.com") + assert.Len(t, loaded.Groups, 1) + g, exists := loaded.Groups["my-group"] + assert.True(t, exists, "expected group to exist: my-group") + assertGroupLoadedValidFunc(t, g) + + assert.Len(t, loaded.Policies, 1) + assert.Contains(t, loaded.Policies, "my-policy", "expected policy to exist: my-policy") + assert.Len(t, loaded.ServiceUsers, 0) }) t.Run("Load single file - with refs", func(t *testing.T) { @@ -66,35 +95,31 @@ func TestLoad(t *testing.T) { }) t.Run("Load multiple files", func(t *testing.T) { + t.Setenv(featureflags.ServiceUsers.EnvName(), "true") loaded, err := Load(afero.NewOsFs(), "testdata/multi") assert.NoError(t, err) assert.Len(t, loaded.Users, 1) assert.Len(t, loaded.Groups, 1) assert.Len(t, loaded.Policies, 1) + assert.Len(t, loaded.ServiceUsers, 1) }) t.Run("Loads origin objectIDs", func(t *testing.T) { + t.Setenv(featureflags.ServiceUsers.EnvName(), "true") loaded, err := Load(afero.NewOsFs(), "testdata/valid-origin-object-id.yaml") assert.NoError(t, err) - assert.Len(t, loaded.Users, 1) - _, exists := loaded.Users["monaco@dynatrace.com"] - assert.True(t, exists, "expected user to exist: monaco@dynatrace.com") + assert.Contains(t, loaded.Users, "monaco@dynatrace.com", "expected user to exist: monaco@dynatrace.com") + assert.Len(t, loaded.Groups, 1) g, exists := loaded.Groups["my-group"] assert.True(t, exists, "expected group to exist: my-group") + assertGroupLoadedValidFunc(t, g) assert.Equal(t, "32952350-5e78-476d-ab1a-786dd9d4fe33", g.OriginObjectID, "expected group to be loaded with originObjectID") + assert.Len(t, loaded.Policies, 1) p, exists := loaded.Policies["my-policy"] assert.Equal(t, "2338ebda-4aad-4911-96a2-6f60d7c3d2cb", p.OriginObjectID, "expected policy to be loaded with originObjectID") assert.True(t, exists, "expected policy to exist: my-policy") - assert.Len(t, maps.Values(loaded.Groups)[0].Account.Policies, 1) - assert.Len(t, maps.Values(loaded.Groups)[0].Account.Permissions, 1) - assert.Len(t, maps.Values(loaded.Groups)[0].Environment, 1) - assert.Len(t, maps.Values(loaded.Groups)[0].Environment[0].Policies, 2) - assert.Len(t, maps.Values(loaded.Groups)[0].Environment[0].Permissions, 1) - assert.Len(t, maps.Values(loaded.Groups)[0].ManagementZone, 1) - assert.Len(t, maps.Values(loaded.Groups)[0].ManagementZone[0].Permissions, 1) - }) t.Run("Load multiple files but ignore config files", func(t *testing.T) { @@ -136,6 +161,12 @@ func TestLoad(t *testing.T) { assert.Error(t, err) }) + t.Run("Duplicate service user produces error", func(t *testing.T) { + t.Setenv(featureflags.ServiceUsers.EnvName(), "true") + _, err := Load(afero.NewOsFs(), "testdata/duplicate-service-user.yaml") + assert.Error(t, err) + }) + t.Run("Missing environment ID for env-level policy produces error", func(t *testing.T) { _, err := Load(afero.NewOsFs(), "testdata/policy-missing-env-id.yaml") assert.Error(t, err) @@ -156,12 +187,19 @@ func TestLoad(t *testing.T) { assert.Error(t, err) }) + t.Run("Partial service user definition produces error", func(t *testing.T) { + t.Setenv(featureflags.ServiceUsers.EnvName(), "true") + _, err := Load(afero.NewOsFs(), "testdata/partial-service-user.yaml") + assert.Error(t, err) + }) + t.Run("root folder not found", func(t *testing.T) { result, err := Load(afero.NewOsFs(), "testdata/non-existent-folder") assert.Equal(t, &account.Resources{ - Policies: make(map[string]account.Policy, 0), - Groups: make(map[string]account.Group, 0), - Users: make(map[string]account.User, 0), + Policies: make(map[string]account.Policy, 0), + Groups: make(map[string]account.Group, 0), + Users: make(map[string]account.User, 0), + ServiceUsers: make(map[string]account.ServiceUser, 0), }, result) assert.NoError(t, err) }) diff --git a/pkg/persistence/account/loader/testdata/configs-accounts-mixed.yaml b/pkg/persistence/account/loader/testdata/configs-accounts-mixed.yaml index 901c06c8e..059ae692c 100644 --- a/pkg/persistence/account/loader/testdata/configs-accounts-mixed.yaml +++ b/pkg/persistence/account/loader/testdata/configs-accounts-mixed.yaml @@ -40,6 +40,15 @@ policies: policy: |- ALLOW a:b:c; +service-users: + - name: Service User 1 + description: Description + groups: + - type: reference + id: my-group + - Log viewer + + configs: - id: something omit: other-values diff --git a/pkg/persistence/account/loader/testdata/duplicate-group.yaml b/pkg/persistence/account/loader/testdata/duplicate-group.yaml index 1e23d41bb..189d18f72 100644 --- a/pkg/persistence/account/loader/testdata/duplicate-group.yaml +++ b/pkg/persistence/account/loader/testdata/duplicate-group.yaml @@ -63,3 +63,11 @@ policies: policy: |- ALLOW a:b:c; +service-users: + - name: Service User 1 + description: Description + groups: + - type: reference + id: my-group + - Log viewer + diff --git a/pkg/persistence/account/loader/testdata/duplicate-service-user.yaml b/pkg/persistence/account/loader/testdata/duplicate-service-user.yaml new file mode 100644 index 000000000..b3dd92912 --- /dev/null +++ b/pkg/persistence/account/loader/testdata/duplicate-service-user.yaml @@ -0,0 +1,13 @@ +service-users: + - name: Service User 1 + description: Description + groups: + - type: reference + id: my-group + - Log viewer + - name: Service User 1 + description: Description + groups: + - type: reference + id: my-group + - Log viewer diff --git a/pkg/persistence/account/loader/testdata/multi-with-configs/a/b/c/d/d.yaml b/pkg/persistence/account/loader/testdata/multi-with-configs/a/b/c/d/d.yaml new file mode 100644 index 000000000..0439d5544 --- /dev/null +++ b/pkg/persistence/account/loader/testdata/multi-with-configs/a/b/c/d/d.yaml @@ -0,0 +1,7 @@ +service-users: + - name: Service User 1 + description: Description + groups: + - type: reference + id: my-group + - Log viewer diff --git a/pkg/persistence/account/loader/testdata/multi-with-delete-file/a/b/c/d/d.yaml b/pkg/persistence/account/loader/testdata/multi-with-delete-file/a/b/c/d/d.yaml new file mode 100644 index 000000000..0439d5544 --- /dev/null +++ b/pkg/persistence/account/loader/testdata/multi-with-delete-file/a/b/c/d/d.yaml @@ -0,0 +1,7 @@ +service-users: + - name: Service User 1 + description: Description + groups: + - type: reference + id: my-group + - Log viewer diff --git a/pkg/persistence/account/loader/testdata/multi/a/b/c/d/d.yaml b/pkg/persistence/account/loader/testdata/multi/a/b/c/d/d.yaml new file mode 100644 index 000000000..0439d5544 --- /dev/null +++ b/pkg/persistence/account/loader/testdata/multi/a/b/c/d/d.yaml @@ -0,0 +1,7 @@ +service-users: + - name: Service User 1 + description: Description + groups: + - type: reference + id: my-group + - Log viewer diff --git a/pkg/persistence/account/loader/testdata/partial-service-user.yaml b/pkg/persistence/account/loader/testdata/partial-service-user.yaml new file mode 100644 index 000000000..90e223ab0 --- /dev/null +++ b/pkg/persistence/account/loader/testdata/partial-service-user.yaml @@ -0,0 +1,7 @@ +service-users: + - description: Description + groups: + - type: reference + id: my-group + - Log viewer + # missing 'name' diff --git a/pkg/persistence/account/loader/testdata/valid-origin-object-id.yaml b/pkg/persistence/account/loader/testdata/valid-origin-object-id.yaml index d671fce56..36647e87a 100644 --- a/pkg/persistence/account/loader/testdata/valid-origin-object-id.yaml +++ b/pkg/persistence/account/loader/testdata/valid-origin-object-id.yaml @@ -5,7 +5,6 @@ users: id: my-group - Log viewer - groups: - name: My Group id: my-group @@ -42,3 +41,11 @@ policies: policy: |- ALLOW a:b:c; +service-users: + - name: Service User 1 + originObjectId: 12345678-1234-1234-1234-123456781234 + description: Description + groups: + - type: reference + id: my-group + - Log viewer diff --git a/pkg/persistence/account/loader/testdata/valid.yaml b/pkg/persistence/account/loader/testdata/valid.yaml index 4a47a0b50..8bf76ea17 100644 --- a/pkg/persistence/account/loader/testdata/valid.yaml +++ b/pkg/persistence/account/loader/testdata/valid.yaml @@ -40,3 +40,10 @@ policies: policy: |- ALLOW a:b:c; +service-users: + - name: Service User 1 + description: Description + groups: + - type: reference + id: my-group + - Log viewer diff --git a/pkg/persistence/account/loader/transform.go b/pkg/persistence/account/loader/transform.go index 9f984b1fd..2ed4acd68 100644 --- a/pkg/persistence/account/loader/transform.go +++ b/pkg/persistence/account/loader/transform.go @@ -23,9 +23,10 @@ import ( func transformToAccountResources(resources *persistence.Resources) *account.Resources { return &account.Resources{ - Policies: transformPolicies(resources.Policies), - Groups: transformGroups(resources.Groups), - Users: transformUsers(resources.Users), + Policies: transformPolicies(resources.Policies), + Groups: transformGroups(resources.Groups), + Users: transformUsers(resources.Users), + ServiceUsers: transformServiceUsers(resources.ServiceUsers), } } @@ -118,6 +119,18 @@ func transformUsers(pUsers map[string]persistence.User) map[account.UserId]accou return users } +func transformServiceUsers(pUsers map[string]persistence.ServiceUser) map[account.ServiceUserId]account.ServiceUser { + serviceUsers := make(map[account.ServiceUserId]account.ServiceUser, len(pUsers)) + for id, su := range pUsers { + serviceUsers[id] = account.ServiceUser{ + Name: su.Name, + Description: su.Description, + Groups: transformReferences(su.Groups), + } + } + return serviceUsers +} + func transformReferences(pReferences []persistence.Reference) []account.Ref { res := make([]account.Ref, len(pReferences)) for i, el := range pReferences { diff --git a/pkg/persistence/account/loader/validate.go b/pkg/persistence/account/loader/validate.go index 65e023f32..f760f208d 100644 --- a/pkg/persistence/account/loader/validate.go +++ b/pkg/persistence/account/loader/validate.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/featureflags" "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/persistence/account/internal/types" persistence "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/persistence/account/internal/types" ) @@ -103,6 +104,14 @@ func validateFile(file persistence.File) error { } } + if featureflags.ServiceUsers.Enabled() { + for _, su := range file.ServiceUsers { + if err := validateServiceUser(su); err != nil { + return err + } + } + } + return nil } @@ -113,6 +122,13 @@ func validateUser(u types.User) error { return nil } +func validateServiceUser(su types.ServiceUser) error { + if su.Name == "" { + return errors.New("missing required field 'name' for service user") + } + return nil +} + func validateGroup(g types.Group) error { if g.ID == "" { return errors.New("missing required field 'id' for policy") diff --git a/pkg/persistence/account/writer/writer.go b/pkg/persistence/account/writer/writer.go index 9b32c4baf..26eac45fc 100644 --- a/pkg/persistence/account/writer/writer.go +++ b/pkg/persistence/account/writer/writer.go @@ -18,15 +18,18 @@ package writer import ( "fmt" + "path/filepath" + "strings" + + "github.com/spf13/afero" + "golang.org/x/exp/slices" + "gopkg.in/yaml.v2" + + "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/featureflags" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/log" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/log/field" "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/account" persistence "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/persistence/account/internal/types" - "github.com/spf13/afero" - "golang.org/x/exp/slices" - "gopkg.in/yaml.v2" - "path/filepath" - "strings" ) // Context for this account resource writer, defining the filesystem and paths to create resources at @@ -41,7 +44,7 @@ type Context struct { // Write the given account.Resources to the target filesystem and paths defined by the Context. // This will create a folder "filepath.Abs()//", and create -// individual "policies.yaml", "users.yaml" & "groups.yaml" files containing YAML representations of the given account.Resources. +// individual "policies.yaml", "users.yaml", "service-users.yaml" & "groups.yaml" files containing YAML representations of the given account.Resources. // // Returns an error if any step of transforming or persisting resources fails, but will attempt to write as many files as // possible. If policies fail to be written to a file, an error is logged, but groups and users are attempted to be written @@ -81,6 +84,14 @@ func Write(writerContext Context, resources account.Resources) error { } } + if featureflags.ServiceUsers.Enabled() && len(resources.ServiceUsers) > 0 { + serviceUsers := toPersistenceServiceUsers(resources.ServiceUsers) + if err := persistToFile(persistence.File{ServiceUsers: serviceUsers}, writerContext.Fs, filepath.Join(projectFolder, "service-users.yaml")); err != nil { + errOccurred = true + log.Error("Failed to persist service users: %w", err) + } + } + if errOccurred { return fmt.Errorf("failed to persist some account resources to folder %q", projectFolder) } @@ -214,6 +225,23 @@ func toPersistenceUsers(users map[string]account.User) []persistence.User { return out } +func toPersistenceServiceUsers(serviceUsers map[string]account.ServiceUser) []persistence.ServiceUser { + out := make([]persistence.ServiceUser, 0, len(serviceUsers)) + for _, v := range serviceUsers { + out = append(out, persistence.ServiceUser{ + Name: v.Name, + Description: v.Description, + Groups: transformRefs(v.Groups), + OriginObjectID: v.OriginObjectID, + }) + } + // sort service users by name so that they are stable within a persisted file + slices.SortFunc(out, func(a, b persistence.ServiceUser) int { + return caseInsensitiveLexicographicSmaller(a.Name, b.Name) + }) + return out +} + func createFolderIfNoneExists(fs afero.Fs, path string) error { exists, err := afero.Exists(fs, path) if err != nil { diff --git a/pkg/persistence/account/writer/writer_test.go b/pkg/persistence/account/writer/writer_test.go index 09c8bf7e0..cfa43e0ab 100644 --- a/pkg/persistence/account/writer/writer_test.go +++ b/pkg/persistence/account/writer/writer_test.go @@ -18,19 +18,24 @@ package writer_test import ( - "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/account" - "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/persistence/account/writer" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" "path/filepath" "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + + "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/featureflags" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/account" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/persistence/account/writer" ) func TestWriteAccountResources(t *testing.T) { + t.Setenv(featureflags.ServiceUsers.EnvName(), "true") type want struct { - groups string - users string - policies string + groups string + users string + policies string + serviceUsers string } tests := []struct { name string @@ -240,6 +245,47 @@ func TestWriteAccountResources(t *testing.T) { type: account description: This is my policy. There's many like it, but this one is mine. policy: ALLOW a:b:c; +`, + }, + }, + { + "only service users", + account.Resources{ + ServiceUsers: map[account.ServiceUserId]account.ServiceUser{ + "Service User 1": { + Name: "Service User 1", + Description: "Description of service user", + Groups: []account.Ref{ + account.Reference{Id: "my-group"}, + account.StrReference("Log viewer"), + }, + }, + }, + }, + want{serviceUsers: `service-users: +- name: Service User 1 + description: Description of service user + groups: + - Log viewer + - type: reference + id: my-group +`, + }, + }, + { + "groups are not written if service user has none", + account.Resources{ + ServiceUsers: map[account.ServiceUserId]account.ServiceUser{ + "Service User 1": { + Name: "Service User 1", + Description: "Description of service user", + }, + }, + }, + want{ + serviceUsers: `service-users: +- name: Service User 1 + description: Description of service user `, }, }, @@ -292,6 +338,16 @@ func TestWriteAccountResources(t *testing.T) { Policy: "ALLOW a:b:c;", }, }, + ServiceUsers: map[account.ServiceUserId]account.ServiceUser{ + "Service User 1": { + Name: "Service User 1", + Description: "Description of service user", + Groups: []account.Ref{ + account.Reference{Id: "my-group"}, + account.StrReference("Log viewer"), + }, + }, + }, }, want{ users: `users: @@ -331,6 +387,14 @@ func TestWriteAccountResources(t *testing.T) { type: account description: This is my policy. There's many like it, but this one is mine. policy: ALLOW a:b:c; +`, + serviceUsers: `service-users: +- name: Service User 1 + description: Description of service user + groups: + - Log viewer + - type: reference + id: my-group `, }, }, @@ -385,6 +449,17 @@ func TestWriteAccountResources(t *testing.T) { Policy: "ALLOW a:b:c;", }, }, + ServiceUsers: map[account.ServiceUserId]account.ServiceUser{ + "Service User 1": { + Name: "Service User 1", + OriginObjectID: "ObjectID-789", + Description: "Description of service user", + Groups: []account.Ref{ + account.Reference{Id: "my-group"}, + account.StrReference("Log viewer"), + }, + }, + }, }, want{ users: `users: @@ -426,6 +501,15 @@ func TestWriteAccountResources(t *testing.T) { description: This is my policy. There's many like it, but this one is mine. policy: ALLOW a:b:c; originObjectId: ObjectID-456 +`, + serviceUsers: `service-users: +- name: Service User 1 + description: Description of service user + groups: + - Log viewer + - type: reference + id: my-group + originObjectId: ObjectID-789 `, }, }, @@ -521,6 +605,24 @@ func TestWriteAccountResources(t *testing.T) { Policy: "ALLOW a:b:c;", }, }, + ServiceUsers: map[account.ServiceUserId]account.ServiceUser{ + "Second service user": { + Name: "Second service user", + Description: "Description of service user", + Groups: []account.Ref{ + account.Reference{Id: "my-group"}, + account.StrReference("Log viewer"), + }, + }, + "First service user": { + Name: "First service user", + Description: "Description of service user", + Groups: []account.Ref{ + account.Reference{Id: "my-group"}, + account.StrReference("Log viewer"), + }, + }, + }, }, want{ users: `users: @@ -596,6 +698,20 @@ func TestWriteAccountResources(t *testing.T) { type: account description: This is my policy. There's many like it, but this one is mine. policy: ALLOW a:b:c; +`, + serviceUsers: `service-users: +- name: First service user + description: Description of service user + groups: + - Log viewer + - type: reference + id: my-group +- name: Second service user + description: Description of service user + groups: + - Log viewer + - type: reference + id: my-group `, }, }, @@ -612,37 +728,151 @@ func TestWriteAccountResources(t *testing.T) { expectedFolder := c.OutputFolder - users := filepath.Join(expectedFolder, c.ProjectFolder, "users.yaml") + usersFilename := filepath.Join(expectedFolder, c.ProjectFolder, "users.yaml") if tt.wantPersisted.users == "" { - exists, err := afero.Exists(c.Fs, users) - assert.NoError(t, err) - assert.False(t, exists, "expected no users file to be created") + assertNoFile(t, c.Fs, usersFilename) } else { - assertFile(t, c.Fs, users, tt.wantPersisted.users) + assertFile(t, c.Fs, usersFilename, tt.wantPersisted.users) } - groups := filepath.Join(expectedFolder, c.ProjectFolder, "groups.yaml") + groupsFilename := filepath.Join(expectedFolder, c.ProjectFolder, "groups.yaml") if tt.wantPersisted.groups == "" { - exists, err := afero.Exists(c.Fs, groups) - assert.NoError(t, err) - assert.False(t, exists, "expected no groups file to be created") + assertNoFile(t, c.Fs, groupsFilename) } else { - assertFile(t, c.Fs, groups, tt.wantPersisted.groups) + assertFile(t, c.Fs, groupsFilename, tt.wantPersisted.groups) } - policies := filepath.Join(expectedFolder, c.ProjectFolder, "policies.yaml") + policiesFilename := filepath.Join(expectedFolder, c.ProjectFolder, "policies.yaml") if tt.wantPersisted.policies == "" { - exists, err := afero.Exists(c.Fs, policies) - assert.NoError(t, err) - assert.False(t, exists, "expected no policies file to be created") + assertNoFile(t, c.Fs, policiesFilename) } else { - assertFile(t, c.Fs, policies, tt.wantPersisted.policies) + assertFile(t, c.Fs, policiesFilename, tt.wantPersisted.policies) + } + + serviceUsersFilename := filepath.Join(expectedFolder, c.ProjectFolder, "service-users.yaml") + if tt.wantPersisted.serviceUsers == "" { + assertNoFile(t, c.Fs, serviceUsersFilename) + } else { + assertFile(t, c.Fs, serviceUsersFilename, tt.wantPersisted.serviceUsers) } }) } } +func TestServiceUsersNotPersistedIfFeatureFlagDisabled(t *testing.T) { + t.Setenv(featureflags.ServiceUsers.EnvName(), "false") + resources := + account.Resources{ + Users: map[account.UserId]account.User{ + "monaco@dynatrace.com": account.User{ + Email: "monaco@dynatrace.com", + Groups: []account.Ref{ + account.Reference{Id: "my-group"}, + account.StrReference("Log viewer"), + }, + }, + }, + Groups: map[account.GroupId]account.Group{ + "my-group": { + ID: "my-group", + Name: "My Group", + Description: "This is my group", + Account: &account.Account{ + Permissions: []string{"View my Group Stuff"}, + Policies: []account.Ref{account.StrReference("Request my Group Stuff")}, + }, + Environment: []account.Environment{ + { + Name: "myenv123", + Permissions: []string{"View environment"}, + Policies: []account.Ref{ + account.StrReference("View environment"), + account.Reference{Id: "my-policy"}, + }, + }, + }, + ManagementZone: []account.ManagementZone{ + { + Environment: "myenv123", + ManagementZone: "My MZone", + Permissions: []string{"Do Stuff"}, + }, + }, + }, + }, + Policies: map[account.PolicyId]account.Policy{ + "my-policy": { + ID: "my-policy", + Name: "My Policy", + Level: account.PolicyLevelAccount{Type: "account"}, + Description: "This is my policy. There's many like it, but this one is mine.", + Policy: "ALLOW a:b:c;", + }, + }, + ServiceUsers: map[account.ServiceUserId]account.ServiceUser{ + "Service User 1": { + Name: "Service User 1", + Description: "Description of service user", + Groups: []account.Ref{ + account.Reference{Id: "my-group"}, + account.StrReference("Log viewer"), + }, + }, + }, + } + expectedUsers := `users: +- email: monaco@dynatrace.com + groups: + - Log viewer + - type: reference + id: my-group +` + expectedGroups := `groups: +- id: my-group + name: My Group + description: This is my group + account: + permissions: + - View my Group Stuff + policies: + - Request my Group Stuff + environments: + - environment: myenv123 + permissions: + - View environment + policies: + - type: reference + id: my-policy + - View environment + managementZones: + - environment: myenv123 + managementZone: My MZone + permissions: + - Do Stuff +` + expectedPolicies := `policies: +- id: my-policy + name: My Policy + level: + type: account + description: This is my policy. There's many like it, but this one is mine. + policy: ALLOW a:b:c; +` + + c := writer.Context{ + Fs: afero.NewMemMapFs(), + OutputFolder: "test", + ProjectFolder: "project", + } + err := writer.Write(c, resources) + assert.NoError(t, err) + assertFile(t, c.Fs, filepath.Join(c.OutputFolder, c.ProjectFolder, "users.yaml"), expectedUsers) + assertFile(t, c.Fs, filepath.Join(c.OutputFolder, c.ProjectFolder, "groups.yaml"), expectedGroups) + assertFile(t, c.Fs, filepath.Join(c.OutputFolder, c.ProjectFolder, "policies.yaml"), expectedPolicies) + assertNoFile(t, c.Fs, filepath.Join(c.OutputFolder, c.ProjectFolder, "service-users.yaml")) +} + func assertFile(t *testing.T, fs afero.Fs, expectedPath, expectedContent string) { exists, err := afero.Exists(fs, expectedPath) assert.True(t, exists) @@ -651,3 +881,9 @@ func assertFile(t *testing.T, fs afero.Fs, expectedPath, expectedContent string) assert.NoError(t, err) assert.Equal(t, expectedContent, string(got)) } + +func assertNoFile(t *testing.T, fs afero.Fs, expectedPath string) { + exists, err := afero.Exists(fs, expectedPath) + assert.NoError(t, err) + assert.False(t, exists, "expected file not to exist") +}