Skip to content
Draft
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
9 changes: 9 additions & 0 deletions gateway/api/agents/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/hoophq/hoop/common/proto"
"github.com/hoophq/hoop/gateway/api/openapi"
apivalidation "github.com/hoophq/hoop/gateway/api/validation"
"github.com/hoophq/hoop/gateway/audit"
"github.com/hoophq/hoop/gateway/models"
"github.com/hoophq/hoop/gateway/storagev2"
)
Expand Down Expand Up @@ -66,6 +67,7 @@ func Post(c *gin.Context) {
}

err = models.CreateAgent(ctx.OrgID, req.Name, req.Mode, secretKeyHash)
audit.LogFromContextErr(c, audit.ResourceAgent, audit.ActionCreate, req.Name, req.Name, payloadAgentCreate(req.Name, req.Mode), err)
switch err {
case models.ErrAlreadyExists:
c.JSON(http.StatusConflict, gin.H{"message": models.ErrAlreadyExists.Error()})
Expand All @@ -92,6 +94,7 @@ func Delete(c *gin.Context) {
ctx := storagev2.ParseContext(c)
nameOrID := c.Param("nameOrID")
err := models.DeleteAgentByNameOrID(ctx.OrgID, nameOrID)
audit.LogFromContextErr(c, audit.ResourceAgent, audit.ActionDelete, nameOrID, nameOrID, nil, err)
switch err {
case nil:
c.Writer.WriteHeader(204)
Expand Down Expand Up @@ -210,3 +213,9 @@ func List(c *gin.Context) {
}
c.JSON(http.StatusOK, result)
}

func payloadAgentCreate(name, mode string) audit.PayloadFn {
return func() map[string]any {
return map[string]any{"name": name, "mode": mode}
}
}
109 changes: 109 additions & 0 deletions gateway/api/auditlog/auditlog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package auditlog

import (
"net/http"
"strconv"

"github.com/gin-gonic/gin"
"github.com/hoophq/hoop/common/log"
"github.com/hoophq/hoop/gateway/api/openapi"
apivalidation "github.com/hoophq/hoop/gateway/api/validation"
"github.com/hoophq/hoop/gateway/models"
"github.com/hoophq/hoop/gateway/storagev2"
)

// List
//
// @Summary List security audit logs
// @Description Lists security audit log entries for the organization. Only admins can access this API. Supports filtering by actor, resource type, action, outcome, and date range. Results are ordered by created_at descending.
// @Tags Audit Logs
// @Produce json
// @Param page query int false "Page number (default: 1)" default(1)
// @Param page_size query int false "Page size (1-100, default: 50)" default(50)
// @Param actor_subject query string false "Filter by actor subject (partial match)"
// @Param actor_email query string false "Filter by actor email (partial match)"
// @Param resource_type query string false "Filter by resource type (e.g. connections, users, resources)"
// @Param action query string false "Filter by action (create, update, delete, revoke)"
// @Param resource_id query string false "Filter by resource ID (UUID)"
// @Param resource_name query string false "Filter by resource name (partial match)"
// @Param outcome query bool false "Filter by outcome (true = success, false = failure)"
// @Param created_after query string false "Filter entries created on or after this time (RFC3339 or YYYY-MM-DD)"
// @Param created_before query string false "Filter entries created on or before this time (RFC3339 or YYYY-MM-DD)"
// @Success 200 {object} openapi.PaginatedResponse[openapi.SecurityAuditLogResponse]
// @Failure 400,403,500 {object} openapi.HTTPError
// @Router /audit-logs [get]
func List(c *gin.Context) {
ctx := storagev2.ParseContext(c)

pageStr := c.Query("page")
pageSizeStr := c.Query("page_size")
page, pageSize, err := apivalidation.ParsePaginationParams(pageStr, pageSizeStr)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"message": err.Error()})
return
}

f := models.SecurityAuditLogFilter{
Page: page,
PageSize: pageSize,
ActorSubject: c.Query("actor_subject"),
ActorEmail: c.Query("actor_email"),
ResourceType: c.Query("resource_type"),
Action: c.Query("action"),
ResourceID: c.Query("resource_id"),
ResourceName: c.Query("resource_name"),
CreatedAfter: c.Query("created_after"),
CreatedBefore: c.Query("created_before"),
}

if outcomeStr := c.Query("outcome"); outcomeStr != "" {
outcome, err := strconv.ParseBool(outcomeStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "outcome must be true or false"})
return
}
f.Outcome = &outcome
}

rows, total, err := models.ListSecurityAuditLogs(models.DB, ctx.OrgID, f)
if err != nil {
log.Errorf("failed to list security audit logs: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"message": "internal server error"})
return
}

data := make([]openapi.SecurityAuditLogResponse, len(rows))
for i := range rows {
data[i] = toResponse(&rows[i])
}

c.JSON(http.StatusOK, openapi.PaginatedResponse[openapi.SecurityAuditLogResponse]{
Pages: openapi.Pagination{
Total: int(total),
Page: f.Page,
Size: f.PageSize,
},
Data: data,
})
}

func toResponse(r *models.SecurityAuditLog) openapi.SecurityAuditLogResponse {
res := openapi.SecurityAuditLogResponse{
ID: r.ID.String(),
OrgID: r.OrgID,
ActorSubject: r.ActorSubject,
ActorEmail: r.ActorEmail,
ActorName: r.ActorName,
CreatedAt: r.CreatedAt,
ResourceType: r.ResourceType,
Action: r.Action,
ResourceName: r.ResourceName,
RequestPayloadRedacted: r.RequestPayloadRedacted,
Outcome: r.Outcome,
ErrorMessage: r.ErrorMessage,
}
if r.ResourceID != nil {
res.ResourceID = r.ResourceID.String()
}
return res
}
21 changes: 21 additions & 0 deletions gateway/api/connections/connections.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/hoophq/hoop/gateway/api/apiroutes"
"github.com/hoophq/hoop/gateway/api/openapi"
apivalidation "github.com/hoophq/hoop/gateway/api/validation"
"github.com/hoophq/hoop/gateway/audit"
"github.com/hoophq/hoop/gateway/clientexec"
"github.com/hoophq/hoop/gateway/models"
"github.com/hoophq/hoop/gateway/storagev2"
Expand Down Expand Up @@ -95,6 +96,11 @@ func Post(c *gin.Context) {
AccessMaxDuration: req.AccessMaxDuration,
MinReviewApprovals: req.MinReviewApprovals,
})
resourceID := ""
if resp != nil {
resourceID = resp.ID
}
audit.LogFromContextErr(c, audit.ResourceConnection, audit.ActionCreate, resourceID, req.Name, payloadConnectionCreate(req.Name, req.Type, req.AgentId), err)
if err != nil {
log.Errorf("failed creating connection, err=%v", err)
c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()})
Expand Down Expand Up @@ -179,6 +185,7 @@ func Put(c *gin.Context) {
AccessMaxDuration: req.AccessMaxDuration,
MinReviewApprovals: req.MinReviewApprovals,
})
audit.LogFromContextErr(c, audit.ResourceConnection, audit.ActionUpdate, conn.ID, conn.Name, payloadConnectionUpdate(conn.Name, conn.Type), err)
if err != nil {
switch err.(type) {
case *models.ErrNotFoundGuardRailRules:
Expand Down Expand Up @@ -289,6 +296,7 @@ func Patch(c *gin.Context) {
}

resp, err := models.UpsertConnection(ctx, conn)
audit.LogFromContextErr(c, audit.ResourceConnection, audit.ActionUpdate, conn.ID, conn.Name, payloadConnectionUpdate(conn.Name, conn.Type), err)
if err != nil {
switch err.(type) {
case *models.ErrNotFoundGuardRailRules:
Expand Down Expand Up @@ -320,6 +328,7 @@ func Delete(c *gin.Context) {
return
}
err := models.DeleteConnection(ctx.OrgID, connName)
audit.LogFromContextErr(c, audit.ResourceConnection, audit.ActionDelete, connName, connName, nil, err)
switch err {
case models.ErrNotFound:
c.JSON(http.StatusNotFound, gin.H{"message": "not found"})
Expand Down Expand Up @@ -593,3 +602,15 @@ func testConnection(ctx *storagev2.Context, bearerToken string, conn *models.Con

return nil
}

func payloadConnectionCreate(name, connType, agentID string) audit.PayloadFn {
return func() map[string]any {
return map[string]any{"name": name, "type": connType, "agent_id": agentID}
}
}

func payloadConnectionUpdate(name, connType string) audit.PayloadFn {
return func() map[string]any {
return map[string]any{"name": name, "type": connType}
}
}
22 changes: 18 additions & 4 deletions gateway/api/datamasking/datamasking.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/hoophq/hoop/common/license"
"github.com/hoophq/hoop/common/log"
"github.com/hoophq/hoop/gateway/api/openapi"
"github.com/hoophq/hoop/gateway/audit"
"github.com/hoophq/hoop/gateway/models"
"github.com/hoophq/hoop/gateway/storagev2"
)
Expand Down Expand Up @@ -109,7 +110,11 @@ func Post(c *gin.Context) {
ConnectionIDs: req.ConnectionIDs,
UpdatedAt: time.Now().UTC(),
})

resourceID := ""
if rule != nil {
resourceID = rule.ID
}
audit.LogFromContextErr(c, audit.ResourceDataMasking, audit.ActionCreate, resourceID, req.Name, payloadDataMaskingRule(req.Name, req.Description, req.ConnectionIDs), err)
switch err {
case models.ErrAlreadyExists:
c.JSON(http.StatusConflict, gin.H{"message": err.Error()})
Expand Down Expand Up @@ -166,8 +171,9 @@ func Put(c *gin.Context) {
})
}

ruleID := c.Param("id")
rule, err := models.UpdateDataMaskingRule(&models.DataMaskingRule{
ID: c.Param("id"),
ID: ruleID,
OrgID: ctx.GetOrgID(),
Name: req.Name,
Description: req.Description,
Expand All @@ -177,7 +183,7 @@ func Put(c *gin.Context) {
ConnectionIDs: req.ConnectionIDs,
UpdatedAt: time.Now().UTC(),
})

audit.LogFromContextErr(c, audit.ResourceDataMasking, audit.ActionUpdate, ruleID, req.Name, payloadDataMaskingRule(req.Name, req.Description, req.ConnectionIDs), err)
switch err {
case models.ErrNotFound:
c.JSON(http.StatusNotFound, gin.H{"message": err.Error()})
Expand Down Expand Up @@ -252,7 +258,9 @@ func Get(c *gin.Context) {
// @Router /datamasking-rules/{id} [delete]
func Delete(c *gin.Context) {
ctx := storagev2.ParseContext(c)
err := models.DeleteDataMaskingRule(ctx.GetOrgID(), c.Param("id"))
ruleID := c.Param("id")
err := models.DeleteDataMaskingRule(ctx.GetOrgID(), ruleID)
audit.LogFromContextErr(c, audit.ResourceDataMasking, audit.ActionDelete, ruleID, "", nil, err)
switch err {
case models.ErrNotFound:
c.JSON(http.StatusNotFound, gin.H{"message": "resource not found"})
Expand Down Expand Up @@ -335,3 +343,9 @@ func parseRequestPayload(c *gin.Context) *openapi.DataMaskingRuleRequest {
}
return &req
}

func payloadDataMaskingRule(name, description string, connectionIDs []string) audit.PayloadFn {
return func() map[string]any {
return map[string]any{"name": name, "description": description, "connection_ids": connectionIDs}
}
}
15 changes: 12 additions & 3 deletions gateway/api/guardrails/guardrails.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/google/uuid"
"github.com/hoophq/hoop/common/log"
"github.com/hoophq/hoop/gateway/api/openapi"
"github.com/hoophq/hoop/gateway/audit"
"github.com/hoophq/hoop/gateway/models"
"github.com/hoophq/hoop/gateway/storagev2"
)
Expand Down Expand Up @@ -46,7 +47,7 @@ func Post(c *gin.Context) {

// Create guardrail and associate connections in a single transaction
err := models.UpsertGuardRailRuleWithConnections(rule, validConnectionIDs, true)

audit.LogFromContextErr(c, audit.ResourceGuardrails, audit.ActionCreate, rule.ID, rule.Name, payloadGuardrailRule(req.Name, req.Description, validConnectionIDs), err)
switch err {
case models.ErrAlreadyExists:
c.JSON(http.StatusConflict, gin.H{"message": err.Error()})
Expand Down Expand Up @@ -101,7 +102,7 @@ func Put(c *gin.Context) {

// Update guardrail and associate connections in a single transaction
err := models.UpsertGuardRailRuleWithConnections(rule, validConnectionIDs, false)

audit.LogFromContextErr(c, audit.ResourceGuardrails, audit.ActionUpdate, rule.ID, rule.Name, payloadGuardrailRule(req.Name, req.Description, validConnectionIDs), err)
switch err {
case models.ErrNotFound:
c.JSON(http.StatusNotFound, gin.H{"message": err.Error()})
Expand Down Expand Up @@ -204,7 +205,9 @@ func Get(c *gin.Context) {
// @Router /guardrails/{id} [delete]
func Delete(c *gin.Context) {
ctx := storagev2.ParseContext(c)
err := models.DeleteGuardRailRules(ctx.GetOrgID(), c.Param("id"))
ruleID := c.Param("id")
err := models.DeleteGuardRailRules(ctx.GetOrgID(), ruleID)
audit.LogFromContextErr(c, audit.ResourceGuardrails, audit.ActionDelete, ruleID, "", nil, err)
switch err {
case models.ErrNotFound:
c.JSON(http.StatusNotFound, gin.H{"message": "resource not found"})
Expand Down Expand Up @@ -236,3 +239,9 @@ func filterEmptyIDs(ids []string) []string {
}
return result
}

func payloadGuardrailRule(name, description string, connectionIDs []string) audit.PayloadFn {
return func() map[string]any {
return map[string]any{"name": name, "description": description, "connection_ids": connectionIDs}
}
}
Loading
Loading