diff --git a/google/api/group_binding.go b/google/api/group_binding.go index 6671172..2746d30 100644 --- a/google/api/group_binding.go +++ b/google/api/group_binding.go @@ -58,9 +58,9 @@ func CreateGoogleBinding(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "google_group_email must be a valid email address"}) return } - ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Minute) + ctx, cancel := context.WithTimeout(c.Request.Context(), 25*time.Second) defer cancel() - binding, preflight, err := service.ApplyGoogleBinding( + binding, preflight, err := service.QueueGoogleBinding( ctx, req.GroupID, email, @@ -73,7 +73,7 @@ func CreateGoogleBinding(c *gin.Context) { writeGoogleBindingError(c, err, preflight) return } - c.JSON(http.StatusOK, binding) + writeGoogleBindingQueued(c, binding) } type googleBindingPreflightRequest struct { @@ -93,7 +93,7 @@ func PreflightGoogleBinding(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "google_group_email must be a valid email address"}) return } - ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 25*time.Second) defer cancel() preflight, err := service.PreflightGoogleBinding(ctx, req.GroupID, email) if err != nil { @@ -123,9 +123,9 @@ func ApplyGoogleBinding(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "google_group_email must be a valid email address"}) return } - ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Minute) + ctx, cancel := context.WithTimeout(c.Request.Context(), 25*time.Second) defer cancel() - binding, preflight, err := service.ApplyGoogleBinding( + binding, preflight, err := service.QueueGoogleBinding( ctx, req.GroupID, email, @@ -139,7 +139,7 @@ func ApplyGoogleBinding(c *gin.Context) { writeGoogleBindingError(c, err, preflight) return } - c.JSON(http.StatusOK, gin.H{"binding": binding}) + writeGoogleBindingQueued(c, binding) } func normalizeGoogleGroupEmail(value string, allowEmpty bool) (string, error) { @@ -163,6 +163,8 @@ func writeGoogleBindingError(c *gin.Context, err error, preflight service.Google c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "preflight": preflight}) case errors.As(err, &alreadyBoundErr): c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrBindingSyncInProgress): + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) case errors.Is(err, service.ErrGoogleSyncUnavailable): c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) default: @@ -170,6 +172,19 @@ func writeGoogleBindingError(c *gin.Context, err error, preflight service.Google } } +func writeGoogleBindingQueued(c *gin.Context, binding *model.GroupGoogleBinding) { + if binding == nil || binding.OperationID == "" { + c.JSON(http.StatusOK, gin.H{"binding": binding, "status": "unchanged"}) + return + } + c.JSON(http.StatusAccepted, gin.H{ + "binding": binding, + "message": "google group sync queued", + "operation_id": binding.OperationID, + "status": "queued", + }) +} + // 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. @@ -181,9 +196,9 @@ func DeleteGoogleBinding(c *gin.Context) { return } Require(c, RequestTokenHasAdminAccess(c)) - ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Minute) + ctx, cancel := context.WithTimeout(c.Request.Context(), 25*time.Second) defer cancel() - _, preflight, err := service.ApplyGoogleBinding( + binding, preflight, err := service.QueueGoogleBinding( ctx, groupID, "", @@ -196,5 +211,5 @@ func DeleteGoogleBinding(c *gin.Context) { writeGoogleBindingError(c, err, preflight) return } - c.Status(http.StatusNoContent) + writeGoogleBindingQueued(c, binding) } diff --git a/google/model/group_binding.go b/google/model/group_binding.go index 424fcc0..978deab 100644 --- a/google/model/group_binding.go +++ b/google/model/group_binding.go @@ -11,10 +11,19 @@ import "time" // 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"` + ID string `json:"id" gorm:"primaryKey"` + GroupID string `json:"group_id" gorm:"uniqueIndex"` + GoogleGroupEmail string `json:"google_group_email" gorm:"uniqueIndex"` + Status string `json:"status" gorm:"default:active;index"` + OperationID string `json:"operation_id,omitempty" gorm:"index"` + PreviousGoogleGroupEmail string `json:"-"` + PreviousGoogleGroupID string `json:"-"` + TargetGoogleGroupID string `json:"-"` + DeleteRequested bool `json:"-"` + AllowBulkRemovals bool `json:"-"` + LastSyncError string `json:"last_sync_error,omitempty"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` } func (GroupGoogleBinding) TableName() string { diff --git a/google/service/group_binding.go b/google/service/group_binding.go index 2684648..640f1e2 100644 --- a/google/service/group_binding.go +++ b/google/service/group_binding.go @@ -9,6 +9,7 @@ import ( "github.com/gaucho-racing/sentinel/google/database" "github.com/gaucho-racing/sentinel/google/model" + "github.com/gaucho-racing/sentinel/google/pkg/logger" "github.com/gaucho-racing/sentinel/google/pkg/sentinel" "github.com/gaucho-racing/ulid-go" "gorm.io/gorm" @@ -16,8 +17,12 @@ import ( var ErrBindingNotFound = errors.New("group google binding not found") var ErrGoogleSyncUnavailable = errors.New("google group management is not configured") +var ErrBindingSyncInProgress = errors.New("google group binding sync is already in progress") +var ErrGoogleGroupStateChanged = errors.New("google group changed after preflight") const ManagedGoogleGroupOwnerEmail = "team@gauchoracing.com" +const BindingStatusActive = "active" +const BindingStatusPending = "pending" type GoogleGroupMemberSnapshot struct { Email string `json:"email"` @@ -107,24 +112,6 @@ func getGoogleBindingForEmail(googleGroupEmail string) (model.GroupGoogleBinding 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 -} - -func updateGoogleBinding(binding model.GroupGoogleBinding, googleGroupEmail string) (model.GroupGoogleBinding, error) { - binding.GoogleGroupEmail = googleGroupEmail - if err := database.DB.Save(&binding).Error; err != nil { - return model.GroupGoogleBinding{}, err - } - return binding, nil -} - func inspectGoogleGroup(ctx context.Context, groupEmail string) (*GoogleGroupSnapshot, error) { group, exists, err := getGoogleGroup(ctx, groupEmail) if err != nil { @@ -230,7 +217,7 @@ func getCoreGroup(groupID string) (coreGroup, error) { return group, nil } -func ApplyGoogleBinding( +func QueueGoogleBinding( ctx context.Context, groupID string, requestedEmail string, @@ -244,6 +231,9 @@ func ApplyGoogleBinding( if err != nil { return nil, GoogleBindingPreflight{}, err } + if preflight.CurrentBinding != nil && preflight.CurrentBinding.Status == BindingStatusPending { + return nil, preflight, ErrBindingSyncInProgress + } currentBindingID := "" if preflight.CurrentBinding != nil { currentBindingID = preflight.CurrentBinding.ID @@ -260,74 +250,216 @@ func ApplyGoogleBinding( } canonicalRequestedEmail := preflight.RequestedEmail - createdRequestedGroup := false if preflight.RequestedGroup != nil { if preflight.RequestedGroup.Exists { canonicalRequestedEmail = preflight.RequestedGroup.Email - } else { - group, err := getCoreGroup(groupID) - if err != nil { - return nil, preflight, fmt.Errorf("load sentinel group: %w", err) - } - created, err := createGoogleGroup(ctx, preflight.RequestedEmail, group.Name) - if err != nil { - return nil, preflight, err - } - createdRequestedGroup = true - canonicalRequestedEmail = strings.ToLower(created.Email) } - candidate := model.GroupGoogleBinding{ - GroupID: groupID, - GoogleGroupEmail: canonicalRequestedEmail, + } + operationID := ulid.Make().Prefixed("gbo") + previousGoogleGroupEmail := "" + previousGoogleGroupID := "" + if preflight.RequiredConfirmation.DeletePreviousGroup { + previousGoogleGroupEmail = preflight.PreviousGroup.Email + previousGoogleGroupID = preflight.PreviousGroup.ID + } + targetGoogleGroupID := "" + if preflight.RequestedGroup != nil && preflight.RequestedGroup.Exists { + targetGoogleGroupID = preflight.RequestedGroup.ID + } + + var binding model.GroupGoogleBinding + if preflight.CurrentBinding != nil { + binding = *preflight.CurrentBinding + updates := map[string]any{ + "status": BindingStatusPending, + "operation_id": operationID, + "previous_google_group_email": previousGoogleGroupEmail, + "previous_google_group_id": previousGoogleGroupID, + "target_google_group_id": targetGoogleGroupID, + "delete_requested": canonicalRequestedEmail == "", + "allow_bulk_removals": confirmations.OverwriteRequestedGroupMembers, + "last_sync_error": "", + } + if canonicalRequestedEmail != "" { + updates["google_group_email"] = canonicalRequestedEmail + } + result := database.DB.Model(&model.GroupGoogleBinding{}). + Where("id = ? AND group_id = ? AND status = ?", binding.ID, groupID, BindingStatusActive). + Updates(updates) + if result.Error != nil { + return nil, preflight, result.Error + } + if result.RowsAffected != 1 { + return nil, preflight, &BindingStateChangedError{Preflight: preflight} + } + if err := database.DB.First(&binding, "id = ?", binding.ID).Error; err != nil { + return nil, preflight, err + } + } else { + if canonicalRequestedEmail == "" { + return nil, preflight, nil + } + binding = model.GroupGoogleBinding{ + ID: ulid.Make().Prefixed("ggb"), + GroupID: groupID, + GoogleGroupEmail: canonicalRequestedEmail, + Status: BindingStatusPending, + OperationID: operationID, + PreviousGoogleGroupEmail: previousGoogleGroupEmail, + PreviousGoogleGroupID: previousGoogleGroupID, + TargetGoogleGroupID: targetGoogleGroupID, + AllowBulkRemovals: confirmations.OverwriteRequestedGroupMembers, } - if err := reconcileBinding( - ctx, - candidate, - confirmations.OverwriteRequestedGroupMembers, - ); err != nil { - if createdRequestedGroup { - _ = deleteGoogleGroup(ctx, canonicalRequestedEmail) - } + if err := database.DB.Create(&binding).Error; err != nil { return nil, preflight, err } } + logger.SugarLogger.Infof( + "google binding: queued operation=%s group=%s google=%s delete=%t", + binding.OperationID, + binding.GroupID, + binding.GoogleGroupEmail, + binding.DeleteRequested, + ) + TriggerReconcile() + return &binding, preflight, nil +} - if preflight.RequiredConfirmation.DeletePreviousGroup { - if err := deleteGoogleGroup(ctx, preflight.PreviousGroup.Email); err != nil { - return nil, preflight, err +func applyPendingGoogleBinding(ctx context.Context, binding model.GroupGoogleBinding) error { + if binding.Status != BindingStatusPending || binding.OperationID == "" { + return nil + } + if binding.DeleteRequested { + groupKey := binding.PreviousGoogleGroupID + if groupKey == "" { + groupKey = binding.GoogleGroupEmail + } + if err := deleteGoogleGroup(ctx, groupKey); err != nil { + return err } + result := database.DB.Where( + "id = ? AND operation_id = ? AND status = ?", + binding.ID, + binding.OperationID, + BindingStatusPending, + ).Delete(&model.GroupGoogleBinding{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrBindingSyncInProgress + } + return nil } - if canonicalRequestedEmail == "" { - if preflight.CurrentBinding != nil { - if err := DeleteGoogleBinding(groupID, preflight.CurrentBinding.ID); err != nil { - return nil, preflight, err - } + group, exists, err := getGoogleGroup(ctx, binding.GoogleGroupEmail) + if err != nil { + return err + } + if exists && binding.TargetGoogleGroupID != "" && group.Id != binding.TargetGoogleGroupID { + return ErrGoogleGroupStateChanged + } + if !exists { + if binding.TargetGoogleGroupID != "" { + return ErrGoogleGroupStateChanged + } + coreGroup, err := getCoreGroup(binding.GroupID) + if err != nil { + return fmt.Errorf("load sentinel group: %w", err) + } + group, err = createGoogleGroup(ctx, binding.GoogleGroupEmail, coreGroup.Name) + if err != nil { + return err + } + binding.TargetGoogleGroupID = group.Id + result := database.DB.Model(&model.GroupGoogleBinding{}). + Where( + "id = ? AND operation_id = ? AND status = ?", + binding.ID, + binding.OperationID, + BindingStatusPending, + ). + Update("target_google_group_id", binding.TargetGoogleGroupID) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrBindingSyncInProgress } - return nil, preflight, nil } - if preflight.CurrentBinding != nil { - binding, err := updateGoogleBinding(*preflight.CurrentBinding, canonicalRequestedEmail) + + if err := reconcileBinding(ctx, binding, binding.AllowBulkRemovals); err != nil { + return err + } + if binding.PreviousGoogleGroupEmail != "" && !strings.EqualFold(binding.PreviousGoogleGroupEmail, binding.GoogleGroupEmail) { + current, err := pendingGoogleBinding(binding.ID, binding.OperationID) if err != nil { - return nil, preflight, err + return err + } + if !current { + return ErrBindingSyncInProgress + } + groupKey := binding.PreviousGoogleGroupID + if groupKey == "" { + groupKey = binding.PreviousGoogleGroupEmail + } + if err := deleteGoogleGroup(ctx, groupKey); err != nil { + return err } - return &binding, preflight, nil } - binding, err := CreateGoogleBinding(model.GroupGoogleBinding{ - GroupID: groupID, - GoogleGroupEmail: canonicalRequestedEmail, - }) - if err != nil { - return nil, preflight, err + + result := database.DB.Model(&model.GroupGoogleBinding{}). + Where( + "id = ? AND operation_id = ? AND status = ?", + binding.ID, + binding.OperationID, + BindingStatusPending, + ). + Updates(map[string]any{ + "status": BindingStatusActive, + "operation_id": "", + "previous_google_group_email": "", + "previous_google_group_id": "", + "target_google_group_id": "", + "delete_requested": false, + "allow_bulk_removals": false, + "last_sync_error": "", + }) + if result.Error != nil { + return result.Error } - return &binding, preflight, nil + if result.RowsAffected != 1 { + return ErrBindingSyncInProgress + } + return 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 +func pendingGoogleBinding(bindingID string, operationID string) (bool, error) { + var count int64 + err := database.DB.Model(&model.GroupGoogleBinding{}). + Where( + "id = ? AND operation_id = ? AND status = ?", + bindingID, + operationID, + BindingStatusPending, + ). + Count(&count).Error + return count == 1, err +} + +func recordGoogleBindingSyncError(binding model.GroupGoogleBinding, syncErr error) { + message := syncErr.Error() + if len(message) > 4096 { + message = message[:4096] + } + if err := database.DB.Model(&model.GroupGoogleBinding{}). + Where( + "id = ? AND operation_id = ? AND status = ?", + binding.ID, + binding.OperationID, + BindingStatusPending, + ). + Update("last_sync_error", message).Error; err != nil { + logger.SugarLogger.Errorf("google sync: record pending binding error: %v", err) } - return nil } diff --git a/google/service/group_sync.go b/google/service/group_sync.go index e8d1748..4b2680b 100644 --- a/google/service/group_sync.go +++ b/google/service/group_sync.go @@ -96,6 +96,7 @@ func reconcileBinding(ctx context.Context, b model.GroupGoogleBinding, allowBulk } } + var operationErrors []error for email := range desired { if err := ctx.Err(); err != nil { return err @@ -105,6 +106,7 @@ func reconcileBinding(ctx context.Context, b model.GroupGoogleBinding, allowBulk } if err := insertMember(ctx, b.GoogleGroupEmail, email); err != nil { logger.SugarLogger.Errorf("google sync: %v", err) + operationErrors = append(operationErrors, err) continue } logger.SugarLogger.Infof("google sync: added %s to %s", email, b.GoogleGroupEmail) @@ -127,11 +129,12 @@ func reconcileBinding(ctx context.Context, b model.GroupGoogleBinding, allowBulk } if err := deleteMember(ctx, b.GoogleGroupEmail, email); err != nil { logger.SugarLogger.Errorf("google sync: %v", err) + operationErrors = append(operationErrors, err) continue } logger.SugarLogger.Infof("google sync: removed %s from %s", email, b.GoogleGroupEmail) } - return nil + return errors.Join(operationErrors...) } // ReconcileAll reconciles every binding. A failure on one binding is logged and @@ -145,11 +148,23 @@ func ReconcileAll(ctx context.Context) error { if err := ctx.Err(); err != nil { return err } - if err := reconcileBinding(ctx, b, false); err != nil { - if errors.Is(err, context.Canceled) { - return err + var reconcileErr error + if b.Status == BindingStatusPending { + logger.SugarLogger.Infof("google binding: starting operation=%s group=%s", b.OperationID, b.GroupID) + reconcileErr = applyPendingGoogleBinding(ctx, b) + } else { + reconcileErr = reconcileBinding(ctx, b, false) + } + if reconcileErr != nil { + if b.Status == BindingStatusPending { + recordGoogleBindingSyncError(b, reconcileErr) } - logger.SugarLogger.Errorf("google sync: reconcile failed for group=%s google=%s: %v", b.GroupID, b.GoogleGroupEmail, err) + if errors.Is(reconcileErr, context.Canceled) { + return reconcileErr + } + logger.SugarLogger.Errorf("google sync: reconcile failed for group=%s google=%s: %v", b.GroupID, b.GoogleGroupEmail, reconcileErr) + } else if b.Status == BindingStatusPending { + logger.SugarLogger.Infof("google binding: completed operation=%s group=%s", b.OperationID, b.GroupID) } } return nil @@ -204,4 +219,5 @@ func StartReconcileCron() { runSweep() } }() + runSweep() } diff --git a/web/src/lib/google.ts b/web/src/lib/google.ts index 090ab8e..d2f4a4d 100644 --- a/web/src/lib/google.ts +++ b/web/src/lib/google.ts @@ -8,7 +8,11 @@ export type GroupGoogleBinding = { id: string group_id: string google_group_email: string + status: "active" | "pending" + operation_id?: string + last_sync_error?: string created_at: string + updated_at: string } export type GoogleGroupMemberSnapshot = { @@ -60,7 +64,11 @@ export async function applyGoogleBinding( expectedCurrentBindingID: string, confirmations: GoogleBindingConfirmations, ) { - const res = await api.put<{ binding: GroupGoogleBinding | null }>( + const res = await api.put<{ + binding: GroupGoogleBinding | null + operation_id?: string + status: "queued" | "unchanged" + }>( "/google/group-bindings", { group_id: groupID, @@ -71,7 +79,7 @@ export async function applyGoogleBinding( confirm_delete_previous_group: confirmations.delete_previous_group, }, ) - return res.data.binding + return res.data } // useGroupGoogleBinding returns the single binding for a group, or null. The diff --git a/web/src/pages/groups/GroupEditPage.tsx b/web/src/pages/groups/GroupEditPage.tsx index 51862ca..15a50ab 100644 --- a/web/src/pages/groups/GroupEditPage.tsx +++ b/web/src/pages/groups/GroupEditPage.tsx @@ -732,7 +732,11 @@ export default function GroupEditPage() { qc.invalidateQueries({ queryKey: ["group", id, "applications"] }) setGoogleConfirmOpen(false) setGooglePreflight(null) - toast.success("Group updated") + toast.success( + preflight?.binding_changed + ? "Group updated. Google sync started." + : "Group updated", + ) navigate(`/groups/${id}`) } catch (err: unknown) { const response = (