mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-25 05:49:06 +01:00
[Client] Surface readable Authz errors and add profile claim command (#7540)
* [client] Surface error messages for IPC authz in UI (#7553)
This commit is contained in:
@@ -20,8 +20,9 @@ type DaemonState interface {
|
||||
|
||||
// OwnsProfile reports whether id owns the profile a request names. An empty
|
||||
// handle is the active profile, which is what a method that acts on the
|
||||
// live session resolves against.
|
||||
OwnsProfile(id Identity, handle string) bool
|
||||
// live session resolves against. The error says what was wrong with the
|
||||
// handle itself.
|
||||
OwnsProfile(id Identity, handle string) (bool, error)
|
||||
}
|
||||
|
||||
// AuthzGate authorizes every RPC call before its handler run.
|
||||
@@ -82,6 +83,36 @@ func denyLevel(r Request, want AuthzLevel) error {
|
||||
"%s requires %s, caller %s is %s", r.Method, want, r.Identity, r.Level)
|
||||
}
|
||||
|
||||
// denyPolicyLevel refuses a caller at the gate, where the policy is in hand.
|
||||
//
|
||||
// Requiring privilege is the one denial a caller can act on, so it carries the
|
||||
// elevated command rather than a bare refusal. A privileged method that declares
|
||||
// no action keeps the plain message. Rules deny through denyLevel instead: they
|
||||
// cannot reach the policy table without an initialization cycle, and no rule
|
||||
// requires privilege.
|
||||
func denyPolicyLevel(r Request, p MethodPolicy) error {
|
||||
switch p.Level {
|
||||
case AuthzLevelPrivileged:
|
||||
if p.Action != "" {
|
||||
actor, command := RequiredActor(p.Command)
|
||||
return PrivilegeError(PrivilegeSummary(p.Action, actor), command)
|
||||
}
|
||||
|
||||
case AuthzLevelSessionHolder:
|
||||
// resolveLevel stops at profile owner only when a session is running and
|
||||
// somebody else holds it.
|
||||
if r.Level == AuthzLevelProfileOwner {
|
||||
return SessionHeldError(p.Action)
|
||||
}
|
||||
return NotOwnerError(p.Action)
|
||||
|
||||
case AuthzLevelProfileOwner:
|
||||
return NotOwnerError(p.Action)
|
||||
}
|
||||
|
||||
return denyLevel(r, p.Level)
|
||||
}
|
||||
|
||||
// StreamPolicyInterceptor authorizes each streaming RPC before the handler runs.
|
||||
// The request payload is not yet available, so no streaming method may be
|
||||
// target-scoped.
|
||||
@@ -130,9 +161,11 @@ func (g *AuthzGate) authorize(ctx context.Context, method string, msg any) error
|
||||
target = named
|
||||
}
|
||||
|
||||
level, resolveErr := resolveLevel(id, target, st)
|
||||
|
||||
req := Request{
|
||||
Identity: id,
|
||||
Level: resolveLevel(id, target, st),
|
||||
Level: level,
|
||||
Target: target,
|
||||
Method: method,
|
||||
State: st,
|
||||
@@ -140,7 +173,10 @@ func (g *AuthzGate) authorize(ctx context.Context, method string, msg any) error
|
||||
}
|
||||
if req.Level < policy.Level {
|
||||
log.Warnf("ipc authz: DENY %s for %s (%s), requires %s", method, id, req.Level, policy.Level)
|
||||
return denyLevel(req, policy.Level)
|
||||
if resolveErr != nil {
|
||||
return resolveErr
|
||||
}
|
||||
return denyPolicyLevel(req, policy)
|
||||
}
|
||||
for _, rule := range policy.Rules {
|
||||
if err := rule(req); err != nil {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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{ownsErr: 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{ownsErr: 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{ownsErr: 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{ownsErr: 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{ownsErr: 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)
|
||||
|
||||
assert.NoError(t, g.authorize(transportCtx(unprivUser, nil), servicePath+method, nil))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ownership is the gate's answer, never the error's: a resolution that failed is
|
||||
// a no whatever it returned alongside.
|
||||
func TestAuthorizeRefusesWhenResolutionFails(t *testing.T) {
|
||||
g := gateFor(t, stubState{owns: false, ownsErr: gstatus.Error(codes.NotFound, "profile not found")})
|
||||
|
||||
err := g.authorize(transportCtx(unprivUser, nil), servicePath+"SwitchProfile", switchTo("some-profile"))
|
||||
assert.Error(t, err, "an error from the resolution cannot be read as ownership")
|
||||
}
|
||||
|
||||
// A resolution that failed established nothing about the profile, so no level
|
||||
// returned alongside the error may be acted on. This is the invariant the gate
|
||||
// clamps, pinned at the function that has to hold it.
|
||||
func TestResolveLevelNeverRaisesTheLevelOnAFailure(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
|
||||
notFound := gstatus.Error(codes.NotFound, "profile not found")
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
st stubState
|
||||
}{
|
||||
{"a live session it reports as owned", stubState{owns: true, running: true, ownsErr: notFound}},
|
||||
{"an idle daemon it reports as owned", stubState{owns: true, ownsErr: notFound}},
|
||||
{"a daemon-side failure it reports as owned", stubState{owns: true, ownsErr: errors.New("read profile directory")}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
level, _ := resolveLevel(unprivUser, "some-profile", tc.st)
|
||||
assert.Equal(t, AuthzLevelIdentified, level,
|
||||
"a failed resolution cannot confer %s", level)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// AuthzLevel is the authority a caller holds over the daemon's current state.
|
||||
// The values are ordered, and each level can do everything the levles below
|
||||
// it can. A MethodPolicy is satisfied when the caller's level is at least
|
||||
@@ -42,18 +46,44 @@ func (l AuthzLevel) String() string {
|
||||
}
|
||||
}
|
||||
|
||||
func resolveLevel(id Identity, target string, st DaemonState) AuthzLevel {
|
||||
// resolveLevel is the authority the caller holds over the profile the request
|
||||
// names. The second return is what was wrong with the handle, when that is
|
||||
// worth showing the caller instead of a refusal. It never raises the level: a
|
||||
// resolution that failed still denies.
|
||||
func resolveLevel(id Identity, target string, st DaemonState) (AuthzLevel, error) {
|
||||
if !id.Known() {
|
||||
return AuthzLevelNone
|
||||
return AuthzLevelNone, nil
|
||||
}
|
||||
if IsPrivilegedCaller(id) {
|
||||
return AuthzLevelPrivileged
|
||||
return AuthzLevelPrivileged, nil
|
||||
}
|
||||
if !st.OwnsProfile(id, target) {
|
||||
return AuthzLevelIdentified
|
||||
ownsProfile, err := st.OwnsProfile(id, target)
|
||||
if err != nil {
|
||||
return AuthzLevelIdentified, presentableHandleError(target, err)
|
||||
}
|
||||
if !ownsProfile {
|
||||
return AuthzLevelIdentified, nil
|
||||
}
|
||||
if holder, running := st.SessionHolder(); !running || holder.Matches(id) {
|
||||
return AuthzLevelSessionHolder
|
||||
return AuthzLevelSessionHolder, nil
|
||||
}
|
||||
return AuthzLevelProfileOwner
|
||||
return AuthzLevelProfileOwner, nil
|
||||
}
|
||||
|
||||
// presentableHandleError keeps a resolution failure only when the gate can put
|
||||
// it in front of the caller in place of its own refusal. Everything else is
|
||||
// dropped, and the caller gets the refusal their level earned.
|
||||
func presentableHandleError(target string, err error) error {
|
||||
// An empty target is the active profile rather than something the caller
|
||||
// typed, so a failure to resolve it is not theirs to correct.
|
||||
if target == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only a gRPC status reaches the caller as a sentence the CLI and the UI
|
||||
// render.
|
||||
if _, ok := gstatus.FromError(err); !ok {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ package ipcauth
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -211,6 +212,68 @@ func OwnerPrincipalForIdentity(id Identity) string {
|
||||
return UIDPrincipal(id.UID)
|
||||
}
|
||||
|
||||
// ValidatePrincipal checks an owner principal typed by a user, as opposed to one
|
||||
// read back off disk.
|
||||
func ValidatePrincipal(s string) (Principal, error) {
|
||||
p, ok := ParsePrincipal(s)
|
||||
if !ok {
|
||||
return Principal{}, fmt.Errorf("owner %q is not a %s: or %s: principal", s, KindUID, KindSID)
|
||||
}
|
||||
if err := p.Validate(); err != nil {
|
||||
return Principal{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Validate reports whether a principal is one a caller on this platform could
|
||||
// ever hold.
|
||||
func (p Principal) Validate() error {
|
||||
switch p.Kind {
|
||||
case KindUID:
|
||||
if runtime.GOOS == "windows" {
|
||||
return fmt.Errorf("owner %q names a Unix user ID, which no caller on this platform can hold", p.String())
|
||||
}
|
||||
if _, err := strconv.ParseUint(p.Value, 10, 32); err != nil {
|
||||
return fmt.Errorf("owner %q does not carry a user ID", p.String())
|
||||
}
|
||||
case KindSID:
|
||||
if runtime.GOOS != "windows" {
|
||||
return fmt.Errorf("owner %q names a Windows SID, which no caller on this platform can hold", p.String())
|
||||
}
|
||||
if !looksLikeSID(p.Value) {
|
||||
return fmt.Errorf("owner %q does not carry a SID", p.String())
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("owner %q is not a %s: or %s: principal", p.String(), KindUID, KindSID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// looksLikeSID reports whether a value has the shape of a security identifier,
|
||||
// "S-1-<authority>" followed by one to fifteen sub-authorities. A shape check
|
||||
// only, since the account it names need not exist yet.
|
||||
func looksLikeSID(v string) bool {
|
||||
parts := strings.Split(v, "-")
|
||||
if len(parts) < 4 || parts[0] != "S" || parts[1] != "1" {
|
||||
return false
|
||||
}
|
||||
// The identifier authority is a 48-bit field, unlike the 32-bit
|
||||
// sub-authorities that follow it, of which a SID carries at most 15.
|
||||
if _, err := strconv.ParseUint(parts[2], 10, 48); err != nil {
|
||||
return false
|
||||
}
|
||||
subAuthorities := parts[3:]
|
||||
if len(subAuthorities) > 15 {
|
||||
return false
|
||||
}
|
||||
for _, part := range subAuthorities {
|
||||
if _, err := strconv.ParseUint(part, 10, 32); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Matches reports whether a kernel-attested caller satisfies this stored owner
|
||||
// principal.
|
||||
//
|
||||
|
||||
@@ -40,72 +40,90 @@ type MethodPolicy struct {
|
||||
Rules []Rule
|
||||
Audit bool
|
||||
TargetsProfile bool
|
||||
|
||||
// Action and Command turn a privilege denial into guidance the caller can
|
||||
// act on. Action reads as the subject of a sentence ("claiming a profile"),
|
||||
// Command is the same operation run with the privileges it needs. Only read
|
||||
// when Level is AuthzLevelPrivileged, the one denial a caller can fix by
|
||||
// running as somebody else.
|
||||
Action string
|
||||
Command string
|
||||
}
|
||||
|
||||
// methodPolicies is the complete authorization surface. Every RPC on
|
||||
// DaemonService appears here exactly once.
|
||||
var methodPolicies = map[string]MethodPolicy{
|
||||
// Any identified caller.
|
||||
servicePath + "Status": {Level: AuthzLevelIdentified, Rules: []Rule{RequireHolderForFullStatus}},
|
||||
servicePath + "AddProfile": {Level: AuthzLevelIdentified, Audit: true},
|
||||
servicePath + "ListProfiles": {Level: AuthzLevelIdentified},
|
||||
servicePath + "GetActiveProfile": {Level: AuthzLevelIdentified},
|
||||
servicePath + "GetFeatures": {Level: AuthzLevelIdentified},
|
||||
servicePath + "WailsUIReady": {Level: AuthzLevelIdentified},
|
||||
servicePath + "Status": {Level: AuthzLevelIdentified, Rules: []Rule{RequireHolderForFullStatus}, Action: "reading status"},
|
||||
servicePath + "AddProfile": {Level: AuthzLevelIdentified, Audit: true, Action: "adding a profile"},
|
||||
servicePath + "ListProfiles": {Level: AuthzLevelIdentified, Action: "listing profiles"},
|
||||
servicePath + "GetActiveProfile": {Level: AuthzLevelIdentified, Action: "reading the active profile"},
|
||||
servicePath + "GetFeatures": {Level: AuthzLevelIdentified, Action: "reading feature flags"},
|
||||
servicePath + "WailsUIReady": {Level: AuthzLevelIdentified, Action: "starting the UI"},
|
||||
// If a higher level is used, the deny logs message is spammed on frequent UI polls.
|
||||
servicePath + "RegisterUILog": {Level: AuthzLevelIdentified, Action: "registering the UI log"},
|
||||
|
||||
// Owner of the profile the request names.
|
||||
servicePath + "GetConfig": {Level: AuthzLevelProfileOwner, TargetsProfile: true, Audit: true},
|
||||
servicePath + "SetConfig": {Level: AuthzLevelProfileOwner, TargetsProfile: true, Audit: true},
|
||||
servicePath + "Login": {Level: AuthzLevelSessionHolder, TargetsProfile: true, Audit: true},
|
||||
servicePath + "Logout": {Level: AuthzLevelProfileOwner, TargetsProfile: true, Audit: true},
|
||||
servicePath + "RenameProfile": {Level: AuthzLevelProfileOwner, TargetsProfile: true},
|
||||
servicePath + "RemoveProfile": {Level: AuthzLevelProfileOwner, TargetsProfile: true, Audit: true},
|
||||
servicePath + "SwitchProfile": {Level: AuthzLevelSessionHolder, TargetsProfile: true, Audit: true},
|
||||
servicePath + "GetConfig": {Level: AuthzLevelProfileOwner, TargetsProfile: true, Audit: true, Action: "reading the profile configuration"},
|
||||
servicePath + "SetConfig": {Level: AuthzLevelProfileOwner, TargetsProfile: true, Audit: true, Action: "changing the profile configuration"},
|
||||
servicePath + "Login": {Level: AuthzLevelSessionHolder, TargetsProfile: true, Audit: true, Action: "logging in"},
|
||||
servicePath + "Logout": {Level: AuthzLevelProfileOwner, TargetsProfile: true, Audit: true, Action: "logging out"},
|
||||
servicePath + "RenameProfile": {Level: AuthzLevelProfileOwner, TargetsProfile: true, Action: "renaming a profile"},
|
||||
servicePath + "RemoveProfile": {Level: AuthzLevelProfileOwner, TargetsProfile: true, Audit: true, Action: "removing a profile"},
|
||||
servicePath + "SwitchProfile": {Level: AuthzLevelSessionHolder, TargetsProfile: true, Audit: true, Action: "switching profile"},
|
||||
|
||||
// Owner of the active profile, which is what an empty target resolves to.
|
||||
servicePath + "GetLogLevel": {Level: AuthzLevelProfileOwner},
|
||||
servicePath + "ListStates": {Level: AuthzLevelProfileOwner},
|
||||
servicePath + "GetInstallerResult": {Level: AuthzLevelProfileOwner},
|
||||
servicePath + "WaitSSOLogin": {Level: AuthzLevelProfileOwner, Audit: true, Action: "waiting for the login to finish"},
|
||||
servicePath + "WaitJWTToken": {Level: AuthzLevelProfileOwner, Audit: true, Action: "waiting for the token"},
|
||||
servicePath + "WaitExtendAuthSession": {Level: AuthzLevelProfileOwner, Action: "extending the session"},
|
||||
|
||||
// Session holder: the live engine and everything daemon-wide. A pending
|
||||
// authentication flow belongs to the profile it was started for, so each
|
||||
// Wait sits at the level of the RPC that starts it.
|
||||
servicePath + "Up": {Level: AuthzLevelSessionHolder, TargetsProfile: true, Audit: true},
|
||||
servicePath + "Down": {Level: AuthzLevelSessionHolder, Audit: true},
|
||||
servicePath + "SubscribeStatus": {Level: AuthzLevelSessionHolder},
|
||||
servicePath + "SubscribeEvents": {Level: AuthzLevelSessionHolder},
|
||||
servicePath + "GetEvents": {Level: AuthzLevelSessionHolder},
|
||||
servicePath + "ListNetworks": {Level: AuthzLevelSessionHolder},
|
||||
servicePath + "SelectNetworks": {Level: AuthzLevelSessionHolder, Audit: true},
|
||||
servicePath + "DeselectNetworks": {Level: AuthzLevelSessionHolder, Audit: true},
|
||||
servicePath + "ForwardingRules": {Level: AuthzLevelSessionHolder},
|
||||
servicePath + "ExposeService": {Level: AuthzLevelSessionHolder, Audit: true},
|
||||
servicePath + "GetPeerSSHHostKey": {Level: AuthzLevelSessionHolder},
|
||||
servicePath + "RequestJWTAuth": {Level: AuthzLevelSessionHolder, Audit: true},
|
||||
servicePath + "WaitJWTToken": {Level: AuthzLevelSessionHolder, Audit: true},
|
||||
servicePath + "RequestExtendAuthSession": {Level: AuthzLevelSessionHolder},
|
||||
servicePath + "WaitExtendAuthSession": {Level: AuthzLevelSessionHolder},
|
||||
servicePath + "WaitSSOLogin": {Level: AuthzLevelSessionHolder, Audit: true},
|
||||
servicePath + "DismissSessionWarning": {Level: AuthzLevelSessionHolder},
|
||||
servicePath + "DebugBundle": {Level: AuthzLevelSessionHolder, Audit: true},
|
||||
servicePath + "SetLogLevel": {Level: AuthzLevelSessionHolder},
|
||||
servicePath + "SetSyncResponsePersistence": {Level: AuthzLevelSessionHolder},
|
||||
servicePath + "StartCapture": {Level: AuthzLevelSessionHolder, Audit: true},
|
||||
servicePath + "StartBundleCapture": {Level: AuthzLevelSessionHolder, Audit: true},
|
||||
servicePath + "StopBundleCapture": {Level: AuthzLevelSessionHolder},
|
||||
servicePath + "StartCPUProfile": {Level: AuthzLevelSessionHolder},
|
||||
servicePath + "StopCPUProfile": {Level: AuthzLevelSessionHolder},
|
||||
servicePath + "CleanState": {Level: AuthzLevelSessionHolder, Audit: true},
|
||||
servicePath + "DeleteState": {Level: AuthzLevelSessionHolder, Audit: true},
|
||||
servicePath + "TracePacket": {Level: AuthzLevelSessionHolder},
|
||||
servicePath + "RegisterUILog": {Level: AuthzLevelSessionHolder},
|
||||
servicePath + "TriggerUpdate": {Level: AuthzLevelSessionHolder, Audit: true},
|
||||
// Owner of some profile
|
||||
servicePath + "GetLogLevel": {Level: AuthzLevelProfileOwner, Action: "reading the log level"},
|
||||
servicePath + "ListStates": {Level: AuthzLevelProfileOwner, Action: "listing stored state"},
|
||||
servicePath + "GetInstallerResult": {Level: AuthzLevelProfileOwner, Action: "reading the installer result"},
|
||||
|
||||
// Session holder: the live engine and everything daemon-wide.
|
||||
servicePath + "Up": {Level: AuthzLevelSessionHolder, TargetsProfile: true, Audit: true, Action: "connecting"},
|
||||
servicePath + "Down": {Level: AuthzLevelSessionHolder, Audit: true, Action: "disconnecting"},
|
||||
servicePath + "SubscribeStatus": {Level: AuthzLevelSessionHolder, Action: "following status"},
|
||||
servicePath + "SubscribeEvents": {Level: AuthzLevelSessionHolder, Action: "following events"},
|
||||
servicePath + "GetEvents": {Level: AuthzLevelSessionHolder, Action: "reading events"},
|
||||
servicePath + "ListNetworks": {Level: AuthzLevelSessionHolder, Action: "listing networks"},
|
||||
servicePath + "SelectNetworks": {Level: AuthzLevelSessionHolder, Audit: true, Action: "selecting networks"},
|
||||
servicePath + "DeselectNetworks": {Level: AuthzLevelSessionHolder, Audit: true, Action: "deselecting networks"},
|
||||
servicePath + "ForwardingRules": {Level: AuthzLevelSessionHolder, Action: "listing forwarding rules"},
|
||||
servicePath + "ExposeService": {Level: AuthzLevelSessionHolder, Audit: true, Action: "exposing a service"},
|
||||
servicePath + "GetPeerSSHHostKey": {Level: AuthzLevelSessionHolder, Action: "reading a peer SSH host key"},
|
||||
servicePath + "RequestJWTAuth": {Level: AuthzLevelSessionHolder, Audit: true, Action: "starting authentication"},
|
||||
servicePath + "RequestExtendAuthSession": {Level: AuthzLevelSessionHolder, Action: "extending the session"},
|
||||
servicePath + "DismissSessionWarning": {Level: AuthzLevelSessionHolder, Action: "dismissing the session warning"},
|
||||
servicePath + "DebugBundle": {Level: AuthzLevelSessionHolder, Audit: true, Action: "creating a debug bundle"},
|
||||
servicePath + "SetLogLevel": {Level: AuthzLevelSessionHolder, Action: "changing the log level"},
|
||||
servicePath + "SetSyncResponsePersistence": {Level: AuthzLevelSessionHolder, Action: "changing sync persistence"},
|
||||
servicePath + "StartCapture": {Level: AuthzLevelSessionHolder, Audit: true, Action: "starting a packet capture"},
|
||||
servicePath + "StartBundleCapture": {Level: AuthzLevelSessionHolder, Audit: true, Action: "starting a bundle capture"},
|
||||
servicePath + "StopBundleCapture": {Level: AuthzLevelSessionHolder, Action: "stopping a bundle capture"},
|
||||
servicePath + "StartCPUProfile": {Level: AuthzLevelSessionHolder, Action: "starting a CPU profile"},
|
||||
servicePath + "StopCPUProfile": {Level: AuthzLevelSessionHolder, Action: "stopping a CPU profile"},
|
||||
servicePath + "CleanState": {Level: AuthzLevelSessionHolder, Audit: true, Action: "clearing stored state"},
|
||||
servicePath + "DeleteState": {Level: AuthzLevelSessionHolder, Audit: true, Action: "deleting stored state"},
|
||||
servicePath + "TracePacket": {Level: AuthzLevelSessionHolder, Action: "tracing a packet"},
|
||||
servicePath + "TriggerUpdate": {Level: AuthzLevelSessionHolder, Audit: true, Action: "starting an update"},
|
||||
|
||||
// Root or administrator only. Claiming names an arbitrary principal, so the
|
||||
// caller asserts who a profile belongs to. Ownership does not enter it.
|
||||
servicePath + "ClaimProfile": {
|
||||
Level: AuthzLevelPrivileged,
|
||||
TargetsProfile: true,
|
||||
Audit: true,
|
||||
Action: "claiming a profile",
|
||||
Command: ElevatedCommand("netbird profile claim <profile>"),
|
||||
},
|
||||
}
|
||||
|
||||
func methodPolicyFor(method string) MethodPolicy {
|
||||
if p, ok := methodPolicies[method]; ok {
|
||||
return p
|
||||
}
|
||||
// TODO: reconsider falling back to Privileged rather than direct DENY.
|
||||
return MethodPolicy{Level: AuthzLevelPrivileged, Audit: true}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ValidatePrincipal gates what a user may write. ParsePrincipal gates what is
|
||||
// read back, and stays lenient so an existing config is never reinterpreted.
|
||||
func TestValidatePrincipal(t *testing.T) {
|
||||
unix := runtime.GOOS != "windows"
|
||||
|
||||
for _, tc := range []struct {
|
||||
in string
|
||||
valid bool
|
||||
}{
|
||||
{"uid:0", unix},
|
||||
{"uid:1000", unix},
|
||||
{"uid:4294967295", unix},
|
||||
{"uid:4294967296", false},
|
||||
{"uid:abc", false},
|
||||
{"uid:-1", false},
|
||||
{"uid:1000:extra", false},
|
||||
{"sid:S-1-5-21-1-2-3-1001", !unix},
|
||||
{"sid:S-1-5-18", !unix},
|
||||
{"sid:S-1", false},
|
||||
{"sid:S-1-5-", false},
|
||||
{"sid:hello", false},
|
||||
{"sid:X-1-5-18", false},
|
||||
{"bogus:1000", false},
|
||||
{"uid:", false},
|
||||
{"1000", false},
|
||||
{"", false},
|
||||
} {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
got, err := ValidatePrincipal(tc.in)
|
||||
if !tc.valid {
|
||||
require.Error(t, err, "%q must not be accepted as an owner", tc.in)
|
||||
assert.Equal(t, Principal{}, got)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.in, got.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The read path must keep accepting what it always did, whatever the write path
|
||||
// now refuses.
|
||||
func TestParsePrincipalStaysLenient(t *testing.T) {
|
||||
for _, in := range []string{"uid:abc", "uid:-1", "sid:hello", "uid:1000:extra"} {
|
||||
t.Run(in, func(t *testing.T) {
|
||||
_, ok := ParsePrincipal(in)
|
||||
assert.True(t, ok, "ParsePrincipal must still read %q, a stored config may carry it", in)
|
||||
|
||||
_, err := ValidatePrincipal(in)
|
||||
assert.Error(t, err, "but it must not be accepted as new input")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ValidatePrincipal cannot reach the unknown kinds, since ParsePrincipal refuses
|
||||
// them first. A Principal built in code can carry one, and a privileged writer
|
||||
// validates the value it was handed rather than a string it parsed.
|
||||
func TestPrincipalValidateRejectsKindsParsingNeverProduces(t *testing.T) {
|
||||
for _, p := range []Principal{
|
||||
{},
|
||||
{Kind: "bogus", Value: "1000"},
|
||||
{Kind: KindUID},
|
||||
{Kind: KindSID},
|
||||
} {
|
||||
t.Run(p.String(), func(t *testing.T) {
|
||||
assert.Error(t, p.Validate(), "%v must not be accepted as an owner", p)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
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
|
||||
owns bool
|
||||
ownsErr error
|
||||
}
|
||||
|
||||
func (s stubState) SessionHolder() (Principal, bool) { return s.holder, s.running }
|
||||
func (s stubState) OwnsProfile(Identity, string) (bool, error) { return s.owns, s.ownsErr }
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// Fields of the ErrorInfo detail the daemon attaches to a PermissionDenied it
|
||||
@@ -19,6 +26,17 @@ const (
|
||||
// ErrorMetaCommand is the command that performs the same operation with the
|
||||
// privileges it needs, ready to copy and run.
|
||||
ErrorMetaCommand = "command"
|
||||
|
||||
// ErrorReasonSessionHeld identifies a refusal caused by another user's live
|
||||
// connection. Nothing the caller can run satisfies it, since the session is
|
||||
// not theirs to end, so the detail carries no command.
|
||||
ErrorReasonSessionHeld = "SESSION_HELD"
|
||||
|
||||
// ErrorReasonNotProfileOwner identifies a refusal caused by the profile
|
||||
// belonging to another account. It carries no command either: privilege is
|
||||
// not what the method asked for, so telling the caller to elevate would send
|
||||
// them the wrong way.
|
||||
ErrorReasonNotProfileOwner = "NOT_PROFILE_OWNER"
|
||||
)
|
||||
|
||||
// The identity of the process evaluating callers, captured once because it cannot
|
||||
@@ -144,3 +162,131 @@ func ElevatedCommand(command string) string {
|
||||
func UpCommand(flags string) string {
|
||||
return ElevatedCommand("netbird down") + "; " + ElevatedCommand("netbird up "+flags)
|
||||
}
|
||||
|
||||
// Denial is a refusal the daemon explained, read back off the error it raised.
|
||||
// The reason identifies which refusal it was, so a consumer can present each one
|
||||
// in its own way without matching on message text.
|
||||
type Denial struct {
|
||||
Reason string
|
||||
Summary string
|
||||
Command string
|
||||
}
|
||||
|
||||
// DenialFrom returns the refusal a daemon error explains, if it explains one.
|
||||
func DenialFrom(err error) (Denial, bool) {
|
||||
if err == nil {
|
||||
return Denial{}, false
|
||||
}
|
||||
|
||||
st := status.Convert(err)
|
||||
for _, detail := range st.Details() {
|
||||
info, ok := detail.(*errdetails.ErrorInfo)
|
||||
if !ok || info.GetDomain() != ErrorDomain {
|
||||
continue
|
||||
}
|
||||
|
||||
summary := info.GetMetadata()[ErrorMetaSummary]
|
||||
if summary == "" {
|
||||
// A detail with no summary still refused something. The status
|
||||
// message carries the same sentence, and showing it beats showing
|
||||
// a consumer nothing.
|
||||
summary = strings.TrimSpace(st.Message())
|
||||
}
|
||||
|
||||
return Denial{
|
||||
Reason: info.GetReason(),
|
||||
Summary: summary,
|
||||
Command: info.GetMetadata()[ErrorMetaCommand],
|
||||
}, true
|
||||
}
|
||||
|
||||
return Denial{}, false
|
||||
}
|
||||
|
||||
// PrivilegeError builds the PermissionDenied carrying summary and command.
|
||||
func PrivilegeError(summary, command string) error {
|
||||
return denialError(ErrorReasonPrivilegeRequired, summary, command)
|
||||
}
|
||||
|
||||
// SessionHeldError refuses an operation because another user has the machine
|
||||
// connected.
|
||||
func SessionHeldError(action string) error {
|
||||
return denialError(ErrorReasonSessionHeld, sessionHeldSummary(action), ElevatedCommand("netbird down"))
|
||||
}
|
||||
|
||||
// NotOwnerError refuses an operation because the profile it addresses belongs to
|
||||
// somebody else.
|
||||
func NotOwnerError(action string) error {
|
||||
return denialError(ErrorReasonNotProfileOwner, notOwnerSummary(action), "")
|
||||
}
|
||||
|
||||
// sessionHeldSummary says whose the connection is and why that settles it.
|
||||
func sessionHeldSummary(action string) string {
|
||||
return refusedSubject(action) + " refused while another user has this machine connected. " +
|
||||
"The active profile and the connection on it belong to the user who brought it up, " +
|
||||
"so the connection has to come down before anyone else can use the machine."
|
||||
}
|
||||
|
||||
// notOwnerSummary says who the profile belongs to and why that settles it.
|
||||
func notOwnerSummary(action string) string {
|
||||
return refusedSubject(action) + " refused because the profile it addresses belongs to another user. " +
|
||||
"A profile and the configuration on it stay with the account that created or claimed it, " +
|
||||
"so use one of your own or ask an administrator to hand this one over."
|
||||
}
|
||||
|
||||
// refusedSubject opens a refusal with what was refused, falling back to the
|
||||
// command itself for a method that names no action.
|
||||
func refusedSubject(action string) string {
|
||||
if action == "" {
|
||||
return "This command is"
|
||||
}
|
||||
return capitalize(action) + " is"
|
||||
}
|
||||
|
||||
// denialError builds a PermissionDenied carrying a summary a client can render,
|
||||
// and a command when there is one to give.
|
||||
func denialError(reason, summary, command string) error {
|
||||
message := summary
|
||||
metadata := map[string]string{ErrorMetaSummary: summary}
|
||||
if command != "" {
|
||||
message = fmt.Sprintf("%s\n\n%s", summary, command)
|
||||
metadata[ErrorMetaCommand] = command
|
||||
}
|
||||
|
||||
st := status.New(codes.PermissionDenied, message)
|
||||
detailed, err := st.WithDetails(&errdetails.ErrorInfo{
|
||||
Reason: reason,
|
||||
Domain: ErrorDomain,
|
||||
Metadata: metadata,
|
||||
})
|
||||
if err != nil {
|
||||
log.Debugf("attach %s error detail: %v", reason, err)
|
||||
return st.Err()
|
||||
}
|
||||
return detailed.Err()
|
||||
}
|
||||
|
||||
// RequiredActor names who may perform the operation and adjusts the command to
|
||||
// match. A daemon that is not itself privileged delegates to its own identity, so
|
||||
// telling that host's user to become root is wrong twice over: root is not what the
|
||||
// daemon checks for, and a rootless container has neither root nor sudo.
|
||||
func RequiredActor(command string) (string, string) {
|
||||
self, delegates := SelfDelegatesTo()
|
||||
if !delegates {
|
||||
return PrivilegedActor(), command
|
||||
}
|
||||
return fmt.Sprintf("the user the daemon runs as (%s)", self), strings.ReplaceAll(command, "sudo ", "")
|
||||
}
|
||||
|
||||
// PrivilegeSummary states what is refused and what it needs, in one sentence
|
||||
// that reads the same in a dialog and in a terminal.
|
||||
func PrivilegeSummary(action, actor string) string {
|
||||
return fmt.Sprintf("%s requires %s.", capitalize(action), actor)
|
||||
}
|
||||
|
||||
func capitalize(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
return strings.ToUpper(s[:1]) + s[1:]
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ func (s *ServiceManager) stampActiveUserDir(profiles []Profile, active *ActivePr
|
||||
return fmt.Errorf("resolve %q: %w", active.Username, err)
|
||||
}
|
||||
|
||||
principal, ok := principalForUser(u)
|
||||
principal, ok := PrincipalForUser(u)
|
||||
if !ok {
|
||||
return fmt.Errorf("account %q has no usable id %q", active.Username, u.Uid)
|
||||
}
|
||||
@@ -231,10 +231,10 @@ func takesActiveAccountOwner(p *Profile, dir string) bool {
|
||||
return p.ID == defaultProfileName && !defaultProfileClaimDisabled()
|
||||
}
|
||||
|
||||
// principalForUser turns a resolved account into an owner principal. os/user
|
||||
// PrincipalForUser turns a resolved account into an owner principal. os/user
|
||||
// reports a numeric id on Unix and a SID on Windows, which is what tells the
|
||||
// two kinds apart without a build tag.
|
||||
func principalForUser(u *user.User) (string, bool) {
|
||||
func PrincipalForUser(u *user.User) (string, bool) {
|
||||
if uid, err := strconv.ParseUint(u.Uid, 10, 32); err == nil {
|
||||
return ipcauth.UIDPrincipal(uint32(uid)), true
|
||||
}
|
||||
|
||||
@@ -959,6 +959,28 @@ func readProfileOwners(path string) ([]ipcauth.Principal, error) {
|
||||
return []ipcauth.Principal{principal}, nil
|
||||
}
|
||||
|
||||
// ClaimProfile records a principal as a profile's sole owner, replacing whoever
|
||||
// is recorded now.
|
||||
//
|
||||
// The principal comes from an administrator rather than from the kernel, so it
|
||||
// is never turned into an Identity on the way and it is validated here.
|
||||
func (s *ServiceManager) ClaimProfile(p *Profile, principal ipcauth.Principal) error {
|
||||
if err := principal.Validate(); err != nil {
|
||||
return fmt.Errorf("claim %s: %w", p.ID, err)
|
||||
}
|
||||
|
||||
path, err := p.FilePath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("profile path: %w", err)
|
||||
}
|
||||
if err := stampPrincipal(path, principal.String()); err != nil {
|
||||
return fmt.Errorf("claim %s for %s: %w", p.ID, principal, err)
|
||||
}
|
||||
p.Owners = []ipcauth.Principal{principal}
|
||||
log.Infof("claimed profile %s for %s", path, principal)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StampOwner records a caller as a profile's owner, replacing whoever is
|
||||
// recorded now.
|
||||
func StampOwner(path string, owner ipcauth.Identity) error {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
@@ -681,6 +682,115 @@ func TestActiveProfilePath_RefusesToGuessBetweenNamesakes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// claimIdentity names a caller the platform could actually hold: a uid names
|
||||
// nobody on Windows, where a caller is a SID. The account itself need not
|
||||
// exist, since a claim never looks one up.
|
||||
func claimIdentity(n uint32) ipcauth.Identity {
|
||||
if runtime.GOOS == "windows" {
|
||||
return ipcauth.KnownForTest(ipcauth.Identity{SID: fmt.Sprintf("S-1-5-21-1-2-3-%d", n)})
|
||||
}
|
||||
return ipcauth.KnownForTest(ipcauth.Identity{UID: n})
|
||||
}
|
||||
|
||||
// claimPrincipal is the owner principal that claimIdentity's caller matches.
|
||||
func claimPrincipal(t *testing.T, n uint32) ipcauth.Principal {
|
||||
t.Helper()
|
||||
p, err := ipcauth.ValidatePrincipal(ipcauth.OwnerPrincipalForIdentity(claimIdentity(n)))
|
||||
require.NoError(t, err)
|
||||
return p
|
||||
}
|
||||
|
||||
func TestClaimProfile_RecordsAnArbitraryPrincipal(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, _ ipcauth.Identity) {
|
||||
p, err := sm.AddProfile("work", nil)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, readOwners(t, p.Path))
|
||||
|
||||
owner := claimPrincipal(t, 4242)
|
||||
require.NoError(t, sm.ClaimProfile(p, owner))
|
||||
assert.Equal(t, []string{owner.String()}, readOwners(t, p.Path))
|
||||
|
||||
alice := claimIdentity(4242)
|
||||
bob := claimIdentity(5252)
|
||||
assert.True(t, p.AccessibleBy(alice), "the claim is reflected in memory, not only on disk")
|
||||
assert.False(t, p.AccessibleBy(bob))
|
||||
|
||||
got, err := sm.ListProfiles(alice)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, profileIDs(got), p.ID.String())
|
||||
|
||||
got, err = sm.ListProfiles(bob)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, profileIDs(got), p.ID.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestClaimProfile_ReplacesTheRecordedOwner(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, _ ipcauth.Identity) {
|
||||
p, err := sm.AddProfile("work", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, sm.ClaimProfile(p, claimPrincipal(t, 4242)))
|
||||
require.NoError(t, sm.ClaimProfile(p, claimPrincipal(t, 5252)))
|
||||
|
||||
assert.Equal(t, []string{claimPrincipal(t, 5252).String()}, readOwners(t, p.Path),
|
||||
"handing a profile over replaces the owner rather than adding one")
|
||||
|
||||
old := claimIdentity(4242)
|
||||
assert.False(t, p.AccessibleBy(old), "the previous owner loses access")
|
||||
})
|
||||
}
|
||||
|
||||
func TestClaimProfile_ClaimsTheDefaultProfile(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, _ ipcauth.Identity) {
|
||||
all, err := sm.loadAllProfiles()
|
||||
require.NoError(t, err)
|
||||
var def *Profile
|
||||
for i := range all {
|
||||
if all[i].ID == defaultProfileName {
|
||||
def = &all[i]
|
||||
}
|
||||
}
|
||||
require.NotNil(t, def)
|
||||
|
||||
owner := claimPrincipal(t, 4242)
|
||||
require.NoError(t, sm.ClaimProfile(def, owner))
|
||||
assert.Equal(t, []string{owner.String()}, readOwners(t, DefaultConfigPath),
|
||||
"the headless case this exists for: no console user, owner recorded by hand")
|
||||
|
||||
alice := claimIdentity(4242)
|
||||
got, err := sm.ListProfiles(alice)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, profileIDs(got), defaultProfileName)
|
||||
})
|
||||
}
|
||||
|
||||
// ClaimProfile writes the value the ownership check reads back, so an owner no
|
||||
// caller could ever match is refused here rather than in whichever caller
|
||||
// happens to reach it.
|
||||
func TestClaimProfile_RefusesAnOwnerNobodyCanMatch(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
principal ipcauth.Principal
|
||||
}{
|
||||
{"no kind", ipcauth.Principal{}},
|
||||
{"unknown kind", ipcauth.Principal{Kind: "bogus", Value: "1000"}},
|
||||
{"uid that is not a number", ipcauth.Principal{Kind: ipcauth.KindUID, Value: "abc"}},
|
||||
{"sid that is not a sid", ipcauth.Principal{Kind: ipcauth.KindSID, Value: "any"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, _ ipcauth.Identity) {
|
||||
p, err := sm.AddProfile("work", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Error(t, sm.ClaimProfile(p, tc.principal))
|
||||
assert.Empty(t, readOwners(t, p.Path), "a refused claim records nothing")
|
||||
assert.Empty(t, p.Owners, "and leaves the loaded profile as it was")
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProfiles_ClaimKeepsFieldsThisVersionDoesNotModel(t *testing.T) {
|
||||
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
|
||||
// What a client newer than this one leaves behind: a key Config has no
|
||||
|
||||
Reference in New Issue
Block a user