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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions core/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,3 +290,36 @@ func RequestTokenHasUserID(c *gin.Context, userID string) bool {
func RequestUserIsAdmin(c *gin.Context) bool {
return service.IsAdmin(GetRequestTokenEntityID(c))
}

// RequestUserIsGroupOwner reports whether the bearer's subject entity
// is on the GroupOwner roster for groupID. Used by gates that let the
// owners of a group manage its members and join requests without
// requiring global admin elevation. Returns false for unauth'd
// requests and on any lookup error.
func RequestUserIsGroupOwner(c *gin.Context, groupID string) bool {
entityID := GetRequestTokenEntityID(c)
if entityID == "" {
return false
}
owner, err := service.GetGroupOwner(groupID, entityID)
return err == nil && owner.EntityID != ""
}

// requireGroupOwnerOrAdmin is the standard gate for endpoints that
// mutate group state — members, owners, join requests, conditional
// bindings. Group owners can manage their own group; admins override;
// sentinel:all bypasses (matches the codebase-wide convention where
// the scope is reserved for first-party internal automation). Aborts
// 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"),
RequestUserIsGroupOwner(c, groupID),
RequestUserIsAdmin(c),
) {
return true
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "you are not authorized to manage this group"})
return false
}
6 changes: 6 additions & 0 deletions core/api/conditional_binding.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ type createConditionalBindingRequest struct {

func CreateGroupConditionalBinding(c *gin.Context) {
id := c.Param("id")
if !requireGroupOwnerOrAdmin(c, id) {
return
}
var req createConditionalBindingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
Expand Down Expand Up @@ -57,6 +60,9 @@ func CreateGroupConditionalBinding(c *gin.Context) {

func DeleteGroupConditionalBinding(c *gin.Context) {
id := c.Param("id")
if !requireGroupOwnerOrAdmin(c, id) {
return
}
bindingID := c.Param("bindingID")
if err := service.DeleteConditionalBinding(id, bindingID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
Expand Down
78 changes: 78 additions & 0 deletions core/api/group.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,17 @@ func CreateOrUpdateGroup(c *gin.Context) {
return
}

// Create vs. update have different trust requirements. Anyone with a
// valid bearer can create a group (they become the auto-added owner
// below). Updates have to clear the owner-or-admin gate against the
// existing group — without this check, anyone could rename or
// rewrite allowed_sources on any group, including the Admins group.
if existing.ID == "" {
Require(c, RequestTokenExists(c))
} else if !requireGroupOwnerOrAdmin(c, existing.ID) {
return
}

available, err := service.IsGroupNameAvailable(req.Name, existing.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
Expand Down Expand Up @@ -207,6 +218,9 @@ func DeleteGroup(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"error": "the Admins group cannot be deleted"})
return
}
if !requireGroupOwnerOrAdmin(c, id) {
return
}
if err := service.DeleteGroup(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
Expand Down Expand Up @@ -236,6 +250,9 @@ type addGroupMemberRequest struct {

func AddGroupMember(c *gin.Context) {
id := c.Param("id")
if !requireGroupOwnerOrAdmin(c, id) {
return
}
var req addGroupMemberRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
Expand Down Expand Up @@ -266,6 +283,9 @@ func AddGroupMember(c *gin.Context) {

func RemoveGroupMember(c *gin.Context) {
id := c.Param("id")
if !requireGroupOwnerOrAdmin(c, id) {
return
}
entityID := c.Param("entityID")
source := c.Query("source")
if err := service.DeleteGroupMember(id, entityID, source); err != nil {
Expand Down Expand Up @@ -296,6 +316,9 @@ type addGroupOwnerRequest struct {

func AddGroupOwner(c *gin.Context) {
id := c.Param("id")
if !requireGroupOwnerOrAdmin(c, id) {
return
}
var req addGroupOwnerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
Expand All @@ -315,6 +338,9 @@ func AddGroupOwner(c *gin.Context) {

func RemoveGroupOwner(c *gin.Context) {
id := c.Param("id")
if !requireGroupOwnerOrAdmin(c, id) {
return
}
entityID := c.Param("entityID")
if err := service.DeleteGroupOwner(id, entityID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
Expand Down Expand Up @@ -362,6 +388,15 @@ func CreateGroupJoinRequest(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Users request to join groups on their own behalf — the bearer's
// entity must match the requested entity_id. The only way around
// 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),
))
if err := validateMembershipExpiration(req.HasExpiration, req.ExpiresAt); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
Expand Down Expand Up @@ -397,6 +432,10 @@ 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 {
Expand Down Expand Up @@ -452,6 +491,10 @@ func ApproveGroupJoinRequest(c *gin.Context) {
}

func RejectGroupJoinRequest(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 {
Expand Down Expand Up @@ -479,6 +522,10 @@ func RejectGroupJoinRequest(c *gin.Context) {
}

func DeleteGroupJoinRequest(c *gin.Context) {
id := c.Param("id")
if !requireGroupOwnerOrAdmin(c, id) {
return
}
requestID := c.Param("requestID")
if err := service.DeleteJoinRequest(requestID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
Expand All @@ -495,12 +542,24 @@ type createJoinRequestCommentRequest struct {
}

func CreateJoinRequestComment(c *gin.Context) {
id := c.Param("id")
requestID := c.Param("requestID")
var req createJoinRequestCommentRequest
if err := c.ShouldBindJSON(&req); err != nil {
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.
Require(c, Any(
RequestTokenHasScope(c, "sentinel:all"),
RequestTokenHasEntityID(c, req.EntityID),
RequestUserIsGroupOwner(c, id),
RequestUserIsAdmin(c),
))
comment, err := service.CreateJoinRequestComment(model.GroupJoinRequestComment{
RequestID: requestID,
EntityID: req.EntityID,
Expand All @@ -514,7 +573,26 @@ func CreateJoinRequestComment(c *gin.Context) {
}

func DeleteJoinRequestComment(c *gin.Context) {
id := c.Param("id")
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 != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "comment not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
Require(c, Any(
RequestTokenHasScope(c, "sentinel:all"),
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()})
return
Expand Down
11 changes: 11 additions & 0 deletions core/service/group.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,17 @@ func GetCommentsForJoinRequest(requestID string) ([]model.GroupJoinRequestCommen
return comments, nil
}

// 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) {
var comment model.GroupJoinRequestComment
if err := database.DB.Where("id = ?", id).First(&comment).Error; err != nil {
return model.GroupJoinRequestComment{}, err
}
return comment, nil
}

func CreateJoinRequestComment(comment model.GroupJoinRequestComment) (model.GroupJoinRequestComment, error) {
if comment.ID == "" {
comment.ID = ulid.Make().Prefixed("gjrc")
Expand Down
Loading