From 8cdeed09d87803ff0b2aa04ddd3daf813bd77b79 Mon Sep 17 00:00:00 2001 From: Patrick Date: Wed, 1 Jan 2025 18:29:20 +0000 Subject: [PATCH 01/10] WIP. Still specing stuff out --- director/director.go | 37 ++++++++++++++++++ director/director_db.go | 38 +++++++++++++++++++ .../20241230195730_create_db_tables.sql | 20 ++++++++++ 3 files changed, 95 insertions(+) create mode 100644 director/migrations/20241230195730_create_db_tables.sql diff --git a/director/director.go b/director/director.go index a059b91b5..9dff7f296 100644 --- a/director/director.go +++ b/director/director.go @@ -77,6 +77,10 @@ type ( // Context key for the project name ProjectContextKey struct{} + + CreateGrafanaTokenReq struct { + Description string `json:"description" binding:"required"` + } ) const ( @@ -1360,6 +1364,37 @@ func listNamespacesV2(ctx *gin.Context) { ctx.JSON(http.StatusOK, namespacesAdsV2) } +func createGrafanaToken(ctx *gin.Context) { + authOption := token.AuthOption{ + Sources: []token.TokenSource{token.Header}, + Issuers: []token.TokenIssuer{token.LocalIssuer}, + Scopes: []token_scopes.TokenScope{token_scopes.WebUi_Access}, + } + + status, ok, err := token.Verify(ctx, authOption) + if !ok { + log.Warningf("Cannot verify token: %v", err) + ctx.JSON(status, server_structs.SimpleApiResp{ + Status: server_structs.RespFailed, + Msg: err.Error(), + }) + return + } + // marshall body into struct + var req CreateGrafanaTokenReq + err = ctx.ShouldBindJSON(&req) + if err != nil { + ctx.JSON(http.StatusBadRequest, server_structs.SimpleApiResp{ + Status: server_structs.RespFailed, + Msg: "Invalid request body", + }) + return + } + + grafanaToken, _ := createGrafanaApiKey(req.Description) + ctx.JSON(http.StatusOK, grafanaToken) +} + func getPrefixByPath(ctx *gin.Context) { pathParam := ctx.Param("path") if pathParam == "" || pathParam == "/" { @@ -1510,6 +1545,8 @@ func RegisterDirectorAPI(ctx context.Context, router *gin.RouterGroup) { // so that director can be our point of contact for collecting system-level metrics. // Rename the endpoint to reflect such plan. directorAPIV1.GET("/discoverServers", discoverOriginCache) + + directorAPIV1.POST("/createGrafanaToken", createGrafanaToken) } directorAPIV2 := router.Group("/api/v2.0/director") diff --git a/director/director_db.go b/director/director_db.go index 1a5065359..44d2b8108 100644 --- a/director/director_db.go +++ b/director/director_db.go @@ -23,6 +23,7 @@ import ( "github.com/google/uuid" "github.com/pkg/errors" + "golang.org/x/crypto/bcrypt" "gorm.io/gorm" "gorm.io/gorm/logger" @@ -30,6 +31,16 @@ import ( "github.com/pelicanplatform/pelican/server_utils" ) +type GrafanaApiKey struct { + // Salted and Hashed API Key + Key string `gorm:"primaryKey"` + Name string `gorm:"not null"` + Description string `gorm:"not null"` + ExpiresAt time.Time + Creator string + CreatedAt time.Time +} + type ServerDowntime struct { UUID string `gorm:"primaryKey"` Name string `gorm:"not null;unique"` @@ -68,6 +79,33 @@ func shutdownDirectorDB() error { return server_utils.ShutdownDB(db) } +func createGrafanaApiKey(description string) (string, error) { + uuid, err := uuid.NewRandom() + if err != nil { + return "", errors.Wrap(err, "unable to create new UUID for new entry in Grafana API key table") + } + + bytes, err := uuid.MarshalBinary() + if err != nil { + return "", errors.Wrap(err, "unable to marshal UUID to binary") + } + + bcryptHash, err := bcrypt.GenerateFromPassword(bytes, bcrypt.DefaultCost) + if err != nil { + return "", errors.Wrap(err, "unable to hash UUID") + } + + apiKey := GrafanaApiKey{ + Key: string(bcryptHash), + Description: description, + CreatedAt: time.Now(), + } + if err := db.Create(apiKey).Error; err != nil { + return "", errors.Wrap(err, "unable to create Grafana API key table") + } + return uuid.String(), nil +} + // Create a new db entry representing the downtime info of a server func createServerDowntime(serverName string, filterType filterType) error { id, err := uuid.NewV7() diff --git a/director/migrations/20241230195730_create_db_tables.sql b/director/migrations/20241230195730_create_db_tables.sql new file mode 100644 index 000000000..7787f3d0b --- /dev/null +++ b/director/migrations/20241230195730_create_db_tables.sql @@ -0,0 +1,20 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS server_downtimes ( + uuid TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + filter_type TEXT NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +); + +CREATE TABLE IF NOT EXISTS grafana_api_keys ( + key TEXT PRIMARY KEY, + description TEXT NOT NULL, + created_at DATETIME NOT NULL +) +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +-- +goose StatementEnd From 5c89dd3749c914bf9971157e1625157a79f02993 Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 3 Jan 2025 16:49:59 +0000 Subject: [PATCH 02/10] Create token seems to be working --- director/director.go | 22 ++++-- director/director_db.go | 76 +++++++++++++------ ...ql => 20250103161929_create_db_tables.sql} | 19 +++-- 3 files changed, 82 insertions(+), 35 deletions(-) rename director/migrations/{20241230195730_create_db_tables.sql => 20250103161929_create_db_tables.sql} (55%) diff --git a/director/director.go b/director/director.go index 9dff7f296..94eda62d0 100644 --- a/director/director.go +++ b/director/director.go @@ -79,7 +79,9 @@ type ( ProjectContextKey struct{} CreateGrafanaTokenReq struct { - Description string `json:"description" binding:"required"` + Name string `json:"name"` + CreatedBy string `json:"created_by"` + Scopes string `json:"scopes"` } ) @@ -1365,8 +1367,9 @@ func listNamespacesV2(ctx *gin.Context) { } func createGrafanaToken(ctx *gin.Context) { + fmt.Println("CREATING GRAFANA TOKEN") authOption := token.AuthOption{ - Sources: []token.TokenSource{token.Header}, + Sources: []token.TokenSource{token.Cookie}, Issuers: []token.TokenIssuer{token.LocalIssuer}, Scopes: []token_scopes.TokenScope{token_scopes.WebUi_Access}, } @@ -1386,13 +1389,22 @@ func createGrafanaToken(ctx *gin.Context) { if err != nil { ctx.JSON(http.StatusBadRequest, server_structs.SimpleApiResp{ Status: server_structs.RespFailed, - Msg: "Invalid request body", + Msg: fmt.Sprintf("Invalid request body: %v", err), + }) + return + } + + token, err := createGrafanaApiKey(req.Name, req.CreatedBy, req.Scopes) + if err != nil { + log.Warning("Failed to create Grafana API key: ", err) + ctx.JSON(status, server_structs.SimpleApiResp{ + Status: server_structs.RespFailed, + Msg: err.Error(), }) return } - grafanaToken, _ := createGrafanaApiKey(req.Description) - ctx.JSON(http.StatusOK, grafanaToken) + ctx.JSON(http.StatusOK, gin.H{"token": token}) } func getPrefixByPath(ctx *gin.Context) { diff --git a/director/director_db.go b/director/director_db.go index 44d2b8108..16b7e4547 100644 --- a/director/director_db.go +++ b/director/director_db.go @@ -18,7 +18,11 @@ package director import ( + "crypto/rand" + "crypto/sha256" "embed" + "encoding/hex" + "fmt" "time" "github.com/google/uuid" @@ -32,13 +36,13 @@ import ( ) type GrafanaApiKey struct { - // Salted and Hashed API Key - Key string `gorm:"primaryKey"` - Name string `gorm:"not null"` - Description string `gorm:"not null"` + ID string `gorm:"primaryKey;column:id;type:text;not null"` + Name string `gorm:"column:name;type:text"` + HashedValue string `gorm:"column:hashed_value;type:text;not null"` + Scopes string `gorm:"column:scopes;type:text"` ExpiresAt time.Time - Creator string CreatedAt time.Time + CreatedBy string `gorm:"column:created_by;type:text"` } type ServerDowntime struct { @@ -79,31 +83,55 @@ func shutdownDirectorDB() error { return server_utils.ShutdownDB(db) } -func createGrafanaApiKey(description string) (string, error) { - uuid, err := uuid.NewRandom() +func generateSecret(length int) (string, error) { + bytes := make([]byte, length) + _, err := rand.Read(bytes) if err != nil { - return "", errors.Wrap(err, "unable to create new UUID for new entry in Grafana API key table") + return "", err } + return hex.EncodeToString(bytes), nil +} - bytes, err := uuid.MarshalBinary() - if err != nil { - return "", errors.Wrap(err, "unable to marshal UUID to binary") - } +func generateTokenID(secret string) string { + hash := sha256.Sum256([]byte(secret)) + return hex.EncodeToString(hash[:])[:5] +} - bcryptHash, err := bcrypt.GenerateFromPassword(bytes, bcrypt.DefaultCost) - if err != nil { - return "", errors.Wrap(err, "unable to hash UUID") - } +func createGrafanaApiKey(name, createdBy, scopes string) (string, error) { + expiresAt := time.Now().Add(time.Hour * 24 * 30) // 30 days + for { + secret, err := generateSecret(32) + if err != nil { + return "", errors.Wrap(err, "failed to generate a secret") + } - apiKey := GrafanaApiKey{ - Key: string(bcryptHash), - Description: description, - CreatedAt: time.Now(), - } - if err := db.Create(apiKey).Error; err != nil { - return "", errors.Wrap(err, "unable to create Grafana API key table") + id := generateTokenID(secret) + + hashedValue, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost) + if err != nil { + return "", errors.Wrap(err, "failed to hash the secret") + } + + apiKey := GrafanaApiKey{ + ID: id, + Name: name, + HashedValue: string(hashedValue), + Scopes: scopes, + ExpiresAt: expiresAt, + CreatedAt: time.Now(), + CreatedBy: createdBy, + } + result := db.Create(apiKey) + if result.Error != nil { + isConstraintError := result.Error.Error() == "UNIQUE constraint failed: tokens.id" + if !isConstraintError { + return "", errors.Wrap(result.Error, "failed to create a new Grafana API key") + } + // If the ID is already taken, try again + continue + } + return fmt.Sprintf("%s.%s", id, secret), nil } - return uuid.String(), nil } // Create a new db entry representing the downtime info of a server diff --git a/director/migrations/20241230195730_create_db_tables.sql b/director/migrations/20250103161929_create_db_tables.sql similarity index 55% rename from director/migrations/20241230195730_create_db_tables.sql rename to director/migrations/20250103161929_create_db_tables.sql index 7787f3d0b..bf1596132 100644 --- a/director/migrations/20241230195730_create_db_tables.sql +++ b/director/migrations/20250103161929_create_db_tables.sql @@ -1,5 +1,8 @@ -- +goose Up -- +goose StatementBegin +SELECT 'up SQL query'; +-- +goose StatementEnd + CREATE TABLE IF NOT EXISTS server_downtimes ( uuid TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, @@ -8,13 +11,17 @@ CREATE TABLE IF NOT EXISTS server_downtimes ( updated_at DATETIME NOT NULL ); -CREATE TABLE IF NOT EXISTS grafana_api_keys ( - key TEXT PRIMARY KEY, - description TEXT NOT NULL, - created_at DATETIME NOT NULL -) --- +goose StatementEnd +CREATE TABLE grafana_api_keys ( + id TEXT PRIMARY KEY, + name TEXT, + hashed_value TEXT NOT NULL, + scopes TEXT, + expires_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_by TEXT +); -- +goose Down -- +goose StatementBegin +SELECT 'down SQL query'; -- +goose StatementEnd From 960e54f00522e6d05ea3e30bafe7df687821ce7b Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 3 Jan 2025 18:43:55 +0000 Subject: [PATCH 03/10] Implemented verification logic. Needs to be implemented in the token package --- director/director_db.go | 48 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/director/director_db.go b/director/director_db.go index 16b7e4547..bef032753 100644 --- a/director/director_db.go +++ b/director/director_db.go @@ -23,9 +23,11 @@ import ( "embed" "encoding/hex" "fmt" + "strings" "time" "github.com/google/uuid" + "github.com/jellydator/ttlcache/v3" "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" @@ -59,8 +61,11 @@ var db *gorm.DB //go:embed migrations/*.sql var embedMigrations embed.FS +var verifiedKeysCache *ttlcache.Cache[string, GrafanaApiKey] = ttlcache.New[string, GrafanaApiKey]() + // Initialize the Director's sqlite database, which is used to persist information about server downtimes func InitializeDB() error { + go verifiedKeysCache.Start() dbPath := param.Director_DbLocation.GetString() tdb, err := server_utils.InitSQLiteDB(dbPath) if err != nil { @@ -134,6 +139,49 @@ func createGrafanaApiKey(name, createdBy, scopes string) (string, error) { } } +// REMOVE THIS +// +//nolint:golint,unused +func verifyGrafanaApiKey(apiKey string) (bool, error) { + parts := strings.Split(apiKey, ".") + if len(parts) != 2 { + return false, errors.New("invalid API key format") + } + id := parts[0] + secret := parts[1] + + item := verifiedKeysCache.Get(id) + if item != nil { + cachedToken := item.Value() + beforeExpiration := time.Now().Before(cachedToken.ExpiresAt) + matches := bcrypt.CompareHashAndPassword([]byte(cachedToken.HashedValue), []byte(secret)) == nil + if beforeExpiration && matches { + return true, nil + } + } + + var token GrafanaApiKey + result := db.First(&token, "id = ?", id) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return false, nil // token not found + } + return false, errors.Wrap(result.Error, "failed to retrieve the Grafana API key") + } + + if time.Now().After(token.ExpiresAt) { + return false, nil + } + + err := bcrypt.CompareHashAndPassword([]byte(token.HashedValue), []byte(secret)) + if err != nil { + return false, nil + } + + verifiedKeysCache.Set(id, token, ttlcache.DefaultTTL) + return true, nil +} + // Create a new db entry representing the downtime info of a server func createServerDowntime(serverName string, filterType filterType) error { id, err := uuid.NewV7() From 11dc06544975b50403aff1c8eb6a628a8ecb8662 Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 10 Jan 2025 16:14:40 +0000 Subject: [PATCH 04/10] WIP --- director/director_db.go | 87 +++++++++++++++++++---------------------- token/token_verify.go | 5 ++- web_ui/authorization.go | 2 +- web_ui/database.go | 1 + 4 files changed, 46 insertions(+), 49 deletions(-) create mode 100644 web_ui/database.go diff --git a/director/director_db.go b/director/director_db.go index bef032753..88e6fe682 100644 --- a/director/director_db.go +++ b/director/director_db.go @@ -56,16 +56,54 @@ type ServerDowntime struct { UpdatedAt time.Time } +var verifiedKeysCache *ttlcache.Cache[string, GrafanaApiKey] = ttlcache.New[string, GrafanaApiKey]() var db *gorm.DB +func VerifyGrafanaApiKey(apiKey string) (bool, error) { + parts := strings.Split(apiKey, ".") + if len(parts) != 2 { + return false, errors.New("invalid API key format") + } + id := parts[0] + secret := parts[1] + + item := verifiedKeysCache.Get(id) + if item != nil { + cachedToken := item.Value() + beforeExpiration := time.Now().Before(cachedToken.ExpiresAt) + matches := bcrypt.CompareHashAndPassword([]byte(cachedToken.HashedValue), []byte(secret)) == nil + if beforeExpiration && matches { + return true, nil + } + } + + var token GrafanaApiKey + result := db.First(&token, "id = ?", id) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return false, nil // token not found + } + return false, errors.Wrap(result.Error, "failed to retrieve the Grafana API key") + } + + if time.Now().After(token.ExpiresAt) { + return false, nil + } + + err := bcrypt.CompareHashAndPassword([]byte(token.HashedValue), []byte(secret)) + if err != nil { + return false, nil + } + + verifiedKeysCache.Set(id, token, ttlcache.DefaultTTL) + return true, nil +} + //go:embed migrations/*.sql var embedMigrations embed.FS -var verifiedKeysCache *ttlcache.Cache[string, GrafanaApiKey] = ttlcache.New[string, GrafanaApiKey]() - // Initialize the Director's sqlite database, which is used to persist information about server downtimes func InitializeDB() error { - go verifiedKeysCache.Start() dbPath := param.Director_DbLocation.GetString() tdb, err := server_utils.InitSQLiteDB(dbPath) if err != nil { @@ -139,49 +177,6 @@ func createGrafanaApiKey(name, createdBy, scopes string) (string, error) { } } -// REMOVE THIS -// -//nolint:golint,unused -func verifyGrafanaApiKey(apiKey string) (bool, error) { - parts := strings.Split(apiKey, ".") - if len(parts) != 2 { - return false, errors.New("invalid API key format") - } - id := parts[0] - secret := parts[1] - - item := verifiedKeysCache.Get(id) - if item != nil { - cachedToken := item.Value() - beforeExpiration := time.Now().Before(cachedToken.ExpiresAt) - matches := bcrypt.CompareHashAndPassword([]byte(cachedToken.HashedValue), []byte(secret)) == nil - if beforeExpiration && matches { - return true, nil - } - } - - var token GrafanaApiKey - result := db.First(&token, "id = ?", id) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return false, nil // token not found - } - return false, errors.Wrap(result.Error, "failed to retrieve the Grafana API key") - } - - if time.Now().After(token.ExpiresAt) { - return false, nil - } - - err := bcrypt.CompareHashAndPassword([]byte(token.HashedValue), []byte(secret)) - if err != nil { - return false, nil - } - - verifiedKeysCache.Set(id, token, ttlcache.DefaultTTL) - return true, nil -} - // Create a new db entry representing the downtime info of a server func createServerDowntime(serverName string, filterType filterType) error { id, err := uuid.NewV7() diff --git a/token/token_verify.go b/token/token_verify.go index 22df6d72e..e76a6b9e0 100644 --- a/token/token_verify.go +++ b/token/token_verify.go @@ -62,8 +62,9 @@ const ( ) const ( - FederationIssuer TokenIssuer = "FederationIssuer" - LocalIssuer TokenIssuer = "LocalIssuer" + FederationIssuer TokenIssuer = "FederationIssuer" + LocalIssuer TokenIssuer = "LocalIssuer" + GrafanaTokenIssuer TokenIssuer = "GrafanaTokenIssuer" ) var ( diff --git a/web_ui/authorization.go b/web_ui/authorization.go index b55830a19..51e5d6ba0 100644 --- a/web_ui/authorization.go +++ b/web_ui/authorization.go @@ -89,7 +89,7 @@ func promQueryEngineAuthHandler(av1 *route.Router) gin.HandlerFunc { authOption := token.AuthOption{ // Cookie for web user access and header for external service like Grafana to access Sources: []token.TokenSource{token.Cookie, token.Header}, - Issuers: []token.TokenIssuer{token.LocalIssuer}, + Issuers: []token.TokenIssuer{token.LocalIssuer, token.GrafanaTokenIssuer}, Scopes: []token_scopes.TokenScope{token_scopes.Monitoring_Query}} status, ok, err := token.Verify(c, authOption) diff --git a/web_ui/database.go b/web_ui/database.go new file mode 100644 index 000000000..2df2a21d6 --- /dev/null +++ b/web_ui/database.go @@ -0,0 +1 @@ +package web_ui From c20da91fd77e268f23d2b12ff7825bc653160f91 Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 17 Jan 2025 18:56:24 +0000 Subject: [PATCH 05/10] Create database package for director db connection --- database/director.go | 5 +++++ director/director_db.go | 25 +++++++++++++------------ director/director_db_test.go | 7 ++++--- 3 files changed, 22 insertions(+), 15 deletions(-) create mode 100644 database/director.go diff --git a/database/director.go b/database/director.go new file mode 100644 index 000000000..d251c4d71 --- /dev/null +++ b/database/director.go @@ -0,0 +1,5 @@ +package database + +import "gorm.io/gorm" + +var DirectorDB *gorm.DB diff --git a/director/director_db.go b/director/director_db.go index 88e6fe682..e39b0ee85 100644 --- a/director/director_db.go +++ b/director/director_db.go @@ -33,6 +33,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/logger" + "github.com/pelicanplatform/pelican/database" "github.com/pelicanplatform/pelican/param" "github.com/pelicanplatform/pelican/server_utils" ) @@ -57,7 +58,6 @@ type ServerDowntime struct { } var verifiedKeysCache *ttlcache.Cache[string, GrafanaApiKey] = ttlcache.New[string, GrafanaApiKey]() -var db *gorm.DB func VerifyGrafanaApiKey(apiKey string) (bool, error) { parts := strings.Split(apiKey, ".") @@ -78,7 +78,8 @@ func VerifyGrafanaApiKey(apiKey string) (bool, error) { } var token GrafanaApiKey - result := db.First(&token, "id = ?", id) + // result := db.First(&token, "id = ?", id) + result := database.DirectorDB.First(&token, "id = ?", id) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { return false, nil // token not found @@ -109,8 +110,8 @@ func InitializeDB() error { if err != nil { return errors.Wrap(err, "failed to initialize the Director's sqlite database") } - db = tdb - sqldb, err := db.DB() + database.DirectorDB = tdb + sqldb, err := database.DirectorDB.DB() if err != nil { return errors.Wrapf(err, "failed to get sql.DB from gorm DB: %s", dbPath) } @@ -123,7 +124,7 @@ func InitializeDB() error { // Shut down the Director's sqlite database func shutdownDirectorDB() error { - return server_utils.ShutdownDB(db) + return server_utils.ShutdownDB(database.DirectorDB) } func generateSecret(length int) (string, error) { @@ -164,7 +165,7 @@ func createGrafanaApiKey(name, createdBy, scopes string) (string, error) { CreatedAt: time.Now(), CreatedBy: createdBy, } - result := db.Create(apiKey) + result := database.DirectorDB.Create(apiKey) if result.Error != nil { isConstraintError := result.Error.Error() == "UNIQUE constraint failed: tokens.id" if !isConstraintError { @@ -191,7 +192,7 @@ func createServerDowntime(serverName string, filterType filterType) error { UpdatedAt: time.Now(), } - if err := db.Create(serverDowntime).Error; err != nil { + if err := database.DirectorDB.Create(serverDowntime).Error; err != nil { return errors.Wrap(err, "unable to create server downtime table") } return nil @@ -200,7 +201,7 @@ func createServerDowntime(serverName string, filterType filterType) error { // Retrieve the downtime info of a given server (filter applied to the server) func getServerDowntime(serverName string) (filterType, error) { var serverDowntime ServerDowntime - err := db.First(&serverDowntime, "name = ?", serverName).Error + err := database.DirectorDB.First(&serverDowntime, "name = ?", serverName).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return "", errors.Wrapf(err, "%s is not found in the Director db", serverName) @@ -213,7 +214,7 @@ func getServerDowntime(serverName string) (filterType, error) { // Retrieve the downtime info of all servers saved in the Director's sqlite database func getAllServerDowntimes() ([]ServerDowntime, error) { var statuses []ServerDowntime - result := db.Find(&statuses) + result := database.DirectorDB.Find(&statuses) if result.Error != nil { return nil, errors.Wrap(result.Error, "unable to get the downtime of all servers") @@ -225,7 +226,7 @@ func getAllServerDowntimes() ([]ServerDowntime, error) { func setServerDowntime(serverName string, filterType filterType) error { var serverDowntime ServerDowntime // silence the logger for this query because there's definitely an ErrRecordNotFound when a new downtime info entry inserted - err := db.Session(&gorm.Session{Logger: db.Logger.LogMode(logger.Silent)}).First(&serverDowntime, "name = ?", serverName).Error + err := database.DirectorDB.Session(&gorm.Session{Logger: database.DirectorDB.Logger.LogMode(logger.Silent)}).First(&serverDowntime, "name = ?", serverName).Error // If the server doesn't exist in director db, create a new entry for it if err != nil { @@ -239,7 +240,7 @@ func setServerDowntime(serverName string, filterType filterType) error { serverDowntime.FilterType = filterType serverDowntime.UpdatedAt = time.Now() - if err := db.Save(&serverDowntime).Error; err != nil { + if err := database.DirectorDB.Save(&serverDowntime).Error; err != nil { return errors.Wrap(err, "unable to update") } return nil @@ -253,7 +254,7 @@ var setServerDowntimeFn setServerDowntimeFunc = setServerDowntime // Delete the downtime info of a given server from the Director's sqlite database func deleteServerDowntime(serverName string) error { - if err := db.Where("name = ?", serverName).Delete(&ServerDowntime{}).Error; err != nil { + if err := database.DirectorDB.Where("name = ?", serverName).Delete(&ServerDowntime{}).Error; err != nil { return errors.Wrap(err, "failed to delete an entry in Server Status table") } return nil diff --git a/director/director_db_test.go b/director/director_db_test.go index 094ce59e8..d1e71bd70 100644 --- a/director/director_db_test.go +++ b/director/director_db_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "gorm.io/gorm" + "github.com/pelicanplatform/pelican/database" "github.com/pelicanplatform/pelican/server_utils" ) @@ -22,9 +23,9 @@ var ( func SetupMockDirectorDB(t *testing.T) { mockDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - db = mockDB + database.DirectorDB = mockDB require.NoError(t, err, "Error setting up mock origin DB") - err = db.AutoMigrate(&ServerDowntime{}) + err = database.DirectorDB.AutoMigrate(&ServerDowntime{}) require.NoError(t, err, "Failed to migrate DB for Globus table") } @@ -34,7 +35,7 @@ func TeardownMockDirectorDB(t *testing.T) { } func insertMockDBData(ss []ServerDowntime) error { - return db.Create(&ss).Error + return database.DirectorDB.Create(&ss).Error } func TestDirectorDBBasics(t *testing.T) { From 827bce09b31c409505637ba07e7c73f533c21a67 Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 17 Jan 2025 19:10:15 +0000 Subject: [PATCH 06/10] Migrate grafana token code to database package and use grafana verify function in token verification process --- database/director.go | 117 +++++++++++++++++++++++++++++++++++++++- director/director.go | 4 +- director/director_db.go | 111 -------------------------------------- token/token_verify.go | 11 ++++ 4 files changed, 129 insertions(+), 114 deletions(-) diff --git a/database/director.go b/database/director.go index d251c4d71..e65678727 100644 --- a/database/director.go +++ b/database/director.go @@ -1,5 +1,120 @@ package database -import "gorm.io/gorm" +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "time" + + "github.com/jellydator/ttlcache/v3" + "github.com/pkg/errors" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) var DirectorDB *gorm.DB + +type GrafanaApiKey struct { + ID string `gorm:"primaryKey;column:id;type:text;not null"` + Name string `gorm:"column:name;type:text"` + HashedValue string `gorm:"column:hashed_value;type:text;not null"` + Scopes string `gorm:"column:scopes;type:text"` + ExpiresAt time.Time + CreatedAt time.Time + CreatedBy string `gorm:"column:created_by;type:text"` +} + +var verifiedKeysCache *ttlcache.Cache[string, GrafanaApiKey] = ttlcache.New[string, GrafanaApiKey]() + +func generateSecret(length int) (string, error) { + bytes := make([]byte, length) + _, err := rand.Read(bytes) + if err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +func generateTokenID(secret string) string { + hash := sha256.Sum256([]byte(secret)) + return hex.EncodeToString(hash[:])[:5] +} +func VerifyGrafanaApiKey(apiKey string) (bool, error) { + parts := strings.Split(apiKey, ".") + if len(parts) != 2 { + return false, errors.New("invalid API key format") + } + id := parts[0] + secret := parts[1] + + item := verifiedKeysCache.Get(id) + if item != nil { + cachedToken := item.Value() + beforeExpiration := time.Now().Before(cachedToken.ExpiresAt) + matches := bcrypt.CompareHashAndPassword([]byte(cachedToken.HashedValue), []byte(secret)) == nil + if beforeExpiration && matches { + return true, nil + } + } + + var token GrafanaApiKey + // result := db.First(&token, "id = ?", id) + result := DirectorDB.First(&token, "id = ?", id) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return false, nil // token not found + } + return false, errors.Wrap(result.Error, "failed to retrieve the Grafana API key") + } + + if time.Now().After(token.ExpiresAt) { + return false, nil + } + + err := bcrypt.CompareHashAndPassword([]byte(token.HashedValue), []byte(secret)) + if err != nil { + return false, nil + } + + verifiedKeysCache.Set(id, token, ttlcache.DefaultTTL) + return true, nil +} + +func CreateGrafanaApiKey(name, createdBy, scopes string) (string, error) { + expiresAt := time.Now().Add(time.Hour * 24 * 30) // 30 days + for { + secret, err := generateSecret(32) + if err != nil { + return "", errors.Wrap(err, "failed to generate a secret") + } + + id := generateTokenID(secret) + + hashedValue, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost) + if err != nil { + return "", errors.Wrap(err, "failed to hash the secret") + } + + apiKey := GrafanaApiKey{ + ID: id, + Name: name, + HashedValue: string(hashedValue), + Scopes: scopes, + ExpiresAt: expiresAt, + CreatedAt: time.Now(), + CreatedBy: createdBy, + } + result := DirectorDB.Create(apiKey) + if result.Error != nil { + isConstraintError := result.Error.Error() == "UNIQUE constraint failed: tokens.id" + if !isConstraintError { + return "", errors.Wrap(result.Error, "failed to create a new Grafana API key") + } + // If the ID is already taken, try again + continue + } + return fmt.Sprintf("%s.%s", id, secret), nil + } +} diff --git a/director/director.go b/director/director.go index 94eda62d0..6e7738670 100644 --- a/director/director.go +++ b/director/director.go @@ -39,6 +39,7 @@ import ( "golang.org/x/sync/errgroup" "github.com/pelicanplatform/pelican/config" + "github.com/pelicanplatform/pelican/database" "github.com/pelicanplatform/pelican/metrics" "github.com/pelicanplatform/pelican/param" "github.com/pelicanplatform/pelican/pelican_url" @@ -1367,7 +1368,6 @@ func listNamespacesV2(ctx *gin.Context) { } func createGrafanaToken(ctx *gin.Context) { - fmt.Println("CREATING GRAFANA TOKEN") authOption := token.AuthOption{ Sources: []token.TokenSource{token.Cookie}, Issuers: []token.TokenIssuer{token.LocalIssuer}, @@ -1394,7 +1394,7 @@ func createGrafanaToken(ctx *gin.Context) { return } - token, err := createGrafanaApiKey(req.Name, req.CreatedBy, req.Scopes) + token, err := database.CreateGrafanaApiKey(req.Name, req.CreatedBy, req.Scopes) if err != nil { log.Warning("Failed to create Grafana API key: ", err) ctx.JSON(status, server_structs.SimpleApiResp{ diff --git a/director/director_db.go b/director/director_db.go index e39b0ee85..c588558e4 100644 --- a/director/director_db.go +++ b/director/director_db.go @@ -18,18 +18,11 @@ package director import ( - "crypto/rand" - "crypto/sha256" "embed" - "encoding/hex" - "fmt" - "strings" "time" "github.com/google/uuid" - "github.com/jellydator/ttlcache/v3" "github.com/pkg/errors" - "golang.org/x/crypto/bcrypt" "gorm.io/gorm" "gorm.io/gorm/logger" @@ -38,16 +31,6 @@ import ( "github.com/pelicanplatform/pelican/server_utils" ) -type GrafanaApiKey struct { - ID string `gorm:"primaryKey;column:id;type:text;not null"` - Name string `gorm:"column:name;type:text"` - HashedValue string `gorm:"column:hashed_value;type:text;not null"` - Scopes string `gorm:"column:scopes;type:text"` - ExpiresAt time.Time - CreatedAt time.Time - CreatedBy string `gorm:"column:created_by;type:text"` -} - type ServerDowntime struct { UUID string `gorm:"primaryKey"` Name string `gorm:"not null;unique"` @@ -57,49 +40,6 @@ type ServerDowntime struct { UpdatedAt time.Time } -var verifiedKeysCache *ttlcache.Cache[string, GrafanaApiKey] = ttlcache.New[string, GrafanaApiKey]() - -func VerifyGrafanaApiKey(apiKey string) (bool, error) { - parts := strings.Split(apiKey, ".") - if len(parts) != 2 { - return false, errors.New("invalid API key format") - } - id := parts[0] - secret := parts[1] - - item := verifiedKeysCache.Get(id) - if item != nil { - cachedToken := item.Value() - beforeExpiration := time.Now().Before(cachedToken.ExpiresAt) - matches := bcrypt.CompareHashAndPassword([]byte(cachedToken.HashedValue), []byte(secret)) == nil - if beforeExpiration && matches { - return true, nil - } - } - - var token GrafanaApiKey - // result := db.First(&token, "id = ?", id) - result := database.DirectorDB.First(&token, "id = ?", id) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return false, nil // token not found - } - return false, errors.Wrap(result.Error, "failed to retrieve the Grafana API key") - } - - if time.Now().After(token.ExpiresAt) { - return false, nil - } - - err := bcrypt.CompareHashAndPassword([]byte(token.HashedValue), []byte(secret)) - if err != nil { - return false, nil - } - - verifiedKeysCache.Set(id, token, ttlcache.DefaultTTL) - return true, nil -} - //go:embed migrations/*.sql var embedMigrations embed.FS @@ -127,57 +67,6 @@ func shutdownDirectorDB() error { return server_utils.ShutdownDB(database.DirectorDB) } -func generateSecret(length int) (string, error) { - bytes := make([]byte, length) - _, err := rand.Read(bytes) - if err != nil { - return "", err - } - return hex.EncodeToString(bytes), nil -} - -func generateTokenID(secret string) string { - hash := sha256.Sum256([]byte(secret)) - return hex.EncodeToString(hash[:])[:5] -} - -func createGrafanaApiKey(name, createdBy, scopes string) (string, error) { - expiresAt := time.Now().Add(time.Hour * 24 * 30) // 30 days - for { - secret, err := generateSecret(32) - if err != nil { - return "", errors.Wrap(err, "failed to generate a secret") - } - - id := generateTokenID(secret) - - hashedValue, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost) - if err != nil { - return "", errors.Wrap(err, "failed to hash the secret") - } - - apiKey := GrafanaApiKey{ - ID: id, - Name: name, - HashedValue: string(hashedValue), - Scopes: scopes, - ExpiresAt: expiresAt, - CreatedAt: time.Now(), - CreatedBy: createdBy, - } - result := database.DirectorDB.Create(apiKey) - if result.Error != nil { - isConstraintError := result.Error.Error() == "UNIQUE constraint failed: tokens.id" - if !isConstraintError { - return "", errors.Wrap(result.Error, "failed to create a new Grafana API key") - } - // If the ID is already taken, try again - continue - } - return fmt.Sprintf("%s.%s", id, secret), nil - } -} - // Create a new db entry representing the downtime info of a server func createServerDowntime(serverName string, filterType filterType) error { id, err := uuid.NewV7() diff --git a/token/token_verify.go b/token/token_verify.go index e76a6b9e0..397dc767c 100644 --- a/token/token_verify.go +++ b/token/token_verify.go @@ -35,6 +35,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/pelicanplatform/pelican/config" + "github.com/pelicanplatform/pelican/database" "github.com/pelicanplatform/pelican/param" "github.com/pelicanplatform/pelican/token_scopes" ) @@ -244,6 +245,16 @@ func Verify(ctx *gin.Context, authOption AuthOption) (status int, verified bool, } else { return http.StatusOK, true, nil } + case GrafanaTokenIssuer: + fmt.Println("GOT GRAFANA TOKEN") + ok, err := database.VerifyGrafanaApiKey(token) + if err != nil { + fmt.Println("FAILED TO VERIFY GRAFANA TOKEN") + errMsg += fmt.Sprintln("Cannot verify token with Grafana issuer: ", err) + } else if ok { + fmt.Println("GRAFANA TOKEN VERIFIED") + return http.StatusOK, true, nil + } default: log.Error("Invalid/unsupported token issuer") return http.StatusInternalServerError, false, errors.New("Cannot verify token due to bad server configuration. Invalid/unsupported token issuer") From 5ac3527b2261983c195e5c341293d4271cde6b46 Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 17 Jan 2025 19:35:14 +0000 Subject: [PATCH 07/10] Use monitoring scopes --- director/director.go | 4 ++-- web_ui/authorization.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/director/director.go b/director/director.go index 6e7738670..944bf3c02 100644 --- a/director/director.go +++ b/director/director.go @@ -82,7 +82,6 @@ type ( CreateGrafanaTokenReq struct { Name string `json:"name"` CreatedBy string `json:"created_by"` - Scopes string `json:"scopes"` } ) @@ -1394,7 +1393,8 @@ func createGrafanaToken(ctx *gin.Context) { return } - token, err := database.CreateGrafanaApiKey(req.Name, req.CreatedBy, req.Scopes) + scopes := fmt.Sprintf("%s,%s", token_scopes.Monitoring_Query.String(), token_scopes.Monitoring_Scrape.String()) + token, err := database.CreateGrafanaApiKey(req.Name, req.CreatedBy, scopes) if err != nil { log.Warning("Failed to create Grafana API key: ", err) ctx.JSON(status, server_structs.SimpleApiResp{ diff --git a/web_ui/authorization.go b/web_ui/authorization.go index 51e5d6ba0..62284fdcf 100644 --- a/web_ui/authorization.go +++ b/web_ui/authorization.go @@ -64,7 +64,7 @@ func promMetricAuthHandler(ctx *gin.Context) { // 1.director scraper 2.server (self) scraper 3.authenticated web user (via cookie) authOption := token.AuthOption{ Sources: []token.TokenSource{token.Header, token.Cookie}, - Issuers: []token.TokenIssuer{token.FederationIssuer, token.LocalIssuer}, + Issuers: []token.TokenIssuer{token.FederationIssuer, token.LocalIssuer, token.GrafanaTokenIssuer}, Scopes: []token_scopes.TokenScope{token_scopes.Monitoring_Scrape}} status, ok, err := token.Verify(ctx, authOption) From 0bc8348a796a31541dd63ffd01badfd448e9d23a Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 17 Jan 2025 19:40:19 +0000 Subject: [PATCH 08/10] Add delete endpoint --- database/director.go | 8 ++++++++ director/director.go | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/database/director.go b/database/director.go index e65678727..39ee8a65a 100644 --- a/database/director.go +++ b/database/director.go @@ -118,3 +118,11 @@ func CreateGrafanaApiKey(name, createdBy, scopes string) (string, error) { return fmt.Sprintf("%s.%s", id, secret), nil } } + +func DeleteGrafanaApiKey(id string) error { + result := DirectorDB.Delete(&GrafanaApiKey{}, "id = ?", id) + if result.Error != nil { + return errors.Wrap(result.Error, "failed to delete the Grafana API key") + } + return nil +} diff --git a/director/director.go b/director/director.go index 944bf3c02..dfd84502d 100644 --- a/director/director.go +++ b/director/director.go @@ -1407,6 +1407,24 @@ func createGrafanaToken(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"token": token}) } +func deleteGrafanaToken(ctx *gin.Context) { + id := ctx.Param("id") + err := database.DeleteGrafanaApiKey(id) + if err != nil { + log.Warning("Failed to delete Grafana API key: ", err) + ctx.JSON(http.StatusInternalServerError, server_structs.SimpleApiResp{ + Status: server_structs.RespFailed, + Msg: err.Error(), + }) + return + } + + ctx.JSON(http.StatusOK, server_structs.SimpleApiResp{ + Status: server_structs.RespOK, + Msg: "Grafana API key deleted", + }) +} + func getPrefixByPath(ctx *gin.Context) { pathParam := ctx.Param("path") if pathParam == "" || pathParam == "/" { @@ -1559,6 +1577,7 @@ func RegisterDirectorAPI(ctx context.Context, router *gin.RouterGroup) { directorAPIV1.GET("/discoverServers", discoverOriginCache) directorAPIV1.POST("/createGrafanaToken", createGrafanaToken) + directorAPIV1.DELETE("/deleteGrafanaToken/:id", deleteGrafanaToken) } directorAPIV2 := router.Group("/api/v2.0/director") From 34885879e6249134b974db54ce793fb2017d65bb Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 17 Jan 2025 19:45:46 +0000 Subject: [PATCH 09/10] Delete token from cache --- database/director.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/database/director.go b/database/director.go index 39ee8a65a..80b8fb06e 100644 --- a/database/director.go +++ b/database/director.go @@ -60,7 +60,6 @@ func VerifyGrafanaApiKey(apiKey string) (bool, error) { } var token GrafanaApiKey - // result := db.First(&token, "id = ?", id) result := DirectorDB.First(&token, "id = ?", id) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { @@ -124,5 +123,7 @@ func DeleteGrafanaApiKey(id string) error { if result.Error != nil { return errors.Wrap(result.Error, "failed to delete the Grafana API key") } + // delete from cache so that we don't accidentally allow the deleted key to be used + verifiedKeysCache.Delete(id) return nil } From 74642e90d3c33cafb6ba6fa90762234221d23d8c Mon Sep 17 00:00:00 2001 From: Patrick Date: Sat, 18 Jan 2025 04:54:02 +0000 Subject: [PATCH 10/10] Add check for if delete affected rows --- database/director.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/database/director.go b/database/director.go index 80b8fb06e..0a0c1b008 100644 --- a/database/director.go +++ b/database/director.go @@ -123,6 +123,9 @@ func DeleteGrafanaApiKey(id string) error { if result.Error != nil { return errors.Wrap(result.Error, "failed to delete the Grafana API key") } + if result.RowsAffected == 0 { + return errors.New("API key not found") + } // delete from cache so that we don't accidentally allow the deleted key to be used verifiedKeysCache.Delete(id) return nil