extract repo

This commit is contained in:
Pascal Fischer
2025-06-11 23:24:35 +02:00
parent e662c5dd54
commit f612dba3d3
43 changed files with 49 additions and 4021 deletions
@@ -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
-178
View File
@@ -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")
}
})
}
}
-144
View File
@@ -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
}
-302
View File
@@ -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
}
-177
View File
@@ -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
}
-54
View File
@@ -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
}
-407
View File
@@ -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)
}
})
}
}
-11
View File
@@ -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-----
-16
View File
@@ -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
}
-10
View File
@@ -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).
-45
View File
@@ -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
}
-29
View File
@@ -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