From 7b7e6fa51c0503d32002e9ea5f70cb557f2c785a Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 12 Jan 2026 12:55:00 +0530 Subject: [PATCH 1/4] Add Audit Service to CE and PDP in OE federator --- audit-service/README.md | 137 +++--- audit-service/config/config.go | 54 +-- audit-service/config/enums.yaml | 1 - audit-service/database/client.go | 4 +- audit-service/v1/database/gorm_repository.go | 2 + audit-service/v1/models/audit_log.go | 72 +++- audit-service/v1/models/request_dtos.go | 12 +- audit-service/v1/models/response_dtos.go | 6 +- .../v1/services/audit_service_test.go | 28 +- audit-service/v1/testutil/mock_repository.go | 112 ++++- .../v1/testutil/mock_repository_test.go | 405 ++++++++++++++++++ exchange/docker-compose.yml | 4 +- .../orchestration-engine/config.example.json | 6 + .../orchestration-engine/configs/config.go | 20 + .../orchestration-engine/consent/ce_client.go | 7 + .../federator/federator.go | 105 +++++ exchange/orchestration-engine/go.mod | 33 ++ exchange/orchestration-engine/go.sum | 68 ++- exchange/orchestration-engine/main.go | 14 +- .../middleware/audit_middleware.go | 144 +++++-- .../orchestration-engine/policy/pdpclient.go | 7 + exchange/shared/monitoring/go.mod | 2 +- exchange/shared/monitoring/trace.go | 67 +++ .../v1/middleware/audit_middleware.go | 87 ++-- shared/audit/init.go | 4 +- shared/audit/interface.go | 31 ++ shared/audit/middleware.go | 14 +- shared/audit/utils.go | 28 ++ shared/audit/utils_test.go | 172 ++++++++ .../audit_flow_integration_test.go | 331 ++++++++++++++ tests/integration/audit_traceid_test.go | 237 ++++++++++ tests/integration/docker-compose.test.yml | 57 ++- 32 files changed, 2073 insertions(+), 198 deletions(-) create mode 100644 audit-service/v1/testutil/mock_repository_test.go create mode 100644 exchange/shared/monitoring/trace.go create mode 100644 shared/audit/interface.go create mode 100644 shared/audit/utils.go create mode 100644 shared/audit/utils_test.go create mode 100644 tests/integration/audit_flow_integration_test.go create mode 100644 tests/integration/audit_traceid_test.go diff --git a/audit-service/README.md b/audit-service/README.md index 41654980..e76ab2fa 100644 --- a/audit-service/README.md +++ b/audit-service/README.md @@ -1,6 +1,6 @@ # Audit Service -A Go microservice for centralized audit logging across OpenDIF services, providing distributed tracing and comprehensive event tracking. +A generic Go microservice for centralized audit logging across distributed services, providing distributed tracing and comprehensive event tracking. [![Go Version](https://img.shields.io/badge/Go-1.21%2B-blue)](https://golang.org/) [![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)](../LICENSE) @@ -115,6 +115,31 @@ Copy `.env.example` to `.env` and configure: For PostgreSQL configuration and advanced settings, see [.env.example](.env.example). +### Event Type Configuration + +Event types are configurable via `config/enums.yaml`. This allows you to customize the audit service for your specific use case. The service comes with generic default event types, but you can add project-specific ones. + +**Default Event Types:** +- `MANAGEMENT_EVENT` - Administrative operations +- `USER_MANAGEMENT` - User-related operations +- `DATA_FETCH` - Data retrieval operations + +**Customizing Event Types:** + +Edit `config/enums.yaml` to add your own event types: + +```yaml +enums: + eventTypes: + - MANAGEMENT_EVENT + - USER_MANAGEMENT + - DATA_FETCH + - YOUR_CUSTOM_EVENT_TYPE + - ANOTHER_EVENT_TYPE +``` + +See [config/README.md](config/README.md) for detailed configuration options. + ## API Endpoints ### Core Endpoints @@ -136,13 +161,13 @@ curl -X POST http://localhost:3001/api/audit-logs \ -d '{ "traceId": "550e8400-e29b-41d4-a716-446655440000", "timestamp": "2024-01-20T10:00:00Z", - "eventType": "POLICY_CHECK", + "eventType": "MANAGEMENT_EVENT", "eventAction": "READ", "status": "SUCCESS", "actorType": "SERVICE", - "actorId": "orchestration-engine", + "actorId": "my-service", "targetType": "SERVICE", - "targetId": "policy-decision-point" + "targetId": "target-service" }' ``` @@ -156,18 +181,9 @@ curl http://localhost:3001/api/audit-logs curl http://localhost:3001/api/audit-logs?traceId=550e8400-e29b-41d4-a716-446655440000 # Filter by event type -curl http://localhost:3001/api/audit-logs?eventType=POLICY_CHECK&status=SUCCESS +curl http://localhost:3001/api/audit-logs?eventType=MANAGEMENT_EVENT&status=SUCCESS ``` -See [docs/API.md](docs/API.md) for complete API documentation. - -## Documentation - -- **[API Documentation](docs/API.md)** - Complete API reference with examples -- **[Database Configuration](docs/DATABASE_CONFIGURATION.md)** - Database setup and configuration guide -- **[Architecture](docs/ARCHITECTURE.md)** - Project structure and design patterns -- **[OpenAPI Spec](openapi.yaml)** - OpenAPI 3.0 specification - ## Development ### Project Structure @@ -187,8 +203,6 @@ audit-service/ └── main.go # Entry point ``` -See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for detailed architecture documentation. - ### Running Tests ```bash @@ -221,6 +235,49 @@ go build -o audit-service go build -ldflags="-X main.Version=1.0.0 -X main.GitCommit=$(git rev-parse HEAD)" -o audit-service ``` +## Integration + +The Audit Service is designed to be integrated into any microservices architecture. It provides a simple REST API that can be called from any service. + +### Integration Pattern + +1. **Configure your service** to point to the audit service URL +2. **Send audit events** via HTTP POST to `/api/audit-logs` +3. **Query audit logs** via HTTP GET from `/api/audit-logs` + +### Client Libraries + +You can integrate the audit service using: + +- **HTTP Client**: Direct HTTP calls to the REST API +- **Shared Client Package**: Use the `shared/audit` package (if available in your project) +- **Custom Wrapper**: Create your own client library + +### Example Integration + +```go +// Example: Log an audit event from your service +auditRequest := map[string]interface{}{ + "traceId": traceID, + "timestamp": time.Now().UTC().Format(time.RFC3339), + "eventType": "YOUR_EVENT_TYPE", + "status": "SUCCESS", + "actorType": "SERVICE", + "actorId": "your-service-name", + "targetType": "RESOURCE", + "targetId": "resource-id", +} + +// POST to http://audit-service:3001/api/audit-logs +``` + +### Graceful Degradation + +- Services continue to function normally if audit service is unavailable +- No errors are thrown when audit service URL is not configured +- Audit operations should be asynchronous (fire-and-forget) to avoid blocking requests +- Services can be started before audit service is ready + ## Deployment ### Docker @@ -243,13 +300,17 @@ docker run -d \ -e DB_TYPE=postgres \ -e DB_HOST=postgres \ -e DB_PASSWORD=your_password \ + -e DB_NAME=audit_db \ audit-service ``` ### Docker Compose +The audit service includes a `docker-compose.yml` for standalone deployment: + ```bash -# Start service +# Deploy audit service +cd audit-service docker compose up -d # View logs @@ -266,30 +327,8 @@ docker compose down 3. **CORS**: Configure `CORS_ALLOWED_ORIGINS` appropriately 4. **Monitoring**: Monitor service health via `/health` endpoint 5. **Backup**: Implement database backup strategy - -## Integration with OpenDIF Services - -The Audit Service integrates with: - -- **Orchestration Engine** - Tracks data exchange operations -- **Portal Backend** - Logs administrative actions -- **Consent Engine** - Records consent changes - -Audit logging is **optional** - services function normally without it. - -### Configuration in Other Services - -```bash -# Enable audit logging in orchestration-engine -export AUDIT_SERVICE_ENABLED=true -export AUDIT_SERVICE_URL=http://audit-service:3001 - -# Enable audit logging in portal-backend -export AUDIT_SERVICE_ENABLED=true -export AUDIT_SERVICE_URL=http://audit-service:3001 -``` - -See [../exchange/AUDIT_SERVICE.md](../exchange/AUDIT_SERVICE.md) for integration documentation. +6. **High Availability**: Consider deploying multiple instances behind a load balancer +7. **Security**: Implement authentication/authorization if exposing the service publicly ## Troubleshooting @@ -312,6 +351,12 @@ See [../exchange/AUDIT_SERVICE.md](../exchange/AUDIT_SERVICE.md) for integration - Check credentials and SSL settings - Verify network connectivity +**Event type validation errors:** + +- Check that your event types are defined in `config/enums.yaml` +- Verify the enum configuration file is being loaded correctly +- Check service logs for validation error details + See [docs/DATABASE_CONFIGURATION.md](docs/DATABASE_CONFIGURATION.md) for detailed troubleshooting. ## Contributing @@ -328,12 +373,4 @@ This project is licensed under the Apache License 2.0 - see [LICENSE](../LICENSE ## Support -- **Issues**: [GitHub Issues](https://github.com/OpenDIF/opendif-core/issues) -- **Discussions**: [GitHub Discussions](https://github.com/OpenDIF/opendif-core/discussions) -- **Documentation**: [OpenDIF Documentation](https://github.com/OpenDIF/opendif-core/tree/main/docs) - -## Related Services - -- [Orchestration Engine](../exchange/orchestration-engine/) - Data exchange orchestration -- [Portal Backend](../portal-backend/) - Admin portal backend -- [Consent Engine](../exchange/consent-engine/) - Consent management +For issues, questions, or contributions, please use the project's issue tracker and discussion forums. diff --git a/audit-service/config/config.go b/audit-service/config/config.go index fc2ecf86..27d50afe 100644 --- a/audit-service/config/config.go +++ b/audit-service/config/config.go @@ -32,32 +32,34 @@ type Config struct { Enums AuditEnums `yaml:"enums"` } -// DefaultEnums provides default enum values if config file is not found -var DefaultEnums = AuditEnums{ - EventTypes: []string{ - "POLICY_CHECK", - "MANAGEMENT_EVENT", - "USER_MANAGEMENT", - "DATA_FETCH", - "CONSENT_CHECK", - }, - EventActions: []string{ - "CREATE", - "READ", - "UPDATE", - "DELETE", - }, - ActorTypes: []string{ - "SERVICE", - "ADMIN", - "MEMBER", - "SYSTEM", - }, - TargetTypes: []string{ - "SERVICE", - "RESOURCE", - }, -} +var ( + // DefaultEnums provides default enum values if config file is not found + // Note: OpenDIF-specific event types (ORCHESTRATION_REQUEST_RECEIVED, POLICY_CHECK, CONSENT_CHECK, PROVIDER_FETCH) + // should be added to config/enums.yaml for project-specific configurations + DefaultEnums = AuditEnums{ + EventTypes: []string{ + "MANAGEMENT_EVENT", + "USER_MANAGEMENT", + "DATA_FETCH", + }, + EventActions: []string{ + "CREATE", + "READ", + "UPDATE", + "DELETE", + }, + ActorTypes: []string{ + "SERVICE", + "ADMIN", + "MEMBER", + "SYSTEM", + }, + TargetTypes: []string{ + "SERVICE", + "RESOURCE", + }, + } +) // LoadEnums loads enum configuration from YAML file // If the file is not found or cannot be read, returns default enums diff --git a/audit-service/config/enums.yaml b/audit-service/config/enums.yaml index 95078ec8..3c42cf9d 100644 --- a/audit-service/config/enums.yaml +++ b/audit-service/config/enums.yaml @@ -10,7 +10,6 @@ enums: - MANAGEMENT_EVENT - USER_MANAGEMENT - DATA_FETCH - - CONSENT_CHECK # Event Action: CRUD operations eventActions: diff --git a/audit-service/database/client.go b/audit-service/database/client.go index 6b942d85..3bb06d0a 100644 --- a/audit-service/database/client.go +++ b/audit-service/database/client.go @@ -68,7 +68,7 @@ func NewDatabaseConfig() *Config { // For SQLite: only DB_TYPE=sqlite or DB_PATH count as configuration // DB_HOST is only relevant when DB_TYPE=postgres -useFileBasedSQLite := dbPathSet || (dbTypeSet && dbTypeStr != "postgres" && dbTypeStr != "postgresql") + useFileBasedSQLite := dbPathSet || (dbTypeSet && dbTypeStr != "postgres" && dbTypeStr != "postgresql") switch dbTypeStr { case "postgres", "postgresql": @@ -103,7 +103,7 @@ useFileBasedSQLite := dbPathSet || (dbTypeSet && dbTypeStr != "postgres" && dbTy config.MaxIdleConns = parseIntOrDefault("DB_MAX_IDLE_CONNS", 1) // Determine database path based on configuration -if !useFileBasedSQLite { + if !useFileBasedSQLite { // No SQLite configuration at all → in-memory database for quick testing config.DatabasePath = ":memory:" slog.Info("No database configuration found, using in-memory SQLite") diff --git a/audit-service/v1/database/gorm_repository.go b/audit-service/v1/database/gorm_repository.go index 45c93017..e5a12388 100644 --- a/audit-service/v1/database/gorm_repository.go +++ b/audit-service/v1/database/gorm_repository.go @@ -77,6 +77,8 @@ func (r *GormRepository) GetAuditLogs(ctx context.Context, filters *AuditLogFilt } // Apply pagination and ordering + // Note: Results are ordered by timestamp DESC (newest first) for general queries. + // For trace-specific queries, use GetAuditLogsByTraceID which orders by ASC (chronological). limit := filters.Limit if limit <= 0 { limit = 100 // default diff --git a/audit-service/v1/models/audit_log.go b/audit-service/v1/models/audit_log.go index ac1d8538..54653ab0 100644 --- a/audit-service/v1/models/audit_log.go +++ b/audit-service/v1/models/audit_log.go @@ -1,6 +1,7 @@ package models import ( + "database/sql/driver" "encoding/json" "fmt" "sync" @@ -11,6 +12,71 @@ import ( "gorm.io/gorm" ) +// JSONBRawMessage is a custom type that properly handles JSONB scanning from PostgreSQL +// It implements sql.Scanner and driver.Valuer interfaces to handle both PostgreSQL JSONB +// (which can return as string or []byte) and SQLite TEXT (which returns as []byte) +// This type wraps json.RawMessage to provide database scanning capabilities while maintaining +// the same JSON marshaling behavior as json.RawMessage. +type JSONBRawMessage json.RawMessage + +// Scan implements the sql.Scanner interface for JSONBRawMessage +// Handles both PostgreSQL JSONB (string or []byte) and SQLite TEXT ([]byte) +func (j *JSONBRawMessage) Scan(value interface{}) error { + if value == nil { + *j = JSONBRawMessage(nil) + return nil + } + + var bytes []byte + switch v := value.(type) { + case []byte: + bytes = v + case string: + bytes = []byte(v) + default: + return fmt.Errorf("cannot scan %T into JSONBRawMessage", value) + } + + // Note: We don't validate JSON here to avoid performance overhead. + // The database should already contain valid JSON. If validation is needed, + // it should be done at the application layer when unmarshaling. + *j = JSONBRawMessage(bytes) + return nil +} + +// Value implements the driver.Valuer interface for JSONBRawMessage +func (j JSONBRawMessage) Value() (driver.Value, error) { + if len(j) == 0 { + return nil, nil + } + return []byte(j), nil +} + +// MarshalJSON implements json.Marshaler for JSONBRawMessage +// Delegates to the underlying json.RawMessage behavior +func (j JSONBRawMessage) MarshalJSON() ([]byte, error) { + if len(j) == 0 { + return []byte("null"), nil + } + return []byte(j), nil +} + +// UnmarshalJSON implements json.Unmarshaler for JSONBRawMessage +// Delegates to the underlying json.RawMessage behavior +func (j *JSONBRawMessage) UnmarshalJSON(data []byte) error { + if j == nil { + return fmt.Errorf("JSONBRawMessage: UnmarshalJSON on nil pointer") + } + *j = JSONBRawMessage(data) + return nil +} + +// GormDataType returns the GORM data type for JSONBRawMessage +// This helps GORM understand the database column type +func (JSONBRawMessage) GormDataType() string { + return "jsonb" +} + // Audit log status constants (not configurable via YAML as they are core to the system) const ( StatusSuccess = "SUCCESS" @@ -66,9 +132,9 @@ type AuditLog struct { TargetID *string `gorm:"type:varchar(255)" json:"targetId,omitempty"` // resource_id or service_name // Metadata (Payload without PII/sensitive data) - RequestMetadata json.RawMessage `gorm:"type:text" json:"requestMetadata,omitempty"` // Request payload without PII/sensitive data - ResponseMetadata json.RawMessage `gorm:"type:text" json:"responseMetadata,omitempty"` // Response or Error details - AdditionalMetadata json.RawMessage `gorm:"type:text" json:"additionalMetadata,omitempty"` // Additional context-specific data + 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 // BaseModel provides CreatedAt BaseModel diff --git a/audit-service/v1/models/request_dtos.go b/audit-service/v1/models/request_dtos.go index e75065d9..f8b8c167 100644 --- a/audit-service/v1/models/request_dtos.go +++ b/audit-service/v1/models/request_dtos.go @@ -1,9 +1,5 @@ package models -import ( - "encoding/json" -) - // CreateAuditLogRequest represents the request payload for creating a generalized audit log // This matches the final SQL schema with unified actor/target approach type CreateAuditLogRequest struct { @@ -27,7 +23,9 @@ type CreateAuditLogRequest struct { TargetID *string `json:"targetId,omitempty"` // resource_id or service_name // Metadata (Payload without PII/sensitive data) - RequestMetadata json.RawMessage `json:"requestMetadata,omitempty"` // Request payload without PII/sensitive data - ResponseMetadata json.RawMessage `json:"responseMetadata,omitempty"` // Response or Error details - AdditionalMetadata json.RawMessage `json:"additionalMetadata,omitempty"` // Additional context-specific 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 } diff --git a/audit-service/v1/models/response_dtos.go b/audit-service/v1/models/response_dtos.go index e215844d..42c07152 100644 --- a/audit-service/v1/models/response_dtos.go +++ b/audit-service/v1/models/response_dtos.go @@ -52,9 +52,9 @@ func ToAuditLogResponse(log AuditLog) AuditLogResponse { ActorID: log.ActorID, TargetType: log.TargetType, TargetID: log.TargetID, - RequestMetadata: log.RequestMetadata, - ResponseMetadata: log.ResponseMetadata, - AdditionalMetadata: log.AdditionalMetadata, + RequestMetadata: json.RawMessage(log.RequestMetadata), + ResponseMetadata: json.RawMessage(log.ResponseMetadata), + AdditionalMetadata: json.RawMessage(log.AdditionalMetadata), CreatedAt: log.CreatedAt, } } diff --git a/audit-service/v1/services/audit_service_test.go b/audit-service/v1/services/audit_service_test.go index ccde5c9b..cbbafb4d 100644 --- a/audit-service/v1/services/audit_service_test.go +++ b/audit-service/v1/services/audit_service_test.go @@ -6,11 +6,34 @@ import ( "time" "github.com/gov-dx-sandbox/audit-service/config" + "github.com/gov-dx-sandbox/audit-service/v1/database" v1models "github.com/gov-dx-sandbox/audit-service/v1/models" - v1testutil "github.com/gov-dx-sandbox/audit-service/v1/testutil" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" ) +// setupSQLiteTestDB creates an in-memory SQLite database for testing +func setupSQLiteTestDB(t *testing.T) *gorm.DB { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err, "Failed to open SQLite test database") + + // Auto-migrate the audit_logs table + err = db.AutoMigrate(&v1models.AuditLog{}) + require.NoError(t, err, "Failed to migrate audit_logs table") + + return db +} + +// setupTestService creates an audit service with SQLite test database +func setupTestService(t *testing.T) (*AuditService, *gorm.DB) { + db := setupSQLiteTestDB(t) + repo := database.NewGormRepository(db) + service := NewAuditService(repo) + return service, db +} + func TestAuditService_CreateAuditLog_Validation(t *testing.T) { // Set up enum configuration enums := &config.AuditEnums{ @@ -22,8 +45,7 @@ func TestAuditService_CreateAuditLog_Validation(t *testing.T) { enums.InitializeMaps() v1models.SetEnumConfig(enums) - mockRepo := v1testutil.NewMockRepository() - service := NewAuditService(mockRepo) + service, _ := setupTestService(t) tests := []struct { name string diff --git a/audit-service/v1/testutil/mock_repository.go b/audit-service/v1/testutil/mock_repository.go index 78574369..1e88f252 100644 --- a/audit-service/v1/testutil/mock_repository.go +++ b/audit-service/v1/testutil/mock_repository.go @@ -2,6 +2,7 @@ package testutil import ( "context" + "sort" "github.com/google/uuid" "github.com/gov-dx-sandbox/audit-service/v1/database" @@ -30,14 +31,117 @@ func (m *MockRepository) CreateAuditLog(ctx context.Context, log *v1models.Audit return log, nil } -// GetAuditLogsByTraceID returns empty results (can be extended for more complex test scenarios) +// GetAuditLogsByTraceID retrieves all audit logs for a given trace ID +// Results are ordered by timestamp ASC (chronological order) func (m *MockRepository) GetAuditLogsByTraceID(ctx context.Context, traceID string) ([]v1models.AuditLog, error) { - return nil, nil + // Parse traceID string to UUID for comparison + traceUUID, err := uuid.Parse(traceID) + if err != nil { + return []v1models.AuditLog{}, nil // Return empty slice for invalid UUID + } + + filteredLogs := []v1models.AuditLog{} + for _, log := range m.logs { + if log.TraceID != nil && *log.TraceID == traceUUID { + filteredLogs = append(filteredLogs, *log) + } + } + + // Sort by timestamp ASC (chronological order) + sort.Slice(filteredLogs, func(i, j int) bool { + return filteredLogs[i].Timestamp.Before(filteredLogs[j].Timestamp) + }) + + return filteredLogs, nil } -// GetAuditLogs returns empty results (can be extended for more complex test scenarios) +// GetAuditLogs retrieves audit logs with optional filtering +// Results are ordered by timestamp DESC (newest first) and paginated func (m *MockRepository) GetAuditLogs(ctx context.Context, filters *database.AuditLogFilters) ([]v1models.AuditLog, int64, error) { - return nil, 0, nil + if filters == nil { + filters = &database.AuditLogFilters{} + } + + // Filter logs based on provided criteria + filteredLogs := []v1models.AuditLog{} + for _, log := range m.logs { + matches := true + + // Filter by TraceID + if filters.TraceID != nil && *filters.TraceID != "" { + traceUUID, err := uuid.Parse(*filters.TraceID) + if err != nil { + continue // Skip if traceID is invalid + } + if log.TraceID == nil || *log.TraceID != traceUUID { + matches = false + } + } + + // Filter by EventType + if matches && filters.EventType != nil && *filters.EventType != "" { + if log.EventType == nil || *log.EventType != *filters.EventType { + matches = false + } + } + + // Filter by EventAction + if matches && filters.EventAction != nil && *filters.EventAction != "" { + if log.EventAction == nil || *log.EventAction != *filters.EventAction { + matches = false + } + } + + // Filter by Status + if matches && filters.Status != nil && *filters.Status != "" { + if log.Status != *filters.Status { + matches = false + } + } + + if matches { + filteredLogs = append(filteredLogs, *log) + } + } + + // Get total count before pagination + total := int64(len(filteredLogs)) + + // Sort by timestamp DESC (newest first) + sort.Slice(filteredLogs, func(i, j int) bool { + return filteredLogs[i].Timestamp.After(filteredLogs[j].Timestamp) + }) + + // Apply pagination + limit := filters.Limit + if limit <= 0 { + limit = 100 // default + } + if limit > 1000 { + limit = 1000 // max + } + + offset := filters.Offset + if offset < 0 { + offset = 0 + } + + // Apply offset and limit + start := offset + end := offset + limit + if start > len(filteredLogs) { + start = len(filteredLogs) + } + if end > len(filteredLogs) { + end = len(filteredLogs) + } + + if start >= end { + return []v1models.AuditLog{}, total, nil + } + + paginatedLogs := filteredLogs[start:end] + return paginatedLogs, total, nil } // GetLogs returns all logs stored in the mock (useful for test assertions) diff --git a/audit-service/v1/testutil/mock_repository_test.go b/audit-service/v1/testutil/mock_repository_test.go new file mode 100644 index 00000000..1a2a39fb --- /dev/null +++ b/audit-service/v1/testutil/mock_repository_test.go @@ -0,0 +1,405 @@ +package testutil + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gov-dx-sandbox/audit-service/v1/database" + v1models "github.com/gov-dx-sandbox/audit-service/v1/models" +) + +func TestMockRepository_GetAuditLogsByTraceID(t *testing.T) { + mockRepo := NewMockRepository() + ctx := context.Background() + + // Create test trace IDs + traceID1 := uuid.New() + traceID2 := uuid.New() + + // Create audit logs with different trace IDs + eventType1 := "POLICY_CHECK" + eventType2 := "CONSENT_CHECK" + eventType3 := "PROVIDER_FETCH" + + // Logs for traceID1 (should be returned in chronological order) + log1 := &v1models.AuditLog{ + ID: uuid.New(), + Timestamp: time.Now().Add(-2 * time.Hour), + TraceID: &traceID1, + Status: v1models.StatusSuccess, + EventType: &eventType1, + ActorType: "SERVICE", + ActorID: "policy-decision-point", + TargetType: "SERVICE", + } + + log2 := &v1models.AuditLog{ + ID: uuid.New(), + Timestamp: time.Now().Add(-1 * time.Hour), + TraceID: &traceID1, + Status: v1models.StatusSuccess, + EventType: &eventType2, + ActorType: "SERVICE", + ActorID: "consent-engine", + TargetType: "SERVICE", + } + + log3 := &v1models.AuditLog{ + ID: uuid.New(), + Timestamp: time.Now(), + TraceID: &traceID1, + Status: v1models.StatusSuccess, + EventType: &eventType3, + ActorType: "SERVICE", + ActorID: "orchestration-engine", + TargetType: "SERVICE", + } + + // Log for traceID2 (should not be returned) + log4 := &v1models.AuditLog{ + ID: uuid.New(), + Timestamp: time.Now(), + TraceID: &traceID2, + Status: v1models.StatusSuccess, + EventType: &eventType1, + ActorType: "SERVICE", + ActorID: "policy-decision-point", + TargetType: "SERVICE", + } + + // Create logs in the repository + _, err := mockRepo.CreateAuditLog(ctx, log1) + require.NoError(t, err) + _, err = mockRepo.CreateAuditLog(ctx, log2) + require.NoError(t, err) + _, err = mockRepo.CreateAuditLog(ctx, log3) + require.NoError(t, err) + _, err = mockRepo.CreateAuditLog(ctx, log4) + require.NoError(t, err) + + // Test: Get logs by traceID1 + logs, err := mockRepo.GetAuditLogsByTraceID(ctx, traceID1.String()) + require.NoError(t, err) + assert.Len(t, logs, 3, "Should return 3 logs for traceID1") + + // Verify chronological ordering (ASC) + assert.Equal(t, log1.ID, logs[0].ID, "First log should be the oldest") + assert.Equal(t, log2.ID, logs[1].ID, "Second log should be the middle one") + assert.Equal(t, log3.ID, logs[2].ID, "Third log should be the newest") + + // Verify all logs have the correct traceID + for _, log := range logs { + assert.NotNil(t, log.TraceID) + assert.Equal(t, traceID1, *log.TraceID) + } + + // Test: Get logs by traceID2 + logs2, err := mockRepo.GetAuditLogsByTraceID(ctx, traceID2.String()) + require.NoError(t, err) + assert.Len(t, logs2, 1, "Should return 1 log for traceID2") + assert.Equal(t, log4.ID, logs2[0].ID) + + // Test: Get logs by non-existent traceID + logs3, err := mockRepo.GetAuditLogsByTraceID(ctx, uuid.New().String()) + require.NoError(t, err) + assert.Len(t, logs3, 0, "Should return empty slice for non-existent traceID") + assert.NotNil(t, logs3, "Should return empty slice, not nil") +} + +func TestMockRepository_GetAuditLogs_Filtering(t *testing.T) { + mockRepo := NewMockRepository() + ctx := context.Background() + + // Create test data + traceID1 := uuid.New() + traceID2 := uuid.New() + eventType1 := "POLICY_CHECK" + eventType2 := "CONSENT_CHECK" + eventAction1 := "READ" + eventAction2 := "CREATE" + + // Create various audit logs + logs := []*v1models.AuditLog{ + { + ID: uuid.New(), + Timestamp: time.Now().Add(-3 * time.Hour), + TraceID: &traceID1, + Status: v1models.StatusSuccess, + EventType: &eventType1, + EventAction: &eventAction1, + ActorType: "SERVICE", + ActorID: "policy-decision-point", + TargetType: "SERVICE", + }, + { + ID: uuid.New(), + Timestamp: time.Now().Add(-2 * time.Hour), + TraceID: &traceID1, + Status: v1models.StatusSuccess, + EventType: &eventType2, + EventAction: &eventAction1, + ActorType: "SERVICE", + ActorID: "consent-engine", + TargetType: "SERVICE", + }, + { + ID: uuid.New(), + Timestamp: time.Now().Add(-1 * time.Hour), + TraceID: &traceID2, + Status: v1models.StatusFailure, + EventType: &eventType1, + EventAction: &eventAction2, + ActorType: "SERVICE", + ActorID: "policy-decision-point", + TargetType: "SERVICE", + }, + { + ID: uuid.New(), + Timestamp: time.Now(), + TraceID: &traceID2, + Status: v1models.StatusSuccess, + EventType: &eventType2, + EventAction: &eventAction1, + ActorType: "SERVICE", + ActorID: "consent-engine", + TargetType: "SERVICE", + }, + } + + // Seed the repository + for _, log := range logs { + _, err := mockRepo.CreateAuditLog(ctx, log) + require.NoError(t, err) + } + + // Test: Filter by TraceID + t.Run("FilterByTraceID", func(t *testing.T) { + filters := &database.AuditLogFilters{ + TraceID: stringPtr(traceID1.String()), + } + result, total, err := mockRepo.GetAuditLogs(ctx, filters) + require.NoError(t, err) + assert.Equal(t, int64(2), total, "Should find 2 logs with traceID1") + assert.Len(t, result, 2, "Should return 2 logs") + for _, log := range result { + assert.NotNil(t, log.TraceID) + assert.Equal(t, traceID1, *log.TraceID) + } + }) + + // Test: Filter by EventType + t.Run("FilterByEventType", func(t *testing.T) { + filters := &database.AuditLogFilters{ + EventType: &eventType1, + } + result, total, err := mockRepo.GetAuditLogs(ctx, filters) + require.NoError(t, err) + assert.Equal(t, int64(2), total, "Should find 2 logs with eventType1") + assert.Len(t, result, 2, "Should return 2 logs") + for _, log := range result { + assert.NotNil(t, log.EventType) + assert.Equal(t, eventType1, *log.EventType) + } + }) + + // Test: Filter by Status + t.Run("FilterByStatus", func(t *testing.T) { + filters := &database.AuditLogFilters{ + Status: stringPtr(v1models.StatusFailure), + } + result, total, err := mockRepo.GetAuditLogs(ctx, filters) + require.NoError(t, err) + assert.Equal(t, int64(1), total, "Should find 1 log with FAILURE status") + assert.Len(t, result, 1, "Should return 1 log") + assert.Equal(t, v1models.StatusFailure, result[0].Status) + }) + + // Test: Filter by EventAction + t.Run("FilterByEventAction", func(t *testing.T) { + filters := &database.AuditLogFilters{ + EventAction: &eventAction1, + } + result, total, err := mockRepo.GetAuditLogs(ctx, filters) + require.NoError(t, err) + assert.Equal(t, int64(3), total, "Should find 3 logs with eventAction1") + assert.Len(t, result, 3, "Should return 3 logs") + for _, log := range result { + assert.NotNil(t, log.EventAction) + assert.Equal(t, eventAction1, *log.EventAction) + } + }) + + // Test: Multiple filters combined + t.Run("MultipleFilters", func(t *testing.T) { + filters := &database.AuditLogFilters{ + TraceID: stringPtr(traceID1.String()), + EventType: &eventType1, + } + result, total, err := mockRepo.GetAuditLogs(ctx, filters) + require.NoError(t, err) + assert.Equal(t, int64(1), total, "Should find 1 log matching both filters") + assert.Len(t, result, 1, "Should return 1 log") + assert.Equal(t, traceID1, *result[0].TraceID) + assert.Equal(t, eventType1, *result[0].EventType) + }) + + // Test: No filters (should return all logs) + t.Run("NoFilters", func(t *testing.T) { + filters := &database.AuditLogFilters{} + result, total, err := mockRepo.GetAuditLogs(ctx, filters) + require.NoError(t, err) + assert.Equal(t, int64(4), total, "Should find all 4 logs") + assert.Len(t, result, 4, "Should return all 4 logs") + }) +} + +func TestMockRepository_GetAuditLogs_Pagination(t *testing.T) { + mockRepo := NewMockRepository() + ctx := context.Background() + + // Create 10 audit logs + traceID := uuid.New() + eventType := "POLICY_CHECK" + for i := 0; i < 10; i++ { + log := &v1models.AuditLog{ + ID: uuid.New(), + Timestamp: time.Now().Add(time.Duration(i) * time.Minute), + TraceID: &traceID, + Status: v1models.StatusSuccess, + EventType: &eventType, + ActorType: "SERVICE", + ActorID: "test-service", + TargetType: "SERVICE", + } + _, err := mockRepo.CreateAuditLog(ctx, log) + require.NoError(t, err) + } + + // Test: Pagination with limit + t.Run("PaginationWithLimit", func(t *testing.T) { + filters := &database.AuditLogFilters{ + Limit: 5, + } + result, total, err := mockRepo.GetAuditLogs(ctx, filters) + require.NoError(t, err) + assert.Equal(t, int64(10), total, "Total should be 10") + assert.Len(t, result, 5, "Should return 5 logs (limit)") + }) + + // Test: Pagination with offset + t.Run("PaginationWithOffset", func(t *testing.T) { + filters := &database.AuditLogFilters{ + Limit: 5, + Offset: 5, + } + result, total, err := mockRepo.GetAuditLogs(ctx, filters) + require.NoError(t, err) + assert.Equal(t, int64(10), total, "Total should be 10") + assert.Len(t, result, 5, "Should return 5 logs (offset 5, limit 5)") + }) + + // Test: Pagination beyond available logs + t.Run("PaginationBeyondAvailable", func(t *testing.T) { + filters := &database.AuditLogFilters{ + Limit: 5, + Offset: 10, + } + result, total, err := mockRepo.GetAuditLogs(ctx, filters) + require.NoError(t, err) + assert.Equal(t, int64(10), total, "Total should be 10") + assert.Len(t, result, 0, "Should return empty slice when offset exceeds available logs") + assert.NotNil(t, result, "Should return empty slice, not nil") + }) + + // Test: Default limit + t.Run("DefaultLimit", func(t *testing.T) { + filters := &database.AuditLogFilters{ + Limit: 0, // Should default to 100 + } + result, total, err := mockRepo.GetAuditLogs(ctx, filters) + require.NoError(t, err) + assert.Equal(t, int64(10), total, "Total should be 10") + assert.Len(t, result, 10, "Should return all 10 logs (default limit is 100, but we only have 10)") + }) + + // Test: Max limit + t.Run("MaxLimit", func(t *testing.T) { + filters := &database.AuditLogFilters{ + Limit: 2000, // Should be capped at 1000 + } + result, total, err := mockRepo.GetAuditLogs(ctx, filters) + require.NoError(t, err) + assert.Equal(t, int64(10), total, "Total should be 10") + assert.Len(t, result, 10, "Should return all 10 logs (limit capped at 1000, but we only have 10)") + }) +} + +func TestMockRepository_GetAuditLogs_Ordering(t *testing.T) { + mockRepo := NewMockRepository() + ctx := context.Background() + + // Create logs with different timestamps + traceID := uuid.New() + eventType := "POLICY_CHECK" + now := time.Now() + + logs := []*v1models.AuditLog{ + { + ID: uuid.New(), + Timestamp: now.Add(-2 * time.Hour), + TraceID: &traceID, + Status: v1models.StatusSuccess, + EventType: &eventType, + ActorType: "SERVICE", + ActorID: "test-service", + TargetType: "SERVICE", + }, + { + ID: uuid.New(), + Timestamp: now, + TraceID: &traceID, + Status: v1models.StatusSuccess, + EventType: &eventType, + ActorType: "SERVICE", + ActorID: "test-service", + TargetType: "SERVICE", + }, + { + ID: uuid.New(), + Timestamp: now.Add(-1 * time.Hour), + TraceID: &traceID, + Status: v1models.StatusSuccess, + EventType: &eventType, + ActorType: "SERVICE", + ActorID: "test-service", + TargetType: "SERVICE", + }, + } + + // Seed in non-chronological order + for _, log := range logs { + _, err := mockRepo.CreateAuditLog(ctx, log) + require.NoError(t, err) + } + + // Test: Results should be ordered DESC (newest first) + filters := &database.AuditLogFilters{} + result, total, err := mockRepo.GetAuditLogs(ctx, filters) + require.NoError(t, err) + assert.Equal(t, int64(3), total) + + // Verify DESC ordering (newest first) + assert.Equal(t, logs[1].ID, result[0].ID, "First result should be the newest") + assert.Equal(t, logs[2].ID, result[1].ID, "Second result should be the middle one") + assert.Equal(t, logs[0].ID, result[2].ID, "Third result should be the oldest") +} + +// Helper function to create string pointer +func stringPtr(s string) *string { + return &s +} diff --git a/exchange/docker-compose.yml b/exchange/docker-compose.yml index a5fb5714..39114d68 100644 --- a/exchange/docker-compose.yml +++ b/exchange/docker-compose.yml @@ -5,7 +5,7 @@ # Note: Audit Service is optional and can be deployed separately. # See audit-service/docker-compose.yml for standalone deployment. # Services will function normally without audit-service when -# CHOREO_AUDIT_CONNECTION_SERVICEURL is not set or ENABLE_AUDIT=false. +# AUDIT_SERVICE_URL is not set or ENABLE_AUDIT=false. services: policy-decision-point: @@ -83,6 +83,8 @@ services: - LOG_FORMAT=${LOG_FORMAT:-text} - SERVICE_NAME=orchestration-engine - OTEL_METRICS_EXPORTER=${OTEL_METRICS_EXPORTER:-prometheus} + - AUDIT_SERVICE_URL=${AUDIT_SERVICE_URL:-http://audit-service:3001} + - ENABLE_AUDIT=${ENABLE_AUDIT:-true} healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4000/health"] interval: 30s diff --git a/exchange/orchestration-engine/config.example.json b/exchange/orchestration-engine/config.example.json index 371e1002..6f3441a6 100644 --- a/exchange/orchestration-engine/config.example.json +++ b/exchange/orchestration-engine/config.example.json @@ -1,6 +1,12 @@ { "ceUrl": "http://localhost:8081", "pdpUrl": "http://localhost:8082", + "auditConfig": { + "serviceUrl": "http://localhost:3001", + "actorType": "SERVICE", + "actorId": "orchestration-engine", + "targetType": "SERVICE" + }, "providers": [ { "providerKey": "drp", diff --git a/exchange/orchestration-engine/configs/config.go b/exchange/orchestration-engine/configs/config.go index e5bf35e0..9a92b240 100644 --- a/exchange/orchestration-engine/configs/config.go +++ b/exchange/orchestration-engine/configs/config.go @@ -27,6 +27,7 @@ type Config struct { Services ServicesConfig `json:"services,omitempty"` PdpConfig PdpConfig `json:"pdpConfig,omitempty"` CeConfig CeConfig `json:"ceConfig,omitempty"` + AuditConfig AuditConfig `json:"auditConfig,omitempty"` Schema *string `json:"schema,omitempty"` Sdl *string `json:"sdl,omitempty"` ArgMapping []*graphql.ArgMapping `json:"argMapping,omitempty"` @@ -65,6 +66,14 @@ type CeConfig struct { ClientURL string `json:"clientUrl"` } +// AuditConfig holds Audit Service configuration +type AuditConfig struct { + ServiceURL string `json:"serviceUrl,omitempty"` + ActorType string `json:"actorType,omitempty"` // Default: "SERVICE" + ActorID string `json:"actorId,omitempty"` // Default: "orchestration-engine" + TargetType string `json:"targetType,omitempty"` // Default: "SERVICE" +} + // LoadConfigFromBytes unmarshals JSON into config (pure function, testable) func LoadConfigFromBytes(data []byte) (*Config, error) { var config Config @@ -83,6 +92,17 @@ func LoadConfigFromBytes(data []byte) (*Config, error) { config.ArgMapping = config.ArgMappings } + // Set default audit config values if not provided + if config.AuditConfig.ActorType == "" { + config.AuditConfig.ActorType = "SERVICE" + } + if config.AuditConfig.ActorID == "" { + config.AuditConfig.ActorID = "orchestration-engine" + } + if config.AuditConfig.TargetType == "" { + config.AuditConfig.TargetType = "SERVICE" + } + return &config, nil } diff --git a/exchange/orchestration-engine/consent/ce_client.go b/exchange/orchestration-engine/consent/ce_client.go index 47034079..1bf2f7b8 100644 --- a/exchange/orchestration-engine/consent/ce_client.go +++ b/exchange/orchestration-engine/consent/ce_client.go @@ -9,6 +9,7 @@ import ( "time" "github.com/ginaxu1/gov-dx-sandbox/exchange/orchestration-engine/logger" + "github.com/gov-dx-sandbox/exchange/shared/monitoring" ) // CEServiceClient represents a client to interact with the Consent Engine service @@ -41,6 +42,12 @@ func (c *CEServiceClient) CreateConsent(ctx context.Context, request *CreateCons return nil, err } req.Header.Set("Content-Type", "application/json") + + // Propagate traceID from context to header for audit correlation + traceID := monitoring.GetTraceIDFromContext(ctx) + if traceID != "" { + req.Header.Set("X-Trace-ID", traceID) + } resp, err := c.httpClient.Do(req) if err != nil { diff --git a/exchange/orchestration-engine/federator/federator.go b/exchange/orchestration-engine/federator/federator.go index 25148ed1..028662cb 100644 --- a/exchange/orchestration-engine/federator/federator.go +++ b/exchange/orchestration-engine/federator/federator.go @@ -21,6 +21,9 @@ import ( "github.com/ginaxu1/gov-dx-sandbox/exchange/orchestration-engine/pkg/graphql" "github.com/ginaxu1/gov-dx-sandbox/exchange/orchestration-engine/policy" "github.com/ginaxu1/gov-dx-sandbox/exchange/orchestration-engine/provider" + "github.com/google/uuid" + "github.com/gov-dx-sandbox/exchange/shared/monitoring" + auditpkg "github.com/gov-dx-sandbox/shared/audit" "github.com/graphql-go/graphql/language/ast" "github.com/graphql-go/graphql/language/parser" "github.com/graphql-go/graphql/language/source" @@ -146,6 +149,17 @@ func Initialize(configs *configs.Config, providerHandler *provider.Handler, sche // FederateQuery takes a raw GraphQL query, splits it into sub-queries for each service, // sends them to the respective providers, and merges the responses. func (f *Federator) FederateQuery(ctx context.Context, request graphql.Request, consumerInfo *auth.ConsumerAssertion) graphql.Response { + // Ensure traceID is in context (should already be set by monitoring.TraceIDMiddleware, but ensure it) + traceID := monitoring.GetTraceIDFromContext(ctx) + if traceID == "" { + traceID = uuid.New().String() + ctx = monitoring.WithTraceID(ctx, traceID) + } + + // Log orchestration request received event + // Update context with traceID if one was generated + ctx = f.logOrchestrationRequestReceived(ctx, consumerInfo.ApplicationId, request.Query) + // Convert the query string into its ast src := source.NewSource(&source.Source{ Body: []byte(request.Query), @@ -287,6 +301,11 @@ func (f *Federator) FederateQuery(ctx context.Context, request graphql.Request, pdpRequest.RequiredFields = requiredFields pdpResponse, err = pdpClient.MakePdpRequest(ctx, pdpRequest) + + // Log policy check audit event + // Update context with traceID if one was generated + ctx = f.logPolicyCheck(ctx, consumerInfo.ApplicationId, pdpRequest, pdpResponse, err) + if err != nil { logger.Log.Error("PDP request failed", "error", err) return createErrorResponseWithCode(fmt.Sprintf("Authorization check failed: %v", err), errors.CodePDPError) @@ -390,6 +409,11 @@ func (f *Federator) FederateQuery(ctx context.Context, request graphql.Request, } ceResp, err := ceClient.CreateConsent(ctx, ceRequest) + + // Log consent check audit event + // Update context with traceID if one was generated + ctx = f.logConsentCheck(ctx, consumerInfo.ApplicationId, ownerEmail, ownerEmail, ceRequest, ceResp, err) + if err != nil { logger.Log.Info("CE request failed", "error", err) return createErrorResponseWithCode("CE request failed", errors.CodeCEError) @@ -536,6 +560,87 @@ func (f *Federator) performFederation(ctx context.Context, r *federationRequest) return FederationResponse } +// logOrchestrationRequestReceived logs an ORCHESTRATION_REQUEST_RECEIVED event +// Returns the updated context with traceID to ensure trace correlation +func (f *Federator) logOrchestrationRequestReceived(ctx context.Context, consumerAppID string, query string) context.Context { + requestMetadata := map[string]interface{}{ + "applicationId": consumerAppID, + "query": query, + } + return middleware.LogAuditEvent(ctx, "ORCHESTRATION_REQUEST_RECEIVED", nil, requestMetadata, nil, auditpkg.StatusSuccess) +} + +// logPolicyCheck logs a POLICY_CHECK event from orchestration-engine's perspective +// This is called after making a request to the Policy Decision Point +// Returns the updated context with traceID to ensure trace correlation +func (f *Federator) logPolicyCheck(ctx context.Context, applicationID string, req *policy.PdpRequest, resp *policy.PdpResponse, err error) context.Context { + targetID := "policy-decision-point" + status := auditpkg.StatusSuccess + responseMetadata := make(map[string]interface{}) + + // Include application ID from request + responseMetadata["applicationId"] = applicationID + + if err != nil { + status = auditpkg.StatusFailure + responseMetadata["error"] = err.Error() + } else if resp != nil { + responseMetadata["authorized"] = resp.AppAuthorized + responseMetadata["consentRequired"] = resp.AppRequiresOwnerConsent + responseMetadata["accessExpired"] = resp.AppAccessExpired + if !resp.AppAuthorized { + status = auditpkg.StatusFailure + responseMetadata["unauthorizedFields"] = resp.UnauthorizedFields + } + if resp.AppAccessExpired { + status = auditpkg.StatusFailure + responseMetadata["expiredFields"] = resp.ExpiredFields + } + if resp.AppRequiresOwnerConsent { + responseMetadata["consentRequiredFields"] = resp.ConsentRequiredFields + } + } + + // Update context with traceID if one was generated + ctx = middleware.LogAuditEvent(ctx, "POLICY_CHECK", &targetID, nil, responseMetadata, status) + return ctx +} + +// logConsentCheck logs a CONSENT_CHECK event from orchestration-engine's perspective +// This is called after making a request to the Consent Engine +// Returns the updated context with traceID to ensure trace correlation +func (f *Federator) logConsentCheck(ctx context.Context, applicationID, ownerEmail, ownerID string, req *consent.CreateConsentRequest, resp *consent.ConsentResponseInternalView, err error) context.Context { + targetID := "consent-engine" + status := auditpkg.StatusSuccess + responseMetadata := make(map[string]interface{}) + + // Include request context in metadata + responseMetadata["applicationId"] = applicationID + if ownerEmail != "" { + responseMetadata["ownerEmail"] = ownerEmail + } + if ownerID != "" { + responseMetadata["ownerId"] = ownerID + } + + if err != nil { + status = auditpkg.StatusFailure + responseMetadata["error"] = err.Error() + } else if resp != nil { + responseMetadata["consentId"] = resp.ConsentID + responseMetadata["status"] = resp.Status + if resp.ConsentPortalURL != nil { + responseMetadata["consentPortalUrl"] = *resp.ConsentPortalURL + } + if resp.Fields != nil { + responseMetadata["fieldsCount"] = len(*resp.Fields) + } + } + + // Update context with traceID if one was generated + return middleware.LogAuditEvent(ctx, "CONSENT_CHECK", &targetID, nil, responseMetadata, status) +} + func (f *Federator) mergeResponses(responses []*ProviderResponse) graphql.Response { merged := graphql.Response{ Data: make(map[string]interface{}), diff --git a/exchange/orchestration-engine/go.mod b/exchange/orchestration-engine/go.mod index ae3dc427..15192353 100644 --- a/exchange/orchestration-engine/go.mod +++ b/exchange/orchestration-engine/go.mod @@ -15,9 +15,40 @@ require ( require ( github.com/go-chi/chi/v5 v5.2.3 github.com/google/uuid v1.6.0 + github.com/gov-dx-sandbox/exchange/shared/monitoring v0.0.0-00010101000000-000000000000 github.com/gov-dx-sandbox/shared/audit v0.0.0 ) +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_golang v1.20.5 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.60.1 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + go.opentelemetry.io/otel v1.32.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 // indirect + go.opentelemetry.io/otel/exporters/prometheus v0.54.0 // indirect + go.opentelemetry.io/otel/metric v1.32.0 // indirect + go.opentelemetry.io/otel/sdk v1.32.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.32.0 // indirect + go.opentelemetry.io/otel/trace v1.32.0 // indirect + go.opentelemetry.io/proto/otlp v1.3.1 // indirect + golang.org/x/net v0.30.0 // indirect + golang.org/x/sys v0.27.0 // indirect + golang.org/x/text v0.20.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 // indirect + google.golang.org/grpc v1.67.1 // indirect + google.golang.org/protobuf v1.35.1 // indirect +) + require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 @@ -26,3 +57,5 @@ require ( ) replace github.com/gov-dx-sandbox/shared/audit => ../../shared/audit + +replace github.com/gov-dx-sandbox/exchange/shared/monitoring => ../shared/monitoring diff --git a/exchange/orchestration-engine/go.sum b/exchange/orchestration-engine/go.sum index b44ad3e7..4ca15b93 100644 --- a/exchange/orchestration-engine/go.sum +++ b/exchange/orchestration-engine/go.sum @@ -1,22 +1,88 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/graphql-go/graphql v0.8.1 h1:p7/Ou/WpmulocJeEx7wjQy611rtXGQaAcXGqanuMMgc= github.com/graphql-go/graphql v0.8.1/go.mod h1:nKiHzRM0qopJEwCITUuIsxk9PlVlwIiiI8pnJEhordQ= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 h1:ad0vkEBuk23VJzZR9nkLVG0YAoN9coASF1GusYX6AlU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0/go.mod h1:igFoXX2ELCW06bol23DWPB5BEWfZISOzSP5K2sbLea0= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.60.1 h1:FUas6GcOw66yB/73KC+BOZoFJmbo/1pojoILArPAaSc= +github.com/prometheus/common v0.60.1/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= +go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8= +go.opentelemetry.io/otel/exporters/prometheus v0.54.0 h1:rFwzp68QMgtzu9PgP3jm9XaMICI6TsofWWPcBDKwlsU= +go.opentelemetry.io/otel/exporters/prometheus v0.54.0/go.mod h1:QyjcV9qDP6VeK5qPyKETvNjmaaEc7+gqjh4SS0ZYzDU= +go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M= +go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8= +go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4= +go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU= +go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= +go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= +go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM= +go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 h1:M0KvPgPmDZHPlbRbaNU1APr28TvwvvdUPlSv7PUvy8g= +google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:dguCy7UOdZhTvLzDyt15+rOrawrpM4q7DD9dQ1P11P4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 h1:XVhgTWWV3kGQlwJHR3upFWZeTsei6Oks1apkZSeonIE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= +google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= +google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/exchange/orchestration-engine/main.go b/exchange/orchestration-engine/main.go index efe40df1..e97986e4 100644 --- a/exchange/orchestration-engine/main.go +++ b/exchange/orchestration-engine/main.go @@ -7,6 +7,7 @@ import ( "github.com/ginaxu1/gov-dx-sandbox/exchange/orchestration-engine/configs" "github.com/ginaxu1/gov-dx-sandbox/exchange/orchestration-engine/federator" "github.com/ginaxu1/gov-dx-sandbox/exchange/orchestration-engine/logger" + "github.com/ginaxu1/gov-dx-sandbox/exchange/orchestration-engine/middleware" "github.com/ginaxu1/gov-dx-sandbox/exchange/orchestration-engine/provider" "github.com/ginaxu1/gov-dx-sandbox/exchange/orchestration-engine/server" auditclient "github.com/gov-dx-sandbox/shared/audit" @@ -22,10 +23,21 @@ func main() { } // Initialize audit middleware - auditServiceURL := os.Getenv("CHOREO_AUDIT_CONNECTION_SERVICEURL") + // Environment variable takes precedence over config.json for flexibility + auditServiceURL := os.Getenv("AUDIT_SERVICE_URL") + if auditServiceURL == "" { + auditServiceURL = config.AuditConfig.ServiceURL + } auditClient := auditclient.NewClient(auditServiceURL) auditclient.InitializeGlobalAudit(auditClient) + // Initialize audit configuration (actorType, actorID, targetType) + middleware.InitializeAuditConfig( + config.AuditConfig.ActorType, + config.AuditConfig.ActorID, + config.AuditConfig.TargetType, + ) + providerHandler := provider.NewProviderHandler(config.GetProviders()) federationObject := federator.Initialize(config, providerHandler, nil) diff --git a/exchange/orchestration-engine/middleware/audit_middleware.go b/exchange/orchestration-engine/middleware/audit_middleware.go index 9a498d2e..e702b065 100644 --- a/exchange/orchestration-engine/middleware/audit_middleware.go +++ b/exchange/orchestration-engine/middleware/audit_middleware.go @@ -2,23 +2,73 @@ package middleware import ( "context" - "encoding/json" - "time" + "sync" "github.com/ginaxu1/gov-dx-sandbox/exchange/orchestration-engine/logger" "github.com/ginaxu1/gov-dx-sandbox/exchange/orchestration-engine/pkg/graphql" "github.com/google/uuid" + "github.com/gov-dx-sandbox/exchange/shared/monitoring" auditpkg "github.com/gov-dx-sandbox/shared/audit" ) +// auditConfig holds the audit configuration values +var ( + auditConfig struct { + actorType string + actorID string + targetType string + } + auditConfigOnce sync.Once +) + +// InitializeAuditConfig initializes the audit configuration from config values +// This should be called once during application startup from main.go +// Values are read from config.json via config.AuditConfig +func InitializeAuditConfig(actorType, actorID, targetType string) { + auditConfigOnce.Do(func() { + // Set values with defaults if not provided (defensive programming) + if actorType == "" { + actorType = "SERVICE" + } + if actorID == "" { + actorID = "orchestration-engine" + } + if targetType == "" { + targetType = "SERVICE" + } + auditConfig.actorType = actorType + auditConfig.actorID = actorID + auditConfig.targetType = targetType + }) +} + +// getAuditActorType returns the configured actor type +// InitializeAuditConfig guarantees this is always set (with default if needed) +func getAuditActorType() string { + return auditConfig.actorType +} + +// getAuditActorID returns the configured actor ID +// InitializeAuditConfig guarantees this is always set (with default if needed) +func getAuditActorID() string { + return auditConfig.actorID +} + +// getAuditTargetType returns the configured target type +// InitializeAuditConfig guarantees this is always set (with default if needed) +func getAuditTargetType() string { + return auditConfig.targetType +} + +// maxAuditErrorsToLog is the maximum number of errors to include in audit log metadata +// This limit prevents audit logs from becoming too large when there are many errors +const maxAuditErrorsToLog = 3 + // Context key for audit metadata type contextKey string const auditMetadataKey contextKey = "auditMetadata" -// Context key for trace ID -type traceIDKey struct{} - // Metadata holds metadata needed for audit logging in orchestration-engine type Metadata struct { ConsumerAppID string @@ -47,13 +97,49 @@ func MetadataFromContext(ctx context.Context) *Metadata { return metadata } -// GetTraceIDFromContext retrieves the trace ID from the context -// Returns empty string if trace ID is not found in context -func GetTraceIDFromContext(ctx context.Context) string { - if traceID, ok := ctx.Value(traceIDKey{}).(string); ok { - return traceID +// LogAuditEvent is a shared helper function that handles common audit logging logic: +// - Gets/ensures traceID in context +// - Marshals metadata (request or response) +// - Creates AuditLogRequest struct +// - Logs the event asynchronously +// Returns the updated context with traceID (if one was generated) to ensure trace correlation +func LogAuditEvent(ctx context.Context, eventType string, targetID *string, requestMetadata map[string]interface{}, responseMetadata map[string]interface{}, status string) context.Context { + // Get or generate traceID + traceID := monitoring.GetTraceIDFromContext(ctx) + if traceID == "" { + traceID = uuid.New().String() + ctx = monitoring.WithTraceID(ctx, traceID) } - return "" + + // Use configured audit fields from config.json (with fallback defaults for safety) + // These are initialized in main.go via InitializeAuditConfig() from config.AuditConfig + actorType := getAuditActorType() + actorID := getAuditActorID() + targetType := getAuditTargetType() + + // Marshal metadata using shared utility + requestMetadataJSON := auditpkg.MarshalMetadata(requestMetadata) + responseMetadataJSON := auditpkg.MarshalMetadata(responseMetadata) + + // Create audit request + auditRequest := &auditpkg.AuditLogRequest{ + TraceID: &traceID, + Timestamp: auditpkg.CurrentTimestamp(), + EventType: &eventType, + Status: status, + ActorType: actorType, + ActorID: actorID, + TargetType: targetType, + TargetID: targetID, + RequestMetadata: requestMetadataJSON, + ResponseMetadata: responseMetadataJSON, + } + + // Log the audit event asynchronously using the global audit package + auditpkg.LogAuditEvent(ctx, auditRequest) + + // Return the updated context to ensure traceID correlation across the request flow + return ctx } // FederationServiceRequest represents a service request for audit logging @@ -82,18 +168,6 @@ func LogProviderFetch(ctx context.Context, providerSchemaID string, req *Federat } } - // Create audit request using the new v1 API structure - traceID := GetTraceIDFromContext(ctx) - if traceID == "" { - // If no trace ID in context, generate one (fallback) - traceID = uuid.New().String() - } - eventType := "PROVIDER_FETCH" - actorType := "SERVICE" - actorID := "orchestration-engine" - targetType := "SERVICE" - targetID := req.ServiceKey - // Combine requested data and additional info into response metadata // (since we're logging after receiving the response) responseMetadata := map[string]interface{}{ @@ -113,7 +187,7 @@ func LogProviderFetch(ctx context.Context, providerSchemaID string, req *Federat // Include first few errors (limit to avoid large payloads) errorDetails := make([]interface{}, 0) for i, gqlErr := range response.Errors { - if i >= 3 { // Limit to first 3 errors + if i >= maxAuditErrorsToLog { break } errorDetails = append(errorDetails, gqlErr) @@ -129,29 +203,13 @@ func LogProviderFetch(ctx context.Context, providerSchemaID string, req *Federat responseMetadata["dataKeys"] = dataKeys } } - responseMetadataJSON, jsonErr := json.Marshal(responseMetadata) - if jsonErr != nil { - logger.Log.Error("Failed to marshal response metadata for audit", "error", jsonErr) - responseMetadataJSON = []byte("{}") - } auditStatus := auditpkg.StatusSuccess if err != nil || (response != nil && len(response.Errors) > 0) { auditStatus = auditpkg.StatusFailure } - auditRequest := &auditpkg.AuditLogRequest{ - TraceID: &traceID, - Timestamp: time.Now().UTC().Format(time.RFC3339), - EventType: &eventType, - Status: auditStatus, - ActorType: actorType, - ActorID: actorID, - TargetType: targetType, - TargetID: &targetID, - ResponseMetadata: json.RawMessage(responseMetadataJSON), - } - - // Log the audit event asynchronously using the global audit package - auditpkg.LogAuditEvent(ctx, auditRequest) + // Use shared helper function to log audit event + // Update context with traceID if one was generated + ctx = LogAuditEvent(ctx, "PROVIDER_FETCH", &req.ServiceKey, nil, responseMetadata, auditStatus) } diff --git a/exchange/orchestration-engine/policy/pdpclient.go b/exchange/orchestration-engine/policy/pdpclient.go index 072e052f..e1dd07ec 100644 --- a/exchange/orchestration-engine/policy/pdpclient.go +++ b/exchange/orchestration-engine/policy/pdpclient.go @@ -9,6 +9,7 @@ import ( "time" "github.com/ginaxu1/gov-dx-sandbox/exchange/orchestration-engine/logger" + "github.com/gov-dx-sandbox/exchange/shared/monitoring" ) // PdpClient represents a client to interact with the Policy Decision Point service @@ -48,6 +49,12 @@ func (p *PdpClient) MakePdpRequest(ctx context.Context, request *PdpRequest) (*P } req.Header.Set("Content-Type", "application/json") + // Propagate traceID from context to header for audit correlation + traceID := monitoring.GetTraceIDFromContext(ctx) + if traceID != "" { + req.Header.Set("X-Trace-ID", traceID) + } + response, err := p.httpClient.Do(req) if err != nil { // handle error diff --git a/exchange/shared/monitoring/go.mod b/exchange/shared/monitoring/go.mod index d7f11eff..daee3e65 100644 --- a/exchange/shared/monitoring/go.mod +++ b/exchange/shared/monitoring/go.mod @@ -3,6 +3,7 @@ module github.com/gov-dx-sandbox/exchange/shared/monitoring go 1.24.6 require ( + github.com/google/uuid v1.6.0 github.com/prometheus/client_golang v1.20.5 go.opentelemetry.io/otel v1.32.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 @@ -18,7 +19,6 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect github.com/klauspost/compress v1.17.9 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect diff --git a/exchange/shared/monitoring/trace.go b/exchange/shared/monitoring/trace.go new file mode 100644 index 00000000..685323b8 --- /dev/null +++ b/exchange/shared/monitoring/trace.go @@ -0,0 +1,67 @@ +package monitoring + +import ( + "context" + "net/http" + + "github.com/google/uuid" +) + +// TraceIDHeader is the HTTP header name for trace ID +const TraceIDHeader = "X-Trace-ID" + +// traceIDKey is the context key for trace ID +// This is used for distributed tracing and observability correlation +type traceIDKey struct{} + +// GetTraceIDFromContext retrieves the trace ID from the context +// Returns empty string if trace ID is not found in context +// This is used for distributed tracing and observability correlation across service boundaries +func GetTraceIDFromContext(ctx context.Context) string { + if traceID, ok := ctx.Value(traceIDKey{}).(string); ok { + return traceID + } + return "" +} + +// WithTraceID adds the given trace ID to the context +// This is used to propagate trace IDs for distributed tracing and observability correlation +func WithTraceID(ctx context.Context, traceID string) context.Context { + return context.WithValue(ctx, traceIDKey{}, traceID) +} + +// ExtractTraceIDFromRequest extracts trace ID from HTTP header and adds it to context +// If no trace ID is found in header, generates a new one +// This ensures trace ID propagation across HTTP service boundaries +func ExtractTraceIDFromRequest(r *http.Request) context.Context { + traceID := r.Header.Get(TraceIDHeader) + if traceID == "" { + traceID = uuid.New().String() + } + return WithTraceID(r.Context(), traceID) +} + +// TraceIDMiddleware extracts or generates a trace ID and adds it to the request context +// It checks for X-Trace-ID header first, and if not present, generates a new UUID +// The trace ID is also set in the response header for client visibility +// This middleware should be applied early in the middleware chain to ensure trace ID +// is available throughout the request lifecycle +func TraceIDMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check for existing trace ID in header + traceID := r.Header.Get(TraceIDHeader) + if traceID == "" { + // Generate new trace ID if not present + traceID = uuid.New().String() + } + + // Add trace ID to context using the shared traceIDKey + ctx := WithTraceID(r.Context(), traceID) + + // Set trace ID in response header for client visibility + w.Header().Set(TraceIDHeader, traceID) + + // Continue with the updated context + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/portal-backend/v1/middleware/audit_middleware.go b/portal-backend/v1/middleware/audit_middleware.go index fc6edbbb..37ba2873 100644 --- a/portal-backend/v1/middleware/audit_middleware.go +++ b/portal-backend/v1/middleware/audit_middleware.go @@ -2,10 +2,8 @@ package middleware import ( "context" - "encoding/json" "log/slog" "net/http" - "time" "github.com/gov-dx-sandbox/portal-backend/v1/models" auditpkg "github.com/gov-dx-sandbox/shared/audit" @@ -46,17 +44,12 @@ func LogAudit(client auditpkg.AuditClient, r *http.Request, resource string, res targetType := "RESOURCE" // Create audit event using shared/audit DTO - timestamp := time.Now().UTC().Format(time.RFC3339) - additionalMetadata := func() json.RawMessage { - meta := map[string]interface{}{ - "resource": resource, - "resourceId": resourceID, - } - if bytes, err := json.Marshal(meta); err == nil { - return bytes - } - return nil - }() + // Use shared utilities for timestamp and metadata marshaling + timestamp := auditpkg.CurrentTimestamp() + additionalMetadata := auditpkg.MarshalMetadata(map[string]interface{}{ + "resource": resource, + "resourceId": resourceID, + }) auditRequest := &auditpkg.AuditLogRequest{ TraceID: nil, // No trace ID for standalone management events @@ -76,34 +69,54 @@ func LogAudit(client auditpkg.AuditClient, r *http.Request, resource string, res } // extractActorInfoFromRequest extracts actor information from the request +// Returns actorType, actorID, and actorRole for audit logging +// Security: Uses SYSTEM as default for unauthenticated/unknown roles to prevent privilege escalation func extractActorInfoFromRequest(r *http.Request) (actorType string, actorID *string, actorRole *string) { // Try to get authenticated user first user, err := GetUserFromRequest(r) - if err == nil && user != nil { - userID := user.IdpUserID - actorID = &userID - - // Map user's primary role to actor type - primaryRole := user.GetPrimaryRole() - var actorTypeConst models.ActorType - - switch primaryRole { - case models.RoleAdmin: - actorTypeConst = models.ActorTypeAdmin - case models.RoleMember: - actorTypeConst = models.ActorTypeMember - case models.RoleSystem: - actorTypeConst = models.ActorTypeSystem - default: - // Safe fallback for unknown roles - actorTypeConst = models.ActorTypeMember - } - - // Convert to string for both actorType and actorRole - roleStr := string(actorTypeConst) - actorType = roleStr - actorRole = &roleStr + if err != nil || user == nil { + // Unauthenticated request: use SYSTEM actor type with request identifier + // This prevents misclassification and ensures unauthenticated actions are clearly marked + systemActorID := "unauthenticated-request" + systemActorType := string(models.ActorTypeSystem) + slog.Warn("Audit log for unauthenticated request - using SYSTEM actor type", + "path", r.URL.Path, + "method", r.Method, + "remote_addr", r.RemoteAddr) + return systemActorType, &systemActorID, &systemActorType } + + // Authenticated user found - extract actor information + userID := user.IdpUserID + actorID = &userID + + // Map user's primary role to actor type + primaryRole := user.GetPrimaryRole() + var actorTypeConst models.ActorType + + switch primaryRole { + case models.RoleAdmin: + actorTypeConst = models.ActorTypeAdmin + case models.RoleMember: + actorTypeConst = models.ActorTypeMember + case models.RoleSystem: + actorTypeConst = models.ActorTypeSystem + default: + // Security: Use SYSTEM for unknown roles instead of MEMBER to prevent privilege escalation + // Unknown roles should be investigated and properly mapped + actorTypeConst = models.ActorTypeSystem + slog.Warn("Unknown role encountered in audit log - using SYSTEM actor type as safe default", + "user_id", userID, + "primary_role", primaryRole, + "all_roles", user.Roles, + "path", r.URL.Path, + "method", r.Method) + } + + // Convert to string for both actorType and actorRole + roleStr := string(actorTypeConst) + actorType = roleStr + actorRole = &roleStr return } diff --git a/shared/audit/init.go b/shared/audit/init.go index e6060988..027b8f09 100644 --- a/shared/audit/init.go +++ b/shared/audit/init.go @@ -4,10 +4,10 @@ package audit // This should be called once during application startup. // Subsequent calls will be ignored (safe to call multiple times). // -// The client parameter should be an implementation of AuditClient interface. +// The client parameter should be an implementation of Auditor interface. // When client is nil or IsEnabled() returns false, audit logging will be skipped // but services will continue to function normally. -func InitializeGlobalAudit(client AuditClient) { +func InitializeGlobalAudit(client Auditor) { globalAuditOnce.Do(func() { globalAuditMiddleware = &AuditMiddleware{client: client} }) diff --git a/shared/audit/interface.go b/shared/audit/interface.go new file mode 100644 index 00000000..5f080b0c --- /dev/null +++ b/shared/audit/interface.go @@ -0,0 +1,31 @@ +package audit + +import "context" + +// Auditor is the primary interface for audit logging operations. +// This interface provides a clean abstraction for audit capabilities, +// making it easy to swap implementations when audit-service moves to its own repository. +// +// Implementations should handle: +// - Asynchronous logging (fire-and-forget) +// - Graceful degradation when audit service is unavailable +// - Thread-safe operations +type Auditor interface { + // LogEvent logs an audit event asynchronously. + // The implementation should handle the event in a background goroutine + // to avoid blocking the calling code. + // + // If the audit service is disabled or unavailable, this method should + // return immediately without error (graceful degradation). + LogEvent(ctx context.Context, event *AuditLogRequest) + + // IsEnabled returns whether audit logging is currently enabled. + // This can be used by callers to skip expensive audit event preparation + // when audit logging is disabled. + IsEnabled() bool +} + +// AuditClient is an alias for Auditor to maintain backward compatibility. +// Deprecated: Use Auditor instead. This will be removed in a future version. +type AuditClient = Auditor + diff --git a/shared/audit/middleware.go b/shared/audit/middleware.go index 5b429d2c..25b3a075 100644 --- a/shared/audit/middleware.go +++ b/shared/audit/middleware.go @@ -6,15 +6,9 @@ import ( "sync" ) -// AuditClient is an interface for sending audit events -type AuditClient interface { - LogEvent(ctx context.Context, event *AuditLogRequest) - IsEnabled() bool -} - // AuditMiddleware handles audit logging operations type AuditMiddleware struct { - client AuditClient + client Auditor } // Global audit middleware instance for easy access from handlers @@ -27,10 +21,10 @@ var ( // This function should typically only be called once during application startup. // Subsequent calls will return a new instance but won't update the global instance. // -// The client parameter should be an implementation of AuditClient interface. +// The client parameter should be an implementation of Auditor interface. // When client is nil or IsEnabled() returns false, the middleware will skip all audit logging operations // but services will continue to function normally. -func NewAuditMiddleware(client AuditClient) *AuditMiddleware { +func NewAuditMiddleware(client Auditor) *AuditMiddleware { middleware := &AuditMiddleware{client: client} globalAuditOnce.Do(func() { @@ -42,7 +36,7 @@ func NewAuditMiddleware(client AuditClient) *AuditMiddleware { // Client returns the audit client instance // This allows service-specific wrappers to access the client -func (m *AuditMiddleware) Client() AuditClient { +func (m *AuditMiddleware) Client() Auditor { return m.client } diff --git a/shared/audit/utils.go b/shared/audit/utils.go new file mode 100644 index 00000000..808fb1f0 --- /dev/null +++ b/shared/audit/utils.go @@ -0,0 +1,28 @@ +package audit + +import ( + "encoding/json" + "log/slog" + "time" +) + +// MarshalMetadata safely marshals metadata to json.RawMessage. +// Returns empty JSON object "{}" on error to ensure valid JSON. +// Returns nil if metadata is nil. +func MarshalMetadata(metadata map[string]interface{}) json.RawMessage { + if metadata == nil { + return nil + } + bytes, err := json.Marshal(metadata) + if err != nil { + slog.Error("Failed to marshal metadata for audit", "error", err) + return json.RawMessage("{}") + } + return json.RawMessage(bytes) +} + +// CurrentTimestamp returns current UTC time in RFC3339 format. +// This provides a consistent timestamp format across all audit logs. +func CurrentTimestamp() string { + return time.Now().UTC().Format(time.RFC3339) +} diff --git a/shared/audit/utils_test.go b/shared/audit/utils_test.go new file mode 100644 index 00000000..65e02aa8 --- /dev/null +++ b/shared/audit/utils_test.go @@ -0,0 +1,172 @@ +package audit + +import ( + "encoding/json" + "testing" + "time" +) + +func TestMarshalMetadata(t *testing.T) { + tests := []struct { + name string + metadata map[string]interface{} + wantNil bool + wantJSON string + }{ + { + name: "nil metadata", + metadata: nil, + wantNil: true, + }, + { + name: "empty metadata", + metadata: map[string]interface{}{}, + wantNil: false, + wantJSON: "{}", + }, + { + name: "simple metadata", + metadata: map[string]interface{}{ + "key": "value", + }, + wantNil: false, + wantJSON: `{"key":"value"}`, + }, + { + name: "complex metadata", + metadata: map[string]interface{}{ + "applicationId": "test-app-123", + "query": "query { citizen(nic: \"123456789V\") { name } }", + "count": 42, + }, + wantNil: false, + wantJSON: `{"applicationId":"test-app-123","count":42,"query":"query { citizen(nic: \"123456789V\") { name } }"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MarshalMetadata(tt.metadata) + + if tt.wantNil { + if got != nil { + t.Errorf("MarshalMetadata() = %v, want nil", got) + } + return + } + + if got == nil { + t.Errorf("MarshalMetadata() = nil, want non-nil") + return + } + + // Verify it's valid JSON + var unmarshaled map[string]interface{} + if err := json.Unmarshal(got, &unmarshaled); err != nil { + t.Errorf("MarshalMetadata() produced invalid JSON: %v", err) + } + + // Verify content matches (accounting for map ordering) + expectedBytes, _ := json.Marshal(tt.metadata) + gotStr := string(got) + expectedStr := string(expectedBytes) + + // Parse both to compare semantically + var gotMap, expectedMap map[string]interface{} + json.Unmarshal(got, &gotMap) + json.Unmarshal(expectedBytes, &expectedMap) + + if len(gotMap) != len(expectedMap) { + t.Errorf("MarshalMetadata() length mismatch: got %d, want %d", len(gotMap), len(expectedMap)) + } + + // Check that the JSON is valid and contains expected content + if tt.wantJSON != "" { + // For simple cases, we can check exact match + if len(tt.metadata) == 1 { + if gotStr != expectedStr { + t.Errorf("MarshalMetadata() = %s, want %s", gotStr, expectedStr) + } + } + } + }) + } +} + +func TestMarshalMetadata_InvalidData(t *testing.T) { + // Test with data that cannot be marshaled (channel, function, etc.) + // In practice, this shouldn't happen, but we want to ensure graceful handling + metadata := map[string]interface{}{ + "valid": "value", + // Note: We can't easily test unmarshalable data in Go without using reflect, + // but the function should handle it gracefully if it occurs + } + + got := MarshalMetadata(metadata) + if got == nil { + t.Error("MarshalMetadata() = nil for valid metadata") + } + + // Verify it's valid JSON + var unmarshaled map[string]interface{} + if err := json.Unmarshal(got, &unmarshaled); err != nil { + t.Errorf("MarshalMetadata() produced invalid JSON: %v", err) + } +} + +func TestCurrentTimestamp(t *testing.T) { + // Test that CurrentTimestamp returns valid RFC3339 format + timestamp := CurrentTimestamp() + + // Parse the timestamp to verify it's valid RFC3339 + _, err := time.Parse(time.RFC3339, timestamp) + if err != nil { + t.Errorf("CurrentTimestamp() = %q, is not valid RFC3339: %v", timestamp, err) + } + + // Test that it's UTC + parsed, _ := time.Parse(time.RFC3339, timestamp) + if parsed.Location().String() != "UTC" { + t.Errorf("CurrentTimestamp() location = %v, want UTC", parsed.Location()) + } + + // Test that it's recent (within last 5 seconds) + now := time.Now().UTC() + diff := now.Sub(parsed) + if diff < 0 { + diff = -diff + } + if diff > 5*time.Second { + t.Errorf("CurrentTimestamp() = %v, is too old (diff: %v)", timestamp, diff) + } +} + +func TestCurrentTimestamp_Format(t *testing.T) { + // Test multiple calls to ensure consistent format + timestamps := make([]string, 10) + for i := 0; i < 10; i++ { + timestamps[i] = CurrentTimestamp() + time.Sleep(1 * time.Millisecond) // Small delay to ensure different timestamps + } + + // Verify all are valid RFC3339 + for i, ts := range timestamps { + _, err := time.Parse(time.RFC3339, ts) + if err != nil { + t.Errorf("CurrentTimestamp() call %d = %q, is not valid RFC3339: %v", i, ts, err) + } + } + + // Verify they're in chronological order (or at least valid) + for i := 1; i < len(timestamps); i++ { + prev, _ := time.Parse(time.RFC3339, timestamps[i-1]) + curr, _ := time.Parse(time.RFC3339, timestamps[i]) + if curr.Before(prev) { + // This is acceptable if timestamps are very close, but log it + diff := prev.Sub(curr) + if diff > 1*time.Second { + t.Logf("Warning: timestamp %d is before timestamp %d (diff: %v)", i, i-1, diff) + } + } + } +} diff --git a/tests/integration/audit_flow_integration_test.go b/tests/integration/audit_flow_integration_test.go new file mode 100644 index 00000000..84c848b5 --- /dev/null +++ b/tests/integration/audit_flow_integration_test.go @@ -0,0 +1,331 @@ +package integration_test + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +// TestCompleteAuditFlow tests the complete audit flow against PostgreSQL database +// This test simulates a full orchestration request that generates multiple audit events +// all linked by the same traceID +func TestCompleteAuditFlow(t *testing.T) { + // Skip if audit service is not available + if !isAuditServiceAvailable() { + t.Skip("Audit service is not available. Skipping complete audit flow test.") + } + + // Setup PostgreSQL test database connection + db := setupAuditTestDB(t) + if db == nil { + t.Skip("Could not connect to PostgreSQL test database") + } + + // Generate a unique traceID for this test (must be valid UUID format) + // Format: 8-4-4-4-12 hex digits + testTraceID := fmt.Sprintf("550e8400-e29b-41d4-a716-%012x", time.Now().UnixNano()%0xffffffffffff) + t.Logf("Test traceID: %s", testTraceID) + + // Simulate the complete audit flow: + // 1. ORCHESTRATION_REQUEST_RECEIVED + // 2. POLICY_CHECK + // 3. CONSENT_CHECK + // 4. PROVIDER_FETCH (multiple providers) + + // Step 1: Log ORCHESTRATION_REQUEST_RECEIVED + t.Run("ORCHESTRATION_REQUEST_RECEIVED", func(t *testing.T) { + req := createAuditLogRequest(t, AuditLogRequest{ + TraceID: testTraceID, + EventType: "ORCHESTRATION_REQUEST_RECEIVED", + Status: "SUCCESS", + ActorType: "SERVICE", + ActorID: "orchestration-engine", + TargetType: "SERVICE", + RequestMetadata: map[string]interface{}{ + "applicationId": "test-app-123", + "query": "query { citizen(nic: \"123456789V\") { name } }", + }, + }) + resp, err := testHTTPClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusCreated, resp.StatusCode, "Should create audit log successfully") + }) + + // Small delay to ensure different timestamps + time.Sleep(100 * time.Millisecond) + + // Step 2: Log POLICY_CHECK + t.Run("POLICY_CHECK", func(t *testing.T) { + req := createAuditLogRequest(t, AuditLogRequest{ + TraceID: testTraceID, + EventType: "POLICY_CHECK", + Status: "SUCCESS", + ActorType: "SERVICE", + ActorID: "orchestration-engine", + TargetType: "SERVICE", + TargetID: stringPtr("policy-decision-point"), + ResponseMetadata: map[string]interface{}{ + "applicationId": "test-app-123", + "authorized": true, + "consentRequired": true, + "accessExpired": false, + "consentRequiredFields": []string{"citizen.name"}, + }, + }) + resp, err := testHTTPClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusCreated, resp.StatusCode, "Should create audit log successfully") + }) + + time.Sleep(100 * time.Millisecond) + + // Step 3: Log CONSENT_CHECK + t.Run("CONSENT_CHECK", func(t *testing.T) { + req := createAuditLogRequest(t, AuditLogRequest{ + TraceID: testTraceID, + EventType: "CONSENT_CHECK", + Status: "SUCCESS", + ActorType: "SERVICE", + ActorID: "orchestration-engine", + TargetType: "SERVICE", + TargetID: stringPtr("consent-engine"), + ResponseMetadata: map[string]interface{}{ + "applicationId": "test-app-123", + "ownerEmail": "test@example.com", + "ownerId": "test-owner-123", + "consentId": "consent-123", + "status": "APPROVED", + "fieldsCount": 1, + }, + }) + resp, err := testHTTPClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusCreated, resp.StatusCode, "Should create audit log successfully") + }) + + time.Sleep(100 * time.Millisecond) + + // Step 4: Log PROVIDER_FETCH (multiple providers) + providers := []string{"provider-1", "provider-2"} + for _, provider := range providers { + t.Run(fmt.Sprintf("PROVIDER_FETCH_%s", provider), func(t *testing.T) { + req := createAuditLogRequest(t, AuditLogRequest{ + TraceID: testTraceID, + EventType: "PROVIDER_FETCH", + Status: "SUCCESS", + ActorType: "SERVICE", + ActorID: "orchestration-engine", + TargetType: "SERVICE", + TargetID: stringPtr(provider), + ResponseMetadata: map[string]interface{}{ + "applicationId": "test-app-123", + "schemaId": "test-schema-123", + "serviceKey": provider, + "requestedFields": []string{"citizen.name"}, + "query": "query { citizen(nic: \"123456789V\") { name } }", + "hasErrors": false, + "dataKeys": []string{"citizen"}, + }, + }) + resp, err := testHTTPClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusCreated, resp.StatusCode, "Should create audit log successfully") + }) + time.Sleep(100 * time.Millisecond) + } + + // Wait a moment for async operations to complete + time.Sleep(500 * time.Millisecond) + + // Verify all events are in the database with the same traceID + t.Run("VerifyTraceIDCorrelation", func(t *testing.T) { + // Query database directly to verify all events + var logs []AuditLogDB + result := db.Where("trace_id = ?", testTraceID).Order("created_at ASC").Find(&logs) + require.NoError(t, result.Error, "Should query audit logs from database") + require.Greater(t, result.RowsAffected, int64(0), "Should find at least one audit log") + + // Expected event types in order + expectedEvents := []string{ + "ORCHESTRATION_REQUEST_RECEIVED", + "POLICY_CHECK", + "CONSENT_CHECK", + "PROVIDER_FETCH", + "PROVIDER_FETCH", + } + + assert.Equal(t, len(expectedEvents), len(logs), "Should have exactly %d events", len(expectedEvents)) + + // Verify all events have the same traceID + for i, log := range logs { + assert.NotNil(t, log.TraceID, "Log %d should have traceID", i) + assert.Equal(t, testTraceID, *log.TraceID, "All logs should share the same traceID") + assert.Equal(t, expectedEvents[i], *log.EventType, "Event %d should be %s", i, expectedEvents[i]) + assert.Equal(t, "SUCCESS", log.Status, "Event %d should have SUCCESS status", i) + assert.Equal(t, "SERVICE", log.ActorType, "Event %d should have SERVICE actor type", i) + assert.Equal(t, "orchestration-engine", log.ActorID, "Event %d should have orchestration-engine actor", i) + } + + t.Logf("Verified %d audit events all linked by traceID: %s", len(logs), testTraceID) + }) + + // Verify via API endpoint + t.Run("VerifyViaAPI", func(t *testing.T) { + auditLogs, err := getAuditLogsByTraceIDWithRetry(t, testTraceID, 5*time.Second, 200*time.Millisecond) + require.NoError(t, err) + require.Greater(t, len(auditLogs), 0, "Should find audit logs via API") + + // Verify all events share the same traceID + for _, log := range auditLogs { + require.NotNil(t, log.TraceID, "Audit log should have traceID") + assert.Equal(t, testTraceID, *log.TraceID, "All audit logs should share the same traceID") + } + + // Count event types + eventTypeCount := make(map[string]int) + for _, log := range auditLogs { + if log.EventType != nil { + eventTypeCount[*log.EventType]++ + } + } + + assert.GreaterOrEqual(t, eventTypeCount["ORCHESTRATION_REQUEST_RECEIVED"], 1, "Should have ORCHESTRATION_REQUEST_RECEIVED") + assert.GreaterOrEqual(t, eventTypeCount["POLICY_CHECK"], 1, "Should have POLICY_CHECK") + assert.GreaterOrEqual(t, eventTypeCount["CONSENT_CHECK"], 1, "Should have CONSENT_CHECK") + assert.GreaterOrEqual(t, eventTypeCount["PROVIDER_FETCH"], 2, "Should have at least 2 PROVIDER_FETCH events") + + t.Logf("Verified %d audit events via API, all linked by traceID: %s", len(auditLogs), testTraceID) + }) + + // Cleanup: Remove test data + t.Cleanup(func() { + if db != nil { + db.Where("trace_id = ?", testTraceID).Delete(&AuditLogDB{}) + t.Logf("Cleaned up test audit logs for traceID: %s", testTraceID) + } + }) +} + +// AuditLogRequest represents the request payload for creating an audit log +type AuditLogRequest struct { + TraceID string + EventType string + Status string + ActorType string + ActorID string + TargetType string + TargetID *string + RequestMetadata map[string]interface{} + ResponseMetadata map[string]interface{} +} + +// AuditLogDB represents the audit log structure in the database +type AuditLogDB struct { + ID string `gorm:"type:uuid;primary_key"` + TraceID *string `gorm:"type:uuid"` + Timestamp time.Time + EventType *string + EventAction *string + Status string + ActorType string + ActorID string + TargetType string + TargetID *string + RequestMetadata *string `gorm:"type:jsonb"` + ResponseMetadata *string `gorm:"type:jsonb"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (AuditLogDB) TableName() string { + return "audit_logs" +} + +// setupAuditTestDB creates a PostgreSQL test database connection for audit service +func setupAuditTestDB(t *testing.T) *gorm.DB { + // Use environment variables that match docker-compose.test.yml + host := getEnvOrDefault("TEST_AUDIT_DB_HOST", "localhost") + port := getEnvOrDefault("TEST_AUDIT_DB_PORT", "5435") // Matches docker-compose.test.yml port mapping + user := getEnvOrDefault("TEST_AUDIT_DB_USERNAME", "postgres") + password := getEnvOrDefault("TEST_AUDIT_DB_PASSWORD", "password") + database := getEnvOrDefault("TEST_AUDIT_DB_DATABASE", "audit_db") + sslmode := getEnvOrDefault("TEST_AUDIT_DB_SSLMODE", "disable") + + dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", + host, port, user, password, database, sslmode) + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + DisableForeignKeyConstraintWhenMigrating: true, + }) + if err != nil { + t.Skipf("Skipping audit flow test: could not connect to test database: %v", err) + return nil + } + + // Test connection + sqlDB, err := db.DB() + if err != nil { + t.Skipf("Skipping audit flow test: failed to get sql.DB: %v", err) + return nil + } + + if err := sqlDB.Ping(); err != nil { + t.Skipf("Skipping audit flow test: failed to ping database: %v", err) + return nil + } + + t.Logf("Connected to PostgreSQL audit test database: %s@%s:%s/%s", user, host, port, database) + + return db +} + +// createAuditLogRequest creates an HTTP request to create an audit log +func createAuditLogRequest(t *testing.T, req AuditLogRequest) *http.Request { + payload := map[string]interface{}{ + "traceId": req.TraceID, + "timestamp": time.Now().UTC().Format(time.RFC3339), + "status": req.Status, + "actorType": req.ActorType, + "actorId": req.ActorID, + "targetType": req.TargetType, + } + + if req.EventType != "" { + payload["eventType"] = req.EventType + } + if req.TargetID != nil { + payload["targetId"] = *req.TargetID + } + if req.RequestMetadata != nil { + payload["requestMetadata"] = req.RequestMetadata + } + if req.ResponseMetadata != nil { + payload["responseMetadata"] = req.ResponseMetadata + } + + jsonData, err := json.Marshal(payload) + require.NoError(t, err, "Should marshal audit log request") + + httpReq, err := http.NewRequest("POST", auditServiceURL+"/api/audit-logs", bytes.NewBuffer(jsonData)) + require.NoError(t, err, "Should create HTTP request") + httpReq.Header.Set("Content-Type", "application/json") + + return httpReq +} + +// stringPtr returns a pointer to the given string +func stringPtr(s string) *string { + return &s +} diff --git a/tests/integration/audit_traceid_test.go b/tests/integration/audit_traceid_test.go new file mode 100644 index 00000000..28776306 --- /dev/null +++ b/tests/integration/audit_traceid_test.go @@ -0,0 +1,237 @@ +package integration_test + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + auditServiceURL = "http://127.0.0.1:3001" +) + +// AuditLog represents an audit log entry from the audit service +type AuditLog struct { + ID string `json:"id"` + TraceID *string `json:"traceId"` + Timestamp string `json:"timestamp"` + EventType *string `json:"eventType"` + EventAction *string `json:"eventAction"` + Status string `json:"status"` + ActorType string `json:"actorType"` + ActorID string `json:"actorId"` + TargetType string `json:"targetType"` + TargetID *string `json:"targetId"` + RequestMetadata map[string]interface{} `json:"requestMetadata,omitempty"` + ResponseMetadata map[string]interface{} `json:"responseMetadata,omitempty"` +} + +// AuditLogsResponse represents the response from GET /api/audit-logs +type AuditLogsResponse struct { + Logs []AuditLog `json:"logs"` + Total int64 `json:"total"` +} + +// TestAuditTraceIDCorrelation verifies that all audit events for a single request +// share the same traceID and can be correlated across the entire flow. +func TestAuditTraceIDCorrelation(t *testing.T) { + // Skip test if audit service is not available + if !isAuditServiceAvailable() { + t.Skip("Audit service is not available. Skipping traceID correlation test.") + } + + // Create a test scenario that will generate multiple audit events + // This requires: policy metadata, allowlist, consent, and a GraphQL query + + // Step 1: Setup test data (policy metadata, allowlist, consent) + // This is similar to TestGraphQLFlow_SuccessPath but we'll track the traceID + + // For now, we'll create a simple test that: + // 1. Makes a GraphQL request to OE + // 2. Extracts traceID from response header + // 3. Queries audit service for events with that traceID + // 4. Verifies all expected events are present with the same traceID + + // Create a test JWT + appID := fmt.Sprintf("test-audit-app-%d", time.Now().Unix()) + token, err := createTestJWT(appID) + require.NoError(t, err) + + // Make a GraphQL request + query := `query { + citizen(nic: "123456789V") { + name + } + }` + + reqBody := map[string]interface{}{ + "query": query, + } + + reqBodyJSON, err := json.Marshal(reqBody) + require.NoError(t, err) + + req, err := http.NewRequest("POST", orchestrationEngineURL, bytes.NewBuffer(reqBodyJSON)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-JWT-Assertion", token) + + resp, err := testHTTPClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + // Extract traceID from response header + traceID := resp.Header.Get("X-Trace-ID") + if traceID == "" { + t.Skip("TraceID not found in response header. Audit logging may not be enabled.") + } + + t.Logf("Extracted traceID from response: %s", traceID) + + // Poll for audit logs with retry mechanism (async logging may take time) + auditLogs, err := getAuditLogsByTraceIDWithRetry(t, traceID, 10*time.Second, 300*time.Millisecond) + require.NoError(t, err) + + // Verify we got at least one event + require.Greater(t, len(auditLogs), 0, "Expected at least one audit log for traceID %s", traceID) + + // Verify all events share the same traceID + expectedEventTypes := map[string]bool{ + "ORCHESTRATION_REQUEST_RECEIVED": false, + "POLICY_CHECK": false, + "CONSENT_CHECK": false, + "PROVIDER_FETCH": false, + } + + for _, log := range auditLogs { + // Verify traceID matches + require.NotNil(t, log.TraceID, "Audit log should have a traceID") + assert.Equal(t, traceID, *log.TraceID, "All audit logs should share the same traceID") + + // Track which event types we found + if log.EventType != nil { + if _, exists := expectedEventTypes[*log.EventType]; exists { + expectedEventTypes[*log.EventType] = true + } + } + + t.Logf("Found audit event: type=%s, status=%s, actor=%s, target=%v", + getStringValue(log.EventType), + log.Status, + log.ActorID, + getStringValue(log.TargetID)) + } + + // Verify we found at least ORCHESTRATION_REQUEST_RECEIVED + assert.True(t, expectedEventTypes["ORCHESTRATION_REQUEST_RECEIVED"], + "Expected to find ORCHESTRATION_REQUEST_RECEIVED event") + + // Log summary + foundCount := 0 + for eventType, found := range expectedEventTypes { + if found { + foundCount++ + t.Logf("Found event type: %s", eventType) + } else { + t.Logf("⚠️ Event type not found (may be expected): %s", eventType) + } + } + + t.Logf("Summary: Found %d/%d expected event types for traceID %s", foundCount, len(expectedEventTypes), traceID) +} + +// isAuditServiceAvailable checks if the audit service is available +func isAuditServiceAvailable() bool { + client := &http.Client{ + Timeout: 2 * time.Second, + } + resp, err := client.Get(auditServiceURL + "/health") + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +// getAuditLogsByTraceID queries the audit service for logs with a specific traceID +func getAuditLogsByTraceID(t *testing.T, traceID string) ([]AuditLog, error) { + url := fmt.Sprintf("%s/api/audit-logs?traceId=%s", auditServiceURL, traceID) + + resp, err := testHTTPClient.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to query audit service: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("audit service returned status %d", resp.StatusCode) + } + + var auditResponse AuditLogsResponse + if err := json.NewDecoder(resp.Body).Decode(&auditResponse); err != nil { + return nil, fmt.Errorf("failed to decode audit response: %w", err) + } + + return auditResponse.Logs, nil +} + +// getAuditLogsByTraceIDWithRetry polls the audit service for logs with a specific traceID +// until logs are found or the timeout is reached. This handles async audit logging gracefully. +func getAuditLogsByTraceIDWithRetry(t *testing.T, traceID string, timeout time.Duration, pollInterval time.Duration) ([]AuditLog, error) { + deadline := time.Now().Add(timeout) + startTime := time.Now() + attempt := 0 + + for time.Now().Before(deadline) { + attempt++ + logs, err := getAuditLogsByTraceID(t, traceID) + if err != nil { + // If there's an error, wait and retry + remaining := time.Until(deadline) + if remaining > pollInterval { + time.Sleep(pollInterval) + continue + } + return nil, fmt.Errorf("failed to get audit logs after %d attempts: %w", attempt, err) + } + + // If we found logs, return them immediately + if len(logs) > 0 { + if attempt > 1 { + elapsed := time.Since(startTime) + t.Logf("Found audit logs after %d attempts (polled for %v)", attempt, elapsed) + } + return logs, nil + } + + // No logs yet, wait before next attempt + remaining := time.Until(deadline) + if remaining <= 0 { + break + } + if remaining < pollInterval { + time.Sleep(remaining) + } else { + time.Sleep(pollInterval) + } + } + + // Timeout reached, return empty slice (caller can decide if this is an error) + elapsed := time.Since(startTime) + t.Logf("Timeout reached after %d attempts (polled for %v)", attempt, elapsed) + return []AuditLog{}, nil +} + +// getStringValue safely gets string value from pointer +func getStringValue(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/tests/integration/docker-compose.test.yml b/tests/integration/docker-compose.test.yml index 9b3f6046..6e2121db 100644 --- a/tests/integration/docker-compose.test.yml +++ b/tests/integration/docker-compose.test.yml @@ -91,8 +91,9 @@ services: PDP_URL: http://policy-decision-point:8082 CONSENT_ENGINE_URL: http://consent-engine:8081 CONFIG_PATH: /app/config/config.json - # NOTE: Audit service URL removed/not needed for basic integration tests if not part of main's test scope - # CHOREO_AUDIT_CONNECTION_SERVICEURL: http://audit-service:3001 + # Audit service configuration for integration tests + AUDIT_SERVICE_URL: http://audit-service:3001 + ENABLE_AUDIT: "true" volumes: - ./config.json:/app/config/config.json:ro - ./schema.graphql:/app/schema.graphql:ro @@ -116,4 +117,54 @@ services: interval: 10s timeout: 5s retries: 5 - start_period: 10s \ No newline at end of file + start_period: 10s + + # Audit Service + audit-service: + build: + context: ../.. + dockerfile: audit-service/Dockerfile + environment: + PORT: 3001 + DB_TYPE: postgres + DB_HOST: audit-db + DB_PORT: 5432 + DB_USERNAME: postgres + DB_PASSWORD: password + DB_NAME: audit_db + DB_SSLMODE: disable + AUDIT_LOGS_TABLE_NAME: audit_logs_unified + ports: + - "3001:3001" + depends_on: + audit-db: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://localhost:3001/health", + ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + # PostgreSQL Database for Audit Service + audit-db: + image: postgres:15-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: audit_db + ports: + - "5435:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 \ No newline at end of file From 42590472558c6e96d9906e4b973de41fba6d8df3 Mon Sep 17 00:00:00 2001 From: ginaxu1 <167130561+ginaxu1@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:25:18 +0530 Subject: [PATCH 2/4] Update exchange/orchestration-engine/middleware/audit_middleware.go Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../orchestration-engine/config.example.json | 3 +- .../orchestration-engine/configs/config.go | 10 +++--- .../federator/federator.go | 32 +++++++++++------- exchange/orchestration-engine/main.go | 13 +++----- .../middleware/audit_middleware.go | 33 +++++-------------- 5 files changed, 38 insertions(+), 53 deletions(-) diff --git a/exchange/orchestration-engine/config.example.json b/exchange/orchestration-engine/config.example.json index 6f3441a6..4263ab52 100644 --- a/exchange/orchestration-engine/config.example.json +++ b/exchange/orchestration-engine/config.example.json @@ -4,8 +4,7 @@ "auditConfig": { "serviceUrl": "http://localhost:3001", "actorType": "SERVICE", - "actorId": "orchestration-engine", - "targetType": "SERVICE" + "actorId": "orchestration-engine" }, "providers": [ { diff --git a/exchange/orchestration-engine/configs/config.go b/exchange/orchestration-engine/configs/config.go index 9a92b240..a4ab6e2e 100644 --- a/exchange/orchestration-engine/configs/config.go +++ b/exchange/orchestration-engine/configs/config.go @@ -69,9 +69,9 @@ type CeConfig struct { // AuditConfig holds Audit Service configuration type AuditConfig struct { ServiceURL string `json:"serviceUrl,omitempty"` - ActorType string `json:"actorType,omitempty"` // Default: "SERVICE" - ActorID string `json:"actorId,omitempty"` // Default: "orchestration-engine" - TargetType string `json:"targetType,omitempty"` // Default: "SERVICE" + ActorType string `json:"actorType,omitempty"` // Default: "SERVICE" + ActorID string `json:"actorId,omitempty"` // Default: "orchestration-engine" + // Note: targetType is not configured here as it varies per API call } // LoadConfigFromBytes unmarshals JSON into config (pure function, testable) @@ -99,9 +99,7 @@ func LoadConfigFromBytes(data []byte) (*Config, error) { if config.AuditConfig.ActorID == "" { config.AuditConfig.ActorID = "orchestration-engine" } - if config.AuditConfig.TargetType == "" { - config.AuditConfig.TargetType = "SERVICE" - } + // Note: targetType is not set here as it's determined per API call return &config, nil } diff --git a/exchange/orchestration-engine/federator/federator.go b/exchange/orchestration-engine/federator/federator.go index 028662cb..d7055058 100644 --- a/exchange/orchestration-engine/federator/federator.go +++ b/exchange/orchestration-engine/federator/federator.go @@ -567,7 +567,8 @@ func (f *Federator) logOrchestrationRequestReceived(ctx context.Context, consume "applicationId": consumerAppID, "query": query, } - return middleware.LogAuditEvent(ctx, "ORCHESTRATION_REQUEST_RECEIVED", nil, requestMetadata, nil, auditpkg.StatusSuccess) + // No target for orchestration request received (it's the entry point) + return middleware.LogAuditEvent(ctx, "ORCHESTRATION_REQUEST_RECEIVED", nil, "", requestMetadata, nil, auditpkg.StatusSuccess) } // logPolicyCheck logs a POLICY_CHECK event from orchestration-engine's perspective @@ -576,10 +577,14 @@ func (f *Federator) logOrchestrationRequestReceived(ctx context.Context, consume func (f *Federator) logPolicyCheck(ctx context.Context, applicationID string, req *policy.PdpRequest, resp *policy.PdpResponse, err error) context.Context { targetID := "policy-decision-point" status := auditpkg.StatusSuccess + requestMetadata := make(map[string]interface{}) responseMetadata := make(map[string]interface{}) - // Include application ID from request - responseMetadata["applicationId"] = applicationID + // Populate request metadata + requestMetadata["applicationId"] = applicationID + if req != nil { + requestMetadata["requiredFields"] = req.RequiredFields + } if err != nil { status = auditpkg.StatusFailure @@ -602,7 +607,8 @@ func (f *Federator) logPolicyCheck(ctx context.Context, applicationID string, re } // Update context with traceID if one was generated - ctx = middleware.LogAuditEvent(ctx, "POLICY_CHECK", &targetID, nil, responseMetadata, status) + // Policy Decision Point is a service, so targetType is "SERVICE" + ctx = middleware.LogAuditEvent(ctx, "POLICY_CHECK", &targetID, "SERVICE", requestMetadata, responseMetadata, status) return ctx } @@ -612,15 +618,19 @@ func (f *Federator) logPolicyCheck(ctx context.Context, applicationID string, re func (f *Federator) logConsentCheck(ctx context.Context, applicationID, ownerEmail, ownerID string, req *consent.CreateConsentRequest, resp *consent.ConsentResponseInternalView, err error) context.Context { targetID := "consent-engine" status := auditpkg.StatusSuccess + requestMetadata := make(map[string]interface{}) responseMetadata := make(map[string]interface{}) - // Include request context in metadata - responseMetadata["applicationId"] = applicationID + // Populate request metadata from request context + requestMetadata["applicationId"] = applicationID if ownerEmail != "" { - responseMetadata["ownerEmail"] = ownerEmail + requestMetadata["ownerEmail"] = ownerEmail } if ownerID != "" { - responseMetadata["ownerId"] = ownerID + requestMetadata["ownerId"] = ownerID + } + if req != nil { + requestMetadata["fieldsCount"] = len(req.ConsentRequirement.Fields) } if err != nil { @@ -632,13 +642,11 @@ func (f *Federator) logConsentCheck(ctx context.Context, applicationID, ownerEma if resp.ConsentPortalURL != nil { responseMetadata["consentPortalUrl"] = *resp.ConsentPortalURL } - if resp.Fields != nil { - responseMetadata["fieldsCount"] = len(*resp.Fields) - } } // Update context with traceID if one was generated - return middleware.LogAuditEvent(ctx, "CONSENT_CHECK", &targetID, nil, responseMetadata, status) + // Consent Engine is a service, so targetType is "SERVICE" + return middleware.LogAuditEvent(ctx, "CONSENT_CHECK", &targetID, "SERVICE", requestMetadata, responseMetadata, status) } func (f *Federator) mergeResponses(responses []*ProviderResponse) graphql.Response { diff --git a/exchange/orchestration-engine/main.go b/exchange/orchestration-engine/main.go index e97986e4..b7ab1dde 100644 --- a/exchange/orchestration-engine/main.go +++ b/exchange/orchestration-engine/main.go @@ -2,7 +2,6 @@ package main import ( "log" - "os" "github.com/ginaxu1/gov-dx-sandbox/exchange/orchestration-engine/configs" "github.com/ginaxu1/gov-dx-sandbox/exchange/orchestration-engine/federator" @@ -23,19 +22,15 @@ func main() { } // Initialize audit middleware - // Environment variable takes precedence over config.json for flexibility - auditServiceURL := os.Getenv("AUDIT_SERVICE_URL") - if auditServiceURL == "" { - auditServiceURL = config.AuditConfig.ServiceURL - } - auditClient := auditclient.NewClient(auditServiceURL) + // All configuration comes from config.json for consistency + auditClient := auditclient.NewClient(config.AuditConfig.ServiceURL) auditclient.InitializeGlobalAudit(auditClient) - // Initialize audit configuration (actorType, actorID, targetType) + // Initialize audit configuration (actorType, actorID) + // Note: targetType is determined per API call, not from global config middleware.InitializeAuditConfig( config.AuditConfig.ActorType, config.AuditConfig.ActorID, - config.AuditConfig.TargetType, ) providerHandler := provider.NewProviderHandler(config.GetProviders()) diff --git a/exchange/orchestration-engine/middleware/audit_middleware.go b/exchange/orchestration-engine/middleware/audit_middleware.go index e702b065..266a7168 100644 --- a/exchange/orchestration-engine/middleware/audit_middleware.go +++ b/exchange/orchestration-engine/middleware/audit_middleware.go @@ -14,9 +14,8 @@ import ( // auditConfig holds the audit configuration values var ( auditConfig struct { - actorType string - actorID string - targetType string + actorType string + actorID string } auditConfigOnce sync.Once ) @@ -24,21 +23,12 @@ var ( // InitializeAuditConfig initializes the audit configuration from config values // This should be called once during application startup from main.go // Values are read from config.json via config.AuditConfig -func InitializeAuditConfig(actorType, actorID, targetType string) { +// Note: targetType is not stored here as it varies per API call +func InitializeAuditConfig(actorType, actorID string) { auditConfigOnce.Do(func() { - // Set values with defaults if not provided (defensive programming) - if actorType == "" { - actorType = "SERVICE" - } - if actorID == "" { - actorID = "orchestration-engine" - } - if targetType == "" { - targetType = "SERVICE" - } + // These values are expected to be pre-populated with defaults from the configs package. auditConfig.actorType = actorType auditConfig.actorID = actorID - auditConfig.targetType = targetType }) } @@ -54,12 +44,6 @@ func getAuditActorID() string { return auditConfig.actorID } -// getAuditTargetType returns the configured target type -// InitializeAuditConfig guarantees this is always set (with default if needed) -func getAuditTargetType() string { - return auditConfig.targetType -} - // maxAuditErrorsToLog is the maximum number of errors to include in audit log metadata // This limit prevents audit logs from becoming too large when there are many errors const maxAuditErrorsToLog = 3 @@ -103,7 +87,8 @@ func MetadataFromContext(ctx context.Context) *Metadata { // - Creates AuditLogRequest struct // - Logs the event asynchronously // Returns the updated context with traceID (if one was generated) to ensure trace correlation -func LogAuditEvent(ctx context.Context, eventType string, targetID *string, requestMetadata map[string]interface{}, responseMetadata map[string]interface{}, status string) context.Context { +// targetType should be determined per API call (e.g., "SERVICE" for service-to-service calls, "RESOURCE" for resource operations) +func LogAuditEvent(ctx context.Context, eventType string, targetID *string, targetType string, requestMetadata map[string]interface{}, responseMetadata map[string]interface{}, status string) context.Context { // Get or generate traceID traceID := monitoring.GetTraceIDFromContext(ctx) if traceID == "" { @@ -115,7 +100,6 @@ func LogAuditEvent(ctx context.Context, eventType string, targetID *string, requ // These are initialized in main.go via InitializeAuditConfig() from config.AuditConfig actorType := getAuditActorType() actorID := getAuditActorID() - targetType := getAuditTargetType() // Marshal metadata using shared utility requestMetadataJSON := auditpkg.MarshalMetadata(requestMetadata) @@ -211,5 +195,6 @@ func LogProviderFetch(ctx context.Context, providerSchemaID string, req *Federat // Use shared helper function to log audit event // Update context with traceID if one was generated - ctx = LogAuditEvent(ctx, "PROVIDER_FETCH", &req.ServiceKey, nil, responseMetadata, auditStatus) + // Providers are services, so targetType is "SERVICE" + ctx = LogAuditEvent(ctx, "PROVIDER_FETCH", &req.ServiceKey, "SERVICE", nil, responseMetadata, auditStatus) } From de1b3f19d4074b5c0ad27e21cfc60ffc72f24f96 Mon Sep 17 00:00:00 2001 From: Thanikan Date: Tue, 13 Jan 2026 19:25:44 +0530 Subject: [PATCH 3/4] Refactor audit service enums and logging methods for improved clarity and consistency --- audit-service/config/config.go | 54 +++++++++---------- audit-service/config/enums.yaml | 4 +- .../orchestration-engine/consent/ce_client.go | 2 +- .../federator/federator.go | 2 +- .../middleware/audit_middleware.go | 34 ++++++++++++ 5 files changed, 65 insertions(+), 31 deletions(-) diff --git a/audit-service/config/config.go b/audit-service/config/config.go index 27d50afe..82271e22 100644 --- a/audit-service/config/config.go +++ b/audit-service/config/config.go @@ -32,34 +32,32 @@ type Config struct { Enums AuditEnums `yaml:"enums"` } -var ( - // DefaultEnums provides default enum values if config file is not found - // Note: OpenDIF-specific event types (ORCHESTRATION_REQUEST_RECEIVED, POLICY_CHECK, CONSENT_CHECK, PROVIDER_FETCH) - // should be added to config/enums.yaml for project-specific configurations - DefaultEnums = AuditEnums{ - EventTypes: []string{ - "MANAGEMENT_EVENT", - "USER_MANAGEMENT", - "DATA_FETCH", - }, - EventActions: []string{ - "CREATE", - "READ", - "UPDATE", - "DELETE", - }, - ActorTypes: []string{ - "SERVICE", - "ADMIN", - "MEMBER", - "SYSTEM", - }, - TargetTypes: []string{ - "SERVICE", - "RESOURCE", - }, - } -) +// DefaultEnums provides default enum values if config file is not found +// Note: OpenDIF-specific event types (ORCHESTRATION_REQUEST_RECEIVED, POLICY_CHECK, CONSENT_CHECK, PROVIDER_FETCH) +// should be added to config/enums.yaml for project-specific configurations +var DefaultEnums = AuditEnums{ + EventTypes: []string{ + "MANAGEMENT_EVENT", + "USER_MANAGEMENT", + "DATA_FETCH", + }, + EventActions: []string{ + "CREATE", + "READ", + "UPDATE", + "DELETE", + }, + ActorTypes: []string{ + "SERVICE", + "ADMIN", + "MEMBER", + "SYSTEM", + }, + TargetTypes: []string{ + "SERVICE", + "RESOURCE", + }, +} // LoadEnums loads enum configuration from YAML file // If the file is not found or cannot be read, returns default enums diff --git a/audit-service/config/enums.yaml b/audit-service/config/enums.yaml index 3c42cf9d..e6881029 100644 --- a/audit-service/config/enums.yaml +++ b/audit-service/config/enums.yaml @@ -9,7 +9,8 @@ enums: - POLICY_CHECK - MANAGEMENT_EVENT - USER_MANAGEMENT - - DATA_FETCH + - DATA_REQUEST + - PROVIDER_FETCH # Event Action: CRUD operations eventActions: @@ -28,6 +29,7 @@ enums: - ADMIN - MEMBER - SYSTEM + - APPLICATION # Target Type: Types of targets that actions can be performed on targetTypes: diff --git a/exchange/orchestration-engine/consent/ce_client.go b/exchange/orchestration-engine/consent/ce_client.go index 1bf2f7b8..3a70bb8a 100644 --- a/exchange/orchestration-engine/consent/ce_client.go +++ b/exchange/orchestration-engine/consent/ce_client.go @@ -42,7 +42,7 @@ func (c *CEServiceClient) CreateConsent(ctx context.Context, request *CreateCons return nil, err } req.Header.Set("Content-Type", "application/json") - + // Propagate traceID from context to header for audit correlation traceID := monitoring.GetTraceIDFromContext(ctx) if traceID != "" { diff --git a/exchange/orchestration-engine/federator/federator.go b/exchange/orchestration-engine/federator/federator.go index d7055058..04b27fab 100644 --- a/exchange/orchestration-engine/federator/federator.go +++ b/exchange/orchestration-engine/federator/federator.go @@ -568,7 +568,7 @@ func (f *Federator) logOrchestrationRequestReceived(ctx context.Context, consume "query": query, } // No target for orchestration request received (it's the entry point) - return middleware.LogAuditEvent(ctx, "ORCHESTRATION_REQUEST_RECEIVED", nil, "", requestMetadata, nil, auditpkg.StatusSuccess) + return middleware.LogRequestReceived(ctx, "DATA_REQUEST", "APPLICATION", consumerAppID, requestMetadata) } // logPolicyCheck logs a POLICY_CHECK event from orchestration-engine's perspective diff --git a/exchange/orchestration-engine/middleware/audit_middleware.go b/exchange/orchestration-engine/middleware/audit_middleware.go index 266a7168..b243409c 100644 --- a/exchange/orchestration-engine/middleware/audit_middleware.go +++ b/exchange/orchestration-engine/middleware/audit_middleware.go @@ -198,3 +198,37 @@ func LogProviderFetch(ctx context.Context, providerSchemaID string, req *Federat // Providers are services, so targetType is "SERVICE" ctx = LogAuditEvent(ctx, "PROVIDER_FETCH", &req.ServiceKey, "SERVICE", nil, responseMetadata, auditStatus) } + +// LogRequestReceived logs a request received event to the audit service asynchronously +func LogRequestReceived(ctx context.Context, eventType string, actorType string, actorId string, requestMetadata map[string]interface{}) context.Context { + // Get or generate traceID + traceID := monitoring.GetTraceIDFromContext(ctx) + if traceID == "" { + traceID = uuid.New().String() + ctx = monitoring.WithTraceID(ctx, traceID) + } + status := auditpkg.StatusSuccess + + targetID := "SERVICE" + // Marshal metadata using shared utility + requestMetadataJSON := auditpkg.MarshalMetadata(requestMetadata) + + // Create audit request + auditRequest := &auditpkg.AuditLogRequest{ + TraceID: &traceID, + Timestamp: auditpkg.CurrentTimestamp(), + EventType: &eventType, + Status: status, + ActorType: actorType, + ActorID: actorId, + TargetType: "SERVICE", + TargetID: &targetID, + RequestMetadata: requestMetadataJSON, + } + + // Log the audit event asynchronously using the global audit package + auditpkg.LogAuditEvent(ctx, auditRequest) + + // Return the updated context to ensure traceID correlation across the request flow + return ctx +} From 6026adf66ca620b08ac347e02256f8bcac792c27 Mon Sep 17 00:00:00 2001 From: Thanikan Date: Tue, 13 Jan 2026 19:31:47 +0530 Subject: [PATCH 4/4] Add CONSENT_CHECK to eventTypes in audit service enum configuration --- audit-service/config/enums.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/audit-service/config/enums.yaml b/audit-service/config/enums.yaml index e6881029..ad5b2462 100644 --- a/audit-service/config/enums.yaml +++ b/audit-service/config/enums.yaml @@ -9,6 +9,7 @@ enums: - POLICY_CHECK - MANAGEMENT_EVENT - USER_MANAGEMENT + - CONSENT_CHECK - DATA_REQUEST - PROVIDER_FETCH