mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-01 19:19:07 +02:00
Merge branch 'refs/heads/main' into prototype/reverse-proxy
# Conflicts: # management/server/activity/codes.go # management/server/http/handler.go # management/server/store/sql_store.go # management/server/store/store.go # shared/management/http/api/openapi.yml # shared/management/http/api/types.gen.go # shared/management/proto/management.pb.go
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
"github.com/netbirdio/netbird/shared/auth"
|
||||
|
||||
cacheStore "github.com/eko/gocache/lib/v4/store"
|
||||
@@ -70,6 +71,7 @@ type DefaultAccountManager struct {
|
||||
// cacheLoading keeps the accountIDs that are currently reloading. The accountID has to be removed once cache has been reloaded
|
||||
cacheLoading map[string]chan struct{}
|
||||
networkMapController network_map.Controller
|
||||
jobManager *job.Manager
|
||||
idpManager idp.Manager
|
||||
cacheManager *nbcache.AccountUserDataCache
|
||||
externalCacheManager nbcache.UserDataCache
|
||||
@@ -178,6 +180,7 @@ func BuildManager(
|
||||
config *nbconfig.Config,
|
||||
store store.Store,
|
||||
networkMapController network_map.Controller,
|
||||
jobManager *job.Manager,
|
||||
idpManager idp.Manager,
|
||||
singleAccountModeDomain string,
|
||||
eventStore activity.Store,
|
||||
@@ -200,6 +203,7 @@ func BuildManager(
|
||||
config: config,
|
||||
geo: geo,
|
||||
networkMapController: networkMapController,
|
||||
jobManager: jobManager,
|
||||
idpManager: idpManager,
|
||||
ctx: context.Background(),
|
||||
cacheMux: sync.Mutex{},
|
||||
|
||||
@@ -30,8 +30,15 @@ type Manager interface {
|
||||
autoGroups []string, usageLimit int, userID string, ephemeral bool, allowExtraDNSLabels bool) (*types.SetupKey, error)
|
||||
SaveSetupKey(ctx context.Context, accountID string, key *types.SetupKey, userID string) (*types.SetupKey, error)
|
||||
CreateUser(ctx context.Context, accountID, initiatorUserID string, key *types.UserInfo) (*types.UserInfo, error)
|
||||
CreateUserInvite(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error)
|
||||
AcceptUserInvite(ctx context.Context, token, password string) error
|
||||
RegenerateUserInvite(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error)
|
||||
GetUserInviteInfo(ctx context.Context, token string) (*types.UserInviteInfo, error)
|
||||
ListUserInvites(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error)
|
||||
DeleteUserInvite(ctx context.Context, accountID, initiatorUserID, inviteID string) error
|
||||
DeleteUser(ctx context.Context, accountID, initiatorUserID string, targetUserID string) error
|
||||
DeleteRegularUsers(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string, userInfos map[string]*types.UserInfo) error
|
||||
UpdateUserPassword(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error
|
||||
InviteUser(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) error
|
||||
ApproveUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) (*types.UserInfo, error)
|
||||
RejectUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) error
|
||||
@@ -129,4 +136,7 @@ type Manager interface {
|
||||
CreateIdentityProvider(ctx context.Context, accountID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error)
|
||||
UpdateIdentityProvider(ctx context.Context, accountID, idpID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error)
|
||||
DeleteIdentityProvider(ctx context.Context, accountID, idpID, userID string) error
|
||||
CreatePeerJob(ctx context.Context, accountID, peerID, userID string, job *types.Job) error
|
||||
GetAllPeerJobs(ctx context.Context, accountID, userID, peerID string) ([]*types.Job, error)
|
||||
GetPeerJobByID(ctx context.Context, accountID, userID, peerID, jobID string) (*types.Job, error)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/http/testing/testing_tools"
|
||||
"github.com/netbirdio/netbird/management/server/idp"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
|
||||
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
|
||||
networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
|
||||
@@ -3023,13 +3024,14 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU
|
||||
AnyTimes()
|
||||
|
||||
permissionsManager := permissions.NewManager(store)
|
||||
peersManager := peers.NewManager(store, permissionsManager)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := NewAccountRequestBuffer(ctx, store)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
|
||||
manager, err := BuildManager(ctx, &config.Config{}, store, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
|
||||
manager, err := BuildManager(ctx, &config.Config{}, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -195,9 +195,18 @@ const (
|
||||
DNSRecordUpdated Activity = 100
|
||||
DNSRecordDeleted Activity = 101
|
||||
|
||||
ServiceCreated Activity = 102
|
||||
ServiceUpdated Activity = 103
|
||||
ServiceDeleted Activity = 104
|
||||
JobCreatedByUser Activity = 102
|
||||
|
||||
UserPasswordChanged Activity = 103
|
||||
|
||||
UserInviteLinkCreated Activity = 104
|
||||
UserInviteLinkAccepted Activity = 105
|
||||
UserInviteLinkRegenerated Activity = 106
|
||||
UserInviteLinkDeleted Activity = 107
|
||||
|
||||
ServiceCreated Activity = 108
|
||||
ServiceUpdated Activity = 109
|
||||
ServiceDeleted Activity = 110
|
||||
|
||||
AccountDeleted Activity = 99999
|
||||
)
|
||||
@@ -324,6 +333,15 @@ var activityMap = map[Activity]Code{
|
||||
DNSRecordUpdated: {"DNS zone record updated", "dns.zone.record.update"},
|
||||
DNSRecordDeleted: {"DNS zone record deleted", "dns.zone.record.delete"},
|
||||
|
||||
JobCreatedByUser: {"Create Job for peer", "peer.job.create"},
|
||||
|
||||
UserPasswordChanged: {"User password changed", "user.password.change"},
|
||||
|
||||
UserInviteLinkCreated: {"User invite link created", "user.invite.link.create"},
|
||||
UserInviteLinkAccepted: {"User invite link accepted", "user.invite.link.accept"},
|
||||
UserInviteLinkRegenerated: {"User invite link regenerated", "user.invite.link.regenerate"},
|
||||
UserInviteLinkDeleted: {"User invite link deleted", "user.invite.link.delete"},
|
||||
|
||||
ServiceCreated: {"Service created", "service.create"},
|
||||
ServiceUpdated: {"Service updated", "service.update"},
|
||||
ServiceDeleted: {"Service deleted", "service.delete"},
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var iv = []byte{10, 22, 13, 79, 05, 8, 52, 91, 87, 98, 88, 98, 35, 25, 13, 05}
|
||||
|
||||
type FieldEncrypt struct {
|
||||
block cipher.Block
|
||||
gcm cipher.AEAD
|
||||
}
|
||||
|
||||
func GenerateKey() (string, error) {
|
||||
key := make([]byte, 32)
|
||||
_, err := rand.Read(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
readableKey := base64.StdEncoding.EncodeToString(key)
|
||||
return readableKey, nil
|
||||
}
|
||||
|
||||
func NewFieldEncrypt(key string) (*FieldEncrypt, error) {
|
||||
binKey, err := base64.StdEncoding.DecodeString(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(binKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ec := &FieldEncrypt{
|
||||
block: block,
|
||||
gcm: gcm,
|
||||
}
|
||||
|
||||
return ec, nil
|
||||
}
|
||||
|
||||
func (ec *FieldEncrypt) LegacyEncrypt(payload string) string {
|
||||
plainText := pkcs5Padding([]byte(payload))
|
||||
cipherText := make([]byte, len(plainText))
|
||||
cbc := cipher.NewCBCEncrypter(ec.block, iv)
|
||||
cbc.CryptBlocks(cipherText, plainText)
|
||||
return base64.StdEncoding.EncodeToString(cipherText)
|
||||
}
|
||||
|
||||
// Encrypt encrypts plaintext using AES-GCM
|
||||
func (ec *FieldEncrypt) Encrypt(payload string) (string, error) {
|
||||
plaintext := []byte(payload)
|
||||
nonceSize := ec.gcm.NonceSize()
|
||||
|
||||
nonce := make([]byte, nonceSize, len(plaintext)+nonceSize+ec.gcm.Overhead())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ciphertext := ec.gcm.Seal(nonce, nonce, plaintext, nil)
|
||||
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
func (ec *FieldEncrypt) LegacyDecrypt(data string) (string, error) {
|
||||
cipherText, err := base64.StdEncoding.DecodeString(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cbc := cipher.NewCBCDecrypter(ec.block, iv)
|
||||
cbc.CryptBlocks(cipherText, cipherText)
|
||||
payload, err := pkcs5UnPadding(cipherText)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(payload), nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts ciphertext using AES-GCM
|
||||
func (ec *FieldEncrypt) Decrypt(data string) (string, error) {
|
||||
cipherText, err := base64.StdEncoding.DecodeString(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonceSize := ec.gcm.NonceSize()
|
||||
if len(cipherText) < nonceSize {
|
||||
return "", errors.New("cipher text too short")
|
||||
}
|
||||
|
||||
nonce, cipherText := cipherText[:nonceSize], cipherText[nonceSize:]
|
||||
plainText, err := ec.gcm.Open(nil, nonce, cipherText, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(plainText), nil
|
||||
}
|
||||
|
||||
func pkcs5Padding(ciphertext []byte) []byte {
|
||||
padding := aes.BlockSize - len(ciphertext)%aes.BlockSize
|
||||
padText := bytes.Repeat([]byte{byte(padding)}, padding)
|
||||
return append(ciphertext, padText...)
|
||||
}
|
||||
func pkcs5UnPadding(src []byte) ([]byte, error) {
|
||||
srcLen := len(src)
|
||||
if srcLen == 0 {
|
||||
return nil, errors.New("input data is empty")
|
||||
}
|
||||
|
||||
paddingLen := int(src[srcLen-1])
|
||||
if paddingLen == 0 || paddingLen > aes.BlockSize || paddingLen > srcLen {
|
||||
return nil, errors.New("invalid padding size")
|
||||
}
|
||||
|
||||
// Verify that all padding bytes are the same
|
||||
for i := 0; i < paddingLen; i++ {
|
||||
if src[srcLen-1-i] != byte(paddingLen) {
|
||||
return nil, errors.New("invalid padding")
|
||||
}
|
||||
}
|
||||
|
||||
return src[:srcLen-paddingLen], nil
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateKey(t *testing.T) {
|
||||
testData := "exampl@netbird.io"
|
||||
key, err := GenerateKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate key: %s", err)
|
||||
}
|
||||
ee, err := NewFieldEncrypt(key)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init email encryption: %s", err)
|
||||
}
|
||||
|
||||
encrypted, err := ee.Encrypt(testData)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encrypt data: %s", err)
|
||||
}
|
||||
|
||||
if encrypted == "" {
|
||||
t.Fatalf("invalid encrypted text")
|
||||
}
|
||||
|
||||
decrypted, err := ee.Decrypt(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decrypt data: %s", err)
|
||||
}
|
||||
|
||||
if decrypted != testData {
|
||||
t.Fatalf("decrypted data is not match with test data: %s, %s", testData, decrypted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateKeyLegacy(t *testing.T) {
|
||||
testData := "exampl@netbird.io"
|
||||
key, err := GenerateKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate key: %s", err)
|
||||
}
|
||||
ee, err := NewFieldEncrypt(key)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init email encryption: %s", err)
|
||||
}
|
||||
|
||||
encrypted := ee.LegacyEncrypt(testData)
|
||||
if encrypted == "" {
|
||||
t.Fatalf("invalid encrypted text")
|
||||
}
|
||||
|
||||
decrypted, err := ee.LegacyDecrypt(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decrypt data: %s", err)
|
||||
}
|
||||
|
||||
if decrypted != testData {
|
||||
t.Fatalf("decrypted data is not match with test data: %s, %s", testData, decrypted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorruptKey(t *testing.T) {
|
||||
testData := "exampl@netbird.io"
|
||||
key, err := GenerateKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate key: %s", err)
|
||||
}
|
||||
ee, err := NewFieldEncrypt(key)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init email encryption: %s", err)
|
||||
}
|
||||
|
||||
encrypted, err := ee.Encrypt(testData)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encrypt data: %s", err)
|
||||
}
|
||||
|
||||
if encrypted == "" {
|
||||
t.Fatalf("invalid encrypted text")
|
||||
}
|
||||
|
||||
newKey, err := GenerateKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate key: %s", err)
|
||||
}
|
||||
|
||||
ee, err = NewFieldEncrypt(newKey)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init email encryption: %s", err)
|
||||
}
|
||||
|
||||
res, _ := ee.Decrypt(encrypted)
|
||||
if res == testData {
|
||||
t.Fatalf("incorrect decryption, the result is: %s", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt(t *testing.T) {
|
||||
// Generate a key for encryption/decryption
|
||||
key, err := GenerateKey()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate key: %v", err)
|
||||
}
|
||||
|
||||
// Initialize the FieldEncrypt with the generated key
|
||||
ec, err := NewFieldEncrypt(key)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create FieldEncrypt: %v", err)
|
||||
}
|
||||
|
||||
// Test cases
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{
|
||||
name: "Empty String",
|
||||
input: "",
|
||||
},
|
||||
{
|
||||
name: "Short String",
|
||||
input: "Hello",
|
||||
},
|
||||
{
|
||||
name: "String with Spaces",
|
||||
input: "Hello, World!",
|
||||
},
|
||||
{
|
||||
name: "Long String",
|
||||
input: "The quick brown fox jumps over the lazy dog.",
|
||||
},
|
||||
{
|
||||
name: "Unicode Characters",
|
||||
input: "こんにちは世界",
|
||||
},
|
||||
{
|
||||
name: "Special Characters",
|
||||
input: "!@#$%^&*()_+-=[]{}|;':\",./<>?",
|
||||
},
|
||||
{
|
||||
name: "Numeric String",
|
||||
input: "1234567890",
|
||||
},
|
||||
{
|
||||
name: "Repeated Characters",
|
||||
input: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
},
|
||||
{
|
||||
name: "Multi-block String",
|
||||
input: "This is a longer string that will span multiple blocks in the encryption algorithm.",
|
||||
},
|
||||
{
|
||||
name: "Non-ASCII and ASCII Mix",
|
||||
input: "Hello 世界 123",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name+" - Legacy", func(t *testing.T) {
|
||||
// Legacy Encryption
|
||||
encryptedLegacy := ec.LegacyEncrypt(tc.input)
|
||||
if encryptedLegacy == "" {
|
||||
t.Errorf("LegacyEncrypt returned empty string for input '%s'", tc.input)
|
||||
}
|
||||
|
||||
// Legacy Decryption
|
||||
decryptedLegacy, err := ec.LegacyDecrypt(encryptedLegacy)
|
||||
if err != nil {
|
||||
t.Errorf("LegacyDecrypt failed for input '%s': %v", tc.input, err)
|
||||
}
|
||||
|
||||
// Verify that the decrypted value matches the original input
|
||||
if decryptedLegacy != tc.input {
|
||||
t.Errorf("LegacyDecrypt output '%s' does not match original input '%s'", decryptedLegacy, tc.input)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(tc.name+" - New", func(t *testing.T) {
|
||||
// New Encryption
|
||||
encryptedNew, err := ec.Encrypt(tc.input)
|
||||
if err != nil {
|
||||
t.Errorf("Encrypt failed for input '%s': %v", tc.input, err)
|
||||
}
|
||||
if encryptedNew == "" {
|
||||
t.Errorf("Encrypt returned empty string for input '%s'", tc.input)
|
||||
}
|
||||
|
||||
// New Decryption
|
||||
decryptedNew, err := ec.Decrypt(encryptedNew)
|
||||
if err != nil {
|
||||
t.Errorf("Decrypt failed for input '%s': %v", tc.input, err)
|
||||
}
|
||||
|
||||
// Verify that the decrypted value matches the original input
|
||||
if decryptedNew != tc.input {
|
||||
t.Errorf("Decrypt output '%s' does not match original input '%s'", decryptedNew, tc.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPKCS5UnPadding(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []byte
|
||||
expected []byte
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "Valid Padding",
|
||||
input: append([]byte("Hello, World!"), bytes.Repeat([]byte{4}, 4)...),
|
||||
expected: []byte("Hello, World!"),
|
||||
},
|
||||
{
|
||||
name: "Empty Input",
|
||||
input: []byte{},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Padding Length Zero",
|
||||
input: append([]byte("Hello, World!"), bytes.Repeat([]byte{0}, 4)...),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Padding Length Exceeds Block Size",
|
||||
input: append([]byte("Hello, World!"), bytes.Repeat([]byte{17}, 17)...),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Padding Length Exceeds Input Length",
|
||||
input: []byte{5, 5, 5},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid Padding Bytes",
|
||||
input: append([]byte("Hello, World!"), []byte{2, 3, 4, 5}...),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Valid Single Byte Padding",
|
||||
input: append([]byte("Hello, World!"), byte(1)),
|
||||
expected: []byte("Hello, World!"),
|
||||
},
|
||||
{
|
||||
name: "Invalid Mixed Padding Bytes",
|
||||
input: append([]byte("Hello, World!"), []byte{3, 3, 2}...),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Valid Full Block Padding",
|
||||
input: append([]byte("Hello, World!"), bytes.Repeat([]byte{16}, 16)...),
|
||||
expected: []byte("Hello, World!"),
|
||||
},
|
||||
{
|
||||
name: "Non-Padding Byte at End",
|
||||
input: append([]byte("Hello, World!"), []byte{4, 4, 4, 5}...),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Valid Padding with Different Text Length",
|
||||
input: append([]byte("Test"), bytes.Repeat([]byte{12}, 12)...),
|
||||
expected: []byte("Test"),
|
||||
},
|
||||
{
|
||||
name: "Padding Length Equal to Input Length",
|
||||
input: bytes.Repeat([]byte{8}, 8),
|
||||
expected: []byte{},
|
||||
},
|
||||
{
|
||||
name: "Invalid Padding Length Zero (Again)",
|
||||
input: append([]byte("Test"), byte(0)),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Padding Length Greater Than Input",
|
||||
input: []byte{10},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Input Length Not Multiple of Block Size",
|
||||
input: append([]byte("Invalid Length"), byte(1)),
|
||||
expected: []byte("Invalid Length"),
|
||||
},
|
||||
{
|
||||
name: "Valid Padding with Non-ASCII Characters",
|
||||
input: append([]byte("こんにちは"), bytes.Repeat([]byte{2}, 2)...),
|
||||
expected: []byte("こんにちは"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := pkcs5UnPadding(tt.input)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error but got nil")
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Did not expect error but got: %v", err)
|
||||
}
|
||||
if !bytes.Equal(result, tt.expected) {
|
||||
t.Errorf("Expected output %v, got %v", tt.expected, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,10 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/migration"
|
||||
"github.com/netbirdio/netbird/util/crypt"
|
||||
)
|
||||
|
||||
func migrate(ctx context.Context, crypt *FieldEncrypt, db *gorm.DB) error {
|
||||
func migrate(ctx context.Context, crypt *crypt.FieldEncrypt, db *gorm.DB) error {
|
||||
migrations := getMigrations(ctx, crypt)
|
||||
|
||||
for _, m := range migrations {
|
||||
@@ -26,7 +27,7 @@ func migrate(ctx context.Context, crypt *FieldEncrypt, db *gorm.DB) error {
|
||||
|
||||
type migrationFunc func(*gorm.DB) error
|
||||
|
||||
func getMigrations(ctx context.Context, crypt *FieldEncrypt) []migrationFunc {
|
||||
func getMigrations(ctx context.Context, crypt *crypt.FieldEncrypt) []migrationFunc {
|
||||
return []migrationFunc{
|
||||
func(db *gorm.DB) error {
|
||||
return migration.MigrateNewField[activity.DeletedUser](ctx, db, "name", "")
|
||||
@@ -45,7 +46,7 @@ func getMigrations(ctx context.Context, crypt *FieldEncrypt) []migrationFunc {
|
||||
|
||||
// migrateLegacyEncryptedUsersToGCM migrates previously encrypted data using
|
||||
// legacy CBC encryption with a static IV to the new GCM encryption method.
|
||||
func migrateLegacyEncryptedUsersToGCM(ctx context.Context, db *gorm.DB, crypt *FieldEncrypt) error {
|
||||
func migrateLegacyEncryptedUsersToGCM(ctx context.Context, db *gorm.DB, crypt *crypt.FieldEncrypt) error {
|
||||
model := &activity.DeletedUser{}
|
||||
|
||||
if !db.Migrator().HasTable(model) {
|
||||
@@ -80,7 +81,7 @@ func migrateLegacyEncryptedUsersToGCM(ctx context.Context, db *gorm.DB, crypt *F
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateDeletedUserData(transaction *gorm.DB, user activity.DeletedUser, crypt *FieldEncrypt) error {
|
||||
func updateDeletedUserData(transaction *gorm.DB, user activity.DeletedUser, crypt *crypt.FieldEncrypt) error {
|
||||
var err error
|
||||
var decryptedEmail, decryptedName string
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/migration"
|
||||
"github.com/netbirdio/netbird/management/server/testutil"
|
||||
"github.com/netbirdio/netbird/util/crypt"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -40,10 +41,10 @@ func setupDatabase(t *testing.T) *gorm.DB {
|
||||
func TestMigrateLegacyEncryptedUsersToGCM(t *testing.T) {
|
||||
db := setupDatabase(t)
|
||||
|
||||
key, err := GenerateKey()
|
||||
key, err := crypt.GenerateKey()
|
||||
require.NoError(t, err, "Failed to generate key")
|
||||
|
||||
crypt, err := NewFieldEncrypt(key)
|
||||
crypt, err := crypt.NewFieldEncrypt(key)
|
||||
require.NoError(t, err, "Failed to initialize FieldEncrypt")
|
||||
|
||||
t.Run("empty table, no migration required", func(t *testing.T) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/util/crypt"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -45,12 +46,12 @@ type eventWithNames struct {
|
||||
// Store is the implementation of the activity.Store interface backed by SQLite
|
||||
type Store struct {
|
||||
db *gorm.DB
|
||||
fieldEncrypt *FieldEncrypt
|
||||
fieldEncrypt *crypt.FieldEncrypt
|
||||
}
|
||||
|
||||
// NewSqlStore creates a new Store with an event table if not exists.
|
||||
func NewSqlStore(ctx context.Context, dataDir string, encryptionKey string) (*Store, error) {
|
||||
crypt, err := NewFieldEncrypt(encryptionKey)
|
||||
fieldEncrypt, err := crypt.NewFieldEncrypt(encryptionKey)
|
||||
if err != nil {
|
||||
|
||||
return nil, err
|
||||
@@ -61,7 +62,7 @@ func NewSqlStore(ctx context.Context, dataDir string, encryptionKey string) (*St
|
||||
return nil, fmt.Errorf("initialize database: %w", err)
|
||||
}
|
||||
|
||||
if err = migrate(ctx, crypt, db); err != nil {
|
||||
if err = migrate(ctx, fieldEncrypt, db); err != nil {
|
||||
return nil, fmt.Errorf("events database migration: %w", err)
|
||||
}
|
||||
|
||||
@@ -72,7 +73,7 @@ func NewSqlStore(ctx context.Context, dataDir string, encryptionKey string) (*St
|
||||
|
||||
return &Store{
|
||||
db: db,
|
||||
fieldEncrypt: crypt,
|
||||
fieldEncrypt: fieldEncrypt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,12 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/util/crypt"
|
||||
)
|
||||
|
||||
func TestNewSqlStore(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
key, _ := GenerateKey()
|
||||
key, _ := crypt.GenerateKey()
|
||||
store, err := NewSqlStore(context.Background(), dataDir, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
|
||||
"github.com/netbirdio/netbird/management/internals/server/config"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/settings"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
@@ -221,13 +222,14 @@ func createDNSManager(t *testing.T) (*DefaultAccountManager, error) {
|
||||
// return empty extra settings for expected calls to UpdateAccountPeers
|
||||
settingsMockManager.EXPECT().GetExtraSettings(gomock.Any(), gomock.Any()).Return(&types.ExtraSettings{}, nil).AnyTimes()
|
||||
permissionsManager := permissions.NewManager(store)
|
||||
peersManager := peers.NewManager(store, permissionsManager)
|
||||
|
||||
ctx := context.Background()
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := NewAccountRequestBuffer(ctx, store)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.test", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
|
||||
|
||||
return BuildManager(context.Background(), nil, store, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
|
||||
return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
|
||||
}
|
||||
|
||||
func createDNSStore(t *testing.T) (store.Store, error) {
|
||||
|
||||
@@ -71,6 +71,13 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks
|
||||
if err := bypass.AddBypassPath("/api/setup"); err != nil {
|
||||
return nil, fmt.Errorf("failed to add bypass path: %w", err)
|
||||
}
|
||||
// Public invite endpoints (tokens start with nbi_)
|
||||
if err := bypass.AddBypassPath("/api/users/invites/nbi_*"); err != nil {
|
||||
return nil, fmt.Errorf("failed to add bypass path: %w", err)
|
||||
}
|
||||
if err := bypass.AddBypassPath("/api/users/invites/nbi_*/accept"); err != nil {
|
||||
return nil, fmt.Errorf("failed to add bypass path: %w", err)
|
||||
}
|
||||
|
||||
var rateLimitingConfig *middleware.RateLimiterConfig
|
||||
if os.Getenv(rateLimitingEnabledKey) == "true" {
|
||||
@@ -135,6 +142,8 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks
|
||||
accounts.AddEndpoints(accountManager, settingsManager, embeddedIdpEnabled, router)
|
||||
peers.AddEndpoints(accountManager, router, networkMapController)
|
||||
users.AddEndpoints(accountManager, router)
|
||||
users.AddInvitesEndpoints(accountManager, router)
|
||||
users.AddPublicInvitesEndpoints(accountManager, router)
|
||||
setup_keys.AddEndpoints(accountManager, router)
|
||||
policies.AddEndpoints(accountManager, LocationManager, router)
|
||||
policies.AddPostureCheckEndpoints(accountManager, LocationManager, router)
|
||||
@@ -148,6 +157,7 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks
|
||||
recordsManager.RegisterEndpoints(router, rManager)
|
||||
idp.AddEndpoints(accountManager, router)
|
||||
instance.AddEndpoints(instanceManager, router)
|
||||
instance.AddVersionEndpoint(instanceManager, router)
|
||||
services.RegisterEndpoints(router, serviceManager)
|
||||
|
||||
// Mount embedded IdP handler at /oauth2 path if configured
|
||||
|
||||
@@ -28,6 +28,15 @@ func AddEndpoints(instanceManager nbinstance.Manager, router *mux.Router) {
|
||||
router.HandleFunc("/setup", h.setup).Methods("POST", "OPTIONS")
|
||||
}
|
||||
|
||||
// AddVersionEndpoint registers the authenticated version endpoint.
|
||||
func AddVersionEndpoint(instanceManager nbinstance.Manager, router *mux.Router) {
|
||||
h := &handler{
|
||||
instanceManager: instanceManager,
|
||||
}
|
||||
|
||||
router.HandleFunc("/instance/version", h.getVersionInfo).Methods("GET", "OPTIONS")
|
||||
}
|
||||
|
||||
// getInstanceStatus returns the instance status including whether setup is required.
|
||||
// This endpoint is unauthenticated.
|
||||
func (h *handler) getInstanceStatus(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -65,3 +74,29 @@ func (h *handler) setup(w http.ResponseWriter, r *http.Request) {
|
||||
Email: userData.Email,
|
||||
})
|
||||
}
|
||||
|
||||
// getVersionInfo returns version information for NetBird components.
|
||||
// This endpoint requires authentication.
|
||||
func (h *handler) getVersionInfo(w http.ResponseWriter, r *http.Request) {
|
||||
versionInfo, err := h.instanceManager.GetVersionInfo(r.Context())
|
||||
if err != nil {
|
||||
log.WithContext(r.Context()).Errorf("failed to get version info: %v", err)
|
||||
util.WriteErrorResponse("failed to get version info", http.StatusInternalServerError, w)
|
||||
return
|
||||
}
|
||||
|
||||
resp := api.InstanceVersionInfo{
|
||||
ManagementCurrentVersion: versionInfo.CurrentVersion,
|
||||
ManagementUpdateAvailable: versionInfo.ManagementUpdateAvailable,
|
||||
}
|
||||
|
||||
if versionInfo.DashboardVersion != "" {
|
||||
resp.DashboardAvailableVersion = &versionInfo.DashboardVersion
|
||||
}
|
||||
|
||||
if versionInfo.ManagementVersion != "" {
|
||||
resp.ManagementAvailableVersion = &versionInfo.ManagementVersion
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, resp)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ type mockInstanceManager struct {
|
||||
isSetupRequired bool
|
||||
isSetupRequiredFn func(ctx context.Context) (bool, error)
|
||||
createOwnerUserFn func(ctx context.Context, email, password, name string) (*idp.UserData, error)
|
||||
getVersionInfoFn func(ctx context.Context) (*nbinstance.VersionInfo, error)
|
||||
}
|
||||
|
||||
func (m *mockInstanceManager) IsSetupRequired(ctx context.Context) (bool, error) {
|
||||
@@ -66,6 +67,18 @@ func (m *mockInstanceManager) CreateOwnerUser(ctx context.Context, email, passwo
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockInstanceManager) GetVersionInfo(ctx context.Context) (*nbinstance.VersionInfo, error) {
|
||||
if m.getVersionInfoFn != nil {
|
||||
return m.getVersionInfoFn(ctx)
|
||||
}
|
||||
return &nbinstance.VersionInfo{
|
||||
CurrentVersion: "0.34.0",
|
||||
DashboardVersion: "2.0.0",
|
||||
ManagementVersion: "0.35.0",
|
||||
ManagementUpdateAvailable: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var _ nbinstance.Manager = (*mockInstanceManager)(nil)
|
||||
|
||||
func setupTestRouter(manager nbinstance.Manager) *mux.Router {
|
||||
@@ -279,3 +292,44 @@ func TestSetup_ManagerError(t *testing.T) {
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||
}
|
||||
|
||||
func TestGetVersionInfo_Success(t *testing.T) {
|
||||
manager := &mockInstanceManager{}
|
||||
router := mux.NewRouter()
|
||||
AddVersionEndpoint(manager, router)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/instance/version", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var response api.InstanceVersionInfo
|
||||
err := json.NewDecoder(rec.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "0.34.0", response.ManagementCurrentVersion)
|
||||
assert.NotNil(t, response.DashboardAvailableVersion)
|
||||
assert.Equal(t, "2.0.0", *response.DashboardAvailableVersion)
|
||||
assert.NotNil(t, response.ManagementAvailableVersion)
|
||||
assert.Equal(t, "0.35.0", *response.ManagementAvailableVersion)
|
||||
assert.True(t, response.ManagementUpdateAvailable)
|
||||
}
|
||||
|
||||
func TestGetVersionInfo_Error(t *testing.T) {
|
||||
manager := &mockInstanceManager{
|
||||
getVersionInfoFn: func(ctx context.Context) (*nbinstance.VersionInfo, error) {
|
||||
return nil, errors.New("failed to fetch versions")
|
||||
},
|
||||
}
|
||||
router := mux.NewRouter()
|
||||
AddVersionEndpoint(manager, router)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/instance/version", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ func AddEndpoints(accountManager account.Manager, router *mux.Router, networkMap
|
||||
Methods("GET", "PUT", "DELETE", "OPTIONS")
|
||||
router.HandleFunc("/peers/{peerId}/accessible-peers", peersHandler.GetAccessiblePeers).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/peers/{peerId}/temporary-access", peersHandler.CreateTemporaryAccess).Methods("POST", "OPTIONS")
|
||||
router.HandleFunc("/peers/{peerId}/jobs", peersHandler.ListJobs).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/peers/{peerId}/jobs", peersHandler.CreateJob).Methods("POST", "OPTIONS")
|
||||
router.HandleFunc("/peers/{peerId}/jobs/{jobId}", peersHandler.GetJob).Methods("GET", "OPTIONS")
|
||||
}
|
||||
|
||||
// NewHandler creates a new peers Handler
|
||||
@@ -46,6 +49,99 @@ func NewHandler(accountManager account.Manager, networkMapController network_map
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) CreateJob(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(ctx)
|
||||
if err != nil {
|
||||
util.WriteError(ctx, err, w)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
peerID := vars["peerId"]
|
||||
|
||||
req := &api.JobRequest{}
|
||||
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
|
||||
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
job, err := types.NewJob(userAuth.UserId, userAuth.AccountId, peerID, req)
|
||||
if err != nil {
|
||||
util.WriteError(ctx, err, w)
|
||||
return
|
||||
}
|
||||
if err := h.accountManager.CreatePeerJob(ctx, userAuth.AccountId, peerID, userAuth.UserId, job); err != nil {
|
||||
util.WriteError(ctx, err, w)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := toSingleJobResponse(job)
|
||||
if err != nil {
|
||||
util.WriteError(ctx, err, w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(ctx, w, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) ListJobs(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(ctx)
|
||||
if err != nil {
|
||||
util.WriteError(ctx, err, w)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
peerID := vars["peerId"]
|
||||
|
||||
jobs, err := h.accountManager.GetAllPeerJobs(ctx, userAuth.AccountId, userAuth.UserId, peerID)
|
||||
if err != nil {
|
||||
util.WriteError(ctx, err, w)
|
||||
return
|
||||
}
|
||||
|
||||
respBody := make([]*api.JobResponse, 0, len(jobs))
|
||||
for _, job := range jobs {
|
||||
resp, err := toSingleJobResponse(job)
|
||||
if err != nil {
|
||||
util.WriteError(ctx, err, w)
|
||||
return
|
||||
}
|
||||
respBody = append(respBody, resp)
|
||||
}
|
||||
|
||||
util.WriteJSONObject(ctx, w, respBody)
|
||||
}
|
||||
|
||||
func (h *Handler) GetJob(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(ctx)
|
||||
if err != nil {
|
||||
util.WriteError(ctx, err, w)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
peerID := vars["peerId"]
|
||||
jobID := vars["jobId"]
|
||||
|
||||
job, err := h.accountManager.GetPeerJobByID(ctx, userAuth.AccountId, userAuth.UserId, peerID, jobID)
|
||||
if err != nil {
|
||||
util.WriteError(ctx, err, w)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := toSingleJobResponse(job)
|
||||
if err != nil {
|
||||
util.WriteError(ctx, err, w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(ctx, w, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) getPeer(ctx context.Context, accountID, peerID, userID string, w http.ResponseWriter) {
|
||||
peer, err := h.accountManager.GetPeer(ctx, accountID, peerID, userID)
|
||||
if err != nil {
|
||||
@@ -521,6 +617,28 @@ func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dn
|
||||
}
|
||||
}
|
||||
|
||||
func toSingleJobResponse(job *types.Job) (*api.JobResponse, error) {
|
||||
workload, err := job.BuildWorkloadResponse()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var failed *string
|
||||
if job.FailedReason != "" {
|
||||
failed = &job.FailedReason
|
||||
}
|
||||
|
||||
return &api.JobResponse{
|
||||
Id: job.ID,
|
||||
CreatedAt: job.CreatedAt,
|
||||
CompletedAt: job.CompletedAt,
|
||||
TriggeredBy: job.TriggeredBy,
|
||||
Status: api.JobResponseStatus(job.Status),
|
||||
FailedReason: failed,
|
||||
Workload: *workload,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fqdn(peer *nbpeer.Peer, dnsDomain string) string {
|
||||
fqdn := peer.FQDN(dnsDomain)
|
||||
if fqdn == "" {
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/management/server/http/middleware"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// publicInviteRateLimiter limits public invite requests by IP address to prevent brute-force attacks
|
||||
var publicInviteRateLimiter = middleware.NewAPIRateLimiter(&middleware.RateLimiterConfig{
|
||||
RequestsPerMinute: 10, // 10 attempts per minute per IP
|
||||
Burst: 5, // Allow burst of 5 requests
|
||||
CleanupInterval: 10 * time.Minute,
|
||||
LimiterTTL: 30 * time.Minute,
|
||||
})
|
||||
|
||||
// toUserInviteResponse converts a UserInvite to an API response.
|
||||
func toUserInviteResponse(invite *types.UserInvite) api.UserInvite {
|
||||
autoGroups := invite.UserInfo.AutoGroups
|
||||
if autoGroups == nil {
|
||||
autoGroups = []string{}
|
||||
}
|
||||
var inviteLink *string
|
||||
if invite.InviteToken != "" {
|
||||
inviteLink = &invite.InviteToken
|
||||
}
|
||||
return api.UserInvite{
|
||||
Id: invite.UserInfo.ID,
|
||||
Email: invite.UserInfo.Email,
|
||||
Name: invite.UserInfo.Name,
|
||||
Role: invite.UserInfo.Role,
|
||||
AutoGroups: autoGroups,
|
||||
ExpiresAt: invite.InviteExpiresAt.UTC(),
|
||||
CreatedAt: invite.InviteCreatedAt.UTC(),
|
||||
Expired: time.Now().After(invite.InviteExpiresAt),
|
||||
InviteToken: inviteLink,
|
||||
}
|
||||
}
|
||||
|
||||
// invitesHandler handles user invite operations
|
||||
type invitesHandler struct {
|
||||
accountManager account.Manager
|
||||
}
|
||||
|
||||
// AddInvitesEndpoints registers invite-related endpoints
|
||||
func AddInvitesEndpoints(accountManager account.Manager, router *mux.Router) {
|
||||
h := &invitesHandler{accountManager: accountManager}
|
||||
|
||||
// Authenticated endpoints (require admin)
|
||||
router.HandleFunc("/users/invites", h.listInvites).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/users/invites", h.createInvite).Methods("POST", "OPTIONS")
|
||||
router.HandleFunc("/users/invites/{inviteId}", h.deleteInvite).Methods("DELETE", "OPTIONS")
|
||||
router.HandleFunc("/users/invites/{inviteId}/regenerate", h.regenerateInvite).Methods("POST", "OPTIONS")
|
||||
}
|
||||
|
||||
// AddPublicInvitesEndpoints registers public (unauthenticated) invite endpoints with rate limiting
|
||||
func AddPublicInvitesEndpoints(accountManager account.Manager, router *mux.Router) {
|
||||
h := &invitesHandler{accountManager: accountManager}
|
||||
|
||||
// Create a subrouter for public invite endpoints with rate limiting middleware
|
||||
publicRouter := router.PathPrefix("/users/invites").Subrouter()
|
||||
publicRouter.Use(publicInviteRateLimiter.Middleware)
|
||||
|
||||
// Public endpoints (no auth required, protected by token and rate limited)
|
||||
publicRouter.HandleFunc("/{token}", h.getInviteInfo).Methods("GET", "OPTIONS")
|
||||
publicRouter.HandleFunc("/{token}/accept", h.acceptInvite).Methods("POST", "OPTIONS")
|
||||
}
|
||||
|
||||
// listInvites handles GET /api/users/invites
|
||||
func (h *invitesHandler) listInvites(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
invites, err := h.accountManager.ListUserInvites(r.Context(), userAuth.AccountId, userAuth.UserId)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]api.UserInvite, 0, len(invites))
|
||||
for _, invite := range invites {
|
||||
resp = append(resp, toUserInviteResponse(invite))
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, resp)
|
||||
}
|
||||
|
||||
// createInvite handles POST /api/users/invites
|
||||
func (h *invitesHandler) createInvite(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
var req api.UserInviteCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
invite := &types.UserInfo{
|
||||
Email: req.Email,
|
||||
Name: req.Name,
|
||||
Role: req.Role,
|
||||
AutoGroups: req.AutoGroups,
|
||||
}
|
||||
|
||||
expiresIn := 0
|
||||
if req.ExpiresIn != nil {
|
||||
expiresIn = *req.ExpiresIn
|
||||
}
|
||||
|
||||
result, err := h.accountManager.CreateUserInvite(r.Context(), userAuth.AccountId, userAuth.UserId, invite, expiresIn)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
result.InviteCreatedAt = time.Now().UTC()
|
||||
resp := toUserInviteResponse(result)
|
||||
util.WriteJSONObject(r.Context(), w, &resp)
|
||||
}
|
||||
|
||||
// getInviteInfo handles GET /api/users/invites/{token}
|
||||
func (h *invitesHandler) getInviteInfo(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
vars := mux.Vars(r)
|
||||
token := vars["token"]
|
||||
if token == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "token is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := h.accountManager.GetUserInviteInfo(r.Context(), token)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := info.ExpiresAt.UTC()
|
||||
util.WriteJSONObject(r.Context(), w, &api.UserInviteInfo{
|
||||
Email: info.Email,
|
||||
Name: info.Name,
|
||||
ExpiresAt: expiresAt,
|
||||
Valid: info.Valid,
|
||||
InvitedBy: info.InvitedBy,
|
||||
})
|
||||
}
|
||||
|
||||
// acceptInvite handles POST /api/users/invites/{token}/accept
|
||||
func (h *invitesHandler) acceptInvite(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
vars := mux.Vars(r)
|
||||
token := vars["token"]
|
||||
if token == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "token is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
var req api.UserInviteAcceptRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
err := h.accountManager.AcceptUserInvite(r.Context(), token, req.Password)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, &api.UserInviteAcceptResponse{Success: true})
|
||||
}
|
||||
|
||||
// regenerateInvite handles POST /api/users/invites/{inviteId}/regenerate
|
||||
func (h *invitesHandler) regenerateInvite(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
|
||||
return
|
||||
}
|
||||
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
inviteID := vars["inviteId"]
|
||||
if inviteID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invite ID is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
var req api.UserInviteRegenerateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
// Allow empty body (io.EOF) - expiresIn is optional
|
||||
if !errors.Is(err, io.EOF) {
|
||||
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
expiresIn := 0
|
||||
if req.ExpiresIn != nil {
|
||||
expiresIn = *req.ExpiresIn
|
||||
}
|
||||
|
||||
result, err := h.accountManager.RegenerateUserInvite(r.Context(), userAuth.AccountId, userAuth.UserId, inviteID, expiresIn)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := result.InviteExpiresAt.UTC()
|
||||
util.WriteJSONObject(r.Context(), w, &api.UserInviteRegenerateResponse{
|
||||
InviteToken: result.InviteToken,
|
||||
InviteExpiresAt: expiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
// deleteInvite handles DELETE /api/users/invites/{inviteId}
|
||||
func (h *invitesHandler) deleteInvite(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
inviteID := vars["inviteId"]
|
||||
if inviteID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invite ID is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.accountManager.DeleteUserInvite(r.Context(), userAuth.AccountId, userAuth.UserId, inviteID)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/management/server/mock_server"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/auth"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
const (
|
||||
testAccountID = "test-account-id"
|
||||
testUserID = "test-user-id"
|
||||
testInviteID = "test-invite-id"
|
||||
testInviteToken = "nbi_testtoken123456789012345678"
|
||||
testEmail = "invite@example.com"
|
||||
testName = "Test User"
|
||||
)
|
||||
|
||||
func setupInvitesTestHandler(am *mock_server.MockAccountManager) *invitesHandler {
|
||||
return &invitesHandler{
|
||||
accountManager: am,
|
||||
}
|
||||
}
|
||||
|
||||
func TestListInvites(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
testInvites := []*types.UserInvite{
|
||||
{
|
||||
UserInfo: &types.UserInfo{
|
||||
ID: "invite-1",
|
||||
Email: "user1@example.com",
|
||||
Name: "User One",
|
||||
Role: "user",
|
||||
AutoGroups: []string{"group-1"},
|
||||
},
|
||||
InviteExpiresAt: now.Add(24 * time.Hour),
|
||||
InviteCreatedAt: now,
|
||||
},
|
||||
{
|
||||
UserInfo: &types.UserInfo{
|
||||
ID: "invite-2",
|
||||
Email: "user2@example.com",
|
||||
Name: "User Two",
|
||||
Role: "admin",
|
||||
AutoGroups: nil,
|
||||
},
|
||||
InviteExpiresAt: now.Add(-1 * time.Hour), // Expired
|
||||
InviteCreatedAt: now.Add(-48 * time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
tt := []struct {
|
||||
name string
|
||||
expectedStatus int
|
||||
mockFunc func(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error)
|
||||
expectedCount int
|
||||
}{
|
||||
{
|
||||
name: "successful list",
|
||||
expectedStatus: http.StatusOK,
|
||||
mockFunc: func(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error) {
|
||||
return testInvites, nil
|
||||
},
|
||||
expectedCount: 2,
|
||||
},
|
||||
{
|
||||
name: "empty list",
|
||||
expectedStatus: http.StatusOK,
|
||||
mockFunc: func(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error) {
|
||||
return []*types.UserInvite{}, nil
|
||||
},
|
||||
expectedCount: 0,
|
||||
},
|
||||
{
|
||||
name: "permission denied",
|
||||
expectedStatus: http.StatusForbidden,
|
||||
mockFunc: func(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error) {
|
||||
return nil, status.NewPermissionDeniedError()
|
||||
},
|
||||
expectedCount: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
am := &mock_server.MockAccountManager{
|
||||
ListUserInvitesFunc: tc.mockFunc,
|
||||
}
|
||||
handler := setupInvitesTestHandler(am)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/users/invites", nil)
|
||||
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
|
||||
UserId: testUserID,
|
||||
AccountId: testAccountID,
|
||||
})
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.listInvites(rr, req)
|
||||
|
||||
assert.Equal(t, tc.expectedStatus, rr.Code)
|
||||
|
||||
if tc.expectedStatus == http.StatusOK {
|
||||
var resp []api.UserInvite
|
||||
err := json.NewDecoder(rr.Body).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, resp, tc.expectedCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateInvite(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
expiresAt := now.Add(72 * time.Hour)
|
||||
|
||||
tt := []struct {
|
||||
name string
|
||||
requestBody string
|
||||
expectedStatus int
|
||||
mockFunc func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error)
|
||||
}{
|
||||
{
|
||||
name: "successful create",
|
||||
requestBody: `{"email":"test@example.com","name":"Test User","role":"user","auto_groups":["group-1"]}`,
|
||||
expectedStatus: http.StatusOK,
|
||||
mockFunc: func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) {
|
||||
return &types.UserInvite{
|
||||
UserInfo: &types.UserInfo{
|
||||
ID: testInviteID,
|
||||
Email: invite.Email,
|
||||
Name: invite.Name,
|
||||
Role: invite.Role,
|
||||
AutoGroups: invite.AutoGroups,
|
||||
Status: string(types.UserStatusInvited),
|
||||
},
|
||||
InviteToken: testInviteToken,
|
||||
InviteExpiresAt: expiresAt,
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful create with custom expiration",
|
||||
requestBody: `{"email":"test@example.com","name":"Test User","role":"admin","auto_groups":[],"expires_in":3600}`,
|
||||
expectedStatus: http.StatusOK,
|
||||
mockFunc: func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) {
|
||||
assert.Equal(t, 3600, expiresIn)
|
||||
return &types.UserInvite{
|
||||
UserInfo: &types.UserInfo{
|
||||
ID: testInviteID,
|
||||
Email: invite.Email,
|
||||
Name: invite.Name,
|
||||
Role: invite.Role,
|
||||
AutoGroups: []string{},
|
||||
Status: string(types.UserStatusInvited),
|
||||
},
|
||||
InviteToken: testInviteToken,
|
||||
InviteExpiresAt: expiresAt,
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user already exists",
|
||||
requestBody: `{"email":"existing@example.com","name":"Existing User","role":"user","auto_groups":[]}`,
|
||||
expectedStatus: http.StatusConflict,
|
||||
mockFunc: func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) {
|
||||
return nil, status.Errorf(status.UserAlreadyExists, "user with this email already exists")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invite already exists",
|
||||
requestBody: `{"email":"invited@example.com","name":"Invited User","role":"user","auto_groups":[]}`,
|
||||
expectedStatus: http.StatusConflict,
|
||||
mockFunc: func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) {
|
||||
return nil, status.Errorf(status.AlreadyExists, "invite already exists for this email")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "permission denied",
|
||||
requestBody: `{"email":"test@example.com","name":"Test User","role":"user","auto_groups":[]}`,
|
||||
expectedStatus: http.StatusForbidden,
|
||||
mockFunc: func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) {
|
||||
return nil, status.NewPermissionDeniedError()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "embedded IDP not enabled",
|
||||
requestBody: `{"email":"test@example.com","name":"Test User","role":"user","auto_groups":[]}`,
|
||||
expectedStatus: http.StatusPreconditionFailed,
|
||||
mockFunc: func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) {
|
||||
return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
requestBody: `{invalid json}`,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
mockFunc: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
am := &mock_server.MockAccountManager{
|
||||
CreateUserInviteFunc: tc.mockFunc,
|
||||
}
|
||||
handler := setupInvitesTestHandler(am)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/users/invites", bytes.NewBufferString(tc.requestBody))
|
||||
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
|
||||
UserId: testUserID,
|
||||
AccountId: testAccountID,
|
||||
})
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.createInvite(rr, req)
|
||||
|
||||
assert.Equal(t, tc.expectedStatus, rr.Code)
|
||||
|
||||
if tc.expectedStatus == http.StatusOK {
|
||||
var resp api.UserInvite
|
||||
err := json.NewDecoder(rr.Body).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testInviteID, resp.Id)
|
||||
assert.NotNil(t, resp.InviteToken)
|
||||
assert.NotEmpty(t, *resp.InviteToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInviteInfo(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
|
||||
tt := []struct {
|
||||
name string
|
||||
token string
|
||||
expectedStatus int
|
||||
mockFunc func(ctx context.Context, token string) (*types.UserInviteInfo, error)
|
||||
}{
|
||||
{
|
||||
name: "successful get valid invite",
|
||||
token: testInviteToken,
|
||||
expectedStatus: http.StatusOK,
|
||||
mockFunc: func(ctx context.Context, token string) (*types.UserInviteInfo, error) {
|
||||
return &types.UserInviteInfo{
|
||||
Email: testEmail,
|
||||
Name: testName,
|
||||
ExpiresAt: now.Add(24 * time.Hour),
|
||||
Valid: true,
|
||||
InvitedBy: "Admin User",
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful get expired invite",
|
||||
token: testInviteToken,
|
||||
expectedStatus: http.StatusOK,
|
||||
mockFunc: func(ctx context.Context, token string) (*types.UserInviteInfo, error) {
|
||||
return &types.UserInviteInfo{
|
||||
Email: testEmail,
|
||||
Name: testName,
|
||||
ExpiresAt: now.Add(-24 * time.Hour),
|
||||
Valid: false,
|
||||
InvitedBy: "Admin User",
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invite not found",
|
||||
token: "nbi_invalidtoken1234567890123456",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
mockFunc: func(ctx context.Context, token string) (*types.UserInviteInfo, error) {
|
||||
return nil, status.Errorf(status.NotFound, "invite not found")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid token format",
|
||||
token: "invalid",
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
mockFunc: func(ctx context.Context, token string) (*types.UserInviteInfo, error) {
|
||||
return nil, status.Errorf(status.InvalidArgument, "invalid invite token")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing token",
|
||||
token: "",
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
mockFunc: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
am := &mock_server.MockAccountManager{
|
||||
GetUserInviteInfoFunc: tc.mockFunc,
|
||||
}
|
||||
handler := setupInvitesTestHandler(am)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/users/invites/"+tc.token, nil)
|
||||
if tc.token != "" {
|
||||
req = mux.SetURLVars(req, map[string]string{"token": tc.token})
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.getInviteInfo(rr, req)
|
||||
|
||||
assert.Equal(t, tc.expectedStatus, rr.Code)
|
||||
|
||||
if tc.expectedStatus == http.StatusOK {
|
||||
var resp api.UserInviteInfo
|
||||
err := json.NewDecoder(rr.Body).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testEmail, resp.Email)
|
||||
assert.Equal(t, testName, resp.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptInvite(t *testing.T) {
|
||||
tt := []struct {
|
||||
name string
|
||||
token string
|
||||
requestBody string
|
||||
expectedStatus int
|
||||
mockFunc func(ctx context.Context, token, password string) error
|
||||
}{
|
||||
{
|
||||
name: "successful accept",
|
||||
token: testInviteToken,
|
||||
requestBody: `{"password":"SecurePass123!"}`,
|
||||
expectedStatus: http.StatusOK,
|
||||
mockFunc: func(ctx context.Context, token, password string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invite not found",
|
||||
token: "nbi_invalidtoken1234567890123456",
|
||||
requestBody: `{"password":"SecurePass123!"}`,
|
||||
expectedStatus: http.StatusNotFound,
|
||||
mockFunc: func(ctx context.Context, token, password string) error {
|
||||
return status.Errorf(status.NotFound, "invite not found")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invite expired",
|
||||
token: testInviteToken,
|
||||
requestBody: `{"password":"SecurePass123!"}`,
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
mockFunc: func(ctx context.Context, token, password string) error {
|
||||
return status.Errorf(status.InvalidArgument, "invite has expired")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "embedded IDP not enabled",
|
||||
token: testInviteToken,
|
||||
requestBody: `{"password":"SecurePass123!"}`,
|
||||
expectedStatus: http.StatusPreconditionFailed,
|
||||
mockFunc: func(ctx context.Context, token, password string) error {
|
||||
return status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing token",
|
||||
token: "",
|
||||
requestBody: `{"password":"SecurePass123!"}`,
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
mockFunc: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
token: testInviteToken,
|
||||
requestBody: `{invalid}`,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
mockFunc: nil,
|
||||
},
|
||||
{
|
||||
name: "password too short",
|
||||
token: testInviteToken,
|
||||
requestBody: `{"password":"Short1!"}`,
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
mockFunc: func(ctx context.Context, token, password string) error {
|
||||
return status.Errorf(status.InvalidArgument, "password must be at least 8 characters long")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "password missing digit",
|
||||
token: testInviteToken,
|
||||
requestBody: `{"password":"NoDigitPass!"}`,
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
mockFunc: func(ctx context.Context, token, password string) error {
|
||||
return status.Errorf(status.InvalidArgument, "password must contain at least one digit")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "password missing uppercase",
|
||||
token: testInviteToken,
|
||||
requestBody: `{"password":"nouppercase1!"}`,
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
mockFunc: func(ctx context.Context, token, password string) error {
|
||||
return status.Errorf(status.InvalidArgument, "password must contain at least one uppercase letter")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "password missing special character",
|
||||
token: testInviteToken,
|
||||
requestBody: `{"password":"NoSpecial123"}`,
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
mockFunc: func(ctx context.Context, token, password string) error {
|
||||
return status.Errorf(status.InvalidArgument, "password must contain at least one special character")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
am := &mock_server.MockAccountManager{
|
||||
AcceptUserInviteFunc: tc.mockFunc,
|
||||
}
|
||||
handler := setupInvitesTestHandler(am)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/users/invites/"+tc.token+"/accept", bytes.NewBufferString(tc.requestBody))
|
||||
if tc.token != "" {
|
||||
req = mux.SetURLVars(req, map[string]string{"token": tc.token})
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.acceptInvite(rr, req)
|
||||
|
||||
assert.Equal(t, tc.expectedStatus, rr.Code)
|
||||
|
||||
if tc.expectedStatus == http.StatusOK {
|
||||
var resp api.UserInviteAcceptResponse
|
||||
err := json.NewDecoder(rr.Body).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.Success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegenerateInvite(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
expiresAt := now.Add(72 * time.Hour)
|
||||
|
||||
tt := []struct {
|
||||
name string
|
||||
inviteID string
|
||||
requestBody string
|
||||
expectedStatus int
|
||||
mockFunc func(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error)
|
||||
}{
|
||||
{
|
||||
name: "successful regenerate with empty body",
|
||||
inviteID: testInviteID,
|
||||
requestBody: "",
|
||||
expectedStatus: http.StatusOK,
|
||||
mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error) {
|
||||
assert.Equal(t, 0, expiresIn)
|
||||
return &types.UserInvite{
|
||||
UserInfo: &types.UserInfo{
|
||||
ID: inviteID,
|
||||
Email: testEmail,
|
||||
},
|
||||
InviteToken: "nbi_newtoken12345678901234567890",
|
||||
InviteExpiresAt: expiresAt,
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful regenerate with custom expiration",
|
||||
inviteID: testInviteID,
|
||||
requestBody: `{"expires_in":7200}`,
|
||||
expectedStatus: http.StatusOK,
|
||||
mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error) {
|
||||
assert.Equal(t, 7200, expiresIn)
|
||||
return &types.UserInvite{
|
||||
UserInfo: &types.UserInfo{
|
||||
ID: inviteID,
|
||||
Email: testEmail,
|
||||
},
|
||||
InviteToken: "nbi_newtoken12345678901234567890",
|
||||
InviteExpiresAt: expiresAt,
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invite not found",
|
||||
inviteID: "non-existent-invite",
|
||||
requestBody: "",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error) {
|
||||
return nil, status.Errorf(status.NotFound, "invite not found")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "permission denied",
|
||||
inviteID: testInviteID,
|
||||
requestBody: "",
|
||||
expectedStatus: http.StatusForbidden,
|
||||
mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error) {
|
||||
return nil, status.NewPermissionDeniedError()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing invite ID",
|
||||
inviteID: "",
|
||||
requestBody: "",
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
mockFunc: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid JSON should return error",
|
||||
inviteID: testInviteID,
|
||||
requestBody: `{invalid json}`,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
mockFunc: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
am := &mock_server.MockAccountManager{
|
||||
RegenerateUserInviteFunc: tc.mockFunc,
|
||||
}
|
||||
handler := setupInvitesTestHandler(am)
|
||||
|
||||
var body io.Reader
|
||||
if tc.requestBody != "" {
|
||||
body = bytes.NewBufferString(tc.requestBody)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/users/invites/"+tc.inviteID+"/regenerate", body)
|
||||
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
|
||||
UserId: testUserID,
|
||||
AccountId: testAccountID,
|
||||
})
|
||||
if tc.inviteID != "" {
|
||||
req = mux.SetURLVars(req, map[string]string{"inviteId": tc.inviteID})
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.regenerateInvite(rr, req)
|
||||
|
||||
assert.Equal(t, tc.expectedStatus, rr.Code)
|
||||
|
||||
if tc.expectedStatus == http.StatusOK {
|
||||
var resp api.UserInviteRegenerateResponse
|
||||
err := json.NewDecoder(rr.Body).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, resp.InviteToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteInvite(t *testing.T) {
|
||||
tt := []struct {
|
||||
name string
|
||||
inviteID string
|
||||
expectedStatus int
|
||||
mockFunc func(ctx context.Context, accountID, initiatorUserID, inviteID string) error
|
||||
}{
|
||||
{
|
||||
name: "successful delete",
|
||||
inviteID: testInviteID,
|
||||
expectedStatus: http.StatusOK,
|
||||
mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invite not found",
|
||||
inviteID: "non-existent-invite",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string) error {
|
||||
return status.Errorf(status.NotFound, "invite not found")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "permission denied",
|
||||
inviteID: testInviteID,
|
||||
expectedStatus: http.StatusForbidden,
|
||||
mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string) error {
|
||||
return status.NewPermissionDeniedError()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "embedded IDP not enabled",
|
||||
inviteID: testInviteID,
|
||||
expectedStatus: http.StatusPreconditionFailed,
|
||||
mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string) error {
|
||||
return status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing invite ID",
|
||||
inviteID: "",
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
mockFunc: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
am := &mock_server.MockAccountManager{
|
||||
DeleteUserInviteFunc: tc.mockFunc,
|
||||
}
|
||||
handler := setupInvitesTestHandler(am)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/users/invites/"+tc.inviteID, nil)
|
||||
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
|
||||
UserId: testUserID,
|
||||
AccountId: testAccountID,
|
||||
})
|
||||
if tc.inviteID != "" {
|
||||
req = mux.SetURLVars(req, map[string]string{"inviteId": tc.inviteID})
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.deleteInvite(rr, req)
|
||||
|
||||
assert.Equal(t, tc.expectedStatus, rr.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ func AddEndpoints(accountManager account.Manager, router *mux.Router) {
|
||||
router.HandleFunc("/users/{userId}/invite", userHandler.inviteUser).Methods("POST", "OPTIONS")
|
||||
router.HandleFunc("/users/{userId}/approve", userHandler.approveUser).Methods("POST", "OPTIONS")
|
||||
router.HandleFunc("/users/{userId}/reject", userHandler.rejectUser).Methods("DELETE", "OPTIONS")
|
||||
router.HandleFunc("/users/{userId}/password", userHandler.changePassword).Methods("PUT", "OPTIONS")
|
||||
addUsersTokensEndpoint(accountManager, router)
|
||||
}
|
||||
|
||||
@@ -410,3 +411,46 @@ func (h *handler) rejectUser(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
|
||||
}
|
||||
|
||||
// passwordChangeRequest represents the request body for password change
|
||||
type passwordChangeRequest struct {
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// changePassword is a PUT request to change user's password.
|
||||
// Only available when embedded IDP is enabled.
|
||||
// Users can only change their own password.
|
||||
func (h *handler) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
targetUserID := vars["userId"]
|
||||
if len(targetUserID) == 0 {
|
||||
util.WriteErrorResponse("invalid user ID", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
var req passwordChangeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.accountManager.UpdateUserPassword(r.Context(), userAuth.AccountId, userAuth.UserId, targetUserID, req.OldPassword, req.NewPassword)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
|
||||
}
|
||||
|
||||
@@ -856,3 +856,118 @@ func TestRejectUserEndpoint(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePasswordEndpoint(t *testing.T) {
|
||||
tt := []struct {
|
||||
name string
|
||||
expectedStatus int
|
||||
requestBody string
|
||||
targetUserID string
|
||||
currentUserID string
|
||||
mockError error
|
||||
expectMockNotCalled bool
|
||||
}{
|
||||
{
|
||||
name: "successful password change",
|
||||
expectedStatus: http.StatusOK,
|
||||
requestBody: `{"old_password": "OldPass123!", "new_password": "NewPass456!"}`,
|
||||
targetUserID: existingUserID,
|
||||
currentUserID: existingUserID,
|
||||
mockError: nil,
|
||||
},
|
||||
{
|
||||
name: "missing old password",
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
requestBody: `{"new_password": "NewPass456!"}`,
|
||||
targetUserID: existingUserID,
|
||||
currentUserID: existingUserID,
|
||||
mockError: status.Errorf(status.InvalidArgument, "old password is required"),
|
||||
},
|
||||
{
|
||||
name: "missing new password",
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
requestBody: `{"old_password": "OldPass123!"}`,
|
||||
targetUserID: existingUserID,
|
||||
currentUserID: existingUserID,
|
||||
mockError: status.Errorf(status.InvalidArgument, "new password is required"),
|
||||
},
|
||||
{
|
||||
name: "wrong old password",
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
requestBody: `{"old_password": "WrongPass!", "new_password": "NewPass456!"}`,
|
||||
targetUserID: existingUserID,
|
||||
currentUserID: existingUserID,
|
||||
mockError: status.Errorf(status.InvalidArgument, "invalid password"),
|
||||
},
|
||||
{
|
||||
name: "embedded IDP not enabled",
|
||||
expectedStatus: http.StatusPreconditionFailed,
|
||||
requestBody: `{"old_password": "OldPass123!", "new_password": "NewPass456!"}`,
|
||||
targetUserID: existingUserID,
|
||||
currentUserID: existingUserID,
|
||||
mockError: status.Errorf(status.PreconditionFailed, "password change is only available with embedded identity provider"),
|
||||
},
|
||||
{
|
||||
name: "invalid JSON request",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
requestBody: `{invalid json}`,
|
||||
targetUserID: existingUserID,
|
||||
currentUserID: existingUserID,
|
||||
expectMockNotCalled: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mockCalled := false
|
||||
am := &mock_server.MockAccountManager{}
|
||||
am.UpdateUserPasswordFunc = func(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error {
|
||||
mockCalled = true
|
||||
return tc.mockError
|
||||
}
|
||||
|
||||
handler := newHandler(am)
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/users/{userId}/password", handler.changePassword).Methods("PUT")
|
||||
|
||||
reqPath := "/users/" + tc.targetUserID + "/password"
|
||||
req, err := http.NewRequest("PUT", reqPath, bytes.NewBufferString(tc.requestBody))
|
||||
require.NoError(t, err)
|
||||
|
||||
userAuth := auth.UserAuth{
|
||||
AccountId: existingAccountID,
|
||||
UserId: tc.currentUserID,
|
||||
}
|
||||
ctx := nbcontext.SetUserAuthInContext(req.Context(), userAuth)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, tc.expectedStatus, rr.Code)
|
||||
|
||||
if tc.expectMockNotCalled {
|
||||
assert.False(t, mockCalled, "mock should not have been called")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePasswordEndpoint_WrongMethod(t *testing.T) {
|
||||
am := &mock_server.MockAccountManager{}
|
||||
handler := newHandler(am)
|
||||
|
||||
req, err := http.NewRequest("POST", "/users/test-user/password", bytes.NewBufferString(`{}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
userAuth := auth.UserAuth{
|
||||
AccountId: existingAccountID,
|
||||
UserId: existingUserID,
|
||||
}
|
||||
req = nbcontext.SetUserAuthInRequest(req, userAuth)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.changePassword(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, rr.Code)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,14 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
)
|
||||
|
||||
// RateLimiterConfig holds configuration for the API rate limiter
|
||||
@@ -144,3 +148,25 @@ func (rl *APIRateLimiter) Reset(key string) {
|
||||
defer rl.mu.Unlock()
|
||||
delete(rl.limiters, key)
|
||||
}
|
||||
|
||||
// Middleware returns an HTTP middleware that rate limits requests by client IP.
|
||||
// Returns 429 Too Many Requests if the rate limit is exceeded.
|
||||
func (rl *APIRateLimiter) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP := getClientIP(r)
|
||||
if !rl.Allow(clientIP) {
|
||||
util.WriteErrorResponse("rate limit exceeded, please try again later", http.StatusTooManyRequests, w)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// getClientIP extracts the client IP address from the request.
|
||||
func getClientIP(r *http.Request) string {
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAPIRateLimiter_Allow(t *testing.T) {
|
||||
rl := NewAPIRateLimiter(&RateLimiterConfig{
|
||||
RequestsPerMinute: 60, // 1 per second
|
||||
Burst: 2,
|
||||
CleanupInterval: time.Minute,
|
||||
LimiterTTL: time.Minute,
|
||||
})
|
||||
defer rl.Stop()
|
||||
|
||||
// First two requests should be allowed (burst)
|
||||
assert.True(t, rl.Allow("test-key"))
|
||||
assert.True(t, rl.Allow("test-key"))
|
||||
|
||||
// Third request should be denied (exceeded burst)
|
||||
assert.False(t, rl.Allow("test-key"))
|
||||
|
||||
// Different key should be allowed
|
||||
assert.True(t, rl.Allow("different-key"))
|
||||
}
|
||||
|
||||
func TestAPIRateLimiter_Middleware(t *testing.T) {
|
||||
rl := NewAPIRateLimiter(&RateLimiterConfig{
|
||||
RequestsPerMinute: 60, // 1 per second
|
||||
Burst: 2,
|
||||
CleanupInterval: time.Minute,
|
||||
LimiterTTL: time.Minute,
|
||||
})
|
||||
defer rl.Stop()
|
||||
|
||||
// Create a simple handler that returns 200 OK
|
||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// Wrap with rate limiter middleware
|
||||
handler := rl.Middleware(nextHandler)
|
||||
|
||||
// First two requests should pass (burst)
|
||||
for i := 0; i < 2; i++ {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.RemoteAddr = "192.168.1.1:12345"
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusOK, rr.Code, "request %d should be allowed", i+1)
|
||||
}
|
||||
|
||||
// Third request should be rate limited
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.RemoteAddr = "192.168.1.1:12345"
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusTooManyRequests, rr.Code)
|
||||
}
|
||||
|
||||
func TestAPIRateLimiter_Middleware_DifferentIPs(t *testing.T) {
|
||||
rl := NewAPIRateLimiter(&RateLimiterConfig{
|
||||
RequestsPerMinute: 60,
|
||||
Burst: 1,
|
||||
CleanupInterval: time.Minute,
|
||||
LimiterTTL: time.Minute,
|
||||
})
|
||||
defer rl.Stop()
|
||||
|
||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
handler := rl.Middleware(nextHandler)
|
||||
|
||||
// Request from first IP
|
||||
req1 := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req1.RemoteAddr = "192.168.1.1:12345"
|
||||
rr1 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr1, req1)
|
||||
assert.Equal(t, http.StatusOK, rr1.Code)
|
||||
|
||||
// Second request from first IP should be rate limited
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req2.RemoteAddr = "192.168.1.1:12345"
|
||||
rr2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr2, req2)
|
||||
assert.Equal(t, http.StatusTooManyRequests, rr2.Code)
|
||||
|
||||
// Request from different IP should be allowed
|
||||
req3 := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req3.RemoteAddr = "192.168.1.2:12345"
|
||||
rr3 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr3, req3)
|
||||
assert.Equal(t, http.StatusOK, rr3.Code)
|
||||
}
|
||||
|
||||
func TestGetClientIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "remote addr with port",
|
||||
remoteAddr: "192.168.1.1:12345",
|
||||
expected: "192.168.1.1",
|
||||
},
|
||||
{
|
||||
name: "remote addr without port",
|
||||
remoteAddr: "192.168.1.1",
|
||||
expected: "192.168.1.1",
|
||||
},
|
||||
{
|
||||
name: "IPv6 with port",
|
||||
remoteAddr: "[::1]:12345",
|
||||
expected: "::1",
|
||||
},
|
||||
{
|
||||
name: "IPv6 without port",
|
||||
remoteAddr: "::1",
|
||||
expected: "::1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.RemoteAddr = tc.remoteAddr
|
||||
assert.Equal(t, tc.expected, getClientIP(req))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIRateLimiter_Reset(t *testing.T) {
|
||||
rl := NewAPIRateLimiter(&RateLimiterConfig{
|
||||
RequestsPerMinute: 60,
|
||||
Burst: 1,
|
||||
CleanupInterval: time.Minute,
|
||||
LimiterTTL: time.Minute,
|
||||
})
|
||||
defer rl.Stop()
|
||||
|
||||
// Use up the burst
|
||||
assert.True(t, rl.Allow("test-key"))
|
||||
assert.False(t, rl.Allow("test-key"))
|
||||
|
||||
// Reset the limiter
|
||||
rl.Reset("test-key")
|
||||
|
||||
// Should be allowed again
|
||||
assert.True(t, rl.Allow("test-key"))
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/netbirdio/management-integrations/integrations"
|
||||
|
||||
zonesManager "github.com/netbirdio/netbird/management/internals/modules/zones/manager"
|
||||
recordsManager "github.com/netbirdio/netbird/management/internals/modules/zones/records/manager"
|
||||
"github.com/netbirdio/netbird/management/internals/server/config"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/internals/modules/peers"
|
||||
ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
@@ -72,11 +74,14 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
|
||||
userManager := users.NewManager(store)
|
||||
permissionsManager := permissions.NewManager(store)
|
||||
settingsManager := settings.NewManager(store, userManager, integrations.NewManager(&activity.InMemoryEventStore{}), permissionsManager)
|
||||
peersManager := peers.NewManager(store, permissionsManager)
|
||||
|
||||
jobManager := job.NewJobManager(nil, store, peersManager)
|
||||
|
||||
ctx := context.Background()
|
||||
requestBuffer := server.NewAccountRequestBuffer(ctx, store)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
|
||||
am, err := server.BuildManager(ctx, nil, store, networkMapController, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, proxyController, settingsManager, permissionsManager, false)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{})
|
||||
am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, proxyController, settingsManager, permissionsManager, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create manager: %v", err)
|
||||
}
|
||||
@@ -94,7 +99,6 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
|
||||
resourcesManagerMock := resources.NewManagerMock()
|
||||
routersManagerMock := routers.NewManagerMock()
|
||||
groupsManagerMock := groups.NewManagerMock()
|
||||
peersManager := peers.NewManager(store, permissionsManager)
|
||||
customZonesManager := zonesManager.NewManager(store, am, permissionsManager, "")
|
||||
zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/idp"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/settings"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
@@ -80,11 +81,12 @@ func createManagerWithEmbeddedIdP(t testing.TB) (*DefaultAccountManager, *update
|
||||
AnyTimes()
|
||||
|
||||
permissionsManager := permissions.NewManager(testStore)
|
||||
peersManager := peers.NewManager(testStore, permissionsManager)
|
||||
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := NewAccountRequestBuffer(ctx, testStore)
|
||||
networkMapController := controller.NewController(ctx, testStore, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(testStore, peers.NewManager(testStore, permissionsManager)), &config.Config{})
|
||||
manager, err := BuildManager(ctx, &config.Config{}, testStore, networkMapController, idpManager, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
|
||||
networkMapController := controller.NewController(ctx, testStore, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(testStore, peersManager), &config.Config{})
|
||||
manager, err := BuildManager(ctx, &config.Config{}, testStore, networkMapController, job.NewJobManager(nil, testStore, peersManager), idpManager, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -135,10 +135,11 @@ func NewAuth0Manager(config Auth0ClientConfig, appMetrics telemetry.AppMetrics)
|
||||
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
httpTransport.MaxIdleConns = 5
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
httpClient := &http.Client{
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
|
||||
helper := JsonParser{}
|
||||
|
||||
if config.AuthIssuer == "" {
|
||||
|
||||
@@ -48,16 +48,15 @@ type AuthentikCredentials struct {
|
||||
}
|
||||
|
||||
// NewAuthentikManager creates a new instance of the AuthentikManager.
|
||||
func NewAuthentikManager(config AuthentikClientConfig,
|
||||
appMetrics telemetry.AppMetrics) (*AuthentikManager, error) {
|
||||
func NewAuthentikManager(config AuthentikClientConfig, appMetrics telemetry.AppMetrics) (*AuthentikManager, error) {
|
||||
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
httpTransport.MaxIdleConns = 5
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
|
||||
|
||||
helper := JsonParser{}
|
||||
|
||||
if config.ClientID == "" {
|
||||
|
||||
@@ -57,10 +57,11 @@ func NewAzureManager(config AzureClientConfig, appMetrics telemetry.AppMetrics)
|
||||
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
httpTransport.MaxIdleConns = 5
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
httpClient := &http.Client{
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
|
||||
helper := JsonParser{}
|
||||
|
||||
if config.ClientID == "" {
|
||||
|
||||
@@ -20,7 +20,7 @@ const (
|
||||
staticClientCLI = "netbird-cli"
|
||||
defaultCLIRedirectURL1 = "http://localhost:53000/"
|
||||
defaultCLIRedirectURL2 = "http://localhost:54000/"
|
||||
defaultScopes = "openid profile email"
|
||||
defaultScopes = "openid profile email groups"
|
||||
defaultUserIDClaim = "sub"
|
||||
)
|
||||
|
||||
@@ -400,7 +400,6 @@ func (m *EmbeddedIdPManager) CreateUserWithPassword(ctx context.Context, email,
|
||||
|
||||
// InviteUserByID resends an invitation to a user.
|
||||
func (m *EmbeddedIdPManager) InviteUserByID(ctx context.Context, userID string) error {
|
||||
// TODO: implement
|
||||
return fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
@@ -432,6 +431,33 @@ func (m *EmbeddedIdPManager) DeleteUser(ctx context.Context, userID string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUserPassword updates the password for a user in the embedded IdP.
|
||||
// It verifies that the current user is changing their own password and
|
||||
// validates the current password before updating to the new password.
|
||||
func (m *EmbeddedIdPManager) UpdateUserPassword(ctx context.Context, currentUserID, targetUserID string, oldPassword, newPassword string) error {
|
||||
// Verify the user is changing their own password
|
||||
if currentUserID != targetUserID {
|
||||
return fmt.Errorf("users can only change their own password")
|
||||
}
|
||||
|
||||
// Verify the new password is different from the old password
|
||||
if oldPassword == newPassword {
|
||||
return fmt.Errorf("new password must be different from current password")
|
||||
}
|
||||
|
||||
err := m.provider.UpdateUserPassword(ctx, targetUserID, oldPassword, newPassword)
|
||||
if err != nil {
|
||||
if m.appMetrics != nil {
|
||||
m.appMetrics.IDPMetrics().CountRequestError()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Debugf("updated password for user %s in embedded IdP", targetUserID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateConnector creates a new identity provider connector in Dex.
|
||||
// Returns the created connector config with the redirect URL populated.
|
||||
func (m *EmbeddedIdPManager) CreateConnector(ctx context.Context, cfg *dex.ConnectorConfig) (*dex.ConnectorConfig, error) {
|
||||
@@ -449,15 +475,8 @@ func (m *EmbeddedIdPManager) ListConnectors(ctx context.Context) ([]*dex.Connect
|
||||
}
|
||||
|
||||
// UpdateConnector updates an existing identity provider connector.
|
||||
// Field preservation for partial updates is handled by Provider.UpdateConnector.
|
||||
func (m *EmbeddedIdPManager) UpdateConnector(ctx context.Context, cfg *dex.ConnectorConfig) error {
|
||||
// Preserve existing secret if not provided in update
|
||||
if cfg.ClientSecret == "" {
|
||||
existing, err := m.provider.GetConnector(ctx, cfg.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get existing connector: %w", err)
|
||||
}
|
||||
cfg.ClientSecret = existing.ClientSecret
|
||||
}
|
||||
return m.provider.UpdateConnector(ctx, cfg)
|
||||
}
|
||||
|
||||
|
||||
@@ -248,6 +248,71 @@ func TestEmbeddedIdPManager_UserIDFormat_MatchesJWT(t *testing.T) {
|
||||
t.Logf(" Connector: %s", connectorID)
|
||||
}
|
||||
|
||||
func TestEmbeddedIdPManager_UpdateUserPassword(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
config := &EmbeddedIdPConfig{
|
||||
Enabled: true,
|
||||
Issuer: "http://localhost:5556/dex",
|
||||
Storage: EmbeddedStorageConfig{
|
||||
Type: "sqlite3",
|
||||
Config: EmbeddedStorageTypeConfig{
|
||||
File: filepath.Join(tmpDir, "dex.db"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
manager, err := NewEmbeddedIdPManager(ctx, config, nil)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = manager.Stop(ctx) }()
|
||||
|
||||
// Create a user with a known password
|
||||
email := "password-test@example.com"
|
||||
name := "Password Test User"
|
||||
initialPassword := "InitialPass123!"
|
||||
|
||||
userData, err := manager.CreateUserWithPassword(ctx, email, initialPassword, name)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, userData)
|
||||
|
||||
userID := userData.ID
|
||||
|
||||
t.Run("successful password change", func(t *testing.T) {
|
||||
newPassword := "NewSecurePass456!"
|
||||
err := manager.UpdateUserPassword(ctx, userID, userID, initialPassword, newPassword)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the new password works by changing it again
|
||||
anotherPassword := "AnotherPass789!"
|
||||
err = manager.UpdateUserPassword(ctx, userID, userID, newPassword, anotherPassword)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("wrong old password", func(t *testing.T) {
|
||||
err := manager.UpdateUserPassword(ctx, userID, userID, "wrongpassword", "NewPass123!")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "current password is incorrect")
|
||||
})
|
||||
|
||||
t.Run("cannot change other user password", func(t *testing.T) {
|
||||
otherUserID := "other-user-id"
|
||||
err := manager.UpdateUserPassword(ctx, userID, otherUserID, "oldpass", "newpass")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "users can only change their own password")
|
||||
})
|
||||
|
||||
t.Run("same password rejected", func(t *testing.T) {
|
||||
samePassword := "SamePass123!"
|
||||
err := manager.UpdateUserPassword(ctx, userID, userID, samePassword, samePassword)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "new password must be different")
|
||||
})
|
||||
}
|
||||
|
||||
func TestEmbeddedIdPManager_GetLocalKeysLocation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/oauth2/google"
|
||||
@@ -49,9 +48,10 @@ func NewGoogleWorkspaceManager(ctx context.Context, config GoogleWorkspaceClient
|
||||
httpTransport.MaxIdleConns = 5
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
|
||||
helper := JsonParser{}
|
||||
|
||||
if config.CustomerID == "" {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
v1 "github.com/TheJumpCloud/jcapi-go/v1"
|
||||
|
||||
@@ -46,9 +45,10 @@ func NewJumpCloudManager(config JumpCloudClientConfig, appMetrics telemetry.AppM
|
||||
httpTransport.MaxIdleConns = 5
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
|
||||
helper := JsonParser{}
|
||||
|
||||
if config.APIToken == "" {
|
||||
|
||||
@@ -63,9 +63,10 @@ func NewKeycloakManager(config KeycloakClientConfig, appMetrics telemetry.AppMet
|
||||
httpTransport.MaxIdleConns = 5
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
|
||||
helper := JsonParser{}
|
||||
|
||||
if config.ClientID == "" {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/okta/okta-sdk-golang/v2/okta"
|
||||
"github.com/okta/okta-sdk-golang/v2/okta/query"
|
||||
@@ -45,7 +44,7 @@ func NewOktaManager(config OktaClientConfig, appMetrics telemetry.AppMetrics) (*
|
||||
httpTransport.MaxIdleConns = 5
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/telemetry"
|
||||
)
|
||||
@@ -88,9 +87,10 @@ func NewPocketIdManager(config PocketIdClientConfig, appMetrics telemetry.AppMet
|
||||
httpTransport.MaxIdleConns = 5
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
|
||||
helper := JsonParser{}
|
||||
|
||||
if config.ManagementEndpoint == "" {
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -69,3 +71,24 @@ func baseURL(rawURL string) string {
|
||||
|
||||
return parsedURL.Scheme + "://" + parsedURL.Host
|
||||
}
|
||||
|
||||
const (
|
||||
// Provides the env variable name for use with idpTimeout function
|
||||
idpTimeoutEnv = "NB_IDP_TIMEOUT"
|
||||
// Sets the defaultTimeout to 10s.
|
||||
defaultTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// idpTimeout returns a timeout value for the IDP
|
||||
func idpTimeout() time.Duration {
|
||||
timeoutStr, ok := os.LookupEnv(idpTimeoutEnv)
|
||||
if !ok || timeoutStr == "" {
|
||||
return defaultTimeout
|
||||
}
|
||||
|
||||
timeout, err := time.ParseDuration(timeoutStr)
|
||||
if err != nil {
|
||||
return defaultTimeout
|
||||
}
|
||||
return timeout
|
||||
}
|
||||
|
||||
@@ -164,9 +164,10 @@ func NewZitadelManager(config ZitadelClientConfig, appMetrics telemetry.AppMetri
|
||||
httpTransport.MaxIdleConns = 5
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
|
||||
helper := JsonParser{}
|
||||
|
||||
hasPAT := config.PAT != ""
|
||||
|
||||
@@ -2,18 +2,54 @@ package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
goversion "github.com/hashicorp/go-version"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/idp"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
const (
|
||||
// Version endpoints
|
||||
managementVersionURL = "https://pkgs.netbird.io/releases/latest/version"
|
||||
dashboardReleasesURL = "https://api.github.com/repos/netbirdio/dashboard/releases/latest"
|
||||
|
||||
// Cache TTL for version information
|
||||
versionCacheTTL = 60 * time.Minute
|
||||
|
||||
// HTTP client timeout
|
||||
httpTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// VersionInfo contains version information for NetBird components
|
||||
type VersionInfo struct {
|
||||
// CurrentVersion is the running management server version
|
||||
CurrentVersion string
|
||||
// DashboardVersion is the latest available dashboard version from GitHub
|
||||
DashboardVersion string
|
||||
// ManagementVersion is the latest available management version from GitHub
|
||||
ManagementVersion string
|
||||
// ManagementUpdateAvailable indicates if a newer management version is available
|
||||
ManagementUpdateAvailable bool
|
||||
}
|
||||
|
||||
// githubRelease represents a GitHub release response
|
||||
type githubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
|
||||
// Manager handles instance-level operations like initial setup.
|
||||
type Manager interface {
|
||||
// IsSetupRequired checks if instance setup is required.
|
||||
@@ -23,6 +59,9 @@ type Manager interface {
|
||||
// CreateOwnerUser creates the initial owner user in the embedded IDP.
|
||||
// This should only be called when IsSetupRequired returns true.
|
||||
CreateOwnerUser(ctx context.Context, email, password, name string) (*idp.UserData, error)
|
||||
|
||||
// GetVersionInfo returns version information for NetBird components.
|
||||
GetVersionInfo(ctx context.Context) (*VersionInfo, error)
|
||||
}
|
||||
|
||||
// DefaultManager is the default implementation of Manager.
|
||||
@@ -32,6 +71,12 @@ type DefaultManager struct {
|
||||
|
||||
setupRequired bool
|
||||
setupMu sync.RWMutex
|
||||
|
||||
// Version caching
|
||||
httpClient *http.Client
|
||||
versionMu sync.RWMutex
|
||||
cachedVersions *VersionInfo
|
||||
lastVersionFetch time.Time
|
||||
}
|
||||
|
||||
// NewManager creates a new instance manager.
|
||||
@@ -43,6 +88,9 @@ func NewManager(ctx context.Context, store store.Store, idpManager idp.Manager)
|
||||
store: store,
|
||||
embeddedIdpManager: embeddedIdp,
|
||||
setupRequired: false,
|
||||
httpClient: &http.Client{
|
||||
Timeout: httpTimeout,
|
||||
},
|
||||
}
|
||||
|
||||
if embeddedIdp != nil {
|
||||
@@ -134,3 +182,130 @@ func (m *DefaultManager) validateSetupInfo(email, password, name string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetVersionInfo returns version information for NetBird components.
|
||||
func (m *DefaultManager) GetVersionInfo(ctx context.Context) (*VersionInfo, error) {
|
||||
m.versionMu.RLock()
|
||||
if m.cachedVersions != nil && time.Since(m.lastVersionFetch) < versionCacheTTL {
|
||||
cached := *m.cachedVersions
|
||||
m.versionMu.RUnlock()
|
||||
return &cached, nil
|
||||
}
|
||||
m.versionMu.RUnlock()
|
||||
|
||||
return m.fetchVersionInfo(ctx)
|
||||
}
|
||||
|
||||
func (m *DefaultManager) fetchVersionInfo(ctx context.Context) (*VersionInfo, error) {
|
||||
m.versionMu.Lock()
|
||||
// Double-check after acquiring write lock
|
||||
if m.cachedVersions != nil && time.Since(m.lastVersionFetch) < versionCacheTTL {
|
||||
cached := *m.cachedVersions
|
||||
m.versionMu.Unlock()
|
||||
return &cached, nil
|
||||
}
|
||||
m.versionMu.Unlock()
|
||||
|
||||
info := &VersionInfo{
|
||||
CurrentVersion: version.NetbirdVersion(),
|
||||
}
|
||||
|
||||
// Fetch management version from pkgs.netbird.io (plain text)
|
||||
mgmtVersion, err := m.fetchPlainTextVersion(ctx, managementVersionURL)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Warnf("failed to fetch management version: %v", err)
|
||||
} else {
|
||||
info.ManagementVersion = mgmtVersion
|
||||
info.ManagementUpdateAvailable = isNewerVersion(info.CurrentVersion, mgmtVersion)
|
||||
}
|
||||
|
||||
// Fetch dashboard version from GitHub
|
||||
dashVersion, err := m.fetchGitHubRelease(ctx, dashboardReleasesURL)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Warnf("failed to fetch dashboard version from GitHub: %v", err)
|
||||
} else {
|
||||
info.DashboardVersion = dashVersion
|
||||
}
|
||||
|
||||
// Update cache
|
||||
m.versionMu.Lock()
|
||||
m.cachedVersions = info
|
||||
m.lastVersionFetch = time.Now()
|
||||
m.versionMu.Unlock()
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// isNewerVersion returns true if latestVersion is greater than currentVersion
|
||||
func isNewerVersion(currentVersion, latestVersion string) bool {
|
||||
current, err := goversion.NewVersion(currentVersion)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
latest, err := goversion.NewVersion(latestVersion)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return latest.GreaterThan(current)
|
||||
}
|
||||
|
||||
func (m *DefaultManager) fetchPlainTextVersion(ctx context.Context, url string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "NetBird-Management/"+version.NetbirdVersion())
|
||||
|
||||
resp, err := m.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 100))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(body)), nil
|
||||
}
|
||||
|
||||
func (m *DefaultManager) fetchGitHubRelease(ctx context.Context, url string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
||||
req.Header.Set("User-Agent", "NetBird-Management/"+version.NetbirdVersion())
|
||||
|
||||
resp, err := m.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var release githubRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return "", fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
|
||||
// Remove 'v' prefix if present
|
||||
tag := release.TagName
|
||||
if len(tag) > 0 && tag[0] == 'v' {
|
||||
tag = tag[1:]
|
||||
}
|
||||
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// mockRoundTripper implements http.RoundTripper for testing
|
||||
type mockRoundTripper struct {
|
||||
callCount atomic.Int32
|
||||
managementVersion string
|
||||
dashboardVersion string
|
||||
}
|
||||
|
||||
func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
m.callCount.Add(1)
|
||||
|
||||
var body string
|
||||
if strings.Contains(req.URL.String(), "pkgs.netbird.io") {
|
||||
// Plain text response for management version
|
||||
body = m.managementVersion
|
||||
} else if strings.Contains(req.URL.String(), "github.com") {
|
||||
// JSON response for dashboard version
|
||||
jsonResp, _ := json.Marshal(githubRelease{TagName: "v" + m.dashboardVersion})
|
||||
body = string(jsonResp)
|
||||
}
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewBufferString(body)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestDefaultManager_GetVersionInfo_ReturnsCurrentVersion(t *testing.T) {
|
||||
mockTransport := &mockRoundTripper{
|
||||
managementVersion: "0.65.0",
|
||||
dashboardVersion: "2.10.0",
|
||||
}
|
||||
|
||||
m := &DefaultManager{
|
||||
httpClient: &http.Client{Transport: mockTransport},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
info, err := m.GetVersionInfo(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// CurrentVersion should always be set
|
||||
assert.NotEmpty(t, info.CurrentVersion)
|
||||
assert.Equal(t, "0.65.0", info.ManagementVersion)
|
||||
assert.Equal(t, "2.10.0", info.DashboardVersion)
|
||||
assert.Equal(t, int32(2), mockTransport.callCount.Load()) // 2 calls: management + dashboard
|
||||
}
|
||||
|
||||
func TestDefaultManager_GetVersionInfo_CachesResults(t *testing.T) {
|
||||
mockTransport := &mockRoundTripper{
|
||||
managementVersion: "0.65.0",
|
||||
dashboardVersion: "2.10.0",
|
||||
}
|
||||
|
||||
m := &DefaultManager{
|
||||
httpClient: &http.Client{Transport: mockTransport},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// First call
|
||||
info1, err := m.GetVersionInfo(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, info1.CurrentVersion)
|
||||
assert.Equal(t, "0.65.0", info1.ManagementVersion)
|
||||
|
||||
initialCallCount := mockTransport.callCount.Load()
|
||||
|
||||
// Second call should use cache (no additional HTTP calls)
|
||||
info2, err := m.GetVersionInfo(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, info1.CurrentVersion, info2.CurrentVersion)
|
||||
assert.Equal(t, info1.ManagementVersion, info2.ManagementVersion)
|
||||
assert.Equal(t, info1.DashboardVersion, info2.DashboardVersion)
|
||||
|
||||
// Verify no additional HTTP calls were made (cache was used)
|
||||
assert.Equal(t, initialCallCount, mockTransport.callCount.Load())
|
||||
}
|
||||
|
||||
func TestDefaultManager_FetchGitHubRelease_ParsesTagName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tagName string
|
||||
expected string
|
||||
shouldError bool
|
||||
}{
|
||||
{
|
||||
name: "tag with v prefix",
|
||||
tagName: "v1.2.3",
|
||||
expected: "1.2.3",
|
||||
},
|
||||
{
|
||||
name: "tag without v prefix",
|
||||
tagName: "1.2.3",
|
||||
expected: "1.2.3",
|
||||
},
|
||||
{
|
||||
name: "tag with prerelease",
|
||||
tagName: "v2.0.0-beta.1",
|
||||
expected: "2.0.0-beta.1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(githubRelease{TagName: tc.tagName})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
m := &DefaultManager{
|
||||
httpClient: &http.Client{Timeout: 5 * time.Second},
|
||||
}
|
||||
|
||||
version, err := m.fetchGitHubRelease(context.Background(), server.URL)
|
||||
|
||||
if tc.shouldError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, version)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultManager_FetchGitHubRelease_HandlesErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "not found",
|
||||
statusCode: http.StatusNotFound,
|
||||
body: `{"message": "Not Found"}`,
|
||||
},
|
||||
{
|
||||
name: "rate limited",
|
||||
statusCode: http.StatusForbidden,
|
||||
body: `{"message": "API rate limit exceeded"}`,
|
||||
},
|
||||
{
|
||||
name: "server error",
|
||||
statusCode: http.StatusInternalServerError,
|
||||
body: `{"message": "Internal Server Error"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(tc.statusCode)
|
||||
_, _ = w.Write([]byte(tc.body))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
m := &DefaultManager{
|
||||
httpClient: &http.Client{Timeout: 5 * time.Second},
|
||||
}
|
||||
|
||||
_, err := m.fetchGitHubRelease(context.Background(), server.URL)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultManager_FetchGitHubRelease_InvalidJSON(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{invalid json}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
m := &DefaultManager{
|
||||
httpClient: &http.Client{Timeout: 5 * time.Second},
|
||||
}
|
||||
|
||||
_, err := m.fetchGitHubRelease(context.Background(), server.URL)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDefaultManager_FetchGitHubRelease_ContextCancellation(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(1 * time.Second)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(githubRelease{TagName: "v1.0.0"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
m := &DefaultManager{
|
||||
httpClient: &http.Client{Timeout: 5 * time.Second},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
_, err := m.fetchGitHubRelease(ctx, server.URL)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestIsNewerVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
currentVersion string
|
||||
latestVersion string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "latest is newer - minor version",
|
||||
currentVersion: "0.64.1",
|
||||
latestVersion: "0.65.0",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "latest is newer - patch version",
|
||||
currentVersion: "0.64.1",
|
||||
latestVersion: "0.64.2",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "latest is newer - major version",
|
||||
currentVersion: "0.64.1",
|
||||
latestVersion: "1.0.0",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "versions are equal",
|
||||
currentVersion: "0.64.1",
|
||||
latestVersion: "0.64.1",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "current is newer - minor version",
|
||||
currentVersion: "0.65.0",
|
||||
latestVersion: "0.64.1",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "current is newer - patch version",
|
||||
currentVersion: "0.64.2",
|
||||
latestVersion: "0.64.1",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "development version",
|
||||
currentVersion: "development",
|
||||
latestVersion: "0.65.0",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "invalid latest version",
|
||||
currentVersion: "0.64.1",
|
||||
latestVersion: "invalid",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := isNewerVersion(tc.currentVersion, tc.latestVersion)
|
||||
assert.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// todo consider the channel buffer size when we allow to run multiple jobs
|
||||
const jobChannelBuffer = 1
|
||||
|
||||
var (
|
||||
ErrJobChannelClosed = errors.New("job channel closed")
|
||||
)
|
||||
|
||||
type Channel struct {
|
||||
events chan *Event
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func NewChannel() *Channel {
|
||||
jc := &Channel{
|
||||
events: make(chan *Event, jobChannelBuffer),
|
||||
}
|
||||
|
||||
return jc
|
||||
}
|
||||
|
||||
func (jc *Channel) AddEvent(ctx context.Context, responseWait time.Duration, event *Event) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
// todo: timeout is handled in the wrong place. If the peer does not respond with the job response, the server does not clean it up from the pending jobs and cannot apply a new job
|
||||
case <-time.After(responseWait):
|
||||
return fmt.Errorf("failed to add the event to the channel")
|
||||
case jc.events <- event:
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (jc *Channel) Close() {
|
||||
jc.once.Do(func() {
|
||||
close(jc.events)
|
||||
})
|
||||
}
|
||||
|
||||
func (jc *Channel) Event(ctx context.Context) (*Event, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case job, open := <-jc.events:
|
||||
if !open {
|
||||
return nil, ErrJobChannelClosed
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/peers"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/telemetry"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
PeerID string
|
||||
Request *proto.JobRequest
|
||||
Response *proto.JobResponse
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
mu *sync.RWMutex
|
||||
jobChannels map[string]*Channel // per-peer job streams
|
||||
pending map[string]*Event // jobID → event
|
||||
responseWait time.Duration
|
||||
metrics telemetry.AppMetrics
|
||||
Store store.Store
|
||||
peersManager peers.Manager
|
||||
}
|
||||
|
||||
func NewJobManager(metrics telemetry.AppMetrics, store store.Store, peersManager peers.Manager) *Manager {
|
||||
|
||||
return &Manager{
|
||||
jobChannels: make(map[string]*Channel),
|
||||
pending: make(map[string]*Event),
|
||||
responseWait: 5 * time.Minute,
|
||||
metrics: metrics,
|
||||
mu: &sync.RWMutex{},
|
||||
Store: store,
|
||||
peersManager: peersManager,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateJobChannel creates or replaces a channel for a peer
|
||||
func (jm *Manager) CreateJobChannel(ctx context.Context, accountID, peerID string) *Channel {
|
||||
// all pending jobs stored in db for this peer should be failed
|
||||
if err := jm.Store.MarkAllPendingJobsAsFailed(ctx, accountID, peerID, "Pending job cleanup: marked as failed automatically due to being stuck too long"); err != nil {
|
||||
log.WithContext(ctx).Error(err.Error())
|
||||
}
|
||||
|
||||
jm.mu.Lock()
|
||||
defer jm.mu.Unlock()
|
||||
|
||||
if ch, ok := jm.jobChannels[peerID]; ok {
|
||||
ch.Close()
|
||||
delete(jm.jobChannels, peerID)
|
||||
}
|
||||
|
||||
ch := NewChannel()
|
||||
jm.jobChannels[peerID] = ch
|
||||
return ch
|
||||
}
|
||||
|
||||
// SendJob sends a job to a peer and tracks it as pending
|
||||
func (jm *Manager) SendJob(ctx context.Context, accountID, peerID string, req *proto.JobRequest) error {
|
||||
jm.mu.RLock()
|
||||
ch, ok := jm.jobChannels[peerID]
|
||||
jm.mu.RUnlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("peer %s has no channel", peerID)
|
||||
}
|
||||
|
||||
event := &Event{
|
||||
PeerID: peerID,
|
||||
Request: req,
|
||||
}
|
||||
|
||||
jm.mu.Lock()
|
||||
jm.pending[string(req.ID)] = event
|
||||
jm.mu.Unlock()
|
||||
|
||||
if err := ch.AddEvent(ctx, jm.responseWait, event); err != nil {
|
||||
jm.cleanup(ctx, accountID, string(req.ID), err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleResponse marks a job as finished and moves it to completed
|
||||
func (jm *Manager) HandleResponse(ctx context.Context, resp *proto.JobResponse, peerKey string) error {
|
||||
jm.mu.Lock()
|
||||
defer jm.mu.Unlock()
|
||||
|
||||
// todo: validate job ID and would be nice to use uuid text marshal instead of string
|
||||
jobID := string(resp.ID)
|
||||
|
||||
// todo: in this map has jobs for all peers in any account. Consider to validate the jobID association for the peer
|
||||
event, ok := jm.pending[jobID]
|
||||
if !ok {
|
||||
return fmt.Errorf("job %s not found", jobID)
|
||||
}
|
||||
var job types.Job
|
||||
// todo: ApplyResponse should be static. Any member value is unusable in this way
|
||||
if err := job.ApplyResponse(resp); err != nil {
|
||||
return fmt.Errorf("invalid job response: %v", err)
|
||||
}
|
||||
|
||||
peerID, err := jm.peersManager.GetPeerID(ctx, peerKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get peer ID: %v", err)
|
||||
}
|
||||
if peerID != event.PeerID {
|
||||
return fmt.Errorf("peer ID mismatch: %s != %s", peerID, event.PeerID)
|
||||
}
|
||||
|
||||
// update or create the store for job response
|
||||
err = jm.Store.CompletePeerJob(ctx, &job)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to complete job %s: %v", jobID, err)
|
||||
}
|
||||
|
||||
delete(jm.pending, jobID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloseChannel closes a peer’s channel and cleans up its jobs
|
||||
func (jm *Manager) CloseChannel(ctx context.Context, accountID, peerID string) {
|
||||
jm.mu.Lock()
|
||||
defer jm.mu.Unlock()
|
||||
|
||||
if ch, ok := jm.jobChannels[peerID]; ok {
|
||||
ch.Close()
|
||||
delete(jm.jobChannels, peerID)
|
||||
}
|
||||
|
||||
for jobID, ev := range jm.pending {
|
||||
if ev.PeerID == peerID {
|
||||
// if the client disconnect and there is pending job then mark it as failed
|
||||
if err := jm.Store.MarkPendingJobsAsFailed(ctx, accountID, peerID, jobID, "Time out peer disconnected"); err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to mark pending jobs as failed: %v", err)
|
||||
}
|
||||
delete(jm.pending, jobID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanup removes a pending job safely
|
||||
func (jm *Manager) cleanup(ctx context.Context, accountID, jobID string, reason string) {
|
||||
jm.mu.Lock()
|
||||
defer jm.mu.Unlock()
|
||||
|
||||
if ev, ok := jm.pending[jobID]; ok {
|
||||
if err := jm.Store.MarkPendingJobsAsFailed(ctx, accountID, ev.PeerID, jobID, reason); err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to mark pending jobs as failed: %v", err)
|
||||
}
|
||||
delete(jm.pending, jobID)
|
||||
}
|
||||
}
|
||||
|
||||
func (jm *Manager) IsPeerConnected(peerID string) bool {
|
||||
jm.mu.RLock()
|
||||
defer jm.mu.RUnlock()
|
||||
|
||||
_, ok := jm.jobChannels[peerID]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (jm *Manager) IsPeerHasPendingJobs(peerID string) bool {
|
||||
jm.mu.RLock()
|
||||
defer jm.mu.RUnlock()
|
||||
|
||||
for _, ev := range jm.pending {
|
||||
if ev.PeerID == peerID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/groups"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/settings"
|
||||
@@ -361,13 +362,15 @@ func startManagementForTest(t *testing.T, testFile string, config *config.Config
|
||||
AnyTimes()
|
||||
permissionsManager := permissions.NewManager(store)
|
||||
groupsManager := groups.NewManagerMock()
|
||||
peersManager := peers.NewManager(store, permissionsManager)
|
||||
jobManager := job.NewJobManager(nil, store, peersManager)
|
||||
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := NewAccountRequestBuffer(ctx, store)
|
||||
ephemeralMgr := manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager))
|
||||
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeralMgr, config)
|
||||
accountManager, err := BuildManager(ctx, nil, store, networkMapController, nil, "",
|
||||
accountManager, err := BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "",
|
||||
eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
|
||||
|
||||
if err != nil {
|
||||
@@ -381,7 +384,7 @@ func startManagementForTest(t *testing.T, testFile string, config *config.Config
|
||||
return nil, nil, "", cleanup, err
|
||||
}
|
||||
|
||||
mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, secretsManager, nil, nil, MockIntegratedValidator{}, networkMapController, nil)
|
||||
mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, MockIntegratedValidator{}, networkMapController, nil)
|
||||
if err != nil {
|
||||
return nil, nil, "", cleanup, err
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/groups"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/settings"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
@@ -202,6 +203,8 @@ func startServer(
|
||||
AnyTimes()
|
||||
|
||||
permissionsManager := permissions.NewManager(str)
|
||||
peersManager := peers.NewManager(str, permissionsManager)
|
||||
jobManager := job.NewJobManager(nil, str, peersManager)
|
||||
|
||||
ctx := context.Background()
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
@@ -213,6 +216,7 @@ func startServer(
|
||||
nil,
|
||||
str,
|
||||
networkMapController,
|
||||
jobManager,
|
||||
nil,
|
||||
"",
|
||||
eventStore,
|
||||
@@ -237,6 +241,7 @@ func startServer(
|
||||
config,
|
||||
accountManager,
|
||||
settingsMockManager,
|
||||
jobManager,
|
||||
secretsManager,
|
||||
nil,
|
||||
nil,
|
||||
|
||||
@@ -74,6 +74,7 @@ type MockAccountManager struct {
|
||||
SaveOrAddUsersFunc func(ctx context.Context, accountID, initiatorUserID string, update []*types.User, addIfNotExists bool) ([]*types.UserInfo, error)
|
||||
DeleteUserFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) error
|
||||
DeleteRegularUsersFunc func(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string, userInfos map[string]*types.UserInfo) error
|
||||
UpdateUserPasswordFunc func(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error
|
||||
CreatePATFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserId string, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error)
|
||||
DeletePATFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserId string, tokenID string) error
|
||||
GetPATFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserId string, tokenID string) (*types.PersonalAccessToken, error)
|
||||
@@ -135,6 +136,35 @@ type MockAccountManager struct {
|
||||
CreateIdentityProviderFunc func(ctx context.Context, accountID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error)
|
||||
UpdateIdentityProviderFunc func(ctx context.Context, accountID, idpID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error)
|
||||
DeleteIdentityProviderFunc func(ctx context.Context, accountID, idpID, userID string) error
|
||||
CreatePeerJobFunc func(ctx context.Context, accountID, peerID, userID string, job *types.Job) error
|
||||
GetAllPeerJobsFunc func(ctx context.Context, accountID, userID, peerID string) ([]*types.Job, error)
|
||||
GetPeerJobByIDFunc func(ctx context.Context, accountID, userID, peerID, jobID string) (*types.Job, error)
|
||||
CreateUserInviteFunc func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error)
|
||||
AcceptUserInviteFunc func(ctx context.Context, token, password string) error
|
||||
RegenerateUserInviteFunc func(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error)
|
||||
GetUserInviteInfoFunc func(ctx context.Context, token string) (*types.UserInviteInfo, error)
|
||||
ListUserInvitesFunc func(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error)
|
||||
DeleteUserInviteFunc func(ctx context.Context, accountID, initiatorUserID, inviteID string) error
|
||||
}
|
||||
|
||||
func (am *MockAccountManager) CreatePeerJob(ctx context.Context, accountID, peerID, userID string, job *types.Job) error {
|
||||
if am.CreatePeerJobFunc != nil {
|
||||
return am.CreatePeerJobFunc(ctx, accountID, peerID, userID, job)
|
||||
}
|
||||
return status.Errorf(codes.Unimplemented, "method CreatePeerJob is not implemented")
|
||||
}
|
||||
|
||||
func (am *MockAccountManager) GetAllPeerJobs(ctx context.Context, accountID, userID, peerID string) ([]*types.Job, error) {
|
||||
if am.GetAllPeerJobsFunc != nil {
|
||||
return am.GetAllPeerJobsFunc(ctx, accountID, userID, peerID)
|
||||
}
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetAllPeerJobs is not implemented")
|
||||
}
|
||||
func (am *MockAccountManager) GetPeerJobByID(ctx context.Context, accountID, userID, peerID, jobID string) (*types.Job, error) {
|
||||
if am.GetPeerJobByIDFunc != nil {
|
||||
return am.GetPeerJobByIDFunc(ctx, accountID, userID, peerID, jobID)
|
||||
}
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetPeerJobByID is not implemented")
|
||||
}
|
||||
|
||||
func (am *MockAccountManager) CreateGroup(ctx context.Context, accountID, userID string, group *types.Group) error {
|
||||
@@ -612,6 +642,14 @@ func (am *MockAccountManager) DeleteRegularUsers(ctx context.Context, accountID,
|
||||
return status.Errorf(codes.Unimplemented, "method DeleteRegularUsers is not implemented")
|
||||
}
|
||||
|
||||
// UpdateUserPassword mocks UpdateUserPassword of the AccountManager interface
|
||||
func (am *MockAccountManager) UpdateUserPassword(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error {
|
||||
if am.UpdateUserPasswordFunc != nil {
|
||||
return am.UpdateUserPasswordFunc(ctx, accountID, currentUserID, targetUserID, oldPassword, newPassword)
|
||||
}
|
||||
return status.Errorf(codes.Unimplemented, "method UpdateUserPassword is not implemented")
|
||||
}
|
||||
|
||||
func (am *MockAccountManager) InviteUser(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) error {
|
||||
if am.InviteUserFunc != nil {
|
||||
return am.InviteUserFunc(ctx, accountID, initiatorUserID, targetUserID)
|
||||
@@ -681,6 +719,48 @@ func (am *MockAccountManager) CreateUser(ctx context.Context, accountID, userID
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CreateUser is not implemented")
|
||||
}
|
||||
|
||||
func (am *MockAccountManager) CreateUserInvite(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) {
|
||||
if am.CreateUserInviteFunc != nil {
|
||||
return am.CreateUserInviteFunc(ctx, accountID, initiatorUserID, invite, expiresIn)
|
||||
}
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CreateUserInvite is not implemented")
|
||||
}
|
||||
|
||||
func (am *MockAccountManager) AcceptUserInvite(ctx context.Context, token, password string) error {
|
||||
if am.AcceptUserInviteFunc != nil {
|
||||
return am.AcceptUserInviteFunc(ctx, token, password)
|
||||
}
|
||||
return status.Errorf(codes.Unimplemented, "method AcceptUserInvite is not implemented")
|
||||
}
|
||||
|
||||
func (am *MockAccountManager) RegenerateUserInvite(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error) {
|
||||
if am.RegenerateUserInviteFunc != nil {
|
||||
return am.RegenerateUserInviteFunc(ctx, accountID, initiatorUserID, inviteID, expiresIn)
|
||||
}
|
||||
return nil, status.Errorf(codes.Unimplemented, "method RegenerateUserInvite is not implemented")
|
||||
}
|
||||
|
||||
func (am *MockAccountManager) GetUserInviteInfo(ctx context.Context, token string) (*types.UserInviteInfo, error) {
|
||||
if am.GetUserInviteInfoFunc != nil {
|
||||
return am.GetUserInviteInfoFunc(ctx, token)
|
||||
}
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetUserInviteInfo is not implemented")
|
||||
}
|
||||
|
||||
func (am *MockAccountManager) ListUserInvites(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error) {
|
||||
if am.ListUserInvitesFunc != nil {
|
||||
return am.ListUserInvitesFunc(ctx, accountID, initiatorUserID)
|
||||
}
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListUserInvites is not implemented")
|
||||
}
|
||||
|
||||
func (am *MockAccountManager) DeleteUserInvite(ctx context.Context, accountID, initiatorUserID, inviteID string) error {
|
||||
if am.DeleteUserInviteFunc != nil {
|
||||
return am.DeleteUserInviteFunc(ctx, accountID, initiatorUserID, inviteID)
|
||||
}
|
||||
return status.Errorf(codes.Unimplemented, "method DeleteUserInvite is not implemented")
|
||||
}
|
||||
|
||||
func (am *MockAccountManager) GetAccountIDFromUserAuth(ctx context.Context, userAuth auth.UserAuth) (string, string, error) {
|
||||
if am.GetAccountIDFromUserAuthFunc != nil {
|
||||
return am.GetAccountIDFromUserAuthFunc(ctx, userAuth)
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/internals/server/config"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/settings"
|
||||
@@ -790,13 +791,14 @@ func createNSManager(t *testing.T) (*DefaultAccountManager, error) {
|
||||
AnyTimes()
|
||||
|
||||
permissionsManager := permissions.NewManager(store)
|
||||
peersManager := peers.NewManager(store, permissionsManager)
|
||||
|
||||
ctx := context.Background()
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := NewAccountRequestBuffer(ctx, store)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
|
||||
|
||||
return BuildManager(context.Background(), nil, store, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
|
||||
return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
|
||||
}
|
||||
|
||||
func createNSStore(t *testing.T) (store.Store, error) {
|
||||
|
||||
@@ -31,6 +31,8 @@ import (
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
const remoteJobsMinVer = "0.64.0"
|
||||
|
||||
// GetPeers returns a list of peers under the given account filtering out peers that do not belong to a user if
|
||||
// the current user is not an admin.
|
||||
func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) {
|
||||
@@ -324,6 +326,134 @@ func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, user
|
||||
return peer, nil
|
||||
}
|
||||
|
||||
func (am *DefaultAccountManager) CreatePeerJob(ctx context.Context, accountID, peerID, userID string, job *types.Job) error {
|
||||
allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Create)
|
||||
if err != nil {
|
||||
return status.NewPermissionValidationError(err)
|
||||
}
|
||||
if !allowed {
|
||||
return status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
p, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if p.AccountID != accountID {
|
||||
return status.NewPeerNotPartOfAccountError()
|
||||
}
|
||||
|
||||
meetMinVer, err := posture.MeetsMinVersion(remoteJobsMinVer, p.Meta.WtVersion)
|
||||
if !strings.Contains(p.Meta.WtVersion, "dev") && (!meetMinVer || err != nil) {
|
||||
return status.Errorf(status.PreconditionFailed, "peer version %s does not meet the minimum required version %s for remote jobs", p.Meta.WtVersion, remoteJobsMinVer)
|
||||
}
|
||||
|
||||
if !am.jobManager.IsPeerConnected(peerID) {
|
||||
return status.Errorf(status.BadRequest, "peer not connected")
|
||||
}
|
||||
|
||||
// check if already has pending jobs
|
||||
// todo: The job checks here are not protected. The user can run this function from multiple threads,
|
||||
// and each thread can think there is no job yet. This means entries in the pending job map will be overwritten,
|
||||
// and only one will be kept, but potentially another one will overwrite it in the queue.
|
||||
if am.jobManager.IsPeerHasPendingJobs(peerID) {
|
||||
return status.Errorf(status.BadRequest, "peer already has pending job")
|
||||
}
|
||||
|
||||
jobStream, err := job.ToStreamJobRequest()
|
||||
if err != nil {
|
||||
return status.Errorf(status.BadRequest, "invalid job request %v", err)
|
||||
}
|
||||
|
||||
// try sending job first
|
||||
if err := am.jobManager.SendJob(ctx, accountID, peerID, jobStream); err != nil {
|
||||
return status.Errorf(status.Internal, "failed to send job: %v", err)
|
||||
}
|
||||
|
||||
var peer *nbpeer.Peer
|
||||
var eventsToStore func()
|
||||
|
||||
// persist job in DB only if send succeeded
|
||||
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
peer, err = transaction.GetPeerByID(ctx, store.LockingStrengthUpdate, accountID, peerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := transaction.CreatePeerJob(ctx, job); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
jobMeta := map[string]any{
|
||||
"for_peer_name": peer.Name,
|
||||
"job_type": job.Workload.Type,
|
||||
}
|
||||
|
||||
eventsToStore = func() {
|
||||
am.StoreEvent(ctx, userID, peer.ID, accountID, activity.JobCreatedByUser, jobMeta)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eventsToStore()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (am *DefaultAccountManager) GetAllPeerJobs(ctx context.Context, accountID, userID, peerID string) ([]*types.Job, error) {
|
||||
// todo: Create permissions for job
|
||||
allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Read)
|
||||
if err != nil {
|
||||
return nil, status.NewPermissionValidationError(err)
|
||||
}
|
||||
if !allowed {
|
||||
return nil, status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
peerAccountID, err := am.Store.GetAccountIDByPeerID(ctx, store.LockingStrengthNone, peerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if peerAccountID != accountID {
|
||||
return nil, status.NewPeerNotPartOfAccountError()
|
||||
}
|
||||
|
||||
accountJobs, err := am.Store.GetPeerJobs(ctx, accountID, peerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return accountJobs, nil
|
||||
}
|
||||
|
||||
func (am *DefaultAccountManager) GetPeerJobByID(ctx context.Context, accountID, userID, peerID, jobID string) (*types.Job, error) {
|
||||
allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Read)
|
||||
if err != nil {
|
||||
return nil, status.NewPermissionValidationError(err)
|
||||
}
|
||||
if !allowed {
|
||||
return nil, status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
peerAccountID, err := am.Store.GetAccountIDByPeerID(ctx, store.LockingStrengthNone, peerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if peerAccountID != accountID {
|
||||
return nil, status.NewPeerNotPartOfAccountError()
|
||||
}
|
||||
|
||||
job, err := am.Store.GetPeerJobByID(ctx, accountID, jobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return job, nil
|
||||
}
|
||||
|
||||
// DeletePeer removes peer from the account by its IP
|
||||
func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peerID, userID string) error {
|
||||
allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Delete)
|
||||
@@ -598,6 +728,11 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
|
||||
return fmt.Errorf("failed adding peer to All group: %w", err)
|
||||
}
|
||||
|
||||
if temporary {
|
||||
// we should track ephemeral peers to be able to clean them if the peer don't sync and be marked as connected
|
||||
am.networkMapController.TrackEphemeralPeer(ctx, newPeer)
|
||||
}
|
||||
|
||||
if addedByUser {
|
||||
err := transaction.SaveUserLastLogin(ctx, accountID, userID, newPeer.GetLastLogin())
|
||||
if err != nil {
|
||||
|
||||
@@ -34,6 +34,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/internals/shared/grpc"
|
||||
"github.com/netbirdio/netbird/management/server/http/testing/testing_tools"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/settings"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
@@ -1289,13 +1290,14 @@ func Test_RegisterPeerByUser(t *testing.T) {
|
||||
t.Cleanup(ctrl.Finish)
|
||||
settingsMockManager := settings.NewMockManager(ctrl)
|
||||
permissionsManager := permissions.NewManager(s)
|
||||
peersManager := peers.NewManager(s, permissionsManager)
|
||||
|
||||
ctx := context.Background()
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := NewAccountRequestBuffer(ctx, s)
|
||||
networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
|
||||
|
||||
am, err := BuildManager(context.Background(), nil, s, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
|
||||
am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
@@ -1374,13 +1376,14 @@ func Test_RegisterPeerBySetupKey(t *testing.T) {
|
||||
Return(&types.ExtraSettings{}, nil).
|
||||
AnyTimes()
|
||||
permissionsManager := permissions.NewManager(s)
|
||||
peersManager := peers.NewManager(s, permissionsManager)
|
||||
|
||||
ctx := context.Background()
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := NewAccountRequestBuffer(ctx, s)
|
||||
networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
|
||||
|
||||
am, err := BuildManager(context.Background(), nil, s, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
|
||||
am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
@@ -1527,13 +1530,14 @@ func Test_RegisterPeerRollbackOnFailure(t *testing.T) {
|
||||
settingsMockManager := settings.NewMockManager(ctrl)
|
||||
|
||||
permissionsManager := permissions.NewManager(s)
|
||||
peersManager := peers.NewManager(s, permissionsManager)
|
||||
|
||||
ctx := context.Background()
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := NewAccountRequestBuffer(ctx, s)
|
||||
networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
|
||||
|
||||
am, err := BuildManager(context.Background(), nil, s, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
|
||||
am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
@@ -1607,13 +1611,14 @@ func Test_LoginPeer(t *testing.T) {
|
||||
Return(&types.ExtraSettings{}, nil).
|
||||
AnyTimes()
|
||||
permissionsManager := permissions.NewManager(s)
|
||||
peersManager := peers.NewManager(s, permissionsManager)
|
||||
|
||||
ctx := context.Background()
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := NewAccountRequestBuffer(ctx, s)
|
||||
networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
|
||||
|
||||
am, err := BuildManager(context.Background(), nil, s, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
|
||||
am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
|
||||
@@ -3,36 +3,38 @@ package modules
|
||||
type Module string
|
||||
|
||||
const (
|
||||
Networks Module = "networks"
|
||||
Peers Module = "peers"
|
||||
Groups Module = "groups"
|
||||
Settings Module = "settings"
|
||||
Accounts Module = "accounts"
|
||||
Dns Module = "dns"
|
||||
Nameservers Module = "nameservers"
|
||||
Events Module = "events"
|
||||
Policies Module = "policies"
|
||||
Routes Module = "routes"
|
||||
Users Module = "users"
|
||||
SetupKeys Module = "setup_keys"
|
||||
Pats Module = "pats"
|
||||
Networks Module = "networks"
|
||||
Peers Module = "peers"
|
||||
RemoteJobs Module = "remote_jobs"
|
||||
Groups Module = "groups"
|
||||
Settings Module = "settings"
|
||||
Accounts Module = "accounts"
|
||||
Dns Module = "dns"
|
||||
Nameservers Module = "nameservers"
|
||||
Events Module = "events"
|
||||
Policies Module = "policies"
|
||||
Routes Module = "routes"
|
||||
Users Module = "users"
|
||||
SetupKeys Module = "setup_keys"
|
||||
Pats Module = "pats"
|
||||
IdentityProviders Module = "identity_providers"
|
||||
Services Module = "services"
|
||||
)
|
||||
|
||||
var All = map[Module]struct{}{
|
||||
Networks: {},
|
||||
Peers: {},
|
||||
Groups: {},
|
||||
Settings: {},
|
||||
Accounts: {},
|
||||
Dns: {},
|
||||
Nameservers: {},
|
||||
Events: {},
|
||||
Policies: {},
|
||||
Routes: {},
|
||||
Users: {},
|
||||
SetupKeys: {},
|
||||
Pats: {},
|
||||
Networks: {},
|
||||
Peers: {},
|
||||
RemoteJobs: {},
|
||||
Groups: {},
|
||||
Settings: {},
|
||||
Accounts: {},
|
||||
Dns: {},
|
||||
Nameservers: {},
|
||||
Events: {},
|
||||
Policies: {},
|
||||
Routes: {},
|
||||
Users: {},
|
||||
SetupKeys: {},
|
||||
Pats: {},
|
||||
IdentityProviders: {},
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/internals/server/config"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
|
||||
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
|
||||
networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
|
||||
@@ -1289,13 +1290,14 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, *update_channel.
|
||||
Return(&types.ExtraSettings{}, nil)
|
||||
|
||||
permissionsManager := permissions.NewManager(store)
|
||||
peersManager := peers.NewManager(store, permissionsManager)
|
||||
|
||||
ctx := context.Background()
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := NewAccountRequestBuffer(ctx, store)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
|
||||
|
||||
am, err := BuildManager(context.Background(), nil, store, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
|
||||
am, err := BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -44,14 +44,15 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
storeSqliteFileName = "store.db"
|
||||
idQueryCondition = "id = ?"
|
||||
keyQueryCondition = "key = ?"
|
||||
mysqlKeyQueryCondition = "`key` = ?"
|
||||
accountAndIDQueryCondition = "account_id = ? and id = ?"
|
||||
accountAndIDsQueryCondition = "account_id = ? AND id IN ?"
|
||||
accountIDCondition = "account_id = ?"
|
||||
peerNotFoundFMT = "peer %s not found"
|
||||
storeSqliteFileName = "store.db"
|
||||
idQueryCondition = "id = ?"
|
||||
keyQueryCondition = "key = ?"
|
||||
mysqlKeyQueryCondition = "`key` = ?"
|
||||
accountAndIDQueryCondition = "account_id = ? and id = ?"
|
||||
accountAndPeerIDQueryCondition = "account_id = ? and peer_id = ?"
|
||||
accountAndIDsQueryCondition = "account_id = ? AND id IN ?"
|
||||
accountIDCondition = "account_id = ?"
|
||||
peerNotFoundFMT = "peer %s not found"
|
||||
|
||||
pgMaxConnections = 30
|
||||
pgMinConnections = 1
|
||||
@@ -126,7 +127,7 @@ func NewSqlStore(ctx context.Context, db *gorm.DB, storeEngine types.Engine, met
|
||||
&types.Account{}, &types.Policy{}, &types.PolicyRule{}, &route.Route{}, &nbdns.NameServerGroup{},
|
||||
&installation{}, &types.ExtraSettings{}, &posture.Checks{}, &nbpeer.NetworkAddress{},
|
||||
&networkTypes.Network{}, &routerTypes.NetworkRouter{}, &resourceTypes.NetworkResource{}, &types.AccountOnboarding{},
|
||||
&zones.Zone{}, &records.Record{}, &services.Service{},
|
||||
&types.Job{}, &zones.Zone{}, &records.Record{}, &types.UserInviteRecord{}, &services.Service{},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auto migratePreAuto: %w", err)
|
||||
@@ -145,6 +146,97 @@ func GetKeyQueryCondition(s *SqlStore) string {
|
||||
return keyQueryCondition
|
||||
}
|
||||
|
||||
// SaveJob persists a job in DB
|
||||
func (s *SqlStore) CreatePeerJob(ctx context.Context, job *types.Job) error {
|
||||
result := s.db.Create(job)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to create job in store: %s", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to create job in store")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) CompletePeerJob(ctx context.Context, job *types.Job) error {
|
||||
result := s.db.
|
||||
Model(&types.Job{}).
|
||||
Where(idQueryCondition, job.ID).
|
||||
Updates(job)
|
||||
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to update job in store: %s", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to update job in store")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// job was pending for too long and has been cancelled
|
||||
func (s *SqlStore) MarkPendingJobsAsFailed(ctx context.Context, accountID, peerID, jobID, reason string) error {
|
||||
now := time.Now().UTC()
|
||||
result := s.db.
|
||||
Model(&types.Job{}).
|
||||
Where(accountAndPeerIDQueryCondition+" AND id = ?"+" AND status = ?", accountID, peerID, jobID, types.JobStatusPending).
|
||||
Updates(types.Job{
|
||||
Status: types.JobStatusFailed,
|
||||
FailedReason: reason,
|
||||
CompletedAt: &now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to mark pending jobs as Failed job in store: %s", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to mark pending job as Failed in store")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// job was pending for too long and has been cancelled
|
||||
func (s *SqlStore) MarkAllPendingJobsAsFailed(ctx context.Context, accountID, peerID, reason string) error {
|
||||
now := time.Now().UTC()
|
||||
result := s.db.
|
||||
Model(&types.Job{}).
|
||||
Where(accountAndPeerIDQueryCondition+" AND status = ?", accountID, peerID, types.JobStatusPending).
|
||||
Updates(types.Job{
|
||||
Status: types.JobStatusFailed,
|
||||
FailedReason: reason,
|
||||
CompletedAt: &now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to mark pending jobs as Failed job in store: %s", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to mark pending job as Failed in store")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetJobByID fetches job by ID
|
||||
func (s *SqlStore) GetPeerJobByID(ctx context.Context, accountID, jobID string) (*types.Job, error) {
|
||||
var job types.Job
|
||||
err := s.db.
|
||||
Where(accountAndIDQueryCondition, accountID, jobID).
|
||||
First(&job).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Errorf(status.NotFound, "job %s not found", jobID)
|
||||
}
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to fetch job from store: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
return &job, nil
|
||||
}
|
||||
|
||||
// get all jobs
|
||||
func (s *SqlStore) GetPeerJobs(ctx context.Context, accountID, peerID string) ([]*types.Job, error) {
|
||||
var jobs []*types.Job
|
||||
err := s.db.
|
||||
Where(accountAndPeerIDQueryCondition, accountID, peerID).
|
||||
Order("created_at DESC").
|
||||
Find(&jobs).Error
|
||||
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to fetch jobs from store: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
// AcquireGlobalLock acquires global lock across all the accounts and returns a function that releases the lock
|
||||
func (s *SqlStore) AcquireGlobalLock(ctx context.Context) (unlock func()) {
|
||||
log.WithContext(ctx).Tracef("acquiring global lock")
|
||||
@@ -724,6 +816,130 @@ func (s *SqlStore) GetAccountOwner(ctx context.Context, lockStrength LockingStre
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// SaveUserInvite saves a user invite to the database
|
||||
func (s *SqlStore) SaveUserInvite(ctx context.Context, invite *types.UserInviteRecord) error {
|
||||
inviteCopy := invite.Copy()
|
||||
if err := inviteCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil {
|
||||
return fmt.Errorf("encrypt invite: %w", err)
|
||||
}
|
||||
|
||||
result := s.db.Save(inviteCopy)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to save user invite to store: %s", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to save user invite to store")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserInviteByID retrieves a user invite by its ID and account ID
|
||||
func (s *SqlStore) GetUserInviteByID(ctx context.Context, lockStrength LockingStrength, accountID, inviteID string) (*types.UserInviteRecord, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var invite types.UserInviteRecord
|
||||
result := tx.Where("account_id = ?", accountID).Take(&invite, idQueryCondition, inviteID)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Errorf(status.NotFound, "user invite not found")
|
||||
}
|
||||
log.WithContext(ctx).Errorf("failed to get user invite from store: %s", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get user invite from store")
|
||||
}
|
||||
|
||||
if err := invite.DecryptSensitiveData(s.fieldEncrypt); err != nil {
|
||||
return nil, fmt.Errorf("decrypt invite: %w", err)
|
||||
}
|
||||
|
||||
return &invite, nil
|
||||
}
|
||||
|
||||
// GetUserInviteByHashedToken retrieves a user invite by its hashed token
|
||||
func (s *SqlStore) GetUserInviteByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types.UserInviteRecord, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var invite types.UserInviteRecord
|
||||
result := tx.Take(&invite, "hashed_token = ?", hashedToken)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Errorf(status.NotFound, "user invite not found")
|
||||
}
|
||||
log.WithContext(ctx).Errorf("failed to get user invite from store: %s", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get user invite from store")
|
||||
}
|
||||
|
||||
if err := invite.DecryptSensitiveData(s.fieldEncrypt); err != nil {
|
||||
return nil, fmt.Errorf("decrypt invite: %w", err)
|
||||
}
|
||||
|
||||
return &invite, nil
|
||||
}
|
||||
|
||||
// GetUserInviteByEmail retrieves a user invite by account ID and email.
|
||||
// Since email is encrypted with random IVs, we fetch all invites for the account
|
||||
// and compare emails in memory after decryption.
|
||||
func (s *SqlStore) GetUserInviteByEmail(ctx context.Context, lockStrength LockingStrength, accountID, email string) (*types.UserInviteRecord, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var invites []*types.UserInviteRecord
|
||||
result := tx.Find(&invites, "account_id = ?", accountID)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get user invites from store: %s", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get user invites from store")
|
||||
}
|
||||
|
||||
for _, invite := range invites {
|
||||
if err := invite.DecryptSensitiveData(s.fieldEncrypt); err != nil {
|
||||
return nil, fmt.Errorf("decrypt invite: %w", err)
|
||||
}
|
||||
if strings.EqualFold(invite.Email, email) {
|
||||
return invite, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, status.Errorf(status.NotFound, "user invite not found for email")
|
||||
}
|
||||
|
||||
// GetAccountUserInvites retrieves all user invites for an account
|
||||
func (s *SqlStore) GetAccountUserInvites(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.UserInviteRecord, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var invites []*types.UserInviteRecord
|
||||
result := tx.Find(&invites, "account_id = ?", accountID)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get user invites from store: %s", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get user invites from store")
|
||||
}
|
||||
|
||||
for _, invite := range invites {
|
||||
if err := invite.DecryptSensitiveData(s.fieldEncrypt); err != nil {
|
||||
return nil, fmt.Errorf("decrypt invite: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return invites, nil
|
||||
}
|
||||
|
||||
// DeleteUserInvite deletes a user invite by its ID
|
||||
func (s *SqlStore) DeleteUserInvite(ctx context.Context, inviteID string) error {
|
||||
result := s.db.Delete(&types.UserInviteRecord{}, idQueryCondition, inviteID)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to delete user invite from store: %s", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to delete user invite from store")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) GetAccountGroups(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.Group, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
@@ -4178,6 +4394,9 @@ func (s *SqlStore) GetUserIDByPeerKey(ctx context.Context, lockStrength LockingS
|
||||
Take(&userID, GetKeyQueryCondition(s), peerKey)
|
||||
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return "", status.Errorf(status.NotFound, "peer not found: index lookup failed")
|
||||
}
|
||||
return "", status.Errorf(status.Internal, "failed to get user ID by peer key")
|
||||
}
|
||||
|
||||
@@ -4365,6 +4584,26 @@ func (s *SqlStore) DeleteZoneDNSRecords(ctx context.Context, accountID, zoneID s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) GetPeerIDByKey(ctx context.Context, lockStrength LockingStrength, key string) (string, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var peerID string
|
||||
result := tx.Model(&nbpeer.Peer{}).
|
||||
Select("id").
|
||||
Where(GetKeyQueryCondition(s), key).
|
||||
Limit(1).
|
||||
Scan(&peerID)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get peer ID by key: %s", result.Error)
|
||||
return "", status.Errorf(status.Internal, "failed to get peer ID by key")
|
||||
}
|
||||
|
||||
return peerID, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) CreateService(ctx context.Context, service *services.Service) error {
|
||||
result := s.db.Create(service)
|
||||
if result.Error != nil {
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
func TestSqlStore_SaveUserInvite(t *testing.T) {
|
||||
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
|
||||
if store == nil {
|
||||
t.Skip("store is nil")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
invite := &types.UserInviteRecord{
|
||||
ID: "invite-1",
|
||||
AccountID: "account-1",
|
||||
Email: "test@example.com",
|
||||
Name: "Test User",
|
||||
Role: "user",
|
||||
AutoGroups: []string{"group-1", "group-2"},
|
||||
HashedToken: "hashed-token-123",
|
||||
ExpiresAt: time.Now().Add(72 * time.Hour),
|
||||
CreatedAt: time.Now(),
|
||||
CreatedBy: "admin-user",
|
||||
}
|
||||
|
||||
err := store.SaveUserInvite(ctx, invite)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the invite was saved
|
||||
retrieved, err := store.GetUserInviteByID(ctx, LockingStrengthNone, invite.AccountID, invite.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.ID, retrieved.ID)
|
||||
assert.Equal(t, invite.Email, retrieved.Email)
|
||||
assert.Equal(t, invite.Name, retrieved.Name)
|
||||
assert.Equal(t, invite.Role, retrieved.Role)
|
||||
assert.Equal(t, invite.AutoGroups, retrieved.AutoGroups)
|
||||
assert.Equal(t, invite.CreatedBy, retrieved.CreatedBy)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSqlStore_SaveUserInvite_Update(t *testing.T) {
|
||||
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
|
||||
if store == nil {
|
||||
t.Skip("store is nil")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
invite := &types.UserInviteRecord{
|
||||
ID: "invite-update",
|
||||
AccountID: "account-1",
|
||||
Email: "test@example.com",
|
||||
Name: "Test User",
|
||||
Role: "user",
|
||||
AutoGroups: []string{"group-1"},
|
||||
HashedToken: "hashed-token-123",
|
||||
ExpiresAt: time.Now().Add(72 * time.Hour),
|
||||
CreatedAt: time.Now(),
|
||||
CreatedBy: "admin-user",
|
||||
}
|
||||
|
||||
err := store.SaveUserInvite(ctx, invite)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Update the invite with a new token
|
||||
invite.HashedToken = "new-hashed-token"
|
||||
invite.ExpiresAt = time.Now().Add(24 * time.Hour)
|
||||
|
||||
err = store.SaveUserInvite(ctx, invite)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the update
|
||||
retrieved, err := store.GetUserInviteByID(ctx, LockingStrengthNone, invite.AccountID, invite.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "new-hashed-token", retrieved.HashedToken)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSqlStore_GetUserInviteByID(t *testing.T) {
|
||||
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
|
||||
if store == nil {
|
||||
t.Skip("store is nil")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
invite := &types.UserInviteRecord{
|
||||
ID: "invite-get-by-id",
|
||||
AccountID: "account-1",
|
||||
Email: "getbyid@example.com",
|
||||
Name: "Get By ID User",
|
||||
Role: "admin",
|
||||
AutoGroups: []string{},
|
||||
HashedToken: "hashed-token-get",
|
||||
ExpiresAt: time.Now().Add(72 * time.Hour),
|
||||
CreatedAt: time.Now(),
|
||||
CreatedBy: "admin-user",
|
||||
}
|
||||
|
||||
err := store.SaveUserInvite(ctx, invite)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get by ID - success
|
||||
retrieved, err := store.GetUserInviteByID(ctx, LockingStrengthNone, invite.AccountID, invite.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.ID, retrieved.ID)
|
||||
assert.Equal(t, invite.Email, retrieved.Email)
|
||||
|
||||
// Get by ID - wrong account
|
||||
_, err = store.GetUserInviteByID(ctx, LockingStrengthNone, "wrong-account", invite.ID)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Get by ID - not found
|
||||
_, err = store.GetUserInviteByID(ctx, LockingStrengthNone, invite.AccountID, "non-existent")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSqlStore_GetUserInviteByHashedToken(t *testing.T) {
|
||||
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
|
||||
if store == nil {
|
||||
t.Skip("store is nil")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
invite := &types.UserInviteRecord{
|
||||
ID: "invite-get-by-token",
|
||||
AccountID: "account-1",
|
||||
Email: "getbytoken@example.com",
|
||||
Name: "Get By Token User",
|
||||
Role: "user",
|
||||
AutoGroups: []string{"group-1"},
|
||||
HashedToken: "unique-hashed-token-456",
|
||||
ExpiresAt: time.Now().Add(72 * time.Hour),
|
||||
CreatedAt: time.Now(),
|
||||
CreatedBy: "admin-user",
|
||||
}
|
||||
|
||||
err := store.SaveUserInvite(ctx, invite)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get by hashed token - success
|
||||
retrieved, err := store.GetUserInviteByHashedToken(ctx, LockingStrengthNone, invite.HashedToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.ID, retrieved.ID)
|
||||
assert.Equal(t, invite.Email, retrieved.Email)
|
||||
|
||||
// Get by hashed token - not found
|
||||
_, err = store.GetUserInviteByHashedToken(ctx, LockingStrengthNone, "non-existent-token")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSqlStore_GetUserInviteByEmail(t *testing.T) {
|
||||
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
|
||||
if store == nil {
|
||||
t.Skip("store is nil")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
invite := &types.UserInviteRecord{
|
||||
ID: "invite-get-by-email",
|
||||
AccountID: "account-email-test",
|
||||
Email: "unique-email@example.com",
|
||||
Name: "Get By Email User",
|
||||
Role: "user",
|
||||
AutoGroups: []string{},
|
||||
HashedToken: "hashed-token-email",
|
||||
ExpiresAt: time.Now().Add(72 * time.Hour),
|
||||
CreatedAt: time.Now(),
|
||||
CreatedBy: "admin-user",
|
||||
}
|
||||
|
||||
err := store.SaveUserInvite(ctx, invite)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get by email - success
|
||||
retrieved, err := store.GetUserInviteByEmail(ctx, LockingStrengthNone, invite.AccountID, invite.Email)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.ID, retrieved.ID)
|
||||
|
||||
// Get by email - case insensitive
|
||||
retrieved, err = store.GetUserInviteByEmail(ctx, LockingStrengthNone, invite.AccountID, "UNIQUE-EMAIL@EXAMPLE.COM")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.ID, retrieved.ID)
|
||||
|
||||
// Get by email - wrong account
|
||||
_, err = store.GetUserInviteByEmail(ctx, LockingStrengthNone, "wrong-account", invite.Email)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Get by email - not found
|
||||
_, err = store.GetUserInviteByEmail(ctx, LockingStrengthNone, invite.AccountID, "nonexistent@example.com")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSqlStore_GetAccountUserInvites(t *testing.T) {
|
||||
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
|
||||
if store == nil {
|
||||
t.Skip("store is nil")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
accountID := "account-list-invites"
|
||||
|
||||
invites := []*types.UserInviteRecord{
|
||||
{
|
||||
ID: "invite-list-1",
|
||||
AccountID: accountID,
|
||||
Email: "user1@example.com",
|
||||
Name: "User One",
|
||||
Role: "user",
|
||||
AutoGroups: []string{"group-1"},
|
||||
HashedToken: "hashed-token-list-1",
|
||||
ExpiresAt: time.Now().Add(72 * time.Hour),
|
||||
CreatedAt: time.Now(),
|
||||
CreatedBy: "admin-user",
|
||||
},
|
||||
{
|
||||
ID: "invite-list-2",
|
||||
AccountID: accountID,
|
||||
Email: "user2@example.com",
|
||||
Name: "User Two",
|
||||
Role: "admin",
|
||||
AutoGroups: []string{"group-2"},
|
||||
HashedToken: "hashed-token-list-2",
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
CreatedAt: time.Now(),
|
||||
CreatedBy: "admin-user",
|
||||
},
|
||||
{
|
||||
ID: "invite-list-3",
|
||||
AccountID: "different-account",
|
||||
Email: "user3@example.com",
|
||||
Name: "User Three",
|
||||
Role: "user",
|
||||
AutoGroups: []string{},
|
||||
HashedToken: "hashed-token-list-3",
|
||||
ExpiresAt: time.Now().Add(72 * time.Hour),
|
||||
CreatedAt: time.Now(),
|
||||
CreatedBy: "admin-user",
|
||||
},
|
||||
}
|
||||
|
||||
for _, invite := range invites {
|
||||
err := store.SaveUserInvite(ctx, invite)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Get all invites for the account
|
||||
retrieved, err := store.GetAccountUserInvites(ctx, LockingStrengthNone, accountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, retrieved, 2)
|
||||
|
||||
// Verify the invites belong to the correct account
|
||||
for _, invite := range retrieved {
|
||||
assert.Equal(t, accountID, invite.AccountID)
|
||||
}
|
||||
|
||||
// Get invites for account with no invites
|
||||
retrieved, err = store.GetAccountUserInvites(ctx, LockingStrengthNone, "empty-account")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, retrieved, 0)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSqlStore_DeleteUserInvite(t *testing.T) {
|
||||
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
|
||||
if store == nil {
|
||||
t.Skip("store is nil")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
invite := &types.UserInviteRecord{
|
||||
ID: "invite-delete",
|
||||
AccountID: "account-delete-test",
|
||||
Email: "delete@example.com",
|
||||
Name: "Delete User",
|
||||
Role: "user",
|
||||
AutoGroups: []string{},
|
||||
HashedToken: "hashed-token-delete",
|
||||
ExpiresAt: time.Now().Add(72 * time.Hour),
|
||||
CreatedAt: time.Now(),
|
||||
CreatedBy: "admin-user",
|
||||
}
|
||||
|
||||
err := store.SaveUserInvite(ctx, invite)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify invite exists
|
||||
_, err = store.GetUserInviteByID(ctx, LockingStrengthNone, invite.AccountID, invite.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Delete the invite
|
||||
err = store.DeleteUserInvite(ctx, invite.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify invite is deleted
|
||||
_, err = store.GetUserInviteByID(ctx, LockingStrengthNone, invite.AccountID, invite.ID)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSqlStore_UserInvite_EncryptedFields(t *testing.T) {
|
||||
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
|
||||
if store == nil {
|
||||
t.Skip("store is nil")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
invite := &types.UserInviteRecord{
|
||||
ID: "invite-encrypted",
|
||||
AccountID: "account-encrypted",
|
||||
Email: "sensitive-email@example.com",
|
||||
Name: "Sensitive Name",
|
||||
Role: "user",
|
||||
AutoGroups: []string{"group-1"},
|
||||
HashedToken: "hashed-token-encrypted",
|
||||
ExpiresAt: time.Now().Add(72 * time.Hour),
|
||||
CreatedAt: time.Now(),
|
||||
CreatedBy: "admin-user",
|
||||
}
|
||||
|
||||
err := store.SaveUserInvite(ctx, invite)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Retrieve and verify decryption works
|
||||
retrieved, err := store.GetUserInviteByID(ctx, LockingStrengthNone, invite.AccountID, invite.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "sensitive-email@example.com", retrieved.Email)
|
||||
assert.Equal(t, "Sensitive Name", retrieved.Name)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSqlStore_DeleteUserInvite_NonExistent(t *testing.T) {
|
||||
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
|
||||
if store == nil {
|
||||
t.Skip("store is nil")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
// Deleting a non-existent invite should not return an error
|
||||
err := store.DeleteUserInvite(ctx, "non-existent-invite-id")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSqlStore_UserInvite_SameEmailDifferentAccounts(t *testing.T) {
|
||||
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
|
||||
if store == nil {
|
||||
t.Skip("store is nil")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
email := "shared-email@example.com"
|
||||
|
||||
// Create invite in first account
|
||||
invite1 := &types.UserInviteRecord{
|
||||
ID: "invite-account1",
|
||||
AccountID: "account-1",
|
||||
Email: email,
|
||||
Name: "User Account 1",
|
||||
Role: "user",
|
||||
AutoGroups: []string{},
|
||||
HashedToken: "hashed-token-account1",
|
||||
ExpiresAt: time.Now().Add(72 * time.Hour),
|
||||
CreatedAt: time.Now(),
|
||||
CreatedBy: "admin-1",
|
||||
}
|
||||
|
||||
// Create invite in second account with same email
|
||||
invite2 := &types.UserInviteRecord{
|
||||
ID: "invite-account2",
|
||||
AccountID: "account-2",
|
||||
Email: email,
|
||||
Name: "User Account 2",
|
||||
Role: "admin",
|
||||
AutoGroups: []string{"group-1"},
|
||||
HashedToken: "hashed-token-account2",
|
||||
ExpiresAt: time.Now().Add(72 * time.Hour),
|
||||
CreatedAt: time.Now(),
|
||||
CreatedBy: "admin-2",
|
||||
}
|
||||
|
||||
err := store.SaveUserInvite(ctx, invite1)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = store.SaveUserInvite(ctx, invite2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify each account gets the correct invite by email
|
||||
retrieved1, err := store.GetUserInviteByEmail(ctx, LockingStrengthNone, "account-1", email)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "invite-account1", retrieved1.ID)
|
||||
assert.Equal(t, "User Account 1", retrieved1.Name)
|
||||
|
||||
retrieved2, err := store.GetUserInviteByEmail(ctx, LockingStrengthNone, "account-2", email)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "invite-account2", retrieved2.ID)
|
||||
assert.Equal(t, "User Account 2", retrieved2.Name)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSqlStore_UserInvite_LockingStrength(t *testing.T) {
|
||||
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
|
||||
if store == nil {
|
||||
t.Skip("store is nil")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
invite := &types.UserInviteRecord{
|
||||
ID: "invite-locking",
|
||||
AccountID: "account-locking",
|
||||
Email: "locking@example.com",
|
||||
Name: "Locking Test User",
|
||||
Role: "user",
|
||||
AutoGroups: []string{},
|
||||
HashedToken: "hashed-token-locking",
|
||||
ExpiresAt: time.Now().Add(72 * time.Hour),
|
||||
CreatedAt: time.Now(),
|
||||
CreatedBy: "admin-user",
|
||||
}
|
||||
|
||||
err := store.SaveUserInvite(ctx, invite)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test with different locking strengths
|
||||
lockStrengths := []LockingStrength{LockingStrengthNone, LockingStrengthShare, LockingStrengthUpdate}
|
||||
|
||||
for _, strength := range lockStrengths {
|
||||
retrieved, err := store.GetUserInviteByID(ctx, strength, invite.AccountID, invite.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.ID, retrieved.ID)
|
||||
|
||||
retrieved, err = store.GetUserInviteByHashedToken(ctx, strength, invite.HashedToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.ID, retrieved.ID)
|
||||
|
||||
retrieved, err = store.GetUserInviteByEmail(ctx, strength, invite.AccountID, invite.Email)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.ID, retrieved.ID)
|
||||
|
||||
invites, err := store.GetAccountUserInvites(ctx, strength, invite.AccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, invites, 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSqlStore_UserInvite_EmptyAutoGroups(t *testing.T) {
|
||||
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
|
||||
if store == nil {
|
||||
t.Skip("store is nil")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
// Test with nil AutoGroups
|
||||
invite := &types.UserInviteRecord{
|
||||
ID: "invite-nil-autogroups",
|
||||
AccountID: "account-autogroups",
|
||||
Email: "nilgroups@example.com",
|
||||
Name: "Nil Groups User",
|
||||
Role: "user",
|
||||
AutoGroups: nil,
|
||||
HashedToken: "hashed-token-nil",
|
||||
ExpiresAt: time.Now().Add(72 * time.Hour),
|
||||
CreatedAt: time.Now(),
|
||||
CreatedBy: "admin-user",
|
||||
}
|
||||
|
||||
err := store.SaveUserInvite(ctx, invite)
|
||||
require.NoError(t, err)
|
||||
|
||||
retrieved, err := store.GetUserInviteByID(ctx, LockingStrengthNone, invite.AccountID, invite.ID)
|
||||
require.NoError(t, err)
|
||||
// Should return empty slice or nil, both are acceptable
|
||||
assert.Empty(t, retrieved.AutoGroups)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSqlStore_UserInvite_TimestampPrecision(t *testing.T) {
|
||||
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
|
||||
if store == nil {
|
||||
t.Skip("store is nil")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
expiresAt := now.Add(72 * time.Hour)
|
||||
|
||||
invite := &types.UserInviteRecord{
|
||||
ID: "invite-timestamp",
|
||||
AccountID: "account-timestamp",
|
||||
Email: "timestamp@example.com",
|
||||
Name: "Timestamp User",
|
||||
Role: "user",
|
||||
AutoGroups: []string{},
|
||||
HashedToken: "hashed-token-timestamp",
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: now,
|
||||
CreatedBy: "admin-user",
|
||||
}
|
||||
|
||||
err := store.SaveUserInvite(ctx, invite)
|
||||
require.NoError(t, err)
|
||||
|
||||
retrieved, err := store.GetUserInviteByID(ctx, LockingStrengthNone, invite.AccountID, invite.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify timestamps are preserved (within reasonable precision)
|
||||
assert.WithinDuration(t, now, retrieved.CreatedAt, time.Second)
|
||||
assert.WithinDuration(t, expiresAt, retrieved.ExpiresAt, time.Second)
|
||||
})
|
||||
}
|
||||
@@ -93,6 +93,13 @@ type Store interface {
|
||||
DeleteHashedPAT2TokenIDIndex(hashedToken string) error
|
||||
DeleteTokenID2UserIDIndex(tokenID string) error
|
||||
|
||||
SaveUserInvite(ctx context.Context, invite *types.UserInviteRecord) error
|
||||
GetUserInviteByID(ctx context.Context, lockStrength LockingStrength, accountID, inviteID string) (*types.UserInviteRecord, error)
|
||||
GetUserInviteByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types.UserInviteRecord, error)
|
||||
GetUserInviteByEmail(ctx context.Context, lockStrength LockingStrength, accountID, email string) (*types.UserInviteRecord, error)
|
||||
GetAccountUserInvites(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.UserInviteRecord, error)
|
||||
DeleteUserInvite(ctx context.Context, inviteID string) error
|
||||
|
||||
GetPATByID(ctx context.Context, lockStrength LockingStrength, userID, patID string) (*types.PersonalAccessToken, error)
|
||||
GetUserPATs(ctx context.Context, lockStrength LockingStrength, userID string) ([]*types.PersonalAccessToken, error)
|
||||
GetPATByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types.PersonalAccessToken, error)
|
||||
@@ -227,6 +234,13 @@ type Store interface {
|
||||
GetZoneDNSRecords(ctx context.Context, lockStrength LockingStrength, accountID, zoneID string) ([]*records.Record, error)
|
||||
GetZoneDNSRecordsByName(ctx context.Context, lockStrength LockingStrength, accountID, zoneID, name string) ([]*records.Record, error)
|
||||
DeleteZoneDNSRecords(ctx context.Context, accountID, zoneID string) error
|
||||
CreatePeerJob(ctx context.Context, job *types.Job) error
|
||||
CompletePeerJob(ctx context.Context, job *types.Job) error
|
||||
GetPeerJobByID(ctx context.Context, accountID, jobID string) (*types.Job, error)
|
||||
GetPeerJobs(ctx context.Context, accountID, peerID string) ([]*types.Job, error)
|
||||
MarkPendingJobsAsFailed(ctx context.Context, accountID, peerID, jobID, reason string) error
|
||||
MarkAllPendingJobsAsFailed(ctx context.Context, accountID, peerID, reason string) error
|
||||
GetPeerIDByKey(ctx context.Context, lockStrength LockingStrength, key string) (string, error)
|
||||
|
||||
CreateService(ctx context.Context, service *services.Service) error
|
||||
UpdateService(ctx context.Context, service *services.Service) error
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
type JobStatus string
|
||||
|
||||
const (
|
||||
JobStatusPending JobStatus = "pending"
|
||||
JobStatusSucceeded JobStatus = "succeeded"
|
||||
JobStatusFailed JobStatus = "failed"
|
||||
)
|
||||
|
||||
type JobType string
|
||||
|
||||
const (
|
||||
JobTypeBundle JobType = "bundle"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxJobReasonLength is the maximum length allowed for job failure reasons
|
||||
MaxJobReasonLength = 4096
|
||||
)
|
||||
|
||||
type Job struct {
|
||||
// ID is the primary identifier
|
||||
ID string `gorm:"primaryKey"`
|
||||
|
||||
// CreatedAt when job was created (UTC)
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
|
||||
// CompletedAt when job finished, null if still running
|
||||
CompletedAt *time.Time
|
||||
|
||||
// TriggeredBy user that triggered this job
|
||||
TriggeredBy string `gorm:"index"`
|
||||
|
||||
PeerID string `gorm:"index"`
|
||||
|
||||
AccountID string `gorm:"index"`
|
||||
|
||||
// Status of the job: pending, succeeded, failed
|
||||
Status JobStatus `gorm:"index;type:varchar(50)"`
|
||||
|
||||
// FailedReason describes why the job failed (if failed)
|
||||
FailedReason string
|
||||
|
||||
Workload Workload `gorm:"embedded;embeddedPrefix:workload_"`
|
||||
}
|
||||
|
||||
type Workload struct {
|
||||
Type JobType `gorm:"column:workload_type;index;type:varchar(50)"`
|
||||
Parameters json.RawMessage `gorm:"type:json"`
|
||||
Result json.RawMessage `gorm:"type:json"`
|
||||
}
|
||||
|
||||
// NewJob creates a new job with default fields and validation
|
||||
func NewJob(triggeredBy, accountID, peerID string, req *api.JobRequest) (*Job, error) {
|
||||
if req == nil {
|
||||
return nil, status.Errorf(status.BadRequest, "job request cannot be nil")
|
||||
}
|
||||
|
||||
// Determine job type
|
||||
jobTypeStr, err := req.Workload.Discriminator()
|
||||
if err != nil {
|
||||
return nil, status.Errorf(status.BadRequest, "could not determine job type: %v", err)
|
||||
}
|
||||
jobType := JobType(jobTypeStr)
|
||||
|
||||
if jobType == "" {
|
||||
return nil, status.Errorf(status.BadRequest, "job type is required")
|
||||
}
|
||||
|
||||
var workload Workload
|
||||
|
||||
switch jobType {
|
||||
case JobTypeBundle:
|
||||
if err := validateAndBuildBundleParams(req.Workload, &workload); err != nil {
|
||||
return nil, status.Errorf(status.BadRequest, "%v", err)
|
||||
}
|
||||
default:
|
||||
return nil, status.Errorf(status.BadRequest, "unsupported job type: %s", jobType)
|
||||
}
|
||||
|
||||
return &Job{
|
||||
ID: uuid.New().String(),
|
||||
TriggeredBy: triggeredBy,
|
||||
PeerID: peerID,
|
||||
AccountID: accountID,
|
||||
Status: JobStatusPending,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Workload: workload,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (j *Job) BuildWorkloadResponse() (*api.WorkloadResponse, error) {
|
||||
var wl api.WorkloadResponse
|
||||
|
||||
switch j.Workload.Type {
|
||||
case JobTypeBundle:
|
||||
if err := j.buildBundleResponse(&wl); err != nil {
|
||||
return nil, status.Errorf(status.Internal, "failed to process job: %v", err.Error())
|
||||
}
|
||||
return &wl, nil
|
||||
|
||||
default:
|
||||
return nil, status.Errorf(status.InvalidArgument, "unknown job type: %v", j.Workload.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (j *Job) buildBundleResponse(wl *api.WorkloadResponse) error {
|
||||
var p api.BundleParameters
|
||||
if err := json.Unmarshal(j.Workload.Parameters, &p); err != nil {
|
||||
return fmt.Errorf("invalid parameters for bundle job: %w", err)
|
||||
}
|
||||
var r api.BundleResult
|
||||
if err := json.Unmarshal(j.Workload.Result, &r); err != nil {
|
||||
return fmt.Errorf("invalid result for bundle job: %w", err)
|
||||
}
|
||||
|
||||
if err := wl.FromBundleWorkloadResponse(api.BundleWorkloadResponse{
|
||||
Type: api.WorkloadTypeBundle,
|
||||
Parameters: p,
|
||||
Result: r,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("unknown job parameters: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAndBuildBundleParams(req api.WorkloadRequest, workload *Workload) error {
|
||||
bundle, err := req.AsBundleWorkloadRequest()
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid parameters for bundle job")
|
||||
}
|
||||
// validate bundle_for_time <= 5 minutes if BundleFor is enabled
|
||||
if bundle.Parameters.BundleFor && (bundle.Parameters.BundleForTime < 1 || bundle.Parameters.BundleForTime > 5) {
|
||||
return fmt.Errorf("bundle_for_time must be between 1 and 5, got %d", bundle.Parameters.BundleForTime)
|
||||
}
|
||||
// validate log-file-count ≥ 1 and ≤ 1000
|
||||
if bundle.Parameters.LogFileCount < 1 || bundle.Parameters.LogFileCount > 1000 {
|
||||
return fmt.Errorf("log-file-count must be between 1 and 1000, got %d", bundle.Parameters.LogFileCount)
|
||||
}
|
||||
|
||||
workload.Parameters, err = json.Marshal(bundle.Parameters)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal workload parameters: %w", err)
|
||||
}
|
||||
workload.Result = []byte("{}")
|
||||
workload.Type = JobType(api.WorkloadTypeBundle)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyResponse validates and maps a proto.JobResponse into the Job fields.
|
||||
func (j *Job) ApplyResponse(resp *proto.JobResponse) error {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
j.ID = string(resp.ID)
|
||||
now := time.Now().UTC()
|
||||
j.CompletedAt = &now
|
||||
switch resp.Status {
|
||||
case proto.JobStatus_succeeded:
|
||||
j.Status = JobStatusSucceeded
|
||||
case proto.JobStatus_failed:
|
||||
j.Status = JobStatusFailed
|
||||
if len(resp.Reason) > 0 {
|
||||
reason := string(resp.Reason)
|
||||
if len(resp.Reason) > MaxJobReasonLength {
|
||||
reason = string(resp.Reason[:MaxJobReasonLength]) + "... (truncated)"
|
||||
}
|
||||
j.FailedReason = fmt.Sprintf("Client error: '%s'", reason)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unexpected job status: %v", resp.Status)
|
||||
}
|
||||
|
||||
// Handle workload results (oneof)
|
||||
var err error
|
||||
switch r := resp.WorkloadResults.(type) {
|
||||
case *proto.JobResponse_Bundle:
|
||||
if j.Workload.Result, err = json.Marshal(r.Bundle); err != nil {
|
||||
return fmt.Errorf("failed to marshal workload results: %w", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported workload response type: %T", r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *Job) ToStreamJobRequest() (*proto.JobRequest, error) {
|
||||
switch j.Workload.Type {
|
||||
case JobTypeBundle:
|
||||
return j.buildStreamBundleResponse()
|
||||
default:
|
||||
return nil, status.Errorf(status.InvalidArgument, "unknown job type: %v", j.Workload.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (j *Job) buildStreamBundleResponse() (*proto.JobRequest, error) {
|
||||
var p api.BundleParameters
|
||||
if err := json.Unmarshal(j.Workload.Parameters, &p); err != nil {
|
||||
return nil, fmt.Errorf("invalid parameters for bundle job: %w", err)
|
||||
}
|
||||
return &proto.JobRequest{
|
||||
ID: []byte(j.ID),
|
||||
WorkloadParameters: &proto.JobRequest_Bundle{
|
||||
Bundle: &proto.BundleParameters{
|
||||
BundleFor: p.BundleFor,
|
||||
BundleForTime: int64(p.BundleForTime),
|
||||
LogFileCount: int32(p.LogFileCount),
|
||||
Anonymize: p.Anonymize,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
b64 "encoding/base64"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
b "github.com/hashicorp/go-secure-stdlib/base62"
|
||||
"github.com/rs/xid"
|
||||
|
||||
"github.com/netbirdio/netbird/base62"
|
||||
"github.com/netbirdio/netbird/util/crypt"
|
||||
)
|
||||
|
||||
const (
|
||||
// InviteTokenPrefix is the prefix for invite tokens
|
||||
InviteTokenPrefix = "nbi_"
|
||||
// InviteTokenSecretLength is the length of the random secret part
|
||||
InviteTokenSecretLength = 30
|
||||
// InviteTokenChecksumLength is the length of the encoded checksum
|
||||
InviteTokenChecksumLength = 6
|
||||
// InviteTokenLength is the total length of the token (4 + 30 + 6 = 40)
|
||||
InviteTokenLength = 40
|
||||
// DefaultInviteExpirationSeconds is the default expiration time for invites (72 hours)
|
||||
DefaultInviteExpirationSeconds = 259200
|
||||
// MinInviteExpirationSeconds is the minimum expiration time for invites (1 hour)
|
||||
MinInviteExpirationSeconds = 3600
|
||||
)
|
||||
|
||||
// UserInviteRecord represents an invitation for a user to set up their account (database model)
|
||||
type UserInviteRecord struct {
|
||||
ID string `gorm:"primaryKey"`
|
||||
AccountID string `gorm:"index;not null"`
|
||||
Email string `gorm:"index;not null"`
|
||||
Name string `gorm:"not null"`
|
||||
Role string `gorm:"not null"`
|
||||
AutoGroups []string `gorm:"serializer:json"`
|
||||
HashedToken string `gorm:"index;not null"` // SHA-256 hash of the token (base64 encoded)
|
||||
ExpiresAt time.Time `gorm:"not null"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
CreatedBy string `gorm:"not null"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (UserInviteRecord) TableName() string {
|
||||
return "user_invites"
|
||||
}
|
||||
|
||||
// GenerateInviteToken creates a new invite token with the format: nbi_<secret><checksum>
|
||||
// Returns the hashed token (for storage) and the plain token (to give to the user)
|
||||
func GenerateInviteToken() (hashedToken string, plainToken string, err error) {
|
||||
secret, err := b.Random(InviteTokenSecretLength)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to generate random secret: %w", err)
|
||||
}
|
||||
|
||||
checksum := crc32.ChecksumIEEE([]byte(secret))
|
||||
encodedChecksum := base62.Encode(checksum)
|
||||
// Left-pad with '0' to ensure exactly 6 characters (fmt.Sprintf %s pads with spaces which breaks base62.Decode)
|
||||
paddedChecksum := encodedChecksum
|
||||
if len(paddedChecksum) < InviteTokenChecksumLength {
|
||||
paddedChecksum = strings.Repeat("0", InviteTokenChecksumLength-len(paddedChecksum)) + paddedChecksum
|
||||
}
|
||||
|
||||
plainToken = InviteTokenPrefix + secret + paddedChecksum
|
||||
hash := sha256.Sum256([]byte(plainToken))
|
||||
hashedToken = b64.StdEncoding.EncodeToString(hash[:])
|
||||
|
||||
return hashedToken, plainToken, nil
|
||||
}
|
||||
|
||||
// HashInviteToken creates a SHA-256 hash of the token (base64 encoded)
|
||||
func HashInviteToken(token string) string {
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
return b64.StdEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// ValidateInviteToken validates the token format and checksum.
|
||||
// Returns an error if the token is invalid.
|
||||
func ValidateInviteToken(token string) error {
|
||||
if len(token) != InviteTokenLength {
|
||||
return fmt.Errorf("invalid token length")
|
||||
}
|
||||
|
||||
prefix := token[:len(InviteTokenPrefix)]
|
||||
if prefix != InviteTokenPrefix {
|
||||
return fmt.Errorf("invalid token prefix")
|
||||
}
|
||||
|
||||
secret := token[len(InviteTokenPrefix) : len(InviteTokenPrefix)+InviteTokenSecretLength]
|
||||
encodedChecksum := token[len(InviteTokenPrefix)+InviteTokenSecretLength:]
|
||||
|
||||
verificationChecksum, err := base62.Decode(encodedChecksum)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checksum decoding failed: %w", err)
|
||||
}
|
||||
|
||||
secretChecksum := crc32.ChecksumIEEE([]byte(secret))
|
||||
if secretChecksum != verificationChecksum {
|
||||
return fmt.Errorf("checksum does not match")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsExpired checks if the invite has expired
|
||||
func (i *UserInviteRecord) IsExpired() bool {
|
||||
return time.Now().After(i.ExpiresAt)
|
||||
}
|
||||
|
||||
// UserInvite contains the result of creating or regenerating an invite
|
||||
type UserInvite struct {
|
||||
UserInfo *UserInfo
|
||||
InviteToken string
|
||||
InviteExpiresAt time.Time
|
||||
InviteCreatedAt time.Time
|
||||
}
|
||||
|
||||
// UserInviteInfo contains public information about an invite (for unauthenticated endpoint)
|
||||
type UserInviteInfo struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Valid bool `json:"valid"`
|
||||
InvitedBy string `json:"invited_by"`
|
||||
}
|
||||
|
||||
// NewInviteID generates a new invite ID using xid
|
||||
func NewInviteID() string {
|
||||
return xid.New().String()
|
||||
}
|
||||
|
||||
// EncryptSensitiveData encrypts the invite's sensitive fields (Email and Name) in place.
|
||||
func (i *UserInviteRecord) EncryptSensitiveData(enc *crypt.FieldEncrypt) error {
|
||||
if enc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
if i.Email != "" {
|
||||
i.Email, err = enc.Encrypt(i.Email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt email: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if i.Name != "" {
|
||||
i.Name, err = enc.Encrypt(i.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt name: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DecryptSensitiveData decrypts the invite's sensitive fields (Email and Name) in place.
|
||||
func (i *UserInviteRecord) DecryptSensitiveData(enc *crypt.FieldEncrypt) error {
|
||||
if enc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
if i.Email != "" {
|
||||
i.Email, err = enc.Decrypt(i.Email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt email: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if i.Name != "" {
|
||||
i.Name, err = enc.Decrypt(i.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt name: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of the UserInviteRecord
|
||||
func (i *UserInviteRecord) Copy() *UserInviteRecord {
|
||||
autoGroups := make([]string, len(i.AutoGroups))
|
||||
copy(autoGroups, i.AutoGroups)
|
||||
|
||||
return &UserInviteRecord{
|
||||
ID: i.ID,
|
||||
AccountID: i.AccountID,
|
||||
Email: i.Email,
|
||||
Name: i.Name,
|
||||
Role: i.Role,
|
||||
AutoGroups: autoGroups,
|
||||
HashedToken: i.HashedToken,
|
||||
ExpiresAt: i.ExpiresAt,
|
||||
CreatedAt: i.CreatedAt,
|
||||
CreatedBy: i.CreatedBy,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
b64 "encoding/base64"
|
||||
"hash/crc32"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/base62"
|
||||
"github.com/netbirdio/netbird/util/crypt"
|
||||
)
|
||||
|
||||
func TestUserInviteRecord_TableName(t *testing.T) {
|
||||
invite := UserInviteRecord{}
|
||||
assert.Equal(t, "user_invites", invite.TableName())
|
||||
}
|
||||
|
||||
func TestGenerateInviteToken_Success(t *testing.T) {
|
||||
hashedToken, plainToken, err := GenerateInviteToken()
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, hashedToken)
|
||||
assert.NotEmpty(t, plainToken)
|
||||
}
|
||||
|
||||
func TestGenerateInviteToken_Length(t *testing.T) {
|
||||
_, plainToken, err := GenerateInviteToken()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, plainToken, InviteTokenLength)
|
||||
}
|
||||
|
||||
func TestGenerateInviteToken_Prefix(t *testing.T) {
|
||||
_, plainToken, err := GenerateInviteToken()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, strings.HasPrefix(plainToken, InviteTokenPrefix))
|
||||
}
|
||||
|
||||
func TestGenerateInviteToken_Hashing(t *testing.T) {
|
||||
hashedToken, plainToken, err := GenerateInviteToken()
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedHash := sha256.Sum256([]byte(plainToken))
|
||||
expectedHashedToken := b64.StdEncoding.EncodeToString(expectedHash[:])
|
||||
assert.Equal(t, expectedHashedToken, hashedToken)
|
||||
}
|
||||
|
||||
func TestGenerateInviteToken_Checksum(t *testing.T) {
|
||||
_, plainToken, err := GenerateInviteToken()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Extract parts
|
||||
secret := plainToken[len(InviteTokenPrefix) : len(InviteTokenPrefix)+InviteTokenSecretLength]
|
||||
checksumStr := plainToken[len(InviteTokenPrefix)+InviteTokenSecretLength:]
|
||||
|
||||
// Verify checksum
|
||||
expectedChecksum := crc32.ChecksumIEEE([]byte(secret))
|
||||
actualChecksum, err := base62.Decode(checksumStr)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expectedChecksum, actualChecksum)
|
||||
}
|
||||
|
||||
func TestGenerateInviteToken_Uniqueness(t *testing.T) {
|
||||
tokens := make(map[string]bool)
|
||||
for i := 0; i < 100; i++ {
|
||||
_, plainToken, err := GenerateInviteToken()
|
||||
require.NoError(t, err)
|
||||
assert.False(t, tokens[plainToken], "Token should be unique")
|
||||
tokens[plainToken] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashInviteToken(t *testing.T) {
|
||||
token := "nbi_testtoken123456789012345678901234"
|
||||
hashedToken := HashInviteToken(token)
|
||||
|
||||
expectedHash := sha256.Sum256([]byte(token))
|
||||
expectedHashedToken := b64.StdEncoding.EncodeToString(expectedHash[:])
|
||||
assert.Equal(t, expectedHashedToken, hashedToken)
|
||||
}
|
||||
|
||||
func TestHashInviteToken_Consistency(t *testing.T) {
|
||||
token := "nbi_testtoken123456789012345678901234"
|
||||
hash1 := HashInviteToken(token)
|
||||
hash2 := HashInviteToken(token)
|
||||
assert.Equal(t, hash1, hash2)
|
||||
}
|
||||
|
||||
func TestHashInviteToken_DifferentTokens(t *testing.T) {
|
||||
token1 := "nbi_testtoken123456789012345678901234"
|
||||
token2 := "nbi_testtoken123456789012345678901235"
|
||||
hash1 := HashInviteToken(token1)
|
||||
hash2 := HashInviteToken(token2)
|
||||
assert.NotEqual(t, hash1, hash2)
|
||||
}
|
||||
|
||||
func TestValidateInviteToken_Success(t *testing.T) {
|
||||
_, plainToken, err := GenerateInviteToken()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ValidateInviteToken(plainToken)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestValidateInviteToken_InvalidLength(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
token string
|
||||
}{
|
||||
{"empty", ""},
|
||||
{"too short", "nbi_abc"},
|
||||
{"too long", "nbi_" + strings.Repeat("a", 50)},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidateInviteToken(tc.token)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid token length")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateInviteToken_InvalidPrefix(t *testing.T) {
|
||||
// Create a token with wrong prefix but correct length
|
||||
token := "xyz_" + strings.Repeat("a", 30) + "000000"
|
||||
err := ValidateInviteToken(token)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid token prefix")
|
||||
}
|
||||
|
||||
func TestValidateInviteToken_InvalidChecksum(t *testing.T) {
|
||||
// Create a token with correct format but invalid checksum
|
||||
token := InviteTokenPrefix + strings.Repeat("a", InviteTokenSecretLength) + "ZZZZZZ"
|
||||
err := ValidateInviteToken(token)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "checksum")
|
||||
}
|
||||
|
||||
func TestValidateInviteToken_ModifiedToken(t *testing.T) {
|
||||
_, plainToken, err := GenerateInviteToken()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Modify one character in the secret part
|
||||
modifiedToken := plainToken[:5] + "X" + plainToken[6:]
|
||||
err = ValidateInviteToken(modifiedToken)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestUserInviteRecord_IsExpired(t *testing.T) {
|
||||
t.Run("not expired", func(t *testing.T) {
|
||||
invite := &UserInviteRecord{
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}
|
||||
assert.False(t, invite.IsExpired())
|
||||
})
|
||||
|
||||
t.Run("expired", func(t *testing.T) {
|
||||
invite := &UserInviteRecord{
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
}
|
||||
assert.True(t, invite.IsExpired())
|
||||
})
|
||||
|
||||
t.Run("just expired", func(t *testing.T) {
|
||||
invite := &UserInviteRecord{
|
||||
ExpiresAt: time.Now().Add(-time.Second),
|
||||
}
|
||||
assert.True(t, invite.IsExpired())
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewInviteID(t *testing.T) {
|
||||
id := NewInviteID()
|
||||
assert.NotEmpty(t, id)
|
||||
assert.Len(t, id, 20) // xid generates 20 character IDs
|
||||
}
|
||||
|
||||
func TestNewInviteID_Uniqueness(t *testing.T) {
|
||||
ids := make(map[string]bool)
|
||||
for i := 0; i < 100; i++ {
|
||||
id := NewInviteID()
|
||||
assert.False(t, ids[id], "ID should be unique")
|
||||
ids[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserInviteRecord_EncryptDecryptSensitiveData(t *testing.T) {
|
||||
key, err := crypt.GenerateKey()
|
||||
require.NoError(t, err)
|
||||
fieldEncrypt, err := crypt.NewFieldEncrypt(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("encrypt and decrypt", func(t *testing.T) {
|
||||
invite := &UserInviteRecord{
|
||||
ID: "test-invite",
|
||||
AccountID: "test-account",
|
||||
Email: "test@example.com",
|
||||
Name: "Test User",
|
||||
Role: "user",
|
||||
}
|
||||
|
||||
// Encrypt
|
||||
err := invite.EncryptSensitiveData(fieldEncrypt)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify encrypted values are different from original
|
||||
assert.NotEqual(t, "test@example.com", invite.Email)
|
||||
assert.NotEqual(t, "Test User", invite.Name)
|
||||
|
||||
// Decrypt
|
||||
err = invite.DecryptSensitiveData(fieldEncrypt)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify decrypted values match original
|
||||
assert.Equal(t, "test@example.com", invite.Email)
|
||||
assert.Equal(t, "Test User", invite.Name)
|
||||
})
|
||||
|
||||
t.Run("encrypt empty fields", func(t *testing.T) {
|
||||
invite := &UserInviteRecord{
|
||||
ID: "test-invite",
|
||||
AccountID: "test-account",
|
||||
Email: "",
|
||||
Name: "",
|
||||
Role: "user",
|
||||
}
|
||||
|
||||
err := invite.EncryptSensitiveData(fieldEncrypt)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", invite.Email)
|
||||
assert.Equal(t, "", invite.Name)
|
||||
|
||||
err = invite.DecryptSensitiveData(fieldEncrypt)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", invite.Email)
|
||||
assert.Equal(t, "", invite.Name)
|
||||
})
|
||||
|
||||
t.Run("nil encryptor", func(t *testing.T) {
|
||||
invite := &UserInviteRecord{
|
||||
ID: "test-invite",
|
||||
AccountID: "test-account",
|
||||
Email: "test@example.com",
|
||||
Name: "Test User",
|
||||
Role: "user",
|
||||
}
|
||||
|
||||
err := invite.EncryptSensitiveData(nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test@example.com", invite.Email)
|
||||
assert.Equal(t, "Test User", invite.Name)
|
||||
|
||||
err = invite.DecryptSensitiveData(nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test@example.com", invite.Email)
|
||||
assert.Equal(t, "Test User", invite.Name)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserInviteRecord_Copy(t *testing.T) {
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(72 * time.Hour)
|
||||
|
||||
original := &UserInviteRecord{
|
||||
ID: "invite-id",
|
||||
AccountID: "account-id",
|
||||
Email: "test@example.com",
|
||||
Name: "Test User",
|
||||
Role: "user",
|
||||
AutoGroups: []string{"group1", "group2"},
|
||||
HashedToken: "hashed-token",
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: now,
|
||||
CreatedBy: "creator-id",
|
||||
}
|
||||
|
||||
copied := original.Copy()
|
||||
|
||||
// Verify all fields are copied
|
||||
assert.Equal(t, original.ID, copied.ID)
|
||||
assert.Equal(t, original.AccountID, copied.AccountID)
|
||||
assert.Equal(t, original.Email, copied.Email)
|
||||
assert.Equal(t, original.Name, copied.Name)
|
||||
assert.Equal(t, original.Role, copied.Role)
|
||||
assert.Equal(t, original.AutoGroups, copied.AutoGroups)
|
||||
assert.Equal(t, original.HashedToken, copied.HashedToken)
|
||||
assert.Equal(t, original.ExpiresAt, copied.ExpiresAt)
|
||||
assert.Equal(t, original.CreatedAt, copied.CreatedAt)
|
||||
assert.Equal(t, original.CreatedBy, copied.CreatedBy)
|
||||
|
||||
// Verify deep copy of AutoGroups (modifying copy doesn't affect original)
|
||||
copied.AutoGroups[0] = "modified"
|
||||
assert.NotEqual(t, original.AutoGroups[0], copied.AutoGroups[0])
|
||||
assert.Equal(t, "group1", original.AutoGroups[0])
|
||||
}
|
||||
|
||||
func TestUserInviteRecord_Copy_EmptyAutoGroups(t *testing.T) {
|
||||
original := &UserInviteRecord{
|
||||
ID: "invite-id",
|
||||
AccountID: "account-id",
|
||||
AutoGroups: []string{},
|
||||
}
|
||||
|
||||
copied := original.Copy()
|
||||
assert.NotNil(t, copied.AutoGroups)
|
||||
assert.Len(t, copied.AutoGroups, 0)
|
||||
}
|
||||
|
||||
func TestUserInviteRecord_Copy_NilAutoGroups(t *testing.T) {
|
||||
original := &UserInviteRecord{
|
||||
ID: "invite-id",
|
||||
AccountID: "account-id",
|
||||
AutoGroups: nil,
|
||||
}
|
||||
|
||||
copied := original.Copy()
|
||||
assert.NotNil(t, copied.AutoGroups)
|
||||
assert.Len(t, copied.AutoGroups, 0)
|
||||
}
|
||||
|
||||
func TestInviteTokenConstants(t *testing.T) {
|
||||
// Verify constants are consistent
|
||||
expectedLength := len(InviteTokenPrefix) + InviteTokenSecretLength + InviteTokenChecksumLength
|
||||
assert.Equal(t, InviteTokenLength, expectedLength)
|
||||
assert.Equal(t, 4, len(InviteTokenPrefix))
|
||||
assert.Equal(t, 30, InviteTokenSecretLength)
|
||||
assert.Equal(t, 6, InviteTokenChecksumLength)
|
||||
assert.Equal(t, 40, InviteTokenLength)
|
||||
assert.Equal(t, 259200, DefaultInviteExpirationSeconds) // 72 hours
|
||||
assert.Equal(t, 3600, MinInviteExpirationSeconds) // 1 hour
|
||||
}
|
||||
|
||||
func TestGenerateInviteToken_ValidatesOwnOutput(t *testing.T) {
|
||||
// Generate multiple tokens and ensure they all validate
|
||||
for i := 0; i < 50; i++ {
|
||||
_, plainToken, err := GenerateInviteToken()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ValidateInviteToken(plainToken)
|
||||
assert.NoError(t, err, "Generated token should always be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashInviteToken_MatchesGeneratedHash(t *testing.T) {
|
||||
hashedToken, plainToken, err := GenerateInviteToken()
|
||||
require.NoError(t, err)
|
||||
|
||||
// HashInviteToken should produce the same hash as GenerateInviteToken
|
||||
rehashedToken := HashInviteToken(plainToken)
|
||||
assert.Equal(t, hashedToken, rehashedToken)
|
||||
}
|
||||
+414
-4
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/shared/auth"
|
||||
@@ -249,6 +250,37 @@ func (am *DefaultAccountManager) ListUsers(ctx context.Context, accountID string
|
||||
return am.Store.GetAccountUsers(ctx, store.LockingStrengthNone, accountID)
|
||||
}
|
||||
|
||||
// UpdateUserPassword updates the password for a user in the embedded IdP.
|
||||
// This is only available when the embedded IdP is enabled.
|
||||
// Users can only change their own password.
|
||||
func (am *DefaultAccountManager) UpdateUserPassword(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error {
|
||||
if !IsEmbeddedIdp(am.idpManager) {
|
||||
return status.Errorf(status.PreconditionFailed, "password change is only available with embedded identity provider")
|
||||
}
|
||||
|
||||
if oldPassword == "" {
|
||||
return status.Errorf(status.InvalidArgument, "old password is required")
|
||||
}
|
||||
|
||||
if newPassword == "" {
|
||||
return status.Errorf(status.InvalidArgument, "new password is required")
|
||||
}
|
||||
|
||||
embeddedIdp, ok := am.idpManager.(*idp.EmbeddedIdPManager)
|
||||
if !ok {
|
||||
return status.Errorf(status.Internal, "failed to get embedded IdP manager")
|
||||
}
|
||||
|
||||
err := embeddedIdp.UpdateUserPassword(ctx, currentUserID, targetUserID, oldPassword, newPassword)
|
||||
if err != nil {
|
||||
return status.Errorf(status.InvalidArgument, "failed to update password: %v", err)
|
||||
}
|
||||
|
||||
am.StoreEvent(ctx, currentUserID, targetUserID, accountID, activity.UserPasswordChanged, nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (am *DefaultAccountManager) deleteServiceUser(ctx context.Context, accountID string, initiatorUserID string, targetUser *types.User) error {
|
||||
if err := am.Store.DeleteUser(ctx, accountID, targetUser.Id); err != nil {
|
||||
return err
|
||||
@@ -673,7 +705,7 @@ func (am *DefaultAccountManager) prepareUserUpdateEvents(ctx context.Context, ac
|
||||
"is_service_user": oldUser.IsServiceUser, "user_name": oldUser.ServiceUserName,
|
||||
}
|
||||
eventsToStore = append(eventsToStore, func() {
|
||||
am.StoreEvent(ctx, oldUser.Id, oldUser.Id, accountID, activity.GroupAddedToUser, meta)
|
||||
am.StoreEvent(ctx, initiatorUserID, oldUser.Id, accountID, activity.GroupAddedToUser, meta)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -687,7 +719,7 @@ func (am *DefaultAccountManager) prepareUserUpdateEvents(ctx context.Context, ac
|
||||
"is_service_user": oldUser.IsServiceUser, "user_name": oldUser.ServiceUserName,
|
||||
}
|
||||
eventsToStore = append(eventsToStore, func() {
|
||||
am.StoreEvent(ctx, oldUser.Id, oldUser.Id, accountID, activity.GroupRemovedFromUser, meta)
|
||||
am.StoreEvent(ctx, initiatorUserID, oldUser.Id, accountID, activity.GroupRemovedFromUser, meta)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -806,7 +838,20 @@ func (am *DefaultAccountManager) getUserInfo(ctx context.Context, user *types.Us
|
||||
}
|
||||
return user.ToUserInfo(userData)
|
||||
}
|
||||
return user.ToUserInfo(nil)
|
||||
|
||||
userInfo, err := user.ToUserInfo(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// For embedded IDP users, extract the IdPID (connector ID) from the encoded user ID
|
||||
if IsEmbeddedIdp(am.idpManager) && !user.IsServiceUser {
|
||||
if _, connectorID, decodeErr := dex.DecodeDexUserID(user.Id); decodeErr == nil && connectorID != "" {
|
||||
userInfo.IdPID = connectorID
|
||||
}
|
||||
}
|
||||
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
// validateUserUpdate validates the update operation for a user.
|
||||
@@ -1238,7 +1283,7 @@ func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountI
|
||||
addPeerRemovedEvent()
|
||||
}
|
||||
|
||||
meta := map[string]any{"name": targetUserInfo.Name, "email": targetUserInfo.Email, "created_at": targetUser.CreatedAt}
|
||||
meta := map[string]any{"name": targetUserInfo.Name, "email": targetUserInfo.Email, "created_at": targetUser.CreatedAt, "issued": targetUser.Issued}
|
||||
am.StoreEvent(ctx, initiatorUserID, targetUser.Id, accountID, activity.UserDeleted, meta)
|
||||
|
||||
return updateAccountPeers, nil
|
||||
@@ -1409,3 +1454,368 @@ func (am *DefaultAccountManager) RejectUser(ctx context.Context, accountID, init
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateUserInvite creates an invite link for a new user in the embedded IdP.
|
||||
// The user is NOT created until the invite is accepted.
|
||||
func (am *DefaultAccountManager) CreateUserInvite(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) {
|
||||
if !IsEmbeddedIdp(am.idpManager) {
|
||||
return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
|
||||
}
|
||||
|
||||
if err := validateUserInvite(invite); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create)
|
||||
if err != nil {
|
||||
return nil, status.NewPermissionValidationError(err)
|
||||
}
|
||||
if !allowed {
|
||||
return nil, status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
// Check if user already exists in NetBird DB
|
||||
existingUsers, err := am.Store.GetAccountUsers(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, user := range existingUsers {
|
||||
if strings.EqualFold(user.Email, invite.Email) {
|
||||
return nil, status.Errorf(status.UserAlreadyExists, "user with this email already exists")
|
||||
}
|
||||
}
|
||||
|
||||
// Check if invite already exists for this email
|
||||
existingInvite, err := am.Store.GetUserInviteByEmail(ctx, store.LockingStrengthNone, accountID, invite.Email)
|
||||
if err != nil {
|
||||
if sErr, ok := status.FromError(err); !ok || sErr.Type() != status.NotFound {
|
||||
return nil, fmt.Errorf("failed to check existing invites: %w", err)
|
||||
}
|
||||
}
|
||||
if existingInvite != nil {
|
||||
return nil, status.Errorf(status.AlreadyExists, "invite already exists for this email")
|
||||
}
|
||||
|
||||
// Calculate expiration time
|
||||
if expiresIn <= 0 {
|
||||
expiresIn = types.DefaultInviteExpirationSeconds
|
||||
}
|
||||
|
||||
if expiresIn < types.MinInviteExpirationSeconds {
|
||||
return nil, status.Errorf(status.InvalidArgument, "invite expiration must be at least 1 hour")
|
||||
}
|
||||
expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second)
|
||||
|
||||
// Generate invite token
|
||||
inviteID := types.NewInviteID()
|
||||
hashedToken, plainToken, err := types.GenerateInviteToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate invite token: %w", err)
|
||||
}
|
||||
|
||||
// Create the invite record (no user created yet)
|
||||
userInvite := &types.UserInviteRecord{
|
||||
ID: inviteID,
|
||||
AccountID: accountID,
|
||||
Email: invite.Email,
|
||||
Name: invite.Name,
|
||||
Role: invite.Role,
|
||||
AutoGroups: invite.AutoGroups,
|
||||
HashedToken: hashedToken,
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
CreatedBy: initiatorUserID,
|
||||
}
|
||||
|
||||
if err := am.Store.SaveUserInvite(ctx, userInvite); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
am.StoreEvent(ctx, initiatorUserID, inviteID, accountID, activity.UserInviteLinkCreated, map[string]any{"email": invite.Email})
|
||||
|
||||
return &types.UserInvite{
|
||||
UserInfo: &types.UserInfo{
|
||||
ID: inviteID,
|
||||
Email: invite.Email,
|
||||
Name: invite.Name,
|
||||
Role: invite.Role,
|
||||
AutoGroups: invite.AutoGroups,
|
||||
Status: string(types.UserStatusInvited),
|
||||
Issued: types.UserIssuedAPI,
|
||||
},
|
||||
InviteToken: plainToken,
|
||||
InviteExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetUserInviteInfo retrieves invite information from a token (public endpoint).
|
||||
func (am *DefaultAccountManager) GetUserInviteInfo(ctx context.Context, token string) (*types.UserInviteInfo, error) {
|
||||
if err := types.ValidateInviteToken(token); err != nil {
|
||||
return nil, status.Errorf(status.InvalidArgument, "invalid invite token: %v", err)
|
||||
}
|
||||
|
||||
hashedToken := types.HashInviteToken(token)
|
||||
invite, err := am.Store.GetUserInviteByHashedToken(ctx, store.LockingStrengthNone, hashedToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get the inviter's name
|
||||
invitedBy := ""
|
||||
if invite.CreatedBy != "" {
|
||||
inviter, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, invite.CreatedBy)
|
||||
if err == nil && inviter != nil {
|
||||
invitedBy = inviter.Name
|
||||
}
|
||||
}
|
||||
|
||||
return &types.UserInviteInfo{
|
||||
Email: invite.Email,
|
||||
Name: invite.Name,
|
||||
ExpiresAt: invite.ExpiresAt,
|
||||
Valid: !invite.IsExpired(),
|
||||
InvitedBy: invitedBy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListUserInvites returns all invites for an account.
|
||||
func (am *DefaultAccountManager) ListUserInvites(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error) {
|
||||
if !IsEmbeddedIdp(am.idpManager) {
|
||||
return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
|
||||
}
|
||||
|
||||
allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Read)
|
||||
if err != nil {
|
||||
return nil, status.NewPermissionValidationError(err)
|
||||
}
|
||||
if !allowed {
|
||||
return nil, status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
records, err := am.Store.GetAccountUserInvites(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
invites := make([]*types.UserInvite, 0, len(records))
|
||||
for _, record := range records {
|
||||
invites = append(invites, &types.UserInvite{
|
||||
UserInfo: &types.UserInfo{
|
||||
ID: record.ID,
|
||||
Email: record.Email,
|
||||
Name: record.Name,
|
||||
Role: record.Role,
|
||||
AutoGroups: record.AutoGroups,
|
||||
},
|
||||
InviteExpiresAt: record.ExpiresAt,
|
||||
InviteCreatedAt: record.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return invites, nil
|
||||
}
|
||||
|
||||
// AcceptUserInvite accepts an invite and creates the user in both IdP and NetBird DB.
|
||||
func (am *DefaultAccountManager) AcceptUserInvite(ctx context.Context, token, password string) error {
|
||||
if !IsEmbeddedIdp(am.idpManager) {
|
||||
return status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
|
||||
}
|
||||
|
||||
if password == "" {
|
||||
return status.Errorf(status.InvalidArgument, "password is required")
|
||||
}
|
||||
|
||||
if err := validatePassword(password); err != nil {
|
||||
return status.Errorf(status.InvalidArgument, "invalid password: %v", err)
|
||||
}
|
||||
|
||||
if err := types.ValidateInviteToken(token); err != nil {
|
||||
return status.Errorf(status.InvalidArgument, "invalid invite token: %v", err)
|
||||
}
|
||||
|
||||
hashedToken := types.HashInviteToken(token)
|
||||
invite, err := am.Store.GetUserInviteByHashedToken(ctx, store.LockingStrengthUpdate, hashedToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if invite.IsExpired() {
|
||||
return status.Errorf(status.InvalidArgument, "invite has expired")
|
||||
}
|
||||
|
||||
// Create user in Dex with the provided password
|
||||
embeddedIdp, ok := am.idpManager.(*idp.EmbeddedIdPManager)
|
||||
if !ok {
|
||||
return status.Errorf(status.Internal, "failed to get embedded IdP manager")
|
||||
}
|
||||
|
||||
idpUser, err := embeddedIdp.CreateUserWithPassword(ctx, invite.Email, password, invite.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create user in IdP: %w", err)
|
||||
}
|
||||
|
||||
// Create user in NetBird DB
|
||||
newUser := &types.User{
|
||||
Id: idpUser.ID,
|
||||
AccountID: invite.AccountID,
|
||||
Role: types.StrRoleToUserRole(invite.Role),
|
||||
AutoGroups: invite.AutoGroups,
|
||||
Issued: types.UserIssuedAPI,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Email: invite.Email,
|
||||
Name: invite.Name,
|
||||
}
|
||||
|
||||
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
if err := transaction.SaveUser(ctx, newUser); err != nil {
|
||||
return fmt.Errorf("failed to save user: %w", err)
|
||||
}
|
||||
if err := transaction.DeleteUserInvite(ctx, invite.ID); err != nil {
|
||||
return fmt.Errorf("failed to delete invite: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// Best-effort rollback: delete the IdP user to avoid orphaned records
|
||||
if deleteErr := embeddedIdp.DeleteUser(ctx, idpUser.ID); deleteErr != nil {
|
||||
log.WithContext(ctx).WithError(deleteErr).Errorf("failed to rollback IdP user %s after transaction failure", idpUser.ID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
am.StoreEvent(ctx, newUser.Id, newUser.Id, invite.AccountID, activity.UserInviteLinkAccepted, map[string]any{"email": invite.Email})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegenerateUserInvite creates a new invite token for an existing invite, invalidating the previous one.
|
||||
func (am *DefaultAccountManager) RegenerateUserInvite(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error) {
|
||||
if !IsEmbeddedIdp(am.idpManager) {
|
||||
return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
|
||||
}
|
||||
|
||||
allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Update)
|
||||
if err != nil {
|
||||
return nil, status.NewPermissionValidationError(err)
|
||||
}
|
||||
if !allowed {
|
||||
return nil, status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
// Get existing invite
|
||||
existingInvite, err := am.Store.GetUserInviteByID(ctx, store.LockingStrengthUpdate, accountID, inviteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Calculate expiration time
|
||||
if expiresIn <= 0 {
|
||||
expiresIn = types.DefaultInviteExpirationSeconds
|
||||
}
|
||||
if expiresIn < types.MinInviteExpirationSeconds {
|
||||
return nil, status.Errorf(status.InvalidArgument, "invite expiration must be at least 1 hour")
|
||||
}
|
||||
expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second)
|
||||
|
||||
// Generate new invite token
|
||||
hashedToken, plainToken, err := types.GenerateInviteToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate invite token: %w", err)
|
||||
}
|
||||
|
||||
// Update existing invite with new token and expiration
|
||||
existingInvite.HashedToken = hashedToken
|
||||
existingInvite.ExpiresAt = expiresAt
|
||||
existingInvite.CreatedBy = initiatorUserID
|
||||
|
||||
err = am.Store.SaveUserInvite(ctx, existingInvite)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
am.StoreEvent(ctx, initiatorUserID, existingInvite.ID, accountID, activity.UserInviteLinkRegenerated, map[string]any{"email": existingInvite.Email})
|
||||
|
||||
return &types.UserInvite{
|
||||
UserInfo: &types.UserInfo{
|
||||
ID: existingInvite.ID,
|
||||
Email: existingInvite.Email,
|
||||
Name: existingInvite.Name,
|
||||
Role: existingInvite.Role,
|
||||
AutoGroups: existingInvite.AutoGroups,
|
||||
Status: string(types.UserStatusInvited),
|
||||
Issued: types.UserIssuedAPI,
|
||||
},
|
||||
InviteToken: plainToken,
|
||||
InviteExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteUserInvite deletes an existing invite by ID.
|
||||
func (am *DefaultAccountManager) DeleteUserInvite(ctx context.Context, accountID, initiatorUserID, inviteID string) error {
|
||||
if !IsEmbeddedIdp(am.idpManager) {
|
||||
return status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
|
||||
}
|
||||
|
||||
allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
|
||||
if err != nil {
|
||||
return status.NewPermissionValidationError(err)
|
||||
}
|
||||
if !allowed {
|
||||
return status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
invite, err := am.Store.GetUserInviteByID(ctx, store.LockingStrengthUpdate, accountID, inviteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := am.Store.DeleteUserInvite(ctx, inviteID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
am.StoreEvent(ctx, initiatorUserID, inviteID, accountID, activity.UserInviteLinkDeleted, map[string]any{"email": invite.Email})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const minPasswordLength = 8
|
||||
|
||||
// validatePassword checks password strength requirements:
|
||||
// - Minimum 8 characters
|
||||
// - At least 1 digit
|
||||
// - At least 1 uppercase letter
|
||||
// - At least 1 special character
|
||||
func validatePassword(password string) error {
|
||||
if len(password) < minPasswordLength {
|
||||
return errors.New("password must be at least 8 characters long")
|
||||
}
|
||||
|
||||
var hasDigit, hasUpper, hasSpecial bool
|
||||
for _, c := range password {
|
||||
switch {
|
||||
case unicode.IsDigit(c):
|
||||
hasDigit = true
|
||||
case unicode.IsUpper(c):
|
||||
hasUpper = true
|
||||
case !unicode.IsLetter(c) && !unicode.IsDigit(c):
|
||||
hasSpecial = true
|
||||
}
|
||||
}
|
||||
|
||||
var missing []string
|
||||
if !hasDigit {
|
||||
missing = append(missing, "one digit")
|
||||
}
|
||||
if !hasUpper {
|
||||
missing = append(missing, "one uppercase letter")
|
||||
}
|
||||
if !hasSpecial {
|
||||
missing = append(missing, "one special character")
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
return errors.New("password must contain at least " + strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user