diff --git a/core/api/api.go b/core/api/api.go index dfdb22d..702ccdd 100644 --- a/core/api/api.go +++ b/core/api/api.go @@ -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) diff --git a/core/api/impersonation.go b/core/api/impersonation.go new file mode 100644 index 0000000..35514ba --- /dev/null +++ b/core/api/impersonation.go @@ -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, + }) +} diff --git a/core/api/service_account.go b/core/api/service_account.go index cea1933..87fa883 100644 --- a/core/api/service_account.go +++ b/core/api/service_account.go @@ -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 { @@ -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 } @@ -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 { @@ -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 diff --git a/core/model/audit_event.go b/core/model/audit_event.go index dc57995..9091d4a 100644 --- a/core/model/audit_event.go +++ b/core/model/audit_event.go @@ -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 diff --git a/core/model/jwt.go b/core/model/jwt.go index c458df8..49966ce 100644 --- a/core/model/jwt.go +++ b/core/model/jwt.go @@ -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"` diff --git a/core/service/group.go b/core/service/group.go index 2567000..c1b2856 100644 --- a/core/service/group.go +++ b/core/service/group.go @@ -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" @@ -17,7 +19,7 @@ func IsAdmin(entityID string) bool { if entityID == "" { return false } - _, err := GetGroupMember(AdminsGroupID, entityID) + _, err := GetActiveGroupMember(AdminsGroupID, entityID) return err == nil } @@ -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 { @@ -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 @@ -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 diff --git a/core/service/impersonation.go b/core/service/impersonation.go new file mode 100644 index 0000000..36b383d --- /dev/null +++ b/core/service/impersonation.go @@ -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) + 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 +} diff --git a/core/service/jwt.go b/core/service/jwt.go index b20aec2..1f5b443 100644 --- a/core/service/jwt.go +++ b/core/service/jwt.go @@ -122,6 +122,10 @@ func PublicKeyToJWKS(publicKey *rsa.PublicKey) map[string]interface{} { } func GenerateToken(entityID string, clientID string, scope string, expiresIn int, claims map[string]interface{}) (string, string, error) { + return GenerateTokenWithActor(entityID, clientID, scope, expiresIn, claims, "") +} + +func GenerateTokenWithActor(entityID string, clientID string, scope string, expiresIn int, claims map[string]interface{}, actorID string) (string, string, error) { expirationTime := time.Now().Add(time.Duration(expiresIn) * time.Second) tokenID := ulid.Make().Prefixed("jwt") @@ -149,6 +153,7 @@ func GenerateToken(entityID string, clientID string, scope string, expiresIn int dbToken := &model.Token{ ID: tokenID, EntityID: entityID, + ActorID: actorID, ClientID: clientID, Scope: scope, ExpiresAt: expirationTime, diff --git a/core/service/service_account.go b/core/service/service_account.go index 61d7f83..07559eb 100644 --- a/core/service/service_account.go +++ b/core/service/service_account.go @@ -34,11 +34,21 @@ var ErrInvalidServiceAccountScope = errors.New("scope contains a value not allow // against ServiceAccountAllowedScopes. Empty scope is allowed (a token // with no scope can only be used for endpoints that don't require any). func ValidateServiceAccountScope(s string) error { + return validateServiceAccountScope(s, ServiceAccountAllowedScopes) +} + +func ValidatePrivilegedServiceAccountScope(s string) error { + allowed := append([]string{}, ServiceAccountAllowedScopes...) + allowed = append(allowed, ImpersonationScope) + return validateServiceAccountScope(s, allowed) +} + +func validateServiceAccountScope(s string, allowedScopes []string) error { if strings.TrimSpace(s) == "" { return nil } - allowed := make(map[string]struct{}, len(ServiceAccountAllowedScopes)) - for _, a := range ServiceAccountAllowedScopes { + allowed := make(map[string]struct{}, len(allowedScopes)) + for _, a := range allowedScopes { allowed[a] = struct{}{} } for _, scope := range strings.Fields(s) { diff --git a/core/service/user.go b/core/service/user.go index 76cd1bc..3a100cf 100644 --- a/core/service/user.go +++ b/core/service/user.go @@ -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" @@ -103,7 +105,10 @@ func PopulateUser(user *model.User) { // count, to the point of exceeding the timeouts relying parties allow. func GetGroupsForEntity(entityID string) ([]model.Group, error) { var members []model.GroupMember - if err := database.DB.Where("entity_id = ?", entityID).Find(&members).Error; err != nil { + if err := database.DB. + Where("entity_id = ?", entityID). + Where("has_expiration = false OR expires_at > ?", time.Now()). + Find(&members).Error; err != nil { return []model.Group{}, err } if len(members) == 0 { diff --git a/web/src/lib/service-accounts.ts b/web/src/lib/service-accounts.ts index 9135801..67f47aa 100644 --- a/web/src/lib/service-accounts.ts +++ b/web/src/lib/service-accounts.ts @@ -47,12 +47,17 @@ export const SA_ALLOWED_SCOPES = [ "applications:read", ] as const -export type SAScope = (typeof SA_ALLOWED_SCOPES)[number] +export const SA_IMPERSONATION_SCOPE = "sentinel:impersonate" as const + +export type SAScope = + | (typeof SA_ALLOWED_SCOPES)[number] + | typeof SA_IMPERSONATION_SCOPE export const SA_SCOPE_DESCRIPTIONS: Record = { "user:read": "Read user and entity profiles", "groups:read": "Read group memberships", "applications:read": "Read application details", + "sentinel:impersonate": "Mint short-lived user tokens for an application", } // TTL_PRESETS is the dropdown shown on the create / rotate dialogs. diff --git a/web/src/pages/applications/ServiceAccountsCard.tsx b/web/src/pages/applications/ServiceAccountsCard.tsx index 8c58dbb..5b74852 100644 --- a/web/src/pages/applications/ServiceAccountsCard.tsx +++ b/web/src/pages/applications/ServiceAccountsCard.tsx @@ -41,6 +41,7 @@ import { loadSession } from "@/lib/auth" import { isNeverExpires, SA_ALLOWED_SCOPES, + SA_IMPERSONATION_SCOPE, SA_SCOPE_DESCRIPTIONS, TTL_PRESETS, useApplicationServiceAccounts, @@ -78,6 +79,10 @@ function extractError(e: unknown, fallback: string): string { return msg ?? fallback } +function hasImpersonationScope(scope: string): boolean { + return scope.split(/\s+/).includes(SA_IMPERSONATION_SCOPE) +} + export function ServiceAccountsCard({ applicationID }: { applicationID: string }) { const sasQuery = useApplicationServiceAccounts(applicationID) const [createOpen, setCreateOpen] = useState(false) @@ -117,7 +122,12 @@ export function ServiceAccountsCard({ applicationID }: { applicationID: string } key={sa.id} sa={sa} applicationID={applicationID} - canViewToken={isAdmin || sa.created_by === myEntityID} + canViewToken={ + isAdmin || + (!hasImpersonationScope(sa.scope) && + sa.created_by === myEntityID) + } + canRotateToken={isAdmin || !hasImpersonationScope(sa.scope)} onRevealToken={(result) => setRevealed(result)} /> ))} @@ -136,6 +146,7 @@ export function ServiceAccountsCard({ applicationID }: { applicationID: string } open={createOpen} onOpenChange={setCreateOpen} applicationID={applicationID} + canAssignImpersonationScope={isAdmin} onCreated={(result) => { setCreateOpen(false) setRevealed(result) @@ -155,11 +166,13 @@ function ServiceAccountItem({ sa, applicationID, canViewToken, + canRotateToken, onRevealToken, }: { sa: ServiceAccount applicationID: string canViewToken: boolean + canRotateToken: boolean onRevealToken: (result: ServiceAccountWithToken) => void }) { const viewToken = useViewServiceAccountToken() @@ -223,14 +236,16 @@ function ServiceAccountItem({ )} - + {canRotateToken && ( + + )}