Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions google/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 88 additions & 0 deletions google/api/group_binding.go
Original file line number Diff line number Diff line change
@@ -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)
}
6 changes: 5 additions & 1 deletion google/database/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
}
}
1 change: 1 addition & 0 deletions google/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions google/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
22 changes: 22 additions & 0 deletions google/model/group_binding.go
Original file line number Diff line number Diff line change
@@ -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"
}
53 changes: 53 additions & 0 deletions google/service/group_binding.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading