Skip to content

Commit 00860cf

Browse files
authored
feat: add group mapping option for group sync (#6705)
* feat: add group mapping option for group sync * fixup! feat: add group mapping option for group sync
1 parent 120bc4b commit 00860cf

File tree

12 files changed

+114
-18
lines changed

12 files changed

+114
-18
lines changed

cli/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,7 @@ flags, and YAML configuration. The precedence is as follows:
796796
AllowSignups: cfg.OIDC.AllowSignups.Value(),
797797
UsernameField: cfg.OIDC.UsernameField.String(),
798798
GroupField: cfg.OIDC.GroupField.String(),
799+
GroupMapping: cfg.OIDC.GroupMapping.Value,
799800
SignInText: cfg.OIDC.SignInText.String(),
800801
IconURL: cfg.OIDC.IconURL.String(),
801802
IgnoreEmailVerified: cfg.OIDC.IgnoreEmailVerified.Value(),

coderd/apidoc/docs.go

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderd.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,12 @@ func New(options *Options) *API {
223223
options.SSHConfig.HostnamePrefix = "coder."
224224
}
225225
if options.SetUserGroups == nil {
226-
options.SetUserGroups = func(context.Context, database.Store, uuid.UUID, []string) error { return nil }
226+
options.SetUserGroups = func(ctx context.Context, _ database.Store, id uuid.UUID, groups []string) error {
227+
options.Logger.Warn(ctx, "attempted to assign OIDC groups without enterprise license",
228+
slog.F("id", id), slog.F("groups", groups),
229+
)
230+
return nil
231+
}
227232
}
228233
if options.TemplateScheduleStore == nil {
229234
options.TemplateScheduleStore = schedule.NewAGPLTemplateScheduleStore()

coderd/userauth.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,10 @@ type OIDCConfig struct {
481481
// groups. If the group field is the empty string, then no group updates
482482
// will ever come from the OIDC provider.
483483
GroupField string
484+
// GroupMapping controls how groups returned by the OIDC provider get mapped
485+
// to groups within Coder.
486+
// map[oidcGroupName]coderGroupName
487+
GroupMapping map[string]string
484488
// SignInText is the text to display on the OIDC login button
485489
SignInText string
486490
// IconURL points to the URL of an icon to display on the OIDC login button
@@ -651,6 +655,11 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
651655
})
652656
return
653657
}
658+
659+
if mappedGroup, ok := api.OIDCConfig.GroupMapping[group]; ok {
660+
group = mappedGroup
661+
}
662+
654663
groups = append(groups, group)
655664
}
656665
} else {

codersdk/deployment.go

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"context"
55
"encoding/json"
66
"flag"
7-
"fmt"
87
"math"
98
"net/http"
109
"os"
@@ -199,7 +198,7 @@ func ParseSSHConfigOption(opt string) (key string, value string, err error) {
199198
return r == ' ' || r == '='
200199
})
201200
if idx == -1 {
202-
return "", "", fmt.Errorf("invalid config-ssh option %q", opt)
201+
return "", "", xerrors.Errorf("invalid config-ssh option %q", opt)
203202
}
204203
return opt[:idx], opt[idx+1:], nil
205204
}
@@ -248,17 +247,18 @@ type OAuth2GithubConfig struct {
248247
}
249248

250249
type OIDCConfig struct {
251-
AllowSignups clibase.Bool `json:"allow_signups" typescript:",notnull"`
252-
ClientID clibase.String `json:"client_id" typescript:",notnull"`
253-
ClientSecret clibase.String `json:"client_secret" typescript:",notnull"`
254-
EmailDomain clibase.Strings `json:"email_domain" typescript:",notnull"`
255-
IssuerURL clibase.String `json:"issuer_url" typescript:",notnull"`
256-
Scopes clibase.Strings `json:"scopes" typescript:",notnull"`
257-
IgnoreEmailVerified clibase.Bool `json:"ignore_email_verified" typescript:",notnull"`
258-
UsernameField clibase.String `json:"username_field" typescript:",notnull"`
259-
GroupField clibase.String `json:"groups_field" typescript:",notnull"`
260-
SignInText clibase.String `json:"sign_in_text" typescript:",notnull"`
261-
IconURL clibase.URL `json:"icon_url" typescript:",notnull"`
250+
AllowSignups clibase.Bool `json:"allow_signups" typescript:",notnull"`
251+
ClientID clibase.String `json:"client_id" typescript:",notnull"`
252+
ClientSecret clibase.String `json:"client_secret" typescript:",notnull"`
253+
EmailDomain clibase.Strings `json:"email_domain" typescript:",notnull"`
254+
IssuerURL clibase.String `json:"issuer_url" typescript:",notnull"`
255+
Scopes clibase.Strings `json:"scopes" typescript:",notnull"`
256+
IgnoreEmailVerified clibase.Bool `json:"ignore_email_verified" typescript:",notnull"`
257+
UsernameField clibase.String `json:"username_field" typescript:",notnull"`
258+
GroupField clibase.String `json:"groups_field" typescript:",notnull"`
259+
GroupMapping clibase.Struct[map[string]string] `json:"group_mapping" typescript:",notnull"`
260+
SignInText clibase.String `json:"sign_in_text" typescript:",notnull"`
261+
IconURL clibase.URL `json:"icon_url" typescript:",notnull"`
262262
}
263263

264264
type TelemetryConfig struct {
@@ -875,6 +875,16 @@ when required by your organization's security policy.`,
875875
Group: &deploymentGroupOIDC,
876876
YAML: "groupField",
877877
},
878+
{
879+
Name: "OIDC Group Mapping",
880+
Description: "A map of OIDC group IDs and the group in Coder it should map to. This is useful for when OIDC providers only return group IDs.",
881+
Flag: "oidc-group-mapping",
882+
Env: "OIDC_GROUP_MAPPING",
883+
Default: "{}",
884+
Value: &c.OIDC.GroupMapping,
885+
Group: &deploymentGroupOIDC,
886+
YAML: "groupMapping",
887+
},
878888
{
879889
Name: "OpenID Connect sign in text",
880890
Description: "The text to show on the OpenID Connect sign in button",

docs/admin/auth.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,4 +197,20 @@ CODER_OIDC_SCOPES=openid,profile,email,groups
197197
On login, users will automatically be assigned to groups that have matching
198198
names in Coder and removed from groups that the user no longer belongs to.
199199

200+
For cases when an OIDC provider only returns group IDs ([Azure AD][azure-gids])
201+
or you want to have different group names in Coder than in your OIDC provider,
202+
you can configure mapping between the two.
203+
204+
```console
205+
# as an environment variable
206+
CODER_OIDC_GROUP_MAPPING='{"myOIDCGroupID": "myCoderGroupName"}'
207+
# as a flag
208+
--oidc-group-mapping '{"myOIDCGroupID": "myCoderGroupName"}'
209+
```
210+
211+
From the example above, users that belong to the `myOIDCGroupID` group in your
212+
OIDC provider will be added to the `myCoderGroupName` group in Coder.
213+
200214
> **Note:** Groups are only updated on login.
215+
216+
[azure-gids]: https://github.com/MicrosoftDocs/azure-docs/issues/59766#issuecomment-664387195

docs/api/general.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
234234
"client_id": "string",
235235
"client_secret": "string",
236236
"email_domain": ["string"],
237+
"group_mapping": {},
237238
"groups_field": "string",
238239
"icon_url": {
239240
"forceQuery": true,

docs/api/schemas.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1766,6 +1766,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a
17661766
"client_id": "string",
17671767
"client_secret": "string",
17681768
"email_domain": ["string"],
1769+
"group_mapping": {},
17691770
"groups_field": "string",
17701771
"icon_url": {
17711772
"forceQuery": true,
@@ -2110,6 +2111,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a
21102111
"client_id": "string",
21112112
"client_secret": "string",
21122113
"email_domain": ["string"],
2114+
"group_mapping": {},
21132115
"groups_field": "string",
21142116
"icon_url": {
21152117
"forceQuery": true,
@@ -2771,6 +2773,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a
27712773
"client_id": "string",
27722774
"client_secret": "string",
27732775
"email_domain": ["string"],
2776+
"group_mapping": {},
27742777
"groups_field": "string",
27752778
"icon_url": {
27762779
"forceQuery": true,
@@ -2801,6 +2804,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a
28012804
| `client_id` | string | false | | |
28022805
| `client_secret` | string | false | | |
28032806
| `email_domain` | array of string | false | | |
2807+
| `group_mapping` | object | false | | |
28042808
| `groups_field` | string | false | | |
28052809
| `icon_url` | [clibase.URL](#clibaseurl) | false | | |
28062810
| `ignore_email_verified` | boolean | false | | |

enterprise/coderd/userauth_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,51 @@ func TestUserOIDC(t *testing.T) {
6767
require.NoError(t, err)
6868
require.Len(t, group.Members, 1)
6969
})
70+
t.Run("AssignsMapped", func(t *testing.T) {
71+
t.Parallel()
72+
73+
ctx, _ := testutil.Context(t)
74+
conf := coderdtest.NewOIDCConfig(t, "")
75+
76+
oidcGroupName := "pingpong"
77+
coderGroupName := "bingbong"
78+
79+
config := conf.OIDCConfig(t, jwt.MapClaims{}, func(cfg *coderd.OIDCConfig) {
80+
cfg.GroupMapping = map[string]string{oidcGroupName: coderGroupName}
81+
})
82+
config.AllowSignups = true
83+
84+
client := coderdenttest.New(t, &coderdenttest.Options{
85+
Options: &coderdtest.Options{
86+
OIDCConfig: config,
87+
},
88+
})
89+
_ = coderdtest.CreateFirstUser(t, client)
90+
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
91+
AllFeatures: true,
92+
})
93+
94+
admin, err := client.User(ctx, "me")
95+
require.NoError(t, err)
96+
require.Len(t, admin.OrganizationIDs, 1)
97+
98+
group, err := client.CreateGroup(ctx, admin.OrganizationIDs[0], codersdk.CreateGroupRequest{
99+
Name: coderGroupName,
100+
})
101+
require.NoError(t, err)
102+
require.Len(t, group.Members, 0)
103+
104+
resp := oidcCallback(t, client, conf.EncodeClaims(t, jwt.MapClaims{
105+
"email": "colin@coder.com",
106+
"groups": []string{oidcGroupName},
107+
}))
108+
assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
109+
110+
group, err = client.Group(ctx, group.ID)
111+
require.NoError(t, err)
112+
require.Len(t, group.Members, 1)
113+
})
114+
70115
t.Run("AddThenRemove", func(t *testing.T) {
71116
t.Parallel()
72117

0 commit comments

Comments
 (0)