Skip to content

Commit

Permalink
Group role bindings
Browse files Browse the repository at this point in the history
- Read groups from infra.yaml
- Update data model for FK relations between groups, users, sources, and roles
- Find user role bindings from their groups
- Update test YAML
- Update config docs
- Improve logging
  • Loading branch information
BruceMacD committed Aug 10, 2021
1 parent cda3740 commit a8344ac
Show file tree
Hide file tree
Showing 8 changed files with 464 additions and 74 deletions.
14 changes: 13 additions & 1 deletion docs/permissions.md
Expand Up @@ -7,10 +7,22 @@ In Infra, roles are configured via the [configuration file](./configuration.md).
First, create or edit an existing a config file `infra.yaml`:

```
groups:
- name: developers # group name in an external identity provider
sources: # the identity providers this group applies to
- okta
roles:
- name: writer # Kubernetes cluster-role name
kind: cluster-role
clusters: # clusters for which to apply
- cluster-AAA
users:
- name: admin@example.com # user email
groups: # manually assign groups this user belongs to
- developers
roles:
- name: admin # Kubernetes cluster role name
- name: admin # Kubernetes cluster-role name
kind: cluster-role
clusters: # clusters for which to apply
- cluster-AAA
Expand Down
41 changes: 36 additions & 5 deletions internal/registry/_testdata/infra.yaml
Expand Up @@ -4,7 +4,38 @@ sources:
oktaClientId: 0oapn0qwiQPiMIyR35d6
oktaClientSecret: jfpn0qwiQPiMIfs408fjs048fjpn0qwiQPiMajsdf08j10j2
oktaApiToken: 001XJv9xhv899sdfns938haos3h8oahsdaohd2o8hdao82hd

groups:
- name: ios-developers
sources:
- okta
roles:
- name: writer
kind: cluster-role
clusters:
- cluster-AAA
- name: mac-admins
sources:
- okta
roles:
- name: writer
kind: cluster-role
clusters:
- cluster-BBB
- name: adversaries
sources:
- outside
roles:
- name: cluster-admin
kind: cluster-role
clusters:
- cluster-AAA

users:
- name: woz@example.com
groups:
- ios-developers
- mac-admins
- name: admin@example.com
roles:
- name: admin
Expand All @@ -13,17 +44,17 @@ users:
- cluster-AAA
- cluster-BBB
- name: user@example.com
groups:
- ios-developers
roles:
- name: writer
kind: cluster-role
clusters:
- cluster-AAA
- name: reader
kind: cluster-role
clusters:
- cluster-BBB
- cluster-AAA
- cluster-UNKNOWN
- name: unknown@example.com
groups:
- adversaries
roles:
- name: writer
kind: cluster-role
Expand Down
200 changes: 159 additions & 41 deletions internal/registry/config.go
Expand Up @@ -22,14 +22,21 @@ type ConfigRoleKubernetes struct {
Clusters []string `yaml:"clusters"`
}

type ConfigGroupMapping struct {
Name string `yaml:"name"`
Sources []string `yaml:"sources"`
Roles []ConfigRoleKubernetes `yaml:"roles"`
}

type ConfigUserMapping struct {
Name string
Roles []ConfigRoleKubernetes
// TODO (brucemacd): Add groups here
Name string `yaml:"name"`
Roles []ConfigRoleKubernetes `yaml:"roles"`
Groups []string `yaml:"groups"`
}
type Config struct {
Sources []ConfigSource `yaml:"sources"`
Users []ConfigUserMapping `yaml:"users"`
Sources []ConfigSource `yaml:"sources"`
Groups []ConfigGroupMapping `yaml:"groups"`
Users []ConfigUserMapping `yaml:"users"`
}

// this config is loaded at start-up and re-applied when the registry state changes (ex: a user is added)
Expand Down Expand Up @@ -66,6 +73,67 @@ func ImportSources(db *gorm.DB, sources []ConfigSource) error {
return nil
}

func ApplyGroupMappings(db *gorm.DB, groups []ConfigGroupMapping) (groupIds []string, roleIds []string, err error) {
for _, g := range groups {
// get the sources from the datastore that this group specifies
var sources []Source
for _, src := range g.Sources {
var source Source
// Assumes that only one type of each source can exist
srcReadErr := db.Where(&Source{Type: src}).First(&source).Error
if srcReadErr != nil {
if errors.Is(srcReadErr, gorm.ErrRecordNotFound) {
// skip this source, it will need to be added in the config and re-applied
logging.L.Debug("skipping source in config that does not exist: " + src)
continue
}
err = srcReadErr
return
}
sources = append(sources, source)
}

if len(sources) == 0 {
logging.L.Debug("no valid sources found, skipping group: " + g.Name)
continue
}

var group Group
// Group names must be unique for mapping purposes
err = db.FirstOrCreate(&group, &Group{Name: g.Name}).Error
if err != nil {
return
}

// add the identity provider associations to the group
for _, source := range sources {
if db.Model(&source).Where(&Group{Id: group.Id}).Association("Groups").Count() == 0 {
if err = db.Model(&source).Where(&Group{Id: group.Id}).Association("Groups").Append(&group); err != nil {
return
}
}
}

// import the roles on this group from the datastore
var roles []Role
roles, err = importRoles(db, g.Roles)
if err != nil {
return
}
// add the new group associations to the roles
for _, role := range roles {
if db.Model(&role).Where(&Group{Id: group.Id}).Association("Groups").Count() == 0 {
if err = db.Model(&role).Where(&Group{Id: group.Id}).Association("Groups").Append(&group); err != nil {
return
}
}
roleIds = append(roleIds, role.Id)
}
groupIds = append(groupIds, group.Id)
}
return
}

func ApplyUserMapping(db *gorm.DB, users []ConfigUserMapping) ([]string, error) {
var ids []string

Expand All @@ -80,54 +148,70 @@ func ApplyUserMapping(db *gorm.DB, users []ConfigUserMapping) ([]string, error)
}
return nil, err
}
for _, r := range u.Roles {
switch r.Kind {
case ROLE_KIND_K8S_ROLE:
// TODO (brucemacd): Handle config imports of roles when we support RoleBindings
logging.L.Info("Skipping role: " + r.Name + ", RoleBindings are not supported yet")
case ROLE_KIND_K8S_CLUSTER_ROLE:
for _, cName := range r.Clusters {
var destination Destination
err := db.Where(&Destination{Name: cName}).First(&destination).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// when a destination is added then the config import will be retried, skip for now
logging.L.Debug("skipping destination in config import that has not yet been discovered")
continue
}
return nil, err
}
var role Role
if err = db.FirstOrCreate(&role, &Role{Name: r.Name, Kind: r.Kind, DestinationId: destination.Id, FromConfig: true}).Error; err != nil {
return nil, err
}
// if this role is not yet associated with this user, add that association now
// important: do not create the association on the user, that runs an upsert that creates a deadlock because User.AfterCreate() calls this function
if db.Model(&user).Where(&Role{Id: role.Id}).Association("Roles").Count() == 0 {
if err = db.Model(&user).Where(&Role{Id: role.Id}).Association("Roles").Append(&role); err != nil {
return nil, err
}
}
ids = append(ids, role.Id)

// add the user to groups
for _, gName := range u.Groups {
// Assumes that only one group can exist with a given name, regardless of sources
var group Group
grpReadErr := db.Where(&Group{Name: gName}).First(&group).Error
if grpReadErr != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logging.L.Debug("skipping unknown group \"" + gName + "\" on user")
continue
}
return nil, grpReadErr
}
if db.Model(&user).Where(&Group{Id: group.Id}).Association("Groups").Count() == 0 {
if err = db.Model(&user).Where(&Group{Id: group.Id}).Association("Groups").Append(&group); err != nil {
return nil, err
}
default:
logging.L.Info("Unrecognized role kind: " + r.Kind + " in infra.yaml, role skipped.")
}
}

// TODO: add user to groups here
// add roles to user
roles, err := importRoles(db, u.Roles)
if err != nil {
return nil, err
}
// for all roles attached to this user update their user associations now that we have made sure they exist
// important: do not create the association on the user, that runs an upsert that creates a concurrent write because User.AfterCreate() calls this function
for _, role := range roles {
if db.Model(&user).Where(&Role{Id: role.Id}).Association("Roles").Count() == 0 {
if err = db.Model(&user).Where(&Role{Id: role.Id}).Association("Roles").Append(&role); err != nil {
return nil, err
}
}
ids = append(ids, role.Id)
}
}
return ids, nil
}

func ImportUserMappings(db *gorm.DB, users []ConfigUserMapping) error {
idsToKeep, err := ApplyUserMapping(db, users)
// ImportMappings imports the group and user role mappings and removes previously created roles if they no longer exist
func ImportMappings(db *gorm.DB, groups []ConfigGroupMapping, users []ConfigUserMapping) error {
grpIdsToKeep, grpRoleIdsToKeep, err := ApplyGroupMappings(db, groups)
if err != nil {
return err
}
// clean up existing groups which have been removed from the config
err = db.Where("1 = 1").Not(grpIdsToKeep).Delete(Group{}).Error
if err != nil {
return err
}

usrRoleIdsToKeep, err := ApplyUserMapping(db, users)
if err != nil {
return err
}
return db.Where(&Role{FromConfig: true}).Not(idsToKeep).Delete(Role{}).Error

// clean up existing roles which have been removed from the config
var roleIdsToKeep []string
roleIdsToKeep = append(roleIdsToKeep, grpRoleIdsToKeep...)
roleIdsToKeep = append(roleIdsToKeep, usrRoleIdsToKeep...)
return db.Where(&Role{FromConfig: true}).Not(roleIdsToKeep).Delete(Role{}).Error
}

// ImportConfig tries to import all valid fields in a config file
func ImportConfig(db *gorm.DB, bs []byte) error {
var config Config
err := yaml.Unmarshal(bs, &config)
Expand All @@ -141,9 +225,43 @@ func ImportConfig(db *gorm.DB, bs []byte) error {
if err = ImportSources(tx, config.Sources); err != nil {
return err
}
if err = ImportUserMappings(tx, config.Users); err != nil {
// Need to import of group/user mappings together becuase they both rely on roles
if err = ImportMappings(tx, config.Groups, config.Users); err != nil {
return err
}
return nil
})
}

// import roles creates roles specified in the config, or updates their assosiations
func importRoles(db *gorm.DB, roles []ConfigRoleKubernetes) ([]Role, error) {
var rolesImported []Role
for _, r := range roles {
switch r.Kind {
case ROLE_KIND_K8S_ROLE:
// TODO (brucemacd): Handle config imports of roles when we support RoleBindings
logging.L.Info("Skipping role: " + r.Name + ", RoleBindings are not supported yet")
case ROLE_KIND_K8S_CLUSTER_ROLE:
for _, cName := range r.Clusters {
var destination Destination
err := db.Where(&Destination{Name: cName}).First(&destination).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// when a destination is added then the config import will be retried, skip for now
logging.L.Debug("skipping destination in config import that has not yet been discovered")
continue
}
return nil, err
}
var role Role
if err = db.FirstOrCreate(&role, &Role{Name: r.Name, Kind: r.Kind, DestinationId: destination.Id, FromConfig: true}).Error; err != nil {
return nil, err
}
rolesImported = append(rolesImported, role)
}
default:
logging.L.Info("Unrecognized role kind: " + r.Kind + " in infra.yaml, role skipped.")
}
}
return rolesImported, nil
}

0 comments on commit a8344ac

Please sign in to comment.