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 # Namespace settings
csharp_style_namespace_declarations = file_scoped:warning 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 # All files
[*] [*]
guidelines = 120 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.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.SecretManagerFeatures.Projects.Interfaces; using Bit.Core.SecretManagerFeatures.Projects.Interfaces;
@ -8,36 +10,65 @@ namespace Bit.Commercial.Core.SecretManagerFeatures.Projects;
public class DeleteProjectCommand : IDeleteProjectCommand public class DeleteProjectCommand : IDeleteProjectCommand
{ {
private readonly IProjectRepository _projectRepository; private readonly IProjectRepository _projectRepository;
private readonly ICurrentContext _currentContext;
public DeleteProjectCommand(IProjectRepository projectRepository) public DeleteProjectCommand(IProjectRepository projectRepository, ICurrentContext currentContext)
{ {
_projectRepository = projectRepository; _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(); 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); throw new UnauthorizedAccessException();
if (project == null) }
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 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; 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.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.SecretManagerFeatures.Projects.Interfaces; using Bit.Core.SecretManagerFeatures.Projects.Interfaces;
@ -8,23 +10,38 @@ namespace Bit.Commercial.Core.SecretManagerFeatures.Projects;
public class UpdateProjectCommand : IUpdateProjectCommand public class UpdateProjectCommand : IUpdateProjectCommand
{ {
private readonly IProjectRepository _projectRepository; private readonly IProjectRepository _projectRepository;
private readonly ICurrentContext _currentContext;
public UpdateProjectCommand(IProjectRepository projectRepository) public UpdateProjectCommand(IProjectRepository projectRepository, ICurrentContext currentContext)
{ {
_projectRepository = projectRepository; _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); var project = await _projectRepository.GetByIdAsync(updatedProject.Id);
if (existingProject == null) if (project == null)
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
project.OrganizationId = existingProject.OrganizationId; var orgAdmin = await _currentContext.OrganizationAdmin(project.OrganizationId);
project.CreationDate = existingProject.CreationDate; var accessClient = AccessClientHelper.ToAccessClient(_currentContext.ClientType, orgAdmin);
project.DeletedDate = existingProject.DeletedDate;
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; project.RevisionDate = DateTime.UtcNow;
await _projectRepository.ReplaceAsync(project); await _projectRepository.ReplaceAsync(project);

View File

@ -1,5 +1,6 @@
using System.Linq.Expressions; using System.Linq.Expressions;
using AutoMapper; using AutoMapper;
using Bit.Core.Enums;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Infrastructure.EntityFramework.Models; using Bit.Infrastructure.EntityFramework.Models;
using Bit.Infrastructure.EntityFramework.Repositories; 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(); using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope); var dbContext = GetDatabaseContext(scope);
var project = await dbContext.Project var query = dbContext.Project.Where(p => p.OrganizationId == organizationId && p.DeletedDate == null);
.Where(p => p.OrganizationId == organizationId && p.DeletedDate == null)
// TODO: Enable this + Handle Admins query = accessType switch
//.Where(UserHasAccessToProject(userId)) {
.OrderBy(p => p.RevisionDate) AccessClientType.NoAccessCheck => query,
.ToListAsync(); AccessClientType.User => query.Where(UserHasReadAccessToProject(userId)),
return Mapper.Map<List<Core.Entities.Project>>(project); 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.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)); 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) public async Task DeleteManyByIdAsync(IEnumerable<Guid> ids)
{ {
using (var scope = ServiceScopeFactory.CreateScope()) using (var scope = ServiceScopeFactory.CreateScope())
{ {
var dbContext = GetDatabaseContext(scope); var dbContext = GetDatabaseContext(scope);
var utcNow = DateTime.UtcNow;
var projects = dbContext.Project.Where(c => ids.Contains(c.Id)); var projects = dbContext.Project.Where(c => ids.Contains(c.Id));
await projects.ForEachAsync(project => await projects.ForEachAsync(project =>
{ {
@ -68,6 +80,27 @@ public class ProjectRepository : Repository<Core.Entities.Project, Project, Guid
.ToListAsync(); .ToListAsync();
return Mapper.Map<List<Core.Entities.Project>>(projects); 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.Commercial.Core.SecretManagerFeatures.Projects;
using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Identity;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
@ -14,19 +16,19 @@ public class DeleteProjectCommandTests
{ {
[Theory] [Theory]
[BitAutoData] [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<DeleteProjectCommand> sutProvider)
{ {
sutProvider.GetDependency<IProjectRepository>().GetManyByIds(data).Returns(new List<Project>()); 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); await sutProvider.GetDependency<IProjectRepository>().DidNotReceiveWithAnyArgs().DeleteManyByIdAsync(default);
} }
[Theory] [Theory]
[BitAutoData] [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) SutProvider<DeleteProjectCommand> sutProvider)
{ {
var project = new Project() var project = new Project()
@ -35,35 +37,69 @@ public class DeleteProjectCommandTests
}; };
sutProvider.GetDependency<IProjectRepository>().GetManyByIds(data).Returns(new List<Project>() { project }); 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); await sutProvider.GetDependency<IProjectRepository>().DidNotReceiveWithAnyArgs().DeleteManyByIdAsync(default);
} }
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task DeleteSecrets_Success(List<Guid> data, public async Task DeleteSecrets_User_Success(List<Guid> data, Guid userId, Guid organizationId,
SutProvider<DeleteProjectCommand> sutProvider) SutProvider<DeleteProjectCommand> sutProvider)
{ {
var projects = new List<Project>(); var projects = data.Select(id => new Project { Id = id, OrganizationId = organizationId }).ToList();
foreach (Guid id in data)
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() Assert.Equal("", result.Item2);
{
Id = id
};
projects.Add(project);
} }
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); 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) foreach (var result in results)
{ {
Assert.Equal("", result.Item2); 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.Request;
using Bit.Api.SecretManagerFeatures.Models.Response; using Bit.Api.SecretManagerFeatures.Models.Response;
using Bit.Api.Utilities; using Bit.Api.Utilities;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.SecretManagerFeatures.Projects.Interfaces; using Bit.Core.SecretManagerFeatures.Projects.Interfaces;
@ -18,24 +20,32 @@ public class ProjectsController : Controller
private readonly ICreateProjectCommand _createProjectCommand; private readonly ICreateProjectCommand _createProjectCommand;
private readonly IUpdateProjectCommand _updateProjectCommand; private readonly IUpdateProjectCommand _updateProjectCommand;
private readonly IDeleteProjectCommand _deleteProjectCommand; private readonly IDeleteProjectCommand _deleteProjectCommand;
private readonly ICurrentContext _currentContext;
public ProjectsController( public ProjectsController(
IUserService userService, IUserService userService,
IProjectRepository projectRepository, IProjectRepository projectRepository,
ICreateProjectCommand createProjectCommand, ICreateProjectCommand createProjectCommand,
IUpdateProjectCommand updateProjectCommand, IUpdateProjectCommand updateProjectCommand,
IDeleteProjectCommand deleteProjectCommand) IDeleteProjectCommand deleteProjectCommand,
ICurrentContext currentContext)
{ {
_userService = userService; _userService = userService;
_projectRepository = projectRepository; _projectRepository = projectRepository;
_createProjectCommand = createProjectCommand; _createProjectCommand = createProjectCommand;
_updateProjectCommand = updateProjectCommand; _updateProjectCommand = updateProjectCommand;
_deleteProjectCommand = deleteProjectCommand; _deleteProjectCommand = deleteProjectCommand;
_currentContext = currentContext;
} }
[HttpPost("organizations/{organizationId}/projects")] [HttpPost("organizations/{organizationId}/projects")]
public async Task<ProjectResponseModel> CreateAsync([FromRoute] Guid organizationId, [FromBody] ProjectCreateRequestModel createRequest) 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)); var result = await _createProjectCommand.CreateAsync(createRequest.ToProject(organizationId));
return new ProjectResponseModel(result); return new ProjectResponseModel(result);
} }
@ -43,15 +53,22 @@ public class ProjectsController : Controller
[HttpPut("projects/{id}")] [HttpPut("projects/{id}")]
public async Task<ProjectResponseModel> UpdateProjectAsync([FromRoute] Guid id, [FromBody] ProjectUpdateRequestModel updateRequest) 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); return new ProjectResponseModel(result);
} }
[HttpGet("organizations/{organizationId}/projects")] [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 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)); var responses = projects.Select(project => new ProjectResponseModel(project));
return new ListResponseModel<ProjectResponseModel>(responses); return new ListResponseModel<ProjectResponseModel>(responses);
} }
@ -64,13 +81,32 @@ public class ProjectsController : Controller
{ {
throw new NotFoundException(); 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); return new ProjectResponseModel(project);
} }
[HttpPost("projects/delete")] [HttpPost("projects/delete")]
public async Task<ListResponseModel<BulkDeleteResponseModel>> BulkDeleteProjectsAsync([FromBody] List<Guid> ids) 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)); var responses = results.Select(r => new BulkDeleteResponseModel(r.Item1.Id, r.Item2));
return new ListResponseModel<BulkDeleteResponseModel>(responses); return new ListResponseModel<BulkDeleteResponseModel>(responses);
} }

View File

@ -34,6 +34,7 @@ public class CurrentContext : ICurrentContext
public virtual int? BotScore { get; set; } public virtual int? BotScore { get; set; }
public virtual string ClientId { get; set; } public virtual string ClientId { get; set; }
public virtual Version ClientVersion { get; set; } public virtual Version ClientVersion { get; set; }
public virtual ClientType ClientType { get; set; }
public CurrentContext(IProviderUserRepository providerUserRepository) 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); DeviceIdentifier = GetClaimValue(claimsDict, Claims.Device);
Organizations = GetOrganizations(claimsDict, orgApi); Organizations = GetOrganizations(claimsDict, orgApi);

View File

@ -1,6 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Identity;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Settings; using Bit.Core.Settings;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@ -18,6 +19,7 @@ public interface ICurrentContext
List<CurrentContentOrganization> Organizations { get; set; } List<CurrentContentOrganization> Organizations { get; set; }
Guid? InstallationId { get; set; } Guid? InstallationId { get; set; }
Guid? OrganizationId { get; set; } Guid? OrganizationId { get; set; }
ClientType ClientType { get; set; }
bool IsBot { get; set; } bool IsBot { get; set; }
bool MaybeBot { get; set; } bool MaybeBot { get; set; }
int? BotScore { 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 // Service Account
public const string Organization = "organization"; 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.Entities;
using Bit.Core.Enums;
namespace Bit.Core.Repositories; namespace Bit.Core.Repositories;
public interface IProjectRepository 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<IEnumerable<Project>> GetManyByIds(IEnumerable<Guid> ids);
Task<Project> GetByIdAsync(Guid id); Task<Project> GetByIdAsync(Guid id);
Task<Project> CreateAsync(Project project); Task<Project> CreateAsync(Project project);
Task ReplaceAsync(Project project); Task ReplaceAsync(Project project);
Task DeleteManyByIdAsync(IEnumerable<Guid> ids); 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 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 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> Claims = new List<ClientClaim>
{ {
new(JwtClaimTypes.Subject, apiKey.ServiceAccountId.ToString()), 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.Subject, user.Id.ToString()),
new(JwtClaimTypes.AuthenticationMethod, "Application", "external"), new(JwtClaimTypes.AuthenticationMethod, "Application", "external"),
new(Claims.Type, ClientType.User.ToString()),
}; };
var orgs = await _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id); var orgs = await _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id);
var providers = await _currentContext.ProviderMembershipAsync(_providerUserRepository, user.Id); var providers = await _currentContext.ProviderMembershipAsync(_providerUserRepository, user.Id);
@ -198,6 +200,7 @@ public class ClientStore : IClientStore
Claims = new List<ClientClaim> Claims = new List<ClientClaim>
{ {
new(JwtClaimTypes.Subject, org.Id.ToString()), 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.Factories;
using Bit.Api.IntegrationTest.Helpers; using Bit.Api.IntegrationTest.Helpers;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Api.SecretManagerFeatures.Models.Request; using Bit.Api.SecretManagerFeatures.Models.Request;
using Bit.Api.SecretManagerFeatures.Models.Response; using Bit.Api.SecretManagerFeatures.Models.Response;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Test.Common.Helpers; using Bit.Test.Common.Helpers;
using Xunit; using Xunit;
@ -31,10 +33,19 @@ public class ProjectsControllerTest : IClassFixture<ApiApplicationFactory>, IAsy
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
var ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; var ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
var tokens = await _factory.LoginWithNewAccount(ownerEmail); await _factory.LoginWithNewAccount(ownerEmail);
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, ownerEmail: ownerEmail, billingEmail: 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); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token);
_organization = organization;
} }
public Task DisposeAsync() public Task DisposeAsync()
@ -44,12 +55,9 @@ public class ProjectsControllerTest : IClassFixture<ApiApplicationFactory>, IAsy
} }
[Fact] [Fact]
public async Task CreateProject() public async Task CreateProject_Success()
{ {
var request = new ProjectCreateRequestModel() var request = new ProjectCreateRequestModel { Name = _mockEncryptedString };
{
Name = _mockEncryptedString
};
var response = await _client.PostAsJsonAsync($"/organizations/{_organization.Id}/projects", request); var response = await _client.PostAsJsonAsync($"/organizations/{_organization.Id}/projects", request);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
@ -69,7 +77,17 @@ public class ProjectsControllerTest : IClassFixture<ApiApplicationFactory>, IAsy
} }
[Fact] [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 var initialProject = await _projectRepository.CreateAsync(new Project
{ {
@ -101,6 +119,42 @@ public class ProjectsControllerTest : IClassFixture<ApiApplicationFactory>, IAsy
Assert.NotEqual(initialProject.RevisionDate, updatedProject.RevisionDate); 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] [Fact]
public async Task GetProject() public async Task GetProject()
{ {

View File

@ -45,4 +45,12 @@ public class ApiApplicationFactory : WebApplicationFactoryBase<Startup>
return await _identityApplicationFactory.TokenFromPasswordAsync(email, masterPasswordHash); 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 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, PlanType plan = PlanType.Free,
string ownerEmail = "integration-test@bitwarden.com", string ownerEmail = "integration-test@bitwarden.com",
string name = "Integration Test Org", string name = "Integration Test Org",
@ -30,4 +31,32 @@ public static class OrganizationTestHelpers
Owner = owner, 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.Api.Controllers;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.SecretManagerFeatures.Projects.Interfaces; using Bit.Core.SecretManagerFeatures.Projects.Interfaces;
using Bit.Core.Services;
using Bit.Core.Test.AutoFixture.ProjectsFixture; using Bit.Core.Test.AutoFixture.ProjectsFixture;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
@ -19,17 +20,18 @@ public class ProjectsControllerTests
[BitAutoData] [BitAutoData]
public async void BulkDeleteProjects_Success(SutProvider<ProjectsController> sutProvider, List<Project> data) 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>>(); var mockResult = new List<Tuple<Project, string>>();
foreach (var project in data) foreach (var project in data)
{ {
mockResult.Add(new Tuple<Project, string>(project, "")); 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); var results = await sutProvider.Sut.BulkDeleteProjectsAsync(ids);
await sutProvider.GetDependency<IDeleteProjectCommand>().Received(1) await sutProvider.GetDependency<IDeleteProjectCommand>().Received(1)
.DeleteProjects(Arg.Is(ids)); .DeleteProjects(Arg.Is(ids), Arg.Any<Guid>());
Assert.Equal(data.Count, results.Data.Count()); Assert.Equal(data.Count, results.Data.Count());
} }
@ -37,6 +39,7 @@ public class ProjectsControllerTests
[BitAutoData] [BitAutoData]
public async void BulkDeleteProjects_NoGuids_ThrowsArgumentNullException(SutProvider<ProjectsController> sutProvider) 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>())); await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.BulkDeleteProjectsAsync(new List<Guid>()));
} }
} }