From de5d0a894806b21361f0f5a19fd973a9385a0e4d Mon Sep 17 00:00:00 2001 From: Christian Schaible Date: Wed, 26 Jun 2024 15:49:29 +0200 Subject: [PATCH] Add Schema and API draft --- .gitignore | 3 + README.md | 193 +++++++++++ api.go | 83 +++++ api_common.go | 167 ++++++++++ api_common_test.go | 227 +++++++++++++ api_legacy.go | 346 ++++++++++++++++++++ api_legacy_test.go | 486 ++++++++++++++++++++++++++++ api_mock.go | 51 +++ api_mock_test.go | 41 +++ api_routable.go | 114 +++++++ api_routable_test.go | 396 +++++++++++++++++++++++ audit-schema.iml | 15 + buf.lock | 2 + client.go | 219 +++++++++++++ go.mod | 74 +++++ go.sum | 231 +++++++++++++ main.go | 115 +++++++ main.py | 37 +++ messaging.go | 196 +++++++++++ messaging_test.go | 172 ++++++++++ proto/audit/v1/audit_event.proto | 83 +++++ proto/audit/v1/event.proto | 17 + proto/audit/v1/routable_event.proto | 89 +++++ proto/buf.gen.yaml | 34 ++ proto/buf.lock | 8 + proto/buf.yaml | 9 + requirements.txt | 1 + solace.go | 347 ++++++++++++++++++++ test_data.go | 186 +++++++++++ 29 files changed, 3942 insertions(+) create mode 100644 README.md create mode 100644 api.go create mode 100644 api_common.go create mode 100644 api_common_test.go create mode 100644 api_legacy.go create mode 100644 api_legacy_test.go create mode 100644 api_mock.go create mode 100644 api_mock_test.go create mode 100644 api_routable.go create mode 100644 api_routable_test.go create mode 100644 audit-schema.iml create mode 100644 buf.lock create mode 100644 client.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 main.py create mode 100644 messaging.go create mode 100644 messaging_test.go create mode 100644 proto/audit/v1/audit_event.proto create mode 100644 proto/audit/v1/event.proto create mode 100644 proto/audit/v1/routable_event.proto create mode 100644 proto/buf.gen.yaml create mode 100644 proto/buf.lock create mode 100644 proto/buf.yaml create mode 100644 requirements.txt create mode 100644 solace.go create mode 100644 test_data.go 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 +}