5
5
using Bit . Core . AdminConsole . Services ;
6
6
using Bit . Core . Enums ;
7
7
using Bit . Core . Exceptions ;
8
+ using Bit . Core . Repositories ;
8
9
using Bit . Scim . Groups . Interfaces ;
9
10
using Bit . Scim . Models ;
11
+ using Bit . Scim . Utilities ;
10
12
11
13
namespace Bit . Scim . Groups ;
12
14
@@ -16,118 +18,137 @@ public class PatchGroupCommand : IPatchGroupCommand
16
18
private readonly IGroupService _groupService ;
17
19
private readonly IUpdateGroupCommand _updateGroupCommand ;
18
20
private readonly ILogger < PatchGroupCommand > _logger ;
21
+ private readonly IOrganizationRepository _organizationRepository ;
19
22
20
23
public PatchGroupCommand (
21
24
IGroupRepository groupRepository ,
22
25
IGroupService groupService ,
23
26
IUpdateGroupCommand updateGroupCommand ,
24
- ILogger < PatchGroupCommand > logger )
27
+ ILogger < PatchGroupCommand > logger ,
28
+ IOrganizationRepository organizationRepository )
25
29
{
26
30
_groupRepository = groupRepository ;
27
31
_groupService = groupService ;
28
32
_updateGroupCommand = updateGroupCommand ;
29
33
_logger = logger ;
34
+ _organizationRepository = organizationRepository ;
30
35
}
31
36
32
- public async Task PatchGroupAsync ( Organization organization , Guid id , ScimPatchModel model )
37
+ public async Task PatchGroupAsync ( Group group , ScimPatchModel model )
33
38
{
34
- var group = await _groupRepository . GetByIdAsync ( id ) ;
35
- if ( group == null || group . OrganizationId != organization . Id )
39
+ foreach ( var operation in model . Operations )
36
40
{
37
- throw new NotFoundException ( "Group not found." ) ;
41
+ await HandleOperationAsync ( group , operation ) ;
38
42
}
43
+ }
39
44
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 ( ) )
42
48
{
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 :
48
51
{
49
52
var ids = GetOperationValueIds ( operation . Value ) ;
50
53
await _groupRepository . UpdateUsersAsync ( group . Id , ids ) ;
51
- operationHandled = true ;
54
+ break ;
52
55
}
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 :
55
59
{
56
60
group . Name = operation . Value . GetString ( ) ;
61
+ var organization = await _organizationRepository . GetByIdAsync ( group . OrganizationId ) ;
62
+ if ( organization == null )
63
+ {
64
+ throw new NotFoundException ( ) ;
65
+ }
57
66
await _updateGroupCommand . UpdateGroupAsync ( group , organization , EventSystemUser . SCIM ) ;
58
- operationHandled = true ;
67
+ break ;
59
68
}
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 ) :
63
74
{
64
75
group . Name = displayNameProperty . GetString ( ) ;
76
+ var organization = await _organizationRepository . GetByIdAsync ( group . OrganizationId ) ;
77
+ if ( organization == null )
78
+ {
79
+ throw new NotFoundException ( ) ;
80
+ }
65
81
await _updateGroupCommand . UpdateGroupAsync ( group , organization , EventSystemUser . SCIM ) ;
66
- operationHandled = true ;
82
+ break ;
67
83
}
68
- }
84
+
69
85
// Add a single member
70
- else if ( operation . Op ? . ToLowerInvariant ( ) == "add" &&
86
+ case PatchOps . Add when
71
87
! 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 ) :
76
90
{
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 ;
81
93
}
82
- }
94
+
83
95
// 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 :
89
98
{
90
- orgUserIds . Add ( v ) ;
99
+ await AddMembersAsync ( group , GetOperationValueIds ( operation . Value ) ) ;
100
+ break ;
91
101
}
92
- await _groupRepository . UpdateUsersAsync ( group . Id , orgUserIds ) ;
93
- operationHandled = true ;
94
- }
102
+
95
103
// Remove a single member
96
- else if ( operation . Op ? . ToLowerInvariant ( ) == "remove" &&
104
+ case PatchOps . Remove when
97
105
! 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 ) :
102
108
{
103
- await _groupService . DeleteUserAsync ( group , removeId . Value , EventSystemUser . SCIM ) ;
104
- operationHandled = true ;
109
+ await _groupService . DeleteUserAsync ( group , removeId , EventSystemUser . SCIM ) ;
110
+ break ;
105
111
}
106
- }
112
+
107
113
// 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 :
113
116
{
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 ;
115
130
}
116
- await _groupRepository . UpdateUsersAsync ( group . Id , orgUserIds ) ;
117
- operationHandled = true ;
118
- }
119
131
}
132
+ }
120
133
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 ) )
122
141
{
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 ;
125
144
}
145
+
146
+ await _groupRepository . AddGroupUsersByIdAsync ( group . Id , usersToAdd ) ;
126
147
}
127
148
128
- private List < Guid > GetOperationValueIds ( JsonElement objArray )
149
+ private static HashSet < Guid > GetOperationValueIds ( JsonElement objArray )
129
150
{
130
- var ids = new List < Guid > ( ) ;
151
+ var ids = new HashSet < Guid > ( ) ;
131
152
foreach ( var obj in objArray . EnumerateArray ( ) )
132
153
{
133
154
if ( obj . TryGetProperty ( "value" , out var valueProperty ) )
@@ -141,13 +162,9 @@ private List<Guid> GetOperationValueIds(JsonElement objArray)
141
162
return ids ;
142
163
}
143
164
144
- private Guid ? GetOperationPathId ( string path )
165
+ private static bool TryGetOperationPathId ( string path , out Guid pathId )
145
166
{
146
167
// 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 ) ;
152
169
}
153
170
}
0 commit comments