Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
private readonly IUpdateUserResetPasswordEnrollmentCommand _updateUserResetPasswordEnrollmentCommand;
private readonly IAcceptOrganizationInviteLinkCommand _acceptOrganizationInviteLinkCommand;
private readonly IConfirmOrganizationInviteLinkCommand _confirmOrganizationInviteLinkCommand;
private readonly IGetOrganizationInviteCommand _getOrganizationInviteCommand;

public OrganizationUsersController(IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
Expand Down Expand Up @@ -126,7 +127,8 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor
IUpdateUserResetPasswordEnrollmentCommand updateUserResetPasswordEnrollmentCommand,
IGetPendingAutoConfirmUsersQuery getPendingAutoConfirmUsersQuery,
IAcceptOrganizationInviteLinkCommand acceptOrganizationInviteLinkCommand,
IConfirmOrganizationInviteLinkCommand confirmOrganizationInviteLinkCommand)
IConfirmOrganizationInviteLinkCommand confirmOrganizationInviteLinkCommand,
IGetOrganizationInviteCommand getOrganizationInviteCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
Expand Down Expand Up @@ -163,6 +165,7 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor
_updateUserResetPasswordEnrollmentCommand = updateUserResetPasswordEnrollmentCommand;
_acceptOrganizationInviteLinkCommand = acceptOrganizationInviteLinkCommand;
_confirmOrganizationInviteLinkCommand = confirmOrganizationInviteLinkCommand;
_getOrganizationInviteCommand = getOrganizationInviteCommand;
}

[HttpGet("{id}")]
Expand Down Expand Up @@ -896,4 +899,24 @@ public async Task<IResult> ConfirmInviteLink([FromBody] ConfirmOrganizationInvit

return Handle(result, _ => TypedResults.Ok());
}

[HttpPost("/organizations/users/invite-link/invite")]
[RequireFeature(FeatureFlagKeys.GenerateInviteLink)]
public async Task<IResult> GetInvite([FromBody] GetOrganizationInviteRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}

var result = await _getOrganizationInviteCommand.GetInviteAsync(new GetOrganizationInviteRequest
{
OrganizationId = model.OrganizationId,
Code = model.Code,
User = user,
});

return Handle(result, invite => TypedResults.Ok(new OrganizationInviteResponseModel(invite)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
ο»Ώusing System.ComponentModel.DataAnnotations;

namespace Bit.Api.AdminConsole.Models.Request.Organizations;

public class GetOrganizationInviteRequestModel
{
[Required]
public required Guid Code { get; set; }

[Required]
public required Guid OrganizationId { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ο»Ώnamespace Bit.Api.AdminConsole.Models.Response.Organizations;

public record OrganizationInviteResponseModel(string Invite);
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
ο»Ώusing Bit.Core.AdminConsole.AbilitiesCache;
using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.AdminConsole.Utilities.v2.Results;

namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks;

/// <summary>
/// Retrieves the opaque invite for an invite link. See
/// <see cref="IGetOrganizationInviteCommand"/> for the behavior.
/// </summary>
public class GetOrganizationInviteCommand(
IOrganizationInviteLinkRepository organizationInviteLinkRepository,
IOrganizationAbilityCacheService organizationAbilityCacheService)
: IGetOrganizationInviteCommand
{
public async Task<CommandResult<string>> GetInviteAsync(GetOrganizationInviteRequest request)
{
var user = request.User;

var link = await organizationInviteLinkRepository.GetByOrganizationIdAsync(request.OrganizationId);
if (link is null || !link.CodeMatches(request.Code.ToString()))
{
return new InviteLinkNotFound();
}

var organizationAbility = await organizationAbilityCacheService.GetOrganizationAbilityAsync(link.OrganizationId);
if (organizationAbility is null or { Enabled: false })
{
return new InviteLinkNotFound();
}

if (!organizationAbility.UseInviteLinks)
{
return new InviteLinkNotAvailable();
}

if (!InviteLinkDomainValidator.IsEmailDomainAllowed(user.Email, link.GetAllowedDomains()))
{
return new EmailDomainNotAllowed();
}

return link.Invite;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
ο»Ώusing Bit.Core.Entities;

namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks;

/// <summary>
/// The data required to retrieve the invite for an invite link. The invite is an opaque
/// cryptographic value that the server stores and transports but never inspects; it is decrypted
/// client-side to reconstruct and confirm the invite link.
/// </summary>
public record GetOrganizationInviteRequest
{
/// <summary>
/// The ID of the organization whose invite link the user is retrieving the invite for.
/// </summary>
public required Guid OrganizationId { get; init; }

/// <summary>
/// The secret code embedded in the invite link the user is retrieving the invite for.
/// </summary>
public required Guid Code { get; init; }

/// <summary>
/// The authenticated user requesting the invite.
/// </summary>
public required User User { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
ο»Ώusing Bit.Core.AdminConsole.Utilities.v2.Results;

namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces;

/// <summary>
/// Retrieves the opaque invite for an invite link after validating that the link exists, its
/// organization is enabled and supports invite links, and the user's email domain is allowed.
/// </summary>
public interface IGetOrganizationInviteCommand
{
Task<CommandResult<string>> GetInviteAsync(GetOrganizationInviteRequest request);
}
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ private static void AddOrganizationInviteLinkCommandsQueries(this IServiceCollec
services.TryAddScoped<IAcceptOrganizationInviteLinkCommand, AcceptOrganizationInviteLinkCommand>();
services.TryAddScoped<IConfirmOrganizationInviteLinkValidator, ConfirmOrganizationInviteLinkValidator>();
services.TryAddScoped<IConfirmOrganizationInviteLinkCommand, ConfirmOrganizationInviteLinkCommand>();
services.TryAddScoped<IGetOrganizationInviteCommand, GetOrganizationInviteCommand>();
}

private static void AddOrganizationDomainCommandsQueries(this IServiceCollection services)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
ο»Ώ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.AbilitiesCache;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using NSubstitute;
using Xunit;

namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;

public class OrganizationUsersControllerGetInviteTests : IClassFixture<ApiApplicationFactory>, IAsyncLifetime
{
private readonly HttpClient _client;
private readonly ApiApplicationFactory _factory;
private readonly LoginHelper _loginHelper;

private const string _validEncryptedKey =
"2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=";

private Organization _organization = null!;
private string _ownerEmail = null!;

public OrganizationUsersControllerGetInviteTests(ApiApplicationFactory factory)
{
_factory = factory;
_factory.SubstituteService<IFeatureService>(featureService =>
{
featureService
.IsEnabled(FeatureFlagKeys.GenerateInviteLink)
.Returns(true);
featureService
.IsEnabled(FeatureFlagKeys.InviteLinkAutoConfirm)
.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);

var organizationRepository = _factory.GetService<IOrganizationRepository>();
_organization.UseInviteLinks = true;
await organizationRepository.ReplaceAsync(_organization);

// The endpoint reads Enabled/UseInviteLinks from the organization ability cache, so refresh it
// to reflect the invite links being enabled above.
await _factory.GetService<IOrganizationAbilityCacheService>()
.UpsertOrganizationAbilityAsync(_organization);

await _loginHelper.LoginAsync(_ownerEmail);
}

public Task DisposeAsync()
{
_client.Dispose();
return Task.CompletedTask;
}

[Fact]
public async Task GetInvite_WithValidRequest_ReturnsOkAndInvite()
{
// Arrange
var code = await CreateInviteLinkAsync(["example.com"]);
var (joinerClient, _) = await CreateJoinerClientAsync();

// Act
var response = await joinerClient.PostAsJsonAsync(
"/organizations/users/invite-link/invite", BuildRequest(_organization.Id, code));

// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

var result = await response.Content.ReadFromJsonAsync<OrganizationInviteResponseModel>();
Assert.NotNull(result);
Assert.Equal(_validEncryptedKey, result.Invite);
}

private async Task<Guid> CreateInviteLinkAsync(string[] allowedDomains)
{
var createRequest = new CreateOrganizationInviteLinkRequestModel
{
AllowedDomains = allowedDomains,
Invite = _validEncryptedKey,
SupportsConfirmation = true,
};
var createResponse = await _client.PostAsJsonAsync(
$"/organizations/{_organization.Id}/invite-link", createRequest);
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);

var created = await createResponse.Content.ReadFromJsonAsync<OrganizationInviteLinkResponseModel>();
Assert.NotNull(created);
return created.Code;
}

private async Task<(HttpClient Client, string Email)> CreateJoinerClientAsync()
{
var joinerEmail = $"integration-test{Guid.NewGuid()}@example.com";
await _factory.LoginWithNewAccount(joinerEmail);
var joinerClient = _factory.CreateClient();
var joinerLoginHelper = new LoginHelper(_factory, joinerClient);
await joinerLoginHelper.LoginAsync(joinerEmail);
return (joinerClient, joinerEmail);
}

private static GetOrganizationInviteRequestModel BuildRequest(Guid organizationId, Guid code) =>
new()
{
OrganizationId = organizationId,
Code = code,
};
}
Loading
Loading