From 4ee064da111ca7a1e73f26dbed88613b78534e8c Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Fri, 3 Mar 2023 15:06:13 +0000 Subject: [PATCH] Chore: Pass signed `user_hash` to Intercom via Rudderstack (#63921) (#64145) * move analytics identifiers to backend * implement hash function * grab secret from env * expose and retrieve intercom secret from config * concat email with appUrl to ensure uniqueness * revert to just using email * Revert "revert to just using email" This reverts commit 8f10f9b1bcb6da80c8002cd8e402217cf455634b. * add docstring (cherry picked from commit d61bcdf4ca5e69489e0067c56fbe7f0bfdf84ee4) --- conf/defaults.ini | 3 ++ conf/sample.ini | 3 ++ packages/grafana-data/src/types/config.ts | 11 +++++ packages/grafana-data/src/types/index.ts | 1 + pkg/api/dtos/models.go | 6 +++ pkg/api/index.go | 4 ++ pkg/services/user/model.go | 6 +++ pkg/services/user/userimpl/store.go | 2 + pkg/services/user/userimpl/user.go | 25 +++++++++++ pkg/setting/setting.go | 3 ++ public/app/core/services/context_srv.ts | 6 ++- .../echo/backends/analytics/GA4Backend.ts | 4 +- .../backends/analytics/RudderstackBackend.ts | 27 ++++++++---- public/app/core/services/echo/utils.test.ts | 41 ------------------- public/app/core/services/echo/utils.ts | 14 ------- 15 files changed, 90 insertions(+), 66 deletions(-) delete mode 100644 public/app/core/services/echo/utils.test.ts diff --git a/conf/defaults.ini b/conf/defaults.ini index f146b5bda8e..7329abd3d3d 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -262,6 +262,9 @@ rudderstack_sdk_url = # Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config rudderstack_config_url = +# Intercom secret, optional, used to hash user_id before passing to Intercom via Rudderstack +intercom_secret = + # Application Insights connection string. Specify an URL string to enable this feature. application_insights_connection_string = diff --git a/conf/sample.ini b/conf/sample.ini index c70dd660b41..6fb853a213a 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -269,6 +269,9 @@ # Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config ;rudderstack_config_url = +# Intercom secret, optional, used to hash user_id before passing to Intercom via Rudderstack +;intercom_secret = + # Controls if the UI contains any links to user feedback forms ;feedback_links_enabled = true diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index d4c9381f6f7..2eddf5af5cd 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -106,6 +106,16 @@ export type OAuth = */ export type OAuthSettings = Partial>; +/** + * Information needed for analytics providers + * + * @internal + */ +export interface AnalyticsSettings { + identifier: string; + intercomIdentifier?: string; +} + /** Current user info included in bootData * * @internal @@ -129,6 +139,7 @@ export interface CurrentUserDTO { locale: string; language: string; permissions?: Record; + analytics: AnalyticsSettings; /** @deprecated Use theme instead */ lightTheme: boolean; diff --git a/packages/grafana-data/src/types/index.ts b/packages/grafana-data/src/types/index.ts index 0c55fe6879e..79a1a9baa16 100644 --- a/packages/grafana-data/src/types/index.ts +++ b/packages/grafana-data/src/types/index.ts @@ -38,6 +38,7 @@ export * from './geometry'; export { isUnsignedPluginSignature } from './pluginSignature'; export type { CurrentUserDTO, + AnalyticsSettings, BootData, OAuth, OAuthSettings, diff --git a/pkg/api/dtos/models.go b/pkg/api/dtos/models.go index 9394ca0c877..8339940ac78 100644 --- a/pkg/api/dtos/models.go +++ b/pkg/api/dtos/models.go @@ -48,6 +48,12 @@ type CurrentUser struct { HelpFlags1 user.HelpFlags1 `json:"helpFlags1"` HasEditPermissionInFolders bool `json:"hasEditPermissionInFolders"` Permissions UserPermissionsMap `json:"permissions,omitempty"` + Analytics AnalyticsSettings `json:"analytics"` +} + +type AnalyticsSettings struct { + Identifier string `json:"identifier"` + IntercomIdentifier string `json:"intercomIdentifier,omitempty"` } type UserPermissionsMap map[string]bool diff --git a/pkg/api/index.go b/pkg/api/index.go index 2aee274b792..52d38d9c86c 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -109,6 +109,10 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV Language: language, HelpFlags1: c.HelpFlags1, HasEditPermissionInFolders: hasEditPerm, + Analytics: dtos.AnalyticsSettings{ + Identifier: c.SignedInUser.Analytics.Identifier, + IntercomIdentifier: c.SignedInUser.Analytics.IntercomIdentifier, + }, }, Settings: settings, Theme: prefs.Theme, diff --git a/pkg/services/user/model.go b/pkg/services/user/model.go index 88951c1cc5a..a7904604c9a 100644 --- a/pkg/services/user/model.go +++ b/pkg/services/user/model.go @@ -192,6 +192,11 @@ type GetSignedInUserQuery struct { OrgID int64 `xorm:"org_id"` } +type AnalyticsSettings struct { + Identifier string + IntercomIdentifier string +} + type SignedInUser struct { UserID int64 `xorm:"user_id"` OrgID int64 `xorm:"org_id"` @@ -211,6 +216,7 @@ type SignedInUser struct { HelpFlags1 HelpFlags1 LastSeenAt time.Time Teams []int64 + Analytics AnalyticsSettings // Permissions grouped by orgID and actions Permissions map[int64]map[string][]string `json:"-"` } diff --git a/pkg/services/user/userimpl/store.go b/pkg/services/user/userimpl/store.go index d9c460f9302..f2f9355ddfe 100644 --- a/pkg/services/user/userimpl/store.go +++ b/pkg/services/user/userimpl/store.go @@ -432,6 +432,8 @@ func (ss *sqlStore) GetSignedInUser(ctx context.Context, query *user.GetSignedIn if signedInUser.ExternalAuthModule != "oauth_grafana_com" { signedInUser.ExternalAuthID = "" } + + signedInUser.Analytics = buildUserAnalyticsSettings(signedInUser, ss.cfg.IntercomSecret) return nil }) return &signedInUser, err diff --git a/pkg/services/user/userimpl/user.go b/pkg/services/user/userimpl/user.go index 7551fc22f5e..0024b7214b8 100644 --- a/pkg/services/user/userimpl/user.go +++ b/pkg/services/user/userimpl/user.go @@ -2,6 +2,9 @@ package userimpl import ( "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "strings" @@ -547,3 +550,25 @@ func (s *Service) CreateServiceAccount(ctx context.Context, cmd *user.CreateUser } return usr, nil } + +func hashUserIdentifier(identifier string, secret string) string { + key := []byte(secret) + h := hmac.New(sha256.New, key) + h.Write([]byte(identifier)) + return hex.EncodeToString(h.Sum(nil)) +} + +func buildUserAnalyticsSettings(signedInUser user.SignedInUser, intercomSecret string) user.AnalyticsSettings { + var settings user.AnalyticsSettings + + if signedInUser.ExternalAuthID != "" { + settings.Identifier = signedInUser.ExternalAuthID + } else { + settings.Identifier = signedInUser.Email + "@" + setting.AppUrl + } + + if intercomSecret != "" { + settings.IntercomIdentifier = hashUserIdentifier(settings.Identifier, intercomSecret) + } + return settings +} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 4734f5c12c1..2dfc9fc9ea5 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -425,6 +425,8 @@ type Cfg struct { ApplicationInsightsEndpointUrl string FeedbackLinksEnabled bool + // Frontend analytics + IntercomSecret string // AzureAD AzureADSkipOrgRoleSync bool @@ -1042,6 +1044,7 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { RudderstackDataPlaneUrl = analytics.Key("rudderstack_data_plane_url").String() RudderstackSdkUrl = analytics.Key("rudderstack_sdk_url").String() RudderstackConfigUrl = analytics.Key("rudderstack_config_url").String() + cfg.IntercomSecret = analytics.Key("intercom_secret").String() cfg.ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true) cfg.ReportingDistributor = analytics.Key("reporting_distributor").MustString("grafana-labs") diff --git a/public/app/core/services/context_srv.ts b/public/app/core/services/context_srv.ts index 4a7a5fddbe4..49171bffca0 100644 --- a/public/app/core/services/context_srv.ts +++ b/public/app/core/services/context_srv.ts @@ -1,6 +1,6 @@ import { extend } from 'lodash'; -import { OrgRole, rangeUtil, WithAccessControlMetadata } from '@grafana/data'; +import { AnalyticsSettings, OrgRole, rangeUtil, WithAccessControlMetadata } from '@grafana/data'; import { featureEnabled, getBackendSrv } from '@grafana/runtime'; import { AccessControlAction, UserPermission } from 'app/types'; import { CurrentUserInternal } from 'app/types/config'; @@ -28,6 +28,7 @@ export class User implements Omit { helpFlags1: number; hasEditPermissionInFolders: boolean; permissions?: UserPermission; + analytics: AnalyticsSettings; fiscalYearStartMonth: number; constructor() { @@ -51,6 +52,9 @@ export class User implements Omit { this.language = ''; this.weekStart = ''; this.gravatarUrl = ''; + this.analytics = { + identifier: '', + }; if (config.bootData.user) { extend(this, config.bootData.user); diff --git a/public/app/core/services/echo/backends/analytics/GA4Backend.ts b/public/app/core/services/echo/backends/analytics/GA4Backend.ts index 8a822f5d30d..0319c6eabe6 100644 --- a/public/app/core/services/echo/backends/analytics/GA4Backend.ts +++ b/public/app/core/services/echo/backends/analytics/GA4Backend.ts @@ -1,7 +1,7 @@ import { CurrentUserDTO } from '@grafana/data'; import { EchoBackend, EchoEventType, PageviewEchoEvent } from '@grafana/runtime'; -import { getUserIdentifier, loadScript } from '../../utils'; +import { loadScript } from '../../utils'; declare global { interface Window { @@ -34,7 +34,7 @@ export class GA4EchoBackend implements EchoBackend { - it('should return the external user ID (gcom ID) if available', () => { - const id = getUserIdentifier(gcomUser); - expect(id).toBe('abc-123'); - }); - - it('should fall back to the email address', () => { - const id = getUserIdentifier(baseUser); - expect(id).toBe('email@example.com'); - }); -}); diff --git a/public/app/core/services/echo/utils.ts b/public/app/core/services/echo/utils.ts index 99fb0f3bc42..6117c9d66c6 100644 --- a/public/app/core/services/echo/utils.ts +++ b/public/app/core/services/echo/utils.ts @@ -1,19 +1,5 @@ -import { CurrentUserDTO } from '@grafana/data'; import { attachDebugger, createLogger } from '@grafana/ui'; -/** - * Returns an opaque identifier for a user, for reporting purposes. - * Because this is for use when reporting across multiple Grafana installations - * It cannot simply be user.id because that's not unique across two installations. - */ -export function getUserIdentifier(user: CurrentUserDTO) { - if (user.externalUserId.length) { - return user.externalUserId; - } - - return user.email; -} - export function loadScript(url: string, async = false) { return new Promise((resolve) => { const script = document.createElement('script');