From 09ee1233cf5a4d4bdcc09913c35daaccb90a3eff Mon Sep 17 00:00:00 2001 From: Mariell Hoversholm Date: Fri, 14 Feb 2025 13:40:43 +0100 Subject: [PATCH] Provisioning: Support rotating secrets (#100705) --- go.mod | 2 +- pkg/services/secrets/migrator/migrator.go | 2 +- pkg/services/secrets/migrator/provisioning.go | 187 ++++++++++++++++++ 3 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 pkg/services/secrets/migrator/provisioning.go diff --git a/go.mod b/go.mod index e2845126aa1..2dda2cc05ec 100644 --- a/go.mod +++ b/go.mod @@ -111,7 +111,7 @@ require ( github.com/influxdata/influxql v1.4.0 // @grafana/partner-datasources github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf // @grafana/grafana-app-platform-squad github.com/jmespath-community/go-jmespath v1.1.1 // @grafana/identity-access-team - github.com/jmespath/go-jmespath v0.4.0 // // @grafana/grafana-backend-group + github.com/jmespath/go-jmespath v0.4.0 // indirect; // @grafana/grafana-backend-group github.com/jmoiron/sqlx v1.3.5 // @grafana/grafana-backend-group github.com/json-iterator/go v1.1.12 // @grafana/grafana-backend-group github.com/lib/pq v1.10.9 // @grafana/grafana-backend-group diff --git a/pkg/services/secrets/migrator/migrator.go b/pkg/services/secrets/migrator/migrator.go index d0dd383a62d..e4324a14104 100644 --- a/pkg/services/secrets/migrator/migrator.go +++ b/pkg/services/secrets/migrator/migrator.go @@ -51,7 +51,7 @@ func ProvideSecretsMigrator( b64Secret{simpleSecret: simpleSecret{tableName: "user_external_session", columnName: "refresh_token"}, encoding: base64.StdEncoding}, b64Secret{simpleSecret: simpleSecret{tableName: "user_external_session", columnName: "session_id"}, encoding: base64.StdEncoding}, b64Secret{simpleSecret: simpleSecret{tableName: "user_external_session", columnName: "name_id"}, encoding: base64.StdEncoding}, - // FIXME: Rotate provisioning secrets + provisioningSecrets{}, } return &SecretsMigrator{ diff --git a/pkg/services/secrets/migrator/provisioning.go b/pkg/services/secrets/migrator/provisioning.go new file mode 100644 index 00000000000..8e4e6c3a669 --- /dev/null +++ b/pkg/services/secrets/migrator/provisioning.go @@ -0,0 +1,187 @@ +package migrator + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/encryption" + "github.com/grafana/grafana/pkg/services/secrets" + "github.com/grafana/grafana/pkg/services/secrets/manager" +) + +var errNoEncryptedValue = errors.New("no encrypted value was found") + +var _ SecretsRotator = provisioningSecrets{} + +type provisioningSecrets struct{} + +func (p provisioningSecrets) ReEncrypt(ctx context.Context, svc *manager.SecretsService, sqlStore db.DB) bool { + return p.reEncrypt(ctx, svc, func(ctx context.Context, payload []byte) ([]byte, error) { + return svc.Encrypt(ctx, payload, secrets.WithoutScope()) + }, sqlStore, "rotation") +} + +func (p provisioningSecrets) Rollback(ctx context.Context, svc *manager.SecretsService, internal encryption.Internal, sqlStore db.DB, secretKey string) bool { + return p.reEncrypt(ctx, svc, func(ctx context.Context, payload []byte) ([]byte, error) { + return internal.Encrypt(ctx, payload, secretKey) + }, sqlStore, "rollback") +} + +func (p provisioningSecrets) reEncrypt( + ctx context.Context, + svc *manager.SecretsService, + encrypt func(context.Context, []byte) ([]byte, error), + sqlStore db.DB, + action string, +) bool { + var rows []struct { + Guid string + Value []byte + } + + if err := sqlStore.WithDbSession(ctx, func(sess *db.Session) error { + return sess.Table("resource").Where("`group` = 'provisioning.grafana.app' AND `resource` = 'repositories'"). + Select("guid, value"). + Find(&rows) + }); err != nil { + logger.Warn("Could not find any provisioning secrets to re-encrypt", "error", err, "action", action) + return false + } + + var failures int + + for _, row := range rows { + var resource map[string]any + if err := json.Unmarshal(row.Value, &resource); err != nil { + logger.Error("Failed to decode resource", "guid", row.Guid, "error", err, "action", action) + failures++ + continue + } + + err := p.reEncryptGitHubToken(ctx, svc, encrypt, sqlStore, resource) + if err != nil && !errors.Is(err, errNoEncryptedValue) { + logger.Error("Failed to rotate GitHub token", "guid", row.Guid, "error", err, "action", action) + failures++ + } + update := err == nil + + err = p.reEncryptWebhookSecret(ctx, svc, encrypt, sqlStore, resource) + if err != nil && !errors.Is(err, errNoEncryptedValue) { + logger.Error("Failed to rotate webhook secret", "guid", row.Guid, "error", err, "action", action) + failures++ + } + update = update || err == nil + + if update { + // Do it... + encoded, err := json.Marshal(resource) + if err != nil { + logger.Error("Failed to marshal resource to JSON", "guid", row.Guid, "error", err, "action", action) + failures++ + continue + } + + if err := sqlStore.WithDbSession(ctx, func(sess *db.Session) error { + // TODO: Figure out what resource_version is supposed to contain, and update it. + _, err = sess.Exec("UPDATE resource SET value = ? WHERE guid = ?", string(encoded), row.Guid) + return err + }); err != nil { + logger.Error("Failed to update resource with re-encrypted values", "guid", row.Guid, "error", err, "action", action) + failures++ + } + } + } + + if failures > 0 { + logger.Warn("Failed to rotate provisioning secrets", "failures", failures, "action", action) + } else { + logger.Info("Successfully rotated provisioning secrets", "action", action) + } + return failures > 0 +} + +func (provisioningSecrets) reEncryptGitHubToken( + ctx context.Context, + svc *manager.SecretsService, + encrypt func(context.Context, []byte) ([]byte, error), + sqlStore db.DB, + obj map[string]any, +) error { + spec, _ := getCast[map[string]any](obj, "spec") + github, _ := getCast[map[string]any](spec, "github") + b64Token, ok := getCast[string](github, "encryptedToken") + if !ok { + return errNoEncryptedValue + } + + token, err := base64.StdEncoding.DecodeString(b64Token) + if err != nil { + return fmt.Errorf("failed to decode base64 encrypted token: %w", err) + } + + decrypted, err := svc.Decrypt(ctx, token) + if err != nil { + return fmt.Errorf("failed to decrypt existing encrypted token: %w", err) + } + + encrypted, err := encrypt(ctx, decrypted) + if err != nil { + return fmt.Errorf("failed to encrypt decrypted token: %w", err) + } + + b64Token = base64.StdEncoding.EncodeToString(encrypted) + github["encryptedToken"] = b64Token + return nil +} + +func (provisioningSecrets) reEncryptWebhookSecret( + ctx context.Context, + svc *manager.SecretsService, + encrypt func(context.Context, []byte) ([]byte, error), + sqlStore db.DB, + obj map[string]any, +) error { + status, _ := getCast[map[string]any](obj, "status") + webhook, _ := getCast[map[string]any](status, "webhook") + b64Token, ok := getCast[string](webhook, "encryptedSecret") + if !ok { + return errNoEncryptedValue + } + + token, err := base64.StdEncoding.DecodeString(b64Token) + if err != nil { + return fmt.Errorf("failed to decode base64 encrypted secret: %w", err) + } + + decrypted, err := svc.Decrypt(ctx, token) + if err != nil { + return fmt.Errorf("failed to decrypt existing encrypted secret: %w", err) + } + + encrypted, err := encrypt(ctx, decrypted) + if err != nil { + return fmt.Errorf("failed to encrypt decrypted secret: %w", err) + } + + b64Token = base64.StdEncoding.EncodeToString(encrypted) + webhook["encryptedSecret"] = b64Token + return nil +} + +func getCast[T any](m map[string]any, name string) (T, bool) { + if m == nil { + var zero T + return zero, false + } + anyV, ok := m[name] + if ok { + t, ok := anyV.(T) + return t, ok + } + var zero T + return zero, false +}