Merge branch 'main' into 21297-container-status
[arvados.git] / tools / sync-users / sync-users.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         "regexp"
18         "strconv"
19         "strings"
20
21         "git.arvados.org/arvados.git/lib/cmd"
22         "git.arvados.org/arvados.git/sdk/go/arvados"
23 )
24
25 var version = "dev"
26
27 type resourceList interface {
28         Len() int
29         GetItems() []interface{}
30 }
31
32 // UserList implements resourceList interface
33 type UserList struct {
34         arvados.UserList
35 }
36
37 // Len returns the amount of items this list holds
38 func (l UserList) Len() int {
39         return len(l.Items)
40 }
41
42 // GetItems returns the list of items
43 func (l UserList) GetItems() (out []interface{}) {
44         for _, item := range l.Items {
45                 out = append(out, item)
46         }
47         return
48 }
49
50 func main() {
51         cfg, err := GetConfig()
52         if err != nil {
53                 log.Fatalf("%v", err)
54         }
55
56         if err := doMain(&cfg); err != nil {
57                 log.Fatalf("%v", err)
58         }
59 }
60
61 type ConfigParams struct {
62         CaseInsensitive    bool
63         Client             *arvados.Client
64         ClusterID          string
65         CurrentUser        arvados.User
66         DeactivateUnlisted bool
67         Path               string
68         UserID             string
69         SysUserUUID        string
70         AnonUserUUID       string
71         Verbose            bool
72 }
73
74 func ParseFlags(cfg *ConfigParams) error {
75         // Acceptable attributes to identify a user on the CSV file
76         userIDOpts := map[string]bool{
77                 "email":    true, // default
78                 "username": true,
79         }
80
81         flags := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
82         flags.Usage = func() {
83                 usageStr := `Synchronize remote users into Arvados from a CSV format file with 5 columns:
84   * 1st: User Identifier (email or username)
85   * 2nd: First name
86   * 3rd: Last name
87   * 4th: Active status (0 or 1)
88   * 5th: Admin status (0 or 1)`
89                 fmt.Fprintf(flags.Output(), "%s\n\n", usageStr)
90                 fmt.Fprintf(flags.Output(), "Usage:\n%s [OPTIONS] <input-file.csv>\n\n", os.Args[0])
91                 fmt.Fprintf(flags.Output(), "Options:\n")
92                 flags.PrintDefaults()
93         }
94
95         caseInsensitive := flags.Bool(
96                 "case-insensitive",
97                 false,
98                 "Performs case insensitive matching on user IDs. Always ON when using 'email' user IDs.")
99         deactivateUnlisted := flags.Bool(
100                 "deactivate-unlisted",
101                 false,
102                 "Deactivate users that are not in the input file.")
103         userID := flags.String(
104                 "user-id",
105                 "email",
106                 "Attribute by which every user is identified. Valid values are: email and username.")
107         verbose := flags.Bool(
108                 "verbose",
109                 false,
110                 "Log informational messages.")
111         getVersion := flags.Bool(
112                 "version",
113                 false,
114                 "Print version information and exit.")
115
116         if ok, code := cmd.ParseFlags(flags, os.Args[0], os.Args[1:], "input-file.csv", os.Stderr); !ok {
117                 os.Exit(code)
118         } else if *getVersion {
119                 fmt.Printf("%s %s\n", os.Args[0], version)
120                 os.Exit(0)
121         }
122
123         // Input file as a required positional argument
124         if flags.NArg() == 0 {
125                 return fmt.Errorf("please provide a path to an input file")
126         } else if flags.NArg() > 1 {
127                 return fmt.Errorf("please provide just one input file argument")
128         }
129         srcPath := &os.Args[len(os.Args)-1]
130
131         // Validations
132         if *srcPath == "" {
133                 return fmt.Errorf("input file path invalid")
134         }
135         if !userIDOpts[*userID] {
136                 var options []string
137                 for opt := range userIDOpts {
138                         options = append(options, opt)
139                 }
140                 return fmt.Errorf("user ID must be one of: %s", strings.Join(options, ", "))
141         }
142         if *userID == "email" {
143                 // Always do case-insensitive email addresses matching
144                 *caseInsensitive = true
145         }
146
147         cfg.CaseInsensitive = *caseInsensitive
148         cfg.DeactivateUnlisted = *deactivateUnlisted
149         cfg.Path = *srcPath
150         cfg.UserID = *userID
151         cfg.Verbose = *verbose
152
153         return nil
154 }
155
156 // GetConfig sets up a ConfigParams struct
157 func GetConfig() (cfg ConfigParams, err error) {
158         err = ParseFlags(&cfg)
159         if err != nil {
160                 return
161         }
162
163         cfg.Client = arvados.NewClientFromEnv()
164
165         // Check current user permissions
166         u, err := cfg.Client.CurrentUser()
167         if err != nil {
168                 return cfg, fmt.Errorf("error getting the current user: %s", err)
169         }
170         if !u.IsAdmin {
171                 return cfg, fmt.Errorf("current user %q is not an admin user", u.UUID)
172         }
173         if cfg.Verbose {
174                 log.Printf("Running as admin user %q (%s)", u.Email, u.UUID)
175         }
176         cfg.CurrentUser = u
177
178         var ac struct {
179                 ClusterID string
180                 Login     struct {
181                         LoginCluster string
182                 }
183         }
184         err = cfg.Client.RequestAndDecode(&ac, "GET", "arvados/v1/config", nil, nil)
185         if err != nil {
186                 return cfg, fmt.Errorf("error getting the exported config: %s", err)
187         }
188         if ac.Login.LoginCluster != "" && ac.Login.LoginCluster != ac.ClusterID {
189                 return cfg, fmt.Errorf("cannot run on a cluster other than the login cluster")
190         }
191         cfg.SysUserUUID = ac.ClusterID + "-tpzed-000000000000000"
192         cfg.AnonUserUUID = ac.ClusterID + "-tpzed-anonymouspublic"
193         cfg.ClusterID = ac.ClusterID
194
195         return cfg, nil
196 }
197
198 // GetUserID returns the correct user id value depending on the selector
199 func GetUserID(u arvados.User, idSelector string) (string, error) {
200         switch idSelector {
201         case "email":
202                 return u.Email, nil
203         case "username":
204                 return u.Username, nil
205         default:
206                 return "", fmt.Errorf("cannot identify user by %q selector", idSelector)
207         }
208 }
209
210 func doMain(cfg *ConfigParams) error {
211         // Try opening the input file early, just in case there's a problem.
212         f, err := os.Open(cfg.Path)
213         if err != nil {
214                 return fmt.Errorf("error opening input file: %s", err)
215         }
216         defer f.Close()
217
218         iCaseLog := ""
219         if cfg.UserID == "username" && cfg.CaseInsensitive {
220                 iCaseLog = " - username matching requested to be case-insensitive"
221         }
222         log.Printf("%s %s started. Using %q as users id%s", os.Args[0], version, cfg.UserID, iCaseLog)
223
224         allUsers := make(map[string]arvados.User)
225         userIDToUUID := make(map[string]string) // Index by email or username
226         dupedEmails := make(map[string][]arvados.User)
227         emptyUserIDs := []string{}
228         processedUsers := make(map[string]bool)
229         results, err := GetAll(cfg.Client, "users", arvados.ResourceListParams{}, &UserList{})
230         if err != nil {
231                 return fmt.Errorf("error getting all users: %s", err)
232         }
233         log.Printf("Found %d users in cluster %q", len(results), cfg.ClusterID)
234         localUserUuidRegex := regexp.MustCompile(fmt.Sprintf("^%s-tpzed-[0-9a-z]{15}$", cfg.ClusterID))
235         for _, item := range results {
236                 u := item.(arvados.User)
237
238                 // Remote user check
239                 if !localUserUuidRegex.MatchString(u.UUID) {
240                         if cfg.Verbose {
241                                 log.Printf("Remote user %q (%s) won't be considered for processing", u.Email, u.UUID)
242                         }
243                         continue
244                 }
245
246                 // Duplicated user id check
247                 uID, err := GetUserID(u, cfg.UserID)
248                 if err != nil {
249                         return err
250                 }
251                 if uID == "" {
252                         if u.UUID != cfg.AnonUserUUID && u.UUID != cfg.SysUserUUID {
253                                 emptyUserIDs = append(emptyUserIDs, u.UUID)
254                                 log.Printf("Empty %s found in user %s - ignoring", cfg.UserID, u.UUID)
255                         }
256                         continue
257                 }
258                 if cfg.CaseInsensitive {
259                         uID = strings.ToLower(uID)
260                 }
261                 if alreadySeenUUID, found := userIDToUUID[uID]; found {
262                         if cfg.UserID == "username" && uID != "" {
263                                 return fmt.Errorf("case insensitive collision for username %q between %q and %q", uID, u.UUID, alreadySeenUUID)
264                         } else if cfg.UserID == "email" && uID != "" {
265                                 log.Printf("Duplicated email %q found in user %s - ignoring", uID, u.UUID)
266                                 if len(dupedEmails[uID]) == 0 {
267                                         dupedEmails[uID] = []arvados.User{allUsers[alreadySeenUUID]}
268                                 }
269                                 dupedEmails[uID] = append(dupedEmails[uID], u)
270                                 delete(allUsers, alreadySeenUUID) // Skip even the first occurrence,
271                                 // for security purposes.
272                                 continue
273                         }
274                 }
275                 if cfg.Verbose {
276                         log.Printf("Seen user %q (%s)", uID, u.UUID)
277                 }
278                 userIDToUUID[uID] = u.UUID
279                 allUsers[u.UUID] = u
280                 processedUsers[u.UUID] = false
281         }
282
283         loadedRecords, err := LoadInputFile(f)
284         if err != nil {
285                 return fmt.Errorf("reading input file %q: %s", cfg.Path, err)
286         }
287         log.Printf("Loaded %d records from input file", len(loadedRecords))
288
289         updatesSucceeded := map[string]bool{}
290         updatesFailed := map[string]bool{}
291         updatesSkipped := map[string]bool{}
292
293         for _, record := range loadedRecords {
294                 if cfg.CaseInsensitive {
295                         record.UserID = strings.ToLower(record.UserID)
296                 }
297                 recordUUID := userIDToUUID[record.UserID]
298                 processedUsers[recordUUID] = true
299                 if cfg.UserID == "email" && record.UserID == cfg.CurrentUser.Email {
300                         updatesSkipped[recordUUID] = true
301                         log.Printf("Skipping current user %q (%s) from processing", record.UserID, cfg.CurrentUser.UUID)
302                         continue
303                 }
304                 if updated, err := ProcessRecord(cfg, record, userIDToUUID, allUsers); err != nil {
305                         log.Printf("error processing record %q: %s", record.UserID, err)
306                         updatesFailed[recordUUID] = true
307                 } else if updated {
308                         updatesSucceeded[recordUUID] = true
309                 }
310         }
311
312         if cfg.DeactivateUnlisted {
313                 for userUUID, user := range allUsers {
314                         if shouldSkip(cfg, user) {
315                                 updatesSkipped[userUUID] = true
316                                 log.Printf("Skipping unlisted user %q (%s) from deactivating", user.Email, user.UUID)
317                                 continue
318                         }
319                         if !processedUsers[userUUID] && allUsers[userUUID].IsActive {
320                                 if cfg.Verbose {
321                                         log.Printf("Deactivating unlisted user %q (%s)", user.Username, user.UUID)
322                                 }
323                                 var updatedUser arvados.User
324                                 if err := UnsetupUser(cfg.Client, user.UUID, &updatedUser); err != nil {
325                                         log.Printf("error deactivating unlisted user %q: %s", user.UUID, err)
326                                         updatesFailed[userUUID] = true
327                                 } else {
328                                         allUsers[userUUID] = updatedUser
329                                         updatesSucceeded[userUUID] = true
330                                 }
331                         }
332                 }
333         }
334
335         log.Printf("User update successes: %d, skips: %d, failures: %d", len(updatesSucceeded), len(updatesSkipped), len(updatesFailed))
336
337         var errors []string
338         if len(dupedEmails) > 0 {
339                 emails := make([]string, len(dupedEmails))
340                 i := 0
341                 for e := range dupedEmails {
342                         emails[i] = e
343                         i++
344                 }
345                 errors = append(errors, fmt.Sprintf("skipped %d duplicated email address(es) in the cluster's local user list: %v", len(dupedEmails), emails))
346         }
347         if len(emptyUserIDs) > 0 {
348                 errors = append(errors, fmt.Sprintf("skipped %d user account(s) with empty %s: %v", len(emptyUserIDs), cfg.UserID, emptyUserIDs))
349         }
350         if len(errors) > 0 {
351                 return fmt.Errorf("%s", strings.Join(errors, "\n"))
352         }
353
354         return nil
355 }
356
357 func shouldSkip(cfg *ConfigParams, user arvados.User) bool {
358         switch user.UUID {
359         case cfg.SysUserUUID, cfg.AnonUserUUID:
360                 return true
361         case cfg.CurrentUser.UUID:
362                 return true
363         }
364         return false
365 }
366
367 type userRecord struct {
368         UserID    string
369         FirstName string
370         LastName  string
371         Active    bool
372         Admin     bool
373 }
374
375 func needsUpdating(user arvados.User, record userRecord) bool {
376         userData := userRecord{"", user.FirstName, user.LastName, user.IsActive, user.IsAdmin}
377         recordData := userRecord{"", record.FirstName, record.LastName, record.Active, record.Active && record.Admin}
378         return userData != recordData
379 }
380
381 // ProcessRecord creates or updates a user based on the given record
382 func ProcessRecord(cfg *ConfigParams, record userRecord, userIDToUUID map[string]string, allUsers map[string]arvados.User) (bool, error) {
383         if cfg.Verbose {
384                 log.Printf("Processing record for user %q", record.UserID)
385         }
386
387         wantedActiveStatus := strconv.FormatBool(record.Active)
388         wantedAdminStatus := strconv.FormatBool(record.Active && record.Admin)
389         createRequired := false
390         updateRequired := false
391         // Check if user exists, set its active & admin status.
392         var user arvados.User
393         recordUUID := userIDToUUID[record.UserID]
394         user, found := allUsers[recordUUID]
395         if !found {
396                 if cfg.Verbose {
397                         log.Printf("User %q does not exist, creating", record.UserID)
398                 }
399                 createRequired = true
400                 err := CreateUser(cfg.Client, &user, map[string]string{
401                         cfg.UserID:   record.UserID,
402                         "first_name": record.FirstName,
403                         "last_name":  record.LastName,
404                         "is_active":  wantedActiveStatus,
405                         "is_admin":   wantedAdminStatus,
406                 })
407                 if err != nil {
408                         return false, fmt.Errorf("error creating user %q: %s", record.UserID, err)
409                 }
410         } else if needsUpdating(user, record) {
411                 updateRequired = true
412                 if record.Active {
413                         if !user.IsActive && cfg.Verbose {
414                                 log.Printf("User %q (%s) is inactive, activating", record.UserID, user.UUID)
415                         }
416                         // Here we assume the 'setup' is done elsewhere if needed.
417                         err := UpdateUser(cfg.Client, user.UUID, &user, map[string]string{
418                                 "first_name": record.FirstName,
419                                 "last_name":  record.LastName,
420                                 "is_active":  wantedActiveStatus,
421                                 "is_admin":   wantedAdminStatus,
422                         })
423                         if err != nil {
424                                 return false, fmt.Errorf("error updating user %q: %s", record.UserID, err)
425                         }
426                 } else {
427                         fnChanged := user.FirstName != record.FirstName
428                         lnChanged := user.LastName != record.LastName
429                         if fnChanged || lnChanged {
430                                 err := UpdateUser(cfg.Client, user.UUID, &user, map[string]string{
431                                         "first_name": record.FirstName,
432                                         "last_name":  record.LastName,
433                                 })
434                                 if err != nil {
435                                         return false, fmt.Errorf("error updating user %q: %s", record.UserID, err)
436                                 }
437                         }
438                         if user.IsActive {
439                                 if cfg.Verbose {
440                                         log.Printf("User %q is active, deactivating", record.UserID)
441                                 }
442                                 err := UnsetupUser(cfg.Client, user.UUID, &user)
443                                 if err != nil {
444                                         return false, fmt.Errorf("error deactivating user %q: %s", record.UserID, err)
445                                 }
446                         }
447                 }
448         }
449         if createRequired {
450                 log.Printf("Created user %q", record.UserID)
451         }
452         if updateRequired {
453                 log.Printf("Updated user %q", record.UserID)
454         }
455
456         return createRequired || updateRequired, nil
457 }
458
459 // LoadInputFile reads the input file and returns a list of user records
460 func LoadInputFile(f *os.File) (loadedRecords []userRecord, err error) {
461         lineNo := 0
462         csvReader := csv.NewReader(f)
463         loadedRecords = make([]userRecord, 0)
464
465         for {
466                 record, e := csvReader.Read()
467                 if e == io.EOF {
468                         break
469                 }
470                 lineNo++
471                 if e != nil {
472                         err = fmt.Errorf("parsing error at line %d: %s", lineNo, e)
473                         return
474                 }
475                 if len(record) != 5 {
476                         err = fmt.Errorf("parsing error at line %d: expected 5 fields, found %d", lineNo, len(record))
477                         return
478                 }
479                 userID := strings.ToLower(strings.TrimSpace(record[0]))
480                 firstName := strings.TrimSpace(record[1])
481                 lastName := strings.TrimSpace(record[2])
482                 active := strings.TrimSpace(record[3])
483                 admin := strings.TrimSpace(record[4])
484                 if userID == "" || firstName == "" || lastName == "" || active == "" || admin == "" {
485                         err = fmt.Errorf("parsing error at line %d: fields cannot be empty", lineNo)
486                         return
487                 }
488                 activeBool, err := strconv.ParseBool(active)
489                 if err != nil {
490                         return nil, fmt.Errorf("parsing error at line %d: active status not recognized", lineNo)
491                 }
492                 adminBool, err := strconv.ParseBool(admin)
493                 if err != nil {
494                         return nil, fmt.Errorf("parsing error at line %d: admin status not recognized", lineNo)
495                 }
496                 loadedRecords = append(loadedRecords, userRecord{
497                         UserID:    userID,
498                         FirstName: firstName,
499                         LastName:  lastName,
500                         Active:    activeBool,
501                         Admin:     adminBool,
502                 })
503         }
504         return loadedRecords, nil
505 }
506
507 // GetAll adds all objects of type 'resource' to the 'allItems' list
508 func GetAll(c *arvados.Client, res string, params arvados.ResourceListParams, page resourceList) (allItems []interface{}, err error) {
509         // Use the maximum page size the server allows
510         limit := 1<<31 - 1
511         params.Limit = &limit
512         params.Offset = 0
513         params.Order = "uuid"
514         for {
515                 if err = GetResourceList(c, &page, res, params); err != nil {
516                         return allItems, err
517                 }
518                 // Have we finished paging?
519                 if page.Len() == 0 {
520                         break
521                 }
522                 allItems = append(allItems, page.GetItems()...)
523                 params.Offset += page.Len()
524         }
525         return allItems, nil
526 }
527
528 func jsonReader(rscName string, ob interface{}) io.Reader {
529         j, err := json.Marshal(ob)
530         if err != nil {
531                 panic(err)
532         }
533         v := url.Values{}
534         v[rscName] = []string{string(j)}
535         return bytes.NewBufferString(v.Encode())
536 }
537
538 // GetResourceList fetches res list using params
539 func GetResourceList(c *arvados.Client, dst *resourceList, res string, params interface{}) error {
540         return c.RequestAndDecode(dst, "GET", "/arvados/v1/"+res, nil, params)
541 }
542
543 // CreateUser creates a user with userData parameters, assigns it to dst
544 func CreateUser(c *arvados.Client, dst *arvados.User, userData map[string]string) error {
545         return c.RequestAndDecode(dst, "POST", "/arvados/v1/users", jsonReader("user", userData), nil)
546 }
547
548 // UpdateUser updates a user with userData parameters
549 func UpdateUser(c *arvados.Client, userUUID string, dst *arvados.User, userData map[string]string) error {
550         return c.RequestAndDecode(&dst, "PUT", "/arvados/v1/users/"+userUUID, jsonReader("user", userData), nil)
551 }
552
553 // UnsetupUser deactivates a user
554 func UnsetupUser(c *arvados.Client, userUUID string, dst *arvados.User) error {
555         return c.RequestAndDecode(&dst, "POST", "/arvados/v1/users/"+userUUID+"/unsetup", nil, nil)
556 }