mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-30 01:42:36 -04:00
Make active profile name available for other owners in status and list
This commit is contained in:
@@ -117,10 +117,12 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error {
|
||||
} else {
|
||||
fmt.Fprintln(tw, "NAME\tACTIVE")
|
||||
}
|
||||
anyActive := false
|
||||
for _, profile := range resp.Profiles {
|
||||
marker := ""
|
||||
if profile.IsActive {
|
||||
marker = "✓"
|
||||
anyActive = true
|
||||
}
|
||||
name := profilemanager.StripCtrlChars(profile.Name)
|
||||
id := profilemanager.ID(profile.Id)
|
||||
@@ -130,7 +132,19 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error {
|
||||
fmt.Fprintf(tw, "%s\t%s\n", name, marker)
|
||||
}
|
||||
}
|
||||
return tw.Flush()
|
||||
if err := tw.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// None of the caller's profiles is active: another user may hold the daemon.
|
||||
// Surface it so the empty ACTIVE column is not mistaken for "nothing active".
|
||||
if !anyActive {
|
||||
if active, aerr := daemonClient.GetActiveProfile(cmd.Context(), &proto.GetActiveProfileRequest{}); aerr == nil && active.GetUsername() != "" && !usernamesMatch(active.GetUsername(), currUser.Username) {
|
||||
cmd.Printf("\nActive profile belongs to another user: %s (user %s)\n",
|
||||
profilemanager.StripCtrlChars(active.GetProfileName()), active.GetUsername())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addProfileFunc(cmd *cobra.Command, args []string) error {
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -172,10 +174,12 @@ func getStatus(ctx context.Context, fullPeerStatus bool, shouldRunProbes bool) (
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// getActiveProfileName asks the daemon for the active profile's display
|
||||
// name. The daemon runs as root and can read the per-user profile files to
|
||||
// resolve the ID to its human-readable name. Returns an empty string on any
|
||||
// error so status output degrades gracefully.
|
||||
// getActiveProfileName asks the daemon for the active profile's display name.
|
||||
// The daemon runs as root and can read the per-user profile files to resolve the
|
||||
// ID to its human-readable name. When the active profile belongs to another local
|
||||
// user, the name is annotated with that owner so the caller understands why the
|
||||
// daemon is not under their control. Returns an empty string on any error so
|
||||
// status output degrades gracefully.
|
||||
func getActiveProfileName(ctx context.Context) string {
|
||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||
if err != nil {
|
||||
@@ -188,7 +192,22 @@ func getActiveProfileName(ctx context.Context) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
return resp.GetProfileName()
|
||||
name := resp.GetProfileName()
|
||||
if owner := resp.GetUsername(); owner != "" {
|
||||
if curr, uerr := user.Current(); uerr != nil || !usernamesMatch(owner, curr.Username) {
|
||||
name = fmt.Sprintf("%s (user %s)", name, owner)
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// usernamesMatch compares usernames case-insensitively on Windows (domain
|
||||
// accounts) and exactly elsewhere.
|
||||
func usernamesMatch(a, b string) bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.EqualFold(a, b)
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
|
||||
func parseFilters() error {
|
||||
|
||||
@@ -72,33 +72,35 @@ func (i *Interceptor) authorize(ctx context.Context, fullMethod string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// These RPCs authorize themselves in the handler (target-profile check) or are
|
||||
// connection-lifecycle actions any authenticated local user may perform. They
|
||||
// bypass the active-profile gate. Still audit the C/H ones on allow.
|
||||
// Owner tier: daemon-level RPCs (Add, Down, Status) require a daemon-wide owner.
|
||||
if ownersAuthorizedMethods[fullMethod] {
|
||||
allowed, err := i.authorizeOwnership(id, i.policy.DaemonOwnership, i.policy.ClaimDaemonOwnerIfUnowned)
|
||||
if err != nil {
|
||||
log.Errorf("ipc authz: claim daemon owner for %s: %v", id, err)
|
||||
return status.Error(codes.Internal, "failed to claim daemon ownership")
|
||||
}
|
||||
if allowed {
|
||||
i.auditAllow(id, fullMethod)
|
||||
return nil
|
||||
}
|
||||
log.Warnf("ipc authz: DENY %s for %s. not a daemon owner", fullMethod, id)
|
||||
return status.Errorf(codes.PermissionDenied,
|
||||
"not authorized (caller %s is not a daemon owner). ask an owner or run as root/administrator", id)
|
||||
}
|
||||
|
||||
// Profile tier: the handler self-authorizes against the target profile.
|
||||
if handlerAuthorizedMethods[fullMethod] {
|
||||
i.auditAllow(id, fullMethod)
|
||||
return nil
|
||||
}
|
||||
|
||||
o := i.policy.ActiveProfileOwnership()
|
||||
|
||||
// Trust-on-first-use: an unowned, non-shared profile is claimed by the first
|
||||
// caller. The claim is atomic, if we lose the race we re-read and authorize.
|
||||
if len(o.Owners) == 0 && !o.Shared {
|
||||
claimed, err := i.policy.ClaimActiveProfileOwnerIfUnowned(id)
|
||||
if err != nil {
|
||||
log.Errorf("ipc authz: claim active profile for %s: %v", id, err)
|
||||
return status.Error(codes.Internal, "failed to claim profile ownership")
|
||||
}
|
||||
if claimed {
|
||||
log.Infof("ipc authz: %s claimed ownership of the active profile (trust-on-first-use)", id)
|
||||
i.auditAllow(id, fullMethod)
|
||||
return nil
|
||||
}
|
||||
o = i.policy.ActiveProfileOwnership()
|
||||
// Default: gated on the active profile's ownership.
|
||||
allowed, err := i.authorizeOwnership(id, i.policy.ActiveProfileOwnership, i.policy.ClaimActiveProfileOwnerIfUnowned)
|
||||
if err != nil {
|
||||
log.Errorf("ipc authz: claim active profile for %s: %v", id, err)
|
||||
return status.Error(codes.Internal, "failed to claim profile ownership")
|
||||
}
|
||||
|
||||
if Authorize(o, id, i.resolver) {
|
||||
if allowed {
|
||||
i.auditAllow(id, fullMethod)
|
||||
return nil
|
||||
}
|
||||
@@ -108,6 +110,24 @@ func (i *Interceptor) authorize(ctx context.Context, fullMethod string) error {
|
||||
"not authorized to control the active profile (caller %s). ask an owner or run as root/administrator", id)
|
||||
}
|
||||
|
||||
// authorizeOwnership authorizes id against an ownership set, claiming it via
|
||||
// trust-on-first-use when it is unowned and unshared. The claim is atomic, so on
|
||||
// a lost race it re-reads and authorizes normally.
|
||||
func (i *Interceptor) authorizeOwnership(id Identity, get func() Ownership, claim func(Identity) (bool, error)) (bool, error) {
|
||||
o := get()
|
||||
if len(o.Owners) == 0 && !o.Shared {
|
||||
claimed, err := claim(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if claimed {
|
||||
return true, nil
|
||||
}
|
||||
o = get()
|
||||
}
|
||||
return Authorize(o, id, i.resolver), nil
|
||||
}
|
||||
|
||||
// isSelfOrPrivileged reports whether the caller is the platform administrator
|
||||
// (root / elevated-admin / LocalSystem) or the daemon's own user.
|
||||
func (i *Interceptor) isSelfOrPrivileged(id Identity) bool {
|
||||
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
)
|
||||
|
||||
type mockPolicy struct {
|
||||
o Ownership
|
||||
claimed bool
|
||||
o Ownership // active profile ownership
|
||||
daemon Ownership // daemon-wide ownership
|
||||
claimed bool
|
||||
daemonClaimed bool
|
||||
}
|
||||
|
||||
func (m *mockPolicy) ActiveProfileOwnership() Ownership { return m.o }
|
||||
@@ -29,6 +31,18 @@ func (m *mockPolicy) ClaimActiveProfileOwnerIfUnowned(id Identity) (bool, error)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (m *mockPolicy) DaemonOwnership() Ownership { return m.daemon }
|
||||
|
||||
// ClaimDaemonOwnerIfUnowned records a daemon claim and marks the daemon owned.
|
||||
func (m *mockPolicy) ClaimDaemonOwnerIfUnowned(id Identity) (bool, error) {
|
||||
if len(m.daemon.Owners) == 0 && !m.daemon.Shared {
|
||||
m.daemon.Owners = []string{OwnerPrincipalForIdentity(id)}
|
||||
m.daemonClaimed = true
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
type mockResolver struct {
|
||||
gids map[uint32]struct{}
|
||||
names map[string]uint32
|
||||
@@ -46,6 +60,8 @@ const (
|
||||
list = servicePath + "ListProfiles"
|
||||
unkwn = servicePath + "SomeFutureMethod"
|
||||
down = servicePath + "Down"
|
||||
statusm = servicePath + "Status"
|
||||
addp = servicePath + "AddProfile"
|
||||
switchp = servicePath + "SwitchProfile"
|
||||
)
|
||||
|
||||
@@ -54,37 +70,48 @@ func TestInterceptorAuthorize(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
own Ownership
|
||||
own Ownership // active profile ownership
|
||||
daemon Ownership // daemon-wide ownership
|
||||
resolver GroupResolver
|
||||
ctx context.Context
|
||||
method string
|
||||
wantErr bool
|
||||
}{
|
||||
{"no identity denies", Ownership{}, nil, context.Background(), up, true},
|
||||
{"root allowed", Ownership{}, nil, ctxWith(Identity{UID: 0}), up, false},
|
||||
{"daemon-self allowed", Ownership{}, nil, ctxWith(Identity{UID: selfUID}), up, false},
|
||||
{"shared allows any", Ownership{Shared: true}, nil, ctxWith(Identity{UID: 1234}), up, false},
|
||||
{"uid owner allowed", Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 1000}), up, false},
|
||||
{"non-owner denied", Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), up, true},
|
||||
{"handler-authorized bypass", Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), list, false},
|
||||
{"down allowed while another user's profile active", Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), down, false},
|
||||
{"switch-profile bypasses active-profile gate", Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), switchp, false},
|
||||
{"unknown method gated", Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), unkwn, true},
|
||||
{"primary gid owner", Ownership{Owners: []string{"gid:5000"}}, nil, ctxWith(Identity{UID: 2000, GID: 5000}), up, false},
|
||||
{"group-name owner via resolver", Ownership{Owners: []string{"group:admins"}},
|
||||
// Default gate (active profile ownership).
|
||||
{"no identity denies", Ownership{}, Ownership{}, nil, context.Background(), up, true},
|
||||
{"root allowed", Ownership{}, Ownership{}, nil, ctxWith(Identity{UID: 0}), up, false},
|
||||
{"daemon-self allowed", Ownership{}, Ownership{}, nil, ctxWith(Identity{UID: selfUID}), up, false},
|
||||
{"shared allows any", Ownership{Shared: true}, Ownership{}, nil, ctxWith(Identity{UID: 1234}), up, false},
|
||||
{"uid owner allowed", Ownership{Owners: []string{"uid:1000"}}, Ownership{}, nil, ctxWith(Identity{UID: 1000}), up, false},
|
||||
{"non-owner denied", Ownership{Owners: []string{"uid:1000"}}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), up, true},
|
||||
{"unknown method gated", Ownership{Owners: []string{"uid:1000"}}, Ownership{}, nil, ctxWith(Identity{UID: 2000}), unkwn, true},
|
||||
{"primary gid owner", Ownership{Owners: []string{"gid:5000"}}, Ownership{}, nil, ctxWith(Identity{UID: 2000, GID: 5000}), up, false},
|
||||
{"group-name owner via resolver", Ownership{Owners: []string{"group:admins"}}, Ownership{},
|
||||
mockResolver{names: map[string]uint32{"admins": 5000}, gids: map[uint32]struct{}{5000: {}}},
|
||||
ctxWith(Identity{UID: 2000, GID: 42}), up, false},
|
||||
{"windows sid owner", Ownership{Owners: []string{"sid:S-1-5-21-9"}}, nil,
|
||||
{"windows sid owner", Ownership{Owners: []string{"sid:S-1-5-21-9"}}, Ownership{}, nil,
|
||||
ctxWith(Identity{SID: "S-1-5-21-9"}), up, false},
|
||||
{"windows group-sid owner", Ownership{Owners: []string{"sid:S-1-5-32-544"}}, nil,
|
||||
{"windows group-sid owner", Ownership{Owners: []string{"sid:S-1-5-32-544"}}, Ownership{}, nil,
|
||||
ctxWith(Identity{SID: "S-1-5-21-1", Groups: []string{"S-1-5-32-544"}}), up, false},
|
||||
{"windows elevated privileged", Ownership{}, nil,
|
||||
{"windows elevated privileged", Ownership{}, Ownership{}, nil,
|
||||
ctxWith(Identity{SID: "S-1-5-21-1", Elevated: true}), up, false},
|
||||
|
||||
// Profile tier (handler self-authorizes, bypass).
|
||||
{"list bypasses gate", Ownership{Owners: []string{"uid:1000"}}, Ownership{}, nil, ctxWith(Identity{UID: 2000}), list, false},
|
||||
{"switch-profile bypasses gate", Ownership{Owners: []string{"uid:1000"}}, Ownership{}, nil, ctxWith(Identity{UID: 2000}), switchp, false},
|
||||
|
||||
// Owner tier (daemon-wide ownership), independent of the active profile.
|
||||
{"down by daemon owner allowed", Ownership{Owners: []string{"uid:9"}}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 1000}), down, false},
|
||||
{"down by non-owner denied", Ownership{}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), down, true},
|
||||
{"status by non-owner denied", Ownership{}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), statusm, true},
|
||||
{"add by daemon owner allowed", Ownership{}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 1000}), addp, false},
|
||||
{"add by non-owner denied", Ownership{}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), addp, true},
|
||||
{"owner-tier TOFU claims unowned daemon", Ownership{}, Ownership{}, nil, ctxWith(Identity{UID: 2000}), down, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
i := &Interceptor{policy: &mockPolicy{o: tt.own}, resolver: tt.resolver, selfUID: selfUID}
|
||||
i := &Interceptor{policy: &mockPolicy{o: tt.own, daemon: tt.daemon}, resolver: tt.resolver, selfUID: selfUID}
|
||||
err := i.authorize(tt.ctx, tt.method)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
|
||||
@@ -4,9 +4,9 @@ import "sync"
|
||||
|
||||
const servicePath = "/daemon.DaemonService/"
|
||||
|
||||
// ProfilePolicy exposes the active profile's ownership to the interceptor. The
|
||||
// daemon server implements it. ConfigAdapter bridges the gap because the gRPC
|
||||
// server (and its interceptor) is constructed before the server instance exists.
|
||||
// ProfilePolicy exposes ownership to the interceptor. The daemon server
|
||||
// implements it. ConfigAdapter bridges the gap because the gRPC server (and its
|
||||
// interceptor) is constructed before the server instance exists.
|
||||
type ProfilePolicy interface {
|
||||
// ActiveProfileOwnership returns the active profile's ownership policy.
|
||||
ActiveProfileOwnership() Ownership
|
||||
@@ -14,31 +14,40 @@ type ProfilePolicy interface {
|
||||
// ClaimActiveProfileOwnerIfUnowned atomically claims the active profile for
|
||||
// id when it has no owners and is not shared (trust-on-first-use), and
|
||||
// reports whether id is now an owner. A false return means the profile was
|
||||
// already owned/shared or another caller won the claim.
|
||||
// already owned or shared or another caller won the claim.
|
||||
ClaimActiveProfileOwnerIfUnowned(id Identity) (bool, error)
|
||||
|
||||
// DaemonOwnership returns the daemon-wide ownership policy that governs the
|
||||
// owner-tier RPCs and the default profile.
|
||||
DaemonOwnership() Ownership
|
||||
|
||||
// ClaimDaemonOwnerIfUnowned atomically claims daemon-wide ownership for id
|
||||
// when the daemon is unowned and not shared (trust-on-first-use).
|
||||
ClaimDaemonOwnerIfUnowned(id Identity) (bool, error)
|
||||
}
|
||||
|
||||
// handlerAuthorizedMethods bypass the active-profile gate. Peer identity is
|
||||
// still required to reach them. Two groups:
|
||||
//
|
||||
// - Per-user or per-target-profile ops that self-authorize in the handler,
|
||||
// bound to the caller identity: AddProfile, ListProfiles, GetActiveProfile,
|
||||
// RemoveProfile, RenameProfile, and SwitchProfile (target ownership checked
|
||||
// via authorizeTargetProfile).
|
||||
// - Connection-lifecycle ops any authenticated local user may run on the shared
|
||||
// daemon connection: Down and Status. Gating these on the active profile's
|
||||
// owner would trap a user behind another user's profile, unable to disconnect
|
||||
// or switch to their own. SwitchProfile is bounded by its target check, Down
|
||||
// and Status only read or tear down the connection.
|
||||
// ownersAuthorizedMethods require a daemon-wide owner (or root). They are
|
||||
// daemon-level operations independent of any single profile: creating profiles,
|
||||
// tearing down the connection, and reading daemon status.
|
||||
var ownersAuthorizedMethods = map[string]bool{
|
||||
servicePath + "AddProfile": true,
|
||||
servicePath + "Down": true,
|
||||
servicePath + "Status": true,
|
||||
}
|
||||
|
||||
// handlerAuthorizedMethods bypass the active-profile gate. Peer identity is still
|
||||
// required to reach them. They either self-authorize in the handler against the
|
||||
// profile they target (SwitchProfile, RemoveProfile, RenameProfile via
|
||||
// authorizeTargetProfile) or against the caller's own profiles (ListProfiles via
|
||||
// bindCallerUsername), or return only non-sensitive metadata that any local user
|
||||
// may read (GetActiveProfile: the active profile's id, name, and owning username,
|
||||
// so the CLI can show which profile, and whose, holds the daemon).
|
||||
var handlerAuthorizedMethods = map[string]bool{
|
||||
servicePath + "AddProfile": true,
|
||||
servicePath + "ListProfiles": true,
|
||||
servicePath + "GetActiveProfile": true,
|
||||
servicePath + "RemoveProfile": true,
|
||||
servicePath + "RenameProfile": true,
|
||||
servicePath + "SwitchProfile": true,
|
||||
servicePath + "Down": true,
|
||||
servicePath + "Status": true,
|
||||
servicePath + "GetActiveProfile": true,
|
||||
}
|
||||
|
||||
// auditMethods are worth an audit log line. Denials are always logged.
|
||||
@@ -100,3 +109,24 @@ func (a *ConfigAdapter) ClaimActiveProfileOwnerIfUnowned(id Identity) (bool, err
|
||||
}
|
||||
return a.backend.ClaimActiveProfileOwnerIfUnowned(id)
|
||||
}
|
||||
|
||||
// DaemonOwnership delegates to the backend, reporting unowned when none is set.
|
||||
func (a *ConfigAdapter) DaemonOwnership() Ownership {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.backend == nil {
|
||||
return Ownership{}
|
||||
}
|
||||
return a.backend.DaemonOwnership()
|
||||
}
|
||||
|
||||
// ClaimDaemonOwnerIfUnowned delegates to the backend. Before the backend is set
|
||||
// it cannot claim, so it reports not-owned (fail closed).
|
||||
func (a *ConfigAdapter) ClaimDaemonOwnerIfUnowned(id Identity) (bool, error) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.backend == nil {
|
||||
return false, nil
|
||||
}
|
||||
return a.backend.ClaimDaemonOwnerIfUnowned(id)
|
||||
}
|
||||
|
||||
@@ -127,6 +127,22 @@ func (s *Server) ClaimActiveProfileOwnerIfUnowned(id ipcauth.Identity) (bool, er
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// DaemonOwnership returns the daemon-wide owner set. It governs the owner-tier
|
||||
// RPCs (Add, Down, Status) and the default profile.
|
||||
func (s *Server) DaemonOwnership() ipcauth.Ownership {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
return s.owners
|
||||
}
|
||||
|
||||
// ClaimDaemonOwnerIfUnowned claims daemon-wide ownership for id when the daemon
|
||||
// is unowned and unshared (trust-on-first-use).
|
||||
func (s *Server) ClaimDaemonOwnerIfUnowned(id ipcauth.Identity) (bool, error) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
return s.claimDaemonOwnerLocked(id)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
Reference in New Issue
Block a user