From a99f82dddd647d8abd166851bcfca07752e1e3ab Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Thu, 9 Jan 2025 12:14:24 -0800 Subject: [PATCH] [PM-14378] SecurityTask Authorization Handler (#5039) * [PM-14378] Introduce GetCipherPermissionsForOrganization query for Dapper CipherRepository * [PM-14378] Introduce GetCipherPermissionsForOrganization method for Entity Framework * [PM-14378] Add integration tests for new repository method * [PM-14378] Introduce IGetCipherPermissionsForUserQuery CQRS query * [PM-14378] Introduce SecurityTaskOperationRequirement * [PM-14378] Introduce SecurityTaskAuthorizationHandler.cs * [PM-14378] Introduce SecurityTaskOrganizationAuthorizationHandler.cs * [PM-14378] Register new authorization handlers * [PM-14378] Formatting * [PM-14378] Add unit tests for GetCipherPermissionsForUserQuery * [PM-15378] Cleanup SecurityTaskAuthorizationHandler and add tests * [PM-14378] Add tests for SecurityTaskOrganizationAuthorizationHandler * [PM-14378] Formatting * [PM-14378] Update date in migration file * [PM-14378] Add missing awaits * [PM-14378] Bump migration script date * [PM-14378] Remove Unassigned property from OrganizationCipherPermission as it was making the query too complicated * [PM-14378] Update sproc to use Union All to improve query performance * [PM-14378] Bump migration script date --- .../Utilities/ServiceCollectionExtensions.cs | 3 + .../SecurityTaskAuthorizationHandler.cs | 142 ++++++ .../SecurityTaskOperationRequirement.cs | 27 ++ ...ityTaskOrganizationAuthorizationHandler.cs | 47 ++ .../Data/OrganizationCipherPermission.cs | 40 ++ .../GetCipherPermissionsForUserQuery.cs | 97 ++++ .../IGetCipherPermissionsForUserQuery.cs | 19 + .../Vault/Repositories/ICipherRepository.cs | 10 + .../Vault/VaultServiceCollectionExtensions.cs | 1 + .../Vault/Repositories/CipherRepository.cs | 14 + .../Vault/Repositories/CipherRepository.cs | 46 ++ .../CipherOrganizationPermissionsQuery.cs | 63 +++ ...ionPermissions_GetManyByOrganizationId.sql | 76 ++++ .../SecurityTaskAuthorizationHandlerTests.cs | 430 ++++++++++++++++++ ...skOrganizationAuthorizationHandlerTests.cs | 104 +++++ .../GetCipherPermissionsForUserQueryTests.cs | 238 ++++++++++ .../Repositories/CipherRepositoryTests.cs | 235 ++++++++++ ..._00_CipherOrganizationPermissionsQuery.sql | 77 ++++ 18 files changed, 1669 insertions(+) create mode 100644 src/Core/Vault/Authorization/SecurityTasks/SecurityTaskAuthorizationHandler.cs create mode 100644 src/Core/Vault/Authorization/SecurityTasks/SecurityTaskOperationRequirement.cs create mode 100644 src/Core/Vault/Authorization/SecurityTasks/SecurityTaskOrganizationAuthorizationHandler.cs create mode 100644 src/Core/Vault/Models/Data/OrganizationCipherPermission.cs create mode 100644 src/Core/Vault/Queries/GetCipherPermissionsForUserQuery.cs create mode 100644 src/Core/Vault/Queries/IGetCipherPermissionsForUserQuery.cs create mode 100644 src/Infrastructure.EntityFramework/Vault/Repositories/Queries/CipherOrganizationPermissionsQuery.cs create mode 100644 src/Sql/Vault/dbo/Stored Procedures/Cipher/CipherOrganizationPermissions_GetManyByOrganizationId.sql create mode 100644 test/Core.Test/Vault/Authorization/SecurityTaskAuthorizationHandlerTests.cs create mode 100644 test/Core.Test/Vault/Authorization/SecurityTaskOrganizationAuthorizationHandlerTests.cs create mode 100644 test/Core.Test/Vault/Queries/GetCipherPermissionsForUserQueryTests.cs create mode 100644 util/Migrator/DbScripts/2025-01-08_00_CipherOrganizationPermissionsQuery.sql diff --git a/src/Api/Utilities/ServiceCollectionExtensions.cs b/src/Api/Utilities/ServiceCollectionExtensions.cs index 270055be8f..be106786e8 100644 --- a/src/Api/Utilities/ServiceCollectionExtensions.cs +++ b/src/Api/Utilities/ServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization; using Bit.Core.IdentityServer; using Bit.Core.Settings; using Bit.Core.Utilities; +using Bit.Core.Vault.Authorization.SecurityTasks; using Bit.SharedWeb.Health; using Bit.SharedWeb.Swagger; using Microsoft.AspNetCore.Authorization; @@ -104,5 +105,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } } diff --git a/src/Core/Vault/Authorization/SecurityTasks/SecurityTaskAuthorizationHandler.cs b/src/Core/Vault/Authorization/SecurityTasks/SecurityTaskAuthorizationHandler.cs new file mode 100644 index 0000000000..eedae99083 --- /dev/null +++ b/src/Core/Vault/Authorization/SecurityTasks/SecurityTaskAuthorizationHandler.cs @@ -0,0 +1,142 @@ +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Queries; +using Microsoft.AspNetCore.Authorization; + +namespace Bit.Core.Vault.Authorization.SecurityTasks; + +public class SecurityTaskAuthorizationHandler : AuthorizationHandler +{ + private readonly ICurrentContext _currentContext; + private readonly IGetCipherPermissionsForUserQuery _getCipherPermissionsForUserQuery; + + private readonly Dictionary> _cipherPermissionCache = new(); + + public SecurityTaskAuthorizationHandler(ICurrentContext currentContext, IGetCipherPermissionsForUserQuery getCipherPermissionsForUserQuery) + { + _currentContext = currentContext; + _getCipherPermissionsForUserQuery = getCipherPermissionsForUserQuery; + } + + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, + SecurityTaskOperationRequirement requirement, + SecurityTask task) + { + if (!_currentContext.UserId.HasValue) + { + return; + } + + var org = _currentContext.GetOrganization(task.OrganizationId); + + if (org == null) + { + // User must be a member of the organization + return; + } + + var authorized = requirement switch + { + not null when requirement == SecurityTaskOperations.Read => await CanReadAsync(task, org), + not null when requirement == SecurityTaskOperations.Create => await CanCreateAsync(task, org), + not null when requirement == SecurityTaskOperations.Update => await CanUpdateAsync(task, org), + _ => throw new ArgumentOutOfRangeException(nameof(requirement), requirement, null) + }; + + if (authorized) + { + context.Succeed(requirement); + } + } + + private async Task CanReadAsync(SecurityTask task, CurrentContextOrganization org) + { + if (!task.CipherId.HasValue) + { + // Tasks without cipher IDs are not possible currently + return false; + } + + if (HasAdminAccessToSecurityTasks(org)) + { + // Admins can read any task for ciphers in the organization + return await CipherBelongsToOrgAsync(org, task.CipherId.Value); + } + + return await CanReadCipherForOrgAsync(org, task.CipherId.Value); + } + + private async Task CanCreateAsync(SecurityTask task, CurrentContextOrganization org) + { + if (!task.CipherId.HasValue) + { + // Tasks without cipher IDs are not possible currently + return false; + } + + if (!HasAdminAccessToSecurityTasks(org)) + { + // User must be an Admin/Owner or have custom permissions for reporting + return false; + } + + return await CipherBelongsToOrgAsync(org, task.CipherId.Value); + } + + private async Task CanUpdateAsync(SecurityTask task, CurrentContextOrganization org) + { + if (!task.CipherId.HasValue) + { + // Tasks without cipher IDs are not possible currently + return false; + } + + // Only users that can edit the cipher can update the task + return await CanEditCipherForOrgAsync(org, task.CipherId.Value); + } + + private async Task CanEditCipherForOrgAsync(CurrentContextOrganization org, Guid cipherId) + { + var ciphers = await GetCipherPermissionsForOrgAsync(org); + + return ciphers.TryGetValue(cipherId, out var cipher) && cipher.Edit; + } + + private async Task CanReadCipherForOrgAsync(CurrentContextOrganization org, Guid cipherId) + { + var ciphers = await GetCipherPermissionsForOrgAsync(org); + + return ciphers.TryGetValue(cipherId, out var cipher) && cipher.Read; + } + + private async Task CipherBelongsToOrgAsync(CurrentContextOrganization org, Guid cipherId) + { + var ciphers = await GetCipherPermissionsForOrgAsync(org); + + return ciphers.ContainsKey(cipherId); + } + + private bool HasAdminAccessToSecurityTasks(CurrentContextOrganization org) + { + return org is + { Type: OrganizationUserType.Admin or OrganizationUserType.Owner } or + { Type: OrganizationUserType.Custom, Permissions.AccessReports: true }; + } + + private async Task> GetCipherPermissionsForOrgAsync(CurrentContextOrganization organization) + { + // Re-use permissions we've already fetched for the organization + if (_cipherPermissionCache.TryGetValue(organization.Id, out var cachedCiphers)) + { + return cachedCiphers; + } + + var cipherPermissions = await _getCipherPermissionsForUserQuery.GetByOrganization(organization.Id); + + _cipherPermissionCache.Add(organization.Id, cipherPermissions); + + return cipherPermissions; + } +} diff --git a/src/Core/Vault/Authorization/SecurityTasks/SecurityTaskOperationRequirement.cs b/src/Core/Vault/Authorization/SecurityTasks/SecurityTaskOperationRequirement.cs new file mode 100644 index 0000000000..4ced1d70b9 --- /dev/null +++ b/src/Core/Vault/Authorization/SecurityTasks/SecurityTaskOperationRequirement.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Authorization.Infrastructure; + +namespace Bit.Core.Vault.Authorization.SecurityTasks; + +public class SecurityTaskOperationRequirement : OperationAuthorizationRequirement +{ + public SecurityTaskOperationRequirement(string name) + { + Name = name; + } +} + +public static class SecurityTaskOperations +{ + public static readonly SecurityTaskOperationRequirement Read = new SecurityTaskOperationRequirement(nameof(Read)); + public static readonly SecurityTaskOperationRequirement Create = new SecurityTaskOperationRequirement(nameof(Create)); + public static readonly SecurityTaskOperationRequirement Update = new SecurityTaskOperationRequirement(nameof(Update)); + + /// + /// List all security tasks for a specific organization. + /// + /// var orgContext = _currentContext.GetOrganization(organizationId); + /// _authorizationService.AuthorizeOrThrowAsync(User, SecurityTaskOperations.ListAllForOrganization, orgContext); + /// + /// + public static readonly SecurityTaskOperationRequirement ListAllForOrganization = new SecurityTaskOperationRequirement(nameof(ListAllForOrganization)); +} diff --git a/src/Core/Vault/Authorization/SecurityTasks/SecurityTaskOrganizationAuthorizationHandler.cs b/src/Core/Vault/Authorization/SecurityTasks/SecurityTaskOrganizationAuthorizationHandler.cs new file mode 100644 index 0000000000..ec3800dc94 --- /dev/null +++ b/src/Core/Vault/Authorization/SecurityTasks/SecurityTaskOrganizationAuthorizationHandler.cs @@ -0,0 +1,47 @@ +using Bit.Core.Context; +using Bit.Core.Enums; +using Microsoft.AspNetCore.Authorization; + +namespace Bit.Core.Vault.Authorization.SecurityTasks; + +public class + SecurityTaskOrganizationAuthorizationHandler : AuthorizationHandler +{ + private readonly ICurrentContext _currentContext; + + public SecurityTaskOrganizationAuthorizationHandler(ICurrentContext currentContext) + { + _currentContext = currentContext; + } + + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, + SecurityTaskOperationRequirement requirement, + CurrentContextOrganization resource) + { + if (!_currentContext.UserId.HasValue) + { + return Task.CompletedTask; + } + + var authorized = requirement switch + { + not null when requirement == SecurityTaskOperations.ListAllForOrganization => CanListAllTasksForOrganization(resource), + _ => throw new ArgumentOutOfRangeException(nameof(requirement), requirement, null) + }; + + if (authorized) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } + + private static bool CanListAllTasksForOrganization(CurrentContextOrganization org) + { + return org is + { Type: OrganizationUserType.Admin or OrganizationUserType.Owner } or + { Type: OrganizationUserType.Custom, Permissions.AccessReports: true }; + } +} diff --git a/src/Core/Vault/Models/Data/OrganizationCipherPermission.cs b/src/Core/Vault/Models/Data/OrganizationCipherPermission.cs new file mode 100644 index 0000000000..c89284c2b4 --- /dev/null +++ b/src/Core/Vault/Models/Data/OrganizationCipherPermission.cs @@ -0,0 +1,40 @@ +namespace Bit.Core.Vault.Models.Data; + +/// +/// Data model that represents a Users permissions for a given cipher +/// that belongs to an organization. +/// To be used internally for authorization. +/// +public class OrganizationCipherPermission +{ + /// + /// The cipher Id + /// + public Guid Id { get; set; } + + /// + /// The organization Id that the cipher belongs to. + /// + public Guid OrganizationId { get; set; } + + /// + /// The user can read the cipher. + /// See for password visibility. + /// + public bool Read { get; set; } + + /// + /// The user has permission to view the password of the cipher. + /// + public bool ViewPassword { get; set; } + + /// + /// The user has permission to edit the cipher. + /// + public bool Edit { get; set; } + + /// + /// The user has manage level access to the cipher. + /// + public bool Manage { get; set; } +} diff --git a/src/Core/Vault/Queries/GetCipherPermissionsForUserQuery.cs b/src/Core/Vault/Queries/GetCipherPermissionsForUserQuery.cs new file mode 100644 index 0000000000..5cce87e958 --- /dev/null +++ b/src/Core/Vault/Queries/GetCipherPermissionsForUserQuery.cs @@ -0,0 +1,97 @@ +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Repositories; + +namespace Bit.Core.Vault.Queries; + +public class GetCipherPermissionsForUserQuery : IGetCipherPermissionsForUserQuery +{ + private readonly ICurrentContext _currentContext; + private readonly ICipherRepository _cipherRepository; + private readonly IApplicationCacheService _applicationCacheService; + + public GetCipherPermissionsForUserQuery(ICurrentContext currentContext, ICipherRepository cipherRepository, IApplicationCacheService applicationCacheService) + { + _currentContext = currentContext; + _cipherRepository = cipherRepository; + _applicationCacheService = applicationCacheService; + } + + public async Task> GetByOrganization(Guid organizationId) + { + var org = _currentContext.GetOrganization(organizationId); + var userId = _currentContext.UserId; + + if (org == null || !userId.HasValue) + { + throw new NotFoundException(); + } + + var cipherPermissions = + (await _cipherRepository.GetCipherPermissionsForOrganizationAsync(organizationId, userId.Value)) + .ToList() + .ToDictionary(c => c.Id); + + if (await CanEditAllCiphersAsync(org)) + { + foreach (var cipher in cipherPermissions) + { + cipher.Value.Read = true; + cipher.Value.Edit = true; + cipher.Value.Manage = true; + cipher.Value.ViewPassword = true; + } + } + else if (await CanAccessUnassignedCiphersAsync(org)) + { + var unassignedCiphers = await _cipherRepository.GetManyUnassignedOrganizationDetailsByOrganizationIdAsync(organizationId); + foreach (var unassignedCipher in unassignedCiphers) + { + if (cipherPermissions.TryGetValue(unassignedCipher.Id, out var p)) + { + p.Read = true; + p.Edit = true; + p.Manage = true; + p.ViewPassword = true; + } + } + } + + return cipherPermissions; + } + + private async Task CanEditAllCiphersAsync(CurrentContextOrganization org) + { + // Custom users with EditAnyCollection permissions can always edit all ciphers + if (org is { Type: OrganizationUserType.Custom, Permissions.EditAnyCollection: true }) + { + return true; + } + + var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(org.Id); + + // Owners/Admins can only edit all ciphers if the organization has the setting enabled + if (orgAbility is { AllowAdminAccessToAllCollectionItems: true } && org is + { Type: OrganizationUserType.Admin or OrganizationUserType.Owner }) + { + return true; + } + + return false; + } + + private async Task CanAccessUnassignedCiphersAsync(CurrentContextOrganization org) + { + if (org is + { Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or + { Permissions.EditAnyCollection: true }) + { + return true; + } + + return false; + } +} diff --git a/src/Core/Vault/Queries/IGetCipherPermissionsForUserQuery.cs b/src/Core/Vault/Queries/IGetCipherPermissionsForUserQuery.cs new file mode 100644 index 0000000000..3ab40f26f0 --- /dev/null +++ b/src/Core/Vault/Queries/IGetCipherPermissionsForUserQuery.cs @@ -0,0 +1,19 @@ +using Bit.Core.Vault.Models.Data; + +namespace Bit.Core.Vault.Queries; + +public interface IGetCipherPermissionsForUserQuery +{ + /// + /// Retrieves the permissions of every organization cipher (including unassigned) for the + /// ICurrentContext's user. + /// + /// It considers the Collection Management setting for allowing Admin/Owners access to all ciphers. + /// + /// + /// The primary use case of this query is internal cipher authorization logic. + /// + /// + /// A dictionary of CipherIds and a corresponding OrganizationCipherPermission + public Task> GetByOrganization(Guid organizationId); +} diff --git a/src/Core/Vault/Repositories/ICipherRepository.cs b/src/Core/Vault/Repositories/ICipherRepository.cs index f3f34c595b..2950cb99c2 100644 --- a/src/Core/Vault/Repositories/ICipherRepository.cs +++ b/src/Core/Vault/Repositories/ICipherRepository.cs @@ -39,6 +39,16 @@ public interface ICipherRepository : IRepository Task RestoreByIdsOrganizationIdAsync(IEnumerable ids, Guid organizationId); Task DeleteDeletedAsync(DateTime deletedDateBefore); + /// + /// Low-level query to get all cipher permissions for a user in an organization. DOES NOT consider the user's + /// organization role, any collection management settings on the organization, or special unassigned cipher + /// permissions. + /// + /// Recommended to use instead to handle those cases. + /// + Task> GetCipherPermissionsForOrganizationAsync(Guid organizationId, + Guid userId); + /// /// Updates encrypted data for ciphers during a key rotation /// diff --git a/src/Core/Vault/VaultServiceCollectionExtensions.cs b/src/Core/Vault/VaultServiceCollectionExtensions.cs index 15cb01f1a0..4995d0405f 100644 --- a/src/Core/Vault/VaultServiceCollectionExtensions.cs +++ b/src/Core/Vault/VaultServiceCollectionExtensions.cs @@ -19,5 +19,6 @@ public static class VaultServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } } diff --git a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs index 69b1383f4b..098e8299e4 100644 --- a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs @@ -309,6 +309,20 @@ public class CipherRepository : Repository, ICipherRepository } } + public async Task> GetCipherPermissionsForOrganizationAsync( + Guid organizationId, Guid userId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[CipherOrganizationPermissions_GetManyByOrganizationId]", + new { OrganizationId = organizationId, UserId = userId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + /// public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation( Guid userId, IEnumerable ciphers) diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs index c12167a78c..6a4ffb4b35 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs @@ -302,6 +302,52 @@ public class CipherRepository : Repository> + GetCipherPermissionsForOrganizationAsync(Guid organizationId, Guid userId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var query = new CipherOrganizationPermissionsQuery(organizationId, userId).Run(dbContext); + + ICollection permissions; + + // SQLite does not support the GROUP BY clause + if (dbContext.Database.IsSqlite()) + { + permissions = (await query.ToListAsync()) + .GroupBy(c => new { c.Id, c.OrganizationId }) + .Select(g => new OrganizationCipherPermission + { + Id = g.Key.Id, + OrganizationId = g.Key.OrganizationId, + Read = Convert.ToBoolean(g.Max(c => Convert.ToInt32(c.Read))), + ViewPassword = Convert.ToBoolean(g.Max(c => Convert.ToInt32(c.ViewPassword))), + Edit = Convert.ToBoolean(g.Max(c => Convert.ToInt32(c.Edit))), + Manage = Convert.ToBoolean(g.Max(c => Convert.ToInt32(c.Manage))), + }).ToList(); + } + else + { + var groupByQuery = from p in query + group p by new { p.Id, p.OrganizationId } + into g + select new OrganizationCipherPermission + { + Id = g.Key.Id, + OrganizationId = g.Key.OrganizationId, + Read = Convert.ToBoolean(g.Max(c => Convert.ToInt32(c.Read))), + ViewPassword = Convert.ToBoolean(g.Max(c => Convert.ToInt32(c.ViewPassword))), + Edit = Convert.ToBoolean(g.Max(c => Convert.ToInt32(c.Edit))), + Manage = Convert.ToBoolean(g.Max(c => Convert.ToInt32(c.Manage))), + }; + permissions = await groupByQuery.ToListAsync(); + } + + return permissions; + } + } + public async Task GetByIdAsync(Guid id, Guid userId) { using (var scope = ServiceScopeFactory.CreateScope()) diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/CipherOrganizationPermissionsQuery.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/CipherOrganizationPermissionsQuery.cs new file mode 100644 index 0000000000..89e70f4f92 --- /dev/null +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/CipherOrganizationPermissionsQuery.cs @@ -0,0 +1,63 @@ +using Bit.Core.Vault.Models.Data; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.Infrastructure.EntityFramework.Repositories.Queries; + +namespace Bit.Infrastructure.EntityFramework.Vault.Repositories.Queries; + +public class CipherOrganizationPermissionsQuery : IQuery +{ + private readonly Guid _organizationId; + private readonly Guid _userId; + + public CipherOrganizationPermissionsQuery(Guid organizationId, Guid userId) + { + _organizationId = organizationId; + _userId = userId; + } + + public IQueryable Run(DatabaseContext dbContext) + { + return from c in dbContext.Ciphers + + join ou in dbContext.OrganizationUsers + on new { CipherUserId = c.UserId, c.OrganizationId, UserId = (Guid?)_userId } equals + new { CipherUserId = (Guid?)null, OrganizationId = (Guid?)ou.OrganizationId, ou.UserId, } + + join o in dbContext.Organizations + on new { c.OrganizationId, OuOrganizationId = ou.OrganizationId, Enabled = true } equals + new { OrganizationId = (Guid?)o.Id, OuOrganizationId = o.Id, o.Enabled } + + join cc in dbContext.CollectionCiphers + on c.Id equals cc.CipherId into cc_g + from cc in cc_g.DefaultIfEmpty() + + join cu in dbContext.CollectionUsers + on new { cc.CollectionId, OrganizationUserId = ou.Id } equals + new { cu.CollectionId, cu.OrganizationUserId } into cu_g + from cu in cu_g.DefaultIfEmpty() + + join gu in dbContext.GroupUsers + on new { CollectionId = (Guid?)cu.CollectionId, OrganizationUserId = ou.Id } equals + new { CollectionId = (Guid?)null, gu.OrganizationUserId } into gu_g + from gu in gu_g.DefaultIfEmpty() + + join g in dbContext.Groups + on gu.GroupId equals g.Id into g_g + from g in g_g.DefaultIfEmpty() + + join cg in dbContext.CollectionGroups + on new { cc.CollectionId, gu.GroupId } equals + new { cg.CollectionId, cg.GroupId } into cg_g + from cg in cg_g.DefaultIfEmpty() + + select new OrganizationCipherPermission() + { + Id = c.Id, + OrganizationId = o.Id, + Read = cu != null || cg != null, + ViewPassword = !((bool?)cu.HidePasswords ?? (bool?)cg.HidePasswords ?? true), + Edit = !((bool?)cu.ReadOnly ?? (bool?)cg.ReadOnly ?? true), + Manage = (bool?)cu.Manage ?? (bool?)cg.Manage ?? false, + }; + } +} diff --git a/src/Sql/Vault/dbo/Stored Procedures/Cipher/CipherOrganizationPermissions_GetManyByOrganizationId.sql b/src/Sql/Vault/dbo/Stored Procedures/Cipher/CipherOrganizationPermissions_GetManyByOrganizationId.sql new file mode 100644 index 0000000000..3fb5b53da7 --- /dev/null +++ b/src/Sql/Vault/dbo/Stored Procedures/Cipher/CipherOrganizationPermissions_GetManyByOrganizationId.sql @@ -0,0 +1,76 @@ +CREATE PROCEDURE [dbo].[CipherOrganizationPermissions_GetManyByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + ;WITH BaseCiphers AS ( + SELECT C.[Id], C.[OrganizationId] + FROM [dbo].[CipherDetails](@UserId) C + INNER JOIN [OrganizationUser] OU ON + C.[UserId] IS NULL + AND C.[OrganizationId] = @OrganizationId + AND OU.[UserId] = @UserId + INNER JOIN [dbo].[Organization] O ON + O.[Id] = OU.[OrganizationId] + AND O.[Id] = C.[OrganizationId] + AND O.[Enabled] = 1 + ), + UserPermissions AS ( + SELECT DISTINCT + CC.[CipherId], + CASE WHEN CC.[CollectionId] IS NULL THEN 0 ELSE 1 END as [Read], + CASE WHEN CU.[HidePasswords] = 0 THEN 1 ELSE 0 END as [ViewPassword], + CASE WHEN CU.[ReadOnly] = 0 THEN 1 ELSE 0 END as [Edit], + COALESCE(CU.[Manage], 0) as [Manage] + FROM [dbo].[CollectionCipher] CC + INNER JOIN [dbo].[CollectionUser] CU ON + CU.[CollectionId] = CC.[CollectionId] + AND CU.[OrganizationUserId] = ( + SELECT [Id] FROM [OrganizationUser] + WHERE [UserId] = @UserId + AND [OrganizationId] = @OrganizationId + ) + ), + GroupPermissions AS ( + SELECT DISTINCT + CC.[CipherId], + CASE WHEN CC.[CollectionId] IS NULL THEN 0 ELSE 1 END as [Read], + CASE WHEN CG.[HidePasswords] = 0 THEN 1 ELSE 0 END as [ViewPassword], + CASE WHEN CG.[ReadOnly] = 0 THEN 1 ELSE 0 END as [Edit], + COALESCE(CG.[Manage], 0) as [Manage] + FROM [dbo].[CollectionCipher] CC + INNER JOIN [dbo].[CollectionGroup] CG ON + CG.[CollectionId] = CC.[CollectionId] + INNER JOIN [dbo].[GroupUser] GU ON + GU.[GroupId] = CG.[GroupId] + AND GU.[OrganizationUserId] = ( + SELECT [Id] FROM [OrganizationUser] + WHERE [UserId] = @UserId + AND [OrganizationId] = @OrganizationId + ) + WHERE NOT EXISTS ( + SELECT 1 + FROM UserPermissions UP + WHERE UP.[CipherId] = CC.[CipherId] + ) + ), + CombinedPermissions AS ( + SELECT CipherId, [Read], ViewPassword, Edit, Manage + FROM UserPermissions + UNION ALL + SELECT CipherId, [Read], ViewPassword, Edit, Manage + FROM GroupPermissions + ) + SELECT + C.[Id], + C.[OrganizationId], + ISNULL(MAX(P.[Read]), 0) as [Read], + ISNULL(MAX(P.[ViewPassword]), 0) as [ViewPassword], + ISNULL(MAX(P.[Edit]), 0) as [Edit], + ISNULL(MAX(P.[Manage]), 0) as [Manage] + FROM BaseCiphers C + LEFT JOIN CombinedPermissions P ON P.CipherId = C.[Id] + GROUP BY C.[Id], C.[OrganizationId] +END diff --git a/test/Core.Test/Vault/Authorization/SecurityTaskAuthorizationHandlerTests.cs b/test/Core.Test/Vault/Authorization/SecurityTaskAuthorizationHandlerTests.cs new file mode 100644 index 0000000000..43bdceac98 --- /dev/null +++ b/test/Core.Test/Vault/Authorization/SecurityTaskAuthorizationHandlerTests.cs @@ -0,0 +1,430 @@ +using System.Security.Claims; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Core.Vault.Authorization.SecurityTasks; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Queries; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Authorization; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Vault.Authorization; + +[SutProviderCustomize] +public class SecurityTaskAuthorizationHandlerTests +{ + [Theory, CurrentContextOrganizationCustomize, BitAutoData] + public async Task MissingOrg_Failure( + CurrentContextOrganization organization, + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + var task = new SecurityTask + { + OrganizationId = organization.Id, + CipherId = Guid.NewGuid() + }; + + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns((CurrentContextOrganization)null); + + var context = new AuthorizationHandlerContext( + new[] { SecurityTaskOperations.Read }, + new ClaimsPrincipal(), + task); + + await sutProvider.Sut.HandleAsync(context); + + Assert.False(context.HasSucceeded); + } + + [Theory, CurrentContextOrganizationCustomize, BitAutoData] + public async Task MissingCipherId_Failure( + CurrentContextOrganization organization, + SutProvider sutProvider) + { + var operations = new[] + { + SecurityTaskOperations.Read, SecurityTaskOperations.Create, SecurityTaskOperations.Update + }; + var userId = Guid.NewGuid(); + var task = new SecurityTask + { + OrganizationId = organization.Id, + CipherId = null + }; + + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + + foreach (var operation in operations) + { + var context = new AuthorizationHandlerContext( + new[] { operation }, + new ClaimsPrincipal(), + task); + + await sutProvider.Sut.HandleAsync(context); + + Assert.False(context.HasSucceeded, operation.ToString()); + } + + } + + [Theory, CurrentContextOrganizationCustomize(Type = OrganizationUserType.User), BitAutoData] + public async Task Read_User_CanReadCipher_Success( + CurrentContextOrganization organization, + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + var task = new SecurityTask + { + OrganizationId = organization.Id, + CipherId = Guid.NewGuid() + }; + var cipherPermissions = new OrganizationCipherPermission + { + Id = task.CipherId.Value, + Read = true + }; + + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetByOrganization(organization.Id).Returns(new Dictionary + { + { task.CipherId.Value, cipherPermissions } + }); + + var context = new AuthorizationHandlerContext( + new[] { SecurityTaskOperations.Read }, + new ClaimsPrincipal(), + task); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasSucceeded); + } + + [Theory, CurrentContextOrganizationCustomize(Type = OrganizationUserType.Admin), BitAutoData] + public async Task Read_Admin_Success( + CurrentContextOrganization organization, + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + var task = new SecurityTask + { + OrganizationId = organization.Id, + CipherId = Guid.NewGuid() + }; + var cipherPermissions = new OrganizationCipherPermission + { + Id = task.CipherId.Value, + }; + + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetByOrganization(organization.Id).Returns(new Dictionary + { + { task.CipherId.Value, cipherPermissions } + }); + + var context = new AuthorizationHandlerContext( + new[] { SecurityTaskOperations.Read }, + new ClaimsPrincipal(), + task); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasSucceeded); + } + + [Theory, CurrentContextOrganizationCustomize(Type = OrganizationUserType.Admin), BitAutoData] + public async Task Read_Admin_MissingCipher_Failure( + CurrentContextOrganization organization, + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + var task = new SecurityTask + { + OrganizationId = organization.Id, + CipherId = Guid.NewGuid() + }; + + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetByOrganization(organization.Id).Returns(new Dictionary()); + + var context = new AuthorizationHandlerContext( + new[] { SecurityTaskOperations.Read }, + new ClaimsPrincipal(), + task); + + await sutProvider.Sut.HandleAsync(context); + + Assert.False(context.HasSucceeded); + } + + [Theory, CurrentContextOrganizationCustomize(Type = OrganizationUserType.User), BitAutoData] + public async Task Read_User_CannotReadCipher_Failure( + CurrentContextOrganization organization, + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + var task = new SecurityTask + { + OrganizationId = organization.Id, + CipherId = Guid.NewGuid() + }; + var cipherPermissions = new OrganizationCipherPermission + { + Id = task.CipherId.Value, + Read = false + }; + + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetByOrganization(organization.Id).Returns(new Dictionary + { + { task.CipherId.Value, cipherPermissions } + }); + + var context = new AuthorizationHandlerContext( + new[] { SecurityTaskOperations.Read }, + new ClaimsPrincipal(), + task); + + await sutProvider.Sut.HandleAsync(context); + + Assert.False(context.HasSucceeded); + } + + [Theory, CurrentContextOrganizationCustomize(Type = OrganizationUserType.User), BitAutoData] + public async Task Create_User_Failure( + CurrentContextOrganization organization, + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + var task = new SecurityTask + { + OrganizationId = organization.Id, + CipherId = Guid.NewGuid() + }; + var cipherPermissions = new OrganizationCipherPermission + { + Id = task.CipherId.Value, + Read = true, + Edit = true, + }; + + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetByOrganization(organization.Id).Returns(new Dictionary + { + { task.CipherId.Value, cipherPermissions } + }); + + var context = new AuthorizationHandlerContext( + new[] { SecurityTaskOperations.Create }, + new ClaimsPrincipal(), + task); + + await sutProvider.Sut.HandleAsync(context); + + Assert.False(context.HasSucceeded); + } + + [Theory, CurrentContextOrganizationCustomize(Type = OrganizationUserType.Admin), BitAutoData] + public async Task Create_Admin_MissingCipher_Failure( + CurrentContextOrganization organization, + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + var task = new SecurityTask + { + OrganizationId = organization.Id, + CipherId = Guid.NewGuid() + }; + + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetByOrganization(organization.Id).Returns(new Dictionary()); + + var context = new AuthorizationHandlerContext( + new[] { SecurityTaskOperations.Create }, + new ClaimsPrincipal(), + task); + + await sutProvider.Sut.HandleAsync(context); + + Assert.False(context.HasSucceeded); + } + + [Theory, CurrentContextOrganizationCustomize(Type = OrganizationUserType.Admin), BitAutoData] + public async Task Create_Admin_Success( + CurrentContextOrganization organization, + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + var task = new SecurityTask + { + OrganizationId = organization.Id, + CipherId = Guid.NewGuid() + }; + var cipherPermissions = new OrganizationCipherPermission + { + Id = task.CipherId.Value, + Read = true, + Edit = true, + }; + + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetByOrganization(organization.Id).Returns(new Dictionary + { + { task.CipherId.Value, cipherPermissions } + }); + + var context = new AuthorizationHandlerContext( + new[] { SecurityTaskOperations.Create }, + new ClaimsPrincipal(), + task); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasSucceeded); + } + + [Theory, CurrentContextOrganizationCustomize(Type = OrganizationUserType.User), BitAutoData] + public async Task Update_User_CanEditCipher_Success( + CurrentContextOrganization organization, + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + var task = new SecurityTask + { + OrganizationId = organization.Id, + CipherId = Guid.NewGuid() + }; + var cipherPermissions = new OrganizationCipherPermission + { + Id = task.CipherId.Value, + Read = true, + Edit = true + }; + + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetByOrganization(organization.Id).Returns(new Dictionary + { + { task.CipherId.Value, cipherPermissions } + }); + + var context = new AuthorizationHandlerContext( + new[] { SecurityTaskOperations.Update }, + new ClaimsPrincipal(), + task); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasSucceeded); + } + + [Theory, CurrentContextOrganizationCustomize(Type = OrganizationUserType.Admin), BitAutoData] + public async Task Update_Admin_CanEditCipher_Success( + CurrentContextOrganization organization, + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + var task = new SecurityTask + { + OrganizationId = organization.Id, + CipherId = Guid.NewGuid() + }; + var cipherPermissions = new OrganizationCipherPermission + { + Id = task.CipherId.Value, + Edit = true + }; + + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetByOrganization(organization.Id).Returns(new Dictionary + { + { task.CipherId.Value, cipherPermissions } + }); + + var context = new AuthorizationHandlerContext( + new[] { SecurityTaskOperations.Update }, + new ClaimsPrincipal(), + task); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasSucceeded); + } + + [Theory, CurrentContextOrganizationCustomize(Type = OrganizationUserType.Admin), BitAutoData] + public async Task Read_Admin_ReadonlyCipher_Failure( + CurrentContextOrganization organization, + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + var task = new SecurityTask + { + OrganizationId = organization.Id, + CipherId = Guid.NewGuid() + }; + + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetByOrganization(organization.Id).Returns(new Dictionary()); + + var context = new AuthorizationHandlerContext( + new[] { SecurityTaskOperations.Update }, + new ClaimsPrincipal(), + task); + + await sutProvider.Sut.HandleAsync(context); + + Assert.False(context.HasSucceeded); + } + + [Theory, CurrentContextOrganizationCustomize(Type = OrganizationUserType.User), BitAutoData] + public async Task Update_User_CannotEditCipher_Failure( + CurrentContextOrganization organization, + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + var task = new SecurityTask + { + OrganizationId = organization.Id, + CipherId = Guid.NewGuid() + }; + var cipherPermissions = new OrganizationCipherPermission + { + Id = task.CipherId.Value, + Read = true, + Edit = false + }; + + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetByOrganization(organization.Id).Returns(new Dictionary + { + { task.CipherId.Value, cipherPermissions } + }); + + var context = new AuthorizationHandlerContext( + new[] { SecurityTaskOperations.Update }, + new ClaimsPrincipal(), + task); + + await sutProvider.Sut.HandleAsync(context); + + Assert.False(context.HasSucceeded); + } +} diff --git a/test/Core.Test/Vault/Authorization/SecurityTaskOrganizationAuthorizationHandlerTests.cs b/test/Core.Test/Vault/Authorization/SecurityTaskOrganizationAuthorizationHandlerTests.cs new file mode 100644 index 0000000000..d0b2ecbcf0 --- /dev/null +++ b/test/Core.Test/Vault/Authorization/SecurityTaskOrganizationAuthorizationHandlerTests.cs @@ -0,0 +1,104 @@ +using System.Security.Claims; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Core.Vault.Authorization.SecurityTasks; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Authorization; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Vault.Authorization; + +[SutProviderCustomize] +public class SecurityTaskOrganizationAuthorizationHandlerTests +{ + [Theory, CurrentContextOrganizationCustomize, BitAutoData] + public async Task MissingOrg_Failure( + CurrentContextOrganization organization, + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns((CurrentContextOrganization)null); + + var context = new AuthorizationHandlerContext( + new[] { SecurityTaskOperations.ListAllForOrganization }, + new ClaimsPrincipal(), + organization); + + await sutProvider.Sut.HandleAsync(context); + + Assert.False(context.HasSucceeded); + } + + [Theory, CurrentContextOrganizationCustomize, BitAutoData] + public async Task MissingUserId_Failure( + CurrentContextOrganization organization, + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + + sutProvider.GetDependency().UserId.Returns(null as Guid?); + + var context = new AuthorizationHandlerContext( + new[] { SecurityTaskOperations.ListAllForOrganization }, + new ClaimsPrincipal(), + organization); + + await sutProvider.Sut.HandleAsync(context); + + Assert.False(context.HasSucceeded); + } + + [Theory, CurrentContextOrganizationCustomize] + [BitAutoData(OrganizationUserType.Owner)] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Custom)] + public async Task ListAllForOrganization_Admin_Success( + OrganizationUserType userType, + CurrentContextOrganization organization, + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + organization.Type = userType; + if (organization.Type == OrganizationUserType.Custom) + { + organization.Permissions.AccessReports = true; + } + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + + var context = new AuthorizationHandlerContext( + new[] { SecurityTaskOperations.ListAllForOrganization }, + new ClaimsPrincipal(), + organization); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasSucceeded); + } + + [Theory, CurrentContextOrganizationCustomize(Type = OrganizationUserType.User), BitAutoData] + public async Task ListAllForOrganization_User_Failure( + CurrentContextOrganization organization, + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + + var context = new AuthorizationHandlerContext( + new[] { SecurityTaskOperations.ListAllForOrganization }, + new ClaimsPrincipal(), + organization); + + await sutProvider.Sut.HandleAsync(context); + + Assert.False(context.HasSucceeded); + } + +} diff --git a/test/Core.Test/Vault/Queries/GetCipherPermissionsForUserQueryTests.cs b/test/Core.Test/Vault/Queries/GetCipherPermissionsForUserQueryTests.cs new file mode 100644 index 0000000000..0afac58925 --- /dev/null +++ b/test/Core.Test/Vault/Queries/GetCipherPermissionsForUserQueryTests.cs @@ -0,0 +1,238 @@ +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Services; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Queries; +using Bit.Core.Vault.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Vault.Queries; + +[SutProviderCustomize] +public class GetCipherPermissionsForUserQueryTests +{ + private static Guid _noAccessCipherId = Guid.NewGuid(); + private static Guid _readOnlyCipherId = Guid.NewGuid(); + private static Guid _editCipherId = Guid.NewGuid(); + private static Guid _manageCipherId = Guid.NewGuid(); + private static Guid _readExceptPasswordCipherId = Guid.NewGuid(); + private static Guid _unassignedCipherId = Guid.NewGuid(); + + private static List _cipherIds = new[] + { + _noAccessCipherId, + _readOnlyCipherId, + _editCipherId, + _manageCipherId, + _readExceptPasswordCipherId, + _unassignedCipherId + }.ToList(); + + + [Theory, BitAutoData] + public async Task GetCipherPermissionsForUserQuery_Base(Guid userId, CurrentContextOrganization org, SutProvider sutProvider + ) + { + var organizationId = org.Id; + org.Type = OrganizationUserType.User; + org.Permissions.EditAnyCollection = false; + var cipherPermissions = CreateCipherPermissions(); + + sutProvider.GetDependency().GetOrganization(organizationId).Returns(org); + sutProvider.GetDependency().UserId.Returns(userId); + + sutProvider.GetDependency().GetCipherPermissionsForOrganizationAsync(organizationId, userId) + .Returns(cipherPermissions); + sutProvider.GetDependency() + .GetManyUnassignedOrganizationDetailsByOrganizationIdAsync(organizationId) + .Returns(new List + { + new() { Id = _unassignedCipherId } + }); + + var result = await sutProvider.Sut.GetByOrganization(organizationId); + + Assert.Equal(6, result.Count); + Assert.All(result, x => Assert.Contains(x.Key, _cipherIds)); + Assert.False(result[_noAccessCipherId].Read); + Assert.True(result[_readOnlyCipherId].Read); + Assert.False(result[_readOnlyCipherId].Edit); + Assert.True(result[_editCipherId].Edit); + Assert.True(result[_manageCipherId].Manage); + Assert.True(result[_readExceptPasswordCipherId].Read); + Assert.False(result[_readExceptPasswordCipherId].ViewPassword); + Assert.False(result[_unassignedCipherId].Read); + } + + [Theory, BitAutoData] + public async Task GetCipherPermissionsForUserQuery_CanEditAllCiphers_CustomUser(Guid userId, CurrentContextOrganization org, SutProvider sutProvider + ) + { + var organizationId = org.Id; + var cipherPermissions = CreateCipherPermissions(); + org.Permissions.EditAnyCollection = true; + org.Type = OrganizationUserType.Custom; + + sutProvider.GetDependency().GetOrganization(organizationId).Returns(org); + sutProvider.GetDependency().UserId.Returns(userId); + + sutProvider.GetDependency().GetCipherPermissionsForOrganizationAsync(organizationId, userId) + .Returns(cipherPermissions); + sutProvider.GetDependency() + .GetManyUnassignedOrganizationDetailsByOrganizationIdAsync(organizationId) + .Returns(new List + { + new() { Id = _unassignedCipherId } + }); + + var result = await sutProvider.Sut.GetByOrganization(organizationId); + + Assert.Equal(6, result.Count); + Assert.All(result, x => Assert.Contains(x.Key, _cipherIds)); + Assert.All(result, x => Assert.True(x.Value.Read && x.Value.Edit && x.Value.Manage && x.Value.ViewPassword)); + } + + [Theory, BitAutoData] + public async Task GetCipherPermissionsForUserQuery_CanEditAllCiphers_Admin(Guid userId, CurrentContextOrganization org, SutProvider sutProvider + ) + { + var organizationId = org.Id; + var cipherPermissions = CreateCipherPermissions(); + org.Permissions.EditAnyCollection = false; + org.Type = OrganizationUserType.Admin; + + sutProvider.GetDependency().GetOrganization(organizationId).Returns(org); + sutProvider.GetDependency().UserId.Returns(userId); + + sutProvider.GetDependency().GetOrganizationAbilityAsync(org.Id).Returns(new OrganizationAbility + { + AllowAdminAccessToAllCollectionItems = true + }); + + sutProvider.GetDependency().GetCipherPermissionsForOrganizationAsync(organizationId, userId) + .Returns(cipherPermissions); + sutProvider.GetDependency() + .GetManyUnassignedOrganizationDetailsByOrganizationIdAsync(organizationId) + .Returns(new List + { + new() { Id = _unassignedCipherId } + }); + + var result = await sutProvider.Sut.GetByOrganization(organizationId); + + Assert.Equal(6, result.Count); + Assert.All(result, x => Assert.Contains(x.Key, _cipherIds)); + Assert.All(result, x => Assert.True(x.Value.Read && x.Value.Edit && x.Value.Manage && x.Value.ViewPassword)); + } + + [Theory, BitAutoData] + public async Task GetCipherPermissionsForUserQuery_CanEditUnassignedCiphers(Guid userId, CurrentContextOrganization org, SutProvider sutProvider + ) + { + var organizationId = org.Id; + var cipherPermissions = CreateCipherPermissions(); + org.Type = OrganizationUserType.Owner; + org.Permissions.EditAnyCollection = false; + + sutProvider.GetDependency().GetOrganization(organizationId).Returns(org); + sutProvider.GetDependency().UserId.Returns(userId); + + sutProvider.GetDependency().GetCipherPermissionsForOrganizationAsync(organizationId, userId) + .Returns(cipherPermissions); + sutProvider.GetDependency() + .GetManyUnassignedOrganizationDetailsByOrganizationIdAsync(organizationId) + .Returns(new List + { + new() { Id = _unassignedCipherId } + }); + + var result = await sutProvider.Sut.GetByOrganization(organizationId); + + Assert.Equal(6, result.Count); + Assert.All(result, x => Assert.Contains(x.Key, _cipherIds)); + Assert.False(result[_noAccessCipherId].Read); + Assert.True(result[_readOnlyCipherId].Read); + Assert.False(result[_readOnlyCipherId].Edit); + Assert.True(result[_editCipherId].Edit); + Assert.True(result[_manageCipherId].Manage); + Assert.True(result[_readExceptPasswordCipherId].Read); + Assert.False(result[_readExceptPasswordCipherId].ViewPassword); + + Assert.True(result[_unassignedCipherId].Read); + Assert.True(result[_unassignedCipherId].Edit); + Assert.True(result[_unassignedCipherId].ViewPassword); + Assert.True(result[_unassignedCipherId].Manage); + } + + private List CreateCipherPermissions() + { + // User has no relationship with the cipher + var noAccessCipher = new OrganizationCipherPermission + { + Id = _noAccessCipherId, + Read = false, + Edit = false, + Manage = false, + ViewPassword = false, + }; + + var readOnlyCipher = new OrganizationCipherPermission + { + Id = _readOnlyCipherId, + Read = true, + Edit = false, + Manage = false, + ViewPassword = true, + }; + + var editCipher = new OrganizationCipherPermission + { + Id = _editCipherId, + Read = true, + Edit = true, + Manage = false, + ViewPassword = true, + }; + + var manageCipher = new OrganizationCipherPermission + { + Id = _manageCipherId, + Read = true, + Edit = true, + Manage = true, + ViewPassword = true, + }; + + var readExceptPasswordCipher = new OrganizationCipherPermission + { + Id = _readExceptPasswordCipherId, + Read = true, + Edit = false, + Manage = false, + ViewPassword = false, + }; + + var unassignedCipher = new OrganizationCipherPermission + { + Id = _unassignedCipherId, + Read = false, + Edit = false, + Manage = false, + ViewPassword = false, + }; + + return new List + { + noAccessCipher, + readOnlyCipher, + editCipher, + manageCipher, + readExceptPasswordCipher, + unassignedCipher + }; + } +} diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs index ce9b5ef7ae..97f370bbcd 100644 --- a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; @@ -198,4 +199,238 @@ public class CipherRepositoryTests Assert.NotEqual(default, userProperty); Assert.Equal(folder.Id, userProperty.Value.GetGuid()); } + + [DatabaseTheory, DatabaseData] + public async Task GetCipherPermissionsForOrganizationAsync_Works( + ICipherRepository cipherRepository, + IUserRepository userRepository, + ICollectionCipherRepository collectionCipherRepository, + ICollectionRepository collectionRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IGroupRepository groupRepository + ) + { + + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Organization", + BillingEmail = user.Email, + Plan = "Test" + }); + + var orgUser = await organizationUserRepository.CreateAsync(new OrganizationUser + { + UserId = user.Id, + OrganizationId = organization.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Owner, + }); + + // A group that will be assigned Edit permissions to any collections + var editGroup = await groupRepository.CreateAsync(new Group + { + OrganizationId = organization.Id, + Name = "Edit Group", + }); + await groupRepository.UpdateUsersAsync(editGroup.Id, new[] { orgUser.Id }); + + // MANAGE + + var manageCollection = await collectionRepository.CreateAsync(new Collection + { + Name = "Manage Collection", + OrganizationId = organization.Id + }); + + var manageCipher = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + await collectionCipherRepository.UpdateCollectionsForAdminAsync(manageCipher.Id, organization.Id, + new List { manageCollection.Id }); + + await collectionRepository.UpdateUsersAsync(manageCollection.Id, new List + { + new() + { + Id = orgUser.Id, + HidePasswords = false, + ReadOnly = false, + Manage = true + } + }); + + // EDIT + + var editCollection = await collectionRepository.CreateAsync(new Collection + { + Name = "Edit Collection", + OrganizationId = organization.Id + }); + + var editCipher = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + await collectionCipherRepository.UpdateCollectionsForAdminAsync(editCipher.Id, organization.Id, + new List { editCollection.Id }); + + await collectionRepository.UpdateUsersAsync(editCollection.Id, + new List + { + new() { Id = orgUser.Id, HidePasswords = false, ReadOnly = false, Manage = false } + }); + + // EDIT EXCEPT PASSWORD + + var editExceptPasswordCollection = await collectionRepository.CreateAsync(new Collection + { + Name = "Edit Except Password Collection", + OrganizationId = organization.Id + }); + + var editExceptPasswordCipher = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + await collectionCipherRepository.UpdateCollectionsForAdminAsync(editExceptPasswordCipher.Id, organization.Id, + new List { editExceptPasswordCollection.Id }); + + await collectionRepository.UpdateUsersAsync(editExceptPasswordCollection.Id, new List + { + new() { Id = orgUser.Id, HidePasswords = true, ReadOnly = false, Manage = false } + }); + + // VIEW ONLY + + var viewOnlyCollection = await collectionRepository.CreateAsync(new Collection + { + Name = "View Only Collection", + OrganizationId = organization.Id + }); + + var viewOnlyCipher = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + await collectionCipherRepository.UpdateCollectionsForAdminAsync(viewOnlyCipher.Id, organization.Id, + new List { viewOnlyCollection.Id }); + + await collectionRepository.UpdateUsersAsync(viewOnlyCollection.Id, + new List + { + new() { Id = orgUser.Id, HidePasswords = false, ReadOnly = true, Manage = false } + }); + + // Assign the EditGroup to this View Only collection. The user belongs to this group. + // The user permissions specified above (ViewOnly) should take precedence. + await groupRepository.ReplaceAsync(editGroup, + new[] + { + new CollectionAccessSelection + { + Id = viewOnlyCollection.Id, HidePasswords = false, ReadOnly = false, Manage = false + }, + }); + + // VIEW EXCEPT PASSWORD + + var viewExceptPasswordCollection = await collectionRepository.CreateAsync(new Collection + { + Name = "View Except Password Collection", + OrganizationId = organization.Id + }); + + var viewExceptPasswordCipher = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + await collectionCipherRepository.UpdateCollectionsForAdminAsync(viewExceptPasswordCipher.Id, organization.Id, + new List { viewExceptPasswordCollection.Id }); + + await collectionRepository.UpdateUsersAsync(viewExceptPasswordCollection.Id, + new List + { + new() { Id = orgUser.Id, HidePasswords = true, ReadOnly = true, Manage = false } + }); + + // UNASSIGNED + + var unassignedCipher = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + var permissions = await cipherRepository.GetCipherPermissionsForOrganizationAsync(organization.Id, user.Id); + + Assert.NotEmpty(permissions); + + var manageCipherPermission = permissions.FirstOrDefault(c => c.Id == manageCipher.Id); + Assert.NotNull(manageCipherPermission); + Assert.True(manageCipherPermission.Manage); + Assert.True(manageCipherPermission.Edit); + Assert.True(manageCipherPermission.Read); + Assert.True(manageCipherPermission.ViewPassword); + + var editCipherPermission = permissions.FirstOrDefault(c => c.Id == editCipher.Id); + Assert.NotNull(editCipherPermission); + Assert.False(editCipherPermission.Manage); + Assert.True(editCipherPermission.Edit); + Assert.True(editCipherPermission.Read); + Assert.True(editCipherPermission.ViewPassword); + + var editExceptPasswordCipherPermission = permissions.FirstOrDefault(c => c.Id == editExceptPasswordCipher.Id); + Assert.NotNull(editExceptPasswordCipherPermission); + Assert.False(editExceptPasswordCipherPermission.Manage); + Assert.True(editExceptPasswordCipherPermission.Edit); + Assert.True(editExceptPasswordCipherPermission.Read); + Assert.False(editExceptPasswordCipherPermission.ViewPassword); + + var viewOnlyCipherPermission = permissions.FirstOrDefault(c => c.Id == viewOnlyCipher.Id); + Assert.NotNull(viewOnlyCipherPermission); + Assert.False(viewOnlyCipherPermission.Manage); + Assert.False(viewOnlyCipherPermission.Edit); + Assert.True(viewOnlyCipherPermission.Read); + Assert.True(viewOnlyCipherPermission.ViewPassword); + + var viewExceptPasswordCipherPermission = permissions.FirstOrDefault(c => c.Id == viewExceptPasswordCipher.Id); + Assert.NotNull(viewExceptPasswordCipherPermission); + Assert.False(viewExceptPasswordCipherPermission.Manage); + Assert.False(viewExceptPasswordCipherPermission.Edit); + Assert.True(viewExceptPasswordCipherPermission.Read); + Assert.False(viewExceptPasswordCipherPermission.ViewPassword); + + var unassignedCipherPermission = permissions.FirstOrDefault(c => c.Id == unassignedCipher.Id); + Assert.NotNull(unassignedCipherPermission); + Assert.False(unassignedCipherPermission.Manage); + Assert.False(unassignedCipherPermission.Edit); + Assert.False(unassignedCipherPermission.Read); + Assert.False(unassignedCipherPermission.ViewPassword); + } } diff --git a/util/Migrator/DbScripts/2025-01-08_00_CipherOrganizationPermissionsQuery.sql b/util/Migrator/DbScripts/2025-01-08_00_CipherOrganizationPermissionsQuery.sql new file mode 100644 index 0000000000..2da5f5c393 --- /dev/null +++ b/util/Migrator/DbScripts/2025-01-08_00_CipherOrganizationPermissionsQuery.sql @@ -0,0 +1,77 @@ +CREATE OR ALTER PROCEDURE [dbo].[CipherOrganizationPermissions_GetManyByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + ;WITH BaseCiphers AS ( + SELECT C.[Id], C.[OrganizationId] + FROM [dbo].[CipherDetails](@UserId) C + INNER JOIN [OrganizationUser] OU ON + C.[UserId] IS NULL + AND C.[OrganizationId] = @OrganizationId + AND OU.[UserId] = @UserId + INNER JOIN [dbo].[Organization] O ON + O.[Id] = OU.[OrganizationId] + AND O.[Id] = C.[OrganizationId] + AND O.[Enabled] = 1 + ), + UserPermissions AS ( + SELECT DISTINCT + CC.[CipherId], + CASE WHEN CC.[CollectionId] IS NULL THEN 0 ELSE 1 END as [Read], + CASE WHEN CU.[HidePasswords] = 0 THEN 1 ELSE 0 END as [ViewPassword], + CASE WHEN CU.[ReadOnly] = 0 THEN 1 ELSE 0 END as [Edit], + COALESCE(CU.[Manage], 0) as [Manage] + FROM [dbo].[CollectionCipher] CC + INNER JOIN [dbo].[CollectionUser] CU ON + CU.[CollectionId] = CC.[CollectionId] + AND CU.[OrganizationUserId] = ( + SELECT [Id] FROM [OrganizationUser] + WHERE [UserId] = @UserId + AND [OrganizationId] = @OrganizationId + ) + ), + GroupPermissions AS ( + SELECT DISTINCT + CC.[CipherId], + CASE WHEN CC.[CollectionId] IS NULL THEN 0 ELSE 1 END as [Read], + CASE WHEN CG.[HidePasswords] = 0 THEN 1 ELSE 0 END as [ViewPassword], + CASE WHEN CG.[ReadOnly] = 0 THEN 1 ELSE 0 END as [Edit], + COALESCE(CG.[Manage], 0) as [Manage] + FROM [dbo].[CollectionCipher] CC + INNER JOIN [dbo].[CollectionGroup] CG ON + CG.[CollectionId] = CC.[CollectionId] + INNER JOIN [dbo].[GroupUser] GU ON + GU.[GroupId] = CG.[GroupId] + AND GU.[OrganizationUserId] = ( + SELECT [Id] FROM [OrganizationUser] + WHERE [UserId] = @UserId + AND [OrganizationId] = @OrganizationId + ) + WHERE NOT EXISTS ( + SELECT 1 + FROM UserPermissions UP + WHERE UP.[CipherId] = CC.[CipherId] + ) + ), + CombinedPermissions AS ( + SELECT CipherId, [Read], ViewPassword, Edit, Manage + FROM UserPermissions + UNION ALL + SELECT CipherId, [Read], ViewPassword, Edit, Manage + FROM GroupPermissions + ) + SELECT + C.[Id], + C.[OrganizationId], + ISNULL(MAX(P.[Read]), 0) as [Read], + ISNULL(MAX(P.[ViewPassword]), 0) as [ViewPassword], + ISNULL(MAX(P.[Edit]), 0) as [Edit], + ISNULL(MAX(P.[Manage]), 0) as [Manage] + FROM BaseCiphers C + LEFT JOIN CombinedPermissions P ON P.CipherId = C.[Id] + GROUP BY C.[Id], C.[OrganizationId] +END +GO