From a2782f5b91733162c5c9bd2bf09b1c77c6d48691 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Wed, 9 Sep 2026 13:08:06 -0700 Subject: [PATCH 1/4] fix(auth): enforce Sentinel authorization boundaries --- core/api/analytics.go | 4 +- core/api/api.go | 47 +++++++++++++++---- core/api/application.go | 57 ++++++++--------------- core/api/bootstrap.go | 5 +++ core/api/conditional_binding.go | 3 +- core/api/entity.go | 44 ++++++------------ core/api/group.go | 77 +++++++++++++++++++------------- core/api/identity_summary.go | 7 +-- core/api/jwt.go | 4 +- core/api/onboarding.go | 10 ++--- core/api/provisioning.go | 2 +- core/api/service_account.go | 24 +++++----- core/api/user.go | 55 ++++++++++------------- core/authz/scope.go | 62 +++++++++++++++++++++++++ core/jobs/init.go | 17 ++++++- discord/api/auth.go | 65 ++++++++++++++++++++++++--- discord/api/channel_archive.go | 2 +- discord/api/guild.go | 4 +- discord/api/role_binding.go | 8 ++-- discord/authz/scope.go | 62 +++++++++++++++++++++++++ discord/pkg/sentinel/sentinel.go | 6 ++- google/api/auth.go | 65 ++++++++++++++++++++++++--- google/api/group_binding.go | 8 ++-- google/api/reconcile.go | 2 +- google/authz/scope.go | 62 +++++++++++++++++++++++++ google/pkg/sentinel/sentinel.go | 6 ++- oauth/api/authorize.go | 12 +++-- oauth/api/login.go | 3 +- oauth/api/well_known.go | 3 +- oauth/authz/scope.go | 53 ++++++++++++++++++++++ oauth/model/scope.go | 24 +++++----- oauth/service/token_claims.go | 3 +- web/src/lib/scopes.ts | 5 +++ 33 files changed, 602 insertions(+), 209 deletions(-) create mode 100644 core/authz/scope.go create mode 100644 discord/authz/scope.go create mode 100644 google/authz/scope.go create mode 100644 oauth/authz/scope.go diff --git a/core/api/analytics.go b/core/api/analytics.go index 639bb9dc..556f7381 100644 --- a/core/api/analytics.go +++ b/core/api/analytics.go @@ -15,8 +15,8 @@ import ( // (sentinel:all). Mirrors the GetApplicationSecret gate. func requireAnalyticsAccess(c *gin.Context) { Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasAudience(c, "sentinel") && RequestUserIsAdmin(c), + RequestTokenHasInternalAccess(c), + RequestTokenHasFirstPartyAccess(c) && RequestUserIsAdmin(c), )) } diff --git a/core/api/api.go b/core/api/api.go index 3affa14b..47cbcdcc 100644 --- a/core/api/api.go +++ b/core/api/api.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "github.com/gaucho-racing/sentinel/core/authz" "github.com/gaucho-racing/sentinel/core/config" "github.com/gaucho-racing/sentinel/core/pkg/logger" "github.com/gaucho-racing/sentinel/core/service" @@ -66,6 +67,7 @@ func InitializeRoutes(router *gin.Engine) { router.POST("/core/internal/bootstrap-token", BootstrapToken) router.GET("/entities/@me", GetMe) + router.GET("/entities/@me/admin-access", CheckAdminAccess) router.POST("/entities/resolve", ResolveIdentitySummaries) router.GET("/entities/:id", GetEntity) @@ -101,6 +103,7 @@ func InitializeRoutes(router *gin.Engine) { router.GET("/groups", GetAllGroups) router.GET("/groups/:id", GetGroupByID) + router.GET("/groups/:id/write-access", CheckGroupWriteAccess) router.POST("/groups", CreateOrUpdateGroup) router.DELETE("/groups/:id", DeleteGroup) @@ -218,13 +221,7 @@ func RequestTokenExists(c *gin.Context) bool { } func RequestTokenHasScope(c *gin.Context, scope string) bool { - scopes := GetRequestTokenScopes(c) - for _, s := range strings.Split(scopes, " ") { - if s == scope { - return true - } - } - return false + return authz.HasScope(GetRequestTokenScopes(c), scope) } func RequestTokenHasAudience(c *gin.Context, audience string) bool { @@ -267,6 +264,36 @@ func GetRequestTokenClaims(c *gin.Context) map[string]interface{} { return claims.(map[string]interface{}) } +func RequestTokenHasFirstPartyAccess(c *gin.Context) bool { + return authz.IsFirstPartyUser( + GetRequestTokenScopes(c), + GetRequestTokenAudience(c), + GetRequestTokenClaims(c), + ) +} + +func RequestTokenHasInternalAccess(c *gin.Context) bool { + return authz.IsInternalServiceAccount( + GetRequestTokenScopes(c), + GetRequestTokenAudience(c), + GetRequestTokenClaims(c), + ) +} + +func RequestTokenHasResourceScope(c *gin.Context, scope string) bool { + return Any( + RequestTokenHasInternalAccess(c), + RequestTokenHasFirstPartyAccess(c), + RequestTokenHasScope(c, scope), + ) +} + +func CheckAdminAccess(c *gin.Context) { + Require(c, RequestTokenHasInternalAccess(c) || + RequestTokenHasFirstPartyAccess(c) && RequestUserIsAdmin(c)) + c.Status(http.StatusNoContent) +} + // GetRequestTokenEntityID returns the subject (entity_id) of the bearer that // AuthChecker resolved, or "" if no valid bearer was presented. func GetRequestTokenEntityID(c *gin.Context) string { @@ -324,8 +351,10 @@ func RequestUserIsGroupOwner(c *gin.Context, groupID string) bool { // with 403 on failure and returns false; otherwise returns true and // the caller continues. func requireGroupOwnerOrAdmin(c *gin.Context, groupID string) bool { - if Any( - RequestTokenHasScope(c, "sentinel:all"), + if RequestTokenHasInternalAccess(c) { + return true + } + if RequestTokenHasResourceScope(c, authz.GroupsWriteScope) && Any( RequestUserIsGroupOwner(c, groupID), RequestUserIsAdmin(c), ) { diff --git a/core/api/application.go b/core/api/application.go index e805b307..da20d0bc 100644 --- a/core/api/application.go +++ b/core/api/application.go @@ -3,6 +3,7 @@ package api import ( "net/http" + "github.com/gaucho-racing/sentinel/core/authz" "github.com/gaucho-racing/sentinel/core/model" "github.com/gaucho-racing/sentinel/core/service" "github.com/gin-gonic/gin" @@ -10,11 +11,7 @@ import ( ) func GetAllApplications(c *gin.Context) { - Require(c, Any( - RequestTokenHasAudience(c, "sentinel"), - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasScope(c, "applications:read"), - )) + Require(c, RequestTokenHasResourceScope(c, authz.ApplicationsReadScope)) applications, err := service.GetAllApplications() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -24,11 +21,7 @@ func GetAllApplications(c *gin.Context) { } func GetApplicationByID(c *gin.Context) { - Require(c, Any( - RequestTokenHasAudience(c, "sentinel"), - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasScope(c, "applications:read"), - )) + Require(c, RequestTokenHasResourceScope(c, authz.ApplicationsReadScope)) id := c.Param("id") app, err := service.GetApplicationByID(id) if err != nil { @@ -62,10 +55,7 @@ func GetApplicationByClientID(c *gin.Context) { // internal metadata. The oauth/saml services use it to look up // the app a token request is targeting, so internal automation // must work; admins also have full read. - Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), - RequestUserIsAdmin(c), - )) + Require(c, RequestTokenHasResourceScope(c, authz.ApplicationsReadScope)) clientID := c.Param("clientID") app, err := service.GetApplicationByClientID(clientID) if err != nil { @@ -117,11 +107,7 @@ type createdApplicationResponse struct { } func CreateApplication(c *gin.Context) { - Require(c, Any( - RequestTokenHasAudience(c, "sentinel"), - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasScope(c, "applications:write"), - )) + Require(c, RequestTokenHasResourceScope(c, authz.ApplicationsWriteScope)) var req createApplicationRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -196,9 +182,11 @@ func GetApplicationSecret(c *gin.Context) { return } Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasAudience(c, "sentinel") && RequestTokenHasEntityID(c, app.OwnerID), - RequestTokenHasAudience(c, "sentinel") && RequestUserIsAdmin(c), + RequestTokenHasInternalAccess(c), + RequestTokenHasResourceScope(c, authz.ApplicationsReadScope) && Any( + RequestTokenHasEntityID(c, app.OwnerID), + RequestUserIsAdmin(c), + ), )) recordAudit(c, model.AuditActionApplicationSecretRevealed, "application", app.ID, model.JSONMap{"name": app.Name}) c.JSON(http.StatusOK, gin.H{"client_secret": app.ClientSecret}) @@ -225,11 +213,7 @@ func DeleteApplication(c *gin.Context) { } func GetApplicationGroups(c *gin.Context) { - Require(c, Any( - RequestTokenHasAudience(c, "sentinel"), - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasScope(c, "applications:read"), - )) + Require(c, RequestTokenHasResourceScope(c, authz.ApplicationsReadScope)) id := c.Param("id") groups, err := service.GetGroupsForApplication(id) if err != nil { @@ -244,7 +228,7 @@ func GetApplicationGroups(c *gin.Context) { // groups claim and enforce the access gate. Now that oauth carries its own SA // bearer, the gate is sentinel:all (matches the other internal-only reads). func GetApplicationGroupsByClientID(c *gin.Context) { - Require(c, RequestTokenHasScope(c, "sentinel:all")) + Require(c, RequestTokenHasInternalAccess(c)) clientID := c.Param("clientID") app, err := service.GetApplicationByClientID(clientID) if err != nil { @@ -320,11 +304,7 @@ func RemoveApplicationGroup(c *gin.Context) { } func GetApplicationRedirectURIs(c *gin.Context) { - Require(c, Any( - RequestTokenHasAudience(c, "sentinel"), - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasScope(c, "applications:read"), - )) + Require(c, RequestTokenHasResourceScope(c, authz.ApplicationsReadScope)) id := c.Param("id") uris, err := service.GetRedirectURIsForApplication(id) if err != nil { @@ -392,10 +372,9 @@ func RemoveApplicationRedirectURI(c *gin.Context) { // owner OR an Admins-group member, or a third-party token with // applications:write granted by the owner. func ApplicationWriteAuthorized(c *gin.Context, app model.Application) bool { - return Any( - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasAudience(c, "sentinel") && RequestTokenHasEntityID(c, app.OwnerID), - RequestTokenHasAudience(c, "sentinel") && RequestUserIsAdmin(c), - RequestTokenHasScope(c, "applications:write") && RequestTokenHasEntityID(c, app.OwnerID), - ) + return RequestTokenHasInternalAccess(c) || + RequestTokenHasResourceScope(c, authz.ApplicationsWriteScope) && Any( + RequestTokenHasEntityID(c, app.OwnerID), + RequestUserIsAdmin(c), + ) } diff --git a/core/api/bootstrap.go b/core/api/bootstrap.go index b5dad905..bd694afa 100644 --- a/core/api/bootstrap.go +++ b/core/api/bootstrap.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" + "github.com/gaucho-racing/sentinel/core/authz" "github.com/gaucho-racing/sentinel/core/config" "github.com/gaucho-racing/sentinel/core/jobs" "github.com/gaucho-racing/sentinel/core/pkg/logger" @@ -78,6 +79,10 @@ func BootstrapToken(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + if sa.Scope != authz.SentinelInternalScope { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "internal service account scope is not configured"}) + return + } if sa.SignedToken == "" { // Best-effort mint to recover from a partial-seed state. If diff --git a/core/api/conditional_binding.go b/core/api/conditional_binding.go index dbe23c9c..f4c22dd1 100644 --- a/core/api/conditional_binding.go +++ b/core/api/conditional_binding.go @@ -4,13 +4,14 @@ import ( "errors" "net/http" + "github.com/gaucho-racing/sentinel/core/authz" "github.com/gaucho-racing/sentinel/core/model" "github.com/gaucho-racing/sentinel/core/service" "github.com/gin-gonic/gin" ) func GetGroupConditionalBindings(c *gin.Context) { - Require(c, RequestTokenExists(c)) + Require(c, RequestTokenHasResourceScope(c, authz.GroupsReadScope)) id := c.Param("id") bindings, err := service.GetConditionalBindingsForGroup(id) diff --git a/core/api/entity.go b/core/api/entity.go index b0d3fae1..5b80301f 100644 --- a/core/api/entity.go +++ b/core/api/entity.go @@ -3,6 +3,7 @@ package api import ( "net/http" + "github.com/gaucho-racing/sentinel/core/authz" "github.com/gaucho-racing/sentinel/core/model" "github.com/gaucho-racing/sentinel/core/service" "github.com/gin-gonic/gin" @@ -10,10 +11,7 @@ import ( ) func GetMe(c *gin.Context) { - Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasScope(c, "user:read"), - )) + Require(c, RequestTokenHasResourceScope(c, authz.UserReadScope)) id := GetRequestTokenEntityID(c) entity, err := service.GetEntityByID(id) @@ -30,11 +28,7 @@ func GetMe(c *gin.Context) { func GetEntity(c *gin.Context) { id := c.Param("id") - Require(c, Any( - RequestTokenHasAudience(c, "sentinel"), - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasScope(c, "user:read") && RequestTokenHasEntityID(c, id), - )) + Require(c, RequestTokenHasResourceScope(c, authz.UserReadScope)) entity, err := service.GetEntityByID(id) if err != nil { @@ -53,11 +47,7 @@ func GetEntityByID(c *gin.Context) { // Entity rows carry PII (email-auth, phone-auth, linked external // identities, user profile). Self can read their own; admin and // internal automation override. - Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasEntityID(c, entityID), - RequestUserIsAdmin(c), - )) + Require(c, RequestTokenHasResourceScope(c, authz.UserReadScope)) entity, err := service.GetEntityByID(entityID) if err != nil { if err == gorm.ErrRecordNotFound { @@ -75,11 +65,7 @@ func GetEntityGroups(c *gin.Context) { // Group membership is an authorization signal — leaking another // user's groups would tell an attacker who has admin-equivalent // access. Self / admin / internal only. - Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasEntityID(c, entityID), - RequestUserIsAdmin(c), - )) + Require(c, RequestTokenHasResourceScope(c, authz.GroupsReadScope)) groups, err := service.GetGroupsForEntity(entityID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -96,11 +82,7 @@ func GetEntityMemberships(c *gin.Context) { // Raw GroupMember rows (with source labels) are used by integration // services to diff their own writes — same self/admin/internal // trust level as GetEntityGroups. - Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasEntityID(c, entityID), - RequestUserIsAdmin(c), - )) + Require(c, RequestTokenHasResourceScope(c, authz.GroupsReadScope)) source := c.Query("source") memberships, err := service.GetMembershipsForEntity(entityID, source) if err != nil { @@ -115,7 +97,7 @@ func GetEntityByExternalAuth(c *gin.Context) { // map to?" — leaks the user/Discord identity pairing. Reserved for // internal automation; the oauth-discord-login flow is the // canonical caller. - Require(c, RequestTokenHasScope(c, "sentinel:all")) + Require(c, RequestTokenHasInternalAccess(c)) provider := c.Param("provider") externalID := c.Param("externalID") entity, err := service.GetEntityByExternalAuth(provider, externalID) @@ -133,7 +115,7 @@ func GetEntityByExternalAuth(c *gin.Context) { func ListExternalAuthsByProvider(c *gin.Context) { // Enumeration of every onboarded user for a provider — used by // the discord sync's full sweep. Internal callers only. - Require(c, RequestTokenHasScope(c, "sentinel:all")) + Require(c, RequestTokenHasInternalAccess(c)) provider := c.Param("provider") auths, err := service.ListExternalAuthsByProvider(provider) if err != nil { @@ -148,7 +130,7 @@ func CreateEntityLogin(c *gin.Context) { // them is reserved for the oauth service (which records each // session it mints); admins/users shouldn't be backdating their // own entries. - Require(c, RequestTokenHasScope(c, "sentinel:all")) + Require(c, RequestTokenHasInternalAccess(c)) var login model.EntityLogin if err := c.ShouldBindJSON(&login); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -167,9 +149,11 @@ func GetEntityLogins(c *gin.Context) { // Login history is an audit-grade signal. Self / admin / internal // only. Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasEntityID(c, entityID), - RequestUserIsAdmin(c), + RequestTokenHasInternalAccess(c), + RequestTokenHasResourceScope(c, authz.UserReadScope) && Any( + RequestTokenHasEntityID(c, entityID), + RequestUserIsAdmin(c), + ), )) logins, err := service.GetEntityLogins(service.EntityLoginsFilter{ EntityID: entityID, diff --git a/core/api/group.go b/core/api/group.go index 136d4599..731c4f3a 100644 --- a/core/api/group.go +++ b/core/api/group.go @@ -6,6 +6,7 @@ import ( "time" "unicode" + "github.com/gaucho-racing/sentinel/core/authz" "github.com/gaucho-racing/sentinel/core/model" "github.com/gaucho-racing/sentinel/core/pkg/logger" "github.com/gaucho-racing/sentinel/core/service" @@ -49,7 +50,7 @@ func validateMembershipExpiration(hasExpiration bool, expiresAt time.Time) error } func GetAllGroups(c *gin.Context) { - Require(c, RequestTokenExists(c)) + Require(c, RequestTokenHasResourceScope(c, authz.GroupsReadScope)) groups, err := service.GetAllGroups() if err != nil { @@ -92,7 +93,7 @@ func cascadeRemovedSources(groupID string, before model.StringSlice, after []str } func GetGroupByID(c *gin.Context) { - Require(c, RequestTokenExists(c)) + Require(c, RequestTokenHasResourceScope(c, authz.GroupsReadScope)) id := c.Param("id") group, err := service.GetGroupByID(id) @@ -107,6 +108,13 @@ func GetGroupByID(c *gin.Context) { c.JSON(http.StatusOK, group) } +func CheckGroupWriteAccess(c *gin.Context) { + if !requireGroupOwnerOrAdmin(c, c.Param("id")) { + return + } + c.Status(http.StatusNoContent) +} + type upsertGroupRequest struct { ID string `json:"id"` Name string `json:"name"` @@ -140,10 +148,7 @@ func CreateOrUpdateGroup(c *gin.Context) { // without this check, anyone could rename or rewrite allowed_sources // on any group, including the Admins group. if existing.ID == "" { - Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasScope(c, "groups:write"), - )) + Require(c, RequestTokenHasResourceScope(c, authz.GroupsWriteScope)) } else if !requireGroupOwnerOrAdmin(c, existing.ID) { return } @@ -212,7 +217,7 @@ func CreateOrUpdateGroup(c *gin.Context) { } func GetGroupApplications(c *gin.Context) { - Require(c, RequestTokenExists(c)) + Require(c, RequestTokenHasResourceScope(c, authz.GroupsReadScope)) id := c.Param("id") apps, err := service.GetApplicationsForGroup(id) @@ -242,7 +247,7 @@ func DeleteGroup(c *gin.Context) { // Members func GetGroupMembers(c *gin.Context) { - Require(c, RequestTokenExists(c)) + Require(c, RequestTokenHasResourceScope(c, authz.GroupsReadScope)) id := c.Param("id") members, err := service.GetMembersForGroup(id) @@ -262,7 +267,7 @@ type addGroupMemberRequest struct { } func requestAddedBy(c *gin.Context, claimed string) string { - if RequestTokenHasScope(c, "sentinel:all") && claimed != "" { + if RequestTokenHasInternalAccess(c) && claimed != "" { return claimed } return GetRequestTokenEntityID(c) @@ -303,13 +308,13 @@ func AddGroupMember(c *gin.Context) { if source == "" { source = string(model.GroupMemberSourceDirect) } - if source != string(model.GroupMemberSourceDirect) && !RequestTokenHasScope(c, "sentinel:all") { + if source != string(model.GroupMemberSourceDirect) && !RequestTokenHasInternalAccess(c) { c.JSON(http.StatusForbidden, gin.H{"error": "only internal services can add synced group members"}) return } if source == string(model.GroupMemberSourceDirect) && !containsSource(group.AllowedSources, model.GroupMemberSourceDirect) && - !RequestTokenHasScope(c, "sentinel:all") { + !RequestTokenHasInternalAccess(c) { c.JSON(http.StatusBadRequest, gin.H{"error": "direct memberships are not enabled for this group"}) return } @@ -368,7 +373,7 @@ func RemoveGroupMember(c *gin.Context) { // Owners func GetGroupOwners(c *gin.Context) { - Require(c, RequestTokenExists(c)) + Require(c, RequestTokenHasResourceScope(c, authz.GroupsReadScope)) id := c.Param("id") owners, err := service.GetOwnersForGroup(id) @@ -448,9 +453,13 @@ func RemoveGroupOwner(c *gin.Context) { func GetGroupJoinRequests(c *gin.Context) { id := c.Param("id") - if !requireGroupOwnerOrAdmin(c, id) { - return - } + Require(c, Any( + RequestTokenHasInternalAccess(c), + RequestTokenHasResourceScope(c, authz.GroupsReadScope) && Any( + RequestUserIsGroupOwner(c, id), + RequestUserIsAdmin(c), + ), + )) requests, err := service.GetJoinRequestsByGroup(id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -473,10 +482,12 @@ func GetGroupJoinRequest(c *gin.Context) { // Applicants can read their own request; otherwise the group's // owner roster, admins, and internal services can see it. Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasEntityID(c, request.EntityID), - RequestUserIsGroupOwner(c, request.GroupID), - RequestUserIsAdmin(c), + RequestTokenHasInternalAccess(c), + RequestTokenHasResourceScope(c, authz.GroupsReadScope) && Any( + RequestTokenHasEntityID(c, request.EntityID), + RequestUserIsGroupOwner(c, request.GroupID), + RequestUserIsAdmin(c), + ), )) c.JSON(http.StatusOK, request) } @@ -499,9 +510,11 @@ func CreateGroupJoinRequest(c *gin.Context) { // that is admin or internal; group owners can't backdoor people in // via this endpoint (they'd use AddGroupMember directly). Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasEntityID(c, req.EntityID), - RequestUserIsAdmin(c), + RequestTokenHasInternalAccess(c), + RequestTokenHasResourceScope(c, authz.GroupsWriteScope) && Any( + RequestTokenHasEntityID(c, req.EntityID), + RequestUserIsAdmin(c), + ), )) if err := validateMembershipExpiration(req.HasExpiration, req.ExpiresAt); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -669,10 +682,12 @@ func CreateJoinRequestComment(c *gin.Context) { // Bearer must match the comment's claimed entity_id; the owner/ // admin path bypasses the self check. Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasEntityID(c, req.EntityID), - RequestUserIsGroupOwner(c, id), - RequestUserIsAdmin(c), + RequestTokenHasInternalAccess(c), + RequestTokenHasResourceScope(c, authz.GroupsWriteScope) && Any( + RequestTokenHasEntityID(c, req.EntityID), + RequestUserIsGroupOwner(c, id), + RequestUserIsAdmin(c), + ), )) comment, err := service.CreateJoinRequestComment(model.GroupJoinRequestComment{ RequestID: requestID, @@ -702,10 +717,12 @@ func DeleteJoinRequestComment(c *gin.Context) { return } Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasEntityID(c, comment.EntityID), - RequestUserIsGroupOwner(c, id), - RequestUserIsAdmin(c), + RequestTokenHasInternalAccess(c), + RequestTokenHasResourceScope(c, authz.GroupsWriteScope) && Any( + RequestTokenHasEntityID(c, comment.EntityID), + RequestUserIsGroupOwner(c, id), + RequestUserIsAdmin(c), + ), )) if err := service.DeleteJoinRequestComment(commentID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) diff --git a/core/api/identity_summary.go b/core/api/identity_summary.go index 8b0c5df7..960694a9 100644 --- a/core/api/identity_summary.go +++ b/core/api/identity_summary.go @@ -4,6 +4,7 @@ import ( "net/http" "strings" + "github.com/gaucho-racing/sentinel/core/authz" "github.com/gaucho-racing/sentinel/core/service" "github.com/gin-gonic/gin" ) @@ -15,11 +16,7 @@ type identitySummaryRequest struct { } func ResolveIdentitySummaries(c *gin.Context) { - Require(c, Any( - RequestTokenHasAudience(c, "sentinel"), - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasScope(c, "user:read"), - )) + Require(c, RequestTokenHasResourceScope(c, authz.UserReadScope)) var req identitySummaryRequest if err := c.ShouldBindJSON(&req); err != nil { diff --git a/core/api/jwt.go b/core/api/jwt.go index 48cb0ebe..b90e446d 100644 --- a/core/api/jwt.go +++ b/core/api/jwt.go @@ -28,7 +28,7 @@ func GenerateToken(c *gin.Context) { // the caller a JWT identifying themselves as whoever they like, with // whatever permissions they specify (including sentinel:all itself). // Reserved for first-party automations carrying sentinel:all. - Require(c, RequestTokenHasScope(c, "sentinel:all")) + Require(c, RequestTokenHasInternalAccess(c)) var req generateTokenRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -65,7 +65,7 @@ func RevokeToken(c *gin.Context) { // Revoking arbitrary tokens lets a caller deny any user service // access by ID. Same trust level as minting — reserved for // first-party automations carrying sentinel:all. - Require(c, RequestTokenHasScope(c, "sentinel:all")) + Require(c, RequestTokenHasInternalAccess(c)) id := c.Param("id") if err := service.RevokeToken(id); err != nil { diff --git a/core/api/onboarding.go b/core/api/onboarding.go index cf046f60..bdec10be 100644 --- a/core/api/onboarding.go +++ b/core/api/onboarding.go @@ -18,7 +18,7 @@ func CreateEntity(c *gin.Context) { // off (users, service accounts, group memberships). Creation is // reserved for internal automation — the discord onboarding flow // is the canonical caller and now carries sentinel:all via its SA. - Require(c, RequestTokenHasScope(c, "sentinel:all")) + Require(c, RequestTokenHasInternalAccess(c)) var req createEntityRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -47,7 +47,7 @@ func CreateEntityEmailAuth(c *gin.Context) { // callers (discord onboarding mints the initial email auth; future // password-reset flows would go through a separate token-mediated // path before hitting this handler). - Require(c, RequestTokenHasScope(c, "sentinel:all")) + Require(c, RequestTokenHasInternalAccess(c)) entityID := c.Param("entityID") var req createEmailAuthRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -89,7 +89,7 @@ type createPhoneAuthRequest struct { func CreateEntityPhoneAuth(c *gin.Context) { // Same trust level as the other entity-auth writers: internal // onboarding only. - Require(c, RequestTokenHasScope(c, "sentinel:all")) + Require(c, RequestTokenHasInternalAccess(c)) entityID := c.Param("entityID") var req createPhoneAuthRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -113,7 +113,7 @@ func CreateEntityExternalAuth(c *gin.Context) { // Linking an external identity (DISCORD, GITHUB, etc.) to an // entity is account-takeover-adjacent — anyone able to write this // row can claim any entity. Internal callers only. - Require(c, RequestTokenHasScope(c, "sentinel:all")) + Require(c, RequestTokenHasInternalAccess(c)) entityID := c.Param("entityID") var req createExternalAuthRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -144,7 +144,7 @@ func UpdateEntityExternalAuthMetadata(c *gin.Context) { // Called by login handlers on every successful provider sign-in // (oauth-discord-login refreshes the cached email/username/avatar // after a successful Discord exchange). Internal callers only. - Require(c, RequestTokenHasScope(c, "sentinel:all")) + Require(c, RequestTokenHasInternalAccess(c)) entityID := c.Param("entityID") provider := c.Param("provider") var req updateExternalAuthMetadataRequest diff --git a/core/api/provisioning.go b/core/api/provisioning.go index c56faabf..d44e91ad 100644 --- a/core/api/provisioning.go +++ b/core/api/provisioning.go @@ -8,7 +8,7 @@ import ( ) func GetApplicationProvisioningSnapshot(c *gin.Context) { - Require(c, RequestTokenHasScope(c, "sentinel:all")) + Require(c, RequestTokenHasInternalAccess(c)) snapshot, err := service.GetApplicationProvisioningSnapshot(c.Param("id")) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "could not build provisioning snapshot"}) diff --git a/core/api/service_account.go b/core/api/service_account.go index d0b4c80f..cea19330 100644 --- a/core/api/service_account.go +++ b/core/api/service_account.go @@ -5,6 +5,7 @@ import ( "net/http" "strings" + "github.com/gaucho-racing/sentinel/core/authz" "github.com/gaucho-racing/sentinel/core/model" "github.com/gaucho-racing/sentinel/core/pkg/logger" "github.com/gaucho-racing/sentinel/core/service" @@ -26,7 +27,7 @@ var allowedSATTLs = map[int]struct{}{ // first-party automation carrying sentinel:all skips the check entirely // (matches the codebase-wide convention where sentinel:all is the // internal-services bypass scope). Returns the resolved app on success. -func requireAppOwnerOrAdmin(c *gin.Context, appID string) (model.Application, bool) { +func requireAppOwnerOrAdmin(c *gin.Context, appID string, scope string) (model.Application, bool) { app, err := service.GetApplicationByID(appID) if err != nil { if err == gorm.ErrRecordNotFound { @@ -36,11 +37,10 @@ func requireAppOwnerOrAdmin(c *gin.Context, appID string) (model.Application, bo } return model.Application{}, false } - if !Any( - RequestTokenHasScope(c, "sentinel:all"), + if !RequestTokenHasInternalAccess(c) && !(RequestTokenHasResourceScope(c, scope) && Any( RequestTokenHasEntityID(c, app.OwnerID), RequestUserIsAdmin(c), - ) { + )) { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "you are not authorized to manage this application"}) return model.Application{}, false } @@ -49,7 +49,7 @@ func requireAppOwnerOrAdmin(c *gin.Context, appID string) (model.Application, bo func ListServiceAccountsForApplication(c *gin.Context) { id := c.Param("id") - if _, ok := requireAppOwnerOrAdmin(c, id); !ok { + if _, ok := requireAppOwnerOrAdmin(c, id, authz.ApplicationsReadScope); !ok { return } sas, err := service.GetServiceAccountsByApplicationID(id) @@ -80,7 +80,7 @@ type serviceAccountWithToken struct { func CreateServiceAccountForApp(c *gin.Context) { id := c.Param("id") - if _, ok := requireAppOwnerOrAdmin(c, id); !ok { + if _, ok := requireAppOwnerOrAdmin(c, id, authz.ApplicationsWriteScope); !ok { return } var req createServiceAccountRequest @@ -138,7 +138,7 @@ func RotateServiceAccountToken(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - if _, ok := requireAppOwnerOrAdmin(c, sa.ApplicationID); !ok { + if _, ok := requireAppOwnerOrAdmin(c, sa.ApplicationID, authz.ApplicationsWriteScope); !ok { return } @@ -170,9 +170,11 @@ func GetServiceAccountToken(c *gin.Context) { return } Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasEntityID(c, sa.CreatedBy), - RequestUserIsAdmin(c), + RequestTokenHasInternalAccess(c), + RequestTokenHasResourceScope(c, authz.ApplicationsReadScope) && Any( + RequestTokenHasEntityID(c, sa.CreatedBy), + RequestUserIsAdmin(c), + ), )) if sa.ActiveToken == nil || sa.SignedToken == "" { c.JSON(http.StatusNotFound, gin.H{"error": "no active token; rotate to mint a new one"}) @@ -192,7 +194,7 @@ func DeleteServiceAccount(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - if _, ok := requireAppOwnerOrAdmin(c, sa.ApplicationID); !ok { + if _, ok := requireAppOwnerOrAdmin(c, sa.ApplicationID, authz.ApplicationsWriteScope); !ok { return } diff --git a/core/api/user.go b/core/api/user.go index 28e15c93..ab39c16c 100644 --- a/core/api/user.go +++ b/core/api/user.go @@ -4,6 +4,7 @@ import ( "net/http" "strconv" + "github.com/gaucho-racing/sentinel/core/authz" "github.com/gaucho-racing/sentinel/core/model" "github.com/gaucho-racing/sentinel/core/service" "github.com/gin-gonic/gin" @@ -11,10 +12,7 @@ import ( ) func GetAllUsers(c *gin.Context) { - Require(c, Any( - RequestTokenHasAudience(c, "sentinel"), - RequestTokenHasScope(c, "sentinel:all"), - )) + Require(c, RequestTokenHasResourceScope(c, authz.UserReadScope)) users, err := service.GetAllUsers() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -44,12 +42,7 @@ func GetUserByID(c *gin.Context) { // internal only. The user:read scope is allowed when the caller // is reading themselves (matches the existing patterns on // GetUserLogins and GetUserRecentApplications). - Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasUserID(c, id), - RequestTokenHasScope(c, "user:read") && RequestTokenHasUserID(c, id), - RequestUserIsAdmin(c), - )) + Require(c, RequestTokenHasResourceScope(c, authz.UserReadScope)) user, err := service.GetUserByID(id) if err != nil { if err == gorm.ErrRecordNotFound { @@ -81,16 +74,17 @@ func CreateOrUpdateUser(c *gin.Context) { // their own profile, plus the usual admin/internal overrides. if existing.ID == "" { Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), - RequestUserIsAdmin(c), + RequestTokenHasInternalAccess(c), + RequestTokenHasResourceScope(c, authz.UserWriteScope) && RequestUserIsAdmin(c), )) } else { Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasUserID(c, existing.ID), - RequestTokenHasEntityID(c, existing.EntityID), - RequestTokenHasScope(c, "user:write") && RequestTokenHasUserID(c, existing.ID), - RequestUserIsAdmin(c), + RequestTokenHasInternalAccess(c), + RequestTokenHasResourceScope(c, authz.UserWriteScope) && Any( + RequestTokenHasUserID(c, existing.ID), + RequestTokenHasEntityID(c, existing.EntityID), + RequestUserIsAdmin(c), + ), )) } @@ -111,8 +105,8 @@ func DeleteUser(c *gin.Context) { // this endpoint (a separate account-closure flow would handle // that with the right cleanup). Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), - RequestUserIsAdmin(c), + RequestTokenHasInternalAccess(c), + RequestTokenHasResourceScope(c, authz.UserWriteScope) && RequestUserIsAdmin(c), )) id := c.Param("id") if err := service.DeleteUser(id); err != nil { @@ -127,12 +121,7 @@ func GetUserGroups(c *gin.Context) { // Same authorization-signal concern as GetEntityGroups — leaking // who has admin-equivalent groups would be a recon win for an // attacker. Self / admin / internal only. - Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasUserID(c, id), - RequestTokenHasScope(c, "groups:read") && RequestTokenHasUserID(c, id), - RequestUserIsAdmin(c), - )) + Require(c, RequestTokenHasResourceScope(c, authz.GroupsReadScope)) user, err := service.GetUserByID(id) if err != nil { if err == gorm.ErrRecordNotFound { @@ -148,9 +137,11 @@ func GetUserGroups(c *gin.Context) { func GetUserRecentApplications(c *gin.Context) { id := c.Param("id") Require(c, Any( - RequestTokenHasAudience(c, "sentinel"), - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasScope(c, "user:read") && RequestTokenHasUserID(c, id), + RequestTokenHasInternalAccess(c), + RequestTokenHasResourceScope(c, authz.UserReadScope) && Any( + RequestTokenHasUserID(c, id), + RequestUserIsAdmin(c), + ), )) user, err := service.GetUserByID(id) @@ -181,9 +172,11 @@ func GetUserRecentApplications(c *gin.Context) { func GetUserLogins(c *gin.Context) { id := c.Param("id") Require(c, Any( - RequestTokenHasAudience(c, "sentinel"), - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasScope(c, "user:read") && RequestTokenHasUserID(c, id), + RequestTokenHasInternalAccess(c), + RequestTokenHasResourceScope(c, authz.UserReadScope) && Any( + RequestTokenHasUserID(c, id), + RequestUserIsAdmin(c), + ), )) user, err := service.GetUserByID(id) diff --git a/core/authz/scope.go b/core/authz/scope.go new file mode 100644 index 00000000..fdfa68ac --- /dev/null +++ b/core/authz/scope.go @@ -0,0 +1,62 @@ +package authz + +import "strings" + +const ( + SentinelAudience = "sentinel" + SentinelAllScope = "sentinel:all" + SentinelInternalScope = "sentinel:internal" + UserReadScope = "user:read" + UserWriteScope = "user:write" + GroupsReadScope = "groups:read" + GroupsWriteScope = "groups:write" + ApplicationsReadScope = "applications:read" + ApplicationsWriteScope = "applications:write" +) + +func HasScope(scopes string, required string) bool { + for _, scope := range strings.Fields(scopes) { + if scope == required { + return true + } + } + return false +} + +func AudienceContains(audience any, required string) bool { + switch value := audience.(type) { + case string: + return value == required + case []string: + for _, candidate := range value { + if candidate == required { + return true + } + } + case []any: + for _, candidate := range value { + if candidate == required { + return true + } + } + } + return false +} + +func IsInternalServiceAccount(scopes string, audience any, claims map[string]any) bool { + if !HasScope(scopes, SentinelInternalScope) || !AudienceContains(audience, SentinelAudience) { + return false + } + accountType, typeOK := claims["type"].(string) + accountID, idOK := claims["service_account_id"].(string) + return typeOK && accountType == "service_account" && idOK && accountID != "" +} + +func IsFirstPartyUser(scopes string, audience any, claims map[string]any) bool { + if !HasScope(scopes, SentinelAllScope) || !AudienceContains(audience, SentinelAudience) { + return false + } + entityType, typeOK := claims["entity_type"].(string) + userID, idOK := claims["user_id"].(string) + return typeOK && entityType == "USER" && idOK && userID != "" +} diff --git a/core/jobs/init.go b/core/jobs/init.go index 4a4366c4..f2184220 100644 --- a/core/jobs/init.go +++ b/core/jobs/init.go @@ -4,6 +4,7 @@ import ( "errors" "strings" + "github.com/gaucho-racing/sentinel/core/authz" "github.com/gaucho-racing/sentinel/core/config" "github.com/gaucho-racing/sentinel/core/database" "github.com/gaucho-racing/sentinel/core/model" @@ -233,7 +234,7 @@ func initializeInternalServiceAccounts() { sa, err = service.CreateServiceAccountForApp( SentinelApplicationID, name, - "sentinel:all", + authz.SentinelInternalScope, 0, // never expires SentinelCoreEntityID, ) @@ -247,6 +248,20 @@ func initializeInternalServiceAccounts() { continue } + if sa.Scope != authz.SentinelInternalScope { + if err := database.DB.Model(&model.ServiceAccount{}). + Where("id = ?", sa.ID). + Updates(map[string]any{ + "scope": authz.SentinelInternalScope, + "signed_token": "", + }).Error; err != nil { + logger.SugarLogger.Errorf("Failed to migrate internal SA %s scope: %v", name, err) + continue + } + sa.Scope = authz.SentinelInternalScope + sa.SignedToken = "" + } + if sa.SignedToken == "" { if _, _, err := service.MintServiceAccountToken(sa); err != nil { logger.SugarLogger.Errorf("Failed to mint token for internal SA %s: %v", name, err) diff --git a/discord/api/auth.go b/discord/api/auth.go index cc57430d..f647aa29 100644 --- a/discord/api/auth.go +++ b/discord/api/auth.go @@ -2,8 +2,10 @@ package api import ( "net/http" + "net/url" "strings" + "github.com/gaucho-racing/sentinel/discord/authz" "github.com/gaucho-racing/sentinel/discord/pkg/logger" "github.com/gaucho-racing/sentinel/discord/pkg/sentinel" "github.com/gin-gonic/gin" @@ -29,6 +31,7 @@ func AuthChecker() gin.HandlerFunc { return } c.Set("Auth-Token", token) + c.Set("Auth-Claims", claims) if sub, ok := claims["sub"].(string); ok { c.Set("Auth-EntityID", sub) } @@ -69,10 +72,62 @@ func RequestTokenHasScope(c *gin.Context, scope string) bool { if !ok { return false } - for _, s := range strings.Split(scopes.(string), " ") { - if s == scope { - return true - } + return authz.HasScope(scopes.(string), scope) +} + +func GetRequestToken(c *gin.Context) string { + token, _ := c.Get("Auth-Token") + value, _ := token.(string) + return value +} + +func GetRequestTokenClaims(c *gin.Context) map[string]any { + claims, _ := c.Get("Auth-Claims") + value, _ := claims.(map[string]any) + return value +} + +func RequestTokenHasInternalAccess(c *gin.Context) bool { + claims := GetRequestTokenClaims(c) + return authz.IsInternalServiceAccount( + getRequestTokenScopes(c), + claims["aud"], + claims, + ) +} + +func RequestTokenHasFirstPartyAccess(c *gin.Context) bool { + claims := GetRequestTokenClaims(c) + return authz.IsFirstPartyUser(getRequestTokenScopes(c), claims["aud"], claims) +} + +func RequestTokenCanManageGroup(c *gin.Context, groupID string) bool { + if RequestTokenHasInternalAccess(c) { + return true + } + if !RequestTokenHasFirstPartyAccess(c) && !RequestTokenHasScope(c, authz.GroupsWriteScope) { + return false + } + return requestCoreAccessCheck(c, "/api/groups/"+url.PathEscape(groupID)+"/write-access") +} + +func RequestTokenHasAdminAccess(c *gin.Context) bool { + if RequestTokenHasInternalAccess(c) { + return true + } + return RequestTokenHasFirstPartyAccess(c) && requestCoreAccessCheck(c, "/api/entities/@me/admin-access") +} + +func getRequestTokenScopes(c *gin.Context) string { + scopes, _ := c.Get("Auth-Scope") + value, _ := scopes.(string) + return value +} + +func requestCoreAccessCheck(c *gin.Context, route string) bool { + token := GetRequestToken(c) + if token == "" { + return false } - return false + return sentinel.Get(route, nil, map[string]string{"Authorization": "Bearer " + token}) == nil } diff --git a/discord/api/channel_archive.go b/discord/api/channel_archive.go index f479ddfb..eb19541d 100644 --- a/discord/api/channel_archive.go +++ b/discord/api/channel_archive.go @@ -9,7 +9,7 @@ import ( ) func GetArchivedChannels(c *gin.Context) { - Require(c, RequestTokenHasScope(c, "sentinel:all")) + Require(c, RequestTokenHasInternalAccess(c) || RequestTokenHasFirstPartyAccess(c)) records, err := service.GetAllArchivedChannels() if err != nil { diff --git a/discord/api/guild.go b/discord/api/guild.go index b6ca5bfb..2f2df5b2 100644 --- a/discord/api/guild.go +++ b/discord/api/guild.go @@ -29,7 +29,7 @@ type discordChannel struct { } func GetRoles(c *gin.Context) { - Require(c, RequestTokenHasScope(c, "sentinel:all")) + Require(c, RequestTokenHasInternalAccess(c) || RequestTokenHasFirstPartyAccess(c)) roles, err := service.GetGuildRoles() if err != nil { @@ -54,7 +54,7 @@ func GetRoles(c *gin.Context) { } func GetChannels(c *gin.Context) { - Require(c, RequestTokenHasScope(c, "sentinel:all")) + Require(c, RequestTokenHasInternalAccess(c) || RequestTokenHasFirstPartyAccess(c)) channels, err := service.GetGuildChannels() if err != nil { diff --git a/discord/api/role_binding.go b/discord/api/role_binding.go index 284e8d19..947e77a8 100644 --- a/discord/api/role_binding.go +++ b/discord/api/role_binding.go @@ -11,7 +11,7 @@ import ( // ListRoleBindings returns all role bindings, optionally filtered by group_id. // Used by the web UI (per-group view) and by reconciliation (full sweep). func ListRoleBindings(c *gin.Context) { - Require(c, RequestTokenHasScope(c, "sentinel:all")) + Require(c, RequestTokenHasInternalAccess(c) || RequestTokenHasFirstPartyAccess(c)) groupID := c.Query("group_id") var ( @@ -36,13 +36,12 @@ type createRoleBindingRequest struct { } func CreateRoleBinding(c *gin.Context) { - Require(c, RequestTokenHasScope(c, "sentinel:all")) - var req createRoleBindingRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + Require(c, RequestTokenCanManageGroup(c, req.GroupID)) if len(req.DiscordRoleIDs) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "discord_role_ids must be non-empty"}) return @@ -63,14 +62,13 @@ func CreateRoleBinding(c *gin.Context) { // required to scope the delete — protects against URL tampering that would // otherwise let a caller delete a binding they don't own access to. func DeleteRoleBinding(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 } + Require(c, RequestTokenCanManageGroup(c, groupID)) if err := service.DeleteRoleBinding(groupID, bindingID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/discord/authz/scope.go b/discord/authz/scope.go new file mode 100644 index 00000000..fdfa68ac --- /dev/null +++ b/discord/authz/scope.go @@ -0,0 +1,62 @@ +package authz + +import "strings" + +const ( + SentinelAudience = "sentinel" + SentinelAllScope = "sentinel:all" + SentinelInternalScope = "sentinel:internal" + UserReadScope = "user:read" + UserWriteScope = "user:write" + GroupsReadScope = "groups:read" + GroupsWriteScope = "groups:write" + ApplicationsReadScope = "applications:read" + ApplicationsWriteScope = "applications:write" +) + +func HasScope(scopes string, required string) bool { + for _, scope := range strings.Fields(scopes) { + if scope == required { + return true + } + } + return false +} + +func AudienceContains(audience any, required string) bool { + switch value := audience.(type) { + case string: + return value == required + case []string: + for _, candidate := range value { + if candidate == required { + return true + } + } + case []any: + for _, candidate := range value { + if candidate == required { + return true + } + } + } + return false +} + +func IsInternalServiceAccount(scopes string, audience any, claims map[string]any) bool { + if !HasScope(scopes, SentinelInternalScope) || !AudienceContains(audience, SentinelAudience) { + return false + } + accountType, typeOK := claims["type"].(string) + accountID, idOK := claims["service_account_id"].(string) + return typeOK && accountType == "service_account" && idOK && accountID != "" +} + +func IsFirstPartyUser(scopes string, audience any, claims map[string]any) bool { + if !HasScope(scopes, SentinelAllScope) || !AudienceContains(audience, SentinelAudience) { + return false + } + entityType, typeOK := claims["entity_type"].(string) + userID, idOK := claims["user_id"].(string) + return typeOK && entityType == "USER" && idOK && userID != "" +} diff --git a/discord/pkg/sentinel/sentinel.go b/discord/pkg/sentinel/sentinel.go index 4710d45d..a18fe696 100644 --- a/discord/pkg/sentinel/sentinel.go +++ b/discord/pkg/sentinel/sentinel.go @@ -145,7 +145,11 @@ func do(method, route string, body, result interface{}, headers []map[string]str // it at startup. The explicit `headers` param (used by Bootstrap // itself for the X-Bootstrap-Secret header) overrides nothing here; // it's additive, applied after SetAuthToken. - if b := getBearer(); b != "" { + explicitBearer := false + if len(headers) > 0 { + _, explicitBearer = headers[0]["Authorization"] + } + if b := getBearer(); b != "" && !explicitBearer { req = req.SetAuthToken(b) } if body != nil { diff --git a/google/api/auth.go b/google/api/auth.go index bce7b436..8dcf8ede 100644 --- a/google/api/auth.go +++ b/google/api/auth.go @@ -2,8 +2,10 @@ package api import ( "net/http" + "net/url" "strings" + "github.com/gaucho-racing/sentinel/google/authz" "github.com/gaucho-racing/sentinel/google/pkg/logger" "github.com/gaucho-racing/sentinel/google/pkg/sentinel" "github.com/gin-gonic/gin" @@ -29,6 +31,7 @@ func AuthChecker() gin.HandlerFunc { return } c.Set("Auth-Token", token) + c.Set("Auth-Claims", claims) if sub, ok := claims["sub"].(string); ok { c.Set("Auth-EntityID", sub) } @@ -81,10 +84,62 @@ func RequestTokenHasScope(c *gin.Context, scope string) bool { if !ok { return false } - for _, s := range strings.Split(scopes.(string), " ") { - if s == scope { - return true - } + return authz.HasScope(scopes.(string), scope) +} + +func GetRequestToken(c *gin.Context) string { + token, _ := c.Get("Auth-Token") + value, _ := token.(string) + return value +} + +func GetRequestTokenClaims(c *gin.Context) map[string]any { + claims, _ := c.Get("Auth-Claims") + value, _ := claims.(map[string]any) + return value +} + +func RequestTokenHasInternalAccess(c *gin.Context) bool { + claims := GetRequestTokenClaims(c) + return authz.IsInternalServiceAccount( + getRequestTokenScopes(c), + claims["aud"], + claims, + ) +} + +func RequestTokenHasFirstPartyAccess(c *gin.Context) bool { + claims := GetRequestTokenClaims(c) + return authz.IsFirstPartyUser(getRequestTokenScopes(c), claims["aud"], claims) +} + +func RequestTokenCanManageGroup(c *gin.Context, groupID string) bool { + if RequestTokenHasInternalAccess(c) { + return true + } + if !RequestTokenHasFirstPartyAccess(c) && !RequestTokenHasScope(c, authz.GroupsWriteScope) { + return false + } + return requestCoreAccessCheck(c, "/api/groups/"+url.PathEscape(groupID)+"/write-access") +} + +func RequestTokenHasAdminAccess(c *gin.Context) bool { + if RequestTokenHasInternalAccess(c) { + return true + } + return RequestTokenHasFirstPartyAccess(c) && requestCoreAccessCheck(c, "/api/entities/@me/admin-access") +} + +func getRequestTokenScopes(c *gin.Context) string { + scopes, _ := c.Get("Auth-Scope") + value, _ := scopes.(string) + return value +} + +func requestCoreAccessCheck(c *gin.Context, route string) bool { + token := GetRequestToken(c) + if token == "" { + return false } - return false + return sentinel.Get(route, nil, map[string]string{"Authorization": "Bearer " + token}) == nil } diff --git a/google/api/group_binding.go b/google/api/group_binding.go index 6a58a369..aa78d7ca 100644 --- a/google/api/group_binding.go +++ b/google/api/group_binding.go @@ -14,7 +14,7 @@ import ( // 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")) + Require(c, RequestTokenHasInternalAccess(c) || RequestTokenHasFirstPartyAccess(c)) if groupID := c.Query("group_id"); groupID != "" { binding, err := service.GetGoogleBindingForGroup(groupID) @@ -44,13 +44,12 @@ type createGoogleBindingRequest struct { } 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 } + Require(c, RequestTokenCanManageGroup(c, req.GroupID)) 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"}) @@ -72,14 +71,13 @@ func CreateGoogleBinding(c *gin.Context) { // 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 } + Require(c, RequestTokenCanManageGroup(c, groupID)) if err := service.DeleteGoogleBinding(groupID, bindingID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/google/api/reconcile.go b/google/api/reconcile.go index 055c8e53..5499d31d 100644 --- a/google/api/reconcile.go +++ b/google/api/reconcile.go @@ -10,7 +10,7 @@ import ( // 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")) + Require(c, RequestTokenHasAdminAccess(c)) service.TriggerReconcile() c.JSON(http.StatusAccepted, gin.H{"message": "reconcile triggered"}) } diff --git a/google/authz/scope.go b/google/authz/scope.go new file mode 100644 index 00000000..fdfa68ac --- /dev/null +++ b/google/authz/scope.go @@ -0,0 +1,62 @@ +package authz + +import "strings" + +const ( + SentinelAudience = "sentinel" + SentinelAllScope = "sentinel:all" + SentinelInternalScope = "sentinel:internal" + UserReadScope = "user:read" + UserWriteScope = "user:write" + GroupsReadScope = "groups:read" + GroupsWriteScope = "groups:write" + ApplicationsReadScope = "applications:read" + ApplicationsWriteScope = "applications:write" +) + +func HasScope(scopes string, required string) bool { + for _, scope := range strings.Fields(scopes) { + if scope == required { + return true + } + } + return false +} + +func AudienceContains(audience any, required string) bool { + switch value := audience.(type) { + case string: + return value == required + case []string: + for _, candidate := range value { + if candidate == required { + return true + } + } + case []any: + for _, candidate := range value { + if candidate == required { + return true + } + } + } + return false +} + +func IsInternalServiceAccount(scopes string, audience any, claims map[string]any) bool { + if !HasScope(scopes, SentinelInternalScope) || !AudienceContains(audience, SentinelAudience) { + return false + } + accountType, typeOK := claims["type"].(string) + accountID, idOK := claims["service_account_id"].(string) + return typeOK && accountType == "service_account" && idOK && accountID != "" +} + +func IsFirstPartyUser(scopes string, audience any, claims map[string]any) bool { + if !HasScope(scopes, SentinelAllScope) || !AudienceContains(audience, SentinelAudience) { + return false + } + entityType, typeOK := claims["entity_type"].(string) + userID, idOK := claims["user_id"].(string) + return typeOK && entityType == "USER" && idOK && userID != "" +} diff --git a/google/pkg/sentinel/sentinel.go b/google/pkg/sentinel/sentinel.go index cc486fdd..4b0cca14 100644 --- a/google/pkg/sentinel/sentinel.go +++ b/google/pkg/sentinel/sentinel.go @@ -148,7 +148,11 @@ func do(method, route string, body, result interface{}, headers []map[string]str // it at startup. The explicit `headers` param (used by Bootstrap // itself for the X-Bootstrap-Secret header) is additive, applied // after SetAuthToken. - if b := getBearer(); b != "" { + explicitBearer := false + if len(headers) > 0 { + _, explicitBearer = headers[0]["Authorization"] + } + if b := getBearer(); b != "" && !explicitBearer { req = req.SetAuthToken(b) } if body != nil { diff --git a/oauth/api/authorize.go b/oauth/api/authorize.go index f449e7af..94652511 100644 --- a/oauth/api/authorize.go +++ b/oauth/api/authorize.go @@ -7,6 +7,7 @@ import ( "net/url" "time" + "github.com/gaucho-racing/sentinel/oauth/authz" "github.com/gaucho-racing/sentinel/oauth/pkg/logger" "github.com/gaucho-racing/sentinel/oauth/pkg/sentinel" "github.com/gaucho-racing/sentinel/oauth/service" @@ -79,8 +80,8 @@ func ValidateAuthorize(c *gin.Context) { return } - if service.ScopesContain(scope, "sentinel:all") { - c.JSON(http.StatusBadRequest, gin.H{"error": "sentinel:all scope cannot be requested by client applications"}) + if containsReservedScope(scope) { + c.JSON(http.StatusBadRequest, gin.H{"error": "reserved Sentinel scopes cannot be requested by client applications"}) return } @@ -151,7 +152,7 @@ func Authorize(c *gin.Context) { return } - if !service.ValidateScopes(scope) || service.ScopesContain(scope, "sentinel:all") { + if !service.ValidateScopes(scope) || containsReservedScope(scope) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid scope"}) return } @@ -172,3 +173,8 @@ func Authorize(c *gin.Context) { "redirect_uri": redirectURI, }) } + +func containsReservedScope(scope string) bool { + return service.ScopesContain(scope, authz.SentinelAllScope) || + service.ScopesContain(scope, authz.SentinelInternalScope) +} diff --git a/oauth/api/login.go b/oauth/api/login.go index 138dc395..1707e2ba 100644 --- a/oauth/api/login.go +++ b/oauth/api/login.go @@ -4,6 +4,7 @@ import ( "errors" "net/http" + "github.com/gaucho-racing/sentinel/oauth/authz" "github.com/gaucho-racing/sentinel/oauth/config" "github.com/gaucho-racing/sentinel/oauth/pkg/logger" "github.com/gaucho-racing/sentinel/oauth/pkg/sentinel" @@ -96,7 +97,7 @@ func RefreshSession(c *gin.Context) { // groups:read, etc.) exist for third-party OAuth clients to request via // the consent flow; first-party sessions don't need them since the audit // surface already gates on the sentinel audience or sentinel:all scope. -const firstPartyAccessScope = "sentinel:all" +const firstPartyAccessScope = authz.SentinelAllScope const firstPartyRefreshScope = firstPartyAccessScope + " refresh_token" // mintFirstPartySession builds claims, mints access + refresh JWTs, and diff --git a/oauth/api/well_known.go b/oauth/api/well_known.go index c87fe945..862ff826 100644 --- a/oauth/api/well_known.go +++ b/oauth/api/well_known.go @@ -4,6 +4,7 @@ import ( "net/http" "sort" + "github.com/gaucho-racing/sentinel/oauth/authz" "github.com/gaucho-racing/sentinel/oauth/config" "github.com/gaucho-racing/sentinel/oauth/model" "github.com/gin-gonic/gin" @@ -41,7 +42,7 @@ func OpenIDConfiguration(c *gin.Context) { func supportedScopes() []string { scopes := make([]string, 0, len(model.ValidScopes)) for scope := range model.ValidScopes { - if scope == "sentinel:all" { + if scope == authz.SentinelAllScope || scope == authz.SentinelInternalScope { continue } scopes = append(scopes, scope) diff --git a/oauth/authz/scope.go b/oauth/authz/scope.go new file mode 100644 index 00000000..1aeef008 --- /dev/null +++ b/oauth/authz/scope.go @@ -0,0 +1,53 @@ +package authz + +import "strings" + +const ( + SentinelAudience = "sentinel" + SentinelAllScope = "sentinel:all" + SentinelInternalScope = "sentinel:internal" + UserReadScope = "user:read" + UserWriteScope = "user:write" + GroupsReadScope = "groups:read" + GroupsWriteScope = "groups:write" + ApplicationsReadScope = "applications:read" + ApplicationsWriteScope = "applications:write" +) + +func HasScope(scopes string, required string) bool { + for _, scope := range strings.Fields(scopes) { + if scope == required { + return true + } + } + return false +} + +func AudienceContains(audience any, required string) bool { + switch value := audience.(type) { + case string: + return value == required + case []string: + for _, candidate := range value { + if candidate == required { + return true + } + } + case []any: + for _, candidate := range value { + if candidate == required { + return true + } + } + } + return false +} + +func IsInternalServiceAccount(scopes string, audience any, claims map[string]any) bool { + if !HasScope(scopes, SentinelInternalScope) || !AudienceContains(audience, SentinelAudience) { + return false + } + accountType, typeOK := claims["type"].(string) + accountID, idOK := claims["service_account_id"].(string) + return typeOK && accountType == "service_account" && idOK && accountID != "" +} diff --git a/oauth/model/scope.go b/oauth/model/scope.go index 9f476449..c0af305b 100644 --- a/oauth/model/scope.go +++ b/oauth/model/scope.go @@ -1,14 +1,18 @@ package model +import "github.com/gaucho-racing/sentinel/oauth/authz" + var ValidScopes = map[string]string{ - "openid": "Authenticate you and issue an ID token", - "profile": "Read your basic profile (name, username, picture)", - "email": "Read your email address", - "offline_access": "Stay signed in without re-authenticating (refresh token)", - "user:read": "Read user and entity profile information", - "user:write": "Update user profile information", - "groups:read": "Read group memberships", - "applications:read": "Read application details", - "applications:write": "Manage applications", - "sentinel:all": "Full internal access (not available to third-party apps)", + "openid": "Authenticate you and issue an ID token", + "profile": "Read your basic profile (name, username, picture)", + "email": "Read your email address", + "offline_access": "Stay signed in without re-authenticating (refresh token)", + authz.UserReadScope: "Read user and entity profile information", + authz.UserWriteScope: "Update user profile information", + authz.GroupsReadScope: "Read group memberships", + authz.GroupsWriteScope: "Manage groups and group memberships", + authz.ApplicationsReadScope: "Read application details", + authz.ApplicationsWriteScope: "Manage applications", + authz.SentinelAllScope: "Full read and write access to Sentinel resources", + authz.SentinelInternalScope: "Full internal service access", } diff --git a/oauth/service/token_claims.go b/oauth/service/token_claims.go index 75bd86bc..077a8083 100644 --- a/oauth/service/token_claims.go +++ b/oauth/service/token_claims.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" + "github.com/gaucho-racing/sentinel/oauth/authz" "github.com/gaucho-racing/sentinel/oauth/config" "github.com/gaucho-racing/sentinel/oauth/pkg/sentinel" ) @@ -105,7 +106,7 @@ func SetGroupClaims(claims map[string]interface{}, groups []GroupRef) { // include the groups claim — granted by the first-party sentinel:all scope or // the explicit groups:read scope. func GroupsClaimAllowed(scope string) bool { - return ScopesContain(scope, "sentinel:all") || ScopesContain(scope, "groups:read") + return ScopesContain(scope, authz.SentinelAllScope) || ScopesContain(scope, authz.GroupsReadScope) } // FilteredGroups resolves the groups an entity should expose to a given diff --git a/web/src/lib/scopes.ts b/web/src/lib/scopes.ts index 7e5296e8..03bea4e3 100644 --- a/web/src/lib/scopes.ts +++ b/web/src/lib/scopes.ts @@ -53,6 +53,11 @@ const SCOPES: Record = { description: "See which groups you belong to and your role in each.", icon: Users, }, + "groups:write": { + label: "Manage groups", + description: "Create groups and manage groups you own.", + icon: Users, + }, "applications:read": { label: "Read application details", description: "See registered applications and their metadata.", From 161f49a49e4742e23b6705da1ffff282222152d0 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Wed, 9 Sep 2026 14:20:53 -0700 Subject: [PATCH 2/4] refactor(auth): inspect admin claims locally --- core/api/api.go | 7 ------- discord/api/auth.go | 7 ------- google/api/auth.go | 3 ++- google/authz/scope.go | 6 +++++- 4 files changed, 7 insertions(+), 16 deletions(-) diff --git a/core/api/api.go b/core/api/api.go index 47cbcdcc..dfdb22d2 100644 --- a/core/api/api.go +++ b/core/api/api.go @@ -67,7 +67,6 @@ func InitializeRoutes(router *gin.Engine) { router.POST("/core/internal/bootstrap-token", BootstrapToken) router.GET("/entities/@me", GetMe) - router.GET("/entities/@me/admin-access", CheckAdminAccess) router.POST("/entities/resolve", ResolveIdentitySummaries) router.GET("/entities/:id", GetEntity) @@ -288,12 +287,6 @@ func RequestTokenHasResourceScope(c *gin.Context, scope string) bool { ) } -func CheckAdminAccess(c *gin.Context) { - Require(c, RequestTokenHasInternalAccess(c) || - RequestTokenHasFirstPartyAccess(c) && RequestUserIsAdmin(c)) - c.Status(http.StatusNoContent) -} - // GetRequestTokenEntityID returns the subject (entity_id) of the bearer that // AuthChecker resolved, or "" if no valid bearer was presented. func GetRequestTokenEntityID(c *gin.Context) string { diff --git a/discord/api/auth.go b/discord/api/auth.go index f647aa29..8e39dd4b 100644 --- a/discord/api/auth.go +++ b/discord/api/auth.go @@ -111,13 +111,6 @@ func RequestTokenCanManageGroup(c *gin.Context, groupID string) bool { return requestCoreAccessCheck(c, "/api/groups/"+url.PathEscape(groupID)+"/write-access") } -func RequestTokenHasAdminAccess(c *gin.Context) bool { - if RequestTokenHasInternalAccess(c) { - return true - } - return RequestTokenHasFirstPartyAccess(c) && requestCoreAccessCheck(c, "/api/entities/@me/admin-access") -} - func getRequestTokenScopes(c *gin.Context) string { scopes, _ := c.Get("Auth-Scope") value, _ := scopes.(string) diff --git a/google/api/auth.go b/google/api/auth.go index 8dcf8ede..93fb0056 100644 --- a/google/api/auth.go +++ b/google/api/auth.go @@ -127,7 +127,8 @@ func RequestTokenHasAdminAccess(c *gin.Context) bool { if RequestTokenHasInternalAccess(c) { return true } - return RequestTokenHasFirstPartyAccess(c) && requestCoreAccessCheck(c, "/api/entities/@me/admin-access") + claims := GetRequestTokenClaims(c) + return RequestTokenHasFirstPartyAccess(c) && authz.StringClaimContains(claims["groups"], "Admins") } func getRequestTokenScopes(c *gin.Context) string { diff --git a/google/authz/scope.go b/google/authz/scope.go index fdfa68ac..dc674af2 100644 --- a/google/authz/scope.go +++ b/google/authz/scope.go @@ -24,7 +24,11 @@ func HasScope(scopes string, required string) bool { } func AudienceContains(audience any, required string) bool { - switch value := audience.(type) { + return StringClaimContains(audience, required) +} + +func StringClaimContains(claim any, required string) bool { + switch value := claim.(type) { case string: return value == required case []string: From 9264c697ea52789002219ba9b23123cc8cce0e69 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Wed, 9 Sep 2026 15:13:24 -0700 Subject: [PATCH 3/4] feat(google): manage bound group lifecycle --- google/api/api.go | 2 + google/api/group_binding.go | 138 ++++++++++-- google/service/google.go | 65 +++++- google/service/group_binding.go | 280 +++++++++++++++++++++++++ google/service/group_sync.go | 9 +- web/src/lib/google.ts | 63 ++++++ web/src/pages/groups/GroupEditPage.tsx | 228 +++++++++++++++++--- 7 files changed, 739 insertions(+), 46 deletions(-) diff --git a/google/api/api.go b/google/api/api.go index 87ebb914..48cea541 100644 --- a/google/api/api.go +++ b/google/api/api.go @@ -39,7 +39,9 @@ func InitializeRoutes(router *gin.Engine) { router.GET("/google/ping", Ping) router.GET("/google/group-bindings", ListGoogleBindings) + router.POST("/google/group-bindings/preflight", PreflightGoogleBinding) router.POST("/google/group-bindings", CreateGoogleBinding) + router.PUT("/google/group-bindings", ApplyGoogleBinding) router.DELETE("/google/group-bindings/:bindingID", DeleteGoogleBinding) router.POST("/google/reconcile", TriggerReconcile) diff --git a/google/api/group_binding.go b/google/api/group_binding.go index aa78d7ca..f244fb77 100644 --- a/google/api/group_binding.go +++ b/google/api/group_binding.go @@ -1,10 +1,12 @@ package api import ( + "context" "errors" "net/http" "net/mail" "strings" + "time" "github.com/gaucho-racing/sentinel/google/model" "github.com/gaucho-racing/sentinel/google/service" @@ -39,8 +41,9 @@ func ListGoogleBindings(c *gin.Context) { } type createGoogleBindingRequest struct { - GroupID string `json:"group_id" binding:"required"` - GoogleGroupEmail string `json:"google_group_email" binding:"required"` + GroupID string `json:"group_id" binding:"required"` + GoogleGroupEmail string `json:"google_group_email" binding:"required"` + ConfirmOverwriteRequestedGroupMembers bool `json:"confirm_overwrite_requested_group_members"` } func CreateGoogleBinding(c *gin.Context) { @@ -50,23 +53,123 @@ func CreateGoogleBinding(c *gin.Context) { return } Require(c, RequestTokenCanManageGroup(c, req.GroupID)) - email := strings.TrimSpace(req.GoogleGroupEmail) - if _, err := mail.ParseAddress(email); err != nil { + email, err := normalizeGoogleGroupEmail(req.GoogleGroupEmail, false) + if 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, - }) + ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Minute) + defer cancel() + binding, preflight, err := service.ApplyGoogleBinding( + ctx, + req.GroupID, + email, + "", + service.GoogleBindingConfirmations{ + OverwriteRequestedGroupMembers: req.ConfirmOverwriteRequestedGroupMembers, + }, + ) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + writeGoogleBindingError(c, err, preflight) return } c.JSON(http.StatusOK, binding) } +type googleBindingPreflightRequest struct { + GroupID string `json:"group_id" binding:"required"` + GoogleGroupEmail string `json:"google_group_email"` +} + +func PreflightGoogleBinding(c *gin.Context) { + var req googleBindingPreflightRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + Require(c, RequestTokenCanManageGroup(c, req.GroupID)) + email, err := normalizeGoogleGroupEmail(req.GoogleGroupEmail, true) + if err != nil { + 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) + defer cancel() + preflight, err := service.PreflightGoogleBinding(ctx, req.GroupID, email) + if err != nil { + writeGoogleBindingError(c, err, preflight) + return + } + c.JSON(http.StatusOK, preflight) +} + +type applyGoogleBindingRequest struct { + GroupID string `json:"group_id" binding:"required"` + GoogleGroupEmail string `json:"google_group_email"` + ExpectedCurrentBindingID string `json:"expected_current_binding_id"` + ConfirmOverwriteRequestedGroupMembers bool `json:"confirm_overwrite_requested_group_members"` + ConfirmDeletePreviousGroup bool `json:"confirm_delete_previous_group"` +} + +func ApplyGoogleBinding(c *gin.Context) { + var req applyGoogleBindingRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + Require(c, RequestTokenCanManageGroup(c, req.GroupID)) + email, err := normalizeGoogleGroupEmail(req.GoogleGroupEmail, true) + if err != nil { + 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) + defer cancel() + binding, preflight, err := service.ApplyGoogleBinding( + ctx, + req.GroupID, + email, + req.ExpectedCurrentBindingID, + service.GoogleBindingConfirmations{ + OverwriteRequestedGroupMembers: req.ConfirmOverwriteRequestedGroupMembers, + DeletePreviousGroup: req.ConfirmDeletePreviousGroup, + }, + ) + if err != nil { + writeGoogleBindingError(c, err, preflight) + return + } + c.JSON(http.StatusOK, gin.H{"binding": binding}) +} + +func normalizeGoogleGroupEmail(value string, allowEmpty bool) (string, error) { + email := strings.ToLower(strings.TrimSpace(value)) + if email == "" && allowEmpty { + return "", nil + } + parsed, err := mail.ParseAddress(email) + if err != nil || parsed.Address != email { + return "", errors.New("invalid email address") + } + return email, nil +} + +func writeGoogleBindingError(c *gin.Context, err error, preflight service.GoogleBindingPreflight) { + var confirmationErr *service.ConfirmationRequiredError + var stateChangedErr *service.BindingStateChangedError + var alreadyBoundErr *service.GoogleGroupAlreadyBoundError + switch { + case errors.As(err, &confirmationErr), errors.As(err, &stateChangedErr): + 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.ErrGoogleSyncUnavailable): + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } +} + // 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. @@ -78,8 +181,19 @@ func DeleteGoogleBinding(c *gin.Context) { return } Require(c, RequestTokenCanManageGroup(c, groupID)) - if err := service.DeleteGoogleBinding(groupID, bindingID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Minute) + defer cancel() + _, preflight, err := service.ApplyGoogleBinding( + ctx, + groupID, + "", + bindingID, + service.GoogleBindingConfirmations{ + DeletePreviousGroup: c.Query("confirm_delete_group") == "true", + }, + ) + if err != nil { + writeGoogleBindingError(c, err, preflight) return } c.Status(http.StatusNoContent) diff --git a/google/service/google.go b/google/service/google.go index 14b9a21a..e46b248d 100644 --- a/google/service/google.go +++ b/google/service/google.go @@ -21,7 +21,7 @@ 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 + Role string } // InitGoogleClient builds the Directory client from GOOGLE_SERVICE_ACCOUNT, @@ -33,7 +33,11 @@ func InitGoogleClient() error { 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) + jwtConfig, err := google.JWTConfigFromJSON( + []byte(config.GoogleServiceAccount), + directory.AdminDirectoryGroupScope, + directory.AdminDirectoryGroupMemberScope, + ) if err != nil { return fmt.Errorf("parse google service account: %w", err) } @@ -49,6 +53,63 @@ func InitGoogleClient() error { return nil } +func getGoogleGroup(ctx context.Context, groupEmail string) (*directory.Group, bool, error) { + group, err := directorySvc.Groups.Get(groupEmail).Context(ctx).Do() + if err != nil { + if isStatus(err, 404) { + return nil, false, nil + } + return nil, false, fmt.Errorf("get google group %s: %w", groupEmail, err) + } + return group, true, nil +} + +func createGoogleGroup(ctx context.Context, groupEmail, name string) (*directory.Group, error) { + group, err := directorySvc.Groups.Insert(&directory.Group{ + Email: groupEmail, + Name: name, + }).Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("create google group %s: %w", groupEmail, err) + } + return group, nil +} + +func deleteGoogleGroup(ctx context.Context, groupEmail string) error { + err := directorySvc.Groups.Delete(groupEmail).Context(ctx).Do() + if err != nil && !isStatus(err, 404) { + return fmt.Errorf("delete google group %s: %w", groupEmail, err) + } + return nil +} + +func ensureGroupOwner(ctx context.Context, groupEmail, ownerEmail string) error { + member, err := directorySvc.Members.Get(groupEmail, ownerEmail).Context(ctx).Do() + if err != nil { + if !isStatus(err, 404) { + return fmt.Errorf("get owner %s in %s: %w", ownerEmail, groupEmail, err) + } + _, err = directorySvc.Members.Insert(groupEmail, &directory.Member{ + Email: ownerEmail, + Role: "OWNER", + }).Context(ctx).Do() + if err != nil { + return fmt.Errorf("add owner %s to %s: %w", ownerEmail, groupEmail, err) + } + return nil + } + if member.Role == "OWNER" { + return nil + } + _, err = directorySvc.Members.Update(groupEmail, ownerEmail, &directory.Member{ + Role: "OWNER", + }).Context(ctx).Do() + if err != nil { + return fmt.Errorf("promote %s to owner of %s: %w", ownerEmail, groupEmail, err) + } + return nil +} + // listGroupMembers returns every member of the Google Group, paginated. func listGroupMembers(ctx context.Context, groupEmail string) ([]memberEntry, error) { var members []memberEntry diff --git a/google/service/group_binding.go b/google/service/group_binding.go index d76bdf80..26846481 100644 --- a/google/service/group_binding.go +++ b/google/service/group_binding.go @@ -1,15 +1,78 @@ package service import ( + "context" "errors" + "fmt" + "strings" + "sync" "github.com/gaucho-racing/sentinel/google/database" "github.com/gaucho-racing/sentinel/google/model" + "github.com/gaucho-racing/sentinel/google/pkg/sentinel" "github.com/gaucho-racing/ulid-go" "gorm.io/gorm" ) var ErrBindingNotFound = errors.New("group google binding not found") +var ErrGoogleSyncUnavailable = errors.New("google group management is not configured") + +const ManagedGoogleGroupOwnerEmail = "team@gauchoracing.com" + +type GoogleGroupMemberSnapshot struct { + Email string `json:"email"` + Role string `json:"role"` +} + +type GoogleGroupSnapshot struct { + RequestedEmail string `json:"requested_email"` + ID string `json:"id,omitempty"` + Email string `json:"email"` + Name string `json:"name,omitempty"` + Exists bool `json:"exists"` + Members []GoogleGroupMemberSnapshot `json:"members"` +} + +type GoogleBindingConfirmations struct { + OverwriteRequestedGroupMembers bool `json:"overwrite_requested_group_members"` + DeletePreviousGroup bool `json:"delete_previous_group"` +} + +type GoogleBindingPreflight struct { + GroupID string `json:"group_id"` + RequestedEmail string `json:"requested_email"` + BindingChanged bool `json:"binding_changed"` + CurrentBinding *model.GroupGoogleBinding `json:"current_binding"` + PreviousGroup *GoogleGroupSnapshot `json:"previous_group"` + RequestedGroup *GoogleGroupSnapshot `json:"requested_group"` + RequiredConfirmation GoogleBindingConfirmations `json:"required_confirmation"` +} + +type ConfirmationRequiredError struct { + Preflight GoogleBindingPreflight +} + +func (e *ConfirmationRequiredError) Error() string { + return "google group binding confirmation is required" +} + +type BindingStateChangedError struct { + Preflight GoogleBindingPreflight +} + +func (e *BindingStateChangedError) Error() string { + return "google group binding changed after preflight" +} + +type GoogleGroupAlreadyBoundError struct { + Binding model.GroupGoogleBinding +} + +func (e *GoogleGroupAlreadyBoundError) Error() string { + return fmt.Sprintf("google group %s is already bound to sentinel group %s", e.Binding.GoogleGroupEmail, e.Binding.GroupID) +} + +var bindingMutationMu sync.Mutex func GetAllGoogleBindings() ([]model.GroupGoogleBinding, error) { bindings := []model.GroupGoogleBinding{} @@ -33,6 +96,17 @@ func GetGoogleBindingForGroup(groupID string) (model.GroupGoogleBinding, error) return binding, nil } +func getGoogleBindingForEmail(googleGroupEmail string) (model.GroupGoogleBinding, error) { + var binding model.GroupGoogleBinding + if err := database.DB.Where("LOWER(google_group_email) = ?", strings.ToLower(googleGroupEmail)).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") @@ -43,6 +117,212 @@ func CreateGoogleBinding(binding model.GroupGoogleBinding) (model.GroupGoogleBin 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 { + return nil, err + } + snapshot := &GoogleGroupSnapshot{ + RequestedEmail: groupEmail, + Email: groupEmail, + Exists: exists, + Members: []GoogleGroupMemberSnapshot{}, + } + if !exists { + return snapshot, nil + } + snapshot.ID = group.Id + snapshot.Email = strings.ToLower(group.Email) + snapshot.Name = group.Name + members, err := listGroupMembers(ctx, groupEmail) + if err != nil { + return nil, err + } + for _, member := range members { + snapshot.Members = append(snapshot.Members, GoogleGroupMemberSnapshot{ + Email: strings.ToLower(member.Email), + Role: member.Role, + }) + } + return snapshot, nil +} + +func PreflightGoogleBinding(ctx context.Context, groupID, requestedEmail string) (GoogleBindingPreflight, error) { + if directorySvc == nil { + return GoogleBindingPreflight{}, ErrGoogleSyncUnavailable + } + requestedEmail = strings.ToLower(strings.TrimSpace(requestedEmail)) + preflight := GoogleBindingPreflight{ + GroupID: groupID, + RequestedEmail: requestedEmail, + } + current, err := GetGoogleBindingForGroup(groupID) + if err != nil && !errors.Is(err, ErrBindingNotFound) { + return GoogleBindingPreflight{}, err + } + if err == nil { + preflight.CurrentBinding = ¤t + } + currentEmail := "" + if preflight.CurrentBinding != nil { + currentEmail = strings.ToLower(preflight.CurrentBinding.GoogleGroupEmail) + preflight.PreviousGroup, err = inspectGoogleGroup(ctx, currentEmail) + if err != nil { + return GoogleBindingPreflight{}, err + } + } + preflight.BindingChanged = currentEmail != requestedEmail + if requestedEmail != "" { + preflight.RequestedGroup, err = inspectGoogleGroup(ctx, requestedEmail) + if err != nil { + return GoogleBindingPreflight{}, err + } + bindingEmail := requestedEmail + if preflight.RequestedGroup.Exists { + bindingEmail = preflight.RequestedGroup.Email + } + bound, bindingErr := getGoogleBindingForEmail(bindingEmail) + if bindingErr != nil && !errors.Is(bindingErr, ErrBindingNotFound) { + return GoogleBindingPreflight{}, bindingErr + } + if bindingErr == nil && bound.GroupID != groupID { + return GoogleBindingPreflight{}, &GoogleGroupAlreadyBoundError{Binding: bound} + } + } + if !preflight.BindingChanged { + return preflight, nil + } + sameGoogleGroup := preflight.PreviousGroup != nil && + preflight.PreviousGroup.Exists && + preflight.RequestedGroup != nil && + preflight.RequestedGroup.Exists && + preflight.PreviousGroup.ID == preflight.RequestedGroup.ID + preflight.RequiredConfirmation.DeletePreviousGroup = preflight.PreviousGroup != nil && + preflight.PreviousGroup.Exists && + !sameGoogleGroup + preflight.RequiredConfirmation.OverwriteRequestedGroupMembers = preflight.RequestedGroup != nil && + preflight.RequestedGroup.Exists && + len(preflight.RequestedGroup.Members) > 0 && + !sameGoogleGroup + return preflight, nil +} + +type coreGroup struct { + Name string `json:"name"` +} + +func getCoreGroup(groupID string) (coreGroup, error) { + var group coreGroup + if err := sentinel.Get("/api/groups/"+groupID, &group); err != nil { + return coreGroup{}, err + } + if strings.TrimSpace(group.Name) == "" { + return coreGroup{}, errors.New("sentinel group has no name") + } + return group, nil +} + +func ApplyGoogleBinding( + ctx context.Context, + groupID string, + requestedEmail string, + expectedCurrentBindingID string, + confirmations GoogleBindingConfirmations, +) (*model.GroupGoogleBinding, GoogleBindingPreflight, error) { + bindingMutationMu.Lock() + defer bindingMutationMu.Unlock() + + preflight, err := PreflightGoogleBinding(ctx, groupID, requestedEmail) + if err != nil { + return nil, GoogleBindingPreflight{}, err + } + currentBindingID := "" + if preflight.CurrentBinding != nil { + currentBindingID = preflight.CurrentBinding.ID + } + if currentBindingID != expectedCurrentBindingID { + return nil, preflight, &BindingStateChangedError{Preflight: preflight} + } + if (preflight.RequiredConfirmation.OverwriteRequestedGroupMembers && !confirmations.OverwriteRequestedGroupMembers) || + (preflight.RequiredConfirmation.DeletePreviousGroup && !confirmations.DeletePreviousGroup) { + return nil, preflight, &ConfirmationRequiredError{Preflight: preflight} + } + if !preflight.BindingChanged { + return preflight.CurrentBinding, preflight, nil + } + + 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, + } + if err := reconcileBinding( + ctx, + candidate, + confirmations.OverwriteRequestedGroupMembers, + ); err != nil { + if createdRequestedGroup { + _ = deleteGoogleGroup(ctx, canonicalRequestedEmail) + } + return nil, preflight, err + } + } + + if preflight.RequiredConfirmation.DeletePreviousGroup { + if err := deleteGoogleGroup(ctx, preflight.PreviousGroup.Email); err != nil { + return nil, preflight, err + } + } + + if canonicalRequestedEmail == "" { + if preflight.CurrentBinding != nil { + if err := DeleteGoogleBinding(groupID, preflight.CurrentBinding.ID); err != nil { + return nil, preflight, err + } + } + return nil, preflight, nil + } + if preflight.CurrentBinding != nil { + binding, err := updateGoogleBinding(*preflight.CurrentBinding, canonicalRequestedEmail) + if err != nil { + return nil, preflight, err + } + return &binding, preflight, nil + } + binding, err := CreateGoogleBinding(model.GroupGoogleBinding{ + GroupID: groupID, + GoogleGroupEmail: canonicalRequestedEmail, + }) + if err != nil { + return nil, preflight, err + } + return &binding, preflight, 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 { diff --git a/google/service/group_sync.go b/google/service/group_sync.go index db7b3eb6..e8d17487 100644 --- a/google/service/group_sync.go +++ b/google/service/group_sync.go @@ -58,7 +58,10 @@ func resolveEntityEmail(entityID string) (string, error) { // 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 { +func reconcileBinding(ctx context.Context, b model.GroupGoogleBinding, allowBulkRemovals bool) error { + if err := ensureGroupOwner(ctx, b.GoogleGroupEmail, ManagedGoogleGroupOwnerEmail); err != nil { + return err + } members, err := getGroupMembers(b.GroupID) if err != nil { return fmt.Errorf("fetch sentinel members for group %s: %w", b.GroupID, err) @@ -114,7 +117,7 @@ func reconcileBinding(ctx context.Context, b model.GroupGoogleBinding) error { } toRemove = append(toRemove, email) } - if len(toRemove) > config.GoogleSyncMaxRemovals { + if !allowBulkRemovals && 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 } @@ -142,7 +145,7 @@ func ReconcileAll(ctx context.Context) error { if err := ctx.Err(); err != nil { return err } - if err := reconcileBinding(ctx, b); err != nil { + if err := reconcileBinding(ctx, b, false); err != nil { if errors.Is(err, context.Canceled) { return err } diff --git a/web/src/lib/google.ts b/web/src/lib/google.ts index cdbdc60e..090ab8eb 100644 --- a/web/src/lib/google.ts +++ b/web/src/lib/google.ts @@ -11,6 +11,69 @@ export type GroupGoogleBinding = { created_at: string } +export type GoogleGroupMemberSnapshot = { + email: string + role: "OWNER" | "MANAGER" | "MEMBER" +} + +export type GoogleGroupSnapshot = { + requested_email: string + id?: string + email: string + name?: string + exists: boolean + members: GoogleGroupMemberSnapshot[] +} + +export type GoogleBindingConfirmations = { + overwrite_requested_group_members: boolean + delete_previous_group: boolean +} + +export type GoogleBindingPreflight = { + group_id: string + requested_email: string + binding_changed: boolean + current_binding: GroupGoogleBinding | null + previous_group: GoogleGroupSnapshot | null + requested_group: GoogleGroupSnapshot | null + required_confirmation: GoogleBindingConfirmations +} + +export async function preflightGoogleBinding( + groupID: string, + googleGroupEmail: string, +) { + const res = await api.post( + "/google/group-bindings/preflight", + { + group_id: groupID, + google_group_email: googleGroupEmail, + }, + ) + return res.data +} + +export async function applyGoogleBinding( + groupID: string, + googleGroupEmail: string, + expectedCurrentBindingID: string, + confirmations: GoogleBindingConfirmations, +) { + const res = await api.put<{ binding: GroupGoogleBinding | null }>( + "/google/group-bindings", + { + group_id: groupID, + google_group_email: googleGroupEmail, + expected_current_binding_id: expectedCurrentBindingID, + confirm_overwrite_requested_group_members: + confirmations.overwrite_requested_group_members, + confirm_delete_previous_group: confirmations.delete_previous_group, + }, + ) + return res.data.binding +} + // useGroupGoogleBinding returns the single binding for a group, or null. The // list endpoint returns an array (0 or 1 rows) since the mapping is 1:1. export function useGroupGoogleBinding(groupID: string) { diff --git a/web/src/pages/groups/GroupEditPage.tsx b/web/src/pages/groups/GroupEditPage.tsx index b9dda48e..f6a83676 100644 --- a/web/src/pages/groups/GroupEditPage.tsx +++ b/web/src/pages/groups/GroupEditPage.tsx @@ -1,5 +1,5 @@ import { useQuery, useQueryClient } from "@tanstack/react-query" -import { ArrowLeft, Bot, Mail, Plus, Sparkles, Trash2, X } from "lucide-react" +import { AlertTriangle, ArrowLeft, Bot, Mail, Plus, Sparkles, Trash2, X } from "lucide-react" import { useEffect, useMemo, useState } from "react" import { Link, useNavigate, useParams } from "react-router-dom" import { toast } from "sonner" @@ -39,7 +39,13 @@ import { useGroupDiscordBindings, type GroupDiscordRoleBinding, } from "@/lib/discord" -import { useGroupGoogleBinding } from "@/lib/google" +import { + applyGoogleBinding, + preflightGoogleBinding, + useGroupGoogleBinding, + type GoogleBindingConfirmations, + type GoogleBindingPreflight, +} from "@/lib/google" import type { Group, GroupMember, GroupOwner, GroupSource } from "@/lib/groups" import { DiscordRolePickerDialog } from "./DiscordRolePickerDialog" @@ -249,7 +255,8 @@ function GoogleSyncCard({ Mirror this group's members into a Google Group. Everyone in the group is synced as a MEMBER; owners and managers added directly in Google are left - untouched. Leave blank to disable. Changes apply on Save. + untouched. Leave blank to disable and delete the linked Google Group. Changes + apply on Save. @@ -464,6 +471,10 @@ export default function GroupEditPage() { const [syncingGoogle, setSyncingGoogle] = useState(false) const [confirmOpen, setConfirmOpen] = useState(false) const [cascadeConfirmOpen, setCascadeConfirmOpen] = useState(false) + const [googleConfirmOpen, setGoogleConfirmOpen] = useState(false) + const [googlePreflight, setGooglePreflight] = useState( + null, + ) // Pending binding state — staged changes are applied to the server in // commitSave alongside the basics, so the Save button is the single commit // point for the entire page. @@ -626,10 +637,25 @@ export default function GroupEditPage() { }) } - async function commitSave() { + async function commitSave( + preflight: GoogleBindingPreflight | null, + confirmations: GoogleBindingConfirmations = { + overwrite_requested_group_members: false, + delete_previous_group: false, + }, + ) { if (!values || !id) return setSubmitting(true) try { + if (preflight?.binding_changed) { + await applyGoogleBinding( + id, + googleEmail.trim(), + preflight.current_binding?.id ?? "", + confirmations, + ) + } + // Only apply staged binding changes if Discord is staying enabled. // If DISCORD is being unchecked the group will stop honoring bindings // regardless, so any pending edits would just create orphans. @@ -663,27 +689,6 @@ export default function GroupEditPage() { }) } } - // Google Group binding is 1:1, so diff the input against the server - // binding: clear/replace deletes the old row, a non-empty value upserts. - // Not gated on allowed_sources — Google is an outbound projection, not a - // membership source. - const serverGoogleBinding = googleBindingQuery.data ?? null - const desiredGoogleEmail = googleEmail.trim() - const currentGoogleEmail = serverGoogleBinding?.google_group_email ?? "" - if (desiredGoogleEmail !== currentGoogleEmail) { - if (serverGoogleBinding) { - await api.delete(`/google/group-bindings/${serverGoogleBinding.id}`, { - params: { group_id: id }, - }) - } - if (desiredGoogleEmail) { - await api.post(`/google/group-bindings`, { - group_id: id, - google_group_email: desiredGoogleEmail, - }) - } - } - // Diff application links against the server state. POST is upsert, // so we send any link whose required flag differs (or doesn't exist // yet); DELETE anything the server has that's no longer in our state. @@ -716,12 +721,39 @@ export default function GroupEditPage() { qc.invalidateQueries({ queryKey: ["group", id, "discord-bindings"] }) qc.invalidateQueries({ queryKey: ["group", id, "google-binding"] }) qc.invalidateQueries({ queryKey: ["group", id, "applications"] }) + setGoogleConfirmOpen(false) + setGooglePreflight(null) toast.success("Group updated") navigate(`/groups/${id}`) } catch (err: unknown) { + const response = ( + err as { + response?: { + status?: number + data?: { error?: string; preflight?: GoogleBindingPreflight } + } + } + ).response + if (response?.status === 409 && response.data?.preflight) { + const freshPreflight = response.data.preflight + const requiresConfirmation = + freshPreflight.required_confirmation.overwrite_requested_group_members || + freshPreflight.required_confirmation.delete_previous_group + if (requiresConfirmation) { + setGooglePreflight(freshPreflight) + setGoogleConfirmOpen(true) + } else { + setGoogleConfirmOpen(false) + setGooglePreflight(null) + void qc.invalidateQueries({ queryKey: ["group", id, "google-binding"] }) + toast.error( + "The Google Group binding changed while you were editing. Review it and save again.", + ) + } + return + } const message = - (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? - "Couldn't save the group." + response?.data?.error ?? "Couldn't save the group." toast.error(message) } finally { setSubmitting(false) @@ -729,6 +761,39 @@ export default function GroupEditPage() { } } + async function preflightGoogleAndSave() { + if (!id) return + const desiredGoogleEmail = googleEmail.trim().toLowerCase() + const currentGoogleEmail = ( + googleBindingQuery.data?.google_group_email ?? "" + ).toLowerCase() + if (desiredGoogleEmail === currentGoogleEmail) { + await commitSave(null) + return + } + + setSubmitting(true) + try { + const preflight = await preflightGoogleBinding(id, desiredGoogleEmail) + const requiresConfirmation = + preflight.required_confirmation.overwrite_requested_group_members || + preflight.required_confirmation.delete_previous_group + if (requiresConfirmation) { + setGooglePreflight(preflight) + setGoogleConfirmOpen(true) + return + } + await commitSave(preflight) + } catch (err: unknown) { + const message = + (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? + "Couldn't inspect the Google Group." + toast.error(message) + } finally { + setSubmitting(false) + } + } + function handleSubmit() { if (!values || !id || !query.data) return const savedSources = new Set(query.data.allowed_sources ?? []) @@ -752,7 +817,7 @@ export default function GroupEditPage() { setCascadeConfirmOpen(true) return } - void commitSave() + void preflightGoogleAndSave() } async function handleDelete() { @@ -1037,13 +1102,118 @@ export default function GroupEditPage() { type="button" variant="destructive" disabled={submitting} - onClick={commitSave} + onClick={() => { + setCascadeConfirmOpen(false) + void preflightGoogleAndSave() + }} > {submitting ? "Saving…" : "Save anyway"} + + { + if (submitting) return + setGoogleConfirmOpen(open) + if (!open) setGooglePreflight(null) + }} + > + + +
+ +
+ Confirm Google Group changes + + Google Group membership and group deletion happen outside Sentinel. Review + these changes before saving. + +
+ + {googlePreflight && ( +
+ {googlePreflight.required_confirmation + .overwrite_requested_group_members && + googlePreflight.requested_group && ( +
+

+ Replace existing membership +

+

+ + {googlePreflight.requested_group.email} + {" "} + already has {googlePreflight.requested_group.members.length} direct + member + {googlePreflight.requested_group.members.length === 1 ? "" : "s"}. + Ordinary members will be reconciled to this Sentinel group. Existing + owners and managers will remain, and team@gauchoracing.com will be an + owner. +

+
    + {googlePreflight.requested_group.members.map((member) => ( +
  • + {member.email} · {member.role.toLowerCase()} +
  • + ))} +
+
+ )} + + {googlePreflight.required_confirmation.delete_previous_group && + googlePreflight.previous_group && ( +
+

+ Delete the previous Google Group +

+

+ + {googlePreflight.previous_group.email} + {" "} + and its {googlePreflight.previous_group.members.length} direct member + {googlePreflight.previous_group.members.length === 1 ? "" : "s"} will + be permanently deleted from Google Workspace. +

+
+ )} +
+ )} + +
+ + +
+
+
) } From f53504adebba5173b8713c3acfb947f431e7656d Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Wed, 9 Sep 2026 15:19:14 -0700 Subject: [PATCH 4/4] fix(google): restrict group sync to admins --- google/api/group_binding.go | 8 ++++---- web/src/pages/groups/GroupEditPage.tsx | 20 +++++++++++++++----- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/google/api/group_binding.go b/google/api/group_binding.go index f244fb77..66711724 100644 --- a/google/api/group_binding.go +++ b/google/api/group_binding.go @@ -52,7 +52,7 @@ func CreateGoogleBinding(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - Require(c, RequestTokenCanManageGroup(c, req.GroupID)) + Require(c, RequestTokenHasAdminAccess(c)) email, err := normalizeGoogleGroupEmail(req.GoogleGroupEmail, false) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "google_group_email must be a valid email address"}) @@ -87,7 +87,7 @@ func PreflightGoogleBinding(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - Require(c, RequestTokenCanManageGroup(c, req.GroupID)) + Require(c, RequestTokenHasAdminAccess(c)) email, err := normalizeGoogleGroupEmail(req.GoogleGroupEmail, true) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "google_group_email must be a valid email address"}) @@ -117,7 +117,7 @@ func ApplyGoogleBinding(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - Require(c, RequestTokenCanManageGroup(c, req.GroupID)) + Require(c, RequestTokenHasAdminAccess(c)) email, err := normalizeGoogleGroupEmail(req.GoogleGroupEmail, true) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "google_group_email must be a valid email address"}) @@ -180,7 +180,7 @@ func DeleteGoogleBinding(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "group_id query param is required"}) return } - Require(c, RequestTokenCanManageGroup(c, groupID)) + Require(c, RequestTokenHasAdminAccess(c)) ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Minute) defer cancel() _, preflight, err := service.ApplyGoogleBinding( diff --git a/web/src/pages/groups/GroupEditPage.tsx b/web/src/pages/groups/GroupEditPage.tsx index f6a83676..51862ca5 100644 --- a/web/src/pages/groups/GroupEditPage.tsx +++ b/web/src/pages/groups/GroupEditPage.tsx @@ -239,11 +239,13 @@ function GoogleSyncCard({ onChange, onSyncNow, syncing, + canManage, }: { email: string onChange: (email: string) => void onSyncNow: () => void syncing: boolean + canManage: boolean }) { return ( @@ -265,13 +267,20 @@ function GoogleSyncCard({ autoComplete="off" placeholder="team-aero@gauchoracing.com" value={email} + disabled={!canManage} onChange={(e) => onChange(e.target.value)} /> -
- -
+ {canManage ? ( +
+ +
+ ) : ( +

+ Only Sentinel admins can change Google Group sync. +

+ )}
) @@ -961,6 +970,7 @@ export default function GroupEditPage() { onChange={setGoogleEmail} onSyncNow={handleSyncGoogleNow} syncing={syncingGoogle} + canManage={isAdmin} />