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
1 change: 1 addition & 0 deletions core/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ func InitializeRoutes(router *gin.Engine) {
router.GET("/core/ping", Ping)
router.GET("/core/keys", JWKS)
router.POST("/core/token", GenerateToken)
router.POST("/core/token/impersonate", ImpersonateToken)
router.POST("/core/token/validate", ValidateToken)
router.DELETE("/core/token/:id", RevokeToken)

Expand Down
73 changes: 73 additions & 0 deletions core/api/impersonation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package api

import (
"errors"
"net/http"

"github.com/gaucho-racing/sentinel/core/model"
"github.com/gaucho-racing/sentinel/core/service"
"github.com/gin-gonic/gin"
)

type impersonateTokenRequest struct {
UserID string `json:"user_id" binding:"required"`
ApplicationID string `json:"application_id" binding:"required"`
}

type impersonateTokenResponse struct {
AccessToken string `json:"access_token"`
TokenID string `json:"token_id"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope"`
}

func ImpersonateToken(c *gin.Context) {
c.Header("Cache-Control", "no-store")
if !RequestTokenExists(c) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
if !RequestTokenHasScope(c, service.ImpersonationScope) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "token lacks sentinel:impersonate"})
return
}

var req impersonateTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

result, err := service.ImpersonateUser(GetRequestTokenEntityID(c), req.UserID, req.ApplicationID)
if err != nil {
switch {
case errors.Is(err, service.ErrImpersonationCallerNotServiceAccount):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrImpersonationAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrImpersonationUserNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
case errors.Is(err, service.ErrImpersonationApplicationNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "application not found"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}

recordAudit(c, model.AuditActionImpersonationTokenIssued, "user", result.UserID, model.JSONMap{
"application_id": result.ApplicationID,
"client_id": result.ClientID,
"token_id": result.TokenID,
"scope": result.Scope,
"expires_in": result.ExpiresIn,
})
c.JSON(http.StatusOK, impersonateTokenResponse{
AccessToken: result.AccessToken,
TokenID: result.TokenID,
TokenType: "Bearer",
ExpiresIn: result.ExpiresIn,
Scope: result.Scope,
})
}
31 changes: 28 additions & 3 deletions core/api/service_account.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ func requireAppOwnerOrAdmin(c *gin.Context, appID string, scope string) (model.A
return app, true
}

func requireImpersonationScopeAdmin(c *gin.Context, scope string) bool {
if !authz.HasScope(scope, service.ImpersonationScope) {
return true
}
if RequestTokenHasInternalAccess(c) || RequestUserIsAdmin(c) {
return true
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "sentinel:impersonate can only be assigned or managed by an admin"})
return false
}

func ListServiceAccountsForApplication(c *gin.Context) {
id := c.Param("id")
if _, ok := requireAppOwnerOrAdmin(c, id, authz.ApplicationsReadScope); !ok {
Expand Down Expand Up @@ -93,7 +104,14 @@ func CreateServiceAccountForApp(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
if err := service.ValidateServiceAccountScope(req.Scope); err != nil {
if !requireImpersonationScopeAdmin(c, req.Scope) {
return
}
validateScope := service.ValidateServiceAccountScope
if authz.HasScope(req.Scope, service.ImpersonationScope) {
validateScope = service.ValidatePrivilegedServiceAccountScope
}
if err := validateScope(req.Scope); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
Expand Down Expand Up @@ -141,6 +159,9 @@ func RotateServiceAccountToken(c *gin.Context) {
if _, ok := requireAppOwnerOrAdmin(c, sa.ApplicationID, authz.ApplicationsWriteScope); !ok {
return
}
if !requireImpersonationScopeAdmin(c, sa.Scope) {
return
}

_, raw, err := service.MintServiceAccountToken(sa)
if err != nil {
Expand Down Expand Up @@ -169,13 +190,17 @@ func GetServiceAccountToken(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
Require(c, Any(
canViewToken := Any(
RequestTokenHasInternalAccess(c),
RequestTokenHasResourceScope(c, authz.ApplicationsReadScope) && Any(
RequestTokenHasEntityID(c, sa.CreatedBy),
RequestUserIsAdmin(c),
),
))
)
if authz.HasScope(sa.Scope, service.ImpersonationScope) {
canViewToken = RequestTokenHasInternalAccess(c) || RequestUserIsAdmin(c)
}
Require(c, canViewToken)
if sa.ActiveToken == nil || sa.SignedToken == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "no active token; rotate to mint a new one"})
return
Expand Down
1 change: 1 addition & 0 deletions core/model/audit_event.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const (
AuditActionGroupMemberRemoved AuditAction = "GROUP_MEMBER_REMOVED"
AuditActionJoinRequestApproved AuditAction = "JOIN_REQUEST_APPROVED"
AuditActionJoinRequestRejected AuditAction = "JOIN_REQUEST_REJECTED"
AuditActionImpersonationTokenIssued AuditAction = "IMPERSONATION_TOKEN_ISSUED"
)

// AuditEvent is one recorded administrative action. Rows are written
Expand Down
1 change: 1 addition & 0 deletions core/model/jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ func (tc *TokenClaims) UnmarshalJSON(data []byte) error {
type Token struct {
ID string `json:"id" gorm:"primaryKey"`
EntityID string `json:"entity_id"`
ActorID string `json:"actor_id,omitempty" gorm:"index"`
ClientID string `json:"client_id"`
Scope string `json:"scope"`
ExpiresAt time.Time `json:"expires_at"`
Expand Down
25 changes: 22 additions & 3 deletions core/service/group.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package service

import (
"time"

"github.com/gaucho-racing/sentinel/core/database"
"github.com/gaucho-racing/sentinel/core/model"
"github.com/gaucho-racing/sentinel/core/pkg/logger"
Expand All @@ -17,7 +19,7 @@ func IsAdmin(entityID string) bool {
if entityID == "" {
return false
}
_, err := GetGroupMember(AdminsGroupID, entityID)
_, err := GetActiveGroupMember(AdminsGroupID, entityID)
return err == nil
}

Expand All @@ -42,7 +44,10 @@ func GetGroupByID(id string) (model.Group, error) {
}

func PopulateGroup(group *model.Group) {
if err := database.DB.Model(&model.GroupMember{}).Where("group_id = ?", group.ID).Count(&group.MemberCount).Error; err != nil {
if err := database.DB.Model(&model.GroupMember{}).
Where("group_id = ?", group.ID).
Where("has_expiration = false OR expires_at > ?", time.Now()).
Count(&group.MemberCount).Error; err != nil {
logger.SugarLogger.Errorf("Failed to count members for group %s: %v", group.ID, err)
}
if err := database.DB.Model(&model.GroupOwner{}).Where("group_id = ?", group.ID).Count(&group.OwnerCount).Error; err != nil {
Expand Down Expand Up @@ -94,7 +99,10 @@ func DeleteGroup(id string) error {

func GetMembersForGroup(groupID string) ([]model.GroupMember, error) {
members := []model.GroupMember{}
if err := database.DB.Where("group_id = ?", groupID).Find(&members).Error; err != nil {
if err := database.DB.
Where("group_id = ?", groupID).
Where("has_expiration = false OR expires_at > ?", time.Now()).
Find(&members).Error; err != nil {
return []model.GroupMember{}, err
}
return members, nil
Expand All @@ -108,6 +116,17 @@ func GetGroupMember(groupID string, entityID string) (model.GroupMember, error)
return member, nil
}

func GetActiveGroupMember(groupID string, entityID string) (model.GroupMember, error) {
var member model.GroupMember
if err := database.DB.
Where("group_id = ? AND entity_id = ?", groupID, entityID).
Where("has_expiration = false OR expires_at > ?", time.Now()).
First(&member).Error; err != nil {
return model.GroupMember{}, err
}
return member, nil
}

func CreateGroupMember(member model.GroupMember) (model.GroupMember, error) {
if err := database.DB.Create(&member).Error; err != nil {
return model.GroupMember{}, err
Expand Down
168 changes: 168 additions & 0 deletions core/service/impersonation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package service

import (
"errors"
"fmt"

"github.com/gaucho-racing/sentinel/core/model"
"gorm.io/gorm"
)

const ImpersonationScope = "sentinel:impersonate"
const ImpersonationTokenTTLSeconds = 5 * 60
const defaultImpersonationScope = "user:read groups:read"
const sentinelImpersonationScope = "sentinel:all"
const sentinelClientID = "sentinel"

var ErrImpersonationCallerNotServiceAccount = errors.New("impersonation caller must be a service account")
var ErrImpersonationUserNotFound = errors.New("impersonation user not found")
var ErrImpersonationApplicationNotFound = errors.New("impersonation application not found")
var ErrImpersonationAccessDenied = errors.New("user does not have access to the application")

type ImpersonationResult struct {
AccessToken string
TokenID string
UserID string
ApplicationID string
ClientID string
Scope string
ExpiresIn int
}

func ImpersonateUser(actorEntityID string, userID string, applicationID string) (ImpersonationResult, error) {
actor, err := GetServiceAccountByEntityID(actorEntityID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ImpersonationResult{}, ErrImpersonationCallerNotServiceAccount
}
return ImpersonationResult{}, fmt.Errorf("load impersonation caller: %w", err)
}

user, err := GetUserByID(userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ImpersonationResult{}, ErrImpersonationUserNotFound
}
return ImpersonationResult{}, fmt.Errorf("load impersonation user: %w", err)
}

application, err := GetApplicationByID(applicationID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ImpersonationResult{}, ErrImpersonationApplicationNotFound
}
return ImpersonationResult{}, fmt.Errorf("load impersonation application: %w", err)
}

groups, err := impersonationGroups(user.EntityID, application)
if err != nil {
return ImpersonationResult{}, err
}

scope := defaultImpersonationScope
if application.ClientID == sentinelClientID {
scope = sentinelImpersonationScope
}

groupNames := make([]string, 0, len(groups))
groupIDs := make([]string, 0, len(groups))
for _, group := range groups {
groupNames = append(groupNames, group.Name)
groupIDs = append(groupIDs, group.ID)
}

claims := map[string]interface{}{
"entity_type": string(model.EntityTypeUser),
"user_id": user.ID,
"groups": groupNames,
"group_ids": groupIDs,
"act": map[string]interface{}{
"sub": actorEntityID,
"service_account_id": actor.ID,
},
}

accessToken, tokenID, err := GenerateTokenWithActor(
user.EntityID,
application.ClientID,
scope,
ImpersonationTokenTTLSeconds,
claims,
actorEntityID,
)
if err != nil {
return ImpersonationResult{}, fmt.Errorf("mint impersonation token: %w", err)
}

return ImpersonationResult{
AccessToken: accessToken,
TokenID: tokenID,
UserID: user.ID,
ApplicationID: application.ID,
ClientID: application.ClientID,
Scope: scope,
ExpiresIn: ImpersonationTokenTTLSeconds,
}, nil
}

func impersonationGroups(entityID string, application model.Application) ([]model.Group, error) {
userGroups, err := GetGroupsForEntity(entityID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exclude expired memberships from impersonation groups

GetGroupsForEntity returns every group_member row without checking has_expiration or expires_at, so a user whose required membership has expired still passes passesApplicationGate, and the expired group is embedded in the impersonation token. Load only active memberships here before enforcing the application gate and constructing authoritative group claims.

Useful? React with 👍 / 👎.

if err != nil {
return nil, fmt.Errorf("load user groups: %w", err)
}

applicationGroups, err := GetGroupsForApplication(application.ID)
if err != nil {
return nil, fmt.Errorf("load application groups: %w", err)
}
if !passesApplicationGate(userGroups, applicationGroups) {
return nil, ErrImpersonationAccessDenied
}
if application.ClientID == sentinelClientID {
return userGroups, nil
}

sentinelApplication, err := GetApplicationByClientID(sentinelClientID)
if err != nil {
return nil, fmt.Errorf("load sentinel application: %w", err)
}
sentinelGroups, err := GetGroupsForApplication(sentinelApplication.ID)
if err != nil {
return nil, fmt.Errorf("load sentinel groups: %w", err)
}

allowed := make(map[string]struct{}, len(applicationGroups)+len(sentinelGroups))
for _, group := range applicationGroups {
allowed[group.ID] = struct{}{}
}
for _, group := range sentinelGroups {
allowed[group.ID] = struct{}{}
}

filtered := make([]model.Group, 0, len(userGroups))
for _, group := range userGroups {
if _, ok := allowed[group.ID]; ok {
filtered = append(filtered, group)
}
}
return filtered, nil
}

func passesApplicationGate(userGroups []model.Group, applicationGroups []GroupWithRequired) bool {
userGroupIDs := make(map[string]struct{}, len(userGroups))
for _, group := range userGroups {
userGroupIDs[group.ID] = struct{}{}
}

hasRequiredGroup := false
for _, group := range applicationGroups {
if !group.Required {
continue
}
hasRequiredGroup = true
if _, ok := userGroupIDs[group.ID]; ok {
return true
}
}
return !hasRequiredGroup
}
Loading
Loading