1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
19 "git.curoverse.com/arvados.git/sdk/go/arvados"
20 arvadosVersion "git.curoverse.com/arvados.git/sdk/go/version"
23 type resourceList interface {
25 GetItems() []interface{}
28 // GroupInfo tracks previous and current members of a particular Group
29 type GroupInfo struct {
31 PreviousMembers map[string]bool
32 CurrentMembers map[string]bool
35 // GetUserID returns the correct user id value depending on the selector
36 func GetUserID(u arvados.User, idSelector string) (string, error) {
41 return u.Username, nil
43 return "", fmt.Errorf("cannot identify user by %q selector", idSelector)
47 // UserList implements resourceList interface
48 type UserList struct {
52 // Len returns the amount of items this list holds
53 func (l UserList) Len() int {
57 // GetItems returns the list of items
58 func (l UserList) GetItems() (out []interface{}) {
59 for _, item := range l.Items {
60 out = append(out, item)
65 // GroupList implements resourceList interface
66 type GroupList struct {
70 // Len returns the amount of items this list holds
71 func (l GroupList) Len() int {
75 // GetItems returns the list of items
76 func (l GroupList) GetItems() (out []interface{}) {
77 for _, item := range l.Items {
78 out = append(out, item)
83 // LinkList implements resourceList interface
84 type LinkList struct {
88 // Len returns the amount of items this list holds
89 func (l LinkList) Len() int {
93 // GetItems returns the list of items
94 func (l LinkList) GetItems() (out []interface{}) {
95 for _, item := range l.Items {
96 out = append(out, item)
102 // Parse & validate arguments, set up arvados client.
103 cfg, err := GetConfig()
105 log.Fatalf("%v", err)
108 if err := doMain(&cfg); err != nil {
109 log.Fatalf("%v", err)
113 // ConfigParams holds configuration data for this tool
114 type ConfigParams struct {
118 ParentGroupUUID string
119 ParentGroupName string
121 Client *arvados.Client
124 // ParseFlags parses and validates command line arguments
125 func ParseFlags(config *ConfigParams) error {
126 // Acceptable attributes to identify a user on the CSV file
127 userIDOpts := map[string]bool{
128 "email": true, // default
132 flags := flag.NewFlagSet("arv-sync-groups", flag.ExitOnError)
134 // Set up usage message
135 flags.Usage = func() {
136 usageStr := `Synchronize remote groups into Arvados from a CSV format file with 2 columns:
137 * 1st column: Group name
138 * 2nd column: User identifier`
139 fmt.Fprintf(os.Stderr, "%s\n\n", usageStr)
140 fmt.Fprintf(os.Stderr, "Usage:\n%s [OPTIONS] <input-file.csv>\n\n", os.Args[0])
141 fmt.Fprintf(os.Stderr, "Options:\n")
142 flags.PrintDefaults()
145 // Set up option flags
146 userID := flags.String(
149 "Attribute by which every user is identified. Valid values are: email and username.")
150 verbose := flags.Bool(
153 "Log informational messages. Off by default.")
154 getVersion := flags.Bool(
157 "Print version information and exit.")
158 parentGroupUUID := flags.String(
161 "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).")
163 // Parse args; omit the first arg which is the command name
164 flags.Parse(os.Args[1:])
166 // Print version information if requested
168 fmt.Printf("Version: %s\n", arvadosVersion.GetVersion())
172 // Input file as a required positional argument
173 if flags.NArg() == 0 {
174 return fmt.Errorf("please provide a path to an input file")
176 srcPath := &os.Args[flags.NFlag()+1]
180 return fmt.Errorf("input file path invalid")
182 if !userIDOpts[*userID] {
184 for opt := range userIDOpts {
185 options = append(options, opt)
187 return fmt.Errorf("user ID must be one of: %s", strings.Join(options, ", "))
190 config.Path = *srcPath
191 config.ParentGroupUUID = *parentGroupUUID
192 config.UserID = *userID
193 config.Verbose = *verbose
198 // SetParentGroup finds/create parent group of all remote groups
199 func SetParentGroup(cfg *ConfigParams) error {
200 var parentGroup arvados.Group
201 if cfg.ParentGroupUUID == "" {
202 // UUID not provided, search for preexisting parent group
204 params := arvados.ResourceListParams{
205 Filters: []arvados.Filter{{
208 Operand: cfg.ParentGroupName,
212 Operand: cfg.SysUserUUID,
215 if err := cfg.Client.RequestAndDecode(&gl, "GET", "/arvados/v1/groups", nil, params); err != nil {
216 return fmt.Errorf("error searching for parent group: %s", err)
218 if len(gl.Items) == 0 {
219 // Default parent group not existant, create one.
221 log.Println("Default parent group not found, creating...")
223 groupData := map[string]string{
224 "name": cfg.ParentGroupName,
225 "owner_uuid": cfg.SysUserUUID,
227 if err := CreateGroup(cfg, &parentGroup, groupData); err != nil {
228 return fmt.Errorf("error creating system user owned group named %q: %s", groupData["name"], err)
230 } else if len(gl.Items) == 1 {
231 // Default parent group found.
232 parentGroup = gl.Items[0]
234 // This should never happen, as there's an unique index for
235 // (owner_uuid, name) on groups.
236 return fmt.Errorf("bug: found %d groups owned by system user and named %q", len(gl.Items), cfg.ParentGroupName)
238 cfg.ParentGroupUUID = parentGroup.UUID
240 // UUID provided. Check if exists and if it's owned by system user
241 if err := GetGroup(cfg, &parentGroup, cfg.ParentGroupUUID); err != nil {
242 return fmt.Errorf("error searching for parent group with UUID %q: %s", cfg.ParentGroupUUID, err)
244 if parentGroup.OwnerUUID != cfg.SysUserUUID {
245 return fmt.Errorf("parent group %q (%s) must be owned by system user", parentGroup.Name, cfg.ParentGroupUUID)
251 // GetConfig sets up a ConfigParams struct
252 func GetConfig() (config ConfigParams, err error) {
253 config.ParentGroupName = "Externally synchronized groups"
256 err = ParseFlags(&config)
261 // Arvados Client setup
262 config.Client = arvados.NewClientFromEnv()
264 // Check current user permissions & get System user's UUID
265 u, err := config.Client.CurrentUser()
267 return config, fmt.Errorf("error getting the current user: %s", err)
269 if !u.IsActive || !u.IsAdmin {
270 return config, fmt.Errorf("current user (%s) is not an active admin user", u.UUID)
272 config.SysUserUUID = u.UUID[:12] + "000000000000000"
274 // Set up remote groups' parent
275 if err = SetParentGroup(&config); err != nil {
282 func doMain(cfg *ConfigParams) error {
283 // Try opening the input file early, just in case there's a problem.
284 f, err := os.Open(cfg.Path)
286 return fmt.Errorf("%s", err)
290 log.Printf("arv-sync-groups %q started. Using %q as users id and parent group UUID %q", arvadosVersion.GetVersion(), cfg.UserID, cfg.ParentGroupUUID)
292 // Get the complete user list to minimize API Server requests
293 allUsers := make(map[string]arvados.User)
294 userIDToUUID := make(map[string]string) // Index by email or username
295 results, err := GetAll(cfg.Client, "users", arvados.ResourceListParams{}, &UserList{})
297 return fmt.Errorf("error getting user list: %s", err)
299 log.Printf("Found %d users", len(results))
300 for _, item := range results {
301 u := item.(arvados.User)
303 uID, err := GetUserID(u, cfg.UserID)
307 userIDToUUID[uID] = u.UUID
309 log.Printf("Seen user %q (%s)", u.Username, u.Email)
313 // Get remote groups and their members
314 remoteGroups, groupNameToUUID, err := GetRemoteGroups(cfg, allUsers)
318 log.Printf("Found %d remote groups", len(remoteGroups))
320 membershipsRemoved := 0
323 groupsCreated, membershipsAdded, membershipsSkipped, err := ProcessFile(cfg, f, userIDToUUID, groupNameToUUID, remoteGroups, allUsers)
328 // Remove previous members not listed on this run
329 for groupUUID := range remoteGroups {
330 gi := remoteGroups[groupUUID]
331 evictedMembers := subtract(gi.PreviousMembers, gi.CurrentMembers)
332 groupName := gi.Group.Name
333 if len(evictedMembers) > 0 {
334 log.Printf("Removing %d users from group %q", len(evictedMembers), groupName)
336 for evictedUser := range evictedMembers {
337 if err := RemoveMemberFromGroup(cfg, allUsers[userIDToUUID[evictedUser]], gi.Group); err != nil {
343 log.Printf("Groups created: %d. Memberships added: %d, removed: %d, skipped: %d", groupsCreated, membershipsAdded, membershipsRemoved, membershipsSkipped)
348 // ProcessFile reads the CSV file and process every record
352 userIDToUUID map[string]string,
353 groupNameToUUID map[string]string,
354 remoteGroups map[string]*GroupInfo,
355 allUsers map[string]arvados.User,
356 ) (groupsCreated, membersAdded, membersSkipped int, err error) {
358 csvReader := csv.NewReader(f)
359 csvReader.FieldsPerRecord = 2
361 record, e := csvReader.Read()
367 err = fmt.Errorf("error parsing %q, line %d", cfg.Path, lineNo)
370 groupName := strings.TrimSpace(record[0])
371 groupMember := strings.TrimSpace(record[1]) // User ID (username or email)
372 if groupName == "" || groupMember == "" {
373 log.Printf("Warning: CSV record has at least one empty field (%s, %s). Skipping", groupName, groupMember)
377 if _, found := userIDToUUID[groupMember]; !found {
378 // User not present on the system, skip.
379 log.Printf("Warning: there's no user with %s %q on the system, skipping.", cfg.UserID, groupMember)
383 if _, found := groupNameToUUID[groupName]; !found {
384 // Group doesn't exist, create it before continuing
386 log.Printf("Remote group %q not found, creating...", groupName)
388 var newGroup arvados.Group
389 groupData := map[string]string{
391 "owner_uuid": cfg.ParentGroupUUID,
392 "group_class": "role",
394 if e := CreateGroup(cfg, &newGroup, groupData); e != nil {
395 err = fmt.Errorf("error creating group named %q: %s", groupName, err)
398 // Update cached group data
399 groupNameToUUID[groupName] = newGroup.UUID
400 remoteGroups[newGroup.UUID] = &GroupInfo{
402 PreviousMembers: make(map[string]bool), // Empty set
403 CurrentMembers: make(map[string]bool), // Empty set
407 // Both group & user exist, check if user is a member
408 groupUUID := groupNameToUUID[groupName]
409 gi := remoteGroups[groupUUID]
410 if !gi.PreviousMembers[groupMember] && !gi.CurrentMembers[groupMember] {
412 log.Printf("Adding %q to group %q", groupMember, groupName)
414 // User wasn't a member, but should be.
415 if e := AddMemberToGroup(cfg, allUsers[userIDToUUID[groupMember]], gi.Group); e != nil {
421 gi.CurrentMembers[groupMember] = true
426 // GetAll : Adds all objects of type 'resource' to the 'allItems' list
427 func GetAll(c *arvados.Client, res string, params arvados.ResourceListParams, page resourceList) (allItems []interface{}, err error) {
428 // Use the maximum page size the server allows
430 params.Limit = &limit
432 params.Order = "uuid"
434 if err = GetResourceList(c, &page, res, params); err != nil {
437 // Have we finished paging?
441 for _, i := range page.GetItems() {
442 allItems = append(allItems, i)
444 params.Offset += page.Len()
449 func subtract(setA map[string]bool, setB map[string]bool) map[string]bool {
450 result := make(map[string]bool)
451 for element := range setA {
453 result[element] = true
459 func jsonReader(rscName string, ob interface{}) io.Reader {
460 j, err := json.Marshal(ob)
465 v[rscName] = []string{string(j)}
466 return bytes.NewBufferString(v.Encode())
469 // GetRemoteGroups fetches all remote groups with their members
470 func GetRemoteGroups(cfg *ConfigParams, allUsers map[string]arvados.User) (remoteGroups map[string]*GroupInfo, groupNameToUUID map[string]string, err error) {
471 remoteGroups = make(map[string]*GroupInfo)
472 groupNameToUUID = make(map[string]string) // Index by group name
474 params := arvados.ResourceListParams{
475 Filters: []arvados.Filter{{
478 Operand: cfg.ParentGroupUUID,
481 results, err := GetAll(cfg.Client, "groups", params, &GroupList{})
483 return remoteGroups, groupNameToUUID, fmt.Errorf("error getting remote groups: %s", err)
485 for _, item := range results {
486 group := item.(arvados.Group)
487 // Group -> User filter
488 g2uFilter := arvados.ResourceListParams{
489 Filters: []arvados.Filter{{
492 Operand: cfg.SysUserUUID,
496 Operand: "permission",
508 Operand: "arvados#user",
511 // User -> Group filter
512 u2gFilter := arvados.ResourceListParams{
513 Filters: []arvados.Filter{{
516 Operand: cfg.SysUserUUID,
520 Operand: "permission",
524 Operand: "can_write",
532 Operand: "arvados#user",
535 g2uLinks, err := GetAll(cfg.Client, "links", g2uFilter, &LinkList{})
537 return remoteGroups, groupNameToUUID, fmt.Errorf("error getting member (can_read) links for group %q: %s", group.Name, err)
539 u2gLinks, err := GetAll(cfg.Client, "links", u2gFilter, &LinkList{})
541 return remoteGroups, groupNameToUUID, fmt.Errorf("error getting member (can_write) links for group %q: %s", group.Name, err)
543 // Build a list of user ids (email or username) belonging to this group
544 membersSet := make(map[string]bool)
545 u2gLinkSet := make(map[string]bool)
546 for _, l := range u2gLinks {
547 linkedMemberUUID := l.(arvados.Link).TailUUID
548 u2gLinkSet[linkedMemberUUID] = true
550 for _, item := range g2uLinks {
551 link := item.(arvados.Link)
552 // We may have received an old link pointing to a removed account.
553 if _, found := allUsers[link.HeadUUID]; !found {
556 // The matching User -> Group link may not exist if the link
557 // creation failed on a previous run. If that's the case, don't
558 // include this account on the "previous members" list.
559 if _, found := u2gLinkSet[link.HeadUUID]; !found {
562 memberID, err := GetUserID(allUsers[link.HeadUUID], cfg.UserID)
564 return remoteGroups, groupNameToUUID, err
566 membersSet[memberID] = true
568 remoteGroups[group.UUID] = &GroupInfo{
570 PreviousMembers: membersSet,
571 CurrentMembers: make(map[string]bool), // Empty set
573 groupNameToUUID[group.Name] = group.UUID
575 return remoteGroups, groupNameToUUID, nil
578 // RemoveMemberFromGroup remove all links related to the membership
579 func RemoveMemberFromGroup(cfg *ConfigParams, user arvados.User, group arvados.Group) error {
581 log.Printf("Getting group membership links for user %q (%s) on group %q (%s)", user.Email, user.UUID, group.Name, group.UUID)
583 var links []interface{}
584 // Search for all group<->user links (both ways)
585 for _, filterset := range [][]arvados.Filter{
590 Operand: "permission",
604 Operand: "permission",
615 l, err := GetAll(cfg.Client, "links", arvados.ResourceListParams{Filters: filterset}, &LinkList{})
617 userID, _ := GetUserID(user, cfg.UserID)
618 return fmt.Errorf("error getting links needed to remove user %q from group %q: %s", userID, group.Name, err)
620 for _, link := range l {
621 links = append(links, link)
624 for _, item := range links {
625 link := item.(arvados.Link)
626 userID, _ := GetUserID(user, cfg.UserID)
628 log.Printf("Removing %q permission link for %q on group %q", link.Name, userID, group.Name)
630 if err := DeleteLink(cfg, link.UUID); err != nil {
631 return fmt.Errorf("error removing user %q from group %q: %s", userID, group.Name, err)
637 // AddMemberToGroup create membership links
638 func AddMemberToGroup(cfg *ConfigParams, user arvados.User, group arvados.Group) error {
639 var newLink arvados.Link
640 linkData := map[string]string{
641 "owner_uuid": cfg.SysUserUUID,
642 "link_class": "permission",
644 "tail_uuid": group.UUID,
645 "head_uuid": user.UUID,
647 if err := CreateLink(cfg, &newLink, linkData); err != nil {
648 userID, _ := GetUserID(user, cfg.UserID)
649 return fmt.Errorf("error adding group %q -> user %q read permission: %s", group.Name, userID, err)
651 linkData = map[string]string{
652 "owner_uuid": cfg.SysUserUUID,
653 "link_class": "permission",
655 "tail_uuid": user.UUID,
656 "head_uuid": group.UUID,
658 if err := CreateLink(cfg, &newLink, linkData); err != nil {
659 userID, _ := GetUserID(user, cfg.UserID)
660 return fmt.Errorf("error adding user %q -> group %q write permission: %s", userID, group.Name, err)
665 // CreateGroup creates a group with groupData parameters, assigns it to dst
666 func CreateGroup(cfg *ConfigParams, dst *arvados.Group, groupData map[string]string) error {
667 return cfg.Client.RequestAndDecode(dst, "POST", "/arvados/v1/groups", jsonReader("group", groupData), nil)
670 // GetGroup fetches a group by its UUID
671 func GetGroup(cfg *ConfigParams, dst *arvados.Group, groupUUID string) error {
672 return cfg.Client.RequestAndDecode(&dst, "GET", "/arvados/v1/groups/"+groupUUID, nil, nil)
675 // CreateLink creates a link with linkData parameters, assigns it to dst
676 func CreateLink(cfg *ConfigParams, dst *arvados.Link, linkData map[string]string) error {
677 return cfg.Client.RequestAndDecode(dst, "POST", "/arvados/v1/links", jsonReader("link", linkData), nil)
680 // DeleteLink deletes a link by its UUID
681 func DeleteLink(cfg *ConfigParams, linkUUID string) error {
683 return fmt.Errorf("cannot delete link with invalid UUID: %q", linkUUID)
685 return cfg.Client.RequestAndDecode(&arvados.Link{}, "DELETE", "/arvados/v1/links/"+linkUUID, nil, nil)
688 // GetResourceList fetches res list using params
689 func GetResourceList(c *arvados.Client, dst *resourceList, res string, params interface{}) error {
690 return c.RequestAndDecode(dst, "GET", "/arvados/v1/"+res, nil, params)