feat: add SCIM provisioning via Okta (#4132)

Co-authored-by: Ben Potter <ben@coder.com>
This commit is contained in:
Colin Adler
2022-09-20 15:16:26 -05:00
committed by GitHub
co-authored by Ben Potter
parent 50321ba2aa
commit 5e2efb68f1
16 changed files with 467 additions and 13 deletions
+19
View File
@@ -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 {
@@ -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)
+1
View File
@@ -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 {
+4
View File
@@ -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"])
})
}
+194
View File
@@ -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)
}
+203
View File
@@ -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)
})
})
}