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