1
0
mirror of https://github.com/bitwarden/server synced 2025-12-21 02:33:30 +00:00

[PM-24192] Add OrganizationContext in API project (#6291)

This commit is contained in:
Thomas Rittson
2025-09-11 07:37:45 +10:00
committed by GitHub
parent 04cb7820a6
commit bd1745a50d
7 changed files with 238 additions and 12 deletions

View File

@@ -0,0 +1,21 @@
using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Bit.Api.AdminConsole.Authorization;
public static class AuthorizationHandlerCollectionExtensions
{
public static void AddAdminConsoleAuthorizationHandlers(this IServiceCollection services)
{
services.TryAddScoped<IOrganizationContext, OrganizationContext>();
services.TryAddEnumerable([
ServiceDescriptor.Scoped<IAuthorizationHandler, BulkCollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, CollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, GroupAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, OrganizationRequirementHandler>(),
]);
}
}

View File

@@ -1,6 +1,4 @@
#nullable enable using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;

View File

@@ -1,6 +1,4 @@
#nullable enable using System.Security.Claims;
using System.Security.Claims;
using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;

View File

@@ -0,0 +1,84 @@
using System.Security.Claims;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context;
using Bit.Core.Services;
// Note: do not move this into Core! See remarks below.
namespace Bit.Api.AdminConsole.Authorization;
/// <summary>
/// Provides information about a user's membership or provider relationship with an organization.
/// Used for authorization decisions in the API layer, usually called by a controller or authorization handler or attribute.
/// </summary>
/// <remarks>
/// This is intended to deprecate organization-related methods in <see cref="ICurrentContext"/>.
/// It should remain in the API layer (not Core) because it is closely tied to user claims and authentication.
/// </remarks>
public interface IOrganizationContext
{
/// <summary>
/// Parses the provided <see cref="ClaimsPrincipal"/> for claims relating to the specified organization.
/// A user will have organization claims if they are a confirmed member of the organization.
/// </summary>
/// <param name="user">The claims for the user.</param>
/// <param name="organizationId">The organization to extract claims for.</param>
/// <returns>
/// A <see cref="CurrentContextOrganization"/> representing the user's claims for the organization,
/// or null if the user has no claims.
/// </returns>
public CurrentContextOrganization? GetOrganizationClaims(ClaimsPrincipal user, Guid organizationId);
/// <summary>
/// Used to determine whether the user is a ProviderUser for the specified organization.
/// </summary>
/// <param name="user">The claims for the user.</param>
/// <param name="organizationId">The organization to check the provider relationship for.</param>
/// <returns>True if the user is a ProviderUser for the specified organization, otherwise false.</returns>
/// <remarks>
/// This requires a database call, but the results are cached for the lifetime of the service instance.
/// Try to check purely claims-based sources of authorization first (such as organization membership with
/// <see cref="GetOrganizationClaims"/>) to avoid unnecessary database calls.
/// </remarks>
public Task<bool> IsProviderUserForOrganization(ClaimsPrincipal user, Guid organizationId);
}
public class OrganizationContext(
IUserService userService,
IProviderUserRepository providerUserRepository) : IOrganizationContext
{
public const string NoUserIdError = "This method should only be called on the private api with a logged in user.";
/// <summary>
/// Caches provider relationships by UserId.
/// In practice this should only have 1 entry (for the current user), but this approach ensures that a mix-up
/// between users cannot occur if <see cref="IsProviderUserForOrganization"/> is called with a different
/// ClaimsPrincipal for any reason.
/// </summary>
private readonly Dictionary<Guid, IEnumerable<ProviderUserOrganizationDetails>> _providerUserOrganizationsCache = new();
public CurrentContextOrganization? GetOrganizationClaims(ClaimsPrincipal user, Guid organizationId)
{
return user.GetCurrentContextOrganization(organizationId);
}
public async Task<bool> IsProviderUserForOrganization(ClaimsPrincipal user, Guid organizationId)
{
var userId = userService.GetProperUserId(user);
if (!userId.HasValue)
{
throw new InvalidOperationException(NoUserIdError);
}
if (!_providerUserOrganizationsCache.TryGetValue(userId.Value, out var providerUserOrganizations))
{
providerUserOrganizations =
await providerUserRepository.GetManyOrganizationDetailsByUserAsync(userId.Value,
ProviderUserStatusType.Confirmed);
providerUserOrganizations = providerUserOrganizations.ToList();
_providerUserOrganizationsCache[userId.Value] = providerUserOrganizations;
}
return providerUserOrganizations.Any(o => o.OrganizationId == organizationId);
}
}

View File

@@ -1,7 +1,5 @@
using Bit.Api.AdminConsole.Authorization; using Bit.Api.AdminConsole.Authorization;
using Bit.Api.Tools.Authorization; using Bit.Api.Tools.Authorization;
using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
using Bit.Core.Auth.IdentityServer; using Bit.Core.Auth.IdentityServer;
using Bit.Core.PhishingDomainFeatures; using Bit.Core.PhishingDomainFeatures;
using Bit.Core.PhishingDomainFeatures.Interfaces; using Bit.Core.PhishingDomainFeatures.Interfaces;
@@ -109,14 +107,12 @@ public static class ServiceCollectionExtensions
public static void AddAuthorizationHandlers(this IServiceCollection services) public static void AddAuthorizationHandlers(this IServiceCollection services)
{ {
services.AddScoped<IAuthorizationHandler, BulkCollectionAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, CollectionAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, GroupAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, VaultExportAuthorizationHandler>(); services.AddScoped<IAuthorizationHandler, VaultExportAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, SecurityTaskAuthorizationHandler>(); services.AddScoped<IAuthorizationHandler, SecurityTaskAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, SecurityTaskOrganizationAuthorizationHandler>(); services.AddScoped<IAuthorizationHandler, SecurityTaskOrganizationAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, OrganizationRequirementHandler>(); // Admin Console authorization handlers
services.AddAdminConsoleAuthorizationHandlers();
} }
public static void AddPhishingDomainServices(this IServiceCollection services, GlobalSettings globalSettings) public static void AddPhishingDomainServices(this IServiceCollection services, GlobalSettings globalSettings)

View File

@@ -5,6 +5,10 @@ using Bit.Core.Utilities;
namespace Bit.Core.Context; namespace Bit.Core.Context;
/// <summary>
/// Represents the claims for a user in relation to a particular organization.
/// These claims will only be present for users in the <see cref="OrganizationUserStatusType.Confirmed"/> status.
/// </summary>
public class CurrentContextOrganization public class CurrentContextOrganization
{ {
public CurrentContextOrganization() { } public CurrentContextOrganization() { }

View File

@@ -0,0 +1,125 @@
using System.Security.Claims;
using AutoFixture;
using Bit.Api.AdminConsole.Authorization;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Authorization;
[SutProviderCustomize]
public class OrganizationContextTests
{
[Theory, BitAutoData]
public async Task IsProviderUserForOrganization_UserIsProviderUser_ReturnsTrue(
Guid userId, Guid organizationId, Guid otherOrganizationId,
SutProvider<OrganizationContext> sutProvider)
{
var claimsPrincipal = new ClaimsPrincipal();
var providerUserOrganizations = new List<ProviderUserOrganizationDetails>
{
new() { OrganizationId = organizationId },
new() { OrganizationId = otherOrganizationId }
};
sutProvider.GetDependency<IUserService>()
.GetProperUserId(claimsPrincipal)
.Returns(userId);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed)
.Returns(providerUserOrganizations);
var result = await sutProvider.Sut.IsProviderUserForOrganization(claimsPrincipal, organizationId);
Assert.True(result);
await sutProvider.GetDependency<IProviderUserRepository>()
.Received(1)
.GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed);
}
public static IEnumerable<object[]> UserIsNotProviderUserData()
{
// User has provider organizations, but not for the target organization
yield return
[
new List<ProviderUserOrganizationDetails>
{
new Fixture().Create<ProviderUserOrganizationDetails>()
}
];
// User has no provider organizations
yield return [Array.Empty<ProviderUserOrganizationDetails>()];
}
[Theory, BitMemberAutoData(nameof(UserIsNotProviderUserData))]
public async Task IsProviderUserForOrganization_UserIsNotProviderUser_ReturnsFalse(
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizations,
Guid userId, Guid organizationId,
SutProvider<OrganizationContext> sutProvider)
{
var claimsPrincipal = new ClaimsPrincipal();
sutProvider.GetDependency<IUserService>()
.GetProperUserId(claimsPrincipal)
.Returns(userId);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed)
.Returns(providerUserOrganizations);
var result = await sutProvider.Sut.IsProviderUserForOrganization(claimsPrincipal, organizationId);
Assert.False(result);
}
[Theory, BitAutoData]
public async Task IsProviderUserForOrganization_UserIdIsNull_ThrowsException(
Guid organizationId,
SutProvider<OrganizationContext> sutProvider)
{
var claimsPrincipal = new ClaimsPrincipal();
sutProvider.GetDependency<IUserService>()
.GetProperUserId(claimsPrincipal)
.Returns((Guid?)null);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
sutProvider.Sut.IsProviderUserForOrganization(claimsPrincipal, organizationId));
Assert.Equal(OrganizationContext.NoUserIdError, exception.Message);
}
[Theory, BitAutoData]
public async Task IsProviderUserForOrganization_UsesCaching(
Guid userId, Guid organizationId,
SutProvider<OrganizationContext> sutProvider)
{
var claimsPrincipal = new ClaimsPrincipal();
var providerUserOrganizations = new List<ProviderUserOrganizationDetails>
{
new() { OrganizationId = organizationId }
};
sutProvider.GetDependency<IUserService>()
.GetProperUserId(claimsPrincipal)
.Returns(userId);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed)
.Returns(providerUserOrganizations);
await sutProvider.Sut.IsProviderUserForOrganization(claimsPrincipal, organizationId);
await sutProvider.Sut.IsProviderUserForOrganization(claimsPrincipal, organizationId);
await sutProvider.GetDependency<IProviderUserRepository>()
.Received(1)
.GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed);
}
}