mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-26 14:29:08 +01:00
[management] Record proxy logins and mesh activity for active-user accounting
Activity accounting counts a user as active from their last login or from a peer of theirs being seen. Neither timestamp moved when someone reached a service through the reverse proxy, so a person who only ever uses proxy-protected services and never opens the dashboard has no login on record at all and is skipped outright. The two proxy entry points mean different things, so they write different things. GenerateSessionToken is only reached after an ID token was verified, so a completed SSO sign-in records a login on the user. ValidateTunnelPeer authorises by tunnel IP with no IdP involved, so it records that the peer was seen instead; the owner counts through that. Both write on the granted path only, in UTC, and log and drop failures — no authorisation decision reads them back. The peer write is throttled to once an hour against the peer already in hand, so a busy peer does not rewrite its row behind every request. Both store methods update one column and leave the session-ownership fields to the sync stream that owns them. Peers that accounting excludes, embedded proxy peers and browser clients, are skipped rather than written for nothing.
This commit is contained in:
@@ -1672,7 +1672,7 @@ func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, u
|
||||
|
||||
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
|
||||
|
||||
return sessionkey.SignToken(
|
||||
token, err := sessionkey.SignToken(
|
||||
service.SessionPrivateKey,
|
||||
userID,
|
||||
user.Email,
|
||||
@@ -1682,6 +1682,28 @@ func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, u
|
||||
groupNames,
|
||||
proxyauth.DefaultSessionExpiry,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
s.recordUserLogin(ctx, service.AccountID, user)
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// recordUserLogin marks a completed reverse proxy SSO login on the user. The
|
||||
// timestamp is what activity accounting reads to count someone who only ever
|
||||
// reaches proxy-protected services and never opens the dashboard. Service users
|
||||
// have no interactive login to record. A failure is logged and dropped: the
|
||||
// next login marks it again and no authorization decision reads it.
|
||||
func (s *ProxyServiceServer) recordUserLogin(ctx context.Context, accountID string, user *types.User) {
|
||||
if user.IsServiceUser {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.usersManager.RefreshLastLogin(ctx, accountID, user.Id, time.Now().UTC()); 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.
|
||||
@@ -2031,6 +2053,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,
|
||||
@@ -2048,6 +2072,33 @@ func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto.
|
||||
}, nil
|
||||
}
|
||||
|
||||
// proxyPeerSeenInterval is how stale a peer's LastSeen must be before reaching
|
||||
// a private service refreshes it. Positive tunnel validations are cached on the
|
||||
// proxy for five minutes, so without a floor a busy peer would rewrite its row
|
||||
// all day; an hour still sits well inside the window activity accounting asks
|
||||
// about.
|
||||
const proxyPeerSeenInterval = time.Hour
|
||||
|
||||
// recordPeerSeen marks a peer as seen when it reaches a private service over
|
||||
// the mesh, which is what lets its owner count as active. Peers that activity
|
||||
// accounting excludes are skipped rather than written for nothing, and so is a
|
||||
// peer already seen inside the interval — the row is in hand, so the throttle
|
||||
// costs nothing. A failure is logged and dropped: the next request marks it
|
||||
// again.
|
||||
func (s *ProxyServiceServer) recordPeerSeen(ctx context.Context, accountID string, peer *peer.Peer) {
|
||||
if !peer.CountsTowardActivity() {
|
||||
return
|
||||
}
|
||||
|
||||
if peer.Status != nil && time.Since(peer.Status.LastSeen) < proxyPeerSeenInterval {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.peersManager.RefreshLastSeen(ctx, accountID, peer.ID, time.Now().UTC()); 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
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -122,6 +123,20 @@ type mockUsersManager struct {
|
||||
users map[string]*types.User
|
||||
err error
|
||||
getUserCalls int
|
||||
loginMarks []loginMark
|
||||
}
|
||||
|
||||
// loginMark records a RefreshLastLogin call so tests can assert what the proxy
|
||||
// wrote, rather than that it merely called something.
|
||||
type loginMark struct {
|
||||
accountID string
|
||||
userID string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func (m *mockUsersManager) RefreshLastLogin(_ context.Context, accountID, userID string, loginAt time.Time) error {
|
||||
m.loginMarks = append(m.loginMarks, loginMark{accountID: accountID, userID: userID, at: loginAt})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUsersManager) GetUser(ctx context.Context, userID string) (*types.User, error) {
|
||||
@@ -153,6 +168,19 @@ type mockTunnelPeersManager struct {
|
||||
peerErr error
|
||||
groups []*types.Group
|
||||
groupsErr error
|
||||
seenMarks []seenMark
|
||||
}
|
||||
|
||||
// seenMark records a RefreshLastSeen call.
|
||||
type seenMark struct {
|
||||
accountID string
|
||||
peerID string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func (m *mockTunnelPeersManager) RefreshLastSeen(_ context.Context, accountID, peerID string, seenAt time.Time) error {
|
||||
m.seenMarks = append(m.seenMarks, seenMark{accountID: accountID, peerID: peerID, at: seenAt})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockTunnelPeersManager) GetPeerByTunnelIP(_ context.Context, _ string, _ net.IP) (*peer.Peer, error) {
|
||||
@@ -745,6 +773,121 @@ func TestValidateTunnelPeerOwnerStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateTunnelPeerRecordsActivity covers the activity write on the mesh
|
||||
// fast-path: a peer reaching a private service is what lets its owner count as
|
||||
// active, but only peers that activity accounting actually counts are written,
|
||||
// and only once per interval.
|
||||
func TestValidateTunnelPeerRecordsActivity(t *testing.T) {
|
||||
const (
|
||||
domain = "app.example.com"
|
||||
accountID = "account1"
|
||||
peerID = "peer1"
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
peer *peer.Peer
|
||||
expectMark bool
|
||||
}{
|
||||
{
|
||||
name: "peer seen long ago is marked",
|
||||
peer: &peer.Peer{ID: peerID, Name: "agent", Status: &peer.PeerStatus{LastSeen: time.Now().Add(-3 * time.Hour)}},
|
||||
expectMark: true,
|
||||
},
|
||||
{
|
||||
name: "peer never seen is marked",
|
||||
peer: &peer.Peer{ID: peerID, Name: "agent", Status: &peer.PeerStatus{}},
|
||||
expectMark: true,
|
||||
},
|
||||
{
|
||||
// The throttle. The peer row is already in hand, so a recently seen
|
||||
// peer costs nothing to skip.
|
||||
name: "peer seen inside the interval is skipped",
|
||||
peer: &peer.Peer{ID: peerID, Name: "agent", Status: &peer.PeerStatus{LastSeen: time.Now().Add(-10 * time.Minute)}},
|
||||
expectMark: false,
|
||||
},
|
||||
{
|
||||
name: "embedded proxy peer is skipped",
|
||||
peer: &peer.Peer{ID: peerID, Name: "embedded", ProxyMeta: peer.ProxyMeta{Embedded: true}, Status: &peer.PeerStatus{LastSeen: time.Now().Add(-3 * time.Hour)}},
|
||||
expectMark: false,
|
||||
},
|
||||
{
|
||||
name: "browser client is skipped",
|
||||
peer: &peer.Peer{ID: peerID, Name: "browser", Meta: peer.PeerSystemMeta{KernelVersion: "wasm"}, Status: &peer.PeerStatus{LastSeen: time.Now().Add(-3 * time.Hour)}},
|
||||
expectMark: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
peersManager := &mockTunnelPeersManager{peer: tt.peer}
|
||||
server := &ProxyServiceServer{
|
||||
serviceManager: &mockReverseProxyManager{
|
||||
proxiesByAccount: map[string][]*service.Service{
|
||||
accountID: {{Domain: domain, AccountID: accountID}},
|
||||
},
|
||||
},
|
||||
peersManager: peersManager,
|
||||
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")
|
||||
|
||||
if !tt.expectMark {
|
||||
assert.Empty(t, peersManager.seenMarks, "peer should not have been marked seen")
|
||||
return
|
||||
}
|
||||
|
||||
require.Len(t, peersManager.seenMarks, 1, "peer should have been marked seen exactly once")
|
||||
mark := peersManager.seenMarks[0]
|
||||
assert.Equal(t, accountID, mark.accountID, "activity must be recorded against the service account")
|
||||
assert.Equal(t, peerID, mark.peerID, "activity must be recorded against the calling peer")
|
||||
assert.Equal(t, time.UTC, mark.at.Location(), "timestamps are written in UTC")
|
||||
assert.WithinDuration(t, time.Now().UTC(), mark.at, time.Minute, "seen timestamp should be now")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
peersManager := &mockTunnelPeersManager{
|
||||
peer: &peer.Peer{ID: "peer1", Name: "agent", UserID: "user1", Status: &peer.PeerStatus{LastSeen: time.Now().Add(-3 * time.Hour)}},
|
||||
}
|
||||
server := &ProxyServiceServer{
|
||||
serviceManager: &mockReverseProxyManager{
|
||||
proxiesByAccount: map[string][]*service.Service{
|
||||
accountID: {{Domain: domain, AccountID: accountID}},
|
||||
},
|
||||
},
|
||||
peersManager: peersManager,
|
||||
// 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, peersManager.seenMarks, "a denied peer must not be marked seen")
|
||||
}
|
||||
|
||||
func TestGetAccountProxyByDomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -587,6 +587,10 @@ type testValidateSessionUsersManager struct {
|
||||
store store.Store
|
||||
}
|
||||
|
||||
func (m *testValidateSessionUsersManager) RefreshLastLogin(ctx context.Context, accountID, userID string, loginAt time.Time) error {
|
||||
return m.store.RefreshUserLastLogin(ctx, accountID, userID, loginAt)
|
||||
}
|
||||
|
||||
func (m *testValidateSessionUsersManager) GetUser(ctx context.Context, userID string) (*types.User, error) {
|
||||
return m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user