Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Api.AdminConsole.Authorization.Providers;
using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection.Extensions;
Expand All @@ -17,6 +18,7 @@ public static void AddAdminConsoleAuthorizationHandlers(this IServiceCollection
ServiceDescriptor.Scoped<IAuthorizationHandler, GroupAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, OrgUserLinkedToUserIdHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, OrganizationRequirementHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, ProviderRequirementHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, RecoverAccountAuthorizationHandler>(),
]);
}
Expand Down
23 changes: 22 additions & 1 deletion src/Api/AdminConsole/Authorization/HttpContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ namespace Bit.Api.AdminConsole.Authorization;
public static class HttpContextExtensions
{
public const string NoOrgIdError =
"A route decorated with with '[Authorize<Requirement>]' must include a route value named 'orgId' or 'organizationId' either through the [Controller] attribute or through a '[Http*]' attribute.";
"A route decorated with with '[Authorize<IOrganizationRequirement>]' must include a route value named 'orgId' or 'organizationId' either through the [Controller] attribute or through a '[Http*]' attribute.";

public const string NoProviderIdError =
"A route decorated with '[Authorize<IProviderRequirement>]' must include a route value named 'providerId' either through the [Controller] attribute or through a '[Http*]' attribute.";

/// <summary>
/// Returns the result of the callback, caching it in HttpContext.Features for the lifetime of the request.
Expand Down Expand Up @@ -82,4 +85,22 @@ public static Guid GetOrganizationId(this HttpContext httpContext)

throw new InvalidOperationException(NoOrgIdError);
}

/// <summary>
/// Parses the {providerId} route parameter into a Guid, or throws if it is not present or is not a valid Guid.
/// </summary>
/// <exception cref="InvalidOperationException"></exception>
public static Guid GetProviderId(this HttpContext httpContext)
{
var routeValues = httpContext.GetRouteData().Values;

if (routeValues.TryGetValue("providerId", out var providerIdParam) &&
providerIdParam != null &&
Guid.TryParse(providerIdParam.ToString(), out var providerId))
{
return providerId;
}

throw new InvalidOperationException(NoProviderIdError);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Bit.Core.AdminConsole.Context;
using Microsoft.AspNetCore.Authorization;

namespace Bit.Api.AdminConsole.Authorization.Providers;

/// <summary>
/// A requirement that implements this interface will be handled by <see cref="ProviderRequirementHandler"/>,
/// which calls AuthorizeAsync with the provider details from the route.
/// This is used for simple role-based checks.
/// This may only be used on endpoints with {providerId} in their path.
/// </summary>
public interface IProviderRequirement : IAuthorizationRequirement
{
/// <summary>
/// Whether to authorize a request that has this requirement.
/// </summary>
/// <param name="providerClaims">
/// The CurrentContextProvider for the user if they are a member of the provider.
/// This is null if they are not a member.
/// </param>
/// <returns>True if the requirement has been satisfied, otherwise false.</returns>
public bool Authorize(CurrentContextProvider? providerClaims);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Security.Claims;
using Bit.Core.AdminConsole.Context;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Auth.Identity;

namespace Bit.Api.AdminConsole.Authorization.Providers;

public static class ProviderClaimsExtensions
{
/// <summary>
/// Parses a user's claims and returns an object representing their claims for the specified provider.
/// </summary>
/// <param name="user">The user who has the claims.</param>
/// <param name="providerId">The providerId to look for in the claims.</param>
/// <returns>
/// A <see cref="CurrentContextProvider"/> representing the user's claims for that provider, or null
/// if the user does not have any claims for that provider.
/// </returns>
public static CurrentContextProvider? GetCurrentContextProvider(this ClaimsPrincipal user, Guid providerId)
{
var claimsDict = user.Claims
.GroupBy(c => c.Type)
.ToDictionary(
c => c.Key,
c => c.ToList());

bool hasClaim(string claimType) =>
claimsDict.TryGetValue(claimType, out var claims) &&
claims.Any(c => Guid.TryParse(c.Value, out var id) && id == providerId);

if (hasClaim(Claims.ProviderAdmin))
{
return new CurrentContextProvider { Id = providerId, Type = ProviderUserType.ProviderAdmin };
}

if (hasClaim(Claims.ProviderServiceUser))
{
return new CurrentContextProvider { Id = providerId, Type = ProviderUserType.ServiceUser };
}

return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;

namespace Bit.Api.AdminConsole.Authorization.Providers;

/// <summary>
/// Handles any requirement that implements <see cref="IProviderRequirement"/>.
/// Retrieves the Provider ID from the route and then passes the provider claims to the requirement's AuthorizeAsync
/// callback to determine whether the action is authorized.
/// </summary>
public class ProviderRequirementHandler(
IHttpContextAccessor httpContextAccessor,
IUserService userService)
: AuthorizationHandler<IProviderRequirement>
{
public const string NoHttpContextError = "This method should only be called in the context of an HTTP Request.";

protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IProviderRequirement requirement)
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext == null)
{
throw new InvalidOperationException(NoHttpContextError);
}

var providerId = httpContext.GetProviderId();

var userId = userService.GetProperUserId(httpContext.User);
if (userId == null)
{
return Task.CompletedTask;
}

var providerClaims = httpContext.User.GetCurrentContextProvider(providerId);

if (requirement.Authorize(providerClaims))
{
context.Succeed(requirement);
}

return Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Bit.Api.AdminConsole.Authorization.Providers.Requirements;

/// <summary>
/// Authorizes users who can manage provider users (ProviderAdmin only).
/// </summary>
public class ManageProviderUsersRequirement : ProviderAdminRequirement;
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Bit.Core.AdminConsole.Context;
using Bit.Core.AdminConsole.Enums.Provider;

namespace Bit.Api.AdminConsole.Authorization.Providers.Requirements;

/// <summary>
/// Authorizes ProviderAdmin users only.
/// </summary>
public class ProviderAdminRequirement : IProviderRequirement
{
public bool Authorize(CurrentContextProvider? providerClaims)
=> providerClaims?.Type == ProviderUserType.ProviderAdmin;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Bit.Core.AdminConsole.Context;

namespace Bit.Api.AdminConsole.Authorization.Providers.Requirements;

/// <summary>
/// Authorizes any provider member (ProviderAdmin or ServiceUser).
/// </summary>
public class ProviderUserRequirement : IProviderRequirement
{
public bool Authorize(CurrentContextProvider? providerClaims)
=> providerClaims != null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System.Security.Claims;
using Bit.Api.AdminConsole.Authorization.Providers;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Auth.Identity;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;

namespace Bit.Api.Test.AdminConsole.Authorization.Providers;

public class ProviderClaimsExtensionsTests
{
[Theory, BitAutoData]
public void GetCurrentContextProvider_WhenUserIsProviderAdmin_ReturnsProviderAdminClaims(Guid providerId)
{
var claims = new[] { new Claim(Claims.ProviderAdmin, providerId.ToString()) };
var claimsPrincipal = MakeClaimsPrincipal(claims);

var result = claimsPrincipal.GetCurrentContextProvider(providerId);

Assert.NotNull(result);
Assert.Equal(providerId, result.Id);
Assert.Equal(ProviderUserType.ProviderAdmin, result.Type);
}

[Theory, BitAutoData]
public void GetCurrentContextProvider_WhenUserIsServiceUser_ReturnsServiceUserClaims(Guid providerId)
{
var claims = new[] { new Claim(Claims.ProviderServiceUser, providerId.ToString()) };
var claimsPrincipal = MakeClaimsPrincipal(claims);

var result = claimsPrincipal.GetCurrentContextProvider(providerId);

Assert.NotNull(result);
Assert.Equal(providerId, result.Id);
Assert.Equal(ProviderUserType.ServiceUser, result.Type);
}

[Theory, BitAutoData]
public void GetCurrentContextProvider_WhenUserIsNotProviderMember_ReturnsNull(Guid providerId)
{
var claimsPrincipal = MakeClaimsPrincipal([]);

var result = claimsPrincipal.GetCurrentContextProvider(providerId);

Assert.Null(result);
}

[Theory, BitAutoData]
public void GetCurrentContextProvider_WhenClaimsContainDifferentProviderId_ReturnsNull(Guid providerId, Guid otherProviderId)
{
var claims = new[] { new Claim(Claims.ProviderAdmin, otherProviderId.ToString()) };
var claimsPrincipal = MakeClaimsPrincipal(claims);

var result = claimsPrincipal.GetCurrentContextProvider(providerId);

Assert.Null(result);
}

private static ClaimsPrincipal MakeClaimsPrincipal(IEnumerable<Claim> claims)
{
var principal = new ClaimsPrincipal();
principal.AddIdentities([new ClaimsIdentity(claims)]);
return principal;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using System.Security.Claims;
using Bit.Api.AdminConsole.Authorization;
using Bit.Api.AdminConsole.Authorization.Providers;
using Bit.Core.AdminConsole.Context;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using NSubstitute;
using Xunit;

namespace Bit.Api.Test.AdminConsole.Authorization.Providers;

[SutProviderCustomize]
public class ProviderRequirementHandlerTests
{
[Theory]
[BitAutoData((string)null)]
[BitAutoData("malformed guid")]
public async Task IfInvalidProviderId_Throws(string providerId, Guid userId, SutProvider<ProviderRequirementHandler> sutProvider)
{
// Arrange
ArrangeRouteAndUser(sutProvider, providerId, userId);
var testRequirement = Substitute.For<IProviderRequirement>();
var authContext = new AuthorizationHandlerContext([testRequirement], new ClaimsPrincipal(), null);

// Act
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => sutProvider.Sut.HandleAsync(authContext));
Assert.Contains(HttpContextExtensions.NoProviderIdError, exception.Message);
Assert.False(authContext.HasSucceeded);
}

[Theory, BitAutoData]
public async Task IfHttpContextIsNull_Throws(SutProvider<ProviderRequirementHandler> sutProvider)
{
// Arrange
sutProvider.GetDependency<IHttpContextAccessor>().HttpContext = null;
var testRequirement = Substitute.For<IProviderRequirement>();
var authContext = new AuthorizationHandlerContext([testRequirement], new ClaimsPrincipal(), null);

// Act
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => sutProvider.Sut.HandleAsync(authContext));
Assert.Contains(ProviderRequirementHandler.NoHttpContextError, exception.Message);
Assert.False(authContext.HasSucceeded);
}

[Theory, BitAutoData]
public async Task IfUserIdIsNull_DoesNotAuthorize(Guid providerId, SutProvider<ProviderRequirementHandler> sutProvider)
{
// Arrange
ArrangeRouteAndUser(sutProvider, providerId.ToString(), null);
var testRequirement = Substitute.For<IProviderRequirement>();
var authContext = new AuthorizationHandlerContext([testRequirement], new ClaimsPrincipal(), null);

// Act
await sutProvider.Sut.HandleAsync(authContext);

// Assert — requirement is not invoked and context has not succeeded
testRequirement.DidNotReceive().Authorize(Arg.Any<CurrentContextProvider?>());
Assert.False(authContext.HasSucceeded);
}

[Theory, BitAutoData]
public async Task DoesNotAuthorize_IfAuthorizeAsync_ReturnsFalse(
SutProvider<ProviderRequirementHandler> sutProvider, Guid providerId, Guid userId)
{
// Arrange
ArrangeRouteAndUser(sutProvider, providerId.ToString(), userId);

var testRequirement = Substitute.For<IProviderRequirement>();
testRequirement.Authorize(null).ReturnsForAnyArgs(false);
var authContext = new AuthorizationHandlerContext([testRequirement], new ClaimsPrincipal(), null);

// Act
await sutProvider.Sut.HandleAsync(authContext);

// Assert
testRequirement.Received(1).Authorize(null);
Assert.False(authContext.HasSucceeded);
}

[Theory, BitAutoData]
public async Task Authorizes_IfAuthorizeAsync_ReturnsTrue(
SutProvider<ProviderRequirementHandler> sutProvider, Guid providerId, Guid userId)
{
// Arrange
ArrangeRouteAndUser(sutProvider, providerId.ToString(), userId);

var testRequirement = Substitute.For<IProviderRequirement>();
testRequirement.Authorize(null).ReturnsForAnyArgs(true);
var authContext = new AuthorizationHandlerContext([testRequirement], new ClaimsPrincipal(), null);

// Act
await sutProvider.Sut.HandleAsync(authContext);

// Assert
testRequirement.Received(1).Authorize(null);
Assert.True(authContext.HasSucceeded);
}

private static void ArrangeRouteAndUser(SutProvider<ProviderRequirementHandler> sutProvider, string providerIdRouteValue,
Guid? userId)
{
var httpContext = new DefaultHttpContext();
httpContext.Request.RouteValues["providerId"] = providerIdRouteValue;
sutProvider.GetDependency<IHttpContextAccessor>().HttpContext = httpContext;
sutProvider.GetDependency<IUserService>().GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
}
}
Loading
Loading