mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
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:
@ -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)
|
||||
}
|
||||
|
Reference in New Issue
Block a user