From cb9d2ee3bba7af88b4a3b20522e4225937b39889 Mon Sep 17 00:00:00 2001 From: "Theodor S. Midtlien" Date: Fri, 18 Sep 2026 11:28:16 +0200 Subject: [PATCH] Small refactor of Authz --- client/internal/ipcauth/authz_gate.go | 118 ++++++++++--------------- client/internal/ipcauth/authz_level.go | 73 +++++++-------- client/internal/ipcauth/methods.go | 23 +++++ 3 files changed, 109 insertions(+), 105 deletions(-) diff --git a/client/internal/ipcauth/authz_gate.go b/client/internal/ipcauth/authz_gate.go index b94f6081e..f50671fde 100644 --- a/client/internal/ipcauth/authz_gate.go +++ b/client/internal/ipcauth/authz_gate.go @@ -4,11 +4,10 @@ import ( "context" "sync" - "github.com/netbirdio/netbird/client/proto" log "github.com/sirupsen/logrus" "google.golang.org/grpc" "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" + gstatus "google.golang.org/grpc/status" ) // DaemonState is used to surface the server state needed for determining @@ -50,70 +49,6 @@ func (g *AuthzGate) state() DaemonState { return g.st } -// RequireHolderForFullStatus escalates a StatusRequest that asks for peer detail -// or for probes to be run. -func RequireHolderForFullStatus(r Request) error { - statusReq, ok := r.Msg.(*proto.StatusRequest) - if !ok { - return nil - } - if r.Level < AuthzLevelSessionHolder { - if statusReq.GetFullPeerStatus { - statusReq.GetFullPeerStatus = false - } - if statusReq.ShouldRunProbes { - statusReq.ShouldRunProbes = false - } - return nil - } - return RequireLevel(AuthzLevelSessionHolder)(r) -} - -// RequireLevel builds a rule from a level, for composing inside another rule. -func RequireLevel(want AuthzLevel) Rule { - return func(r Request) error { - if r.Level >= want { - return nil - } - return denyLevel(r, want) - } -} - -func denyLevel(r Request, want AuthzLevel) error { - return status.Errorf(codes.PermissionDenied, - "%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. @@ -139,17 +74,36 @@ func (g *AuthzGate) UnaryPolicyInterceptor() grpc.UnaryServerInterceptor { } } +// resolveLevel is the authority the caller holds over the profile the request +// resolved to. A profile the caller does not own confers nothing beyond being +// identified, which is also what an unresolved handle leaves them with. +func (g *AuthzGate) resolveLevel(id Identity, target Target) AuthzLevel { + if !id.Known() { + return AuthzLevelNone + } + if IsPrivilegedCaller(id) { + return AuthzLevelPrivileged + } + if !target.Owned { + return AuthzLevelIdentified + } + if holder, running := g.st.SessionHolder(); !running || holder.Matches(id) { + return AuthzLevelSessionHolder + } + return AuthzLevelProfileOwner +} + func (g *AuthzGate) authorize(ctx context.Context, method string, msg any) (context.Context, error) { id, ok := CallerIdentity(ctx) if !ok { log.Warnf("ipc authz: DENY %s, caller identity unavailable", method) - return ctx, status.Error(codes.PermissionDenied, + return ctx, gstatus.Error(codes.PermissionDenied, "caller identity could not be verified on the daemon control channel") } st := g.state() if st == nil { log.Warnf("ipc authz: DENY %s for %s, daemon state not attached", method, id) - return ctx, status.Error(codes.Unavailable, "daemon not initialized") + return ctx, gstatus.Error(codes.Unavailable, "daemon not initialized") } policy := methodPolicyFor(method) @@ -159,14 +113,14 @@ func (g *AuthzGate) authorize(ctx context.Context, method string, msg any) (cont if policy.TargetsProfile { named, ok := targetProfile(msg) if !ok { - return ctx, status.Errorf(codes.Internal, "%s is declared target-scoped but names no profile", method) + return ctx, gstatus.Errorf(codes.Internal, "%s is declared target-scoped but names no profile", method) } handle = named } target, handleErr := st.ResolveTarget(id, handle) - level := resolveLevel(id, target, st) + level := g.resolveLevel(id, target) if handleErr != nil && handle != "" { level = AuthzLevelIdentified @@ -204,3 +158,27 @@ func (g *AuthzGate) authorize(ctx context.Context, method string, msg any) (cont } return ContextWithTarget(ctx, target.Path), 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(handle string, err error) error { + if err == nil { + return nil + } + + // An empty handle is the active profile rather than something the caller + // typed, so a failure to resolve it is not theirs to correct. + if handle == "" { + return nil + } + + // Only a gRPC status reaches the caller as a sentence the CLI and the UI + // render. A plain error is a daemon-side failure, and putting it on the + // wire would tell the caller about the daemon rather than about the handle + // they gave. + if _, ok := gstatus.FromError(err); !ok { + return nil + } + return err +} diff --git a/client/internal/ipcauth/authz_level.go b/client/internal/ipcauth/authz_level.go index 53711276e..cc7c87173 100644 --- a/client/internal/ipcauth/authz_level.go +++ b/client/internal/ipcauth/authz_level.go @@ -1,6 +1,7 @@ package ipcauth import ( + "google.golang.org/grpc/codes" gstatus "google.golang.org/grpc/status" ) @@ -46,45 +47,47 @@ func (l AuthzLevel) String() string { } } -// resolveLevel is the authority the caller holds over the profile the request -// resolved to. A profile the caller does not own confers nothing beyond being -// identified, which is also what an unresolved handle leaves them with. -func resolveLevel(id Identity, target Target, st DaemonState) AuthzLevel { - if !id.Known() { - return AuthzLevelNone +// RequireLevel builds a rule from a level, for composing inside another rule. +func RequireLevel(want AuthzLevel) Rule { + return func(r Request) error { + if r.Level >= want { + return nil + } + return denyLevel(r, want) } - if IsPrivilegedCaller(id) { - return AuthzLevelPrivileged - } - if !target.Owned { - return AuthzLevelIdentified - } - if holder, running := st.SessionHolder(); !running || holder.Matches(id) { - return AuthzLevelSessionHolder - } - return AuthzLevelProfileOwner } -// 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(handle string, err error) error { - if err == nil { - return nil - } +func denyLevel(r Request, want AuthzLevel) error { + return gstatus.Errorf(codes.PermissionDenied, + "%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) - // An empty handle is the active profile rather than something the caller - // typed, so a failure to resolve it is not theirs to correct. - if handle == "" { - return nil + case AuthzLevelProfileOwner: + return NotOwnerError(p.Action) } - // Only a gRPC status reaches the caller as a sentence the CLI and the UI - // render. A plain error is a daemon-side failure, and putting it on the - // wire would tell the caller about the daemon rather than about the handle - // they gave. - if _, ok := gstatus.FromError(err); !ok { - return nil - } - return err + return denyLevel(r, p.Level) } diff --git a/client/internal/ipcauth/methods.go b/client/internal/ipcauth/methods.go index 52fb44024..1ad8b5831 100644 --- a/client/internal/ipcauth/methods.go +++ b/client/internal/ipcauth/methods.go @@ -1,5 +1,9 @@ package ipcauth +import ( + "github.com/netbirdio/netbird/client/proto" +) + const servicePath = "/daemon.DaemonService/" // Request is what a rule decides on: the authorization plus the state and the @@ -50,6 +54,25 @@ type MethodPolicy struct { Command string } +// RequireHolderForFullStatus escalates a StatusRequest that asks for peer detail +// or for probes to be run. +func RequireHolderForFullStatus(r Request) error { + statusReq, ok := r.Msg.(*proto.StatusRequest) + if !ok { + return nil + } + if r.Level < AuthzLevelSessionHolder { + if statusReq.GetFullPeerStatus { + statusReq.GetFullPeerStatus = false + } + if statusReq.ShouldRunProbes { + statusReq.ShouldRunProbes = false + } + return nil + } + return RequireLevel(AuthzLevelSessionHolder)(r) +} + // methodPolicies is the complete authorization surface. Every RPC on // DaemonService appears here exactly once. var methodPolicies = map[string]MethodPolicy{