@@ -24,6 +24,7 @@ import (
2424 "github.com/mattermost/mattermost/server/public/model"
2525 "github.com/mattermost/mattermost/server/public/plugin"
2626 "github.com/mattermost/mattermost/server/public/pluginapi"
27+ "github.com/mattermost/mattermost/server/public/pluginapi/cluster"
2728 "github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/logger"
2829 "github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/poster"
2930
@@ -36,8 +37,9 @@ const (
3637 githubUsernameKey = "_githubusername"
3738 githubPrivateRepoKey = "_githubprivate"
3839
39- mm34646MutexKey = "mm34646_token_reset_mutex"
40- mm34646DoneKey = "mm34646_token_reset_done"
40+ mm34646MutexKey = "mm34646_token_reset_mutex"
41+ mm34646DoneKey = "mm34646_token_reset_done"
42+ reEncryptMutexKey = "reencrypt_user_data_mutex"
4143
4244 wsEventConnect = "connect"
4345 wsEventDisconnect = "disconnect"
@@ -657,10 +659,8 @@ type UserSettings struct {
657659 Notifications bool `json:"notifications"`
658660}
659661
660- func (p * Plugin ) storeGitHubUserInfo (info * GitHubUserInfo ) error {
661- config := p .getConfiguration ()
662-
663- encryptedToken , err := encrypt ([]byte (config .EncryptionKey ), info .Token .AccessToken )
662+ func (p * Plugin ) storeGitHubUserInfo (info * GitHubUserInfo , encryptionKey string ) error {
663+ encryptedToken , err := encrypt ([]byte (encryptionKey ), info .Token .AccessToken )
664664 if err != nil {
665665 return errors .Wrap (err , "error occurred while encrypting access token" )
666666 }
@@ -728,8 +728,24 @@ func (p *Plugin) getGitHubToUsernameMapping(githubUsername string) string {
728728}
729729
730730func (p * Plugin ) disconnectGitHubAccount (userID string ) {
731- userInfo , _ := p .getGitHubUserInfo (userID )
732- if userInfo == nil {
731+ userInfo , apiErr := p .getGitHubUserInfo (userID )
732+ if apiErr != nil {
733+ if apiErr .ID == apiErrorIDNotConnected {
734+ return
735+ }
736+
737+ p .client .Log .Warn ("Failed to load user info for disconnect, falling back to force-disconnect" ,
738+ "user_id" , userID , "error" , apiErr .Message )
739+ var rawInfo * GitHubUserInfo
740+ if err := p .store .Get (userID + githubTokenKey , & rawInfo ); err != nil {
741+ p .client .Log .Warn ("Failed to load raw user info during fallback disconnect" ,
742+ "user_id" , userID , "error" , err .Error ())
743+ }
744+ githubUsername := ""
745+ if rawInfo != nil {
746+ githubUsername = rawInfo .GitHubUsername
747+ }
748+ p .forceDisconnectUser (userID , githubUsername )
733749 return
734750 }
735751
@@ -738,7 +754,11 @@ func (p *Plugin) disconnectGitHubAccount(userID string) {
738754 }
739755
740756 if err := p .store .Delete (userInfo .GitHubUsername + githubUsernameKey ); err != nil {
741- p .client .Log .Warn ("Failed to delete github token from KV store" , "userID" , userID , "error" , err .Error ())
757+ p .client .Log .Warn ("Failed to delete github username mapping from KV store" , "userID" , userID , "error" , err .Error ())
758+ }
759+
760+ if err := p .store .Delete (userID + githubPrivateRepoKey ); err != nil {
761+ p .client .Log .Warn ("Failed to delete github private repo key from KV store" , "userID" , userID , "error" , err .Error ())
742762 }
743763
744764 user , err := p .client .User .Get (userID )
@@ -750,7 +770,7 @@ func (p *Plugin) disconnectGitHubAccount(userID string) {
750770 delete (user .Props , "git_user" )
751771 err := p .client .User .Update (user )
752772 if err != nil {
753- p .client .Log .Warn ("Failed to get update user props" , "userID" , userID , "error" , err .Error ())
773+ p .client .Log .Warn ("Failed to update user props" , "userID" , userID , "error" , err .Error ())
754774 }
755775 }
756776 }
@@ -762,6 +782,158 @@ func (p *Plugin) disconnectGitHubAccount(userID string) {
762782 )
763783}
764784
785+ // reEncryptUserData re-encrypts all connected users' access tokens when
786+ // the encryption key changes. Users whose tokens cannot be migrated are
787+ // force-disconnected and notified to reconnect.
788+ // A cluster mutex ensures only one node performs the migration in HA setups.
789+ func (p * Plugin ) reEncryptUserData (newEncryptionKey , previousEncryptionKey string ) {
790+ m , err := cluster .NewMutex (p .API , reEncryptMutexKey )
791+ if err != nil {
792+ p .client .Log .Warn ("Failed to create cluster mutex for encryption key rotation" , "error" , err .Error ())
793+ return
794+ }
795+ m .Lock ()
796+ defer m .Unlock ()
797+
798+ checker := func (key string ) (keep bool , err error ) {
799+ return strings .HasSuffix (key , githubTokenKey ), nil
800+ }
801+
802+ var allKeys []string
803+ for page := 0 ; ; page ++ {
804+ keys , err := p .store .ListKeys (page , keysPerPage , pluginapi .WithChecker (checker ))
805+ if err != nil {
806+ p .client .Log .Warn ("Encryption key changed but failed to list user keys for re-encryption, proceeding with keys collected so far" ,
807+ "page" , fmt .Sprintf ("%d" , page ), "keys_collected" , fmt .Sprintf ("%d" , len (allKeys )), "error" , err .Error ())
808+ break
809+ }
810+ allKeys = append (allKeys , keys ... )
811+ if len (keys ) < keysPerPage {
812+ break
813+ }
814+ }
815+
816+ if len (allKeys ) == 0 {
817+ return
818+ }
819+
820+ auditRec := plugin .MakeAuditRecord ("reEncryptUserData" , model .AuditStatusFail )
821+ defer p .API .LogAuditRec (auditRec )
822+ model .AddEventParameterAuditableToAuditRec (auditRec , "re_encrypt_user_data" , ReEncryptUserDataAuditParams {
823+ TotalUsers : len (allKeys ),
824+ })
825+
826+ p .client .Log .Info ("Encryption key changed, re-encrypting user tokens" ,
827+ "user_count" , fmt .Sprintf ("%d" , len (allKeys )))
828+
829+ var migrated , forceDisconnected int
830+ for _ , key := range allKeys {
831+ userID := strings .TrimSuffix (key , githubTokenKey )
832+
833+ githubUsername , err := p .reEncryptUserToken (key , newEncryptionKey , previousEncryptionKey )
834+ if err != nil {
835+ p .client .Log .Warn ("Failed to re-encrypt user token during encryption key rotation" ,
836+ "user_id" , userID , "error" , err .Error ())
837+ auditRec .AddErrorDesc (fmt .Sprintf ("user %s: %s" , userID , err .Error ()))
838+ p .forceDisconnectUser (userID , githubUsername )
839+ forceDisconnected ++
840+ } else {
841+ migrated ++
842+ }
843+ }
844+
845+ if forceDisconnected == 0 {
846+ auditRec .Success ()
847+ }
848+ auditRec .AddEventResultState (ReEncryptUserDataAuditResult {
849+ Migrated : migrated ,
850+ ForceDisconnected : forceDisconnected ,
851+ })
852+ }
853+
854+ // reEncryptUserToken decrypts a single user's token with the old key and
855+ // re-encrypts it with the new key. Returns the GitHub username (best-effort,
856+ // may be empty) and any error encountered.
857+ func (p * Plugin ) reEncryptUserToken (kvKey , newEncryptionKey , previousEncryptionKey string ) (string , error ) {
858+ var userInfo * GitHubUserInfo
859+ if err := p .store .Get (kvKey , & userInfo ); err != nil {
860+ return "" , errors .Wrap (err , "could not load user info" )
861+ }
862+ if userInfo == nil {
863+ return "" , errors .New ("user info not found" )
864+ }
865+
866+ if userInfo .Token == nil || userInfo .Token .AccessToken == "" {
867+ return userInfo .GitHubUsername , errors .New ("user has no token to re-encrypt" )
868+ }
869+
870+ if _ , err := decrypt ([]byte (newEncryptionKey ), userInfo .Token .AccessToken ); err == nil {
871+ return userInfo .GitHubUsername , nil
872+ }
873+
874+ plainToken , err := decrypt ([]byte (previousEncryptionKey ), userInfo .Token .AccessToken )
875+ if err != nil {
876+ return userInfo .GitHubUsername , errors .Wrap (err , "could not decrypt token with previous key" )
877+ }
878+
879+ userInfo .Token .AccessToken = plainToken
880+ if err := p .storeGitHubUserInfo (userInfo , newEncryptionKey ); err != nil {
881+ return userInfo .GitHubUsername , errors .Wrap (err , "could not store re-encrypted token" )
882+ }
883+
884+ return userInfo .GitHubUsername , nil
885+ }
886+
887+ // forceDisconnectUser performs a best-effort cleanup of a user's encrypted
888+ // data and notifies them to reconnect
889+ func (p * Plugin ) forceDisconnectUser (userID , githubUsername string ) {
890+ if err := p .store .Delete (userID + githubTokenKey ); err != nil {
891+ p .client .Log .Warn ("forceDisconnectUser: failed to delete github token" ,
892+ "user_id" , userID , "error" , err .Error ())
893+ }
894+
895+ if err := p .store .Delete (userID + githubPrivateRepoKey ); err != nil {
896+ p .client .Log .Warn ("forceDisconnectUser: failed to delete github private repo key" ,
897+ "user_id" , userID , "error" , err .Error ())
898+ }
899+
900+ user , err := p .client .User .Get (userID )
901+ if err != nil {
902+ p .client .Log .Warn ("forceDisconnectUser: failed to get user props" ,
903+ "user_id" , userID , "error" , err .Error ())
904+ } else {
905+ if githubUsername == "" {
906+ if gitUser , ok := user .Props ["git_user" ]; ok {
907+ githubUsername = gitUser
908+ }
909+ }
910+ if _ , ok := user .Props ["git_user" ]; ok {
911+ delete (user .Props , "git_user" )
912+ if err := p .client .User .Update (user ); err != nil {
913+ p .client .Log .Warn ("forceDisconnectUser: failed to update user props" ,
914+ "user_id" , userID , "error" , err .Error ())
915+ }
916+ }
917+ }
918+
919+ if githubUsername != "" {
920+ if err := p .store .Delete (githubUsername + githubUsernameKey ); err != nil {
921+ p .client .Log .Warn ("forceDisconnectUser: failed to delete username mapping" ,
922+ "user_id" , userID , "error" , err .Error ())
923+ }
924+ }
925+
926+ p .client .Frontend .PublishWebSocketEvent (
927+ wsEventDisconnect ,
928+ nil ,
929+ & model.WebsocketBroadcast {UserId : userID },
930+ )
931+
932+ p .CreateBotDMPost (userID ,
933+ "Your GitHub connection has been reset due to a change in the plugin configuration. Please reconnect your account using `/github connect`." ,
934+ "custom_git_disconnect" )
935+ }
936+
765937func (p * Plugin ) openIssueCreateModal (userID string , channelID string , title string ) {
766938 p .client .Frontend .PublishWebSocketEvent (
767939 wsEventCreateIssue ,
0 commit comments