From 7bebaf0a0092ab925c3f772e1f5d1ed89d0668d1 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Wed, 15 Apr 2026 16:44:28 +0100 Subject: [PATCH 01/39] Add ConflictError type --- src/Core/AdminConsole/Utilities/v2/Errors.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/AdminConsole/Utilities/v2/Errors.cs b/src/Core/AdminConsole/Utilities/v2/Errors.cs index c1c66b2630e5..541e8217d4d0 100644 --- a/src/Core/AdminConsole/Utilities/v2/Errors.cs +++ b/src/Core/AdminConsole/Utilities/v2/Errors.cs @@ -12,4 +12,5 @@ public abstract record Error(string Message); public abstract record NotFoundError(string Message) : Error(Message); public abstract record BadRequestError(string Message) : Error(Message); +public abstract record ConflictError(string Message) : Error(Message); public abstract record InternalError(string Message) : Error(Message); From 9fe8d6e4421f9d65e547c63ec323bb81d9c4f252 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Wed, 15 Apr 2026 16:45:12 +0100 Subject: [PATCH 02/39] Add generic Handle and extract MapError on BaseAdminConsoleController --- .../Controllers/BaseAdminConsoleController.cs | 55 +++++++++++-------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/src/Api/AdminConsole/Controllers/BaseAdminConsoleController.cs b/src/Api/AdminConsole/Controllers/BaseAdminConsoleController.cs index 6fd741bf3a5a..3cd230aff49d 100644 --- a/src/Api/AdminConsole/Controllers/BaseAdminConsoleController.cs +++ b/src/Api/AdminConsole/Controllers/BaseAdminConsoleController.cs @@ -8,33 +8,42 @@ namespace Bit.Api.AdminConsole.Controllers; public abstract class BaseAdminConsoleController : Controller { + /// + /// Maps a void to an HTTP response. + /// Returns 204 No Content on success, or the appropriate error status code on failure. + /// protected static IResult Handle(CommandResult commandResult) => commandResult.Match( - error => error switch - { - BadRequestError badRequest => Error.BadRequest(badRequest.Message), - NotFoundError notFound => Error.NotFound(notFound.Message), - InternalError internalError => Error.InternalError(internalError.Message), - _ => Error.InternalError(error.Message) - }, + error => MapError(error), _ => TypedResults.NoContent() ); - protected static class Error - { - public static NotFound NotFound(string message = "Resource not found.") => - TypedResults.NotFound(new ErrorResponseModel(message)); - - public static UnauthorizedHttpResult Unauthorized() => - TypedResults.Unauthorized(); - - public static BadRequest BadRequest(string message) => - TypedResults.BadRequest(new ErrorResponseModel(message)); + /// + /// Maps a to an HTTP response. + /// On success, delegates to so the caller can choose the response shape + /// (e.g. TypedResults.Created for POST, TypedResults.Ok for GET/PUT). + /// On failure, returns the appropriate error status code. + /// + protected static IResult Handle(CommandResult commandResult, Func success) => + commandResult.Match( + error => MapError(error), + success + ); - public static JsonHttpResult InternalError( - string message = "Something went wrong with your request. Please contact support for assistance.") => - TypedResults.Json( - new ErrorResponseModel(message), - statusCode: StatusCodes.Status500InternalServerError); - } + private static IResult MapError(Error error) => + error switch + { + BadRequestError badRequest => TypedResults.BadRequest(new ErrorResponseModel(badRequest.Message)), + NotFoundError notFound => TypedResults.NotFound(new ErrorResponseModel(notFound.Message)), + ConflictError conflict => TypedResults.Json( + new ErrorResponseModel(conflict.Message), + statusCode: StatusCodes.Status409Conflict), + InternalError internalError => TypedResults.Json( + new ErrorResponseModel(internalError.Message), + statusCode: StatusCodes.Status500InternalServerError), + _ => TypedResults.Json( + new ErrorResponseModel(error.Message), + statusCode: StatusCodes.Status500InternalServerError + ) + }; } From 8ca69083bf6b9f835653a5db402fd35fddcd22fa Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Wed, 15 Apr 2026 16:45:58 +0100 Subject: [PATCH 03/39] Initialize Code property with a new GUID in OrganizationInviteLink class --- src/Core/AdminConsole/Entities/OrganizationInviteLink.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/AdminConsole/Entities/OrganizationInviteLink.cs b/src/Core/AdminConsole/Entities/OrganizationInviteLink.cs index 4000a1d44de2..d2e266bcd3d5 100644 --- a/src/Core/AdminConsole/Entities/OrganizationInviteLink.cs +++ b/src/Core/AdminConsole/Entities/OrganizationInviteLink.cs @@ -6,7 +6,7 @@ namespace Bit.Core.AdminConsole.Entities; public class OrganizationInviteLink : ITableObject { public Guid Id { get; set; } - public Guid Code { get; set; } + public Guid Code { get; set; } = Guid.NewGuid(); public Guid OrganizationId { get; set; } public string AllowedDomains { get; set; } = null!; public string EncryptedInviteKey { get; set; } = null!; From 5fe0162e8bfee443fb1a1f5b5e687465509263d2 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Wed, 15 Apr 2026 16:46:33 +0100 Subject: [PATCH 04/39] Add ICreateOrganizationInviteLinkCommand interface --- .../ICreateOrganizationInviteLinkCommand.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/ICreateOrganizationInviteLinkCommand.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/ICreateOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/ICreateOrganizationInviteLinkCommand.cs new file mode 100644 index 000000000000..5f308a399e13 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/ICreateOrganizationInviteLinkCommand.cs @@ -0,0 +1,19 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Utilities.v2.Results; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; + +public interface ICreateOrganizationInviteLinkCommand +{ + /// + /// Creates a new invite link for the specified organization. + /// + /// The organization to create the invite link for. + /// Email domains that are permitted to accept the invite. At least one is required. + /// The invite key wrapped with the organization key. Never contains the raw key. + /// The created , or an error if validation fails or a link already exists. + Task> CreateAsync( + Guid organizationId, + IEnumerable allowedDomains, + string encryptedInviteKey); +} From af214d44464b6f60c2cf912409bc21fb041a2476 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Wed, 15 Apr 2026 17:00:22 +0100 Subject: [PATCH 05/39] Add CreateOrganizationInviteLinkRequest record for invite link creation --- .../CreateOrganizationInviteLinkRequest.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkRequest.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkRequest.cs new file mode 100644 index 000000000000..66fdcca412a3 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkRequest.cs @@ -0,0 +1,13 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; + +public record CreateOrganizationInviteLinkRequest +{ + public required Guid OrganizationId { get; init; } + public required IEnumerable AllowedDomains { get; init; } + public required string EncryptedInviteKey { get; init; } + + /// + /// The organization key encrypted for the invite link. Currently unused; will be populated in a future stage. + /// + public string? EncryptedOrgKey { get; init; } +} From b5ee1835421064822ec646103ee5aecb50e47e51 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Wed, 15 Apr 2026 17:01:14 +0100 Subject: [PATCH 06/39] Add OrganizationInviteLink request and response models for invite link management --- ...reateOrganizationInviteLinkRequestModel.cs | 32 ++++++++++++++++ .../OrganizationInviteLinkResponseModel.cs | 38 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs create mode 100644 src/Api/AdminConsole/Models/Response/Organizations/OrganizationInviteLinkResponseModel.cs diff --git a/src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs new file mode 100644 index 000000000000..b6e04e9a19ec --- /dev/null +++ b/src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; + +namespace Bit.Api.AdminConsole.Models.Request.Organizations; + +public class CreateOrganizationInviteLinkRequestModel +{ + /// + /// Email domains permitted to accept the invite link (e.g. ["acme.com"]). + /// + [Required] + public IEnumerable AllowedDomains { get; set; } = null!; + + /// + /// The invite key encrypted with the organization key. + /// + [Required] + public string EncryptedInviteKey { get; set; } = null!; + + /// + /// The organization key encrypted for the invite link. Currently unused; will be populated in a future stage. + /// + public string? EncryptedOrgKey { get; set; } + + public CreateOrganizationInviteLinkRequest ToCommandRequest(Guid organizationId) => new() + { + OrganizationId = organizationId, + AllowedDomains = AllowedDomains, + EncryptedInviteKey = EncryptedInviteKey, + EncryptedOrgKey = EncryptedOrgKey, + }; +} diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationInviteLinkResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationInviteLinkResponseModel.cs new file mode 100644 index 000000000000..2b37d71a726d --- /dev/null +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationInviteLinkResponseModel.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Models.Api; + +namespace Bit.Api.AdminConsole.Models.Response.Organizations; + +public class OrganizationInviteLinkResponseModel : ResponseModel +{ + public OrganizationInviteLinkResponseModel() : base("organizationInviteLink") + { + } + + public OrganizationInviteLinkResponseModel(OrganizationInviteLink inviteLink) + : base("organizationInviteLink") + { + ArgumentNullException.ThrowIfNull(inviteLink); + + Id = inviteLink.Id; + Code = inviteLink.Code; + OrganizationId = inviteLink.OrganizationId; + if (!string.IsNullOrWhiteSpace(inviteLink.AllowedDomains)) + { + AllowedDomains = JsonSerializer.Deserialize>(inviteLink.AllowedDomains) + ?? throw new JsonException("Failed to deserialize AllowedDomains."); + } + EncryptedInviteKey = inviteLink.EncryptedInviteKey; + EncryptedOrgKey = inviteLink.EncryptedOrgKey; + CreationDate = inviteLink.CreationDate; + } + + public Guid Id { get; set; } + public Guid Code { get; set; } + public Guid OrganizationId { get; set; } + public IEnumerable AllowedDomains { get; set; } = []; + public string EncryptedInviteKey { get; set; } = null!; + public string? EncryptedOrgKey { get; set; } + public DateTime CreationDate { get; set; } +} From 39674bff3b67192cf34c19e47517aa865e29b589 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Wed, 15 Apr 2026 17:01:50 +0100 Subject: [PATCH 07/39] Refactor ICreateOrganizationInviteLinkCommand interface to use CreateOrganizationInviteLinkRequest for invite link creation --- .../Interfaces/ICreateOrganizationInviteLinkCommand.cs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/ICreateOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/ICreateOrganizationInviteLinkCommand.cs index 5f308a399e13..126cb5843273 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/ICreateOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/ICreateOrganizationInviteLinkCommand.cs @@ -8,12 +8,7 @@ public interface ICreateOrganizationInviteLinkCommand /// /// Creates a new invite link for the specified organization. /// - /// The organization to create the invite link for. - /// Email domains that are permitted to accept the invite. At least one is required. - /// The invite key wrapped with the organization key. Never contains the raw key. + /// The details for the invite link to create. /// The created , or an error if validation fails or a link already exists. - Task> CreateAsync( - Guid organizationId, - IEnumerable allowedDomains, - string encryptedInviteKey); + Task> CreateAsync(CreateOrganizationInviteLinkRequest request); } From 5fc96fde511aa2b64eeab4cb214ad9c167ff88f4 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Wed, 15 Apr 2026 17:02:42 +0100 Subject: [PATCH 08/39] Add CreateOrganizationInviteLinkCommand class to handle invite link creation logic, including domain sanitization and validation checks. --- .../CreateOrganizationInviteLinkCommand.cs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs new file mode 100644 index 000000000000..d886ff057d17 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Utilities.v2.Results; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; + +public class CreateOrganizationInviteLinkCommand( + IOrganizationInviteLinkRepository organizationInviteLinkRepository, + TimeProvider timeProvider) + : ICreateOrganizationInviteLinkCommand +{ + public async Task> CreateAsync( + CreateOrganizationInviteLinkRequest request) + { + var sanitizedDomains = SanitizeDomains(request.AllowedDomains); + + if (sanitizedDomains.Count == 0) + { + return new InviteLinkDomainsRequired(); + } + + if (string.IsNullOrWhiteSpace(request.EncryptedInviteKey)) + { + return new InviteLinkEncryptedKeyRequired(); + } + + var existingLink = await organizationInviteLinkRepository.GetByOrganizationIdAsync(request.OrganizationId); + if (existingLink != null) + { + return new InviteLinkAlreadyExists(); + } + + var now = timeProvider.GetUtcNow().UtcDateTime; + var inviteLink = new OrganizationInviteLink + { + OrganizationId = request.OrganizationId, + AllowedDomains = JsonSerializer.Serialize(sanitizedDomains), + EncryptedInviteKey = request.EncryptedInviteKey, + EncryptedOrgKey = request.EncryptedOrgKey, + CreationDate = now, + RevisionDate = now, + }; + inviteLink.SetNewId(); + + await organizationInviteLinkRepository.CreateAsync(inviteLink); + + return inviteLink; + } + + private static List SanitizeDomains(IEnumerable? domains) => + domains? + .Select(d => d?.Trim()) + .Where(d => !string.IsNullOrEmpty(d)) + .Cast() + .ToList() ?? []; +} From a5683b10a6ebb0bdd37428f8aecfb694fc4f9baa Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Wed, 15 Apr 2026 17:03:19 +0100 Subject: [PATCH 09/39] Add error handling for invite link creation with specific conflict and validation errors --- .../OrganizationFeatures/InviteLinks/Errors.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Errors.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Errors.cs new file mode 100644 index 000000000000..eac2dbb7cb10 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Errors.cs @@ -0,0 +1,12 @@ +using Bit.Core.AdminConsole.Utilities.v2; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; + +public record InviteLinkAlreadyExists() + : ConflictError("An invite link already exists for this organization."); + +public record InviteLinkDomainsRequired() + : BadRequestError("At least one allowed domain is required."); + +public record InviteLinkEncryptedKeyRequired() + : BadRequestError("An encrypted invite key is required."); From 49995c2bddab6ff03ba46e8f63c6fe52bed8fdef Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Wed, 15 Apr 2026 17:04:08 +0100 Subject: [PATCH 10/39] Add OrganizationInviteLink service commands to OrganizationServiceCollectionExtensions --- .../OrganizationServiceCollectionExtensions.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 71edf1c79795..1605f45dda19 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -6,6 +6,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Groups; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Import; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections; @@ -64,6 +66,7 @@ public static void AddOrganizationServices(this IServiceCollection services, IGl services.AddOrganizationApiKeyCommandsQueries(); services.AddOrganizationCollectionCommands(); services.AddOrganizationGroupCommands(); + services.AddOrganizationInviteLinkCommands(); services.AddOrganizationDomainCommandsQueries(); services.AddOrganizationSignUpCommands(); services.AddOrganizationDeleteCommands(); @@ -187,6 +190,11 @@ private static void AddOrganizationGroupCommands(this IServiceCollection service services.AddScoped(); } + private static void AddOrganizationInviteLinkCommands(this IServiceCollection services) + { + services.AddScoped(); + } + private static void AddOrganizationDomainCommandsQueries(this IServiceCollection services) { services.AddScoped(); From b20c1f3b12354275588a9bd8dc40b5688474ebd0 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Wed, 15 Apr 2026 17:05:00 +0100 Subject: [PATCH 11/39] Add OrganizationInviteLinksController to manage invite link creation for organizations --- .../OrganizationInviteLinksController.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/Api/AdminConsole/Controllers/OrganizationInviteLinksController.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationInviteLinksController.cs b/src/Api/AdminConsole/Controllers/OrganizationInviteLinksController.cs new file mode 100644 index 000000000000..0ad76fbc39e4 --- /dev/null +++ b/src/Api/AdminConsole/Controllers/OrganizationInviteLinksController.cs @@ -0,0 +1,32 @@ +using Bit.Api.AdminConsole.Authorization; +using Bit.Api.AdminConsole.Authorization.Requirements; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Core; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.AdminConsole.Controllers; + +[Route("organizations/{orgId}/invite-link")] +[Authorize("Application")] +[RequireFeature(FeatureFlagKeys.GenerateInviteLink)] +public class OrganizationInviteLinksController( + ICreateOrganizationInviteLinkCommand createOrganizationInviteLinkCommand) + : BaseAdminConsoleController +{ + [HttpPost("")] + [Authorize] + public async Task Create(Guid orgId, [FromBody] CreateOrganizationInviteLinkRequestModel model) + { + var result = await createOrganizationInviteLinkCommand.CreateAsync( + model.ToCommandRequest(orgId)); + + return Handle(result, link => + TypedResults.Created( + $"organizations/{orgId}/invite-link", + new OrganizationInviteLinkResponseModel(link))); + } +} From 8876cf63a601555a4d2ec66b316d4c30f56e5c04 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Wed, 15 Apr 2026 17:06:00 +0100 Subject: [PATCH 12/39] Add integration tests for OrganizationInviteLinksController and CreateOrganizationInviteLinkCommand to validate invite link creation logic, including success and error scenarios. --- .../OrganizationInviteLinksControllerTests.cs | 88 ++++++++ .../OrganizationInviteLinksControllerTests.cs | 97 +++++++++ ...reateOrganizationInviteLinkCommandTests.cs | 201 ++++++++++++++++++ 3 files changed, 386 insertions(+) create mode 100644 test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs create mode 100644 test/Api.Test/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommandTests.cs diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs new file mode 100644 index 000000000000..48f55ed2a397 --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs @@ -0,0 +1,88 @@ +using System.Net; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Core.Services; +using NSubstitute; +using Xunit; + +namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; + +public class OrganizationInviteLinksControllerTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + + private Organization _organization = null!; + private string _ownerEmail = null!; + + public OrganizationInviteLinksControllerTests(ApiApplicationFactory factory) + { + _factory = factory; + _factory.SubstituteService(featureService => + { + featureService + .IsEnabled(FeatureFlagKeys.GenerateInviteLink) + .Returns(true); + }); + _client = factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + } + + public async Task InitializeAsync() + { + _ownerEmail = $"integration-test{Guid.NewGuid()}@example.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + + (_organization, _) = await OrganizationTestHelpers.SignUpAsync( + _factory, + plan: PlanType.EnterpriseAnnually, + ownerEmail: _ownerEmail, + passwordManagerSeats: 10, + paymentMethod: PaymentMethodType.Card); + + await _loginHelper.LoginAsync(_ownerEmail); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task Create_AsOwner_ReturnsCreated() + { + var request = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = ["acme.com", "example.com"], + EncryptedInviteKey = "encrypted-key", + }; + + var response = await _client.PostAsJsonAsync( + $"/organizations/{_organization.Id}/invite-link", request); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var content = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(content); + Assert.NotEqual(Guid.Empty, content.Id); + Assert.NotEqual(Guid.Empty, content.Code); + Assert.Equal(_organization.Id, content.OrganizationId); + Assert.Equal(["acme.com", "example.com"], content.AllowedDomains); + Assert.Equal("encrypted-key", content.EncryptedInviteKey); + + var repository = _factory.GetService(); + var persisted = await repository.GetByOrganizationIdAsync(_organization.Id); + Assert.NotNull(persisted); + Assert.Equal(content.Id, persisted.Id); + } + +} diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs new file mode 100644 index 000000000000..0c00b839d21c --- /dev/null +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs @@ -0,0 +1,97 @@ +using Bit.Api.AdminConsole.Controllers; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; +using Bit.Core.AdminConsole.Utilities.v2.Results; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Controllers; + +[ControllerCustomize(typeof(OrganizationInviteLinksController))] +[SutProviderCustomize] +public class OrganizationInviteLinksControllerTests +{ + [Theory, BitAutoData] + public async Task Create_WithValidInput_Success( + Guid orgId, + OrganizationInviteLink inviteLink, + SutProvider sutProvider) + { + inviteLink.OrganizationId = orgId; + inviteLink.AllowedDomains = "[\"acme.com\"]"; + + var model = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = ["acme.com"], + EncryptedInviteKey = "encrypted-key", + }; + + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(new CommandResult(inviteLink)); + + var result = await sutProvider.Sut.Create(orgId, model); + + var createdResult = Assert.IsType>(result); + Assert.Equal($"organizations/{orgId}/invite-link", createdResult.Location); + Assert.NotNull(createdResult.Value); + Assert.Equal(inviteLink.Id, createdResult.Value.Id); + Assert.Equal(inviteLink.Code, createdResult.Value.Code); + Assert.Equal(orgId, createdResult.Value.OrganizationId); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(Arg.Is(r => + r.OrganizationId == orgId && + r.EncryptedInviteKey == "encrypted-key")); + } + + [Theory, BitAutoData] + public async Task Create_WithExistingLink_Returns409( + Guid orgId, + SutProvider sutProvider) + { + var model = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = ["acme.com"], + EncryptedInviteKey = "encrypted-key", + }; + + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(new CommandResult(new InviteLinkAlreadyExists())); + + var result = await sutProvider.Sut.Create(orgId, model); + + var jsonResult = Assert.IsType>(result); + Assert.Equal(StatusCodes.Status409Conflict, jsonResult.StatusCode); + } + + [Theory, BitAutoData] + public async Task Create_WithValidationError_Returns400( + Guid orgId, + SutProvider sutProvider) + { + var model = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = [], + EncryptedInviteKey = "encrypted-key", + }; + + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(new CommandResult(new InviteLinkDomainsRequired())); + + var result = await sutProvider.Sut.Create(orgId, model); + + var badRequestResult = Assert.IsType>(result); + Assert.NotNull(badRequestResult.Value); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommandTests.cs new file mode 100644 index 000000000000..512c543dbf50 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommandTests.cs @@ -0,0 +1,201 @@ +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; +using Bit.Core.AdminConsole.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.InviteLinks; + +[SutProviderCustomize] +public class CreateOrganizationInviteLinkCommandTests +{ + [Theory, BitAutoData] + public async Task CreateAsync_WithValidInput_Success( + Guid organizationId, + SutProvider sutProvider) + { + var request = new CreateOrganizationInviteLinkRequest + { + OrganizationId = organizationId, + AllowedDomains = ["acme.com", "example.com"], + EncryptedInviteKey = "encrypted-key-value", + }; + + var result = await sutProvider.Sut.CreateAsync(request); + + Assert.True(result.IsSuccess); + var link = result.AsSuccess; + Assert.Equal(organizationId, link.OrganizationId); + Assert.NotEqual(Guid.Empty, link.Id); + Assert.NotEqual(Guid.Empty, link.Code); + Assert.Equal(request.EncryptedInviteKey, link.EncryptedInviteKey); + + var deserializedDomains = JsonSerializer.Deserialize>(link.AllowedDomains); + Assert.NotNull(deserializedDomains); + Assert.Equal(2, deserializedDomains.Count); + Assert.Contains("acme.com", deserializedDomains); + Assert.Contains("example.com", deserializedDomains); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(link); + } + + [Theory, BitAutoData] + public async Task CreateAsync_WithExistingLinkForOrg_ReturnsConflictError( + Guid organizationId, + OrganizationInviteLink existingLink, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByOrganizationIdAsync(organizationId) + .Returns(existingLink); + + var request = new CreateOrganizationInviteLinkRequest + { + OrganizationId = organizationId, + AllowedDomains = ["acme.com"], + EncryptedInviteKey = "encrypted-key", + }; + + var result = await sutProvider.Sut.CreateAsync(request); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_WithEmptyDomainsList_ReturnsBadRequestError( + Guid organizationId, + SutProvider sutProvider) + { + var request = new CreateOrganizationInviteLinkRequest + { + OrganizationId = organizationId, + AllowedDomains = [], + EncryptedInviteKey = "encrypted-key", + }; + + var result = await sutProvider.Sut.CreateAsync(request); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_WithWhitespaceOnlyDomains_ReturnsBadRequestError( + Guid organizationId, + SutProvider sutProvider) + { + var request = new CreateOrganizationInviteLinkRequest + { + OrganizationId = organizationId, + AllowedDomains = [" ", ""], + EncryptedInviteKey = "encrypted-key", + }; + + var result = await sutProvider.Sut.CreateAsync(request); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory, BitAutoData] + public async Task CreateAsync_WithNullDomains_ReturnsBadRequestError( + Guid organizationId, + SutProvider sutProvider) + { + var request = new CreateOrganizationInviteLinkRequest + { + OrganizationId = organizationId, + AllowedDomains = null!, + EncryptedInviteKey = "encrypted-key", + }; + + var result = await sutProvider.Sut.CreateAsync(request); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory] + [BitAutoData("")] + [BitAutoData(" ")] + public async Task CreateAsync_WithEmptyOrWhitespaceEncryptedKey_ReturnsBadRequestError( + string encryptedKey, + Guid organizationId, + SutProvider sutProvider) + { + var request = new CreateOrganizationInviteLinkRequest + { + OrganizationId = organizationId, + AllowedDomains = ["acme.com"], + EncryptedInviteKey = encryptedKey, + }; + + var result = await sutProvider.Sut.CreateAsync(request); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_WithNullEncryptedKey_ReturnsBadRequestError( + Guid organizationId, + SutProvider sutProvider) + { + var request = new CreateOrganizationInviteLinkRequest + { + OrganizationId = organizationId, + AllowedDomains = ["acme.com"], + EncryptedInviteKey = null!, + }; + + var result = await sutProvider.Sut.CreateAsync(request); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_WithMixedValidAndBlankDomains_Success( + Guid organizationId, + SutProvider sutProvider) + { + var request = new CreateOrganizationInviteLinkRequest + { + OrganizationId = organizationId, + AllowedDomains = [" acme.com ", "", " ", "example.com "], + EncryptedInviteKey = "encrypted-key", + }; + + var result = await sutProvider.Sut.CreateAsync(request); + + Assert.True(result.IsSuccess); + var link = result.AsSuccess; + var deserializedDomains = JsonSerializer.Deserialize>(link.AllowedDomains); + Assert.NotNull(deserializedDomains); + Assert.Equal(2, deserializedDomains.Count); + Assert.Contains("acme.com", deserializedDomains); + Assert.Contains("example.com", deserializedDomains); + } +} From 687f83901aad6505f2f8392d3556a936a7c6b8bd Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Wed, 15 Apr 2026 17:06:53 +0100 Subject: [PATCH 13/39] Remove unnecessary blank line in OrganizationInviteLinksControllerTests class --- .../Controllers/OrganizationInviteLinksControllerTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs index 48f55ed2a397..1c2a8b5b7420 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs @@ -84,5 +84,4 @@ public async Task Create_AsOwner_ReturnsCreated() Assert.NotNull(persisted); Assert.Equal(content.Id, persisted.Id); } - } From c9aa1afcf5a3ac01eaa84c1aa1aad6f5ef352290 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Thu, 16 Apr 2026 11:39:56 +0100 Subject: [PATCH 14/39] Refactor CreateOrganizationInviteLinkRequestModel to use required properties for AllowedDomains and EncryptedInviteKey --- .../Organizations/CreateOrganizationInviteLinkRequestModel.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs index b6e04e9a19ec..909149673340 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs @@ -9,13 +9,13 @@ public class CreateOrganizationInviteLinkRequestModel /// Email domains permitted to accept the invite link (e.g. ["acme.com"]). /// [Required] - public IEnumerable AllowedDomains { get; set; } = null!; + public required IEnumerable AllowedDomains { get; set; } /// /// The invite key encrypted with the organization key. /// [Required] - public string EncryptedInviteKey { get; set; } = null!; + public required string EncryptedInviteKey { get; set; } /// /// The organization key encrypted for the invite link. Currently unused; will be populated in a future stage. From d0756cc01e1ab649060a5bb730ec9e5eefbbb680 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Thu, 16 Apr 2026 13:22:44 +0100 Subject: [PATCH 15/39] Update CreateOrganizationInviteLinkCommand to validate allowed domains by using DomainNameValidator --- .../CreateOrganizationInviteLinkCommand.cs | 16 +++++++-- .../InviteLinks/Errors.cs | 4 +-- ...reateOrganizationInviteLinkCommandTests.cs | 35 ++++++++++--------- 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs index d886ff057d17..ae23058022a4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs @@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities.v2.Results; +using Bit.Core.Utilities; namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; @@ -11,19 +12,21 @@ public class CreateOrganizationInviteLinkCommand( TimeProvider timeProvider) : ICreateOrganizationInviteLinkCommand { + private static readonly DomainNameValidatorAttribute _domainValidator = new(); + public async Task> CreateAsync( CreateOrganizationInviteLinkRequest request) { var sanitizedDomains = SanitizeDomains(request.AllowedDomains); - if (sanitizedDomains.Count == 0) { return new InviteLinkDomainsRequired(); } - if (string.IsNullOrWhiteSpace(request.EncryptedInviteKey)) + var invalidDomains = sanitizedDomains.Where(d => !IsValidDomain(d)).ToList(); + if (invalidDomains.Count > 0) { - return new InviteLinkEncryptedKeyRequired(); + return new InviteLinkInvalidDomains(invalidDomains); } var existingLink = await organizationInviteLinkRepository.GetByOrganizationIdAsync(request.OrganizationId); @@ -49,10 +52,17 @@ public async Task> CreateAsync( return inviteLink; } + /// + /// Sanitizes the domains by trimming whitespace and removing empty domains. + /// + /// The domains to sanitize. + /// A list of sanitized domains. private static List SanitizeDomains(IEnumerable? domains) => domains? .Select(d => d?.Trim()) .Where(d => !string.IsNullOrEmpty(d)) .Cast() .ToList() ?? []; + + private static bool IsValidDomain(string domain) => _domainValidator.IsValid(domain); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Errors.cs index eac2dbb7cb10..6b9699643aeb 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Errors.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Errors.cs @@ -8,5 +8,5 @@ public record InviteLinkAlreadyExists() public record InviteLinkDomainsRequired() : BadRequestError("At least one allowed domain is required."); -public record InviteLinkEncryptedKeyRequired() - : BadRequestError("An encrypted invite key is required."); +public record InviteLinkInvalidDomains(IEnumerable InvalidDomains) + : BadRequestError($"One or more domains are invalid: {string.Join(", ", InvalidDomains)}."); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommandTests.cs index 512c543dbf50..bba244b0485c 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommandTests.cs @@ -130,50 +130,51 @@ public async Task CreateAsync_WithNullDomains_ReturnsBadRequestError( } [Theory] - [BitAutoData("")] - [BitAutoData(" ")] - public async Task CreateAsync_WithEmptyOrWhitespaceEncryptedKey_ReturnsBadRequestError( - string encryptedKey, + [BitAutoData("not a domain")] + [BitAutoData("")] + [BitAutoData("double..dot.com")] + [BitAutoData("-starts-with-hyphen.com")] + public async Task CreateAsync_WithInvalidDomainFormat_ReturnsInvalidDomainsError( + string invalidDomain, Guid organizationId, SutProvider sutProvider) { var request = new CreateOrganizationInviteLinkRequest { OrganizationId = organizationId, - AllowedDomains = ["acme.com"], - EncryptedInviteKey = encryptedKey, + AllowedDomains = [invalidDomain], + EncryptedInviteKey = "encrypted-key", }; var result = await sutProvider.Sut.CreateAsync(request); Assert.True(result.IsError); - Assert.IsType(result.AsError); + Assert.IsType(result.AsError); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .CreateAsync(default!); } - [Theory, BitAutoData] - public async Task CreateAsync_WithNullEncryptedKey_ReturnsBadRequestError( + [Theory] + [BitAutoData("acme.com")] + [BitAutoData("sub.example.org")] + [BitAutoData("my-company.co.uk")] + public async Task CreateAsync_WithValidDomainFormat_Succeeds( + string validDomain, Guid organizationId, SutProvider sutProvider) { var request = new CreateOrganizationInviteLinkRequest { OrganizationId = organizationId, - AllowedDomains = ["acme.com"], - EncryptedInviteKey = null!, + AllowedDomains = [validDomain], + EncryptedInviteKey = "encrypted-key", }; var result = await sutProvider.Sut.CreateAsync(request); - Assert.True(result.IsError); - Assert.IsType(result.AsError); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .CreateAsync(default!); + Assert.True(result.IsSuccess); } [Theory, BitAutoData] From 474905d4e7982f57ecd704d1d6c799106c0ab403 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Thu, 16 Apr 2026 14:05:24 +0100 Subject: [PATCH 16/39] Add encryption validation attributes to CreateOrganizationInviteLinkRequestModel and implement unit tests for model validation --- ...reateOrganizationInviteLinkRequestModel.cs | 3 + ...OrganizationInviteLinkRequestModelTests.cs | 78 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 test/Api.Test/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModelTests.cs diff --git a/src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs index 909149673340..54289f2b3445 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; +using Bit.Core.Utilities; namespace Bit.Api.AdminConsole.Models.Request.Organizations; @@ -15,11 +16,13 @@ public class CreateOrganizationInviteLinkRequestModel /// The invite key encrypted with the organization key. /// [Required] + [EncryptedString] public required string EncryptedInviteKey { get; set; } /// /// The organization key encrypted for the invite link. Currently unused; will be populated in a future stage. /// + [EncryptedString] public string? EncryptedOrgKey { get; set; } public CreateOrganizationInviteLinkRequest ToCommandRequest(Guid organizationId) => new() diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModelTests.cs new file mode 100644 index 000000000000..c5a4b4c8660f --- /dev/null +++ b/test/Api.Test/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModelTests.cs @@ -0,0 +1,78 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Models.Request.Organizations; + +public class CreateOrganizationInviteLinkRequestModelTests +{ + private const string _validEncryptedString = + "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk="; + + [Fact] + public void Validate_ValidModel_ReturnsNoErrors() + { + var model = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = ["acme.com"], + EncryptedInviteKey = _validEncryptedString, + }; + + var results = Validate(model); + + Assert.Empty(results); + } + + [Fact] + public void Validate_WithValidEncryptedOrgKey_ReturnsNoErrors() + { + var model = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = ["acme.com"], + EncryptedInviteKey = _validEncryptedString, + EncryptedOrgKey = _validEncryptedString, + }; + + var results = Validate(model); + + Assert.Empty(results); + } + + [Fact] + public void Validate_EncryptedInviteKeyNotEncryptedString_ReturnsError() + { + var model = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = ["acme.com"], + EncryptedInviteKey = "not-an-encrypted-string", + }; + + var results = Validate(model); + + Assert.Single(results); + Assert.Contains(results, r => r.ErrorMessage == "EncryptedInviteKey is not a valid encrypted string."); + } + + [Fact] + public void Validate_EncryptedOrgKeyNotEncryptedString_ReturnsError() + { + var model = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = ["acme.com"], + EncryptedInviteKey = _validEncryptedString, + EncryptedOrgKey = "not-an-encrypted-string", + }; + + var results = Validate(model); + + Assert.Single(results); + Assert.Contains(results, r => r.ErrorMessage == "EncryptedOrgKey is not a valid encrypted string."); + } + + private static List Validate(CreateOrganizationInviteLinkRequestModel model) + { + var results = new List(); + Validator.TryValidateObject(model, new ValidationContext(model), results, true); + return results; + } +} From 0d7f27a20443735826f0ef57d8f16dc4ab4a6dc8 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Thu, 16 Apr 2026 14:15:57 +0100 Subject: [PATCH 17/39] Refactor OrganizationInviteLink to encapsulate AllowedDomains serialization logic within methods. Update OrganizationInviteLinkResponseModel to utilize new GetAllowedDomains method for improved clarity and maintainability. --- .../OrganizationInviteLinkResponseModel.cs | 9 ++------- .../AdminConsole/Entities/OrganizationInviteLink.cs | 10 +++++++++- .../InviteLinks/CreateOrganizationInviteLinkCommand.cs | 5 ++--- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationInviteLinkResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationInviteLinkResponseModel.cs index 2b37d71a726d..38946406ef21 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationInviteLinkResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationInviteLinkResponseModel.cs @@ -1,5 +1,4 @@ -using System.Text.Json; -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Models.Api; namespace Bit.Api.AdminConsole.Models.Response.Organizations; @@ -18,11 +17,7 @@ public OrganizationInviteLinkResponseModel(OrganizationInviteLink inviteLink) Id = inviteLink.Id; Code = inviteLink.Code; OrganizationId = inviteLink.OrganizationId; - if (!string.IsNullOrWhiteSpace(inviteLink.AllowedDomains)) - { - AllowedDomains = JsonSerializer.Deserialize>(inviteLink.AllowedDomains) - ?? throw new JsonException("Failed to deserialize AllowedDomains."); - } + AllowedDomains = inviteLink.GetAllowedDomains(); EncryptedInviteKey = inviteLink.EncryptedInviteKey; EncryptedOrgKey = inviteLink.EncryptedOrgKey; CreationDate = inviteLink.CreationDate; diff --git a/src/Core/AdminConsole/Entities/OrganizationInviteLink.cs b/src/Core/AdminConsole/Entities/OrganizationInviteLink.cs index d2e266bcd3d5..34d192df4625 100644 --- a/src/Core/AdminConsole/Entities/OrganizationInviteLink.cs +++ b/src/Core/AdminConsole/Entities/OrganizationInviteLink.cs @@ -1,4 +1,5 @@ -using Bit.Core.Entities; +using System.Text.Json; +using Bit.Core.Entities; using Bit.Core.Utilities; namespace Bit.Core.AdminConsole.Entities; @@ -14,6 +15,13 @@ public class OrganizationInviteLink : ITableObject public DateTime CreationDate { get; set; } = DateTime.UtcNow; public DateTime RevisionDate { get; set; } = DateTime.UtcNow; + public IEnumerable GetAllowedDomains() => + JsonSerializer.Deserialize>(AllowedDomains) + ?? throw new JsonException("Failed to deserialize AllowedDomains."); + + public void SetAllowedDomains(IEnumerable domains) => + AllowedDomains = JsonSerializer.Serialize(domains); + public void SetNewId() { if (Id == default) diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs index ae23058022a4..1112e0b3da42 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs @@ -1,5 +1,4 @@ -using System.Text.Json; -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities.v2.Results; @@ -39,12 +38,12 @@ public async Task> CreateAsync( var inviteLink = new OrganizationInviteLink { OrganizationId = request.OrganizationId, - AllowedDomains = JsonSerializer.Serialize(sanitizedDomains), EncryptedInviteKey = request.EncryptedInviteKey, EncryptedOrgKey = request.EncryptedOrgKey, CreationDate = now, RevisionDate = now, }; + inviteLink.SetAllowedDomains(sanitizedDomains); inviteLink.SetNewId(); await organizationInviteLinkRepository.CreateAsync(inviteLink); From 779b7ac0c6ace12526bd2b2768bae4176e976c5a Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Thu, 16 Apr 2026 14:25:09 +0100 Subject: [PATCH 18/39] Enhance domain sanitization in CreateOrganizationInviteLinkCommand by converting domains to lowercase during trimming for improved consistency. --- .../InviteLinks/CreateOrganizationInviteLinkCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs index 1112e0b3da42..46c8c25f553b 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs @@ -58,7 +58,7 @@ public async Task> CreateAsync( /// A list of sanitized domains. private static List SanitizeDomains(IEnumerable? domains) => domains? - .Select(d => d?.Trim()) + .Select(d => d?.Trim().ToLowerInvariant()) .Where(d => !string.IsNullOrEmpty(d)) .Cast() .ToList() ?? []; From 562a6875a5f3c85e6c6ce593f2a7b8ef68600097 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Thu, 16 Apr 2026 15:05:37 +0100 Subject: [PATCH 19/39] Update OrganizationInviteLinksControllerTests to use a valid encrypted invite key constant for consistency in test cases. --- .../Controllers/OrganizationInviteLinksControllerTests.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs index 1c2a8b5b7420..4cf1f24da941 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs @@ -20,6 +20,9 @@ public class OrganizationInviteLinksControllerTests : IClassFixture(); var persisted = await repository.GetByOrganizationIdAsync(_organization.Id); From ef2977db75bf7c0794a27c045a6081ccd93438e5 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Wed, 22 Apr 2026 15:48:40 +0100 Subject: [PATCH 20/39] Add ability check for organization invite links in CreateOrganizationInviteLinkCommand - Introduced a new method to verify if an organization can use invite links based on its ability. - Added a new error type for cases where invite links are not available due to organizational plan restrictions. - Updated tests to cover scenarios where the organization lacks the ability to create invite links. --- .../CreateOrganizationInviteLinkCommand.cs | 13 ++++ .../InviteLinks/Errors.cs | 3 + ...reateOrganizationInviteLinkCommandTests.cs | 78 +++++++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs index 46c8c25f553b..030a51fe2eab 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs @@ -2,12 +2,14 @@ using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities.v2.Results; +using Bit.Core.Services; using Bit.Core.Utilities; namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; public class CreateOrganizationInviteLinkCommand( IOrganizationInviteLinkRepository organizationInviteLinkRepository, + IApplicationCacheService applicationCacheService, TimeProvider timeProvider) : ICreateOrganizationInviteLinkCommand { @@ -16,6 +18,11 @@ public class CreateOrganizationInviteLinkCommand( public async Task> CreateAsync( CreateOrganizationInviteLinkRequest request) { + if (!await OrganizationHasInviteLinksAbilityAsync(request.OrganizationId)) + { + return new InviteLinkNotAvailable(); + } + var sanitizedDomains = SanitizeDomains(request.AllowedDomains); if (sanitizedDomains.Count == 0) { @@ -51,6 +58,12 @@ public async Task> CreateAsync( return inviteLink; } + private async Task OrganizationHasInviteLinksAbilityAsync(Guid organizationId) + { + var ability = await applicationCacheService.GetOrganizationAbilityAsync(organizationId); + return ability is not null && ability.UseInviteLinks; + } + /// /// Sanitizes the domains by trimming whitespace and removing empty domains. /// diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Errors.cs index 6b9699643aeb..1bdb710ccd67 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Errors.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Errors.cs @@ -10,3 +10,6 @@ public record InviteLinkDomainsRequired() public record InviteLinkInvalidDomains(IEnumerable InvalidDomains) : BadRequestError($"One or more domains are invalid: {string.Join(", ", InvalidDomains)}."); + +public record InviteLinkNotAvailable() + : BadRequestError("Your organization's plan does not support invite links."); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommandTests.cs index bba244b0485c..c76c288d6f36 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommandTests.cs @@ -2,6 +2,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -12,11 +14,23 @@ namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.InviteLinks; [SutProviderCustomize] public class CreateOrganizationInviteLinkCommandTests { + private static void SetupAbility( + SutProvider sutProvider, + Guid organizationId, + bool useInviteLinks = true) + { + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(organizationId) + .Returns(new OrganizationAbility { UseInviteLinks = useInviteLinks }); + } + [Theory, BitAutoData] public async Task CreateAsync_WithValidInput_Success( Guid organizationId, SutProvider sutProvider) { + SetupAbility(sutProvider, organizationId); + var request = new CreateOrganizationInviteLinkRequest { OrganizationId = organizationId, @@ -50,6 +64,8 @@ public async Task CreateAsync_WithExistingLinkForOrg_ReturnsConflictError( OrganizationInviteLink existingLink, SutProvider sutProvider) { + SetupAbility(sutProvider, organizationId); + sutProvider.GetDependency() .GetByOrganizationIdAsync(organizationId) .Returns(existingLink); @@ -76,6 +92,8 @@ public async Task CreateAsync_WithEmptyDomainsList_ReturnsBadRequestError( Guid organizationId, SutProvider sutProvider) { + SetupAbility(sutProvider, organizationId); + var request = new CreateOrganizationInviteLinkRequest { OrganizationId = organizationId, @@ -98,6 +116,8 @@ public async Task CreateAsync_WithWhitespaceOnlyDomains_ReturnsBadRequestError( Guid organizationId, SutProvider sutProvider) { + SetupAbility(sutProvider, organizationId); + var request = new CreateOrganizationInviteLinkRequest { OrganizationId = organizationId, @@ -116,6 +136,8 @@ public async Task CreateAsync_WithNullDomains_ReturnsBadRequestError( Guid organizationId, SutProvider sutProvider) { + SetupAbility(sutProvider, organizationId); + var request = new CreateOrganizationInviteLinkRequest { OrganizationId = organizationId, @@ -139,6 +161,8 @@ public async Task CreateAsync_WithInvalidDomainFormat_ReturnsInvalidDomainsError Guid organizationId, SutProvider sutProvider) { + SetupAbility(sutProvider, organizationId); + var request = new CreateOrganizationInviteLinkRequest { OrganizationId = organizationId, @@ -165,6 +189,8 @@ public async Task CreateAsync_WithValidDomainFormat_Succeeds( Guid organizationId, SutProvider sutProvider) { + SetupAbility(sutProvider, organizationId); + var request = new CreateOrganizationInviteLinkRequest { OrganizationId = organizationId, @@ -182,6 +208,8 @@ public async Task CreateAsync_WithMixedValidAndBlankDomains_Success( Guid organizationId, SutProvider sutProvider) { + SetupAbility(sutProvider, organizationId); + var request = new CreateOrganizationInviteLinkRequest { OrganizationId = organizationId, @@ -199,4 +227,54 @@ public async Task CreateAsync_WithMixedValidAndBlankDomains_Success( Assert.Contains("acme.com", deserializedDomains); Assert.Contains("example.com", deserializedDomains); } + + [Theory, BitAutoData] + public async Task CreateAsync_WithoutUseInviteLinksAbility_ReturnsBadRequestError( + Guid organizationId, + SutProvider sutProvider) + { + SetupAbility(sutProvider, organizationId, useInviteLinks: false); + + var request = new CreateOrganizationInviteLinkRequest + { + OrganizationId = organizationId, + AllowedDomains = ["acme.com"], + EncryptedInviteKey = "encrypted-key", + }; + + var result = await sutProvider.Sut.CreateAsync(request); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_WithNullAbility_ReturnsBadRequestError( + Guid organizationId, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(organizationId) + .Returns((OrganizationAbility?)null); + + var request = new CreateOrganizationInviteLinkRequest + { + OrganizationId = organizationId, + AllowedDomains = ["acme.com"], + EncryptedInviteKey = "encrypted-key", + }; + + var result = await sutProvider.Sut.CreateAsync(request); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CreateAsync(default!); + } } From c8b340939ee64ed4f763128ef7b75bf1c3ebb852 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Wed, 22 Apr 2026 15:50:55 +0100 Subject: [PATCH 21/39] Add documentation for Code property in OrganizationInviteLink class - Added XML summary comments to the Code property to clarify its purpose and generation method. - Explained the choice of using Guid.NewGuid for the Code to avoid predictability and ensure uniqueness. --- src/Core/AdminConsole/Entities/OrganizationInviteLink.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Core/AdminConsole/Entities/OrganizationInviteLink.cs b/src/Core/AdminConsole/Entities/OrganizationInviteLink.cs index 34d192df4625..4e02a5b7d884 100644 --- a/src/Core/AdminConsole/Entities/OrganizationInviteLink.cs +++ b/src/Core/AdminConsole/Entities/OrganizationInviteLink.cs @@ -7,6 +7,12 @@ namespace Bit.Core.AdminConsole.Entities; public class OrganizationInviteLink : ITableObject { public Guid Id { get; set; } + /// + /// A random, publicly shareable code used to identify the invite link. + /// Uses rather than a sequential/comb GUID because this is not + /// a table identifier and therefore does not need index-friendly ordering. A comb GUID's embedded + /// timestamp would also make the code partially predictable. + /// public Guid Code { get; set; } = Guid.NewGuid(); public Guid OrganizationId { get; set; } public string AllowedDomains { get; set; } = null!; From 49dd0fbc039d7b95e854bc4ebf39e08254b3a6c8 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Wed, 22 Apr 2026 16:03:57 +0100 Subject: [PATCH 22/39] Implement domain validation in CreateOrganizationInviteLinkRequestModel - Added IValidatableObject implementation to CreateOrganizationInviteLinkRequestModel for domain validation. - Introduced Validate method to check the format of allowed domains and return appropriate validation results. - Updated tests to cover scenarios for invalid domain formats and mixed valid/invalid domains. - Removed redundant domain validation logic from CreateOrganizationInviteLinkCommand. --- ...reateOrganizationInviteLinkRequestModel.cs | 16 +++- .../CreateOrganizationInviteLinkCommand.cs | 15 +--- ...OrganizationInviteLinkRequestModelTests.cs | 34 ++++++++ ...reateOrganizationInviteLinkCommandTests.cs | 77 ------------------- 4 files changed, 50 insertions(+), 92 deletions(-) diff --git a/src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs index 54289f2b3445..66cb645d9724 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs @@ -4,7 +4,7 @@ namespace Bit.Api.AdminConsole.Models.Request.Organizations; -public class CreateOrganizationInviteLinkRequestModel +public class CreateOrganizationInviteLinkRequestModel : IValidatableObject { /// /// Email domains permitted to accept the invite link (e.g. ["acme.com"]). @@ -25,6 +25,20 @@ public class CreateOrganizationInviteLinkRequestModel [EncryptedString] public string? EncryptedOrgKey { get; set; } + public IEnumerable Validate(ValidationContext validationContext) + { + var validator = new DomainNameValidatorAttribute(); + foreach (var domain in AllowedDomains ?? []) + { + if (!validator.IsValid(domain)) + { + yield return new ValidationResult( + $"'{domain}' is not a valid domain name.", + [nameof(AllowedDomains)]); + } + } + } + public CreateOrganizationInviteLinkRequest ToCommandRequest(Guid organizationId) => new() { OrganizationId = organizationId, diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs index 030a51fe2eab..cd3cbdc36c1f 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs @@ -3,7 +3,6 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities.v2.Results; using Bit.Core.Services; -using Bit.Core.Utilities; namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; @@ -13,8 +12,6 @@ public class CreateOrganizationInviteLinkCommand( TimeProvider timeProvider) : ICreateOrganizationInviteLinkCommand { - private static readonly DomainNameValidatorAttribute _domainValidator = new(); - public async Task> CreateAsync( CreateOrganizationInviteLinkRequest request) { @@ -29,12 +26,6 @@ public async Task> CreateAsync( return new InviteLinkDomainsRequired(); } - var invalidDomains = sanitizedDomains.Where(d => !IsValidDomain(d)).ToList(); - if (invalidDomains.Count > 0) - { - return new InviteLinkInvalidDomains(invalidDomains); - } - var existingLink = await organizationInviteLinkRepository.GetByOrganizationIdAsync(request.OrganizationId); if (existingLink != null) { @@ -65,16 +56,12 @@ private async Task OrganizationHasInviteLinksAbilityAsync(Guid organizatio } /// - /// Sanitizes the domains by trimming whitespace and removing empty domains. + /// Normalizes domains to lowercase and removes blank entries. /// - /// The domains to sanitize. - /// A list of sanitized domains. private static List SanitizeDomains(IEnumerable? domains) => domains? .Select(d => d?.Trim().ToLowerInvariant()) .Where(d => !string.IsNullOrEmpty(d)) .Cast() .ToList() ?? []; - - private static bool IsValidDomain(string domain) => _domainValidator.IsValid(domain); } diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModelTests.cs index c5a4b4c8660f..2e48c483221f 100644 --- a/test/Api.Test/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModelTests.cs +++ b/test/Api.Test/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModelTests.cs @@ -69,6 +69,40 @@ public void Validate_EncryptedOrgKeyNotEncryptedString_ReturnsError() Assert.Contains(results, r => r.ErrorMessage == "EncryptedOrgKey is not a valid encrypted string."); } + [Theory] + [InlineData("not a domain")] + [InlineData("")] + [InlineData("double..dot.com")] + [InlineData("-starts-with-hyphen.com")] + [InlineData(" acme.com ")] + public void Validate_WithInvalidDomainFormat_ReturnsError(string invalidDomain) + { + var model = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = [invalidDomain], + EncryptedInviteKey = _validEncryptedString, + }; + + var results = Validate(model); + + Assert.Single(results); + Assert.Contains(results, r => r.MemberNames.Contains(nameof(model.AllowedDomains))); + } + + [Fact] + public void Validate_WithMixedValidAndInvalidDomains_ReturnsOneErrorPerInvalidDomain() + { + var model = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = ["acme.com", "not a domain", "")] - [BitAutoData("double..dot.com")] - [BitAutoData("-starts-with-hyphen.com")] - public async Task CreateAsync_WithInvalidDomainFormat_ReturnsInvalidDomainsError( - string invalidDomain, - Guid organizationId, - SutProvider sutProvider) - { - SetupAbility(sutProvider, organizationId); - - var request = new CreateOrganizationInviteLinkRequest - { - OrganizationId = organizationId, - AllowedDomains = [invalidDomain], - EncryptedInviteKey = "encrypted-key", - }; - - var result = await sutProvider.Sut.CreateAsync(request); - - Assert.True(result.IsError); - Assert.IsType(result.AsError); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .CreateAsync(default!); - } - - [Theory] - [BitAutoData("acme.com")] - [BitAutoData("sub.example.org")] - [BitAutoData("my-company.co.uk")] - public async Task CreateAsync_WithValidDomainFormat_Succeeds( - string validDomain, - Guid organizationId, - SutProvider sutProvider) - { - SetupAbility(sutProvider, organizationId); - - var request = new CreateOrganizationInviteLinkRequest - { - OrganizationId = organizationId, - AllowedDomains = [validDomain], - EncryptedInviteKey = "encrypted-key", - }; - - var result = await sutProvider.Sut.CreateAsync(request); - - Assert.True(result.IsSuccess); - } - - [Theory, BitAutoData] - public async Task CreateAsync_WithMixedValidAndBlankDomains_Success( - Guid organizationId, - SutProvider sutProvider) - { - SetupAbility(sutProvider, organizationId); - - var request = new CreateOrganizationInviteLinkRequest - { - OrganizationId = organizationId, - AllowedDomains = [" acme.com ", "", " ", "example.com "], - EncryptedInviteKey = "encrypted-key", - }; - - var result = await sutProvider.Sut.CreateAsync(request); - - Assert.True(result.IsSuccess); - var link = result.AsSuccess; - var deserializedDomains = JsonSerializer.Deserialize>(link.AllowedDomains); - Assert.NotNull(deserializedDomains); - Assert.Equal(2, deserializedDomains.Count); - Assert.Contains("acme.com", deserializedDomains); - Assert.Contains("example.com", deserializedDomains); - } - [Theory, BitAutoData] public async Task CreateAsync_WithoutUseInviteLinksAbility_ReturnsBadRequestError( Guid organizationId, From 7077468053d8c3e8fa6cb1a166e34e1dec4cc583 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Wed, 22 Apr 2026 16:06:08 +0100 Subject: [PATCH 23/39] Remove outdated tests from CreateOrganizationInviteLinkRequestModelTests - Deleted tests for validating EncryptedInviteKey and EncryptedOrgKey as they are no longer relevant. - Cleaned up the test class to focus on current validation logic for allowed domains. --- ...OrganizationInviteLinkRequestModelTests.cs | 46 ------------------- 1 file changed, 46 deletions(-) diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModelTests.cs index 2e48c483221f..7ab7530afe28 100644 --- a/test/Api.Test/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModelTests.cs +++ b/test/Api.Test/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModelTests.cs @@ -23,52 +23,6 @@ public void Validate_ValidModel_ReturnsNoErrors() Assert.Empty(results); } - [Fact] - public void Validate_WithValidEncryptedOrgKey_ReturnsNoErrors() - { - var model = new CreateOrganizationInviteLinkRequestModel - { - AllowedDomains = ["acme.com"], - EncryptedInviteKey = _validEncryptedString, - EncryptedOrgKey = _validEncryptedString, - }; - - var results = Validate(model); - - Assert.Empty(results); - } - - [Fact] - public void Validate_EncryptedInviteKeyNotEncryptedString_ReturnsError() - { - var model = new CreateOrganizationInviteLinkRequestModel - { - AllowedDomains = ["acme.com"], - EncryptedInviteKey = "not-an-encrypted-string", - }; - - var results = Validate(model); - - Assert.Single(results); - Assert.Contains(results, r => r.ErrorMessage == "EncryptedInviteKey is not a valid encrypted string."); - } - - [Fact] - public void Validate_EncryptedOrgKeyNotEncryptedString_ReturnsError() - { - var model = new CreateOrganizationInviteLinkRequestModel - { - AllowedDomains = ["acme.com"], - EncryptedInviteKey = _validEncryptedString, - EncryptedOrgKey = "not-an-encrypted-string", - }; - - var results = Validate(model); - - Assert.Single(results); - Assert.Contains(results, r => r.ErrorMessage == "EncryptedOrgKey is not a valid encrypted string."); - } - [Theory] [InlineData("not a domain")] [InlineData("")] From abd9cde637512965dfeac054ddb8bde1b0ee1c6d Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Wed, 22 Apr 2026 16:13:10 +0100 Subject: [PATCH 24/39] Refactor GetAllowedDomains method in OrganizationInviteLink class - Updated the GetAllowedDomains method to return an empty array instead of throwing a JsonException when deserialization fails. - This change improves the method's resilience by providing a default value for invalid or missing allowed domains. --- src/Core/AdminConsole/Entities/OrganizationInviteLink.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Core/AdminConsole/Entities/OrganizationInviteLink.cs b/src/Core/AdminConsole/Entities/OrganizationInviteLink.cs index 4e02a5b7d884..26883a424de9 100644 --- a/src/Core/AdminConsole/Entities/OrganizationInviteLink.cs +++ b/src/Core/AdminConsole/Entities/OrganizationInviteLink.cs @@ -22,8 +22,7 @@ public class OrganizationInviteLink : ITableObject public DateTime RevisionDate { get; set; } = DateTime.UtcNow; public IEnumerable GetAllowedDomains() => - JsonSerializer.Deserialize>(AllowedDomains) - ?? throw new JsonException("Failed to deserialize AllowedDomains."); + JsonSerializer.Deserialize>(AllowedDomains) ?? []; public void SetAllowedDomains(IEnumerable domains) => AllowedDomains = JsonSerializer.Serialize(domains); From 1db861eb79797b3b33a451be5fb224aad5f1d824 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Wed, 22 Apr 2026 16:24:37 +0100 Subject: [PATCH 25/39] Remove unused InviteLinkInvalidDomains error type from Errors.cs - Deleted the InviteLinkInvalidDomains record as it is no longer needed. - This cleanup aligns with recent changes in domain validation logic and improves code maintainability. --- .../AdminConsole/OrganizationFeatures/InviteLinks/Errors.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Errors.cs index 1bdb710ccd67..375903db92b7 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Errors.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Errors.cs @@ -8,8 +8,5 @@ public record InviteLinkAlreadyExists() public record InviteLinkDomainsRequired() : BadRequestError("At least one allowed domain is required."); -public record InviteLinkInvalidDomains(IEnumerable InvalidDomains) - : BadRequestError($"One or more domains are invalid: {string.Join(", ", InvalidDomains)}."); - public record InviteLinkNotAvailable() : BadRequestError("Your organization's plan does not support invite links."); From 641a0a700d41e4d8fe2e0437c758830e4e24f56d Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Wed, 22 Apr 2026 16:48:12 +0100 Subject: [PATCH 26/39] Update OrganizationServiceCollectionExtensions to use TryAddScoped for command registration - Changed the registration of ICreateOrganizationInviteLinkCommand to use TryAddScoped instead of AddScoped. --- .../OrganizationServiceCollectionExtensions.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 1605f45dda19..d732f7582dca 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -47,6 +47,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using AccountRecoveryV2 = Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery.v2; using V1_RevokeUsersCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1; @@ -192,7 +193,7 @@ private static void AddOrganizationGroupCommands(this IServiceCollection service private static void AddOrganizationInviteLinkCommands(this IServiceCollection services) { - services.AddScoped(); + services.TryAddScoped(); } private static void AddOrganizationDomainCommandsQueries(this IServiceCollection services) From d64c6e57c653ba0d6364344a99c168b243a8d113 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Thu, 23 Apr 2026 15:27:15 +0100 Subject: [PATCH 27/39] Mock organization ability retrieval in OrganizationInviteLinksControllerTests --- .../Controllers/OrganizationInviteLinksControllerTests.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs index 4cf1f24da941..dd36d7471b55 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs @@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations; using Bit.Core.Services; using NSubstitute; using Xunit; @@ -35,6 +36,12 @@ public OrganizationInviteLinksControllerTests(ApiApplicationFactory factory) .IsEnabled(FeatureFlagKeys.GenerateInviteLink) .Returns(true); }); + _factory.SubstituteService(cacheService => + { + cacheService + .GetOrganizationAbilityAsync(Arg.Any()) + .Returns(new OrganizationAbility { UseInviteLinks = true }); + }); _client = factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); } From 8c2fe7bd7efeb0129ea85885e13b5fe2aa774935 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Tue, 28 Apr 2026 15:48:55 +0100 Subject: [PATCH 28/39] Add ValidateSequenceAttribute for collection validation and corresponding unit tests --- .../Utilities/ValidateSequenceAttribute.cs | 26 +++++++++ .../ValidateSequenceAttributeTests.cs | 55 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 src/Core/Utilities/ValidateSequenceAttribute.cs create mode 100644 test/Core.Test/Utilities/ValidateSequenceAttributeTests.cs diff --git a/src/Core/Utilities/ValidateSequenceAttribute.cs b/src/Core/Utilities/ValidateSequenceAttribute.cs new file mode 100644 index 000000000000..8f4244cb17aa --- /dev/null +++ b/src/Core/Utilities/ValidateSequenceAttribute.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Core.Utilities; + +/// +/// Validates each string element of a collection using , reporting +/// all invalid items in a single . +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)] +public class ValidateSequenceAttribute : ValidationAttribute + where TValidator : ValidationAttribute, new() +{ + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + var items = (value as IEnumerable ?? []).ToList(); + var validator = new TValidator(); + var invalid = items.Where(item => !validator.IsValid(item)).ToList(); + + if (invalid.Count == 0) return ValidationResult.Success; + + var memberNames = new[] { validationContext.MemberName ?? validationContext.DisplayName }; + var message = $"The following items are not valid: {string.Join(", ", invalid.Select(v => $"'{v}'"))}"; + + return new ValidationResult(message, memberNames!); + } +} diff --git a/test/Core.Test/Utilities/ValidateSequenceAttributeTests.cs b/test/Core.Test/Utilities/ValidateSequenceAttributeTests.cs new file mode 100644 index 000000000000..aff5ed10399e --- /dev/null +++ b/test/Core.Test/Utilities/ValidateSequenceAttributeTests.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Utilities; +using Xunit; + +namespace Bit.Core.Test.Utilities; + +public class ValidateSequenceAttributeTests +{ + [Fact] + public void IsValid_WithEmptyCollection_ReturnsSuccess() + { + var result = Validate([]); + + Assert.Equal(ValidationResult.Success, result); + } + + [Fact] + public void IsValid_WithNullCollection_ReturnsSuccess() + { + var result = Validate(null); + + Assert.Equal(ValidationResult.Success, result); + } + + [Fact] + public void IsValid_WithSomeInvalidItems_ReturnsErrorMessage() + { + var result = Validate(["bad", "also-bad", "ok"]); + + Assert.NotNull(result); + Assert.Contains("'bad'", result!.ErrorMessage); + Assert.Contains("'also-bad'", result.ErrorMessage); + Assert.DoesNotContain("ok", result.ErrorMessage); + } + + /// + /// Invokes directly so edge cases + /// (null/empty collection) can be tested in isolation, independent of any model's + /// [Required] attribute which would intercept those cases first. + /// + private static ValidationResult? Validate(IEnumerable? value) + { + var attr = new ValidateSequenceAttribute(); + return attr.GetValidationResult(value, new ValidationContext(new object())); + } + + /// + /// Accepts only the literal string "ok" so tests are not coupled to + /// regex rules. + /// + private class OnlyAcceptsOkValidator : ValidationAttribute + { + public override bool IsValid(object? value) => value?.ToString() == "ok"; + } +} From db439fcde0c0d94e48d8391e92672b1566431f5e Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Tue, 28 Apr 2026 15:50:36 +0100 Subject: [PATCH 29/39] Refactor CreateOrganizationInviteLinkRequestModel to use ValidateSequenceAttribute for domain validation and update unit tests for improved error handling. --- .../CreateOrganizationInviteLinkRequestModel.cs | 17 ++--------------- ...teOrganizationInviteLinkRequestModelTests.cs | 6 ++++-- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs index 66cb645d9724..b9575590be96 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs @@ -4,12 +4,13 @@ namespace Bit.Api.AdminConsole.Models.Request.Organizations; -public class CreateOrganizationInviteLinkRequestModel : IValidatableObject +public class CreateOrganizationInviteLinkRequestModel { /// /// Email domains permitted to accept the invite link (e.g. ["acme.com"]). /// [Required] + [ValidateSequence] public required IEnumerable AllowedDomains { get; set; } /// @@ -25,20 +26,6 @@ public class CreateOrganizationInviteLinkRequestModel : IValidatableObject [EncryptedString] public string? EncryptedOrgKey { get; set; } - public IEnumerable Validate(ValidationContext validationContext) - { - var validator = new DomainNameValidatorAttribute(); - foreach (var domain in AllowedDomains ?? []) - { - if (!validator.IsValid(domain)) - { - yield return new ValidationResult( - $"'{domain}' is not a valid domain name.", - [nameof(AllowedDomains)]); - } - } - } - public CreateOrganizationInviteLinkRequest ToCommandRequest(Guid organizationId) => new() { OrganizationId = organizationId, diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModelTests.cs index 7ab7530afe28..269ac7e31f78 100644 --- a/test/Api.Test/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModelTests.cs +++ b/test/Api.Test/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModelTests.cs @@ -44,7 +44,7 @@ public void Validate_WithInvalidDomainFormat_ReturnsError(string invalidDomain) } [Fact] - public void Validate_WithMixedValidAndInvalidDomains_ReturnsOneErrorPerInvalidDomain() + public void Validate_WithMixedValidAndInvalidDomains_ReturnsError() { var model = new CreateOrganizationInviteLinkRequestModel { @@ -54,7 +54,9 @@ public void Validate_WithMixedValidAndInvalidDomains_ReturnsOneErrorPerInvalidDo var results = Validate(model); - Assert.Equal(2, results.Count); + var error = Assert.Single(results); + Assert.Contains("'not a domain'", error.ErrorMessage); + Assert.Contains("'