diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 8e0217d2..f4377242 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -636,7 +636,7 @@ func main() { // Template & email send services templateService = template.NewService(templateRepository) schedulerService := scheduler.NewSchedulerService(taskRepository, warmupRepository, campaignProgressRepository, emailRepostory, campaignRepostory) - emailSendService = emailsend.NewService(taskRepository, emailRepostory, schedulerService, tasksClient, featureGateService) + emailSendService = emailsend.NewService(taskRepository, emailRepostory, userRepostory, schedulerService, tasksClient, featureGateService) advancedService = advanced.NewService( advancedRepository, campaignRepostory, diff --git a/internal/app/auth/login.go b/internal/app/auth/login.go index 85494306..a2cf6460 100644 --- a/internal/app/auth/login.go +++ b/internal/app/auth/login.go @@ -114,6 +114,16 @@ func (s *authService) LoginConfirm(ctx context.Context, data *ConfirmData, sessi return nil, errx.ErrCode } + // Ban-scope enforcement (migration 000045). The runtime treats + // BanScopeLogin as "this account cannot authenticate" — the row's + // banned_at is set in tandem so legacy callers still see the user + // as banned, but the bit makes the rule auditable. + if scope, scopeErr := s.userRepository.GetBanState(ctx, atoken.UserID); scopeErr == nil { + if models.BanScope(scope).Has(models.BanScopeLogin) { + return nil, errx.New(errx.Forbidden, "this account has been suspended") + } + } + newToken, err := s.tokenService.GenerateSession(ctx, atoken.UserID, "", ipaddr, userAgent, token.AuthProviderEmail) if err != nil { return nil, err diff --git a/internal/app/emailsend/service.go b/internal/app/emailsend/service.go index 0c9624f5..720c593a 100644 --- a/internal/app/emailsend/service.go +++ b/internal/app/emailsend/service.go @@ -8,6 +8,7 @@ import ( "github.com/warmbly/warmbly/internal/app/feature" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/infrastructure/gtasks" + "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/repository" "github.com/warmbly/warmbly/internal/scheduler" "github.com/warmbly/warmbly/internal/tasks/proto" @@ -38,6 +39,7 @@ type EmailSendService interface { type emailSendService struct { taskRepo repository.TaskRepository emailRepo repository.EmailRepository + userRepo repository.UserRepository scheduler scheduler.SchedulerService tasksClient *gtasks.Client featureGate feature.FeatureGateService @@ -46,6 +48,7 @@ type emailSendService struct { func NewService( taskRepo repository.TaskRepository, emailRepo repository.EmailRepository, + userRepo repository.UserRepository, scheduler scheduler.SchedulerService, tasksClient *gtasks.Client, featureGate feature.FeatureGateService, @@ -53,6 +56,7 @@ func NewService( return &emailSendService{ taskRepo: taskRepo, emailRepo: emailRepo, + userRepo: userRepo, scheduler: scheduler, tasksClient: tasksClient, featureGate: featureGate, @@ -60,6 +64,17 @@ func NewService( } func (s *emailSendService) SendEmail(ctx context.Context, userID, orgID, accountID uuid.UUID, req *SendEmailRequest) (*SendEmailResponse, *errx.Error) { + // Ban-scope enforcement (migration 000045). Block outbound send + // when the admin set BanScopeSend, even if the user can otherwise + // log in and inspect their account. + if s.userRepo != nil { + if scope, scopeErr := s.userRepo.GetBanState(ctx, userID); scopeErr == nil { + if models.BanScope(scope).Has(models.BanScopeSend) { + return nil, errx.New(errx.Forbidden, "this account cannot send email") + } + } + } + // Validate email account exists and belongs to user/org _, xerr := s.emailRepo.GetByID(ctx, accountID) if xerr != nil { diff --git a/internal/app/organization/service.go b/internal/app/organization/service.go index a4963439..bc651aa0 100644 --- a/internal/app/organization/service.go +++ b/internal/app/organization/service.go @@ -122,6 +122,15 @@ func NewService( // Create creates a new organization and adds the user as owner func (s *organizationService) Create(ctx context.Context, userID uuid.UUID, name string) (*models.Organization, *errx.Error) { + // Ban-scope enforcement (migration 000045). Block new workspace + // creation when the admin's set the BanScopeOrgCreate bit, even + // if the user can otherwise log in. + if scope, scopeErr := s.userRepo.GetBanState(ctx, userID); scopeErr == nil { + if models.BanScope(scope).Has(models.BanScopeOrgCreate) { + return nil, errx.New(errx.Forbidden, "this account cannot create new workspaces") + } + } + // Check organization limit user, userErr := s.userRepo.GetUser(ctx, userID) if userErr != nil { diff --git a/internal/repository/pg_user.go b/internal/repository/pg_user.go index e89452fe..380f0de7 100644 --- a/internal/repository/pg_user.go +++ b/internal/repository/pg_user.go @@ -23,6 +23,11 @@ type UserRepository interface { SetFreeTrialUsed(ctx context.Context, userID uuid.UUID) error UpdateOnboarding(ctx context.Context, userID uuid.UUID, firstName, lastName, referralSource string) error UpdateAvatar(ctx context.Context, userID uuid.UUID, avatarURL *string) error + + // GetBanState returns the user's ban_scope bitmask (0 = not + // banned). Used by middleware to enforce BanScopeLogin etc. + // without re-fetching the full user row. + GetBanState(ctx context.Context, userID uuid.UUID) (scope uint32, err error) } type userRepository struct { @@ -156,3 +161,14 @@ func (r *userRepository) UpdateAvatar(ctx context.Context, userID uuid.UUID, ava _, err := r.DB.Exec(ctx, q, userID, avatarURL) return err } + +// GetBanState reads only ban_scope — banned_at is implied by +// scope > 0 since unban sets both back to zero. Returns 0 for unbanned +// users and for users that don't exist (the latter is fine because +// those callers fail elsewhere on the auth check). +func (r *userRepository) GetBanState(ctx context.Context, userID uuid.UUID) (uint32, error) { + const q = `SELECT ban_scope FROM users WHERE id = $1` + var scope uint32 + err := r.DB.QueryRow(ctx, q, userID).Scan(&scope) + return scope, err +}