mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
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:
@ -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.
|
||||
|
93
enterprise/coderd/enidpsync/role.go
Normal file
93
enterprise/coderd/enidpsync/role.go
Normal 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
|
||||
}
|
144
enterprise/coderd/enidpsync/role_test.go
Normal file
144
enterprise/coderd/enidpsync/role_test.go
Normal 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)
|
||||
})
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -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"
|
||||
},
|
||||
})
|
||||
|
||||
|
Reference in New Issue
Block a user