-
Notifications
You must be signed in to change notification settings - Fork 1.6k
[PM-34387] Add organization invite link creation endpoint #7477
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7bebaf0
9fe8d6e
8ca6908
5fe0162
af214d4
b5ee183
39674bf
5fc96fd
a5683b1
49995c2
b20c1f3
8876cf6
687f839
c9aa1af
d0756cc
474905d
0d7f27a
779b7ac
562a687
ef2977d
c8b3409
49dd0fb
7077468
abd9cde
1db861e
641a0a7
d64c6e5
8c2fe7b
db439fc
b13ec9a
910d28b
f767bb3
3a3877f
bb7114e
73a02de
0900dc4
ebcefb1
f286229
dfa3d74
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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] | ||
| [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; } | ||
| } |
|
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(); | ||
| } | ||
|
|
||
|
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() ?? []; | ||
|
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); | ||
| } |
| 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<string></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; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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!); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.