Merge branch '15881-ldap'
authorTom Clegg <tom@tomclegg.ca>
Wed, 27 May 2020 20:10:06 +0000 (16:10 -0400)
committerTom Clegg <tom@tomclegg.ca>
Wed, 27 May 2020 20:10:06 +0000 (16:10 -0400)
refs #15881

Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom@tomclegg.ca>

doc/admin/federation.html.textile.liquid
doc/architecture/index.html.textile.liquid
doc/install/arvados-on-kubernetes-GKE.html.textile.liquid
doc/install/arvados-on-kubernetes-minikube.html.textile.liquid
doc/user/topics/arvados-sync-groups.html.textile.liquid
tools/sync-groups/sync-groups.go
tools/sync-groups/sync-groups_test.go

index b1f1506e4c7cd93b4e2538876d0930ab5c2dbd48..3726a6d96c798a11dad9a3359ca389fe42b247b6 100644 (file)
@@ -42,7 +42,7 @@ h2(#LoginCluster). Federation user management
 
 A federation of clusters can be configured to use a separate user database per cluster, or delegate a central cluster to manage the database.
 
-If clusters belong to separate organizations, each cluster will have its own user database for the members of that organization.  Through federation, a user from one organization can be granted access to the cluster of another organization.  The admin of the seond cluster controls access on a individual basis by choosing to activate or deactivate accounts from other organizations (with the default policy the value of  @ActivateUsers@).
+If clusters belong to separate organizations, each cluster will have its own user database for the members of that organization.  Through federation, a user from one organization can be granted access to the cluster of another organization.  The admin of the second cluster controls access on a individual basis by choosing to activate or deactivate accounts from other organizations (with the default policy the value of  @ActivateUsers@).
 
 On the other hand, if all clusters belong to the same organization, and users in that organization should have access to all the clusters, user management can be simplified by setting the @LoginCluster@ which manages the user database used by all other clusters in the federation.  To do this, choose one cluster in the federation which will be the 'login cluster'.  Set the the @Login.LoginCluster@ configuration value on all clusters in the federation to the cluster id of the login cluster.  After setting @LoginCluster@, restart arvados-api-server and arvados-controller.
 
index c7ea3268e13687d9fd8bee28fd65ee7df6609f59..705048cd620cf566ad5ece5722e311262642d623 100644 (file)
@@ -56,4 +56,4 @@ table(table table-bordered table-condensed).
 |keep-block-check|Given a list of keep block locators, check that each block exists on one of the configured keepstore servers and verify the block hash.|
 |keep-exercise|Benchmarking tool to test throughput and reliability of keepstores under various usage patterns.|
 |keep-rsync|Get lists of blocks from two clusters, copy blocks which exist on source cluster but are missing from destination cluster.|
-|sync-groups|Take a CSV file listing (group, username) pairs and synchronize membership in Arvados groups.|
+|sync-groups|Take a CSV file listing with (group, user, permission) records and synchronize membership in Arvados groups.|
index c4236b1c224bb4529500542b720f6824ac43a6c4..0801b7d4e3f6e43080efc5c8b966a369b5a098d8 100644 (file)
@@ -75,11 +75,11 @@ There should be no errors. The command will return nothing.
 
 h2(#git). Clone the repository
 
-Clone the repository and nagivate to the @arvados-kubernetes/charts/arvados@ directory:
+Clone the repository and nagivate to the @arvados-k8s/charts/arvados@ directory:
 
 <pre>
-$ git clone https://github.com/arvados/arvados-kubernetes.git
-$ cd arvados-kubernetes/charts/arvados
+$ git clone https://github.com/arvados/arvados-k8s.git
+$ cd arvados-k8s/charts/arvados
 </pre>
 
 h2(#Start). Start the Arvados cluster
index 56a51b035f08d42f26b6ba53c7dc9490bad7ca80..86aaf08f96ca2a00d3585fe05b747e2132a3d4da 100644 (file)
@@ -47,11 +47,11 @@ There should be no errors. The command will return nothing.
 
 h2(#git). Clone the repository
 
-Clone the repository and nagivate to the @arvados-kubernetes/charts/arvados@ directory:
+Clone the repository and nagivate to the @arvados-k8s/charts/arvados@ directory:
 
 <pre>
-$ git clone https://github.com/arvados/arvados-kubernetes.git
-$ cd arvados-kubernetes/charts/arvados
+$ git clone https://github.com/arvados/arvados-k8s.git
+$ cd arvados-k8s/charts/arvados
 </pre>
 
 h2(#Start). Start the Arvados cluster
index 9a609039b4903420f2cd1aeedee530d4a07f82f4..7d831bf04021633ec5802d2616baca31fa90e4f0 100644 (file)
@@ -15,10 +15,12 @@ h1. Using arvados-sync-groups
 
 This tool reads a CSV (comma-separated values) file having information about external groups and their members. When running it for the first time, it'll create a special group named 'Externally synchronized groups' meant to be the parent of all the remote groups.
 
-Every line on the file should have 2 values: a group name and a local user identifier, meaning that the named user is a member of the group. The tool will create the group if it doesn't exist, and add the user to it. If group member is not present on the input file, the account will be removed from the group.
+Every line on the file should have 3 values: a group name, a local user identifier and a permission level, meaning that the named user is a member of the group with the provided permission. The tool will create the group if it doesn't exist, and add the user to it. If any group member is not present on the input file, it will be removed from the group.
 
 Users can be identified by their email address or username: the tool will check if every user exist on the system, and report back when not found. Groups on the other hand, are identified by their name.
 
+Permission level can be one of the following: @can_read@, @can_write@ or @can_manage@, giving the group member read, read/write or managing privileges on the group. For backwards compatibility purposes, if any record omits the third (permission) field, it will default to @can_write@ permission. You can read more about permissions on the "group management admin guide":/admin/group-management.html.
+
 This tool is designed to be run periodically reading a file created by a remote auth system (ie: LDAP) dump script, applying what's included on the file as the source of truth.
 
 
index 7c5cd0558bbbc2927f4758fa93e2ec53920f0726..9e2307b7a6751c66b6b990634bece50af0a6d366 100644 (file)
@@ -26,11 +26,14 @@ type resourceList interface {
        GetItems() []interface{}
 }
 
-// GroupInfo tracks previous and current members of a particular Group
+// GroupPermissions maps permission levels on groups (can_read, can_write, can_manage)
+type GroupPermissions map[string]bool
+
+// GroupInfo tracks previous and current member's permissions on a particular Group
 type GroupInfo struct {
        Group           arvados.Group
-       PreviousMembers map[string]bool
-       CurrentMembers  map[string]bool
+       PreviousMembers map[string]GroupPermissions
+       CurrentMembers  map[string]GroupPermissions
 }
 
 // GetUserID returns the correct user id value depending on the selector
@@ -134,9 +137,10 @@ func ParseFlags(config *ConfigParams) error {
 
        // Set up usage message
        flags.Usage = func() {
-               usageStr := `Synchronize remote groups into Arvados from a CSV format file with 2 columns:
-  * 1st column: Group name
-  * 2nd column: User identifier`
+               usageStr := `Synchronize remote groups into Arvados from a CSV format file with 3 columns:
+  * 1st: Group name
+  * 2nd: User identifier
+  * 3rd (Optional): User permission on the group: can_read, can_write or can_manage. (Default: can_write)`
                fmt.Fprintf(os.Stderr, "%s\n\n", usageStr)
                fmt.Fprintf(os.Stderr, "Usage:\n%s [OPTIONS] <input-file.csv>\n\n", os.Args[0])
                fmt.Fprintf(os.Stderr, "Options:\n")
@@ -334,16 +338,30 @@ func doMain(cfg *ConfigParams) error {
        // Remove previous members not listed on this run
        for groupUUID := range remoteGroups {
                gi := remoteGroups[groupUUID]
-               evictedMembers := subtract(gi.PreviousMembers, gi.CurrentMembers)
+               evictedMemberPerms := subtract(gi.PreviousMembers, gi.CurrentMembers)
                groupName := gi.Group.Name
-               if len(evictedMembers) > 0 {
-                       log.Printf("Removing %d users from group %q", len(evictedMembers), groupName)
-               }
-               for evictedUser := range evictedMembers {
-                       if err := RemoveMemberFromGroup(cfg, allUsers[userIDToUUID[evictedUser]], gi.Group); err != nil {
+               if len(evictedMemberPerms) > 0 {
+                       log.Printf("Removing permissions from %d users on group %q", len(evictedMemberPerms), groupName)
+               }
+               for member := range evictedMemberPerms {
+                       var perms []string
+                       completeMembershipRemoval := false
+                       if _, ok := gi.CurrentMembers[member]; !ok {
+                               completeMembershipRemoval = true
+                               membershipsRemoved++
+                       } else {
+                               // Collect which user->group permission links should be removed
+                               for p := range evictedMemberPerms[member] {
+                                       if evictedMemberPerms[member][p] {
+                                               perms = append(perms, p)
+                                       }
+                               }
+                               membershipsRemoved += len(perms)
+                       }
+                       if err := RemoveMemberLinksFromGroup(cfg, allUsers[userIDToUUID[member]],
+                               perms, completeMembershipRemoval, gi.Group); err != nil {
                                return err
                        }
-                       membershipsRemoved++
                }
        }
        log.Printf("Groups created: %d. Memberships added: %d, removed: %d, skipped: %d", groupsCreated, membershipsAdded, membershipsRemoved, membershipsSkipped)
@@ -362,7 +380,8 @@ func ProcessFile(
 ) (groupsCreated, membersAdded, membersSkipped int, err error) {
        lineNo := 0
        csvReader := csv.NewReader(f)
-       csvReader.FieldsPerRecord = 2
+       // Allow variable number of fields.
+       csvReader.FieldsPerRecord = -1
        for {
                record, e := csvReader.Read()
                if e == io.EOF {
@@ -373,10 +392,24 @@ func ProcessFile(
                        err = fmt.Errorf("error parsing %q, line %d", cfg.Path, lineNo)
                        return
                }
+               // Only allow 2 or 3 fields per record for backwards compatibility.
+               if len(record) < 2 || len(record) > 3 {
+                       err = fmt.Errorf("error parsing %q, line %d: found %d fields but only 2 or 3 are allowed", cfg.Path, lineNo, len(record))
+                       return
+               }
                groupName := strings.TrimSpace(record[0])
                groupMember := strings.TrimSpace(record[1]) // User ID (username or email)
-               if groupName == "" || groupMember == "" {
-                       log.Printf("Warning: CSV record has at least one empty field (%s, %s). Skipping", groupName, groupMember)
+               groupPermission := "can_write"
+               if len(record) == 3 {
+                       groupPermission = strings.ToLower(record[2])
+               }
+               if groupName == "" || groupMember == "" || groupPermission == "" {
+                       log.Printf("Warning: CSV record has at least one empty field (%s, %s, %s). Skipping", groupName, groupMember, groupPermission)
+                       membersSkipped++
+                       continue
+               }
+               if !(groupPermission == "can_read" || groupPermission == "can_write" || groupPermission == "can_manage") {
+                       log.Printf("Warning: 3rd field should be 'can_read', 'can_write' or 'can_manage'. Found: %q at line %d, skipping.", groupPermission, lineNo)
                        membersSkipped++
                        continue
                }
@@ -405,26 +438,36 @@ func ProcessFile(
                        groupNameToUUID[groupName] = newGroup.UUID
                        remoteGroups[newGroup.UUID] = &GroupInfo{
                                Group:           newGroup,
-                               PreviousMembers: make(map[string]bool), // Empty set
-                               CurrentMembers:  make(map[string]bool), // Empty set
+                               PreviousMembers: make(map[string]GroupPermissions),
+                               CurrentMembers:  make(map[string]GroupPermissions),
                        }
                        groupsCreated++
                }
                // Both group & user exist, check if user is a member
                groupUUID := groupNameToUUID[groupName]
                gi := remoteGroups[groupUUID]
-               if !gi.PreviousMembers[groupMember] && !gi.CurrentMembers[groupMember] {
+               if !gi.PreviousMembers[groupMember][groupPermission] && !gi.CurrentMembers[groupMember][groupPermission] {
                        if cfg.Verbose {
                                log.Printf("Adding %q to group %q", groupMember, groupName)
                        }
-                       // User wasn't a member, but should be.
-                       if e := AddMemberToGroup(cfg, allUsers[userIDToUUID[groupMember]], gi.Group); e != nil {
+                       // User permissionwasn't there, but should be. Avoid duplicating the
+                       // group->user link when necessary.
+                       createG2ULink := true
+                       if _, ok := gi.PreviousMembers[groupMember]; ok {
+                               createG2ULink = false // User is already member of the group
+                       }
+                       if e := AddMemberToGroup(cfg, allUsers[userIDToUUID[groupMember]], gi.Group, groupPermission, createG2ULink); e != nil {
                                err = e
                                return
                        }
                        membersAdded++
                }
-               gi.CurrentMembers[groupMember] = true
+               if _, ok := gi.CurrentMembers[groupMember]; ok {
+                       gi.CurrentMembers[groupMember][groupPermission] = true
+               } else {
+                       gi.CurrentMembers[groupMember] = GroupPermissions{groupPermission: true}
+               }
+
        }
        return
 }
@@ -452,11 +495,17 @@ func GetAll(c *arvados.Client, res string, params arvados.ResourceListParams, pa
        return allItems, nil
 }
 
-func subtract(setA map[string]bool, setB map[string]bool) map[string]bool {
-       result := make(map[string]bool)
+func subtract(setA map[string]GroupPermissions, setB map[string]GroupPermissions) map[string]GroupPermissions {
+       result := make(map[string]GroupPermissions)
        for element := range setA {
-               if !setB[element] {
-                       result[element] = true
+               if _, ok := setB[element]; !ok {
+                       result[element] = setA[element]
+               } else {
+                       for perm := range setA[element] {
+                               if _, ok := setB[element][perm]; !ok {
+                                       result[element] = GroupPermissions{perm: true}
+                               }
+                       }
                }
        }
        return result
@@ -526,8 +575,8 @@ func GetRemoteGroups(cfg *ConfigParams, allUsers map[string]arvados.User) (remot
                                Operand:  "permission",
                        }, {
                                Attr:     "name",
-                               Operator: "=",
-                               Operand:  "can_write",
+                               Operator: "in",
+                               Operand:  []string{"can_read", "can_write", "can_manage"},
                        }, {
                                Attr:     "head_uuid",
                                Operator: "=",
@@ -540,18 +589,23 @@ func GetRemoteGroups(cfg *ConfigParams, allUsers map[string]arvados.User) (remot
                }
                g2uLinks, err := GetAll(cfg.Client, "links", g2uFilter, &LinkList{})
                if err != nil {
-                       return remoteGroups, groupNameToUUID, fmt.Errorf("error getting member (can_read) links for group %q: %s", group.Name, err)
+                       return remoteGroups, groupNameToUUID, fmt.Errorf("error getting group->user 'can_read' links for group %q: %s", group.Name, err)
                }
                u2gLinks, err := GetAll(cfg.Client, "links", u2gFilter, &LinkList{})
                if err != nil {
-                       return remoteGroups, groupNameToUUID, fmt.Errorf("error getting member (can_write) links for group %q: %s", group.Name, err)
+                       return remoteGroups, groupNameToUUID, fmt.Errorf("error getting user->group links for group %q: %s", group.Name, err)
                }
-               // Build a list of user ids (email or username) belonging to this group
-               membersSet := make(map[string]bool)
-               u2gLinkSet := make(map[string]bool)
+               // Build a list of user ids (email or username) belonging to this group.
+               membersSet := make(map[string]GroupPermissions)
+               u2gLinkSet := make(map[string]GroupPermissions)
                for _, l := range u2gLinks {
-                       linkedMemberUUID := l.(arvados.Link).TailUUID
-                       u2gLinkSet[linkedMemberUUID] = true
+                       link := l.(arvados.Link)
+                       // Also save the member's group access level.
+                       if _, ok := u2gLinkSet[link.TailUUID]; ok {
+                               u2gLinkSet[link.TailUUID][link.Name] = true
+                       } else {
+                               u2gLinkSet[link.TailUUID] = GroupPermissions{link.Name: true}
+                       }
                }
                for _, item := range g2uLinks {
                        link := item.(arvados.Link)
@@ -569,55 +623,81 @@ func GetRemoteGroups(cfg *ConfigParams, allUsers map[string]arvados.User) (remot
                        if err != nil {
                                return remoteGroups, groupNameToUUID, err
                        }
-                       membersSet[memberID] = true
+                       membersSet[memberID] = u2gLinkSet[link.HeadUUID]
                }
                remoteGroups[group.UUID] = &GroupInfo{
                        Group:           group,
                        PreviousMembers: membersSet,
-                       CurrentMembers:  make(map[string]bool), // Empty set
+                       CurrentMembers:  make(map[string]GroupPermissions),
                }
                groupNameToUUID[group.Name] = group.UUID
        }
        return remoteGroups, groupNameToUUID, nil
 }
 
-// RemoveMemberFromGroup remove all links related to the membership
-func RemoveMemberFromGroup(cfg *ConfigParams, user arvados.User, group arvados.Group) error {
+// RemoveMemberLinksFromGroup remove all links related to the membership
+func RemoveMemberLinksFromGroup(cfg *ConfigParams, user arvados.User, linkNames []string, completeRemoval bool, group arvados.Group) error {
        if cfg.Verbose {
                log.Printf("Getting group membership links for user %q (%s) on group %q (%s)", user.Username, user.UUID, group.Name, group.UUID)
        }
        var links []interface{}
-       // Search for all group<->user links (both ways)
-       for _, filterset := range [][]arvados.Filter{
-               // Group -> User
-               {{
-                       Attr:     "link_class",
-                       Operator: "=",
-                       Operand:  "permission",
-               }, {
-                       Attr:     "tail_uuid",
-                       Operator: "=",
-                       Operand:  group.UUID,
-               }, {
-                       Attr:     "head_uuid",
-                       Operator: "=",
-                       Operand:  user.UUID,
-               }},
-               // Group <- User
-               {{
-                       Attr:     "link_class",
-                       Operator: "=",
-                       Operand:  "permission",
-               }, {
-                       Attr:     "tail_uuid",
-                       Operator: "=",
-                       Operand:  user.UUID,
-               }, {
-                       Attr:     "head_uuid",
-                       Operator: "=",
-                       Operand:  group.UUID,
-               }},
-       } {
+       var filters [][]arvados.Filter
+       if completeRemoval {
+               // Search for all group<->user links (both ways)
+               filters = [][]arvados.Filter{
+                       // Group -> User
+                       {{
+                               Attr:     "link_class",
+                               Operator: "=",
+                               Operand:  "permission",
+                       }, {
+                               Attr:     "tail_uuid",
+                               Operator: "=",
+                               Operand:  group.UUID,
+                       }, {
+                               Attr:     "head_uuid",
+                               Operator: "=",
+                               Operand:  user.UUID,
+                       }},
+                       // Group <- User
+                       {{
+                               Attr:     "link_class",
+                               Operator: "=",
+                               Operand:  "permission",
+                       }, {
+                               Attr:     "tail_uuid",
+                               Operator: "=",
+                               Operand:  user.UUID,
+                       }, {
+                               Attr:     "head_uuid",
+                               Operator: "=",
+                               Operand:  group.UUID,
+                       }},
+               }
+       } else {
+               // Search only for the requested Group <- User permission links
+               filters = [][]arvados.Filter{
+                       {{
+                               Attr:     "link_class",
+                               Operator: "=",
+                               Operand:  "permission",
+                       }, {
+                               Attr:     "tail_uuid",
+                               Operator: "=",
+                               Operand:  user.UUID,
+                       }, {
+                               Attr:     "head_uuid",
+                               Operator: "=",
+                               Operand:  group.UUID,
+                       }, {
+                               Attr:     "name",
+                               Operator: "in",
+                               Operand:  linkNames,
+                       }},
+               }
+       }
+
+       for _, filterset := range filters {
                l, err := GetAll(cfg.Client, "links", arvados.ResourceListParams{Filters: filterset}, &LinkList{})
                if err != nil {
                        userID, _ := GetUserID(user, cfg.UserID)
@@ -641,29 +721,32 @@ func RemoveMemberFromGroup(cfg *ConfigParams, user arvados.User, group arvados.G
 }
 
 // AddMemberToGroup create membership links
-func AddMemberToGroup(cfg *ConfigParams, user arvados.User, group arvados.Group) error {
+func AddMemberToGroup(cfg *ConfigParams, user arvados.User, group arvados.Group, perm string, createG2ULink bool) error {
        var newLink arvados.Link
-       linkData := map[string]string{
-               "owner_uuid": cfg.SysUserUUID,
-               "link_class": "permission",
-               "name":       "can_read",
-               "tail_uuid":  group.UUID,
-               "head_uuid":  user.UUID,
-       }
-       if err := CreateLink(cfg, &newLink, linkData); err != nil {
-               userID, _ := GetUserID(user, cfg.UserID)
-               return fmt.Errorf("error adding group %q -> user %q read permission: %s", group.Name, userID, err)
+       var linkData map[string]string
+       if createG2ULink {
+               linkData = map[string]string{
+                       "owner_uuid": cfg.SysUserUUID,
+                       "link_class": "permission",
+                       "name":       "can_read",
+                       "tail_uuid":  group.UUID,
+                       "head_uuid":  user.UUID,
+               }
+               if err := CreateLink(cfg, &newLink, linkData); err != nil {
+                       userID, _ := GetUserID(user, cfg.UserID)
+                       return fmt.Errorf("error adding group %q -> user %q read permission: %s", group.Name, userID, err)
+               }
        }
        linkData = map[string]string{
                "owner_uuid": cfg.SysUserUUID,
                "link_class": "permission",
-               "name":       "can_write",
+               "name":       perm,
                "tail_uuid":  user.UUID,
                "head_uuid":  group.UUID,
        }
        if err := CreateLink(cfg, &newLink, linkData); err != nil {
                userID, _ := GetUserID(user, cfg.UserID)
-               return fmt.Errorf("error adding user %q -> group %q write permission: %s", userID, group.Name, err)
+               return fmt.Errorf("error adding user %q -> group %q %s permission: %s", userID, group.Name, perm, err)
        }
        return nil
 }
index 3ef36007976afe04a411bcf613ea24f6bd71ce6a..9eec6b6d97aa3d1305f55f8d02e8d551e37ddc07 100644 (file)
@@ -106,7 +106,7 @@ func MakeTempCSVFile(data [][]string) (f *os.File, err error) {
 }
 
 // GroupMembershipExists checks that both needed links exist between user and group
-func GroupMembershipExists(ac *arvados.Client, userUUID string, groupUUID string) bool {
+func GroupMembershipExists(ac *arvados.Client, userUUID string, groupUUID string, perm string) bool {
        ll := LinkList{}
        // Check Group -> User can_read permission
        params := arvados.ResourceListParams{
@@ -145,7 +145,7 @@ func GroupMembershipExists(ac *arvados.Client, userUUID string, groupUUID string
                }, {
                        Attr:     "name",
                        Operator: "=",
-                       Operand:  "can_write",
+                       Operand:  perm,
                }, {
                        Attr:     "tail_uuid",
                        Operator: "=",
@@ -259,10 +259,103 @@ func (s *TestSuite) TestIgnoreSpaces(c *C) {
                groupUUID, err := RemoteGroupExists(s.cfg, groupName)
                c.Assert(err, IsNil)
                c.Assert(groupUUID, Not(Equals), "")
-               c.Assert(GroupMembershipExists(s.cfg.Client, activeUserUUID, groupUUID), Equals, true)
+               c.Assert(GroupMembershipExists(s.cfg.Client, activeUserUUID, groupUUID, "can_write"), Equals, true)
        }
 }
 
+// Error out when records have <2 or >3 records
+func (s *TestSuite) TestWrongNumberOfFields(c *C) {
+       for _, testCase := range [][][]string{
+               {{"field1"}},
+               {{"field1", "field2", "field3", "field4"}},
+               {{"field1", "field2", "field3", "field4", "field5"}},
+       } {
+               tmpfile, err := MakeTempCSVFile(testCase)
+               c.Assert(err, IsNil)
+               defer os.Remove(tmpfile.Name())
+               s.cfg.Path = tmpfile.Name()
+               err = doMain(s.cfg)
+               c.Assert(err, Not(IsNil))
+       }
+}
+
+// Check different membership permissions
+func (s *TestSuite) TestMembershipLevels(c *C) {
+       userEmail := s.users[arvadostest.ActiveUserUUID].Email
+       userUUID := s.users[arvadostest.ActiveUserUUID].UUID
+       data := [][]string{
+               {"TestGroup1", userEmail, "can_read"},
+               {"TestGroup2", userEmail, "can_write"},
+               {"TestGroup3", userEmail, "can_manage"},
+               {"TestGroup4", userEmail, "invalid_permission"},
+       }
+       tmpfile, err := MakeTempCSVFile(data)
+       c.Assert(err, IsNil)
+       defer os.Remove(tmpfile.Name()) // clean up
+       s.cfg.Path = tmpfile.Name()
+       err = doMain(s.cfg)
+       c.Assert(err, IsNil)
+       for _, record := range data {
+               groupName := record[0]
+               permLevel := record[2]
+               if permLevel != "invalid_permission" {
+                       groupUUID, err := RemoteGroupExists(s.cfg, groupName)
+                       c.Assert(err, IsNil)
+                       c.Assert(groupUUID, Not(Equals), "")
+                       c.Assert(GroupMembershipExists(s.cfg.Client, userUUID, groupUUID, permLevel), Equals, true)
+               } else {
+                       groupUUID, err := RemoteGroupExists(s.cfg, groupName)
+                       c.Assert(err, IsNil)
+                       c.Assert(groupUUID, Equals, "")
+               }
+       }
+}
+
+// Check membership level change
+func (s *TestSuite) TestMembershipLevelUpdate(c *C) {
+       userEmail := s.users[arvadostest.ActiveUserUUID].Email
+       userUUID := s.users[arvadostest.ActiveUserUUID].UUID
+       groupName := "TestGroup1"
+       // Give read permissions
+       tmpfile, err := MakeTempCSVFile([][]string{{groupName, userEmail, "can_read"}})
+       c.Assert(err, IsNil)
+       defer os.Remove(tmpfile.Name()) // clean up
+       s.cfg.Path = tmpfile.Name()
+       err = doMain(s.cfg)
+       c.Assert(err, IsNil)
+       // Check permissions
+       groupUUID, err := RemoteGroupExists(s.cfg, groupName)
+       c.Assert(err, IsNil)
+       c.Assert(groupUUID, Not(Equals), "")
+       c.Assert(GroupMembershipExists(s.cfg.Client, userUUID, groupUUID, "can_read"), Equals, true)
+       c.Assert(GroupMembershipExists(s.cfg.Client, userUUID, groupUUID, "can_write"), Equals, false)
+       c.Assert(GroupMembershipExists(s.cfg.Client, userUUID, groupUUID, "can_manage"), Equals, false)
+
+       // Give write permissions
+       tmpfile, err = MakeTempCSVFile([][]string{{groupName, userEmail, "can_write"}})
+       c.Assert(err, IsNil)
+       defer os.Remove(tmpfile.Name()) // clean up
+       s.cfg.Path = tmpfile.Name()
+       err = doMain(s.cfg)
+       c.Assert(err, IsNil)
+       // Check permissions
+       c.Assert(GroupMembershipExists(s.cfg.Client, userUUID, groupUUID, "can_read"), Equals, false)
+       c.Assert(GroupMembershipExists(s.cfg.Client, userUUID, groupUUID, "can_write"), Equals, true)
+       c.Assert(GroupMembershipExists(s.cfg.Client, userUUID, groupUUID, "can_manage"), Equals, false)
+
+       // Give manage permissions
+       tmpfile, err = MakeTempCSVFile([][]string{{groupName, userEmail, "can_manage"}})
+       c.Assert(err, IsNil)
+       defer os.Remove(tmpfile.Name()) // clean up
+       s.cfg.Path = tmpfile.Name()
+       err = doMain(s.cfg)
+       c.Assert(err, IsNil)
+       // Check permissions
+       c.Assert(GroupMembershipExists(s.cfg.Client, userUUID, groupUUID, "can_read"), Equals, false)
+       c.Assert(GroupMembershipExists(s.cfg.Client, userUUID, groupUUID, "can_write"), Equals, false)
+       c.Assert(GroupMembershipExists(s.cfg.Client, userUUID, groupUUID, "can_manage"), Equals, true)
+}
+
 // The absence of a user membership on the CSV file implies its removal
 func (s *TestSuite) TestMembershipRemoval(c *C) {
        localUserEmail := s.users[arvadostest.ActiveUserUUID].Email
@@ -286,8 +379,8 @@ func (s *TestSuite) TestMembershipRemoval(c *C) {
                groupUUID, err := RemoteGroupExists(s.cfg, groupName)
                c.Assert(err, IsNil)
                c.Assert(groupUUID, Not(Equals), "")
-               c.Assert(GroupMembershipExists(s.cfg.Client, localUserUUID, groupUUID), Equals, true)
-               c.Assert(GroupMembershipExists(s.cfg.Client, remoteUserUUID, groupUUID), Equals, true)
+               c.Assert(GroupMembershipExists(s.cfg.Client, localUserUUID, groupUUID, "can_write"), Equals, true)
+               c.Assert(GroupMembershipExists(s.cfg.Client, remoteUserUUID, groupUUID, "can_write"), Equals, true)
        }
        // New CSV with some previous membership missing
        data = [][]string{
@@ -304,14 +397,14 @@ func (s *TestSuite) TestMembershipRemoval(c *C) {
        groupUUID, err := RemoteGroupExists(s.cfg, "TestGroup1")
        c.Assert(err, IsNil)
        c.Assert(groupUUID, Not(Equals), "")
-       c.Assert(GroupMembershipExists(s.cfg.Client, localUserUUID, groupUUID), Equals, true)
-       c.Assert(GroupMembershipExists(s.cfg.Client, remoteUserUUID, groupUUID), Equals, false)
+       c.Assert(GroupMembershipExists(s.cfg.Client, localUserUUID, groupUUID, "can_write"), Equals, true)
+       c.Assert(GroupMembershipExists(s.cfg.Client, remoteUserUUID, groupUUID, "can_write"), Equals, false)
        // Confirm TestGroup1 memberships
        groupUUID, err = RemoteGroupExists(s.cfg, "TestGroup2")
        c.Assert(err, IsNil)
        c.Assert(groupUUID, Not(Equals), "")
-       c.Assert(GroupMembershipExists(s.cfg.Client, localUserUUID, groupUUID), Equals, false)
-       c.Assert(GroupMembershipExists(s.cfg.Client, remoteUserUUID, groupUUID), Equals, true)
+       c.Assert(GroupMembershipExists(s.cfg.Client, localUserUUID, groupUUID, "can_write"), Equals, false)
+       c.Assert(GroupMembershipExists(s.cfg.Client, remoteUserUUID, groupUUID, "can_write"), Equals, true)
 }
 
 // If a group doesn't exist on the system, create it before adding users
@@ -336,7 +429,7 @@ func (s *TestSuite) TestAutoCreateGroupWhenNotExisting(c *C) {
        c.Assert(err, IsNil)
        c.Assert(groupUUID, Not(Equals), "")
        // active user should be a member
-       c.Assert(GroupMembershipExists(s.cfg.Client, arvadostest.ActiveUserUUID, groupUUID), Equals, true)
+       c.Assert(GroupMembershipExists(s.cfg.Client, arvadostest.ActiveUserUUID, groupUUID, "can_write"), Equals, true)
 }
 
 // Users listed on the file that don't exist on the system are ignored
@@ -362,7 +455,7 @@ func (s *TestSuite) TestIgnoreNonexistantUsers(c *C) {
        groupUUID, err = RemoteGroupExists(s.cfg, "TestGroup4")
        c.Assert(err, IsNil)
        c.Assert(groupUUID, Not(Equals), "")
-       c.Assert(GroupMembershipExists(s.cfg.Client, activeUserUUID, groupUUID), Equals, true)
+       c.Assert(GroupMembershipExists(s.cfg.Client, activeUserUUID, groupUUID, "can_write"), Equals, true)
 }
 
 // Users listed on the file that don't exist on the system are ignored
@@ -370,13 +463,16 @@ func (s *TestSuite) TestIgnoreEmptyFields(c *C) {
        activeUserEmail := s.users[arvadostest.ActiveUserUUID].Email
        activeUserUUID := s.users[arvadostest.ActiveUserUUID].UUID
        // Confirm that group doesn't exist
-       groupUUID, err := RemoteGroupExists(s.cfg, "TestGroup4")
-       c.Assert(err, IsNil)
-       c.Assert(groupUUID, Equals, "")
+       for _, groupName := range []string{"TestGroup4", "TestGroup5"} {
+               groupUUID, err := RemoteGroupExists(s.cfg, groupName)
+               c.Assert(err, IsNil)
+               c.Assert(groupUUID, Equals, "")
+       }
        // Create file & run command
        data := [][]string{
-               {"", activeUserEmail}, // Empty field
-               {"TestGroup5", ""},    // Empty field
+               {"", activeUserEmail},               // Empty field
+               {"TestGroup5", ""},                  // Empty field
+               {"TestGroup5", activeUserEmail, ""}, // Empty 3rd field: is optional but cannot be empty
                {"TestGroup4", activeUserEmail},
        }
        tmpfile, err := MakeTempCSVFile(data)
@@ -385,11 +481,15 @@ func (s *TestSuite) TestIgnoreEmptyFields(c *C) {
        s.cfg.Path = tmpfile.Name()
        err = doMain(s.cfg)
        c.Assert(err, IsNil)
-       // Confirm that memberships exist
+       // Confirm that records about TestGroup5 were skipped
+       groupUUID, err := RemoteGroupExists(s.cfg, "TestGroup5")
+       c.Assert(err, IsNil)
+       c.Assert(groupUUID, Equals, "")
+       // Confirm that membership exists
        groupUUID, err = RemoteGroupExists(s.cfg, "TestGroup4")
        c.Assert(err, IsNil)
        c.Assert(groupUUID, Not(Equals), "")
-       c.Assert(GroupMembershipExists(s.cfg.Client, activeUserUUID, groupUUID), Equals, true)
+       c.Assert(GroupMembershipExists(s.cfg.Client, activeUserUUID, groupUUID, "can_write"), Equals, true)
 }
 
 // Instead of emails, use username as identifier
@@ -416,5 +516,5 @@ func (s *TestSuite) TestUseUsernames(c *C) {
        groupUUID, err = RemoteGroupExists(s.cfg, "TestGroup1")
        c.Assert(err, IsNil)
        c.Assert(groupUUID, Not(Equals), "")
-       c.Assert(GroupMembershipExists(s.cfg.Client, activeUserUUID, groupUUID), Equals, true)
+       c.Assert(GroupMembershipExists(s.cfg.Client, activeUserUUID, groupUUID, "can_write"), Equals, true)
 }