From 2357e7869bc538f38a617db65060a5f9251a90dd Mon Sep 17 00:00:00 2001 From: karma Date: Tue, 20 Jan 2026 18:31:07 +0530 Subject: [PATCH 1/6] [feat] implement webhook activate and deactivate Signed-off-by: karma --- api/http/controllers/webhooks.go | 31 +++++++++++++++++++++++++------ api/http/routes.go | 3 ++- internal/webhooks/delete.go | 11 +++-------- internal/webhooks/service.go | 1 + internal/webhooks/status.go | 14 ++++++++++++++ 5 files changed, 45 insertions(+), 15 deletions(-) create mode 100644 internal/webhooks/status.go diff --git a/api/http/controllers/webhooks.go b/api/http/controllers/webhooks.go index db23338..db5ba8b 100644 --- a/api/http/controllers/webhooks.go +++ b/api/http/controllers/webhooks.go @@ -88,12 +88,7 @@ func (h *Handler) DeleteWebhook(c *gin.Context) { err := h.webhook.Delete(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 } @@ -113,3 +108,27 @@ func (h *Handler) ListWebhooks(c *gin.Context) { h.response.Success(c, "All Webhooks", data) } + +func (h *Handler) DeactivateWebhook(c *gin.Context) { + slug := c.Param("slug") + + err := h.webhook.SetStatus(slug, false) + if err != nil { + h.response.ServerError(c, err) + return + } + + h.response.Success(c, "Hook Deactivated", nil) +} + +func (h *Handler) ActivateWebhook(c *gin.Context) { + slug := c.Param("slug") + + err := h.webhook.SetStatus(slug, true) + if err != nil { + 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..b26edc4 100644 --- a/api/http/routes.go +++ b/api/http/routes.go @@ -25,8 +25,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 diff --git a/internal/webhooks/delete.go b/internal/webhooks/delete.go index e620800..eaeda68 100644 --- a/internal/webhooks/delete.go +++ b/internal/webhooks/delete.go @@ -2,16 +2,11 @@ package webhooks func (s *Service) Delete(slug string) error { - s.mu.Lock() - defer s.mu.Unlock() - - if _, exists := s.registry[slug]; !exists { - return ErrHookNotFound - } + // [TODO] delete from DB + s.mu.Lock() delete(s.registry, slug) - - // [TODO] delete from DB + s.mu.Unlock() return nil } diff --git a/internal/webhooks/service.go b/internal/webhooks/service.go index 9726434..1f6371a 100644 --- a/internal/webhooks/service.go +++ b/internal/webhooks/service.go @@ -16,6 +16,7 @@ type WebhookService interface { Resolve(slug string, headers http.Header, body []byte) (*Webhook, error) Delete(slug string) error Find(filter WebhookFilter) []*Webhook + SetStatus(slug string, isActive bool) error } var ( diff --git a/internal/webhooks/status.go b/internal/webhooks/status.go new file mode 100644 index 0000000..c8d2bf4 --- /dev/null +++ b/internal/webhooks/status.go @@ -0,0 +1,14 @@ +package webhooks + +func (s *Service) SetStatus(slug string, isActive bool) error { + + // [TODO] db call to change webhook status + + s.mu.Lock() + if v, ok := s.registry[slug]; ok { + v.IsActive = isActive + } + s.mu.Unlock() + + return nil +} From 2fe23afdef831b08b578691eaab84049e3330dd6 Mon Sep 17 00:00:00 2001 From: karma Date: Tue, 27 Jan 2026 21:06:57 +0530 Subject: [PATCH 2/6] [feat] user creation and auth with db service Signed-off-by: karma --- .air.toml | 2 +- api/dto/{auth.go => user.go} | 5 ++ api/http/apiutils/user-role.go | 7 +-- api/http/controllers/auth.go | 62 +++++++++++++++++--- api/http/controllers/handler.go | 5 +- api/http/routes.go | 1 + api/http/server.go | 5 +- cmd/helper.go | 34 +++++++++++ cmd/main.go | 16 ++++- go.mod | 16 ++++- go.sum | 37 +++++++++--- internal/config/types.go | 10 ++++ internal/models/user.go | 28 +++++++++ internal/store/db.go | 47 +++++++++++++++ internal/store/store.go | 15 +++++ internal/store/user.go | 101 ++++++++++++++++++++++++++++++++ makefile | 26 ++++++++ 17 files changed, 389 insertions(+), 28 deletions(-) rename api/dto/{auth.go => user.go} (74%) create mode 100644 internal/models/user.go create mode 100644 internal/store/db.go create mode 100644 internal/store/store.go create mode 100644 internal/store/user.go create mode 100644 makefile diff --git a/.air.toml b/.air.toml index c8fee00..02c4a8b 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"] 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..a86023d 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..f482aa6 100644 --- a/api/http/apiutils/user-role.go +++ b/api/http/apiutils/user-role.go @@ -3,15 +3,14 @@ package apiutils type Role string const ( - RoleAdmin Role = "admin" - RoleEmployee Role = "employee" - RoleUser Role = "user" + RoleAdmin Role = "admin" + RoleUser Role = "user" ) func (r Role) ValidRole() (Role, bool) { valid := false switch r { - case RoleAdmin, RoleEmployee, RoleUser: + case RoleAdmin, RoleUser: valid = true } return r, valid diff --git a/api/http/controllers/auth.go b/api/http/controllers/auth.go index d2126d5..1d7a0fb 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.ErrUserNotFound): + 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..e2d61bb 100644 --- a/api/http/controllers/handler.go +++ b/api/http/controllers/handler.go @@ -3,6 +3,7 @@ package controllers import ( "github.com/kunalvirwal/shogun-cd/api/http/response" "github.com/kunalvirwal/shogun-cd/internal/config" + "github.com/kunalvirwal/shogun-cd/internal/store" "github.com/kunalvirwal/shogun-cd/internal/utils" "github.com/kunalvirwal/shogun-cd/internal/webhooks" ) @@ -12,13 +13,15 @@ type Handler struct { config *config.Config response response.Responder webhook webhooks.WebhookService + store *store.Store } -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) *Handler { return &Handler{ logger: l, config: cfg, response: responder, webhook: wh, + store: store, } } diff --git a/api/http/routes.go b/api/http/routes.go index b26edc4..4258dc7 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") diff --git a/api/http/server.go b/api/http/server.go index 45b74b0..1822f0d 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -9,17 +9,18 @@ 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/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) { 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) initRoutes(r, m, h) 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 3d21d9e..85b9aef 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -7,6 +7,7 @@ import ( "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/store" "github.com/kunalvirwal/shogun-cd/internal/target" "github.com/kunalvirwal/shogun-cd/internal/utils" "github.com/kunalvirwal/shogun-cd/internal/webhooks" @@ -50,9 +51,22 @@ func initServices() { _ = app + // Initialize webhook service webhook := webhooks.NewWebhookService(logger, orch) - go api.StartAPIServer(logger, cfg, webhook) + db, err := store.Connect(cfg, logger) + if err != nil { + logger.LogNewError(err.Error()) + return + } + + store := store.NewStore(db) + if err := seedAdmin(store, cfg); err != nil { + logger.LogNewError("Unable to Seed Admin account : %v", err.Error()) + } + + // Initialize api + go api.StartAPIServer(logger, cfg, webhook, store) <-make(chan struct{}) // Block forever } diff --git a/go.mod b/go.mod index 160bbc3..e6eea0f 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,11 @@ 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 ( @@ -24,6 +27,12 @@ require ( github.com/go-playground/validator/v10 v10.30.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 @@ -40,7 +49,8 @@ require ( go.uber.org/mock v0.6.0 // indirect golang.org/x/arch v0.23.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 google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 591f5d1..229e91c 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,20 @@ 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.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= @@ -67,6 +81,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 +98,19 @@ 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/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= 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 +119,7 @@ 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/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/config/types.go b/internal/config/types.go index cfc8f6c..656d5c1 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,12 @@ 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"` +} diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..bbe22c2 --- /dev/null +++ b/internal/models/user.go @@ -0,0 +1,28 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "github.com/kunalvirwal/shogun-cd/api/http/apiutils" + "gorm.io/gorm" +) + +type User struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` + Email string `gorm:"uniqueIndex;not null" json:"email"` + Password string `gorm:"not null" json:"-"` + Role string `gorm:"not null" json:"role"` + IsActive *bool `gorm:"default:true" json:"is_active"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + LastLoginAt *time.Time `json:"last_login_at"` +} + +// hook to insert default role during user creation +func (u *User) BeforeCreate(tx *gorm.DB) (err error) { + if u.Role == "" { + u.Role = string(apiutils.RoleUser) + } + return nil +} diff --git a/internal/store/db.go b/internal/store/db.go new file mode 100644 index 0000000..be2f11f --- /dev/null +++ b/internal/store/db.go @@ -0,0 +1,47 @@ +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{}, + ) + if err != nil { + return nil, fmt.Errorf("migration failed : %w", err) + } + + return db, nil +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..1a5ec56 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,15 @@ +package store + +import ( + "gorm.io/gorm" +) + +type Store struct { + User UserStore +} + +func NewStore(db *gorm.DB) *Store { + return &Store{ + User: newUserStore(db), + } +} diff --git a/internal/store/user.go b/internal/store/user.go new file mode 100644 index 0000000..ab7d8e2 --- /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" +) + +var ( + ErrEmailTaken = errors.New("User with this email already exists") + ErrUserNotFound = errors.New("User Not Found") + ErrAccountSuspended = errors.New("Account Suspended") +) + +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) +} + +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, ErrUserNotFound + 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/makefile b/makefile new file mode 100644 index 0000000..0d4fe26 --- /dev/null +++ b/makefile @@ -0,0 +1,26 @@ +DB_CONTAINER_NAME=shogun_db +DB_PORT=5434 +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) \ + -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) + docker rm $(DB_CONTAINER_NAME) + @echo "Database stopped and removed." + +db-logs: + docker logs -f $(DB_CONTAINER_NAME) \ No newline at end of file From cc38a1fad9488ee093ded22e8b555dedc1a30544 Mon Sep 17 00:00:00 2001 From: karma Date: Sun, 1 Feb 2026 19:59:58 +0530 Subject: [PATCH 3/6] [feat] db operations for webhook management and persistence Signed-off-by: karma --- .air.toml | 4 +- api/http/controllers/webhooks.go | 55 ++++++++++++++--- api/http/server.go | 6 +- cmd/helper.go | 3 +- cmd/main.go | 30 ++++++--- internal/models/user.go | 2 +- internal/models/webhook.go | 16 +++++ internal/store/db.go | 1 + internal/store/store.go | 6 +- internal/store/user.go | 12 ++-- internal/store/webhook.go | 102 +++++++++++++++++++++++++++++++ internal/webhooks/create.go | 45 ++++++++++---- internal/webhooks/delete.go | 8 ++- internal/webhooks/find.go | 4 +- internal/webhooks/load.go | 56 +++++++++++++++++ internal/webhooks/resolve.go | 5 +- internal/webhooks/service.go | 19 +++--- internal/webhooks/status.go | 8 ++- internal/webhooks/verify.go | 3 +- 19 files changed, 323 insertions(+), 62 deletions(-) create mode 100644 internal/models/webhook.go create mode 100644 internal/store/webhook.go create mode 100644 internal/webhooks/load.go diff --git a/.air.toml b/.air.toml index 02c4a8b..cb6b392 100644 --- a/.air.toml +++ b/.air.toml @@ -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/http/controllers/webhooks.go b/api/http/controllers/webhooks.go index db5ba8b..1bef890 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,9 +93,12 @@ 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 { h.response.ServerError(c, err) return @@ -96,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 == "" { @@ -104,30 +119,50 @@ 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(slug, false) + err := h.webhook.SetStatus(ctx, slug, false) if err != nil { - h.response.ServerError(c, err) - return + switch { + case errors.Is(err, store.ErrHookNotFound): + 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(slug, true) + err := h.webhook.SetStatus(ctx, slug, true) if err != nil { - h.response.ServerError(c, err) - return + switch { + case errors.Is(err, store.ErrHookNotFound): + 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/server.go b/api/http/server.go index 1822f0d..1cfd134 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -1,6 +1,7 @@ package api import ( + "context" "fmt" "github.com/gin-contrib/cors" @@ -14,8 +15,7 @@ import ( "github.com/kunalvirwal/shogun-cd/internal/webhooks" ) -func StartAPIServer(logger utils.Logger, cfg *config.Config, w webhooks.WebhookService, store *store.Store) { - +func StartAPIServer(ctx context.Context, logger utils.Logger, cfg *config.Config, w webhooks.WebhookService, store *store.Store) { r := newRouter() responder := response.NewResponder(cfg.Debug) @@ -27,7 +27,7 @@ func StartAPIServer(logger utils.Logger, cfg *config.Config, w webhooks.WebhookS 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 98b14a9..d67a920 100644 --- a/cmd/helper.go +++ b/cmd/helper.go @@ -10,8 +10,7 @@ import ( ) // makes the admin user upon startup (ignores if admin exists) -func seedAdmin(s *store.Store, cfg *config.Config) error { - ctx := context.Background() +func seedAdmin(ctx context.Context, s *store.Store, cfg *config.Config) error { if err := s.User.Create(ctx, &dto.LoginInput{ Email: cfg.ApiConfig.Admin.Email, Password: cfg.ApiConfig.Admin.Password, diff --git a/cmd/main.go b/cmd/main.go index 85b9aef..289abeb 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,6 +1,11 @@ package main import ( + "context" + "os" + "os/signal" + "syscall" + api "github.com/kunalvirwal/shogun-cd/api/http" "github.com/kunalvirwal/shogun-cd/internal/app" "github.com/kunalvirwal/shogun-cd/internal/config" @@ -14,11 +19,16 @@ import ( ) func main() { - initServices() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + initServices(ctx) // pipeline.LoadPipeline("./examples/pipeline.yaml") + + <-ctx.Done() // Block forever } -func initServices() { +func initServices(ctx context.Context) { // Initialize logger logger := utils.NewLogger(utils.DebugLevel, true) @@ -27,6 +37,7 @@ func initServices() { cfg, err := config.LoadConfigs(logger) if err != nil { logger.LogNewError("Invalid config: Stopping Shogun...") + return } logger.SetLevel(cfg.Debug) @@ -51,9 +62,6 @@ func initServices() { _ = app - // Initialize webhook service - webhook := webhooks.NewWebhookService(logger, orch) - db, err := store.Connect(cfg, logger) if err != nil { logger.LogNewError(err.Error()) @@ -61,12 +69,18 @@ func initServices() { } store := store.NewStore(db) - if err := seedAdmin(store, cfg); err != nil { + if err := seedAdmin(ctx, store, cfg); err != nil { logger.LogNewError("Unable to Seed Admin account : %v", err.Error()) + return + } + + // Initialize webhook service + webhook := webhooks.NewWebhookService(logger, orch, store) + if err = webhook.Load(ctx); err != nil { + logger.LogNewError("Unable to load Webhooks : %v", err.Error()) } // Initialize api - go api.StartAPIServer(logger, cfg, webhook, store) + go api.StartAPIServer(ctx, logger, cfg, webhook, store) - <-make(chan struct{}) // Block forever } diff --git a/internal/models/user.go b/internal/models/user.go index bbe22c2..d3fd255 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -16,7 +16,7 @@ type User struct { IsActive *bool `gorm:"default:true" json:"is_active"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - LastLoginAt *time.Time `json:"last_login_at"` + LastLoginAt *time.Time `json:"last_login_at"` // [TODO] discuss viability of last login time } // hook to insert default role during user creation diff --git a/internal/models/webhook.go b/internal/models/webhook.go new file mode 100644 index 0000000..fa21e0e --- /dev/null +++ b/internal/models/webhook.go @@ -0,0 +1,16 @@ +package models + +import "time" + +type Webhook struct { + ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()"` + Slug string `gorm:"uniqueIndex;not null;type:varchar(50)"` + Secret string `gorm:"not null"` + Pipeline string `gorm:"index;not null"` + Alias string `gorm:"type:varchar(100)"` + CreatedBy string `gorm:"type:varchar(100)"` + IsActive *bool `gorm:"default:true"` + + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} diff --git a/internal/store/db.go b/internal/store/db.go index be2f11f..cf28d31 100644 --- a/internal/store/db.go +++ b/internal/store/db.go @@ -38,6 +38,7 @@ func Connect(cfg *config.Config, logger utils.Logger) (*gorm.DB, error) { err = db.AutoMigrate( &models.User{}, + &models.Webhook{}, ) if err != nil { return nil, fmt.Errorf("migration failed : %w", err) diff --git a/internal/store/store.go b/internal/store/store.go index 1a5ec56..5dcd798 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -5,11 +5,13 @@ import ( ) type Store struct { - User UserStore + User UserStore + Webhook WebhookStore } func NewStore(db *gorm.DB) *Store { return &Store{ - User: newUserStore(db), + User: newUserStore(db), + Webhook: newWebhookStore(db), } } diff --git a/internal/store/user.go b/internal/store/user.go index ab7d8e2..8aa7b4c 100644 --- a/internal/store/user.go +++ b/internal/store/user.go @@ -11,18 +11,18 @@ import ( "gorm.io/gorm" ) -var ( - ErrEmailTaken = errors.New("User with this email already exists") - ErrUserNotFound = errors.New("User Not Found") - ErrAccountSuspended = errors.New("Account Suspended") -) - 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") + ErrUserNotFound = errors.New("User Not Found") + ErrAccountSuspended = errors.New("Account Suspended") +) + type userStore struct { db *gorm.DB } diff --git a/internal/store/webhook.go b/internal/store/webhook.go new file mode 100644 index 0000000..4b5fb2a --- /dev/null +++ b/internal/store/webhook.go @@ -0,0 +1,102 @@ +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") + ErrHookNotFound = errors.New("Webhook Not Found") +) + +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 ErrHookNotFound + } + + 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..5928d08 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,46 @@ func (s *Service) Create(input *dto.HookInput) (*Webhook, error) { return nil, err } - hook := &Webhook{ - Slug: "", + //[TODO] enforce input field lengths for webhook using validator tags + + dbHook := &models.Webhook{ Secret: secret, Pipeline: input.Pipeline, Alias: input.Alias, CreatedBy: input.CreatedBy, - IsActive: true, - CreatedAt: time.Now(), + } + hook := &Webhook{ + Secret: dbHook.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 eaeda68..d54b6a1 100644 --- a/internal/webhooks/delete.go +++ b/internal/webhooks/delete.go @@ -1,8 +1,12 @@ package webhooks -func (s *Service) Delete(slug string) error { +import "context" - // [TODO] delete from DB +func (s *Service) Delete(ctx context.Context, slug string) error { + + if err := s.store.Delete(ctx, slug); err != nil { + return err + } s.mu.Lock() delete(s.registry, slug) 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..b95b1fe --- /dev/null +++ b/internal/webhooks/load.go @@ -0,0 +1,56 @@ +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 +func (s *Service) Load(ctx context.Context) error { + 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() + wh := &Webhook{ + ID: hook.ID, + Slug: hook.Slug, + Secret: hook.Secret, // [TODO] [CRITICAL] decrypt the secret to store in registry + 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 Webhooks loaded from Database.", 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 1f6371a..6719487 100644 --- a/internal/webhooks/service.go +++ b/internal/webhooks/service.go @@ -1,6 +1,7 @@ package webhooks import ( + "context" "errors" "net/http" "sync" @@ -8,15 +9,18 @@ import ( "github.com/kunalvirwal/shogun-cd/api/dto" "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 - SetStatus(slug string, isActive bool) error + 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(ctx context.Context) error } var ( @@ -43,15 +47,16 @@ type Webhook struct { type Service struct { registry map[string]*Webhook orchestrator orchestrator.Orchestrator + store store.WebhookStore 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) WebhookService { svc := &Service{ registry: make(map[string]*Webhook), orchestrator: o, + store: s.Webhook, logger: l, } return svc diff --git a/internal/webhooks/status.go b/internal/webhooks/status.go index c8d2bf4..87fc734 100644 --- a/internal/webhooks/status.go +++ b/internal/webhooks/status.go @@ -1,8 +1,12 @@ package webhooks -func (s *Service) SetStatus(slug string, isActive bool) error { +import "context" - // [TODO] db call to change webhook status +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 { 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 From fd26805f69be6ede88d9dbd819372ca8d66be74c Mon Sep 17 00:00:00 2001 From: karma Date: Mon, 2 Feb 2026 01:34:37 +0530 Subject: [PATCH 4/6] [feat] encryption utility in db service Signed-off-by: karma --- cmd/main.go | 7 ++- internal/config/types.go | 15 ++++--- internal/models/user.go | 15 +++---- internal/store/crypto.go | 93 +++++++++++++++++++++++++++++++++++++++ internal/store/store.go | 12 +++-- internal/store/webhook.go | 34 ++++++++++++-- sample.config.yaml | 9 ++++ 7 files changed, 162 insertions(+), 23 deletions(-) create mode 100644 internal/store/crypto.go diff --git a/cmd/main.go b/cmd/main.go index 289abeb..77e951f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -67,8 +67,11 @@ func initServices(ctx context.Context) { logger.LogNewError(err.Error()) return } - - store := store.NewStore(db) + store, err := store.NewStore(cfg, db) + if err != nil { + logger.LogNewError("Unable to Initialise Store : %v", err.Error()) + return + } if err := seedAdmin(ctx, store, cfg); err != nil { logger.LogNewError("Unable to Seed Admin account : %v", err.Error()) return diff --git a/internal/config/types.go b/internal/config/types.go index 656d5c1..a928d10 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -32,10 +32,13 @@ type JWT struct { } 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"` + 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/models/user.go b/internal/models/user.go index d3fd255..df4b369 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -9,14 +9,13 @@ import ( ) type User struct { - ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` - Email string `gorm:"uniqueIndex;not null" json:"email"` - Password string `gorm:"not null" json:"-"` - Role string `gorm:"not null" json:"role"` - IsActive *bool `gorm:"default:true" json:"is_active"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - LastLoginAt *time.Time `json:"last_login_at"` // [TODO] discuss viability of last login time + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` + Email string `gorm:"uniqueIndex;not null" json:"email"` + Password string `gorm:"not null" json:"-"` + Role string `gorm:"not null" json:"role"` + IsActive *bool `gorm:"default:true" json:"is_active"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` } // hook to insert default role during user creation diff --git a/internal/store/crypto.go b/internal/store/crypto.go new file mode 100644 index 0000000..1fa6863 --- /dev/null +++ b/internal/store/crypto.go @@ -0,0 +1,93 @@ +package store + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + + "github.com/kunalvirwal/shogun-cd/internal/config" +) + +const ( + iterations = 100000 + keyLen = 32 // 32bytes requires for aes256 +) + +type Crypto interface { + Encrypt(plaintext string) (string, error) + Decrypt(ciphertext string) (string, error) +} + +type crypto struct { + aead cipher.AEAD +} + +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 +} + +func newCrypto(cfg *config.Config) (Crypto, 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 &crypto{ + aead: aead, + }, nil +} + +func (c *crypto) 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 +} + +func (c *crypto) 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("decryption failed : %w", err) + } + + return string(plaintext), nil +} diff --git a/internal/store/store.go b/internal/store/store.go index 5dcd798..336fe8a 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1,6 +1,7 @@ package store import ( + "github.com/kunalvirwal/shogun-cd/internal/config" "gorm.io/gorm" ) @@ -9,9 +10,14 @@ type Store struct { Webhook WebhookStore } -func NewStore(db *gorm.DB) *Store { +func NewStore(cfg *config.Config, db *gorm.DB) (*Store, error) { + c, err := newCrypto(cfg) + if err != nil { + return nil, err + } + return &Store{ User: newUserStore(db), - Webhook: newWebhookStore(db), - } + Webhook: newWebhookStore(db, c), + }, nil } diff --git a/internal/store/webhook.go b/internal/store/webhook.go index 4b5fb2a..539aff0 100644 --- a/internal/store/webhook.go +++ b/internal/store/webhook.go @@ -19,15 +19,19 @@ type WebhookStore interface { var ( ErrSlugCollision = errors.New("Slug Collision") ErrHookNotFound = errors.New("Webhook Not Found") + ErrEncryption = errors.New("Error Encrypting Data") + ErrDecryption = errors.New("Error Decrypting Data") ) type webhookStore struct { - db *gorm.DB + db *gorm.DB + crypto Crypto } -func newWebhookStore(db *gorm.DB) WebhookStore { +func newWebhookStore(db *gorm.DB, c Crypto) WebhookStore { return &webhookStore{ - db: db, + db: db, + crypto: c, } } @@ -35,6 +39,14 @@ func (w *webhookStore) Create(ctx context.Context, in *models.Webhook) error { if ctx.Err() != nil { return ctx.Err() } + if in.Secret != "" { + secret := in.Secret + encrypted, err := w.crypto.Encrypt(secret) + if err != nil { + return ErrEncryption + } + in.Secret = encrypted + } if err := gorm.G[models.Webhook](w.db). Create(ctx, in); err != nil { @@ -98,5 +110,19 @@ func (w *webhookStore) Load(ctx context.Context) ([]*models.Webhook, error) { return nil, err } - return hooks, nil + var validHooks []*models.Webhook + + for _, h := range hooks { + if h.Secret == "" { + continue + } + decrypted, err := w.crypto.Decrypt(h.Secret) + if err != nil { + continue + } + h.Secret = decrypted + validHooks = append(validHooks, h) + } + + return validHooks, nil } diff --git a/sample.config.yaml b/sample.config.yaml index b6086cf..bb1a327 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: 5434 + user: "shogun" + password: "pwd" + dbname: "shogun_db" + ssl_mode: "disable" + master_key: "qwertyuiop" + encryption_salt: "salty-salt" From ab9ce3c699a9a3fdbe5da1e19f633d538ece2858 Mon Sep 17 00:00:00 2001 From: karma Date: Sun, 8 Feb 2026 03:50:56 +0530 Subject: [PATCH 5/6] [feat] implement secret service and refactor store service Signed-off-by: karma --- api/dto/secret.go | 15 ++++ api/dto/user.go | 2 +- api/http/controllers/admin.go | 69 ++++++++++++++--- api/http/controllers/auth.go | 4 +- api/http/controllers/handler.go | 5 +- api/http/routes.go | 5 +- api/http/server.go | 6 +- cmd/helper.go | 3 +- cmd/main.go | 52 ++++++------- internal/encryption/decrypt.go | 28 +++++++ internal/encryption/encrypt.go | 21 +++++ internal/encryption/service.go | 63 +++++++++++++++ internal/models/secret.go | 33 ++++++++ internal/models/user.go | 30 +++++-- internal/models/webhook.go | 35 +++++++-- internal/secrets/service.go | 102 ++++++++++++++++++++++++ internal/store/crypto.go | 93 ---------------------- internal/store/db.go | 1 + internal/store/secret.go | 133 ++++++++++++++++++++++++++++++++ internal/store/store.go | 15 ++-- internal/store/user.go | 14 ++-- internal/store/webhook.go | 34 +------- internal/webhooks/create.go | 9 ++- internal/webhooks/load.go | 15 +++- internal/webhooks/service.go | 7 +- 25 files changed, 594 insertions(+), 200 deletions(-) create mode 100644 api/dto/secret.go create mode 100644 internal/encryption/decrypt.go create mode 100644 internal/encryption/encrypt.go create mode 100644 internal/encryption/service.go create mode 100644 internal/models/secret.go create mode 100644 internal/secrets/service.go delete mode 100644 internal/store/crypto.go create mode 100644 internal/store/secret.go 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/user.go b/api/dto/user.go index a86023d..5b848fb 100644 --- a/api/dto/user.go +++ b/api/dto/user.go @@ -6,6 +6,6 @@ type LoginInput struct { } type UserFilter struct { - Email *string + Email string IsActive *bool } diff --git a/api/http/controllers/admin.go b/api/http/controllers/admin.go index 36abdd7..45d46ed 100644 --- a/api/http/controllers/admin.go +++ b/api/http/controllers/admin.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/gin-gonic/gin" + "github.com/kunalvirwal/shogun-cd/api/dto" ) func (h *Handler) CreateAPIKey(c *gin.Context) { @@ -29,32 +30,82 @@ func (h *Handler) DeleteAPIKey(c *gin.Context) { } func (h *Handler) ListAllSecrets(c *gin.Context) { + var req dto.SecretFilter - // [TODO] list all the secret's name (NEVER display values) + if err := c.ShouldBind(&req); err != nil { + h.response.BadRequest(c, "Bad Input", err) + return + } - h.response.Success(c, "All Secrets (without value)", nil) + // [TODO] granular error handling for different http status codes. + data, err := h.secret.FindMany(c.Request.Context(), &req) + if err != nil { + h.response.ServerError(c, err) + return + } + + h.response.Success(c, "All Secrets (without value)", data) } -func (h *Handler) CreateSecret(c *gin.Context) { +func (h *Handler) CreateSecrets(c *gin.Context) { + var req []dto.SecretInput + + if err := c.ShouldBindJSON(&req); err != nil { + h.response.BadRequest(c, "Bad Input", err) + return + } - // [TODO] create a new secret + // [TODO] granular error handling for different http status codes. + if err := h.secret.Create(c.Request.Context(), req); err != nil { + h.response.ServerError(c, err) + return + } - h.response.Created(c, "New Secret Created", nil) + h.response.Created(c, "New Secret(s) Created", nil) +} + +func (h *Handler) ForceCreateSecrets(c *gin.Context) { + var req []dto.SecretInput + + if err := c.ShouldBindJSON(&req); err != nil { + h.response.BadRequest(c, "Bad Input", err) + return + } + + // [TODO] granular error handling for different http status codes. + if err := h.secret.Upsert(c.Request.Context(), req); err != nil { + h.response.ServerError(c, err) + return + } + + h.response.Created(c, "New Secret(s) Created", nil) } func (h *Handler) UpdateSecret(c *gin.Context) { - s := c.Param("secret") + var req dto.SecretInput + + if err := c.ShouldBindJSON(&req); err != nil { + h.response.BadRequest(c, "Bad Input", err) + return + } - // [TODO] updates a secret + // [TODO] granular error handling for different http status codes. + if err := h.secret.Update(c.Request.Context(), &req); err != nil { + h.response.ServerError(c, err) + return + } - h.response.Success(c, fmt.Sprintf("Secret Updated - %v", s), nil) + h.response.Success(c, fmt.Sprintf("Secret Updated - %v", req.Name), nil) } func (h *Handler) DeleteSecret(c *gin.Context) { s := c.Param("secret") - // [TODO] deletes a secret + if err := h.secret.Delete(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/auth.go b/api/http/controllers/auth.go index 1d7a0fb..5f7622c 100644 --- a/api/http/controllers/auth.go +++ b/api/http/controllers/auth.go @@ -44,11 +44,11 @@ func (h *Handler) Login(c *gin.Context) { } user, err := h.store.User.FindOne(c.Request.Context(), &dto.UserFilter{ - Email: &req.Email, + Email: req.Email, }) if err != nil { switch { - case errors.Is(err, store.ErrUserNotFound): + case errors.Is(err, store.ErrRecordNotFound): h.response.Unauthorized(c, "Invalid email or password", err) return default: diff --git a/api/http/controllers/handler.go b/api/http/controllers/handler.go index e2d61bb..88cb099 100644 --- a/api/http/controllers/handler.go +++ b/api/http/controllers/handler.go @@ -3,6 +3,7 @@ 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" @@ -14,14 +15,16 @@ type Handler struct { response response.Responder webhook webhooks.WebhookService store *store.Store + secret secrets.Service } -func NewHandler(l utils.Logger, cfg *config.Config, responder response.Responder, wh webhooks.WebhookService, store *store.Store) *Handler { +func NewHandler(l utils.Logger, cfg *config.Config, responder response.Responder, wh webhooks.WebhookService, store *store.Store, secrets secrets.Service) *Handler { return &Handler{ logger: l, config: cfg, response: responder, webhook: wh, store: store, + secret: secrets, } } diff --git a/api/http/routes.go b/api/http/routes.go index 4258dc7..4761837 100644 --- a/api/http/routes.go +++ b/api/http/routes.go @@ -42,8 +42,9 @@ func initRoutes(router *gin.Engine, m *middlewares.Manager, h *controllers.Handl secret := admin.Group("/secrets") { secret.GET("", h.ListAllSecrets) - secret.POST("", h.CreateSecret) - secret.PATCH("/:secret", h.UpdateSecret) + secret.POST("", h.CreateSecrets) + secret.POST("/force", h.ForceCreateSecrets) + secret.PATCH("", h.UpdateSecret) secret.DELETE("/:secret", h.DeleteSecret) } } diff --git a/api/http/server.go b/api/http/server.go index 1cfd134..0e183ed 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -1,7 +1,6 @@ package api import ( - "context" "fmt" "github.com/gin-contrib/cors" @@ -10,17 +9,18 @@ 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(ctx context.Context, logger utils.Logger, cfg *config.Config, w webhooks.WebhookService, store *store.Store) { +func StartAPIServer(logger utils.Logger, cfg *config.Config, w webhooks.WebhookService, store *store.Store, secrets secrets.Service) { r := newRouter() responder := response.NewResponder(cfg.Debug) m := middlewares.NewManager(logger, cfg, responder, w) - h := controllers.NewHandler(logger, cfg, responder, w, store) + h := controllers.NewHandler(logger, cfg, responder, w, store, secrets) initRoutes(r, m, h) diff --git a/cmd/helper.go b/cmd/helper.go index d67a920..98b14a9 100644 --- a/cmd/helper.go +++ b/cmd/helper.go @@ -10,7 +10,8 @@ import ( ) // makes the admin user upon startup (ignores if admin exists) -func seedAdmin(ctx context.Context, s *store.Store, cfg *config.Config) error { +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, diff --git a/cmd/main.go b/cmd/main.go index 77e951f..c356c43 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,17 +1,14 @@ package main import ( - "context" - "os" - "os/signal" - "syscall" - 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" @@ -19,16 +16,11 @@ import ( ) func main() { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - initServices(ctx) + initServices() // pipeline.LoadPipeline("./examples/pipeline.yaml") - - <-ctx.Done() // Block forever } -func initServices(ctx context.Context) { +func initServices() { // Initialize logger logger := utils.NewLogger(utils.DebugLevel, true) @@ -42,6 +34,23 @@ func initServices(ctx context.Context) { 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 + } + secrets := secrets.NewSecretService(encryption, *store, logger) + // Initialize Git service gitService, err := git.NewGitService(logger, cfg) if err != nil { @@ -62,28 +71,19 @@ func initServices(ctx context.Context) { _ = app - 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 - } - if err := seedAdmin(ctx, store, cfg); err != nil { + 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) - if err = webhook.Load(ctx); err != nil { + webhook := webhooks.NewWebhookService(logger, orch, store, encryption) + if err = webhook.Load(); err != nil { logger.LogNewError("Unable to load Webhooks : %v", err.Error()) } // Initialize api - go api.StartAPIServer(ctx, logger, cfg, webhook, store) + go api.StartAPIServer(logger, cfg, webhook, store, secrets) + <-make(chan struct{}) // Block forever } 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..227be02 --- /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 Service 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) (Service, 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 index df4b369..79d2b21 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -8,16 +8,36 @@ import ( "gorm.io/gorm" ) +// 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:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` - Email string `gorm:"uniqueIndex;not null" json:"email"` - Password string `gorm:"not null" json:"-"` - Role string `gorm:"not null" json:"role"` - IsActive *bool `gorm:"default:true" json:"is_active"` + 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 +} + // hook to insert default role during user creation func (u *User) BeforeCreate(tx *gorm.DB) (err error) { if u.Role == "" { diff --git a/internal/models/webhook.go b/internal/models/webhook.go index fa21e0e..e54b43c 100644 --- a/internal/models/webhook.go +++ b/internal/models/webhook.go @@ -2,15 +2,36 @@ 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:"primaryKey;type:uuid;default:gen_random_uuid()"` - Slug string `gorm:"uniqueIndex;not null;type:varchar(50)"` - Secret string `gorm:"not null"` - Pipeline string `gorm:"index;not null"` - Alias string `gorm:"type:varchar(100)"` - CreatedBy string `gorm:"type:varchar(100)"` - IsActive *bool `gorm:"default:true"` + 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/secrets/service.go b/internal/secrets/service.go new file mode 100644 index 0000000..7770ffe --- /dev/null +++ b/internal/secrets/service.go @@ -0,0 +1,102 @@ +package secrets + +import ( + "context" + + "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 Service interface { + Create(ctx context.Context, in []dto.SecretInput) error + Upsert(ctx context.Context, in []dto.SecretInput) error + Update(ctx context.Context, in *dto.SecretInput) error + Resolve(ctx context.Context, name string) (string, error) // provides decrypted "value" of the secret + FindMany(ctx context.Context, filter *dto.SecretFilter) ([]*dto.Secret, error) + Delete(ctx context.Context, name string) error +} + +type service struct { + encryption encryption.Service + store store.SecretStore + logger utils.Logger +} + +func NewSecretService(e encryption.Service, s store.Store, l utils.Logger) Service { + return &service{ + encryption: e, + store: s.Secret, + logger: l, + } +} + +func (s *service) Create(ctx context.Context, in []dto.SecretInput) error { + secrets := make([]models.Secret, len(in)) + + for k, v := range in { + encrypted, err := s.encryption.Encrypt(in[k].Value) + if err != nil { + return err + } + secrets[k] = models.Secret{ + Name: v.Name, + Value: encrypted, + } + } + + return s.store.Create(ctx, secrets) +} + +func (s *service) Upsert(ctx context.Context, in []dto.SecretInput) error { + secrets := make([]models.Secret, len(in)) + + for k, v := range in { + encrypted, err := s.encryption.Encrypt(in[k].Value) + if err != nil { + return err + } + secrets[k] = models.Secret{ + Name: v.Name, + Value: encrypted, + } + } + + return s.store.Upsert(ctx, secrets) +} + +func (s *service) Update(ctx context.Context, in *dto.SecretInput) error { + encrypted, err := s.encryption.Encrypt(in.Value) + if err != nil { + return err + } + + return s.store.Update(ctx, &models.Secret{ + Name: in.Name, + Value: encrypted, + }) +} + +func (s *service) Resolve(ctx context.Context, name string) (string, error) { + secrets, err := s.store.GetSecret(ctx, name) + if err != nil { + return "", err + } + + decrypted, err := s.encryption.Decrypt(secrets) + if err != nil { + return "", err + } + + return decrypted, nil +} + +func (s *service) FindMany(ctx context.Context, filter *dto.SecretFilter) ([]*dto.Secret, error) { + return s.store.FindMany(ctx, filter) +} + +func (s *service) Delete(ctx context.Context, name string) error { + return s.store.Delete(ctx, name) +} diff --git a/internal/store/crypto.go b/internal/store/crypto.go deleted file mode 100644 index 1fa6863..0000000 --- a/internal/store/crypto.go +++ /dev/null @@ -1,93 +0,0 @@ -package store - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/pbkdf2" - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - - "github.com/kunalvirwal/shogun-cd/internal/config" -) - -const ( - iterations = 100000 - keyLen = 32 // 32bytes requires for aes256 -) - -type Crypto interface { - Encrypt(plaintext string) (string, error) - Decrypt(ciphertext string) (string, error) -} - -type crypto struct { - aead cipher.AEAD -} - -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 -} - -func newCrypto(cfg *config.Config) (Crypto, 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 &crypto{ - aead: aead, - }, nil -} - -func (c *crypto) 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 -} - -func (c *crypto) 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("decryption failed : %w", err) - } - - return string(plaintext), nil -} diff --git a/internal/store/db.go b/internal/store/db.go index cf28d31..3ebc3e5 100644 --- a/internal/store/db.go +++ b/internal/store/db.go @@ -39,6 +39,7 @@ func Connect(cfg *config.Config, logger utils.Logger) (*gorm.DB, error) { err = db.AutoMigrate( &models.User{}, &models.Webhook{}, + &models.Secret{}, ) if err != nil { return nil, fmt.Errorf("migration failed : %w", err) 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 index 336fe8a..cb9442d 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1,6 +1,8 @@ package store import ( + "errors" + "github.com/kunalvirwal/shogun-cd/internal/config" "gorm.io/gorm" ) @@ -8,16 +10,17 @@ import ( type Store struct { User UserStore Webhook WebhookStore + Secret SecretStore } -func NewStore(cfg *config.Config, db *gorm.DB) (*Store, error) { - c, err := newCrypto(cfg) - if err != nil { - return nil, err - } +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, c), + Webhook: newWebhookStore(db), + Secret: newSecretStore(db), }, nil } diff --git a/internal/store/user.go b/internal/store/user.go index 8aa7b4c..c79e8aa 100644 --- a/internal/store/user.go +++ b/internal/store/user.go @@ -19,7 +19,6 @@ type UserStore interface { var ( ErrEmailTaken = errors.New("User with this email already exists") - ErrUserNotFound = errors.New("User Not Found") ErrAccountSuspended = errors.New("Account Suspended") ) @@ -43,10 +42,11 @@ func (u *userStore) Create(ctx context.Context, in *dto.LoginInput) error { 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), - }) + 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): @@ -77,12 +77,12 @@ func (u *userStore) MakeAdmin(ctx context.Context, in *dto.LoginInput) error { func (u *userStore) FindOne(ctx context.Context, filter *dto.UserFilter) (*models.User, error) { user, err := gorm.G[*models.User](u.db). - Where(&filter). + Where(filter). First(ctx) if err != nil { switch { case errors.Is(err, gorm.ErrRecordNotFound): - return nil, ErrUserNotFound + return nil, ErrRecordNotFound default: return nil, err } diff --git a/internal/store/webhook.go b/internal/store/webhook.go index 539aff0..4b5fb2a 100644 --- a/internal/store/webhook.go +++ b/internal/store/webhook.go @@ -19,19 +19,15 @@ type WebhookStore interface { var ( ErrSlugCollision = errors.New("Slug Collision") ErrHookNotFound = errors.New("Webhook Not Found") - ErrEncryption = errors.New("Error Encrypting Data") - ErrDecryption = errors.New("Error Decrypting Data") ) type webhookStore struct { - db *gorm.DB - crypto Crypto + db *gorm.DB } -func newWebhookStore(db *gorm.DB, c Crypto) WebhookStore { +func newWebhookStore(db *gorm.DB) WebhookStore { return &webhookStore{ - db: db, - crypto: c, + db: db, } } @@ -39,14 +35,6 @@ func (w *webhookStore) Create(ctx context.Context, in *models.Webhook) error { if ctx.Err() != nil { return ctx.Err() } - if in.Secret != "" { - secret := in.Secret - encrypted, err := w.crypto.Encrypt(secret) - if err != nil { - return ErrEncryption - } - in.Secret = encrypted - } if err := gorm.G[models.Webhook](w.db). Create(ctx, in); err != nil { @@ -110,19 +98,5 @@ func (w *webhookStore) Load(ctx context.Context) ([]*models.Webhook, error) { return nil, err } - var validHooks []*models.Webhook - - for _, h := range hooks { - if h.Secret == "" { - continue - } - decrypted, err := w.crypto.Decrypt(h.Secret) - if err != nil { - continue - } - h.Secret = decrypted - validHooks = append(validHooks, h) - } - - return validHooks, nil + return hooks, nil } diff --git a/internal/webhooks/create.go b/internal/webhooks/create.go index 5928d08..597e9d8 100644 --- a/internal/webhooks/create.go +++ b/internal/webhooks/create.go @@ -32,14 +32,19 @@ func (s *Service) Create(ctx context.Context, input *dto.HookInput) (*Webhook, e //[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: secret, + Secret: encrypted, Pipeline: input.Pipeline, Alias: input.Alias, CreatedBy: input.CreatedBy, } hook := &Webhook{ - Secret: dbHook.Secret, + Secret: secret, Pipeline: dbHook.Pipeline, Alias: dbHook.Alias, CreatedBy: dbHook.CreatedBy, diff --git a/internal/webhooks/load.go b/internal/webhooks/load.go index b95b1fe..9cfa89e 100644 --- a/internal/webhooks/load.go +++ b/internal/webhooks/load.go @@ -9,7 +9,10 @@ import ( // sync the local registry with database // current implementation of this function is meant to be run during startup -func (s *Service) Load(ctx context.Context) error { +// 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 @@ -29,10 +32,16 @@ func (s *Service) Load(ctx context.Context) error { 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: hook.Secret, // [TODO] [CRITICAL] decrypt the secret to store in registry + Secret: decrypted, Pipeline: hook.Pipeline, Alias: hook.Alias, CreatedBy: hook.CreatedBy, @@ -50,7 +59,7 @@ func (s *Service) Load(ctx context.Context) error { s.registry = newRegistry s.mu.Unlock() - s.logger.LogInfo("%v Webhooks loaded from Database.", len(dbHooks)) + s.logger.LogInfo("%v/%v Webhooks loaded from Database.", len(newRegistry), len(dbHooks)) return nil } diff --git a/internal/webhooks/service.go b/internal/webhooks/service.go index 6719487..0471950 100644 --- a/internal/webhooks/service.go +++ b/internal/webhooks/service.go @@ -8,6 +8,7 @@ import ( "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" @@ -20,7 +21,7 @@ type WebhookService interface { Delete(ctx context.Context, slug string) error Find(ctx context.Context, filter WebhookFilter) []*Webhook SetStatus(ctx context.Context, slug string, isActive bool) error - Load(ctx context.Context) error + Load() error } var ( @@ -48,16 +49,18 @@ type Service struct { registry map[string]*Webhook orchestrator orchestrator.Orchestrator store store.WebhookStore + encryption encryption.Service logger utils.Logger mu sync.RWMutex } -func NewWebhookService(l utils.Logger, o orchestrator.Orchestrator, s *store.Store) WebhookService { +func NewWebhookService(l utils.Logger, o orchestrator.Orchestrator, s *store.Store, e encryption.Service) WebhookService { svc := &Service{ registry: make(map[string]*Webhook), orchestrator: o, store: s.Webhook, logger: l, + encryption: e, } return svc } From aeb1c86b6cca96a6444b64736d88857e0dfc586c Mon Sep 17 00:00:00 2001 From: karma Date: Wed, 11 Feb 2026 14:52:46 +0530 Subject: [PATCH 6/6] [refactor] rename secret service, remove user role, update makefile Signed-off-by: karma --- api/http/apiutils/user-role.go | 3 +- api/http/controllers/admin.go | 111 ------------------------------- api/http/controllers/api-key.go | 29 ++++++++ api/http/controllers/handler.go | 4 +- api/http/controllers/secret.go | 52 +++++++++++++++ api/http/controllers/webhooks.go | 4 +- api/http/routes.go | 6 +- api/http/server.go | 2 +- cmd/main.go | 11 ++- go.mod | 9 +++ go.sum | 30 +++++++++ internal/app/app.go | 4 +- internal/encryption/service.go | 4 +- internal/models/user.go | 10 --- internal/pipeline/service.go | 4 +- internal/pipeline/steps/deps.go | 2 +- internal/pipeline/steps/exec.go | 7 +- internal/secrets/service.go | 83 +++++++++++------------ internal/store/webhook.go | 3 +- internal/webhooks/service.go | 4 +- makefile | 7 +- sample.config.yaml | 2 +- 22 files changed, 193 insertions(+), 198 deletions(-) delete mode 100644 api/http/controllers/admin.go create mode 100644 api/http/controllers/api-key.go create mode 100644 api/http/controllers/secret.go diff --git a/api/http/apiutils/user-role.go b/api/http/apiutils/user-role.go index f482aa6..b633fe8 100644 --- a/api/http/apiutils/user-role.go +++ b/api/http/apiutils/user-role.go @@ -4,13 +4,12 @@ type Role string const ( RoleAdmin Role = "admin" - RoleUser Role = "user" ) func (r Role) ValidRole() (Role, bool) { valid := false switch r { - case RoleAdmin, 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 45d46ed..0000000 --- a/api/http/controllers/admin.go +++ /dev/null @@ -1,111 +0,0 @@ -package controllers - -import ( - "fmt" - - "github.com/gin-gonic/gin" - "github.com/kunalvirwal/shogun-cd/api/dto" -) - -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) { - var req dto.SecretFilter - - if err := c.ShouldBind(&req); err != nil { - h.response.BadRequest(c, "Bad Input", err) - return - } - - // [TODO] granular error handling for different http status codes. - data, err := h.secret.FindMany(c.Request.Context(), &req) - if err != nil { - h.response.ServerError(c, err) - return - } - - h.response.Success(c, "All Secrets (without value)", data) -} - -func (h *Handler) CreateSecrets(c *gin.Context) { - var req []dto.SecretInput - - if err := c.ShouldBindJSON(&req); err != nil { - h.response.BadRequest(c, "Bad Input", err) - return - } - - // [TODO] granular error handling for different http status codes. - if err := h.secret.Create(c.Request.Context(), req); err != nil { - h.response.ServerError(c, err) - return - } - - h.response.Created(c, "New Secret(s) Created", nil) -} - -func (h *Handler) ForceCreateSecrets(c *gin.Context) { - var req []dto.SecretInput - - if err := c.ShouldBindJSON(&req); err != nil { - h.response.BadRequest(c, "Bad Input", err) - return - } - - // [TODO] granular error handling for different http status codes. - if err := h.secret.Upsert(c.Request.Context(), req); err != nil { - h.response.ServerError(c, err) - return - } - - h.response.Created(c, "New Secret(s) Created", nil) -} - -func (h *Handler) UpdateSecret(c *gin.Context) { - var req dto.SecretInput - - if err := c.ShouldBindJSON(&req); err != nil { - h.response.BadRequest(c, "Bad Input", err) - return - } - - // [TODO] granular error handling for different http status codes. - if err := h.secret.Update(c.Request.Context(), &req); err != nil { - h.response.ServerError(c, err) - return - } - - h.response.Success(c, fmt.Sprintf("Secret Updated - %v", req.Name), nil) - -} - -func (h *Handler) DeleteSecret(c *gin.Context) { - s := c.Param("secret") - - if err := h.secret.Delete(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/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/handler.go b/api/http/controllers/handler.go index 88cb099..44f7f73 100644 --- a/api/http/controllers/handler.go +++ b/api/http/controllers/handler.go @@ -15,10 +15,10 @@ type Handler struct { response response.Responder webhook webhooks.WebhookService store *store.Store - secret secrets.Service + secret secrets.SecretService } -func NewHandler(l utils.Logger, cfg *config.Config, responder response.Responder, wh webhooks.WebhookService, store *store.Store, secrets secrets.Service) *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, 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 1bef890..7c96d36 100644 --- a/api/http/controllers/webhooks.go +++ b/api/http/controllers/webhooks.go @@ -133,7 +133,7 @@ func (h *Handler) DeactivateWebhook(c *gin.Context) { err := h.webhook.SetStatus(ctx, slug, false) if err != nil { switch { - case errors.Is(err, store.ErrHookNotFound): + case errors.Is(err, store.ErrRecordNotFound): h.response.NotFound(c, "Webhook Not Found", err) return @@ -155,7 +155,7 @@ func (h *Handler) ActivateWebhook(c *gin.Context) { err := h.webhook.SetStatus(ctx, slug, true) if err != nil { switch { - case errors.Is(err, store.ErrHookNotFound): + case errors.Is(err, store.ErrRecordNotFound): h.response.NotFound(c, "Webhook Not Found", err) return diff --git a/api/http/routes.go b/api/http/routes.go index 4761837..13c9a83 100644 --- a/api/http/routes.go +++ b/api/http/routes.go @@ -38,13 +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.CreateSecrets) - secret.POST("/force", h.ForceCreateSecrets) - secret.PATCH("", h.UpdateSecret) + secret.POST("", h.SetSecrets) secret.DELETE("/:secret", h.DeleteSecret) } } diff --git a/api/http/server.go b/api/http/server.go index 0e183ed..1f82f68 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -15,7 +15,7 @@ import ( "github.com/kunalvirwal/shogun-cd/internal/webhooks" ) -func StartAPIServer(logger utils.Logger, cfg *config.Config, w webhooks.WebhookService, store *store.Store, secrets secrets.Service) { +func StartAPIServer(logger utils.Logger, cfg *config.Config, w webhooks.WebhookService, store *store.Store, secrets secrets.SecretService) { r := newRouter() responder := response.NewResponder(cfg.Debug) diff --git a/cmd/main.go b/cmd/main.go index 6b4ac2b..b7542d9 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -49,7 +49,9 @@ func initServices() { logger.LogNewError("Unable to Initialise Encryption service : %v", err.Error()) return } - secretService := secrets.NewSecretService(encryption, *store, logger) + + // Initialize Secret Manager + secretService := secrets.NewSecretService(encryption, store, logger) // Initialize Git service gitService, err := git.NewGitService(logger, cfg) @@ -57,11 +59,8 @@ func initServices() { logger.LogNewError("Unable to initialize Git service: Stopping Shogun...") } - // Initialize Secret Manager - inMemorySecretService := secrets.NewInMemoryService() - // Initialize Pipeline service - pipelineService := pipeline.NewPipelineService(logger, gitService, inMemorySecretService) + pipelineService := pipeline.NewPipelineService(logger, gitService, secretService) // Initialize Target service targetService := target.NewTargetService(logger, gitService) @@ -70,7 +69,7 @@ func initServices() { orch.Start() // Initialize main application - app := app.NewApp(logger, gitService, pipelineService, targetService, inMemorySecretService) + app := app.NewApp(logger, gitService, pipelineService, targetService, secretService) _ = app diff --git a/go.mod b/go.mod index e6eea0f..5f0a1eb 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( ) 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 @@ -25,6 +26,7 @@ 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 @@ -48,9 +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/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 229e91c..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= @@ -47,6 +53,8 @@ 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= @@ -61,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= @@ -100,6 +109,8 @@ 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.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= @@ -111,6 +122,8 @@ 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= @@ -119,7 +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/app/app.go b/internal/app/app.go index 06543b7..9566f2f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -13,10 +13,10 @@ type App struct { GitService git.GitService PipelineService pipeline.PipelineService TargetService target.TargetService - SecretService secrets.InMemorySecretService + SecretService secrets.SecretService } -func NewApp(logger utils.Logger, gitService git.GitService, pipelineService pipeline.PipelineService, targetService target.TargetService, secretService secrets.InMemorySecretService) *App { +func NewApp(logger utils.Logger, gitService git.GitService, pipelineService pipeline.PipelineService, targetService target.TargetService, secretService secrets.SecretService) *App { return &App{ Logger: logger, GitService: gitService, diff --git a/internal/encryption/service.go b/internal/encryption/service.go index 227be02..4784533 100644 --- a/internal/encryption/service.go +++ b/internal/encryption/service.go @@ -11,7 +11,7 @@ import ( "github.com/kunalvirwal/shogun-cd/internal/utils" ) -type Service interface { +type EncryptionService interface { Encrypt(plaintext string) (string, error) Decrypt(ciphertext string) (string, error) } @@ -31,7 +31,7 @@ type service struct { logger utils.Logger } -func NewService(cfg *config.Config, l utils.Logger) (Service, error) { +func NewService(cfg *config.Config, l utils.Logger) (EncryptionService, error) { if err := deriveKey(cfg); err != nil { return nil, err } diff --git a/internal/models/user.go b/internal/models/user.go index 79d2b21..15e2adf 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -4,8 +4,6 @@ import ( "time" "github.com/google/uuid" - "github.com/kunalvirwal/shogun-cd/api/http/apiutils" - "gorm.io/gorm" ) // NOTE : the values of constants must be consistent to the "column" in gorm tags. @@ -37,11 +35,3 @@ type User struct { func (User) TableName() string { return UserTableName } - -// hook to insert default role during user creation -func (u *User) BeforeCreate(tx *gorm.DB) (err error) { - if u.Role == "" { - u.Role = string(apiutils.RoleUser) - } - return nil -} diff --git a/internal/pipeline/service.go b/internal/pipeline/service.go index 876551a..4a5f5fd 100644 --- a/internal/pipeline/service.go +++ b/internal/pipeline/service.go @@ -17,10 +17,10 @@ type PipelineService interface { type Service struct { logger utils.Logger gitService git.GitService - secretService secrets.InMemorySecretService + secretService secrets.SecretService } -func NewPipelineService(logger utils.Logger, gitService git.GitService, secretService secrets.InMemorySecretService) *Service { +func NewPipelineService(logger utils.Logger, gitService git.GitService, secretService secrets.SecretService) *Service { return &Service{ logger: logger, gitService: gitService, diff --git a/internal/pipeline/steps/deps.go b/internal/pipeline/steps/deps.go index 67cf5e9..3e9d875 100644 --- a/internal/pipeline/steps/deps.go +++ b/internal/pipeline/steps/deps.go @@ -17,7 +17,7 @@ type StepDeps struct { PipelineName string Logger utils.Logger GitService git.GitService - SecretService secrets.InMemorySecretService + SecretService secrets.SecretService Targets map[string]*target.Target HookValues map[string]string SSHManager sshclient.SSHManager 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/service.go b/internal/secrets/service.go index 7770ffe..ea5f5ec 100644 --- a/internal/secrets/service.go +++ b/internal/secrets/service.go @@ -2,6 +2,9 @@ package secrets import ( "context" + "fmt" + "regexp" + "strings" "github.com/kunalvirwal/shogun-cd/api/dto" "github.com/kunalvirwal/shogun-cd/internal/encryption" @@ -10,22 +13,21 @@ import ( "github.com/kunalvirwal/shogun-cd/internal/utils" ) -type Service interface { - Create(ctx context.Context, in []dto.SecretInput) error - Upsert(ctx context.Context, in []dto.SecretInput) error - Update(ctx context.Context, in *dto.SecretInput) error - Resolve(ctx context.Context, name string) (string, error) // provides decrypted "value" of the secret +type SecretService interface { + SetSecrets(ctx context.Context, in []dto.SecretInput) error FindMany(ctx context.Context, filter *dto.SecretFilter) ([]*dto.Secret, error) - Delete(ctx context.Context, name string) 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 } type service struct { - encryption encryption.Service + encryption encryption.EncryptionService store store.SecretStore logger utils.Logger } -func NewSecretService(e encryption.Service, s store.Store, l utils.Logger) Service { +func NewSecretService(e encryption.EncryptionService, s *store.Store, l utils.Logger) SecretService { return &service{ encryption: e, store: s.Secret, @@ -33,11 +35,12 @@ func NewSecretService(e encryption.Service, s store.Store, l utils.Logger) Servi } } -func (s *service) Create(ctx context.Context, in []dto.SecretInput) error { +// 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(in[k].Value) + encrypted, err := s.encryption.Encrypt(v.Value) if err != nil { return err } @@ -47,56 +50,50 @@ func (s *service) Create(ctx context.Context, in []dto.SecretInput) error { } } - return s.store.Create(ctx, secrets) + return s.store.Upsert(ctx, secrets) } -func (s *service) Upsert(ctx context.Context, in []dto.SecretInput) error { - secrets := make([]models.Secret, len(in)) - - for k, v := range in { - encrypted, err := s.encryption.Encrypt(in[k].Value) - if err != nil { - return err - } - secrets[k] = models.Secret{ - Name: v.Name, - Value: encrypted, - } +func (s *service) FetchSecret(ctx context.Context, name string) (string, error) { + secret, err := s.store.GetSecret(ctx, name) + if err != nil { + return "", err } - return s.store.Upsert(ctx, secrets) -} - -func (s *service) Update(ctx context.Context, in *dto.SecretInput) error { - encrypted, err := s.encryption.Encrypt(in.Value) + decrypted, err := s.encryption.Decrypt(secret) if err != nil { - return err + return "", fmt.Errorf("%w : %w", encryption.ErrDecryption, err) } - return s.store.Update(ctx, &models.Secret{ - Name: in.Name, - Value: encrypted, - }) + return decrypted, nil } -func (s *service) Resolve(ctx context.Context, name string) (string, error) { - secrets, err := s.store.GetSecret(ctx, name) - if err != nil { - return "", err - } +// 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_]*)\}\}`) - decrypted, err := s.encryption.Decrypt(secrets) - if err != nil { - return "", err + 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 + } + + output = strings.ReplaceAll(output, secretTag, decrypted) } - return decrypted, nil + 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) Delete(ctx context.Context, name string) error { +func (s *service) DeleteSecret(ctx context.Context, name string) error { return s.store.Delete(ctx, name) } diff --git a/internal/store/webhook.go b/internal/store/webhook.go index 4b5fb2a..8ec6d7e 100644 --- a/internal/store/webhook.go +++ b/internal/store/webhook.go @@ -18,7 +18,6 @@ type WebhookStore interface { var ( ErrSlugCollision = errors.New("Slug Collision") - ErrHookNotFound = errors.New("Webhook Not Found") ) type webhookStore struct { @@ -82,7 +81,7 @@ func (w *webhookStore) SetStatus(ctx context.Context, slug string, isActive bool return err } if rows == 0 { - return ErrHookNotFound + return ErrRecordNotFound } return nil diff --git a/internal/webhooks/service.go b/internal/webhooks/service.go index 0471950..fa5751b 100644 --- a/internal/webhooks/service.go +++ b/internal/webhooks/service.go @@ -49,12 +49,12 @@ type Service struct { registry map[string]*Webhook orchestrator orchestrator.Orchestrator store store.WebhookStore - encryption encryption.Service + encryption encryption.EncryptionService logger utils.Logger mu sync.RWMutex } -func NewWebhookService(l utils.Logger, o orchestrator.Orchestrator, s *store.Store, e encryption.Service) WebhookService { +func NewWebhookService(l utils.Logger, o orchestrator.Orchestrator, s *store.Store, e encryption.EncryptionService) WebhookService { svc := &Service{ registry: make(map[string]*Webhook), orchestrator: o, diff --git a/makefile b/makefile index 0d4fe26..1b38a68 100644 --- a/makefile +++ b/makefile @@ -1,5 +1,5 @@ DB_CONTAINER_NAME=shogun_db -DB_PORT=5434 +DB_PORT=5432 DB_USER=shogun DB_PASSWORD=pwd DB_NAME=shogun_db @@ -9,6 +9,8 @@ DB_NAME=shogun_db 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) \ @@ -19,8 +21,7 @@ db-run: db-down: @echo "Stopping database..." docker stop $(DB_CONTAINER_NAME) - docker rm $(DB_CONTAINER_NAME) - @echo "Database stopped and removed." + @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 bb1a327..b78c5f1 100644 --- a/sample.config.yaml +++ b/sample.config.yaml @@ -15,7 +15,7 @@ api: expiration_hours: 1 database: host: "hogwarts" - port: 5434 + port: 5432 user: "shogun" password: "pwd" dbname: "shogun_db"