mirror of
https://github.com/coder/coder.git
synced 2025-07-06 15:41:45 +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:
18
coderd/apidoc/docs.go
generated
18
coderd/apidoc/docs.go
generated
@ -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": {
|
||||
|
18
coderd/apidoc/swagger.json
generated
18
coderd/apidoc/swagger.json
generated
@ -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": {
|
||||
|
@ -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]{}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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},
|
||||
|
@ -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,
|
||||
|
@ -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{
|
||||
|
@ -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)
|
||||
}
|
||||
|
Reference in New Issue
Block a user