mirror of
https://github.com/netbirdio/management-refactor.git
synced 2026-05-22 17:12:59 -07:00
extract repo
This commit is contained in:
@@ -1,310 +0,0 @@
|
||||
package sqlite
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/netbirdio/management-refactor/management/server/activity"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupDatabase(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
dbFile := filepath.Join(t.TempDir(), eventSinkDB)
|
||||
db, err := sql.Open("sqlite3", dbFile)
|
||||
require.NoError(t, err, "Failed to open database")
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = db.Close()
|
||||
})
|
||||
|
||||
_, err = db.Exec(createTableQuery)
|
||||
require.NoError(t, err, "Failed to create events table")
|
||||
|
||||
_, err = db.Exec(`CREATE TABLE deleted_users (id TEXT NOT NULL, email TEXT NOT NULL, name TEXT);`)
|
||||
require.NoError(t, err, "Failed to create deleted_users table")
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func TestMigrate(t *testing.T) {
|
||||
db := setupDatabase(t)
|
||||
|
||||
key, err := GenerateKey()
|
||||
require.NoError(t, err, "Failed to generate key")
|
||||
|
||||
crypt, err := NewFieldEncrypt(key)
|
||||
require.NoError(t, err, "Failed to initialize FieldEncrypt")
|
||||
|
||||
legacyEmail := crypt.LegacyEncrypt("testaccount@test.com")
|
||||
legacyName := crypt.LegacyEncrypt("Test Account")
|
||||
|
||||
_, err = db.Exec(`INSERT INTO events(activity, timestamp, initiator_id, target_id, account_id, meta) VALUES(?, ?, ?, ?, ?, ?)`,
|
||||
activity.UserDeleted, time.Now(), "initiatorID", "targetID", "accountID", "")
|
||||
require.NoError(t, err, "Failed to insert event")
|
||||
|
||||
_, err = db.Exec(`INSERT INTO deleted_users(id, email, name) VALUES(?, ?, ?)`, "targetID", legacyEmail, legacyName)
|
||||
require.NoError(t, err, "Failed to insert legacy encrypted data")
|
||||
|
||||
colExists, err := checkColumnExists(db, "deleted_users", "enc_algo")
|
||||
require.NoError(t, err, "Failed to check if enc_algo column exists")
|
||||
require.False(t, colExists, "enc_algo column should not exist before migration")
|
||||
|
||||
err = migrate(context.Background(), crypt, db)
|
||||
require.NoError(t, err, "Migration failed")
|
||||
|
||||
colExists, err = checkColumnExists(db, "deleted_users", "enc_algo")
|
||||
require.NoError(t, err, "Failed to check if enc_algo column exists after migration")
|
||||
require.True(t, colExists, "enc_algo column should exist after migration")
|
||||
|
||||
var encAlgo string
|
||||
err = db.QueryRow(`SELECT enc_algo FROM deleted_users LIMIT 1`, "").Scan(&encAlgo)
|
||||
require.NoError(t, err, "Failed to select updated data")
|
||||
require.Equal(t, gcmEncAlgo, encAlgo, "enc_algo should be set to 'GCM' after migration")
|
||||
|
||||
store, err := createStore(crypt, db)
|
||||
require.NoError(t, err, "Failed to create store")
|
||||
|
||||
events, err := store.Get(context.Background(), "accountID", 0, 1, false)
|
||||
require.NoError(t, err, "Failed to get events")
|
||||
|
||||
require.Len(t, events, 1, "Should have one event")
|
||||
require.Equal(t, activity.UserDeleted, events[0].Activity, "activity should match")
|
||||
require.Equal(t, "initiatorID", events[0].InitiatorID, "initiator id should match")
|
||||
require.Equal(t, "targetID", events[0].TargetID, "target id should match")
|
||||
require.Equal(t, "accountID", events[0].AccountID, "account id should match")
|
||||
require.Equal(t, "testaccount@test.com", events[0].Meta["email"], "email should match")
|
||||
require.Equal(t, "Test Account", events[0].Meta["username"], "username should match")
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/netbirdio/management-refactor/management/server/activity"
|
||||
)
|
||||
|
||||
func TestNewSQLiteStore(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
key, _ := GenerateKey()
|
||||
store, err := NewSQLiteStore(context.Background(), dataDir, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
defer store.Close(context.Background()) //nolint
|
||||
|
||||
accountID := "account_1"
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
_, err = store.Save(context.Background(), &activity.Event{
|
||||
Timestamp: time.Now().UTC(),
|
||||
Activity: activity.PeerAddedByUser,
|
||||
InitiatorID: "user_" + fmt.Sprint(i),
|
||||
TargetID: "peer_" + fmt.Sprint(i),
|
||||
AccountID: accountID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
result, err := store.Get(context.Background(), accountID, 0, 10, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.Len(t, result, 10)
|
||||
assert.True(t, result[0].Timestamp.Before(result[len(result)-1].Timestamp))
|
||||
|
||||
result, err = store.Get(context.Background(), accountID, 0, 5, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.Len(t, result, 5)
|
||||
assert.True(t, result[0].Timestamp.After(result[len(result)-1].Timestamp))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,178 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/management-refactor/management/proto"
|
||||
"github.com/netbirdio/management-refactor/management/server/telemetry"
|
||||
"github.com/netbirdio/management-refactor/management/server/types"
|
||||
)
|
||||
|
||||
const channelBufferSize = 100
|
||||
|
||||
type UpdateMessage struct {
|
||||
Update *proto.SyncResponse
|
||||
NetworkMap *types.NetworkMap
|
||||
}
|
||||
|
||||
type UpdateChannel struct {
|
||||
// peerChannels is an update channel indexed by Peer.ID
|
||||
peerChannels map[string]chan *UpdateMessage
|
||||
// channelsMux keeps the mutex to access peerChannels
|
||||
channelsMux *sync.RWMutex
|
||||
// metrics provides method to collect application metrics
|
||||
metrics telemetry.AppMetrics
|
||||
}
|
||||
|
||||
// NewUpdateChannel returns a new instance of UpdateChannel
|
||||
func NewUpdateChannel(metrics telemetry.AppMetrics) *UpdateChannel {
|
||||
return &UpdateChannel{
|
||||
peerChannels: make(map[string]chan *UpdateMessage),
|
||||
channelsMux: &sync.RWMutex{},
|
||||
metrics: metrics,
|
||||
}
|
||||
}
|
||||
|
||||
// SendUpdate sends update message to the peer's channel
|
||||
func (p *UpdateChannel) SendUpdate(ctx context.Context, peerID string, update *UpdateMessage) {
|
||||
start := time.Now()
|
||||
var found, dropped bool
|
||||
|
||||
p.channelsMux.RLock()
|
||||
|
||||
defer func() {
|
||||
p.channelsMux.RUnlock()
|
||||
if p.metrics != nil {
|
||||
p.metrics.UpdateChannelMetrics().CountSendUpdateDuration(time.Since(start), found, dropped)
|
||||
}
|
||||
}()
|
||||
|
||||
if channel, ok := p.peerChannels[peerID]; ok {
|
||||
found = true
|
||||
select {
|
||||
case channel <- update:
|
||||
log.WithContext(ctx).Debugf("update was sent to channel for peer %s", peerID)
|
||||
default:
|
||||
dropped = true
|
||||
log.WithContext(ctx).Warnf("channel for peer %s is %d full or closed", peerID, len(channel))
|
||||
}
|
||||
} else {
|
||||
log.WithContext(ctx).Debugf("peer %s has no channel", peerID)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateChannel creates a go channel for a given peer used to deliver updates relevant to the peer.
|
||||
func (p *UpdateChannel) CreateChannel(ctx context.Context, peerID string) chan *UpdateMessage {
|
||||
start := time.Now()
|
||||
|
||||
closed := false
|
||||
|
||||
p.channelsMux.Lock()
|
||||
defer func() {
|
||||
p.channelsMux.Unlock()
|
||||
if p.metrics != nil {
|
||||
p.metrics.UpdateChannelMetrics().CountCreateChannelDuration(time.Since(start), closed)
|
||||
}
|
||||
}()
|
||||
|
||||
if channel, ok := p.peerChannels[peerID]; ok {
|
||||
closed = true
|
||||
delete(p.peerChannels, peerID)
|
||||
close(channel)
|
||||
}
|
||||
// mbragin: todo shouldn't it be more? or configurable?
|
||||
channel := make(chan *UpdateMessage, channelBufferSize)
|
||||
p.peerChannels[peerID] = channel
|
||||
|
||||
log.WithContext(ctx).Debugf("opened updates channel for a peer %s", peerID)
|
||||
|
||||
return channel
|
||||
}
|
||||
|
||||
func (p *UpdateChannel) closeChannel(ctx context.Context, peerID string) {
|
||||
if channel, ok := p.peerChannels[peerID]; ok {
|
||||
delete(p.peerChannels, peerID)
|
||||
close(channel)
|
||||
|
||||
log.WithContext(ctx).Debugf("closed updates channel of a peer %s", peerID)
|
||||
return
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Debugf("closing updates channel: peer %s has no channel", peerID)
|
||||
}
|
||||
|
||||
// CloseChannels closes updates channel for each given peer
|
||||
func (p *UpdateChannel) CloseChannels(ctx context.Context, peerIDs []string) {
|
||||
start := time.Now()
|
||||
|
||||
p.channelsMux.Lock()
|
||||
defer func() {
|
||||
p.channelsMux.Unlock()
|
||||
if p.metrics != nil {
|
||||
p.metrics.UpdateChannelMetrics().CountCloseChannelsDuration(time.Since(start), len(peerIDs))
|
||||
}
|
||||
}()
|
||||
|
||||
for _, id := range peerIDs {
|
||||
p.closeChannel(ctx, id)
|
||||
}
|
||||
}
|
||||
|
||||
// CloseChannel closes updates channel of a given peer
|
||||
func (p *UpdateChannel) CloseChannel(ctx context.Context, peerID string) {
|
||||
start := time.Now()
|
||||
|
||||
p.channelsMux.Lock()
|
||||
defer func() {
|
||||
p.channelsMux.Unlock()
|
||||
if p.metrics != nil {
|
||||
p.metrics.UpdateChannelMetrics().CountCloseChannelDuration(time.Since(start))
|
||||
}
|
||||
}()
|
||||
|
||||
p.closeChannel(ctx, peerID)
|
||||
}
|
||||
|
||||
// GetAllConnectedPeers returns a copy of the connected peers map
|
||||
func (p *UpdateChannel) GetAllConnectedPeers() map[string]struct{} {
|
||||
start := time.Now()
|
||||
|
||||
p.channelsMux.RLock()
|
||||
|
||||
m := make(map[string]struct{})
|
||||
|
||||
defer func() {
|
||||
p.channelsMux.RUnlock()
|
||||
if p.metrics != nil {
|
||||
p.metrics.UpdateChannelMetrics().CountGetAllConnectedPeersDuration(time.Since(start), len(m))
|
||||
}
|
||||
}()
|
||||
|
||||
for ID := range p.peerChannels {
|
||||
m[ID] = struct{}{}
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// HasChannel returns true if peers has channel in update manager, otherwise false
|
||||
func (p *UpdateChannel) HasChannel(peerID string) bool {
|
||||
start := time.Now()
|
||||
|
||||
p.channelsMux.RLock()
|
||||
|
||||
defer func() {
|
||||
p.channelsMux.RUnlock()
|
||||
if p.metrics != nil {
|
||||
p.metrics.UpdateChannelMetrics().CountHasChannelDuration(time.Since(start))
|
||||
}
|
||||
}()
|
||||
|
||||
_, ok := p.peerChannels[peerID]
|
||||
|
||||
return ok
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/management-refactor/management/proto"
|
||||
)
|
||||
|
||||
// var peersUpdater *UpdateChannel
|
||||
|
||||
func TestCreateChannel(t *testing.T) {
|
||||
peer := "test-create"
|
||||
peersUpdater := NewUpdateChannel(nil)
|
||||
defer peersUpdater.CloseChannel(context.Background(), peer)
|
||||
|
||||
_ = peersUpdater.CreateChannel(context.Background(), peer)
|
||||
if _, ok := peersUpdater.peerChannels[peer]; !ok {
|
||||
t.Error("Error creating the channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendUpdate(t *testing.T) {
|
||||
peer := "test-sendupdate"
|
||||
peersUpdater := NewUpdateChannel(nil)
|
||||
update1 := &UpdateMessage{Update: &proto.SyncResponse{
|
||||
NetworkMap: &proto.NetworkMap{
|
||||
Serial: 0,
|
||||
},
|
||||
}}
|
||||
_ = peersUpdater.CreateChannel(context.Background(), peer)
|
||||
if _, ok := peersUpdater.peerChannels[peer]; !ok {
|
||||
t.Error("Error creating the channel")
|
||||
}
|
||||
peersUpdater.SendUpdate(context.Background(), peer, update1)
|
||||
select {
|
||||
case <-peersUpdater.peerChannels[peer]:
|
||||
default:
|
||||
t.Error("Update wasn't send")
|
||||
}
|
||||
|
||||
for range [channelBufferSize]int{} {
|
||||
peersUpdater.SendUpdate(context.Background(), peer, update1)
|
||||
}
|
||||
|
||||
update2 := &UpdateMessage{Update: &proto.SyncResponse{
|
||||
NetworkMap: &proto.NetworkMap{
|
||||
Serial: 10,
|
||||
},
|
||||
}}
|
||||
|
||||
peersUpdater.SendUpdate(context.Background(), peer, update2)
|
||||
timeout := time.After(5 * time.Second)
|
||||
for range [channelBufferSize]int{} {
|
||||
select {
|
||||
case <-timeout:
|
||||
t.Error("timed out reading previously sent updates")
|
||||
case updateReader := <-peersUpdater.peerChannels[peer]:
|
||||
if updateReader.Update.NetworkMap.Serial == update2.Update.NetworkMap.Serial {
|
||||
t.Error("got the update that shouldn't have been sent")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestCloseChannel(t *testing.T) {
|
||||
peer := "test-close"
|
||||
peersUpdater := NewUpdateChannel(nil)
|
||||
_ = peersUpdater.CreateChannel(context.Background(), peer)
|
||||
if _, ok := peersUpdater.peerChannels[peer]; !ok {
|
||||
t.Error("Error creating the channel")
|
||||
}
|
||||
peersUpdater.CloseChannel(context.Background(), peer)
|
||||
if _, ok := peersUpdater.peerChannels[peer]; ok {
|
||||
t.Error("Error closing the channel")
|
||||
}
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/netbirdio/management-refactor/management/server/auth"
|
||||
nbjwt "github.com/netbirdio/management-refactor/management/server/auth/jwt"
|
||||
nbcontext "github.com/netbirdio/management-refactor/management/server/context"
|
||||
"github.com/netbirdio/management-refactor/management/server/util"
|
||||
|
||||
"github.com/netbirdio/management-refactor/management/server/http/middleware/bypass"
|
||||
"github.com/netbirdio/management-refactor/management/server/types"
|
||||
)
|
||||
|
||||
const (
|
||||
audience = "audience"
|
||||
userIDClaim = "userIDClaim"
|
||||
accountID = "accountID"
|
||||
domain = "domain"
|
||||
domainCategory = "domainCategory"
|
||||
userID = "userID"
|
||||
tokenID = "tokenID"
|
||||
PAT = "nbp_PAT"
|
||||
JWT = "JWT"
|
||||
wrongToken = "wrongToken"
|
||||
)
|
||||
|
||||
var testAccount = &types.Account{
|
||||
Id: accountID,
|
||||
Domain: domain,
|
||||
Users: map[string]*types.User{
|
||||
userID: {
|
||||
Id: userID,
|
||||
AccountID: accountID,
|
||||
PATs: map[string]*types.PersonalAccessToken{
|
||||
tokenID: {
|
||||
ID: tokenID,
|
||||
Name: "My first token",
|
||||
HashedToken: "someHash",
|
||||
ExpirationDate: util.ToPtr(time.Now().UTC().AddDate(0, 0, 7)),
|
||||
CreatedBy: userID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
LastUsed: util.ToPtr(time.Now().UTC()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func mockGetAccountInfoFromPAT(_ context.Context, token string) (user *types.User, pat *types.PersonalAccessToken, domain string, category string, err error) {
|
||||
if token == PAT {
|
||||
return testAccount.Users[userID], testAccount.Users[userID].PATs[tokenID], testAccount.Domain, testAccount.DomainCategory, nil
|
||||
}
|
||||
return nil, nil, "", "", fmt.Errorf("PAT invalid")
|
||||
}
|
||||
|
||||
func mockValidateAndParseToken(_ context.Context, token string) (nbcontext.UserAuth, *jwt.Token, error) {
|
||||
if token == JWT {
|
||||
return nbcontext.UserAuth{
|
||||
UserId: userID,
|
||||
AccountId: accountID,
|
||||
Domain: testAccount.Domain,
|
||||
DomainCategory: testAccount.DomainCategory,
|
||||
},
|
||||
&jwt.Token{
|
||||
Claims: jwt.MapClaims{
|
||||
userIDClaim: userID,
|
||||
audience + nbjwt.AccountIDSuffix: accountID,
|
||||
},
|
||||
Valid: true,
|
||||
}, nil
|
||||
}
|
||||
return nbcontext.UserAuth{}, nil, fmt.Errorf("JWT invalid")
|
||||
}
|
||||
|
||||
func mockMarkPATUsed(_ context.Context, token string) error {
|
||||
if token == tokenID {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("Should never get reached")
|
||||
}
|
||||
|
||||
func mockEnsureUserAccessByJWTGroups(_ context.Context, userAuth nbcontext.UserAuth, token *jwt.Token) (nbcontext.UserAuth, error) {
|
||||
if userAuth.IsChild || userAuth.IsPAT {
|
||||
return userAuth, nil
|
||||
}
|
||||
|
||||
if testAccount.Id != userAuth.AccountId {
|
||||
return userAuth, fmt.Errorf("account with id %s does not exist", userAuth.AccountId)
|
||||
}
|
||||
|
||||
if _, ok := testAccount.Users[userAuth.UserId]; !ok {
|
||||
return userAuth, fmt.Errorf("user with id %s does not exist", userAuth.UserId)
|
||||
}
|
||||
|
||||
return userAuth, nil
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_Handler(t *testing.T) {
|
||||
tt := []struct {
|
||||
name string
|
||||
path string
|
||||
authHeader string
|
||||
expectedStatusCode int
|
||||
shouldBypassAuth bool
|
||||
}{
|
||||
{
|
||||
name: "Valid PAT Token",
|
||||
path: "/test",
|
||||
authHeader: "Token " + PAT,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
{
|
||||
name: "Invalid PAT Token",
|
||||
path: "/test",
|
||||
authHeader: "Token " + wrongToken,
|
||||
expectedStatusCode: 401,
|
||||
},
|
||||
{
|
||||
name: "Fallback to PAT Token",
|
||||
path: "/test",
|
||||
authHeader: "Bearer " + PAT,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
{
|
||||
name: "Valid JWT Token",
|
||||
path: "/test",
|
||||
authHeader: "Bearer " + JWT,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
{
|
||||
name: "Invalid JWT Token",
|
||||
path: "/test",
|
||||
authHeader: "Bearer " + wrongToken,
|
||||
expectedStatusCode: 401,
|
||||
},
|
||||
{
|
||||
name: "Basic Auth",
|
||||
path: "/test",
|
||||
authHeader: "Basic " + PAT,
|
||||
expectedStatusCode: 401,
|
||||
},
|
||||
{
|
||||
name: "Webhook Path Bypass",
|
||||
path: "/webhook",
|
||||
authHeader: "",
|
||||
expectedStatusCode: 200,
|
||||
shouldBypassAuth: true,
|
||||
},
|
||||
{
|
||||
name: "Webhook Path Bypass with Subpath",
|
||||
path: "/webhook/test",
|
||||
authHeader: "",
|
||||
expectedStatusCode: 200,
|
||||
shouldBypassAuth: true,
|
||||
},
|
||||
{
|
||||
name: "Different Webhook Path",
|
||||
path: "/webhooktest",
|
||||
authHeader: "",
|
||||
expectedStatusCode: 401,
|
||||
shouldBypassAuth: false,
|
||||
},
|
||||
}
|
||||
|
||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
})
|
||||
|
||||
mockAuth := &auth.MockManager{
|
||||
ValidateAndParseTokenFunc: mockValidateAndParseToken,
|
||||
EnsureUserAccessByJWTGroupsFunc: mockEnsureUserAccessByJWTGroups,
|
||||
MarkPATUsedFunc: mockMarkPATUsed,
|
||||
GetPATInfoFunc: mockGetAccountInfoFromPAT,
|
||||
}
|
||||
|
||||
authMiddleware := NewAuthMiddleware(
|
||||
mockAuth,
|
||||
func(ctx context.Context, userAuth nbcontext.UserAuth) (string, string, error) {
|
||||
return userAuth.AccountId, userAuth.UserId, nil
|
||||
},
|
||||
func(ctx context.Context, userAuth nbcontext.UserAuth) error {
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
handlerToTest := authMiddleware.Handler(nextHandler)
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.shouldBypassAuth {
|
||||
err := bypass.AddBypassPath(tc.path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to add bypass path: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "http://testing"+tc.path, nil)
|
||||
req.Header.Set("Authorization", tc.authHeader)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handlerToTest.ServeHTTP(rec, req)
|
||||
|
||||
result := rec.Result()
|
||||
defer result.Body.Close()
|
||||
|
||||
if result.StatusCode != tc.expectedStatusCode {
|
||||
t.Errorf("expected status code %d, got %d", tc.expectedStatusCode, result.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_Handler_Child(t *testing.T) {
|
||||
tt := []struct {
|
||||
name string
|
||||
path string
|
||||
authHeader string
|
||||
expectedUserAuth *nbcontext.UserAuth // nil expects 401 response status
|
||||
}{
|
||||
{
|
||||
name: "Valid PAT Token",
|
||||
path: "/test",
|
||||
authHeader: "Token " + PAT,
|
||||
expectedUserAuth: &nbcontext.UserAuth{
|
||||
AccountId: accountID,
|
||||
UserId: userID,
|
||||
Domain: testAccount.Domain,
|
||||
DomainCategory: testAccount.DomainCategory,
|
||||
IsPAT: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Valid PAT Token ignores child",
|
||||
path: "/test?account=xyz",
|
||||
authHeader: "Token " + PAT,
|
||||
expectedUserAuth: &nbcontext.UserAuth{
|
||||
AccountId: accountID,
|
||||
UserId: userID,
|
||||
Domain: testAccount.Domain,
|
||||
DomainCategory: testAccount.DomainCategory,
|
||||
IsPAT: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Valid JWT Token",
|
||||
path: "/test",
|
||||
authHeader: "Bearer " + JWT,
|
||||
expectedUserAuth: &nbcontext.UserAuth{
|
||||
AccountId: accountID,
|
||||
UserId: userID,
|
||||
Domain: testAccount.Domain,
|
||||
DomainCategory: testAccount.DomainCategory,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Valid JWT Token with child",
|
||||
path: "/test?account=xyz",
|
||||
authHeader: "Bearer " + JWT,
|
||||
expectedUserAuth: &nbcontext.UserAuth{
|
||||
AccountId: "xyz",
|
||||
UserId: userID,
|
||||
Domain: testAccount.Domain,
|
||||
DomainCategory: testAccount.DomainCategory,
|
||||
IsChild: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockAuth := &auth.MockManager{
|
||||
ValidateAndParseTokenFunc: mockValidateAndParseToken,
|
||||
EnsureUserAccessByJWTGroupsFunc: mockEnsureUserAccessByJWTGroups,
|
||||
MarkPATUsedFunc: mockMarkPATUsed,
|
||||
GetPATInfoFunc: mockGetAccountInfoFromPAT,
|
||||
}
|
||||
|
||||
authMiddleware := NewAuthMiddleware(
|
||||
mockAuth,
|
||||
func(ctx context.Context, userAuth nbcontext.UserAuth) (string, string, error) {
|
||||
return userAuth.AccountId, userAuth.UserId, nil
|
||||
},
|
||||
func(ctx context.Context, userAuth nbcontext.UserAuth) error {
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
handlerToTest := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromRequest(r)
|
||||
if tc.expectedUserAuth != nil {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, *tc.expectedUserAuth, userAuth)
|
||||
} else {
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, userAuth)
|
||||
}
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "http://testing"+tc.path, nil)
|
||||
req.Header.Set("Authorization", tc.authHeader)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handlerToTest.ServeHTTP(rec, req)
|
||||
|
||||
result := rec.Result()
|
||||
defer result.Body.Close()
|
||||
|
||||
if tc.expectedUserAuth != nil {
|
||||
assert.Equal(t, 200, result.StatusCode)
|
||||
} else {
|
||||
assert.Equal(t, 401, result.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package bypass_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/management-refactor/management/server/http/middleware/bypass"
|
||||
)
|
||||
|
||||
func TestGetList(t *testing.T) {
|
||||
bypassPaths := []string{"/path1", "/path2", "/path3"}
|
||||
|
||||
for _, path := range bypassPaths {
|
||||
err := bypass.AddBypassPath(path)
|
||||
require.NoError(t, err, "Adding bypass path should not fail")
|
||||
}
|
||||
|
||||
list := bypass.GetList()
|
||||
|
||||
assert.ElementsMatch(t, bypassPaths, list, "Bypass path list did not match expected paths")
|
||||
}
|
||||
|
||||
func TestAuthBypass(t *testing.T) {
|
||||
dummyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pathToAdd string
|
||||
pathToRemove string
|
||||
testPath string
|
||||
expectBypass bool
|
||||
expectHTTPCode int
|
||||
}{
|
||||
{
|
||||
name: "Path added to bypass",
|
||||
pathToAdd: "/bypass",
|
||||
testPath: "/bypass",
|
||||
expectBypass: true,
|
||||
expectHTTPCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "Wildcard path added to bypass",
|
||||
pathToAdd: "/bypass/*",
|
||||
testPath: "/bypass/extra",
|
||||
expectBypass: true,
|
||||
expectHTTPCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "Path not added to bypass",
|
||||
testPath: "/no-bypass",
|
||||
expectBypass: false,
|
||||
expectHTTPCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "Path removed from bypass",
|
||||
pathToAdd: "/remove-bypass",
|
||||
pathToRemove: "/remove-bypass",
|
||||
testPath: "/remove-bypass",
|
||||
expectBypass: false,
|
||||
expectHTTPCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "Exact path matches bypass",
|
||||
pathToAdd: "/webhook",
|
||||
testPath: "/webhook",
|
||||
expectBypass: true,
|
||||
expectHTTPCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "Subpath does not match bypass",
|
||||
pathToAdd: "/webhook",
|
||||
testPath: "/webhook/extra",
|
||||
expectBypass: false,
|
||||
expectHTTPCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "Wildcard subpath does not match bypass",
|
||||
pathToAdd: "/webhook/*",
|
||||
testPath: "/webhook/extra/path",
|
||||
expectBypass: false,
|
||||
expectHTTPCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "Similar path does not match bypass",
|
||||
pathToAdd: "/webhook",
|
||||
testPath: "/webhooking",
|
||||
expectBypass: false,
|
||||
expectHTTPCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "Prefix path does not match bypass",
|
||||
pathToAdd: "/webhook",
|
||||
testPath: "/web",
|
||||
expectBypass: false,
|
||||
expectHTTPCode: http.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.pathToAdd != "" {
|
||||
err := bypass.AddBypassPath(tc.pathToAdd)
|
||||
require.NoError(t, err, "Adding bypass path should not fail")
|
||||
defer bypass.RemovePath(tc.pathToAdd)
|
||||
}
|
||||
|
||||
if tc.pathToRemove != "" {
|
||||
bypass.RemovePath(tc.pathToRemove)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest("GET", tc.testPath, nil)
|
||||
require.NoError(t, err, "Creating request should not fail")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
bypassed := bypass.ShouldBypass(tc.testPath, dummyHandler, recorder, request)
|
||||
|
||||
assert.Equal(t, tc.expectBypass, bypassed, "Bypass check did not match expectation")
|
||||
|
||||
if tc.expectBypass {
|
||||
assert.Equal(t, tc.expectHTTPCode, recorder.Code, "HTTP status code did not match expectation for bypassed path")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nbcontext "github.com/netbirdio/management-refactor/management/server/context"
|
||||
)
|
||||
|
||||
const (
|
||||
// AccountIDSuffix suffix for the account id claim
|
||||
AccountIDSuffix = "wt_account_id"
|
||||
// DomainIDSuffix suffix for the domain id claim
|
||||
DomainIDSuffix = "wt_account_domain"
|
||||
// DomainCategorySuffix suffix for the domain category claim
|
||||
DomainCategorySuffix = "wt_account_domain_category"
|
||||
// UserIDClaim claim for the user id
|
||||
UserIDClaim = "sub"
|
||||
// LastLoginSuffix claim for the last login
|
||||
LastLoginSuffix = "nb_last_login"
|
||||
// Invited claim indicates that an incoming JWT is from a user that just accepted an invitation
|
||||
Invited = "nb_invited"
|
||||
)
|
||||
|
||||
var (
|
||||
errUserIDClaimEmpty = errors.New("user ID claim token value is empty")
|
||||
)
|
||||
|
||||
// ClaimsExtractor struct that holds the extract function
|
||||
type ClaimsExtractor struct {
|
||||
authAudience string
|
||||
userIDClaim string
|
||||
}
|
||||
|
||||
// ClaimsExtractorOption is a function that configures the ClaimsExtractor
|
||||
type ClaimsExtractorOption func(*ClaimsExtractor)
|
||||
|
||||
// WithAudience sets the audience for the extractor
|
||||
func WithAudience(audience string) ClaimsExtractorOption {
|
||||
return func(c *ClaimsExtractor) {
|
||||
c.authAudience = audience
|
||||
}
|
||||
}
|
||||
|
||||
// WithUserIDClaim sets the user id claim for the extractor
|
||||
func WithUserIDClaim(userIDClaim string) ClaimsExtractorOption {
|
||||
return func(c *ClaimsExtractor) {
|
||||
c.userIDClaim = userIDClaim
|
||||
}
|
||||
}
|
||||
|
||||
// NewClaimsExtractor returns an extractor, and if provided with a function with ExtractClaims signature,
|
||||
// then it will use that logic. Uses ExtractClaimsFromRequestContext by default
|
||||
func NewClaimsExtractor(options ...ClaimsExtractorOption) *ClaimsExtractor {
|
||||
ce := &ClaimsExtractor{}
|
||||
for _, option := range options {
|
||||
option(ce)
|
||||
}
|
||||
|
||||
if ce.userIDClaim == "" {
|
||||
ce.userIDClaim = UserIDClaim
|
||||
}
|
||||
return ce
|
||||
}
|
||||
|
||||
func parseTime(timeString string) time.Time {
|
||||
if timeString == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
parsedTime, err := time.Parse(time.RFC3339, timeString)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return parsedTime
|
||||
}
|
||||
|
||||
func (c ClaimsExtractor) audienceClaim(claimName string) string {
|
||||
url, err := url.JoinPath(c.authAudience, claimName)
|
||||
if err != nil {
|
||||
return c.authAudience + claimName // as it was previously
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
func (c *ClaimsExtractor) ToUserAuth(token *jwt.Token) (nbcontext.UserAuth, error) {
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
userAuth := nbcontext.UserAuth{}
|
||||
|
||||
userID, ok := claims[c.userIDClaim].(string)
|
||||
if !ok {
|
||||
return userAuth, errUserIDClaimEmpty
|
||||
}
|
||||
userAuth.UserId = userID
|
||||
|
||||
if accountIDClaim, ok := claims[c.audienceClaim(AccountIDSuffix)]; ok {
|
||||
userAuth.AccountId = accountIDClaim.(string)
|
||||
}
|
||||
|
||||
if domainClaim, ok := claims[c.audienceClaim(DomainIDSuffix)]; ok {
|
||||
userAuth.Domain = domainClaim.(string)
|
||||
}
|
||||
|
||||
if domainCategoryClaim, ok := claims[c.audienceClaim(DomainCategorySuffix)]; ok {
|
||||
userAuth.DomainCategory = domainCategoryClaim.(string)
|
||||
}
|
||||
|
||||
if lastLoginClaimString, ok := claims[c.audienceClaim(LastLoginSuffix)]; ok {
|
||||
userAuth.LastLogin = parseTime(lastLoginClaimString.(string))
|
||||
}
|
||||
|
||||
if invitedBool, ok := claims[c.audienceClaim(Invited)]; ok {
|
||||
if value, ok := invitedBool.(bool); ok {
|
||||
userAuth.Invited = value
|
||||
}
|
||||
}
|
||||
|
||||
return userAuth, nil
|
||||
}
|
||||
|
||||
func (c *ClaimsExtractor) ToGroups(token *jwt.Token, claimName string) []string {
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
userJWTGroups := make([]string, 0)
|
||||
|
||||
if claim, ok := claims[claimName]; ok {
|
||||
if claimGroups, ok := claim.([]interface{}); ok {
|
||||
for _, g := range claimGroups {
|
||||
if group, ok := g.(string); ok {
|
||||
userJWTGroups = append(userJWTGroups, group)
|
||||
} else {
|
||||
log.Debugf("JWT claim %q contains a non-string group (type: %T): %v", claimName, g, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Debugf("JWT claim %q is not a string array", claimName)
|
||||
}
|
||||
|
||||
return userJWTGroups
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Jwks is a collection of JSONWebKey obtained from Config.HttpServerConfig.AuthKeysLocation
|
||||
type Jwks struct {
|
||||
Keys []JSONWebKey `json:"keys"`
|
||||
expiresInTime time.Time
|
||||
}
|
||||
|
||||
// The supported elliptic curves types
|
||||
const (
|
||||
// p256 represents a cryptographic elliptical curve type.
|
||||
p256 = "P-256"
|
||||
|
||||
// p384 represents a cryptographic elliptical curve type.
|
||||
p384 = "P-384"
|
||||
|
||||
// p521 represents a cryptographic elliptical curve type.
|
||||
p521 = "P-521"
|
||||
)
|
||||
|
||||
// JSONWebKey is a representation of a Jason Web Key
|
||||
type JSONWebKey struct {
|
||||
Kty string `json:"kty"`
|
||||
Kid string `json:"kid"`
|
||||
Use string `json:"use"`
|
||||
N string `json:"n"`
|
||||
E string `json:"e"`
|
||||
Crv string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
Y string `json:"y"`
|
||||
X5c []string `json:"x5c"`
|
||||
}
|
||||
|
||||
type Validator struct {
|
||||
lock sync.Mutex
|
||||
issuer string
|
||||
audienceList []string
|
||||
keysLocation string
|
||||
idpSignkeyRefreshEnabled bool
|
||||
keys *Jwks
|
||||
}
|
||||
|
||||
var (
|
||||
errKeyNotFound = errors.New("unable to find appropriate key")
|
||||
errInvalidAudience = errors.New("invalid audience")
|
||||
errInvalidIssuer = errors.New("invalid issuer")
|
||||
errTokenEmpty = errors.New("required authorization token not found")
|
||||
errTokenInvalid = errors.New("token is invalid")
|
||||
errTokenParsing = errors.New("token could not be parsed")
|
||||
)
|
||||
|
||||
func NewValidator(issuer string, audienceList []string, keysLocation string, idpSignkeyRefreshEnabled bool) *Validator {
|
||||
keys, err := getPemKeys(keysLocation)
|
||||
if err != nil {
|
||||
log.WithField("keysLocation", keysLocation).Errorf("could not get keys from location: %s", err)
|
||||
}
|
||||
|
||||
return &Validator{
|
||||
keys: keys,
|
||||
issuer: issuer,
|
||||
audienceList: audienceList,
|
||||
keysLocation: keysLocation,
|
||||
idpSignkeyRefreshEnabled: idpSignkeyRefreshEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Validator) getKeyFunc(ctx context.Context) jwt.Keyfunc {
|
||||
return func(token *jwt.Token) (interface{}, error) {
|
||||
// Verify 'aud' claim
|
||||
var checkAud bool
|
||||
for _, audience := range v.audienceList {
|
||||
checkAud = token.Claims.(jwt.MapClaims).VerifyAudience(audience, false)
|
||||
if checkAud {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !checkAud {
|
||||
return token, errInvalidAudience
|
||||
}
|
||||
|
||||
// Verify 'issuer' claim
|
||||
checkIss := token.Claims.(jwt.MapClaims).VerifyIssuer(v.issuer, false)
|
||||
if !checkIss {
|
||||
return token, errInvalidIssuer
|
||||
}
|
||||
|
||||
// If keys are rotated, verify the keys prior to token validation
|
||||
if v.idpSignkeyRefreshEnabled {
|
||||
// If the keys are invalid, retrieve new ones
|
||||
// @todo propose a separate go routine to regularly check these to prevent blocking when actually
|
||||
// validating the token
|
||||
if !v.keys.stillValid() {
|
||||
v.lock.Lock()
|
||||
defer v.lock.Unlock()
|
||||
|
||||
refreshedKeys, err := getPemKeys(v.keysLocation)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Debugf("cannot get JSONWebKey: %v, falling back to old keys", err)
|
||||
refreshedKeys = v.keys
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Debugf("keys refreshed, new UTC expiration time: %s", refreshedKeys.expiresInTime.UTC())
|
||||
|
||||
v.keys = refreshedKeys
|
||||
}
|
||||
}
|
||||
|
||||
publicKey, err := getPublicKey(token, v.keys)
|
||||
if err == nil {
|
||||
return publicKey, nil
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("getPublicKey error: %s", err)
|
||||
if errors.Is(err, errKeyNotFound) && !v.idpSignkeyRefreshEnabled {
|
||||
msg = fmt.Sprintf("getPublicKey error: %s. You can enable key refresh by setting HttpServerConfig.IdpSignKeyRefreshEnabled to true in your management.json file and restart the service", err)
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Error(msg)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateAndParse validates the token and returns the parsed token
|
||||
func (m *Validator) ValidateAndParse(ctx context.Context, token string) (*jwt.Token, error) {
|
||||
// If the token is empty...
|
||||
if token == "" {
|
||||
// If we get here, the required token is missing
|
||||
log.WithContext(ctx).Debugf(" Error: No credentials found (CredentialsOptional=false)")
|
||||
return nil, errTokenEmpty
|
||||
}
|
||||
|
||||
// Now parse the token
|
||||
parsedToken, err := jwt.Parse(token, m.getKeyFunc(ctx))
|
||||
|
||||
// Check if there was an error in parsing...
|
||||
if err != nil {
|
||||
err = fmt.Errorf("%w: %s", errTokenParsing, err)
|
||||
log.WithContext(ctx).Error(err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if the parsed token is valid...
|
||||
if !parsedToken.Valid {
|
||||
log.WithContext(ctx).Debug(errTokenInvalid.Error())
|
||||
return nil, errTokenInvalid
|
||||
}
|
||||
|
||||
return parsedToken, nil
|
||||
}
|
||||
|
||||
// stillValid returns true if the JSONWebKey still valid and have enough time to be used
|
||||
func (jwks *Jwks) stillValid() bool {
|
||||
return !jwks.expiresInTime.IsZero() && time.Now().Add(5*time.Second).Before(jwks.expiresInTime)
|
||||
}
|
||||
|
||||
func getPemKeys(keysLocation string) (*Jwks, error) {
|
||||
jwks := &Jwks{}
|
||||
|
||||
url, err := url.ParseRequestURI(keysLocation)
|
||||
if err != nil {
|
||||
return jwks, err
|
||||
}
|
||||
|
||||
resp, err := http.Get(url.String())
|
||||
if err != nil {
|
||||
return jwks, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(jwks)
|
||||
if err != nil {
|
||||
return jwks, err
|
||||
}
|
||||
|
||||
cacheControlHeader := resp.Header.Get("Cache-Control")
|
||||
expiresIn := getMaxAgeFromCacheHeader(cacheControlHeader)
|
||||
jwks.expiresInTime = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
|
||||
return jwks, nil
|
||||
}
|
||||
|
||||
func getPublicKey(token *jwt.Token, jwks *Jwks) (interface{}, error) {
|
||||
// todo as we load the jkws when the server is starting, we should build a JKS map with the pem cert at the boot time
|
||||
for k := range jwks.Keys {
|
||||
if token.Header["kid"] != jwks.Keys[k].Kid {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(jwks.Keys[k].X5c) != 0 {
|
||||
cert := "-----BEGIN CERTIFICATE-----\n" + jwks.Keys[k].X5c[0] + "\n-----END CERTIFICATE-----"
|
||||
return jwt.ParseRSAPublicKeyFromPEM([]byte(cert))
|
||||
}
|
||||
|
||||
if jwks.Keys[k].Kty == "RSA" {
|
||||
return getPublicKeyFromRSA(jwks.Keys[k])
|
||||
}
|
||||
if jwks.Keys[k].Kty == "EC" {
|
||||
return getPublicKeyFromECDSA(jwks.Keys[k])
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errKeyNotFound
|
||||
}
|
||||
|
||||
func getPublicKeyFromECDSA(jwk JSONWebKey) (publicKey *ecdsa.PublicKey, err error) {
|
||||
if jwk.X == "" || jwk.Y == "" || jwk.Crv == "" {
|
||||
return nil, fmt.Errorf("ecdsa key incomplete")
|
||||
}
|
||||
|
||||
var xCoordinate []byte
|
||||
if xCoordinate, err = base64.RawURLEncoding.DecodeString(jwk.X); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var yCoordinate []byte
|
||||
if yCoordinate, err = base64.RawURLEncoding.DecodeString(jwk.Y); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
publicKey = &ecdsa.PublicKey{}
|
||||
|
||||
var curve elliptic.Curve
|
||||
switch jwk.Crv {
|
||||
case p256:
|
||||
curve = elliptic.P256()
|
||||
case p384:
|
||||
curve = elliptic.P384()
|
||||
case p521:
|
||||
curve = elliptic.P521()
|
||||
}
|
||||
|
||||
publicKey.Curve = curve
|
||||
publicKey.X = big.NewInt(0).SetBytes(xCoordinate)
|
||||
publicKey.Y = big.NewInt(0).SetBytes(yCoordinate)
|
||||
|
||||
return publicKey, nil
|
||||
}
|
||||
|
||||
func getPublicKeyFromRSA(jwk JSONWebKey) (*rsa.PublicKey, error) {
|
||||
decodedE, err := base64.RawURLEncoding.DecodeString(jwk.E)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decodedN, err := base64.RawURLEncoding.DecodeString(jwk.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var n, e big.Int
|
||||
e.SetBytes(decodedE)
|
||||
n.SetBytes(decodedN)
|
||||
|
||||
return &rsa.PublicKey{
|
||||
E: int(e.Int64()),
|
||||
N: &n,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getMaxAgeFromCacheHeader extracts max-age directive from the Cache-Control header
|
||||
func getMaxAgeFromCacheHeader(cacheControl string) int {
|
||||
// Split into individual directives
|
||||
directives := strings.Split(cacheControl, ",")
|
||||
|
||||
for _, directive := range directives {
|
||||
directive = strings.TrimSpace(directive)
|
||||
if strings.HasPrefix(directive, "max-age=") {
|
||||
// Extract the max-age value
|
||||
maxAgeStr := strings.TrimPrefix(directive, "max-age=")
|
||||
maxAge, err := strconv.Atoi(maxAgeStr)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return maxAge
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/netbirdio/management-refactor/base62"
|
||||
nbjwt "github.com/netbirdio/management-refactor/management/server/auth/jwt"
|
||||
nbcontext "github.com/netbirdio/management-refactor/management/server/context"
|
||||
"github.com/netbirdio/management-refactor/management/server/store"
|
||||
"github.com/netbirdio/management-refactor/management/server/types"
|
||||
|
||||
"github.com/netbirdio/management-refactor/internals/modules/accounts/settings"
|
||||
"github.com/netbirdio/management-refactor/internals/modules/users"
|
||||
"github.com/netbirdio/management-refactor/internals/modules/users/pats"
|
||||
|
||||
"github.com/netbirdio/management-refactor/internals/shared/db"
|
||||
)
|
||||
|
||||
var _ Manager = (*manager)(nil)
|
||||
|
||||
type Manager interface {
|
||||
ValidateAndParseToken(ctx context.Context, value string) (nbcontext.UserAuth, *jwt.Token, error)
|
||||
EnsureUserAccessByJWTGroups(ctx context.Context, userAuth nbcontext.UserAuth, token *jwt.Token) (nbcontext.UserAuth, error)
|
||||
MarkPATUsed(ctx context.Context, tokenID string) error
|
||||
GetPATInfo(ctx context.Context, token string) (user *users.User, pat *pats.PersonalAccessToken, domain string, category string, err error)
|
||||
}
|
||||
|
||||
type manager struct {
|
||||
userManager *users.Manager
|
||||
settingsManager *settings.Manager
|
||||
|
||||
validator *nbjwt.Validator
|
||||
extractor *nbjwt.ClaimsExtractor
|
||||
}
|
||||
|
||||
func NewManager(userManager *users.Manager, settingsManager *settings.Manager, issuer, audience, keysLocation, userIdClaim string, allAudiences []string, idpRefreshKeys bool) Manager {
|
||||
// @note if invalid/missing parameters are sent the validator will instantiate
|
||||
// but it will fail when validating and parsing the token
|
||||
jwtValidator := nbjwt.NewValidator(
|
||||
issuer,
|
||||
allAudiences,
|
||||
keysLocation,
|
||||
idpRefreshKeys,
|
||||
)
|
||||
|
||||
claimsExtractor := nbjwt.NewClaimsExtractor(
|
||||
nbjwt.WithAudience(audience),
|
||||
nbjwt.WithUserIDClaim(userIdClaim),
|
||||
)
|
||||
|
||||
return &manager{
|
||||
userManager: userManager,
|
||||
settingsManager: settingsManager,
|
||||
|
||||
validator: jwtValidator,
|
||||
extractor: claimsExtractor,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *manager) ValidateAndParseToken(ctx context.Context, value string) (nbcontext.UserAuth, *jwt.Token, error) {
|
||||
token, err := m.validator.ValidateAndParse(ctx, value)
|
||||
if err != nil {
|
||||
return nbcontext.UserAuth{}, nil, err
|
||||
}
|
||||
|
||||
userAuth, err := m.extractor.ToUserAuth(token)
|
||||
if err != nil {
|
||||
return nbcontext.UserAuth{}, nil, err
|
||||
}
|
||||
return userAuth, token, err
|
||||
}
|
||||
|
||||
func (m *manager) EnsureUserAccessByJWTGroups(ctx context.Context, userAuth nbcontext.UserAuth, token *jwt.Token) (nbcontext.UserAuth, error) {
|
||||
if userAuth.IsChild || userAuth.IsPAT {
|
||||
return userAuth, nil
|
||||
}
|
||||
|
||||
settings, err := m.settingsManager.GetSettings(ctx, nil, db.LockingStrengthShare, userAuth.AccountId, userAuth.UserId)
|
||||
if err != nil {
|
||||
return userAuth, err
|
||||
}
|
||||
|
||||
// Ensures JWT group synchronization to the management is enabled before,
|
||||
// filtering access based on the allowed groups.
|
||||
if settings != nil && settings.JWTGroupsEnabled {
|
||||
userAuth.Groups = m.extractor.ToGroups(token, settings.JWTGroupsClaimName)
|
||||
if allowedGroups := settings.JWTAllowGroups; len(allowedGroups) > 0 {
|
||||
if !userHasAllowedGroup(allowedGroups, userAuth.Groups) {
|
||||
return userAuth, fmt.Errorf("user does not belong to any of the allowed JWT groups")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return userAuth, nil
|
||||
}
|
||||
|
||||
// MarkPATUsed marks a personal access token as used
|
||||
func (am *manager) MarkPATUsed(ctx context.Context, tokenID string) error {
|
||||
return am.store.MarkPATUsed(ctx, store.LockingStrengthUpdate, tokenID)
|
||||
}
|
||||
|
||||
// GetPATInfo retrieves user, personal access token, domain, and category details from a personal access token.
|
||||
func (am *manager) GetPATInfo(ctx context.Context, token string) (user *types.User, pat *pattypes.PersonalAccessToken, domain string, category string, err error) {
|
||||
user, pat, err = am.extractPATFromToken(ctx, token)
|
||||
if err != nil {
|
||||
return nil, nil, "", "", err
|
||||
}
|
||||
|
||||
domain, category, err = am.store.GetAccountDomainAndCategory(ctx, store.LockingStrengthShare, user.AccountID)
|
||||
if err != nil {
|
||||
return nil, nil, "", "", err
|
||||
}
|
||||
|
||||
return user, pat, domain, category, nil
|
||||
}
|
||||
|
||||
// extractPATFromToken validates the token structure and retrieves associated User and PAT.
|
||||
func (am *manager) extractPATFromToken(ctx context.Context, token string) (*types.User, *pattypes.PersonalAccessToken, error) {
|
||||
if len(token) != pattypes.PATLength {
|
||||
return nil, nil, fmt.Errorf("PAT has incorrect length")
|
||||
}
|
||||
|
||||
prefix := token[:len(pattypes.PATPrefix)]
|
||||
if prefix != pattypes.PATPrefix {
|
||||
return nil, nil, fmt.Errorf("PAT has wrong prefix")
|
||||
}
|
||||
secret := token[len(pattypes.PATPrefix) : len(pattypes.PATPrefix)+pattypes.PATSecretLength]
|
||||
encodedChecksum := token[len(pattypes.PATPrefix)+pattypes.PATSecretLength : len(pattypes.PATPrefix)+pattypes.PATSecretLength+pattypes.PATChecksumLength]
|
||||
|
||||
verificationChecksum, err := base62.Decode(encodedChecksum)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("PAT checksum decoding failed: %w", err)
|
||||
}
|
||||
|
||||
secretChecksum := crc32.ChecksumIEEE([]byte(secret))
|
||||
if secretChecksum != verificationChecksum {
|
||||
return nil, nil, fmt.Errorf("PAT checksum does not match")
|
||||
}
|
||||
|
||||
hashedToken := sha256.Sum256([]byte(token))
|
||||
encodedHashedToken := base64.StdEncoding.EncodeToString(hashedToken[:])
|
||||
|
||||
var user *types.User
|
||||
var pat *pattypes.PersonalAccessToken
|
||||
|
||||
err = am.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
pat, err = transaction.GetPATByHashedToken(ctx, store.LockingStrengthShare, encodedHashedToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, err = transaction.GetUserByPATID(ctx, store.LockingStrengthShare, pat.ID)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return user, pat, nil
|
||||
}
|
||||
|
||||
// userHasAllowedGroup checks if a user belongs to any of the allowed groups.
|
||||
func userHasAllowedGroup(allowedGroups []string, userGroups []string) bool {
|
||||
for _, userGroup := range userGroups {
|
||||
for _, allowedGroup := range allowedGroups {
|
||||
if userGroup == allowedGroup {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/golang-jwt/jwt"
|
||||
|
||||
nbcontext "github.com/netbirdio/management-refactor/management/server/context"
|
||||
"github.com/netbirdio/management-refactor/management/server/types"
|
||||
)
|
||||
|
||||
var (
|
||||
_ Manager = (*MockManager)(nil)
|
||||
)
|
||||
|
||||
// @note really dislike this mocking approach but rather than have to do additional test refactoring.
|
||||
type MockManager struct {
|
||||
ValidateAndParseTokenFunc func(ctx context.Context, value string) (nbcontext.UserAuth, *jwt.Token, error)
|
||||
EnsureUserAccessByJWTGroupsFunc func(ctx context.Context, userAuth nbcontext.UserAuth, token *jwt.Token) (nbcontext.UserAuth, error)
|
||||
MarkPATUsedFunc func(ctx context.Context, tokenID string) error
|
||||
GetPATInfoFunc func(ctx context.Context, token string) (user *types.User, pat *types.PersonalAccessToken, domain string, category string, err error)
|
||||
}
|
||||
|
||||
// EnsureUserAccessByJWTGroups implements Manager.
|
||||
func (m *MockManager) EnsureUserAccessByJWTGroups(ctx context.Context, userAuth nbcontext.UserAuth, token *jwt.Token) (nbcontext.UserAuth, error) {
|
||||
if m.EnsureUserAccessByJWTGroupsFunc != nil {
|
||||
return m.EnsureUserAccessByJWTGroupsFunc(ctx, userAuth, token)
|
||||
}
|
||||
return nbcontext.UserAuth{}, nil
|
||||
}
|
||||
|
||||
// GetPATInfo implements Manager.
|
||||
func (m *MockManager) GetPATInfo(ctx context.Context, token string) (user *types.User, pat *types.PersonalAccessToken, domain string, category string, err error) {
|
||||
if m.GetPATInfoFunc != nil {
|
||||
return m.GetPATInfoFunc(ctx, token)
|
||||
}
|
||||
return &types.User{}, &types.PersonalAccessToken{}, "", "", nil
|
||||
}
|
||||
|
||||
// MarkPATUsed implements Manager.
|
||||
func (m *MockManager) MarkPATUsed(ctx context.Context, tokenID string) error {
|
||||
if m.MarkPATUsedFunc != nil {
|
||||
return m.MarkPATUsedFunc(ctx, tokenID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAndParseToken implements Manager.
|
||||
func (m *MockManager) ValidateAndParseToken(ctx context.Context, value string) (nbcontext.UserAuth, *jwt.Token, error) {
|
||||
if m.ValidateAndParseTokenFunc != nil {
|
||||
return m.ValidateAndParseTokenFunc(ctx, value)
|
||||
}
|
||||
return nbcontext.UserAuth{}, &jwt.Token{}, nil
|
||||
}
|
||||
@@ -1,407 +0,0 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/management-refactor/management/server/auth"
|
||||
nbjwt "github.com/netbirdio/management-refactor/management/server/auth/jwt"
|
||||
nbcontext "github.com/netbirdio/management-refactor/management/server/context"
|
||||
"github.com/netbirdio/management-refactor/management/server/store"
|
||||
"github.com/netbirdio/management-refactor/management/server/types"
|
||||
)
|
||||
|
||||
func TestAuthManager_GetAccountInfoFromPAT(t *testing.T) {
|
||||
store, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Error when creating store: %s", err)
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
token := "nbp_9999EUDNdkeusjentDLSJEn1902u84390W6W"
|
||||
hashedToken := sha256.Sum256([]byte(token))
|
||||
encodedHashedToken := base64.StdEncoding.EncodeToString(hashedToken[:])
|
||||
account := &types.Account{
|
||||
Id: "account_id",
|
||||
Users: map[string]*types.User{"someUser": {
|
||||
Id: "someUser",
|
||||
PATs: map[string]*types.PersonalAccessToken{
|
||||
"tokenId": {
|
||||
ID: "tokenId",
|
||||
UserID: "someUser",
|
||||
HashedToken: encodedHashedToken,
|
||||
},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
err = store.SaveAccount(context.Background(), account)
|
||||
if err != nil {
|
||||
t.Fatalf("Error when saving account: %s", err)
|
||||
}
|
||||
|
||||
manager := auth.NewManager(store, "", "", "", "", []string{}, false)
|
||||
|
||||
user, pat, _, _, err := manager.GetPATInfo(context.Background(), token)
|
||||
if err != nil {
|
||||
t.Fatalf("Error when getting Account from PAT: %s", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "account_id", user.AccountID)
|
||||
assert.Equal(t, "someUser", user.Id)
|
||||
assert.Equal(t, account.Users["someUser"].PATs["tokenId"].ID, pat.ID)
|
||||
}
|
||||
|
||||
func TestAuthManager_MarkPATUsed(t *testing.T) {
|
||||
store, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Error when creating store: %s", err)
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
token := "nbp_9999EUDNdkeusjentDLSJEn1902u84390W6W"
|
||||
hashedToken := sha256.Sum256([]byte(token))
|
||||
encodedHashedToken := base64.StdEncoding.EncodeToString(hashedToken[:])
|
||||
account := &types.Account{
|
||||
Id: "account_id",
|
||||
Users: map[string]*types.User{"someUser": {
|
||||
Id: "someUser",
|
||||
PATs: map[string]*types.PersonalAccessToken{
|
||||
"tokenId": {
|
||||
ID: "tokenId",
|
||||
HashedToken: encodedHashedToken,
|
||||
},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
err = store.SaveAccount(context.Background(), account)
|
||||
if err != nil {
|
||||
t.Fatalf("Error when saving account: %s", err)
|
||||
}
|
||||
|
||||
manager := auth.NewManager(store, "", "", "", "", []string{}, false)
|
||||
|
||||
err = manager.MarkPATUsed(context.Background(), "tokenId")
|
||||
if err != nil {
|
||||
t.Fatalf("Error when marking PAT used: %s", err)
|
||||
}
|
||||
|
||||
account, err = store.GetAccount(context.Background(), "account_id")
|
||||
if err != nil {
|
||||
t.Fatalf("Error when getting account: %s", err)
|
||||
}
|
||||
assert.True(t, !account.Users["someUser"].PATs["tokenId"].GetLastUsed().IsZero())
|
||||
}
|
||||
|
||||
func TestAuthManager_EnsureUserAccessByJWTGroups(t *testing.T) {
|
||||
store, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Error when creating store: %s", err)
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
userId := "user-id"
|
||||
domain := "test.domain"
|
||||
|
||||
account := &types.Account{
|
||||
Id: "account_id",
|
||||
Domain: domain,
|
||||
Users: map[string]*types.User{"someUser": {
|
||||
Id: "someUser",
|
||||
}},
|
||||
Settings: &types.Settings{},
|
||||
}
|
||||
|
||||
err = store.SaveAccount(context.Background(), account)
|
||||
if err != nil {
|
||||
t.Fatalf("Error when saving account: %s", err)
|
||||
}
|
||||
|
||||
// this has been validated and parsed by ValidateAndParseToken
|
||||
userAuth := nbcontext.UserAuth{
|
||||
AccountId: account.Id,
|
||||
Domain: domain,
|
||||
UserId: userId,
|
||||
DomainCategory: "test-category",
|
||||
// Groups: []string{"group1", "group2"},
|
||||
}
|
||||
|
||||
// these tests only assert groups are parsed from token as per account settings
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"idp-groups": []interface{}{"group1", "group2"}})
|
||||
|
||||
manager := auth.NewManager(store, "", "", "", "", []string{}, false)
|
||||
|
||||
t.Run("JWT groups disabled", func(t *testing.T) {
|
||||
userAuth, err := manager.EnsureUserAccessByJWTGroups(context.Background(), userAuth, token)
|
||||
require.NoError(t, err, "ensure user access by JWT groups failed")
|
||||
require.Len(t, userAuth.Groups, 0, "account not enabled to ensure access by groups")
|
||||
})
|
||||
|
||||
t.Run("User impersonated", func(t *testing.T) {
|
||||
userAuth, err := manager.EnsureUserAccessByJWTGroups(context.Background(), userAuth, token)
|
||||
require.NoError(t, err, "ensure user access by JWT groups failed")
|
||||
require.Len(t, userAuth.Groups, 0, "account not enabled to ensure access by groups")
|
||||
})
|
||||
|
||||
t.Run("User PAT", func(t *testing.T) {
|
||||
userAuth, err := manager.EnsureUserAccessByJWTGroups(context.Background(), userAuth, token)
|
||||
require.NoError(t, err, "ensure user access by JWT groups failed")
|
||||
require.Len(t, userAuth.Groups, 0, "account not enabled to ensure access by groups")
|
||||
})
|
||||
|
||||
t.Run("JWT groups enabled without claim name", func(t *testing.T) {
|
||||
account.Settings.JWTGroupsEnabled = true
|
||||
err := store.SaveAccount(context.Background(), account)
|
||||
require.NoError(t, err, "save account failed")
|
||||
|
||||
userAuth, err := manager.EnsureUserAccessByJWTGroups(context.Background(), userAuth, token)
|
||||
require.NoError(t, err, "ensure user access by JWT groups failed")
|
||||
require.Len(t, userAuth.Groups, 0, "account missing groups claim name")
|
||||
})
|
||||
|
||||
t.Run("JWT groups enabled without allowed groups", func(t *testing.T) {
|
||||
account.Settings.JWTGroupsEnabled = true
|
||||
account.Settings.JWTGroupsClaimName = "idp-groups"
|
||||
err := store.SaveAccount(context.Background(), account)
|
||||
require.NoError(t, err, "save account failed")
|
||||
|
||||
userAuth, err := manager.EnsureUserAccessByJWTGroups(context.Background(), userAuth, token)
|
||||
require.NoError(t, err, "ensure user access by JWT groups failed")
|
||||
require.Equal(t, []string{"group1", "group2"}, userAuth.Groups, "group parsed do not match")
|
||||
})
|
||||
|
||||
t.Run("User in allowed JWT groups", func(t *testing.T) {
|
||||
account.Settings.JWTGroupsEnabled = true
|
||||
account.Settings.JWTGroupsClaimName = "idp-groups"
|
||||
account.Settings.JWTAllowGroups = []string{"group1"}
|
||||
err := store.SaveAccount(context.Background(), account)
|
||||
require.NoError(t, err, "save account failed")
|
||||
|
||||
userAuth, err := manager.EnsureUserAccessByJWTGroups(context.Background(), userAuth, token)
|
||||
require.NoError(t, err, "ensure user access by JWT groups failed")
|
||||
|
||||
require.Equal(t, []string{"group1", "group2"}, userAuth.Groups, "group parsed do not match")
|
||||
})
|
||||
|
||||
t.Run("User not in allowed JWT groups", func(t *testing.T) {
|
||||
account.Settings.JWTGroupsEnabled = true
|
||||
account.Settings.JWTGroupsClaimName = "idp-groups"
|
||||
account.Settings.JWTAllowGroups = []string{"not-a-group"}
|
||||
err := store.SaveAccount(context.Background(), account)
|
||||
require.NoError(t, err, "save account failed")
|
||||
|
||||
_, err = manager.EnsureUserAccessByJWTGroups(context.Background(), userAuth, token)
|
||||
require.Error(t, err, "ensure user access is not in allowed groups")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthManager_ValidateAndParseToken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Cache-Control", "max-age=30") // set a 30s expiry to these keys
|
||||
http.ServeFile(w, r, "test_data/jwks.json")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
issuer := "http://issuer.local"
|
||||
audience := "http://audience.local"
|
||||
userIdClaim := "" // defaults to "sub"
|
||||
|
||||
// we're only testing with RSA256
|
||||
keyData, _ := os.ReadFile("test_data/sample_key")
|
||||
key, _ := jwt.ParseRSAPrivateKeyFromPEM(keyData)
|
||||
keyId := "test-key"
|
||||
|
||||
// note, we can use a nil store because ValidateAndParseToken does not use it in it's flow
|
||||
manager := auth.NewManager(nil, issuer, audience, server.URL, userIdClaim, []string{audience}, false)
|
||||
|
||||
customClaim := func(name string) string {
|
||||
return fmt.Sprintf("%s/%s", audience, name)
|
||||
}
|
||||
|
||||
lastLogin := time.Date(2025, 2, 12, 14, 25, 26, 0, time.UTC) // "2025-02-12T14:25:26.186Z"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tokenFunc func() string
|
||||
expected *nbcontext.UserAuth // nil indicates expected error
|
||||
}{
|
||||
{
|
||||
name: "Valid with custom claims",
|
||||
tokenFunc: func() string {
|
||||
token := jwt.New(jwt.SigningMethodRS256)
|
||||
token.Header["kid"] = keyId
|
||||
token.Claims = jwt.MapClaims{
|
||||
"iss": issuer,
|
||||
"aud": []string{audience},
|
||||
"iat": time.Now().Unix(),
|
||||
"exp": time.Now().Add(time.Hour * 1).Unix(),
|
||||
"sub": "user-id|123",
|
||||
customClaim(nbjwt.AccountIDSuffix): "account-id|567",
|
||||
customClaim(nbjwt.DomainIDSuffix): "http://localhost",
|
||||
customClaim(nbjwt.DomainCategorySuffix): "private",
|
||||
customClaim(nbjwt.LastLoginSuffix): lastLogin.Format(time.RFC3339),
|
||||
customClaim(nbjwt.Invited): false,
|
||||
}
|
||||
tokenString, _ := token.SignedString(key)
|
||||
return tokenString
|
||||
},
|
||||
expected: &nbcontext.UserAuth{
|
||||
UserId: "user-id|123",
|
||||
AccountId: "account-id|567",
|
||||
Domain: "http://localhost",
|
||||
DomainCategory: "private",
|
||||
LastLogin: lastLogin,
|
||||
Invited: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Valid without custom claims",
|
||||
tokenFunc: func() string {
|
||||
token := jwt.New(jwt.SigningMethodRS256)
|
||||
token.Header["kid"] = keyId
|
||||
token.Claims = jwt.MapClaims{
|
||||
"iss": issuer,
|
||||
"aud": []string{audience},
|
||||
"iat": time.Now().Unix(),
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"sub": "user-id|123",
|
||||
}
|
||||
tokenString, _ := token.SignedString(key)
|
||||
return tokenString
|
||||
},
|
||||
expected: &nbcontext.UserAuth{
|
||||
UserId: "user-id|123",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Expired token",
|
||||
tokenFunc: func() string {
|
||||
token := jwt.New(jwt.SigningMethodRS256)
|
||||
token.Header["kid"] = keyId
|
||||
token.Claims = jwt.MapClaims{
|
||||
"iss": issuer,
|
||||
"aud": []string{audience},
|
||||
"iat": time.Now().Add(time.Hour * -2).Unix(),
|
||||
"exp": time.Now().Add(time.Hour * -1).Unix(),
|
||||
"sub": "user-id|123",
|
||||
}
|
||||
tokenString, _ := token.SignedString(key)
|
||||
return tokenString
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Not yet valid",
|
||||
tokenFunc: func() string {
|
||||
token := jwt.New(jwt.SigningMethodRS256)
|
||||
token.Header["kid"] = keyId
|
||||
token.Claims = jwt.MapClaims{
|
||||
"iss": issuer,
|
||||
"aud": []string{audience},
|
||||
"iat": time.Now().Add(time.Hour).Unix(),
|
||||
"exp": time.Now().Add(time.Hour * 2).Unix(),
|
||||
"sub": "user-id|123",
|
||||
}
|
||||
tokenString, _ := token.SignedString(key)
|
||||
return tokenString
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Invalid signature",
|
||||
tokenFunc: func() string {
|
||||
token := jwt.New(jwt.SigningMethodRS256)
|
||||
token.Header["kid"] = keyId
|
||||
token.Claims = jwt.MapClaims{
|
||||
"iss": issuer,
|
||||
"aud": []string{audience},
|
||||
"iat": time.Now().Unix(),
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"sub": "user-id|123",
|
||||
}
|
||||
tokenString, _ := token.SignedString(key)
|
||||
parts := strings.Split(tokenString, ".")
|
||||
parts[2] = "invalid-signature"
|
||||
return strings.Join(parts, ".")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Invalid issuer",
|
||||
tokenFunc: func() string {
|
||||
token := jwt.New(jwt.SigningMethodRS256)
|
||||
token.Header["kid"] = keyId
|
||||
token.Claims = jwt.MapClaims{
|
||||
"iss": "not-the-issuer",
|
||||
"aud": []string{audience},
|
||||
"iat": time.Now().Unix(),
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"sub": "user-id|123",
|
||||
}
|
||||
tokenString, _ := token.SignedString(key)
|
||||
return tokenString
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Invalid audience",
|
||||
tokenFunc: func() string {
|
||||
token := jwt.New(jwt.SigningMethodRS256)
|
||||
token.Header["kid"] = keyId
|
||||
token.Claims = jwt.MapClaims{
|
||||
"iss": issuer,
|
||||
"aud": []string{"not-the-audience"},
|
||||
"iat": time.Now().Unix(),
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"sub": "user-id|123",
|
||||
}
|
||||
tokenString, _ := token.SignedString(key)
|
||||
return tokenString
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Invalid user claim",
|
||||
tokenFunc: func() string {
|
||||
token := jwt.New(jwt.SigningMethodRS256)
|
||||
token.Header["kid"] = keyId
|
||||
token.Claims = jwt.MapClaims{
|
||||
"iss": issuer,
|
||||
"aud": []string{audience},
|
||||
"iat": time.Now().Unix(),
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"not-sub": "user-id|123",
|
||||
}
|
||||
tokenString, _ := token.SignedString(key)
|
||||
return tokenString
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tokenString := tt.tokenFunc()
|
||||
|
||||
userAuth, token, err := manager.ValidateAndParseToken(context.Background(), tokenString)
|
||||
|
||||
if tt.expected != nil {
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, token.Valid)
|
||||
assert.Equal(t, *tt.expected, userAuth)
|
||||
} else {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, token)
|
||||
assert.Empty(t, userAuth)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"keys": [
|
||||
{
|
||||
"kty": "RSA",
|
||||
"kid": "test-key",
|
||||
"use": "sig",
|
||||
"n": "4f5wg5l2hKsTeNem_V41fGnJm6gOdrj8ym3rFkEU_wT8RDtnSgFEZOQpHEgQ7JL38xUfU0Y3g6aYw9QT0hJ7mCpz9Er5qLaMXJwZxzHzAahlfA0icqabvJOMvQtzD6uQv6wPEyZtDTWiQi9AXwBpHssPnpYGIn20ZZuNlX2BrClciHhCPUIIZOQn_MmqTD31jSyjoQoV7MhhMTATKJx2XrHhR-1DcKJzQBSTAGnpYVaqpsARap-nwRipr3nUTuxyGohBTSmjJ2usSeQXHI3bODIRe1AuTyHceAbewn8b462yEWKARdpd9AjQW5SIVPfdsz5B6GlYQ5LdYKtznTuy7w",
|
||||
"e": "AQAB"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEowIBAAKCAQEA4f5wg5l2hKsTeNem/V41fGnJm6gOdrj8ym3rFkEU/wT8RDtn
|
||||
SgFEZOQpHEgQ7JL38xUfU0Y3g6aYw9QT0hJ7mCpz9Er5qLaMXJwZxzHzAahlfA0i
|
||||
cqabvJOMvQtzD6uQv6wPEyZtDTWiQi9AXwBpHssPnpYGIn20ZZuNlX2BrClciHhC
|
||||
PUIIZOQn/MmqTD31jSyjoQoV7MhhMTATKJx2XrHhR+1DcKJzQBSTAGnpYVaqpsAR
|
||||
ap+nwRipr3nUTuxyGohBTSmjJ2usSeQXHI3bODIRe1AuTyHceAbewn8b462yEWKA
|
||||
Rdpd9AjQW5SIVPfdsz5B6GlYQ5LdYKtznTuy7wIDAQABAoIBAQCwia1k7+2oZ2d3
|
||||
n6agCAbqIE1QXfCmh41ZqJHbOY3oRQG3X1wpcGH4Gk+O+zDVTV2JszdcOt7E5dAy
|
||||
MaomETAhRxB7hlIOnEN7WKm+dGNrKRvV0wDU5ReFMRHg31/Lnu8c+5BvGjZX+ky9
|
||||
POIhFFYJqwCRlopGSUIxmVj5rSgtzk3iWOQXr+ah1bjEXvlxDOWkHN6YfpV5ThdE
|
||||
KdBIPGEVqa63r9n2h+qazKrtiRqJqGnOrHzOECYbRFYhexsNFz7YT02xdfSHn7gM
|
||||
IvabDDP/Qp0PjE1jdouiMaFHYnLBbgvlnZW9yuVf/rpXTUq/njxIXMmvmEyyvSDn
|
||||
FcFikB8pAoGBAPF77hK4m3/rdGT7X8a/gwvZ2R121aBcdPwEaUhvj/36dx596zvY
|
||||
mEOjrWfZhF083/nYWE2kVquj2wjs+otCLfifEEgXcVPTnEOPO9Zg3uNSL0nNQghj
|
||||
FuD3iGLTUBCtM66oTe0jLSslHe8gLGEQqyMzHOzYxNqibxcOZIe8Qt0NAoGBAO+U
|
||||
I5+XWjWEgDmvyC3TrOSf/KCGjtu0TSv30ipv27bDLMrpvPmD/5lpptTFwcxvVhCs
|
||||
2b+chCjlghFSWFbBULBrfci2FtliClOVMYrlNBdUSJhf3aYSG2Doe6Bgt1n2CpNn
|
||||
/iu37Y3NfemZBJA7hNl4dYe+f+uzM87cdQ214+jrAoGAXA0XxX8ll2+ToOLJsaNT
|
||||
OvNB9h9Uc5qK5X5w+7G7O998BN2PC/MWp8H+2fVqpXgNENpNXttkRm1hk1dych86
|
||||
EunfdPuqsX+as44oCyJGFHVBnWpm33eWQw9YqANRI+pCJzP08I5WK3osnPiwshd+
|
||||
hR54yjgfYhBFNI7B95PmEQkCgYBzFSz7h1+s34Ycr8SvxsOBWxymG5zaCsUbPsL0
|
||||
4aCgLScCHb9J+E86aVbbVFdglYa5Id7DPTL61ixhl7WZjujspeXZGSbmq0Kcnckb
|
||||
mDgqkLECiOJW2NHP/j0McAkDLL4tysF8TLDO8gvuvzNC+WQ6drO2ThrypLVZQ+ry
|
||||
eBIPmwKBgEZxhqa0gVvHQG/7Od69KWj4eJP28kq13RhKay8JOoN0vPmspXJo1HY3
|
||||
CKuHRG+AP579dncdUnOMvfXOtkdM4vk0+hWASBQzM9xzVcztCa+koAugjVaLS9A+
|
||||
9uQoqEeVNTckxx0S2bYevRy7hGQmUJTyQm3j1zEUR5jpdbL83Fbq
|
||||
-----END RSA PRIVATE KEY-----
|
||||
@@ -1,9 +0,0 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4f5wg5l2hKsTeNem/V41
|
||||
fGnJm6gOdrj8ym3rFkEU/wT8RDtnSgFEZOQpHEgQ7JL38xUfU0Y3g6aYw9QT0hJ7
|
||||
mCpz9Er5qLaMXJwZxzHzAahlfA0icqabvJOMvQtzD6uQv6wPEyZtDTWiQi9AXwBp
|
||||
HssPnpYGIn20ZZuNlX2BrClciHhCPUIIZOQn/MmqTD31jSyjoQoV7MhhMTATKJx2
|
||||
XrHhR+1DcKJzQBSTAGnpYVaqpsARap+nwRipr3nUTuxyGohBTSmjJ2usSeQXHI3b
|
||||
ODIRe1AuTyHceAbewn8b462yEWKARdpd9AjQW5SIVPfdsz5B6GlYQ5LdYKtznTuy
|
||||
7wIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -1,16 +0,0 @@
|
||||
package event_bus
|
||||
|
||||
type BaseEvent struct {
|
||||
AccountID string
|
||||
}
|
||||
|
||||
type PeerCreated struct {
|
||||
BaseEvent
|
||||
PeerID string
|
||||
}
|
||||
|
||||
type PortAllocated struct {
|
||||
BaseEvent
|
||||
PeerID string
|
||||
Port int
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
# Pocketbase Hooks
|
||||
|
||||
This package original authoring is by Gani Georgiev the creator and mantainer of Pocketbase, I've merely ported and adapted it by removing dependencies. You can read more about it and its use on Pocketbase [here](https://pocketbase.io/docs/go-event-hooks/).
|
||||
|
||||
This is a very powerful event/hook system, especially this:
|
||||
|
||||
> All hook handler functions share the same func(e T) error signature and expect the user to call e.Next() if they want to proceed with the execution chain.
|
||||
|
||||
I've included it in our cloud repository and implemented the concept with a sub-set of billing events as a PoC of how we could re-architect Management for a cleaner developer experience and faster development by decoupling server bootstrapping (dependencies), serve (http and gRPC) and actual features (billing, idp, event streaming, etc).
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package hook
|
||||
|
||||
// Resolver defines a common interface for a Hook event (see [Event]).
|
||||
type Resolver interface {
|
||||
// Next triggers the next handler in the hook's chain (if any).
|
||||
Next() error
|
||||
|
||||
// note: kept only for the generic interface; may get removed in the future
|
||||
nextFunc() func() error
|
||||
setNextFunc(f func() error)
|
||||
}
|
||||
|
||||
var _ Resolver = (*Event)(nil)
|
||||
|
||||
// Event implements [Resolver] and it is intended to be used as a base
|
||||
// Hook event that you can embed in your custom typed event structs.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// type CustomEvent struct {
|
||||
// hook.Event
|
||||
//
|
||||
// SomeField int
|
||||
// }
|
||||
type Event struct {
|
||||
next func() error
|
||||
}
|
||||
|
||||
// Next calls the next hook handler.
|
||||
func (e *Event) Next() error {
|
||||
if e.next != nil {
|
||||
return e.next()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nextFunc returns the function that Next calls.
|
||||
func (e *Event) nextFunc() func() error {
|
||||
return e.next
|
||||
}
|
||||
|
||||
// setNextFunc sets the function that Next calls.
|
||||
func (e *Event) setNextFunc(f func() error) {
|
||||
e.next = f
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package hook
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEventNext(t *testing.T) {
|
||||
calls := 0
|
||||
|
||||
e := Event{}
|
||||
|
||||
if e.nextFunc() != nil {
|
||||
t.Fatalf("Expected nextFunc to be nil")
|
||||
}
|
||||
|
||||
e.setNextFunc(func() error {
|
||||
calls++
|
||||
return nil
|
||||
})
|
||||
|
||||
if e.nextFunc() == nil {
|
||||
t.Fatalf("Expected nextFunc to be non-nil")
|
||||
}
|
||||
|
||||
e.Next()
|
||||
e.Next()
|
||||
|
||||
if calls != 2 {
|
||||
t.Fatalf("Expected %d calls, got %d", 2, calls)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user