Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
chore: refactor into agpl and enterprise
  • Loading branch information
Emyrk committed Aug 29, 2024
commit f596da131ccc1dd93c113b0c5d147c343ccf0685
6 changes: 6 additions & 0 deletions cli/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import (

"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/v2/coderd/idpsync"
"github.com/coder/pretty"
"github.com/coder/quartz"
"github.com/coder/retry"
Expand Down Expand Up @@ -197,6 +198,11 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De
SignupsDisabledText: vals.OIDC.SignupsDisabledText.String(),
IconURL: vals.OIDC.IconURL.String(),
IgnoreEmailVerified: vals.OIDC.IgnoreEmailVerified.Value(),
IDPSync: idpsync.NewSync(logger, idpsync.SyncSettings{
OrganizationField: vals.OIDC.OrganizationField.Value(),
OrganizationMapping: vals.OIDC.OrganizationMapping.Value,
OrganizationAssignDefault: vals.OIDC.OrganizationAssignDefault.Value,
}),
}, nil
}

Expand Down
38 changes: 26 additions & 12 deletions coderd/idpsync/idpsync.go
Original file line number Diff line number Diff line change
@@ -1,40 +1,54 @@
package idpsync

import (
"context"
"net/http"
"strings"

"github.com/google/uuid"
"golang.org/x/xerrors"

"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/entitlements"
"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 the configuration for syncing user information from an external
type IDPSync interface {
// ParseOrganizationClaims takes claims from an OIDC provider, and returns the
// organization sync params for assigning users into organizations.
ParseOrganizationClaims(ctx context.Context, _ map[string]interface{}) (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 IDPSync struct {
logger slog.Logger
entitlements *entitlements.Set
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
// OrganizationAlwaysAssign will ensure all users that authenticate will be
// placed into the specified organizations.
OrganizationAlwaysAssign []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
}

func NewSync(logger slog.Logger, set *entitlements.Set) *IDPSync {
return &IDPSync{
logger: logger.Named("idp-sync"),
entitlements: set,
func NewSync(logger slog.Logger, settings SyncSettings) *AGPLIDPSync {
return &AGPLIDPSync{
Logger: logger.Named("idp-sync"),
SyncSettings: settings,
}
}

Expand Down
69 changes: 25 additions & 44 deletions coderd/idpsync/organization.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package idpsync
import (
"context"
"database/sql"
"net/http"

"github.com/google/uuid"
"golang.org/x/xerrors"
Expand All @@ -16,64 +15,46 @@ import (
"github.com/coder/coder/v2/coderd/util/slice"
)

func (s IDPSync) ParseOrganizationClaims(ctx context.Context, mergedClaims map[string]interface{}) (OrganizationParams, *HttpError) {
func (s AGPLIDPSync) ParseOrganizationClaims(ctx context.Context, _ map[string]interface{}) (OrganizationParams, *HttpError) {
// nolint:gocritic // all syncing is done as a system user
ctx = dbauthz.AsSystemRestricted(ctx)

// Copy in the always included static set of organizations.
userOrganizations := make([]uuid.UUID, len(s.OrganizationAlwaysAssign))
copy(userOrganizations, s.OrganizationAlwaysAssign)

// Pull extra organizations from the claims.
if s.OrganizationField != "" {
organizationRaw, ok := mergedClaims[s.OrganizationField]
if ok {
parsedOrganizations, err := ParseStringSliceClaim(organizationRaw)
if err != nil {
return OrganizationParams{}, &HttpError{
Code: http.StatusBadRequest,
Msg: "Failed to sync organizations from the OIDC claims",
Detail: err.Error(),
RenderStaticPage: false,
RenderDetailMarkdown: false,
}
}

// Keep track of which claims are not mapped for debugging purposes.
var ignored []string
for _, parsedOrg := range parsedOrganizations {
if mappedOrganization, ok := s.OrganizationMapping[parsedOrg]; ok {
// parsedOrg is in the mapping, so add the mapped organizations to the
// user's organizations.
userOrganizations = append(userOrganizations, mappedOrganization...)
} else {
ignored = append(ignored, parsedOrg)
}
}

s.logger.Debug(ctx, "parsed organizations from claim",
slog.F("len", len(parsedOrganizations)),
slog.F("ignored", ignored),
slog.F("organizations", parsedOrganizations),
)
}
}

// For AGPL we only rely on 'OrganizationAlwaysAssign'
return OrganizationParams{
Organizations: userOrganizations,
SyncEnabled: false,
IncludeDefault: s.OrganizationAssignDefault,
Organizations: []uuid.UUID{},
}, nil
}

type OrganizationParams struct {
// SyncEnabled if false will skip syncing the user's organizations.
SyncEnabled bool
IncludeDefault bool
// Organizations is the list of organizations the user should be a member of
// assuming syncing is turned on.
Organizations []uuid.UUID
}

func (s IDPSync) SyncOrganizations(ctx context.Context, tx database.Store, user database.User, params OrganizationParams) error {
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)
Expand Down Expand Up @@ -117,7 +98,7 @@ func (s IDPSync) SyncOrganizations(ctx context.Context, tx database.Store, user
}

if len(notExists) > 0 {
s.logger.Debug(ctx, "organizations do not exist but attempted to use in org sync",
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),
Expand Down
15 changes: 5 additions & 10 deletions coderd/userauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,11 @@ type OIDCConfig struct {
// support the userinfo endpoint, or if the userinfo endpoint causes
// undesirable behavior.
IgnoreUserInfo bool
// IDPSync contains all the configuration for syncing user information
// from the external IDP.
IDPSync idpsync.IDPSync

// 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.
Expand Down Expand Up @@ -768,16 +773,6 @@ type OIDCConfig struct {
// UserRolesDefault is the default set of roles to assign to a user if role sync
// is enabled.
UserRolesDefault []string
// 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][]string
// OrganizationAlwaysAssign will ensure all users that authenticate will be
// placed into the specified organizations. 'default' is a special keyword
// that will use the `IsDefault` organization.
OrganizationAlwaysAssign []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
Expand Down
50 changes: 27 additions & 23 deletions codersdk/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"strings"
"time"

"github.com/google/uuid"
"golang.org/x/mod/semver"
"golang.org/x/xerrors"

Expand Down Expand Up @@ -512,29 +513,32 @@ type OIDCConfig struct {
ClientID serpent.String `json:"client_id" typescript:",notnull"`
ClientSecret serpent.String `json:"client_secret" typescript:",notnull"`
// ClientKeyFile & ClientCertFile are used in place of ClientSecret for PKI auth.
ClientKeyFile serpent.String `json:"client_key_file" typescript:",notnull"`
ClientCertFile serpent.String `json:"client_cert_file" typescript:",notnull"`
EmailDomain serpent.StringArray `json:"email_domain" typescript:",notnull"`
IssuerURL serpent.String `json:"issuer_url" typescript:",notnull"`
Scopes serpent.StringArray `json:"scopes" typescript:",notnull"`
IgnoreEmailVerified serpent.Bool `json:"ignore_email_verified" typescript:",notnull"`
UsernameField serpent.String `json:"username_field" typescript:",notnull"`
NameField serpent.String `json:"name_field" typescript:",notnull"`
EmailField serpent.String `json:"email_field" typescript:",notnull"`
AuthURLParams serpent.Struct[map[string]string] `json:"auth_url_params" typescript:",notnull"`
IgnoreUserInfo serpent.Bool `json:"ignore_user_info" typescript:",notnull"`
GroupAutoCreate serpent.Bool `json:"group_auto_create" typescript:",notnull"`
GroupRegexFilter serpent.Regexp `json:"group_regex_filter" typescript:",notnull"`
GroupAllowList serpent.StringArray `json:"group_allow_list" typescript:",notnull"`
GroupField serpent.String `json:"groups_field" typescript:",notnull"`
GroupMapping serpent.Struct[map[string]string] `json:"group_mapping" typescript:",notnull"`
UserRoleField serpent.String `json:"user_role_field" typescript:",notnull"`
UserRoleMapping serpent.Struct[map[string][]string] `json:"user_role_mapping" typescript:",notnull"`
UserRolesDefault serpent.StringArray `json:"user_roles_default" typescript:",notnull"`
SignInText serpent.String `json:"sign_in_text" typescript:",notnull"`
IconURL serpent.URL `json:"icon_url" typescript:",notnull"`
SignupsDisabledText serpent.String `json:"signups_disabled_text" typescript:",notnull"`
SkipIssuerChecks serpent.Bool `json:"skip_issuer_checks" typescript:",notnull"`
ClientKeyFile serpent.String `json:"client_key_file" typescript:",notnull"`
ClientCertFile serpent.String `json:"client_cert_file" typescript:",notnull"`
EmailDomain serpent.StringArray `json:"email_domain" typescript:",notnull"`
IssuerURL serpent.String `json:"issuer_url" typescript:",notnull"`
Scopes serpent.StringArray `json:"scopes" typescript:",notnull"`
IgnoreEmailVerified serpent.Bool `json:"ignore_email_verified" typescript:",notnull"`
UsernameField serpent.String `json:"username_field" typescript:",notnull"`
NameField serpent.String `json:"name_field" typescript:",notnull"`
EmailField serpent.String `json:"email_field" typescript:",notnull"`
AuthURLParams serpent.Struct[map[string]string] `json:"auth_url_params" typescript:",notnull"`
IgnoreUserInfo serpent.Bool `json:"ignore_user_info" typescript:",notnull"`
OrganizationField serpent.String `json:"organization_field" typescript:",notnull"`
OrganizationMapping serpent.Struct[map[string][]uuid.UUID] `json:"organization_mapping" typescript:",notnull"`
OrganizationAssignDefault serpent.Bool `json:"organization_assign_default" typescript:",notnull"`
GroupAutoCreate serpent.Bool `json:"group_auto_create" typescript:",notnull"`
GroupRegexFilter serpent.Regexp `json:"group_regex_filter" typescript:",notnull"`
GroupAllowList serpent.StringArray `json:"group_allow_list" typescript:",notnull"`
GroupField serpent.String `json:"groups_field" typescript:",notnull"`
GroupMapping serpent.Struct[map[string]string] `json:"group_mapping" typescript:",notnull"`
UserRoleField serpent.String `json:"user_role_field" typescript:",notnull"`
UserRoleMapping serpent.Struct[map[string][]string] `json:"user_role_mapping" typescript:",notnull"`
UserRolesDefault serpent.StringArray `json:"user_roles_default" typescript:",notnull"`
SignInText serpent.String `json:"sign_in_text" typescript:",notnull"`
IconURL serpent.URL `json:"icon_url" typescript:",notnull"`
SignupsDisabledText serpent.String `json:"signups_disabled_text" typescript:",notnull"`
SkipIssuerChecks serpent.Bool `json:"skip_issuer_checks" typescript:",notnull"`
}

type TelemetryConfig struct {
Expand Down
20 changes: 20 additions & 0 deletions enterprise/coderd/enidpsync/enidpsync.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package enidpsync

import (
"cdr.dev/slog"

"github.com/coder/coder/v2/coderd/entitlements"
"github.com/coder/coder/v2/coderd/idpsync"
)

type EnterpriseIDPSync struct {
entitlements *entitlements.Set
agpl *idpsync.AGPLIDPSync
}

func NewSync(logger slog.Logger, entitlements *entitlements.Set, settings idpsync.SyncSettings) *EnterpriseIDPSync {
return &EnterpriseIDPSync{
entitlements: entitlements,
agpl: idpsync.NewSync(logger, settings),
}
}
63 changes: 63 additions & 0 deletions enterprise/coderd/enidpsync/organizations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package enidpsync

import (
"context"
"net/http"

"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/idpsync"
"github.com/coder/coder/v2/codersdk"
)

func (e EnterpriseIDPSync) ParseOrganizationClaims(ctx context.Context, mergedClaims map[string]interface{}) (idpsync.OrganizationParams, *HttpError) {
s := e.agpl
if !e.entitlements.Enabled(codersdk.FeatureMultipleOrganizations) {
// Default to agpl if multi-org is not enabled
return e.agpl.ParseOrganizationClaims(ctx, mergedClaims)
}

// nolint:gocritic // all syncing is done as a system user
ctx = dbauthz.AsSystemRestricted(ctx)

// Pull extra organizations from the claims.
if s.OrganizationField != "" {
organizationRaw, ok := mergedClaims[s.OrganizationField]
if ok {
parsedOrganizations, err := idpsync.ParseStringSliceClaim(organizationRaw)
if err != nil {
return idpsync.OrganizationParams{}, &idpsync.HttpError{
Code: http.StatusBadRequest,
Msg: "Failed to sync organizations from the OIDC claims",
Detail: err.Error(),
RenderStaticPage: false,
RenderDetailMarkdown: false,
}
}

// Keep track of which claims are not mapped for debugging purposes.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

var ignored []string
for _, parsedOrg := range parsedOrganizations {
if mappedOrganization, ok := s.OrganizationMapping[parsedOrg]; ok {
// parsedOrg is in the mapping, so add the mapped organizations to the
// user's organizations.
userOrganizations = append(userOrganizations, mappedOrganization...)
} else {
ignored = append(ignored, parsedOrg)
}
}

s.Logger.Debug(ctx, "parsed organizations from claim",
slog.F("len", len(parsedOrganizations)),
slog.F("ignored", ignored),
slog.F("organizations", parsedOrganizations),
)
}
}

return idpsync.OrganizationParams{
SyncEnabled: true,
IncludeDefault: s.OrganizationAssignDefault,
Organizations: userOrganizations,
}, nil
}