From ee2ae456537162d153bc74adae30d142a64e0d75 Mon Sep 17 00:00:00 2001 From: pascal Date: Thu, 12 Feb 2026 14:31:23 +0100 Subject: [PATCH] add permissions validation to domain manager --- .../modules/reverseproxy/domain/domain.go | 17 +++ .../modules/reverseproxy/domain/interface.go | 12 ++ .../reverseproxy/domain/{ => manager}/api.go | 20 +-- .../domain/{ => manager}/manager.go | 126 ++++++++++++------ .../modules/reverseproxy/domain/validator.go | 26 +--- .../modules/reverseproxy/manager/api.go | 6 +- management/internals/server/modules.go | 8 +- management/server/http/handler.go | 7 +- .../testing/testing_tools/channel/channel.go | 4 +- 9 files changed, 144 insertions(+), 82 deletions(-) create mode 100644 management/internals/modules/reverseproxy/domain/domain.go create mode 100644 management/internals/modules/reverseproxy/domain/interface.go rename management/internals/modules/reverseproxy/domain/{ => manager}/api.go (87%) rename management/internals/modules/reverseproxy/domain/{ => manager}/manager.go (64%) diff --git a/management/internals/modules/reverseproxy/domain/domain.go b/management/internals/modules/reverseproxy/domain/domain.go new file mode 100644 index 000000000..bae79de2c --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/domain.go @@ -0,0 +1,17 @@ +package domain + +type DomainType string + +const ( + TypeFree DomainType = "free" + TypeCustom DomainType = "custom" +) + +type Domain struct { + ID string `gorm:"unique;primaryKey;autoIncrement"` + Domain string `gorm:"unique"` // Domain records must be unique, this avoids domain reuse across accounts. + AccountID string `gorm:"index"` + TargetCluster string // The proxy cluster this domain should be validated against + Type DomainType `gorm:"-"` + Validated bool +} diff --git a/management/internals/modules/reverseproxy/domain/interface.go b/management/internals/modules/reverseproxy/domain/interface.go new file mode 100644 index 000000000..d40e9b637 --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/interface.go @@ -0,0 +1,12 @@ +package domain + +import ( + "context" +) + +type Manager interface { + GetDomains(ctx context.Context, accountID, userID string) ([]*Domain, error) + CreateDomain(ctx context.Context, accountID, userID, domainName, targetCluster string) (*Domain, error) + DeleteDomain(ctx context.Context, accountID, userID, domainID string) error + ValidateDomain(ctx context.Context, accountID, userID, domainID string) +} diff --git a/management/internals/modules/reverseproxy/domain/api.go b/management/internals/modules/reverseproxy/domain/manager/api.go similarity index 87% rename from management/internals/modules/reverseproxy/domain/api.go rename to management/internals/modules/reverseproxy/domain/manager/api.go index 87b6a4f26..7b73552c6 100644 --- a/management/internals/modules/reverseproxy/domain/api.go +++ b/management/internals/modules/reverseproxy/domain/manager/api.go @@ -1,10 +1,12 @@ -package domain +package manager import ( "encoding/json" "net/http" "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/http/util" @@ -26,11 +28,11 @@ func RegisterEndpoints(router *mux.Router, manager Manager) { router.HandleFunc("/domains/{domainId}/validate", h.triggerCustomDomainValidation).Methods("GET", "OPTIONS") } -func domainTypeToApi(t domainType) api.ReverseProxyDomainType { +func domainTypeToApi(t domain.DomainType) api.ReverseProxyDomainType { switch t { - case TypeCustom: + case domain.TypeCustom: return api.ReverseProxyDomainTypeCustom - case TypeFree: + case domain.TypeFree: return api.ReverseProxyDomainTypeFree } // By default return as a "free" domain as that is more restrictive. @@ -38,7 +40,7 @@ func domainTypeToApi(t domainType) api.ReverseProxyDomainType { return api.ReverseProxyDomainTypeFree } -func domainToApi(d *Domain) api.ReverseProxyDomain { +func domainToApi(d *domain.Domain) api.ReverseProxyDomain { resp := api.ReverseProxyDomain{ Domain: d.Domain, Id: d.ID, @@ -58,7 +60,7 @@ func (h *handler) getAllDomains(w http.ResponseWriter, r *http.Request) { return } - domains, err := h.manager.GetDomains(r.Context(), userAuth.AccountId) + domains, err := h.manager.GetDomains(r.Context(), userAuth.AccountId, userAuth.UserId) if err != nil { util.WriteError(r.Context(), err, w) return @@ -85,7 +87,7 @@ func (h *handler) createCustomDomain(w http.ResponseWriter, r *http.Request) { return } - domain, err := h.manager.CreateDomain(r.Context(), userAuth.AccountId, req.Domain, req.TargetCluster) + domain, err := h.manager.CreateDomain(r.Context(), userAuth.AccountId, userAuth.UserId, req.Domain, req.TargetCluster) if err != nil { util.WriteError(r.Context(), err, w) return @@ -107,7 +109,7 @@ func (h *handler) deleteCustomDomain(w http.ResponseWriter, r *http.Request) { return } - if err := h.manager.DeleteDomain(r.Context(), userAuth.AccountId, domainID); err != nil { + if err := h.manager.DeleteDomain(r.Context(), userAuth.AccountId, userAuth.UserId, domainID); err != nil { util.WriteError(r.Context(), err, w) return } @@ -128,7 +130,7 @@ func (h *handler) triggerCustomDomainValidation(w http.ResponseWriter, r *http.R return } - go h.manager.ValidateDomain(userAuth.AccountId, domainID) + go h.manager.ValidateDomain(r.Context(), userAuth.AccountId, userAuth.UserId, domainID) w.WriteHeader(http.StatusAccepted) } diff --git a/management/internals/modules/reverseproxy/domain/manager.go b/management/internals/modules/reverseproxy/domain/manager/manager.go similarity index 64% rename from management/internals/modules/reverseproxy/domain/manager.go rename to management/internals/modules/reverseproxy/domain/manager/manager.go index b54365f91..9d96678a3 100644 --- a/management/internals/modules/reverseproxy/domain/manager.go +++ b/management/internals/modules/reverseproxy/domain/manager/manager.go @@ -1,4 +1,4 @@ -package domain +package manager import ( "context" @@ -7,34 +7,24 @@ import ( "net/url" "strings" - "github.com/netbirdio/netbird/management/server/types" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" ) -type domainType string - -const ( - TypeFree domainType = "free" - TypeCustom domainType = "custom" -) - -type Domain struct { - ID string `gorm:"unique;primaryKey;autoIncrement"` - Domain string `gorm:"unique"` // Domain records must be unique, this avoids domain reuse across accounts. - AccountID string `gorm:"index"` - TargetCluster string // The proxy cluster this domain should be validated against - Type domainType `gorm:"-"` - Validated bool -} - type store interface { GetAccount(ctx context.Context, accountID string) (*types.Account, error) - GetCustomDomain(ctx context.Context, accountID string, domainID string) (*Domain, error) + GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error) ListFreeDomains(ctx context.Context, accountID string) ([]string, error) - ListCustomDomains(ctx context.Context, accountID string) ([]*Domain, error) - CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*Domain, error) - UpdateCustomDomain(ctx context.Context, accountID string, d *Domain) (*Domain, error) + ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) + CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error) + UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error) DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error } @@ -43,28 +33,38 @@ type proxyURLProvider interface { } type Manager struct { - store store - validator Validator - proxyURLProvider proxyURLProvider + store store + validator domain.Validator + proxyURLProvider proxyURLProvider + permissionsManager permissions.Manager } -func NewManager(store store, proxyURLProvider proxyURLProvider) Manager { +func NewManager(store store, proxyURLProvider proxyURLProvider, permissionsManager permissions.Manager) Manager { return Manager{ store: store, proxyURLProvider: proxyURLProvider, - validator: Validator{ - resolver: net.DefaultResolver, + validator: domain.Validator{ + Resolver: net.DefaultResolver, }, + permissionsManager: permissionsManager, } } -func (m Manager) GetDomains(ctx context.Context, accountID string) ([]*Domain, error) { +func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*domain.Domain, error) { + ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + domains, err := m.store.ListCustomDomains(ctx, accountID) if err != nil { return nil, fmt.Errorf("list custom domains: %w", err) } - var ret []*Domain + var ret []*domain.Domain // Add connected proxy clusters as free domains. // The cluster address itself is the free domain base (e.g., "eu.proxy.netbird.io"). @@ -75,30 +75,37 @@ func (m Manager) GetDomains(ctx context.Context, accountID string) ([]*Domain, e }).Debug("getting domains with proxy allow list") for _, cluster := range allowList { - ret = append(ret, &Domain{ + ret = append(ret, &domain.Domain{ Domain: cluster, AccountID: accountID, - Type: TypeFree, + Type: domain.TypeFree, Validated: true, }) } // Add custom domains. - for _, domain := range domains { - ret = append(ret, &Domain{ - ID: domain.ID, - Domain: domain.Domain, + for _, d := range domains { + ret = append(ret, &domain.Domain{ + ID: d.ID, + Domain: d.Domain, AccountID: accountID, - TargetCluster: domain.TargetCluster, - Type: TypeCustom, - Validated: domain.Validated, + TargetCluster: d.TargetCluster, + Type: domain.TypeCustom, + Validated: d.Validated, }) } return ret, nil } -func (m Manager) CreateDomain(ctx context.Context, accountID, domainName, targetCluster string) (*Domain, error) { +func (m Manager) CreateDomain(ctx context.Context, accountID, userID, domainName, targetCluster string) (*domain.Domain, error) { + ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } // Verify the target cluster is in the available clusters allowList := m.proxyURLAllowList() @@ -126,7 +133,15 @@ func (m Manager) CreateDomain(ctx context.Context, accountID, domainName, target return d, nil } -func (m Manager) DeleteDomain(ctx context.Context, accountID, domainID string) error { +func (m Manager) DeleteDomain(ctx context.Context, accountID, userID, domainID string) error { + ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete) + if err != nil { + return status.NewPermissionValidationError(err) + } + if !ok { + return status.NewPermissionDeniedError() + } + if err := m.store.DeleteCustomDomain(ctx, accountID, domainID); err != nil { // TODO: check for "no records" type error. Because that is a success condition. return fmt.Errorf("delete domain from store: %w", err) @@ -134,7 +149,22 @@ func (m Manager) DeleteDomain(ctx context.Context, accountID, domainID string) e return nil } -func (m Manager) ValidateDomain(accountID, domainID string) { +func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID string) { + ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create) + if err != nil { + log.WithFields(log.Fields{ + "accountID": accountID, + "domainID": domainID, + }).WithError(err).Error("validate domain") + return + } + if !ok { + log.WithFields(log.Fields{ + "accountID": accountID, + "domainID": domainID, + }).WithError(err).Error("validate domain") + } + log.WithFields(log.Fields{ "accountID": accountID, "domainID": domainID, @@ -260,3 +290,15 @@ func (m Manager) DeriveClusterFromDomain(ctx context.Context, domain string) (st return "", fmt.Errorf("domain %s does not match any available proxy cluster", domain) } + +// ExtractClusterFromFreeDomain extracts the cluster address from a free domain. +// Free domains have the format: .. (e.g., myapp.abc123.eu.proxy.netbird.io) +// It matches the domain suffix against available clusters and returns the matching cluster. +func ExtractClusterFromFreeDomain(domain string, availableClusters []string) (string, bool) { + for _, cluster := range availableClusters { + if strings.HasSuffix(domain, "."+cluster) { + return cluster, true + } + } + return "", false +} diff --git a/management/internals/modules/reverseproxy/domain/validator.go b/management/internals/modules/reverseproxy/domain/validator.go index bb9c09035..9c23c1192 100644 --- a/management/internals/modules/reverseproxy/domain/validator.go +++ b/management/internals/modules/reverseproxy/domain/validator.go @@ -13,15 +13,15 @@ type resolver interface { } type Validator struct { - resolver resolver + Resolver resolver } -// NewValidator initializes a validator with a specific DNS resolver. -// If a Validator is used without specifying a resolver, then it will +// NewValidator initializes a validator with a specific DNS Resolver. +// If a Validator is used without specifying a Resolver, then it will // use the net.DefaultResolver. func NewValidator(resolver resolver) *Validator { return &Validator{ - resolver: resolver, + Resolver: resolver, } } @@ -39,8 +39,8 @@ func (v *Validator) IsValid(ctx context.Context, domain string, accept []string) // ValidateWithCluster validates a custom domain and returns the matched cluster address. // Returns the cluster address and true if valid, or empty string and false if invalid. func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, accept []string) (string, bool) { - if v.resolver == nil { - v.resolver = net.DefaultResolver + if v.Resolver == nil { + v.Resolver = net.DefaultResolver } lookupDomain := "validation." + domain @@ -50,7 +50,7 @@ func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, acce "acceptList": accept, }).Debug("looking up CNAME for domain validation") - cname, err := v.resolver.LookupCNAME(ctx, lookupDomain) + cname, err := v.Resolver.LookupCNAME(ctx, lookupDomain) if err != nil { log.WithFields(log.Fields{ "domain": domain, @@ -86,15 +86,3 @@ func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, acce }).Warn("domain CNAME does not match any accepted cluster") return "", false } - -// ExtractClusterFromFreeDomain extracts the cluster address from a free domain. -// Free domains have the format: .. (e.g., myapp.abc123.eu.proxy.netbird.io) -// It matches the domain suffix against available clusters and returns the matching cluster. -func ExtractClusterFromFreeDomain(domain string, availableClusters []string) (string, bool) { - for _, cluster := range availableClusters { - if strings.HasSuffix(domain, "."+cluster) { - return cluster, true - } - } - return "", false -} diff --git a/management/internals/modules/reverseproxy/manager/api.go b/management/internals/modules/reverseproxy/manager/api.go index fb4c69809..9117ecd38 100644 --- a/management/internals/modules/reverseproxy/manager/api.go +++ b/management/internals/modules/reverseproxy/manager/api.go @@ -9,7 +9,7 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/reverseproxy" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager" - "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + domainmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager" nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/http/util" @@ -21,13 +21,13 @@ type handler struct { } // RegisterEndpoints registers all service HTTP endpoints. -func RegisterEndpoints(manager reverseproxy.Manager, domainManager domain.Manager, accessLogsManager accesslogs.Manager, router *mux.Router) { +func RegisterEndpoints(manager reverseproxy.Manager, domainManager domainmanager.Manager, accessLogsManager accesslogs.Manager, router *mux.Router) { h := &handler{ manager: manager, } domainRouter := router.PathPrefix("/reverse-proxies").Subrouter() - domain.RegisterEndpoints(domainRouter, domainManager) + domainmanager.RegisterEndpoints(domainRouter, domainManager) accesslogsmanager.RegisterEndpoints(router, accessLogsManager) diff --git a/management/internals/server/modules.go b/management/internals/server/modules.go index d83dde58d..58125c0a3 100644 --- a/management/internals/server/modules.go +++ b/management/internals/server/modules.go @@ -9,7 +9,7 @@ import ( "github.com/netbirdio/management-integrations/integrations" "github.com/netbirdio/netbird/management/internals/modules/peers" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy" - "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager" nbreverseproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/manager" "github.com/netbirdio/netbird/management/internals/modules/zones" zonesManager "github.com/netbirdio/netbird/management/internals/modules/zones/manager" @@ -196,9 +196,9 @@ func (s *BaseServer) ReverseProxyManager() reverseproxy.Manager { }) } -func (s *BaseServer) ReverseProxyDomainManager() *domain.Manager { - return Create(s, func() *domain.Manager { - m := domain.NewManager(s.Store(), s.ReverseProxyGRPCServer()) +func (s *BaseServer) ReverseProxyDomainManager() *manager.Manager { + return Create(s, func() *manager.Manager { + m := manager.NewManager(s.Store(), s.ReverseProxyGRPCServer(), s.PermissionsManager()) return &m }) } diff --git a/management/server/http/handler.go b/management/server/http/handler.go index 11b7a340e..6e8b3cc55 100644 --- a/management/server/http/handler.go +++ b/management/server/http/handler.go @@ -9,13 +9,14 @@ import ( "time" "github.com/gorilla/mux" - "github.com/netbirdio/netbird/management/server/types" "github.com/rs/cors" log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" - "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" reverseproxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/manager" nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" idpmanager "github.com/netbirdio/netbird/management/server/idp" @@ -67,7 +68,7 @@ const ( ) // NewAPIHandler creates the Management service HTTP API handler registering all the available endpoints. -func NewAPIHandler(ctx context.Context, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, integratedValidator integrated_validator.IntegratedValidator, proxyController port_forwarding.Controller, permissionsManager permissions.Manager, peersManager nbpeers.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, reverseProxyManager reverseproxy.Manager, reverseProxyDomainManager *domain.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer) (http.Handler, error) { +func NewAPIHandler(ctx context.Context, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, integratedValidator integrated_validator.IntegratedValidator, proxyController port_forwarding.Controller, permissionsManager permissions.Manager, peersManager nbpeers.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, reverseProxyManager reverseproxy.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer) (http.Handler, error) { // Register bypass paths for unauthenticated endpoints if err := bypass.AddBypassPath("/api/instance"); err != nil { diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go index 78fcb39f2..ad9d56d5e 100644 --- a/management/server/http/testing/testing_tools/channel/channel.go +++ b/management/server/http/testing/testing_tools/channel/channel.go @@ -11,7 +11,7 @@ import ( "github.com/netbirdio/management-integrations/integrations" accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager" - "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager" reverseproxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/manager" nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" @@ -93,7 +93,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee accessLogsManager := accesslogsmanager.NewManager(store, permissionsManager, nil) proxyTokenStore := nbgrpc.NewOneTimeTokenStore(1 * time.Minute) proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager) - domainManager := domain.NewManager(store, proxyServiceServer) + domainManager := manager.NewManager(store, proxyServiceServer) reverseProxyManager := reverseproxymanager.NewManager(store, am, permissionsManager, proxyServiceServer, domainManager) proxyServiceServer.SetProxyManager(reverseProxyManager) am.SetServiceManager(reverseProxyManager)