Skip to content
13 changes: 13 additions & 0 deletions src/Api/AdminConsole/Authorization/NoopAuthorizeAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
ο»Ώusing Microsoft.AspNetCore.Authorization;

namespace Bit.Api.AdminConsole.Authorization;

/// <summary>
/// A no-op attribute which documents an intentional choice to not use
/// <see cref="AuthorizeAttribute{T}"/> - for example, because you are manually handling
/// authorization in imperative code, or the endpoint does not require authorization.
/// Unlike <see cref="AllowAnonymousAttribute"/>, this does not bypass the class-level <see cref="AuthorizeAttribute"/>;
/// it indicates that no <b>additional</b> authorization is needed.
/// </summary>
[AttributeUsage(AttributeTargets.Method)]
public class NoopAuthorizeAttribute : Attribute;
32 changes: 23 additions & 9 deletions src/Api/AdminConsole/Controllers/BaseAdminConsoleController.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
ο»Ώusing Bit.Core.AdminConsole.Utilities.v2;
using Bit.Core.AdminConsole.Utilities.v2.Results;
using Bit.Core.Models.Api;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;

namespace Bit.Api.AdminConsole.Controllers;
Expand All @@ -11,16 +12,29 @@ protected static IResult Handle(CommandResult commandResult) =>
commandResult.Match<IResult>(
error => error switch
{
BadRequestError badRequest => TypedResults.BadRequest(new ErrorResponseModel(badRequest.Message)),
NotFoundError notFound => TypedResults.NotFound(new ErrorResponseModel(notFound.Message)),
InternalError internalError => TypedResults.Json(
new ErrorResponseModel(internalError.Message),
statusCode: StatusCodes.Status500InternalServerError),
_ => TypedResults.Json(
new ErrorResponseModel(error.Message),
statusCode: StatusCodes.Status500InternalServerError
)
BadRequestError badRequest => Error.BadRequest(badRequest.Message),
NotFoundError notFound => Error.NotFound(notFound.Message),
InternalError internalError => Error.InternalError(internalError.Message),
_ => Error.InternalError(error.Message)
},
_ => TypedResults.NoContent()
);

protected static class Error
{
public static NotFound<ErrorResponseModel> NotFound(string message = "Resource not found.") =>
TypedResults.NotFound(new ErrorResponseModel(message));

public static UnauthorizedHttpResult Unauthorized() =>
TypedResults.Unauthorized();

public static BadRequest<ErrorResponseModel> BadRequest(string message) =>
TypedResults.BadRequest(new ErrorResponseModel(message));

public static JsonHttpResult<ErrorResponseModel> InternalError(
string message = "Something went wrong with your request. Please contact support for assistance.") =>
TypedResults.Json(
new ErrorResponseModel(message),
statusCode: StatusCodes.Status500InternalServerError);
}
}
77 changes: 61 additions & 16 deletions src/Api/AdminConsole/Controllers/ProviderClientsController.cs
Original file line number Diff line number Diff line change
@@ -1,48 +1,53 @@
ο»Ώ// FIXME: Update this file to be null safe and then delete the line below
#nullable disable

using Bit.Api.Billing.Controllers;
using Bit.Api.AdminConsole.Authorization;
using Bit.Api.AdminConsole.Authorization.Providers.Requirements;
using Bit.Api.Billing.Models.Requests;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
using Bit.Core.Models.Business;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace Bit.Api.AdminConsole.Controllers;

[Route("providers/{providerId:guid}/clients")]
[Authorize("Application")]
public class ProviderClientsController(
ICurrentContext currentContext,
ILogger<BaseProviderController> logger,
ILogger<ProviderClientsController> logger,
IOrganizationRepository organizationRepository,
IProviderBillingService providerBillingService,
IProviderOrganizationRepository providerOrganizationRepository,
IProviderRepository providerRepository,
IProviderService providerService,
IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService)
IUserService userService) : BaseAdminConsoleController
{
private readonly ICurrentContext _currentContext = currentContext;

[HttpPost]
[SelfHosted(NotSelfHostedOnly = true)]
[Authorize<ProviderAdminRequirement>]
public async Task<IResult> CreateAsync(
[FromRoute] Guid providerId,
[FromBody] CreateClientOrganizationRequestBody requestBody)
{
var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId);
var (provider, result) = await TryGetBillableProviderAsync(providerId);

if (provider == null)
{
return result;
}

var user = await UserService.GetUserByPrincipalAsync(User);
var user = await userService.GetUserByPrincipalAsync(User);

if (user == null)
{
Expand Down Expand Up @@ -88,12 +93,13 @@ await providerBillingService.CreateCustomerForClientOrganization(

[HttpPut("{providerOrganizationId:guid}")]
[SelfHosted(NotSelfHostedOnly = true)]
[Authorize<ProviderUserRequirement>]
public async Task<IResult> UpdateAsync(
[FromRoute] Guid providerId,
[FromRoute] Guid providerOrganizationId,
[FromBody] UpdateClientOrganizationRequestBody requestBody)
{
var (provider, result) = await TryGetBillableProviderForServiceUserOperation(providerId);
var (provider, result) = await TryGetBillableProviderAsync(providerId);

if (provider == null)
{
Expand All @@ -116,7 +122,7 @@ public async Task<IResult> UpdateAsync(

if (clientOrganization is not { Status: OrganizationStatusType.Managed })
{
return Error.ServerError();
return Error.InternalError();
}

var seatAdjustment = requestBody.AssignedSeats - (clientOrganization.Seats ?? 0);
Expand All @@ -126,9 +132,11 @@ public async Task<IResult> UpdateAsync(
clientOrganization.PlanType,
seatAdjustment);

if (seatAdjustmentResultsInPurchase && !_currentContext.ProviderProviderAdmin(provider.Id))
if (seatAdjustmentResultsInPurchase && !currentContext.ProviderProviderAdmin(provider.Id))
{
return Error.Unauthorized("Service users cannot purchase additional seats.");
return TypedResults.Json(
new ErrorResponseModel("Service users cannot purchase additional seats."),
statusCode: StatusCodes.Status401Unauthorized);
}

await providerBillingService.ScaleSeats(provider, clientOrganization.PlanType, seatAdjustment);
Expand All @@ -143,16 +151,17 @@ public async Task<IResult> UpdateAsync(

[HttpGet("addable")]
[SelfHosted(NotSelfHostedOnly = true)]
[Authorize<ProviderUserRequirement>]
public async Task<IResult> GetAddableOrganizationsAsync([FromRoute] Guid providerId)
{
var (provider, result) = await TryGetBillableProviderForServiceUserOperation(providerId);
var (provider, result) = await TryGetBillableProviderAsync(providerId);

if (provider == null)
{
return result;
}

var userId = _currentContext.UserId;
var userId = currentContext.UserId;

if (!userId.HasValue)
{
Expand All @@ -167,24 +176,25 @@ public async Task<IResult> GetAddableOrganizationsAsync([FromRoute] Guid provide

[HttpPost("existing")]
[SelfHosted(NotSelfHostedOnly = true)]
[Authorize<ProviderAdminRequirement>]
public async Task<IResult> AddExistingOrganizationAsync(
[FromRoute] Guid providerId,
[FromBody] AddExistingOrganizationRequestBody requestBody)
{
var userId = _currentContext.UserId;
var userId = currentContext.UserId;
if (!userId.HasValue)
{
return Error.Unauthorized();
}

var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId);
var (provider, result) = await TryGetBillableProviderAsync(providerId);

if (provider == null)
{
return result;
}

if (!await _currentContext.OrganizationOwner(requestBody.OrganizationId))
if (!await currentContext.OrganizationOwner(requestBody.OrganizationId))
{
return Error.Unauthorized();
}
Expand All @@ -201,4 +211,39 @@ public async Task<IResult> AddExistingOrganizationAsync(

return TypedResults.Ok();
}

private async Task<(Provider, IResult)> TryGetBillableProviderAsync(Guid providerId)
{
var provider = await providerRepository.GetByIdAsync(providerId);

if (provider == null)
{
logger.LogError(
"Cannot find provider ({ProviderID}) for Consolidated Billing operation",
providerId);

return (null, Error.NotFound());
}

if (!provider.IsBillable())
{
logger.LogError(
"Cannot run Consolidated Billing operation for provider ({ProviderID}) that is not billable",
providerId);

return (null, Error.Unauthorized());
}

if (provider.IsStripeEnabled())
{
return (provider, null);
}

logger.LogError(
"Cannot run Consolidated Billing operation for provider ({ProviderID}) that is missing Stripe configuration",
providerId);

return (null, Error.InternalError());
}

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
ο»Ώ// FIXME: Update this file to be null safe and then delete the line below
#nullable disable

using Bit.Api.AdminConsole.Authorization;
using Bit.Api.AdminConsole.Authorization.Providers.Requirements;
using Bit.Api.AdminConsole.Models.Request.Providers;
using Bit.Api.AdminConsole.Models.Response.Providers;
using Bit.Api.Models.Response;
Expand Down Expand Up @@ -48,23 +50,19 @@ public ProviderOrganizationsController(
}

[HttpGet("")]
[Authorize<ProviderUserRequirement>]
public async Task<ListResponseModel<ProviderOrganizationOrganizationDetailsResponseModel>> Get(Guid providerId)
{
if (!_currentContext.AccessProviderOrganizations(providerId))
{
throw new NotFoundException();
}

var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId);
var responses = providerOrganizations.Select(o => new ProviderOrganizationOrganizationDetailsResponseModel(o));
return new ListResponseModel<ProviderOrganizationOrganizationDetailsResponseModel>(responses);
}

[HttpPost("add")]
[Authorize<ProviderAdminRequirement>]
public async Task Add(Guid providerId, [FromBody] ProviderOrganizationAddRequestModel model)
{
if (!_currentContext.ManageProviderOrganizations(providerId) ||
!await _currentContext.OrganizationOwner(model.OrganizationId))
if (!await _currentContext.OrganizationOwner(model.OrganizationId))
{
throw new NotFoundException();
}
Expand All @@ -74,6 +72,7 @@ public async Task Add(Guid providerId, [FromBody] ProviderOrganizationAddRequest

[HttpPost("")]
[SelfHosted(NotSelfHostedOnly = true)]
[Authorize<ProviderAdminRequirement>]
public async Task<ProviderOrganizationResponseModel> Post(Guid providerId, [FromBody] ProviderOrganizationCreateRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
Expand All @@ -82,25 +81,16 @@ public async Task<ProviderOrganizationResponseModel> Post(Guid providerId, [From
throw new UnauthorizedAccessException();
}

if (!_currentContext.ManageProviderOrganizations(providerId))
{
throw new NotFoundException();
}

var organizationSignup = model.OrganizationCreateRequest.ToOrganizationSignup(user);
organizationSignup.IsFromProvider = true;
var result = await _providerService.CreateOrganizationAsync(providerId, organizationSignup, model.ClientOwnerEmail, user);
return new ProviderOrganizationResponseModel(result);
}

[HttpDelete("{id:guid}")]
[Authorize<ProviderAdminRequirement>]
public async Task Delete(Guid providerId, Guid id)
{
if (!_currentContext.ManageProviderOrganizations(providerId))
{
throw new NotFoundException();
}

var provider = await _providerRepository.GetByIdAsync(providerId);

var providerOrganization = await _providerOrganizationRepository.GetByIdAsync(id);
Expand All @@ -115,6 +105,7 @@ await _removeOrganizationFromProviderCommand.RemoveOrganizationFromProvider(

[HttpPost("{id:guid}/delete")]
[Obsolete("This endpoint is deprecated. Use DELETE method instead")]
[Authorize<ProviderAdminRequirement>]
public async Task PostDelete(Guid providerId, Guid id)
{
await Delete(providerId, id);
Expand Down
Loading
Loading