Repository: Azure/ARO-RP
Pull request #1224 (2020-12-10 19:57:36)

Add New Audit Package
### Which issue this PR addresses:

Fixes [7311453](https://msazure.visualstudio.com/AzureRedHatOpenShift/_dashboards/dashboard/e59764b2-08a2-4092-be99-5c40ee667094)

### What this PR does / why we need it:

This PR introduces a new `pkg/util/audit` package that contains the IFxAudit log schema
definition and functions to emit logs.

No callers have been added in the RP code yet.

1. Introduce a new `audit.NewEntry` constructor
2. Add a new `logrus.Hook` to the new entry to hydrate IFxAudit audit payload to be sent to 
Geneva
3. Update the `instancemetadata.InstanceMetadata` interface with a new `Hostname()`  
method to get the hostname needed by the audit log payload

### Test plan for issue:

N/A.

### Is there any documentation that needs to be updated for this PR?

N/A.


Patch:
diff --git a/pkg/metrics/statsd/statsd.go b/pkg/metrics/statsd/statsd.go
index 7bc871e7b..924544941 100644
--- a/pkg/metrics/statsd/statsd.go
+++ b/pkg/metrics/statsd/statsd.go
@@ -8,7 +8,6 @@ import (
 	"context"
 	"fmt"
 	"net"
-	"os"
 	"time"
 
 	"github.com/sirupsen/logrus"
@@ -23,7 +22,6 @@ type statsd struct {
 	log *logrus.Entry
 	env env.Core
 
-	hostname  string
 	account   string
 	namespace string
 
@@ -47,12 +45,6 @@ func New(ctx context.Context, log *logrus.Entry, env env.Core, account, namespac
 		now: time.Now,
 	}
 
-	hostname, err := os.Hostname()
-	if err != nil {
-		return nil, err
-	}
-	s.hostname = hostname
-
 	if s.account == "" {
 		s.account = "*"
 	}
@@ -91,7 +83,7 @@ func (s *statsd) emitMetric(m *metric) {
 		m.dims = map[string]string{}
 	}
 	m.dims["location"] = s.env.Location()
-	m.dims["hostname"] = s.hostname
+	m.dims["hostname"] = s.env.Hostname()
 	m.ts = s.now()
 
 	s.ch <- m
diff --git a/pkg/util/instancemetadata/dev.go b/pkg/util/instancemetadata/dev.go
index 335db9a85..a307b7507 100644
--- a/pkg/util/instancemetadata/dev.go
+++ b/pkg/util/instancemetadata/dev.go
@@ -33,7 +33,13 @@ func NewDev(checkEnv bool) (InstanceMetadata, error) {
 		}
 	}
 
+	hostname, err := os.Hostname()
+	if err != nil {
+		return nil, err
+	}
+
 	return &instanceMetadata{
+		hostname:       hostname,
 		tenantID:       os.Getenv("AZURE_TENANT_ID"),
 		subscriptionID: os.Getenv("AZURE_SUBSCRIPTION_ID"),
 		location:       os.Getenv("LOCATION"),
diff --git a/pkg/util/instancemetadata/instancemetadata.go b/pkg/util/instancemetadata/instancemetadata.go
index 361dd5474..1ef532e02 100644
--- a/pkg/util/instancemetadata/instancemetadata.go
+++ b/pkg/util/instancemetadata/instancemetadata.go
@@ -12,6 +12,7 @@ import (
 )
 
 type InstanceMetadata interface {
+	Hostname() string
 	TenantID() string
 	SubscriptionID() string
 	Location() string
@@ -20,6 +21,7 @@ type InstanceMetadata interface {
 }
 
 type instanceMetadata struct {
+	hostname       string
 	tenantID       string
 	subscriptionID string
 	location       string
@@ -27,6 +29,10 @@ type instanceMetadata struct {
 	environment    *azure.Environment
 }
 
+func (im *instanceMetadata) Hostname() string {
+	return im.hostname
+}
+
 func (im *instanceMetadata) TenantID() string {
 	return im.tenantID
 }
diff --git a/pkg/util/instancemetadata/prod.go b/pkg/util/instancemetadata/prod.go
index 0b2b1a36f..8e0219215 100644
--- a/pkg/util/instancemetadata/prod.go
+++ b/pkg/util/instancemetadata/prod.go
@@ -8,6 +8,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"net/http"
+	"os"
 	"strings"
 
 	"github.com/Azure/go-autorest/autorest/adal"
@@ -126,5 +127,11 @@ func (p *prod) populateInstanceMetadata() error {
 	p.location = m.Location
 	p.resourceGroup = m.ResourceGroupName
 
+	hostname, err := os.Hostname()
+	if err != nil {
+		return err
+	}
+	p.hostname = hostname
+
 	return nil
 }
diff --git a/pkg/util/log/audit/audit.go b/pkg/util/log/audit/audit.go
new file mode 100644
index 000000000..160510032
--- /dev/null
+++ b/pkg/util/log/audit/audit.go
@@ -0,0 +1,197 @@
+package audit
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"encoding/json"
+	"sync"
+	"time"
+
+	uuid "github.com/satori/go.uuid"
+	"github.com/sirupsen/logrus"
+
+	"github.com/Azure/ARO-RP/pkg/env"
+)
+
+const (
+	MetadataCreatedTime = "createdTime"
+	MetadataPayload     = "payload"
+	MetadataLogKind     = "logKind"
+	MetadataSource      = "source"
+
+	SourceAdminPortal = "aro-admin"
+	SourceRP          = "aro-rp"
+
+	EnvKeyAppID               = "envAppID"
+	EnvKeyAppVer              = "envAppVer"
+	EnvKeyCloudDeploymentUnit = "envCloudDeploymentUnit"
+	EnvKeyCloudRole           = "envCloudRole"
+	EnvKeyCloudRoleVer        = "envCloudRoleVer"
+	EnvKeyCorrelationID       = "envCorrelationID"
+	EnvKeyIKey                = "envIKey"
+
+	PayloadKeyCallerIdentities = "payloadCallerIdentities"
+	PayloadKeyCategory         = "payloadCategory"
+	PayloadKeyNCloud           = "payloadNCloud"
+	PayloadKeyOperationName    = "payloadOperationName"
+	PayloadKeyResult           = "payloadResult"
+	PayloadKeyRequestID        = "payloadRequestID"
+	PayloadKeyTargetResources  = "payloadTargetResources"
+
+	ifxAuditCloudVer = 1.0
+	ifxAuditLogKind  = "ifxaudit"
+	ifxAuditName     = "#Ifx.AuditSchema"
+	ifxAuditVersion  = 2.1
+
+	// ifxAuditFlags is a collection of values bit-packed into a 64-bit integer.
+	// These properties describe how the event should be processed by the pipeline
+	// in an implementation-independent way.
+	ifxAuditFlags = 257
+)
+
+var (
+	// epoch is an unique identifier associated with the current session of the
+	// telemetry library running on the platform. It must be stable during a
+	// session, and has no implied ordering across sessions.
+	epoch = uuid.NewV4().String()
+
+	// seqNum is used to track absolute order of uploaded events, per session.
+	// It is reset when the ARO component is restarted. The first log will have
+	// its sequence number set to 1.
+	seqNum      uint64
+	seqNumMutex sync.Mutex
+)
+
+// NewEntry returns a log entry that embeds the provided logger. It has a hook
+// that knows how to hydrate an IFxAudit log payload before logging it.
+func NewEntry(env env.Core, logger *logrus.Logger) *logrus.Entry {
+	logger.AddHook(&payloadHook{
+		payload: &AuditPayload{},
+		env:     env,
+	})
+
+	return logrus.NewEntry(logger)
+}
+
+// payloadHook, when fires, hydrates an IFxAudit log payload using data in a log
+// entry.
+type payloadHook struct {
+	payload *AuditPayload
+	env     env.Core
+}
+
+func (payloadHook) Levels() []logrus.Level {
+	return logrus.AllLevels
+}
+
+func (h *payloadHook) Fire(entry *logrus.Entry) error {
+	payload := *h.payload // shallow copy
+
+	// Part-A
+	payload.EnvVer = ifxAuditVersion
+	payload.EnvName = ifxAuditName
+
+	logTime := entry.Time.UTC().Format(time.RFC3339)
+	payload.EnvTime = logTime
+
+	payload.EnvEpoch = epoch
+	payload.EnvSeqNum = nextSeqNum()
+
+	if v, ok := entry.Data[EnvKeyIKey].(string); ok {
+		payload.EnvIKey = v
+		delete(entry.Data, EnvKeyIKey)
+	}
+
+	payload.EnvFlags = ifxAuditFlags
+
+	if v, ok := entry.Data[EnvKeyAppID].(string); ok {
+		payload.EnvAppId = v
+		delete(entry.Data, EnvKeyAppID)
+	}
+
+	if v, ok := entry.Data[EnvKeyAppVer].(string); ok {
+		payload.EnvAppVer = v
+		delete(entry.Data, EnvKeyAppVer)
+	}
+
+	if v, ok := entry.Data[EnvKeyCorrelationID].(string); ok {
+		payload.EnvCV = v
+		delete(entry.Data, EnvKeyCorrelationID)
+	}
+
+	payload.EnvCloudName = h.env.Environment().Name
+
+	if v, ok := entry.Data[EnvKeyCloudRole].(string); ok {
+		payload.EnvCloudRole = v
+		delete(entry.Data, EnvKeyCloudRole)
+	}
+
+	if v, ok := entry.Data[EnvKeyCloudRoleVer].(string); ok {
+		payload.EnvCloudRoleVer = v
+		delete(entry.Data, EnvKeyCloudRoleVer)
+	}
+
+	payload.EnvCloudRoleInstance = h.env.Hostname()
+	payload.EnvCloudEnvironment = h.env.Environment().Name
+	payload.EnvCloudLocation = h.env.Location()
+
+	if v, ok := entry.Data[EnvKeyCloudDeploymentUnit].(string); ok {
+		payload.EnvCloudDeploymentUnit = v
+		delete(entry.Data, EnvKeyCloudDeploymentUnit)
+	}
+
+	payload.EnvCloudVer = ifxAuditCloudVer
+
+	// Part-B
+	if ids, ok := entry.Data[PayloadKeyCallerIdentities].([]*CallerIdentity); ok {
+		payload.CallerIdentities = append(payload.CallerIdentities, ids...)
+		delete(entry.Data, PayloadKeyCallerIdentities)
+	}
+
+	if v, ok := entry.Data[PayloadKeyCategory].(string); ok {
+		payload.Category = Category(v)
+		delete(entry.Data, PayloadKeyCategory)
+	}
+
+	if v, ok := entry.Data[PayloadKeyOperationName].(string); ok {
+		payload.OperationName = v
+		delete(entry.Data, PayloadKeyOperationName)
+	}
+
+	if v, ok := entry.Data[PayloadKeyResult].(*Result); ok {
+		payload.Result = v
+		delete(entry.Data, PayloadKeyResult)
+	}
+
+	if v, ok := entry.Data[PayloadKeyRequestID].(string); ok {
+		payload.RequestID = v
+		delete(entry.Data, PayloadKeyRequestID)
+	}
+
+	if rs, ok := entry.Data[PayloadKeyTargetResources].([]*TargetResource); ok {
+		payload.TargetResources = append(payload.TargetResources, rs...)
+		delete(entry.Data, PayloadKeyTargetResources)
+	}
+
+	// add the audit payload
+	b, err := json.Marshal(payload)
+	if err != nil {
+		return err
+	}
+	entry.Data[MetadataPayload] = string(b)
+
+	// add non-IFxAudit metadata for our own use
+	entry.Data[MetadataCreatedTime] = logTime
+	entry.Data[MetadataLogKind] = ifxAuditLogKind
+
+	return nil
+}
+
+func nextSeqNum() uint64 {
+	seqNumMutex.Lock()
+	defer seqNumMutex.Unlock()
+
+	seqNum++
+	return seqNum
+}
diff --git a/pkg/util/log/audit/schema.go b/pkg/util/log/audit/schema.go
new file mode 100644
index 000000000..e23c712ee
--- /dev/null
+++ b/pkg/util/log/audit/schema.go
@@ -0,0 +1,106 @@
+package audit
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+// AuditPayload is the IFxAudit log payload that will be sent to Geneva. It has
+// all the required and optional fields defined in IFxAudit Part-A and Part-B
+// schema.
+//
+// Fields that are marked as optional or "required when applicable" in the
+// schema are marked with the omitempty tag. Fields that are marked as "unused"
+// are not included.
+type AuditPayload struct {
+	// Part-A
+	EnvVer                 float64 `json:"env_ver"`
+	EnvName                string  `json:"env_name"`
+	EnvTime                string  `json:"env_time"`
+	EnvEpoch               string  `json:"env_epoch,omitempty"`
+	EnvSeqNum              uint64  `json:"env_seqNum,omitempty"`
+	EnvIKey                string  `json:"env_iKey,omitempty"`
+	EnvFlags               int     `json:"env_flags,omitempty"`
+	EnvAppId               string  `json:"env_appId"`
+	EnvAppVer              string  `json:"env_appVer,omitempty"`
+	EnvCV                  string  `json:"env_cv,omitempty"`
+	EnvCloudName           string  `json:"env_cloud_name"`
+	EnvCloudRole           string  `json:"env_cloud_role"`
+	EnvCloudRoleVer        string  `json:"env_cloud_roleVer,omitempty"`
+	EnvCloudRoleInstance   string  `json:"env_cloud_roleInstance"`
+	EnvCloudEnvironment    string  `json:"env_cloud_environment,omitempty"`
+	EnvCloudLocation       string  `json:"env_cloud_location"`
+	EnvCloudDeploymentUnit string  `json:"env_cloud_deploymentUnit,omitempty"`
+	EnvCloudVer            float64 `json:"env_cloud_ver"`
+
+	// Part-B
+	CallerIdentities []*CallerIdentity `json:"CallerIdentities"`
+	Category         Category          `json:"Category"`
+	OperationName    string            `json:"OperationName"`
+	Result           *Result           `json:"Result"`
+	RequestID        string            `json:"requestId"`
+	TargetResources  []*TargetResource `json:"TargetResources"`
+}
+
+// CallerIdentity has identity information on the entity that invoke the
+// operation described in the audit log.
+type CallerIdentity struct {
+	CallerDisplayName   string             `json:"CallerDisplayName,omitempty"`
+	CallerIdentityType  CallerIdentityType `json:"CallerIdentityType"`
+	CallerIdentityValue string             `json:"CallerIdentityValue"`
+	CallerIPAddress     string             `json:"CallerIpAddress,omitempty"`
+}
+
+// CallerIdentityType represents the type of identity used in an auditable event.
+type CallerIdentityType string
+
+// Category provides information for the category of the operation.
+type Category string
+
+// Result provides information on the result of the operation.
+type Result struct {
+	ResultType        string `json:"ResultType"`
+	ResultDescription string `json:"ResultDescription,omitempty"`
+}
+
+// ResultType indicates the outcome of the operation.
+type ResultType string
+
+// TargetResource has identity information on the entity affected by the
+// operation described in the audit log.
+type TargetResource struct {
+	TargetResourceType string `json:"TargetResourceType"`
+	TargetResourceName string `json:"TargetResourceName"`
+}
+
+const (
+	CallerIdentityTypeUPN            = "UPN"
+	CallerIdentityTypePUID           = "PUID"
+	CallerIdentityTypeObjectID       = "ObjectID"
+	CallerIdentityTypeCertificate    = "Certificate"
+	CallerIdentityTypeClaim          = "Claim"
+	CallerIdentityTypeUsername       = "Username"
+	CallerIdentityTypeKeyName        = "KeyName"
+	CallerIdentityTypeApplicationID  = "ApplicationID"
+	CallerIdentityTypeSubscriptionID = "SubscriptionID"
+
+	CategoryAuthentication        = "Authentication"
+	CategoryAuthorization         = "Authorization"
+	CategoryUserManagement        = "UserManagement"
+	CategoryGroupManagement       = "GroupManagement"
+	CategoryRoleManagement        = "RoleManagement"
+	CategoryApplicationManagement = "ApplicationManagement"
+	CategoryKeyManagement         = "KeyManagement"
+	CategoryDirectoryManagement   = "DirectoryManagement"
+	CategoryResourceManagement    = "ResourceManagement"
+	CategoryPolicyManagement      = "PolicyManagement"
+	CategoryDeviceManagement      = "DeviceManagement"
+	CategoryEntitlementManagement = "EntitlementManagement"
+	CategoryPasswordManagement    = "PasswordManagement"
+	CategoryObjectManagement      = "ObjectManagement"
+	CategoryIdentityProtection    = "IdentityProtection"
+	CategoryOther                 = "Other"
+
+	ResultTypeSuccess     = "Success"
+	ResultTypeFail        = "Fail"
+	ResultTypeTimeout     = "Timeout"
+	ResultTypeClientError = "Client Error"
+)
diff --git a/pkg/util/log/log.go b/pkg/util/log/log.go
index a4a45ddff..ff2a58535 100644
--- a/pkg/util/log/log.go
+++ b/pkg/util/log/log.go
@@ -29,21 +29,33 @@ var (
 	rxTolerantResourceID = regexp.MustCompile(`(?i)^(?:/admin)?/subscriptions/([^/]+)(?:/resourceGroups/([^/]+)(?:/providers/([^/]+)/([^/]+)(?:/([^/]+))?)?)?`)
 )
 
+func GetBaseLogger() *logrus.Logger {
+	logger := logrus.New()
+
+	logger.SetFormatter(&logrus.TextFormatter{
+		FullTimestamp: true,
+	})
+
+	if journal.Enabled() {
+		logger.AddHook(&journaldHook{})
+	}
+
+	return logger
+}
+
 // GetLogger returns a consistently configured log entry
 func GetLogger() *logrus.Entry {
-	logrus.SetReportCaller(true)
-	logrus.SetFormatter(&logrus.TextFormatter{
+	logger := GetBaseLogger()
+
+	logger.SetReportCaller(true)
+	logger.SetFormatter(&logrus.TextFormatter{
 		FullTimestamp:    true,
 		CallerPrettyfier: relativeFilePathPrettier,
 	})
 
-	logrus.AddHook(&logrHook{})
-
-	if journal.Enabled() {
-		logrus.AddHook(&journaldHook{})
-	}
+	logger.AddHook(&logrHook{})
 
-	log := logrus.NewEntry(logrus.StandardLogger())
+	log := logrus.NewEntry(logger)
 
 	l, err := logrus.ParseLevel(*loglevel)
 	if err == nil {
diff --git a/pkg/util/mocks/env/env.go b/pkg/util/mocks/env/env.go
index c95fb198a..0b491d36a 100644
--- a/pkg/util/mocks/env/env.go
+++ b/pkg/util/mocks/env/env.go
@@ -72,6 +72,20 @@ func (mr *MockCoreMockRecorder) Environment() *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Environment", reflect.TypeOf((*MockCore)(nil).Environment))
 }
 
+// Hostname mocks base method
+func (m *MockCore) Hostname() string {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Hostname")
+	ret0, _ := ret[0].(string)
+	return ret0
+}
+
+// Hostname indicates an expected call of Hostname
+func (mr *MockCoreMockRecorder) Hostname() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Hostname", reflect.TypeOf((*MockCore)(nil).Hostname))
+}
+
 // Location mocks base method
 func (m *MockCore) Location() string {
 	m.ctrl.T.Helper()
@@ -379,6 +393,20 @@ func (mr *MockInterfaceMockRecorder) FPAuthorizer(arg0, arg1 interface{}) *gomoc
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FPAuthorizer", reflect.TypeOf((*MockInterface)(nil).FPAuthorizer), arg0, arg1)
 }
 
+// Hostname mocks base method
+func (m *MockInterface) Hostname() string {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Hostname")
+	ret0, _ := ret[0].(string)
+	return ret0
+}
+
+// Hostname indicates an expected call of Hostname
+func (mr *MockInterfaceMockRecorder) Hostname() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Hostname", reflect.TypeOf((*MockInterface)(nil).Hostname))
+}
+
 // InitializeAuthorizers mocks base method
 func (m *MockInterface) InitializeAuthorizers() error {
 	m.ctrl.T.Helper()
diff --git a/pkg/util/mocks/instancemetadata/instancemetadata.go b/pkg/util/mocks/instancemetadata/instancemetadata.go
index 0d6fcf385..b5bad8cb6 100644
--- a/pkg/util/mocks/instancemetadata/instancemetadata.go
+++ b/pkg/util/mocks/instancemetadata/instancemetadata.go
@@ -100,6 +100,20 @@ func (mr *MockInstanceMetadataMockRecorder) Environment() *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Environment", reflect.TypeOf((*MockInstanceMetadata)(nil).Environment))
 }
 
+// Hostname mocks base method
+func (m *MockInstanceMetadata) Hostname() string {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Hostname")
+	ret0, _ := ret[0].(string)
+	return ret0
+}
+
+// Hostname indicates an expected call of Hostname
+func (mr *MockInstanceMetadataMockRecorder) Hostname() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Hostname", reflect.TypeOf((*MockInstanceMetadata)(nil).Hostname))
+}
+
 // Location mocks base method
 func (m *MockInstanceMetadata) Location() string {
 	m.ctrl.T.Helper()


Test patch:
diff --git a/pkg/metrics/statsd/statsd_test.go b/pkg/metrics/statsd/statsd_test.go
index 142b9edb0..79f2bfa41 100644
--- a/pkg/metrics/statsd/statsd_test.go
+++ b/pkg/metrics/statsd/statsd_test.go
@@ -20,6 +20,7 @@ func TestEmitGauge(t *testing.T) {
 
 	env := mock_env.NewMockInterface(controller)
 	env.EXPECT().Location().AnyTimes().Return("eastus")
+	env.EXPECT().Hostname().AnyTimes().Return("test-host")
 
 	c1, c2 := net.Pipe()
 
@@ -43,7 +44,7 @@ func TestEmitGauge(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	if m != `{"Metric":"tests.test_key","Account":"*","Namespace":"*","Dims":{"hostname":"","key":"value","location":"eastus"},"TS":"0001-01-01T00:00:00.000"}:42|g`+"\n" {
+	if m != `{"Metric":"tests.test_key","Account":"*","Namespace":"*","Dims":{"hostname":"test-host","key":"value","location":"eastus"},"TS":"0001-01-01T00:00:00.000"}:42|g`+"\n" {
 		t.Error(m)
 	}
 }
@@ -54,6 +55,7 @@ func TestEmitFloat(t *testing.T) {
 
 	env := mock_env.NewMockInterface(controller)
 	env.EXPECT().Location().AnyTimes().Return("eastus")
+	env.EXPECT().Hostname().AnyTimes().Return("test-host")
 
 	c1, c2 := net.Pipe()
 
@@ -77,7 +79,7 @@ func TestEmitFloat(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	if m != `{"Metric":"tests.test_key","Account":"*","Namespace":"*","Dims":{"hostname":"","key":"value","location":"eastus"},"TS":"0001-01-01T00:00:00.000"}:5.000000|f`+"\n" {
+	if m != `{"Metric":"tests.test_key","Account":"*","Namespace":"*","Dims":{"hostname":"test-host","key":"value","location":"eastus"},"TS":"0001-01-01T00:00:00.000"}:5.000000|f`+"\n" {
 		t.Error(m)
 	}
 }
diff --git a/pkg/util/log/audit/audit_test.go b/pkg/util/log/audit/audit_test.go
new file mode 100644
index 000000000..f62655dbd
--- /dev/null
+++ b/pkg/util/log/audit/audit_test.go
@@ -0,0 +1,67 @@
+package audit
+
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache License 2.0.
+
+import (
+	"testing"
+	"time"
+
+	"github.com/Azure/go-autorest/autorest/azure"
+	"github.com/golang/mock/gomock"
+	"github.com/onsi/gomega"
+	"github.com/onsi/gomega/types"
+	uuid "github.com/satori/go.uuid"
+	"github.com/sirupsen/logrus"
+	"github.com/sirupsen/logrus/hooks/test"
+
+	mock_env "github.com/Azure/ARO-RP/pkg/util/mocks/env"
+	utillog "github.com/Azure/ARO-RP/test/util/log"
+)
+
+func TestAudit(t *testing.T) {
+	controller := gomock.NewController(t)
+	defer controller.Finish()
+
+	env := mock_env.NewMockInterface(controller)
+	env.EXPECT().Environment().AnyTimes().Return(&azure.PublicCloud)
+	env.EXPECT().Location().AnyTimes().Return("eastus")
+	env.EXPECT().Hostname().AnyTimes().Return("test-host")
+
+	epoch = uuid.NewV4().String()
+	var (
+		now          = time.Now().UTC()
+		formattedNow = now.Format(time.RFC3339)
+		logger, h    = test.NewNullLogger()
+	)
+
+	auditLog := NewEntry(env, logger)
+	auditLog.WithFields(logrus.Fields{
+		PayloadKeyCategory:      CategoryAuthorization,
+		PayloadKeyOperationName: "initializeAuthorizers",
+		MetadataSource:          SourceRP,
+		MetadataCreatedTime:     formattedNow,
+	}).Print("see auditFullPayload field for full log data")
+
+	if err := utillog.AssertLoggingOutput(h, []map[string]types.GomegaMatcher{
+		{
+			"level":         gomega.Equal(logrus.InfoLevel),
+			"msg":           gomega.Equal("see auditFullPayload field for full log data"),
+			MetadataSource:  gomega.Equal("aro-rp"),
+			MetadataLogKind: gomega.Equal("ifxaudit"),
+			MetadataCreatedTime: gomega.WithTransform(
+				func(s string) time.Time {
+					t, err := time.Parse(time.RFC3339, s)
+					if err != nil {
+						panic(err)
+					}
+					return t
+				},
+				gomega.BeTemporally("~", now, time.Second),
+			),
+			MetadataPayload: gomega.Equal(`{"env_ver":2.1,"env_name":"#Ifx.AuditSchema","env_time":"` + formattedNow + `","env_epoch":"` + epoch + `","env_seqNum":1,"env_flags":257,"env_appId":"","env_cloud_name":"AzurePublicCloud","env_cloud_role":"","env_cloud_roleInstance":"test-host","env_cloud_environment":"AzurePublicCloud","env_cloud_location":"eastus","env_cloud_ver":1,"CallerIdentities":null,"Category":"Authorization","OperationName":"initializeAuthorizers","Result":null,"requestId":"","TargetResources":null}`),
+		},
+	}); err != nil {
+		t.Error(err)
+	}
+}