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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 596 additions and 46 deletions

3
.gitattributes vendored
View File

@ -1,5 +1,7 @@
# Generated files
coderd/apidoc/docs.go linguist-generated=true
docs/api/*.md linguist-generated=true
docs/cli/*.md linguist-generated=true
coderd/apidoc/swagger.json linguist-generated=true
coderd/database/dump.sql linguist-generated=true
peerbroker/proto/*.go linguist-generated=true
@ -9,3 +11,4 @@ provisionersdk/proto/*.go linguist-generated=true
*.tfstate.json linguist-generated=true
*.tfstate.dot linguist-generated=true
*.tfplan.dot linguist-generated=true

View File

@ -596,6 +596,9 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
IgnoreUserInfo: cfg.OIDC.IgnoreUserInfo.Value(),
GroupField: cfg.OIDC.GroupField.String(),
GroupMapping: cfg.OIDC.GroupMapping.Value,
UserRoleField: cfg.OIDC.UserRoleField.String(),
UserRoleMapping: cfg.OIDC.UserRoleMapping.Value,
UserRolesDefault: cfg.OIDC.UserRolesDefault.GetSlice(),
SignInText: cfg.OIDC.SignInText.String(),
IconURL: cfg.OIDC.IconURL.String(),
IgnoreEmailVerified: cfg.OIDC.IgnoreEmailVerified.Value(),

View File

@ -1095,6 +1095,8 @@ func TestServer(t *testing.T) {
require.False(t, deploymentConfig.Values.OIDC.IgnoreUserInfo.Value())
require.Empty(t, deploymentConfig.Values.OIDC.GroupField.Value())
require.Empty(t, deploymentConfig.Values.OIDC.GroupMapping.Value)
require.Empty(t, deploymentConfig.Values.OIDC.UserRoleField.Value())
require.Empty(t, deploymentConfig.Values.OIDC.UserRoleMapping.Value)
require.Equal(t, "OpenID Connect", deploymentConfig.Values.OIDC.SignInText.Value())
require.Empty(t, deploymentConfig.Values.OIDC.IconURL.Value())
})

View File

@ -337,6 +337,20 @@ can safely ignore these settings.
--oidc-scopes string-array, $CODER_OIDC_SCOPES (default: openid,profile,email)
Scopes to grant when authenticating with OIDC.
--oidc-user-role-default string-array, $CODER_OIDC_USER_ROLE_DEFAULT
If user role sync is enabled, these roles are always included for all
authenticated users. The 'member' role is always assigned.
--oidc-user-role-field string, $CODER_OIDC_USER_ROLE_FIELD
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.
--oidc-user-role-mapping struct[map[string][]string], $CODER_OIDC_USER_ROLE_MAPPING (default: {})
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. If
mapped to the empty string, the role will ignored.
--oidc-username-field string, $CODER_OIDC_USERNAME_FIELD (default: preferred_username)
OIDC claim field to use as the username.

View File

@ -15,7 +15,8 @@
"display_name": "Owner"
}
],
"avatar_url": ""
"avatar_url": "",
"login_type": "password"
},
{
"id": "[second user ID]",
@ -28,6 +29,7 @@
"[first org ID]"
],
"roles": [],
"avatar_url": ""
"avatar_url": "",
"login_type": "password"
}
]

View File

@ -268,6 +268,20 @@ oidc:
# for when OIDC providers only return group IDs.
# (default: {}, type: struct[map[string]string])
groupMapping: {}
# 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.
# (default: <unset>, type: string)
userRoleField: ""
# 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. If mapped to the empty string,
# the role will ignored.
# (default: {}, type: struct[map[string][]string])
userRoleMapping: {}
# If user role sync is enabled, these roles are always included for all
# authenticated users. The 'member' role is always assigned.
# (default: <unset>, type: string-array)
userRoleDefault: []
# The text to show on the OpenID Connect sign in button.
# (default: OpenID Connect, type: string)
signInText: OpenID Connect

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)
}

View File

@ -40,6 +40,7 @@ const (
FeatureBrowserOnly FeatureName = "browser_only"
FeatureSCIM FeatureName = "scim"
FeatureTemplateRBAC FeatureName = "template_rbac"
FeatureUserRoleManagement FeatureName = "user_role_management"
FeatureHighAvailability FeatureName = "high_availability"
FeatureMultipleGitAuth FeatureName = "multiple_git_auth"
FeatureExternalProvisionerDaemons FeatureName = "external_provisioner_daemons"
@ -62,6 +63,7 @@ var FeatureNames = []FeatureName{
FeatureAppearance,
FeatureAdvancedTemplateScheduling,
FeatureWorkspaceProxy,
FeatureUserRoleManagement,
}
// Humanize returns the feature name in a human-readable format.
@ -258,21 +260,24 @@ type OAuth2GithubConfig struct {
}
type OIDCConfig struct {
AllowSignups clibase.Bool `json:"allow_signups" typescript:",notnull"`
ClientID clibase.String `json:"client_id" typescript:",notnull"`
ClientSecret clibase.String `json:"client_secret" typescript:",notnull"`
EmailDomain clibase.StringArray `json:"email_domain" typescript:",notnull"`
IssuerURL clibase.String `json:"issuer_url" typescript:",notnull"`
Scopes clibase.StringArray `json:"scopes" typescript:",notnull"`
IgnoreEmailVerified clibase.Bool `json:"ignore_email_verified" typescript:",notnull"`
UsernameField clibase.String `json:"username_field" typescript:",notnull"`
EmailField clibase.String `json:"email_field" typescript:",notnull"`
AuthURLParams clibase.Struct[map[string]string] `json:"auth_url_params" typescript:",notnull"`
IgnoreUserInfo clibase.Bool `json:"ignore_user_info" typescript:",notnull"`
GroupField clibase.String `json:"groups_field" typescript:",notnull"`
GroupMapping clibase.Struct[map[string]string] `json:"group_mapping" typescript:",notnull"`
SignInText clibase.String `json:"sign_in_text" typescript:",notnull"`
IconURL clibase.URL `json:"icon_url" typescript:",notnull"`
AllowSignups clibase.Bool `json:"allow_signups" typescript:",notnull"`
ClientID clibase.String `json:"client_id" typescript:",notnull"`
ClientSecret clibase.String `json:"client_secret" typescript:",notnull"`
EmailDomain clibase.StringArray `json:"email_domain" typescript:",notnull"`
IssuerURL clibase.String `json:"issuer_url" typescript:",notnull"`
Scopes clibase.StringArray `json:"scopes" typescript:",notnull"`
IgnoreEmailVerified clibase.Bool `json:"ignore_email_verified" typescript:",notnull"`
UsernameField clibase.String `json:"username_field" typescript:",notnull"`
EmailField clibase.String `json:"email_field" typescript:",notnull"`
AuthURLParams clibase.Struct[map[string]string] `json:"auth_url_params" typescript:",notnull"`
IgnoreUserInfo clibase.Bool `json:"ignore_user_info" typescript:",notnull"`
GroupField clibase.String `json:"groups_field" typescript:",notnull"`
GroupMapping clibase.Struct[map[string]string] `json:"group_mapping" typescript:",notnull"`
UserRoleField clibase.String `json:"user_role_field" typescript:",notnull"`
UserRoleMapping clibase.Struct[map[string][]string] `json:"user_role_mapping" typescript:",notnull"`
UserRolesDefault clibase.StringArray `json:"user_roles_default" typescript:",notnull"`
SignInText clibase.String `json:"sign_in_text" typescript:",notnull"`
IconURL clibase.URL `json:"icon_url" typescript:",notnull"`
}
type TelemetryConfig struct {
@ -1043,6 +1048,38 @@ when required by your organization's security policy.`,
Group: &deploymentGroupOIDC,
YAML: "groupMapping",
},
{
Name: "OIDC User Role Field",
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.",
Flag: "oidc-user-role-field",
Env: "CODER_OIDC_USER_ROLE_FIELD",
// This value is intentionally blank. If this is empty, then OIDC user role
// sync behavior is disabled.
Default: "",
Value: &c.OIDC.UserRoleField,
Group: &deploymentGroupOIDC,
YAML: "userRoleField",
},
{
Name: "OIDC User Role Mapping",
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. If mapped to the empty string, the role will ignored.",
Flag: "oidc-user-role-mapping",
Env: "CODER_OIDC_USER_ROLE_MAPPING",
Default: "{}",
Value: &c.OIDC.UserRoleMapping,
Group: &deploymentGroupOIDC,
YAML: "userRoleMapping",
},
{
Name: "OIDC User Role Default",
Description: "If user role sync is enabled, these roles are always included for all authenticated users. The 'member' role is always assigned.",
Flag: "oidc-user-role-default",
Env: "CODER_OIDC_USER_ROLE_DEFAULT",
Default: "",
Value: &c.OIDC.UserRolesDefault,
Group: &deploymentGroupOIDC,
YAML: "userRoleDefault",
},
{
Name: "OpenID Connect sign in text",
Description: "The text to show on the OpenID Connect sign in button.",

View File

@ -45,6 +45,7 @@ type User struct {
OrganizationIDs []uuid.UUID `json:"organization_ids" format:"uuid"`
Roles []Role `json:"roles"`
AvatarURL string `json:"avatar_url" format:"uri"`
LoginType LoginType `json:"login_type"`
}
type GetUsersResponse struct {

1
docs/api/audit.md generated
View File

@ -63,6 +63,7 @@ curl -X GET http://coder-server:8080/api/v2/audit?q=string \
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{

40
docs/api/enterprise.md generated
View File

@ -182,6 +182,7 @@ curl -X GET http://coder-server:8080/api/v2/groups/{group} \
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -241,6 +242,7 @@ curl -X DELETE http://coder-server:8080/api/v2/groups/{group} \
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -300,6 +302,7 @@ curl -X PATCH http://coder-server:8080/api/v2/groups/{group} \
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -434,6 +437,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -473,6 +477,7 @@ Status Code **200**
| `»» email` | string(email) | true | | |
| `»» id` | string(uuid) | true | | |
| `»» last_seen_at` | string(date-time) | false | | |
| `»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | |
| `»» organization_ids` | array | false | | |
| `»» roles` | array | false | | |
| `»»» display_name` | string | false | | |
@ -485,10 +490,15 @@ Status Code **200**
#### Enumerated Values
| Property | Value |
| -------- | ----------- |
| `status` | `active` |
| `status` | `suspended` |
| Property | Value |
| ------------ | ----------- |
| `login_type` | `password` |
| `login_type` | `github` |
| `login_type` | `oidc` |
| `login_type` | `token` |
| `login_type` | `none` |
| `status` | `active` |
| `status` | `suspended` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
@ -538,6 +548,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/groups
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -598,6 +609,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups/
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -959,6 +971,7 @@ curl -X PATCH http://coder-server:8080/api/v2/scim/v2/Users/{id} \
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -1010,6 +1023,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl \
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"role": "admin",
"roles": [
@ -1042,6 +1056,7 @@ Status Code **200**
| `» email` | string(email) | true | | |
| `» id` | string(uuid) | true | | |
| `» last_seen_at` | string(date-time) | false | | |
| `» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | |
| `» organization_ids` | array | false | | |
| `» role` | [codersdk.TemplateRole](schemas.md#codersdktemplaterole) | false | | |
| `» roles` | array | false | | |
@ -1052,12 +1067,17 @@ Status Code **200**
#### Enumerated Values
| Property | Value |
| -------- | ----------- |
| `role` | `admin` |
| `role` | `use` |
| `status` | `active` |
| `status` | `suspended` |
| Property | Value |
| ------------ | ----------- |
| `login_type` | `password` |
| `login_type` | `github` |
| `login_type` | `oidc` |
| `login_type` | `token` |
| `login_type` | `none` |
| `role` | `admin` |
| `role` | `use` |
| `status` | `active` |
| `status` | `suspended` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).

3
docs/api/general.md generated
View File

@ -279,6 +279,9 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
"issuer_url": "string",
"scopes": ["string"],
"sign_in_text": "string",
"user_role_field": "string",
"user_role_mapping": {},
"user_roles_default": ["string"],
"username_field": "string"
},
"pg_connection_url": "string",

21
docs/api/schemas.md generated
View File

@ -1000,6 +1000,7 @@
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -1076,6 +1077,7 @@
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -2006,6 +2008,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"issuer_url": "string",
"scopes": ["string"],
"sign_in_text": "string",
"user_role_field": "string",
"user_role_mapping": {},
"user_roles_default": ["string"],
"username_field": "string"
},
"pg_connection_url": "string",
@ -2359,6 +2364,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"issuer_url": "string",
"scopes": ["string"],
"sign_in_text": "string",
"user_role_field": "string",
"user_role_mapping": {},
"user_roles_default": ["string"],
"username_field": "string"
},
"pg_connection_url": "string",
@ -2649,6 +2657,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -2865,6 +2874,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -3223,6 +3233,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"issuer_url": "string",
"scopes": ["string"],
"sign_in_text": "string",
"user_role_field": "string",
"user_role_mapping": {},
"user_roles_default": ["string"],
"username_field": "string"
}
```
@ -3245,6 +3258,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `issuer_url` | string | false | | |
| `scopes` | array of string | false | | |
| `sign_in_text` | string | false | | |
| `user_role_field` | string | false | | |
| `user_role_mapping` | object | false | | |
| `user_roles_default` | array of string | false | | |
| `username_field` | string | false | | |
## codersdk.Organization
@ -4304,6 +4320,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"role": "admin",
"roles": [
@ -4326,6 +4343,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `email` | string | true | | |
| `id` | string | true | | |
| `last_seen_at` | string | false | | |
| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | |
| `organization_ids` | array of string | false | | |
| `role` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | |
| `roles` | array of [codersdk.Role](#codersdkrole) | false | | |
@ -4352,6 +4370,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -4821,6 +4840,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -4842,6 +4862,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| `email` | string | true | | |
| `id` | string | true | | |
| `last_seen_at` | string | false | | |
| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | |
| `organization_ids` | array of string | false | | |
| `roles` | array of [codersdk.Role](#codersdkrole) | false | | |
| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | |

19
docs/api/templates.md generated
View File

@ -380,6 +380,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -461,6 +462,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -566,6 +568,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -878,6 +881,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/versions \
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -938,6 +942,7 @@ Status Code **200**
| `»» email` | string(email) | true | | |
| `»» id` | string(uuid) | true | | |
| `»» last_seen_at` | string(date-time) | false | | |
| `»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | |
| `»» organization_ids` | array | false | | |
| `»» roles` | array | false | | |
| `»»» display_name` | string | false | | |
@ -972,6 +977,11 @@ Status Code **200**
| Property | Value |
| ------------ | ----------------------------- |
| `login_type` | `password` |
| `login_type` | `github` |
| `login_type` | `oidc` |
| `login_type` | `token` |
| `login_type` | `none` |
| `status` | `active` |
| `status` | `suspended` |
| `error_code` | `MISSING_TEMPLATE_PARAMETER` |
@ -1073,6 +1083,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/versions/{templ
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -1133,6 +1144,7 @@ Status Code **200**
| `»» email` | string(email) | true | | |
| `»» id` | string(uuid) | true | | |
| `»» last_seen_at` | string(date-time) | false | | |
| `»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | |
| `»» organization_ids` | array | false | | |
| `»» roles` | array | false | | |
| `»»» display_name` | string | false | | |
@ -1167,6 +1179,11 @@ Status Code **200**
| Property | Value |
| ------------ | ----------------------------- |
| `login_type` | `password` |
| `login_type` | `github` |
| `login_type` | `oidc` |
| `login_type` | `token` |
| `login_type` | `none` |
| `status` | `active` |
| `status` | `suspended` |
| `error_code` | `MISSING_TEMPLATE_PARAMETER` |
@ -1212,6 +1229,7 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion} \
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -1302,6 +1320,7 @@ curl -X PATCH http://coder-server:8080/api/v2/templateversions/{templateversion}
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{

9
docs/api/users.md generated
View File

@ -36,6 +36,7 @@ curl -X GET http://coder-server:8080/api/v2/users \
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -101,6 +102,7 @@ curl -X POST http://coder-server:8080/api/v2/users \
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -359,6 +361,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user} \
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -409,6 +412,7 @@ curl -X DELETE http://coder-server:8080/api/v2/users/{user} \
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -1002,6 +1006,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/profile \
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -1052,6 +1057,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/roles \
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -1112,6 +1118,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/roles \
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -1162,6 +1169,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/status/activate \
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{
@ -1212,6 +1220,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/status/suspend \
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "password",
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"roles": [
{

31
docs/cli/server.md generated
View File

@ -522,6 +522,37 @@ Issuer URL to use for Login with OIDC.
Scopes to grant when authenticating with OIDC.
### --oidc-user-role-default
| | |
| ----------- | ------------------------------------------ |
| Type | <code>string-array</code> |
| Environment | <code>$CODER_OIDC_USER_ROLE_DEFAULT</code> |
| YAML | <code>oidc.userRoleDefault</code> |
If user role sync is enabled, these roles are always included for all authenticated users. The 'member' role is always assigned.
### --oidc-user-role-field
| | |
| ----------- | ---------------------------------------- |
| Type | <code>string</code> |
| Environment | <code>$CODER_OIDC_USER_ROLE_FIELD</code> |
| YAML | <code>oidc.userRoleField</code> |
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.
### --oidc-user-role-mapping
| | |
| ----------- | ------------------------------------------ |
| Type | <code>struct[map[string][]string]</code> |
| Environment | <code>$CODER_OIDC_USER_ROLE_MAPPING</code> |
| YAML | <code>oidc.userRoleMapping</code> |
| Default | <code>{}</code> |
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. If mapped to the empty string, the role will ignored.
### --oidc-username-field
| | |

View File

@ -337,6 +337,20 @@ can safely ignore these settings.
--oidc-scopes string-array, $CODER_OIDC_SCOPES (default: openid,profile,email)
Scopes to grant when authenticating with OIDC.
--oidc-user-role-default string-array, $CODER_OIDC_USER_ROLE_DEFAULT
If user role sync is enabled, these roles are always included for all
authenticated users. The 'member' role is always assigned.
--oidc-user-role-field string, $CODER_OIDC_USER_ROLE_FIELD
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.
--oidc-user-role-mapping struct[map[string][]string], $CODER_OIDC_USER_ROLE_MAPPING (default: {})
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. If
mapped to the empty string, the role will ignored.
--oidc-username-field string, $CODER_OIDC_USERNAME_FIELD (default: preferred_username)
OIDC claim field to use as the username.

View File

@ -69,6 +69,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
}()
api.AGPL.Options.SetUserGroups = api.setUserGroups
api.AGPL.Options.SetUserSiteRoles = api.setUserSiteRoles
api.AGPL.SiteHandler.AppearanceFetcher = api.fetchAppearanceConfig
api.AGPL.SiteHandler.RegionsFetcher = func(ctx context.Context) (any, error) {
// If the user can read the workspace proxy resource, return that.
@ -405,6 +406,7 @@ func (api *API) updateEntitlements(ctx context.Context) error {
// FeatureAdvancedTemplateScheduling.
codersdk.FeatureTemplateRestartRequirement: api.DefaultQuietHoursSchedule != "",
codersdk.FeatureWorkspaceProxy: true,
codersdk.FeatureUserRoleManagement: true,
})
if err != nil {
return err

View File

@ -56,6 +56,7 @@ func TestEntitlements(t *testing.T) {
codersdk.FeatureExternalProvisionerDaemons: 1,
codersdk.FeatureAdvancedTemplateScheduling: 1,
codersdk.FeatureWorkspaceProxy: 1,
codersdk.FeatureUserRoleManagement: 1,
},
GraceAt: time.Now().Add(59 * 24 * time.Hour),
})

View File

@ -416,6 +416,7 @@ func convertUser(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

@ -6,6 +6,8 @@ import (
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/codersdk"
)
@ -50,3 +52,29 @@ func (api *API) setUserGroups(ctx context.Context, db database.Store, userID uui
return nil
}, nil)
}
func (api *API) setUserSiteRoles(ctx context.Context, db database.Store, userID uuid.UUID, roles []string) error {
api.entitlementsMu.RLock()
enabled := api.entitlements.Features[codersdk.FeatureUserRoleManagement].Enabled
api.entitlementsMu.RUnlock()
if !enabled {
api.Logger.Warn(ctx, "attempted to assign OIDC user roles without enterprise entitlement, roles left unchanged",
slog.F("user_id", userID), slog.F("roles", roles),
)
return nil
}
// Should this be feature protected?
return db.InTx(func(tx database.Store) error {
_, err := coderd.UpdateSiteUserRoles(ctx, db, database.UpdateUserRolesParams{
GrantedRoles: roles,
ID: userID,
})
if err != nil {
return xerrors.Errorf("set user roles(%s): %w", userID.String(), err)
}
return nil
}, nil)
}

View File

@ -16,6 +16,7 @@ import (
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/enterprise/coderd/coderdenttest"
"github.com/coder/coder/testutil"
@ -24,6 +25,99 @@ import (
// nolint:bodyclose
func TestUserOIDC(t *testing.T) {
t.Parallel()
t.Run("RoleSync", func(t *testing.T) {
t.Parallel()
t.Run("NewUserAndRemoveRoles", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
conf := coderdtest.NewOIDCConfig(t, "")
oidcRoleName := "TemplateAuthor"
config := conf.OIDCConfig(t, jwt.MapClaims{}, func(cfg *coderd.OIDCConfig) {
cfg.UserRoleMapping = map[string][]string{oidcRoleName: {rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin()}}
})
config.AllowSignups = true
config.UserRoleField = "roles"
client, _ := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
OIDCConfig: config,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureUserRoleManagement: 1},
},
})
admin, err := client.User(ctx, "me")
require.NoError(t, err)
require.Len(t, admin.OrganizationIDs, 1)
resp := oidcCallback(t, client, conf.EncodeClaims(t, jwt.MapClaims{
"email": "alice@coder.com",
"roles": []string{"random", oidcRoleName, rbac.RoleOwner()},
}))
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
user, err := client.User(ctx, "alice")
require.NoError(t, err)
require.Len(t, user.Roles, 3)
roleNames := []string{user.Roles[0].Name, user.Roles[1].Name, user.Roles[2].Name}
require.ElementsMatch(t, roleNames, []string{rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin(), rbac.RoleOwner()})
// Now remove the roles with a new oidc login
resp = oidcCallback(t, client, conf.EncodeClaims(t, jwt.MapClaims{
"email": "alice@coder.com",
"roles": []string{"random"},
}))
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
user, err = client.User(ctx, "alice")
require.NoError(t, err)
require.Len(t, user.Roles, 0)
})
t.Run("BlockAssignRoles", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
conf := coderdtest.NewOIDCConfig(t, "")
config := conf.OIDCConfig(t, jwt.MapClaims{})
config.AllowSignups = true
config.UserRoleField = "roles"
client, _ := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
OIDCConfig: config,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureUserRoleManagement: 1},
},
})
admin, err := client.User(ctx, "me")
require.NoError(t, err)
require.Len(t, admin.OrganizationIDs, 1)
resp := oidcCallback(t, client, conf.EncodeClaims(t, jwt.MapClaims{
"email": "alice@coder.com",
"roles": []string{},
}))
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
// Try to manually update user roles, even though controlled by oidc
// role sync.
_, err = client.UpdateUserRoles(ctx, "alice", codersdk.UpdateRoles{
Roles: []string{
rbac.RoleTemplateAdmin(),
},
})
require.Error(t, err)
require.ErrorContains(t, err, "Cannot modify roles for OIDC users when role sync is enabled.")
})
})
t.Run("Groups", func(t *testing.T) {
t.Parallel()
t.Run("Assigns", func(t *testing.T) {

View File

@ -613,6 +613,12 @@ export interface OIDCConfig {
// Named type "github.com/coder/coder/cli/clibase.Struct[map[string]string]" unknown, using "any"
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type
readonly group_mapping: any
readonly user_role_field: string
// Named type "github.com/coder/coder/cli/clibase.Struct[map[string][]string]" unknown, using "any"
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type
readonly user_role_mapping: any
// This is likely an enum in an external package ("github.com/coder/coder/cli/clibase.StringArray")
readonly user_roles_default: string[]
readonly sign_in_text: string
readonly icon_url: string
}
@ -1162,6 +1168,7 @@ export interface User {
readonly organization_ids: string[]
readonly roles: Role[]
readonly avatar_url: string
readonly login_type: LoginType
}
// From codersdk/insights.go
@ -1564,6 +1571,7 @@ export type FeatureName =
| "template_rbac"
| "template_restart_requirement"
| "user_limit"
| "user_role_management"
| "workspace_proxy"
export const FeatureNames: FeatureName[] = [
"advanced_template_scheduling",
@ -1577,6 +1585,7 @@ export const FeatureNames: FeatureName[] = [
"template_rbac",
"template_restart_requirement",
"user_limit",
"user_role_management",
"workspace_proxy",
]

View File

@ -34,6 +34,8 @@ Loading.args = {
isLoading: true,
roles: MockSiteRoles,
selectedRoles: [MockUserAdminRole, MockOwnerRole],
userLoginType: "password",
oidcRoleSync: false,
}
Loading.parameters = {
chromatic: { delay: 300 },

View File

@ -8,6 +8,12 @@ import { Stack } from "components/Stack/Stack"
import Checkbox from "@mui/material/Checkbox"
import UserIcon from "@mui/icons-material/PersonOutline"
import { Role } from "api/typesGenerated"
import {
HelpTooltip,
HelpTooltipText,
HelpTooltipTitle,
} from "components/Tooltips/HelpTooltip"
import { Maybe } from "components/Conditionals/Maybe"
const Option: React.FC<{
value: string
@ -46,6 +52,8 @@ export interface EditRolesButtonProps {
selectedRoles: Role[]
onChange: (roles: Role["name"][]) => void
defaultIsOpen?: boolean
oidcRoleSync: boolean
userLoginType: string
}
export const EditRolesButton: FC<EditRolesButtonProps> = ({
@ -54,6 +62,8 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
onChange,
isLoading,
defaultIsOpen = false,
userLoginType,
oidcRoleSync,
}) => {
const styles = useStyles()
const { t } = useTranslation("usersPage")
@ -71,17 +81,30 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
onChange([...selectedRoleNames, roleName])
}
const canSetRoles =
userLoginType !== "oidc" || (userLoginType === "oidc" && !oidcRoleSync)
return (
<>
<IconButton
ref={anchorRef}
size="small"
className={styles.editButton}
title={t("editUserRolesTooltip") || ""}
onClick={() => setIsOpen(true)}
>
<EditSquare />
</IconButton>
<Maybe condition={canSetRoles}>
<IconButton
ref={anchorRef}
size="small"
className={styles.editButton}
title={t("editUserRolesTooltip") || ""}
onClick={() => setIsOpen(true)}
>
<EditSquare />
</IconButton>
</Maybe>
<Maybe condition={!canSetRoles}>
<HelpTooltip size="small">
<HelpTooltipTitle>Externally controlled</HelpTooltipTitle>
<HelpTooltipText>
Roles for this user are controlled by the OIDC identity provider.
</HelpTooltipText>
</HelpTooltip>
</Maybe>
<Popover
id={id}

View File

@ -36,6 +36,7 @@ export interface UsersTableProps {
) => void
isNonInitialPage: boolean
actorID: string
oidcRoleSyncEnabled: boolean
}
export const UsersTable: FC<React.PropsWithChildren<UsersTableProps>> = ({
@ -54,6 +55,7 @@ export const UsersTable: FC<React.PropsWithChildren<UsersTableProps>> = ({
isLoading,
isNonInitialPage,
actorID,
oidcRoleSyncEnabled,
}) => {
return (
<TableContainer>
@ -91,6 +93,7 @@ export const UsersTable: FC<React.PropsWithChildren<UsersTableProps>> = ({
onUpdateUserRoles={onUpdateUserRoles}
isNonInitialPage={isNonInitialPage}
actorID={actorID}
oidcRoleSyncEnabled={oidcRoleSyncEnabled}
/>
</TableBody>
</Table>

View File

@ -48,6 +48,10 @@ interface UsersTableBodyProps {
) => void
isNonInitialPage: boolean
actorID: string
// oidcRoleSyncEnabled should be set to false if unknown.
// This is used to determine if the oidc roles are synced from the oidc idp and
// editing via the UI should be disabled.
oidcRoleSyncEnabled: boolean
}
export const UsersTableBody: FC<
@ -68,6 +72,7 @@ export const UsersTableBody: FC<
isLoading,
isNonInitialPage,
actorID,
oidcRoleSyncEnabled,
}) => {
const styles = useStyles()
const { t } = useTranslation("usersPage")
@ -127,6 +132,8 @@ export const UsersTableBody: FC<
roles={roles ? sortRoles(roles) : []}
selectedRoles={userRoles}
isLoading={Boolean(isUpdatingUserRoles)}
userLoginType={user.login_type}
oidcRoleSync={oidcRoleSyncEnabled}
onChange={(roles) => {
// Remove the fallback role because it is only for the UI
const rolesWithoutFallback = roles.filter(

View File

@ -38,6 +38,7 @@ describe("AccountPage", () => {
roles: [],
avatar_url: "",
last_seen_at: new Date().toString(),
login_type: "password",
...data,
}),
)

View File

@ -20,6 +20,7 @@ import { UsersPageView } from "./UsersPageView"
import { useStatusFilterMenu } from "./UsersFilter"
import { useFilter } from "components/Filter/filter"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { deploymentConfigMachine } from "xServices/deploymentConfig/deploymentConfigMachine"
export const Language = {
suspendDialogTitle: "Suspend user",
@ -61,7 +62,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
count,
} = usersState.context
const { updateUsers: canEditUsers } = usePermissions()
const { updateUsers: canEditUsers, viewDeploymentValues } = usePermissions()
const [rolesState] = useMachine(siteRolesMachine, {
context: {
hasPermission: canEditUsers,
@ -69,6 +70,16 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
})
const { roles } = rolesState.context
// Ideally this only runs if 'canViewDeployment' is true.
// TODO: Prevent api call if the user does not have the perms.
const [state] = useMachine(deploymentConfigMachine)
const { deploymentValues } = state.context
// Indicates if oidc roles are synced from the oidc idp.
// Assign 'false' if unknown.
const oidcRoleSyncEnabled =
viewDeploymentValues &&
deploymentValues?.config.oidc?.user_role_field !== ""
// Is loading if
// - users are loading or
// - the user can edit the users but the roles are loading
@ -102,6 +113,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
<title>{pageTitle("Users")}</title>
</Helmet>
<UsersPageView
oidcRoleSyncEnabled={oidcRoleSyncEnabled}
roles={roles}
users={users}
count={count}

View File

@ -16,6 +16,7 @@ export interface UsersPageViewProps {
roles?: TypesGen.AssignableRoles[]
isUpdatingUserRoles?: boolean
canEditUsers?: boolean
oidcRoleSyncEnabled: boolean
canViewActivity?: boolean
isLoading?: boolean
onSuspendUser: (user: TypesGen.User) => void
@ -47,6 +48,7 @@ export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
onUpdateUserRoles,
isUpdatingUserRoles,
canEditUsers,
oidcRoleSyncEnabled,
canViewActivity,
isLoading,
filterProps,
@ -77,6 +79,7 @@ export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
onUpdateUserRoles={onUpdateUserRoles}
isUpdatingUserRoles={isUpdatingUserRoles}
canEditUsers={canEditUsers}
oidcRoleSyncEnabled={oidcRoleSyncEnabled}
canViewActivity={canViewActivity}
isLoading={isLoading}
isNonInitialPage={isNonInitialPage}

View File

@ -268,6 +268,7 @@ export const MockUser: TypesGen.User = {
roles: [MockOwnerRole],
avatar_url: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4",
last_seen_at: "",
login_type: "password",
}
export const MockUserAdmin: TypesGen.User = {
@ -280,6 +281,7 @@ export const MockUserAdmin: TypesGen.User = {
roles: [MockUserAdminRole],
avatar_url: "",
last_seen_at: "",
login_type: "password",
}
export const MockUser2: TypesGen.User = {
@ -292,6 +294,7 @@ export const MockUser2: TypesGen.User = {
roles: [],
avatar_url: "",
last_seen_at: "2022-09-14T19:12:21Z",
login_type: "oidc",
}
export const SuspendedMockUser: TypesGen.User = {
@ -304,6 +307,7 @@ export const SuspendedMockUser: TypesGen.User = {
roles: [],
avatar_url: "",
last_seen_at: "",
login_type: "password",
}
export const MockProvisioner: TypesGen.ProvisionerDaemon = {