1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-02 16:42:50 -05:00

[PM-17830] Backend changes for admin initiated sponsorships (#5531)

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* Add `Notes` column to `OrganizationSponsorships` table

* Add feature flag to `CreateAdminInitiatedSponsorshipHandler`

* Unit tests for `CreateSponsorshipHandler`

* More tests for `CreateSponsorshipHandler`

* Forgot to add `Notes` column to `OrganizationSponsorships` table in the migration script

* `CreateAdminInitiatedSponsorshipHandler` unit tests

* Fix `CreateSponsorshipCommandTests`

* Encrypt the notes field

* Wrong business logic checking for invalid permissions.

* Wrong business logic checking for invalid permissions.

* Remove design patterns

* duplicate definition in Constants.cs

* Allow rollback

* Fix stored procedures & type

* Fix stored procedures & type

* Properly encapsulating this PR behind its feature flag

* Removed comments

* Updated ValidateSponsorshipCommand to validate admin initiated requirements

---------

Co-authored-by: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com>
Co-authored-by: Conner Turnbull <cturnbull@bitwarden.com>
This commit is contained in:
Jonas Hendrickx
2025-04-16 17:27:58 +02:00
committed by GitHub
parent f678e3db79
commit c182b37347
46 changed files with 10466 additions and 76 deletions

View File

@ -114,6 +114,11 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
/// </summary>
public bool UseRiskInsights { get; set; }
/// <summary>
/// If set to true, admins can initiate organization-issued sponsorships.
/// </summary>
public bool UseAdminSponsoredFamilies { get; set; }
public void SetNewId()
{
if (Id == default(Guid))

View File

@ -26,6 +26,7 @@ public class OrganizationAbility
LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UseRiskInsights = organization.UseRiskInsights;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
}
public Guid Id { get; set; }
@ -45,4 +46,5 @@ public class OrganizationAbility
public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; }
public bool UseRiskInsights { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
}

View File

@ -59,4 +59,5 @@ public class OrganizationUserOrganizationDetails
public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; }
public bool UseRiskInsights { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
}

View File

@ -45,5 +45,6 @@ public class ProviderUserOrganizationDetails
public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; }
public bool UseRiskInsights { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
public ProviderType ProviderType { get; set; }
}

View File

@ -141,6 +141,7 @@ public static class FeatureFlagKeys
/* Billing Team */
public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
public const string TrialPayment = "PM-8163-trial-payment";
public const string PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships";
public const string UsePricingService = "use-pricing-service";
public const string P15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal";
public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features";

View File

@ -20,6 +20,8 @@ public class OrganizationSponsorship : ITableObject<Guid>
public DateTime? LastSyncDate { get; set; }
public DateTime? ValidUntil { get; set; }
public bool ToDelete { get; set; }
public bool IsAdminInitiated { get; set; }
public string? Notes { get; set; }
public void SetNewId()
{

View File

@ -16,6 +16,8 @@ public class OrganizationSponsorshipData
LastSyncDate = sponsorship.LastSyncDate;
ValidUntil = sponsorship.ValidUntil;
ToDelete = sponsorship.ToDelete;
IsAdminInitiated = sponsorship.IsAdminInitiated;
Notes = sponsorship.Notes;
}
public Guid SponsoringOrganizationUserId { get; set; }
public Guid? SponsoredOrganizationId { get; set; }
@ -25,6 +27,8 @@ public class OrganizationSponsorshipData
public DateTime? LastSyncDate { get; set; }
public DateTime? ValidUntil { get; set; }
public bool ToDelete { get; set; }
public bool IsAdminInitiated { get; set; }
public string Notes { get; set; }
public bool CloudSponsorshipRemoved { get; set; }
}

View File

@ -112,6 +112,13 @@ public class ValidateSponsorshipCommand : CancelSponsorshipCommand, IValidateSpo
return false;
}
if (existingSponsorship.IsAdminInitiated && !sponsoringOrganization.UseAdminSponsoredFamilies)
{
_logger.LogWarning("Admin initiated sponsorship for sponsored Organization {SponsoredOrganizationId} is not allowed because sponsoring organization does not have UseAdminSponsoredFamilies enabled", sponsoredOrganizationId);
await CancelSponsorshipAsync(sponsoredOrganization, existingSponsorship);
return false;
}
var sponsoringOrgProductTier = sponsoringOrganization.PlanType.GetProductTier();
if (sponsoredPlan.SponsoringProductTierType != sponsoringOrgProductTier)

View File

@ -1,5 +1,6 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Extensions;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -10,29 +11,24 @@ using Bit.Core.Utilities;
namespace Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise;
public class CreateSponsorshipCommand : ICreateSponsorshipCommand
public class CreateSponsorshipCommand(
ICurrentContext currentContext,
IOrganizationSponsorshipRepository organizationSponsorshipRepository,
IUserService userService) : ICreateSponsorshipCommand
{
private readonly IOrganizationSponsorshipRepository _organizationSponsorshipRepository;
private readonly IUserService _userService;
public CreateSponsorshipCommand(IOrganizationSponsorshipRepository organizationSponsorshipRepository,
IUserService userService)
public async Task<OrganizationSponsorship> CreateSponsorshipAsync(Organization sponsoringOrganization,
OrganizationUser sponsoringMember, PlanSponsorshipType sponsorshipType, string sponsoredEmail,
string friendlyName, string notes)
{
_organizationSponsorshipRepository = organizationSponsorshipRepository;
_userService = userService;
}
var sponsoringUser = await userService.GetUserByIdAsync(sponsoringMember.UserId!.Value);
public async Task<OrganizationSponsorship> CreateSponsorshipAsync(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser,
PlanSponsorshipType sponsorshipType, string sponsoredEmail, string friendlyName)
{
var sponsoringUser = await _userService.GetUserByIdAsync(sponsoringOrgUser.UserId.Value);
if (sponsoringUser == null || string.Equals(sponsoringUser.Email, sponsoredEmail, System.StringComparison.InvariantCultureIgnoreCase))
if (sponsoringUser == null || string.Equals(sponsoringUser.Email, sponsoredEmail, StringComparison.InvariantCultureIgnoreCase))
{
throw new BadRequestException("Cannot offer a Families Organization Sponsorship to yourself. Choose a different email.");
}
var requiredSponsoringProductType = StaticStore.GetSponsoredPlan(sponsorshipType)?.SponsoringProductTierType;
var sponsoringOrgProductTier = sponsoringOrg.PlanType.GetProductTier();
var sponsoringOrgProductTier = sponsoringOrganization.PlanType.GetProductTier();
if (requiredSponsoringProductType == null ||
sponsoringOrgProductTier != requiredSponsoringProductType.Value)
@ -40,26 +36,24 @@ public class CreateSponsorshipCommand : ICreateSponsorshipCommand
throw new BadRequestException("Specified Organization cannot sponsor other organizations.");
}
if (sponsoringOrgUser == null || sponsoringOrgUser.Status != OrganizationUserStatusType.Confirmed)
if (sponsoringMember.Status != OrganizationUserStatusType.Confirmed)
{
throw new BadRequestException("Only confirmed users can sponsor other organizations.");
}
var existingOrgSponsorship = await _organizationSponsorshipRepository
.GetBySponsoringOrganizationUserIdAsync(sponsoringOrgUser.Id);
var existingOrgSponsorship = await organizationSponsorshipRepository
.GetBySponsoringOrganizationUserIdAsync(sponsoringMember.Id);
if (existingOrgSponsorship?.SponsoredOrganizationId != null)
{
throw new BadRequestException("Can only sponsor one organization per Organization User.");
}
var sponsorship = new OrganizationSponsorship
{
SponsoringOrganizationId = sponsoringOrg.Id,
SponsoringOrganizationUserId = sponsoringOrgUser.Id,
FriendlyName = friendlyName,
OfferedToEmail = sponsoredEmail,
PlanSponsorshipType = sponsorshipType,
};
var sponsorship = new OrganizationSponsorship();
sponsorship.SponsoringOrganizationId = sponsoringOrganization.Id;
sponsorship.SponsoringOrganizationUserId = sponsoringMember.Id;
sponsorship.FriendlyName = friendlyName;
sponsorship.OfferedToEmail = sponsoredEmail;
sponsorship.PlanSponsorshipType = sponsorshipType;
if (existingOrgSponsorship != null)
{
@ -67,16 +61,42 @@ public class CreateSponsorshipCommand : ICreateSponsorshipCommand
sponsorship.Id = existingOrgSponsorship.Id;
}
var isAdminInitiated = false;
if (currentContext.UserId != sponsoringMember.UserId)
{
var organization = currentContext.Organizations.First(x => x.Id == sponsoringOrganization.Id);
OrganizationUserType[] allowedUserTypes =
[
OrganizationUserType.Admin,
OrganizationUserType.Owner
];
if (!organization.Permissions.ManageUsers && allowedUserTypes.All(x => x != organization.Type))
{
throw new UnauthorizedAccessException("You do not have permissions to send sponsorships on behalf of the organization.");
}
if (!sponsoringOrganization.UseAdminSponsoredFamilies)
{
throw new BadRequestException("Sponsoring organization cannot sponsor other Family organizations.");
}
isAdminInitiated = true;
}
sponsorship.IsAdminInitiated = isAdminInitiated;
sponsorship.Notes = notes;
try
{
await _organizationSponsorshipRepository.UpsertAsync(sponsorship);
await organizationSponsorshipRepository.UpsertAsync(sponsorship);
return sponsorship;
}
catch
{
if (sponsorship.Id != default)
if (sponsorship.Id != Guid.Empty)
{
await _organizationSponsorshipRepository.DeleteAsync(sponsorship);
await organizationSponsorshipRepository.DeleteAsync(sponsorship);
}
throw;
}

View File

@ -7,5 +7,5 @@ namespace Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnte
public interface ICreateSponsorshipCommand
{
Task<OrganizationSponsorship> CreateSponsorshipAsync(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser,
PlanSponsorshipType sponsorshipType, string sponsoredEmail, string friendlyName);
PlanSponsorshipType sponsorshipType, string sponsoredEmail, string friendlyName, string notes);
}