Files
netbird/client/internal/ipcauth/privilege_denial_test.go
T

250 lines
8.9 KiB
Go

package ipcauth
import (
"errors"
"fmt"
"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 privileged method must also say how to satisfy it, since that is the one
// refusal the caller can act on.
func TestPrivilegedPoliciesDeclareGuidance(t *testing.T) {
for method, policy := range methodPolicies {
if policy.Level != AuthzLevelPrivileged {
continue
}
assert.NotEmpty(t, policy.Command, "%s requires privilege but declares no Command", method)
}
}
// Only privilege is something the caller can run their way out of. The other
// refusals explain and stop there.
func TestOnlyPrivilegedPoliciesDeclareACommand(t *testing.T) {
for method, policy := range methodPolicies {
if policy.Level == AuthzLevelPrivileged {
continue
}
assert.Empty(t, policy.Command, "%s is not privileged but offers a command", method)
}
}
func TestDenyPolicyLevelCarriesPrivilegeGuidance(t *testing.T) {
req := Request{
Identity: KnownForTest(Identity{UID: 1000}),
Level: AuthzLevelIdentified,
Method: servicePath + "ClaimProfile",
}
err := denyPolicyLevel(req, methodPolicies[servicePath+"ClaimProfile"])
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.NotEmpty(t, info.GetMetadata()[ErrorMetaCommand])
}
// 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"]))
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))
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.
func TestDenyPolicyLevelExplainsAHeldSession(t *testing.T) {
req := Request{
Identity: KnownForTest(Identity{UID: 1000}),
Level: AuthzLevelProfileOwner,
Method: servicePath + "Up",
State: stubState{holder: Principal{Kind: KindUID, Value: "4242"}, running: true},
}
info := denialDetail(t, denyPolicyLevel(req, methodPolicies[servicePath+"Up"]))
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")
}
// With no session running, a caller short of session holder fell short on
// ownership instead, and the refusal says so rather than blaming a session.
func TestDenyPolicyLevelWithNoSessionBlamesOwnership(t *testing.T) {
req := Request{
Identity: KnownForTest(Identity{UID: 1000}),
Level: AuthzLevelIdentified,
Method: servicePath + "Up",
State: stubState{},
}
info := denialDetail(t, denyPolicyLevel(req, methodPolicies[servicePath+"Up"]))
assert.Equal(t, ErrorReasonNotProfileOwner, info.GetReason())
assert.NotContains(t, info.GetMetadata()[ErrorMetaSummary], "connected")
}
// Somebody else's session is not what stops a caller who never owned the
// profile: they are refused for the profile, and netbird down is neither theirs
// to run nor any help.
func TestDenyPolicyLevelBlamesOwnershipWhileASessionRuns(t *testing.T) {
req := Request{
Identity: KnownForTest(Identity{UID: 1000}),
Level: AuthzLevelIdentified,
Method: servicePath + "Up",
State: stubState{holder: Principal{Kind: KindUID, Value: "4242"}, running: true},
}
info := denialDetail(t, denyPolicyLevel(req, methodPolicies[servicePath+"Up"]))
assert.Equal(t, ErrorReasonNotProfileOwner, info.GetReason())
assert.Contains(t, info.GetMetadata()[ErrorMetaSummary], "belongs to another user")
_, 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)
}