diff --git a/cmd/src/users.go b/cmd/src/users.go index c189ae1b45..38d4cfdc00 100644 --- a/cmd/src/users.go +++ b/cmd/src/users.go @@ -20,6 +20,7 @@ The commands are: get gets a user create creates a user account delete deletes a user account + clean deletes inactive users tag add/remove a tag on a user Use "src users [command] -h" for more information about a command. @@ -57,7 +58,11 @@ fragment UserFields on User { } emails { email - verified + verified + } + usageStatistics { + lastActiveTime + lastActiveCodeHostIntegrationTime } url } @@ -71,11 +76,17 @@ type User struct { Organizations struct { Nodes []Org } - Emails []UserEmail - URL string + Emails []UserEmail + UsageStatistics UserUsageStatistics + URL string } type UserEmail struct { Email string Verified bool } + +type UserUsageStatistics struct { + LastActiveTime string + LastActiveCodeHostIntegrationTime string +} diff --git a/cmd/src/users_clean.go b/cmd/src/users_clean.go new file mode 100644 index 0000000000..adcd5ddbd7 --- /dev/null +++ b/cmd/src/users_clean.go @@ -0,0 +1,212 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/jedib0t/go-pretty/v6/table" + + "github.com/sourcegraph/src-cli/internal/api" +) + +func init() { + usage := ` +This command removes users from a Sourcegraph instance who have been inactive for 60 or more days. Admin accounts are omitted by default. + +Examples: + + $ src users clean -days 182 + + $ src users clean -remove-admin -remove-never-active +` + + flagSet := flag.NewFlagSet("clean", flag.ExitOnError) + usageFunc := func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src users %s':\n", flagSet.Name()) + flagSet.PrintDefaults() + fmt.Println(usage) + } + var ( + daysToDelete = flagSet.Int("days", 60, "Days threshold on which to remove users, must be 60 days or greater and defaults to this value ") + removeAdmin = flagSet.Bool("remove-admin", false, "clean admin accounts") + removeNoLastActive = flagSet.Bool("remove-never-active", false, "removes users with null lastActive value") + skipConfirmation = flagSet.Bool("force", false, "skips user confirmation step allowing programmatic use") + apiFlags = api.NewFlags(flagSet) + ) + + handler := func(args []string) error { + if err := flagSet.Parse(args); err != nil { + return err + } + if *daysToDelete < 60 { + fmt.Println("-days flag must be set to 60 or greater") + return nil + } + + ctx := context.Background() + client := cfg.apiClient(apiFlags, flagSet.Output()) + + currentUserQuery := ` +query { + currentUser { + username + } +} +` + var currentUserResult struct { + Data struct { + CurrentUser struct { + Username string + } + } + } + if ok, err := cfg.apiClient(apiFlags, flagSet.Output()).NewRequest(currentUserQuery, nil).DoRaw(context.Background(), ¤tUserResult); err != nil || !ok { + return err + } + fmt.Println(currentUserResult) + + usersQuery := ` +query Users() { + users() { + nodes { + ...UserFields + } + } +} +` + userFragment + + // get users to delete + var usersResult struct { + Users struct { + Nodes []User + } + } + if ok, err := client.NewRequest(usersQuery, nil).Do(ctx, &usersResult); err != nil || !ok { + return err + } + fmt.Println(usersResult) + + usersToDelete := make([]UserToDelete, 0) + for _, user := range usersResult.Users.Nodes { + daysSinceLastUse, wasLastActive, err := computeDaysSinceLastUse(user) + if err != nil { + return err + } + // never remove user issuing command + if user.Username == currentUserResult.Data.CurrentUser.Username { + continue + } + if !wasLastActive && !*removeNoLastActive { + continue + } + if !*removeAdmin && user.SiteAdmin { + continue + } + if daysSinceLastUse <= *daysToDelete && wasLastActive { + continue + } + deleteUser := UserToDelete{user, daysSinceLastUse} + + usersToDelete = append(usersToDelete, deleteUser) + } + + if *skipConfirmation { + for _, user := range usersToDelete { + if err := removeUser(user.User, client, ctx); err != nil { + return err + } + } + return nil + } + + // confirm and remove users + if confirmed, _ := confirmUserRemoval(usersToDelete); !confirmed { + fmt.Println("Aborting removal") + return nil + } else { + fmt.Println("REMOVING USERS") + for _, user := range usersToDelete { + if err := removeUser(user.User, client, ctx); err != nil { + return err + } + } + } + + return nil + } + + // Register the command. + usersCommands = append(usersCommands, &command{ + flagSet: flagSet, + handler: handler, + usageFunc: usageFunc, + }) +} + +// computes days since last usage from current day and time and UsageStatistics.LastActiveTime, uses time.Parse +func computeDaysSinceLastUse(user User) (timeDiff int, wasLastActive bool, _ error) { + // handle for null lastActiveTime returned from + if user.UsageStatistics.LastActiveTime == "" { + wasLastActive = false + return 0, wasLastActive, nil + } + timeLast, err := time.Parse(time.RFC3339, user.UsageStatistics.LastActiveTime) + if err != nil { + return 0, false, err + } + timeDiff = int(time.Since(timeLast).Hours() / 24) + + return timeDiff, true, err +} + +// Issue graphQL api request to remove user +func removeUser(user User, client api.Client, ctx context.Context) error { + query := `mutation DeleteUser($user: ID!) { + deleteUser(user: $user) { + alwaysNil + } +}` + vars := map[string]interface{}{ + "user": user.ID, + } + if ok, err := client.NewRequest(query, vars).Do(ctx, nil); err != nil || !ok { + return err + } + return nil +} + +type UserToDelete struct { + User User + DaysSinceLastUse int +} + +// Verify user wants to remove users with table of users and a command prompt for [y/N] +func confirmUserRemoval(usersToRemove []UserToDelete) (bool, error) { + fmt.Printf("Users to remove from instance at %s\n", cfg.Endpoint) + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Username", "Email", "Days Since Last Active"}) + for _, user := range usersToRemove { + if len(user.User.Emails) > 0 { + t.AppendRow([]interface{}{user.User.Username, user.User.Emails[0].Email, user.DaysSinceLastUse}) + t.AppendSeparator() + } else { + t.AppendRow([]interface{}{user.User.Username, "", user.DaysSinceLastUse}) + t.AppendSeparator() + } + } + t.SetStyle(table.StyleRounded) + t.Render() + input := "" + for strings.ToLower(input) != "y" && strings.ToLower(input) != "n" { + fmt.Printf("Do you wish to proceed with user removal [y/N]: ") + if _, err := fmt.Scanln(&input); err != nil { + return false, err + } + } + return strings.ToLower(input) == "y", nil +} diff --git a/go.mod b/go.mod index 4688c68922..923bcc71fb 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/google/go-cmp v0.5.8 github.com/grafana/regexp v0.0.0-20220304100321-149c8afcd6cb github.com/hexops/autogold v1.3.0 + github.com/jedib0t/go-pretty/v6 v6.3.7 github.com/jig/teereadcloser v0.0.0-20181016160506-953720c48e05 github.com/json-iterator/go v1.1.12 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 diff --git a/go.sum b/go.sum index 547852d34c..25ba1fefe8 100644 --- a/go.sum +++ b/go.sum @@ -198,6 +198,8 @@ github.com/iris-contrib/pongo2 v0.0.1/go.mod h1:Ssh+00+3GAZqSQb30AvBRNxBx7rf0Gqw github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw= github.com/jdxcode/netrc v0.0.0-20210204082910-926c7f70242a h1:d4+I1YEKVmWZrgkt6jpXBnLgV2ZjO0YxEtLDdfIZfH4= github.com/jdxcode/netrc v0.0.0-20210204082910-926c7f70242a/go.mod h1:Zi/ZFkEqFHTm7qkjyNJjaWH4LQA9LQhGJyF0lTYGpxw= +github.com/jedib0t/go-pretty/v6 v6.3.7 h1:H3Ulkf7h6A+p0HgKBGzgDn0bZIupRbKKWF4pO4Bs7iA= +github.com/jedib0t/go-pretty/v6 v6.3.7/go.mod h1:MgmISkTWDSFu0xOqiZ0mKNntMQ2mDgOcwOkwBEkMDJI= github.com/jhump/gopoet v0.0.0-20190322174617-17282ff210b3/go.mod h1:me9yfT6IJSlOL3FCfrg+L6yzUEZ+5jW6WHt4Sk+UPUI= github.com/jhump/gopoet v0.1.0/go.mod h1:me9yfT6IJSlOL3FCfrg+L6yzUEZ+5jW6WHt4Sk+UPUI= github.com/jhump/goprotoc v0.5.0/go.mod h1:VrbvcYrQOrTi3i0Vf+m+oqQWk9l72mjkJCYo7UvLHRQ= @@ -396,6 +398,7 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=