mirror of
https://github.com/gravitational/teleport.git
synced 2026-11-02 06:08:23 +08:00
* Move configuration from lib/service to lib/service/servicecfg The new servicecfg package will hold only configuration for services. This will allow other packages (like tctl and tsh) to depend on servicecfg without pulling in all of lib/service (which has a number of platform-specific details). This is the first step towards being able to build tctl for Windows. * Move PAM and BPF config into servicecfg This breaks a compile-time dependency on BPF/PAM for tctl.
468 lines
16 KiB
Go
468 lines
16 KiB
Go
/*
|
|
Copyright 2015-2021 Gravitational, Inc.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package common
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gravitational/kingpin"
|
|
"github.com/gravitational/trace"
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"github.com/gravitational/teleport"
|
|
"github.com/gravitational/teleport/api/constants"
|
|
"github.com/gravitational/teleport/api/types"
|
|
"github.com/gravitational/teleport/lib/asciitable"
|
|
"github.com/gravitational/teleport/lib/auth"
|
|
"github.com/gravitational/teleport/lib/defaults"
|
|
"github.com/gravitational/teleport/lib/service/servicecfg"
|
|
"github.com/gravitational/teleport/lib/services"
|
|
"github.com/gravitational/teleport/lib/utils/gcp"
|
|
)
|
|
|
|
// UserCommand implements `tctl users` set of commands
|
|
// It implements CLICommand interface
|
|
type UserCommand struct {
|
|
config *servicecfg.Config
|
|
login string
|
|
allowedLogins []string
|
|
allowedWindowsLogins []string
|
|
allowedKubeUsers []string
|
|
allowedKubeGroups []string
|
|
allowedDatabaseUsers []string
|
|
allowedDatabaseNames []string
|
|
allowedAWSRoleARNs []string
|
|
allowedAzureIdentities []string
|
|
allowedGCPServiceAccounts []string
|
|
allowedRoles []string
|
|
|
|
ttl time.Duration
|
|
|
|
// format is the output format, e.g. text or json
|
|
format string
|
|
|
|
userAdd *kingpin.CmdClause
|
|
userUpdate *kingpin.CmdClause
|
|
userList *kingpin.CmdClause
|
|
userDelete *kingpin.CmdClause
|
|
userResetPassword *kingpin.CmdClause
|
|
}
|
|
|
|
// Initialize allows UserCommand to plug itself into the CLI parser
|
|
func (u *UserCommand) Initialize(app *kingpin.Application, config *servicecfg.Config) {
|
|
const helpPrefix string = "[Teleport DB users only]"
|
|
|
|
u.config = config
|
|
users := app.Command("users", "Manage user accounts")
|
|
|
|
u.userAdd = users.Command("add", "Generate a user invitation token "+helpPrefix)
|
|
u.userAdd.Arg("account", "Teleport user account name").Required().StringVar(&u.login)
|
|
|
|
u.userAdd.Flag("logins", "List of allowed SSH logins for the new user").StringsVar(&u.allowedLogins)
|
|
u.userAdd.Flag("windows-logins", "List of allowed Windows logins for the new user").StringsVar(&u.allowedWindowsLogins)
|
|
u.userAdd.Flag("kubernetes-users", "List of allowed Kubernetes users for the new user").StringsVar(&u.allowedKubeUsers)
|
|
u.userAdd.Flag("kubernetes-groups", "List of allowed Kubernetes groups for the new user").StringsVar(&u.allowedKubeGroups)
|
|
u.userAdd.Flag("db-users", "List of allowed database users for the new user").StringsVar(&u.allowedDatabaseUsers)
|
|
u.userAdd.Flag("db-names", "List of allowed database names for the new user").StringsVar(&u.allowedDatabaseNames)
|
|
u.userAdd.Flag("aws-role-arns", "List of allowed AWS role ARNs for the new user").StringsVar(&u.allowedAWSRoleARNs)
|
|
u.userAdd.Flag("azure-identities", "List of allowed Azure identities for the new user").StringsVar(&u.allowedAzureIdentities)
|
|
u.userAdd.Flag("gcp-service-accounts", "List of allowed GCP service accounts for the new user").StringsVar(&u.allowedGCPServiceAccounts)
|
|
|
|
u.userAdd.Flag("roles", "List of roles for the new user to assume").Required().StringsVar(&u.allowedRoles)
|
|
|
|
u.userAdd.Flag("ttl", fmt.Sprintf("Set expiration time for token, default is %v, maximum is %v",
|
|
defaults.SignupTokenTTL, defaults.MaxSignupTokenTTL)).
|
|
Default(fmt.Sprintf("%v", defaults.SignupTokenTTL)).DurationVar(&u.ttl)
|
|
u.userAdd.Flag("format", "Output format, 'text' or 'json'").Hidden().Default(teleport.Text).StringVar(&u.format)
|
|
u.userAdd.Alias(AddUserHelp)
|
|
|
|
u.userUpdate = users.Command("update", "Update user account")
|
|
u.userUpdate.Arg("account", "Teleport user account name").Required().StringVar(&u.login)
|
|
u.userUpdate.Flag("set-roles", "List of roles for the user to assume, replaces current roles").
|
|
StringsVar(&u.allowedRoles)
|
|
u.userUpdate.Flag("set-logins", "List of allowed SSH logins for the user, replaces current logins").
|
|
StringsVar(&u.allowedLogins)
|
|
u.userUpdate.Flag("set-windows-logins", "List of allowed Windows logins for the user, replaces current Windows logins").
|
|
StringsVar(&u.allowedWindowsLogins)
|
|
u.userUpdate.Flag("set-kubernetes-users", "List of allowed Kubernetes users for the user, replaces current Kubernetes users").
|
|
StringsVar(&u.allowedKubeUsers)
|
|
u.userUpdate.Flag("set-kubernetes-groups", "List of allowed Kubernetes groups for the user, replaces current Kubernetes groups").
|
|
StringsVar(&u.allowedKubeGroups)
|
|
u.userUpdate.Flag("set-db-users", "List of allowed database users for the user, replaces current database users").
|
|
StringsVar(&u.allowedDatabaseUsers)
|
|
u.userUpdate.Flag("set-db-names", "List of allowed database names for the user, replaces current database names").
|
|
StringsVar(&u.allowedDatabaseNames)
|
|
u.userUpdate.Flag("set-aws-role-arns", "List of allowed AWS role ARNs for the user, replaces current AWS role ARNs").
|
|
StringsVar(&u.allowedAWSRoleARNs)
|
|
u.userUpdate.Flag("set-azure-identities", "List of allowed Azure identities for the user, replaces current Azure identities").
|
|
StringsVar(&u.allowedAzureIdentities)
|
|
u.userUpdate.Flag("set-gcp-service-accounts", "List of allowed GCP service accounts for the user, replaces current service accounts").
|
|
StringsVar(&u.allowedGCPServiceAccounts)
|
|
|
|
u.userList = users.Command("ls", "Lists all user accounts.")
|
|
u.userList.Flag("format", "Output format, 'text' or 'json'").Hidden().Default(teleport.Text).StringVar(&u.format)
|
|
|
|
u.userDelete = users.Command("rm", "Deletes user accounts").Alias("del")
|
|
u.userDelete.Arg("logins", "Comma-separated list of user logins to delete").
|
|
Required().StringVar(&u.login)
|
|
|
|
u.userResetPassword = users.Command("reset", "Reset user password and generate a new token "+helpPrefix)
|
|
u.userResetPassword.Arg("account", "Teleport user account name").Required().StringVar(&u.login)
|
|
u.userResetPassword.Flag("ttl", fmt.Sprintf("Set expiration time for token, default is %v, maximum is %v",
|
|
defaults.ChangePasswordTokenTTL, defaults.MaxChangePasswordTokenTTL)).
|
|
Default(fmt.Sprintf("%v", defaults.ChangePasswordTokenTTL)).DurationVar(&u.ttl)
|
|
u.userResetPassword.Flag("format", "Output format, 'text' or 'json'").Hidden().Default(teleport.Text).StringVar(&u.format)
|
|
}
|
|
|
|
// TryRun takes the CLI command as an argument (like "users add") and executes it.
|
|
func (u *UserCommand) TryRun(ctx context.Context, cmd string, client auth.ClientI) (match bool, err error) {
|
|
switch cmd {
|
|
case u.userAdd.FullCommand():
|
|
err = u.Add(ctx, client)
|
|
case u.userUpdate.FullCommand():
|
|
err = u.Update(ctx, client)
|
|
case u.userList.FullCommand():
|
|
err = u.List(ctx, client)
|
|
case u.userDelete.FullCommand():
|
|
err = u.Delete(ctx, client)
|
|
case u.userResetPassword.FullCommand():
|
|
err = u.ResetPassword(ctx, client)
|
|
default:
|
|
return false, nil
|
|
}
|
|
return true, trace.Wrap(err)
|
|
}
|
|
|
|
// ResetPassword resets user password and generates a token to setup new password
|
|
func (u *UserCommand) ResetPassword(ctx context.Context, client auth.ClientI) error {
|
|
req := auth.CreateUserTokenRequest{
|
|
Name: u.login,
|
|
TTL: u.ttl,
|
|
Type: auth.UserTokenTypeResetPassword,
|
|
}
|
|
token, err := client.CreateResetPasswordToken(ctx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = u.PrintResetPasswordToken(token, u.format)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// PrintResetPasswordToken prints ResetPasswordToken
|
|
func (u *UserCommand) PrintResetPasswordToken(token types.UserToken, format string) error {
|
|
err := u.printResetPasswordToken(token,
|
|
format,
|
|
"User %q has been reset. Share this URL with the user to complete password reset, link is valid for %v:\n%v\n\n",
|
|
)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// PrintResetPasswordTokenAsInvite prints ResetPasswordToken as Invite
|
|
func (u *UserCommand) PrintResetPasswordTokenAsInvite(token types.UserToken, format string) error {
|
|
err := u.printResetPasswordToken(token,
|
|
format,
|
|
"User %q has been created but requires a password. Share this URL with the user to complete user setup, link is valid for %v:\n%v\n\n")
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// PrintResetPasswordToken prints ResetPasswordToken
|
|
func (u *UserCommand) printResetPasswordToken(token types.UserToken, format string, messageFormat string) (err error) {
|
|
switch strings.ToLower(u.format) {
|
|
case teleport.JSON:
|
|
err = printTokenAsJSON(token)
|
|
case teleport.Text:
|
|
err = printTokenAsText(token, messageFormat)
|
|
default:
|
|
err = printTokenAsText(token, messageFormat)
|
|
}
|
|
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Add implements `tctl users add` for the enterprise edition. Unlike the OSS
|
|
// version, this one requires --roles flag to be set
|
|
func (u *UserCommand) Add(ctx context.Context, client auth.ClientI) error {
|
|
u.allowedRoles = flattenSlice(u.allowedRoles)
|
|
u.allowedLogins = flattenSlice(u.allowedLogins)
|
|
u.allowedWindowsLogins = flattenSlice(u.allowedWindowsLogins)
|
|
|
|
// Validate roles (server does not do this yet).
|
|
for _, roleName := range u.allowedRoles {
|
|
if _, err := client.GetRole(ctx, roleName); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
azureIdentities := flattenSlice(u.allowedAzureIdentities)
|
|
for _, identity := range azureIdentities {
|
|
if !services.MatchValidAzureIdentity(identity) {
|
|
return trace.BadParameter("Azure identity %q has invalid format.", identity)
|
|
}
|
|
if identity == types.Wildcard {
|
|
return trace.BadParameter("Azure identity cannot be a wildcard.")
|
|
}
|
|
}
|
|
|
|
gcpServiceAccounts := flattenSlice(u.allowedGCPServiceAccounts)
|
|
for _, account := range gcpServiceAccounts {
|
|
if err := gcp.ValidateGCPServiceAccountName(account); err != nil {
|
|
return trace.Wrap(err, "GCP service account %q is invalid", account)
|
|
}
|
|
}
|
|
|
|
traits := map[string][]string{
|
|
constants.TraitLogins: u.allowedLogins,
|
|
constants.TraitWindowsLogins: u.allowedWindowsLogins,
|
|
constants.TraitKubeUsers: flattenSlice(u.allowedKubeUsers),
|
|
constants.TraitKubeGroups: flattenSlice(u.allowedKubeGroups),
|
|
constants.TraitDBUsers: flattenSlice(u.allowedDatabaseUsers),
|
|
constants.TraitDBNames: flattenSlice(u.allowedDatabaseNames),
|
|
constants.TraitAWSRoleARNs: flattenSlice(u.allowedAWSRoleARNs),
|
|
constants.TraitAzureIdentities: azureIdentities,
|
|
constants.TraitGCPServiceAccounts: gcpServiceAccounts,
|
|
}
|
|
|
|
user, err := types.NewUser(u.login)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
user.SetTraits(traits)
|
|
user.SetRoles(u.allowedRoles)
|
|
|
|
if err := client.CreateUser(ctx, user); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
token, err := client.CreateResetPasswordToken(ctx, auth.CreateUserTokenRequest{
|
|
Name: u.login,
|
|
TTL: u.ttl,
|
|
Type: auth.UserTokenTypeResetPasswordInvite,
|
|
})
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
if err := u.PrintResetPasswordTokenAsInvite(token, u.format); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// flattenSlice takes a slice of strings like ["one,two", "three"] and returns
|
|
// ["one", "two", "three"]
|
|
func flattenSlice(slice []string) (retval []string) {
|
|
for i := range slice {
|
|
for _, role := range strings.Split(slice[i], ",") {
|
|
retval = append(retval, strings.TrimSpace(role))
|
|
}
|
|
}
|
|
return retval
|
|
}
|
|
|
|
func printTokenAsJSON(token types.UserToken) error {
|
|
out, err := json.MarshalIndent(token, "", " ")
|
|
if err != nil {
|
|
return trace.Wrap(err, "failed to marshal reset password token")
|
|
}
|
|
fmt.Print(string(out))
|
|
return nil
|
|
}
|
|
|
|
func printTokenAsText(token types.UserToken, messageFormat string) error {
|
|
url, err := url.Parse(token.GetURL())
|
|
if err != nil {
|
|
return trace.Wrap(err, "failed to parse reset password token url")
|
|
}
|
|
|
|
ttl := trimDurationZeroSuffix(token.Expiry().Sub(time.Now().UTC()))
|
|
fmt.Printf(messageFormat, token.GetUser(), ttl, url)
|
|
fmt.Printf("NOTE: Make sure %v points at a Teleport proxy which users can access.\n", url.Host)
|
|
return nil
|
|
}
|
|
|
|
// Update updates existing user
|
|
func (u *UserCommand) Update(ctx context.Context, client auth.ClientI) error {
|
|
user, err := client.GetUser(u.login, false)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
updateMessages := make(map[string][]string)
|
|
if len(u.allowedRoles) > 0 {
|
|
roles := flattenSlice(u.allowedRoles)
|
|
for _, role := range roles {
|
|
if _, err := client.GetRole(ctx, role); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
user.SetRoles(roles)
|
|
updateMessages["roles"] = roles
|
|
}
|
|
if len(u.allowedLogins) > 0 {
|
|
logins := flattenSlice(u.allowedLogins)
|
|
user.SetLogins(logins)
|
|
updateMessages["logins"] = logins
|
|
}
|
|
if len(u.allowedWindowsLogins) > 0 {
|
|
windowsLogins := flattenSlice(u.allowedWindowsLogins)
|
|
user.SetWindowsLogins(windowsLogins)
|
|
updateMessages["Windows logins"] = windowsLogins
|
|
}
|
|
if len(u.allowedKubeUsers) > 0 {
|
|
kubeUsers := flattenSlice(u.allowedKubeUsers)
|
|
user.SetKubeUsers(kubeUsers)
|
|
updateMessages["Kubernetes users"] = kubeUsers
|
|
}
|
|
if len(u.allowedKubeGroups) > 0 {
|
|
kubeGroups := flattenSlice(u.allowedKubeGroups)
|
|
user.SetKubeGroups(kubeGroups)
|
|
updateMessages["Kubernetes groups"] = kubeGroups
|
|
}
|
|
if len(u.allowedDatabaseUsers) > 0 {
|
|
dbUsers := flattenSlice(u.allowedDatabaseUsers)
|
|
user.SetDatabaseUsers(dbUsers)
|
|
updateMessages["database users"] = dbUsers
|
|
}
|
|
if len(u.allowedDatabaseNames) > 0 {
|
|
dbNames := flattenSlice(u.allowedDatabaseNames)
|
|
user.SetDatabaseNames(dbNames)
|
|
updateMessages["database names"] = dbNames
|
|
}
|
|
if len(u.allowedAWSRoleARNs) > 0 {
|
|
awsRoleARNs := flattenSlice(u.allowedAWSRoleARNs)
|
|
user.SetAWSRoleARNs(awsRoleARNs)
|
|
updateMessages["AWS role ARNs"] = awsRoleARNs
|
|
}
|
|
if len(u.allowedAzureIdentities) > 0 {
|
|
azureIdentities := flattenSlice(u.allowedAzureIdentities)
|
|
for _, identity := range azureIdentities {
|
|
if !services.MatchValidAzureIdentity(identity) {
|
|
return trace.BadParameter("Azure identity %q has invalid format.", identity)
|
|
}
|
|
if identity == types.Wildcard {
|
|
return trace.BadParameter("Azure identity cannot be a wildcard.")
|
|
}
|
|
}
|
|
user.SetAzureIdentities(azureIdentities)
|
|
updateMessages["Azure identities"] = azureIdentities
|
|
}
|
|
if len(u.allowedGCPServiceAccounts) > 0 {
|
|
accounts := flattenSlice(u.allowedGCPServiceAccounts)
|
|
for _, account := range accounts {
|
|
if err := gcp.ValidateGCPServiceAccountName(account); err != nil {
|
|
return trace.Wrap(err, "GCP service account %q is invalid", account)
|
|
}
|
|
}
|
|
user.SetGCPServiceAccounts(accounts)
|
|
updateMessages["GCP service accounts"] = accounts
|
|
}
|
|
|
|
if len(updateMessages) == 0 {
|
|
return trace.BadParameter("Nothing to update. Please provide at least one --set flag.")
|
|
}
|
|
|
|
for _, roleName := range user.GetRoles() {
|
|
if _, err := client.GetRole(ctx, roleName); err != nil {
|
|
log.Warnf("Error checking role %q when upserting user %q: %v", roleName, user.GetName(), err)
|
|
}
|
|
}
|
|
if err := client.UpsertUser(user); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
fmt.Printf("User %v has been updated:\n", user.GetName())
|
|
for field, values := range updateMessages {
|
|
fmt.Printf("\tNew %v: %v\n", field, strings.Join(values, ","))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// List prints all existing user accounts
|
|
func (u *UserCommand) List(ctx context.Context, client auth.ClientI) error {
|
|
users, err := client.GetUsers(false)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if u.format == teleport.Text {
|
|
if len(users) == 0 {
|
|
fmt.Println("No users found")
|
|
return nil
|
|
}
|
|
t := asciitable.MakeTable([]string{"User", "Roles"})
|
|
for _, u := range users {
|
|
t.AddRow([]string{
|
|
u.GetName(), strings.Join(u.GetRoles(), ","),
|
|
})
|
|
}
|
|
fmt.Println(t.AsBuffer().String())
|
|
} else {
|
|
out, err := json.MarshalIndent(users, "", " ")
|
|
if err != nil {
|
|
return trace.Wrap(err, "failed to marshal users")
|
|
}
|
|
fmt.Print(string(out))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Delete deletes teleport user(s). User IDs are passed as a comma-separated
|
|
// list in UserCommand.login
|
|
func (u *UserCommand) Delete(ctx context.Context, client auth.ClientI) error {
|
|
for _, l := range strings.Split(u.login, ",") {
|
|
if err := client.DeleteUser(ctx, l); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
fmt.Printf("User %q has been deleted\n", l)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func trimDurationZeroSuffix(d time.Duration) string {
|
|
s := d.Round(time.Second).String()
|
|
switch {
|
|
case strings.HasSuffix(s, "h0m0s"):
|
|
return strings.TrimSuffix(s, "0m0s")
|
|
case strings.HasSuffix(s, "m0s"):
|
|
return strings.TrimSuffix(s, "0s")
|
|
default:
|
|
return s
|
|
}
|
|
}
|