Skip to content

Commit 10c958b

Browse files
authored
chore: implement organization sync and create idpsync package (coder#14432)
* chore: implement filters for the organizations query * chore: implement organization sync and create idpsync package Organization sync can now be configured to assign users to an org based on oidc claims.
1 parent 043f4f5 commit 10c958b

26 files changed

+1299
-223
lines changed

cli/server.go

+2
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import (
5555

5656
"cdr.dev/slog"
5757
"cdr.dev/slog/sloggers/sloghuman"
58+
"github.com/coder/coder/v2/coderd/entitlements"
5859
"github.com/coder/pretty"
5960
"github.com/coder/quartz"
6061
"github.com/coder/retry"
@@ -605,6 +606,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
605606
SSHConfigOptions: configSSHOptions,
606607
},
607608
AllowWorkspaceRenames: vals.AllowWorkspaceRenames.Value(),
609+
Entitlements: entitlements.New(),
608610
NotificationsEnqueuer: notifications.NewNoopEnqueuer(), // Changed further down if notifications enabled.
609611
}
610612
if httpServers.TLSConfig != nil {

cli/testdata/coder_server_--help.golden

+13
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,11 @@ OIDC OPTIONS:
433433
groups. This filter is applied after the group mapping and before the
434434
regex filter.
435435

436+
--oidc-organization-assign-default bool, $CODER_OIDC_ORGANIZATION_ASSIGN_DEFAULT (default: true)
437+
If set to true, users will always be added to the default
438+
organization. If organization sync is enabled, then the default org is
439+
always added to the user's set of expectedorganizations.
440+
436441
--oidc-auth-url-params struct[map[string]string], $CODER_OIDC_AUTH_URL_PARAMS (default: {"access_type": "offline"})
437442
OIDC auth URL parameters to pass to the upstream provider.
438443

@@ -479,6 +484,14 @@ OIDC OPTIONS:
479484
--oidc-name-field string, $CODER_OIDC_NAME_FIELD (default: name)
480485
OIDC claim field to use as the name.
481486

487+
--oidc-organization-field string, $CODER_OIDC_ORGANIZATION_FIELD
488+
This field must be set if using the organization sync feature. Set to
489+
the claim to be used for organizations.
490+
491+
--oidc-organization-mapping struct[map[string][]uuid.UUID], $CODER_OIDC_ORGANIZATION_MAPPING (default: {})
492+
A map of OIDC claims and the organizations in Coder it should map to.
493+
This is required because organization IDs must be used within Coder.
494+
482495
--oidc-group-regex-filter regexp, $CODER_OIDC_GROUP_REGEX_FILTER (default: .*)
483496
If provided any group name not matching the regex is ignored. This
484497
allows for filtering out groups that are not needed. This filter is

cli/testdata/server-config.yaml.golden

+13
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,19 @@ oidc:
319319
# Ignore the userinfo endpoint and only use the ID token for user information.
320320
# (default: false, type: bool)
321321
ignoreUserInfo: false
322+
# This field must be set if using the organization sync feature. Set to the claim
323+
# to be used for organizations.
324+
# (default: <unset>, type: string)
325+
organizationField: ""
326+
# If set to true, users will always be added to the default organization. If
327+
# organization sync is enabled, then the default org is always added to the user's
328+
# set of expectedorganizations.
329+
# (default: true, type: bool)
330+
organizationAssignDefault: true
331+
# A map of OIDC claims and the organizations in Coder it should map to. This is
332+
# required because organization IDs must be used within Coder.
333+
# (default: {}, type: struct[map[string][]uuid.UUID])
334+
organizationMapping: {}
322335
# This field must be set if using the group sync feature and the scope name is not
323336
# 'groups'. Set to the claim to be used for groups.
324337
# (default: <unset>, type: string)

coderd/apidoc/docs.go

+9
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

+9
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderd.go

+11
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import (
3838

3939
"cdr.dev/slog"
4040
"github.com/coder/coder/v2/coderd/entitlements"
41+
"github.com/coder/coder/v2/coderd/idpsync"
4142
"github.com/coder/quartz"
4243
"github.com/coder/serpent"
4344

@@ -243,6 +244,9 @@ type Options struct {
243244
WorkspaceUsageTracker *workspacestats.UsageTracker
244245
// NotificationsEnqueuer handles enqueueing notifications for delivery by SMTP, webhook, etc.
245246
NotificationsEnqueuer notifications.Enqueuer
247+
248+
// IDPSync holds all configured values for syncing external IDP users into Coder.
249+
IDPSync idpsync.IDPSync
246250
}
247251

248252
// @title Coder API
@@ -270,6 +274,13 @@ func New(options *Options) *API {
270274
if options.Entitlements == nil {
271275
options.Entitlements = entitlements.New()
272276
}
277+
if options.IDPSync == nil {
278+
options.IDPSync = idpsync.NewAGPLSync(options.Logger, idpsync.SyncSettings{
279+
OrganizationField: options.DeploymentValues.OIDC.OrganizationField.Value(),
280+
OrganizationMapping: options.DeploymentValues.OIDC.OrganizationMapping.Value,
281+
OrganizationAssignDefault: options.DeploymentValues.OIDC.OrganizationAssignDefault.Value(),
282+
})
283+
}
273284
if options.NewTicker == nil {
274285
options.NewTicker = func(duration time.Duration) (tick <-chan time.Time, done func()) {
275286
ticker := time.NewTicker(duration)

coderd/database/dbauthz/dbauthz.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ var (
243243
rbac.ResourceAssignOrgRole.Type: rbac.ResourceAssignOrgRole.AvailableActions(),
244244
rbac.ResourceSystem.Type: {policy.WildcardSymbol},
245245
rbac.ResourceOrganization.Type: {policy.ActionCreate, policy.ActionRead},
246-
rbac.ResourceOrganizationMember.Type: {policy.ActionCreate},
246+
rbac.ResourceOrganizationMember.Type: {policy.ActionCreate, policy.ActionDelete, policy.ActionRead},
247247
rbac.ResourceProvisionerDaemon.Type: {policy.ActionCreate, policy.ActionUpdate},
248248
rbac.ResourceProvisionerKeys.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionDelete},
249249
rbac.ResourceUser.Type: rbac.ResourceUser.AvailableActions(),

coderd/idpsync/idpsync.go

+172
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package idpsync
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"strings"
7+
8+
"github.com/golang-jwt/jwt/v4"
9+
"github.com/google/uuid"
10+
"golang.org/x/xerrors"
11+
12+
"cdr.dev/slog"
13+
"github.com/coder/coder/v2/coderd/database"
14+
"github.com/coder/coder/v2/coderd/httpapi"
15+
"github.com/coder/coder/v2/codersdk"
16+
"github.com/coder/coder/v2/site"
17+
)
18+
19+
// IDPSync is an interface, so we can implement this as AGPL and as enterprise,
20+
// and just swap the underlying implementation.
21+
// IDPSync exists to contain all the logic for mapping a user's external IDP
22+
// claims to the internal representation of a user in Coder.
23+
// TODO: Move group + role sync into this interface.
24+
type IDPSync interface {
25+
OrganizationSyncEnabled() bool
26+
// ParseOrganizationClaims takes claims from an OIDC provider, and returns the
27+
// organization sync params for assigning users into organizations.
28+
ParseOrganizationClaims(ctx context.Context, _ jwt.MapClaims) (OrganizationParams, *HTTPError)
29+
// SyncOrganizations assigns and removed users from organizations based on the
30+
// provided params.
31+
SyncOrganizations(ctx context.Context, tx database.Store, user database.User, params OrganizationParams) error
32+
}
33+
34+
// AGPLIDPSync is the configuration for syncing user information from an external
35+
// IDP. All related code to syncing user information should be in this package.
36+
type AGPLIDPSync struct {
37+
Logger slog.Logger
38+
39+
SyncSettings
40+
}
41+
42+
type SyncSettings struct {
43+
// OrganizationField selects the claim field to be used as the created user's
44+
// organizations. If the field is the empty string, then no organization updates
45+
// will ever come from the OIDC provider.
46+
OrganizationField string
47+
// OrganizationMapping controls how organizations returned by the OIDC provider get mapped
48+
OrganizationMapping map[string][]uuid.UUID
49+
// OrganizationAssignDefault will ensure all users that authenticate will be
50+
// placed into the default organization. This is mostly a hack to support
51+
// legacy deployments.
52+
OrganizationAssignDefault bool
53+
}
54+
55+
type OrganizationParams struct {
56+
// SyncEnabled if false will skip syncing the user's organizations.
57+
SyncEnabled bool
58+
// IncludeDefault is primarily for single org deployments. It will ensure
59+
// a user is always inserted into the default org.
60+
IncludeDefault bool
61+
// Organizations is the list of organizations the user should be a member of
62+
// assuming syncing is turned on.
63+
Organizations []uuid.UUID
64+
}
65+
66+
func NewAGPLSync(logger slog.Logger, settings SyncSettings) *AGPLIDPSync {
67+
return &AGPLIDPSync{
68+
Logger: logger.Named("idp-sync"),
69+
SyncSettings: settings,
70+
}
71+
}
72+
73+
// ParseStringSliceClaim parses the claim for groups and roles, expected []string.
74+
//
75+
// Some providers like ADFS return a single string instead of an array if there
76+
// is only 1 element. So this function handles the edge cases.
77+
func ParseStringSliceClaim(claim interface{}) ([]string, error) {
78+
groups := make([]string, 0)
79+
if claim == nil {
80+
return groups, nil
81+
}
82+
83+
// The simple case is the type is exactly what we expected
84+
asStringArray, ok := claim.([]string)
85+
if ok {
86+
return asStringArray, nil
87+
}
88+
89+
asArray, ok := claim.([]interface{})
90+
if ok {
91+
for i, item := range asArray {
92+
asString, ok := item.(string)
93+
if !ok {
94+
return nil, xerrors.Errorf("invalid claim type. Element %d expected a string, got: %T", i, item)
95+
}
96+
groups = append(groups, asString)
97+
}
98+
return groups, nil
99+
}
100+
101+
asString, ok := claim.(string)
102+
if ok {
103+
if asString == "" {
104+
// Empty string should be 0 groups.
105+
return []string{}, nil
106+
}
107+
// If it is a single string, first check if it is a csv.
108+
// If a user hits this, it is likely a misconfiguration and they need
109+
// to reconfigure their IDP to send an array instead.
110+
if strings.Contains(asString, ",") {
111+
return nil, xerrors.Errorf("invalid claim type. Got a csv string (%q), change this claim to return an array of strings instead.", asString)
112+
}
113+
return []string{asString}, nil
114+
}
115+
116+
// Not sure what the user gave us.
117+
return nil, xerrors.Errorf("invalid claim type. Expected an array of strings, got: %T", claim)
118+
}
119+
120+
// IsHTTPError handles us being inconsistent with returning errors as values or
121+
// pointers.
122+
func IsHTTPError(err error) *HTTPError {
123+
var httpErr HTTPError
124+
if xerrors.As(err, &httpErr) {
125+
return &httpErr
126+
}
127+
128+
var httpErrPtr *HTTPError
129+
if xerrors.As(err, &httpErrPtr) {
130+
return httpErrPtr
131+
}
132+
return nil
133+
}
134+
135+
// HTTPError is a helper struct for returning errors from the IDP sync process.
136+
// A regular error is not sufficient because many of these errors are surfaced
137+
// to a user logging in, and the errors should be descriptive.
138+
type HTTPError struct {
139+
Code int
140+
Msg string
141+
Detail string
142+
RenderStaticPage bool
143+
RenderDetailMarkdown bool
144+
}
145+
146+
func (e HTTPError) Write(rw http.ResponseWriter, r *http.Request) {
147+
if e.RenderStaticPage {
148+
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
149+
Status: e.Code,
150+
HideStatus: true,
151+
Title: e.Msg,
152+
Description: e.Detail,
153+
RetryEnabled: false,
154+
DashboardURL: "/login",
155+
156+
RenderDescriptionMarkdown: e.RenderDetailMarkdown,
157+
})
158+
return
159+
}
160+
httpapi.Write(r.Context(), rw, e.Code, codersdk.Response{
161+
Message: e.Msg,
162+
Detail: e.Detail,
163+
})
164+
}
165+
166+
func (e HTTPError) Error() string {
167+
if e.Detail != "" {
168+
return e.Detail
169+
}
170+
171+
return e.Msg
172+
}

coderd/userauth_internal_test.go renamed to coderd/idpsync/idpsync_test.go

+14-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
package coderd
1+
package idpsync_test
22

33
import (
44
"encoding/json"
55
"testing"
66

77
"github.com/stretchr/testify/require"
8+
9+
"github.com/coder/coder/v2/coderd/idpsync"
810
)
911

1012
func TestParseStringSliceClaim(t *testing.T) {
@@ -123,7 +125,7 @@ func TestParseStringSliceClaim(t *testing.T) {
123125
require.NoError(t, err, "unmarshal json claim")
124126
}
125127

126-
found, err := parseStringSliceClaim(c.GoClaim)
128+
found, err := idpsync.ParseStringSliceClaim(c.GoClaim)
127129
if c.ErrorExpected {
128130
require.Error(t, err)
129131
} else {
@@ -133,3 +135,13 @@ func TestParseStringSliceClaim(t *testing.T) {
133135
})
134136
}
135137
}
138+
139+
func TestIsHTTPError(t *testing.T) {
140+
t.Parallel()
141+
142+
herr := idpsync.HTTPError{}
143+
require.NotNil(t, idpsync.IsHTTPError(herr))
144+
require.NotNil(t, idpsync.IsHTTPError(&herr))
145+
146+
require.Nil(t, error(nil))
147+
}

0 commit comments

Comments
 (0)