chore: implement organization sync and create idpsync package (#14432)

* chore: implement filters for the organizations query
* chore: implement organization sync and create idpsync package

Organization sync can now be configured to assign users to an org based on oidc claims.
This commit is contained in:
Steven Masley
2024-08-30 11:19:36 -05:00
committed by GitHub
parent 043f4f5327
commit 10c958bba1
26 changed files with 1299 additions and 223 deletions

9
coderd/apidoc/docs.go generated
View File

@ -11083,6 +11083,15 @@ const docTemplate = `{
"name_field": {
"type": "string"
},
"organization_assign_default": {
"type": "boolean"
},
"organization_field": {
"type": "string"
},
"organization_mapping": {
"type": "object"
},
"scopes": {
"type": "array",
"items": {

View File

@ -9978,6 +9978,15 @@
"name_field": {
"type": "string"
},
"organization_assign_default": {
"type": "boolean"
},
"organization_field": {
"type": "string"
},
"organization_mapping": {
"type": "object"
},
"scopes": {
"type": "array",
"items": {

View File

@ -38,6 +38,7 @@ import (
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/entitlements"
"github.com/coder/coder/v2/coderd/idpsync"
"github.com/coder/quartz"
"github.com/coder/serpent"
@ -243,6 +244,9 @@ type Options struct {
WorkspaceUsageTracker *workspacestats.UsageTracker
// NotificationsEnqueuer handles enqueueing notifications for delivery by SMTP, webhook, etc.
NotificationsEnqueuer notifications.Enqueuer
// IDPSync holds all configured values for syncing external IDP users into Coder.
IDPSync idpsync.IDPSync
}
// @title Coder API
@ -270,6 +274,13 @@ func New(options *Options) *API {
if options.Entitlements == nil {
options.Entitlements = entitlements.New()
}
if options.IDPSync == nil {
options.IDPSync = idpsync.NewAGPLSync(options.Logger, idpsync.SyncSettings{
OrganizationField: options.DeploymentValues.OIDC.OrganizationField.Value(),
OrganizationMapping: options.DeploymentValues.OIDC.OrganizationMapping.Value,
OrganizationAssignDefault: options.DeploymentValues.OIDC.OrganizationAssignDefault.Value(),
})
}
if options.NewTicker == nil {
options.NewTicker = func(duration time.Duration) (tick <-chan time.Time, done func()) {
ticker := time.NewTicker(duration)

View File

@ -243,7 +243,7 @@ var (
rbac.ResourceAssignOrgRole.Type: rbac.ResourceAssignOrgRole.AvailableActions(),
rbac.ResourceSystem.Type: {policy.WildcardSymbol},
rbac.ResourceOrganization.Type: {policy.ActionCreate, policy.ActionRead},
rbac.ResourceOrganizationMember.Type: {policy.ActionCreate},
rbac.ResourceOrganizationMember.Type: {policy.ActionCreate, policy.ActionDelete, policy.ActionRead},
rbac.ResourceProvisionerDaemon.Type: {policy.ActionCreate, policy.ActionUpdate},
rbac.ResourceProvisionerKeys.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionDelete},
rbac.ResourceUser.Type: rbac.ResourceUser.AvailableActions(),

172
coderd/idpsync/idpsync.go Normal file
View File

@ -0,0 +1,172 @@
package idpsync
import (
"context"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/site"
)
// IDPSync is an interface, so we can implement this as AGPL and as enterprise,
// and just swap the underlying implementation.
// IDPSync exists to contain all the logic for mapping a user's external IDP
// claims to the internal representation of a user in Coder.
// TODO: Move group + role sync into this interface.
type IDPSync interface {
OrganizationSyncEnabled() bool
// ParseOrganizationClaims takes claims from an OIDC provider, and returns the
// organization sync params for assigning users into organizations.
ParseOrganizationClaims(ctx context.Context, _ jwt.MapClaims) (OrganizationParams, *HTTPError)
// SyncOrganizations assigns and removed users from organizations based on the
// provided params.
SyncOrganizations(ctx context.Context, tx database.Store, user database.User, params OrganizationParams) error
}
// AGPLIDPSync is the configuration for syncing user information from an external
// IDP. All related code to syncing user information should be in this package.
type AGPLIDPSync struct {
Logger slog.Logger
SyncSettings
}
type SyncSettings struct {
// OrganizationField selects the claim field to be used as the created user's
// organizations. If the field is the empty string, then no organization updates
// will ever come from the OIDC provider.
OrganizationField string
// OrganizationMapping controls how organizations returned by the OIDC provider get mapped
OrganizationMapping map[string][]uuid.UUID
// OrganizationAssignDefault will ensure all users that authenticate will be
// placed into the default organization. This is mostly a hack to support
// legacy deployments.
OrganizationAssignDefault bool
}
type OrganizationParams struct {
// SyncEnabled if false will skip syncing the user's organizations.
SyncEnabled bool
// IncludeDefault is primarily for single org deployments. It will ensure
// a user is always inserted into the default org.
IncludeDefault bool
// Organizations is the list of organizations the user should be a member of
// assuming syncing is turned on.
Organizations []uuid.UUID
}
func NewAGPLSync(logger slog.Logger, settings SyncSettings) *AGPLIDPSync {
return &AGPLIDPSync{
Logger: logger.Named("idp-sync"),
SyncSettings: settings,
}
}
// ParseStringSliceClaim parses the claim for groups and roles, expected []string.
//
// Some providers like ADFS return a single string instead of an array if there
// is only 1 element. So this function handles the edge cases.
func ParseStringSliceClaim(claim interface{}) ([]string, error) {
groups := make([]string, 0)
if claim == nil {
return groups, nil
}
// The simple case is the type is exactly what we expected
asStringArray, ok := claim.([]string)
if ok {
return asStringArray, nil
}
asArray, ok := claim.([]interface{})
if ok {
for i, item := range asArray {
asString, ok := item.(string)
if !ok {
return nil, xerrors.Errorf("invalid claim type. Element %d expected a string, got: %T", i, item)
}
groups = append(groups, asString)
}
return groups, nil
}
asString, ok := claim.(string)
if ok {
if asString == "" {
// Empty string should be 0 groups.
return []string{}, nil
}
// If it is a single string, first check if it is a csv.
// If a user hits this, it is likely a misconfiguration and they need
// to reconfigure their IDP to send an array instead.
if strings.Contains(asString, ",") {
return nil, xerrors.Errorf("invalid claim type. Got a csv string (%q), change this claim to return an array of strings instead.", asString)
}
return []string{asString}, nil
}
// Not sure what the user gave us.
return nil, xerrors.Errorf("invalid claim type. Expected an array of strings, got: %T", claim)
}
// IsHTTPError handles us being inconsistent with returning errors as values or
// pointers.
func IsHTTPError(err error) *HTTPError {
var httpErr HTTPError
if xerrors.As(err, &httpErr) {
return &httpErr
}
var httpErrPtr *HTTPError
if xerrors.As(err, &httpErrPtr) {
return httpErrPtr
}
return nil
}
// HTTPError is a helper struct for returning errors from the IDP sync process.
// A regular error is not sufficient because many of these errors are surfaced
// to a user logging in, and the errors should be descriptive.
type HTTPError struct {
Code int
Msg string
Detail string
RenderStaticPage bool
RenderDetailMarkdown bool
}
func (e HTTPError) Write(rw http.ResponseWriter, r *http.Request) {
if e.RenderStaticPage {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: e.Code,
HideStatus: true,
Title: e.Msg,
Description: e.Detail,
RetryEnabled: false,
DashboardURL: "/login",
RenderDescriptionMarkdown: e.RenderDetailMarkdown,
})
return
}
httpapi.Write(r.Context(), rw, e.Code, codersdk.Response{
Message: e.Msg,
Detail: e.Detail,
})
}
func (e HTTPError) Error() string {
if e.Detail != "" {
return e.Detail
}
return e.Msg
}

View File

@ -1,10 +1,12 @@
package coderd
package idpsync_test
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/idpsync"
)
func TestParseStringSliceClaim(t *testing.T) {
@ -123,7 +125,7 @@ func TestParseStringSliceClaim(t *testing.T) {
require.NoError(t, err, "unmarshal json claim")
}
found, err := parseStringSliceClaim(c.GoClaim)
found, err := idpsync.ParseStringSliceClaim(c.GoClaim)
if c.ErrorExpected {
require.Error(t, err)
} else {
@ -133,3 +135,13 @@ func TestParseStringSliceClaim(t *testing.T) {
})
}
}
func TestIsHTTPError(t *testing.T) {
t.Parallel()
herr := idpsync.HTTPError{}
require.NotNil(t, idpsync.IsHTTPError(herr))
require.NotNil(t, idpsync.IsHTTPError(&herr))
require.Nil(t, error(nil))
}

View File

@ -0,0 +1,104 @@
package idpsync
import (
"context"
"database/sql"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/util/slice"
)
func (AGPLIDPSync) OrganizationSyncEnabled() bool {
// AGPL does not support syncing organizations.
return false
}
func (s AGPLIDPSync) ParseOrganizationClaims(_ context.Context, _ jwt.MapClaims) (OrganizationParams, *HTTPError) {
// For AGPL we only sync the default organization.
return OrganizationParams{
SyncEnabled: s.OrganizationSyncEnabled(),
IncludeDefault: s.OrganizationAssignDefault,
Organizations: []uuid.UUID{},
}, nil
}
// SyncOrganizations if enabled will ensure the user is a member of the provided
// organizations. It will add and remove their membership to match the expected set.
func (s AGPLIDPSync) SyncOrganizations(ctx context.Context, tx database.Store, user database.User, params OrganizationParams) error {
// Nothing happens if sync is not enabled
if !params.SyncEnabled {
return nil
}
// nolint:gocritic // all syncing is done as a system user
ctx = dbauthz.AsSystemRestricted(ctx)
// This is a bit hacky, but if AssignDefault is included, then always
// make sure to include the default org in the list of expected.
if s.OrganizationAssignDefault {
defaultOrg, err := tx.GetDefaultOrganization(ctx)
if err != nil {
return xerrors.Errorf("failed to get default organization: %w", err)
}
params.Organizations = append(params.Organizations, defaultOrg.ID)
}
existingOrgs, err := tx.GetOrganizationsByUserID(ctx, user.ID)
if err != nil {
return xerrors.Errorf("failed to get user organizations: %w", err)
}
existingOrgIDs := db2sdk.List(existingOrgs, func(org database.Organization) uuid.UUID {
return org.ID
})
// Find the difference in the expected and the existing orgs, and
// correct the set of orgs the user is a member of.
add, remove := slice.SymmetricDifference(existingOrgIDs, params.Organizations)
notExists := make([]uuid.UUID, 0)
for _, orgID := range add {
//nolint:gocritic // System actor being used to assign orgs
_, err := tx.InsertOrganizationMember(dbauthz.AsSystemRestricted(ctx), database.InsertOrganizationMemberParams{
OrganizationID: orgID,
UserID: user.ID,
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
Roles: []string{},
})
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
notExists = append(notExists, orgID)
continue
}
return xerrors.Errorf("add user to organization: %w", err)
}
}
for _, orgID := range remove {
//nolint:gocritic // System actor being used to assign orgs
err := tx.DeleteOrganizationMember(dbauthz.AsSystemRestricted(ctx), database.DeleteOrganizationMemberParams{
OrganizationID: orgID,
UserID: user.ID,
})
if err != nil {
return xerrors.Errorf("remove user from organization: %w", err)
}
}
if len(notExists) > 0 {
s.Logger.Debug(ctx, "organizations do not exist but attempted to use in org sync",
slog.F("not_found", notExists),
slog.F("user_id", user.ID),
slog.F("username", user.Username),
)
}
return nil
}

View File

@ -0,0 +1,58 @@
package idpsync_test
import (
"testing"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/idpsync"
"github.com/coder/coder/v2/testutil"
)
func TestParseOrganizationClaims(t *testing.T) {
t.Parallel()
t.Run("SingleOrgDeployment", func(t *testing.T) {
t.Parallel()
s := idpsync.NewAGPLSync(slogtest.Make(t, &slogtest.Options{}), idpsync.SyncSettings{
OrganizationField: "",
OrganizationMapping: nil,
OrganizationAssignDefault: true,
})
ctx := testutil.Context(t, testutil.WaitMedium)
params, err := s.ParseOrganizationClaims(ctx, jwt.MapClaims{})
require.Nil(t, err)
require.Empty(t, params.Organizations)
require.True(t, params.IncludeDefault)
require.False(t, params.SyncEnabled)
})
t.Run("AGPL", func(t *testing.T) {
t.Parallel()
// AGPL has limited behavior
s := idpsync.NewAGPLSync(slogtest.Make(t, &slogtest.Options{}), idpsync.SyncSettings{
OrganizationField: "orgs",
OrganizationMapping: map[string][]uuid.UUID{
"random": {uuid.New()},
},
OrganizationAssignDefault: false,
})
ctx := testutil.Context(t, testutil.WaitMedium)
params, err := s.ParseOrganizationClaims(ctx, jwt.MapClaims{})
require.Nil(t, err)
require.Empty(t, params.Organizations)
require.False(t, params.IncludeDefault)
require.False(t, params.SyncEnabled)
})
}

View File

@ -2,6 +2,7 @@ package coderd
import (
"context"
"fmt"
"net/http"
"github.com/google/uuid"
@ -43,6 +44,14 @@ func (api *API) postOrganizationMember(rw http.ResponseWriter, r *http.Request)
aReq.Old = database.AuditableOrganizationMember{}
defer commitAudit()
if user.LoginType == database.LoginTypeOIDC && api.IDPSync.OrganizationSyncEnabled() {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Organization sync is enabled for OIDC users, meaning manual organization assignment is not allowed for this user.",
Detail: fmt.Sprintf("User %s is an OIDC user and organization sync is enabled. Ask an administrator to resolve this in your external IDP.", user.ID),
})
return
}
member, err := api.Database.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{
OrganizationID: organization.ID,
UserID: user.ID,

View File

@ -25,6 +25,7 @@ import (
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/idpsync"
"github.com/coder/coder/v2/coderd/apikey"
"github.com/coder/coder/v2/coderd/audit"
@ -40,7 +41,6 @@ import (
"github.com/coder/coder/v2/coderd/userpassword"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/cryptorand"
"github.com/coder/coder/v2/site"
)
const (
@ -659,17 +659,21 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
AvatarURL: ghUser.GetAvatarURL(),
Name: normName,
DebugContext: OauthDebugContext{},
OrganizationSync: idpsync.OrganizationParams{
SyncEnabled: false,
IncludeDefault: true,
Organizations: []uuid.UUID{},
},
}).SetInitAuditRequest(func(params *audit.RequestParams) (*audit.Request[database.User], func()) {
return audit.InitRequest[database.User](rw, params)
})
cookies, user, key, err := api.oauthLogin(r, params)
defer params.CommitAuditLogs()
var httpErr httpError
if xerrors.As(err, &httpErr) {
httpErr.Write(rw, r)
return
}
if err != nil {
if httpErr := idpsync.IsHTTPError(err); httpErr != nil {
httpErr.Write(rw, r)
return
}
logger.Error(ctx, "oauth2: login failed", slog.F("user", user.Username), slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to process OAuth login.",
@ -737,6 +741,8 @@ type OIDCConfig struct {
// support the userinfo endpoint, or if the userinfo endpoint causes
// undesirable behavior.
IgnoreUserInfo bool
// TODO: Move all idp fields into the IDPSync struct
// GroupField selects the claim field to be used as the created user's
// groups. If the group field is the empty string, then no group updates
// will ever come from the OIDC provider.
@ -1020,6 +1026,12 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
return
}
orgSync, orgSyncErr := api.IDPSync.ParseOrganizationClaims(ctx, mergedClaims)
if orgSyncErr != nil {
orgSyncErr.Write(rw, r)
return
}
// If a new user is authenticating for the first time
// the audit action is 'register', not 'login'
if user.ID == uuid.Nil {
@ -1041,6 +1053,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
Roles: roles,
UsingGroups: usingGroups,
Groups: groups,
OrganizationSync: orgSync,
CreateMissingGroups: api.OIDCConfig.CreateMissingGroups,
GroupFilter: api.OIDCConfig.GroupFilter,
DebugContext: OauthDebugContext{
@ -1052,12 +1065,11 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
})
cookies, user, key, err := api.oauthLogin(r, params)
defer params.CommitAuditLogs()
var httpErr httpError
if xerrors.As(err, &httpErr) {
httpErr.Write(rw, r)
return
}
if err != nil {
if hErr := idpsync.IsHTTPError(err); hErr != nil {
hErr.Write(rw, r)
return
}
logger.Error(ctx, "oauth2: login failed", slog.F("user", user.Username), slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to process OAuth login.",
@ -1080,7 +1092,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
}
// oidcGroups returns the groups for the user from the OIDC claims.
func (api *API) oidcGroups(ctx context.Context, mergedClaims map[string]interface{}) (bool, []string, *httpError) {
func (api *API) oidcGroups(ctx context.Context, mergedClaims map[string]interface{}) (bool, []string, *idpsync.HTTPError) {
logger := api.Logger.Named(userAuthLoggerName)
usingGroups := false
var groups []string
@ -1095,17 +1107,17 @@ func (api *API) oidcGroups(ctx context.Context, mergedClaims map[string]interfac
usingGroups = true
groupsRaw, ok := mergedClaims[api.OIDCConfig.GroupField]
if ok {
parsedGroups, err := parseStringSliceClaim(groupsRaw)
parsedGroups, err := idpsync.ParseStringSliceClaim(groupsRaw)
if err != nil {
api.Logger.Debug(ctx, "groups field was an unknown type in oidc claims",
slog.F("type", fmt.Sprintf("%T", groupsRaw)),
slog.Error(err),
)
return false, nil, &httpError{
code: http.StatusBadRequest,
msg: "Failed to sync groups from OIDC claims",
detail: err.Error(),
renderStaticPage: false,
return false, nil, &idpsync.HTTPError{
Code: http.StatusBadRequest,
Msg: "Failed to sync groups from OIDC claims",
Detail: err.Error(),
RenderStaticPage: false,
}
}
@ -1134,11 +1146,11 @@ func (api *API) oidcGroups(ctx context.Context, mergedClaims map[string]interfac
if len(groups) == 0 {
detail = "You are currently not a member of any groups! Ask an administrator to add you to an authorized group to login."
}
return usingGroups, groups, &httpError{
code: http.StatusForbidden,
msg: "Not a member of an allowed group",
detail: detail,
renderStaticPage: true,
return usingGroups, groups, &idpsync.HTTPError{
Code: http.StatusForbidden,
Msg: "Not a member of an allowed group",
Detail: detail,
RenderStaticPage: true,
}
}
}
@ -1158,7 +1170,7 @@ func (api *API) oidcGroups(ctx context.Context, mergedClaims map[string]interfac
// It would be preferred to just return an error, however this function
// decorates returned errors with the appropriate HTTP status codes and details
// that are hard to carry in a standard `error` without more work.
func (api *API) oidcRoles(ctx context.Context, mergedClaims map[string]interface{}) ([]string, *httpError) {
func (api *API) oidcRoles(ctx context.Context, mergedClaims map[string]interface{}) ([]string, *idpsync.HTTPError) {
roles := api.OIDCConfig.UserRolesDefault
if !api.OIDCConfig.RoleSyncEnabled() {
return roles, nil
@ -1174,17 +1186,17 @@ func (api *API) oidcRoles(ctx context.Context, mergedClaims map[string]interface
rolesRow = []interface{}{}
}
parsedRoles, err := parseStringSliceClaim(rolesRow)
parsedRoles, err := idpsync.ParseStringSliceClaim(rolesRow)
if err != nil {
api.Logger.Error(ctx, "oidc claims user roles field was an unknown type",
slog.F("type", fmt.Sprintf("%T", rolesRow)),
slog.Error(err),
)
return nil, &httpError{
code: http.StatusInternalServerError,
msg: "Login disabled until OIDC config is fixed",
detail: fmt.Sprintf("Roles claim must be an array of strings, type found: %T. Disabling role sync will allow login to proceed.", rolesRow),
renderStaticPage: false,
return nil, &idpsync.HTTPError{
Code: http.StatusInternalServerError,
Msg: "Login disabled until OIDC config is fixed",
Detail: fmt.Sprintf("Roles claim must be an array of strings, type found: %T. Disabling role sync will allow login to proceed.", rolesRow),
RenderStaticPage: false,
}
}
@ -1264,6 +1276,8 @@ type oauthLoginParams struct {
Username string
Name string
AvatarURL string
// OrganizationSync has the organizations that the user will be assigned to.
OrganizationSync idpsync.OrganizationParams
// Is UsingGroups is true, then the user will be assigned
// to the Groups provided.
UsingGroups bool
@ -1303,43 +1317,6 @@ func (p *oauthLoginParams) CommitAuditLogs() {
}
}
type httpError struct {
code int
msg string
detail string
renderStaticPage bool
renderDetailMarkdown bool
}
func (e httpError) Write(rw http.ResponseWriter, r *http.Request) {
if e.renderStaticPage {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: e.code,
HideStatus: true,
Title: e.msg,
Description: e.detail,
RetryEnabled: false,
DashboardURL: "/login",
RenderDescriptionMarkdown: e.renderDetailMarkdown,
})
return
}
httpapi.Write(r.Context(), rw, e.code, codersdk.Response{
Message: e.msg,
Detail: e.detail,
})
}
func (e httpError) Error() string {
if e.detail != "" {
return e.detail
}
return e.msg
}
func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.Cookie, database.User, database.APIKey, error) {
var (
ctx = r.Context()
@ -1376,13 +1353,12 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
if api.OIDCConfig != nil && api.OIDCConfig.SignupsDisabledText != "" {
signupsDisabledText = render.HTMLFromMarkdown(api.OIDCConfig.SignupsDisabledText)
}
return httpError{
code: http.StatusForbidden,
msg: "Signups are disabled",
detail: signupsDisabledText,
renderStaticPage: true,
renderDetailMarkdown: true,
return &idpsync.HTTPError{
Code: http.StatusForbidden,
Msg: "Signups are disabled",
Detail: signupsDisabledText,
RenderStaticPage: true,
RenderDetailMarkdown: true,
}
}
@ -1428,19 +1404,26 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
}
}
if !validUsername {
return httpError{
code: http.StatusConflict,
msg: fmt.Sprintf("exhausted alternatives for taken username %q", original),
return &idpsync.HTTPError{
Code: http.StatusConflict,
Msg: fmt.Sprintf("exhausted alternatives for taken username %q", original),
}
}
}
// Even if org sync is disabled, single org deployments will always
// have this set to true.
orgIDs := []uuid.UUID{}
if params.OrganizationSync.IncludeDefault {
orgIDs = append(orgIDs, defaultOrganization.ID)
}
//nolint:gocritic
user, err = api.CreateUser(dbauthz.AsSystemRestricted(ctx), tx, CreateUserRequest{
CreateUserRequestWithOrgs: codersdk.CreateUserRequestWithOrgs{
Email: params.Email,
Username: params.Username,
OrganizationIDs: []uuid.UUID{defaultOrganization.ID},
OrganizationIDs: orgIDs,
},
LoginType: params.LoginType,
})
@ -1503,6 +1486,11 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
}
}
err = api.IDPSync.SyncOrganizations(ctx, tx, user, params.OrganizationSync)
if err != nil {
return xerrors.Errorf("sync organizations: %w", err)
}
// Ensure groups are correct.
// This places all groups into the default organization.
// To go multi-org, we need to add a mapping feature here to know which
@ -1569,11 +1557,11 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
//nolint:gocritic
err := api.Options.SetUserSiteRoles(dbauthz.AsSystemRestricted(ctx), logger, tx, user.ID, filtered)
if err != nil {
return httpError{
code: http.StatusBadRequest,
msg: "Invalid roles through OIDC claims",
detail: fmt.Sprintf("Error from role assignment attempt: %s", err.Error()),
renderStaticPage: true,
return &idpsync.HTTPError{
Code: http.StatusBadRequest,
Msg: "Invalid roles through OIDC claims",
Detail: fmt.Sprintf("Error from role assignment attempt: %s", err.Error()),
RenderStaticPage: true,
}
}
if len(ignored) > 0 {
@ -1684,17 +1672,17 @@ func (api *API) convertUserToOauth(ctx context.Context, r *http.Request, db data
// Trying to convert to OIDC, but the email does not match.
// So do not make a new user, just block the request.
if user.ID == uuid.Nil {
return database.User{}, httpError{
code: http.StatusBadRequest,
msg: fmt.Sprintf("The oidc account with the email %q does not match the email of the account you are trying to convert. Contact your administrator to resolve this issue.", params.Email),
return database.User{}, idpsync.HTTPError{
Code: http.StatusBadRequest,
Msg: fmt.Sprintf("The oidc account with the email %q does not match the email of the account you are trying to convert. Contact your administrator to resolve this issue.", params.Email),
}
}
jwtCookie, err := r.Cookie(OAuthConvertCookieValue)
if err != nil {
return database.User{}, httpError{
code: http.StatusBadRequest,
msg: fmt.Sprintf("Convert to oauth cookie not found. Missing signed jwt to authorize this action. " +
return database.User{}, idpsync.HTTPError{
Code: http.StatusBadRequest,
Msg: fmt.Sprintf("Convert to oauth cookie not found. Missing signed jwt to authorize this action. " +
"Please try again."),
}
}
@ -1704,15 +1692,15 @@ func (api *API) convertUserToOauth(ctx context.Context, r *http.Request, db data
})
if xerrors.Is(err, jwt.ErrSignatureInvalid) || !token.Valid {
// These errors are probably because the user is mixing 2 coder deployments.
return database.User{}, httpError{
code: http.StatusBadRequest,
msg: "Using an invalid jwt to authorize this action. Ensure there is only 1 coder deployment and try again.",
return database.User{}, idpsync.HTTPError{
Code: http.StatusBadRequest,
Msg: "Using an invalid jwt to authorize this action. Ensure there is only 1 coder deployment and try again.",
}
}
if err != nil {
return database.User{}, httpError{
code: http.StatusInternalServerError,
msg: fmt.Sprintf("Error parsing jwt: %v", err),
return database.User{}, idpsync.HTTPError{
Code: http.StatusInternalServerError,
Msg: fmt.Sprintf("Error parsing jwt: %v", err),
}
}
@ -1732,16 +1720,16 @@ func (api *API) convertUserToOauth(ctx context.Context, r *http.Request, db data
oauthConvertAudit.Old = user
if claims.RegisteredClaims.Issuer != api.DeploymentID {
return database.User{}, httpError{
code: http.StatusForbidden,
msg: "Request to convert login type failed. Issuer mismatch. Found a cookie from another coder deployment, please try again.",
return database.User{}, idpsync.HTTPError{
Code: http.StatusForbidden,
Msg: "Request to convert login type failed. Issuer mismatch. Found a cookie from another coder deployment, please try again.",
}
}
if params.State.StateString != claims.State {
return database.User{}, httpError{
code: http.StatusForbidden,
msg: "Request to convert login type failed. State mismatch.",
return database.User{}, idpsync.HTTPError{
Code: http.StatusForbidden,
Msg: "Request to convert login type failed. State mismatch.",
}
}
@ -1751,9 +1739,9 @@ func (api *API) convertUserToOauth(ctx context.Context, r *http.Request, db data
if user.ID != claims.UserID ||
codersdk.LoginType(user.LoginType) != claims.FromLoginType ||
codersdk.LoginType(params.LoginType) != claims.ToLoginType {
return database.User{}, httpError{
code: http.StatusForbidden,
msg: fmt.Sprintf("Request to convert login type from %s to %s failed", user.LoginType, params.LoginType),
return database.User{}, idpsync.HTTPError{
Code: http.StatusForbidden,
Msg: fmt.Sprintf("Request to convert login type from %s to %s failed", user.LoginType, params.LoginType),
}
}
@ -1767,9 +1755,9 @@ func (api *API) convertUserToOauth(ctx context.Context, r *http.Request, db data
UserID: user.ID,
})
if err != nil {
return database.User{}, httpError{
code: http.StatusInternalServerError,
msg: "Failed to convert user to new login type",
return database.User{}, idpsync.HTTPError{
Code: http.StatusInternalServerError,
Msg: "Failed to convert user to new login type",
}
}
oauthConvertAudit.New = user
@ -1855,63 +1843,16 @@ func clearOAuthConvertCookie() *http.Cookie {
}
}
func wrongLoginTypeHTTPError(user database.LoginType, params database.LoginType) httpError {
func wrongLoginTypeHTTPError(user database.LoginType, params database.LoginType) idpsync.HTTPError {
addedMsg := ""
if user == database.LoginTypePassword {
addedMsg = " You can convert your account to use this login type by visiting your account settings."
}
return httpError{
code: http.StatusForbidden,
renderStaticPage: true,
msg: "Incorrect login type",
detail: fmt.Sprintf("Attempting to use login type %q, but the user has the login type %q.%s",
return idpsync.HTTPError{
Code: http.StatusForbidden,
RenderStaticPage: true,
Msg: "Incorrect login type",
Detail: fmt.Sprintf("Attempting to use login type %q, but the user has the login type %q.%s",
params, user, addedMsg),
}
}
// parseStringSliceClaim parses the claim for groups and roles, expected []string.
//
// Some providers like ADFS return a single string instead of an array if there
// is only 1 element. So this function handles the edge cases.
func parseStringSliceClaim(claim interface{}) ([]string, error) {
groups := make([]string, 0)
if claim == nil {
return groups, nil
}
// The simple case is the type is exactly what we expected
asStringArray, ok := claim.([]string)
if ok {
return asStringArray, nil
}
asArray, ok := claim.([]interface{})
if ok {
for i, item := range asArray {
asString, ok := item.(string)
if !ok {
return nil, xerrors.Errorf("invalid claim type. Element %d expected a string, got: %T", i, item)
}
groups = append(groups, asString)
}
return groups, nil
}
asString, ok := claim.(string)
if ok {
if asString == "" {
// Empty string should be 0 groups.
return []string{}, nil
}
// If it is a single string, first check if it is a csv.
// If a user hits this, it is likely a misconfiguration and they need
// to reconfigure their IDP to send an array instead.
if strings.Contains(asString, ",") {
return nil, xerrors.Errorf("invalid claim type. Got a csv string (%q), change this claim to return an array of strings instead.", asString)
}
return []string{asString}, nil
}
// Not sure what the user gave us.
return nil, xerrors.Errorf("invalid claim type. Expected an array of strings, got: %T", claim)
}

View File

@ -366,6 +366,7 @@ func TestUserOAuth2Github(t *testing.T) {
require.Equal(t, "kyle", user.Username)
require.Equal(t, "Kylium Carbonate", user.Name)
require.Equal(t, "/hello-world", user.AvatarURL)
require.Equal(t, 1, len(user.OrganizationIDs), "in the default org")
require.Len(t, auditor.AuditLogs(), numLogs)
require.NotEqual(t, auditor.AuditLogs()[numLogs-1].UserID, uuid.Nil)
@ -419,6 +420,7 @@ func TestUserOAuth2Github(t *testing.T) {
require.Equal(t, "kyle", user.Username)
require.Equal(t, strings.Repeat("a", 128), user.Name)
require.Equal(t, "/hello-world", user.AvatarURL)
require.Equal(t, 1, len(user.OrganizationIDs), "in the default org")
require.Len(t, auditor.AuditLogs(), numLogs)
require.NotEqual(t, auditor.AuditLogs()[numLogs-1].UserID, uuid.Nil)
@ -474,6 +476,7 @@ func TestUserOAuth2Github(t *testing.T) {
require.Equal(t, "kyle", user.Username)
require.Equal(t, "Kylium Carbonate", user.Name)
require.Equal(t, "/hello-world", user.AvatarURL)
require.Equal(t, 1, len(user.OrganizationIDs), "in the default org")
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
require.Len(t, auditor.AuditLogs(), numLogs)
@ -536,6 +539,7 @@ func TestUserOAuth2Github(t *testing.T) {
require.Equal(t, "mathias@coder.com", user.Email)
require.Equal(t, "mathias", user.Username)
require.Equal(t, "Mathias Mathias", user.Name)
require.Equal(t, 1, len(user.OrganizationIDs), "in the default org")
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
require.Len(t, auditor.AuditLogs(), numLogs)
@ -598,6 +602,7 @@ func TestUserOAuth2Github(t *testing.T) {
require.Equal(t, "mathias@coder.com", user.Email)
require.Equal(t, "mathias", user.Username)
require.Equal(t, "Mathias Mathias", user.Name)
require.Equal(t, 1, len(user.OrganizationIDs), "in the default org")
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
require.Len(t, auditor.AuditLogs(), numLogs)
@ -1270,6 +1275,7 @@ func TestUserOIDC(t *testing.T) {
require.Len(t, auditor.AuditLogs(), numLogs)
require.NotEqual(t, uuid.Nil, auditor.AuditLogs()[numLogs-1].UserID)
require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action)
require.Equal(t, 1, len(user.OrganizationIDs), "in the default org")
}
})
}

View File

@ -1294,10 +1294,6 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create
var user database.User
err := store.InTx(func(tx database.Store) error {
orgRoles := make([]string, 0)
// Organization is required to know where to allocate the user.
if len(req.OrganizationIDs) == 0 {
return xerrors.Errorf("organization ID must be provided")
}
params := database.InsertUserParams{
ID: uuid.New(),