12018: Removed superfluous check
[arvados.git] / tools / arv-sync-groups / arv-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         "encoding/csv"
9         "flag"
10         "fmt"
11         "io"
12         "log"
13         "os"
14         "strings"
15
16         "git.curoverse.com/arvados.git/sdk/go/arvados"
17         "git.curoverse.com/arvados.git/sdk/go/arvadosclient"
18 )
19
20 type resourceList interface {
21         items() []interface{}
22         itemsAvailable() int
23         offset() int
24 }
25
26 type groupInfo struct {
27         Group           group
28         PreviousMembers map[string]bool
29         CurrentMembers  map[string]bool
30 }
31
32 type user struct {
33         UUID     string `json:"uuid,omitempty"`
34         Email    string `json:"email,omitempty"`
35         Username string `json:"username,omitempty"`
36 }
37
38 func (u user) GetID(idSelector string) (string, error) {
39         switch idSelector {
40         case "email":
41                 return u.Email, nil
42         case "username":
43                 return u.Username, nil
44         default:
45                 return "", fmt.Errorf("cannot identify user by %q selector", idSelector)
46         }
47 }
48
49 // userList implements resourceList interface
50 type userList struct {
51         Items          []user `json:"items"`
52         ItemsAvailable int    `json:"items_available"`
53         Offset         int    `json:"offset"`
54 }
55
56 func (l userList) items() []interface{} {
57         var out []interface{}
58         for _, item := range l.Items {
59                 out = append(out, item)
60         }
61         return out
62 }
63
64 func (l userList) itemsAvailable() int {
65         return l.ItemsAvailable
66 }
67
68 func (l userList) offset() int {
69         return l.Offset
70 }
71
72 type group struct {
73         UUID      string `json:"uuid,omitempty"`
74         Name      string `json:"name,omitempty"`
75         OwnerUUID string `json:"owner_uuid,omitempty"`
76 }
77
78 // groupList implements resourceList interface
79 type groupList struct {
80         Items          []group `json:"items"`
81         ItemsAvailable int     `json:"items_available"`
82         Offset         int     `json:"offset"`
83 }
84
85 func (l groupList) items() []interface{} {
86         var out []interface{}
87         for _, item := range l.Items {
88                 out = append(out, item)
89         }
90         return out
91 }
92
93 func (l groupList) itemsAvailable() int {
94         return l.ItemsAvailable
95 }
96
97 func (l groupList) offset() int {
98         return l.Offset
99 }
100
101 type link struct {
102         UUID      string `json:"uuid, omiempty"`
103         Name      string `json:"name,omitempty"`
104         LinkClass string `json:"link_class,omitempty"`
105         HeadUUID  string `json:"head_uuid,omitempty"`
106         HeadKind  string `json:"head_kind,omitempty"`
107         TailUUID  string `json:"tail_uuid,omitempty"`
108         TailKind  string `json:"tail_kind,omitempty"`
109 }
110
111 // linkList implements resourceList interface
112 type linkList struct {
113         Items          []link `json:"items"`
114         ItemsAvailable int    `json:"items_available"`
115         Offset         int    `json:"offset"`
116 }
117
118 func (l linkList) items() []interface{} {
119         var out []interface{}
120         for _, item := range l.Items {
121                 out = append(out, item)
122         }
123         return out
124 }
125
126 func (l linkList) itemsAvailable() int {
127         return l.ItemsAvailable
128 }
129
130 func (l linkList) offset() int {
131         return l.Offset
132 }
133
134 func main() {
135         err := doMain()
136         if err != nil {
137                 log.Fatalf("%v", err)
138         }
139 }
140
141 func doMain() error {
142         const groupTag string = "remote_group"
143         const remoteGroupParentName string = "Externally synchronized groups"
144         userIDOpts := []string{"email", "username"}
145
146         flags := flag.NewFlagSet("arv-sync-groups", flag.ExitOnError)
147
148         srcPath := flags.String(
149                 "path",
150                 "",
151                 "Local file path containing a CSV format.")
152
153         userID := flags.String(
154                 "user-id",
155                 "email",
156                 "Attribute by which every user is identified. "+
157                         "Valid values are: email (the default) and username.")
158
159         verbose := flags.Bool(
160                 "verbose",
161                 false,
162                 "Log informational messages. By default is deactivated.")
163
164         retries := flags.Int(
165                 "retries",
166                 3,
167                 "Maximum number of times to retry server requests that encounter "+
168                         "temporary failures (e.g., server down).  Default 3.")
169
170         parentGroupUUID := flags.String(
171                 "parent-group-uuid",
172                 "",
173                 "Use given group UUID as a parent for the remote groups. Should "+
174                         "be owned by the system user. If not specified, a group named '"+
175                         remoteGroupParentName+"' will be used (and created if nonexistant).")
176
177         // Parse args; omit the first arg which is the command name
178         flags.Parse(os.Args[1:])
179
180         // Validations
181         if *retries < 0 {
182                 return fmt.Errorf("retry quantity must be >= 0")
183         }
184
185         if *srcPath == "" {
186                 return fmt.Errorf("please provide a path to an input file")
187         }
188
189         // Try opening the input file early, just in case there's problems.
190         f, err := os.Open(*srcPath)
191         if err != nil {
192                 return fmt.Errorf("%s", err)
193         }
194         defer f.Close()
195
196         validUserID := false
197         for _, opt := range userIDOpts {
198                 if *userID == opt {
199                         validUserID = true
200                 }
201         }
202         if !validUserID {
203                 return fmt.Errorf("user ID must be one of: %s",
204                         strings.Join(userIDOpts, ", "))
205         }
206
207         // Arvados Client setup
208         ac := arvados.NewClientFromEnv()
209         arv, err := arvadosclient.New(ac)
210         if err != nil {
211                 return fmt.Errorf("error setting up arvados client %s", err)
212         }
213         arv.Retries = *retries
214
215         // Check current user permissions & get System user's UUID
216         u, err := ac.CurrentUser()
217         if err != nil {
218                 return fmt.Errorf("error getting the current user: %s", err)
219         }
220         if !u.IsActive || !u.IsAdmin {
221                 return fmt.Errorf("current user (%s) is not an active admin user", u.UUID)
222         }
223         sysUserUUID := u.UUID[:12] + "000000000000000"
224
225         // Find/create parent group
226         var parentGroup group
227         if *parentGroupUUID == "" {
228                 // UUID not provided, search for preexisting parent group
229                 var gl groupList
230                 err := arv.List("groups", arvadosclient.Dict{
231                         "filters": [][]string{
232                                 {"name", "=", remoteGroupParentName},
233                                 {"owner_uuid", "=", sysUserUUID}},
234                 }, &gl)
235                 if err != nil {
236                         return fmt.Errorf("error searching for parent group: %s", err)
237                 }
238                 if len(gl.Items) == 0 {
239                         // Default parent group not existant, create one.
240                         if *verbose {
241                                 log.Println("Default parent group not found, creating...")
242                         }
243                         err := arv.Create("groups", arvadosclient.Dict{
244                                 "group": arvadosclient.Dict{
245                                         "name":       remoteGroupParentName,
246                                         "owner_uuid": sysUserUUID},
247                         }, &parentGroup)
248                         if err != nil {
249                                 return fmt.Errorf("error creating system user owned group named %q: %s", remoteGroupParentName, err)
250                         }
251                 } else if len(gl.Items) == 1 {
252                         // Default parent group found.
253                         parentGroup = gl.Items[0]
254                 } else {
255                         // This should never happen, as there's an unique index for
256                         // (owner_uuid, name) on groups.
257                         return fmt.Errorf("found %d groups owned by system user and named %q", len(gl.Items), remoteGroupParentName)
258                 }
259         } else {
260                 // UUID provided. Check if exists and if it's owned by system user
261                 err := arv.Get("groups", *parentGroupUUID, arvadosclient.Dict{}, &parentGroup)
262                 if err != nil {
263                         return fmt.Errorf("error searching for parent group with UUID %q: %s", *parentGroupUUID, err)
264                 }
265                 if parentGroup.OwnerUUID != sysUserUUID {
266                         return fmt.Errorf("parent group %q (%s) must be owned by system user", parentGroup.Name, *parentGroupUUID)
267                 }
268         }
269
270         log.Printf("Group sync starting. Using %q as users id and parent group UUID %q", *userID, parentGroup.UUID)
271
272         // Get the complete user list to minimize API Server requests
273         allUsers := make(map[string]user)
274         userIDToUUID := make(map[string]string) // Index by email or username
275         results, err := ListAll(arv, "users", arvadosclient.Dict{}, &userList{})
276         if err != nil {
277                 return fmt.Errorf("error getting user list: %s", err)
278         }
279         log.Printf("Found %d users", len(results))
280         for _, item := range results {
281                 u := item.(user)
282                 allUsers[u.UUID] = u
283                 uID, err := u.GetID(*userID)
284                 if err != nil {
285                         return err
286                 }
287                 userIDToUUID[uID] = u.UUID
288                 if *verbose {
289                         log.Printf("Seen user %q (%s)", u.Username, u.Email)
290                 }
291         }
292
293         // Request all UUIDs for groups tagged as remote
294         remoteGroupUUIDs := make(map[string]bool)
295         results, err = ListAll(arv, "links", arvadosclient.Dict{
296                 "filters": [][]string{
297                         {"link_class", "=", "tag"},
298                         {"name", "=", groupTag},
299                         {"head_kind", "=", "arvados#group"},
300                 },
301         }, &linkList{})
302         if err != nil {
303                 return fmt.Errorf("error getting remote group UUIDs: %s", err)
304         }
305         for _, item := range results {
306                 link := item.(link)
307                 remoteGroupUUIDs[link.HeadUUID] = true
308         }
309         // Get remote groups and their members
310         var uuidList []string
311         for uuid := range remoteGroupUUIDs {
312                 uuidList = append(uuidList, uuid)
313         }
314         remoteGroups := make(map[string]*groupInfo)
315         groupNameToUUID := make(map[string]string) // Index by group name
316         results, err = ListAll(arv, "groups", arvadosclient.Dict{
317                 "filters": [][]interface{}{
318                         {"uuid", "in", uuidList},
319                         {"owner_uuid", "=", parentGroup.UUID},
320                 },
321         }, &groupList{})
322         if err != nil {
323                 return fmt.Errorf("error getting remote groups by UUID: %s", err)
324         }
325         for _, item := range results {
326                 group := item.(group)
327                 results, err := ListAll(arv, "links", arvadosclient.Dict{
328                         "filters": [][]string{
329                                 {"link_class", "=", "permission"},
330                                 {"name", "=", "can_read"},
331                                 {"tail_uuid", "=", group.UUID},
332                                 {"head_kind", "=", "arvados#user"},
333                         },
334                 }, &linkList{})
335                 if err != nil {
336                         return fmt.Errorf("error getting member links: %s", err)
337                 }
338                 // Build a list of user ids (email or username) belonging to this group
339                 membersSet := make(map[string]bool)
340                 for _, item := range results {
341                         link := item.(link)
342                         memberID, err := allUsers[link.HeadUUID].GetID(*userID)
343                         if err != nil {
344                                 return err
345                         }
346                         membersSet[memberID] = true
347                 }
348                 remoteGroups[group.UUID] = &groupInfo{
349                         Group:           group,
350                         PreviousMembers: membersSet,
351                         CurrentMembers:  make(map[string]bool), // Empty set
352                 }
353                 groupNameToUUID[group.Name] = group.UUID
354         }
355         log.Printf("Found %d remote groups", len(remoteGroups))
356
357         groupsCreated := 0
358         membersAdded := 0
359         membersRemoved := 0
360
361         csvReader := csv.NewReader(f)
362         for {
363                 record, err := csvReader.Read()
364                 if err == io.EOF {
365                         break
366                 }
367                 if err != nil {
368                         return fmt.Errorf("error reading %q: %s", *srcPath, err)
369                 }
370                 groupName := record[0]
371                 groupMember := record[1] // User ID (username or email)
372                 if _, found := userIDToUUID[groupMember]; !found {
373                         // User not present on the system, skip.
374                         log.Printf("Warning: there's no user with %s %q on the system, skipping.", *userID, groupMember)
375                         continue
376                 }
377                 if _, found := groupNameToUUID[groupName]; !found {
378                         // Group doesn't exist, create and tag it before continuing
379                         if *verbose {
380                                 log.Printf("Remote group %q not found, creating...", groupName)
381                         }
382                         var group group
383                         err := arv.Create("groups", arvadosclient.Dict{
384                                 "group": arvadosclient.Dict{
385                                         "name":       groupName,
386                                         "owner_uuid": parentGroup.UUID,
387                                 },
388                         }, &group)
389                         if err != nil {
390                                 return fmt.Errorf("error creating group named %q: %s",
391                                         groupName, err)
392                         }
393                         link := make(map[string]interface{})
394                         err = arv.Create("links", arvadosclient.Dict{
395                                 "link": arvadosclient.Dict{
396                                         "link_class": "tag",
397                                         "name":       groupTag,
398                                         "head_uuid":  group.UUID,
399                                 },
400                         }, &link)
401                         if err != nil {
402                                 return fmt.Errorf("error creating tag for group %q: %s",
403                                         groupName, err)
404                         }
405                         // Update cached group data
406                         groupNameToUUID[groupName] = group.UUID
407                         remoteGroups[group.UUID] = &groupInfo{
408                                 Group:           group,
409                                 PreviousMembers: make(map[string]bool), // Empty set
410                                 CurrentMembers:  make(map[string]bool), // Empty set
411                         }
412                         groupsCreated++
413                 }
414                 // Both group & user exist, check if user is a member
415                 groupUUID := groupNameToUUID[groupName]
416                 gi := remoteGroups[groupUUID]
417                 if !gi.PreviousMembers[groupMember] && !gi.CurrentMembers[groupMember] {
418                         if *verbose {
419                                 log.Printf("Adding %q to group %q", groupMember, groupName)
420                         }
421                         // User wasn't a member, but should.
422                         link := make(map[string]interface{})
423                         err := arv.Create("links", arvadosclient.Dict{
424                                 "link": arvadosclient.Dict{
425                                         "link_class": "permission",
426                                         "name":       "can_read",
427                                         "tail_uuid":  groupUUID,
428                                         "head_uuid":  userIDToUUID[groupMember],
429                                 },
430                         }, &link)
431                         if err != nil {
432                                 return fmt.Errorf("error adding user %q to group %q: %s",
433                                         groupMember, groupName, err)
434                         }
435                         membersAdded++
436                 }
437                 gi.CurrentMembers[groupMember] = true
438         }
439
440         // Remove previous members not listed on this run
441         for groupUUID := range remoteGroups {
442                 gi := remoteGroups[groupUUID]
443                 evictedMembers := subtract(gi.PreviousMembers, gi.CurrentMembers)
444                 groupName := gi.Group.Name
445                 if len(evictedMembers) > 0 {
446                         log.Printf("Removing %d users from group %q", len(evictedMembers), groupName)
447                 }
448                 for evictedUser := range evictedMembers {
449                         links, err := ListAll(arv, "links", arvadosclient.Dict{
450                                 "filters": [][]string{
451                                         {"link_class", "=", "permission"},
452                                         {"name", "=", "can_read"},
453                                         {"tail_uuid", "=", groupUUID},
454                                         {"head_uuid", "=", userIDToUUID[evictedUser]},
455                                 },
456                         }, &linkList{})
457                         if err != nil {
458                                 return fmt.Errorf("error getting links needed to remove user %q from group %q: %s", evictedUser, groupName, err)
459                         }
460                         for _, item := range links {
461                                 link := item.(link)
462                                 var l map[string]interface{}
463                                 if *verbose {
464                                         log.Printf("Removing %q from group %q", evictedUser, gi.Group.Name)
465                                 }
466                                 err := arv.Delete("links", link.UUID, arvadosclient.Dict{}, &l)
467                                 if err != nil {
468                                         return fmt.Errorf("error removing user %q from group %q: %s", evictedUser, groupName, err)
469                                 }
470                         }
471                         membersRemoved++
472                 }
473         }
474         log.Printf("Groups created: %d, members added: %d, members removed: %d", groupsCreated, membersAdded, membersRemoved)
475
476         return nil
477 }
478
479 // ListAll : Adds all objects of type 'resource' to the 'output' list
480 func ListAll(arv *arvadosclient.ArvadosClient, resource string, parameters arvadosclient.Dict, rl resourceList) (allItems []interface{}, err error) {
481         if _, ok := parameters["limit"]; !ok {
482                 // Default limit value: use the maximum page size the server allows
483                 parameters["limit"] = 1<<31 - 1
484         }
485         offset := 0
486         itemsAvailable := parameters["limit"].(int)
487         for len(allItems) < itemsAvailable {
488                 parameters["offset"] = offset
489                 err = arv.List(resource, parameters, &rl)
490                 if err != nil {
491                         return allItems, err
492                 }
493                 for _, i := range rl.items() {
494                         allItems = append(allItems, i)
495                 }
496                 offset = rl.offset() + len(rl.items())
497                 itemsAvailable = rl.itemsAvailable()
498         }
499         return allItems, nil
500 }
501
502 func subtract(setA map[string]bool, setB map[string]bool) map[string]bool {
503         result := make(map[string]bool)
504         for element := range setA {
505                 if !setB[element] {
506                         result[element] = true
507                 }
508         }
509         return result
510 }