-
Notifications
You must be signed in to change notification settings - Fork 92
MM-67613 Adding encryption key rotation with graceful fallback #664
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7fab3d9
7cd5fc9
86737b4
955c63e
d3ade68
8ce0215
992090e
fe4ffca
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -276,6 +276,22 @@ func (p *Plugin) getOAuthConfig() (*oauth2.Config, error) { | |
| }, nil | ||
| } | ||
|
|
||
| // canConnect checks whether the plugin has enough configuration to allow a user to connect, | ||
| // either via a KV-backed instance (default instance) or via legacy plugin settings. | ||
| func (p *Plugin) canConnect() bool { | ||
| config := p.getConfiguration() | ||
|
|
||
| if config.DefaultInstanceName != "" { | ||
| if _, err := p.getInstance(config.DefaultInstanceName); err == nil { | ||
| return true | ||
| } | ||
| } | ||
|
|
||
| return config.GitlabOAuthClientID != "" && | ||
| config.GitlabOAuthClientSecret != "" && | ||
| isValidURL(config.GitlabURL) == nil | ||
| } | ||
|
|
||
| // resolveOAuthCredentials returns OAuth client credentials and the parsed GitLab base URL | ||
| // by first trying the KV-backed instance configuration, then falling back to legacy plugin | ||
| // settings for backwards compatibility with upgrades from v1.11 and earlier. | ||
|
|
@@ -469,8 +485,17 @@ func (p *Plugin) getGitlabUserTokenByMattermostID(userID string) (*oauth2.Token, | |
|
|
||
| unencryptedToken, err := decrypt([]byte(config.EncryptionKey), string(tokenBytes)) | ||
| if err != nil { | ||
| p.client.Log.Warn("can't decrypt token", "err", err.Error()) | ||
| return nil, &APIErrorResponse{ID: "", Message: "Unable to decrypt access token.", StatusCode: http.StatusInternalServerError} | ||
| prevKey := config.PreviousEncryptionKey | ||
| if prevKey != "" { | ||
| if fallback, fbErr := decrypt([]byte(prevKey), string(tokenBytes)); fbErr == nil { | ||
| unencryptedToken = fallback | ||
| err = nil | ||
| } | ||
| } | ||
| if err != nil { | ||
| p.client.Log.Warn("can't decrypt token", "err", err.Error()) | ||
| return nil, &APIErrorResponse{ID: "", Message: "Unable to decrypt access token.", StatusCode: http.StatusInternalServerError} | ||
| } | ||
| } | ||
|
|
||
| if err := json.Unmarshal([]byte(unencryptedToken), &token); err != nil { | ||
|
|
@@ -527,9 +552,13 @@ func (p *Plugin) getGitlabIDToUsernameMapping(gitlabUserID string) string { | |
| } | ||
|
|
||
| func (p *Plugin) disconnectGitlabAccount(userID string) { | ||
| userInfo, err := p.getGitlabUserInfoByMattermostID(userID) | ||
| if err != nil { | ||
| p.client.Log.Warn("can't get GitLab user info from mattermost id", "err", err.Message) | ||
| userInfo, apiErr := p.getGitlabUserInfoByMattermostID(userID) | ||
| if apiErr != nil { | ||
| p.client.Log.Warn("can't get GitLab user info from mattermost id, attempting best-effort cleanup", | ||
| "err", apiErr.Message, "user_id", userID) | ||
| // Fall back to best-effort cleanup using only the userID, in case the user is | ||
| // in a partial state (e.g., _userinfo is missing but a token key still exists). | ||
| p.forceDisconnectUser(userID) | ||
| return | ||
| } | ||
| if userInfo == nil { | ||
|
|
@@ -991,6 +1020,193 @@ func (p *Plugin) handleRevokedToken(info *gitlab.UserInfo) { | |
| } | ||
| } | ||
|
|
||
| // reEncryptUserData re-encrypts all stored user tokens from previousEncryptionKey to newEncryptionKey. | ||
| // It is safe to call concurrently; a cluster mutex prevents parallel executions. | ||
| func (p *Plugin) reEncryptUserData(newEncryptionKey, previousEncryptionKey string) { | ||
| auditRec := plugin.MakeAuditRecord("reEncryptUserData", model.AuditStatusFail) | ||
| defer p.API.LogAuditRec(auditRec) | ||
|
|
||
| mutex, err := cluster.NewMutex(p.API, "gitlab-reencrypt-lock") | ||
| if err != nil { | ||
| p.client.Log.Warn("Failed to acquire re-encryption mutex", "error", err.Error()) | ||
| auditRec.AddErrorDesc(err.Error()) | ||
| return | ||
| } | ||
| mutex.Lock() | ||
| defer mutex.Unlock() | ||
|
|
||
| p.client.Log.Info("Encryption key changed, re-encrypting user tokens") | ||
|
|
||
| const keysPerPage = 1000 | ||
| var allKeys []string | ||
| for page := 0; ; page++ { | ||
| keys, listErr := p.client.KV.ListKeys(page, keysPerPage, pluginapi.WithChecker(func(key string) (bool, error) { | ||
| return strings.HasSuffix(key, GitlabUserTokenKey), nil | ||
| })) | ||
| if listErr != nil { | ||
| p.client.Log.Warn("Failed to list KV keys during re-encryption, continuing with already-collected keys", | ||
| "page", page, "error", listErr.Error()) | ||
| break | ||
| } | ||
| allKeys = append(allKeys, keys...) | ||
| if len(keys) < keysPerPage { | ||
| break | ||
| } | ||
| } | ||
|
|
||
| params := ReEncryptUserDataAuditParams{TotalUsers: len(allKeys)} | ||
| model.AddEventParameterAuditableToAuditRec(auditRec, "re_encrypt_user_data", params) | ||
|
|
||
| result := ReEncryptUserDataAuditResult{} | ||
|
|
||
| for _, key := range allKeys { | ||
| migrated, reErr := p.reEncryptUserToken(key, newEncryptionKey, previousEncryptionKey) | ||
| if reErr != nil { | ||
| result.ForceDisconnectCount++ | ||
| continue | ||
| } | ||
| if migrated { | ||
| result.MigratedCount++ | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| auditRec.AddEventResultState(result) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Even if ForceDisconnectCount == len(allKeys) audit says success. Mark as failure orpartial when disconnects occurred.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh yes this makes more sense, I'll adjust this |
||
|
|
||
| if result.ForceDisconnectCount > 0 { | ||
| auditRec.AddErrorDesc(fmt.Sprintf("%d users were force-disconnected during re-encryption", result.ForceDisconnectCount)) | ||
| } else { | ||
| auditRec.Success() | ||
| } | ||
|
|
||
| // Clear the fallback key now that all tokens have been migrated. | ||
| p.configurationLock.Lock() | ||
| if p.configuration != nil && p.configuration.PreviousEncryptionKey == previousEncryptionKey { | ||
| p.configuration.PreviousEncryptionKey = "" | ||
| } | ||
| p.configurationLock.Unlock() | ||
| } | ||
|
|
||
| // reEncryptUserToken re-encrypts a single user token KV entry from previousEncryptionKey to newEncryptionKey. | ||
| // Returns true if the token was re-encrypted, false if it was already migrated (idempotent). | ||
| // On failure, calls forceDisconnectUser and returns an error. | ||
| func (p *Plugin) reEncryptUserToken(kvKey, newEncryptionKey, previousEncryptionKey string) (bool, error) { | ||
| userID := strings.TrimSuffix(kvKey, GitlabUserTokenKey) | ||
|
|
||
| var tokenBytes []byte | ||
| if err := p.client.KV.Get(kvKey, &tokenBytes); err != nil { | ||
| p.client.Log.Warn("Failed to read token during re-encryption, force-disconnecting user", | ||
| "user_id", userID, "error", err.Error()) | ||
| p.forceDisconnectUser(userID) | ||
| return false, err | ||
| } | ||
| if tokenBytes == nil { | ||
| return false, nil | ||
| } | ||
|
|
||
| tokenStr := string(tokenBytes) | ||
|
|
||
| // Idempotency check: if the token can already be decrypted with the new key, it was already migrated. | ||
| if plain, decErr := decrypt([]byte(newEncryptionKey), tokenStr); decErr == nil { | ||
| var tok oauth2.Token | ||
| if json.Unmarshal([]byte(plain), &tok) == nil { | ||
| return false, nil | ||
| } | ||
| } | ||
|
|
||
| plainJSON, err := decrypt([]byte(previousEncryptionKey), tokenStr) | ||
| if err != nil { | ||
| p.client.Log.Warn("Failed to decrypt token with previous key during re-encryption, force-disconnecting user", | ||
| "user_id", userID, "error", err.Error()) | ||
| p.forceDisconnectUser(userID) | ||
| return false, err | ||
| } | ||
|
|
||
| var tok oauth2.Token | ||
| if err = json.Unmarshal([]byte(plainJSON), &tok); err != nil { | ||
| p.client.Log.Warn("Decrypted token is not valid JSON, force-disconnecting user", | ||
| "user_id", userID, "error", err.Error()) | ||
| p.forceDisconnectUser(userID) | ||
| return false, err | ||
| } | ||
|
|
||
| reEncrypted, err := encrypt([]byte(newEncryptionKey), plainJSON) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If decrypt returns garbage that passes padding corrupted data gets re encrypted silently. Add json.Unmarshal validation before re-encrypting.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this also resolves the false-positive issue you listed above so I'll keep this as a way to resolve both, thanks for the suggestion! |
||
| if err != nil { | ||
| p.client.Log.Warn("Failed to re-encrypt token with new key, force-disconnecting user", | ||
| "user_id", userID, "error", err.Error()) | ||
| p.forceDisconnectUser(userID) | ||
| return false, err | ||
| } | ||
|
|
||
| swapped, appErr := p.API.KVCompareAndSet(kvKey, tokenBytes, []byte(reEncrypted)) | ||
| if appErr != nil { | ||
| p.client.Log.Warn("Failed to store re-encrypted token, force-disconnecting user", | ||
| "user_id", userID, "error", appErr.Error()) | ||
| p.forceDisconnectUser(userID) | ||
| return false, appErr | ||
| } | ||
| if !swapped { | ||
| p.client.Log.Info("Token was modified concurrently during re-encryption, skipping user", | ||
| "user_id", userID) | ||
| return false, nil | ||
| } | ||
|
|
||
| return true, nil | ||
| } | ||
|
|
||
| // forceDisconnectUser performs a best-effort cleanup of a user's encrypted data and notifies them to reconnect. | ||
| func (p *Plugin) forceDisconnectUser(userID string) { | ||
| // Fetch user info for GitLab-specific mapping cleanup. User info is not encrypted, | ||
| // so this should succeed even after a key rotation. | ||
| userInfo, apiErr := p.getGitlabUserInfoByMattermostID(userID) | ||
| if apiErr != nil { | ||
| p.client.Log.Warn("Failed to load user info for force-disconnect", | ||
| "user_id", userID, "error", apiErr.Message) | ||
| } | ||
|
|
||
| if err := p.client.KV.Delete(userID + GitlabUserTokenKey); err != nil { | ||
| p.client.Log.Warn("Failed to delete user token during force-disconnect", "user_id", userID, "error", err.Error()) | ||
| } | ||
| if err := p.client.KV.Delete(userID + GitlabUserInfoKey); err != nil { | ||
| p.client.Log.Warn("Failed to delete user info during force-disconnect", "user_id", userID, "error", err.Error()) | ||
| } | ||
|
|
||
| if userInfo != nil { | ||
| if userInfo.GitlabUsername != "" { | ||
| if err := p.deleteGitlabToUserIDMapping(userInfo.GitlabUsername); err != nil { | ||
| p.client.Log.Warn("Failed to delete GitLab username mapping during force-disconnect", | ||
| "user_id", userID, "gitlab_username", userInfo.GitlabUsername, "error", err.Error()) | ||
| } | ||
| } | ||
| if userInfo.GitlabUserID != 0 { | ||
| if err := p.deleteGitlabIDToUserIDMapping(userInfo.GitlabUserID); err != nil { | ||
| p.client.Log.Warn("Failed to delete GitLab ID mapping during force-disconnect", | ||
| "user_id", userID, "gitlab_user_id", userInfo.GitlabUserID, "error", err.Error()) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if user, err := p.client.User.Get(userID); err == nil && user.Props != nil && len(user.Props["git_user"]) > 0 { | ||
| delete(user.Props, "git_user") | ||
| if err := p.client.User.Update(user); err != nil { | ||
| p.client.Log.Warn("Failed to update user props during force-disconnect", "user_id", userID, "error", err.Error()) | ||
| } | ||
| } | ||
|
|
||
| p.client.Frontend.PublishWebSocketEvent( | ||
| WsEventDisconnect, | ||
| nil, | ||
| &model.WebsocketBroadcast{UserId: userID}, | ||
| ) | ||
|
|
||
| if err := p.CreateBotDMPost( | ||
| userID, | ||
| "Your GitLab account has been disconnected because the encryption key was rotated and your token could not be re-encrypted. Please reconnect your account using the `/gitlab connect` command.", | ||
| "custom_git_disconnected", | ||
| ); err != nil { | ||
| p.client.Log.Warn("Failed to send force-disconnect DM", "user_id", userID, "error", err.Error()) | ||
| } | ||
| } | ||
|
|
||
| func (p *Plugin) getOrRefreshTokenWithMutex(info *gitlab.UserInfo) (*oauth2.Token, error) { | ||
| token, apiErr := p.getGitlabUserTokenByMattermostID(info.UserID) | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.