Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Service user persistence #1665

Merged
merged 2 commits into from
Jan 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions internal/featureflags/temporary.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
arthurpitman marked this conversation as resolved.
Show resolved Hide resolved
)

// temporaryDefaultValues defines temporary feature flags and their default values.
Expand All @@ -44,4 +47,5 @@ var temporaryDefaultValues = map[FeatureFlag]defaultValue{
OpenPipeline: true,
IgnoreSkippedConfigs: false,
Segments: false,
ServiceUsers: false,
}
30 changes: 23 additions & 7 deletions pkg/account/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -46,9 +49,11 @@ type (
Policy string
OriginObjectID string
}

PolicyLevelAccount struct {
Type string
}

PolicyLevelEnvironment struct {
Type string
Environment string
Expand All @@ -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
Expand All @@ -83,6 +91,14 @@ type (
Email secret.Email
Groups []Ref
}

ServiceUser struct {
Name string
Description string
Groups []Ref
OriginObjectID string
}

Reference struct {
Id string
}
Expand Down
42 changes: 31 additions & 11 deletions pkg/persistence/account/internal/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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."`
Expand All @@ -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."`
Expand All @@ -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."`
Expand Down Expand Up @@ -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"
)
19 changes: 15 additions & 4 deletions pkg/persistence/account/loader/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
96 changes: 67 additions & 29 deletions pkg/persistence/account/loader/load_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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["[email protected]"]
assert.True(t, exists, "expected user to exist: [email protected]")
assert.Contains(t, loaded.Users, "[email protected]", "expected user to exist: [email protected]")

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, "[email protected]", "expected user to exist: [email protected]")

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) {
Expand All @@ -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["[email protected]"]
assert.True(t, exists, "expected user to exist: [email protected]")
assert.Contains(t, loaded.Users, "[email protected]", "expected user to exist: [email protected]")

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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
})
Expand Down
Loading
Loading