diff --git a/.air.toml b/.air.toml index c8fee00..cb6b392 100644 --- a/.air.toml +++ b/.air.toml @@ -7,7 +7,7 @@ tmp_dir = "tmp" [build] args_bin = [] bin = "./tmp/main" - cmd = "go build -o ./tmp/main ./cmd/main.go" + cmd = "go build -o ./tmp/main ./cmd/" delay = 1000 entrypoint = ["./tmp/main"] exclude_dir = ["assets", "tmp", "vendor", "testdata"] @@ -19,7 +19,7 @@ tmp_dir = "tmp" include_dir = [] include_ext = ["go", "tpl", "tmpl", "html", "yaml"] include_file = [] - kill_delay = "0s" + kill_delay = "1s" log = "build-errors.log" poll = false poll_interval = 0 @@ -27,7 +27,7 @@ tmp_dir = "tmp" pre_cmd = [] rerun = false rerun_delay = 500 - send_interrupt = false + send_interrupt = true stop_on_error = true [color] diff --git a/api/dto/secret.go b/api/dto/secret.go new file mode 100644 index 0000000..245e4d9 --- /dev/null +++ b/api/dto/secret.go @@ -0,0 +1,15 @@ +package dto + +type SecretInput struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type Secret struct { + Name string `json:"name"` + Value string `json:"-"` +} + +type SecretFilter struct { + Name string +} diff --git a/api/dto/auth.go b/api/dto/user.go similarity index 74% rename from api/dto/auth.go rename to api/dto/user.go index 1b5c5e6..5b848fb 100644 --- a/api/dto/auth.go +++ b/api/dto/user.go @@ -4,3 +4,8 @@ type LoginInput struct { Email string `json:"email" form:"email" binding:"required,email"` Password string `json:"password" form:"password" binding:"required"` } + +type UserFilter struct { + Email string + IsActive *bool +} diff --git a/api/http/apiutils/user-role.go b/api/http/apiutils/user-role.go index 25df262..b633fe8 100644 --- a/api/http/apiutils/user-role.go +++ b/api/http/apiutils/user-role.go @@ -3,15 +3,13 @@ package apiutils type Role string const ( - RoleAdmin Role = "admin" - RoleEmployee Role = "employee" - RoleUser Role = "user" + RoleAdmin Role = "admin" ) func (r Role) ValidRole() (Role, bool) { valid := false switch r { - case RoleAdmin, RoleEmployee, RoleUser: + case RoleAdmin: valid = true } return r, valid diff --git a/api/http/controllers/admin.go b/api/http/controllers/admin.go deleted file mode 100644 index 36abdd7..0000000 --- a/api/http/controllers/admin.go +++ /dev/null @@ -1,60 +0,0 @@ -package controllers - -import ( - "fmt" - - "github.com/gin-gonic/gin" -) - -func (h *Handler) CreateAPIKey(c *gin.Context) { - - // [TODO] create api key and display it only ONCE (alias -> unique identifier) - - h.response.Created(c, "New API Key Created", nil) -} - -func (h *Handler) ListAllAPIKeys(c *gin.Context) { - - // [TODO] display aliases and other metadata of api keys (NEVER the actual key) - - h.response.Success(c, "All API Keys", nil) -} - -func (h *Handler) DeleteAPIKey(c *gin.Context) { - key := c.Param("key") - - // [TODO] delete an api key through given alias - - h.response.Success(c, fmt.Sprintf("Key Deleted - %v", key), nil) -} - -func (h *Handler) ListAllSecrets(c *gin.Context) { - - // [TODO] list all the secret's name (NEVER display values) - - h.response.Success(c, "All Secrets (without value)", nil) -} - -func (h *Handler) CreateSecret(c *gin.Context) { - - // [TODO] create a new secret - - h.response.Created(c, "New Secret Created", nil) -} - -func (h *Handler) UpdateSecret(c *gin.Context) { - s := c.Param("secret") - - // [TODO] updates a secret - - h.response.Success(c, fmt.Sprintf("Secret Updated - %v", s), nil) - -} - -func (h *Handler) DeleteSecret(c *gin.Context) { - s := c.Param("secret") - - // [TODO] deletes a secret - - h.response.Success(c, fmt.Sprintf("Secret Deleted - %v", s), nil) -} diff --git a/api/http/controllers/api-key.go b/api/http/controllers/api-key.go new file mode 100644 index 0000000..d122e65 --- /dev/null +++ b/api/http/controllers/api-key.go @@ -0,0 +1,29 @@ +package controllers + +import ( + "fmt" + + "github.com/gin-gonic/gin" +) + +func (h *Handler) CreateAPIKey(c *gin.Context) { + + // [TODO] create api key and display it only ONCE (alias -> unique identifier) + + h.response.Created(c, "New API Key Created", nil) +} + +func (h *Handler) ListAllAPIKeys(c *gin.Context) { + + // [TODO] display aliases and other metadata of api keys (NEVER the actual key) + + h.response.Success(c, "All API Keys", nil) +} + +func (h *Handler) DeleteAPIKey(c *gin.Context) { + key := c.Param("key") + + // [TODO] delete an api key through given alias + + h.response.Success(c, fmt.Sprintf("Key Deleted - %v", key), nil) +} diff --git a/api/http/controllers/auth.go b/api/http/controllers/auth.go index d2126d5..5f7622c 100644 --- a/api/http/controllers/auth.go +++ b/api/http/controllers/auth.go @@ -1,14 +1,40 @@ package controllers import ( - "crypto/subtle" + "errors" "fmt" "github.com/gin-gonic/gin" "github.com/kunalvirwal/shogun-cd/api/dto" "github.com/kunalvirwal/shogun-cd/api/http/apiutils" + "github.com/kunalvirwal/shogun-cd/internal/store" + "golang.org/x/crypto/bcrypt" ) +func (h *Handler) Register(c *gin.Context) { + var req dto.LoginInput + + if err := c.ShouldBind(&req); err != nil { + h.response.BadRequest(c, "Bad Input", err) + return + } + ctx := c.Request.Context() + err := h.store.User.Create(ctx, &req) + if err != nil { + switch { + case errors.Is(err, store.ErrEmailTaken): + h.response.BadRequest(c, store.ErrEmailTaken.Error(), err) + return + + default: + h.response.ServerError(c, err) + return + } + } + + h.response.Success(c, fmt.Sprintf("registered new user - %v", req.Email), nil) +} + func (h *Handler) Login(c *gin.Context) { var req dto.LoginInput @@ -17,16 +43,36 @@ func (h *Handler) Login(c *gin.Context) { return } - //[TODO] multi user support through DB lookup - validEmail := subtle.ConstantTimeCompare([]byte(req.Email), []byte(h.config.ApiConfig.Admin.Email)) == 1 - validPassword := subtle.ConstantTimeCompare([]byte(req.Password), []byte(h.config.ApiConfig.Admin.Password)) == 1 - if !validEmail || !validPassword { - h.response.Unauthorized(c, "Invalid Email or Password", nil) + user, err := h.store.User.FindOne(c.Request.Context(), &dto.UserFilter{ + Email: req.Email, + }) + if err != nil { + switch { + case errors.Is(err, store.ErrRecordNotFound): + h.response.Unauthorized(c, "Invalid email or password", err) + return + default: + h.response.ServerError(c, err) + return + } + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { + switch { + case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword): + h.response.Unauthorized(c, "Invalid email or password", err) + return + default: + h.response.ServerError(c, err) + return + } + } + if !*user.IsActive { + h.response.Forbidden(c, "This Account is Suspended", store.ErrAccountSuspended) return } - //[TODO] DB call to get the user's role to be embedded in jwt. - role := apiutils.RoleAdmin + role := apiutils.Role(user.Role) secret := h.config.ApiConfig.JWT.Secret exp := h.config.ApiConfig.JWT.ExpirationHours diff --git a/api/http/controllers/handler.go b/api/http/controllers/handler.go index c85d88b..44f7f73 100644 --- a/api/http/controllers/handler.go +++ b/api/http/controllers/handler.go @@ -3,6 +3,8 @@ package controllers import ( "github.com/kunalvirwal/shogun-cd/api/http/response" "github.com/kunalvirwal/shogun-cd/internal/config" + "github.com/kunalvirwal/shogun-cd/internal/secrets" + "github.com/kunalvirwal/shogun-cd/internal/store" "github.com/kunalvirwal/shogun-cd/internal/utils" "github.com/kunalvirwal/shogun-cd/internal/webhooks" ) @@ -12,13 +14,17 @@ type Handler struct { config *config.Config response response.Responder webhook webhooks.WebhookService + store *store.Store + secret secrets.SecretService } -func NewHandler(l utils.Logger, cfg *config.Config, responder response.Responder, wh webhooks.WebhookService) *Handler { +func NewHandler(l utils.Logger, cfg *config.Config, responder response.Responder, wh webhooks.WebhookService, store *store.Store, secrets secrets.SecretService) *Handler { return &Handler{ logger: l, config: cfg, response: responder, webhook: wh, + store: store, + secret: secrets, } } diff --git a/api/http/controllers/secret.go b/api/http/controllers/secret.go new file mode 100644 index 0000000..9a9e2a9 --- /dev/null +++ b/api/http/controllers/secret.go @@ -0,0 +1,52 @@ +package controllers + +import ( + "fmt" + + "github.com/gin-gonic/gin" + "github.com/kunalvirwal/shogun-cd/api/dto" +) + +func (h *Handler) ListAllSecrets(c *gin.Context) { + var req dto.SecretFilter + + if err := c.ShouldBind(&req); err != nil { + h.response.BadRequest(c, "Bad Input", err) + return + } + + data, err := h.secret.FindMany(c.Request.Context(), &req) + if err != nil { + h.response.ServerError(c, err) + return + } + + h.response.Success(c, "All Secrets (names only)", data) +} + +func (h *Handler) SetSecrets(c *gin.Context) { + var req []dto.SecretInput + + if err := c.ShouldBindJSON(&req); err != nil { + h.response.BadRequest(c, "Bad Input", err) + return + } + + if err := h.secret.SetSecrets(c.Request.Context(), req); err != nil { + h.response.ServerError(c, err) + return + } + + h.response.Created(c, "New Secret(s) Created", nil) +} + +func (h *Handler) DeleteSecret(c *gin.Context) { + s := c.Param("secret") + + if err := h.secret.DeleteSecret(c.Request.Context(), s); err != nil { + h.response.ServerError(c, err) + return + } + + h.response.Success(c, fmt.Sprintf("Secret Deleted - %v", s), nil) +} diff --git a/api/http/controllers/webhooks.go b/api/http/controllers/webhooks.go index db23338..7c96d36 100644 --- a/api/http/controllers/webhooks.go +++ b/api/http/controllers/webhooks.go @@ -2,17 +2,23 @@ package controllers import ( "bytes" + "context" "errors" "fmt" "io" + "time" "github.com/gin-gonic/gin" "github.com/kunalvirwal/shogun-cd/api/dto" "github.com/kunalvirwal/shogun-cd/api/http/apiutils" + "github.com/kunalvirwal/shogun-cd/internal/store" "github.com/kunalvirwal/shogun-cd/internal/webhooks" ) func (h *Handler) HandleWebhook(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + slug := c.Param("slug") bodyBytes, err := io.ReadAll(c.Request.Body) @@ -22,7 +28,7 @@ func (h *Handler) HandleWebhook(c *gin.Context) { } c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) - _, err = h.webhook.Resolve(slug, c.Request.Header, bodyBytes) + _, err = h.webhook.Resolve(ctx, slug, c.Request.Header, bodyBytes) if err != nil { switch { case errors.Is(err, webhooks.ErrBadWebhookHeader): @@ -50,6 +56,9 @@ func (h *Handler) HandleWebhook(c *gin.Context) { } func (h *Handler) CreateWebhook(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + var req dto.HookInput if err := c.ShouldBindJSON(&req); err != nil { @@ -58,7 +67,7 @@ func (h *Handler) CreateWebhook(c *gin.Context) { } req.CreatedBy = apiutils.GetUserEmail(c) - data, err := h.webhook.Create(&req) + data, err := h.webhook.Create(ctx, &req) if err != nil { switch { case errors.Is(err, webhooks.ErrInvalidPipeline): @@ -84,16 +93,14 @@ func (h *Handler) CreateWebhook(c *gin.Context) { } func (h *Handler) DeleteWebhook(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + slug := c.Param("slug") - err := h.webhook.Delete(slug) + err := h.webhook.Delete(ctx, slug) if err != nil { - switch { - case errors.Is(err, webhooks.ErrHookNotFound): - h.response.NotFound(c, err.Error(), err) - default: - h.response.ServerError(c, err) - } + h.response.ServerError(c, err) return } @@ -101,6 +108,9 @@ func (h *Handler) DeleteWebhook(c *gin.Context) { } func (h *Handler) ListWebhooks(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + pipeline := c.Param("pipeline") var filter webhooks.WebhookFilter if pipeline == "" { @@ -109,7 +119,51 @@ func (h *Handler) ListWebhooks(c *gin.Context) { filter = webhooks.FilterByPipeline(pipeline) } - data := h.webhook.Find(filter) + data := h.webhook.Find(ctx, filter) h.response.Success(c, "All Webhooks", data) } + +func (h *Handler) DeactivateWebhook(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + + slug := c.Param("slug") + + err := h.webhook.SetStatus(ctx, slug, false) + if err != nil { + switch { + case errors.Is(err, store.ErrRecordNotFound): + h.response.NotFound(c, "Webhook Not Found", err) + return + + default: + h.response.ServerError(c, err) + return + } + } + + h.response.Success(c, "Hook Deactivated", nil) +} + +func (h *Handler) ActivateWebhook(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + + slug := c.Param("slug") + + err := h.webhook.SetStatus(ctx, slug, true) + if err != nil { + switch { + case errors.Is(err, store.ErrRecordNotFound): + h.response.NotFound(c, "Webhook Not Found", err) + return + + default: + h.response.ServerError(c, err) + return + } + } + + h.response.Success(c, "Hook Activated", nil) +} diff --git a/api/http/routes.go b/api/http/routes.go index f98aabd..13c9a83 100644 --- a/api/http/routes.go +++ b/api/http/routes.go @@ -10,6 +10,7 @@ func initRoutes(router *gin.Engine, m *middlewares.Manager, h *controllers.Handl auth := router.Group("/auth") { auth.POST("/login", h.Login) + auth.POST("/register", h.Register) } webhook := router.Group("/hook") @@ -25,8 +26,9 @@ func initRoutes(router *gin.Engine, m *middlewares.Manager, h *controllers.Handl webhook := admin.Group("/hook") { webhook.POST("", h.CreateWebhook) + webhook.PATCH("/:slug/pause", h.DeactivateWebhook) + webhook.PATCH("/:slug/resume", h.ActivateWebhook) webhook.DELETE("/:slug", h.DeleteWebhook) - // [TODO] deactivate a webhook } // [TODO] api key routes are not implemented yet @@ -36,12 +38,11 @@ func initRoutes(router *gin.Engine, m *middlewares.Manager, h *controllers.Handl key.GET("", h.ListAllAPIKeys) key.DELETE("/:key", h.DeleteAPIKey) } - // [TODO] secrets routes are not implemented yet + secret := admin.Group("/secrets") { secret.GET("", h.ListAllSecrets) - secret.POST("", h.CreateSecret) - secret.PATCH("/:secret", h.UpdateSecret) + secret.POST("", h.SetSecrets) secret.DELETE("/:secret", h.DeleteSecret) } } diff --git a/api/http/server.go b/api/http/server.go index 45b74b0..1f82f68 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -9,24 +9,25 @@ import ( "github.com/kunalvirwal/shogun-cd/api/http/middlewares" "github.com/kunalvirwal/shogun-cd/api/http/response" "github.com/kunalvirwal/shogun-cd/internal/config" + "github.com/kunalvirwal/shogun-cd/internal/secrets" + "github.com/kunalvirwal/shogun-cd/internal/store" "github.com/kunalvirwal/shogun-cd/internal/utils" "github.com/kunalvirwal/shogun-cd/internal/webhooks" ) -func StartAPIServer(logger utils.Logger, cfg *config.Config, w webhooks.WebhookService) { - +func StartAPIServer(logger utils.Logger, cfg *config.Config, w webhooks.WebhookService, store *store.Store, secrets secrets.SecretService) { r := newRouter() responder := response.NewResponder(cfg.Debug) m := middlewares.NewManager(logger, cfg, responder, w) - h := controllers.NewHandler(logger, cfg, responder, w) + h := controllers.NewHandler(logger, cfg, responder, w, store, secrets) initRoutes(r, m, h) if err := r.Run(fmt.Sprintf(":%v", cfg.ApiConfig.Port)); err != nil { logger.LogError(err) } - + // [TODO] implement server shutdown using context } func newRouter() *gin.Engine { diff --git a/cmd/helper.go b/cmd/helper.go index 06ab7d0..98b14a9 100644 --- a/cmd/helper.go +++ b/cmd/helper.go @@ -1 +1,35 @@ package main + +import ( + "context" + "errors" + + "github.com/kunalvirwal/shogun-cd/api/dto" + "github.com/kunalvirwal/shogun-cd/internal/config" + "github.com/kunalvirwal/shogun-cd/internal/store" +) + +// makes the admin user upon startup (ignores if admin exists) +func seedAdmin(s *store.Store, cfg *config.Config) error { + ctx := context.Background() + if err := s.User.Create(ctx, &dto.LoginInput{ + Email: cfg.ApiConfig.Admin.Email, + Password: cfg.ApiConfig.Admin.Password, + }); err != nil { + switch { + case errors.Is(err, store.ErrEmailTaken): + return nil + + default: + return err + } + } + + if err := s.User.MakeAdmin(ctx, &dto.LoginInput{ + Email: cfg.ApiConfig.Admin.Email, + }); err != nil { + return err + } + + return nil +} diff --git a/cmd/main.go b/cmd/main.go index c6a1f9d..b7542d9 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -4,10 +4,12 @@ import ( api "github.com/kunalvirwal/shogun-cd/api/http" "github.com/kunalvirwal/shogun-cd/internal/app" "github.com/kunalvirwal/shogun-cd/internal/config" + "github.com/kunalvirwal/shogun-cd/internal/encryption" "github.com/kunalvirwal/shogun-cd/internal/git" "github.com/kunalvirwal/shogun-cd/internal/orchestrator" "github.com/kunalvirwal/shogun-cd/internal/pipeline" "github.com/kunalvirwal/shogun-cd/internal/secrets" + "github.com/kunalvirwal/shogun-cd/internal/store" "github.com/kunalvirwal/shogun-cd/internal/target" "github.com/kunalvirwal/shogun-cd/internal/utils" "github.com/kunalvirwal/shogun-cd/internal/webhooks" @@ -27,19 +29,36 @@ func initServices() { cfg, err := config.LoadConfigs(logger) if err != nil { logger.LogNewError("Invalid config: Stopping Shogun...") + return } logger.SetLevel(cfg.Debug) + db, err := store.Connect(cfg, logger) + if err != nil { + logger.LogNewError(err.Error()) + return + } + store, err := store.NewStore(cfg, db) + if err != nil { + logger.LogNewError("Unable to Initialise Store : %v", err.Error()) + return + } + encryption, err := encryption.NewService(cfg, logger) + if err != nil { + logger.LogNewError("Unable to Initialise Encryption service : %v", err.Error()) + return + } + + // Initialize Secret Manager + secretService := secrets.NewSecretService(encryption, store, logger) + // Initialize Git service gitService, err := git.NewGitService(logger, cfg) if err != nil { logger.LogNewError("Unable to initialize Git service: Stopping Shogun...") } - // Initialize Secret Manager - secretService := secrets.NewSecretService() - // Initialize Pipeline service pipelineService := pipeline.NewPipelineService(logger, gitService, secretService) @@ -54,9 +73,19 @@ func initServices() { _ = app - webhook := webhooks.NewWebhookService(logger, orch) + if err := seedAdmin(store, cfg); err != nil { + logger.LogNewError("Unable to Seed Admin account : %v", err.Error()) + return + } + + // Initialize webhook service + webhook := webhooks.NewWebhookService(logger, orch, store, encryption) + if err = webhook.Load(); err != nil { + logger.LogNewError("Unable to load Webhooks : %v", err.Error()) + } - go api.StartAPIServer(logger, cfg, webhook) + // Initialize api + go api.StartAPIServer(logger, cfg, webhook, store, secretService) <-make(chan struct{}) // Block forever } diff --git a/go.mod b/go.mod index 160bbc3..5f0a1eb 100644 --- a/go.mod +++ b/go.mod @@ -8,11 +8,15 @@ require ( github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.11.0 github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/uuid v1.6.0 go.yaml.in/yaml/v3 v3.0.4 - golang.org/x/crypto v0.46.0 + golang.org/x/crypto v0.47.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.1 ) require ( + filippo.io/edwards25519 v1.1.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.14.2 // indirect github.com/bytedance/sonic/loader v0.4.0 // indirect @@ -22,8 +26,15 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kr/text v0.2.0 // indirect @@ -39,8 +50,16 @@ require ( github.com/ugorji/go/codec v1.3.1 // indirect go.uber.org/mock v0.6.0 // indirect golang.org/x/arch v0.23.0 // indirect + golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.48.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/tools v0.40.0 // indirect google.golang.org/protobuf v1.36.11 // indirect + gorm.io/datatypes v1.2.4 // indirect + gorm.io/driver/mysql v1.5.6 // indirect + gorm.io/gen v0.3.27 // indirect + gorm.io/hints v1.1.0 // indirect + gorm.io/plugin/dbresolver v1.5.0 // indirect ) diff --git a/go.sum b/go.sum index 591f5d1..378961b 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE= @@ -26,6 +28,10 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE= @@ -35,6 +41,22 @@ github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.2/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -47,6 +69,7 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -67,6 +90,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= @@ -83,17 +107,23 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -102,3 +132,24 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/datatypes v1.2.4 h1:uZmGAcK/QZ0uyfCuVg0VQY1ZmV9h1fuG0tMwKByO1z4= +gorm.io/datatypes v1.2.4/go.mod h1:f4BsLcFAX67szSv8svwLRjklArSHAvHLeE3pXAS5DZI= +gorm.io/driver/mysql v1.4.3/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c= +gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8= +gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.1.6/go.mod h1:W8LmC/6UvVbHKah0+QOC7Ja66EaZXHwUTjgXY8YNWX8= +gorm.io/gen v0.3.27 h1:ziocAFLpE7e0g4Rum69pGfB9S6DweTxK8gAun7cU8as= +gorm.io/gen v0.3.27/go.mod h1:9zquz2xD1f3Eb/eHq4oLn2z6vDVvQlCY5S3uMBLv4EA= +gorm.io/gorm v1.21.15/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0= +gorm.io/gorm v1.22.2/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0= +gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= +gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gorm.io/hints v1.1.0 h1:Lp4z3rxREufSdxn4qmkK3TLDltrM10FLTHiuqwDPvXw= +gorm.io/hints v1.1.0/go.mod h1:lKQ0JjySsPBj3uslFzY3JhYDtqEwzm+G1hv8rWujB6Y= +gorm.io/plugin/dbresolver v1.5.0 h1:XVHLxh775eP0CqVh3vcfJtYqja3uFl5Wr3cKlY8jgDY= +gorm.io/plugin/dbresolver v1.5.0/go.mod h1:l4Cn87EHLEYuqUncpEeTC2tTJQkjngPSD+lo8hIvcT0= diff --git a/internal/config/types.go b/internal/config/types.go index cfc8f6c..a928d10 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -5,6 +5,7 @@ type Config struct { DataDir string `yaml:"data_dir"` GitConfig Git `yaml:"git"` ApiConfig Api `yaml:"api"` + DBConfig DB `yaml:"database"` } type Git struct { @@ -29,3 +30,15 @@ type JWT struct { Secret string `yaml:"secret"` ExpirationHours int `yaml:"expiration_hours"` } + +type DB struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + User string `yaml:"user"` + Password string `yaml:"password"` + DBName string `yaml:"dbname"` + SSLMode string `yaml:"ssl_mode"` + MasterKey string `yaml:"master_key"` + EncryptionSalt string `yaml:"encryption_salt"` + DerivedKey []byte `yaml:"-"` +} diff --git a/internal/encryption/decrypt.go b/internal/encryption/decrypt.go new file mode 100644 index 0000000..3b44ee3 --- /dev/null +++ b/internal/encryption/decrypt.go @@ -0,0 +1,28 @@ +package encryption + +import ( + "encoding/hex" + "fmt" +) + +func (c *service) Decrypt(ciphertext string) (string, error) { + data, err := hex.DecodeString(ciphertext) + if err != nil { + return "", fmt.Errorf("failed to decode hex: %w", err) + } + + // The data MUST contain at least the nonce, if it's smaller than std nonce size then its not a valid encrypted data + nonceSize := c.aead.NonceSize() + if len(data) < nonceSize { + return "", fmt.Errorf("ciphertext too short") + } + + nonce, encryptedData := data[:nonceSize], data[nonceSize:] + + plaintext, err := c.aead.Open(nil, nonce, encryptedData, nil) + if err != nil { + return "", fmt.Errorf("%w : %w", ErrDecryption, err) + } + + return string(plaintext), nil +} diff --git a/internal/encryption/encrypt.go b/internal/encryption/encrypt.go new file mode 100644 index 0000000..acf10dc --- /dev/null +++ b/internal/encryption/encrypt.go @@ -0,0 +1,21 @@ +package encryption + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "io" +) + +func (c *service) Encrypt(plaintext string) (string, error) { + nonce := make([]byte, c.aead.NonceSize()) + + // 12 random bytes from the OS's cryptographically secure random source + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("failed to generate nonce: %w", err) + } + + ciphertext := c.aead.Seal(nonce, nonce, []byte(plaintext), nil) + + return hex.EncodeToString(ciphertext), nil +} diff --git a/internal/encryption/service.go b/internal/encryption/service.go new file mode 100644 index 0000000..4784533 --- /dev/null +++ b/internal/encryption/service.go @@ -0,0 +1,63 @@ +package encryption + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/pbkdf2" + "crypto/sha256" + "errors" + + "github.com/kunalvirwal/shogun-cd/internal/config" + "github.com/kunalvirwal/shogun-cd/internal/utils" +) + +type EncryptionService interface { + Encrypt(plaintext string) (string, error) + Decrypt(ciphertext string) (string, error) +} + +const ( + iterations = 600000 + keyLen = 32 // 32bytes requires for aes256 +) + +var ( + ErrEncryption = errors.New("Error Encrypting Data") + ErrDecryption = errors.New("Error Decrypting Data") +) + +type service struct { + aead cipher.AEAD + logger utils.Logger +} + +func NewService(cfg *config.Config, l utils.Logger) (EncryptionService, error) { + if err := deriveKey(cfg); err != nil { + return nil, err + } + + block, err := aes.NewCipher(cfg.DBConfig.DerivedKey) + if err != nil { + return nil, err + } + + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + return &service{ + aead: aead, + logger: l, + }, nil +} + +func deriveKey(cfg *config.Config) error { + key, err := pbkdf2.Key(sha256.New, cfg.DBConfig.MasterKey, []byte(cfg.DBConfig.EncryptionSalt), iterations, keyLen) + if err != nil { + return err + } + cfg.DBConfig.DerivedKey = key + + return nil +} diff --git a/internal/models/secret.go b/internal/models/secret.go new file mode 100644 index 0000000..81eb046 --- /dev/null +++ b/internal/models/secret.go @@ -0,0 +1,33 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// NOTE : the values of constants must be consistent to the "column" in gorm tags. +const ( + // table name + SecretTableName = "secrets" + // columns + SecretColID = "id" + SecretColName = "name" + SecretColValue = "value" + // automatically managed + SecretColCreatedAt = "created_at" + SecretColUpdatedAt = "updated_at" +) + +type Secret struct { + ID uuid.UUID `gorm:"column:id; type:uuid; default:gen_random_uuid(); primaryKey"` + Name string `gorm:"column:name; uniqueIndex; not null"` + Value string `gorm:"column:value; not null; type:text" json:"-"` + + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (Secret) TableName() string { + return SecretTableName +} diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..15e2adf --- /dev/null +++ b/internal/models/user.go @@ -0,0 +1,37 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// NOTE : the values of constants must be consistent to the "column" in gorm tags. +const ( + // table name + UserTableName = "users" + // columns + UserColID = "id" + UserColEmail = "email" + UserColPassword = "password" + UserColRole = "role" + UserColIsActive = "is_active" + // automatically managed + UserColCreatedAt = "created_at" + UserColUpdatedAt = "updated_at" +) + +type User struct { + ID uuid.UUID `gorm:"column:id; type:uuid; default:gen_random_uuid(); primaryKey" json:"id"` + Email string `gorm:"column:email; uniqueIndex; not null" json:"email"` + Password string `gorm:"column:password; not null" json:"-"` + Role string `gorm:"column:role; not null" json:"role"` + IsActive *bool `gorm:"column:is_active; default:true" json:"is_active"` + + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +func (User) TableName() string { + return UserTableName +} diff --git a/internal/models/webhook.go b/internal/models/webhook.go new file mode 100644 index 0000000..e54b43c --- /dev/null +++ b/internal/models/webhook.go @@ -0,0 +1,37 @@ +package models + +import "time" + +// NOTE : the values of constants must be consistent to the "column" in gorm tags. +const ( + // table name + WebhookTableName = "webhooks" + // columns + WebhookColID = "id" + WebhookColSlug = "slug" + WebhookColSecret = "secret" + WebhookColPipeline = "pipeline" + WebhookColAlias = "alias" + WebhookColCreatedBy = "created_by" + WebhookColIsActive = "is_active" + // automatically managed + WebhookColCreatedAt = "created_at" + WebhookColUpdatedAt = "updated_at" +) + +type Webhook struct { + ID string `gorm:"column:id; primaryKey; type:uuid; default:gen_random_uuid()"` + Slug string `gorm:"column:slug; uniqueIndex; not null; type:varchar(50)"` + Secret string `gorm:"column:secret; not null" json:"-"` + Pipeline string `gorm:"column:pipeline; index; not null"` + Alias string `gorm:"column:alias; type:varchar(100)"` + CreatedBy string `gorm:"column:created_by; type:varchar(100)"` + IsActive *bool `gorm:"column:is_active; default:true"` + + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (Webhook) TableName() string { + return WebhookTableName +} diff --git a/internal/pipeline/steps/exec.go b/internal/pipeline/steps/exec.go index 58b6915..8caccd4 100644 --- a/internal/pipeline/steps/exec.go +++ b/internal/pipeline/steps/exec.go @@ -45,7 +45,7 @@ func (es *ExecStep) Execute(ctx context.Context, deps *StepDeps) (string, error) client, err := deps.SSHManager.GetClient(es.Target) // No client or dead client, create a new one if err != nil { - sshkey, err := deps.SecretService.FetchSecret(targetInstance.Spec.AccessSecret) + sshkey, err := deps.SecretService.FetchSecret(ctx, targetInstance.Spec.AccessSecret) if err != nil { return deps.Logger.LogShogunError(output, "Failed to fetch secret for target %s: %v", es.Target, err) } @@ -62,7 +62,10 @@ func (es *ExecStep) Execute(ctx context.Context, deps *StepDeps) (string, error) for i, cmd := range es.Commands { cmd = InterpolateVariables(cmd, deps.HookValues) - cmd = deps.SecretService.ResolveSecrets(cmd) + cmd, err = deps.SecretService.ResolveSecrets(ctx, cmd) + if err != nil { + return deps.Logger.LogShogunError(output, "Failed to resolve secret(s) in the string %s: %v", cmd, err) + } script.WriteString("echo \"" + prompt + " " + fmt.Sprint(i) + "\"") script.WriteByte('\n') diff --git a/internal/secrets/inmemory.go b/internal/secrets/inmemory.go new file mode 100644 index 0000000..1e5fe91 --- /dev/null +++ b/internal/secrets/inmemory.go @@ -0,0 +1,70 @@ +package secrets + +import ( + "fmt" + "regexp" + "sync" +) + +type InMemorySecretService interface { + // Fetches the secret value if present othervise error + FetchSecret(name string) (string, error) + // Adds a new secret with the given name and value, should take care of encryption internally + AddSecret(name string, value string) + // Deletes the secret with the given name + DeleteSecret(name string) + // [TODO] : Currently add is used to update as well + // Discuss about having a separate update method + // Resolve secret value in a string with {{NAME}} patterns + ResolveSecrets(input string) string +} + +// [TODO]: This is a temporary in-memory implementation for testing, +// we will use postgress to store encrypted values or use services like HashiCorp Vault, AWS Secrets Manager, etc. +type InMemoryService struct { + SecretMap map[string]string + mu sync.RWMutex +} + +func NewInMemoryService() InMemorySecretService { + s := InMemoryService{ + SecretMap: make(map[string]string), + } + return &s +} + +func (s *InMemoryService) FetchSecret(name string) (string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + value, exists := s.SecretMap[name] + if !exists { + return "", fmt.Errorf("Secret value not defined") + } + return value, nil +} + +func (s *InMemoryService) AddSecret(name string, value string) { + s.mu.Lock() + defer s.mu.Unlock() + s.SecretMap[name] = value +} + +func (s *InMemoryService) DeleteSecret(name string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.SecretMap, name) +} + +func (s *InMemoryService) ResolveSecrets(input string) string { + var secretRegex = regexp.MustCompile(`\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}`) + return secretRegex.ReplaceAllStringFunc(input, func(match string) string { + s.mu.Lock() + defer s.mu.Unlock() + secretName := secretRegex.FindStringSubmatch(match)[1] + val, exists := s.SecretMap[secretName] + if exists { + return val + } + return "" + }) +} diff --git a/internal/secrets/service.go b/internal/secrets/service.go index 19b0cf1..ea5f5ec 100644 --- a/internal/secrets/service.go +++ b/internal/secrets/service.go @@ -1,70 +1,99 @@ package secrets import ( + "context" "fmt" "regexp" - "sync" + "strings" + + "github.com/kunalvirwal/shogun-cd/api/dto" + "github.com/kunalvirwal/shogun-cd/internal/encryption" + "github.com/kunalvirwal/shogun-cd/internal/models" + "github.com/kunalvirwal/shogun-cd/internal/store" + "github.com/kunalvirwal/shogun-cd/internal/utils" ) type SecretService interface { - // Fetches the secret value if present othervise error - FetchSecret(name string) (string, error) - // Adds a new secret with the given name and value, should take care of encryption internally - AddSecret(name string, value string) - // Deletes the secret with the given name - DeleteSecret(name string) - // [TODO] : Currently add is used to update as well - // Discuss about having a separate update method - // Resolve secret value in a string with {{NAME}} patterns - ResolveSecrets(input string) string + SetSecrets(ctx context.Context, in []dto.SecretInput) error + FindMany(ctx context.Context, filter *dto.SecretFilter) ([]*dto.Secret, error) + FetchSecret(ctx context.Context, name string) (string, error) + ResolveSecrets(ctx context.Context, name string) (string, error) // provides decrypted "value" of the secret + DeleteSecret(ctx context.Context, name string) error } -// [TODO]: This is a temporary in-memory implementation for testing, -// we will use postgress to store encrypted values or use services like HashiCorp Vault, AWS Secrets Manager, etc. -type Service struct { - SecretMap map[string]string - mu sync.RWMutex +type service struct { + encryption encryption.EncryptionService + store store.SecretStore + logger utils.Logger } -func NewSecretService() SecretService { - s := Service{ - SecretMap: make(map[string]string), +func NewSecretService(e encryption.EncryptionService, s *store.Store, l utils.Logger) SecretService { + return &service{ + encryption: e, + store: s.Secret, + logger: l, } - return &s } -func (s *Service) FetchSecret(name string) (string, error) { - s.mu.RLock() - defer s.mu.RUnlock() - value, exists := s.SecretMap[name] - if !exists { - return "", fmt.Errorf("Secret value not defined") +// updates the value of a secret, creates new if no such secret exists +func (s *service) SetSecrets(ctx context.Context, in []dto.SecretInput) error { + secrets := make([]models.Secret, len(in)) + + for k, v := range in { + encrypted, err := s.encryption.Encrypt(v.Value) + if err != nil { + return err + } + secrets[k] = models.Secret{ + Name: v.Name, + Value: encrypted, + } } - return value, nil -} -func (s *Service) AddSecret(name string, value string) { - s.mu.Lock() - defer s.mu.Unlock() - s.SecretMap[name] = value + return s.store.Upsert(ctx, secrets) } -func (s *Service) DeleteSecret(name string) { - s.mu.Lock() - defer s.mu.Unlock() - delete(s.SecretMap, name) +func (s *service) FetchSecret(ctx context.Context, name string) (string, error) { + secret, err := s.store.GetSecret(ctx, name) + if err != nil { + return "", err + } + + decrypted, err := s.encryption.Decrypt(secret) + if err != nil { + return "", fmt.Errorf("%w : %w", encryption.ErrDecryption, err) + } + + return decrypted, nil } -func (s *Service) ResolveSecrets(input string) string { +// takes a string as input and replaces the {{SECRET_NAME}} with its decrypted value as per the database. +func (s *service) ResolveSecrets(ctx context.Context, input string) (string, error) { var secretRegex = regexp.MustCompile(`\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}`) - return secretRegex.ReplaceAllStringFunc(input, func(match string) string { - s.mu.Lock() - defer s.mu.Unlock() - secretName := secretRegex.FindStringSubmatch(match)[1] - val, exists := s.SecretMap[secretName] - if exists { - return val + + matches := secretRegex.FindAllStringSubmatch(input, -1) + + output := input + + for _, match := range matches { + secretTag := match[0] + secretName := match[1] + + decrypted, err := s.FetchSecret(ctx, secretName) + if err != nil { + return "", err } - return "" - }) + + output = strings.ReplaceAll(output, secretTag, decrypted) + } + + return output, nil +} + +func (s *service) FindMany(ctx context.Context, filter *dto.SecretFilter) ([]*dto.Secret, error) { + return s.store.FindMany(ctx, filter) +} + +func (s *service) DeleteSecret(ctx context.Context, name string) error { + return s.store.Delete(ctx, name) } diff --git a/internal/store/db.go b/internal/store/db.go new file mode 100644 index 0000000..3ebc3e5 --- /dev/null +++ b/internal/store/db.go @@ -0,0 +1,49 @@ +package store + +import ( + "fmt" + "time" + + "github.com/kunalvirwal/shogun-cd/internal/config" + "github.com/kunalvirwal/shogun-cd/internal/models" + "github.com/kunalvirwal/shogun-cd/internal/utils" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func Connect(cfg *config.Config, logger utils.Logger) (*gorm.DB, error) { + dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%d sslmode=%s", + cfg.DBConfig.Host, + cfg.DBConfig.User, + cfg.DBConfig.Password, + cfg.DBConfig.DBName, + cfg.DBConfig.Port, + cfg.DBConfig.SSLMode, + ) + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + TranslateError: true, + }) + if err != nil { + return nil, fmt.Errorf("connection failed : %w", err) + } + + sqlDB, err := db.DB() + if err != nil { + return nil, err + } + sqlDB.SetMaxOpenConns(25) + sqlDB.SetMaxIdleConns(25) + sqlDB.SetConnMaxLifetime(5 * time.Minute) + + err = db.AutoMigrate( + &models.User{}, + &models.Webhook{}, + &models.Secret{}, + ) + if err != nil { + return nil, fmt.Errorf("migration failed : %w", err) + } + + return db, nil +} diff --git a/internal/store/secret.go b/internal/store/secret.go new file mode 100644 index 0000000..5a2ed19 --- /dev/null +++ b/internal/store/secret.go @@ -0,0 +1,133 @@ +package store + +import ( + "context" + + "github.com/kunalvirwal/shogun-cd/api/dto" + "github.com/kunalvirwal/shogun-cd/internal/models" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type SecretStore interface { + Create(ctx context.Context, secrets []models.Secret) error + Upsert(ctx context.Context, secret []models.Secret) error + Update(ctx context.Context, secret *models.Secret) error + FindMany(ctx context.Context, filter *dto.SecretFilter) ([]*dto.Secret, error) + GetSecret(ctx context.Context, name string) (string, error) + Delete(ctx context.Context, name string) error +} + +type secretStore struct { + db *gorm.DB +} + +func newSecretStore(db *gorm.DB) SecretStore { + return &secretStore{ + db: db, + } +} + +func (s *secretStore) Create(ctx context.Context, secrets []models.Secret) error { + if ctx.Err() != nil { + return ctx.Err() + } + + return gorm.G[[]models.Secret](s.db). + Table(models.SecretTableName). + Create(ctx, &secrets) +} + +func (s *secretStore) Upsert(ctx context.Context, secret []models.Secret) error { + if ctx.Err() != nil { + return ctx.Err() + } + + return gorm.G[[]models.Secret](s.db, clause.OnConflict{ + Columns: []clause.Column{{ + Name: models.SecretColName, + }}, + DoUpdates: clause.AssignmentColumns([]string{ + models.SecretColValue, + models.SecretColUpdatedAt, + }), + }). + Table(models.SecretTableName). + Create(ctx, &secret) +} + +func (s *secretStore) Update(ctx context.Context, secret *models.Secret) error { + if ctx.Err() != nil { + return ctx.Err() + } + r, err := gorm.G[dto.SecretInput](s.db). + Table(models.SecretTableName). + Where(&models.Secret{ + Name: secret.Name, + }). + Update(ctx, models.SecretColValue, secret.Value) + + if r == 0 { + return ErrRecordNotFound + } + + if err != nil { + return err + } + + return nil +} + +func (s *secretStore) FindMany(ctx context.Context, filter *dto.SecretFilter) ([]*dto.Secret, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + + secrets, err := gorm.G[*dto.Secret](s.db). + Table(models.SecretTableName). + Select(models.SecretColName). + Where(filter). + Find(ctx) + if err != nil { + return nil, err + } + + return secrets, nil +} + +// returns the value of secret as in the db +func (s *secretStore) GetSecret(ctx context.Context, name string) (string, error) { + if ctx.Err() != nil { + return "", ctx.Err() + } + + secret, err := gorm.G[*models.Secret](s.db). + Table(models.SecretTableName). + Select(models.SecretColValue). + Where(&models.Secret{ + Name: name, + }). + First(ctx) + if err != nil { + return "", err + } + + return secret.Value, nil +} + +func (s *secretStore) Delete(ctx context.Context, name string) error { + if ctx.Err() != nil { + return ctx.Err() + } + + _, err := gorm.G[models.Secret](s.db). + Where(&models.Secret{ + Name: name, + }). + Delete(ctx) + if err != nil { + return err + } + + return nil +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..cb9442d --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,26 @@ +package store + +import ( + "errors" + + "github.com/kunalvirwal/shogun-cd/internal/config" + "gorm.io/gorm" +) + +type Store struct { + User UserStore + Webhook WebhookStore + Secret SecretStore +} + +var ( + ErrRecordNotFound = errors.New("Record Not Found") +) + +func NewStore(cfg *config.Config, db *gorm.DB) (*Store, error) { + return &Store{ + User: newUserStore(db), + Webhook: newWebhookStore(db), + Secret: newSecretStore(db), + }, nil +} diff --git a/internal/store/user.go b/internal/store/user.go new file mode 100644 index 0000000..c79e8aa --- /dev/null +++ b/internal/store/user.go @@ -0,0 +1,101 @@ +package store + +import ( + "context" + "errors" + + "github.com/kunalvirwal/shogun-cd/api/dto" + "github.com/kunalvirwal/shogun-cd/api/http/apiutils" + "github.com/kunalvirwal/shogun-cd/internal/models" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type UserStore interface { + Create(ctx context.Context, in *dto.LoginInput) error + MakeAdmin(ctx context.Context, in *dto.LoginInput) error + FindOne(ctx context.Context, filter *dto.UserFilter) (*models.User, error) +} + +var ( + ErrEmailTaken = errors.New("User with this email already exists") + ErrAccountSuspended = errors.New("Account Suspended") +) + +type userStore struct { + db *gorm.DB +} + +func newUserStore(db *gorm.DB) UserStore { + return &userStore{ + db: db, + } +} + +// create a new user with the role 'admin' +func (u *userStore) Create(ctx context.Context, in *dto.LoginInput) error { + if ctx.Err() != nil { + return ctx.Err() + } + pwdHash, err := bcrypt.GenerateFromPassword([]byte(in.Password), 10) + if err != nil { + return err + } + // role is set to `user` by default + err = gorm.G[models.User](u.db). + Create(ctx, &models.User{ + Email: in.Email, + Password: string(pwdHash), + }) + if err != nil { + switch { + case errors.Is(err, gorm.ErrDuplicatedKey): + return ErrEmailTaken + + default: + return err + } + } + return nil +} + +// escelates user's role to 'admin' +func (u *userStore) MakeAdmin(ctx context.Context, in *dto.LoginInput) error { + _, err := gorm.G[models.User](u.db). + Where(&models.User{ + Email: in.Email, + }). + Updates(ctx, models.User{ + Role: string(apiutils.RoleAdmin), + }) + + if err != nil { + return err + } + return nil +} + +func (u *userStore) FindOne(ctx context.Context, filter *dto.UserFilter) (*models.User, error) { + user, err := gorm.G[*models.User](u.db). + Where(filter). + First(ctx) + if err != nil { + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + return nil, ErrRecordNotFound + default: + return nil, err + } + } + return user, nil +} + +func (u *userStore) FindMany(ctx context.Context, filter *dto.UserFilter) ([]*models.User, error) { + users, err := gorm.G[*models.User](u.db). + Where(filter). + Find(ctx) + if err != nil { + return nil, err + } + return users, nil +} diff --git a/internal/store/webhook.go b/internal/store/webhook.go new file mode 100644 index 0000000..8ec6d7e --- /dev/null +++ b/internal/store/webhook.go @@ -0,0 +1,101 @@ +package store + +import ( + "context" + "errors" + + "github.com/kunalvirwal/shogun-cd/internal/models" + + "gorm.io/gorm" +) + +type WebhookStore interface { + Create(ctx context.Context, in *models.Webhook) error + Delete(ctx context.Context, slug string) error + SetStatus(ctx context.Context, slug string, isActive bool) error + Load(ctx context.Context) ([]*models.Webhook, error) +} + +var ( + ErrSlugCollision = errors.New("Slug Collision") +) + +type webhookStore struct { + db *gorm.DB +} + +func newWebhookStore(db *gorm.DB) WebhookStore { + return &webhookStore{ + db: db, + } +} + +func (w *webhookStore) Create(ctx context.Context, in *models.Webhook) error { + if ctx.Err() != nil { + return ctx.Err() + } + + if err := gorm.G[models.Webhook](w.db). + Create(ctx, in); err != nil { + switch { + case errors.Is(err, gorm.ErrDuplicatedKey): + return ErrSlugCollision + + default: + return err + } + } + + return nil +} + +func (w *webhookStore) Delete(ctx context.Context, slug string) error { + if ctx.Err() != nil { + return ctx.Err() + } + + if _, err := gorm.G[models.Webhook](w.db). + Where(&models.Webhook{ + Slug: slug, + }). + Delete(ctx); err != nil { + return err + } + + return nil +} + +func (w *webhookStore) SetStatus(ctx context.Context, slug string, isActive bool) error { + if ctx.Err() != nil { + return ctx.Err() + } + + rows, err := gorm.G[models.Webhook](w.db). + Where(&models.Webhook{ + Slug: slug, + }). + Updates(ctx, models.Webhook{ + IsActive: &isActive, + }) + if err != nil { + return err + } + if rows == 0 { + return ErrRecordNotFound + } + + return nil +} + +func (w *webhookStore) Load(ctx context.Context) ([]*models.Webhook, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + + hooks, err := gorm.G[*models.Webhook](w.db).Find(ctx) + if err != nil { + return nil, err + } + + return hooks, nil +} diff --git a/internal/webhooks/create.go b/internal/webhooks/create.go index a60fad0..597e9d8 100644 --- a/internal/webhooks/create.go +++ b/internal/webhooks/create.go @@ -1,11 +1,14 @@ package webhooks import ( + "context" "crypto/rand" "encoding/hex" - "time" + "errors" "github.com/kunalvirwal/shogun-cd/api/dto" + "github.com/kunalvirwal/shogun-cd/internal/models" + "github.com/kunalvirwal/shogun-cd/internal/store" ) const ( @@ -14,7 +17,7 @@ const ( SecretEntropy = 32 ) -func (s *Service) Create(input *dto.HookInput) (*Webhook, error) { +func (s *Service) Create(ctx context.Context, input *dto.HookInput) (*Webhook, error) { var secret string var err error @@ -27,30 +30,51 @@ func (s *Service) Create(input *dto.HookInput) (*Webhook, error) { return nil, err } - hook := &Webhook{ - Slug: "", - Secret: secret, + //[TODO] enforce input field lengths for webhook using validator tags + + encrypted, err := s.encryption.Encrypt(secret) + if err != nil { + return nil, err + } + + dbHook := &models.Webhook{ + Secret: encrypted, Pipeline: input.Pipeline, Alias: input.Alias, CreatedBy: input.CreatedBy, - IsActive: true, - CreatedAt: time.Now(), + } + hook := &Webhook{ + Secret: secret, + Pipeline: dbHook.Pipeline, + Alias: dbHook.Alias, + CreatedBy: dbHook.CreatedBy, } for { temp, _ := generateSecretHex(SlugEntropy) newSlug := prefix + temp - s.mu.Lock() - if _, exists := s.registry[newSlug]; exists { - s.mu.Unlock() - s.logger.LogInfo("Slug collision, Retrying") - continue + dbHook.Slug = newSlug + err := s.store.Create(ctx, dbHook) + if err != nil { + switch { + case errors.Is(err, store.ErrSlugCollision): + s.logger.LogError(store.ErrSlugCollision) + continue + default: + return nil, err + } } - hook.Slug = newSlug - s.registry[newSlug] = hook + hook.ID = dbHook.ID + hook.Slug = dbHook.Slug + hook.CreatedAt = dbHook.CreatedAt + hook.IsActive = *dbHook.IsActive + + s.mu.Lock() + s.registry[newSlug] = hook s.mu.Unlock() + return hook, nil } } diff --git a/internal/webhooks/delete.go b/internal/webhooks/delete.go index e620800..d54b6a1 100644 --- a/internal/webhooks/delete.go +++ b/internal/webhooks/delete.go @@ -1,17 +1,16 @@ package webhooks -func (s *Service) Delete(slug string) error { +import "context" - s.mu.Lock() - defer s.mu.Unlock() +func (s *Service) Delete(ctx context.Context, slug string) error { - if _, exists := s.registry[slug]; !exists { - return ErrHookNotFound + if err := s.store.Delete(ctx, slug); err != nil { + return err } + s.mu.Lock() delete(s.registry, slug) - - // [TODO] delete from DB + s.mu.Unlock() return nil } diff --git a/internal/webhooks/find.go b/internal/webhooks/find.go index b8d7f77..5bd18c9 100644 --- a/internal/webhooks/find.go +++ b/internal/webhooks/find.go @@ -1,8 +1,10 @@ package webhooks +import "context" + type WebhookFilter func(*Webhook) bool -func (s *Service) Find(filter WebhookFilter) []*Webhook { +func (s *Service) Find(ctx context.Context, filter WebhookFilter) []*Webhook { s.mu.RLock() defer s.mu.RUnlock() diff --git a/internal/webhooks/load.go b/internal/webhooks/load.go new file mode 100644 index 0000000..9cfa89e --- /dev/null +++ b/internal/webhooks/load.go @@ -0,0 +1,65 @@ +package webhooks + +import ( + "context" + "sync" + + "github.com/kunalvirwal/shogun-cd/internal/models" +) + +// sync the local registry with database +// current implementation of this function is meant to be run during startup +// re-loading with this logic could lead to toctou errors with possible hook deletion +func (s *Service) Load() error { + ctx := context.Background() + + dbHooks, err := s.store.Load(ctx) + if err != nil { + return err + } + + newRegistry := make(map[string]*Webhook, len(dbHooks)) + var mu sync.Mutex + var wg sync.WaitGroup + + for _, dbHook := range dbHooks { + wg.Add(1) + + isActive := false // Default safe value if db has NULL for the boolean pointer + if dbHook.IsActive != nil { + isActive = *dbHook.IsActive + } + + go func(hook *models.Webhook, isActive bool) { + defer wg.Done() + decrypted, err := s.encryption.Decrypt(hook.Secret) + if err != nil { + s.logger.LogNewError("Failed to load webhook '%s': %v", hook.Slug, err) + return + } + + wh := &Webhook{ + ID: hook.ID, + Slug: hook.Slug, + Secret: decrypted, + Pipeline: hook.Pipeline, + Alias: hook.Alias, + CreatedBy: hook.CreatedBy, + CreatedAt: hook.CreatedAt, + IsActive: isActive, + } + mu.Lock() + newRegistry[wh.Slug] = wh + mu.Unlock() + }(dbHook, isActive) + } + wg.Wait() + + s.mu.Lock() + s.registry = newRegistry + s.mu.Unlock() + + s.logger.LogInfo("%v/%v Webhooks loaded from Database.", len(newRegistry), len(dbHooks)) + + return nil +} diff --git a/internal/webhooks/resolve.go b/internal/webhooks/resolve.go index 1088ec3..64fd204 100644 --- a/internal/webhooks/resolve.go +++ b/internal/webhooks/resolve.go @@ -1,6 +1,7 @@ package webhooks import ( + "context" "encoding/json" "fmt" "net/http" @@ -9,7 +10,7 @@ import ( ) // fetches webhook info, verifies payload, and runs the pipeline -func (s *Service) Resolve(slug string, headers http.Header, body []byte) (*Webhook, error) { +func (s *Service) Resolve(ctx context.Context, slug string, headers http.Header, body []byte) (*Webhook, error) { s.mu.RLock() hook, exists := s.registry[slug] @@ -23,7 +24,7 @@ func (s *Service) Resolve(slug string, headers http.Header, body []byte) (*Webho return nil, ErrHookInactive } - ok, err := hook.VerifyPayload(headers, body) + ok, err := hook.VerifyPayload(ctx, headers, body) if err != nil { return nil, err } diff --git a/internal/webhooks/service.go b/internal/webhooks/service.go index 9726434..fa5751b 100644 --- a/internal/webhooks/service.go +++ b/internal/webhooks/service.go @@ -1,21 +1,27 @@ package webhooks import ( + "context" "errors" "net/http" "sync" "time" "github.com/kunalvirwal/shogun-cd/api/dto" + "github.com/kunalvirwal/shogun-cd/internal/encryption" "github.com/kunalvirwal/shogun-cd/internal/orchestrator" + "github.com/kunalvirwal/shogun-cd/internal/store" + "github.com/kunalvirwal/shogun-cd/internal/utils" ) type WebhookService interface { - Create(input *dto.HookInput) (*Webhook, error) - Resolve(slug string, headers http.Header, body []byte) (*Webhook, error) - Delete(slug string) error - Find(filter WebhookFilter) []*Webhook + Create(ctx context.Context, input *dto.HookInput) (*Webhook, error) + Resolve(ctx context.Context, slug string, headers http.Header, body []byte) (*Webhook, error) + Delete(ctx context.Context, slug string) error + Find(ctx context.Context, filter WebhookFilter) []*Webhook + SetStatus(ctx context.Context, slug string, isActive bool) error + Load() error } var ( @@ -42,16 +48,19 @@ type Webhook struct { type Service struct { registry map[string]*Webhook orchestrator orchestrator.Orchestrator + store store.WebhookStore + encryption encryption.EncryptionService logger utils.Logger mu sync.RWMutex } -func NewWebhookService(l utils.Logger, o orchestrator.Orchestrator) WebhookService { - // [TODO] Bulk read and sync the registry with DB +func NewWebhookService(l utils.Logger, o orchestrator.Orchestrator, s *store.Store, e encryption.EncryptionService) WebhookService { svc := &Service{ registry: make(map[string]*Webhook), orchestrator: o, + store: s.Webhook, logger: l, + encryption: e, } return svc } diff --git a/internal/webhooks/status.go b/internal/webhooks/status.go new file mode 100644 index 0000000..87fc734 --- /dev/null +++ b/internal/webhooks/status.go @@ -0,0 +1,18 @@ +package webhooks + +import "context" + +func (s *Service) SetStatus(ctx context.Context, slug string, isActive bool) error { + + if err := s.store.SetStatus(ctx, slug, isActive); err != nil { + return err + } + + s.mu.Lock() + if v, ok := s.registry[slug]; ok { + v.IsActive = isActive + } + s.mu.Unlock() + + return nil +} diff --git a/internal/webhooks/verify.go b/internal/webhooks/verify.go index cb425e7..e7a7319 100644 --- a/internal/webhooks/verify.go +++ b/internal/webhooks/verify.go @@ -1,6 +1,7 @@ package webhooks import ( + "context" "crypto/hmac" "crypto/sha256" "crypto/subtle" @@ -14,7 +15,7 @@ const ( ShogunHMACHeader = "X-Shogun-HMAC" // contains the HMAC signature (for cli requests) ) -func (w *Webhook) VerifyPayload(headers http.Header, body []byte) (bool, error) { +func (w *Webhook) VerifyPayload(ctx context.Context, headers http.Header, body []byte) (bool, error) { if token := headers.Get(ShogunSecretHeader); token != "" { return subtle.ConstantTimeCompare([]byte(token), []byte(w.Secret)) == 1, nil diff --git a/makefile b/makefile new file mode 100644 index 0000000..1b38a68 --- /dev/null +++ b/makefile @@ -0,0 +1,27 @@ +DB_CONTAINER_NAME=shogun_db +DB_PORT=5432 +DB_USER=shogun +DB_PASSWORD=pwd +DB_NAME=shogun_db + +.PHONY: db-run db-down db-logs + +db-run: + @echo "Starting database container '$(DB_CONTAINER_NAME)' on port $(DB_PORT)..." + docker run --name $(DB_CONTAINER_NAME) \ + --rm \ + -v $(PWD)/.data/db_volume:/var/lib/postgresql/ \ + -p $(DB_PORT):5432 \ + -e POSTGRES_USER=$(DB_USER) \ + -e POSTGRES_PASSWORD=$(DB_PASSWORD) \ + -e POSTGRES_DB=$(DB_NAME) \ + -d postgres:alpine + @echo "Database is ready!" + +db-down: + @echo "Stopping database..." + docker stop $(DB_CONTAINER_NAME) + @echo "Database stopped." + +db-logs: + docker logs -f $(DB_CONTAINER_NAME) \ No newline at end of file diff --git a/sample.config.yaml b/sample.config.yaml index b6086cf..b78c5f1 100644 --- a/sample.config.yaml +++ b/sample.config.yaml @@ -13,3 +13,12 @@ api: jwt: secret: "jwt-secret" expiration_hours: 1 +database: + host: "hogwarts" + port: 5432 + user: "shogun" + password: "pwd" + dbname: "shogun_db" + ssl_mode: "disable" + master_key: "qwertyuiop" + encryption_salt: "salty-salt"