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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public static void AddAdminConsoleAuthorizationHandlers(this IServiceCollection
ServiceDescriptor.Scoped<IAuthorizationHandler, BulkCollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, CollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, CollectionUserAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, CollectionGroupAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, OrganizationCollectionManagementAccessHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, OrgUserLinkedToUserIdHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, OrganizationRequirementHandler>(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
ο»Ώusing Bit.Core.Entities;
using Bit.Core.Models.Data;

namespace Bit.Api.AdminConsole.Authorization.Collections;

public record CollectionGroupAccessResource(
Collection Collection,
CollectionAccessDetails AccessDetails);
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
ο»Ώ#nullable enable
using Bit.Core.AdminConsole.AbilitiesCache;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;

namespace Bit.Api.AdminConsole.Authorization.Collections;

/// <summary>
/// Checks whether the caller can change one or more groups' access to one or more collections.
/// All the collections must be in the same organization.
/// </summary>
public class CollectionGroupAuthorizationHandler
: BulkAuthorizationHandler<CollectionGroupOperationRequirement, CollectionGroupAccessResource>
{
private readonly ICurrentContext _currentContext;
private readonly ICollectionRepository _collectionRepository;
private readonly IOrganizationAbilityCacheService _organizationAbilityCacheService;
private HashSet<Guid>? _managedCollectionIds;

public CollectionGroupAuthorizationHandler(
ICurrentContext currentContext,
ICollectionRepository collectionRepository,
IOrganizationAbilityCacheService organizationAbilityCacheService)
{
_currentContext = currentContext;
_collectionRepository = collectionRepository;
_organizationAbilityCacheService = organizationAbilityCacheService;
}

protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
CollectionGroupOperationRequirement requirement, ICollection<CollectionGroupAccessResource> resources)
{
if (resources.Count == 0)
{
return;
}

if (!_currentContext.UserId.HasValue)
{
return;
}

var organizationId = resources.First().Collection.OrganizationId;
if (resources.Any(r => r.Collection.OrganizationId != organizationId))
{
throw new BadRequestException("Requested collections must belong to the same organization.");
}

var organization = _currentContext.GetOrganization(organizationId);
var organizationAbility = await _organizationAbilityCacheService.GetOrganizationAbilityAsync(organizationId);
var allowAdminAccessToAllCollectionItems = organizationAbility is { AllowAdminAccessToAllCollectionItems: true };

var authorized = true;
foreach (var resource in resources)
{
var callerManagesCollection = await CallerManagesCollectionAsync(resource.Collection.Id);
if (!CollectionGroupAuthorizationRules.CanModifyGroupAccess(
resource.AccessDetails, organization, allowAdminAccessToAllCollectionItems, callerManagesCollection))
{
authorized = false;
break;
}
}

if (!authorized)
{
authorized = await _currentContext.ProviderUserForOrgAsync(organizationId);
}

if (authorized)
{
context.Succeed(requirement);
}
}

private async Task<bool> CallerManagesCollectionAsync(Guid collectionId)
{
if (_managedCollectionIds == null)
{
var callerCollections = await _collectionRepository.GetManyByUserIdAsync(_currentContext.UserId!.Value);
_managedCollectionIds = callerCollections.Where(c => c.Manage).Select(c => c.Id).ToHashSet();
}

return _managedCollectionIds.Contains(collectionId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
ο»Ώ#nullable enable
using Bit.Core.Context;
using Bit.Core.Models.Data;

namespace Bit.Api.AdminConsole.Authorization.Collections;

/// <summary>
/// Decides whether a user can change a group's access to a collection.
/// </summary>
public static class CollectionGroupAuthorizationRules
{
/// <summary>
/// Returns true if the acting user can add, change, or remove a group's access to this collection.
/// <paramref name="callerManagesCollection"/> covers <c>Manage</c> granted directly or through a group.
/// </summary>
public static bool CanModifyGroupAccess(
CollectionAccessDetails accessDetails,
CurrentContextOrganization? organization,
bool allowAdminAccessToAllCollectionItems,
bool callerManagesCollection)
{
if (organization is { Permissions.EditAnyCollection: true })
{
return true;
}

if (allowAdminAccessToAllCollectionItems && organization is { Permissions.ManageUsers: true })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ CRITICAL: Group-access rule checks Permissions.ManageUsers instead of Permissions.ManageGroups

Details and fix

This looks like a copy-paste carry-over from CollectionUserAuthorizationRules.CanModifyUserAccess (the two classes are otherwise byte-identical). The established rule for modifying a group's collection access is ManageGroups β€” see BulkCollectionAuthorizationHandler:

private async Task<bool> CanUpdateGroupAccessAsync(...)
{
    if (await AllowAdminAccessToAllCollectionItems(org) && org?.Permissions.ManageGroups == true)

As written, once PM-41448 wires up the endpoint:

  • a Custom user with ManageUsers but not ManageGroups gains the ability to change group access (privilege escalation relative to the existing ModifyGroupAccess rule);
  • a Custom user with ManageGroups loses it (regression).

Suggested change:

if (allowAdminAccessToAllCollectionItems && organization is { Permissions.ManageGroups: true })

CollectionGroupAuthorizationRulesTests.CanModifyGroupAccess_WithManageUsersPermission_AllowAdminAccessTrue_Success and ..._CustomUserWithManageUsersPermission_AllowAdminAccessFalse_Failure were also copied over and will need renaming/retargeting to ManageGroups.

Related: CollectionGroupAuthorizationHandler, CollectionGroupAccessResource, and CollectionGroupAuthorizationRules are exact duplicates of their CollectionUser* counterparts apart from type names. Worth considering a shared generic base (e.g. parameterised on the permission to check) so a single authorization rule change can't silently diverge between the two paths again.

{
return true;
}

if (allowAdminAccessToAllCollectionItems && organization is { IsAdminOrOwner: true })
{
return true;
}

if (callerManagesCollection)
{
return true;
}

// Owners and Admins can still manage an orphaned collection even when
// AllowAdminAccessToAllCollectionItems is off.
if (organization is not { IsAdminOrOwner: true })
{
return false;
}

var isOrphaned = !accessDetails.Users.Any(u => u.Manage) && !accessDetails.Groups.Any(g => g.Manage);
return isOrphaned;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
ο»Ώusing Microsoft.AspNetCore.Authorization.Infrastructure;

namespace Bit.Api.AdminConsole.Authorization.Collections;

public class CollectionGroupOperationRequirement : OperationAuthorizationRequirement { }

public static class CollectionGroupOperations
{
public static readonly CollectionGroupOperationRequirement Create = new() { Name = nameof(Create) };
public static readonly CollectionGroupOperationRequirement Update = new() { Name = nameof(Update) };
public static readonly CollectionGroupOperationRequirement Delete = new() { Name = nameof(Delete) };
}
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,14 @@
ο»Ώusing Bit.Core.AdminConsole.Utilities.v2.Results;

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

/// <summary>
/// Applies an add/update/remove delta to one or more collections' group access.
/// </summary>
public interface IModifyCollectionGroupAccessCommand
{
/// <summary>
/// Validates and persists the delta.
/// </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