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 12:01:37 -05:00
committed by GitHub
co-authored by Steven Masley
parent 2df9a3e554
commit 5ed065d88d
18 changed files with 797 additions and 67 deletions
+111
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": {
+103
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": {
+50 -52
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)
+26 -12
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
}
+2 -1
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
+9
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,
+7
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"),
},
},
}
+14
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.
+62
View File
@@ -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)
}
+2
View File
@@ -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},
+94
View File
@@ -1681,6 +1681,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
+5
View File
@@ -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` |
+41
View File
@@ -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
@@ -8962,6 +8993,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
+4
View File
@@ -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) {
+79
View File
@@ -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)
}
+172
View File
@@ -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())
})
}
+4
View File
@@ -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",
+12 -2
View File
@@ -633,6 +633,16 @@ export interface GroupArguments {
readonly HasMember: string;
}
// From codersdk/idpsync.go
export interface GroupSyncSettings {
readonly field: string;
readonly mapping: Record<string, Readonly<Array<string>>>;
// external type "regexp.Regexp", using "unknown"
readonly regex_filter?: unknown;
readonly auto_create_missing_groups: boolean;
readonly legacy_group_name_mapping?: Record<string, string>;
}
// 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"