Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 81feea5

Browse files
committed
feat: oidc user role sync
User roles come from oidc claims. Prevent manual user role changes if set.
1 parent 6318c4c commit 81feea5

File tree

8 files changed

+155
-3
lines changed

8 files changed

+155
-3
lines changed

cli/server.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,9 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
595595
IgnoreUserInfo: cfg.OIDC.IgnoreUserInfo.Value(),
596596
GroupField: cfg.OIDC.GroupField.String(),
597597
GroupMapping: cfg.OIDC.GroupMapping.Value,
598+
UserRoleField: cfg.OIDC.UserRoleField.String(),
599+
UserRoleMapping: cfg.OIDC.UserRoleMapping.Value,
600+
UserRolesDefault: cfg.OIDC.UserRolesDefault.GetSlice(),
598601
SignInText: cfg.OIDC.SignInText.String(),
599602
IconURL: cfg.OIDC.IconURL.String(),
600603
IgnoreEmailVerified: cfg.OIDC.IgnoreEmailVerified.Value(),

cli/server_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,6 +1095,8 @@ func TestServer(t *testing.T) {
10951095
require.False(t, deploymentConfig.Values.OIDC.IgnoreUserInfo.Value())
10961096
require.Empty(t, deploymentConfig.Values.OIDC.GroupField.Value())
10971097
require.Empty(t, deploymentConfig.Values.OIDC.GroupMapping.Value)
1098+
require.Empty(t, deploymentConfig.Values.OIDC.UserRoleField.Value())
1099+
require.Empty(t, deploymentConfig.Values.OIDC.UserRoleMapping.Value)
10981100
require.Equal(t, "OpenID Connect", deploymentConfig.Values.OIDC.SignInText.Value())
10991101
require.Empty(t, deploymentConfig.Values.OIDC.IconURL.Value())
11001102
})

coderd/coderd.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ type Options struct {
124124
DERPMap *tailcfg.DERPMap
125125
SwaggerEndpoint bool
126126
SetUserGroups func(ctx context.Context, tx database.Store, userID uuid.UUID, groupNames []string) error
127+
SetUserSiteRoles func(ctx context.Context, tx database.Store, userID uuid.UUID, roles []string) error
127128
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
128129
// AppSecurityKey is the crypto key used to sign and encrypt tokens related to
129130
// workspace applications. It consists of both a signing and encryption key.
@@ -257,6 +258,14 @@ func New(options *Options) *API {
257258
return nil
258259
}
259260
}
261+
if options.SetUserSiteRoles == nil {
262+
options.SetUserSiteRoles = func(ctx context.Context, _ database.Store, userID uuid.UUID, roles []string) error {
263+
options.Logger.Warn(ctx, "attempted to assign OIDC user roles without enterprise license",
264+
slog.F("user_id", userID), slog.F("roles", roles),
265+
)
266+
return nil
267+
}
268+
}
260269
if options.TemplateScheduleStore == nil {
261270
options.TemplateScheduleStore = &atomic.Pointer[schedule.TemplateScheduleStore]{}
262271
}

coderd/userauth.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -684,12 +684,27 @@ type OIDCConfig struct {
684684
// to groups within Coder.
685685
// map[oidcGroupName]coderGroupName
686686
GroupMapping map[string]string
687+
// UserRoleField selects the claim field to be used as the created user's
688+
// roles. If the field is the empty string, then no role updates
689+
// will ever come from the OIDC provider.
690+
UserRoleField string
691+
// UserRoleMapping controls how groups returned by the OIDC provider get mapped
692+
// to roles within Coder.
693+
// map[oidcRoleName]coderRoleName
694+
UserRoleMapping map[string]string
695+
// UserRolesDefault is the default set of roles to assign to a user if role sync
696+
// is enabled.
697+
UserRolesDefault []string
687698
// SignInText is the text to display on the OIDC login button
688699
SignInText string
689700
// IconURL points to the URL of an icon to display on the OIDC login button
690701
IconURL string
691702
}
692703

704+
func (cfg OIDCConfig) RoleSyncEnabled() bool {
705+
return cfg.UserRoleField != ""
706+
}
707+
693708
// @Summary OpenID Connect Callback
694709
// @ID openid-connect-callback
695710
// @Security CoderSessionToken
@@ -890,6 +905,55 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
890905
}
891906
}
892907

908+
roles := api.OIDCConfig.UserRolesDefault
909+
if api.OIDCConfig.RoleSyncEnabled() {
910+
rolesRow, ok := claims[api.OIDCConfig.UserRoleField]
911+
if !ok {
912+
logger.Error(ctx, "oidc user roles are missing from claim")
913+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
914+
Message: "Login disabled until OIDC config is fixed, contact your administrator. Missing OIDC user roles in claim.",
915+
Detail: "If role sync is enabled, then the OIDC user roles must be present in the claim. Disabling role sync will allow login to proceed.",
916+
})
917+
return
918+
}
919+
920+
// Convert the []interface{} we get to a []string.
921+
rolesInterface, ok := rolesRow.([]interface{})
922+
if !ok {
923+
api.Logger.Error(ctx, "oidc claim user roles field was an unknown type",
924+
slog.F("type", fmt.Sprintf("%T", rolesRow)),
925+
)
926+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
927+
Message: "Login disabled until OIDC config is fixed, contact your administrator. Missing OIDC user roles in claim.",
928+
Detail: fmt.Sprintf("Roles claim must be an array of strings, type found: %T. Disabling role sync will allow login to proceed.", rolesRow),
929+
})
930+
return
931+
}
932+
933+
api.Logger.Debug(ctx, "roles returned in oidc claims",
934+
slog.F("len", len(rolesInterface)),
935+
slog.F("roles", rolesInterface),
936+
)
937+
for _, roleInterface := range rolesInterface {
938+
role, ok := roleInterface.(string)
939+
if !ok {
940+
api.Logger.Error(ctx, "invalid oidc user role type",
941+
slog.F("type", fmt.Sprintf("%T", rolesRow)),
942+
)
943+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
944+
Message: fmt.Sprintf("Invalid user role type. Expected string, got: %T", roleInterface),
945+
})
946+
return
947+
}
948+
949+
if mappedRole, ok := api.OIDCConfig.UserRoleMapping[role]; ok {
950+
role = mappedRole
951+
}
952+
953+
roles = append(roles, role)
954+
}
955+
}
956+
893957
// This conditional is purely to warn the user they might have misconfigured their OIDC
894958
// configuration.
895959
if _, groupClaimExists := claims["groups"]; !usingGroups && groupClaimExists {
@@ -959,6 +1023,8 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
9591023
Username: username,
9601024
AvatarURL: picture,
9611025
UsingGroups: usingGroups,
1026+
UsingRoles: api.OIDCConfig.RoleSyncEnabled(),
1027+
Roles: roles,
9621028
Groups: groups,
9631029
}).SetInitAuditRequest(func(params *audit.RequestParams) (*audit.Request[database.User], func()) {
9641030
return audit.InitRequest[database.User](rw, params)
@@ -1045,6 +1111,10 @@ type oauthLoginParams struct {
10451111
// to the Groups provided.
10461112
UsingGroups bool
10471113
Groups []string
1114+
// Is UsingRoles is true, then the user will be assigned
1115+
// the roles provided.
1116+
UsingRoles bool
1117+
Roles []string
10481118

10491119
commitLock sync.Mutex
10501120
initAuditRequest func(params *audit.RequestParams) *audit.Request[database.User]
@@ -1248,6 +1318,15 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
12481318
}
12491319
}
12501320

1321+
// Ensure roles are correct.
1322+
if params.UsingRoles {
1323+
//nolint:gocritic
1324+
err := api.Options.SetUserSiteRoles(dbauthz.AsSystemRestricted(ctx), tx, user.ID, params.Roles)
1325+
if err != nil {
1326+
return xerrors.Errorf("set user groups: %w", err)
1327+
}
1328+
}
1329+
12511330
needsUpdate := false
12521331
if user.AvatarURL.String != params.AvatarURL {
12531332
user.AvatarURL = sql.NullString{

coderd/users.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,14 @@ func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) {
889889
defer commitAudit()
890890
aReq.Old = user
891891

892+
if user.LoginType == database.LoginTypeOIDC && api.OIDCConfig.RoleSyncEnabled() {
893+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
894+
Message: "Cannot modify roles for OIDC users when role sync is enabled.",
895+
Detail: "'User Role Field' is set in the OIDC configuration. All role changes must come from the oidc identity provider.",
896+
})
897+
return
898+
}
899+
892900
if apiKey.UserID == user.ID {
893901
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
894902
Message: "You cannot change your own roles.",
@@ -901,7 +909,7 @@ func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) {
901909
return
902910
}
903911

904-
updatedUser, err := api.updateSiteUserRoles(ctx, database.UpdateUserRolesParams{
912+
updatedUser, err := api.UpdateSiteUserRoles(ctx, database.UpdateUserRolesParams{
905913
GrantedRoles: params.Roles,
906914
ID: user.ID,
907915
})
@@ -929,9 +937,9 @@ func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) {
929937
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(updatedUser, organizationIDs))
930938
}
931939

932-
// updateSiteUserRoles will ensure only site wide roles are passed in as arguments.
940+
// UpdateSiteUserRoles will ensure only site wide roles are passed in as arguments.
933941
// If an organization role is included, an error is returned.
934-
func (api *API) updateSiteUserRoles(ctx context.Context, args database.UpdateUserRolesParams) (database.User, error) {
942+
func (api *API) UpdateSiteUserRoles(ctx context.Context, args database.UpdateUserRolesParams) (database.User, error) {
935943
// Enforce only site wide roles.
936944
for _, r := range args.GrantedRoles {
937945
if _, ok := rbac.IsOrgRole(r); ok {

codersdk/deployment.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,9 @@ type OIDCConfig struct {
269269
IgnoreUserInfo clibase.Bool `json:"ignore_user_info" typescript:",notnull"`
270270
GroupField clibase.String `json:"groups_field" typescript:",notnull"`
271271
GroupMapping clibase.Struct[map[string]string] `json:"group_mapping" typescript:",notnull"`
272+
UserRoleField clibase.String `json:"user_role_field" typescript:",notnull"`
273+
UserRoleMapping clibase.Struct[map[string]string] `json:"user_role_mapping" typescript:",notnull"`
274+
UserRolesDefault clibase.StringArray `json:"user_roles_default" typescript:",notnull"`
272275
SignInText clibase.String `json:"sign_in_text" typescript:",notnull"`
273276
IconURL clibase.URL `json:"icon_url" typescript:",notnull"`
274277
}
@@ -1029,6 +1032,38 @@ when required by your organization's security policy.`,
10291032
Group: &deploymentGroupOIDC,
10301033
YAML: "groupMapping",
10311034
},
1035+
{
1036+
Name: "OIDC User Role Field",
1037+
Description: "This field must be set if using the user roles sync feature. Set this to the name of the claim used to store the user's role. The roles should be sent as an array of strings.",
1038+
Flag: "oidc-user-role-field",
1039+
Env: "CODER_OIDC_USER_ROLE_FIELD",
1040+
// This value is intentionally blank. If this is empty, then OIDC user role
1041+
// sync behavior is disabled.
1042+
Default: "",
1043+
Value: &c.OIDC.UserRoleField,
1044+
Group: &deploymentGroupOIDC,
1045+
YAML: "userRoleField",
1046+
},
1047+
{
1048+
Name: "OIDC User Role Mapping",
1049+
Description: "A map of the OIDC passed in user roles and the groups in Coder it should map to. This is useful if the group names do not match.",
1050+
Flag: "oidc-user-role-mapping",
1051+
Env: "CODER_OIDC_USER_ROLE_MAPPING",
1052+
Default: "{}",
1053+
Value: &c.OIDC.UserRoleMapping,
1054+
Group: &deploymentGroupOIDC,
1055+
YAML: "userRoleMapping",
1056+
},
1057+
{
1058+
Name: "OIDC User Role Default",
1059+
Description: "If user role sync is enabled, these roles are always included for all authenticated users. The 'member' role is always assigned.",
1060+
Flag: "oidc-user-role-default",
1061+
Env: "CODER_OIDC_USER_ROLE_DEFAULT",
1062+
Default: strings.Join([]string{}, ","),
1063+
Value: &c.OIDC.UserRolesDefault,
1064+
Group: &deploymentGroupOIDC,
1065+
YAML: "userRoleDefault",
1066+
},
10321067
{
10331068
Name: "OpenID Connect sign in text",
10341069
Description: "The text to show on the OpenID Connect sign in button.",

enterprise/coderd/coderd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
6767
}()
6868

6969
api.AGPL.Options.SetUserGroups = api.setUserGroups
70+
api.AGPL.Options.SetUserSiteRoles = api.setUserSiteRoles
7071
api.AGPL.SiteHandler.AppearanceFetcher = api.fetchAppearanceConfig
7172
api.AGPL.SiteHandler.RegionsFetcher = func(ctx context.Context) (any, error) {
7273
// If the user can read the workspace proxy resource, return that.

enterprise/coderd/userauth.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,18 @@ func (api *API) setUserGroups(ctx context.Context, db database.Store, userID uui
5050
return nil
5151
}, nil)
5252
}
53+
54+
func (api *API) setUserSiteRoles(ctx context.Context, db database.Store, userID uuid.UUID, roles []string) error {
55+
// Should this be feature protected?
56+
return db.InTx(func(tx database.Store) error {
57+
_, err := api.AGPL.UpdateSiteUserRoles(ctx, database.UpdateUserRolesParams{
58+
GrantedRoles: roles,
59+
ID: userID,
60+
})
61+
if err != nil {
62+
return xerrors.Errorf("set user roles: %w", err)
63+
}
64+
65+
return nil
66+
}, nil)
67+
}

0 commit comments

Comments
 (0)