Adopt K8s infra for gsuite mgmt (#905)

* first commit

* adjust makefile

* import the tool properly

* reconcile groups

* fix owners, typos and tighen validation checks
This commit is contained in:
Mahamed 2022-02-23 21:34:40 +03:00 committed by GitHub
parent fb40880831
commit 62d1cf134b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 4599 additions and 0 deletions

30
groups/Makefile Normal file
View File

@ -0,0 +1,30 @@
# Copyright 2019 The Kubernetes Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
SHELL := /usr/bin/env bash
ifeq (run, $(firstword $(MAKECMDGOALS)))
runargs := $(wordlist 2, $(words $(MAKECMDGOALS)), $(MAKECMDGOALS))
$(eval $(runargs):;@true)
endif
default: run
.PHONY: run
run: test
go run . $(runargs)
.PHONY: test
test:
go test

16
groups/OWNERS Normal file
View File

@ -0,0 +1,16 @@
# The OWNERS file is used by prow to automatically merge approved PRs.
# root approval is restricted to members of k8s-infra-group-admins@
# because they have the ability to manually run, troubleshoot, and undo
# changes
options:
no_parent_owners: true
approvers:
- dprotaso
- upodroid
- chizhg
reviewers:
- productivity-reviewers

40
groups/README.md Normal file
View File

@ -0,0 +1,40 @@
# Automation of Google Groups maintenance for `knative.team`
- [Making changes](#making-changes)
- [Staging access groups](#staging-access-groups)
- [Manual deploy](#manual-deploy)
## Making changes
- Edit your SIG's `groups.yaml`, e.g. [`sig-release/groups.yaml`][/groups/sig-release/groups.yaml]
- If adding or removing a group, edit [`restrictions.yaml`] to add or remove the group name
- Use `make test` to ensure the changes meet conventions
- Open a pull request
- When the pull request merges, the [post-k8sio-groups] job will deploy the changes
### Staging access groups
Google Groups for granting push access to container repositories and/or buckets
must be of the form:
```console
k8s-infra-staging-<project-name>@knative.team
```
**The project name has a max length of 18 characters.**
## Manual deploy
- Must be run by someone who is a member of the productivity-infra-gcp-org@knative.team group
- Run `gcloud auth application-default login` to login
- Use `make run` to dry run the changes
- Use `make run -- --confirm` if the changes suggested in the previous step looks good
[post-k8sio-groups]: https://testgrid.k8s.io/sig-k8s-infra-k8sio#post-k8sio-groups
## How does this work?
- The groups are managed with the Google Admin SDK Groups API
- Google has a process called Domain Wide Delegation(DWD) that allows a Google Service Account to
impersonate a google workspace user. https://developers.google.com/admin-sdk/directory/v1/guides/delegation
- Configuring DWD is one time process as long as the Google Service Account impersonating is not deleted.

140
groups/client.go Normal file
View File

@ -0,0 +1,140 @@
/*
Copyright 2021 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"context"
admin "google.golang.org/api/admin/directory/v1"
groupssettings "google.golang.org/api/groupssettings/v1"
"google.golang.org/api/option"
)
type AdminServiceClient interface {
GetGroup(groupKey string) (*admin.Group, error)
GetMember(groupKey, memberKey string) (*admin.Member, error)
ListGroups() (*admin.Groups, error)
ListMembers(groupKey string) ([]*admin.Member, error)
InsertGroup(group *admin.Group) (*admin.Group, error)
InsertMember(groupKey string, member *admin.Member) (*admin.Member, error)
UpdateGroup(groupKey string, group *admin.Group) (*admin.Group, error)
UpdateMember(groupKey, memberKey string, member *admin.Member) (*admin.Member, error)
DeleteGroup(groupKey string) error
DeleteMember(groupKey, memberKey string) error
}
func NewAdminServiceClient(ctx context.Context, clientOption option.ClientOption) (AdminServiceClient, error) {
adminSvc, err := admin.NewService(ctx, clientOption)
if err != nil {
return nil, err
}
return &adminServiceClient{service: adminSvc}, nil
}
type adminServiceClient struct {
service *admin.Service
}
func (asc *adminServiceClient) GetGroup(groupKey string) (*admin.Group, error) {
return asc.service.Groups.Get(groupKey).Do()
}
func (asc *adminServiceClient) GetMember(groupKey, memberKey string) (*admin.Member, error) {
return asc.service.Members.Get(groupKey, memberKey).Do()
}
func (asc *adminServiceClient) ListGroups() (*admin.Groups, error) {
return asc.service.Groups.List().Customer("my_customer").OrderBy("email").Do()
}
func (asc *adminServiceClient) ListMembers(groupKey string) ([]*admin.Member, error) {
var members []*admin.Member
var resp *admin.Members
var err error
call := asc.service.Members.List(groupKey)
for {
resp, err = call.Do()
if err != nil {
return nil, err
}
members = append(members, resp.Members...)
if resp.NextPageToken == "" {
break
}
call.PageToken(resp.NextPageToken)
}
return members, nil
}
func (asc *adminServiceClient) InsertGroup(group *admin.Group) (*admin.Group, error) {
return asc.service.Groups.Insert(group).Do()
}
func (asc *adminServiceClient) InsertMember(groupKey string, member *admin.Member) (*admin.Member, error) {
return asc.service.Members.Insert(groupKey, member).Do()
}
func (asc *adminServiceClient) UpdateGroup(groupKey string, group *admin.Group) (*admin.Group, error) {
return asc.service.Groups.Update(groupKey, group).Do()
}
func (asc *adminServiceClient) UpdateMember(groupKey, memberKey string, member *admin.Member) (*admin.Member, error) {
return asc.service.Members.Update(groupKey, memberKey, member).Do()
}
func (asc *adminServiceClient) DeleteGroup(groupKey string) error {
return asc.service.Groups.Delete(groupKey).Do()
}
func (asc *adminServiceClient) DeleteMember(groupKey, memberKey string) error {
return asc.service.Members.Delete(groupKey, memberKey).Do()
}
var _ AdminServiceClient = (*adminServiceClient)(nil)
type GroupServiceClient interface {
Get(groupUniqueID string) (*groupssettings.Groups, error)
Patch(groupUniqueID string, groups *groupssettings.Groups) (*groupssettings.Groups, error)
}
func NewGroupServiceClient(ctx context.Context, clientOption option.ClientOption) (GroupServiceClient, error) {
groupSvc, err := groupssettings.NewService(ctx, clientOption)
if err != nil {
return nil, err
}
return &groupServiceClient{service: groupSvc}, nil
}
type groupServiceClient struct {
service *groupssettings.Service
}
func (gsc *groupServiceClient) Get(groupUniqueID string) (*groupssettings.Groups, error) {
return gsc.service.Groups.Get(groupUniqueID).Do()
}
func (gsc *groupServiceClient) Patch(groupUniqueID string, groups *groupssettings.Groups) (*groupssettings.Groups, error) {
return gsc.service.Groups.Patch(groupUniqueID, groups).Do()
}
var _ GroupServiceClient = (*groupServiceClient)(nil)

View File

@ -0,0 +1,6 @@
# The OWNERS file is used by prow to automatically merge approved PRs.
approvers:
- steering-committee
reviewers:
- steering-committee

View File

@ -0,0 +1,15 @@
groups:
- email-id: code-of-conduct@knative.team
name: code-of-conduct
description: |-
Group for handling Code of Conduct issues.
Due to potentially sensitive nature of the issues discussed, this is a closed group.
settings:
ReconcileMembers: "true"
WhoCanPostMessage: "ANYONE_CAN_POST"
owners:
- pmorie@knative.team
- thisisnotapril@knative.team
- vaikas@knative.team
- lball@knative.team
- csantanapr@knative.team

View File

@ -0,0 +1,6 @@
# The OWNERS file is used by prow to automatically merge approved PRs.
approvers:
- technical-oversight-committee
reviewers:
- technical-oversight-committee

View File

@ -0,0 +1,17 @@
# Technical Oversight Committee
groups:
- email-id: toc@knative.team
name: toc
description: |-
Technical Oversight Committee Email
settings:
WhoCanPostMessage: "ANYONE_CAN_POST"
ReconcileMembers: "true"
owners:
- markusthoemmes@knative.team
members:
- evankanderson@knative.team
- grantr@knative.team
- mattmoor@knative.team
- rhuss@knative.team

View File

@ -0,0 +1,6 @@
# The OWNERS file is used by prow to automatically merge approved PRs.
approvers:
- steering-committee
reviewers:
- steering-committee

View File

@ -0,0 +1,28 @@
groups:
- email-id: steering@knative.team
name: steering
description: |-
Private list for current and emeritus members of the Knative Steering Committee
settings:
ReconcileMembers: "true"
owners:
- pmorie@knative.team
- thisisnotapril@knative.team
- vaikas@knative.team
- csantanapr@knative.team
members:
- anassi@google.com
- lball@knative.team
- pmorie@redhat.com
- vaikas@gmail.com
- itsmurugappan@knative.team
- email-id: elections@knative.team
name: elections
description: |-
Knative Elections Mailing List
settings:
ReconcileMembers: "true"
members:
- fosterd@vmware.com
- jberkus@redhat.com

View File

@ -0,0 +1,6 @@
# The OWNERS file is used by prow to automatically merge approved PRs.
approvers:
- trademark-committee
reviewers:
- trademark-committee

View File

@ -0,0 +1,12 @@
groups:
- email-id: trademark@knative.team
name: trademark
description: |-
Trademark Committee Mailing List
settings:
WhoCanPostMessage: "ANYONE_CAN_POST"
ReconcileMembers: "true"
owners:
- bsnchan@knative.team
- duglin@gmail.com
- avnur@knative.team

13
groups/config.yaml Normal file
View File

@ -0,0 +1,13 @@
# Configuration for the kubernetes.io Google Groups setup
# Secret version of the service account key to use
secret-version: projects/knative-gsuite/secrets/gsuite-group-manager_key/versions/latest
# Email id of the bot service account
bot-id: prow-robot@knative.team # TO BE CREATED
# Directory with groups.yaml files, relative to location of this config file
groups-path: .
# Path to restrictions.yaml file, relative to location of this config file
restrictions-path: restrictions.yaml

272
groups/fake/fake_client.go Normal file
View File

@ -0,0 +1,272 @@
/*
Copyright 2021 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package fake
import (
"fmt"
admin "google.golang.org/api/admin/directory/v1"
groupssettings "google.golang.org/api/groupssettings/v1"
)
// FakeAdminServiceClient implements the AdminServiceClient but is fake.
type FakeAdminServiceClient struct {
// Groups is a mapping from groupKey to *admin.Group
Groups map[string]*admin.Group
// Members is a mapping from groupKey -> members of that group
// Members of a group are a mapping from memberKey -> *admin.Member
Members map[string]map[string]*admin.Member
// OnGroupInsert is a callback function called whenever an
// Insert operation is done for a group. This is nescessary
// because creating a group also involves reflecting that
// creation in the group service fake client, where the GsGroups
// map takes the group email as the key.
onGroupInsert func(string)
}
func NewFakeAdminServiceClient() *FakeAdminServiceClient {
return &FakeAdminServiceClient{
Groups: make(map[string]*admin.Group),
Members: make(map[string]map[string]*admin.Member),
}
}
func NewAugmentedFakeAdminServiceClient() *FakeAdminServiceClient {
fakeClient := NewFakeAdminServiceClient()
fakeClient.Groups = map[string]*admin.Group{
"group1@email.com": {
Email: "group1@email.com",
Name: "group1",
Description: "group1",
},
"group2@email.com": {
Email: "group2@email.com",
Name: "group2",
Description: "group2",
},
}
fakeClient.Members = map[string]map[string]*admin.Member{
"group1@email.com": {
"m1-group1@email.com": {
Email: "m1-group1@email.com",
Role: "MEMBER",
Id: "m1-group1@email.com",
},
"m2-group1@email.com": {
Email: "m2-group1@email.com",
Role: "MANAGER",
Id: "m2-group1@email.com",
},
},
"group2@email.com": {
"m1-group2@email.com": {
Email: "m1-group2@email.com",
Role: "MEMBER",
Id: "m1-group2@email.com",
},
"m2-group2@email.com": {
Email: "m2-group2@email.com",
Role: "OWNER",
Id: "m2-group2@email.com",
},
},
}
return fakeClient
}
func (fasc *FakeAdminServiceClient) RegisterCallback(onGroupInsert func(string)) {
fasc.onGroupInsert = onGroupInsert
}
func (fasc *FakeAdminServiceClient) GetGroup(groupKey string) (*admin.Group, error) {
group, ok := fasc.Groups[groupKey]
if !ok {
return nil, fmt.Errorf("group key %s not found", groupKey)
}
return group, nil
}
func (fasc *FakeAdminServiceClient) GetMember(groupKey, memberKey string) (*admin.Member, error) {
members, ok := fasc.Members[groupKey]
if !ok {
return nil, fmt.Errorf("group with group key %s not found", groupKey)
}
member, ok := members[memberKey]
if !ok {
return nil, fmt.Errorf("member with groupKey %s and memberKey %s not found", groupKey, memberKey)
}
return member, nil
}
func (fasc *FakeAdminServiceClient) ListGroups() (*admin.Groups, error) {
groups := &admin.Groups{}
for _, group := range fasc.Groups {
groups.Groups = append(groups.Groups, group)
}
return groups, nil
}
func (fasc *FakeAdminServiceClient) ListMembers(groupKey string) ([]*admin.Member, error) {
_, ok := fasc.Members[groupKey]
if !ok {
return nil, fmt.Errorf("groupKey %s not found", groupKey)
}
members := &admin.Members{}
for _, member := range fasc.Members[groupKey] {
members.Members = append(members.Members, member)
}
return members.Members, nil
}
func (fasc *FakeAdminServiceClient) InsertGroup(group *admin.Group) (*admin.Group, error) {
fasc.Groups[group.Email] = group
if fasc.onGroupInsert != nil {
fasc.onGroupInsert(group.Email)
}
fasc.Members[group.Email] = map[string]*admin.Member{}
return group, nil
}
func (fasc *FakeAdminServiceClient) InsertMember(groupKey string, member *admin.Member) (*admin.Member, error) {
_, ok := fasc.Members[groupKey]
if !ok {
return nil, fmt.Errorf("groupKey %s not found", groupKey)
}
fasc.Members[groupKey][member.Email] = member
return member, nil
}
func (fasc *FakeAdminServiceClient) UpdateGroup(groupKey string, group *admin.Group) (*admin.Group, error) {
_, ok := fasc.Groups[groupKey]
if !ok {
return nil, fmt.Errorf("group key %s not found", groupKey)
}
fasc.Groups[groupKey] = group
return group, nil
}
func (fasc *FakeAdminServiceClient) UpdateMember(groupKey, memberKey string, member *admin.Member) (*admin.Member, error) {
_, ok := fasc.Members[groupKey]
if !ok {
return nil, fmt.Errorf("group with group key %s not found", groupKey)
}
_, ok = fasc.Members[groupKey][memberKey]
if !ok {
return nil, fmt.Errorf("member with groupKey %s and memberKey %s not found", groupKey, memberKey)
}
fasc.Members[groupKey][memberKey] = member
return member, nil
}
func (fasc *FakeAdminServiceClient) DeleteGroup(groupKey string) error {
_, ok := fasc.Groups[groupKey]
if !ok {
return fmt.Errorf("group key %s not found", groupKey)
}
delete(fasc.Groups, groupKey)
return nil
}
func (fasc *FakeAdminServiceClient) DeleteMember(groupKey, memberKey string) error {
_, ok := fasc.Members[groupKey]
if !ok {
return fmt.Errorf("group with group key %s not found", groupKey)
}
_, ok = fasc.Members[groupKey][memberKey]
if !ok {
return fmt.Errorf("member with groupKey %s and memberKey %s not found", groupKey, memberKey)
}
delete(fasc.Members[groupKey], memberKey)
return nil
}
// FakeGroupServiceClient implements the GroupServiceClient but is fake.
type FakeGroupServiceClient struct {
GsGroups map[string]*groupssettings.Groups
}
func NewFakeGroupServiceClient() *FakeGroupServiceClient {
return &FakeGroupServiceClient{
GsGroups: make(map[string]*groupssettings.Groups),
}
}
func NewAugmentedFakeGroupServiceClient() *FakeGroupServiceClient {
fakeClient := NewFakeGroupServiceClient()
fakeClient.GsGroups = map[string]*groupssettings.Groups{
"group1@email.com": {
AllowExternalMembers: "true",
WhoCanJoin: "CAN_REQUEST_TO_JOIN",
WhoCanViewMembership: "ALL_MANAGERS_CAN_VIEW",
WhoCanViewGroup: "ALL_MEMBERS_CAN_VIEW",
WhoCanDiscoverGroup: "ALL_IN_DOMAIN_CAN_DISCOVER",
WhoCanModerateMembers: "OWNERS_AND_MANAGERS",
WhoCanModerateContent: "OWNERS_AND_MANAGERS",
WhoCanPostMessage: "ALL_MEMBERS_CAN_POST",
MessageModerationLevel: "MODERATE_NONE",
MembersCanPostAsTheGroup: "true",
},
"group2@email.com": {
AllowExternalMembers: "true",
WhoCanJoin: "INVITED_CAN_JOIN",
WhoCanViewMembership: "ALL_MANAGERS_CAN_VIEW",
WhoCanViewGroup: "ALL_MEMBERS_CAN_VIEW",
WhoCanDiscoverGroup: "ALL_IN_DOMAIN_CAN_DISCOVER",
WhoCanModerateMembers: "OWNERS_ONLY",
WhoCanModerateContent: "OWNERS_AND_MANAGERS",
WhoCanPostMessage: "ALL_MEMBERS_CAN_POST",
MessageModerationLevel: "MODERATE_NONE",
MembersCanPostAsTheGroup: "false",
},
}
return fakeClient
}
func (fgsc *FakeGroupServiceClient) Get(groupUniqueID string) (*groupssettings.Groups, error) {
gsg, ok := fgsc.GsGroups[groupUniqueID]
if !ok {
return nil, fmt.Errorf("groupUniqueID not found %ss", groupUniqueID)
}
return gsg, nil
}
func (fgsc *FakeGroupServiceClient) Patch(groupUniqueID string, groups *groupssettings.Groups) (*groupssettings.Groups, error) {
_, ok := fgsc.GsGroups[groupUniqueID]
if !ok {
return nil, fmt.Errorf("groupUniqueID not found %ss", groupUniqueID)
}
fgsc.GsGroups[groupUniqueID] = groups
return groups, nil
}

32
groups/go.mod Normal file
View File

@ -0,0 +1,32 @@
module k8s.io/k8s.io/groups
go 1.17
require (
cloud.google.com/go/secretmanager v1.0.0
github.com/bmatcuk/doublestar v1.1.1
golang.org/x/net v0.0.0-20211007125505-59d4e928ea9d
golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1
google.golang.org/api v0.58.0
google.golang.org/genproto v0.0.0-20211007155348-82e027067bd4
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c
k8s.io/apimachinery v0.0.0-20190817020851-f2f3a405f61d
k8s.io/test-infra v0.0.0-20191024183346-202cefeb6ff5
)
require (
cloud.google.com/go v0.97.0 // indirect
github.com/clarketm/json v1.13.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/go-cmp v0.5.6 // indirect
github.com/googleapis/gax-go/v2 v2.1.1 // indirect
go.opencensus.io v0.23.0 // indirect
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac // indirect
golang.org/x/text v0.3.7 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/grpc v1.41.0 // indirect
google.golang.org/protobuf v1.27.1 // indirect
)
replace k8s.io/apimachinery => k8s.io/apimachinery v0.0.0-20190817020851-f2f3a405f61d

1014
groups/go.sum Normal file

File diff suppressed because it is too large Load Diff

70
groups/groups.yaml Normal file
View File

@ -0,0 +1,70 @@
groups:
- email-id: wg-leads@knative.team
name: wg-leads
description: |-
Knative Working Group leads
settings:
AllowWebPosting: "true"
WhoCanViewGroup: "ANYONE_CAN_VIEW"
WhoCanDiscoverGroup: "ANYONE_CAN_DISCOVER"
WhoCanPostMessage: "ANYONE_CAN_POST"
MessageModerationLevel: "MODERATE_NON_MEMBERS"
ReconcileMembers: "true"
owners:
- dprotaso@gmail.com
- evan.k.anderson@gmail.com
- evana@vmware.com
- evankanderson@knative.team
- julz.friedman@uk.ibm.com
- julz@knative.team
- markusthoemmes@knative.team
- markusthoemmes@me.com
- me.markusthoemmes@googlemail.com
- rhuss@knative.team
- rhuss@redhat.com
- tcnghia@gmail.com
members:
- chizhg@google.com
- chizhg@knative.team
- csantanapr@knative.team
- devguyio@knative.team
- dprotaso@knative.team
- dsimansk@knative.team
- dsimansk@redhat.com
- eng.a.abdalla@gmail.com
- fguardia@redhat.com
- grantr@knative.team
- houshengbo@knative.team
- knakayam@redhat.com
- lionelvillard@knative.team
- matzew@knative.team
- mwessend@redhat.com
- mwessendorf@gmail.com
- n3wscott@knative.team
- nak3@knative.team
- navidshaikh@knative.team
- nshaikh@redhat.com
- omerbensaadon@knative.team
- shou@us.ibm.com
- slinkydeveloper@knative.team
- snneji@knative.team
- travis.minke@sap.com
- vagababov@gmail.com
- villard@us.ibm.com
- zhiminx@google.com
- zhiminxiang@knative.team
- email-id: calendar-maintainers@knative.team
name: calendar-maintainers
description: |-
Folks that can manage Knative calendar
settings:
AllowExternalMembers: "false"
owners:
- omerbensaadon@knative.team
- pmorie@knative.team
- thisisnotapril@knative.team
- vaikas@knative.team
members:
- abrennan@knative.team
- abrennan89@knative.team

348
groups/groups_test.go Normal file
View File

@ -0,0 +1,348 @@
/*
Copyright 2019 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"flag"
"fmt"
"k8s.io/apimachinery/pkg/util/sets"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
"unicode/utf8"
)
var cfg GroupsConfig
var rConfig RestrictionsConfig
var groupsPath = flag.String("groups-path", "", "Directory containing groups.yaml files")
var restrictionsPath = flag.String("restrictions-path", "", "Path to the configuration file containing restrictions")
func TestMain(m *testing.M) {
flag.Parse()
var err error
if *restrictionsPath != "" && !filepath.IsAbs(*restrictionsPath) {
fmt.Printf("restrictions-path \"%s\" must be an absolute path\n", *restrictionsPath)
os.Exit(1)
}
if *restrictionsPath == "" {
baseDir, err := os.Getwd()
if err != nil {
fmt.Printf("Cannot get current working directory: %v\n", err)
os.Exit(1)
}
rPath := filepath.Join(baseDir, defaultRestrictionsFile)
restrictionsPath = &rPath
}
if err := rConfig.Load(*restrictionsPath); err != nil {
fmt.Printf("Could not load restrictions config: %v\n", err)
os.Exit(1)
}
if *groupsPath != "" && !filepath.IsAbs(*groupsPath) {
fmt.Printf("groups-path \"%s\" must be an absolute path\n", *groupsPath)
os.Exit(1)
}
if *groupsPath == "" {
*groupsPath, err = os.Getwd()
if err != nil {
fmt.Printf("Cannot get current working directory: %v\n", err)
os.Exit(1)
}
}
if err := cfg.Load(*groupsPath, &rConfig); err != nil {
fmt.Printf("Could not load groups config: %v\n", err)
os.Exit(1)
}
os.Exit(m.Run())
}
// TestMergedGroupsConfig tests that readGroupsConfig reads all
// groups.yaml files and the merged config does not contain any duplicates.
//
// It tests that the config is merged by checking that the final
// GroupsConfig contains at least one group that isn't in the
// root groups.yaml file.
func TestMergedGroupsConfig(t *testing.T) {
var containsMergedConfig bool
found := sets.String{}
dups := sets.String{}
for _, g := range cfg.Groups {
name := g.Name
if name == "steering" {
containsMergedConfig = true
}
if found.Has(name) {
dups.Insert(name)
}
found.Insert(name)
}
if !containsMergedConfig {
t.Errorf("Final GroupsConfig does not have merged configs from all groups.yaml files")
}
if n := len(dups); n > 0 {
t.Errorf("%d duplicate groups: %s", n, strings.Join(dups.List(), ", "))
}
}
// TestStagingEmailLength tests that the number of characters in the
// project name in emails used for staging repos does not exceed 18.
//
// This validation is needed because gcloud allows PROJECT_IDs of length
// between 6 and 30. So after discounting the "k8s-staging" prefix,
// we are left with 18 chars for the project name.
func TestStagingEmailLength(t *testing.T) {
var errs []error
for _, g := range cfg.Groups {
if strings.HasPrefix(g.EmailId, "k8s-infra-staging-") {
projectName := strings.TrimSuffix(strings.TrimPrefix(g.EmailId, "k8s-infra-staging-"), "@knative.team")
len := utf8.RuneCountInString(projectName)
if len > 18 {
errs = append(errs, fmt.Errorf("Number of characters in project name \"%s\" should not exceed 18; is: %d", projectName, len))
}
}
}
if len(errs) > 0 {
for _, err := range errs {
t.Error(err)
}
}
}
// TestDescriptionLength tests that the number of characters in the
// google groups description does not exceed 300.
//
// This validation is needed because gcloud allows apps:description
// with length no greater than 300
func TestDescriptionLength(t *testing.T) {
var errs []error
for _, g := range cfg.Groups {
description := g.Description
len := utf8.RuneCountInString(description)
//Ref: https://developers.google.com/admin-sdk/groups-settings/v1/reference/groups
if len > 300 {
errs = append(errs,
fmt.Errorf("Number of characters in description \"%s\" for group name \"%s\" "+
"should not exceed 300; is: %d", description, g.Name, len))
}
}
if len(errs) > 0 {
for _, err := range errs {
t.Error(err)
}
}
}
// Enforce conventions for all groups
func TestGroupConventions(t *testing.T) {
for _, g := range cfg.Groups {
// groups are easier to reason about if email and name match
expectedEmailId := g.Name + "@knative.team"
if g.EmailId != expectedEmailId {
t.Errorf("group '%s': expected email '%s', got '%s'", g.Name, expectedEmailId, g.EmailId)
}
}
}
// Enforce conventions for all k8s-infra groups
func TestK8sInfraGroupConventions(t *testing.T) {
for _, g := range cfg.Groups {
if strings.HasPrefix(g.EmailId, "k8s-infra") {
// no owners because we want to prevent manual membership changes
if len(g.Owners) > 0 {
t.Errorf("group '%s': must have no owners, only members", g.Name)
}
// treat files here as source of truth for membership
reconcileMembers, ok := g.Settings["ReconcileMembers"]
if !ok || reconcileMembers != "true" {
t.Errorf("group '%s': must have settings.ReconcileMembers = true", g.Name)
}
}
}
}
// Enforce conventions for groups used by GKE Group-based RBAC
// - there must be a gke-security-groups@ group
// - its members must be k8s-infra-rbac-*@ groups (and vice-versa)
// - all groups involved must have settings.WhoCanViewMembership = ALL_MEMBERS_CAN_VIEW
func TestK8sInfraRBACGroupConventions(t *testing.T) {
rbacEmails := make(map[string]bool)
for _, g := range cfg.Groups {
if strings.HasPrefix(g.EmailId, "k8s-infra-rbac") {
rbacEmails[g.EmailId] = false
// this is necessary for group-based rbac to work
whoCanViewMembership, ok := g.Settings["WhoCanViewMembership"]
if !ok || whoCanViewMembership != "ALL_MEMBERS_CAN_VIEW" {
t.Errorf("group '%s': must have settings.WhoCanViewMembership = ALL_MEMBERS_CAN_VIEW", g.Name)
}
}
}
foundGKEGroup := false
for _, g := range cfg.Groups {
if g.EmailId == "gke-security-groups@knative.team" {
foundGKEGroup = true
// this is necessary for group-based rbac to work
whoCanViewMembership, ok := g.Settings["WhoCanViewMembership"]
if !ok || whoCanViewMembership != "ALL_MEMBERS_CAN_VIEW" {
t.Errorf("group '%s': must have settings.WhoCanViewMembership = ALL_MEMBERS_CAN_VIEW", g.Name)
}
for _, email := range g.Members {
if _, ok := rbacEmails[email]; !ok {
t.Errorf("group '%s': invalid member '%s', must be a k8s-infra-rbac-*@knative.team group", g.Name, email)
} else {
rbacEmails[email] = true
}
}
}
}
if !foundGKEGroup {
t.Errorf("group '%s' is missing", "gke-security-groups@knative.team")
}
for email, found := range rbacEmails {
if !found {
t.Errorf("group '%s': must be a member of gke-security-groups@knative.team", email)
}
}
}
// An e-mail address can only show up once within a given group, whether that
// be as a member, manager, or owner
func TestNoDuplicateMembers(t *testing.T) {
for _, g := range cfg.Groups {
members := map[string]bool{}
for _, m := range g.Members {
if _, ok := members[m]; ok {
t.Errorf("group '%s' cannot have duplicate member '%s'", g.EmailId, m)
}
members[m] = true
}
managers := map[string]bool{}
for _, m := range g.Managers {
if _, ok := members[m]; ok {
t.Errorf("group '%s' manager '%s' cannot also be listed as a member", g.EmailId, m)
}
if _, ok := managers[m]; ok {
t.Errorf("group '%s' cannot have duplicate manager '%s'", g.EmailId, m)
}
managers[m] = true
}
owners := map[string]bool{}
for _, m := range g.Owners {
if _, ok := members[m]; ok {
t.Errorf("group '%s' owner '%s' cannot also be listed as a member", g.EmailId, m)
}
if _, ok := managers[m]; ok {
t.Errorf("group '%s' owner '%s' cannot also be listed as a manager", g.EmailId, m)
}
if _, ok := owners[m]; ok {
t.Errorf("group '%s' cannot have duplicate owner '%s'", g.EmailId, m)
}
owners[m] = true
}
}
}
// NOTE: make very certain you know what you are doing if you change one
// // of these groups, we don't want to accidentally lock ourselves out
func TestHardcodedGroupsForParanoia(t *testing.T) {
groups := map[string][]string{
"kn-infra-gcp-org-admins@knative.team": {
"chizhg@google.com",
"cy@borg.dev",
"chizhg@knative.team",
"cy@knative.team",
"racker-maha-66b7d200@gcp.rackspace.com",
},
}
found := make(map[string]bool)
for _, g := range cfg.Groups {
if expected, ok := groups[g.EmailId]; ok {
found[g.EmailId] = true
sort.Strings(expected)
actual := make([]string, len(g.Members))
copy(actual, g.Members)
sort.Strings(actual)
if !reflect.DeepEqual(expected, actual) {
t.Errorf("group '%s': expected members '%v', got '%v'", g.Name, expected, actual)
}
}
}
for email := range groups {
if _, ok := found[email]; !ok {
t.Errorf("group '%s' is missing, should be present", email)
}
}
}
// Setting AllowWebPosting should be set for every group which should support
// access to the group not only via gmail but also via web (you can see the list
// and history of threads and also use web interface to operate the group)
// More info:
// https://developers.google.com/admin-sdk/groups-settings/v1/reference/groups#allowWebPosting
func TestGroupsWhichShouldSupportHistory(t *testing.T) {
groups := map[string]struct{}{
"wg-leads@knative.team": {},
}
found := make(map[string]struct{})
for _, group := range cfg.Groups {
emailId := group.EmailId
found[emailId] = struct{}{}
if _, ok := groups[emailId]; ok {
allowedWebPosting, ok := group.Settings["AllowWebPosting"]
if !ok {
t.Errorf(
"group '%s': must have 'settings.allowedWebPosting = true'",
group.Name,
)
} else if allowedWebPosting != "true" {
t.Errorf(
"group '%s': must have 'settings.allowedWebPosting = true'"+
" but have 'settings.allowedWebPosting = %s' instead",
group.Name,
allowedWebPosting,
)
}
}
}
for email := range groups {
if _, ok := found[email]; !ok {
t.Errorf("group '%s' is missing, should be present", email)
}
}
}

495
groups/reconcile.go Normal file
View File

@ -0,0 +1,495 @@
/*
Copyright 2019 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"flag"
"fmt"
"io/ioutil"
"log"
"os"
"path/filepath"
"regexp"
"strings"
secretmanager "cloud.google.com/go/secretmanager/apiv1"
"github.com/bmatcuk/doublestar"
"golang.org/x/net/context"
"golang.org/x/oauth2/google"
admin "google.golang.org/api/admin/directory/v1"
"google.golang.org/api/groupssettings/v1"
"google.golang.org/api/option"
secretmanagerpb "google.golang.org/genproto/googleapis/cloud/secretmanager/v1"
"gopkg.in/yaml.v3"
utilerrors "k8s.io/apimachinery/pkg/util/errors"
"k8s.io/test-infra/pkg/genyaml"
)
type Config struct {
// the email id for the bot/service account
BotID string `yaml:"bot-id"`
// the gcloud secret containing a service account key to authenticate with
SecretVersion string `yaml:"secret-version,omitempty"`
// GroupsPath is the path to the directory with
// groups.yaml files containing groups/members information.
// It must be an absolute path. If not specified,
// it defaults to the directory containing the config.yaml file.
GroupsPath string `yaml:"groups-path,omitempty"`
// RestrictionsPath is the absolute path to the configuration file
// containing restrictions for which groups can be defined in sub-directories.
// If not specified, it defaults to "restrictions.yaml" in the groups-path directory.
RestrictionsPath string `yaml:"restrictions-path,omitempty"`
// If false, don't make any mutating API calls
ConfirmChanges bool
}
type GroupsConfig struct {
// This file has the list of groups in kubernetes.io gsuite org that we use
// for granting permissions to various community resources.
Groups []GoogleGroup `yaml:"groups,omitempty" json:"groups,omitempty"`
}
type GoogleGroup struct {
EmailId string `yaml:"email-id" json:"email-id"`
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Settings map[string]string `yaml:"settings,omitempty" json:"settings,omitempty"`
// +optional
Owners []string `yaml:"owners,omitempty" json:"owners,omitempty"`
// +optional
Managers []string `yaml:"managers,omitempty" json:"managers,omitempty"`
// +optional
Members []string `yaml:"members,omitempty" json:"members,omitempty"`
}
// RestrictionsConfig contains the list of restrictions for
// which groups can be defined in sub-directories.
type RestrictionsConfig struct {
Restrictions []Restriction `yaml:"restrictions,omitempty" json:"restrictions,omitempty"`
}
type Restriction struct {
// Path is the relative path of a sub-directory to the groups-path.
Path string `yaml:"path" json:"path"`
// AllowedGroups is the list of regular expressions for email-ids
// of groups that can be defined for the Path.
//
// Compiles to AllowedGroupsRe during config load.
AllowedGroups []string `yaml:"allowedGroups" json:"allowedGroups"`
AllowedGroupsRe []*regexp.Regexp
}
func Usage() {
fmt.Fprintf(os.Stderr, `
Usage: %s [-config <config-yaml-file>] [--confirm]
Command line flags override config values.
`, os.Args[0])
flag.PrintDefaults()
}
var (
config Config
groupsConfig GroupsConfig
restrictionsConfig RestrictionsConfig
verbose = flag.Bool("v", false, "log extra information")
defaultConfigFile = "config.yaml"
defaultRestrictionsFile = "restrictions.yaml"
emptyRegexp = regexp.MustCompile("")
defaultRestriction = Restriction{Path: "*", AllowedGroupsRe: []*regexp.Regexp{emptyRegexp}}
)
func main() {
configFilePath := flag.String("config", defaultConfigFile, "the config file in yaml format")
confirmChanges := flag.Bool("confirm", false, "false by default means that we do not push anything to google groups")
printConfig := flag.Bool("print", false, "print the existing group information")
flag.Usage = Usage
flag.Parse()
if *printConfig {
log.Printf("print: %v -- disabling confirm, will print existing group information", *confirmChanges)
*confirmChanges = false
}
if !*confirmChanges {
log.Printf("confirm: %v -- dry-run mode, changes will not be pushed", *confirmChanges)
}
err := config.Load(*configFilePath, *confirmChanges)
if err != nil {
log.Fatal(err)
}
log.Printf("config: BotID: %v", config.BotID)
log.Printf("config: SecretVersion: %v", config.SecretVersion)
log.Printf("config: GroupsPath: %v", config.GroupsPath)
log.Printf("config: RestrictionsPath: %v", config.RestrictionsPath)
log.Printf("config: ConfirmChanges: %v", config.ConfirmChanges)
err = restrictionsConfig.Load(config.RestrictionsPath)
if err != nil {
log.Fatal(err)
}
err = groupsConfig.Load(config.GroupsPath, &restrictionsConfig)
if err != nil {
log.Fatal(err)
}
serviceAccountKey, err := accessSecretVersion(config.SecretVersion)
if err != nil {
log.Fatalf("Unable to access secret-version %s, %v", config.SecretVersion, err)
}
credential, err := google.JWTConfigFromJSON(serviceAccountKey, admin.AdminDirectoryUserReadonlyScope,
admin.AdminDirectoryGroupScope,
admin.AdminDirectoryGroupMemberScope,
groupssettings.AppsGroupsSettingsScope)
if err != nil {
log.Fatalf("Unable to authenticate using key in secret-version %s, %v", config.SecretVersion, err)
}
credential.Subject = config.BotID
ctx := context.Background()
client := credential.Client(ctx)
clientOption := option.WithHTTPClient(client)
r, err := NewReconciler(ctx, clientOption)
if err != nil {
log.Fatal(err)
}
if *printConfig {
err = r.printGroupMembersAndSettings()
if err != nil {
log.Fatal(err)
}
return
}
log.Println(" ======================= Updates =======================")
err = r.ReconcileGroups(groupsConfig.Groups)
if err != nil {
log.Fatal(err)
}
}
// Reconciler syncs the actual state of the world with the configuration.
// It does so by making use of AdminService and GroupService which are mockable
// interfaces.
type Reconciler struct {
adminService AdminService
groupService GroupService
}
func NewReconciler(ctx context.Context, clientOption option.ClientOption) (*Reconciler, error) {
as, err := NewAdminService(ctx, clientOption)
if err != nil {
return nil, err
}
gs, err := NewGroupService(ctx, clientOption)
if err != nil {
return nil, err
}
return &Reconciler{adminService: as, groupService: gs}, nil
}
func (r *Reconciler) ReconcileGroups(groups []GoogleGroup) error {
// aggregate the errors that occured and return them together in the end.
var errs []error
for _, g := range groups {
if g.EmailId == "" {
errs = append(errs, fmt.Errorf("group has no email-id: %#v", g))
}
err := r.adminService.CreateOrUpdateGroupIfNescessary(g)
if err != nil {
errs = append(errs, err)
}
err = r.groupService.UpdateGroupSettings(g)
if err != nil {
errs = append(errs, err)
}
err = r.adminService.AddOrUpdateGroupMembers(g, OwnerRole, g.Owners)
if err != nil {
errs = append(errs, err)
}
err = r.adminService.AddOrUpdateGroupMembers(g, ManagerRole, g.Managers)
if err != nil {
errs = append(errs, err)
}
err = r.adminService.AddOrUpdateGroupMembers(g, MemberRole, g.Members)
if err != nil {
errs = append(errs, err)
}
if g.Settings["ReconcileMembers"] == "true" {
members := append(g.Owners, g.Managers...)
members = append(members, g.Members...)
err = r.adminService.RemoveMembersFromGroup(g, members)
if err != nil {
errs = append(errs, err)
}
} else {
members := append(g.Owners, g.Managers...)
err = r.adminService.RemoveOwnerOrManagersFromGroup(g, members)
if err != nil {
errs = append(errs, err)
}
}
}
err := r.adminService.DeleteGroupsIfNecessary()
if err != nil {
errs = append(errs, err)
}
return utilerrors.NewAggregate(errs)
}
func (r *Reconciler) printGroupMembersAndSettings() error {
g, err := r.adminService.ListGroups()
if err != nil {
return fmt.Errorf("unable to retrieve users in domain: %w", err)
}
var groupsConfig GroupsConfig
for _, g := range g.Groups {
group := GoogleGroup{
EmailId: g.Email,
Name: g.Name,
Description: g.Description,
}
g2, err := r.groupService.Get(g.Email)
if err != nil {
return fmt.Errorf("unable to retrieve group info for group %s: %w", g.Email, err)
}
group.Settings = make(map[string]string)
group.Settings["AllowExternalMembers"] = g2.AllowExternalMembers
group.Settings["WhoCanJoin"] = g2.WhoCanJoin
group.Settings["WhoCanViewMembership"] = g2.WhoCanViewMembership
group.Settings["WhoCanViewGroup"] = g2.WhoCanViewGroup
group.Settings["WhoCanDiscoverGroup"] = g2.WhoCanDiscoverGroup
group.Settings["WhoCanInvite"] = g2.WhoCanInvite
group.Settings["WhoCanAdd"] = g2.WhoCanAdd
group.Settings["WhoCanApproveMembers"] = g2.WhoCanApproveMembers
group.Settings["WhoCanModifyMembers"] = g2.WhoCanModifyMembers
group.Settings["WhoCanModerateMembers"] = g2.WhoCanModerateMembers
group.Settings["MembersCanPostAsTheGroup"] = g2.MembersCanPostAsTheGroup
l, err := r.adminService.ListMembers(g.Email)
if err != nil {
return fmt.Errorf("unable to retrieve members in group : %w", err)
}
for _, m := range l {
switch m.Role {
case OwnerRole:
group.Owners = append(group.Owners, m.Email)
case ManagerRole:
group.Managers = append(group.Managers, m.Email)
case MemberRole:
group.Members = append(group.Members, m.Email)
}
}
groupsConfig.Groups = append(groupsConfig.Groups, group)
}
cm := genyaml.NewCommentMap("reconcile.go")
yamlSnippet, err := cm.GenYaml(groupsConfig)
if err != nil {
return fmt.Errorf("unable to generate yaml for groups : %w", err)
}
fmt.Println(yamlSnippet)
return nil
}
func (c *Config) Load(configFilePath string, confirmChanges bool) error {
log.Printf("reading config file: %s", configFilePath)
content, err := ioutil.ReadFile(configFilePath)
if err != nil {
return fmt.Errorf("error reading config file %s: %w", configFilePath, err)
}
if err = yaml.Unmarshal(content, &c); err != nil {
return fmt.Errorf("error parsing config file %s: %w", configFilePath, err)
}
if !filepath.IsAbs(c.GroupsPath) {
c.GroupsPath = filepath.Clean(filepath.Join(filepath.Dir(configFilePath), c.GroupsPath))
}
c.GroupsPath, err = filepath.Abs(c.GroupsPath)
if err != nil {
return fmt.Errorf("error converting groups-path %v to absolute path: %w", c.GroupsPath, err)
}
if c.RestrictionsPath == "" {
c.RestrictionsPath = filepath.Join(filepath.Dir(configFilePath), defaultRestrictionsFile)
}
c.RestrictionsPath, err = filepath.Abs(c.RestrictionsPath)
if err != nil {
return fmt.Errorf("error converting retrictions-path %v to absolute path: %w", c.RestrictionsPath, err)
}
c.ConfirmChanges = confirmChanges
return err
}
// Load populates the RestrictionsConfig with data parsed from path and returns
// nil if successful, or an error otherwise
func (rc *RestrictionsConfig) Load(path string) error {
log.Printf("reading restrictions config file: %s", path)
content, err := ioutil.ReadFile(path)
if err != nil {
return fmt.Errorf("error reading restrictions config file %s: %w", path, err)
}
if err = yaml.Unmarshal(content, &rc); err != nil {
return fmt.Errorf("error parsing restrictions config file %s: %w", path, err)
}
ret := make([]Restriction, 0, len(rc.Restrictions))
for _, r := range rc.Restrictions {
r.AllowedGroupsRe = make([]*regexp.Regexp, 0, len(r.AllowedGroups))
for _, g := range r.AllowedGroups {
re, err := regexp.Compile(g)
if err != nil {
return fmt.Errorf("error parsing group pattern %q for path %q: %w", g, r.Path, err)
}
r.AllowedGroupsRe = append(r.AllowedGroupsRe, re)
}
ret = append(ret, r)
}
rc.Restrictions = ret
return err
}
// readGroupsConfig starts at the rootDir and recursively walksthrough
// all directories and files. It reads the GroupsConfig from all groups.yaml
// files and verifies that the groups in GroupsConfig satisfy the
// restrictions in restrictionsConfig.
// Finally, it adds all the groups in each GroupsConfig to config.Groups.
func (gc *GroupsConfig) Load(rootDir string, restrictions *RestrictionsConfig) error {
log.Printf("reading groups.yaml files recursively at %s", rootDir)
return filepath.Walk(rootDir, func(path string, info os.FileInfo, _ error) error {
if filepath.Base(path) == "groups.yaml" {
cleanPath := strings.Trim(strings.TrimPrefix(path, rootDir), string(filepath.Separator))
log.Printf("groups: %s", cleanPath)
var (
groupsConfigAtPath GroupsConfig
content []byte
err error
)
if content, err = ioutil.ReadFile(path); err != nil {
return fmt.Errorf("error reading groups config file %s: %w", path, err)
}
if err = yaml.Unmarshal(content, &groupsConfigAtPath); err != nil {
return fmt.Errorf("error parsing groups config at %s: %w", path, err)
}
r := restrictions.GetRestrictionForPath(path, rootDir)
mergedGroups, err := mergeGroups(gc.Groups, groupsConfigAtPath.Groups, r)
if err != nil {
return fmt.Errorf("couldn't merge groups: %w", err)
}
gc.Groups = mergedGroups
}
return nil
})
}
// GetRestrictionForPath returns the first Restriction whose Path matches the
// given path relative to the given rootDir, or defaultRestriction if no
// Restriction is found
func (rc *RestrictionsConfig) GetRestrictionForPath(path, rootDir string) Restriction {
cleanPath := strings.Trim(strings.TrimPrefix(path, rootDir), string(filepath.Separator))
for _, r := range rc.Restrictions {
if match, err := doublestar.Match(r.Path, cleanPath); err == nil && match {
return r
}
}
return defaultRestriction
}
func mergeGroups(a []GoogleGroup, b []GoogleGroup, r Restriction) ([]GoogleGroup, error) {
emails := map[string]struct{}{}
for _, v := range a {
emails[v.EmailId] = struct{}{}
}
for _, v := range b {
if v.EmailId == "" {
return nil, fmt.Errorf("groups must have email-id")
}
if !matchesRegexList(v.EmailId, r.AllowedGroupsRe) {
return nil, fmt.Errorf("cannot define group %q in %q", v.EmailId, r.Path)
}
if _, ok := emails[v.EmailId]; ok {
return nil, fmt.Errorf("cannot overwrite group definitions (duplicate group name %s)", v.EmailId)
}
}
return append(a, b...), nil
}
func matchesRegexList(s string, list []*regexp.Regexp) bool {
for _, r := range list {
if r.MatchString(s) {
return true
}
}
return false
}
// accessSecretVersion accesses the payload for the given secret version if one exists
// secretVersion is of the form projects/{project}/secrets/{secret}/versions/{version}
func accessSecretVersion(secretVersion string) ([]byte, error) {
ctx := context.Background()
client, err := secretmanager.NewClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to create secretmanager client: %w", err)
}
req := &secretmanagerpb.AccessSecretVersionRequest{
Name: secretVersion,
}
result, err := client.AccessSecretVersion(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to access secret version: %w", err)
}
return result.Payload.Data, nil
}

709
groups/reconcile_test.go Normal file
View File

@ -0,0 +1,709 @@
/*
Copyright 2019 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"fmt"
"reflect"
"testing"
admin "google.golang.org/api/admin/directory/v1"
groupssettings "google.golang.org/api/groupssettings/v1"
"k8s.io/k8s.io/groups/fake"
)
// state is used to hold information needed
// to construct the state of the world.
type state struct {
adminClient AdminServiceClient
groupClient GroupServiceClient
}
// isReconciled constructs the current state of the world using adminClient
// and groupClient and checks if it is reconciled against the desiredState
// and matches it.
func (s state) isReconciled(desiredState []GoogleGroup) error {
// incrementally construct the state of the world and check
// against the desiredState.
// see if the groups that exist are reconciled or not.
res, _ := s.adminClient.ListGroups()
if !checkForAdminGroupGoogleGroupEquality(res.Groups, desiredState) {
return fmt.Errorf(
"groups do not match (email, name, description): desired: %#v, actual: %#v",
desiredState,
constructGoogleGroupFromAdminGroup(res.Groups),
)
}
// now that we know that atleast the groups that exist are the same, we
// create a map of emailID -> *admin.Group to make sure we are checking
// the same group in every iteration of the next step.
currGroups := make(map[string]*admin.Group)
for i, g := range res.Groups {
currGroups[g.Email] = res.Groups[i]
}
// provided the groups that exist are the same, check for member equality
// and groupSettings.Group equality.
for i := 0; i < len(desiredState); i++ {
// safe to assume that the EmailID will exist as a key because
// we already checked if the groups that exist are the same or
// not.
currGroup := currGroups[desiredState[i].EmailId]
desiredMembers := constructMemberListFromGoogleGroup(desiredState[i])
currentMembers, err := s.adminClient.ListMembers(currGroup.Email)
if err != nil {
return err
}
if !checkForMemberListEquality(desiredMembers, currentMembers) {
return fmt.Errorf(
"member lists do not match (email, role): desired: %#v, actual: %#v",
getMemberListInPrintableForm(desiredMembers),
getMemberListInPrintableForm(currentMembers),
)
}
currentSettings, err := s.groupClient.Get(currGroup.Email)
if err != nil {
return err
}
desiredSettings := constructGroupSettingsFromGoogleGroup(desiredState[i])
if !reflect.DeepEqual(desiredSettings, currentSettings) {
return fmt.Errorf(
"group settings do not match, desired: %#v, actual: %#v",
desiredSettings,
currentSettings,
)
}
}
return nil
}
func constructMemberListFromGoogleGroup(g GoogleGroup) []*admin.Member {
res := make([]*admin.Member, len(g.Members)+len(g.Managers)+len(g.Owners))
index := 0
for _, m := range g.Members {
res[index] = &admin.Member{Email: m, Id: m, Role: MemberRole}
index++
}
for _, m := range g.Managers {
res[index] = &admin.Member{Email: m, Id: m, Role: ManagerRole}
index++
}
for _, m := range g.Owners {
res[index] = &admin.Member{Email: m, Id: m, Role: OwnerRole}
index++
}
return res
}
func constructGroupSettingsFromGoogleGroup(g GoogleGroup) *groupssettings.Groups {
requiredSettingsDefaults := map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "INVITED_CAN_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_AND_MANAGERS",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "false",
}
for s, v := range requiredSettingsDefaults {
if _, ok := g.Settings[s]; !ok {
g.Settings[s] = v
}
}
return &groupssettings.Groups{
AllowExternalMembers: g.Settings["AllowExternalMembers"],
WhoCanJoin: g.Settings["WhoCanJoin"],
WhoCanViewMembership: g.Settings["WhoCanViewMembership"],
WhoCanViewGroup: g.Settings["WhoCanViewGroup"],
WhoCanDiscoverGroup: g.Settings["WhoCanDiscoverGroup"],
WhoCanModerateMembers: g.Settings["WhoCanModerateMembers"],
WhoCanModerateContent: g.Settings["WhoCanModerateContent"],
WhoCanPostMessage: g.Settings["WhoCanPostMessage"],
MessageModerationLevel: g.Settings["MessageModerationLevel"],
MembersCanPostAsTheGroup: g.Settings["MembersCanPostAsTheGroup"],
}
}
func constructGoogleGroupFromAdminGroup(ag []*admin.Group) []GoogleGroup {
res := make([]GoogleGroup, len(ag))
for i := 0; i < len(res); i++ {
res[i] = GoogleGroup{EmailId: ag[i].Email, Name: ag[i].Name, Description: ag[i].Description}
}
return res
}
func TestRestrictionForPath(t *testing.T) {
rc := &RestrictionsConfig{
Restrictions: []Restriction{
{
Path: "full-path-to-sig-foo/file.yaml",
AllowedGroups: []string{
"sig-foo-group-a",
"sig-foo-group-b",
},
},
},
}
testcases := []struct {
name string
path string
expectedPath string
expectedGroups []string
}{
{
name: "empty path",
path: "",
expectedPath: "*",
expectedGroups: nil,
},
{
name: "full path match",
path: "full-path-to-sig-foo/file.yaml",
expectedPath: "full-path-to-sig-foo/file.yaml",
expectedGroups: []string{
"sig-foo-group-a",
"sig-foo-group-b",
},
},
{
name: "path not found",
path: "does-not/exist.yaml",
expectedPath: "*",
expectedGroups: nil,
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
path := tc.path
root := "root" // TODO: add testcases that vary root
expected := Restriction{
Path: tc.expectedPath,
AllowedGroups: tc.expectedGroups,
}
actual := rc.GetRestrictionForPath(path, root)
if expected.Path != actual.Path {
t.Errorf("Unexpected restriction.path for %v, %v: expected %v, got %v", path, root, expected.Path, actual.Path)
}
if !reflect.DeepEqual(expected.AllowedGroups, actual.AllowedGroups) {
t.Errorf("Unexpected restriction.allowedGroups for %v, %v: expected %v, got %v", path, root, expected.AllowedGroups, actual.AllowedGroups)
}
})
}
}
func TestReconcileGroups(t *testing.T) {
config.ConfirmChanges = true
cases := []struct {
desc string
// desired state is not nescessarily the same as expected state.
// We might need the two of them to differ in cases where we want
// to test out functionality where our desired state warrants for
// a change but in actuality this change should not occur - which
// is signified via expected state. shouldConsiderExpectedState is
// what differentiates what we should consider when comparing results.
// If false - desired is same as expected. This is done because in
// most cases these two are same and we can avoid clutter.
shouldConsiderExpectedState bool
desiredState []GoogleGroup
expectedState []GoogleGroup
}{
{
desc: "state matches, nothing to reconcile",
desiredState: []GoogleGroup{
{
EmailId: "group1@email.com", Name: "group1", Description: "group1",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "CAN_REQUEST_TO_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_AND_MANAGERS",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "true",
},
Members: []string{"m1-group1@email.com"},
Managers: []string{"m2-group1@email.com"},
},
{
EmailId: "group2@email.com", Name: "group2", Description: "group2",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "INVITED_CAN_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_ONLY",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "false",
},
Members: []string{"m1-group2@email.com"},
Owners: []string{"m2-group2@email.com"},
},
},
},
{
desc: "new members added, attempt to reconcile",
desiredState: []GoogleGroup{
{
EmailId: "group1@email.com", Name: "group1", Description: "group1",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "CAN_REQUEST_TO_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_AND_MANAGERS",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "true",
},
Members: []string{"m1-group1@email.com"},
Managers: []string{"m2-group1@email.com"},
Owners: []string{"m3-group1@email.com"}, // new member
},
{
EmailId: "group2@email.com", Name: "group2", Description: "group2",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "INVITED_CAN_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_ONLY",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "false",
},
Members: []string{"m1-group2@email.com"},
Managers: []string{"m3-group2@email.com"}, // new member
Owners: []string{"m2-group2@email.com"},
},
},
},
{
desc: "members with OWNER/MANAGER role deleted from group1 and group2, attempt to reconcile",
desiredState: []GoogleGroup{
{
EmailId: "group1@email.com", Name: "group1", Description: "group1",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "CAN_REQUEST_TO_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_AND_MANAGERS",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "true",
},
Members: []string{"m1-group1@email.com"},
Managers: []string{}, // member removed
},
{
EmailId: "group2@email.com", Name: "group2", Description: "group2",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "INVITED_CAN_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_ONLY",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "false",
},
Members: []string{"m1-group2@email.com"},
Owners: []string{}, // member removed
},
},
},
{
desc: "member with MANAGER role deleted in group1 and member with MEMBER role added in group2, attempt to reconcile",
desiredState: []GoogleGroup{
{
EmailId: "group1@email.com", Name: "group1", Description: "group1",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "CAN_REQUEST_TO_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_AND_MANAGERS",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "true",
},
Members: []string{"m1-group1@email.com"},
Managers: []string{}, // member removed
},
{
EmailId: "group2@email.com", Name: "group2", Description: "group2",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "INVITED_CAN_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_ONLY",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "false",
},
Members: []string{"m1-group2@email.com", "m3-group2@email.com"}, // member added
Owners: []string{"m2-group2@email.com"},
},
},
},
{
desc: "member with MEMBER role deleted from group1 with ReconcileMembers setting enabled, attempt to reconcile",
desiredState: []GoogleGroup{
{
EmailId: "group1@email.com", Name: "group1", Description: "group1",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "CAN_REQUEST_TO_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_AND_MANAGERS",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "true",
"ReconcileMembers": "true",
},
Members: []string{}, // member removed
Managers: []string{"m2-group1@email.com"},
},
{
EmailId: "group2@email.com", Name: "group2", Description: "group2",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "INVITED_CAN_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_ONLY",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "false",
},
Members: []string{"m1-group2@email.com"},
Owners: []string{"m2-group2@email.com"},
},
},
},
{
desc: "member with MEMBER role deleted from group1 with ReconcileMembers setting disabled, no change",
desiredState: []GoogleGroup{
{
EmailId: "group1@email.com", Name: "group1", Description: "group1",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "CAN_REQUEST_TO_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_AND_MANAGERS",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "true",
"ReconcileMembers": "false",
},
Members: []string{}, // member removed
Managers: []string{"m2-group1@email.com"},
},
{
EmailId: "group2@email.com", Name: "group2", Description: "group2",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "INVITED_CAN_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_ONLY",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "false",
},
Members: []string{"m1-group2@email.com"},
Owners: []string{"m2-group2@email.com"},
},
},
shouldConsiderExpectedState: true,
expectedState: []GoogleGroup{
{
EmailId: "group1@email.com", Name: "group1", Description: "group1",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "CAN_REQUEST_TO_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_AND_MANAGERS",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "true",
"ReconcileMembers": "false", // setting disabled.
},
Members: []string{"m1-group1@email.com"}, // member not removed.
Managers: []string{"m2-group1@email.com"},
},
{
EmailId: "group2@email.com", Name: "group2", Description: "group2",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "INVITED_CAN_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_ONLY",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "false",
},
Members: []string{"m1-group2@email.com"},
Owners: []string{"m2-group2@email.com"},
},
},
},
{
desc: "group2 deleted, attempt to reconcile",
desiredState: []GoogleGroup{
{
EmailId: "group1@email.com", Name: "group1", Description: "group1",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "CAN_REQUEST_TO_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_AND_MANAGERS",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "true",
},
Members: []string{"m1-group1@email.com"},
Managers: []string{"m2-group1@email.com"},
},
},
},
{
desc: "group3 added, member with OWNER role added to group1 and member with MANAGER role added to group2",
desiredState: []GoogleGroup{
{
EmailId: "group1@email.com", Name: "group1", Description: "group1",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "CAN_REQUEST_TO_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_AND_MANAGERS",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "true",
},
Members: []string{"m1-group1@email.com"},
Managers: []string{"m2-group1@email.com"},
Owners: []string{"m3-group1@email.com"}, // member added
},
{
EmailId: "group2@email.com", Name: "group2", Description: "group2",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "INVITED_CAN_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_ONLY",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "false",
},
Members: []string{"m1-group2@email.com"},
Owners: []string{"m2-group2@email.com"},
Managers: []string{"m3-group2@email.com"}, // member added
},
{
// group added
EmailId: "group3@email.com", Name: "group3", Description: "group3",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "CAN_REQUEST_TO_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_AND_MANAGERS",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "true",
},
Members: []string{"m1-group3@email.com"},
Managers: []string{"m2-group3@email.com"},
Owners: []string{"m3-group3@email.com"},
},
},
},
{
desc: "move member from MEMBER role in group1 to OWNER role.",
desiredState: []GoogleGroup{
{
EmailId: "group1@email.com", Name: "group1", Description: "group1",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "CAN_REQUEST_TO_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_AND_MANAGERS",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "true",
},
Members: []string{},
Managers: []string{"m2-group1@email.com"},
Owners: []string{"m1-group1@email.com"},
},
{
EmailId: "group2@email.com", Name: "group2", Description: "group2",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "INVITED_CAN_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_ONLY",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "false",
},
Members: []string{"m1-group2@email.com"},
Owners: []string{"m2-group2@email.com"},
},
},
},
{
desc: "move member from OWNER role in group2 to MEMBER role.",
desiredState: []GoogleGroup{
{
EmailId: "group1@email.com", Name: "group1", Description: "group1",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "CAN_REQUEST_TO_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_AND_MANAGERS",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "true",
},
Members: []string{"m1-group1@email.com"},
Managers: []string{"m2-group1@email.com"},
},
{
EmailId: "group2@email.com", Name: "group2", Description: "group2",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "INVITED_CAN_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_ONLY",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "false",
},
Members: []string{"m1-group2@email.com", "m2-group2@email.com"},
Owners: []string{},
},
},
},
}
errFunc := func(err error) bool {
return err != nil
}
for _, c := range cases {
groupsConfig.Groups = c.desiredState
fakeAdminClient := fake.NewAugmentedFakeAdminServiceClient()
fakeGroupClient := fake.NewAugmentedFakeGroupServiceClient()
fakeAdminClient.RegisterCallback(func(groupKey string) {
_, ok := fakeGroupClient.GsGroups[groupKey]
if !ok {
fakeGroupClient.GsGroups[groupKey] = &groupssettings.Groups{}
}
})
adminSvc, _ := NewAdminServiceWithClientAndErrFunc(fakeAdminClient, errFunc)
groupSvc, _ := NewGroupServiceWithClientAndErrFunc(fakeGroupClient, errFunc)
reconciler := &Reconciler{adminService: adminSvc, groupService: groupSvc}
err := reconciler.ReconcileGroups(c.desiredState)
if err != nil {
t.Errorf("error reconciling groups for case %s: %s", c.desc, err.Error())
}
s := state{adminClient: fakeAdminClient, groupClient: fakeGroupClient}
var expectedState []GoogleGroup
if c.shouldConsiderExpectedState {
expectedState = c.expectedState
} else {
expectedState = c.desiredState
}
if err = s.isReconciled(expectedState); err != nil {
t.Errorf("reconciliation unsuccessful for case %s: %w", c.desc, err)
}
}
}

28
groups/restrictions.yaml Normal file
View File

@ -0,0 +1,28 @@
restrictions:
- path: "committee-code-of-conduct/groups.yaml"
allowedGroups:
- "^code-of-conduct@knative.team$"
- path: "committee-trademark/groups.yaml"
allowedGroups:
- "^trademark@knative.team$"
- path: "committee-oversight/groups.yaml"
allowedGroups:
- "^toc@knative.team$"
- path: "committee-steering/groups.yaml"
allowedGroups:
- "^steering@knative.team$"
- "^elections@knative.team$"
- path: "wg-productivity/groups.yaml"
allowedGroups:
- "^gke-security-groups@knative.team$"
- "^kn-infra-gcp-org-admins@knative.team$"
- "^k8s-infra-rbac-[a-z]{0,8}@knative.team$"
- "^automation@knative.team$"
- path: "wg-security/groups.yaml"
allowedGroups:
- "^security@knative.team$"
- path: "groups.yaml"
allowedGroups:
- "^wg-leads@knative.team$"
- "^calendar-maintainers@knative.team$"
- path: "**/*" # prevent any other file from containing anything

545
groups/service.go Normal file
View File

@ -0,0 +1,545 @@
/*
Copyright 2021 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"reflect"
"strings"
admin "google.golang.org/api/admin/directory/v1"
"google.golang.org/api/googleapi"
groupssettings "google.golang.org/api/groupssettings/v1"
"google.golang.org/api/option"
utilerrors "k8s.io/apimachinery/pkg/util/errors"
)
const (
OwnerRole = "OWNER"
ManagerRole = "MANAGER"
MemberRole = "MEMBER"
)
// AdminService provides functionality to perform high level
// tasks using a AdminServiceClient.
type AdminService interface {
AddOrUpdateGroupMembers(group GoogleGroup, role string, members []string) error
CreateOrUpdateGroupIfNescessary(group GoogleGroup) error
DeleteGroupsIfNecessary() error
RemoveOwnerOrManagersFromGroup(group GoogleGroup, members []string) error
RemoveMembersFromGroup(group GoogleGroup, members []string) error
// ListGroup here is a proxy to the ListGroups method of the underlying
// AdminServiceClient being used.
ListGroups() (*admin.Groups, error)
// ListMembers here is a proxy to the ListMembers method of the underlying
// AdminServiceClient being used.
ListMembers(groupKey string) ([]*admin.Member, error)
}
// GroupService provides functionality to perform high level
// tasks using a GroupServiceClient.
type GroupService interface {
UpdateGroupSettings(group GoogleGroup) error
// Get here is a proxy to the Get method of the
// underlying GroupServiceClient
Get(groupUniqueID string) (*groupssettings.Groups, error)
}
func NewAdminService(ctx context.Context, clientOption option.ClientOption) (AdminService, error) {
client, err := NewAdminServiceClient(ctx, clientOption)
if err != nil {
return nil, err
}
return NewAdminServiceWithClient(client)
}
func NewAdminServiceWithClient(client AdminServiceClient) (AdminService, error) {
errFunc := func(err error) bool {
apierr, ok := err.(*googleapi.Error)
return ok && apierr.Code == http.StatusNotFound
}
return &adminService{
client: client,
checkForAPIErr404: errFunc,
}, nil
}
func NewAdminServiceWithClientAndErrFunc(client AdminServiceClient, errFunc clientErrCheckFunc) (AdminService, error) {
return &adminService{
client: client,
checkForAPIErr404: errFunc,
}, nil
}
func NewGroupService(ctx context.Context, clientOption option.ClientOption) (GroupService, error) {
client, err := NewGroupServiceClient(ctx, clientOption)
if err != nil {
return nil, err
}
return NewGroupServiceWithClient(client)
}
func NewGroupServiceWithClient(client GroupServiceClient) (GroupService, error) {
errFunc := func(err error) bool {
apierr, ok := err.(*googleapi.Error)
return ok && apierr.Code == http.StatusNotFound
}
return &groupService{
client: client,
checkForAPIErr404: errFunc,
}, nil
}
func NewGroupServiceWithClientAndErrFunc(client GroupServiceClient, errFunc clientErrCheckFunc) (GroupService, error) {
return &groupService{
client: client,
checkForAPIErr404: errFunc,
}, nil
}
// clientErrCheckFunc is stubbed out here for testing
// purposes in order to implement custome error checking
// logic. This function is used to check errors returned
// by the client. The boolean return signifies if the
// check for the error passed or not.
type clientErrCheckFunc func(error) bool
type adminService struct {
client AdminServiceClient
checkForAPIErr404 clientErrCheckFunc
}
// AddOrUpdateGroupMembers first lists all members that are part of group. Based on this list and the
// members, it will update the member in the group (if needed) or if the member is not found in the
// list, it will create the member.
func (as *adminService) AddOrUpdateGroupMembers(group GoogleGroup, role string, members []string) error {
if *verbose {
log.Printf("adminService.AddOrUpdateGroupMembers %s %s %v", group.EmailId, role, members)
}
l, err := as.client.ListMembers(group.EmailId)
if err != nil {
if as.checkForAPIErr404(err) {
log.Printf("skipping adding members to group %q as it has not yet been created\n", group.EmailId)
return nil
}
return fmt.Errorf("unable to retrieve members in group %q: %w", group.EmailId, err)
}
// aggregate the errors that occured and return them together in the end.
var errs []error
for _, memberEmailId := range members {
var member *admin.Member
for _, m := range l {
if EmailAddressEquals(m.Email, memberEmailId) {
member = m
break
}
}
if member != nil {
// update if necessary
if member.Role != role {
member.Role = role
if config.ConfirmChanges {
log.Printf("Updating %s to %q as a %s\n", memberEmailId, group.EmailId, role)
_, err := as.client.UpdateMember(group.EmailId, member.Email, member)
if err != nil {
logErr := fmt.Errorf("unable to update %s in %q as %s: %w", memberEmailId, group.EmailId, role, err)
log.Printf("%s\n", logErr)
errs = append(errs, logErr)
continue
}
log.Printf("Updated %s to %q as a %s\n", memberEmailId, group.EmailId, role)
} else {
log.Printf("dry-run: would update %s to %q as %s\n", memberEmailId, group.EmailId, role)
}
}
continue
}
member = &admin.Member{
Email: memberEmailId,
Role: role,
}
// We did not find the person in the google group, so we add them
if config.ConfirmChanges {
log.Printf("Adding %s to %q as a %s\n", memberEmailId, group.EmailId, role)
_, err := as.client.InsertMember(group.EmailId, member)
if err != nil {
logErr := fmt.Errorf("unable to add %s to %q as %s: %w", memberEmailId, group.EmailId, role, err)
log.Printf("%s\n", logErr)
errs = append(errs, logErr)
continue
}
log.Printf("Added %s to %q as a %s\n", memberEmailId, group.EmailId, role)
} else {
log.Printf("dry-run: would add %s to %q as %s\n", memberEmailId, group.EmailId, role)
}
}
return utilerrors.NewAggregate(errs)
}
// CreateOrUpdateGroupIfNescessary will create a group if the provided group's email ID
// does not already exist. If it exists, it will update the group if needed to match the
// provided group.
func (as *adminService) CreateOrUpdateGroupIfNescessary(group GoogleGroup) error {
if *verbose {
log.Printf("adminService.CreateOrUpdateGroupIfNecessary %s", group.EmailId)
}
grp, err := as.client.GetGroup(group.EmailId)
if err != nil {
if as.checkForAPIErr404(err) {
if !config.ConfirmChanges {
log.Printf("dry-run: would create group %q\n", group.EmailId)
} else {
log.Printf("Trying to create group: %q\n", group.EmailId)
g := admin.Group{
Email: group.EmailId,
}
if group.Name != "" {
g.Name = group.Name
}
if group.Description != "" {
g.Description = group.Description
}
g4, err := as.client.InsertGroup(&g)
if err != nil {
return fmt.Errorf("unable to add new group %q: %w", group.EmailId, err)
}
log.Printf("> Successfully created group %s\n", g4.Email)
}
} else {
return fmt.Errorf("unable to fetch group %q: %w", group.EmailId, err)
}
} else {
if group.Name != "" && grp.Name != group.Name ||
group.Description != "" && grp.Description != group.Description {
if !config.ConfirmChanges {
log.Printf("dry-run: would update group name/description %q\n", group.EmailId)
} else {
log.Printf("Trying to update group: %q\n", group.EmailId)
g := admin.Group{
Email: group.EmailId,
}
if group.Name != "" {
g.Name = group.Name
}
if group.Description != "" {
g.Description = group.Description
}
g4, err := as.client.UpdateGroup(group.EmailId, &g)
if err != nil {
return fmt.Errorf("unable to update group %q: %w", group.EmailId, err)
}
log.Printf("> Successfully updated group %s\n", g4.Email)
}
}
}
return nil
}
// DeleteGroupsIfNecessary checks against the groups config provided by the user. It
// first lists all existing groups, if a group in this list does not appear in the
// provided group config, it will delete this group to match the desired state.
func (as *adminService) DeleteGroupsIfNecessary() error {
g, err := as.client.ListGroups()
if err != nil {
return fmt.Errorf("unable to retrieve users in domain: %w", err)
}
// aggregate the errors that occured and return them together in the end.
var errs []error
for _, g := range g.Groups {
found := false
for _, g2 := range groupsConfig.Groups {
if EmailAddressEquals(g2.EmailId, g.Email) {
found = true
break
}
}
if found {
continue
}
// We did not find the group in our groups.xml, so delete the group
if config.ConfirmChanges {
log.Printf("Deleting group %s", g.Email)
err := as.client.DeleteGroup(g.Email)
if err != nil {
errs = append(errs, fmt.Errorf("unable to remove group %s : %w", g.Email, err))
continue
}
log.Printf("Removed group %s\n", g.Email)
} else {
log.Printf("dry-run: would remove group %s\n", g.Email)
}
}
return utilerrors.NewAggregate(errs)
}
// RemoveOwnerOrManagersFromGroup lists members of the group and checks against the list of members
// passed. If a member from the retrieved list of members does not exist in the passed list of members,
// this member is removed - provided this member had a OWNER/MANAGER role.
func (as *adminService) RemoveOwnerOrManagersFromGroup(group GoogleGroup, members []string) error {
if *verbose {
log.Printf("adminService.RemoveOwnerOrManagersGroup %s %v", group.EmailId, members)
}
l, err := as.client.ListMembers(group.EmailId)
if err != nil {
if as.checkForAPIErr404(err) {
log.Printf("skipping removing members group %q as group has not yet been created\n", group.EmailId)
return nil
}
return fmt.Errorf("unable to retrieve members in group %q: %w", group.EmailId, err)
}
// aggregate the errors that occured and return them together in the end.
var errs []error
for _, m := range l {
found := false
for _, m2 := range members {
if EmailAddressEquals(m2, m.Email) {
found = true
break
}
}
// If a member m exists in our desired list of members, do nothing.
// However, if this member m does not exist in our desired list of
// members but is in the role of a MEMBER (non OWNER/MANAGER), still
// do nothing since we care only about OWNER/MANAGER roles here.
if found || m.Role == MemberRole {
continue
}
// a person was deleted from a group, let's remove them
if config.ConfirmChanges {
log.Printf("Removing %s from %q as OWNER or MANAGER\n", m.Email, group.EmailId)
err := as.client.DeleteMember(group.EmailId, m.Id)
if err != nil {
logErr := fmt.Errorf("unable to remove %s from %q as a %s: %w", m.Email, group.EmailId, m.Role, err)
log.Printf("%s\n", logErr)
errs = append(errs, logErr)
continue
}
log.Printf("Removed %s from %q as OWNER or MANAGER\n", m.Email, group.EmailId)
} else {
log.Printf("dry-run: would remove %s from %q as OWNER or MANAGER\n", m.Email, group.EmailId)
}
}
return utilerrors.NewAggregate(errs)
}
// RemoveMembersFromGroup lists members of the group and checks against the list of members passed.
// If a member from the retrieved list of members does not exist in the passed list of members, this
// member is removed. Unlike RemoveOwnerOrManagersFromGroup, RemoveMembersFromGroup will remove the
// member regardless of the role that the member held.
func (as *adminService) RemoveMembersFromGroup(group GoogleGroup, members []string) error {
if *verbose {
log.Printf("adminService.RemoveMembersFromGroup %s %v", group.EmailId, members)
}
l, err := as.client.ListMembers(group.EmailId)
if err != nil {
if as.checkForAPIErr404(err) {
log.Printf("skipping removing members group %q as group has not yet been created\n", group.EmailId)
return nil
}
return fmt.Errorf("unable to retrieve members in group %q: %w", group.EmailId, err)
}
// aggregate the errors that occured and return them together in the end.
var errs []error
for _, m := range l {
found := false
for _, m2 := range members {
if EmailAddressEquals(m2, m.Email) {
found = true
break
}
}
if found {
continue
}
// a person was deleted from a group, let's remove them
if config.ConfirmChanges {
log.Printf("Removing %s from %q as a %s\n", m.Email, group.EmailId, m.Role)
err := as.client.DeleteMember(group.EmailId, m.Id)
if err != nil {
logErr := fmt.Errorf("unable to remove %s from %q as a %s: %w", m.Email, group.EmailId, m.Role, err)
log.Printf("%s\n", logErr)
errs = append(errs, logErr)
continue
}
log.Printf("Removed %s from %q as a %s\n", m.Email, group.EmailId, m.Role)
} else {
log.Printf("dry-run: would remove %s from %q as a %s\n", m.Email, group.EmailId, m.Role)
}
}
return utilerrors.NewAggregate(errs)
}
// ListGroups lists all the groups available.
func (as *adminService) ListGroups() (*admin.Groups, error) {
return as.client.ListGroups()
}
// ListMembers lists all the members of a group with a particular groupKey.
func (as *adminService) ListMembers(groupKey string) ([]*admin.Member, error) {
return as.client.ListMembers(groupKey)
}
var _ AdminService = (*adminService)(nil)
type groupService struct {
client GroupServiceClient
checkForAPIErr404 clientErrCheckFunc
}
// UpdateGroupSettings updates the groupsettings.Groups corresponding to the
// passed group based on what the current state of the groupsetting.Groups is.
func (gs *groupService) UpdateGroupSettings(group GoogleGroup) error {
if *verbose {
log.Printf("groupService.UpdateGroupSettings %s", group.EmailId)
}
g2, err := gs.client.Get(group.EmailId)
if err != nil {
if gs.checkForAPIErr404(err) {
log.Printf("skipping updating group settings as group %q has not yet been created\n", group.EmailId)
return nil
}
return fmt.Errorf("unable to retrieve group info for group %q: %w", group.EmailId, err)
}
var (
haveSettings groupssettings.Groups
wantSettings groupssettings.Groups
)
// We copy the settings we get from the API into haveSettings, and then copy
// it again into wantSettings so we have a version we can manipulate.
deepCopySettings(&g2, &haveSettings)
deepCopySettings(&haveSettings, &wantSettings)
// This sets safe/sane defaults
wantSettings.AllowExternalMembers = "true"
wantSettings.WhoCanJoin = "INVITED_CAN_JOIN"
wantSettings.WhoCanViewMembership = "ALL_MANAGERS_CAN_VIEW"
wantSettings.WhoCanViewGroup = "ALL_MEMBERS_CAN_VIEW"
wantSettings.WhoCanDiscoverGroup = "ALL_IN_DOMAIN_CAN_DISCOVER"
wantSettings.WhoCanModerateMembers = "OWNERS_AND_MANAGERS"
wantSettings.WhoCanModerateContent = "OWNERS_AND_MANAGERS"
wantSettings.WhoCanPostMessage = "ALL_MEMBERS_CAN_POST"
wantSettings.MessageModerationLevel = "MODERATE_NONE"
wantSettings.MembersCanPostAsTheGroup = "false"
for key, value := range group.Settings {
switch key {
case "AllowExternalMembers":
wantSettings.AllowExternalMembers = value
case "AllowWebPosting":
wantSettings.AllowWebPosting = value
case "WhoCanJoin":
wantSettings.WhoCanJoin = value
case "WhoCanViewMembership":
wantSettings.WhoCanViewMembership = value
case "WhoCanViewGroup":
wantSettings.WhoCanViewGroup = value
case "WhoCanDiscoverGroup":
wantSettings.WhoCanDiscoverGroup = value
case "WhoCanModerateMembers":
wantSettings.WhoCanModerateMembers = value
case "WhoCanPostMessage":
wantSettings.WhoCanPostMessage = value
case "MessageModerationLevel":
wantSettings.MessageModerationLevel = value
case "MembersCanPostAsTheGroup":
wantSettings.MembersCanPostAsTheGroup = value
}
}
if !reflect.DeepEqual(&haveSettings, &wantSettings) {
if config.ConfirmChanges {
_, err := gs.client.Patch(group.EmailId, &wantSettings)
if err != nil {
return fmt.Errorf("unable to update group info for group %q: %w", group.EmailId, err)
}
log.Printf("> Successfully updated group settings for %q to allow external members and other security settings\n", group.EmailId)
} else {
log.Printf("dry-run: would update group settings for %q\n", group.EmailId)
log.Printf("dry-run: current settings %+q", haveSettings)
log.Printf("dry-run: desired settings %+q", wantSettings)
}
}
return nil
}
// Get retrieves the group settings of a group with groupUniqueID.
func (gs *groupService) Get(groupUniqueID string) (*groupssettings.Groups, error) {
return gs.client.Get(groupUniqueID)
}
var _ GroupService = (*groupService)(nil)
// DeepCopy deepcopies a to b using json marshaling. This discards fields like
// the server response that don't have a specifc json field name.
func deepCopySettings(a, b interface{}) {
byt, _ := json.Marshal(a)
json.Unmarshal(byt, b)
}
// EmailAddressEquals checks equivalence between two e-mail addresses according
// to the following rules:
// - email addresses are case-insensitive (e.g. FOO@bar.com == foo@bar.com)
// - local parts are dot-inensitive (e.g. foo@bar.com == f.o.o@bar.com)
func EmailAddressEquals(a, b string) bool {
// if they match case-insensitive, don't bother checking dot-insensitive
if strings.EqualFold(a, b) {
return true
}
aParts := strings.Split(a, "@")
bParts := strings.Split(b, "@")
// These aren't valid e-mail addresses if they don't have exactly one @
// and we already know they're not case-insensitively equal, so return false
if len(aParts) != 2 || len(bParts) != 2 {
return false
}
// strip dots from local parts and case-insensitively compare the resulting
// email addresses
aLocal := strings.ReplaceAll(aParts[0], ".", "")
bLocal := strings.ReplaceAll(bParts[0], ".", "")
return strings.EqualFold(aLocal+"@"+aParts[1], bLocal+"@"+bParts[1])
}

644
groups/service_test.go Normal file
View File

@ -0,0 +1,644 @@
/*
Copyright 2021 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"reflect"
"testing"
admin "google.golang.org/api/admin/directory/v1"
groupssettings "google.golang.org/api/groupssettings/v1"
"k8s.io/k8s.io/groups/fake"
)
// This checks for equality of two member lists based on two things:
// 1. Email ID
// 2. Role
func checkForMemberListEquality(a, b []*admin.Member) bool {
if len(a) != len(b) {
return false
}
aMap := make(map[string]*admin.Member)
bMap := make(map[string]*admin.Member)
for i := 0; i < len(b); i++ {
aMap[a[i].Email] = a[i]
bMap[b[i].Email] = b[i]
}
for aEmail, aMember := range aMap {
inB, ok := bMap[aEmail]
if !ok {
return false
}
if inB.Role != aMember.Role {
return false
}
}
return true
}
// This checks for equality of two group lists based on three things:
// 1. Email ID
// 2. Name
// 3. Descirption
func checkForGroupListEquality(a, b []*admin.Group) bool {
if len(a) != len(b) {
return false
}
aMap := make(map[string]*admin.Group)
bMap := make(map[string]*admin.Group)
for i := 0; i < len(b); i++ {
aMap[a[i].Email] = a[i]
bMap[b[i].Email] = b[i]
}
for aEmail, aGroup := range aMap {
inB, ok := bMap[aEmail]
if !ok {
return false
}
if inB.Name != aGroup.Name || inB.Description != aGroup.Description {
return false
}
}
return true
}
// This checks for equality of one admin.Group list and one GoogleGroup list based on 3 things:
// 1. Email ID
// 2. Name
// 3. Descirption
func checkForAdminGroupGoogleGroupEquality(a []*admin.Group, b []GoogleGroup) bool {
if len(a) != len(b) {
return false
}
aMap := make(map[string]*admin.Group)
bMap := make(map[string]GoogleGroup)
for i := 0; i < len(b); i++ {
aMap[a[i].Email] = a[i]
bMap[b[i].EmailId] = b[i]
}
for aEmail, aGroup := range aMap {
inB, ok := bMap[aEmail]
if !ok {
return false
}
if inB.Name != aGroup.Name || inB.Description != aGroup.Description {
return false
}
}
return true
}
func getMemberListInPrintableForm(members []*admin.Member) []admin.Member {
res := make([]admin.Member, len(members))
for i := 0; i < len(res); i++ {
res[i] = *members[i]
}
return res
}
func getGroupListInPrintableForm(groups []*admin.Group) []admin.Group {
res := make([]admin.Group, len(groups))
for i := 0; i < len(res); i++ {
res[i] = *groups[i]
}
return res
}
func TestAddOrUpdateGroupMembers(t *testing.T) {
config.ConfirmChanges = true
cases := []struct {
desc string
g GoogleGroup
members []string
expectedMembers []*admin.Member
role string
}{
{
desc: "all members already exist, no create/update",
g: GoogleGroup{EmailId: "group1@email.com"},
members: []string{"m1-group1@email.com"},
expectedMembers: []*admin.Member{
{Email: "m1-group1@email.com", Role: MemberRole},
{Email: "m2-group1@email.com", Role: ManagerRole},
},
role: MemberRole,
},
{
desc: "new members to add, create operation",
g: GoogleGroup{EmailId: "group1@email.com"},
members: []string{"new-group1@email.com"},
expectedMembers: []*admin.Member{
{Email: "m1-group1@email.com", Role: MemberRole},
{Email: "m2-group1@email.com", Role: ManagerRole},
{Email: "new-group1@email.com", Role: MemberRole},
},
role: MemberRole,
},
{
desc: "change member role, update operation",
g: GoogleGroup{EmailId: "group1@email.com"},
members: []string{"m1-group1@email.com"},
expectedMembers: []*admin.Member{
{Email: "m1-group1@email.com", Role: OwnerRole},
{Email: "m2-group1@email.com", Role: ManagerRole},
},
role: OwnerRole,
},
}
errFunc := func(err error) bool {
return err != nil
}
for _, c := range cases {
fakeClient := fake.NewAugmentedFakeAdminServiceClient()
adminSvc, err := NewAdminServiceWithClientAndErrFunc(fakeClient, errFunc)
if err != nil {
t.Errorf("error creating client %w", err)
}
err = adminSvc.AddOrUpdateGroupMembers(c.g, c.role, c.members)
if err != nil {
t.Errorf("error while executing AddOrUpdateGroupMembers for case %s: %s", c.desc, err.Error())
}
result, err := fakeClient.ListMembers(c.g.EmailId)
if err != nil {
t.Errorf("error while listing members for groupKey %s and case %s: %w", c.g.EmailId, c.desc, err)
}
if !checkForMemberListEquality(result, c.expectedMembers) {
t.Errorf("unexpected list of members for %s, expected: %#v, got: %#v",
c.desc,
getMemberListInPrintableForm(c.expectedMembers),
getMemberListInPrintableForm(result),
)
}
}
}
func TestCreateOrUpdateGroupIfNescessary(t *testing.T) {
config.ConfirmChanges = true
cases := []struct {
desc string
g GoogleGroup
expectedGroups []*admin.Group
}{
{
desc: "group already exists, do nothing",
g: GoogleGroup{EmailId: "group1@email.com", Name: "group1", Description: "group1"},
expectedGroups: []*admin.Group{
{Email: "group1@email.com", Name: "group1", Description: "group1"},
{Email: "group2@email.com", Name: "group2", Description: "group2"},
},
},
{
desc: "group does not exist, add group",
g: GoogleGroup{EmailId: "group3@email.com", Name: "group3", Description: "group3"},
expectedGroups: []*admin.Group{
{Email: "group1@email.com", Name: "group1", Description: "group1"},
{Email: "group2@email.com", Name: "group2", Description: "group2"},
{Email: "group3@email.com", Name: "group3", Description: "group3"},
},
},
{
desc: "group exists, but group name was modified, update group",
g: GoogleGroup{EmailId: "group1@email.com", Name: "group1New", Description: "group1"},
expectedGroups: []*admin.Group{
{Email: "group1@email.com", Name: "group1New", Description: "group1"},
{Email: "group2@email.com", Name: "group2", Description: "group2"},
},
},
{
desc: "group exists, but group description was modified, update group",
g: GoogleGroup{EmailId: "group1@email.com", Name: "group1", Description: "group1New"},
expectedGroups: []*admin.Group{
{Email: "group1@email.com", Name: "group1", Description: "group1New"},
{Email: "group2@email.com", Name: "group2", Description: "group2"},
},
},
}
errFunc := func(err error) bool {
return err != nil
}
for _, c := range cases {
fakeClient := fake.NewAugmentedFakeAdminServiceClient()
adminSvc, err := NewAdminServiceWithClientAndErrFunc(fakeClient, errFunc)
if err != nil {
t.Errorf("error creating client %w", err)
}
err = adminSvc.CreateOrUpdateGroupIfNescessary(c.g)
if err != nil {
t.Errorf("error while executing CreateOrUpdateGroupIfNescessary for case %s: %s", c.desc, err.Error())
}
result, err := fakeClient.ListGroups()
if err != nil {
t.Errorf("error while listing groups for case %s: %w", c.desc, err)
}
if !checkForGroupListEquality(result.Groups, c.expectedGroups) {
t.Errorf("unexpected list of groups for %s, expected: %#v, got: %#v",
c.desc,
getGroupListInPrintableForm(c.expectedGroups),
getGroupListInPrintableForm(result.Groups),
)
}
}
}
func TestDeleteGroupsIfNecessary(t *testing.T) {
config.ConfirmChanges = true
cases := []struct {
desc string
desiredState []GoogleGroup
}{
{
desc: "states match, nothing to reconcile",
desiredState: []GoogleGroup{
{EmailId: "group1@email.com", Name: "group1", Description: "group1"},
{EmailId: "group2@email.com", Name: "group2", Description: "group2"},
},
},
{
desc: "mismatch in desired state, delete group2",
desiredState: []GoogleGroup{
{EmailId: "group1@email.com", Name: "group1", Description: "group1"},
},
},
}
errFunc := func(err error) bool {
return err != nil
}
for _, c := range cases {
fakeClient := fake.NewAugmentedFakeAdminServiceClient()
adminSvc, err := NewAdminServiceWithClientAndErrFunc(fakeClient, errFunc)
if err != nil {
t.Errorf("error creating client %w", err)
}
groupsConfig.Groups = c.desiredState
err = adminSvc.DeleteGroupsIfNecessary()
if err != nil {
t.Errorf("error while executing DeleteGroupsIfNecessary for case %s: %s", c.desc, err.Error())
}
result, err := fakeClient.ListGroups()
if err != nil {
t.Errorf("error while listing groups for case %s: %w", c.desc, err)
}
if !checkForAdminGroupGoogleGroupEquality(result.Groups, c.desiredState) {
t.Errorf("unexpected list of groups for %s, expected: %#v, got: %#v",
c.desc,
c.desiredState,
getGroupListInPrintableForm(result.Groups),
)
}
}
}
func TestRemoveOwnerOrManagersFromGroup(t *testing.T) {
config.ConfirmChanges = true
cases := []struct {
desc string
g GoogleGroup
desiredState []string
expectedMembers []*admin.Member
}{
{
desc: "state matches, no deletion",
g: GoogleGroup{EmailId: "group1@email.com"},
desiredState: []string{"m1-group1@email.com", "m2-group1@email.com"},
expectedMembers: []*admin.Member{
{Email: "m1-group1@email.com", Role: MemberRole},
{Email: "m2-group1@email.com", Role: ManagerRole},
},
},
{
desc: "state does not match, but member to delete has MEMBER role, skip deletion",
g: GoogleGroup{EmailId: "group1@email.com"},
desiredState: []string{"m2-group1@email.com"},
expectedMembers: []*admin.Member{
{Email: "m1-group1@email.com", Role: MemberRole},
{Email: "m2-group1@email.com", Role: ManagerRole},
},
},
{
desc: "state does not match, member to delete is OWNER/MANAGER, perform deletion",
g: GoogleGroup{EmailId: "group1@email.com"},
desiredState: []string{"m1-group1@email.com"},
expectedMembers: []*admin.Member{
{Email: "m1-group1@email.com", Role: MemberRole},
},
},
}
errFunc := func(err error) bool {
return err != nil
}
for _, c := range cases {
fakeClient := fake.NewAugmentedFakeAdminServiceClient()
adminSvc, err := NewAdminServiceWithClientAndErrFunc(fakeClient, errFunc)
if err != nil {
t.Errorf("error creating client %w", err)
}
err = adminSvc.RemoveOwnerOrManagersFromGroup(c.g, c.desiredState)
if err != nil {
t.Errorf("error while executing RemoveOwnerOrManagersFromGroup for case %s: %s", c.desc, err.Error())
}
result, err := fakeClient.ListMembers(c.g.EmailId)
if err != nil {
t.Errorf("error while listing members for groupKey %s and case %s: %w", c.g.EmailId, c.desc, err)
}
if !checkForMemberListEquality(result, c.expectedMembers) {
t.Errorf("unexpected list of members for %s, expected: %#v, got: %#v",
c.desc,
getMemberListInPrintableForm(c.expectedMembers),
getMemberListInPrintableForm(result),
)
}
}
}
func TestRemoveMembersFromGroup(t *testing.T) {
config.ConfirmChanges = true
cases := []struct {
desc string
g GoogleGroup
desiredState []string
expectedMembers []*admin.Member
}{
{
desc: "state matches, no deletion",
g: GoogleGroup{EmailId: "group1@email.com"},
desiredState: []string{"m1-group1@email.com", "m2-group1@email.com"},
expectedMembers: []*admin.Member{
{Email: "m1-group1@email.com", Role: MemberRole},
{Email: "m2-group1@email.com", Role: ManagerRole},
},
},
{
desc: "state does not match, member to delete in MEMBER role, perform deletion",
g: GoogleGroup{EmailId: "group1@email.com"},
desiredState: []string{"m2-group1@email.com"},
expectedMembers: []*admin.Member{
{Email: "m2-group1@email.com", Role: ManagerRole},
},
},
{
desc: "state does not match, member to delete is OWNER/MANAGER, perform deletion",
g: GoogleGroup{EmailId: "group1@email.com"},
desiredState: []string{"m1-group1@email.com"},
expectedMembers: []*admin.Member{
{Email: "m1-group1@email.com", Role: MemberRole},
},
},
}
errFunc := func(err error) bool {
return err != nil
}
for _, c := range cases {
fakeClient := fake.NewAugmentedFakeAdminServiceClient()
adminSvc, err := NewAdminServiceWithClientAndErrFunc(fakeClient, errFunc)
if err != nil {
t.Errorf("error creating client %w", err)
}
err = adminSvc.RemoveMembersFromGroup(c.g, c.desiredState)
if err != nil {
t.Errorf("error while executing RemoveMembersFromGroup for case %s: %s", c.desc, err.Error())
}
result, err := fakeClient.ListMembers(c.g.EmailId)
if err != nil {
t.Errorf("error while listing members for groupKey %s and case %s: %w", c.g.EmailId, c.desc, err)
}
if !checkForMemberListEquality(result, c.expectedMembers) {
t.Errorf("unexpected list of members for %s, expected: %#v, got: %#v",
c.desc,
getMemberListInPrintableForm(c.expectedMembers),
getMemberListInPrintableForm(result),
)
}
}
}
func TestUpdateGroupSettings(t *testing.T) {
config.ConfirmChanges = true
cases := []struct {
desc string
g GoogleGroup
expectedSettings *groupssettings.Groups
}{
{
desc: "group settings match, no change",
g: GoogleGroup{
EmailId: "group1@email.com",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "CAN_REQUEST_TO_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_AND_MANAGERS",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "true",
},
},
expectedSettings: &groupssettings.Groups{
AllowExternalMembers: "true",
WhoCanJoin: "CAN_REQUEST_TO_JOIN",
WhoCanViewMembership: "ALL_MANAGERS_CAN_VIEW",
WhoCanViewGroup: "ALL_MEMBERS_CAN_VIEW",
WhoCanDiscoverGroup: "ALL_IN_DOMAIN_CAN_DISCOVER",
WhoCanModerateMembers: "OWNERS_AND_MANAGERS",
WhoCanModerateContent: "OWNERS_AND_MANAGERS",
WhoCanPostMessage: "ALL_MEMBERS_CAN_POST",
MessageModerationLevel: "MODERATE_NONE",
MembersCanPostAsTheGroup: "true",
},
},
{
desc: "group settings don't match, attempt to update",
g: GoogleGroup{
EmailId: "group1@email.com",
Settings: map[string]string{
"AllowExternalMembers": "true",
"WhoCanJoin": "CAN_REQUEST_TO_JOIN",
"WhoCanViewMembership": "ALL_MANAGERS_CAN_VIEW",
"WhoCanViewGroup": "ALL_MEMBERS_CAN_VIEW",
"WhoCanDiscoverGroup": "ALL_IN_DOMAIN_CAN_DISCOVER",
"WhoCanModerateMembers": "OWNERS_ONLY",
"WhoCanModerateContent": "OWNERS_AND_MANAGERS",
"WhoCanPostMessage": "ALL_MEMBERS_CAN_POST",
"MessageModerationLevel": "MODERATE_NONE",
"MembersCanPostAsTheGroup": "false",
},
},
expectedSettings: &groupssettings.Groups{
AllowExternalMembers: "true",
WhoCanJoin: "CAN_REQUEST_TO_JOIN",
WhoCanViewMembership: "ALL_MANAGERS_CAN_VIEW",
WhoCanViewGroup: "ALL_MEMBERS_CAN_VIEW",
WhoCanDiscoverGroup: "ALL_IN_DOMAIN_CAN_DISCOVER",
WhoCanModerateMembers: "OWNERS_ONLY",
WhoCanModerateContent: "OWNERS_AND_MANAGERS",
WhoCanPostMessage: "ALL_MEMBERS_CAN_POST",
MessageModerationLevel: "MODERATE_NONE",
MembersCanPostAsTheGroup: "false",
},
},
{
desc: "group settings don't match, attempt to update (test for defaults being set)",
g: GoogleGroup{EmailId: "group1@email.com", Settings: map[string]string{}},
expectedSettings: &groupssettings.Groups{
AllowExternalMembers: "true",
WhoCanJoin: "INVITED_CAN_JOIN",
WhoCanViewMembership: "ALL_MANAGERS_CAN_VIEW",
WhoCanViewGroup: "ALL_MEMBERS_CAN_VIEW",
WhoCanDiscoverGroup: "ALL_IN_DOMAIN_CAN_DISCOVER",
WhoCanModerateMembers: "OWNERS_AND_MANAGERS",
WhoCanModerateContent: "OWNERS_AND_MANAGERS",
WhoCanPostMessage: "ALL_MEMBERS_CAN_POST",
MessageModerationLevel: "MODERATE_NONE",
MembersCanPostAsTheGroup: "false",
},
},
}
errFunc := func(err error) bool {
return err != nil
}
for _, c := range cases {
fakeClient := fake.NewAugmentedFakeGroupServiceClient()
groupSvc, err := NewGroupServiceWithClientAndErrFunc(fakeClient, errFunc)
if err != nil {
t.Errorf("error creating client %w", err)
}
err = groupSvc.UpdateGroupSettings(c.g)
if err != nil {
t.Errorf("error while executing UpdateGroupSettings for case %s: %s", c.desc, err.Error())
}
result, err := fakeClient.Get(c.g.EmailId)
if err != nil {
t.Errorf("error while getting groupsettings of group with groupKey %s: %w", c.g.EmailId, err)
}
if !reflect.DeepEqual(result, c.expectedSettings) {
t.Errorf("unexpected groupsettings for case %s, expected: %#v, got: %#v", c.desc, c.expectedSettings, result)
}
}
}
func TestEmailAddressEquals(t *testing.T) {
testcases := []struct {
name string
a string
b string
expected bool
}{
{
name: "empty",
a: "",
b: "",
expected: true,
},
{
name: "empty vs not",
a: "",
b: "foo@bar.com",
expected: false,
},
{
name: "equal",
a: "foo@bar.com",
b: "foo@bar.com",
expected: true,
},
{
name: "different",
a: "foo@bar.com",
b: "bar@foo.com",
expected: false,
},
{
name: "equal case-insensitive",
a: "foo@bar.com",
b: "FOO@bar.com",
expected: true,
},
{
name: "equal dot-insensitive",
a: "foo@bar.com",
b: "f.o.o@bar.com",
expected: true,
},
{
name: "equal case-and-dot-insensitive",
a: "foo@bar.com",
b: "F.O.O@bar.com",
expected: true,
},
{
name: "host is dot-sensitive",
a: "foo@bar.com",
b: "f.o.o@b.a.r.com",
expected: false,
},
{
name: "host is case-insensitive",
a: "foo@bar.com",
b: "foo@BAR.com",
expected: true,
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
actual := EmailAddressEquals(tc.a, tc.b)
if actual != tc.expected {
t.Errorf("Expected %v emailequals %v to be %v, got: %v", tc.a, tc.b, tc.expected, actual)
}
})
}
}

View File

@ -0,0 +1,7 @@
# The OWNERS file is used by prow to automatically merge approved PRs.
approvers:
- productivity-writers
reviewers:
- productivity-writers
- productivity-reviewers

View File

@ -0,0 +1,74 @@
groups:
#
# k8s-infra owners for sig-owned subprojects
#
# Each group here represents highly privileged access to kubernetes project
# infrastructure owned or managed by this SIG. A high level of trust is
# required for membership in these groups.
#
###
### GROUPS FOR GKE/GCP RBAC
###
- email-id: kn-infra-gcp-org-admins@knative.team
name: kn-infra-gcp-org-admins
description: |-
granted owner access to the knative.team GCP organization, as well as
additional privileges necessary for billing and admin purposes
access granted via custom organization role organization.admin
settings:
ReconcileMembers: "true"
members:
- chizhg@google.com
- chizhg@knative.team
- cy@knative.team
- racker-maha-66b7d200@gcp.rackspace.com # Mahamed Ali
- cy@borg.dev # Mahamed Ali
# Every GKE RBAC group should be added here.
- email-id: gke-security-groups@knative.team
name: gke-security-groups
description: |-
Security Groups for GKE clusters
settings:
ReconcileMembers: "true"
WhoCanViewMembership: "ALL_MEMBERS_CAN_VIEW" # needed for RBAC
members:
- k8s-infra-rbac-prow@knative.team
# GKE RBAC groups:
# - grant access to the `namespace-user` role for a single namespace on the `aaa` cluster
# - must have WhoCanViewMemberShip: "ALL_MEMBERS_CAN_VIEW"
# - must be members of gke-security-groups@kubernetes.io
- email-id: k8s-infra-rbac-prow@knative.team
name: k8s-infra-rbac-prow
description: |-
Grants access to the prow cluster
settings:
ReconcileMembers: "true"
WhoCanViewMembership: "ALL_MEMBERS_CAN_VIEW" # required
members:
- chizhg@google.com
###
### Productivity WG related mailing lists
###
- email-id: automation@knative.team
name: automation
description: |-
User group for administrators of knative-automation.
settings:
AllowExternalMembers: "true"
owners:
- evana@gcp.vmware.com
- evankanderson@knative.team
- markusthoemmes@gmail.com
- mattmoor@knative.team
- mattomata@gmail.com
- tcnghia@gmail.com
managers:
- chizhg@google.com

View File

@ -0,0 +1,6 @@
# The OWNERS file is used by prow to automatically merge approved PRs.
approvers:
- security-writers
reviewers:
- security-writers

View File

@ -0,0 +1,20 @@
# Security WG
groups:
- email-id: security@knative.team
name: security
description: |-
Security WG email
settings:
WhoCanPostMessage: "ANYONE_CAN_POST"
ReconcileMembers: "true"
owners:
- evan.k.anderson@gmail.com
- evana@vmware.com
- evankanderson@knative.team
- julz.friedman@uk.ibm.com
- julz@knative.team
members:
- pablo@triggermesh.com
- paulschw@us.ibm.com
- vaikas@gmail.com