@@ -2,17 +2,27 @@ package idpsync
2
2
3
3
import (
4
4
"context"
5
+ "encoding/json"
5
6
6
7
"github.com/golang-jwt/jwt/v4"
8
+ "github.com/google/uuid"
9
+ "golang.org/x/exp/slices"
10
+ "golang.org/x/xerrors"
7
11
12
+ "cdr.dev/slog"
8
13
"github.com/coder/coder/v2/coderd/database"
9
14
"github.com/coder/coder/v2/coderd/database/dbauthz"
15
+ "github.com/coder/coder/v2/coderd/rbac"
16
+ "github.com/coder/coder/v2/coderd/rbac/rolestore"
10
17
"github.com/coder/coder/v2/coderd/runtimeconfig"
11
18
)
12
19
13
20
type RoleParams struct {
14
21
// SyncEnabled if false will skip syncing the user's roles
15
- SyncEnabled bool
22
+ SyncEnabled bool
23
+ SyncSiteWide bool
24
+ SiteWideRoles []string
25
+ // MergedClaims are passed to the organization level for syncing
16
26
MergedClaims jwt.MapClaims
17
27
}
18
28
@@ -26,7 +36,8 @@ func (s AGPLIDPSync) RoleSyncSettings() runtimeconfig.RuntimeEntry[*RoleSyncSett
26
36
27
37
func (s AGPLIDPSync ) ParseRoleClaims (_ context.Context , _ jwt.MapClaims ) (RoleParams , * HTTPError ) {
28
38
return RoleParams {
29
- SyncEnabled : s .RoleSyncEnabled (),
39
+ SyncEnabled : s .RoleSyncEnabled (),
40
+ SyncSiteWide : false ,
30
41
}, nil
31
42
}
32
43
@@ -39,9 +50,196 @@ func (s AGPLIDPSync) SyncRoles(ctx context.Context, db database.Store, user data
39
50
// nolint:gocritic // all syncing is done as a system user
40
51
ctx = dbauthz .AsSystemRestricted (ctx )
41
52
53
+ err := db .InTx (func (tx database.Store ) error {
54
+ if params .SyncSiteWide {
55
+ if err := s .syncSiteWideRoles (ctx , tx , user , params ); err != nil {
56
+ return err
57
+ }
58
+ }
59
+
60
+ // sync roles per organization
61
+ orgMemberships , err := tx .OrganizationMembers (ctx , database.OrganizationMembersParams {
62
+ UserID : user .ID ,
63
+ })
64
+ if err != nil {
65
+ return xerrors .Errorf ("get organizations by user id: %w" , err )
66
+ }
67
+
68
+ // Sync for each organization
69
+ // If a key for a given org exists in the map, the user's roles will be
70
+ // updated to the value of that key.
71
+ expectedRoles := make (map [uuid.UUID ][]rbac.RoleIdentifier )
72
+ existingRoles := make (map [uuid.UUID ][]string )
73
+ allExpected := make ([]rbac.RoleIdentifier , 0 )
74
+ for _ , member := range orgMemberships {
75
+ orgID := member .OrganizationMember .OrganizationID
76
+ orgResolver := s .Manager .OrganizationResolver (tx , orgID )
77
+ settings , err := s .RoleSyncSettings ().Resolve (ctx , orgResolver )
78
+ if err != nil {
79
+ if ! xerrors .Is (err , runtimeconfig .ErrEntryNotFound ) {
80
+ return xerrors .Errorf ("resolve group sync settings: %w" , err )
81
+ }
82
+ // No entry means no role syncing for this organization
83
+ continue
84
+ }
85
+ if settings .Field == "" {
86
+ // Explicitly disabled role sync for this organization
87
+ continue
88
+ }
89
+
90
+ existingRoles [orgID ] = member .OrganizationMember .Roles
91
+ orgRoleClaims , err := s .RolesFromClaim (settings .Field , params .MergedClaims )
92
+ if err != nil {
93
+ s .Logger .Error (ctx , "failed to parse roles from claim" ,
94
+ slog .F ("field" , settings .Field ),
95
+ slog .F ("organization_id" , orgID ),
96
+ slog .F ("user_id" , user .ID ),
97
+ slog .F ("username" , user .Username ),
98
+ slog .Error (err ),
99
+ )
100
+
101
+ // Failing role sync should reset a user's roles.
102
+ expectedRoles [orgID ] = []rbac.RoleIdentifier {}
103
+
104
+ // Do not return an error, because that would prevent a user
105
+ // from logging in. A misconfigured organization should not
106
+ // stop a user from logging into the site.
107
+ continue
108
+ }
109
+
110
+ expected := make ([]rbac.RoleIdentifier , 0 , len (orgRoleClaims ))
111
+ for _ , role := range orgRoleClaims {
112
+ if mappedRoles , ok := settings .Mapping [role ]; ok {
113
+ for _ , mappedRole := range mappedRoles {
114
+ expected = append (expected , rbac.RoleIdentifier {OrganizationID : orgID , Name : mappedRole })
115
+ }
116
+ continue
117
+ }
118
+ expected = append (expected , rbac.RoleIdentifier {OrganizationID : orgID , Name : role })
119
+ }
120
+
121
+ expectedRoles [orgID ] = expected
122
+ allExpected = append (allExpected , expected ... )
123
+ }
124
+
125
+ // Now mass sync the user's org membership roles.
126
+ validRoles , err := rolestore .Expand (ctx , tx , allExpected )
127
+ if err != nil {
128
+ return xerrors .Errorf ("expand roles: %w" , err )
129
+ }
130
+ validMap := make (map [string ]struct {}, len (validRoles ))
131
+ for _ , validRole := range validRoles {
132
+ validMap [validRole .Identifier .UniqueName ()] = struct {}{}
133
+ }
134
+
135
+ // For each org, do the SQL query to update the user's roles.
136
+ // TODO: Would be better to batch all these into a single SQL query.
137
+ for orgID , roles := range expectedRoles {
138
+ validExpected := make ([]string , 0 , len (roles ))
139
+ for _ , role := range roles {
140
+ if _ , ok := validMap [role .UniqueName ()]; ok {
141
+ validExpected = append (validExpected , role .Name )
142
+ }
143
+ }
144
+ // Always add the member role to the user.
145
+ validExpected = append (validExpected , rbac .RoleOrgMember ())
146
+
147
+ // Is there a difference between the expected roles and the existing roles?
148
+ if ! slices .Equal (existingRoles [orgID ], validExpected ) {
149
+ _ , err = tx .UpdateMemberRoles (ctx , database.UpdateMemberRolesParams {
150
+ GrantedRoles : validExpected ,
151
+ UserID : user .ID ,
152
+ OrgID : orgID ,
153
+ })
154
+ if err != nil {
155
+ return xerrors .Errorf ("update member roles(%s): %w" , user .ID .String (), err )
156
+ }
157
+ }
158
+ }
159
+ return nil
160
+ }, nil )
161
+ if err != nil {
162
+ return xerrors .Errorf ("sync user roles(%s): %w" , user .ID .String (), err )
163
+ }
164
+
42
165
return nil
43
166
}
44
167
168
+ // resetUserOrgRoles will reset the user's roles for a specific organization.
169
+ // It does not remove them as a member from the organization.
170
+ func (s AGPLIDPSync ) resetUserOrgRoles (ctx context.Context , tx database.Store , member database.OrganizationMembersRow , orgID uuid.UUID ) error {
171
+ withoutMember := slices .DeleteFunc (member .OrganizationMember .Roles , func (s string ) bool {
172
+ return s == rbac .RoleOrgMember ()
173
+ })
174
+ // If the user has no roles, then skip doing any database request.
175
+ if len (withoutMember ) == 0 {
176
+ return nil
177
+ }
178
+
179
+ _ , err := tx .UpdateMemberRoles (ctx , database.UpdateMemberRolesParams {
180
+ GrantedRoles : []string {},
181
+ UserID : member .OrganizationMember .UserID ,
182
+ OrgID : orgID ,
183
+ })
184
+ if err != nil {
185
+ return xerrors .Errorf ("zero out member roles(%s): %w" , member .OrganizationMember .UserID .String (), err )
186
+ }
187
+ return nil
188
+ }
189
+
190
+ func (s AGPLIDPSync ) syncSiteWideRoles (ctx context.Context , tx database.Store , user database.User , params RoleParams ) error {
191
+ // Apply site wide roles to a user.
192
+ // ignored is the list of roles that are not valid Coder roles and will
193
+ // be skipped.
194
+ ignored := make ([]string , 0 )
195
+ filtered := make ([]string , 0 , len (params .SiteWideRoles ))
196
+ for _ , role := range params .SiteWideRoles {
197
+ // Because we are only syncing site wide roles, we intentionally will always
198
+ // omit 'OrganizationID' from the RoleIdentifier.
199
+ if _ , err := rbac .RoleByName (rbac.RoleIdentifier {Name : role }); err == nil {
200
+ filtered = append (filtered , role )
201
+ } else {
202
+ ignored = append (ignored , role )
203
+ }
204
+ }
205
+ if len (ignored ) > 0 {
206
+ s .Logger .Debug (ctx , "OIDC roles ignored in assignment" ,
207
+ slog .F ("ignored" , ignored ),
208
+ slog .F ("assigned" , filtered ),
209
+ slog .F ("user_id" , user .ID ),
210
+ slog .F ("username" , user .Username ),
211
+ )
212
+ }
213
+
214
+ _ , err := tx .UpdateUserRoles (ctx , database.UpdateUserRolesParams {
215
+ GrantedRoles : filtered ,
216
+ ID : user .ID ,
217
+ })
218
+ if err != nil {
219
+ return xerrors .Errorf ("set site wide roles: %w" , err )
220
+ }
221
+ return nil
222
+ }
223
+
224
+ func (s AGPLIDPSync ) RolesFromClaim (field string , claims jwt.MapClaims ) ([]string , error ) {
225
+ rolesRow , ok := claims [field ]
226
+ if ! ok {
227
+ // If no claim is provided than we can assume the user is just
228
+ // a member. This is because there is no way to tell the difference
229
+ // between []string{} and nil for OIDC claims. IDPs omit claims
230
+ // if they are empty ([]string{}).
231
+ // Use []interface{}{} so the next typecast works.
232
+ rolesRow = []interface {}{}
233
+ }
234
+
235
+ parsedRoles , err := ParseStringSliceClaim (rolesRow )
236
+ if err != nil {
237
+ return nil , xerrors .Errorf ("failed to parse roles from claim: %w" , err )
238
+ }
239
+
240
+ return parsedRoles , nil
241
+ }
242
+
45
243
type RoleSyncSettings struct {
46
244
// Field selects the claim field to be used as the created user's
47
245
// groups. If the group field is the empty string, then no group updates
@@ -50,3 +248,11 @@ type RoleSyncSettings struct {
50
248
// Mapping maps from an OIDC group --> Coder organization role
51
249
Mapping map [string ][]string `json:"mapping"`
52
250
}
251
+
252
+ func (s * RoleSyncSettings ) Set (v string ) error {
253
+ return json .Unmarshal ([]byte (v ), s )
254
+ }
255
+
256
+ func (s * RoleSyncSettings ) String () string {
257
+ return runtimeconfig .JSONString (s )
258
+ }
0 commit comments