From 7d9e5a37d6aa6e64440ba982fa9ca2d69198b6e3 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Mon, 22 Jun 2026 18:26:44 -0700 Subject: [PATCH] feat(google): group->Google-Group binding model and CRUD API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GroupGoogleBinding (1:1 Sentinel group <-> Google Group email) with AutoMigration, a service layer, and sentinel:all-gated CRUD endpoints (GET/POST/DELETE /google/group-bindings). No Google API calls yet — this is just the mapping table the reconcile engine will read. --- google/api/api.go | 4 ++ google/api/group_binding.go | 88 +++++++++++++++++++++++++++++++++ google/database/db.go | 6 ++- google/go.mod | 1 + google/go.sum | 2 + google/model/group_binding.go | 22 +++++++++ google/service/group_binding.go | 53 ++++++++++++++++++++ 7 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 google/api/group_binding.go create mode 100644 google/model/group_binding.go create mode 100644 google/service/group_binding.go diff --git a/google/api/api.go b/google/api/api.go index 3ed67e8..8568fef 100644 --- a/google/api/api.go +++ b/google/api/api.go @@ -37,6 +37,10 @@ func InitializeRouter() *gin.Engine { func InitializeRoutes(router *gin.Engine) { router.GET("/google/ping", Ping) + + router.GET("/google/group-bindings", ListGoogleBindings) + router.POST("/google/group-bindings", CreateGoogleBinding) + router.DELETE("/google/group-bindings/:bindingID", DeleteGoogleBinding) } // GetClientIP returns the originating client IP, preferring Cloudflare's diff --git a/google/api/group_binding.go b/google/api/group_binding.go new file mode 100644 index 0000000..6a58a36 --- /dev/null +++ b/google/api/group_binding.go @@ -0,0 +1,88 @@ +package api + +import ( + "errors" + "net/http" + "net/mail" + "strings" + + "github.com/gaucho-racing/sentinel/google/model" + "github.com/gaucho-racing/sentinel/google/service" + "github.com/gin-gonic/gin" +) + +// ListGoogleBindings returns all group→Google-Group bindings, optionally +// filtered to a single group_id. Used by the web UI and by reconciliation. +func ListGoogleBindings(c *gin.Context) { + Require(c, RequestTokenHasScope(c, "sentinel:all")) + + if groupID := c.Query("group_id"); groupID != "" { + binding, err := service.GetGoogleBindingForGroup(groupID) + if errors.Is(err, service.ErrBindingNotFound) { + c.JSON(http.StatusOK, []model.GroupGoogleBinding{}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, []model.GroupGoogleBinding{binding}) + return + } + + bindings, err := service.GetAllGoogleBindings() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, bindings) +} + +type createGoogleBindingRequest struct { + GroupID string `json:"group_id" binding:"required"` + GoogleGroupEmail string `json:"google_group_email" binding:"required"` +} + +func CreateGoogleBinding(c *gin.Context) { + Require(c, RequestTokenHasScope(c, "sentinel:all")) + + var req createGoogleBindingRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + email := strings.TrimSpace(req.GoogleGroupEmail) + if _, err := mail.ParseAddress(email); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "google_group_email must be a valid email address"}) + return + } + + binding, err := service.CreateGoogleBinding(model.GroupGoogleBinding{ + GroupID: req.GroupID, + GoogleGroupEmail: email, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, binding) +} + +// DeleteGoogleBinding removes a binding by ID. The group_id query param is +// required to scope the delete — protects against URL tampering that would +// otherwise let a caller delete a binding for a group they don't control. +func DeleteGoogleBinding(c *gin.Context) { + Require(c, RequestTokenHasScope(c, "sentinel:all")) + + bindingID := c.Param("bindingID") + groupID := c.Query("group_id") + if groupID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "group_id query param is required"}) + return + } + if err := service.DeleteGoogleBinding(groupID, bindingID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.Status(http.StatusNoContent) +} diff --git a/google/database/db.go b/google/database/db.go index 1a77abf..6c404f6 100644 --- a/google/database/db.go +++ b/google/database/db.go @@ -5,6 +5,7 @@ import ( "time" "github.com/gaucho-racing/sentinel/google/config" + "github.com/gaucho-racing/sentinel/google/model" "github.com/gaucho-racing/sentinel/google/pkg/logger" "gorm.io/driver/postgres" "gorm.io/gorm" @@ -28,7 +29,10 @@ func Init() { } } else { logger.SugarLogger.Infoln("Connected to database") - // Models and AutoMigration are added with the group-binding work. + db.AutoMigrate( + &model.GroupGoogleBinding{}, + ) + logger.SugarLogger.Infoln("AutoMigration complete") DB = db } } diff --git a/google/go.mod b/google/go.mod index ce72023..7534895 100644 --- a/google/go.mod +++ b/google/go.mod @@ -4,6 +4,7 @@ go 1.25.6 require ( github.com/fatih/color v1.19.0 + github.com/gaucho-racing/ulid-go v1.1.0 github.com/gin-contrib/cors v1.7.7 github.com/gin-gonic/gin v1.12.0 github.com/go-resty/resty/v2 v2.17.2 diff --git a/google/go.sum b/google/go.sum index 923c34c..54dc335 100644 --- a/google/go.sum +++ b/google/go.sum @@ -13,6 +13,8 @@ github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gaucho-racing/ulid-go v1.1.0 h1:x00XM8EjlegfhlLYIob+U8ba5iX0gDRUr8mgBsjCunk= +github.com/gaucho-racing/ulid-go v1.1.0/go.mod h1:HwqoC27UtvXHrmhTO7K2GnXZ1VAeR6tg6EjrSEP5JUU= github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q= github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= diff --git a/google/model/group_binding.go b/google/model/group_binding.go new file mode 100644 index 0000000..424fcc0 --- /dev/null +++ b/google/model/group_binding.go @@ -0,0 +1,22 @@ +package model + +import "time" + +// GroupGoogleBinding maps a Sentinel group to the Google Group its membership +// is mirrored into. The relationship is 1:1 — one Sentinel group projects onto +// one Google Group, and a given Google Group is driven by a single Sentinel +// group — so both columns are unique. +// +// Owned by the google service: bindings are an integration-side concept that +// reference Sentinel group IDs from core but live in google's domain. Sync is +// one-way (Sentinel -> Google); this row only records where to project. +type GroupGoogleBinding struct { + ID string `json:"id" gorm:"primaryKey"` + GroupID string `json:"group_id" gorm:"uniqueIndex"` + GoogleGroupEmail string `json:"google_group_email" gorm:"uniqueIndex"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` +} + +func (GroupGoogleBinding) TableName() string { + return "group_google_binding" +} diff --git a/google/service/group_binding.go b/google/service/group_binding.go new file mode 100644 index 0000000..d76bdf8 --- /dev/null +++ b/google/service/group_binding.go @@ -0,0 +1,53 @@ +package service + +import ( + "errors" + + "github.com/gaucho-racing/sentinel/google/database" + "github.com/gaucho-racing/sentinel/google/model" + "github.com/gaucho-racing/ulid-go" + "gorm.io/gorm" +) + +var ErrBindingNotFound = errors.New("group google binding not found") + +func GetAllGoogleBindings() ([]model.GroupGoogleBinding, error) { + bindings := []model.GroupGoogleBinding{} + if err := database.DB.Find(&bindings).Error; err != nil { + return []model.GroupGoogleBinding{}, err + } + return bindings, nil +} + +// GetGoogleBindingForGroup returns the binding for a group, or +// ErrBindingNotFound if the group has none. The 1:1 model means at most one +// row per group. +func GetGoogleBindingForGroup(groupID string) (model.GroupGoogleBinding, error) { + var binding model.GroupGoogleBinding + if err := database.DB.Where("group_id = ?", groupID).First(&binding).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.GroupGoogleBinding{}, ErrBindingNotFound + } + return model.GroupGoogleBinding{}, err + } + return binding, nil +} + +func CreateGoogleBinding(binding model.GroupGoogleBinding) (model.GroupGoogleBinding, error) { + if binding.ID == "" { + binding.ID = ulid.Make().Prefixed("ggb") + } + if err := database.DB.Create(&binding).Error; err != nil { + return model.GroupGoogleBinding{}, err + } + return binding, nil +} + +// DeleteGoogleBinding scopes the delete to (groupID, bindingID) so a tampered +// request can't drop a binding for a different group. +func DeleteGoogleBinding(groupID, bindingID string) error { + if err := database.DB.Where("group_id = ? AND id = ?", groupID, bindingID).Delete(&model.GroupGoogleBinding{}).Error; err != nil { + return err + } + return nil +}