Merge branch '18097-sync-groups-case-insensitive' into main. Closes #18097
[arvados.git] / tools / sync-groups / sync-groups.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package main
6
7 import (
8         "bytes"
9         "encoding/csv"
10         "encoding/json"
11         "flag"
12         "fmt"
13         "io"
14         "log"
15         "net/url"
16         "os"
17         "strings"
18
19         "git.arvados.org/arvados.git/sdk/go/arvados"
20 )
21
22 var version = "dev"
23
24 type resourceList interface {
25         Len() int
26         GetItems() []interface{}
27 }
28
29 // GroupPermissions maps permission levels on groups (can_read, can_write, can_manage)
30 type GroupPermissions map[string]bool
31
32 // GroupInfo tracks previous and current member's permissions on a particular Group
33 type GroupInfo struct {
34         Group           arvados.Group
35         PreviousMembers map[string]GroupPermissions
36         CurrentMembers  map[string]GroupPermissions
37 }
38
39 // GetUserID returns the correct user id value depending on the selector
40 func GetUserID(u arvados.User, idSelector string) (string, error) {
41         switch idSelector {
42         case "email":
43                 return u.Email, nil
44         case "username":
45                 return u.Username, nil
46         default:
47                 return "", fmt.Errorf("cannot identify user by %q selector", idSelector)
48         }
49 }
50
51 // UserList implements resourceList interface
52 type UserList struct {
53         arvados.UserList
54 }
55
56 // Len returns the amount of items this list holds
57 func (l UserList) Len() int {
58         return len(l.Items)
59 }
60
61 // GetItems returns the list of items
62 func (l UserList) GetItems() (out []interface{}) {
63         for _, item := range l.Items {
64                 out = append(out, item)
65         }
66         return
67 }
68
69 // GroupList implements resourceList interface
70 type GroupList struct {
71         arvados.GroupList
72 }
73
74 // Len returns the amount of items this list holds
75 func (l GroupList) Len() int {
76         return len(l.Items)
77 }
78
79 // GetItems returns the list of items
80 func (l GroupList) GetItems() (out []interface{}) {
81         for _, item := range l.Items {
82                 out = append(out, item)
83         }
84         return
85 }
86
87 // LinkList implements resourceList interface
88 type LinkList struct {
89         arvados.LinkList
90 }
91
92 // Len returns the amount of items this list holds
93 func (l LinkList) Len() int {
94         return len(l.Items)
95 }
96
97 // GetItems returns the list of items
98 func (l LinkList) GetItems() (out []interface{}) {
99         for _, item := range l.Items {
100                 out = append(out, item)
101         }
102         return
103 }
104
105 func main() {
106         // Parse & validate arguments, set up arvados client.
107         cfg, err := GetConfig()
108         if err != nil {
109                 log.Fatalf("%v", err)
110         }
111
112         if err := doMain(&cfg); err != nil {
113                 log.Fatalf("%v", err)
114         }
115 }
116
117 // ConfigParams holds configuration data for this tool
118 type ConfigParams struct {
119         Path            string
120         UserID          string
121         Verbose         bool
122         CaseInsensitive bool
123         ParentGroupUUID string
124         ParentGroupName string
125         SysUserUUID     string
126         Client          *arvados.Client
127 }
128
129 // ParseFlags parses and validates command line arguments
130 func ParseFlags(config *ConfigParams) error {
131         // Acceptable attributes to identify a user on the CSV file
132         userIDOpts := map[string]bool{
133                 "email":    true, // default
134                 "username": true,
135         }
136
137         flags := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
138
139         // Set up usage message
140         flags.Usage = func() {
141                 usageStr := `Synchronize remote groups into Arvados from a CSV format file with 3 columns:
142   * 1st: Group name
143   * 2nd: User identifier
144   * 3rd (Optional): User permission on the group: can_read, can_write or can_manage. (Default: can_write)`
145                 fmt.Fprintf(os.Stderr, "%s\n\n", usageStr)
146                 fmt.Fprintf(os.Stderr, "Usage:\n%s [OPTIONS] <input-file.csv>\n\n", os.Args[0])
147                 fmt.Fprintf(os.Stderr, "Options:\n")
148                 flags.PrintDefaults()
149         }
150
151         // Set up option flags
152         userID := flags.String(
153                 "user-id",
154                 "email",
155                 "Attribute by which every user is identified. Valid values are: email and username.")
156         caseInsensitive := flags.Bool(
157                 "case-insensitive",
158                 false,
159                 "Performs case insensitive matching on user IDs. Off by default.")
160         verbose := flags.Bool(
161                 "verbose",
162                 false,
163                 "Log informational messages. Off by default.")
164         getVersion := flags.Bool(
165                 "version",
166                 false,
167                 "Print version information and exit.")
168         parentGroupUUID := flags.String(
169                 "parent-group-uuid",
170                 "",
171                 "Use given group UUID as a parent for the remote groups. Should be owned by the system user. If not specified, a group named '"+config.ParentGroupName+"' will be used (and created if nonexistant).")
172
173         // Parse args; omit the first arg which is the command name
174         flags.Parse(os.Args[1:])
175
176         // Print version information if requested
177         if *getVersion {
178                 fmt.Printf("%s %s\n", os.Args[0], version)
179                 os.Exit(0)
180         }
181
182         // Input file as a required positional argument
183         if flags.NArg() == 0 {
184                 return fmt.Errorf("please provide a path to an input file")
185         }
186         srcPath := &os.Args[flags.NFlag()+1]
187
188         // Validations
189         if *srcPath == "" {
190                 return fmt.Errorf("input file path invalid")
191         }
192         if !userIDOpts[*userID] {
193                 var options []string
194                 for opt := range userIDOpts {
195                         options = append(options, opt)
196                 }
197                 return fmt.Errorf("user ID must be one of: %s", strings.Join(options, ", "))
198         }
199
200         config.Path = *srcPath
201         config.ParentGroupUUID = *parentGroupUUID
202         config.UserID = *userID
203         config.Verbose = *verbose
204         config.CaseInsensitive = *caseInsensitive
205
206         return nil
207 }
208
209 // SetParentGroup finds/create parent group of all remote groups
210 func SetParentGroup(cfg *ConfigParams) error {
211         var parentGroup arvados.Group
212         if cfg.ParentGroupUUID == "" {
213                 // UUID not provided, search for preexisting parent group
214                 var gl GroupList
215                 params := arvados.ResourceListParams{
216                         Filters: []arvados.Filter{{
217                                 Attr:     "name",
218                                 Operator: "=",
219                                 Operand:  cfg.ParentGroupName,
220                         }, {
221                                 Attr:     "owner_uuid",
222                                 Operator: "=",
223                                 Operand:  cfg.SysUserUUID,
224                         }},
225                 }
226                 if err := cfg.Client.RequestAndDecode(&gl, "GET", "/arvados/v1/groups", nil, params); err != nil {
227                         return fmt.Errorf("error searching for parent group: %s", err)
228                 }
229                 if len(gl.Items) == 0 {
230                         // Default parent group does not exist, create it.
231                         if cfg.Verbose {
232                                 log.Println("Default parent group not found, creating...")
233                         }
234                         groupData := map[string]string{
235                                 "name":        cfg.ParentGroupName,
236                                 "owner_uuid":  cfg.SysUserUUID,
237                                 "group_class": "role",
238                         }
239                         if err := CreateGroup(cfg, &parentGroup, groupData); err != nil {
240                                 return fmt.Errorf("error creating system user owned group named %q: %s", groupData["name"], err)
241                         }
242                 } else if len(gl.Items) == 1 {
243                         // Default parent group found.
244                         parentGroup = gl.Items[0]
245                 } else {
246                         // This should never happen, as there's an unique index for
247                         // (owner_uuid, name) on groups.
248                         return fmt.Errorf("bug: found %d groups owned by system user and named %q", len(gl.Items), cfg.ParentGroupName)
249                 }
250                 cfg.ParentGroupUUID = parentGroup.UUID
251         } else {
252                 // UUID provided. Check if exists and if it's owned by system user
253                 if err := GetGroup(cfg, &parentGroup, cfg.ParentGroupUUID); err != nil {
254                         return fmt.Errorf("error searching for parent group with UUID %q: %s", cfg.ParentGroupUUID, err)
255                 }
256                 if parentGroup.OwnerUUID != cfg.SysUserUUID {
257                         return fmt.Errorf("parent group %q (%s) must be owned by system user", parentGroup.Name, cfg.ParentGroupUUID)
258                 }
259         }
260         return nil
261 }
262
263 // GetConfig sets up a ConfigParams struct
264 func GetConfig() (config ConfigParams, err error) {
265         config.ParentGroupName = "Externally synchronized groups"
266
267         // Command arguments
268         err = ParseFlags(&config)
269         if err != nil {
270                 return config, err
271         }
272
273         // Arvados Client setup
274         config.Client = arvados.NewClientFromEnv()
275
276         // Check current user permissions & get System user's UUID
277         u, err := config.Client.CurrentUser()
278         if err != nil {
279                 return config, fmt.Errorf("error getting the current user: %s", err)
280         }
281         if !u.IsActive || !u.IsAdmin {
282                 return config, fmt.Errorf("current user (%s) is not an active admin user", u.UUID)
283         }
284
285         var ac struct{ ClusterID string }
286         err = config.Client.RequestAndDecode(&ac, "GET", "arvados/v1/config", nil, nil)
287         if err != nil {
288                 return config, fmt.Errorf("error getting the exported config: %s", err)
289         }
290         config.SysUserUUID = ac.ClusterID + "-tpzed-000000000000000"
291
292         // Set up remote groups' parent
293         if err = SetParentGroup(&config); err != nil {
294                 return config, err
295         }
296
297         return config, nil
298 }
299
300 func doMain(cfg *ConfigParams) error {
301         // Try opening the input file early, just in case there's a problem.
302         f, err := os.Open(cfg.Path)
303         if err != nil {
304                 return fmt.Errorf("%s", err)
305         }
306         defer f.Close()
307
308         iCaseLog := ""
309         if cfg.UserID == "username" && cfg.CaseInsensitive {
310                 iCaseLog = " - username matching requested to be case-insensitive"
311         }
312         log.Printf("%s %s started. Using %q as users id and parent group UUID %q%s", os.Args[0], version, cfg.UserID, cfg.ParentGroupUUID, iCaseLog)
313
314         // Get the complete user list to minimize API Server requests
315         allUsers := make(map[string]arvados.User)
316         userIDToUUID := make(map[string]string) // Index by email or username
317         results, err := GetAll(cfg.Client, "users", arvados.ResourceListParams{}, &UserList{})
318         if err != nil {
319                 return fmt.Errorf("error getting user list: %s", err)
320         }
321         log.Printf("Found %d users", len(results))
322         for _, item := range results {
323                 u := item.(arvados.User)
324                 allUsers[u.UUID] = u
325                 uID, err := GetUserID(u, cfg.UserID)
326                 if err != nil {
327                         return err
328                 }
329                 if cfg.UserID == "username" && uID != "" && cfg.CaseInsensitive {
330                         uID = strings.ToLower(uID)
331                         if uuid, found := userIDToUUID[uID]; found {
332                                 return fmt.Errorf("case insensitive collision for username %q between %q and %q", uID, u.UUID, uuid)
333                         }
334                 }
335                 userIDToUUID[uID] = u.UUID
336                 if cfg.Verbose {
337                         log.Printf("Seen user %q (%s)", u.Username, u.UUID)
338                 }
339         }
340
341         // Get remote groups and their members
342         remoteGroups, groupNameToUUID, err := GetRemoteGroups(cfg, allUsers)
343         if err != nil {
344                 return err
345         }
346         log.Printf("Found %d remote groups", len(remoteGroups))
347         if cfg.Verbose {
348                 for groupUUID := range remoteGroups {
349                         log.Printf("- Group %q: %d users", remoteGroups[groupUUID].Group.Name, len(remoteGroups[groupUUID].PreviousMembers))
350                 }
351         }
352
353         membershipsRemoved := 0
354
355         // Read the CSV file
356         groupsCreated, membershipsAdded, membershipsSkipped, err := ProcessFile(cfg, f, userIDToUUID, groupNameToUUID, remoteGroups, allUsers)
357         if err != nil {
358                 return err
359         }
360
361         // Remove previous members not listed on this run
362         for groupUUID := range remoteGroups {
363                 gi := remoteGroups[groupUUID]
364                 evictedMemberPerms := subtract(gi.PreviousMembers, gi.CurrentMembers)
365                 groupName := gi.Group.Name
366                 if len(evictedMemberPerms) > 0 {
367                         log.Printf("Removing permissions from %d users on group %q", len(evictedMemberPerms), groupName)
368                 }
369                 for member := range evictedMemberPerms {
370                         var perms []string
371                         completeMembershipRemoval := false
372                         if _, ok := gi.CurrentMembers[member]; !ok {
373                                 completeMembershipRemoval = true
374                                 membershipsRemoved++
375                         } else {
376                                 // Collect which user->group permission links should be removed
377                                 for p := range evictedMemberPerms[member] {
378                                         if evictedMemberPerms[member][p] {
379                                                 perms = append(perms, p)
380                                         }
381                                 }
382                                 membershipsRemoved += len(perms)
383                         }
384                         if err := RemoveMemberLinksFromGroup(cfg, allUsers[userIDToUUID[member]],
385                                 perms, completeMembershipRemoval, gi.Group); err != nil {
386                                 return err
387                         }
388                 }
389         }
390         log.Printf("Groups created: %d. Memberships added: %d, removed: %d, skipped: %d", groupsCreated, membershipsAdded, membershipsRemoved, membershipsSkipped)
391
392         return nil
393 }
394
395 // ProcessFile reads the CSV file and process every record
396 func ProcessFile(
397         cfg *ConfigParams,
398         f *os.File,
399         userIDToUUID map[string]string,
400         groupNameToUUID map[string]string,
401         remoteGroups map[string]*GroupInfo,
402         allUsers map[string]arvados.User,
403 ) (groupsCreated, membersAdded, membersSkipped int, err error) {
404         lineNo := 0
405         csvReader := csv.NewReader(f)
406         // Allow variable number of fields.
407         csvReader.FieldsPerRecord = -1
408         for {
409                 record, e := csvReader.Read()
410                 if e == io.EOF {
411                         break
412                 }
413                 lineNo++
414                 if e != nil {
415                         err = fmt.Errorf("error parsing %q, line %d", cfg.Path, lineNo)
416                         return
417                 }
418                 // Only allow 2 or 3 fields per record for backwards compatibility.
419                 if len(record) < 2 || len(record) > 3 {
420                         err = fmt.Errorf("error parsing %q, line %d: found %d fields but only 2 or 3 are allowed", cfg.Path, lineNo, len(record))
421                         return
422                 }
423                 groupName := strings.TrimSpace(record[0])
424                 groupMember := strings.TrimSpace(record[1]) // User ID (username or email)
425                 groupPermission := "can_write"
426                 if len(record) == 3 {
427                         groupPermission = strings.ToLower(record[2])
428                 }
429                 if groupName == "" || groupMember == "" || groupPermission == "" {
430                         log.Printf("Warning: CSV record has at least one empty field (%s, %s, %s). Skipping", groupName, groupMember, groupPermission)
431                         membersSkipped++
432                         continue
433                 }
434                 if cfg.UserID == "username" && cfg.CaseInsensitive {
435                         groupMember = strings.ToLower(groupMember)
436                 }
437                 if !(groupPermission == "can_read" || groupPermission == "can_write" || groupPermission == "can_manage") {
438                         log.Printf("Warning: 3rd field should be 'can_read', 'can_write' or 'can_manage'. Found: %q at line %d, skipping.", groupPermission, lineNo)
439                         membersSkipped++
440                         continue
441                 }
442                 if _, found := userIDToUUID[groupMember]; !found {
443                         // User not present on the system, skip.
444                         log.Printf("Warning: there's no user with %s %q on the system, skipping.", cfg.UserID, groupMember)
445                         membersSkipped++
446                         continue
447                 }
448                 if _, found := groupNameToUUID[groupName]; !found {
449                         // Group doesn't exist, create it before continuing
450                         if cfg.Verbose {
451                                 log.Printf("Remote group %q not found, creating...", groupName)
452                         }
453                         var newGroup arvados.Group
454                         groupData := map[string]string{
455                                 "name":        groupName,
456                                 "owner_uuid":  cfg.ParentGroupUUID,
457                                 "group_class": "role",
458                         }
459                         if e := CreateGroup(cfg, &newGroup, groupData); e != nil {
460                                 err = fmt.Errorf("error creating group named %q: %s", groupName, e)
461                                 return
462                         }
463                         // Update cached group data
464                         groupNameToUUID[groupName] = newGroup.UUID
465                         remoteGroups[newGroup.UUID] = &GroupInfo{
466                                 Group:           newGroup,
467                                 PreviousMembers: make(map[string]GroupPermissions),
468                                 CurrentMembers:  make(map[string]GroupPermissions),
469                         }
470                         groupsCreated++
471                 }
472                 // Both group & user exist, check if user is a member
473                 groupUUID := groupNameToUUID[groupName]
474                 gi := remoteGroups[groupUUID]
475                 if !gi.PreviousMembers[groupMember][groupPermission] && !gi.CurrentMembers[groupMember][groupPermission] {
476                         if cfg.Verbose {
477                                 log.Printf("Adding %q to group %q", groupMember, groupName)
478                         }
479                         // User permissionwasn't there, but should be. Avoid duplicating the
480                         // group->user link when necessary.
481                         createG2ULink := true
482                         if _, ok := gi.PreviousMembers[groupMember]; ok {
483                                 createG2ULink = false // User is already member of the group
484                         }
485                         if e := AddMemberToGroup(cfg, allUsers[userIDToUUID[groupMember]], gi.Group, groupPermission, createG2ULink); e != nil {
486                                 err = e
487                                 return
488                         }
489                         membersAdded++
490                 }
491                 if _, ok := gi.CurrentMembers[groupMember]; ok {
492                         gi.CurrentMembers[groupMember][groupPermission] = true
493                 } else {
494                         gi.CurrentMembers[groupMember] = GroupPermissions{groupPermission: true}
495                 }
496
497         }
498         return
499 }
500
501 // GetAll : Adds all objects of type 'resource' to the 'allItems' list
502 func GetAll(c *arvados.Client, res string, params arvados.ResourceListParams, page resourceList) (allItems []interface{}, err error) {
503         // Use the maximum page size the server allows
504         limit := 1<<31 - 1
505         params.Limit = &limit
506         params.Offset = 0
507         params.Order = "uuid"
508         for {
509                 if err = GetResourceList(c, &page, res, params); err != nil {
510                         return allItems, err
511                 }
512                 // Have we finished paging?
513                 if page.Len() == 0 {
514                         break
515                 }
516                 allItems = append(allItems, page.GetItems()...)
517                 params.Offset += page.Len()
518         }
519         return allItems, nil
520 }
521
522 func subtract(setA map[string]GroupPermissions, setB map[string]GroupPermissions) map[string]GroupPermissions {
523         result := make(map[string]GroupPermissions)
524         for element := range setA {
525                 if _, ok := setB[element]; !ok {
526                         result[element] = setA[element]
527                 } else {
528                         for perm := range setA[element] {
529                                 if _, ok := setB[element][perm]; !ok {
530                                         result[element] = GroupPermissions{perm: true}
531                                 }
532                         }
533                 }
534         }
535         return result
536 }
537
538 func jsonReader(rscName string, ob interface{}) io.Reader {
539         j, err := json.Marshal(ob)
540         if err != nil {
541                 panic(err)
542         }
543         v := url.Values{}
544         v[rscName] = []string{string(j)}
545         return bytes.NewBufferString(v.Encode())
546 }
547
548 // GetRemoteGroups fetches all remote groups with their members
549 func GetRemoteGroups(cfg *ConfigParams, allUsers map[string]arvados.User) (remoteGroups map[string]*GroupInfo, groupNameToUUID map[string]string, err error) {
550         remoteGroups = make(map[string]*GroupInfo)
551         groupNameToUUID = make(map[string]string) // Index by group name
552
553         params := arvados.ResourceListParams{
554                 Filters: []arvados.Filter{{
555                         Attr:     "tail_uuid",
556                         Operator: "=",
557                         Operand:  cfg.ParentGroupUUID,
558                 }},
559         }
560         results, err := GetAll(cfg.Client, "links", params, &LinkList{})
561         if err != nil {
562                 return remoteGroups, groupNameToUUID, fmt.Errorf("error getting remote groups: %s", err)
563         }
564         for _, item := range results {
565                 var group arvados.Group
566                 err = GetGroup(cfg, &group, item.(arvados.Link).HeadUUID)
567                 if err != nil {
568                         return remoteGroups, groupNameToUUID, fmt.Errorf("error getting remote group: %s", err)
569                 }
570                 // Group -> User filter
571                 g2uFilter := arvados.ResourceListParams{
572                         Filters: []arvados.Filter{{
573                                 Attr:     "owner_uuid",
574                                 Operator: "=",
575                                 Operand:  cfg.SysUserUUID,
576                         }, {
577                                 Attr:     "link_class",
578                                 Operator: "=",
579                                 Operand:  "permission",
580                         }, {
581                                 Attr:     "name",
582                                 Operator: "=",
583                                 Operand:  "can_read",
584                         }, {
585                                 Attr:     "tail_uuid",
586                                 Operator: "=",
587                                 Operand:  group.UUID,
588                         }, {
589                                 Attr:     "head_uuid",
590                                 Operator: "is_a",
591                                 Operand:  "arvados#user",
592                         }},
593                 }
594                 // User -> Group filter
595                 u2gFilter := arvados.ResourceListParams{
596                         Filters: []arvados.Filter{{
597                                 Attr:     "owner_uuid",
598                                 Operator: "=",
599                                 Operand:  cfg.SysUserUUID,
600                         }, {
601                                 Attr:     "link_class",
602                                 Operator: "=",
603                                 Operand:  "permission",
604                         }, {
605                                 Attr:     "name",
606                                 Operator: "in",
607                                 Operand:  []string{"can_read", "can_write", "can_manage"},
608                         }, {
609                                 Attr:     "head_uuid",
610                                 Operator: "=",
611                                 Operand:  group.UUID,
612                         }, {
613                                 Attr:     "tail_uuid",
614                                 Operator: "is_a",
615                                 Operand:  "arvados#user",
616                         }},
617                 }
618                 g2uLinks, err := GetAll(cfg.Client, "links", g2uFilter, &LinkList{})
619                 if err != nil {
620                         return remoteGroups, groupNameToUUID, fmt.Errorf("error getting group->user 'can_read' links for group %q: %s", group.Name, err)
621                 }
622                 u2gLinks, err := GetAll(cfg.Client, "links", u2gFilter, &LinkList{})
623                 if err != nil {
624                         return remoteGroups, groupNameToUUID, fmt.Errorf("error getting user->group links for group %q: %s", group.Name, err)
625                 }
626                 // Build a list of user ids (email or username) belonging to this group.
627                 membersSet := make(map[string]GroupPermissions)
628                 u2gLinkSet := make(map[string]GroupPermissions)
629                 for _, l := range u2gLinks {
630                         link := l.(arvados.Link)
631                         // Also save the member's group access level.
632                         if _, ok := u2gLinkSet[link.TailUUID]; ok {
633                                 u2gLinkSet[link.TailUUID][link.Name] = true
634                         } else {
635                                 u2gLinkSet[link.TailUUID] = GroupPermissions{link.Name: true}
636                         }
637                 }
638                 for _, item := range g2uLinks {
639                         link := item.(arvados.Link)
640                         // We may have received an old link pointing to a removed account.
641                         if _, found := allUsers[link.HeadUUID]; !found {
642                                 continue
643                         }
644                         // The matching User -> Group link may not exist if the link
645                         // creation failed on a previous run. If that's the case, don't
646                         // include this account on the "previous members" list.
647                         if _, found := u2gLinkSet[link.HeadUUID]; !found {
648                                 continue
649                         }
650                         memberID, err := GetUserID(allUsers[link.HeadUUID], cfg.UserID)
651                         if err != nil {
652                                 return remoteGroups, groupNameToUUID, err
653                         }
654                         if cfg.UserID == "username" && cfg.CaseInsensitive {
655                                 memberID = strings.ToLower(memberID)
656                         }
657                         membersSet[memberID] = u2gLinkSet[link.HeadUUID]
658                 }
659                 remoteGroups[group.UUID] = &GroupInfo{
660                         Group:           group,
661                         PreviousMembers: membersSet,
662                         CurrentMembers:  make(map[string]GroupPermissions),
663                 }
664                 groupNameToUUID[group.Name] = group.UUID
665         }
666         return remoteGroups, groupNameToUUID, nil
667 }
668
669 // RemoveMemberLinksFromGroup remove all links related to the membership
670 func RemoveMemberLinksFromGroup(cfg *ConfigParams, user arvados.User, linkNames []string, completeRemoval bool, group arvados.Group) error {
671         if cfg.Verbose {
672                 log.Printf("Getting group membership links for user %q (%s) on group %q (%s)", user.Username, user.UUID, group.Name, group.UUID)
673         }
674         var links []interface{}
675         var filters [][]arvados.Filter
676         if completeRemoval {
677                 // Search for all group<->user links (both ways)
678                 filters = [][]arvados.Filter{
679                         // Group -> User
680                         {{
681                                 Attr:     "link_class",
682                                 Operator: "=",
683                                 Operand:  "permission",
684                         }, {
685                                 Attr:     "tail_uuid",
686                                 Operator: "=",
687                                 Operand:  group.UUID,
688                         }, {
689                                 Attr:     "head_uuid",
690                                 Operator: "=",
691                                 Operand:  user.UUID,
692                         }},
693                         // Group <- User
694                         {{
695                                 Attr:     "link_class",
696                                 Operator: "=",
697                                 Operand:  "permission",
698                         }, {
699                                 Attr:     "tail_uuid",
700                                 Operator: "=",
701                                 Operand:  user.UUID,
702                         }, {
703                                 Attr:     "head_uuid",
704                                 Operator: "=",
705                                 Operand:  group.UUID,
706                         }},
707                 }
708         } else {
709                 // Search only for the requested Group <- User permission links
710                 filters = [][]arvados.Filter{
711                         {{
712                                 Attr:     "link_class",
713                                 Operator: "=",
714                                 Operand:  "permission",
715                         }, {
716                                 Attr:     "tail_uuid",
717                                 Operator: "=",
718                                 Operand:  user.UUID,
719                         }, {
720                                 Attr:     "head_uuid",
721                                 Operator: "=",
722                                 Operand:  group.UUID,
723                         }, {
724                                 Attr:     "name",
725                                 Operator: "in",
726                                 Operand:  linkNames,
727                         }},
728                 }
729         }
730
731         for _, filterset := range filters {
732                 l, err := GetAll(cfg.Client, "links", arvados.ResourceListParams{Filters: filterset}, &LinkList{})
733                 if err != nil {
734                         userID, _ := GetUserID(user, cfg.UserID)
735                         return fmt.Errorf("error getting links needed to remove user %q from group %q: %s", userID, group.Name, err)
736                 }
737                 links = append(links, l...)
738         }
739         for _, item := range links {
740                 link := item.(arvados.Link)
741                 userID, _ := GetUserID(user, cfg.UserID)
742                 if cfg.Verbose {
743                         log.Printf("Removing %q permission link for %q on group %q", link.Name, userID, group.Name)
744                 }
745                 if err := DeleteLink(cfg, link.UUID); err != nil {
746                         return fmt.Errorf("error removing user %q from group %q: %s", userID, group.Name, err)
747                 }
748         }
749         return nil
750 }
751
752 // AddMemberToGroup create membership links
753 func AddMemberToGroup(cfg *ConfigParams, user arvados.User, group arvados.Group, perm string, createG2ULink bool) error {
754         var newLink arvados.Link
755         var linkData map[string]string
756         if createG2ULink {
757                 linkData = map[string]string{
758                         "owner_uuid": cfg.SysUserUUID,
759                         "link_class": "permission",
760                         "name":       "can_read",
761                         "tail_uuid":  group.UUID,
762                         "head_uuid":  user.UUID,
763                 }
764                 if err := CreateLink(cfg, &newLink, linkData); err != nil {
765                         userID, _ := GetUserID(user, cfg.UserID)
766                         return fmt.Errorf("error adding group %q -> user %q read permission: %s", group.Name, userID, err)
767                 }
768         }
769         linkData = map[string]string{
770                 "owner_uuid": cfg.SysUserUUID,
771                 "link_class": "permission",
772                 "name":       perm,
773                 "tail_uuid":  user.UUID,
774                 "head_uuid":  group.UUID,
775         }
776         if err := CreateLink(cfg, &newLink, linkData); err != nil {
777                 userID, _ := GetUserID(user, cfg.UserID)
778                 return fmt.Errorf("error adding user %q -> group %q %s permission: %s", userID, group.Name, perm, err)
779         }
780         return nil
781 }
782
783 // CreateGroup creates a group with groupData parameters, assigns it to dst
784 func CreateGroup(cfg *ConfigParams, dst *arvados.Group, groupData map[string]string) error {
785         return cfg.Client.RequestAndDecode(dst, "POST", "/arvados/v1/groups", jsonReader("group", groupData), nil)
786 }
787
788 // GetGroup fetches a group by its UUID
789 func GetGroup(cfg *ConfigParams, dst *arvados.Group, groupUUID string) error {
790         return cfg.Client.RequestAndDecode(&dst, "GET", "/arvados/v1/groups/"+groupUUID, nil, nil)
791 }
792
793 // CreateLink creates a link with linkData parameters, assigns it to dst
794 func CreateLink(cfg *ConfigParams, dst *arvados.Link, linkData map[string]string) error {
795         return cfg.Client.RequestAndDecode(dst, "POST", "/arvados/v1/links", jsonReader("link", linkData), nil)
796 }
797
798 // DeleteLink deletes a link by its UUID
799 func DeleteLink(cfg *ConfigParams, linkUUID string) error {
800         if linkUUID == "" {
801                 return fmt.Errorf("cannot delete link with invalid UUID: %q", linkUUID)
802         }
803         return cfg.Client.RequestAndDecode(&arvados.Link{}, "DELETE", "/arvados/v1/links/"+linkUUID, nil, nil)
804 }
805
806 // GetResourceList fetches res list using params
807 func GetResourceList(c *arvados.Client, dst *resourceList, res string, params interface{}) error {
808         return c.RequestAndDecode(dst, "GET", "/arvados/v1/"+res, nil, params)
809 }