Files
phishingclub/backend/service/recipientGroup.go
T
2026-06-11 22:34:41 +02:00

634 lines
19 KiB
Go

package service
import (
"context"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/validate"
"gorm.io/gorm"
)
// RecipientGroup is a recipient group service
type RecipientGroup struct {
Common
CampaignRepository *repository.Campaign
CampaignRecipientRepository *repository.CampaignRecipient
RecipientGroupRepository *repository.RecipientGroup
RecipientRepository *repository.Recipient
RecipientService *Recipient
DB *gorm.DB
}
// Create inserts a new recipient group
func (r *RecipientGroup) Create(
ctx context.Context,
session *model.Session,
group *model.RecipientGroup,
) (*uuid.UUID, error) {
ae := NewAuditEvent("RecipientGroup.Create", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
r.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
r.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// if dynamic, validate that filter fields are present, allowed, and non-empty
isDynamic := group.IsDynamic.IsSpecified() && !group.IsDynamic.IsNull() && group.IsDynamic.MustGet()
if isDynamic {
if err := group.ValidateDynamic(); err != nil {
return nil, errs.Wrap(err)
}
}
// check uniqueness
var companyID *uuid.UUID
if cid, err := group.CompanyID.Get(); err == nil {
companyID = &cid
}
name := group.Name.MustGet()
isOK, err := repository.CheckNameIsUnique(
ctx,
r.RecipientRepository.DB,
"recipient_groups",
name.String(),
companyID,
nil,
)
if err != nil {
r.Logger.Errorw("failed to check recipient group uniqueness", "error", err)
return nil, errs.Wrap(err)
}
if !isOK {
r.Logger.Debugw("recipient group is already taken", "name", name.String())
return nil, validate.WrapErrorWithField(errors.New("is not unique"), "name")
}
// insert recipient group
recipientGroupID, err := r.RecipientGroupRepository.Insert(
ctx,
group,
)
if err != nil {
r.Logger.Debugw("failed to create recipient group - failed to insert recipient group",
"error", err,
)
return nil, errs.Wrap(err)
}
ae.Details["id"] = recipientGroupID.String()
r.AuditLogAuthorized(ae)
return recipientGroupID, nil
}
// Import imports recipients into a recipient group
// if the recipient does not exists, it will be created and added to the group
// if the recipient exits, it will be updated and added to the group
func (r *RecipientGroup) Import(
ctx context.Context,
session *model.Session,
recipients []*model.Recipient,
ignoreOverwriteEmptyFields bool,
recipientGroupID *uuid.UUID,
companyID *uuid.UUID,
) (*RecipientImportResult, error) {
ae := NewAuditEvent("RecipientGroup.Import", session)
ae.Details["recipientGroupId"] = recipientGroupID.String()
if companyID != nil {
ae.Details["companyId"] = companyID.String()
}
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
r.LogAuthError(err)
return nil, err
}
if !isAuthorized {
r.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
if len(recipients) == 0 {
return nil, validate.WrapErrorWithField(errors.New("no recipients"), "add recipients")
}
// check that the recipient group exists
group, err := r.RecipientGroupRepository.GetByID(
ctx,
recipientGroupID,
&repository.RecipientGroupOption{},
)
if err != nil {
r.Logger.Debugw("failed to import recipients - failed to get recipient group", "error", err)
return nil, err
}
// dynamic groups do not support explicit membership via import
if group.IsDynamic.IsSpecified() && !group.IsDynamic.IsNull() && group.IsDynamic.MustGet() {
return nil, validate.WrapErrorWithField(errors.New("cannot import recipients into a dynamic group"), "group")
}
result, err := r.RecipientService.Import(
ctx,
session,
recipients,
ignoreOverwriteEmptyFields,
companyID,
)
if err != nil {
return result, err
}
// add recpients to group
err = r.AddRecipients(
ctx,
session,
recipientGroupID,
result.SuccessIDs,
)
if err != nil {
r.Logger.Debugw("failed to import recipients - failed to add recipients to group",
"error", err,
)
return result, err
}
r.AuditLogAuthorized(ae)
return result, nil
}
// GetByID returns a recipient group by ID
func (r *RecipientGroup) GetByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
options *repository.RecipientGroupOption,
) (*model.RecipientGroup, error) {
ae := NewAuditEvent("RecipientGroup.GetByID", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
r.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
r.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// get recipient group
recipientGroup, err := r.RecipientGroupRepository.GetByID(
ctx,
id,
options,
)
if err != nil {
r.Logger.Errorw("failed to get recipient group by id - failed to get recipient group",
"error", err,
)
return nil, errs.Wrap(err)
}
// no audit on read
return recipientGroup, nil
}
// GetByCompanyID returns recipient groups by company ID
func (r *RecipientGroup) GetByCompanyID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
options *repository.RecipientGroupOption,
) ([]*model.RecipientGroup, error) {
ae := NewAuditEvent("RecipientGroup.GetByCompanyID", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
r.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
r.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// get recipient group
recipientGroups, err := r.RecipientGroupRepository.GetAllByCompanyID(
ctx,
id,
options,
)
if err != nil {
r.Logger.Errorw("failed to get recipient groups by id - failed to get recipient group",
"error", err,
)
return nil, errs.Wrap(err)
}
// no audit on read
return recipientGroups, nil
}
// GetAll returns all recipient groups using pagination
func (r *RecipientGroup) GetAll(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID, // can be null
options *repository.RecipientGroupOption,
) (*model.Result[model.RecipientGroup], error) {
result := model.NewEmptyResult[model.RecipientGroup]()
ae := NewAuditEvent("RecipientGroup.GetAll", session)
if companyID != nil {
ae.Details["companyId"] = companyID.String()
}
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
r.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
r.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
// get recipient groups
result, err = r.RecipientGroupRepository.GetAll(
ctx,
companyID,
options,
)
if err != nil {
r.Logger.Errorw("failed to get all recipient groups - failed to get all recipient groups",
"error", err,
)
return result, errs.Wrap(err)
}
// no audit log on read
return result, nil
}
// GetRecipientsByID returns all recipients of a recipient group
func (r *RecipientGroup) GetRecipientsByGroupID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
options *repository.RecipientOption,
) (*model.Result[model.Recipient], error) {
result := model.NewEmptyResult[model.Recipient]()
ae := NewAuditEvent("RecipientGroup.GetRecipientsByGroupID", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
r.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
r.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
// get recipients
result, err = r.RecipientGroupRepository.GetRecipientsByGroupID(
ctx,
id,
options,
)
if err != nil {
r.Logger.Errorw("failed to get recipients by id - failed to get recipients", "error", err)
return result, errs.Wrap(err)
}
// no audit on read
return result, nil
}
// UpdateByID updates a recipient group by ID
func (r *RecipientGroup) UpdateByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
incoming *model.RecipientGroup,
) error {
ae := NewAuditEvent("RecipientGroup.UpdateByID", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
r.LogAuthError(err)
return err
}
if !isAuthorized {
r.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// get current
current, err := r.RecipientGroupRepository.GetByID(
ctx,
id,
&repository.RecipientGroupOption{},
)
if err != nil {
r.Logger.Errorw("failed to get recipient group", "error", err)
return err
}
if incoming.Name.IsSpecified() && !incoming.Name.IsNull() {
var companyID *uuid.UUID
if cid, err := current.CompanyID.Get(); err == nil {
companyID = &cid
}
name := incoming.Name.MustGet()
isOK, err := repository.CheckNameIsUnique(
ctx,
r.RecipientRepository.DB,
"recipient_groups",
name.String(),
companyID,
id,
)
if err != nil {
r.Logger.Errorw("failed to check recipient group uniqueness", "error", err)
return err
}
if !isOK {
r.Logger.Debugw("recipient group is already taken", "name", name.String())
return validate.WrapErrorWithField(errors.New("is not unique"), "name")
}
current.Name.Set(name)
}
// disallow toggling isDynamic after creation
if incoming.IsDynamic.IsSpecified() && !incoming.IsDynamic.IsNull() {
currentIsDynamic := current.IsDynamic.IsSpecified() && !current.IsDynamic.IsNull() && current.IsDynamic.MustGet()
if incoming.IsDynamic.MustGet() != currentIsDynamic {
return validate.WrapErrorWithField(errors.New("cannot change after creation"), "isDynamic")
}
}
isDynamic := current.IsDynamic.IsSpecified() && !current.IsDynamic.IsNull() && current.IsDynamic.MustGet()
if isDynamic {
// propagate filter field/value updates; ValidateDynamic carries the allowlist check
if incoming.FilterField.IsSpecified() && !incoming.FilterField.IsNull() {
current.FilterField.Set(incoming.FilterField.MustGet())
}
if incoming.FilterValue.IsSpecified() && !incoming.FilterValue.IsNull() {
current.FilterValue.Set(incoming.FilterValue.MustGet())
}
// re-validate the merged state so allowlist and length rules are enforced
if err := current.ValidateDynamic(); err != nil {
return errs.Wrap(err)
}
} else {
// reject filter field/value changes on a static group
if (incoming.FilterField.IsSpecified() && !incoming.FilterField.IsNull()) ||
(incoming.FilterValue.IsSpecified() && !incoming.FilterValue.IsNull()) {
return validate.WrapErrorWithField(errors.New("cannot set filter fields on a static group"), "filterField")
}
}
// update recipient group
err = r.RecipientGroupRepository.UpdateByID(
ctx,
id,
current,
)
if err != nil {
r.Logger.Errorw("failed to update recipient group by id - failed to update recipient group",
"error", err,
)
return err
}
r.AuditLogAuthorized(ae)
return nil
}
// AddRecipients adds recipients to a recipient group.
// returns an error if the group is dynamic, as membership is derived from the filter query.
func (r *RecipientGroup) AddRecipients(
ctx context.Context,
session *model.Session,
groupID *uuid.UUID,
recipients []*uuid.UUID,
) error {
ae := NewAuditEvent("RecipientGroup.AddRecipients", session)
ae.Details["id"] = groupID.String()
ae.Details["recipientIds"] = repository.UUIDsToStrings(recipients)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
r.LogAuthError(err)
return err
}
if !isAuthorized {
r.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// ensure that the recipient group exists
group, err := r.RecipientGroupRepository.GetByID(
ctx,
groupID,
&repository.RecipientGroupOption{},
)
if err != nil {
r.Logger.Errorw("failed to add recipients - failed to get recipient group", "error", err)
return err
}
// dynamic groups do not support explicit membership
if group.IsDynamic.IsSpecified() && !group.IsDynamic.IsNull() && group.IsDynamic.MustGet() {
return validate.WrapErrorWithField(errors.New("cannot add recipients to a dynamic group"), "group")
}
// check if the recipients can be added to group
for _, recipientID := range recipients {
recipient, err := r.RecipientRepository.GetByID(
ctx,
recipientID,
&repository.RecipientOption{},
)
if err != nil {
r.Logger.Errorw("failed to add recipients - failed to get recipient by id",
"error", err,
)
return err
}
// if the group has a company ID then the recipients company ID must match
// unless the recipient has no company id as it is global
if v, err := group.CompanyID.Get(); err == nil {
// if the recipient company is set and does not match the groups
if recipient.CompanyID.IsSpecified() && !recipient.CompanyID.IsNull() && v.String() != recipient.CompanyID.MustGet().String() {
r.Logger.Errorw("failed to add recipients - recipient company id does not match group id",
"error", err,
)
return validate.WrapErrorWithField(errors.New("company id does not match group id"), "recipient")
}
} else {
// if the group does not have a company ID then the recipient must not have a company ID
if recipient.CompanyID.IsSpecified() && !recipient.CompanyID.IsNull() {
r.Logger.Errorw("failed to add recipients - recipient company id is not nil", "error", err)
return validate.WrapErrorWithField(errors.New("cant add recipient belonging to a company to a global group"), "recipient")
}
}
}
// add recipients to group
err = r.RecipientGroupRepository.AddRecipients(
ctx,
groupID,
recipients,
)
if err != nil {
r.Logger.Errorw("failed to add recipients - failed to add recipients to group", "error", err)
return err
}
r.AuditLogAuthorized(ae)
return nil
}
// RemoveRecipients removes recipients from a recipient group.
// returns an error if the group is dynamic, as membership is derived from the filter query.
func (r *RecipientGroup) RemoveRecipients(
ctx context.Context,
session *model.Session,
groupID *uuid.UUID,
recipientIDs []*uuid.UUID,
) error {
ae := NewAuditEvent("RecipientGroup.RemoveRecipients", session)
ae.Details["groupId"] = groupID.String()
ae.Details["recipientIds"] = repository.UUIDsToStrings(recipientIDs)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
r.LogAuthError(err)
return err
}
if !isAuthorized {
r.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// dynamic groups do not support explicit membership removal
existingGroup, err := r.RecipientGroupRepository.GetByID(
ctx,
groupID,
&repository.RecipientGroupOption{},
)
if err != nil {
r.Logger.Errorw("failed to remove recipients - failed to get recipient group", "error", err)
return err
}
if existingGroup.IsDynamic.IsSpecified() && !existingGroup.IsDynamic.IsNull() && existingGroup.IsDynamic.MustGet() {
return validate.WrapErrorWithField(errors.New("cannot remove recipients from a dynamic group"), "group")
}
// cancel any pending sends for these recipients in active campaigns; the
// recipients are kept and their campaign history is left intact
for _, recpID := range recipientIDs {
err = r.CampaignRecipientRepository.CancelInActiveCampaigns(ctx, recpID)
if err != nil {
r.Logger.Errorw("failed to cancel campaign recipient", "error", err)
return err
}
}
// remove recipient from group
err = r.RecipientGroupRepository.RemoveRecipients(
ctx,
groupID,
recipientIDs,
)
if err != nil {
r.Logger.Errorw("failed to remove recipient - failed to remove recipient from group",
"error", err,
)
return err
}
r.AuditLogAuthorized(ae)
return nil
}
// DeleteByID deletes a recipient group by ID
func (r *RecipientGroup) DeleteByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
) error {
ae := NewAuditEvent("RecipientGroup.DeleteByID", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
r.LogAuthError(err)
return err
}
if !isAuthorized {
r.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// get all recipients in group; for dynamic groups the junction table is empty so we
// must resolve members via the filter query before anonymizing campaign data
group, err := r.RecipientGroupRepository.GetByID(
ctx,
id,
&repository.RecipientGroupOption{
WithRecipients: true,
},
)
if err != nil {
r.Logger.Errorw("failed to delete recipient group - failed to get recipient group", "error", err)
return err
}
recipients := group.Recipients
isDynamic := group.IsDynamic.IsSpecified() && !group.IsDynamic.IsNull() && group.IsDynamic.MustGet()
if isDynamic {
// dynamic groups carry no junction-table rows; resolve members via filter query
dynamicResult, err := r.RecipientGroupRepository.GetRecipientsByGroupID(
ctx,
id,
&repository.RecipientOption{},
)
if err != nil {
r.Logger.Errorw("failed to delete recipient group - failed to get dynamic group recipients", "error", err)
return err
}
recipients = dynamicResult.Rows
}
if len(recipients) > 0 {
// cancel any pending sends for these recipients in active campaigns; the
// recipients are kept and their campaign history is left intact
for _, recipient := range recipients {
recpID := recipient.ID.MustGet()
err = r.CampaignRecipientRepository.CancelInActiveCampaigns(ctx, &recpID)
if err != nil {
r.Logger.Errorw("failed to cancel campaign recipient", "error", err)
return err
}
}
}
// remove group from campaign groups
err = r.CampaignRepository.RemoveCampaignRecipientGroupByGroupID(
ctx,
id,
)
if err != nil {
r.Logger.Errorw(
"failed to delete group - failed remove group from campaign data",
"error", err,
)
return err
}
// remove group and recipients from group
err = r.RecipientGroupRepository.DeleteByID(
ctx,
id,
)
if err != nil {
r.Logger.Errorw("failed to delete recipient group by id - failed to delete recipient group",
"error", err,
)
return err
}
r.AuditLogAuthorized(ae)
return nil
}