Skip to content

Commit 5308c22

Browse files
authored
Merge branch 'main' into ps/PM-15127-tighten-scope-secrets
2 parents 13cdd8f + fb0567b commit 5308c22

File tree

77 files changed

+12189
-1469
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

77 files changed

+12189
-1469
lines changed

.github/workflows/build.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,8 @@ jobs:
324324
uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
325325
with:
326326
sarif_file: ${{ steps.container-scan.outputs.sarif }}
327+
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
328+
ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
327329

328330
upload:
329331
name: Upload

.github/workflows/scan.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ jobs:
4949
uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
5050
with:
5151
sarif_file: cx_result.sarif
52+
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
53+
ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
5254

5355
quality:
5456
name: Quality scan

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<PropertyGroup>
44
<TargetFramework>net8.0</TargetFramework>
55

6-
<Version>2025.3.6</Version>
6+
<Version>2025.3.3</Version>
77

88
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
99
<ImplicitUsings>enable</ImplicitUsings>

bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
using Bit.Core;
2-
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
1+
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
32
using Bit.Core.AdminConsole.Repositories;
43
using Bit.Core.Enums;
54
using Bit.Core.Exceptions;
65
using Bit.Core.Repositories;
7-
using Bit.Core.Services;
86
using Bit.Scim.Groups.Interfaces;
97
using Bit.Scim.Models;
108
using Bit.Scim.Utilities;
@@ -24,32 +22,26 @@ public class GroupsController : Controller
2422
private readonly IGetGroupsListQuery _getGroupsListQuery;
2523
private readonly IDeleteGroupCommand _deleteGroupCommand;
2624
private readonly IPatchGroupCommand _patchGroupCommand;
27-
private readonly IPatchGroupCommandvNext _patchGroupCommandvNext;
2825
private readonly IPostGroupCommand _postGroupCommand;
2926
private readonly IPutGroupCommand _putGroupCommand;
30-
private readonly IFeatureService _featureService;
3127

3228
public GroupsController(
3329
IGroupRepository groupRepository,
3430
IOrganizationRepository organizationRepository,
3531
IGetGroupsListQuery getGroupsListQuery,
3632
IDeleteGroupCommand deleteGroupCommand,
3733
IPatchGroupCommand patchGroupCommand,
38-
IPatchGroupCommandvNext patchGroupCommandvNext,
3934
IPostGroupCommand postGroupCommand,
40-
IPutGroupCommand putGroupCommand,
41-
IFeatureService featureService
35+
IPutGroupCommand putGroupCommand
4236
)
4337
{
4438
_groupRepository = groupRepository;
4539
_organizationRepository = organizationRepository;
4640
_getGroupsListQuery = getGroupsListQuery;
4741
_deleteGroupCommand = deleteGroupCommand;
4842
_patchGroupCommand = patchGroupCommand;
49-
_patchGroupCommandvNext = patchGroupCommandvNext;
5043
_postGroupCommand = postGroupCommand;
5144
_putGroupCommand = putGroupCommand;
52-
_featureService = featureService;
5345
}
5446

5547
[HttpGet("{id}")]
@@ -103,21 +95,13 @@ public async Task<IActionResult> Put(Guid organizationId, Guid id, [FromBody] Sc
10395
[HttpPatch("{id}")]
10496
public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model)
10597
{
106-
if (_featureService.IsEnabled(FeatureFlagKeys.ShortcutDuplicatePatchRequests))
98+
var group = await _groupRepository.GetByIdAsync(id);
99+
if (group == null || group.OrganizationId != organizationId)
107100
{
108-
var group = await _groupRepository.GetByIdAsync(id);
109-
if (group == null || group.OrganizationId != organizationId)
110-
{
111-
throw new NotFoundException("Group not found.");
112-
}
113-
114-
await _patchGroupCommandvNext.PatchGroupAsync(group, model);
115-
return new NoContentResult();
101+
throw new NotFoundException("Group not found.");
116102
}
117103

118-
var organization = await _organizationRepository.GetByIdAsync(organizationId);
119-
await _patchGroupCommand.PatchGroupAsync(organization, id, model);
120-
104+
await _patchGroupCommand.PatchGroupAsync(group, model);
121105
return new NoContentResult();
122106
}
123107

bitwarden_license/src/Scim/Groups/Interfaces/IPatchGroupCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ namespace Bit.Scim.Groups.Interfaces;
55

66
public interface IPatchGroupCommand
77
{
8-
Task PatchGroupAsync(Organization organization, Guid id, ScimPatchModel model);
8+
Task PatchGroupAsync(Group group, ScimPatchModel model);
99
}

bitwarden_license/src/Scim/Groups/Interfaces/IPatchGroupCommandvNext.cs

Lines changed: 0 additions & 9 deletions
This file was deleted.

bitwarden_license/src/Scim/Groups/PatchGroupCommand.cs

Lines changed: 85 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
using Bit.Core.AdminConsole.Services;
66
using Bit.Core.Enums;
77
using Bit.Core.Exceptions;
8+
using Bit.Core.Repositories;
89
using Bit.Scim.Groups.Interfaces;
910
using Bit.Scim.Models;
11+
using Bit.Scim.Utilities;
1012

1113
namespace Bit.Scim.Groups;
1214

@@ -16,118 +18,137 @@ public class PatchGroupCommand : IPatchGroupCommand
1618
private readonly IGroupService _groupService;
1719
private readonly IUpdateGroupCommand _updateGroupCommand;
1820
private readonly ILogger<PatchGroupCommand> _logger;
21+
private readonly IOrganizationRepository _organizationRepository;
1922

2023
public PatchGroupCommand(
2124
IGroupRepository groupRepository,
2225
IGroupService groupService,
2326
IUpdateGroupCommand updateGroupCommand,
24-
ILogger<PatchGroupCommand> logger)
27+
ILogger<PatchGroupCommand> logger,
28+
IOrganizationRepository organizationRepository)
2529
{
2630
_groupRepository = groupRepository;
2731
_groupService = groupService;
2832
_updateGroupCommand = updateGroupCommand;
2933
_logger = logger;
34+
_organizationRepository = organizationRepository;
3035
}
3136

32-
public async Task PatchGroupAsync(Organization organization, Guid id, ScimPatchModel model)
37+
public async Task PatchGroupAsync(Group group, ScimPatchModel model)
3338
{
34-
var group = await _groupRepository.GetByIdAsync(id);
35-
if (group == null || group.OrganizationId != organization.Id)
39+
foreach (var operation in model.Operations)
3640
{
37-
throw new NotFoundException("Group not found.");
41+
await HandleOperationAsync(group, operation);
3842
}
43+
}
3944

40-
var operationHandled = false;
41-
foreach (var operation in model.Operations)
45+
private async Task HandleOperationAsync(Group group, ScimPatchModel.OperationModel operation)
46+
{
47+
switch (operation.Op?.ToLowerInvariant())
4248
{
43-
// Replace operations
44-
if (operation.Op?.ToLowerInvariant() == "replace")
45-
{
46-
// Replace a list of members
47-
if (operation.Path?.ToLowerInvariant() == "members")
49+
// Replace a list of members
50+
case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.Members:
4851
{
4952
var ids = GetOperationValueIds(operation.Value);
5053
await _groupRepository.UpdateUsersAsync(group.Id, ids);
51-
operationHandled = true;
54+
break;
5255
}
53-
// Replace group name from path
54-
else if (operation.Path?.ToLowerInvariant() == "displayname")
56+
57+
// Replace group name from path
58+
case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.DisplayName:
5559
{
5660
group.Name = operation.Value.GetString();
61+
var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId);
62+
if (organization == null)
63+
{
64+
throw new NotFoundException();
65+
}
5766
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
58-
operationHandled = true;
67+
break;
5968
}
60-
// Replace group name from value object
61-
else if (string.IsNullOrWhiteSpace(operation.Path) &&
62-
operation.Value.TryGetProperty("displayName", out var displayNameProperty))
69+
70+
// Replace group name from value object
71+
case PatchOps.Replace when
72+
string.IsNullOrWhiteSpace(operation.Path) &&
73+
operation.Value.TryGetProperty("displayName", out var displayNameProperty):
6374
{
6475
group.Name = displayNameProperty.GetString();
76+
var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId);
77+
if (organization == null)
78+
{
79+
throw new NotFoundException();
80+
}
6581
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
66-
operationHandled = true;
82+
break;
6783
}
68-
}
84+
6985
// Add a single member
70-
else if (operation.Op?.ToLowerInvariant() == "add" &&
86+
case PatchOps.Add when
7187
!string.IsNullOrWhiteSpace(operation.Path) &&
72-
operation.Path.ToLowerInvariant().StartsWith("members[value eq "))
73-
{
74-
var addId = GetOperationPathId(operation.Path);
75-
if (addId.HasValue)
88+
operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) &&
89+
TryGetOperationPathId(operation.Path, out var addId):
7690
{
77-
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
78-
orgUserIds.Add(addId.Value);
79-
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
80-
operationHandled = true;
91+
await AddMembersAsync(group, [addId]);
92+
break;
8193
}
82-
}
94+
8395
// Add a list of members
84-
else if (operation.Op?.ToLowerInvariant() == "add" &&
85-
operation.Path?.ToLowerInvariant() == "members")
86-
{
87-
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
88-
foreach (var v in GetOperationValueIds(operation.Value))
96+
case PatchOps.Add when
97+
operation.Path?.ToLowerInvariant() == PatchPaths.Members:
8998
{
90-
orgUserIds.Add(v);
99+
await AddMembersAsync(group, GetOperationValueIds(operation.Value));
100+
break;
91101
}
92-
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
93-
operationHandled = true;
94-
}
102+
95103
// Remove a single member
96-
else if (operation.Op?.ToLowerInvariant() == "remove" &&
104+
case PatchOps.Remove when
97105
!string.IsNullOrWhiteSpace(operation.Path) &&
98-
operation.Path.ToLowerInvariant().StartsWith("members[value eq "))
99-
{
100-
var removeId = GetOperationPathId(operation.Path);
101-
if (removeId.HasValue)
106+
operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) &&
107+
TryGetOperationPathId(operation.Path, out var removeId):
102108
{
103-
await _groupService.DeleteUserAsync(group, removeId.Value, EventSystemUser.SCIM);
104-
operationHandled = true;
109+
await _groupService.DeleteUserAsync(group, removeId, EventSystemUser.SCIM);
110+
break;
105111
}
106-
}
112+
107113
// Remove a list of members
108-
else if (operation.Op?.ToLowerInvariant() == "remove" &&
109-
operation.Path?.ToLowerInvariant() == "members")
110-
{
111-
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
112-
foreach (var v in GetOperationValueIds(operation.Value))
114+
case PatchOps.Remove when
115+
operation.Path?.ToLowerInvariant() == PatchPaths.Members:
113116
{
114-
orgUserIds.Remove(v);
117+
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
118+
foreach (var v in GetOperationValueIds(operation.Value))
119+
{
120+
orgUserIds.Remove(v);
121+
}
122+
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
123+
break;
124+
}
125+
126+
default:
127+
{
128+
_logger.LogWarning("Group patch operation not handled: {OperationOp}:{OperationPath}", operation.Op, operation.Path);
129+
break;
115130
}
116-
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
117-
operationHandled = true;
118-
}
119131
}
132+
}
120133

121-
if (!operationHandled)
134+
private async Task AddMembersAsync(Group group, HashSet<Guid> usersToAdd)
135+
{
136+
// Azure Entra ID is known to send redundant "add" requests for each existing member every time any member
137+
// is removed. To avoid excessive load on the database, we check against the high availability replica and
138+
// return early if they already exist.
139+
var groupMembers = await _groupRepository.GetManyUserIdsByIdAsync(group.Id, useReadOnlyReplica: true);
140+
if (usersToAdd.IsSubsetOf(groupMembers))
122141
{
123-
_logger.LogWarning("Group patch operation not handled: {0} : ",
124-
string.Join(", ", model.Operations.Select(o => $"{o.Op}:{o.Path}")));
142+
_logger.LogDebug("Ignoring duplicate SCIM request to add members {Members} to group {Group}", usersToAdd, group.Id);
143+
return;
125144
}
145+
146+
await _groupRepository.AddGroupUsersByIdAsync(group.Id, usersToAdd);
126147
}
127148

128-
private List<Guid> GetOperationValueIds(JsonElement objArray)
149+
private static HashSet<Guid> GetOperationValueIds(JsonElement objArray)
129150
{
130-
var ids = new List<Guid>();
151+
var ids = new HashSet<Guid>();
131152
foreach (var obj in objArray.EnumerateArray())
132153
{
133154
if (obj.TryGetProperty("value", out var valueProperty))
@@ -141,13 +162,9 @@ private List<Guid> GetOperationValueIds(JsonElement objArray)
141162
return ids;
142163
}
143164

144-
private Guid? GetOperationPathId(string path)
165+
private static bool TryGetOperationPathId(string path, out Guid pathId)
145166
{
146167
// Parse Guid from string like: members[value eq "{GUID}"}]
147-
if (Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out var id))
148-
{
149-
return id;
150-
}
151-
return null;
168+
return Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out pathId);
152169
}
153170
}

0 commit comments

Comments
 (0)