1
0
mirror of https://github.com/bitwarden/server.git synced 2025-06-09 12:40:32 -05:00

Merge branch 'main' into test-docker-stuff

This commit is contained in:
tangowithfoxtrot 2025-04-23 09:25:55 -07:00
commit def622f77d
No known key found for this signature in database
224 changed files with 29955 additions and 1028 deletions

2
.github/CODEOWNERS vendored
View File

@ -37,6 +37,8 @@ util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev
**/Auth @bitwarden/team-auth-dev **/Auth @bitwarden/team-auth-dev
bitwarden_license/src/Sso @bitwarden/team-auth-dev bitwarden_license/src/Sso @bitwarden/team-auth-dev
src/Identity @bitwarden/team-auth-dev src/Identity @bitwarden/team-auth-dev
src/Core/Identity @bitwarden/team-auth-dev
src/Core/IdentityServer @bitwarden/team-auth-dev
# Key Management team # Key Management team
**/KeyManagement @bitwarden/team-key-management-dev **/KeyManagement @bitwarden/team-key-management-dev

View File

@ -9,6 +9,19 @@
"nuget", "nuget",
], ],
packageRules: [ packageRules: [
{
// Group all release-related workflows for GitHub Actions together for BRE.
groupName: "github-action",
matchManagers: ["github-actions"],
matchFileNames: [
".github/workflows/publish.yml",
".github/workflows/release.yml",
".github/workflows/repository-management.yml"
],
commitMessagePrefix: "[deps] BRE:",
reviewers: ["team:dept-bre"],
addLabels: ["hold"]
},
{ {
groupName: "dockerfile minor", groupName: "dockerfile minor",
matchManagers: ["dockerfile"], matchManagers: ["dockerfile"],

View File

@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Version>2025.4.1</Version> <Version>2025.4.3</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace> <RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>

View File

@ -8,7 +8,7 @@ using Bit.Core.Utilities;
using Bit.Scim.Context; using Bit.Scim.Context;
using Bit.Scim.Utilities; using Bit.Scim.Utilities;
using Bit.SharedWeb.Utilities; using Bit.SharedWeb.Utilities;
using Duende.IdentityModel; using IdentityModel;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using Stripe; using Stripe;

View File

@ -3,7 +3,7 @@ using System.Text.Encodings.Web;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Scim.Context; using Bit.Scim.Context;
using Duende.IdentityModel; using IdentityModel;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;

View File

@ -19,10 +19,10 @@ using Bit.Core.Tokens;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Sso.Models; using Bit.Sso.Models;
using Bit.Sso.Utilities; using Bit.Sso.Utilities;
using Duende.IdentityModel;
using Duende.IdentityServer; using Duende.IdentityServer;
using Duende.IdentityServer.Services; using Duende.IdentityServer.Services;
using Duende.IdentityServer.Stores; using Duende.IdentityServer.Stores;
using IdentityModel;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;

View File

@ -7,9 +7,9 @@ using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Sso.Models; using Bit.Sso.Models;
using Bit.Sso.Utilities; using Bit.Sso.Utilities;
using Duende.IdentityModel;
using Duende.IdentityServer; using Duende.IdentityServer;
using Duende.IdentityServer.Infrastructure; using Duende.IdentityServer.Infrastructure;
using IdentityModel;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;

0
dev/ef_migrate.ps1 Normal file → Executable file
View File

View File

@ -25,6 +25,12 @@
"Subscriptions": [ "Subscriptions": [
{ {
"Name": "events-write-subscription" "Name": "events-write-subscription"
},
{
"Name": "events-slack-subscription"
},
{
"Name": "events-webhook-subscription"
} }
] ]
} }

View File

@ -462,6 +462,7 @@ public class OrganizationsController : Controller
organization.UsersGetPremium = model.UsersGetPremium; organization.UsersGetPremium = model.UsersGetPremium;
organization.UseSecretsManager = model.UseSecretsManager; organization.UseSecretsManager = model.UseSecretsManager;
organization.UseRiskInsights = model.UseRiskInsights; organization.UseRiskInsights = model.UseRiskInsights;
organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies;
//secrets //secrets
organization.SmSeats = model.SmSeats; organization.SmSeats = model.SmSeats;

View File

@ -3,11 +3,13 @@ using System.Net;
using Bit.Admin.AdminConsole.Models; using Bit.Admin.AdminConsole.Models;
using Bit.Admin.Enums; using Bit.Admin.Enums;
using Bit.Admin.Utilities; using Bit.Admin.Utilities;
using Bit.Core;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities; using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
@ -23,6 +25,7 @@ using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Stripe;
namespace Bit.Admin.AdminConsole.Controllers; namespace Bit.Admin.AdminConsole.Controllers;
@ -44,6 +47,7 @@ public class ProvidersController : Controller
private readonly IProviderPlanRepository _providerPlanRepository; private readonly IProviderPlanRepository _providerPlanRepository;
private readonly IProviderBillingService _providerBillingService; private readonly IProviderBillingService _providerBillingService;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
private readonly IStripeAdapter _stripeAdapter;
private readonly string _stripeUrl; private readonly string _stripeUrl;
private readonly string _braintreeMerchantUrl; private readonly string _braintreeMerchantUrl;
private readonly string _braintreeMerchantId; private readonly string _braintreeMerchantId;
@ -63,7 +67,8 @@ public class ProvidersController : Controller
IProviderPlanRepository providerPlanRepository, IProviderPlanRepository providerPlanRepository,
IProviderBillingService providerBillingService, IProviderBillingService providerBillingService,
IWebHostEnvironment webHostEnvironment, IWebHostEnvironment webHostEnvironment,
IPricingClient pricingClient) IPricingClient pricingClient,
IStripeAdapter stripeAdapter)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationService = organizationService; _organizationService = organizationService;
@ -79,6 +84,7 @@ public class ProvidersController : Controller
_providerPlanRepository = providerPlanRepository; _providerPlanRepository = providerPlanRepository;
_providerBillingService = providerBillingService; _providerBillingService = providerBillingService;
_pricingClient = pricingClient; _pricingClient = pricingClient;
_stripeAdapter = stripeAdapter;
_stripeUrl = webHostEnvironment.GetStripeUrl(); _stripeUrl = webHostEnvironment.GetStripeUrl();
_braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl(); _braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl();
_braintreeMerchantId = globalSettings.Braintree.MerchantId; _braintreeMerchantId = globalSettings.Braintree.MerchantId;
@ -306,6 +312,23 @@ public class ProvidersController : Controller
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: model.EnterpriseMonthlySeatMinimum) (Plan: PlanType.EnterpriseMonthly, SeatsMinimum: model.EnterpriseMonthlySeatMinimum)
]); ]);
await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand); await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand);
if (_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically))
{
var customer = await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId);
if (model.PayByInvoice != customer.ApprovedToPayByInvoice())
{
var approvedToPayByInvoice = model.PayByInvoice ? "1" : "0";
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
{
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.InvoiceApproved] = approvedToPayByInvoice
}
});
}
}
break; break;
case ProviderType.BusinessUnit: case ProviderType.BusinessUnit:
{ {
@ -345,14 +368,18 @@ public class ProvidersController : Controller
if (!provider.IsBillable()) if (!provider.IsBillable())
{ {
return new ProviderEditModel(provider, users, providerOrganizations, new List<ProviderPlan>()); return new ProviderEditModel(provider, users, providerOrganizations, new List<ProviderPlan>(), false);
} }
var providerPlans = await _providerPlanRepository.GetByProviderId(id); var providerPlans = await _providerPlanRepository.GetByProviderId(id);
var payByInvoice =
_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) &&
(await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId)).ApprovedToPayByInvoice();
return new ProviderEditModel( return new ProviderEditModel(
provider, users, providerOrganizations, provider, users, providerOrganizations,
providerPlans.ToList(), GetGatewayCustomerUrl(provider), GetGatewaySubscriptionUrl(provider)); providerPlans.ToList(), payByInvoice, GetGatewayCustomerUrl(provider), GetGatewaySubscriptionUrl(provider));
} }
[RequirePermission(Permission.Provider_ResendEmailInvite)] [RequirePermission(Permission.Provider_ResendEmailInvite)]

View File

@ -86,6 +86,7 @@ public class OrganizationEditModel : OrganizationViewModel
UseApi = org.UseApi; UseApi = org.UseApi;
UseSecretsManager = org.UseSecretsManager; UseSecretsManager = org.UseSecretsManager;
UseRiskInsights = org.UseRiskInsights; UseRiskInsights = org.UseRiskInsights;
UseAdminSponsoredFamilies = org.UseAdminSponsoredFamilies;
UseResetPassword = org.UseResetPassword; UseResetPassword = org.UseResetPassword;
SelfHost = org.SelfHost; SelfHost = org.SelfHost;
UsersGetPremium = org.UsersGetPremium; UsersGetPremium = org.UsersGetPremium;
@ -154,6 +155,8 @@ public class OrganizationEditModel : OrganizationViewModel
public new bool UseSecretsManager { get; set; } public new bool UseSecretsManager { get; set; }
[Display(Name = "Risk Insights")] [Display(Name = "Risk Insights")]
public new bool UseRiskInsights { get; set; } public new bool UseRiskInsights { get; set; }
[Display(Name = "Admin Sponsored Families")]
public bool UseAdminSponsoredFamilies { get; set; }
[Display(Name = "Self Host")] [Display(Name = "Self Host")]
public bool SelfHost { get; set; } public bool SelfHost { get; set; }
[Display(Name = "Users Get Premium")] [Display(Name = "Users Get Premium")]
@ -295,6 +298,7 @@ public class OrganizationEditModel : OrganizationViewModel
existingOrganization.UseApi = UseApi; existingOrganization.UseApi = UseApi;
existingOrganization.UseSecretsManager = UseSecretsManager; existingOrganization.UseSecretsManager = UseSecretsManager;
existingOrganization.UseRiskInsights = UseRiskInsights; existingOrganization.UseRiskInsights = UseRiskInsights;
existingOrganization.UseAdminSponsoredFamilies = UseAdminSponsoredFamilies;
existingOrganization.UseResetPassword = UseResetPassword; existingOrganization.UseResetPassword = UseResetPassword;
existingOrganization.SelfHost = SelfHost; existingOrganization.SelfHost = SelfHost;
existingOrganization.UsersGetPremium = UsersGetPremium; existingOrganization.UsersGetPremium = UsersGetPremium;

View File

@ -18,6 +18,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
IEnumerable<ProviderUserUserDetails> providerUsers, IEnumerable<ProviderUserUserDetails> providerUsers,
IEnumerable<ProviderOrganizationOrganizationDetails> organizations, IEnumerable<ProviderOrganizationOrganizationDetails> organizations,
IReadOnlyCollection<ProviderPlan> providerPlans, IReadOnlyCollection<ProviderPlan> providerPlans,
bool payByInvoice,
string gatewayCustomerUrl = null, string gatewayCustomerUrl = null,
string gatewaySubscriptionUrl = null) : base(provider, providerUsers, organizations, providerPlans) string gatewaySubscriptionUrl = null) : base(provider, providerUsers, organizations, providerPlans)
{ {
@ -33,6 +34,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
GatewayCustomerUrl = gatewayCustomerUrl; GatewayCustomerUrl = gatewayCustomerUrl;
GatewaySubscriptionUrl = gatewaySubscriptionUrl; GatewaySubscriptionUrl = gatewaySubscriptionUrl;
Type = provider.Type; Type = provider.Type;
PayByInvoice = payByInvoice;
if (Type == ProviderType.BusinessUnit) if (Type == ProviderType.BusinessUnit)
{ {
@ -62,6 +64,8 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
public string GatewaySubscriptionId { get; set; } public string GatewaySubscriptionId { get; set; }
public string GatewayCustomerUrl { get; } public string GatewayCustomerUrl { get; }
public string GatewaySubscriptionUrl { get; } public string GatewaySubscriptionUrl { get; }
[Display(Name = "Pay By Invoice")]
public bool PayByInvoice { get; set; }
[Display(Name = "Provider Type")] [Display(Name = "Provider Type")]
public ProviderType Type { get; set; } public ProviderType Type { get; set; }

View File

@ -136,6 +136,17 @@
</div> </div>
</div> </div>
</div> </div>
@if (FeatureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) && Model.Provider.Type == ProviderType.Msp && Model.Provider.IsBillable())
{
<div class="row">
<div class="col-sm">
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" asp-for="PayByInvoice">
<label class="form-check-label" asp-for="PayByInvoice"></label>
</div>
</div>
</div>
}
} }
</form> </form>
@await Html.PartialAsync("Organizations", Model) @await Html.PartialAsync("Organizations", Model)

View File

@ -1,9 +1,11 @@
@using Bit.Admin.Enums; @using Bit.Admin.Enums;
@using Bit.Core
@using Bit.Core.Enums @using Bit.Core.Enums
@using Bit.Core.AdminConsole.Enums.Provider @using Bit.Core.AdminConsole.Enums.Provider
@using Bit.Core.Billing.Enums @using Bit.Core.Billing.Enums
@using Bit.SharedWeb.Utilities @using Bit.SharedWeb.Utilities
@inject Bit.Admin.Services.IAccessControlService AccessControlService; @inject Bit.Admin.Services.IAccessControlService AccessControlService;
@inject Bit.Core.Services.IFeatureService FeatureService
@model OrganizationEditModel @model OrganizationEditModel
@ -146,6 +148,13 @@
<input type="checkbox" class="form-check-input" asp-for="UseCustomPermissions" disabled='@(canEditPlan ? null : "disabled")'> <input type="checkbox" class="form-check-input" asp-for="UseCustomPermissions" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseCustomPermissions"></label> <label class="form-check-label" asp-for="UseCustomPermissions"></label>
</div> </div>
@if(FeatureService.IsEnabled(FeatureFlagKeys.PM17772_AdminInitiatedSponsorships))
{
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseAdminSponsoredFamilies" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label>
</div>
}
</div> </div>
<div class="col-3"> <div class="col-3">
<h3>Password Manager</h3> <h3>Password Manager</h3>

View File

@ -0,0 +1,21 @@
#nullable enable
using Microsoft.AspNetCore.Authorization;
namespace Bit.Api.AdminConsole.Authorization;
/// <summary>
/// An attribute which requires authorization using the specified requirement.
/// This uses the standard ASP.NET authorization middleware.
/// </summary>
/// <typeparam name="T">The IAuthorizationRequirement that will be used to authorize the user.</typeparam>
public class AuthorizeAttribute<T>
: AuthorizeAttribute, IAuthorizationRequirementData
where T : IAuthorizationRequirement, new()
{
public IEnumerable<IAuthorizationRequirement> GetRequirements()
{
var requirement = new T();
return [requirement];
}
}

View File

@ -0,0 +1,79 @@
#nullable enable
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.Repositories;
namespace Bit.Api.AdminConsole.Authorization;
public static class HttpContextExtensions
{
public const string NoOrgIdError =
"A route decorated with with '[Authorize<Requirement>]' must include a route value named 'orgId' either through the [Controller] attribute or through a '[Http*]' attribute.";
/// <summary>
/// Returns the result of the callback, caching it in HttpContext.Features for the lifetime of the request.
/// Subsequent calls will retrieve the cached value.
/// Results are stored by type and therefore must be of a unique type.
/// </summary>
public static async Task<T> WithFeaturesCacheAsync<T>(this HttpContext httpContext, Func<Task<T>> callback)
{
var cachedResult = httpContext.Features.Get<T>();
if (cachedResult != null)
{
return cachedResult;
}
var result = await callback();
httpContext.Features.Set(result);
return result;
}
/// <summary>
/// Returns true if the user is a ProviderUser for a Provider which manages the specified organization, otherwise false.
/// </summary>
/// <remarks>
/// This data is fetched from the database and cached as a HttpContext Feature for the lifetime of the request.
/// </remarks>
public static async Task<bool> IsProviderUserForOrgAsync(
this HttpContext httpContext,
IProviderUserRepository providerUserRepository,
Guid userId,
Guid organizationId)
{
var organizations = await httpContext.GetProviderUserOrganizationsAsync(providerUserRepository, userId);
return organizations.Any(o => o.OrganizationId == organizationId);
}
/// <summary>
/// Returns the ProviderUserOrganizations for a user. These are the organizations the ProviderUser manages via their Provider, if any.
/// </summary>
/// <remarks>
/// This data is fetched from the database and cached as a HttpContext Feature for the lifetime of the request.
/// </remarks>
private static async Task<IEnumerable<ProviderUserOrganizationDetails>> GetProviderUserOrganizationsAsync(
this HttpContext httpContext,
IProviderUserRepository providerUserRepository,
Guid userId)
=> await httpContext.WithFeaturesCacheAsync(() =>
providerUserRepository.GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed));
/// <summary>
/// Parses the {orgId} route parameter into a Guid, or throws if the {orgId} is not present or not a valid guid.
/// </summary>
/// <param name="httpContext"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
public static Guid GetOrganizationId(this HttpContext httpContext)
{
httpContext.GetRouteData().Values.TryGetValue("orgId", out var orgIdParam);
if (orgIdParam == null || !Guid.TryParse(orgIdParam.ToString(), out var orgId))
{
throw new InvalidOperationException(NoOrgIdError);
}
return orgId;
}
}

View File

@ -0,0 +1,31 @@
#nullable enable
using Bit.Core.Context;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Api.AdminConsole.Authorization;
/// <summary>
/// A requirement that implements this interface will be handled by <see cref="OrganizationRequirementHandler"/>,
/// which calls AuthorizeAsync with the organization details from the route.
/// This is used for simple role-based checks.
/// This may only be used on endpoints with {orgId} in their path.
/// </summary>
public interface IOrganizationRequirement : IAuthorizationRequirement
{
/// <summary>
/// Whether to authorize a request that has this requirement.
/// </summary>
/// <param name="organizationClaims">
/// The CurrentContextOrganization for the user if they are a member of the organization.
/// This is null if they are not a member.
/// </param>
/// <param name="isProviderUserForOrg">
/// A callback that returns true if the user is a ProviderUser that manages the organization, otherwise false.
/// This requires a database query, call it last.
/// </param>
/// <returns>True if the requirement has been satisfied, otherwise false.</returns>
public Task<bool> AuthorizeAsync(
CurrentContextOrganization? organizationClaims,
Func<Task<bool>> isProviderUserForOrg);
}

View File

@ -0,0 +1,119 @@
#nullable enable
using System.Security.Claims;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Identity;
using Bit.Core.Models.Data;
namespace Bit.Api.AdminConsole.Authorization;
public static class OrganizationClaimsExtensions
{
/// <summary>
/// Parses a user's claims and returns an object representing their claims for the specified organization.
/// </summary>
/// <param name="user">The user who has the claims.</param>
/// <param name="organizationId">The organizationId to look for in the claims.</param>
/// <returns>
/// A <see cref="CurrentContextOrganization"/> representing the user's claims for that organization, or null
/// if the user does not have any claims for that organization.
/// </returns>
public static CurrentContextOrganization? GetCurrentContextOrganization(this ClaimsPrincipal user, Guid organizationId)
{
var hasClaim = GetClaimsParser(user, organizationId);
var role = GetRoleFromClaims(hasClaim);
if (!role.HasValue)
{
// Not an organization member
return null;
}
return new CurrentContextOrganization
{
Id = organizationId,
Type = role.Value,
AccessSecretsManager = hasClaim(Claims.SecretsManagerAccess),
Permissions = role == OrganizationUserType.Custom
? GetPermissionsFromClaims(hasClaim)
: new Permissions()
};
}
/// <summary>
/// Returns a function for evaluating claims for the specified user and organizationId.
/// The function returns true if the claim type exists and false otherwise.
/// </summary>
private static Func<string, bool> GetClaimsParser(ClaimsPrincipal user, Guid organizationId)
{
// Group claims by ClaimType
var claimsDict = user.Claims
.GroupBy(c => c.Type)
.ToDictionary(
c => c.Key,
c => c.ToList());
return claimType
=> claimsDict.TryGetValue(claimType, out var claims) &&
claims
.ParseGuids()
.Any(v => v == organizationId);
}
/// <summary>
/// Parses the provided claims into proper Guids, or ignore them if they are not valid guids.
/// </summary>
private static IEnumerable<Guid> ParseGuids(this IEnumerable<Claim> claims)
{
foreach (var claim in claims)
{
if (Guid.TryParse(claim.Value, out var guid))
{
yield return guid;
}
}
}
private static OrganizationUserType? GetRoleFromClaims(Func<string, bool> hasClaim)
{
if (hasClaim(Claims.OrganizationOwner))
{
return OrganizationUserType.Owner;
}
if (hasClaim(Claims.OrganizationAdmin))
{
return OrganizationUserType.Admin;
}
if (hasClaim(Claims.OrganizationCustom))
{
return OrganizationUserType.Custom;
}
if (hasClaim(Claims.OrganizationUser))
{
return OrganizationUserType.User;
}
return null;
}
private static Permissions GetPermissionsFromClaims(Func<string, bool> hasClaim)
=> new()
{
AccessEventLogs = hasClaim(Claims.CustomPermissions.AccessEventLogs),
AccessImportExport = hasClaim(Claims.CustomPermissions.AccessImportExport),
AccessReports = hasClaim(Claims.CustomPermissions.AccessReports),
CreateNewCollections = hasClaim(Claims.CustomPermissions.CreateNewCollections),
EditAnyCollection = hasClaim(Claims.CustomPermissions.EditAnyCollection),
DeleteAnyCollection = hasClaim(Claims.CustomPermissions.DeleteAnyCollection),
ManageGroups = hasClaim(Claims.CustomPermissions.ManageGroups),
ManagePolicies = hasClaim(Claims.CustomPermissions.ManagePolicies),
ManageSso = hasClaim(Claims.CustomPermissions.ManageSso),
ManageUsers = hasClaim(Claims.CustomPermissions.ManageUsers),
ManageResetPassword = hasClaim(Claims.CustomPermissions.ManageResetPassword),
ManageScim = hasClaim(Claims.CustomPermissions.ManageScim),
};
}

View File

@ -0,0 +1,49 @@
#nullable enable
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Api.AdminConsole.Authorization;
/// <summary>
/// Handles any requirement that implements <see cref="IOrganizationRequirement"/>.
/// Retrieves the Organization ID from the route and then passes it to the requirement's AuthorizeAsync callback to
/// determine whether the action is authorized.
/// </summary>
public class OrganizationRequirementHandler(
IHttpContextAccessor httpContextAccessor,
IProviderUserRepository providerUserRepository,
IUserService userService)
: AuthorizationHandler<IOrganizationRequirement>
{
public const string NoHttpContextError = "This method should only be called in the context of an HTTP Request.";
public const string NoUserIdError = "This method should only be called on the private api with a logged in user.";
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, IOrganizationRequirement requirement)
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext == null)
{
throw new InvalidOperationException(NoHttpContextError);
}
var organizationId = httpContext.GetOrganizationId();
var organizationClaims = httpContext.User.GetCurrentContextOrganization(organizationId);
var userId = userService.GetProperUserId(httpContext.User);
if (userId == null)
{
throw new InvalidOperationException(NoUserIdError);
}
Task<bool> IsProviderUserForOrg() => httpContext.IsProviderUserForOrgAsync(providerUserRepository, userId.Value, organizationId);
var authorized = await requirement.AuthorizeAsync(organizationClaims, IsProviderUserForOrg);
if (authorized)
{
context.Succeed(requirement);
}
}
}

View File

@ -0,0 +1,20 @@
#nullable enable
using Bit.Core.Context;
using Bit.Core.Enums;
namespace Bit.Api.AdminConsole.Authorization.Requirements;
public class ManageUsersRequirement : IOrganizationRequirement
{
public async Task<bool> AuthorizeAsync(
CurrentContextOrganization? organizationClaims,
Func<Task<bool>> isProviderUserForOrg)
=> organizationClaims switch
{
{ Type: OrganizationUserType.Owner } => true,
{ Type: OrganizationUserType.Admin } => true,
{ Permissions.ManageUsers: true } => true,
_ => await isProviderUserForOrg()
};
}

View File

@ -0,0 +1,16 @@
#nullable enable
using Bit.Core.Context;
namespace Bit.Api.AdminConsole.Authorization.Requirements;
/// <summary>
/// Requires that the user is a member of the organization or a provider for the organization.
/// </summary>
public class MemberOrProviderRequirement : IOrganizationRequirement
{
public async Task<bool> AuthorizeAsync(
CurrentContextOrganization? organizationClaims,
Func<Task<bool>> isProviderUserForOrg)
=> organizationClaims is not null || await isProviderUserForOrg();
}

View File

@ -0,0 +1,103 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers;
[Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")]
[Authorize("Application")]
public class OrganizationIntegrationConfigurationController(
ICurrentContext currentContext,
IOrganizationIntegrationRepository integrationRepository,
IOrganizationIntegrationConfigurationRepository integrationConfigurationRepository) : Controller
{
[HttpPost("")]
public async Task<OrganizationIntegrationConfigurationResponseModel> CreateAsync(
Guid organizationId,
Guid integrationId,
[FromBody] OrganizationIntegrationConfigurationRequestModel model)
{
if (!await HasPermission(organizationId))
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
if (!model.IsValidForType(integration.Type))
{
throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}");
}
var organizationIntegrationConfiguration = model.ToOrganizationIntegrationConfiguration(integrationId);
var configuration = await integrationConfigurationRepository.CreateAsync(organizationIntegrationConfiguration);
return new OrganizationIntegrationConfigurationResponseModel(configuration);
}
[HttpPut("{configurationId:guid}")]
public async Task<OrganizationIntegrationConfigurationResponseModel> UpdateAsync(
Guid organizationId,
Guid integrationId,
Guid configurationId,
[FromBody] OrganizationIntegrationConfigurationRequestModel model)
{
if (!await HasPermission(organizationId))
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
if (!model.IsValidForType(integration.Type))
{
throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}");
}
var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId);
if (configuration is null || configuration.OrganizationIntegrationId != integrationId)
{
throw new NotFoundException();
}
var newConfiguration = model.ToOrganizationIntegrationConfiguration(configuration);
await integrationConfigurationRepository.ReplaceAsync(newConfiguration);
return new OrganizationIntegrationConfigurationResponseModel(newConfiguration);
}
[HttpDelete("{configurationId:guid}")]
[HttpPost("{configurationId:guid}/delete")]
public async Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId)
{
if (!await HasPermission(organizationId))
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId);
if (configuration is null || configuration.OrganizationIntegrationId != integrationId)
{
throw new NotFoundException();
}
await integrationConfigurationRepository.DeleteAsync(configuration);
}
private async Task<bool> HasPermission(Guid organizationId)
{
return await currentContext.OrganizationOwner(organizationId);
}
}

View File

@ -0,0 +1,71 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
#nullable enable
namespace Bit.Api.AdminConsole.Controllers;
[Route("organizations/{organizationId:guid}/integrations")]
[Authorize("Application")]
public class OrganizationIntegrationController(
ICurrentContext currentContext,
IOrganizationIntegrationRepository integrationRepository) : Controller
{
[HttpPost("")]
public async Task<OrganizationIntegrationResponseModel> CreateAsync(Guid organizationId, [FromBody] OrganizationIntegrationRequestModel model)
{
if (!await HasPermission(organizationId))
{
throw new NotFoundException();
}
var integration = await integrationRepository.CreateAsync(model.ToOrganizationIntegration(organizationId));
return new OrganizationIntegrationResponseModel(integration);
}
[HttpPut("{integrationId:guid}")]
public async Task<OrganizationIntegrationResponseModel> UpdateAsync(Guid organizationId, Guid integrationId, [FromBody] OrganizationIntegrationRequestModel model)
{
if (!await HasPermission(organizationId))
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration is null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
await integrationRepository.ReplaceAsync(model.ToOrganizationIntegration(integration));
return new OrganizationIntegrationResponseModel(integration);
}
[HttpDelete("{integrationId:guid}")]
[HttpPost("{integrationId:guid}/delete")]
public async Task DeleteAsync(Guid organizationId, Guid integrationId)
{
if (!await HasPermission(organizationId))
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration is null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
await integrationRepository.DeleteAsync(integration);
}
private async Task<bool> HasPermission(Guid organizationId)
{
return await currentContext.OrganizationOwner(organizationId);
}
}

View File

@ -313,7 +313,7 @@ public class OrganizationUsersController : Controller
throw new UnauthorizedAccessException(); throw new UnauthorizedAccessException();
} }
await _organizationService.InitPendingOrganization(user.Id, orgId, organizationUserId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName); await _organizationService.InitPendingOrganization(user, orgId, organizationUserId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName, model.Token);
await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService); await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService);
await _confirmOrganizationUserCommand.ConfirmUserAsync(orgId, organizationUserId, model.Key, user.Id); await _confirmOrganizationUserCommand.ConfirmUserAsync(orgId, organizationUserId, model.Key, user.Id);
} }
@ -494,7 +494,7 @@ public class OrganizationUsersController : Controller
} }
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId); var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId);
var isTdeEnrollment = ssoConfig != null && ssoConfig.GetData().MemberDecryptionType == MemberDecryptionType.TrustedDeviceEncryption; var isTdeEnrollment = ssoConfig != null && ssoConfig.Enabled && ssoConfig.GetData().MemberDecryptionType == MemberDecryptionType.TrustedDeviceEncryption;
if (!isTdeEnrollment && !string.IsNullOrWhiteSpace(model.ResetPasswordKey) && !await _userService.VerifySecretAsync(user, model.MasterPasswordHash)) if (!isTdeEnrollment && !string.IsNullOrWhiteSpace(model.ResetPasswordKey) && !await _userService.VerifySecretAsync(user, model.MasterPasswordHash))
{ {
throw new BadRequestException("Incorrect password"); throw new BadRequestException("Incorrect password");

View File

@ -0,0 +1,77 @@
using System.Text.Json;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Integrations;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers;
[Route("organizations/{organizationId:guid}/integrations/slack")]
[Authorize("Application")]
public class SlackIntegrationController(
ICurrentContext currentContext,
IOrganizationIntegrationRepository integrationRepository,
ISlackService slackService) : Controller
{
[HttpGet("redirect")]
public async Task<IActionResult> RedirectAsync(Guid organizationId)
{
if (!await currentContext.OrganizationOwner(organizationId))
{
throw new NotFoundException();
}
string callbackUrl = Url.RouteUrl(
nameof(CreateAsync),
new { organizationId },
currentContext.HttpContext.Request.Scheme);
var redirectUrl = slackService.GetRedirectUrl(callbackUrl);
if (string.IsNullOrEmpty(redirectUrl))
{
throw new NotFoundException();
}
return Redirect(redirectUrl);
}
[HttpGet("create", Name = nameof(CreateAsync))]
public async Task<IActionResult> CreateAsync(Guid organizationId, [FromQuery] string code)
{
if (!await currentContext.OrganizationOwner(organizationId))
{
throw new NotFoundException();
}
if (string.IsNullOrEmpty(code))
{
throw new BadRequestException("Missing code from Slack.");
}
string callbackUrl = Url.RouteUrl(
nameof(CreateAsync),
new { organizationId },
currentContext.HttpContext.Request.Scheme);
var token = await slackService.ObtainTokenViaOAuth(code, callbackUrl);
if (string.IsNullOrEmpty(token))
{
throw new BadRequestException("Invalid response from Slack.");
}
var integration = await integrationRepository.CreateAsync(new OrganizationIntegration
{
OrganizationId = organizationId,
Type = IntegrationType.Slack,
Configuration = JsonSerializer.Serialize(new SlackIntegration(token)),
});
var location = $"/organizations/{organizationId}/integrations/{integration.Id}";
return Created(location, new OrganizationIntegrationResponseModel(integration));
}
}

View File

@ -0,0 +1,73 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Integrations;
#nullable enable
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
public class OrganizationIntegrationConfigurationRequestModel
{
public string? Configuration { get; set; }
[Required]
public EventType EventType { get; set; }
public string? Template { get; set; }
public bool IsValidForType(IntegrationType integrationType)
{
switch (integrationType)
{
case IntegrationType.CloudBillingSync or IntegrationType.Scim:
return false;
case IntegrationType.Slack:
return !string.IsNullOrWhiteSpace(Template) && IsConfigurationValid<SlackIntegrationConfiguration>();
case IntegrationType.Webhook:
return !string.IsNullOrWhiteSpace(Template) && IsConfigurationValid<WebhookIntegrationConfiguration>();
default:
return false;
}
}
public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(Guid organizationIntegrationId)
{
return new OrganizationIntegrationConfiguration()
{
OrganizationIntegrationId = organizationIntegrationId,
Configuration = Configuration,
EventType = EventType,
Template = Template
};
}
public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(OrganizationIntegrationConfiguration currentConfiguration)
{
currentConfiguration.Configuration = Configuration;
currentConfiguration.EventType = EventType;
currentConfiguration.Template = Template;
return currentConfiguration;
}
private bool IsConfigurationValid<T>()
{
if (string.IsNullOrWhiteSpace(Configuration))
{
return false;
}
try
{
var config = JsonSerializer.Deserialize<T>(Configuration);
return config is not null;
}
catch
{
return false;
}
}
}

View File

@ -0,0 +1,56 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
#nullable enable
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
public class OrganizationIntegrationRequestModel : IValidatableObject
{
public string? Configuration { get; set; }
public IntegrationType Type { get; set; }
public OrganizationIntegration ToOrganizationIntegration(Guid organizationId)
{
return new OrganizationIntegration()
{
OrganizationId = organizationId,
Configuration = Configuration,
Type = Type,
};
}
public OrganizationIntegration ToOrganizationIntegration(OrganizationIntegration currentIntegration)
{
currentIntegration.Configuration = Configuration;
return currentIntegration;
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
switch (Type)
{
case IntegrationType.CloudBillingSync or IntegrationType.Scim:
yield return new ValidationResult($"{nameof(Type)} integrations are not yet supported.", new[] { nameof(Type) });
break;
case IntegrationType.Slack:
yield return new ValidationResult($"{nameof(Type)} integrations cannot be created directly.", new[] { nameof(Type) });
break;
case IntegrationType.Webhook:
if (Configuration is not null)
{
yield return new ValidationResult(
"Webhook integrations must not include configuration.",
new[] { nameof(Configuration) });
}
break;
default:
yield return new ValidationResult(
$"Integration type '{Type}' is not recognized.",
new[] { nameof(Type) });
break;
}
}
}

View File

@ -0,0 +1,28 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
#nullable enable
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class OrganizationIntegrationConfigurationResponseModel : ResponseModel
{
public OrganizationIntegrationConfigurationResponseModel(OrganizationIntegrationConfiguration organizationIntegrationConfiguration, string obj = "organizationIntegrationConfiguration")
: base(obj)
{
ArgumentNullException.ThrowIfNull(organizationIntegrationConfiguration);
Id = organizationIntegrationConfiguration.Id;
Configuration = organizationIntegrationConfiguration.Configuration;
CreationDate = organizationIntegrationConfiguration.CreationDate;
EventType = organizationIntegrationConfiguration.EventType;
Template = organizationIntegrationConfiguration.Template;
}
public Guid Id { get; set; }
public string? Configuration { get; set; }
public DateTime CreationDate { get; set; }
public EventType EventType { get; set; }
public string? Template { get; set; }
}

View File

@ -0,0 +1,22 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
#nullable enable
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class OrganizationIntegrationResponseModel : ResponseModel
{
public OrganizationIntegrationResponseModel(OrganizationIntegration organizationIntegration, string obj = "organizationIntegration")
: base(obj)
{
ArgumentNullException.ThrowIfNull(organizationIntegration);
Id = organizationIntegration.Id;
Type = organizationIntegration.Type;
}
public Guid Id { get; set; }
public IntegrationType Type { get; set; }
}

View File

@ -64,6 +64,7 @@ public class OrganizationResponseModel : ResponseModel
LimitItemDeletion = organization.LimitItemDeletion; LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UseRiskInsights = organization.UseRiskInsights; UseRiskInsights = organization.UseRiskInsights;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
} }
public Guid Id { get; set; } public Guid Id { get; set; }
@ -110,6 +111,7 @@ public class OrganizationResponseModel : ResponseModel
public bool LimitItemDeletion { get; set; } public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; }
public bool UseRiskInsights { get; set; } public bool UseRiskInsights { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
} }
public class OrganizationSubscriptionResponseModel : OrganizationResponseModel public class OrganizationSubscriptionResponseModel : OrganizationResponseModel

View File

@ -72,6 +72,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UserIsClaimedByOrganization = organizationIdsClaimingUser.Contains(organization.OrganizationId); UserIsClaimedByOrganization = organizationIdsClaimingUser.Contains(organization.OrganizationId);
UseRiskInsights = organization.UseRiskInsights; UseRiskInsights = organization.UseRiskInsights;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
if (organization.SsoConfig != null) if (organization.SsoConfig != null)
{ {
@ -155,4 +156,5 @@ public class ProfileOrganizationResponseModel : ResponseModel
/// </returns> /// </returns>
public bool UserIsClaimedByOrganization { get; set; } public bool UserIsClaimedByOrganization { get; set; }
public bool UseRiskInsights { get; set; } public bool UseRiskInsights { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
} }

View File

@ -50,5 +50,6 @@ public class ProfileProviderOrganizationResponseModel : ProfileOrganizationRespo
LimitItemDeletion = organization.LimitItemDeletion; LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UseRiskInsights = organization.UseRiskInsights; UseRiskInsights = organization.UseRiskInsights;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
} }
} }

View File

@ -284,52 +284,6 @@ public class AccountsController : Controller
throw new BadRequestException(ModelState); throw new BadRequestException(ModelState);
} }
[HttpPost("set-key-connector-key")]
public async Task PostSetKeyConnectorKeyAsync([FromBody] SetKeyConnectorKeyRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier);
if (result.Succeeded)
{
return;
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
throw new BadRequestException(ModelState);
}
[HttpPost("convert-to-key-connector")]
public async Task PostConvertToKeyConnector()
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var result = await _userService.ConvertToKeyConnectorAsync(user);
if (result.Succeeded)
{
return;
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
throw new BadRequestException(ModelState);
}
[HttpPost("kdf")] [HttpPost("kdf")]
public async Task PostKdf([FromBody] KdfRequestModel model) public async Task PostKdf([FromBody] KdfRequestModel model)
{ {

View File

@ -4,6 +4,7 @@ using Bit.Api.Auth.Models.Response.WebAuthn;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Api.Response.Accounts; using Bit.Core.Auth.Models.Api.Response.Accounts;
@ -31,6 +32,8 @@ public class WebAuthnController : Controller
private readonly ICreateWebAuthnLoginCredentialCommand _createWebAuthnLoginCredentialCommand; private readonly ICreateWebAuthnLoginCredentialCommand _createWebAuthnLoginCredentialCommand;
private readonly IAssertWebAuthnLoginCredentialCommand _assertWebAuthnLoginCredentialCommand; private readonly IAssertWebAuthnLoginCredentialCommand _assertWebAuthnLoginCredentialCommand;
private readonly IGetWebAuthnLoginCredentialAssertionOptionsCommand _getWebAuthnLoginCredentialAssertionOptionsCommand; private readonly IGetWebAuthnLoginCredentialAssertionOptionsCommand _getWebAuthnLoginCredentialAssertionOptionsCommand;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IFeatureService _featureService;
public WebAuthnController( public WebAuthnController(
IUserService userService, IUserService userService,
@ -41,7 +44,9 @@ public class WebAuthnController : Controller
IGetWebAuthnLoginCredentialCreateOptionsCommand getWebAuthnLoginCredentialCreateOptionsCommand, IGetWebAuthnLoginCredentialCreateOptionsCommand getWebAuthnLoginCredentialCreateOptionsCommand,
ICreateWebAuthnLoginCredentialCommand createWebAuthnLoginCredentialCommand, ICreateWebAuthnLoginCredentialCommand createWebAuthnLoginCredentialCommand,
IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand, IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand,
IGetWebAuthnLoginCredentialAssertionOptionsCommand getWebAuthnLoginCredentialAssertionOptionsCommand) IGetWebAuthnLoginCredentialAssertionOptionsCommand getWebAuthnLoginCredentialAssertionOptionsCommand,
IPolicyRequirementQuery policyRequirementQuery,
IFeatureService featureService)
{ {
_userService = userService; _userService = userService;
_policyService = policyService; _policyService = policyService;
@ -52,7 +57,8 @@ public class WebAuthnController : Controller
_createWebAuthnLoginCredentialCommand = createWebAuthnLoginCredentialCommand; _createWebAuthnLoginCredentialCommand = createWebAuthnLoginCredentialCommand;
_assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand; _assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand;
_getWebAuthnLoginCredentialAssertionOptionsCommand = getWebAuthnLoginCredentialAssertionOptionsCommand; _getWebAuthnLoginCredentialAssertionOptionsCommand = getWebAuthnLoginCredentialAssertionOptionsCommand;
_policyRequirementQuery = policyRequirementQuery;
_featureService = featureService;
} }
[HttpGet("")] [HttpGet("")]
@ -68,7 +74,7 @@ public class WebAuthnController : Controller
public async Task<WebAuthnCredentialCreateOptionsResponseModel> AttestationOptions([FromBody] SecretVerificationRequestModel model) public async Task<WebAuthnCredentialCreateOptionsResponseModel> AttestationOptions([FromBody] SecretVerificationRequestModel model)
{ {
var user = await VerifyUserAsync(model); var user = await VerifyUserAsync(model);
await ValidateRequireSsoPolicyDisabledOrNotApplicable(user.Id); await ValidateIfUserCanUsePasskeyLogin(user.Id);
var options = await _getWebAuthnLoginCredentialCreateOptionsCommand.GetWebAuthnLoginCredentialCreateOptionsAsync(user); var options = await _getWebAuthnLoginCredentialCreateOptionsCommand.GetWebAuthnLoginCredentialCreateOptionsAsync(user);
var tokenable = new WebAuthnCredentialCreateOptionsTokenable(user, options); var tokenable = new WebAuthnCredentialCreateOptionsTokenable(user, options);
@ -101,7 +107,7 @@ public class WebAuthnController : Controller
public async Task Post([FromBody] WebAuthnLoginCredentialCreateRequestModel model) public async Task Post([FromBody] WebAuthnLoginCredentialCreateRequestModel model)
{ {
var user = await GetUserAsync(); var user = await GetUserAsync();
await ValidateRequireSsoPolicyDisabledOrNotApplicable(user.Id); await ValidateIfUserCanUsePasskeyLogin(user.Id);
var tokenable = _createOptionsDataProtector.Unprotect(model.Token); var tokenable = _createOptionsDataProtector.Unprotect(model.Token);
if (!tokenable.TokenIsValid(user)) if (!tokenable.TokenIsValid(user))
@ -126,6 +132,22 @@ public class WebAuthnController : Controller
} }
} }
private async Task ValidateIfUserCanUsePasskeyLogin(Guid userId)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements))
{
await ValidateRequireSsoPolicyDisabledOrNotApplicable(userId);
return;
}
var requireSsoPolicyRequirement = await _policyRequirementQuery.GetAsync<RequireSsoPolicyRequirement>(userId);
if (!requireSsoPolicyRequirement.CanUsePasskeyLogin)
{
throw new BadRequestException("Passkeys cannot be created for your account. SSO login is required.");
}
}
[HttpPut()] [HttpPut()]
public async Task UpdateCredential([FromBody] WebAuthnLoginCredentialUpdateRequestModel model) public async Task UpdateCredential([FromBody] WebAuthnLoginCredentialUpdateRequestModel model)
{ {

View File

@ -84,10 +84,27 @@ public class OrganizationSponsorshipsController : Controller
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator."); throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
} }
if (!_featureService.IsEnabled(Bit.Core.FeatureFlagKeys.PM17772_AdminInitiatedSponsorships))
{
if (model.SponsoringUserId.HasValue)
{
throw new NotFoundException();
}
if (!string.IsNullOrWhiteSpace(model.Notes))
{
model.Notes = null;
}
}
var targetUser = model.SponsoringUserId ?? _currentContext.UserId!.Value;
var sponsorship = await _createSponsorshipCommand.CreateSponsorshipAsync( var sponsorship = await _createSponsorshipCommand.CreateSponsorshipAsync(
sponsoringOrg, sponsoringOrg,
await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default), await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, targetUser),
model.PlanSponsorshipType, model.SponsoredEmail, model.FriendlyName); model.PlanSponsorshipType,
model.SponsoredEmail,
model.FriendlyName,
model.Notes);
await _sendSponsorshipOfferCommand.SendSponsorshipOfferAsync(sponsorship, sponsoringOrg.Name); await _sendSponsorshipOfferCommand.SendSponsorshipOfferAsync(sponsorship, sponsoringOrg.Name);
} }

View File

@ -128,6 +128,7 @@ public class DevicesController : Controller
} }
[HttpPost("{identifier}/retrieve-keys")] [HttpPost("{identifier}/retrieve-keys")]
[Obsolete("This endpoint is deprecated. The keys are on the regular device GET endpoints now.")]
public async Task<ProtectedDeviceResponseModel> GetDeviceKeys(string identifier) public async Task<ProtectedDeviceResponseModel> GetDeviceKeys(string identifier)
{ {
var user = await _userService.GetUserByPrincipalAsync(User); var user = await _userService.GetUserByPrincipalAsync(User);

View File

@ -3,6 +3,7 @@ using Bit.Core.Context;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -20,6 +21,7 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
private readonly ICreateSponsorshipCommand _offerSponsorshipCommand; private readonly ICreateSponsorshipCommand _offerSponsorshipCommand;
private readonly IRevokeSponsorshipCommand _revokeSponsorshipCommand; private readonly IRevokeSponsorshipCommand _revokeSponsorshipCommand;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService;
public SelfHostedOrganizationSponsorshipsController( public SelfHostedOrganizationSponsorshipsController(
ICreateSponsorshipCommand offerSponsorshipCommand, ICreateSponsorshipCommand offerSponsorshipCommand,
@ -27,7 +29,8 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IOrganizationSponsorshipRepository organizationSponsorshipRepository, IOrganizationSponsorshipRepository organizationSponsorshipRepository,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
ICurrentContext currentContext ICurrentContext currentContext,
IFeatureService featureService
) )
{ {
_offerSponsorshipCommand = offerSponsorshipCommand; _offerSponsorshipCommand = offerSponsorshipCommand;
@ -36,15 +39,29 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
_organizationSponsorshipRepository = organizationSponsorshipRepository; _organizationSponsorshipRepository = organizationSponsorshipRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_currentContext = currentContext; _currentContext = currentContext;
_featureService = featureService;
} }
[HttpPost("{sponsoringOrgId}/families-for-enterprise")] [HttpPost("{sponsoringOrgId}/families-for-enterprise")]
public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model) public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model)
{ {
if (!_featureService.IsEnabled(Bit.Core.FeatureFlagKeys.PM17772_AdminInitiatedSponsorships))
{
if (model.SponsoringUserId.HasValue)
{
throw new NotFoundException();
}
if (!string.IsNullOrWhiteSpace(model.Notes))
{
model.Notes = null;
}
}
await _offerSponsorshipCommand.CreateSponsorshipAsync( await _offerSponsorshipCommand.CreateSponsorshipAsync(
await _organizationRepository.GetByIdAsync(sponsoringOrgId), await _organizationRepository.GetByIdAsync(sponsoringOrgId),
await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default), await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, model.SponsoringUserId ?? _currentContext.UserId ?? default),
model.PlanSponsorshipType, model.SponsoredEmail, model.FriendlyName); model.PlanSponsorshipType, model.SponsoredEmail, model.FriendlyName, model.Notes);
} }
[HttpDelete("{sponsoringOrgId}")] [HttpDelete("{sponsoringOrgId}")]

View File

@ -24,7 +24,7 @@ using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.KeyManagement.Controllers; namespace Bit.Api.KeyManagement.Controllers;
[Route("accounts/key-management")] [Route("accounts")]
[Authorize("Application")] [Authorize("Application")]
public class AccountsKeyManagementController : Controller public class AccountsKeyManagementController : Controller
{ {
@ -77,7 +77,7 @@ public class AccountsKeyManagementController : Controller
_deviceValidator = deviceValidator; _deviceValidator = deviceValidator;
} }
[HttpPost("regenerate-keys")] [HttpPost("key-management/regenerate-keys")]
public async Task RegenerateKeysAsync([FromBody] KeyRegenerationRequestModel request) public async Task RegenerateKeysAsync([FromBody] KeyRegenerationRequestModel request)
{ {
if (!_featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration)) if (!_featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration))
@ -93,7 +93,7 @@ public class AccountsKeyManagementController : Controller
} }
[HttpPost("rotate-user-account-keys")] [HttpPost("key-management/rotate-user-account-keys")]
public async Task RotateUserAccountKeysAsync([FromBody] RotateUserAccountKeysAndDataRequestModel model) public async Task RotateUserAccountKeysAsync([FromBody] RotateUserAccountKeysAndDataRequestModel model)
{ {
var user = await _userService.GetUserByPrincipalAsync(User); var user = await _userService.GetUserByPrincipalAsync(User);
@ -133,4 +133,50 @@ public class AccountsKeyManagementController : Controller
throw new BadRequestException(ModelState); throw new BadRequestException(ModelState);
} }
[HttpPost("set-key-connector-key")]
public async Task PostSetKeyConnectorKeyAsync([FromBody] SetKeyConnectorKeyRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier);
if (result.Succeeded)
{
return;
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
throw new BadRequestException(ModelState);
}
[HttpPost("convert-to-key-connector")]
public async Task PostConvertToKeyConnectorAsync()
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var result = await _userService.ConvertToKeyConnectorAsync(user);
if (result.Succeeded)
{
return;
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
throw new BadRequestException(ModelState);
}
} }

View File

@ -3,7 +3,7 @@ using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
namespace Bit.Api.Auth.Models.Request.Accounts; namespace Bit.Api.KeyManagement.Models.Requests;
public class SetKeyConnectorKeyRequestModel public class SetKeyConnectorKeyRequestModel
{ {

View File

@ -16,4 +16,14 @@ public class OrganizationSponsorshipCreateRequestModel
[StringLength(256)] [StringLength(256)]
public string FriendlyName { get; set; } public string FriendlyName { get; set; }
/// <summary>
/// (optional) The user to target for the sponsorship.
/// </summary>
/// <remarks>Left empty when creating a sponsorship for the authenticated user.</remarks>
public Guid? SponsoringUserId { get; set; }
[EncryptedString]
[EncryptedStringLength(512)]
public string Notes { get; set; }
} }

View File

@ -2,6 +2,7 @@
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
using Bit.Core.Utilities;
namespace Bit.Api.Models.Response; namespace Bit.Api.Models.Response;
@ -21,6 +22,8 @@ public class DeviceResponseModel : ResponseModel
Identifier = device.Identifier; Identifier = device.Identifier;
CreationDate = device.CreationDate; CreationDate = device.CreationDate;
IsTrusted = device.IsTrusted(); IsTrusted = device.IsTrusted();
EncryptedUserKey = device.EncryptedUserKey;
EncryptedPublicKey = device.EncryptedPublicKey;
} }
public Guid Id { get; set; } public Guid Id { get; set; }
@ -29,4 +32,10 @@ public class DeviceResponseModel : ResponseModel
public string Identifier { get; set; } public string Identifier { get; set; }
public DateTime CreationDate { get; set; } public DateTime CreationDate { get; set; }
public bool IsTrusted { get; set; } public bool IsTrusted { get; set; }
[EncryptedString]
[EncryptedStringLength(2000)]
public string EncryptedUserKey { get; set; }
[EncryptedString]
[EncryptedStringLength(2000)]
public string EncryptedPublicKey { get; set; }
} }

View File

@ -5,7 +5,7 @@ using Bit.Core.Settings;
using AspNetCoreRateLimit; using AspNetCoreRateLimit;
using Stripe; using Stripe;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Duende.IdentityModel; using IdentityModel;
using System.Globalization; using System.Globalization;
using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Request;
@ -27,8 +27,10 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions;
using Bit.Core.Tools.Entities; using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Core.AdminConsole.Services.NoopImplementations;
using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Services;
using Bit.Core.Tools.ImportFeatures; using Bit.Core.Tools.ImportFeatures;
using Bit.Core.Tools.ReportFeatures; using Bit.Core.Tools.ReportFeatures;
using Bit.Core.Auth.Models.Api.Request; using Bit.Core.Auth.Models.Api.Request;
@ -216,6 +218,19 @@ public class Startup
services.AddHostedService<Core.HostedServices.ApplicationCacheHostedService>(); services.AddHostedService<Core.HostedServices.ApplicationCacheHostedService>();
} }
// Slack
if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes))
{
services.AddHttpClient(SlackService.HttpClientName);
services.AddSingleton<ISlackService, SlackService>();
}
else
{
services.AddSingleton<ISlackService, NoopSlackService>();
}
// This should be registered last because it customizes the primary http message handler and we want it to win. // This should be registered last because it customizes the primary http message handler and we want it to win.
services.AddX509ChainCustomization(); services.AddX509ChainCustomization();
} }

View File

@ -77,10 +77,9 @@ public class ImportCiphersController : Controller
//An User is allowed to import if CanCreate Collections or has AccessToImportExport //An User is allowed to import if CanCreate Collections or has AccessToImportExport
var authorized = await CheckOrgImportPermission(collections, orgId); var authorized = await CheckOrgImportPermission(collections, orgId);
if (!authorized) if (!authorized)
{ {
throw new NotFoundException(); throw new BadRequestException("Not enough privileges to import into this organization.");
} }
var userId = _userService.GetProperUserId(User).Value; var userId = _userService.GetProperUserId(User).Value;
@ -103,21 +102,59 @@ public class ImportCiphersController : Controller
.Select(c => c.Id) .Select(c => c.Id)
.ToHashSet(); .ToHashSet();
//We need to verify if the user is trying to import into existing collections // when there are no collections, then we can import
var existingCollections = collections.Where(tc => orgCollectionIds.Contains(tc.Id)); if (collections.Count == 0)
//When importing into existing collection, we need to verify if the user has permissions
if (existingCollections.Any() && !(await _authorizationService.AuthorizeAsync(User, existingCollections, BulkCollectionOperations.ImportCiphers)).Succeeded)
{ {
return false; return true;
};
//Users allowed to import if they CanCreate Collections
if (!(await _authorizationService.AuthorizeAsync(User, collections, BulkCollectionOperations.Create)).Succeeded)
{
return false;
} }
return true; // are we trying to import into existing collections?
var existingCollections = collections.Where(tc => orgCollectionIds.Contains(tc.Id));
// are we trying to create new collections?
var hasNewCollections = collections.Any(tc => !orgCollectionIds.Contains(tc.Id));
// suppose we have both new and existing collections
if (hasNewCollections && existingCollections.Any())
{
// since we are creating new collection, user must have import/manage and create collection permission
if ((await _authorizationService.AuthorizeAsync(User, collections, BulkCollectionOperations.Create)).Succeeded
&& (await _authorizationService.AuthorizeAsync(User, existingCollections, BulkCollectionOperations.ImportCiphers)).Succeeded)
{
// can import collections and create new ones
return true;
}
else
{
// user does not have permission to import
return false;
}
}
// suppose we have new collections and none of our collections exist
if (hasNewCollections && !existingCollections.Any())
{
// user is trying to create new collections
// we need to check if the user has permission to create collections
if ((await _authorizationService.AuthorizeAsync(User, collections, BulkCollectionOperations.Create)).Succeeded)
{
return true;
}
else
{
// user does not have permission to create new collections
return false;
}
}
// in many import formats, we don't create collections, we just import ciphers into an existing collection
// When importing, we need to verify if the user has ImportCiphers permission
if (existingCollections.Any() && (await _authorizationService.AuthorizeAsync(User, existingCollections, BulkCollectionOperations.ImportCiphers)).Succeeded)
{
return true;
};
return false;
} }
} }

View File

@ -1,4 +1,5 @@
using Bit.Api.Tools.Authorization; using Bit.Api.AdminConsole.Authorization;
using Bit.Api.Tools.Authorization;
using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
using Bit.Core.IdentityServer; using Bit.Core.IdentityServer;
@ -105,5 +106,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<IAuthorizationHandler, VaultExportAuthorizationHandler>(); services.AddScoped<IAuthorizationHandler, VaultExportAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, SecurityTaskAuthorizationHandler>(); services.AddScoped<IAuthorizationHandler, SecurityTaskAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, SecurityTaskOrganizationAuthorizationHandler>(); services.AddScoped<IAuthorizationHandler, SecurityTaskOrganizationAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, OrganizationRequirementHandler>();
} }
} }

View File

@ -177,12 +177,7 @@ public class CiphersController : Controller
} }
await _cipherService.SaveDetailsAsync(cipher, user.Id, model.Cipher.LastKnownRevisionDate, model.CollectionIds, cipher.OrganizationId.HasValue); await _cipherService.SaveDetailsAsync(cipher, user.Id, model.Cipher.LastKnownRevisionDate, model.CollectionIds, cipher.OrganizationId.HasValue);
var response = new CipherResponseModel( return await Get(cipher.Id);
cipher,
user,
await _applicationCacheService.GetOrganizationAbilitiesAsync(),
_globalSettings);
return response;
} }
[HttpPost("admin")] [HttpPost("admin")]

View File

@ -16,6 +16,12 @@ public interface IStripeFacade
RequestOptions requestOptions = null, RequestOptions requestOptions = null,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task<Customer> UpdateCustomer(
string customerId,
CustomerUpdateOptions customerUpdateOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<Event> GetEvent( Task<Event> GetEvent(
string eventId, string eventId,
EventGetOptions eventGetOptions = null, EventGetOptions eventGetOptions = null,

View File

@ -1,4 +1,10 @@
using Bit.Billing.Constants; using Bit.Billing.Constants;
using Bit.Core;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Services;
using Stripe; using Stripe;
using Event = Stripe.Event; using Event = Stripe.Event;
@ -10,20 +16,124 @@ public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler
private readonly IStripeEventService _stripeEventService; private readonly IStripeEventService _stripeEventService;
private readonly IStripeFacade _stripeFacade; private readonly IStripeFacade _stripeFacade;
private readonly IStripeEventUtilityService _stripeEventUtilityService; private readonly IStripeEventUtilityService _stripeEventUtilityService;
private readonly IFeatureService _featureService;
private readonly IProviderRepository _providerRepository;
public PaymentMethodAttachedHandler( public PaymentMethodAttachedHandler(
ILogger<PaymentMethodAttachedHandler> logger, ILogger<PaymentMethodAttachedHandler> logger,
IStripeEventService stripeEventService, IStripeEventService stripeEventService,
IStripeFacade stripeFacade, IStripeFacade stripeFacade,
IStripeEventUtilityService stripeEventUtilityService) IStripeEventUtilityService stripeEventUtilityService,
IFeatureService featureService,
IProviderRepository providerRepository)
{ {
_logger = logger; _logger = logger;
_stripeEventService = stripeEventService; _stripeEventService = stripeEventService;
_stripeFacade = stripeFacade; _stripeFacade = stripeFacade;
_stripeEventUtilityService = stripeEventUtilityService; _stripeEventUtilityService = stripeEventUtilityService;
_featureService = featureService;
_providerRepository = providerRepository;
} }
public async Task HandleAsync(Event parsedEvent) public async Task HandleAsync(Event parsedEvent)
{
var updateMSPToChargeAutomatically =
_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically);
if (updateMSPToChargeAutomatically)
{
await HandleVNextAsync(parsedEvent);
}
else
{
await HandleVCurrentAsync(parsedEvent);
}
}
private async Task HandleVNextAsync(Event parsedEvent)
{
var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent, true, ["customer.subscriptions.data.latest_invoice"]);
if (paymentMethod == null)
{
_logger.LogWarning("Attempted to handle the event payment_method.attached but paymentMethod was null");
return;
}
var customer = paymentMethod.Customer;
var subscriptions = customer?.Subscriptions;
// This represents a provider subscription set to "send_invoice" that was paid using a Stripe hosted invoice payment page.
var invoicedProviderSubscription = subscriptions?.Data.FirstOrDefault(subscription =>
subscription.Metadata.ContainsKey(StripeConstants.MetadataKeys.ProviderId) &&
subscription.Status != StripeConstants.SubscriptionStatus.Canceled &&
subscription.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice);
/*
* If we have an invoiced provider subscription where the customer hasn't been marked as invoice-approved,
* we need to try and set the default payment method and update the collection method to be "charge_automatically".
*/
if (invoicedProviderSubscription != null &&
!customer.ApprovedToPayByInvoice() &&
Guid.TryParse(invoicedProviderSubscription.Metadata[StripeConstants.MetadataKeys.ProviderId], out var providerId))
{
var provider = await _providerRepository.GetByIdAsync(providerId);
if (provider is { Type: ProviderType.Msp })
{
if (customer.InvoiceSettings.DefaultPaymentMethodId != paymentMethod.Id)
{
try
{
await _stripeFacade.UpdateCustomer(customer.Id,
new CustomerUpdateOptions
{
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
DefaultPaymentMethod = paymentMethod.Id
}
});
}
catch (Exception exception)
{
_logger.LogWarning(exception,
"Failed to set customer's ({CustomerID}) default payment method during 'payment_method.attached' webhook",
customer.Id);
}
}
try
{
await _stripeFacade.UpdateSubscription(invoicedProviderSubscription.Id,
new SubscriptionUpdateOptions
{
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically
});
}
catch (Exception exception)
{
_logger.LogWarning(exception,
"Failed to set subscription's ({SubscriptionID}) collection method to 'charge_automatically' during 'payment_method.attached' webhook",
customer.Id);
}
}
}
var unpaidSubscriptions = subscriptions?.Data.Where(subscription =>
subscription.Status == StripeConstants.SubscriptionStatus.Unpaid).ToList();
if (unpaidSubscriptions == null || unpaidSubscriptions.Count == 0)
{
return;
}
foreach (var unpaidSubscription in unpaidSubscriptions)
{
await AttemptToPayOpenSubscriptionAsync(unpaidSubscription);
}
}
private async Task HandleVCurrentAsync(Event parsedEvent)
{ {
var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent); var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent);
if (paymentMethod is null) if (paymentMethod is null)

View File

@ -33,6 +33,13 @@ public class StripeFacade : IStripeFacade
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) =>
await _customerService.GetAsync(customerId, customerGetOptions, requestOptions, cancellationToken); await _customerService.GetAsync(customerId, customerGetOptions, requestOptions, cancellationToken);
public async Task<Customer> UpdateCustomer(
string customerId,
CustomerUpdateOptions customerUpdateOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default) =>
await _customerService.UpdateAsync(customerId, customerUpdateOptions, requestOptions, cancellationToken);
public async Task<Invoice> GetInvoice( public async Task<Invoice> GetInvoice(
string invoiceId, string invoiceId,
InvoiceGetOptions invoiceGetOptions = null, InvoiceGetOptions invoiceGetOptions = null,

View File

@ -114,6 +114,11 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
/// </summary> /// </summary>
public bool UseRiskInsights { get; set; } 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() public void SetNewId()
{ {
if (Id == default(Guid)) if (Id == default(Guid))

View File

@ -2,6 +2,8 @@
public enum IntegrationType : int public enum IntegrationType : int
{ {
Slack = 1, CloudBillingSync = 1,
Webhook = 2, Scim = 2,
Slack = 3,
Webhook = 4,
} }

View File

@ -0,0 +1,3 @@
namespace Bit.Core.Models.Data.Integrations;
public record SlackIntegration(string token);

View File

@ -0,0 +1,3 @@
namespace Bit.Core.Models.Data.Integrations;
public record SlackIntegrationConfiguration(string channelId);

View File

@ -0,0 +1,3 @@
namespace Bit.Core.Models.Data.Integrations;
public record SlackIntegrationConfigurationDetails(string channelId, string token);

View File

@ -0,0 +1,3 @@
namespace Bit.Core.Models.Data.Integrations;
public record WebhookIntegrationConfiguration(string url);

View File

@ -0,0 +1,3 @@
namespace Bit.Core.Models.Data.Integrations;
public record WebhookIntegrationConfigurationDetils(string url);

View File

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

View File

@ -1,4 +1,5 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Bit.Core.Identity;
namespace Bit.Core.Models.Data; namespace Bit.Core.Models.Data;
@ -20,17 +21,17 @@ public class Permissions
[JsonIgnore] [JsonIgnore]
public List<(bool Permission, string ClaimName)> ClaimsMap => new() public List<(bool Permission, string ClaimName)> ClaimsMap => new()
{ {
(AccessEventLogs, "accesseventlogs"), (AccessEventLogs, Claims.CustomPermissions.AccessEventLogs),
(AccessImportExport, "accessimportexport"), (AccessImportExport, Claims.CustomPermissions.AccessImportExport),
(AccessReports, "accessreports"), (AccessReports, Claims.CustomPermissions.AccessReports),
(CreateNewCollections, "createnewcollections"), (CreateNewCollections, Claims.CustomPermissions.CreateNewCollections),
(EditAnyCollection, "editanycollection"), (EditAnyCollection, Claims.CustomPermissions.EditAnyCollection),
(DeleteAnyCollection, "deleteanycollection"), (DeleteAnyCollection, Claims.CustomPermissions.DeleteAnyCollection),
(ManageGroups, "managegroups"), (ManageGroups, Claims.CustomPermissions.ManageGroups),
(ManagePolicies, "managepolicies"), (ManagePolicies, Claims.CustomPermissions.ManagePolicies),
(ManageSso, "managesso"), (ManageSso, Claims.CustomPermissions.ManageSso),
(ManageUsers, "manageusers"), (ManageUsers, Claims.CustomPermissions.ManageUsers),
(ManageResetPassword, "manageresetpassword"), (ManageResetPassword, Claims.CustomPermissions.ManageResetPassword),
(ManageScim, "managescim"), (ManageScim, Claims.CustomPermissions.ManageScim),
}; };
} }

View File

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

View File

@ -0,0 +1,57 @@

using System.Text.Json.Serialization;
namespace Bit.Core.Models.Slack;
public abstract class SlackApiResponse
{
public bool Ok { get; set; }
[JsonPropertyName("response_metadata")]
public SlackResponseMetadata ResponseMetadata { get; set; } = new();
public string Error { get; set; } = string.Empty;
}
public class SlackResponseMetadata
{
[JsonPropertyName("next_cursor")]
public string NextCursor { get; set; } = string.Empty;
}
public class SlackChannelListResponse : SlackApiResponse
{
public List<SlackChannel> Channels { get; set; } = new();
}
public class SlackUserResponse : SlackApiResponse
{
public SlackUser User { get; set; } = new();
}
public class SlackOAuthResponse : SlackApiResponse
{
[JsonPropertyName("access_token")]
public string AccessToken { get; set; } = string.Empty;
public SlackTeam Team { get; set; } = new();
}
public class SlackTeam
{
public string Id { get; set; } = string.Empty;
}
public class SlackChannel
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
}
public class SlackUser
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
}
public class SlackDmResponse : SlackApiResponse
{
public SlackChannel Channel { get; set; } = new();
}

View File

@ -159,13 +159,13 @@ public class InviteOrganizationUsersCommand(IEventService eventService,
private async Task RevertPasswordManagerChangesAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization) private async Task RevertPasswordManagerChangesAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
{ {
if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd > 0) if (validatedResult.Value.PasswordManagerSubscriptionUpdate is { Seats: > 0, SeatsRequiredToAdd: > 0 })
{ {
// When reverting seats, we have to tell payments service that the seats are going back down by what we attempted to add.
// However, this might lead to a problem if we don't actually update stripe but throw any ways.
// stripe could not be updated, and then we would decrement the number of seats in stripe accidentally. await paymentService.AdjustSeatsAsync(organization,
var seatsToRemove = validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd; validatedResult.Value.InviteOrganization.Plan,
await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, -seatsToRemove); validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats.Value);
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats; organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats;
@ -206,10 +206,26 @@ public class InviteOrganizationUsersCommand(IEventService eventService,
private async Task SendAdditionalEmailsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization) private async Task SendAdditionalEmailsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
{ {
await SendPasswordManagerMaxSeatLimitEmailsAsync(validatedResult, organization); await NotifyOwnersIfAutoscaleOccursAsync(validatedResult, organization);
await NotifyOwnersIfPasswordManagerMaxSeatLimitReachedAsync(validatedResult, organization);
} }
private async Task SendPasswordManagerMaxSeatLimitEmailsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization) private async Task NotifyOwnersIfAutoscaleOccursAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
{
if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd > 0
&& !organization.OwnersNotifiedOfAutoscaling.HasValue)
{
await mailService.SendOrganizationAutoscaledEmailAsync(
organization,
validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats!.Value,
await GetOwnerEmailAddressesAsync(validatedResult.Value.InviteOrganization));
organization.OwnersNotifiedOfAutoscaling = validatedResult.Value.PerformedAt.UtcDateTime;
await organizationRepository.UpsertAsync(organization);
}
}
private async Task NotifyOwnersIfPasswordManagerMaxSeatLimitReachedAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
{ {
if (!validatedResult.Value.PasswordManagerSubscriptionUpdate.MaxSeatsReached) if (!validatedResult.Value.PasswordManagerSubscriptionUpdate.MaxSeatsReached)
{ {
@ -258,25 +274,25 @@ public class InviteOrganizationUsersCommand(IEventService eventService,
private async Task AdjustPasswordManagerSeatsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization) private async Task AdjustPasswordManagerSeatsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
{ {
if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd <= 0) if (validatedResult.Value.PasswordManagerSubscriptionUpdate is { SeatsRequiredToAdd: > 0, UpdatedSeatTotal: > 0 })
{ {
return; await paymentService.AdjustSeatsAsync(organization,
validatedResult.Value.InviteOrganization.Plan,
validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal.Value);
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal;
await organizationRepository.ReplaceAsync(organization); // could optimize this with only a property update
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
await referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, currentContext)
{
PlanName = validatedResult.Value.InviteOrganization.Plan.Name,
PlanType = validatedResult.Value.InviteOrganization.Plan.Type,
Seats = validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal,
PreviousSeats = validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats
});
} }
await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd);
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal;
await organizationRepository.ReplaceAsync(organization); // could optimize this with only a property update
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
await referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, currentContext)
{
PlanName = validatedResult.Value.InviteOrganization.Plan.Name,
PlanType = validatedResult.Value.InviteOrganization.Plan.Type,
Seats = validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal,
PreviousSeats = validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats
});
} }
} }

View File

@ -0,0 +1,62 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.Enums;
using Bit.Core.Settings;
/// <summary>
/// Policy requirements for the Require SSO policy.
/// </summary>
public class RequireSsoPolicyRequirement : IPolicyRequirement
{
/// <summary>
/// Indicates whether the user can use passkey login.
/// </summary>
/// <remarks>
/// The user can use passkey login if they are not a member (Accepted/Confirmed) of an organization
/// that has the Require SSO policy enabled.
/// </remarks>
public bool CanUsePasskeyLogin { get; init; }
/// <summary>
/// Indicates whether SSO requirement is enforced for the user.
/// </summary>
/// <remarks>
/// The user is required to login with SSO if they are a confirmed member of an organization
/// that has the Require SSO policy enabled.
/// </remarks>
public bool SsoRequired { get; init; }
}
public class RequireSsoPolicyRequirementFactory : BasePolicyRequirementFactory<RequireSsoPolicyRequirement>
{
private readonly GlobalSettings _globalSettings;
public RequireSsoPolicyRequirementFactory(GlobalSettings globalSettings)
{
_globalSettings = globalSettings;
}
public override PolicyType PolicyType => PolicyType.RequireSso;
protected override IEnumerable<OrganizationUserType> ExemptRoles =>
_globalSettings.Sso.EnforceSsoPolicyForAllUsers
? Array.Empty<OrganizationUserType>()
: [OrganizationUserType.Owner, OrganizationUserType.Admin];
public override RequireSsoPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails)
{
var result = new RequireSsoPolicyRequirement
{
CanUsePasskeyLogin = policyDetails.All(p =>
p.OrganizationUserStatus == OrganizationUserStatusType.Revoked ||
p.OrganizationUserStatus == OrganizationUserStatusType.Invited),
SsoRequired = policyDetails.Any(p =>
p.OrganizationUserStatus == OrganizationUserStatusType.Confirmed)
};
return result;
}
}

View File

@ -35,5 +35,6 @@ public static class PolicyServiceCollectionExtensions
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, SendOptionsPolicyRequirementFactory>(); services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, SendOptionsPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, ResetPasswordPolicyRequirementFactory>(); services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, ResetPasswordPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, PersonalOwnershipPolicyRequirementFactory>(); services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, PersonalOwnershipPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, RequireSsoPolicyRequirementFactory>();
} }
} }

View File

@ -54,7 +54,7 @@ public interface IOrganizationService
/// <remarks> /// <remarks>
/// This method must target a disabled Organization that has null keys and status as 'Pending'. /// This method must target a disabled Organization that has null keys and status as 'Pending'.
/// </remarks> /// </remarks>
Task InitPendingOrganization(Guid userId, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName); Task InitPendingOrganization(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken);
Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null); Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null);
void ValidatePasswordManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade); void ValidatePasswordManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade);

View File

@ -0,0 +1,11 @@
namespace Bit.Core.Services;
public interface ISlackService
{
Task<string> GetChannelIdAsync(string token, string channelName);
Task<List<string>> GetChannelIdsAsync(string token, List<string> channelNames);
Task<string> GetDmChannelByEmailAsync(string token, string email);
string GetRedirectUrl(string redirectUrl);
Task<string> ObtainTokenViaOAuth(string code, string redirectUrl);
Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId);
}

View File

@ -13,6 +13,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
@ -30,10 +31,12 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Platform.Push; using Bit.Core.Platform.Push;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Tokens;
using Bit.Core.Tools.Enums; using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Models.Business;
using Bit.Core.Tools.Services; using Bit.Core.Tools.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Stripe; using Stripe;
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite; using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
@ -74,6 +77,8 @@ public class OrganizationService : IOrganizationService
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand; private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand;
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
private readonly IDataProtector _dataProtector;
public OrganizationService( public OrganizationService(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -107,7 +112,10 @@ public class OrganizationService : IOrganizationService
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
IPricingClient pricingClient, IPricingClient pricingClient,
IPolicyRequirementQuery policyRequirementQuery, IPolicyRequirementQuery policyRequirementQuery,
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand) ISendOrganizationInvitesCommand sendOrganizationInvitesCommand,
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
IDataProtectionProvider dataProtectionProvider
)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@ -141,6 +149,8 @@ public class OrganizationService : IOrganizationService
_pricingClient = pricingClient; _pricingClient = pricingClient;
_policyRequirementQuery = policyRequirementQuery; _policyRequirementQuery = policyRequirementQuery;
_sendOrganizationInvitesCommand = sendOrganizationInvitesCommand; _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand;
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
_dataProtector = dataProtectionProvider.CreateProtector(OrgUserInviteTokenable.DataProtectorPurpose);
} }
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken, public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
@ -1912,9 +1922,28 @@ public class OrganizationService : IOrganizationService
}); });
} }
public async Task InitPendingOrganization(Guid userId, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName) public async Task InitPendingOrganization(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken)
{ {
await ValidateSignUpPoliciesAsync(userId); await ValidateSignUpPoliciesAsync(user.Id);
var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId);
if (orgUser == null)
{
throw new BadRequestException("User invalid.");
}
// TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete
var newTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken(
_orgUserInviteTokenDataFactory, emailToken, orgUser);
var tokenValid = newTokenValid ||
CoreHelpers.UserInviteTokenIsValid(_dataProtector, emailToken, user.Email, orgUser.Id,
_globalSettings);
if (!tokenValid)
{
throw new BadRequestException("Invalid token.");
}
var org = await GetOrgById(organizationId); var org = await GetOrgById(organizationId);

View File

@ -0,0 +1,46 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Integrations;
using Bit.Core.Repositories;
namespace Bit.Core.Services;
public class SlackEventHandler(
IOrganizationIntegrationConfigurationRepository configurationRepository,
ISlackService slackService)
: IEventMessageHandler
{
public async Task HandleEventAsync(EventMessage eventMessage)
{
var organizationId = eventMessage.OrganizationId ?? Guid.Empty;
var configurations = await configurationRepository.GetConfigurationDetailsAsync(
organizationId,
IntegrationType.Slack,
eventMessage.Type);
foreach (var configuration in configurations)
{
var config = configuration.MergedConfiguration.Deserialize<SlackIntegrationConfigurationDetails>();
if (config is null)
{
continue;
}
await slackService.SendSlackMessageByChannelIdAsync(
config.token,
IntegrationTemplateProcessor.ReplaceTokens(configuration.Template, eventMessage),
config.channelId
);
}
}
public async Task HandleManyEventsAsync(IEnumerable<EventMessage> eventMessages)
{
foreach (var eventMessage in eventMessages)
{
await HandleEventAsync(eventMessage);
}
}
}

View File

@ -0,0 +1,162 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Web;
using Bit.Core.Models.Slack;
using Bit.Core.Settings;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Services;
public class SlackService(
IHttpClientFactory httpClientFactory,
GlobalSettings globalSettings,
ILogger<SlackService> logger) : ISlackService
{
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);
private readonly string _clientId = globalSettings.Slack.ClientId;
private readonly string _clientSecret = globalSettings.Slack.ClientSecret;
private readonly string _scopes = globalSettings.Slack.Scopes;
private readonly string _slackApiBaseUrl = globalSettings.Slack.ApiBaseUrl;
public const string HttpClientName = "SlackServiceHttpClient";
public async Task<string> GetChannelIdAsync(string token, string channelName)
{
return (await GetChannelIdsAsync(token, [channelName])).FirstOrDefault();
}
public async Task<List<string>> GetChannelIdsAsync(string token, List<string> channelNames)
{
var matchingChannelIds = new List<string>();
var baseUrl = $"{_slackApiBaseUrl}/conversations.list";
var nextCursor = string.Empty;
do
{
var uriBuilder = new UriBuilder(baseUrl);
var queryParameters = HttpUtility.ParseQueryString(uriBuilder.Query);
queryParameters["types"] = "public_channel,private_channel";
queryParameters["limit"] = "1000";
if (!string.IsNullOrEmpty(nextCursor))
{
queryParameters["cursor"] = nextCursor;
}
uriBuilder.Query = queryParameters.ToString();
var request = new HttpRequestMessage(HttpMethod.Get, uriBuilder.Uri);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _httpClient.SendAsync(request);
var result = await response.Content.ReadFromJsonAsync<SlackChannelListResponse>();
if (result is { Ok: true })
{
matchingChannelIds.AddRange(result.Channels
.Where(channel => channelNames.Contains(channel.Name))
.Select(channel => channel.Id));
nextCursor = result.ResponseMetadata.NextCursor;
}
else
{
logger.LogError("Error getting Channel Ids: {Error}", result.Error);
nextCursor = string.Empty;
}
} while (!string.IsNullOrEmpty(nextCursor));
return matchingChannelIds;
}
public async Task<string> GetDmChannelByEmailAsync(string token, string email)
{
var userId = await GetUserIdByEmailAsync(token, email);
return await OpenDmChannel(token, userId);
}
public string GetRedirectUrl(string redirectUrl)
{
return $"https://slack.com/oauth/v2/authorize?client_id={_clientId}&scope={_scopes}&redirect_uri={redirectUrl}";
}
public async Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)
{
var tokenResponse = await _httpClient.PostAsync($"{_slackApiBaseUrl}/oauth.v2.access",
new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("client_id", _clientId),
new KeyValuePair<string, string>("client_secret", _clientSecret),
new KeyValuePair<string, string>("code", code),
new KeyValuePair<string, string>("redirect_uri", redirectUrl)
}));
SlackOAuthResponse result;
try
{
result = await tokenResponse.Content.ReadFromJsonAsync<SlackOAuthResponse>();
}
catch
{
result = null;
}
if (result == null)
{
logger.LogError("Error obtaining token via OAuth: Unknown error");
return string.Empty;
}
if (!result.Ok)
{
logger.LogError("Error obtaining token via OAuth: {Error}", result.Error);
return string.Empty;
}
return result.AccessToken;
}
public async Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId)
{
var payload = JsonContent.Create(new { channel = channelId, text = message });
var request = new HttpRequestMessage(HttpMethod.Post, $"{_slackApiBaseUrl}/chat.postMessage");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Content = payload;
await _httpClient.SendAsync(request);
}
private async Task<string> GetUserIdByEmailAsync(string token, string email)
{
var request = new HttpRequestMessage(HttpMethod.Get, $"{_slackApiBaseUrl}/users.lookupByEmail?email={email}");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _httpClient.SendAsync(request);
var result = await response.Content.ReadFromJsonAsync<SlackUserResponse>();
if (!result.Ok)
{
logger.LogError("Error retrieving Slack user ID: {Error}", result.Error);
return string.Empty;
}
return result.User.Id;
}
private async Task<string> OpenDmChannel(string token, string userId)
{
if (string.IsNullOrEmpty(userId))
return string.Empty;
var payload = JsonContent.Create(new { users = userId });
var request = new HttpRequestMessage(HttpMethod.Post, $"{_slackApiBaseUrl}/conversations.open");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Content = payload;
var response = await _httpClient.SendAsync(request);
var result = await response.Content.ReadFromJsonAsync<SlackDmResponse>();
if (!result.Ok)
{
logger.LogError("Error opening DM channel: {Error}", result.Error);
return string.Empty;
}
return result.Channel.Id;
}
}

View File

@ -1,30 +1,57 @@
using System.Net.Http.Json; using System.Text;
using System.Text.Json;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Enums;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
using Bit.Core.Settings; using Bit.Core.Models.Data.Integrations;
using Bit.Core.Repositories;
#nullable enable
namespace Bit.Core.Services; namespace Bit.Core.Services;
public class WebhookEventHandler( public class WebhookEventHandler(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
GlobalSettings globalSettings) IOrganizationIntegrationConfigurationRepository configurationRepository)
: IEventMessageHandler : IEventMessageHandler
{ {
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);
private readonly string _webhookUrl = globalSettings.EventLogging.WebhookUrl;
public const string HttpClientName = "WebhookEventHandlerHttpClient"; public const string HttpClientName = "WebhookEventHandlerHttpClient";
public async Task HandleEventAsync(EventMessage eventMessage) public async Task HandleEventAsync(EventMessage eventMessage)
{ {
var content = JsonContent.Create(eventMessage); var organizationId = eventMessage.OrganizationId ?? Guid.Empty;
var response = await _httpClient.PostAsync(_webhookUrl, content); var configurations = await configurationRepository.GetConfigurationDetailsAsync(
response.EnsureSuccessStatusCode(); organizationId,
IntegrationType.Webhook,
eventMessage.Type);
foreach (var configuration in configurations)
{
var config = configuration.MergedConfiguration.Deserialize<WebhookIntegrationConfigurationDetils>();
if (config is null || string.IsNullOrEmpty(config.url))
{
continue;
}
var content = new StringContent(
IntegrationTemplateProcessor.ReplaceTokens(configuration.Template, eventMessage),
Encoding.UTF8,
"application/json"
);
var response = await _httpClient.PostAsync(
config.url,
content);
response.EnsureSuccessStatusCode();
}
} }
public async Task HandleManyEventsAsync(IEnumerable<EventMessage> eventMessages) public async Task HandleManyEventsAsync(IEnumerable<EventMessage> eventMessages)
{ {
var content = JsonContent.Create(eventMessages); foreach (var eventMessage in eventMessages)
var response = await _httpClient.PostAsync(_webhookUrl, content); {
response.EnsureSuccessStatusCode(); await HandleEventAsync(eventMessage);
}
} }
} }

View File

@ -0,0 +1,36 @@
using Bit.Core.Services;
namespace Bit.Core.AdminConsole.Services.NoopImplementations;
public class NoopSlackService : ISlackService
{
public Task<string> GetChannelIdAsync(string token, string channelName)
{
return Task.FromResult(string.Empty);
}
public Task<List<string>> GetChannelIdsAsync(string token, List<string> channelNames)
{
return Task.FromResult(new List<string>());
}
public Task<string> GetDmChannelByEmailAsync(string token, string email)
{
return Task.FromResult(string.Empty);
}
public string GetRedirectUrl(string redirectUrl)
{
return string.Empty;
}
public Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId)
{
return Task.FromResult(0);
}
public Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)
{
return Task.FromResult(string.Empty);
}
}

View File

@ -0,0 +1,23 @@
using System.Text.RegularExpressions;
namespace Bit.Core.AdminConsole.Utilities;
public static partial class IntegrationTemplateProcessor
{
[GeneratedRegex(@"#(\w+)#")]
private static partial Regex TokenRegex();
public static string ReplaceTokens(string template, object values)
{
if (string.IsNullOrEmpty(template) || values == null)
return template;
var type = values.GetType();
return TokenRegex().Replace(template, match =>
{
var propertyName = match.Groups[1].Value;
var property = type.GetProperty(propertyName);
return property?.GetValue(values)?.ToString() ?? match.Value;
});
}
}

View File

@ -1,6 +1,7 @@
using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Models.Data;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
using Bit.Core.Utilities;
namespace Bit.Core.Auth.Models.Api.Response; namespace Bit.Core.Auth.Models.Api.Response;
@ -19,6 +20,8 @@ public class DeviceAuthRequestResponseModel : ResponseModel
Identifier = deviceAuthDetails.Identifier, Identifier = deviceAuthDetails.Identifier,
CreationDate = deviceAuthDetails.CreationDate, CreationDate = deviceAuthDetails.CreationDate,
IsTrusted = deviceAuthDetails.IsTrusted, IsTrusted = deviceAuthDetails.IsTrusted,
EncryptedPublicKey = deviceAuthDetails.EncryptedPublicKey,
EncryptedUserKey = deviceAuthDetails.EncryptedUserKey
}; };
if (deviceAuthDetails.AuthRequestId != null && deviceAuthDetails.AuthRequestCreatedAt != null) if (deviceAuthDetails.AuthRequestId != null && deviceAuthDetails.AuthRequestCreatedAt != null)
@ -39,6 +42,12 @@ public class DeviceAuthRequestResponseModel : ResponseModel
public string Identifier { get; set; } public string Identifier { get; set; }
public DateTime CreationDate { get; set; } public DateTime CreationDate { get; set; }
public bool IsTrusted { get; set; } public bool IsTrusted { get; set; }
[EncryptedString]
[EncryptedStringLength(2000)]
public string EncryptedUserKey { get; set; }
[EncryptedString]
[EncryptedStringLength(2000)]
public string EncryptedPublicKey { get; set; }
public PendingAuthRequest DevicePendingAuthRequest { get; set; } public PendingAuthRequest DevicePendingAuthRequest { get; set; }

View File

@ -29,6 +29,8 @@ public class DeviceAuthDetails : Device
Identifier = device.Identifier; Identifier = device.Identifier;
CreationDate = device.CreationDate; CreationDate = device.CreationDate;
IsTrusted = device.IsTrusted(); IsTrusted = device.IsTrusted();
EncryptedPublicKey = device.EncryptedPublicKey;
EncryptedUserKey = device.EncryptedUserKey;
AuthRequestId = authRequestId; AuthRequestId = authRequestId;
AuthRequestCreatedAt = authRequestCreationDate; AuthRequestCreatedAt = authRequestCreationDate;
} }
@ -74,6 +76,8 @@ public class DeviceAuthDetails : Device
EncryptedPrivateKey = encryptedPrivateKey, EncryptedPrivateKey = encryptedPrivateKey,
Active = active Active = active
}.IsTrusted(); }.IsTrusted();
EncryptedPublicKey = encryptedPublicKey;
EncryptedUserKey = encryptedUserKey;
AuthRequestId = authRequestId != Guid.Empty ? authRequestId : null; AuthRequestId = authRequestId != Guid.Empty ? authRequestId : null;
AuthRequestCreatedAt = AuthRequestCreatedAt =
authRequestCreationDate != DateTime.MinValue ? authRequestCreationDate : null; authRequestCreationDate != DateTime.MinValue ? authRequestCreationDate : null;

View File

@ -8,6 +8,7 @@ public interface IRegisterUserCommand
/// <summary> /// <summary>
/// Creates a new user, sends a welcome email, and raises the signup reference event. /// Creates a new user, sends a welcome email, and raises the signup reference event.
/// This method is used for JIT of organization Users.
/// </summary> /// </summary>
/// <param name="user">The <see cref="User"/> to create</param> /// <param name="user">The <see cref="User"/> to create</param>
/// <returns><see cref="IdentityResult"/></returns> /// <returns><see cref="IdentityResult"/></returns>

View File

@ -46,6 +46,7 @@ public static class StripeConstants
public static class MetadataKeys public static class MetadataKeys
{ {
public const string InvoiceApproved = "invoice_approved";
public const string OrganizationId = "organizationId"; public const string OrganizationId = "organizationId";
public const string ProviderId = "providerId"; public const string ProviderId = "providerId";
public const string UserId = "userId"; public const string UserId = "userId";

View File

@ -27,4 +27,8 @@ public static class CustomerExtensions
{ {
return customer != null ? customer.Balance / 100M : default; return customer != null ? customer.Balance / 100M : default;
} }
public static bool ApprovedToPayByInvoice(this Customer customer)
=> customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.InvoiceApproved, out var value) &&
int.TryParse(value, out var invoiceApproved) && invoiceApproved == 1;
} }

View File

@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Enums;
namespace Bit.Core.Billing.Models.Api.Requests.Organizations; namespace Bit.Core.Billing.Models.Api.Requests.Organizations;
@ -20,6 +21,8 @@ public class OrganizationPasswordManagerRequestModel
{ {
public PlanType Plan { get; set; } public PlanType Plan { get; set; }
public PlanSponsorshipType? SponsoredPlan { get; set; }
[Range(0, int.MaxValue)] [Range(0, int.MaxValue)]
public int Seats { get; set; } public int Seats { get; set; }

View File

@ -91,9 +91,20 @@ public class OrganizationBillingService(
var subscription = await subscriberService.GetSubscription(organization); var subscription = await subscriberService.GetSubscription(organization);
if (customer == null || subscription == null)
{
return OrganizationMetadata.Default with
{
IsEligibleForSelfHost = isEligibleForSelfHost,
IsManaged = isManaged
};
}
var isOnSecretsManagerStandalone = await IsOnSecretsManagerStandalone(organization, customer, subscription); var isOnSecretsManagerStandalone = await IsOnSecretsManagerStandalone(organization, customer, subscription);
var invoice = await stripeAdapter.InvoiceGetAsync(subscription.LatestInvoiceId, new InvoiceGetOptions()); var invoice = !string.IsNullOrEmpty(subscription.LatestInvoiceId)
? await stripeAdapter.InvoiceGetAsync(subscription.LatestInvoiceId, new InvoiceGetOptions())
: null;
return new OrganizationMetadata( return new OrganizationMetadata(
isEligibleForSelfHost, isEligibleForSelfHost,

View File

@ -112,7 +112,6 @@ public static class FeatureFlagKeys
/* Auth Team */ /* Auth Team */
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence"; public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence";
public const string DuoRedirect = "duo-redirect";
public const string EmailVerification = "email-verification"; public const string EmailVerification = "email-verification";
public const string DeviceTrustLogging = "pm-8285-device-trust-logging"; public const string DeviceTrustLogging = "pm-8285-device-trust-logging";
public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token"; public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token";
@ -141,6 +140,7 @@ public static class FeatureFlagKeys
/* Billing Team */ /* Billing Team */
public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email"; public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
public const string TrialPayment = "PM-8163-trial-payment"; 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 UsePricingService = "use-pricing-service";
public const string P15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal"; public const string P15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal";
public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features"; public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features";
@ -148,6 +148,7 @@ public static class FeatureFlagKeys
public const string PM19147_AutomaticTaxImprovements = "pm-19147-automatic-tax-improvements"; public const string PM19147_AutomaticTaxImprovements = "pm-19147-automatic-tax-improvements";
public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates";
public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion"; public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion";
public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically";
/* Key Management Team */ /* Key Management Team */
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
@ -156,6 +157,8 @@ public static class FeatureFlagKeys
public const string Argon2Default = "argon2-default"; public const string Argon2Default = "argon2-default";
public const string UserkeyRotationV2 = "userkey-rotation-v2"; public const string UserkeyRotationV2 = "userkey-rotation-v2";
public const string SSHKeyItemVaultItem = "ssh-key-vault-item"; public const string SSHKeyItemVaultItem = "ssh-key-vault-item";
public const string UserSdkForDecryption = "use-sdk-for-decryption";
public const string PM17987_BlockType0 = "pm-17987-block-type-0";
/* Mobile Team */ /* Mobile Team */
public const string NativeCarouselFlow = "native-carousel-flow"; public const string NativeCarouselFlow = "native-carousel-flow";
@ -172,6 +175,7 @@ public static class FeatureFlagKeys
public const string PM3553_MobileSimpleLoginSelfHostAlias = "simple-login-self-host-alias"; public const string PM3553_MobileSimpleLoginSelfHostAlias = "simple-login-self-host-alias";
public const string EnablePMFlightRecorder = "enable-pm-flight-recorder"; public const string EnablePMFlightRecorder = "enable-pm-flight-recorder";
public const string MobileErrorReporting = "mobile-error-reporting"; public const string MobileErrorReporting = "mobile-error-reporting";
public const string AndroidChromeAutofill = "android-chrome-autofill";
/* Platform Team */ /* Platform Team */
public const string PersistPopupView = "persist-popup-view"; public const string PersistPopupView = "persist-popup-view";
@ -185,8 +189,6 @@ public static class FeatureFlagKeys
public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application"; public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application";
public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications"; public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications";
public const string DesktopSendUIRefresh = "desktop-send-ui-refresh"; public const string DesktopSendUIRefresh = "desktop-send-ui-refresh";
public const string ExportAttachments = "export-attachments";
public const string GeneratorToolsModernization = "generator-tools-modernization";
/* Vault Team */ /* Vault Team */
public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge"; public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge";
@ -198,6 +200,8 @@ public static class FeatureFlagKeys
public const string SecurityTasks = "security-tasks"; public const string SecurityTasks = "security-tasks";
public const string CipherKeyEncryption = "cipher-key-encryption"; public const string CipherKeyEncryption = "cipher-key-encryption";
public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms"; public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms";
public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk";
public const string EndUserNotifications = "pm-10609-end-user-notifications";
public static List<string> GetAllKeys() public static List<string> GetAllKeys()
{ {
@ -210,9 +214,6 @@ public static class FeatureFlagKeys
public static Dictionary<string, string> GetLocalOverrideFlagValues() public static Dictionary<string, string> GetLocalOverrideFlagValues()
{ {
// place overriding values when needed locally (offline), or return null // place overriding values when needed locally (offline), or return null
return new Dictionary<string, string>() return null;
{
{ DuoRedirect, "true" },
};
} }
} }

View File

@ -52,7 +52,7 @@
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" /> <PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" /> <PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" />
<PackageReference Include="Sentry.Serilog" Version="5.0.0" /> <PackageReference Include="Sentry.Serilog" Version="5.0.0" />
<PackageReference Include="Duende.IdentityServer" Version="7.1.0" /> <PackageReference Include="Duende.IdentityServer" Version="7.0.8" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog.Sinks.SyslogMessages" Version="4.0.0" /> <PackageReference Include="Serilog.Sinks.SyslogMessages" Version="4.0.0" />
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" /> <PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />

View File

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

View File

@ -22,4 +22,21 @@ public static class Claims
// General // General
public const string Type = "type"; public const string Type = "type";
// Organization custom permissions
public static class CustomPermissions
{
public const string AccessEventLogs = "accesseventlogs";
public const string AccessImportExport = "accessimportexport";
public const string AccessReports = "accessreports";
public const string CreateNewCollections = "createnewcollections";
public const string EditAnyCollection = "editanycollection";
public const string DeleteAnyCollection = "deleteanycollection";
public const string ManageGroups = "managegroups";
public const string ManagePolicies = "managepolicies";
public const string ManageSso = "managesso";
public const string ManageUsers = "manageusers";
public const string ManageResetPassword = "manageresetpassword";
public const string ManageScim = "managescim";
}
} }

View File

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

View File

@ -32,20 +32,22 @@ public class NotificationHubPushNotificationService : IPushNotificationService
private readonly INotificationHubPool _notificationHubPool; private readonly INotificationHubPool _notificationHubPool;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly IGlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings;
private readonly TimeProvider _timeProvider;
public NotificationHubPushNotificationService( public NotificationHubPushNotificationService(
IInstallationDeviceRepository installationDeviceRepository, IInstallationDeviceRepository installationDeviceRepository,
INotificationHubPool notificationHubPool, INotificationHubPool notificationHubPool,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
ILogger<NotificationHubPushNotificationService> logger, ILogger<NotificationHubPushNotificationService> logger,
IGlobalSettings globalSettings) IGlobalSettings globalSettings,
TimeProvider timeProvider)
{ {
_installationDeviceRepository = installationDeviceRepository; _installationDeviceRepository = installationDeviceRepository;
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_notificationHubPool = notificationHubPool; _notificationHubPool = notificationHubPool;
_logger = logger; _logger = logger;
_globalSettings = globalSettings; _globalSettings = globalSettings;
_timeProvider = timeProvider;
if (globalSettings.Installation.Id == Guid.Empty) if (globalSettings.Installation.Id == Guid.Empty)
{ {
logger.LogWarning("Installation ID is not set. Push notifications for installations will not work."); logger.LogWarning("Installation ID is not set. Push notifications for installations will not work.");
@ -152,7 +154,7 @@ public class NotificationHubPushNotificationService : IPushNotificationService
private async Task PushUserAsync(Guid userId, PushType type, bool excludeCurrentContext = false) private async Task PushUserAsync(Guid userId, PushType type, bool excludeCurrentContext = false)
{ {
var message = new UserPushNotification { UserId = userId, Date = DateTime.UtcNow }; var message = new UserPushNotification { UserId = userId, Date = _timeProvider.GetUtcNow().UtcDateTime };
await SendPayloadToUserAsync(userId, type, message, excludeCurrentContext); await SendPayloadToUserAsync(userId, type, message, excludeCurrentContext);
} }

View File

@ -112,6 +112,13 @@ public class ValidateSponsorshipCommand : CancelSponsorshipCommand, IValidateSpo
return false; 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(); var sponsoringOrgProductTier = sponsoringOrganization.PlanType.GetProductTier();
if (sponsoredPlan.SponsoringProductTierType != sponsoringOrgProductTier) if (sponsoredPlan.SponsoringProductTierType != sponsoringOrgProductTier)

View File

@ -1,5 +1,6 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -10,29 +11,24 @@ using Bit.Core.Utilities;
namespace Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise; namespace Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise;
public class CreateSponsorshipCommand : ICreateSponsorshipCommand public class CreateSponsorshipCommand(
ICurrentContext currentContext,
IOrganizationSponsorshipRepository organizationSponsorshipRepository,
IUserService userService) : ICreateSponsorshipCommand
{ {
private readonly IOrganizationSponsorshipRepository _organizationSponsorshipRepository; public async Task<OrganizationSponsorship> CreateSponsorshipAsync(Organization sponsoringOrganization,
private readonly IUserService _userService; OrganizationUser sponsoringMember, PlanSponsorshipType sponsorshipType, string sponsoredEmail,
string friendlyName, string notes)
public CreateSponsorshipCommand(IOrganizationSponsorshipRepository organizationSponsorshipRepository,
IUserService userService)
{ {
_organizationSponsorshipRepository = organizationSponsorshipRepository; var sponsoringUser = await userService.GetUserByIdAsync(sponsoringMember.UserId!.Value);
_userService = userService;
}
public async Task<OrganizationSponsorship> CreateSponsorshipAsync(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, if (sponsoringUser == null || string.Equals(sponsoringUser.Email, sponsoredEmail, StringComparison.InvariantCultureIgnoreCase))
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))
{ {
throw new BadRequestException("Cannot offer a Families Organization Sponsorship to yourself. Choose a different email."); throw new BadRequestException("Cannot offer a Families Organization Sponsorship to yourself. Choose a different email.");
} }
var requiredSponsoringProductType = StaticStore.GetSponsoredPlan(sponsorshipType)?.SponsoringProductTierType; var requiredSponsoringProductType = StaticStore.GetSponsoredPlan(sponsorshipType)?.SponsoringProductTierType;
var sponsoringOrgProductTier = sponsoringOrg.PlanType.GetProductTier(); var sponsoringOrgProductTier = sponsoringOrganization.PlanType.GetProductTier();
if (requiredSponsoringProductType == null || if (requiredSponsoringProductType == null ||
sponsoringOrgProductTier != requiredSponsoringProductType.Value) sponsoringOrgProductTier != requiredSponsoringProductType.Value)
@ -40,26 +36,24 @@ public class CreateSponsorshipCommand : ICreateSponsorshipCommand
throw new BadRequestException("Specified Organization cannot sponsor other organizations."); 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."); throw new BadRequestException("Only confirmed users can sponsor other organizations.");
} }
var existingOrgSponsorship = await _organizationSponsorshipRepository var existingOrgSponsorship = await organizationSponsorshipRepository
.GetBySponsoringOrganizationUserIdAsync(sponsoringOrgUser.Id); .GetBySponsoringOrganizationUserIdAsync(sponsoringMember.Id);
if (existingOrgSponsorship?.SponsoredOrganizationId != null) if (existingOrgSponsorship?.SponsoredOrganizationId != null)
{ {
throw new BadRequestException("Can only sponsor one organization per Organization User."); throw new BadRequestException("Can only sponsor one organization per Organization User.");
} }
var sponsorship = new OrganizationSponsorship var sponsorship = new OrganizationSponsorship();
{ sponsorship.SponsoringOrganizationId = sponsoringOrganization.Id;
SponsoringOrganizationId = sponsoringOrg.Id, sponsorship.SponsoringOrganizationUserId = sponsoringMember.Id;
SponsoringOrganizationUserId = sponsoringOrgUser.Id, sponsorship.FriendlyName = friendlyName;
FriendlyName = friendlyName, sponsorship.OfferedToEmail = sponsoredEmail;
OfferedToEmail = sponsoredEmail, sponsorship.PlanSponsorshipType = sponsorshipType;
PlanSponsorshipType = sponsorshipType,
};
if (existingOrgSponsorship != null) if (existingOrgSponsorship != null)
{ {
@ -67,16 +61,42 @@ public class CreateSponsorshipCommand : ICreateSponsorshipCommand
sponsorship.Id = existingOrgSponsorship.Id; 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 try
{ {
await _organizationSponsorshipRepository.UpsertAsync(sponsorship); await organizationSponsorshipRepository.UpsertAsync(sponsorship);
return sponsorship; return sponsorship;
} }
catch catch
{ {
if (sponsorship.Id != default) if (sponsorship.Id != Guid.Empty)
{ {
await _organizationSponsorshipRepository.DeleteAsync(sponsorship); await organizationSponsorshipRepository.DeleteAsync(sponsorship);
} }
throw; throw;
} }

View File

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

View File

@ -1,4 +1,6 @@
namespace Bit.Core.Platform.Installations; #nullable enable
namespace Bit.Core.Platform.Installations;
/// <summary> /// <summary>
/// Command interface responsible for updating data on an `Installation` /// Command interface responsible for updating data on an `Installation`

View File

@ -1,4 +1,6 @@
namespace Bit.Core.Platform.Installations; #nullable enable
namespace Bit.Core.Platform.Installations;
/// <summary> /// <summary>
/// Commands responsible for updating an installation from /// Commands responsible for updating an installation from

View File

@ -1,4 +1,6 @@
namespace Bit.Core.Platform.Installations; #nullable enable
namespace Bit.Core.Platform.Installations;
/// <summary> /// <summary>
/// Queries responsible for fetching an installation from /// Queries responsible for fetching an installation from
@ -19,7 +21,7 @@ public class GetInstallationQuery : IGetInstallationQuery
} }
/// <inheritdoc cref="IGetInstallationQuery.GetByIdAsync"/> /// <inheritdoc cref="IGetInstallationQuery.GetByIdAsync"/>
public async Task<Installation> GetByIdAsync(Guid installationId) public async Task<Installation?> GetByIdAsync(Guid installationId)
{ {
if (installationId == default(Guid)) if (installationId == default(Guid))
{ {

View File

@ -1,4 +1,6 @@
namespace Bit.Core.Platform.Installations; #nullable enable
namespace Bit.Core.Platform.Installations;
/// <summary> /// <summary>
/// Query interface responsible for fetching an installation from /// Query interface responsible for fetching an installation from
@ -16,5 +18,5 @@ public interface IGetInstallationQuery
/// <param name="installationId">The GUID id of the installation.</param> /// <param name="installationId">The GUID id of the installation.</param>
/// <returns>A task containing an `Installation`.</returns> /// <returns>A task containing an `Installation`.</returns>
/// <seealso cref="T:Bit.Core.Platform.Installations.Repositories.IInstallationRepository"/> /// <seealso cref="T:Bit.Core.Platform.Installations.Repositories.IInstallationRepository"/>
Task<Installation> GetByIdAsync(Guid installationId); Task<Installation?> GetByIdAsync(Guid installationId);
} }

View File

@ -1,4 +1,6 @@
using Bit.Core.Platform.Installations; #nullable enable
using Bit.Core.Platform.Installations;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Platform; namespace Bit.Core.Platform;

View File

@ -22,17 +22,19 @@ public class AzureQueuePushNotificationService : IPushNotificationService
private readonly QueueClient _queueClient; private readonly QueueClient _queueClient;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IGlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings;
private readonly TimeProvider _timeProvider;
public AzureQueuePushNotificationService( public AzureQueuePushNotificationService(
[FromKeyedServices("notifications")] QueueClient queueClient, [FromKeyedServices("notifications")] QueueClient queueClient,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
ILogger<AzureQueuePushNotificationService> logger) ILogger<AzureQueuePushNotificationService> logger,
TimeProvider timeProvider)
{ {
_queueClient = queueClient; _queueClient = queueClient;
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_globalSettings = globalSettings; _globalSettings = globalSettings;
_timeProvider = timeProvider;
if (globalSettings.Installation.Id == Guid.Empty) if (globalSettings.Installation.Id == Guid.Empty)
{ {
logger.LogWarning("Installation ID is not set. Push notifications for installations will not work."); logger.LogWarning("Installation ID is not set. Push notifications for installations will not work.");
@ -140,7 +142,7 @@ public class AzureQueuePushNotificationService : IPushNotificationService
private async Task PushUserAsync(Guid userId, PushType type, bool excludeCurrentContext = false) private async Task PushUserAsync(Guid userId, PushType type, bool excludeCurrentContext = false)
{ {
var message = new UserPushNotification { UserId = userId, Date = DateTime.UtcNow }; var message = new UserPushNotification { UserId = userId, Date = _timeProvider.GetUtcNow().UtcDateTime };
await SendMessageAsync(type, message, excludeCurrentContext); await SendMessageAsync(type, message, excludeCurrentContext);
} }

View File

@ -1,4 +1,6 @@
using Bit.Core.Enums; #nullable enable
using Bit.Core.Enums;
using Bit.Core.NotificationHub; using Bit.Core.NotificationHub;
namespace Bit.Core.Platform.Push; namespace Bit.Core.Platform.Push;

View File

@ -1,4 +1,6 @@
using Bit.Core.Enums; #nullable enable
using Bit.Core.Enums;
using Bit.Core.NotificationHub; using Bit.Core.NotificationHub;
namespace Bit.Core.Platform.Push.Internal; namespace Bit.Core.Platform.Push.Internal;

View File

@ -24,12 +24,14 @@ public class NotificationsApiPushNotificationService : BaseIdentityClientService
{ {
private readonly IGlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly TimeProvider _timeProvider;
public NotificationsApiPushNotificationService( public NotificationsApiPushNotificationService(
IHttpClientFactory httpFactory, IHttpClientFactory httpFactory,
GlobalSettings globalSettings, GlobalSettings globalSettings,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
ILogger<NotificationsApiPushNotificationService> logger) ILogger<NotificationsApiPushNotificationService> logger,
TimeProvider timeProvider)
: base( : base(
httpFactory, httpFactory,
globalSettings.BaseServiceUri.InternalNotifications, globalSettings.BaseServiceUri.InternalNotifications,
@ -41,6 +43,7 @@ public class NotificationsApiPushNotificationService : BaseIdentityClientService
{ {
_globalSettings = globalSettings; _globalSettings = globalSettings;
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_timeProvider = timeProvider;
} }
public async Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable<Guid> collectionIds) public async Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable<Guid> collectionIds)
@ -148,7 +151,7 @@ public class NotificationsApiPushNotificationService : BaseIdentityClientService
var message = new UserPushNotification var message = new UserPushNotification
{ {
UserId = userId, UserId = userId,
Date = DateTime.UtcNow Date = _timeProvider.GetUtcNow().UtcDateTime,
}; };
await SendMessageAsync(type, message, excludeCurrentContext); await SendMessageAsync(type, message, excludeCurrentContext);

View File

@ -27,13 +27,15 @@ public class RelayPushNotificationService : BaseIdentityClientService, IPushNoti
private readonly IDeviceRepository _deviceRepository; private readonly IDeviceRepository _deviceRepository;
private readonly IGlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly TimeProvider _timeProvider;
public RelayPushNotificationService( public RelayPushNotificationService(
IHttpClientFactory httpFactory, IHttpClientFactory httpFactory,
IDeviceRepository deviceRepository, IDeviceRepository deviceRepository,
GlobalSettings globalSettings, GlobalSettings globalSettings,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
ILogger<RelayPushNotificationService> logger) ILogger<RelayPushNotificationService> logger,
TimeProvider timeProvider)
: base( : base(
httpFactory, httpFactory,
globalSettings.PushRelayBaseUri, globalSettings.PushRelayBaseUri,
@ -46,6 +48,7 @@ public class RelayPushNotificationService : BaseIdentityClientService, IPushNoti
_deviceRepository = deviceRepository; _deviceRepository = deviceRepository;
_globalSettings = globalSettings; _globalSettings = globalSettings;
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_timeProvider = timeProvider;
} }
public async Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable<Guid> collectionIds) public async Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable<Guid> collectionIds)
@ -147,7 +150,7 @@ public class RelayPushNotificationService : BaseIdentityClientService, IPushNoti
private async Task PushUserAsync(Guid userId, PushType type, bool excludeCurrentContext = false) private async Task PushUserAsync(Guid userId, PushType type, bool excludeCurrentContext = false)
{ {
var message = new UserPushNotification { UserId = userId, Date = DateTime.UtcNow }; var message = new UserPushNotification { UserId = userId, Date = _timeProvider.GetUtcNow().UtcDateTime };
await SendPayloadToUserAsync(userId, type, message, excludeCurrentContext); await SendPayloadToUserAsync(userId, type, message, excludeCurrentContext);
} }

View File

@ -1,4 +1,6 @@
using Bit.Core.Enums; #nullable enable
using Bit.Core.Enums;
using Bit.Core.IdentityServer; using Bit.Core.IdentityServer;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
using Bit.Core.NotificationHub; using Bit.Core.NotificationHub;

View File

@ -48,7 +48,7 @@ public interface ICollectionRepository : IRepository<Collection, Guid>
Task<CollectionAdminDetails?> GetByIdWithPermissionsAsync(Guid collectionId, Guid? userId, bool includeAccessRelationships); Task<CollectionAdminDetails?> GetByIdWithPermissionsAsync(Guid collectionId, Guid? userId, bool includeAccessRelationships);
Task CreateAsync(Collection obj, IEnumerable<CollectionAccessSelection>? groups, IEnumerable<CollectionAccessSelection>? users); Task CreateAsync(Collection obj, IEnumerable<CollectionAccessSelection>? groups, IEnumerable<CollectionAccessSelection>? users);
Task ReplaceAsync(Collection obj, IEnumerable<CollectionAccessSelection> groups, IEnumerable<CollectionAccessSelection> users); Task ReplaceAsync(Collection obj, IEnumerable<CollectionAccessSelection>? groups, IEnumerable<CollectionAccessSelection>? users);
Task DeleteUserAsync(Guid collectionId, Guid organizationUserId); Task DeleteUserAsync(Guid collectionId, Guid organizationUserId);
Task UpdateUsersAsync(Guid id, IEnumerable<CollectionAccessSelection> users); Task UpdateUsersAsync(Guid id, IEnumerable<CollectionAccessSelection> users);
Task<ICollection<CollectionAccessSelection>> GetManyUsersByIdAsync(Guid id); Task<ICollection<CollectionAccessSelection>> GetManyUsersByIdAsync(Guid id);

Some files were not shown because too many files have changed in this diff Show More