diff --git a/src/Core/Vault/Authorization/SecurityTasks/SecurityTaskAuthorizationHandler.cs b/src/Core/Vault/Authorization/SecurityTasks/SecurityTaskAuthorizationHandler.cs new file mode 100644 index 000000000..64c21dd89 --- /dev/null +++ b/src/Core/Vault/Authorization/SecurityTasks/SecurityTaskAuthorizationHandler.cs @@ -0,0 +1,134 @@ +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 authorized = requirement switch + { + not null when requirement == SecurityTaskOperations.Read => await CanReadAsync(task), + not null when requirement == SecurityTaskOperations.Create => await CanCreateAsync(task), + not null when requirement == SecurityTaskOperations.Update => await CanUpdateAsync(task), + _ => throw new ArgumentOutOfRangeException(nameof(requirement)) + }; + + if (authorized) + { + context.Succeed(requirement); + } + } + + private async Task CanReadAsync(SecurityTask task) + { + var org = _currentContext.GetOrganization(task.OrganizationId); + + if (org == null) + { + // The user does not belong to the organization + return false; + } + + if (task.CipherId.HasValue) + { + // Ensure the user has edit access to the cipher + return await CanEditCipherForOrgAsync(org, task.CipherId.Value); + } + + return true; + } + + private async Task CanCreateAsync(SecurityTask task) + { + var org = _currentContext.GetOrganization(task.OrganizationId); + + // User must be an Admin/Owner or have custom permissions for reporting + if (org is + not ({ Type: OrganizationUserType.Admin or OrganizationUserType.Owner } or + { Permissions.EditAnyCollection: true } or + { Permissions.AccessReports: true })) + { + return false; + } + + if (task.CipherId.HasValue) + { + return await CipherBelongsToOrgAsync(org, task.CipherId.Value); + } + + return true; + } + + private async Task CanUpdateAsync(SecurityTask task) + { + var org = _currentContext.GetOrganization(task.OrganizationId); + + if (org == null) + { + // The user does not belong to the organization + return false; + } + + if (task.CipherId.HasValue) + { + // Ensure the user has edit access to the cipher (required for updating a task) + return await CanEditCipherForOrgAsync(org, task.CipherId.Value); + } + + return true; + } + + 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 CipherBelongsToOrgAsync(CurrentContextOrganization org, Guid cipherId) + { + var ciphers = await GetCipherPermissionsForOrgAsync(org); + + return ciphers.ContainsKey(cipherId); + } + + 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 000000000..eb32b84dc --- /dev/null +++ b/src/Core/Vault/Authorization/SecurityTasks/SecurityTaskOperationRequirement.cs @@ -0,0 +1,20 @@ +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)); + + 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 000000000..120b2064a --- /dev/null +++ b/src/Core/Vault/Authorization/SecurityTasks/SecurityTaskOrganizationAuthorizationHandler.cs @@ -0,0 +1,11 @@ +using Bit.Core.Context; +using Microsoft.AspNetCore.Authorization; + +namespace Bit.Core.Vault.Authorization.SecurityTasks; + +public class SecurityTaskOrganizationAuthorizationHandler : AuthorizationHandler +{ + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, SecurityTaskOperationRequirement requirement, + CurrentContextOrganization resource) => + throw new NotImplementedException(); +} diff --git a/src/Core/Vault/Models/Data/OrganizationCipherPermission.cs b/src/Core/Vault/Models/Data/OrganizationCipherPermission.cs new file mode 100644 index 000000000..e519341bd --- /dev/null +++ b/src/Core/Vault/Models/Data/OrganizationCipherPermission.cs @@ -0,0 +1,16 @@ +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 +{ + public Guid Id { get; set; } + public Guid Organization { get; set; } + public bool Edit { get; set; } + public bool ViewPassword { get; set; } + public bool Manage { get; set; } + public bool Unassigned { get; set; } +} diff --git a/src/Core/Vault/Queries/GetCipherPermissionsForUserQuery.cs b/src/Core/Vault/Queries/GetCipherPermissionsForUserQuery.cs new file mode 100644 index 000000000..6a313bb8f --- /dev/null +++ b/src/Core/Vault/Queries/GetCipherPermissionsForUserQuery.cs @@ -0,0 +1,102 @@ +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; + private readonly IFeatureService _featureService; + + public GetCipherPermissionsForUserQuery(ICurrentContext currentContext, ICipherRepository cipherRepository, IApplicationCacheService applicationCacheService, IFeatureService featureService) + { + _currentContext = currentContext; + _cipherRepository = cipherRepository; + _applicationCacheService = applicationCacheService; + _featureService = featureService; + } + + public async Task> GetByOrganization(Guid organizationId) + { + var org = _currentContext.GetOrganization(organizationId); + + if (org == null) + { + throw new NotFoundException(); + } + + var cipherPermissions = (await _cipherRepository.GetCipherPermissionsForOrganizationAsync(organizationId)).ToList(); + + if (await CanEditAllCiphersAsync(org)) + { + foreach (var cipher in cipherPermissions) + { + cipher.Edit = true; + cipher.Manage = true; + cipher.ViewPassword = true; + } + } + + if (await CanAccessUnassignedCiphersAsync(org)) + { + foreach (var unassignedCipher in cipherPermissions.Where(c => c.Unassigned)) + { + unassignedCipher.Edit = true; + unassignedCipher.Manage = true; + unassignedCipher.ViewPassword = true; + } + } + + return cipherPermissions.ToDictionary(c => c.Id); + } + + 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; + } + + // Provider users can edit all ciphers if RestrictProviderAccess is disabled + if (await _currentContext.ProviderUserForOrgAsync(org.Id)) + { + return !_featureService.IsEnabled(FeatureFlagKeys.RestrictProviderAccess); + } + + return false; + } + + private async Task CanAccessUnassignedCiphersAsync(CurrentContextOrganization org) + { + if (org is + { Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or + { Permissions.EditAnyCollection: true }) + { + return true; + } + + // Provider users can only access all ciphers if RestrictProviderAccess is disabled + if (await _currentContext.ProviderUserForOrgAsync(org.Id)) + { + return !_featureService.IsEnabled(FeatureFlagKeys.RestrictProviderAccess); + } + + return false; + } +} diff --git a/src/Core/Vault/Queries/IGetCipherPermissionsForUserQuery.cs b/src/Core/Vault/Queries/IGetCipherPermissionsForUserQuery.cs new file mode 100644 index 000000000..3ab40f26f --- /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 132aa5ac6..01d756bca 100644 --- a/src/Core/Vault/Repositories/ICipherRepository.cs +++ b/src/Core/Vault/Repositories/ICipherRepository.cs @@ -38,6 +38,7 @@ public interface ICipherRepository : IRepository Task RestoreAsync(IEnumerable ids, Guid userId); Task RestoreByIdsOrganizationIdAsync(IEnumerable ids, Guid organizationId); Task DeleteDeletedAsync(DateTime deletedDateBefore); + Task> GetCipherPermissionsForOrganizationAsync(Guid organizationId); /// /// Updates encrypted data for ciphers during a key rotation diff --git a/src/Sql/Vault/dbo/Stored Procedures/Cipher/CipherOrganizationPermissions_GetByOrganizationId.sql b/src/Sql/Vault/dbo/Stored Procedures/Cipher/CipherOrganizationPermissions_GetByOrganizationId.sql new file mode 100644 index 000000000..354065e5c --- /dev/null +++ b/src/Sql/Vault/dbo/Stored Procedures/Cipher/CipherOrganizationPermissions_GetByOrganizationId.sql @@ -0,0 +1,56 @@ +CREATE PROCEDURE [dbo].[CipherOrganizationPermissions_GetByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + C.[Id], + MAX(CASE + WHEN COALESCE(CU.[ReadOnly], CG.[ReadOnly], 1) = 0 + THEN 1 + ELSE 0 + END) [Edit], + MAX(CASE + WHEN COALESCE(CU.[HidePasswords], CG.[HidePasswords], 1) = 0 + THEN 1 + ELSE 0 + END) [ViewPassword], + MAX(COALESCE(CU.[Manage], CG.[Manage], 0)) [Manage], + CASE + WHEN COUNT(CC.[CollectionId]) > 0 THEN 0 + ELSE 1 + END [Unassigned] + FROM + [dbo].[CipherDetails](@UserId) C + INNER JOIN + [OrganizationUser] OU ON + C.[UserId] IS NULL + AND C.[OrganizationId] = @OrganizationId + INNER JOIN + [dbo].[Organization] O ON + O.[Id] = OU.[OrganizationId] + AND O.[Id] = C.[OrganizationId] + AND O.[Enabled] = 1 + LEFT JOIN + [dbo].[CollectionCipher] CC ON + CC.[CipherId] = C.[Id] + LEFT JOIN + [dbo].[CollectionUser] CU ON + CU.[CollectionId] = CC.[CollectionId] + AND CU.[OrganizationUserId] = OU.[Id] + LEFT JOIN + [dbo].[GroupUser] GU ON + CU.[CollectionId] IS NULL + AND GU.[OrganizationUserId] = OU.[Id] + LEFT JOIN + [dbo].[Group] G ON + G.[Id] = GU.[GroupId] + LEFT JOIN + [dbo].[CollectionGroup] CG ON + CG.[CollectionId] = CC.[CollectionId] + AND CG.[GroupId] = GU.[GroupId] + GROUP BY + C.[Id] +END