diff --git a/.gitignore b/.gitignore index 39b72ba..7d874f4 100644 --- a/.gitignore +++ b/.gitignore @@ -170,3 +170,6 @@ fabric.properties # Editor-based Rest Client .idea/httpRequests + +# Buf +gen \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1ec1ff8 --- /dev/null +++ b/README.md @@ -0,0 +1,193 @@ +## Audit Log API +The audit log api is a library that simplifies sending audit logs to the audit log system. + +### Logging API + +The audit api provides two ways to log audit events: + +- **Direct logging** + Logs are directly validated, serialized and sent via solace. + It's the responsibility of the sending service to handle errors and to retry in case of an error. + ``` + Log( + ctx context.Context, + event *auditV1.AuditEvent, + visibility auditV1.Visibility, + routingIdentifier *RoutingIdentifier, + objectIdentifier *auditV1.ObjectIdentifier, + ) error + ``` + +- **Transactional safe logging (transactional outbox pattern)** + Functionality is split into two steps: + - Serialization + Validation + The `ValidateAndSerialize` method can be called to validate and serialize the event data. + The serialized data can be stored in an outbox table / collection in + the database. + ``` + ValidateAndSerialize( + event *auditV1.AuditEvent, + visibility auditV1.Visibility, + routingIdentifier *RoutingIdentifier, + objectIdentifier *auditV1.ObjectIdentifier, + ) (SerializedPayload, error) + ``` + + - Sending data to solace + The `Send` method can be used to send the previously serialized data. + It is the responsibility of the sending service to ensure that the + method is called (repetitive) until no error is reported. + ``` + Send( + ctx context.Context, + routingIdentifier *RoutingIdentifier, + serializedPayload *SerializedPayload, + ) error + ``` + +### Usage +1. Create Solace client / acl rules + The topic names have to be compliant to the format as specified + [here](https://async-api.stackit.schwarz/core-platform/asyncapi.html#operation-publish-stackit-platform/t/swz/audit-log/{region}/{version}/{eventSource}/{additionalParts}). + A solace client has to be provisioned manually with terraform (for + [dev](https://dev.azure.com/schwarzit/schwarzit.esb/_git/QaaS-Terraform?path=/00_dev/essolske01/stackit/client_usernames.tf) / + [qa](https://dev.azure.com/schwarzit/schwarzit.esb/_git/QaaS-Terraform?path=/02_qs/qssolske01/stackit/client_usernames.tf) / + [prod](https://dev.azure.com/schwarzit/schwarzit.esb/_git/QaaS-Terraform?path=/03_prod/pssolske01/stackit/client_usernames.tf)). + The permission to write to the topic needs to be specified in the terraform configuration (for + [dev](https://dev.azure.com/schwarzit/schwarzit.esb/_git/QaaS-Terraform?path=/00_dev/essolske01/stackit/acl_profiles.tf) / + [qa](https://dev.azure.com/schwarzit/schwarzit.esb/_git/QaaS-Terraform?path=/02_qs/qssolske01/stackit/acl_profiles.tf) / + [prod](https://dev.azure.com/schwarzit/schwarzit.esb/_git/QaaS-Terraform?path=/03_prod/pssolske01/stackit/acl_profiles.tf)). + +2. Instantiation of the messaging API + The audit log api uses solace as messaging system. The actual implementation for the + connection to the messaging system is implemented by specifying an abstraction and providing a + concrete implementation for AMQP/Solace. + ```go + messagingApi, err := NewAmqpMessagingApi(AmqpConfig{URL: "...", User: "", Password: ""}) + if err != nil { ... } + ``` + +3. Instantiation of the audit log api + There will be a new Audit API in the future as audit logs will be routed dynamically + to different data sinks in the future. + The plan though is to keep the actual methods that (validates, serializes and) sends the log statements as stable + as possible only replacing the API instantiation part later. + The instantiation of the legacy api is shown below: + ``` + validator, err := protovalidate.New() + if err != nil { ... } + + auditApi, err := NewLegacyAuditApi( + messagingApi, + LegacyTopicNameConfig{TopicName: topicName}, + validator, + ) + ``` + + For each Solace topic data should be sent to, a new object instance of the legacy API is needed. + The `messagingAPI` object can be shared across the audit api object instances. + +4. Logging audit events + As described in [Logging API](#logging-api) messages can be validated, serialized and sent + through the API. + It is important to retry as long as errors are returned to ensure that the message is really sent. + +### Examples +The test code can be taken as an inspiration how to use the API. +- [api_legacy_test.go](./api_legacy_test.go) + +### Tests +For users of the API who integrate the library there is an additional implementation available for +testing purpose. The `MockAuditApi` does not require a connection to a messaging system (therefore, +does not send any data via messaging). It only serializes and validates data. +It can be instantiated as follows: + +``` +auditApi, err := NewMockAuditApi() +if err != nil { ... } +``` + +### Customization + +#### os.Stdout logging + +The audit log api uses `slog` to print messages on `os.Stdout` as it is part +of the standard library (Go >= 1.21). + +To configure slog to print json instead of plain text, the default +logger has to be configured before using it. + +```go +import "log/slog" + +func main() { + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) +} +``` + +### Development + +#### Build +Before the library can be used, modified or if tests should be executed it's required to generate protobuf files +from the schema definition. + +##### Generate Protobuf files + +```bash +# Create the gen/java directory manually or rerun the buf generate command +mkdir -p gen/java + +cd proto +# --include-imports is required for python code generation +buf generate --include-imports +``` + +##### Build library +```bash +go mod download && go mod tidy && go get ./... && go fmt ./... && go vet ./... && go build ./... && go test ./... +``` + +#### Register buf validation schema in IntelliJ / Goland +The schema files use `buf` protobuf extensions for validation of constraints. + +To register the schema in IntelliJ / Goland clone the repo and add the import path: +```bash +git clone https://github.com/bufbuild/protovalidate.git +``` + +IntelliJ/Goland > Settings > Languages & Frameworks > Protocol Buffers > Import Paths > + (Add Path) > …/protovalidate/proto/protovalidate + +#### Testcontainers +To run the tests **Docker** is needed as [Testcontainers](https://testcontainers.com/) +is used to run integration tests using a solace docker container. + +#### Additional API implementations +There's already an implementation draft of the api for the new dynamically routing +audit log solution. As the implementation of the system has not officially been +started yet, it's only a draft with integration tests. +The API code is private to not confuse users or loose data until the new system +is ready to be used. + +The code can be found in the [api_routable.go](./api_routable.go) and +[api_routable_test.go](./api_routable_test.go) files. + +### Java and Python +The repo currently contains additional configuration for Java and Python code generation. + +For Python there's an example file showcasing the instantiation and validation, +that needs to be copied to the `gen/python` directory after generating the python-files +from the `.proto` files. +For Python, it's additionally required to install `protovalidate` on the system as +described [here](https://github.com/bufbuild/protovalidate-python) or by +running `pip3 install -r requirements.txt`. + +In the future related code and configurations for both additional languages (Java and +Python) will be extracted into separate repositories. + +### Open Issues +- Finalizing messaging schema +- Extraction of python / java configurations and code +- Clarify if `client.go` file can be used for licence / legal reasons +- Clean up repo (delete main.go, etc. files) +- Finalizing API Design + - Decision whether serialized payload should be replaced with []byte or base64 encoded string \ No newline at end of file diff --git a/api.go b/api.go new file mode 100644 index 0000000..66ab0f7 --- /dev/null +++ b/api.go @@ -0,0 +1,83 @@ +package main + +import ( + auditV1 "audit-schema/gen/go/audit/v1" + "context" + "github.com/google/uuid" + "google.golang.org/protobuf/proto" +) + +// AuditApi is the interface to log audit events. +// +// It provides a Log method that can be used to validate and directly send events. +// If the transactional outbox patter should be used, the ValidateAndSerialize and Send methods +// can be called manually to decouple operations. +type AuditApi interface { + + // Log is a convenience method that validates, serializes and sends data over the wire. + // If the transactional outbox patter should be used, the ValidateAndSerialize method + // and Send method can be called manually. + // + // Parameters: + // * ctx - the context object + // * event - the auditV1.AuditEvent + // * visibility - route the event only internally or to the customer (not evaluated in the legacy solution) + // * routingIdentifier - the identifier for the AMQP-Topic selection (optional) + // * auditV1.ObjectType_OBJECT_TYPE_ORGANIZATION + // * auditV1.ObjectType_OBJECT_TYPE_PROJECT + // * objectIdentifier - the identifier of the object (optional - if not folder must be identical to routingIdentifier) + // * auditV1.ObjectType_OBJECT_TYPE_ORGANIZATION + // * auditV1.ObjectType_OBJECT_TYPE_FOLDER + // * auditV1.ObjectType_OBJECT_TYPE_PROJECT + // + // It may return one of the following errors: + /* + - ErrEventNil - if event is nil + - ErrObjectIdentifierVisibilityMismatch - if object identifier and visibility are not in a valid state + - ErrRoutableIdentifierMissing - if routing identifier type and object identifier type do not match + - ErrRoutableIdentifierTypeMismatch - if routing identifier type and object identifier types are not compatible + - ErrUnsupportedObjectIdentifierType - if an unsupported object identifier type was provided + - ErrUnsupportedResourceReferenceType - if an unsupported resource reference type was provided + - protovalidate.ValidationError - if schema validation errors have been detected + - protobuf serialization errors - if the event couldn't be serialized + */ + Log(ctx context.Context, event *auditV1.AuditEvent, visibility auditV1.Visibility, routingIdentifier *RoutingIdentifier, objectIdentifier *auditV1.ObjectIdentifier) error + + // ValidateAndSerialize validates and serializes the event into a byte representation. + // The result has to be sent explicitly by calling the Send method. + ValidateAndSerialize(event *auditV1.AuditEvent, visibility auditV1.Visibility, routingIdentifier *RoutingIdentifier, objectIdentifier *auditV1.ObjectIdentifier) (SerializedPayload, error) + + // Send the serialized content as byte array to the audit log system. + Send(ctx context.Context, routingIdentifier *RoutingIdentifier, serializedPayload *SerializedPayload) error +} + +// ProtobufValidator is an abstraction for validators. +// Concrete implementations are e.g. protovalidate.Validator +type ProtobufValidator interface { + Validate(msg proto.Message) error +} + +// SerializedPayload is an abstraction for serialized content +type SerializedPayload interface { + + // GetPayload returns the actual payload as byte array + GetPayload() []byte + + // GetContentType returns the content type of the payload + GetContentType() string +} + +// RoutingIdentifierType is an enumeration of allowed identifier types. +type RoutingIdentifierType int + +// RoutingIdentifierType enumeration values. +const ( + RoutingIdentifierTypeOrganization RoutingIdentifierType = 0 + RoutingIdentifierTypeProject RoutingIdentifierType = 1 +) + +// RoutingIdentifier is a representation for identifiers of allowed types. +type RoutingIdentifier struct { + Identifier uuid.UUID + Type RoutingIdentifierType +} diff --git a/api_common.go b/api_common.go new file mode 100644 index 0000000..f9b8c15 --- /dev/null +++ b/api_common.go @@ -0,0 +1,167 @@ +package main + +import ( + auditV1 "audit-schema/gen/go/audit/v1" + "context" + "errors" + "fmt" + "google.golang.org/protobuf/proto" +) + +const ContentTypeProtobuf = "application/x-protobuf" + +// ErrEventNil indicates that the event was nil +var ErrEventNil = errors.New("event is nil") + +// ErrObjectIdentifierVisibilityMismatch indicates that a reference mismatch was detected. +// +// Valid combinations are: +// * Visibility: Public, ObjectIdentifier: +// * Visibility: Private, ObjectIdentifier: -> If ObjectIdentifier is nil, +// the ObjectReference in the message will be ObjectName_OBJECT_NAME_SYSTEM +var ErrObjectIdentifierVisibilityMismatch = errors.New("object reference visibility mismatch") + +// ErrRoutableIdentifierMissing indicates that a routable identifier is expected for the constellation of data. +var ErrRoutableIdentifierMissing = errors.New("routable identifier expected") + +// ErrRoutableIdentifierMismatch indicates that a routable identifier (uuid) does not match. +var ErrRoutableIdentifierMismatch = errors.New("routable identifier type does not match") + +// ErrRoutableIdentifierTypeMismatch indicates that a routable identifier type does not match the expected type. +var ErrRoutableIdentifierTypeMismatch = errors.New("routable identifier type does not match") + +// ErrUnsupportedObjectIdentifierType indicates that an unsupported object identifier type has been provided. +var ErrUnsupportedObjectIdentifierType = errors.New("unsupported object identifier type") + +// ErrUnsupportedResourceReferenceType indicates that an unsupported reference type has been provided. +var ErrUnsupportedResourceReferenceType = errors.New("unsupported resource reference type") + +// ErrTopicNameResolverNil states that the topic name resolve is nil +var ErrTopicNameResolverNil = errors.New("topic name resolver nil") + +// ErrMessagingApiNil states that the messaging api is nil +var ErrMessagingApiNil = errors.New("messaging api nil") + +// ErrSerializedPayloadNil states that the give serialized payload is nil +var ErrSerializedPayloadNil = errors.New("serialized payload nil") + +func validateAndSerializePartially(validator *ProtobufValidator, event *auditV1.AuditEvent, visibility auditV1.Visibility, routingIdentifier *RoutingIdentifier, objectIdentifier *auditV1.ObjectIdentifier) (*auditV1.RoutableAuditEvent, error) { + + // Return error if the given event is nil + if event == nil { + return nil, ErrEventNil + } + + // Validate the actual event + err := (*validator).Validate(event) + if err != nil { + return nil, err + } + + // Ensure that an object identifier is set if the event is public + if objectIdentifier == nil && visibility == auditV1.Visibility_VISIBILITY_PUBLIC { + return nil, ErrObjectIdentifierVisibilityMismatch + } + + // Routing identifier not set but object identifier with routable identifier + if routingIdentifier == nil && objectIdentifier != nil && + visibility != auditV1.Visibility_VISIBILITY_PRIVATE { + + return nil, ErrRoutableIdentifierMissing + } + + // Routing identifier type and object identifier types are not compatible + if routingIdentifier != nil && + objectIdentifier != nil && + objectIdentifier.Type == auditV1.ObjectType_OBJECT_TYPE_FOLDER && + routingIdentifier.Type != RoutingIdentifierTypeOrganization { + + return nil, ErrRoutableIdentifierTypeMismatch + } + + // Routing identifier type and object identifier do not match + if routingIdentifier != nil && + objectIdentifier != nil && + objectIdentifier.Type != auditV1.ObjectType_OBJECT_TYPE_FOLDER && + routingIdentifier.Identifier.String() != objectIdentifier.Identifier { + + return nil, ErrRoutableIdentifierMismatch + } + + // Test serialization even if the data is dropped later while logging to the legacy solution + auditEventBytes, err := proto.Marshal(event) + if err != nil { + return nil, err + } + + payload := auditV1.UnencryptedData{ + Data: auditEventBytes, + ProtobufType: fmt.Sprintf("%v", event.ProtoReflect().Descriptor().FullName()), + } + + routableEvent := auditV1.RoutableAuditEvent{ + EventName: event.EventName, + Visibility: visibility, + Data: &auditV1.RoutableAuditEvent_UnencryptedData{UnencryptedData: &payload}, + } + + // Set oneof protobuf fields after creation of the object + if objectIdentifier == nil { + routableEvent.ResourceReference = &auditV1.RoutableAuditEvent_ObjectName{ObjectName: auditV1.ObjectName_OBJECT_NAME_SYSTEM} + } else { + routableEvent.ResourceReference = &auditV1.RoutableAuditEvent_ObjectIdentifier{ObjectIdentifier: objectIdentifier} + } + + err = (*validator).Validate(&routableEvent) + if err != nil { + return nil, err + } + + return &routableEvent, nil +} + +func serializeToProtobufMessage(routableEvent *auditV1.RoutableAuditEvent) (SerializedPayload, error) { + // Serialize routable event + routableEventBytes, err := proto.Marshal(routableEvent) + if err != nil { + return nil, err + } + + // Package the routable event with information about the type in a message + message := auditV1.ProtobufMessage{ + Value: routableEventBytes, + ProtobufType: fmt.Sprintf("%v", routableEvent.ProtoReflect().Descriptor().FullName()), + } + + // Serialize message + messageBytes, err := proto.Marshal(&message) + if err != nil { + return nil, err + } + + return &routablePayload{ + payload: messageBytes, + contentType: ContentTypeProtobuf, + }, nil +} + +// Send implements AuditApi.Send +func send(topicNameResolver *TopicNameResolver, messagingApi *MessagingApi, ctx context.Context, routingIdentifier *RoutingIdentifier, serializedPayload *SerializedPayload) error { + + if topicNameResolver == nil { + return ErrTopicNameResolverNil + } + if messagingApi == nil { + return ErrMessagingApiNil + } + if serializedPayload == nil { + return ErrSerializedPayloadNil + } + + topic, err := (*topicNameResolver).Resolve(routingIdentifier) + if err != nil { + return err + } + + return (*messagingApi).Send(ctx, topic, (*serializedPayload).GetPayload(), (*serializedPayload).GetContentType()) +} diff --git a/api_common_test.go b/api_common_test.go new file mode 100644 index 0000000..1f88f24 --- /dev/null +++ b/api_common_test.go @@ -0,0 +1,227 @@ +package main + +import ( + auditV1 "audit-schema/gen/go/audit/v1" + "context" + "errors" + "fmt" + "github.com/bufbuild/protovalidate-go" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "google.golang.org/protobuf/proto" + "testing" +) + +type ProtobufValidatorMock struct { + mock.Mock +} + +func (m *ProtobufValidatorMock) Validate(msg proto.Message) error { + args := m.Called(msg) + return args.Error(0) +} + +type TopicNameResolverMock struct { + mock.Mock +} + +func (m *TopicNameResolverMock) Resolve(routingIdentifier *RoutingIdentifier) (string, error) { + args := m.Called(routingIdentifier) + return args.String(0), args.Error(1) +} + +func NewValidator(t *testing.T) ProtobufValidator { + validator, err := protovalidate.New() + var protoValidator ProtobufValidator = validator + assert.NoError(t, err) + + return protoValidator +} + +func Test_ValidateAndSerializePartially_EventNil(t *testing.T) { + validator := NewValidator(t) + + _, err := validateAndSerializePartially(&validator, nil, auditV1.Visibility_VISIBILITY_PUBLIC, nil, nil) + assert.ErrorIs(t, err, ErrEventNil) +} + +func Test_ValidateAndSerializePartially_AuditEventValidationFailed(t *testing.T) { + validator := NewValidator(t) + + event, routingIdentifier, objectIdentifier := NewOrganizationAuditEvent(nil) + event.EventName = "" + + _, err := validateAndSerializePartially(&validator, event, auditV1.Visibility_VISIBILITY_PUBLIC, routingIdentifier, objectIdentifier) + assert.EqualError(t, err, "validation error:\n - event_name: value is required [required]") +} + +func Test_ValidateAndSerializePartially_RoutableEventValidationFailed(t *testing.T) { + validator := NewValidator(t) + + event, routingIdentifier, objectIdentifier := NewOrganizationAuditEvent(nil) + + _, err := validateAndSerializePartially(&validator, event, 3, routingIdentifier, objectIdentifier) + assert.EqualError(t, err, "validation error:\n - visibility: value must be one of the defined enum values [enum.defined_only]") +} + +func Test_ValidateAndSerializePartially_CheckVisibility(t *testing.T) { + validator := NewValidator(t) + + event, routingIdentifier, objectIdentifier := NewOrganizationAuditEvent(nil) + + t.Run("Visibility public - object identifier nil - routing identifier nil", func(t *testing.T) { + _, err := validateAndSerializePartially(&validator, event, auditV1.Visibility_VISIBILITY_PUBLIC, nil, nil) + assert.ErrorIs(t, err, ErrObjectIdentifierVisibilityMismatch) + }) + + t.Run("Visibility public - object identifier nil - routing identifier set", func(t *testing.T) { + _, err := validateAndSerializePartially(&validator, event, auditV1.Visibility_VISIBILITY_PUBLIC, routingIdentifier, nil) + assert.ErrorIs(t, err, ErrObjectIdentifierVisibilityMismatch) + }) + + t.Run("Visibility public - object identifier set - routing identifier nil", func(t *testing.T) { + _, err := validateAndSerializePartially(&validator, event, auditV1.Visibility_VISIBILITY_PUBLIC, nil, objectIdentifier) + assert.ErrorIs(t, err, ErrRoutableIdentifierMissing) + }) + + t.Run("Visibility public - object identifier set - routing identifier set", func(t *testing.T) { + routableEvent, err := validateAndSerializePartially(&validator, event, auditV1.Visibility_VISIBILITY_PUBLIC, routingIdentifier, objectIdentifier) + assert.NoError(t, err) + assert.NotNil(t, routableEvent) + }) + + t.Run("Visibility private - object identifier nil - routing identifier nil", func(t *testing.T) { + routableEvent, err := validateAndSerializePartially(&validator, event, auditV1.Visibility_VISIBILITY_PRIVATE, nil, nil) + assert.NoError(t, err) + assert.NotNil(t, routableEvent) + }) + + t.Run("Visibility private - object identifier nil - routing identifier set", func(t *testing.T) { + routableEvent, err := validateAndSerializePartially(&validator, event, auditV1.Visibility_VISIBILITY_PRIVATE, routingIdentifier, nil) + assert.NoError(t, err) + assert.NotNil(t, routableEvent) + }) + + t.Run("Visibility private - object identifier set - routing identifier nil", func(t *testing.T) { + routableEvent, err := validateAndSerializePartially(&validator, event, auditV1.Visibility_VISIBILITY_PRIVATE, nil, objectIdentifier) + assert.NoError(t, err) + assert.NotNil(t, routableEvent) + }) + + t.Run("Visibility private - object identifier set - routing identifier set", func(t *testing.T) { + routableEvent, err := validateAndSerializePartially(&validator, event, auditV1.Visibility_VISIBILITY_PRIVATE, routingIdentifier, objectIdentifier) + assert.NoError(t, err) + assert.NotNil(t, routableEvent) + }) +} + +func Test_ValidateAndSerializePartially_IdentifierTypeMismatch(t *testing.T) { + validator := NewValidator(t) + + event, routingIdentifier, objectIdentifier := NewFolderAuditEvent(nil) + routingIdentifier.Type = RoutingIdentifierTypeProject + + _, err := validateAndSerializePartially(&validator, event, auditV1.Visibility_VISIBILITY_PUBLIC, routingIdentifier, objectIdentifier) + assert.ErrorIs(t, err, ErrRoutableIdentifierTypeMismatch) +} + +func Test_ValidateAndSerializePartially_IdentifierMismatch(t *testing.T) { + validator := NewValidator(t) + + event, routingIdentifier, objectIdentifier := NewProjectAuditEvent(nil) + routingIdentifier.Identifier = uuid.New() + + _, err := validateAndSerializePartially(&validator, event, auditV1.Visibility_VISIBILITY_PUBLIC, routingIdentifier, objectIdentifier) + assert.ErrorIs(t, err, ErrRoutableIdentifierMismatch) +} + +func Test_ValidateAndSerializePartially_SystemEvent(t *testing.T) { + validator := NewValidator(t) + + event := NewSystemAuditEvent(nil) + + routableEvent, err := validateAndSerializePartially(&validator, event, auditV1.Visibility_VISIBILITY_PRIVATE, nil, nil) + assert.NoError(t, err) + + switch reference := routableEvent.ResourceReference.(type) { + case *auditV1.RoutableAuditEvent_ObjectName: + assert.Equal(t, auditV1.ObjectName_OBJECT_NAME_SYSTEM, reference.ObjectName) + default: + assert.Fail(t, "unexpected resource reference") + } +} + +func Test_SerializeToProtobufMessage(t *testing.T) { + validator := NewValidator(t) + + // Create test data + event, identifier, objectIdentifier := NewOrganizationAuditEventWithDetails() + + // Serialize to routable event + routableEvent, err := validateAndSerializePartially(&validator, event, auditV1.Visibility_VISIBILITY_PUBLIC, identifier, objectIdentifier) + assert.NoError(t, err) + + // Serialize to protobuf message + serializedPayload, err := serializeToProtobufMessage(routableEvent) + assert.NoError(t, err) + assert.Equal(t, serializedPayload.GetContentType(), ContentTypeProtobuf) + + // Deserialize + var deserializedEvent auditV1.ProtobufMessage + assert.NoError(t, proto.Unmarshal(serializedPayload.GetPayload(), &deserializedEvent)) + + expectedProtobufType := fmt.Sprintf("%v", routableEvent.ProtoReflect().Descriptor().FullName()) + assert.Equal(t, expectedProtobufType, deserializedEvent.ProtobufType) + + var deserializedRoutableEvent auditV1.RoutableAuditEvent + assert.NoError(t, proto.Unmarshal(deserializedEvent.Value, &deserializedRoutableEvent)) + assert.True(t, proto.Equal(routableEvent, &deserializedRoutableEvent)) +} + +func Test_Send_TopicNameResolverNil(t *testing.T) { + err := send(nil, nil, context.Background(), nil, nil) + assert.ErrorIs(t, err, ErrTopicNameResolverNil) +} + +func Test_Send_TopicNameResolutionError(t *testing.T) { + expectedError := errors.New("expected error") + + topicNameResolverMock := TopicNameResolverMock{} + topicNameResolverMock.On("Resolve", mock.Anything).Return("topic", expectedError) + var topicNameResolver TopicNameResolver = &topicNameResolverMock + + var serializedPayload SerializedPayload = &routablePayload{} + + var messagingApi MessagingApi = &AmqpMessagingApi{} + err := send(&topicNameResolver, &messagingApi, context.Background(), nil, &serializedPayload) + assert.ErrorIs(t, err, expectedError) +} + +func Test_Send_MessagingApiNil(t *testing.T) { + var topicNameResolver TopicNameResolver = &LegacyTopicNameResolver{topicName: "test"} + err := send(&topicNameResolver, nil, context.Background(), nil, nil) + assert.ErrorIs(t, err, ErrMessagingApiNil) +} + +func Test_Send_SerializedPayloadNil(t *testing.T) { + var topicNameResolver TopicNameResolver = &LegacyTopicNameResolver{topicName: "test"} + var messagingApi MessagingApi = &AmqpMessagingApi{} + err := send(&topicNameResolver, &messagingApi, context.Background(), nil, nil) + assert.ErrorIs(t, err, ErrSerializedPayloadNil) +} + +func Test_Send(t *testing.T) { + topicNameResolverMock := TopicNameResolverMock{} + topicNameResolverMock.On("Resolve", mock.Anything).Return("topic", nil) + var topicNameResolver TopicNameResolver = &topicNameResolverMock + + var serializedPayload SerializedPayload = &routablePayload{} + + messagingApiMock := MessagingApiMock{} + messagingApiMock.On("Send", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + var messagingApi MessagingApi = &messagingApiMock + + assert.NoError(t, send(&topicNameResolver, &messagingApi, context.Background(), nil, &serializedPayload)) + assert.True(t, messagingApiMock.AssertNumberOfCalls(t, "Send", 1)) +} diff --git a/api_legacy.go b/api_legacy.go new file mode 100644 index 0000000..b0a1d26 --- /dev/null +++ b/api_legacy.go @@ -0,0 +1,346 @@ +package main + +import ( + auditV1 "audit-schema/gen/go/audit/v1" + "context" + "encoding/json" + "errors" + "google.golang.org/protobuf/proto" + "time" +) + +// legacyPayload implements SerializedPayload. +type legacyPayload struct { + payload []byte +} + +// GetPayload implements SerializedPayload.GetPayload. +func (p *legacyPayload) GetPayload() []byte { + return p.payload +} + +// GetContentType implements SerializedPayload.GetContentType. +func (p *legacyPayload) GetContentType() string { + return "application/json" +} + +// LegacyTopicNameResolver implements TopicNameResolver. +// A hard-coded topic name is used, routing identifiers are ignored. +type LegacyTopicNameResolver struct { + topicName string +} + +// Resolve implements TopicNameResolver.Resolve +func (r *LegacyTopicNameResolver) Resolve(*RoutingIdentifier) (string, error) { + return r.topicName, nil +} + +// LegacyTopicNameConfig provides topic name information required for the topic name resolution. +type LegacyTopicNameConfig struct { + TopicName string +} + +// LegacyAuditApi is an implementation of AuditApi to send events to the legacy audit log system. +// +// Note: The implementation will be deprecated and replaced with the "routableAuditApi" once the new audit log routing is implemented +type LegacyAuditApi struct { + messagingApi *MessagingApi + topicNameResolver *TopicNameResolver + validator *ProtobufValidator +} + +// NewLegacyAuditApi can be used to initialize the audit log api with LegacyAuditLogConnectionDetails. +// +// Note: The NewLegacyAuditApi method will be deprecated and replaced with "newRoutableAuditApi" once the new audit log routing is implemented +func NewLegacyAuditApi(messagingApi *MessagingApi, topicNameConfig LegacyTopicNameConfig, validator ProtobufValidator) (*AuditApi, error) { + + if messagingApi == nil { + return nil, errors.New("messaging api nil") + } + + // Topic resolver + var topicNameResolver TopicNameResolver = &LegacyTopicNameResolver{topicName: topicNameConfig.TopicName} + + // Audit api + var auditApi AuditApi = &LegacyAuditApi{ + messagingApi: messagingApi, + topicNameResolver: &topicNameResolver, + validator: &validator, + } + + return &auditApi, nil +} + +// Log implements AuditApi.Log +func (a *LegacyAuditApi) Log(ctx context.Context, event *auditV1.AuditEvent, visibility auditV1.Visibility, routingIdentifier *RoutingIdentifier, objectIdentifier *auditV1.ObjectIdentifier) error { + + serializedPayload, err := a.ValidateAndSerialize(event, visibility, routingIdentifier, objectIdentifier) + if err != nil { + return err + } + + return a.Send(ctx, routingIdentifier, &serializedPayload) +} + +// ValidateAndSerialize implements AuditApi.ValidateAndSerialize. +// It serializes the event into the byte representation of the legacy audit log system. +func (a *LegacyAuditApi) ValidateAndSerialize(event *auditV1.AuditEvent, visibility auditV1.Visibility, routingIdentifier *RoutingIdentifier, objectIdentifier *auditV1.ObjectIdentifier) (SerializedPayload, error) { + routableEvent, err := validateAndSerializePartially(a.validator, event, visibility, routingIdentifier, objectIdentifier) + if err != nil { + return nil, err + } + + // Do nothing with the serialized data in the legacy solution + _, err = proto.Marshal(routableEvent) + if err != nil { + return nil, err + } + + // Convert attributes + legacyBytes, err := a.convertAndSerializeIntoLegacyFormat(event, routableEvent) + if err != nil { + return nil, err + } + + return &legacyPayload{payload: legacyBytes}, nil +} + +// Send implements AuditApi.Send +func (a *LegacyAuditApi) Send(ctx context.Context, routingIdentifier *RoutingIdentifier, serializedPayload *SerializedPayload) error { + return send(a.topicNameResolver, a.messagingApi, ctx, routingIdentifier, serializedPayload) +} + +// convertAndSerializeIntoLegacyFormat converts the protobuf events into the json serialized legacy audit log format +func (a *LegacyAuditApi) convertAndSerializeIntoLegacyFormat( + event *auditV1.AuditEvent, + routableEvent *auditV1.RoutableAuditEvent, +) ([]byte, error) { + + // Source IP & User agent + var sourceIpAddress string + var userAgent string + if event.Request == nil { + sourceIpAddress = "0.0.0.0" + userAgent = "none" + } else { + sourceIpAddress = event.Request.GetSourceIpAddress() + userAgent = event.Request.GetUserAgent() + } + + // Principals + var serviceAccountDelegationInfo *LegacyAuditEventServiceAccountDelegationInfo = nil + if len(event.Principals) > 0 { + var principals []LegacyAuditEventPrincipal + for _, principal := range event.Principals { + if principal != nil { + p := LegacyAuditEventPrincipal{ + Id: principal.Id, + Email: principal.Email, + } + principals = append(principals, p) + } + } + serviceAccountDelegationInfo = &LegacyAuditEventServiceAccountDelegationInfo{Principals: principals} + } + + // Request + var request LegacyAuditEventRequest + if event.Request == nil { + request = LegacyAuditEventRequest{ + Endpoint: "none", + } + } else { + var parameters map[string]interface{} = nil + if event.Request.Parameters != nil { + parameters = event.Request.Parameters.AsMap() + } + + var body map[string]interface{} = nil + if event.Request.Body != nil { + body = event.Request.Body.AsMap() + } + var headers map[string]interface{} = nil + if event.Request.Headers != nil { + headers = map[string]interface{}{} + for _, header := range event.Request.Headers { + if header != nil { + headers[header.Key] = header.Value + } + } + } + + request = LegacyAuditEventRequest{ + Endpoint: event.Request.Endpoint, + Parameters: ¶meters, + Body: &body, + Headers: &headers, + } + } + + // Context and event type + var messageContext *LegacyAuditEventContext + var eventType string + switch ref := routableEvent.GetResourceReference().(type) { + case *auditV1.RoutableAuditEvent_ObjectIdentifier: + eventType = "ADMIN_ACTIVITY" + if ref.ObjectIdentifier.Type == auditV1.ObjectType_OBJECT_TYPE_ORGANIZATION { + messageContext = &LegacyAuditEventContext{ + OrganizationId: nil, + FolderId: nil, + ProjectId: nil, + } + + } else if ref.ObjectIdentifier.Type == auditV1.ObjectType_OBJECT_TYPE_FOLDER { + messageContext = &LegacyAuditEventContext{ + OrganizationId: nil, + FolderId: nil, + ProjectId: nil, + } + } else if ref.ObjectIdentifier.Type == auditV1.ObjectType_OBJECT_TYPE_PROJECT { + messageContext = &LegacyAuditEventContext{ + OrganizationId: nil, + FolderId: nil, + ProjectId: nil, + } + } else { + return nil, ErrUnsupportedObjectIdentifierType + } + break + case *auditV1.RoutableAuditEvent_ObjectName: + eventType = "SYSTEM_EVENT" + messageContext = nil + break + default: + return nil, ErrUnsupportedResourceReferenceType + } + + // Details + var details map[string]interface{} = nil + if event.Details != nil { + details = event.Details.AsMap() + } + + // Result + var result map[string]interface{} = nil + if event.Result != nil { + result = event.Result.AsMap() + } + + // Instantiate the legacy event - missing values are filled with defaults + legacyAuditEvent := LegacyAuditEvent{ + Severity: "INFO", + Visibility: routableEvent.Visibility.String(), + EventType: eventType, + EventTimeStamp: event.EventTimeStamp.AsTime(), + EventName: event.EventName, + SourceIpAddress: sourceIpAddress, + UserAgent: userAgent, + Initiator: LegacyAuditEventPrincipal{ + Id: event.Initiator.Id, + Email: event.Initiator.Email, + }, + ServiceAccountDelegationInfo: serviceAccountDelegationInfo, + Request: request, + Context: messageContext, + ResourceId: event.ResourceId, + ResourceName: event.ResourceName, + CorrelationId: event.CorrelationId, + Result: &result, + Details: &details, + } + + bytes, err := json.Marshal(legacyAuditEvent) + if err != nil { + return nil, err + } + + return bytes, nil +} + +// LegacyAuditEvent has the format as follows: +/* +{ + "severity": "INFO", + "visibility": "PUBLIC", + "eventType": "ADMIN_ACTIVITY", + "eventTimeStamp": "2019-08-24T14:15:22Z", + "eventName": "Create organization", + "sourceIpAddress": "127.0.0.1", + "userAgent": "CLI", + "initiator": { + "id": "string", + "email": "user@example.com" + }, + "serviceAccountDelegationInfo": { + "principals": [ + { + "id": "string", + "email": "user@example.com" + } + ] + }, + "request": { + "endpoint": "string", + "parameters": {}, + "body": {}, + "headers": { + "Content-Type": "application/json" + } + }, + "context": { + "organizationId": "string", + "folderId": "string", + "projectId": "string" + }, + "resourceId": "string", + "resourceName": "string", + "correlationId": "string", + "result": {}, + "details": {} +} +*/ +type LegacyAuditEvent struct { + Severity string `json:"severity"` + Visibility string `json:"visibility"` + EventType string `json:"eventType"` + EventTimeStamp time.Time `json:"eventTimeStamp"` + EventName string `json:"eventName"` + SourceIpAddress string `json:"sourceIpAddress"` + UserAgent string `json:"userAgent"` + Initiator LegacyAuditEventPrincipal `json:"initiator"` + ServiceAccountDelegationInfo *LegacyAuditEventServiceAccountDelegationInfo `json:"serviceAccountDelegationInfo"` + Request LegacyAuditEventRequest `json:"request"` + Context *LegacyAuditEventContext `json:"context"` + ResourceId *string `json:"resourceId"` + ResourceName *string `json:"resourceName"` + CorrelationId *string `json:"correlationId"` + Result *map[string]interface{} `json:"result"` + Details *map[string]interface{} `json:"details"` +} + +// LegacyAuditEventPrincipal is a representation for a principal's id (+optional email) information. +type LegacyAuditEventPrincipal struct { + Id string `json:"id"` + Email *string `json:"email"` +} + +// LegacyAuditEventServiceAccountDelegationInfo contains information about service account delegation. +type LegacyAuditEventServiceAccountDelegationInfo struct { + Principals []LegacyAuditEventPrincipal `json:"principals"` +} + +// LegacyAuditEventRequest contains request information, which mirrors the action of the user and +// the resulting changes within the system. +type LegacyAuditEventRequest struct { + Endpoint string `json:"endpoint"` + Parameters *map[string]interface{} `json:"parameters"` + Body *map[string]interface{} `json:"body"` + Headers *map[string]interface{} `json:"headers"` +} + +// LegacyAuditEventContext contains optional context information. +type LegacyAuditEventContext struct { + OrganizationId *string `json:"organizationId"` + FolderId *string `json:"folderId"` + ProjectId *string `json:"projectId"` +} diff --git a/api_legacy_test.go b/api_legacy_test.go new file mode 100644 index 0000000..0465370 --- /dev/null +++ b/api_legacy_test.go @@ -0,0 +1,486 @@ +package main + +import ( + auditV1 "audit-schema/gen/go/audit/v1" + "context" + "encoding/json" + "errors" + "github.com/Azure/go-amqp" + "github.com/bufbuild/protovalidate-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "log/slog" + "os" + "testing" + "time" +) + +func TestLegacyAuditApi(t *testing.T) { + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) + + // Specify test timeout + ctx, cancelFn := context.WithTimeout(context.Background(), 120*time.Second) + defer cancelFn() + + // Start solace docker container + solaceContainer, err := NewSolaceContainer(context.Background()) + assert.NoError(t, err) + defer solaceContainer.Stop() + + // Instantiate the messaging api + messagingApi, err := NewAmqpMessagingApi(AmqpConfig{URL: solaceContainer.AmqpConnectionString}) + assert.NoError(t, err) + + // Validator + validator, err := protovalidate.New() + assert.NoError(t, err) + + topicSubscriptionTopicPattern := "audit-log/>" + + // Check logging of organization events + t.Run("Log public organization event", func(t *testing.T) { + defer solaceContainer.StopOnError() + + slog.Info("test abc") + + // Create the queue and topic subscription in solace + queueName := "org-event-public-legacy" + assert.NoError(t, solaceContainer.QueueCreate(ctx, queueName)) + assert.NoError(t, solaceContainer.TopicSubscriptionCreate(ctx, queueName, topicSubscriptionTopicPattern)) + + topicName := "topic://audit-log/eu01/v1/resource-manager/organization-created" + assert.NoError(t, solaceContainer.ValidateTopicName(topicSubscriptionTopicPattern, topicName)) + + // Instantiate audit api + auditApi, err := NewLegacyAuditApi( + messagingApi, + LegacyTopicNameConfig{TopicName: topicName}, + validator, + ) + assert.NoError(t, err) + + // Instantiate test data + event, routingIdentifier, objectIdentifier := NewOrganizationAuditEvent(nil) + + // Log the event to solace + visibility := auditV1.Visibility_VISIBILITY_PUBLIC + assert.NoError(t, (*auditApi).Log( + ctx, + event, + visibility, + routingIdentifier, + objectIdentifier, + )) + + message, err := solaceContainer.NextMessageFromQueue(ctx, queueName, true) + assert.NoError(t, err) + + validateSentMessage(t, topicName, message, event) + }) + + t.Run("Log private organization event", func(t *testing.T) { + defer solaceContainer.StopOnError() + + // Create the queue and topic subscription in solace + queueName := "org-event-private-legacy" + assert.NoError(t, solaceContainer.QueueCreate(ctx, queueName)) + assert.NoError(t, solaceContainer.TopicSubscriptionCreate(ctx, queueName, topicSubscriptionTopicPattern)) + + topicName := "topic://audit-log/eu01/v1/resource-manager/organization-created" + assert.NoError(t, solaceContainer.ValidateTopicName(topicSubscriptionTopicPattern, topicName)) + + // Instantiate audit api + auditApi, err := NewLegacyAuditApi( + messagingApi, + LegacyTopicNameConfig{TopicName: topicName}, + validator, + ) + assert.NoError(t, err) + + // Instantiate test data + event, routingIdentifier, objectIdentifier := NewOrganizationAuditEvent(nil) + + // Log the event to solace + visibility := auditV1.Visibility_VISIBILITY_PRIVATE + assert.NoError(t, (*auditApi).Log( + ctx, + event, + visibility, + routingIdentifier, + objectIdentifier, + )) + + message, err := solaceContainer.NextMessageFromQueue(ctx, queueName, true) + assert.NoError(t, err) + + validateSentMessage(t, topicName, message, event) + }) + + // Check logging of folder events + t.Run("Log public folder event", func(t *testing.T) { + defer solaceContainer.StopOnError() + + // Create the queue and topic subscription in solace + queueName := "folder-event-public-legacy" + assert.NoError(t, solaceContainer.QueueCreate(ctx, queueName)) + assert.NoError(t, solaceContainer.TopicSubscriptionCreate(ctx, queueName, topicSubscriptionTopicPattern)) + + topicName := "topic://audit-log/eu01/v1/resource-manager/folder-created" + assert.NoError(t, solaceContainer.ValidateTopicName(topicSubscriptionTopicPattern, topicName)) + + // Instantiate audit api + auditApi, err := NewLegacyAuditApi( + messagingApi, + LegacyTopicNameConfig{TopicName: topicName}, + validator, + ) + assert.NoError(t, err) + + // Instantiate test data + event, routingIdentifier, objectIdentifier := NewFolderAuditEvent(nil) + + // Log the event to solace + visibility := auditV1.Visibility_VISIBILITY_PUBLIC + assert.NoError(t, (*auditApi).Log( + ctx, + event, + visibility, + routingIdentifier, + objectIdentifier, + )) + + message, err := solaceContainer.NextMessageFromQueue(ctx, queueName, true) + assert.NoError(t, err) + + validateSentMessage(t, topicName, message, event) + }) + + t.Run("Log private folder event", func(t *testing.T) { + defer solaceContainer.StopOnError() + + // Create the queue and topic subscription in solace + queueName := "folder-event-private-legacy" + assert.NoError(t, solaceContainer.QueueCreate(ctx, queueName)) + assert.NoError(t, solaceContainer.TopicSubscriptionCreate(ctx, queueName, topicSubscriptionTopicPattern)) + + topicName := "topic://audit-log/eu01/v1/resource-manager/folder-created" + assert.NoError(t, solaceContainer.ValidateTopicName(topicSubscriptionTopicPattern, topicName)) + + // Instantiate audit api + auditApi, err := NewLegacyAuditApi( + messagingApi, + LegacyTopicNameConfig{TopicName: topicName}, + validator, + ) + assert.NoError(t, err) + + // Instantiate test data + event, routingIdentifier, objectIdentifier := NewFolderAuditEvent(nil) + + // Log the event to solace + visibility := auditV1.Visibility_VISIBILITY_PRIVATE + assert.NoError(t, (*auditApi).Log( + ctx, + event, + visibility, + routingIdentifier, + objectIdentifier, + )) + + message, err := solaceContainer.NextMessageFromQueue(ctx, queueName, true) + assert.NoError(t, err) + + validateSentMessage(t, topicName, message, event) + }) + + // Check logging of project events + t.Run("Log public project event", func(t *testing.T) { + defer solaceContainer.StopOnError() + + // Create the queue and topic subscription in solace + queueName := "project-event-public-legacy" + assert.NoError(t, solaceContainer.QueueCreate(ctx, queueName)) + assert.NoError(t, solaceContainer.TopicSubscriptionCreate(ctx, queueName, topicSubscriptionTopicPattern)) + + topicName := "topic://audit-log/eu01/v1/resource-manager/project-created" + assert.NoError(t, solaceContainer.ValidateTopicName(topicSubscriptionTopicPattern, topicName)) + + // Instantiate audit api + auditApi, err := NewLegacyAuditApi( + messagingApi, + LegacyTopicNameConfig{TopicName: topicName}, + validator, + ) + assert.NoError(t, err) + + // Instantiate test data + event, routingIdentifier, objectIdentifier := NewProjectAuditEvent(nil) + + // Log the event to solace + visibility := auditV1.Visibility_VISIBILITY_PUBLIC + assert.NoError(t, (*auditApi).Log( + ctx, + event, + visibility, + routingIdentifier, + objectIdentifier, + )) + + message, err := solaceContainer.NextMessageFromQueue(ctx, queueName, true) + assert.NoError(t, err) + + validateSentMessage(t, topicName, message, event) + }) + + t.Run("Log private project event", func(t *testing.T) { + defer solaceContainer.StopOnError() + + // Create the queue and topic subscription in solace + queueName := "project-event-private-legacy" + assert.NoError(t, solaceContainer.QueueCreate(ctx, queueName)) + assert.NoError(t, solaceContainer.TopicSubscriptionCreate(ctx, queueName, topicSubscriptionTopicPattern)) + + topicName := "topic://audit-log/eu01/v1/resource-manager/project-created" + assert.NoError(t, solaceContainer.ValidateTopicName(topicSubscriptionTopicPattern, topicName)) + + // Instantiate audit api + auditApi, err := NewLegacyAuditApi( + messagingApi, + LegacyTopicNameConfig{TopicName: topicName}, + validator, + ) + assert.NoError(t, err) + + // Instantiate test data + event, routingIdentifier, objectIdentifier := NewProjectAuditEvent(nil) + + // Log the event to solace + visibility := auditV1.Visibility_VISIBILITY_PRIVATE + assert.NoError(t, (*auditApi).Log( + ctx, + event, + visibility, + routingIdentifier, + objectIdentifier, + )) + + message, err := solaceContainer.NextMessageFromQueue(ctx, queueName, true) + assert.NoError(t, err) + + validateSentMessage(t, topicName, message, event) + }) + + // Check logging of system events + t.Run("Log private system event", func(t *testing.T) { + defer solaceContainer.StopOnError() + + queueName := "system-event-private" + assert.NoError(t, solaceContainer.QueueCreate(ctx, queueName)) + assert.NoError(t, solaceContainer.TopicSubscriptionCreate(ctx, queueName, topicSubscriptionTopicPattern)) + + topicName := "topic://audit-log/eu01/v1/resource-manager/system-changed" + assert.NoError(t, solaceContainer.ValidateTopicName(topicSubscriptionTopicPattern, topicName)) + + // Instantiate audit api + auditApi, err := NewLegacyAuditApi( + messagingApi, + LegacyTopicNameConfig{TopicName: topicName}, + validator, + ) + assert.NoError(t, err) + + // Instantiate test data + event := NewSystemAuditEvent(nil) + + // Log the event to solace + visibility := auditV1.Visibility_VISIBILITY_PRIVATE + assert.NoError(t, + (*auditApi).Log( + ctx, + event, + visibility, + nil, + nil, + )) + + // Receive the event from solace + message, err := solaceContainer.NextMessageFromQueue(ctx, queueName, true) + assert.NoError(t, err) + + // Check topic name + assert.Equal(t, topicName, *message.Properties.To) + + // Check topic name + assert.Equal(t, topicName, *message.Properties.To) + + // Check deserialized message + var auditEvent LegacyAuditEvent + assert.NoError(t, json.Unmarshal(message.Data[0], &auditEvent)) + + assert.Equal(t, event.EventName, auditEvent.EventName) + assert.Equal(t, event.EventTimeStamp.AsTime(), auditEvent.EventTimeStamp) + assert.Equal(t, event.Initiator.Id, auditEvent.Initiator.Id) + assert.Equal(t, "SYSTEM_EVENT", auditEvent.EventType) + assert.Equal(t, "INFO", auditEvent.Severity) + assert.Equal(t, "none", auditEvent.Request.Endpoint) + assert.Equal(t, "0.0.0.0", auditEvent.SourceIpAddress) + assert.Equal(t, "none", auditEvent.UserAgent) + }) + + t.Run("Log event with details", func(t *testing.T) { + defer solaceContainer.StopOnError() + + // Create the queue and topic subscription in solace + queueName := "org-event-with-details-legacy" + assert.NoError(t, solaceContainer.QueueCreate(ctx, queueName)) + assert.NoError(t, solaceContainer.TopicSubscriptionCreate(ctx, queueName, topicSubscriptionTopicPattern)) + + topicName := "topic://audit-log/eu01/v1/resource-manager/organization-created" + assert.NoError(t, solaceContainer.ValidateTopicName(topicSubscriptionTopicPattern, topicName)) + + // Instantiate audit api + auditApi, err := NewLegacyAuditApi( + messagingApi, + LegacyTopicNameConfig{TopicName: topicName}, + validator, + ) + assert.NoError(t, err) + + // Instantiate test data + event, routingIdentifier, objectIdentifier := NewOrganizationAuditEventWithDetails() + + // Log the event to solace + visibility := auditV1.Visibility_VISIBILITY_PUBLIC + assert.NoError(t, (*auditApi).Log( + ctx, + event, + visibility, + routingIdentifier, + objectIdentifier, + )) + + message, err := solaceContainer.NextMessageFromQueue(ctx, queueName, true) + assert.NoError(t, err) + + validateSentMessageWithDetails(t, topicName, message, event) + }) +} + +func validateSentMessage(t *testing.T, topicName string, message *amqp.Message, event *auditV1.AuditEvent) { + // Check topic name + assert.Equal(t, topicName, *message.Properties.To) + + // Check deserialized message + var auditEvent LegacyAuditEvent + assert.NoError(t, json.Unmarshal(message.Data[0], &auditEvent)) + + assert.Equal(t, event.EventName, auditEvent.EventName) + assert.Equal(t, event.EventTimeStamp.AsTime(), auditEvent.EventTimeStamp) + assert.Equal(t, event.Initiator.Id, auditEvent.Initiator.Id) + assert.Equal(t, "ADMIN_ACTIVITY", auditEvent.EventType) + assert.Equal(t, "INFO", auditEvent.Severity) + assert.Equal(t, "none", auditEvent.Request.Endpoint) + assert.Equal(t, "0.0.0.0", auditEvent.SourceIpAddress) + assert.Equal(t, "none", auditEvent.UserAgent) +} + +func validateSentMessageWithDetails(t *testing.T, topicName string, message *amqp.Message, event *auditV1.AuditEvent) { + // Check topic name + assert.Equal(t, topicName, *message.Properties.To) + + // Check deserialized message + var auditEvent LegacyAuditEvent + assert.NoError(t, json.Unmarshal(message.Data[0], &auditEvent)) + + assert.Equal(t, event.EventName, auditEvent.EventName) + assert.Equal(t, event.EventTimeStamp.AsTime(), auditEvent.EventTimeStamp) + assert.Equal(t, event.Initiator.Id, auditEvent.Initiator.Id) + assert.Equal(t, "ADMIN_ACTIVITY", auditEvent.EventType) + assert.Equal(t, "INFO", auditEvent.Severity) + assert.Equal(t, event.Request.Endpoint, auditEvent.Request.Endpoint) + assert.Equal(t, event.Request.SourceIpAddress, auditEvent.SourceIpAddress) + assert.Equal(t, *event.Request.UserAgent, auditEvent.UserAgent) + assert.Equal(t, event.Request.Body.AsMap(), *auditEvent.Request.Body) + assert.Equal(t, event.Request.Parameters.AsMap(), *auditEvent.Request.Parameters) + assert.Equal(t, event.Details.AsMap(), *auditEvent.Details) + assert.Equal(t, event.Result.AsMap(), *auditEvent.Result) + for _, header := range event.Request.Headers { + assert.Equal(t, header.Value, (*auditEvent.Request.Headers)[header.Key]) + } + + for idx, _ := range event.Principals { + assert.Equal(t, event.Principals[idx].Id, auditEvent.ServiceAccountDelegationInfo.Principals[idx].Id) + assert.Equal(t, event.Principals[idx].Email, auditEvent.ServiceAccountDelegationInfo.Principals[idx].Email) + } +} + +func TestLegacyAuditApi_NewLegacyAuditApi_MessagingApiNil(t *testing.T) { + auditApi, err := NewLegacyAuditApi(nil, LegacyTopicNameConfig{}, nil) + assert.Nil(t, auditApi) + assert.EqualError(t, err, "messaging api nil") +} + +func TestLegacyAuditApi_ValidateAndSerialize_ValidationFailed(t *testing.T) { + expectedError := errors.New("expected error") + + validator := &ProtobufValidatorMock{} + validator.On("Validate", mock.Anything).Return(expectedError) + var protobufValidator ProtobufValidator = validator + + auditApi := LegacyAuditApi{validator: &protobufValidator} + + event := NewSystemAuditEvent(nil) + _, err := auditApi.ValidateAndSerialize(event, auditV1.Visibility_VISIBILITY_PUBLIC, nil, nil) + assert.ErrorIs(t, err, expectedError) +} + +func TestLegacyAuditApi_Log_ValidationFailed(t *testing.T) { + expectedError := errors.New("expected error") + + validator := &ProtobufValidatorMock{} + validator.On("Validate", mock.Anything).Return(expectedError) + var protobufValidator ProtobufValidator = validator + + auditApi := LegacyAuditApi{validator: &protobufValidator} + + event := NewSystemAuditEvent(nil) + err := auditApi.Log(context.Background(), event, auditV1.Visibility_VISIBILITY_PUBLIC, nil, nil) + assert.ErrorIs(t, err, expectedError) +} + +func TestLegacyAuditApi_Log_NilEvent(t *testing.T) { + auditApi := LegacyAuditApi{} + err := auditApi.Log(context.Background(), nil, auditV1.Visibility_VISIBILITY_PUBLIC, nil, nil) + assert.ErrorIs(t, err, ErrEventNil) +} + +func TestLegacyAuditApi_ConvertAndSerializeIntoLegacyFormatInvalidObjectIdentifierType(t *testing.T) { + customization := func(event *auditV1.AuditEvent, + routingIdentifier *RoutingIdentifier, + objectIdentifier *auditV1.ObjectIdentifier) { + objectIdentifier.Type = 4 + } + event, identifier, objectIdentifier := NewProjectAuditEvent(&customization) + + validator := &ProtobufValidatorMock{} + validator.On("Validate", mock.Anything).Return(nil) + var protobufValidator ProtobufValidator = validator + + auditApi := LegacyAuditApi{validator: &protobufValidator} + _, err := auditApi.ValidateAndSerialize(event, auditV1.Visibility_VISIBILITY_PUBLIC, identifier, objectIdentifier) + assert.ErrorIs(t, err, ErrUnsupportedObjectIdentifierType) +} + +func TestLegacyAuditApi_ConvertAndSerializeIntoLegacyFormat_NoResourceReference(t *testing.T) { + event, _, _ := NewProjectAuditEvent(nil) + routableEvent := auditV1.RoutableAuditEvent{ + EventName: event.EventName, + Visibility: auditV1.Visibility_VISIBILITY_PUBLIC, + ResourceReference: nil, + Data: nil, + } + + auditApi := LegacyAuditApi{} + _, err := auditApi.convertAndSerializeIntoLegacyFormat(event, &routableEvent) + assert.ErrorIs(t, err, ErrUnsupportedResourceReferenceType) +} diff --git a/api_mock.go b/api_mock.go new file mode 100644 index 0000000..96ecf49 --- /dev/null +++ b/api_mock.go @@ -0,0 +1,51 @@ +package main + +import ( + auditV1 "audit-schema/gen/go/audit/v1" + "context" + "github.com/bufbuild/protovalidate-go" +) + +// MockAuditApi is an implementation of AuditApi that does nothing and has no dependency to external systems. +type MockAuditApi struct { + validator *ProtobufValidator +} + +func NewMockAuditApi() (*AuditApi, error) { + validator, err := protovalidate.New() + if err != nil { + return nil, err + } + var protobufValidator ProtobufValidator = validator + var auditApi AuditApi = &MockAuditApi{validator: &protobufValidator} + return &auditApi, nil +} + +// Log implements AuditApi.Log. +// Validates and serializes the event but doesn't send it. +func (a *MockAuditApi) Log( + _ context.Context, + event *auditV1.AuditEvent, + visibility auditV1.Visibility, + routingIdentifier *RoutingIdentifier, + objectIdentifier *auditV1.ObjectIdentifier, +) error { + + _, err := a.ValidateAndSerialize(event, visibility, routingIdentifier, objectIdentifier) + return err +} + +// ValidateAndSerialize implements AuditApi.ValidateAndSerialize +func (a *MockAuditApi) ValidateAndSerialize(event *auditV1.AuditEvent, visibility auditV1.Visibility, routingIdentifier *RoutingIdentifier, objectIdentifier *auditV1.ObjectIdentifier) (SerializedPayload, error) { + routableEvent, err := validateAndSerializePartially(a.validator, event, visibility, routingIdentifier, objectIdentifier) + if err != nil { + return nil, err + } + + return serializeToProtobufMessage(routableEvent) +} + +// Send implements AuditApi.Send +func (a *MockAuditApi) Send(context.Context, *RoutingIdentifier, *SerializedPayload) error { + return nil +} diff --git a/api_mock_test.go b/api_mock_test.go new file mode 100644 index 0000000..726b0bd --- /dev/null +++ b/api_mock_test.go @@ -0,0 +1,41 @@ +package main + +import ( + auditV1 "audit-schema/gen/go/audit/v1" + "context" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestMockAuditApi_Log(t *testing.T) { + + auditApi, err := NewMockAuditApi() + assert.NoError(t, err) + + // Instantiate test data + event, routingIdentifier, objectIdentifier := NewOrganizationAuditEvent(nil) + + // Test + t.Run("Log", func(t *testing.T) { + assert.Nil(t, (*auditApi).Log(context.Background(), event, auditV1.Visibility_VISIBILITY_PUBLIC, routingIdentifier, objectIdentifier)) + }) + + t.Run("ValidateAndSerialize", func(t *testing.T) { + visibility := auditV1.Visibility_VISIBILITY_PUBLIC + serializedPayload, err := (*auditApi).ValidateAndSerialize(event, visibility, routingIdentifier, objectIdentifier) + assert.NoError(t, err) + + validateProtobufMessagePayload(t, serializedPayload.GetPayload(), routingIdentifier, objectIdentifier, event, event.EventName, visibility) + }) + + t.Run("ValidateAndSerialize event nil", func(t *testing.T) { + visibility := auditV1.Visibility_VISIBILITY_PUBLIC + _, err := (*auditApi).ValidateAndSerialize(nil, visibility, routingIdentifier, objectIdentifier) + assert.ErrorIs(t, err, ErrEventNil) + }) + + t.Run("Send", func(t *testing.T) { + var payload SerializedPayload = &routablePayload{} + assert.Nil(t, (*auditApi).Send(context.Background(), routingIdentifier, &payload)) + }) +} diff --git a/api_routable.go b/api_routable.go new file mode 100644 index 0000000..48385e1 --- /dev/null +++ b/api_routable.go @@ -0,0 +1,114 @@ +package main + +import ( + auditV1 "audit-schema/gen/go/audit/v1" + "context" + "errors" + "fmt" +) + +// routablePayload implements SerializedPayload. +type routablePayload struct { + payload []byte + contentType string +} + +// GetPayload implements SerializedPayload.GetPayload. +func (p *routablePayload) GetPayload() []byte { + return p.payload +} + +// GetContentType implements SerializedPayload.GetContentType. +func (p *routablePayload) GetContentType() string { + return p.contentType +} + +// routableTopicNameResolver implements TopicNameResolver. +// Resolves topic names by concatenating topic type prefixes with routing identifiers. +type routableTopicNameResolver struct { + organizationTopicPrefix string + ProjectTopicPrefix string + // If no identifier is provided for routing, it will be routed to a system topic + systemTopicName string +} + +// Resolve implements TopicNameResolver.Resolve. +func (r *routableTopicNameResolver) Resolve(routingIdentifier *RoutingIdentifier) (string, error) { + if routingIdentifier == nil { + return r.systemTopicName, nil + } + switch routingIdentifier.Type { + case RoutingIdentifierTypeOrganization: + return fmt.Sprintf("topic://%s/%s", r.organizationTopicPrefix, routingIdentifier.Identifier), nil + case RoutingIdentifierTypeProject: + return fmt.Sprintf("topic://%s/%s", r.ProjectTopicPrefix, routingIdentifier.Identifier), nil + default: + return "", ErrUnsupportedObjectIdentifierType + } +} + +// topicNameConfig provides topic name information required for the topic name resolution. +type topicNameConfig struct { + OrganizationTopicPrefix string + ProjectTopicPrefix string + SystemTopicName string +} + +// routableAuditApi is an implementation of AuditApi. +// Warning: It is only there for local (compatibility) testing. +// DO NOT USE IT! +type routableAuditApi struct { + messagingApi *MessagingApi + topicNameResolver *TopicNameResolver + validator *ProtobufValidator +} + +// NewRoutableAuditApi can be used to initialize the audit log api. +func newRoutableAuditApi(messagingApi *MessagingApi, topicNameConfig topicNameConfig, validator ProtobufValidator) (*AuditApi, error) { + + if messagingApi == nil { + return nil, errors.New("messaging api nil") + } + + // Topic resolver + var topicNameResolver TopicNameResolver = &routableTopicNameResolver{ + organizationTopicPrefix: topicNameConfig.OrganizationTopicPrefix, + ProjectTopicPrefix: topicNameConfig.ProjectTopicPrefix, + systemTopicName: topicNameConfig.SystemTopicName, + } + + // Audit api + var auditApi AuditApi = &routableAuditApi{ + messagingApi: messagingApi, + topicNameResolver: &topicNameResolver, + validator: &validator, + } + + return &auditApi, nil +} + +// Log implements AuditApi.Log +func (a *routableAuditApi) Log(ctx context.Context, event *auditV1.AuditEvent, visibility auditV1.Visibility, routingIdentifier *RoutingIdentifier, objectIdentifier *auditV1.ObjectIdentifier) error { + + serializedPayload, err := a.ValidateAndSerialize(event, visibility, routingIdentifier, objectIdentifier) + if err != nil { + return err + } + + return a.Send(ctx, routingIdentifier, &serializedPayload) +} + +// ValidateAndSerialize implements AuditApi.ValidateAndSerialize +func (a *routableAuditApi) ValidateAndSerialize(event *auditV1.AuditEvent, visibility auditV1.Visibility, routingIdentifier *RoutingIdentifier, objectIdentifier *auditV1.ObjectIdentifier) (SerializedPayload, error) { + routableEvent, err := validateAndSerializePartially(a.validator, event, visibility, routingIdentifier, objectIdentifier) + if err != nil { + return nil, err + } + + return serializeToProtobufMessage(routableEvent) +} + +// Send implements AuditApi.Send +func (a *routableAuditApi) Send(ctx context.Context, routingIdentifier *RoutingIdentifier, serializedPayload *SerializedPayload) error { + return send(a.topicNameResolver, a.messagingApi, ctx, routingIdentifier, serializedPayload) +} diff --git a/api_routable_test.go b/api_routable_test.go new file mode 100644 index 0000000..45ff26f --- /dev/null +++ b/api_routable_test.go @@ -0,0 +1,396 @@ +package main + +import ( + auditV1 "audit-schema/gen/go/audit/v1" + "context" + "errors" + "fmt" + "github.com/Azure/go-amqp" + "github.com/bufbuild/protovalidate-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "google.golang.org/protobuf/proto" + "testing" + "time" +) + +func TestRoutableAuditApi(t *testing.T) { + + // Specify test timeout + ctx, cancelFn := context.WithTimeout(context.Background(), 120*time.Second) + defer cancelFn() + + // Start solace docker container + solaceContainer, err := NewSolaceContainer(context.Background()) + assert.NoError(t, err) + defer solaceContainer.Stop() + + // Instantiate the messaging api + messagingApi, err := NewAmqpMessagingApi(AmqpConfig{URL: solaceContainer.AmqpConnectionString}) + assert.NoError(t, err) + + // Validator + validator, err := protovalidate.New() + assert.NoError(t, err) + + // Instantiate the audit api + organizationTopicPrefix := "org" + projectTopicPrefix := "project" + systemTopicName := "topic://system/admin-events" + auditApi, err := newRoutableAuditApi( + messagingApi, + topicNameConfig{OrganizationTopicPrefix: organizationTopicPrefix, ProjectTopicPrefix: projectTopicPrefix, SystemTopicName: systemTopicName}, + validator, + ) + assert.NoError(t, err) + + // Check logging of organization events + t.Run("Log public organization event", func(t *testing.T) { + defer solaceContainer.StopOnError() + + // Create the queue and topic subscription in solace + queueName := "org-event-public" + assert.NoError(t, solaceContainer.QueueCreate(ctx, queueName)) + assert.NoError(t, solaceContainer.TopicSubscriptionCreate(ctx, queueName, "org/*")) + + // Instantiate test data + event, routingIdentifier, objectIdentifier := NewOrganizationAuditEvent(nil) + + // Log the event to solace + visibility := auditV1.Visibility_VISIBILITY_PUBLIC + assert.NoError(t, (*auditApi).Log( + ctx, + event, + visibility, + routingIdentifier, + objectIdentifier, + )) + + message, err := solaceContainer.NextMessageFromQueue(ctx, queueName, true) + assert.NoError(t, err) + + validateSentEvent(t, organizationTopicPrefix, message, routingIdentifier, objectIdentifier, event, "ORGANIZATION_CREATED", visibility) + }) + + t.Run("Log private organization event", func(t *testing.T) { + defer solaceContainer.StopOnError() + + queueName := "org-event-private" + assert.NoError(t, solaceContainer.QueueCreate(ctx, queueName)) + assert.NoError(t, solaceContainer.TopicSubscriptionCreate(ctx, queueName, "org/*")) + + // Instantiate test data + event, routingIdentifier, objectIdentifier := NewOrganizationAuditEvent(nil) + assert.NoError(t, solaceContainer.TopicSubscriptionCreate(ctx, queueName, fmt.Sprintf("org/%s", routingIdentifier.Identifier))) + + // Log the event to solace + visibility := auditV1.Visibility_VISIBILITY_PRIVATE + assert.NoError(t, + (*auditApi).Log( + ctx, + event, + visibility, + routingIdentifier, + objectIdentifier, + )) + + // Receive the event from solace + message, err := solaceContainer.NextMessageFromQueue(ctx, queueName, true) + assert.NoError(t, err) + + validateSentEvent(t, organizationTopicPrefix, message, routingIdentifier, objectIdentifier, event, "ORGANIZATION_CREATED", visibility) + }) + + // Check logging of folder events + t.Run("Log public folder event", func(t *testing.T) { + defer solaceContainer.StopOnError() + + // Create the queue and topic subscription in solace + queueName := "folder-event-public" + assert.NoError(t, solaceContainer.QueueCreate(ctx, queueName)) + assert.NoError(t, solaceContainer.TopicSubscriptionCreate(ctx, queueName, "org/*")) + + // Instantiate test data + event, routingIdentifier, objectIdentifier := NewFolderAuditEvent(nil) + + // Log the event to solace + visibility := auditV1.Visibility_VISIBILITY_PUBLIC + assert.NoError(t, (*auditApi).Log( + ctx, + event, + visibility, + routingIdentifier, + objectIdentifier, + )) + + message, err := solaceContainer.NextMessageFromQueue(ctx, queueName, true) + assert.NoError(t, err) + + validateSentEvent(t, organizationTopicPrefix, message, routingIdentifier, objectIdentifier, event, "FOLDER_CREATED", visibility) + }) + + t.Run("Log private folder event", func(t *testing.T) { + defer solaceContainer.StopOnError() + + queueName := "folder-event-private" + assert.NoError(t, solaceContainer.QueueCreate(ctx, queueName)) + assert.NoError(t, solaceContainer.TopicSubscriptionCreate(ctx, queueName, "org/*")) + + // Instantiate test data + event, routingIdentifier, objectIdentifier := NewFolderAuditEvent(nil) + assert.NoError(t, solaceContainer.TopicSubscriptionCreate(ctx, queueName, fmt.Sprintf("org/%s", routingIdentifier.Identifier))) + + // Log the event to solace + visibility := auditV1.Visibility_VISIBILITY_PRIVATE + assert.NoError(t, + (*auditApi).Log( + ctx, + event, + visibility, + routingIdentifier, + objectIdentifier, + )) + + // Receive the event from solace + message, err := solaceContainer.NextMessageFromQueue(ctx, queueName, true) + assert.NoError(t, err) + + validateSentEvent(t, organizationTopicPrefix, message, routingIdentifier, objectIdentifier, event, "FOLDER_CREATED", visibility) + }) + + // Check logging of project events + t.Run("Log public project event", func(t *testing.T) { + defer solaceContainer.StopOnError() + + queueName := "project-event-public" + assert.NoError(t, solaceContainer.QueueCreate(ctx, queueName)) + assert.NoError(t, solaceContainer.TopicSubscriptionCreate(ctx, queueName, "project/*")) + + // Instantiate test data + event, routingIdentifier, objectIdentifier := NewProjectAuditEvent(nil) + + // Log the event to solace + visibility := auditV1.Visibility_VISIBILITY_PUBLIC + assert.NoError(t, + (*auditApi).Log( + ctx, + event, + visibility, + routingIdentifier, + objectIdentifier, + )) + + // Receive the event from solace + message, err := solaceContainer.NextMessageFromQueue(ctx, queueName, true) + assert.NoError(t, err) + + validateSentEvent(t, projectTopicPrefix, message, routingIdentifier, objectIdentifier, event, "PROJECT_CREATED", visibility) + }) + + t.Run("Log private project event", func(t *testing.T) { + defer solaceContainer.StopOnError() + + queueName := "project-event-private" + assert.NoError(t, solaceContainer.QueueCreate(ctx, queueName)) + assert.NoError(t, solaceContainer.TopicSubscriptionCreate(ctx, queueName, "project/*")) + + // Instantiate test data + event, routingIdentifier, objectIdentifier := NewProjectAuditEvent(nil) + + // Log the event to solace + visibility := auditV1.Visibility_VISIBILITY_PRIVATE + assert.NoError(t, + (*auditApi).Log( + ctx, + event, + visibility, + routingIdentifier, + objectIdentifier, + )) + + // Receive the event from solace + message, err := solaceContainer.NextMessageFromQueue(ctx, queueName, true) + assert.NoError(t, err) + + validateSentEvent(t, projectTopicPrefix, message, routingIdentifier, objectIdentifier, event, "PROJECT_CREATED", visibility) + }) + + // Check logging of system events + t.Run("Log private system event", func(t *testing.T) { + defer solaceContainer.StopOnError() + + queueName := "system-event-private" + assert.NoError(t, solaceContainer.QueueCreate(ctx, queueName)) + assert.NoError(t, solaceContainer.TopicSubscriptionCreate(ctx, queueName, "system/*")) + + // Instantiate test data + event := NewSystemAuditEvent(nil) + + // Log the event to solace + visibility := auditV1.Visibility_VISIBILITY_PRIVATE + assert.NoError(t, + (*auditApi).Log( + ctx, + event, + visibility, + nil, + nil, + )) + + // Receive the event from solace + message, err := solaceContainer.NextMessageFromQueue(ctx, queueName, true) + assert.NoError(t, err) + + // Check topic name + assert.Equal(t, systemTopicName, *message.Properties.To) + + // Check deserialized message + var protobufMessage auditV1.ProtobufMessage + assert.NoError(t, proto.Unmarshal(message.Data[0], &protobufMessage)) + assert.Equal(t, "audit.v1.RoutableAuditEvent", protobufMessage.ProtobufType) + + validateProtobufMessagePayload(t, message.Data[0], nil, nil, event, "SYSTEM_CHANGED", visibility) + }) + + // Check logging of organization events + t.Run("Log event with details", func(t *testing.T) { + defer solaceContainer.StopOnError() + + // Create the queue and topic subscription in solace + queueName := "org-event-with-details" + assert.NoError(t, solaceContainer.QueueCreate(ctx, queueName)) + assert.NoError(t, solaceContainer.TopicSubscriptionCreate(ctx, queueName, "org/*")) + + // Instantiate test data + event, routingIdentifier, objectIdentifier := NewOrganizationAuditEventWithDetails() + + // Log the event to solace + visibility := auditV1.Visibility_VISIBILITY_PUBLIC + assert.NoError(t, (*auditApi).Log( + ctx, + event, + visibility, + routingIdentifier, + objectIdentifier, + )) + + message, err := solaceContainer.NextMessageFromQueue(ctx, queueName, true) + assert.NoError(t, err) + + validateSentEvent(t, organizationTopicPrefix, message, routingIdentifier, objectIdentifier, event, "ORGANIZATION_CREATED", visibility) + }) +} + +func validateSentEvent(t *testing.T, topicPrefix string, message *amqp.Message, routingIdentifier *RoutingIdentifier, objectIdentifier *auditV1.ObjectIdentifier, event *auditV1.AuditEvent, eventName string, visibility auditV1.Visibility) { + + // Check topic name + assert.Equal(t, + fmt.Sprintf("topic://%s/%s", topicPrefix, routingIdentifier.Identifier), + *message.Properties.To) + + // Check deserialized message + var protobufMessage auditV1.ProtobufMessage + assert.NoError(t, proto.Unmarshal(message.Data[0], &protobufMessage)) + assert.Equal(t, "audit.v1.RoutableAuditEvent", protobufMessage.ProtobufType) + + validateProtobufMessagePayload(t, message.Data[0], routingIdentifier, objectIdentifier, event, eventName, visibility) +} + +func validateProtobufMessagePayload(t *testing.T, payload []byte, routingIdentifier *RoutingIdentifier, objectIdentifier *auditV1.ObjectIdentifier, event *auditV1.AuditEvent, eventName string, visibility auditV1.Visibility) { + + // Check deserialized message + var protobufMessage auditV1.ProtobufMessage + assert.NoError(t, proto.Unmarshal(payload, &protobufMessage)) + assert.Equal(t, "audit.v1.RoutableAuditEvent", protobufMessage.ProtobufType) + + // Check routable audit event parameters + var routableAuditEvent auditV1.RoutableAuditEvent + assert.NoError(t, proto.Unmarshal(protobufMessage.Value, &routableAuditEvent)) + + assert.Equal(t, eventName, routableAuditEvent.EventName) + assert.Equal(t, visibility, routableAuditEvent.Visibility) + + switch reference := routableAuditEvent.ResourceReference.(type) { + case *auditV1.RoutableAuditEvent_ObjectIdentifier: + assert.True(t, proto.Equal(objectIdentifier, reference.ObjectIdentifier)) + + switch routingIdentifier.Type { + case RoutingIdentifierTypeOrganization: + if objectIdentifier.Type == auditV1.ObjectType_OBJECT_TYPE_ORGANIZATION { + assert.Equal(t, routingIdentifier.Identifier.String(), reference.ObjectIdentifier.Identifier) + } + break + case RoutingIdentifierTypeProject: + assert.Equal(t, routingIdentifier.Identifier.String(), reference.ObjectIdentifier.Identifier) + assert.Equal(t, auditV1.ObjectType_OBJECT_TYPE_PROJECT, reference.ObjectIdentifier.Type) + break + default: + assert.Fail(t, "Routing identifier type not expected") + } + break + case *auditV1.RoutableAuditEvent_ObjectName: + assert.Nil(t, objectIdentifier) + assert.Nil(t, routingIdentifier) + assert.Equal(t, auditV1.ObjectName_OBJECT_NAME_SYSTEM, reference.ObjectName) + default: + assert.Fail(t, "Object name not expected") + } + + var auditEvent auditV1.AuditEvent + switch data := routableAuditEvent.Data.(type) { + case *auditV1.RoutableAuditEvent_UnencryptedData: + assert.NoError(t, proto.Unmarshal(data.UnencryptedData.Data, &auditEvent)) + break + default: + assert.Fail(t, "Encrypted data not expected") + } + + // Check audit event + assert.True(t, proto.Equal(event, &auditEvent)) +} + +func TestRoutableTopicNameResolver_Resolve_UnsupportedIdentifierType(t *testing.T) { + resolver := routableTopicNameResolver{} + _, err := resolver.Resolve(&RoutingIdentifier{Type: 2}) + assert.ErrorIs(t, err, ErrUnsupportedObjectIdentifierType) +} + +func TestNewRoutableAuditApi_NewRoutableAuditApi_MessagingApiNil(t *testing.T) { + auditApi, err := newRoutableAuditApi(nil, topicNameConfig{}, nil) + assert.Nil(t, auditApi) + assert.EqualError(t, err, "messaging api nil") +} + +func TestRoutableAuditApi_ValidateAndSerialize_ValidationFailed(t *testing.T) { + expectedError := errors.New("expected error") + + validator := &ProtobufValidatorMock{} + validator.On("Validate", mock.Anything).Return(expectedError) + var protobufValidator ProtobufValidator = validator + + auditApi := routableAuditApi{validator: &protobufValidator} + + event := NewSystemAuditEvent(nil) + _, err := auditApi.ValidateAndSerialize(event, auditV1.Visibility_VISIBILITY_PUBLIC, nil, nil) + assert.ErrorIs(t, err, expectedError) +} + +func TestRoutableAuditApi_Log_ValidationFailed(t *testing.T) { + expectedError := errors.New("expected error") + + validator := &ProtobufValidatorMock{} + validator.On("Validate", mock.Anything).Return(expectedError) + var protobufValidator ProtobufValidator = validator + + auditApi := routableAuditApi{validator: &protobufValidator} + + event := NewSystemAuditEvent(nil) + err := auditApi.Log(context.Background(), event, auditV1.Visibility_VISIBILITY_PUBLIC, nil, nil) + assert.ErrorIs(t, err, expectedError) +} + +func TestRoutableAuditApi_Log_NilEvent(t *testing.T) { + auditApi := routableAuditApi{} + err := auditApi.Log(context.Background(), nil, auditV1.Visibility_VISIBILITY_PUBLIC, nil, nil) + assert.ErrorIs(t, err, ErrEventNil) +} diff --git a/audit-schema.iml b/audit-schema.iml new file mode 100644 index 0000000..a41966e --- /dev/null +++ b/audit-schema.iml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/buf.lock b/buf.lock new file mode 100644 index 0000000..c91b581 --- /dev/null +++ b/buf.lock @@ -0,0 +1,2 @@ +# Generated by buf. DO NOT EDIT. +version: v1 diff --git a/client.go b/client.go new file mode 100644 index 0000000..3bef6ad --- /dev/null +++ b/client.go @@ -0,0 +1,219 @@ +package main + +// terraform-provider-solacebroker +// +// Copyright 2024 Solace Corporation. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "github.com/hashicorp/go-retryablehttp" + "io" + "log/slog" + "net/http" + "net/http/cookiejar" + "time" +) + +var ( + ErrResourceNotFound = errors.New("resource not found") +) + +var firstRequest = true + +type Client struct { + *retryablehttp.Client + url string + username string + password string + bearerToken string + retries uint + retryMinInterval time.Duration + retryMaxInterval time.Duration + requestMinInterval time.Duration + requestTimeout time.Duration + rateLimiter <-chan time.Time +} + +type Option func(*Client) + +func BasicAuth(username, password string) Option { + return func(client *Client) { + client.username = username + client.password = password + } +} + +//func BearerToken(bearerToken string) Option { +// return func(client *Client) { +// client.bearerToken = bearerToken +// } +//} +// +//func Retries(numRetries uint, retryMinInterval, retryMaxInterval time.Duration) Option { +// return func(client *Client) { +// client.retries = numRetries +// client.retryMinInterval = retryMinInterval +// client.retryMaxInterval = retryMaxInterval +// } +//} +// +//func RequestLimits(requestTimeoutDuration, requestMinInterval time.Duration) Option { +// return func(client *Client) { +// client.requestTimeout = requestTimeoutDuration +// client.requestMinInterval = requestMinInterval +// } +//} + +func NewClient(url string, insecure_skip_verify bool, providerClient bool, options ...Option) *Client { + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure_skip_verify}, + MaxIdleConnsPerHost: 10, + } + retryClient := retryablehttp.NewClient() + retryClient.HTTPClient.Transport = tr + if !providerClient { + retryClient.Logger = nil + } + client := &Client{ + Client: retryClient, + url: url, + retries: 10, // default 3 + retryMinInterval: time.Second, + retryMaxInterval: time.Second * 10, + } + for _, o := range options { + o(client) + } + client.Client.RetryMax = int(client.retries) + client.Client.RetryWaitMin = client.retryMinInterval + client.Client.RetryWaitMax = client.retryMaxInterval + client.HTTPClient.Timeout = client.requestTimeout + client.HTTPClient.Jar, _ = cookiejar.New(nil) + if client.requestMinInterval > 0 { + client.rateLimiter = time.NewTicker(client.requestMinInterval).C + } else { + ch := make(chan time.Time) + // closing the channel will make receiving from the channel non-blocking (the value received will be the + // zero value) + close(ch) + client.rateLimiter = ch + } + firstRequest = true + return client +} + +func (c *Client) RequestWithBody(ctx context.Context, method, url string, body any) (map[string]any, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + request, err := http.NewRequestWithContext(ctx, method, c.url+url, bytes.NewBuffer(data)) + if err != nil { + return nil, err + } + slog.Debug(fmt.Sprintf("===== %v to %v =====", request.Method, request.URL)) + rawBody, err := c.doRequest(request) + if err != nil { + return nil, err + } + return parseResponseAsObject(ctx, request, rawBody) +} + +func (c *Client) doRequest(request *http.Request) ([]byte, error) { + if !firstRequest { + // the value doesn't matter, it is waiting for the value that matters + <-c.rateLimiter + } else { + // only skip rate limiter for the first request + firstRequest = false + } + if request.Method != http.MethodGet { + request.Header.Set("Content-Type", "application/json") + } + // Prefer OAuth even if Basic Auth credentials provided + if c.bearerToken != "" { + request.Header.Set("Authorization", "Bearer "+c.bearerToken) + } else if c.username != "" { + request.SetBasicAuth(c.username, c.password) + } else { + return nil, fmt.Errorf("either username or bearer token must be provided to access the broker") + } + var response *http.Response + var err error + response, err = c.StandardClient().Do(request) + if err != nil || response == nil { + return nil, err + } + defer response.Body.Close() + rawBody, err := io.ReadAll(response.Body) + if err != nil || (response.StatusCode != http.StatusOK && response.StatusCode != http.StatusBadRequest) { + return nil, fmt.Errorf("could not perform request: status %v (%v) during %v to %v, response body:\n%s", response.StatusCode, response.Status, request.Method, request.URL, rawBody) + } + if _, err := io.Copy(io.Discard, response.Body); err != nil { + return nil, fmt.Errorf("response processing error: during %v to %v", request.Method, request.URL) + } + return rawBody, nil +} + +func parseResponseAsObject(ctx context.Context, request *http.Request, dataResponse []byte) (map[string]any, error) { + data := map[string]any{} + err := json.Unmarshal(dataResponse, &data) + if err != nil { + return nil, fmt.Errorf("could not parse response body from %v to %v, response body was:\n%s", request.Method, request.URL, dataResponse) + } + rawData, ok := data["data"] + if ok { + // Valid data + data, _ = rawData.(map[string]any) + return data, nil + } else { + // Analize response metadata details + rawData, ok = data["meta"] + if ok { + data, _ = rawData.(map[string]any) + if data["responseCode"].(float64) == http.StatusOK { + // this is valid response for delete + return nil, nil + } + description := data["error"].(map[string]interface{})["description"].(string) + status := data["error"].(map[string]interface{})["status"].(string) + if status == "NOT_FOUND" { + // resource not found is a special type we want to return + return nil, fmt.Errorf("request failed from %v to %v, %v, %v, %w", request.Method, request.URL, description, status, ErrResourceNotFound) + } + slog.Error(fmt.Sprintf("SEMP request returned %v, %v", description, status)) + return nil, fmt.Errorf("request failed for %v using %v, %v, %v", request.URL, request.Method, description, status) + } + } + return nil, fmt.Errorf("could not parse response details from %v to %v, response body was:\n%s", request.Method, request.URL, dataResponse) +} + +func (c *Client) RequestWithoutBody(ctx context.Context, method, url string) (map[string]interface{}, error) { + request, err := http.NewRequestWithContext(ctx, method, c.url+url, nil) + if err != nil { + return nil, err + } + slog.Debug(fmt.Sprintf("===== %v to %v =====", request.Method, request.URL)) + rawBody, err := c.doRequest(request) + if err != nil { + return nil, err + } + return parseResponseAsObject(ctx, request, rawBody) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..33f769c --- /dev/null +++ b/go.mod @@ -0,0 +1,74 @@ +module audit-schema + +go 1.22 + +require ( + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.33.0-20240401165935-b983156c5e99.1 + github.com/Azure/go-amqp v1.0.5 + github.com/bufbuild/protovalidate-go v0.6.2 + github.com/google/uuid v1.6.0 + github.com/hashicorp/go-retryablehttp v0.7.7 + github.com/stretchr/testify v1.9.0 + github.com/testcontainers/testcontainers-go v0.31.0 + google.golang.org/protobuf v1.33.0 +) + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/Microsoft/hcsshim v0.11.4 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/containerd/containerd v1.7.15 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/cpuguy83/dockercfg v0.3.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.5.0 // indirect + github.com/docker/docker v25.0.5+incompatible // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/cel-go v0.20.1 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/klauspost/compress v1.16.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.5.0 // indirect + github.com/moby/sys/user v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/shirou/gopsutil/v3 v3.23.12 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.3 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.24.0 // indirect + go.opentelemetry.io/otel/metric v1.24.0 // indirect + go.opentelemetry.io/otel/trace v1.24.0 // indirect + golang.org/x/crypto v0.22.0 // indirect + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect + golang.org/x/mod v0.16.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/tools v0.19.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240401170217-c3f982113cda // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect + google.golang.org/grpc v1.62.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5a65803 --- /dev/null +++ b/go.sum @@ -0,0 +1,231 @@ +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.33.0-20240401165935-b983156c5e99.1 h1:2IGhRovxlsOIQgx2ekZWo4wTPAYpck41+18ICxs37is= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.33.0-20240401165935-b983156c5e99.1/go.mod h1:Tgn5bgL220vkFOI0KPStlcClPeOJzAv4uT+V8JXGUnw= +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-amqp v1.0.5 h1:po5+ljlcNSU8xtapHTe8gIc8yHxCzC03E8afH2g1ftU= +github.com/Azure/go-amqp v1.0.5/go.mod h1:vZAogwdrkbyK3Mla8m/CxSc/aKdnTZ4IbPxl51Y5WZE= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/Microsoft/hcsshim v0.11.4 h1:68vKo2VN8DE9AdN4tnkWnmdhqdbpUFM8OF3Airm7fz8= +github.com/Microsoft/hcsshim v0.11.4/go.mod h1:smjE4dvqPX9Zldna+t5FG3rnoHhaB7QYxPRqGcpAD9w= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/bufbuild/protovalidate-go v0.6.2 h1:U/V3CGF0kPlR12v41rjO4DrYZtLcS4ZONLmWN+rJVCQ= +github.com/bufbuild/protovalidate-go v0.6.2/go.mod h1:4BR3rKEJiUiTy+sqsusFn2ladOf0kYmA2Reo6BHSBgQ= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/containerd/containerd v1.7.15 h1:afEHXdil9iAm03BmhjzKyXnnEBtjaLJefdU7DV0IFes= +github.com/containerd/containerd v1.7.15/go.mod h1:ISzRRTMF8EXNpJlTzyr2XMhN+j9K302C21/+cr3kUnY= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/cpuguy83/dockercfg v0.3.1 h1:/FpZ+JaygUR/lZP2NlFI2DVfrOEMAIKP5wWEJdoYe9E= +github.com/cpuguy83/dockercfg v0.3.1/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0= +github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v25.0.5+incompatible h1:UmQydMduGkrD5nQde1mecF/YnSbTOaPeFIeP5C4W+DE= +github.com/docker/docker v25.0.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/envoyproxy/protoc-gen-validate v1.0.4 h1:gVPz/FMfvh57HdSJQyvBtF00j8JU4zdyUgIUNhlgg0A= +github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/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/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.20.1 h1:nDx9r8S3L4pE61eDdt8igGj8rf5kjYR3ILxWIpWNi84= +github.com/google/cel-go v0.20.1/go.mod h1:kWcIzTsPX0zmQ+H3TirHstLLf9ep5QTsZBN9u4dOYLg= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4= +github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= +github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= +github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= +github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= +github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= +github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/testcontainers/testcontainers-go v0.31.0 h1:W0VwIhcEVhRflwL9as3dhY6jXjVCA27AkmbnZ+UTh3U= +github.com/testcontainers/testcontainers-go v0.31.0/go.mod h1:D2lAoA0zUFiSY+eAflqK5mcUx/A5hrrORaEQrd0SefI= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= +github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= +go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= +go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= +go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o= +go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= +go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20240401170217-c3f982113cda h1:b6F6WIV4xHHD0FA4oIyzU6mHWg2WI2X1RBehwa5QN38= +google.golang.org/genproto/googleapis/api v0.0.0-20240401170217-c3f982113cda/go.mod h1:AHcE/gZH76Bk/ROZhQphlRoWo5xKDEtz3eVEO1LfA8c= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk= +google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY= +gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= diff --git a/main.go b/main.go new file mode 100644 index 0000000..486cdcd --- /dev/null +++ b/main.go @@ -0,0 +1,115 @@ +package main + +import ( + auditV1 "audit-schema/gen/go/audit/v1" + "fmt" + "github.com/bufbuild/protovalidate-go" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/known/structpb" +) + +func main() { + agent := "aaa" + parameters, _ := structpb.NewValue(map[string]any{ + "parameter": "b", + }) + body, _ := structpb.NewValue(map[string]any{ + "body": "b", + }) + auditEvent := auditV1.AuditEvent{ + EventName: "XXX", + EventTrigger: auditV1.EventTrigger_EVENT_TRIGGER_REQUEST, + Request: &auditV1.RequestDetails{ + Endpoint: "XXX", + SourceIpAddress: "127.0.0.1", + UserAgent: &agent, + Parameters: parameters.GetStructValue(), + Body: body.GetStructValue(), + Headers: []*auditV1.RequestHeader{ + { + Key: "abc", + Value: "def", + }, + }, + }, + EventTimeStamp: nil, + Initiator: nil, + Principals: nil, + ResourceId: nil, + ResourceName: nil, + CorrelationId: nil, + Details: nil, + } + + auditEventBytes, err := proto.Marshal(&auditEvent) + if err != nil { + fmt.Println(err.Error()) + return + } + payload := auditV1.UnencryptedData{ + Data: auditEventBytes, + ProtobufType: fmt.Sprintf("%v", auditEvent.ProtoReflect().Descriptor().FullName()), + } + + //var version int32 = 0 + routableEvent := auditV1.RoutableAuditEvent{ + EventName: "A_V1", + Visibility: auditV1.Visibility_VISIBILITY_PRIVATE, + ResourceReference: &auditV1.RoutableAuditEvent_ObjectName{ObjectName: auditV1.ObjectName_OBJECT_NAME_SYSTEM}, + Data: &auditV1.RoutableAuditEvent_UnencryptedData{UnencryptedData: &payload}, + } + + validator, err := protovalidate.New() + if err != nil { + fmt.Println("failed to initialize validator:", err) + } + err = validator.Validate(&auditEvent) + if err != nil { + fmt.Println(err.Error()) + } + + err = validator.Validate(&routableEvent) + if err != nil { + fmt.Println(err.Error()) + } + + routableEventBytes, err := proto.Marshal(&routableEvent) + if err != nil { + fmt.Println(err.Error()) + return + } + + var deserializedRoutableEvent auditV1.RoutableAuditEvent + err = proto.Unmarshal(routableEventBytes, &deserializedRoutableEvent) + if err != nil { + fmt.Println(err.Error()) + return + } + if proto.Equal(&routableEvent, &deserializedRoutableEvent) { + fmt.Println("Event Matched") + } else { + fmt.Println("Event Not Matched") + } + //fmt.Println(deserializedRoutableEvent.String()) + + protoMessage := getProto(deserializedRoutableEvent.GetUnencryptedData().ProtobufType) + err = proto.Unmarshal(deserializedRoutableEvent.GetUnencryptedData().Data, protoMessage) + if err != nil { + fmt.Println(err.Error()) + return + } + deserializedAuditEvent, isAuditEvent := protoMessage.(*auditV1.AuditEvent) + if isAuditEvent { + fmt.Println(deserializedAuditEvent.String()) + } + + fmt.Println(string(auditEventBytes)) +} + +func getProto(dataType string) protoreflect.ProtoMessage { + t, _ := protoregistry.GlobalTypes.FindMessageByName(protoreflect.FullName(dataType)) + m := t.New().Interface() + return m +} diff --git a/main.py b/main.py new file mode 100644 index 0000000..6900411 --- /dev/null +++ b/main.py @@ -0,0 +1,37 @@ +# Install protovalidate: +# python3 -m venv path/to/venv +# source path/to/venv/bin/activate +# pip3 install -r requirements.txt +import protovalidate + +from audit.v1.audit_event_pb2 import AuditEvent, EventTrigger, RequestDetails +from audit.v1.routable_event_pb2 import RoutableAuditEvent, UnencryptedData + +audit_event = AuditEvent() +audit_event.event_name = "XXX" +audit_event.event_trigger = EventTrigger.EVENT_REQUEST + +audit_event.request.endpoint = "XXX" +audit_event.request.source_ip_address = "127.0.0.1" +audit_event.request.user_agent = "aaa" + +routable_audit_event = RoutableAuditEvent() +routable_audit_event.event_name = "AAA" + +routable_audit_event.unencrypted_data.data = audit_event.SerializeToString() +routable_audit_event.unencrypted_data.protobuf_type = audit_event.DESCRIPTOR.full_name + +try: + protovalidate.validate(audit_event) +except protovalidate.ValidationError as e: + print("AuditEvent validation errors:") + for error in e.errors(): + print("field_path: " + error.field_path + ", constraint_id: " + error.constraint_id + ", message: " + error.message) +try: + protovalidate.validate(routable_audit_event) +except protovalidate.ValidationError as e: + print("\nRoutableAuditEvent validation errors:") + for error in e.errors(): + print("field_path: " + error.field_path + ", constraint_id: " + error.constraint_id + ", message: " + error.message) + +print(audit_event.SerializeToString()) diff --git a/messaging.go b/messaging.go new file mode 100644 index 0000000..9994ed3 --- /dev/null +++ b/messaging.go @@ -0,0 +1,196 @@ +package main + +import ( + "context" + "fmt" + "github.com/Azure/go-amqp" + "log/slog" + "strings" + "time" +) + +// Default connection timeout for the AMQP connection +const connectionTimeoutSeconds = 10 + +// TopicNameResolver is an abstraction for dynamic topic name resolution +// based on event data or api parameters. +type TopicNameResolver interface { + + // Resolve returns a topic name for the given routing identifier + Resolve(routingIdentifier *RoutingIdentifier) (string, error) +} + +// MessagingApi is an abstraction for a messaging system that can be used to send +// audit logs to the audit log system. +type MessagingApi interface { + + // Send method will send the given data to the specified topic synchronously. + // Parameters: + // * ctx - the context object + // * topic - the messaging topic where to send the data to + // * data - the serialized data as byte array + // * contentType - the contentType of the serialized data + // + // It returns technical errors for connection issues or sending problems. + Send(ctx context.Context, topic string, data []byte, contentType string) error +} + +// AmqpConfig provides AMQP connection related parameters. +type AmqpConfig struct { + URL string + User string + Password string +} + +// AmqpSession is an abstraction providing a subset of the methods of amqp.Session +type AmqpSession interface { + NewSender(ctx context.Context, target string, opts *amqp.SenderOptions) (*AmqpSender, error) + Close(ctx context.Context) error +} + +type AmqpSessionWrapper struct { + session *amqp.Session +} + +func (w AmqpSessionWrapper) NewSender(ctx context.Context, target string, opts *amqp.SenderOptions) (*AmqpSender, error) { + sender, err := w.session.NewSender(ctx, target, opts) + var amqpSender AmqpSender = sender + return &amqpSender, err +} + +func (w AmqpSessionWrapper) Close(ctx context.Context) error { + return w.session.Close(ctx) +} + +// AmqpSender is an abstraction providing a subset of the methods of amqp.Sender +type AmqpSender interface { + Send(ctx context.Context, msg *amqp.Message, opts *amqp.SendOptions) error + Close(ctx context.Context) error +} + +// AmqpMessagingApi implements MessagingApi. +type AmqpMessagingApi struct { + config AmqpConfig + connection *amqp.Conn + session *AmqpSession +} + +func NewAmqpMessagingApi(amqpConfig AmqpConfig) (*MessagingApi, error) { + amqpApi := &AmqpMessagingApi{config: amqpConfig} + + err := amqpApi.connect() + if err != nil { + return nil, err + } + + var messagingApi MessagingApi = amqpApi + return &messagingApi, nil +} + +// connect opens a new connection and session to the AMQP messaging system. +// The connection attempt will be cancelled after connectionTimeoutSeconds. +func (a *AmqpMessagingApi) connect() error { + slog.Info("connecting to messaging system") + + // Set credentials if specified + auth := amqp.SASLTypeAnonymous() + + if a.config.User != "" && a.config.Password != "" { + auth = amqp.SASLTypePlain(a.config.User, a.config.Password) + slog.Info("using username and password for messaging") + } else { + slog.Warn("using anonymous messaging!") + } + + options := &amqp.ConnOptions{ + SASLType: auth, + } + + // Create new context with timeout for the connection initialization + subCtx, cancel := context.WithTimeout(context.Background(), connectionTimeoutSeconds*time.Second) + defer cancel() + + // Initialize connection + conn, err := amqp.Dial(subCtx, a.config.URL, options) + if err != nil { + return err + } + a.connection = conn + + // Initialize session + session, err := conn.NewSession(context.Background(), nil) + if err != nil { + return err + } + + var amqpSession AmqpSession = AmqpSessionWrapper{session: session} + a.session = &amqpSession + + return nil +} + +// Send implements MessagingApi.Send. +// If errors occur the connection to the messaging system will be closed and re-established. +func (a *AmqpMessagingApi) Send(ctx context.Context, topic string, data []byte, contentType string) error { + err := a.trySend(ctx, topic, data, contentType) + if err == nil { + return nil + } + + // Drop the current sender, as it cannot connect to the broker anymore + slog.Error("message sender error, recreating", err) + + err = a.resetConnection(ctx) + if err != nil { + return err + } + + return a.trySend(ctx, topic, data, contentType) +} + +// trySend actually sends the given data as amqp.Message to the messaging system. +func (a *AmqpMessagingApi) trySend(ctx context.Context, topic string, data []byte, contentType string) error { + if !strings.HasPrefix(topic, AmqpTopicPrefix) { + return fmt.Errorf( + "topic %q name lacks mandatory prefix %q", + topic, + AmqpTopicPrefix, + ) + } + + sender, err := (*a.session).NewSender(ctx, topic, nil) + if err != nil { + return err + } + + bytes := [][]byte{data} + message := amqp.Message{ + Header: &amqp.MessageHeader{ + Durable: true, + }, + Properties: &amqp.MessageProperties{ + To: &topic, + ContentType: &contentType, + }, + Data: bytes, + } + + err = (*sender).Send(ctx, &message, nil) + if err != nil { + _ = (*sender).Close(ctx) + return err + } + + return nil +} + +// resetConnection closes the current session and connection and reconnects to the messaging system. +func (a *AmqpMessagingApi) resetConnection(ctx context.Context) error { + _ = (*a.session).Close(ctx) + err := a.connection.Close() + if err != nil { + slog.Error("failed to close message connection", err) + } + + return a.connect() +} diff --git a/messaging_test.go b/messaging_test.go new file mode 100644 index 0000000..8216a53 --- /dev/null +++ b/messaging_test.go @@ -0,0 +1,172 @@ +package main + +import ( + "context" + "errors" + "fmt" + "github.com/Azure/go-amqp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "testing" + "time" +) + +type MessagingApiMock struct { + mock.Mock +} + +func (m *MessagingApiMock) Send(ctx context.Context, topic string, data []byte, contentType string) error { + args := m.Called(ctx, topic, data, contentType) + return args.Error(0) +} + +type AmqpSessionMock struct { + mock.Mock +} + +func (m *AmqpSessionMock) NewSender(ctx context.Context, target string, opts *amqp.SenderOptions) (*AmqpSender, error) { + args := m.Called(ctx, target, opts) + var sender *AmqpSender = nil + if args.Get(0) != nil { + sender = args.Get(0).(*AmqpSender) + } + err := args.Error(1) + return sender, err +} + +func (m *AmqpSessionMock) Close(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +type AmqpSenderMock struct { + mock.Mock +} + +func (m *AmqpSenderMock) Send(ctx context.Context, msg *amqp.Message, opts *amqp.SendOptions) error { + args := m.Called(ctx, msg, opts) + return args.Error(0) +} + +func (m *AmqpSenderMock) Close(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +func Test_NewAmqpMessagingApi(t *testing.T) { + _, err := NewAmqpMessagingApi(AmqpConfig{URL: "not-handled-protocol://localhost:5672"}) + assert.EqualError(t, err, "unsupported scheme \"not-handled-protocol\"") +} + +func TestAmqpMessagingApi_Send(t *testing.T) { + // Specify test timeout + ctx, cancelFn := context.WithTimeout(context.Background(), 120*time.Second) + defer cancelFn() + + // Start solace docker container + solaceContainer, err := NewSolaceContainer(context.Background()) + assert.NoError(t, err) + defer solaceContainer.Stop() + + t.Run("Missing topic prefix", func(t *testing.T) { + defer solaceContainer.StopOnError() + + api, err := NewAmqpMessagingApi(AmqpConfig{URL: solaceContainer.AmqpConnectionString}) + assert.NoError(t, err) + + err = (*api).Send(ctx, "topic-name", []byte{}, "application/json") + assert.EqualError(t, err, "topic \"topic-name\" name lacks mandatory prefix \"topic://\"") + }) + + t.Run("New sender call returns error", func(t *testing.T) { + defer solaceContainer.StopOnError() + + // Initialize the solace queue + topicSubscriptionTopicPattern := "auditlog/>" + queueName := "messaging-new-sender" + assert.NoError(t, solaceContainer.QueueCreate(ctx, queueName)) + assert.NoError(t, solaceContainer.TopicSubscriptionCreate(ctx, queueName, topicSubscriptionTopicPattern)) + topicName := fmt.Sprintf("topic://auditlog/%s", "amqp-no-new-sender") + assert.NoError(t, solaceContainer.ValidateTopicName(topicSubscriptionTopicPattern, topicName)) + + api := &AmqpMessagingApi{config: AmqpConfig{URL: solaceContainer.AmqpConnectionString}} + err := api.connect() + assert.NoError(t, err) + + expectedError := errors.New("expected error") + + // Set mock session + sessionMock := AmqpSessionMock{} + sessionMock.On("NewSender", mock.Anything, mock.Anything, mock.Anything).Return(nil, expectedError) + sessionMock.On("Close", mock.Anything).Return(nil) + + var amqpSession AmqpSession = &sessionMock + api.session = &amqpSession + + // It's expected that the test succeeds. + // First the session is closed as it returns the expected error + // Then the retry mechanism restarts the connection and successfully sends the data + value := "test" + err = (*api).Send(ctx, topicName, []byte(value), "application/json") + assert.NoError(t, err) + + // Check that the mock was called + assert.True(t, sessionMock.AssertNumberOfCalls(t, "NewSender", 1)) + assert.True(t, sessionMock.AssertNumberOfCalls(t, "Close", 1)) + + message, err := solaceContainer.NextMessage(ctx, fmt.Sprintf("queue://%s", queueName), true) + assert.NoError(t, err) + assert.Equal(t, value, string(message.Data[0])) + assert.Equal(t, topicName, *message.Properties.To) + }) + + t.Run("Send call on sender returns error", func(t *testing.T) { + defer solaceContainer.StopOnError() + + // Initialize the solace queue + topicSubscriptionTopicPattern := "auditlog/>" + queueName := "messaging-sender-error" + assert.NoError(t, solaceContainer.QueueCreate(ctx, queueName)) + assert.NoError(t, solaceContainer.TopicSubscriptionCreate(ctx, queueName, topicSubscriptionTopicPattern)) + topicName := fmt.Sprintf("topic://auditlog/%s", "amqp-sender-error") + assert.NoError(t, solaceContainer.ValidateTopicName(topicSubscriptionTopicPattern, topicName)) + + api := &AmqpMessagingApi{config: AmqpConfig{URL: solaceContainer.AmqpConnectionString}} + err := api.connect() + assert.NoError(t, err) + + expectedError := errors.New("expected error") + + // Instantiate mock sender + senderMock := AmqpSenderMock{} + senderMock.On("Send", mock.Anything, mock.Anything, mock.Anything).Return(expectedError) + senderMock.On("Close", mock.Anything).Return(nil) + var amqpSender AmqpSender = &senderMock + + // Set mock session + sessionMock := AmqpSessionMock{} + sessionMock.On("NewSender", mock.Anything, mock.Anything, mock.Anything).Return(&amqpSender, nil) + sessionMock.On("Close", mock.Anything).Return(nil) + + var amqpSession AmqpSession = &sessionMock + api.session = &amqpSession + + // It's expected that the test succeeds. + // First the sender and session are closed as the sender returns the expected error + // Then the retry mechanism restarts the connection and successfully sends the data + value := "test" + err = (*api).Send(ctx, topicName, []byte(value), "application/json") + assert.NoError(t, err) + + // Check that the mocks were called + assert.True(t, sessionMock.AssertNumberOfCalls(t, "NewSender", 1)) + assert.True(t, sessionMock.AssertNumberOfCalls(t, "Close", 1)) + assert.True(t, senderMock.AssertNumberOfCalls(t, "Send", 1)) + assert.True(t, senderMock.AssertNumberOfCalls(t, "Close", 1)) + + message, err := solaceContainer.NextMessage(ctx, fmt.Sprintf("queue://%s", queueName), true) + assert.NoError(t, err) + assert.Equal(t, value, string(message.Data[0])) + assert.Equal(t, topicName, *message.Properties.To) + }) +} diff --git a/proto/audit/v1/audit_event.proto b/proto/audit/v1/audit_event.proto new file mode 100644 index 0000000..e529609 --- /dev/null +++ b/proto/audit/v1/audit_event.proto @@ -0,0 +1,83 @@ +syntax = "proto3"; + +import "buf/validate/validate.proto"; +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; + +package audit.v1; + +option go_package = "./audit;auditV1"; +option java_multiple_files = true; +option java_package = "com.schwarz.stackit.audit.v1"; + +enum EventTrigger { + EVENT_TRIGGER_UNSPECIFIED = 0; + // Event from messaging system + EVENT_TRIGGER_EVENT = 1; + // Time based scheduler + EVENT_TRIGGER_SCHEDULER = 2; + // Network request (REST, gRPC, etc.) + EVENT_TRIGGER_REQUEST = 3; +} + +message Principal { + // A UUID or another kind of identifier + string id = 1 [(buf.validate.field).required = true]; + optional string email = 2 [(buf.validate.field).string.email = true, (buf.validate.field).string.max_len = 255]; +} + +message RequestDetails { + string endpoint = 1 [(buf.validate.field).required = true, (buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 255]; + // Accepts ipv4 and ipv6 + string source_ip_address = 2 [(buf.validate.field).required = true, (buf.validate.field).string.ip = true]; + optional string user_agent = 3 [(buf.validate.field).required = true, (buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 255]; + optional google.protobuf.Struct parameters = 4; + optional google.protobuf.Struct body = 5; + repeated RequestHeader headers = 6; +} + +// Key-value pair for request headers. Key and value are mandatory. +message RequestHeader { + string key = 1 [(buf.validate.field).required = true, (buf.validate.field).string.min_len = 1]; + string value = 2 [(buf.validate.field).required = true, (buf.validate.field).string.min_len = 1]; +} + +message AuditEvent { + // Validate that "request" details are set if the event trigger is set to "EVENT_REQUEST" + option (buf.validate.message).cel = { + id: "request.details" + message: "request details must be set" + expression: "this.event_trigger == 3 && has(this.request) || this.event_trigger != 3" + }; + + // Functional event name with pattern _, e.g. ORGANIZATION_CREATED + // Important for filtering and translation / verbalization of event types + // in the UI or data sinks. + string event_name = 1 [(buf.validate.field).required = true, (buf.validate.field).string.pattern = "^[A-Z]+_[A-Z]+$"]; + + // The time when the event happened. Must not be a value in the future. + google.protobuf.Timestamp event_time_stamp = 2 [(buf.validate.field).required = true, (buf.validate.field).timestamp.lt_now = true]; + + EventTrigger event_trigger = 3 [(buf.validate.field).required = true, (buf.validate.field).enum.defined_only = true]; + + // Request details - mandatory if event_trigger is set to "EVENT_REQUEST" + optional RequestDetails request = 4; + + Principal initiator = 5 [(buf.validate.field).required = true]; + + // List of service account delegation principals. + // -> Chain from service account to the actual user who initiated the action. + repeated Principal principals = 6; + + optional string resource_id = 7 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 255]; + + optional string resource_name = 8 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 255]; + + optional string correlation_id = 9 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 255]; + + // Result of the operation to publish with the event + optional google.protobuf.Struct result = 10; + + // Additional information to publish with the event + optional google.protobuf.Struct details = 11; +} \ No newline at end of file diff --git a/proto/audit/v1/event.proto b/proto/audit/v1/event.proto new file mode 100644 index 0000000..3e970bc --- /dev/null +++ b/proto/audit/v1/event.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +import "buf/validate/validate.proto"; + +package audit.v1; + +option go_package = "./audit;auditV1"; +option java_multiple_files = true; +option java_package = "com.schwarz.stackit.audit.v1"; + +message ProtobufMessage { + // Serialized event + bytes value = 1 [(buf.validate.field).required = true, (buf.validate.field).bytes.min_len = 1]; + + // Name of the protobuf type + string protobuf_type = 2 [(buf.validate.field).required = true, (buf.validate.field).string.min_len = 1]; +} \ No newline at end of file diff --git a/proto/audit/v1/routable_event.proto b/proto/audit/v1/routable_event.proto new file mode 100644 index 0000000..0169c8d --- /dev/null +++ b/proto/audit/v1/routable_event.proto @@ -0,0 +1,89 @@ +syntax = "proto3"; + +import "buf/validate/validate.proto"; + +package audit.v1; + +option go_package = "./audit;auditV1"; +option java_multiple_files = true; +option java_package = "com.schwarz.stackit.audit.v1"; + +enum Visibility { + VISIBILITY_UNSPECIFIED = 0; + // Will be routed to customer data sinks + VISIBILITY_PUBLIC = 1; + // Will NOT be routed to customer data sinks + VISIBILITY_PRIVATE = 2; +} + +enum ObjectName { + OBJECT_NAME_UNSPECIFIED = 0; + // If the action happens on system level and doesn't relate to a known ObjectType. + OBJECT_NAME_SYSTEM = 1; +} + +// The type of the object the audit event refers to. +// Relevant for type-detection and lookups in the routing. +enum ObjectType { + OBJECT_TYPE_UNSPECIFIED = 0; + OBJECT_TYPE_ORGANIZATION = 1; + OBJECT_TYPE_FOLDER = 2; + OBJECT_TYPE_PROJECT = 3; +} + +message ObjectIdentifier { + // Identifier of the respective entity (e.g. Identifier of an organization) + string identifier = 1 [(buf.validate.field).required = true, (buf.validate.field).string.uuid = true]; + + // Type of the respective entity relevant for routing + ObjectType type = 2 [(buf.validate.field).required = true, (buf.validate.field).enum.defined_only = true]; +} + +message EncryptedData { + // Encrypted serialized protobuf content (the actual audit event) + bytes data = 1 [(buf.validate.field).required = true, (buf.validate.field).bytes.min_len = 1]; + + // Name of the protobuf type + string protobuf_type = 2 [(buf.validate.field).required = true, (buf.validate.field).string.min_len = 1]; + + // The password taken to derive the encryption key from + string encrypted_password = 3 [(buf.validate.field).required = true, (buf.validate.field).string.min_len = 1]; + + // Version of the encrypted key + int32 key_version = 4 [(buf.validate.field).int32.gte = 1]; +} + +message UnencryptedData { + // Unencrypted serialized protobuf content (the actual audit event) + bytes data = 1 [(buf.validate.field).required = true, (buf.validate.field).bytes.min_len = 1]; + + // Name of the protobuf type + string protobuf_type = 2 [(buf.validate.field).required = true, (buf.validate.field).string.min_len = 1]; +} + +message RoutableAuditEvent { + + // Functional event name with pattern _, e.g. ORGANIZATION_CREATED + // Will be copied over by the SDK from the AuditEvent + string event_name = 1 [(buf.validate.field).required = true, (buf.validate.field).string.pattern = "^[A-Z]+_[A-Z]+$"]; + + // Visibility relevant for differentiating between internal and public events + Visibility visibility = 2 [(buf.validate.field).required = true, (buf.validate.field).enum.defined_only = true]; + + // Identifier the audit log event refers to + oneof resource_reference { + option (buf.validate.oneof).required = true; + // If it is a technical event not related to an organization, folder or project + // Will NOT be routed to the end-user, only for internal analysis -> + // Clarify what do in the router + ObjectName object_name = 3; + ObjectIdentifier object_identifier = 4; + } + + // The actual audit event is transferred in one of the attributes below + oneof data { + option (buf.validate.oneof).required = true; + UnencryptedData unencrypted_data = 5; + EncryptedData encrypted_data = 6; + } +} \ No newline at end of file diff --git a/proto/buf.gen.yaml b/proto/buf.gen.yaml new file mode 100644 index 0000000..5cf2dd2 --- /dev/null +++ b/proto/buf.gen.yaml @@ -0,0 +1,34 @@ +version: v2 +managed: + enabled: true + override: + - file_option: java_package_prefix + value: "" + #override: + # - file_option: go_package_prefix + # : name in go.mod + # : where generated code should be output + # value: dev.azure.com/schwarzit/schwarzit.stackit-core-platform/message-schema + # Remove `disable` field if googleapis is not used + # disable: + # - module: buf.build/googleapis/googleapis + # file_option: go_package_prefix +plugins: + - remote: buf.build/protocolbuffers/go:v1.34.1 + out: ../gen/go + opt: + - paths=source_relative + - remote: buf.build/protocolbuffers/java:v25.3 + out: ../gen/java/schema-proto.jar + - remote: buf.build/bufbuild/validate-go:v1.0.4 + out: ../gen/go + opt: + - paths=source_relative + - remote: buf.build/bufbuild/validate-java:v1.0.4 + out: ../gen/java + opt: + - paths=source_relative + - remote: buf.build/protocolbuffers/python:v23.4 + out: ../gen/python +# - remote: buf.build/protocolbuffers/pyi +# out: ../gen/python \ No newline at end of file diff --git a/proto/buf.lock b/proto/buf.lock new file mode 100644 index 0000000..53f9512 --- /dev/null +++ b/proto/buf.lock @@ -0,0 +1,8 @@ +# Generated by buf. DO NOT EDIT. +version: v1 +deps: + - remote: buf.build + owner: bufbuild + repository: protovalidate + commit: 46a4cf4ba1094a34bcd89a6c67163b4b + digest: shake256:436ce453801917c11bc7b21d66bcfae87da2aceb804a041487be1e51dc9fbc219e61ea6a552db7a7aa6d63bb5efd0f3ed5fe3d4c42d4f750d0eb35f14144e3b6 diff --git a/proto/buf.yaml b/proto/buf.yaml new file mode 100644 index 0000000..cdfb0af --- /dev/null +++ b/proto/buf.yaml @@ -0,0 +1,9 @@ +version: v1 +breaking: + use: + - FILE +deps: + - buf.build/bufbuild/protovalidate +lint: + use: + - DEFAULT \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b2037c8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +protovalidate \ No newline at end of file diff --git a/solace.go b/solace.go new file mode 100644 index 0000000..e9abe72 --- /dev/null +++ b/solace.go @@ -0,0 +1,347 @@ +package main + +import ( + "context" + "errors" + "fmt" + "github.com/Azure/go-amqp" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + "log/slog" + "regexp" + "strings" + "time" +) + +const ( + AmqpTopicPrefix = "topic://" + AmqpQueuePrefix = "queue://" +) + +// SolaceContainer wraps a testcontainers docker container instance of solace. +// +// The container must be terminated by calling: +// solaceContainer.Terminate(ctx) +type SolaceContainer struct { + testcontainers.Container + AmqpConnectionString string + sempClient *Client +} + +// NewSolaceContainer starts a container and +func NewSolaceContainer(ctx context.Context) (*SolaceContainer, error) { + + env := make(map[string]string) + env["username_admin_globalaccesslevel"] = "admin" + env["username_admin_password"] = "admin" + + // Start docker container + request := testcontainers.ContainerRequest{ + Image: "solace/solace-pubsub-standard:10.8", + ExposedPorts: []string{"5672/tcp", "8080/tcp"}, + SkipReaper: true, + AutoRemove: true, + ShmSize: 1024 * 1024 * 1024, // 1 GB, + Env: env, + WaitingFor: wait.ForLog("Running pre-startup checks:"). + WithStartupTimeout(60 * time.Second), + } + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: request, + Started: true, + }) + if err != nil { + return nil, err + } + + // Extract host and port information + host, err := container.Host(ctx) + if err != nil { + _ = container.Terminate(ctx) + return nil, err + } + + amqpPort, err := container.MappedPort(ctx, "5672") + if err != nil { + _ = container.Terminate(ctx) + return nil, err + } + + sempPort, err := container.MappedPort(ctx, "8080") + if err != nil { + _ = container.Terminate(ctx) + return nil, err + } + slog.Info("UI Port: " + sempPort.Port()) + + // Construct connection strings + amqpConnectionString := fmt.Sprintf("amqp://%s:%s/", host, amqpPort.Port()) + sempApiBaseUrl := fmt.Sprintf("http://%s:%s/SEMP/v2", host, sempPort.Port()) + + // Construct SEMP client + sempClient := NewClient( + sempApiBaseUrl, + true, + false, + BasicAuth("admin", "admin"), + ) + + // Poll queue endpoint until solace is ready to interact + solaceStarting := true + for solaceStarting { + _, err := sempClient.RequestWithoutBody( + ctx, + "GET", + "/config/msgVpns/default/queues/test", + ) + if err != nil && strings.Contains(err.Error(), "NOT_FOUND") { + solaceStarting = false + } else { + time.Sleep(100 * time.Millisecond) + } + } + + // Return container object + return &SolaceContainer{ + Container: container, + AmqpConnectionString: amqpConnectionString, + sempClient: sempClient, + }, nil +} + +// QueueCreate creates a queue with the given name. +func (c SolaceContainer) QueueCreate(ctx context.Context, queueName string) error { + + // Construct parameters + var queueConfig = make(map[string]any) + queueConfig["accessType"] = "non-exclusive" + queueConfig["egressEnabled"] = true + queueConfig["ingressEnabled"] = true + queueConfig["permission"] = "consume" + queueConfig["queueName"] = queueName + queueConfig["maxBindCount"] = 100 + + // Create the queue + _, err := c.sempClient.RequestWithBody( + ctx, + "POST", + fmt.Sprintf("/config/msgVpns/default/queues"), queueConfig) + + return err +} + +// QueueExists checks if a queue with the given name exists. +func (c SolaceContainer) QueueExists(ctx context.Context, queueName string) (bool, error) { + + // Check if exists + _, err := c.sempClient.RequestWithoutBody( + ctx, + "GET", + fmt.Sprintf("/config/msgVpns/default/queues/%s", queueName), + ) + // Check if response contains "NOT_FOUND" string indicating that the queue doesn't exist + if err != nil { + if strings.Contains(err.Error(), "NOT_FOUND") { + return false, nil + } + + // Return technical errors + return false, err + } + + // Return queue exists + return true, nil +} + +// QueueDeleteIfExists deletes the queue with the given name if it exists. +func (c SolaceContainer) QueueDeleteIfExists(ctx context.Context, queueName string) error { + + // Check if queue exists + exists, err := c.QueueExists(ctx, queueName) + if err != nil { + return err + } + + // Delete if exists + if exists { + _, err := c.sempClient.RequestWithoutBody( + ctx, + "DELETE", + fmt.Sprintf("/config/msgVpns/default/queues/%s", queueName), + ) + return err + } + + return nil +} + +// TopicSubscriptionCreate creates a topic subscription for a (underlying) queue. +// +// Parameters: +// * ctx - the context object +// * queueName - the name of the queue where the topic(s) should be subscribed +// * topicName - the name of the topic with optional wildcards (e.g. "organizations/org-*") +func (c SolaceContainer) TopicSubscriptionCreate(ctx context.Context, queueName string, topicName string) error { + + // Construct url and parameters + url := fmt.Sprintf("/config/msgVpns/default/queues/%s/subscriptions", queueName) + + subscriptionConfig := make(map[string]any) + subscriptionConfig["subscriptionTopic"] = topicName + + // Create the subscription + _, err := c.sempClient.RequestWithBody(ctx, "POST", url, subscriptionConfig) + + return err +} + +func (c SolaceContainer) NewAmqpConnection(ctx context.Context) (*amqp.Conn, error) { + return amqp.Dial(ctx, c.AmqpConnectionString, nil) +} + +// ValidateTopicName checks that topicName and topicSubscriptionTopicPattern are valid and compatible +// Solace topic name constraints can be found here: +// https://docs.solace.com/Messaging/SMF-Topics.htm +func (c SolaceContainer) ValidateTopicName(topicSubscriptionTopicPattern string, topicName string) error { + // Cut off the topic:// prefix + var name string + if strings.HasPrefix(topicName, "topic://") { + name = topicName[len("topic://"):] + } else { + name = topicName + } + + // Check input + if topicSubscriptionTopicPattern == "" { + return errors.New("topicSubscriptionTopicPattern is empty") + } + if name == "" { + return errors.New("topicName is empty") + } + + // Check topic name + allowedTopicCharacters, err := regexp.Compile("[0-9A-Za-z-.]+(?:/[0-9A-Za-z-.]+)+|[0-9A-Za-z-.]+") + if err != nil { + return err + } + if !allowedTopicCharacters.MatchString(name) { + return errors.New("invalid topic name") + } + + // Check topic subscription topic pattern + allowedTopicSubscriptionCharacters, err := regexp.Compile("(?:(?:[0-9A-Za-z-.]+|[0-9A-Za-z-.]*\\*)(?:/(?:[0-9A-Za-z-.]+|[0-9A-Za-z-.]*\\*))+|(?:[0-9A-Za-z-.]+|[0-9A-Za-z-.]*\\*)|/>)|>") + if err != nil { + return err + } + if !allowedTopicSubscriptionCharacters.MatchString(topicSubscriptionTopicPattern) { + return errors.New("invalid topic subscription name") + } + + // Check compatibility + subscriptionIndex := 0 + var expectedNextCharacter uint8 = 0 + var nextError error + for i := 0; i < len(name); i++ { + if expectedNextCharacter != 0 { + if expectedNextCharacter != name[i] { + return nextError + } else { + expectedNextCharacter = 0 + nextError = nil + } + } + + switch topicSubscriptionTopicPattern[subscriptionIndex] { + case '*': + if name[i] == '/' { + expectedNextCharacter = '/' + nextError = errors.New(fmt.Sprintf("Invalid character '/' at index %d", i)) + subscriptionIndex++ + } + break + case '/': + if name[i] != '/' { + return errors.New(fmt.Sprintf("Expected character '/', got %c at index %d", name[i], i)) + } + subscriptionIndex++ + break + case '>': + // everything is allowed + break + default: + if name[i] != topicSubscriptionTopicPattern[subscriptionIndex] { + return errors.New(fmt.Sprintf( + "Expected character %c, got %c at index %d", + topicSubscriptionTopicPattern[subscriptionIndex], + name[i], + i, + )) + } else { + subscriptionIndex++ + } + } + } + return nil +} + +// NextMessageFromQueue returns the next message from the queue. +// It is important that the topic subscription matches the topic name. +// Otherwise, no message is returned and the test will fail by exceeding the timeout. +func (c SolaceContainer) NextMessageFromQueue( + ctx context.Context, + queueName string, + accept bool, +) (*amqp.Message, error) { + + return c.NextMessage(ctx, fmt.Sprintf("queue://%s", queueName), accept) +} + +func (c SolaceContainer) NextMessage(ctx context.Context, target string, accept bool) (*amqp.Message, error) { + if !strings.HasPrefix(target, AmqpTopicPrefix) && !strings.HasPrefix(target, AmqpQueuePrefix) { + return nil, fmt.Errorf( + "solace receive: target %q name lacks mandatory prefix %q, %q", + target, + AmqpTopicPrefix, + AmqpQueuePrefix, + ) + } + + connection, err := c.NewAmqpConnection(ctx) + if err != nil { + return nil, err + } + + session, err := connection.NewSession(ctx, nil) + if err != nil { + return nil, err + } + + receiver, err := session.NewReceiver(ctx, target, nil) + if err != nil { + return nil, err + } + + message, err := receiver.Receive(ctx, nil) + if err != nil { + return nil, err + } + + if accept { + err := receiver.AcceptMessage(ctx, message) + if err != nil { + return nil, err + } + } + + return message, nil +} + +func (c SolaceContainer) Stop() { + _ = c.Terminate(context.Background()) +} + +func (c SolaceContainer) StopOnError() { + if r := recover(); r != nil { + c.Stop() + } +} diff --git a/test_data.go b/test_data.go new file mode 100644 index 0000000..ae9570d --- /dev/null +++ b/test_data.go @@ -0,0 +1,186 @@ +package main + +import ( + auditV1 "audit-schema/gen/go/audit/v1" + "github.com/google/uuid" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" + "time" +) + +func NewOrganizationAuditEvent( + customization *func( + *auditV1.AuditEvent, + *RoutingIdentifier, + *auditV1.ObjectIdentifier, + )) ( + *auditV1.AuditEvent, + *RoutingIdentifier, + *auditV1.ObjectIdentifier, +) { + + auditEvent := &auditV1.AuditEvent{ + EventName: "ORGANIZATION_CREATED", + EventTimeStamp: timestamppb.New(time.Now()), + EventTrigger: auditV1.EventTrigger_EVENT_TRIGGER_EVENT, + Initiator: &auditV1.Principal{ + Id: uuid.NewString(), + }, + } + + identifier := uuid.New() + routingIdentifier := &RoutingIdentifier{ + Identifier: identifier, + Type: RoutingIdentifierTypeOrganization, + } + + objectIdentifier := &auditV1.ObjectIdentifier{ + Identifier: identifier.String(), + Type: auditV1.ObjectType_OBJECT_TYPE_ORGANIZATION, + } + + if customization != nil { + (*customization)(auditEvent, routingIdentifier, objectIdentifier) + } + + return auditEvent, routingIdentifier, objectIdentifier +} + +func NewOrganizationAuditEventWithDetails() (*auditV1.AuditEvent, + *RoutingIdentifier, + *auditV1.ObjectIdentifier) { + customization := func(event *auditV1.AuditEvent, + routingIdentifier *RoutingIdentifier, + objectIdentifier *auditV1.ObjectIdentifier) { + userAgent := "firefox" + parameters, _ := structpb.NewStruct(map[string]any{"parameter1": "value"}) + body, _ := structpb.NewStruct(map[string]any{"body": "value"}) + + event.Request = &auditV1.RequestDetails{ + Endpoint: "/test", + SourceIpAddress: "127.0.0.1", + UserAgent: &userAgent, + Parameters: parameters, + Body: body, + Headers: []*auditV1.RequestHeader{ + { + Key: "header1", + Value: "value", + }, + }, + } + + email := "test@example.com" + event.Principals = []*auditV1.Principal{ + { + Id: "id", + Email: &email, + }, + } + + details, _ := structpb.NewStruct(map[string]interface{}{ + "detail": "value", + }) + event.Details = details + + result, _ := structpb.NewStruct(map[string]interface{}{ + "result": "value", + }) + event.Result = result + } + return NewOrganizationAuditEvent(&customization) +} + +func NewFolderAuditEvent( + customization *func( + *auditV1.AuditEvent, + *RoutingIdentifier, + *auditV1.ObjectIdentifier, + )) ( + *auditV1.AuditEvent, + *RoutingIdentifier, + *auditV1.ObjectIdentifier, +) { + + auditEvent := &auditV1.AuditEvent{ + EventName: "FOLDER_CREATED", + EventTimeStamp: timestamppb.New(time.Now()), + EventTrigger: auditV1.EventTrigger_EVENT_TRIGGER_EVENT, + Initiator: &auditV1.Principal{ + Id: uuid.NewString(), + }, + } + + routingIdentifier := &RoutingIdentifier{ + Identifier: uuid.New(), + Type: RoutingIdentifierTypeOrganization, + } + + objectIdentifier := &auditV1.ObjectIdentifier{ + Identifier: uuid.New().String(), + Type: auditV1.ObjectType_OBJECT_TYPE_FOLDER, + } + + if customization != nil { + (*customization)(auditEvent, routingIdentifier, objectIdentifier) + } + + return auditEvent, routingIdentifier, objectIdentifier +} + +func NewProjectAuditEvent( + customization *func( + *auditV1.AuditEvent, + *RoutingIdentifier, + *auditV1.ObjectIdentifier, + )) ( + *auditV1.AuditEvent, + *RoutingIdentifier, + *auditV1.ObjectIdentifier, +) { + + auditEvent := &auditV1.AuditEvent{ + EventName: "PROJECT_CREATED", + EventTimeStamp: timestamppb.New(time.Now()), + EventTrigger: auditV1.EventTrigger_EVENT_TRIGGER_EVENT, + Initiator: &auditV1.Principal{ + Id: uuid.NewString(), + }, + } + + identifier := uuid.New() + routingIdentifier := &RoutingIdentifier{ + Identifier: identifier, + Type: RoutingIdentifierTypeProject, + } + + objectIdentifier := &auditV1.ObjectIdentifier{ + Identifier: identifier.String(), + Type: auditV1.ObjectType_OBJECT_TYPE_PROJECT, + } + + if customization != nil { + (*customization)(auditEvent, routingIdentifier, objectIdentifier) + } + + return auditEvent, routingIdentifier, objectIdentifier +} + +func NewSystemAuditEvent( + customization *func(*auditV1.AuditEvent)) *auditV1.AuditEvent { + + auditEvent := &auditV1.AuditEvent{ + EventName: "SYSTEM_CHANGED", + EventTimeStamp: timestamppb.New(time.Now()), + EventTrigger: auditV1.EventTrigger_EVENT_TRIGGER_EVENT, + Initiator: &auditV1.Principal{ + Id: uuid.NewString(), + }, + } + + if customization != nil { + (*customization)(auditEvent) + } + + return auditEvent +}