1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-21 12:05:42 +01:00

[SM-380] Access checks for listing projects (#2496)

* Add project access checks for listing
This commit is contained in:
Oscar Hinton 2023-01-20 16:33:11 +01:00 committed by GitHub
parent a7c2ff9dbf
commit 5cd571df64
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 452 additions and 69 deletions

View File

@ -117,6 +117,10 @@ csharp_new_line_before_members_in_anonymous_types = true
# Namespace settings
csharp_style_namespace_declarations = file_scoped:warning
# Switch expression
dotnet_diagnostic.CS8509.severity = error # missing switch case for named enum value
dotnet_diagnostic.CS8524.severity = none # missing switch case for unnamed enum value
# All files
[*]
guidelines = 120

View File

@ -1,4 +1,6 @@
using Bit.Core.Entities;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.SecretManagerFeatures.Projects.Interfaces;
@ -8,36 +10,65 @@ namespace Bit.Commercial.Core.SecretManagerFeatures.Projects;
public class DeleteProjectCommand : IDeleteProjectCommand
{
private readonly IProjectRepository _projectRepository;
private readonly ICurrentContext _currentContext;
public DeleteProjectCommand(IProjectRepository projectRepository)
public DeleteProjectCommand(IProjectRepository projectRepository, ICurrentContext currentContext)
{
_projectRepository = projectRepository;
_currentContext = currentContext;
}
public async Task<List<Tuple<Project, string>>> DeleteProjects(List<Guid> ids)
public async Task<List<Tuple<Project, string>>> DeleteProjects(List<Guid> ids, Guid userId)
{
var projects = await _projectRepository.GetManyByIds(ids);
if (ids.Any() != true || userId == new Guid())
{
throw new ArgumentNullException();
}
if (projects?.Any() != true)
var projects = (await _projectRepository.GetManyByIds(ids))?.ToList();
if (projects?.Any() != true || projects.Count != ids.Count)
{
throw new NotFoundException();
}
var results = ids.Select(id =>
// Ensure all projects belongs to the same organization
var organizationId = projects.First().OrganizationId;
if (projects.Any(p => p.OrganizationId != organizationId))
{
var project = projects.FirstOrDefault(project => project.Id == id);
if (project == null)
throw new UnauthorizedAccessException();
}
var orgAdmin = await _currentContext.OrganizationAdmin(organizationId);
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.ClientType, orgAdmin);
var results = new List<Tuple<Project, String>>(projects.Count);
var deleteIds = new List<Guid>();
foreach (var project in projects)
{
var hasAccess = accessClient switch
{
throw new NotFoundException();
AccessClientType.NoAccessCheck => true,
AccessClientType.User => await _projectRepository.UserHasWriteAccessToProject(project.Id, userId),
_ => false,
};
if (!hasAccess)
{
results.Add(new Tuple<Project, string>(project, "access denied"));
}
// TODO Once permissions are implemented add check for each project here.
else
{
return new Tuple<Project, string>(project, "");
results.Add(new Tuple<Project, string>(project, ""));
deleteIds.Add(project.Id);
}
}).ToList();
}
await _projectRepository.DeleteManyByIdAsync(ids);
if (deleteIds.Count > 0)
{
await _projectRepository.DeleteManyByIdAsync(deleteIds);
}
return results;
}
}

View File

@ -1,4 +1,6 @@
using Bit.Core.Entities;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.SecretManagerFeatures.Projects.Interfaces;
@ -8,23 +10,38 @@ namespace Bit.Commercial.Core.SecretManagerFeatures.Projects;
public class UpdateProjectCommand : IUpdateProjectCommand
{
private readonly IProjectRepository _projectRepository;
private readonly ICurrentContext _currentContext;
public UpdateProjectCommand(IProjectRepository projectRepository)
public UpdateProjectCommand(IProjectRepository projectRepository, ICurrentContext currentContext)
{
_projectRepository = projectRepository;
_currentContext = currentContext;
}
public async Task<Project> UpdateAsync(Project project)
public async Task<Project> UpdateAsync(Project updatedProject, Guid userId)
{
var existingProject = await _projectRepository.GetByIdAsync(project.Id);
if (existingProject == null)
var project = await _projectRepository.GetByIdAsync(updatedProject.Id);
if (project == null)
{
throw new NotFoundException();
}
project.OrganizationId = existingProject.OrganizationId;
project.CreationDate = existingProject.CreationDate;
project.DeletedDate = existingProject.DeletedDate;
var orgAdmin = await _currentContext.OrganizationAdmin(project.OrganizationId);
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.ClientType, orgAdmin);
var hasAccess = accessClient switch
{
AccessClientType.NoAccessCheck => true,
AccessClientType.User => await _projectRepository.UserHasWriteAccessToProject(updatedProject.Id, userId),
_ => false,
};
if (!hasAccess)
{
throw new UnauthorizedAccessException();
}
project.Name = updatedProject.Name;
project.RevisionDate = DateTime.UtcNow;
await _projectRepository.ReplaceAsync(project);

View File

@ -1,5 +1,6 @@
using System.Linq.Expressions;
using AutoMapper;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Infrastructure.EntityFramework.Models;
using Bit.Infrastructure.EntityFramework.Repositories;
@ -26,29 +27,40 @@ public class ProjectRepository : Repository<Core.Entities.Project, Project, Guid
}
}
public async Task<IEnumerable<Core.Entities.Project>> GetManyByOrganizationIdAsync(Guid organizationId, Guid userId)
public async Task<IEnumerable<Core.Entities.Project>> GetManyByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var project = await dbContext.Project
.Where(p => p.OrganizationId == organizationId && p.DeletedDate == null)
// TODO: Enable this + Handle Admins
//.Where(UserHasAccessToProject(userId))
.OrderBy(p => p.RevisionDate)
.ToListAsync();
return Mapper.Map<List<Core.Entities.Project>>(project);
var query = dbContext.Project.Where(p => p.OrganizationId == organizationId && p.DeletedDate == null);
query = accessType switch
{
AccessClientType.NoAccessCheck => query,
AccessClientType.User => query.Where(UserHasReadAccessToProject(userId)),
AccessClientType.ServiceAccount => query.Where(ServiceAccountHasReadAccessToProject(userId)),
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
};
var projects = await query.OrderBy(p => p.RevisionDate).ToListAsync();
return Mapper.Map<List<Core.Entities.Project>>(projects);
}
private static Expression<Func<Project, bool>> UserHasAccessToProject(Guid userId) => p =>
private static Expression<Func<Project, bool>> UserHasReadAccessToProject(Guid userId) => p =>
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) ||
p.GroupAccessPolicies.Any(ap => ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Read));
private static Expression<Func<Project, bool>> UserHasWriteAccessToProject(Guid userId) => p =>
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) ||
p.GroupAccessPolicies.Any(ap => ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write));
private static Expression<Func<Project, bool>> ServiceAccountHasReadAccessToProject(Guid serviceAccountId) => p =>
p.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccount.Id == serviceAccountId && ap.Read);
public async Task DeleteManyByIdAsync(IEnumerable<Guid> ids)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var utcNow = DateTime.UtcNow;
var projects = dbContext.Project.Where(c => ids.Contains(c.Id));
await projects.ForEachAsync(project =>
{
@ -68,6 +80,27 @@ public class ProjectRepository : Repository<Core.Entities.Project, Project, Guid
.ToListAsync();
return Mapper.Map<List<Core.Entities.Project>>(projects);
}
}
public async Task<bool> UserHasReadAccessToProject(Guid id, Guid userId)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var query = dbContext.Project
.Where(p => p.Id == id)
.Where(UserHasReadAccessToProject(userId));
return await query.AnyAsync();
}
public async Task<bool> UserHasWriteAccessToProject(Guid id, Guid userId)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var query = dbContext.Project
.Where(p => p.Id == id)
.Where(UserHasWriteAccessToProject(userId));
return await query.AnyAsync();
}
}

View File

@ -1,6 +1,8 @@
using Bit.Commercial.Core.SecretManagerFeatures.Projects;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Identity;
using Bit.Core.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@ -14,19 +16,19 @@ public class DeleteProjectCommandTests
{
[Theory]
[BitAutoData]
public async Task DeleteProjects_Throws_NotFoundException(List<Guid> data,
public async Task DeleteProjects_Throws_NotFoundException(List<Guid> data, Guid userId,
SutProvider<DeleteProjectCommand> sutProvider)
{
sutProvider.GetDependency<IProjectRepository>().GetManyByIds(data).Returns(new List<Project>());
var exception = await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.DeleteProjects(data));
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.DeleteProjects(data, userId));
await sutProvider.GetDependency<IProjectRepository>().DidNotReceiveWithAnyArgs().DeleteManyByIdAsync(default);
}
[Theory]
[BitAutoData]
public async Task DeleteSecrets_OneIdNotFound_Throws_NotFoundException(List<Guid> data,
public async Task DeleteSecrets_OneIdNotFound_Throws_NotFoundException(List<Guid> data, Guid userId,
SutProvider<DeleteProjectCommand> sutProvider)
{
var project = new Project()
@ -35,35 +37,69 @@ public class DeleteProjectCommandTests
};
sutProvider.GetDependency<IProjectRepository>().GetManyByIds(data).Returns(new List<Project>() { project });
var exception = await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.DeleteProjects(data));
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.DeleteProjects(data, userId));
await sutProvider.GetDependency<IProjectRepository>().DidNotReceiveWithAnyArgs().DeleteManyByIdAsync(default);
}
[Theory]
[BitAutoData]
public async Task DeleteSecrets_Success(List<Guid> data,
SutProvider<DeleteProjectCommand> sutProvider)
public async Task DeleteSecrets_User_Success(List<Guid> data, Guid userId, Guid organizationId,
SutProvider<DeleteProjectCommand> sutProvider)
{
var projects = new List<Project>();
foreach (Guid id in data)
var projects = data.Select(id => new Project { Id = id, OrganizationId = organizationId }).ToList();
sutProvider.GetDependency<ICurrentContext>().ClientType = ClientType.User;
sutProvider.GetDependency<IProjectRepository>().GetManyByIds(data).Returns(projects);
sutProvider.GetDependency<IProjectRepository>().UserHasWriteAccessToProject(Arg.Any<Guid>(), userId).Returns(true);
var results = await sutProvider.Sut.DeleteProjects(data, userId);
foreach (var result in results)
{
var project = new Project()
{
Id = id
};
projects.Add(project);
Assert.Equal("", result.Item2);
}
await sutProvider.GetDependency<IProjectRepository>().Received(1).DeleteManyByIdAsync(Arg.Is<List<Guid>>(d => d.SequenceEqual(data)));
}
[Theory]
[BitAutoData]
public async Task DeleteSecrets_User_No_Permission(List<Guid> data, Guid userId, Guid organizationId,
SutProvider<DeleteProjectCommand> sutProvider)
{
var projects = data.Select(id => new Project { Id = id, OrganizationId = organizationId }).ToList();
sutProvider.GetDependency<ICurrentContext>().ClientType = ClientType.User;
sutProvider.GetDependency<IProjectRepository>().GetManyByIds(data).Returns(projects);
sutProvider.GetDependency<IProjectRepository>().UserHasWriteAccessToProject(userId, userId).Returns(false);
var results = await sutProvider.Sut.DeleteProjects(data, userId);
foreach (var result in results)
{
Assert.Equal("access denied", result.Item2);
}
await sutProvider.GetDependency<IProjectRepository>().DidNotReceiveWithAnyArgs().DeleteManyByIdAsync(default);
}
[Theory]
[BitAutoData]
public async Task DeleteSecrets_OrganizationAdmin_Success(List<Guid> data, Guid userId, Guid organizationId,
SutProvider<DeleteProjectCommand> sutProvider)
{
var projects = data.Select(id => new Project { Id = id, OrganizationId = organizationId }).ToList();
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(organizationId).Returns(true);
sutProvider.GetDependency<IProjectRepository>().GetManyByIds(data).Returns(projects);
var results = await sutProvider.Sut.DeleteProjects(data);
var results = await sutProvider.Sut.DeleteProjects(data, userId);
await sutProvider.GetDependency<IProjectRepository>().Received(1).DeleteManyByIdAsync(Arg.Is(data));
await sutProvider.GetDependency<IProjectRepository>().Received(1).DeleteManyByIdAsync(Arg.Is<List<Guid>>(d => d.SequenceEqual(data)));
foreach (var result in results)
{
Assert.Equal("", result.Item2);
}
}
}

View File

@ -0,0 +1,75 @@
using Bit.Commercial.Core.SecretManagerFeatures.Projects;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Test.AutoFixture.ProjectsFixture;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Xunit;
namespace Bit.Commercial.Core.Test.SecretManagerFeatures.Projects;
[SutProviderCustomize]
[ProjectCustomize]
public class UpdateProjectCommandTests
{
[Theory]
[BitAutoData]
public async Task UpdateAsync_Throws_NotFoundException(Project project, Guid userId, SutProvider<UpdateProjectCommand> sutProvider)
{
sutProvider.GetDependency<IProjectRepository>().GetByIdAsync(project.Id).ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.UpdateAsync(project, userId));
await sutProvider.GetDependency<IProjectRepository>().DidNotReceiveWithAnyArgs().ReplaceAsync(default);
}
[Theory]
[BitAutoData]
public async Task UpdateAsync_Admin_Succeeds(Project project, Guid userId, SutProvider<UpdateProjectCommand> sutProvider)
{
sutProvider.GetDependency<IProjectRepository>().GetByIdAsync(project.Id).Returns(project);
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(project.OrganizationId).Returns(true);
var project2 = new Project { Id = project.Id, Name = "newName" };
var result = await sutProvider.Sut.UpdateAsync(project2, userId);
Assert.NotNull(result);
Assert.Equal("newName", result.Name);
AssertHelper.AssertRecent(result.RevisionDate);
await sutProvider.GetDependency<IProjectRepository>().ReceivedWithAnyArgs(1).ReplaceAsync(default);
}
[Theory]
[BitAutoData]
public async Task UpdateAsync_User_NoAccess(Project project, Guid userId, SutProvider<UpdateProjectCommand> sutProvider)
{
sutProvider.GetDependency<IProjectRepository>().GetByIdAsync(project.Id).Returns(project);
sutProvider.GetDependency<IProjectRepository>().UserHasWriteAccessToProject(project.Id, userId).Returns(false);
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => sutProvider.Sut.UpdateAsync(project, userId));
await sutProvider.GetDependency<IProjectRepository>().DidNotReceiveWithAnyArgs().ReplaceAsync(default);
}
[Theory]
[BitAutoData]
public async Task UpdateAsync_User_Success(Project project, Guid userId, SutProvider<UpdateProjectCommand> sutProvider)
{
sutProvider.GetDependency<IProjectRepository>().GetByIdAsync(project.Id).Returns(project);
sutProvider.GetDependency<IProjectRepository>().UserHasWriteAccessToProject(project.Id, userId).Returns(true);
var project2 = new Project { Id = project.Id, Name = "newName" };
var result = await sutProvider.Sut.UpdateAsync(project2, userId);
Assert.NotNull(result);
Assert.Equal("newName", result.Name);
await sutProvider.GetDependency<IProjectRepository>().ReceivedWithAnyArgs(1).ReplaceAsync(default);
}
}

View File

@ -2,6 +2,8 @@
using Bit.Api.SecretManagerFeatures.Models.Request;
using Bit.Api.SecretManagerFeatures.Models.Response;
using Bit.Api.Utilities;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.SecretManagerFeatures.Projects.Interfaces;
@ -18,24 +20,32 @@ public class ProjectsController : Controller
private readonly ICreateProjectCommand _createProjectCommand;
private readonly IUpdateProjectCommand _updateProjectCommand;
private readonly IDeleteProjectCommand _deleteProjectCommand;
private readonly ICurrentContext _currentContext;
public ProjectsController(
IUserService userService,
IProjectRepository projectRepository,
ICreateProjectCommand createProjectCommand,
IUpdateProjectCommand updateProjectCommand,
IDeleteProjectCommand deleteProjectCommand)
IDeleteProjectCommand deleteProjectCommand,
ICurrentContext currentContext)
{
_userService = userService;
_projectRepository = projectRepository;
_createProjectCommand = createProjectCommand;
_updateProjectCommand = updateProjectCommand;
_deleteProjectCommand = deleteProjectCommand;
_currentContext = currentContext;
}
[HttpPost("organizations/{organizationId}/projects")]
public async Task<ProjectResponseModel> CreateAsync([FromRoute] Guid organizationId, [FromBody] ProjectCreateRequestModel createRequest)
{
if (!await _currentContext.OrganizationUser(organizationId))
{
throw new NotFoundException();
}
var result = await _createProjectCommand.CreateAsync(createRequest.ToProject(organizationId));
return new ProjectResponseModel(result);
}
@ -43,15 +53,22 @@ public class ProjectsController : Controller
[HttpPut("projects/{id}")]
public async Task<ProjectResponseModel> UpdateProjectAsync([FromRoute] Guid id, [FromBody] ProjectUpdateRequestModel updateRequest)
{
var result = await _updateProjectCommand.UpdateAsync(updateRequest.ToProject(id));
var userId = _userService.GetProperUserId(User).Value;
var result = await _updateProjectCommand.UpdateAsync(updateRequest.ToProject(id), userId);
return new ProjectResponseModel(result);
}
[HttpGet("organizations/{organizationId}/projects")]
public async Task<ListResponseModel<ProjectResponseModel>> GetProjectsByOrganizationAsync([FromRoute] Guid organizationId)
public async Task<ListResponseModel<ProjectResponseModel>> GetProjectsByOrganizationAsync(
[FromRoute] Guid organizationId)
{
var userId = _userService.GetProperUserId(User).Value;
var projects = await _projectRepository.GetManyByOrganizationIdAsync(organizationId, userId);
var orgAdmin = await _currentContext.OrganizationAdmin(organizationId);
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.ClientType, orgAdmin);
var projects = await _projectRepository.GetManyByOrganizationIdAsync(organizationId, userId, accessClient);
var responses = projects.Select(project => new ProjectResponseModel(project));
return new ListResponseModel<ProjectResponseModel>(responses);
}
@ -64,13 +81,32 @@ public class ProjectsController : Controller
{
throw new NotFoundException();
}
var userId = _userService.GetProperUserId(User).Value;
var orgAdmin = await _currentContext.OrganizationAdmin(project.OrganizationId);
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.ClientType, orgAdmin);
var hasAccess = accessClient switch
{
AccessClientType.NoAccessCheck => true,
AccessClientType.User => await _projectRepository.UserHasReadAccessToProject(id, userId),
_ => false,
};
if (!hasAccess)
{
throw new NotFoundException();
}
return new ProjectResponseModel(project);
}
[HttpPost("projects/delete")]
public async Task<ListResponseModel<BulkDeleteResponseModel>> BulkDeleteProjectsAsync([FromBody] List<Guid> ids)
{
var results = await _deleteProjectCommand.DeleteProjects(ids);
var userId = _userService.GetProperUserId(User).Value;
var results = await _deleteProjectCommand.DeleteProjects(ids, userId);
var responses = results.Select(r => new BulkDeleteResponseModel(r.Item1.Id, r.Item2));
return new ListResponseModel<BulkDeleteResponseModel>(responses);
}

View File

@ -34,6 +34,7 @@ public class CurrentContext : ICurrentContext
public virtual int? BotScore { get; set; }
public virtual string ClientId { get; set; }
public virtual Version ClientVersion { get; set; }
public virtual ClientType ClientType { get; set; }
public CurrentContext(IProviderUserRepository providerUserRepository)
{
@ -138,6 +139,13 @@ public class CurrentContext : ICurrentContext
}
}
var clientType = GetClaimValue(claimsDict, Claims.Type);
if (clientType != null)
{
Enum.TryParse(clientType, out ClientType c);
ClientType = c;
}
DeviceIdentifier = GetClaimValue(claimsDict, Claims.Device);
Organizations = GetOrganizations(claimsDict, orgApi);

View File

@ -1,6 +1,7 @@
using System.Security.Claims;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Identity;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Microsoft.AspNetCore.Http;
@ -18,6 +19,7 @@ public interface ICurrentContext
List<CurrentContentOrganization> Organizations { get; set; }
Guid? InstallationId { get; set; }
Guid? OrganizationId { get; set; }
ClientType ClientType { get; set; }
bool IsBot { get; set; }
bool MaybeBot { get; set; }
int? BotScore { get; set; }

View File

@ -0,0 +1,30 @@
using Bit.Core.Identity;
namespace Bit.Core.Enums;
public enum AccessClientType
{
NoAccessCheck = -1,
User = 0,
Organization = 1,
ServiceAccount = 2,
}
public static class AccessClientHelper
{
public static AccessClientType ToAccessClient(ClientType clientType, bool bypassAccessCheck = false)
{
if (bypassAccessCheck)
{
return AccessClientType.NoAccessCheck;
}
return clientType switch
{
ClientType.User => AccessClientType.User,
ClientType.Organization => AccessClientType.Organization,
ClientType.ServiceAccount => AccessClientType.ServiceAccount,
_ => throw new ArgumentOutOfRangeException(nameof(clientType), clientType, null),
};
}
}

View File

@ -16,4 +16,7 @@ public static class Claims
// Service Account
public const string Organization = "organization";
// General
public const string Type = "type";
}

View File

@ -0,0 +1,8 @@
namespace Bit.Core.Identity;
public enum ClientType : byte
{
User = 0,
Organization = 1,
ServiceAccount = 2,
}

View File

@ -1,13 +1,16 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
namespace Bit.Core.Repositories;
public interface IProjectRepository
{
Task<IEnumerable<Project>> GetManyByOrganizationIdAsync(Guid organizationId, Guid userId);
Task<IEnumerable<Project>> GetManyByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType);
Task<IEnumerable<Project>> GetManyByIds(IEnumerable<Guid> ids);
Task<Project> GetByIdAsync(Guid id);
Task<Project> CreateAsync(Project project);
Task ReplaceAsync(Project project);
Task DeleteManyByIdAsync(IEnumerable<Guid> ids);
Task<bool> UserHasReadAccessToProject(Guid id, Guid userId);
Task<bool> UserHasWriteAccessToProject(Guid id, Guid userId);
}

View File

@ -4,6 +4,6 @@ namespace Bit.Core.SecretManagerFeatures.Projects.Interfaces;
public interface IDeleteProjectCommand
{
Task<List<Tuple<Project, string>>> DeleteProjects(List<Guid> ids);
Task<List<Tuple<Project, string>>> DeleteProjects(List<Guid> ids, Guid userId);
}

View File

@ -4,5 +4,5 @@ namespace Bit.Core.SecretManagerFeatures.Projects.Interfaces;
public interface IUpdateProjectCommand
{
Task<Project> UpdateAsync(Project project);
Task<Project> UpdateAsync(Project updatedProject, Guid userId);
}

View File

@ -110,6 +110,7 @@ public class ClientStore : IClientStore
Claims = new List<ClientClaim>
{
new(JwtClaimTypes.Subject, apiKey.ServiceAccountId.ToString()),
new(Claims.Type, ClientType.ServiceAccount.ToString()),
},
};
@ -141,6 +142,7 @@ public class ClientStore : IClientStore
{
new(JwtClaimTypes.Subject, user.Id.ToString()),
new(JwtClaimTypes.AuthenticationMethod, "Application", "external"),
new(Claims.Type, ClientType.User.ToString()),
};
var orgs = await _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id);
var providers = await _currentContext.ProviderMembershipAsync(_providerUserRepository, user.Id);
@ -198,6 +200,7 @@ public class ClientStore : IClientStore
Claims = new List<ClientClaim>
{
new(JwtClaimTypes.Subject, org.Id.ToString()),
new(Claims.Type, ClientType.Organization.ToString()),
},
};
}

View File

@ -1,10 +1,12 @@
using System.Net.Http.Headers;
using System.Net;
using System.Net.Http.Headers;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Api.Models.Response;
using Bit.Api.SecretManagerFeatures.Models.Request;
using Bit.Api.SecretManagerFeatures.Models.Response;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Test.Common.Helpers;
using Xunit;
@ -31,10 +33,19 @@ public class ProjectsControllerTest : IClassFixture<ApiApplicationFactory>, IAsy
public async Task InitializeAsync()
{
var ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
var tokens = await _factory.LoginWithNewAccount(ownerEmail);
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, ownerEmail: ownerEmail, billingEmail: ownerEmail);
await _factory.LoginWithNewAccount(ownerEmail);
(_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, ownerEmail: ownerEmail, billingEmail: ownerEmail);
var tokens = await _factory.LoginAsync(ownerEmail);
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token);
}
public async Task LoginAsNewOrgUser(OrganizationUserType type = OrganizationUserType.User)
{
var email = $"integration-test{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(email);
await OrganizationTestHelpers.CreateUserAsync(_factory, _organization.Id, email, type);
var tokens = await _factory.LoginAsync(email);
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token);
_organization = organization;
}
public Task DisposeAsync()
@ -44,12 +55,9 @@ public class ProjectsControllerTest : IClassFixture<ApiApplicationFactory>, IAsy
}
[Fact]
public async Task CreateProject()
public async Task CreateProject_Success()
{
var request = new ProjectCreateRequestModel()
{
Name = _mockEncryptedString
};
var request = new ProjectCreateRequestModel { Name = _mockEncryptedString };
var response = await _client.PostAsJsonAsync($"/organizations/{_organization.Id}/projects", request);
response.EnsureSuccessStatusCode();
@ -69,7 +77,17 @@ public class ProjectsControllerTest : IClassFixture<ApiApplicationFactory>, IAsy
}
[Fact]
public async Task UpdateProject()
public async Task CreateProject_NoPermission()
{
var request = new ProjectCreateRequestModel { Name = _mockEncryptedString };
var response = await _client.PostAsJsonAsync("/organizations/911d9106-7cf1-4d55-a3f9-f9abdeadecb3/projects", request);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task UpdateProject_Success()
{
var initialProject = await _projectRepository.CreateAsync(new Project
{
@ -101,6 +119,42 @@ public class ProjectsControllerTest : IClassFixture<ApiApplicationFactory>, IAsy
Assert.NotEqual(initialProject.RevisionDate, updatedProject.RevisionDate);
}
[Fact]
public async Task UpdateProject_NotFound()
{
var request = new ProjectUpdateRequestModel()
{
Name = "2.3Uk+WNBIoU5xzmVFNcoWzz==|1MsPIYuRfdOHfu/0uY6H2Q==|/98xy4wb6pHP1VTZ9JcNCYgQjEUMFPlqJgCwRk1YXKg=",
};
var response = await _client.PutAsJsonAsync("/projects/c53de509-4581-402c-8cbd-f26d2c516fba", request);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task UpdateProject_MissingPermission()
{
// Create a new account as a user
await LoginAsNewOrgUser();
var project = await _projectRepository.CreateAsync(new Project
{
OrganizationId = _organization.Id,
Name = _mockEncryptedString
});
var request = new ProjectUpdateRequestModel()
{
Name = "2.3Uk+WNBIoU5xzmVFNcoWzz==|1MsPIYuRfdOHfu/0uY6H2Q==|/98xy4wb6pHP1VTZ9JcNCYgQjEUMFPlqJgCwRk1YXKg=",
};
var response = await _client.PutAsJsonAsync($"/projects/{project.Id}", request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task GetProject()
{

View File

@ -45,4 +45,12 @@ public class ApiApplicationFactory : WebApplicationFactoryBase<Startup>
return await _identityApplicationFactory.TokenFromPasswordAsync(email, masterPasswordHash);
}
/// <summary>
/// Helper for logging in to an account
/// </summary>
public async Task<(string Token, string RefreshToken)> LoginAsync(string email = "integration-test@bitwarden.com", string masterPasswordHash = "master_password_hash")
{
return await _identityApplicationFactory.TokenFromPasswordAsync(email, masterPasswordHash);
}
}

View File

@ -9,7 +9,8 @@ namespace Bit.Api.IntegrationTest.Helpers;
public static class OrganizationTestHelpers
{
public static async Task<Tuple<Organization, OrganizationUser>> SignUpAsync<T>(WebApplicationFactoryBase<T> factory,
public static async Task<Tuple<Organization, OrganizationUser>> SignUpAsync<T>(
WebApplicationFactoryBase<T> factory,
PlanType plan = PlanType.Free,
string ownerEmail = "integration-test@bitwarden.com",
string name = "Integration Test Org",
@ -30,4 +31,32 @@ public static class OrganizationTestHelpers
Owner = owner,
});
}
public static async Task<OrganizationUser> CreateUserAsync<T>(
WebApplicationFactoryBase<T> factory,
Guid organizationId,
string userEmail,
OrganizationUserType type
) where T : class
{
var userRepository = factory.GetService<IUserRepository>();
var organizationUserRepository = factory.GetService<IOrganizationUserRepository>();
var user = await userRepository.GetByEmailAsync(userEmail);
var orgUser = new OrganizationUser
{
OrganizationId = organizationId,
UserId = user.Id,
Key = null,
Type = type,
Status = OrganizationUserStatusType.Invited,
AccessAll = false,
ExternalId = null,
};
await organizationUserRepository.CreateAsync(orgUser);
return orgUser;
}
}

View File

@ -1,6 +1,7 @@
using Bit.Api.Controllers;
using Bit.Core.Entities;
using Bit.Core.SecretManagerFeatures.Projects.Interfaces;
using Bit.Core.Services;
using Bit.Core.Test.AutoFixture.ProjectsFixture;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@ -19,17 +20,18 @@ public class ProjectsControllerTests
[BitAutoData]
public async void BulkDeleteProjects_Success(SutProvider<ProjectsController> sutProvider, List<Project> data)
{
var ids = data.Select(project => project.Id).ToList();
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(Guid.NewGuid());
var ids = data.Select(project => project.Id)?.ToList();
var mockResult = new List<Tuple<Project, string>>();
foreach (var project in data)
{
mockResult.Add(new Tuple<Project, string>(project, ""));
}
sutProvider.GetDependency<IDeleteProjectCommand>().DeleteProjects(ids).ReturnsForAnyArgs(mockResult);
sutProvider.GetDependency<IDeleteProjectCommand>().DeleteProjects(ids, default).ReturnsForAnyArgs(mockResult);
var results = await sutProvider.Sut.BulkDeleteProjectsAsync(ids);
await sutProvider.GetDependency<IDeleteProjectCommand>().Received(1)
.DeleteProjects(Arg.Is(ids));
.DeleteProjects(Arg.Is(ids), Arg.Any<Guid>());
Assert.Equal(data.Count, results.Data.Count());
}
@ -37,6 +39,7 @@ public class ProjectsControllerTests
[BitAutoData]
public async void BulkDeleteProjects_NoGuids_ThrowsArgumentNullException(SutProvider<ProjectsController> sutProvider)
{
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(Guid.NewGuid());
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.BulkDeleteProjectsAsync(new List<Guid>()));
}
}