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
parent 50321ba2aa
commit 5e2efb68f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 467 additions and 13 deletions

View File

@ -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"

View File

@ -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.

View File

@ -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{}

View File

@ -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,

View File

@ -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)

View File

@ -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"`

View File

@ -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"
```

View File

@ -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
}

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 {

View File

@ -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)

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 {

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
enterprise/coderd/scim.go Normal file
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)
}

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)
})
})
}

2
go.mod
View File

@ -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

5
go.sum
View File

@ -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=