mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-19 16:01:16 +00:00
e0ced97ba1
Move audit logs into org-scoped Postgres storage, wire audit events across backend handlers, and surface actor details in the dashboard activity log. Add realtime audit invalidation and retention pruning so the trail stays current and bounded.
194 lines
4.9 KiB
Go
194 lines
4.9 KiB
Go
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/warmbly/warmbly/internal/api/middleware"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/utils/validate"
|
|
)
|
|
|
|
// audit records an action performed in the current request's organization. It
|
|
// pulls the actor, org, client IP and user-agent from the gin context, so
|
|
// handlers only describe what happened. It is a no-op when there is no org
|
|
// context (e.g. pre-auth flows); those paths log explicitly with the org they
|
|
// create or resolve.
|
|
//
|
|
// IMPORTANT: never pass secret values (API key material, webhook secrets,
|
|
// passwords) in changes or metadata — record only that a field changed.
|
|
func (h *Handler) auditOrg(c *gin.Context, action models.AuditAction, entityType models.AuditEntityType, entityID *uuid.UUID, changes, metadata map[string]string) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
return
|
|
}
|
|
actorID, err := middleware.GetUserUUID(c)
|
|
if err != nil {
|
|
return
|
|
}
|
|
h.AuditService.LogAction(c.Request.Context(), *orgID, actorID, action, entityType, entityID, c.ClientIP(), c.Request.UserAgent(), changes, metadata)
|
|
}
|
|
|
|
// GetAuditLogs returns the organization-wide activity trail for the caller's
|
|
// current organization ("who did what, when, from where"). Gated to
|
|
// owners/admins via PermViewAnalytics. The organization is always taken from
|
|
// the session, never from a client parameter, so one org can never read
|
|
// another's trail.
|
|
//
|
|
// GET /audit-logs
|
|
func (h *Handler) GetAuditLogs(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.Handle(c, errx.ErrAuth)
|
|
return
|
|
}
|
|
|
|
limit, xerr := validate.Limit(c.Query("limit"))
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
cursor, xerr := validate.Uuid(c.Query("cursor"))
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
params := &models.AuditLogSearch{
|
|
OrgID: orgID,
|
|
Limit: int(limit),
|
|
}
|
|
if cursor != nil {
|
|
params.Cursor = *cursor
|
|
}
|
|
|
|
if v := c.Query("actor_id"); v != "" {
|
|
id, err := uuid.Parse(v)
|
|
if err != nil {
|
|
errx.Handle(c, errx.ErrUuid)
|
|
return
|
|
}
|
|
params.ActorID = &id
|
|
}
|
|
|
|
if v := c.Query("entity_id"); v != "" {
|
|
id, err := uuid.Parse(v)
|
|
if err != nil {
|
|
errx.Handle(c, errx.ErrUuid)
|
|
return
|
|
}
|
|
params.EntityID = &id
|
|
}
|
|
|
|
if v := c.Query("entity_type"); v != "" {
|
|
et := models.AuditEntityType(v)
|
|
params.EntityType = &et
|
|
}
|
|
|
|
if v := c.Query("action"); v != "" {
|
|
a := models.AuditAction(v)
|
|
params.Action = &a
|
|
}
|
|
|
|
// Single-day filter (the dashboard sends ?date=YYYY-MM-DD).
|
|
if v := c.Query("date"); v != "" {
|
|
day, err := time.Parse("2006-01-02", v)
|
|
if err != nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "date must be in YYYY-MM-DD format"))
|
|
return
|
|
}
|
|
since := day
|
|
until := day.Add(24 * time.Hour)
|
|
params.Since = &since
|
|
params.Until = &until
|
|
}
|
|
|
|
// Optional explicit range; overrides the single-day bounds when present.
|
|
if v := c.Query("start_date"); v != "" {
|
|
t, err := parseAuditDate(v)
|
|
if err != nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "start_date must be RFC3339 or YYYY-MM-DD"))
|
|
return
|
|
}
|
|
params.Since = &t
|
|
}
|
|
if v := c.Query("end_date"); v != "" {
|
|
t, err := parseAuditDate(v)
|
|
if err != nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "end_date must be RFC3339 or YYYY-MM-DD"))
|
|
return
|
|
}
|
|
params.Until = &t
|
|
}
|
|
|
|
result, xerr := h.AuditService.Search(c.Request.Context(), params)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
func parseAuditDate(v string) (time.Time, error) {
|
|
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
|
return t, nil
|
|
}
|
|
return time.Parse("2006-01-02", v)
|
|
}
|
|
|
|
// GetUserRateLimits gets rate limits for a user (admin only)
|
|
// GET /admin/users/:id/rate-limits
|
|
func (h *Handler) GetUserRateLimits(c *gin.Context) {
|
|
userIDStr := c.Param("id")
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
errx.Handle(c, errx.ErrNotFound)
|
|
return
|
|
}
|
|
|
|
limits, xerr := h.RateLimitService.GetUserLimits(c.Request.Context(), userID)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, limits)
|
|
}
|
|
|
|
// UpdateUserRateLimits updates rate limits for a user (admin only)
|
|
// PATCH /admin/users/:id/rate-limits
|
|
func (h *Handler) UpdateUserRateLimits(c *gin.Context) {
|
|
adminIDStr := middleware.GetUserID(c)
|
|
adminID, err := uuid.Parse(adminIDStr)
|
|
if err != nil {
|
|
errx.Handle(c, errx.ErrAuth)
|
|
return
|
|
}
|
|
|
|
userIDStr := c.Param("id")
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
errx.Handle(c, errx.ErrNotFound)
|
|
return
|
|
}
|
|
|
|
var data models.UpdateUserRateLimits
|
|
if err := c.ShouldBindJSON(&data); err != nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "Invalid request body"))
|
|
return
|
|
}
|
|
|
|
limits, xerr := h.RateLimitService.UpdateUserLimits(c.Request.Context(), userID, &data, adminID)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, limits)
|
|
}
|