diff --git a/core/api/analytics.go b/core/api/analytics.go index 639bb9d..fd28df0 100644 --- a/core/api/analytics.go +++ b/core/api/analytics.go @@ -15,7 +15,7 @@ import ( // (sentinel:all). Mirrors the GetApplicationSecret gate. func requireAnalyticsAccess(c *gin.Context) { Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), + RequestTokenHasInternalAccess(c), RequestTokenHasAudience(c, "sentinel") && RequestUserIsAdmin(c), )) } @@ -33,15 +33,17 @@ func recordAudit(c *gin.Context, action model.AuditAction, targetType string, ta }) } -// queryInt reads an integer query param, falling back to def when absent or -// unparseable. -func queryInt(c *gin.Context, key string, def int) int { - if v := c.Query(key); v != "" { - if n, err := strconv.Atoi(v); err == nil { - return n - } +func boundedQueryInt(c *gin.Context, key string, fallback int, maximum int) (int, bool) { + raw := c.Query(key) + if raw == "" { + return fallback, true } - return def + value, err := strconv.Atoi(raw) + if err != nil || value < 1 || value > maximum { + c.JSON(http.StatusBadRequest, gin.H{"error": key + " must be between 1 and " + strconv.Itoa(maximum)}) + return 0, false + } + return value, true } func AnalyticsOverview(c *gin.Context) { @@ -56,7 +58,11 @@ func AnalyticsOverview(c *gin.Context) { func AnalyticsLoginTimeSeries(c *gin.Context) { requireAnalyticsAccess(c) - series, err := service.GetLoginTimeSeries(queryInt(c, "days", 30)) + days, ok := boundedQueryInt(c, "days", 30, service.MaxAnalyticsDays) + if !ok { + return + } + series, err := service.GetLoginTimeSeries(days) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -66,7 +72,11 @@ func AnalyticsLoginTimeSeries(c *gin.Context) { func AnalyticsLoginHeatmap(c *gin.Context) { requireAnalyticsAccess(c) - cells, err := service.GetLoginHeatmap(queryInt(c, "days", 90)) + days, ok := boundedQueryInt(c, "days", 90, service.MaxAnalyticsDays) + if !ok { + return + } + cells, err := service.GetLoginHeatmap(days) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -76,7 +86,15 @@ func AnalyticsLoginHeatmap(c *gin.Context) { func AnalyticsTopApplications(c *gin.Context) { requireAnalyticsAccess(c) - apps, err := service.GetTopApplications(queryInt(c, "days", 30), queryInt(c, "limit", 10)) + days, ok := boundedQueryInt(c, "days", 30, service.MaxAnalyticsDays) + if !ok { + return + } + limit, ok := boundedQueryInt(c, "limit", 10, service.MaxAnalyticsLimit) + if !ok { + return + } + apps, err := service.GetTopApplications(days, limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -86,7 +104,11 @@ func AnalyticsTopApplications(c *gin.Context) { func AnalyticsUserGrowth(c *gin.Context) { requireAnalyticsAccess(c) - growth, err := service.GetUserGrowth(queryInt(c, "months", 12)) + months, ok := boundedQueryInt(c, "months", 12, service.MaxAnalyticsMonths) + if !ok { + return + } + growth, err := service.GetUserGrowth(months) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -96,7 +118,11 @@ func AnalyticsUserGrowth(c *gin.Context) { func AnalyticsMemberDemographics(c *gin.Context) { requireAnalyticsAccess(c) - demographics, err := service.GetMemberDemographics(queryInt(c, "major_limit", 10)) + limit, ok := boundedQueryInt(c, "major_limit", 10, service.MaxAnalyticsLimit) + if !ok { + return + } + demographics, err := service.GetMemberDemographics(limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -126,7 +152,11 @@ func AnalyticsGroupMembership(c *gin.Context) { func AnalyticsJoinRequests(c *gin.Context) { requireAnalyticsAccess(c) - funnel, err := service.GetJoinRequestFunnel(queryInt(c, "days", 90)) + days, ok := boundedQueryInt(c, "days", 90, service.MaxAnalyticsDays) + if !ok { + return + } + funnel, err := service.GetJoinRequestFunnel(days) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -136,6 +166,10 @@ func AnalyticsJoinRequests(c *gin.Context) { func AnalyticsAuditEvents(c *gin.Context) { requireAnalyticsAccess(c) + limit, ok := boundedQueryInt(c, "limit", 100, service.MaxAuditEventLimit) + if !ok { + return + } events, err := service.GetAuditEvents(service.AuditEventsFilter{ ActorID: c.Query("actor_id"), Action: c.Query("action"), @@ -143,7 +177,7 @@ func AnalyticsAuditEvents(c *gin.Context) { TargetID: c.Query("target_id"), Before: c.Query("before"), After: c.Query("after"), - Limit: c.Query("limit"), + Limit: strconv.Itoa(limit), }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -154,7 +188,11 @@ func AnalyticsAuditEvents(c *gin.Context) { func AnalyticsAuditSummary(c *gin.Context) { requireAnalyticsAccess(c) - summary, err := service.GetAuditActionSummary(queryInt(c, "days", 30)) + days, ok := boundedQueryInt(c, "days", 30, service.MaxAnalyticsDays) + if !ok { + return + } + summary, err := service.GetAuditActionSummary(days) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/core/model/entity_login.go b/core/model/entity_login.go index 9e76ea8..ef2a529 100644 --- a/core/model/entity_login.go +++ b/core/model/entity_login.go @@ -4,13 +4,13 @@ import "time" type EntityLogin struct { ID string `json:"id" gorm:"primaryKey"` - EntityID string `json:"entity_id" gorm:"index"` - ClientID string `json:"client_id" gorm:"index"` + EntityID string `json:"entity_id" gorm:"index;index:idx_entity_login_entity_created,priority:1"` + ClientID string `json:"client_id" gorm:"index;index:idx_entity_login_client_created,priority:1"` Scope string `json:"scope"` AccessTokenID string `json:"access_token_id"` RefreshTokenID string `json:"refresh_token_id"` IPAddress string `json:"ip_address"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index:idx_entity_login_created_at;index:idx_entity_login_entity_created,priority:2;index:idx_entity_login_client_created,priority:2"` } func (EntityLogin) TableName() string { diff --git a/core/model/group.go b/core/model/group.go index 5d6f6a6..50f0e46 100644 --- a/core/model/group.go +++ b/core/model/group.go @@ -88,12 +88,12 @@ type GroupJoinRequest struct { ID string `json:"id" gorm:"primaryKey"` GroupID string `json:"group_id"` EntityID string `json:"entity_id"` - Status string `json:"status"` + Status string `json:"status" gorm:"index;index:idx_group_join_request_status_created,priority:1"` ReviewedBy string `json:"reviewed_by"` ReviewedAt time.Time `json:"reviewed_at"` HasExpiration bool `json:"has_expiration"` ExpiresAt time.Time `json:"expires_at"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index:idx_group_join_request_status_created,priority:2"` Comments []GroupJoinRequestComment `json:"comments" gorm:"-"` } @@ -112,4 +112,3 @@ type GroupJoinRequestComment struct { func (GroupJoinRequestComment) TableName() string { return "group_join_request_comment" } - diff --git a/core/model/user.go b/core/model/user.go index 9ee9eed..1187b5a 100644 --- a/core/model/user.go +++ b/core/model/user.go @@ -29,7 +29,7 @@ type User struct { InitialRole string `json:"initial_role"` Groups []string `json:"groups" gorm:"-"` UpdatedAt time.Time `json:"updated_at"` - CreatedAt time.Time `json:"created_at"` + CreatedAt time.Time `json:"created_at" gorm:"index"` } func (User) TableName() string { diff --git a/core/service/analytics.go b/core/service/analytics.go index 7ba0280..76ae2cb 100644 --- a/core/service/analytics.go +++ b/core/service/analytics.go @@ -1,12 +1,38 @@ package service import ( + "fmt" "time" "github.com/gaucho-racing/sentinel/core/database" "github.com/gaucho-racing/sentinel/core/model" + "gorm.io/gorm" ) +const ( + MaxAnalyticsDays = 3660 + MaxAnalyticsMonths = 120 + MaxAnalyticsLimit = 100 + MaxAuditEventLimit = 500 +) + +func boundedAnalyticsValue(value int, fallback int, maximum int) int { + if value <= 0 { + return fallback + } + if value > maximum { + return maximum + } + return value +} + +func countMetric(name string, query *gorm.DB, destination *int64) error { + if err := query.Count(destination).Error; err != nil { + return fmt.Errorf("count %s: %w", name, err) + } + return nil +} + // CategoryCount is a generic label/value pair used by the breakdown charts // (grad year, major, auth method, audit action, etc.). type CategoryCount struct { @@ -36,20 +62,29 @@ func GetAnalyticsOverview() (AnalyticsOverview, error) { now := time.Now() db := database.DB - db.Model(&model.User{}).Count(&o.TotalUsers) - db.Model(&model.Entity{}).Where("type = ?", model.EntityTypeServiceAccount).Count(&o.TotalServiceAccounts) - db.Model(&model.Application{}).Count(&o.TotalApplications) - db.Model(&model.Group{}).Count(&o.TotalGroups) - db.Model(&model.User{}).Where("created_at > ?", now.AddDate(0, 0, -30)).Count(&o.NewUsers30d) - - db.Model(&model.EntityLogin{}).Where("created_at > ?", now.Add(-24*time.Hour)).Count(&o.Logins24h) - db.Model(&model.EntityLogin{}).Where("created_at > ?", now.AddDate(0, 0, -7)).Count(&o.Logins7d) - db.Model(&model.EntityLogin{}).Where("created_at > ?", now.AddDate(0, 0, -30)).Count(&o.Logins30d) - db.Model(&model.EntityLogin{}).Where("created_at > ?", now.AddDate(0, 0, -7)).Distinct("entity_id").Count(&o.ActiveUsers7d) - db.Model(&model.EntityLogin{}).Where("created_at > ?", now.AddDate(0, 0, -30)).Distinct("entity_id").Count(&o.ActiveUsers30d) - - db.Model(&model.GroupJoinRequest{}).Where("status = ?", model.GroupJoinRequestStatusPending).Count(&o.PendingJoinRequests) - db.Model(&model.AuditEvent{}).Where("created_at > ?", now.AddDate(0, 0, -7)).Count(&o.AuditEvents7d) + metrics := []struct { + name string + query *gorm.DB + destination *int64 + }{ + {name: "users", query: db.Model(&model.User{}), destination: &o.TotalUsers}, + {name: "service accounts", query: db.Model(&model.Entity{}).Where("type = ?", model.EntityTypeServiceAccount), destination: &o.TotalServiceAccounts}, + {name: "applications", query: db.Model(&model.Application{}), destination: &o.TotalApplications}, + {name: "groups", query: db.Model(&model.Group{}), destination: &o.TotalGroups}, + {name: "new users", query: db.Model(&model.User{}).Where("created_at > ?", now.AddDate(0, 0, -30)), destination: &o.NewUsers30d}, + {name: "24 hour logins", query: db.Model(&model.EntityLogin{}).Where("created_at > ?", now.Add(-24*time.Hour)), destination: &o.Logins24h}, + {name: "7 day logins", query: db.Model(&model.EntityLogin{}).Where("created_at > ?", now.AddDate(0, 0, -7)), destination: &o.Logins7d}, + {name: "30 day logins", query: db.Model(&model.EntityLogin{}).Where("created_at > ?", now.AddDate(0, 0, -30)), destination: &o.Logins30d}, + {name: "7 day active users", query: db.Model(&model.EntityLogin{}).Where("created_at > ?", now.AddDate(0, 0, -7)).Distinct("entity_id"), destination: &o.ActiveUsers7d}, + {name: "30 day active users", query: db.Model(&model.EntityLogin{}).Where("created_at > ?", now.AddDate(0, 0, -30)).Distinct("entity_id"), destination: &o.ActiveUsers30d}, + {name: "pending join requests", query: db.Model(&model.GroupJoinRequest{}).Where("status = ?", model.GroupJoinRequestStatusPending), destination: &o.PendingJoinRequests}, + {name: "7 day audit events", query: db.Model(&model.AuditEvent{}).Where("created_at > ?", now.AddDate(0, 0, -7)), destination: &o.AuditEvents7d}, + } + for _, metric := range metrics { + if err := countMetric(metric.name, metric.query, metric.destination); err != nil { + return AnalyticsOverview{}, err + } + } return o, nil } @@ -65,9 +100,7 @@ type LoginPoint struct { // the trailing `days` window, gap-filled so every calendar day is present // (charts render a continuous axis without client-side interpolation). func GetLoginTimeSeries(days int) ([]LoginPoint, error) { - if days <= 0 { - days = 30 - } + days = boundedAnalyticsValue(days, 30, MaxAnalyticsDays) now := time.Now().UTC() startDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, -(days - 1)) @@ -115,9 +148,7 @@ type HeatmapCell struct { } func GetLoginHeatmap(days int) ([]HeatmapCell, error) { - if days <= 0 { - days = 90 - } + days = boundedAnalyticsValue(days, 90, MaxAnalyticsDays) start := time.Now().AddDate(0, 0, -days) cells := []HeatmapCell{} sql := ` @@ -146,12 +177,8 @@ type TopApplication struct { } func GetTopApplications(days int, limit int) ([]TopApplication, error) { - if days <= 0 { - days = 30 - } - if limit <= 0 { - limit = 10 - } + days = boundedAnalyticsValue(days, 30, MaxAnalyticsDays) + limit = boundedAnalyticsValue(limit, 10, MaxAnalyticsLimit) start := time.Now().AddDate(0, 0, -days) apps := []TopApplication{} sql := ` @@ -182,16 +209,16 @@ type UserGrowthPoint struct { } func GetUserGrowth(months int) ([]UserGrowthPoint, error) { - if months <= 0 { - months = 12 - } + months = boundedAnalyticsValue(months, 12, MaxAnalyticsMonths) now := time.Now().UTC() startMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, -(months - 1), 0) // Members created before the window form the cumulative baseline so the // running total reflects the whole roster, not just the visible range. var baseline int64 - database.DB.Model(&model.User{}).Where("created_at < ?", startMonth).Count(&baseline) + if err := countMetric("users before growth window", database.DB.Model(&model.User{}).Where("created_at < ?", startMonth), &baseline); err != nil { + return []UserGrowthPoint{}, err + } type row struct { Month string @@ -233,9 +260,7 @@ type MemberDemographics struct { } func GetMemberDemographics(majorLimit int) (MemberDemographics, error) { - if majorLimit <= 0 { - majorLimit = 10 - } + majorLimit = boundedAnalyticsValue(majorLimit, 10, MaxAnalyticsLimit) var d MemberDemographics d.ByGradYear = []CategoryCount{} @@ -289,11 +314,22 @@ type AuthMethodBreakdown struct { func GetAuthMethodBreakdown() (AuthMethodBreakdown, error) { var b AuthMethodBreakdown db := database.DB - db.Model(&model.EntityEmail{}).Distinct("entity_id").Count(&b.Email) - db.Model(&model.EntityPhone{}).Distinct("entity_id").Count(&b.Phone) - db.Model(&model.EntityExternalAuth{}).Where("provider = ?", model.ExternalAuthProviderDiscord).Distinct("entity_id").Count(&b.Discord) - db.Model(&model.EntityExternalAuth{}).Where("provider = ?", model.ExternalAuthProviderGoogle).Distinct("entity_id").Count(&b.Google) - db.Model(&model.EntityExternalAuth{}).Where("provider = ?", model.ExternalAuthProviderGitHub).Distinct("entity_id").Count(&b.GitHub) + metrics := []struct { + name string + query *gorm.DB + destination *int64 + }{ + {name: "email authentication methods", query: db.Model(&model.EntityEmail{}).Distinct("entity_id"), destination: &b.Email}, + {name: "phone authentication methods", query: db.Model(&model.EntityPhone{}).Distinct("entity_id"), destination: &b.Phone}, + {name: "Discord authentication methods", query: db.Model(&model.EntityExternalAuth{}).Where("provider = ?", model.ExternalAuthProviderDiscord).Distinct("entity_id"), destination: &b.Discord}, + {name: "Google authentication methods", query: db.Model(&model.EntityExternalAuth{}).Where("provider = ?", model.ExternalAuthProviderGoogle).Distinct("entity_id"), destination: &b.Google}, + {name: "GitHub authentication methods", query: db.Model(&model.EntityExternalAuth{}).Where("provider = ?", model.ExternalAuthProviderGitHub).Distinct("entity_id"), destination: &b.GitHub}, + } + for _, metric := range metrics { + if err := countMetric(metric.name, metric.query, metric.destination); err != nil { + return AuthMethodBreakdown{}, err + } + } return b, nil } @@ -339,15 +375,24 @@ type JoinRequestFunnel struct { } func GetJoinRequestFunnel(days int) (JoinRequestFunnel, error) { - if days <= 0 { - days = 90 - } + days = boundedAnalyticsValue(days, 90, MaxAnalyticsDays) start := time.Now().AddDate(0, 0, -days) var f JoinRequestFunnel db := database.DB - db.Model(&model.GroupJoinRequest{}).Where("status = ?", model.GroupJoinRequestStatusPending).Count(&f.Pending) - db.Model(&model.GroupJoinRequest{}).Where("status = ? AND created_at >= ?", model.GroupJoinRequestStatusApproved, start).Count(&f.Approved) - db.Model(&model.GroupJoinRequest{}).Where("status = ? AND created_at >= ?", model.GroupJoinRequestStatusRejected, start).Count(&f.Rejected) + metrics := []struct { + name string + query *gorm.DB + destination *int64 + }{ + {name: "pending join requests", query: db.Model(&model.GroupJoinRequest{}).Where("status = ?", model.GroupJoinRequestStatusPending), destination: &f.Pending}, + {name: "approved join requests", query: db.Model(&model.GroupJoinRequest{}).Where("status = ? AND created_at >= ?", model.GroupJoinRequestStatusApproved, start), destination: &f.Approved}, + {name: "rejected join requests", query: db.Model(&model.GroupJoinRequest{}).Where("status = ? AND created_at >= ?", model.GroupJoinRequestStatusRejected, start), destination: &f.Rejected}, + } + for _, metric := range metrics { + if err := countMetric(metric.name, metric.query, metric.destination); err != nil { + return JoinRequestFunnel{}, err + } + } var median float64 sql := ` diff --git a/core/service/audit_event.go b/core/service/audit_event.go index 517b528..e1f0680 100644 --- a/core/service/audit_event.go +++ b/core/service/audit_event.go @@ -65,7 +65,7 @@ func GetAuditEvents(filter AuditEventsFilter) ([]model.AuditEvent, error) { limit := 100 if filter.Limit != "" { if n, err := strconv.Atoi(filter.Limit); err == nil && n > 0 { - limit = n + limit = boundedAnalyticsValue(n, 100, MaxAuditEventLimit) } } query = query.Limit(limit) @@ -78,9 +78,7 @@ func GetAuditEvents(filter AuditEventsFilter) ([]model.AuditEvent, error) { // GetAuditActionSummary returns the count of audit events per action over the // trailing window, most frequent first. Powers the audit breakdown chart. func GetAuditActionSummary(days int) ([]CategoryCount, error) { - if days <= 0 { - days = 30 - } + days = boundedAnalyticsValue(days, 30, MaxAnalyticsDays) start := time.Now().AddDate(0, 0, -days) out := []CategoryCount{} sql := `