b8c94ed5df232b0dbbfc92db45cfc4c7021d4bd4
[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.UUID == "" {
266                         return fmt.Errorf("parent group with UUID %q not found", *parentGroupUUID)
267                 }
268                 if parentGroup.OwnerUUID != sysUserUUID {
269                         return fmt.Errorf("parent group %q (%s) must be owned by system user", parentGroup.Name, *parentGroupUUID)
270                 }
271         }
272
273         log.Printf("Group sync starting. Using %q as users id and parent group UUID %q", *userID, parentGroup.UUID)
274
275         // Get the complete user list to minimize API Server requests
276         allUsers := make(map[string]user)
277         userIDToUUID := make(map[string]string) // Index by email or username
278         results, err := ListAll(arv, "users", arvadosclient.Dict{}, &userList{})
279         if err != nil {
280                 return fmt.Errorf("error getting user list: %s", err)
281         }
282         log.Printf("Found %d users", len(results))
283         for _, item := range results {
284                 u := item.(user)
285                 allUsers[u.UUID] = u
286                 uID, err := u.GetID(*userID)
287                 if err != nil {
288                         return err
289                 }
290                 userIDToUUID[uID] = u.UUID
291                 if *verbose {
292                         log.Printf("Seen user %q (%s)", u.Username, u.Email)
293                 }
294         }
295
296         // Request all UUIDs for groups tagged as remote
297         remoteGroupUUIDs := make(map[string]bool)
298         results, err = ListAll(arv, "links", arvadosclient.Dict{
299                 "filters": [][]string{
300                         {"link_class", "=", "tag"},
301                         {"name", "=", groupTag},
302                         {"head_kind", "=", "arvados#group"},
303                 },
304         }, &linkList{})
305         if err != nil {
306                 return fmt.Errorf("error getting remote group UUIDs: %s", err)
307         }
308         for _, item := range results {
309                 link := item.(link)
310                 remoteGroupUUIDs[link.HeadUUID] = true
311         }
312         // Get remote groups and their members
313         var uuidList []string
314         for uuid := range remoteGroupUUIDs {
315                 uuidList = append(uuidList, uuid)
316         }
317         remoteGroups := make(map[string]*groupInfo)
318         groupNameToUUID := make(map[string]string) // Index by group name
319         results, err = ListAll(arv, "groups", arvadosclient.Dict{
320                 "filters": [][]interface{}{
321                         {"uuid", "in", uuidList},
322                         {"owner_uuid", "=", parentGroup.UUID},
323                 },
324         }, &groupList{})
325         if err != nil {
326                 return fmt.Errorf("error getting remote groups by UUID: %s", err)
327         }
328         for _, item := range results {
329                 group := item.(group)
330                 results, err := ListAll(arv, "links", arvadosclient.Dict{
331                         "filters": [][]string{
332                                 {"link_class", "=", "permission"},
333                                 {"name", "=", "can_read"},
334                                 {"tail_uuid", "=", group.UUID},
335                                 {"head_kind", "=", "arvados#user"},
336                         },
337                 }, &linkList{})
338                 if err != nil {
339                         return fmt.Errorf("error getting member links: %s", err)
340                 }
341                 // Build a list of user ids (email or username) belonging to this group
342                 membersSet := make(map[string]bool)
343                 for _, item := range results {
344                         link := item.(link)
345                         memberID, err := allUsers[link.HeadUUID].GetID(*userID)
346                         if err != nil {
347                                 return err
348                         }
349                         membersSet[memberID] = true
350                 }
351                 remoteGroups[group.UUID] = &groupInfo{
352                         Group:           group,
353                         PreviousMembers: membersSet,
354                         CurrentMembers:  make(map[string]bool), // Empty set
355                 }
356                 groupNameToUUID[group.Name] = group.UUID
357         }
358         log.Printf("Found %d remote groups", len(remoteGroups))
359
360         groupsCreated := 0
361         membersAdded := 0
362         membersRemoved := 0
363
364         csvReader := csv.NewReader(f)
365         for {
366                 record, err := csvReader.Read()
367                 if err == io.EOF {
368                         break
369                 }
370                 if err != nil {
371                         return fmt.Errorf("error reading %q: %s", *srcPath, err)
372                 }
373                 groupName := record[0]
374                 groupMember := record[1] // User ID (username or email)
375                 if _, found := userIDToUUID[groupMember]; !found {
376                         // User not present on the system, skip.
377                         log.Printf("Warning: there's no user with %s %q on the system, skipping.", *userID, groupMember)
378                         continue
379                 }
380                 if _, found := groupNameToUUID[groupName]; !found {
381                         // Group doesn't exist, create and tag it before continuing
382                         if *verbose {
383                                 log.Printf("Remote group %q not found, creating...", groupName)
384                         }
385                         var group group
386                         err := arv.Create("groups", arvadosclient.Dict{
387                                 "group": arvadosclient.Dict{
388                                         "name":       groupName,
389                                         "owner_uuid": parentGroup.UUID,
390                                 },
391                         }, &group)
392                         if err != nil {
393                                 return fmt.Errorf("error creating group named %q: %s",
394                                         groupName, err)
395                         }
396                         link := make(map[string]interface{})
397                         err = arv.Create("links", arvadosclient.Dict{
398                                 "link": arvadosclient.Dict{
399                                         "link_class": "tag",
400                                         "name":       groupTag,
401                                         "head_uuid":  group.UUID,
402                                 },
403                         }, &link)
404                         if err != nil {
405                                 return fmt.Errorf("error creating tag for group %q: %s",
406                                         groupName, err)
407                         }
408                         // Update cached group data
409                         groupNameToUUID[groupName] = group.UUID
410                         remoteGroups[group.UUID] = &groupInfo{
411                                 Group:           group,
412                                 PreviousMembers: make(map[string]bool), // Empty set
413                                 CurrentMembers:  make(map[string]bool), // Empty set
414                         }
415                         groupsCreated++
416                 }
417                 // Both group & user exist, check if user is a member
418                 groupUUID := groupNameToUUID[groupName]
419                 gi := remoteGroups[groupUUID]
420                 if !gi.PreviousMembers[groupMember] && !gi.CurrentMembers[groupMember] {
421                         if *verbose {
422                                 log.Printf("Adding %q to group %q", groupMember, groupName)
423                         }
424                         // User wasn't a member, but should.
425                         link := make(map[string]interface{})
426                         err := arv.Create("links", arvadosclient.Dict{
427                                 "link": arvadosclient.Dict{
428                                         "link_class": "permission",
429                                         "name":       "can_read",
430                                         "tail_uuid":  groupUUID,
431                                         "head_uuid":  userIDToUUID[groupMember],
432                                 },
433                         }, &link)
434                         if err != nil {
435                                 return fmt.Errorf("error adding user %q to group %q: %s",
436                                         groupMember, groupName, err)
437                         }
438                         membersAdded++
439                 }
440                 gi.CurrentMembers[groupMember] = true
441         }
442
443         // Remove previous members not listed on this run
444         for groupUUID := range remoteGroups {
445                 gi := remoteGroups[groupUUID]
446                 evictedMembers := subtract(gi.PreviousMembers, gi.CurrentMembers)
447                 groupName := gi.Group.Name
448                 if len(evictedMembers) > 0 {
449                         log.Printf("Removing %d users from group %q", len(evictedMembers), groupName)
450                 }
451                 for evictedUser := range evictedMembers {
452                         links, err := ListAll(arv, "links", arvadosclient.Dict{
453                                 "filters": [][]string{
454                                         {"link_class", "=", "permission"},
455                                         {"name", "=", "can_read"},
456                                         {"tail_uuid", "=", groupUUID},
457                                         {"head_uuid", "=", userIDToUUID[evictedUser]},
458                                 },
459                         }, &linkList{})
460                         if err != nil {
461                                 return fmt.Errorf("error getting links needed to remove user %q from group %q: %s", evictedUser, groupName, err)
462                         }
463                         for _, item := range links {
464                                 link := item.(link)
465                                 var l map[string]interface{}
466                                 if *verbose {
467                                         log.Printf("Removing %q from group %q", evictedUser, gi.Group.Name)
468                                 }
469                                 err := arv.Delete("links", link.UUID, arvadosclient.Dict{}, &l)
470                                 if err != nil {
471                                         return fmt.Errorf("error removing user %q from group %q: %s", evictedUser, groupName, err)
472                                 }
473                         }
474                         membersRemoved++
475                 }
476         }
477         log.Printf("Groups created: %d, members added: %d, members removed: %d", groupsCreated, membersAdded, membersRemoved)
478
479         return nil
480 }
481
482 // ListAll : Adds all objects of type 'resource' to the 'output' list
483 func ListAll(arv *arvadosclient.ArvadosClient, resource string, parameters arvadosclient.Dict, rl resourceList) (allItems []interface{}, err error) {
484         if _, ok := parameters["limit"]; !ok {
485                 // Default limit value: use the maximum page size the server allows
486                 parameters["limit"] = 1<<31 - 1
487         }
488         offset := 0
489         itemsAvailable := parameters["limit"].(int)
490         for len(allItems) < itemsAvailable {
491                 parameters["offset"] = offset
492                 err = arv.List(resource, parameters, &rl)
493                 if err != nil {
494                         return allItems, err
495                 }
496                 for _, i := range rl.items() {
497                         allItems = append(allItems, i)
498                 }
499                 offset = rl.offset() + len(rl.items())
500                 itemsAvailable = rl.itemsAvailable()
501         }
502         return allItems, nil
503 }
504
505 func subtract(setA map[string]bool, setB map[string]bool) map[string]bool {
506         result := make(map[string]bool)
507         for element := range setA {
508                 if !setB[element] {
509                         result[element] = true
510                 }
511         }
512         return result
513 }