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
88 changes: 86 additions & 2 deletions src/Api/AdminConsole/Controllers/CollectionsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
using Bit.Api.AdminConsole.Models.Request;
using Bit.Api.AdminConsole.Models.Response;
using Bit.Api.Models.Response;
using Bit.Core;
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.Services;
using Bit.Core.Context;
using Bit.Core.Entities;
Expand All @@ -21,7 +25,7 @@ namespace Bit.Api.AdminConsole.Controllers;

[Route("organizations/{orgId}/collections")]
[Authorize("Application")]
public class CollectionsController : Controller
public class CollectionsController : BaseAdminConsoleController
{
private readonly ICollectionRepository _collectionRepository;
private readonly ICreateCollectionCommand _createCollectionCommand;
Expand All @@ -32,6 +36,11 @@ public class CollectionsController : Controller
private readonly ICurrentContext _currentContext;
private readonly IBulkAddCollectionAccessCommand _bulkAddCollectionAccessCommand;
private readonly IProviderService _providerService;
private readonly ICollectionAuthorizationService _collectionAuthorizationService;
private readonly IOrganizationAbilityCacheService _organizationAbilityCacheService;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IModifyCollectionUserAccessCommand _modifyCollectionUserAccessCommand;
private readonly IModifyCollectionGroupAccessCommand _modifyCollectionGroupAccessCommand;

public CollectionsController(
ICollectionRepository collectionRepository,
Expand All @@ -42,7 +51,12 @@ public CollectionsController(
IAuthorizationService authorizationService,
ICurrentContext currentContext,
IBulkAddCollectionAccessCommand bulkAddCollectionAccessCommand,
IProviderService providerService)
IProviderService providerService,
ICollectionAuthorizationService collectionAuthorizationService,
IOrganizationAbilityCacheService organizationAbilityCacheService,
IOrganizationUserRepository organizationUserRepository,
IModifyCollectionUserAccessCommand modifyCollectionUserAccessCommand,
IModifyCollectionGroupAccessCommand modifyCollectionGroupAccessCommand)
{
_collectionRepository = collectionRepository;
_createCollectionCommand = createCollectionCommand;
Expand All @@ -53,6 +67,11 @@ public CollectionsController(
_currentContext = currentContext;
_bulkAddCollectionAccessCommand = bulkAddCollectionAccessCommand;
_providerService = providerService;
_collectionAuthorizationService = collectionAuthorizationService;
_organizationAbilityCacheService = organizationAbilityCacheService;
_organizationUserRepository = organizationUserRepository;
_modifyCollectionUserAccessCommand = modifyCollectionUserAccessCommand;
_modifyCollectionGroupAccessCommand = modifyCollectionGroupAccessCommand;
}

[HttpGet("{id}")]
Expand Down Expand Up @@ -225,6 +244,71 @@ public async Task<CollectionResponseModel> PostPut(Guid orgId, Guid id, [FromBod
return await Put(orgId, id, model);
}

/// <summary>
/// Like <see cref="Put"/>, but takes add/update/remove deltas for access instead of a full replace list.
/// </summary>
[HttpPatch("{id}")]
[Bitwarden.Server.Sdk.Features.RequireFeature(FeatureFlagKeys.PM12473CollectionUserAccessEndpoint)]
public async Task<IResult> PatchWithDelta(Guid orgId, Guid id, [FromBody] UpdateCollectionWithDeltaRequestModel model)
{
var authorized = await _collectionAuthorizationService.AuthorizeUpdateAsync(orgId, id);
if (!authorized)
{
throw new NotFoundException();
}

// Persistence needs its own copy of the collection's current access details for the delta commands below.
var (collection, accessDetails) = await _collectionRepository.GetByIdWithAccessAsync(id);
if (collection is null || collection.OrganizationId != orgId)
{
throw new NotFoundException();
}

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

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;

if (string.IsNullOrEmpty(collection.DefaultUserCollectionEmail) && !string.IsNullOrWhiteSpace(model.Name))
{
collection.Name = model.Name;
}
collection.ExternalId = model.ExternalId;

await _updateCollectionCommand.UpdateAsync(collection);

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 userResult = await _modifyCollectionUserAccessCommand.ModifyAsync(userRequest);
if (userResult.IsError)
{
return Handle(userResult, _ => TypedResults.NoContent());
}

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);

var groupResult = await _modifyCollectionGroupAccessCommand.ModifyAsync(groupRequest);
return Handle(groupResult);
}

[HttpPost("bulk-access")]
public async Task PostBulkCollectionAccess(Guid orgId, [FromBody] BulkCollectionAccessRequestModel model)
{
Expand Down
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; set; } = [];
public IEnumerable<SelectionReadOnlyRequestModel> Update { get; set; } = [];
public IEnumerable<Guid> Remove { get; set; } = [];
}
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; set; } = [];
public IEnumerable<SelectionReadOnlyRequestModel> Update { get; set; } = [];
public IEnumerable<Guid> Remove { get; set; } = [];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
ο»Ώ#nullable enable
using System.ComponentModel.DataAnnotations;
using Bit.Core.Utilities;

namespace Bit.Api.AdminConsole.Models.Request;

/// <summary>
/// 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; set; }

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

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

public CollectionGroupAccessDeltaRequestModel Groups { get; set; } = new();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
ο»Ώusing Bit.Core.AdminConsole.Utilities.v2;

namespace Bit.Core.AdminConsole.OrganizationFeatures.Collections.ModifyGroupAccess;

public record DuplicateGroupId() : BadRequestError("A group id cannot be listed more than once within add or update.");
public record OverlappingGroupId() : BadRequestError("A group id cannot appear in more than one of add, update, or remove.");
public record CannotModifyDefaultUserCollectionAccess() : BadRequestError("You cannot modify group access on a collection with the type as DefaultUserCollection.");
public record GroupAlreadyHasAccess() : BadRequestError("Cannot add access for a group that already has access to this collection.");
public record GroupDoesNotHaveAccess() : BadRequestError("Cannot update access for a group that does not currently have access to this collection.");
public record GroupsNotFound() : BadRequestError("One or more groups do not exist.");
public record GroupsNotInOrganization() : BadRequestError("One or more groups do not belong to the same organization as the collection being assigned.");
public record NoRemainingManageAccess() : BadRequestError("At least one member or group must have can manage permission.");
public record InvalidManageAssociation() : BadRequestError("The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true.");
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
ο»Ώusing Bit.Core.AdminConsole.Utilities.v2.Results;

namespace Bit.Core.AdminConsole.OrganizationFeatures.Collections.ModifyGroupAccess;

public interface IModifyCollectionGroupAccessCommand
{
/// <summary>
/// Validates and applies an add/update/remove delta to one or more collections' group access.
/// </summary>
Task<CommandResult> ModifyAsync(ModifyCollectionGroupAccessRequest request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
ο»Ώusing Bit.Core.AdminConsole.Utilities.v2.Validation;

namespace Bit.Core.AdminConsole.OrganizationFeatures.Collections.ModifyGroupAccess;

/// <summary>
/// Checks whether an add/update/remove delta to collection group access may be applied.
/// </summary>
public interface IModifyCollectionGroupAccessValidator
{
Task<ValidationResult<ModifyCollectionGroupAccessRequest>> ValidateAsync(ModifyCollectionGroupAccessRequest request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
ο»Ώusing Bit.Core.AdminConsole.Utilities.v2.Results;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using OneOf.Types;

namespace Bit.Core.AdminConsole.OrganizationFeatures.Collections.ModifyGroupAccess;

public class ModifyCollectionGroupAccessCommand(
ICollectionRepository collectionRepository,
IModifyCollectionGroupAccessValidator validator,
IEventService eventService,
TimeProvider timeProvider) : IModifyCollectionGroupAccessCommand
{
public async Task<CommandResult> ModifyAsync(ModifyCollectionGroupAccessRequest request)
{
// Nothing to do, so skip saving and logging.
if (request.Add.Count == 0 && request.Update.Count == 0 && request.Remove.Count == 0)
{
return new None();
}

var validationResult = await validator.ValidateAsync(request);
if (validationResult.IsError)
{
return validationResult.AsError;
}

var revisionDate = timeProvider.GetUtcNow().UtcDateTime;
var upserts = request.Add.Concat(request.Update).ToList();

// Drop ids that aren't members, so we don't bump an unrelated group's revision date.
var existingGroupIds = request.Targets
.SelectMany(t => t.AccessDetails.Groups.Select(g => g.Id))
.ToHashSet();
var removeIds = request.Remove.Where(existingGroupIds.Contains).ToList();

var organizationId = request.Targets.First().Collection.OrganizationId;
var collectionIds = request.Targets.Select(t => t.Collection.Id).ToList();

await collectionRepository.ModifyGroupAccessAsync(organizationId, collectionIds, upserts, removeIds, revisionDate);

await eventService.LogCollectionEventsAsync(
request.Targets.Select(t => (t.Collection, EventType.Collection_Updated, (DateTime?)revisionDate)));

return new None();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
ο»Ώusing Bit.Core.Entities;
using Bit.Core.Models.Data;

namespace Bit.Core.AdminConsole.OrganizationFeatures.Collections.ModifyGroupAccess;

public record CollectionGroupAccessTarget(Collection Collection, CollectionAccessDetails AccessDetails);

public record ModifyCollectionGroupAccessRequest(
IReadOnlyCollection<CollectionGroupAccessTarget> Targets,
IReadOnlyCollection<CollectionAccessSelection> Add,
IReadOnlyCollection<CollectionAccessSelection> Update,
IReadOnlyCollection<Guid> Remove,
Guid? PerformingOrganizationUserId,
bool AllowAdminAccessToAllCollectionItems);
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
ο»Ώusing Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Utilities.v2.Validation;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers;

namespace Bit.Core.AdminConsole.OrganizationFeatures.Collections.ModifyGroupAccess;

public class ModifyCollectionGroupAccessValidator(IGroupRepository groupRepository)
: IModifyCollectionGroupAccessValidator
{
public async Task<ValidationResult<ModifyCollectionGroupAccessRequest>> ValidateAsync(
ModifyCollectionGroupAccessRequest request)
{
if (HasDuplicateIds(request.Add) || HasDuplicateIds(request.Update))
{
return Invalid(request, new DuplicateGroupId());
}

var addIds = request.Add.Select(a => a.Id).ToHashSet();
var updateIds = request.Update.Select(u => u.Id).ToHashSet();
var removeIds = request.Remove.ToHashSet();

if (addIds.Overlaps(updateIds) || addIds.Overlaps(removeIds) || updateIds.Overlaps(removeIds))
{
return Invalid(request, new OverlappingGroupId());
}

if (request.Add.Concat(request.Update).Any(s => s.Manage && (s.ReadOnly || s.HidePasswords)))
{
return Invalid(request, new InvalidManageAssociation());
}

if (request.Targets.Any(t => t.Collection.Type == CollectionType.DefaultUserCollection))
{
return Invalid(request, new CannotModifyDefaultUserCollectionAccess());
}

// Only meaningful for a single collection: across several, a group may already have access to one
// target but not another.
if (request.Targets.Count == 1)
{
var existingIds = request.Targets.Single().AccessDetails.Groups.Select(g => g.Id).ToHashSet();
if (addIds.Any(existingIds.Contains))
{
return Invalid(request, new GroupAlreadyHasAccess());
}

if (updateIds.Any(id => !existingIds.Contains(id)))
{
return Invalid(request, new GroupDoesNotHaveAccess());
}
}

var upsertIds = addIds.Concat(updateIds).ToList();
if (upsertIds.Count > 0)
{
var organizationId = request.Targets.First().Collection.OrganizationId;
var groups = await groupRepository.GetManyByManyIds(upsertIds);
if (groups.Count != upsertIds.Count)
{
return Invalid(request, new GroupsNotFound());
}

if (groups.Any(g => g.OrganizationId != organizationId))
{
return Invalid(request, new GroupsNotInOrganization());
}
}

if (!request.AllowAdminAccessToAllCollectionItems
&& request.Targets.Any(t => !HasRemainingManageAccess(t, request, removeIds)))
{
return Invalid(request, new NoRemainingManageAccess());
}

return Valid(request);
}

private static bool HasRemainingManageAccess(
CollectionGroupAccessTarget target, ModifyCollectionGroupAccessRequest request, HashSet<Guid> removeIds)
{
if (target.AccessDetails.Users.Any(u => u.Manage))
{
return true;
}

var existingIds = target.AccessDetails.Groups.Select(g => g.Id).ToHashSet();
var updatedById = request.Update.ToDictionary(u => u.Id);
var finalGroups = target.AccessDetails.Groups
.Where(g => !removeIds.Contains(g.Id))
.Select(g => updatedById.GetValueOrDefault(g.Id, g))
.Concat(request.Add)
// An Update entry grants access on targets the group isn't a member of, so it counts as an Add here.
.Concat(request.Update.Where(u => !existingIds.Contains(u.Id)));

return finalGroups.Any(g => g.Manage);
}

private static bool HasDuplicateIds(IReadOnlyCollection<CollectionAccessSelection> selections)
{
var ids = selections.Select(s => s.Id).ToList();
return ids.Count != ids.Distinct().Count();
}
}
Loading
Loading