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 @@ -19,6 +19,7 @@ public class OrganizationInviteLinksController(
IGetOrganizationInviteLinkQuery getOrganizationInviteLinkQuery,
IGetOrganizationInviteLinkStatusQuery getOrganizationInviteLinkStatusQuery,
IUpdateOrganizationInviteLinkCommand updateOrganizationInviteLinkCommand,
IUpdateInviteSupportConfirmCommand updateInviteSupportConfirmCommand,
IDeleteOrganizationInviteLinkCommand deleteOrganizationInviteLinkCommand,
IRefreshOrganizationInviteLinkCommand refreshOrganizationInviteLinkCommand,
IValidateOrganizationInviteLinkEmailDomainQuery validateOrganizationInviteLinkEmailDomainQuery,
Expand Down Expand Up @@ -97,6 +98,17 @@ public async Task<IResult> Update(Guid orgId, [FromBody] UpdateOrganizationInvit
TypedResults.Ok(new OrganizationInviteLinkResponseModel(link)));
}

[HttpPut("support-confirm")]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't feel strongly about this route name. We have an endpoint with the base route that only updates the domain name, so we need to name this one something different. I'm happy to hear any suggestions.

[Authorize<ManageUsersRequirement>]
public async Task<IResult> UpdateInviteSupportConfirm(Guid orgId, [FromBody] UpdateInviteSupportConfirmRequestModel model)
{
var result = await updateInviteSupportConfirmCommand.UpdateAsync(
model.ToCommandRequest(orgId));

return Handle(result, link =>
TypedResults.Ok(new OrganizationInviteLinkResponseModel(link)));
}

[HttpDelete("")]
[Authorize<ManageUsersRequirement>]
public async Task<IResult> Delete(Guid orgId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public class CreateOrganizationInviteLinkRequestModel
public required string Invite { get; set; }

/// <summary>
/// Indicates if the link supports user auto confirmation (not supported yet).
/// Whether this invite link can be used to confirm a user.
/// </summary>
[Required]
public required bool SupportsConfirmation { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public class RefreshOrganizationInviteLinkRequestModel
public required string Invite { get; set; }

/// <summary>
/// Indicates if the link supports user auto confirmation (not supported yet).
/// Whether this invite link can be used to confirm a user.
/// </summary>
[Required]
public required bool SupportsConfirmation { get; set; }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
ο»Ώusing System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks;
using Bit.Core.Utilities;

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

public class UpdateInviteSupportConfirmRequestModel
{
/// <summary>
/// An opaque cryptographic blob. The server only stores and transports it, so its format is not
/// validated here.
/// </summary>
[Required]
[EncryptedStringLength(3000)]
public required string Invite { get; set; }

/// <summary>
/// Whether this invite link can be used to confirm a user.
/// </summary>
[Required]
public required bool SupportsConfirmation { get; set; }

public UpdateInviteSupportConfirmRequest ToCommandRequest(Guid organizationId) => new()
{
OrganizationId = organizationId,
Invite = Invite,
SupportsConfirmation = SupportsConfirmation,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
ο»Ώusing Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Utilities.v2.Results;

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

public interface IUpdateInviteSupportConfirmCommand
{
/// <summary>
/// Updates only the <see cref="OrganizationInviteLink.Invite"/> blob and
/// <see cref="OrganizationInviteLink.SupportsConfirmation"/> flag for the specified organization's invite link.
/// </summary>
/// <param name="request">The details for the invite link update.</param>
/// <returns>The updated <see cref="OrganizationInviteLink"/>, or an error if the organization does not support
/// invite links or a link does not exist.</returns>
Task<CommandResult<OrganizationInviteLink>> UpdateAsync(UpdateInviteSupportConfirmRequest request);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public record RefreshOrganizationInviteLinkRequest
public required string Invite { get; init; }

/// <summary>
/// Indicates if the link supports user auto confirmation (not supported yet).
/// Whether this invite link can be used to confirm a user.
/// </summary>
public required bool SupportsConfirmation { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
ο»Ώusing Bit.Core.AdminConsole.AbilitiesCache;
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 UpdateInviteSupportConfirmCommand(
IOrganizationInviteLinkRepository organizationInviteLinkRepository,
IOrganizationAbilityCacheService organizationAbilityCacheService,
TimeProvider timeProvider)
: IUpdateInviteSupportConfirmCommand
{
public async Task<CommandResult<OrganizationInviteLink>> UpdateAsync(
UpdateInviteSupportConfirmRequest request)
{
if (!await OrganizationHasInviteLinksAbilityAsync(request.OrganizationId))
{
return new InviteLinkNotAvailable();
}

var inviteLink = await organizationInviteLinkRepository.GetByOrganizationIdAsync(request.OrganizationId);
if (inviteLink is null)
{
return new InviteLinkNotFound();
}

inviteLink.Invite = request.Invite;
inviteLink.SupportsConfirmation = request.SupportsConfirmation;
inviteLink.RevisionDate = timeProvider.GetUtcNow().UtcDateTime;

await organizationInviteLinkRepository.ReplaceAsync(inviteLink);

return inviteLink;
}

private async Task<bool> OrganizationHasInviteLinksAbilityAsync(Guid organizationId)
{
var ability = await organizationAbilityCacheService.GetOrganizationAbilityAsync(organizationId);
Comment on lines +21 to +40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ QUESTION: This mutating command does not log an organization audit event, unlike every sibling invite-link command.

Details

CreateOrganizationInviteLinkCommand, UpdateOrganizationInviteLinkCommand (Organization_InviteLinkDomainsEdited), RefreshOrganizationInviteLinkCommand, and DeleteOrganizationInviteLinkCommand all inject IEventService and log an EventType.Organization_InviteLink* event on success. This command changes the Invite blob and SupportsConfirmation flag but records nothing in the audit trail.

Is the missing audit event intentional (e.g., deferred to a follow-up, or intentionally out of scope), or should a new EventType be logged here for consistency with the other mutating operations?

return ability is not null && ability.UseInviteLinks;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
ο»Ώnamespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks;

public record UpdateInviteSupportConfirmRequest
{
public required Guid OrganizationId { get; init; }

/// <summary>
/// The invite link cryptographic blob.
/// </summary>
public required string Invite { get; init; }

/// <summary>
/// Whether this invite link can be used to confirm a user.
/// </summary>
public required bool SupportsConfirmation { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ private static void AddOrganizationInviteLinkCommandsQueries(this IServiceCollec
services.TryAddScoped<IValidateOrganizationInviteLinkEmailDomainQuery, ValidateOrganizationInviteLinkEmailDomainQuery>();
services.TryAddScoped<IGetOrganizationInviteLinkStatusQuery, GetOrganizationInviteLinkStatusQuery>();
services.TryAddScoped<IUpdateOrganizationInviteLinkCommand, UpdateOrganizationInviteLinkCommand>();
services.TryAddScoped<IUpdateInviteSupportConfirmCommand, UpdateInviteSupportConfirmCommand>();
services.TryAddScoped<IDeleteOrganizationInviteLinkCommand, DeleteOrganizationInviteLinkCommand>();
services.TryAddScoped<IRefreshOrganizationInviteLinkCommand, RefreshOrganizationInviteLinkCommand>();
services.TryAddScoped<IAcceptOrganizationInviteLinkCommand, AcceptOrganizationInviteLinkCommand>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,61 @@ public async Task CreateThenUpdateThenGet_AsOwner_ReturnsCreatedAndOkAndOk()
Assert.Equal(["example.com", "new.com"], content.AllowedDomains);
}

[Fact]
public async Task UpdateInviteSupportConfirmThenGet_AsOwner_UpdatesOnlyInviteAndSupportsConfirmation()
{
// Arrange
var createRequest = new CreateOrganizationInviteLinkRequestModel
{
AllowedDomains = ["acme.com"],
Invite = _invite,
SupportsConfirmation = false,
};

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);

const string updatedInvite = "updated-invite-blob";
var updateRequest = new UpdateInviteSupportConfirmRequestModel
{
Invite = updatedInvite,
SupportsConfirmation = true,
};

// Act
var updateResponse = await _client.PutAsJsonAsync(
$"/organizations/{_organization.Id}/invite-link/support-confirm", updateRequest);

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

var updated = await updateResponse.Content.ReadFromJsonAsync<OrganizationInviteLinkResponseModel>();
Assert.NotNull(updated);
Assert.Equal(created.Id, updated.Id);
Assert.Equal(created.Code, updated.Code);
Assert.Equal(_organization.Id, updated.OrganizationId);
Assert.Equal(updatedInvite, updated.Invite);
Assert.True(updated.SupportsConfirmation);
Assert.Equal(["acme.com"], updated.AllowedDomains);

var getResponse = await _client.GetAsync($"/organizations/{_organization.Id}/invite-link");

Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);

var content = await getResponse.Content.ReadFromJsonAsync<OrganizationInviteLinkResponseModel>();
Assert.NotNull(content);
Assert.Equal(created.Id, content.Id);
Assert.Equal(created.Code, content.Code);
Assert.Equal(updatedInvite, content.Invite);
Assert.True(content.SupportsConfirmation);
Assert.Equal(["acme.com"], content.AllowedDomains);
}

[Fact]
public async Task Delete_AsOwner_ReturnsNoContentAndRemovesLink()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,91 @@ public async Task Update_WithValidationError_Returns400(
Assert.NotNull(badRequestResult.Value);
}

[Theory, BitAutoData]
public async Task UpdateInviteSupportConfirm_WithValidInput_ReturnsOk(
Guid orgId,
OrganizationInviteLink inviteLink,
SutProvider<OrganizationInviteLinksController> sutProvider)
{
// Arrange
inviteLink.OrganizationId = orgId;
inviteLink.AllowedDomains = "[\"acme.com\"]";

var model = new UpdateInviteSupportConfirmRequestModel
{
Invite = "new-invite-blob",
SupportsConfirmation = true,
};

sutProvider.GetDependency<IUpdateInviteSupportConfirmCommand>()
.UpdateAsync(Arg.Any<UpdateInviteSupportConfirmRequest>())
.Returns(new CommandResult<OrganizationInviteLink>(inviteLink));

// Act
var result = await sutProvider.Sut.UpdateInviteSupportConfirm(orgId, model);

// Assert
var okResult = Assert.IsType<Ok<OrganizationInviteLinkResponseModel>>(result);
Assert.NotNull(okResult.Value);
Assert.Equal(inviteLink.Id, okResult.Value.Id);
Assert.Equal(orgId, okResult.Value.OrganizationId);

await sutProvider.GetDependency<IUpdateInviteSupportConfirmCommand>()
.Received(1)
.UpdateAsync(Arg.Is<UpdateInviteSupportConfirmRequest>(r =>
r.OrganizationId == orgId &&
r.Invite == "new-invite-blob" &&
r.SupportsConfirmation == true));
}

[Theory, BitAutoData]
public async Task UpdateInviteSupportConfirm_WhenNoLinkExists_ReturnsNotFound(
Guid orgId,
SutProvider<OrganizationInviteLinksController> sutProvider)
{
// Arrange
var model = new UpdateInviteSupportConfirmRequestModel
{
Invite = "new-invite-blob",
SupportsConfirmation = true,
};

sutProvider.GetDependency<IUpdateInviteSupportConfirmCommand>()
.UpdateAsync(Arg.Any<UpdateInviteSupportConfirmRequest>())
.Returns(new CommandResult<OrganizationInviteLink>(new InviteLinkNotFound()));

// Act
var result = await sutProvider.Sut.UpdateInviteSupportConfirm(orgId, model);

// Assert
var notFoundResult = Assert.IsType<NotFound<ErrorResponseModel>>(result);
Assert.NotNull(notFoundResult.Value);
}

[Theory, BitAutoData]
public async Task UpdateInviteSupportConfirm_WhenInviteLinkNotAvailable_Returns400(
Guid orgId,
SutProvider<OrganizationInviteLinksController> sutProvider)
{
// Arrange
var model = new UpdateInviteSupportConfirmRequestModel
{
Invite = "new-invite-blob",
SupportsConfirmation = true,
};

sutProvider.GetDependency<IUpdateInviteSupportConfirmCommand>()
.UpdateAsync(Arg.Any<UpdateInviteSupportConfirmRequest>())
.Returns(new CommandResult<OrganizationInviteLink>(new InviteLinkNotAvailable()));

// Act
var result = await sutProvider.Sut.UpdateInviteSupportConfirm(orgId, model);

// Assert
var badRequestResult = Assert.IsType<BadRequest<ErrorResponseModel>>(result);
Assert.NotNull(badRequestResult.Value);
}

[Theory, BitAutoData]
public async Task GetStatus_WithValidQuery_Success(
GetOrganizationInviteLinkStatusRequestModel model,
Expand Down
Loading
Loading