1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
16 "git.curoverse.com/arvados.git/sdk/go/arvados"
17 "git.curoverse.com/arvados.git/sdk/go/arvadosclient"
20 type resourceList interface {
26 type groupInfo struct {
28 PreviousMembers map[string]bool
29 CurrentMembers map[string]bool
33 UUID string `json:"uuid,omitempty"`
34 Email string `json:"email,omitempty"`
35 Username string `json:"username,omitempty"`
38 func (u user) GetID(idSelector string) (string, error) {
43 return u.Username, nil
45 return "", fmt.Errorf("cannot identify user by %q selector", idSelector)
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"`
56 func (l userList) items() []interface{} {
58 for _, item := range l.Items {
59 out = append(out, item)
64 func (l userList) itemsAvailable() int {
65 return l.ItemsAvailable
68 func (l userList) offset() int {
73 UUID string `json:"uuid,omitempty"`
74 Name string `json:"name,omitempty"`
75 OwnerUUID string `json:"owner_uuid,omitempty"`
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"`
85 func (l groupList) items() []interface{} {
87 for _, item := range l.Items {
88 out = append(out, item)
93 func (l groupList) itemsAvailable() int {
94 return l.ItemsAvailable
97 func (l groupList) offset() int {
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"`
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"`
118 func (l linkList) items() []interface{} {
119 var out []interface{}
120 for _, item := range l.Items {
121 out = append(out, item)
126 func (l linkList) itemsAvailable() int {
127 return l.ItemsAvailable
130 func (l linkList) offset() int {
137 log.Fatalf("%v", err)
141 func doMain() error {
142 const groupTag string = "remote_group"
143 const remoteGroupParentName string = "Externally synchronized groups"
144 userIDOpts := []string{"email", "username"}
146 flags := flag.NewFlagSet("arv-sync-groups", flag.ExitOnError)
148 srcPath := flags.String(
151 "Local file path containing a CSV format.")
153 userID := flags.String(
156 "Attribute by which every user is identified. "+
157 "Valid values are: email (the default) and username.")
159 verbose := flags.Bool(
162 "Log informational messages. By default is deactivated.")
164 retries := flags.Int(
167 "Maximum number of times to retry server requests that encounter "+
168 "temporary failures (e.g., server down). Default 3.")
170 parentGroupUUID := flags.String(
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).")
177 // Parse args; omit the first arg which is the command name
178 flags.Parse(os.Args[1:])
182 return fmt.Errorf("retry quantity must be >= 0")
186 return fmt.Errorf("please provide a path to an input file")
189 // Try opening the input file early, just in case there's problems.
190 f, err := os.Open(*srcPath)
192 return fmt.Errorf("%s", err)
197 for _, opt := range userIDOpts {
203 return fmt.Errorf("user ID must be one of: %s",
204 strings.Join(userIDOpts, ", "))
207 // Arvados Client setup
208 ac := arvados.NewClientFromEnv()
209 arv, err := arvadosclient.New(ac)
211 return fmt.Errorf("error setting up arvados client %s", err)
213 arv.Retries = *retries
215 // Check current user permissions & get System user's UUID
216 u, err := ac.CurrentUser()
218 return fmt.Errorf("error getting the current user: %s", err)
220 if !u.IsActive || !u.IsAdmin {
221 return fmt.Errorf("current user (%s) is not an active admin user", u.UUID)
223 sysUserUUID := u.UUID[:12] + "000000000000000"
225 // Find/create parent group
226 var parentGroup group
227 if *parentGroupUUID == "" {
228 // UUID not provided, search for preexisting parent group
230 err := arv.List("groups", arvadosclient.Dict{
231 "filters": [][]string{
232 {"name", "=", remoteGroupParentName},
233 {"owner_uuid", "=", sysUserUUID}},
236 return fmt.Errorf("error searching for parent group: %s", err)
238 if len(gl.Items) == 0 {
239 // Default parent group not existant, create one.
241 log.Println("Default parent group not found, creating...")
243 err := arv.Create("groups", arvadosclient.Dict{
244 "group": arvadosclient.Dict{
245 "name": remoteGroupParentName,
246 "owner_uuid": sysUserUUID},
249 return fmt.Errorf("error creating system user owned group named %q: %s", remoteGroupParentName, err)
251 } else if len(gl.Items) == 1 {
252 // Default parent group found.
253 parentGroup = gl.Items[0]
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)
260 // UUID provided. Check if exists and if it's owned by system user
261 err := arv.Get("groups", *parentGroupUUID, arvadosclient.Dict{}, &parentGroup)
263 return fmt.Errorf("error searching for parent group with UUID %q: %s", *parentGroupUUID, err)
265 if parentGroup.UUID == "" {
266 return fmt.Errorf("parent group with UUID %q not found", *parentGroupUUID)
268 if parentGroup.OwnerUUID != sysUserUUID {
269 return fmt.Errorf("parent group %q (%s) must be owned by system user", parentGroup.Name, *parentGroupUUID)
273 log.Printf("Group sync starting. Using %q as users id and parent group UUID %q", *userID, parentGroup.UUID)
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{})
280 return fmt.Errorf("error getting user list: %s", err)
282 log.Printf("Found %d users", len(results))
283 for _, item := range results {
286 uID, err := u.GetID(*userID)
290 userIDToUUID[uID] = u.UUID
292 log.Printf("Seen user %q (%s)", u.Username, u.Email)
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"},
306 return fmt.Errorf("error getting remote group UUIDs: %s", err)
308 for _, item := range results {
310 remoteGroupUUIDs[link.HeadUUID] = true
312 // Get remote groups and their members
313 var uuidList []string
314 for uuid := range remoteGroupUUIDs {
315 uuidList = append(uuidList, uuid)
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},
326 return fmt.Errorf("error getting remote groups by UUID: %s", err)
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"},
339 return fmt.Errorf("error getting member links: %s", err)
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 {
345 memberID, err := allUsers[link.HeadUUID].GetID(*userID)
349 membersSet[memberID] = true
351 remoteGroups[group.UUID] = &groupInfo{
353 PreviousMembers: membersSet,
354 CurrentMembers: make(map[string]bool), // Empty set
356 groupNameToUUID[group.Name] = group.UUID
358 log.Printf("Found %d remote groups", len(remoteGroups))
364 csvReader := csv.NewReader(f)
366 record, err := csvReader.Read()
371 return fmt.Errorf("error reading %q: %s", *srcPath, err)
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)
380 if _, found := groupNameToUUID[groupName]; !found {
381 // Group doesn't exist, create and tag it before continuing
383 log.Printf("Remote group %q not found, creating...", groupName)
386 err := arv.Create("groups", arvadosclient.Dict{
387 "group": arvadosclient.Dict{
389 "owner_uuid": parentGroup.UUID,
393 return fmt.Errorf("error creating group named %q: %s",
396 link := make(map[string]interface{})
397 err = arv.Create("links", arvadosclient.Dict{
398 "link": arvadosclient.Dict{
401 "head_uuid": group.UUID,
405 return fmt.Errorf("error creating tag for group %q: %s",
408 // Update cached group data
409 groupNameToUUID[groupName] = group.UUID
410 remoteGroups[group.UUID] = &groupInfo{
412 PreviousMembers: make(map[string]bool), // Empty set
413 CurrentMembers: make(map[string]bool), // Empty set
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] {
422 log.Printf("Adding %q to group %q", groupMember, groupName)
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",
430 "tail_uuid": groupUUID,
431 "head_uuid": userIDToUUID[groupMember],
435 return fmt.Errorf("error adding user %q to group %q: %s",
436 groupMember, groupName, err)
440 gi.CurrentMembers[groupMember] = true
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)
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]},
461 return fmt.Errorf("error getting links needed to remove user %q from group %q: %s", evictedUser, groupName, err)
463 for _, item := range links {
465 var l map[string]interface{}
467 log.Printf("Removing %q from group %q", evictedUser, gi.Group.Name)
469 err := arv.Delete("links", link.UUID, arvadosclient.Dict{}, &l)
471 return fmt.Errorf("error removing user %q from group %q: %s", evictedUser, groupName, err)
477 log.Printf("Groups created: %d, members added: %d, members removed: %d", groupsCreated, membersAdded, membersRemoved)
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
489 itemsAvailable := parameters["limit"].(int)
490 for len(allItems) < itemsAvailable {
491 parameters["offset"] = offset
492 err = arv.List(resource, parameters, &rl)
496 for _, i := range rl.items() {
497 allItems = append(allItems, i)
499 offset = rl.offset() + len(rl.items())
500 itemsAvailable = rl.itemsAvailable()
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 {
509 result[element] = true