diff --git a/core/api/api.go b/core/api/api.go index 6e71f1e..3affa14 100644 --- a/core/api/api.go +++ b/core/api/api.go @@ -66,6 +66,7 @@ func InitializeRoutes(router *gin.Engine) { router.POST("/core/internal/bootstrap-token", BootstrapToken) router.GET("/entities/@me", GetMe) + router.POST("/entities/resolve", ResolveIdentitySummaries) router.GET("/entities/:id", GetEntity) router.GET("/users", GetAllUsers) diff --git a/core/api/identity_summary.go b/core/api/identity_summary.go new file mode 100644 index 0000000..8b0c5df --- /dev/null +++ b/core/api/identity_summary.go @@ -0,0 +1,46 @@ +package api + +import ( + "net/http" + "strings" + + "github.com/gaucho-racing/sentinel/core/service" + "github.com/gin-gonic/gin" +) + +const maxIdentitySummaryIDs = 100 + +type identitySummaryRequest struct { + IDs []string `json:"ids" binding:"required"` +} + +func ResolveIdentitySummaries(c *gin.Context) { + Require(c, Any( + RequestTokenHasAudience(c, "sentinel"), + RequestTokenHasScope(c, "sentinel:all"), + RequestTokenHasScope(c, "user:read"), + )) + + var req identitySummaryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "ids is required"}) + return + } + if len(req.IDs) > maxIdentitySummaryIDs { + c.JSON(http.StatusBadRequest, gin.H{"error": "at most 100 entity IDs may be resolved at once"}) + return + } + for _, entityID := range req.IDs { + if strings.TrimSpace(entityID) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "entity IDs must not be empty"}) + return + } + } + + summaries, err := service.GetIdentitySummaries(req.IDs) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, summaries) +} diff --git a/core/model/identity_summary.go b/core/model/identity_summary.go new file mode 100644 index 0000000..2f53369 --- /dev/null +++ b/core/model/identity_summary.go @@ -0,0 +1,17 @@ +package model + +type IdentityApplicationSummary struct { + ID string `json:"id"` + Name string `json:"name"` + ClientID string `json:"client_id"` + IconURL string `json:"icon_url"` +} + +type IdentitySummary struct { + ID string `json:"id"` + Type EntityType `json:"type"` + Name string `json:"name"` + Username string `json:"username,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + Application *IdentityApplicationSummary `json:"application,omitempty"` +} diff --git a/core/service/identity_summary.go b/core/service/identity_summary.go new file mode 100644 index 0000000..c33769f --- /dev/null +++ b/core/service/identity_summary.go @@ -0,0 +1,115 @@ +package service + +import ( + "strings" + + "github.com/gaucho-racing/sentinel/core/database" + "github.com/gaucho-racing/sentinel/core/model" +) + +type identitySummaryRow struct { + EntityID string `gorm:"column:entity_id"` + EntityType model.EntityType `gorm:"column:entity_type"` + Username string `gorm:"column:username"` + FirstName string `gorm:"column:first_name"` + LastName string `gorm:"column:last_name"` + UserAvatarURL string `gorm:"column:user_avatar_url"` + ServiceAccountName string `gorm:"column:service_account_name"` + ApplicationID string `gorm:"column:application_id"` + ApplicationName string `gorm:"column:application_name"` + ApplicationClientID string `gorm:"column:application_client_id"` + ApplicationIconURL string `gorm:"column:application_icon_url"` +} + +func GetIdentitySummaries(entityIDs []string) ([]model.IdentitySummary, error) { + entityIDs = uniqueNonEmptyStrings(entityIDs) + if len(entityIDs) == 0 { + return []model.IdentitySummary{}, nil + } + + rows := []identitySummaryRow{} + err := database.DB. + Table("auth_entity AS entity"). + Select(` + entity.id AS entity_id, + entity.type AS entity_type, + COALESCE("user".username, '') AS username, + COALESCE("user".first_name, '') AS first_name, + COALESCE("user".last_name, '') AS last_name, + COALESCE("user".avatar_url, '') AS user_avatar_url, + COALESCE(service_account.name, '') AS service_account_name, + COALESCE(application.id, '') AS application_id, + COALESCE(application.name, '') AS application_name, + COALESCE(application.client_id, '') AS application_client_id, + COALESCE(application.icon_url, '') AS application_icon_url + `). + Joins(`LEFT JOIN "user" ON "user".entity_id = entity.id`). + Joins("LEFT JOIN service_account ON service_account.entity_id = entity.id"). + Joins("LEFT JOIN application ON application.id = service_account.application_id"). + Where("entity.id IN ?", entityIDs). + Scan(&rows).Error + if err != nil { + return nil, err + } + + return buildIdentitySummaries(entityIDs, rows), nil +} + +func uniqueNonEmptyStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + unique := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + unique = append(unique, value) + } + return unique +} + +func buildIdentitySummaries(entityIDs []string, rows []identitySummaryRow) []model.IdentitySummary { + byID := make(map[string]model.IdentitySummary, len(rows)) + for _, row := range rows { + summary := model.IdentitySummary{ + ID: row.EntityID, + Type: row.EntityType, + } + switch row.EntityType { + case model.EntityTypeUser: + summary.Name = strings.TrimSpace(row.FirstName + " " + row.LastName) + if summary.Name == "" { + summary.Name = row.Username + } + summary.Username = row.Username + summary.AvatarURL = row.UserAvatarURL + case model.EntityTypeServiceAccount: + summary.Name = row.ServiceAccountName + summary.AvatarURL = row.ApplicationIconURL + if row.ApplicationID != "" { + summary.Application = &model.IdentityApplicationSummary{ + ID: row.ApplicationID, + Name: row.ApplicationName, + ClientID: row.ApplicationClientID, + IconURL: row.ApplicationIconURL, + } + } + } + if summary.Name == "" { + summary.Name = row.EntityID + } + byID[row.EntityID] = summary + } + + summaries := make([]model.IdentitySummary, 0, len(byID)) + for _, entityID := range uniqueNonEmptyStrings(entityIDs) { + if summary, exists := byID[entityID]; exists { + summaries = append(summaries, summary) + } + } + return summaries +}