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:
Steven Masley
2023-07-24 08:34:24 -04:00
committed by GitHub
parent 94541d201f
commit f827829afe
38 changed files with 596 additions and 46 deletions

View File

@ -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{