Make active profile name available for other owners in status and list

This commit is contained in:
Theodor S. Midtlien
2026-07-25 20:04:01 +02:00
parent 14917dbc22
commit 11642aeb44
6 changed files with 192 additions and 66 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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) {