mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-21 22:29:08 +02:00
[client] Profile ownership migration (#7508)
* Implement OwnsProfile on Server * (WIP) List profiles based on ownership by Identity * (WIP) Migrate active_profile * Fix status and list profiles * Add profile stamping as active migration * Add one-shot migration * Only default profile fail open * Use restricted write for config json * Fix stale server config after stamp * Fix OwnsProfile fallback to active profile * Fix config concurrent reload during OwnsProfile check * Move known check to inside stamp owner * Update client/internal/profilemanager/service.go Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * Recover from dup active profiles that cannot be resolved with username. * Add test for already owned profile during migration * Improve stamping of fields in the config * Apply suggestion from @cubic-dev-ai[bot] Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * Fix codespell and test comment --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
cubic-dev-ai[bot]
parent
15ed6f8f15
commit
52b16e7a5c
@@ -31,6 +31,7 @@ func TestLogin_RefusedChangeLeavesTheProfileAlone(t *testing.T) {
|
||||
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"),
|
||||
ManagementURL: "https://api.netbird.io:443",
|
||||
ServerSSHAllowed: boolPtr(true),
|
||||
Owner: testProfileOwner(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -63,6 +64,7 @@ func TestLogin_ChangeThatBecomesPrivilegedMidRequestHasNoSideEffects(t *testing.
|
||||
ConfigPath: targetPath,
|
||||
ManagementURL: "https://api.netbird.io:443",
|
||||
ServerSSHAllowed: boolPtr(false),
|
||||
Owner: testProfileOwner(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -110,6 +112,7 @@ func TestLogin_RefusedChangeLeavesAnInProgressLoginAlone(t *testing.T) {
|
||||
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"),
|
||||
ManagementURL: "https://api.netbird.io:443",
|
||||
ServerSSHAllowed: boolPtr(true),
|
||||
Owner: testProfileOwner(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -80,7 +80,8 @@ func TestPersistLoginOverrides(t *testing.T) {
|
||||
require.NoError(t, err, "seed config")
|
||||
|
||||
activeProf := &profilemanager.ActiveProfileState{ID: "default"}
|
||||
err = persistLoginOverrides(activeProf, tt.newMgmtURL, tt.newPSK)
|
||||
srv := &Server{profileManager: profilemanager.NewServiceManager("")}
|
||||
err = srv.persistLoginOverrides(activeProf, tt.newMgmtURL, tt.newPSK)
|
||||
require.NoError(t, err, "persistLoginOverrides")
|
||||
|
||||
cfg, err := profilemanager.ReadConfig(profilemanager.DefaultConfigPath)
|
||||
|
||||
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -69,6 +70,7 @@ func TestLogout_OtherProfileStaysGatedWhenProfilesDisabled(t *testing.T) {
|
||||
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, other+".json"),
|
||||
ManagementURL: unreachableManagementURL,
|
||||
Owner: testProfileOwner(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -86,28 +88,18 @@ func TestLogout_OtherProfileStaysGatedWhenProfilesDisabled(t *testing.T) {
|
||||
|
||||
// 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.
|
||||
// logout pass the gate against the other user's active profile, so the config
|
||||
// file, not the ID, decides which profile is the active one.
|
||||
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",
|
||||
}))
|
||||
plantNamesakeProfiles(t, s, shared)
|
||||
|
||||
s.profilesDisabled = true
|
||||
|
||||
_, err = s.Logout(userCtx(), &proto.LogoutRequest{
|
||||
_, err := s.Logout(userCtx(), &proto.LogoutRequest{
|
||||
ProfileName: &shared,
|
||||
Username: &username,
|
||||
})
|
||||
@@ -117,6 +109,35 @@ func TestLogout_ForeignUserProfileStaysGatedWhenProfilesDisabled(t *testing.T) {
|
||||
"another user's profile must not pass the gate on an ID match alone: %v", err)
|
||||
}
|
||||
|
||||
// plantNamesakeProfiles creates two profiles that share one legacy ID: the
|
||||
// caller's own, and another user's in that user's legacy profile directory,
|
||||
// which is the one made active. Only the caller's copy carries an owner, so
|
||||
// that is the one a handle resolves to, while the active profile stays the
|
||||
// other file.
|
||||
func plantNamesakeProfiles(t *testing.T, s *Server, id string) {
|
||||
t.Helper()
|
||||
|
||||
foreignDir := filepath.Join(profilemanager.DefaultConfigPathDir, "someone-else")
|
||||
require.NoError(t, os.MkdirAll(foreignDir, 0700))
|
||||
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: filepath.Join(foreignDir, id+".json"),
|
||||
ManagementURL: unreachableManagementURL,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, id+".json"),
|
||||
ManagementURL: unreachableManagementURL,
|
||||
Owner: testProfileOwner(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
||||
ID: profilemanager.ID(id),
|
||||
Username: "someone-else",
|
||||
}))
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -135,15 +156,7 @@ func TestLogout_ForeignUserProfileDoesNotUseTheRunningConfig(t *testing.T) {
|
||||
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",
|
||||
}))
|
||||
plantNamesakeProfiles(t, s, shared)
|
||||
|
||||
// Bounded so the deregistration the fixed path attempts fails on the dial
|
||||
// rather than sitting in gRPC backoff for the whole test timeout.
|
||||
@@ -165,18 +178,21 @@ func TestLogout_ForeignUserProfileDoesNotUseTheRunningConfig(t *testing.T) {
|
||||
// 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, _, activeProfile, _, cfgPath := setupServerWithProfile(t)
|
||||
s.rootCtx = internal.CtxInitState(context.Background())
|
||||
|
||||
state := internal.CtxGetState(s.rootCtx)
|
||||
|
||||
s.cleanupAfterProfileLogout("some-other-profile", username)
|
||||
s.cleanupAfterProfileLogout(&profilemanager.Profile{ID: "some-other-profile"})
|
||||
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)
|
||||
s.cleanupAfterProfileLogout(&profilemanager.Profile{
|
||||
ID: profilemanager.ID(activeProfile),
|
||||
Path: cfgPath,
|
||||
})
|
||||
status, err = state.Status()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, internal.StatusNeedsLogin, status,
|
||||
|
||||
+266
-100
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
@@ -287,12 +288,30 @@ func (s *Server) Start() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// A half-migrated machine still runs, it just keeps resolving profiles the
|
||||
// old way, so a failure here is logged and retried on the next start rather
|
||||
// than kept from starting at all.
|
||||
if err := s.profileManager.MigrateLegacyProfiles(); err != nil {
|
||||
log.Errorf("profile migration did not finish, retrying on next start: %v", err)
|
||||
}
|
||||
|
||||
activeProf, err := s.profileManager.GetActiveProfileState()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get active profile state: %w", err)
|
||||
}
|
||||
|
||||
config, existingConfig, err := s.getConfig(activeProf)
|
||||
if errors.Is(err, profilemanager.ErrAmbiguousActiveProfile) {
|
||||
// Running one of the namesakes anyway could connect the machine as
|
||||
// another users profile, so the daemon comes up on the default
|
||||
// profile instead of refusing to start.
|
||||
log.Errorf("starting on the default profile, the active one could not be resolved: %v", err)
|
||||
if err := s.profileManager.SetActiveProfileStateToDefault(); err != nil {
|
||||
return fmt.Errorf("set active profile to default: %w", err)
|
||||
}
|
||||
activeProf = &profilemanager.ActiveProfileState{ID: profilemanager.DefaultProfileName}
|
||||
config, existingConfig, err = s.getConfig(activeProf)
|
||||
}
|
||||
if err != nil {
|
||||
log.Errorf("failed to get active profile config: %v", err)
|
||||
|
||||
@@ -513,7 +532,12 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stored, err := s.storedProfileConfig(msg.ProfileName, msg.Username)
|
||||
callerID, err := callerIdentity(callerCtx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stored, err := s.storedProfileConfig(msg.ProfileName, callerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -521,7 +545,7 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config, err := s.setConfigInputFromRequest(msg)
|
||||
config, err := s.setConfigInputFromRequest(msg, callerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -533,7 +557,7 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
||||
}
|
||||
|
||||
if activeProf, err := s.profileManager.GetActiveProfileState(); err == nil {
|
||||
if activePath, err := activeProf.FilePath(); err == nil && activePath == config.ConfigPath {
|
||||
if activePath, err := s.profileManager.ActiveProfilePath(activeProf); err == nil && activePath == config.ConfigPath {
|
||||
s.localMetrics.Reconcile(updatedConf.LocalMetricsEnabled, updatedConf.LocalMetricsAddress)
|
||||
}
|
||||
}
|
||||
@@ -551,10 +575,10 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
||||
// field is its own optional case. Returns the resolved ConfigInput
|
||||
// and a non-nil error only when the active profile file path cannot
|
||||
// be determined.
|
||||
func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profilemanager.ConfigInput, error) {
|
||||
func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest, callerID ipcauth.Identity) (profilemanager.ConfigInput, error) {
|
||||
var config profilemanager.ConfigInput
|
||||
|
||||
resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username)
|
||||
resolved, err := s.resolveProfileHandle(msg.ProfileName, callerID)
|
||||
if err != nil {
|
||||
log.Errorf("failed to resolve profile %q: %v", msg.ProfileName, err)
|
||||
return config, err
|
||||
@@ -657,6 +681,11 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
}
|
||||
}
|
||||
|
||||
callerID, err := callerIdentity(callerCtx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
activeProf, err := s.profileManager.GetActiveProfileState()
|
||||
if err != nil {
|
||||
log.Errorf("failed to get active profile state: %v", err)
|
||||
@@ -668,7 +697,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
// refused login neither switches the profile nor cancels a login already in
|
||||
// progress, and it reads the profile the request targets, which is the one the
|
||||
// switch below would activate.
|
||||
stored, err := s.storedLoginConfig(activeProf, msg)
|
||||
stored, err := s.storedLoginConfig(activeProf, msg, callerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -701,7 +730,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username)
|
||||
log.Infof("active profile: %s", activeProf.ID)
|
||||
|
||||
s.mutex.Lock()
|
||||
|
||||
@@ -1062,6 +1091,12 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
|
||||
return nil, fmt.Errorf("config is not defined, please call login command first")
|
||||
}
|
||||
|
||||
callerID, err := callerIdentity(callerCtx)
|
||||
if err != nil {
|
||||
s.mutex.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
activeProf, err := s.profileManager.GetActiveProfileState()
|
||||
if err != nil {
|
||||
s.mutex.Unlock()
|
||||
@@ -1070,7 +1105,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
|
||||
}
|
||||
|
||||
if msg != nil && msg.ProfileName != nil {
|
||||
if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
|
||||
if _, err := s.switchProfileIfNeeded(*msg.ProfileName, callerID, activeProf); err != nil {
|
||||
s.mutex.Unlock()
|
||||
log.Errorf("failed to switch profile: %v", err)
|
||||
return nil, err
|
||||
@@ -1084,7 +1119,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
|
||||
return nil, fmt.Errorf("failed to get active profile state: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username)
|
||||
log.Infof("active profile: %s", activeProf.ID)
|
||||
|
||||
config, _, err := s.getConfig(activeProf)
|
||||
if err != nil {
|
||||
@@ -1136,8 +1171,8 @@ func (s *Server) waitForUp(callerCtx context.Context) (*proto.UpResponse, error)
|
||||
// targets, so a privileged-change decision can be made against the values the
|
||||
// profile currently holds. A profile that has no config file yet yields nil,
|
||||
// which every caller must read as "nothing enabled yet".
|
||||
func (s *Server) storedProfileConfig(handle, username string) (*profilemanager.Config, error) {
|
||||
resolved, err := s.resolveProfileHandle(handle, username)
|
||||
func (s *Server) storedProfileConfig(handle string, callerID ipcauth.Identity) (*profilemanager.Config, error) {
|
||||
resolved, err := s.resolveProfileHandle(handle, callerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1153,23 +1188,18 @@ func (s *Server) storedProfileConfig(handle, username string) (*profilemanager.C
|
||||
// storedLoginConfig loads the on-disk config of the profile a login request
|
||||
// targets: the one it names, or the active one when it names none. Used to decide
|
||||
// a privileged change before the request is allowed to switch profiles.
|
||||
func (s *Server) storedLoginConfig(activeProf *profilemanager.ActiveProfileState, msg *proto.LoginRequest) (*profilemanager.Config, error) {
|
||||
func (s *Server) storedLoginConfig(activeProf *profilemanager.ActiveProfileState, msg *proto.LoginRequest, callerID ipcauth.Identity) (*profilemanager.Config, error) {
|
||||
if msg.ProfileName == nil {
|
||||
cfgPath, err := activeProf.FilePath()
|
||||
cfgPath, err := s.profileManager.ActiveProfilePath(activeProf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("active profile file path: %w", err)
|
||||
}
|
||||
return s.storedConfigAtPath(cfgPath)
|
||||
}
|
||||
|
||||
// Mirrors switchProfileIfNeeded: the default profile resolves without a
|
||||
// username, so this reads the same profile the switch would activate.
|
||||
handle := *msg.ProfileName
|
||||
username := ""
|
||||
if handle != profilemanager.DefaultProfileName {
|
||||
username = msg.GetUsername()
|
||||
}
|
||||
return s.storedProfileConfig(handle, username)
|
||||
// Mirrors switchProfileIfNeeded, so this reads the very profile the switch
|
||||
// would activate.
|
||||
return s.storedProfileConfig(*msg.ProfileName, callerID)
|
||||
}
|
||||
|
||||
// storedConfigAtPath reads a profile config file, yielding nil when it does not
|
||||
@@ -1189,11 +1219,26 @@ func (s *Server) storedConfigAtPath(path string) (*profilemanager.Config, error)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// callerIdentity returns the kernel-authenticated identity of the RPC caller.
|
||||
//
|
||||
// Every profile-addressing RPC scopes itself with this rather than with the
|
||||
// username its request carries: that field is whatever the client chose to
|
||||
// send, so scoping by it lets any local caller address another user's profile.
|
||||
// The username fields on the wire are kept for compatibility and ignored.
|
||||
func callerIdentity(ctx context.Context) (ipcauth.Identity, error) {
|
||||
id, ok := ipcauth.CallerIdentity(ctx)
|
||||
if !ok {
|
||||
return ipcauth.Identity{}, gstatus.Error(codes.Unauthenticated, "caller identity could not be verified on the daemon control channel")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// resolveProfileHandle resolves a wire-level profile handle (display
|
||||
// name, ID, or unique ID prefix) to a concrete profile. Returns gRPC
|
||||
// status errors so handlers can return them directly.
|
||||
func (s *Server) resolveProfileHandle(handle, username string) (*profilemanager.Profile, error) {
|
||||
p, err := s.profileManager.ResolveProfile(handle, username)
|
||||
// name, ID, or unique ID prefix) to a concrete profile owned by, or open to,
|
||||
// the calling identity. Returns gRPC status errors so handlers can return them
|
||||
// directly.
|
||||
func (s *Server) resolveProfileHandle(handle string, callerID ipcauth.Identity) (*profilemanager.Profile, error) {
|
||||
p, err := s.profileManager.ResolveProfile(handle, callerID)
|
||||
if err == nil {
|
||||
return p, nil
|
||||
}
|
||||
@@ -1210,36 +1255,28 @@ func (s *Server) resolveProfileHandle(handle, username string) (*profilemanager.
|
||||
// switchProfileIfNeeded resolves the user-supplied handle, updates the
|
||||
// active profile state if it differs from the current one, and returns
|
||||
// the resolved profile so callers can include its ID in RPC responses.
|
||||
func (s *Server) switchProfileIfNeeded(handle string, userName *string, activeProf *profilemanager.ActiveProfileState) (*profilemanager.Profile, error) {
|
||||
if handle != profilemanager.DefaultProfileName && (userName == nil || *userName == "") {
|
||||
log.Errorf("profile name is set to %s, but username is not provided", handle)
|
||||
return nil, fmt.Errorf("profile name is set to %s, but username is not provided", handle)
|
||||
}
|
||||
|
||||
var username string
|
||||
if handle != profilemanager.DefaultProfileName {
|
||||
username = *userName
|
||||
}
|
||||
|
||||
resolved, err := s.resolveProfileHandle(handle, username)
|
||||
func (s *Server) switchProfileIfNeeded(handle string, callerID ipcauth.Identity, activeProf *profilemanager.ActiveProfileState) (*profilemanager.Profile, error) {
|
||||
resolved, err := s.resolveProfileHandle(handle, callerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resolved.ID != activeProf.ID || username != activeProf.Username {
|
||||
if s.checkProfilesDisabled() {
|
||||
log.Errorf("profiles are disabled, you cannot use this feature without profiles enabled")
|
||||
return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
|
||||
}
|
||||
if s.isActiveProfile(activeProf, resolved) {
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
log.Infof("switching to profile %s (%s) for user %s", resolved.Name, resolved.ID, username)
|
||||
if err := s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
||||
ID: resolved.ID,
|
||||
Username: username,
|
||||
}); err != nil {
|
||||
log.Errorf("failed to set active profile state: %v", err)
|
||||
return nil, fmt.Errorf("failed to set active profile state: %w", err)
|
||||
}
|
||||
if s.checkProfilesDisabled() {
|
||||
log.Errorf("profiles are disabled, you cannot use this feature without profiles enabled")
|
||||
return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
|
||||
}
|
||||
|
||||
log.Infof("switching to profile %s (%s) for %s", resolved.Name, resolved.ID, callerID)
|
||||
if err := s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
||||
ID: resolved.ID,
|
||||
Username: legacyDirHint(resolved),
|
||||
}); err != nil {
|
||||
log.Errorf("failed to set active profile state: %v", err)
|
||||
return nil, fmt.Errorf("failed to set active profile state: %w", err)
|
||||
}
|
||||
|
||||
return resolved, nil
|
||||
@@ -1250,6 +1287,11 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
callerID, err := callerIdentity(callerCtx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
activeProf, err := s.profileManager.GetActiveProfileState()
|
||||
if err != nil {
|
||||
log.Errorf("failed to get active profile state: %v", err)
|
||||
@@ -1257,7 +1299,7 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
|
||||
}
|
||||
|
||||
if msg != nil && msg.ProfileName != nil {
|
||||
if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
|
||||
if _, err := s.switchProfileIfNeeded(*msg.ProfileName, callerID, activeProf); err != nil {
|
||||
log.Errorf("failed to switch profile: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
@@ -1410,12 +1452,12 @@ func (s *Server) Logout(ctx context.Context, msg *proto.LogoutRequest) (*proto.L
|
||||
}
|
||||
|
||||
func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutRequest) (*proto.LogoutResponse, error) {
|
||||
if msg.Username == nil || *msg.Username == "" {
|
||||
return nil, gstatus.Errorf(codes.InvalidArgument, "username must be provided when profile name is specified")
|
||||
callerID, err := callerIdentity(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
username := *msg.Username
|
||||
|
||||
resolved, err := s.resolveProfileHandle(*msg.ProfileName, username)
|
||||
resolved, err := s.resolveProfileHandle(*msg.ProfileName, callerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1425,11 +1467,11 @@ func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutReque
|
||||
return nil, gstatus.Errorf(codes.FailedPrecondition, "failed to get active profile state: %v", err)
|
||||
}
|
||||
|
||||
if err := s.validateProfileLogout(resolved.ID, isActiveProfile(activeProf, resolved.ID, username)); err != nil {
|
||||
if err := s.validateProfileLogout(resolved.ID, s.isActiveProfile(activeProf, resolved)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.logoutFromProfile(ctx, resolved, username); err != nil {
|
||||
if err := s.logoutFromProfile(ctx, resolved); err != nil {
|
||||
log.Errorf("failed to logout from profile %s: %v", resolved.ID, err)
|
||||
// A refused deregistration is already a status error carrying the reason
|
||||
// and the command to run; rewrapping it as Internal would flatten both
|
||||
@@ -1440,7 +1482,7 @@ func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutReque
|
||||
return nil, gstatus.Errorf(codes.Internal, "logout: %v", err)
|
||||
}
|
||||
|
||||
s.cleanupAfterProfileLogout(resolved.ID, username)
|
||||
s.cleanupAfterProfileLogout(resolved)
|
||||
|
||||
return &proto.LogoutResponse{}, nil
|
||||
}
|
||||
@@ -1451,14 +1493,14 @@ func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutReque
|
||||
// check: Login switches profiles under guardedConfigMu, which this path does not
|
||||
// hold, so a login that landed meanwhile must not have its fresh connection
|
||||
// dropped by a logout that targeted the profile it replaced.
|
||||
func (s *Server) cleanupAfterProfileLogout(id profilemanager.ID, username string) {
|
||||
func (s *Server) cleanupAfterProfileLogout(profile *profilemanager.Profile) {
|
||||
activeProf, err := s.profileManager.GetActiveProfileState()
|
||||
if err != nil {
|
||||
log.Errorf("failed to get active profile state after logout from profile %s: %v", id, err)
|
||||
log.Errorf("failed to get active profile state after logout from profile %s: %v", profile.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !isActiveProfile(activeProf, id, username) {
|
||||
if !s.isActiveProfile(activeProf, profile) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1504,7 +1546,7 @@ func (s *Server) handleActiveProfileLogout(ctx context.Context) (*proto.LogoutRe
|
||||
|
||||
// getConfig reads config file and returns Config and whether the config file already existed. Errors out if it does not exist
|
||||
func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*profilemanager.Config, bool, error) {
|
||||
cfgPath, err := activeProf.FilePath()
|
||||
cfgPath, err := s.profileManager.ActiveProfilePath(activeProf)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("failed to get active profile file path: %w", err)
|
||||
}
|
||||
@@ -1548,27 +1590,51 @@ func (s *Server) validateProfileLogout(id profilemanager.ID, isActive bool) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// isActiveProfile reports whether id is the profile the daemon runs for
|
||||
// username. The username is part of the comparison because legacy profile IDs
|
||||
// are display names, which two users can both hold; the default profile is
|
||||
// shared by every user and carries no username.
|
||||
func isActiveProfile(activeProf *profilemanager.ActiveProfileState, id profilemanager.ID, username string) bool {
|
||||
if activeProf == nil || activeProf.ID != id {
|
||||
// isActiveProfile reports whether profile is the one the daemon runs.
|
||||
//
|
||||
// The comparison ends on the config file rather than on the ID, because a
|
||||
// legacy profile ID is a display name that two users can each hold: the path is
|
||||
// what tells alice's `work` from bob's. It is not the caller's username, which
|
||||
// says nothing about which profile the daemon activated.
|
||||
func (s *Server) isActiveProfile(activeProf *profilemanager.ActiveProfileState, profile *profilemanager.Profile) bool {
|
||||
if activeProf == nil || profile == nil || activeProf.ID != profile.ID {
|
||||
return false
|
||||
}
|
||||
if profile.ID == profilemanager.DefaultProfileName {
|
||||
return true
|
||||
}
|
||||
|
||||
return id == profilemanager.DefaultProfileName || activeProf.Username == username
|
||||
activePath, err := s.profileManager.ActiveProfilePath(activeProf)
|
||||
if err != nil {
|
||||
log.Warnf("cannot resolve the active profile's path, treating %s as not active: %v", profile.ID, err)
|
||||
return false
|
||||
}
|
||||
return activePath == profile.Path
|
||||
}
|
||||
|
||||
// legacyDirHint records which per-username directory a pre-migration profile's
|
||||
// file sits in, which is all the active-profile state still reads its username
|
||||
// for. A profile in the shared directory needs no hint: its ID is unique.
|
||||
func legacyDirHint(profile *profilemanager.Profile) string {
|
||||
if profile.Path == "" || profile.ID == profilemanager.DefaultProfileName {
|
||||
return ""
|
||||
}
|
||||
dir := filepath.Base(filepath.Dir(profile.Path))
|
||||
if dir == profilemanager.DefaultProfilePathDir {
|
||||
return ""
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// logoutFromProfile deregisters profile, reusing the running config when
|
||||
// profile is the one the daemon is connected with. The username takes part in
|
||||
// that decision for the same reason it does in the logout gate: a legacy
|
||||
// profile ID is a display name two users can share, and sending the running
|
||||
// config for a namesake would deregister the active peer instead of the
|
||||
// profile is the one the daemon is connected with. That decision is made on the
|
||||
// profile's file rather than on its ID, for the same reason the logout gate is:
|
||||
// a legacy profile ID is a display name two users can share, and sending the
|
||||
// running config for a namesake would deregister the active peer instead of the
|
||||
// requested one.
|
||||
func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.Profile, username string) error {
|
||||
func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.Profile) error {
|
||||
activeProf, err := s.profileManager.GetActiveProfileState()
|
||||
if err == nil && isActiveProfile(activeProf, profile.ID, username) && s.connectClient != nil {
|
||||
if err == nil && s.isActiveProfile(activeProf, profile) && s.connectClient != nil {
|
||||
return s.sendLogoutRequest(ctx)
|
||||
}
|
||||
|
||||
@@ -2203,7 +2269,12 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
resolved, err := s.resolveProfileHandle(req.ProfileName, req.Username)
|
||||
callerID, err := callerIdentity(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resolved, err := s.resolveProfileHandle(req.ProfileName, callerID)
|
||||
if err != nil {
|
||||
log.Errorf("failed to resolve profile %q: %v", req.ProfileName, err)
|
||||
return nil, err
|
||||
@@ -2316,15 +2387,16 @@ func (s *Server) AddProfile(ctx context.Context, msg *proto.AddProfileRequest) (
|
||||
return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
|
||||
}
|
||||
|
||||
if msg.ProfileName == "" || msg.Username == "" {
|
||||
return nil, gstatus.Errorf(codes.InvalidArgument, "profile name and username must be provided")
|
||||
if msg.ProfileName == "" {
|
||||
return nil, gstatus.Errorf(codes.InvalidArgument, "profile name must be provided")
|
||||
}
|
||||
|
||||
callerId, ok := ipcauth.CallerIdentity(ctx)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to get identity from context")
|
||||
callerID, err := callerIdentity(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
created, err := s.profileManager.AddProfile(msg.ProfileName, msg.Username, &callerId)
|
||||
|
||||
created, err := s.profileManager.AddProfile(msg.ProfileName, &callerID)
|
||||
if err != nil {
|
||||
log.Errorf("failed to create profile: %v", err)
|
||||
return nil, fmt.Errorf("failed to create profile: %w", err)
|
||||
@@ -2343,16 +2415,21 @@ func (s *Server) RenameProfile(ctx context.Context, msg *proto.RenameProfileRequ
|
||||
return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
|
||||
}
|
||||
|
||||
if msg.Handle == "" || msg.Username == "" || msg.NewProfileName == "" {
|
||||
return nil, gstatus.Errorf(codes.InvalidArgument, "profile name, username and new profile name must be provided")
|
||||
if msg.Handle == "" || msg.NewProfileName == "" {
|
||||
return nil, gstatus.Errorf(codes.InvalidArgument, "profile name and new profile name must be provided")
|
||||
}
|
||||
|
||||
resolved, err := s.resolveProfileHandle(msg.Handle, msg.Username)
|
||||
callerID, err := callerIdentity(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = s.profileManager.RenameProfile(resolved.ID, msg.Username, msg.NewProfileName)
|
||||
resolved, err := s.resolveProfileHandle(msg.Handle, callerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = s.profileManager.RenameProfile(resolved.ID, callerID, msg.NewProfileName)
|
||||
if err != nil {
|
||||
log.Errorf("failed to rename profile: %v", err)
|
||||
return nil, fmt.Errorf("failed to rename profile: %w", err)
|
||||
@@ -2376,19 +2453,24 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ
|
||||
return nil, gstatus.Errorf(codes.InvalidArgument, "profile name must be provided")
|
||||
}
|
||||
|
||||
resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username)
|
||||
callerID, err := callerIdentity(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.logoutFromProfile(ctx, resolved, msg.Username); err != nil {
|
||||
resolved, err := s.resolveProfileHandle(msg.ProfileName, callerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.logoutFromProfile(ctx, resolved); err != nil {
|
||||
// Deregistration is best-effort here: the local profile is removed
|
||||
// either way, so an unprivileged caller leaves the peer registered on
|
||||
// the management server rather than being blocked from removing it.
|
||||
log.Warnf("removing profile %s locally without deregistering it: %v", resolved.ID, err)
|
||||
}
|
||||
|
||||
if err := s.profileManager.RemoveProfile(resolved.ID, msg.Username); err != nil {
|
||||
if err := s.profileManager.RemoveProfile(resolved.ID, callerID); err != nil {
|
||||
log.Errorf("failed to remove profile: %v", err)
|
||||
return nil, fmt.Errorf("failed to remove profile: %w", err)
|
||||
}
|
||||
@@ -2443,11 +2525,12 @@ func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesReques
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
if msg.Username == "" {
|
||||
return nil, gstatus.Errorf(codes.InvalidArgument, "username must be provided")
|
||||
callerID, err := callerIdentity(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profiles, err := s.profileManager.ListProfiles(msg.Username)
|
||||
profiles, err := s.profileManager.ListProfiles(callerID)
|
||||
if err != nil {
|
||||
log.Errorf("failed to list profiles: %v", err)
|
||||
return nil, fmt.Errorf("failed to list profiles: %w", err)
|
||||
@@ -2480,10 +2563,15 @@ func (s *Server) GetActiveProfile(ctx context.Context, msg *proto.GetActiveProfi
|
||||
return nil, fmt.Errorf("failed to get active profile state: %w", err)
|
||||
}
|
||||
|
||||
userID, ok := ipcauth.CallerIdentity(ctx)
|
||||
if !ok {
|
||||
return nil, gstatus.Error(codes.Unauthenticated, "caller identity could not be resolved")
|
||||
}
|
||||
|
||||
// Fallback to legacy name == ID
|
||||
displayName := activeProfile.ID.String()
|
||||
if activeProfile.ID != profilemanager.DefaultProfileName {
|
||||
if profiles, lerr := s.profileManager.ListProfiles(activeProfile.Username); lerr == nil {
|
||||
if profiles, lerr := s.profileManager.ListProfiles(userID); lerr == nil {
|
||||
for _, p := range profiles {
|
||||
if p.ID == activeProfile.ID {
|
||||
displayName = p.Name
|
||||
@@ -2701,10 +2789,15 @@ func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.
|
||||
afterLoginPreCheck()
|
||||
}
|
||||
|
||||
callerID, err := callerIdentity(callerCtx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
s.guardedConfigMu.Lock()
|
||||
defer s.guardedConfigMu.Unlock()
|
||||
|
||||
stored, err := s.storedLoginConfig(activeProf, msg)
|
||||
stored, err := s.storedLoginConfig(activeProf, msg, callerID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -2728,7 +2821,7 @@ func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.
|
||||
}
|
||||
|
||||
if msg.ProfileName != nil {
|
||||
if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
|
||||
if _, err := s.switchProfileIfNeeded(*msg.ProfileName, callerID, activeProf); err != nil {
|
||||
return nil, nil, fmt.Errorf("switch profile: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -2738,7 +2831,7 @@ func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.
|
||||
return nil, nil, fmt.Errorf("active profile state: %w", err)
|
||||
}
|
||||
|
||||
if err := persistLoginOverrides(activeProf, msg.ManagementUrl, msg.OptionalPreSharedKey); err != nil {
|
||||
if err := s.persistLoginOverrides(activeProf, msg.ManagementUrl, msg.OptionalPreSharedKey); err != nil {
|
||||
return nil, nil, fmt.Errorf("persist login overrides: %w", err)
|
||||
}
|
||||
|
||||
@@ -2769,12 +2862,85 @@ func (s *Server) SessionHolder() (ipcauth.Principal, bool) {
|
||||
return principal, true
|
||||
}
|
||||
|
||||
// OwnsProfile reports whether the profile the handle resolves to answers to
|
||||
// this identity.
|
||||
//
|
||||
// This triggers stamping of legacy profiles, and reloads the active profile's
|
||||
// config so the stamp is visible to SessionHolder.
|
||||
func (s *Server) OwnsProfile(id ipcauth.Identity, handle string) bool {
|
||||
// TODO
|
||||
return false
|
||||
// Without the active profile there is nothing to fall back to and nothing
|
||||
// to refresh, so the gate gets a no rather than a guess.
|
||||
activeProfile, err := s.profileManager.GetActiveProfileState()
|
||||
if err != nil {
|
||||
log.Warnf("failed to get active profile: %v", err)
|
||||
return false
|
||||
}
|
||||
if activeProfile == nil {
|
||||
log.Warn("no active profile to authorize against")
|
||||
return false
|
||||
}
|
||||
if handle == "" {
|
||||
handle = activeProfile.ID.String()
|
||||
}
|
||||
|
||||
resolved, resolveErr := s.resolveProfileHandle(handle, id)
|
||||
|
||||
if afterProfileResolve != nil {
|
||||
afterProfileResolve()
|
||||
}
|
||||
|
||||
// Resolving stamps an owner on every legacy profile the caller can claim,
|
||||
// not only the one the handle names, so the daemon's copy of the active
|
||||
// profile's config goes stale whatever the handle was, and whether or not
|
||||
// resolution succeeded. SessionHolder reads Owners off that copy, so
|
||||
// refresh it before this answer reaches the gate.
|
||||
s.reloadActiveConfig()
|
||||
|
||||
if resolveErr != nil {
|
||||
log.Errorf("failed to resolve profile %q: %v", handle, resolveErr)
|
||||
return false
|
||||
}
|
||||
return resolved.AccessibleBy(id)
|
||||
}
|
||||
|
||||
func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, managementURL string, preSharedKey *string) error {
|
||||
// afterProfileResolve is a seam for tests to run a concurrent profile switch
|
||||
// between the resolution that stamps owners and the reload that publishes them.
|
||||
var afterProfileResolve func()
|
||||
|
||||
// reloadActiveConfig refreshes the daemon's copy of the active profile's config
|
||||
// from disk, which is where SessionHolder reads the owner of a live session.
|
||||
//
|
||||
// The active profile is read here and the whole reload runs under s.mutex.
|
||||
// SwitchProfile and Up change the active profile and install its config under
|
||||
// that same lock.
|
||||
func (s *Server) reloadActiveConfig() {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
// The handlers that start a session read their own config off disk, no need
|
||||
// for a reload.
|
||||
if !s.clientRunning {
|
||||
return
|
||||
}
|
||||
|
||||
activeProfile, err := s.profileManager.GetActiveProfileState()
|
||||
if err != nil {
|
||||
log.Errorf("failed to reload the active profile state: %v", err)
|
||||
return
|
||||
}
|
||||
if activeProfile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
config, _, err := s.getConfig(activeProfile)
|
||||
if err != nil {
|
||||
log.Errorf("failed to reload active profile config: %v", err)
|
||||
return
|
||||
}
|
||||
s.config = config
|
||||
}
|
||||
|
||||
func (s *Server) persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, managementURL string, preSharedKey *string) error {
|
||||
if preSharedKey != nil && *preSharedKey == "" {
|
||||
preSharedKey = nil
|
||||
}
|
||||
@@ -2782,7 +2948,7 @@ func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, manage
|
||||
return nil
|
||||
}
|
||||
|
||||
cfgPath, err := activeProf.FilePath()
|
||||
cfgPath, err := s.profileManager.ActiveProfilePath(activeProf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("active profile file path: %w", err)
|
||||
}
|
||||
|
||||
@@ -102,17 +102,20 @@ func TestSwitchProfile_ClearsJWTCache(t *testing.T) {
|
||||
// switchProfileIfNeeded rather than the no-op path a nil request takes.
|
||||
const target = "second"
|
||||
username := "tester"
|
||||
owner := unprivilegedIdentity()
|
||||
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"),
|
||||
ManagementURL: "https://api.netbird.io:443",
|
||||
Owner: &owner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
owner := unprivilegedIdentity()
|
||||
s.jwtCache.store("token", owner, testTTL, s.jwtCache.currentGeneration())
|
||||
|
||||
name := target
|
||||
_, err = s.SwitchProfile(ctx, &proto.SwitchProfileRequest{ProfileName: &name, Username: &username})
|
||||
// The handler scopes the switch to the caller's identity, which a real
|
||||
// caller gets from the daemon's transport credentials.
|
||||
_, err = s.SwitchProfile(ctxWithIdentity(owner), &proto.SwitchProfileRequest{ProfileName: &name, Username: &username})
|
||||
require.NoError(t, err)
|
||||
|
||||
active, err := s.profileManager.GetActiveProfileState()
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
// Resolving a handle claims every legacy profile the caller can take, the
|
||||
// active one included, whatever profile the handle itself names. SessionHolder
|
||||
// answers from the daemon's in-memory config, so OwnsProfile has to refresh it
|
||||
// for any handle: a copy taken before the claim reports no owner at all, and a
|
||||
// session with no owner is one every identified caller may take over.
|
||||
func TestOwnsProfile_RefreshesActiveConfigForAnyHandle(t *testing.T) {
|
||||
other := "second-profile"
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
handle string
|
||||
}{
|
||||
{name: "no handle falls back to the active profile", handle: ""},
|
||||
{name: "the active profile by ID", handle: "test-profile-mdm"},
|
||||
{name: "another profile entirely", handle: other},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s, _, activeProfile, _, _ := setupServerWithProfile(t)
|
||||
require.Equal(t, "test-profile-mdm", activeProfile)
|
||||
|
||||
owner := unprivilegedIdentity()
|
||||
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, other+".json"),
|
||||
ManagementURL: "https://api.netbird.io:443",
|
||||
Owner: &owner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// The daemon's copy as it stood before the claim landed on disk.
|
||||
s.config = &profilemanager.Config{}
|
||||
s.clientRunning = true
|
||||
_, running := s.SessionHolder()
|
||||
require.False(t, running, "fixture is wrong: the stale copy already names an owner")
|
||||
|
||||
require.True(t, s.OwnsProfile(owner, tc.handle), "the caller owns every profile in this fixture")
|
||||
|
||||
holder, running := s.SessionHolder()
|
||||
require.True(t, running, "the claimed owner never reached the daemon's config, so the live session is unowned")
|
||||
require.True(t, holder.Matches(owner), "the session is held by %v, not by the profile's owner", holder)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A caller whose profile the daemon cannot read is not the owner of anything.
|
||||
// The answer has to be no rather than a panic in the authorization path.
|
||||
func TestOwnsProfile_UnreadableActiveProfileStateDenies(t *testing.T) {
|
||||
s, _, _, _, _ := setupServerWithProfile(t)
|
||||
|
||||
require.NoError(t, os.WriteFile(profilemanager.ActiveProfileStatePath, []byte("{"), 0600))
|
||||
|
||||
require.False(t, s.OwnsProfile(unprivilegedIdentity(), ""))
|
||||
}
|
||||
|
||||
// A config the daemon cannot re-read leaves the one it already has in place.
|
||||
// Dropping a nil in its stead would take down every reader of it, SessionHolder
|
||||
// among them, which is the authorization path itself.
|
||||
func TestOwnsProfile_UnreadableConfigKeepsTheOneInPlace(t *testing.T) {
|
||||
s, _, _, _, _ := setupServerWithProfile(t)
|
||||
|
||||
// An ID no path can be built for, which is what a hand-edited or
|
||||
// downgrade-written state file can leave behind.
|
||||
require.NoError(t, os.WriteFile(profilemanager.ActiveProfileStatePath, []byte(`{"name":"../escape"}`), 0600))
|
||||
|
||||
kept := &profilemanager.Config{Owners: []string{ipcauth.OwnerPrincipalForIdentity(unprivilegedIdentity())}}
|
||||
s.config = kept
|
||||
s.clientRunning = true
|
||||
|
||||
require.False(t, s.OwnsProfile(unprivilegedIdentity(), ""))
|
||||
require.Same(t, kept, s.config, "a failed reload replaced the daemon's config")
|
||||
|
||||
holder, running := s.SessionHolder()
|
||||
require.True(t, running)
|
||||
require.True(t, holder.Matches(unprivilegedIdentity()))
|
||||
}
|
||||
|
||||
// A profile switch can land while the gate is still resolving: the resolution
|
||||
// reads every profile off disk, and SwitchProfile only needs the daemon lock,
|
||||
// which the gate does not hold. The config the reload publishes has to be the
|
||||
// one the daemon is now on, not the one the check started out reading.
|
||||
func TestOwnsProfile_ReloadFollowsASwitchThatLandsMidCheck(t *testing.T) {
|
||||
s, _, activeProfile, _, _ := setupServerWithProfile(t)
|
||||
owner := unprivilegedIdentity()
|
||||
|
||||
switchedTo := "switched-to"
|
||||
switchedToURL := "https://switched-to.example:443"
|
||||
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, switchedTo+".json"),
|
||||
ManagementURL: switchedToURL,
|
||||
Owner: &owner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
s.config = &profilemanager.Config{}
|
||||
s.clientRunning = true
|
||||
|
||||
// Stand in for a SwitchProfile that lands between the resolution and the
|
||||
// reload, which is the whole window the profile files are being read in.
|
||||
afterProfileResolve = func() {
|
||||
require.NoError(t, s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
||||
ID: profilemanager.ID(switchedTo),
|
||||
}))
|
||||
}
|
||||
t.Cleanup(func() { afterProfileResolve = nil })
|
||||
|
||||
require.True(t, s.OwnsProfile(owner, activeProfile))
|
||||
|
||||
require.NotNil(t, s.config.ManagementURL)
|
||||
require.Equal(t, switchedToURL, s.config.ManagementURL.String(),
|
||||
"the reload published the config of a profile the daemon had already left")
|
||||
}
|
||||
|
||||
// The handlers that start a session read their config off disk themselves.
|
||||
func TestOwnsProfile_IdleDaemonKeepsItsConfig(t *testing.T) {
|
||||
s, _, activeProfile, _, _ := setupServerWithProfile(t)
|
||||
|
||||
untouched := &profilemanager.Config{}
|
||||
s.config = untouched
|
||||
s.clientRunning = false
|
||||
|
||||
require.True(t, s.OwnsProfile(unprivilegedIdentity(), activeProfile))
|
||||
require.Same(t, untouched, s.config)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
@@ -83,6 +84,7 @@ func setupServerWithProfile(t *testing.T) (s *Server, ctx context.Context, profN
|
||||
_, err = profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: cfgPath,
|
||||
ManagementURL: "https://api.netbird.io:443",
|
||||
Owner: testProfileOwner(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -101,6 +103,13 @@ func setupServerWithProfile(t *testing.T) (s *Server, ctx context.Context, profN
|
||||
return s, ctx, profName, currUser.Username, cfgPath
|
||||
}
|
||||
|
||||
// testProfileOwner is the identity userCtx carries, which is who a fixture
|
||||
// profile belongs to.
|
||||
func testProfileOwner() *ipcauth.Identity {
|
||||
id := unprivilegedIdentity()
|
||||
return &id
|
||||
}
|
||||
|
||||
// extractViolation pulls the MDMManagedFieldsViolation detail from a
|
||||
// FailedPrecondition error. Fails the test if absent or malformed.
|
||||
func extractViolation(t *testing.T, err error) *proto.MDMManagedFieldsViolation {
|
||||
|
||||
Reference in New Issue
Block a user