From d61bcdf4ca5e69489e0067c56fbe7f0bfdf84ee4 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Fri, 3 Mar 2023 14:39:53 +0000 Subject: [PATCH] Chore: Pass signed `user_hash` to Intercom via Rudderstack (#63921) * 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 --- 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 | 2 + 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, 89 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 2c4d2b8e283..66b694263f0 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -265,6 +265,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 de27e435615..a3d3f937dfb 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -272,6 +272,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 170b8d0ddb1..73112ebff8e 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -96,6 +96,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 @@ -119,6 +129,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 eec036d3517..d39a5b6fc9b 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 3865a3564ca..469655292f4 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -112,6 +112,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 966e13a72d1..989b2ecc7b4 100644 --- a/pkg/services/user/model.go +++ b/pkg/services/user/model.go @@ -193,6 +193,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"` @@ -212,6 +217,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 fc7912a5b3b..b51116fb21d 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" "encoding/json" "errors" "fmt" @@ -560,3 +563,25 @@ func (s *Service) supportBundleCollector() supportbundles.Collector { Fn: collectorFn, } } + +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 e3f95bfbd18..a4aecbe59db 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -401,6 +401,7 @@ type Cfg struct { RudderstackWriteKey string RudderstackSDKURL string RudderstackConfigURL string + IntercomSecret string // AzureAD AzureADSkipOrgRoleSync bool @@ -1034,6 +1035,7 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { cfg.RudderstackDataPlaneURL = analytics.Key("rudderstack_data_plane_url").String() cfg.RudderstackSDKURL = analytics.Key("rudderstack_sdk_url").String() cfg.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');