Refactor owner to be daemon-wide

This commit is contained in:
Theodor S. Midtlien
2026-07-25 17:59:33 +02:00
parent ae9ee15501
commit 14917dbc22
10 changed files with 338 additions and 73 deletions

View File

@@ -18,13 +18,63 @@ import (
// Verify that the daemon Server implements ipcauth.ProfilePolicy.
var _ ipcauth.ProfilePolicy = (*Server)(nil)
// ActiveProfileOwnership returns the active profile's ownership policy. Reads
// the in-memory active config (kept current by the handlers), falling back to
// the on-disk active profile when the daemon hasn't loaded one yet.
// DaemonOwnerStore persists the daemon-wide owner set. The cmd layer implements
// it over service.json. The interface lives here to avoid an import cycle. A nil
// store means the daemon is unowned, so non-privileged callers are denied on the
// default profile.
type DaemonOwnerStore interface {
// Load returns the persisted daemon owner principals and shared flag.
Load() (owners []string, shared bool, err error)
// Save persists the daemon owner principals and shared flag.
Save(owners []string, shared bool) error
}
// SetDaemonOwnerStore installs the owner persistence backend and loads the
// current owner set into memory. Called once by cmd before serving RPCs.
func (s *Server) SetDaemonOwnerStore(store DaemonOwnerStore) {
s.mutex.Lock()
defer s.mutex.Unlock()
s.daemonOwnerStore = store
if store == nil {
return
}
owners, shared, err := store.Load()
if err != nil {
log.Warnf("ownership: cannot load daemon owners, treating as unowned: %v", err)
return
}
s.owners = ipcauth.Ownership{Owners: owners, Shared: shared}
log.Infof("daemon owners loaded: %d principal(s), shared=%t", len(owners), shared)
}
// activeIsDefaultLocked reports whether the active profile is the shared default.
// The default is owned daemon-wide, every other profile by its own per-profile
// owner. Caller must hold s.mutex.
func (s *Server) activeIsDefaultLocked() (bool, error) {
active, err := s.profileManager.GetActiveProfileState()
if err != nil {
return false, fmt.Errorf("get active profile: %w", err)
}
return active.ID == profilemanager.DefaultProfileName, nil
}
// ActiveProfileOwnership returns the ownership the interceptor gates the active
// profile against. The default profile uses the daemon-wide owner set, every
// other profile uses its own collision-free owner (isolated per user).
func (s *Server) ActiveProfileOwnership() ipcauth.Ownership {
s.mutex.Lock()
defer s.mutex.Unlock()
isDefault, err := s.activeIsDefaultLocked()
if err != nil {
log.Warnf("ownership: cannot determine active profile, treating as unowned: %v", err)
return ipcauth.Ownership{}
}
if isDefault {
return s.owners
}
cfg := s.config
if cfg == nil {
loaded, err := s.loadActiveProfileConfigLocked()
@@ -38,12 +88,22 @@ func (s *Server) ActiveProfileOwnership() ipcauth.Ownership {
}
// ClaimActiveProfileOwnerIfUnowned atomically claims the active profile for id
// when it has no owners and is not shared (trust-on-first-use). Returns whether
// id is now an owner. Concurrent first-callers are serialized by s.mutex.
// when it has no owners and is not shared (trust-on-first-use). The default
// profile claims daemon-wide ownership via the owner store, every other profile
// claims its own per-profile owner. Returns whether id is now an owner. Concurrent
// first-callers are serialized by s.mutex.
func (s *Server) ClaimActiveProfileOwnerIfUnowned(id ipcauth.Identity) (bool, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
isDefault, err := s.activeIsDefaultLocked()
if err != nil {
return false, fmt.Errorf("determine active profile: %w", err)
}
if isDefault {
return s.claimDaemonOwnerLocked(id)
}
cfg := s.config
if cfg == nil {
loaded, err := s.loadActiveProfileConfigLocked()
@@ -67,6 +127,45 @@ func (s *Server) ClaimActiveProfileOwnerIfUnowned(id ipcauth.Identity) (bool, er
return true, nil
}
// claimDaemonOwnerLocked claims daemon-wide ownership for id when the daemon is
// unowned and unshared, persisting via the owner store. Caller must hold s.mutex.
func (s *Server) claimDaemonOwnerLocked(id ipcauth.Identity) (bool, error) {
if s.daemonOwnerStore == nil {
return false, nil // no store, cannot claim, fail closed
}
if len(s.owners.Owners) > 0 || s.owners.Shared {
return false, nil // already owned or shared
}
principal := ipcauth.OwnerPrincipalForIdentity(id)
if err := s.daemonOwnerStore.Save([]string{principal}, false); err != nil {
return false, fmt.Errorf("persist daemon owner claim: %w", err)
}
s.owners = ipcauth.Ownership{Owners: []string{principal}}
log.Infof("daemon ownership claimed by %s (trust-on-first-use)", id)
return true, nil
}
// addDaemonOwnerLocked adds id's principal to the daemon owner set and persists.
// Idempotent, and a no-op for privileged callers. Caller must hold s.mutex.
func (s *Server) addDaemonOwnerLocked(id ipcauth.Identity) error {
if id.IsPrivileged() {
return nil
}
if s.daemonOwnerStore == nil {
return fmt.Errorf("daemon owner store unavailable")
}
principal := ipcauth.OwnerPrincipalForIdentity(id)
if slices.Contains(s.owners.Owners, principal) {
return nil
}
next := append(slices.Clone(s.owners.Owners), principal)
if err := s.daemonOwnerStore.Save(next, s.owners.Shared); err != nil {
return err
}
s.owners.Owners = next
return nil
}
// activeProfileConfigPathLocked resolves the active profile's config file path.
func (s *Server) activeProfileConfigPathLocked() (string, error) {
activeProf, err := s.profileManager.GetActiveProfileState()
@@ -98,22 +197,21 @@ func (s *Server) persistActiveProfileConfigLocked(cfg *profilemanager.Config) er
return util.WriteJson(context.Background(), path, cfg)
}
// activeConfigLocked returns the in-memory active config, loading it from disk
// if the daemon hasn't cached one. Caller must hold s.mutex.
func (s *Server) activeConfigLocked() (*profilemanager.Config, error) {
if s.config != nil {
return s.config, nil
}
return s.loadActiveProfileConfigLocked()
}
// claimForCallerLocked adds the caller's principal to cfg (if absent) and
// persists. No-op for privileged callers (they need no ownership entry). Caller
// must hold s.mutex.
// claimForCallerLocked adds the caller's principal to the active profile's owner
// set (if absent) and persists. No-op for privileged callers. For the default
// profile it adds to the daemon-wide owners, for any other profile it adds to
// that profile's per-profile owners. Caller must hold s.mutex.
func (s *Server) claimForCallerLocked(id ipcauth.Identity, cfg *profilemanager.Config) error {
if id.IsPrivileged() {
return nil
}
isDefault, err := s.activeIsDefaultLocked()
if err != nil {
return err
}
if isDefault {
return s.addDaemonOwnerLocked(id)
}
principal := ipcauth.OwnerPrincipalForIdentity(id)
if slices.Contains(cfg.Owners, principal) {
return nil
@@ -148,6 +246,16 @@ func (s *Server) authorizeTargetProfile(ctx context.Context, target *profilemana
return nil
}
// The default profile is governed by the daemon-wide owners (all owners may
// use it), not a per-profile owner. Authorize against s.owners and never stamp.
if target.ID == profilemanager.DefaultProfileName {
if ipcauth.Authorize(s.owners, id, s.groupResolver) {
return nil
}
return gstatus.Errorf(codes.PermissionDenied,
"not authorized to use the default profile (caller %s is not a daemon owner)", id)
}
path, err := target.FilePath()
if err != nil {
return fmt.Errorf("resolve target profile path: %w", err)
@@ -181,8 +289,8 @@ func (s *Server) authorizeTargetProfile(ctx context.Context, target *profilemana
return nil
}
// AddOwner adds a principal to the active profile's owner list. The interceptor
// has already confirmed the caller is an owner or privileged, the handler just
// AddOwner adds a principal to the daemon-wide owner set. The interceptor has
// already confirmed the caller is an owner or privileged, the handler just
// validates and persists.
func (s *Server) AddOwner(_ context.Context, msg *proto.AddOwnerRequest) (*proto.AddOwnerResponse, error) {
principal := msg.GetPrincipal()
@@ -193,25 +301,23 @@ func (s *Server) AddOwner(_ context.Context, msg *proto.AddOwnerRequest) (*proto
s.mutex.Lock()
defer s.mutex.Unlock()
cfg, err := s.activeConfigLocked()
if err != nil {
return nil, fmt.Errorf("load active profile config: %w", err)
if s.daemonOwnerStore == nil {
return nil, gstatus.Error(codes.Unavailable, "daemon owner store unavailable")
}
if slices.Contains(cfg.Owners, principal) {
if slices.Contains(s.owners.Owners, principal) {
return &proto.AddOwnerResponse{}, nil
}
cfg.Owners = append(cfg.Owners, principal)
if err := s.persistActiveProfileConfigLocked(cfg); err != nil {
cfg.Owners = cfg.Owners[:len(cfg.Owners)-1]
next := append(slices.Clone(s.owners.Owners), principal)
if err := s.daemonOwnerStore.Save(next, s.owners.Shared); err != nil {
return nil, fmt.Errorf("persist owner: %w", err)
}
s.config = cfg
log.Infof("added owner %q to the active profile", principal)
s.owners.Owners = next
log.Infof("added daemon owner %q", principal)
return &proto.AddOwnerResponse{}, nil
}
// ResetOwner clears the active profile's owner list (and shared flag), returning
// it to the unowned state so the next caller re-claims via trust-on-first-use.
// ResetOwner clears the daemon-wide owner set (and shared flag), returning the
// daemon to the unowned state so the next caller re-claims via trust-on-first-use.
// Privileged-only, so co-owners cannot evict each other.
func (s *Server) ResetOwner(ctx context.Context, _ *proto.ResetOwnerRequest) (*proto.ResetOwnerResponse, error) {
id, ok := ipcauth.IdentityFromContext(ctx)
@@ -222,35 +328,31 @@ func (s *Server) ResetOwner(ctx context.Context, _ *proto.ResetOwnerRequest) (*p
s.mutex.Lock()
defer s.mutex.Unlock()
cfg, err := s.activeConfigLocked()
if err != nil {
return nil, fmt.Errorf("load active profile config: %w", err)
if s.daemonOwnerStore == nil {
return nil, gstatus.Error(codes.Unavailable, "daemon owner store unavailable")
}
cfg.Owners = nil
cfg.Shared = false
if err := s.persistActiveProfileConfigLocked(cfg); err != nil {
if err := s.daemonOwnerStore.Save(nil, false); err != nil {
return nil, fmt.Errorf("persist owner reset: %w", err)
}
s.config = cfg
log.Infof("active profile owner list reset; next caller will re-claim (trust-on-first-use)")
s.owners = ipcauth.Ownership{}
log.Infof("daemon owner list reset, next caller will re-claim (trust-on-first-use)")
return &proto.ResetOwnerResponse{}, nil
}
// ShareProfile marks the active profile shared or unshared. The interceptor has
// ShareProfile marks the daemon shared or unshared. When shared, any authenticated
// local caller may control the daemon and its default profile. The interceptor has
// already confirmed the caller is an owner or privileged.
func (s *Server) ShareProfile(_ context.Context, msg *proto.ShareProfileRequest) (*proto.ShareProfileResponse, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
cfg, err := s.activeConfigLocked()
if err != nil {
return nil, fmt.Errorf("load active profile config: %w", err)
if s.daemonOwnerStore == nil {
return nil, gstatus.Error(codes.Unavailable, "daemon owner store unavailable")
}
cfg.Shared = msg.GetShared()
if err := s.persistActiveProfileConfigLocked(cfg); err != nil {
if err := s.daemonOwnerStore.Save(s.owners.Owners, msg.GetShared()); err != nil {
return nil, fmt.Errorf("persist shared flag: %w", err)
}
s.config = cfg
log.Infof("active profile shared flag set to %t", msg.GetShared())
s.owners.Shared = msg.GetShared()
log.Infof("daemon shared flag set to %t", msg.GetShared())
return &proto.ShareProfileResponse{}, nil
}

View File

@@ -12,9 +12,94 @@ import (
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/util"
)
// fakeOwnerStore is an in-memory server.DaemonOwnerStore for tests.
type fakeOwnerStore struct {
owners []string
shared bool
}
func (f *fakeOwnerStore) Load() ([]string, bool, error) { return f.owners, f.shared, nil }
func (f *fakeOwnerStore) Save(o []string, s bool) error { f.owners, f.shared = o, s; return nil }
// useTempProfileDirs points the profilemanager globals at a temp dir so
// GetActiveProfileState resolves to the default profile without touching /etc.
func useTempProfileDirs(t *testing.T) {
t.Helper()
tempDir := t.TempDir()
origDir := profilemanager.DefaultConfigPathDir
origActive := profilemanager.ActiveProfileStatePath
origDefault := profilemanager.DefaultConfigPath
profilemanager.ConfigDirOverride = tempDir
profilemanager.DefaultConfigPathDir = tempDir
profilemanager.ActiveProfileStatePath = filepath.Join(tempDir, "active_profile.json")
profilemanager.DefaultConfigPath = filepath.Join(tempDir, "default.json")
t.Cleanup(func() {
profilemanager.DefaultConfigPathDir = origDir
profilemanager.ActiveProfileStatePath = origActive
profilemanager.DefaultConfigPath = origDefault
profilemanager.ConfigDirOverride = ""
})
}
// TestDaemonOwnerPolicyDefaultProfile exercises the daemon-wide owner branch that
// governs the default profile: TOFU claim, add and reset, all via the store.
func TestDaemonOwnerPolicyDefaultProfile(t *testing.T) {
useTempProfileDirs(t)
store := &fakeOwnerStore{}
s := &Server{profileManager: &profilemanager.ServiceManager{}, groupResolver: ipcauth.NewDefaultGroupResolver()}
s.SetDaemonOwnerStore(store)
// Active profile is the default, daemon is unowned to start.
o := s.ActiveProfileOwnership()
assert.Empty(t, o.Owners)
assert.False(t, o.Shared)
// Trust-on-first-use: the first caller claims daemon ownership, persisted.
claimed, err := s.ClaimActiveProfileOwnerIfUnowned(ipcauth.Identity{UID: 1000})
require.NoError(t, err)
assert.True(t, claimed)
assert.Equal(t, []string{"uid:1000"}, store.owners)
assert.Equal(t, []string{"uid:1000"}, s.ActiveProfileOwnership().Owners)
// A second, different caller does not re-claim an owned daemon.
claimed, err = s.ClaimActiveProfileOwnerIfUnowned(ipcauth.Identity{UID: 1001})
require.NoError(t, err)
assert.False(t, claimed)
// AddOwner appends a daemon-wide principal (persisted).
_, err = s.AddOwner(context.Background(), &proto.AddOwnerRequest{Principal: "uid:1001"})
require.NoError(t, err)
assert.Equal(t, []string{"uid:1000", "uid:1001"}, store.owners)
// ResetOwner (privileged) clears the daemon owner set.
_, err = s.ResetOwner(ctxWithIdentity(ipcauth.Identity{UID: 0}), &proto.ResetOwnerRequest{})
require.NoError(t, err)
assert.Empty(t, store.owners)
assert.False(t, store.shared)
}
// TestDaemonOwnerAllOwnersUseDefault verifies every daemon owner is authorized
// for the default profile, while a non-owner is denied.
func TestDaemonOwnerAllOwnersUseDefault(t *testing.T) {
s := &Server{groupResolver: ipcauth.NewDefaultGroupResolver()}
s.owners = ipcauth.Ownership{Owners: []string{"uid:1000", "uid:1001"}}
deflt := &profilemanager.Profile{ID: profilemanager.ID(profilemanager.DefaultProfileName), Name: "default"}
// Both owners may use the default profile.
require.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(ipcauth.Identity{UID: 1000}), deflt, true))
require.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(ipcauth.Identity{UID: 1001}), deflt, true))
// A non-owner is denied the default profile.
err := s.authorizeTargetProfile(ctxWithIdentity(ipcauth.Identity{UID: 2000}), deflt, true)
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
}
// writeTargetProfile writes a profile JSON with the given ownership and returns
// a Profile handle pointing at it (Path set, so FilePath() resolves directly).
func writeTargetProfile(t *testing.T, dir, id string, owners []string, shared bool) *profilemanager.Profile {

View File

@@ -130,9 +130,18 @@ type Server struct {
// groupResolver resolves a Unix caller's supplementary group membership
// (NSS/getent) so gid:/group: owner principals authorize correctly. Nil on
// Windows (SID group membership travels in the identity itself); ipcauth
// Windows, where SID group membership travels in the identity itself. ipcauth
// treats a nil resolver as "no group matching".
groupResolver ipcauth.GroupResolver
// owners is the in-memory daemon-wide owner set, loaded from daemonOwnerStore.
// It governs the default profile and daemon-wide access. Non-default profiles
// stay isolated per user via their own per-profile owner. Guarded by s.mutex.
owners ipcauth.Ownership
// daemonOwnerStore persists owners to service.json, injected by cmd via
// SetDaemonOwnerStore. Nil before injection or in tests, where the daemon is
// treated as unowned and non-privileged callers are denied on the default.
daemonOwnerStore DaemonOwnerStore
}
type oauthAuthFlow struct {