diff --git a/core/api/api.go b/core/api/api.go index 6e71f1e..8718792 100644 --- a/core/api/api.go +++ b/core/api/api.go @@ -293,6 +293,14 @@ func RequestTokenHasUserID(c *gin.Context, userID string) bool { return GetRequestTokenUserID(c) == userID } +func RequestTokenHasInternalAccess(c *gin.Context) bool { + claims := GetRequestTokenClaims(c) + if claims == nil || claims["type"] != "service_account" { + return false + } + return RequestTokenHasScope(c, "sentinel:all") +} + // RequestUserIsAdmin reports whether the bearer's subject entity is a // member of the Admins group. Used to grant admin-only write access // without requiring per-resource ownership. Returns false for unauth'd @@ -324,7 +332,7 @@ func RequestUserIsGroupOwner(c *gin.Context, groupID string) bool { // the caller continues. func requireGroupOwnerOrAdmin(c *gin.Context, groupID string) bool { if Any( - RequestTokenHasScope(c, "sentinel:all"), + RequestTokenHasInternalAccess(c), RequestUserIsGroupOwner(c, groupID), RequestUserIsAdmin(c), ) { diff --git a/core/api/group.go b/core/api/group.go index 136d459..b7f7aba 100644 --- a/core/api/group.go +++ b/core/api/group.go @@ -141,7 +141,7 @@ func CreateOrUpdateGroup(c *gin.Context) { // on any group, including the Admins group. if existing.ID == "" { Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), + RequestTokenHasInternalAccess(c), RequestTokenHasScope(c, "groups:write"), )) } else if !requireGroupOwnerOrAdmin(c, existing.ID) { @@ -262,7 +262,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 +303,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 } @@ -460,8 +460,9 @@ func GetGroupJoinRequests(c *gin.Context) { } func GetGroupJoinRequest(c *gin.Context) { + id := c.Param("id") requestID := c.Param("requestID") - request, err := service.GetJoinRequestByID(requestID) + request, err := service.GetJoinRequestForGroup(id, requestID) if err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "join request not found"}) @@ -473,7 +474,7 @@ 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"), + RequestTokenHasInternalAccess(c), RequestTokenHasEntityID(c, request.EntityID), RequestUserIsGroupOwner(c, request.GroupID), RequestUserIsAdmin(c), @@ -499,7 +500,7 @@ 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"), + RequestTokenHasInternalAccess(c), RequestTokenHasEntityID(c, req.EntityID), RequestUserIsAdmin(c), )) @@ -527,7 +528,6 @@ func CreateGroupJoinRequest(c *gin.Context) { } type reviewJoinRequestRequest struct { - ReviewedBy string `json:"reviewed_by" binding:"required"` // Optional approval-time overrides. When provided, they replace the // expiration that the requester originally chose — used by reviewers // who want to grant a shorter/longer membership than what was asked @@ -539,25 +539,24 @@ type reviewJoinRequestRequest struct { func ApproveGroupJoinRequest(c *gin.Context) { id := c.Param("id") - if !requireGroupOwnerOrAdmin(c, id) { - return - } requestID := c.Param("requestID") - var req reviewJoinRequestRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - request, err := service.GetJoinRequestByID(requestID) + request, err := service.GetJoinRequestForGroup(id, requestID) if err != nil { - if err == gorm.ErrRecordNotFound { + if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "join request not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - + if !requireGroupOwnerOrAdmin(c, id) { + return + } + var req reviewJoinRequestRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } hasExpiration := request.HasExpiration expiresAt := request.ExpiresAt if req.HasExpiration != nil { @@ -571,25 +570,23 @@ func ApproveGroupJoinRequest(c *gin.Context) { return } - request.Status = string(model.GroupJoinRequestStatusApproved) - request.ReviewedBy = req.ReviewedBy - request.ReviewedAt = time.Now() - request.HasExpiration = hasExpiration - request.ExpiresAt = expiresAt - request, err = service.UpdateJoinRequest(request) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - _, err = service.CreateGroupMember(model.GroupMember{ - GroupID: request.GroupID, - EntityID: request.EntityID, - Source: string(model.GroupMemberSourceDirect), - AddedBy: req.ReviewedBy, - HasExpiration: hasExpiration, - ExpiresAt: expiresAt, - }) + request, err = service.ReviewJoinRequest( + id, + requestID, + GetRequestTokenEntityID(c), + model.GroupJoinRequestStatusApproved, + hasExpiration, + expiresAt, + ) if err != nil { + if errors.Is(err, service.ErrJoinRequestNotPending) || errors.Is(err, service.ErrGroupMemberExists) { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "join request not found"}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -597,37 +594,50 @@ func ApproveGroupJoinRequest(c *gin.Context) { "group_id": request.GroupID, "entity_id": request.EntityID, }) + service.ReconcileConditionalForEntity(request.EntityID) c.JSON(http.StatusOK, request) } func RejectGroupJoinRequest(c *gin.Context) { id := c.Param("id") + requestID := c.Param("requestID") + request, err := service.GetJoinRequestForGroup(id, requestID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "join request not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } if !requireGroupOwnerOrAdmin(c, id) { return } - requestID := c.Param("requestID") var req reviewJoinRequestRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - request, err := service.GetJoinRequestByID(requestID) + request, err = service.ReviewJoinRequest( + id, + requestID, + GetRequestTokenEntityID(c), + model.GroupJoinRequestStatusRejected, + request.HasExpiration, + request.ExpiresAt, + ) if err != nil { - if err == gorm.ErrRecordNotFound { + if errors.Is(err, service.ErrJoinRequestNotPending) { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "join request not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - request.Status = string(model.GroupJoinRequestStatusRejected) - request.ReviewedBy = req.ReviewedBy - request.ReviewedAt = time.Now() - request, err = service.UpdateJoinRequest(request) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } recordAudit(c, model.AuditActionJoinRequestRejected, "join_request", requestID, model.JSONMap{ "group_id": request.GroupID, "entity_id": request.EntityID, @@ -637,11 +647,30 @@ func RejectGroupJoinRequest(c *gin.Context) { func DeleteGroupJoinRequest(c *gin.Context) { id := c.Param("id") - if !requireGroupOwnerOrAdmin(c, id) { + requestID := c.Param("requestID") + request, err := service.GetJoinRequestForGroup(id, requestID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "join request not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - requestID := c.Param("requestID") - if err := service.DeleteJoinRequest(requestID); err != nil { + if !Any( + RequestTokenHasInternalAccess(c), + RequestTokenHasEntityID(c, request.EntityID) && request.Status == string(model.GroupJoinRequestStatusPending), + RequestUserIsGroupOwner(c, id), + RequestUserIsAdmin(c), + ) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "you are not authorized to delete this join request"}) + return + } + if err := service.DeleteJoinRequestForGroup(id, requestID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "join request not found"}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -651,8 +680,7 @@ func DeleteGroupJoinRequest(c *gin.Context) { // Join Request Comments type createJoinRequestCommentRequest struct { - EntityID string `json:"entity_id" binding:"required"` - Comment string `json:"comment" binding:"required"` + Comment string `json:"comment" binding:"required"` } func CreateJoinRequestComment(c *gin.Context) { @@ -663,20 +691,25 @@ func CreateJoinRequestComment(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - // Comments are scoped to the join-request thread: the requester - // (commenting on their own request) and the group's owners / - // admins (reviewing the request) are the legitimate posters. - // Bearer must match the comment's claimed entity_id; the owner/ - // admin path bypasses the self check. + request, err := service.GetJoinRequestForGroup(id, requestID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "join request not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + actorID := GetRequestTokenEntityID(c) Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasEntityID(c, req.EntityID), + RequestTokenHasInternalAccess(c), + actorID == request.EntityID, RequestUserIsGroupOwner(c, id), RequestUserIsAdmin(c), )) comment, err := service.CreateJoinRequestComment(model.GroupJoinRequestComment{ RequestID: requestID, - EntityID: req.EntityID, + EntityID: actorID, Comment: req.Comment, }) if err != nil { @@ -688,11 +721,17 @@ func CreateJoinRequestComment(c *gin.Context) { func DeleteJoinRequestComment(c *gin.Context) { id := c.Param("id") + requestID := c.Param("requestID") commentID := c.Param("commentID") - // Look up the comment first so we can authorize against its - // claimed author (the entity who posted it can delete their own - // comment; otherwise owner/admin/internal). - comment, err := service.GetJoinRequestComment(commentID) + if _, err := service.GetJoinRequestForGroup(id, requestID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "join request not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + comment, err := service.GetJoinRequestCommentForRequest(requestID, commentID) if err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "comment not found"}) @@ -702,12 +741,16 @@ func DeleteJoinRequestComment(c *gin.Context) { return } Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), + RequestTokenHasInternalAccess(c), RequestTokenHasEntityID(c, comment.EntityID), RequestUserIsGroupOwner(c, id), RequestUserIsAdmin(c), )) - if err := service.DeleteJoinRequestComment(commentID); err != nil { + if err := service.DeleteJoinRequestCommentForRequest(requestID, commentID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "comment not found"}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/core/api/jwt.go b/core/api/jwt.go index 21d64c5..48cb0eb 100644 --- a/core/api/jwt.go +++ b/core/api/jwt.go @@ -1,11 +1,13 @@ package api import ( + "errors" "net/http" "github.com/gaucho-racing/sentinel/core/config" "github.com/gaucho-racing/sentinel/core/service" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) func JWKS(c *gin.Context) { @@ -67,6 +69,10 @@ func RevokeToken(c *gin.Context) { id := c.Param("id") if err := service.RevokeToken(id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "token not found"}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/core/service/group.go b/core/service/group.go index 2567000..9d9a162 100644 --- a/core/service/group.go +++ b/core/service/group.go @@ -1,16 +1,26 @@ package service import ( + "errors" + "time" + "github.com/gaucho-racing/sentinel/core/database" "github.com/gaucho-racing/sentinel/core/model" "github.com/gaucho-racing/sentinel/core/pkg/logger" "github.com/gaucho-racing/ulid-go" + "gorm.io/gorm" + "gorm.io/gorm/clause" ) // AdminsGroupID is the fixed ID of the global Admins group. Members get // owner-equivalent permissions on every group and other admin-gated surfaces. const AdminsGroupID = "grp_01kqs3w6h82xkdnft94vpj7qrm" +var ( + ErrJoinRequestNotPending = errors.New("join request is not pending") + ErrGroupMemberExists = errors.New("entity is already a member of this group") +) + // IsAdmin reports whether the given entity is a member of the Admins group. // Returns false if the lookup fails so callers can treat it as a deny-by-default. func IsAdmin(entityID string) bool { @@ -203,9 +213,9 @@ func GetJoinRequestsByEntity(entityID string) ([]model.GroupJoinRequest, error) return requests, nil } -func GetJoinRequestByID(id string) (model.GroupJoinRequest, error) { +func GetJoinRequestForGroup(groupID string, id string) (model.GroupJoinRequest, error) { var request model.GroupJoinRequest - if err := database.DB.Where("id = ?", id).First(&request).Error; err != nil { + if err := database.DB.Where("id = ? AND group_id = ?", id, groupID).First(&request).Error; err != nil { return model.GroupJoinRequest{}, err } PopulateJoinRequest(&request) @@ -223,19 +233,63 @@ func CreateJoinRequest(request model.GroupJoinRequest) (model.GroupJoinRequest, return request, nil } -func UpdateJoinRequest(request model.GroupJoinRequest) (model.GroupJoinRequest, error) { - if err := database.DB.Save(&request).Error; err != nil { +func ReviewJoinRequest(groupID string, id string, reviewerID string, status model.GroupJoinRequestStatus, hasExpiration bool, expiresAt time.Time) (model.GroupJoinRequest, error) { + var request model.GroupJoinRequest + err := database.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ? AND group_id = ?", id, groupID). + First(&request).Error; err != nil { + return err + } + if request.Status != string(model.GroupJoinRequestStatusPending) { + return ErrJoinRequestNotPending + } + + if status == model.GroupJoinRequestStatusApproved { + member := model.GroupMember{ + GroupID: request.GroupID, + EntityID: request.EntityID, + Source: string(model.GroupMemberSourceDirect), + AddedBy: reviewerID, + HasExpiration: hasExpiration, + ExpiresAt: expiresAt, + } + result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&member) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrGroupMemberExists + } + } + + request.Status = string(status) + request.ReviewedBy = reviewerID + request.ReviewedAt = time.Now() + request.HasExpiration = hasExpiration + request.ExpiresAt = expiresAt + return tx.Save(&request).Error + }) + if err != nil { return model.GroupJoinRequest{}, err } PopulateJoinRequest(&request) return request, nil } -func DeleteJoinRequest(id string) error { - if err := database.DB.Where("id = ?", id).Delete(&model.GroupJoinRequest{}).Error; err != nil { - return err - } - return nil +func DeleteJoinRequestForGroup(groupID string, id string) error { + return database.DB.Transaction(func(tx *gorm.DB) error { + var request model.GroupJoinRequest + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ? AND group_id = ?", id, groupID). + First(&request).Error; err != nil { + return err + } + if err := tx.Where("request_id = ?", request.ID).Delete(&model.GroupJoinRequestComment{}).Error; err != nil { + return err + } + return tx.Delete(&request).Error + }) } func PopulateJoinRequest(request *model.GroupJoinRequest) { @@ -257,9 +311,9 @@ func GetCommentsForJoinRequest(requestID string) ([]model.GroupJoinRequestCommen // GetJoinRequestComment returns a single comment by ID. Used by the // delete handler to authorize the requester against the comment's // claimed author before letting them delete it. -func GetJoinRequestComment(id string) (model.GroupJoinRequestComment, error) { +func GetJoinRequestCommentForRequest(requestID string, id string) (model.GroupJoinRequestComment, error) { var comment model.GroupJoinRequestComment - if err := database.DB.Where("id = ?", id).First(&comment).Error; err != nil { + if err := database.DB.Where("id = ? AND request_id = ?", id, requestID).First(&comment).Error; err != nil { return model.GroupJoinRequestComment{}, err } return comment, nil @@ -275,9 +329,13 @@ func CreateJoinRequestComment(comment model.GroupJoinRequestComment) (model.Grou return comment, nil } -func DeleteJoinRequestComment(id string) error { - if err := database.DB.Where("id = ?", id).Delete(&model.GroupJoinRequestComment{}).Error; err != nil { - return err +func DeleteJoinRequestCommentForRequest(requestID string, id string) error { + result := database.DB.Where("id = ? AND request_id = ?", id, requestID).Delete(&model.GroupJoinRequestComment{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound } return nil } diff --git a/core/service/jwt.go b/core/service/jwt.go index 2bfbe5a..b20aec2 100644 --- a/core/service/jwt.go +++ b/core/service/jwt.go @@ -215,5 +215,8 @@ func RevokeToken(id string) error { logger.SugarLogger.Errorf("Failed to revoke token: %v", result.Error) return result.Error } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } return nil } diff --git a/oauth/api/login.go b/oauth/api/login.go index 19c39ca..138dc39 100644 --- a/oauth/api/login.go +++ b/oauth/api/login.go @@ -71,18 +71,19 @@ func RefreshSession(c *gin.Context) { return } - entityID, _ := claims["sub"].(string) - scope, _ := claims["scope"].(string) - if entityID == "" || !service.ScopesContain(scope, "refresh_token") { + refreshClaims, err := parseRefreshTokenClaims(claims, config.SentinelClientID) + if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "not a refresh token"}) return } - if tokenID, ok := claims["jti"].(string); ok { - sentinel.Delete("/api/core/token/"+tokenID, nil) + if err := sentinel.Delete("/api/core/token/"+refreshClaims.TokenID, nil); err != nil { + logger.SugarLogger.Errorf("Failed to revoke first-party refresh token %s: %v", refreshClaims.TokenID, err) + c.JSON(http.StatusBadGateway, gin.H{"error": "failed to rotate refresh token"}) + return } - resp, err := mintFirstPartySession(c, entityID) + resp, err := mintFirstPartySession(c, refreshClaims.EntityID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/oauth/api/refresh_claims.go b/oauth/api/refresh_claims.go new file mode 100644 index 0000000..5e6caa6 --- /dev/null +++ b/oauth/api/refresh_claims.go @@ -0,0 +1,48 @@ +package api + +import ( + "errors" + + "github.com/gaucho-racing/sentinel/oauth/service" +) + +var errInvalidRefreshTokenClaims = errors.New("invalid refresh token claims") + +type refreshTokenClaims struct { + EntityID string + Scope string + TokenID string +} + +func parseRefreshTokenClaims(claims map[string]interface{}, expectedAudience string) (refreshTokenClaims, error) { + entityID, _ := claims["sub"].(string) + scope, _ := claims["scope"].(string) + tokenID, _ := claims["jti"].(string) + if entityID == "" || tokenID == "" || !service.ScopesContain(scope, "refresh_token") { + return refreshTokenClaims{}, errInvalidRefreshTokenClaims + } + if !audienceMatches(claims["aud"], expectedAudience) { + return refreshTokenClaims{}, errInvalidRefreshTokenClaims + } + return refreshTokenClaims{EntityID: entityID, Scope: scope, TokenID: tokenID}, nil +} + +func audienceMatches(raw interface{}, expected string) bool { + if expected == "" { + return false + } + switch audience := raw.(type) { + case string: + return audience == expected + case []interface{}: + if len(audience) != 1 { + return false + } + value, ok := audience[0].(string) + return ok && value == expected + case []string: + return len(audience) == 1 && audience[0] == expected + default: + return false + } +} diff --git a/oauth/api/token.go b/oauth/api/token.go index 3bdf6d9..4abe03d 100644 --- a/oauth/api/token.go +++ b/oauth/api/token.go @@ -191,32 +191,31 @@ func handleRefreshTokenExchange(c *gin.Context) { return } - entityID, _ := claims["sub"].(string) - scope, _ := claims["scope"].(string) - - if !service.ScopesContain(scope, "refresh_token") { + refreshClaims, err := parseRefreshTokenClaims(claims, clientID) + if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "provided token is not a refresh token"}) return } - // Revoke the old refresh token - if tokenID, ok := claims["jti"].(string); ok { - sentinel.Delete("/api/core/token/"+tokenID, nil) + if err := sentinel.Delete("/api/core/token/"+refreshClaims.TokenID, nil); err != nil { + logger.SugarLogger.Errorf("Failed to revoke OAuth refresh token %s: %v", refreshClaims.TokenID, err) + c.JSON(http.StatusBadGateway, gin.H{"error": "failed to rotate refresh token"}) + return } // Re-check the gate on refresh — group membership may have changed // since the original grant. If the user no longer qualifies, the // refresh fails and they have to re-authenticate (which will hit the // gate again at the authorize step). - if err := service.CheckAccessGate(entityID, clientID); err != nil { + if err := service.CheckAccessGate(refreshClaims.EntityID, clientID); err != nil { writeGateError(c, err) return } // Strip refresh_token from scope for the access token - accessScope := service.RemoveScope(scope, "refresh_token") + accessScope := service.RemoveScope(refreshClaims.Scope, "refresh_token") - newClaims, err := service.BuildTokenClaims(entityID, clientID, accessScope) + newClaims, err := service.BuildTokenClaims(refreshClaims.EntityID, clientID, accessScope) if err != nil { logger.SugarLogger.Errorf("Failed to build token claims: %v", err) c.JSON(http.StatusBadGateway, gin.H{"error": "server_error"}) @@ -224,14 +223,14 @@ func handleRefreshTokenExchange(c *gin.Context) { } // Generate new access token - accessToken, accessTokenID, err := generateToken(entityID, clientID, accessScope, config.AccessTokenTTL, newClaims) + accessToken, accessTokenID, err := generateToken(refreshClaims.EntityID, clientID, accessScope, config.AccessTokenTTL, newClaims) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate access token"}) return } // Generate new refresh token (keep refresh_token in scope) - newRefreshToken, newRefreshTokenID, err := generateToken(entityID, clientID, scope, config.RefreshTokenTTL, newClaims) + newRefreshToken, newRefreshTokenID, err := generateToken(refreshClaims.EntityID, clientID, refreshClaims.Scope, config.RefreshTokenTTL, newClaims) if err != nil { logger.SugarLogger.Errorf("Failed to generate refresh token: %v", err) newRefreshToken = "" @@ -239,7 +238,7 @@ func handleRefreshTokenExchange(c *gin.Context) { } sentinel.Post("/api/core/entity/logins", map[string]string{ - "entity_id": entityID, + "entity_id": refreshClaims.EntityID, "client_id": clientID, "scope": accessScope, "access_token_id": accessTokenID, @@ -253,13 +252,13 @@ func handleRefreshTokenExchange(c *gin.Context) { // carried forward. var idToken string if service.ScopesContain(accessScope, "openid") { - idClaims, idErr := service.BuildIDTokenClaims(entityID, clientID, accessScope, "", accessToken, time.Now().Unix()) + idClaims, idErr := service.BuildIDTokenClaims(refreshClaims.EntityID, clientID, accessScope, "", accessToken, time.Now().Unix()) if idErr != nil { logger.SugarLogger.Errorf("Failed to build id token claims: %v", idErr) c.JSON(http.StatusBadGateway, gin.H{"error": "server_error"}) return } - idToken, _, err = generateToken(entityID, clientID, accessScope, config.AccessTokenTTL, idClaims) + idToken, _, err = generateToken(refreshClaims.EntityID, clientID, accessScope, config.AccessTokenTTL, idClaims) if err != nil { logger.SugarLogger.Errorf("Failed to generate id token: %v", err) idToken = "" diff --git a/web/src/pages/groups/GroupDetailsPage.tsx b/web/src/pages/groups/GroupDetailsPage.tsx index 7a10407..f02c7c6 100644 --- a/web/src/pages/groups/GroupDetailsPage.tsx +++ b/web/src/pages/groups/GroupDetailsPage.tsx @@ -492,7 +492,6 @@ export default function GroupDetailsPage() { const reason = joinReason.trim() if (reason) { await api.post(`/groups/${id}/requests/${res.data.id}/comments`, { - entity_id: myEntityID, comment: reason, }) } @@ -1199,7 +1198,6 @@ export default function GroupDetailsPage() { groupID={id ?? ""} request={reviewTarget?.request ?? null} action={reviewTarget?.action ?? "approve"} - reviewerEntityID={myEntityID} /> {addPersonOpen && ( diff --git a/web/src/pages/groups/GroupRequestDetailsPage.tsx b/web/src/pages/groups/GroupRequestDetailsPage.tsx index 9489d7a..079f4cd 100644 --- a/web/src/pages/groups/GroupRequestDetailsPage.tsx +++ b/web/src/pages/groups/GroupRequestDetailsPage.tsx @@ -179,7 +179,6 @@ export default function GroupRequestDetailsPage() { setPosting(true) try { await api.post(`/groups/${id}/requests/${requestID}/comments`, { - entity_id: myEntityID, comment: body, }) qc.invalidateQueries({ queryKey: ["group", id, "requests", requestID] }) @@ -421,7 +420,6 @@ export default function GroupRequestDetailsPage() { groupID={id ?? ""} request={request} action={reviewAction ?? "approve"} - reviewerEntityID={myEntityID} /> ) diff --git a/web/src/pages/groups/ReviewRequestDialog.tsx b/web/src/pages/groups/ReviewRequestDialog.tsx index 87e60b9..e9de0b3 100644 --- a/web/src/pages/groups/ReviewRequestDialog.tsx +++ b/web/src/pages/groups/ReviewRequestDialog.tsx @@ -41,14 +41,12 @@ export function ReviewRequestDialog({ groupID, request, action, - reviewerEntityID, }: { open: boolean onOpenChange: (open: boolean) => void groupID: string request: GroupJoinRequest | null action: Action - reviewerEntityID: string }) { const qc = useQueryClient() const [comment, setComment] = useState("") @@ -101,12 +99,11 @@ export function ReviewRequestDialog({ const trimmed = comment.trim() if (trimmed) { await api.post(`/groups/${groupID}/requests/${request.id}/comments`, { - entity_id: reviewerEntityID, comment: trimmed, }) } - const payload: Record = { reviewed_by: reviewerEntityID } + const payload: Record = {} if (overrideExpiresAt) { payload.has_expiration = true payload.expires_at = overrideExpiresAt.toISOString()