diff --git a/src/Api/AdminConsole/Controllers/BaseAdminConsoleController.cs b/src/Api/AdminConsole/Controllers/BaseAdminConsoleController.cs
index 6fd741bf3a5a..33efdb0c8253 100644
--- a/src/Api/AdminConsole/Controllers/BaseAdminConsoleController.cs
+++ b/src/Api/AdminConsole/Controllers/BaseAdminConsoleController.cs
@@ -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
{
+ ///
+ /// 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()
);
+ ///
+ /// 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
+ );
+
protected static class Error
{
public static NotFound NotFound(string message = "Resource not found.") =>
@@ -37,4 +48,21 @@ public static JsonHttpResult 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
+ )
+ };
}
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)));
+ }
+}
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..1070525c5949
--- /dev/null
+++ b/src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs
@@ -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
+{
+ ///
+ /// Email domains permitted to accept the invite link (e.g. ["acme.com"]).
+ ///
+ [Required]
+ [MinLength(1)]
+ [ValidateSequence]
+ public required IEnumerable AllowedDomains { get; set; }
+
+ ///
+ /// 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()
+ {
+ 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..9988db033e63
--- /dev/null
+++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationInviteLinkResponseModel.cs
@@ -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 AllowedDomains { get; set; } = [];
+ public string EncryptedInviteKey { get; set; } = null!;
+ public string? EncryptedOrgKey { get; set; }
+ public DateTime CreationDate { get; set; }
+}
diff --git a/src/Core/AdminConsole/Entities/OrganizationInviteLink.cs b/src/Core/AdminConsole/Entities/OrganizationInviteLink.cs
index 4000a1d44de2..26883a424de9 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;
@@ -6,7 +7,13 @@ namespace Bit.Core.AdminConsole.Entities;
public class OrganizationInviteLink : ITableObject
{
public Guid Id { get; set; }
- public Guid Code { 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!;
public string EncryptedInviteKey { get; set; } = null!;
@@ -14,6 +21,12 @@ public class OrganizationInviteLink : ITableObject
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
+ public IEnumerable GetAllowedDomains() =>
+ JsonSerializer.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
new file mode 100644
index 000000000000..cd3cbdc36c1f
--- /dev/null
+++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs
@@ -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> 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();
+ }
+
+ 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 OrganizationHasInviteLinksAbilityAsync(Guid organizationId)
+ {
+ var ability = await applicationCacheService.GetOrganizationAbilityAsync(organizationId);
+ return ability is not null && ability.UseInviteLinks;
+ }
+
+ ///
+ /// Normalizes domains to lowercase and removes blank entries.
+ ///
+ private static List SanitizeDomains(IEnumerable? domains) =>
+ domains?
+ .Select(d => d?.Trim().ToLowerInvariant())
+ .Where(d => !string.IsNullOrEmpty(d))
+ .Cast()
+ .ToList() ?? [];
+}
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; }
+}
diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Errors.cs
new file mode 100644
index 000000000000..375903db92b7
--- /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 InviteLinkNotAvailable()
+ : BadRequestError("Your organization's plan does not support invite links.");
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..126cb5843273
--- /dev/null
+++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/ICreateOrganizationInviteLinkCommand.cs
@@ -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
+{
+ ///
+ /// Creates a new invite link for the specified organization.
+ ///
+ /// The details for the invite link to create.
+ /// The created , or an error if validation fails or a link already exists.
+ Task> CreateAsync(CreateOrganizationInviteLinkRequest request);
+}
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);
diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs
index 71edf1c79795..d732f7582dca 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;
@@ -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;
@@ -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();
@@ -187,6 +191,11 @@ private static void AddOrganizationGroupCommands(this IServiceCollection service
services.AddScoped();
}
+ private static void AddOrganizationInviteLinkCommands(this IServiceCollection services)
+ {
+ services.TryAddScoped();
+ }
+
private static void AddOrganizationDomainCommandsQueries(this IServiceCollection services)
{
services.AddScoped();
diff --git a/src/Core/Utilities/ValidateSequenceAttribute.cs b/src/Core/Utilities/ValidateSequenceAttribute.cs
new file mode 100644
index 000000000000..e50b39ecc001
--- /dev/null
+++ b/src/Core/Utilities/ValidateSequenceAttribute.cs
@@ -0,0 +1,42 @@
+using System.ComponentModel.DataAnnotations;
+using System.Globalization;
+
+namespace Bit.Core.Utilities;
+
+///
+/// Validates each element of a collection using .
+/// The property must be of a reference type (e.g. IEnumerable<string>).
+/// An empty collection passes validation; use [MinLength(1)] if an empty collection should be invalid.
+///
+[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)]
+public class ValidateSequenceAttribute : 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;
+ }
+
+ if (value is not IEnumerable