25c7594560a9800f308c10e4b95da05e99306839
[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         "strconv"
18         "strings"
19
20         "git.arvados.org/arvados.git/lib/cmd"
21         "git.arvados.org/arvados.git/sdk/go/arvados"
22 )
23
24 var version = "dev"
25
26 type resourceList interface {
27         Len() int
28         GetItems() []interface{}
29 }
30
31 // UserList implements resourceList interface
32 type UserList struct {
33         arvados.UserList
34 }
35
36 // Len returns the amount of items this list holds
37 func (l UserList) Len() int {
38         return len(l.Items)
39 }
40
41 // GetItems returns the list of items
42 func (l UserList) GetItems() (out []interface{}) {
43         for _, item := range l.Items {
44                 out = append(out, item)
45         }
46         return
47 }
48
49 func main() {
50         cfg, err := GetConfig()
51         if err != nil {
52                 log.Fatalf("%v", err)
53         }
54
55         if err := doMain(&cfg); err != nil {
56                 log.Fatalf("%v", err)
57         }
58 }
59
60 type ConfigParams struct {
61         Client             *arvados.Client
62         ClusterID          string
63         CurrentUser        arvados.User
64         DeactivateUnlisted bool
65         Path               string
66         SysUserUUID        string
67         AnonUserUUID       string
68         Verbose            bool
69 }
70
71 func ParseFlags(cfg *ConfigParams) error {
72         flags := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
73         flags.Usage = func() {
74                 usageStr := `Synchronize remote users into Arvados from a CSV format file with 5 columns:
75   * 1st: E-mail address
76   * 2nd: First name
77   * 3rd: Last name
78   * 4th: Active status (0 or 1)
79   * 5th: Admin status (0 or 1)`
80                 fmt.Fprintf(flags.Output(), "%s\n\n", usageStr)
81                 fmt.Fprintf(flags.Output(), "Usage:\n%s [OPTIONS] <input-file.csv>\n\n", os.Args[0])
82                 fmt.Fprintf(flags.Output(), "Options:\n")
83                 flags.PrintDefaults()
84         }
85
86         deactivateUnlisted := flags.Bool(
87                 "deactivate-unlisted",
88                 false,
89                 "Deactivate users that are not in the input file.")
90         verbose := flags.Bool(
91                 "verbose",
92                 false,
93                 "Log informational messages. Off by default.")
94         getVersion := flags.Bool(
95                 "version",
96                 false,
97                 "Print version information and exit.")
98
99         if ok, code := cmd.ParseFlags(flags, os.Args[0], os.Args[1:], "input-file.csv", os.Stderr); !ok {
100                 os.Exit(code)
101         } else if *getVersion {
102                 fmt.Printf("%s %s\n", os.Args[0], version)
103                 os.Exit(0)
104         }
105
106         // Input file as a required positional argument
107         if flags.NArg() == 0 {
108                 return fmt.Errorf("please provide a path to an input file")
109         }
110         srcPath := &os.Args[flags.NFlag()+1]
111
112         // Validations
113         if *srcPath == "" {
114                 return fmt.Errorf("input file path invalid")
115         }
116
117         cfg.DeactivateUnlisted = *deactivateUnlisted
118         cfg.Path = *srcPath
119         cfg.Verbose = *verbose
120
121         return nil
122 }
123
124 // GetConfig sets up a ConfigParams struct
125 func GetConfig() (cfg ConfigParams, err error) {
126         err = ParseFlags(&cfg)
127         if err != nil {
128                 return
129         }
130
131         cfg.Client = arvados.NewClientFromEnv()
132
133         // Check current user permissions
134         u, err := cfg.Client.CurrentUser()
135         if err != nil {
136                 return cfg, fmt.Errorf("error getting the current user: %s", err)
137         }
138         if !u.IsAdmin {
139                 return cfg, fmt.Errorf("current user %q is not an admin user", u.UUID)
140         }
141         if cfg.Verbose {
142                 log.Printf("Running as admin user %q (%s)", u.Email, u.UUID)
143         }
144         cfg.CurrentUser = u
145
146         var ac struct {
147                 ClusterID string
148                 Login     struct {
149                         LoginCluster string
150                 }
151         }
152         err = cfg.Client.RequestAndDecode(&ac, "GET", "arvados/v1/config", nil, nil)
153         if err != nil {
154                 return cfg, fmt.Errorf("error getting the exported config: %s", err)
155         }
156         if ac.Login.LoginCluster != "" && ac.Login.LoginCluster != ac.ClusterID {
157                 return cfg, fmt.Errorf("cannot run on a cluster other than the login cluster")
158         }
159         cfg.SysUserUUID = ac.ClusterID + "-tpzed-000000000000000"
160         cfg.AnonUserUUID = ac.ClusterID + "-tpzed-anonymouspublic"
161         cfg.ClusterID = ac.ClusterID
162
163         return cfg, nil
164 }
165
166 func doMain(cfg *ConfigParams) error {
167         // Try opening the input file early, just in case there's a problem.
168         f, err := os.Open(cfg.Path)
169         if err != nil {
170                 return fmt.Errorf("error opening input file: %s", err)
171         }
172         defer f.Close()
173
174         allUsers := make(map[string]arvados.User)
175         processedUsers := make(map[string]bool)
176         results, err := GetAll(cfg.Client, "users", arvados.ResourceListParams{}, &UserList{})
177         if err != nil {
178                 return fmt.Errorf("error getting all users: %s", err)
179         }
180         log.Printf("Found %d users in cluster %q", len(results), cfg.ClusterID)
181         for _, item := range results {
182                 u := item.(arvados.User)
183                 allUsers[strings.ToLower(u.Email)] = u
184                 processedUsers[strings.ToLower(u.Email)] = false
185         }
186
187         loadedRecords, err := LoadInputFile(f)
188         if err != nil {
189                 return fmt.Errorf("reading input file %q: %s", cfg.Path, err)
190         }
191         log.Printf("Loaded %d records from input file", len(loadedRecords))
192
193         updatesSucceeded := map[string]bool{}
194         updatesFailed := map[string]bool{}
195         updatesSkipped := map[string]bool{}
196
197         for _, record := range loadedRecords {
198                 processedUsers[record.Email] = true
199                 if record.Email == cfg.CurrentUser.Email {
200                         updatesSkipped[record.Email] = true
201                         log.Printf("Skipping current user %q (%s) from processing", record.Email, cfg.CurrentUser.UUID)
202                         continue
203                 }
204                 if updated, err := ProcessRecord(cfg, record, allUsers); err != nil {
205                         log.Printf("error processing record %q: %s", record.Email, err)
206                         updatesFailed[record.Email] = true
207                 } else if updated {
208                         updatesSucceeded[record.Email] = true
209                 }
210         }
211
212         if cfg.DeactivateUnlisted {
213                 for email, user := range allUsers {
214                         if shouldSkip(cfg, user) {
215                                 updatesSkipped[email] = true
216                                 log.Printf("Skipping unlisted user %q (%s) from deactivating", user.Email, user.UUID)
217                                 continue
218                         }
219                         if !processedUsers[email] && allUsers[email].IsActive {
220                                 if cfg.Verbose {
221                                         log.Printf("Deactivating unlisted user %q (%s)", user.Email, user.UUID)
222                                 }
223                                 var updatedUser arvados.User
224                                 if err := UnsetupUser(cfg.Client, user.UUID, &updatedUser); err != nil {
225                                         log.Printf("error deactivating unlisted user %q: %s", user.UUID, err)
226                                         updatesFailed[email] = true
227                                 } else {
228                                         allUsers[email] = updatedUser
229                                         updatesSucceeded[email] = true
230                                 }
231                         }
232                 }
233         }
234
235         log.Printf("User update successes: %d, skips: %d, failures: %d", len(updatesSucceeded), len(updatesSkipped), len(updatesFailed))
236
237         return nil
238 }
239
240 func shouldSkip(cfg *ConfigParams, user arvados.User) bool {
241         switch user.UUID {
242         case cfg.SysUserUUID, cfg.AnonUserUUID:
243                 return true
244         case cfg.CurrentUser.UUID:
245                 return true
246         }
247         return false
248 }
249
250 type userRecord struct {
251         Email     string
252         FirstName string
253         LastName  string
254         Active    bool
255         Admin     bool
256 }
257
258 // ProcessRecord creates or updates a user based on the given record
259 func ProcessRecord(cfg *ConfigParams, record userRecord, allUsers map[string]arvados.User) (bool, error) {
260         if cfg.Verbose {
261                 log.Printf("Processing record for user %q", record.Email)
262         }
263
264         wantedActiveStatus := strconv.FormatBool(record.Active)
265         wantedAdminStatus := strconv.FormatBool(record.Admin)
266         createRequired := false
267         updateRequired := false
268         // Check if user exists, set its active & admin status.
269         var user arvados.User
270         user, ok := allUsers[record.Email]
271         if !ok {
272                 if cfg.Verbose {
273                         log.Printf("User %q does not exist, creating", record.Email)
274                 }
275                 createRequired = true
276                 err := CreateUser(cfg.Client, &user, map[string]string{
277                         "email":      record.Email,
278                         "first_name": record.FirstName,
279                         "last_name":  record.LastName,
280                         "is_active":  wantedActiveStatus,
281                         "is_admin":   wantedAdminStatus,
282                 })
283                 if err != nil {
284                         return false, fmt.Errorf("error creating user %q: %s", record.Email, err)
285                 }
286         }
287         if record.Active != user.IsActive {
288                 updateRequired = true
289                 if record.Active {
290                         if cfg.Verbose {
291                                 log.Printf("User %q is inactive, activating", record.Email)
292                         }
293                         // Here we assume the 'setup' is done elsewhere if needed.
294                         err := UpdateUser(cfg.Client, user.UUID, &user, map[string]string{
295                                 "is_active": wantedActiveStatus,
296                                 "is_admin":  wantedAdminStatus, // Just in case it needs to be changed.
297                         })
298                         if err != nil {
299                                 return false, fmt.Errorf("error updating user %q: %s", record.Email, err)
300                         }
301                 } else {
302                         if cfg.Verbose {
303                                 log.Printf("User %q is active, deactivating", record.Email)
304                         }
305                         err := UnsetupUser(cfg.Client, user.UUID, &user)
306                         if err != nil {
307                                 return false, fmt.Errorf("error deactivating user %q: %s", record.Email, err)
308                         }
309                 }
310         }
311         // Inactive users cannot be admins.
312         if user.IsActive && record.Admin != user.IsAdmin {
313                 if cfg.Verbose {
314                         log.Printf("User %q is active, changing admin status to %v", record.Email, record.Admin)
315                 }
316                 updateRequired = true
317                 err := UpdateUser(cfg.Client, user.UUID, &user, map[string]string{
318                         "is_admin": wantedAdminStatus,
319                 })
320                 if err != nil {
321                         return false, fmt.Errorf("error updating user %q: %s", record.Email, err)
322                 }
323         }
324         allUsers[record.Email] = user
325         if createRequired {
326                 log.Printf("Created user %q", record.Email)
327         }
328         if updateRequired {
329                 log.Printf("Updated user %q", record.Email)
330         }
331
332         return createRequired || updateRequired, nil
333 }
334
335 // LoadInputFile reads the input file and returns a list of user records
336 func LoadInputFile(f *os.File) (loadedRecords []userRecord, err error) {
337         lineNo := 0
338         csvReader := csv.NewReader(f)
339         loadedRecords = make([]userRecord, 0)
340
341         for {
342                 record, e := csvReader.Read()
343                 if e == io.EOF {
344                         break
345                 }
346                 lineNo++
347                 if e != nil {
348                         err = fmt.Errorf("parsing error at line %d: %s", lineNo, e)
349                         return
350                 }
351                 if len(record) != 5 {
352                         err = fmt.Errorf("parsing error at line %d: expected 5 fields, found %d", lineNo, len(record))
353                         return
354                 }
355                 email := strings.ToLower(strings.TrimSpace(record[0]))
356                 firstName := strings.TrimSpace(record[1])
357                 lastName := strings.TrimSpace(record[2])
358                 active := strings.TrimSpace(record[3])
359                 admin := strings.TrimSpace(record[4])
360                 if email == "" || firstName == "" || lastName == "" || active == "" || admin == "" {
361                         err = fmt.Errorf("parsing error at line %d: fields cannot be empty", lineNo)
362                         return
363                 }
364                 activeBool, err := strconv.ParseBool(active)
365                 if err != nil {
366                         return nil, fmt.Errorf("parsing error at line %d: active status not recognized", lineNo)
367                 }
368                 adminBool, err := strconv.ParseBool(admin)
369                 if err != nil {
370                         return nil, fmt.Errorf("parsing error at line %d: admin status not recognized", lineNo)
371                 }
372                 loadedRecords = append(loadedRecords, userRecord{
373                         Email:     email,
374                         FirstName: firstName,
375                         LastName:  lastName,
376                         Active:    activeBool,
377                         Admin:     adminBool,
378                 })
379         }
380         return loadedRecords, nil
381 }
382
383 // GetAll adds all objects of type 'resource' to the 'allItems' list
384 func GetAll(c *arvados.Client, res string, params arvados.ResourceListParams, page resourceList) (allItems []interface{}, err error) {
385         // Use the maximum page size the server allows
386         limit := 1<<31 - 1
387         params.Limit = &limit
388         params.Offset = 0
389         params.Order = "uuid"
390         for {
391                 if err = GetResourceList(c, &page, res, params); err != nil {
392                         return allItems, err
393                 }
394                 // Have we finished paging?
395                 if page.Len() == 0 {
396                         break
397                 }
398                 allItems = append(allItems, page.GetItems()...)
399                 params.Offset += page.Len()
400         }
401         return allItems, nil
402 }
403
404 func jsonReader(rscName string, ob interface{}) io.Reader {
405         j, err := json.Marshal(ob)
406         if err != nil {
407                 panic(err)
408         }
409         v := url.Values{}
410         v[rscName] = []string{string(j)}
411         return bytes.NewBufferString(v.Encode())
412 }
413
414 // GetResourceList fetches res list using params
415 func GetResourceList(c *arvados.Client, dst *resourceList, res string, params interface{}) error {
416         return c.RequestAndDecode(dst, "GET", "/arvados/v1/"+res, nil, params)
417 }
418
419 // CreateUser creates a user with userData parameters, assigns it to dst
420 func CreateUser(c *arvados.Client, dst *arvados.User, userData map[string]string) error {
421         return c.RequestAndDecode(dst, "POST", "/arvados/v1/users", jsonReader("user", userData), nil)
422 }
423
424 // UpdateUser updates a user with userData parameters
425 func UpdateUser(c *arvados.Client, userUUID string, dst *arvados.User, userData map[string]string) error {
426         return c.RequestAndDecode(&dst, "PUT", "/arvados/v1/users/"+userUUID, jsonReader("user", userData), nil)
427 }
428
429 // UnsetupUser deactivates a user
430 func UnsetupUser(c *arvados.Client, userUUID string, dst *arvados.User) error {
431         return c.RequestAndDecode(&dst, "POST", "/arvados/v1/users/"+userUUID+"/unsetup", nil, nil)
432 }