Skip to content

Commit

Permalink
allow setting static plaintext passwords
Browse files Browse the repository at this point in the history
  • Loading branch information
xrstf committed Mar 1, 2021
1 parent 6056615 commit e731915
Show file tree
Hide file tree
Showing 10 changed files with 236 additions and 6 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -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: [email protected]
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 [email protected] \
-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
Expand Down
11 changes: 10 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type options struct {
licensesYAML bool
clientSecretFile string
impersonatedUserEmail string
insecurePasswords bool
throttleRequests time.Duration
licenses []config.License
}
Expand All @@ -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()

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -324,6 +331,7 @@ func getScopes(readonly bool) []string {
directoryv1.AdminDirectoryOrgunitReadonlyScope,
directoryv1.AdminDirectoryGroupMemberReadonlyScope,
directoryv1.AdminDirectoryResourceCalendarReadonlyScope,
directoryv1.AdminDirectoryUserschemaReadonlyScope,
}
}

Expand All @@ -333,5 +341,6 @@ func getScopes(readonly bool) []string {
directoryv1.AdminDirectoryOrgunitScope,
directoryv1.AdminDirectoryGroupMemberScope,
directoryv1.AdminDirectoryResourceCalendarScope,
directoryv1.AdminDirectoryUserschemaScope,
}
}
16 changes: 16 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,20 @@ limitations under the License.
package config

import (
"crypto/sha256"
"fmt"
"os"
"sort"

"gopkg.in/yaml.v3"
"k8s.io/apimachinery/pkg/util/sets"
)

const (
SchemaName = "gman"
PasswordHashCustomField = "passwordHash"
)

const (
// WhoCanContactOwner
GroupOptionAllManagersCanContact = "ALL_MANAGERS_CAN_CONTACT"
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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])
}
33 changes: 32 additions & 1 deletion pkg/config/conversion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down
35 changes: 35 additions & 0 deletions pkg/glib/directory_schemas.go
Original file line number Diff line number Diff line change
@@ -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()
}
9 changes: 8 additions & 1 deletion pkg/glib/directory_users.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions pkg/sync/compare.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
63 changes: 63 additions & 0 deletions pkg/sync/schema.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit e731915

Please sign in to comment.