mirror of
https://github.com/bitwarden/server.git
synced 2026-04-10 09:41:16 -05:00
GetMasterPasswordPolicy incorrectly used the MemberRequirement authz requirement. However, this endpoint needs to support users who are in the invited state, and that only authorizes users who are confirmed. Recreate previous logic inside a new attribute.
586 lines
20 KiB
C#
586 lines
20 KiB
C#
using System.Net;
|
|
using System.Text.Json;
|
|
using Bit.Api.AdminConsole.Models.Request;
|
|
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
|
using Bit.Api.IntegrationTest.Factories;
|
|
using Bit.Api.IntegrationTest.Helpers;
|
|
using Bit.Core.AdminConsole.Entities;
|
|
using Bit.Core.AdminConsole.Enums;
|
|
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
|
using Bit.Core.AdminConsole.Repositories;
|
|
using Bit.Core.Billing.Enums;
|
|
using Bit.Core.Entities;
|
|
using Bit.Core.Enums;
|
|
using Bit.Core.Repositories;
|
|
using Bit.Test.Common.Helpers;
|
|
using NSubstitute;
|
|
using Xunit;
|
|
|
|
namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;
|
|
|
|
public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAsyncLifetime
|
|
{
|
|
private readonly HttpClient _client;
|
|
private readonly ApiApplicationFactory _factory;
|
|
private readonly LoginHelper _loginHelper;
|
|
|
|
private Organization _organization = null!;
|
|
private string _ownerEmail = null!;
|
|
|
|
public PoliciesControllerTests(ApiApplicationFactory factory)
|
|
{
|
|
_factory = factory;
|
|
_factory.SubstituteService<Core.Services.IFeatureService>(featureService =>
|
|
{
|
|
featureService
|
|
.IsEnabled("pm-19467-create-default-location")
|
|
.Returns(true);
|
|
});
|
|
_client = factory.CreateClient();
|
|
_loginHelper = new LoginHelper(_factory, _client);
|
|
}
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
_ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
|
|
await _factory.LoginWithNewAccount(_ownerEmail);
|
|
|
|
(_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually,
|
|
ownerEmail: _ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card);
|
|
|
|
await _loginHelper.LoginAsync(_ownerEmail);
|
|
}
|
|
|
|
public Task DisposeAsync()
|
|
{
|
|
_client.Dispose();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PutVNext_OrganizationDataOwnershipPolicy_Success()
|
|
{
|
|
// Arrange
|
|
const PolicyType policyType = PolicyType.OrganizationDataOwnership;
|
|
|
|
const string defaultCollectionName = "Test Default Collection";
|
|
var request = new SavePolicyRequest
|
|
{
|
|
Policy = new PolicyRequestModel
|
|
{
|
|
Enabled = true,
|
|
},
|
|
Metadata = new Dictionary<string, object>
|
|
{
|
|
{ "defaultUserCollectionName", defaultCollectionName }
|
|
}
|
|
};
|
|
|
|
var (_, admin) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory,
|
|
_organization.Id, OrganizationUserType.Admin);
|
|
|
|
var (_, user) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory,
|
|
_organization.Id, OrganizationUserType.User);
|
|
|
|
// Act
|
|
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext",
|
|
JsonContent.Create(request));
|
|
|
|
// Assert
|
|
await AssertResponse();
|
|
|
|
await AssertPolicy();
|
|
|
|
await AssertDefaultCollectionCreatedOnlyForUserTypeAsync();
|
|
return;
|
|
|
|
async Task AssertDefaultCollectionCreatedOnlyForUserTypeAsync()
|
|
{
|
|
var collectionRepository = _factory.GetService<ICollectionRepository>();
|
|
await AssertUserExpectations(collectionRepository);
|
|
await AssertAdminExpectations(collectionRepository);
|
|
}
|
|
|
|
async Task AssertUserExpectations(ICollectionRepository collectionRepository)
|
|
{
|
|
var collections = await collectionRepository.GetManyByUserIdAsync(user.UserId!.Value);
|
|
var defaultCollection = collections.FirstOrDefault(c => c.Name == defaultCollectionName);
|
|
Assert.NotNull(defaultCollection);
|
|
Assert.Equal(_organization.Id, defaultCollection.OrganizationId);
|
|
}
|
|
|
|
async Task AssertAdminExpectations(ICollectionRepository collectionRepository)
|
|
{
|
|
var collections = await collectionRepository.GetManyByUserIdAsync(admin.UserId!.Value);
|
|
var defaultCollection = collections.FirstOrDefault(c => c.Name == defaultCollectionName);
|
|
Assert.Null(defaultCollection);
|
|
}
|
|
|
|
async Task AssertResponse()
|
|
{
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
var content = await response.Content.ReadFromJsonAsync<PolicyResponseModel>();
|
|
|
|
Assert.True(content.Enabled);
|
|
Assert.Equal(policyType, content.Type);
|
|
Assert.Equal(_organization.Id, content.OrganizationId);
|
|
}
|
|
|
|
async Task AssertPolicy()
|
|
{
|
|
var policyRepository = _factory.GetService<IPolicyRepository>();
|
|
var policy = await policyRepository.GetByOrganizationIdTypeAsync(_organization.Id, policyType);
|
|
|
|
Assert.NotNull(policy);
|
|
Assert.True(policy.Enabled);
|
|
Assert.Equal(policyType, policy.Type);
|
|
Assert.Null(policy.Data);
|
|
Assert.Equal(_organization.Id, policy.OrganizationId);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PutVNext_MasterPasswordPolicy_Success()
|
|
{
|
|
// Arrange
|
|
var policyType = PolicyType.MasterPassword;
|
|
var request = new SavePolicyRequest
|
|
{
|
|
Policy = new PolicyRequestModel
|
|
{
|
|
Enabled = true,
|
|
Data = new Dictionary<string, object>
|
|
{
|
|
{ "minComplexity", 4 },
|
|
{ "minLength", 128 },
|
|
{ "requireUpper", true },
|
|
{ "requireLower", false },
|
|
{ "requireNumbers", true },
|
|
{ "requireSpecial", false },
|
|
{ "enforceOnLogin", true }
|
|
}
|
|
}
|
|
};
|
|
|
|
// Act
|
|
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext",
|
|
JsonContent.Create(request));
|
|
|
|
// Assert
|
|
await AssertResponse();
|
|
|
|
await AssertPolicyDataForMasterPasswordPolicy();
|
|
return;
|
|
|
|
async Task AssertPolicyDataForMasterPasswordPolicy()
|
|
{
|
|
var policyRepository = _factory.GetService<IPolicyRepository>();
|
|
var policy = await policyRepository.GetByOrganizationIdTypeAsync(_organization.Id, policyType);
|
|
|
|
AssertPolicy(policy);
|
|
AssertMasterPasswordPolicyData(policy);
|
|
}
|
|
|
|
async Task AssertResponse()
|
|
{
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
var content = await response.Content.ReadFromJsonAsync<PolicyResponseModel>();
|
|
|
|
Assert.True(content.Enabled);
|
|
Assert.Equal(policyType, content.Type);
|
|
Assert.Equal(_organization.Id, content.OrganizationId);
|
|
}
|
|
|
|
void AssertPolicy(Policy policy)
|
|
{
|
|
Assert.NotNull(policy);
|
|
Assert.True(policy.Enabled);
|
|
Assert.Equal(policyType, policy.Type);
|
|
Assert.Equal(_organization.Id, policy.OrganizationId);
|
|
Assert.NotNull(policy.Data);
|
|
}
|
|
|
|
void AssertMasterPasswordPolicyData(Policy policy)
|
|
{
|
|
var resultData = policy.GetDataModel<MasterPasswordPolicyData>();
|
|
|
|
var json = JsonSerializer.Serialize(request.Policy.Data);
|
|
var expectedData = JsonSerializer.Deserialize<MasterPasswordPolicyData>(json);
|
|
AssertHelper.AssertPropertyEqual(resultData, expectedData);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Put_MasterPasswordPolicy_InvalidDataType_ReturnsBadRequest()
|
|
{
|
|
// Arrange
|
|
var policyType = PolicyType.MasterPassword;
|
|
var request = new PolicyRequestModel
|
|
{
|
|
Enabled = true,
|
|
Data = new Dictionary<string, object>
|
|
{
|
|
{ "minLength", "not a number" }, // Wrong type - should be int
|
|
{ "requireUpper", true }
|
|
}
|
|
};
|
|
|
|
// Act
|
|
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}",
|
|
JsonContent.Create(request));
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
var content = await response.Content.ReadAsStringAsync();
|
|
Assert.Contains("minLength", content); // Verify field name is in error message
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Put_SendOptionsPolicy_InvalidDataType_ReturnsBadRequest()
|
|
{
|
|
// Arrange
|
|
var policyType = PolicyType.SendOptions;
|
|
var request = new PolicyRequestModel
|
|
{
|
|
Enabled = true,
|
|
Data = new Dictionary<string, object>
|
|
{
|
|
{ "disableHideEmail", "not a boolean" } // Wrong type - should be bool
|
|
}
|
|
};
|
|
|
|
// Act
|
|
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}",
|
|
JsonContent.Create(request));
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Put_ResetPasswordPolicy_InvalidDataType_ReturnsBadRequest()
|
|
{
|
|
// Arrange
|
|
var policyType = PolicyType.ResetPassword;
|
|
var request = new PolicyRequestModel
|
|
{
|
|
Enabled = true,
|
|
Data = new Dictionary<string, object>
|
|
{
|
|
{ "autoEnrollEnabled", 123 } // Wrong type - should be bool
|
|
}
|
|
};
|
|
|
|
// Act
|
|
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}",
|
|
JsonContent.Create(request));
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PutVNext_MasterPasswordPolicy_InvalidDataType_ReturnsBadRequest()
|
|
{
|
|
// Arrange
|
|
var policyType = PolicyType.MasterPassword;
|
|
var request = new SavePolicyRequest
|
|
{
|
|
Policy = new PolicyRequestModel
|
|
{
|
|
Enabled = true,
|
|
Data = new Dictionary<string, object>
|
|
{
|
|
{ "minComplexity", "not a number" }, // Wrong type - should be int
|
|
{ "minLength", 12 }
|
|
}
|
|
}
|
|
};
|
|
|
|
// Act
|
|
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext",
|
|
JsonContent.Create(request));
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
var content = await response.Content.ReadAsStringAsync();
|
|
Assert.Contains("minComplexity", content); // Verify field name is in error message
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PutVNext_SendOptionsPolicy_InvalidDataType_ReturnsBadRequest()
|
|
{
|
|
// Arrange
|
|
var policyType = PolicyType.SendOptions;
|
|
var request = new SavePolicyRequest
|
|
{
|
|
Policy = new PolicyRequestModel
|
|
{
|
|
Enabled = true,
|
|
Data = new Dictionary<string, object>
|
|
{
|
|
{ "disableHideEmail", "not a boolean" } // Wrong type - should be bool
|
|
}
|
|
}
|
|
};
|
|
|
|
// Act
|
|
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext",
|
|
JsonContent.Create(request));
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PutVNext_ResetPasswordPolicy_InvalidDataType_ReturnsBadRequest()
|
|
{
|
|
// Arrange
|
|
var policyType = PolicyType.ResetPassword;
|
|
var request = new SavePolicyRequest
|
|
{
|
|
Policy = new PolicyRequestModel
|
|
{
|
|
Enabled = true,
|
|
Data = new Dictionary<string, object>
|
|
{
|
|
{ "autoEnrollEnabled", 123 } // Wrong type - should be bool
|
|
}
|
|
}
|
|
};
|
|
|
|
// Act
|
|
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext",
|
|
JsonContent.Create(request));
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Put_PolicyWithNullData_Success()
|
|
{
|
|
// Arrange
|
|
var policyType = PolicyType.SingleOrg;
|
|
var request = new PolicyRequestModel
|
|
{
|
|
Enabled = true,
|
|
Data = null
|
|
};
|
|
|
|
// Act
|
|
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}",
|
|
JsonContent.Create(request));
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PutVNext_PolicyWithNullData_Success()
|
|
{
|
|
// Arrange
|
|
var policyType = PolicyType.TwoFactorAuthentication;
|
|
var request = new SavePolicyRequest
|
|
{
|
|
Policy = new PolicyRequestModel
|
|
{
|
|
Enabled = true,
|
|
Data = null
|
|
},
|
|
Metadata = null
|
|
};
|
|
|
|
// Act
|
|
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext",
|
|
JsonContent.Create(request));
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Put_MasterPasswordPolicy_ExcessiveMinLength_ReturnsBadRequest()
|
|
{
|
|
// Arrange
|
|
var policyType = PolicyType.MasterPassword;
|
|
var request = new PolicyRequestModel
|
|
{
|
|
Enabled = true,
|
|
Data = new Dictionary<string, object>
|
|
{
|
|
{ "minLength", 129 }
|
|
}
|
|
};
|
|
|
|
// Act
|
|
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}",
|
|
JsonContent.Create(request));
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Put_MasterPasswordPolicy_ExcessiveMinComplexity_ReturnsBadRequest()
|
|
{
|
|
// Arrange
|
|
var policyType = PolicyType.MasterPassword;
|
|
var request = new PolicyRequestModel
|
|
{
|
|
Enabled = true,
|
|
Data = new Dictionary<string, object>
|
|
{
|
|
{ "minComplexity", 5 }
|
|
}
|
|
};
|
|
|
|
// Act
|
|
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}",
|
|
JsonContent.Create(request));
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetMasterPasswordPolicy_Unauthenticated_ReturnsUnauthorized()
|
|
{
|
|
// Arrange
|
|
_client.DefaultRequestHeaders.Authorization = null;
|
|
|
|
// Act
|
|
var response = await _client.GetAsync($"/organizations/{_organization.Id}/policies/master-password");
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetMasterPasswordPolicy_AuthenticatedNonMember_ReturnsForbidden()
|
|
{
|
|
// Arrange
|
|
var nonMemberEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
|
|
await _factory.LoginWithNewAccount(nonMemberEmail);
|
|
await _loginHelper.LoginAsync(nonMemberEmail);
|
|
|
|
// Act
|
|
var response = await _client.GetAsync($"/organizations/{_organization.Id}/policies/master-password");
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetMasterPasswordPolicy_AuthenticatedMember_ReturnsPolicy()
|
|
{
|
|
// Arrange - owner is already logged in from InitializeAsync
|
|
var policyRepository = _factory.GetService<IPolicyRepository>();
|
|
await policyRepository.CreateAsync(new Policy
|
|
{
|
|
OrganizationId = _organization.Id,
|
|
Type = PolicyType.MasterPassword,
|
|
Enabled = true
|
|
});
|
|
|
|
// Act
|
|
var response = await _client.GetAsync($"/organizations/{_organization.Id}/policies/master-password");
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
var content = await response.Content.ReadFromJsonAsync<PolicyResponseModel>();
|
|
Assert.NotNull(content);
|
|
Assert.True(content.Enabled);
|
|
Assert.Equal(PolicyType.MasterPassword, content.Type);
|
|
Assert.Equal(_organization.Id, content.OrganizationId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// An OrganizationUser with Invited status can still have a UserId linked due to the SSO JIT provisioning bug
|
|
/// (PM-34092). This requirement exists to support that case, so Invited + UserId must succeed.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task GetMasterPasswordPolicy_InvitedMemberWithLinkedUserId_ReturnsPolicy()
|
|
{
|
|
// Arrange
|
|
var policyRepository = _factory.GetService<IPolicyRepository>();
|
|
await policyRepository.CreateAsync(new Policy
|
|
{
|
|
OrganizationId = _organization.Id,
|
|
Type = PolicyType.MasterPassword,
|
|
Enabled = true
|
|
});
|
|
|
|
// Create a user account and add them to the org in Invited status (but with UserId populated)
|
|
var invitedEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
|
|
await _factory.LoginWithNewAccount(invitedEmail);
|
|
|
|
var userRepository = _factory.GetService<IUserRepository>();
|
|
var user = await userRepository.GetByEmailAsync(invitedEmail);
|
|
|
|
var organizationUserRepository = _factory.GetService<IOrganizationUserRepository>();
|
|
await organizationUserRepository.CreateAsync(new OrganizationUser
|
|
{
|
|
OrganizationId = _organization.Id,
|
|
UserId = user!.Id,
|
|
Type = OrganizationUserType.User,
|
|
Status = OrganizationUserStatusType.Invited
|
|
});
|
|
|
|
await _loginHelper.LoginAsync(invitedEmail);
|
|
|
|
// Act
|
|
var response = await _client.GetAsync($"/organizations/{_organization.Id}/policies/master-password");
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
var content = await response.Content.ReadFromJsonAsync<PolicyResponseModel>();
|
|
Assert.NotNull(content);
|
|
Assert.True(content.Enabled);
|
|
Assert.Equal(PolicyType.MasterPassword, content.Type);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Put_SingleOrgPolicy_RevokesNonCompliantUser()
|
|
{
|
|
// Arrange
|
|
// Create a second organization (Org B) with its own owner
|
|
var orgBOwnerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
|
|
await _factory.LoginWithNewAccount(orgBOwnerEmail);
|
|
var (orgB, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually,
|
|
ownerEmail: orgBOwnerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card);
|
|
|
|
// Create a user that belongs to both Org A and Org B
|
|
var multiOrgUserEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
|
|
await _factory.LoginWithNewAccount(multiOrgUserEmail);
|
|
|
|
var orgUserInOrgA = await OrganizationTestHelpers.CreateUserAsync(_factory, _organization.Id,
|
|
multiOrgUserEmail, OrganizationUserType.User);
|
|
await OrganizationTestHelpers.CreateUserAsync(_factory, orgB.Id,
|
|
multiOrgUserEmail, OrganizationUserType.User);
|
|
|
|
// Re-authenticate as the owner of Org A
|
|
await _loginHelper.LoginAsync(_ownerEmail);
|
|
|
|
var request = new PolicyRequestModel
|
|
{
|
|
Enabled = true,
|
|
Data = null
|
|
};
|
|
|
|
// Act - Enable Single Org policy on Org A
|
|
var response = await _client.PutAsync(
|
|
$"/organizations/{_organization.Id}/policies/{PolicyType.SingleOrg}",
|
|
JsonContent.Create(request));
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
|
|
// Verify the multi-org user was revoked in Org A
|
|
var organizationUserRepository = _factory.GetService<IOrganizationUserRepository>();
|
|
var updatedOrgUser = await organizationUserRepository.GetByIdAsync(orgUserInOrgA.Id);
|
|
Assert.NotNull(updatedOrgUser);
|
|
Assert.Equal(OrganizationUserStatusType.Revoked, updatedOrgUser.Status);
|
|
}
|
|
}
|