From ebb824622cd59a17fc2b0103ec7371bcaed94a70 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Mon, 22 Jun 2026 19:03:16 -0700 Subject: [PATCH 1/5] feat(google): Directory API client and reconcile engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Admin SDK Directory client (service account + domain-wide delegation) and the one-way reconcile engine: per binding, mirror the Sentinel group's member emails into the Google Group as role MEMBER. The role=MEMBER set is the sync's authoritative state — OWNER/MANAGER (manually added) are never touched. Includes a cron (GOOGLE_SYNC_INTERVAL), a manual POST /google/reconcile trigger, idempotent add/remove (409/404 tolerated), and a GOOGLE_SYNC_MAX_REMOVALS guard against draining a group. Google sync is disabled (no-op) when GOOGLE_SERVICE_ACCOUNT / GOOGLE_ADMIN_SUBJECT are unset, so the service still boots and serves binding CRUD. --- docker-compose.yml | 2 + example.env | 7 ++ google/api/api.go | 2 + google/api/reconcile.go | 16 +++ google/config/config.go | 54 ++++++++++ google/go.mod | 34 ++++-- google/go.sum | 81 +++++++++++--- google/main.go | 6 ++ google/service/google.go | 96 +++++++++++++++++ google/service/group_sync.go | 203 +++++++++++++++++++++++++++++++++++ 10 files changed, 480 insertions(+), 21 deletions(-) create mode 100644 google/api/reconcile.go create mode 100644 google/service/google.go create mode 100644 google/service/group_sync.go diff --git a/docker-compose.yml b/docker-compose.yml index 53332e0..ff2dd9f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -151,6 +151,8 @@ services: KERBECS_USER: admin KERBECS_PASSWORD: admin INTERNAL_BOOTSTRAP_SECRET: ${INTERNAL_BOOTSTRAP_SECRET} + GOOGLE_SERVICE_ACCOUNT: ${GOOGLE_SERVICE_ACCOUNT} + GOOGLE_ADMIN_SUBJECT: ${GOOGLE_ADMIN_SUBJECT} web: container_name: sentinel-web diff --git a/example.env b/example.env index 910fa78..32e8d31 100644 --- a/example.env +++ b/example.env @@ -26,6 +26,13 @@ INTERNAL_BOOTSTRAP_SECRET="" # disable the override. TEAM_GOOGLE_CLIENT_ID="" +# Google sync (sentinel-google service). Service-account JSON key with +# domain-wide delegation for the admin.directory.group.member scope, and the +# super-admin it impersonates. Leave empty to disable group syncing — the +# service still boots and serves binding CRUD. +GOOGLE_SERVICE_ACCOUNT="" +GOOGLE_ADMIN_SUBJECT="" + DRIVE_SERVICE_ACCOUNT="" RSA_PUBLIC_KEY="" diff --git a/google/api/api.go b/google/api/api.go index 8568fef..87ebb91 100644 --- a/google/api/api.go +++ b/google/api/api.go @@ -41,6 +41,8 @@ func InitializeRoutes(router *gin.Engine) { router.GET("/google/group-bindings", ListGoogleBindings) router.POST("/google/group-bindings", CreateGoogleBinding) router.DELETE("/google/group-bindings/:bindingID", DeleteGoogleBinding) + + router.POST("/google/reconcile", TriggerReconcile) } // GetClientIP returns the originating client IP, preferring Cloudflare's diff --git a/google/api/reconcile.go b/google/api/reconcile.go new file mode 100644 index 0000000..055c8e5 --- /dev/null +++ b/google/api/reconcile.go @@ -0,0 +1,16 @@ +package api + +import ( + "net/http" + + "github.com/gaucho-racing/sentinel/google/service" + "github.com/gin-gonic/gin" +) + +// TriggerReconcile kicks a full reconcile sweep in the background. Useful for +// ops and for applying a binding change without waiting for the cron. +func TriggerReconcile(c *gin.Context) { + Require(c, RequestTokenHasScope(c, "sentinel:all")) + service.TriggerReconcile() + c.JSON(http.StatusAccepted, gin.H{"message": "reconcile triggered"}) +} diff --git a/google/config/config.go b/google/config/config.go index a193cf3..482e71d 100644 --- a/google/config/config.go +++ b/google/config/config.go @@ -2,6 +2,8 @@ package config import ( "os" + "strconv" + "time" ) const Name = "sentinel-google" @@ -37,6 +39,58 @@ var InternalBootstrapSecret = os.Getenv("INTERNAL_BOOTSTRAP_SECRET") // core/jobs/init.go::InternalServiceAccountNames. const InternalServiceName = "sentinel-google" +// GoogleServiceAccount is the JSON key for the service account used to call the +// Admin SDK Directory API. It must have domain-wide delegation granted for the +// admin.directory.group.member scope. When empty, Google sync is disabled and +// the service runs as a no-op (binding CRUD still works). +var GoogleServiceAccount = os.Getenv("GOOGLE_SERVICE_ACCOUNT") + +// GoogleAdminSubject is the super-admin user the service account impersonates +// (domain-wide delegation requires a subject). Required when GoogleServiceAccount +// is set. +var GoogleAdminSubject = os.Getenv("GOOGLE_ADMIN_SUBJECT") + +// GoogleSyncInterval is how often the reconcile cron fires. Parsed once at +// startup; an unparseable or unset value falls back to 1h. Set to 0 (or any +// non-positive duration) to disable the cron. +var GoogleSyncInterval = parseDurationOr("GOOGLE_SYNC_INTERVAL", time.Hour) + +// GoogleSyncMaxRemovals caps how many members a single per-group reconcile may +// delete. If a run wants to remove more than this, it skips the removals for +// that group and logs loudly — a guard against draining a group when core +// returns an empty/partial member set (e.g. mid-outage). +var GoogleSyncMaxRemovals = parseIntOr("GOOGLE_SYNC_MAX_REMOVALS", 25) + +func parseDurationOr(envKey string, fallback time.Duration) time.Duration { + raw := os.Getenv(envKey) + if raw == "" { + return fallback + } + d, err := time.ParseDuration(raw) + if err != nil { + return fallback + } + return d +} + +func parseIntOr(envKey string, fallback int) int { + raw := os.Getenv(envKey) + if raw == "" { + return fallback + } + n, err := strconv.Atoi(raw) + if err != nil { + return fallback + } + return n +} + func IsProduction() bool { return Env == "PROD" } + +// GoogleSyncEnabled reports whether the service has the credentials needed to +// talk to Google. When false, the reconcile engine no-ops. +func GoogleSyncEnabled() bool { + return GoogleServiceAccount != "" && GoogleAdminSubject != "" +} diff --git a/google/go.mod b/google/go.mod index 7534895..926aa5a 100644 --- a/google/go.mod +++ b/google/go.mod @@ -1,6 +1,6 @@ module github.com/gaucho-racing/sentinel/google -go 1.25.6 +go 1.25.8 require ( github.com/fatih/color v1.19.0 @@ -9,22 +9,35 @@ require ( github.com/gin-gonic/gin v1.12.0 github.com/go-resty/resty/v2 v2.17.2 go.uber.org/zap v1.28.0 + golang.org/x/oauth2 v0.36.0 + google.golang.org/api v0.286.0 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 ) require ( + cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect 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/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // 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 @@ -44,12 +57,19 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.23.0 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.51.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect - google.golang.org/protobuf v1.36.10 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/google/go.sum b/google/go.sum index 54dc335..9a69591 100644 --- a/google/go.sum +++ b/google/go.sum @@ -1,9 +1,17 @@ +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= 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.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -11,6 +19,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 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= @@ -21,6 +31,11 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -35,9 +50,19 @@ 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.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 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/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +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/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= 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= @@ -91,6 +116,20 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= @@ -103,21 +142,35 @@ 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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.286.0 h1:TdTXMvzYKnWV1/lPbCdbXRqBrkDqjPto22H2xeZZ8LI= +google.golang.org/api v0.286.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI= +google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad h1:45WmJvIV6C2+O/jjLkPUH+F3aOj/1miDoU2DD0+NWbg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +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= 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= diff --git a/google/main.go b/google/main.go index 9fab0cb..8f18750 100644 --- a/google/main.go +++ b/google/main.go @@ -7,6 +7,7 @@ import ( "github.com/gaucho-racing/sentinel/google/pkg/kerbecs" "github.com/gaucho-racing/sentinel/google/pkg/logger" "github.com/gaucho-racing/sentinel/google/pkg/sentinel" + "github.com/gaucho-racing/sentinel/google/service" ) func main() { @@ -26,5 +27,10 @@ func main() { database.Init() + if err := service.InitGoogleClient(); err != nil { + logger.SugarLogger.Fatalf("Failed to initialize Google client: %v", err) + } + service.StartReconcileCron() + api.Run() } diff --git a/google/service/google.go b/google/service/google.go new file mode 100644 index 0000000..14b9a21 --- /dev/null +++ b/google/service/google.go @@ -0,0 +1,96 @@ +package service + +import ( + "context" + "errors" + "fmt" + + "github.com/gaucho-racing/sentinel/google/config" + "github.com/gaucho-racing/sentinel/google/pkg/logger" + "golang.org/x/oauth2/google" + directory "google.golang.org/api/admin/directory/v1" + "google.golang.org/api/googleapi" + "google.golang.org/api/option" +) + +// directorySvc is the Admin SDK Directory client, built once at startup from +// the service-account key with domain-wide delegation. nil when Google sync is +// disabled (no credentials configured). +var directorySvc *directory.Service + +// memberEntry is a Google Group member reduced to the fields reconcile needs. +type memberEntry struct { + Email string + Role string // OWNER | MANAGER | MEMBER +} + +// InitGoogleClient builds the Directory client from GOOGLE_SERVICE_ACCOUNT, +// impersonating GOOGLE_ADMIN_SUBJECT (domain-wide delegation). A no-op when +// sync is disabled, so the service still boots and serves binding CRUD without +// Google credentials. +func InitGoogleClient() error { + if !config.GoogleSyncEnabled() { + logger.SugarLogger.Warnln("google sync disabled: GOOGLE_SERVICE_ACCOUNT / GOOGLE_ADMIN_SUBJECT not set") + return nil + } + jwtConfig, err := google.JWTConfigFromJSON([]byte(config.GoogleServiceAccount), directory.AdminDirectoryGroupMemberScope) + if err != nil { + return fmt.Errorf("parse google service account: %w", err) + } + jwtConfig.Subject = config.GoogleAdminSubject + + ctx := context.Background() + svc, err := directory.NewService(ctx, option.WithHTTPClient(jwtConfig.Client(ctx))) + if err != nil { + return fmt.Errorf("init directory service: %w", err) + } + directorySvc = svc + logger.SugarLogger.Infof("google sync enabled, impersonating %s", config.GoogleAdminSubject) + return nil +} + +// listGroupMembers returns every member of the Google Group, paginated. +func listGroupMembers(ctx context.Context, groupEmail string) ([]memberEntry, error) { + var members []memberEntry + err := directorySvc.Members.List(groupEmail).Context(ctx).Pages(ctx, func(page *directory.Members) error { + for _, m := range page.Members { + members = append(members, memberEntry{Email: m.Email, Role: m.Role}) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("list members of %s: %w", groupEmail, err) + } + return members, nil +} + +// insertMember adds email to the Google Group as a plain MEMBER. A 409 +// (already a member) is treated as success — reconcile is idempotent. +func insertMember(ctx context.Context, groupEmail, email string) error { + _, err := directorySvc.Members.Insert(groupEmail, &directory.Member{Email: email, Role: "MEMBER"}).Context(ctx).Do() + if err != nil { + if isStatus(err, 409) { + return nil + } + return fmt.Errorf("insert %s into %s: %w", email, groupEmail, err) + } + return nil +} + +// deleteMember removes email from the Google Group. A 404 (not a member) is +// treated as success. +func deleteMember(ctx context.Context, groupEmail, email string) error { + err := directorySvc.Members.Delete(groupEmail, email).Context(ctx).Do() + if err != nil { + if isStatus(err, 404) { + return nil + } + return fmt.Errorf("delete %s from %s: %w", email, groupEmail, err) + } + return nil +} + +func isStatus(err error, code int) bool { + var gerr *googleapi.Error + return errors.As(err, &gerr) && gerr.Code == code +} diff --git a/google/service/group_sync.go b/google/service/group_sync.go new file mode 100644 index 0000000..c9c74de --- /dev/null +++ b/google/service/group_sync.go @@ -0,0 +1,203 @@ +package service + +import ( + "context" + "fmt" + "strings" + "sync/atomic" + "time" + + "github.com/gaucho-racing/sentinel/google/config" + "github.com/gaucho-racing/sentinel/google/model" + "github.com/gaucho-racing/sentinel/google/pkg/logger" + "github.com/gaucho-racing/sentinel/google/pkg/sentinel" +) + +// coreGroupMember mirrors the fields of core's GroupMember we need. +type coreGroupMember struct { + EntityID string `json:"entity_id"` + Source string `json:"source"` +} + +// coreEntity mirrors the entity fields needed to resolve a member's email. +type coreEntity struct { + EmailAuth struct { + Email string `json:"email"` + } `json:"email_auth"` + User *struct { + Email string `json:"email"` + } `json:"user"` +} + +func getGroupMembers(groupID string) ([]coreGroupMember, error) { + var rows []coreGroupMember + if err := sentinel.Get("/api/groups/"+groupID+"/members", &rows); err != nil { + return nil, err + } + return rows, nil +} + +// resolveEntityEmail returns the entity's login email (email_auth, falling back +// to the user profile email). Empty string when the entity has no email — e.g. +// a service account — which the caller skips. +func resolveEntityEmail(entityID string) (string, error) { + var e coreEntity + if err := sentinel.Get("/api/core/entity/"+entityID, &e); err != nil { + return "", err + } + if e.EmailAuth.Email != "" { + return e.EmailAuth.Email, nil + } + if e.User != nil { + return e.User.Email, nil + } + return "", nil +} + +// reconcileBinding brings one Google Group's MEMBER-role membership into +// agreement with its Sentinel group. The Google Group's role=MEMBER set is the +// sync's authoritative state: anything manually added is OWNER/MANAGER and is +// never touched. Adds are skipped when the user is already present in any role. +func reconcileBinding(ctx context.Context, b model.GroupGoogleBinding) error { + members, err := getGroupMembers(b.GroupID) + if err != nil { + return fmt.Errorf("fetch sentinel members for group %s: %w", b.GroupID, err) + } + + desired := make(map[string]struct{}, len(members)) + for _, m := range members { + email, err := resolveEntityEmail(m.EntityID) + if err != nil { + logger.SugarLogger.Errorf("google sync: resolve email for entity %s: %v", m.EntityID, err) + continue + } + if email == "" { + continue + } + desired[strings.ToLower(email)] = struct{}{} + } + + actual, err := listGroupMembers(ctx, b.GoogleGroupEmail) + if err != nil { + return err + } + // present = members in any role (skip ADDs for these); managed = role=MEMBER + // only (the only rows the sync may DELETE). + present := make(map[string]struct{}, len(actual)) + managed := make(map[string]struct{}, len(actual)) + for _, a := range actual { + le := strings.ToLower(a.Email) + present[le] = struct{}{} + if a.Role == "MEMBER" { + managed[le] = struct{}{} + } + } + + for email := range desired { + if err := ctx.Err(); err != nil { + return err + } + if _, ok := present[email]; ok { + continue + } + if err := insertMember(ctx, b.GoogleGroupEmail, email); err != nil { + logger.SugarLogger.Errorf("google sync: %v", err) + continue + } + logger.SugarLogger.Infof("google sync: added %s to %s", email, b.GoogleGroupEmail) + } + + var toRemove []string + for email := range managed { + if _, ok := desired[email]; ok { + continue + } + toRemove = append(toRemove, email) + } + if len(toRemove) > config.GoogleSyncMaxRemovals { + logger.SugarLogger.Errorf("google sync: refusing to remove %d members from %s (exceeds GOOGLE_SYNC_MAX_REMOVALS=%d); skipping removals for this group", len(toRemove), b.GoogleGroupEmail, config.GoogleSyncMaxRemovals) + return nil + } + for _, email := range toRemove { + if err := ctx.Err(); err != nil { + return err + } + if err := deleteMember(ctx, b.GoogleGroupEmail, email); err != nil { + logger.SugarLogger.Errorf("google sync: %v", err) + continue + } + logger.SugarLogger.Infof("google sync: removed %s from %s", email, b.GoogleGroupEmail) + } + return nil +} + +// ReconcileAll reconciles every binding. A failure on one binding is logged and +// does not abort the others. +func ReconcileAll(ctx context.Context) error { + bindings, err := GetAllGoogleBindings() + if err != nil { + return fmt.Errorf("load bindings: %w", err) + } + for _, b := range bindings { + if err := ctx.Err(); err != nil { + return err + } + if err := reconcileBinding(ctx, b); err != nil { + logger.SugarLogger.Errorf("google sync: reconcile failed for group=%s google=%s: %v", b.GroupID, b.GoogleGroupEmail, err) + } + } + return nil +} + +// sweepRunning serializes sweeps: a trigger that arrives while one is in flight +// is dropped (not queued). Safe because every sweep re-reads live state. +var sweepRunning atomic.Bool + +func runSweep() { + if directorySvc == nil { + logger.SugarLogger.Debugln("google sync: skipping sweep, sync disabled") + return + } + if !sweepRunning.CompareAndSwap(false, true) { + logger.SugarLogger.Infoln("google sync: sweep already running, skipping this trigger") + return + } + defer sweepRunning.Store(false) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + logger.SugarLogger.Infoln("google sync: starting reconcile sweep") + if err := ReconcileAll(ctx); err != nil { + logger.SugarLogger.Errorf("google sync: sweep failed: %v", err) + return + } + logger.SugarLogger.Infoln("google sync: reconcile sweep complete") +} + +// TriggerReconcile kicks a sweep in the background and returns immediately. +func TriggerReconcile() { + go runSweep() +} + +// StartReconcileCron runs a periodic sweep on config.GoogleSyncInterval. A +// non-positive interval (or disabled sync) turns the cron off. +func StartReconcileCron() { + if directorySvc == nil { + logger.SugarLogger.Infoln("google sync: cron disabled (sync not configured)") + return + } + interval := config.GoogleSyncInterval + if interval <= 0 { + logger.SugarLogger.Infof("google sync: cron disabled (interval=%v)", interval) + return + } + logger.SugarLogger.Infof("google sync: cron enabled, interval=%v", interval) + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + runSweep() + } + }() +} From 79f456c4ba039e1b6292c1b247178759b9806ad2 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Mon, 22 Jun 2026 19:12:29 -0700 Subject: [PATCH 2/5] refactor(google): hardcode sync tuning consts; route google via kerbecs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make GoogleSyncInterval (1h) and GoogleSyncMaxRemovals (25) hardcoded consts instead of env vars — they're tuning knobs, not deploy config. Register the sentinel-google upstream and /api/google/* route in kerbecs.yaml so the binding/reconcile API is reachable through the gateway. --- docker-compose.yml | 1 + google/config/config.go | 33 +++------------------------------ google/service/group_sync.go | 13 ++++--------- kerbecs.yaml | 14 ++++++++++++++ 4 files changed, 22 insertions(+), 39 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index ff2dd9f..8d90d12 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,7 @@ services: - oauth - discord - saml + - google - web core: diff --git a/google/config/config.go b/google/config/config.go index 482e71d..a0fd302 100644 --- a/google/config/config.go +++ b/google/config/config.go @@ -2,7 +2,6 @@ package config import ( "os" - "strconv" "time" ) @@ -50,40 +49,14 @@ var GoogleServiceAccount = os.Getenv("GOOGLE_SERVICE_ACCOUNT") // is set. var GoogleAdminSubject = os.Getenv("GOOGLE_ADMIN_SUBJECT") -// GoogleSyncInterval is how often the reconcile cron fires. Parsed once at -// startup; an unparseable or unset value falls back to 1h. Set to 0 (or any -// non-positive duration) to disable the cron. -var GoogleSyncInterval = parseDurationOr("GOOGLE_SYNC_INTERVAL", time.Hour) +// GoogleSyncInterval is how often the reconcile cron fires. +const GoogleSyncInterval = time.Hour // GoogleSyncMaxRemovals caps how many members a single per-group reconcile may // delete. If a run wants to remove more than this, it skips the removals for // that group and logs loudly — a guard against draining a group when core // returns an empty/partial member set (e.g. mid-outage). -var GoogleSyncMaxRemovals = parseIntOr("GOOGLE_SYNC_MAX_REMOVALS", 25) - -func parseDurationOr(envKey string, fallback time.Duration) time.Duration { - raw := os.Getenv(envKey) - if raw == "" { - return fallback - } - d, err := time.ParseDuration(raw) - if err != nil { - return fallback - } - return d -} - -func parseIntOr(envKey string, fallback int) int { - raw := os.Getenv(envKey) - if raw == "" { - return fallback - } - n, err := strconv.Atoi(raw) - if err != nil { - return fallback - } - return n -} +const GoogleSyncMaxRemovals = 25 func IsProduction() bool { return Env == "PROD" diff --git a/google/service/group_sync.go b/google/service/group_sync.go index c9c74de..ea3e35c 100644 --- a/google/service/group_sync.go +++ b/google/service/group_sync.go @@ -180,21 +180,16 @@ func TriggerReconcile() { go runSweep() } -// StartReconcileCron runs a periodic sweep on config.GoogleSyncInterval. A -// non-positive interval (or disabled sync) turns the cron off. +// StartReconcileCron runs a periodic sweep on config.GoogleSyncInterval. The +// cron is off only when sync isn't configured. func StartReconcileCron() { if directorySvc == nil { logger.SugarLogger.Infoln("google sync: cron disabled (sync not configured)") return } - interval := config.GoogleSyncInterval - if interval <= 0 { - logger.SugarLogger.Infof("google sync: cron disabled (interval=%v)", interval) - return - } - logger.SugarLogger.Infof("google sync: cron enabled, interval=%v", interval) + logger.SugarLogger.Infof("google sync: cron enabled, interval=%v", config.GoogleSyncInterval) go func() { - ticker := time.NewTicker(interval) + ticker := time.NewTicker(config.GoogleSyncInterval) defer ticker.Stop() for range ticker.C { runSweep() diff --git a/kerbecs.yaml b/kerbecs.yaml index a13c470..12a239b 100644 --- a/kerbecs.yaml +++ b/kerbecs.yaml @@ -51,6 +51,12 @@ upstreams: instances: - http://saml:9996 + google: + name: sentinel-google + version: 0.1.0 + instances: + - http://google:9995 + web: name: sentinel-web version: 0.1.0 @@ -130,6 +136,14 @@ routes: strip_prefix: /api envelope: passthrough + - name: google + match: + path: /api/google/* + upstream: google + rewrite: + strip_prefix: /api + envelope: passthrough + # SAML consent endpoints — the SPA calls these through the /api prefix. - name: saml match: From 777a4f2844be7e31bc3bc550f1f71c2bb2ead9a9 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Mon, 22 Jun 2026 19:14:33 -0700 Subject: [PATCH 3/5] refactor(google): cancel-and-restart sweeps; bump max removals to 100 Replace the skip-if-running sweep guard with the discord-style syncJob (latest-wins): a new trigger cancels the in-flight sweep and runs a fresh one with the latest state. ReconcileAll now returns on context cancellation. Bump GoogleSyncMaxRemovals 25 -> 100. --- google/config/config.go | 2 +- google/service/group_sync.go | 48 ++++++++++++---------- google/service/sync_job.go | 80 ++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 22 deletions(-) create mode 100644 google/service/sync_job.go diff --git a/google/config/config.go b/google/config/config.go index a0fd302..c1b2c76 100644 --- a/google/config/config.go +++ b/google/config/config.go @@ -56,7 +56,7 @@ const GoogleSyncInterval = time.Hour // delete. If a run wants to remove more than this, it skips the removals for // that group and logs loudly — a guard against draining a group when core // returns an empty/partial member set (e.g. mid-outage). -const GoogleSyncMaxRemovals = 25 +const GoogleSyncMaxRemovals = 100 func IsProduction() bool { return Env == "PROD" diff --git a/google/service/group_sync.go b/google/service/group_sync.go index ea3e35c..db7b3eb 100644 --- a/google/service/group_sync.go +++ b/google/service/group_sync.go @@ -2,9 +2,9 @@ package service import ( "context" + "errors" "fmt" "strings" - "sync/atomic" "time" "github.com/gaucho-racing/sentinel/google/config" @@ -143,41 +143,47 @@ func ReconcileAll(ctx context.Context) error { return err } if err := reconcileBinding(ctx, b); err != nil { + if errors.Is(err, context.Canceled) { + return err + } logger.SugarLogger.Errorf("google sync: reconcile failed for group=%s google=%s: %v", b.GroupID, b.GoogleGroupEmail, err) } } return nil } -// sweepRunning serializes sweeps: a trigger that arrives while one is in flight -// is dropped (not queued). Safe because every sweep re-reads live state. -var sweepRunning atomic.Bool +// sweepJob serializes sweeps with cancel-and-restart: a trigger that arrives +// while one is in flight cancels it and runs a fresh sweep with the latest +// state. Safe because every sweep re-reads live state and reconcile is +// idempotent. +var sweepJob syncJob func runSweep() { if directorySvc == nil { logger.SugarLogger.Debugln("google sync: skipping sweep, sync disabled") return } - if !sweepRunning.CompareAndSwap(false, true) { - logger.SugarLogger.Infoln("google sync: sweep already running, skipping this trigger") - return - } - defer sweepRunning.Store(false) - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) - defer cancel() - - logger.SugarLogger.Infoln("google sync: starting reconcile sweep") - if err := ReconcileAll(ctx); err != nil { - logger.SugarLogger.Errorf("google sync: sweep failed: %v", err) - return - } - logger.SugarLogger.Infoln("google sync: reconcile sweep complete") + sweepJob.Start(func(ctx context.Context) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + + logger.SugarLogger.Infoln("google sync: starting reconcile sweep") + if err := ReconcileAll(ctx); err != nil { + if errors.Is(err, context.Canceled) { + logger.SugarLogger.Debugln("google sync: sweep cancelled by newer trigger") + return + } + logger.SugarLogger.Errorf("google sync: sweep failed: %v", err) + return + } + logger.SugarLogger.Infoln("google sync: reconcile sweep complete") + }) } -// TriggerReconcile kicks a sweep in the background and returns immediately. +// TriggerReconcile kicks a sweep and returns immediately. A sweep already in +// flight is cancelled in favor of this one. func TriggerReconcile() { - go runSweep() + runSweep() } // StartReconcileCron runs a periodic sweep on config.GoogleSyncInterval. The diff --git a/google/service/sync_job.go b/google/service/sync_job.go new file mode 100644 index 0000000..2070001 --- /dev/null +++ b/google/service/sync_job.go @@ -0,0 +1,80 @@ +package service + +import ( + "context" + "sync" + + "github.com/gaucho-racing/sentinel/google/pkg/logger" +) + +// syncJob serializes background work with "latest-wins" semantics. Calling +// Start cancels any in-flight run and queues a new one that begins as soon as +// the cancelled run has exited. +// +// The pattern is safe specifically because the reconcile op is idempotent: +// every run reads the live state, computes a diff, and applies it — so a run +// that gets cancelled mid-way is harmless, and the next run catches up from +// whatever state the world ended up in. fn is expected to check ctx.Err() at +// convenient points (between bindings, between member writes); there's no +// attempt to abort an in-flight HTTP request. +type syncJob struct { + mu sync.Mutex + cancel context.CancelFunc + done chan struct{} +} + +// Start cancels any in-flight run and spawns a new one with fn. Returns +// immediately once the new goroutine is queued (does not wait for it to +// finish). Successive rapid calls each cancel the previous; only the most +// recent fn is guaranteed to run to completion. +func (sj *syncJob) Start(fn func(ctx context.Context)) { + sj.mu.Lock() + + // Cancel the previous run (if any) and remember its done so the new + // goroutine can wait for the old one to fully exit before starting — + // that's what gives us true serialization (no overlapping writes). + if sj.cancel != nil { + sj.cancel() + } + prevDone := sj.done + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + sj.cancel = cancel + sj.done = done + + sj.mu.Unlock() + + go func() { + // Close `done` last so chained waiters know we've fully exited. + defer close(done) + + // Recover so a panic in fn doesn't deadlock future Starts (the + // goroutine would die before close(done), waiters would block + // forever, and sj.cancel would stay non-nil). + defer func() { + if r := recover(); r != nil { + logger.SugarLogger.Errorf("sync job panic: %v", r) + } + }() + + // Wait for previous run to exit fully before starting our work. + if prevDone != nil { + <-prevDone + } + + // fn must respect ctx — if we were already cancelled by a newer + // Start before getting here, fn's first ctx.Err() check exits it. + fn(ctx) + + // Clear our pointers ONLY if we're still the latest. If a newer + // Start replaced us, sj.done points at its `done`, not ours, and + // we mustn't clobber it. + sj.mu.Lock() + if sj.done == done { + sj.cancel = nil + sj.done = nil + } + sj.mu.Unlock() + }() +} From b0009b736b610f8336155ee7d91d726531b31c61 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Mon, 22 Jun 2026 19:19:12 -0700 Subject: [PATCH 4/5] feat(google): run reconcile cron every 15m Shorten GoogleSyncInterval to 15m for near-real-time membership propagation without coupling core to the google service. --- google/config/config.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/google/config/config.go b/google/config/config.go index c1b2c76..583a496 100644 --- a/google/config/config.go +++ b/google/config/config.go @@ -49,8 +49,11 @@ var GoogleServiceAccount = os.Getenv("GOOGLE_SERVICE_ACCOUNT") // is set. var GoogleAdminSubject = os.Getenv("GOOGLE_ADMIN_SUBJECT") -// GoogleSyncInterval is how often the reconcile cron fires. -const GoogleSyncInterval = time.Hour +// GoogleSyncInterval is how often the reconcile cron fires. Sync is one-way and +// not latency-critical (mailing-list membership), so a periodic full sweep — +// rather than per-change event triggers from core — keeps the dependency graph +// clean while landing changes within the interval. +const GoogleSyncInterval = 15 * time.Minute // GoogleSyncMaxRemovals caps how many members a single per-group reconcile may // delete. If a run wants to remove more than this, it skips the removals for From 0fbb585b0ce097230c692727ee9c6d2c9842ff04 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Mon, 22 Jun 2026 19:22:46 -0700 Subject: [PATCH 5/5] feat(google): run reconcile cron every 5m --- google/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google/config/config.go b/google/config/config.go index 583a496..336804b 100644 --- a/google/config/config.go +++ b/google/config/config.go @@ -53,7 +53,7 @@ var GoogleAdminSubject = os.Getenv("GOOGLE_ADMIN_SUBJECT") // not latency-critical (mailing-list membership), so a periodic full sweep — // rather than per-change event triggers from core — keeps the dependency graph // clean while landing changes within the interval. -const GoogleSyncInterval = 15 * time.Minute +const GoogleSyncInterval = 5 * time.Minute // GoogleSyncMaxRemovals caps how many members a single per-group reconcile may // delete. If a run wants to remove more than this, it skips the removals for