From ee32a2b15180cc86cf480699324a71d5a81b27b4 Mon Sep 17 00:00:00 2001 From: Carl Jacobs Date: Tue, 5 Sep 2023 17:56:48 -0400 Subject: [PATCH] Added support for Audit Reporting API v2 --- Egnyte.Api.Tests/Audit/AuditV2ReportTests.cs | 227 ++++++++++++++++++ .../Audit/CreateLoginAuditReportTests.cs | 2 +- Egnyte.Api.Tests/Egnyte.Api.Tests.csproj | 7 +- Egnyte.Api/Audit/AuditClient.cs | 88 ++++++- Egnyte.Api/Audit/AuditV2MetadataResponse.cs | 39 +++ .../AuditV2RateLimitExceededException.cs | 35 +++ Egnyte.Api/Audit/AuditV2ReportResponse.cs | 18 ++ Egnyte.Api/Audit/AuditV2Type.cs | 55 +++++ Egnyte.Api/Common/ExceptionHelper.cs | 11 +- Egnyte.Api/Egnyte.Api.csproj | 4 +- 10 files changed, 478 insertions(+), 8 deletions(-) create mode 100644 Egnyte.Api.Tests/Audit/AuditV2ReportTests.cs create mode 100644 Egnyte.Api/Audit/AuditV2MetadataResponse.cs create mode 100644 Egnyte.Api/Audit/AuditV2RateLimitExceededException.cs create mode 100644 Egnyte.Api/Audit/AuditV2ReportResponse.cs create mode 100644 Egnyte.Api/Audit/AuditV2Type.cs diff --git a/Egnyte.Api.Tests/Audit/AuditV2ReportTests.cs b/Egnyte.Api.Tests/Audit/AuditV2ReportTests.cs new file mode 100644 index 0000000..2d352a2 --- /dev/null +++ b/Egnyte.Api.Tests/Audit/AuditV2ReportTests.cs @@ -0,0 +1,227 @@ +using Egnyte.Api.Audit; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Reflection.PortableExecutable; +using System.Text; +using System.Threading.Tasks; + +namespace Egnyte.Api.Tests.Search +{ + [TestFixture] + public class AuditV2ReportTests + { + private const string RetryAfter = "20"; + private const string RateLimitMinute = "10"; + private const string RateLimitRemainingMinute = "0"; + private const string RateLimitHour = "100"; + private const string RateLimitRemainingHour = "46"; + + const string GetAuditV2ReportRequestContent = @" + { + ""startDate"": ""2021-12-08T00:00:00Z"", + ""endDate"": ""2021-12-10T00:00:00Z"", + ""auditType"": [ + ""FILE_AUDIT"", + ""USER_AUDIT"", + ""GROUP_AUDIT"" + ] + }"; + + const string GetAuditV2ReportRequestContentUsingCursor = @" + { + ""nextCursor"": ""QmlnVGFibGVLZXk="" + }"; + + const string GetAuditV2ReportResponseContent = @" + { + ""nextCursor"": ""AAN_lwABAX1zZe9AAAAAAAAAAAAAAAAAAAAAAA"", + ""events"": + [ + { ""date"":1638936585716, + ""sourcePath"":""/Shared/Departments/Marketing/Branding/Logo.jpg"", + ""targetPath"":""N/A"", + ""user"":""Jack Smith ( jsmith@company.com )"", + ""userId"":""101"", + ""action"":""Preview"", + ""access"":""Web UI"", + ""ipAddress"":""173.226.89.189"", + ""actionInfo"":"""", + ""auditSource"":""FILE_AUDIT"" + }, + { ""date"":1638937051697, + ""sourcePath"":""/Shared/Departments/Engineering/ProductY/Photo.png"", + ""targetPath"":""N/A"", + ""user"":""Adam Jackson ( ajackson@company.com )"", + ""userId"":""146"", + ""action"":""Upload"", + ""access"":""Web UI"", + ""ipAddress"":""172.8.18.17"", + ""actionInfo"":"""", + ""auditSource"":""FILE_AUDIT"" + }, + { ""date"":1638940605824, + ""actor"":""Jennifer Watkins ( jwatkins@company.com )"", + ""subject"":""Paul Chen ( pchen@company.com )"", + ""action"":""Disable"", + ""actionInfo"":"""", + ""source"":""Web UI"", + ""auditSource"":""USER_AUDIT"" + }, + { ""date"":1638942414189, + ""actor"":""Jennifer Watkins ( jwatkins@company.com )"", + ""group"":""Engineering"", + ""action"":""Create"", + ""actionInfo"":"""", + ""source"":""Web UI"", + ""auditSource"":""GROUP_AUDIT"" + } + ], + ""moreEvents"":true + }"; + + [Test] + public async Task GetAuditV2Report_ReturnsSuccess() + { + var httpHandlerMock = new HttpMessageHandlerMock(); + var httpClient = new HttpClient(httpHandlerMock); + + httpHandlerMock.SendAsyncFunc = + (request, cancellationToken) => + Task.FromResult( + new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(GetAuditV2ReportResponseContent, + Encoding.UTF8, + "application/json") + }); + + var egnyteClient = new EgnyteClient("token", "acme", httpClient); + var auditReportResult = await egnyteClient.Audit.GetAuditV2Report( + new DateTime(2021, 12, 08), + new DateTime(2021, 12, 10), + new List { AuditV2Type.FILE_AUDIT, AuditV2Type.USER_AUDIT, AuditV2Type.GROUP_AUDIT }); + + var requestMessage = httpHandlerMock.GetHttpRequestMessage(); + Assert.AreEqual( + "https://acme.egnyte.com/pubapi/v2/audit/stream", + requestMessage.RequestUri.ToString()); + + Assert.AreEqual(HttpMethod.Post, requestMessage.Method); + Assert.AreEqual("AAN_lwABAX1zZe9AAAAAAAAAAAAAAAAAAAAAAA", auditReportResult.NextCursor); + Assert.AreEqual(4, auditReportResult.Events.Count); + + Assert.AreEqual(1638936585716, auditReportResult.Events[0].Date); + Assert.AreEqual("/Shared/Departments/Marketing/Branding/Logo.jpg", auditReportResult.Events[0].SourcePath); + Assert.AreEqual("N/A", auditReportResult.Events[0].TargetPath); + Assert.AreEqual("Jack Smith ( jsmith@company.com )", auditReportResult.Events[0].User); + Assert.AreEqual("101", auditReportResult.Events[0].UserId); + Assert.AreEqual("Preview", auditReportResult.Events[0].Action); + Assert.AreEqual("Web UI", auditReportResult.Events[0].Access); + Assert.AreEqual("173.226.89.189", auditReportResult.Events[0].IpAddress); + Assert.AreEqual("", auditReportResult.Events[0].ActionInfo); + Assert.AreEqual(AuditV2Type.FILE_AUDIT.ToString(), auditReportResult.Events[0].AuditSource); + + Assert.AreEqual(true, auditReportResult.MoreEvents); + + var requestContent = httpHandlerMock.GetRequestContentAsString(); + Assert.AreEqual( + TestsHelper.RemoveWhitespaces(GetAuditV2ReportRequestContent), + TestsHelper.RemoveWhitespaces(requestContent)); + } + + [Test] + public async Task GetAuditV2Report_WithCursor_ReturnsSuccess() + { + var httpHandlerMock = new HttpMessageHandlerMock(); + var httpClient = new HttpClient(httpHandlerMock); + + httpHandlerMock.SendAsyncFunc = + (request, cancellationToken) => + Task.FromResult( + new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(GetAuditV2ReportResponseContent, + Encoding.UTF8, + "application/json") + }); + + var egnyteClient = new EgnyteClient("token", "acme", httpClient); + var auditReportResult = await egnyteClient.Audit.GetAuditV2Report( + new DateTime(2021, 12, 08), + new DateTime(2021, 12, 10), + new List { AuditV2Type.FILE_AUDIT, AuditV2Type.USER_AUDIT, AuditV2Type.GROUP_AUDIT }, + "QmlnVGFibGVLZXk="); + + var requestMessage = httpHandlerMock.GetHttpRequestMessage(); + Assert.AreEqual( + "https://acme.egnyte.com/pubapi/v2/audit/stream", + requestMessage.RequestUri.ToString()); + + Assert.AreEqual(HttpMethod.Post, requestMessage.Method); + Assert.AreEqual("AAN_lwABAX1zZe9AAAAAAAAAAAAAAAAAAAAAAA", auditReportResult.NextCursor); + Assert.AreEqual(4, auditReportResult.Events.Count); + + Assert.AreEqual(true, auditReportResult.MoreEvents); + + var requestContent = httpHandlerMock.GetRequestContentAsString(); + Assert.AreEqual( + TestsHelper.RemoveWhitespaces(GetAuditV2ReportRequestContentUsingCursor), + TestsHelper.RemoveWhitespaces(requestContent)); + } + + [Test] + public async Task GetAuditV2Report_WhenStartDateAndNextCursorAreEmpty_ThrowsException() + { + var httpClient = new HttpClient(new HttpMessageHandlerMock()); + var egnyteClient = new EgnyteClient("token", "acme", httpClient); + + var exception = await AssertExtensions.ThrowsAsync( + () => egnyteClient.Audit.GetAuditV2Report( + auditTypes: new List { AuditV2Type.FILE_AUDIT, AuditV2Type.USER_AUDIT, AuditV2Type.GROUP_AUDIT })); + + Assert.IsTrue(exception.Message.Contains("nextCursor")); + Assert.IsNull(exception.InnerException); + } + + [Test] + public async Task GetAuditV2Report_ThrowsAuditV2RateLimitExceededException_WhenAccountOverAuditV2Limit() + { + var httpHandlerMock = new HttpMessageHandlerMock(); + var httpClient = new HttpClient(httpHandlerMock); + const string Content = "

Developer Over AuditV2 Rate Limit

"; + var responseMessage = new HttpResponseMessage + { + StatusCode = (HttpStatusCode)429, + Content = new StringContent(Content) + }; + responseMessage.Headers.Add("retry-after", RetryAfter); + responseMessage.Headers.Add("x-ratelimit-limit-minute", RateLimitMinute); + responseMessage.Headers.Add("x-ratelimit-remaining-minute", RateLimitRemainingMinute); + responseMessage.Headers.Add("x-ratelimit-limit-hour", RateLimitHour); + responseMessage.Headers.Add("x-ratelimit-remaining-hour", RateLimitRemainingHour); + + httpHandlerMock.SendAsyncFunc = + (request, cancellationToken) => + Task.FromResult(responseMessage); + + var egnyteClient = new EgnyteClient("token", "acme", httpClient); + + var exception = await AssertExtensions.ThrowsAsync( + () => egnyteClient.Audit.GetAuditV2Report(nextCursor: "QmlnVGFibGVLZXk=")); + + Assert.IsNull(exception.InnerException); + Assert.AreEqual("Audit V2 Report stream over rate limit", exception.Message); + + Assert.AreEqual(RetryAfter, exception.RetryAfter); + Assert.AreEqual(RateLimitMinute, exception.RateLimitMinute); + Assert.AreEqual(RateLimitRemainingMinute, exception.RateLimitRemainingMinute); + Assert.AreEqual(RateLimitHour, exception.RateLimitHour); + Assert.AreEqual(RateLimitRemainingHour, exception.RateLimitRemainingHour); + } + } +} diff --git a/Egnyte.Api.Tests/Audit/CreateLoginAuditReportTests.cs b/Egnyte.Api.Tests/Audit/CreateLoginAuditReportTests.cs index f37fc9a..bad2400 100644 --- a/Egnyte.Api.Tests/Audit/CreateLoginAuditReportTests.cs +++ b/Egnyte.Api.Tests/Audit/CreateLoginAuditReportTests.cs @@ -76,7 +76,7 @@ public async Task CreateLoginAuditReport_ReturnsSuccess() } [Test] - public async Task CreateLoginAuditReport_WhenEventsAreEMpty_ThrowsException() + public async Task CreateLoginAuditReport_WhenEventsAreEmpty_ThrowsException() { var httpClient = new HttpClient(new HttpMessageHandlerMock()); var egnyteClient = new EgnyteClient("token", "acme", httpClient); diff --git a/Egnyte.Api.Tests/Egnyte.Api.Tests.csproj b/Egnyte.Api.Tests/Egnyte.Api.Tests.csproj index 0dc1fec..461e4b8 100644 --- a/Egnyte.Api.Tests/Egnyte.Api.Tests.csproj +++ b/Egnyte.Api.Tests/Egnyte.Api.Tests.csproj @@ -7,9 +7,10 @@ - - - + + + + diff --git a/Egnyte.Api/Audit/AuditClient.cs b/Egnyte.Api/Audit/AuditClient.cs index ac96ac0..097edea 100644 --- a/Egnyte.Api/Audit/AuditClient.cs +++ b/Egnyte.Api/Audit/AuditClient.cs @@ -12,9 +12,93 @@ public class AuditClient : BaseClient { const string AuditReportMethod = "/pubapi/v1/audit"; + const string AuditStreamingMethod = "/pubapi/v2/audit/stream"; internal AuditClient(HttpClient httpClient, string domain = "", string host = "") : base(httpClient, domain, host) { } + /// + /// Access streaming version of audit reporting. + /// + /// Required if nextCursor not specified. Start of date range for the initial set of audit events. The start date should be within the last 7 days (from now). To retrieve past audit events outside of the 7 day window, it is needed to use Audit Reporting API v1. + /// Optional. End of date range for the initial set of audit events. + /// Allows to receive only specific types of audit events. If multiple types of events are required, it is recommended to receive all the required types (specifying the list of types in this filter) and then process them on the client as required. Allows filtering audit events by type based on the list of audit event types. + /// Iteration pointer for a following (not the initial) request. A cursor is returned in response to the initial request and then every following request generates a new cursor to be used in the next request. + /// + /// + public async Task GetAuditV2Report( + DateTime? startDate = null, + DateTime? endDate = null, + List auditTypes = null, + string nextCursor = null) + { + if (startDate == null && nextCursor == null) + { + throw new ArgumentException("Either 'startDate' or 'nextCursor' must be specified.", nameof(nextCursor)); + } + + var uriBuilder = BuildUri(AuditStreamingMethod); + var httpRequest = new HttpRequestMessage(HttpMethod.Post, uriBuilder.Uri) + { + Content = new StringContent( + GetAuditV2ReportContent( + startDate, + endDate, + auditTypes, + nextCursor), + Encoding.UTF8, + "application/json") + }; + + var serviceHandler = new ServiceHandler(httpClient); + var response = await serviceHandler.SendRequestAsync(httpRequest).ConfigureAwait(false); + + return response.Data; + } + + string GetAuditV2ReportContent( + DateTime? startDate = null, + DateTime? endDate = null, + List auditTypes = null, + string nextCursor = null) + { + var builder = new StringBuilder(); + + if (nextCursor != null) + { + builder + .Append("{") + .Append(string.Format("\"nextCursor\": \"{0}\"", nextCursor)) + .Append("}"); + } + else + { + builder + .Append("{") + .Append(string.Format("\"startDate\": \"{0:yyyy-MM-ddTHH:mm:ssZ}\"", startDate)); + + if (endDate != null) + { + builder + .Append(",") + .Append(string.Format("\"endDate\": \"{0:yyyy-MM-ddTHH:mm:ssZ}\"", endDate)); + } + + if (auditTypes != null) + { + string auditsContent = "[" + + string.Join(",", auditTypes.Select(e => "\"" + e.ToString() + "\"")) + + "]"; + builder + .Append(",") + .Append("\"auditType\": " + auditsContent); + } + + builder.Append("}"); + } + + return builder.ToString(); + } + /// /// Creates login audit report /// @@ -44,7 +128,7 @@ public async Task CreateLoginAuditReport( var httpRequest = new HttpRequestMessage(HttpMethod.Post, uriBuilder.Uri) { Content = new StringContent( - GetCrateLoginAuditReportContent( + GetCreateLoginAuditReportContent( format, startDate, endDate, @@ -61,7 +145,7 @@ public async Task CreateLoginAuditReport( return response.Data.Id; } - string GetCrateLoginAuditReportContent( + string GetCreateLoginAuditReportContent( AuditReportFormat format, DateTime startDate, DateTime endDate, diff --git a/Egnyte.Api/Audit/AuditV2MetadataResponse.cs b/Egnyte.Api/Audit/AuditV2MetadataResponse.cs new file mode 100644 index 0000000..fbc12bd --- /dev/null +++ b/Egnyte.Api/Audit/AuditV2MetadataResponse.cs @@ -0,0 +1,39 @@ +using Newtonsoft.Json; +using System; + +namespace Egnyte.Api.Audit +{ + public class AuditV2MetadataResponse + { + [JsonProperty(PropertyName = "date")] + public Int64 Date { get; set; } + + [JsonProperty(PropertyName = "sourcePath")] + public string SourcePath { get; set; } + + [JsonProperty(PropertyName = "targetPath")] + public string TargetPath { get; set; } + + [JsonProperty(PropertyName = "user")] + public string User { get; set; } + + [JsonProperty(PropertyName = "userId")] + public string UserId { get; set; } + + [JsonProperty(PropertyName = "action")] + public string Action { get; set; } + + [JsonProperty(PropertyName = "access")] + public string Access { get; set; } + + [JsonProperty(PropertyName = "ipAddress")] + public string IpAddress { get; set; } + + [JsonProperty(PropertyName = "actionInfo")] + public string ActionInfo { get; set; } + + [JsonProperty(PropertyName = "auditSource")] + public string AuditSource { get; set; } + + } +} diff --git a/Egnyte.Api/Audit/AuditV2RateLimitExceededException.cs b/Egnyte.Api/Audit/AuditV2RateLimitExceededException.cs new file mode 100644 index 0000000..d505e76 --- /dev/null +++ b/Egnyte.Api/Audit/AuditV2RateLimitExceededException.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Egnyte.Api.Audit +{ + public class AuditV2RateLimitExceededException : Exception + { + public AuditV2RateLimitExceededException(Dictionary headers) + : base("Audit V2 Report stream over rate limit") + { + RateLimitMinute = GetHeaderValue(headers, "x-ratelimit-limit-minute"); + RateLimitRemainingMinute = GetHeaderValue(headers, "x-ratelimit-remaining-minute"); + RateLimitHour = GetHeaderValue(headers, "x-ratelimit-limit-hour"); + RateLimitRemainingHour = GetHeaderValue(headers, "x-ratelimit-remaining-hour"); + RetryAfter = GetHeaderValue(headers, "retry-after"); + } + + public string RateLimitMinute { get; set; } + public string RateLimitRemainingMinute { get; set; } + public string RateLimitHour { get; set; } + public string RateLimitRemainingHour { get; set; } + public string RetryAfter { get; set; } + + private string GetHeaderValue(Dictionary headers, string headerName) + { + if (headers.ContainsKey(headerName) && !string.IsNullOrWhiteSpace(headers[headerName])) + { + return headers[headerName]; + } + + return string.Empty; + } + } +} diff --git a/Egnyte.Api/Audit/AuditV2ReportResponse.cs b/Egnyte.Api/Audit/AuditV2ReportResponse.cs new file mode 100644 index 0000000..0871101 --- /dev/null +++ b/Egnyte.Api/Audit/AuditV2ReportResponse.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; + +namespace Egnyte.Api.Audit +{ + public class AuditV2ReportResponse + { + [JsonProperty(PropertyName = "nextCursor")] + public string NextCursor { get; set; } + + [JsonProperty(PropertyName = "events")] + public List Events { get; set; } + + [JsonProperty(PropertyName = "moreEvents")] + public Boolean MoreEvents { get; set; } + } +} diff --git a/Egnyte.Api/Audit/AuditV2Type.cs b/Egnyte.Api/Audit/AuditV2Type.cs new file mode 100644 index 0000000..6fe70b2 --- /dev/null +++ b/Egnyte.Api/Audit/AuditV2Type.cs @@ -0,0 +1,55 @@ +namespace Egnyte.Api.Audit +{ + public enum AuditV2Type + { + /// + /// File Audit + /// + FILE_AUDIT, + + /// + /// Login Audit + /// + LOGIN_AUDIT, + + /// + /// Permissions Audit + /// + PERMISSION_AUDIT, + + /// + /// User Audit + /// + USER_AUDIT, + + /// + /// Group Audit + /// + GROUP_AUDIT, + + /// + /// WG Settings Audit + /// + WG_SETTINGS_AUDIT, + + /// + /// Workflow Audit + /// + WORKFLOW_AUDIT, + + /// + /// Quality Docs Audit + /// + QUALITY_DOCS_AUDIT, + + /// + /// Quality Docs Categories Audit + /// + QUALITY_DOCS_CATEGORIES_AUDIT, + + /// + /// Any + /// + ANY + } +} diff --git a/Egnyte.Api/Common/ExceptionHelper.cs b/Egnyte.Api/Common/ExceptionHelper.cs index c31c226..8fa775d 100644 --- a/Egnyte.Api/Common/ExceptionHelper.cs +++ b/Egnyte.Api/Common/ExceptionHelper.cs @@ -1,4 +1,6 @@ -using System; +using Egnyte.Api.Audit; +using System; +using System.Net; using System.Net.Http; namespace Egnyte.Api.Common @@ -25,6 +27,13 @@ public static void CheckErrorStatusCode(HttpResponseMessage response, string res throw new RateLimitExceededException(headers); } } + else + { + if ( (response.StatusCode == (HttpStatusCode)429) && (headers.ContainsKey("retry-after")) ) + { + throw new AuditV2RateLimitExceededException(headers); + } + } throw new EgnyteApiException(responseContent, response); } diff --git a/Egnyte.Api/Egnyte.Api.csproj b/Egnyte.Api/Egnyte.Api.csproj index 1b7366e..52334d7 100644 --- a/Egnyte.Api/Egnyte.Api.csproj +++ b/Egnyte.Api/Egnyte.Api.csproj @@ -16,7 +16,9 @@ - + + +