mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-27 09:09:07 +02:00
Merge branch 'main' into embedded-vnc
This commit is contained in:
@@ -321,7 +321,7 @@ func buildJWTConfig(config *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfi
|
||||
|
||||
return &proto.JWTConfig{
|
||||
Issuer: issuer,
|
||||
Audience: audience,
|
||||
Audience: audience, //nolint:staticcheck
|
||||
Audiences: audiences,
|
||||
KeysLocation: keysLocation,
|
||||
}
|
||||
|
||||
@@ -29,14 +29,15 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/peers"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/activity"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
|
||||
"github.com/netbirdio/netbird/management/server/idp"
|
||||
"github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/management/server/users"
|
||||
proxyauth "github.com/netbirdio/netbird/proxy/auth"
|
||||
@@ -61,6 +62,17 @@ type ProxyTokenChecker interface {
|
||||
IsProxyAccessTokenValid(ctx context.Context, tokenID string) (bool, error)
|
||||
}
|
||||
|
||||
// ProxyConnectAuthorizer authorizes a proxy's claim to the cluster address it
|
||||
// declares at connect time. Implementations are supplied by integrations; none
|
||||
// is installed by default, so every well-formed claim is authorized — the
|
||||
// declared address is otherwise only checked for availability. token is nil
|
||||
// when the connection carries no proxy access token. A returned status error
|
||||
// is sent to the proxy unchanged; any other error is wrapped as
|
||||
// PermissionDenied.
|
||||
type ProxyConnectAuthorizer interface {
|
||||
AuthorizeProxyConnect(ctx context.Context, token *types.ProxyAccessToken, proxyID, address string) error
|
||||
}
|
||||
|
||||
// ProxyServiceServer implements the ProxyService gRPC server
|
||||
// AgentNetworkSynthesizer produces in-memory reverse-proxy services from
|
||||
// Agent Network provider/policy state for the proxy snapshot path; synthesised
|
||||
@@ -99,6 +111,9 @@ type ProxyServiceServer struct {
|
||||
// and the post-flight consumption write (RecordLLMUsage). Optional — when
|
||||
// nil both RPCs return Unimplemented.
|
||||
agentNetworkLimits AgentNetworkLimitsService
|
||||
// connectAuthorizer authorizes address claims at proxy connect time.
|
||||
// Optional — when nil every well-formed claim is authorized.
|
||||
connectAuthorizer ProxyConnectAuthorizer
|
||||
// ProxyController for service updates and cluster management
|
||||
proxyController proxy.Controller
|
||||
|
||||
@@ -114,6 +129,9 @@ type ProxyServiceServer struct {
|
||||
// Manager for IdP-enriched user data (may be nil when no IdP is configured)
|
||||
idpManager idp.Manager
|
||||
|
||||
// Manager that records reverse proxy usage for activity accounting
|
||||
activityManager activity.Manager
|
||||
|
||||
// Store for one-time authentication tokens
|
||||
tokenStore *OneTimeTokenStore
|
||||
|
||||
@@ -236,6 +254,13 @@ func (s *ProxyServiceServer) SetServiceManager(manager rpservice.Manager) {
|
||||
s.serviceManager = manager
|
||||
}
|
||||
|
||||
// SetActivityManager wires the manager that records reverse proxy usage.
|
||||
func (s *ProxyServiceServer) SetActivityManager(manager activity.Manager) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.activityManager = manager
|
||||
}
|
||||
|
||||
// SetAgentNetworkSynthesizer wires the agent-network service synthesiser.
|
||||
// Optional — when nil the snapshot path skips agent-network synthesis. The
|
||||
// modules layer injects this after both the proxy server and the agent-network
|
||||
@@ -262,6 +287,23 @@ func (s *ProxyServiceServer) agentNetworkSynthesizer() AgentNetworkSynthesizer {
|
||||
return s.agentNetworkSynth
|
||||
}
|
||||
|
||||
// SetProxyConnectAuthorizer wires the connect-time address-claim authorizer.
|
||||
// Optional — when nil (the default) every well-formed claim is authorized,
|
||||
// which is the behavior without the hook. The modules layer injects this
|
||||
// after the proxy server is constructed, like the other setters.
|
||||
func (s *ProxyServiceServer) SetProxyConnectAuthorizer(authorizer ProxyConnectAuthorizer) {
|
||||
s.mu.Lock()
|
||||
s.connectAuthorizer = authorizer
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// proxyConnectAuthorizer returns the connect authorizer under read lock.
|
||||
func (s *ProxyServiceServer) proxyConnectAuthorizer() ProxyConnectAuthorizer {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.connectAuthorizer
|
||||
}
|
||||
|
||||
// CheckLLMPolicyLimits is the pre-flight policy gate the proxy calls before
|
||||
// forwarding an LLM request upstream. Delegates to the agent-network selector,
|
||||
// which scores applicable policies by remaining headroom and returns the
|
||||
@@ -446,8 +488,9 @@ func recvSyncInit(stream proto.ProxyService_SyncMappingsServer) (*proto.SyncMapp
|
||||
return init, nil
|
||||
}
|
||||
|
||||
// validateProxyConnect validates the proxy ID and address, and checks cluster
|
||||
// address availability for account-scoped tokens.
|
||||
// validateProxyConnect validates the proxy ID and address, checks cluster
|
||||
// address availability for account-scoped tokens, and finally consults the
|
||||
// connect authorizer (when installed) on the address claim.
|
||||
func (s *ProxyServiceServer) validateProxyConnect(proxyID, address string, ctx context.Context) (proxyConnectParams, error) {
|
||||
if proxyID == "" {
|
||||
return proxyConnectParams{}, status.Errorf(codes.InvalidArgument, "proxy_id is required")
|
||||
@@ -467,6 +510,19 @@ func (s *ProxyServiceServer) validateProxyConnect(proxyID, address string, ctx c
|
||||
}
|
||||
}
|
||||
|
||||
// The authorizer runs last, outside the account-scoped branch, so it also
|
||||
// sees management-wide and token-less connects. PermissionDenied keeps an
|
||||
// authorization rejection distinguishable from the AlreadyExists address
|
||||
// conflict above in proxy logs.
|
||||
if authorizer := s.proxyConnectAuthorizer(); authorizer != nil {
|
||||
if err := authorizer.AuthorizeProxyConnect(ctx, token, proxyID, address); err != nil {
|
||||
if _, ok := status.FromError(err); ok {
|
||||
return proxyConnectParams{}, err
|
||||
}
|
||||
return proxyConnectParams{}, status.Errorf(codes.PermissionDenied, "proxy connect not authorized: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return proxyConnectParams{proxyID: proxyID, address: address}, nil
|
||||
}
|
||||
|
||||
@@ -1255,7 +1311,7 @@ func (s *ProxyServiceServer) authenticateHeader(ctx context.Context, serviceID s
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return true, "header-user", proxyauth.MethodHeader
|
||||
return true, proxyauth.HeaderUserID, proxyauth.MethodHeader
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
@@ -1579,9 +1635,62 @@ func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL
|
||||
return verifier, redirectURL, nil
|
||||
}
|
||||
|
||||
// Denied reasons reported to the proxy when access is refused because of the
|
||||
// account status of the user behind the request.
|
||||
const (
|
||||
deniedReasonPendingApproval = "pending_approval"
|
||||
deniedReasonUserBlocked = "user_blocked"
|
||||
deniedReasonUserNotFound = "user_not_found"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrUserPendingApproval reports a user whose account still awaits approval
|
||||
// by an administrator and may therefore not hold a proxy session.
|
||||
ErrUserPendingApproval = errors.New("user pending approval")
|
||||
|
||||
// ErrUserBlocked reports a blocked user, who may not hold a proxy session.
|
||||
ErrUserBlocked = errors.New("user blocked")
|
||||
|
||||
errUserUnresolved = errors.New("user could not be resolved")
|
||||
)
|
||||
|
||||
// checkUserStatus reports whether the user's account status permits reverse
|
||||
// proxy access, returning the denied reason for the proxy access log together
|
||||
// with the sentinel error callers match on. A user awaiting approval is stored
|
||||
// as both pending and blocked, so the pending state is reported first: it is
|
||||
// the one an administrator can act on.
|
||||
func checkUserStatus(user *types.User) (string, error) {
|
||||
switch {
|
||||
case user == nil:
|
||||
return deniedReasonUserNotFound, errUserUnresolved
|
||||
case user.PendingApproval:
|
||||
return deniedReasonPendingApproval, ErrUserPendingApproval
|
||||
case user.IsBlocked():
|
||||
return deniedReasonUserBlocked, ErrUserBlocked
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
// userStatusDeniedReason returns the denied reason for callers that report a
|
||||
// decision rather than an error, and an empty string when the user may proceed.
|
||||
func userStatusDeniedReason(user *types.User) string {
|
||||
reason, _ := checkUserStatus(user)
|
||||
return reason
|
||||
}
|
||||
|
||||
// sameAccount reports whether a user belongs to a service's account. An empty
|
||||
// identifier on either side never matches: two unset accounts must not compare
|
||||
// equal into a grant.
|
||||
func sameAccount(userAccountID, serviceAccountID string) bool {
|
||||
return userAccountID != "" && serviceAccountID != "" && userAccountID == serviceAccountID
|
||||
}
|
||||
|
||||
// GenerateSessionToken creates a signed session JWT for the given domain and
|
||||
// user. The user's group memberships are embedded in the token so policy-aware
|
||||
// middlewares on the proxy can authorise without an extra management round-trip.
|
||||
// A user the store cannot resolve, or whose account is pending approval or
|
||||
// blocked, gets no token at all, so the browser never receives a session cookie.
|
||||
func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, userID string, method proxyauth.Method) (string, error) {
|
||||
service, err := s.getServiceByDomain(ctx, domain)
|
||||
if err != nil {
|
||||
@@ -1592,31 +1701,62 @@ func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, u
|
||||
return "", fmt.Errorf("no session key configured for domain: %s", domain)
|
||||
}
|
||||
|
||||
var (
|
||||
email string
|
||||
groupIDs []string
|
||||
groupNames []string
|
||||
)
|
||||
if s.usersManager != nil {
|
||||
user, userGroups, uerr := s.usersManager.GetUserWithGroups(ctx, userID)
|
||||
if uerr != nil {
|
||||
log.WithContext(ctx).Debugf("session token mint: lookup user %s: %v", userID, uerr)
|
||||
} else if user != nil {
|
||||
email = user.Email
|
||||
groupIDs, groupNames = pairGroupIDsAndNames(userGroups)
|
||||
}
|
||||
if s.usersManager == nil {
|
||||
return "", errors.New("users manager not configured")
|
||||
}
|
||||
|
||||
return sessionkey.SignToken(
|
||||
user, userGroups, err := s.usersManager.GetUserWithGroups(ctx, userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get user %s: %w", userID, err)
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return "", fmt.Errorf("get user %s: %w", userID, errUserUnresolved)
|
||||
}
|
||||
|
||||
// Bind the OIDC identity to the service's account before signing anything
|
||||
// with that service's session key. The proxy validates an installed cookie
|
||||
// locally against the service public key, so a token minted for a user of
|
||||
// another account would be honoured without a management round-trip.
|
||||
if !sameAccount(user.AccountID, service.AccountID) {
|
||||
return "", fmt.Errorf("user %s does not belong to the service account", userID)
|
||||
}
|
||||
|
||||
if _, err := checkUserStatus(user); err != nil {
|
||||
return "", fmt.Errorf("session token for user %s: %w", userID, err)
|
||||
}
|
||||
|
||||
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
|
||||
|
||||
token, err := sessionkey.SignToken(
|
||||
service.SessionPrivateKey,
|
||||
userID,
|
||||
email,
|
||||
user.Email,
|
||||
domain,
|
||||
method,
|
||||
groupIDs,
|
||||
groupNames,
|
||||
proxyauth.DefaultSessionExpiry,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
s.recordUserLogin(ctx, service.AccountID, user)
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// recordUserLogin hands the sign-in to the activity manager. The RPC must not
|
||||
// fail on it, so the error is logged and dropped here rather than returned.
|
||||
func (s *ProxyServiceServer) recordUserLogin(ctx context.Context, accountID string, user *types.User) {
|
||||
if s.activityManager == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.activityManager.RecordUserLogin(ctx, accountID, user); err != nil {
|
||||
log.WithContext(ctx).Debugf("record proxy login for user %s: %v", user.Id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateUserGroupAccess checks if a user has access to a service.
|
||||
@@ -1628,6 +1768,10 @@ func (s *ProxyServiceServer) ValidateUserGroupAccess(ctx context.Context, domain
|
||||
return fmt.Errorf("user not found: %s", userID)
|
||||
}
|
||||
|
||||
if _, err := checkUserStatus(user); err != nil {
|
||||
return fmt.Errorf("user %s denied access to domain %s: %w", userID, domain, err)
|
||||
}
|
||||
|
||||
service, err := s.getAccountServiceByDomain(ctx, user.AccountID, domain)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1682,10 +1826,7 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
|
||||
sessionToken := req.GetSessionToken()
|
||||
|
||||
if domain == "" || sessionToken == "" {
|
||||
return &proto.ValidateSessionResponse{
|
||||
Valid: false,
|
||||
DeniedReason: "missing domain or session_token",
|
||||
}, nil
|
||||
return deniedSessionResponse("missing domain or session_token"), nil
|
||||
}
|
||||
|
||||
service, err := s.getServiceByDomain(ctx, domain)
|
||||
@@ -1695,83 +1836,49 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
|
||||
"error": err.Error(),
|
||||
}).Debug("ValidateSession: service not found")
|
||||
//nolint:nilerr
|
||||
return &proto.ValidateSessionResponse{
|
||||
Valid: false,
|
||||
DeniedReason: "service_not_found",
|
||||
}, nil
|
||||
return deniedSessionResponse("service_not_found"), nil
|
||||
}
|
||||
|
||||
if err := enforceAccountScope(ctx, service.AccountID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pubKeyBytes, err := base64.StdEncoding.DecodeString(service.SessionPublicKey)
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"error": err.Error(),
|
||||
}).Error("ValidateSession: decode public key")
|
||||
//nolint:nilerr
|
||||
return &proto.ValidateSessionResponse{
|
||||
Valid: false,
|
||||
DeniedReason: "invalid_service_config",
|
||||
}, nil
|
||||
}
|
||||
|
||||
userID, _, _, _, _, err := proxyauth.ValidateSessionJWT(sessionToken, domain, pubKeyBytes)
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"error": err.Error(),
|
||||
}).Debug("ValidateSession: invalid session token")
|
||||
//nolint:nilerr
|
||||
return &proto.ValidateSessionResponse{
|
||||
Valid: false,
|
||||
DeniedReason: "invalid_token",
|
||||
}, nil
|
||||
userID, reason := sessionTokenSubject(domain, service, sessionToken)
|
||||
if reason != "" {
|
||||
return deniedSessionResponse(reason), nil
|
||||
}
|
||||
|
||||
user, userGroups, err := s.usersManager.GetUserWithGroups(ctx, userID)
|
||||
if err != nil {
|
||||
if err != nil || user == nil {
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"user_id": userID,
|
||||
"error": err.Error(),
|
||||
"error": err,
|
||||
}).Debug("ValidateSession: user not found")
|
||||
//nolint:nilerr
|
||||
return &proto.ValidateSessionResponse{
|
||||
Valid: false,
|
||||
DeniedReason: "user_not_found",
|
||||
}, nil
|
||||
return deniedSessionResponse(deniedReasonUserNotFound), nil
|
||||
}
|
||||
|
||||
if user.AccountID != service.AccountID {
|
||||
// A user from another account gets a bare response: none of their identity
|
||||
// belongs in an answer to a proxy serving a different account.
|
||||
if !sameAccount(user.AccountID, service.AccountID) {
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"user_id": userID,
|
||||
"user_account": user.AccountID,
|
||||
"service_account": service.AccountID,
|
||||
}).Debug("ValidateSession: user account mismatch")
|
||||
//nolint:nilerr
|
||||
return &proto.ValidateSessionResponse{
|
||||
Valid: false,
|
||||
DeniedReason: "account_mismatch",
|
||||
}, nil
|
||||
return deniedSessionResponse("account_mismatch"), nil
|
||||
}
|
||||
|
||||
if err := s.checkGroupAccess(service, user); err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"user_id": userID,
|
||||
"error": err.Error(),
|
||||
}).Debug("ValidateSession: access denied")
|
||||
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
|
||||
//nolint:nilerr
|
||||
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
|
||||
|
||||
if reason := s.accountUserDeniedReason(domain, service, user); reason != "" {
|
||||
return &proto.ValidateSessionResponse{
|
||||
Valid: false,
|
||||
UserId: user.Id,
|
||||
UserEmail: user.Email,
|
||||
DeniedReason: "not_in_group",
|
||||
DeniedReason: reason,
|
||||
PeerGroupIds: groupIDs,
|
||||
PeerGroupNames: groupNames,
|
||||
}, nil
|
||||
@@ -1783,7 +1890,6 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
|
||||
"email": user.Email,
|
||||
}).Debug("ValidateSession: access granted")
|
||||
|
||||
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
|
||||
return &proto.ValidateSessionResponse{
|
||||
Valid: true,
|
||||
UserId: user.Id,
|
||||
@@ -1793,6 +1899,66 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
|
||||
}, nil
|
||||
}
|
||||
|
||||
// deniedSessionResponse builds a denial that carries no identity, for the
|
||||
// checks that run before a user of this service's account is resolved.
|
||||
func deniedSessionResponse(reason string) *proto.ValidateSessionResponse {
|
||||
return &proto.ValidateSessionResponse{
|
||||
Valid: false,
|
||||
DeniedReason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
// sessionTokenSubject verifies the session token against the service's session
|
||||
// key and returns the user it was minted for, or the reason it cannot be
|
||||
// trusted.
|
||||
func sessionTokenSubject(domain string, service *rpservice.Service, sessionToken string) (userID, deniedReason string) {
|
||||
pubKeyBytes, err := base64.StdEncoding.DecodeString(service.SessionPublicKey)
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"error": err.Error(),
|
||||
}).Error("ValidateSession: decode public key")
|
||||
return "", "invalid_service_config"
|
||||
}
|
||||
|
||||
userID, _, _, _, _, err = proxyauth.ValidateSessionJWT(sessionToken, domain, pubKeyBytes)
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"error": err.Error(),
|
||||
}).Debug("ValidateSession: invalid session token")
|
||||
return "", "invalid_token"
|
||||
}
|
||||
|
||||
return userID, ""
|
||||
}
|
||||
|
||||
// accountUserDeniedReason gates a user of the service's own account, returning
|
||||
// an empty string when access is granted. Account status comes before group
|
||||
// membership: a user awaiting approval or blocked has no access regardless of
|
||||
// the groups they were auto-assigned.
|
||||
func (s *ProxyServiceServer) accountUserDeniedReason(domain string, service *rpservice.Service, user *types.User) string {
|
||||
if reason := userStatusDeniedReason(user); reason != "" {
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"user_id": user.Id,
|
||||
"reason": reason,
|
||||
}).Debug("ValidateSession: user status denies access")
|
||||
return reason
|
||||
}
|
||||
|
||||
if err := s.checkGroupAccess(service, user); err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"user_id": user.Id,
|
||||
"error": err.Error(),
|
||||
}).Debug("ValidateSession: access denied")
|
||||
return "not_in_group"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *ProxyServiceServer) getServiceByDomain(ctx context.Context, domain string) (*rpservice.Service, error) {
|
||||
service, err := s.serviceManager.GetServiceByDomain(ctx, domain)
|
||||
if err == nil {
|
||||
@@ -1907,7 +2073,20 @@ func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto.
|
||||
}
|
||||
|
||||
groupIDs, groupNames := pairGroupIDsAndNames(peerGroups)
|
||||
principalID, displayIdentity := s.getTunnelPeerInfo(ctx, domain, service, peer)
|
||||
owner := s.resolvePeerOwner(ctx, peer, service.AccountID)
|
||||
principalID, displayIdentity := s.getTunnelPeerInfo(ctx, domain, service, peer, owner)
|
||||
|
||||
if reason := peerOwnerDeniedReason(peer, owner); reason != "" {
|
||||
log.WithFields(log.Fields{"domain": domain, "peer_id": peer.ID, "user_id": peer.UserID, "reason": reason}).Debug("ValidateTunnelPeer: owner status denies access")
|
||||
return &proto.ValidateTunnelPeerResponse{
|
||||
Valid: false,
|
||||
UserId: principalID,
|
||||
UserEmail: displayIdentity,
|
||||
DeniedReason: reason,
|
||||
PeerGroupIds: groupIDs,
|
||||
PeerGroupNames: groupNames,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if err := checkPeerGroupAccess(service, groupIDs); err != nil {
|
||||
log.WithFields(log.Fields{"domain": domain, "peer_id": peer.ID, "error": err.Error()}).Debug("ValidateTunnelPeer: access denied")
|
||||
@@ -1927,6 +2106,8 @@ func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.recordPeerSeen(ctx, service.AccountID, peer)
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"tunnel_ip": tunnelIPStr,
|
||||
@@ -1944,9 +2125,67 @@ func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto.
|
||||
}, nil
|
||||
}
|
||||
|
||||
// recordPeerSeen hands the mesh request to the activity manager. The RPC must
|
||||
// not fail on it, so the error is logged and dropped here rather than returned.
|
||||
func (s *ProxyServiceServer) recordPeerSeen(ctx context.Context, accountID string, peer *peer.Peer) {
|
||||
if s.activityManager == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.activityManager.RecordPeerSeen(ctx, accountID, peer); err != nil {
|
||||
log.WithContext(ctx).Debugf("record proxy activity for peer %s: %v", peer.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePeerOwner returns the user a peer is linked to, once per request so
|
||||
// the status gate and the identity resolution below share a single lookup.
|
||||
// Unlinked peers (machine agents) have no owner. A lookup that fails returns
|
||||
// nil rather than an error: both callers treat an unresolved owner the same
|
||||
// way, and neither may trust one it could not read.
|
||||
func (s *ProxyServiceServer) resolvePeerOwner(ctx context.Context, peer *peer.Peer, accountID string) *types.User {
|
||||
if peer.UserID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
user, err := s.usersManager.GetUser(ctx, peer.UserID)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Debugf("ValidateTunnelPeer: look up owner %s of peer %s: %v", peer.UserID, peer.ID, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// The lookup is by user ID alone, so a peer row pointing outside the
|
||||
// service's account would otherwise resolve a foreign user. Leave the owner
|
||||
// unresolved instead: the gate denies it, and neither the response nor the
|
||||
// minted token carries an identity from another account.
|
||||
if !sameAccount(user.AccountID, accountID) {
|
||||
log.WithContext(ctx).Debugf("ValidateTunnelPeer: owner %s of peer %s belongs to another account", peer.UserID, peer.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
// peerOwnerDeniedReason gates the mesh fast-path on the account status of the
|
||||
// peer's owning user, so a user blocked after registering a peer loses
|
||||
// mesh-origin access too. Unlinked peers (machine agents) have no owner to gate
|
||||
// on and stay first-class callers. An owner the store cannot resolve denies:
|
||||
// an unavailable lookup must not grant access.
|
||||
func peerOwnerDeniedReason(peer *peer.Peer, owner *types.User) string {
|
||||
if peer.UserID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if owner == nil {
|
||||
return deniedReasonUserNotFound
|
||||
}
|
||||
|
||||
return userStatusDeniedReason(owner)
|
||||
}
|
||||
|
||||
// getTunnelPeerInfo returns the principal ID and display name for a peer, e.g. a
|
||||
// user or peer ID, and peer name or user email.
|
||||
func (s *ProxyServiceServer) getTunnelPeerInfo(ctx context.Context, domain string, service *rpservice.Service, peer *peer.Peer) (string, string) {
|
||||
// user or peer ID, and peer name or user email. owner is the already-resolved
|
||||
// user the peer is linked to, or nil.
|
||||
func (s *ProxyServiceServer) getTunnelPeerInfo(ctx context.Context, domain string, service *rpservice.Service, peer *peer.Peer, owner *types.User) (string, string) {
|
||||
// Resolve the principal: when the peer is linked to a user, the human is the
|
||||
// principal so multiple peers owned by the same user share a single
|
||||
// identity. Unlinked peers (machine agents) are their own principal keyed on
|
||||
@@ -1963,10 +2202,10 @@ func (s *ProxyServiceServer) getTunnelPeerInfo(ctx context.Context, domain strin
|
||||
principalID := peer.UserID
|
||||
displayIdentity := peer.Name
|
||||
// Stored column first (cheap, but often empty for OIDC-provisioned users).
|
||||
if user, uerr := s.usersManager.GetUser(ctx, peer.UserID); uerr == nil && user != nil {
|
||||
principalID = user.Id
|
||||
if user.Email != "" {
|
||||
displayIdentity = user.Email
|
||||
if owner != nil {
|
||||
principalID = owner.Id
|
||||
if owner.Email != "" {
|
||||
displayIdentity = owner.Email
|
||||
}
|
||||
}
|
||||
// IdP enrichment wins when available — the stored email column is a
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
grpcstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// capturingAuthorizer records the arguments of the last AuthorizeProxyConnect
|
||||
// call and returns a fixed error.
|
||||
type capturingAuthorizer struct {
|
||||
called int
|
||||
token *types.ProxyAccessToken
|
||||
proxyID string
|
||||
address string
|
||||
err error
|
||||
}
|
||||
|
||||
func (a *capturingAuthorizer) AuthorizeProxyConnect(_ context.Context, token *types.ProxyAccessToken, proxyID, address string) error {
|
||||
a.called++
|
||||
a.token = token
|
||||
a.proxyID = proxyID
|
||||
a.address = address
|
||||
return a.err
|
||||
}
|
||||
|
||||
// authorizerServer builds a ProxyServiceServer whose proxy manager reports
|
||||
// every cluster address as available, so the authorizer is the only thing
|
||||
// standing between a claim and success.
|
||||
func authorizerServer(t *testing.T) *ProxyServiceServer {
|
||||
t.Helper()
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr := proxy.NewMockManager(ctrl)
|
||||
mgr.EXPECT().IsClusterAddressAvailable(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil).AnyTimes()
|
||||
return &ProxyServiceServer{proxyManager: mgr}
|
||||
}
|
||||
|
||||
// TestValidateProxyConnect_NilAuthorizerUnchanged guards the no-behavior-change
|
||||
// claim: with no authorizer installed — the OSS default — a well-formed claim
|
||||
// succeeds and malformed input is rejected exactly as before the hook existed.
|
||||
func TestValidateProxyConnect_NilAuthorizerUnchanged(t *testing.T) {
|
||||
s := authorizerServer(t)
|
||||
|
||||
params, err := s.validateProxyConnect("proxy-1", "cluster.example.com", scopedCtx("acc-1"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "proxy-1", params.proxyID)
|
||||
assert.Equal(t, "cluster.example.com", params.address)
|
||||
|
||||
_, err = s.validateProxyConnect("", "cluster.example.com", scopedCtx("acc-1"))
|
||||
require.Error(t, err)
|
||||
st, ok := grpcstatus.FromError(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, codes.InvalidArgument, st.Code(), "missing proxy_id must stay InvalidArgument")
|
||||
|
||||
_, err = s.validateProxyConnect("proxy-1", "not a hostname", scopedCtx("acc-1"))
|
||||
require.Error(t, err)
|
||||
st, ok = grpcstatus.FromError(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, codes.InvalidArgument, st.Code(), "invalid address must stay InvalidArgument")
|
||||
}
|
||||
|
||||
// TestValidateProxyConnect_AuthorizerReceivesClaim pins the hook contract: the
|
||||
// authorizer sees the presented token and the claimed proxy ID and address,
|
||||
// and an authorized claim proceeds.
|
||||
func TestValidateProxyConnect_AuthorizerReceivesClaim(t *testing.T) {
|
||||
s := authorizerServer(t)
|
||||
auth := &capturingAuthorizer{}
|
||||
s.SetProxyConnectAuthorizer(auth)
|
||||
|
||||
params, err := s.validateProxyConnect("proxy-1", "cluster.example.com", scopedCtx("acc-1"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "cluster.example.com", params.address)
|
||||
|
||||
require.Equal(t, 1, auth.called, "authorizer must be consulted exactly once per connect")
|
||||
assert.Equal(t, "proxy-1", auth.proxyID)
|
||||
assert.Equal(t, "cluster.example.com", auth.address)
|
||||
require.NotNil(t, auth.token, "the presented token must be handed to the authorizer")
|
||||
require.NotNil(t, auth.token.AccountID)
|
||||
assert.Equal(t, "acc-1", *auth.token.AccountID)
|
||||
}
|
||||
|
||||
// TestValidateProxyConnect_PlainErrorBecomesPermissionDenied pins the error
|
||||
// mapping: a non-status error from the authorizer surfaces as
|
||||
// PermissionDenied — distinguishable from the AlreadyExists used for address
|
||||
// conflicts — and the claim does not proceed even though the address itself
|
||||
// was available.
|
||||
func TestValidateProxyConnect_PlainErrorBecomesPermissionDenied(t *testing.T) {
|
||||
s := authorizerServer(t)
|
||||
s.SetProxyConnectAuthorizer(&capturingAuthorizer{err: errors.New("not the assigned credential")})
|
||||
|
||||
_, err := s.validateProxyConnect("proxy-1", "cluster.example.com", scopedCtx("acc-1"))
|
||||
require.Error(t, err)
|
||||
st, ok := grpcstatus.FromError(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, codes.PermissionDenied, st.Code())
|
||||
assert.Contains(t, st.Message(), "not the assigned credential", "the authorizer's reason must survive into the status message")
|
||||
}
|
||||
|
||||
// TestValidateProxyConnect_StatusErrorPassesThrough pins that an authorizer
|
||||
// which chooses its own status code is not second-guessed.
|
||||
func TestValidateProxyConnect_StatusErrorPassesThrough(t *testing.T) {
|
||||
s := authorizerServer(t)
|
||||
s.SetProxyConnectAuthorizer(&capturingAuthorizer{
|
||||
err: grpcstatus.Errorf(codes.ResourceExhausted, "try later"),
|
||||
})
|
||||
|
||||
_, err := s.validateProxyConnect("proxy-1", "cluster.example.com", scopedCtx("acc-1"))
|
||||
require.Error(t, err)
|
||||
st, ok := grpcstatus.FromError(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, codes.ResourceExhausted, st.Code(), "a status error must pass through unchanged")
|
||||
assert.Equal(t, "try later", st.Message())
|
||||
}
|
||||
|
||||
// TestValidateProxyConnect_AuthorizerSeesGlobalAndTokenlessConnects pins the
|
||||
// call-site placement: the authorizer sits outside the account-scoped branch,
|
||||
// so management-wide tokens (AccountID == nil) and connections without any
|
||||
// token are also presented to it rather than bypassing policy.
|
||||
func TestValidateProxyConnect_AuthorizerSeesGlobalAndTokenlessConnects(t *testing.T) {
|
||||
s := &ProxyServiceServer{} // no proxy manager: neither path may reach the availability check
|
||||
|
||||
auth := &capturingAuthorizer{}
|
||||
s.SetProxyConnectAuthorizer(auth)
|
||||
|
||||
_, err := s.validateProxyConnect("proxy-1", "cluster.example.com", globalCtx())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, auth.called, "a management-wide token must still be presented to the authorizer")
|
||||
require.NotNil(t, auth.token)
|
||||
assert.Nil(t, auth.token.AccountID)
|
||||
|
||||
_, err = s.validateProxyConnect("proxy-1", "cluster.example.com", context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, auth.called, "a token-less connect must still be presented to the authorizer")
|
||||
assert.Nil(t, auth.token, "no token in context must surface as a nil token, not a zero value")
|
||||
}
|
||||
|
||||
// TestValidateProxyConnect_AuthorizerRunsLast pins the ordering: input
|
||||
// validation and the availability check precede policy, so the authorizer is
|
||||
// never consulted about a claim that is malformed or already rejected.
|
||||
func TestValidateProxyConnect_AuthorizerRunsLast(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr := proxy.NewMockManager(ctrl)
|
||||
mgr.EXPECT().IsClusterAddressAvailable(gomock.Any(), gomock.Any(), gomock.Any()).Return(false, nil)
|
||||
s := &ProxyServiceServer{proxyManager: mgr}
|
||||
|
||||
auth := &capturingAuthorizer{}
|
||||
s.SetProxyConnectAuthorizer(auth)
|
||||
|
||||
_, err := s.validateProxyConnect("proxy-1", "not a hostname", scopedCtx("acc-1"))
|
||||
require.Error(t, err)
|
||||
assert.Zero(t, auth.called, "a malformed address must be rejected before policy runs")
|
||||
|
||||
_, err = s.validateProxyConnect("proxy-1", "cluster.example.com", scopedCtx("acc-1"))
|
||||
require.Error(t, err)
|
||||
st, ok := grpcstatus.FromError(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, codes.AlreadyExists, st.Code(), "an address conflict must keep its own status")
|
||||
assert.Zero(t, auth.called, "a conflicting address must be rejected before policy runs")
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -119,11 +120,13 @@ func (m *mockReverseProxyManager) GetClusters(_ context.Context, _, _ string) ([
|
||||
}
|
||||
|
||||
type mockUsersManager struct {
|
||||
users map[string]*types.User
|
||||
err error
|
||||
users map[string]*types.User
|
||||
err error
|
||||
getUserCalls int
|
||||
}
|
||||
|
||||
func (m *mockUsersManager) GetUser(ctx context.Context, userID string) (*types.User, error) {
|
||||
m.getUserCalls++
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
@@ -153,6 +156,27 @@ type mockTunnelPeersManager struct {
|
||||
groupsErr error
|
||||
}
|
||||
|
||||
// mockActivityManager records what the RPC handed to the activity manager. The
|
||||
// policy (throttling, exclusions) is the manager's and is tested there; these
|
||||
// tests only pin which requests reach it.
|
||||
type mockActivityManager struct {
|
||||
seenMarks []seenMark
|
||||
}
|
||||
|
||||
type seenMark struct {
|
||||
accountID string
|
||||
peerID string
|
||||
}
|
||||
|
||||
func (m *mockActivityManager) RecordUserLogin(_ context.Context, _ string, _ *types.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockActivityManager) RecordPeerSeen(_ context.Context, accountID string, peer *peer.Peer) error {
|
||||
m.seenMarks = append(m.seenMarks, seenMark{accountID: accountID, peerID: peer.ID})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockTunnelPeersManager) GetPeerByTunnelIP(_ context.Context, _ string, _ net.IP) (*peer.Peer, error) {
|
||||
return m.peer, m.peerErr
|
||||
}
|
||||
@@ -350,6 +374,64 @@ func TestValidateUserGroupAccess(t *testing.T) {
|
||||
},
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "user pending approval denied despite group membership",
|
||||
domain: "app.example.com",
|
||||
userID: "user1",
|
||||
proxiesByAccount: map[string][]*service.Service{
|
||||
"account1": {{
|
||||
Domain: "app.example.com",
|
||||
AccountID: "account1",
|
||||
Auth: service.AuthConfig{
|
||||
BearerAuth: &service.BearerAuthConfig{
|
||||
Enabled: true,
|
||||
DistributionGroups: []string{"group1"},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
users: map[string]*types.User{
|
||||
// The approval flow stores a pending user as blocked as well.
|
||||
"user1": {Id: "user1", AccountID: "account1", AutoGroups: []string{"group1"}, Blocked: true, PendingApproval: true},
|
||||
},
|
||||
expectErr: true,
|
||||
expectErrMsg: "user pending approval",
|
||||
},
|
||||
{
|
||||
name: "blocked user denied despite group membership",
|
||||
domain: "app.example.com",
|
||||
userID: "user1",
|
||||
proxiesByAccount: map[string][]*service.Service{
|
||||
"account1": {{
|
||||
Domain: "app.example.com",
|
||||
AccountID: "account1",
|
||||
Auth: service.AuthConfig{
|
||||
BearerAuth: &service.BearerAuthConfig{
|
||||
Enabled: true,
|
||||
DistributionGroups: []string{"group1"},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
users: map[string]*types.User{
|
||||
"user1": {Id: "user1", AccountID: "account1", AutoGroups: []string{"group1"}, Blocked: true},
|
||||
},
|
||||
expectErr: true,
|
||||
expectErrMsg: "user blocked",
|
||||
},
|
||||
{
|
||||
name: "blocked user denied on a service with no auth configured",
|
||||
domain: "app.example.com",
|
||||
userID: "user1",
|
||||
proxiesByAccount: map[string][]*service.Service{
|
||||
"account1": {{Domain: "app.example.com", AccountID: "account1", Auth: service.AuthConfig{}}},
|
||||
},
|
||||
users: map[string]*types.User{
|
||||
"user1": {Id: "user1", AccountID: "account1", Blocked: true},
|
||||
},
|
||||
expectErr: true,
|
||||
expectErrMsg: "user blocked",
|
||||
},
|
||||
{
|
||||
name: "proxy manager error",
|
||||
domain: "app.example.com",
|
||||
@@ -421,17 +503,18 @@ func TestValidateTunnelPeerUserEmailEnrichment(t *testing.T) {
|
||||
storedUserNoEmail := map[string]*types.User{userID: {Id: userID, AccountID: accountID, Email: ""}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
peerUserID string
|
||||
storedUsers map[string]*types.User
|
||||
storedErr error
|
||||
noIdP bool
|
||||
idpEmail string
|
||||
idpHasData bool
|
||||
idpErr error
|
||||
expectEmail string
|
||||
expectUserID string
|
||||
expectIdPHit bool
|
||||
name string
|
||||
peerUserID string
|
||||
storedUsers map[string]*types.User
|
||||
storedErr error
|
||||
noIdP bool
|
||||
idpEmail string
|
||||
idpHasData bool
|
||||
idpErr error
|
||||
expectEmail string
|
||||
expectUserID string
|
||||
expectIdPHit bool
|
||||
expectDeniedReason string
|
||||
}{
|
||||
{
|
||||
name: "idp email wins over stored email",
|
||||
@@ -490,14 +573,17 @@ func TestValidateTunnelPeerUserEmailEnrichment(t *testing.T) {
|
||||
expectIdPHit: true,
|
||||
},
|
||||
{
|
||||
name: "idp email when stored user missing keeps peer.UserID as principal",
|
||||
peerUserID: userID,
|
||||
storedUsers: map[string]*types.User{},
|
||||
idpEmail: "idp@example.com",
|
||||
idpHasData: true,
|
||||
expectEmail: "idp@example.com",
|
||||
expectUserID: userID,
|
||||
expectIdPHit: true,
|
||||
// The identity still resolves from the IdP, but an owner the store
|
||||
// cannot resolve denies the fast-path rather than granting it.
|
||||
name: "idp email when stored user missing keeps peer.UserID as principal",
|
||||
peerUserID: userID,
|
||||
storedUsers: map[string]*types.User{},
|
||||
idpEmail: "idp@example.com",
|
||||
idpHasData: true,
|
||||
expectEmail: "idp@example.com",
|
||||
expectUserID: userID,
|
||||
expectIdPHit: true,
|
||||
expectDeniedReason: deniedReasonUserNotFound,
|
||||
},
|
||||
{
|
||||
name: "unlinked peer uses peer name and never consults idp",
|
||||
@@ -545,9 +631,13 @@ func TestValidateTunnelPeerUserEmailEnrichment(t *testing.T) {
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.True(t, resp.GetValid(), "expected access granted")
|
||||
assert.Equal(t, tt.expectDeniedReason == "", resp.GetValid(), "unexpected access decision")
|
||||
assert.Equal(t, tt.expectDeniedReason, resp.GetDeniedReason(), "unexpected denied reason")
|
||||
assert.Equal(t, tt.expectEmail, resp.GetUserEmail())
|
||||
assert.Equal(t, tt.expectUserID, resp.GetUserId())
|
||||
if tt.expectDeniedReason != "" {
|
||||
assert.Empty(t, resp.GetSessionToken(), "a denied peer must not receive a session token")
|
||||
}
|
||||
|
||||
if idpMock != nil {
|
||||
if tt.expectIdPHit {
|
||||
@@ -562,6 +652,193 @@ func TestValidateTunnelPeerUserEmailEnrichment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeniedReasonValues pins the wire values of the account status denied
|
||||
// reasons. The proxy logs them and operators filter access logs on them, so a
|
||||
// rename is a breaking change rather than an internal detail.
|
||||
// TestSameAccount pins the fail-closed behaviour of the account binding: an
|
||||
// unset account on either side must never compare equal into a grant.
|
||||
func TestSameAccount(t *testing.T) {
|
||||
assert.True(t, sameAccount("account1", "account1"), "matching accounts should bind")
|
||||
assert.False(t, sameAccount("account1", "account2"), "different accounts must not bind")
|
||||
assert.False(t, sameAccount("", ""), "two unset accounts must not bind")
|
||||
assert.False(t, sameAccount("account1", ""), "an unset service account must not bind")
|
||||
assert.False(t, sameAccount("", "account1"), "an unset user account must not bind")
|
||||
}
|
||||
|
||||
func TestDeniedReasonValues(t *testing.T) {
|
||||
assert.Equal(t, "pending_approval", deniedReasonPendingApproval, "pending approval denied reason wire value")
|
||||
assert.Equal(t, "user_blocked", deniedReasonUserBlocked, "blocked user denied reason wire value")
|
||||
assert.Equal(t, "user_not_found", deniedReasonUserNotFound, "unresolved user denied reason wire value")
|
||||
}
|
||||
|
||||
// TestValidateTunnelPeerOwnerStatus verifies that the mesh fast-path gates on
|
||||
// the account status of the peer's owning user. A peer whose owner was blocked
|
||||
// after the peer registered must lose access, while an unlinked machine peer
|
||||
// keeps it.
|
||||
func TestValidateTunnelPeerOwnerStatus(t *testing.T) {
|
||||
const (
|
||||
domain = "app.example.com"
|
||||
accountID = "account1"
|
||||
peerID = "peer1"
|
||||
peerName = "peer-display-name"
|
||||
userID = "user1"
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
peerUserID string
|
||||
owner *types.User
|
||||
expectDeniedReason string
|
||||
expectEmail string
|
||||
}{
|
||||
{
|
||||
name: "active owner allowed",
|
||||
peerUserID: userID,
|
||||
owner: &types.User{Id: userID, AccountID: accountID, Email: "user@example.com"},
|
||||
},
|
||||
{
|
||||
name: "owner pending approval denied",
|
||||
peerUserID: userID,
|
||||
owner: &types.User{Id: userID, AccountID: accountID, Email: "user@example.com", Blocked: true, PendingApproval: true},
|
||||
expectDeniedReason: deniedReasonPendingApproval,
|
||||
},
|
||||
{
|
||||
name: "owner blocked after registering the peer denied",
|
||||
peerUserID: userID,
|
||||
owner: &types.User{Id: userID, AccountID: accountID, Email: "user@example.com", Blocked: true},
|
||||
expectDeniedReason: deniedReasonUserBlocked,
|
||||
},
|
||||
{
|
||||
name: "unlinked machine peer stays allowed",
|
||||
peerUserID: "",
|
||||
owner: &types.User{Id: userID, AccountID: accountID, Blocked: true},
|
||||
},
|
||||
{
|
||||
// The user lookup is not account-scoped, so a peer row pointing at
|
||||
// another account's user must not resolve into an owner: the peer is
|
||||
// denied and the foreign email never reaches the response.
|
||||
name: "owner in another account denied and not disclosed",
|
||||
peerUserID: userID,
|
||||
owner: &types.User{Id: userID, AccountID: "otherAccount", Email: "foreign@example.com"},
|
||||
expectDeniedReason: deniedReasonUserNotFound,
|
||||
expectEmail: peerName,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc := &service.Service{Domain: domain, AccountID: accountID}
|
||||
usersManager := &mockUsersManager{users: map[string]*types.User{userID: tt.owner}}
|
||||
server := &ProxyServiceServer{
|
||||
serviceManager: &mockReverseProxyManager{
|
||||
proxiesByAccount: map[string][]*service.Service{accountID: {svc}},
|
||||
},
|
||||
peersManager: &mockTunnelPeersManager{
|
||||
peer: &peer.Peer{ID: peerID, Name: peerName, UserID: tt.peerUserID},
|
||||
},
|
||||
usersManager: usersManager,
|
||||
}
|
||||
|
||||
resp, err := server.ValidateTunnelPeer(context.Background(), &proto.ValidateTunnelPeerRequest{
|
||||
Domain: domain,
|
||||
TunnelIp: "100.64.0.1",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, tt.expectDeniedReason, resp.GetDeniedReason(), "unexpected denied reason")
|
||||
assert.Equal(t, tt.expectDeniedReason == "", resp.GetValid(), "unexpected access decision")
|
||||
if tt.expectDeniedReason != "" {
|
||||
assert.Empty(t, resp.GetSessionToken(), "a denied peer must not receive a session token")
|
||||
}
|
||||
|
||||
if tt.expectEmail != "" {
|
||||
assert.Equal(t, tt.expectEmail, resp.GetUserEmail(), "unexpected identity on the response")
|
||||
}
|
||||
|
||||
// The status gate and the identity resolution share one lookup;
|
||||
// an unlinked peer has no owner to look up at all.
|
||||
wantLookups := 1
|
||||
if tt.peerUserID == "" {
|
||||
wantLookups = 0
|
||||
}
|
||||
assert.Equal(t, wantLookups, usersManager.getUserCalls, "owner must be resolved exactly once per request")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateTunnelPeerRecordsActivity pins that a granted mesh request is
|
||||
// handed to the activity manager. Which of those the manager then writes is its
|
||||
// own decision, covered by its tests.
|
||||
func TestValidateTunnelPeerRecordsActivity(t *testing.T) {
|
||||
const (
|
||||
domain = "app.example.com"
|
||||
accountID = "account1"
|
||||
peerID = "peer1"
|
||||
)
|
||||
|
||||
activityManager := &mockActivityManager{}
|
||||
server := &ProxyServiceServer{
|
||||
activityManager: activityManager,
|
||||
serviceManager: &mockReverseProxyManager{
|
||||
proxiesByAccount: map[string][]*service.Service{
|
||||
accountID: {{Domain: domain, AccountID: accountID}},
|
||||
},
|
||||
},
|
||||
peersManager: &mockTunnelPeersManager{
|
||||
peer: &peer.Peer{ID: peerID, Name: "agent", Status: &peer.PeerStatus{LastSeen: time.Now().Add(-3 * time.Hour)}},
|
||||
},
|
||||
usersManager: &mockUsersManager{users: map[string]*types.User{}},
|
||||
}
|
||||
|
||||
resp, err := server.ValidateTunnelPeer(context.Background(), &proto.ValidateTunnelPeerRequest{
|
||||
Domain: domain,
|
||||
TunnelIp: "100.64.0.1",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.GetValid(), "peer should be granted access")
|
||||
|
||||
require.Len(t, activityManager.seenMarks, 1, "a granted peer should reach the activity manager once")
|
||||
assert.Equal(t, accountID, activityManager.seenMarks[0].accountID, "activity must be attributed to the service account")
|
||||
assert.Equal(t, peerID, activityManager.seenMarks[0].peerID, "activity must be attributed to the calling peer")
|
||||
}
|
||||
|
||||
// TestValidateTunnelPeerDeniedRecordsNoActivity keeps the write on the granted
|
||||
// path only: a refused peer is not evidence its owner was active.
|
||||
func TestValidateTunnelPeerDeniedRecordsNoActivity(t *testing.T) {
|
||||
const (
|
||||
domain = "app.example.com"
|
||||
accountID = "account1"
|
||||
)
|
||||
|
||||
activityManager := &mockActivityManager{}
|
||||
server := &ProxyServiceServer{
|
||||
activityManager: activityManager,
|
||||
serviceManager: &mockReverseProxyManager{
|
||||
proxiesByAccount: map[string][]*service.Service{
|
||||
accountID: {{Domain: domain, AccountID: accountID}},
|
||||
},
|
||||
},
|
||||
peersManager: &mockTunnelPeersManager{
|
||||
peer: &peer.Peer{ID: "peer1", Name: "agent", UserID: "user1", Status: &peer.PeerStatus{LastSeen: time.Now().Add(-3 * time.Hour)}},
|
||||
},
|
||||
// The owner is blocked, so the tunnel gate denies before the mint.
|
||||
usersManager: &mockUsersManager{users: map[string]*types.User{
|
||||
"user1": {Id: "user1", AccountID: accountID, Blocked: true},
|
||||
}},
|
||||
}
|
||||
|
||||
resp, err := server.ValidateTunnelPeer(context.Background(), &proto.ValidateTunnelPeerRequest{
|
||||
Domain: domain,
|
||||
TunnelIp: "100.64.0.1",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.False(t, resp.GetValid(), "blocked owner should be denied")
|
||||
assert.Empty(t, activityManager.seenMarks, "a denied peer must not be marked seen")
|
||||
}
|
||||
|
||||
func TestGetAccountProxyByDomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
@@ -1141,7 +1141,7 @@ func (s *Server) GetDeviceAuthorizationFlow(ctx context.Context, req *proto.Encr
|
||||
Provider: proto.DeviceAuthorizationFlowProvider(provider),
|
||||
ProviderConfig: &proto.ProviderConfig{
|
||||
ClientID: s.config.DeviceAuthorizationFlow.ProviderConfig.ClientID,
|
||||
ClientSecret: s.config.DeviceAuthorizationFlow.ProviderConfig.ClientSecret,
|
||||
ClientSecret: s.config.DeviceAuthorizationFlow.ProviderConfig.ClientSecret, //nolint:staticcheck
|
||||
Domain: s.config.DeviceAuthorizationFlow.ProviderConfig.Domain,
|
||||
Audience: s.config.DeviceAuthorizationFlow.ProviderConfig.Audience,
|
||||
DeviceAuthEndpoint: s.config.DeviceAuthorizationFlow.ProviderConfig.DeviceAuthEndpoint,
|
||||
@@ -1212,7 +1212,7 @@ func (s *Server) GetPKCEAuthorizationFlow(ctx context.Context, req *proto.Encryp
|
||||
ProviderConfig: &proto.ProviderConfig{
|
||||
Audience: s.config.PKCEAuthorizationFlow.ProviderConfig.Audience,
|
||||
ClientID: s.config.PKCEAuthorizationFlow.ProviderConfig.ClientID,
|
||||
ClientSecret: s.config.PKCEAuthorizationFlow.ProviderConfig.ClientSecret,
|
||||
ClientSecret: s.config.PKCEAuthorizationFlow.ProviderConfig.ClientSecret, //nolint:staticcheck
|
||||
TokenEndpoint: s.config.PKCEAuthorizationFlow.ProviderConfig.TokenEndpoint,
|
||||
AuthorizationEndpoint: s.config.PKCEAuthorizationFlow.ProviderConfig.AuthorizationEndpoint,
|
||||
Scope: s.config.PKCEAuthorizationFlow.ProviderConfig.Scope,
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
|
||||
@@ -46,6 +46,7 @@ func setupValidateSessionTest(t *testing.T) *validateSessionTestSetup {
|
||||
proxyService.SetServiceManager(serviceManager)
|
||||
|
||||
createTestProxies(t, ctx, testStore)
|
||||
createStatusTestUsers(t, ctx, testStore)
|
||||
|
||||
return &validateSessionTestSetup{
|
||||
proxyService: proxyService,
|
||||
@@ -91,6 +92,82 @@ func createTestProxies(t *testing.T, ctx context.Context, testStore store.Store)
|
||||
},
|
||||
}
|
||||
require.NoError(t, testStore.CreateService(ctx, restrictedProxy))
|
||||
|
||||
// Distributed to the account's "All" group, the configuration that hands a
|
||||
// service to every user in the account.
|
||||
allUsersProxy := &service.Service{
|
||||
ID: "allUsersProxyId",
|
||||
AccountID: "testAccountId",
|
||||
Name: "All Users Proxy",
|
||||
Domain: "all-users-proxy.example.com",
|
||||
Enabled: true,
|
||||
SessionPrivateKey: privKey,
|
||||
SessionPublicKey: pubKey,
|
||||
Auth: service.AuthConfig{
|
||||
BearerAuth: &service.BearerAuthConfig{
|
||||
Enabled: true,
|
||||
DistributionGroups: []string{allUsersGroupID},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, testStore.CreateService(ctx, allUsersProxy))
|
||||
}
|
||||
|
||||
const (
|
||||
allUsersGroupID = "allUsersGroupId"
|
||||
pendingUserID = "pendingUserId"
|
||||
blockedUserID = "blockedUserId"
|
||||
pendingAllUsersID = "pendingAllUsersUserId"
|
||||
)
|
||||
|
||||
// createStatusTestUsers adds the users whose account status must keep them out
|
||||
// of a proxy session. A user awaiting approval is persisted as both blocked and
|
||||
// pending approval, the way the approval flow stores one.
|
||||
func createStatusTestUsers(t *testing.T, ctx context.Context, testStore store.Store) {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, testStore.CreateGroup(ctx, &types.Group{
|
||||
ID: allUsersGroupID,
|
||||
AccountID: "testAccountId",
|
||||
Name: "All",
|
||||
Issued: types.GroupIssuedAPI,
|
||||
}))
|
||||
|
||||
users := []*types.User{
|
||||
{
|
||||
Id: pendingUserID,
|
||||
AccountID: "testAccountId",
|
||||
Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"allowedGroupId"},
|
||||
Blocked: true,
|
||||
PendingApproval: true,
|
||||
Issued: "api",
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
{
|
||||
Id: pendingAllUsersID,
|
||||
AccountID: "testAccountId",
|
||||
Role: types.UserRoleUser,
|
||||
AutoGroups: []string{allUsersGroupID},
|
||||
Blocked: true,
|
||||
PendingApproval: true,
|
||||
Issued: "api",
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
{
|
||||
Id: blockedUserID,
|
||||
AccountID: "testAccountId",
|
||||
Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"allowedGroupId"},
|
||||
Blocked: true,
|
||||
PendingApproval: false,
|
||||
Issued: "api",
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
for _, user := range users {
|
||||
require.NoError(t, testStore.SaveUser(ctx, user))
|
||||
}
|
||||
}
|
||||
|
||||
func generateSessionKeyPair(t *testing.T) (string, string) {
|
||||
@@ -149,6 +226,114 @@ func TestValidateSession_UserNotInAllowedGroup(t *testing.T) {
|
||||
assert.Empty(t, resp.GetPeerGroupIds(), "PeerGroupIds must mirror the resolved user's actual (empty) memberships on denial")
|
||||
}
|
||||
|
||||
// TestValidateSession_PendingApprovalUserDenied covers a user who is a member of
|
||||
// the service's distribution group but is still waiting for an administrator to
|
||||
// approve the account. Group membership alone must not open the service.
|
||||
func TestValidateSession_PendingApprovalUserDenied(t *testing.T) {
|
||||
setup := setupValidateSessionTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "restrictedProxyId")
|
||||
require.NoError(t, err)
|
||||
|
||||
token := createSessionToken(t, proxy.SessionPrivateKey, pendingUserID, "restricted-proxy.example.com")
|
||||
|
||||
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
||||
Domain: "restricted-proxy.example.com",
|
||||
SessionToken: token,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.Valid, "User pending approval should be denied")
|
||||
assert.Equal(t, deniedReasonPendingApproval, resp.DeniedReason, "Denied reason should name the pending approval state")
|
||||
assert.Equal(t, pendingUserID, resp.UserId, "Denial should identify the user it applies to")
|
||||
assert.Equal(t, []string{"allowedGroupId"}, resp.GetPeerGroupIds(), "PeerGroupIds must mirror the resolved user's group memberships on denial")
|
||||
assert.Equal(t, []string{"Allowed Group"}, resp.GetPeerGroupNames(), "PeerGroupNames must pair with PeerGroupIds on denial")
|
||||
}
|
||||
|
||||
// TestValidateSession_PendingApprovalUserInAllUsersGroupDenied covers the same
|
||||
// user against a service distributed to the account's "All" group, where every
|
||||
// user of the account is a member by default.
|
||||
func TestValidateSession_PendingApprovalUserInAllUsersGroupDenied(t *testing.T) {
|
||||
setup := setupValidateSessionTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "allUsersProxyId")
|
||||
require.NoError(t, err)
|
||||
|
||||
token := createSessionToken(t, proxy.SessionPrivateKey, pendingAllUsersID, "all-users-proxy.example.com")
|
||||
|
||||
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
||||
Domain: "all-users-proxy.example.com",
|
||||
SessionToken: token,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.Valid, "User pending approval should be denied even in the All Users group")
|
||||
assert.Equal(t, deniedReasonPendingApproval, resp.DeniedReason, "Denied reason should name the pending approval state")
|
||||
assert.Equal(t, pendingAllUsersID, resp.UserId, "Denial should identify the user it applies to")
|
||||
assert.Equal(t, []string{allUsersGroupID}, resp.GetPeerGroupIds(), "PeerGroupIds must mirror the resolved user's group memberships on denial")
|
||||
}
|
||||
|
||||
// TestValidateSession_BlockedUserDenied covers a user blocked after having been
|
||||
// approved, so PendingApproval is false and only the blocked flag is set.
|
||||
func TestValidateSession_BlockedUserDenied(t *testing.T) {
|
||||
setup := setupValidateSessionTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "restrictedProxyId")
|
||||
require.NoError(t, err)
|
||||
|
||||
token := createSessionToken(t, proxy.SessionPrivateKey, blockedUserID, "restricted-proxy.example.com")
|
||||
|
||||
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
||||
Domain: "restricted-proxy.example.com",
|
||||
SessionToken: token,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.Valid, "Blocked user should be denied")
|
||||
assert.Equal(t, deniedReasonUserBlocked, resp.DeniedReason, "Denied reason should name the blocked state")
|
||||
assert.Equal(t, blockedUserID, resp.UserId, "Denial should identify the user it applies to")
|
||||
}
|
||||
|
||||
// TestValidateSession_UserAllowedAfterApproval walks the same session token
|
||||
// through the approval transition: denied while pending, allowed once an
|
||||
// administrator clears both flags.
|
||||
func TestValidateSession_UserAllowedAfterApproval(t *testing.T) {
|
||||
setup := setupValidateSessionTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
proxy, err := setup.store.GetServiceByID(ctx, store.LockingStrengthNone, "testAccountId", "restrictedProxyId")
|
||||
require.NoError(t, err)
|
||||
|
||||
token := createSessionToken(t, proxy.SessionPrivateKey, pendingUserID, "restricted-proxy.example.com")
|
||||
req := &proto.ValidateSessionRequest{
|
||||
Domain: "restricted-proxy.example.com",
|
||||
SessionToken: token,
|
||||
}
|
||||
|
||||
resp, err := setup.proxyService.ValidateSession(ctx, req)
|
||||
require.NoError(t, err)
|
||||
require.False(t, resp.Valid, "User pending approval should be denied before approval")
|
||||
assert.Equal(t, deniedReasonPendingApproval, resp.DeniedReason, "Denied reason should name the pending approval state")
|
||||
|
||||
user, err := setup.store.GetUserByUserID(ctx, store.LockingStrengthNone, pendingUserID)
|
||||
require.NoError(t, err)
|
||||
user.PendingApproval = false
|
||||
user.Blocked = false
|
||||
require.NoError(t, setup.store.SaveUser(ctx, user))
|
||||
|
||||
resp, err = setup.proxyService.ValidateSession(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.Valid, "Approved user should be allowed access")
|
||||
assert.Empty(t, resp.DeniedReason)
|
||||
assert.Equal(t, pendingUserID, resp.UserId, "Approved user should be identified in the response")
|
||||
assert.Equal(t, []string{"allowedGroupId"}, resp.GetPeerGroupIds(), "PeerGroupIds must mirror the approved user's group memberships")
|
||||
}
|
||||
|
||||
func TestValidateSession_UserInDifferentAccount(t *testing.T) {
|
||||
setup := setupValidateSessionTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
Reference in New Issue
Block a user