Skip to content

Commit b49ea6f

Browse files
committed
feat(enterprise/scim): add support for multi-organization
1 parent f3b35c5 commit b49ea6f

File tree

6 files changed

+155
-11
lines changed

6 files changed

+155
-11
lines changed

codersdk/deployment.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,7 @@ type DeploymentValues struct {
366366
AgentFallbackTroubleshootingURL serpent.URL `json:"agent_fallback_troubleshooting_url,omitempty" typescript:",notnull"`
367367
BrowserOnly serpent.Bool `json:"browser_only,omitempty" typescript:",notnull"`
368368
SCIMAPIKey serpent.String `json:"scim_api_key,omitempty" typescript:",notnull"`
369+
SCIMOrganization serpent.String `json:"scim_organization,omitempty" typescript:",notnull"`
369370
ExternalTokenEncryptionKeys serpent.StringArray `json:"external_token_encryption_keys,omitempty" typescript:",notnull"`
370371
Provisioner ProvisionerConfig `json:"provisioner,omitempty" typescript:",notnull"`
371372
RateLimit RateLimitConfig `json:"rate_limit,omitempty" typescript:",notnull"`
@@ -2162,6 +2163,15 @@ when required by your organization's security policy.`,
21622163
Annotations: serpent.Annotations{}.Mark(annotationEnterpriseKey, "true").Mark(annotationSecretKey, "true"),
21632164
Value: &c.SCIMAPIKey,
21642165
},
2166+
{
2167+
Name: "SCIM Organization",
2168+
Description: "Enables SCIM and sets the authentication header for the built-in SCIM server. New users are automatically created with OIDC authentication.",
2169+
Flag: "scim-auth-header",
2170+
Env: "CODER_SCIM_AUTH_HEADER",
2171+
Annotations: serpent.Annotations{}.Mark(annotationEnterpriseKey, "true").Mark(annotationSecretKey, "true"),
2172+
Value: &c.SCIMOrganization,
2173+
Default: "default",
2174+
},
21652175
{
21662176
Name: "External Token Encryption Keys",
21672177
Description: "Encrypt OIDC and Git authentication tokens with AES-256-GCM in the database. The value must be a comma-separated list of base64-encoded keys. Each key, when base64-decoded, must be exactly 32 bytes in length. The first key will be used to encrypt new values. Subsequent keys will be used as a fallback when decrypting. During normal operation it is recommended to only set one key unless you are in the process of rotating keys with the `coder server dbcrypt rotate` command.",

enterprise/cli/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ func (r *RootCmd) Server(_ func()) *serpent.Command {
8888
AuditLogging: true,
8989
BrowserOnly: options.DeploymentValues.BrowserOnly.Value(),
9090
SCIMAPIKey: []byte(options.DeploymentValues.SCIMAPIKey.Value()),
91+
SCIMOrganizationName: options.DeploymentValues.SCIMOrganization.Value(),
9192
RBAC: true,
9293
DERPServerRelayAddress: options.DeploymentValues.DERP.Server.RelayURL.String(),
9394
DERPServerRegionID: int(options.DeploymentValues.DERP.Server.RegionID.Value()),

enterprise/coderd/coderd.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -527,8 +527,9 @@ type Options struct {
527527
RBAC bool
528528
AuditLogging bool
529529
// Whether to block non-browser connections.
530-
BrowserOnly bool
531-
SCIMAPIKey []byte
530+
BrowserOnly bool
531+
SCIMAPIKey []byte
532+
SCIMOrganizationName string
532533

533534
ExternalTokenEncryption []dbcrypt.Cipher
534535

enterprise/coderd/coderdenttest/coderdenttest.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ type Options struct {
6565
BrowserOnly bool
6666
EntitlementsUpdateInterval time.Duration
6767
SCIMAPIKey []byte
68+
SCIMOrganizationName string
6869
UserWorkspaceQuota int
6970
ProxyHealthInterval time.Duration
7071
LicenseOptions *LicenseOptions
@@ -105,6 +106,7 @@ func NewWithAPI(t *testing.T, options *Options) (
105106
AuditLogging: options.AuditLogging,
106107
BrowserOnly: options.BrowserOnly,
107108
SCIMAPIKey: options.SCIMAPIKey,
109+
SCIMOrganizationName: options.SCIMOrganizationName,
108110
DERPServerRelayAddress: oop.AccessURL.String(),
109111
DERPServerRegionID: oop.BaseDERPMap.RegionIDs()[0],
110112
ReplicaSyncUpdateInterval: options.ReplicaSyncUpdateInterval,

enterprise/coderd/scim.go

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"net/http"
88

9+
"cdr.dev/slog"
910
"github.com/go-chi/chi/v5"
1011
"github.com/google/uuid"
1112
"github.com/imulab/go-scim/pkg/v2/handlerutil"
@@ -217,22 +218,36 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
217218
sUser.UserName = codersdk.UsernameFrom(sUser.UserName)
218219
}
219220

220-
// TODO: This is a temporary solution that does not support multi-org
221-
// deployments. This assumption places all new SCIM users into the
222-
// default organization.
223-
//nolint:gocritic
224-
defaultOrganization, err := api.Database.GetDefaultOrganization(dbauthz.AsSystemRestricted(ctx))
225-
if err != nil {
226-
_ = handlerutil.WriteError(rw, err)
227-
return
221+
var org database.Organization
222+
switch api.SCIMOrganizationName {
223+
case "", "default":
224+
//nolint:gocritic
225+
org, err = api.Database.GetDefaultOrganization(dbauthz.AsSystemRestricted(ctx))
226+
if err != nil {
227+
_ = handlerutil.WriteError(rw, err)
228+
return
229+
}
230+
231+
default:
232+
//nolint:gocritic
233+
org, err = api.Database.GetOrganizationByName(dbauthz.AsSystemRestricted(ctx), api.SCIMOrganizationName)
234+
if err != nil {
235+
if xerrors.Is(err, sql.ErrNoRows) {
236+
api.Logger.Error(ctx, "unknown organization set for CODER_SCIM_ORGANIZATION", slog.Error(err), slog.F("organization_name", api.SCIMOrganizationName))
237+
_ = handlerutil.WriteError(rw, xerrors.Errorf("SCIM is configured with an organization that does not exist: %q", api.SCIMOrganizationName))
238+
return
239+
}
240+
_ = handlerutil.WriteError(rw, err)
241+
return
242+
}
228243
}
229244

230245
//nolint:gocritic // needed for SCIM
231246
dbUser, err = api.AGPL.CreateUser(dbauthz.AsSystemRestricted(ctx), api.Database, agpl.CreateUserRequest{
232247
CreateUserRequestWithOrgs: codersdk.CreateUserRequestWithOrgs{
233248
Username: sUser.UserName,
234249
Email: email,
235-
OrganizationIDs: []uuid.UUID{defaultOrganization.ID},
250+
OrganizationIDs: []uuid.UUID{org.ID},
236251
},
237252
LoginType: database.LoginTypeOIDC,
238253
// Do not send notifications to user admins as SCIM endpoint might be called sequentially to all users.

enterprise/coderd/scim_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package coderd_test
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/json"
67
"fmt"
@@ -157,11 +158,125 @@ func TestScim(t *testing.T) {
157158
require.Len(t, userRes.Users, 1)
158159
assert.Equal(t, sUser.Emails[0].Value, userRes.Users[0].Email)
159160
assert.Equal(t, sUser.UserName, userRes.Users[0].Username)
161+
require.Len(t, userRes.Users[0].OrganizationIDs, 1)
160162

161163
// Expect zero notifications (SkipNotifications = true)
162164
require.Empty(t, notifyEnq.Sent)
163165
})
164166

167+
t.Run("OK_Organization", func(t *testing.T) {
168+
t.Parallel()
169+
170+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
171+
defer cancel()
172+
orgName := "coolin-org"
173+
174+
// given
175+
dv := coderdtest.DeploymentValues(t)
176+
dv.Experiments = []string{string(codersdk.ExperimentMultiOrganization)}
177+
scimAPIKey := []byte("hi")
178+
mockAudit := audit.NewMock()
179+
notifyEnq := &testutil.FakeNotificationsEnqueuer{}
180+
client, _ := coderdenttest.New(t, &coderdenttest.Options{
181+
Options: &coderdtest.Options{
182+
Auditor: mockAudit,
183+
NotificationsEnqueuer: notifyEnq,
184+
DeploymentValues: dv,
185+
},
186+
SCIMAPIKey: scimAPIKey,
187+
SCIMOrganizationName: orgName,
188+
AuditLogging: true,
189+
LicenseOptions: &coderdenttest.LicenseOptions{
190+
AccountID: "coolin",
191+
FeatureSet: codersdk.FeatureSetPremium,
192+
// Features: license.Features{
193+
// codersdk.FeatureSCIM: 1,
194+
// codersdk.FeatureAuditLog: 1,
195+
// },
196+
},
197+
})
198+
199+
org, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
200+
Name: orgName,
201+
})
202+
require.NoError(t, err)
203+
mockAudit.ResetLogs()
204+
205+
// when
206+
sUser := makeScimUser(t)
207+
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
208+
require.NoError(t, err)
209+
defer res.Body.Close()
210+
require.Equal(t, http.StatusOK, res.StatusCode)
211+
212+
// then
213+
// Expect audit logs
214+
aLogs := mockAudit.AuditLogs()
215+
require.Len(t, aLogs, 1)
216+
af := map[string]string{}
217+
err = json.Unmarshal([]byte(aLogs[0].AdditionalFields), &af)
218+
require.NoError(t, err)
219+
assert.Equal(t, coderd.SCIMAuditAdditionalFields, af)
220+
assert.Equal(t, database.AuditActionCreate, aLogs[0].Action)
221+
222+
// Expect users exposed over API
223+
userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value})
224+
require.NoError(t, err)
225+
require.Len(t, userRes.Users, 1)
226+
assert.Equal(t, sUser.Emails[0].Value, userRes.Users[0].Email)
227+
assert.Equal(t, sUser.UserName, userRes.Users[0].Username)
228+
assert.Len(t, userRes.Users[0].OrganizationIDs, 1)
229+
assert.Equal(t, org.ID, userRes.Users[0].OrganizationIDs[0])
230+
231+
// Expect zero notifications (SkipNotifications = true)
232+
require.Empty(t, notifyEnq.Sent)
233+
})
234+
235+
t.Run("UnknownOrganization", func(t *testing.T) {
236+
t.Parallel()
237+
238+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
239+
defer cancel()
240+
orgName := "coolin-org"
241+
242+
// given
243+
dv := coderdtest.DeploymentValues(t)
244+
dv.Experiments = []string{string(codersdk.ExperimentMultiOrganization)}
245+
scimAPIKey := []byte("hi")
246+
mockAudit := audit.NewMock()
247+
notifyEnq := &testutil.FakeNotificationsEnqueuer{}
248+
client, _ := coderdenttest.New(t, &coderdenttest.Options{
249+
Options: &coderdtest.Options{
250+
Auditor: mockAudit,
251+
NotificationsEnqueuer: notifyEnq,
252+
DeploymentValues: dv,
253+
},
254+
SCIMAPIKey: scimAPIKey,
255+
SCIMOrganizationName: "org-that-doesnt-exist",
256+
AuditLogging: true,
257+
LicenseOptions: &coderdenttest.LicenseOptions{
258+
AccountID: "coolin",
259+
FeatureSet: codersdk.FeatureSetPremium,
260+
},
261+
})
262+
263+
_, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
264+
Name: orgName,
265+
})
266+
require.NoError(t, err)
267+
mockAudit.ResetLogs()
268+
269+
// when
270+
sUser := makeScimUser(t)
271+
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
272+
require.NoError(t, err)
273+
defer res.Body.Close()
274+
require.Equal(t, http.StatusInternalServerError, res.StatusCode)
275+
body := bytes.NewBuffer(nil)
276+
_, _ = io.Copy(body, res.Body)
277+
require.Contains(t, body.String(), "org-that-doesnt-exist")
278+
})
279+
165280
t.Run("Duplicate", func(t *testing.T) {
166281
t.Parallel()
167282

0 commit comments

Comments
 (0)