1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
19 "git.arvados.org/arvados.git/lib/cmd"
20 "git.arvados.org/arvados.git/sdk/go/arvados"
25 type resourceList interface {
27 GetItems() []interface{}
30 // GroupPermissions maps permission levels on groups (can_read, can_write, can_manage)
31 type GroupPermissions map[string]bool
33 // GroupInfo tracks previous and current member's permissions on a particular Group
34 type GroupInfo struct {
36 PreviousMembers map[string]GroupPermissions
37 CurrentMembers map[string]GroupPermissions
40 // GetUserID returns the correct user id value depending on the selector
41 func GetUserID(u arvados.User, idSelector string) (string, error) {
46 return u.Username, nil
48 return "", fmt.Errorf("cannot identify user by %q selector", idSelector)
52 // UserList implements resourceList interface
53 type UserList struct {
57 // Len returns the amount of items this list holds
58 func (l UserList) Len() int {
62 // GetItems returns the list of items
63 func (l UserList) GetItems() (out []interface{}) {
64 for _, item := range l.Items {
65 out = append(out, item)
70 // GroupList implements resourceList interface
71 type GroupList struct {
75 // Len returns the amount of items this list holds
76 func (l GroupList) Len() int {
80 // GetItems returns the list of items
81 func (l GroupList) GetItems() (out []interface{}) {
82 for _, item := range l.Items {
83 out = append(out, item)
88 // LinkList implements resourceList interface
89 type LinkList struct {
93 // Len returns the amount of items this list holds
94 func (l LinkList) Len() int {
98 // GetItems returns the list of items
99 func (l LinkList) GetItems() (out []interface{}) {
100 for _, item := range l.Items {
101 out = append(out, item)
107 // Parse & validate arguments, set up arvados client.
108 cfg, err := GetConfig()
110 log.Fatalf("%v", err)
113 if err := doMain(&cfg); err != nil {
114 log.Fatalf("%v", err)
118 // ConfigParams holds configuration data for this tool
119 type ConfigParams struct {
124 ParentGroupUUID string
125 ParentGroupName string
127 Client *arvados.Client
130 // ParseFlags parses and validates command line arguments
131 func ParseFlags(config *ConfigParams) error {
132 // Acceptable attributes to identify a user on the CSV file
133 userIDOpts := map[string]bool{
134 "email": true, // default
138 flags := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
140 // Set up usage message
141 flags.Usage = func() {
142 usageStr := `Synchronize remote groups into Arvados from a CSV format file with 3 columns:
144 * 2nd: User identifier
145 * 3rd (Optional): User permission on the group: can_read, can_write or can_manage. (Default: can_write)`
146 fmt.Fprintf(flags.Output(), "%s\n\n", usageStr)
147 fmt.Fprintf(flags.Output(), "Usage:\n%s [OPTIONS] <input-file.csv>\n\n", os.Args[0])
148 fmt.Fprintf(flags.Output(), "Options:\n")
149 flags.PrintDefaults()
152 // Set up option flags
153 userID := flags.String(
156 "Attribute by which every user is identified. Valid values are: email and username.")
157 caseInsensitive := flags.Bool(
160 "Performs case insensitive matching on user IDs. Off by default.")
161 verbose := flags.Bool(
164 "Log informational messages. Off by default.")
165 getVersion := flags.Bool(
168 "Print version information and exit.")
169 parentGroupUUID := flags.String(
172 "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).")
174 if ok, code := cmd.ParseFlags(flags, os.Args[0], os.Args[1:], "input-file.csv", os.Stderr); !ok {
176 } else if *getVersion {
177 fmt.Printf("%s %s\n", os.Args[0], version)
181 // Input file as a required positional argument
182 if flags.NArg() == 0 {
183 return fmt.Errorf("please provide a path to an input file")
184 } else if flags.NArg() > 1 {
185 return fmt.Errorf("please provide just one input file argument")
187 srcPath := &os.Args[len(os.Args)-1]
191 return fmt.Errorf("input file path invalid")
193 if !userIDOpts[*userID] {
195 for opt := range userIDOpts {
196 options = append(options, opt)
198 return fmt.Errorf("user ID must be one of: %s", strings.Join(options, ", "))
201 config.Path = *srcPath
202 config.ParentGroupUUID = *parentGroupUUID
203 config.UserID = *userID
204 config.Verbose = *verbose
205 config.CaseInsensitive = *caseInsensitive
210 // SetParentGroup finds/create parent group of all remote groups
211 func SetParentGroup(cfg *ConfigParams) error {
212 var parentGroup arvados.Group
213 if cfg.ParentGroupUUID == "" {
214 // UUID not provided, search for preexisting parent group
216 params := arvados.ResourceListParams{
217 Filters: []arvados.Filter{{
220 Operand: cfg.ParentGroupName,
224 Operand: cfg.SysUserUUID,
227 if err := cfg.Client.RequestAndDecode(&gl, "GET", "/arvados/v1/groups", nil, params); err != nil {
228 return fmt.Errorf("error searching for parent group: %s", err)
230 if len(gl.Items) == 0 {
231 // Default parent group does not exist, create it.
233 log.Println("Default parent group not found, creating...")
235 groupData := map[string]string{
236 "name": cfg.ParentGroupName,
237 "owner_uuid": cfg.SysUserUUID,
238 "group_class": "role",
240 if err := CreateGroup(cfg, &parentGroup, groupData); err != nil {
241 return fmt.Errorf("error creating system user owned group named %q: %s", groupData["name"], err)
243 } else if len(gl.Items) == 1 {
244 // Default parent group found.
245 parentGroup = gl.Items[0]
247 // This should never happen, as there's an unique index for
248 // (owner_uuid, name) on groups.
249 return fmt.Errorf("bug: found %d groups owned by system user and named %q", len(gl.Items), cfg.ParentGroupName)
251 cfg.ParentGroupUUID = parentGroup.UUID
253 // UUID provided. Check if exists and if it's owned by system user
254 if err := GetGroup(cfg, &parentGroup, cfg.ParentGroupUUID); err != nil {
255 return fmt.Errorf("error searching for parent group with UUID %q: %s", cfg.ParentGroupUUID, err)
257 if parentGroup.OwnerUUID != cfg.SysUserUUID {
258 return fmt.Errorf("parent group %q (%s) must be owned by system user", parentGroup.Name, cfg.ParentGroupUUID)
264 // GetConfig sets up a ConfigParams struct
265 func GetConfig() (config ConfigParams, err error) {
266 config.ParentGroupName = "Externally synchronized groups"
269 err = ParseFlags(&config)
274 // Arvados Client setup
275 config.Client = arvados.NewClientFromEnv()
277 // Check current user permissions & get System user's UUID
278 u, err := config.Client.CurrentUser()
280 return config, fmt.Errorf("error getting the current user: %s", err)
282 if !u.IsActive || !u.IsAdmin {
283 return config, fmt.Errorf("current user (%s) is not an active admin user", u.UUID)
286 var ac struct{ ClusterID string }
287 err = config.Client.RequestAndDecode(&ac, "GET", "arvados/v1/config", nil, nil)
289 return config, fmt.Errorf("error getting the exported config: %s", err)
291 config.SysUserUUID = ac.ClusterID + "-tpzed-000000000000000"
293 // Set up remote groups' parent
294 if err = SetParentGroup(&config); err != nil {
301 func doMain(cfg *ConfigParams) error {
302 // Try opening the input file early, just in case there's a problem.
303 f, err := os.Open(cfg.Path)
305 return fmt.Errorf("%s", err)
310 if cfg.UserID == "username" && cfg.CaseInsensitive {
311 iCaseLog = " - username matching requested to be case-insensitive"
313 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)
315 // Get the complete user list to minimize API Server requests
316 allUsers := make(map[string]arvados.User)
317 userIDToUUID := make(map[string]string) // Index by email or username
318 results, err := GetAll(cfg.Client, "users", arvados.ResourceListParams{}, &UserList{})
320 return fmt.Errorf("error getting user list: %s", err)
322 log.Printf("Found %d users", len(results))
323 for _, item := range results {
324 u := item.(arvados.User)
326 uID, err := GetUserID(u, cfg.UserID)
330 if cfg.UserID == "username" && uID != "" && cfg.CaseInsensitive {
331 uID = strings.ToLower(uID)
332 if uuid, found := userIDToUUID[uID]; found {
333 return fmt.Errorf("case insensitive collision for username %q between %q and %q", uID, u.UUID, uuid)
336 userIDToUUID[uID] = u.UUID
338 log.Printf("Seen user %q (%s)", u.Username, u.UUID)
342 // Get remote groups and their members
343 remoteGroups, groupNameToUUID, err := GetRemoteGroups(cfg, allUsers)
347 log.Printf("Found %d remote groups", len(remoteGroups))
349 for groupUUID := range remoteGroups {
350 log.Printf("- Group %q: %d users", remoteGroups[groupUUID].Group.Name, len(remoteGroups[groupUUID].PreviousMembers))
354 membershipsRemoved := 0
357 groupsCreated, membershipsAdded, membershipsSkipped, err := ProcessFile(cfg, f, userIDToUUID, groupNameToUUID, remoteGroups, allUsers)
362 // Remove previous members not listed on this run
363 for groupUUID := range remoteGroups {
364 gi := remoteGroups[groupUUID]
365 evictedMemberPerms := subtract(gi.PreviousMembers, gi.CurrentMembers)
366 groupName := gi.Group.Name
367 if len(evictedMemberPerms) > 0 {
368 log.Printf("Removing permissions from %d users on group %q", len(evictedMemberPerms), groupName)
370 for member := range evictedMemberPerms {
372 completeMembershipRemoval := false
373 if _, ok := gi.CurrentMembers[member]; !ok {
374 completeMembershipRemoval = true
377 // Collect which user->group permission links should be removed
378 for p := range evictedMemberPerms[member] {
379 if evictedMemberPerms[member][p] {
380 perms = append(perms, p)
383 membershipsRemoved += len(perms)
385 if err := RemoveMemberLinksFromGroup(cfg, allUsers[userIDToUUID[member]],
386 perms, completeMembershipRemoval, gi.Group); err != nil {
391 log.Printf("Groups created: %d. Memberships added: %d, removed: %d, skipped: %d", groupsCreated, membershipsAdded, membershipsRemoved, membershipsSkipped)
396 // ProcessFile reads the CSV file and process every record
400 userIDToUUID map[string]string,
401 groupNameToUUID map[string]string,
402 remoteGroups map[string]*GroupInfo,
403 allUsers map[string]arvados.User,
404 ) (groupsCreated, membersAdded, membersSkipped int, err error) {
406 csvReader := csv.NewReader(f)
407 // Allow variable number of fields.
408 csvReader.FieldsPerRecord = -1
410 record, e := csvReader.Read()
416 err = fmt.Errorf("error parsing %q, line %d", cfg.Path, lineNo)
419 // Only allow 2 or 3 fields per record for backwards compatibility.
420 if len(record) < 2 || len(record) > 3 {
421 err = fmt.Errorf("error parsing %q, line %d: found %d fields but only 2 or 3 are allowed", cfg.Path, lineNo, len(record))
424 groupName := strings.TrimSpace(record[0])
425 groupMember := strings.TrimSpace(record[1]) // User ID (username or email)
426 groupPermission := "can_write"
427 if len(record) == 3 {
428 groupPermission = strings.ToLower(record[2])
430 if groupName == "" || groupMember == "" || groupPermission == "" {
431 log.Printf("Warning: CSV record has at least one empty field (%s, %s, %s). Skipping", groupName, groupMember, groupPermission)
435 if cfg.UserID == "username" && cfg.CaseInsensitive {
436 groupMember = strings.ToLower(groupMember)
438 if !(groupPermission == "can_read" || groupPermission == "can_write" || groupPermission == "can_manage") {
439 log.Printf("Warning: 3rd field should be 'can_read', 'can_write' or 'can_manage'. Found: %q at line %d, skipping.", groupPermission, lineNo)
443 if _, found := userIDToUUID[groupMember]; !found {
444 // User not present on the system, skip.
445 log.Printf("Warning: there's no user with %s %q on the system, skipping.", cfg.UserID, groupMember)
449 if _, found := groupNameToUUID[groupName]; !found {
450 // Group doesn't exist, create it before continuing
452 log.Printf("Remote group %q not found, creating...", groupName)
454 var newGroup arvados.Group
455 groupData := map[string]string{
457 "owner_uuid": cfg.ParentGroupUUID,
458 "group_class": "role",
460 if e := CreateGroup(cfg, &newGroup, groupData); e != nil {
461 err = fmt.Errorf("error creating group named %q: %s", groupName, e)
464 // Update cached group data
465 groupNameToUUID[groupName] = newGroup.UUID
466 remoteGroups[newGroup.UUID] = &GroupInfo{
468 PreviousMembers: make(map[string]GroupPermissions),
469 CurrentMembers: make(map[string]GroupPermissions),
473 // Both group & user exist, check if user is a member
474 groupUUID := groupNameToUUID[groupName]
475 gi := remoteGroups[groupUUID]
476 if !gi.PreviousMembers[groupMember][groupPermission] && !gi.CurrentMembers[groupMember][groupPermission] {
478 log.Printf("Adding %q to group %q", groupMember, groupName)
480 // User permissionwasn't there, but should be. Avoid duplicating the
481 // group->user link when necessary.
482 createG2ULink := true
483 if _, ok := gi.PreviousMembers[groupMember]; ok {
484 createG2ULink = false // User is already member of the group
486 if e := AddMemberToGroup(cfg, allUsers[userIDToUUID[groupMember]], gi.Group, groupPermission, createG2ULink); e != nil {
492 if _, ok := gi.CurrentMembers[groupMember]; ok {
493 gi.CurrentMembers[groupMember][groupPermission] = true
495 gi.CurrentMembers[groupMember] = GroupPermissions{groupPermission: true}
502 // GetAll : Adds all objects of type 'resource' to the 'allItems' list
503 func GetAll(c *arvados.Client, res string, params arvados.ResourceListParams, page resourceList) (allItems []interface{}, err error) {
504 // Use the maximum page size the server allows
506 params.Limit = &limit
508 params.Order = "uuid"
510 if err = GetResourceList(c, &page, res, params); err != nil {
513 // Have we finished paging?
517 allItems = append(allItems, page.GetItems()...)
518 params.Offset += page.Len()
523 func subtract(setA map[string]GroupPermissions, setB map[string]GroupPermissions) map[string]GroupPermissions {
524 result := make(map[string]GroupPermissions)
525 for element := range setA {
526 if _, ok := setB[element]; !ok {
527 result[element] = setA[element]
529 for perm := range setA[element] {
530 if _, ok := setB[element][perm]; !ok {
531 result[element] = GroupPermissions{perm: true}
539 func jsonReader(rscName string, ob interface{}) io.Reader {
540 j, err := json.Marshal(ob)
545 v[rscName] = []string{string(j)}
546 return bytes.NewBufferString(v.Encode())
549 // GetRemoteGroups fetches all remote groups with their members
550 func GetRemoteGroups(cfg *ConfigParams, allUsers map[string]arvados.User) (remoteGroups map[string]*GroupInfo, groupNameToUUID map[string]string, err error) {
551 remoteGroups = make(map[string]*GroupInfo)
552 groupNameToUUID = make(map[string]string) // Index by group name
554 params := arvados.ResourceListParams{
555 Filters: []arvados.Filter{{
558 Operand: cfg.ParentGroupUUID,
561 results, err := GetAll(cfg.Client, "links", params, &LinkList{})
563 return remoteGroups, groupNameToUUID, fmt.Errorf("error getting remote groups: %s", err)
565 for _, item := range results {
566 var group arvados.Group
567 err = GetGroup(cfg, &group, item.(arvados.Link).HeadUUID)
569 return remoteGroups, groupNameToUUID, fmt.Errorf("error getting remote group: %s", err)
571 // Group -> User filter
572 g2uFilter := arvados.ResourceListParams{
573 Filters: []arvados.Filter{{
576 Operand: cfg.SysUserUUID,
580 Operand: "permission",
592 Operand: "arvados#user",
595 // User -> Group filter
596 u2gFilter := arvados.ResourceListParams{
597 Filters: []arvados.Filter{{
600 Operand: cfg.SysUserUUID,
604 Operand: "permission",
608 Operand: []string{"can_read", "can_write", "can_manage"},
616 Operand: "arvados#user",
619 g2uLinks, err := GetAll(cfg.Client, "links", g2uFilter, &LinkList{})
621 return remoteGroups, groupNameToUUID, fmt.Errorf("error getting group->user 'can_read' links for group %q: %s", group.Name, err)
623 u2gLinks, err := GetAll(cfg.Client, "links", u2gFilter, &LinkList{})
625 return remoteGroups, groupNameToUUID, fmt.Errorf("error getting user->group links for group %q: %s", group.Name, err)
627 // Build a list of user ids (email or username) belonging to this group.
628 membersSet := make(map[string]GroupPermissions)
629 u2gLinkSet := make(map[string]GroupPermissions)
630 for _, l := range u2gLinks {
631 link := l.(arvados.Link)
632 // Also save the member's group access level.
633 if _, ok := u2gLinkSet[link.TailUUID]; ok {
634 u2gLinkSet[link.TailUUID][link.Name] = true
636 u2gLinkSet[link.TailUUID] = GroupPermissions{link.Name: true}
639 for _, item := range g2uLinks {
640 link := item.(arvados.Link)
641 // We may have received an old link pointing to a removed account.
642 if _, found := allUsers[link.HeadUUID]; !found {
645 // The matching User -> Group link may not exist if the link
646 // creation failed on a previous run. If that's the case, don't
647 // include this account on the "previous members" list.
648 if _, found := u2gLinkSet[link.HeadUUID]; !found {
651 memberID, err := GetUserID(allUsers[link.HeadUUID], cfg.UserID)
653 return remoteGroups, groupNameToUUID, err
655 if cfg.UserID == "username" && cfg.CaseInsensitive {
656 memberID = strings.ToLower(memberID)
658 membersSet[memberID] = u2gLinkSet[link.HeadUUID]
660 remoteGroups[group.UUID] = &GroupInfo{
662 PreviousMembers: membersSet,
663 CurrentMembers: make(map[string]GroupPermissions),
665 groupNameToUUID[group.Name] = group.UUID
667 return remoteGroups, groupNameToUUID, nil
670 // RemoveMemberLinksFromGroup remove all links related to the membership
671 func RemoveMemberLinksFromGroup(cfg *ConfigParams, user arvados.User, linkNames []string, completeRemoval bool, group arvados.Group) error {
673 log.Printf("Getting group membership links for user %q (%s) on group %q (%s)", user.Username, user.UUID, group.Name, group.UUID)
675 var links []interface{}
676 var filters [][]arvados.Filter
678 // Search for all group<->user links (both ways)
679 filters = [][]arvados.Filter{
684 Operand: "permission",
698 Operand: "permission",
710 // Search only for the requested Group <- User permission links
711 filters = [][]arvados.Filter{
715 Operand: "permission",
732 for _, filterset := range filters {
733 l, err := GetAll(cfg.Client, "links", arvados.ResourceListParams{Filters: filterset}, &LinkList{})
735 userID, _ := GetUserID(user, cfg.UserID)
736 return fmt.Errorf("error getting links needed to remove user %q from group %q: %s", userID, group.Name, err)
738 links = append(links, l...)
740 for _, item := range links {
741 link := item.(arvados.Link)
742 userID, _ := GetUserID(user, cfg.UserID)
744 log.Printf("Removing %q permission link for %q on group %q", link.Name, userID, group.Name)
746 if err := DeleteLink(cfg, link.UUID); err != nil {
747 return fmt.Errorf("error removing user %q from group %q: %s", userID, group.Name, err)
753 // AddMemberToGroup create membership links
754 func AddMemberToGroup(cfg *ConfigParams, user arvados.User, group arvados.Group, perm string, createG2ULink bool) error {
755 var newLink arvados.Link
756 var linkData map[string]string
758 linkData = map[string]string{
759 "owner_uuid": cfg.SysUserUUID,
760 "link_class": "permission",
762 "tail_uuid": group.UUID,
763 "head_uuid": user.UUID,
765 if err := CreateLink(cfg, &newLink, linkData); err != nil {
766 userID, _ := GetUserID(user, cfg.UserID)
767 return fmt.Errorf("error adding group %q -> user %q read permission: %s", group.Name, userID, err)
770 linkData = map[string]string{
771 "owner_uuid": cfg.SysUserUUID,
772 "link_class": "permission",
774 "tail_uuid": user.UUID,
775 "head_uuid": group.UUID,
777 if err := CreateLink(cfg, &newLink, linkData); err != nil {
778 userID, _ := GetUserID(user, cfg.UserID)
779 return fmt.Errorf("error adding user %q -> group %q %s permission: %s", userID, group.Name, perm, err)
784 // CreateGroup creates a group with groupData parameters, assigns it to dst
785 func CreateGroup(cfg *ConfigParams, dst *arvados.Group, groupData map[string]string) error {
786 return cfg.Client.RequestAndDecode(dst, "POST", "/arvados/v1/groups", jsonReader("group", groupData), nil)
789 // GetGroup fetches a group by its UUID
790 func GetGroup(cfg *ConfigParams, dst *arvados.Group, groupUUID string) error {
791 return cfg.Client.RequestAndDecode(&dst, "GET", "/arvados/v1/groups/"+groupUUID, nil, nil)
794 // CreateLink creates a link with linkData parameters, assigns it to dst
795 func CreateLink(cfg *ConfigParams, dst *arvados.Link, linkData map[string]string) error {
796 return cfg.Client.RequestAndDecode(dst, "POST", "/arvados/v1/links", jsonReader("link", linkData), nil)
799 // DeleteLink deletes a link by its UUID
800 func DeleteLink(cfg *ConfigParams, linkUUID string) error {
802 return fmt.Errorf("cannot delete link with invalid UUID: %q", linkUUID)
804 return cfg.Client.RequestAndDecode(&arvados.Link{}, "DELETE", "/arvados/v1/links/"+linkUUID, nil, nil)
807 // GetResourceList fetches res list using params
808 func GetResourceList(c *arvados.Client, dst *resourceList, res string, params interface{}) error {
809 return c.RequestAndDecode(dst, "GET", "/arvados/v1/"+res, nil, params)