Compare commits

...

5 Commits

Author SHA1 Message Date
mlsmaycon
f4af85e130 add some extra logs 2026-02-03 14:00:54 +01:00
Misha Bragin
b20d484972 [docs] Add selfhosting video (#5235) 2026-02-01 16:06:36 +01:00
Vlad
8931293343 [management] run cancelPeerRoutinesWithoutLock in sync (#5234) 2026-02-01 15:44:27 +01:00
Vlad
7b830d8f72 disable sync lim (#5233) 2026-02-01 14:37:00 +01:00
Misha Bragin
3a0cf230a1 Disable local users for a smooth single-idp mode (#5226)
Add LocalAuthDisabled option to embedded IdP configuration

This adds the ability to disable local (email/password) authentication when using the embedded Dex identity provider. When disabled, users can only authenticate via external
identity providers (Google, OIDC, etc.).

This simplifies user login when there is only one external IdP configured. The login page will redirect directly to the IdP login page.

Key changes:

Added LocalAuthDisabled field to EmbeddedIdPConfig
Added methods to check and toggle local auth: IsLocalAuthEnabled, HasNonLocalConnectors, DisableLocalAuth, EnableLocalAuth
Validation prevents disabling local auth if no external connectors are configured
Existing local users are preserved when disabled and can login again when re-enabled
Operations are idempotent (disabling already disabled is a no-op)
2026-02-01 14:26:22 +01:00
26 changed files with 838 additions and 31 deletions

View File

@@ -60,8 +60,8 @@
https://github.com/user-attachments/assets/10cec749-bb56-4ab3-97af-4e38850108d2
### NetBird on Lawrence Systems (Video)
[![Watch the video](https://img.youtube.com/vi/Kwrff6h0rEw/0.jpg)](https://www.youtube.com/watch?v=Kwrff6h0rEw)
### Self-Host NetBird (Video)
[![Watch the video](https://img.youtube.com/vi/bZAgpT6nzaQ/0.jpg)](https://youtu.be/bZAgpT6nzaQ)
### Key features

View File

@@ -828,8 +828,16 @@ func (e *Engine) handleAutoUpdateVersion(autoUpdateSettings *mgmProto.AutoUpdate
}
func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
started := time.Now()
defer func() {
log.Warnf("sync with lock finished in %s", time.Since(started))
}()
e.syncMsgMux.Lock()
defer e.syncMsgMux.Unlock()
started2 := time.Now()
defer func() {
log.Warnf("sync finished in %s", time.Since(started2))
}()
// Check context INSIDE lock to ensure atomicity with shutdown
if e.ctx.Err() != nil {

View File

@@ -319,6 +319,8 @@ func (m *DefaultManager) Stop(stateManager *statemanager.Manager) {
// UpdateRoutes compares received routes with existing routes and removes, updates or adds them to the client and server maps
func (m *DefaultManager) updateSystemRoutes(newRoutes route.HAMap) error {
startTotal := time.Now()
toAdd := make(map[route.HAUniqueID]*route.Route)
toRemove := make(map[route.HAUniqueID]client.RouteHandler)
@@ -337,13 +339,20 @@ func (m *DefaultManager) updateSystemRoutes(newRoutes route.HAMap) error {
}
var merr *multierror.Error
startRemove := time.Now()
for id, handler := range toRemove {
if err := handler.RemoveRoute(); err != nil {
merr = multierror.Append(merr, fmt.Errorf("remove route %s: %w", handler.String(), err))
}
delete(m.activeRoutes, id)
}
if len(toRemove) > 0 {
log.Warnf("[TIMING] updateSystemRoutes: removed %d routes in %v", len(toRemove), time.Since(startRemove))
}
startAdd := time.Now()
addedCount := 0
for id, route := range toAdd {
params := common.HandlerParams{
Route: route,
@@ -365,7 +374,14 @@ func (m *DefaultManager) updateSystemRoutes(newRoutes route.HAMap) error {
continue
}
m.activeRoutes[id] = handler
addedCount++
}
if len(toAdd) > 0 {
log.Warnf("[TIMING] updateSystemRoutes: added %d routes in %v (%.2f routes/sec)",
addedCount, time.Since(startAdd), float64(addedCount)/time.Since(startAdd).Seconds())
}
log.Warnf("[TIMING] updateSystemRoutes: total %d routes processed in %v", len(toAdd)+len(toRemove), time.Since(startTotal))
return nberrors.FormatErrorOrNil(merr)
}

View File

@@ -7,6 +7,7 @@ import (
"runtime"
"strings"
"sync"
"time"
"github.com/hashicorp/go-multierror"
log "github.com/sirupsen/logrus"
@@ -110,7 +111,11 @@ func (rm *Counter[Key, I, O]) increment(key Key, in I) (Ref[O], error) {
// Call AddFunc only if it's a new key
if ref.Count == 0 {
logCallerF("Calling add for key %v", key)
startTime := time.Now()
out, err := rm.add(key, in)
if elapsed := time.Since(startTime); elapsed > 10*time.Millisecond {
log.Warnf("[TIMING] refcounter.add(%v): %v", key, elapsed)
}
if errors.Is(err, ErrIgnore) {
return ref, nil

View File

@@ -0,0 +1,290 @@
//go:build windows
package systemops
import (
"net"
"net/netip"
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
"golang.zx2c4.com/wireguard/tun"
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
)
const (
testInterfaceName = "wg_bench_test"
testInterfaceGUID = "{a1b2c3d4-e5f6-7890-abcd-ef1234567890}"
testInterfaceMTU = 1280
benchmarkRoutes = 4000
)
// BenchmarkRouteAddition benchmarks route addition using both interface-only and address-based methods.
func BenchmarkRouteAddition(b *testing.B) {
log.SetLevel(log.WarnLevel)
tunDev, ifaceIdx, cleanup := setupBenchmarkInterface(b)
defer cleanup()
gatewayIP := netip.MustParseAddr("10.200.0.254")
b.Run("InterfaceOnly", func(b *testing.B) {
benchmarkRouteMode(b, ifaceIdx, netip.Addr{}, benchmarkRoutes)
})
b.Run("WithGatewayAddress", func(b *testing.B) {
benchmarkRouteMode(b, ifaceIdx, gatewayIP, benchmarkRoutes)
})
_ = tunDev
}
// TestRouteAdditionSpeed tests and compares route addition speed for both modes.
func TestRouteAdditionSpeed(t *testing.T) {
if testing.Short() {
t.Skip("Skipping route benchmark test in short mode")
}
log.SetLevel(log.WarnLevel)
tunDev, ifaceIdx, cleanup := setupBenchmarkInterface(t)
defer cleanup()
gatewayIP := netip.MustParseAddr("10.200.0.254")
numRoutes := benchmarkRoutes
t.Logf("Testing route addition with %d routes on interface index %d", numRoutes, ifaceIdx)
// Test interface-only mode
t.Run("InterfaceOnly", func(t *testing.T) {
routes := generateTestPrefixes(numRoutes, 0)
nexthop := Nexthop{
Intf: &net.Interface{Index: ifaceIdx},
}
start := time.Now()
addedRoutes := addRoutesWithCleanup(t, routes, nexthop)
addDuration := time.Since(start)
t.Logf("Interface-only mode: added %d routes in %v (%.2f routes/sec)",
addedRoutes, addDuration, float64(addedRoutes)/addDuration.Seconds())
start = time.Now()
deleteRoutes(t, routes[:addedRoutes], nexthop)
deleteDuration := time.Since(start)
t.Logf("Interface-only mode: deleted %d routes in %v (%.2f routes/sec)",
addedRoutes, deleteDuration, float64(addedRoutes)/deleteDuration.Seconds())
})
// Test address-based mode
t.Run("WithGatewayAddress", func(t *testing.T) {
routes := generateTestPrefixes(numRoutes, 1)
nexthop := Nexthop{
IP: gatewayIP,
Intf: &net.Interface{Index: ifaceIdx},
}
start := time.Now()
addedRoutes := addRoutesWithCleanup(t, routes, nexthop)
addDuration := time.Since(start)
t.Logf("Address-based mode: added %d routes in %v (%.2f routes/sec)",
addedRoutes, addDuration, float64(addedRoutes)/addDuration.Seconds())
start = time.Now()
deleteRoutes(t, routes[:addedRoutes], nexthop)
deleteDuration := time.Since(start)
t.Logf("Address-based mode: deleted %d routes in %v (%.2f routes/sec)",
addedRoutes, deleteDuration, float64(addedRoutes)/deleteDuration.Seconds())
})
_ = tunDev
}
// TestRouteAdditionSpeedComparison runs a direct comparison test and outputs results.
func TestRouteAdditionSpeedComparison(t *testing.T) {
if testing.Short() {
t.Skip("Skipping route benchmark comparison test in short mode")
}
log.SetLevel(log.WarnLevel)
tunDev, ifaceIdx, cleanup := setupBenchmarkInterface(t)
defer cleanup()
gatewayIP := netip.MustParseAddr("10.200.0.254")
numRoutes := benchmarkRoutes
t.Logf("=== Route Addition Speed Comparison ===")
t.Logf("Testing with %d routes on interface index %d", numRoutes, ifaceIdx)
t.Logf("")
// Interface-only mode test
routesIfaceOnly := generateTestPrefixes(numRoutes, 0)
nexthopIfaceOnly := Nexthop{
Intf: &net.Interface{Index: ifaceIdx},
}
startIfaceOnly := time.Now()
addedIfaceOnly := addRoutesWithCleanup(t, routesIfaceOnly, nexthopIfaceOnly)
durationIfaceOnly := time.Since(startIfaceOnly)
deleteRoutes(t, routesIfaceOnly[:addedIfaceOnly], nexthopIfaceOnly)
// Address-based mode test
routesWithAddr := generateTestPrefixes(numRoutes, 1)
nexthopWithAddr := Nexthop{
IP: gatewayIP,
Intf: &net.Interface{Index: ifaceIdx},
}
startWithAddr := time.Now()
addedWithAddr := addRoutesWithCleanup(t, routesWithAddr, nexthopWithAddr)
durationWithAddr := time.Since(startWithAddr)
deleteRoutes(t, routesWithAddr[:addedWithAddr], nexthopWithAddr)
// Output comparison results
t.Logf("")
t.Logf("=== Results ===")
t.Logf("Interface-only mode (gateway=0.0.0.0):")
t.Logf(" Routes added: %d", addedIfaceOnly)
t.Logf(" Duration: %v", durationIfaceOnly)
t.Logf(" Speed: %.2f routes/sec", float64(addedIfaceOnly)/durationIfaceOnly.Seconds())
t.Logf("")
t.Logf("Address-based mode (gateway=%s):", gatewayIP)
t.Logf(" Routes added: %d", addedWithAddr)
t.Logf(" Duration: %v", durationWithAddr)
t.Logf(" Speed: %.2f routes/sec", float64(addedWithAddr)/durationWithAddr.Seconds())
t.Logf("")
if durationIfaceOnly < durationWithAddr {
speedup := float64(durationWithAddr) / float64(durationIfaceOnly)
t.Logf("Interface-only mode is %.2fx faster", speedup)
} else {
speedup := float64(durationIfaceOnly) / float64(durationWithAddr)
t.Logf("Address-based mode is %.2fx faster", speedup)
}
_ = tunDev
}
func setupBenchmarkInterface(tb testing.TB) (*tun.NativeTun, int, func()) {
tb.Helper()
guid, err := windows.GUIDFromString(testInterfaceGUID)
require.NoError(tb, err, "Failed to parse GUID")
tunDevice, err := tun.CreateTUNWithRequestedGUID(testInterfaceName, &guid, testInterfaceMTU)
require.NoError(tb, err, "Failed to create TUN device")
nativeTun := tunDevice.(*tun.NativeTun)
ifaceName, err := nativeTun.Name()
require.NoError(tb, err, "Failed to get interface name")
iface, err := net.InterfaceByName(ifaceName)
require.NoError(tb, err, "Failed to get interface by name")
tb.Logf("Created test interface: %s (index: %d)", ifaceName, iface.Index)
// Assign an IP address to the interface using winipcfg
assignInterfaceAddress(tb, nativeTun)
cleanup := func() {
if err := tunDevice.Close(); err != nil {
tb.Logf("Failed to close TUN device: %v", err)
}
}
return nativeTun, iface.Index, cleanup
}
func assignInterfaceAddress(tb testing.TB, nativeTun *tun.NativeTun) {
tb.Helper()
luid := winipcfg.LUID(nativeTun.LUID())
addr := netip.MustParsePrefix("10.200.0.1/24")
err := luid.SetIPAddresses([]netip.Prefix{addr})
require.NoError(tb, err, "Failed to assign IP address to interface")
// Allow the network stack to fully initialize the interface.
time.Sleep(100 * time.Millisecond)
tb.Logf("Assigned address %s to interface (LUID: %d)", addr, luid)
}
func generateTestPrefixes(count int, offset int) []netip.Prefix {
prefixes := make([]netip.Prefix, count)
// Generate unique /32 prefixes in the 172.16.0.0/12 range
baseIP := 172<<24 | 16<<16
for i := 0; i < count; i++ {
ipNum := baseIP + i + (offset * count)
ip := netip.AddrFrom4([4]byte{
byte(ipNum >> 24),
byte(ipNum >> 16),
byte(ipNum >> 8),
byte(ipNum),
})
prefixes[i] = netip.PrefixFrom(ip, 32)
}
return prefixes
}
func addRoutesWithCleanup(tb testing.TB, prefixes []netip.Prefix, nexthop Nexthop) int {
tb.Helper()
added := 0
for _, prefix := range prefixes {
if err := addRoute(prefix, nexthop); err != nil {
tb.Logf("Failed to add route %s after %d successful additions: %v", prefix, added, err)
break
}
added++
}
return added
}
func deleteRoutes(tb testing.TB, prefixes []netip.Prefix, nexthop Nexthop) {
tb.Helper()
for _, prefix := range prefixes {
if err := deleteRoute(prefix, nexthop); err != nil {
log.Debugf("Failed to delete route %s: %v", prefix, err)
}
}
}
func benchmarkRouteMode(b *testing.B, ifaceIdx int, gatewayIP netip.Addr, routeCount int) {
b.Helper()
for i := 0; i < b.N; i++ {
b.StopTimer()
prefixes := generateTestPrefixes(routeCount, i)
nexthop := Nexthop{
Intf: &net.Interface{Index: ifaceIdx},
}
if gatewayIP.IsValid() {
nexthop.IP = gatewayIP
}
b.StartTimer()
for _, prefix := range prefixes {
if err := addRoute(prefix, nexthop); err != nil {
b.Fatalf("Failed to add route: %v", err)
}
}
b.StopTimer()
for _, prefix := range prefixes {
_ = deleteRoute(prefix, nexthop)
}
}
}

View File

@@ -95,7 +95,12 @@ func (r *SysOps) getSeq() int {
return int(r.seq.Add(1))
}
var t = true
func (r *SysOps) validateRoute(prefix netip.Prefix) error {
if t {
return nil
}
addr := prefix.Addr()
switch {

View File

@@ -208,6 +208,11 @@ func (r *SysOps) refreshLocalSubnetsCache() {
// genericAddVPNRoute adds a new route to the vpn interface, it splits the default prefix
// in two /1 prefixes to avoid replacing the existing default route
func (r *SysOps) genericAddVPNRoute(prefix netip.Prefix, intf *net.Interface) error {
startTime := time.Now()
defer func() {
log.Warnf("[TIMING] genericAddVPNRoute(%s): total %v", prefix, time.Since(startTime))
}()
nextHop := Nexthop{netip.Addr{}, intf}
switch prefix {
@@ -339,6 +344,13 @@ func (r *SysOps) setupHooks(initAddresses []net.IP, stateManager *statemanager.M
}
func GetNextHop(ip netip.Addr) (Nexthop, error) {
startTime := time.Now()
defer func() {
if elapsed := time.Since(startTime); elapsed > 10*time.Millisecond {
log.Warnf("[TIMING] GetNextHop(%s): %v", ip, elapsed)
}
}()
r, err := netroute.New()
if err != nil {
return Nexthop{}, fmt.Errorf("new netroute: %w", err)

View File

@@ -211,6 +211,13 @@ func (r *SysOps) CleanupRouting(stateManager *statemanager.Manager, advancedRout
}
func (r *SysOps) addToRouteTable(prefix netip.Prefix, nexthop Nexthop) error {
startTime := time.Now()
defer func() {
if elapsed := time.Since(startTime); elapsed > 5*time.Millisecond {
log.Warnf("[TIMING] addToRouteTable(%s): %v", prefix, elapsed)
}
}()
log.Debugf("Adding route to %s via %s", prefix, nexthop)
// if we don't have an interface but a zone, extract the interface index from the zone
if nexthop.IP.Zone() != "" && nexthop.Intf == nil {
@@ -266,16 +273,25 @@ func addRoute(prefix netip.Prefix, nexthop Nexthop) (err error) {
}
}()
setupStart := time.Now()
route, setupErr := setupRouteEntry(prefix, nexthop)
if setupErr != nil {
return fmt.Errorf("setup route entry: %w", setupErr)
}
setupDuration := time.Since(setupStart)
route.Metric = 1
route.ValidLifetime = InfiniteLifetime
route.PreferredLifetime = InfiniteLifetime
return createIPForwardEntry2(route)
apiStart := time.Now()
err = createIPForwardEntry2(route)
apiDuration := time.Since(apiStart)
if setupDuration > 1*time.Millisecond || apiDuration > 1*time.Millisecond {
log.Warnf("[TIMING] addRoute(%s): setup=%v api=%v", prefix, setupDuration, apiDuration)
}
return err
}
// deleteRoute deletes a route using Windows iphelper APIs
@@ -561,11 +577,14 @@ func cancelMibChangeNotify2(handle windows.Handle) error {
// GetRoutesFromTable returns the current routing table from with prefixes only.
// It caches the result for 2 seconds to avoid blocking the caller.
func GetRoutesFromTable() ([]netip.Prefix, error) {
startTime := time.Now()
mux.Lock()
defer mux.Unlock()
// If many routes are added at the same time this might block for a long time (seconds to minutes), so we cache the result
if !isCacheDisabled() && time.Since(lastUpdate) < 2*time.Second {
log.Warnf("[TIMING] GetRoutesFromTable: cache hit, returning %d routes in %v", len(prefixList), time.Since(startTime))
return prefixList, nil
}
@@ -580,17 +599,20 @@ func GetRoutesFromTable() ([]netip.Prefix, error) {
}
lastUpdate = time.Now()
log.Warnf("[TIMING] GetRoutesFromTable: fetched %d routes in %v", len(prefixList), time.Since(startTime))
return prefixList, nil
}
// GetRoutes retrieves the current routing table using WMI.
func GetRoutes() ([]Route, error) {
startTime := time.Now()
var entries []MSFT_NetRoute
query := `SELECT DestinationPrefix, Nexthop, InterfaceIndex, InterfaceAlias, AddressFamily FROM MSFT_NetRoute`
if err := wmi.QueryNamespace(query, &entries, `ROOT\StandardCimv2`); err != nil {
return nil, fmt.Errorf("get routes: %w", err)
}
log.Warnf("[TIMING] GetRoutes WMI query: fetched %d entries in %v", len(entries), time.Since(startTime))
var routes []Route
for _, entry := range entries {
@@ -903,6 +925,13 @@ func sortRouteCandidates(candidates []candidateRoute) {
// 2. Lowest route metric
// 3. Lowest interface metric
func GetBestInterface(dest netip.Addr, vpnIntf string) (*net.Interface, error) {
startTime := time.Now()
defer func() {
if elapsed := time.Since(startTime); elapsed > 10*time.Millisecond {
log.Warnf("[TIMING] GetBestInterface(%s): %v", dest, elapsed)
}
}()
var skipInterfaceIndex int
if vpnIntf != "" {
if iface, err := net.InterfaceByName(vpnIntf); err == nil {
@@ -913,11 +942,15 @@ func GetBestInterface(dest netip.Addr, vpnIntf string) (*net.Interface, error) {
}
}
tableStart := time.Now()
table, err := getWindowsRoutingTable()
if err != nil {
return nil, fmt.Errorf("get routing table: %w", err)
}
defer freeWindowsRoutingTable(table)
if elapsed := time.Since(tableStart); elapsed > 5*time.Millisecond {
log.Warnf("[TIMING] GetBestInterface: getWindowsRoutingTable took %v", elapsed)
}
candidates := parseCandidatesFromTable(table, dest, skipInterfaceIndex)

View File

@@ -327,6 +327,60 @@ func ensureLocalConnector(ctx context.Context, stor storage.Storage) error {
return nil
}
// HasNonLocalConnectors checks if there are any connectors other than the local connector.
func (p *Provider) HasNonLocalConnectors(ctx context.Context) (bool, error) {
connectors, err := p.storage.ListConnectors(ctx)
if err != nil {
return false, fmt.Errorf("failed to list connectors: %w", err)
}
p.logger.Info("checking for non-local connectors", "total_connectors", len(connectors))
for _, conn := range connectors {
p.logger.Info("found connector in storage", "id", conn.ID, "type", conn.Type, "name", conn.Name)
if conn.ID != "local" || conn.Type != "local" {
p.logger.Info("found non-local connector", "id", conn.ID)
return true, nil
}
}
p.logger.Info("no non-local connectors found")
return false, nil
}
// DisableLocalAuth removes the local (password) connector.
// Returns an error if no other connectors are configured.
func (p *Provider) DisableLocalAuth(ctx context.Context) error {
hasOthers, err := p.HasNonLocalConnectors(ctx)
if err != nil {
return err
}
if !hasOthers {
return fmt.Errorf("cannot disable local authentication: no other identity providers configured")
}
// Check if local connector exists
_, err = p.storage.GetConnector(ctx, "local")
if errors.Is(err, storage.ErrNotFound) {
// Already disabled
return nil
}
if err != nil {
return fmt.Errorf("failed to check local connector: %w", err)
}
// Delete the local connector
if err := p.storage.DeleteConnector(ctx, "local"); err != nil {
return fmt.Errorf("failed to delete local connector: %w", err)
}
p.logger.Info("local authentication disabled")
return nil
}
// EnableLocalAuth creates the local (password) connector if it doesn't exist.
func (p *Provider) EnableLocalAuth(ctx context.Context) error {
return ensureLocalConnector(ctx, p.storage)
}
// ensureStaticConnectors creates or updates static connectors in storage
func ensureStaticConnectors(ctx context.Context, stor storage.Storage, connectors []Connector) error {
for _, conn := range connectors {

View File

@@ -69,7 +69,14 @@ func (s *BaseServer) UsersManager() users.Manager {
func (s *BaseServer) SettingsManager() settings.Manager {
return Create(s, func() settings.Manager {
extraSettingsManager := integrations.NewManager(s.EventStore())
return settings.NewManager(s.Store(), s.UsersManager(), extraSettingsManager, s.PermissionsManager())
idpConfig := settings.IdpConfig{}
if s.Config.EmbeddedIdP != nil && s.Config.EmbeddedIdP.Enabled {
idpConfig.EmbeddedIdpEnabled = true
idpConfig.LocalAuthDisabled = s.Config.EmbeddedIdP.LocalAuthDisabled
}
return settings.NewManager(s.Store(), s.UsersManager(), extraSettingsManager, s.PermissionsManager(), idpConfig)
})
}

View File

@@ -77,8 +77,9 @@ type Server struct {
oAuthConfigProvider idp.OAuthConfigProvider
syncSem atomic.Int32
syncLim int32
syncSem atomic.Int32
syncLimEnabled bool
syncLim int32
}
// NewServer creates a new Management server
@@ -108,6 +109,7 @@ func NewServer(
blockPeersWithSameConfig := strings.ToLower(os.Getenv(envBlockPeers)) == "true"
syncLim := int32(defaultSyncLim)
syncLimEnabled := true
if syncLimStr := os.Getenv(envConcurrentSyncs); syncLimStr != "" {
syncLimParsed, err := strconv.Atoi(syncLimStr)
if err != nil {
@@ -115,6 +117,9 @@ func NewServer(
} else {
//nolint:gosec
syncLim = int32(syncLimParsed)
if syncLim < 0 {
syncLimEnabled = false
}
}
}
@@ -134,7 +139,8 @@ func NewServer(
loginFilter: newLoginFilter(),
syncLim: syncLim,
syncLim: syncLim,
syncLimEnabled: syncLimEnabled,
}, nil
}
@@ -212,7 +218,7 @@ func (s *Server) Job(srv proto.ManagementService_JobServer) error {
// Sync validates the existence of a connecting peer, sends an initial state (all available for the connecting peers) and
// notifies the connected peer of any updates (e.g. new peers under the same account)
func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_SyncServer) error {
if s.syncSem.Load() >= s.syncLim {
if s.syncLimEnabled && s.syncSem.Load() >= s.syncLim {
return status.Errorf(codes.ResourceExhausted, "too many concurrent sync requests, please try again later")
}
s.syncSem.Add(1)
@@ -305,7 +311,7 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S
if err != nil {
log.WithContext(ctx).Debugf("error while sending initial sync for %s: %v", peerKey.String(), err)
s.syncSem.Add(-1)
s.cancelPeerRoutines(ctx, accountID, peer)
s.cancelPeerRoutinesWithoutLock(ctx, accountID, peer)
return err
}
@@ -313,7 +319,7 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S
if err != nil {
log.WithContext(ctx).Debugf("error while notify peer connected for %s: %v", peerKey.String(), err)
s.syncSem.Add(-1)
s.cancelPeerRoutines(ctx, accountID, peer)
s.cancelPeerRoutinesWithoutLock(ctx, accountID, peer)
return err
}
@@ -484,6 +490,10 @@ func (s *Server) cancelPeerRoutines(ctx context.Context, accountID string, peer
unlock := s.acquirePeerLockByUID(ctx, peer.Key)
defer unlock()
s.cancelPeerRoutinesWithoutLock(ctx, accountID, peer)
}
func (s *Server) cancelPeerRoutinesWithoutLock(ctx context.Context, accountID string, peer *nbpeer.Peer) {
err := s.accountManager.OnPeerDisconnected(ctx, accountID, peer.Key)
if err != nil {
log.WithContext(ctx).Errorf("failed to disconnect peer %s properly: %v", peer.Key, err)

View File

@@ -26,7 +26,6 @@ import (
"golang.org/x/exp/maps"
nbdns "github.com/netbirdio/netbird/dns"
nbdomain "github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/formatter/hook"
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
@@ -49,6 +48,7 @@ import (
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/management/server/util"
"github.com/netbirdio/netbird/route"
nbdomain "github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/shared/management/status"
)
@@ -795,6 +795,19 @@ func IsEmbeddedIdp(i idp.Manager) bool {
return ok
}
// IsLocalAuthDisabled checks if local (email/password) authentication is disabled.
// Returns true only when using embedded IDP with local auth disabled in config.
func IsLocalAuthDisabled(ctx context.Context, i idp.Manager) bool {
if isNil(i) {
return false
}
embeddedIdp, ok := i.(*idp.EmbeddedIdPManager)
if !ok {
return false
}
return embeddedIdp.IsLocalAuthDisabled()
}
// addAccountIDToIDPAppMeta update user's app metadata in idp manager
func (am *DefaultAccountManager) addAccountIDToIDPAppMeta(ctx context.Context, userID string, accountID string) error {
if !isNil(am.idpManager) && !IsEmbeddedIdp(am.idpManager) {

View File

@@ -129,14 +129,14 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks
return nil, fmt.Errorf("register integrations endpoints: %w", err)
}
// Check if embedded IdP is enabled
// Check if embedded IdP is enabled for instance manager
embeddedIdP, embeddedIdpEnabled := idpManager.(*idpmanager.EmbeddedIdPManager)
instanceManager, err := nbinstance.NewManager(ctx, accountManager.GetStore(), embeddedIdP)
if err != nil {
return nil, fmt.Errorf("failed to create instance manager: %w", err)
}
accounts.AddEndpoints(accountManager, settingsManager, embeddedIdpEnabled, router)
accounts.AddEndpoints(accountManager, settingsManager, router)
peers.AddEndpoints(accountManager, router, networkMapController)
users.AddEndpoints(accountManager, router)
users.AddInvitesEndpoints(accountManager, router)

View File

@@ -36,24 +36,22 @@ const (
// handler is a handler that handles the server.Account HTTP endpoints
type handler struct {
accountManager account.Manager
settingsManager settings.Manager
embeddedIdpEnabled bool
accountManager account.Manager
settingsManager settings.Manager
}
func AddEndpoints(accountManager account.Manager, settingsManager settings.Manager, embeddedIdpEnabled bool, router *mux.Router) {
accountsHandler := newHandler(accountManager, settingsManager, embeddedIdpEnabled)
func AddEndpoints(accountManager account.Manager, settingsManager settings.Manager, router *mux.Router) {
accountsHandler := newHandler(accountManager, settingsManager)
router.HandleFunc("/accounts/{accountId}", accountsHandler.updateAccount).Methods("PUT", "OPTIONS")
router.HandleFunc("/accounts/{accountId}", accountsHandler.deleteAccount).Methods("DELETE", "OPTIONS")
router.HandleFunc("/accounts", accountsHandler.getAllAccounts).Methods("GET", "OPTIONS")
}
// newHandler creates a new handler HTTP handler
func newHandler(accountManager account.Manager, settingsManager settings.Manager, embeddedIdpEnabled bool) *handler {
func newHandler(accountManager account.Manager, settingsManager settings.Manager) *handler {
return &handler{
accountManager: accountManager,
settingsManager: settingsManager,
embeddedIdpEnabled: embeddedIdpEnabled,
accountManager: accountManager,
settingsManager: settingsManager,
}
}
@@ -165,7 +163,7 @@ func (h *handler) getAllAccounts(w http.ResponseWriter, r *http.Request) {
return
}
resp := toAccountResponse(accountID, settings, meta, onboarding, h.embeddedIdpEnabled)
resp := toAccountResponse(accountID, settings, meta, onboarding)
util.WriteJSONObject(r.Context(), w, []*api.Account{resp})
}
@@ -292,7 +290,7 @@ func (h *handler) updateAccount(w http.ResponseWriter, r *http.Request) {
return
}
resp := toAccountResponse(accountID, updatedSettings, meta, updatedOnboarding, h.embeddedIdpEnabled)
resp := toAccountResponse(accountID, updatedSettings, meta, updatedOnboarding)
util.WriteJSONObject(r.Context(), w, &resp)
}
@@ -321,7 +319,7 @@ func (h *handler) deleteAccount(w http.ResponseWriter, r *http.Request) {
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
}
func toAccountResponse(accountID string, settings *types.Settings, meta *types.AccountMeta, onboarding *types.AccountOnboarding, embeddedIdpEnabled bool) *api.Account {
func toAccountResponse(accountID string, settings *types.Settings, meta *types.AccountMeta, onboarding *types.AccountOnboarding) *api.Account {
jwtAllowGroups := settings.JWTAllowGroups
if jwtAllowGroups == nil {
jwtAllowGroups = []string{}
@@ -341,7 +339,8 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A
LazyConnectionEnabled: &settings.LazyConnectionEnabled,
DnsDomain: &settings.DNSDomain,
AutoUpdateVersion: &settings.AutoUpdateVersion,
EmbeddedIdpEnabled: &embeddedIdpEnabled,
EmbeddedIdpEnabled: &settings.EmbeddedIdpEnabled,
LocalAuthDisabled: &settings.LocalAuthDisabled,
}
if settings.NetworkRange.IsValid() {

View File

@@ -33,7 +33,6 @@ func initAccountsTestData(t *testing.T, account *types.Account) *handler {
AnyTimes()
return &handler{
embeddedIdpEnabled: false,
accountManager: &mock_server.MockAccountManager{
GetAccountSettingsFunc: func(ctx context.Context, accountID string, userID string) (*types.Settings, error) {
return account.Settings, nil
@@ -124,6 +123,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
DnsDomain: sr(""),
AutoUpdateVersion: sr(""),
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
},
expectedArray: true,
expectedID: accountID,
@@ -148,6 +148,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
DnsDomain: sr(""),
AutoUpdateVersion: sr(""),
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
},
expectedArray: false,
expectedID: accountID,
@@ -172,6 +173,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
DnsDomain: sr(""),
AutoUpdateVersion: sr("latest"),
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
},
expectedArray: false,
expectedID: accountID,
@@ -196,6 +198,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
DnsDomain: sr(""),
AutoUpdateVersion: sr(""),
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
},
expectedArray: false,
expectedID: accountID,
@@ -220,6 +223,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
DnsDomain: sr(""),
AutoUpdateVersion: sr(""),
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
},
expectedArray: false,
expectedID: accountID,
@@ -244,6 +248,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
DnsDomain: sr(""),
AutoUpdateVersion: sr(""),
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
},
expectedArray: false,
expectedID: accountID,

View File

@@ -46,7 +46,7 @@ func (h *handler) getInstanceStatus(w http.ResponseWriter, r *http.Request) {
util.WriteErrorResponse("failed to check instance status", http.StatusInternalServerError, w)
return
}
log.WithContext(r.Context()).Infof("instance setup status: %v", setupRequired)
util.WriteJSONObject(r.Context(), w, api.InstanceStatus{
SetupRequired: setupRequired,
})

View File

@@ -205,6 +205,14 @@ func TestCreateInvite(t *testing.T) {
return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
},
},
{
name: "local auth disabled",
requestBody: `{"email":"test@example.com","name":"Test User","role":"user","auto_groups":[]}`,
expectedStatus: http.StatusPreconditionFailed,
mockFunc: func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) {
return nil, status.Errorf(status.PreconditionFailed, "local user creation is disabled - use an external identity provider")
},
},
{
name: "invalid JSON",
requestBody: `{invalid json}`,
@@ -376,6 +384,15 @@ func TestAcceptInvite(t *testing.T) {
return status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
},
},
{
name: "local auth disabled",
token: testInviteToken,
requestBody: `{"password":"SecurePass123!"}`,
expectedStatus: http.StatusPreconditionFailed,
mockFunc: func(ctx context.Context, token, password string) error {
return status.Errorf(status.PreconditionFailed, "local user creation is disabled - use an external identity provider")
},
},
{
name: "missing token",
token: "",

View File

@@ -73,7 +73,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
proxyController := integrations.NewController(store)
userManager := users.NewManager(store)
permissionsManager := permissions.NewManager(store)
settingsManager := settings.NewManager(store, userManager, integrations.NewManager(&activity.InMemoryEventStore{}), permissionsManager)
settingsManager := settings.NewManager(store, userManager, integrations.NewManager(&activity.InMemoryEventStore{}), permissionsManager, settings.IdpConfig{})
peersManager := peers.NewManager(store, permissionsManager)
jobManager := job.NewJobManager(nil, store, peersManager)

View File

@@ -43,6 +43,11 @@ type EmbeddedIdPConfig struct {
Owner *OwnerConfig
// SignKeyRefreshEnabled enables automatic key rotation for signing keys
SignKeyRefreshEnabled bool
// LocalAuthDisabled disables the local (email/password) authentication connector.
// When true, users cannot authenticate via email/password, only via external identity providers.
// Existing local users are preserved and will be able to login again if re-enabled.
// Cannot be enabled if no external identity provider connectors are configured.
LocalAuthDisabled bool
}
// EmbeddedStorageConfig holds storage configuration for the embedded IdP.
@@ -105,6 +110,8 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) {
Issuer: "NetBird",
Theme: "light",
},
// Always enable password DB initially - we disable the local connector after startup if needed.
// This ensures Dex has at least one connector during initialization.
EnablePasswordDB: true,
StaticClients: []storage.Client{
{
@@ -192,11 +199,32 @@ func NewEmbeddedIdPManager(ctx context.Context, config *EmbeddedIdPConfig, appMe
return nil, err
}
log.WithContext(ctx).Debugf("initializing embedded Dex IDP with config: %+v", config)
provider, err := dex.NewProviderFromYAML(ctx, yamlConfig)
if err != nil {
return nil, fmt.Errorf("failed to create embedded IdP provider: %w", err)
}
// If local auth is disabled, validate that other connectors exist
if config.LocalAuthDisabled {
hasOthers, err := provider.HasNonLocalConnectors(ctx)
if err != nil {
_ = provider.Stop(ctx)
return nil, fmt.Errorf("failed to check connectors: %w", err)
}
if !hasOthers {
_ = provider.Stop(ctx)
return nil, fmt.Errorf("cannot disable local authentication: no other identity providers configured")
}
// Ensure local connector is removed (it might exist from a previous run)
if err := provider.DisableLocalAuth(ctx); err != nil {
_ = provider.Stop(ctx)
return nil, fmt.Errorf("failed to disable local auth: %w", err)
}
log.WithContext(ctx).Info("local authentication disabled - only external identity providers can be used")
}
log.WithContext(ctx).Infof("embedded Dex IDP initialized with issuer: %s", yamlConfig.Issuer)
return &EmbeddedIdPManager{
@@ -281,6 +309,8 @@ func (m *EmbeddedIdPManager) GetAllAccounts(ctx context.Context) (map[string][]*
return nil, fmt.Errorf("failed to list users: %w", err)
}
log.WithContext(ctx).Debugf("retrieved %d users from embedded IdP", len(users))
indexedUsers := make(map[string][]*UserData)
for _, user := range users {
indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], &UserData{
@@ -290,11 +320,17 @@ func (m *EmbeddedIdPManager) GetAllAccounts(ctx context.Context) (map[string][]*
})
}
log.WithContext(ctx).Debugf("retrieved %d users from embedded IdP", len(indexedUsers[UnsetAccountID]))
return indexedUsers, nil
}
// CreateUser creates a new user in the embedded IdP.
func (m *EmbeddedIdPManager) CreateUser(ctx context.Context, email, name, accountID, invitedByEmail string) (*UserData, error) {
if m.config.LocalAuthDisabled {
return nil, fmt.Errorf("local user creation is disabled")
}
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountCreateUser()
}
@@ -364,6 +400,10 @@ func (m *EmbeddedIdPManager) GetUserByEmail(ctx context.Context, email string) (
// Unlike CreateUser which auto-generates a password, this method uses the provided password.
// This is useful for instance setup where the user provides their own password.
func (m *EmbeddedIdPManager) CreateUserWithPassword(ctx context.Context, email, password, name string) (*UserData, error) {
if m.config.LocalAuthDisabled {
return nil, fmt.Errorf("local user creation is disabled")
}
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountCreateUser()
}
@@ -553,3 +593,13 @@ func (m *EmbeddedIdPManager) GetClientIDs() []string {
func (m *EmbeddedIdPManager) GetUserIDClaim() string {
return defaultUserIDClaim
}
// IsLocalAuthDisabled returns whether local authentication is disabled based on configuration.
func (m *EmbeddedIdPManager) IsLocalAuthDisabled() bool {
return m.config.LocalAuthDisabled
}
// HasNonLocalConnectors checks if there are any identity provider connectors other than local.
func (m *EmbeddedIdPManager) HasNonLocalConnectors(ctx context.Context) (bool, error) {
return m.provider.HasNonLocalConnectors(ctx)
}

View File

@@ -370,3 +370,234 @@ func TestEmbeddedIdPManager_GetLocalKeysLocation(t *testing.T) {
})
}
}
func TestEmbeddedIdPManager_LocalAuthDisabled(t *testing.T) {
ctx := context.Background()
t.Run("cannot start with local auth disabled without other connectors", func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
config := &EmbeddedIdPConfig{
Enabled: true,
Issuer: "http://localhost:5556/dex",
LocalAuthDisabled: true,
Storage: EmbeddedStorageConfig{
Type: "sqlite3",
Config: EmbeddedStorageTypeConfig{
File: filepath.Join(tmpDir, "dex.db"),
},
},
}
_, err = NewEmbeddedIdPManager(ctx, config, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "no other identity providers configured")
})
t.Run("local auth enabled by default", func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
config := &EmbeddedIdPConfig{
Enabled: true,
Issuer: "http://localhost:5556/dex",
Storage: EmbeddedStorageConfig{
Type: "sqlite3",
Config: EmbeddedStorageTypeConfig{
File: filepath.Join(tmpDir, "dex.db"),
},
},
}
manager, err := NewEmbeddedIdPManager(ctx, config, nil)
require.NoError(t, err)
defer func() { _ = manager.Stop(ctx) }()
// Verify local auth is enabled by default
assert.False(t, manager.IsLocalAuthDisabled())
})
t.Run("start with local auth disabled when connector exists", func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
dbFile := filepath.Join(tmpDir, "dex.db")
// First, create a manager with local auth enabled and add a connector
config1 := &EmbeddedIdPConfig{
Enabled: true,
Issuer: "http://localhost:5556/dex",
Storage: EmbeddedStorageConfig{
Type: "sqlite3",
Config: EmbeddedStorageTypeConfig{
File: dbFile,
},
},
}
manager1, err := NewEmbeddedIdPManager(ctx, config1, nil)
require.NoError(t, err)
// Create a user
userData, err := manager1.CreateUser(ctx, "preserved@example.com", "Preserved User", "account1", "admin@example.com")
require.NoError(t, err)
userID := userData.ID
// Add an external connector (Google doesn't require OIDC discovery)
_, err = manager1.CreateConnector(ctx, &dex.ConnectorConfig{
ID: "google-test",
Name: "Google Test",
Type: "google",
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
})
require.NoError(t, err)
// Stop the first manager
err = manager1.Stop(ctx)
require.NoError(t, err)
// Now create a new manager with local auth disabled
config2 := &EmbeddedIdPConfig{
Enabled: true,
Issuer: "http://localhost:5556/dex",
LocalAuthDisabled: true,
Storage: EmbeddedStorageConfig{
Type: "sqlite3",
Config: EmbeddedStorageTypeConfig{
File: dbFile,
},
},
}
manager2, err := NewEmbeddedIdPManager(ctx, config2, nil)
require.NoError(t, err)
defer func() { _ = manager2.Stop(ctx) }()
// Verify local auth is disabled via config
assert.True(t, manager2.IsLocalAuthDisabled())
// Verify the user still exists in storage (just can't login via local)
lookedUp, err := manager2.GetUserDataByID(ctx, userID, AppMetadata{})
require.NoError(t, err)
assert.Equal(t, "preserved@example.com", lookedUp.Email)
})
t.Run("CreateUser fails when local auth is disabled", func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
dbFile := filepath.Join(tmpDir, "dex.db")
// First, create a manager and add an external connector
config1 := &EmbeddedIdPConfig{
Enabled: true,
Issuer: "http://localhost:5556/dex",
Storage: EmbeddedStorageConfig{
Type: "sqlite3",
Config: EmbeddedStorageTypeConfig{
File: dbFile,
},
},
}
manager1, err := NewEmbeddedIdPManager(ctx, config1, nil)
require.NoError(t, err)
_, err = manager1.CreateConnector(ctx, &dex.ConnectorConfig{
ID: "google-test",
Name: "Google Test",
Type: "google",
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
})
require.NoError(t, err)
err = manager1.Stop(ctx)
require.NoError(t, err)
// Create manager with local auth disabled
config2 := &EmbeddedIdPConfig{
Enabled: true,
Issuer: "http://localhost:5556/dex",
LocalAuthDisabled: true,
Storage: EmbeddedStorageConfig{
Type: "sqlite3",
Config: EmbeddedStorageTypeConfig{
File: dbFile,
},
},
}
manager2, err := NewEmbeddedIdPManager(ctx, config2, nil)
require.NoError(t, err)
defer func() { _ = manager2.Stop(ctx) }()
// Try to create a user - should fail
_, err = manager2.CreateUser(ctx, "newuser@example.com", "New User", "account1", "admin@example.com")
require.Error(t, err)
assert.Contains(t, err.Error(), "local user creation is disabled")
})
t.Run("CreateUserWithPassword fails when local auth is disabled", func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
dbFile := filepath.Join(tmpDir, "dex.db")
// First, create a manager and add an external connector
config1 := &EmbeddedIdPConfig{
Enabled: true,
Issuer: "http://localhost:5556/dex",
Storage: EmbeddedStorageConfig{
Type: "sqlite3",
Config: EmbeddedStorageTypeConfig{
File: dbFile,
},
},
}
manager1, err := NewEmbeddedIdPManager(ctx, config1, nil)
require.NoError(t, err)
_, err = manager1.CreateConnector(ctx, &dex.ConnectorConfig{
ID: "google-test",
Name: "Google Test",
Type: "google",
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
})
require.NoError(t, err)
err = manager1.Stop(ctx)
require.NoError(t, err)
// Create manager with local auth disabled
config2 := &EmbeddedIdPConfig{
Enabled: true,
Issuer: "http://localhost:5556/dex",
LocalAuthDisabled: true,
Storage: EmbeddedStorageConfig{
Type: "sqlite3",
Config: EmbeddedStorageTypeConfig{
File: dbFile,
},
},
}
manager2, err := NewEmbeddedIdPManager(ctx, config2, nil)
require.NoError(t, err)
defer func() { _ = manager2.Stop(ctx) }()
// Try to create a user with password - should fail
_, err = manager2.CreateUserWithPassword(ctx, "newuser@example.com", "SecurePass123!", "New User")
require.Error(t, err)
assert.Contains(t, err.Error(), "local user creation is disabled")
})
}

View File

@@ -104,13 +104,22 @@ func NewManager(ctx context.Context, store store.Store, idpManager idp.Manager)
}
func (m *DefaultManager) loadSetupRequired(ctx context.Context) error {
// Check if there are any accounts in the NetBird store
numAccounts, err := m.store.GetAccountsCounter(ctx)
if err != nil {
return err
}
hasAccounts := numAccounts > 0
// Check if there are any users in the embedded IdP (Dex)
users, err := m.embeddedIdpManager.GetAllAccounts(ctx)
if err != nil {
return err
}
hasLocalUsers := len(users) > 0
m.setupMu.Lock()
m.setupRequired = len(users) == 0
m.setupRequired = !(hasAccounts || hasLocalUsers)
m.setupMu.Unlock()
return nil

View File

@@ -24,19 +24,28 @@ type Manager interface {
UpdateExtraSettings(ctx context.Context, accountID, userID string, extraSettings *types.ExtraSettings) (bool, error)
}
// IdpConfig holds IdP-related configuration that is set at runtime
// and not stored in the database.
type IdpConfig struct {
EmbeddedIdpEnabled bool
LocalAuthDisabled bool
}
type managerImpl struct {
store store.Store
extraSettingsManager extra_settings.Manager
userManager users.Manager
permissionsManager permissions.Manager
idpConfig IdpConfig
}
func NewManager(store store.Store, userManager users.Manager, extraSettingsManager extra_settings.Manager, permissionsManager permissions.Manager) Manager {
func NewManager(store store.Store, userManager users.Manager, extraSettingsManager extra_settings.Manager, permissionsManager permissions.Manager, idpConfig IdpConfig) Manager {
return &managerImpl{
store: store,
extraSettingsManager: extraSettingsManager,
userManager: userManager,
permissionsManager: permissionsManager,
idpConfig: idpConfig,
}
}
@@ -74,6 +83,10 @@ func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string)
settings.Extra.FlowDnsCollectionEnabled = extraSettings.FlowDnsCollectionEnabled
}
// Fill in IdP-related runtime settings
settings.EmbeddedIdpEnabled = m.idpConfig.EmbeddedIdpEnabled
settings.LocalAuthDisabled = m.idpConfig.LocalAuthDisabled
return settings, nil
}

View File

@@ -55,6 +55,14 @@ type Settings struct {
// AutoUpdateVersion client auto-update version
AutoUpdateVersion string `gorm:"default:'disabled'"`
// EmbeddedIdpEnabled indicates if the embedded identity provider is enabled.
// This is a runtime-only field, not stored in the database.
EmbeddedIdpEnabled bool `gorm:"-"`
// LocalAuthDisabled indicates if local (email/password) authentication is disabled.
// This is a runtime-only field, not stored in the database.
LocalAuthDisabled bool `gorm:"-"`
}
// Copy copies the Settings struct
@@ -76,6 +84,8 @@ func (s *Settings) Copy() *Settings {
DNSDomain: s.DNSDomain,
NetworkRange: s.NetworkRange,
AutoUpdateVersion: s.AutoUpdateVersion,
EmbeddedIdpEnabled: s.EmbeddedIdpEnabled,
LocalAuthDisabled: s.LocalAuthDisabled,
}
if s.Extra != nil {
settings.Extra = s.Extra.Copy()

View File

@@ -191,6 +191,10 @@ func (am *DefaultAccountManager) createNewIdpUser(ctx context.Context, accountID
// Unlike createNewIdpUser, this method fetches user data directly from the database
// since the embedded IdP usage ensures the username and email are stored locally in the User table.
func (am *DefaultAccountManager) createEmbeddedIdpUser(ctx context.Context, accountID string, inviterID string, invite *types.UserInfo) (*idp.UserData, error) {
if IsLocalAuthDisabled(ctx, am.idpManager) {
return nil, status.Errorf(status.PreconditionFailed, "local user creation is disabled - use an external identity provider")
}
inviter, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, inviterID)
if err != nil {
return nil, fmt.Errorf("failed to get inviter user: %w", err)
@@ -1462,6 +1466,10 @@ func (am *DefaultAccountManager) CreateUserInvite(ctx context.Context, accountID
return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
}
if IsLocalAuthDisabled(ctx, am.idpManager) {
return nil, status.Errorf(status.PreconditionFailed, "local user creation is disabled - use an external identity provider")
}
if err := validateUserInvite(invite); err != nil {
return nil, err
}
@@ -1621,6 +1629,10 @@ func (am *DefaultAccountManager) AcceptUserInvite(ctx context.Context, token, pa
return status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
}
if IsLocalAuthDisabled(ctx, am.idpManager) {
return status.Errorf(status.PreconditionFailed, "local user creation is disabled - use an external identity provider")
}
if password == "" {
return status.Errorf(status.InvalidArgument, "password is required")
}

View File

@@ -294,6 +294,11 @@ components:
type: boolean
readOnly: true
example: false
local_auth_disabled:
description: Indicates whether local (email/password) authentication is disabled. When true, users can only authenticate via external identity providers. This is a read-only field.
type: boolean
readOnly: true
example: false
required:
- peer_login_expiration_enabled
- peer_login_expiration

View File

@@ -415,6 +415,9 @@ type AccountSettings struct {
// LazyConnectionEnabled Enables or disables experimental lazy connection
LazyConnectionEnabled *bool `json:"lazy_connection_enabled,omitempty"`
// LocalAuthDisabled Indicates whether local (email/password) authentication is disabled. When true, users can only authenticate via external identity providers. This is a read-only field.
LocalAuthDisabled *bool `json:"local_auth_disabled,omitempty"`
// NetworkRange Allows to define a custom network range for the account in CIDR format
NetworkRange *string `json:"network_range,omitempty"`