diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b5e3d9558..ce81bce68 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,6 +21,8 @@ jobs: lint: name: Lint runs-on: ubuntu-22.04 + needs: + - check-run steps: - name: Debug run: | @@ -41,7 +43,8 @@ jobs: build-artifacts: name: Build artifacts runs-on: ubuntu-22.04 - needs: lint + needs: + - lint strategy: fail-fast: false matrix: @@ -135,7 +138,6 @@ jobs: security-events: write needs: - build-artifacts - - check-run strategy: fail-fast: false matrix: @@ -481,7 +483,8 @@ jobs: build-mssqlmigratorutility: name: Build MSSQL migrator utility runs-on: ubuntu-22.04 - needs: lint + needs: + - lint defaults: run: shell: bash @@ -534,7 +537,8 @@ jobs: name: Trigger self-host build if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' runs-on: ubuntu-22.04 - needs: build-docker + needs: + - build-docker steps: - name: Log in to Azure - CI subscription uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -567,7 +571,8 @@ jobs: name: Trigger k8s deploy if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' runs-on: ubuntu-22.04 - needs: build-docker + needs: + - build-docker steps: - name: Log in to Azure - CI subscription uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -603,7 +608,8 @@ jobs: github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'ephemeral-environment') runs-on: ubuntu-24.04 - needs: build-docker + needs: + - build-docker steps: - name: Log in to Azure - CI subscription uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index 4a1becc0b..ee48cdd5d 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -1,7 +1,11 @@ using Bit.Api.AdminConsole.Models.Request; +using Bit.Api.AdminConsole.Models.Response.Helpers; +using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Models.Response; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.Models.Api.Response; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Business.Tokenables; @@ -16,7 +20,6 @@ using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Mvc; -using AdminConsoleEntities = Bit.Core.AdminConsole.Entities; namespace Bit.Api.AdminConsole.Controllers; @@ -32,6 +35,8 @@ public class PoliciesController : Controller private readonly GlobalSettings _globalSettings; private readonly IDataProtector _organizationServiceDataProtector; private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; + private readonly IFeatureService _featureService; + private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery; public PoliciesController( IPolicyRepository policyRepository, @@ -41,7 +46,9 @@ public class PoliciesController : Controller ICurrentContext currentContext, GlobalSettings globalSettings, IDataProtectionProvider dataProtectionProvider, - IDataProtectorTokenFactory orgUserInviteTokenDataFactory) + IDataProtectorTokenFactory orgUserInviteTokenDataFactory, + IFeatureService featureService, + IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery) { _policyRepository = policyRepository; _policyService = policyService; @@ -53,10 +60,12 @@ public class PoliciesController : Controller "OrganizationServiceDataProtector"); _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; + _featureService = featureService; + _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; } [HttpGet("{type}")] - public async Task Get(Guid orgId, int type) + public async Task Get(Guid orgId, int type) { if (!await _currentContext.ManagePolicies(orgId)) { @@ -65,10 +74,15 @@ public class PoliciesController : Controller var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type); if (policy == null) { - return new PolicyResponseModel(new AdminConsoleEntities.Policy() { Type = (PolicyType)type, Enabled = false }); + return new PolicyDetailResponseModel(new Policy { Type = (PolicyType)type }); } - return new PolicyResponseModel(policy); + if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && policy.Type is PolicyType.SingleOrg) + { + return await policy.GetSingleOrgPolicyDetailResponseAsync(_organizationHasVerifiedDomainsQuery); + } + + return new PolicyDetailResponseModel(policy); } [HttpGet("")] @@ -81,8 +95,8 @@ public class PoliciesController : Controller } var policies = await _policyRepository.GetManyByOrganizationIdAsync(orgIdGuid); - var responses = policies.Select(p => new PolicyResponseModel(p)); - return new ListResponseModel(responses); + + return new ListResponseModel(policies.Select(p => new PolicyResponseModel(p))); } [AllowAnonymous] diff --git a/src/Api/AdminConsole/Models/Response/Helpers/PolicyDetailResponses.cs b/src/Api/AdminConsole/Models/Response/Helpers/PolicyDetailResponses.cs new file mode 100644 index 000000000..14b9642f6 --- /dev/null +++ b/src/Api/AdminConsole/Models/Response/Helpers/PolicyDetailResponses.cs @@ -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 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)); + } +} diff --git a/src/Api/AdminConsole/Models/Response/Organizations/PolicyDetailResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/PolicyDetailResponseModel.cs new file mode 100644 index 000000000..cb5560e68 --- /dev/null +++ b/src/Api/AdminConsole/Models/Response/Organizations/PolicyDetailResponseModel.cs @@ -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; + } + + /// + /// Indicates whether the Policy can be enabled/disabled + /// + public bool CanToggleState { get; set; } = true; +} diff --git a/src/Core/AdminConsole/Models/Api/Response/PolicyResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs similarity index 93% rename from src/Core/AdminConsole/Models/Api/Response/PolicyResponseModel.cs rename to src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs index 7ef6b1573..86e62a419 100644 --- a/src/Core/AdminConsole/Models/Api/Response/PolicyResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs @@ -3,7 +3,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.Models.Api; -namespace Bit.Core.AdminConsole.Models.Api.Response; +namespace Bit.Api.AdminConsole.Models.Response.Organizations; public class PolicyResponseModel : ResponseModel { diff --git a/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs index 71e03a547..f2e7c35d2 100644 --- a/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs @@ -41,14 +41,13 @@ public class PoliciesController : Controller [ProducesResponseType((int)HttpStatusCode.NotFound)] public async Task Get(PolicyType type) { - var policy = await _policyRepository.GetByOrganizationIdTypeAsync( - _currentContext.OrganizationId.Value, type); + var policy = await _policyRepository.GetByOrganizationIdTypeAsync(_currentContext.OrganizationId.Value, type); if (policy == null) { return new NotFoundResult(); } - var response = new PolicyResponseModel(policy); - return new JsonResult(response); + + return new JsonResult(new PolicyResponseModel(policy)); } /// @@ -62,9 +61,8 @@ public class PoliciesController : Controller public async Task List() { var policies = await _policyRepository.GetManyByOrganizationIdAsync(_currentContext.OrganizationId.Value); - var policyResponses = policies.Select(p => new PolicyResponseModel(p)); - var response = new ListResponseModel(policyResponses); - return new JsonResult(response); + + return new JsonResult(new ListResponseModel(policies.Select(p => new PolicyResponseModel(p)))); } /// diff --git a/src/Api/AdminConsole/Public/Models/MemberBaseModel.cs b/src/Api/AdminConsole/Public/Models/MemberBaseModel.cs index 931f63741..c56117ae7 100644 --- a/src/Api/AdminConsole/Public/Models/MemberBaseModel.cs +++ b/src/Api/AdminConsole/Public/Models/MemberBaseModel.cs @@ -18,7 +18,6 @@ public abstract class MemberBaseModel Type = user.Type; ExternalId = user.ExternalId; - ResetPasswordEnrolled = user.ResetPasswordKey != null; if (Type == OrganizationUserType.Custom) { @@ -35,7 +34,6 @@ public abstract class MemberBaseModel Type = user.Type; ExternalId = user.ExternalId; - ResetPasswordEnrolled = user.ResetPasswordKey != null; if (Type == OrganizationUserType.Custom) { @@ -55,11 +53,7 @@ public abstract class MemberBaseModel /// external_id_123456 [StringLength(300)] public string ExternalId { get; set; } - /// - /// Returns true if the member has enrolled in Password Reset assistance within the organization - /// - [Required] - public bool ResetPasswordEnrolled { get; set; } + /// /// The member's custom permissions if the member has a Custom role. If not supplied, all custom permissions will /// default to false. diff --git a/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs b/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs index 6f73532ad..ab6ecbca4 100644 --- a/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs +++ b/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs @@ -28,6 +28,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel Email = user.Email; Status = user.Status; Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c)); + ResetPasswordEnrolled = user.ResetPasswordKey != null; } public MemberResponseModel(OrganizationUserUserDetails user, bool twoFactorEnabled, @@ -45,6 +46,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel TwoFactorEnabled = twoFactorEnabled; Status = user.Status; Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c)); + ResetPasswordEnrolled = user.ResetPasswordKey != null; } /// @@ -93,4 +95,10 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel /// The associated collections that this member can access. /// public IEnumerable Collections { get; set; } + + /// + /// Returns true if the member has enrolled in Password Reset assistance within the organization + /// + [Required] + public bool ResetPasswordEnrolled { get; } } diff --git a/src/Api/Auth/Controllers/EmergencyAccessController.cs b/src/Api/Auth/Controllers/EmergencyAccessController.cs index 95fac234c..9f8ea3df0 100644 --- a/src/Api/Auth/Controllers/EmergencyAccessController.cs +++ b/src/Api/Auth/Controllers/EmergencyAccessController.cs @@ -1,10 +1,10 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Response; using Bit.Api.Models.Response; using Bit.Api.Vault.Models.Response; using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Models.Api.Response; using Bit.Core.Auth.Services; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 8a7721bcb..7962215a4 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -32,6 +32,8 @@ using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Core.Auth.Models.Data; +using Bit.Core.Tools.ReportFeatures; + #if !OSS @@ -176,6 +178,7 @@ public class Startup services.AddOrganizationSubscriptionServices(); services.AddCoreLocalizationServices(); services.AddBillingOperations(); + services.AddReportingServices(); // Authorization Handlers services.AddAuthorizationHandlers(); diff --git a/src/Api/Tools/Controllers/ReportsController.cs b/src/Api/Tools/Controllers/ReportsController.cs index 5beb320e4..9f465c7b8 100644 --- a/src/Api/Tools/Controllers/ReportsController.cs +++ b/src/Api/Tools/Controllers/ReportsController.cs @@ -1,13 +1,13 @@ -using Bit.Api.Tools.Models.Response; -using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Api.Tools.Models; +using Bit.Api.Tools.Models.Response; using Bit.Core.Context; using Bit.Core.Exceptions; -using Bit.Core.Repositories; -using Bit.Core.Services; -using Bit.Core.Vault.Queries; -using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; -using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Tools.ReportFeatures.Interfaces; +using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces; +using Bit.Core.Tools.ReportFeatures.Requests; +using Bit.Core.Tools.Requests; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -17,33 +17,55 @@ namespace Bit.Api.Tools.Controllers; [Authorize("Application")] public class ReportsController : Controller { - private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery; - private readonly IGroupRepository _groupRepository; - private readonly ICollectionRepository _collectionRepository; private readonly ICurrentContext _currentContext; - private readonly IOrganizationCiphersQuery _organizationCiphersQuery; - private readonly IApplicationCacheService _applicationCacheService; - private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; + private readonly IMemberAccessCipherDetailsQuery _memberAccessCipherDetailsQuery; + private readonly IAddPasswordHealthReportApplicationCommand _addPwdHealthReportAppCommand; + private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery; public ReportsController( - IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery, - IGroupRepository groupRepository, - ICollectionRepository collectionRepository, ICurrentContext currentContext, - IOrganizationCiphersQuery organizationCiphersQuery, - IApplicationCacheService applicationCacheService, - ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery + IMemberAccessCipherDetailsQuery memberAccessCipherDetailsQuery, + IAddPasswordHealthReportApplicationCommand addPasswordHealthReportApplicationCommand, + IGetPasswordHealthReportApplicationQuery getPasswordHealthReportApplicationQuery ) { - _organizationUserUserDetailsQuery = organizationUserUserDetailsQuery; - _groupRepository = groupRepository; - _collectionRepository = collectionRepository; _currentContext = currentContext; - _organizationCiphersQuery = organizationCiphersQuery; - _applicationCacheService = applicationCacheService; - _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; + _memberAccessCipherDetailsQuery = memberAccessCipherDetailsQuery; + _addPwdHealthReportAppCommand = addPasswordHealthReportApplicationCommand; + _getPwdHealthReportAppQuery = getPasswordHealthReportApplicationQuery; } + /// + /// Organization member information containing a list of cipher ids + /// assigned + /// + /// Organzation Id + /// IEnumerable of MemberCipherDetailsResponseModel + /// If Access reports permission is not assigned + [HttpGet("member-cipher-details/{orgId}")] + public async Task> 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; + } + + /// + /// Access details for an organization member. Includes the member information, + /// group collection assignment, and item counts + /// + /// Organization Id + /// IEnumerable of MemberAccessReportResponseModel + /// If Access reports permission is not assigned [HttpGet("member-access/{orgId}")] public async Task> GetMemberAccessReport(Guid orgId) { @@ -52,26 +74,91 @@ public class ReportsController : Controller throw new NotFoundException(); } - var orgUsers = await _organizationUserUserDetailsQuery.GetOrganizationUserUserDetails( - new OrganizationUserUserDetailsQueryRequest - { - OrganizationId = orgId, - IncludeCollections = true, - IncludeGroups = true - }); + var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId }); - var orgGroups = await _groupRepository.GetManyByOrganizationIdAsync(orgId); - var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(orgId); - var orgCollectionsWithAccess = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(orgId); - var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(orgId); - var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers); + var responses = memberCipherDetails.Select(x => new MemberAccessReportResponseModel(x)); - var reports = MemberAccessReportResponseModel.CreateReport( - orgGroups, - orgCollectionsWithAccess, - orgItems, - organizationUsersTwoFactorEnabled, - orgAbility); - return reports; + return responses; + } + + /// + /// Contains the organization member info, the cipher ids associated with the member, + /// and details on their collections, groups, and permissions + /// + /// Request to the MemberAccessCipherDetailsQuery + /// IEnumerable of MemberAccessCipherDetails + private async Task> GetMemberCipherDetails(MemberAccessCipherDetailsRequest request) + { + var memberCipherDetails = + await _memberAccessCipherDetailsQuery.GetMemberAccessCipherDetails(request); + return memberCipherDetails; + } + + /// + /// Get the password health report applications for an organization + /// + /// A valid Organization Id + /// An Enumerable of PasswordHealthReportApplication + /// If the user lacks access + /// If the organization Id is not valid + [HttpGet("password-health-report-applications/{orgId}")] + public async Task> GetPasswordHealthReportApplications(Guid orgId) + { + if (!await _currentContext.AccessReports(orgId)) + { + throw new NotFoundException(); + } + + return await _getPwdHealthReportAppQuery.GetPasswordHealthReportApplicationAsync(orgId); + } + + /// + /// Adds a new record into PasswordHealthReportApplication + /// + /// A single instance of PasswordHealthReportApplication Model + /// A single instance of PasswordHealthReportApplication + /// If the organization Id is not valid + /// If the user lacks access + [HttpPost("password-health-report-application")] + public async Task 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); + } + + /// + /// Adds multiple records into PasswordHealthReportApplication + /// + /// A enumerable of PasswordHealthReportApplicationModel + /// An Enumerable of PasswordHealthReportApplication + /// If user does not have access to the OrganizationId + /// If the organization Id is not valid + [HttpPost("password-health-report-applications")] + public async Task> AddPasswordHealthReportApplications( + [FromBody] IEnumerable 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); } } diff --git a/src/Api/Tools/Models/PasswordHealthReportApplicationModel.cs b/src/Api/Tools/Models/PasswordHealthReportApplicationModel.cs new file mode 100644 index 000000000..93467e117 --- /dev/null +++ b/src/Api/Tools/Models/PasswordHealthReportApplicationModel.cs @@ -0,0 +1,7 @@ +namespace Bit.Api.Tools.Models; + +public class PasswordHealthReportApplicationModel +{ + public Guid OrganizationId { get; set; } + public string Url { get; set; } +} diff --git a/src/Api/Tools/Models/Response/MemberAccessReportModel.cs b/src/Api/Tools/Models/Response/MemberAccessReportModel.cs index 378d4d94c..b110c316c 100644 --- a/src/Api/Tools/Models/Response/MemberAccessReportModel.cs +++ b/src/Api/Tools/Models/Response/MemberAccessReportModel.cs @@ -1,30 +1,7 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Entities; -using Bit.Core.Models.Data; -using Bit.Core.Models.Data.Organizations; -using Bit.Core.Models.Data.Organizations.OrganizationUsers; -using Bit.Core.Vault.Models.Data; +using Bit.Core.Tools.Models.Data; namespace Bit.Api.Tools.Models.Response; -/// -/// 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. -/// -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; } -} - /// /// Contains the collections and group collections a user has access to including /// the permission level for the collection and group collection. @@ -40,134 +17,18 @@ public class MemberAccessReportResponseModel public int TotalItemCount { get; set; } public Guid? UserGuid { get; set; } public bool UsesKeyConnector { get; set; } - public IEnumerable AccessDetails { get; set; } + public IEnumerable AccessDetails { get; set; } - /// - /// 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 - /// - /// Organization groups collection - /// Collections for the organization and the groups/users and permissions - /// Cipher items for the organization with the collections associated with them - /// Organization users and two factor status - /// Organization ability for account recovery status - /// List of the MemberAccessReportResponseModel; - public static IEnumerable CreateReport( - ICollection orgGroups, - ICollection> orgCollectionsWithAccess, - IEnumerable orgItems, - IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled, - OrganizationAbility orgAbility) + public MemberAccessReportResponseModel(MemberAccessCipherDetails memberAccessCipherDetails) { - var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user); - // Create a dictionary to lookup the group names later. - var groupNameDictionary = orgGroups.ToDictionary(x => x.Id, x => x.Name); - - // Get collections grouped and into a dictionary for counts - var collectionItems = orgItems - .SelectMany(x => x.CollectionIds, - (x, b) => new { CipherId = x.Id, CollectionId = b }) - .GroupBy(y => y.CollectionId, - (key, g) => new { CollectionId = key, Ciphers = g }); - var collectionItemCounts = collectionItems.ToDictionary(x => x.CollectionId, x => x.Ciphers.Count()); - - - // Loop through the org users and populate report and access data - var memberAccessReport = new List(); - foreach (var user in orgUsers) - { - // Take the collections/groups and create the access details items - var groupAccessDetails = new List(); - var userCollectionAccessDetails = new List(); - 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(); - if (user.Groups.Any()) - { - var userGroups = groupAccessDetails.Where(x => user.Groups.Contains(x.GroupId.GetValueOrDefault())); - userAccessDetails.AddRange(userGroups); - } - - // There can be edge cases where groups don't have a collection - var groupsWithoutCollections = user.Groups.Where(x => !userAccessDetails.Any(y => x == y.GroupId)); - if (groupsWithoutCollections.Count() > 0) - { - var emptyGroups = groupsWithoutCollections.Select(x => new MemberAccessReportAccessDetails - { - GroupId = x, - GroupName = groupNameDictionary[x], - ItemCount = 0 - }); - userAccessDetails.AddRange(emptyGroups); - } - - if (user.Collections.Any()) - { - var userCollections = userCollectionAccessDetails.Where(x => user.Collections.Any(y => x.CollectionId == y.Id)); - userAccessDetails.AddRange(userCollections); - } - report.AccessDetails = userAccessDetails; - - report.TotalItemCount = collectionItems - .Where(x => report.AccessDetails.Any(y => x.CollectionId == y.CollectionId)) - .SelectMany(x => x.Ciphers) - .GroupBy(g => g.CipherId).Select(grp => grp.FirstOrDefault()) - .Count(); - - // Distinct items only - var distinctItems = report.AccessDetails.Where(x => x.CollectionId.HasValue).Select(x => x.CollectionId).Distinct(); - report.CollectionsCount = distinctItems.Count(); - report.GroupsCount = report.AccessDetails.Select(x => x.GroupId).Where(y => y.HasValue).Distinct().Count(); - memberAccessReport.Add(report); - } - return memberAccessReport; + this.UserName = memberAccessCipherDetails.UserName; + this.Email = memberAccessCipherDetails.Email; + this.TwoFactorEnabled = memberAccessCipherDetails.TwoFactorEnabled; + this.AccountRecoveryEnabled = memberAccessCipherDetails.AccountRecoveryEnabled; + this.GroupsCount = memberAccessCipherDetails.GroupsCount; + this.CollectionsCount = memberAccessCipherDetails.CollectionsCount; + this.TotalItemCount = memberAccessCipherDetails.TotalItemCount; + this.UserGuid = memberAccessCipherDetails.UserGuid; + this.AccessDetails = memberAccessCipherDetails.AccessDetails; } - } diff --git a/src/Api/Tools/Models/Response/MemberCipherDetailsResponseModel.cs b/src/Api/Tools/Models/Response/MemberCipherDetailsResponseModel.cs new file mode 100644 index 000000000..5c87264c5 --- /dev/null +++ b/src/Api/Tools/Models/Response/MemberCipherDetailsResponseModel.cs @@ -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; } + + /// + /// A distinct list of the cipher ids associated with + /// the organization member + /// + public IEnumerable CipherIds { get; set; } + + public MemberCipherDetailsResponseModel(MemberAccessCipherDetails memberAccessCipherDetails) + { + this.UserName = memberAccessCipherDetails.UserName; + this.Email = memberAccessCipherDetails.Email; + this.UsesKeyConnector = memberAccessCipherDetails.UsesKeyConnector; + this.CipherIds = memberAccessCipherDetails.CipherIds; + } +} diff --git a/src/Api/Vault/Models/Response/SyncResponseModel.cs b/src/Api/Vault/Models/Response/SyncResponseModel.cs index ce5f4562d..a9b87ac31 100644 --- a/src/Api/Vault/Models/Response/SyncResponseModel.cs +++ b/src/Api/Vault/Models/Response/SyncResponseModel.cs @@ -1,7 +1,7 @@ -using Bit.Api.Models.Response; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Api.Models.Response; using Bit.Api.Tools.Models.Response; using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Models.Api.Response; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.Entities; using Bit.Core.Models.Api; diff --git a/src/Billing/Services/Implementations/ProviderEventService.cs b/src/Billing/Services/Implementations/ProviderEventService.cs index e716f8f16..7ce72526c 100644 --- a/src/Billing/Services/Implementations/ProviderEventService.cs +++ b/src/Billing/Services/Implementations/ProviderEventService.cs @@ -94,20 +94,17 @@ public class ProviderEventService( var unassignedEnterpriseSeats = enterpriseProviderPlan.SeatMinimum - enterpriseClientSeats ?? 0; - if (unassignedEnterpriseSeats > 0) + invoiceItems.Add(new ProviderInvoiceItem { - invoiceItems.Add(new ProviderInvoiceItem - { - ProviderId = parsedProviderId, - InvoiceId = invoice.Id, - InvoiceNumber = invoice.Number, - ClientName = "Unassigned seats", - PlanName = enterprisePlan.Name, - AssignedSeats = unassignedEnterpriseSeats, - UsedSeats = 0, - Total = unassignedEnterpriseSeats * discountedEnterpriseSeatPrice - }); - } + ProviderId = parsedProviderId, + InvoiceId = invoice.Id, + InvoiceNumber = invoice.Number, + ClientName = "Unassigned seats", + PlanName = enterprisePlan.Name, + AssignedSeats = unassignedEnterpriseSeats, + UsedSeats = 0, + Total = unassignedEnterpriseSeats * discountedEnterpriseSeatPrice + }); } if (teamsProviderPlan.PurchasedSeats is null or 0) @@ -118,20 +115,17 @@ public class ProviderEventService( var unassignedTeamsSeats = teamsProviderPlan.SeatMinimum - teamsClientSeats ?? 0; - if (unassignedTeamsSeats > 0) + invoiceItems.Add(new ProviderInvoiceItem { - invoiceItems.Add(new ProviderInvoiceItem - { - ProviderId = parsedProviderId, - InvoiceId = invoice.Id, - InvoiceNumber = invoice.Number, - ClientName = "Unassigned seats", - PlanName = teamsPlan.Name, - AssignedSeats = unassignedTeamsSeats, - UsedSeats = 0, - Total = unassignedTeamsSeats * discountedTeamsSeatPrice - }); - } + ProviderId = parsedProviderId, + InvoiceId = invoice.Id, + InvoiceNumber = invoice.Number, + ClientName = "Unassigned seats", + PlanName = teamsPlan.Name, + AssignedSeats = unassignedTeamsSeats, + UsedSeats = 0, + Total = unassignedTeamsSeats * discountedTeamsSeatPrice + }); } await Task.WhenAll(invoiceItems.Select(providerInvoiceItemRepository.CreateAsync)); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs index 4a597a290..870fa72aa 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs @@ -20,7 +20,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand private readonly IGlobalSettings _globalSettings; private readonly IPolicyService _policyService; private readonly IFeatureService _featureService; - private readonly IOrganizationService _organizationService; private readonly ILogger _logger; public VerifyOrganizationDomainCommand( @@ -30,7 +29,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand IGlobalSettings globalSettings, IPolicyService policyService, IFeatureService featureService, - IOrganizationService organizationService, ILogger logger) { _organizationDomainRepository = organizationDomainRepository; @@ -39,7 +37,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand _globalSettings = globalSettings; _policyService = policyService; _featureService = featureService; - _organizationService = organizationService; _logger = logger; } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs index 01ffce2cc..f193aeabd 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs @@ -87,8 +87,7 @@ public class SavePolicyCommand : ISavePolicyCommand if (currentPolicy is not { Enabled: true } && policyUpdate.Enabled) { var missingRequiredPolicyTypes = validator.RequiredPolicies - .Where(requiredPolicyType => - savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true }) + .Where(requiredPolicyType => savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true }) .ToList(); if (missingRequiredPolicyTypes.Count != 0) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs index 3e1f8d26c..cc6971f94 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.Auth.Enums; @@ -23,7 +24,9 @@ public class SingleOrgPolicyValidator : IPolicyValidator private readonly IOrganizationRepository _organizationRepository; private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ICurrentContext _currentContext; + private readonly IFeatureService _featureService; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; + private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery; public SingleOrgPolicyValidator( IOrganizationUserRepository organizationUserRepository, @@ -31,14 +34,18 @@ public class SingleOrgPolicyValidator : IPolicyValidator IOrganizationRepository organizationRepository, ISsoConfigRepository ssoConfigRepository, ICurrentContext currentContext, - IRemoveOrganizationUserCommand removeOrganizationUserCommand) + IFeatureService featureService, + IRemoveOrganizationUserCommand removeOrganizationUserCommand, + IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery) { _organizationUserRepository = organizationUserRepository; _mailService = mailService; _organizationRepository = organizationRepository; _ssoConfigRepository = ssoConfigRepository; _currentContext = currentContext; + _featureService = featureService; _removeOrganizationUserCommand = removeOrganizationUserCommand; + _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; } public IEnumerable RequiredPolicies => []; @@ -93,9 +100,21 @@ public class SingleOrgPolicyValidator : IPolicyValidator if (policyUpdate is not { Enabled: true }) { var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(policyUpdate.OrganizationId); - return ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]); + + var validateDecryptionErrorMessage = ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]); + + if (!string.IsNullOrWhiteSpace(validateDecryptionErrorMessage)) + { + return validateDecryptionErrorMessage; + } + + 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 ""; + return string.Empty; } } diff --git a/src/Core/AdminConsole/Services/Implementations/PolicyService.cs b/src/Core/AdminConsole/Services/Implementations/PolicyService.cs index 072aa8283..42655040a 100644 --- a/src/Core/AdminConsole/Services/Implementations/PolicyService.cs +++ b/src/Core/AdminConsole/Services/Implementations/PolicyService.cs @@ -289,7 +289,7 @@ public class PolicyService : IPolicyService if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && 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."); } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 8e759e143..f75920749 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -154,6 +154,7 @@ public static class FeatureFlagKeys public const string IntegrationPage = "pm-14505-admin-console-integration-page"; public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss"; public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss"; + public const string SecurityTasks = "security-tasks"; public static List GetAllKeys() { diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 338b908da..913c4b387 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -21,8 +21,8 @@ - - + + diff --git a/src/Core/Tools/Models/Data/MemberAccessCipherDetails.cs b/src/Core/Tools/Models/Data/MemberAccessCipherDetails.cs new file mode 100644 index 000000000..943d56c53 --- /dev/null +++ b/src/Core/Tools/Models/Data/MemberAccessCipherDetails.cs @@ -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; } + + /// + /// The CipherIds associated with the group/collection access + /// + public IEnumerable 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; } + + /// + /// The details for the member's collection access depending + /// on the collections and groups they are assigned to + /// + public IEnumerable AccessDetails { get; set; } + + /// + /// A distinct list of the cipher ids associated with + /// the organization member + /// + public IEnumerable CipherIds { get; set; } +} diff --git a/src/Core/Tools/ReportFeatures/AddPasswordHealthReportApplicationCommand.cs b/src/Core/Tools/ReportFeatures/AddPasswordHealthReportApplicationCommand.cs new file mode 100644 index 000000000..c6bdb4417 --- /dev/null +++ b/src/Core/Tools/ReportFeatures/AddPasswordHealthReportApplicationCommand.cs @@ -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 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> AddPasswordHealthReportApplicationAsync(IEnumerable 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(); + foreach (var record in passwordHealthReportApplications) + { + var data = await _passwordHealthReportApplicationRepo.CreateAsync(record); + response.Add(data); + } + + return response; + } + + private async Task> ValidateRequestAsync( + AddPasswordHealthReportApplicationRequest request) + { + // verify that the organization exists + var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + return new Tuple(request, false, "Invalid Organization"); + } + + // ensure that we have a URL + if (string.IsNullOrWhiteSpace(request.Url)) + { + return new Tuple(request, false, "URL is required"); + } + + return new Tuple(request, true, string.Empty); + } +} diff --git a/src/Core/Tools/ReportFeatures/GetPasswordHealthReportApplicationQuery.cs b/src/Core/Tools/ReportFeatures/GetPasswordHealthReportApplicationQuery.cs new file mode 100644 index 000000000..5baf5b2f7 --- /dev/null +++ b/src/Core/Tools/ReportFeatures/GetPasswordHealthReportApplicationQuery.cs @@ -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> GetPasswordHealthReportApplicationAsync(Guid organizationId) + { + if (organizationId == Guid.Empty) + { + throw new BadRequestException("OrganizationId is required."); + } + + return await _passwordHealthReportApplicationRepo.GetByOrganizationIdAsync(organizationId); + } +} diff --git a/src/Core/Tools/ReportFeatures/Interfaces/IAddPasswordHealthReportApplicationCommand.cs b/src/Core/Tools/ReportFeatures/Interfaces/IAddPasswordHealthReportApplicationCommand.cs new file mode 100644 index 000000000..86e7d44c7 --- /dev/null +++ b/src/Core/Tools/ReportFeatures/Interfaces/IAddPasswordHealthReportApplicationCommand.cs @@ -0,0 +1,10 @@ +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Requests; + +namespace Bit.Core.Tools.ReportFeatures.Interfaces; + +public interface IAddPasswordHealthReportApplicationCommand +{ + Task AddPasswordHealthReportApplicationAsync(AddPasswordHealthReportApplicationRequest request); + Task> AddPasswordHealthReportApplicationAsync(IEnumerable requests); +} diff --git a/src/Core/Tools/ReportFeatures/Interfaces/IGetPasswordHealthReportApplicationQuery.cs b/src/Core/Tools/ReportFeatures/Interfaces/IGetPasswordHealthReportApplicationQuery.cs new file mode 100644 index 000000000..f24119c2b --- /dev/null +++ b/src/Core/Tools/ReportFeatures/Interfaces/IGetPasswordHealthReportApplicationQuery.cs @@ -0,0 +1,8 @@ +using Bit.Core.Tools.Entities; + +namespace Bit.Core.Tools.ReportFeatures.Interfaces; + +public interface IGetPasswordHealthReportApplicationQuery +{ + Task> GetPasswordHealthReportApplicationAsync(Guid organizationId); +} diff --git a/src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs b/src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs new file mode 100644 index 000000000..a08359a84 --- /dev/null +++ b/src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs @@ -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> 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; + } + + /// + /// 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. + /// + /// Organization groups collection + /// Collections for the organization and the groups/users and permissions + /// Cipher items for the organization with the collections associated with them + /// Organization users and two factor status + /// Organization ability for account recovery status + /// List of the MemberAccessCipherDetailsModel; + private IEnumerable GenerateAccessData( + ICollection orgGroups, + ICollection> orgCollectionsWithAccess, + IEnumerable 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(); + foreach (var user in orgUsers) + { + var groupAccessDetails = new List(); + var userCollectionAccessDetails = new List(); + 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(); + 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; + } +} diff --git a/src/Core/Tools/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs b/src/Core/Tools/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs new file mode 100644 index 000000000..c55495fd1 --- /dev/null +++ b/src/Core/Tools/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs @@ -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> GetMemberAccessCipherDetails(MemberAccessCipherDetailsRequest request); +} diff --git a/src/Core/Tools/ReportFeatures/ReportingServiceCollectionExtensions.cs b/src/Core/Tools/ReportFeatures/ReportingServiceCollectionExtensions.cs new file mode 100644 index 000000000..85e5388a0 --- /dev/null +++ b/src/Core/Tools/ReportFeatures/ReportingServiceCollectionExtensions.cs @@ -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(); + services.AddScoped(); + services.AddScoped(); + } +} diff --git a/src/Core/Tools/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs b/src/Core/Tools/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs new file mode 100644 index 000000000..395230f43 --- /dev/null +++ b/src/Core/Tools/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Tools.ReportFeatures.Requests; + +public class MemberAccessCipherDetailsRequest +{ + public Guid OrganizationId { get; set; } +} diff --git a/src/Core/Tools/Requests/AddPasswordHealthReportApplicationRequest.cs b/src/Core/Tools/Requests/AddPasswordHealthReportApplicationRequest.cs new file mode 100644 index 000000000..f5a1d35ea --- /dev/null +++ b/src/Core/Tools/Requests/AddPasswordHealthReportApplicationRequest.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Tools.Requests; + +public class AddPasswordHealthReportApplicationRequest +{ + public Guid OrganizationId { get; set; } + public string Url { get; set; } +} diff --git a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs index ad0b46277..0e33895bf 100644 --- a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs +++ b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs @@ -95,6 +95,7 @@ public static class EntityFrameworkServiceCollectionExtensions services.AddSingleton(); services .AddSingleton(); + services.AddSingleton(); if (selfHosted) { diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 5a5585952..1b99b4cc8 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -34,6 +34,7 @@ using Bit.Core.SecretsManager.Repositories.Noop; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tokens; +using Bit.Core.Tools.ReportFeatures; using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Bit.Core.Vault; @@ -116,6 +117,7 @@ public static class ServiceCollectionExtensions services.AddLoginServices(); services.AddScoped(); services.AddVaultServices(); + services.AddReportingServices(); } public static void AddTokenizers(this IServiceCollection services) diff --git a/test/Api.Test/AdminConsole/Models/Response/Helpers/PolicyDetailResponsesTests.cs b/test/Api.Test/AdminConsole/Models/Response/Helpers/PolicyDetailResponsesTests.cs new file mode 100644 index 000000000..c380185a7 --- /dev/null +++ b/test/Api.Test/AdminConsole/Models/Response/Helpers/PolicyDetailResponsesTests.cs @@ -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() + .Without(p => p.Data) + .With(p => p.Type, PolicyType.SingleOrg) + .Create(); + + var querySub = Substitute.For(); + 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() + .Without(p => p.Data) + .With(p => p.Type, PolicyType.TwoFactorAuthentication) + .Create(); + + var querySub = Substitute.For(); + querySub.HasVerifiedDomainsAsync(policy.OrganizationId) + .Returns(true); + + var action = async () => await policy.GetSingleOrgPolicyDetailResponseAsync(querySub); + + await Assert.ThrowsAsync("policy", action); + } + + [Fact] + public async Task GetSingleOrgPolicyDetailResponseAsync_GivenPolicyEntity_WhenIsSingleOrgTypeAndDoesNotHaveVerifiedDomains_ThenShouldBeAbleToToggle() + { + var fixture = new Fixture(); + + var policy = fixture.Build() + .Without(p => p.Data) + .With(p => p.Type, PolicyType.SingleOrg) + .Create(); + + var querySub = Substitute.For(); + querySub.HasVerifiedDomainsAsync(policy.OrganizationId) + .Returns(false); + + var result = await policy.GetSingleOrgPolicyDetailResponseAsync(querySub); + + Assert.True(result.CanToggleState); + } +} diff --git a/test/Api.Test/AdminConsole/Public/Models/Response/MemberResponseModelTests.cs b/test/Api.Test/AdminConsole/Public/Models/Response/MemberResponseModelTests.cs new file mode 100644 index 000000000..a9193258b --- /dev/null +++ b/test/Api.Test/AdminConsole/Public/Models/Response/MemberResponseModelTests.cs @@ -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(); + var collections = Substitute.For>(); + 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(); + var collections = Substitute.For>(); + + // Act + var sut = new MemberResponseModel(user, collections); + + // Assert + Assert.False(sut.ResetPasswordEnrolled); + } +} diff --git a/test/Api.Test/Controllers/PoliciesControllerTests.cs b/test/Api.Test/Controllers/PoliciesControllerTests.cs index 77cc5ea02..1b96ace5d 100644 --- a/test/Api.Test/Controllers/PoliciesControllerTests.cs +++ b/test/Api.Test/Controllers/PoliciesControllerTests.cs @@ -1,9 +1,9 @@ using System.Security.Claims; using System.Text.Json; using Bit.Api.AdminConsole.Controllers; +using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.Models.Api.Response; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Context; @@ -157,7 +157,7 @@ public class PoliciesControllerTests var result = await sutProvider.Sut.Get(orgId, type); // Assert - Assert.IsType(result); + Assert.IsType(result); Assert.Equal(policy.Id, result.Id); Assert.Equal(policy.Type, result.Type); Assert.Equal(policy.Enabled, result.Enabled); @@ -182,7 +182,7 @@ public class PoliciesControllerTests var result = await sutProvider.Sut.Get(orgId, type); // Assert - Assert.IsType(result); + Assert.IsType(result); Assert.Equal(result.Type, (PolicyType)type); Assert.False(result.Enabled); } diff --git a/test/Api.Test/Tools/Controllers/ReportsControllerTests.cs b/test/Api.Test/Tools/Controllers/ReportsControllerTests.cs new file mode 100644 index 000000000..07d37f672 --- /dev/null +++ b/test/Api.Test/Tools/Controllers/ReportsControllerTests.cs @@ -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 sutProvider) + { + // Arrange + sutProvider.GetDependency().AccessReports(Arg.Any()).Returns(true); + + // Act + var orgId = Guid.NewGuid(); + var result = await sutProvider.Sut.GetPasswordHealthReportApplications(orgId); + + // Assert + _ = sutProvider.GetDependency() + .Received(1) + .GetPasswordHealthReportApplicationAsync(Arg.Is(_ => _ == orgId)); + } + + [Theory, BitAutoData] + public async Task GetPasswordHealthReportApplicationAsync_withoutAccess(SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().AccessReports(Arg.Any()).Returns(false); + + // Act & Assert + var orgId = Guid.NewGuid(); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetPasswordHealthReportApplications(orgId)); + + // Assert + _ = sutProvider.GetDependency() + .Received(0); + } + + +} diff --git a/test/Core.Test/AdminConsole/Services/PolicyServiceTests.cs b/test/Core.Test/AdminConsole/Services/PolicyServiceTests.cs index da3f2b267..68f36e37c 100644 --- a/test/Core.Test/AdminConsole/Services/PolicyServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/PolicyServiceTests.cs @@ -842,6 +842,6 @@ public class PolicyServiceTests var badRequestException = await Assert.ThrowsAsync( () => 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); } } diff --git a/test/Core.Test/Tools/ReportFeatures/AddPasswordHealthReportApplicationCommandTests.cs b/test/Core.Test/Tools/ReportFeatures/AddPasswordHealthReportApplicationCommandTests.cs new file mode 100644 index 000000000..8c3a68fdc --- /dev/null +++ b/test/Core.Test/Tools/ReportFeatures/AddPasswordHealthReportApplicationCommandTests.cs @@ -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 sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(c => c.Arg()); + + // Act + var result = await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request); + + // Assert + Assert.NotNull(result); + } + + [Theory] + [BitAutoData] + public async Task AddPasswordHealthReportApplicationAsync_WithInvalidOrganizationId_ShouldThrowError( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(null as Organization); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request)); + Assert.Equal("Invalid Organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task AddPasswordHealthReportApplicationAsync_WithInvalidUrl_ShouldThrowError( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .Without(_ => _.Url) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(fixture.Create()); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request)); + Assert.Equal("URL is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task AddPasswordHealthReportApplicationAsync_Multiples_WithInvalidOrganizationId_ShouldThrowError( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .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() + .GetByIdAsync(Arg.Is(x => x == request[0].OrganizationId)) + .Returns(fixture.Create()); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request)); + Assert.Equal("Invalid Organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task AddPasswordHealthReportApplicationAsync_Multiples_WithInvalidUrl_ShouldThrowError( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .CreateMany(2).ToList(); + + request[1].Url = string.Empty; + + // return an organization for both requests + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(fixture.Create()); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request)); + Assert.Equal("URL is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task AddPasswordHealthReportApplicationAsync_Multiples_WithValidRequest_ShouldBeSuccessful( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.CreateMany(2); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(c => c.Arg()); + + // Act + var result = await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request); + + // Assert + Assert.True(result.Count() == 2); + sutProvider.GetDependency().Received(2); + sutProvider.GetDependency().Received(2); + } +} diff --git a/test/Core.Test/Tools/ReportFeatures/GetPasswordHealthReportApplicationQueryTests.cs b/test/Core.Test/Tools/ReportFeatures/GetPasswordHealthReportApplicationQueryTests.cs new file mode 100644 index 000000000..c4f098b0c --- /dev/null +++ b/test/Core.Test/Tools/ReportFeatures/GetPasswordHealthReportApplicationQueryTests.cs @@ -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 sutProvider) + { + // Arrange + var fixture = new Fixture(); + var organizationId = fixture.Create(); + sutProvider.GetDependency() + .GetByOrganizationIdAsync(Arg.Any()) + .Returns(fixture.CreateMany(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 sutProvider) + { + // Arrange + var fixture = new Fixture(); + sutProvider.GetDependency() + .GetByOrganizationIdAsync(Arg.Is(x => x == Guid.Empty)) + .Returns(new List()); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetPasswordHealthReportApplicationAsync(Guid.Empty)); + + // Assert + Assert.Equal("OrganizationId is required.", exception.Message); + } +}