Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
7bebaf0
Add ConflictError type
r-tome Apr 15, 2026
9fe8d6e
Add generic Handle<T> and extract MapError on BaseAdminConsoleController
r-tome Apr 15, 2026
8ca6908
Initialize Code property with a new GUID in OrganizationInviteLink class
r-tome Apr 15, 2026
5fe0162
Add ICreateOrganizationInviteLinkCommand interface
r-tome Apr 15, 2026
af214d4
Add CreateOrganizationInviteLinkRequest record for invite link creation
r-tome Apr 15, 2026
b5ee183
Add OrganizationInviteLink request and response models for invite lin…
r-tome Apr 15, 2026
39674bf
Refactor ICreateOrganizationInviteLinkCommand interface to use Create…
r-tome Apr 15, 2026
5fc96fd
Add CreateOrganizationInviteLinkCommand class to handle invite link c…
r-tome Apr 15, 2026
a5683b1
Add error handling for invite link creation with specific conflict an…
r-tome Apr 15, 2026
49995c2
Add OrganizationInviteLink service commands to OrganizationServiceCol…
r-tome Apr 15, 2026
b20c1f3
Add OrganizationInviteLinksController to manage invite link creation …
r-tome Apr 15, 2026
8876cf6
Add integration tests for OrganizationInviteLinksController and Creat…
r-tome Apr 15, 2026
687f839
Remove unnecessary blank line in OrganizationInviteLinksControllerTes…
r-tome Apr 15, 2026
c9aa1af
Refactor CreateOrganizationInviteLinkRequestModel to use required pro…
r-tome Apr 16, 2026
d0756cc
Update CreateOrganizationInviteLinkCommand to validate allowed domain…
r-tome Apr 16, 2026
474905d
Add encryption validation attributes to CreateOrganizationInviteLinkR…
r-tome Apr 16, 2026
0d7f27a
Refactor OrganizationInviteLink to encapsulate AllowedDomains seriali…
r-tome Apr 16, 2026
779b7ac
Enhance domain sanitization in CreateOrganizationInviteLinkCommand by…
r-tome Apr 16, 2026
562a687
Update OrganizationInviteLinksControllerTests to use a valid encrypte…
r-tome Apr 16, 2026
ef2977d
Add ability check for organization invite links in CreateOrganization…
r-tome Apr 22, 2026
c8b3409
Add documentation for Code property in OrganizationInviteLink class
r-tome Apr 22, 2026
49dd0fb
Implement domain validation in CreateOrganizationInviteLinkRequestModel
r-tome Apr 22, 2026
7077468
Remove outdated tests from CreateOrganizationInviteLinkRequestModelTests
r-tome Apr 22, 2026
abd9cde
Refactor GetAllowedDomains method in OrganizationInviteLink class
r-tome Apr 22, 2026
1db861e
Remove unused InviteLinkInvalidDomains error type from Errors.cs
r-tome Apr 22, 2026
641a0a7
Update OrganizationServiceCollectionExtensions to use TryAddScoped fo…
r-tome Apr 22, 2026
d64c6e5
Mock organization ability retrieval in OrganizationInviteLinksControl…
r-tome Apr 23, 2026
8c2fe7b
Add ValidateSequenceAttribute for collection validation and correspon…
r-tome Apr 28, 2026
db439fc
Refactor CreateOrganizationInviteLinkRequestModel to use ValidateSequ…
r-tome Apr 28, 2026
b13ec9a
Enhance ValidateSequenceAttribute to handle null values and improve e…
r-tome Apr 29, 2026
910d28b
Add empty line
r-tome Apr 29, 2026
f767bb3
Refactor ValidateSequenceAttribute to support IEnumerable interface f…
r-tome Apr 29, 2026
3a3877f
Refactor ValidateSequenceAttribute to improve validation logic and er…
r-tome Apr 30, 2026
bb7114e
Remove unused using directive for Microsoft.AspNetCore.Http.HttpResul…
r-tome Apr 30, 2026
73a02de
Add MinLength validation to AllowedDomains in CreateOrganizationInvit…
r-tome Apr 30, 2026
0900dc4
Refactor CreateOrganizationInviteLinkCommandTests to move SetupAbilit…
r-tome Apr 30, 2026
ebcefb1
Add error handling methods in BaseAdminConsoleController for improved…
r-tome Apr 30, 2026
f286229
Update CreateOrganizationInviteLinkRequestModelTests to use array ini…
r-tome Apr 30, 2026
dfa3d74
Refactor OrganizationInviteLinkResponseModel constructor for improved…
r-tome Apr 30, 2026
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
42 changes: 35 additions & 7 deletions src/Api/AdminConsole/Controllers/BaseAdminConsoleController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,34 @@
using Bit.Core.Models.Api;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using CommandError = Bit.Core.AdminConsole.Utilities.v2.Error;

namespace Bit.Api.AdminConsole.Controllers;

public abstract class BaseAdminConsoleController : Controller
{
/// <summary>
/// Maps a void <see cref="CommandResult"/> to an HTTP response.
/// Returns 204 No Content on success, or the appropriate error status code on failure.
/// </summary>
protected static IResult Handle(CommandResult commandResult) =>
commandResult.Match<IResult>(
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()
);

/// <summary>
/// Maps a <see cref="CommandResult{T}"/> to an HTTP response.
/// On success, delegates to <paramref name="success"/> so the caller can choose the response shape
/// (e.g. <c>TypedResults.Created</c> for POST, <c>TypedResults.Ok</c> for GET/PUT).
/// On failure, returns the appropriate error status code.
/// </summary>
protected static IResult Handle<T>(CommandResult<T> commandResult, Func<T, IResult> success) =>
commandResult.Match<IResult>(
error => MapError(error),
success
);

protected static class Error
{
public static NotFound<ErrorResponseModel> NotFound(string message = "Resource not found.") =>
Expand All @@ -37,4 +48,21 @@ public static JsonHttpResult<ErrorResponseModel> InternalError(
new ErrorResponseModel(message),
statusCode: StatusCodes.Status500InternalServerError);
}

private static IResult MapError(CommandError 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
)
};
}
Original file line number Diff line number Diff line change
@@ -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<ManageUsersRequirement>]
public async Task<IResult> 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)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
ο»Ώusing System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks;
using Bit.Core.Utilities;

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

public class CreateOrganizationInviteLinkRequestModel
{
/// <summary>
/// Email domains permitted to accept the invite link (e.g. <c>["acme.com"]</c>).
/// </summary>
[Required]
[MinLength(1)]
[ValidateSequence<DomainNameValidatorAttribute>]
public required IEnumerable<string> AllowedDomains { get; set; }

/// <summary>
/// The invite key encrypted with the organization key.
/// </summary>
[Required]
Comment thread
eliykat marked this conversation as resolved.
[EncryptedString]
public required string EncryptedInviteKey { get; set; }

/// <summary>
/// The organization key encrypted for the invite link. Currently unused; will be populated in a future stage.
/// </summary>
[EncryptedString]
public string? EncryptedOrgKey { get; set; }

public CreateOrganizationInviteLinkRequest ToCommandRequest(Guid organizationId) => new()
{
OrganizationId = organizationId,
AllowedDomains = AllowedDomains,
EncryptedInviteKey = EncryptedInviteKey,
EncryptedOrgKey = EncryptedOrgKey,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
ο»Ώ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;
AllowedDomains = inviteLink.GetAllowedDomains();
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<string> AllowedDomains { get; set; } = [];
public string EncryptedInviteKey { get; set; } = null!;
public string? EncryptedOrgKey { get; set; }
public DateTime CreationDate { get; set; }
}
17 changes: 15 additions & 2 deletions src/Core/AdminConsole/Entities/OrganizationInviteLink.cs
Original file line number Diff line number Diff line change
@@ -1,19 +1,32 @@
ο»Ώusing Bit.Core.Entities;
ο»Ώusing System.Text.Json;
using Bit.Core.Entities;
using Bit.Core.Utilities;

namespace Bit.Core.AdminConsole.Entities;

public class OrganizationInviteLink : ITableObject<Guid>
{
public Guid Id { get; set; }
public Guid Code { get; set; }
/// <summary>
/// A random, publicly shareable code used to identify the invite link.
/// Uses <see cref="Guid.NewGuid"/> 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.
/// </summary>
public Guid Code { get; set; } = Guid.NewGuid();
Comment thread
eliykat marked this conversation as resolved.
public Guid OrganizationId { get; set; }
public string AllowedDomains { get; set; } = null!;
public string EncryptedInviteKey { get; set; } = null!;
public string? EncryptedOrgKey { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;

public IEnumerable<string> GetAllowedDomains() =>
JsonSerializer.Deserialize<IEnumerable<string>>(AllowedDomains) ?? [];

public void SetAllowedDomains(IEnumerable<string> domains) =>
AllowedDomains = JsonSerializer.Serialize(domains);

public void SetNewId()
{
if (Id == default)
Expand Down
Comment thread
eliykat marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
ο»Ώusing Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Utilities.v2.Results;
using Bit.Core.Services;

namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks;

public class CreateOrganizationInviteLinkCommand(
IOrganizationInviteLinkRepository organizationInviteLinkRepository,
IApplicationCacheService applicationCacheService,
TimeProvider timeProvider)
: ICreateOrganizationInviteLinkCommand
{
public async Task<CommandResult<OrganizationInviteLink>> CreateAsync(
CreateOrganizationInviteLinkRequest request)
{
if (!await OrganizationHasInviteLinksAbilityAsync(request.OrganizationId))
{
return new InviteLinkNotAvailable();
}

var sanitizedDomains = SanitizeDomains(request.AllowedDomains);
if (sanitizedDomains.Count == 0)
{
return new InviteLinkDomainsRequired();
}

var existingLink = await organizationInviteLinkRepository.GetByOrganizationIdAsync(request.OrganizationId);
if (existingLink != null)
{
return new InviteLinkAlreadyExists();
}

Comment thread
eliykat marked this conversation as resolved.
var now = timeProvider.GetUtcNow().UtcDateTime;
var inviteLink = new OrganizationInviteLink
{
OrganizationId = request.OrganizationId,
EncryptedInviteKey = request.EncryptedInviteKey,
EncryptedOrgKey = request.EncryptedOrgKey,
CreationDate = now,
RevisionDate = now,
};
inviteLink.SetAllowedDomains(sanitizedDomains);
inviteLink.SetNewId();

await organizationInviteLinkRepository.CreateAsync(inviteLink);

return inviteLink;
}

private async Task<bool> OrganizationHasInviteLinksAbilityAsync(Guid organizationId)
{
var ability = await applicationCacheService.GetOrganizationAbilityAsync(organizationId);
return ability is not null && ability.UseInviteLinks;
}

/// <summary>
/// Normalizes domains to lowercase and removes blank entries.
/// </summary>
private static List<string> SanitizeDomains(IEnumerable<string>? domains) =>
domains?
.Select(d => d?.Trim().ToLowerInvariant())
.Where(d => !string.IsNullOrEmpty(d))
.Cast<string>()
.ToList() ?? [];
Comment thread
eliykat marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
ο»Ώnamespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks;

public record CreateOrganizationInviteLinkRequest
{
public required Guid OrganizationId { get; init; }
public required IEnumerable<string> AllowedDomains { get; init; }
public required string EncryptedInviteKey { get; init; }

/// <summary>
/// The organization key encrypted for the invite link. Currently unused; will be populated in a future stage.
/// </summary>
public string? EncryptedOrgKey { get; init; }
}
Original file line number Diff line number Diff line change
@@ -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 InviteLinkNotAvailable()
: BadRequestError("Your organization's plan does not support invite links.");
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
ο»Ώusing Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Utilities.v2.Results;

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

public interface ICreateOrganizationInviteLinkCommand
{
/// <summary>
/// Creates a new invite link for the specified organization.
/// </summary>
/// <param name="request">The details for the invite link to create.</param>
/// <returns>The created <see cref="OrganizationInviteLink"/>, or an error if validation fails or a link already exists.</returns>
Task<CommandResult<OrganizationInviteLink>> CreateAsync(CreateOrganizationInviteLinkRequest request);
}
1 change: 1 addition & 0 deletions src/Core/AdminConsole/Utilities/v2/Errors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -45,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;
Expand All @@ -64,6 +67,7 @@ public static void AddOrganizationServices(this IServiceCollection services, IGl
services.AddOrganizationApiKeyCommandsQueries();
services.AddOrganizationCollectionCommands();
services.AddOrganizationGroupCommands();
services.AddOrganizationInviteLinkCommands();
services.AddOrganizationDomainCommandsQueries();
services.AddOrganizationSignUpCommands();
services.AddOrganizationDeleteCommands();
Expand Down Expand Up @@ -187,6 +191,11 @@ private static void AddOrganizationGroupCommands(this IServiceCollection service
services.AddScoped<IUpdateGroupCommand, UpdateGroupCommand>();
}

private static void AddOrganizationInviteLinkCommands(this IServiceCollection services)
{
services.TryAddScoped<ICreateOrganizationInviteLinkCommand, CreateOrganizationInviteLinkCommand>();
}

private static void AddOrganizationDomainCommandsQueries(this IServiceCollection services)
{
services.AddScoped<ICreateOrganizationDomainCommand, CreateOrganizationDomainCommand>();
Expand Down
42 changes: 42 additions & 0 deletions src/Core/Utilities/ValidateSequenceAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
ο»Ώusing System.ComponentModel.DataAnnotations;
using System.Globalization;

namespace Bit.Core.Utilities;

/// <summary>
/// Validates each element of a collection using <typeparamref name="TValidator"/>.
/// The property must be <see cref="IEnumerable{T}"/> of a reference type (e.g. <c>IEnumerable&lt;string&gt;</c>).
/// An empty collection passes validation; use <c>[MinLength(1)]</c> if an empty collection should be invalid.
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)]
public class ValidateSequenceAttribute<TValidator> : ValidationAttribute
where TValidator : ValidationAttribute, new()
{
private const string _invalidItemsMessage = "The following items are not valid: {0}";

protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
if (value is null)
{
return ValidationResult.Success;
Copy link
Copy Markdown
Member

@eliykat eliykat Apr 30, 2026

Choose a reason for hiding this comment

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

It's not clear whether an empty list should pass validation or not. In our case it's invalid because the user is meant to supply at least 1 domain name, but it really depends on the business logic.

I think your approach is fine because it matches the behaviour of LINQ .All(). But we should mention it in the xmldoc. e.g. An empty list will always pass validation. Add additional validation if an empty list is invalid. This is our situation because your command checks it, and/or you can use minLength. It just needs to be made clear to callers.

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.

Clarified that in the xmldoc

}

if (value is not IEnumerable<object> items)
{
throw new ArgumentException("ValidateSequenceAttribute can only be used with IEnumerable<T> properties.");
}

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 = string.Format(CultureInfo.InvariantCulture, _invalidItemsMessage, string.Join(", ", invalid.Select(value => $"'{value}'")));

return new ValidationResult(message, memberNames!);
}
}
Loading
Loading