Small refactor of Authz

This commit is contained in:
Theodor S. Midtlien
2026-09-18 11:28:16 +02:00
parent 6dcb1374e9
commit cb9d2ee3bb
3 changed files with 109 additions and 105 deletions
+48 -70
View File
@@ -4,11 +4,10 @@ import (
"context" "context"
"sync" "sync"
"github.com/netbirdio/netbird/client/proto"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/codes" "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 // DaemonState is used to surface the server state needed for determining
@@ -50,70 +49,6 @@ func (g *AuthzGate) state() DaemonState {
return g.st 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. // StreamPolicyInterceptor authorizes each streaming RPC before the handler runs.
// The request payload is not yet available, so no streaming method may be // The request payload is not yet available, so no streaming method may be
// target-scoped. // 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) { func (g *AuthzGate) authorize(ctx context.Context, method string, msg any) (context.Context, error) {
id, ok := CallerIdentity(ctx) id, ok := CallerIdentity(ctx)
if !ok { if !ok {
log.Warnf("ipc authz: DENY %s, caller identity unavailable", method) 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") "caller identity could not be verified on the daemon control channel")
} }
st := g.state() st := g.state()
if st == nil { if st == nil {
log.Warnf("ipc authz: DENY %s for %s, daemon state not attached", method, id) 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) policy := methodPolicyFor(method)
@@ -159,14 +113,14 @@ func (g *AuthzGate) authorize(ctx context.Context, method string, msg any) (cont
if policy.TargetsProfile { if policy.TargetsProfile {
named, ok := targetProfile(msg) named, ok := targetProfile(msg)
if !ok { 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 handle = named
} }
target, handleErr := st.ResolveTarget(id, handle) target, handleErr := st.ResolveTarget(id, handle)
level := resolveLevel(id, target, st) level := g.resolveLevel(id, target)
if handleErr != nil && handle != "" { if handleErr != nil && handle != "" {
level = AuthzLevelIdentified level = AuthzLevelIdentified
@@ -204,3 +158,27 @@ func (g *AuthzGate) authorize(ctx context.Context, method string, msg any) (cont
} }
return ContextWithTarget(ctx, target.Path), nil 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
}
+38 -35
View File
@@ -1,6 +1,7 @@
package ipcauth package ipcauth
import ( import (
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status" 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 // RequireLevel builds a rule from a level, for composing inside another rule.
// resolved to. A profile the caller does not own confers nothing beyond being func RequireLevel(want AuthzLevel) Rule {
// identified, which is also what an unresolved handle leaves them with. return func(r Request) error {
func resolveLevel(id Identity, target Target, st DaemonState) AuthzLevel { if r.Level >= want {
if !id.Known() { return nil
return AuthzLevelNone }
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 func denyLevel(r Request, want AuthzLevel) error {
// it in front of the caller in place of its own refusal. Everything else is return gstatus.Errorf(codes.PermissionDenied,
// dropped, and the caller gets the refusal their level earned. "%s requires %s, caller %s is %s", r.Method, want, r.Identity, r.Level)
func presentableHandleError(handle string, err error) error { }
if err == nil {
return nil // 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 case AuthzLevelProfileOwner:
// typed, so a failure to resolve it is not theirs to correct. return NotOwnerError(p.Action)
if handle == "" {
return nil
} }
// Only a gRPC status reaches the caller as a sentence the CLI and the UI return denyLevel(r, p.Level)
// 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
} }
+23
View File
@@ -1,5 +1,9 @@
package ipcauth package ipcauth
import (
"github.com/netbirdio/netbird/client/proto"
)
const servicePath = "/daemon.DaemonService/" const servicePath = "/daemon.DaemonService/"
// Request is what a rule decides on: the authorization plus the state and the // Request is what a rule decides on: the authorization plus the state and the
@@ -50,6 +54,25 @@ type MethodPolicy struct {
Command string 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 // methodPolicies is the complete authorization surface. Every RPC on
// DaemonService appears here exactly once. // DaemonService appears here exactly once.
var methodPolicies = map[string]MethodPolicy{ var methodPolicies = map[string]MethodPolicy{