diff --git a/.gitattributes b/.gitattributes index 6a13a6f03307b..35f59c7ce002d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 + diff --git a/cli/server.go b/cli/server.go index e0668868023a8..4ae651853c2db 100644 --- a/cli/server.go +++ b/cli/server.go @@ -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(), diff --git a/cli/server_test.go b/cli/server_test.go index 6437fc68c28e1..a6f33be695672 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -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()) }) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index c779bd399945a..35d68b84a0772 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -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. diff --git a/cli/testdata/coder_users_list_--output_json.golden b/cli/testdata/coder_users_list_--output_json.golden index b75519f4d2aa0..c3d8fe6695cd5 100644 --- a/cli/testdata/coder_users_list_--output_json.golden +++ b/cli/testdata/coder_users_list_--output_json.golden @@ -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" } ] diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 4ecfe0f071b73..5d4171a8888df 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -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: , 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: , type: string-array) + userRoleDefault: [] # The text to show on the OpenID Connect sign in button. # (default: OpenID Connect, type: string) signInText: OpenID Connect diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 4fcc4c035949e..939d73cacdc15 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8323,6 +8323,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" } @@ -9228,6 +9240,9 @@ const docTemplate = `{ "type": "string", "format": "date-time" }, + "login_type": { + "$ref": "#/definitions/codersdk.LoginType" + }, "organization_ids": { "type": "array", "items": { @@ -9681,6 +9696,9 @@ const docTemplate = `{ "type": "string", "format": "date-time" }, + "login_type": { + "$ref": "#/definitions/codersdk.LoginType" + }, "organization_ids": { "type": "array", "items": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 2b0140f1004fe..8e1f6223c2d9e 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7474,6 +7474,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" } @@ -8334,6 +8346,9 @@ "type": "string", "format": "date-time" }, + "login_type": { + "$ref": "#/definitions/codersdk.LoginType" + }, "organization_ids": { "type": "array", "items": { @@ -8750,6 +8765,9 @@ "type": "string", "format": "date-time" }, + "login_type": { + "$ref": "#/definitions/codersdk.LoginType" + }, "organization_ids": { "type": "array", "items": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 5ac7881ccdeb4..28890429b183d 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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]{} } diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index be472131881d5..263611d5b168b 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -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 { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 57bcd12c96354..8ba658884bb1d 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -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}, diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index ee3805b716402..6286f8d9c8796 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -283,10 +283,13 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // map[actor_role][assign_role] 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, diff --git a/coderd/userauth.go b/coderd/userauth.go index 6a1aead1ef8cb..5b13a3d8d23ba 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -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{ diff --git a/coderd/users.go b/coderd/users.go index 7f9f7a7f23a7d..cbd2880e52e88 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -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) } diff --git a/codersdk/deployment.go b/codersdk/deployment.go index d56131c12ee78..a01e925abe1c8 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -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.", diff --git a/codersdk/users.go b/codersdk/users.go index 1536635a2106b..4244b3ef886c9 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -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 { diff --git a/docs/api/audit.md b/docs/api/audit.md index 25d47af0bd36a..d5aeb78665d31 100644 --- a/docs/api/audit.md +++ b/docs/api/audit.md @@ -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": [ { diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index 4ed43c12ce770..da03774b433e7 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -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). diff --git a/docs/api/general.md b/docs/api/general.md index be2d6c9ee2d7c..a11c48c3f6be1 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -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", diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 7af780fc5bbf9..3aeb0df983070 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -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": [ { @@ -1990,6 +1992,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", @@ -2343,6 +2348,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", @@ -2634,6 +2642,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": [ { @@ -2850,6 +2859,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": [ { @@ -3194,6 +3204,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" } ``` @@ -3216,6 +3229,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 @@ -4145,6 +4161,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": [ @@ -4167,6 +4184,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 | | | @@ -4193,6 +4211,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": [ { @@ -4662,6 +4681,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": [ { @@ -4683,6 +4703,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 | | | diff --git a/docs/api/templates.md b/docs/api/templates.md index 06b11216727ae..9f453619d4b81 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -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": [ { diff --git a/docs/api/users.md b/docs/api/users.md index 1206d42c2e260..f22f97adb4ab8 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -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": [ { diff --git a/docs/cli/server.md b/docs/cli/server.md index 617399fbb6af4..684c2a3f8ba46 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -522,6 +522,37 @@ Issuer URL to use for Login with OIDC. Scopes to grant when authenticating with OIDC. +### --oidc-user-role-default + +| | | +| ----------- | ------------------------------------------ | +| Type | string-array | +| Environment | $CODER_OIDC_USER_ROLE_DEFAULT | +| YAML | oidc.userRoleDefault | + +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 | string | +| Environment | $CODER_OIDC_USER_ROLE_FIELD | +| YAML | oidc.userRoleField | + +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 | struct[map[string][]string] | +| Environment | $CODER_OIDC_USER_ROLE_MAPPING | +| YAML | oidc.userRoleMapping | +| 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 | | | diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index c779bd399945a..35d68b84a0772 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -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. diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 4833b296fa9f5..1d22e668c6e84 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -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 diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index 71f95c97a69e2..2271c79064b4f 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -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), }) diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index 2ce1685d97ca8..79fba137f81e8 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -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 { diff --git a/enterprise/coderd/userauth.go b/enterprise/coderd/userauth.go index 46d486a3cebe1..86aa9f0ddf88b 100644 --- a/enterprise/coderd/userauth.go +++ b/enterprise/coderd/userauth.go @@ -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) +} diff --git a/enterprise/coderd/userauth_test.go b/enterprise/coderd/userauth_test.go index ca787ba8f74e6..d6b5903c16766 100644 --- a/enterprise/coderd/userauth_test.go +++ b/enterprise/coderd/userauth_test.go @@ -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) { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 707dcfcf57b2b..8b3b9b839cce2 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -607,6 +607,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 } @@ -1114,6 +1120,7 @@ export interface User { readonly organization_ids: string[] readonly roles: Role[] readonly avatar_url: string + readonly login_type: LoginType } // From codersdk/users.go @@ -1490,6 +1497,7 @@ export type FeatureName = | "template_rbac" | "template_restart_requirement" | "user_limit" + | "user_role_management" | "workspace_proxy" export const FeatureNames: FeatureName[] = [ "advanced_template_scheduling", @@ -1503,6 +1511,7 @@ export const FeatureNames: FeatureName[] = [ "template_rbac", "template_restart_requirement", "user_limit", + "user_role_management", "workspace_proxy", ] diff --git a/site/src/components/EditRolesButton/EditRolesButton.stories.tsx b/site/src/components/EditRolesButton/EditRolesButton.stories.tsx index 0323d3dcc60be..66b58f0741300 100644 --- a/site/src/components/EditRolesButton/EditRolesButton.stories.tsx +++ b/site/src/components/EditRolesButton/EditRolesButton.stories.tsx @@ -34,6 +34,8 @@ Loading.args = { isLoading: true, roles: MockSiteRoles, selectedRoles: [MockUserAdminRole, MockOwnerRole], + userLoginType: "password", + oidcRoleSync: false, } Loading.parameters = { chromatic: { delay: 300 }, diff --git a/site/src/components/EditRolesButton/EditRolesButton.tsx b/site/src/components/EditRolesButton/EditRolesButton.tsx index 39c1b40bdb875..e49902936bee3 100644 --- a/site/src/components/EditRolesButton/EditRolesButton.tsx +++ b/site/src/components/EditRolesButton/EditRolesButton.tsx @@ -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 = ({ @@ -54,6 +62,8 @@ export const EditRolesButton: FC = ({ onChange, isLoading, defaultIsOpen = false, + userLoginType, + oidcRoleSync, }) => { const styles = useStyles() const { t } = useTranslation("usersPage") @@ -71,17 +81,30 @@ export const EditRolesButton: FC = ({ onChange([...selectedRoleNames, roleName]) } + const canSetRoles = + userLoginType !== "oidc" || (userLoginType === "oidc" && !oidcRoleSync) + return ( <> - setIsOpen(true)} - > - - + + setIsOpen(true)} + > + + + + + + Externally controlled + + Roles for this user are controlled by the OIDC identity provider. + + + void isNonInitialPage: boolean actorID: string + oidcRoleSyncEnabled: boolean } export const UsersTable: FC> = ({ @@ -54,6 +55,7 @@ export const UsersTable: FC> = ({ isLoading, isNonInitialPage, actorID, + oidcRoleSyncEnabled, }) => { return ( @@ -91,6 +93,7 @@ export const UsersTable: FC> = ({ onUpdateUserRoles={onUpdateUserRoles} isNonInitialPage={isNonInitialPage} actorID={actorID} + oidcRoleSyncEnabled={oidcRoleSyncEnabled} /> diff --git a/site/src/components/UsersTable/UsersTableBody.tsx b/site/src/components/UsersTable/UsersTableBody.tsx index 8548323ce0976..24b958acfd07a 100644 --- a/site/src/components/UsersTable/UsersTableBody.tsx +++ b/site/src/components/UsersTable/UsersTableBody.tsx @@ -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( diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx index b7576f58803c8..468d986fada23 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx @@ -38,6 +38,7 @@ describe("AccountPage", () => { roles: [], avatar_url: "", last_seen_at: new Date().toString(), + login_type: "password", ...data, }), ) diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 95c0f5670be7d..67bdc9d4ae20c 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -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 }> = () => { {pageTitle("Users")} void @@ -47,6 +48,7 @@ export const UsersPageView: FC> = ({ onUpdateUserRoles, isUpdatingUserRoles, canEditUsers, + oidcRoleSyncEnabled, canViewActivity, isLoading, filterProps, @@ -77,6 +79,7 @@ export const UsersPageView: FC> = ({ onUpdateUserRoles={onUpdateUserRoles} isUpdatingUserRoles={isUpdatingUserRoles} canEditUsers={canEditUsers} + oidcRoleSyncEnabled={oidcRoleSyncEnabled} canViewActivity={canViewActivity} isLoading={isLoading} isNonInitialPage={isNonInitialPage} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 4b830d547cca0..ad7259c05a76b 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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 = {