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"
22 // const remoteGroupParentName string = "Externally synchronized groups"
24 type resourceList interface {
26 GetItems() []interface{}
29 // GroupInfo tracks previous and current members of a particular Group
30 type GroupInfo struct {
32 PreviousMembers map[string]bool
33 CurrentMembers map[string]bool
36 // GetUserID returns the correct user id value depending on the selector
37 func GetUserID(u arvados.User, idSelector string) (string, error) {
42 return u.Username, nil
44 return "", fmt.Errorf("cannot identify user by %q selector", idSelector)
48 // UserList implements resourceList interface
49 type UserList struct {
53 // Len returns the amount of items this list holds
54 func (l UserList) Len() int {
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)
66 // GroupList implements resourceList interface
67 type GroupList struct {
71 // Len returns the amount of items this list holds
72 func (l GroupList) Len() int {
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)
84 // Link is an arvados#link record
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"`
96 // LinkList implements resourceList interface
97 type LinkList struct {
98 Items []Link `json:"items"`
101 // Len returns the amount of items this list holds
102 func (l LinkList) Len() int {
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)
115 if err := doMain(); err != nil {
116 log.Fatalf("%v", err)
120 // ConfigParams holds configuration data for this tool
121 type ConfigParams struct {
125 ParentGroupUUID string
126 ParentGroupName string
128 Client *arvados.Client
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
138 flags := flag.NewFlagSet("arv-sync-groups", flag.ExitOnError)
139 srcPath := flags.String(
142 "Local file path containing a CSV format: GroupName,UserID")
143 userID := flags.String(
146 "Attribute by which every user is identified. Valid values are: email and username.")
147 verbose := flags.Bool(
150 "Log informational messages. Off by default.")
151 parentGroupUUID := flags.String(
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).")
156 // Parse args; omit the first arg which is the command name
157 flags.Parse(os.Args[1:])
161 return fmt.Errorf("please provide a path to an input file")
163 if !userIDOpts[*userID] {
165 for opt := range userIDOpts {
166 options = append(options, opt)
168 return fmt.Errorf("user ID must be one of: %s", strings.Join(options, ", "))
171 config.Path = *srcPath
172 config.ParentGroupUUID = *parentGroupUUID
173 config.UserID = *userID
174 config.Verbose = *verbose
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
185 params := arvados.ResourceListParams{
186 Filters: []arvados.Filter{{
189 Operand: cfg.ParentGroupName,
193 Operand: cfg.SysUserUUID,
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)
199 if len(gl.Items) == 0 {
200 // Default parent group not existant, create one.
202 log.Println("Default parent group not found, creating...")
204 groupData := map[string]string{
205 "name": cfg.ParentGroupName,
206 "owner_uuid": cfg.SysUserUUID,
208 if err := cfg.Client.RequestAndDecode(&parentGroup, "POST", "/arvados/v1/groups", jsonReader("group", groupData), nil); err != nil {
209 return fmt.Errorf("error creating system user owned group named %q: %s", groupData["name"], err)
211 } else if len(gl.Items) == 1 {
212 // Default parent group found.
213 parentGroup = gl.Items[0]
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)
219 cfg.ParentGroupUUID = parentGroup.UUID
221 // UUID provided. Check if exists and if it's owned by system user
222 if err := cfg.Client.RequestAndDecode(&parentGroup, "GET", "/arvados/v1/groups/"+cfg.ParentGroupUUID, nil, nil); err != nil {
223 return fmt.Errorf("error searching for parent group with UUID %q: %s", cfg.ParentGroupUUID, err)
225 if parentGroup.OwnerUUID != cfg.SysUserUUID {
226 return fmt.Errorf("parent group %q (%s) must be owned by system user", parentGroup.Name, cfg.ParentGroupUUID)
232 // GetConfig sets up a ConfigParams struct
233 func GetConfig() (config ConfigParams, err error) {
234 config.ParentGroupName = "Externally synchronized groups"
237 err = ParseFlags(&config)
242 // Arvados Client setup
243 config.Client = arvados.NewClientFromEnv()
245 // Check current user permissions & get System user's UUID
246 u, err := config.Client.CurrentUser()
248 return config, fmt.Errorf("error getting the current user: %s", err)
250 if !u.IsActive || !u.IsAdmin {
251 return config, fmt.Errorf("current user (%s) is not an active admin user", u.UUID)
253 config.SysUserUUID = u.UUID[:12] + "000000000000000"
255 // Set up remote groups' parent
256 if err = SetParentGroup(&config); err != nil {
263 func doMain() error {
264 // Parse & validate arguments, set up arvados client.
265 cfg, err := GetConfig()
270 // Try opening the input file early, just in case there's a problem.
271 f, err := os.Open(cfg.Path)
273 return fmt.Errorf("%s", err)
277 log.Printf("Group sync starting. Using %q as users id and parent group UUID %q", cfg.UserID, cfg.ParentGroupUUID)
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{})
284 return fmt.Errorf("error getting user list: %s", err)
286 log.Printf("Found %d users", len(results))
287 for _, item := range results {
288 u := item.(arvados.User)
290 uID, err := GetUserID(u, cfg.UserID)
294 userIDToUUID[uID] = u.UUID
296 log.Printf("Seen user %q (%s)", u.Username, u.Email)
300 // Get remote groups and their members
301 remoteGroups, groupNameToUUID, err := GetRemoteGroups(&cfg, allUsers)
305 log.Printf("Found %d remote groups", len(remoteGroups))
308 membershipsAdded := 0
309 membershipsRemoved := 0
310 membershipsSkipped := 0
313 csvReader := csv.NewReader(f)
315 record, err := csvReader.Read()
320 return fmt.Errorf("error reading %q: %s", cfg.Path, err)
322 groupName := strings.TrimSpace(record[0])
323 groupMember := strings.TrimSpace(record[1]) // User ID (username or email)
324 if groupName == "" || groupMember == "" {
325 log.Printf("Warning: CSV record has at least one empty field (%s, %s). Skipping", groupName, groupMember)
329 if _, found := userIDToUUID[groupMember]; !found {
330 // User not present on the system, skip.
331 log.Printf("Warning: there's no user with %s %q on the system, skipping.", cfg.UserID, groupMember)
335 if _, found := groupNameToUUID[groupName]; !found {
336 // Group doesn't exist, create it before continuing
338 log.Printf("Remote group %q not found, creating...", groupName)
340 var newGroup arvados.Group
341 groupData := map[string]string{
343 "owner_uuid": cfg.ParentGroupUUID,
345 if err := cfg.Client.RequestAndDecode(&newGroup, "POST", "/arvados/v1/groups", jsonReader("group", groupData), nil); err != nil {
346 return fmt.Errorf("error creating group named %q: %s", groupName, err)
348 // Update cached group data
349 groupNameToUUID[groupName] = newGroup.UUID
350 remoteGroups[newGroup.UUID] = &GroupInfo{
352 PreviousMembers: make(map[string]bool), // Empty set
353 CurrentMembers: make(map[string]bool), // Empty set
357 // Both group & user exist, check if user is a member
358 groupUUID := groupNameToUUID[groupName]
359 gi := remoteGroups[groupUUID]
360 if !gi.PreviousMembers[groupMember] && !gi.CurrentMembers[groupMember] {
362 log.Printf("Adding %q to group %q", groupMember, groupName)
364 // User wasn't a member, but should be.
366 linkData := map[string]string{
367 "owner_uuid": cfg.SysUserUUID,
368 "link_class": "permission",
370 "tail_uuid": groupUUID,
371 "head_uuid": userIDToUUID[groupMember],
373 if err := cfg.Client.RequestAndDecode(&newLink, "POST", "/arvados/v1/links", jsonReader("link", linkData), nil); err != nil {
374 return fmt.Errorf("error adding group %q -> user %q read permission: %s", groupName, groupMember, err)
376 linkData = map[string]string{
377 "owner_uuid": cfg.SysUserUUID,
378 "link_class": "permission",
380 "tail_uuid": userIDToUUID[groupMember],
381 "head_uuid": groupUUID,
383 if err = cfg.Client.RequestAndDecode(&newLink, "POST", "/arvados/v1/links", jsonReader("link", linkData), nil); err != nil {
384 return fmt.Errorf("error adding user %q -> group %q manage permission: %s", groupMember, groupName, err)
388 gi.CurrentMembers[groupMember] = true
391 // Remove previous members not listed on this run
392 for groupUUID := range remoteGroups {
393 gi := remoteGroups[groupUUID]
394 evictedMembers := subtract(gi.PreviousMembers, gi.CurrentMembers)
395 groupName := gi.Group.Name
396 if len(evictedMembers) > 0 {
397 log.Printf("Removing %d users from group %q", len(evictedMembers), groupName)
399 for evictedUser := range evictedMembers {
400 if err := RemoveMemberFromGroup(&cfg, allUsers[evictedUser], gi.Group); err != nil {
406 log.Printf("Groups created: %d. Memberships added: %d, removed: %d, skipped: %d", groupsCreated, membershipsAdded, membershipsRemoved, membershipsSkipped)
411 // GetAll : Adds all objects of type 'resource' to the 'allItems' list
412 func GetAll(c *arvados.Client, res string, params arvados.ResourceListParams, page resourceList) (allItems []interface{}, err error) {
413 // Use the maximum page size the server allows
415 params.Limit = &limit
417 params.Order = "uuid"
419 if err = c.RequestAndDecode(&page, "GET", "/arvados/v1/"+res, nil, params); err != nil {
422 // Have we finished paging?
426 for _, i := range page.GetItems() {
427 allItems = append(allItems, i)
429 params.Offset += page.Len()
434 func subtract(setA map[string]bool, setB map[string]bool) map[string]bool {
435 result := make(map[string]bool)
436 for element := range setA {
438 result[element] = true
444 func jsonReader(rscName string, ob interface{}) io.Reader {
445 j, err := json.Marshal(ob)
450 v[rscName] = []string{string(j)}
451 return bytes.NewBufferString(v.Encode())
454 // GetRemoteGroups fetches all remote groups with their members
455 func GetRemoteGroups(cfg *ConfigParams, allUsers map[string]arvados.User) (remoteGroups map[string]*GroupInfo, groupNameToUUID map[string]string, err error) {
456 remoteGroups = make(map[string]*GroupInfo)
457 groupNameToUUID = make(map[string]string) // Index by group name
459 params := arvados.ResourceListParams{
460 Filters: []arvados.Filter{{
463 Operand: cfg.ParentGroupUUID,
466 results, err := GetAll(cfg.Client, "groups", params, &GroupList{})
468 return remoteGroups, groupNameToUUID, fmt.Errorf("error getting remote groups: %s", err)
470 for _, item := range results {
471 group := item.(arvados.Group)
472 // Group -> User filter
473 g2uFilter := arvados.ResourceListParams{
474 Filters: []arvados.Filter{{
477 Operand: cfg.SysUserUUID,
481 Operand: "permission",
493 Operand: "arvados#user",
496 // User -> Group filter
497 u2gFilter := arvados.ResourceListParams{
498 Filters: []arvados.Filter{{
501 Operand: cfg.SysUserUUID,
505 Operand: "permission",
517 Operand: "arvados#user",
520 g2uLinks, err := GetAll(cfg.Client, "links", g2uFilter, &LinkList{})
522 return remoteGroups, groupNameToUUID, fmt.Errorf("error getting member (can_read) links for group %q: %s", group.Name, err)
524 u2gLinks, err := GetAll(cfg.Client, "links", u2gFilter, &LinkList{})
526 return remoteGroups, groupNameToUUID, fmt.Errorf("error getting member (manage) links for group %q: %s", group.Name, err)
528 // Build a list of user ids (email or username) belonging to this group
529 membersSet := make(map[string]bool)
530 u2gLinkSet := make(map[string]bool)
531 for _, l := range u2gLinks {
532 linkedMemberUUID := l.(Link).TailUUID
533 u2gLinkSet[linkedMemberUUID] = true
535 for _, item := range g2uLinks {
537 // We may have received an old link pointing to a removed account.
538 if _, found := allUsers[link.HeadUUID]; !found {
541 // The matching User -> Group link may not exist if the link
542 // creation failed on a previous run. If that's the case, don't
543 // include this account on the "previous members" list.
544 if _, found := u2gLinkSet[link.HeadUUID]; !found {
547 memberID, err := GetUserID(allUsers[link.HeadUUID], cfg.UserID)
549 return remoteGroups, groupNameToUUID, err
551 membersSet[memberID] = true
553 remoteGroups[group.UUID] = &GroupInfo{
555 PreviousMembers: membersSet,
556 CurrentMembers: make(map[string]bool), // Empty set
558 groupNameToUUID[group.Name] = group.UUID
560 return remoteGroups, groupNameToUUID, nil
563 // RemoveMemberFromGroup remove all links related to the membership
564 func RemoveMemberFromGroup(cfg *ConfigParams, user arvados.User, group arvados.Group) error {
566 log.Printf("Getting group membership links for user %q (%s) on group %q (%s)", user.Email, user.UUID, group.Name, group.UUID)
568 var links []interface{}
569 // Search for all group<->user links (both ways)
570 for _, filterset := range [][]arvados.Filter{
575 Operand: "permission",
589 Operand: "permission",
600 l, err := GetAll(cfg.Client, "links", arvados.ResourceListParams{Filters: filterset}, &LinkList{})
602 return fmt.Errorf("error getting links needed to remove user %q from group %q: %s", user.Email, group.Name, err)
604 for _, link := range l {
605 links = append(links, link)
608 for _, item := range links {
611 log.Printf("Removing permission link for %q on group %q", user.Email, group.Name)
613 if err := cfg.Client.RequestAndDecode(&link, "DELETE", "/arvados/v1/links/"+link.UUID, nil, nil); err != nil {
614 return fmt.Errorf("error removing user %q from group %q: %s", user.Email, group.Name, err)