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

18
coderd/apidoc/docs.go generated
View File

@ -8393,6 +8393,18 @@ const docTemplate = `{
"sign_in_text": {
"type": "string"
},
"user_role_field": {
"type": "string"
},
"user_role_mapping": {
"type": "object"
},
"user_roles_default": {
"type": "array",
"items": {
"type": "string"
}
},
"username_field": {
"type": "string"
}
@ -9413,6 +9425,9 @@ const docTemplate = `{
"type": "string",
"format": "date-time"
},
"login_type": {
"$ref": "#/definitions/codersdk.LoginType"
},
"organization_ids": {
"type": "array",
"items": {
@ -9866,6 +9881,9 @@ const docTemplate = `{
"type": "string",
"format": "date-time"
},
"login_type": {
"$ref": "#/definitions/codersdk.LoginType"
},
"organization_ids": {
"type": "array",
"items": {

View File

@ -7532,6 +7532,18 @@
"sign_in_text": {
"type": "string"
},
"user_role_field": {
"type": "string"
},
"user_role_mapping": {
"type": "object"
},
"user_roles_default": {
"type": "array",
"items": {
"type": "string"
}
},
"username_field": {
"type": "string"
}
@ -8503,6 +8515,9 @@
"type": "string",
"format": "date-time"
},
"login_type": {
"$ref": "#/definitions/codersdk.LoginType"
},
"organization_ids": {
"type": "array",
"items": {
@ -8919,6 +8934,9 @@
"type": "string",
"format": "date-time"
},
"login_type": {
"$ref": "#/definitions/codersdk.LoginType"
},
"organization_ids": {
"type": "array",
"items": {

View File

@ -124,6 +124,7 @@ type Options struct {
DERPMap *tailcfg.DERPMap
SwaggerEndpoint bool
SetUserGroups func(ctx context.Context, tx database.Store, userID uuid.UUID, groupNames []string) error
SetUserSiteRoles func(ctx context.Context, tx database.Store, userID uuid.UUID, roles []string) error
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore]
// AppSecurityKey is the crypto key used to sign and encrypt tokens related to
@ -258,6 +259,14 @@ func New(options *Options) *API {
return nil
}
}
if options.SetUserSiteRoles == nil {
options.SetUserSiteRoles = func(ctx context.Context, _ database.Store, userID uuid.UUID, roles []string) error {
options.Logger.Warn(ctx, "attempted to assign OIDC user roles without enterprise license",
slog.F("user_id", userID), slog.F("roles", roles),
)
return nil
}
}
if options.TemplateScheduleStore == nil {
options.TemplateScheduleStore = &atomic.Pointer[schedule.TemplateScheduleStore]{}
}

View File

@ -115,6 +115,7 @@ func User(user database.User, organizationIDs []uuid.UUID) codersdk.User {
OrganizationIDs: organizationIDs,
Roles: make([]codersdk.Role, 0, len(user.RBACRoles)),
AvatarURL: user.AvatarURL.String,
LoginType: codersdk.LoginType(user.LoginType),
}
for _, roleName := range user.RBACRoles {

View File

@ -207,7 +207,7 @@ var (
rbac.ResourceWildcard.Type: {rbac.ActionRead},
rbac.ResourceAPIKey.Type: {rbac.ActionCreate, rbac.ActionUpdate, rbac.ActionDelete},
rbac.ResourceGroup.Type: {rbac.ActionCreate, rbac.ActionUpdate},
rbac.ResourceRoleAssignment.Type: {rbac.ActionCreate},
rbac.ResourceRoleAssignment.Type: {rbac.ActionCreate, rbac.ActionDelete},
rbac.ResourceSystem.Type: {rbac.WildcardSymbol},
rbac.ResourceOrganization.Type: {rbac.ActionCreate},
rbac.ResourceOrganizationMember.Type: {rbac.ActionCreate},

View File

@ -283,10 +283,13 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
// map[actor_role][assign_role]<can_assign>
var assignRoles = map[string]map[string]bool{
"system": {
owner: true,
member: true,
orgAdmin: true,
orgMember: true,
owner: true,
auditor: true,
member: true,
orgAdmin: true,
orgMember: true,
templateAdmin: true,
userAdmin: true,
},
owner: {
owner: true,

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{

View File

@ -889,6 +889,14 @@ func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) {
defer commitAudit()
aReq.Old = user
if user.LoginType == database.LoginTypeOIDC && api.OIDCConfig.RoleSyncEnabled() {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot modify roles for OIDC users when role sync is enabled.",
Detail: "'User Role Field' is set in the OIDC configuration. All role changes must come from the oidc identity provider.",
})
return
}
if apiKey.UserID == user.ID {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "You cannot change your own roles.",
@ -901,7 +909,7 @@ func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) {
return
}
updatedUser, err := api.updateSiteUserRoles(ctx, database.UpdateUserRolesParams{
updatedUser, err := UpdateSiteUserRoles(ctx, api.Database, database.UpdateUserRolesParams{
GrantedRoles: params.Roles,
ID: user.ID,
})
@ -929,9 +937,9 @@ func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(updatedUser, organizationIDs))
}
// updateSiteUserRoles will ensure only site wide roles are passed in as arguments.
// UpdateSiteUserRoles will ensure only site wide roles are passed in as arguments.
// If an organization role is included, an error is returned.
func (api *API) updateSiteUserRoles(ctx context.Context, args database.UpdateUserRolesParams) (database.User, error) {
func UpdateSiteUserRoles(ctx context.Context, db database.Store, args database.UpdateUserRolesParams) (database.User, error) {
// Enforce only site wide roles.
for _, r := range args.GrantedRoles {
if _, ok := rbac.IsOrgRole(r); ok {
@ -943,7 +951,7 @@ func (api *API) updateSiteUserRoles(ctx context.Context, args database.UpdateUse
}
}
updatedUser, err := api.Database.UpdateUserRoles(ctx, args)
updatedUser, err := db.UpdateUserRoles(ctx, args)
if err != nil {
return database.User{}, xerrors.Errorf("update site roles: %w", err)
}