mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-03 03:59:07 +02:00
Make active profile name available for other owners in status and list
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user