package ipcauth import ( "errors" "fmt" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc/codes" gstatus "google.golang.org/grpc/status" ) // Every method names what it does, so a refusal can say what was refused rather // than quoting a level at the user. func TestPoliciesDeclareAnAction(t *testing.T) { for method, policy := range methodPolicies { assert.NotEmpty(t, policy.Action, "%s declares no Action, its refusals cannot name the operation", method) } } // A refusal that names no action cannot say what was refused, and a privileged // method drops to a bare message without one. func TestPrivilegedPoliciesDeclareAnAction(t *testing.T) { for method, policy := range methodPolicies { if policy.Level != AuthzLevelPrivileged { continue } assert.NotEmpty(t, policy.Action, "%s requires privilege but declares no Action", method) } } // ownedByAnother is the profile most of these refusals are about: it exists and // records an owner, that owner is simply not this caller. var ownedByAnother = Target{Path: "/profiles/someone-else.json"} // unownedProfile records no owner, which is what a machine set up with nobody at // its console leaves behind. var unownedProfile = Target{Path: "/profiles/default.json", UnOwned: true, Handle: "default"} func TestDenyPolicyLevelCarriesPrivilegeGuidance(t *testing.T) { req := Request{ Identity: KnownForTest(Identity{UID: 1000}), Level: AuthzLevelIdentified, Method: servicePath + "ClaimProfile", } err := denyPolicyLevel(req, methodPolicies[servicePath+"ClaimProfile"], unownedProfile) require.Error(t, err) st := gstatus.Convert(err) assert.Equal(t, codes.PermissionDenied, st.Code()) var info *errdetails.ErrorInfo for _, d := range st.Details() { if got, ok := d.(*errdetails.ErrorInfo); ok { info = got } } require.NotNil(t, info, "a privilege refusal must be machine readable") assert.Equal(t, ErrorReasonPrivilegeRequired, info.GetReason()) assert.Equal(t, ErrorDomain, info.GetDomain()) assert.NotEmpty(t, info.GetMetadata()[ErrorMetaSummary]) assert.Contains(t, info.GetMetadata()[ErrorMetaCommand], "netbird profile claim default", "the guidance names the profile the request resolved to, not a placeholder") } // A profile that belongs to somebody else is explained, not answered with sudo. func TestDenyPolicyLevelExplainsAProfileOwnedByAnother(t *testing.T) { req := Request{ Identity: KnownForTest(Identity{UID: 1000}), Level: AuthzLevelIdentified, Method: servicePath + "SetConfig", State: stubState{}, } info := denialDetail(t, denyPolicyLevel(req, methodPolicies[servicePath+"SetConfig"], ownedByAnother)) assert.Equal(t, ErrorReasonNotProfileOwner, info.GetReason()) assert.Contains(t, info.GetMetadata()[ErrorMetaSummary], "belongs to another user") _, hasCommand := info.GetMetadata()[ErrorMetaCommand] assert.False(t, hasCommand, "privilege is not what the method asked for") } // A privileged method that declares nothing still refuses, it just cannot say // how to satisfy it. This is the methodPolicyFor fallback for an unknown RPC. func TestDenyPolicyLevelWithoutGuidanceStaysBare(t *testing.T) { req := Request{ Identity: KnownForTest(Identity{UID: 1000}), Level: AuthzLevelIdentified, Method: servicePath + "NotARealMethod", } err := denyPolicyLevel(req, methodPolicyFor(req.Method), ownedByAnother) require.Error(t, err) assert.Equal(t, codes.PermissionDenied, gstatus.Convert(err).Code()) assert.Empty(t, gstatus.Convert(err).Details()) } // stubState stands in for the daemon so a denial can be built without a server. type stubState struct { holder Principal running bool target Target targetErr error } func (s stubState) SessionHolder() (Principal, bool) { return s.holder, s.running } func (s stubState) ResolveTarget(Identity, string) (Target, error) { return s.target, s.targetErr } // A refusal caused by somebody else's connection explains itself and offers no // command, since the caller cannot end a session that is not theirs. // // Profile owner is the whole input: denyPolicyLevel reads the level and nothing // else, and resolveLevel only ever hands it that level when a session is // running and somebody else holds it. TestAuthorizeBlamesAHeldSession is what // holds those two together. func TestDenyPolicyLevelExplainsAHeldSession(t *testing.T) { req := Request{ Identity: KnownForTest(Identity{UID: 1000}), Level: AuthzLevelProfileOwner, Method: servicePath + "Up", } info := denialDetail(t, denyPolicyLevel(req, methodPolicies[servicePath+"Up"], ownedByAnother)) assert.Equal(t, ErrorReasonSessionHeld, info.GetReason()) summary := info.GetMetadata()[ErrorMetaSummary] assert.Contains(t, summary, "Connecting", "the summary names what was refused") assert.Contains(t, summary, "another user") assert.NotContains(t, summary, "4242", "who holds it is not the caller's business") // An administrator outranks the session holder, so taking the connection // down is a remedy the caller can actually be pointed at. assert.Contains(t, info.GetMetadata()[ErrorMetaCommand], "netbird down") } // A caller who never owned the profile is refused for the profile, whatever the // connection is doing. func TestDenyPolicyLevelBelowProfileOwnerBlamesOwnership(t *testing.T) { req := Request{ Identity: KnownForTest(Identity{UID: 1000}), Level: AuthzLevelIdentified, Method: servicePath + "Up", } info := denialDetail(t, denyPolicyLevel(req, methodPolicies[servicePath+"Up"], ownedByAnother)) assert.Equal(t, ErrorReasonNotProfileOwner, info.GetReason()) assert.Contains(t, info.GetMetadata()[ErrorMetaSummary], "belongs to another user") assert.NotContains(t, info.GetMetadata()[ErrorMetaSummary], "connected", "the refusal is about the profile, so it must not blame a connection") _, hasCommand := info.GetMetadata()[ErrorMetaCommand] assert.False(t, hasCommand, "ending a session does not make the profile theirs") } // A method with no Action still refuses, it just cannot name the operation. func TestSessionHeldSummaryWithoutAnAction(t *testing.T) { assert.Contains(t, sessionHeldSummary(""), "This command is refused") assert.Contains(t, sessionHeldSummary("connecting"), "Connecting is refused") } // denialDetail pulls the machine readable half out of a refusal. func denialDetail(t *testing.T, err error) *errdetails.ErrorInfo { t.Helper() require.Error(t, err) st := gstatus.Convert(err) require.Equal(t, codes.PermissionDenied, st.Code()) for _, d := range st.Details() { if info, ok := d.(*errdetails.ErrorInfo); ok { require.Equal(t, ErrorDomain, info.GetDomain()) return info } } t.Fatal("refusal carries no ErrorInfo detail") return nil } // DenialFrom is the one reader of the detail the builders attach, so the CLI and // the UI cannot drift on what counts as a refusal. func TestDenialFromReadsEveryReason(t *testing.T) { for _, tc := range []struct { name string err error reason string command bool }{ {"privilege", PrivilegeError("Claiming a profile requires root.", "sudo netbird profile claim"), ErrorReasonPrivilegeRequired, true}, {"session held", SessionHeldError("connecting"), ErrorReasonSessionHeld, true}, {"not owner", NotOwnerError("switching profile"), ErrorReasonNotProfileOwner, false}, } { t.Run(tc.name, func(t *testing.T) { denial, ok := DenialFrom(tc.err) require.True(t, ok) assert.Equal(t, tc.reason, denial.Reason) assert.NotEmpty(t, denial.Summary) assert.Equal(t, tc.command, denial.Command != "") }) } } func TestDenialFromIgnoresWhatIsNotOurs(t *testing.T) { _, ok := DenialFrom(nil) assert.False(t, ok) _, ok = DenialFrom(errors.New("connection refused")) assert.False(t, ok, "a plain error explains no refusal") _, ok = DenialFrom(gstatus.Error(codes.PermissionDenied, "denied")) assert.False(t, ok, "a status with no detail of ours is not ours to reword") } // A wrap must not hide the refusal, since commands add context before printing. func TestDenialFromSeesThroughWrapping(t *testing.T) { denial, ok := DenialFrom(fmt.Errorf("up failed: %w", SessionHeldError("connecting"))) require.True(t, ok) assert.Equal(t, ErrorReasonSessionHeld, denial.Reason) } // A detail with no summary still refused something, so the status message stands // in rather than leaving a consumer with nothing to show. func TestDenialFromFallsBackToTheStatusMessage(t *testing.T) { st, err := gstatus.New(codes.PermissionDenied, "refused for reasons").WithDetails(&errdetails.ErrorInfo{ Reason: ErrorReasonSessionHeld, Domain: ErrorDomain, }) require.NoError(t, err) denial, ok := DenialFrom(st.Err()) require.True(t, ok) assert.Equal(t, "refused for reasons", denial.Summary) } // A profile nobody has claimed is the headless install: the caller is not being // kept out of somebody else's profile, they are being told to record an owner. func TestDenyPolicyLevelOffersTheClaimForAnUnownedProfile(t *testing.T) { req := Request{ Identity: KnownForTest(Identity{UID: 1000}), Level: AuthzLevelIdentified, Method: servicePath + "Up", } stubConsoleLookup(t, false) info := denialDetail(t, denyPolicyLevel(req, methodPolicies[servicePath+"Up"], unownedProfile)) assert.Equal(t, ErrorReasonProfileUnowned, info.GetReason()) summary := info.GetMetadata()[ErrorMetaSummary] assert.Contains(t, summary, "Connecting", "the summary names what was refused") assert.Contains(t, summary, "no owner on record") assert.NotContains(t, summary, "another user", "nobody owns it, so blaming another user would be untrue") assert.Contains(t, info.GetMetadata()[ErrorMetaCommand], "netbird profile claim default", "the command names the profile that was refused") } // The same refusal reaches a method that only needs profile owner, so a settings // read on a fresh headless machine explains itself the same way connecting does. func TestDenyPolicyLevelOffersTheClaimBelowSessionHolderToo(t *testing.T) { req := Request{ Identity: KnownForTest(Identity{UID: 1000}), Level: AuthzLevelIdentified, Method: servicePath + "GetConfig", } info := denialDetail(t, denyPolicyLevel(req, methodPolicies[servicePath+"GetConfig"], unownedProfile)) assert.Equal(t, ErrorReasonProfileUnowned, info.GetReason()) assert.Contains(t, info.GetMetadata()[ErrorMetaCommand], "netbird profile claim default") } // A session somebody else holds outranks the profile having no owner: the // connection is what is in the way, and ending it is the remedy. func TestDenyPolicyLevelKeepsTheHeldSessionAheadOfOwnership(t *testing.T) { req := Request{ Identity: KnownForTest(Identity{UID: 1000}), Level: AuthzLevelProfileOwner, Method: servicePath + "Up", } info := denialDetail(t, denyPolicyLevel(req, methodPolicies[servicePath+"Up"], unownedProfile)) assert.Equal(t, ErrorReasonSessionHeld, info.GetReason()) } // The claim command names the profile it is going to act on. func TestClaimCommandNamesTheProfile(t *testing.T) { assert.Equal(t, ElevatedCommand("netbird profile claim default"), ClaimCommand("default")) assert.Equal(t, ElevatedCommand("netbird profile claim "), ClaimCommand(""), "with no profile to name the caller fills in the placeholder") } // stubConsoleLookup decides whether a caller counts as being at the console, // without the machine running the test having a seat of its own. func stubConsoleLookup(t *testing.T, atConsole bool) { t.Helper() orig := consoleLookup consoleLookup = func(Identity) bool { return atConsole } t.Cleanup(func() { consoleLookup = orig }) } // A caller away from the console is told what normally claims a profile, since // a machine set up without one is how it goes unclaimed. func TestUnownedSummaryNamesTheConsoleAwayFromIt(t *testing.T) { summary := unownedSummary("connecting", false) assert.Contains(t, summary, "has no owner on record") assert.Contains(t, summary, "console") assert.Contains(t, summary, "An explicit claim of the profile is needed.") } // A caller at the console who still finds no owner got here another way, a // migration that did not finish among them. func TestUnownedSummaryStaysQuietAboutTheConsoleAtIt(t *testing.T) { summary := unownedSummary("connecting", true) assert.Contains(t, summary, "has no owner on record") assert.NotContains(t, summary, "console") assert.Contains(t, summary, "An explicit claim of the profile is needed.", "the remedy is the same wherever the caller is sitting") } // The reason and the command do not move with the caller, only the explanation // of how the profile came to be unowned does. func TestDenyOwnershipKeepsTheClaimForAConsoleCaller(t *testing.T) { stubConsoleLookup(t, true) info := denialDetail(t, denyOwnership("connecting", KnownForTest(Identity{UID: 1000}), unownedProfile)) assert.Equal(t, ErrorReasonProfileUnowned, info.GetReason()) assert.Contains(t, info.GetMetadata()[ErrorMetaCommand], "netbird profile claim default") assert.NotContains(t, info.GetMetadata()[ErrorMetaSummary], "console") } // Two commands put an authorization refusal right, ending the session and // recording an owner. Whatever a denial hands the caller is one of them. func TestDenialsOfferOnlyTheTwoRemedies(t *testing.T) { _, down := RequiredActor(DownCommand()) _, claim := RequiredActor(ClaimCommand(unownedProfile.Handle)) for name, err := range map[string]error{ "privileged": denyPrivileged(methodPolicies[servicePath+"ClaimProfile"], unownedProfile), "session held": SessionHeldError("connecting"), "unowned": UnownedError("connecting", unownedProfile.Handle, false), "not owner": NotOwnerError("connecting"), } { t.Run(name, func(t *testing.T) { denial, ok := DenialFrom(err) require.True(t, ok, "every refusal has to be machine readable") if denial.Command == "" { return } assert.Contains(t, []string{down, claim}, denial.Command, "a refusal offered a command that is neither remedy") }) } } // A sudo prefix already says who has to run the command, so repeating it in the // summary would be noise. func TestRemedyNoteStaysQuietBehindSudo(t *testing.T) { assert.Empty(t, remedyNote("root", "sudo netbird down")) } // Windows has no sudo to prefix and neither does a delegating daemon, so the // summary is the only place that can name who must run the command. func TestRemedyNoteNamesTheActorWithoutSudo(t *testing.T) { assert.Equal(t, " Running this requires administrator privileges.", remedyNote("administrator privileges", "netbird down")) } // stubSelfDaemon pins what the daemon runs as, so a refusal's wording does not // follow from how the test process was started. func stubSelfDaemon(t *testing.T, self Identity) { t.Helper() prevID, prevDelegate := selfIdentity, selfMayDelegate t.Cleanup(func() { selfIdentity, selfMayDelegate = prevID, prevDelegate }) selfIdentity = self selfMayDelegate = self.Known() && !self.IsPrivileged() } // Whoever the daemon runs as, the caller ends up told who has to run the command // they were offered. A sudo prefix says it, and where there is none the summary // does. func TestSessionHeldSaysWhatRunningTheCommandTakes(t *testing.T) { for name, self := range map[string]Identity{ "privileged daemon": KnownForTest(Identity{UID: 0}), "delegating daemon": KnownForTest(Identity{UID: 1000, GID: 1000}), } { t.Run(name, func(t *testing.T) { stubSelfDaemon(t, self) denial, ok := DenialFrom(SessionHeldError("switching profile")) require.True(t, ok) actor, command := RequiredActor(DownCommand()) assert.Equal(t, command, denial.Command) if strings.HasPrefix(command, "sudo ") { assert.NotContains(t, denial.Summary, "Running this requires", "the prefix already names who has to run it") return } assert.Contains(t, denial.Summary, "Running this requires "+actor, "nothing else names who has to run it") }) } }