mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-01 20:41:28 +02:00
* [client] Allow logging out of the active profile when profiles are disabled
A profile-addressed logout was refused outright when the profiles feature is
disabled: handleProfileLogout ran validateProfileOperation, which returned
Unavailable ("profiles are disabled, you cannot use this feature without
profiles enabled") before looking at which profile was targeted.
The desktop UI always addresses logout by profile — both the profile menu and
the session-expiration dialog send the active profile's ID — so a client with
profiles disabled could not log out at all; only a plain `netbird logout`,
which takes the profile-less path, still worked. Logging out of the profile the
daemon is already running is a deregistration, not profile management, and with
profiles disabled there is a single profile anyway, so every profile-addressed
logout is by definition an active-profile logout.
Replace validateProfileOperation with validateProfileLogout, which skips the
profiles-disabled check when the target is the active profile and keeps gating
logout of any other profile. This mirrors switchProfileIfNeeded, which already
gates only the branch that actually manages profiles. The dropped
allowActiveProfile parameter was always true, leaving canRemoveProfile
unreachable, so both are removed.
* [client] Compare the username and propagate state errors on profile logout
Review follow-ups on the logout gate:
Propagate the GetActiveProfileState failure instead of discarding it. A failed
lookup made the target look non-active, so a caller with profiles disabled got
"profiles are disabled" in place of the real error.
Compare the username along with the ID when deciding whether the target is the
active profile, matching switchProfileIfNeeded. Legacy profile IDs are display
names, so two users can hold the same ID in their own profile directories, and
an ID-only match let one user's logout pass the gate against the other user's
active profile. The default profile is shared and carries no username, so it
keeps matching on the ID alone.
Re-read the active profile before the connection teardown rather than reusing
the pre-flight snapshot. Login switches profiles under guardedConfigMu, which
the logout path does not hold, so a login that landed while the deregistration
was in flight would otherwise lose its fresh connection to a stale flag.
* [client] Address review on the profile logout gate
Pass the username down to logoutFromProfile and reuse the running config only
when the target is the active profile for that username. On an ID-only match a
legacy profile ID shared between two users made the connected-client path
deregister the active peer while its connection stayed up, which the gate fix
alone did not cover.
Split the setup-key-less branch of Login into beginSSOLogin, with the
reuse-the-pending-flow decision in pendingOAuthFlowResponse. Login's cognitive
complexity drops from 37 to 21 (gocognit), clearing the SonarQube report on
this file with no behaviour change.
Point the test fixture at an https URL, since the profiles a gated logout must
not touch only need to be unreachable, not plaintext.
201 lines
8.0 KiB
Go
201 lines
8.0 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/grpc/codes"
|
|
gstatus "google.golang.org/grpc/status"
|
|
|
|
"github.com/netbirdio/netbird/client/internal"
|
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
|
"github.com/netbirdio/netbird/client/proto"
|
|
)
|
|
|
|
// unreachableManagementURL keeps a test that is expected to stop at a gate from
|
|
// reaching the network if the gate ever regresses: the profiles a logout must
|
|
// not touch point here, so a leak fails fast instead of contacting a real
|
|
// management server.
|
|
const unreachableManagementURL = "https://127.0.0.1:9"
|
|
|
|
// enableSSHOnProfile rewrites the profile config at cfgPath with the SSH server
|
|
// enabled. Deregistering an SSH-enabled profile is a privileged change, so an
|
|
// unprivileged caller is refused by requirePrivilegeForDeregistration before any
|
|
// management connection is attempted, which is what keeps these tests offline.
|
|
func enableSSHOnProfile(t *testing.T, cfgPath string) {
|
|
t.Helper()
|
|
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
|
ConfigPath: cfgPath,
|
|
ManagementURL: "https://api.netbird.io:443",
|
|
ServerSSHAllowed: boolPtr(true),
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Logging out of the profile the daemon is already running is a deregistration,
|
|
// not profile management, so the profiles-disabled kill switch must not block
|
|
// it. The desktop UI always addresses logout by profile (both the profile menu
|
|
// and the session-expiration dialog), so gating it left users with
|
|
// disableProfiles enforced unable to log out at all.
|
|
func TestLogout_ActiveProfileAllowedWhenProfilesDisabled(t *testing.T) {
|
|
s, _, activeProfile, username, cfgPath := setupServerWithProfile(t)
|
|
s.rootCtx = internal.CtxInitState(context.Background())
|
|
enableSSHOnProfile(t, cfgPath)
|
|
|
|
s.profilesDisabled = true
|
|
|
|
_, err := s.Logout(userCtx(), &proto.LogoutRequest{
|
|
ProfileName: &activeProfile,
|
|
Username: &username,
|
|
})
|
|
|
|
require.Error(t, err, "the SSH privilege gate is expected to refuse this unprivileged caller")
|
|
require.Equal(t, codes.PermissionDenied, gstatus.Code(err),
|
|
"logout of the active profile must reach the deregistration path, not be refused as profile management: %v", err)
|
|
require.NotContains(t, gstatus.Convert(err).Message(), errProfilesDisabled)
|
|
}
|
|
|
|
// A profile-addressed logout that targets some *other* profile does manage
|
|
// profiles, so it stays gated: with profiles disabled the daemon must not
|
|
// deregister a peer the user is not currently running.
|
|
func TestLogout_OtherProfileStaysGatedWhenProfilesDisabled(t *testing.T) {
|
|
s, _, _, username, _ := setupServerWithProfile(t)
|
|
s.rootCtx = internal.CtxInitState(context.Background())
|
|
|
|
other := "other-profile"
|
|
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
|
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, other+".json"),
|
|
ManagementURL: unreachableManagementURL,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
s.profilesDisabled = true
|
|
|
|
_, err = s.Logout(userCtx(), &proto.LogoutRequest{
|
|
ProfileName: &other,
|
|
Username: &username,
|
|
})
|
|
|
|
require.Error(t, err)
|
|
require.Equal(t, codes.Unavailable, gstatus.Code(err), "want the profiles-disabled refusal, got %v", err)
|
|
require.Contains(t, gstatus.Convert(err).Message(), errProfilesDisabled)
|
|
}
|
|
|
|
// A legacy profile ID is a display name, so two users can hold the same ID in
|
|
// their own profile directories. Matching on the ID alone would let one user's
|
|
// logout pass the gate against the other user's active profile, so the username
|
|
// is part of the comparison.
|
|
func TestLogout_ForeignUserProfileStaysGatedWhenProfilesDisabled(t *testing.T) {
|
|
s, _, _, username, _ := setupServerWithProfile(t)
|
|
s.rootCtx = internal.CtxInitState(context.Background())
|
|
|
|
// A legacy-style profile whose ID is its filename stem, and an active state
|
|
// claiming that same ID for a different user.
|
|
shared := "shared-legacy-name"
|
|
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
|
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, shared+".json"),
|
|
ManagementURL: unreachableManagementURL,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
|
ID: profilemanager.ID(shared),
|
|
Username: "someone-else",
|
|
}))
|
|
|
|
s.profilesDisabled = true
|
|
|
|
_, err = s.Logout(userCtx(), &proto.LogoutRequest{
|
|
ProfileName: &shared,
|
|
Username: &username,
|
|
})
|
|
|
|
require.Error(t, err)
|
|
require.Equal(t, codes.Unavailable, gstatus.Code(err),
|
|
"another user's profile must not pass the gate on an ID match alone: %v", err)
|
|
}
|
|
|
|
// Deregistering a namesake profile must not go out with the running config.
|
|
// logoutFromProfile reuses the connected client's config when the target is the
|
|
// active profile, and on an ID-only match a shared legacy ID made it reuse it
|
|
// for another user's profile, deregistering the active peer instead.
|
|
func TestLogout_ForeignUserProfileDoesNotUseTheRunningConfig(t *testing.T) {
|
|
s, _, _, username, cfgPath := setupServerWithProfile(t)
|
|
s.rootCtx = internal.CtxInitState(context.Background())
|
|
|
|
// The running config has the SSH server enabled, so reusing it would be
|
|
// refused with PermissionDenied. The namesake profile does not, so the
|
|
// correct path gets as far as dialing its own unreachable management URL.
|
|
enableSSHOnProfile(t, cfgPath)
|
|
running, err := profilemanager.GetConfig(cfgPath)
|
|
require.NoError(t, err)
|
|
s.config = running
|
|
s.connectClient = newDummyConnectClient(context.Background())
|
|
|
|
shared := "shared-legacy-name"
|
|
_, err = profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
|
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, shared+".json"),
|
|
ManagementURL: unreachableManagementURL,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
|
ID: profilemanager.ID(shared),
|
|
Username: "someone-else",
|
|
}))
|
|
|
|
// Bounded so the deregistration the fixed path attempts fails on the dial
|
|
// rather than sitting in gRPC backoff for the whole test timeout.
|
|
ctx, cancel := context.WithTimeout(userCtx(), 2*time.Second)
|
|
t.Cleanup(cancel)
|
|
|
|
_, err = s.Logout(ctx, &proto.LogoutRequest{
|
|
ProfileName: &shared,
|
|
Username: &username,
|
|
})
|
|
|
|
require.Error(t, err)
|
|
require.NotEqual(t, codes.PermissionDenied, gstatus.Code(err),
|
|
"the namesake profile was deregistered with the running config: %v", err)
|
|
}
|
|
|
|
// The connection teardown follows the profile that is active when the logout
|
|
// completes, not the one seen before it started: Login switches profiles under
|
|
// guardedConfigMu, which the logout path does not hold, so a login that landed
|
|
// meanwhile must keep its connection.
|
|
func TestCleanupAfterProfileLogout_FollowsTheCurrentActiveProfile(t *testing.T) {
|
|
s, _, activeProfile, username, _ := setupServerWithProfile(t)
|
|
s.rootCtx = internal.CtxInitState(context.Background())
|
|
|
|
state := internal.CtxGetState(s.rootCtx)
|
|
|
|
s.cleanupAfterProfileLogout("some-other-profile", username)
|
|
status, err := state.Status()
|
|
require.NoError(t, err)
|
|
require.NotEqual(t, internal.StatusNeedsLogin, status,
|
|
"logging out of a profile that is not active must not ask for a new login")
|
|
|
|
s.cleanupAfterProfileLogout(profilemanager.ID(activeProfile), username)
|
|
status, err = state.Status()
|
|
require.NoError(t, err)
|
|
require.Equal(t, internal.StatusNeedsLogin, status,
|
|
"logging out of the active profile must ask for a new login")
|
|
}
|
|
|
|
// With profiles enabled the gate is out of the way on both surfaces; the active
|
|
// profile still reaches the deregistration path.
|
|
func TestLogout_ActiveProfileAllowedWhenProfilesEnabled(t *testing.T) {
|
|
s, _, activeProfile, username, cfgPath := setupServerWithProfile(t)
|
|
s.rootCtx = internal.CtxInitState(context.Background())
|
|
enableSSHOnProfile(t, cfgPath)
|
|
|
|
_, err := s.Logout(userCtx(), &proto.LogoutRequest{
|
|
ProfileName: &activeProfile,
|
|
Username: &username,
|
|
})
|
|
|
|
require.Error(t, err, "the SSH privilege gate is expected to refuse this unprivileged caller")
|
|
require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want the privilege refusal, got %v", err)
|
|
}
|