12018: Create synchronized remote groups as "role" groups.
[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         "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.curoverse.com/arvados.git/sdk/go/arvados"
20 )
21
22 // const remoteGroupParentName string = "Externally synchronized groups"
23
24 type resourceList interface {
25         Len() int
26         GetItems() []interface{}
27 }
28
29 // GroupInfo tracks previous and current members of a particular Group
30 type GroupInfo struct {
31         Group           arvados.Group
32         PreviousMembers map[string]bool
33         CurrentMembers  map[string]bool
34 }
35
36 // GetUserID returns the correct user id value depending on the selector
37 func GetUserID(u arvados.User, idSelector string) (string, error) {
38         switch idSelector {
39         case "email":
40                 return u.Email, nil
41         case "username":
42                 return u.Username, nil
43         default:
44                 return "", fmt.Errorf("cannot identify user by %q selector", idSelector)
45         }
46 }
47
48 // UserList implements resourceList interface
49 type UserList struct {
50         arvados.UserList
51 }
52
53 // Len returns the amount of items this list holds
54 func (l UserList) Len() int {
55         return len(l.Items)
56 }
57
58 // GetItems returns the list of items
59 func (l UserList) GetItems() (out []interface{}) {
60         for _, item := range l.Items {
61                 out = append(out, item)
62         }
63         return
64 }
65
66 // GroupList implements resourceList interface
67 type GroupList struct {
68         arvados.GroupList
69 }
70
71 // Len returns the amount of items this list holds
72 func (l GroupList) Len() int {
73         return len(l.Items)
74 }
75
76 // GetItems returns the list of items
77 func (l GroupList) GetItems() (out []interface{}) {
78         for _, item := range l.Items {
79                 out = append(out, item)
80         }
81         return
82 }
83
84 // Link is an arvados#link record
85 type Link struct {
86         UUID      string `json:"uuid,omiempty"`
87         OwnerUUID string `json:"owner_uuid,omitempty"`
88         Name      string `json:"name,omitempty"`
89         LinkClass string `json:"link_class,omitempty"`
90         HeadUUID  string `json:"head_uuid,omitempty"`
91         HeadKind  string `json:"head_kind,omitempty"`
92         TailUUID  string `json:"tail_uuid,omitempty"`
93         TailKind  string `json:"tail_kind,omitempty"`
94 }
95
96 // LinkList implements resourceList interface
97 type LinkList struct {
98         Items []Link `json:"items"`
99 }
100
101 // Len returns the amount of items this list holds
102 func (l LinkList) Len() int {
103         return len(l.Items)
104 }
105
106 // GetItems returns the list of items
107 func (l LinkList) GetItems() (out []interface{}) {
108         for _, item := range l.Items {
109                 out = append(out, item)
110         }
111         return
112 }
113
114 func main() {
115         if err := doMain(); err != nil {
116                 log.Fatalf("%v", err)
117         }
118 }
119
120 // ConfigParams holds configuration data for this tool
121 type ConfigParams struct {
122         Path            string
123         UserID          string
124         Verbose         bool
125         ParentGroupUUID string
126         ParentGroupName string
127         SysUserUUID     string
128         Client          *arvados.Client
129 }
130
131 // ParseFlags parses and validates command line arguments
132 func ParseFlags(config *ConfigParams) error {
133         // Acceptable attributes to identify a user on the CSV file
134         userIDOpts := map[string]bool{
135                 "email":    true, // default
136                 "username": true,
137         }
138         flags := flag.NewFlagSet("arv-sync-groups", flag.ExitOnError)
139         srcPath := flags.String(
140                 "path",
141                 "",
142                 "Local file path containing a CSV format: GroupName,UserID")
143         userID := flags.String(
144                 "user-id",
145                 "email",
146                 "Attribute by which every user is identified. Valid values are: email and username.")
147         verbose := flags.Bool(
148                 "verbose",
149                 false,
150                 "Log informational messages. Off by default.")
151         parentGroupUUID := flags.String(
152                 "parent-group-uuid",
153                 "",
154                 "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).")
155
156         // Parse args; omit the first arg which is the command name
157         flags.Parse(os.Args[1:])
158
159         // Validations
160         if *srcPath == "" {
161                 return fmt.Errorf("please provide a path to an input file")
162         }
163         if !userIDOpts[*userID] {
164                 var options []string
165                 for opt := range userIDOpts {
166                         options = append(options, opt)
167                 }
168                 return fmt.Errorf("user ID must be one of: %s", strings.Join(options, ", "))
169         }
170
171         config.Path = *srcPath
172         config.ParentGroupUUID = *parentGroupUUID
173         config.UserID = *userID
174         config.Verbose = *verbose
175
176         return nil
177 }
178
179 // SetParentGroup finds/create parent group of all remote groups
180 func SetParentGroup(cfg *ConfigParams) error {
181         var parentGroup arvados.Group
182         if cfg.ParentGroupUUID == "" {
183                 // UUID not provided, search for preexisting parent group
184                 var gl GroupList
185                 params := arvados.ResourceListParams{
186                         Filters: []arvados.Filter{{
187                                 Attr:     "name",
188                                 Operator: "=",
189                                 Operand:  cfg.ParentGroupName,
190                         }, {
191                                 Attr:     "owner_uuid",
192                                 Operator: "=",
193                                 Operand:  cfg.SysUserUUID,
194                         }},
195                 }
196                 if err := cfg.Client.RequestAndDecode(&gl, "GET", "/arvados/v1/groups", nil, params); err != nil {
197                         return fmt.Errorf("error searching for parent group: %s", err)
198                 }
199                 if len(gl.Items) == 0 {
200                         // Default parent group not existant, create one.
201                         if cfg.Verbose {
202                                 log.Println("Default parent group not found, creating...")
203                         }
204                         groupData := map[string]string{
205                                 "name":       cfg.ParentGroupName,
206                                 "owner_uuid": cfg.SysUserUUID,
207                         }
208                         if err := CreateGroup(cfg, &parentGroup, groupData); err != nil {
209                                 return fmt.Errorf("error creating system user owned group named %q: %s", groupData["name"], err)
210                         }
211                 } else if len(gl.Items) == 1 {
212                         // Default parent group found.
213                         parentGroup = gl.Items[0]
214                 } else {
215                         // This should never happen, as there's an unique index for
216                         // (owner_uuid, name) on groups.
217                         return fmt.Errorf("bug: found %d groups owned by system user and named %q", len(gl.Items), cfg.ParentGroupName)
218                 }
219                 cfg.ParentGroupUUID = parentGroup.UUID
220         } else {
221                 // UUID provided. Check if exists and if it's owned by system user
222                 if err := GetGroup(cfg, &parentGroup, cfg.ParentGroupUUID); err != nil {
223                         return fmt.Errorf("error searching for parent group with UUID %q: %s", cfg.ParentGroupUUID, err)
224                 }
225                 if parentGroup.OwnerUUID != cfg.SysUserUUID {
226                         return fmt.Errorf("parent group %q (%s) must be owned by system user", parentGroup.Name, cfg.ParentGroupUUID)
227                 }
228         }
229         return nil
230 }
231
232 // GetConfig sets up a ConfigParams struct
233 func GetConfig() (config ConfigParams, err error) {
234         config.ParentGroupName = "Externally synchronized groups"
235
236         // Command arguments
237         err = ParseFlags(&config)
238         if err != nil {
239                 return config, err
240         }
241
242         // Arvados Client setup
243         config.Client = arvados.NewClientFromEnv()
244
245         // Check current user permissions & get System user's UUID
246         u, err := config.Client.CurrentUser()
247         if err != nil {
248                 return config, fmt.Errorf("error getting the current user: %s", err)
249         }
250         if !u.IsActive || !u.IsAdmin {
251                 return config, fmt.Errorf("current user (%s) is not an active admin user", u.UUID)
252         }
253         config.SysUserUUID = u.UUID[:12] + "000000000000000"
254
255         // Set up remote groups' parent
256         if err = SetParentGroup(&config); err != nil {
257                 return config, err
258         }
259
260         return config, nil
261 }
262
263 func doMain() error {
264         // Parse & validate arguments, set up arvados client.
265         cfg, err := GetConfig()
266         if err != nil {
267                 return err
268         }
269
270         // Try opening the input file early, just in case there's a problem.
271         f, err := os.Open(cfg.Path)
272         if err != nil {
273                 return fmt.Errorf("%s", err)
274         }
275         defer f.Close()
276
277         log.Printf("Group sync starting. Using %q as users id and parent group UUID %q", cfg.UserID, cfg.ParentGroupUUID)
278
279         // Get the complete user list to minimize API Server requests
280         allUsers := make(map[string]arvados.User)
281         userIDToUUID := make(map[string]string) // Index by email or username
282         results, err := GetAll(cfg.Client, "users", arvados.ResourceListParams{}, &UserList{})
283         if err != nil {
284                 return fmt.Errorf("error getting user list: %s", err)
285         }
286         log.Printf("Found %d users", len(results))
287         for _, item := range results {
288                 u := item.(arvados.User)
289                 allUsers[u.UUID] = u
290                 uID, err := GetUserID(u, cfg.UserID)
291                 if err != nil {
292                         return err
293                 }
294                 userIDToUUID[uID] = u.UUID
295                 if cfg.Verbose {
296                         log.Printf("Seen user %q (%s)", u.Username, u.Email)
297                 }
298         }
299
300         // Get remote groups and their members
301         remoteGroups, groupNameToUUID, err := GetRemoteGroups(&cfg, allUsers)
302         if err != nil {
303                 return err
304         }
305         log.Printf("Found %d remote groups", len(remoteGroups))
306
307         membershipsRemoved := 0
308
309         // Read the CSV file
310         groupsCreated, membershipsAdded, membershipsSkipped, err := ProcessFile(&cfg, f, userIDToUUID, groupNameToUUID, remoteGroups, allUsers)
311         if err != nil {
312                 return err
313         }
314
315         // Remove previous members not listed on this run
316         for groupUUID := range remoteGroups {
317                 gi := remoteGroups[groupUUID]
318                 evictedMembers := subtract(gi.PreviousMembers, gi.CurrentMembers)
319                 groupName := gi.Group.Name
320                 if len(evictedMembers) > 0 {
321                         log.Printf("Removing %d users from group %q", len(evictedMembers), groupName)
322                 }
323                 for evictedUser := range evictedMembers {
324                         if err := RemoveMemberFromGroup(&cfg, allUsers[userIDToUUID[evictedUser]], gi.Group); err != nil {
325                                 return err
326                         }
327                         membershipsRemoved++
328                 }
329         }
330         log.Printf("Groups created: %d. Memberships added: %d, removed: %d, skipped: %d", groupsCreated, membershipsAdded, membershipsRemoved, membershipsSkipped)
331
332         return nil
333 }
334
335 // ProcessFile reads the CSV file and process every record
336 func ProcessFile(cfg *ConfigParams, f *os.File, userIDToUUID map[string]string, groupNameToUUID map[string]string, remoteGroups map[string]*GroupInfo, allUsers map[string]arvados.User) (groupsCreated, membersAdded, membersSkipped int, err error) {
337         csvReader := csv.NewReader(f)
338         for {
339                 record, e := csvReader.Read()
340                 if e == io.EOF {
341                         break
342                 }
343                 if e != nil {
344                         err = fmt.Errorf("error reading %q: %s", cfg.Path, err)
345                         return
346                 }
347                 groupName := strings.TrimSpace(record[0])
348                 groupMember := strings.TrimSpace(record[1]) // User ID (username or email)
349                 if groupName == "" || groupMember == "" {
350                         log.Printf("Warning: CSV record has at least one empty field (%s, %s). Skipping", groupName, groupMember)
351                         membersSkipped++
352                         continue
353                 }
354                 if _, found := userIDToUUID[groupMember]; !found {
355                         // User not present on the system, skip.
356                         log.Printf("Warning: there's no user with %s %q on the system, skipping.", cfg.UserID, groupMember)
357                         membersSkipped++
358                         continue
359                 }
360                 if _, found := groupNameToUUID[groupName]; !found {
361                         // Group doesn't exist, create it before continuing
362                         if cfg.Verbose {
363                                 log.Printf("Remote group %q not found, creating...", groupName)
364                         }
365                         var newGroup arvados.Group
366                         groupData := map[string]string{
367                                 "name":        groupName,
368                                 "owner_uuid":  cfg.ParentGroupUUID,
369                                 "group_class": "role",
370                         }
371                         if e := CreateGroup(cfg, &newGroup, groupData); e != nil {
372                                 err = fmt.Errorf("error creating group named %q: %s", groupName, err)
373                                 return
374                         }
375                         // Update cached group data
376                         groupNameToUUID[groupName] = newGroup.UUID
377                         remoteGroups[newGroup.UUID] = &GroupInfo{
378                                 Group:           newGroup,
379                                 PreviousMembers: make(map[string]bool), // Empty set
380                                 CurrentMembers:  make(map[string]bool), // Empty set
381                         }
382                         groupsCreated++
383                 }
384                 // Both group & user exist, check if user is a member
385                 groupUUID := groupNameToUUID[groupName]
386                 gi := remoteGroups[groupUUID]
387                 if !gi.PreviousMembers[groupMember] && !gi.CurrentMembers[groupMember] {
388                         if cfg.Verbose {
389                                 log.Printf("Adding %q to group %q", groupMember, groupName)
390                         }
391                         // User wasn't a member, but should be.
392                         if e := AddMemberToGroup(cfg, allUsers[userIDToUUID[groupMember]], gi.Group); e != nil {
393                                 err = e
394                                 return
395                         }
396                         membersAdded++
397                 }
398                 gi.CurrentMembers[groupMember] = true
399         }
400         return
401 }
402
403 // GetAll : Adds all objects of type 'resource' to the 'allItems' list
404 func GetAll(c *arvados.Client, res string, params arvados.ResourceListParams, page resourceList) (allItems []interface{}, err error) {
405         // Use the maximum page size the server allows
406         limit := 1<<31 - 1
407         params.Limit = &limit
408         params.Offset = 0
409         params.Order = "uuid"
410         for {
411                 if err = GetResourceList(c, &page, res, params); err != nil {
412                         return allItems, err
413                 }
414                 // Have we finished paging?
415                 if page.Len() == 0 {
416                         break
417                 }
418                 for _, i := range page.GetItems() {
419                         allItems = append(allItems, i)
420                 }
421                 params.Offset += page.Len()
422         }
423         return allItems, nil
424 }
425
426 func subtract(setA map[string]bool, setB map[string]bool) map[string]bool {
427         result := make(map[string]bool)
428         for element := range setA {
429                 if !setB[element] {
430                         result[element] = true
431                 }
432         }
433         return result
434 }
435
436 func jsonReader(rscName string, ob interface{}) io.Reader {
437         j, err := json.Marshal(ob)
438         if err != nil {
439                 panic(err)
440         }
441         v := url.Values{}
442         v[rscName] = []string{string(j)}
443         return bytes.NewBufferString(v.Encode())
444 }
445
446 // GetRemoteGroups fetches all remote groups with their members
447 func GetRemoteGroups(cfg *ConfigParams, allUsers map[string]arvados.User) (remoteGroups map[string]*GroupInfo, groupNameToUUID map[string]string, err error) {
448         remoteGroups = make(map[string]*GroupInfo)
449         groupNameToUUID = make(map[string]string) // Index by group name
450
451         params := arvados.ResourceListParams{
452                 Filters: []arvados.Filter{{
453                         Attr:     "owner_uuid",
454                         Operator: "=",
455                         Operand:  cfg.ParentGroupUUID,
456                 }},
457         }
458         results, err := GetAll(cfg.Client, "groups", params, &GroupList{})
459         if err != nil {
460                 return remoteGroups, groupNameToUUID, fmt.Errorf("error getting remote groups: %s", err)
461         }
462         for _, item := range results {
463                 group := item.(arvados.Group)
464                 // Group -> User filter
465                 g2uFilter := arvados.ResourceListParams{
466                         Filters: []arvados.Filter{{
467                                 Attr:     "owner_uuid",
468                                 Operator: "=",
469                                 Operand:  cfg.SysUserUUID,
470                         }, {
471                                 Attr:     "link_class",
472                                 Operator: "=",
473                                 Operand:  "permission",
474                         }, {
475                                 Attr:     "name",
476                                 Operator: "=",
477                                 Operand:  "can_read",
478                         }, {
479                                 Attr:     "tail_uuid",
480                                 Operator: "=",
481                                 Operand:  group.UUID,
482                         }, {
483                                 Attr:     "head_kind",
484                                 Operator: "=",
485                                 Operand:  "arvados#user",
486                         }},
487                 }
488                 // User -> Group filter
489                 u2gFilter := arvados.ResourceListParams{
490                         Filters: []arvados.Filter{{
491                                 Attr:     "owner_uuid",
492                                 Operator: "=",
493                                 Operand:  cfg.SysUserUUID,
494                         }, {
495                                 Attr:     "link_class",
496                                 Operator: "=",
497                                 Operand:  "permission",
498                         }, {
499                                 Attr:     "name",
500                                 Operator: "=",
501                                 Operand:  "can_write",
502                         }, {
503                                 Attr:     "head_uuid",
504                                 Operator: "=",
505                                 Operand:  group.UUID,
506                         }, {
507                                 Attr:     "tail_kind",
508                                 Operator: "=",
509                                 Operand:  "arvados#user",
510                         }},
511                 }
512                 g2uLinks, err := GetAll(cfg.Client, "links", g2uFilter, &LinkList{})
513                 if err != nil {
514                         return remoteGroups, groupNameToUUID, fmt.Errorf("error getting member (can_read) links for group %q: %s", group.Name, err)
515                 }
516                 u2gLinks, err := GetAll(cfg.Client, "links", u2gFilter, &LinkList{})
517                 if err != nil {
518                         return remoteGroups, groupNameToUUID, fmt.Errorf("error getting member (can_write) links for group %q: %s", group.Name, err)
519                 }
520                 // Build a list of user ids (email or username) belonging to this group
521                 membersSet := make(map[string]bool)
522                 u2gLinkSet := make(map[string]bool)
523                 for _, l := range u2gLinks {
524                         linkedMemberUUID := l.(Link).TailUUID
525                         u2gLinkSet[linkedMemberUUID] = true
526                 }
527                 for _, item := range g2uLinks {
528                         link := item.(Link)
529                         // We may have received an old link pointing to a removed account.
530                         if _, found := allUsers[link.HeadUUID]; !found {
531                                 continue
532                         }
533                         // The matching User -> Group link may not exist if the link
534                         // creation failed on a previous run. If that's the case, don't
535                         // include this account on the "previous members" list.
536                         if _, found := u2gLinkSet[link.HeadUUID]; !found {
537                                 continue
538                         }
539                         memberID, err := GetUserID(allUsers[link.HeadUUID], cfg.UserID)
540                         if err != nil {
541                                 return remoteGroups, groupNameToUUID, err
542                         }
543                         membersSet[memberID] = true
544                 }
545                 remoteGroups[group.UUID] = &GroupInfo{
546                         Group:           group,
547                         PreviousMembers: membersSet,
548                         CurrentMembers:  make(map[string]bool), // Empty set
549                 }
550                 groupNameToUUID[group.Name] = group.UUID
551         }
552         return remoteGroups, groupNameToUUID, nil
553 }
554
555 // RemoveMemberFromGroup remove all links related to the membership
556 func RemoveMemberFromGroup(cfg *ConfigParams, user arvados.User, group arvados.Group) error {
557         if cfg.Verbose {
558                 log.Printf("Getting group membership links for user %q (%s) on group %q (%s)", user.Email, user.UUID, group.Name, group.UUID)
559         }
560         var links []interface{}
561         // Search for all group<->user links (both ways)
562         for _, filterset := range [][]arvados.Filter{
563                 // Group -> User
564                 {{
565                         Attr:     "link_class",
566                         Operator: "=",
567                         Operand:  "permission",
568                 }, {
569                         Attr:     "tail_uuid",
570                         Operator: "=",
571                         Operand:  group.UUID,
572                 }, {
573                         Attr:     "head_uuid",
574                         Operator: "=",
575                         Operand:  user.UUID,
576                 }},
577                 // Group <- User
578                 {{
579                         Attr:     "link_class",
580                         Operator: "=",
581                         Operand:  "permission",
582                 }, {
583                         Attr:     "tail_uuid",
584                         Operator: "=",
585                         Operand:  user.UUID,
586                 }, {
587                         Attr:     "head_uuid",
588                         Operator: "=",
589                         Operand:  group.UUID,
590                 }},
591         } {
592                 l, err := GetAll(cfg.Client, "links", arvados.ResourceListParams{Filters: filterset}, &LinkList{})
593                 if err != nil {
594                         userID, _ := GetUserID(user, cfg.UserID)
595                         return fmt.Errorf("error getting links needed to remove user %q from group %q: %s", userID, group.Name, err)
596                 }
597                 for _, link := range l {
598                         links = append(links, link)
599                 }
600         }
601         for _, item := range links {
602                 link := item.(Link)
603                 if cfg.Verbose {
604                         log.Printf("Removing %q permission link for %q on group %q", link.Name, user.Email, group.Name)
605                 }
606                 if err := DeleteLink(cfg, link.UUID); err != nil {
607                         userID, _ := GetUserID(user, cfg.UserID)
608                         return fmt.Errorf("error removing user %q from group %q: %s", userID, group.Name, err)
609                 }
610         }
611         return nil
612 }
613
614 // AddMemberToGroup create membership links
615 func AddMemberToGroup(cfg *ConfigParams, user arvados.User, group arvados.Group) error {
616         var newLink Link
617         linkData := map[string]string{
618                 "owner_uuid": cfg.SysUserUUID,
619                 "link_class": "permission",
620                 "name":       "can_read",
621                 "tail_uuid":  group.UUID,
622                 "head_uuid":  user.UUID,
623         }
624         if err := CreateLink(cfg, &newLink, linkData); err != nil {
625                 userID, _ := GetUserID(user, cfg.UserID)
626                 return fmt.Errorf("error adding group %q -> user %q read permission: %s", userID, user.Email, err)
627         }
628         linkData = map[string]string{
629                 "owner_uuid": cfg.SysUserUUID,
630                 "link_class": "permission",
631                 "name":       "can_write",
632                 "tail_uuid":  user.UUID,
633                 "head_uuid":  group.UUID,
634         }
635         if err := CreateLink(cfg, &newLink, linkData); err != nil {
636                 userID, _ := GetUserID(user, cfg.UserID)
637                 return fmt.Errorf("error adding user %q -> group %q write permission: %s", userID, group.Name, err)
638         }
639         return nil
640 }
641
642 // CreateGroup creates a group with groupData parameters, assigns it to dst
643 func CreateGroup(cfg *ConfigParams, dst *arvados.Group, groupData map[string]string) error {
644         return cfg.Client.RequestAndDecode(dst, "POST", "/arvados/v1/groups", jsonReader("group", groupData), nil)
645 }
646
647 // GetGroup fetches a group by its UUID
648 func GetGroup(cfg *ConfigParams, dst *arvados.Group, groupUUID string) error {
649         return cfg.Client.RequestAndDecode(&dst, "GET", "/arvados/v1/groups/"+groupUUID, nil, nil)
650 }
651
652 // CreateLink creates a link with linkData parameters, assigns it to dst
653 func CreateLink(cfg *ConfigParams, dst *Link, linkData map[string]string) error {
654         return cfg.Client.RequestAndDecode(dst, "POST", "/arvados/v1/links", jsonReader("link", linkData), nil)
655 }
656
657 // DeleteLink deletes a link by its UUID
658 func DeleteLink(cfg *ConfigParams, linkUUID string) error {
659         if linkUUID == "" {
660                 return fmt.Errorf("cannot delete link with invalid UUID: %q", linkUUID)
661         }
662         return cfg.Client.RequestAndDecode(&Link{}, "DELETE", "/arvados/v1/links/"+linkUUID, nil, nil)
663 }
664
665 // GetResourceList fetches res list using params
666 func GetResourceList(c *arvados.Client, dst *resourceList, res string, params interface{}) error {
667         return c.RequestAndDecode(dst, "GET", "/arvados/v1/"+res, nil, params)
668 }