1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-25 12:45:18 +01:00

Merge branch 'main' into ac/pm-10338/leave-endpoint-to-log-organizationuser_left

This commit is contained in:
Matt Bishop 2024-11-11 16:23:56 -05:00 committed by GitHub
commit 442d98236c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 10437 additions and 271 deletions

View File

@ -21,6 +21,8 @@ jobs:
lint: lint:
name: Lint name: Lint
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs:
- check-run
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -36,7 +38,8 @@ jobs:
build-artifacts: build-artifacts:
name: Build artifacts name: Build artifacts
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: lint needs:
- lint
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -130,7 +133,6 @@ jobs:
security-events: write security-events: write
needs: needs:
- build-artifacts - build-artifacts
- check-run
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -224,7 +226,7 @@ jobs:
- name: Generate Docker image tag - name: Generate Docker image tag
id: tag id: tag
run: | run: |
if [[ $(grep "pull" <<< "${GITHUB_REF}") ]]; then if [[ "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then
IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g") IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g")
else else
IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g") IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g")
@ -476,7 +478,8 @@ jobs:
build-mssqlmigratorutility: build-mssqlmigratorutility:
name: Build MSSQL migrator utility name: Build MSSQL migrator utility
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: lint needs:
- lint
defaults: defaults:
run: run:
shell: bash shell: bash
@ -527,8 +530,10 @@ jobs:
self-host-build: self-host-build:
name: Trigger self-host build name: Trigger self-host build
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: build-docker needs:
- build-docker
steps: steps:
- name: Log in to Azure - CI subscription - name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@ -561,7 +566,8 @@ jobs:
name: Trigger k8s deploy name: Trigger k8s deploy
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: build-docker needs:
- build-docker
steps: steps:
- name: Log in to Azure - CI subscription - name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@ -597,7 +603,8 @@ jobs:
github.event_name == 'pull_request_target' github.event_name == 'pull_request_target'
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment') && contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
needs: build-docker needs:
- build-docker
steps: steps:
- name: Log in to Azure - CI subscription - name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0

View File

@ -1,7 +1,11 @@
using Bit.Api.AdminConsole.Models.Request; 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.Api.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; 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.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Business.Tokenables;
@ -16,7 +20,6 @@ using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using AdminConsoleEntities = Bit.Core.AdminConsole.Entities;
namespace Bit.Api.AdminConsole.Controllers; namespace Bit.Api.AdminConsole.Controllers;
@ -32,6 +35,8 @@ public class PoliciesController : Controller
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly IDataProtector _organizationServiceDataProtector; private readonly IDataProtector _organizationServiceDataProtector;
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory; private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
private readonly IFeatureService _featureService;
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
public PoliciesController( public PoliciesController(
IPolicyRepository policyRepository, IPolicyRepository policyRepository,
@ -41,7 +46,9 @@ public class PoliciesController : Controller
ICurrentContext currentContext, ICurrentContext currentContext,
GlobalSettings globalSettings, GlobalSettings globalSettings,
IDataProtectionProvider dataProtectionProvider, IDataProtectionProvider dataProtectionProvider,
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory) IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
IFeatureService featureService,
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery)
{ {
_policyRepository = policyRepository; _policyRepository = policyRepository;
_policyService = policyService; _policyService = policyService;
@ -53,10 +60,12 @@ public class PoliciesController : Controller
"OrganizationServiceDataProtector"); "OrganizationServiceDataProtector");
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
_featureService = featureService;
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
} }
[HttpGet("{type}")] [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)) if (!await _currentContext.ManagePolicies(orgId))
{ {
@ -65,10 +74,15 @@ public class PoliciesController : Controller
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type); var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type);
if (policy == null) 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("")] [HttpGet("")]
@ -81,8 +95,8 @@ public class PoliciesController : Controller
} }
var policies = await _policyRepository.GetManyByOrganizationIdAsync(orgIdGuid); 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] [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

@ -3,7 +3,7 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
namespace Bit.Core.AdminConsole.Models.Api.Response; namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class PolicyResponseModel : ResponseModel public class PolicyResponseModel : ResponseModel
{ {

View File

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

View File

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

View File

@ -28,6 +28,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
Email = user.Email; Email = user.Email;
Status = user.Status; Status = user.Status;
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c)); Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
ResetPasswordEnrolled = user.ResetPasswordKey != null;
} }
public MemberResponseModel(OrganizationUserUserDetails user, bool twoFactorEnabled, public MemberResponseModel(OrganizationUserUserDetails user, bool twoFactorEnabled,
@ -45,6 +46,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
TwoFactorEnabled = twoFactorEnabled; TwoFactorEnabled = twoFactorEnabled;
Status = user.Status; Status = user.Status;
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c)); Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
ResetPasswordEnrolled = user.ResetPasswordKey != null;
} }
/// <summary> /// <summary>
@ -93,4 +95,10 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
/// The associated collections that this member can access. /// The associated collections that this member can access.
/// </summary> /// </summary>
public IEnumerable<AssociationWithPermissionsResponseModel> Collections { get; set; } 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,10 +1,10 @@
using Bit.Api.AdminConsole.Models.Request.Organizations; 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.Request;
using Bit.Api.Auth.Models.Response; using Bit.Api.Auth.Models.Response;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Api.Vault.Models.Response; using Bit.Api.Vault.Models.Response;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Api.Response;
using Bit.Core.Auth.Services; using Bit.Core.Auth.Services;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;

View File

@ -32,6 +32,8 @@ 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.Auth.Models.Data; using Bit.Core.Auth.Models.Data;
using Bit.Core.Tools.ReportFeatures;
#if !OSS #if !OSS
@ -176,6 +178,7 @@ public class Startup
services.AddOrganizationSubscriptionServices(); services.AddOrganizationSubscriptionServices();
services.AddCoreLocalizationServices(); services.AddCoreLocalizationServices();
services.AddBillingOperations(); services.AddBillingOperations();
services.AddReportingServices();
// Authorization Handlers // Authorization Handlers
services.AddAuthorizationHandlers(); services.AddAuthorizationHandlers();

View File

@ -1,13 +1,13 @@
using Bit.Api.Tools.Models.Response; using Bit.Api.Tools.Models;
using Bit.Core.AdminConsole.Repositories; using Bit.Api.Tools.Models.Response;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Tools.Entities;
using Bit.Core.Services; using Bit.Core.Tools.Models.Data;
using Bit.Core.Vault.Queries; using Bit.Core.Tools.ReportFeatures.Interfaces;
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Bit.Core.Tools.ReportFeatures.Requests;
using Bit.Core.Tools.Requests;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -17,33 +17,55 @@ namespace Bit.Api.Tools.Controllers;
[Authorize("Application")] [Authorize("Application")]
public class ReportsController : Controller public class ReportsController : Controller
{ {
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
private readonly IGroupRepository _groupRepository;
private readonly ICollectionRepository _collectionRepository;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IOrganizationCiphersQuery _organizationCiphersQuery; private readonly IMemberAccessCipherDetailsQuery _memberAccessCipherDetailsQuery;
private readonly IApplicationCacheService _applicationCacheService; private readonly IAddPasswordHealthReportApplicationCommand _addPwdHealthReportAppCommand;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery;
public ReportsController( public ReportsController(
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
IGroupRepository groupRepository,
ICollectionRepository collectionRepository,
ICurrentContext currentContext, ICurrentContext currentContext,
IOrganizationCiphersQuery organizationCiphersQuery, IMemberAccessCipherDetailsQuery memberAccessCipherDetailsQuery,
IApplicationCacheService applicationCacheService, IAddPasswordHealthReportApplicationCommand addPasswordHealthReportApplicationCommand,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery IGetPasswordHealthReportApplicationQuery getPasswordHealthReportApplicationQuery
) )
{ {
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
_groupRepository = groupRepository;
_collectionRepository = collectionRepository;
_currentContext = currentContext; _currentContext = currentContext;
_organizationCiphersQuery = organizationCiphersQuery; _memberAccessCipherDetailsQuery = memberAccessCipherDetailsQuery;
_applicationCacheService = applicationCacheService; _addPwdHealthReportAppCommand = addPasswordHealthReportApplicationCommand;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _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}")] [HttpGet("member-access/{orgId}")]
public async Task<IEnumerable<MemberAccessReportResponseModel>> GetMemberAccessReport(Guid orgId) public async Task<IEnumerable<MemberAccessReportResponseModel>> GetMemberAccessReport(Guid orgId)
{ {
@ -52,26 +74,91 @@ public class ReportsController : Controller
throw new NotFoundException(); throw new NotFoundException();
} }
var orgUsers = await _organizationUserUserDetailsQuery.GetOrganizationUserUserDetails( var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId });
new OrganizationUserUserDetailsQueryRequest
var responses = memberCipherDetails.Select(x => new MemberAccessReportResponseModel(x));
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)
{ {
OrganizationId = orgId, var memberCipherDetails =
IncludeCollections = true, await _memberAccessCipherDetailsQuery.GetMemberAccessCipherDetails(request);
IncludeGroups = true return memberCipherDetails;
}); }
var orgGroups = await _groupRepository.GetManyByOrganizationIdAsync(orgId); /// <summary>
var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(orgId); /// Get the password health report applications for an organization
var orgCollectionsWithAccess = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(orgId); /// </summary>
var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(orgId); /// <param name="orgId">A valid Organization Id</param>
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers); /// <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();
}
var reports = MemberAccessReportResponseModel.CreateReport( return await _getPwdHealthReportAppQuery.GetPasswordHealthReportApplicationAsync(orgId);
orgGroups, }
orgCollectionsWithAccess,
orgItems, /// <summary>
organizationUsersTwoFactorEnabled, /// Adds a new record into PasswordHealthReportApplication
orgAbility); /// </summary>
return reports; /// <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.Tools.Models.Data;
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;
namespace Bit.Api.Tools.Models.Response; 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> /// <summary>
/// Contains the collections and group collections a user has access to including /// Contains the collections and group collections a user has access to including
/// the permission level for the collection and group collection. /// the permission level for the collection and group collection.
@ -40,134 +17,18 @@ public class MemberAccessReportResponseModel
public int TotalItemCount { get; set; } public int TotalItemCount { get; set; }
public Guid? UserGuid { get; set; } public Guid? UserGuid { get; set; }
public bool UsesKeyConnector { get; set; } public bool UsesKeyConnector { get; set; }
public IEnumerable<MemberAccessReportAccessDetails> AccessDetails { get; set; } public IEnumerable<MemberAccessDetails> AccessDetails { get; set; }
/// <summary> public MemberAccessReportResponseModel(MemberAccessCipherDetails memberAccessCipherDetails)
/// 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)
{ {
var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user); this.UserName = memberAccessCipherDetails.UserName;
// Create a dictionary to lookup the group names later. this.Email = memberAccessCipherDetails.Email;
var groupNameDictionary = orgGroups.ToDictionary(x => x.Id, x => x.Name); this.TwoFactorEnabled = memberAccessCipherDetails.TwoFactorEnabled;
this.AccountRecoveryEnabled = memberAccessCipherDetails.AccountRecoveryEnabled;
// Get collections grouped and into a dictionary for counts this.GroupsCount = memberAccessCipherDetails.GroupsCount;
var collectionItems = orgItems this.CollectionsCount = memberAccessCipherDetails.CollectionsCount;
.SelectMany(x => x.CollectionIds, this.TotalItemCount = memberAccessCipherDetails.TotalItemCount;
(x, b) => new { CipherId = x.Id, CollectionId = b }) this.UserGuid = memberAccessCipherDetails.UserGuid;
.GroupBy(y => y.CollectionId, this.AccessDetails = memberAccessCipherDetails.AccessDetails;
(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;
}
} }

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,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.Api.Tools.Models.Response;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Api.Response;
using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;

View File

@ -94,8 +94,6 @@ public class ProviderEventService(
var unassignedEnterpriseSeats = enterpriseProviderPlan.SeatMinimum - enterpriseClientSeats ?? 0; var unassignedEnterpriseSeats = enterpriseProviderPlan.SeatMinimum - enterpriseClientSeats ?? 0;
if (unassignedEnterpriseSeats > 0)
{
invoiceItems.Add(new ProviderInvoiceItem invoiceItems.Add(new ProviderInvoiceItem
{ {
ProviderId = parsedProviderId, ProviderId = parsedProviderId,
@ -108,7 +106,6 @@ public class ProviderEventService(
Total = unassignedEnterpriseSeats * discountedEnterpriseSeatPrice Total = unassignedEnterpriseSeats * discountedEnterpriseSeatPrice
}); });
} }
}
if (teamsProviderPlan.PurchasedSeats is null or 0) if (teamsProviderPlan.PurchasedSeats is null or 0)
{ {
@ -118,8 +115,6 @@ public class ProviderEventService(
var unassignedTeamsSeats = teamsProviderPlan.SeatMinimum - teamsClientSeats ?? 0; var unassignedTeamsSeats = teamsProviderPlan.SeatMinimum - teamsClientSeats ?? 0;
if (unassignedTeamsSeats > 0)
{
invoiceItems.Add(new ProviderInvoiceItem invoiceItems.Add(new ProviderInvoiceItem
{ {
ProviderId = parsedProviderId, ProviderId = parsedProviderId,
@ -132,7 +127,6 @@ public class ProviderEventService(
Total = unassignedTeamsSeats * discountedTeamsSeatPrice Total = unassignedTeamsSeats * discountedTeamsSeatPrice
}); });
} }
}
await Task.WhenAll(invoiceItems.Select(providerInvoiceItemRepository.CreateAsync)); await Task.WhenAll(invoiceItems.Select(providerInvoiceItemRepository.CreateAsync));

View File

@ -20,7 +20,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
private readonly IGlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings;
private readonly IPolicyService _policyService; private readonly IPolicyService _policyService;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IOrganizationService _organizationService;
private readonly ILogger<VerifyOrganizationDomainCommand> _logger; private readonly ILogger<VerifyOrganizationDomainCommand> _logger;
public VerifyOrganizationDomainCommand( public VerifyOrganizationDomainCommand(
@ -30,7 +29,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
IPolicyService policyService, IPolicyService policyService,
IFeatureService featureService, IFeatureService featureService,
IOrganizationService organizationService,
ILogger<VerifyOrganizationDomainCommand> logger) ILogger<VerifyOrganizationDomainCommand> logger)
{ {
_organizationDomainRepository = organizationDomainRepository; _organizationDomainRepository = organizationDomainRepository;
@ -39,7 +37,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
_globalSettings = globalSettings; _globalSettings = globalSettings;
_policyService = policyService; _policyService = policyService;
_featureService = featureService; _featureService = featureService;
_organizationService = organizationService;
_logger = logger; _logger = logger;
} }

View File

@ -87,8 +87,7 @@ public class SavePolicyCommand : ISavePolicyCommand
if (currentPolicy is not { Enabled: true } && policyUpdate.Enabled) if (currentPolicy is not { Enabled: true } && policyUpdate.Enabled)
{ {
var missingRequiredPolicyTypes = validator.RequiredPolicies var missingRequiredPolicyTypes = validator.RequiredPolicies
.Where(requiredPolicyType => .Where(requiredPolicyType => savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true })
savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true })
.ToList(); .ToList();
if (missingRequiredPolicyTypes.Count != 0) if (missingRequiredPolicyTypes.Count != 0)

View File

@ -2,6 +2,7 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
@ -23,7 +24,9 @@ public class SingleOrgPolicyValidator : IPolicyValidator
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
public SingleOrgPolicyValidator( public SingleOrgPolicyValidator(
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
@ -31,14 +34,18 @@ public class SingleOrgPolicyValidator : IPolicyValidator
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
ISsoConfigRepository ssoConfigRepository, ISsoConfigRepository ssoConfigRepository,
ICurrentContext currentContext, ICurrentContext currentContext,
IRemoveOrganizationUserCommand removeOrganizationUserCommand) IFeatureService featureService,
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery)
{ {
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_mailService = mailService; _mailService = mailService;
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_ssoConfigRepository = ssoConfigRepository; _ssoConfigRepository = ssoConfigRepository;
_currentContext = currentContext; _currentContext = currentContext;
_featureService = featureService;
_removeOrganizationUserCommand = removeOrganizationUserCommand; _removeOrganizationUserCommand = removeOrganizationUserCommand;
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
} }
public IEnumerable<PolicyType> RequiredPolicies => []; public IEnumerable<PolicyType> RequiredPolicies => [];
@ -93,9 +100,21 @@ public class SingleOrgPolicyValidator : IPolicyValidator
if (policyUpdate is not { Enabled: true }) if (policyUpdate is not { Enabled: true })
{ {
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(policyUpdate.OrganizationId); var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(policyUpdate.OrganizationId);
return ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]);
var validateDecryptionErrorMessage = ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]);
if (!string.IsNullOrWhiteSpace(validateDecryptionErrorMessage))
{
return validateDecryptionErrorMessage;
} }
return ""; if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policyUpdate.OrganizationId))
{
return "The Single organization policy is required for organizations that have enabled domain verification.";
}
}
return string.Empty;
} }
} }

View File

@ -289,7 +289,7 @@ public class PolicyService : IPolicyService
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(org.Id)) && await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(org.Id))
{ {
throw new BadRequestException("Organization has verified domains."); throw new BadRequestException("The Single organization policy is required for organizations that have enabled domain verification.");
} }
} }

View File

@ -154,6 +154,7 @@ public static class FeatureFlagKeys
public const string IntegrationPage = "pm-14505-admin-console-integration-page"; public const string IntegrationPage = "pm-14505-admin-console-integration-page";
public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss"; public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss";
public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss"; public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss";
public const string SecurityTasks = "security-tasks";
public static List<string> GetAllKeys() public static List<string> GetAllKeys()
{ {

View File

@ -21,8 +21,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" /> <PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.401.35" /> <PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.401.37" />
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.45" /> <PackageReference Include="AWSSDK.SQS" Version="3.7.400.47" />
<PackageReference Include="Azure.Data.Tables" Version="12.9.0" /> <PackageReference Include="Azure.Data.Tables" Version="12.9.0" />
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" /> <PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.10" /> <PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.10" />

View File

@ -1,11 +1,15 @@
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Utilities; using Bit.Core.Utilities;
#nullable enable
namespace Bit.Core.Tools.Entities;
public class PasswordHealthReportApplication : ITableObject<Guid>, IRevisable public class PasswordHealthReportApplication : ITableObject<Guid>, IRevisable
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public Guid OrganizationId { get; set; } public Guid OrganizationId { get; set; }
public string Uri { get; set; } public string? Uri { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow; public DateTime CreationDate { get; set; } = DateTime.UtcNow;
public DateTime RevisionDate { get; set; } = DateTime.UtcNow; public DateTime RevisionDate { get; set; } = DateTime.UtcNow;

View File

@ -0,0 +1,43 @@
namespace Bit.Core.Tools.Models.Data;
public class MemberAccessDetails
{
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>
/// The CipherIds associated with the group/collection access
/// </summary>
public IEnumerable<string> CollectionCipherIds { get; set; }
}
public class MemberAccessCipherDetails
{
public string UserName { get; set; }
public string Email { get; set; }
public bool TwoFactorEnabled { get; set; }
public bool AccountRecoveryEnabled { get; set; }
public int GroupsCount { get; set; }
public int CollectionsCount { get; set; }
public int TotalItemCount { get; set; }
public Guid? UserGuid { get; set; }
public bool UsesKeyConnector { get; set; }
/// <summary>
/// The details for the member's collection access depending
/// on the collections and groups they are assigned to
/// </summary>
public IEnumerable<MemberAccessDetails> AccessDetails { get; set; }
/// <summary>
/// A distinct list of the cipher ids associated with
/// the organization member
/// </summary>
public IEnumerable<string> CipherIds { get; set; }
}

View File

@ -0,0 +1,101 @@
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.ReportFeatures.Interfaces;
using Bit.Core.Tools.Repositories;
using Bit.Core.Tools.Requests;
namespace Bit.Core.Tools.ReportFeatures;
public class AddPasswordHealthReportApplicationCommand : IAddPasswordHealthReportApplicationCommand
{
private IOrganizationRepository _organizationRepo;
private IPasswordHealthReportApplicationRepository _passwordHealthReportApplicationRepo;
public AddPasswordHealthReportApplicationCommand(
IOrganizationRepository organizationRepository,
IPasswordHealthReportApplicationRepository passwordHealthReportApplicationRepository)
{
_organizationRepo = organizationRepository;
_passwordHealthReportApplicationRepo = passwordHealthReportApplicationRepository;
}
public async Task<PasswordHealthReportApplication> AddPasswordHealthReportApplicationAsync(AddPasswordHealthReportApplicationRequest request)
{
var (req, IsValid, errorMessage) = await ValidateRequestAsync(request);
if (!IsValid)
{
throw new BadRequestException(errorMessage);
}
var passwordHealthReportApplication = new PasswordHealthReportApplication
{
OrganizationId = request.OrganizationId,
Uri = request.Url,
};
passwordHealthReportApplication.SetNewId();
var data = await _passwordHealthReportApplicationRepo.CreateAsync(passwordHealthReportApplication);
return data;
}
public async Task<IEnumerable<PasswordHealthReportApplication>> AddPasswordHealthReportApplicationAsync(IEnumerable<AddPasswordHealthReportApplicationRequest> requests)
{
var requestsList = requests.ToList();
// create tasks to validate each request
var tasks = requestsList.Select(async request =>
{
var (req, IsValid, errorMessage) = await ValidateRequestAsync(request);
if (!IsValid)
{
throw new BadRequestException(errorMessage);
}
});
// run validations and allow exceptions to bubble
await Task.WhenAll(tasks);
// create PasswordHealthReportApplication entities
var passwordHealthReportApplications = requestsList.Select(request =>
{
var pwdHealthReportApplication = new PasswordHealthReportApplication
{
OrganizationId = request.OrganizationId,
Uri = request.Url,
};
pwdHealthReportApplication.SetNewId();
return pwdHealthReportApplication;
});
// create and return the entities
var response = new List<PasswordHealthReportApplication>();
foreach (var record in passwordHealthReportApplications)
{
var data = await _passwordHealthReportApplicationRepo.CreateAsync(record);
response.Add(data);
}
return response;
}
private async Task<Tuple<AddPasswordHealthReportApplicationRequest, bool, string>> ValidateRequestAsync(
AddPasswordHealthReportApplicationRequest request)
{
// verify that the organization exists
var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId);
if (organization == null)
{
return new Tuple<AddPasswordHealthReportApplicationRequest, bool, string>(request, false, "Invalid Organization");
}
// ensure that we have a URL
if (string.IsNullOrWhiteSpace(request.Url))
{
return new Tuple<AddPasswordHealthReportApplicationRequest, bool, string>(request, false, "URL is required");
}
return new Tuple<AddPasswordHealthReportApplicationRequest, bool, string>(request, true, string.Empty);
}
}

View File

@ -0,0 +1,27 @@
using Bit.Core.Exceptions;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.ReportFeatures.Interfaces;
using Bit.Core.Tools.Repositories;
namespace Bit.Core.Tools.ReportFeatures;
public class GetPasswordHealthReportApplicationQuery : IGetPasswordHealthReportApplicationQuery
{
private IPasswordHealthReportApplicationRepository _passwordHealthReportApplicationRepo;
public GetPasswordHealthReportApplicationQuery(
IPasswordHealthReportApplicationRepository passwordHealthReportApplicationRepo)
{
_passwordHealthReportApplicationRepo = passwordHealthReportApplicationRepo;
}
public async Task<IEnumerable<PasswordHealthReportApplication>> GetPasswordHealthReportApplicationAsync(Guid organizationId)
{
if (organizationId == Guid.Empty)
{
throw new BadRequestException("OrganizationId is required.");
}
return await _passwordHealthReportApplicationRepo.GetByOrganizationIdAsync(organizationId);
}
}

View File

@ -0,0 +1,10 @@
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Requests;
namespace Bit.Core.Tools.ReportFeatures.Interfaces;
public interface IAddPasswordHealthReportApplicationCommand
{
Task<PasswordHealthReportApplication> AddPasswordHealthReportApplicationAsync(AddPasswordHealthReportApplicationRequest request);
Task<IEnumerable<PasswordHealthReportApplication>> AddPasswordHealthReportApplicationAsync(IEnumerable<AddPasswordHealthReportApplicationRequest> requests);
}

View File

@ -0,0 +1,8 @@
using Bit.Core.Tools.Entities;
namespace Bit.Core.Tools.ReportFeatures.Interfaces;
public interface IGetPasswordHealthReportApplicationQuery
{
Task<IEnumerable<PasswordHealthReportApplication>> GetPasswordHealthReportApplicationAsync(Guid organizationId);
}

View File

@ -0,0 +1,208 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
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.Repositories;
using Bit.Core.Services;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
using Bit.Core.Tools.ReportFeatures.Requests;
using Bit.Core.Vault.Models.Data;
using Bit.Core.Vault.Queries;
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
namespace Bit.Core.Tools.ReportFeatures;
public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery
{
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
private readonly IGroupRepository _groupRepository;
private readonly ICollectionRepository _collectionRepository;
private readonly IOrganizationCiphersQuery _organizationCiphersQuery;
private readonly IApplicationCacheService _applicationCacheService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
public MemberAccessCipherDetailsQuery(
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
IGroupRepository groupRepository,
ICollectionRepository collectionRepository,
IOrganizationCiphersQuery organizationCiphersQuery,
IApplicationCacheService applicationCacheService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery
)
{
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
_groupRepository = groupRepository;
_collectionRepository = collectionRepository;
_organizationCiphersQuery = organizationCiphersQuery;
_applicationCacheService = applicationCacheService;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
}
public async Task<IEnumerable<MemberAccessCipherDetails>> GetMemberAccessCipherDetails(MemberAccessCipherDetailsRequest request)
{
var orgUsers = await _organizationUserUserDetailsQuery.GetOrganizationUserUserDetails(
new OrganizationUserUserDetailsQueryRequest
{
OrganizationId = request.OrganizationId,
IncludeCollections = true,
IncludeGroups = true
});
var orgGroups = await _groupRepository.GetManyByOrganizationIdAsync(request.OrganizationId);
var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(request.OrganizationId);
var orgCollectionsWithAccess = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(request.OrganizationId);
var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(request.OrganizationId);
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers);
var memberAccessCipherDetails = GenerateAccessData(
orgGroups,
orgCollectionsWithAccess,
orgItems,
organizationUsersTwoFactorEnabled,
orgAbility
);
return memberAccessCipherDetails;
}
/// <summary>
/// Generates a report for all members of an organization. Containing summary information
/// such as item, collection, and group counts. Including the cipherIds a member is assigned.
/// Child collection includes 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 MemberAccessCipherDetailsModel</returns>;
private IEnumerable<MemberAccessCipherDetails> GenerateAccessData(
ICollection<Group> orgGroups,
ICollection<Tuple<Collection, CollectionAccessDetails>> orgCollectionsWithAccess,
IEnumerable<CipherOrganizationDetailsWithCollections> orgItems,
IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled,
OrganizationAbility orgAbility)
{
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,
(cipher, collectionId) => new { Cipher = cipher, CollectionId = collectionId })
.GroupBy(y => y.CollectionId,
(key, ciphers) => new { CollectionId = key, Ciphers = ciphers });
var itemLookup = collectionItems.ToDictionary(x => x.CollectionId.ToString(), x => x.Ciphers.Select(c => c.Cipher.Id.ToString()));
// Loop through the org users and populate report and access data
var memberAccessCipherDetails = new List<MemberAccessCipherDetails>();
foreach (var user in orgUsers)
{
var groupAccessDetails = new List<MemberAccessDetails>();
var userCollectionAccessDetails = new List<MemberAccessDetails>();
foreach (var tCollect in orgCollectionsWithAccess)
{
var hasItems = itemLookup.TryGetValue(tCollect.Item1.Id.ToString(), out var items);
var collectionCiphers = hasItems ? items.Select(x => x) : null;
var itemCounts = hasItems ? collectionCiphers.Count() : 0;
if (tCollect.Item2.Groups.Count() > 0)
{
var groupDetails = tCollect.Item2.Groups.Where((tCollectGroups) => user.Groups.Contains(tCollectGroups.Id)).Select(x =>
new MemberAccessDetails
{
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,
CollectionCipherIds = items
});
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 MemberAccessDetails
{
CollectionId = tCollect.Item1.Id,
CollectionName = tCollect.Item1.Name,
ReadOnly = x.ReadOnly,
HidePasswords = x.HidePasswords,
Manage = x.Manage,
ItemCount = itemCounts,
CollectionCipherIds = items
});
userCollectionAccessDetails.AddRange(userCollectionDetails);
}
}
var report = new MemberAccessCipherDetails
{
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<MemberAccessDetails>();
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 MemberAccessDetails
{
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;
var userCiphers =
report.AccessDetails
.Where(x => x.ItemCount > 0)
.SelectMany(y => y.CollectionCipherIds)
.Distinct();
report.CipherIds = userCiphers;
report.TotalItemCount = userCiphers.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();
memberAccessCipherDetails.Add(report);
}
return memberAccessCipherDetails;
}
}

View File

@ -0,0 +1,9 @@
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.ReportFeatures.Requests;
namespace Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
public interface IMemberAccessCipherDetailsQuery
{
Task<IEnumerable<MemberAccessCipherDetails>> GetMemberAccessCipherDetails(MemberAccessCipherDetailsRequest request);
}

View File

@ -0,0 +1,15 @@
using Bit.Core.Tools.ReportFeatures.Interfaces;
using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Tools.ReportFeatures;
public static class ReportingServiceCollectionExtensions
{
public static void AddReportingServices(this IServiceCollection services)
{
services.AddScoped<IMemberAccessCipherDetailsQuery, MemberAccessCipherDetailsQuery>();
services.AddScoped<IAddPasswordHealthReportApplicationCommand, AddPasswordHealthReportApplicationCommand>();
services.AddScoped<IGetPasswordHealthReportApplicationQuery, GetPasswordHealthReportApplicationQuery>();
}
}

View File

@ -0,0 +1,6 @@
namespace Bit.Core.Tools.ReportFeatures.Requests;
public class MemberAccessCipherDetailsRequest
{
public Guid OrganizationId { get; set; }
}

View File

@ -0,0 +1,9 @@
using Bit.Core.Repositories;
using Bit.Core.Tools.Entities;
namespace Bit.Core.Tools.Repositories;
public interface IPasswordHealthReportApplicationRepository : IRepository<PasswordHealthReportApplication, Guid>
{
Task<ICollection<PasswordHealthReportApplication>> GetByOrganizationIdAsync(Guid organizationId);
}

View File

@ -0,0 +1,7 @@
namespace Bit.Core.Tools.Requests;
public class AddPasswordHealthReportApplicationRequest
{
public Guid OrganizationId { get; set; }
public string Url { get; set; }
}

View File

@ -58,6 +58,7 @@ public static class DapperServiceCollectionExtensions
services.AddSingleton<INotificationStatusRepository, NotificationStatusRepository>(); services.AddSingleton<INotificationStatusRepository, NotificationStatusRepository>();
services services
.AddSingleton<IClientOrganizationMigrationRecordRepository, ClientOrganizationMigrationRecordRepository>(); .AddSingleton<IClientOrganizationMigrationRecordRepository, ClientOrganizationMigrationRecordRepository>();
services.AddSingleton<IPasswordHealthReportApplicationRepository, PasswordHealthReportApplicationRepository>();
if (selfHosted) if (selfHosted)
{ {

View File

@ -0,0 +1,33 @@
using System.Data;
using Bit.Core.Settings;
using Bit.Core.Tools.Repositories;
using Bit.Infrastructure.Dapper.Repositories;
using Dapper;
using Microsoft.Data.SqlClient;
using ToolsEntities = Bit.Core.Tools.Entities;
namespace Bit.Infrastructure.Dapper.Tools.Repositories;
public class PasswordHealthReportApplicationRepository : Repository<ToolsEntities.PasswordHealthReportApplication, Guid>, IPasswordHealthReportApplicationRepository
{
public PasswordHealthReportApplicationRepository(GlobalSettings globalSettings)
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
{ }
public PasswordHealthReportApplicationRepository(string connectionString, string readOnlyConnectionString)
: base(connectionString, readOnlyConnectionString)
{ }
public async Task<ICollection<ToolsEntities.PasswordHealthReportApplication>> GetByOrganizationIdAsync(Guid organizationId)
{
using (var connection = new SqlConnection(ReadOnlyConnectionString))
{
var results = await connection.QueryAsync<ToolsEntities.PasswordHealthReportApplication>(
$"[{Schema}].[PasswordHealthReportApplication_ReadByOrganizationId]",
new { OrganizationId = organizationId },
commandType: CommandType.StoredProcedure);
return results.ToList();
}
}
}

View File

@ -95,6 +95,7 @@ public static class EntityFrameworkServiceCollectionExtensions
services.AddSingleton<INotificationStatusRepository, NotificationStatusRepository>(); services.AddSingleton<INotificationStatusRepository, NotificationStatusRepository>();
services services
.AddSingleton<IClientOrganizationMigrationRecordRepository, ClientOrganizationMigrationRecordRepository>(); .AddSingleton<IClientOrganizationMigrationRecordRepository, ClientOrganizationMigrationRecordRepository>();
services.AddSingleton<IPasswordHealthReportApplicationRepository, PasswordHealthReportApplicationRepository>();
if (selfHosted) if (selfHosted)
{ {

View File

@ -7,6 +7,7 @@ using Bit.Infrastructure.EntityFramework.Converters;
using Bit.Infrastructure.EntityFramework.Models; using Bit.Infrastructure.EntityFramework.Models;
using Bit.Infrastructure.EntityFramework.NotificationCenter.Models; using Bit.Infrastructure.EntityFramework.NotificationCenter.Models;
using Bit.Infrastructure.EntityFramework.SecretsManager.Models; using Bit.Infrastructure.EntityFramework.SecretsManager.Models;
using Bit.Infrastructure.EntityFramework.Tools.Models;
using Bit.Infrastructure.EntityFramework.Vault.Models; using Bit.Infrastructure.EntityFramework.Vault.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@ -75,6 +76,7 @@ public class DatabaseContext : DbContext
public DbSet<Notification> Notifications { get; set; } public DbSet<Notification> Notifications { get; set; }
public DbSet<NotificationStatus> NotificationStatuses { get; set; } public DbSet<NotificationStatus> NotificationStatuses { get; set; }
public DbSet<ClientOrganizationMigrationRecord> ClientOrganizationMigrationRecords { get; set; } public DbSet<ClientOrganizationMigrationRecord> ClientOrganizationMigrationRecords { get; set; }
public DbSet<PasswordHealthReportApplication> PasswordHealthReportApplications { get; set; }
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {

View File

@ -0,0 +1,24 @@
using Bit.Infrastructure.EntityFramework.Tools.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Bit.Infrastructure.EntityFramework.Tools.Configurations;
public class PasswordHealthReportApplicationEntityTypeConfiguration : IEntityTypeConfiguration<PasswordHealthReportApplication>
{
public void Configure(EntityTypeBuilder<PasswordHealthReportApplication> builder)
{
builder
.Property(s => s.Id)
.ValueGeneratedNever();
builder.HasIndex(s => s.Id)
.IsClustered(true);
builder
.HasIndex(s => s.OrganizationId)
.IsClustered(false);
builder.ToTable(nameof(PasswordHealthReportApplication));
}
}

View File

@ -0,0 +1,18 @@
using AutoMapper;
using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
namespace Bit.Infrastructure.EntityFramework.Tools.Models;
public class PasswordHealthReportApplication : Core.Tools.Entities.PasswordHealthReportApplication
{
public virtual Organization Organization { get; set; }
}
public class PasswordHealthReportApplicationProfile : Profile
{
public PasswordHealthReportApplicationProfile()
{
CreateMap<Core.Tools.Entities.PasswordHealthReportApplication, PasswordHealthReportApplication>()
.ReverseMap();
}
}

View File

@ -0,0 +1,30 @@
using AutoMapper;
using Bit.Core.Tools.Repositories;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Infrastructure.EntityFramework.Tools.Models;
using LinqToDB;
using Microsoft.Extensions.DependencyInjection;
using AdminConsoleEntities = Bit.Core.Tools.Entities;
namespace Bit.Infrastructure.EntityFramework.Tools.Repositories;
public class PasswordHealthReportApplicationRepository :
Repository<AdminConsoleEntities.PasswordHealthReportApplication, PasswordHealthReportApplication, Guid>,
IPasswordHealthReportApplicationRepository
{
public PasswordHealthReportApplicationRepository(IServiceScopeFactory serviceScopeFactory,
IMapper mapper) : base(serviceScopeFactory, mapper, (DatabaseContext context) => context.PasswordHealthReportApplications)
{ }
public async Task<ICollection<AdminConsoleEntities.PasswordHealthReportApplication>> GetByOrganizationIdAsync(Guid organizationId)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var results = await dbContext.PasswordHealthReportApplications
.Where(p => p.OrganizationId == organizationId)
.ToListAsync();
return Mapper.Map<ICollection<AdminConsoleEntities.PasswordHealthReportApplication>>(results);
}
}
}

View File

@ -34,6 +34,7 @@ using Bit.Core.SecretsManager.Repositories.Noop;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Tokens; using Bit.Core.Tokens;
using Bit.Core.Tools.ReportFeatures;
using Bit.Core.Tools.Services; using Bit.Core.Tools.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Core.Vault; using Bit.Core.Vault;
@ -116,6 +117,7 @@ public static class ServiceCollectionExtensions
services.AddLoginServices(); services.AddLoginServices();
services.AddScoped<IOrganizationDomainService, OrganizationDomainService>(); services.AddScoped<IOrganizationDomainService, OrganizationDomainService>();
services.AddVaultServices(); services.AddVaultServices();
services.AddReportingServices();
} }
public static void AddTokenizers(this IServiceCollection services) public static void AddTokenizers(this IServiceCollection services)

View File

@ -0,0 +1,69 @@
using AutoFixture;
using Bit.Api.AdminConsole.Models.Response.Helpers;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Models.Response.Helpers;
public class PolicyDetailResponsesTests
{
[Fact]
public async Task GetSingleOrgPolicyDetailResponseAsync_GivenPolicyEntity_WhenIsSingleOrgTypeAndHasVerifiedDomains_ThenShouldNotBeAbleToToggle()
{
var fixture = new Fixture();
var policy = fixture.Build<Policy>()
.Without(p => p.Data)
.With(p => p.Type, PolicyType.SingleOrg)
.Create();
var querySub = Substitute.For<IOrganizationHasVerifiedDomainsQuery>();
querySub.HasVerifiedDomainsAsync(policy.OrganizationId)
.Returns(true);
var result = await policy.GetSingleOrgPolicyDetailResponseAsync(querySub);
Assert.False(result.CanToggleState);
}
[Fact]
public async Task GetSingleOrgPolicyDetailResponseAsync_GivenPolicyEntity_WhenIsNotSingleOrgType_ThenShouldThrowArgumentException()
{
var fixture = new Fixture();
var policy = fixture.Build<Policy>()
.Without(p => p.Data)
.With(p => p.Type, PolicyType.TwoFactorAuthentication)
.Create();
var querySub = Substitute.For<IOrganizationHasVerifiedDomainsQuery>();
querySub.HasVerifiedDomainsAsync(policy.OrganizationId)
.Returns(true);
var action = async () => await policy.GetSingleOrgPolicyDetailResponseAsync(querySub);
await Assert.ThrowsAsync<ArgumentException>("policy", action);
}
[Fact]
public async Task GetSingleOrgPolicyDetailResponseAsync_GivenPolicyEntity_WhenIsSingleOrgTypeAndDoesNotHaveVerifiedDomains_ThenShouldBeAbleToToggle()
{
var fixture = new Fixture();
var policy = fixture.Build<Policy>()
.Without(p => p.Data)
.With(p => p.Type, PolicyType.SingleOrg)
.Create();
var querySub = Substitute.For<IOrganizationHasVerifiedDomainsQuery>();
querySub.HasVerifiedDomainsAsync(policy.OrganizationId)
.Returns(false);
var result = await policy.GetSingleOrgPolicyDetailResponseAsync(querySub);
Assert.True(result.CanToggleState);
}
}

View File

@ -0,0 +1,41 @@
using Bit.Api.AdminConsole.Public.Models.Response;
using Bit.Core.Entities;
using Bit.Core.Models.Data;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Public.Models.Response;
public class MemberResponseModelTests
{
[Fact]
public void ResetPasswordEnrolled_ShouldBeTrue_WhenUserHasResetPasswordKey()
{
// Arrange
var user = Substitute.For<OrganizationUser>();
var collections = Substitute.For<IEnumerable<CollectionAccessSelection>>();
user.ResetPasswordKey = "none-empty";
// Act
var sut = new MemberResponseModel(user, collections);
// Assert
Assert.True(sut.ResetPasswordEnrolled);
}
[Fact]
public void ResetPasswordEnrolled_ShouldBeFalse_WhenUserDoesNotHaveResetPasswordKey()
{
// Arrange
var user = Substitute.For<OrganizationUser>();
var collections = Substitute.For<IEnumerable<CollectionAccessSelection>>();
// Act
var sut = new MemberResponseModel(user, collections);
// Assert
Assert.False(sut.ResetPasswordEnrolled);
}
}

View File

@ -1,9 +1,9 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
using Bit.Api.AdminConsole.Controllers; using Bit.Api.AdminConsole.Controllers;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Api.Response;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context; using Bit.Core.Context;
@ -157,7 +157,7 @@ public class PoliciesControllerTests
var result = await sutProvider.Sut.Get(orgId, type); var result = await sutProvider.Sut.Get(orgId, type);
// Assert // Assert
Assert.IsType<PolicyResponseModel>(result); Assert.IsType<PolicyDetailResponseModel>(result);
Assert.Equal(policy.Id, result.Id); Assert.Equal(policy.Id, result.Id);
Assert.Equal(policy.Type, result.Type); Assert.Equal(policy.Type, result.Type);
Assert.Equal(policy.Enabled, result.Enabled); Assert.Equal(policy.Enabled, result.Enabled);
@ -182,7 +182,7 @@ public class PoliciesControllerTests
var result = await sutProvider.Sut.Get(orgId, type); var result = await sutProvider.Sut.Get(orgId, type);
// Assert // Assert
Assert.IsType<PolicyResponseModel>(result); Assert.IsType<PolicyDetailResponseModel>(result);
Assert.Equal(result.Type, (PolicyType)type); Assert.Equal(result.Type, (PolicyType)type);
Assert.False(result.Enabled); Assert.False(result.Enabled);
} }

View File

@ -0,0 +1,49 @@
using Bit.Api.Tools.Controllers;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Tools.ReportFeatures.Interfaces;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.Tools.Controllers;
[ControllerCustomize(typeof(ReportsController))]
[SutProviderCustomize]
public class ReportsControllerTests
{
[Theory, BitAutoData]
public async Task GetPasswordHealthReportApplicationAsync_Success(SutProvider<ReportsController> sutProvider)
{
// Arrange
sutProvider.GetDependency<ICurrentContext>().AccessReports(Arg.Any<Guid>()).Returns(true);
// Act
var orgId = Guid.NewGuid();
var result = await sutProvider.Sut.GetPasswordHealthReportApplications(orgId);
// Assert
_ = sutProvider.GetDependency<IGetPasswordHealthReportApplicationQuery>()
.Received(1)
.GetPasswordHealthReportApplicationAsync(Arg.Is<Guid>(_ => _ == orgId));
}
[Theory, BitAutoData]
public async Task GetPasswordHealthReportApplicationAsync_withoutAccess(SutProvider<ReportsController> sutProvider)
{
// Arrange
sutProvider.GetDependency<ICurrentContext>().AccessReports(Arg.Any<Guid>()).Returns(false);
// Act & Assert
var orgId = Guid.NewGuid();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.GetPasswordHealthReportApplications(orgId));
// Assert
_ = sutProvider.GetDependency<IGetPasswordHealthReportApplicationQuery>()
.Received(0);
}
}

View File

@ -842,6 +842,6 @@ public class PolicyServiceTests
var badRequestException = await Assert.ThrowsAsync<BadRequestException>( var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SaveAsync(policy, null)); () => sutProvider.Sut.SaveAsync(policy, null));
Assert.Equal("Organization has verified domains.", badRequestException.Message); Assert.Equal("The Single organization policy is required for organizations that have enabled domain verification.", badRequestException.Message);
} }
} }

View File

@ -0,0 +1,149 @@
using AutoFixture;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.ReportFeatures;
using Bit.Core.Tools.Repositories;
using Bit.Core.Tools.Requests;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Tools.ReportFeatures;
[SutProviderCustomize]
public class AddPasswordHealthReportApplicationCommandTests
{
[Theory]
[BitAutoData]
public async Task AddPasswordHealthReportApplicationAsync_WithValidRequest_ShouldReturnPasswordHealthReportApplication(
SutProvider<AddPasswordHealthReportApplicationCommand> sutProvider)
{
// Arrange
var fixture = new Fixture();
var request = fixture.Create<AddPasswordHealthReportApplicationRequest>();
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(fixture.Create<Organization>());
sutProvider.GetDependency<IPasswordHealthReportApplicationRepository>()
.CreateAsync(Arg.Any<PasswordHealthReportApplication>())
.Returns(c => c.Arg<PasswordHealthReportApplication>());
// Act
var result = await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request);
// Assert
Assert.NotNull(result);
}
[Theory]
[BitAutoData]
public async Task AddPasswordHealthReportApplicationAsync_WithInvalidOrganizationId_ShouldThrowError(
SutProvider<AddPasswordHealthReportApplicationCommand> sutProvider)
{
// Arrange
var fixture = new Fixture();
var request = fixture.Create<AddPasswordHealthReportApplicationRequest>();
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(null as Organization);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request));
Assert.Equal("Invalid Organization", exception.Message);
}
[Theory]
[BitAutoData]
public async Task AddPasswordHealthReportApplicationAsync_WithInvalidUrl_ShouldThrowError(
SutProvider<AddPasswordHealthReportApplicationCommand> sutProvider)
{
// Arrange
var fixture = new Fixture();
var request = fixture.Build<AddPasswordHealthReportApplicationRequest>()
.Without(_ => _.Url)
.Create();
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(fixture.Create<Organization>());
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request));
Assert.Equal("URL is required", exception.Message);
}
[Theory]
[BitAutoData]
public async Task AddPasswordHealthReportApplicationAsync_Multiples_WithInvalidOrganizationId_ShouldThrowError(
SutProvider<AddPasswordHealthReportApplicationCommand> sutProvider)
{
// Arrange
var fixture = new Fixture();
var request = fixture.Build<AddPasswordHealthReportApplicationRequest>()
.Without(_ => _.OrganizationId)
.CreateMany(2).ToList();
request[0].OrganizationId = Guid.NewGuid();
request[1].OrganizationId = Guid.Empty;
// only return an organization for the first request and null for the second
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(Arg.Is<Guid>(x => x == request[0].OrganizationId))
.Returns(fixture.Create<Organization>());
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request));
Assert.Equal("Invalid Organization", exception.Message);
}
[Theory]
[BitAutoData]
public async Task AddPasswordHealthReportApplicationAsync_Multiples_WithInvalidUrl_ShouldThrowError(
SutProvider<AddPasswordHealthReportApplicationCommand> sutProvider)
{
// Arrange
var fixture = new Fixture();
var request = fixture.Build<AddPasswordHealthReportApplicationRequest>()
.CreateMany(2).ToList();
request[1].Url = string.Empty;
// return an organization for both requests
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(fixture.Create<Organization>());
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request));
Assert.Equal("URL is required", exception.Message);
}
[Theory]
[BitAutoData]
public async Task AddPasswordHealthReportApplicationAsync_Multiples_WithValidRequest_ShouldBeSuccessful(
SutProvider<AddPasswordHealthReportApplicationCommand> sutProvider)
{
// Arrange
var fixture = new Fixture();
var request = fixture.CreateMany<AddPasswordHealthReportApplicationRequest>(2);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(fixture.Create<Organization>());
sutProvider.GetDependency<IPasswordHealthReportApplicationRepository>()
.CreateAsync(Arg.Any<PasswordHealthReportApplication>())
.Returns(c => c.Arg<PasswordHealthReportApplication>());
// Act
var result = await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request);
// Assert
Assert.True(result.Count() == 2);
sutProvider.GetDependency<IOrganizationRepository>().Received(2);
sutProvider.GetDependency<IPasswordHealthReportApplicationRepository>().Received(2);
}
}

View File

@ -0,0 +1,53 @@
using AutoFixture;
using Bit.Core.Exceptions;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.ReportFeatures;
using Bit.Core.Tools.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Tools.ReportFeatures;
[SutProviderCustomize]
public class GetPasswordHealthReportApplicationQueryTests
{
[Theory]
[BitAutoData]
public async Task GetPasswordHealthReportApplicationAsync_WithValidOrganizationId_ShouldReturnPasswordHealthReportApplication(
SutProvider<GetPasswordHealthReportApplicationQuery> sutProvider)
{
// Arrange
var fixture = new Fixture();
var organizationId = fixture.Create<Guid>();
sutProvider.GetDependency<IPasswordHealthReportApplicationRepository>()
.GetByOrganizationIdAsync(Arg.Any<Guid>())
.Returns(fixture.CreateMany<PasswordHealthReportApplication>(2).ToList());
// Act
var result = await sutProvider.Sut.GetPasswordHealthReportApplicationAsync(organizationId);
// Assert
Assert.NotNull(result);
Assert.True(result.Count() == 2);
}
[Theory]
[BitAutoData]
public async Task GetPasswordHealthReportApplicationAsync_WithInvalidOrganizationId_ShouldFail(
SutProvider<GetPasswordHealthReportApplicationQuery> sutProvider)
{
// Arrange
var fixture = new Fixture();
sutProvider.GetDependency<IPasswordHealthReportApplicationRepository>()
.GetByOrganizationIdAsync(Arg.Is<Guid>(x => x == Guid.Empty))
.Returns(new List<PasswordHealthReportApplication>());
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.GetPasswordHealthReportApplicationAsync(Guid.Empty));
// Assert
Assert.Equal("OrganizationId is required.", exception.Message);
}
}

View File

@ -9,6 +9,7 @@ using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider;
using Bit.Infrastructure.EntityFramework.Auth.Models; using Bit.Infrastructure.EntityFramework.Auth.Models;
using Bit.Infrastructure.EntityFramework.Models; using Bit.Infrastructure.EntityFramework.Models;
using Bit.Infrastructure.EntityFramework.Repositories; using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Infrastructure.EntityFramework.Tools.Models;
using Bit.Infrastructure.EntityFramework.Vault.Models; using Bit.Infrastructure.EntityFramework.Vault.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -89,6 +90,7 @@ public class EfRepositoryListBuilder<T> : ISpecimenBuilder where T : BaseEntityF
cfg.AddProfile<TaxRateMapperProfile>(); cfg.AddProfile<TaxRateMapperProfile>();
cfg.AddProfile<TransactionMapperProfile>(); cfg.AddProfile<TransactionMapperProfile>();
cfg.AddProfile<UserMapperProfile>(); cfg.AddProfile<UserMapperProfile>();
cfg.AddProfile<PasswordHealthReportApplicationProfile>();
}) })
.CreateMapper())); .CreateMapper()));

View File

@ -0,0 +1,82 @@
using AutoFixture;
using AutoFixture.Kernel;
using Bit.Core.Tools.Entities;
using Bit.Infrastructure.EntityFramework.AdminConsole.Repositories;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Infrastructure.EntityFramework.Tools.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
namespace Bit.Infrastructure.EFIntegration.Test.AutoFixture;
internal class PasswordHealthReportApplicationBuilder : ISpecimenBuilder
{
public object Create(object request, ISpecimenContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
var type = request as Type;
if (type == null || type != typeof(PasswordHealthReportApplication))
{
return new NoSpecimen();
}
var fixture = new Fixture();
var obj = fixture.WithAutoNSubstitutions().Create<PasswordHealthReportApplication>();
return obj;
}
}
internal class EfPasswordHealthReportApplication : ICustomization
{
public void Customize(IFixture fixture)
{
fixture.Customizations.Add(new IgnoreVirtualMembersCustomization());
fixture.Customizations.Add(new GlobalSettingsBuilder());
fixture.Customizations.Add(new PasswordHealthReportApplicationBuilder());
fixture.Customizations.Add(new OrganizationBuilder());
fixture.Customizations.Add(new EfRepositoryListBuilder<PasswordHealthReportApplicationRepository>());
fixture.Customizations.Add(new EfRepositoryListBuilder<OrganizationRepository>());
}
}
internal class EfPasswordHealthReportApplicationApplicableToUser : ICustomization
{
public void Customize(IFixture fixture)
{
fixture.Customizations.Add(new IgnoreVirtualMembersCustomization());
fixture.Customizations.Add(new GlobalSettingsBuilder());
fixture.Customizations.Add(new PasswordHealthReportApplicationBuilder());
fixture.Customizations.Add(new OrganizationBuilder());
fixture.Customizations.Add(new EfRepositoryListBuilder<PasswordHealthReportApplicationRepository>());
fixture.Customizations.Add(new EfRepositoryListBuilder<UserRepository>());
fixture.Customizations.Add(new EfRepositoryListBuilder<OrganizationRepository>());
fixture.Customizations.Add(new EfRepositoryListBuilder<OrganizationUserRepository>());
fixture.Customizations.Add(new EfRepositoryListBuilder<ProviderRepository>());
fixture.Customizations.Add(new EfRepositoryListBuilder<ProviderUserRepository>());
fixture.Customizations.Add(new EfRepositoryListBuilder<ProviderOrganizationRepository>());
}
}
internal class EfPasswordHealthReportApplicationAutoDataAttribute : CustomAutoDataAttribute
{
public EfPasswordHealthReportApplicationAutoDataAttribute() : base(new SutProviderCustomization(), new EfPasswordHealthReportApplication())
{ }
}
internal class EfPasswordHealthReportApplicationApplicableToUserInlineAutoDataAttribute : InlineCustomAutoDataAttribute
{
public EfPasswordHealthReportApplicationApplicableToUserInlineAutoDataAttribute(params object[] values) :
base(new[] { typeof(SutProviderCustomization), typeof(EfPasswordHealthReportApplicationApplicableToUser) }, values)
{ }
}
internal class InlineEfPasswordHealthReportApplicationAutoDataAttribute : InlineCustomAutoDataAttribute
{
public InlineEfPasswordHealthReportApplicationAutoDataAttribute(params object[] values) : base(new[] { typeof(SutProviderCustomization),
typeof(EfPolicy) }, values)
{ }
}

View File

@ -0,0 +1,269 @@
using AutoFixture;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Repositories;
using Bit.Core.Test.AutoFixture.Attributes;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Repositories;
using Bit.Infrastructure.EFIntegration.Test.AutoFixture;
using Xunit;
using EfRepo = Bit.Infrastructure.EntityFramework.Repositories;
using EfToolsRepo = Bit.Infrastructure.EntityFramework.Tools.Repositories;
using SqlAdminConsoleRepo = Bit.Infrastructure.Dapper.Tools.Repositories;
using SqlRepo = Bit.Infrastructure.Dapper.Repositories;
namespace Bit.Infrastructure.EFIntegration.Test.Tools.Repositories;
public class PasswordHealthReportApplicationRepositoryTests
{
[CiSkippedTheory, EfPasswordHealthReportApplicationAutoData]
public async Task CreateAsync_Works_DataMatches(
PasswordHealthReportApplication passwordHealthReportApplication,
Organization organization,
List<EfToolsRepo.PasswordHealthReportApplicationRepository> suts,
List<EfRepo.OrganizationRepository> efOrganizationRepos,
SqlAdminConsoleRepo.PasswordHealthReportApplicationRepository sqlPasswordHealthReportApplicationRepo,
SqlRepo.OrganizationRepository sqlOrganizationRepo
)
{
var passwordHealthReportApplicationRecords = new List<PasswordHealthReportApplication>();
foreach (var sut in suts)
{
var i = suts.IndexOf(sut);
var efOrganization = await efOrganizationRepos[i].CreateAsync(organization);
sut.ClearChangeTracking();
passwordHealthReportApplication.OrganizationId = efOrganization.Id;
var postEfPasswordHeathReportApp = await sut.CreateAsync(passwordHealthReportApplication);
sut.ClearChangeTracking();
var savedPasswordHealthReportApplication = await sut.GetByIdAsync(postEfPasswordHeathReportApp.Id);
passwordHealthReportApplicationRecords.Add(savedPasswordHealthReportApplication);
}
var sqlOrganization = await sqlOrganizationRepo.CreateAsync(organization);
passwordHealthReportApplication.OrganizationId = sqlOrganization.Id;
var sqlPasswordHealthReportApplicationRecord = await sqlPasswordHealthReportApplicationRepo.CreateAsync(passwordHealthReportApplication);
var savedSqlPasswordHealthReportApplicationRecord = await sqlPasswordHealthReportApplicationRepo.GetByIdAsync(sqlPasswordHealthReportApplicationRecord.Id);
passwordHealthReportApplicationRecords.Add(savedSqlPasswordHealthReportApplicationRecord);
Assert.True(passwordHealthReportApplicationRecords.Count == 4);
}
[CiSkippedTheory, EfPasswordHealthReportApplicationAutoData]
public async Task RetrieveByOrganisation_Works(
SqlAdminConsoleRepo.PasswordHealthReportApplicationRepository sqlPasswordHealthReportApplicationRepo,
SqlRepo.OrganizationRepository sqlOrganizationRepo)
{
var (firstOrg, firstRecord) = await CreateSampleRecord(sqlOrganizationRepo, sqlPasswordHealthReportApplicationRepo);
var (secondOrg, secondRecord) = await CreateSampleRecord(sqlOrganizationRepo, sqlPasswordHealthReportApplicationRepo);
var firstSetOfRecords = await sqlPasswordHealthReportApplicationRepo.GetByOrganizationIdAsync(firstOrg.Id);
var nextSetOfRecords = await sqlPasswordHealthReportApplicationRepo.GetByOrganizationIdAsync(secondOrg.Id);
Assert.True(firstSetOfRecords.Count == 1 && firstSetOfRecords.First().OrganizationId == firstOrg.Id);
Assert.True(nextSetOfRecords.Count == 1 && nextSetOfRecords.First().OrganizationId == secondOrg.Id);
}
[CiSkippedTheory, EfPasswordHealthReportApplicationAutoData]
public async Task ReplaceQuery_Works(
List<EfToolsRepo.PasswordHealthReportApplicationRepository> suts,
List<EfRepo.OrganizationRepository> efOrganizationRepos,
SqlAdminConsoleRepo.PasswordHealthReportApplicationRepository sqlPasswordHealthReportApplicationRepo,
SqlRepo.OrganizationRepository sqlOrganizationRepo)
{
var (org, pwdRecord) = await CreateSampleRecord(sqlOrganizationRepo, sqlPasswordHealthReportApplicationRepo);
var exampleUri = "http://www.example.com";
var exampleRevisionDate = new DateTime(2021, 1, 1);
var dbRecords = new List<PasswordHealthReportApplication>();
foreach (var sut in suts)
{
var i = suts.IndexOf(sut);
// create a new organization for each repository
var organization = await efOrganizationRepos[i].CreateAsync(org);
// map the organization Id and create the PasswordHealthReportApp record
pwdRecord.OrganizationId = organization.Id;
var passwordHealthReportApplication = await sut.CreateAsync(pwdRecord);
// update the record with new values
passwordHealthReportApplication.Uri = exampleUri;
passwordHealthReportApplication.RevisionDate = exampleRevisionDate;
// apply update to the database
await sut.ReplaceAsync(passwordHealthReportApplication);
sut.ClearChangeTracking();
// retrieve the data and add to the list for assertions
var recordFromDb = await sut.GetByIdAsync(passwordHealthReportApplication.Id);
sut.ClearChangeTracking();
dbRecords.Add(recordFromDb);
}
// sql - create a new organization and PasswordHealthReportApplication record
var (sqlOrg, sqlPwdRecord) = await CreateSampleRecord(sqlOrganizationRepo, sqlPasswordHealthReportApplicationRepo);
var sqlPasswordHealthReportApplicationRecord = await sqlPasswordHealthReportApplicationRepo.GetByIdAsync(sqlPwdRecord.Id);
// sql - update the record with new values
sqlPasswordHealthReportApplicationRecord.Uri = exampleUri;
sqlPasswordHealthReportApplicationRecord.RevisionDate = exampleRevisionDate;
await sqlPasswordHealthReportApplicationRepo.ReplaceAsync(sqlPasswordHealthReportApplicationRecord);
// sql - retrieve the data and add to the list for assertions
var sqlDbRecord = await sqlPasswordHealthReportApplicationRepo.GetByIdAsync(sqlPasswordHealthReportApplicationRecord.Id);
dbRecords.Add(sqlDbRecord);
// assertions
// the Guids must be distinct across all records
Assert.True(dbRecords.Select(_ => _.Id).Distinct().Count() == dbRecords.Count);
// the Uri and RevisionDate must match the updated values
Assert.True(dbRecords.All(_ => _.Uri == exampleUri && _.RevisionDate == exampleRevisionDate));
}
[CiSkippedTheory, EfPasswordHealthReportApplicationAutoData]
public async Task Upsert_Works(
List<EfToolsRepo.PasswordHealthReportApplicationRepository> suts,
List<EfRepo.OrganizationRepository> efOrganizationRepos,
SqlAdminConsoleRepo.PasswordHealthReportApplicationRepository sqlPasswordHealthReportApplicationRepo,
SqlRepo.OrganizationRepository sqlOrganizationRepo)
{
var fixture = new Fixture();
var rawOrg = fixture.Build<Organization>().Create();
var rawPwdRecord = fixture.Build<PasswordHealthReportApplication>()
.With(_ => _.OrganizationId, rawOrg.Id)
.Without(_ => _.Id)
.Create();
var exampleUri = "http://www.example.com";
var exampleRevisionDate = new DateTime(2021, 1, 1);
var dbRecords = new List<PasswordHealthReportApplication>();
foreach (var sut in suts)
{
var i = suts.IndexOf(sut);
// create a new organization for each repository
var organization = await efOrganizationRepos[i].CreateAsync(rawOrg);
// map the organization Id and use Upsert to save new record
rawPwdRecord.OrganizationId = organization.Id;
rawPwdRecord.Id = default(Guid);
await sut.UpsertAsync(rawPwdRecord);
sut.ClearChangeTracking();
// retrieve the data and add to the list for assertions
var passwordHealthReportApplication = await sut.GetByIdAsync(rawPwdRecord.Id);
// update the record with new values
passwordHealthReportApplication.Uri = exampleUri;
passwordHealthReportApplication.RevisionDate = exampleRevisionDate;
// apply update using Upsert to make changes to db
await sut.UpsertAsync(passwordHealthReportApplication);
sut.ClearChangeTracking();
// retrieve the data and add to the list for assertions
var recordFromDb = await sut.GetByIdAsync(passwordHealthReportApplication.Id);
dbRecords.Add(recordFromDb);
sut.ClearChangeTracking();
}
// sql - create new records
var organizationForSql = fixture.Create<Organization>();
var passwordHealthReportApplicationForSql = fixture.Build<PasswordHealthReportApplication>()
.With(_ => _.OrganizationId, organizationForSql.Id)
.Without(_ => _.Id)
.Create();
// sql - use Upsert to insert this data
var sqlOrganization = await sqlOrganizationRepo.CreateAsync(organizationForSql);
await sqlPasswordHealthReportApplicationRepo.UpsertAsync(passwordHealthReportApplicationForSql);
var sqlPasswordHealthReportApplicationRecord = await sqlPasswordHealthReportApplicationRepo.GetByIdAsync(passwordHealthReportApplicationForSql.Id);
// sql - update the record with new values
sqlPasswordHealthReportApplicationRecord.Uri = exampleUri;
sqlPasswordHealthReportApplicationRecord.RevisionDate = exampleRevisionDate;
await sqlPasswordHealthReportApplicationRepo.UpsertAsync(sqlPasswordHealthReportApplicationRecord);
// sql - retrieve the data and add to the list for assertions
var sqlDbRecord = await sqlPasswordHealthReportApplicationRepo.GetByIdAsync(sqlPasswordHealthReportApplicationRecord.Id);
dbRecords.Add(sqlDbRecord);
// assertions
// the Guids must be distinct across all records
Assert.True(dbRecords.Select(_ => _.Id).Distinct().Count() == dbRecords.Count);
// the Uri and RevisionDate must match the updated values
Assert.True(dbRecords.All(_ => _.Uri == exampleUri && _.RevisionDate == exampleRevisionDate));
}
[CiSkippedTheory, EfPasswordHealthReportApplicationAutoData]
public async Task Delete_Works(
List<EfToolsRepo.PasswordHealthReportApplicationRepository> suts,
List<EfRepo.OrganizationRepository> efOrganizationRepos,
SqlAdminConsoleRepo.PasswordHealthReportApplicationRepository sqlPasswordHealthReportApplicationRepo,
SqlRepo.OrganizationRepository sqlOrganizationRepo)
{
var fixture = new Fixture();
var rawOrg = fixture.Build<Organization>().Create();
var rawPwdRecord = fixture.Build<PasswordHealthReportApplication>()
.With(_ => _.OrganizationId, rawOrg.Id)
.Create();
var dbRecords = new List<PasswordHealthReportApplication>();
foreach (var sut in suts)
{
var i = suts.IndexOf(sut);
// create a new organization for each repository
var organization = await efOrganizationRepos[i].CreateAsync(rawOrg);
// map the organization Id and use Upsert to save new record
rawPwdRecord.OrganizationId = organization.Id;
rawPwdRecord = await sut.CreateAsync(rawPwdRecord);
sut.ClearChangeTracking();
// apply update using Upsert to make changes to db
await sut.DeleteAsync(rawPwdRecord);
sut.ClearChangeTracking();
// retrieve the data and add to the list for assertions
var recordFromDb = await sut.GetByIdAsync(rawPwdRecord.Id);
dbRecords.Add(recordFromDb);
sut.ClearChangeTracking();
}
// sql - create new records
var (org, passwordHealthReportApplication) = await CreateSampleRecord(sqlOrganizationRepo, sqlPasswordHealthReportApplicationRepo);
await sqlPasswordHealthReportApplicationRepo.DeleteAsync(passwordHealthReportApplication);
var sqlDbRecord = await sqlPasswordHealthReportApplicationRepo.GetByIdAsync(passwordHealthReportApplication.Id);
dbRecords.Add(sqlDbRecord);
// assertions
// all records should be null - as they were deleted before querying
Assert.True(dbRecords.Where(_ => _ == null).Count() == 4);
}
private async Task<(Organization, PasswordHealthReportApplication)> CreateSampleRecord(
IOrganizationRepository organizationRepo,
IPasswordHealthReportApplicationRepository passwordHealthReportApplicationRepo
)
{
var fixture = new Fixture();
var organization = fixture.Create<Organization>();
var passwordHealthReportApplication = fixture.Build<PasswordHealthReportApplication>()
.With(_ => _.OrganizationId, organization.Id)
.Create();
organization = await organizationRepo.CreateAsync(organization);
passwordHealthReportApplication = await passwordHealthReportApplicationRepo.CreateAsync(passwordHealthReportApplication);
return (organization, passwordHealthReportApplication);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.MySqlMigrations.Migrations;
/// <inheritdoc />
public partial class FixPasswordHealthReportApplication : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// this file is not required, but the designer file is required
// in order to keep the database models in sync with the database
// without this - the unit tests will fail when run on your local machine
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}

View File

@ -1887,6 +1887,34 @@ namespace Bit.MySqlMigrations.Migrations
b.ToTable("ServiceAccount", (string)null); b.ToTable("ServiceAccount", (string)null);
}); });
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b =>
{
b.Property<Guid>("Id")
.HasColumnType("char(36)");
b.Property<DateTime>("CreationDate")
.HasColumnType("datetime(6)");
b.Property<Guid>("OrganizationId")
.HasColumnType("char(36)");
b.Property<DateTime>("RevisionDate")
.HasColumnType("datetime(6)");
b.Property<string>("Uri")
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("Id")
.HasAnnotation("SqlServer:Clustered", true);
b.HasIndex("OrganizationId")
.HasAnnotation("SqlServer:Clustered", false);
b.ToTable("PasswordHealthReportApplication", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -2578,6 +2606,17 @@ namespace Bit.MySqlMigrations.Migrations
b.Navigation("Organization"); b.Navigation("Organization");
}); });
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
.WithMany()
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Organization");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b =>
{ {
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.PostgresMigrations.Migrations;
/// <inheritdoc />
public partial class FixPasswordHealthReportApplication : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// this file is not required, but the designer file is required
// in order to keep the database models in sync with the database
// without this - the unit tests will fail when run on your local machine
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}

View File

@ -1893,6 +1893,34 @@ namespace Bit.PostgresMigrations.Migrations
b.ToTable("ServiceAccount", (string)null); b.ToTable("ServiceAccount", (string)null);
}); });
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTime>("CreationDate")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<DateTime>("RevisionDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Uri")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Id")
.HasAnnotation("SqlServer:Clustered", true);
b.HasIndex("OrganizationId")
.HasAnnotation("SqlServer:Clustered", false);
b.ToTable("PasswordHealthReportApplication", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -2584,6 +2612,17 @@ namespace Bit.PostgresMigrations.Migrations
b.Navigation("Organization"); b.Navigation("Organization");
}); });
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
.WithMany()
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Organization");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b =>
{ {
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.SqliteMigrations.Migrations;
/// <inheritdoc />
public partial class FixPasswordHealthReportApplication : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// this file is not required, but the designer file is required
// in order to keep the database models in sync with the database
// without this - the unit tests will fail when run on your local machine
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}

View File

@ -1876,6 +1876,34 @@ namespace Bit.SqliteMigrations.Migrations
b.ToTable("ServiceAccount", (string)null); b.ToTable("ServiceAccount", (string)null);
}); });
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT");
b.Property<DateTime>("CreationDate")
.HasColumnType("TEXT");
b.Property<Guid>("OrganizationId")
.HasColumnType("TEXT");
b.Property<DateTime>("RevisionDate")
.HasColumnType("TEXT");
b.Property<string>("Uri")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Id")
.HasAnnotation("SqlServer:Clustered", true);
b.HasIndex("OrganizationId")
.HasAnnotation("SqlServer:Clustered", false);
b.ToTable("PasswordHealthReportApplication", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -2567,6 +2595,17 @@ namespace Bit.SqliteMigrations.Migrations
b.Navigation("Organization"); b.Navigation("Organization");
}); });
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
.WithMany()
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Organization");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b =>
{ {
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")