mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
4591212482
Rewrites the SCIM 2.0 user provisioning handler to be RFC 7644 compliant. Verified against an external IdP Okta. Behavior is OPT IN
722 lines
23 KiB
Go
722 lines
23 KiB
Go
package coderd_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/imulab/go-scim/pkg/v2/handlerutil"
|
|
"github.com/imulab/go-scim/pkg/v2/spec"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd/audit"
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/coderd/notifications/notificationstest"
|
|
"github.com/coder/coder/v2/coderd/util/ptr"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/cryptorand"
|
|
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
|
|
"github.com/coder/coder/v2/enterprise/coderd/legacyscim"
|
|
"github.com/coder/coder/v2/enterprise/coderd/license"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
//nolint:revive
|
|
func makeScimUser(t testing.TB) legacyscim.SCIMUser {
|
|
rstr, err := cryptorand.String(10)
|
|
require.NoError(t, err)
|
|
|
|
return legacyscim.SCIMUser{
|
|
UserName: rstr,
|
|
Name: struct {
|
|
GivenName string `json:"givenName"`
|
|
FamilyName string `json:"familyName"`
|
|
}{
|
|
GivenName: rstr,
|
|
FamilyName: rstr,
|
|
},
|
|
Emails: []struct {
|
|
Primary bool `json:"primary"`
|
|
Value string `json:"value" format:"email"`
|
|
Type string `json:"type"`
|
|
Display string `json:"display"`
|
|
}{
|
|
{Primary: true, Value: fmt.Sprintf("%s@coder.com", rstr)},
|
|
},
|
|
Active: ptr.Ref(true),
|
|
}
|
|
}
|
|
|
|
func setScimAuth(key []byte) func(*http.Request) {
|
|
return func(r *http.Request) {
|
|
r.Header.Set("Authorization", string(key))
|
|
}
|
|
}
|
|
|
|
// TestLegacyScim tests the legacy SCIM handler (imulab/go-scim based).
|
|
// This is a reduced set of integration tests verifying HTTP routing, auth,
|
|
// and core CRUD. Detailed handler logic is covered by the unit tests in
|
|
// enterprise/coderd/scim/scimusers_test.go.
|
|
//
|
|
//nolint:gocritic // SCIM authenticates via a special header and bypasses internal RBAC.
|
|
func TestLegacyScim(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("disabled", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
client, _ := coderdenttest.New(t, &coderdenttest.Options{
|
|
SCIMAPIKey: []byte("hi"),
|
|
UseLegacySCIM: true,
|
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
|
AccountID: "coolin",
|
|
Features: license.Features{codersdk.FeatureSCIM: 0},
|
|
},
|
|
})
|
|
|
|
res, err := client.Request(ctx, "POST", "/scim/v2/Users", struct{}{})
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
assert.Equal(t, http.StatusForbidden, res.StatusCode)
|
|
})
|
|
|
|
t.Run("noAuth", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
client, _ := coderdenttest.New(t, &coderdenttest.Options{
|
|
SCIMAPIKey: []byte("hi"),
|
|
UseLegacySCIM: true,
|
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
|
AccountID: "coolin",
|
|
Features: license.Features{codersdk.FeatureSCIM: 1},
|
|
},
|
|
})
|
|
|
|
res, err := client.Request(ctx, "POST", "/scim/v2/Users", struct{}{})
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
assert.Equal(t, http.StatusUnauthorized, res.StatusCode)
|
|
})
|
|
|
|
t.Run("postUser", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
scimAPIKey := []byte("hi")
|
|
mockAudit := audit.NewMock()
|
|
notifyEnq := ¬ificationstest.FakeEnqueuer{}
|
|
client, _ := coderdenttest.New(t, &coderdenttest.Options{
|
|
Options: &coderdtest.Options{
|
|
Auditor: mockAudit,
|
|
NotificationsEnqueuer: notifyEnq,
|
|
},
|
|
SCIMAPIKey: scimAPIKey,
|
|
UseLegacySCIM: true,
|
|
AuditLogging: true,
|
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
|
AccountID: "coolin",
|
|
Features: license.Features{
|
|
codersdk.FeatureSCIM: 1,
|
|
codersdk.FeatureAuditLog: 1,
|
|
codersdk.FeatureMultipleOrganizations: 1,
|
|
},
|
|
},
|
|
})
|
|
|
|
sUser := makeScimUser(t)
|
|
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
assert.Equal(t, http.StatusOK, res.StatusCode)
|
|
|
|
var createdUser legacyscim.SCIMUser
|
|
err = json.NewDecoder(res.Body).Decode(&createdUser)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, createdUser.ID)
|
|
assert.Equal(t, sUser.UserName, createdUser.UserName)
|
|
|
|
// Verify user exists.
|
|
userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: createdUser.UserName})
|
|
require.NoError(t, err)
|
|
require.Len(t, userRes.Users, 1)
|
|
assert.Equal(t, codersdk.LoginTypeOIDC, userRes.Users[0].LoginType)
|
|
|
|
// Verify audit log.
|
|
require.True(t, len(mockAudit.AuditLogs()) > 0)
|
|
|
|
// Verify no user admin notification (SCIM skips notifications).
|
|
require.Empty(t, notifyEnq.Sent())
|
|
})
|
|
|
|
t.Run("Duplicate", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
scimAPIKey := []byte("hi")
|
|
client, _ := coderdenttest.New(t, &coderdenttest.Options{
|
|
SCIMAPIKey: scimAPIKey,
|
|
UseLegacySCIM: true,
|
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
|
AccountID: "coolin",
|
|
Features: license.Features{
|
|
codersdk.FeatureSCIM: 1,
|
|
codersdk.FeatureMultipleOrganizations: 1,
|
|
},
|
|
},
|
|
})
|
|
|
|
sUser := makeScimUser(t)
|
|
|
|
// Create same user 3 times.
|
|
for i := 0; i < 3; i++ {
|
|
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
|
|
require.NoError(t, err)
|
|
_ = res.Body.Close()
|
|
assert.Equal(t, http.StatusOK, res.StatusCode)
|
|
}
|
|
|
|
// Only 1 user should exist.
|
|
userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.UserName})
|
|
require.NoError(t, err)
|
|
require.Len(t, userRes.Users, 1)
|
|
})
|
|
|
|
t.Run("patchUser", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
scimAPIKey := []byte("hi")
|
|
mockAudit := audit.NewMock()
|
|
client, _ := coderdenttest.New(t, &coderdenttest.Options{
|
|
Options: &coderdtest.Options{Auditor: mockAudit},
|
|
SCIMAPIKey: scimAPIKey,
|
|
UseLegacySCIM: true,
|
|
AuditLogging: true,
|
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
|
AccountID: "coolin",
|
|
Features: license.Features{
|
|
codersdk.FeatureSCIM: 1,
|
|
codersdk.FeatureAuditLog: 1,
|
|
codersdk.FeatureMultipleOrganizations: 1,
|
|
},
|
|
},
|
|
})
|
|
|
|
// Create user first.
|
|
sUser := makeScimUser(t)
|
|
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
require.Equal(t, http.StatusOK, res.StatusCode)
|
|
|
|
var createdUser legacyscim.SCIMUser
|
|
err = json.NewDecoder(res.Body).Decode(&createdUser)
|
|
require.NoError(t, err)
|
|
|
|
// Suspend via PATCH.
|
|
mockAudit.ResetLogs()
|
|
sUser.Active = ptr.Ref(false)
|
|
res, err = client.Request(ctx, "PATCH", "/scim/v2/Users/"+createdUser.ID, sUser, setScimAuth(scimAPIKey))
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
assert.Equal(t, http.StatusOK, res.StatusCode)
|
|
|
|
// Verify suspended.
|
|
userRes, err := client.User(ctx, createdUser.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, codersdk.UserStatusSuspended, userRes.Status)
|
|
})
|
|
|
|
t.Run("putUser", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
scimAPIKey := []byte("hi")
|
|
mockAudit := audit.NewMock()
|
|
client, _ := coderdenttest.New(t, &coderdenttest.Options{
|
|
Options: &coderdtest.Options{Auditor: mockAudit},
|
|
SCIMAPIKey: scimAPIKey,
|
|
UseLegacySCIM: true,
|
|
AuditLogging: true,
|
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
|
AccountID: "coolin",
|
|
Features: license.Features{
|
|
codersdk.FeatureSCIM: 1,
|
|
codersdk.FeatureAuditLog: 1,
|
|
codersdk.FeatureMultipleOrganizations: 1,
|
|
},
|
|
},
|
|
})
|
|
|
|
// Create user first.
|
|
sUser := makeScimUser(t)
|
|
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
require.Equal(t, http.StatusOK, res.StatusCode)
|
|
|
|
var createdUser legacyscim.SCIMUser
|
|
err = json.NewDecoder(res.Body).Decode(&createdUser)
|
|
require.NoError(t, err)
|
|
|
|
// Suspend via PUT.
|
|
mockAudit.ResetLogs()
|
|
sUser.Active = ptr.Ref(false)
|
|
res, err = client.Request(ctx, "PUT", "/scim/v2/Users/"+createdUser.ID, sUser, setScimAuth(scimAPIKey))
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
assert.Equal(t, http.StatusOK, res.StatusCode)
|
|
|
|
// Verify suspended.
|
|
userRes, err := client.User(ctx, createdUser.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, codersdk.UserStatusSuspended, userRes.Status)
|
|
})
|
|
}
|
|
|
|
// scim2User is a minimal struct for decoding SCIM 2.0 user responses
|
|
// returned by the elimity-com/scim library.
|
|
type scim2User struct {
|
|
ID string `json:"id"`
|
|
UserName string `json:"userName"`
|
|
Active bool `json:"active"`
|
|
}
|
|
|
|
// scim2UserBody is the request body for SCIM 2.0 POST/PUT calls.
|
|
// Unlike the legacy handler, the elimity-com/scim library validates the
|
|
// "schemas" attribute against the core User schema URI and rejects bodies
|
|
// that omit it.
|
|
type scim2UserBody struct {
|
|
Schemas []string `json:"schemas"`
|
|
UserName string `json:"userName"`
|
|
Name struct {
|
|
GivenName string `json:"givenName"`
|
|
FamilyName string `json:"familyName"`
|
|
} `json:"name"`
|
|
Emails []struct {
|
|
Primary bool `json:"primary"`
|
|
Value string `json:"value"`
|
|
} `json:"emails"`
|
|
Active *bool `json:"active,omitempty"`
|
|
}
|
|
|
|
func makeScim2User(t testing.TB) scim2UserBody {
|
|
rstr, err := cryptorand.String(10)
|
|
require.NoError(t, err)
|
|
|
|
b := scim2UserBody{
|
|
Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
|
|
UserName: rstr,
|
|
Active: ptr.Ref(true),
|
|
}
|
|
b.Name.GivenName = rstr
|
|
b.Name.FamilyName = rstr
|
|
b.Emails = []struct {
|
|
Primary bool `json:"primary"`
|
|
Value string `json:"value"`
|
|
}{{Primary: true, Value: fmt.Sprintf("%s@coder.com", rstr)}}
|
|
return b
|
|
}
|
|
|
|
// TestScim exercises the SCIM 2.0 handler through real HTTP routes. It
|
|
// mirrors TestLegacyScim's structure (disabled/noAuth/post/patch/put) and
|
|
// adds coverage for behavior unique to the v2 implementation: discovery
|
|
// endpoints, 409 Conflict on duplicate active users, suspended-user
|
|
// reactivation, GET by id, and DELETE.
|
|
//
|
|
//nolint:gocritic // SCIM authenticates via a special header and bypasses internal RBAC.
|
|
func TestScim(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("disabled", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
client, _ := coderdenttest.New(t, &coderdenttest.Options{
|
|
SCIMAPIKey: []byte("hi"),
|
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
|
AccountID: "coolin",
|
|
Features: license.Features{codersdk.FeatureSCIM: 0},
|
|
},
|
|
})
|
|
|
|
res, err := client.Request(ctx, "POST", "/scim/v2/Users", struct{}{})
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
assert.Equal(t, http.StatusForbidden, res.StatusCode)
|
|
})
|
|
|
|
t.Run("noAuth", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
client, _ := coderdenttest.New(t, &coderdenttest.Options{
|
|
SCIMAPIKey: []byte("hi"),
|
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
|
AccountID: "coolin",
|
|
Features: license.Features{codersdk.FeatureSCIM: 1},
|
|
},
|
|
})
|
|
|
|
res, err := client.Request(ctx, "POST", "/scim/v2/Users", struct{}{})
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
assert.Equal(t, http.StatusUnauthorized, res.StatusCode)
|
|
})
|
|
|
|
t.Run("discovery", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
scimAPIKey := []byte("hi")
|
|
client, _ := coderdenttest.New(t, &coderdenttest.Options{
|
|
SCIMAPIKey: scimAPIKey,
|
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
|
AccountID: "coolin",
|
|
Features: license.Features{codersdk.FeatureSCIM: 1},
|
|
},
|
|
})
|
|
|
|
for _, path := range []string{
|
|
"/scim/v2/ServiceProviderConfig",
|
|
"/scim/v2/ResourceTypes",
|
|
"/scim/v2/Schemas",
|
|
} {
|
|
res, err := client.Request(ctx, "GET", path, nil, setScimAuth(scimAPIKey))
|
|
require.NoError(t, err)
|
|
_ = res.Body.Close()
|
|
assert.Equal(t, http.StatusOK, res.StatusCode, "discovery endpoint %s", path)
|
|
}
|
|
})
|
|
|
|
t.Run("postUser", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
scimAPIKey := []byte("hi")
|
|
mockAudit := audit.NewMock()
|
|
notifyEnq := ¬ificationstest.FakeEnqueuer{}
|
|
client, _ := coderdenttest.New(t, &coderdenttest.Options{
|
|
Options: &coderdtest.Options{
|
|
Auditor: mockAudit,
|
|
NotificationsEnqueuer: notifyEnq,
|
|
},
|
|
SCIMAPIKey: scimAPIKey,
|
|
AuditLogging: true,
|
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
|
AccountID: "coolin",
|
|
Features: license.Features{
|
|
codersdk.FeatureSCIM: 1,
|
|
codersdk.FeatureAuditLog: 1,
|
|
codersdk.FeatureMultipleOrganizations: 1,
|
|
},
|
|
},
|
|
})
|
|
|
|
sUser := makeScim2User(t)
|
|
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
require.Equal(t, http.StatusCreated, res.StatusCode)
|
|
|
|
var created scim2User
|
|
require.NoError(t, json.NewDecoder(res.Body).Decode(&created))
|
|
assert.NotEmpty(t, created.ID)
|
|
assert.Equal(t, sUser.UserName, created.UserName)
|
|
assert.True(t, created.Active)
|
|
|
|
// Verify user exists.
|
|
userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: created.UserName})
|
|
require.NoError(t, err)
|
|
require.Len(t, userRes.Users, 1)
|
|
assert.Equal(t, codersdk.LoginTypeOIDC, userRes.Users[0].LoginType)
|
|
|
|
// Verify audit log.
|
|
require.True(t, len(mockAudit.AuditLogs()) > 0)
|
|
|
|
// Verify no user admin notification (SCIM skips notifications).
|
|
require.Empty(t, notifyEnq.Sent())
|
|
})
|
|
|
|
t.Run("postUserConflict", func(t *testing.T) {
|
|
// SCIM 2.0 returns 409 Conflict on duplicate active user, unlike the
|
|
// legacy handler which returned 200 with the existing user.
|
|
t.Parallel()
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
scimAPIKey := []byte("hi")
|
|
client, _ := coderdenttest.New(t, &coderdenttest.Options{
|
|
SCIMAPIKey: scimAPIKey,
|
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
|
AccountID: "coolin",
|
|
Features: license.Features{
|
|
codersdk.FeatureSCIM: 1,
|
|
codersdk.FeatureMultipleOrganizations: 1,
|
|
},
|
|
},
|
|
})
|
|
|
|
sUser := makeScim2User(t)
|
|
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
|
|
require.NoError(t, err)
|
|
_ = res.Body.Close()
|
|
require.Equal(t, http.StatusCreated, res.StatusCode)
|
|
|
|
res, err = client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
|
|
require.NoError(t, err)
|
|
_ = res.Body.Close()
|
|
assert.Equal(t, http.StatusConflict, res.StatusCode)
|
|
|
|
userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.UserName})
|
|
require.NoError(t, err)
|
|
require.Len(t, userRes.Users, 1)
|
|
})
|
|
|
|
t.Run("postUserReactivatesSuspended", func(t *testing.T) {
|
|
// When the SCIM client deletes a user (which only suspends in Coder),
|
|
// posting the same user again should reactivate the existing row.
|
|
t.Parallel()
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
scimAPIKey := []byte("hi")
|
|
client, _ := coderdenttest.New(t, &coderdenttest.Options{
|
|
SCIMAPIKey: scimAPIKey,
|
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
|
AccountID: "coolin",
|
|
Features: license.Features{
|
|
codersdk.FeatureSCIM: 1,
|
|
codersdk.FeatureMultipleOrganizations: 1,
|
|
},
|
|
},
|
|
})
|
|
|
|
sUser := makeScim2User(t)
|
|
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
|
|
require.NoError(t, err)
|
|
var created scim2User
|
|
require.NoError(t, json.NewDecoder(res.Body).Decode(&created))
|
|
_ = res.Body.Close()
|
|
require.Equal(t, http.StatusCreated, res.StatusCode)
|
|
require.NotEmpty(t, created.ID)
|
|
|
|
// Delete (suspends) the user.
|
|
res, err = client.Request(ctx, "DELETE", "/scim/v2/Users/"+created.ID, nil, setScimAuth(scimAPIKey))
|
|
require.NoError(t, err)
|
|
_ = res.Body.Close()
|
|
assert.Equal(t, http.StatusNoContent, res.StatusCode)
|
|
|
|
userRes, err := client.User(ctx, created.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, codersdk.UserStatusSuspended, userRes.Status)
|
|
|
|
// Re-create. The handler should reactivate the existing row.
|
|
res, err = client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
|
|
require.NoError(t, err)
|
|
var recreated scim2User
|
|
require.NoError(t, json.NewDecoder(res.Body).Decode(&recreated))
|
|
_ = res.Body.Close()
|
|
require.Equal(t, http.StatusCreated, res.StatusCode)
|
|
assert.Equal(t, created.ID, recreated.ID, "recreate should reactivate the existing row, not create a new one")
|
|
assert.True(t, recreated.Active, "recreated user should be active in the SCIM response")
|
|
|
|
// The DB user moves from suspended → dormant on reactivate; the SCIM
|
|
// response reports both Active and Dormant as active=true.
|
|
userRes, err = client.User(ctx, created.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, codersdk.UserStatusDormant, userRes.Status)
|
|
})
|
|
|
|
t.Run("getUser", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
scimAPIKey := []byte("hi")
|
|
client, _ := coderdenttest.New(t, &coderdenttest.Options{
|
|
SCIMAPIKey: scimAPIKey,
|
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
|
AccountID: "coolin",
|
|
Features: license.Features{
|
|
codersdk.FeatureSCIM: 1,
|
|
codersdk.FeatureMultipleOrganizations: 1,
|
|
},
|
|
},
|
|
})
|
|
|
|
sUser := makeScim2User(t)
|
|
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
|
|
require.NoError(t, err)
|
|
var created scim2User
|
|
require.NoError(t, json.NewDecoder(res.Body).Decode(&created))
|
|
_ = res.Body.Close()
|
|
require.Equal(t, http.StatusCreated, res.StatusCode)
|
|
|
|
res, err = client.Request(ctx, "GET", "/scim/v2/Users/"+created.ID, nil, setScimAuth(scimAPIKey))
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
require.Equal(t, http.StatusOK, res.StatusCode)
|
|
|
|
var got scim2User
|
|
require.NoError(t, json.NewDecoder(res.Body).Decode(&got))
|
|
assert.Equal(t, created.ID, got.ID)
|
|
assert.Equal(t, sUser.UserName, got.UserName)
|
|
})
|
|
|
|
t.Run("patchUser", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
scimAPIKey := []byte("hi")
|
|
mockAudit := audit.NewMock()
|
|
client, _ := coderdenttest.New(t, &coderdenttest.Options{
|
|
Options: &coderdtest.Options{Auditor: mockAudit},
|
|
SCIMAPIKey: scimAPIKey,
|
|
AuditLogging: true,
|
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
|
AccountID: "coolin",
|
|
Features: license.Features{
|
|
codersdk.FeatureSCIM: 1,
|
|
codersdk.FeatureAuditLog: 1,
|
|
codersdk.FeatureMultipleOrganizations: 1,
|
|
},
|
|
},
|
|
})
|
|
|
|
sUser := makeScim2User(t)
|
|
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
|
|
require.NoError(t, err)
|
|
var created scim2User
|
|
require.NoError(t, json.NewDecoder(res.Body).Decode(&created))
|
|
_ = res.Body.Close()
|
|
require.Equal(t, http.StatusCreated, res.StatusCode)
|
|
|
|
// PATCH with replace op setting active=false.
|
|
mockAudit.ResetLogs()
|
|
patchBody := map[string]interface{}{
|
|
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
|
"Operations": []map[string]interface{}{
|
|
{"op": "replace", "path": "active", "value": false},
|
|
},
|
|
}
|
|
res, err = client.Request(ctx, "PATCH", "/scim/v2/Users/"+created.ID, patchBody, setScimAuth(scimAPIKey))
|
|
require.NoError(t, err)
|
|
_ = res.Body.Close()
|
|
assert.Equal(t, http.StatusOK, res.StatusCode)
|
|
|
|
userRes, err := client.User(ctx, created.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, codersdk.UserStatusSuspended, userRes.Status)
|
|
})
|
|
|
|
t.Run("putUser", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
scimAPIKey := []byte("hi")
|
|
client, _ := coderdenttest.New(t, &coderdenttest.Options{
|
|
SCIMAPIKey: scimAPIKey,
|
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
|
AccountID: "coolin",
|
|
Features: license.Features{
|
|
codersdk.FeatureSCIM: 1,
|
|
codersdk.FeatureMultipleOrganizations: 1,
|
|
},
|
|
},
|
|
})
|
|
|
|
sUser := makeScim2User(t)
|
|
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
|
|
require.NoError(t, err)
|
|
var created scim2User
|
|
require.NoError(t, json.NewDecoder(res.Body).Decode(&created))
|
|
_ = res.Body.Close()
|
|
require.Equal(t, http.StatusCreated, res.StatusCode)
|
|
|
|
// PUT with active=false.
|
|
sUser.Active = ptr.Ref(false)
|
|
res, err = client.Request(ctx, "PUT", "/scim/v2/Users/"+created.ID, sUser, setScimAuth(scimAPIKey))
|
|
require.NoError(t, err)
|
|
_ = res.Body.Close()
|
|
assert.Equal(t, http.StatusOK, res.StatusCode)
|
|
|
|
userRes, err := client.User(ctx, created.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, codersdk.UserStatusSuspended, userRes.Status)
|
|
})
|
|
|
|
t.Run("deleteUser", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
scimAPIKey := []byte("hi")
|
|
client, _ := coderdenttest.New(t, &coderdenttest.Options{
|
|
SCIMAPIKey: scimAPIKey,
|
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
|
AccountID: "coolin",
|
|
Features: license.Features{
|
|
codersdk.FeatureSCIM: 1,
|
|
codersdk.FeatureMultipleOrganizations: 1,
|
|
},
|
|
},
|
|
})
|
|
|
|
sUser := makeScim2User(t)
|
|
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
|
|
require.NoError(t, err)
|
|
var created scim2User
|
|
require.NoError(t, json.NewDecoder(res.Body).Decode(&created))
|
|
_ = res.Body.Close()
|
|
require.Equal(t, http.StatusCreated, res.StatusCode)
|
|
|
|
res, err = client.Request(ctx, "DELETE", "/scim/v2/Users/"+created.ID, nil, setScimAuth(scimAPIKey))
|
|
require.NoError(t, err)
|
|
_ = res.Body.Close()
|
|
assert.Equal(t, http.StatusNoContent, res.StatusCode)
|
|
|
|
// Coder does not hard-delete users. The user should remain but be suspended.
|
|
userRes, err := client.User(ctx, created.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, codersdk.UserStatusSuspended, userRes.Status)
|
|
})
|
|
}
|
|
|
|
func TestLegacyScimError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Demonstrates that we cannot use the standard errors
|
|
rw := httptest.NewRecorder()
|
|
_ = handlerutil.WriteError(rw, spec.ErrNotFound)
|
|
resp := rw.Result()
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
|
|
|
|
// Our error wrapper works
|
|
rw = httptest.NewRecorder()
|
|
_ = handlerutil.WriteError(rw, legacyscim.NewHTTPError(http.StatusNotFound, spec.ErrNotFound.Type, xerrors.New("not found")))
|
|
resp = rw.Result()
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
|
}
|