diff --git a/client/cmd/profile.go b/client/cmd/profile.go index 268034e70..9d133d6c2 100644 --- a/client/cmd/profile.go +++ b/client/cmd/profile.go @@ -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 { diff --git a/client/cmd/status.go b/client/cmd/status.go index c4057ed82..bdadef2d7 100644 --- a/client/cmd/status.go +++ b/client/cmd/status.go @@ -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 { diff --git a/client/internal/ipcauth/interceptor.go b/client/internal/ipcauth/interceptor.go index de61b3eb9..de4caa911 100644 --- a/client/internal/ipcauth/interceptor.go +++ b/client/internal/ipcauth/interceptor.go @@ -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 { diff --git a/client/internal/ipcauth/interceptor_test.go b/client/internal/ipcauth/interceptor_test.go index 80c7d16fb..8f7fb8b4b 100644 --- a/client/internal/ipcauth/interceptor_test.go +++ b/client/internal/ipcauth/interceptor_test.go @@ -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) diff --git a/client/internal/ipcauth/policy.go b/client/internal/ipcauth/policy.go index 86be2b14e..a5bf67b16 100644 --- a/client/internal/ipcauth/policy.go +++ b/client/internal/ipcauth/policy.go @@ -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) +} diff --git a/client/server/ownership.go b/client/server/ownership.go index 2fa551642..1eee4fe2a 100644 --- a/client/server/ownership.go +++ b/client/server/ownership.go @@ -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) {