[management] Prevent deleting custom domains used by services (#7515)

Deleting a custom domain released its name while services still pointed
at it, leaving them on a namespace the account no longer held.

Deletion now refuses with 412 when a service in the same account uses the
domain or a subdomain, including disabled ones. Service writes revalidate
authorization inside their transaction and hold a shared lock on the
matching registrations, so a delete racing a create cannot strand either.
The dependency lookup is account-scoped: registrations are unique by name,
so another account can hold team.example.com under example.com and its
services are authorized by its own registration.
This commit is contained in:
Maycon Santos
2026-09-28 14:14:32 +02:00
committed by GitHub
parent c6aa6c232e
commit 979571a99f
12 changed files with 424 additions and 10 deletions
@@ -4,13 +4,16 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/rs/xid"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
nbdomain "github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/shared/management/status"
)
@@ -131,16 +134,45 @@ func (s *SqlStore) UpdateCustomDomain(ctx context.Context, accountID string, d *
return d, nil
}
func (s *SqlStore) DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error {
result := s.db.Delete(domain.Domain{}, accountAndIDQueryCondition, accountID, domainID)
if result.Error != nil {
log.WithContext(ctx).Errorf("failed to delete reverse proxy custom domain from store: %v", result.Error)
return status.Errorf(status.Internal, "failed to delete reverse proxy custom domain from store")
// LockCustomDomains holds shared locks on registrations covering a service until commit.
func (s *SqlStore) LockCustomDomains(ctx context.Context, accountID string, serviceDomain nbdomain.Domain) ([]*domain.Domain, error) {
var names []string
for name := serviceDomain.PunycodeString(); name != ""; {
names = append(names, name)
_, name, _ = strings.Cut(name, ".")
}
if result.RowsAffected == 0 {
return status.Errorf(status.NotFound, "reverse proxy custom domain %s not found", domainID)
var domains []*domain.Domain
if err := s.db.WithContext(ctx).Clauses(clause.Locking{Strength: string(LockingStrengthShare)}).
Where(accountIDCondition, accountID).Where("domain IN ?", names).
Order("id").Find(&domains).Error; err != nil {
return nil, fmt.Errorf("lock custom domains: %w", err)
}
return domains, nil
}
return nil
// DeleteCustomDomain removes a registration only when no service uses its namespace.
func (s *SqlStore) DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var d domain.Domain
// Service writes hold a shared lock on this row through commit, so neither
// operation can proceed against the other's outdated view of the domain.
if err := tx.Clauses(clause.Locking{Strength: string(LockingStrengthUpdate)}).
Take(&d, accountAndIDQueryCondition, accountID, domainID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return status.Errorf(status.NotFound, "custom domain not found")
}
return fmt.Errorf("lock custom domain for deletion: %w", err)
}
result := tx.Where(accountAndIDQueryCondition, accountID, domainID).
Where("NOT EXISTS (?)", customDomainServices(tx, &d).Select("1")).Delete(&domain.Domain{})
if result.Error != nil {
return fmt.Errorf("delete custom domain: %w", result.Error)
}
if result.RowsAffected == 0 {
return status.Errorf(status.PreconditionFailed, "custom domain has dependent services; delete or move them before deleting the domain")
}
return nil
})
}