From 1f262ba0446010b24f66bd30595a92c47cd554d3 Mon Sep 17 00:00:00 2001 From: "Theodor S. Midtlien" Date: Fri, 21 Aug 2026 11:58:00 +0200 Subject: [PATCH] Add WIP profile ownership --- client/internal/ipcauth/identity.go | 48 +++++++++++++++++++ client/internal/profilemanager/config.go | 11 +++++ client/internal/profilemanager/prefs_test.go | 11 +++-- client/internal/profilemanager/service.go | 38 ++++++++++++++- .../internal/profilemanager/service_test.go | 19 ++++---- client/server/server.go | 6 ++- 6 files changed, 116 insertions(+), 17 deletions(-) diff --git a/client/internal/ipcauth/identity.go b/client/internal/ipcauth/identity.go index d7d10f57d..235fa9983 100644 --- a/client/internal/ipcauth/identity.go +++ b/client/internal/ipcauth/identity.go @@ -13,6 +13,8 @@ import ( "context" "fmt" "slices" + "strconv" + "strings" "google.golang.org/grpc/credentials" "google.golang.org/grpc/peer" @@ -138,3 +140,49 @@ func IdentityFromContext(ctx context.Context) (Identity, bool) { } return info.Identity, true } + +// PrincipalKind is the type of an owner principal. +type PrincipalKind string + +const ( + KindUID PrincipalKind = "uid" // Unix user ID + KindSID PrincipalKind = "sid" // Windows user or group SID +) + +// Principal is a parsed owner entry from a profile's Owners list. +type Principal struct { + Kind PrincipalKind + Value string +} + +// ParsePrincipal parses a "kind:value" owner string. Returns false for empty +// values or unknown kinds so malformed entries are ignored rather than trusted. +func ParsePrincipal(s string) (Principal, bool) { + kind, value, ok := strings.Cut(s, ":") + if !ok || value == "" { + return Principal{}, false + } + switch PrincipalKind(kind) { + case KindUID, KindSID: + return Principal{Kind: PrincipalKind(kind), Value: value}, true + default: + return Principal{}, false + } +} + +// UIDPrincipal builds the owner string for a Unix user ID. +func UIDPrincipal(uid uint32) string { + return string(KindUID) + ":" + strconv.FormatUint(uint64(uid), 10) +} + +// SIDPrincipal builds the owner string for a Windows SID. +func SIDPrincipal(sid string) string { return string(KindSID) + ":" + sid } + +// OwnerPrincipalForIdentity returns the self-ownership principal for an identity: +// the user's UID on Unix, or the user's SID on Windows. +func OwnerPrincipalForIdentity(id Identity) string { + if id.IsWindows() { + return SIDPrincipal(id.SID) + } + return UIDPrincipal(id.UID) +} diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index 10c1758d1..4239c2dd1 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -21,6 +21,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/netbirdio/netbird/client/internal/routemanager/dynamic" "github.com/netbirdio/netbird/client/mdm" "github.com/netbirdio/netbird/client/ssh" @@ -107,6 +108,7 @@ type ConfigInput struct { LocalMetricsEnabled *bool LocalMetricsAddress *string + Owner *ipcauth.Identity } // Config Configuration type @@ -208,6 +210,8 @@ type Config struct { // Callers query enforcement state via Policy() and the mdm.Policy API // (HasKey, ManagedKeys, IsEmpty). policy *mdm.Policy `json:"-"` + + Owner string } // Policy returns the MDM policy applied to this Config. Returns a non-nil @@ -712,6 +716,13 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } + if input.Owner != nil { + ownerString := ipcauth.OwnerPrincipalForIdentity(*input.Owner) + config.Owner = ownerString + log.Infof("setting '%s' as owner for profile %s", ownerString, config.Name) + updated = true + } + // MDM is the last override layer: any key present in the policy // supersedes defaults, on-disk config, env vars and CLI input. config.applyMDMPolicy(loadMDMPolicy()) diff --git a/client/internal/profilemanager/prefs_test.go b/client/internal/profilemanager/prefs_test.go index 692ade70f..ec9ac5df9 100644 --- a/client/internal/profilemanager/prefs_test.go +++ b/client/internal/profilemanager/prefs_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "testing" + "github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -17,7 +18,7 @@ type testPrefsSection struct { func TestProfilePrefs_RoundTrip(t *testing.T) { withTestSM(t, func(sm *ServiceManager, username string) { - created, err := sm.AddProfile("work", username) + created, err := sm.AddProfile("work", username, ipcauth.Identity{}) require.NoError(t, err) prefs, err := sm.ProfilePrefs(created.ID, username) @@ -42,7 +43,7 @@ func TestProfilePrefs_RoundTrip(t *testing.T) { func TestProfilePrefs_GetMissingNamespace(t *testing.T) { withTestSM(t, func(sm *ServiceManager, username string) { - created, err := sm.AddProfile("work", username) + created, err := sm.AddProfile("work", username, ipcauth.Identity{}) require.NoError(t, err) prefs, err := sm.ProfilePrefs(created.ID, username) @@ -57,7 +58,7 @@ func TestProfilePrefs_GetMissingNamespace(t *testing.T) { func TestProfilePrefs_RemoveNamespace(t *testing.T) { withTestSM(t, func(sm *ServiceManager, username string) { - created, err := sm.AddProfile("work", username) + created, err := sm.AddProfile("work", username, ipcauth.Identity{}) require.NoError(t, err) prefs, err := sm.ProfilePrefs(created.ID, username) @@ -90,7 +91,7 @@ func TestProfilePrefs_RejectsInvalidID(t *testing.T) { func TestProfilePrefs_RejectsEmptyNamespace(t *testing.T) { withTestSM(t, func(sm *ServiceManager, username string) { - created, err := sm.AddProfile("work", username) + created, err := sm.AddProfile("work", username, ipcauth.Identity{}) require.NoError(t, err) prefs, err := sm.ProfilePrefs(created.ID, username) @@ -118,7 +119,7 @@ func TestProfilePrefs_DefaultProfile(t *testing.T) { func TestRemoveProfile_DeletesPrefsFile(t *testing.T) { withTestSM(t, func(sm *ServiceManager, username string) { - created, err := sm.AddProfile("work", username) + created, err := sm.AddProfile("work", username, ipcauth.Identity{}) require.NoError(t, err) prefs, err := sm.ProfilePrefs(created.ID, username) diff --git a/client/internal/profilemanager/service.go b/client/internal/profilemanager/service.go index ec287f01a..b2daf3a78 100644 --- a/client/internal/profilemanager/service.go +++ b/client/internal/profilemanager/service.go @@ -15,6 +15,7 @@ import ( log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/netbirdio/netbird/util" ) @@ -53,6 +54,10 @@ type profileMeta struct { Name string } +type ownerMeta struct { + Owner string +} + func (e *ErrAmbiguousHandle) Error() string { switch e.Kind { case AmbiguityKindIDPrefix: @@ -296,7 +301,7 @@ func (s *ServiceManager) DefaultProfilePath() string { // The returned Profile carries the freshly-generated ID so callers can // show it to the user (and so the gRPC AddProfileResponse can include // it). -func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, error) { +func (s *ServiceManager) AddProfile(displayName string, username string, callerId ipcauth.Identity) (*Profile, error) { configDir, err := s.getConfigDir(username) if err != nil { return nil, fmt.Errorf("failed to get config directory: %w", err) @@ -313,7 +318,7 @@ func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, err } profPath := filepath.Join(configDir, id.String()+".json") - cfg, err := createNewConfig(ConfigInput{ConfigPath: profPath}) + cfg, err := createNewConfig(ConfigInput{ConfigPath: profPath, Owner: &callerId}) if err != nil { return nil, fmt.Errorf("failed to create new config: %w", err) } @@ -571,6 +576,35 @@ func readProfileName(path string) string { return meta.Name } +func readProfileOwner(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + var meta ownerMeta + if err := json.Unmarshal(data, &meta); err != nil { + return "" + } + return meta.Owner +} + +func stampOwner(path string, owner ipcauth.Identity) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return err + } + cfg.Owner = ipcauth.OwnerPrincipalForIdentity(owner) + + if err := util.WriteJson(context.Background(), path, cfg); err != nil { + return fmt.Errorf("failed to write profile owner: %w", err) + } + return nil +} + // activeProfileID returns the currently-active profile's ID. The second // return value is true when the active profile is the default one. func (s *ServiceManager) activeProfileID() (ID, bool) { diff --git a/client/internal/profilemanager/service_test.go b/client/internal/profilemanager/service_test.go index 5e051b15d..e3ed85cf4 100644 --- a/client/internal/profilemanager/service_test.go +++ b/client/internal/profilemanager/service_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/netbirdio/netbird/util" ) @@ -32,7 +33,7 @@ func withTestSM(t *testing.T, fn func(sm *ServiceManager, username string)) { func TestServiceProfile_ExactID(t *testing.T) { withTestSM(t, func(sm *ServiceManager, username string) { - created, err := sm.AddProfile("work", username) + created, err := sm.AddProfile("work", username, ipcauth.Identity{}) require.NoError(t, err) got, err := sm.ResolveProfile(created.ID.String(), username) @@ -44,7 +45,7 @@ func TestServiceProfile_ExactID(t *testing.T) { func TestServiceProfile_IDPrefix(t *testing.T) { withTestSM(t, func(sm *ServiceManager, username string) { - created, err := sm.AddProfile("work", username) + created, err := sm.AddProfile("work", username, ipcauth.Identity{}) require.NoError(t, err) prefix := created.ID[:4] @@ -75,7 +76,7 @@ func TestServiceProfile_AmbiguousPrefix(t *testing.T) { func TestServiceProfile_ExactNameUnique(t *testing.T) { withTestSM(t, func(sm *ServiceManager, username string) { - _, err := sm.AddProfile("work", username) + _, err := sm.AddProfile("work", username, ipcauth.Identity{}) require.NoError(t, err) got, err := sm.ResolveProfile("work", username) @@ -86,9 +87,9 @@ func TestServiceProfile_ExactNameUnique(t *testing.T) { func TestServiceProfile_AmbiguousName(t *testing.T) { withTestSM(t, func(sm *ServiceManager, username string) { - _, err := sm.AddProfile("work", username) + _, err := sm.AddProfile("work", username, ipcauth.Identity{}) require.NoError(t, err) - _, err = sm.AddProfile("work", username) + _, err = sm.AddProfile("work", username, ipcauth.Identity{}) require.NoError(t, err) _, err = sm.ResolveProfile("work", username) @@ -133,10 +134,10 @@ func TestServiceProfile_LegacyFilenameCoexists(t *testing.T) { func TestAddProfile_AllowsDuplicateWithFlag(t *testing.T) { withTestSM(t, func(sm *ServiceManager, username string) { - first, err := sm.AddProfile("work", username) + first, err := sm.AddProfile("work", username, ipcauth.Identity{}) require.NoError(t, err) - second, err := sm.AddProfile("work", username) + second, err := sm.AddProfile("work", username, ipcauth.Identity{}) require.NoError(t, err) assert.NotEqual(t, first.ID, second.ID) assert.Equal(t, "work", second.Name) @@ -151,7 +152,7 @@ func TestAddProfile_RejectsInvalidNames(t *testing.T) { strings.Repeat("a", maxProfileNameLen+1), // too long } for _, name := range cases { - _, err := sm.AddProfile(name, username) + _, err := sm.AddProfile(name, username, ipcauth.Identity{}) assert.Error(t, err, "expected error for %q", name) } }) @@ -215,7 +216,7 @@ func TestIsValidProfileFilenameStem(t *testing.T) { func TestRemoveProfile_DeletesStateFile(t *testing.T) { withTestSM(t, func(sm *ServiceManager, username string) { - created, err := sm.AddProfile("work", username) + created, err := sm.AddProfile("work", username, ipcauth.Identity{}) require.NoError(t, err) configDir, err := sm.getConfigDir(username) diff --git a/client/server/server.go b/client/server/server.go index b7a9c0ffd..fbd48f188 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -2286,7 +2286,11 @@ func (s *Server) AddProfile(ctx context.Context, msg *proto.AddProfileRequest) ( return nil, gstatus.Errorf(codes.InvalidArgument, "profile name and username must be provided") } - created, err := s.profileManager.AddProfile(msg.ProfileName, msg.Username) + callerId, ok := ipcauth.CallerIdentity(ctx) + if !ok { + return nil, fmt.Errorf("failed to get identity from context") + } + created, err := s.profileManager.AddProfile(msg.ProfileName, msg.Username, callerId) if err != nil { log.Errorf("failed to create profile: %v", err) return nil, fmt.Errorf("failed to create profile: %w", err)