mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
* 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.
173 lines
5.2 KiB
Go
173 lines
5.2 KiB
Go
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
|
|
}
|