diff --git a/cmd/argocd/commands/app.go b/cmd/argocd/commands/app.go index 72e0d50d94..f4e313f93f 100644 --- a/cmd/argocd/commands/app.go +++ b/cmd/argocd/commands/app.go @@ -637,9 +637,10 @@ func formatConditionsSummary(app argoappv1.Application) string { // NewApplicationWaitCommand returns a new instance of an `argocd app wait` command func NewApplicationWaitCommand(clientOpts *argocdclient.ClientOptions) *cobra.Command { var ( - syncOnly bool - healthOnly bool - timeout uint + watchSync bool + watchHealth bool + watchOperations bool + timeout uint ) var command = &cobra.Command{ Use: "wait APPNAME", @@ -649,49 +650,22 @@ func NewApplicationWaitCommand(clientOpts *argocdclient.ClientOptions) *cobra.Co c.HelpFunc()(c, args) os.Exit(1) } - if syncOnly && healthOnly { - log.Fatalln("Please specify at most one of --sync-only or --health-only.") + if !watchSync && !watchHealth && !watchOperations { + watchSync = true + watchHealth = true + watchOperations = true } appName := args[0] conn, appIf := argocdclient.NewClientOrDie(clientOpts).NewApplicationClientOrDie() defer util.Close(conn) - ctx := context.Background() - ctx, cancel := context.WithCancel(ctx) - defer cancel() - if timeout != 0 { - time.AfterFunc(time.Duration(timeout)*time.Second, func() { - cancel() - }) - } - - // print the initial components to format the tabwriter columns - app, err := appIf.Get(ctx, &application.ApplicationQuery{Name: &appName}) + _, err := waitOnApplicationStatus(appIf, appName, timeout, watchSync, watchHealth, watchOperations) errors.CheckError(err) - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - printAppResources(w, app, false) - _ = w.Flush() - prevCompRes := &app.Status.ComparisonResult - - appEventCh := watchApp(ctx, appIf, appName) - for appEvent := range appEventCh { - app := appEvent.Application - printAppStateChange(w, prevCompRes, &app) - _ = w.Flush() - prevCompRes = &app.Status.ComparisonResult - - synced := app.Status.ComparisonResult.Status == argoappv1.ComparisonStatusSynced - healthy := app.Status.Health.Status == argoappv1.HealthStatusHealthy - if len(app.Status.GetErrorConditions()) == 0 && ((synced && healthy) || (synced && syncOnly) || (healthy && healthOnly)) { - log.Printf("App %q matches desired state", appName) - return - } - } - log.Fatalf("Timed out (%ds) waiting for app %q match desired state", timeout, appName) }, } - command.Flags().BoolVar(&syncOnly, "sync-only", false, "Wait only for sync") - command.Flags().BoolVar(&healthOnly, "health-only", false, "Wait only for health") + command.Flags().BoolVar(&watchSync, "sync", false, "Wait for sync") + command.Flags().BoolVar(&watchHealth, "health", false, "Wait for health") + command.Flags().BoolVar(&watchOperations, "operation", false, "Wait for pending operations") command.Flags().UintVar(&timeout, "timeout", defaultCheckTimeoutSeconds, "Time out after this many seconds") return command } @@ -803,38 +777,6 @@ func printAppResources(w io.Writer, app *argoappv1.Application, showOperation bo } } -// printAppStateChange prints a component state change if it was different from the last time we saw it -func printAppStateChange(w io.Writer, prevComp *argoappv1.ComparisonResult, app *argoappv1.Application) { - getPrevResState := func(kind, name string) (argoappv1.ComparisonStatus, argoappv1.HealthStatusCode) { - for _, res := range prevComp.Resources { - obj, err := argoappv1.UnmarshalToUnstructured(res.TargetState) - errors.CheckError(err) - if obj == nil { - obj, err = argoappv1.UnmarshalToUnstructured(res.LiveState) - errors.CheckError(err) - } - if obj.GetKind() == kind && obj.GetName() == name { - return res.Status, res.Health.Status - } - } - return "", "" - } - if len(app.Status.ComparisonResult.Resources) > 0 { - for _, res := range app.Status.ComparisonResult.Resources { - obj, err := argoappv1.UnmarshalToUnstructured(res.TargetState) - errors.CheckError(err) - if obj == nil { - obj, err = argoappv1.UnmarshalToUnstructured(res.LiveState) - errors.CheckError(err) - } - prevSync, prevHealth := getPrevResState(obj.GetKind(), obj.GetName()) - if prevSync != res.Status || prevHealth != res.Health.Status { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", obj.GetKind(), obj.GetName(), res.Status, res.Health.Status) - } - } - } -} - // NewApplicationSyncCommand returns a new instance of an `argocd app sync` command func NewApplicationSyncCommand(clientOpts *argocdclient.ClientOptions) *cobra.Command { var ( @@ -875,23 +817,10 @@ func NewApplicationSyncCommand(clientOpts *argocdclient.ClientOptions) *cobra.Co ctx := context.Background() _, err := appIf.Sync(ctx, &syncReq) errors.CheckError(err) - app, err := waitUntilOperationCompleted(appIf, appName, timeout) + + app, err := waitOnApplicationStatus(appIf, appName, timeout, false, false, true) errors.CheckError(err) - // get refreshed app before printing to show accurate sync/health status - app, err = appIf.Get(ctx, &application.ApplicationQuery{Name: &appName, Refresh: true}) - errors.CheckError(err) - - fmt.Printf(printOpFmtStr, "Application:", appName) - printOperationResult(app.Status.OperationState) - - if len(app.Status.ComparisonResult.Resources) > 0 { - fmt.Println() - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - printAppResources(w, app, true) - _ = w.Flush() - } - pruningRequired := 0 for _, resDetails := range app.Status.OperationState.SyncResult.Resources { if resDetails.Status == argoappv1.ResourceDetailsPruningRequired { @@ -916,22 +845,164 @@ func NewApplicationSyncCommand(clientOpts *argocdclient.ClientOptions) *cobra.Co return command } -func waitUntilOperationCompleted(appClient application.ApplicationServiceClient, appName string, timeout uint) (*argoappv1.Application, error) { +// ResourceState tracks the state of a resource when waiting on an application status. +type resourceState struct { + Kind string + Name string + PrevState string + Fields map[string]string + Updated bool +} + +func newResourceState(kind, name, status, healthStatus, resType, message string) *resourceState { + return &resourceState{ + Kind: kind, + Name: name, + Fields: map[string]string{ + "status": status, + "healthStatus": healthStatus, + "type": resType, + "message": message, + }, + } +} + +// Key returns a unique-ish key for the resource. +func (rs *resourceState) Key() string { + return fmt.Sprintf("%s/%s", rs.Kind, rs.Name) +} + +// Merge merges the new state into the previous state, returning whether the +// new state contains any additional keys or different values from the old state. +func (rs *resourceState) Merge() bool { + if out := rs.String(); out != rs.PrevState { + rs.PrevState = out + return true + } + return false +} + +func (rs *resourceState) String() string { + return fmt.Sprintf("%s\t%s\t%s\t%s\t%s\t%s", rs.Kind, rs.Name, rs.Fields["status"], rs.Fields["healthStatus"], rs.Fields["type"], rs.Fields["message"]) +} + +// Update a resourceState with any different contents from another resourceState. +// Blank fields in the receiver state will be updated to non-blank. +// Non-blank fields in the receiver state will never be updated to blank. +func (rs *resourceState) Update(newState *resourceState) { + for k, v := range newState.Fields { + if v != "" { + rs.Fields[k] = v + } + } +} + +func waitOnApplicationStatus(appClient application.ApplicationServiceClient, appName string, timeout uint, watchSync, watchHealth, watchOperations bool) (*argoappv1.Application, error) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + printFinalStatus := func() { + // get refreshed app before printing to show accurate sync/health status + app, err := appClient.Get(ctx, &application.ApplicationQuery{Name: &appName, Refresh: true}) + errors.CheckError(err) + + fmt.Printf(printOpFmtStr, "Application:", appName) + printOperationResult(app.Status.OperationState) + + if len(app.Status.ComparisonResult.Resources) > 0 { + fmt.Println() + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + printAppResources(w, app, true) + _ = w.Flush() + } + } + if timeout != 0 { time.AfterFunc(time.Duration(timeout)*time.Second, func() { cancel() + printFinalStatus() }) } + // print the initial components to format the tabwriter columns + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "KIND\tNAME\tSTATUS\tHEALTH\tHOOK\tOPERATIONMSG") + _ = w.Flush() + + prevStates := make(map[string]*resourceState) + conditionallyPrintOutput := func(w io.Writer, newState *resourceState) { + stateKey := newState.Key() + if prevState, found := prevStates[stateKey]; found { + prevState.Update(newState) + } else { + prevStates[stateKey] = newState + } + } + + printCompResults := func(compResult *argoappv1.ComparisonResult) { + if compResult != nil { + for _, res := range compResult.Resources { + obj, err := argoappv1.UnmarshalToUnstructured(res.TargetState) + errors.CheckError(err) + if obj == nil { + obj, err = argoappv1.UnmarshalToUnstructured(res.LiveState) + errors.CheckError(err) + } + + newState := newResourceState(obj.GetKind(), obj.GetName(), string(res.Status), res.Health.Status, "", "") + conditionallyPrintOutput(w, newState) + } + } + } + + printOpResults := func(opResult *argoappv1.SyncOperationResult) { + if opResult != nil { + if opResult.Hooks != nil { + for _, hook := range opResult.Hooks { + newState := newResourceState(hook.Kind, hook.Name, string(hook.Status), "", string(hook.Type), hook.Message) + conditionallyPrintOutput(w, newState) + } + } + + if opResult.Resources != nil { + for _, res := range opResult.Resources { + newState := newResourceState(res.Kind, res.Name, string(res.Status), "", "", res.Message) + conditionallyPrintOutput(w, newState) + } + } + } + } + appEventCh := watchApp(ctx, appClient, appName) for appEvent := range appEventCh { - if appEvent.Application.Status.OperationState != nil && appEvent.Application.Status.OperationState.Phase.Completed() { - return &appEvent.Application, nil + app := appEvent.Application + + printCompResults(&app.Status.ComparisonResult) + + if opState := app.Status.OperationState; opState != nil { + printOpResults(opState.SyncResult) + printOpResults(opState.RollbackResult) + } + + for _, v := range prevStates { + if v.Merge() { + fmt.Fprintln(w, v) + } + } + + _ = w.Flush() + + // consider skipped checks successful + synced := !watchSync || app.Status.ComparisonResult.Status == argoappv1.ComparisonStatusSynced + healthy := !watchHealth || app.Status.Health.Status == argoappv1.HealthStatusHealthy + operational := !watchOperations || appEvent.Application.Operation == nil + if len(app.Status.GetErrorConditions()) == 0 && synced && healthy && operational { + log.Printf("App %q matches desired state", appName) + printFinalStatus() + return &app, nil } } + return nil, fmt.Errorf("Timed out (%ds) waiting for app %q match desired state", timeout, appName) } @@ -1080,18 +1151,8 @@ func NewApplicationRollbackCommand(clientOpts *argocdclient.ClientOptions) *cobr }) errors.CheckError(err) - app, err = waitUntilOperationCompleted(appIf, appName, timeout) + _, err = waitOnApplicationStatus(appIf, appName, timeout, false, false, true) errors.CheckError(err) - - // get refreshed app before printing to show accurate sync/health status - app, err = appIf.Get(ctx, &application.ApplicationQuery{Name: &appName, Refresh: true}) - errors.CheckError(err) - - fmt.Printf(printOpFmtStr, "Application:", appName) - printOperationResult(app.Status.OperationState) - if !app.Status.OperationState.Phase.Successful() { - os.Exit(1) - } }, } command.Flags().BoolVar(&prune, "prune", false, "Allow deleting unexpected resources") diff --git a/server/account/account.go b/server/account/account.go index 34ba2b3cd7..4c9806ae6b 100644 --- a/server/account/account.go +++ b/server/account/account.go @@ -1,11 +1,14 @@ package account import ( + "time" + jwt "github.com/dgrijalva/jwt-go" "golang.org/x/net/context" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/argoproj/argo-cd/common" jwtutil "github.com/argoproj/argo-cd/util/jwt" "github.com/argoproj/argo-cd/util/password" "github.com/argoproj/argo-cd/util/session" @@ -27,16 +30,17 @@ func NewServer(sessionMgr *session.SessionManager, settingsMgr *settings.Setting } -//UpdatePassword is used to Update a User's Passwords +// UpdatePassword updates the password of the local admin superuser. func (s *Server) UpdatePassword(ctx context.Context, q *UpdatePasswordRequest) (*UpdatePasswordResponse, error) { username := getAuthenticatedUser(ctx) + if username != common.ArgoCDAdminUsername { + return nil, status.Errorf(codes.InvalidArgument, "password can only be changed for local users, not user %q", username) + } + cdSettings, err := s.settingsMgr.GetSettings() if err != nil { return nil, err } - if _, ok := cdSettings.LocalUsers[username]; !ok { - return nil, status.Errorf(codes.InvalidArgument, "password can only be changed for local users") - } err = s.sessionMgr.VerifyUsernamePassword(username, q.CurrentPassword) if err != nil { @@ -48,7 +52,8 @@ func (s *Server) UpdatePassword(ctx context.Context, q *UpdatePasswordRequest) ( return nil, err } - cdSettings.LocalUsers[username] = hashedPassword + cdSettings.AdminPasswordHash = hashedPassword + cdSettings.AdminPasswordMtime = time.Now().UTC() err = s.settingsMgr.SaveSettings(cdSettings) if err != nil { diff --git a/server/session/session.go b/server/session/session.go index b04e01bb2f..80af64d730 100644 --- a/server/session/session.go +++ b/server/session/session.go @@ -36,7 +36,7 @@ func (s *Server) Create(ctx context.Context, q *SessionCreateRequest) (*SessionR if err != nil { return nil, err } - tokenString, err = s.mgr.Create(q.Username) + tokenString, err = s.mgr.Create(q.Username, 0) if err != nil { return nil, err } @@ -50,7 +50,7 @@ func (s *Server) Create(ctx context.Context, q *SessionCreateRequest) (*SessionR if err != nil { return nil, err } - tokenString, err = s.mgr.ReissueClaims(claims) + tokenString, err = s.mgr.ReissueClaims(claims, 0) if err != nil { return nil, fmt.Errorf("Failed to resign claims: %v", err) } diff --git a/util/session/sessionmanager.go b/util/session/sessionmanager.go index 51878e82ad..91ad884bb2 100644 --- a/util/session/sessionmanager.go +++ b/util/session/sessionmanager.go @@ -38,6 +38,7 @@ const ( // invalidLoginError, for security purposes, doesn't say whether the username or password was invalid. This does not mitigate the potential for timing attacks to determine which is which. invalidLoginError = "Invalid username or password" blankPasswordError = "Blank passwords are not allowed" + badUserError = "Bad local superuser username" ) // NewSessionManager creates a new session manager from ArgoCD settings @@ -68,17 +69,21 @@ func NewSessionManager(settings *settings.ArgoCDSettings) *SessionManager { } // Create creates a new token for a given subject (user) and returns it as a string. -func (mgr *SessionManager) Create(subject string) (string, error) { +// Passing a value of `0` for secondsBeforeExpiry creates a token that never expires. +func (mgr *SessionManager) Create(subject string, secondsBeforeExpiry int) (string, error) { // Create a new token object, specifying signing method and the claims // you would like it to contain. - now := time.Now().Unix() + now := time.Now().UTC() claims := jwt.StandardClaims{ - //ExpiresAt: time.Date(2015, 10, 10, 12, 0, 0, 0, time.UTC).Unix(), - IssuedAt: now, + IssuedAt: now.Unix(), Issuer: SessionManagerClaimsIssuer, - NotBefore: now, + NotBefore: now.Unix(), Subject: subject, } + if secondsBeforeExpiry > 0 { + expires := now.Add(time.Duration(secondsBeforeExpiry) * time.Second) + claims.ExpiresAt = expires.Unix() + } return mgr.signClaims(claims) } @@ -89,20 +94,24 @@ func (mgr *SessionManager) signClaims(claims jwt.Claims) (string, error) { } // ReissueClaims re-issues and re-signs a new token signed by us, while preserving most of the claim values -func (mgr *SessionManager) ReissueClaims(claims jwt.MapClaims) (string, error) { - now := time.Now().Unix() +func (mgr *SessionManager) ReissueClaims(claims jwt.MapClaims, secondsBeforeExpiry int) (string, error) { + now := time.Now().UTC() newClaims := make(jwt.MapClaims) for k, v := range claims { newClaims[k] = v } newClaims["iss"] = SessionManagerClaimsIssuer - newClaims["iat"] = now - newClaims["nbf"] = now + newClaims["iat"] = now.Unix() + newClaims["nbf"] = now.Unix() delete(newClaims, "exp") + if secondsBeforeExpiry > 0 { + expires := now.Add(time.Duration(secondsBeforeExpiry) * time.Second) + claims["exp"] = expires.Unix() + } return mgr.signClaims(newClaims) } -// Parse tries to parse the provided string and returns the token claims. +// Parse tries to parse the provided string and returns the token claims for local superuser login. func (mgr *SessionManager) Parse(tokenString string) (jwt.Claims, error) { // Parse takes the token string and a function for looking up the key. The latter is especially // useful if you use multiple keys for your application. The standard is to use 'kid' in the @@ -119,23 +128,23 @@ func (mgr *SessionManager) Parse(tokenString string) (jwt.Claims, error) { if err != nil { return nil, err } + + issuedAt := time.Unix(int64(claims["iat"].(float64)), 0) + if issuedAt.Before(mgr.settings.AdminPasswordMtime) { + return nil, fmt.Errorf("Password for superuser has changed since token issued") + } return token.Claims, nil } // VerifyUsernamePassword verifies if a username/password combo is correct func (mgr *SessionManager) VerifyUsernamePassword(username, password string) error { + if username != common.ArgoCDAdminUsername { + return status.Errorf(codes.Unauthenticated, badUserError) + } if password == "" { return status.Errorf(codes.Unauthenticated, blankPasswordError) } - passwordHash, ok := mgr.settings.LocalUsers[username] - if !ok { - // Username was not found in local user store. - // Ensure we still send password to hashing algorithm for comparison. - // This mitigates potential for timing attacks that benefit from short-circuiting, - // provided the hashing library/algorithm in use doesn't itself short-circuit. - passwordHash = "" - } - valid, _ := passwordutil.VerifyPassword(password, passwordHash) + valid, _ := passwordutil.VerifyPassword(password, mgr.settings.AdminPasswordHash) if !valid { return status.Errorf(codes.Unauthenticated, invalidLoginError) } diff --git a/util/session/sessionmanager_test.go b/util/session/sessionmanager_test.go index 206697d5a4..0e050d0430 100644 --- a/util/session/sessionmanager_test.go +++ b/util/session/sessionmanager_test.go @@ -17,7 +17,7 @@ func TestSessionManager(t *testing.T) { } mgr := NewSessionManager(&set) - token, err := mgr.Create(defaultSubject) + token, err := mgr.Create(defaultSubject, 0) if err != nil { t.Errorf("Could not create token: %v", err) } diff --git a/util/settings/settings.go b/util/settings/settings.go index 26f6f06b6c..5a53ee53a6 100644 --- a/util/settings/settings.go +++ b/util/settings/settings.go @@ -33,10 +33,11 @@ type ArgoCDSettings struct { // URL is the externally facing URL users will visit to reach ArgoCD. // The value here is used when configuring SSO. Omitting this value will disable SSO. URL string `json:"url,omitempty"` + // Admin superuser password storage + AdminPasswordHash string `json:"adminPasswordHash,omitempty"` + AdminPasswordMtime time.Time `json:"adminPasswordMtime,omitempty"` // DexConfig is contains portions of a dex config yaml DexConfig string `json:"dexConfig,omitempty"` - // LocalUsers holds users local to (stored on) the server. This is to be distinguished from any potential alternative future login providers (LDAP, SAML, etc.) that might ever be added. - LocalUsers map[string]string `json:"localUsers,omitempty"` // ServerSignature holds the key used to generate JWT tokens. ServerSignature []byte `json:"serverSignature,omitempty"` // Certificate holds the certificate/private key for the ArgoCD API server. @@ -53,8 +54,10 @@ type ArgoCDSettings struct { } const ( - // settingAdminPasswordKey designates the key for a root password inside a Kubernetes secret. - settingAdminPasswordKey = "admin.password" + // settingAdminPasswordHashKey designates the key for a root password hash inside a Kubernetes secret. + settingAdminPasswordHashKey = "admin.password" + // settingAdminPasswordMtimeKey designates the key for a root password mtime inside a Kubernetes secret. + settingAdminPasswordMtimeKey = "admin.passwordMtime" // settingServerSignatureKey designates the key for a server secret key inside a Kubernetes secret. settingServerSignatureKey = "server.secretkey" // settingServerCertificate designates the key for the public cert used in TLS @@ -107,13 +110,18 @@ func updateSettingsFromConfigMap(settings *ArgoCDSettings, argoCDCM *apiv1.Confi settings.URL = argoCDCM.Data[settingURLKey] } +// UpdateSettingsFromSecret transfers settings from a Kubernetes secret into an ArgoCDSettings struct. func updateSettingsFromSecret(settings *ArgoCDSettings, argoCDSecret *apiv1.Secret) error { - adminPasswordHash, ok := argoCDSecret.Data[settingAdminPasswordKey] + adminPasswordHash, ok := argoCDSecret.Data[settingAdminPasswordHashKey] if !ok { return fmt.Errorf("admin user not found") } - settings.LocalUsers = map[string]string{ - common.ArgoCDAdminUsername: string(adminPasswordHash), + settings.AdminPasswordHash = string(adminPasswordHash) + settings.AdminPasswordMtime = time.Now().UTC() + if adminPasswordMtimeBytes, ok := argoCDSecret.Data[settingAdminPasswordMtimeKey]; ok { + if adminPasswordMtime, err := time.Parse(time.RFC3339, string(adminPasswordMtimeBytes)); err == nil { + settings.AdminPasswordMtime = adminPasswordMtime + } } secretKey, ok := argoCDSecret.Data[settingServerSignatureKey] if !ok { @@ -194,7 +202,8 @@ func (mgr *SettingsManager) SaveSettings(settings *ArgoCDSettings) error { } argoCDSecret.StringData = make(map[string]string) argoCDSecret.StringData[settingServerSignatureKey] = string(settings.ServerSignature) - argoCDSecret.StringData[settingAdminPasswordKey] = settings.LocalUsers[common.ArgoCDAdminUsername] + argoCDSecret.StringData[settingAdminPasswordHashKey] = settings.AdminPasswordHash + argoCDSecret.StringData[settingAdminPasswordMtimeKey] = settings.AdminPasswordMtime.Format(time.RFC3339) if settings.WebhookGitHubSecret != "" { argoCDSecret.StringData[settingsWebhookGitHubSecretKey] = settings.WebhookGitHubSecret } @@ -423,19 +432,15 @@ func UpdateSettings(defaultPassword string, settingsMgr *SettingsManager, update errors.CheckError(err) cdSettings.ServerSignature = signature } - if cdSettings.LocalUsers == nil { - cdSettings.LocalUsers = make(map[string]string) - } - if _, ok := cdSettings.LocalUsers[common.ArgoCDAdminUsername]; !ok || updateSuperuser { + if cdSettings.AdminPasswordHash == "" || updateSuperuser { passwordRaw := defaultPassword if passwordRaw == "" { passwordRaw = ReadAndConfirmPassword() } hashedPassword, err := password.HashPassword(passwordRaw) errors.CheckError(err) - cdSettings.LocalUsers = map[string]string{ - common.ArgoCDAdminUsername: hashedPassword, - } + cdSettings.AdminPasswordHash = hashedPassword + cdSettings.AdminPasswordMtime = time.Now().UTC() } if cdSettings.Certificate == nil {