feat(ban): runtime enforcement for ban-scope bitmask

The bitmask landed in 000045 with schema + UI; this commit wires the
three gates the bits describe.

  - BanScopeLogin    → authService.LoginConfirm checks the scope after
                       password verification and refuses the session
                       with "this account has been suspended"
  - BanScopeOrgCreate → organizationService.Create checks the scope
                       before any other validation and refuses with
                       "this account cannot create new workspaces"
  - BanScopeSend     → emailSendService.SendEmail checks the scope
                       before validating the email account and refuses
                       with "this account cannot send email"

Adds UserRepository.GetBanState(ctx, userID) → uint32 — a single-column
read so the hot paths don't have to fetch the full user row just to
check a flag. Returns 0 when no ban (the column defaults to 0); the
caller treats 0 as "allow."

Threads userRepo into emailSendService — the only constructor change
in this commit. cmd/backend/main.go updated accordingly.
This commit is contained in:
Matt
2026-05-28 12:43:50 +02:00
parent 3ffa416e40
commit d80efc88b4
5 changed files with 51 additions and 1 deletions
+1 -1
View File
@@ -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,
+10
View File
@@ -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
+15
View File
@@ -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 {
+9
View File
@@ -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 {
+16
View File
@@ -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
}