Skip to content
Open
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
163 changes: 163 additions & 0 deletions core/api/analytics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package api

import (
"net/http"
"strconv"

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

// requireAnalyticsAccess gates the analytics + audit endpoints. They expose
// team-wide aggregates and administrative history, so access is limited to
// first-party admin sessions (the dashboard UI) and internal automation
// (sentinel:all). Mirrors the GetApplicationSecret gate.
func requireAnalyticsAccess(c *gin.Context) {
Require(c, Any(
RequestTokenHasScope(c, "sentinel:all"),
RequestTokenHasAudience(c, "sentinel") && RequestUserIsAdmin(c),
))
}

// recordAudit writes an audit row for the current request, resolving the actor
// and client IP from context. Best-effort: never blocks or fails the handler.
func recordAudit(c *gin.Context, action model.AuditAction, targetType string, targetID string, metadata model.JSONMap) {
service.RecordAuditEvent(model.AuditEvent{
ActorID: GetRequestTokenEntityID(c),
Action: string(action),
TargetType: targetType,
TargetID: targetID,
IPAddress: c.ClientIP(),
Metadata: metadata,
})
}

// 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
}
}
return def
}

func AnalyticsOverview(c *gin.Context) {
requireAnalyticsAccess(c)
overview, err := service.GetAnalyticsOverview()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, overview)
}

func AnalyticsLoginTimeSeries(c *gin.Context) {
requireAnalyticsAccess(c)
series, err := service.GetLoginTimeSeries(queryInt(c, "days", 30))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, series)
}

func AnalyticsLoginHeatmap(c *gin.Context) {
requireAnalyticsAccess(c)
cells, err := service.GetLoginHeatmap(queryInt(c, "days", 90))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, cells)
}

func AnalyticsTopApplications(c *gin.Context) {
requireAnalyticsAccess(c)
apps, err := service.GetTopApplications(queryInt(c, "days", 30), queryInt(c, "limit", 10))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, apps)
}

func AnalyticsUserGrowth(c *gin.Context) {
requireAnalyticsAccess(c)
growth, err := service.GetUserGrowth(queryInt(c, "months", 12))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, growth)
}

func AnalyticsMemberDemographics(c *gin.Context) {
requireAnalyticsAccess(c)
demographics, err := service.GetMemberDemographics(queryInt(c, "major_limit", 10))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, demographics)
}

func AnalyticsAuthMethods(c *gin.Context) {
requireAnalyticsAccess(c)
breakdown, err := service.GetAuthMethodBreakdown()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, breakdown)
}

func AnalyticsGroupMembership(c *gin.Context) {
requireAnalyticsAccess(c)
breakdown, err := service.GetGroupMembershipBreakdown()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, breakdown)
}

func AnalyticsJoinRequests(c *gin.Context) {
requireAnalyticsAccess(c)
funnel, err := service.GetJoinRequestFunnel(queryInt(c, "days", 90))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, funnel)
}

func AnalyticsAuditEvents(c *gin.Context) {
requireAnalyticsAccess(c)
events, err := service.GetAuditEvents(service.AuditEventsFilter{
ActorID: c.Query("actor_id"),
Action: c.Query("action"),
TargetType: c.Query("target_type"),
TargetID: c.Query("target_id"),
Before: c.Query("before"),
After: c.Query("after"),
Limit: c.Query("limit"),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, events)
}

func AnalyticsAuditSummary(c *gin.Context) {
requireAnalyticsAccess(c)
summary, err := service.GetAuditActionSummary(queryInt(c, "days", 30))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, summary)
}
12 changes: 12 additions & 0 deletions core/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,18 @@ func InitializeRoutes(router *gin.Engine) {

router.POST("/groups/:id/requests/:requestID/comments", CreateJoinRequestComment)
router.DELETE("/groups/:id/requests/:requestID/comments/:commentID", DeleteJoinRequestComment)

router.GET("/analytics/overview", AnalyticsOverview)
router.GET("/analytics/logins/timeseries", AnalyticsLoginTimeSeries)
router.GET("/analytics/logins/heatmap", AnalyticsLoginHeatmap)
router.GET("/analytics/applications/top", AnalyticsTopApplications)
router.GET("/analytics/users/growth", AnalyticsUserGrowth)
router.GET("/analytics/members/demographics", AnalyticsMemberDemographics)
router.GET("/analytics/auth-methods", AnalyticsAuthMethods)
router.GET("/analytics/groups/membership", AnalyticsGroupMembership)
router.GET("/analytics/groups/join-requests", AnalyticsJoinRequests)
router.GET("/analytics/audit", AnalyticsAuditEvents)
router.GET("/analytics/audit/summary", AnalyticsAuditSummary)
}

func AuthChecker() gin.HandlerFunc {
Expand Down
3 changes: 3 additions & 0 deletions core/api/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ func CreateApplication(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
recordAudit(c, model.AuditActionApplicationCreated, "application", app.ID, model.JSONMap{"name": app.Name})
c.JSON(http.StatusOK, createdApplicationResponse{
Application: app,
Secret: app.ClientSecret,
Expand Down Expand Up @@ -184,6 +185,7 @@ func GetApplicationSecret(c *gin.Context) {
RequestTokenHasAudience(c, "sentinel") && RequestTokenHasEntityID(c, app.OwnerID),
RequestTokenHasAudience(c, "sentinel") && RequestUserIsAdmin(c),
))
recordAudit(c, model.AuditActionApplicationSecretRevealed, "application", app.ID, model.JSONMap{"name": app.Name})
c.JSON(http.StatusOK, gin.H{"client_secret": app.ClientSecret})
}

Expand All @@ -203,6 +205,7 @@ func DeleteApplication(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
recordAudit(c, model.AuditActionApplicationDeleted, "application", id, model.JSONMap{"name": existing.Name})
c.JSON(http.StatusOK, gin.H{"message": "application deleted"})
}

Expand Down
16 changes: 16 additions & 0 deletions core/api/group.go
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,10 @@ func AddGroupMember(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
recordAudit(c, model.AuditActionGroupMemberAdded, "group", id, model.JSONMap{
"entity_id": req.EntityID,
"source": source,
})
// The entity's group set just changed — re-evaluate any conditional
// bindings that depend on it. Conditional sync runs in the background
// via syncJob; failures here are logged, not surfaced to the caller.
Expand All @@ -352,6 +356,10 @@ func RemoveGroupMember(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
recordAudit(c, model.AuditActionGroupMemberRemoved, "group", id, model.JSONMap{
"entity_id": entityID,
"source": source,
})
// Their group set just changed — re-evaluate conditional bindings.
service.ReconcileConditionalForEntity(entityID)
c.JSON(http.StatusOK, gin.H{"message": "member removed from group"})
Expand Down Expand Up @@ -585,6 +593,10 @@ func ApproveGroupJoinRequest(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
recordAudit(c, model.AuditActionJoinRequestApproved, "join_request", requestID, model.JSONMap{
"group_id": request.GroupID,
"entity_id": request.EntityID,
})
c.JSON(http.StatusOK, request)
}

Expand Down Expand Up @@ -616,6 +628,10 @@ func RejectGroupJoinRequest(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
recordAudit(c, model.AuditActionJoinRequestRejected, "join_request", requestID, model.JSONMap{
"group_id": request.GroupID,
"entity_id": request.EntityID,
})
c.JSON(http.StatusOK, request)
}

Expand Down
1 change: 1 addition & 0 deletions core/database/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func Init() {
&model.GroupOwner{},
&model.GroupConditionalBinding{},
&model.SigningKey{},
&model.AuditEvent{},
)
logger.SugarLogger.Infoln("AutoMigration complete")
DB = db
Expand Down
38 changes: 38 additions & 0 deletions core/model/audit_event.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package model

import "time"

// AuditAction enumerates the mutating actions Sentinel records to the audit
// trail. Values are stable strings — they're persisted and queried by the
// analytics layer, so renaming one is a breaking change.
type AuditAction string

const (
AuditActionApplicationCreated AuditAction = "APPLICATION_CREATED"
AuditActionApplicationDeleted AuditAction = "APPLICATION_DELETED"
AuditActionApplicationSecretRevealed AuditAction = "APPLICATION_SECRET_REVEALED"
AuditActionGroupMemberAdded AuditAction = "GROUP_MEMBER_ADDED"
AuditActionGroupMemberRemoved AuditAction = "GROUP_MEMBER_REMOVED"
AuditActionJoinRequestApproved AuditAction = "JOIN_REQUEST_APPROVED"
AuditActionJoinRequestRejected AuditAction = "JOIN_REQUEST_REJECTED"
)

// AuditEvent is one recorded administrative action. Rows are written
// best-effort by the API layer (a failed write is logged, never surfaced to
// the caller) and read back by the analytics endpoints for the audit/security
// views. Metadata carries action-specific context (target name, affected
// entity, source, etc.) without needing a column per action.
type AuditEvent struct {
ID string `json:"id" gorm:"primaryKey"`
ActorID string `json:"actor_id" gorm:"index"`
Action string `json:"action" gorm:"index"`
TargetType string `json:"target_type" gorm:"index"`
TargetID string `json:"target_id" gorm:"index"`
IPAddress string `json:"ip_address"`
Metadata JSONMap `json:"metadata" gorm:"type:jsonb"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
}

func (AuditEvent) TableName() string {
return "audit_event"
}
Loading
Loading