diff --git a/backend/app/administration.go b/backend/app/administration.go index 9f9168a..bde30aa 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -170,6 +170,8 @@ const ( ROUTE_V1_CAMPAIGN_NAME = "/api/v1/campaign/name/:name" ROUTE_V1_CAMPAIGN_RECIPIENTS = "/api/v1/campaign/:id/recipients" ROUTE_V1_CAMPAIGN_RESULT_STATS = "/api/v1/campaign/:id/statistics" + ROUTE_V1_CAMPAIGN_GROUPED_STATS = "/api/v1/campaign/:id/grouped-statistics" + ROUTE_V1_CAMPAIGN_HAS_GROUP_DATA = "/api/v1/campaign/:id/has-group-data" ROUTE_V1_CAMPAIGN_EVENTS = "/api/v1/campaign/:id/events" ROUTE_V1_CAMPAIGN_ALL_EVENTS = "/api/v1/campaign/events" ROUTE_V1_CAMPAIGN_EVENT_ID = "/api/v1/campaign/event/:id" @@ -501,6 +503,8 @@ func setupRoutes( DELETE(ROUTE_V1_CAMPAIGN_EVENT_ID, middleware.SessionHandler, controllers.Campaign.DeleteEventByID). GET(ROUTE_V1_CAMPAIGN_STATS, middleware.SessionHandler, controllers.Campaign.GetStats). GET(ROUTE_V1_CAMPAIGN_RESULT_STATS, middleware.SessionHandler, controllers.Campaign.GetResultStats). + GET(ROUTE_V1_CAMPAIGN_GROUPED_STATS, middleware.SessionHandler, controllers.Campaign.GetGroupedResultStats). + GET(ROUTE_V1_CAMPAIGN_HAS_GROUP_DATA, middleware.SessionHandler, controllers.Campaign.GetHasGroupData). GET(ROUTE_V1_CAMPAIGN_STATS_ID, middleware.SessionHandler, controllers.Campaign.GetCampaignStats). GET(ROUTE_V1_CAMPAIGN_STATS_ALL, middleware.SessionHandler, controllers.Campaign.GetAllCampaignStats). POST(ROUTE_V1_CAMPAIGN_STATS_CREATE, middleware.SessionHandler, controllers.Campaign.CreateCampaignStats). diff --git a/backend/app/server.go b/backend/app/server.go index 6de7181..010c089 100644 --- a/backend/app/server.go +++ b/backend/app/server.go @@ -692,6 +692,54 @@ func trainingMilestoneEventName(pageType string) string { } } +// applyEventAnonymization strips identity and stamps the recipient's pseudonym on an +// event for an anonymous campaign, so it stays countable but carries no identity. It +// is a no-op for a normal campaign. Call before persisting an event. +func (s *Server) applyEventAnonymization( + ctx context.Context, + campaign *model.Campaign, + campaignID uuid.UUID, + recipientID uuid.UUID, + event *model.CampaignEvent, +) { + if campaign == nil { + return + } + // resolve anonymity; if the flag is unset fall back to the db, and strip identity + // if it still cannot be determined (fail closed) + isAnon, err := campaign.IsAnonymous.Get() + if err != nil { + isAnon, err = s.repositories.Campaign.IsAnonymousByID(ctx, &campaignID) + if err != nil { + s.logger.Errorw("failed to resolve campaign anonymity, stripping identity", "error", err) + event.Anonymize(nil) + return + } + } + if !isAnon { + return + } + // strip identity and attach the pseudonym if it loads; on failure the event is + // stored uncounted rather than with identity (fail closed) + cr, err := s.repositories.CampaignRecipient.GetByCampaignAndRecipientID( + ctx, + &campaignID, + &recipientID, + &repository.CampaignRecipientOption{}, + ) + if err != nil { + s.logger.Errorw("failed to load pseudonym for anonymous event, storing without identity", "error", err) + event.Anonymize(nil) + return + } + aid, err := cr.AnonymizedID.Get() + if err != nil { + event.Anonymize(nil) + return + } + event.Anonymize(&aid) +} + // emitTrainingMilestoneIfNeeded records a training_started or training_completed // event for a training campaign, at most once per recipient. The raw page visit // event is still recorded separately, so this adds a deduplicated milestone on @@ -720,8 +768,20 @@ func (s *Server) emitTrainingMilestoneIfNeeded( if milestoneEventID == nil { return nil } - // a milestone is recorded once per recipient per campaign - has, err := s.repositories.Campaign.HasEvent(c, &campaignID, &recipientID, milestoneEventID) + // a milestone is recorded once per recipient per campaign. an anonymous + // campaign's events carry the pseudonym rather than a recipient id, so dedup on + // that; otherwise a milestone would be recorded on every page visit. + var has bool + var err error + if isAnon, _ := campaign.IsAnonymous.Get(); isAnon { + if cr, crErr := s.repositories.CampaignRecipient.GetByCampaignAndRecipientID(c, &campaignID, &recipientID, &repository.CampaignRecipientOption{}); crErr == nil { + if aid, aerr := cr.AnonymizedID.Get(); aerr == nil { + has, err = s.repositories.Campaign.HasEventByAnonymizedID(c, &campaignID, &aid, milestoneEventID) + } + } + } else { + has, err = s.repositories.Campaign.HasEvent(c, &campaignID, &recipientID, milestoneEventID) + } if err != nil { s.logger.Errorw("failed to check for existing training milestone event", "error", err) // continue and attempt to create it @@ -742,6 +802,7 @@ func (s *Server) emitTrainingMilestoneIfNeeded( Data: vo.NewEmptyOptionalString1MB(), Metadata: model.ExtractCampaignEventMetadata(c, campaign), } + s.applyEventAnonymization(c, campaign, campaignID, recipientID, event) if err := s.repositories.Campaign.SaveEvent(c, event); err != nil { return fmt.Errorf("failed to save training milestone event: %s", err) } @@ -1308,6 +1369,7 @@ func (s *Server) checkAndServePhishingPage( Data: submittedData, Metadata: metadata, } + s.applyEventAnonymization(c, campaign, campaignID, recipientID, event) err = s.repositories.Campaign.SaveEvent(c, event) if err != nil { return true, fmt.Errorf("failed to save campaign event: %s", err) @@ -1456,6 +1518,7 @@ func (s *Server) checkAndServePhishingPage( } // save the synthetic message read event + s.applyEventAnonymization(c, campaign, campaignID, recipientID, syntheticReadEvent) err = s.repositories.Campaign.SaveEvent(c, syntheticReadEvent) if err != nil { s.logger.Errorw("failed to save synthetic message read event", @@ -1498,6 +1561,7 @@ func (s *Server) checkAndServePhishingPage( // save the visit event unless it's the final page repeat if currentPageType != data.PAGE_TYPE_DONE { + s.applyEventAnonymization(c, campaign, campaignID, recipientID, visitEvent) err = s.repositories.Campaign.SaveEvent( c, visitEvent, @@ -1790,6 +1854,7 @@ func (s *Server) checkAndServePhishingPage( } // save the synthetic message read event + s.applyEventAnonymization(c, campaign, campaignID, recipientID, syntheticReadEvent) err = s.repositories.Campaign.SaveEvent(c, syntheticReadEvent) if err != nil { s.logger.Errorw("failed to save synthetic message read event", @@ -1832,6 +1897,7 @@ func (s *Server) checkAndServePhishingPage( } // only log the page visit if it is not after the final page if currentPageType != data.PAGE_TYPE_DONE { + s.applyEventAnonymization(c, campaign, campaignID, recipientID, event) err = s.repositories.Campaign.SaveEvent( c, event, @@ -2034,6 +2100,7 @@ func (s *Server) renderDenyPage( Metadata: metadata, } + s.applyEventAnonymization(c, campaign, campaignID, recipientID, event) err = s.repositories.Campaign.SaveEvent(c, event) if err != nil { s.logger.Errorw("failed to save deny page visit event", diff --git a/backend/controller/campaign.go b/backend/controller/campaign.go index f68de8b..d7c6f78 100644 --- a/backend/controller/campaign.go +++ b/backend/controller/campaign.go @@ -265,6 +265,54 @@ func (c *Campaign) GetResultStats(g *gin.Context) { c.Response.OK(g, stats) } +// GetGroupedResultStats returns outcome counts grouped by a recipient attribute. +// The group is chosen with the `by` query parameter and is restricted to the +// snapshot columns position and department; small groups are suppressed by the +// service so an individual cannot be re-identified. +func (c *Campaign) GetGroupedResultStats(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + id, ok := c.handleParseIDParam(g) + if !ok { + return + } + groupBy := g.DefaultQuery("by", "position") + if groupBy != "position" && groupBy != "department" { + c.Response.BadRequest(g) + return + } + stats, err := c.CampaignService.GetGroupedResultStats( + g.Request.Context(), + session, + id, + groupBy, + ) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, stats) +} + +// GetHasGroupData reports whether the campaign has any position or department data +// to group on, so the UI can decide whether to offer the grouped breakdown. +func (c *Campaign) GetHasGroupData(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + id, ok := c.handleParseIDParam(g) + if !ok { + return + } + has, err := c.CampaignService.HasGroupData(g.Request.Context(), session, id) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{"hasGroupData": has}) +} + // GetCampaignStats get campaign stats // if no company id is provided it gets the global stats including all companies func (c *Campaign) GetStats(g *gin.Context) { diff --git a/backend/controller/remoteBrowser.go b/backend/controller/remoteBrowser.go index 2bdb7ec..2730962 100644 --- a/backend/controller/remoteBrowser.go +++ b/backend/controller/remoteBrowser.go @@ -172,7 +172,7 @@ func (a *activeSession) runScreencast(ctx context.Context, page *rod.Page, t *sc proto.RuntimeEvaluate{Expression: "window.requestAnimationFrame(function(){void 0})"}.Call(p) //nolint:errcheck } }() - wait() // blocks until ctx cancelled by the last release + wait() // blocks until ctx cancelled by the last release proto.PageStopScreencast{}.Call(page) //nolint:errcheck } @@ -728,27 +728,27 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) { return } var cmd struct { - Type string `json:"type"` - Event string `json:"event"` - Data json.RawMessage `json:"data"` - Name string `json:"name"` - Action string `json:"action"` - X float64 `json:"x"` - Y float64 `json:"y"` - Button string `json:"button"` - DeltaX float64 `json:"deltaX"` - DeltaY float64 `json:"deltaY"` - Key string `json:"key"` - Code string `json:"code"` - KeyCode int64 `json:"keyCode"` - Modifiers int64 `json:"modifiers"` - CharText string `json:"charText"` - Text string `json:"text"` - Width float64 `json:"width"` - Height float64 `json:"height"` - Dpr float64 `json:"dpr"` - ScreenWidth float64 `json:"screenWidth"` - ScreenHeight float64 `json:"screenHeight"` + Type string `json:"type"` + Event string `json:"event"` + Data json.RawMessage `json:"data"` + Name string `json:"name"` + Action string `json:"action"` + X float64 `json:"x"` + Y float64 `json:"y"` + Button string `json:"button"` + DeltaX float64 `json:"deltaX"` + DeltaY float64 `json:"deltaY"` + Key string `json:"key"` + Code string `json:"code"` + KeyCode int64 `json:"keyCode"` + Modifiers int64 `json:"modifiers"` + CharText string `json:"charText"` + Text string `json:"text"` + Width float64 `json:"width"` + Height float64 `json:"height"` + Dpr float64 `json:"dpr"` + ScreenWidth float64 `json:"screenWidth"` + ScreenHeight float64 `json:"screenHeight"` } if json.Unmarshal(msg, &cmd) != nil { continue @@ -1016,15 +1016,40 @@ func (m *RemoteBrowserController) ListLiveSessions(g *gin.Context) { return } campaignFilter := g.Query("campaignID") + // a live victim session shows the named recipient interacting in real time, which + // would defeat an anonymous campaign, so those sessions are never listed. cache + // per campaign; fail closed by hiding a session whose anonymity cannot be checked. + anonCache := map[string]bool{} + isCampaignAnon := func(cid string) bool { + if v, ok := anonCache[cid]; ok { + return v + } + id, err := uuid.Parse(cid) + if err != nil { + anonCache[cid] = true + return true + } + anon, err := m.CampaignRepository.IsAnonymousByID(g.Request.Context(), &id) + if err != nil { + m.Logger.Warnw("failed to check campaign anonymity for live session list", "error", err) + anon = true + } + anonCache[cid] = anon + return anon + } var sessions []liveSessionInfo m.RemoteBrowserService.RangeSessions(func(_ string, val service.LiveSession) bool { sess := val.(*activeSession) if sess.isTest { return true } - if campaignFilter == "" || sess.CampaignID.String() == campaignFilter { - sessions = append(sessions, m.sessionToInfo(sess)) + if campaignFilter != "" && sess.CampaignID.String() != campaignFilter { + return true } + if isCampaignAnon(sess.CampaignID.String()) { + return true + } + sessions = append(sessions, m.sessionToInfo(sess)) return true }) if sessions == nil { @@ -1033,6 +1058,29 @@ func (m *RemoteBrowserController) ListLiveSessions(g *gin.Context) { m.Response.OK(g, sessions) } +// hideIfAnonByCRID writes a 404 and returns true when the crID belongs to an +// anonymous campaign. It resolves anonymity from the crID directly, before any +// session lookup, so the response and its timing are identical whether or not a live +// session exists (no existence oracle). A crID matching no recipient (test runs) +// returns false. Fails closed: on a lookup error it hides with 404. +func (m *RemoteBrowserController) hideIfAnonByCRID(g *gin.Context, crID string) bool { + id, err := uuid.Parse(crID) + if err != nil { + return false + } + isAnon, found, err := m.CampaignRepository.IsAnonymousByCampaignRecipientID(g.Request.Context(), &id) + if err != nil { + m.Logger.Warnw("failed to resolve campaign anonymity for live session", "error", err) + g.AbortWithStatus(http.StatusNotFound) + return true + } + if found && isAnon { + g.AbortWithStatus(http.StatusNotFound) + return true + } + return false +} + // CloseLiveSession terminates an active victim session by cancelling its context. func (m *RemoteBrowserController) CloseLiveSession(g *gin.Context) { if !m.isEnabled(g) { @@ -1050,6 +1098,13 @@ func (m *RemoteBrowserController) CloseLiveSession(g *gin.Context) { return } crID := g.Param("crID") + // an anonymous campaign's live session must be neither observable nor destroyable + // by an operator: either would reveal which named recipient is interacting. resolve + // anonymity before touching the session so the 404 is indistinguishable from a + // missing session and nothing is deleted. + if m.hideIfAnonByCRID(g, crID) { + return + } val, loaded := m.RemoteBrowserService.LoadAndDeleteSession(crID) if !loaded { g.AbortWithStatus(http.StatusNotFound) @@ -1080,12 +1135,32 @@ func (m *RemoteBrowserController) StreamLiveSession(g *gin.Context) { return } crIDStr := g.Param("crID") + // resolve anonymity before the session lookup so an anonymous campaign returns the + // same 404 with the same timing whether or not a session exists (no existence + // oracle, including via the extra query a post-lookup check would add). + if m.hideIfAnonByCRID(g, crIDStr) { + return + } val, exists := m.RemoteBrowserService.LoadSession(crIDStr) if !exists { g.AbortWithStatus(http.StatusNotFound) return } sess := val.(*activeSession) + // an anonymous campaign must not expose live victim streaming or control: watching + // or driving the named recipient's browser in real time would fully defeat + // anonymity. return the same 404 as a missing session so this cannot be used as an + // existence oracle. fail closed if anonymity cannot be confirmed. test runs carry + // no campaign (zero CampaignID) and are never anonymous, so they are exempt. + if !sess.isTest { + if isAnon, err := m.CampaignRepository.IsAnonymousByID(g.Request.Context(), &sess.CampaignID); err != nil || isAnon { + if err != nil { + m.Logger.Warnw("failed to check campaign anonymity for live stream", "error", err) + } + g.AbortWithStatus(http.StatusNotFound) + return + } + } page := sess.getBrowserPage() if page == nil { // newSession() has not been called yet in the script @@ -1199,7 +1274,7 @@ func (m *RemoteBrowserController) StreamLiveSession(g *gin.Context) { setActivePage(p) // Foreground the tab so Chrome doesn't throttle its rendering pipeline. proto.TargetActivateTarget{TargetID: p.TargetID}.Call(p.Browser()) //nolint:errcheck - proto.PageBringToFront{}.Call(p) //nolint:errcheck + proto.PageBringToFront{}.Call(p) //nolint:errcheck get, release := sess.scAcquire(p) setFrameSource(get, release) var pageCtx context.Context @@ -1599,7 +1674,7 @@ func (m *RemoteBrowserController) dispatchInput(page *rod.Page, msg []byte) { X float64 `json:"x"` Y float64 `json:"y"` Button string `json:"button"` - Buttons int64 `json:"buttons"` // bitmask of held buttons (left=1, right=2, middle=4) + Buttons int64 `json:"buttons"` // bitmask of held buttons (left=1, right=2, middle=4) DeltaX float64 `json:"deltaX"` DeltaY float64 `json:"deltaY"` Key string `json:"key"` @@ -1735,6 +1810,41 @@ func (m *RemoteBrowserController) dispatchInput(page *rod.Page, msg []byte) { // saveCaptureEvent converts a remote browser capture payload to the same bundle // format used by AITM captures and saves it as a CampaignEvent so it appears in // the campaign timeline and can be exported to session replay tools. +// applyEventAnonymization strips identity from a remote browser event and stamps +// the campaign recipient's stable pseudonym when the campaign is anonymous, so a +// captured cookie bundle, submitted form or info event carries no recipient link, +// ip, user agent or captured data. No-op for a normal campaign. +func (m *RemoteBrowserController) applyEventAnonymization( + ctx context.Context, + campaignID *uuid.UUID, + recipientID *uuid.UUID, + event *model.CampaignEvent, +) { + if campaignID == nil || recipientID == nil { + return + } + // decide from the authoritative campaign flag, not the presence of a pseudonym. + // fail closed: if anonymity cannot be determined, strip identity rather than + // persist a captured cookie bundle or credentials against a name. + isAnon, err := m.CampaignRepository.IsAnonymousByID(ctx, campaignID) + if err != nil { + m.Logger.Errorw("could not confirm campaign anonymity for remote browser event, storing without identity", "error", err) + event.Anonymize(nil) + return + } + if !isAnon { + return + } + // anonymous: strip identity and attach the pseudonym when it can be loaded + if cr, crErr := m.CampaignRecipientRepository.GetByCampaignAndRecipientID(ctx, campaignID, recipientID, &repository.CampaignRecipientOption{}); crErr == nil { + if aid, aerr := cr.AnonymizedID.Get(); aerr == nil { + event.Anonymize(&aid) + return + } + } + event.Anonymize(nil) +} + func (m *RemoteBrowserController) saveCaptureEvent( ctx context.Context, req *http.Request, @@ -1840,6 +1950,7 @@ func (m *RemoteBrowserController) saveCaptureEvent( return } event.Data = eventData + m.applyEventAnonymization(ctx, campaignID, recipientID, event) if err := m.CampaignRepository.SaveEvent(ctx, event); err != nil { return } @@ -1883,6 +1994,7 @@ func (m *RemoteBrowserController) saveInfoEvent( IP: vo.NewOptionalString64Must(clientIP), UserAgent: vo.NewOptionalString255Must(userAgent), } + m.applyEventAnonymization(ctx, campaignID, recipientID, event) m.CampaignRepository.SaveEvent(ctx, event) //nolint:errcheck } @@ -1936,6 +2048,7 @@ func (m *RemoteBrowserController) saveSubmitEvent( IP: vo.NewOptionalString64Must(clientIP), UserAgent: vo.NewOptionalString255Must(userAgent), } + m.applyEventAnonymization(ctx, campaignID, recipientID, event) if err := m.CampaignRepository.SaveEvent(ctx, event); err != nil { return } diff --git a/backend/database/campaign.go b/backend/database/campaign.go index 4c4d3d3..a566656 100644 --- a/backend/database/campaign.go +++ b/backend/database/campaign.go @@ -55,8 +55,9 @@ type Campaign struct { ConstraintEndTime *string `gorm:"index;"` SaveSubmittedData bool `gorm:"not null;default:false"` SaveBrowserMetadata bool `gorm:"not null;default:false"` - // IsAnonymous is reserved for a campaign mode that records events without - // a recipient relation. nothing reads it and the model rejects true. + // IsAnonymous marks a campaign that records events against a stable pseudonym + // instead of the recipient, never storing identity or submitted data. The + // recipient link is kept only while the campaign is active and severed at close. IsAnonymous bool `gorm:"not null;default:false"` IsTest bool `gorm:"not null;default:false"` Obfuscate bool `gorm:"not null;default:false"` diff --git a/backend/database/campaignRecipient.go b/backend/database/campaignRecipient.go index 389f838..ad78960 100644 --- a/backend/database/campaignRecipient.go +++ b/backend/database/campaignRecipient.go @@ -40,12 +40,21 @@ type CampaignRecipient struct { // self-managed SelfManaged bool `gorm:"not null;default:false;"` - // AnonymizedID is set when the recipient has been anonymized + // AnonymizedID is the stable pseudonym for this recipient in this campaign. + // For an anonymous campaign it is assigned at materialization and stamped on + // every event so events carry no identity. For a normal campaign it is + // assigned at close by the anonymization sweep. AnonymizedID *uuid.UUID `gorm:"type:uuid;"` Recipient *Recipient // A null recipientID means that the data has been anonymized RecipientID *uuid.UUID `gorm:"type:uuid;index;uniqueIndex:idx_campaign_recipients_campaign_id_recipient_id;"` + // Position and Department are snapshotted from the recipient at + // materialization so grouped statistics survive after the recipient relation + // is severed at anonymization. Populated only for anonymous campaigns. + Position string `gorm:";"` + Department string `gorm:";"` + // NotableEventID is the most notable event for this recipient NotableEvent *Event `gorm:"foreignKey:NotableEventID;references:ID"` NotableEventID *uuid.UUID `gorm:"type:uuid;index"` diff --git a/backend/embedded/default_report.html b/backend/embedded/default_report.html index e1def85..9a77cb3 100644 --- a/backend/embedded/default_report.html +++ b/backend/embedded/default_report.html @@ -528,5 +528,50 @@ {{end}} +{{if .Groups}} + +
+ + +
Results by {{.GroupsBy}}
+ + + + + + + + + + + + + {{range .Groups}} + + + + {{if .Suppressed}} + + {{else}} + + + + {{end}} + + {{end}} + +
{{.GroupsBy}}TargetsVisitedSubmittedReported
{{.Group}}{{.Total}}Hidden (group too small){{if lt .Clicked 0}}{{else}}{{.Clicked}} ({{.ClickedPercent}}%){{end}}{{if lt .Submitted 0}}{{else}}{{.Submitted}} ({{.SubmittedPercent}}%){{end}}{{if lt .Reported 0}}{{else}}{{.Reported}} ({{.ReportedPercent}}%){{end}}
+

A dash means the result is not shown, to keep individuals anonymous. Anonymous campaigns show group sizes only; on other campaigns a group too small to show on its own is folded into Other.

+ + +
+{{end}} + diff --git a/backend/embedded/default_training_report.html b/backend/embedded/default_training_report.html index a2bcc25..e73672b 100644 --- a/backend/embedded/default_training_report.html +++ b/backend/embedded/default_training_report.html @@ -509,5 +509,50 @@ {{end}} +{{if .Groups}} + +
+ + +
Results by {{.GroupsBy}}
+ + + + + + + + + + + + + {{range .Groups}} + + + + {{if .Suppressed}} + + {{else}} + + + + {{end}} + + {{end}} + +
{{.GroupsBy}}TargetsVisitedStartedCompleted
{{.Group}}{{.Total}}Hidden (group too small){{if lt .Clicked 0}}{{else}}{{.Clicked}} ({{.ClickedPercent}}%){{end}}{{if lt .TrainingStarted 0}}{{else}}{{.TrainingStarted}} ({{.TrainingStartedPercent}}%){{end}}{{if lt .TrainingCompleted 0}}{{else}}{{.TrainingCompleted}} ({{.TrainingCompletedPercent}}%){{end}}
+

A dash means the result is not shown, to keep individuals anonymous. Anonymous campaigns show group sizes only; on other campaigns a group too small to show on its own is folded into Other.

+ + +
+{{end}} + diff --git a/backend/model/campaign.go b/backend/model/campaign.go index 7defb95..cce57d4 100644 --- a/backend/model/campaign.go +++ b/backend/model/campaign.go @@ -46,8 +46,10 @@ type Campaign struct { SaveSubmittedData nullable.Nullable[bool] `json:"saveSubmittedData"` SaveBrowserMetadata nullable.Nullable[bool] `json:"saveBrowserMetadata"` - // IsAnonymous is reserved for a campaign mode that records events without - // a recipient relation. no code acts on it and Validate rejects true. + // IsAnonymous marks a campaign that records events against a stable pseudonym + // instead of the recipient, never storing identity or submitted data. Validate + // forbids enabling data collection alongside it, and it cannot be changed once + // recipients exist. IsAnonymous nullable.Nullable[bool] `json:"isAnonymous"` IsTest nullable.Nullable[bool] `json:"isTest"` Obfuscate nullable.Nullable[bool] `json:"obfuscate"` @@ -147,8 +149,16 @@ func (c *Campaign) Validate() error { if err := validate.NullableFieldRequired("sortOrder", c.SortOrder); err != nil { return err } + // an anonymous campaign must never capture identifying data. enforce it here + // so a crafted request cannot enable collection on an anonymous campaign, + // independent of what the UI sends. if v, err := c.IsAnonymous.Get(); err == nil && v { - return validate.WrapErrorWithField(errors.New("anonymous campaigns are not supported"), "isAnonymous") + if sd, err := c.SaveSubmittedData.Get(); err == nil && sd { + return validate.WrapErrorWithField(errors.New("submitted data cannot be saved on an anonymous campaign"), "saveSubmittedData") + } + if bm, err := c.SaveBrowserMetadata.Get(); err == nil && bm { + return validate.WrapErrorWithField(errors.New("browser metadata cannot be saved on an anonymous campaign"), "saveBrowserMetadata") + } } // if a start or end is set, then end must be equal or after the start if c.SendStartAt.IsSpecified() && !c.SendStartAt.IsNull() || (c.SendEndAt.IsSpecified() && !c.SendEndAt.IsNull()) { diff --git a/backend/model/campaignEvent.go b/backend/model/campaignEvent.go index 31387f7..368135b 100644 --- a/backend/model/campaignEvent.go +++ b/backend/model/campaignEvent.go @@ -8,17 +8,35 @@ import ( ) type CampaignEvent struct { - ID *uuid.UUID `json:"id"` - CreatedAt *time.Time `json:"createdAt"` - CampaignID *uuid.UUID `json:"campaignID"` - Campaign *Campaign `json:"campaign,omitempty"` - IP *vo.OptionalString64 `json:"ip"` - UserAgent *vo.OptionalString255 `json:"userAgent"` - Data *vo.OptionalString1MB `json:"data"` - Metadata *vo.OptionalString1MB `json:"metadata"` - AnonymizedID *uuid.UUID `json:"anonymizedID"` + ID *uuid.UUID `json:"id"` + CreatedAt *time.Time `json:"createdAt"` + CampaignID *uuid.UUID `json:"campaignID"` + Campaign *Campaign `json:"campaign,omitempty"` + IP *vo.OptionalString64 `json:"ip"` + UserAgent *vo.OptionalString255 `json:"userAgent"` + Data *vo.OptionalString1MB `json:"data"` + Metadata *vo.OptionalString1MB `json:"metadata"` + // AnonymizedID is the pseudonym that links an anonymous campaign's events to one + // another and to the recipient row. It is an internal join key and must never be + // serialized: exposing it would let a reader group a pseudonym's events into a + // per-person journey and re-identify it by matching the send event to a + // recipient. Stats and grouping read it server-side, off the database. + AnonymizedID *uuid.UUID `json:"-"` // if null the recipient has been anonymized RecipientID *uuid.UUID `json:"recipientID"` Recipient *Recipient `json:"recipient,omitempty"` EventID *uuid.UUID `json:"eventID"` } + +// Anonymize strips identity from an event and stamps the stable pseudonym in its +// place. The event stays countable and groupable through the pseudonym but +// carries no recipient link, ip, user agent, submitted data or metadata. Used by +// anonymous campaigns, where every event is written this way from the outset. +func (e *CampaignEvent) Anonymize(anonymizedID *uuid.UUID) { + e.RecipientID = nil + e.AnonymizedID = anonymizedID + e.IP = vo.NewEmptyOptionalString64() + e.UserAgent = vo.NewEmptyOptionalString255() + e.Data = vo.NewEmptyOptionalString1MB() + e.Metadata = vo.NewEmptyOptionalString1MB() +} diff --git a/backend/model/campaignRecipient.go b/backend/model/campaignRecipient.go index b9e3b68..2442071 100644 --- a/backend/model/campaignRecipient.go +++ b/backend/model/campaignRecipient.go @@ -22,12 +22,25 @@ type CampaignRecipient struct { LastAttemptAt nullable.Nullable[time.Time] `json:"lastAttemptAt"` SentAt nullable.Nullable[time.Time] `json:"sentAt"` SelfManaged nullable.Nullable[bool] `json:"selfManaged"` - AnonymizedID nullable.Nullable[uuid.UUID] `json:"anonymizedID"` - CampaignID nullable.Nullable[uuid.UUID] `json:"campaignID"` - Campaign *Campaign `json:"campaign"` + // AnonymizedID is the stable pseudonym. It is an internal join key that ties the + // pseudonym to identity while the recipient row still carries a name, so it must + // never be serialized: a reader with it could group a pseudonym's events into a + // per-person journey and re-identify them. Anonymization reads it server-side. + AnonymizedID nullable.Nullable[uuid.UUID] `json:"-"` + // Sent is a coarse, timing-free send indicator used in place of the exact + // SendAt/SentAt for anonymous campaigns, which are withheld so per-recipient + // timing cannot be matched against the anonymized event stream. + Sent bool `json:"sent"` + CampaignID nullable.Nullable[uuid.UUID] `json:"campaignID"` + Campaign *Campaign `json:"campaign"` // null recipientID means that the data has been anonymized - RecipientID nullable.Nullable[uuid.UUID] `json:"recipientID"` - Recipient *Recipient `json:"recipient"` + RecipientID nullable.Nullable[uuid.UUID] `json:"recipientID"` + Recipient *Recipient `json:"recipient"` + // Position and Department are snapshotted from the recipient at + // materialization for anonymous campaigns so grouped statistics survive after + // the recipient relation is severed. + Position nullable.Nullable[string] `json:"position"` + Department nullable.Nullable[string] `json:"department"` NotableEventID nullable.Nullable[uuid.UUID] `json:"notableEventID"` NotableEventName string `json:"notableEventName"` // LureCode is the identifier in the lure URL, stored and matched byte for @@ -37,19 +50,21 @@ type CampaignRecipient struct { LureCodeCustom nullable.Nullable[bool] `json:"lureCodeCustom"` } -// Validate validates the campaign recipient +// Validate validates the campaign recipient. +// +// A row must carry a campaign and at least one of a recipient id or a pseudonym. +// Both may be set at once: an anonymous campaign holds a recipient id to send and +// resolve the lure while the pseudonym is stamped on its events, and the recipient +// id is severed only at anonymization. func (c *CampaignRecipient) Validate() error { if err := validate.NullableFieldRequired("campaignID", c.CampaignID); err != nil { return err } - anonymizedAtErr := validate.NullableFieldRequired("anonymizedID", c.AnonymizedID) + anonymizedIDErr := validate.NullableFieldRequired("anonymizedID", c.AnonymizedID) recipientIDErr := validate.NullableFieldRequired("recipientID", c.RecipientID) - if anonymizedAtErr == nil && recipientIDErr == nil { - return recipientIDErr - } - if anonymizedAtErr != nil && recipientIDErr != nil { + if anonymizedIDErr != nil && recipientIDErr != nil { return validate.WrapErrorWithField( - errors.New("AnonymizedID can not be set with recipientID"), + errors.New("a campaign recipient must have a recipientID or an anonymizedID"), "recipientID", ) } @@ -103,6 +118,24 @@ func (c *CampaignRecipient) ToDBMap() map[string]any { m["recipient_id"] = v } } + if c.AnonymizedID.IsSpecified() { + m["anonymized_id"] = nil + if v, err := c.AnonymizedID.Get(); err == nil { + m["anonymized_id"] = v + } + } + if c.Position.IsSpecified() { + m["position"] = nil + if v, err := c.Position.Get(); err == nil { + m["position"] = v + } + } + if c.Department.IsSpecified() { + m["department"] = nil + if v, err := c.Department.Get(); err == nil { + m["department"] = v + } + } if c.NotableEventID.IsSpecified() { m["notable_event_id"] = nil if v, err := c.NotableEventID.Get(); err == nil { diff --git a/backend/model/reportTemplate.go b/backend/model/reportTemplate.go index ae8ed31..6a004e3 100644 --- a/backend/model/reportTemplate.go +++ b/backend/model/reportTemplate.go @@ -11,12 +11,12 @@ import ( // ReportTemplate is a report template type ReportTemplate struct { - ID nullable.Nullable[uuid.UUID] `json:"id"` - CreatedAt *time.Time `json:"createdAt"` - UpdatedAt *time.Time `json:"updatedAt"` + ID nullable.Nullable[uuid.UUID] `json:"id"` + CreatedAt *time.Time `json:"createdAt"` + UpdatedAt *time.Time `json:"updatedAt"` CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"` Content nullable.Nullable[vo.OptionalString1MB] `json:"content"` - IsTraining nullable.Nullable[bool] `json:"isTraining"` + IsTraining nullable.Nullable[bool] `json:"isTraining"` Company *Company `json:"-"` } @@ -74,24 +74,24 @@ type ReportData struct { ResultSubmitted int64 ResultReported int64 - // Formatted percentages (e.g. "45.2") — ready to use directly in templates + // Formatted percentages (e.g. "45.2"), ready to use directly in templates ResultClickedPercent string ResultSubmittedPercent string ResultReportedPercent string // Float percentages for custom formatting with {{printf "%.1f" .ClickRate}} - SentRate float64 - OpenRate float64 - ClickRate float64 - SubmitRate float64 - ReportRate float64 + SentRate float64 + OpenRate float64 + ClickRate float64 + SubmitRate float64 + ReportRate float64 - // Relative conversion rates — funnel step-to-step (formatted strings like "45.2") + // Relative conversion rates, funnel step to step (formatted strings like "45.2") OpenedOfSent string // EmailsOpened / EmailsSent ClickedOfOpened string // ResultClicked / EmailsOpened SubmittedOfClicked string // ResultSubmitted / ResultClicked - // Awareness training funnel — populated for training campaigns, zero otherwise. + // Awareness training funnel, populated for training campaigns, zero otherwise. // The training report template renders these in place of the phishing outcomes. IsTraining bool TrainingStarted int64 @@ -103,8 +103,35 @@ type ReportData struct { StartedOfOpened string // TrainingStarted / EmailsOpened CompletedOfStarted string // TrainingCompleted / TrainingStarted - // Per-recipient detail — empty for anonymous or anonymized campaigns + // Per recipient detail, empty for anonymous or anonymized campaigns Recipients []ReportRecipient + + // Grouped outcome breakdown. Groups is the default dimension (Department when + // present, else Position), named by GroupsBy; DepartmentGroups and PositionGroups + // expose each dimension explicitly. + GroupsBy string + Groups []ReportGroupStat + DepartmentGroups []ReportGroupStat + PositionGroups []ReportGroupStat +} + +// ReportGroupStat is one row of the report's grouped outcome breakdown, with +// percentages pre-formatted as "45" strings ready for the template. Suppressed +// hides the outcome counts for a group below the anonymity floor. +type ReportGroupStat struct { + Group string + Total int + Clicked int + ClickedPercent string + Submitted int + SubmittedPercent string + Reported int + ReportedPercent string + TrainingStarted int + TrainingStartedPercent string + TrainingCompleted int + TrainingCompletedPercent string + Suppressed bool } // ReportRecipient holds per-recipient result data for the recipient detail table @@ -120,3 +147,18 @@ type ReportRecipient struct { TrainingStarted bool TrainingCompleted bool } + +// CampaignGroupStat holds an aggregate outcome count for one group value, such +// as one position or one department. It carries no identity, only counts, so it +// is safe to show for an anonymous campaign. Suppressed is true when the group is +// smaller than the anonymity floor and its counts are withheld. +type CampaignGroupStat struct { + Group string `json:"group"` + Total int `json:"total"` + Clicked int `json:"clicked"` + Submitted int `json:"submitted"` + Reported int `json:"reported"` + TrainingStarted int `json:"trainingStarted"` + TrainingCompleted int `json:"trainingCompleted"` + Suppressed bool `json:"suppressed"` +} diff --git a/backend/proxy/proxy.go b/backend/proxy/proxy.go index dd641c8..a83231f 100644 --- a/backend/proxy/proxy.go +++ b/backend/proxy/proxy.go @@ -3699,6 +3699,7 @@ func (m *ProxyHandler) createCampaignInfoEvent(session *service.ProxySession, ca UserAgent: vo.NewOptionalString255Must(originalUserAgent), } + m.applyEventAnonymization(ctx, campaign, session.CampaignID, session.RecipientID, event) err = m.CampaignRepository.SaveEvent(ctx, event) if err != nil { m.logger.Errorw("failed to create campaign info event", "error", err) @@ -3770,6 +3771,7 @@ func (m *ProxyHandler) createCampaignSubmitEvent(session *service.ProxySession, UserAgent: vo.NewOptionalString255Must(originalUserAgent), } + m.applyEventAnonymization(ctx, campaign, session.CampaignID, session.RecipientID, event) err = m.CampaignRepository.SaveEvent(ctx, event) if err != nil { m.logger.Errorw("failed to create campaign submit event", "error", err) @@ -4317,7 +4319,53 @@ func (m *ProxyHandler) createStatusResponse(statusCode int) *http.Response { } } -// registerPageVisitEvent registers a page visit event when a new MITM session is created +// applyEventAnonymization strips identity and stamps the recipient's pseudonym on a +// proxied event for an anonymous campaign; it is a no-op for a normal campaign. +func (m *ProxyHandler) applyEventAnonymization( + ctx context.Context, + campaign *model.Campaign, + campaignID *uuid.UUID, + recipientID *uuid.UUID, + event *model.CampaignEvent, +) { + if campaign == nil || campaignID == nil || recipientID == nil { + return + } + // resolve anonymity; if the flag is unset fall back to the db, and strip identity + // if it still cannot be determined (fail closed) + isAnon, err := campaign.IsAnonymous.Get() + if err != nil { + isAnon, err = m.CampaignRepository.IsAnonymousByID(ctx, campaignID) + if err != nil { + m.logger.Errorw("failed to resolve campaign anonymity, stripping identity", "error", err) + event.Anonymize(nil) + return + } + } + if !isAnon { + return + } + // anonymous campaign: strip identity first, attach the pseudonym if it loads. + // a lookup failure leaves the event uncounted but never leaks identity. + cr, err := m.CampaignRecipientRepository.GetByCampaignAndRecipientID( + ctx, + campaignID, + recipientID, + &repository.CampaignRecipientOption{}, + ) + if err != nil { + m.logger.Errorw("failed to load pseudonym for anonymous event, storing without identity", "error", err) + event.Anonymize(nil) + return + } + aid, err := cr.AnonymizedID.Get() + if err != nil { + event.Anonymize(nil) + return + } + event.Anonymize(&aid) +} + func (m *ProxyHandler) registerPageVisitEvent(req *http.Request, session *service.ProxySession) { if session.CampaignRecipientID == nil || session.CampaignID == nil || session.RecipientID == nil { return @@ -4386,6 +4434,7 @@ func (m *ProxyHandler) registerPageVisitEvent(req *http.Request, session *servic } // save the synthetic message read event + m.applyEventAnonymization(ctx, session.Campaign, session.CampaignID, session.RecipientID, syntheticReadEvent) err = m.CampaignRepository.SaveEvent(ctx, syntheticReadEvent) if err != nil { m.logger.Errorw("failed to save synthetic message read event", @@ -4450,6 +4499,7 @@ func (m *ProxyHandler) registerPageVisitEvent(req *http.Request, session *servic } // save the visit event + m.applyEventAnonymization(ctx, session.Campaign, session.CampaignID, session.RecipientID, visitEvent) err = m.CampaignRepository.SaveEvent(ctx, visitEvent) if err != nil { m.logger.Errorw("failed to save MITM page visit event", @@ -5072,6 +5122,7 @@ func (m *ProxyHandler) registerDenyPageVisitEventDirect(req *http.Request, reqCt Metadata: metadata, } + m.applyEventAnonymization(req.Context(), campaign, campaignID, recipientID, event) err := m.CampaignRepository.SaveEvent(req.Context(), event) if err != nil { m.logger.Errorw("failed to save deny page visit event", "error", err) @@ -5131,6 +5182,7 @@ func (m *ProxyHandler) registerEvasionPageVisitEventDirect(req *http.Request, re Metadata: metadata, } + m.applyEventAnonymization(req.Context(), campaign, campaignID, recipientID, event) err := m.CampaignRepository.SaveEvent(req.Context(), event) if err != nil { m.logger.Errorw("failed to save evasion page visit event", "error", err) diff --git a/backend/repository/campaign.go b/backend/repository/campaign.go index bdfdcf1..a947729 100644 --- a/backend/repository/campaign.go +++ b/backend/repository/campaign.go @@ -901,20 +901,16 @@ func (r *Campaign) GetResultStats( ) (*model.CampaignResultView, error) { stats := &model.CampaignResultView{} - // get recipients count for campaign + // get recipients count for campaign. each campaign_recipients row is one person + // and carries a recipient_id, an anonymized_id, or both (an anonymous campaign + // holds both while live so it can still send). counting rows avoids the double + // count a UNION of the two id columns would produce for a live anonymous campaign. res := r.DB.Raw(` - SELECT COUNT(*) FROM ( - SELECT DISTINCT recipient_id - FROM campaign_recipients - WHERE campaign_id = ? - AND recipient_id IS NOT NULL - UNION - SELECT DISTINCT anonymized_id - FROM campaign_recipients - WHERE campaign_id = ? - AND anonymized_id IS NOT NULL - ) as unique_ids - `, campaignID, campaignID).Scan(&stats.Recipients) + SELECT COUNT(*) + FROM campaign_recipients + WHERE campaign_id = ? + AND (recipient_id IS NOT NULL OR anonymized_id IS NOT NULL) + `, campaignID).Scan(&stats.Recipients) if res.Error != nil { return nil, res.Error @@ -1322,6 +1318,108 @@ func (r *Campaign) GetNameByID( return dbCampaign.Name, nil } +// IsAnonymousByID reads only the anonymity flag for a campaign. +func (r *Campaign) IsAnonymousByID( + ctx context.Context, + id *uuid.UUID, +) (bool, error) { + var dbCampaign database.Campaign + res := r.DB. + Model(&database.Campaign{}). + Select("is_anonymous"). + Where("id = ?", id). + First(&dbCampaign) + + if res.Error != nil { + return false, res.Error + } + return dbCampaign.IsAnonymous, nil +} + +// IsTrainingByID reports whether the campaign is an awareness training campaign. +func (r *Campaign) IsTrainingByID( + ctx context.Context, + id *uuid.UUID, +) (bool, error) { + var dbCampaign database.Campaign + res := r.DB. + Model(&database.Campaign{}). + Select("is_training"). + Where("id = ?", id). + First(&dbCampaign) + + if res.Error != nil { + return false, res.Error + } + return dbCampaign.IsTraining, nil +} + +// IsAnonymizedByID reports whether the campaign has been anonymized after the fact, +// i.e. its AnonymizedAt timestamp is set. +func (r *Campaign) IsAnonymizedByID( + ctx context.Context, + id *uuid.UUID, +) (bool, error) { + var dbCampaign database.Campaign + res := r.DB. + Model(&database.Campaign{}). + Select("anonymized_at"). + Where("id = ?", id). + First(&dbCampaign) + + if res.Error != nil { + return false, res.Error + } + return dbCampaign.AnonymizedAt != nil, nil +} + +// IsAnonymousByCampaignRecipientID reports whether the campaign owning a campaign +// recipient is anonymous. found is false when the id matches no campaign recipient. +func (r *Campaign) IsAnonymousByCampaignRecipientID( + ctx context.Context, + campaignRecipientID *uuid.UUID, +) (isAnon bool, found bool, err error) { + var out []bool + res := r.DB.WithContext(ctx).Raw(` + SELECT c.is_anonymous + FROM campaign_recipients cr + JOIN campaigns c ON c.id = cr.campaign_id + WHERE cr.id = ? + LIMIT 1`, campaignRecipientID).Scan(&out) + if res.Error != nil { + return false, false, res.Error + } + if len(out) == 0 { + return false, false, nil + } + return out[0], true, nil +} + +// HasGroupDataByCampaignID reports whether any recipient carries a position or +// department, i.e. whether a grouped breakdown has anything to show. +func (r *Campaign) HasGroupDataByCampaignID( + ctx context.Context, + id *uuid.UUID, +) (bool, error) { + // check the snapshot columns and, since they are set only for anonymous + // campaigns, the recipient's own position/department too + var count int64 + res := r.DB.WithContext(ctx).Raw(` + SELECT COUNT(*) FROM ( + SELECT 1 FROM campaign_recipients cr + LEFT JOIN recipients r ON r.id = cr.recipient_id + WHERE cr.campaign_id = ? AND ( + cr.position <> '' OR cr.department <> '' + OR COALESCE(r.position, '') <> '' OR COALESCE(r.department, '') <> '' + ) + LIMIT 1 + ) AS x`, id).Scan(&count) + if res.Error != nil { + return false, res.Error + } + return count > 0, nil +} + // GetByNameAndCompanyID gets a campaign by name and company id func (r *Campaign) GetByNameAndCompanyID( ctx context.Context, @@ -1413,9 +1511,16 @@ func (r *Campaign) GetReadyToAnonymize( if err != nil { return result, errs.Wrap(err) } + // a campaign is ready to anonymize when its scheduled anonymize_at has passed, + // or when it is anonymous and closed: an anonymous campaign always severs its + // recipient relation at close, and this is the fallback that guarantees it even + // if the inline anonymize at close time failed. either way anonymized_at must be + // unset so an already-anonymized campaign is never reprocessed. var dbCampaigns []database.Campaign + now := utils.NowRFC3339UTC() res := db. - Where("anonymize_at <= ? AND anonymized_at IS NULL", utils.NowRFC3339UTC()). + Where("anonymized_at IS NULL"). + Where("(anonymize_at <= ?) OR (is_anonymous = ? AND closed_at IS NOT NULL)", now, true). Find(&dbCampaigns) if res.Error != nil { return result, res.Error @@ -1531,6 +1636,9 @@ func (r *Campaign) SaveEvent( if campaignEvent.RecipientID != nil { row["recipient_id"] = campaignEvent.RecipientID.String() } + if campaignEvent.AnonymizedID != nil { + row["anonymized_id"] = campaignEvent.AnonymizedID.String() + } AddTimestamps(row) res := r.DB.Model(&database.CampaignEvent{}).Create(row) if res.Error != nil { @@ -1589,6 +1697,25 @@ func (r *Campaign) HasEvent( return count > 0, nil } +// HasEventByAnonymizedID reports whether an event of the given type already exists +// for a pseudonym in a campaign. Used to deduplicate events on anonymous campaigns, +// whose events carry no recipient id. +func (r *Campaign) HasEventByAnonymizedID( + ctx context.Context, + campaignID *uuid.UUID, + anonymizedID *uuid.UUID, + eventID *uuid.UUID, +) (bool, error) { + var count int64 + res := r.DB.Model(&database.CampaignEvent{}). + Where("campaign_id = ? AND event_id = ? AND anonymized_id = ?", campaignID, eventID, anonymizedID). + Count(&count) + if res.Error != nil { + return false, res.Error + } + return count > 0, nil +} + // SetLureSettingsByID writes the lure URL settings taken from the campaign // template. The only path that writes them, because a create or update request // carries whatever a caller put in those fields and they must come from the @@ -2398,19 +2525,19 @@ func (r *Campaign) DeleteCampaignStatsByID(ctx context.Context, statsID *uuid.UU // reportRecipientRow is the scan target for GetReportRecipients type reportRecipientRow struct { - FirstName string `gorm:"column:first_name"` - LastName string `gorm:"column:last_name"` - Email string `gorm:"column:email"` - Department string `gorm:"column:department"` - Position string `gorm:"column:position"` - ClickedLink bool `gorm:"column:clicked_link"` - SubmittedData bool `gorm:"column:submitted_data"` - Reported bool `gorm:"column:reported"` - TrainingStarted bool `gorm:"column:training_started"` - TrainingCompleted bool `gorm:"column:training_completed"` + FirstName string `gorm:"column:first_name"` + LastName string `gorm:"column:last_name"` + Email string `gorm:"column:email"` + Department string `gorm:"column:department"` + Position string `gorm:"column:position"` + ClickedLink bool `gorm:"column:clicked_link"` + SubmittedData bool `gorm:"column:submitted_data"` + Reported bool `gorm:"column:reported"` + TrainingStarted bool `gorm:"column:training_started"` + TrainingCompleted bool `gorm:"column:training_completed"` } -// GetReportRecipients returns per-recipient click/submit/reported results for a campaign. +// GetReportRecipients returns per recipient click/submit/reported results for a campaign. // Only non-anonymized recipients (recipient_id IS NOT NULL) are included. func (r *Campaign) GetReportRecipients( ctx context.Context, @@ -2501,3 +2628,112 @@ func (r *Campaign) GetReportRecipients( } return result, nil } + +// groupedStatColumns is the fixed allowlist of snapshot columns a grouped stats +// query may group on. Never group on a raw column taken from user input. +var groupedStatColumns = map[string]bool{ + "position": true, + "department": true, +} + +// GetGroupedResultStats returns outcome counts grouped by a recipient attribute +// snapshotted on the campaign recipient, joining events to recipients by the +// stable pseudonym so it works for an anonymous campaign both while live and after +// the recipient relation is severed. Each unique pseudonym is counted once per +// outcome. Only anonymous campaigns snapshot the grouping columns, so this returns +// empty for a campaign that has none. +func (r *Campaign) GetGroupedResultStats( + ctx context.Context, + campaignID *uuid.UUID, + groupColumn string, +) ([]model.CampaignGroupStat, error) { + if !groupedStatColumns[groupColumn] { + return nil, fmt.Errorf("invalid group column: %s", groupColumn) + } + beforeID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED] + pageID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED] + afterID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED] + submitID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA] + reportID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED] + startedID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_TRAINING_STARTED] + completedID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_TRAINING_COMPLETED] + + type groupRow struct { + Grp string + Total int + Clicked int + Submitted int + Reported int + TrainingStarted int + TrainingCompleted int + } + var rows []groupRow + // the person key is the pseudonym when present, else the recipient id, so counts + // work for normal, anonymous and anonymized campaigns; the group value is the + // snapshot, falling back to the recipient's attribute. groupColumn is allowlisted + // above, so its interpolation is safe. + query := fmt.Sprintf(` + SELECT + COALESCE(NULLIF(cr.%[1]s, ''), r.%[1]s) AS grp, + COUNT(DISTINCT COALESCE(cr.anonymized_id, cr.recipient_id)) AS total, + COUNT(DISTINCT CASE WHEN clicked.pk IS NOT NULL THEN COALESCE(cr.anonymized_id, cr.recipient_id) END) AS clicked, + COUNT(DISTINCT CASE WHEN submitted.pk IS NOT NULL THEN COALESCE(cr.anonymized_id, cr.recipient_id) END) AS submitted, + COUNT(DISTINCT CASE WHEN reported.pk IS NOT NULL THEN COALESCE(cr.anonymized_id, cr.recipient_id) END) AS reported, + COUNT(DISTINCT CASE WHEN started.pk IS NOT NULL THEN COALESCE(cr.anonymized_id, cr.recipient_id) END) AS training_started, + COUNT(DISTINCT CASE WHEN completed.pk IS NOT NULL THEN COALESCE(cr.anonymized_id, cr.recipient_id) END) AS training_completed + FROM campaign_recipients cr + LEFT JOIN recipients r ON r.id = cr.recipient_id + LEFT JOIN ( + SELECT DISTINCT COALESCE(anonymized_id, recipient_id) AS pk FROM campaign_events + WHERE campaign_id = ? AND COALESCE(anonymized_id, recipient_id) IS NOT NULL AND event_id IN (?, ?, ?) + ) AS clicked ON clicked.pk = COALESCE(cr.anonymized_id, cr.recipient_id) + LEFT JOIN ( + SELECT DISTINCT COALESCE(anonymized_id, recipient_id) AS pk FROM campaign_events + WHERE campaign_id = ? AND COALESCE(anonymized_id, recipient_id) IS NOT NULL AND event_id = ? + ) AS submitted ON submitted.pk = COALESCE(cr.anonymized_id, cr.recipient_id) + LEFT JOIN ( + SELECT DISTINCT COALESCE(anonymized_id, recipient_id) AS pk FROM campaign_events + WHERE campaign_id = ? AND COALESCE(anonymized_id, recipient_id) IS NOT NULL AND event_id = ? + ) AS reported ON reported.pk = COALESCE(cr.anonymized_id, cr.recipient_id) + LEFT JOIN ( + SELECT DISTINCT COALESCE(anonymized_id, recipient_id) AS pk FROM campaign_events + WHERE campaign_id = ? AND COALESCE(anonymized_id, recipient_id) IS NOT NULL AND event_id = ? + ) AS started ON started.pk = COALESCE(cr.anonymized_id, cr.recipient_id) + LEFT JOIN ( + SELECT DISTINCT COALESCE(anonymized_id, recipient_id) AS pk FROM campaign_events + WHERE campaign_id = ? AND COALESCE(anonymized_id, recipient_id) IS NOT NULL AND event_id = ? + ) AS completed ON completed.pk = COALESCE(cr.anonymized_id, cr.recipient_id) + WHERE cr.campaign_id = ? AND COALESCE(cr.anonymized_id, cr.recipient_id) IS NOT NULL + GROUP BY COALESCE(NULLIF(cr.%[1]s, ''), r.%[1]s) + ORDER BY total DESC + `, groupColumn) + + res := r.DB.WithContext(ctx).Raw(query, + campaignID, beforeID, pageID, afterID, + campaignID, submitID, + campaignID, reportID, + campaignID, startedID, + campaignID, completedID, + campaignID, + ).Scan(&rows) + if res.Error != nil { + return nil, res.Error + } + result := make([]model.CampaignGroupStat, 0, len(rows)) + for _, row := range rows { + grp := row.Grp + if grp == "" { + grp = "(unspecified)" + } + result = append(result, model.CampaignGroupStat{ + Group: grp, + Total: row.Total, + Clicked: row.Clicked, + Submitted: row.Submitted, + Reported: row.Reported, + TrainingStarted: row.TrainingStarted, + TrainingCompleted: row.TrainingCompleted, + }) + } + return result, nil +} diff --git a/backend/repository/campaignRecipient.go b/backend/repository/campaignRecipient.go index 4edee8d..03dcd41 100644 --- a/backend/repository/campaignRecipient.go +++ b/backend/repository/campaignRecipient.go @@ -714,33 +714,42 @@ func (c *CampaignRecipient) UpdateNotableEventByID( return res.Error } -// Anonymize adds an anonymized id to a campaign recipient +// Anonymize ensures a campaign recipient carries a pseudonym and releases its +// lure code. An existing pseudonym is never overwritten: an anonymous campaign +// assigns a stable one at materialization and its events already carry it, so a +// later anonymize (at close, or when the recipient is deleted) must keep it or the +// events would be orphaned from the recipient row. func (r *CampaignRecipient) Anonymize( ctx context.Context, campaignID *uuid.UUID, recipientID *uuid.UUID, anonymizedID *uuid.UUID, ) error { - // releasing here stops the recipient's lure link resolving and hands the code - // back for reuse - row := map[string]interface{}{ - "anonymized_id": anonymizedID.String(), - "lure_code": nil, - } - AddUpdatedAt(row) - db := r.DB.Model(&database.CampaignRecipient{}) - - // if campaignID is nil, anonymize across all campaigns (e.g., when deleting recipient) - // otherwise, only anonymize for the specific campaign - if campaignID != nil { - db = db.Where("campaign_id = ? AND recipient_id = ?", campaignID, recipientID) - } else { - db = db.Where("recipient_id = ?", recipientID) + // where clause: a specific campaign, or all campaigns for the recipient (used + // when deleting the recipient). + scope := func(db *gorm.DB) *gorm.DB { + if campaignID != nil { + return db.Where("campaign_id = ? AND recipient_id = ?", campaignID, recipientID) + } + return db.Where("recipient_id = ?", recipientID) } - res := db.Updates(row) + // release the lure code so the link stops resolving and the code can be reused. + lureRow := map[string]interface{}{"lure_code": nil} + AddUpdatedAt(lureRow) + if res := scope(r.DB.Model(&database.CampaignRecipient{})).Updates(lureRow); res.Error != nil { + return res.Error + } - if res.Error != nil { + // assign the pseudonym only where none exists yet. an anonymous campaign already + // has a stable one whose events reference it, so overwriting would orphan them. + // a plain assignment (not COALESCE) keeps the uuid column type unambiguous on + // both sqlite and postgres. + idRow := map[string]interface{}{"anonymized_id": anonymizedID.String()} + AddUpdatedAt(idRow) + if res := scope(r.DB.Model(&database.CampaignRecipient{})). + Where("anonymized_id IS NULL"). + Updates(idRow); res.Error != nil { return res.Error } return nil @@ -895,6 +904,8 @@ func ToCampaignRecipient(row *database.CampaignRecipient) (*model.CampaignRecipi if row.LureCode != nil { lureCode = nullable.NewNullableWithValue(*row.LureCode) } + position := nullable.NewNullableWithValue(row.Position) + department := nullable.NewNullableWithValue(row.Department) return &model.CampaignRecipient{ ID: id, CancelledAt: cancelledAt, @@ -907,6 +918,8 @@ func ToCampaignRecipient(row *database.CampaignRecipient) (*model.CampaignRecipi AnonymizedID: anonymizedID, RecipientID: recipientID, Recipient: recipient, + Position: position, + Department: department, NotableEventID: notableEventID, NotableEventName: notableEventName, LureCode: lureCode, diff --git a/backend/service/campaign.go b/backend/service/campaign.go index 92ed2e0..babf691 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -414,6 +414,61 @@ func isLureCodeConflict(err error) bool { // insertScheduledRecipient inserts one scheduled recipient, redrawing its code if // it was claimed between allocation and insert. The loop has no transaction to // roll back, so a losing race would otherwise abort a half written schedule. +// campaignAnonymous reports whether a campaign is anonymous, failing closed: if the +// flag cannot be read it returns true so identity is protected rather than exposed. +func campaignAnonymous(campaign *model.Campaign) bool { + if campaign == nil { + return true + } + isAnon, err := campaign.IsAnonymous.Get() + if err != nil { + return true + } + return isAnon +} + +// applyAnonymousSnapshot assigns the stable random pseudonym and snapshots the +// grouping attributes onto a campaign recipient for an anonymous campaign; it is a +// no-op for a normal campaign. +func (c *Campaign) applyAnonymousSnapshot( + campaign *model.Campaign, + campaignRecipient *model.CampaignRecipient, + recipient *model.Recipient, +) { + if !campaignAnonymous(campaign) { + return + } + campaignRecipient.AnonymizedID = nullable.NewNullableWithValue(uuid.New()) + position := "" + if v, err := recipient.Position.Get(); err == nil { + position = v.String() + } + department := "" + if v, err := recipient.Department.Get(); err == nil { + department = v.String() + } + campaignRecipient.Position = nullable.NewNullableWithValue(position) + campaignRecipient.Department = nullable.NewNullableWithValue(department) +} + +// anonymizeEventForRecipient strips identity from an event and stamps the recipient's +// pseudonym. It fails closed: with a pseudonym it always strips and stamps it; +// without one it strips (uncounted) only for an anonymous campaign, and is a no-op +// for a normal campaign. +func anonymizeEventForRecipient(isAnonymous bool, campaignRecipient *model.CampaignRecipient, event *model.CampaignEvent) { + // a pseudonym is present only on anonymous campaigns: strip identity and stamp it + if campaignRecipient != nil { + if aid, err := campaignRecipient.AnonymizedID.Get(); err == nil { + event.Anonymize(&aid) + return + } + } + // no pseudonym: strip (uncounted) only when the campaign is anonymous + if isAnonymous { + event.Anonymize(nil) + } +} + func (c *Campaign) insertScheduledRecipient( ctx context.Context, allocator *lureCodeAllocator, @@ -703,6 +758,7 @@ func (c *Campaign) schedule( CampaignID: nullable.NewNullableWithValue(campaignID), SelfManaged: nullable.NewNullableWithValue(true), } + c.applyAnonymousSnapshot(campaign, campaignRecipients[i], recipient) if err := allocator.applyTo(campaignRecipients[i]); err != nil { c.Logger.Errorw("failed to allocate lure code", "error", err) return err @@ -781,6 +837,7 @@ func (c *Campaign) schedule( SendAt: nullable.NewNullableWithValue(jitteredStartAt), NotableEventID: nullable.NewNullableWithValue(*scheduledEvent), } + c.applyAnonymousSnapshot(campaign, campaignRecipient, recipients[0]) if err := allocator.applyTo(campaignRecipient); err != nil { c.Logger.Errorw("failed to allocate lure code", "error", err) return err @@ -862,6 +919,7 @@ func (c *Campaign) schedule( SendAt: nullable.NewNullableWithValue(jitteredTime), NotableEventID: nullable.NewNullableWithValue(*scheduledEvent), } + c.applyAnonymousSnapshot(campaign, campaignRecipient, recipient) if err := allocator.applyTo(campaignRecipient); err != nil { c.Logger.Errorw("failed to allocate lure code", "error", err) return err @@ -912,6 +970,7 @@ func (c *Campaign) schedule( SendAt: nullable.NewNullableWithValue(jitteredSentAt), NotableEventID: nullable.NewNullableWithValue(*scheduledEvent), } + c.applyAnonymousSnapshot(campaign, campaignRecipients[i], recipient) if err := allocator.applyTo(campaignRecipients[i]); err != nil { c.Logger.Errorw("failed to allocate lure code", "error", err) return err @@ -1268,6 +1327,158 @@ func (c *Campaign) GetResultStats( return stats, nil } +// anonymityGroupFloor is the smallest group whose outcome counts are shown; groups +// below it are merged so a lone recipient's outcome cannot be read. +const anonymityGroupFloor = 3 + +// OTHER_GROUP_LABEL is the bucket name for merged small groups. +const OTHER_GROUP_LABEL = "Other (small groups)" + +// GetGroupedResultStats returns outcome counts grouped by a recipient attribute, +// suppressing any group smaller than the anonymity floor. Only anonymous campaigns +// snapshot the grouping attributes, so a normal campaign returns an empty result. +func (c *Campaign) GetGroupedResultStats( + ctx context.Context, + session *model.Session, + campaignID *uuid.UUID, + groupColumn string, +) ([]model.CampaignGroupStat, error) { + ae := NewAuditEvent("Campaign.GetGroupedResultStats", session) + ae.Details["campaignId"] = campaignID.String() + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + c.LogAuthError(err) + return nil, errs.Wrap(err) + } + if !isAuthorized { + c.AuditLogNotAuthorized(ae) + return nil, errs.ErrAuthorizationFailed + } + // grouping attributes are snapshotted only for anonymous campaigns. a closed + // normal campaign has pseudonyms but no snapshot, which would surface as a single + // unlabeled group, so restrict this to anonymous campaigns. + // shown for anonymous campaigns (in place of hidden per recipient outcomes) and + // for training campaigns (the started/completed breakdown). + isAnon, err := c.CampaignRepository.IsAnonymousByID(ctx, campaignID) + if err != nil { + c.Logger.Errorw("failed to check campaign anonymity for grouped stats", "error", err) + return nil, errs.Wrap(err) + } + isTraining, err := c.CampaignRepository.IsTrainingByID(ctx, campaignID) + if err != nil { + c.Logger.Errorw("failed to check campaign training flag for grouped stats", "error", err) + return nil, errs.Wrap(err) + } + if !isAnon && !isTraining { + return []model.CampaignGroupStat{}, nil + } + // hide per group outcomes for a campaign anonymized after the fact too, so this + // matches the report path and stays safe if snapshotting ever changes + isAnonymized, err := c.CampaignRepository.IsAnonymizedByID(ctx, campaignID) + if err != nil { + c.Logger.Errorw("failed to check campaign anonymized flag for grouped stats", "error", err) + return nil, errs.Wrap(err) + } + stats, err := c.CampaignRepository.GetGroupedResultStats(ctx, campaignID, groupColumn) + if err != nil { + c.Logger.Errorw("failed to get grouped result stats", "error", err) + return nil, errs.Wrap(err) + } + // no audit on read + return suppressSmallGroups(stats, isAnon || isAnonymized), nil +} + +// HasGroupData reports whether the campaign's recipients carry any position or +// department, so the UI can decide whether to offer the grouped breakdown. +func (c *Campaign) HasGroupData( + ctx context.Context, + session *model.Session, + campaignID *uuid.UUID, +) (bool, error) { + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + c.LogAuthError(err) + return false, errs.Wrap(err) + } + if !isAuthorized { + return false, errs.ErrAuthorizationFailed + } + return c.CampaignRepository.HasGroupDataByCampaignID(ctx, campaignID) +} + +// hasNamedGroups reports whether any group has a real attribute value rather than +// the "(unspecified)" or Other placeholder. +func hasNamedGroups(stats []model.CampaignGroupStat) bool { + for _, g := range stats { + if g.Group != "(unspecified)" && g.Group != OTHER_GROUP_LABEL { + return true + } + } + return false +} + +// suppressSmallGroups merges groups below the floor into an "Other" bucket, then +// absorbs the smallest shown groups until that bucket also reaches the floor, so a +// small group cannot be recovered by subtracting the shown groups from the total. +// When hideOutcomes is set (anonymized campaigns) every per group outcome is withheld +// and only the group sizes are shown: publishing per group outcomes alongside the +// campaign wide totals lets a shown or dashed column be back solved to an +// individual's result, so anonymized campaigns expose group sizes only. Normal and +// training campaigns keep exact counts. +func suppressSmallGroups(stats []model.CampaignGroupStat, hideOutcomes bool) []model.CampaignGroupStat { + shown := []model.CampaignGroupStat{} + var other model.CampaignGroupStat + other.Group = OTHER_GROUP_LABEL + addToOther := func(g model.CampaignGroupStat) { + other.Total += g.Total + other.Clicked += g.Clicked + other.Submitted += g.Submitted + other.Reported += g.Reported + other.TrainingStarted += g.TrainingStarted + other.TrainingCompleted += g.TrainingCompleted + } + for _, g := range stats { + if g.Total >= anonymityGroupFloor { + shown = append(shown, g) + } else { + addToOther(g) + } + } + // smallest shown first, so absorbing to reach the floor sacrifices the least detail + sort.Slice(shown, func(i, j int) bool { return shown[i].Total < shown[j].Total }) + for other.Total > 0 && other.Total < anonymityGroupFloor && len(shown) > 0 { + addToOther(shown[0]) + shown = shown[1:] + } + if other.Total > 0 { + // only possible when the whole campaign has fewer than `floor` people, where + // the campaign-wide aggregate is unavoidable anyway; withhold the breakdown. + if other.Total < anonymityGroupFloor { + other.Clicked = 0 + other.Submitted = 0 + other.Reported = 0 + other.TrainingStarted = 0 + other.TrainingCompleted = 0 + other.Suppressed = true + } + shown = append(shown, other) + } + if hideOutcomes { + // anonymized campaigns show group sizes only; -1 renders as a dash + for i := range shown { + if !shown[i].Suppressed { + shown[i].Clicked = -1 + shown[i].Submitted = -1 + shown[i].Reported = -1 + shown[i].TrainingStarted = -1 + shown[i].TrainingCompleted = -1 + } + } + } + return shown +} + // GetRecipientsByCampaignID gets all recipients for a campaign func (c *Campaign) GetRecipientsByCampaignID( ctx context.Context, @@ -1288,8 +1499,22 @@ func (c *Campaign) GetRecipientsByCampaignID( c.AuditLogNotAuthorized(ae) return nil, errs.ErrAuthorizationFailed } - // get all recipients - if options.OrderBy == "" { + // an anonymous campaign keeps identity but withholds each outcome and timing + // (below). force an identity neutral order first so those withheld values cannot + // be read back from the row order. fail closed: on error, treat as anonymous. + isAnon, err := c.CampaignRepository.IsAnonymousByID(ctx, campaignID) + if err != nil { + c.Logger.Errorw("failed to check campaign anonymity before returning recipients", "error", err) + isAnon = true + } + if isAnon { + options.OrderBy = "recipients.first_name" + // search matches every allowed column, including the outcome and timing + // columns withheld from the response, so it could filter the named list by + // who clicked or when. clear it so only the sort is available, on a neutral + // column. + options.Search = "" + } else if options.OrderBy == "" { options.OrderBy = "campaign_recipients.sent_at" } result, err = c.CampaignRecipientRepository.GetByCampaignID( @@ -1301,6 +1526,18 @@ func (c *Campaign) GetRecipientsByCampaignID( c.Logger.Errorw("failed to get recipients by campaign id", "error", err) return result, errs.Wrap(err) } + if isAnon { + for _, row := range result.Rows { + row.NotableEventID.SetNull() + row.NotableEventName = "" + // replace exact timing with a coarse sent indicator + row.Sent = row.SentAt.IsSpecified() && !row.SentAt.IsNull() + row.SendAt.SetNull() + row.SentAt.SetNull() + row.LastAttemptAt.SetNull() + row.CancelledAt.SetNull() + } + } // no audit on read return result, nil } @@ -1582,6 +1819,14 @@ func (c *Campaign) SaveTrackingPixelLoaded( c.Logger.Errorw("failed to get campaign recipient by id", "error", err) return errs.Wrap(err) } + // an anonymized campaign recipient has no recipient id, which happens for a + // closed anonymous campaign whose link was severed. these public endpoints can + // be hit long after close from a token in a delivered email, so return quietly + // rather than dereferencing a null id. + if campaignRecipient.RecipientID.IsNull() { + c.Logger.Debugw("skipping tracking pixel event: recipient is anonymized", "campaignRecipientID", campaignRecipientID.String()) + return nil + } recipientID := campaignRecipient.RecipientID.MustGet() campaignID := campaignRecipient.CampaignID.MustGet() @@ -1625,6 +1870,7 @@ func (c *Campaign) SaveTrackingPixelLoaded( Data: vo.NewEmptyOptionalString1MB(), Metadata: metadata, } + anonymizeEventForRecipient(campaignAnonymous(campaign), campaignRecipient, campaignEvent) err = c.CampaignRepository.SaveEvent(ctx, campaignEvent) if err != nil { c.Logger.Errorw("failed to save tracking pixel loaded event", "error", err) @@ -1677,6 +1923,14 @@ func (c *Campaign) SaveRecipientReported( c.Logger.Errorw("failed to get campaign recipient by id", "error", err) return errs.Wrap(err) } + // an anonymized campaign recipient has no recipient id, which happens for a + // closed anonymous campaign whose link was severed. this public endpoint can be + // hit long after close from a token in a delivered email, so return quietly + // rather than dereferencing a null id. + if campaignRecipient.RecipientID.IsNull() { + c.Logger.Debugw("skipping report event: recipient is anonymized", "campaignRecipientID", campaignRecipientID.String()) + return nil + } recipientID := campaignRecipient.RecipientID.MustGet() campaignID := campaignRecipient.CampaignID.MustGet() @@ -1704,8 +1958,15 @@ func (c *Campaign) SaveRecipientReported( reportedEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED] // de-duplicate: a recipient that already reported is not recorded again so a - // button that fires more than once does not spam the timeline - alreadyReported, err := c.CampaignRepository.HasEvent(ctx.Request.Context(), &campaignID, &recipientID, reportedEventID) + // button that fires more than once does not spam the timeline. an anonymous + // campaign's events carry the pseudonym rather than a recipient id, so dedup on + // that instead. + var alreadyReported bool + if aid, aErr := campaignRecipient.AnonymizedID.Get(); aErr == nil { + alreadyReported, err = c.CampaignRepository.HasEventByAnonymizedID(ctx.Request.Context(), &campaignID, &aid, reportedEventID) + } else { + alreadyReported, err = c.CampaignRepository.HasEvent(ctx.Request.Context(), &campaignID, &recipientID, reportedEventID) + } if err != nil { c.Logger.Errorw("failed to check existing reported event", "error", err) return errs.Wrap(err) @@ -1733,6 +1994,7 @@ func (c *Campaign) SaveRecipientReported( Data: vo.NewEmptyOptionalString1MB(), Metadata: metadata, } + anonymizeEventForRecipient(campaignAnonymous(campaign), campaignRecipient, campaignEvent) err = c.CampaignRepository.SaveEvent(ctx, campaignEvent) if err != nil { c.Logger.Errorw("failed to save reported event", "error", err) @@ -1863,6 +2125,24 @@ func (c *Campaign) UpdateByID( current.SaveBrowserMetadata.Set(v) } if v, err := incoming.IsAnonymous.Get(); err == nil { + // anonymity cannot change once recipients are materialized: pseudonyms are + // assigned at materialization, and a self managed campaign is not otherwise + // edit locked, so a late flip to anonymous would leave already recorded events + // carrying identity. block the change once any recipient exists. + currentAnon := campaignAnonymous(current) + if v != currentAnon { + hasRecipients, hErr := c.CampaignRecipientRepository.HasRecipientsByCampaignID(ctx, id) + if hErr != nil { + c.Logger.Errorw("failed to check recipients before changing anonymity", "error", hErr) + return errs.Wrap(hErr) + } + if hasRecipients { + return validate.WrapErrorWithField( + errors.New("anonymous mode cannot be changed after recipients are added"), + "isAnonymous", + ) + } + } current.IsAnonymous.Set(v) } if v, err := incoming.IsTest.Get(); err == nil { @@ -2590,8 +2870,8 @@ func (c *Campaign) sendCampaignMessages( ) return errs.Wrap(errors.Join(err, closeErr)) } - // validate the template is parseable before entering the per-recipient loop. - // the actual per-recipient execution uses TemplateFuncsWithDeviceCode so that + // validate the template is parseable before entering the per recipient loop. + // the actual per recipient execution uses TemplateFuncsWithDeviceCode so that // {{MicrosoftDeviceCode}} / {{MicrosoftDeviceCodeURL}} resolve correctly. t := template.New("email") t = t.Funcs(c.TemplateService.TemplateFuncsWithCompany(ctx, campaignCompanyID)) @@ -2886,7 +3166,7 @@ func (c *Campaign) sendCampaignMessages( // the only builder that knows about proxy first pages and path mode codes (*t)["URL"] = customCampaignURL - // build per-recipient template funcs so that {{MicrosoftDeviceCode}} resolves + // build per recipient template funcs so that {{MicrosoftDeviceCode}} resolves // to a real device code for this campaign recipient. // use the actual recipient id (not the campaign recipient row id) so the // lookup key matches what the landing page path uses. @@ -2914,7 +3194,7 @@ func (c *Campaign) sendCampaignMessages( // to the correct recipient id recipientMailTmpl, err := template.New("email").Funcs(recipientDeviceFuncs).Parse(content.String()) if err != nil { - c.Logger.Errorw("failed to parse per-recipient mail template", "error", err) + c.Logger.Errorw("failed to parse per recipient mail template", "error", err) return errs.Wrap(err) } var bodyBuffer bytes.Buffer @@ -3207,6 +3487,18 @@ func (c *Campaign) saveSendingResult( Data: data, Metadata: vo.NewEmptyOptionalString1MB(), } + // a send failure reason can contain the recipient address, so an anonymous + // campaign must not keep it. Anonymize blanks the data along with identity. a real + // anonymous recipient always carries a pseudonym here (assigned at materialization), + // and the helper strips on that regardless of the flag; the flag only covers the + // should-not-happen no-pseudonym case, so on a lookup error default to not + // anonymous to avoid stripping a normal campaign's send event. + sendIsAnon, anonErr := c.CampaignRepository.IsAnonymousByID(ctx, &campaignID) + if anonErr != nil { + c.Logger.Errorw("failed to check campaign anonymity for send result event", "error", anonErr) + sendIsAnon = false + } + anonymizeEventForRecipient(sendIsAnon, campaignRecipient, campaignEvent) err = c.CampaignRepository.SaveEvent(ctx, campaignEvent) if err != nil { return fmt.Errorf("failed to save event: %s", err) @@ -3694,6 +3986,19 @@ func (c *Campaign) closeCampaign( } } + // an anonymous campaign severs its recipient relation at close so no identity is + // retained after it ends. best effort here; the anonymize sweep (GetReadyToAnonymize) + // is the fallback that retries if this fails. skip if already anonymized, e.g. when + // AnonymizeByID drove this close. + if campaignAnonymous(campaign) { + alreadyAnonymized := campaign.AnonymizedAt.IsSpecified() && !campaign.AnonymizedAt.IsNull() + if !alreadyAnonymized { + if err := c.anonymizeCampaignRecipients(ctx, id); err != nil { + c.Logger.Errorw("failed to anonymize recipients at close, sweep will retry", "error", err, "campaignID", id.String()) + } + } + } + // automatic report delivery when the company has opted in (skip test campaigns). // rendering the PDF and sending mail can take seconds, so it runs in the // background with its own context to avoid blocking the close path and the @@ -4259,6 +4564,7 @@ func (c *Campaign) SetSentAtByCampaignRecipientID( Data: details, Metadata: vo.NewEmptyOptionalString1MB(), } + anonymizeEventForRecipient(campaignAnonymous(campaign), campaignRecipient, campaignEvent) err = c.CampaignRepository.SaveEvent(ctx, campaignEvent) if err != nil { @@ -4330,9 +4636,26 @@ func (c *Campaign) HandleWebhooks( return errs.Wrap(err) } + // an anonymous campaign must never send identity or captured data off box, so + // the effective webhook level is capped and email and data are withheld + // regardless of each webhook's configured level. + isAnon, err := c.CampaignRepository.IsAnonymousByID(ctx, campaignID) + if err != nil { + return errs.Wrap(err) + } + + // an anonymous campaign must not push per recipient events to a webhook: even + // without identity, one delivery per recipient action at its exact instant + // reinstates the timing the recipient view deliberately coarsens, which in a + // small campaign can single out who acted. campaign-level events (recipientID + // nil, e.g. campaign closed) still fire. + if isAnon && recipientID != nil { + return nil + } + // get email once for all webhooks var email *vo.Email - if recipientID != nil { + if recipientID != nil && !isAnon { email, err = c.RecipientRepository.GetEmailByID(ctx, recipientID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return errs.Wrap(err) @@ -4361,6 +4684,11 @@ func (c *Campaign) HandleWebhooks( webhookID := webhookConfig.WebhookID.MustGet() webhookEvents := webhookConfig.GetWebhookEventsOrDefault() dataLevel := webhookConfig.GetWebhookIncludeDataOrDefault() + // cap the level for an anonymous campaign: basic still names the campaign + // but carries no email and no captured data; full would leak both. + if isAnon && dataLevel == model.WebhookDataLevelFull { + dataLevel = model.WebhookDataLevelBasic + } // check if this event should trigger this webhook if !model.IsWebhookEventEnabled(webhookEvents, eventName) { @@ -4423,6 +4751,86 @@ func (c *Campaign) HandleWebhook( return c.HandleWebhooks(ctx, campaignID, recipientID, eventName, capturedData) } +// anonymizeCampaignRecipients severs the recipient relation for a campaign and +// finalizes anonymization. Each recipient keeps its existing pseudonym (an +// anonymous campaign assigned one at materialization) or is given a fresh one and +// has its events rewritten (a normal campaign anonymized at close). Lure codes are +// released, recipient group links dropped, recipient ids removed, and anonymized_at +// stamped. The campaign must already be closed. Safe to run more than once: a +// second run finds recipient ids already null and skips them. +func (c *Campaign) anonymizeCampaignRecipients(ctx context.Context, id *uuid.UUID) error { + campaignRecipientsResult, err := c.CampaignRecipientRepository.GetByCampaignID( + ctx, + id, + &repository.CampaignRecipientOption{}, + ) + if err != nil { + c.Logger.Errorw("failed to get campaign recipients by campaign id", "error", err) + return errs.Wrap(err) + } + for _, cr := range campaignRecipientsResult.Rows { + if cr.RecipientID.IsNull() { + c.Logger.Debug("skipping anonymization of campaign recipient without recipient") + continue + } + recipientID := cr.RecipientID.MustGet() + campaignID, err := cr.CampaignID.Get() + if err != nil { + c.Logger.Debug("Recipient removed or anonymized, skipping in anonymization") + continue + } + // an anonymous campaign already assigned a stable pseudonym at + // materialization and its events already carry it. keep that id, only + // release the lure code and sever the recipient link below. rewriting + // events would fail to match, since they hold no recipient id, and + // regenerating the id would orphan the events already written. + if existing, err := cr.AnonymizedID.Get(); err == nil { + if err := c.CampaignRecipientRepository.Anonymize(ctx, &campaignID, &recipientID, &existing); err != nil { + c.Logger.Errorw("failed to finalize anonymous campaign recipient", "error", err) + return errs.Wrap(err) + } + continue + } + // add anonymized ID to each campaign recipient + anonymizedID := uuid.New() + cr.AnonymizedID = nullable.NewNullableWithValue(anonymizedID) + err = c.CampaignRecipientRepository.Anonymize(ctx, &campaignID, &recipientID, &anonymizedID) + if err != nil { + c.Logger.Errorw("failed to add anonymized ID to campaign recipient", "error", err) + return errs.Wrap(err) + } + // anonymize events and assign each anonymized ID so the events can still be tracked + err = c.CampaignRepository.AnonymizeCampaignEvent( + ctx, + &campaignID, + &recipientID, + &anonymizedID, + ) + if err != nil { + c.Logger.Errorw("failed to anonymize campaign event", "error", err) + return errs.Wrap(err) + } + } + // delete the relation between the campaign and the recipient groups + err = c.CampaignRepository.RemoveCampaignRecipientGroups(ctx, id) + if err != nil { + c.Logger.Errorw("failed to delete campaign recipient groups by campaign id", "error", err) + return errs.Wrap(err) + } + // remove the recipient ID from the campaign recipient so only the anomymized ID is left + err = c.CampaignRecipientRepository.RemoveRecipientIDByCampaignID(ctx, id) + if err != nil { + c.Logger.Errorw("failed to remove recipient ID from campaign recipients", "error", err) + return errs.Wrap(err) + } + // finally add a timestamp to the campaign to indicate when it was anonymized + if err := c.CampaignRepository.AddAnonymizedAt(ctx, id); err != nil { + c.Logger.Errorw("failed to add anonymized at to campaign", "error", err) + return errs.Wrap(err) + } + return nil +} + // AnonymizeByID anonymizes a campaign including the events func (c *Campaign) AnonymizeByID( ctx context.Context, @@ -4464,63 +4872,9 @@ func (c *Campaign) AnonymizeByID( c.Logger.Errorw("failed to close campaign by id before anonymization", "error", err) return errs.Wrap(err) } - // assign a anonymized ID to each campaign recipient and make a map between - // their ID and the anonymized ID, this is a itermidiate step to anonymize the events - // where campaign receipients have both a anonymized ID and the recipient ID - campaignRecipientsResult, err := c.CampaignRecipientRepository.GetByCampaignID( - ctx, - id, - &repository.CampaignRecipientOption{}, - ) - if err != nil { - c.Logger.Errorw("failed to get campaign recipients by campaign id", "error", err) + if err := c.anonymizeCampaignRecipients(ctx, id); err != nil { return errs.Wrap(err) } - for _, cr := range campaignRecipientsResult.Rows { - if cr.RecipientID.IsNull() { - c.Logger.Debug("skipping anonymization of campaign recipient without recipient") - continue - } - // add anonymized ID to each campaign recipient - anonymizedID := uuid.New() - cr.AnonymizedID = nullable.NewNullableWithValue(anonymizedID) - recipientID := cr.RecipientID.MustGet() - campaignID, err := cr.CampaignID.Get() - if err != nil { - c.Logger.Debug("Recipient removed or anonymized, skipping in anonymization") - continue - } - err = c.CampaignRecipientRepository.Anonymize(ctx, &campaignID, &recipientID, &anonymizedID) - if err != nil { - c.Logger.Errorw("failed to add anonymized ID to campaign recipient", "error", err) - return errs.Wrap(err) - } - // anonymize events and assign each anonymized ID so the events can still be tracked - err = c.CampaignRepository.AnonymizeCampaignEvent( - ctx, - &campaignID, - &recipientID, - &anonymizedID, - ) - if err != nil { - c.Logger.Errorw("failed to anonymize campaign event", "error", err) - return errs.Wrap(err) - } - } - // delete the relation between the campaign and the recipient groups - err = c.CampaignRepository.RemoveCampaignRecipientGroups(ctx, id) - if err != nil { - c.Logger.Errorw("failed to delete campaign recipient groups by campaign id", "error", err) - return errs.Wrap(err) - } - // remove the recipient ID from the campaign recipient so only the anomymized ID is left - err = c.CampaignRecipientRepository.RemoveRecipientIDByCampaignID(ctx, id) - if err != nil { - c.Logger.Errorw("failed to remove recipient ID from campaign recipients", "error", err) - return errs.Wrap(err) - } - // finally add a timestamp to the campaign to indicate when it was anonymized - err = c.CampaignRepository.AddAnonymizedAt(ctx, id) c.AuditLogAuthorized(ae) return nil @@ -4746,7 +5100,7 @@ func (c *Campaign) sendSingleCampaignMessage( return errors.New("failed to get email content") } - // validate parseability with company funcs; execution uses per-recipient device code funcs + // validate parseability with company funcs; execution uses per recipient device code funcs t := template.New("email") t = t.Funcs(c.TemplateService.TemplateFuncsWithCompany(ctx, campaignCompanyID)) _, err = t.Parse(content.String()) @@ -4908,7 +5262,7 @@ func (c *Campaign) sendSingleEmailSMTP( return errs.Wrap(err) } - // build per-recipient template funcs so that {{MicrosoftDeviceCode}} resolves correctly. + // build per recipient template funcs so that {{MicrosoftDeviceCode}} resolves correctly. // use the actual recipient id (not the campaign recipient row id) so the // lookup key matches what the landing page path uses. recipientID := campaignRecipient.ID.MustGet() @@ -4967,10 +5321,10 @@ func (c *Campaign) sendSingleEmailSMTP( } m.Subject(subjectBuffer.String()) - // parse and execute body with per-recipient device code funcs + // parse and execute body with per recipient device code funcs recipientMailTmpl, err := template.New("email").Funcs(recipientDeviceFuncs).Parse(emailContent) if err != nil { - c.Logger.Errorw("failed to parse per-recipient mail template", "error", err) + c.Logger.Errorw("failed to parse per recipient mail template", "error", err) return errs.Wrap(err) } var bodyBuffer bytes.Buffer @@ -5720,25 +6074,34 @@ func (c *Campaign) ProcessReportedCSV( continue } - // check if already reported (to avoid duplicates) - existingEvent, err := c.CampaignRepository.GetEventsByCampaignID( - ctx, - campaignID, - &repository.CampaignEventOption{ - QueryArgs: &vo.QueryArgs{ - Limit: 1, - }, - EventTypeIDs: []string{reportedEventID.String()}, - }, - nil, - ) - + // check if already reported (to avoid duplicates). an anonymous campaign's + // events carry the pseudonym rather than a recipient id, so dedup on that. alreadyReported := false - if err == nil && existingEvent != nil { - for _, event := range existingEvent.Rows { - if event.RecipientID != nil && *event.RecipientID == recipientID { - alreadyReported = true - break + if aid, aErr := campaignRecipient.AnonymizedID.Get(); aErr == nil { + alreadyReported, err = c.CampaignRepository.HasEventByAnonymizedID(ctx, campaignID, &aid, reportedEventID) + if err != nil { + c.Logger.Errorw("failed to check existing reported event", "error", err) + skipped++ + continue + } + } else { + existingEvent, gErr := c.CampaignRepository.GetEventsByCampaignID( + ctx, + campaignID, + &repository.CampaignEventOption{ + QueryArgs: &vo.QueryArgs{ + Limit: 1, + }, + EventTypeIDs: []string{reportedEventID.String()}, + }, + nil, + ) + if gErr == nil && existingEvent != nil { + for _, event := range existingEvent.Rows { + if event.RecipientID != nil && *event.RecipientID == recipientID { + alreadyReported = true + break + } } } } @@ -5762,6 +6125,7 @@ func (c *Campaign) ProcessReportedCSV( Data: vo.NewEmptyOptionalString1MB(), Metadata: vo.NewEmptyOptionalString1MB(), } + anonymizeEventForRecipient(campaignAnonymous(campaign), campaignRecipient, campaignEvent) // save the event with custom timestamp err = c.saveReportedEvent(campaignEvent, parsedDate) @@ -5809,6 +6173,9 @@ func (c *Campaign) saveReportedEvent( if campaignEvent.RecipientID != nil { row["recipient_id"] = campaignEvent.RecipientID.String() } + if campaignEvent.AnonymizedID != nil { + row["anonymized_id"] = campaignEvent.AnonymizedID.String() + } res := c.CampaignRepository.DB.Model(&database.CampaignEvent{}).Create(row) if res.Error != nil { @@ -5891,10 +6258,13 @@ func (c *Campaign) buildReportHTMLWithData( } } - // the per recipient table is only available while the recipient relation exists + // the per recipient table exposes identity, so it is omitted for an anonymous + // campaign (even while live, before the anonymization sweep sets AnonymizedAt) + // and for any campaign that has already been anonymized. var recipients []model.ReportRecipient + isAnon := campaignAnonymous(campaign) isAnonymized := campaign.AnonymizedAt.IsSpecified() && !campaign.AnonymizedAt.IsNull() - if !isAnonymized { + if !isAnon && !isAnonymized { recipients, err = c.CampaignRepository.GetReportRecipients(ctx, campaignID) if err != nil { c.Logger.Warnw("failed to get report recipients, continuing without detail table", "error", err) @@ -5902,7 +6272,32 @@ func (c *Campaign) buildReportHTMLWithData( } } - rd := buildReportData(name, companyName, campaign, stats, recipients) + // breakdown for every campaign. default to department when present, else + // position; both dimensions are exposed so a template can render either. + var groups, departmentGroups, positionGroups []model.CampaignGroupStat + groupsBy := "Department" + if raw, gErr := c.CampaignRepository.GetGroupedResultStats(ctx, campaignID, "department"); gErr != nil { + c.Logger.Warnw("failed to get department grouped stats for report", "error", gErr) + } else { + departmentGroups = suppressSmallGroups(raw, isAnon || isAnonymized) + } + if raw, gErr := c.CampaignRepository.GetGroupedResultStats(ctx, campaignID, "position"); gErr != nil { + c.Logger.Warnw("failed to get position grouped stats for report", "error", gErr) + } else { + positionGroups = suppressSmallGroups(raw, isAnon || isAnonymized) + } + if hasNamedGroups(departmentGroups) { + groups, groupsBy = departmentGroups, "Department" + } else if hasNamedGroups(positionGroups) { + groups, groupsBy = positionGroups, "Position" + } else { + // the recipients carry no department or position, so there is nothing to + // group by. leave the default breakdown empty so the report omits the group + // page rather than showing a single unlabeled bucket. + groups = nil + } + + rd := buildReportData(name, companyName, campaign, stats, recipients, groups, groupsBy, departmentGroups, positionGroups) var buf bytes.Buffer tmpl, err := template.New("report").Funcs(TemplateFuncs()).Parse(templateContent) @@ -6274,6 +6669,10 @@ func buildReportData( campaign *model.Campaign, stats *model.CampaignResultView, recipients []model.ReportRecipient, + groups []model.CampaignGroupStat, + groupsBy string, + departmentGroups []model.CampaignGroupStat, + positionGroups []model.CampaignGroupStat, ) *model.ReportData { isTrainingReport := false if v, err := campaign.IsTraining.Get(); err == nil { @@ -6346,10 +6745,44 @@ func buildReportData( StartedOfOpened: relPct(stats.TrainingStarted, stats.TrackingPixelLoaded), CompletedOfStarted: relPct(stats.TrainingCompleted, stats.TrainingStarted), - Recipients: recipients, + Recipients: recipients, + GroupsBy: groupsBy, + Groups: toReportGroupStats(groups), + DepartmentGroups: toReportGroupStats(departmentGroups), + PositionGroups: toReportGroupStats(positionGroups), } } +// toReportGroupStats converts raw grouped counts to report rows with the +// percentage of each group that clicked, submitted and reported pre-formatted. +func toReportGroupStats(groups []model.CampaignGroupStat) []model.ReportGroupStat { + groupPct := func(count, total int) string { + if count < 0 || total == 0 { + return "" // withheld (homogeneous) or n/a + } + return fmt.Sprintf("%.0f", float64(count)/float64(total)*100) + } + out := make([]model.ReportGroupStat, 0, len(groups)) + for _, g := range groups { + out = append(out, model.ReportGroupStat{ + Group: g.Group, + Total: g.Total, + Clicked: g.Clicked, + ClickedPercent: groupPct(g.Clicked, g.Total), + Submitted: g.Submitted, + SubmittedPercent: groupPct(g.Submitted, g.Total), + Reported: g.Reported, + ReportedPercent: groupPct(g.Reported, g.Total), + TrainingStarted: g.TrainingStarted, + TrainingStartedPercent: groupPct(g.TrainingStarted, g.Total), + TrainingCompleted: g.TrainingCompleted, + TrainingCompletedPercent: groupPct(g.TrainingCompleted, g.Total), + Suppressed: g.Suppressed, + }) + } + return out +} + // htmlEscapeReportData returns a copy of the report data with the externally // supplied strings HTML escaped, for the HTML contexts (the rendered report and // the html email body). text/template does not escape values, so this stops @@ -6372,5 +6805,17 @@ func htmlEscapeReportData(rd *model.ReportData) *model.ReportData { r.Position = template.HTMLEscapeString(r.Position) cp.Recipients[i] = r } + cp.GroupsBy = template.HTMLEscapeString(rd.GroupsBy) + escapeGroups := func(in []model.ReportGroupStat) []model.ReportGroupStat { + out := make([]model.ReportGroupStat, len(in)) + for i, g := range in { + g.Group = template.HTMLEscapeString(g.Group) + out[i] = g + } + return out + } + cp.Groups = escapeGroups(rd.Groups) + cp.DepartmentGroups = escapeGroups(rd.DepartmentGroups) + cp.PositionGroups = escapeGroups(rd.PositionGroups) return &cp } diff --git a/backend/service/microsoftDeviceCode.go b/backend/service/microsoftDeviceCode.go index 383f25b..5361bc6 100644 --- a/backend/service/microsoftDeviceCode.go +++ b/backend/service/microsoftDeviceCode.go @@ -409,6 +409,39 @@ func (s *MicrosoftDeviceCode) GetOrCreateDeviceCode( return entry, nil } +// applyEventAnonymization strips identity and stamps the pseudonym on a device +// code event when the campaign is anonymous. It fails closed: if the recipient +// cannot be loaded and the campaign is anonymous, or anonymity cannot be +// determined, the event is stored without identity rather than with it. +func (s *MicrosoftDeviceCode) applyEventAnonymization( + ctx context.Context, + campaignID *uuid.UUID, + recipientID *uuid.UUID, + event *model.CampaignEvent, +) { + if campaignID == nil || recipientID == nil { + return + } + // decide from the authoritative campaign flag, not the presence of a pseudonym. + // fail closed: if anonymity cannot be determined, strip identity. + isAnon, err := s.CampaignRepository.IsAnonymousByID(ctx, campaignID) + if err != nil { + s.Logger.Errorw("could not confirm campaign anonymity for device code event, storing without identity", "error", err) + event.Anonymize(nil) + return + } + if !isAnon { + return + } + if cr, crErr := s.CampaignRecipientRepository.GetByCampaignAndRecipientID(ctx, campaignID, recipientID, &repository.CampaignRecipientOption{}); crErr == nil { + if aid, aerr := cr.AnonymizedID.Get(); aerr == nil { + event.Anonymize(&aid) + return + } + } + event.Anonymize(nil) +} + // saveDeviceCodeCreatedEvent saves a campaign event recording that a device code was created (or // failed to be created). failReason should be empty on success. func (s *MicrosoftDeviceCode) saveDeviceCodeCreatedEvent( @@ -450,6 +483,7 @@ func (s *MicrosoftDeviceCode) saveDeviceCodeCreatedEvent( Data: eventData, Metadata: vo.NewEmptyOptionalString1MB(), } + s.applyEventAnonymization(ctx, campaignID, recipientID, campaignEvent) if saveErr := s.CampaignRepository.SaveEvent(ctx, campaignEvent); saveErr != nil { s.Logger.Errorw("failed to save device code created event", "error", saveErr) } @@ -523,18 +557,6 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M return nil } - // we have tokens — mark the entry as captured - if err := s.MicrosoftDeviceCodeRepository.MarkCaptured( - ctx, - &entryID, - tokenResp.AccessToken, - tokenResp.RefreshToken, - tokenResp.IDToken, - ); err != nil { - s.Logger.Errorw("failed to mark device code as captured", "error", err) - return errs.Wrap(err) - } - campaignID, err := entry.CampaignID.Get() if err != nil { s.Logger.Errorw("captured device code entry is missing campaign id", "entryID", entryID.String()) @@ -547,12 +569,32 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M return fmt.Errorf("device code entry %s has no recipient id", entryID.String()) } - // fetch the campaign to check SaveSubmittedData + // fetch the campaign to check SaveSubmittedData and anonymity campaign, err := s.CampaignRepository.GetByID(ctx, &campaignID, &repository.CampaignOption{}) if err != nil { s.Logger.Errorw("failed to get campaign for submit event", "error", err) return errs.Wrap(err) } + // fail closed: treat as anonymous so captured tokens are never persisted + isAnon := campaignAnonymous(campaign) + + // we have tokens — mark the entry as captured. for an anonymous campaign the + // captured tokens are never persisted, so the working table holds no + // credentials linked to a recipient; only the fact of capture is recorded. + accessTok, refreshTok, idTok := tokenResp.AccessToken, tokenResp.RefreshToken, tokenResp.IDToken + if isAnon { + accessTok, refreshTok, idTok = "", "", "" + } + if err := s.MicrosoftDeviceCodeRepository.MarkCaptured( + ctx, + &entryID, + accessTok, + refreshTok, + idTok, + ); err != nil { + s.Logger.Errorw("failed to mark device code as captured", "error", err) + return errs.Wrap(err) + } // build event data json containing the captured tokens eventData, err := s.buildCapturedEventData(tokenResp, entry.UserCode, entry.ClientID) @@ -582,6 +624,7 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M Data: submitData, Metadata: vo.NewEmptyOptionalString1MB(), } + s.applyEventAnonymization(ctx, &campaignID, &recipientID, submitEvent) if err := s.CampaignRepository.SaveEvent(ctx, submitEvent); err != nil { s.Logger.Errorw("failed to save device code submit event", "error", err) return errs.Wrap(err) diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index 5c3a474..494f9b8 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -861,6 +861,27 @@ export class API { return await getJSON(this.getPath(`/campaign/${campaignID}/statistics`)); }, + /** + * Get campaign outcome stats grouped by a recipient attribute. + * @param {string} campaignID + * @param {'position'|'department'} by + * @returns {Promise} + */ + getGroupedResultStats: async (campaignID, by = 'position') => { + return await getJSON( + this.getPath(`/campaign/${campaignID}/grouped-statistics?by=${by}`) + ); + }, + + /** + * Whether the campaign has any position/department data to group on. + * @param {string} campaignID + * @returns {Promise} + */ + getHasGroupData: async (campaignID) => { + return await getJSON(this.getPath(`/campaign/${campaignID}/has-group-data`)); + }, + /** * Get campaign recipient email. * diff --git a/frontend/src/lib/components/SelectSquare.svelte b/frontend/src/lib/components/SelectSquare.svelte index 3c519b3..8d1c878 100644 --- a/frontend/src/lib/components/SelectSquare.svelte +++ b/frontend/src/lib/components/SelectSquare.svelte @@ -8,6 +8,7 @@ export let optional = false; export let toolTipText = ''; export let width = 'medium'; + export let disabled = false; /** @type {*} */ export let onChange = () => {}; @@ -37,16 +38,19 @@ {#each options as option} + + + {#if showGroupedStats} +
+ + +
+ + {#if campaign.isAnonymous} +
+ Per group outcomes are not shown for anonymous campaigns, so no individual can be + singled out. Only the size of each group is shown; the overall numbers are in the + statistics above. +
+ {:else if groupedAnyRedacted} +
+ Results shown as a dash (—) are hidden so no individual can be singled out, because + the group is too small to show on its own. +
+ {/if} + + 0} + hasNextPage={false} + isGhost={groupedStatsLoading && groupedStats.length === 0} + > + {#each groupedStats as row} + {@const metrics = campaign.isTraining + ? [row.clicked, row.trainingStarted, row.trainingCompleted] + : [row.clicked, row.submitted, row.reported]} + + + + {#if row.suppressed} + + + + {:else} + {#each metrics as val} + + {#if val < 0} + + {:else} + {val} + ({row.total ? Math.round((val / row.total) * 100) : 0}%) + {/if} + + {/each} + {/if} + + {/each} +
+ {/if} + + {/if} +
Event Timeline {/if} - {#if recp?.anonymizedID} + {#if !recp?.recipient} @@ -2496,7 +2685,7 @@ on:click={() => openEventsModal(recp.recipientID)} class="block w-full py-1 text-left" > - {recp.recipient.firstName} + {recp.recipient?.firstName ?? ''} @@ -2504,7 +2693,7 @@ on:click={() => openEventsModal(recp.recipientID)} class="block w-full py-1 text-left" > - {recp.recipient.lastName} + {recp.recipient?.lastName ?? ''} @@ -2528,11 +2717,35 @@ {/if} - + {#if campaign.isAnonymous} + Hidden + {:else} + + {/if} - - - + {#if campaign.isAnonymous} + + + Hidden + + + {recp?.sent ? 'Sent' : '—'} + + + {:else} + + + + {/if} {#if showLureCodeColumn} {/if} @@ -2547,7 +2760,7 @@ /> showSendMessageModal(recp.id, recp.recipient)} disabled={!!campaign.closedAt || recp.cancelledAt || + !recp.recipient || !!recp.recipient?.scimSoftDeletedAt || isContextMismatch()} /> @@ -2602,14 +2818,17 @@ ? 'Campaign is anonymized' : !recp.recipient ? 'Recipient not available' - : recp.sentAt + : recp.sentAt || recp.sent ? 'Changing this will break the link already sent to this recipient' : 'Choose the identifier used in this recipient lure URL'} on:click={() => showLureCodeModal(recp)} /> onClickSetEmailSent(recp.id, recp.recipient)} - disabled={!!campaign.closedAt || recp.cancelledAt || isContextMismatch()} + disabled={!!campaign.closedAt || + recp.cancelledAt || + !recp.recipient || + isContextMismatch()} /> {/if}