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

Merge branch 'main' into km/pm-10600

# Conflicts:
#	src/Core/NotificationHub/NotificationHubPushRegistrationService.cs
This commit is contained in:
Maciej Zieniuk
2024-11-19 20:22:10 +00:00
226 changed files with 24114 additions and 3834 deletions

View File

@ -2,15 +2,16 @@
using Bit.Api.AdminConsole.Models.Response;
using Bit.Api.Models.Response;
using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Api.Vault.AuthorizationHandlers.Groups;
using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -89,11 +90,34 @@ public class GroupsController : Controller
}
[HttpGet("")]
public async Task<ListResponseModel<GroupDetailsResponseModel>> Get(Guid orgId)
public async Task<ListResponseModel<GroupDetailsResponseModel>> GetOrganizationGroups(Guid orgId)
{
var authorized =
(await _authorizationService.AuthorizeAsync(User, GroupOperations.ReadAll(orgId))).Succeeded;
if (!authorized)
var authResult = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), GroupOperations.ReadAll);
if (!authResult.Succeeded)
{
throw new NotFoundException();
}
if (_featureService.IsEnabled(FeatureFlagKeys.SecureOrgGroupDetails))
{
var groups = await _groupRepository.GetManyByOrganizationIdAsync(orgId);
var responses = groups.Select(g => new GroupDetailsResponseModel(g, []));
return new ListResponseModel<GroupDetailsResponseModel>(responses);
}
var groupDetails = await _groupRepository.GetManyWithCollectionsByOrganizationIdAsync(orgId);
var detailResponses = groupDetails.Select(g => new GroupDetailsResponseModel(g.Item1, g.Item2));
return new ListResponseModel<GroupDetailsResponseModel>(detailResponses);
}
[HttpGet("details")]
public async Task<ListResponseModel<GroupDetailsResponseModel>> GetOrganizationGroupDetails(Guid orgId)
{
var authResult = _featureService.IsEnabled(FeatureFlagKeys.SecureOrgGroupDetails)
? await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), GroupOperations.ReadAllDetails)
: await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), GroupOperations.ReadAll);
if (!authResult.Succeeded)
{
throw new NotFoundException();
}

View File

@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories;

View File

@ -252,6 +252,12 @@ public class OrganizationsController : Controller
throw new BadRequestException("Your organization's Single Sign-On settings prevent you from leaving.");
}
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& (await _userService.GetOrganizationsManagingUserAsync(user.Id)).Any(x => x.Id == id))
{
throw new BadRequestException("Managed user account cannot leave managing organization. Contact your organization administrator for additional details.");
}
await _removeOrganizationUserCommand.RemoveUserAsync(id, user.Id);
}
@ -283,9 +289,7 @@ public class OrganizationsController : Controller
throw new BadRequestException(string.Empty, "User verification failed.");
}
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
if (consolidatedBillingEnabled && organization.IsValidClient())
if (organization.IsValidClient())
{
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
@ -316,8 +320,7 @@ public class OrganizationsController : Controller
throw new BadRequestException("Invalid token.");
}
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
if (consolidatedBillingEnabled && organization.IsValidClient())
if (organization.IsValidClient())
{
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
if (provider.IsBillable())
@ -351,7 +354,7 @@ public class OrganizationsController : Controller
{
// Non-enterprise orgs should not be able to create or view an apikey of billing sync/scim key types
var plan = StaticStore.GetPlan(organization.PlanType);
if (plan.ProductTier != ProductTierType.Enterprise)
if (plan.ProductTier is not ProductTierType.Enterprise and not ProductTierType.Teams)
{
throw new NotFoundException();
}

View File

@ -1,7 +1,11 @@
using Bit.Api.AdminConsole.Models.Request;
using Bit.Api.AdminConsole.Models.Response.Helpers;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Api.Response;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Models.Business.Tokenables;
@ -16,7 +20,6 @@ using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc;
using AdminConsoleEntities = Bit.Core.AdminConsole.Entities;
namespace Bit.Api.AdminConsole.Controllers;
@ -32,6 +35,8 @@ public class PoliciesController : Controller
private readonly GlobalSettings _globalSettings;
private readonly IDataProtector _organizationServiceDataProtector;
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
private readonly IFeatureService _featureService;
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
public PoliciesController(
IPolicyRepository policyRepository,
@ -41,7 +46,9 @@ public class PoliciesController : Controller
ICurrentContext currentContext,
GlobalSettings globalSettings,
IDataProtectionProvider dataProtectionProvider,
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory)
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
IFeatureService featureService,
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery)
{
_policyRepository = policyRepository;
_policyService = policyService;
@ -53,10 +60,12 @@ public class PoliciesController : Controller
"OrganizationServiceDataProtector");
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
_featureService = featureService;
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
}
[HttpGet("{type}")]
public async Task<PolicyResponseModel> Get(Guid orgId, int type)
public async Task<PolicyDetailResponseModel> Get(Guid orgId, int type)
{
if (!await _currentContext.ManagePolicies(orgId))
{
@ -65,10 +74,15 @@ public class PoliciesController : Controller
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type);
if (policy == null)
{
return new PolicyResponseModel(new AdminConsoleEntities.Policy() { Type = (PolicyType)type, Enabled = false });
return new PolicyDetailResponseModel(new Policy { Type = (PolicyType)type });
}
return new PolicyResponseModel(policy);
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && policy.Type is PolicyType.SingleOrg)
{
return await policy.GetSingleOrgPolicyDetailResponseAsync(_organizationHasVerifiedDomainsQuery);
}
return new PolicyDetailResponseModel(policy);
}
[HttpGet("")]
@ -81,8 +95,8 @@ public class PoliciesController : Controller
}
var policies = await _policyRepository.GetManyByOrganizationIdAsync(orgIdGuid);
var responses = policies.Select(p => new PolicyResponseModel(p));
return new ListResponseModel<PolicyResponseModel>(responses);
return new ListResponseModel<PolicyResponseModel>(policies.Select(p => new PolicyResponseModel(p)));
}
[AllowAnonymous]

View File

@ -0,0 +1,19 @@
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
namespace Bit.Api.AdminConsole.Models.Response.Helpers;
public static class PolicyDetailResponses
{
public static async Task<PolicyDetailResponseModel> GetSingleOrgPolicyDetailResponseAsync(this Policy policy, IOrganizationHasVerifiedDomainsQuery hasVerifiedDomainsQuery)
{
if (policy.Type is not PolicyType.SingleOrg)
{
throw new ArgumentException($"'{nameof(policy)}' must be of type '{nameof(PolicyType.SingleOrg)}'.", nameof(policy));
}
return new PolicyDetailResponseModel(policy, !await hasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policy.OrganizationId));
}
}

View File

@ -0,0 +1,20 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class PolicyDetailResponseModel : PolicyResponseModel
{
public PolicyDetailResponseModel(Policy policy, string obj = "policy") : base(policy, obj)
{
}
public PolicyDetailResponseModel(Policy policy, bool canToggleState) : base(policy)
{
CanToggleState = canToggleState;
}
/// <summary>
/// Indicates whether the Policy can be enabled/disabled
/// </summary>
public bool CanToggleState { get; set; } = true;
}

View File

@ -0,0 +1,33 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.Models.Api;
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class PolicyResponseModel : ResponseModel
{
public PolicyResponseModel(Policy policy, string obj = "policy")
: base(obj)
{
if (policy == null)
{
throw new ArgumentNullException(nameof(policy));
}
Id = policy.Id;
OrganizationId = policy.OrganizationId;
Type = policy.Type;
Enabled = policy.Enabled;
if (!string.IsNullOrWhiteSpace(policy.Data))
{
Data = JsonSerializer.Deserialize<Dictionary<string, object>>(policy.Data);
}
}
public Guid Id { get; set; }
public Guid OrganizationId { get; set; }
public PolicyType Type { get; set; }
public Dictionary<string, object> Data { get; set; }
public bool Enabled { get; set; }
}

View File

@ -41,14 +41,13 @@ public class PoliciesController : Controller
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> Get(PolicyType type)
{
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(
_currentContext.OrganizationId.Value, type);
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(_currentContext.OrganizationId.Value, type);
if (policy == null)
{
return new NotFoundResult();
}
var response = new PolicyResponseModel(policy);
return new JsonResult(response);
return new JsonResult(new PolicyResponseModel(policy));
}
/// <summary>
@ -62,9 +61,8 @@ public class PoliciesController : Controller
public async Task<IActionResult> List()
{
var policies = await _policyRepository.GetManyByOrganizationIdAsync(_currentContext.OrganizationId.Value);
var policyResponses = policies.Select(p => new PolicyResponseModel(p));
var response = new ListResponseModel<PolicyResponseModel>(policyResponses);
return new JsonResult(response);
return new JsonResult(new ListResponseModel<PolicyResponseModel>(policies.Select(p => new PolicyResponseModel(p))));
}
/// <summary>

View File

@ -18,7 +18,6 @@ public abstract class MemberBaseModel
Type = user.Type;
ExternalId = user.ExternalId;
ResetPasswordEnrolled = user.ResetPasswordKey != null;
if (Type == OrganizationUserType.Custom)
{
@ -35,7 +34,6 @@ public abstract class MemberBaseModel
Type = user.Type;
ExternalId = user.ExternalId;
ResetPasswordEnrolled = user.ResetPasswordKey != null;
if (Type == OrganizationUserType.Custom)
{
@ -55,11 +53,7 @@ public abstract class MemberBaseModel
/// <example>external_id_123456</example>
[StringLength(300)]
public string ExternalId { get; set; }
/// <summary>
/// Returns <c>true</c> if the member has enrolled in Password Reset assistance within the organization
/// </summary>
[Required]
public bool ResetPasswordEnrolled { get; set; }
/// <summary>
/// The member's custom permissions if the member has a Custom role. If not supplied, all custom permissions will
/// default to false.

View File

@ -28,6 +28,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
Email = user.Email;
Status = user.Status;
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
ResetPasswordEnrolled = user.ResetPasswordKey != null;
}
public MemberResponseModel(OrganizationUserUserDetails user, bool twoFactorEnabled,
@ -45,6 +46,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
TwoFactorEnabled = twoFactorEnabled;
Status = user.Status;
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
ResetPasswordEnrolled = user.ResetPasswordKey != null;
}
/// <summary>
@ -93,4 +95,10 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
/// The associated collections that this member can access.
/// </summary>
public IEnumerable<AssociationWithPermissionsResponseModel> Collections { get; set; }
/// <summary>
/// Returns <c>true</c> if the member has enrolled in Password Reset assistance within the organization
/// </summary>
[Required]
public bool ResetPasswordEnrolled { get; }
}

View File

@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<UserSecretsId>bitwarden-Api</UserSecretsId>
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>

View File

@ -1,10 +1,10 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Api.Auth.Models.Request;
using Bit.Api.Auth.Models.Response;
using Bit.Api.Models.Response;
using Bit.Api.Vault.Models.Response;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Api.Response;
using Bit.Core.Auth.Services;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;

View File

@ -4,15 +4,14 @@ using Bit.Api.Auth.Models.Response.TwoFactor;
using Bit.Api.Models.Request;
using Bit.Api.Models.Response;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Utilities;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tokens;
using Bit.Core.Utilities;
using Fido2NetLib;
@ -29,11 +28,10 @@ public class TwoFactorController : Controller
private readonly IUserService _userService;
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationService _organizationService;
private readonly GlobalSettings _globalSettings;
private readonly UserManager<User> _userManager;
private readonly ICurrentContext _currentContext;
private readonly IVerifyAuthRequestCommand _verifyAuthRequestCommand;
private readonly IFeatureService _featureService;
private readonly IDuoUniversalTokenService _duoUniversalTokenService;
private readonly IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> _twoFactorAuthenticatorDataProtector;
private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _ssoEmailTwoFactorSessionDataProtector;
@ -41,22 +39,20 @@ public class TwoFactorController : Controller
IUserService userService,
IOrganizationRepository organizationRepository,
IOrganizationService organizationService,
GlobalSettings globalSettings,
UserManager<User> userManager,
ICurrentContext currentContext,
IVerifyAuthRequestCommand verifyAuthRequestCommand,
IFeatureService featureService,
IDuoUniversalTokenService duoUniversalConfigService,
IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> twoFactorAuthenticatorDataProtector,
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> ssoEmailTwoFactorSessionDataProtector)
{
_userService = userService;
_organizationRepository = organizationRepository;
_organizationService = organizationService;
_globalSettings = globalSettings;
_userManager = userManager;
_currentContext = currentContext;
_verifyAuthRequestCommand = verifyAuthRequestCommand;
_featureService = featureService;
_duoUniversalTokenService = duoUniversalConfigService;
_twoFactorAuthenticatorDataProtector = twoFactorAuthenticatorDataProtector;
_ssoEmailTwoFactorSessionDataProtector = ssoEmailTwoFactorSessionDataProtector;
}
@ -184,21 +180,7 @@ public class TwoFactorController : Controller
public async Task<TwoFactorDuoResponseModel> PutDuo([FromBody] UpdateTwoFactorDuoRequestModel model)
{
var user = await CheckAsync(model, true);
try
{
// for backwards compatibility - will be removed with PM-8107
DuoApi duoApi = null;
if (model.ClientId != null && model.ClientSecret != null)
{
duoApi = new DuoApi(model.ClientId, model.ClientSecret, model.Host);
}
else
{
duoApi = new DuoApi(model.IntegrationKey, model.SecretKey, model.Host);
}
await duoApi.JSONApiCall("GET", "/auth/v2/check");
}
catch (DuoException)
if (!await _duoUniversalTokenService.ValidateDuoConfiguration(model.ClientSecret, model.ClientId, model.Host))
{
throw new BadRequestException(
"Duo configuration settings are not valid. Please re-check the Duo Admin panel.");
@ -241,21 +223,7 @@ public class TwoFactorController : Controller
}
var organization = await _organizationRepository.GetByIdAsync(orgIdGuid) ?? throw new NotFoundException();
try
{
// for backwards compatibility - will be removed with PM-8107
DuoApi duoApi = null;
if (model.ClientId != null && model.ClientSecret != null)
{
duoApi = new DuoApi(model.ClientId, model.ClientSecret, model.Host);
}
else
{
duoApi = new DuoApi(model.IntegrationKey, model.SecretKey, model.Host);
}
await duoApi.JSONApiCall("GET", "/auth/v2/check");
}
catch (DuoException)
if (!await _duoUniversalTokenService.ValidateDuoConfiguration(model.ClientSecret, model.ClientId, model.Host))
{
throw new BadRequestException(
"Duo configuration settings are not valid. Please re-check the Duo Admin panel.");

View File

@ -2,8 +2,8 @@
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Utilities;
using Bit.Core.Entities;
using Fido2NetLib;
@ -43,21 +43,16 @@ public class UpdateTwoFactorAuthenticatorRequestModel : SecretVerificationReques
public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IValidatableObject
{
/*
To support both v2 and v4 we need to remove the required annotation from the properties.
todo - the required annotation will be added back in PM-8107.
String lengths based on Duo's documentation
https://github.com/duosecurity/duo_universal_csharp/blob/main/DuoUniversal/Client.cs
*/
[StringLength(50)]
public string ClientId { get; set; }
[StringLength(50)]
public string ClientSecret { get; set; }
//todo - will remove SKey and IKey with PM-8107
[StringLength(50)]
public string IntegrationKey { get; set; }
//todo - will remove SKey and IKey with PM-8107
[StringLength(50)]
public string SecretKey { get; set; }
[Required]
[StringLength(50)]
[StringLength(20, MinimumLength = 20, ErrorMessage = "Client Id must be exactly 20 characters.")]
public string ClientId { get; set; }
[Required]
[StringLength(40, MinimumLength = 40, ErrorMessage = "Client Secret must be exactly 40 characters.")]
public string ClientSecret { get; set; }
[Required]
public string Host { get; set; }
public User ToUser(User existingUser)
@ -65,22 +60,17 @@ public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IV
var providers = existingUser.GetTwoFactorProviders();
if (providers == null)
{
providers = new Dictionary<TwoFactorProviderType, TwoFactorProvider>();
providers = [];
}
else if (providers.ContainsKey(TwoFactorProviderType.Duo))
{
providers.Remove(TwoFactorProviderType.Duo);
}
Temporary_SyncDuoParams();
providers.Add(TwoFactorProviderType.Duo, new TwoFactorProvider
{
MetaData = new Dictionary<string, object>
{
//todo - will remove SKey and IKey with PM-8107
["SKey"] = SecretKey,
["IKey"] = IntegrationKey,
["ClientSecret"] = ClientSecret,
["ClientId"] = ClientId,
["Host"] = Host
@ -96,22 +86,17 @@ public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IV
var providers = existingOrg.GetTwoFactorProviders();
if (providers == null)
{
providers = new Dictionary<TwoFactorProviderType, TwoFactorProvider>();
providers = [];
}
else if (providers.ContainsKey(TwoFactorProviderType.OrganizationDuo))
{
providers.Remove(TwoFactorProviderType.OrganizationDuo);
}
Temporary_SyncDuoParams();
providers.Add(TwoFactorProviderType.OrganizationDuo, new TwoFactorProvider
{
MetaData = new Dictionary<string, object>
{
//todo - will remove SKey and IKey with PM-8107
["SKey"] = SecretKey,
["IKey"] = IntegrationKey,
["ClientSecret"] = ClientSecret,
["ClientId"] = ClientId,
["Host"] = Host
@ -124,34 +109,22 @@ public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IV
public override IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (!DuoApi.ValidHost(Host))
var results = new List<ValidationResult>();
if (string.IsNullOrWhiteSpace(ClientId))
{
yield return new ValidationResult("Host is invalid.", [nameof(Host)]);
results.Add(new ValidationResult("ClientId is required.", [nameof(ClientId)]));
}
if (string.IsNullOrWhiteSpace(ClientSecret) && string.IsNullOrWhiteSpace(ClientId) &&
string.IsNullOrWhiteSpace(SecretKey) && string.IsNullOrWhiteSpace(IntegrationKey))
{
yield return new ValidationResult("Neither v2 or v4 values are valid.", [nameof(IntegrationKey), nameof(SecretKey), nameof(ClientSecret), nameof(ClientId)]);
}
}
/*
use this method to ensure that both v2 params and v4 params are in sync
todo will be removed in pm-8107
*/
private void Temporary_SyncDuoParams()
{
// Even if IKey and SKey exist prioritize v4 params ClientId and ClientSecret
if (!string.IsNullOrWhiteSpace(ClientSecret) && !string.IsNullOrWhiteSpace(ClientId))
if (string.IsNullOrWhiteSpace(ClientSecret))
{
SecretKey = ClientSecret;
IntegrationKey = ClientId;
results.Add(new ValidationResult("ClientSecret is required.", [nameof(ClientSecret)]));
}
else if (!string.IsNullOrWhiteSpace(SecretKey) && !string.IsNullOrWhiteSpace(IntegrationKey))
if (string.IsNullOrWhiteSpace(Host) || !DuoUniversalTokenService.ValidDuoHost(Host))
{
ClientSecret = SecretKey;
ClientId = IntegrationKey;
results.Add(new ValidationResult("Host is invalid.", [nameof(Host)]));
}
return results;
}
}

View File

@ -13,37 +13,26 @@ public class TwoFactorDuoResponseModel : ResponseModel
public TwoFactorDuoResponseModel(User user)
: base(ResponseObj)
{
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
ArgumentNullException.ThrowIfNull(user);
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
Build(provider);
}
public TwoFactorDuoResponseModel(Organization org)
public TwoFactorDuoResponseModel(Organization organization)
: base(ResponseObj)
{
if (org == null)
{
throw new ArgumentNullException(nameof(org));
}
ArgumentNullException.ThrowIfNull(organization);
var provider = org.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
Build(provider);
}
public bool Enabled { get; set; }
public string Host { get; set; }
//TODO - will remove SecretKey with PM-8107
public string SecretKey { get; set; }
//TODO - will remove IntegrationKey with PM-8107
public string IntegrationKey { get; set; }
public string ClientSecret { get; set; }
public string ClientId { get; set; }
// updated build to assist in the EDD migration for the Duo 2FA provider
private void Build(TwoFactorProvider provider)
{
if (provider?.MetaData != null && provider.MetaData.Count > 0)
@ -54,36 +43,13 @@ public class TwoFactorDuoResponseModel : ResponseModel
{
Host = (string)host;
}
//todo - will remove SKey and IKey with PM-8107
// check Skey and IKey first if they exist
if (provider.MetaData.TryGetValue("SKey", out var sKey))
{
ClientSecret = MaskKey((string)sKey);
SecretKey = MaskKey((string)sKey);
}
if (provider.MetaData.TryGetValue("IKey", out var iKey))
{
IntegrationKey = (string)iKey;
ClientId = (string)iKey;
}
// Even if IKey and SKey exist prioritize v4 params ClientId and ClientSecret
if (provider.MetaData.TryGetValue("ClientSecret", out var clientSecret))
{
if (!string.IsNullOrWhiteSpace((string)clientSecret))
{
ClientSecret = MaskKey((string)clientSecret);
SecretKey = MaskKey((string)clientSecret);
}
ClientSecret = MaskSecret((string)clientSecret);
}
if (provider.MetaData.TryGetValue("ClientId", out var clientId))
{
if (!string.IsNullOrWhiteSpace((string)clientId))
{
ClientId = (string)clientId;
IntegrationKey = (string)clientId;
}
ClientId = (string)clientId;
}
}
else
@ -92,30 +58,7 @@ public class TwoFactorDuoResponseModel : ResponseModel
}
}
/*
use this method to ensure that both v2 params and v4 params are in sync
todo will be removed in pm-8107
*/
private void Temporary_SyncDuoParams()
{
// Even if IKey and SKey exist prioritize v4 params ClientId and ClientSecret
if (!string.IsNullOrWhiteSpace(ClientSecret) && !string.IsNullOrWhiteSpace(ClientId))
{
SecretKey = ClientSecret;
IntegrationKey = ClientId;
}
else if (!string.IsNullOrWhiteSpace(SecretKey) && !string.IsNullOrWhiteSpace(IntegrationKey))
{
ClientSecret = SecretKey;
ClientId = IntegrationKey;
}
else
{
throw new InvalidDataException("Invalid Duo parameters.");
}
}
private static string MaskKey(string key)
private static string MaskSecret(string key)
{
if (string.IsNullOrWhiteSpace(key) || key.Length <= 6)
{

View File

@ -22,9 +22,9 @@ public abstract class BaseBillingController : Controller
new ErrorResponseModel(message),
statusCode: StatusCodes.Status500InternalServerError);
public static JsonHttpResult<ErrorResponseModel> Unauthorized() =>
public static JsonHttpResult<ErrorResponseModel> Unauthorized(string message = "Unauthorized.") =>
TypedResults.Json(
new ErrorResponseModel("Unauthorized."),
new ErrorResponseModel(message),
statusCode: StatusCodes.Status401Unauthorized);
}
}

View File

@ -1,5 +1,4 @@
using Bit.Core;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Extensions;
using Bit.Core.Context;
@ -9,7 +8,6 @@ namespace Bit.Api.Billing.Controllers;
public abstract class BaseProviderController(
ICurrentContext currentContext,
IFeatureService featureService,
ILogger<BaseProviderController> logger,
IProviderRepository providerRepository,
IUserService userService) : BaseBillingController
@ -26,15 +24,6 @@ public abstract class BaseProviderController(
Guid providerId,
Func<Guid, bool> checkAuthorization)
{
if (!featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
{
logger.LogError(
"Cannot run Consolidated Billing operation for provider ({ProviderID}) while feature flag is disabled",
providerId);
return (null, Error.NotFound());
}
var provider = await providerRepository.GetByIdAsync(providerId);
if (provider == null)

View File

@ -1,6 +1,9 @@
using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
@ -31,6 +34,8 @@ public class OrganizationSponsorshipsController : Controller
private readonly ICloudSyncSponsorshipsCommand _syncSponsorshipsCommand;
private readonly ICurrentContext _currentContext;
private readonly IUserService _userService;
private readonly IPolicyRepository _policyRepository;
private readonly IFeatureService _featureService;
public OrganizationSponsorshipsController(
IOrganizationSponsorshipRepository organizationSponsorshipRepository,
@ -45,7 +50,9 @@ public class OrganizationSponsorshipsController : Controller
IRemoveSponsorshipCommand removeSponsorshipCommand,
ICloudSyncSponsorshipsCommand syncSponsorshipsCommand,
IUserService userService,
ICurrentContext currentContext)
ICurrentContext currentContext,
IPolicyRepository policyRepository,
IFeatureService featureService)
{
_organizationSponsorshipRepository = organizationSponsorshipRepository;
_organizationRepository = organizationRepository;
@ -60,6 +67,8 @@ public class OrganizationSponsorshipsController : Controller
_syncSponsorshipsCommand = syncSponsorshipsCommand;
_userService = userService;
_currentContext = currentContext;
_policyRepository = policyRepository;
_featureService = featureService;
}
[Authorize("Application")]
@ -94,9 +103,20 @@ public class OrganizationSponsorshipsController : Controller
[Authorize("Application")]
[HttpPost("validate-token")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<bool> PreValidateSponsorshipToken([FromQuery] string sponsorshipToken)
public async Task<PreValidateSponsorshipResponseModel> PreValidateSponsorshipToken([FromQuery] string sponsorshipToken)
{
return (await _validateRedemptionTokenCommand.ValidateRedemptionTokenAsync(sponsorshipToken, (await CurrentUser).Email)).valid;
var isFreeFamilyPolicyEnabled = false;
var (isValid, sponsorship) = await _validateRedemptionTokenCommand.ValidateRedemptionTokenAsync(sponsorshipToken, (await CurrentUser).Email);
if (isValid && _featureService.IsEnabled(FeatureFlagKeys.DisableFreeFamiliesSponsorship) && sponsorship.SponsoringOrganizationId.HasValue)
{
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsorship.SponsoringOrganizationId.Value,
PolicyType.FreeFamiliesSponsorshipPolicy);
isFreeFamilyPolicyEnabled = policy?.Enabled ?? false;
}
var response = PreValidateSponsorshipResponseModel.From(isValid, isFreeFamilyPolicyEnabled);
return response;
}
[Authorize("Application")]

View File

@ -19,14 +19,13 @@ namespace Bit.Api.Billing.Controllers;
[Authorize("Application")]
public class ProviderBillingController(
ICurrentContext currentContext,
IFeatureService featureService,
ILogger<BaseProviderController> logger,
IProviderBillingService providerBillingService,
IProviderPlanRepository providerPlanRepository,
IProviderRepository providerRepository,
ISubscriberService subscriberService,
IStripeAdapter stripeAdapter,
IUserService userService) : BaseProviderController(currentContext, featureService, logger, providerRepository, userService)
IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService)
{
[HttpGet("invoices")]
public async Task<IResult> GetInvoicesAsync([FromRoute] Guid providerId)
@ -93,7 +92,8 @@ public class ProviderBillingController(
subscription,
providerPlans,
taxInformation,
subscriptionSuspension);
subscriptionSuspension,
provider);
return TypedResults.Ok(response);
}

View File

@ -14,14 +14,13 @@ namespace Bit.Api.Billing.Controllers;
[Route("providers/{providerId:guid}/clients")]
public class ProviderClientsController(
ICurrentContext currentContext,
IFeatureService featureService,
ILogger<BaseProviderController> logger,
IOrganizationRepository organizationRepository,
IProviderBillingService providerBillingService,
IProviderOrganizationRepository providerOrganizationRepository,
IProviderRepository providerRepository,
IProviderService providerService,
IUserService userService) : BaseProviderController(currentContext, featureService, logger, providerRepository, userService)
IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService)
{
[HttpPost]
public async Task<IResult> CreateAsync(
@ -102,15 +101,27 @@ public class ProviderClientsController(
var clientOrganization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
if (clientOrganization.Seats != requestBody.AssignedSeats)
if (clientOrganization is not { Status: OrganizationStatusType.Managed })
{
await providerBillingService.AssignSeatsToClientOrganization(
provider,
clientOrganization,
requestBody.AssignedSeats);
return Error.ServerError();
}
var seatAdjustment = requestBody.AssignedSeats - (clientOrganization.Seats ?? 0);
var seatAdjustmentResultsInPurchase = await providerBillingService.SeatAdjustmentResultsInPurchase(
provider,
clientOrganization.PlanType,
seatAdjustment);
if (seatAdjustmentResultsInPurchase && !currentContext.ProviderProviderAdmin(provider.Id))
{
return Error.Unauthorized("Service users cannot purchase additional seats.");
}
await providerBillingService.ScaleSeats(provider, clientOrganization.PlanType, seatAdjustment);
clientOrganization.Name = requestBody.Name;
clientOrganization.Seats = requestBody.AssignedSeats;
await organizationRepository.ReplaceAsync(clientOrganization);

View File

@ -12,7 +12,7 @@ public class CreateClientOrganizationRequestBody
[Required(ErrorMessage = "'ownerEmail' must be provided")]
public string OwnerEmail { get; set; }
[EnumMatches<PlanType>(PlanType.TeamsMonthly, PlanType.EnterpriseMonthly, ErrorMessage = "'planType' must be Teams (Monthly) or Enterprise (Monthly)")]
[EnumMatches<PlanType>(PlanType.TeamsMonthly, PlanType.EnterpriseMonthly, PlanType.EnterpriseAnnually, ErrorMessage = "'planType' must be Teams (Monthly), Enterprise (Monthly) or Enterprise (Annually)")]
public PlanType PlanType { get; set; }
[Range(1, int.MaxValue, ErrorMessage = "'seats' must be greater than 0")]

View File

@ -6,12 +6,14 @@ public record OrganizationMetadataResponse(
bool IsEligibleForSelfHost,
bool IsManaged,
bool IsOnSecretsManagerStandalone,
bool IsSubscriptionUnpaid)
bool IsSubscriptionUnpaid,
bool HasSubscription)
{
public static OrganizationMetadataResponse From(OrganizationMetadata metadata)
=> new(
metadata.IsEligibleForSelfHost,
metadata.IsManaged,
metadata.IsOnSecretsManagerStandalone,
metadata.IsSubscriptionUnpaid);
metadata.IsSubscriptionUnpaid,
metadata.HasSubscription);
}

View File

@ -1,4 +1,7 @@
using Bit.Core.Billing.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models;
using Bit.Core.Utilities;
using Stripe;
@ -14,7 +17,8 @@ public record ProviderSubscriptionResponse(
decimal AccountCredit,
TaxInformation TaxInformation,
DateTime? CancelAt,
SubscriptionSuspension Suspension)
SubscriptionSuspension Suspension,
ProviderType ProviderType)
{
private const string _annualCadence = "Annual";
private const string _monthlyCadence = "Monthly";
@ -23,7 +27,8 @@ public record ProviderSubscriptionResponse(
Subscription subscription,
ICollection<ProviderPlan> providerPlans,
TaxInformation taxInformation,
SubscriptionSuspension subscriptionSuspension)
SubscriptionSuspension subscriptionSuspension,
Provider provider)
{
var providerPlanResponses = providerPlans
.Where(providerPlan => providerPlan.IsConfigured())
@ -35,6 +40,8 @@ public record ProviderSubscriptionResponse(
var cadence = plan.IsAnnual ? _annualCadence : _monthlyCadence;
return new ProviderPlanResponse(
plan.Name,
plan.Type,
plan.ProductTier,
configuredProviderPlan.SeatMinimum,
configuredProviderPlan.PurchasedSeats,
configuredProviderPlan.AssignedSeats,
@ -53,12 +60,15 @@ public record ProviderSubscriptionResponse(
accountCredit,
taxInformation,
subscription.CancelAt,
subscriptionSuspension);
subscriptionSuspension,
provider.Type);
}
}
public record ProviderPlanResponse(
string PlanName,
PlanType Type,
ProductTierType ProductTier,
int SeatMinimum,
int PurchasedSeats,
int AssignedSeats,

View File

@ -23,7 +23,6 @@ using Microsoft.OpenApi.Models;
using Bit.SharedWeb.Utilities;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.UserFeatures;
using Bit.Core.Entities;
using Bit.Core.Billing.Extensions;
@ -32,6 +31,8 @@ using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities;
using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Tools.ReportFeatures;
#if !OSS
@ -176,6 +177,7 @@ public class Startup
services.AddOrganizationSubscriptionServices();
services.AddCoreLocalizationServices();
services.AddBillingOperations();
services.AddReportingServices();
// Authorization Handlers
services.AddAuthorizationHandlers();

View File

@ -1,13 +1,13 @@
using Bit.Api.Tools.Models.Response;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Api.Tools.Models;
using Bit.Api.Tools.Models.Response;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Vault.Queries;
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.ReportFeatures.Interfaces;
using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
using Bit.Core.Tools.ReportFeatures.Requests;
using Bit.Core.Tools.Requests;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -17,33 +17,55 @@ namespace Bit.Api.Tools.Controllers;
[Authorize("Application")]
public class ReportsController : Controller
{
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
private readonly IGroupRepository _groupRepository;
private readonly ICollectionRepository _collectionRepository;
private readonly ICurrentContext _currentContext;
private readonly IOrganizationCiphersQuery _organizationCiphersQuery;
private readonly IApplicationCacheService _applicationCacheService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IMemberAccessCipherDetailsQuery _memberAccessCipherDetailsQuery;
private readonly IAddPasswordHealthReportApplicationCommand _addPwdHealthReportAppCommand;
private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery;
public ReportsController(
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
IGroupRepository groupRepository,
ICollectionRepository collectionRepository,
ICurrentContext currentContext,
IOrganizationCiphersQuery organizationCiphersQuery,
IApplicationCacheService applicationCacheService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery
IMemberAccessCipherDetailsQuery memberAccessCipherDetailsQuery,
IAddPasswordHealthReportApplicationCommand addPasswordHealthReportApplicationCommand,
IGetPasswordHealthReportApplicationQuery getPasswordHealthReportApplicationQuery
)
{
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
_groupRepository = groupRepository;
_collectionRepository = collectionRepository;
_currentContext = currentContext;
_organizationCiphersQuery = organizationCiphersQuery;
_applicationCacheService = applicationCacheService;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_memberAccessCipherDetailsQuery = memberAccessCipherDetailsQuery;
_addPwdHealthReportAppCommand = addPasswordHealthReportApplicationCommand;
_getPwdHealthReportAppQuery = getPasswordHealthReportApplicationQuery;
}
/// <summary>
/// Organization member information containing a list of cipher ids
/// assigned
/// </summary>
/// <param name="orgId">Organzation Id</param>
/// <returns>IEnumerable of MemberCipherDetailsResponseModel</returns>
/// <exception cref="NotFoundException">If Access reports permission is not assigned</exception>
[HttpGet("member-cipher-details/{orgId}")]
public async Task<IEnumerable<MemberCipherDetailsResponseModel>> GetMemberCipherDetails(Guid orgId)
{
// Using the AccessReports permission here until new permissions
// are needed for more control over reports
if (!await _currentContext.AccessReports(orgId))
{
throw new NotFoundException();
}
var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId });
var responses = memberCipherDetails.Select(x => new MemberCipherDetailsResponseModel(x));
return responses;
}
/// <summary>
/// Access details for an organization member. Includes the member information,
/// group collection assignment, and item counts
/// </summary>
/// <param name="orgId">Organization Id</param>
/// <returns>IEnumerable of MemberAccessReportResponseModel</returns>
/// <exception cref="NotFoundException">If Access reports permission is not assigned</exception>
[HttpGet("member-access/{orgId}")]
public async Task<IEnumerable<MemberAccessReportResponseModel>> GetMemberAccessReport(Guid orgId)
{
@ -52,26 +74,91 @@ public class ReportsController : Controller
throw new NotFoundException();
}
var orgUsers = await _organizationUserUserDetailsQuery.GetOrganizationUserUserDetails(
new OrganizationUserUserDetailsQueryRequest
{
OrganizationId = orgId,
IncludeCollections = true,
IncludeGroups = true
});
var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId });
var orgGroups = await _groupRepository.GetManyByOrganizationIdAsync(orgId);
var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(orgId);
var orgCollectionsWithAccess = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(orgId);
var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(orgId);
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers);
var responses = memberCipherDetails.Select(x => new MemberAccessReportResponseModel(x));
var reports = MemberAccessReportResponseModel.CreateReport(
orgGroups,
orgCollectionsWithAccess,
orgItems,
organizationUsersTwoFactorEnabled,
orgAbility);
return reports;
return responses;
}
/// <summary>
/// Contains the organization member info, the cipher ids associated with the member,
/// and details on their collections, groups, and permissions
/// </summary>
/// <param name="request">Request to the MemberAccessCipherDetailsQuery</param>
/// <returns>IEnumerable of MemberAccessCipherDetails</returns>
private async Task<IEnumerable<MemberAccessCipherDetails>> GetMemberCipherDetails(MemberAccessCipherDetailsRequest request)
{
var memberCipherDetails =
await _memberAccessCipherDetailsQuery.GetMemberAccessCipherDetails(request);
return memberCipherDetails;
}
/// <summary>
/// Get the password health report applications for an organization
/// </summary>
/// <param name="orgId">A valid Organization Id</param>
/// <returns>An Enumerable of PasswordHealthReportApplication </returns>
/// <exception cref="NotFoundException">If the user lacks access</exception>
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
[HttpGet("password-health-report-applications/{orgId}")]
public async Task<IEnumerable<PasswordHealthReportApplication>> GetPasswordHealthReportApplications(Guid orgId)
{
if (!await _currentContext.AccessReports(orgId))
{
throw new NotFoundException();
}
return await _getPwdHealthReportAppQuery.GetPasswordHealthReportApplicationAsync(orgId);
}
/// <summary>
/// Adds a new record into PasswordHealthReportApplication
/// </summary>
/// <param name="request">A single instance of PasswordHealthReportApplication Model</param>
/// <returns>A single instance of PasswordHealthReportApplication</returns>
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
/// <exception cref="NotFoundException">If the user lacks access</exception>
[HttpPost("password-health-report-application")]
public async Task<PasswordHealthReportApplication> AddPasswordHealthReportApplication(
[FromBody] PasswordHealthReportApplicationModel request)
{
if (!await _currentContext.AccessReports(request.OrganizationId))
{
throw new NotFoundException();
}
var commandRequest = new AddPasswordHealthReportApplicationRequest
{
OrganizationId = request.OrganizationId,
Url = request.Url
};
return await _addPwdHealthReportAppCommand.AddPasswordHealthReportApplicationAsync(commandRequest);
}
/// <summary>
/// Adds multiple records into PasswordHealthReportApplication
/// </summary>
/// <param name="request">A enumerable of PasswordHealthReportApplicationModel</param>
/// <returns>An Enumerable of PasswordHealthReportApplication</returns>
/// <exception cref="NotFoundException">If user does not have access to the OrganizationId</exception>
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
[HttpPost("password-health-report-applications")]
public async Task<IEnumerable<PasswordHealthReportApplication>> AddPasswordHealthReportApplications(
[FromBody] IEnumerable<PasswordHealthReportApplicationModel> request)
{
if (request.Any(_ => _currentContext.AccessReports(_.OrganizationId).Result == false))
{
throw new NotFoundException();
}
var commandRequests = request.Select(request => new AddPasswordHealthReportApplicationRequest
{
OrganizationId = request.OrganizationId,
Url = request.Url
}).ToList();
return await _addPwdHealthReportAppCommand.AddPasswordHealthReportApplicationAsync(commandRequests);
}
}

View File

@ -0,0 +1,7 @@
namespace Bit.Api.Tools.Models;
public class PasswordHealthReportApplicationModel
{
public Guid OrganizationId { get; set; }
public string Url { get; set; }
}

View File

@ -1,30 +1,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Vault.Models.Data;
using Bit.Core.Tools.Models.Data;
namespace Bit.Api.Tools.Models.Response;
/// <summary>
/// Member access details. The individual item for the detailed member access
/// report. A collection can be assigned directly to a user without a group or
/// the user can be assigned to a collection through a group. Group level permissions
/// can override collection level permissions.
/// </summary>
public class MemberAccessReportAccessDetails
{
public Guid? CollectionId { get; set; }
public Guid? GroupId { get; set; }
public string GroupName { get; set; }
public string CollectionName { get; set; }
public int ItemCount { get; set; }
public bool? ReadOnly { get; set; }
public bool? HidePasswords { get; set; }
public bool? Manage { get; set; }
}
/// <summary>
/// Contains the collections and group collections a user has access to including
/// the permission level for the collection and group collection.
@ -40,134 +17,18 @@ public class MemberAccessReportResponseModel
public int TotalItemCount { get; set; }
public Guid? UserGuid { get; set; }
public bool UsesKeyConnector { get; set; }
public IEnumerable<MemberAccessReportAccessDetails> AccessDetails { get; set; }
public IEnumerable<MemberAccessDetails> AccessDetails { get; set; }
/// <summary>
/// Generates a report for all members of an organization. Containing summary information
/// such as item, collection, and group counts. As well as detailed information on the
/// user and group collections along with their permissions
/// </summary>
/// <param name="orgGroups">Organization groups collection</param>
/// <param name="orgCollectionsWithAccess">Collections for the organization and the groups/users and permissions</param>
/// <param name="orgItems">Cipher items for the organization with the collections associated with them</param>
/// <param name="organizationUsersTwoFactorEnabled">Organization users and two factor status</param>
/// <param name="orgAbility">Organization ability for account recovery status</param>
/// <returns>List of the MemberAccessReportResponseModel</returns>;
public static IEnumerable<MemberAccessReportResponseModel> CreateReport(
ICollection<Group> orgGroups,
ICollection<Tuple<Collection, CollectionAccessDetails>> orgCollectionsWithAccess,
IEnumerable<CipherOrganizationDetailsWithCollections> orgItems,
IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled,
OrganizationAbility orgAbility)
public MemberAccessReportResponseModel(MemberAccessCipherDetails memberAccessCipherDetails)
{
var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user);
// Create a dictionary to lookup the group names later.
var groupNameDictionary = orgGroups.ToDictionary(x => x.Id, x => x.Name);
// Get collections grouped and into a dictionary for counts
var collectionItems = orgItems
.SelectMany(x => x.CollectionIds,
(x, b) => new { CipherId = x.Id, CollectionId = b })
.GroupBy(y => y.CollectionId,
(key, g) => new { CollectionId = key, Ciphers = g });
var collectionItemCounts = collectionItems.ToDictionary(x => x.CollectionId, x => x.Ciphers.Count());
// Loop through the org users and populate report and access data
var memberAccessReport = new List<MemberAccessReportResponseModel>();
foreach (var user in orgUsers)
{
// Take the collections/groups and create the access details items
var groupAccessDetails = new List<MemberAccessReportAccessDetails>();
var userCollectionAccessDetails = new List<MemberAccessReportAccessDetails>();
foreach (var tCollect in orgCollectionsWithAccess)
{
var itemCounts = collectionItemCounts.TryGetValue(tCollect.Item1.Id, out var itemCount) ? itemCount : 0;
if (tCollect.Item2.Groups.Count() > 0)
{
var groupDetails = tCollect.Item2.Groups.Where((tCollectGroups) => user.Groups.Contains(tCollectGroups.Id)).Select(x =>
new MemberAccessReportAccessDetails
{
CollectionId = tCollect.Item1.Id,
CollectionName = tCollect.Item1.Name,
GroupId = x.Id,
GroupName = groupNameDictionary[x.Id],
ReadOnly = x.ReadOnly,
HidePasswords = x.HidePasswords,
Manage = x.Manage,
ItemCount = itemCounts,
});
groupAccessDetails.AddRange(groupDetails);
}
// All collections assigned to users and their permissions
if (tCollect.Item2.Users.Count() > 0)
{
var userCollectionDetails = tCollect.Item2.Users.Where((tCollectUser) => tCollectUser.Id == user.Id).Select(x =>
new MemberAccessReportAccessDetails
{
CollectionId = tCollect.Item1.Id,
CollectionName = tCollect.Item1.Name,
ReadOnly = x.ReadOnly,
HidePasswords = x.HidePasswords,
Manage = x.Manage,
ItemCount = itemCounts,
});
userCollectionAccessDetails.AddRange(userCollectionDetails);
}
}
var report = new MemberAccessReportResponseModel
{
UserName = user.Name,
Email = user.Email,
TwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == user.Id).twoFactorIsEnabled,
// Both the user's ResetPasswordKey must be set and the organization can UseResetPassword
AccountRecoveryEnabled = !string.IsNullOrEmpty(user.ResetPasswordKey) && orgAbility.UseResetPassword,
UserGuid = user.Id,
UsesKeyConnector = user.UsesKeyConnector
};
var userAccessDetails = new List<MemberAccessReportAccessDetails>();
if (user.Groups.Any())
{
var userGroups = groupAccessDetails.Where(x => user.Groups.Contains(x.GroupId.GetValueOrDefault()));
userAccessDetails.AddRange(userGroups);
}
// There can be edge cases where groups don't have a collection
var groupsWithoutCollections = user.Groups.Where(x => !userAccessDetails.Any(y => x == y.GroupId));
if (groupsWithoutCollections.Count() > 0)
{
var emptyGroups = groupsWithoutCollections.Select(x => new MemberAccessReportAccessDetails
{
GroupId = x,
GroupName = groupNameDictionary[x],
ItemCount = 0
});
userAccessDetails.AddRange(emptyGroups);
}
if (user.Collections.Any())
{
var userCollections = userCollectionAccessDetails.Where(x => user.Collections.Any(y => x.CollectionId == y.Id));
userAccessDetails.AddRange(userCollections);
}
report.AccessDetails = userAccessDetails;
report.TotalItemCount = collectionItems
.Where(x => report.AccessDetails.Any(y => x.CollectionId == y.CollectionId))
.SelectMany(x => x.Ciphers)
.GroupBy(g => g.CipherId).Select(grp => grp.FirstOrDefault())
.Count();
// Distinct items only
var distinctItems = report.AccessDetails.Where(x => x.CollectionId.HasValue).Select(x => x.CollectionId).Distinct();
report.CollectionsCount = distinctItems.Count();
report.GroupsCount = report.AccessDetails.Select(x => x.GroupId).Where(y => y.HasValue).Distinct().Count();
memberAccessReport.Add(report);
}
return memberAccessReport;
this.UserName = memberAccessCipherDetails.UserName;
this.Email = memberAccessCipherDetails.Email;
this.TwoFactorEnabled = memberAccessCipherDetails.TwoFactorEnabled;
this.AccountRecoveryEnabled = memberAccessCipherDetails.AccountRecoveryEnabled;
this.GroupsCount = memberAccessCipherDetails.GroupsCount;
this.CollectionsCount = memberAccessCipherDetails.CollectionsCount;
this.TotalItemCount = memberAccessCipherDetails.TotalItemCount;
this.UserGuid = memberAccessCipherDetails.UserGuid;
this.AccessDetails = memberAccessCipherDetails.AccessDetails;
}
}

View File

@ -0,0 +1,24 @@
using Bit.Core.Tools.Models.Data;
namespace Bit.Api.Tools.Models.Response;
public class MemberCipherDetailsResponseModel
{
public string UserName { get; set; }
public string Email { get; set; }
public bool UsesKeyConnector { get; set; }
/// <summary>
/// A distinct list of the cipher ids associated with
/// the organization member
/// </summary>
public IEnumerable<string> CipherIds { get; set; }
public MemberCipherDetailsResponseModel(MemberAccessCipherDetails memberAccessCipherDetails)
{
this.UserName = memberAccessCipherDetails.UserName;
this.Email = memberAccessCipherDetails.Email;
this.UsesKeyConnector = memberAccessCipherDetails.UsesKeyConnector;
this.CipherIds = memberAccessCipherDetails.CipherIds;
}
}

View File

@ -1,5 +1,5 @@
using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Api.Vault.AuthorizationHandlers.Groups;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
using Bit.Core.IdentityServer;
using Bit.Core.Settings;
using Bit.Core.Utilities;

View File

@ -1,62 +0,0 @@
#nullable enable
using Bit.Core.Context;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Api.Vault.AuthorizationHandlers.Groups;
/// <summary>
/// Handles authorization logic for Group operations.
/// This uses new logic implemented in the Flexible Collections initiative.
/// </summary>
public class GroupAuthorizationHandler : AuthorizationHandler<GroupOperationRequirement>
{
private readonly ICurrentContext _currentContext;
public GroupAuthorizationHandler(ICurrentContext currentContext)
{
_currentContext = currentContext;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
GroupOperationRequirement requirement)
{
// Acting user is not authenticated, fail
if (!_currentContext.UserId.HasValue)
{
context.Fail();
return;
}
if (requirement.OrganizationId == default)
{
context.Fail();
return;
}
var org = _currentContext.GetOrganization(requirement.OrganizationId);
switch (requirement)
{
case not null when requirement.Name == nameof(GroupOperations.ReadAll):
await CanReadAllAsync(context, requirement, org);
break;
}
}
private async Task CanReadAllAsync(AuthorizationHandlerContext context, GroupOperationRequirement requirement,
CurrentContextOrganization? org)
{
// All users of an organization can read all groups belonging to the organization for collection access management
if (org is not null)
{
context.Succeed(requirement);
return;
}
// Allow provider users to read all groups if they are a provider for the target organization
if (await _currentContext.ProviderUserForOrgAsync(requirement.OrganizationId))
{
context.Succeed(requirement);
}
}
}

View File

@ -1,22 +0,0 @@
using Microsoft.AspNetCore.Authorization.Infrastructure;
namespace Bit.Api.Vault.AuthorizationHandlers.Groups;
public class GroupOperationRequirement : OperationAuthorizationRequirement
{
public Guid OrganizationId { get; init; }
public GroupOperationRequirement(string name, Guid organizationId)
{
Name = name;
OrganizationId = organizationId;
}
}
public static class GroupOperations
{
public static GroupOperationRequirement ReadAll(Guid organizationId)
{
return new GroupOperationRequirement(nameof(ReadAll), organizationId);
}
}

View File

@ -99,7 +99,10 @@ public class CiphersController : Controller
throw new NotFoundException();
}
return new CipherMiniResponseModel(cipher, _globalSettings, cipher.OrganizationUseTotp);
var collectionCiphers = await _collectionCipherRepository.GetManyByOrganizationIdAsync(cipher.OrganizationId.Value);
var collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key);
return new CipherMiniDetailsResponseModel(cipher, _globalSettings, collectionCiphersGroupDict, cipher.OrganizationUseTotp);
}
[HttpGet("{id}/full-details")]
@ -600,10 +603,10 @@ public class CiphersController : Controller
[HttpPut("{id}/collections-admin")]
[HttpPost("{id}/collections-admin")]
public async Task PutCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model)
public async Task<CipherMiniDetailsResponseModel> PutCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model)
{
var userId = _userService.GetProperUserId(User).Value;
var cipher = await _cipherRepository.GetByIdAsync(new Guid(id));
var cipher = await _cipherRepository.GetOrganizationDetailsByIdAsync(new Guid(id));
if (cipher == null || !cipher.OrganizationId.HasValue ||
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
@ -621,6 +624,11 @@ public class CiphersController : Controller
}
await _cipherService.SaveCollectionsAsync(cipher, collectionIds, userId, true);
var collectionCiphers = await _collectionCipherRepository.GetManyByOrganizationIdAsync(cipher.OrganizationId.Value);
var collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key);
return new CipherMiniDetailsResponseModel(cipher, _globalSettings, collectionCiphersGroupDict, cipher.OrganizationUseTotp);
}
[HttpPost("bulk-collections")]

View File

@ -1,7 +1,9 @@
using Bit.Api.Vault.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -10,6 +12,7 @@ using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tools.Repositories;
using Bit.Core.Vault.Models.Data;
using Bit.Core.Vault.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -30,6 +33,8 @@ public class SyncController : Controller
private readonly IPolicyRepository _policyRepository;
private readonly ISendRepository _sendRepository;
private readonly GlobalSettings _globalSettings;
private readonly ICurrentContext _currentContext;
private readonly Version _sshKeyCipherMinimumVersion = new(Constants.SSHKeyCipherMinimumVersion);
private readonly IFeatureService _featureService;
public SyncController(
@ -43,6 +48,7 @@ public class SyncController : Controller
IPolicyRepository policyRepository,
ISendRepository sendRepository,
GlobalSettings globalSettings,
ICurrentContext currentContext,
IFeatureService featureService)
{
_userService = userService;
@ -55,6 +61,7 @@ public class SyncController : Controller
_policyRepository = policyRepository;
_sendRepository = sendRepository;
_globalSettings = globalSettings;
_currentContext = currentContext;
_featureService = featureService;
}
@ -77,7 +84,8 @@ public class SyncController : Controller
var hasEnabledOrgs = organizationUserDetails.Any(o => o.Enabled);
var folders = await _folderRepository.GetManyByUserIdAsync(user.Id);
var ciphers = await _cipherRepository.GetManyByUserIdAsync(user.Id, withOrganizations: hasEnabledOrgs);
var allCiphers = await _cipherRepository.GetManyByUserIdAsync(user.Id, withOrganizations: hasEnabledOrgs);
var ciphers = FilterSSHKeys(allCiphers);
var sends = await _sendRepository.GetManyByUserIdAsync(user.Id);
IEnumerable<CollectionDetails> collections = null;
@ -101,4 +109,16 @@ public class SyncController : Controller
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
return response;
}
private ICollection<CipherDetails> FilterSSHKeys(ICollection<CipherDetails> ciphers)
{
if (_currentContext.ClientVersion >= _sshKeyCipherMinimumVersion)
{
return ciphers;
}
else
{
return ciphers.Where(c => c.Type != Core.Vault.Enums.CipherType.SSHKey).ToList();
}
}
}

View File

@ -0,0 +1,26 @@
using Bit.Core.Utilities;
using Bit.Core.Vault.Models.Data;
namespace Bit.Api.Vault.Models;
public class CipherSSHKeyModel
{
public CipherSSHKeyModel() { }
public CipherSSHKeyModel(CipherSSHKeyData data)
{
PrivateKey = data.PrivateKey;
PublicKey = data.PublicKey;
KeyFingerprint = data.KeyFingerprint;
}
[EncryptedString]
[EncryptedStringLength(5000)]
public string PrivateKey { get; set; }
[EncryptedString]
[EncryptedStringLength(5000)]
public string PublicKey { get; set; }
[EncryptedString]
[EncryptedStringLength(1000)]
public string KeyFingerprint { get; set; }
}

View File

@ -37,6 +37,7 @@ public class CipherRequestModel
public CipherCardModel Card { get; set; }
public CipherIdentityModel Identity { get; set; }
public CipherSecureNoteModel SecureNote { get; set; }
public CipherSSHKeyModel SSHKey { get; set; }
public DateTime? LastKnownRevisionDate { get; set; } = null;
public CipherDetails ToCipherDetails(Guid userId, bool allowOrgIdSet = true)
@ -82,6 +83,9 @@ public class CipherRequestModel
case CipherType.SecureNote:
existingCipher.Data = JsonSerializer.Serialize(ToCipherSecureNoteData(), JsonHelpers.IgnoreWritingNull);
break;
case CipherType.SSHKey:
existingCipher.Data = JsonSerializer.Serialize(ToCipherSSHKeyData(), JsonHelpers.IgnoreWritingNull);
break;
default:
throw new ArgumentException("Unsupported type: " + nameof(Type) + ".");
}
@ -230,6 +234,21 @@ public class CipherRequestModel
Type = SecureNote.Type,
};
}
private CipherSSHKeyData ToCipherSSHKeyData()
{
return new CipherSSHKeyData
{
Name = Name,
Notes = Notes,
Fields = Fields?.Select(f => f.ToCipherFieldData()),
PasswordHistory = PasswordHistory?.Select(ph => ph.ToCipherPasswordHistoryData()),
PrivateKey = SSHKey.PrivateKey,
PublicKey = SSHKey.PublicKey,
KeyFingerprint = SSHKey.KeyFingerprint,
};
}
}
public class CipherWithIdRequestModel : CipherRequestModel

View File

@ -48,6 +48,12 @@ public class CipherMiniResponseModel : ResponseModel
cipherData = identityData;
Identity = new CipherIdentityModel(identityData);
break;
case CipherType.SSHKey:
var sshKeyData = JsonSerializer.Deserialize<CipherSSHKeyData>(cipher.Data);
Data = sshKeyData;
cipherData = sshKeyData;
SSHKey = new CipherSSHKeyModel(sshKeyData);
break;
default:
throw new ArgumentException("Unsupported " + nameof(Type) + ".");
}
@ -76,6 +82,7 @@ public class CipherMiniResponseModel : ResponseModel
public CipherCardModel Card { get; set; }
public CipherIdentityModel Identity { get; set; }
public CipherSecureNoteModel SecureNote { get; set; }
public CipherSSHKeyModel SSHKey { get; set; }
public IEnumerable<CipherFieldModel> Fields { get; set; }
public IEnumerable<CipherPasswordHistoryModel> PasswordHistory { get; set; }
public IEnumerable<AttachmentResponseModel> Attachments { get; set; }

View File

@ -1,7 +1,7 @@
using Bit.Api.Models.Response;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Api.Models.Response;
using Bit.Api.Tools.Models.Response;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Api.Response;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Entities;
using Bit.Core.Models.Api;