Skip to content

feat: implement organization role sync #14649

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Sep 17, 2024
Prev Previous commit
Next Next commit
chore: remove old role sync, insert new idpsync package
  • Loading branch information
Emyrk committed Sep 16, 2024
commit 2a1e2d06201d6bfb184c72cbf38e90b1bd838381
3 changes: 0 additions & 3 deletions cli/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,6 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De
EmailField: vals.OIDC.EmailField.String(),
AuthURLParams: vals.OIDC.AuthURLParams.Value,
IgnoreUserInfo: vals.OIDC.IgnoreUserInfo.Value(),
UserRoleField: vals.OIDC.UserRoleField.String(),
UserRoleMapping: vals.OIDC.UserRoleMapping.Value,
UserRolesDefault: vals.OIDC.UserRolesDefault.GetSlice(),
SignInText: vals.OIDC.SignInText.String(),
SignupsDisabledText: vals.OIDC.SignupsDisabledText.String(),
IconURL: vals.OIDC.IconURL.String(),
Expand Down
9 changes: 0 additions & 9 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,6 @@ type Options struct {
NetworkTelemetryBatchFrequency time.Duration
NetworkTelemetryBatchMaxSize int
SwaggerEndpoint bool
SetUserSiteRoles func(ctx context.Context, logger slog.Logger, tx database.Store, userID uuid.UUID, roles []string) error
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore]
AccessControlStore *atomic.Pointer[dbauthz.AccessControlStore]
Expand Down Expand Up @@ -373,14 +372,6 @@ func New(options *Options) *API {
if options.TracerProvider == nil {
options.TracerProvider = trace.NewNoopTracerProvider()
}
if options.SetUserSiteRoles == nil {
options.SetUserSiteRoles = func(ctx context.Context, logger slog.Logger, _ database.Store, userID uuid.UUID, roles []string) error {
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]{}
}
Expand Down
35 changes: 32 additions & 3 deletions coderd/idpsync/idpsync.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,24 @@ type IDPSync interface {
// accessed concurrently. The settings are stored in the database.
GroupSyncSettings(ctx context.Context, orgID uuid.UUID, db database.Store) (*GroupSyncSettings, error)
UpdateGroupSettings(ctx context.Context, orgID uuid.UUID, db database.Store, settings GroupSyncSettings) error

// RoleSyncEntitled returns true if the deployment is entitled to role syncing.
RoleSyncEntitled() bool
// OrganizationRoleSyncEnabled returns true if the organization has role sync
// enabled.
OrganizationRoleSyncEnabled(ctx context.Context, db database.Store, org uuid.UUID) (bool, error)
// SiteRoleSyncEnabled returns true if the deployment has role sync enabled
// at the site level.
SiteRoleSyncEnabled() bool
// RoleSyncSettings is similar to GroupSyncSettings. See GroupSyncSettings for
// rational.
RoleSyncSettings() runtimeconfig.RuntimeEntry[*RoleSyncSettings]
// ParseRoleClaims takes claims from an OIDC provider, and returns the params
// for role syncing. Most of the logic happens in SyncRoles.
ParseRoleClaims(ctx context.Context, mergedClaims jwt.MapClaims) (RoleParams, *HTTPError)
// SyncRoles assigns and removes users from roles based on the provided params.
// Site & org roles are handled in this method.
SyncRoles(ctx context.Context, db database.Store, user database.User, params RoleParams) error
}

// AGPLIDPSync is the configuration for syncing user information from an external
Expand Down Expand Up @@ -77,9 +95,16 @@ type DeploymentSyncSettings struct {
// Legacy deployment settings that only apply to the default org.
Legacy DefaultOrgLegacySettings

// SiteRoleField syncs a user's site wide roles from an IDP.
SiteRoleField string
SiteRoleMapping map[string][]string
// SiteRoleField selects the claim field to be used as the created user's
// roles. If the field is the empty string, then no site role updates
// will ever come from the OIDC provider.
SiteRoleField string
// SiteRoleMapping controls how groups returned by the OIDC provider get mapped
// to site roles within Coder.
// map[oidcRoleName][]coderRoleName
SiteRoleMapping map[string][]string
// SiteDefaultRoles is the default set of site roles to assign to a user if role sync
// is enabled.
SiteDefaultRoles []string
}

Expand All @@ -99,6 +124,10 @@ func FromDeploymentValues(dv *codersdk.DeploymentValues) DeploymentSyncSettings
OrganizationMapping: dv.OIDC.OrganizationMapping.Value,
OrganizationAssignDefault: dv.OIDC.OrganizationAssignDefault.Value(),

SiteRoleField: dv.OIDC.UserRoleField.Value(),
SiteRoleMapping: dv.OIDC.UserRoleMapping.Value,
SiteDefaultRoles: dv.OIDC.UserRolesDefault.Value(),

// TODO: Separate group field for allow list from default org.
// Right now you cannot disable group sync from the default org and
// configure an allow list.
Expand Down
22 changes: 16 additions & 6 deletions coderd/idpsync/role.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,32 +19,42 @@ import (
)

type RoleParams struct {
// SyncEnabled if false will skip syncing the user's roles
SyncEnabled bool
// SyncEntitled if false will skip syncing the user's roles at
// all levels.
SyncEntitled bool
SyncSiteWide bool
SiteWideRoles []string
// MergedClaims are passed to the organization level for syncing
MergedClaims jwt.MapClaims
}

func (AGPLIDPSync) RoleSyncEnabled() bool {
func (AGPLIDPSync) RoleSyncEntitled() bool {
// AGPL does not support syncing groups.
return false
}

func (AGPLIDPSync) OrganizationRoleSyncEnabled(_ context.Context, _ database.Store, _ uuid.UUID) (bool, error) {
return false, nil
}

func (AGPLIDPSync) SiteRoleSyncEnabled() bool {
return false
}

func (s AGPLIDPSync) RoleSyncSettings() runtimeconfig.RuntimeEntry[*RoleSyncSettings] {
return s.Role
}

func (s AGPLIDPSync) ParseRoleClaims(_ context.Context, _ jwt.MapClaims) (RoleParams, *HTTPError) {
return RoleParams{
SyncEnabled: s.RoleSyncEnabled(),
SyncSiteWide: false,
SyncEntitled: s.RoleSyncEntitled(),
SyncSiteWide: s.SiteRoleSyncEnabled(),
}, nil
}

func (s AGPLIDPSync) SyncRoles(ctx context.Context, db database.Store, user database.User, params RoleParams) error {
// Nothing happens if sync is not enabled
if !params.SyncEnabled {
if !params.SyncEntitled {
return nil
}

Expand Down
6 changes: 3 additions & 3 deletions coderd/idpsync/role_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ func TestRoleSyncTable(t *testing.T) {

// Do the role sync!
err := s.SyncRoles(ctx, db, user, idpsync.RoleParams{
SyncEnabled: true,
SyncEntitled: true,
SyncSiteWide: false,
MergedClaims: userClaims,
})
Expand Down Expand Up @@ -264,7 +264,7 @@ func TestRoleSyncTable(t *testing.T) {
}

err := s.SyncRoles(ctx, db, user, idpsync.RoleParams{
SyncEnabled: true,
SyncEntitled: true,
SyncSiteWide: true,
SiteWideRoles: []string{
rbac.RoleTemplateAdmin().Name, // Duplicate this value to test deduplication
Expand Down Expand Up @@ -353,7 +353,7 @@ func TestNoopNoDiff(t *testing.T) {
RBACRoles: siteRoles,
LoginType: database.LoginTypePassword,
}, idpsync.RoleParams{
SyncEnabled: true,
SyncEntitled: true,
SyncSiteWide: true,
SiteWideRoles: siteRoles,
MergedClaims: jwt.MapClaims{
Expand Down
28 changes: 28 additions & 0 deletions coderd/members.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/coder/coder/v2/coderd/audit"
"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/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
Expand Down Expand Up @@ -215,6 +216,33 @@ func (api *API) putMemberRoles(rw http.ResponseWriter, r *http.Request) {
aReq.Old = member.OrganizationMember.Auditable(member.Username)
defer commitAudit()

// Keep this block scoping to prevent accidental use of the user variable.
Copy link
Contributor

Choose a reason for hiding this comment

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

This is basically saying this should be a function imo

{
// nolint:gocritic // The caller could be an org admin without this perm.
// We need to disable manual role assignment if role sync is enabled for
// the given organization.
user, err := api.Database.GetUserByID(dbauthz.AsSystemRestricted(ctx), member.UserID)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
if user.LoginType == database.LoginTypeOIDC {
// nolint:gocritic // fetching settings
orgSync, err := api.IDPSync.OrganizationRoleSyncEnabled(dbauthz.AsSystemRestricted(ctx), api.Database, organization.ID)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
if orgSync {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot modify roles for OIDC users when role sync is enabled. This organization member's roles are managed by the identity provider.",
Detail: "'User Role Field' is set in the organization settings. Ask an administrator to adjust or disable these settings.",
})
return
}
}
}

if apiKey.UserID == member.OrganizationMember.UserID {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "You cannot change your own organization roles.",
Expand Down
125 changes: 12 additions & 113 deletions coderd/userauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -740,17 +740,6 @@ type OIDCConfig struct {
// support the userinfo endpoint, or if the userinfo endpoint causes
// undesirable behavior.
IgnoreUserInfo bool
// 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
Expand All @@ -759,10 +748,6 @@ type OIDCConfig struct {
SignupsDisabledText string
}

func (cfg OIDCConfig) RoleSyncEnabled() bool {
return cfg.UserRoleField != ""
}

// @Summary OpenID Connect Callback
// @ID openid-connect-callback
// @Security CoderSessionToken
Expand Down Expand Up @@ -983,12 +968,6 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {

ctx = slog.With(ctx, slog.F("email", email), slog.F("username", username), slog.F("name", name))

roles, roleErr := api.oidcRoles(ctx, mergedClaims)
if roleErr != nil {
roleErr.Write(rw, r)
return
}

user, link, err := findLinkedUser(ctx, api.Database, oidcLinkedID(idToken), email)
if err != nil {
logger.Error(ctx, "oauth2: unable to find linked user", slog.F("email", email), slog.Error(err))
Expand All @@ -1011,6 +990,12 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
return
}

roleSync, roleSyncErr := api.IDPSync.ParseRoleClaims(ctx, mergedClaims)
if roleSyncErr != nil {
roleSyncErr.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 {
Expand All @@ -1028,10 +1013,9 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
Username: username,
Name: name,
AvatarURL: picture,
UsingRoles: api.OIDCConfig.RoleSyncEnabled(),
Roles: roles,
OrganizationSync: orgSync,
GroupSync: groupSync,
RoleSync: roleSync,
DebugContext: OauthDebugContext{
IDTokenClaims: idtokenClaims,
UserInfoClaims: userInfoClaims,
Expand Down Expand Up @@ -1067,61 +1051,6 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
}

// oidcRoles returns the roles for the user from the OIDC claims.
// If the function returns false, then the caller should return early.
// All writes to the response writer are handled by this function.
// 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, *idpsync.HTTPError) {
roles := api.OIDCConfig.UserRolesDefault
if !api.OIDCConfig.RoleSyncEnabled() {
return roles, nil
}

rolesRow, ok := mergedClaims[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{}).
// Use []interface{}{} so the next typecast works.
rolesRow = []interface{}{}
}

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, &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,
}
}

api.Logger.Debug(ctx, "roles returned in oidc claims",
slog.F("len", len(parsedRoles)),
slog.F("roles", parsedRoles),
)
for _, role := range parsedRoles {
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)
}
return roles, nil
}

// claimFields returns the sorted list of fields in the claims map.
func claimFields(claims map[string]interface{}) []string {
fields := []string{}
Expand Down Expand Up @@ -1182,10 +1111,7 @@ type oauthLoginParams struct {
// OrganizationSync has the organizations that the user will be assigned to.
OrganizationSync idpsync.OrganizationParams
GroupSync idpsync.GroupParams
// Is UsingRoles is true, then the user will be assigned
// the roles provided.
UsingRoles bool
Roles []string
RoleSync idpsync.RoleParams

DebugContext OauthDebugContext

Expand Down Expand Up @@ -1394,37 +1320,10 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
return xerrors.Errorf("sync groups: %w", err)
}

// Ensure roles are correct.
if params.UsingRoles {
ignored := make([]string, 0)
filtered := make([]string, 0, len(params.Roles))
for _, role := range params.Roles {
// TODO: This only supports mapping deployment wide roles. Organization scoped roles
// are unsupported.
if _, err := rbac.RoleByName(rbac.RoleIdentifier{Name: role}); err == nil {
filtered = append(filtered, role)
} else {
ignored = append(ignored, role)
}
}

//nolint:gocritic
err := api.Options.SetUserSiteRoles(dbauthz.AsSystemRestricted(ctx), logger, tx, user.ID, filtered)
if err != nil {
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 {
logger.Debug(ctx, "OIDC roles ignored in assignment",
slog.F("ignored", ignored),
slog.F("assigned", filtered),
slog.F("user_id", user.ID),
)
}
// Role sync needs to occur after org sync.
err = api.IDPSync.SyncRoles(ctx, tx, user, params.RoleSync)
if err != nil {
return xerrors.Errorf("sync roles: %w", err)
}

needsUpdate := false
Expand Down
2 changes: 1 addition & 1 deletion coderd/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -1162,7 +1162,7 @@ func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) {
defer commitAudit()
aReq.Old = user

if user.LoginType == database.LoginTypeOIDC && api.OIDCConfig.RoleSyncEnabled() {
if user.LoginType == database.LoginTypeOIDC && api.IDPSync.SiteRoleSyncEnabled() {
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.",
Expand Down
Loading