mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add SCIM provisioning via Okta (#4132)
Co-authored-by: Ben Potter <ben@coder.com>
This commit is contained in:
co-authored by
Ben Potter
parent
50321ba2aa
commit
5e2efb68f1
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user