diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ec24dc..6216941 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to this module will be documented in this file. +## [v0.6.0] - 2021-03-01 + +* allow configuring static passwords if `-insecure-passwords` is given + ## [v0.5.1] - 2021-03-01 * fix removing recovery phones/emails diff --git a/README.md b/README.md index ef33890..8d4f9f0 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ - [Validating](#validating) - [Synchronizing](#synchronizing) - [Confirming synchronization](#confirming-synchronization) + - [Static Password](#static-passwords) - [Limitations](#limitations) - [Sending the login info email to the new users](#sending-the-login-info-email-to-the-new-users) - [API requests quota](#api-requests-quota) @@ -63,6 +64,7 @@ After creating one, it needs to be registered as an API client and have enabled * `https://www.googleapis.com/auth/admin.directory.group.member.readonly` * `https://www.googleapis.com/auth/admin.directory.resource.calendar` * `https://www.googleapis.com/auth/admin.directory.resource.calendar.readonly` +* `https://www.googleapis.com/auth/admin.directory.userschema` * `https://www.googleapis.com/auth/apps.groups.settings` * `https://www.googleapis.com/auth/apps.licensing` @@ -216,6 +218,43 @@ $ gman \ Run the same command again with `-confirm` to perform the changes. +### Static Passwords + +GMan can be used to manage dummy/testing accounts with predefined passwords. Note that you should never +put real passwords in cleartext anywhere near GMan, but if you have public passwords, e.g. for workshops +and demonstrations, this feature can be handy. + +To make use of this, set a cleartext password for a user in your `users.yaml`: + +```yaml +organization: myorganization +users: + - givenName: Josef + familyName: K + primaryEmail: josef@myorganization.com + orgUnitPath: /Developers + password: i-am-not-secure-at-all +``` + +You must then opt-in to this feature by running GMan with `-insecure-passwords`: + +```bash +$ gman \ + -private-key MYKEY.json \ + -impersonated-email me@example.com \ + -users-config myconfig.yaml \ + -groups-config myconfig.yaml \ + -orgunits-config myconfig.yaml \ + -insecure-passwords +2020/06/25 18:55:54 ✓ Configuration is valid. +2020/06/25 18:55:54 ► Updating organization myorganization... +... +``` + +GMan will now set the configured password and store its SHA256 hash as a custom schema field on the user. +On the next run, GMan will compare the hash with the configured password and update the user in GSuite +only if needed. + ## Limitations ### Sending the login info email to the new users diff --git a/main.go b/main.go index 78c8bc2..0c2568b 100644 --- a/main.go +++ b/main.go @@ -56,6 +56,7 @@ type options struct { licensesYAML bool clientSecretFile string impersonatedUserEmail string + insecurePasswords bool throttleRequests time.Duration licenses []config.License } @@ -78,6 +79,7 @@ func main() { flag.BoolVar(&opt.licensesAction, "licenses", false, "print the builtin licenses and then exit") flag.BoolVar(&opt.licensesYAML, "licenses-yaml", false, "print the builtin licenses as YAML (use together with -licenses)") flag.BoolVar(&opt.confirm, "confirm", false, "must be set to actually perform any changes") + flag.BoolVar(&opt.insecurePasswords, "insecure-passwords", false, "allow configuring static passwords for users") flag.DurationVar(&opt.throttleRequests, "throttle-requests", 500*time.Millisecond, "the delay between Enterprise Licensing API requests") flag.Parse() @@ -203,7 +205,12 @@ func syncAction( log.Fatalf("⚠ Failed to sync: %v.", err) } - userChanges, err := sync.SyncUsers(ctx, directorySrv, licensingSrv, opt.usersConfig, opt.licenseStatus, opt.confirm) + err = sync.SyncSchema(ctx, directorySrv, opt.confirm) + if err != nil { + log.Fatalf("⚠ Failed to sync: %v.", err) + } + + userChanges, err := sync.SyncUsers(ctx, directorySrv, licensingSrv, opt.usersConfig, opt.licenseStatus, opt.insecurePasswords, opt.confirm) if err != nil { log.Fatalf("⚠ Failed to sync: %v.", err) } @@ -324,6 +331,7 @@ func getScopes(readonly bool) []string { directoryv1.AdminDirectoryOrgunitReadonlyScope, directoryv1.AdminDirectoryGroupMemberReadonlyScope, directoryv1.AdminDirectoryResourceCalendarReadonlyScope, + directoryv1.AdminDirectoryUserschemaReadonlyScope, } } @@ -333,5 +341,6 @@ func getScopes(readonly bool) []string { directoryv1.AdminDirectoryOrgunitScope, directoryv1.AdminDirectoryGroupMemberScope, directoryv1.AdminDirectoryResourceCalendarScope, + directoryv1.AdminDirectoryUserschemaScope, } } diff --git a/pkg/config/config.go b/pkg/config/config.go index f1567e0..a59c5d0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -17,6 +17,8 @@ limitations under the License. package config import ( + "crypto/sha256" + "fmt" "os" "sort" @@ -24,6 +26,11 @@ import ( "k8s.io/apimachinery/pkg/util/sets" ) +const ( + SchemaName = "gman" + PasswordHashCustomField = "passwordHash" +) + const ( // WhoCanContactOwner GroupOptionAllManagersCanContact = "ALL_MANAGERS_CAN_CONTACT" @@ -139,6 +146,7 @@ type User struct { Employee Employee `yaml:"employeeInfo,omitempty"` Location Location `yaml:"location,omitempty"` Address string `yaml:"address,omitempty"` + Password string `yaml:"password,omitempty"` } func (u *User) Sort() { @@ -239,3 +247,11 @@ func SaveToFile(config *Config, filename string) error { return nil } + +// HashPassword returns an shortened hash for the given password; +// the hash is not meant to validate password inputs, but only to +// compare if the passwords have changed +func HashPassword(password string) string { + checksum := sha256.Sum256([]byte(password)) + return fmt.Sprintf("%x", checksum[:16]) +} diff --git a/pkg/config/conversion.go b/pkg/config/conversion.go index 14e16f2..8fd6d90 100644 --- a/pkg/config/conversion.go +++ b/pkg/config/conversion.go @@ -17,16 +17,36 @@ limitations under the License. package config import ( + "encoding/json" "fmt" "strconv" directoryv1 "google.golang.org/api/admin/directory/v1" + "google.golang.org/api/googleapi" groupssettingsv1 "google.golang.org/api/groupssettings/v1" "github.com/kubermatic-labs/gman/pkg/util" ) -func ToGSuiteUser(user *User) *directoryv1.User { +type CustomSchema struct { + PasswordHash string `json:"passwordHash"` +} + +func GetUserSchema(user *directoryv1.User) *CustomSchema { + customFields, ok := user.CustomSchemas[SchemaName] + if !ok { + return nil + } + + s := &CustomSchema{} + if err := json.Unmarshal(customFields, s); err != nil { + return nil + } + + return s +} + +func ToGSuiteUser(user *User, enableInsecurePasswords bool) *directoryv1.User { gsuiteUser := &directoryv1.User{ Name: &directoryv1.UserName{ GivenName: user.FirstName, @@ -46,6 +66,7 @@ func ToGSuiteUser(user *User) *directoryv1.User { Relations: []directoryv1.UserRelation{}, ExternalIds: []directoryv1.UserExternalId{}, Locations: []directoryv1.UserLocation{}, + CustomSchemas: map[string]googleapi.RawMessage{}, } if len(user.Phones) > 0 { @@ -112,6 +133,16 @@ func ToGSuiteUser(user *User) *directoryv1.User { } } + if enableInsecurePasswords && user.Password != "" { + customData := CustomSchema{ + PasswordHash: HashPassword(user.Password), + } + + encoded, _ := json.Marshal(customData) + gsuiteUser.CustomSchemas[SchemaName] = encoded + gsuiteUser.Password = user.Password + } + return gsuiteUser } diff --git a/pkg/glib/directory_schemas.go b/pkg/glib/directory_schemas.go new file mode 100644 index 0000000..b681aab --- /dev/null +++ b/pkg/glib/directory_schemas.go @@ -0,0 +1,35 @@ +/* +Copyright 2021 The Kubermatic Kubernetes Platform contributors. + +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 glib + +import ( + "context" + + directoryv1 "google.golang.org/api/admin/directory/v1" +) + +func (ds *DirectoryService) GetSchema(ctx context.Context, name string) (*directoryv1.Schema, error) { + return ds.Schemas.Get("my_customer", name).Context(ctx).Do() +} + +func (ds *DirectoryService) CreateSchema(ctx context.Context, schema *directoryv1.Schema) (*directoryv1.Schema, error) { + return ds.Schemas.Insert("my_customer", schema).Context(ctx).Do() +} + +func (ds *DirectoryService) UpdateSchema(ctx context.Context, oldSchema *directoryv1.Schema, newSchema *directoryv1.Schema) (*directoryv1.Schema, error) { + return ds.Schemas.Update("my_customer", oldSchema.SchemaId, newSchema).Context(ctx).Do() +} diff --git a/pkg/glib/directory_users.go b/pkg/glib/directory_users.go index a3a310a..348839d 100644 --- a/pkg/glib/directory_users.go +++ b/pkg/glib/directory_users.go @@ -24,6 +24,7 @@ import ( password "github.com/sethvargo/go-password/password" directoryv1 "google.golang.org/api/admin/directory/v1" + "github.com/kubermatic-labs/gman/pkg/config" "github.com/kubermatic-labs/gman/pkg/util" ) @@ -32,7 +33,13 @@ func (ds *DirectoryService) ListUsers(ctx context.Context) ([]*directoryv1.User, token := "" for { - request := ds.Users.List().Customer("my_customer").OrderBy("email").PageToken(token).Context(ctx) + request := ds.Users.List(). + Customer("my_customer"). + OrderBy("email"). + PageToken(token). + Projection("custom"). + CustomFieldMask(config.SchemaName). + Context(ctx) response, err := request.Do() if err != nil { diff --git a/pkg/sync/compare.go b/pkg/sync/compare.go index c3d4376..43f8051 100644 --- a/pkg/sync/compare.go +++ b/pkg/sync/compare.go @@ -45,9 +45,31 @@ func userUpToDate(configured config.User, live *directoryv1.User, liveLicenses [ configured.Aliases = []string{} } + // password changes are handled by passwordUpToDate() + converted.Password = configured.Password + return reflect.DeepEqual(configured, converted) } +// passwordUpToDate checks if the live account's last password set +// by GMan was what is configured in YAML. This is meant as a mechanism to +// mass-reset accounts to a common, public password, e.g. for testing +// or training accounts. For this reason GMan stores an unsalted hash +// of the password as a custom field, with the hash purely being for +// obfuscating it a bit. +func passwordUpToDate(configured config.User, live *directoryv1.User) bool { + // no password configured, so we do not care at all about the + // state in GSuite; this is the norm for accounts managed by us + if configured.Password == "" { + return true + } + + configuredHash := config.HashPassword(configured.Password) + liveSchema := config.GetUserSchema(live) + + return liveSchema != nil && liveSchema.PasswordHash == configuredHash +} + func groupUpToDate(configured config.Group, live *directoryv1.Group, liveMembers []*directoryv1.Member, settings *groupssettings.Groups) bool { converted, err := config.ToConfigGroup(live, settings, liveMembers) if err != nil { diff --git a/pkg/sync/schema.go b/pkg/sync/schema.go new file mode 100644 index 0000000..fcefc22 --- /dev/null +++ b/pkg/sync/schema.go @@ -0,0 +1,63 @@ +/* +Copyright 2021 The Kubermatic Kubernetes Platform contributors. + +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 sync + +import ( + "context" + "log" + + directoryv1 "google.golang.org/api/admin/directory/v1" + "google.golang.org/api/googleapi" + + "github.com/kubermatic-labs/gman/pkg/config" + "github.com/kubermatic-labs/gman/pkg/glib" +) + +func SyncSchema( + ctx context.Context, + directorySrv *glib.DirectoryService, + confirm bool, +) error { + log.Println("⇄ Syncing schema…") + + if !confirm { + return nil + } + + desiredSchema := &directoryv1.Schema{ + DisplayName: "GMan", + SchemaName: config.SchemaName, + Fields: []*directoryv1.SchemaFieldSpec{ + { + FieldName: config.PasswordHashCustomField, + FieldType: "STRING", + ReadAccessType: "ADMINS_AND_SELF", + Indexed: googleapi.Bool(false), + }, + }, + } + + schema, err := directorySrv.GetSchema(ctx, config.SchemaName) + + if err != nil { + _, err = directorySrv.CreateSchema(ctx, desiredSchema) + } else { + _, err = directorySrv.UpdateSchema(ctx, schema, desiredSchema) + } + + return err +} diff --git a/pkg/sync/users.go b/pkg/sync/users.go index 2e08e53..1c619ed 100644 --- a/pkg/sync/users.go +++ b/pkg/sync/users.go @@ -34,6 +34,7 @@ func SyncUsers( licensingSrv *glib.LicensingService, cfg *config.Config, licenseStatus *glib.LicenseStatus, + enableInsecurePasswords bool, confirm bool, ) (bool, error) { changes := false @@ -63,7 +64,10 @@ func SyncUsers( return changes, fmt.Errorf("failed to fetch aliases: %v", err) } - if userUpToDate(expectedUser, liveUser, currentUserLicenses, currentAliases) { + infoUpToDate := userUpToDate(expectedUser, liveUser, currentUserLicenses, currentAliases) + passwordUpToDate := !enableInsecurePasswords || passwordUpToDate(expectedUser, liveUser) + + if infoUpToDate && passwordUpToDate { // no update needed log.Printf(" ✓ %s", expectedUser.PrimaryEmail) } else { @@ -73,7 +77,7 @@ func SyncUsers( updatedUser := liveUser if confirm { - apiUser := config.ToGSuiteUser(&expectedUser) + apiUser := config.ToGSuiteUser(&expectedUser, enableInsecurePasswords) updatedUser, err = directorySrv.UpdateUser(ctx, liveUser, apiUser) if err != nil { return changes, fmt.Errorf("failed to update user: %v", err) @@ -113,7 +117,7 @@ func SyncUsers( var createdUser *directoryv1.User if confirm { - apiUser := config.ToGSuiteUser(&expectedUser) + apiUser := config.ToGSuiteUser(&expectedUser, enableInsecurePasswords) createdUser, err = directorySrv.CreateUser(ctx, apiUser) if err != nil { return changes, fmt.Errorf("failed to create user: %v", err)