Add Schema and API draft

This commit is contained in:
Christian Schaible 2024-06-26 15:49:29 +02:00
parent b7171c2177
commit de5d0a8948
29 changed files with 3942 additions and 0 deletions

3
.gitignore vendored
View file

@ -170,3 +170,6 @@ fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Buf
gen

193
README.md Normal file
View file

@ -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

83
api.go Normal file
View file

@ -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
}

167
api_common.go Normal file
View file

@ -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: <value>
// * Visibility: Private, ObjectIdentifier: <value | nil> -> 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())
}

227
api_common_test.go Normal file
View file

@ -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))
}

346
api_legacy.go Normal file
View file

@ -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: &parameters,
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"`
}

486
api_legacy_test.go Normal file
View file

@ -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)
}

51
api_mock.go Normal file
View file

@ -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
}

41
api_mock_test.go Normal file
View file

@ -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))
})
}

114
api_routable.go Normal file
View file

@ -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)
}

396
api_routable_test.go Normal file
View file

@ -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)
}

15
audit-schema.iml Normal file
View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="FacetManager">
<facet type="Python" name="Python">
<configuration sdkName="Python 3.9 (kafka)" />
</facet>
</component>
<component name="Go" enabled="true" />
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="Python 3.9 (kafka) interpreter library" level="application" />
</component>
</module>

2
buf.lock Normal file
View file

@ -0,0 +1,2 @@
# Generated by buf. DO NOT EDIT.
version: v1

219
client.go Normal file
View file

@ -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)
}

74
go.mod Normal file
View file

@ -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
)

231
go.sum Normal file
View file

@ -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=

115
main.go Normal file
View file

@ -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
}

37
main.py Normal file
View file

@ -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())

196
messaging.go Normal file
View file

@ -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()
}

172
messaging_test.go Normal file
View file

@ -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)
})
}

View file

@ -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 <TYPE>_<ACTION>, 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;
}

View file

@ -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];
}

View file

@ -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 <TYPE>_<ACTION>, 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;
}
}

34
proto/buf.gen.yaml Normal file
View file

@ -0,0 +1,34 @@
version: v2
managed:
enabled: true
override:
- file_option: java_package_prefix
value: ""
#override:
# - file_option: go_package_prefix
# <module_name> : name in go.mod
# <relative_path> : 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

8
proto/buf.lock Normal file
View file

@ -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

9
proto/buf.yaml Normal file
View file

@ -0,0 +1,9 @@
version: v1
breaking:
use:
- FILE
deps:
- buf.build/bufbuild/protovalidate
lint:
use:
- DEFAULT

1
requirements.txt Normal file
View file

@ -0,0 +1 @@
protovalidate

347
solace.go Normal file
View file

@ -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()
}
}

186
test_data.go Normal file
View file

@ -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
}