mirror of
https://github.com/bitwarden/server.git
synced 2026-06-01 01:55:55 -05:00
440 lines
19 KiB
C#
440 lines
19 KiB
C#
using System.Net;
|
|
using Bit.Api.IntegrationTest.Factories;
|
|
using Bit.Api.IntegrationTest.Helpers;
|
|
using Bit.Core.AdminConsole.Entities;
|
|
using Bit.Core.Billing.Enums;
|
|
using Bit.Core.Entities;
|
|
using Bit.Core.Enums;
|
|
using Bit.Core.Models.Data;
|
|
using Bit.Core.Repositories;
|
|
using Xunit;
|
|
|
|
namespace Bit.Api.IntegrationTest.Billing.Controllers;
|
|
|
|
/// <summary>
|
|
/// Integration tests for OrganizationSponsorshipsController, focusing on authorization checks
|
|
/// for the admin-initiated sponsorship endpoints.
|
|
/// </summary>
|
|
public class OrganizationSponsorshipsControllerTests : IClassFixture<ApiApplicationFactory>, IAsyncLifetime
|
|
{
|
|
private readonly HttpClient _client;
|
|
private readonly ApiApplicationFactory _factory;
|
|
private readonly LoginHelper _loginHelper;
|
|
|
|
private Organization _organization = null!;
|
|
private OrganizationUser _ownerOrgUser = null!;
|
|
private string _ownerEmail = null!;
|
|
|
|
public OrganizationSponsorshipsControllerTests(ApiApplicationFactory factory)
|
|
{
|
|
_factory = factory;
|
|
_client = _factory.CreateClient();
|
|
_loginHelper = new LoginHelper(_factory, _client);
|
|
}
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
// Create an Enterprise org (required for sponsorship features)
|
|
_ownerEmail = $"sponsorship-test-owner-{Guid.NewGuid()}@bitwarden.com";
|
|
await _factory.LoginWithNewAccount(_ownerEmail);
|
|
|
|
(_organization, _ownerOrgUser) = await OrganizationTestHelpers.SignUpAsync(
|
|
_factory,
|
|
plan: PlanType.EnterpriseAnnually,
|
|
ownerEmail: _ownerEmail,
|
|
passwordManagerSeats: 5,
|
|
paymentMethod: PaymentMethodType.Card);
|
|
|
|
// Enable the AdminSponsoredFamilies feature on the org
|
|
var organizationRepository = _factory.GetService<IOrganizationRepository>();
|
|
_organization.UseAdminSponsoredFamilies = true;
|
|
await organizationRepository.ReplaceAsync(_organization);
|
|
}
|
|
|
|
public Task DisposeAsync()
|
|
{
|
|
_client.Dispose();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reproduces VULN-441: Any authenticated user (not a member of the org) can revoke
|
|
/// admin-initiated sponsorships by calling DELETE /{sponsoringOrgId}/{friendlyName}/revoke.
|
|
/// This test asserts the CORRECT behavior (should return Forbidden/Unauthorized),
|
|
/// and will FAIL until the fix is applied.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AdminInitiatedRevokeSponsorship_AsNonMember_ReturnsForbidden()
|
|
{
|
|
// Arrange: Create a sponsorship directly in the DB for the org
|
|
var sponsorship = await CreateAdminInitiatedSponsorshipAsync(
|
|
_organization.Id, _ownerOrgUser.Id, "victim@example.com");
|
|
|
|
// Create a completely separate user who is NOT a member of the org
|
|
var attackerEmail = $"attacker-{Guid.NewGuid()}@bitwarden.com";
|
|
await _factory.LoginWithNewAccount(attackerEmail);
|
|
await _loginHelper.LoginAsync(attackerEmail);
|
|
|
|
// Act: The attacker tries to revoke the sponsorship
|
|
var response = await _client.DeleteAsync(
|
|
$"organization/sponsorship/{_organization.Id}/{Uri.EscapeDataString(sponsorship.FriendlyName!)}/revoke");
|
|
|
|
// Assert: Should be rejected — non-members must not be able to revoke sponsorships
|
|
Assert.True(
|
|
response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
|
|
$"Expected 401 or 403 but got {(int)response.StatusCode} {response.StatusCode}. " +
|
|
"Non-org-members should not be able to revoke admin-initiated sponsorships.");
|
|
|
|
// Verify the sponsorship still exists (was NOT deleted)
|
|
var sponsorshipRepository = _factory.GetService<IOrganizationSponsorshipRepository>();
|
|
var stillExists = await sponsorshipRepository.GetByIdAsync(sponsorship.Id);
|
|
Assert.NotNull(stillExists);
|
|
Assert.False(stillExists.ToDelete, "Sponsorship should not have been marked for deletion.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a regular member (User type, no special permissions) of the org
|
|
/// also cannot revoke admin-initiated sponsorships.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AdminInitiatedRevokeSponsorship_AsRegularMember_ReturnsForbidden()
|
|
{
|
|
// Arrange: Create a sponsorship
|
|
var sponsorship = await CreateAdminInitiatedSponsorshipAsync(
|
|
_organization.Id, _ownerOrgUser.Id, "victim2@example.com");
|
|
|
|
// Create a regular member of the org (User type, no ManageUsers permission)
|
|
var (memberEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
|
|
_factory, _organization.Id, OrganizationUserType.User,
|
|
permissions: new Permissions { ManageUsers = false });
|
|
|
|
await _loginHelper.LoginAsync(memberEmail);
|
|
|
|
// Act
|
|
var response = await _client.DeleteAsync(
|
|
$"organization/sponsorship/{_organization.Id}/{Uri.EscapeDataString(sponsorship.FriendlyName!)}/revoke");
|
|
|
|
// Assert: Regular members without ManageUsers should not be able to revoke
|
|
Assert.True(
|
|
response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
|
|
$"Expected 401 or 403 but got {(int)response.StatusCode} {response.StatusCode}. " +
|
|
"Regular org members without ManageUsers should not be able to revoke admin-initiated sponsorships.");
|
|
|
|
// Verify the sponsorship still exists
|
|
var sponsorshipRepository = _factory.GetService<IOrganizationSponsorshipRepository>();
|
|
var stillExists = await sponsorshipRepository.GetByIdAsync(sponsorship.Id);
|
|
Assert.NotNull(stillExists);
|
|
Assert.False(stillExists.ToDelete, "Sponsorship should not have been marked for deletion.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that an org Owner CAN revoke admin-initiated sponsorships (positive test).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AdminInitiatedRevokeSponsorship_AsOwner_Succeeds()
|
|
{
|
|
// Arrange: Create a sponsorship
|
|
var sponsorship = await CreateAdminInitiatedSponsorshipAsync(
|
|
_organization.Id, _ownerOrgUser.Id, "employee@example.com");
|
|
|
|
await _loginHelper.LoginAsync(_ownerEmail);
|
|
|
|
// Act
|
|
var response = await _client.DeleteAsync(
|
|
$"organization/sponsorship/{_organization.Id}/{Uri.EscapeDataString(sponsorship.FriendlyName!)}/revoke");
|
|
|
|
// Assert: Owner should be able to revoke
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that an org Admin CAN revoke admin-initiated sponsorships.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AdminInitiatedRevokeSponsorship_AsAdmin_Succeeds()
|
|
{
|
|
// Arrange
|
|
var sponsorship = await CreateAdminInitiatedSponsorshipAsync(
|
|
_organization.Id, _ownerOrgUser.Id, "employee-admin@example.com");
|
|
|
|
var (adminEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
|
|
_factory, _organization.Id, OrganizationUserType.Admin);
|
|
|
|
await _loginHelper.LoginAsync(adminEmail);
|
|
|
|
// Act
|
|
var response = await _client.DeleteAsync(
|
|
$"organization/sponsorship/{_organization.Id}/{Uri.EscapeDataString(sponsorship.FriendlyName!)}/revoke");
|
|
|
|
// Assert: Admin should be able to revoke
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a Custom user with ManageUsers permission CAN revoke admin-initiated sponsorships.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AdminInitiatedRevokeSponsorship_AsCustomWithManageUsers_Succeeds()
|
|
{
|
|
// Arrange
|
|
var sponsorship = await CreateAdminInitiatedSponsorshipAsync(
|
|
_organization.Id, _ownerOrgUser.Id, "employee-custom@example.com");
|
|
|
|
var (customEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
|
|
_factory, _organization.Id, OrganizationUserType.Custom,
|
|
permissions: new Permissions { ManageUsers = true });
|
|
|
|
await _loginHelper.LoginAsync(customEmail);
|
|
|
|
// Act
|
|
var response = await _client.DeleteAsync(
|
|
$"organization/sponsorship/{_organization.Id}/{Uri.EscapeDataString(sponsorship.FriendlyName!)}/revoke");
|
|
|
|
// Assert: Custom user with ManageUsers should be able to revoke
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reproduces the cross-org attack: user is admin of Org A but tries to revoke
|
|
/// sponsorships of Org B (of which they are NOT a member).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AdminInitiatedRevokeSponsorship_AsAdminOfDifferentOrg_ReturnsForbidden()
|
|
{
|
|
// Arrange: Create a sponsorship on the target org
|
|
var sponsorship = await CreateAdminInitiatedSponsorshipAsync(
|
|
_organization.Id, _ownerOrgUser.Id, "cross-org-victim@example.com");
|
|
|
|
// Create a different org and make the attacker its owner
|
|
var attackerEmail = $"other-org-admin-{Guid.NewGuid()}@bitwarden.com";
|
|
await _factory.LoginWithNewAccount(attackerEmail);
|
|
|
|
var (otherOrg, _) = await OrganizationTestHelpers.SignUpAsync(
|
|
_factory,
|
|
plan: PlanType.EnterpriseAnnually,
|
|
ownerEmail: attackerEmail,
|
|
name: "Attacker Org",
|
|
billingEmail: attackerEmail,
|
|
passwordManagerSeats: 5,
|
|
paymentMethod: PaymentMethodType.Card);
|
|
|
|
// Log in as the attacker (owner of otherOrg, NOT a member of _organization)
|
|
await _loginHelper.LoginAsync(attackerEmail);
|
|
|
|
// Act: Try to revoke a sponsorship on the target org
|
|
var response = await _client.DeleteAsync(
|
|
$"organization/sponsorship/{_organization.Id}/{Uri.EscapeDataString(sponsorship.FriendlyName!)}/revoke");
|
|
|
|
// Assert: Should be rejected — being admin of another org doesn't grant access
|
|
Assert.True(
|
|
response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
|
|
$"Expected 401 or 403 but got {(int)response.StatusCode} {response.StatusCode}. " +
|
|
"Admin of a different org should not be able to revoke sponsorships of another org.");
|
|
|
|
// Verify the sponsorship still exists
|
|
var sponsorshipRepository = _factory.GetService<IOrganizationSponsorshipRepository>();
|
|
var stillExists = await sponsorshipRepository.GetByIdAsync(sponsorship.Id);
|
|
Assert.NotNull(stillExists);
|
|
Assert.False(stillExists.ToDelete, "Sponsorship should not have been marked for deletion.");
|
|
}
|
|
|
|
#region ResendSponsorshipOffer authorization tests
|
|
|
|
/// <summary>
|
|
/// Verifies that a non-member cannot trigger sponsorship offer emails
|
|
/// for an organization they don't belong to.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ResendSponsorshipOffer_AsNonMember_ReturnsForbidden()
|
|
{
|
|
// Arrange
|
|
var sponsorship = await CreateAdminInitiatedSponsorshipAsync(
|
|
_organization.Id, _ownerOrgUser.Id, "resend-victim@example.com");
|
|
|
|
var attackerEmail = $"resend-attacker-{Guid.NewGuid()}@bitwarden.com";
|
|
await _factory.LoginWithNewAccount(attackerEmail);
|
|
await _loginHelper.LoginAsync(attackerEmail);
|
|
|
|
// Act
|
|
var response = await _client.PostAsync(
|
|
$"organization/sponsorship/{_organization.Id}/families-for-enterprise/resend?sponsoredFriendlyName={Uri.EscapeDataString(sponsorship.FriendlyName!)}",
|
|
null);
|
|
|
|
// Assert
|
|
Assert.True(
|
|
response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
|
|
$"Expected 401 or 403 but got {(int)response.StatusCode} {response.StatusCode}. " +
|
|
"Non-org-members should not be able to resend sponsorship offers.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a regular member without ManageUsers cannot resend sponsorship offers.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ResendSponsorshipOffer_AsRegularMember_ReturnsForbidden()
|
|
{
|
|
// Arrange
|
|
var sponsorship = await CreateAdminInitiatedSponsorshipAsync(
|
|
_organization.Id, _ownerOrgUser.Id, "resend-victim2@example.com");
|
|
|
|
var (memberEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
|
|
_factory, _organization.Id, OrganizationUserType.User,
|
|
permissions: new Permissions { ManageUsers = false });
|
|
|
|
await _loginHelper.LoginAsync(memberEmail);
|
|
|
|
// Act
|
|
var response = await _client.PostAsync(
|
|
$"organization/sponsorship/{_organization.Id}/families-for-enterprise/resend?sponsoredFriendlyName={Uri.EscapeDataString(sponsorship.FriendlyName!)}",
|
|
null);
|
|
|
|
// Assert
|
|
Assert.True(
|
|
response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
|
|
$"Expected 401 or 403 but got {(int)response.StatusCode} {response.StatusCode}. " +
|
|
"Regular org members without ManageUsers should not be able to resend sponsorship offers.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that an admin of a different org cannot resend sponsorship offers
|
|
/// for the target org (cross-org attack).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ResendSponsorshipOffer_AsAdminOfDifferentOrg_ReturnsForbidden()
|
|
{
|
|
// Arrange
|
|
var sponsorship = await CreateAdminInitiatedSponsorshipAsync(
|
|
_organization.Id, _ownerOrgUser.Id, "resend-cross-org@example.com");
|
|
|
|
var attackerEmail = $"resend-other-org-{Guid.NewGuid()}@bitwarden.com";
|
|
await _factory.LoginWithNewAccount(attackerEmail);
|
|
|
|
await OrganizationTestHelpers.SignUpAsync(
|
|
_factory,
|
|
plan: PlanType.EnterpriseAnnually,
|
|
ownerEmail: attackerEmail,
|
|
name: "Resend Attacker Org",
|
|
billingEmail: attackerEmail,
|
|
passwordManagerSeats: 5,
|
|
paymentMethod: PaymentMethodType.Card);
|
|
|
|
await _loginHelper.LoginAsync(attackerEmail);
|
|
|
|
// Act
|
|
var response = await _client.PostAsync(
|
|
$"organization/sponsorship/{_organization.Id}/families-for-enterprise/resend?sponsoredFriendlyName={Uri.EscapeDataString(sponsorship.FriendlyName!)}",
|
|
null);
|
|
|
|
// Assert
|
|
Assert.True(
|
|
response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
|
|
$"Expected 401 or 403 but got {(int)response.StatusCode} {response.StatusCode}. " +
|
|
"Admin of a different org should not be able to resend sponsorship offers for another org.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that an org Owner CAN resend sponsorship offers.
|
|
/// Note: The endpoint may still return a non-200 due to downstream email/policy logic,
|
|
/// but crucially it should NOT return 401/403.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ResendSponsorshipOffer_AsOwner_IsNotForbidden()
|
|
{
|
|
// Arrange
|
|
var sponsorship = await CreateAdminInitiatedSponsorshipAsync(
|
|
_organization.Id, _ownerOrgUser.Id, "resend-employee@example.com");
|
|
|
|
await _loginHelper.LoginAsync(_ownerEmail);
|
|
|
|
// Act
|
|
var response = await _client.PostAsync(
|
|
$"organization/sponsorship/{_organization.Id}/families-for-enterprise/resend?sponsoredFriendlyName={Uri.EscapeDataString(sponsorship.FriendlyName!)}",
|
|
null);
|
|
|
|
// Assert: Should pass authorization (may fail downstream for other reasons, but not 401/403)
|
|
Assert.True(
|
|
response.StatusCode is not HttpStatusCode.Forbidden and not HttpStatusCode.Unauthorized,
|
|
$"Expected to pass authorization but got {(int)response.StatusCode} {response.StatusCode}.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that an org Admin CAN resend sponsorship offers.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ResendSponsorshipOffer_AsAdmin_IsNotForbidden()
|
|
{
|
|
// Arrange
|
|
var sponsorship = await CreateAdminInitiatedSponsorshipAsync(
|
|
_organization.Id, _ownerOrgUser.Id, "resend-admin@example.com");
|
|
|
|
var (adminEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
|
|
_factory, _organization.Id, OrganizationUserType.Admin);
|
|
|
|
await _loginHelper.LoginAsync(adminEmail);
|
|
|
|
// Act
|
|
var response = await _client.PostAsync(
|
|
$"organization/sponsorship/{_organization.Id}/families-for-enterprise/resend?sponsoredFriendlyName={Uri.EscapeDataString(sponsorship.FriendlyName!)}",
|
|
null);
|
|
|
|
// Assert
|
|
Assert.True(
|
|
response.StatusCode is not HttpStatusCode.Forbidden and not HttpStatusCode.Unauthorized,
|
|
$"Expected to pass authorization but got {(int)response.StatusCode} {response.StatusCode}.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a Custom user with ManageUsers CAN resend sponsorship offers.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ResendSponsorshipOffer_AsCustomWithManageUsers_IsNotForbidden()
|
|
{
|
|
// Arrange
|
|
var sponsorship = await CreateAdminInitiatedSponsorshipAsync(
|
|
_organization.Id, _ownerOrgUser.Id, "resend-custom@example.com");
|
|
|
|
var (customEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
|
|
_factory, _organization.Id, OrganizationUserType.Custom,
|
|
permissions: new Permissions { ManageUsers = true });
|
|
|
|
await _loginHelper.LoginAsync(customEmail);
|
|
|
|
// Act
|
|
var response = await _client.PostAsync(
|
|
$"organization/sponsorship/{_organization.Id}/families-for-enterprise/resend?sponsoredFriendlyName={Uri.EscapeDataString(sponsorship.FriendlyName!)}",
|
|
null);
|
|
|
|
// Assert
|
|
Assert.True(
|
|
response.StatusCode is not HttpStatusCode.Forbidden and not HttpStatusCode.Unauthorized,
|
|
$"Expected to pass authorization but got {(int)response.StatusCode} {response.StatusCode}.");
|
|
}
|
|
|
|
#endregion
|
|
|
|
/// <summary>
|
|
/// Helper to create an admin-initiated sponsorship directly in the DB,
|
|
/// bypassing the command layer (which has its own auth checks).
|
|
/// </summary>
|
|
private async Task<OrganizationSponsorship> CreateAdminInitiatedSponsorshipAsync(
|
|
Guid sponsoringOrgId, Guid sponsoringOrgUserId, string friendlyName)
|
|
{
|
|
var sponsorshipRepository = _factory.GetService<IOrganizationSponsorshipRepository>();
|
|
|
|
var sponsorship = new OrganizationSponsorship
|
|
{
|
|
SponsoringOrganizationId = sponsoringOrgId,
|
|
SponsoringOrganizationUserId = sponsoringOrgUserId,
|
|
FriendlyName = friendlyName,
|
|
OfferedToEmail = friendlyName,
|
|
PlanSponsorshipType = PlanSponsorshipType.FamiliesForEnterprise,
|
|
IsAdminInitiated = true,
|
|
ToDelete = false,
|
|
};
|
|
sponsorship.SetNewId();
|
|
|
|
await sponsorshipRepository.CreateAsync(sponsorship);
|
|
return sponsorship;
|
|
}
|
|
}
|