Files
netbird/client/server/ownership_test.go
2026-07-25 20:51:29 +02:00

211 lines
8.4 KiB
Go

package server
import (
"context"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"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/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). The caller must be a
// daemon owner: uid:1000 claimed ownership above.
_, err = s.AddOwner(ctxWithIdentity(ipcauth.Identity{UID: 1000}), &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)
}
// TestOwnerMutationsRequireDaemonOwner checks the handler defense-in-depth: a
// caller who is neither a daemon owner nor privileged is denied at the handler.
func TestOwnerMutationsRequireDaemonOwner(t *testing.T) {
store := &fakeOwnerStore{owners: []string{"uid:1000"}}
s := &Server{groupResolver: ipcauth.NewDefaultGroupResolver()}
s.SetDaemonOwnerStore(store) // loads {uid:1000} into s.owners
owner := ctxWithIdentity(ipcauth.Identity{UID: 1000})
nonOwner := ctxWithIdentity(ipcauth.Identity{UID: 2000})
root := ctxWithIdentity(ipcauth.Identity{UID: 0})
t.Run("AddOwner denied for non-daemon-owner", func(t *testing.T) {
_, err := s.AddOwner(nonOwner, &proto.AddOwnerRequest{Principal: "uid:2000"})
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
assert.Equal(t, []string{"uid:1000"}, store.owners, "owner set must be unchanged")
})
t.Run("ShareProfile denied for non-daemon-owner", func(t *testing.T) {
_, err := s.ShareProfile(nonOwner, &proto.ShareProfileRequest{Shared: true})
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
assert.False(t, store.shared, "shared flag must be unchanged")
})
t.Run("AddOwner allowed for daemon owner", func(t *testing.T) {
_, err := s.AddOwner(owner, &proto.AddOwnerRequest{Principal: "uid:2000"})
require.NoError(t, err)
assert.Equal(t, []string{"uid:1000", "uid:2000"}, store.owners)
})
t.Run("ShareProfile allowed for root", func(t *testing.T) {
_, err := s.ShareProfile(root, &proto.ShareProfileRequest{Shared: true})
require.NoError(t, err)
assert.True(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 {
t.Helper()
path := filepath.Join(dir, id+".json")
cfg := &profilemanager.Config{Owners: owners, Shared: shared}
require.NoError(t, util.WriteJson(context.Background(), path, cfg))
return &profilemanager.Profile{ID: profilemanager.ID(id), Name: id, Path: path}
}
func readOwners(t *testing.T, path string) ([]string, bool) {
t.Helper()
cfg, err := profilemanager.GetConfig(path)
require.NoError(t, err)
return cfg.Owners, cfg.Shared
}
func TestAuthorizeTargetProfile(t *testing.T) {
s := &Server{groupResolver: ipcauth.NewDefaultGroupResolver()}
owner := ipcauth.Identity{UID: 1000}
other := ipcauth.Identity{UID: 1001}
root := ipcauth.Identity{UID: 0}
t.Run("no identity denies", func(t *testing.T) {
p := writeTargetProfile(t, t.TempDir(), "p", []string{"uid:1000"}, false)
err := s.authorizeTargetProfile(context.Background(), p, true)
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
})
t.Run("privileged allowed on another's profile", func(t *testing.T) {
p := writeTargetProfile(t, t.TempDir(), "p", []string{"uid:1000"}, false)
assert.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(root), p, true))
})
t.Run("owner allowed", func(t *testing.T) {
p := writeTargetProfile(t, t.TempDir(), "p", []string{"uid:1000"}, false)
assert.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(owner), p, true))
})
t.Run("non-owner denied", func(t *testing.T) {
p := writeTargetProfile(t, t.TempDir(), "p", []string{"uid:1000"}, false)
err := s.authorizeTargetProfile(ctxWithIdentity(other), p, true)
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
})
t.Run("shared allows any caller", func(t *testing.T) {
p := writeTargetProfile(t, t.TempDir(), "p", nil, true)
assert.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(other), p, true))
})
t.Run("unowned claim stamps owner", func(t *testing.T) {
p := writeTargetProfile(t, t.TempDir(), "p", nil, false)
require.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(other), p, true))
owners, shared := readOwners(t, p.Path)
assert.Equal(t, []string{"uid:1001"}, owners)
assert.False(t, shared)
// A different caller is now locked out of the claimed profile.
err := s.authorizeTargetProfile(ctxWithIdentity(owner), p, true)
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
})
t.Run("unowned without claim leaves profile unowned", func(t *testing.T) {
p := writeTargetProfile(t, t.TempDir(), "p", nil, false)
require.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(other), p, false))
owners, _ := readOwners(t, p.Path)
assert.Empty(t, owners)
})
}