From 5869cfb67d5db0db9abb2b06b575cdb4d89a0ec4 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Mon, 31 Aug 2026 14:55:52 -0700 Subject: [PATCH 1/2] feat(core): add identity summary directory --- core/api/api.go | 1 + core/api/identity_summary.go | 46 ++++++++ core/model/identity_summary.go | 17 +++ core/service/identity_summary.go | 115 ++++++++++++++++++++ core/service/identity_summary_test.go | 149 ++++++++++++++++++++++++++ 5 files changed, 328 insertions(+) create mode 100644 core/api/identity_summary.go create mode 100644 core/model/identity_summary.go create mode 100644 core/service/identity_summary.go create mode 100644 core/service/identity_summary_test.go 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 +} diff --git a/core/service/identity_summary_test.go b/core/service/identity_summary_test.go new file mode 100644 index 0000000..da51aa4 --- /dev/null +++ b/core/service/identity_summary_test.go @@ -0,0 +1,149 @@ +package service + +import ( + "os" + "reflect" + "testing" + + "github.com/gaucho-racing/sentinel/core/database" + "github.com/gaucho-racing/sentinel/core/model" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func TestGetIdentitySummaries(t *testing.T) { + dsn := os.Getenv("CORE_TEST_DATABASE_DSN") + if dsn == "" { + t.Skip("CORE_TEST_DATABASE_DSN is not configured") + } + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate( + &model.Entity{}, + &model.User{}, + &model.Application{}, + &model.ServiceAccount{}, + ); err != nil { + t.Fatal(err) + } + tx := db.Begin() + if tx.Error != nil { + t.Fatal(tx.Error) + } + originalDB := database.DB + database.DB = tx + defer func() { + database.DB = originalDB + tx.Rollback() + }() + + records := []any{ + &model.Entity{ID: "ent_identity_summary_user", Type: model.EntityTypeUser}, + &model.Entity{ID: "ent_identity_summary_service", Type: model.EntityTypeServiceAccount}, + &model.User{ + ID: "usr_identity_summary", + EntityID: "ent_identity_summary_user", + Username: "summary-user", + FirstName: "Summary", + LastName: "User", + AvatarURL: "https://example.com/user.png", + }, + &model.Application{ + ID: "app_identity_summary", + Name: "Summary Application", + ClientID: "summary-client", + IconURL: "https://example.com/application.png", + }, + &model.ServiceAccount{ + ID: "sa_identity_summary", + EntityID: "ent_identity_summary_service", + ApplicationID: "app_identity_summary", + Name: "summary-worker", + }, + } + for _, record := range records { + if err := tx.Create(record).Error; err != nil { + t.Fatal(err) + } + } + + summaries, err := GetIdentitySummaries([]string{ + "ent_identity_summary_service", + "ent_identity_summary_user", + }) + if err != nil { + t.Fatal(err) + } + if len(summaries) != 2 { + t.Fatalf("summaries = %#v", summaries) + } + if summaries[0].Name != "summary-worker" || summaries[0].Application == nil { + t.Fatalf("service account summary = %#v", summaries[0]) + } + if summaries[1].Name != "Summary User" || summaries[1].AvatarURL != "https://example.com/user.png" { + t.Fatalf("user summary = %#v", summaries[1]) + } +} + +func TestBuildIdentitySummaries(t *testing.T) { + rows := []identitySummaryRow{ + { + EntityID: "ent_user", + EntityType: model.EntityTypeUser, + Username: "driver", + FirstName: "Alex", + LastName: "Rivera", + UserAvatarURL: "https://example.com/alex.png", + }, + { + EntityID: "ent_service", + EntityType: model.EntityTypeServiceAccount, + ServiceAccountName: "telemetry-worker", + ApplicationID: "app_telemetry", + ApplicationName: "Telemetry", + ApplicationClientID: "telemetry-client", + ApplicationIconURL: "https://example.com/telemetry.png", + }, + } + + summaries := buildIdentitySummaries( + []string{"ent_service", "ent_missing", "ent_user", "ent_service"}, + rows, + ) + + if len(summaries) != 2 { + t.Fatalf("summaries = %#v", summaries) + } + if summaries[0].ID != "ent_service" || summaries[1].ID != "ent_user" { + t.Fatalf("summary order = %#v", summaries) + } + if summaries[0].Name != "telemetry-worker" || summaries[0].AvatarURL != "https://example.com/telemetry.png" { + t.Fatalf("service account summary = %#v", summaries[0]) + } + wantApplication := &model.IdentityApplicationSummary{ + ID: "app_telemetry", + Name: "Telemetry", + ClientID: "telemetry-client", + IconURL: "https://example.com/telemetry.png", + } + if !reflect.DeepEqual(summaries[0].Application, wantApplication) { + t.Fatalf("application = %#v", summaries[0].Application) + } + if summaries[1].Name != "Alex Rivera" || summaries[1].Username != "driver" { + t.Fatalf("user summary = %#v", summaries[1]) + } +} + +func TestBuildIdentitySummariesFallsBackToStableID(t *testing.T) { + summaries := buildIdentitySummaries( + []string{"ent_profileless"}, + []identitySummaryRow{{EntityID: "ent_profileless", EntityType: model.EntityTypeUser}}, + ) + + if len(summaries) != 1 || summaries[0].Name != "ent_profileless" { + t.Fatalf("summaries = %#v", summaries) + } +} From 5ec5a55de3b36376b5ccab5d387f0d54a8f92a8d Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Mon, 31 Aug 2026 15:10:15 -0700 Subject: [PATCH 2/2] chore(core): remove identity directory tests --- core/service/identity_summary_test.go | 149 -------------------------- 1 file changed, 149 deletions(-) delete mode 100644 core/service/identity_summary_test.go diff --git a/core/service/identity_summary_test.go b/core/service/identity_summary_test.go deleted file mode 100644 index da51aa4..0000000 --- a/core/service/identity_summary_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package service - -import ( - "os" - "reflect" - "testing" - - "github.com/gaucho-racing/sentinel/core/database" - "github.com/gaucho-racing/sentinel/core/model" - "gorm.io/driver/postgres" - "gorm.io/gorm" -) - -func TestGetIdentitySummaries(t *testing.T) { - dsn := os.Getenv("CORE_TEST_DATABASE_DSN") - if dsn == "" { - t.Skip("CORE_TEST_DATABASE_DSN is not configured") - } - - db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate( - &model.Entity{}, - &model.User{}, - &model.Application{}, - &model.ServiceAccount{}, - ); err != nil { - t.Fatal(err) - } - tx := db.Begin() - if tx.Error != nil { - t.Fatal(tx.Error) - } - originalDB := database.DB - database.DB = tx - defer func() { - database.DB = originalDB - tx.Rollback() - }() - - records := []any{ - &model.Entity{ID: "ent_identity_summary_user", Type: model.EntityTypeUser}, - &model.Entity{ID: "ent_identity_summary_service", Type: model.EntityTypeServiceAccount}, - &model.User{ - ID: "usr_identity_summary", - EntityID: "ent_identity_summary_user", - Username: "summary-user", - FirstName: "Summary", - LastName: "User", - AvatarURL: "https://example.com/user.png", - }, - &model.Application{ - ID: "app_identity_summary", - Name: "Summary Application", - ClientID: "summary-client", - IconURL: "https://example.com/application.png", - }, - &model.ServiceAccount{ - ID: "sa_identity_summary", - EntityID: "ent_identity_summary_service", - ApplicationID: "app_identity_summary", - Name: "summary-worker", - }, - } - for _, record := range records { - if err := tx.Create(record).Error; err != nil { - t.Fatal(err) - } - } - - summaries, err := GetIdentitySummaries([]string{ - "ent_identity_summary_service", - "ent_identity_summary_user", - }) - if err != nil { - t.Fatal(err) - } - if len(summaries) != 2 { - t.Fatalf("summaries = %#v", summaries) - } - if summaries[0].Name != "summary-worker" || summaries[0].Application == nil { - t.Fatalf("service account summary = %#v", summaries[0]) - } - if summaries[1].Name != "Summary User" || summaries[1].AvatarURL != "https://example.com/user.png" { - t.Fatalf("user summary = %#v", summaries[1]) - } -} - -func TestBuildIdentitySummaries(t *testing.T) { - rows := []identitySummaryRow{ - { - EntityID: "ent_user", - EntityType: model.EntityTypeUser, - Username: "driver", - FirstName: "Alex", - LastName: "Rivera", - UserAvatarURL: "https://example.com/alex.png", - }, - { - EntityID: "ent_service", - EntityType: model.EntityTypeServiceAccount, - ServiceAccountName: "telemetry-worker", - ApplicationID: "app_telemetry", - ApplicationName: "Telemetry", - ApplicationClientID: "telemetry-client", - ApplicationIconURL: "https://example.com/telemetry.png", - }, - } - - summaries := buildIdentitySummaries( - []string{"ent_service", "ent_missing", "ent_user", "ent_service"}, - rows, - ) - - if len(summaries) != 2 { - t.Fatalf("summaries = %#v", summaries) - } - if summaries[0].ID != "ent_service" || summaries[1].ID != "ent_user" { - t.Fatalf("summary order = %#v", summaries) - } - if summaries[0].Name != "telemetry-worker" || summaries[0].AvatarURL != "https://example.com/telemetry.png" { - t.Fatalf("service account summary = %#v", summaries[0]) - } - wantApplication := &model.IdentityApplicationSummary{ - ID: "app_telemetry", - Name: "Telemetry", - ClientID: "telemetry-client", - IconURL: "https://example.com/telemetry.png", - } - if !reflect.DeepEqual(summaries[0].Application, wantApplication) { - t.Fatalf("application = %#v", summaries[0].Application) - } - if summaries[1].Name != "Alex Rivera" || summaries[1].Username != "driver" { - t.Fatalf("user summary = %#v", summaries[1]) - } -} - -func TestBuildIdentitySummariesFallsBackToStableID(t *testing.T) { - summaries := buildIdentitySummaries( - []string{"ent_profileless"}, - []identitySummaryRow{{EntityID: "ent_profileless", EntityType: model.EntityTypeUser}}, - ) - - if len(summaries) != 1 || summaries[0].Name != "ent_profileless" { - t.Fatalf("summaries = %#v", summaries) - } -}