Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PM-16812] Shortcut duplicate group patch requests #5354

Merged
merged 28 commits into from
Feb 14, 2025
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7eb9e75
Refactor PatchGroupCommand
eliykat Jan 20, 2025
574c1c8
Move changes to vnext patch command
eliykat Jan 21, 2025
8132dd3
Detect duplicate add requests and return early
eliykat Jan 21, 2025
97659af
Tweak wording
eliykat Jan 21, 2025
adb6331
tweak interface
eliykat Jan 21, 2025
3e462c3
Add option to use HA replica
eliykat Jan 22, 2025
42c58ca
Merge remote-tracking branch 'origin/main' into ac/pm-16812/shortcut-โ€ฆ
eliykat Jan 30, 2025
450bc40
Add new repository method
eliykat Jan 31, 2025
a17beef
Merge remote-tracking branch 'origin/main' into ac/pm-16812/shortcut-โ€ฆ
eliykat Jan 31, 2025
cd88a87
Add db tests
eliykat Jan 31, 2025
675bc7d
Merge remote-tracking branch 'origin/main' into ac/pm-16812/shortcut-โ€ฆ
eliykat Feb 5, 2025
840469c
Use HashSet to avoid duplicate guids
eliykat Feb 5, 2025
a1765a9
Update comment
eliykat Feb 5, 2025
9f529af
Add null checks
eliykat Feb 5, 2025
d8036aa
Copy existing unit tests and make them pass
eliykat Feb 5, 2025
36c8840
Add tests for new behavior and strengthen assertions
eliykat Feb 5, 2025
26b1d6f
Move constants to scim application factory
eliykat Feb 5, 2025
b1e56ad
Copy tests and fix factory to use base implementation
eliykat Feb 5, 2025
30a103e
Bump migration script date
eliykat Feb 5, 2025
579032f
Address SonarQube feedback
eliykat Feb 6, 2025
1f2528b
Address Robert feedback
eliykat Feb 6, 2025
7401f74
Update comment
eliykat Feb 6, 2025
9176519
Merge branch 'main' into ac/pm-16812/shortcut-duplicate-group-patch-rโ€ฆ
eliykat Feb 6, 2025
0360f7a
Merge remote-tracking branch 'origin/main' into ac/pm-16812/shortcut-โ€ฆ
eliykat Feb 10, 2025
6363f9a
Merge branch 'main' into ac/pm-16812/shortcut-duplicate-group-patch-rโ€ฆ
eliykat Feb 11, 2025
a57257c
Merge commit 'ae9bb427a16ff2ff5d1ba3b62101ad7e55bec02e' into ac/pm-16โ€ฆ
eliykat Feb 13, 2025
0dde482
bump migration date
eliykat Feb 13, 2025
c01d0cb
Merge branch 'main' into ac/pm-16812/shortcut-duplicate-group-patch-rโ€ฆ
eliykat Feb 13, 2025
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
27 changes: 23 additions & 4 deletions bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
๏ปฟusing Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
๏ปฟusing Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models;
using Bit.Scim.Utilities;
Expand All @@ -22,28 +24,32 @@ public class GroupsController : Controller
private readonly IGetGroupsListQuery _getGroupsListQuery;
private readonly IDeleteGroupCommand _deleteGroupCommand;
private readonly IPatchGroupCommand _patchGroupCommand;
private readonly IPatchGroupCommandvNext _patchGroupCommandvNext;
private readonly IPostGroupCommand _postGroupCommand;
private readonly IPutGroupCommand _putGroupCommand;
private readonly ILogger<GroupsController> _logger;
private readonly IFeatureService _featureService;

public GroupsController(
IGroupRepository groupRepository,
IOrganizationRepository organizationRepository,
IGetGroupsListQuery getGroupsListQuery,
IDeleteGroupCommand deleteGroupCommand,
IPatchGroupCommand patchGroupCommand,
IPatchGroupCommandvNext patchGroupCommandvNext,
IPostGroupCommand postGroupCommand,
IPutGroupCommand putGroupCommand,
ILogger<GroupsController> logger)
IFeatureService featureService
)
{
_groupRepository = groupRepository;
_organizationRepository = organizationRepository;
_getGroupsListQuery = getGroupsListQuery;
_deleteGroupCommand = deleteGroupCommand;
_patchGroupCommand = patchGroupCommand;
_patchGroupCommandvNext = patchGroupCommandvNext;
_postGroupCommand = postGroupCommand;
_putGroupCommand = putGroupCommand;
_logger = logger;
_featureService = featureService;
}

[HttpGet("{id}")]
Expand Down Expand Up @@ -97,8 +103,21 @@ public async Task<IActionResult> Put(Guid organizationId, Guid id, [FromBody] Sc
[HttpPatch("{id}")]
public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model)
{
if (_featureService.IsEnabled(FeatureFlagKeys.ShortcutDuplicatePatchRequests))
{
var group = await _groupRepository.GetByIdAsync(id);
if (group == null || group.OrganizationId != organizationId)
{
throw new NotFoundException("Group not found.");
}

await _patchGroupCommandvNext.PatchGroupAsync(group, model);
return new NoContentResult();
}

var organization = await _organizationRepository.GetByIdAsync(organizationId);
await _patchGroupCommand.PatchGroupAsync(organization, id, model);

return new NoContentResult();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
๏ปฟusing Bit.Core.AdminConsole.Entities;
using Bit.Scim.Models;

namespace Bit.Scim.Groups.Interfaces;

public interface IPatchGroupCommandvNext
{
Task PatchGroupAsync(Group group, ScimPatchModel model);
}
170 changes: 170 additions & 0 deletions bitwarden_license/src/Scim/Groups/PatchGroupCommandvNext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
๏ปฟusing System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models;
using Bit.Scim.Utilities;

namespace Bit.Scim.Groups;

public class PatchGroupCommandvNext : IPatchGroupCommandvNext
{
private readonly IGroupRepository _groupRepository;
private readonly IGroupService _groupService;
private readonly IUpdateGroupCommand _updateGroupCommand;
private readonly ILogger<PatchGroupCommandvNext> _logger;
private readonly IOrganizationRepository _organizationRepository;

public PatchGroupCommandvNext(
IGroupRepository groupRepository,
IGroupService groupService,
IUpdateGroupCommand updateGroupCommand,
ILogger<PatchGroupCommandvNext> logger,
IOrganizationRepository organizationRepository)
{
_groupRepository = groupRepository;
_groupService = groupService;
_updateGroupCommand = updateGroupCommand;
_logger = logger;
_organizationRepository = organizationRepository;
}

public async Task PatchGroupAsync(Group group, ScimPatchModel model)
{
foreach (var operation in model.Operations)
{
await HandleOperationAsync(group, operation);
}
}

private async Task HandleOperationAsync(Group group, ScimPatchModel.OperationModel operation)
{
switch (operation.Op?.ToLowerInvariant())
{
// Replace a list of members
case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.Members:
{
var ids = GetOperationValueIds(operation.Value);
await _groupRepository.UpdateUsersAsync(group.Id, ids);
break;
}

// Replace group name from path
case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.DisplayName:
{
group.Name = operation.Value.GetString();
var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId);
if (organization == null)
{
throw new NotFoundException();

Check warning on line 64 in bitwarden_license/src/Scim/Groups/PatchGroupCommandvNext.cs

View check run for this annotation

Codecov / codecov/patch

bitwarden_license/src/Scim/Groups/PatchGroupCommandvNext.cs#L63-L64

Added lines #L63 - L64 were not covered by tests
}
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
break;
}

// Replace group name from value object
case PatchOps.Replace when
string.IsNullOrWhiteSpace(operation.Path) &&
operation.Value.TryGetProperty("displayName", out var displayNameProperty):
{
group.Name = displayNameProperty.GetString();
var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId);
if (organization == null)
{
throw new NotFoundException();

Check warning on line 79 in bitwarden_license/src/Scim/Groups/PatchGroupCommandvNext.cs

View check run for this annotation

Codecov / codecov/patch

bitwarden_license/src/Scim/Groups/PatchGroupCommandvNext.cs#L78-L79

Added lines #L78 - L79 were not covered by tests
}
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
break;
}

// Add a single member
case PatchOps.Add when
!string.IsNullOrWhiteSpace(operation.Path) &&
operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) &&
TryGetOperationPathId(operation.Path, out var addId):
{
await AddMembersAsync(group, [addId]);
break;
}

// Add a list of members
case PatchOps.Add when
operation.Path?.ToLowerInvariant() == PatchPaths.Members:
{
await AddMembersAsync(group, GetOperationValueIds(operation.Value));
break;
}

// Remove a single member
case PatchOps.Remove when
!string.IsNullOrWhiteSpace(operation.Path) &&
operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) &&
TryGetOperationPathId(operation.Path, out var removeId):
{
await _groupService.DeleteUserAsync(group, removeId, EventSystemUser.SCIM);
break;
}

// Remove a list of members
case PatchOps.Remove when
operation.Path?.ToLowerInvariant() == PatchPaths.Members:
{
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
foreach (var v in GetOperationValueIds(operation.Value))
{
orgUserIds.Remove(v);
}
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
break;
}

default:
{
_logger.LogWarning("Group patch operation not handled: {OperationOp}:{OperationPath}", operation.Op, operation.Path);
break;

Check warning on line 129 in bitwarden_license/src/Scim/Groups/PatchGroupCommandvNext.cs

View check run for this annotation

Codecov / codecov/patch

bitwarden_license/src/Scim/Groups/PatchGroupCommandvNext.cs#L127-L129

Added lines #L127 - L129 were not covered by tests
}
}
}

private async Task AddMembersAsync(Group group, HashSet<Guid> usersToAdd)
{
// Azure Entra ID is known to send redundant "add" requests for each existing member every time any member
// is removed. To avoid excessive load on the database, we check against the high availability replica and
// return early if they already exist.
var groupMembers = await _groupRepository.GetManyUserIdsByIdAsync(group.Id, useReadOnlyReplica: true);
if (usersToAdd.IsSubsetOf(groupMembers))
{
_logger.LogDebug("Ignoring duplicate SCIM request to add members {Members} to group {Group}", usersToAdd, group.Id);
return;
}

await _groupRepository.AddGroupUsersByIdAsync(group.Id, usersToAdd);
}
Comment on lines +134 to +147
Copy link
Member Author

Choose a reason for hiding this comment

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

The interesting part of the new command.


private static HashSet<Guid> GetOperationValueIds(JsonElement objArray)
{
var ids = new HashSet<Guid>();
foreach (var obj in objArray.EnumerateArray())
{
if (obj.TryGetProperty("value", out var valueProperty))
{
if (valueProperty.TryGetGuid(out var guid))
{
ids.Add(guid);
}
}
}
return ids;
}

private static bool TryGetOperationPathId(string path, out Guid pathId)
{
// Parse Guid from string like: members[value eq "{GUID}"}]
return Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out pathId);
}
}
13 changes: 13 additions & 0 deletions bitwarden_license/src/Scim/Utilities/ScimConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,16 @@ public static class ScimConstants
public const string Scim2SchemaUser = "urn:ietf:params:scim:schemas:core:2.0:User";
public const string Scim2SchemaGroup = "urn:ietf:params:scim:schemas:core:2.0:Group";
}

public static class PatchOps
{
public const string Replace = "replace";
public const string Add = "add";
public const string Remove = "remove";
}

public static class PatchPaths
{
public const string Members = "members";
public const string DisplayName = "displayname";
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public static class ScimServiceCollectionExtensions
public static void AddScimGroupCommands(this IServiceCollection services)
{
services.AddScoped<IPatchGroupCommand, PatchGroupCommand>();
services.AddScoped<IPatchGroupCommandvNext, PatchGroupCommandvNext>();
services.AddScoped<IPostGroupCommand, PostGroupCommand>();
services.AddScoped<IPutGroupCommand, PutGroupCommand>();
}
Expand Down
Loading
Loading