diff --git a/management/internals/modules/agentnetwork/settings_bootstrap_test.go b/management/internals/modules/agentnetwork/settings_bootstrap_test.go index 6ac8c869b..388dc7823 100644 --- a/management/internals/modules/agentnetwork/settings_bootstrap_test.go +++ b/management/internals/modules/agentnetwork/settings_bootstrap_test.go @@ -108,6 +108,21 @@ func (f *bootstrapFixture) seedEmbeddedCluster(t *testing.T, clusterAddr string) f.seedProxy(t, "proxy-"+clusterAddr, "", clusterAddr, ptrTo(true)) } +// requireForeignClusterRefusal asserts the refusal a pin onto another +// account's host gets, and that it left no row behind. +func (f *bootstrapFixture) requireForeignClusterRefusal(t *testing.T, err error, accountID string) { + t.Helper() + require.Error(t, err, "another account's host must be refused") + var sErr *status.Error + require.ErrorAs(t, err, &sErr) + assert.Equal(t, status.InvalidArgument, sErr.Type(), "rejection must be a validation error") + assert.Contains(t, err.Error(), "not available to this account", + "the error must say the host is not the account's to use") + + _, err = f.store.GetAgentNetworkSettings(context.Background(), store.LockingStrengthNone, accountID) + assert.Error(t, err, "no row may be left behind by a rejected bootstrap") +} + // TestCreateSettingsRequiresPermission pins the gate: bootstrap assigns the // account's immutable endpoint, a settings write requiring the settings // Create permission — and a denial leaves no row behind. @@ -271,20 +286,6 @@ func TestCreateProviderHasNoSettingsSideEffects(t *testing.T) { assert.Error(t, err, "provider create must not conjure a settings row") } -// TestCreateSettingsAllowsUnknownCluster pins the one opening left: a cluster -// management holds no proxy row for cannot be judged, so the pin is allowed — -// the same order the dedicated path documents (claim the address, connect the -// proxy after). -func TestCreateSettingsAllowsUnknownCluster(t *testing.T) { - ctx := context.Background() - f := newBootstrapFixture(t) - f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true) - - created, err := f.createSettings(ctx, "account1", "user1", "future.example.com", "") - require.NoError(t, err, "a cluster no proxy has ever declared must stay pinnable") - assert.Equal(t, "future.example.com", created.ProxyAddress) -} - // TestCreateSettingsRejectsOfflineCluster is the guard against deciding on // heartbeat freshness. A centralised cluster is refused while its proxies are // live; the same cluster must stay refused once they stop heartbeating, which @@ -387,3 +388,99 @@ func TestCreateSettingsMatchesClusterCasing(t *testing.T) { assert.Contains(t, err.Error(), "embedded proxy") }) } + +// TestCreateSettingsRejectsForeignCluster pins tenant consistency on the pin: +// an account may not pin its gateway onto a host another account's proxy +// declares. That proxy only ever receives its own account's mappings, so the +// pin could never be served, and the endpoint it assigns is immutable. +// Ownership is decided on the proxy rows, not on heartbeat freshness — a +// cluster whose proxies are merely offline is still somebody's — and on the +// normalised host, since proxies declare their address as the operator +// spelled it. +func TestCreateSettingsRejectsForeignCluster(t *testing.T) { + ctx := context.Background() + + cases := map[string]struct { + spelling string + lastSeen time.Time + }{ + "live": {"byop.account2.example.com", time.Now().UTC()}, + "offline": {"byop.account2.example.com", time.Now().UTC().Add(-time.Hour)}, + "spelled in caps": {"BYOP.Account2.Example.com", time.Now().UTC()}, + } + for name, tc := range cases { + t.Run("labeled "+name, func(t *testing.T) { + f := newBootstrapFixture(t) + f.seedProxyAt(t, "proxy1", "account2", tc.spelling, ptrTo(true), tc.lastSeen) + f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true) + + _, err := f.createSettings(ctx, "account1", "user1", "byop.account2.example.com", "") + f.requireForeignClusterRefusal(t, err, "account1") + }) + t.Run("self-addressed "+name, func(t *testing.T) { + f := newBootstrapFixture(t) + f.seedProxyAt(t, "proxy1", "account2", tc.spelling, ptrTo(true), tc.lastSeen) + f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true) + + _, err := f.createSettings(ctx, "account1", "user1", "", "byop.account2.example.com") + f.requireForeignClusterRefusal(t, err, "account1") + }) + } +} + +// TestCreateSettingsSharedClusterStaysPinnable pins the constraint the +// ownership check must respect: a shared (NetBird-operated) cluster is not +// anybody's, so any number of accounts pin their gateways to it — including +// an account that also runs a proxy of its own elsewhere. +func TestCreateSettingsSharedClusterStaysPinnable(t *testing.T) { + ctx := context.Background() + f := newBootstrapFixture(t) + f.seedProxy(t, "shared", "", "eu.proxy.netbird.io", ptrTo(true)) + f.seedProxy(t, "own", "account1", "byop.account1.example.com", ptrTo(true)) + + for _, account := range []string{"account1", "account2"} { + f.expectPermission(account, "user", modules.AgentNetworkSettings, operations.Create, true) + created, err := f.createSettings(ctx, account, "user", "eu.proxy.netbird.io", "") + require.NoError(t, err, "a shared cluster must stay pinnable by %s", account) + assert.Equal(t, "eu.proxy.netbird.io", created.ProxyAddress) + } +} + +// TestCreateSettingsOwnClusterIsPinnable is the BYOP order in both directions: +// the account's own proxy is not a competing claim, whether the pin is labeled +// beneath its cluster or self-addressed onto the very host it declares. +func TestCreateSettingsOwnClusterIsPinnable(t *testing.T) { + ctx := context.Background() + + t.Run("labeled", func(t *testing.T) { + f := newBootstrapFixture(t) + f.seedProxy(t, "own", "account1", "byop.account1.example.com", ptrTo(true)) + f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true) + + created, err := f.createSettings(ctx, "account1", "user1", "byop.account1.example.com", "") + require.NoError(t, err, "the account's own cluster must be pinnable") + assert.True(t, strings.HasSuffix(created.Domain, ".byop.account1.example.com")) + }) + t.Run("self-addressed", func(t *testing.T) { + f := newBootstrapFixture(t) + f.seedProxy(t, "own", "account1", "gw.account1.example.com", ptrTo(true)) + f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true) + + created, err := f.createSettings(ctx, "account1", "user1", "", "gw.account1.example.com") + require.NoError(t, err, "the host the account's own proxy declares must be pinnable") + assert.Equal(t, "gw.account1.example.com", created.ProxyAddress) + }) +} + +// TestCreateSettingsUnknownHostIsPinnable pins the address-first order: a host +// no proxy has ever declared is nobody's, so the pin goes through and the +// proxy is deployed after. +func TestCreateSettingsUnknownHostIsPinnable(t *testing.T) { + ctx := context.Background() + f := newBootstrapFixture(t) + f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true) + + created, err := f.createSettings(ctx, "account1", "user1", "future.example.com", "") + require.NoError(t, err, "a host no proxy has declared must stay pinnable") + assert.Equal(t, "future.example.com", created.ProxyAddress) +}