diff --git a/audit/api/builder.go b/audit/api/builder.go new file mode 100644 index 0000000..176789d --- /dev/null +++ b/audit/api/builder.go @@ -0,0 +1,584 @@ +package api + +import ( + "context" + "dev.azure.com/schwarzit/schwarzit.stackit-core-platform/audit-go.git/audit/utils" + auditV1 "dev.azure.com/schwarzit/schwarzit.stackit-core-platform/audit-go.git/gen/go/audit/v1" + "errors" + "fmt" + "go.opentelemetry.io/otel/trace" + "log/slog" + "time" +) + +type SequenceNumber uint64 + +type AuditParameters struct { + + // A map that is added as "details" to the message + Details map[string]interface{} + + // The type of the event + EventType EventType + + // A set of user-defined (key, value) data that provides additional + // information about the log entry. + Labels map[string]string + + // UUID identifier of the object, the audit event refers to + ObjectId string + + // Type of the object, the audit event refers to + ObjectType SingularType + + // Log severity + Severity auditV1.LogSeverity +} + +func getObjectIdAndTypeFromAuditParams( + ctx context.Context, + auditParams *AuditParameters, +) (string, *SingularType, *PluralType, error) { + + objectId := auditParams.ObjectId + if objectId == "" { + return "", nil, nil, errors.New("object id missing") + } + + var objectType *SingularType + if auditParams.ObjectType != "" { + objectType = &auditParams.ObjectType + } + + if objectType == nil { + return "", nil, nil, errors.New("singular type missing") + } + + // Convert to plural type + plural, err := objectType.AsPluralType() + if err != nil { + slog.LogAttrs(ctx, slog.LevelError, "failed to convert singular type to plural type", slog.Any("error", err)) + return "", nil, nil, err + } + return objectId, objectType, &plural, nil +} + +// AuditLogEntryBuilder collects audit params to construct auditV1.AuditLogEntry +type AuditLogEntryBuilder struct { + auditParams AuditParameters + auditRequest AuditRequest + auditResponse AuditResponse + auditMetadata AuditMetadata + + // Region and optional zone id. If both, separated with a - (dash). + // Example: eu01 + location string + + // The ID of the K8s Pod, Service-Instance, etc (must be unique for a sending service) + workerId string +} + +// NewAuditLogEntryBuilder returns a builder to construct auditV1.AuditLogEntry +func NewAuditLogEntryBuilder() *AuditLogEntryBuilder { + + requestTime := time.Now().UTC() + + return &AuditLogEntryBuilder{ + auditParams: AuditParameters{ + EventType: EventTypeAdminActivity, + }, + auditRequest: AuditRequest{ + Request: nil, + RequestClientIP: "0.0.0.0", + RequestCorrelationId: nil, + RequestId: nil, + RequestTime: &requestTime, + }, + auditResponse: AuditResponse{ + ResponseBodyBytes: nil, + ResponseStatusCode: 200, + ResponseHeaders: make(map[string][]string), + ResponseNumItems: nil, + ResponseTime: nil, + }, + auditMetadata: AuditMetadata{ + AuditInsertId: "", + AuditLabels: nil, + AuditLogName: "", + AuditLogSeverity: auditV1.LogSeverity_LOG_SEVERITY_DEFAULT, + AuditOperationName: "", + AuditPermission: nil, + AuditPermissionGranted: nil, + AuditResourceName: "", + AuditServiceName: "", + AuditTime: nil, + }, + location: "", + workerId: "", + } +} + +// WithRequiredApiRequest adds api request details +func (builder *AuditLogEntryBuilder) WithRequiredApiRequest(request ApiRequest) *AuditLogEntryBuilder { + builder.auditRequest.Request = &request + return builder +} + +// WithRequiredLocation adds the region and optional zone id. If both, separated with a - (dash). +// Example: eu01 +func (builder *AuditLogEntryBuilder) WithRequiredLocation(location string) *AuditLogEntryBuilder { + builder.location = location + return builder +} + +// WithRequiredRequestClientIp adds the client ip +func (builder *AuditLogEntryBuilder) WithRequiredRequestClientIp(requestClientIp string) *AuditLogEntryBuilder { + builder.auditRequest.RequestClientIP = requestClientIp + return builder +} + +// WithRequestCorrelationId adds an optional request correlation id +func (builder *AuditLogEntryBuilder) WithRequestCorrelationId(requestCorrelationId string) *AuditLogEntryBuilder { + builder.auditRequest.RequestCorrelationId = &requestCorrelationId + return builder +} + +// WithRequestId adds an optional request id +func (builder *AuditLogEntryBuilder) WithRequestId(requestId string) *AuditLogEntryBuilder { + builder.auditRequest.RequestId = &requestId + return builder +} + +// WithRequestTime sets the request time on the builder. If not set - the instantiation time of the builder is used. +func (builder *AuditLogEntryBuilder) WithRequestTime(requestTime time.Time) *AuditLogEntryBuilder { + builder.auditRequest.RequestTime = &requestTime + return builder +} + +// WithRequiredServiceName adds the service name in lowercase (allowed characters are [a-z-]). +func (builder *AuditLogEntryBuilder) WithRequiredServiceName(serviceName string) *AuditLogEntryBuilder { + builder.auditMetadata.AuditServiceName = serviceName + return builder +} + +// WithRequiredWorkerId adds the ID of the K8s Pod, Service-Instance, etc. (must be unique for a sending service) +func (builder *AuditLogEntryBuilder) WithRequiredWorkerId(workerId string) *AuditLogEntryBuilder { + builder.workerId = workerId + return builder +} + +// WithRequiredObjectId adds the object identifier. +// May be prefilled by audit middleware (if the identifier can be extracted from the url path). +func (builder *AuditLogEntryBuilder) WithRequiredObjectId(objectId string) *AuditLogEntryBuilder { + builder.auditParams.ObjectId = objectId + return builder +} + +// WithRequiredObjectType adds the object type. +// May be prefilled by audit middleware (if the type can be extracted from the url path). +func (builder *AuditLogEntryBuilder) WithRequiredObjectType(objectType SingularType) *AuditLogEntryBuilder { + builder.auditParams.ObjectType = objectType + return builder +} + +// WithRequiredOperation adds the name of the service method or operation. +// +// Format: stackit.... +// Where: +// +// Product: The name of the service in lowercase +// Version: Optional API version +// Type-Chain: Chained path to object +// Operation: The name of the operation in lowercase +// +// Examples: +// +// "stackit.resource-manager.v1.organizations.create" +// "stackit.authorization.v1.projects.volumes.create" +// "stackit.authorization.v2alpha.projects.volumes.create" +// "stackit.authorization.v2.folders.move" +// "stackit.resource-manager.health" +func (builder *AuditLogEntryBuilder) WithRequiredOperation(operation string) *AuditLogEntryBuilder { + builder.auditMetadata.AuditOperationName = operation + return builder +} + +// WithAuditPermission adds the IAM permission +// +// Examples: +// +// "resourcemanager.project.edit" +func (builder *AuditLogEntryBuilder) WithAuditPermission(permission string) *AuditLogEntryBuilder { + builder.auditMetadata.AuditPermission = &permission + return builder +} + +// WithAuditPermissionCheckResult adds the IAM permission check result +func (builder *AuditLogEntryBuilder) WithAuditPermissionCheckResult(permissionCheckResult bool) *AuditLogEntryBuilder { + builder.auditMetadata.AuditPermissionGranted = &permissionCheckResult + return builder +} + +// WithLabels adds A set of user-defined (key, value) data that provides additional +// information about the log entry. +func (builder *AuditLogEntryBuilder) WithLabels(labels map[string]string) *AuditLogEntryBuilder { + builder.auditMetadata.AuditLabels = &labels + return builder +} + +// WithNumResponseItems adds the number of items returned to the client if applicable. +func (builder *AuditLogEntryBuilder) WithNumResponseItems(numResponseItems int64) *AuditLogEntryBuilder { + builder.auditResponse.ResponseNumItems = &numResponseItems + return builder +} + +// WithEventType overwrites the default event type EventTypeAdminActivity +func (builder *AuditLogEntryBuilder) WithEventType(eventType EventType) *AuditLogEntryBuilder { + builder.auditParams.EventType = eventType + return builder +} + +// WithDetails adds an optional details object to the audit log entry +func (builder *AuditLogEntryBuilder) WithDetails(details map[string]interface{}) *AuditLogEntryBuilder { + builder.auditParams.Details = details + return builder +} + +// WithSeverity overwrites the default log severity level auditV1.LogSeverity_LOG_SEVERITY_DEFAULT +func (builder *AuditLogEntryBuilder) WithSeverity(severity auditV1.LogSeverity) *AuditLogEntryBuilder { + builder.auditMetadata.AuditLogSeverity = severity + return builder +} + +// WithStatusCode adds the (http) response status code +func (builder *AuditLogEntryBuilder) WithStatusCode(statusCode int) *AuditLogEntryBuilder { + builder.auditResponse.ResponseStatusCode = statusCode + return builder +} + +// WithResponseBodyBytes adds the response body as bytes (json or protobuf expected) +func (builder *AuditLogEntryBuilder) WithResponseBodyBytes(responseBody *[]byte) *AuditLogEntryBuilder { + builder.auditResponse.ResponseBodyBytes = responseBody + return builder +} + +// WithResponseHeaders adds response headers +func (builder *AuditLogEntryBuilder) WithResponseHeaders(responseHeaders map[string][]string) *AuditLogEntryBuilder { + builder.auditResponse.ResponseHeaders = responseHeaders + return builder +} + +// WithResponseTime adds the time when the response is sent +func (builder *AuditLogEntryBuilder) WithResponseTime(responseTime time.Time) *AuditLogEntryBuilder { + builder.auditResponse.ResponseTime = &responseTime + return builder +} + +// Build constructs the auditV1.AuditLogEntry. +// +// Parameters: +// - A context object +// - A SequenceNumber +// +// Returns: +// - The auditV1.AuditLogEntry protobuf message or +// - Error if the entry cannot be built +func (builder *AuditLogEntryBuilder) Build(ctx context.Context, sequenceNumber SequenceNumber) (*auditV1.AuditLogEntry, error) { + auditTime := time.Now() + builder.auditMetadata.AuditTime = &auditTime + + objectId, _, pluralType, err := getObjectIdAndTypeFromAuditParams(ctx, &builder.auditParams) + if err != nil { + return nil, err + } + + resourceName := fmt.Sprintf("%s/%s", *pluralType, objectId) + builder.auditMetadata.AuditInsertId = NewInsertId(time.Now().UTC(), builder.location, builder.workerId, uint64(sequenceNumber)) + builder.auditMetadata.AuditLogName = fmt.Sprintf("%s/%s/logs/%s", *pluralType, objectId, builder.auditParams.EventType) + builder.auditMetadata.AuditResourceName = resourceName + + var details *map[string]interface{} = nil + if len(builder.auditParams.Details) > 0 { + details = &builder.auditParams.Details + } + + // Instantiate the audit event + return NewAuditLogEntry( + builder.auditRequest, + builder.auditResponse, + details, + builder.auditMetadata, + nil, + nil, + ) +} + +// AuditEventBuilder collects audit log parameters, validates input and +// returns a cloud event that can be sent to the audit log system. +type AuditEventBuilder struct { + + // The audit api used to validate, serialize and send events + api *AuditApi + + // The audit log entry builder which is used to build the actual protobuf message + auditLogEntryBuilder *AuditLogEntryBuilder + + // Status whether the event has been built + built bool + + // Sequence number generator providing sequential increasing numbers for the insert IDs + sequenceNumberGenerator *utils.SequenceNumberGenerator + + // Opentelemtry tracer + tracer trace.Tracer + + // Visibility of the event + visibility auditV1.Visibility +} + +// NewAuditEventBuilder returns a builder that collects audit log parameters, +// validates input and returns a cloud event that can be sent to the audit log system. +func NewAuditEventBuilder( + // The audit api used to validate, serialize and send events + api *AuditApi, + + // The sequence number generator can be used to get and revert sequence numbers to build audit log events + sequenceNumberGenerator *utils.SequenceNumberGenerator, + + // Tracer + tracer trace.Tracer, + + // The service name in lowercase (allowed characters are [a-z-]). + serviceName string, + + // The ID of the K8s Pod, Service-Instance, etc. (must be unique for a sending service) + workerId string, + + // The location of the service (e.g. eu01) + location string, +) *AuditEventBuilder { + return &AuditEventBuilder{ + api: api, + auditLogEntryBuilder: NewAuditLogEntryBuilder(). + WithRequiredServiceName(serviceName). + WithRequiredWorkerId(workerId). + WithRequiredLocation(location), + sequenceNumberGenerator: sequenceNumberGenerator, + tracer: tracer, + visibility: auditV1.Visibility_VISIBILITY_PUBLIC, + } +} + +// NextSequenceNumber returns the next sequence number from utils.SequenceNumberGenerator. +// In case of an error RevertSequenceNumber must be called to prevent gaps in the sequence of numbers. +func (builder *AuditEventBuilder) NextSequenceNumber() SequenceNumber { + return SequenceNumber((*builder.sequenceNumberGenerator).Next()) +} + +// RevertSequenceNumber can be called to decrease the sequence number on the utils.SequenceNumberGenerator in case of an error +func (builder *AuditEventBuilder) RevertSequenceNumber() { + (*builder.sequenceNumberGenerator).Revert() +} + +// WithAuditLogEntryBuilder overwrites the preconfigured AuditLogEntryBuilder +func (builder *AuditEventBuilder) WithAuditLogEntryBuilder(auditLogEntryBuilder *AuditLogEntryBuilder) *AuditEventBuilder { + builder.auditLogEntryBuilder = auditLogEntryBuilder + return builder +} + +// WithRequiredApiRequest adds api request details +func (builder *AuditEventBuilder) WithRequiredApiRequest(request ApiRequest) *AuditEventBuilder { + builder.auditLogEntryBuilder.WithRequiredApiRequest(request) + return builder +} + +// WithRequiredRequestClientIp adds the client ip +func (builder *AuditEventBuilder) WithRequiredRequestClientIp(requestClientIp string) *AuditEventBuilder { + builder.auditLogEntryBuilder.WithRequiredRequestClientIp(requestClientIp) + return builder +} + +// WithRequestCorrelationId adds an optional request correlation id +func (builder *AuditEventBuilder) WithRequestCorrelationId(requestCorrelationId string) *AuditEventBuilder { + builder.auditLogEntryBuilder.WithRequestCorrelationId(requestCorrelationId) + return builder +} + +// WithRequestId adds an optional request id +func (builder *AuditEventBuilder) WithRequestId(requestId string) *AuditEventBuilder { + builder.auditLogEntryBuilder.WithRequestId(requestId) + return builder +} + +// WithRequestTime sets the request time on the builder. If not set - the instantiation time of the builder is used. +func (builder *AuditEventBuilder) WithRequestTime(requestTime time.Time) *AuditEventBuilder { + builder.auditLogEntryBuilder.WithRequestTime(requestTime) + return builder +} + +// WithRequiredObjectId adds the object identifier. +// May be prefilled by audit middleware (if the identifier can be extracted from the url path). +func (builder *AuditEventBuilder) WithRequiredObjectId(objectId string) *AuditEventBuilder { + builder.auditLogEntryBuilder.WithRequiredObjectId(objectId) + return builder +} + +// WithRequiredObjectType adds the object type. +// May be prefilled by audit middleware (if the type can be extracted from the url path). +func (builder *AuditEventBuilder) WithRequiredObjectType(objectType SingularType) *AuditEventBuilder { + builder.auditLogEntryBuilder.WithRequiredObjectType(objectType) + return builder +} + +// WithRequiredOperation adds the name of the service method or operation. +// +// Format: stackit.... +// Where: +// +// Product: The name of the service in lowercase +// Version: Optional API version +// Type-Chain: Chained path to object +// Operation: The name of the operation in lowercase +// +// Examples: +// +// "stackit.resource-manager.v1.organizations.create" +// "stackit.authorization.v1.projects.volumes.create" +// "stackit.authorization.v2alpha.projects.volumes.create" +// "stackit.authorization.v2.folders.move" +// "stackit.resource-manager.health" +func (builder *AuditEventBuilder) WithRequiredOperation(operation string) *AuditEventBuilder { + builder.auditLogEntryBuilder.auditMetadata.AuditOperationName = operation + return builder +} + +// WithAuditPermission adds the IAM permission +// +// Examples: +// +// "resourcemanager.project.edit" +func (builder *AuditEventBuilder) WithAuditPermission(permission string) *AuditEventBuilder { + builder.auditLogEntryBuilder.WithAuditPermission(permission) + return builder +} + +// WithAuditPermissionCheckResult adds the IAM permission check result +func (builder *AuditEventBuilder) WithAuditPermissionCheckResult(permissionCheckResult bool) *AuditEventBuilder { + builder.auditLogEntryBuilder.WithAuditPermissionCheckResult(permissionCheckResult) + return builder +} + +// WithLabels adds A set of user-defined (key, value) data that provides additional +// information about the log entry. +func (builder *AuditEventBuilder) WithLabels(labels map[string]string) *AuditEventBuilder { + builder.auditLogEntryBuilder.WithLabels(labels) + return builder +} + +// WithNumResponseItems adds the number of items returned to the client if applicable. +func (builder *AuditEventBuilder) WithNumResponseItems(numResponseItems int64) *AuditEventBuilder { + builder.auditLogEntryBuilder.WithNumResponseItems(numResponseItems) + return builder +} + +// WithEventType overwrites the default event type EventTypeAdminActivity +func (builder *AuditEventBuilder) WithEventType(eventType EventType) *AuditEventBuilder { + builder.auditLogEntryBuilder.WithEventType(eventType) + return builder +} + +// WithDetails adds an optional details object to the audit log entry +func (builder *AuditEventBuilder) WithDetails(details map[string]interface{}) *AuditEventBuilder { + builder.auditLogEntryBuilder.WithDetails(details) + return builder +} + +// WithSeverity overwrites the default log severity level auditV1.LogSeverity_LOG_SEVERITY_DEFAULT +func (builder *AuditEventBuilder) WithSeverity(severity auditV1.LogSeverity) *AuditEventBuilder { + builder.auditLogEntryBuilder.WithSeverity(severity) + return builder +} + +// WithStatusCode adds the (http) response status code +func (builder *AuditEventBuilder) WithStatusCode(statusCode int) *AuditEventBuilder { + builder.auditLogEntryBuilder.WithStatusCode(statusCode) + return builder +} + +// WithResponseBodyBytes adds the response body as bytes (json or protobuf expected) +func (builder *AuditEventBuilder) WithResponseBodyBytes(responseBody *[]byte) *AuditEventBuilder { + builder.auditLogEntryBuilder.WithResponseBodyBytes(responseBody) + return builder +} + +// WithResponseHeaders adds response headers +func (builder *AuditEventBuilder) WithResponseHeaders(responseHeaders map[string][]string) *AuditEventBuilder { + builder.auditLogEntryBuilder.WithResponseHeaders(responseHeaders) + return builder +} + +// WithResponseTime adds the time when the response is sent +func (builder *AuditEventBuilder) WithResponseTime(responseTime time.Time) *AuditEventBuilder { + builder.auditLogEntryBuilder.WithResponseTime(responseTime) + return builder +} + +// WithVisibility overwrites the default visibility auditV1.Visibility_VISIBILITY_PUBLIC +func (builder *AuditEventBuilder) WithVisibility(visibility auditV1.Visibility) *AuditEventBuilder { + builder.visibility = visibility + return builder +} + +// IsBuilt returns the status whether the cloud event has been built +func (builder *AuditEventBuilder) IsBuilt() bool { + return builder.built +} + +// Build constructs the CloudEvent. +// +// Parameters: +// - A context object +// - A sequence number. AuditEventBuilder.NextSequenceNumber can be used to get the next SequenceNumber. +// +// Returns: +// - The CloudEvent containing the audit log entry +// - The RoutableIdentifier required for routing the cloud event +// - The operation name +// - Error if the event cannot be built +func (builder *AuditEventBuilder) Build(ctx context.Context, sequenceNumber SequenceNumber) (*CloudEvent, *RoutableIdentifier, string, error) { + if builder.auditLogEntryBuilder == nil { + return nil, nil, "", fmt.Errorf("audit log entry builder not set") + } + auditLogEntry, err := builder.auditLogEntryBuilder.Build(ctx, sequenceNumber) + if err != nil { + return nil, nil, "", err + } + + objectId := builder.auditLogEntryBuilder.auditParams.ObjectId + objectType := builder.auditLogEntryBuilder.auditParams.ObjectType + + ctx, span := builder.tracer.Start(ctx, "create-audit-event") + defer span.End() + + w3cTraceParent := TraceParentFromSpan(span) + var traceParent = &w3cTraceParent + var traceState *string = nil + visibility := builder.visibility + operation := auditLogEntry.ProtoPayload.OperationName + routingIdentifier := NewAuditRoutingIdentifier(objectId, objectType) + + // Validate and serialize the protobuf event into a cloud event + _, validateSerializeSpan := builder.tracer.Start(ctx, "validate-and-serialize-audit-event") + cloudEvent, err := (*builder.api).ValidateAndSerializeWithTrace(auditLogEntry, visibility, routingIdentifier, traceParent, traceState) + validateSerializeSpan.End() + + if err != nil { + return nil, nil, "", err + } + + builder.built = true + return cloudEvent, + routingIdentifier, + operation, + nil +} diff --git a/audit/api/builder_test.go b/audit/api/builder_test.go new file mode 100644 index 0000000..86a00c9 --- /dev/null +++ b/audit/api/builder_test.go @@ -0,0 +1,616 @@ +package api + +import ( + "context" + "dev.azure.com/schwarzit/schwarzit.stackit-core-platform/audit-go.git/audit/utils" + auditV1 "dev.azure.com/schwarzit/schwarzit.stackit-core-platform/audit-go.git/gen/go/audit/v1" + "fmt" + "github.com/bufbuild/protovalidate-go" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/wrapperspb" + "testing" + "time" +) + +func Test_getObjectIdAndTypeFromAuditParams(t *testing.T) { + + t.Run( + "object id empty", func(t *testing.T) { + objectId, objectType, objectTypePlural, err := getObjectIdAndTypeFromAuditParams(context.Background(), &AuditParameters{}) + assert.EqualError(t, err, "object id missing") + assert.Equal(t, "", objectId) + assert.Nil(t, objectType) + assert.Nil(t, objectTypePlural) + }, + ) + + t.Run( + "object type empty", func(t *testing.T) { + objectId, objectType, objectTypePlural, err := getObjectIdAndTypeFromAuditParams(context.Background(), &AuditParameters{ObjectId: "value"}) + assert.EqualError(t, err, "singular type missing") + assert.Equal(t, "", objectId) + assert.Nil(t, objectType) + assert.Nil(t, objectTypePlural) + }, + ) + + t.Run( + "object id and invalid type set", func(t *testing.T) { + objectId, objectType, objectTypePlural, err := getObjectIdAndTypeFromAuditParams( + context.Background(), &AuditParameters{ + ObjectId: "value", + ObjectType: AsSingularType("invalid"), + }, + ) + assert.EqualError(t, err, "unknown singular type") + assert.Equal(t, "", objectId) + assert.Nil(t, objectType) + assert.Nil(t, objectTypePlural) + }, + ) + + t.Run( + "object id and type set", func(t *testing.T) { + objectId, objectType, objectTypePlural, err := getObjectIdAndTypeFromAuditParams( + context.Background(), &AuditParameters{ + ObjectId: "value", + ObjectType: SingularTypeProject, + }, + ) + assert.NoError(t, err) + assert.Equal(t, "value", objectId) + assert.Equal(t, SingularTypeProject, *objectType) + assert.Equal(t, PluralTypeProject, *objectTypePlural) + }, + ) +} + +func Test_AuditLogEntryBuilder(t *testing.T) { + + t.Run("required only", func(t *testing.T) { + builder := NewAuditLogEntryBuilder(). + WithRequiredLocation("eu01"). + WithRequiredObjectId("1"). + WithRequiredObjectType(SingularTypeProject). + WithRequiredOperation("stackit.demo-service.v1.operation"). + WithRequiredApiRequest(ApiRequest{ + Body: nil, + Header: map[string][]string{"user-agent": {"custom"}, "authorization": {"Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlOGJlZjc1LWRmY2QtNGE3My1hMzkxLTU0YTdhZjU3YTdkNiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsic3RhY2tpdC1wb3J0YWwtbG9naW4tZGV2LWNsaWVudC1pZCJdLCJjbGllbnRfaWQiOiJzdGFja2l0LXBvcnRhbC1sb2dpbi1kZXYtY2xpZW50LWlkIiwiZW1haWwiOiJDaHJpc3RpYW4uU2NoYWlibGVAbm92YXRlYy1nbWJoLmRlIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTcyMjU5MDM2NywiaWF0IjoxNzIyNTg2NzY3LCJpc3MiOiJodHRwczovL2FjY291bnRzLmRldi5zdGFja2l0LmNsb3VkIiwianRpIjoiZDczYTY3YWMtZDFlYy00YjU1LTk5ZDQtZTk1MzI3NWYwMjJhIiwibmJmIjoxNzIyNTg2NzY3LCJzY29wZSI6Im9wZW5pZCBlbWFpbCIsInN1YiI6ImNkOTRmMDFhLWRmMmUtNDQ1Ni05MDJlLTQ4ZjVlNTdmMGI2MyJ9.ajhjYbC5l5g7un9NSheoAwBT83YcZM91rH4DJxPTDsB78HzIVrmaKTPrK3AI_E1THlD2Z3_ot9nFr_eX7XcwWp_ZBlataKmakdXlAmeb4xSMGNYefIfzV_3w9ZZAZ66yoeTrtn8dUx5ezquenCYpctB1NcccmK4U09V0kNcq9dFcfF3Sg9YilF3orUCR0ql1d9RnOs3EiFZuUpdBEkyoVsAdSh2P-PRbNViR_FgCcAJem97TsN5CQc9RlvKYe4sYKgqQoqa2GDVi9Niiw3fe1V8SCnROYcpkOzBBWdvuzFMBUjln3uOogYVOz93xkmImV6jidgyQ70fLt-eDUmZZfg"}}, + Host: "localhost", + Method: "POST", + Scheme: "https", + Proto: "HTTP/1.1", + URL: RequestUrl{ + Path: "/", + RawQuery: nil, + }, + }). + WithRequiredRequestClientIp("127.0.0.1"). + WithRequiredServiceName("demo-service"). + WithRequiredWorkerId("worker-id") + + logEntry, err := builder.Build(context.Background(), SequenceNumber(1)) + assert.NoError(t, err) + assert.NotNil(t, logEntry) + + assert.Equal(t, "projects/1/logs/admin-activity", logEntry.LogName) + assert.Nil(t, logEntry.Labels) + assert.Nil(t, logEntry.TraceState) + assert.Nil(t, logEntry.TraceParent) + assert.Equal(t, auditV1.LogSeverity_LOG_SEVERITY_DEFAULT, logEntry.Severity) + assert.NotNil(t, logEntry.Timestamp) + assert.Nil(t, logEntry.CorrelationId) + assert.Regexp(t, "[0-9]+/eu01/worker-id/1", logEntry.InsertId) + + assert.NotNil(t, logEntry.ProtoPayload) + + authenticationInfo := logEntry.ProtoPayload.AuthenticationInfo + assert.NotNil(t, authenticationInfo) + assert.Equal(t, "Christian.Schaible@novatec-gmbh.de", authenticationInfo.PrincipalEmail) + assert.Equal(t, "cd94f01a-df2e-4456-902e-48f5e57f0b63", authenticationInfo.PrincipalId) + assert.Nil(t, authenticationInfo.ServiceAccountDelegationInfo) + assert.Nil(t, authenticationInfo.ServiceAccountName) + + assert.Nil(t, logEntry.ProtoPayload.AuthorizationInfo) + assert.Nil(t, logEntry.ProtoPayload.Metadata) + assert.Equal(t, "stackit.demo-service.v1.operation", logEntry.ProtoPayload.OperationName) + assert.Nil(t, logEntry.ProtoPayload.Request) + + requestMetadata := logEntry.ProtoPayload.RequestMetadata + assert.NotNil(t, requestMetadata) + assert.Equal(t, "127.0.0.1", requestMetadata.CallerIp) + assert.Equal(t, "custom", requestMetadata.CallerSuppliedUserAgent) + + requestAttributes := requestMetadata.RequestAttributes + assert.NotNil(t, requestAttributes) + assert.Equal(t, "/", requestAttributes.Path) + assert.NotNil(t, requestAttributes.Time) + assert.Equal(t, "localhost", requestAttributes.Host) + assert.Equal(t, auditV1.AttributeContext_HTTP_METHOD_POST, requestAttributes.Method) + assert.Nil(t, requestAttributes.Id) + assert.Equal(t, "https", requestAttributes.Scheme) + assert.Equal(t, map[string]string{"user-agent": "custom"}, requestAttributes.Headers) + assert.Nil(t, requestAttributes.Query) + assert.Equal(t, "HTTP/1.1", requestAttributes.Protocol) + + requestAttributesAuth := requestAttributes.Auth + assert.NotNil(t, requestAttributesAuth) + assert.Equal(t, "cd94f01a-df2e-4456-902e-48f5e57f0b63/https%3A%2F%2Faccounts.dev.stackit.cloud", requestAttributesAuth.Principal) + assert.Equal(t, []string{"stackit-portal-login-dev-client-id"}, requestAttributesAuth.Audiences) + assert.NotNil(t, requestAttributesAuth.Claims) + + assert.Equal(t, "projects/1", logEntry.ProtoPayload.ResourceName) + assert.Nil(t, logEntry.ProtoPayload.Response) + + responseMetadata := logEntry.ProtoPayload.ResponseMetadata + assert.NotNil(t, responseMetadata) + assert.Nil(t, responseMetadata.ErrorDetails) + assert.Nil(t, responseMetadata.ErrorMessage) + assert.Equal(t, wrapperspb.Int32(200), responseMetadata.StatusCode) + + responseAttributes := responseMetadata.ResponseAttributes + assert.NotNil(t, responseAttributes) + assert.Nil(t, responseAttributes.Headers) + assert.Nil(t, responseAttributes.NumResponseItems) + assert.Nil(t, responseAttributes.Size) + assert.NotNil(t, responseAttributes.Time) + + assert.Equal(t, "demo-service", logEntry.ProtoPayload.ServiceName) + + validator, err := protovalidate.New() + assert.NoError(t, err) + err = validator.Validate(logEntry) + assert.NoError(t, err) + }) + + t.Run("with details", func(t *testing.T) { + details := map[string]interface{}{"key": "detail"} + permission := "project.edit" + permissionCheckResult := true + requestTime := time.Now().AddDate(0, 0, -2).UTC() + responseTime := time.Now().AddDate(0, 0, -1).UTC() + responseBody := map[string]interface{}{"key": "response"} + responseBodyBytes, err := ResponseBodyToBytes(responseBody) + assert.NoError(t, err) + builder := NewAuditLogEntryBuilder(). + WithRequiredLocation("eu01"). + WithRequiredObjectId("1"). + WithRequiredObjectType(SingularTypeProject). + WithRequiredOperation("stackit.demo-service.v1.operation"). + WithRequiredApiRequest(ApiRequest{ + Body: nil, + Header: map[string][]string{"user-agent": {"custom"}, "authorization": {"Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlOGJlZjc1LWRmY2QtNGE3My1hMzkxLTU0YTdhZjU3YTdkNiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsic3RhY2tpdC1wb3J0YWwtbG9naW4tZGV2LWNsaWVudC1pZCJdLCJjbGllbnRfaWQiOiJzdGFja2l0LXBvcnRhbC1sb2dpbi1kZXYtY2xpZW50LWlkIiwiZW1haWwiOiJDaHJpc3RpYW4uU2NoYWlibGVAbm92YXRlYy1nbWJoLmRlIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTcyMjU5MDM2NywiaWF0IjoxNzIyNTg2NzY3LCJpc3MiOiJodHRwczovL2FjY291bnRzLmRldi5zdGFja2l0LmNsb3VkIiwianRpIjoiZDczYTY3YWMtZDFlYy00YjU1LTk5ZDQtZTk1MzI3NWYwMjJhIiwibmJmIjoxNzIyNTg2NzY3LCJzY29wZSI6Im9wZW5pZCBlbWFpbCIsInN1YiI6ImNkOTRmMDFhLWRmMmUtNDQ1Ni05MDJlLTQ4ZjVlNTdmMGI2MyJ9.ajhjYbC5l5g7un9NSheoAwBT83YcZM91rH4DJxPTDsB78HzIVrmaKTPrK3AI_E1THlD2Z3_ot9nFr_eX7XcwWp_ZBlataKmakdXlAmeb4xSMGNYefIfzV_3w9ZZAZ66yoeTrtn8dUx5ezquenCYpctB1NcccmK4U09V0kNcq9dFcfF3Sg9YilF3orUCR0ql1d9RnOs3EiFZuUpdBEkyoVsAdSh2P-PRbNViR_FgCcAJem97TsN5CQc9RlvKYe4sYKgqQoqa2GDVi9Niiw3fe1V8SCnROYcpkOzBBWdvuzFMBUjln3uOogYVOz93xkmImV6jidgyQ70fLt-eDUmZZfg"}}, + Host: "localhost", + Method: "POST", + Scheme: "https", + Proto: "HTTP/1.1", + URL: RequestUrl{ + Path: "/", + RawQuery: nil, + }, + }). + WithRequiredRequestClientIp("127.0.0.1"). + WithRequiredServiceName("demo-service"). + WithRequiredWorkerId("worker-id"). + WithAuditPermission(permission). + WithAuditPermissionCheckResult(permissionCheckResult). + WithDetails(details). + WithEventType(EventTypeSystemEvent). + WithLabels(map[string]string{"key": "label"}). + WithNumResponseItems(int64(10)). + WithRequestCorrelationId("correlationId"). + WithRequestId("requestId"). + WithRequestTime(requestTime). + WithResponseBodyBytes(responseBodyBytes). + WithResponseHeaders(map[string][]string{"key": {"header"}}). + WithResponseTime(responseTime). + WithSeverity(auditV1.LogSeverity_LOG_SEVERITY_ERROR). + WithStatusCode(400) + + logEntry, err := builder.Build(context.Background(), SequenceNumber(1)) + assert.NoError(t, err) + assert.NotNil(t, logEntry) + + assert.Equal(t, "projects/1/logs/system-event", logEntry.LogName) + assert.Equal(t, map[string]string{"key": "label"}, logEntry.Labels) + assert.Nil(t, logEntry.TraceState) + assert.Nil(t, logEntry.TraceParent) + assert.Equal(t, auditV1.LogSeverity_LOG_SEVERITY_ERROR, logEntry.Severity) + assert.NotNil(t, logEntry.Timestamp) + assert.Equal(t, "correlationId", *logEntry.CorrelationId) + assert.Regexp(t, "[0-9]+/eu01/worker-id/1", logEntry.InsertId) + + assert.NotNil(t, logEntry.ProtoPayload) + + authenticationInfo := logEntry.ProtoPayload.AuthenticationInfo + assert.NotNil(t, authenticationInfo) + assert.Equal(t, "Christian.Schaible@novatec-gmbh.de", authenticationInfo.PrincipalEmail) + assert.Equal(t, "cd94f01a-df2e-4456-902e-48f5e57f0b63", authenticationInfo.PrincipalId) + assert.Nil(t, authenticationInfo.ServiceAccountDelegationInfo) + assert.Nil(t, authenticationInfo.ServiceAccountName) + + assert.Equal(t, []*auditV1.AuthorizationInfo{{ + Resource: "projects/1", + Permission: &permission, + Granted: &permissionCheckResult, + }}, logEntry.ProtoPayload.AuthorizationInfo) + + expectedMetadata, _ := structpb.NewStruct(details) + assert.Equal(t, expectedMetadata, logEntry.ProtoPayload.Metadata) + assert.Equal(t, "stackit.demo-service.v1.operation", logEntry.ProtoPayload.OperationName) + assert.Nil(t, logEntry.ProtoPayload.Request) + + requestMetadata := logEntry.ProtoPayload.RequestMetadata + assert.NotNil(t, requestMetadata) + assert.Equal(t, "127.0.0.1", requestMetadata.CallerIp) + assert.Equal(t, "custom", requestMetadata.CallerSuppliedUserAgent) + + requestAttributes := requestMetadata.RequestAttributes + assert.NotNil(t, requestAttributes) + assert.Equal(t, "/", requestAttributes.Path) + assert.NotNil(t, requestAttributes.Time) + assert.Equal(t, "localhost", requestAttributes.Host) + assert.Equal(t, auditV1.AttributeContext_HTTP_METHOD_POST, requestAttributes.Method) + assert.Equal(t, "requestId", *requestAttributes.Id) + assert.Equal(t, "https", requestAttributes.Scheme) + assert.Equal(t, map[string]string{"user-agent": "custom"}, requestAttributes.Headers) + assert.Nil(t, requestAttributes.Query) + assert.Equal(t, "HTTP/1.1", requestAttributes.Protocol) + + requestAttributesAuth := requestAttributes.Auth + assert.NotNil(t, requestAttributesAuth) + assert.Equal(t, "cd94f01a-df2e-4456-902e-48f5e57f0b63/https%3A%2F%2Faccounts.dev.stackit.cloud", requestAttributesAuth.Principal) + assert.Equal(t, []string{"stackit-portal-login-dev-client-id"}, requestAttributesAuth.Audiences) + assert.NotNil(t, requestAttributesAuth.Claims) + + expectedResponse, _ := structpb.NewStruct(responseBody) + assert.Equal(t, "projects/1", logEntry.ProtoPayload.ResourceName) + assert.Equal(t, expectedResponse, logEntry.ProtoPayload.Response) + + responseMetadata := logEntry.ProtoPayload.ResponseMetadata + assert.NotNil(t, responseMetadata) + assert.Nil(t, responseMetadata.ErrorDetails) + assert.Equal(t, "Client error", *responseMetadata.ErrorMessage) + assert.Equal(t, wrapperspb.Int32(400), responseMetadata.StatusCode) + + responseAttributes := responseMetadata.ResponseAttributes + assert.NotNil(t, responseAttributes) + assert.Equal(t, map[string]string{"key": "header"}, responseAttributes.Headers) + assert.Equal(t, wrapperspb.Int64(10), responseAttributes.NumResponseItems) + assert.Equal(t, wrapperspb.Int64(18), responseAttributes.Size) + assert.NotNil(t, responseAttributes.Time) + + assert.Equal(t, "demo-service", logEntry.ProtoPayload.ServiceName) + + validator, err := protovalidate.New() + assert.NoError(t, err) + err = validator.Validate(logEntry) + assert.NoError(t, err) + }) +} + +func Test_AuditEventBuilder(t *testing.T) { + + t.Run("required only", func(t *testing.T) { + api, _ := NewMockAuditApi() + sequenceNumberGenerator := utils.NewDefaultSequenceNumberGenerator() + tracer := otel.Tracer("test") + + objectId := uuid.NewString() + operation := "stackit.demo-service.v1.operation" + builder := NewAuditEventBuilder(api, sequenceNumberGenerator, tracer, "demo-service", "worker-id", "eu01"). + WithRequiredObjectId(objectId). + WithRequiredObjectType(SingularTypeProject). + WithRequiredOperation(operation). + WithRequiredApiRequest(ApiRequest{ + Body: nil, + Header: map[string][]string{"user-agent": {"custom"}, "authorization": {"Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlOGJlZjc1LWRmY2QtNGE3My1hMzkxLTU0YTdhZjU3YTdkNiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsic3RhY2tpdC1wb3J0YWwtbG9naW4tZGV2LWNsaWVudC1pZCJdLCJjbGllbnRfaWQiOiJzdGFja2l0LXBvcnRhbC1sb2dpbi1kZXYtY2xpZW50LWlkIiwiZW1haWwiOiJDaHJpc3RpYW4uU2NoYWlibGVAbm92YXRlYy1nbWJoLmRlIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTcyMjU5MDM2NywiaWF0IjoxNzIyNTg2NzY3LCJpc3MiOiJodHRwczovL2FjY291bnRzLmRldi5zdGFja2l0LmNsb3VkIiwianRpIjoiZDczYTY3YWMtZDFlYy00YjU1LTk5ZDQtZTk1MzI3NWYwMjJhIiwibmJmIjoxNzIyNTg2NzY3LCJzY29wZSI6Im9wZW5pZCBlbWFpbCIsInN1YiI6ImNkOTRmMDFhLWRmMmUtNDQ1Ni05MDJlLTQ4ZjVlNTdmMGI2MyJ9.ajhjYbC5l5g7un9NSheoAwBT83YcZM91rH4DJxPTDsB78HzIVrmaKTPrK3AI_E1THlD2Z3_ot9nFr_eX7XcwWp_ZBlataKmakdXlAmeb4xSMGNYefIfzV_3w9ZZAZ66yoeTrtn8dUx5ezquenCYpctB1NcccmK4U09V0kNcq9dFcfF3Sg9YilF3orUCR0ql1d9RnOs3EiFZuUpdBEkyoVsAdSh2P-PRbNViR_FgCcAJem97TsN5CQc9RlvKYe4sYKgqQoqa2GDVi9Niiw3fe1V8SCnROYcpkOzBBWdvuzFMBUjln3uOogYVOz93xkmImV6jidgyQ70fLt-eDUmZZfg"}}, + Host: "localhost", + Method: "POST", + Scheme: "https", + Proto: "HTTP/1.1", + URL: RequestUrl{ + Path: "/", + RawQuery: nil, + }, + }). + WithRequiredRequestClientIp("127.0.0.1") + + routableIdentifier := RoutableIdentifier{Identifier: objectId, Type: SingularTypeProject} + + cloudEvent, routingIdentifier, op, err := builder.Build(context.Background(), SequenceNumber(1)) + assert.NoError(t, err) + assert.True(t, builder.IsBuilt()) + + assert.Equal(t, &routableIdentifier, routingIdentifier) + assert.Equal(t, operation, op) + + assert.NotNil(t, cloudEvent) + assert.Equal(t, "application/cloudevents+protobuf", cloudEvent.DataContentType) + assert.Equal(t, "audit.v1.RoutableAuditEvent", cloudEvent.DataType) + assert.Regexp(t, "[0-9]+/eu01/worker-id/1", cloudEvent.Id) + assert.Equal(t, "demo-service", cloudEvent.Source) + assert.Equal(t, "1.0", cloudEvent.SpecVersion) + assert.Equal(t, fmt.Sprintf("projects/%s", objectId), cloudEvent.Subject) + assert.NotNil(t, cloudEvent.Time) + assert.Equal(t, "00-00000000000000000000000000000000-0000000000000000-00", *cloudEvent.TraceParent) + assert.Nil(t, cloudEvent.TraceState) + + var routableAuditEvent auditV1.RoutableAuditEvent + assert.NotNil(t, cloudEvent.Data) + assert.NoError(t, proto.Unmarshal(cloudEvent.Data, &routableAuditEvent)) + + assert.Equal(t, routableIdentifier.ToObjectIdentifier(), routableAuditEvent.ObjectIdentifier) + assert.Equal(t, auditV1.Visibility_VISIBILITY_PUBLIC, routableAuditEvent.Visibility) + assert.Equal(t, operation, routableAuditEvent.OperationName) + + var logEntry auditV1.AuditLogEntry + assert.NotNil(t, routableAuditEvent.GetUnencryptedData().Data) + assert.NoError(t, proto.Unmarshal(routableAuditEvent.GetUnencryptedData().Data, &logEntry)) + + assert.Equal(t, fmt.Sprintf("projects/%s/logs/admin-activity", objectId), logEntry.LogName) + assert.Nil(t, logEntry.Labels) + assert.Nil(t, logEntry.TraceState) + assert.Nil(t, logEntry.TraceParent) + assert.Equal(t, auditV1.LogSeverity_LOG_SEVERITY_DEFAULT, logEntry.Severity) + assert.NotNil(t, logEntry.Timestamp) + assert.Nil(t, logEntry.CorrelationId) + assert.Regexp(t, "[0-9]+/eu01/worker-id/1", logEntry.InsertId) + + assert.NotNil(t, logEntry.ProtoPayload) + + authenticationInfo := logEntry.ProtoPayload.AuthenticationInfo + assert.NotNil(t, authenticationInfo) + assert.Equal(t, "Christian.Schaible@novatec-gmbh.de", authenticationInfo.PrincipalEmail) + assert.Equal(t, "cd94f01a-df2e-4456-902e-48f5e57f0b63", authenticationInfo.PrincipalId) + assert.Nil(t, authenticationInfo.ServiceAccountDelegationInfo) + assert.Nil(t, authenticationInfo.ServiceAccountName) + + assert.Nil(t, logEntry.ProtoPayload.AuthorizationInfo) + assert.Nil(t, logEntry.ProtoPayload.Metadata) + assert.Equal(t, operation, logEntry.ProtoPayload.OperationName) + assert.Nil(t, logEntry.ProtoPayload.Request) + + requestMetadata := logEntry.ProtoPayload.RequestMetadata + assert.NotNil(t, requestMetadata) + assert.Equal(t, "127.0.0.1", requestMetadata.CallerIp) + assert.Equal(t, "custom", requestMetadata.CallerSuppliedUserAgent) + + requestAttributes := requestMetadata.RequestAttributes + assert.NotNil(t, requestAttributes) + assert.Equal(t, "/", requestAttributes.Path) + assert.NotNil(t, requestAttributes.Time) + assert.Equal(t, "localhost", requestAttributes.Host) + assert.Equal(t, auditV1.AttributeContext_HTTP_METHOD_POST, requestAttributes.Method) + assert.Nil(t, requestAttributes.Id) + assert.Equal(t, "https", requestAttributes.Scheme) + assert.Equal(t, map[string]string{"user-agent": "custom"}, requestAttributes.Headers) + assert.Nil(t, requestAttributes.Query) + assert.Equal(t, "HTTP/1.1", requestAttributes.Protocol) + + requestAttributesAuth := requestAttributes.Auth + assert.NotNil(t, requestAttributesAuth) + assert.Equal(t, "cd94f01a-df2e-4456-902e-48f5e57f0b63/https%3A%2F%2Faccounts.dev.stackit.cloud", requestAttributesAuth.Principal) + assert.Equal(t, []string{"stackit-portal-login-dev-client-id"}, requestAttributesAuth.Audiences) + assert.NotNil(t, requestAttributesAuth.Claims) + + assert.Equal(t, fmt.Sprintf("projects/%s", objectId), logEntry.ProtoPayload.ResourceName) + assert.Nil(t, logEntry.ProtoPayload.Response) + + responseMetadata := logEntry.ProtoPayload.ResponseMetadata + assert.NotNil(t, responseMetadata) + assert.Nil(t, responseMetadata.ErrorDetails) + assert.Nil(t, responseMetadata.ErrorMessage) + assert.Equal(t, wrapperspb.Int32(200), responseMetadata.StatusCode) + + responseAttributes := responseMetadata.ResponseAttributes + assert.NotNil(t, responseAttributes) + assert.Nil(t, responseAttributes.Headers) + assert.Nil(t, responseAttributes.NumResponseItems) + assert.Nil(t, responseAttributes.Size) + assert.NotNil(t, responseAttributes.Time) + + assert.Equal(t, "demo-service", logEntry.ProtoPayload.ServiceName) + + validator, err := protovalidate.New() + assert.NoError(t, err) + err = validator.Validate(&logEntry) + assert.NoError(t, err) + }) + + t.Run("with details", func(t *testing.T) { + api, _ := NewMockAuditApi() + sequenceNumberGenerator := utils.NewDefaultSequenceNumberGenerator() + tracer := otel.Tracer("test") + + objectId := uuid.NewString() + operation := "stackit.demo-service.v1.operation" + details := map[string]interface{}{"key": "detail"} + permission := "project.edit" + permissionCheckResult := true + requestTime := time.Now().AddDate(0, 0, -2).UTC() + responseTime := time.Now().AddDate(0, 0, -1).UTC() + responseBody := map[string]interface{}{"key": "response"} + responseBodyBytes, err := ResponseBodyToBytes(responseBody) + assert.NoError(t, err) + builder := NewAuditEventBuilder(api, sequenceNumberGenerator, tracer, "demo-service", "worker-id", "eu01"). + WithRequiredObjectId(objectId). + WithRequiredObjectType(SingularTypeProject). + WithRequiredOperation(operation). + WithRequiredApiRequest(ApiRequest{ + Body: nil, + Header: map[string][]string{"user-agent": {"custom"}, "authorization": {"Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlOGJlZjc1LWRmY2QtNGE3My1hMzkxLTU0YTdhZjU3YTdkNiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsic3RhY2tpdC1wb3J0YWwtbG9naW4tZGV2LWNsaWVudC1pZCJdLCJjbGllbnRfaWQiOiJzdGFja2l0LXBvcnRhbC1sb2dpbi1kZXYtY2xpZW50LWlkIiwiZW1haWwiOiJDaHJpc3RpYW4uU2NoYWlibGVAbm92YXRlYy1nbWJoLmRlIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTcyMjU5MDM2NywiaWF0IjoxNzIyNTg2NzY3LCJpc3MiOiJodHRwczovL2FjY291bnRzLmRldi5zdGFja2l0LmNsb3VkIiwianRpIjoiZDczYTY3YWMtZDFlYy00YjU1LTk5ZDQtZTk1MzI3NWYwMjJhIiwibmJmIjoxNzIyNTg2NzY3LCJzY29wZSI6Im9wZW5pZCBlbWFpbCIsInN1YiI6ImNkOTRmMDFhLWRmMmUtNDQ1Ni05MDJlLTQ4ZjVlNTdmMGI2MyJ9.ajhjYbC5l5g7un9NSheoAwBT83YcZM91rH4DJxPTDsB78HzIVrmaKTPrK3AI_E1THlD2Z3_ot9nFr_eX7XcwWp_ZBlataKmakdXlAmeb4xSMGNYefIfzV_3w9ZZAZ66yoeTrtn8dUx5ezquenCYpctB1NcccmK4U09V0kNcq9dFcfF3Sg9YilF3orUCR0ql1d9RnOs3EiFZuUpdBEkyoVsAdSh2P-PRbNViR_FgCcAJem97TsN5CQc9RlvKYe4sYKgqQoqa2GDVi9Niiw3fe1V8SCnROYcpkOzBBWdvuzFMBUjln3uOogYVOz93xkmImV6jidgyQ70fLt-eDUmZZfg"}}, + Host: "localhost", + Method: "POST", + Scheme: "https", + Proto: "HTTP/1.1", + URL: RequestUrl{ + Path: "/", + RawQuery: nil, + }, + }). + WithRequiredRequestClientIp("127.0.0.1"). + WithAuditPermission(permission). + WithAuditPermissionCheckResult(permissionCheckResult). + WithDetails(details). + WithEventType(EventTypeSystemEvent). + WithLabels(map[string]string{"key": "label"}). + WithNumResponseItems(int64(10)). + WithRequestCorrelationId("correlationId"). + WithRequestId("requestId"). + WithRequestTime(requestTime). + WithResponseBodyBytes(responseBodyBytes). + WithResponseHeaders(map[string][]string{"key": {"header"}}). + WithResponseTime(responseTime). + WithSeverity(auditV1.LogSeverity_LOG_SEVERITY_ERROR). + WithStatusCode(400). + WithVisibility(auditV1.Visibility_VISIBILITY_PRIVATE) + + routableIdentifier := RoutableIdentifier{Identifier: objectId, Type: SingularTypeProject} + + cloudEvent, routingIdentifier, op, err := builder.Build(context.Background(), SequenceNumber(1)) + assert.NoError(t, err) + assert.True(t, builder.IsBuilt()) + + assert.Equal(t, &routableIdentifier, routingIdentifier) + assert.Equal(t, operation, op) + + assert.NotNil(t, cloudEvent) + assert.Equal(t, "application/cloudevents+protobuf", cloudEvent.DataContentType) + assert.Equal(t, "audit.v1.RoutableAuditEvent", cloudEvent.DataType) + assert.Regexp(t, "[0-9]+/eu01/worker-id/1", cloudEvent.Id) + assert.Equal(t, "demo-service", cloudEvent.Source) + assert.Equal(t, "1.0", cloudEvent.SpecVersion) + assert.Equal(t, fmt.Sprintf("projects/%s", objectId), cloudEvent.Subject) + assert.NotNil(t, cloudEvent.Time) + assert.Equal(t, "00-00000000000000000000000000000000-0000000000000000-00", *cloudEvent.TraceParent) + assert.Nil(t, cloudEvent.TraceState) + + var routableAuditEvent auditV1.RoutableAuditEvent + assert.NotNil(t, cloudEvent.Data) + assert.NoError(t, proto.Unmarshal(cloudEvent.Data, &routableAuditEvent)) + + assert.Equal(t, routableIdentifier.ToObjectIdentifier(), routableAuditEvent.ObjectIdentifier) + assert.Equal(t, auditV1.Visibility_VISIBILITY_PRIVATE, routableAuditEvent.Visibility) + assert.Equal(t, operation, routableAuditEvent.OperationName) + + var logEntry auditV1.AuditLogEntry + assert.NotNil(t, routableAuditEvent.GetUnencryptedData().Data) + assert.NoError(t, proto.Unmarshal(routableAuditEvent.GetUnencryptedData().Data, &logEntry)) + + assert.Equal(t, fmt.Sprintf("projects/%s/logs/system-event", objectId), logEntry.LogName) + assert.Equal(t, map[string]string{"key": "label"}, logEntry.Labels) + assert.Nil(t, logEntry.TraceState) + assert.Nil(t, logEntry.TraceParent) + assert.Equal(t, auditV1.LogSeverity_LOG_SEVERITY_ERROR, logEntry.Severity) + assert.NotNil(t, logEntry.Timestamp) + assert.Equal(t, "correlationId", *logEntry.CorrelationId) + assert.Regexp(t, "[0-9]+/eu01/worker-id/1", logEntry.InsertId) + + assert.NotNil(t, logEntry.ProtoPayload) + + authenticationInfo := logEntry.ProtoPayload.AuthenticationInfo + assert.NotNil(t, authenticationInfo) + assert.Equal(t, "Christian.Schaible@novatec-gmbh.de", authenticationInfo.PrincipalEmail) + assert.Equal(t, "cd94f01a-df2e-4456-902e-48f5e57f0b63", authenticationInfo.PrincipalId) + assert.Nil(t, authenticationInfo.ServiceAccountDelegationInfo) + assert.Nil(t, authenticationInfo.ServiceAccountName) + + assert.Equal(t, []*auditV1.AuthorizationInfo{{ + Resource: fmt.Sprintf("projects/%s", objectId), + Permission: &permission, + Granted: &permissionCheckResult, + }}, logEntry.ProtoPayload.AuthorizationInfo) + + expectedMetadata, _ := structpb.NewStruct(details) + assert.Equal(t, expectedMetadata, logEntry.ProtoPayload.Metadata) + assert.Equal(t, "stackit.demo-service.v1.operation", logEntry.ProtoPayload.OperationName) + assert.Nil(t, logEntry.ProtoPayload.Request) + + requestMetadata := logEntry.ProtoPayload.RequestMetadata + assert.NotNil(t, requestMetadata) + assert.Equal(t, "127.0.0.1", requestMetadata.CallerIp) + assert.Equal(t, "custom", requestMetadata.CallerSuppliedUserAgent) + + requestAttributes := requestMetadata.RequestAttributes + assert.NotNil(t, requestAttributes) + assert.Equal(t, "/", requestAttributes.Path) + assert.NotNil(t, requestAttributes.Time) + assert.Equal(t, "localhost", requestAttributes.Host) + assert.Equal(t, auditV1.AttributeContext_HTTP_METHOD_POST, requestAttributes.Method) + assert.Equal(t, "requestId", *requestAttributes.Id) + assert.Equal(t, "https", requestAttributes.Scheme) + assert.Equal(t, map[string]string{"user-agent": "custom"}, requestAttributes.Headers) + assert.Nil(t, requestAttributes.Query) + assert.Equal(t, "HTTP/1.1", requestAttributes.Protocol) + + requestAttributesAuth := requestAttributes.Auth + assert.NotNil(t, requestAttributesAuth) + assert.Equal(t, "cd94f01a-df2e-4456-902e-48f5e57f0b63/https%3A%2F%2Faccounts.dev.stackit.cloud", requestAttributesAuth.Principal) + assert.Equal(t, []string{"stackit-portal-login-dev-client-id"}, requestAttributesAuth.Audiences) + assert.NotNil(t, requestAttributesAuth.Claims) + + expectedResponse, _ := structpb.NewStruct(responseBody) + assert.Equal(t, fmt.Sprintf("projects/%s", objectId), logEntry.ProtoPayload.ResourceName) + assert.Equal(t, expectedResponse, logEntry.ProtoPayload.Response) + + responseMetadata := logEntry.ProtoPayload.ResponseMetadata + assert.NotNil(t, responseMetadata) + assert.Nil(t, responseMetadata.ErrorDetails) + assert.Equal(t, "Client error", *responseMetadata.ErrorMessage) + assert.Equal(t, wrapperspb.Int32(400), responseMetadata.StatusCode) + + responseAttributes := responseMetadata.ResponseAttributes + assert.NotNil(t, responseAttributes) + assert.Equal(t, map[string]string{"key": "header"}, responseAttributes.Headers) + assert.Equal(t, wrapperspb.Int64(10), responseAttributes.NumResponseItems) + assert.Equal(t, wrapperspb.Int64(18), responseAttributes.Size) + assert.NotNil(t, responseAttributes.Time) + + assert.Equal(t, "demo-service", logEntry.ProtoPayload.ServiceName) + + validator, err := protovalidate.New() + assert.NoError(t, err) + err = validator.Validate(&logEntry) + assert.NoError(t, err) + }) + + t.Run("no entry builder", func(t *testing.T) { + api, _ := NewMockAuditApi() + sequenceNumberGenerator := utils.NewDefaultSequenceNumberGenerator() + tracer := otel.Tracer("test") + + cloudEvent, routingIdentifier, operation, err := NewAuditEventBuilder(api, sequenceNumberGenerator, tracer, "demo-service", "worker-id", "eu01"). + WithAuditLogEntryBuilder(nil).Build(context.Background(), SequenceNumber(1)) + + assert.EqualError(t, err, "audit log entry builder not set") + assert.Nil(t, cloudEvent) + assert.Nil(t, routingIdentifier) + assert.Equal(t, "", operation) + }) + + t.Run("next sequence number", func(t *testing.T) { + api, _ := NewMockAuditApi() + sequenceNumberGenerator := utils.NewDefaultSequenceNumberGenerator() + tracer := otel.Tracer("test") + + builder := NewAuditEventBuilder(api, sequenceNumberGenerator, tracer, "demo-service", "worker-id", "eu01") + assert.Equal(t, SequenceNumber(0), builder.NextSequenceNumber()) + assert.Equal(t, SequenceNumber(1), builder.NextSequenceNumber()) + }) + + t.Run("revert sequence number", func(t *testing.T) { + api, _ := NewMockAuditApi() + sequenceNumberGenerator := utils.NewDefaultSequenceNumberGenerator() + tracer := otel.Tracer("test") + + builder := NewAuditEventBuilder(api, sequenceNumberGenerator, tracer, "demo-service", "worker-id", "eu01") + assert.Equal(t, SequenceNumber(0), builder.NextSequenceNumber()) + builder.RevertSequenceNumber() + assert.Equal(t, SequenceNumber(0), builder.NextSequenceNumber()) + }) +} diff --git a/audit/api/model.go b/audit/api/model.go index ee5f55a..4d53ebc 100644 --- a/audit/api/model.go +++ b/audit/api/model.go @@ -9,6 +9,8 @@ import ( "fmt" "github.com/google/uuid" "go.opentelemetry.io/otel/trace" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/wrapperspb" @@ -904,3 +906,30 @@ func StringAttributeFromMetadata(metadata map[string][]string, name string) stri } return value } + +// ResponseBodyToBytes converts a JSON or Protobuf response into a byte array +func ResponseBodyToBytes(response any) (*[]byte, error) { + if response == nil { + return nil, nil + } + + responseBytes, isBytes := response.([]byte) + if isBytes { + return &responseBytes, nil + } + + responseProtoMessage, isProtoMessage := response.(proto.Message) + if isProtoMessage { + responseJson, err := protojson.Marshal(responseProtoMessage) + if err != nil { + return nil, err + } + return &responseJson, nil + } else { + responseJson, err := json.Marshal(response) + if err != nil { + return nil, err + } + return &responseJson, nil + } +} diff --git a/audit/api/model_test.go b/audit/api/model_test.go index 26596f7..107568e 100644 --- a/audit/api/model_test.go +++ b/audit/api/model_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" + "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/wrapperspb" @@ -1030,3 +1031,64 @@ func Test_StringAttributeFromMetadata(t *testing.T) { assert.Equal(t, "value2", attribute) }) } + +func Test_ResponseBodyToBytes(t *testing.T) { + + t.Run( + "nil response body", func(t *testing.T) { + bytes, err := ResponseBodyToBytes(nil) + assert.Nil(t, bytes) + assert.Nil(t, err) + }, + ) + + t.Run( + "bytes", func(t *testing.T) { + responseBody := []byte("data") + bytes, err := ResponseBodyToBytes(responseBody) + assert.Nil(t, err) + assert.Equal(t, &responseBody, bytes) + }, + ) + + t.Run( + "Protobuf message", func(t *testing.T) { + protobufMessage := auditV1.ObjectIdentifier{Identifier: uuid.NewString(), Type: string(SingularTypeProject)} + bytes, err := ResponseBodyToBytes(&protobufMessage) + assert.Nil(t, err) + + expected, err := protojson.Marshal(&protobufMessage) + assert.Nil(t, err) + assert.Equal(t, &expected, bytes) + }, + ) + + t.Run( + "struct", func(t *testing.T) { + type CustomObject struct { + Value string + } + + responseBody := CustomObject{Value: "data"} + bytes, err := ResponseBodyToBytes(responseBody) + assert.Nil(t, err) + + expected, err := json.Marshal(responseBody) + assert.Nil(t, err) + assert.Equal(t, &expected, bytes) + }, + ) + + t.Run( + "map", func(t *testing.T) { + + responseBody := map[string]interface{}{"value": "data"} + bytes, err := ResponseBodyToBytes(responseBody) + assert.Nil(t, err) + + expected, err := json.Marshal(responseBody) + assert.Nil(t, err) + assert.Equal(t, &expected, bytes) + }, + ) +}