mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
feat: synchronize oidc user roles (#8595)
* feat: oidc user role sync User roles come from oidc claims. Prevent manual user role changes if set. * allow mapping 1:many
This commit is contained in:
@ -684,12 +684,27 @@ type OIDCConfig struct {
|
||||
// to groups within Coder.
|
||||
// map[oidcGroupName]coderGroupName
|
||||
GroupMapping map[string]string
|
||||
// UserRoleField selects the claim field to be used as the created user's
|
||||
// roles. If the field is the empty string, then no role updates
|
||||
// will ever come from the OIDC provider.
|
||||
UserRoleField string
|
||||
// UserRoleMapping controls how groups returned by the OIDC provider get mapped
|
||||
// to roles within Coder.
|
||||
// map[oidcRoleName][]coderRoleName
|
||||
UserRoleMapping map[string][]string
|
||||
// UserRolesDefault is the default set of roles to assign to a user if role sync
|
||||
// is enabled.
|
||||
UserRolesDefault []string
|
||||
// SignInText is the text to display on the OIDC login button
|
||||
SignInText string
|
||||
// IconURL points to the URL of an icon to display on the OIDC login button
|
||||
IconURL string
|
||||
}
|
||||
|
||||
func (cfg OIDCConfig) RoleSyncEnabled() bool {
|
||||
return cfg.UserRoleField != ""
|
||||
}
|
||||
|
||||
// @Summary OpenID Connect Callback
|
||||
// @ID openid-connect-callback
|
||||
// @Security CoderSessionToken
|
||||
@ -942,6 +957,62 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
roles := api.OIDCConfig.UserRolesDefault
|
||||
if api.OIDCConfig.RoleSyncEnabled() {
|
||||
rolesRow, ok := claims[api.OIDCConfig.UserRoleField]
|
||||
if !ok {
|
||||
// If no claim is provided than we can assume the user is just
|
||||
// a member. This is because there is no way to tell the difference
|
||||
// between []string{} and nil for OIDC claims. IDPs omit claims
|
||||
// if they are empty ([]string{}).
|
||||
rolesRow = []string{}
|
||||
}
|
||||
|
||||
rolesInterface, ok := rolesRow.([]interface{})
|
||||
if !ok {
|
||||
api.Logger.Error(ctx, "oidc claim user roles field was an unknown type",
|
||||
slog.F("type", fmt.Sprintf("%T", rolesRow)),
|
||||
)
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusInternalServerError,
|
||||
HideStatus: true,
|
||||
Title: "Login disabled until OIDC config is fixed",
|
||||
Description: fmt.Sprintf("Roles claim must be an array of strings, type found: %T. Disabling role sync will allow login to proceed.", rolesRow),
|
||||
RetryEnabled: false,
|
||||
DashboardURL: "/login",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
api.Logger.Debug(ctx, "roles returned in oidc claims",
|
||||
slog.F("len", len(rolesInterface)),
|
||||
slog.F("roles", rolesInterface),
|
||||
)
|
||||
for _, roleInterface := range rolesInterface {
|
||||
role, ok := roleInterface.(string)
|
||||
if !ok {
|
||||
api.Logger.Error(ctx, "invalid oidc user role type",
|
||||
slog.F("type", fmt.Sprintf("%T", rolesRow)),
|
||||
)
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf("Invalid user role type. Expected string, got: %T", roleInterface),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if mappedRoles, ok := api.OIDCConfig.UserRoleMapping[role]; ok {
|
||||
if len(mappedRoles) == 0 {
|
||||
continue
|
||||
}
|
||||
// Mapped roles are added to the list of roles
|
||||
roles = append(roles, mappedRoles...)
|
||||
continue
|
||||
}
|
||||
|
||||
roles = append(roles, role)
|
||||
}
|
||||
}
|
||||
|
||||
// If a new user is authenticating for the first time
|
||||
// the audit action is 'register', not 'login'
|
||||
if user.ID == uuid.Nil {
|
||||
@ -959,6 +1030,8 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
|
||||
Username: username,
|
||||
AvatarURL: picture,
|
||||
UsingGroups: usingGroups,
|
||||
UsingRoles: api.OIDCConfig.RoleSyncEnabled(),
|
||||
Roles: roles,
|
||||
Groups: groups,
|
||||
}).SetInitAuditRequest(func(params *audit.RequestParams) (*audit.Request[database.User], func()) {
|
||||
return audit.InitRequest[database.User](rw, params)
|
||||
@ -1045,6 +1118,10 @@ type oauthLoginParams struct {
|
||||
// to the Groups provided.
|
||||
UsingGroups bool
|
||||
Groups []string
|
||||
// Is UsingRoles is true, then the user will be assigned
|
||||
// the roles provided.
|
||||
UsingRoles bool
|
||||
Roles []string
|
||||
|
||||
commitLock sync.Mutex
|
||||
initAuditRequest func(params *audit.RequestParams) *audit.Request[database.User]
|
||||
@ -1108,6 +1185,7 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
|
||||
ctx = r.Context()
|
||||
user database.User
|
||||
cookies []*http.Cookie
|
||||
logger = api.Logger.Named(userAuthLoggerName)
|
||||
)
|
||||
|
||||
var isConvertLoginType bool
|
||||
@ -1248,6 +1326,37 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure roles are correct.
|
||||
if params.UsingRoles {
|
||||
ignored := make([]string, 0)
|
||||
filtered := make([]string, 0, len(params.Roles))
|
||||
for _, role := range params.Roles {
|
||||
if _, err := rbac.RoleByName(role); err == nil {
|
||||
filtered = append(filtered, role)
|
||||
} else {
|
||||
ignored = append(ignored, role)
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:gocritic
|
||||
err := api.Options.SetUserSiteRoles(dbauthz.AsSystemRestricted(ctx), tx, user.ID, filtered)
|
||||
if err != nil {
|
||||
return httpError{
|
||||
code: http.StatusBadRequest,
|
||||
msg: "Invalid roles through OIDC claim",
|
||||
detail: fmt.Sprintf("Error from role assignment attempt: %s", err.Error()),
|
||||
renderStaticPage: true,
|
||||
}
|
||||
}
|
||||
if len(ignored) > 0 {
|
||||
logger.Debug(ctx, "OIDC roles ignored in assignment",
|
||||
slog.F("ignored", ignored),
|
||||
slog.F("assigned", filtered),
|
||||
slog.F("user_id", user.ID),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
needsUpdate := false
|
||||
if user.AvatarURL.String != params.AvatarURL {
|
||||
user.AvatarURL = sql.NullString{
|
||||
|
Reference in New Issue
Block a user