diff --git a/api/constants/constants.go b/api/constants/constants.go index 43675d1d6cff8..721d96b7a72b3 100644 --- a/api/constants/constants.go +++ b/api/constants/constants.go @@ -338,6 +338,10 @@ const ( // TraitAzureIdentities is the name of the role variable used to store // allowed Azure identity names. TraitAzureIdentities = "azure_identities" + + // TraitGCPServiceAccounts is the name of the role variable used to store + // allowed GCP service accounts. + TraitGCPServiceAccounts = "gcp_service_accounts" ) // Constants for AWS discovery diff --git a/api/types/role.go b/api/types/role.go index 51a1ebe2a0d0b..c014b6ef0ba45 100644 --- a/api/types/role.go +++ b/api/types/role.go @@ -149,6 +149,11 @@ type Role interface { // SetAzureIdentities sets a list of Azure identities this role is allowed to assume. SetAzureIdentities(RoleConditionType, []string) + // GetGCPServiceAccounts returns a list of GCP service accounts this role is allowed to assume. + GetGCPServiceAccounts(RoleConditionType) []string + // SetGCPServiceAccounts sets a list of GCP service accounts this role is allowed to assume. + SetGCPServiceAccounts(RoleConditionType, []string) + // GetWindowsDesktopLabels gets the Windows desktop labels this role // is allowed or denied access to. GetWindowsDesktopLabels(RoleConditionType) Labels @@ -629,6 +634,23 @@ func (r *RoleV6) SetAzureIdentities(rct RoleConditionType, identities []string) } } +// GetGCPServiceAccounts returns a list of GCP service accounts this role is allowed to assume. +func (r *RoleV6) GetGCPServiceAccounts(rct RoleConditionType) []string { + if rct == Allow { + return r.Spec.Allow.GCPServiceAccounts + } + return r.Spec.Deny.GCPServiceAccounts +} + +// SetGCPServiceAccounts sets a list of GCP service accounts this role is allowed to assume. +func (r *RoleV6) SetGCPServiceAccounts(rct RoleConditionType, accounts []string) { + if rct == Allow { + r.Spec.Allow.GCPServiceAccounts = accounts + } else { + r.Spec.Deny.GCPServiceAccounts = accounts + } +} + // GetWindowsDesktopLabels gets the desktop labels this role is allowed or denied access to. func (r *RoleV6) GetWindowsDesktopLabels(rct RoleConditionType) Labels { if rct == Allow { @@ -890,6 +912,11 @@ func (r *RoleV6) CheckAndSetDefaults() error { return trace.BadParameter("wildcard matcher is not allowed in allow.azure_identities") } } + for _, identity := range r.Spec.Allow.GCPServiceAccounts { + if identity == Wildcard { + return trace.BadParameter("wildcard matcher is not allowed in allow.gcp_service_accounts") + } + } checkWildcardSelector := func(labels Labels) error { for key, val := range labels { if key == Wildcard && !(len(val) == 1 && val[0] == Wildcard) { diff --git a/api/types/user.go b/api/types/user.go index d4e6e12555e2e..c206682ce4930 100644 --- a/api/types/user.go +++ b/api/types/user.go @@ -60,6 +60,8 @@ type User interface { GetAWSRoleARNs() []string // GetAzureIdentities gets a list of Azure identities for the user GetAzureIdentities() []string + // GetGCPServiceAccounts gets a list of GCP service accounts for the user + GetGCPServiceAccounts() []string // String returns user String() string // GetStatus return user login status @@ -89,14 +91,16 @@ type User interface { // SetAWSRoleARNs sets a list of AWS role ARNs for user SetAWSRoleARNs(awsRoleARNs []string) // SetAzureIdentities sets a list of Azure identities for the user - SetAzureIdentities(AzureIdentities []string) + SetAzureIdentities(azureIdentities []string) + // SetGCPServiceAccounts sets a list of GCP service accounts for the user + SetGCPServiceAccounts(accounts []string) // GetCreatedBy returns information about user GetCreatedBy() CreatedBy // SetCreatedBy sets created by information SetCreatedBy(CreatedBy) // GetTraits gets the trait map for this user used to populate role variables. GetTraits() map[string][]string - // GetTraits sets the trait map for this user used to populate role variables. + // SetTraits sets the trait map for this user used to populate role variables. SetTraits(map[string][]string) } @@ -283,8 +287,13 @@ func (u *UserV2) SetAWSRoleARNs(awsRoleARNs []string) { } // SetAzureIdentities sets a list of Azure identities for the user -func (u *UserV2) SetAzureIdentities(AzureIdentities []string) { - u.setTrait(constants.TraitAzureIdentities, AzureIdentities) +func (u *UserV2) SetAzureIdentities(identities []string) { + u.setTrait(constants.TraitAzureIdentities, identities) +} + +// SetGCPServiceAccounts sets a list of GCP service accounts for the user +func (u *UserV2) SetGCPServiceAccounts(accounts []string) { + u.setTrait(constants.TraitGCPServiceAccounts, accounts) } // GetStatus returns login status of the user @@ -379,6 +388,11 @@ func (u UserV2) GetAzureIdentities() []string { return u.getTrait(constants.TraitAzureIdentities) } +// GetGCPServiceAccounts gets a list of GCP service accounts for the user +func (u UserV2) GetGCPServiceAccounts() []string { + return u.getTrait(constants.TraitGCPServiceAccounts) +} + func (u *UserV2) String() string { return fmt.Sprintf("User(name=%v, roles=%v, identities=%v)", u.Metadata.Name, u.Spec.Roles, u.Spec.OIDCIdentities) } diff --git a/constants.go b/constants.go index c745660ad3d06..e423f1816ca26 100644 --- a/constants.go +++ b/constants.go @@ -575,6 +575,10 @@ const ( // Azure identities for local accounts. TraitInternalAzureIdentities = "{{internal.azure_identities}}" + // TraitInternalGCPServiceAccounts is the variable used to store allowed + // GCP service accounts for local accounts. + TraitInternalGCPServiceAccounts = "{{internal.gcp_service_accounts}}" + // TraitInternalJWTVariable is the variable used to store JWT token for // app sessions. TraitInternalJWTVariable = "{{internal.jwt}}" diff --git a/lib/services/access_checker.go b/lib/services/access_checker.go index 1c8e73beadc9b..e320a1269ac9c 100644 --- a/lib/services/access_checker.go +++ b/lib/services/access_checker.go @@ -65,6 +65,9 @@ type AccessChecker interface { // CheckAzureIdentities returns a list of Azure identities the user is allowed to assume. CheckAzureIdentities(ttl time.Duration, overrideTTL bool) ([]string, error) + // CheckGCPServiceAccounts returns a list of GCP service accounts the user is allowed to assume. + CheckGCPServiceAccounts(ttl time.Duration, overrideTTL bool) ([]string, error) + // AdjustSessionTTL will reduce the requested ttl to lowest max allowed TTL // for this role set, otherwise it returns ttl unchanged AdjustSessionTTL(ttl time.Duration) time.Duration diff --git a/lib/services/presets.go b/lib/services/presets.go index f9d2740a032af..df8e3687bcb32 100644 --- a/lib/services/presets.go +++ b/lib/services/presets.go @@ -132,6 +132,7 @@ func NewPresetAccessRole() types.Role { role.SetKubeGroups(types.Allow, []string{teleport.TraitInternalKubeGroupsVariable}) role.SetAWSRoleARNs(types.Allow, []string{teleport.TraitInternalAWSRoleARNs}) role.SetAzureIdentities(types.Allow, []string{teleport.TraitInternalAzureIdentities}) + role.SetGCPServiceAccounts(types.Allow, []string{teleport.TraitInternalGCPServiceAccounts}) return role } diff --git a/lib/services/role.go b/lib/services/role.go index acc6b17788488..c27c04478e38a 100644 --- a/lib/services/role.go +++ b/lib/services/role.go @@ -334,6 +334,10 @@ func ApplyTraits(r types.Role, traits map[string][]string) types.Role { warnInvalidAzureIdentities(outAzureIdentities) r.SetAzureIdentities(condition, apiutils.Deduplicate(outAzureIdentities)) + inGCPAccounts := r.GetGCPServiceAccounts(condition) + outGCPAccounts := applyValueTraitsSlice(inGCPAccounts, traits, "GCP service account") + r.SetGCPServiceAccounts(condition, apiutils.Deduplicate(outGCPAccounts)) + // apply templates to kubernetes groups inKubeGroups := r.GetKubeGroups(condition) outKubeGroups := applyValueTraitsSlice(inKubeGroups, traits, "kube group") @@ -498,7 +502,7 @@ func ApplyValueTraits(val string, traits map[string][]string) ([]string, error) constants.TraitKubeGroups, constants.TraitKubeUsers, constants.TraitDBNames, constants.TraitDBUsers, constants.TraitAWSRoleARNs, constants.TraitAzureIdentities, - teleport.TraitJWT: + constants.TraitGCPServiceAccounts, teleport.TraitJWT: default: return nil, trace.BadParameter("unsupported variable %q", variable.Name()) } @@ -1034,6 +1038,19 @@ func MatchAzureIdentity(selectors []string, identity string, matchWildcard bool) return false, fmt.Sprintf("no match, role selectors %v, identity: %v", selectors, identity) } +// MatchGCPServiceAccount returns true if provided GCP service account matches selectors. +func MatchGCPServiceAccount(selectors []string, account string, matchWildcard bool) (bool, string) { + for _, l := range selectors { + if l == account { + return true, "element matched" + } + if matchWildcard && l == types.Wildcard { + return true, "wildcard matched" + } + } + return false, fmt.Sprintf("no match, role selectors %v, identity: %v", selectors, account) +} + // MatchDatabaseName returns true if provided database name matches selectors. func MatchDatabaseName(selectors []string, name string) (bool, string) { for _, n := range selectors { @@ -1447,6 +1464,38 @@ func (set RoleSet) CheckAzureIdentities(ttl time.Duration, overrideTTL bool) ([] return out, nil } +// CheckGCPServiceAccounts returns a list of GCP service accounts this role set is allowed to assume. +func (set RoleSet) CheckGCPServiceAccounts(ttl time.Duration, overrideTTL bool) ([]string, error) { + accounts := make(map[string]struct{}) + var matchedTTL bool + for _, role := range set { + maxSessionTTL := role.GetOptions().MaxSessionTTL.Value() + if overrideTTL || (ttl <= maxSessionTTL && maxSessionTTL != 0) { + matchedTTL = true + for _, account := range role.GetGCPServiceAccounts(types.Allow) { + accounts[strings.ToLower(account)] = struct{}{} + } + } + } + for _, role := range set { + for _, account := range role.GetGCPServiceAccounts(types.Deny) { + // deny * removes all accounts + if account == types.Wildcard { + accounts = make(map[string]struct{}) + } + // remove particular account + delete(accounts, strings.ToLower(account)) + } + } + if !matchedTTL { + return nil, trace.AccessDenied("this user cannot request GCP API access for %v", ttl) + } + if len(accounts) == 0 { + return nil, trace.NotFound("this user cannot request GCP API access, has no assigned service accounts") + } + return utils.StringsSliceFromSet(accounts), nil +} + // CheckLoginDuration checks if role set can login up to given duration and // returns a combined list of allowed logins. func (set RoleSet) CheckLoginDuration(ttl time.Duration) ([]string, error) { @@ -1604,6 +1653,24 @@ func (m *AzureIdentityMatcher) String() string { return fmt.Sprintf("AzureIdentityMatcher(Identity=%v)", m.Identity) } +// GCPServiceAccountMatcher matches a role against GCP service account. +type GCPServiceAccountMatcher struct { + // ServiceAccount is a GCP service account to match, e.g. teleport@example-123456.iam.gserviceaccount.com. + // It can also be a wildcard *, but that is only respected for Deny rules. + ServiceAccount string +} + +// Match matches GCP ServiceAccount against provided role and condition. +func (m *GCPServiceAccountMatcher) Match(role types.Role, condition types.RoleConditionType) (bool, error) { + match, _ := MatchGCPServiceAccount(role.GetGCPServiceAccounts(condition), m.ServiceAccount, condition == types.Deny) + return match, nil +} + +// String returns the matcher's string representation. +func (m *GCPServiceAccountMatcher) String() string { + return fmt.Sprintf("GCPServiceAccountMatcher(ServiceAccount=%v)", m.ServiceAccount) +} + // CanImpersonateSomeone returns true if this checker has any impersonation rules func (set RoleSet) CanImpersonateSomeone() bool { for _, role := range set { diff --git a/lib/services/role_test.go b/lib/services/role_test.go index 855eb8aa2bdf4..66e5b5fd972a9 100644 --- a/lib/services/role_test.go +++ b/lib/services/role_test.go @@ -23,6 +23,7 @@ import ( "fmt" "sort" "strconv" + "strings" "testing" "time" @@ -1917,6 +1918,8 @@ func TestApplyTraits(t *testing.T) { outRoleARNs []string inAzureIdentities []string outAzureIdentities []string + inGCPServiceAccounts []string + outGCPServiceAccounts []string inLabels types.Labels outLabels types.Labels inKubeLabels types.Labels @@ -2061,6 +2064,28 @@ func TestApplyTraits(t *testing.T) { }, }, }, + { + comment: "GCP service account substitute in allow rule", + inTraits: map[string][]string{ + "foo": {"bar"}, + constants.TraitGCPServiceAccounts: {"baz"}, + }, + allow: rule{ + inGCPServiceAccounts: []string{"{{external.foo}}", teleport.TraitInternalGCPServiceAccounts}, + outGCPServiceAccounts: []string{"bar", "baz"}, + }, + }, + { + comment: "GCP service account substitute in deny rule", + inTraits: map[string][]string{ + "foo": {"bar"}, + constants.TraitGCPServiceAccounts: {"baz"}, + }, + deny: rule{ + inGCPServiceAccounts: []string{"{{external.foo}}", teleport.TraitInternalGCPServiceAccounts}, + outGCPServiceAccounts: []string{"bar", "baz"}, + }, + }, { comment: "kube group substitute in allow rule", inTraits: map[string][]string{ @@ -2445,6 +2470,7 @@ func TestApplyTraits(t *testing.T) { AppLabels: tt.allow.inAppLabels, AWSRoleARNs: tt.allow.inRoleARNs, AzureIdentities: tt.allow.inAzureIdentities, + GCPServiceAccounts: tt.allow.inGCPServiceAccounts, DatabaseLabels: tt.allow.inDBLabels, DatabaseNames: tt.allow.inDBNames, DatabaseUsers: tt.allow.inDBUsers, @@ -2463,6 +2489,7 @@ func TestApplyTraits(t *testing.T) { AppLabels: tt.deny.inAppLabels, AWSRoleARNs: tt.deny.inRoleARNs, AzureIdentities: tt.deny.inAzureIdentities, + GCPServiceAccounts: tt.deny.inGCPServiceAccounts, DatabaseLabels: tt.deny.inDBLabels, DatabaseNames: tt.deny.inDBNames, DatabaseUsers: tt.deny.inDBUsers, @@ -2492,6 +2519,7 @@ func TestApplyTraits(t *testing.T) { require.Equal(t, rule.spec.outAppLabels, outRole.GetAppLabels(rule.condition)) require.Equal(t, rule.spec.outRoleARNs, outRole.GetAWSRoleARNs(rule.condition)) require.Equal(t, rule.spec.outAzureIdentities, outRole.GetAzureIdentities(rule.condition)) + require.Equal(t, rule.spec.outGCPServiceAccounts, outRole.GetGCPServiceAccounts(rule.condition)) require.Equal(t, rule.spec.outDBLabels, outRole.GetDatabaseLabels(rule.condition)) require.Equal(t, rule.spec.outDBNames, outRole.GetDatabaseNames(rule.condition)) require.Equal(t, rule.spec.outDBUsers, outRole.GetDatabaseUsers(rule.condition)) @@ -3706,6 +3734,485 @@ func TestCheckAccessToAzureCloud(t *testing.T) { } } +// TestCheckAccessToGCP verifies GCP account access checker. +func TestCheckAccessToGCP(t *testing.T) { + app, err := types.NewAppV3(types.Metadata{Name: "azureapp"}, types.AppSpecV3{Cloud: types.CloudAzure}) + require.NoError(t, err) + readOnlyAccount := "readonly@example-123456.iam.gserviceaccount.com" + fullAccessAccount := "fullaccess@example-123456.iam.gserviceaccount.com" + roleNoAccess := &types.RoleV6{ + Metadata: types.Metadata{ + Name: "noaccess", + Namespace: apidefaults.Namespace, + }, + Spec: types.RoleSpecV6{ + Allow: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}}, + GCPServiceAccounts: []string{}, + }, + }, + } + roleReadOnly := &types.RoleV6{ + Metadata: types.Metadata{ + Name: "readonly", + Namespace: apidefaults.Namespace, + }, + Spec: types.RoleSpecV6{ + Allow: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}}, + GCPServiceAccounts: []string{readOnlyAccount}, + }, + }, + } + roleFullAccess := &types.RoleV6{ + Metadata: types.Metadata{ + Name: "fullaccess", + Namespace: apidefaults.Namespace, + }, + Spec: types.RoleSpecV6{ + Allow: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}}, + GCPServiceAccounts: []string{readOnlyAccount, fullAccessAccount}, + }, + }, + } + tests := []struct { + name string + roles RoleSet + access map[string]bool + }{ + { + name: "empty role set", + roles: nil, + access: map[string]bool{ + readOnlyAccount: false, + fullAccessAccount: false, + }, + }, + { + name: "no access role", + roles: RoleSet{roleNoAccess}, + access: map[string]bool{ + readOnlyAccount: false, + fullAccessAccount: false, + }, + }, + { + name: "readonly role", + roles: RoleSet{roleReadOnly}, + access: map[string]bool{ + readOnlyAccount: true, + fullAccessAccount: false, + }, + }, + { + name: "full access role", + roles: RoleSet{roleFullAccess}, + access: map[string]bool{ + readOnlyAccount: true, + fullAccessAccount: true, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + for account, hasAccess := range test.access { + err := test.roles.checkAccess(app, AccessMFAParams{}, &GCPServiceAccountMatcher{ServiceAccount: account}) + if hasAccess { + require.NoError(t, err) + } else { + require.Error(t, err) + require.True(t, trace.IsAccessDenied(err)) + } + + } + }) + } +} + +func TestCheckAzureIdentities(t *testing.T) { + readOnlyIdentity := "readonly" + fullAccessIdentity := "fullaccess" + + maxSessionTTL := time.Hour * 2 + sessionShort := time.Hour * 1 + sessionLong := time.Hour * 3 + + roleNoAccess := &types.RoleV6{ + Metadata: types.Metadata{ + Name: "noaccess", + Namespace: apidefaults.Namespace, + }, + Spec: types.RoleSpecV6{ + Options: types.RoleOptions{ + MaxSessionTTL: types.NewDuration(maxSessionTTL), + }, + Allow: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}}, + AzureIdentities: []string{}, + }, + }, + } + roleReadOnly := &types.RoleV6{ + Metadata: types.Metadata{ + Name: "readonly", + Namespace: apidefaults.Namespace, + }, + Spec: types.RoleSpecV6{ + Options: types.RoleOptions{ + MaxSessionTTL: types.NewDuration(maxSessionTTL), + }, + Allow: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}}, + AzureIdentities: []string{readOnlyIdentity}, + }, + }, + } + roleFullAccess := &types.RoleV6{ + Metadata: types.Metadata{ + Name: "fullaccess", + Namespace: apidefaults.Namespace, + }, + Spec: types.RoleSpecV6{ + Options: types.RoleOptions{ + MaxSessionTTL: types.NewDuration(maxSessionTTL), + }, + Allow: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}}, + AzureIdentities: []string{readOnlyIdentity, fullAccessIdentity}, + }, + }, + } + + roleDenyReadOnlyIdentity := &types.RoleV6{ + Metadata: types.Metadata{ + Name: "deny-identity", + Namespace: apidefaults.Namespace, + }, + Spec: types.RoleSpecV6{ + Options: types.RoleOptions{ + MaxSessionTTL: types.NewDuration(maxSessionTTL), + }, + Deny: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + AzureIdentities: []string{readOnlyIdentity}, + }, + }, + } + + roleDenyReadOnlyIdentityUppercase := &types.RoleV6{ + Metadata: types.Metadata{ + Name: "deny-identity-upper", + Namespace: apidefaults.Namespace, + }, + Spec: types.RoleSpecV6{ + Options: types.RoleOptions{ + MaxSessionTTL: types.NewDuration(maxSessionTTL), + }, + Deny: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + AzureIdentities: []string{strings.ToUpper(readOnlyIdentity)}, + }, + }, + } + + roleDenyAll := &types.RoleV6{ + Metadata: types.Metadata{ + Name: "deny-all", + Namespace: apidefaults.Namespace, + }, + Spec: types.RoleSpecV6{ + Options: types.RoleOptions{ + MaxSessionTTL: types.NewDuration(maxSessionTTL), + }, + Deny: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + AzureIdentities: []string{types.Wildcard}, + }, + }, + } + + tests := []struct { + name string + roles RoleSet + ttl time.Duration + overrideTTL bool + wantIdentities []string + wantError require.ErrorAssertionFunc + }{ + { + name: "empty role set", + roles: nil, + wantError: require.Error, + }, + { + name: "no access role", + overrideTTL: true, + roles: RoleSet{roleNoAccess}, + wantError: func(t require.TestingT, err error, i ...interface{}) { + require.ErrorContains(t, err, "this user cannot access Azure API, has no assigned identities") + }, + }, + { + name: "readonly role, short session", + overrideTTL: false, + ttl: sessionShort, + roles: RoleSet{roleReadOnly}, + wantIdentities: []string{"readonly"}, + wantError: require.NoError, + }, + { + name: "readonly role, long session", + overrideTTL: false, + ttl: sessionLong, + roles: RoleSet{roleReadOnly}, + wantError: func(t require.TestingT, err error, i ...interface{}) { + require.ErrorContains(t, err, "this user cannot access Azure API for 3h0m0s") + }, + }, + { + name: "readonly role, override TTL", + overrideTTL: true, + roles: RoleSet{roleReadOnly}, + wantIdentities: []string{"readonly"}, + wantError: require.NoError, + }, + { + name: "full access role", + overrideTTL: true, + roles: RoleSet{roleFullAccess}, + wantIdentities: []string{"fullaccess", "readonly"}, + wantError: require.NoError, + }, + { + name: "denying a role works", + overrideTTL: true, + roles: RoleSet{roleFullAccess, roleDenyReadOnlyIdentity}, + wantIdentities: []string{"fullaccess"}, + wantError: require.NoError, + }, + { + name: "denying an uppercase role works", + overrideTTL: true, + roles: RoleSet{roleFullAccess, roleDenyReadOnlyIdentityUppercase}, + wantIdentities: []string{"fullaccess"}, + wantError: require.NoError, + }, + { + name: "denying wildcard works", + overrideTTL: true, + roles: RoleSet{roleFullAccess, roleDenyAll}, + wantIdentities: nil, + wantError: func(t require.TestingT, err error, i ...interface{}) { + require.ErrorContains(t, err, "this user cannot access Azure API, has no assigned identities") + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + identities, err := tt.roles.CheckAzureIdentities(tt.ttl, tt.overrideTTL) + require.Equal(t, tt.wantIdentities, identities) + tt.wantError(t, err) + }) + } +} + +func TestCheckGCPServiceAccounts(t *testing.T) { + readOnlyAccount := "readonly@example-123456.iam.gserviceaccount.com" + fullAccessAccount := "fullaccess@example-123456.iam.gserviceaccount.com" + + maxSessionTTL := time.Hour * 2 + sessionShort := time.Hour * 1 + sessionLong := time.Hour * 3 + + roleNoAccess := &types.RoleV6{ + Metadata: types.Metadata{ + Name: "noaccess", + Namespace: apidefaults.Namespace, + }, + Spec: types.RoleSpecV6{ + Options: types.RoleOptions{ + MaxSessionTTL: types.NewDuration(maxSessionTTL), + }, + Allow: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}}, + GCPServiceAccounts: []string{}, + }, + }, + } + roleReadOnly := &types.RoleV6{ + Metadata: types.Metadata{ + Name: "readonly", + Namespace: apidefaults.Namespace, + }, + Spec: types.RoleSpecV6{ + Options: types.RoleOptions{ + MaxSessionTTL: types.NewDuration(maxSessionTTL), + }, + Allow: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}}, + GCPServiceAccounts: []string{readOnlyAccount}, + }, + }, + } + roleFullAccess := &types.RoleV6{ + Metadata: types.Metadata{ + Name: "fullaccess", + Namespace: apidefaults.Namespace, + }, + Spec: types.RoleSpecV6{ + Options: types.RoleOptions{ + MaxSessionTTL: types.NewDuration(maxSessionTTL), + }, + Allow: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}}, + GCPServiceAccounts: []string{readOnlyAccount, fullAccessAccount}, + }, + }, + } + + roleDenyReadOnlyAccount := &types.RoleV6{ + Metadata: types.Metadata{ + Name: "deny-account", + Namespace: apidefaults.Namespace, + }, + Spec: types.RoleSpecV6{ + Options: types.RoleOptions{ + MaxSessionTTL: types.NewDuration(maxSessionTTL), + }, + Deny: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + GCPServiceAccounts: []string{readOnlyAccount}, + }, + }, + } + + roleDenyReadOnlyAccountUppercase := &types.RoleV6{ + Metadata: types.Metadata{ + Name: "deny-account-upper", + Namespace: apidefaults.Namespace, + }, + Spec: types.RoleSpecV6{ + Options: types.RoleOptions{ + MaxSessionTTL: types.NewDuration(maxSessionTTL), + }, + Deny: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + GCPServiceAccounts: []string{strings.ToUpper(readOnlyAccount)}, + }, + }, + } + + roleDenyAll := &types.RoleV6{ + Metadata: types.Metadata{ + Name: "deny-all", + Namespace: apidefaults.Namespace, + }, + Spec: types.RoleSpecV6{ + Options: types.RoleOptions{ + MaxSessionTTL: types.NewDuration(maxSessionTTL), + }, + Deny: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + GCPServiceAccounts: []string{types.Wildcard}, + }, + }, + } + + tests := []struct { + name string + roles RoleSet + ttl time.Duration + overrideTTL bool + wantAccounts []string + wantError require.ErrorAssertionFunc + }{ + { + name: "empty role set", + roles: nil, + wantError: require.Error, + }, + { + name: "no access role", + overrideTTL: true, + roles: RoleSet{roleNoAccess}, + wantError: func(t require.TestingT, err error, i ...interface{}) { + require.ErrorContains(t, err, "this user cannot request GCP API access, has no assigned service accounts") + }, + }, + { + name: "readonly role, short session", + overrideTTL: false, + ttl: sessionShort, + roles: RoleSet{roleReadOnly}, + wantAccounts: []string{"readonly@example-123456.iam.gserviceaccount.com"}, + wantError: require.NoError, + }, + { + name: "readonly role, long session", + overrideTTL: false, + ttl: sessionLong, + roles: RoleSet{roleReadOnly}, + wantError: func(t require.TestingT, err error, i ...interface{}) { + require.ErrorContains(t, err, "this user cannot request GCP API access for 3h0m0s") + }, + }, + { + name: "readonly role, override TTL", + overrideTTL: true, + roles: RoleSet{roleReadOnly}, + wantAccounts: []string{"readonly@example-123456.iam.gserviceaccount.com"}, + wantError: require.NoError, + }, + { + name: "full access role", + overrideTTL: true, + roles: RoleSet{roleFullAccess}, + wantAccounts: []string{"fullaccess@example-123456.iam.gserviceaccount.com", "readonly@example-123456.iam.gserviceaccount.com"}, + wantError: require.NoError, + }, + { + name: "denying a role works", + overrideTTL: true, + roles: RoleSet{roleFullAccess, roleDenyReadOnlyAccount}, + wantAccounts: []string{"fullaccess@example-123456.iam.gserviceaccount.com"}, + wantError: require.NoError, + }, + { + name: "denying an uppercase role works", + overrideTTL: true, + roles: RoleSet{roleFullAccess, roleDenyReadOnlyAccountUppercase}, + wantAccounts: []string{"fullaccess@example-123456.iam.gserviceaccount.com"}, + wantError: require.NoError, + }, + { + name: "denying wildcard works", + overrideTTL: true, + roles: RoleSet{roleFullAccess, roleDenyAll}, + wantAccounts: nil, + wantError: func(t require.TestingT, err error, i ...interface{}) { + require.ErrorContains(t, err, "this user cannot request GCP API access, has no assigned service accounts") + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + accounts, err := tt.roles.CheckGCPServiceAccounts(tt.ttl, tt.overrideTTL) + require.Equal(t, tt.wantAccounts, accounts) + tt.wantError(t, err) + }) + } +} + func TestCheckAccessToKubernetes(t *testing.T) { clusterNoLabels := &types.KubernetesCluster{ Name: "no-labels", @@ -5658,3 +6165,116 @@ func TestMatchValidAzureIdentity(t *testing.T) { }) } } + +func TestGCPServiceAccountMatcher_Match(t *testing.T) { + tests := []struct { + name string + accounts []string + + role types.Role + matchType types.RoleConditionType + + wantMatched []string + }{ + { + name: "allow ignores wildcard", + accounts: []string{"foo", "bar", "baz"}, + role: newRole(func(r *types.RoleV6) { + r.Spec.Allow.GCPServiceAccounts = []string{"*", "bar", "baz"} + }), + matchType: types.Allow, + wantMatched: []string{"bar", "baz"}, + }, + { + name: "deny matches wildcard", + accounts: []string{"FoO", "BAr", "baz"}, + role: newRole(func(r *types.RoleV6) { + r.Spec.Deny.GCPServiceAccounts = []string{"*"} + }), + matchType: types.Deny, + wantMatched: []string{"FoO", "BAr", "baz"}, + }, + { + name: "non-wildcard deny matches", + accounts: []string{"foo", "bar", "baz"}, + role: newRole(func(r *types.RoleV6) { + r.Spec.Deny.GCPServiceAccounts = []string{"foo", "bar", "admin"} + }), + matchType: types.Deny, + wantMatched: []string{"foo", "bar"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var matched []string + for _, account := range tt.accounts { + m := &GCPServiceAccountMatcher{ServiceAccount: account} + if ok, _ := m.Match(tt.role, tt.matchType); ok { + matched = append(matched, account) + } + } + require.Equal(t, tt.wantMatched, matched) + }) + } +} + +func TestMatchGCPServiceAccount(t *testing.T) { + tests := []struct { + name string + + accounts []string + account string + matchWildcard bool + + wantMatch bool + wantMatchType string + }{ + { + name: "allow exact match", + + accounts: []string{"foo", "bar", "baz"}, + account: "bar", + matchWildcard: false, + + wantMatch: true, + wantMatchType: "element matched", + }, + { + name: "wildcard in allow doesn't work", + + accounts: []string{"foo", "bar", "*"}, + account: "baz", + matchWildcard: false, + + wantMatch: false, + wantMatchType: "no match, role selectors [foo bar *], identity: baz", + }, + { + name: "deny exact match", + + accounts: []string{"foo", "bar", "baz"}, + account: "bar", + matchWildcard: true, + + wantMatch: true, + wantMatchType: "element matched", + }, + { + name: "wildcard in deny works", + + accounts: []string{"foo", "bar", "*"}, + account: "baz", + matchWildcard: true, + + wantMatch: true, + wantMatchType: "wildcard matched", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotMatch, gotMatchType := MatchGCPServiceAccount(tt.accounts, tt.account, tt.matchWildcard) + require.Equal(t, tt.wantMatch, gotMatch) + require.Equal(t, tt.wantMatchType, gotMatchType) + }) + } +}