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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ coverage.html
# Editor/IDE
# .idea/
# .vscode/
cmd/argus/argus
8 changes: 6 additions & 2 deletions cmd/argus/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,9 @@ func main() {
"status": "healthy",
}

json.NewEncoder(w).Encode(response)
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("Failed to encode health response", "error", err)
}
})

// Version endpoint
Expand All @@ -105,7 +107,9 @@ func main() {
"service": "argus",
}

json.NewEncoder(w).Encode(response)
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("Failed to encode version response", "error", err)
}
})

// Initialize v1 API with database-agnostic repository
Expand Down
34 changes: 20 additions & 14 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@ Complete API reference for integrating Argus into your microservices architectur
| `actorId` | string | Required | Actor identifier (email, UUID, service name) |
| `targetType` | string | Required | Target type: `SERVICE` or `RESOURCE` |
| `traceId` | string (UUID) | Optional | Trace ID for distributed tracing |
| `eventType` | string | Optional | Custom event type (e.g., `MANAGEMENT_EVENT`) |
| `eventAction` | string | Optional | Action: `CREATE`, `READ`, `UPDATE`, `DELETE` |
| `eventType` | string | Optional | Custom event type (e.g. `MANAGEMENT_EVENT`) |
| `action` | string | Required | Action: `CREATE`, `READ`, `UPDATE`, `DELETE` |
| `targetId` | string | Optional | Target identifier |
| `requestMetadata` | object | Optional | Request payload (without PII/sensitive data) |
| `responseMetadata` | object | Optional | Response or error details |
| `additionalMetadata` | object | Optional | Additional context-specific data |
| `metadata` | object | Optional | Consolidated context-specific data |
| `message` | string | Optional | Raw message or payload for signing (Base64) |
| `signature` | string | Optional | Base64 encoded digital signature |
| `signatureAlgorithm` | string | Optional | Signature algorithm used (e.g. `RS256`, `EdDSA`) |
| `publicKeyId` | string | Optional | Identifier for the key used to sign the event |

**Example Request:**

Expand All @@ -48,14 +50,16 @@ curl -X POST http://localhost:3001/api/audit-logs \
"traceId": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2024-01-20T10:00:00Z",
"eventType": "MANAGEMENT_EVENT",
"eventAction": "READ",
"action": "READ",
"status": "SUCCESS",
"actorType": "SERVICE",
"actorId": "my-service",
"targetType": "SERVICE",
"targetId": "target-service",
"requestMetadata": {"schemaId": "schema-123"},
"responseMetadata": {"decision": "ALLOWED"}
"metadata": {"schemaId": "schema-123", "decision": "ALLOWED"},
"signature": "base64-encoded-signature",
"signatureAlgorithm": "RS256",
"publicKeyId": "nsw-key-1"
}'
```

Expand All @@ -72,6 +76,8 @@ curl -X POST http://localhost:3001/api/audit-logs \
"actorId": "my-service",
"targetType": "SERVICE",
"targetId": "target-service",
"action": "READ",
"metadata": {"schemaId": "schema-123", "decision": "ALLOWED"},
"createdAt": "2024-01-20T10:00:00.123456Z"
}
```
Expand All @@ -96,7 +102,7 @@ curl -X POST http://localhost:3001/api/audit-logs \
| ------------- | ------------- | -------- | ------- | ----------------------------------------- |
| `traceId` | string (UUID) | Optional | - | Filter by trace ID |
| `eventType` | string | Optional | - | Filter by event type |
| `eventAction` | string | Optional | - | Filter by event action |
| `action` | string | Optional | - | Filter by event action |
| `status` | string | Optional | - | Filter by status (`SUCCESS` or `FAILURE`) |
| `limit` | integer | Optional | 100 | Max results per page (1-1000) |
| `offset` | integer | Optional | 0 | Number of results to skip |
Expand Down Expand Up @@ -132,6 +138,8 @@ curl http://localhost:3001/api/audit-logs?eventType=MANAGEMENT_EVENT&status=SUCC
"actorId": "my-service",
"targetType": "SERVICE",
"targetId": "target-service",
"action": "READ",
"metadata": {"schemaId": "schema-123", "decision": "ALLOWED"},
"createdAt": "2024-01-20T10:00:00.123456Z"
}
],
Expand Down Expand Up @@ -245,11 +253,9 @@ Always use RFC3339 format (ISO 8601):

```json
{
"requestMetadata": {
"metadata": {
"schemaId": "schema-123",
"requestedFields": ["name", "address"]
},
"responseMetadata": {
"requestedFields": ["name", "address"],
"decision": "ALLOWED",
"fieldsReturned": 2
}
Expand All @@ -263,7 +269,7 @@ Always log failed operations:
```json
{
"status": "FAILURE",
"responseMetadata": {
"metadata": {
"error": "operation_failed",
"errorMessage": "Resource not found",
"errorCode": "404"
Expand Down
12 changes: 6 additions & 6 deletions internal/api/v1/database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ type AuditRepository interface {

// AuditLogFilters represents query filters for retrieving audit logs
type AuditLogFilters struct {
TraceID *string
EventType *string
EventAction *string
Status *string
Limit int
Offset int
TraceID *string
EventType *string
Action *string
Status *string
Limit int
Offset int
}
31 changes: 29 additions & 2 deletions internal/api/v1/database/gorm_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ type GormRepository struct {

// NewGormRepository creates a new repository (works with SQLite or PostgreSQL)
func NewGormRepository(db *gorm.DB) *GormRepository {
// Run pre-migration helper to handle breaking changes safely
runPreMigration(db)

// Auto-migrate the audit_logs table
if err := db.AutoMigrate(&models.AuditLog{}); err != nil {
// Log migration error but don't fail service creation
Expand All @@ -25,6 +28,30 @@ func NewGormRepository(db *gorm.DB) *GormRepository {
return &GormRepository{db: db}
}

// runPreMigration handles breaking schema changes before GORM's AutoMigrate
func runPreMigration(db *gorm.DB) {
if !db.Migrator().HasTable("audit_logs") {
return
}

// Handle column rename: event_action -> action
if db.Migrator().HasColumn("audit_logs", "event_action") && !db.Migrator().HasColumn("audit_logs", "action") {
slog.Info("Renaming column event_action to action in audit_logs table")
if err := db.Migrator().RenameColumn("audit_logs", "event_action", "action"); err != nil {
slog.Error("Failed to rename column event_action to action", "error", err)
}
}

// Handle NULLs for non-nullable conversion to avoid migration failures
slog.Info("Ensuring no NULL values in event_type and action columns before applying NOT NULL constraint")
if err := db.Exec("UPDATE audit_logs SET event_type = '' WHERE event_type IS NULL").Error; err != nil {
slog.Warn("Failed to update NULL event_type values", "error", err)
}
if err := db.Exec("UPDATE audit_logs SET action = '' WHERE action IS NULL").Error; err != nil {
slog.Warn("Failed to update NULL action values", "error", err)
}
}

// CreateAuditLog creates a new audit log entry
func (r *GormRepository) CreateAuditLog(ctx context.Context, log *models.AuditLog) (*models.AuditLog, error) {
result := r.db.WithContext(ctx).Create(log)
Expand Down Expand Up @@ -64,8 +91,8 @@ func (r *GormRepository) GetAuditLogs(ctx context.Context, filters *AuditLogFilt
if filters.EventType != nil && *filters.EventType != "" {
query = query.Where("event_type = ?", *filters.EventType)
}
if filters.EventAction != nil && *filters.EventAction != "" {
query = query.Where("event_action = ?", *filters.EventAction)
if filters.Action != nil && *filters.Action != "" {
query = query.Where("action = ?", *filters.Action)
}
if filters.Status != nil && *filters.Status != "" {
query = query.Where("status = ?", *filters.Status)
Expand Down
10 changes: 9 additions & 1 deletion internal/api/v1/handlers/audit_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ func (h *AuditHandler) CreateAuditLog(w http.ResponseWriter, r *http.Request) {
return
}

// Validation for signed events
if req.Signature != "" || req.PublicKeyID != "" || req.SignatureAlgorithm != "" {
if req.Signature == "" || req.PublicKeyID == "" || req.SignatureAlgorithm == "" {
utils.RespondWithError(w, http.StatusBadRequest, "Invalid signed event: signature, publicKeyId, and signatureAlgorithm must all be provided if any are present", nil)
return
}
}

// Validation is handled by the service layer (auditLog.Validate())
auditLog, err := h.service.CreateAuditLog(r.Context(), &req)
if err != nil {
Expand Down Expand Up @@ -94,7 +102,7 @@ func (h *AuditHandler) GetAuditLogs(w http.ResponseWriter, r *http.Request) {

logs, total, err := h.service.GetAuditLogs(r.Context(), traceIDPtr, eventTypePtr, limit, offset)
if err != nil {
// Check if it's a validation error (e.g., invalid traceId format from service layer)
// Check if it's a validation error (e.g. invalid traceId format from service layer)
if services.IsValidationError(err) {
utils.RespondWithError(w, http.StatusBadRequest, "Invalid query parameters", err)
return
Expand Down
48 changes: 22 additions & 26 deletions internal/api/v1/models/audit_log.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,9 @@ type AuditLog struct {
TraceID *uuid.UUID `gorm:"index:idx_audit_logs_trace_id" json:"traceId,omitempty"`

// Event Classification
Status string `gorm:"type:varchar(20);not null;index:idx_audit_logs_status" json:"status"`
EventType *string `gorm:"type:varchar(50)" json:"eventType,omitempty"` // e.g., MANAGEMENT_EVENT, USER_MANAGEMENT (user-defined custom names)
EventAction *string `gorm:"type:varchar(50)" json:"eventAction,omitempty"` // e.g., CREATE, READ, UPDATE, DELETE
Status string `gorm:"type:varchar(20);not null;index:idx_audit_logs_status" json:"status"`
EventType string `gorm:"type:varchar(50);not null;default:''" json:"eventType,omitempty"` // e.g. MANAGEMENT_EVENT, USER_MANAGEMENT
Action string `gorm:"type:varchar(50);not null;default:''" json:"action,omitempty"` // e.g. CREATE, READ, UPDATE, DELETE

// Actor Information (unified approach)
ActorType string `gorm:"type:varchar(50);not null" json:"actorType"`
Expand All @@ -133,9 +133,13 @@ type AuditLog struct {

// Metadata (Payload without PII/sensitive data)
// Using JSONBRawMessage to properly handle PostgreSQL JSONB and SQLite TEXT
RequestMetadata JSONBRawMessage `gorm:"type:jsonb" json:"requestMetadata,omitempty"` // Request payload without PII/sensitive data
ResponseMetadata JSONBRawMessage `gorm:"type:jsonb" json:"responseMetadata,omitempty"` // Response or Error details
AdditionalMetadata JSONBRawMessage `gorm:"type:jsonb" json:"additionalMetadata,omitempty"` // Additional context-specific data
Message JSONBRawMessage `gorm:"type:blob" json:"message,omitempty"` // Raw message or payload (e.g. for signing)
Metadata JSONBRawMessage `gorm:"type:jsonb" json:"metadata,omitempty"` // Consolidated metadata

// Security & Non-Repudiation
Signature string `gorm:"type:text" json:"signature,omitempty"`
SignatureAlgorithm string `gorm:"type:varchar(50)" json:"signatureAlgorithm,omitempty"`
PublicKeyID string `gorm:"type:varchar(255)" json:"publicKeyId,omitempty"`

// BaseModel provides CreatedAt
BaseModel
Expand Down Expand Up @@ -204,33 +208,25 @@ func (l *AuditLog) Validate() error {
}
}

// Validate event_type if provided (nullable field, using config's O(1) validation method)
if l.EventType != nil && *l.EventType != "" {
// Validate event_type if provided
if l.EventType != "" {
if enumConfig != nil {
if !enumConfig.IsValidEventType(*l.EventType) {
return fmt.Errorf("invalid eventType: %s", *l.EventType)
}
} else {
// Fallback to default event types when config is not loaded
// Use config.DefaultEnums to avoid duplication (access fields directly to avoid copying sync.Once)
if !contains(config.DefaultEnums.EventTypes, *l.EventType) {
return fmt.Errorf("invalid eventType: %s", *l.EventType)
if !enumConfig.IsValidEventType(l.EventType) {
return fmt.Errorf("invalid eventType: %s", l.EventType)
}
} else if !contains(config.DefaultEnums.EventTypes, l.EventType) {
return fmt.Errorf("invalid eventType: %s", l.EventType)
}
}

// Validate event_action if provided (nullable field, using config's O(1) validation method)
if l.EventAction != nil && *l.EventAction != "" {
// Validate action if provided
if l.Action != "" {
if enumConfig != nil {
if !enumConfig.IsValidEventAction(*l.EventAction) {
return fmt.Errorf("invalid eventAction: %s", *l.EventAction)
}
} else {
// Fallback to default actions when config is not loaded
// Use config.DefaultEnums to avoid duplication (access fields directly to avoid copying sync.Once)
if !contains(config.DefaultEnums.EventActions, *l.EventAction) {
return fmt.Errorf("invalid eventAction: %s", *l.EventAction)
if !enumConfig.IsValidEventAction(l.Action) {
return fmt.Errorf("invalid action: %s", l.Action)
}
} else if !contains(config.DefaultEnums.EventActions, l.Action) {
return fmt.Errorf("invalid action: %s", l.Action)
}
}

Expand Down
44 changes: 22 additions & 22 deletions internal/api/v1/models/audit_log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ func TestAuditLog_Validate_WithConfig(t *testing.T) {
name: "Valid event type from config",
log: AuditLog{
Status: StatusSuccess,
EventType: stringPtr("MANAGEMENT_EVENT"),
EventType: "MANAGEMENT_EVENT",
ActorType: "SERVICE",
ActorID: "service-1",
TargetType: "SERVICE",
Expand All @@ -118,7 +118,7 @@ func TestAuditLog_Validate_WithConfig(t *testing.T) {
name: "Invalid event type (not in config)",
log: AuditLog{
Status: StatusSuccess,
EventType: stringPtr("INVALID_EVENT"),
EventType: "INVALID_EVENT",
ActorType: "SERVICE",
ActorID: "service-1",
TargetType: "SERVICE",
Expand All @@ -129,24 +129,24 @@ func TestAuditLog_Validate_WithConfig(t *testing.T) {
{
name: "Valid event action from config",
log: AuditLog{
Status: StatusSuccess,
EventAction: stringPtr("CREATE"),
ActorType: "SERVICE",
ActorID: "service-1",
TargetType: "SERVICE",
TargetID: stringPtr("service-2"),
Status: StatusSuccess,
Action: "CREATE",
ActorType: "SERVICE",
ActorID: "service-1",
TargetType: "SERVICE",
TargetID: stringPtr("service-2"),
},
wantErr: false,
},
{
name: "Invalid event action (not in config)",
log: AuditLog{
Status: StatusSuccess,
EventAction: stringPtr("INVALID_ACTION"),
ActorType: "SERVICE",
ActorID: "service-1",
TargetType: "SERVICE",
TargetID: stringPtr("service-2"),
Status: StatusSuccess,
Action: "INVALID_ACTION",
ActorType: "SERVICE",
ActorID: "service-1",
TargetType: "SERVICE",
TargetID: stringPtr("service-2"),
},
wantErr: true,
},
Expand Down Expand Up @@ -264,15 +264,15 @@ func TestAuditLog_Validate_EdgeCases(t *testing.T) {
wantErr: true,
},
{
name: "Valid with nil optional fields",
name: "Valid with empty optional fields",
log: AuditLog{
Status: StatusSuccess,
ActorType: "SERVICE",
ActorID: "service-1",
TargetType: "SERVICE",
EventType: nil,
EventAction: nil,
TargetID: nil,
Status: StatusSuccess,
ActorType: "SERVICE",
ActorID: "service-1",
TargetType: "SERVICE",
EventType: "",
Action: "",
TargetID: nil,
},
wantErr: false,
},
Expand Down
20 changes: 11 additions & 9 deletions internal/api/v1/models/request_dtos.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ type CreateAuditLogRequest struct {
Timestamp string `json:"timestamp" validate:"required"` // ISO 8601 format, required

// Event Classification
EventType *string `json:"eventType,omitempty"` // MANAGEMENT_EVENT, USER_MANAGEMENT (user-defined custom names)
EventAction *string `json:"eventAction,omitempty"` // CREATE, READ, UPDATE, DELETE
Status string `json:"status" validate:"required"` // SUCCESS, FAILURE
EventType string `json:"eventType,omitempty"` // MANAGEMENT_EVENT, USER_MANAGEMENT
Action string `json:"action" validate:"required"` // CREATE, READ, UPDATE, DELETE
Status string `json:"status" validate:"required"` // SUCCESS, FAILURE

// Actor Information (unified approach)
ActorType string `json:"actorType" validate:"required"` // SERVICE, ADMIN, MEMBER, SYSTEM
Expand All @@ -22,10 +22,12 @@ type CreateAuditLogRequest struct {
TargetType string `json:"targetType" validate:"required"` // SERVICE, RESOURCE
TargetID *string `json:"targetId,omitempty"` // resource_id or service_name

// Metadata (Payload without PII/sensitive data)
// Using JSONBRawMessage instead of json.RawMessage to avoid type conversion
// JSONBRawMessage implements json.Unmarshaler, so it works seamlessly with JSON decoding
RequestMetadata JSONBRawMessage `json:"requestMetadata,omitempty"` // Request payload without PII/sensitive data
ResponseMetadata JSONBRawMessage `json:"responseMetadata,omitempty"` // Response or Error details
AdditionalMetadata JSONBRawMessage `json:"additionalMetadata,omitempty"` // Additional context-specific data
// Metadata
Message []byte `json:"message,omitempty"` // Raw message or payload for signing
Metadata map[string]interface{} `json:"metadata,omitempty"` // Consolidated metadata

// Security & Non-Repudiation
Signature string `json:"signature,omitempty"`
SignatureAlgorithm string `json:"signatureAlgorithm,omitempty"`
PublicKeyID string `json:"publicKeyId,omitempty"`
}
Loading