Files
server/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs
Thomas Rittson cafe4ffce8 [PM-34049] Fix PoliciesController authorize attribute (#7303)
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.
2026-03-25 19:27:38 +10:00

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);
}
}