diff --git a/cli/server_test.go b/cli/server_test.go index d7331a0a1cc1e..c29f4c27274ae 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -24,7 +24,7 @@ import ( "testing" "time" - "github.com/go-chi/chi" + "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/goleak" diff --git a/coderd/coderd.go b/coderd/coderd.go index 050d16b86911b..3d6750a111399 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -222,11 +222,7 @@ func New(options *Options) *API { r.Route("/api/v2", func(r chi.Router) { api.APIHandler = r - r.NotFound(func(rw http.ResponseWriter, r *http.Request) { - httpapi.Write(rw, http.StatusNotFound, codersdk.Response{ - Message: "Route not found.", - }) - }) + r.NotFound(func(rw http.ResponseWriter, r *http.Request) { httpapi.RouteNotFound(rw) }) r.Use( tracing.Middleware(api.TracerProvider), // Specific routes can specify smaller limits. diff --git a/coderd/httpapi/httpapi.go b/coderd/httpapi/httpapi.go index 79303adb5ea02..56bdfcaa6a7a0 100644 --- a/coderd/httpapi/httpapi.go +++ b/coderd/httpapi/httpapi.go @@ -75,6 +75,12 @@ func InternalServerError(rw http.ResponseWriter, err error) { }) } +func RouteNotFound(rw http.ResponseWriter) { + Write(rw, http.StatusNotFound, codersdk.Response{ + Message: "Route not found.", + }) +} + // Write outputs a standardized format to an HTTP response body. func Write(rw http.ResponseWriter, status int, response interface{}) { buf := &bytes.Buffer{} diff --git a/coderd/userauth.go b/coderd/userauth.go index e3d1e2320c99d..c7f1769dbce55 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -378,7 +378,7 @@ func (api *API) oauthLogin(r *http.Request, params oauthLoginParams) (*http.Cook organizationID = organizations[0].ID } - user, _, err = api.createUser(ctx, tx, createUserRequest{ + user, _, err = api.CreateUser(ctx, tx, CreateUserRequest{ CreateUserRequest: codersdk.CreateUserRequest{ Email: params.Email, Username: params.Username, diff --git a/coderd/users.go b/coderd/users.go index 631e660eb5770..d106645cbd90e 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -83,7 +83,7 @@ func (api *API) postFirstUser(rw http.ResponseWriter, r *http.Request) { return } - user, organizationID, err := api.createUser(r.Context(), api.Database, createUserRequest{ + user, organizationID, err := api.CreateUser(r.Context(), api.Database, CreateUserRequest{ CreateUserRequest: codersdk.CreateUserRequest{ Email: createUser.Email, Username: createUser.Username, @@ -317,7 +317,7 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { return } - user, _, err := api.createUser(r.Context(), api.Database, createUserRequest{ + user, _, err := api.CreateUser(r.Context(), api.Database, CreateUserRequest{ CreateUserRequest: req, LoginType: database.LoginTypePassword, }) @@ -1101,12 +1101,12 @@ func (api *API) createAPIKey(r *http.Request, params createAPIKeyParams) (*http. }, nil } -type createUserRequest struct { +type CreateUserRequest struct { codersdk.CreateUserRequest LoginType database.LoginType } -func (api *API) createUser(ctx context.Context, store database.Store, req createUserRequest) (database.User, uuid.UUID, error) { +func (api *API) CreateUser(ctx context.Context, store database.Store, req CreateUserRequest) (database.User, uuid.UUID, error) { var user database.User return user, req.OrganizationID, store.InTx(func(tx database.Store) error { orgRoles := make([]string, 0) diff --git a/codersdk/features.go b/codersdk/features.go index 37b0113c37dfb..3764470c8c0f5 100644 --- a/codersdk/features.go +++ b/codersdk/features.go @@ -17,9 +17,10 @@ const ( const ( FeatureUserLimit = "user_limit" FeatureAuditLog = "audit_log" + FeatureSCIM = "scim" ) -var FeatureNames = []string{FeatureUserLimit, FeatureAuditLog} +var FeatureNames = []string{FeatureUserLimit, FeatureAuditLog, FeatureSCIM} type Feature struct { Entitlement Entitlement `json:"entitlement"` diff --git a/docs/admin/auth.md b/docs/admin/auth.md index bae7f1ed5f41d..34368865fb27b 100644 --- a/docs/admin/auth.md +++ b/docs/admin/auth.md @@ -74,3 +74,14 @@ CODER_OIDC_CLIENT_SECRET="G0CSP...7qSM" Once complete, run `sudo service coder restart` to reboot Coder. > When a new user is created, the `preferred_username` claim becomes the username. If this claim is empty, the email address will be stripped of the domain, and become the username (e.g. `example@coder.com` becomes `example`). + +## SCIM + +Coder supports user provisioning and deprovisioning via SCIM 2.0 with header +authentication. Upon deactivation, users are [suspended](userd.md#suspend-a-user) +and are not deleted. [Configure](./configure.md) your SCIM application with an +auth key and supply it the Coder server. + +```console +CODER_SCIM_API_KEY="your-api-key" +``` diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 8fd9e542af4cc..b41197f32614f 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -14,11 +14,13 @@ import ( func server() *cobra.Command { var ( - auditLogging bool + auditLogging bool + scimAuthHeader string ) cmd := agpl.Server(func(ctx context.Context, options *agplcoderd.Options) (*agplcoderd.API, error) { api, err := coderd.New(ctx, &coderd.Options{ AuditLogging: auditLogging, + SCIMAPIKey: []byte(scimAuthHeader), Options: options, }) if err != nil { @@ -28,6 +30,7 @@ func server() *cobra.Command { }) cliflag.BoolVarP(cmd.Flags(), &auditLogging, "audit-logging", "", "CODER_AUDIT_LOGGING", true, "Specifies whether audit logging is enabled.") + cliflag.StringVarP(cmd.Flags(), &scimAuthHeader, "scim-auth-header", "", "CODER_SCIM_API_KEY", "", "Enables SCIM and sets the authentication header for the built-in SCIM server. New users are automatically created with OIDC authentication.") return cmd } diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 20140f0e80d83..c53b885aaaa85 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -63,6 +63,19 @@ func New(ctx context.Context, options *Options) (*API, error) { }) }) + if len(options.SCIMAPIKey) != 0 { + api.AGPL.RootHandler.Route("/scim/v2", func(r chi.Router) { + r.Use(api.scimEnabledMW) + r.Post("/Users", api.scimPostUser) + r.Route("/Users", func(r chi.Router) { + r.Get("/", api.scimGetUsers) + r.Post("/", api.scimPostUser) + r.Get("/{id}", api.scimGetUser) + r.Patch("/{id}", api.scimPatchUser) + }) + }) + } + err := api.updateEntitlements(ctx) if err != nil { return nil, xerrors.Errorf("update entitlements: %w", err) @@ -76,6 +89,7 @@ type Options struct { *coderd.Options AuditLogging bool + SCIMAPIKey []byte EntitlementsUpdateInterval time.Duration Keys map[string]ed25519.PublicKey } @@ -93,6 +107,7 @@ type entitlements struct { hasLicense bool activeUsers codersdk.Feature auditLogs codersdk.Entitlement + scim codersdk.Entitlement } func (api *API) Close() error { @@ -117,6 +132,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { Entitlement: codersdk.EntitlementNotEntitled, }, auditLogs: codersdk.EntitlementNotEntitled, + scim: codersdk.EntitlementNotEntitled, } // Here we loop through licenses to detect enabled features. @@ -149,6 +165,9 @@ func (api *API) updateEntitlements(ctx context.Context) error { if claims.Features.AuditLog > 0 { entitlements.auditLogs = entitlement } + if claims.Features.SCIM > 0 { + entitlements.scim = entitlement + } } if entitlements.auditLogs != api.entitlements.auditLogs { diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 572b858bea31f..30dcbfea39eb2 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -37,6 +37,7 @@ func init() { type Options struct { *coderdtest.Options EntitlementsUpdateInterval time.Duration + SCIMAPIKey []byte } // New constructs a codersdk client connected to an in-memory Enterprise API instance. @@ -55,6 +56,7 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c srv, cancelFunc, oop := coderdtest.NewOptions(t, options.Options) coderAPI, err := coderd.New(context.Background(), &coderd.Options{ AuditLogging: true, + SCIMAPIKey: options.SCIMAPIKey, Options: oop, EntitlementsUpdateInterval: options.EntitlementsUpdateInterval, Keys: map[string]ed25519.PublicKey{ @@ -82,6 +84,7 @@ type LicenseOptions struct { ExpiresAt time.Time UserLimit int64 AuditLog bool + SCIM bool } // AddLicense generates a new license with the options provided and inserts it. @@ -105,6 +108,11 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { if options.AuditLog { auditLog = 1 } + scim := int64(0) + if options.SCIM { + scim = 1 + } + c := &coderd.Claims{ RegisteredClaims: jwt.RegisteredClaims{ Issuer: "test@testing.test", @@ -119,6 +127,7 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { Features: coderd.Features{ UserLimit: options.UserLimit, AuditLog: auditLog, + SCIM: scim, }, } tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c) diff --git a/enterprise/coderd/licenses.go b/enterprise/coderd/licenses.go index 5b8273f2ffe60..b3516912c826d 100644 --- a/enterprise/coderd/licenses.go +++ b/enterprise/coderd/licenses.go @@ -47,6 +47,7 @@ var Keys = map[string]ed25519.PublicKey{"2022-08-12": ed25519.PublicKey(key20220 type Features struct { UserLimit int64 `json:"user_limit"` AuditLog int64 `json:"audit_log"` + SCIM int64 `json:"scim"` } type Claims struct { diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go index 243898a43ca73..12fa82583436e 100644 --- a/enterprise/coderd/licenses_test.go +++ b/enterprise/coderd/licenses_test.go @@ -80,11 +80,13 @@ func TestGetLicense(t *testing.T) { coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ AccountID: "testing", AuditLog: true, + SCIM: true, }) coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ AccountID: "testing2", AuditLog: true, + SCIM: true, UserLimit: 200, }) @@ -96,12 +98,14 @@ func TestGetLicense(t *testing.T) { assert.Equal(t, map[string]interface{}{ codersdk.FeatureUserLimit: json.Number("0"), codersdk.FeatureAuditLog: json.Number("1"), + codersdk.FeatureSCIM: json.Number("1"), }, licenses[0].Claims["features"]) assert.Equal(t, int32(2), licenses[1].ID) assert.Equal(t, "testing2", licenses[1].Claims["account_id"]) assert.Equal(t, map[string]interface{}{ codersdk.FeatureUserLimit: json.Number("200"), codersdk.FeatureAuditLog: json.Number("1"), + codersdk.FeatureSCIM: json.Number("1"), }, licenses[1].Claims["features"]) }) } diff --git a/enterprise/coderd/scim.go b/enterprise/coderd/scim.go new file mode 100644 index 0000000000000..07651992561af --- /dev/null +++ b/enterprise/coderd/scim.go @@ -0,0 +1,194 @@ +package coderd + +import ( + "crypto/subtle" + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/imulab/go-scim/pkg/v2/handlerutil" + scimjson "github.com/imulab/go-scim/pkg/v2/json" + "github.com/imulab/go-scim/pkg/v2/service" + "github.com/imulab/go-scim/pkg/v2/spec" + + agpl "github.com/coder/coder/coderd" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/codersdk" +) + +func (api *API) scimEnabledMW(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + api.entitlementsMu.RLock() + scim := api.entitlements.scim + api.entitlementsMu.RUnlock() + + if scim == codersdk.EntitlementNotEntitled { + httpapi.RouteNotFound(rw) + return + } + + next.ServeHTTP(rw, r) + }) +} + +func (api *API) scimVerifyAuthHeader(r *http.Request) bool { + hdr := []byte(r.Header.Get("Authorization")) + + return len(api.SCIMAPIKey) != 0 && subtle.ConstantTimeCompare(hdr, api.SCIMAPIKey) == 1 +} + +// scimGetUsers intentionally always returns no users. This is done to always force +// Okta to try and create each user individually, this way we don't need to +// implement fetching users twice. +// +//nolint:revive +func (api *API) scimGetUsers(rw http.ResponseWriter, r *http.Request) { + if !api.scimVerifyAuthHeader(r) { + _ = handlerutil.WriteError(rw, spec.Error{Status: http.StatusUnauthorized, Type: "invalidAuthorization"}) + return + } + + _ = handlerutil.WriteSearchResultToResponse(rw, &service.QueryResponse{ + TotalResults: 0, + StartIndex: 1, + ItemsPerPage: 0, + Resources: []scimjson.Serializable{}, + }) +} + +// scimGetUser intentionally always returns an error saying the user wasn't found. +// This is done to always force Okta to try and create the user, this way we +// don't need to implement fetching users twice. +// +//nolint:revive +func (api *API) scimGetUser(rw http.ResponseWriter, r *http.Request) { + if !api.scimVerifyAuthHeader(r) { + _ = handlerutil.WriteError(rw, spec.Error{Status: http.StatusUnauthorized, Type: "invalidAuthorization"}) + return + } + + _ = handlerutil.WriteError(rw, spec.ErrNotFound) +} + +// We currently use our own struct instead of using the SCIM package. This was +// done mostly because the SCIM package was almost impossible to use. We only +// need these fields, so it was much simpler to use our own struct. This was +// tested only with Okta. +type SCIMUser struct { + Schemas []string `json:"schemas"` + ID string `json:"id"` + 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"` + Type string `json:"type"` + Display string `json:"display"` + } `json:"emails"` + Active bool `json:"active"` + Groups []interface{} `json:"groups"` + Meta struct { + ResourceType string `json:"resourceType"` + } `json:"meta"` +} + +// scimPostUser creates a new user, or returns the existing user if it exists. +func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !api.scimVerifyAuthHeader(r) { + _ = handlerutil.WriteError(rw, spec.Error{Status: http.StatusUnauthorized, Type: "invalidAuthorization"}) + return + } + + var sUser SCIMUser + err := json.NewDecoder(r.Body).Decode(&sUser) + if err != nil { + _ = handlerutil.WriteError(rw, err) + return + } + + email := "" + for _, e := range sUser.Emails { + if e.Primary { + email = e.Value + break + } + } + + if email == "" { + _ = handlerutil.WriteError(rw, spec.Error{Status: http.StatusBadRequest, Type: "invalidEmail"}) + return + } + + user, _, err := api.AGPL.CreateUser(ctx, api.Database, agpl.CreateUserRequest{ + CreateUserRequest: codersdk.CreateUserRequest{ + Username: sUser.UserName, + Email: email, + }, + LoginType: database.LoginTypeOIDC, + }) + if err != nil { + _ = handlerutil.WriteError(rw, err) + return + } + + sUser.ID = user.ID.String() + sUser.UserName = user.Username + + httpapi.Write(rw, http.StatusOK, sUser) +} + +// scimPatchUser supports suspending and activating users only. +func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !api.scimVerifyAuthHeader(r) { + _ = handlerutil.WriteError(rw, spec.Error{Status: http.StatusUnauthorized, Type: "invalidAuthorization"}) + return + } + + id := chi.URLParam(r, "id") + + var sUser SCIMUser + err := json.NewDecoder(r.Body).Decode(&sUser) + if err != nil { + _ = handlerutil.WriteError(rw, err) + return + } + sUser.ID = id + + uid, err := uuid.Parse(id) + if err != nil { + _ = handlerutil.WriteError(rw, spec.Error{Status: http.StatusBadRequest, Type: "invalidId"}) + return + } + + dbUser, err := api.Database.GetUserByID(ctx, uid) + if err != nil { + _ = handlerutil.WriteError(rw, err) + return + } + + var status database.UserStatus + if sUser.Active { + status = database.UserStatusActive + } else { + status = database.UserStatusSuspended + } + + _, err = api.Database.UpdateUserStatus(r.Context(), database.UpdateUserStatusParams{ + ID: dbUser.ID, + Status: status, + UpdatedAt: database.Now(), + }) + if err != nil { + _ = handlerutil.WriteError(rw, err) + return + } + + httpapi.Write(rw, http.StatusOK, sUser) +} diff --git a/enterprise/coderd/scim_test.go b/enterprise/coderd/scim_test.go new file mode 100644 index 0000000000000..cf777112faead --- /dev/null +++ b/enterprise/coderd/scim_test.go @@ -0,0 +1,203 @@ +package coderd_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/cryptorand" + "github.com/coder/coder/enterprise/coderd" + "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/testutil" +) + +//nolint:revive +func makeScimUser(t testing.TB) coderd.SCIMUser { + rstr, err := cryptorand.String(10) + require.NoError(t, err) + + return coderd.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\"" + Type string "json:\"type\"" + Display string "json:\"display\"" + }{ + {Primary: true, Value: fmt.Sprintf("%s@coder.com", rstr)}, + }, + Active: true, + } +} + +func setScimAuth(key []byte) func(*http.Request) { + return func(r *http.Request) { + r.Header.Set("Authorization", string(key)) + } +} + +func TestScim(t *testing.T) { + t.Parallel() + + t.Run("postUser", func(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")}) + _ = coderdtest.CreateFirstUser(t, client) + coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + AccountID: "coolin", + SCIM: false, + }) + + res, err := client.Request(ctx, "POST", "/scim/v2/Users", struct{}{}) + require.NoError(t, err) + defer res.Body.Close() + assert.Equal(t, http.StatusNotFound, 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")}) + _ = coderdtest.CreateFirstUser(t, client) + coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + AccountID: "coolin", + SCIM: true, + }) + + res, err := client.Request(ctx, "POST", "/scim/v2/Users", struct{}{}) + require.NoError(t, err) + defer res.Body.Close() + assert.Equal(t, http.StatusInternalServerError, res.StatusCode) + }) + + t.Run("OK", 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}) + _ = coderdtest.CreateFirstUser(t, client) + coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + AccountID: "coolin", + SCIM: true, + }) + + 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) + + users, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value}) + require.NoError(t, err) + require.Len(t, users, 1) + + assert.Equal(t, sUser.Emails[0].Value, users[0].Email) + assert.Equal(t, sUser.UserName, users[0].Username) + }) + }) + + t.Run("patchUser", func(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")}) + _ = coderdtest.CreateFirstUser(t, client) + coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + AccountID: "coolin", + SCIM: false, + }) + + res, err := client.Request(ctx, "PATCH", "/scim/v2/Users/bob", struct{}{}) + require.NoError(t, err) + defer res.Body.Close() + assert.Equal(t, http.StatusNotFound, 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")}) + _ = coderdtest.CreateFirstUser(t, client) + coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + AccountID: "coolin", + SCIM: true, + }) + + res, err := client.Request(ctx, "PATCH", "/scim/v2/Users/bob", struct{}{}) + require.NoError(t, err) + defer res.Body.Close() + assert.Equal(t, http.StatusInternalServerError, res.StatusCode) + }) + + t.Run("OK", 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}) + _ = coderdtest.CreateFirstUser(t, client) + coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + AccountID: "coolin", + SCIM: true, + }) + + 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) + + err = json.NewDecoder(res.Body).Decode(&sUser) + require.NoError(t, err) + + sUser.Active = false + + res, err = client.Request(ctx, "PATCH", "/scim/v2/Users/"+sUser.ID, sUser, setScimAuth(scimAPIKey)) + require.NoError(t, err) + defer res.Body.Close() + assert.Equal(t, http.StatusOK, res.StatusCode) + + users, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value}) + require.NoError(t, err) + require.Len(t, users, 1) + assert.Equal(t, codersdk.UserStatusSuspended, users[0].Status) + }) + }) +} diff --git a/go.mod b/go.mod index 07d353cf3ed8d..36fd8cd5f750b 100644 --- a/go.mod +++ b/go.mod @@ -107,6 +107,7 @@ require ( github.com/hashicorp/terraform-config-inspect v0.0.0-20211115214459-90acf1ca460f github.com/hashicorp/terraform-json v0.14.0 github.com/hashicorp/yamux v0.0.0-20220718163420-dd80a7ee44ce + github.com/imulab/go-scim/pkg/v2 v2.2.0 github.com/jedib0t/go-pretty/v6 v6.3.5 github.com/justinas/nosurf v1.1.1 github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f @@ -260,6 +261,7 @@ require ( github.com/prometheus/procfs v0.8.0 // indirect github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/satori/go.uuid v1.2.0 // indirect github.com/sirupsen/logrus v1.9.0 // indirect github.com/spf13/cast v1.5.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect diff --git a/go.sum b/go.sum index 991bc738fb5f7..5824d84ac440d 100644 --- a/go.sum +++ b/go.sum @@ -1028,6 +1028,8 @@ github.com/imdario/mergo v0.3.10/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/imulab/go-scim/pkg/v2 v2.2.0 h1:PQ1jvNJKagyCwryVjwb3fvLEjztXtpxZh1LHT4BFrzI= +github.com/imulab/go-scim/pkg/v2 v2.2.0/go.mod h1:TvNTXjm2x/rJ3BBCQIKZVErA2AODyylGsLWR/spwL8A= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/insomniacslk/dhcp v0.0.0-20211209223715-7d93572ebe8e h1:IQpunlq7T+NiJJMO7ODYV2YWBiv/KnObR3gofX0mWOo= @@ -1597,6 +1599,7 @@ github.com/safchain/ethtool v0.0.0-20210803160452-9aa261dae9b1/go.mod h1:Z0q5wiB github.com/sagikazarmark/crypt v0.1.0/go.mod h1:B/mN0msZuINBtQ1zZLEQcegFJJf9vnYIR88KRMEuODE= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sanposhiho/wastedassign/v2 v2.0.6/go.mod h1:KyZ0MWTwxxBmfwn33zh3k1dmsbF2ud9pAAGfoLfjhtI= +github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= github.com/sclevine/spec v1.2.0/go.mod h1:W4J29eT/Kzv7/b9IWLB055Z+qvVC9vt0Arko24q7p+U= @@ -1678,6 +1681,7 @@ github.com/stretchr/objx v0.0.0-20180129172003-8a3f7159479f/go.mod h1:HFkY916IF+ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v0.0.0-20170130113145-4d4bfba8f1d1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v0.0.0-20180303142811-b89eecf5ca5d/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -1933,6 +1937,7 @@ golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200117160349-530e935923ad/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=