diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 494eab58e4784..df6405a2f99a1 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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" }, @@ -15160,6 +15268,9 @@ const docTemplate = `{ } } }, + "regexp.Regexp": { + "type": "object" + }, "serpent.Annotations": { "type": "object", "additionalProperties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 54377e00b291e..ac082950ca109 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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" }, @@ -13829,6 +13929,9 @@ } } }, + "regexp.Regexp": { + "type": "object" + }, "serpent.Annotations": { "type": "object", "additionalProperties": { diff --git a/coderd/idpsync/group.go b/coderd/idpsync/group.go index 1b6b8f76dc685..672bcb66da4cf 100644 --- a/coderd/idpsync/group.go +++ b/coderd/idpsync/group.go @@ -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) diff --git a/coderd/idpsync/group_test.go b/coderd/idpsync/group_test.go index cf312a576d720..0d64d8abf6059 100644 --- a/coderd/idpsync/group_test.go +++ b/coderd/idpsync/group_test.go @@ -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 } diff --git a/coderd/idpsync/idpsync.go b/coderd/idpsync/idpsync.go index 2c8ed10ce9bcc..1e8b14956b652 100644 --- a/coderd/idpsync/idpsync.go +++ b/coderd/idpsync/idpsync.go @@ -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 diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index d270fdad5c1bd..596bfe3798136 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -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, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index f71a400890a41..f8515d0a7e5f2 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -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"), + }, + }, } diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index a68132ec76ed3..01de3ee09e34d 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -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. diff --git a/codersdk/idpsync.go b/codersdk/idpsync.go new file mode 100644 index 0000000000000..105efe57c578e --- /dev/null +++ b/codersdk/idpsync.go @@ -0,0 +1,62 @@ +package codersdk + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "regexp" + + "github.com/google/uuid" + "golang.org/x/xerrors" +) + +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"` +} + +func (c *Client) GroupIDPSyncSettings(ctx context.Context, orgID string) (GroupSyncSettings, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/settings/idpsync/groups", orgID), nil) + if err != nil { + return GroupSyncSettings{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return GroupSyncSettings{}, ReadBodyAsError(res) + } + var resp GroupSyncSettings + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +func (c *Client) PatchGroupIDPSyncSettings(ctx context.Context, orgID string, req GroupSyncSettings) (GroupSyncSettings, error) { + res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/organizations/%s/settings/idpsync/groups", orgID), req) + if err != nil { + return GroupSyncSettings{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return GroupSyncSettings{}, ReadBodyAsError(res) + } + var resp GroupSyncSettings + return resp, json.NewDecoder(res.Body).Decode(&resp) +} diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 820d4f31b27a7..6a40784cf607f 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -15,6 +15,7 @@ const ( ResourceFile RBACResource = "file" ResourceGroup RBACResource = "group" ResourceGroupMember RBACResource = "group_member" + ResourceIdpsyncSettings RBACResource = "idpsync_settings" ResourceLicense RBACResource = "license" ResourceNotificationPreference RBACResource = "notification_preference" ResourceNotificationTemplate RBACResource = "notification_template" @@ -67,6 +68,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceFile: {ActionCreate, ActionRead}, ResourceGroup: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceGroupMember: {ActionRead}, + ResourceIdpsyncSettings: {ActionRead, ActionUpdate}, ResourceLicense: {ActionCreate, ActionDelete, ActionRead}, ResourceNotificationPreference: {ActionRead, ActionUpdate}, ResourceNotificationTemplate: {ActionRead, ActionUpdate}, diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 684329814edc1..e40cea4b53b73 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -1680,6 +1680,100 @@ curl -X DELETE http://coder-server:8080/api/v2/organizations/{organization}/prov To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get group IdP Sync settings by organization + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/settings/idpsync/groups \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /organizations/{organization}/settings/idpsync/groups` + +### Parameters + +| Name | In | Type | Required | Description | +| -------------- | ---- | ------------ | -------- | --------------- | +| `organization` | path | string(uuid) | true | Organization ID | + +### Example responses + +> 200 Response + +```json +{ + "auto_create_missing_groups": true, + "field": "string", + "legacy_group_name_mapping": { + "property1": "string", + "property2": "string" + }, + "mapping": { + "property1": ["string"], + "property2": ["string"] + }, + "regex_filter": {} +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [idpsync.GroupSyncSettings](schemas.md#idpsyncgroupsyncsettings) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Update group IdP Sync settings by organization + +### Code samples + +```shell +# Example request using curl +curl -X PATCH http://coder-server:8080/api/v2/organizations/{organization}/settings/idpsync/groups \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PATCH /organizations/{organization}/settings/idpsync/groups` + +### Parameters + +| Name | In | Type | Required | Description | +| -------------- | ---- | ------------ | -------- | --------------- | +| `organization` | path | string(uuid) | true | Organization ID | + +### Example responses + +> 200 Response + +```json +{ + "auto_create_missing_groups": true, + "field": "string", + "legacy_group_name_mapping": { + "property1": "string", + "property2": "string" + }, + "mapping": { + "property1": ["string"], + "property2": ["string"] + }, + "regex_filter": {} +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [idpsync.GroupSyncSettings](schemas.md#idpsyncgroupsyncsettings) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get active replicas ### Code samples diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index e084ae1abe358..f0d3e2a69df5c 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -190,6 +190,7 @@ Status Code **200** | `resource_type` | `file` | | `resource_type` | `group` | | `resource_type` | `group_member` | +| `resource_type` | `idpsync_settings` | | `resource_type` | `license` | | `resource_type` | `notification_preference` | | `resource_type` | `notification_template` | @@ -348,6 +349,7 @@ Status Code **200** | `resource_type` | `file` | | `resource_type` | `group` | | `resource_type` | `group_member` | +| `resource_type` | `idpsync_settings` | | `resource_type` | `license` | | `resource_type` | `notification_preference` | | `resource_type` | `notification_template` | @@ -506,6 +508,7 @@ Status Code **200** | `resource_type` | `file` | | `resource_type` | `group` | | `resource_type` | `group_member` | +| `resource_type` | `idpsync_settings` | | `resource_type` | `license` | | `resource_type` | `notification_preference` | | `resource_type` | `notification_template` | @@ -633,6 +636,7 @@ Status Code **200** | `resource_type` | `file` | | `resource_type` | `group` | | `resource_type` | `group_member` | +| `resource_type` | `idpsync_settings` | | `resource_type` | `license` | | `resource_type` | `notification_preference` | | `resource_type` | `notification_template` | @@ -890,6 +894,7 @@ Status Code **200** | `resource_type` | `file` | | `resource_type` | `group` | | `resource_type` | `group_member` | +| `resource_type` | `idpsync_settings` | | `resource_type` | `license` | | `resource_type` | `notification_preference` | | `resource_type` | `notification_template` | diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 43c6f59dbdfc7..8268c06d3c204 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -4291,6 +4291,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `file` | | `group` | | `group_member` | +| `idpsync_settings` | | `license` | | `notification_preference` | | `notification_template` | @@ -8867,6 +8868,36 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `severity` | `warning` | | `severity` | `error` | +## idpsync.GroupSyncSettings + +```json +{ + "auto_create_missing_groups": true, + "field": "string", + "legacy_group_name_mapping": { + "property1": "string", + "property2": "string" + }, + "mapping": { + "property1": ["string"], + "property2": ["string"] + }, + "regex_filter": {} +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ---------------------------- | ------------------------------ | -------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `auto_create_missing_groups` | boolean | false | | Auto create missing groups controls whether groups returned by the OIDC provider are automatically created in Coder if they are missing. | +| `field` | string | false | | 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. | +| `legacy_group_name_mapping` | object | false | | Legacy group name mapping 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. | +| » `[any property]` | string | false | | | +| `mapping` | object | false | | Mapping maps from an OIDC group --> Coder group ID | +| » `[any property]` | array of string | false | | | +| `regex_filter` | [regexp.Regexp](#regexpregexp) | false | | Regex filter 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. | + ## key.NodePublic ```json @@ -8960,6 +8991,16 @@ _None_ | `refresh_token` | string | false | | Refresh token is a token that's used by the application (as opposed to the user) to refresh the access token if it expires. | | `token_type` | string | false | | Token type is the type of token. The Type method returns either this or "Bearer", the default. | +## regexp.Regexp + +```json +{} +``` + +### Properties + +_None_ + ## serpent.Annotations ```json diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index f9ab3e452ac04..5a392360b209a 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -287,6 +287,10 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Post("/organizations/{organization}/members/roles", api.postOrgRoles) r.Put("/organizations/{organization}/members/roles", api.putOrgRoles) r.Delete("/organizations/{organization}/members/roles/{roleName}", api.deleteOrgRole) + r.Route("/organizations/{organization}/settings", func(r chi.Router) { + r.Get("/idpsync/groups", api.groupIDPSyncSettings) + r.Patch("/idpsync/groups", api.patchGroupIDPSyncSettings) + }) }) r.Group(func(r chi.Router) { diff --git a/enterprise/coderd/idpsync.go b/enterprise/coderd/idpsync.go new file mode 100644 index 0000000000000..22781856ce08a --- /dev/null +++ b/enterprise/coderd/idpsync.go @@ -0,0 +1,79 @@ +package coderd + +import ( + "net/http" + + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/idpsync" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" +) + +// @Summary Get group IdP Sync settings by organization +// @ID get-group-idp-sync-settings-by-organization +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param organization path string true "Organization ID" format(uuid) +// @Success 200 {object} idpsync.GroupSyncSettings +// @Router /organizations/{organization}/settings/idpsync/groups [get] +func (api *API) groupIDPSyncSettings(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + org := httpmw.OrganizationParam(r) + + if !api.Authorize(r, policy.ActionRead, rbac.ResourceIdpsyncSettings.InOrg(org.ID)) { + httpapi.Forbidden(rw) + return + } + + //nolint:gocritic // Requires system context to read runtime config + sysCtx := dbauthz.AsSystemRestricted(ctx) + settings, err := api.IDPSync.GroupSyncSettings(sysCtx, org.ID, api.Database) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, settings) +} + +// @Summary Update group IdP Sync settings by organization +// @ID update-group-idp-sync-settings-by-organization +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param organization path string true "Organization ID" format(uuid) +// @Success 200 {object} idpsync.GroupSyncSettings +// @Router /organizations/{organization}/settings/idpsync/groups [patch] +func (api *API) patchGroupIDPSyncSettings(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + org := httpmw.OrganizationParam(r) + + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceIdpsyncSettings.InOrg(org.ID)) { + httpapi.Forbidden(rw) + return + } + + var req idpsync.GroupSyncSettings + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + //nolint:gocritic // Requires system context to update runtime config + sysCtx := dbauthz.AsSystemRestricted(ctx) + err := api.IDPSync.UpdateGroupSettings(sysCtx, org.ID, api.Database, req) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + settings, err := api.IDPSync.GroupSyncSettings(sysCtx, org.ID, api.Database) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, settings) +} diff --git a/enterprise/coderd/idpsync_test.go b/enterprise/coderd/idpsync_test.go new file mode 100644 index 0000000000000..2dc236516d8ca --- /dev/null +++ b/enterprise/coderd/idpsync_test.go @@ -0,0 +1,172 @@ +package coderd_test + +import ( + "net/http" + "regexp" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "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/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" + "github.com/coder/serpent" +) + +func TestGetGroupSyncConfig(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{ + string(codersdk.ExperimentCustomRoles), + string(codersdk.ExperimentMultiOrganization), + } + + owner, db, user := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureCustomRoles: 1, + codersdk.FeatureMultipleOrganizations: 1, + }, + }, + }) + orgAdmin, _ := coderdtest.CreateAnotherUser(t, owner, user.OrganizationID, rbac.ScopedRoleOrgAdmin(user.OrganizationID)) + + ctx := testutil.Context(t, testutil.WaitShort) + dbresv := runtimeconfig.OrganizationResolver(user.OrganizationID, runtimeconfig.NewStoreResolver(db)) + entry := runtimeconfig.MustNew[*idpsync.GroupSyncSettings]("group-sync-settings") + //nolint:gocritic // Requires system context to set runtime config + err := entry.SetRuntimeValue(dbauthz.AsSystemRestricted(ctx), dbresv, &idpsync.GroupSyncSettings{Field: "august"}) + require.NoError(t, err) + + settings, err := orgAdmin.GroupIDPSyncSettings(ctx, user.OrganizationID.String()) + require.NoError(t, err) + require.Equal(t, "august", settings.Field) + }) + + t.Run("Legacy", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{ + string(codersdk.ExperimentCustomRoles), + string(codersdk.ExperimentMultiOrganization), + } + dv.OIDC.GroupField = "legacy-group" + dv.OIDC.GroupRegexFilter = serpent.Regexp(*regexp.MustCompile("legacy-filter")) + dv.OIDC.GroupMapping = serpent.Struct[map[string]string]{ + Value: map[string]string{ + "foo": "bar", + }, + } + + owner, user := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureCustomRoles: 1, + codersdk.FeatureMultipleOrganizations: 1, + }, + }, + }) + orgAdmin, _ := coderdtest.CreateAnotherUser(t, owner, user.OrganizationID, rbac.ScopedRoleOrgAdmin(user.OrganizationID)) + + ctx := testutil.Context(t, testutil.WaitShort) + + settings, err := orgAdmin.GroupIDPSyncSettings(ctx, user.OrganizationID.String()) + require.NoError(t, err) + require.Equal(t, dv.OIDC.GroupField.Value(), settings.Field) + require.Equal(t, dv.OIDC.GroupRegexFilter.String(), settings.RegexFilter.String()) + require.Equal(t, dv.OIDC.GroupMapping.Value, settings.LegacyNameMapping) + }) +} + +func TestPostGroupSyncConfig(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{ + string(codersdk.ExperimentCustomRoles), + string(codersdk.ExperimentMultiOrganization), + } + + owner, user := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureCustomRoles: 1, + codersdk.FeatureMultipleOrganizations: 1, + }, + }, + }) + + orgAdmin, _ := coderdtest.CreateAnotherUser(t, owner, user.OrganizationID, rbac.ScopedRoleOrgAdmin(user.OrganizationID)) + + // Test as org admin + ctx := testutil.Context(t, testutil.WaitShort) + settings, err := orgAdmin.PatchGroupIDPSyncSettings(ctx, user.OrganizationID.String(), codersdk.GroupSyncSettings{ + Field: "august", + }) + require.NoError(t, err) + require.Equal(t, "august", settings.Field) + + fetchedSettings, err := orgAdmin.GroupIDPSyncSettings(ctx, user.OrganizationID.String()) + require.NoError(t, err) + require.Equal(t, "august", fetchedSettings.Field) + }) + + t.Run("NotAuthorized", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{ + string(codersdk.ExperimentCustomRoles), + string(codersdk.ExperimentMultiOrganization), + } + + owner, user := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureCustomRoles: 1, + codersdk.FeatureMultipleOrganizations: 1, + }, + }, + }) + + member, _ := coderdtest.CreateAnotherUser(t, owner, user.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitShort) + _, err := member.PatchGroupIDPSyncSettings(ctx, user.OrganizationID.String(), codersdk.GroupSyncSettings{ + Field: "august", + }) + var apiError *codersdk.Error + require.ErrorAs(t, err, &apiError) + require.Equal(t, http.StatusForbidden, apiError.StatusCode()) + + _, err = member.GroupIDPSyncSettings(ctx, user.OrganizationID.String()) + require.ErrorAs(t, err, &apiError) + require.Equal(t, http.StatusForbidden, apiError.StatusCode()) + }) +} diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index 366d81fa00d52..a2a6e3d5e4161 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -55,6 +55,10 @@ export const RBACResourceActions: Partial< group_member: { read: "read group members", }, + idpsync_settings: { + read: "read IdP sync settings", + update: "update IdP sync settings", + }, license: { create: "create a license", delete: "delete license", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 64bdb2d262852..6b95096a2adb7 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -633,6 +633,16 @@ export interface GroupArguments { readonly HasMember: string; } +// From codersdk/idpsync.go +export interface GroupSyncSettings { + readonly field: string; + readonly mapping: Record>>; + // external type "regexp.Regexp", using "unknown" + readonly regex_filter?: unknown; + readonly auto_create_missing_groups: boolean; + readonly legacy_group_name_mapping?: Record; +} + // From codersdk/workspaceapps.go export interface Healthcheck { readonly url: string; @@ -2115,8 +2125,8 @@ export type RBACAction = "application_connect" | "assign" | "create" | "delete" export const RBACActions: RBACAction[] = ["application_connect", "assign", "create", "delete", "read", "read_personal", "ssh", "start", "stop", "update", "update_personal", "use", "view_insights"] // From codersdk/rbacresources_gen.go -export type RBACResource = "*" | "api_key" | "assign_org_role" | "assign_role" | "audit_log" | "debug_info" | "deployment_config" | "deployment_stats" | "file" | "group" | "group_member" | "license" | "notification_preference" | "notification_template" | "oauth2_app" | "oauth2_app_code_token" | "oauth2_app_secret" | "organization" | "organization_member" | "provisioner_daemon" | "provisioner_keys" | "replicas" | "system" | "tailnet_coordinator" | "template" | "user" | "workspace" | "workspace_dormant" | "workspace_proxy" -export const RBACResources: RBACResource[] = ["*", "api_key", "assign_org_role", "assign_role", "audit_log", "debug_info", "deployment_config", "deployment_stats", "file", "group", "group_member", "license", "notification_preference", "notification_template", "oauth2_app", "oauth2_app_code_token", "oauth2_app_secret", "organization", "organization_member", "provisioner_daemon", "provisioner_keys", "replicas", "system", "tailnet_coordinator", "template", "user", "workspace", "workspace_dormant", "workspace_proxy"] +export type RBACResource = "*" | "api_key" | "assign_org_role" | "assign_role" | "audit_log" | "debug_info" | "deployment_config" | "deployment_stats" | "file" | "group" | "group_member" | "idpsync_settings" | "license" | "notification_preference" | "notification_template" | "oauth2_app" | "oauth2_app_code_token" | "oauth2_app_secret" | "organization" | "organization_member" | "provisioner_daemon" | "provisioner_keys" | "replicas" | "system" | "tailnet_coordinator" | "template" | "user" | "workspace" | "workspace_dormant" | "workspace_proxy" +export const RBACResources: RBACResource[] = ["*", "api_key", "assign_org_role", "assign_role", "audit_log", "debug_info", "deployment_config", "deployment_stats", "file", "group", "group_member", "idpsync_settings", "license", "notification_preference", "notification_template", "oauth2_app", "oauth2_app_code_token", "oauth2_app_secret", "organization", "organization_member", "provisioner_daemon", "provisioner_keys", "replicas", "system", "tailnet_coordinator", "template", "user", "workspace", "workspace_dormant", "workspace_proxy"] // From codersdk/audit.go export type ResourceType = "api_key" | "convert_login" | "custom_role" | "git_ssh_key" | "group" | "health_settings" | "license" | "notifications_settings" | "oauth2_provider_app" | "oauth2_provider_app_secret" | "organization" | "template" | "template_version" | "user" | "workspace" | "workspace_build" | "workspace_proxy"