Merge branch 'main' into profile-ownership

This commit is contained in:
Theodor S. Midtlien
2026-09-09 09:38:32 +02:00
124 changed files with 5010 additions and 1354 deletions
+42
View File
@@ -4,6 +4,7 @@
package mobile
import (
"errors"
"fmt"
"os"
"path/filepath"
@@ -11,6 +12,7 @@ import (
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/mdm"
)
const (
@@ -22,6 +24,9 @@ const (
profilesSubdir = "profiles"
)
// ErrProfilesDisabled marks a profile mutation rejected by MDM policy.
var ErrProfilesDisabled = errors.New("profile management is disabled by MDM policy")
/*
<configDir>/ ← app-writable config root
@@ -55,6 +60,7 @@ type ProfileManager struct {
configDir string
username string
serviceMgr *profilemanager.ServiceManager
mdmLoader *mdm.Loader
}
// NewProfileManager creates a profile manager rooted at configDir, the
@@ -127,6 +133,9 @@ func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
// SwitchProfile records the given profile ID as the active profile. The caller
// must stop the VPN tunnel before switching.
func (pm *ProfileManager) SwitchProfile(id string) error {
if err := pm.checkProfilesAllowed(); err != nil {
return err
}
if err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{
ID: profilemanager.ID(id),
Username: pm.username,
@@ -141,6 +150,9 @@ func (pm *ProfileManager) SwitchProfile(id string) error {
// AddProfile creates a new profile with the given display name and a
// generated ID. It returns the created profile so the caller learns the ID.
func (pm *ProfileManager) AddProfile(displayName string) (*Profile, error) {
if err := pm.checkProfilesAllowed(); err != nil {
return nil, err
}
profile, err := pm.serviceMgr.AddProfile(displayName, pm.username, nil)
if err != nil {
return nil, fmt.Errorf("add profile: %w", err)
@@ -153,6 +165,9 @@ func (pm *ProfileManager) AddProfile(displayName string) (*Profile, error) {
// RenameProfile changes the display name of the profile identified by id. The
// on-disk filename (the ID) is left unchanged.
func (pm *ProfileManager) RenameProfile(id string, newName string) error {
if err := pm.checkProfilesAllowed(); err != nil {
return err
}
if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), pm.username, newName); err != nil {
return fmt.Errorf("rename profile: %w", err)
}
@@ -165,6 +180,9 @@ func (pm *ProfileManager) RenameProfile(id string, newName string) error {
// private key and SSH key from the config, forcing a re-login. The management
// URL and other settings are preserved.
func (pm *ProfileManager) LogoutProfile(id string) error {
if err := pm.checkProfileLogoutAllowed(id); err != nil {
return err
}
configPath, err := pm.getProfileConfigPath(id)
if err != nil {
return err
@@ -196,6 +214,9 @@ func (pm *ProfileManager) LogoutProfile(id string) error {
// RemoveProfile deletes a profile. The default profile and the active profile
// cannot be removed.
func (pm *ProfileManager) RemoveProfile(id string) error {
if err := pm.checkProfilesAllowed(); err != nil {
return err
}
configPath, err := pm.getProfileConfigPath(id)
if err != nil {
return err
@@ -267,6 +288,27 @@ func (pm *ProfileManager) GetActiveStateFilePath() (string, error) {
return pm.GetStateFilePath(activeProfile.ID)
}
// SetMDMLoader registers the MDM policy source consulted before profile
// mutations; a nil loader disables enforcement.
func (pm *ProfileManager) SetMDMLoader(loader *mdm.Loader) {
pm.mdmLoader = loader
}
func (pm *ProfileManager) checkProfilesAllowed() error {
if v, ok := pm.mdmLoader.Load().GetBool(mdm.KeyDisableProfiles); ok && v {
return ErrProfilesDisabled
}
return nil
}
func (pm *ProfileManager) checkProfileLogoutAllowed(id string) error {
active, err := pm.serviceMgr.GetActiveProfileState()
if err == nil && active.ID.String() == id {
return nil
}
return pm.checkProfilesAllowed()
}
// profileEmail returns the account email recorded for a profile. Display-only,
// so an unresolvable path degrades to "" rather than an error.
func (pm *ProfileManager) profileEmail(id string) string {
+83
View File
@@ -0,0 +1,83 @@
package mobile
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/mdm"
)
type fakeFetcher struct{ values map[string]any }
func (f *fakeFetcher) Fetch() map[string]any { return f.values }
func newTestProfileManager(t *testing.T) *ProfileManager {
t.Helper()
origDir := profilemanager.DefaultConfigPathDir
origPath := profilemanager.DefaultConfigPath
origActive := profilemanager.ActiveProfileStatePath
t.Cleanup(func() {
profilemanager.DefaultConfigPathDir = origDir
profilemanager.DefaultConfigPath = origPath
profilemanager.ActiveProfileStatePath = origActive
})
configDir := t.TempDir()
pm := NewProfileManager(configDir, "mobile")
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
ConfigPath: filepath.Join(configDir, defaultConfigFilename),
})
require.NoError(t, err)
return pm
}
func privateKeyOf(t *testing.T, pm *ProfileManager, id string) string {
t.Helper()
path, err := pm.getProfileConfigPath(id)
require.NoError(t, err)
raw, err := os.ReadFile(path)
require.NoError(t, err)
var cfg struct{ PrivateKey string }
require.NoError(t, json.Unmarshal(raw, &cfg))
return cfg.PrivateKey
}
func TestLogoutProfile_DisableProfiles(t *testing.T) {
pm := newTestProfileManager(t)
other, err := pm.AddProfile("work")
require.NoError(t, err)
require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName))
require.NotEmpty(t, privateKeyOf(t, pm, profilemanager.DefaultProfileName))
require.NotEmpty(t, privateKeyOf(t, pm, other.ID))
pm.SetMDMLoader(mdm.NewLoader(&fakeFetcher{values: map[string]any{
mdm.KeyDisableProfiles: true,
}}))
err = pm.LogoutProfile(other.ID)
assert.ErrorIs(t, err, ErrProfilesDisabled)
assert.NotEmpty(t, privateKeyOf(t, pm, other.ID))
require.NoError(t, pm.LogoutProfile(profilemanager.DefaultProfileName))
assert.Empty(t, privateKeyOf(t, pm, profilemanager.DefaultProfileName))
}
func TestLogoutProfile_ProfilesAllowed(t *testing.T) {
pm := newTestProfileManager(t)
other, err := pm.AddProfile("work")
require.NoError(t, err)
require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName))
pm.SetMDMLoader(mdm.NewLoader(&fakeFetcher{values: map[string]any{
mdm.KeyDisableProfiles: false,
}}))
require.NoError(t, pm.LogoutProfile(other.ID))
assert.Empty(t, privateKeyOf(t, pm, other.ID))
}