feat: implement organization role sync (#14649)

* chore: implement organization and site wide role sync in idpsync
* chore: remove old role sync, insert new idpsync package
This commit is contained in:
Steven Masley
2024-09-16 19:03:25 -05:00
committed by GitHub
parent 5aa54be6ca
commit 71393743dc
16 changed files with 1159 additions and 223 deletions

View File

@ -145,7 +145,6 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
}
return c.Subject, c.Trial, nil
}
api.AGPL.Options.SetUserSiteRoles = api.setUserSiteRoles
api.AGPL.SiteHandler.RegionsFetcher = func(ctx context.Context) (any, error) {
// If the user can read the workspace proxy resource, return that.
// If not, always default to the regions.

View File

@ -0,0 +1,93 @@
package enidpsync
import (
"context"
"fmt"
"net/http"
"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/idpsync"
"github.com/coder/coder/v2/coderd/runtimeconfig"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
)
func (e EnterpriseIDPSync) RoleSyncEntitled() bool {
return e.entitlements.Enabled(codersdk.FeatureUserRoleManagement)
}
func (e EnterpriseIDPSync) OrganizationRoleSyncEnabled(ctx context.Context, db database.Store, orgID uuid.UUID) (bool, error) {
if !e.RoleSyncEntitled() {
return false, nil
}
roleSyncSettings, err := e.Role.Resolve(ctx, e.Manager.OrganizationResolver(db, orgID))
if err != nil {
if xerrors.Is(err, runtimeconfig.ErrEntryNotFound) {
return false, nil
}
return false, err
}
return roleSyncSettings.Field != "", nil
}
func (e EnterpriseIDPSync) SiteRoleSyncEnabled() bool {
if !e.RoleSyncEntitled() {
return false
}
return e.AGPLIDPSync.SiteRoleField != ""
}
func (e EnterpriseIDPSync) ParseRoleClaims(ctx context.Context, mergedClaims jwt.MapClaims) (idpsync.RoleParams, *idpsync.HTTPError) {
if !e.RoleSyncEntitled() {
return e.AGPLIDPSync.ParseRoleClaims(ctx, mergedClaims)
}
var claimRoles []string
if e.AGPLIDPSync.SiteRoleField != "" {
var err error
// TODO: Smoke test this error for org and site
claimRoles, err = e.AGPLIDPSync.RolesFromClaim(e.AGPLIDPSync.SiteRoleField, mergedClaims)
if err != nil {
rawType := mergedClaims[e.AGPLIDPSync.SiteRoleField]
e.Logger.Error(ctx, "oidc claims user roles field was an unknown type",
slog.F("type", fmt.Sprintf("%T", rawType)),
slog.F("field", e.AGPLIDPSync.SiteRoleField),
slog.F("raw_value", rawType),
slog.Error(err),
)
// TODO: Determine a static page or not
return idpsync.RoleParams{}, &idpsync.HTTPError{
Code: http.StatusInternalServerError,
Msg: "Login disabled until site wide 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.", rawType),
RenderStaticPage: false,
}
}
}
siteRoles := append([]string{}, e.SiteDefaultRoles...)
for _, role := range claimRoles {
if mappedRoles, ok := e.SiteRoleMapping[role]; ok {
if len(mappedRoles) == 0 {
continue
}
// Mapped roles are added to the list of roles
siteRoles = append(siteRoles, mappedRoles...)
continue
}
// Append as is.
siteRoles = append(siteRoles, role)
}
return idpsync.RoleParams{
SyncEntitled: e.RoleSyncEntitled(),
SyncSiteWide: e.SiteRoleSyncEnabled(),
SiteWideRoles: slice.Unique(siteRoles),
MergedClaims: mergedClaims,
}, nil
}

View File

@ -0,0 +1,144 @@
package enidpsync_test
import (
"context"
"testing"
"github.com/golang-jwt/jwt/v4"
"github.com/stretchr/testify/require"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/entitlements"
"github.com/coder/coder/v2/coderd/idpsync"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/runtimeconfig"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/enidpsync"
)
func TestEnterpriseParseRoleClaims(t *testing.T) {
t.Parallel()
entitled := entitlements.New()
entitled.Update(func(en *codersdk.Entitlements) {
en.Features[codersdk.FeatureUserRoleManagement] = codersdk.Feature{
Entitlement: codersdk.EntitlementEntitled,
Enabled: true,
}
})
t.Run("NotEntitled", func(t *testing.T) {
t.Parallel()
mgr := runtimeconfig.NewManager()
s := enidpsync.NewSync(slogtest.Make(t, nil), mgr, entitlements.New(), idpsync.DeploymentSyncSettings{})
params, err := s.ParseRoleClaims(context.Background(), jwt.MapClaims{})
require.Nil(t, err)
require.False(t, params.SyncEntitled)
require.False(t, params.SyncSiteWide)
})
t.Run("NotEntitledButEnabled", func(t *testing.T) {
t.Parallel()
// Since it is not entitled, it should not be enabled
mgr := runtimeconfig.NewManager()
s := enidpsync.NewSync(slogtest.Make(t, nil), mgr, entitlements.New(), idpsync.DeploymentSyncSettings{
SiteRoleField: "roles",
})
params, err := s.ParseRoleClaims(context.Background(), jwt.MapClaims{})
require.Nil(t, err)
require.False(t, params.SyncEntitled)
require.False(t, params.SyncSiteWide)
})
t.Run("SiteDisabled", func(t *testing.T) {
t.Parallel()
mgr := runtimeconfig.NewManager()
s := enidpsync.NewSync(slogtest.Make(t, nil), mgr, entitled, idpsync.DeploymentSyncSettings{})
params, err := s.ParseRoleClaims(context.Background(), jwt.MapClaims{})
require.Nil(t, err)
require.True(t, params.SyncEntitled)
require.False(t, params.SyncSiteWide)
})
t.Run("SiteEnabled", func(t *testing.T) {
t.Parallel()
mgr := runtimeconfig.NewManager()
s := enidpsync.NewSync(slogtest.Make(t, nil), mgr, entitled, idpsync.DeploymentSyncSettings{
SiteRoleField: "roles",
SiteRoleMapping: map[string][]string{},
SiteDefaultRoles: []string{rbac.RoleTemplateAdmin().Name},
})
params, err := s.ParseRoleClaims(context.Background(), jwt.MapClaims{
"roles": []string{rbac.RoleAuditor().Name},
})
require.Nil(t, err)
require.True(t, params.SyncEntitled)
require.True(t, params.SyncSiteWide)
require.ElementsMatch(t, []string{
rbac.RoleTemplateAdmin().Name,
rbac.RoleAuditor().Name,
}, params.SiteWideRoles)
})
t.Run("SiteMapping", func(t *testing.T) {
t.Parallel()
mgr := runtimeconfig.NewManager()
s := enidpsync.NewSync(slogtest.Make(t, nil), mgr, entitled, idpsync.DeploymentSyncSettings{
SiteRoleField: "roles",
SiteRoleMapping: map[string][]string{
"foo": {rbac.RoleAuditor().Name, rbac.RoleUserAdmin().Name},
"bar": {rbac.RoleOwner().Name},
},
SiteDefaultRoles: []string{rbac.RoleTemplateAdmin().Name},
})
params, err := s.ParseRoleClaims(context.Background(), jwt.MapClaims{
"roles": []string{"foo", "bar", "random"},
})
require.Nil(t, err)
require.True(t, params.SyncEntitled)
require.True(t, params.SyncSiteWide)
require.ElementsMatch(t, []string{
rbac.RoleTemplateAdmin().Name,
rbac.RoleAuditor().Name,
rbac.RoleUserAdmin().Name,
rbac.RoleOwner().Name,
// Invalid claims are still passed at this point
"random",
}, params.SiteWideRoles)
})
t.Run("DuplicateRoles", func(t *testing.T) {
t.Parallel()
mgr := runtimeconfig.NewManager()
s := enidpsync.NewSync(slogtest.Make(t, nil), mgr, entitled, idpsync.DeploymentSyncSettings{
SiteRoleField: "roles",
SiteRoleMapping: map[string][]string{
"foo": {rbac.RoleOwner().Name, rbac.RoleAuditor().Name},
"bar": {rbac.RoleOwner().Name},
},
SiteDefaultRoles: []string{rbac.RoleAuditor().Name},
})
params, err := s.ParseRoleClaims(context.Background(), jwt.MapClaims{
"roles": []string{"foo", "bar", rbac.RoleAuditor().Name, rbac.RoleOwner().Name},
})
require.Nil(t, err)
require.True(t, params.SyncEntitled)
require.True(t, params.SyncSiteWide)
require.ElementsMatch(t, []string{
rbac.RoleAuditor().Name,
rbac.RoleOwner().Name,
}, params.SiteWideRoles)
})
}

View File

@ -1,34 +1 @@
package coderd
import (
"context"
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/codersdk"
)
func (api *API) setUserSiteRoles(ctx context.Context, logger slog.Logger, db database.Store, userID uuid.UUID, roles []string) error {
if !api.Entitlements.Enabled(codersdk.FeatureUserRoleManagement) {
logger.Warn(ctx, "attempted to assign OIDC user roles without enterprise entitlement, roles left unchanged",
slog.F("user_id", userID), slog.F("roles", roles),
)
return nil
}
// Should this be feature protected?
return db.InTx(func(tx database.Store) error {
_, err := db.UpdateUserRoles(ctx, database.UpdateUserRolesParams{
GrantedRoles: roles,
ID: userID,
})
if err != nil {
return xerrors.Errorf("set user roles(%s): %w", userID.String(), err)
}
return nil
}, nil)
}

View File

@ -42,7 +42,9 @@ func TestUserOIDC(t *testing.T) {
runner := setupOIDCTest(t, oidcTestConfig{
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
cfg.UserRoleField = "roles"
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.UserRoleField = "roles"
},
})
@ -239,7 +241,9 @@ func TestUserOIDC(t *testing.T) {
runner := setupOIDCTest(t, oidcTestConfig{
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
cfg.UserRoleField = "roles"
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.UserRoleField = "roles"
},
})
@ -267,9 +271,13 @@ func TestUserOIDC(t *testing.T) {
runner := setupOIDCTest(t, oidcTestConfig{
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
cfg.UserRoleField = "roles"
cfg.UserRoleMapping = map[string][]string{
oidcRoleName: {rbac.RoleTemplateAdmin().String()},
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.UserRoleField = "roles"
dv.OIDC.UserRoleMapping = serpent.Struct[map[string][]string]{
Value: map[string][]string{
oidcRoleName: {rbac.RoleTemplateAdmin().String()},
},
}
},
})
@ -299,9 +307,13 @@ func TestUserOIDC(t *testing.T) {
Userinfo: jwt.MapClaims{oidcRoleName: []string{rbac.RoleTemplateAdmin().String(), rbac.RoleUserAdmin().String()}},
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
cfg.UserRoleField = "roles"
cfg.UserRoleMapping = map[string][]string{
oidcRoleName: {rbac.RoleTemplateAdmin().String(), rbac.RoleUserAdmin().String()},
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.UserRoleField = "roles"
dv.OIDC.UserRoleMapping = serpent.Struct[map[string][]string]{
Value: map[string][]string{
oidcRoleName: {rbac.RoleTemplateAdmin().String(), rbac.RoleUserAdmin().String()},
},
}
},
})
@ -334,9 +346,13 @@ func TestUserOIDC(t *testing.T) {
Userinfo: jwt.MapClaims{oidcRoleName: []string{rbac.RoleTemplateAdmin().String(), rbac.RoleUserAdmin().String()}},
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
cfg.UserRoleField = "roles"
cfg.UserRoleMapping = map[string][]string{
oidcRoleName: {rbac.RoleTemplateAdmin().String(), rbac.RoleUserAdmin().String()},
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.UserRoleField = "roles"
dv.OIDC.UserRoleMapping = serpent.Struct[map[string][]string]{
Value: map[string][]string{
oidcRoleName: {rbac.RoleTemplateAdmin().String(), rbac.RoleUserAdmin().String()},
},
}
},
})
@ -367,7 +383,9 @@ func TestUserOIDC(t *testing.T) {
runner := setupOIDCTest(t, oidcTestConfig{
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
cfg.UserRoleField = "roles"
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.UserRoleField = "roles"
},
})
@ -653,7 +671,9 @@ func TestUserOIDC(t *testing.T) {
runner := setupOIDCTest(t, oidcTestConfig{
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
cfg.UserRoleField = "roles"
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.UserRoleField = "roles"
},
})