diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcProtectedResourcePolicyProviderTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcProtectedResourcePolicyProviderTests.cs index e4028be..556ac4c 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcProtectedResourcePolicyProviderTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcProtectedResourcePolicyProviderTests.cs @@ -87,9 +87,11 @@ public async Task ShouldThrowsArgumentNullExceptionForNullOrEmptyPolicyName() string policyName = null; // Act & Assert - _ = await Assert.ThrowsExceptionAsync(async () => - // ReSharper disable once AssignNullToNotNullAttribute - await _policyProvider.GetPolicyAsync(policyName).ConfigureAwait(false)).ConfigureAwait(false); + _ = await Assert.ThrowsExceptionAsync( + async () => + // ReSharper disable once AssignNullToNotNullAttribute + await _policyProvider.GetPolicyAsync(policyName).ConfigureAwait(false)) + .ConfigureAwait(false); } /// diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcOrganizationTests/KcOrganizationMemberHappyPathTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcOrganizationTests/KcOrganizationMemberHappyPathTests.cs new file mode 100644 index 0000000..d9daa62 --- /dev/null +++ b/NETCore.Keycloak.Client.Tests/Modules/KcOrganizationTests/KcOrganizationMemberHappyPathTests.cs @@ -0,0 +1,431 @@ +using System.Net; +using NETCore.Keycloak.Client.Models.Organizations; +using NETCore.Keycloak.Client.Models.Users; +using NETCore.Keycloak.Client.Tests.Abstraction; +using Newtonsoft.Json; + +namespace NETCore.Keycloak.Client.Tests.Modules.KcOrganizationTests; + +/// +/// Contains integration tests for Keycloak organization member management operations. +/// Tests the full lifecycle of organization members including adding, listing, retrieving, +/// counting, getting member organizations, and removing members. +/// Organizations are available in Keycloak 26 and above. +/// +[TestClass] +[TestCategory("Sequential")] +public class KcOrganizationMemberHappyPathTests : KcTestingModule +{ + /// + /// Represents the context of the current test. + /// + private const string TestContext = "GlobalContext"; + + /// + /// Gets or sets the Keycloak organization used for testing member operations. + /// + private static KcOrganization TestOrganization + { + get + { + try + { + return JsonConvert.DeserializeObject( + Environment.GetEnvironmentVariable( + $"{nameof(KcOrganizationMemberHappyPathTests)}_KC_ORGANIZATION") ?? string.Empty); + } + catch ( Exception e ) + { + Assert.Fail(e.Message); + return null; + } + } + set => Environment.SetEnvironmentVariable( + $"{nameof(KcOrganizationMemberHappyPathTests)}_KC_ORGANIZATION", + JsonConvert.SerializeObject(value)); + } + + /// + /// Gets or sets the Keycloak user used for testing member operations. + /// + private static KcUser TestUser + { + get + { + try + { + return JsonConvert.DeserializeObject( + Environment.GetEnvironmentVariable( + $"{nameof(KcOrganizationMemberHappyPathTests)}_KC_USER") ?? string.Empty); + } + catch ( Exception e ) + { + Assert.Fail(e.Message); + return null; + } + } + set => Environment.SetEnvironmentVariable( + $"{nameof(KcOrganizationMemberHappyPathTests)}_KC_USER", + JsonConvert.SerializeObject(value)); + } + + /// + /// Sets up the test environment before each test execution. + /// + [TestInitialize] + public void Init() => Assert.IsNotNull(KeycloakRestClient.Organizations); + + /// + /// Creates an organization and a user for the member tests. + /// + [TestMethod] + public async Task A_ShouldSetupOrganizationAndUser() + { + // Skip on Keycloak versions below 26 — organizations are not supported. + if ( GetKcMajorVersion() < 26 ) + { + Assert.Inconclusive("Skipped — organizations require Keycloak 26 or above."); + } + + // Create an organization and user for member testing. + var organization = await CreateAndGetOrganizationAsync(TestContext).ConfigureAwait(false); + Assert.IsNotNull(organization); + TestOrganization = organization; + + // Create a user to be added as a member. + var user = await CreateAndGetRealmUserAsync(TestContext).ConfigureAwait(false); + Assert.IsNotNull(user); + TestUser = user; + } + + /// + /// Validates adding a user as a member of an organization. + /// + [TestMethod] + public async Task B_ShouldAddMemberToOrganization() + { + if ( GetKcMajorVersion() < 26 ) + { + Assert.Inconclusive("Skipped — organizations require Keycloak 26 or above."); + } + + Assert.IsNotNull(TestOrganization); + Assert.IsNotNull(TestUser); + + var accessToken = await GetRealmAdminTokenAsync(TestContext).ConfigureAwait(false); + Assert.IsNotNull(accessToken); + + var addMemberResponse = await KeycloakRestClient.Organizations.AddMemberAsync( + TestEnvironment.TestingRealm.Name, + accessToken.AccessToken, + TestOrganization.Id, + TestUser.Id).ConfigureAwait(false); + + Assert.IsNotNull(addMemberResponse); + Assert.IsFalse(addMemberResponse.IsError); + + KcCommonAssertion.AssertResponseMonitoringMetrics(addMemberResponse.MonitoringMetrics, + HttpStatusCode.Created, HttpMethod.Post); + } + + /// + /// Validates listing members of an organization. + /// + [TestMethod] + public async Task C_ShouldGetOrganizationMembers() + { + if ( GetKcMajorVersion() < 26 ) + { + Assert.Inconclusive("Skipped — organizations require Keycloak 26 or above."); + } + + Assert.IsNotNull(TestOrganization); + + var accessToken = await GetRealmAdminTokenAsync(TestContext).ConfigureAwait(false); + Assert.IsNotNull(accessToken); + + var getMembersResponse = await KeycloakRestClient.Organizations.GetMembersAsync( + TestEnvironment.TestingRealm.Name, + accessToken.AccessToken, + TestOrganization.Id).ConfigureAwait(false); + + Assert.IsNotNull(getMembersResponse); + Assert.IsFalse(getMembersResponse.IsError); + Assert.IsNotNull(getMembersResponse.Response); + Assert.IsTrue(getMembersResponse.Response.Any()); + + KcCommonAssertion.AssertResponseMonitoringMetrics(getMembersResponse.MonitoringMetrics, + HttpStatusCode.OK, HttpMethod.Get); + } + + /// + /// Validates listing members with a filter. + /// + [TestMethod] + public async Task D_ShouldGetOrganizationMembersWithFilter() + { + if ( GetKcMajorVersion() < 26 ) + { + Assert.Inconclusive("Skipped — organizations require Keycloak 26 or above."); + } + + Assert.IsNotNull(TestOrganization); + Assert.IsNotNull(TestUser); + + var accessToken = await GetRealmAdminTokenAsync(TestContext).ConfigureAwait(false); + Assert.IsNotNull(accessToken); + + var getMembersResponse = await KeycloakRestClient.Organizations.GetMembersAsync( + TestEnvironment.TestingRealm.Name, + accessToken.AccessToken, + TestOrganization.Id, + new KcOrganizationMemberFilter + { + Search = TestUser.Email, + Exact = true, + Max = 10 + }).ConfigureAwait(false); + + Assert.IsNotNull(getMembersResponse); + Assert.IsFalse(getMembersResponse.IsError); + Assert.IsNotNull(getMembersResponse.Response); + Assert.IsTrue(getMembersResponse.Response.Any(m => m.Email == TestUser.Email)); + + KcCommonAssertion.AssertResponseMonitoringMetrics(getMembersResponse.MonitoringMetrics, + HttpStatusCode.OK, HttpMethod.Get); + } + + /// + /// Validates counting members of an organization. + /// + [TestMethod] + public async Task E_ShouldGetOrganizationMembersCount() + { + if ( GetKcMajorVersion() < 26 ) + { + Assert.Inconclusive("Skipped — organizations require Keycloak 26 or above."); + } + + Assert.IsNotNull(TestOrganization); + + var accessToken = await GetRealmAdminTokenAsync(TestContext).ConfigureAwait(false); + Assert.IsNotNull(accessToken); + + var countResponse = await KeycloakRestClient.Organizations.GetMembersCountAsync( + TestEnvironment.TestingRealm.Name, + accessToken.AccessToken, + TestOrganization.Id).ConfigureAwait(false); + + Assert.IsNotNull(countResponse); + Assert.IsFalse(countResponse.IsError); + Assert.IsTrue(countResponse.Response > 0); + + KcCommonAssertion.AssertResponseMonitoringMetrics(countResponse.MonitoringMetrics, + HttpStatusCode.OK, HttpMethod.Get); + } + + /// + /// Validates retrieving a specific member by ID. + /// + [TestMethod] + public async Task F_ShouldGetOrganizationMember() + { + if ( GetKcMajorVersion() < 26 ) + { + Assert.Inconclusive("Skipped — organizations require Keycloak 26 or above."); + } + + Assert.IsNotNull(TestOrganization); + Assert.IsNotNull(TestUser); + + var accessToken = await GetRealmAdminTokenAsync(TestContext).ConfigureAwait(false); + Assert.IsNotNull(accessToken); + + var getMemberResponse = await KeycloakRestClient.Organizations.GetMemberAsync( + TestEnvironment.TestingRealm.Name, + accessToken.AccessToken, + TestOrganization.Id, + TestUser.Id).ConfigureAwait(false); + + Assert.IsNotNull(getMemberResponse); + Assert.IsFalse(getMemberResponse.IsError); + Assert.IsNotNull(getMemberResponse.Response); + Assert.IsInstanceOfType(getMemberResponse.Response); + Assert.AreEqual(TestUser.Email, getMemberResponse.Response.Email); + + KcCommonAssertion.AssertResponseMonitoringMetrics(getMemberResponse.MonitoringMetrics, + HttpStatusCode.OK, HttpMethod.Get); + } + + /// + /// Validates retrieving organizations for a member within an organization context. + /// + [TestMethod] + public async Task G_ShouldGetMemberOrganizations() + { + if ( GetKcMajorVersion() < 26 ) + { + Assert.Inconclusive("Skipped — organizations require Keycloak 26 or above."); + } + + Assert.IsNotNull(TestOrganization); + Assert.IsNotNull(TestUser); + + var accessToken = await GetRealmAdminTokenAsync(TestContext).ConfigureAwait(false); + Assert.IsNotNull(accessToken); + + var memberOrgsResponse = await KeycloakRestClient.Organizations.GetMemberOrganizationsAsync( + TestEnvironment.TestingRealm.Name, + accessToken.AccessToken, + TestOrganization.Id, + TestUser.Id).ConfigureAwait(false); + + Assert.IsNotNull(memberOrgsResponse); + Assert.IsFalse(memberOrgsResponse.IsError); + Assert.IsNotNull(memberOrgsResponse.Response); + Assert.IsTrue(memberOrgsResponse.Response.Any(o => o.Id == TestOrganization.Id)); + + KcCommonAssertion.AssertResponseMonitoringMetrics(memberOrgsResponse.MonitoringMetrics, + HttpStatusCode.OK, HttpMethod.Get); + } + + /// + /// Validates retrieving organizations for a user using the top-level endpoint. + /// + [TestMethod] + public async Task H_ShouldGetUserOrganizations() + { + if ( GetKcMajorVersion() < 26 ) + { + Assert.Inconclusive("Skipped — organizations require Keycloak 26 or above."); + } + + Assert.IsNotNull(TestOrganization); + Assert.IsNotNull(TestUser); + + var accessToken = await GetRealmAdminTokenAsync(TestContext).ConfigureAwait(false); + Assert.IsNotNull(accessToken); + + var userOrgsResponse = await KeycloakRestClient.Organizations.GetUserOrganizationsAsync( + TestEnvironment.TestingRealm.Name, + accessToken.AccessToken, + TestUser.Id).ConfigureAwait(false); + + Assert.IsNotNull(userOrgsResponse); + Assert.IsFalse(userOrgsResponse.IsError); + Assert.IsNotNull(userOrgsResponse.Response); + Assert.IsTrue(userOrgsResponse.Response.Any(o => o.Id == TestOrganization.Id)); + + KcCommonAssertion.AssertResponseMonitoringMetrics(userOrgsResponse.MonitoringMetrics, + HttpStatusCode.OK, HttpMethod.Get); + } + + /// + /// Validates removing a member from an organization. + /// + [TestMethod] + public async Task I_ShouldRemoveMemberFromOrganization() + { + if ( GetKcMajorVersion() < 26 ) + { + Assert.Inconclusive("Skipped — organizations require Keycloak 26 or above."); + } + + Assert.IsNotNull(TestOrganization); + Assert.IsNotNull(TestUser); + + var accessToken = await GetRealmAdminTokenAsync(TestContext).ConfigureAwait(false); + Assert.IsNotNull(accessToken); + + var removeMemberResponse = await KeycloakRestClient.Organizations.RemoveMemberAsync( + TestEnvironment.TestingRealm.Name, + accessToken.AccessToken, + TestOrganization.Id, + TestUser.Id).ConfigureAwait(false); + + Assert.IsNotNull(removeMemberResponse); + Assert.IsFalse(removeMemberResponse.IsError); + + KcCommonAssertion.AssertResponseMonitoringMetrics(removeMemberResponse.MonitoringMetrics, + HttpStatusCode.NoContent, HttpMethod.Delete); + } + + /// + /// Validates inviting an existing user to an organization. + /// This test requires SMTP to be configured in the Keycloak realm, + /// as the invite endpoint sends an email to the user. + /// + [TestMethod] + [TestCategory("RequiresSMTP")] + public async Task J_ShouldInviteExistingUserToOrganization() + { + if ( GetKcMajorVersion() < 26 ) + { + Assert.Inconclusive("Skipped — organizations require Keycloak 26 or above."); + } + + Assert.IsNotNull(TestOrganization); + Assert.IsNotNull(TestUser); + + var accessToken = await GetRealmAdminTokenAsync(TestContext).ConfigureAwait(false); + Assert.IsNotNull(accessToken); + + var inviteResponse = await KeycloakRestClient.Organizations.InviteExistingUserAsync( + TestEnvironment.TestingRealm.Name, + accessToken.AccessToken, + TestOrganization.Id, + TestUser.Id).ConfigureAwait(false); + + Assert.IsNotNull(inviteResponse); + + // The invite endpoint requires SMTP configuration. + // If SMTP is not configured, Keycloak returns 500 with "Failed to send invite email". + if ( inviteResponse.IsError && + inviteResponse.ErrorMessage?.Contains("Failed to send invite email", + StringComparison.OrdinalIgnoreCase) == true ) + { + Assert.Inconclusive("Skipped — SMTP not configured in Keycloak realm."); + } + + Assert.IsFalse(inviteResponse.IsError); + + KcCommonAssertion.AssertResponseMonitoringMetrics(inviteResponse.MonitoringMetrics, + HttpStatusCode.NoContent, HttpMethod.Post); + } + + /// + /// Cleans up by deleting the test user and organization. + /// + [TestMethod] + public async Task K_ShouldCleanup() + { + if ( GetKcMajorVersion() < 26 ) + { + Assert.Inconclusive("Skipped — organizations require Keycloak 26 or above."); + } + + Assert.IsNotNull(TestOrganization); + Assert.IsNotNull(TestUser); + + var accessToken = await GetRealmAdminTokenAsync(TestContext).ConfigureAwait(false); + Assert.IsNotNull(accessToken); + + // Delete the test user. + var deleteUserResponse = await KeycloakRestClient.Users.DeleteAsync( + TestEnvironment.TestingRealm.Name, + accessToken.AccessToken, + TestUser.Id).ConfigureAwait(false); + + Assert.IsNotNull(deleteUserResponse); + Assert.IsFalse(deleteUserResponse.IsError); + + // Delete the test organization. + var deleteOrgResponse = await KeycloakRestClient.Organizations.DeleteAsync( + TestEnvironment.TestingRealm.Name, + accessToken.AccessToken, + TestOrganization.Id).ConfigureAwait(false); + + Assert.IsNotNull(deleteOrgResponse); + Assert.IsFalse(deleteOrgResponse.IsError); + } +} diff --git a/NETCore.Keycloak.Client/HttpClients/Abstraction/IKcOrganizations.cs b/NETCore.Keycloak.Client/HttpClients/Abstraction/IKcOrganizations.cs index 99c0a23..cbfeefa 100644 --- a/NETCore.Keycloak.Client/HttpClients/Abstraction/IKcOrganizations.cs +++ b/NETCore.Keycloak.Client/HttpClients/Abstraction/IKcOrganizations.cs @@ -1,6 +1,7 @@ using NETCore.Keycloak.Client.Exceptions; using NETCore.Keycloak.Client.Models; using NETCore.Keycloak.Client.Models.Organizations; +using NETCore.Keycloak.Client.Models.Users; namespace NETCore.Keycloak.Client.HttpClients.Abstraction; @@ -125,4 +126,195 @@ Task> CountAsync( string accessToken, KcOrganizationFilter filter = null, CancellationToken cancellationToken = default); + + /// + /// Adds a user as a member of an organization in a specified Keycloak realm. + /// + /// POST /{realm}/organizations/{organizationId}/members + /// + /// The Keycloak realm where the organization exists. + /// The access token used for authentication. + /// The ID of the organization. + /// The ID of the user to add as a member. + /// Optional cancellation token. + /// + /// A indicating the result of the operation. + /// + /// Thrown if any required parameter is null, empty, or invalid. + Task> AddMemberAsync( + string realm, + string accessToken, + string organizationId, + string userId, + CancellationToken cancellationToken = default); + + /// + /// Retrieves a paginated list of members of an organization in a specified Keycloak realm. + /// + /// GET /{realm}/organizations/{organizationId}/members + /// + /// The Keycloak realm where the organization exists. + /// The access token used for authentication. + /// The ID of the organization. + /// Optional filter criteria for pagination and search. + /// Optional cancellation token. + /// + /// A containing an enumerable of objects. + /// + /// Thrown if any required parameter is null, empty, or invalid. + Task>> GetMembersAsync( + string realm, + string accessToken, + string organizationId, + KcOrganizationMemberFilter filter = null, + CancellationToken cancellationToken = default); + + /// + /// Retrieves the count of members in an organization in a specified Keycloak realm. + /// + /// GET /{realm}/organizations/{organizationId}/members/count + /// + /// The Keycloak realm where the organization exists. + /// The access token used for authentication. + /// The ID of the organization. + /// Optional cancellation token. + /// + /// A with the count of members. + /// + /// Thrown if any required parameter is null, empty, or invalid. + Task> GetMembersCountAsync( + string realm, + string accessToken, + string organizationId, + CancellationToken cancellationToken = default); + + /// + /// Retrieves a specific member of an organization by their ID in a specified Keycloak realm. + /// + /// GET /{realm}/organizations/{organizationId}/members/{memberId} + /// + /// The Keycloak realm where the organization exists. + /// The access token used for authentication. + /// The ID of the organization. + /// The ID of the member to retrieve. + /// Optional cancellation token. + /// + /// A containing the details. + /// + /// Thrown if any required parameter is null, empty, or invalid. + Task> GetMemberAsync( + string realm, + string accessToken, + string organizationId, + string memberId, + CancellationToken cancellationToken = default); + + /// + /// Removes a member from an organization in a specified Keycloak realm. + /// + /// DELETE /{realm}/organizations/{organizationId}/members/{memberId} + /// + /// The Keycloak realm where the organization exists. + /// The access token used for authentication. + /// The ID of the organization. + /// The ID of the member to remove. + /// Optional cancellation token. + /// + /// A indicating the result of the operation. + /// + /// Thrown if any required parameter is null, empty, or invalid. + Task> RemoveMemberAsync( + string realm, + string accessToken, + string organizationId, + string memberId, + CancellationToken cancellationToken = default); + + /// + /// Retrieves the organizations associated with a specific member within an organization context. + /// + /// GET /{realm}/organizations/{organizationId}/members/{memberId}/organizations + /// + /// The Keycloak realm to query. + /// The access token used for authentication. + /// The ID of the organization context. + /// The ID of the member whose organizations are being retrieved. + /// Optional cancellation token. + /// + /// A containing an enumerable of objects. + /// + /// Thrown if any required parameter is null, empty, or invalid. + Task>> GetMemberOrganizationsAsync( + string realm, + string accessToken, + string organizationId, + string memberId, + CancellationToken cancellationToken = default); + + /// + /// Retrieves the organizations associated with a user by their ID (top-level endpoint). + /// + /// GET /{realm}/organizations/members/{userId}/organizations + /// + /// The Keycloak realm to query. + /// The access token used for authentication. + /// The ID of the user whose organizations are being retrieved. + /// Optional cancellation token. + /// + /// A containing an enumerable of objects. + /// + /// Thrown if any required parameter is null, empty, or invalid. + Task>> GetUserOrganizationsAsync( + string realm, + string accessToken, + string userId, + CancellationToken cancellationToken = default); + + /// + /// Invites an existing user to an organization using the specified user ID. + /// + /// POST /{realm}/organizations/{organizationId}/members/invite-existing-user + /// + /// The Keycloak realm where the organization exists. + /// The access token used for authentication. + /// The ID of the organization. + /// The ID of the existing user to invite. + /// Optional cancellation token. + /// + /// A indicating the result of the operation. + /// + /// Thrown if any required parameter is null, empty, or invalid. + Task> InviteExistingUserAsync( + string realm, + string accessToken, + string organizationId, + string userId, + CancellationToken cancellationToken = default); + + /// + /// Invites an existing user or sends a registration link to a new user based on the provided e-mail address. + /// If the user with the given e-mail address exists, it sends an invitation link; + /// otherwise, it sends a registration link. + /// + /// POST /{realm}/organizations/{organizationId}/members/invite-user + /// + /// The Keycloak realm where the organization exists. + /// The access token used for authentication. + /// The ID of the organization. + /// The e-mail address of the user to invite. + /// Optional first name of the user. + /// Optional last name of the user. + /// Optional cancellation token. + /// + /// A indicating the result of the operation. + /// + /// Thrown if any required parameter is null, empty, or invalid. + Task> InviteUserAsync( + string realm, + string accessToken, + string organizationId, + string email, + string firstName = null, + string lastName = null, + CancellationToken cancellationToken = default); } diff --git a/NETCore.Keycloak.Client/HttpClients/Implementation/KcOrganizations.cs b/NETCore.Keycloak.Client/HttpClients/Implementation/KcOrganizations.cs index bffaf60..a7bb6f6 100644 --- a/NETCore.Keycloak.Client/HttpClients/Implementation/KcOrganizations.cs +++ b/NETCore.Keycloak.Client/HttpClients/Implementation/KcOrganizations.cs @@ -1,7 +1,10 @@ using Microsoft.Extensions.Logging; using NETCore.Keycloak.Client.HttpClients.Abstraction; using NETCore.Keycloak.Client.Models; +using NETCore.Keycloak.Client.Models.Common; using NETCore.Keycloak.Client.Models.Organizations; +using NETCore.Keycloak.Client.Models.Users; +using NETCore.Keycloak.Client.Utils; namespace NETCore.Keycloak.Client.HttpClients.Implementation; @@ -139,4 +142,296 @@ public Task> CountAsync( "application/json", cancellationToken); } + + /// + public Task> AddMemberAsync( + string realm, + string accessToken, + string organizationId, + string userId, + CancellationToken cancellationToken = default) + { + ValidateAccess(realm, accessToken); + ValidateRequiredString(nameof(organizationId), organizationId); + ValidateRequiredString(nameof(userId), userId); + + var url = $"{BaseUrl}/{realm}/organizations/{organizationId}/members"; + return ProcessRequestAsync( + url, + HttpMethod.Post, + accessToken, + "Unable to add member to organization", + userId, + "application/json", + cancellationToken); + } + + /// + public Task>> GetMembersAsync( + string realm, + string accessToken, + string organizationId, + KcOrganizationMemberFilter filter = null, + CancellationToken cancellationToken = default) + { + ValidateAccess(realm, accessToken); + ValidateRequiredString(nameof(organizationId), organizationId); + filter ??= new KcOrganizationMemberFilter(); + + var url = $"{BaseUrl}/{realm}/organizations/{organizationId}/members{filter.BuildQuery()}"; + return ProcessRequestAsync>( + url, + HttpMethod.Get, + accessToken, + "Unable to get organization members", + null, + "application/json", + cancellationToken); + } + + /// + public Task> GetMembersCountAsync( + string realm, + string accessToken, + string organizationId, + CancellationToken cancellationToken = default) + { + ValidateAccess(realm, accessToken); + ValidateRequiredString(nameof(organizationId), organizationId); + + var url = $"{BaseUrl}/{realm}/organizations/{organizationId}/members/count"; + return ProcessRequestAsync( + url, + HttpMethod.Get, + accessToken, + "Unable to count organization members", + null, + "application/json", + cancellationToken); + } + + /// + public Task> GetMemberAsync( + string realm, + string accessToken, + string organizationId, + string memberId, + CancellationToken cancellationToken = default) + { + ValidateAccess(realm, accessToken); + ValidateRequiredString(nameof(organizationId), organizationId); + ValidateRequiredString(nameof(memberId), memberId); + + var url = $"{BaseUrl}/{realm}/organizations/{organizationId}/members/{memberId}"; + return ProcessRequestAsync( + url, + HttpMethod.Get, + accessToken, + "Unable to get organization member", + null, + "application/json", + cancellationToken); + } + + /// + public Task> RemoveMemberAsync( + string realm, + string accessToken, + string organizationId, + string memberId, + CancellationToken cancellationToken = default) + { + ValidateAccess(realm, accessToken); + ValidateRequiredString(nameof(organizationId), organizationId); + ValidateRequiredString(nameof(memberId), memberId); + + var url = $"{BaseUrl}/{realm}/organizations/{organizationId}/members/{memberId}"; + return ProcessRequestAsync( + url, + HttpMethod.Delete, + accessToken, + "Unable to remove member from organization", + null, + "application/json", + cancellationToken); + } + + /// + public Task>> GetMemberOrganizationsAsync( + string realm, + string accessToken, + string organizationId, + string memberId, + CancellationToken cancellationToken = default) + { + ValidateAccess(realm, accessToken); + ValidateRequiredString(nameof(organizationId), organizationId); + ValidateRequiredString(nameof(memberId), memberId); + + var url = $"{BaseUrl}/{realm}/organizations/{organizationId}/members/{memberId}/organizations"; + return ProcessRequestAsync>( + url, + HttpMethod.Get, + accessToken, + "Unable to get member organizations", + null, + "application/json", + cancellationToken); + } + + /// + public Task>> GetUserOrganizationsAsync( + string realm, + string accessToken, + string userId, + CancellationToken cancellationToken = default) + { + ValidateAccess(realm, accessToken); + ValidateRequiredString(nameof(userId), userId); + + var url = $"{BaseUrl}/{realm}/organizations/members/{userId}/organizations"; + return ProcessRequestAsync>( + url, + HttpMethod.Get, + accessToken, + "Unable to get user organizations", + null, + "application/json", + cancellationToken); + } + + /// + public async Task> InviteExistingUserAsync( + string realm, + string accessToken, + string organizationId, + string userId, + CancellationToken cancellationToken = default) + { + ValidateAccess(realm, accessToken); + ValidateRequiredString(nameof(organizationId), organizationId); + ValidateRequiredString(nameof(userId), userId); + + var url = $"{BaseUrl}/{realm}/organizations/{organizationId}/members/invite-existing-user"; + + try + { + using var response = await ExecuteRequest(async () => + { + var client = CreateHttpClient(); + + using var form = new FormUrlEncodedContent( + new Dictionary + { + { "id", userId } + }); + + _ = client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", $"Bearer {accessToken}"); + + return await client.PostAsync(new Uri(url), form, cancellationToken) + .ConfigureAwait(false); + }, new KcHttpMonitoringFallbackModel + { + Url = url, + HttpMethod = HttpMethod.Post + }).ConfigureAwait(false); + + return await HandleAsync(response, cancellationToken).ConfigureAwait(false); + } + catch ( Exception e ) + { + if ( Logger != null ) + { + KcLoggerMessages.Error(Logger, "Unable to invite existing user to organization", e); + } + + return new KcResponse + { + IsError = true, + Exception = e, + ErrorMessage = e.Message, + MonitoringMetrics = new KcHttpApiMonitoringMetrics + { + HttpMethod = HttpMethod.Post, + Error = e.Message, + Url = new Uri(url), + RequestException = e + } + }; + } + } + + /// + public async Task> InviteUserAsync( + string realm, + string accessToken, + string organizationId, + string email, + string firstName = null, + string lastName = null, + CancellationToken cancellationToken = default) + { + ValidateAccess(realm, accessToken); + ValidateRequiredString(nameof(organizationId), organizationId); + ValidateRequiredString(nameof(email), email); + + var url = $"{BaseUrl}/{realm}/organizations/{organizationId}/members/invite-user"; + + try + { + using var response = await ExecuteRequest(async () => + { + var client = CreateHttpClient(); + + var formData = new Dictionary + { + { "email", email } + }; + + if ( !string.IsNullOrWhiteSpace(firstName) ) + { + formData.Add("firstName", firstName); + } + + if ( !string.IsNullOrWhiteSpace(lastName) ) + { + formData.Add("lastName", lastName); + } + + using var form = new FormUrlEncodedContent(formData); + + _ = client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", $"Bearer {accessToken}"); + + return await client.PostAsync(new Uri(url), form, cancellationToken) + .ConfigureAwait(false); + }, new KcHttpMonitoringFallbackModel + { + Url = url, + HttpMethod = HttpMethod.Post + }).ConfigureAwait(false); + + return await HandleAsync(response, cancellationToken).ConfigureAwait(false); + } + catch ( Exception e ) + { + if ( Logger != null ) + { + KcLoggerMessages.Error(Logger, "Unable to invite user to organization", e); + } + + return new KcResponse + { + IsError = true, + Exception = e, + ErrorMessage = e.Message, + MonitoringMetrics = new KcHttpApiMonitoringMetrics + { + HttpMethod = HttpMethod.Post, + Error = e.Message, + Url = new Uri(url), + RequestException = e + } + }; + } + } } diff --git a/NETCore.Keycloak.Client/Models/Organizations/KcOrganization.cs b/NETCore.Keycloak.Client/Models/Organizations/KcOrganization.cs index 339dd82..7a0566a 100644 --- a/NETCore.Keycloak.Client/Models/Organizations/KcOrganization.cs +++ b/NETCore.Keycloak.Client/Models/Organizations/KcOrganization.cs @@ -1,4 +1,4 @@ -using System.Text.Json.Serialization; +using Newtonsoft.Json; namespace NETCore.Keycloak.Client.Models.Organizations; @@ -14,7 +14,7 @@ public sealed class KcOrganization /// /// A string representing the unique identifier of the organization. /// - [JsonPropertyName("id")] + [JsonProperty("id")] public string Id { get; set; } /// @@ -23,7 +23,7 @@ public sealed class KcOrganization /// /// A string representing the display name of the organization. /// - [JsonPropertyName("name")] + [JsonProperty("name")] public string Name { get; set; } /// @@ -32,7 +32,7 @@ public sealed class KcOrganization /// /// A string representing an alternate identifier or alias for the organization. /// - [JsonPropertyName("alias")] + [JsonProperty("alias")] public string Alias { get; set; } /// @@ -42,7 +42,7 @@ public sealed class KcOrganization /// A nullable boolean that is true when the organization is enabled, false when disabled, /// or null if the enabled state is not set. /// - [JsonPropertyName("enabled")] + [JsonProperty("enabled")] public bool? Enabled { get; set; } /// @@ -51,7 +51,7 @@ public sealed class KcOrganization /// /// A string containing a human-readable description for the organization. /// - [JsonPropertyName("description")] + [JsonProperty("description")] public string Description { get; set; } /// @@ -60,7 +60,7 @@ public sealed class KcOrganization /// /// A string representing the redirect URL associated with the organization (for example, after login or registration flows). /// - [JsonPropertyName("redirectUrl")] + [JsonProperty("redirectUrl")] public string RedirectUrl { get; set; } /// @@ -69,7 +69,7 @@ public sealed class KcOrganization /// /// A dictionary where the key is the attribute name and the value is a list of values for that attribute (Keycloak style). /// - [JsonPropertyName("attributes")] + [JsonProperty("attributes")] public Dictionary> Attributes { get; set; } /// @@ -78,6 +78,6 @@ public sealed class KcOrganization /// /// A collection of representing domains associated with the organization. /// - [JsonPropertyName("domains")] + [JsonProperty("domains")] public ICollection Domains { get; set; } = []; } diff --git a/NETCore.Keycloak.Client/Models/Organizations/KcOrganizationDomain.cs b/NETCore.Keycloak.Client/Models/Organizations/KcOrganizationDomain.cs index f4424e6..2a59b69 100644 --- a/NETCore.Keycloak.Client/Models/Organizations/KcOrganizationDomain.cs +++ b/NETCore.Keycloak.Client/Models/Organizations/KcOrganizationDomain.cs @@ -1,4 +1,4 @@ -using System.Text.Json.Serialization; +using Newtonsoft.Json; namespace NETCore.Keycloak.Client.Models.Organizations; @@ -14,7 +14,7 @@ public sealed class KcOrganizationDomain /// /// A string representing the domain name (for example, "example.com"). /// - [JsonPropertyName("name")] + [JsonProperty("name")] public string Name { get; set; } /// @@ -24,6 +24,6 @@ public sealed class KcOrganizationDomain /// A nullable boolean indicating whether the domain has been verified by Keycloak. /// True if verified; false if not; null if the verification state is unknown. /// - [JsonPropertyName("verified")] + [JsonProperty("verified")] public bool? Verified { get; set; } } diff --git a/NETCore.Keycloak.Client/Models/Organizations/KcOrganizationMemberFilter.cs b/NETCore.Keycloak.Client/Models/Organizations/KcOrganizationMemberFilter.cs new file mode 100644 index 0000000..0156274 --- /dev/null +++ b/NETCore.Keycloak.Client/Models/Organizations/KcOrganizationMemberFilter.cs @@ -0,0 +1,69 @@ +using System.Globalization; +using System.Text; +using NETCore.Keycloak.Client.Models.Common; +using Newtonsoft.Json; + +namespace NETCore.Keycloak.Client.Models.Organizations; + +/// +/// Represents a filter for querying Keycloak organization members. +/// +public sealed class KcOrganizationMemberFilter : KcFilter +{ + /// + /// Gets or sets a value indicating whether the search parameter must match exactly. + /// + /// + /// true if the parameters must match exactly; otherwise, false. + /// + [JsonProperty("exact")] + public bool? Exact { get; set; } + + /// + /// Gets or sets the membership type to filter by. + /// + /// + /// A string representing the membership type filter. + /// + [JsonProperty("membershipType")] + public string MembershipType { get; set; } + + /// + /// Builds the query string based on the filter properties. + /// + /// + /// A string containing the query parameters to be appended to a URL. + /// + public new string BuildQuery() + { + var builder = new StringBuilder($"?max={Max}"); + + // Include pagination offset if specified + if ( First != null ) + { + _ = builder.Append(CultureInfo.CurrentCulture, + $"&first={string.Create(CultureInfo.CurrentCulture, $"{First}").ToLower(CultureInfo.CurrentCulture)}"); + } + + // Include general search query if specified + if ( !string.IsNullOrWhiteSpace(Search) ) + { + _ = builder.Append(CultureInfo.CurrentCulture, $"&search={Search}"); + } + + // Include exact match filter if specified + if ( Exact != null ) + { + _ = builder.Append(CultureInfo.CurrentCulture, + $"&exact={Exact.ToString().ToLower(CultureInfo.CurrentCulture)}"); + } + + // Include membership type filter if specified + if ( !string.IsNullOrWhiteSpace(MembershipType) ) + { + _ = builder.Append(CultureInfo.CurrentCulture, $"&membershipType={MembershipType}"); + } + + return builder.ToString(); + } +}