Skip to content
Draft
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
33 changes: 3 additions & 30 deletions src/Api/AdminConsole/Controllers/BaseAdminConsoleController.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
using Bit.Core.AdminConsole.Utilities.v2;
using Bit.Api.AdminConsole.Utilities;
using Bit.Core.AdminConsole.Utilities.v2.Results;
using Bit.Core.AdminConsole.Utilities.v2.Validation;
using Bit.Core.Models.Api;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using CommandError = Bit.Core.AdminConsole.Utilities.v2.Error;

namespace Bit.Api.AdminConsole.Controllers;

Expand All @@ -14,11 +12,7 @@ public abstract class BaseAdminConsoleController : Controller
/// Maps a void <see cref="CommandResult"/> to an HTTP response.
/// Returns 204 No Content on success, or the appropriate error status code on failure.
/// </summary>
protected static IResult Handle(CommandResult commandResult) =>
commandResult.Match<IResult>(
error => MapError(error),
_ => TypedResults.NoContent()
);
protected static IResult Handle(CommandResult commandResult) => commandResult.ToHttpResult();

/// <summary>
/// Maps a <see cref="CommandResult{T}"/> to an HTTP response.
Expand All @@ -27,10 +21,7 @@ protected static IResult Handle(CommandResult commandResult) =>
/// On failure, returns the appropriate error status code.
/// </summary>
protected static IResult Handle<T>(CommandResult<T> commandResult, Func<T, IResult> success) =>
commandResult.Match<IResult>(
error => MapError(error),
success
);
commandResult.ToHttpResult(success);

protected static class Error
{
Expand All @@ -49,22 +40,4 @@ public static JsonHttpResult<ErrorResponseModel> InternalError(
new ErrorResponseModel(message),
statusCode: StatusCodes.Status500InternalServerError);
}

private static IResult MapError(CommandError error) =>
error switch
{
IValidationError validationError => TypedResults.BitwardenValidationProblem(validationError),
BadRequestError badRequest => TypedResults.BadRequest(new ErrorResponseModel(badRequest.Message)),
NotFoundError notFound => TypedResults.NotFound(new ErrorResponseModel(notFound.Message)),
ConflictError conflict => TypedResults.Json(
new ErrorResponseModel(conflict.Message),
statusCode: StatusCodes.Status409Conflict),
InternalError internalError => TypedResults.Json(
new ErrorResponseModel(internalError.Message),
statusCode: StatusCodes.Status500InternalServerError),
_ => TypedResults.Json(
new ErrorResponseModel(error.Message),
statusCode: StatusCodes.Status500InternalServerError
)
};
}
29 changes: 29 additions & 0 deletions src/Api/AdminConsole/Endpoints/AdminConsoleEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Bit.Api.AdminConsole.Endpoints.Filters;
using Bit.Core.Auth.Identity;

namespace Bit.Api.AdminConsole.Endpoints;

/// <summary>
/// Maps the admin console Minimal API endpoint groups. Add new groups here rather than in <c>Startup.cs</c>.
/// </summary>
public static class AdminConsoleEndpoints
{
public static void MapAdminConsoleEndpoints(this IEndpointRouteBuilder endpoints)
{
endpoints.MapCollectionEndpoints();
}

/// <summary>
/// Applies the shared admin console endpoint chain to a single Minimal API endpoint. Keeps
/// <see cref="AdminConsoleExceptionHandlerEndpointFilter"/> outermost so exceptions from downstream
/// filters and the handler are translated into the <c>ErrorResponseModel</c> contract, and hides
/// the endpoint from the public OpenAPI spec via the "internal" group name.
/// </summary>
public static RouteHandlerBuilder WithAdminConsoleDefaults(this RouteHandlerBuilder builder)
{
builder.RequireAuthorization(Policies.Application);
builder.AddEndpointFilter<AdminConsoleExceptionHandlerEndpointFilter>();
builder.WithGroupName("internal");
return builder;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Bit.Api.AdminConsole.Endpoints.Handlers;
using Microsoft.Extensions.DependencyInjection.Extensions;

namespace Bit.Api.AdminConsole.Endpoints;

/// <summary>
/// Registers the handler classes behind admin console Minimal API endpoints.
/// </summary>
public static class AdminConsoleServiceCollectionExtensions
{
public static void AddAdminConsoleEndpointHandlers(this IServiceCollection services)
{
services.TryAddScoped<UpdateCollectionHandler>();
}
}
25 changes: 25 additions & 0 deletions src/Api/AdminConsole/Endpoints/CollectionEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Security.Claims;
using Bit.Api.AdminConsole.Endpoints.Handlers;
using Bit.Api.AdminConsole.Models.Request;
using Bit.Core;

namespace Bit.Api.AdminConsole.Endpoints;

/// <summary>
/// Maps the unified collection routes handled via Minimal API. When
/// <see cref="FeatureFlagKeys.PM35160CollectionAuthorizationHandlers"/> is off the flagged
/// endpoints return 404 and the existing MVC controller answers the request instead.
/// </summary>
public static class CollectionEndpoints
{
public static void MapCollectionEndpoints(this IEndpointRouteBuilder endpoints)
{
endpoints.MapPatch("organizations/{orgId:guid}/collections/{id:guid}",
(Guid orgId, Guid id, UpdateCollectionWithDeltaRequestModel model, ClaimsPrincipal user,
UpdateCollectionHandler handler) =>
handler.HandleAsync(orgId, id, model, user))
.RequireFeature(FeatureFlagKeys.PM35160CollectionAuthorizationHandlers)
.WithAdminConsoleDefaults()
.WithName("PatchCollectionWithDelta");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using Bit.Core.Exceptions;
using Bit.Core.Models.Api;

namespace Bit.Api.AdminConsole.Endpoints.Filters;

/// <summary>
/// Turns exceptions thrown by admin console Minimal API endpoints into the same ErrorResponseModel shape
/// every other Bitwarden endpoint already returns.
/// </summary>
public class AdminConsoleExceptionHandlerEndpointFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
try
{
return await next(context);
}
catch (NotFoundException)
{
return TypedResults.NotFound(new ErrorResponseModel("Resource not found."));
}
catch (BadRequestException badRequestException)
{
return TypedResults.BadRequest(new ErrorResponseModel(badRequestException.Message));
}
catch (Exception exception)
{
var endpointName = context.HttpContext.GetEndpoint()?.DisplayName;
context.HttpContext.RequestServices.GetRequiredService<ILogger<AdminConsoleExceptionHandlerEndpointFilter>>()
.LogError(exception, "Unhandled exception in {EndpointName}", endpointName);
return TypedResults.Json(
new ErrorResponseModel("An error has occurred."), statusCode: StatusCodes.Status500InternalServerError);
}
Comment on lines +26 to +33
}
}
136 changes: 136 additions & 0 deletions src/Api/AdminConsole/Endpoints/Handlers/UpdateCollectionHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
using System.Security.Claims;
using System.Transactions;
using Bit.Api.AdminConsole.Authorization.Collections;
using Bit.Api.AdminConsole.Models.Request;
using Bit.Api.AdminConsole.Utilities;
using Bit.Core.AdminConsole.AbilitiesCache;
using Bit.Core.AdminConsole.OrganizationFeatures.Collections.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Collections.ModifyGroupAccess;
using Bit.Core.AdminConsole.OrganizationFeatures.Collections.ModifyUserAccess;
using Bit.Core.AdminConsole.Utilities.v2.Results;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;

namespace Bit.Api.AdminConsole.Endpoints.Handlers;

/// <summary>
/// Handles the unified <c>PUT organizations/{orgId}/collections/{id}</c> endpoint: updates a collection's
/// metadata alongside add/update/remove deltas for its user and group access. Each concern is authorized
/// against its own requirement, and the three writes commit as a single atomic transaction so a partial
/// failure never leaves a collection with metadata applied but access changes rejected (or vice versa).
/// </summary>
public class UpdateCollectionHandler(
ICollectionRepository collectionRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationAbilityCacheService organizationAbilityCacheService,
IAuthorizationService authorizationService,
ICurrentContext currentContext,
IUpdateCollectionCommand updateCollectionCommand,
IModifyCollectionUserAccessCommand modifyCollectionUserAccessCommand,
IModifyCollectionGroupAccessCommand modifyCollectionGroupAccessCommand)
{
public async Task<IResult> HandleAsync(
Guid orgId,
Guid id,
UpdateCollectionWithDeltaRequestModel model,
ClaimsPrincipal user)
{
// Resolve the target collection once with its full access details; the same tuple feeds both
// the update itself and the two access-resource authorization checks that follow.
var (collection, accessDetails) = await collectionRepository.GetByIdWithAccessAsync(id);
if (collection is null || collection.OrganizationId != orgId)
{
throw new NotFoundException();
}

await authorizationService.AuthorizeOrThrowAsync(user, collection, BulkCollectionOperations.Update);

var userResource = new CollectionUserAccessResource(collection, accessDetails);
await authorizationService.AuthorizeOrThrowAsync(user, userResource, CollectionUserOperations.Update);

var groupResource = new CollectionGroupAccessResource(collection, accessDetails);
await authorizationService.AuthorizeOrThrowAsync(user, groupResource, CollectionGroupOperations.Update);

var organizationAbility = await organizationAbilityCacheService.GetOrganizationAbilityAsync(orgId);
var allowAdminAccessToAllCollectionItems =
organizationAbility is { AllowAdminAccessToAllCollectionItems: true };

var callerOrganizationUser = currentContext.UserId.HasValue
? await organizationUserRepository.GetByOrganizationAsync(orgId, currentContext.UserId.Value)
: null;

var userTargets = new[] { new CollectionUserAccessTarget(collection, accessDetails) };
var groupTargets = new[] { new CollectionGroupAccessTarget(collection, accessDetails) };

var userRequest = new ModifyCollectionUserAccessRequest(
userTargets,
model.Users.Add.Select(u => u.ToSelectionReadOnly()).ToList(),
model.Users.Update.Select(u => u.ToSelectionReadOnly()).ToList(),
model.Users.Remove.ToList(),
callerOrganizationUser?.Id,
allowAdminAccessToAllCollectionItems);

var groupRequest = new ModifyCollectionGroupAccessRequest(
groupTargets,
model.Groups.Add.Select(g => g.ToSelectionReadOnly()).ToList(),
model.Groups.Update.Select(g => g.ToSelectionReadOnly()).ToList(),
model.Groups.Remove.ToList(),
callerOrganizationUser?.Id,
allowAdminAccessToAllCollectionItems);

// Bail before opening a transaction if either access delta already knows it's invalid — the
// access commands emit typed CommandResult errors instead of throwing, so we surface those
// as the HTTP response and skip persisting the metadata update.
CommandResult? failedResult = null;

using (var scope = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled))
{
// Apply Name/ExternalId first so subsequent access changes see the current revision. The
// update command itself throws BadRequestException on invalid input, which the endpoint
// filter translates into the shared ErrorResponseModel contract.
ApplyMetadataChanges(collection, model);
await updateCollectionCommand.UpdateAsync(collection);

var userResult = await modifyCollectionUserAccessCommand.ModifyAsync(userRequest);
if (userResult.IsError)
{
failedResult = userResult;
}
else
{
var groupResult = await modifyCollectionGroupAccessCommand.ModifyAsync(groupRequest);
if (groupResult.IsError)
{
failedResult = groupResult;
}
}

if (failedResult is null)
{
scope.Complete();
}
}

return failedResult is not null
? failedResult.ToHttpResult()
: TypedResults.NoContent();
}

private static void ApplyMetadataChanges(Bit.Core.Entities.Collection collection, UpdateCollectionWithDeltaRequestModel model)
{
// Mirror the existing MVC UpdateCollectionRequestModel behaviour: leave a default user collection's
// name untouched, and always accept an ExternalId (including clearing it).
if (string.IsNullOrEmpty(collection.DefaultUserCollectionEmail) && !string.IsNullOrWhiteSpace(model.Name))
{
collection.Name = model.Name;
}

collection.ExternalId = model.ExternalId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Bit.Api.Models.Request;

namespace Bit.Api.AdminConsole.Models.Request;

/// <summary>
/// Explicit add/update/remove changes to a collection's group access, rather than the full desired list.
/// </summary>
public class CollectionGroupAccessDeltaRequestModel
{
public IEnumerable<SelectionReadOnlyRequestModel> Add { get; init; } = [];
public IEnumerable<SelectionReadOnlyRequestModel> Update { get; init; } = [];
public IEnumerable<Guid> Remove { get; init; } = [];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Bit.Api.Models.Request;

namespace Bit.Api.AdminConsole.Models.Request;

/// <summary>
/// Explicit add/update/remove changes to a collection's user access, rather than the full desired list.
/// </summary>
public class CollectionUserAccessDeltaRequestModel
{
public IEnumerable<SelectionReadOnlyRequestModel> Add { get; init; } = [];
public IEnumerable<SelectionReadOnlyRequestModel> Update { get; init; } = [];
public IEnumerable<Guid> Remove { get; init; } = [];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Utilities;

namespace Bit.Api.AdminConsole.Models.Request;

/// <summary>
/// Request body for the unified <c>PUT organizations/{orgId}/collections/{id}</c> endpoint:
/// updates a collection's metadata alongside add/update/remove deltas for its user and group access.
/// </summary>
public class UpdateCollectionWithDeltaRequestModel
{
[EncryptedString]
[EncryptedStringLength(1000)]
public string? Name { get; init; }

[StringLength(300)]
public string? ExternalId { get; init; }

public CollectionUserAccessDeltaRequestModel Users { get; init; } = new();

public CollectionGroupAccessDeltaRequestModel Groups { get; init; } = new();
}
Loading
Loading