feat: get and update group IdP Sync settings (#14647)

---------

Co-authored-by: Steven Masley <stevenmasley@gmail.com>
This commit is contained in:
Kayla Washburn-Love
2024-09-16 11:01:37 -06:00
committed by GitHub
parent 2df9a3e554
commit 5ed065d88d
18 changed files with 797 additions and 67 deletions

111
coderd/apidoc/docs.go generated
View File

@ -3082,6 +3082,74 @@ const docTemplate = `{
}
}
},
"/organizations/{organization}/settings/idpsync/groups": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Get group IdP Sync settings by organization",
"operationId": "get-group-idp-sync-settings-by-organization",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Organization ID",
"name": "organization",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/idpsync.GroupSyncSettings"
}
}
}
},
"patch": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Update group IdP Sync settings by organization",
"operationId": "update-group-idp-sync-settings-by-organization",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Organization ID",
"name": "organization",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/idpsync.GroupSyncSettings"
}
}
}
}
},
"/organizations/{organization}/templates": {
"get": {
"security": [
@ -11733,6 +11801,7 @@ const docTemplate = `{
"file",
"group",
"group_member",
"idpsync_settings",
"license",
"notification_preference",
"notification_template",
@ -11764,6 +11833,7 @@ const docTemplate = `{
"ResourceFile",
"ResourceGroup",
"ResourceGroupMember",
"ResourceIdpsyncSettings",
"ResourceLicense",
"ResourceNotificationPreference",
"ResourceNotificationTemplate",
@ -15046,6 +15116,44 @@ const docTemplate = `{
}
}
},
"idpsync.GroupSyncSettings": {
"type": "object",
"properties": {
"auto_create_missing_groups": {
"description": "AutoCreateMissing controls whether groups returned by the OIDC provider\nare automatically created in Coder if they are missing.",
"type": "boolean"
},
"field": {
"description": "Field selects the claim field to be used as the created user's\ngroups. If the group field is the empty string, then no group updates\nwill ever come from the OIDC provider.",
"type": "string"
},
"legacy_group_name_mapping": {
"description": "LegacyNameMapping is deprecated. It remaps an IDP group name to\na Coder group name. Since configuration is now done at runtime,\ngroup IDs are used to account for group renames.\nFor legacy configurations, this config option has to remain.\nDeprecated: Use Mapping instead.",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"mapping": {
"description": "Mapping maps from an OIDC group --\u003e Coder group ID",
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
},
"regex_filter": {
"description": "RegexFilter is a regular expression that filters the groups returned by\nthe OIDC provider. Any group not matched by this regex will be ignored.\nIf the group filter is nil, then no group filtering will occur.",
"allOf": [
{
"$ref": "#/definitions/regexp.Regexp"
}
]
}
}
},
"key.NodePublic": {
"type": "object"
},
@ -15164,6 +15272,9 @@ const docTemplate = `{
}
}
},
"regexp.Regexp": {
"type": "object"
},
"serpent.Annotations": {
"type": "object",
"additionalProperties": {

View File

@ -2708,6 +2708,66 @@
}
}
},
"/organizations/{organization}/settings/idpsync/groups": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Get group IdP Sync settings by organization",
"operationId": "get-group-idp-sync-settings-by-organization",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Organization ID",
"name": "organization",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/idpsync.GroupSyncSettings"
}
}
}
},
"patch": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Update group IdP Sync settings by organization",
"operationId": "update-group-idp-sync-settings-by-organization",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Organization ID",
"name": "organization",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/idpsync.GroupSyncSettings"
}
}
}
}
},
"/organizations/{organization}/templates": {
"get": {
"security": [
@ -10589,6 +10649,7 @@
"file",
"group",
"group_member",
"idpsync_settings",
"license",
"notification_preference",
"notification_template",
@ -10620,6 +10681,7 @@
"ResourceFile",
"ResourceGroup",
"ResourceGroupMember",
"ResourceIdpsyncSettings",
"ResourceLicense",
"ResourceNotificationPreference",
"ResourceNotificationTemplate",
@ -13715,6 +13777,44 @@
}
}
},
"idpsync.GroupSyncSettings": {
"type": "object",
"properties": {
"auto_create_missing_groups": {
"description": "AutoCreateMissing controls whether groups returned by the OIDC provider\nare automatically created in Coder if they are missing.",
"type": "boolean"
},
"field": {
"description": "Field selects the claim field to be used as the created user's\ngroups. If the group field is the empty string, then no group updates\nwill ever come from the OIDC provider.",
"type": "string"
},
"legacy_group_name_mapping": {
"description": "LegacyNameMapping is deprecated. It remaps an IDP group name to\na Coder group name. Since configuration is now done at runtime,\ngroup IDs are used to account for group renames.\nFor legacy configurations, this config option has to remain.\nDeprecated: Use Mapping instead.",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"mapping": {
"description": "Mapping maps from an OIDC group --\u003e Coder group ID",
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
},
"regex_filter": {
"description": "RegexFilter is a regular expression that filters the groups returned by\nthe OIDC provider. Any group not matched by this regex will be ignored.\nIf the group filter is nil, then no group filtering will occur.",
"allOf": [
{
"$ref": "#/definitions/regexp.Regexp"
}
]
}
}
},
"key.NodePublic": {
"type": "object"
},
@ -13833,6 +13933,9 @@
}
}
},
"regexp.Regexp": {
"type": "object"
},
"serpent.Annotations": {
"type": "object",
"additionalProperties": {

View File

@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"regexp"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
@ -15,7 +14,9 @@ import (
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/runtimeconfig"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
)
type GroupParams struct {
@ -29,8 +30,45 @@ func (AGPLIDPSync) GroupSyncEnabled() bool {
return false
}
func (s AGPLIDPSync) GroupSyncSettings() runtimeconfig.RuntimeEntry[*GroupSyncSettings] {
return s.Group
func (s AGPLIDPSync) UpdateGroupSettings(ctx context.Context, orgID uuid.UUID, db database.Store, settings GroupSyncSettings) error {
orgResolver := s.Manager.OrganizationResolver(db, orgID)
err := s.SyncSettings.Group.SetRuntimeValue(ctx, orgResolver, &settings)
if err != nil {
return xerrors.Errorf("update group sync settings: %w", err)
}
return nil
}
func (s AGPLIDPSync) GroupSyncSettings(ctx context.Context, orgID uuid.UUID, db database.Store) (*GroupSyncSettings, error) {
orgResolver := s.Manager.OrganizationResolver(db, orgID)
settings, err := s.SyncSettings.Group.Resolve(ctx, orgResolver)
if err != nil {
if !xerrors.Is(err, runtimeconfig.ErrEntryNotFound) {
return nil, xerrors.Errorf("resolve group sync settings: %w", err)
}
// Default to not being configured
settings = &GroupSyncSettings{}
// Check for legacy settings if the default org.
if s.DeploymentSyncSettings.Legacy.GroupField != "" {
defaultOrganization, err := db.GetDefaultOrganization(ctx)
if err != nil {
return nil, xerrors.Errorf("get default organization: %w", err)
}
if defaultOrganization.ID == orgID {
settings = ptr.Ref(GroupSyncSettings(codersdk.GroupSyncSettings{
Field: s.Legacy.GroupField,
LegacyNameMapping: s.Legacy.GroupMapping,
RegexFilter: s.Legacy.GroupFilter,
AutoCreateMissing: s.Legacy.CreateMissingGroups,
}))
}
}
}
return settings, nil
}
func (s AGPLIDPSync) ParseGroupClaims(_ context.Context, _ jwt.MapClaims) (GroupParams, *HTTPError) {
@ -48,18 +86,6 @@ func (s AGPLIDPSync) SyncGroups(ctx context.Context, db database.Store, user dat
// nolint:gocritic // all syncing is done as a system user
ctx = dbauthz.AsSystemRestricted(ctx)
// Only care about the default org for deployment settings if the
// legacy deployment settings exist.
defaultOrgID := uuid.Nil
// Default organization is configured via legacy deployment values
if s.DeploymentSyncSettings.Legacy.GroupField != "" {
defaultOrganization, err := db.GetDefaultOrganization(ctx)
if err != nil {
return xerrors.Errorf("get default organization: %w", err)
}
defaultOrgID = defaultOrganization.ID
}
err := db.InTx(func(tx database.Store) error {
userGroups, err := tx.GetGroups(ctx, database.GetGroupsParams{
HasMemberID: user.ID,
@ -82,25 +108,17 @@ func (s AGPLIDPSync) SyncGroups(ctx context.Context, db database.Store, user dat
// organization.
orgSettings := make(map[uuid.UUID]GroupSyncSettings)
for orgID := range userOrgs {
orgResolver := s.Manager.OrganizationResolver(tx, orgID)
settings, err := s.SyncSettings.Group.Resolve(ctx, orgResolver)
settings, err := s.GroupSyncSettings(ctx, orgID, tx)
if err != nil {
if !xerrors.Is(err, runtimeconfig.ErrEntryNotFound) {
return xerrors.Errorf("resolve group sync settings: %w", err)
}
// Default to not being configured
// TODO: This error is currently silent to org admins.
// We need to come up with a way to notify the org admin of this
// error.
s.Logger.Error(ctx, "failed to get group sync settings",
slog.F("organization_id", orgID),
slog.Error(err),
)
settings = &GroupSyncSettings{}
}
// Legacy deployment settings will override empty settings.
if orgID == defaultOrgID && settings.Field == "" {
settings = &GroupSyncSettings{
Field: s.Legacy.GroupField,
LegacyNameMapping: s.Legacy.GroupMapping,
RegexFilter: s.Legacy.GroupFilter,
AutoCreateMissing: s.Legacy.CreateMissingGroups,
}
}
orgSettings[orgID] = *settings
}
@ -243,27 +261,7 @@ func (s AGPLIDPSync) ApplyGroupDifference(ctx context.Context, tx database.Store
return nil
}
type GroupSyncSettings struct {
// Field 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.
Field string `json:"field"`
// Mapping maps from an OIDC group --> Coder group ID
Mapping map[string][]uuid.UUID `json:"mapping"`
// RegexFilter is a regular expression that filters the groups returned by
// the OIDC provider. Any group not matched by this regex will be ignored.
// If the group filter is nil, then no group filtering will occur.
RegexFilter *regexp.Regexp `json:"regex_filter"`
// AutoCreateMissing controls whether groups returned by the OIDC provider
// are automatically created in Coder if they are missing.
AutoCreateMissing bool `json:"auto_create_missing_groups"`
// LegacyNameMapping is deprecated. It remaps an IDP group name to
// a Coder group name. Since configuration is now done at runtime,
// group IDs are used to account for group renames.
// For legacy configurations, this config option has to remain.
// Deprecated: Use Mapping instead.
LegacyNameMapping map[string]string `json:"legacy_group_name_mapping,omitempty"`
}
type GroupSyncSettings codersdk.GroupSyncSettings
func (s *GroupSyncSettings) Set(v string) error {
return json.Unmarshal([]byte(v), s)

View File

@ -22,6 +22,7 @@ import (
"github.com/coder/coder/v2/coderd/idpsync"
"github.com/coder/coder/v2/coderd/runtimeconfig"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
@ -84,7 +85,7 @@ func TestGroupSyncTable(t *testing.T) {
testCases := []orgSetupDefinition{
{
Name: "SwitchGroups",
Settings: &idpsync.GroupSyncSettings{
Settings: &codersdk.GroupSyncSettings{
Field: "groups",
Mapping: map[string][]uuid.UUID{
"foo": {ids.ID("sg-foo"), ids.ID("sg-foo-2")},
@ -110,7 +111,7 @@ func TestGroupSyncTable(t *testing.T) {
},
{
Name: "StayInGroup",
Settings: &idpsync.GroupSyncSettings{
Settings: &codersdk.GroupSyncSettings{
Field: "groups",
// Only match foo, so bar does not map
RegexFilter: regexp.MustCompile("^foo$"),
@ -130,7 +131,7 @@ func TestGroupSyncTable(t *testing.T) {
},
{
Name: "UserJoinsGroups",
Settings: &idpsync.GroupSyncSettings{
Settings: &codersdk.GroupSyncSettings{
Field: "groups",
Mapping: map[string][]uuid.UUID{
"foo": {ids.ID("ng-foo"), uuid.New()},
@ -153,7 +154,7 @@ func TestGroupSyncTable(t *testing.T) {
},
{
Name: "CreateGroups",
Settings: &idpsync.GroupSyncSettings{
Settings: &codersdk.GroupSyncSettings{
Field: "groups",
RegexFilter: regexp.MustCompile("^create"),
AutoCreateMissing: true,
@ -166,7 +167,7 @@ func TestGroupSyncTable(t *testing.T) {
},
{
Name: "GroupNamesNoMapping",
Settings: &idpsync.GroupSyncSettings{
Settings: &codersdk.GroupSyncSettings{
Field: "groups",
RegexFilter: regexp.MustCompile(".*"),
AutoCreateMissing: false,
@ -183,7 +184,7 @@ func TestGroupSyncTable(t *testing.T) {
},
{
Name: "NoUser",
Settings: &idpsync.GroupSyncSettings{
Settings: &codersdk.GroupSyncSettings{
Field: "groups",
Mapping: map[string][]uuid.UUID{
// Extra ID that does not map to a group
@ -205,7 +206,7 @@ func TestGroupSyncTable(t *testing.T) {
},
{
Name: "LegacyMapping",
Settings: &idpsync.GroupSyncSettings{
Settings: &codersdk.GroupSyncSettings{
Field: "groups",
RegexFilter: regexp.MustCompile("^legacy"),
LegacyNameMapping: map[string]string{
@ -241,6 +242,15 @@ func TestGroupSyncTable(t *testing.T) {
manager,
idpsync.DeploymentSyncSettings{
GroupField: "groups",
Legacy: idpsync.DefaultOrgLegacySettings{
GroupField: "groups",
GroupMapping: map[string]string{
"foo": "legacy-foo",
"baz": "legacy-baz",
},
GroupFilter: regexp.MustCompile("^legacy"),
CreateMissingGroups: true,
},
},
)
@ -274,6 +284,8 @@ func TestGroupSyncTable(t *testing.T) {
// Also sync the default org!
idpsync.DeploymentSyncSettings{
GroupField: "groups",
// This legacy field will fail any tests if the legacy override code
// has any bugs.
Legacy: idpsync.DefaultOrgLegacySettings{
GroupField: "groups",
GroupMapping: map[string]string{
@ -373,7 +385,7 @@ func TestSyncDisabled(t *testing.T) {
ids.ID("baz"): false,
ids.ID("bop"): false,
},
Settings: &idpsync.GroupSyncSettings{
Settings: &codersdk.GroupSyncSettings{
Field: "groups",
Mapping: map[string][]uuid.UUID{
"foo": {ids.ID("foo")},
@ -716,9 +728,11 @@ func SetupOrganization(t *testing.T, s *idpsync.AGPLIDPSync, db database.Store,
}
manager := runtimeconfig.NewManager()
orgResolver := manager.OrganizationResolver(db, org.ID)
err = s.Group.SetRuntimeValue(context.Background(), orgResolver, def.Settings)
require.NoError(t, err)
if def.Settings != nil {
orgResolver := manager.OrganizationResolver(db, org.ID)
err = s.Group.SetRuntimeValue(context.Background(), orgResolver, (*idpsync.GroupSyncSettings)(def.Settings))
require.NoError(t, err)
}
if !def.NotMember {
dbgen.OrganizationMember(t, db, database.OrganizationMember{
@ -759,7 +773,7 @@ type orgSetupDefinition struct {
GroupNames map[string]bool
NotMember bool
Settings *idpsync.GroupSyncSettings
Settings *codersdk.GroupSyncSettings
ExpectedGroups []uuid.UUID
ExpectedGroupNames []string
}

View File

@ -41,7 +41,8 @@ type IDPSync interface {
// GroupSyncSettings is exposed for the API to implement CRUD operations
// on the settings used by IDPSync. This entry is thread safe and can be
// accessed concurrently. The settings are stored in the database.
GroupSyncSettings() runtimeconfig.RuntimeEntry[*GroupSyncSettings]
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
}
// AGPLIDPSync is the configuration for syncing user information from an external

View File

@ -102,6 +102,14 @@ var (
Type: "group_member",
}
// ResourceIdpsyncSettings
// Valid Actions
// - "ActionRead" :: read IdP sync settings
// - "ActionUpdate" :: update IdP sync settings
ResourceIdpsyncSettings = Object{
Type: "idpsync_settings",
}
// ResourceLicense
// Valid Actions
// - "ActionCreate" :: create a license
@ -297,6 +305,7 @@ func AllResources() []Objecter {
ResourceFile,
ResourceGroup,
ResourceGroupMember,
ResourceIdpsyncSettings,
ResourceLicense,
ResourceNotificationPreference,
ResourceNotificationTemplate,

View File

@ -274,4 +274,11 @@ var RBACPermissions = map[string]PermissionDefinition{
ActionUpdate: actDef("update notification preferences"),
},
},
// idpsync_settings should always be org scoped
"idpsync_settings": {
Actions: map[Action]ActionDefinition{
ActionRead: actDef("read IdP sync settings"),
ActionUpdate: actDef("update IdP sync settings"),
},
},
}

View File

@ -705,6 +705,20 @@ func TestRolePermissions(t *testing.T) {
},
},
},
{
Name: "IDPSyncSettings",
Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate},
Resource: rbac.ResourceIdpsyncSettings.InOrg(orgID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin},
false: {
orgMemberMe, otherOrgAdmin,
memberMe, userAdmin, templateAdmin,
orgAuditor, orgUserAdmin, orgTemplateAdmin,
otherOrgMember, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
},
},
},
}
// We expect every permission to be tested above.