[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:
Theodor Midtlien
2026-09-17 14:36:56 +02:00
committed by GitHub
parent 801557e21b
commit cec9ee6699
69 changed files with 2912 additions and 681 deletions
+40 -4
View File
@@ -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 {
+139
View File
@@ -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)
})
}
}
+37 -7
View File
@@ -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
}
+63
View File
@@ -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.
//
+68 -50
View File
@@ -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)
}
+146
View File
@@ -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:]
}
+3 -3
View File
@@ -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
}
+22
View File
@@ -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