From c3785897f217c987219d3ac3770a8c030ea73625 Mon Sep 17 00:00:00 2001 From: Max Inno Date: Thu, 1 Feb 2024 03:27:42 +0200 Subject: [PATCH 1/2] feat(api): add Security Logs API support --- src/Crowdin.Api/Core/InternalExtensions.cs | 8 + src/Crowdin.Api/SecurityLogs/SecurityLog.cs | 35 +++++ .../SecurityLogs/SecurityLogEventType.cs | 76 +++++++++ .../SecurityLogs/SecurityLogsApiExecutor.cs | 116 ++++++++++++++ .../Core/Resources/SecurityLogs.Designer.cs | 60 ++++++++ .../Core/Resources/SecurityLogs.resx | 57 +++++++ .../Crowdin.Api.Tests.csproj | 9 ++ .../SecurityLogs/SecurityLogEnumsTests.cs | 49 ++++++ .../SecurityLogs/SecurityLogsApiTests.cs | 145 ++++++++++++++++++ 9 files changed, 555 insertions(+) create mode 100644 src/Crowdin.Api/SecurityLogs/SecurityLog.cs create mode 100644 src/Crowdin.Api/SecurityLogs/SecurityLogEventType.cs create mode 100644 src/Crowdin.Api/SecurityLogs/SecurityLogsApiExecutor.cs create mode 100644 tests/Crowdin.Api.Tests/Core/Resources/SecurityLogs.Designer.cs create mode 100644 tests/Crowdin.Api.Tests/Core/Resources/SecurityLogs.resx create mode 100644 tests/Crowdin.Api.Tests/SecurityLogs/SecurityLogEnumsTests.cs create mode 100644 tests/Crowdin.Api.Tests/SecurityLogs/SecurityLogsApiTests.cs diff --git a/src/Crowdin.Api/Core/InternalExtensions.cs b/src/Crowdin.Api/Core/InternalExtensions.cs index 69b9abd8..9430d5a1 100644 --- a/src/Crowdin.Api/Core/InternalExtensions.cs +++ b/src/Crowdin.Api/Core/InternalExtensions.cs @@ -42,6 +42,14 @@ internal static void AddParamIfPresent(this IDictionary queryPar queryParams.AddParamIfPresent(key, value.ToString()); } } + + internal static void AddParamIfPresent(this IDictionary queryParams, string key, long? value) + { + if (value.HasValue) + { + queryParams.AddParamIfPresent(key, value.ToString()); + } + } internal static void AddParamIfPresent(this IDictionary queryParams, string key, object? value) { diff --git a/src/Crowdin.Api/SecurityLogs/SecurityLog.cs b/src/Crowdin.Api/SecurityLogs/SecurityLog.cs new file mode 100644 index 00000000..e170917f --- /dev/null +++ b/src/Crowdin.Api/SecurityLogs/SecurityLog.cs @@ -0,0 +1,35 @@ + +using System; +using JetBrains.Annotations; +using Newtonsoft.Json; + +namespace Crowdin.Api.SecurityLogs +{ + [PublicAPI] + public class SecurityLog + { + [JsonProperty("id")] + public long Id { get; set; } + + [JsonProperty("event")] + public SecurityLogEventType Event { get; set; } + + [JsonProperty("info")] + public string Info { get; set; } + + [JsonProperty("userId")] + public long UserId { get; set; } + + [JsonProperty("location")] + public string Location { get; set; } + + [JsonProperty("ipAddress")] + public string IpAddress { get; set; } + + [JsonProperty("deviceName")] + public string DeviceName { get; set; } + + [JsonProperty("createdAt")] + public DateTimeOffset CreatedAt { get; set; } + } +} \ No newline at end of file diff --git a/src/Crowdin.Api/SecurityLogs/SecurityLogEventType.cs b/src/Crowdin.Api/SecurityLogs/SecurityLogEventType.cs new file mode 100644 index 00000000..36f2fc11 --- /dev/null +++ b/src/Crowdin.Api/SecurityLogs/SecurityLogEventType.cs @@ -0,0 +1,76 @@ + +using System.ComponentModel; +using JetBrains.Annotations; + +namespace Crowdin.Api.SecurityLogs +{ + [PublicAPI] + public enum SecurityLogEventType + { + [Description("login")] + Login, + + [Description("password.set")] + PasswordSet, + + [Description("password.change")] + PasswordChange, + + [Description("email.change")] + EmailChange, + + [Description("login.change")] + LoginChange, + + [Description("personal_token.issued")] + PersonalTokenIssued, + + [Description("personal_token.revoked")] + PersonalTokenRevoked, + + [Description("mfa.enabled")] + MfaEnabled, + + [Description("mfa.disabled")] + MfaDisabled, + + [Description("session.revoke")] + SessionRevoke, + + [Description("session.revoke_all")] + SessionRevokeAll, + + [Description("sso.connect")] + SsoConnect, + + [Description("sso.disconnect")] + SsoDisconnect, + + [Description("user.remove")] + UserRemove, + + [Description("application.connected")] + ApplicationConnected, + + [Description("application.disconnected")] + ApplicationDisconnected, + + [Description("webauthn.created")] + WebAuthNCreated, + + [Description("webauthn.deleted")] + WebAuthNDeleted, + + [Description("trusted_device.remove")] + TrustedDeviceRemove, + + [Description("trusted_device.remove_all")] + TrustedDeviceRemoveAll, + + [Description("device_verification.enabled")] + DeviceVerificationEnabled, + + [Description("device_verification.disabled")] + DeviceVerificationDisabled + } +} \ No newline at end of file diff --git a/src/Crowdin.Api/SecurityLogs/SecurityLogsApiExecutor.cs b/src/Crowdin.Api/SecurityLogs/SecurityLogsApiExecutor.cs new file mode 100644 index 00000000..91e48cd5 --- /dev/null +++ b/src/Crowdin.Api/SecurityLogs/SecurityLogsApiExecutor.cs @@ -0,0 +1,116 @@ + +using System.Collections.Generic; +using System.Threading.Tasks; + +using Crowdin.Api.Core; +using JetBrains.Annotations; + +#nullable enable + +namespace Crowdin.Api.SecurityLogs +{ + public class SecurityLogsApiExecutor + { + private readonly ICrowdinApiClient _apiClient; + private readonly IJsonParser _jsonParser; + + public SecurityLogsApiExecutor(ICrowdinApiClient apiClient) + { + _apiClient = apiClient; + _jsonParser = apiClient.DefaultJsonParser; + } + + public SecurityLogsApiExecutor(ICrowdinApiClient apiClient, IJsonParser jsonParser) + { + _apiClient = apiClient; + _jsonParser = jsonParser; + } + + /// + /// List User Security Logs. Documentation: + /// Crowdin API + /// Crowdin Enterprise API + /// + [PublicAPI] + public async Task> ListUserSecurityLogs( + long userId, + int limit = 25, + int offset = 0, + SecurityLogEventType? eventType = null, + string? ipAddress = null) + { + string url = FormUrl_Logs(userId); + + IDictionary queryParams = Utils.CreateQueryParamsFromPaging(limit, offset); + queryParams.AddDescriptionEnumValueIfPresent("event", eventType); + queryParams.AddParamIfPresent("ipAddress", ipAddress); + + CrowdinApiResult result = await _apiClient.SendGetRequest(url, queryParams); + return _jsonParser.ParseResponseList(result.JsonObject); + } + + /// + /// Get User Security Log. Documentation: + /// Crowdin API + /// Crowdin Enterprise API + /// + [PublicAPI] + public async Task GetUserSecurityLog(long userId, long securityLogId) + { + string url = FormUrl_LogId(userId, securityLogId); + CrowdinApiResult result = await _apiClient.SendGetRequest(url); + return _jsonParser.ParseResponseObject(result.JsonObject); + } + + #region Helper methods + + private static string FormUrl_Logs(long userId) + { + return $"/users/{userId}/security-logs"; + } + + private static string FormUrl_LogId(long userId, long securityLogId) + { + return $"/users/{userId}/security-logs/{securityLogId}"; + } + + #endregion + + #region Enterprise API + + /// + /// List Organization Security Logs. Documentation: + /// Crowdin Enterprise API + /// + [PublicAPI] + public async Task> ListOrganizationSecurityLogs( + int limit = 25, + int offset = 0, + SecurityLogEventType? eventType = null, + string? ipAddress = null, + long? userId = null) + { + IDictionary queryParams = Utils.CreateQueryParamsFromPaging(limit, offset); + queryParams.AddDescriptionEnumValueIfPresent("event", eventType); + queryParams.AddParamIfPresent("ipAddress", ipAddress); + queryParams.AddParamIfPresent("userId", userId); + + CrowdinApiResult result = await _apiClient.SendGetRequest("/security-logs", queryParams); + return _jsonParser.ParseResponseList(result.JsonObject); + } + + /// + /// Get Organization Security Log. Documentation: + /// Crowdin Enterprise API + /// + [PublicAPI] + public async Task GetOrganizationSecurityLog(long securityLogId) + { + var url = $"/security-logs/{securityLogId}"; + CrowdinApiResult result = await _apiClient.SendGetRequest(url); + return _jsonParser.ParseResponseObject(result.JsonObject); + } + + #endregion + } +} \ No newline at end of file diff --git a/tests/Crowdin.Api.Tests/Core/Resources/SecurityLogs.Designer.cs b/tests/Crowdin.Api.Tests/Core/Resources/SecurityLogs.Designer.cs new file mode 100644 index 00000000..c66861a0 --- /dev/null +++ b/tests/Crowdin.Api.Tests/Core/Resources/SecurityLogs.Designer.cs @@ -0,0 +1,60 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Crowdin.Api.Tests.Core.Resources { + using System; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class SecurityLogs { + + private static System.Resources.ResourceManager resourceMan; + + private static System.Globalization.CultureInfo resourceCulture; + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal SecurityLogs() { + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Resources.ResourceManager ResourceManager { + get { + if (object.Equals(null, resourceMan)) { + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("Crowdin.Api.Tests.Core.Resources.SecurityLogs", typeof(SecurityLogs).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + internal static string ListUserSecurityLogs_Response { + get { + return ResourceManager.GetString("ListUserSecurityLogs_Response", resourceCulture); + } + } + + internal static string GetUserSecurityLog_Response { + get { + return ResourceManager.GetString("GetUserSecurityLog_Response", resourceCulture); + } + } + } +} diff --git a/tests/Crowdin.Api.Tests/Core/Resources/SecurityLogs.resx b/tests/Crowdin.Api.Tests/Core/Resources/SecurityLogs.resx new file mode 100644 index 00000000..aabf9250 --- /dev/null +++ b/tests/Crowdin.Api.Tests/Core/Resources/SecurityLogs.resx @@ -0,0 +1,57 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + { + "data": [ + { + "data": { + "id": 2, + "event": "application.connected", + "info": "Some info", + "userId": 4, + "location": "USA", + "ipAddress": "127.0.0.1", + "deviceName": "MacOs on MacBook", + "createdAt": "2019-09-19T15:10:43+00:00" + } + } + ], + "pagination": { + "offset": 0, + "limit": 25 + } +} + + + { + "data": { + "id": 2, + "event": "application.connected", + "info": "Some info", + "userId": 4, + "location": "USA", + "ipAddress": "127.0.0.1", + "deviceName": "MacOs on MacBook", + "createdAt": "2019-09-19T15:10:43+00:00" + } +} + + \ No newline at end of file diff --git a/tests/Crowdin.Api.Tests/Crowdin.Api.Tests.csproj b/tests/Crowdin.Api.Tests/Crowdin.Api.Tests.csproj index 6045378f..370f65d0 100644 --- a/tests/Crowdin.Api.Tests/Crowdin.Api.Tests.csproj +++ b/tests/Crowdin.Api.Tests/Crowdin.Api.Tests.csproj @@ -174,6 +174,10 @@ ResXFileCodeGenerator Applications.Designer.cs + + ResXFileCodeGenerator + SecurityLogs.Designer.cs + @@ -352,6 +356,11 @@ True Applications.resx + + True + True + SecurityLogs.resx + diff --git a/tests/Crowdin.Api.Tests/SecurityLogs/SecurityLogEnumsTests.cs b/tests/Crowdin.Api.Tests/SecurityLogs/SecurityLogEnumsTests.cs new file mode 100644 index 00000000..72c12105 --- /dev/null +++ b/tests/Crowdin.Api.Tests/SecurityLogs/SecurityLogEnumsTests.cs @@ -0,0 +1,49 @@ + +using System; + +using Newtonsoft.Json; +using Xunit; + +using Crowdin.Api.SecurityLogs; +using Crowdin.Api.Tests.Core; + +namespace Crowdin.Api.Tests.SecurityLogs +{ + public class SecurityLogEnumsTests + { + private static readonly JsonSerializerSettings JsonSettings = TestUtils.CreateJsonSerializerOptions(); + + [Fact] + public void EventTypes() + { + SerializeAndAssert(SecurityLogEventType.Login, "login"); + SerializeAndAssert(SecurityLogEventType.PasswordSet, "password.set"); + SerializeAndAssert(SecurityLogEventType.PasswordChange, "password.change"); + SerializeAndAssert(SecurityLogEventType.EmailChange, "email.change"); + SerializeAndAssert(SecurityLogEventType.LoginChange, "login.change"); + SerializeAndAssert(SecurityLogEventType.PersonalTokenIssued, "personal_token.issued"); + SerializeAndAssert(SecurityLogEventType.PersonalTokenRevoked, "personal_token.revoked"); + SerializeAndAssert(SecurityLogEventType.MfaEnabled, "mfa.enabled"); + SerializeAndAssert(SecurityLogEventType.MfaDisabled, "mfa.disabled"); + SerializeAndAssert(SecurityLogEventType.SessionRevoke, "session.revoke"); + SerializeAndAssert(SecurityLogEventType.SessionRevokeAll, "session.revoke_all"); + SerializeAndAssert(SecurityLogEventType.SsoConnect, "sso.connect"); + SerializeAndAssert(SecurityLogEventType.SsoDisconnect, "sso.disconnect"); + SerializeAndAssert(SecurityLogEventType.UserRemove, "user.remove"); + SerializeAndAssert(SecurityLogEventType.ApplicationConnected, "application.connected"); + SerializeAndAssert(SecurityLogEventType.ApplicationDisconnected, "application.disconnected"); + SerializeAndAssert(SecurityLogEventType.WebAuthNCreated, "webauthn.created"); + SerializeAndAssert(SecurityLogEventType.WebAuthNDeleted, "webauthn.deleted"); + SerializeAndAssert(SecurityLogEventType.TrustedDeviceRemove, "trusted_device.remove"); + SerializeAndAssert(SecurityLogEventType.TrustedDeviceRemoveAll, "trusted_device.remove_all"); + SerializeAndAssert(SecurityLogEventType.DeviceVerificationEnabled, "device_verification.enabled"); + SerializeAndAssert(SecurityLogEventType.DeviceVerificationDisabled, "device_verification.disabled"); + } + + private static void SerializeAndAssert(Enum enumValue, string expectedValueString) + { + string actualValueString = TestUtils.SerializeValue(enumValue, JsonSettings); + Assert.Equal(expectedValueString, actualValueString); + } + } +} \ No newline at end of file diff --git a/tests/Crowdin.Api.Tests/SecurityLogs/SecurityLogsApiTests.cs b/tests/Crowdin.Api.Tests/SecurityLogs/SecurityLogsApiTests.cs new file mode 100644 index 00000000..ea414d3a --- /dev/null +++ b/tests/Crowdin.Api.Tests/SecurityLogs/SecurityLogsApiTests.cs @@ -0,0 +1,145 @@ + +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; + +using Moq; +using Newtonsoft.Json.Linq; +using Xunit; + +using Crowdin.Api.Core; +using Crowdin.Api.SecurityLogs; +using Crowdin.Api.Tests.Core; + +namespace Crowdin.Api.Tests.SecurityLogs +{ + public class SecurityLogsApiTests + { + [Fact] + public async Task ListUserSecurityLogs() + { + const long userId = 1; + const SecurityLogEventType eventType = SecurityLogEventType.ApplicationConnected; + const string ipAddress = "127.0.0.1"; + + Mock mockClient = TestUtils.CreateMockClientWithDefaultParser(); + + var url = $"/users/{userId}/security-logs"; + + IDictionary queryParams = TestUtils.CreateQueryParamsFromPaging(); + queryParams.AddDescriptionEnumValueIfPresent("event", eventType); + queryParams.AddParamIfPresent("ipAddress", ipAddress); + + mockClient + .Setup(client => client.SendGetRequest(url, queryParams)) + .ReturnsAsync(new CrowdinApiResult + { + StatusCode = HttpStatusCode.OK, + JsonObject = JObject.Parse(Core.Resources.SecurityLogs.ListUserSecurityLogs_Response) + }); + + var executor = new SecurityLogsApiExecutor(mockClient.Object); + ResponseList response = await executor.ListUserSecurityLogs( + userId, + eventType: eventType, + ipAddress: ipAddress); + + Assert.NotNull(response); + Assert.Single(response.Data); + Assert_SecurityLog(response.Data[0]); + } + + [Fact] + public async Task GetUserSecurityLog() + { + const long userId = 1; + const long securityLogId = 2; + + Mock mockClient = TestUtils.CreateMockClientWithDefaultParser(); + + var url = $"/users/{userId}/security-logs/{securityLogId}"; + + mockClient + .Setup(client => client.SendGetRequest(url, null)) + .ReturnsAsync(new CrowdinApiResult + { + StatusCode = HttpStatusCode.OK, + JsonObject = JObject.Parse(Core.Resources.SecurityLogs.GetUserSecurityLog_Response) + }); + + var executor = new SecurityLogsApiExecutor(mockClient.Object); + SecurityLog response = await executor.GetUserSecurityLog(userId, securityLogId); + + Assert_SecurityLog(response); + } + + [Fact] + public async Task ListOrganizationSecurityLogs() + { + const SecurityLogEventType eventType = SecurityLogEventType.SsoConnect; + const string ipAddress = "127.0.0.1"; + const long userId = 123; + + Mock mockClient = TestUtils.CreateMockClientWithDefaultParser(); + + IDictionary queryParams = TestUtils.CreateQueryParamsFromPaging(); + queryParams.AddDescriptionEnumValueIfPresent("event", eventType); + queryParams.AddParamIfPresent("ipAddress", ipAddress); + queryParams.AddParamIfPresent("userId", userId); + + mockClient + .Setup(client => client.SendGetRequest("/security-logs", queryParams)) + .ReturnsAsync(new CrowdinApiResult + { + StatusCode = HttpStatusCode.OK, + JsonObject = JObject.Parse(Core.Resources.SecurityLogs.ListUserSecurityLogs_Response) + }); + + var executor = new SecurityLogsApiExecutor(mockClient.Object); + ResponseList response = await executor.ListOrganizationSecurityLogs( + eventType: eventType, + ipAddress: ipAddress, + userId: userId); + + Assert.NotNull(response); + Assert.Single(response.Data); + Assert_SecurityLog(response.Data[0]); + } + + [Fact] + public async Task GetOrganizationSecurityLog() + { + const long securityLogId = 1; + + Mock mockClient = TestUtils.CreateMockClientWithDefaultParser(); + + var url = $"/security-logs/{securityLogId}"; + + mockClient + .Setup(client => client.SendGetRequest(url, null)) + .ReturnsAsync(new CrowdinApiResult + { + StatusCode = HttpStatusCode.OK, + JsonObject = JObject.Parse(Core.Resources.SecurityLogs.GetUserSecurityLog_Response) + }); + + var executor = new SecurityLogsApiExecutor(mockClient.Object); + SecurityLog response = await executor.GetOrganizationSecurityLog(securityLogId); + + Assert_SecurityLog(response); + } + + private static void Assert_SecurityLog(SecurityLog log) + { + Assert.Equal(2, log.Id); + Assert.Equal(SecurityLogEventType.ApplicationConnected, log.Event); + Assert.Equal("Some info", log.Info); + Assert.Equal(4, log.UserId); + Assert.Equal("USA", log.Location); + Assert.Equal("127.0.0.1", log.IpAddress); + Assert.Equal("MacOs on MacBook", log.DeviceName); + Assert.Equal(DateTimeOffset.Parse("2019-09-19T15:10:43+00:00"), log.CreatedAt); + } + } +} \ No newline at end of file From 518a3fe9745056c76db1acbda3cb25faa81c9b80 Mon Sep 17 00:00:00 2001 From: Max Inno Date: Thu, 1 Feb 2024 03:45:13 +0200 Subject: [PATCH 2/2] fix: add to CrowdinApiClient --- src/Crowdin.Api/CrowdinApiClient.cs | 4 ++++ src/Crowdin.Api/ICrowdinApiClient.cs | 3 +++ 2 files changed, 7 insertions(+) diff --git a/src/Crowdin.Api/CrowdinApiClient.cs b/src/Crowdin.Api/CrowdinApiClient.cs index ca21efc1..74dc73ff 100644 --- a/src/Crowdin.Api/CrowdinApiClient.cs +++ b/src/Crowdin.Api/CrowdinApiClient.cs @@ -25,6 +25,7 @@ using Crowdin.Api.ProjectsGroups; using Crowdin.Api.Reports; using Crowdin.Api.Screenshots; +using Crowdin.Api.SecurityLogs; using Crowdin.Api.SourceFiles; using Crowdin.Api.SourceStrings; using Crowdin.Api.Storage; @@ -73,6 +74,8 @@ public class CrowdinApiClient : ICrowdinApiClient public ScreenshotsApiExecutor Screenshots { get; } + public SecurityLogsApiExecutor SecurityLogs { get; } + public SourceFilesApiExecutor SourceFiles { get; } public SourceStringsApiExecutor SourceStrings { get; } @@ -174,6 +177,7 @@ public CrowdinApiClient( ProjectsGroups = new ProjectsGroupsApiExecutor(this); Reports = new ReportsApiExecutor(this); Screenshots = new ScreenshotsApiExecutor(this); + SecurityLogs = new SecurityLogsApiExecutor(this); SourceFiles = new SourceFilesApiExecutor(this); SourceStrings = new SourceStringsApiExecutor(this); Storage = new StorageApiExecutor(this); diff --git a/src/Crowdin.Api/ICrowdinApiClient.cs b/src/Crowdin.Api/ICrowdinApiClient.cs index 28bd1667..cf02a854 100644 --- a/src/Crowdin.Api/ICrowdinApiClient.cs +++ b/src/Crowdin.Api/ICrowdinApiClient.cs @@ -18,6 +18,7 @@ using Crowdin.Api.ProjectsGroups; using Crowdin.Api.Reports; using Crowdin.Api.Screenshots; +using Crowdin.Api.SecurityLogs; using Crowdin.Api.SourceFiles; using Crowdin.Api.SourceStrings; using Crowdin.Api.Storage; @@ -66,6 +67,8 @@ public interface ICrowdinApiClient ScreenshotsApiExecutor Screenshots { get; } + SecurityLogsApiExecutor SecurityLogs { get; } + SourceFilesApiExecutor SourceFiles { get; } SourceStringsApiExecutor SourceStrings { get; }