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

196 lines
7.8 KiB
Go

package ipcauth
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/proto"
)
// gateFor builds a gate over a stub daemon, with this process pinned to root so
// the unprivileged fixture caller is not mistaken for the daemon's own identity.
func gateFor(t *testing.T, st DaemonState) *AuthzGate {
t.Helper()
asDaemon(t, root)
g := NewAuthzGate()
g.SetState(st)
return g
}
func switchTo(handle string) *proto.SwitchProfileRequest {
if handle == "" {
return &proto.SwitchProfileRequest{}
}
return &proto.SwitchProfileRequest{ProfileName: &handle}
}
// A handle that names no profile the caller can address is answered with what
// is wrong with the handle. The refusal about ownership would claim the profile
// exists and belongs to somebody, which a mistyped handle does not.
func TestAuthorizeSurfacesWhatIsWrongWithTheHandle(t *testing.T) {
notFound := gstatus.Errorf(codes.NotFound, "profile %q not found", "asdfasdfasdf")
g := gateFor(t, stubState{targetErr: notFound})
_, err := g.authorize(transportCtx(unprivUser, nil), servicePath+"SwitchProfile", switchTo("asdfasdfasdf"))
require.Error(t, err)
st := gstatus.Convert(err)
assert.Equal(t, codes.NotFound, st.Code(), "a handle that resolves to nothing is not a permission problem")
assert.Contains(t, st.Message(), `profile "asdfasdfasdf" not found`)
_, isDenial := DenialFrom(err)
assert.False(t, isDenial, "the ownership refusal took over an error about the handle")
}
// The candidate list an ambiguous handle produces is the whole value of that
// error, and the CLI reformats it into a hint. It has to reach the CLI.
func TestAuthorizeSurfacesAnAmbiguousHandle(t *testing.T) {
ambiguous := gstatus.Errorf(codes.InvalidArgument, "handle %q matches 2 profiles", "ab")
g := gateFor(t, stubState{targetErr: ambiguous})
_, err := g.authorize(transportCtx(unprivUser, nil), servicePath+"SwitchProfile", switchTo("ab"))
require.Error(t, err)
assert.Equal(t, codes.InvalidArgument, gstatus.Convert(err).Code())
}
// A method that names no profile acts on the active one, which the caller never
// typed. Reporting it as not found would quote back an ID they never gave, so
// the refusal stays about who the profile belongs to.
func TestAuthorizeBlamesOwnershipForTheActiveProfile(t *testing.T) {
notFound := gstatus.Errorf(codes.NotFound, "profile %q not found", "active-profile-id")
g := gateFor(t, stubState{targetErr: notFound})
_, err := g.authorize(transportCtx(unprivUser, nil), servicePath+"SwitchProfile", switchTo(""))
require.Error(t, err)
denial, ok := DenialFrom(err)
require.True(t, ok, "an unnamed profile is refused on ownership, not on the handle")
assert.Equal(t, ErrorReasonNotProfileOwner, denial.Reason)
assert.NotContains(t, denial.Summary, "active-profile-id", "the caller never named a profile")
}
// A daemon-side failure is not something the caller can correct, and putting it
// on the wire would describe the daemon rather than the request.
func TestAuthorizeKeepsADaemonFailureOffTheWire(t *testing.T) {
g := gateFor(t, stubState{targetErr: errors.New("read profile directory: permission denied")})
_, err := g.authorize(transportCtx(unprivUser, nil), servicePath+"SwitchProfile", switchTo("some-profile"))
require.Error(t, err)
denial, ok := DenialFrom(err)
require.True(t, ok, "a daemon-side failure must still refuse in the gate's own words")
assert.Equal(t, ErrorReasonNotProfileOwner, denial.Reason)
assert.NotContains(t, denial.Summary, "permission denied")
}
// Resolving the active profile happens on every call, including the ones any
// identified caller may make. A failure there must not take those down.
func TestAuthorizeAllowsIdentifiedMethodsDespiteAResolveFailure(t *testing.T) {
notFound := gstatus.Errorf(codes.NotFound, "profile %q not found", "active-profile-id")
g := gateFor(t, stubState{targetErr: notFound})
for _, method := range []string{"ListProfiles", "AddProfile", "GetActiveProfile", "GetFeatures"} {
t.Run(method, func(t *testing.T) {
require.Equal(t, AuthzLevelIdentified, methodPolicies[servicePath+method].Level,
"fixture is wrong: %s is no longer open to any identified caller", method)
_, err := g.authorize(transportCtx(unprivUser, nil), servicePath+method, nil)
assert.NoError(t, err)
})
}
}
// A resolution that failed established nothing about the profile, so ownership
// reported alongside the error may not be acted on. A state that answers both
// at once is exactly what this refuses to trust.
func TestResolveLevelNeverRaisesTheLevelOnAFailure(t *testing.T) {
notFound := gstatus.Error(codes.NotFound, "profile not found")
owned := Target{Path: "/profiles/some-profile.json", Owned: true}
someoneElse := Principal{Kind: KindUID, Value: "4242"}
for _, tc := range []struct {
name string
method string
msg any
st stubState
}{
{
// A session somebody else holds stops at profile owner, so this
// needs a method profile owner is enough for.
name: "a live session it reports as owned", method: "GetConfig",
msg: &proto.GetConfigRequest{ProfileName: "some-profile"},
st: stubState{target: owned, running: true, holder: someoneElse, targetErr: notFound},
},
{
name: "an idle daemon it reports as owned", method: "SwitchProfile",
msg: switchTo("some-profile"),
st: stubState{target: owned, targetErr: notFound},
},
{
name: "a daemon-side failure it reports as owned", method: "SwitchProfile",
msg: switchTo("some-profile"),
st: stubState{target: owned, targetErr: errors.New("read profile directory")},
},
} {
t.Run(tc.name, func(t *testing.T) {
g := gateFor(t, tc.st)
_, err := g.authorize(transportCtx(unprivUser, nil), servicePath+tc.method, tc.msg)
assert.Error(t, err, "a failed resolution conferred a level it had no business conferring")
})
}
}
// The profile the gate resolved is what the handler acts on, so it has to reach
// the handler. Resolving the handle a second time downstream is what this
// exists to make unnecessary.
func TestAuthorizeCarriesTheResolvedTargetToTheHandler(t *testing.T) {
g := gateFor(t, stubState{target: Target{Path: "/profiles/abcd1111.json", Owned: true}})
ctx, err := g.authorize(transportCtx(unprivUser, nil), servicePath+"SwitchProfile", switchTo("work"))
require.NoError(t, err)
got, ok := TargetFromContext(ctx)
require.True(t, ok, "the handler has no profile to act on")
assert.Equal(t, "/profiles/abcd1111.json", got,
"the handler would act on a different profile than the one authorized")
}
// A privileged caller skips the ownership question but still needs the profile
// their handle named, or every target-scoped RPC breaks under sudo.
func TestAuthorizeCarriesTheTargetForAPrivilegedCaller(t *testing.T) {
g := gateFor(t, stubState{target: Target{Path: "/profiles/abcd1111.json", Owned: true}})
ctx, err := g.authorize(transportCtx(root, nil), servicePath+"ClaimProfile",
&proto.ClaimProfileRequest{Handle: "work"})
require.NoError(t, err)
got, ok := TargetFromContext(ctx)
require.True(t, ok, "root resolved nothing to act on")
assert.Equal(t, "/profiles/abcd1111.json", got)
}
func TestAuthorizeBlamesAHeldSession(t *testing.T) {
g := gateFor(t, stubState{
target: Target{Path: "/profiles/mine.json", Owned: true},
running: true,
holder: Principal{Kind: KindUID, Value: "4242"},
})
_, err := g.authorize(transportCtx(unprivUser, nil), servicePath+"Up", &proto.UpRequest{})
require.Error(t, err)
denial, ok := DenialFrom(err)
require.True(t, ok, "a caller kept out by somebody else's session got no explanation")
assert.Equal(t, ErrorReasonSessionHeld, denial.Reason,
"the caller owns the profile, so the refusal is about the connection, not ownership")
assert.Contains(t, denial.Command, "netbird down",
"taking the connection down is the remedy this refusal points at")
}