Verify Crisp website ownership before connecting
Crisp is a managed (Model B) connector: Probo holds one plugin token server-side and each connection carries only a Website ID. Nothing stops one organization from entering another organization's Website ID, so prove control of the website before creating the connection. Probo derives a per-(organization, website) verification code as an HMAC over the token secret and exposes it through a new crispVerificationCode query. The customer pastes it into the Probo plugin's per-website settings; at connect time the resolver reads the setting back through the managed plugin token and requires a constant-time match before any row is written. The managed key and plugin ID come from bootstrap, so the connector stays hidden until the deployment configures them. The settings fetch is injected so the create-time gate's branch wiring is unit-tested (mismatch and not-subscribed reject, internal errors stay generic, a matching code passes), and the managed-versus-client key resolution is covered too. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
@@ -574,10 +574,16 @@ func (r *queryResolver) AccessReviewDrivers(ctx context.Context) ([]*types.Conne
|
||||
apiKeySupported := reg.SupportsAPIKey
|
||||
clientCredentialsSupported := reg.SupportsClientCredentials
|
||||
|
||||
// ManagedAPIKey (Model B, e.g. Crisp) providers are connectable only
|
||||
// once the operator configures the Probo-held key; until then they
|
||||
// stay hidden, so such a provider ships deactivated.
|
||||
_, hasManaged := r.providerRegistry.ManagedAPIKey(provider)
|
||||
apiKeyManaged := reg.ManagedAPIKey && hasManaged
|
||||
|
||||
// Skip providers that cannot be connected in this deployment: no
|
||||
// OAuth client credentials configured and no key-based fallback
|
||||
// (API key or client credentials) supported.
|
||||
if !oauthConfigured && !apiKeySupported && !clientCredentialsSupported {
|
||||
// (API key, managed API key, or client credentials) supported.
|
||||
if !oauthConfigured && !apiKeySupported && !clientCredentialsSupported && !apiKeyManaged {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -603,6 +609,7 @@ func (r *queryResolver) AccessReviewDrivers(ctx context.Context) ([]*types.Conne
|
||||
DisplayName: reg.DisplayName,
|
||||
OauthConfigured: oauthConfigured,
|
||||
APIKeySupported: apiKeySupported,
|
||||
APIKeyManaged: apiKeyManaged,
|
||||
ClientCredentialsSupported: clientCredentialsSupported,
|
||||
Oauth2Scopes: scopes,
|
||||
ExtraSettings: extraSettings,
|
||||
@@ -619,6 +626,25 @@ func (r *queryResolver) AccessReviewDrivers(ctx context.Context) ([]*types.Conne
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
// CrispVerificationCode is the resolver for the crispVerificationCode field. It
|
||||
// returns the deterministic ownership-verification code the customer must paste
|
||||
// into the Probo plugin's per-website settings in their Crisp dashboard before
|
||||
// connecting that website. Authorized against the organization with the same
|
||||
// action as the create mutation because the code is organization-bound. This is
|
||||
// a UI-only helper; MCP/CLI/n8n are intentionally not extended.
|
||||
func (r *queryResolver) CrispVerificationCode(ctx context.Context, organizationID gid.GID, websiteID string) (string, error) {
|
||||
if _, err := r.authorize(ctx, organizationID, probo.ActionConnectorCreate); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
websiteID = strings.TrimSpace(websiteID)
|
||||
if websiteID == "" {
|
||||
return "", gqlutils.Invalidf(ctx, "websiteId is required")
|
||||
}
|
||||
|
||||
return computeCrispVerificationCode(r.tokenSecret, organizationID.String(), websiteID), nil
|
||||
}
|
||||
|
||||
// Mutation returns schema.MutationResolver implementation.
|
||||
func (r *Resolver) Mutation() schema.MutationResolver { return &mutationResolver{r} }
|
||||
|
||||
|
||||
@@ -36,17 +36,16 @@ func (r *mutationResolver) CreateAPIKeyConnector(ctx context.Context, input type
|
||||
return nil, err
|
||||
}
|
||||
|
||||
apiKey, err := r.resolveAPIKeyConnectorCredential(input.Provider, input.APIKey)
|
||||
if err != nil {
|
||||
return nil, gqlutils.Invalid(ctx, err)
|
||||
}
|
||||
|
||||
req := probo.CreateConnectorRequest{
|
||||
OrganizationID: input.OrganizationID,
|
||||
Provider: input.Provider,
|
||||
Protocol: coredata.ConnectorProtocolAPIKey,
|
||||
Connection: &connector.APIKeyConnection{
|
||||
APIKey: input.APIKey,
|
||||
Header: r.providerRegistry.APIKeyHeader(input.Provider),
|
||||
BasicAuth: r.providerRegistry.APIKeyUsesBasicAuth(input.Provider),
|
||||
BasicAuthUserPass: r.providerRegistry.APIKeyUsesBasicAuthUserPass(input.Provider),
|
||||
Scheme: r.providerRegistry.APIKeyAuthScheme(input.Provider),
|
||||
},
|
||||
Connection: r.newAPIKeyConnection(input.Provider, apiKey),
|
||||
}
|
||||
|
||||
raw, err := apiKeyConnectorSettings(input)
|
||||
@@ -56,6 +55,16 @@ func (r *mutationResolver) CreateAPIKeyConnector(ctx context.Context, input type
|
||||
|
||||
req.RawSettings = raw
|
||||
|
||||
// Crisp (ManagedAPIKey) requires proof the organization controls the Crisp
|
||||
// website before the connection is created; every other API-key provider is
|
||||
// unaffected. Runs after settings validation and before any write, so a
|
||||
// failed check leaves no row.
|
||||
if input.Provider == coredata.ConnectorProviderCrisp {
|
||||
if err := r.verifyCrispOwnership(ctx, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
cnnctr, err := r.probo.Connectors.Create(ctx, scope, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
|
||||
|
||||
@@ -15,19 +15,144 @@
|
||||
package console_v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/accessreview/drivers"
|
||||
"go.probo.inc/probo/pkg/connector"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/server/api/console/v1/types"
|
||||
"go.probo.inc/probo/pkg/server/gqlutils"
|
||||
)
|
||||
|
||||
// These helpers live outside connector_resolvers.go because that file is
|
||||
// regenerated by gqlgen, which does not preserve standalone functions.
|
||||
|
||||
// resolveAPIKeyConnectorCredential returns the API key to persist on a new
|
||||
// API-key connection. For ManagedAPIKey providers (Model B, e.g. Crisp) it
|
||||
// persists NOTHING (empty string): the Probo-held key is injected fresh at
|
||||
// use time by (*provider.Registry).ApplyManagedAPIKey, so it survives key
|
||||
// rotation and is not duplicated across tenant rows. It still requires the
|
||||
// key to be configured, which is what keeps the provider deactivated, and
|
||||
// ignores any client-supplied value. For all other providers it requires
|
||||
// the client-supplied key. The returned error is surfaced verbatim to the
|
||||
// client via gqlutils.Invalid, so it contains only provider/field names,
|
||||
// never the key itself.
|
||||
func (r *Resolver) resolveAPIKeyConnectorCredential(provider coredata.ConnectorProvider, clientKey *string) (string, error) {
|
||||
if reg, ok := r.providerRegistry.Get(provider); ok && reg.ManagedAPIKey {
|
||||
if _, ok := r.providerRegistry.ManagedAPIKey(provider); !ok {
|
||||
return "", fmt.Errorf("connector is not configured for this deployment")
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if clientKey == nil || *clientKey == "" {
|
||||
return "", fmt.Errorf("apiKey is required")
|
||||
}
|
||||
|
||||
return *clientKey, nil
|
||||
}
|
||||
|
||||
// newAPIKeyConnection builds an API-key connection for provider, filling the auth
|
||||
// presentation (header, basic-auth mode, scheme) from the provider registry and
|
||||
// using key as the credential. Both CreateAPIKeyConnector (the persisted
|
||||
// connection) and verifyCrispOwnership (the ownership-check client) construct
|
||||
// their connection through it, so the verification client authenticates exactly
|
||||
// as the persisted connector will: a new auth flag cannot be added to one path
|
||||
// and silently missed on the other.
|
||||
func (r *Resolver) newAPIKeyConnection(provider coredata.ConnectorProvider, key string) *connector.APIKeyConnection {
|
||||
return &connector.APIKeyConnection{
|
||||
APIKey: key,
|
||||
Header: r.providerRegistry.APIKeyHeader(provider),
|
||||
BasicAuth: r.providerRegistry.APIKeyUsesBasicAuth(provider),
|
||||
BasicAuthUserPass: r.providerRegistry.APIKeyUsesBasicAuthUserPass(provider),
|
||||
Scheme: r.providerRegistry.APIKeyAuthScheme(provider),
|
||||
}
|
||||
}
|
||||
|
||||
// crispSettingsFetcher reads a Crisp plugin's per-website subscription settings.
|
||||
// It matches drivers.GetCrispSubscriptionSettings: verifyCrispOwnership injects
|
||||
// the real fetch, and tests substitute a fake so the branch wiring (the security
|
||||
// polarity and the Invalid-versus-Internal error mapping) is exercised without a
|
||||
// live Crisp API.
|
||||
type crispSettingsFetcher func(ctx context.Context, httpClient *http.Client, websiteID, pluginID string) (*drivers.CrispSubscriptionSettings, error)
|
||||
|
||||
// verifyCrispOwnership proves the connecting organization controls the Crisp
|
||||
// website before a connection is created (the #1b ownership check). It reads the
|
||||
// Probo plugin's per-website settings through the managed plugin token and
|
||||
// requires probo_verification_code to equal the code Probo showed for this exact
|
||||
// (organization, website) pair. Because the code is bound to both, one
|
||||
// organization cannot bind another organization's website, and only someone with
|
||||
// dashboard access to the website could have written the setting. It runs only
|
||||
// for Crisp/managed providers; nothing is persisted before it returns, so a
|
||||
// failed check creates no row. Returned Invalid errors are surfaced to the
|
||||
// client and contain only guidance, never the code or the token.
|
||||
func (r *Resolver) verifyCrispOwnership(ctx context.Context, input types.CreateAPIKeyConnectorInput) error {
|
||||
return r.verifyCrispOwnershipWith(ctx, input, drivers.GetCrispSubscriptionSettings)
|
||||
}
|
||||
|
||||
// verifyCrispOwnershipWith is verifyCrispOwnership with the settings fetch
|
||||
// injected, so its branch wiring can be unit-tested without reaching the live
|
||||
// Crisp API. verifyCrispOwnership passes the real
|
||||
// drivers.GetCrispSubscriptionSettings.
|
||||
func (r *Resolver) verifyCrispOwnershipWith(ctx context.Context, input types.CreateAPIKeyConnectorInput, fetch crispSettingsFetcher) error {
|
||||
if input.CrispWebsiteID == nil || strings.TrimSpace(*input.CrispWebsiteID) == "" {
|
||||
return gqlutils.Invalidf(ctx, "crispWebsiteId is required")
|
||||
}
|
||||
|
||||
websiteID := strings.TrimSpace(*input.CrispWebsiteID)
|
||||
|
||||
// The managed plugin token gates the connector's visibility, so it is set
|
||||
// here; treat its absence as an internal error rather than client input.
|
||||
managedKey, ok := r.providerRegistry.ManagedAPIKey(input.Provider)
|
||||
if !ok {
|
||||
return gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
// The plugin ID is a separate managed value the per-website plugin API
|
||||
// needs; the bootstrap requires it alongside the token, so its absence is a
|
||||
// deployment misconfiguration.
|
||||
pluginID, ok := r.providerRegistry.ManagedResourceID(input.Provider)
|
||||
if !ok {
|
||||
r.logger.ErrorCtx(ctx, "crisp plugin id not configured")
|
||||
|
||||
return gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
conn := r.newAPIKeyConnection(input.Provider, managedKey)
|
||||
|
||||
httpClient, err := conn.Client(ctx)
|
||||
if err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot build crisp verification client", log.Error(err))
|
||||
|
||||
return gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
settings, err := fetch(ctx, httpClient, websiteID, pluginID)
|
||||
|
||||
switch {
|
||||
case errors.Is(err, drivers.ErrCrispPluginNotSubscribed):
|
||||
return gqlutils.Invalidf(ctx, "install and configure the Probo plugin on this Crisp website, then retry")
|
||||
case err != nil:
|
||||
r.logger.ErrorCtx(ctx, "cannot read crisp subscription settings", log.Error(err))
|
||||
|
||||
return gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
if !verifyCrispVerificationCode(r.tokenSecret, input.OrganizationID.String(), websiteID, settings.ProboVerificationCode) {
|
||||
return gqlutils.Invalidf(ctx, "verification code mismatch: paste the code shown in Probo into the plugin settings, then retry")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// apiKeyConnectorSettings marshals the provider-specific extra settings
|
||||
// for an API-key connector from the typed gqlgen input into the JSON
|
||||
// blob persisted on coredata.Connector.RawSettings. It returns (nil,
|
||||
@@ -196,11 +321,20 @@ func apiKeyConnectorSettings(input types.CreateAPIKeyConnectorInput) (json.RawMe
|
||||
|
||||
return json.Marshal(&coredata.ScalewayConnectorSettings{OrganizationID: *input.ScalewayOrganizationID})
|
||||
case coredata.ConnectorProviderCrisp:
|
||||
if input.CrispWebsiteID == nil || *input.CrispWebsiteID == "" {
|
||||
websiteID := ""
|
||||
if input.CrispWebsiteID != nil {
|
||||
websiteID = strings.TrimSpace(*input.CrispWebsiteID)
|
||||
}
|
||||
|
||||
if websiteID == "" {
|
||||
return nil, fmt.Errorf("cannot create crisp connector: crispWebsiteId is required")
|
||||
}
|
||||
|
||||
return json.Marshal(&coredata.CrispConnectorSettings{WebsiteID: *input.CrispWebsiteID})
|
||||
// Persist the same trimmed value that verifyCrispOwnership proved and the
|
||||
// crispVerificationCode query minted the code against, so the stored,
|
||||
// verified, and displayed website are identical (a padded value would
|
||||
// verify then break the driver's URL).
|
||||
return json.Marshal(&coredata.CrispConnectorSettings{WebsiteID: websiteID})
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
|
||||
55
pkg/server/api/console/v1/crisp.go
Normal file
55
pkg/server/api/console/v1/crisp.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package console_v1
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// crispVerificationCodeLength bounds the human-typeable verification code. 12
|
||||
// base32 characters carry ~60 bits, far beyond guessing given the code is only
|
||||
// a proof-of-control challenge (not a secret) and is compared server side.
|
||||
const crispVerificationCodeLength = 12
|
||||
|
||||
// computeCrispVerificationCode derives the deterministic ownership-verification
|
||||
// code Probo shows for a (organization, Crisp website) pair. It is
|
||||
// HMAC(tokenSecret, domain || orgID || websiteID) so only Probo can mint it and
|
||||
// the value is bound to BOTH the organization and the website: a code minted for
|
||||
// one organization cannot verify a website under another, and a code for one
|
||||
// website cannot verify another. Nothing is stored: the same inputs always yield
|
||||
// the same code, so the create-time check re-derives and compares it.
|
||||
func computeCrispVerificationCode(secret, orgID, websiteID string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte("probo/connector/crisp-verification:" + orgID + ":" + websiteID))
|
||||
|
||||
code := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(mac.Sum(nil))
|
||||
|
||||
return code[:crispVerificationCodeLength]
|
||||
}
|
||||
|
||||
// verifyCrispVerificationCode reports whether provided matches the code Probo
|
||||
// would show for (orgID, websiteID). Operators paste the code back through the
|
||||
// Crisp dashboard, so the comparison tolerates surrounding whitespace and a
|
||||
// lowercased value; it is otherwise a constant-time compare.
|
||||
func verifyCrispVerificationCode(secret, orgID, websiteID, provided string) bool {
|
||||
expected := computeCrispVerificationCode(secret, orgID, websiteID)
|
||||
got := strings.ToUpper(strings.TrimSpace(provided))
|
||||
|
||||
return subtle.ConstantTimeCompare([]byte(expected), []byte(got)) == 1
|
||||
}
|
||||
347
pkg/server/api/console/v1/crisp_test.go
Normal file
347
pkg/server/api/console/v1/crisp_test.go
Normal file
@@ -0,0 +1,347 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package console_v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/vektah/gqlparser/v2/gqlerror"
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/accessreview/drivers"
|
||||
"go.probo.inc/probo/pkg/connector/provider"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/server/api/console/v1/types"
|
||||
)
|
||||
|
||||
func TestComputeCrispVerificationCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
secret = "test-token-secret"
|
||||
org = "gid://organization/1"
|
||||
website = "e8592878-c0d0-4632-b2f7-7d882f288d43"
|
||||
)
|
||||
|
||||
t.Run("is deterministic for the same inputs", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := computeCrispVerificationCode(secret, org, website)
|
||||
b := computeCrispVerificationCode(secret, org, website)
|
||||
assert.Equal(t, a, b)
|
||||
})
|
||||
|
||||
t.Run("is bound to the organization", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := computeCrispVerificationCode(secret, "gid://organization/1", website)
|
||||
b := computeCrispVerificationCode(secret, "gid://organization/2", website)
|
||||
assert.NotEqual(t, a, b)
|
||||
})
|
||||
|
||||
t.Run("is bound to the website", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := computeCrispVerificationCode(secret, org, "website-a")
|
||||
b := computeCrispVerificationCode(secret, org, "website-b")
|
||||
assert.NotEqual(t, a, b)
|
||||
})
|
||||
|
||||
t.Run("depends on the secret", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := computeCrispVerificationCode("secret-a", org, website)
|
||||
b := computeCrispVerificationCode("secret-b", org, website)
|
||||
assert.NotEqual(t, a, b)
|
||||
})
|
||||
|
||||
t.Run("is 12 human-typeable base32 characters", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code := computeCrispVerificationCode(secret, org, website)
|
||||
assert.Len(t, code, crispVerificationCodeLength)
|
||||
assert.Regexp(t, regexp.MustCompile(`^[A-Z2-7]{12}$`), code)
|
||||
})
|
||||
|
||||
// Delimiting org and website prevents a boundary collision where a longer
|
||||
// org and shorter website (or vice versa) concatenate to the same bytes.
|
||||
t.Run("delimiter prevents org/website boundary collisions", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := computeCrispVerificationCode(secret, "ab", "c")
|
||||
b := computeCrispVerificationCode(secret, "a", "bc")
|
||||
assert.NotEqual(t, a, b)
|
||||
})
|
||||
}
|
||||
|
||||
func TestVerifyCrispVerificationCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
secret = "test-token-secret"
|
||||
org = "gid://organization/1"
|
||||
website = "e8592878-c0d0-4632-b2f7-7d882f288d43"
|
||||
)
|
||||
|
||||
valid := computeCrispVerificationCode(secret, org, website)
|
||||
require.NotEmpty(t, valid)
|
||||
|
||||
t.Run("accepts the exact code", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.True(t, verifyCrispVerificationCode(secret, org, website, valid))
|
||||
})
|
||||
|
||||
t.Run("tolerates surrounding whitespace and lowercase", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.True(t, verifyCrispVerificationCode(secret, org, website, " "+valid+" "))
|
||||
assert.True(t, verifyCrispVerificationCode(secret, org, website, strings.ToLower(valid)))
|
||||
})
|
||||
|
||||
t.Run("rejects a mismatched or empty code", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.False(t, verifyCrispVerificationCode(secret, org, website, ""))
|
||||
assert.False(t, verifyCrispVerificationCode(secret, org, website, "AAAAAAAAAAAA"))
|
||||
})
|
||||
|
||||
t.Run("rejects a code minted for another organization", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
other := computeCrispVerificationCode(secret, "gid://organization/2", website)
|
||||
assert.False(t, verifyCrispVerificationCode(secret, org, website, other))
|
||||
})
|
||||
|
||||
t.Run("rejects a code minted for another website", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
other := computeCrispVerificationCode(secret, org, "another-website")
|
||||
assert.False(t, verifyCrispVerificationCode(secret, org, website, other))
|
||||
})
|
||||
}
|
||||
|
||||
// The stored Crisp Website ID must be the SAME trimmed value that
|
||||
// verifyCrispOwnership and the CrispVerificationCode query derive the code
|
||||
// against, or a padded ID would verify then break the driver's request URL.
|
||||
func TestApiKeyConnectorSettings_CrispTrimsWebsiteID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
website := " e8592878-c0d0-4632-b2f7-7d882f288d43 "
|
||||
|
||||
raw, err := apiKeyConnectorSettings(types.CreateAPIKeyConnectorInput{
|
||||
Provider: coredata.ConnectorProviderCrisp,
|
||||
CrispWebsiteID: &website,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var settings coredata.CrispConnectorSettings
|
||||
require.NoError(t, json.Unmarshal(raw, &settings))
|
||||
assert.Equal(t, "e8592878-c0d0-4632-b2f7-7d882f288d43", settings.WebsiteID)
|
||||
}
|
||||
|
||||
func TestApiKeyConnectorSettings_CrispRejectsWhitespaceOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
website := " "
|
||||
|
||||
_, err := apiKeyConnectorSettings(types.CreateAPIKeyConnectorInput{
|
||||
Provider: coredata.ConnectorProviderCrisp,
|
||||
CrispWebsiteID: &website,
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// resolveAPIKeyConnectorCredential is the sole gate keeping normal API-key
|
||||
// providers requiring a customer key while ManagedAPIKey providers (Model B,
|
||||
// e.g. Crisp) persist none. A regression either drops the required-key check for
|
||||
// every provider or persists the managed key on the row.
|
||||
func TestResolveAPIKeyConnectorCredential(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// NewBuiltinRegistry registers Crisp as a ManagedAPIKey provider; the
|
||||
// managed key must then be set for it to count as configured.
|
||||
configuredReg := provider.NewBuiltinRegistry()
|
||||
configuredReg.SetManagedAPIKey(coredata.ConnectorProviderCrisp, "identifier:secret")
|
||||
configured := &Resolver{providerRegistry: configuredReg}
|
||||
|
||||
unconfigured := &Resolver{providerRegistry: provider.NewBuiltinRegistry()}
|
||||
|
||||
clientKey := "customer-key"
|
||||
empty := ""
|
||||
|
||||
t.Run("managed and configured persists no key", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key, err := configured.resolveAPIKeyConnectorCredential(coredata.ConnectorProviderCrisp, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", key)
|
||||
})
|
||||
|
||||
t.Run("managed and configured ignores a client-supplied key", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key, err := configured.resolveAPIKeyConnectorCredential(coredata.ConnectorProviderCrisp, &clientKey)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", key)
|
||||
})
|
||||
|
||||
t.Run("managed but unconfigured is rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := unconfigured.resolveAPIKeyConnectorCredential(coredata.ConnectorProviderCrisp, nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("non-managed requires a key", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, errNil := configured.resolveAPIKeyConnectorCredential(coredata.ConnectorProviderTally, nil)
|
||||
require.EqualError(t, errNil, "apiKey is required")
|
||||
|
||||
_, errEmpty := configured.resolveAPIKeyConnectorCredential(coredata.ConnectorProviderTally, &empty)
|
||||
require.EqualError(t, errEmpty, "apiKey is required")
|
||||
})
|
||||
|
||||
t.Run("non-managed returns the client key", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key, err := configured.resolveAPIKeyConnectorCredential(coredata.ConnectorProviderTally, &clientKey)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "customer-key", key)
|
||||
})
|
||||
}
|
||||
|
||||
// verifyCrispOwnershipWith is the #1b create-time ownership gate. This pins its
|
||||
// branch wiring: the security polarity (only a matching code passes) and the
|
||||
// Invalid-versus-Internal error mapping. The settings fetch is faked so no live
|
||||
// Crisp API is reached.
|
||||
func TestVerifyCrispOwnershipWith(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
secret = "test-token-secret"
|
||||
pluginID = "plugin-id"
|
||||
)
|
||||
|
||||
orgID := gid.New(gid.NewTenantID(), coredata.OrganizationEntityType)
|
||||
websiteID := "e8592878-c0d0-4632-b2f7-7d882f288d43"
|
||||
validCode := computeCrispVerificationCode(secret, orgID.String(), websiteID)
|
||||
|
||||
// newResolver builds a Resolver whose registry has Crisp configured as a
|
||||
// managed provider; the plugin ID is set only when withPluginID is true.
|
||||
newResolver := func(withPluginID bool) *Resolver {
|
||||
reg := provider.NewBuiltinRegistry()
|
||||
reg.SetManagedAPIKey(coredata.ConnectorProviderCrisp, "identifier:secret")
|
||||
|
||||
if withPluginID {
|
||||
reg.SetManagedResourceID(coredata.ConnectorProviderCrisp, pluginID)
|
||||
}
|
||||
|
||||
return &Resolver{
|
||||
providerRegistry: reg,
|
||||
tokenSecret: secret,
|
||||
logger: log.NewLogger(log.WithOutput(io.Discard)),
|
||||
}
|
||||
}
|
||||
|
||||
newInput := func(website *string) types.CreateAPIKeyConnectorInput {
|
||||
return types.CreateAPIKeyConnectorInput{
|
||||
OrganizationID: orgID,
|
||||
Provider: coredata.ConnectorProviderCrisp,
|
||||
CrispWebsiteID: website,
|
||||
}
|
||||
}
|
||||
|
||||
fetchCode := func(code string) crispSettingsFetcher {
|
||||
return func(context.Context, *http.Client, string, string) (*drivers.CrispSubscriptionSettings, error) {
|
||||
return &drivers.CrispSubscriptionSettings{ProboVerificationCode: code}, nil
|
||||
}
|
||||
}
|
||||
|
||||
assertCode := func(t *testing.T, err error, code string) {
|
||||
t.Helper()
|
||||
|
||||
require.Error(t, err)
|
||||
|
||||
gqlErr, ok := err.(*gqlerror.Error)
|
||||
require.True(t, ok, "expected *gqlerror.Error, got %T", err)
|
||||
assert.Equal(t, code, gqlErr.Extensions["code"])
|
||||
}
|
||||
|
||||
t.Run("missing website id is invalid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := newResolver(true).verifyCrispOwnershipWith(context.Background(), newInput(nil), fetchCode(validCode))
|
||||
assertCode(t, err, "INVALID")
|
||||
})
|
||||
|
||||
t.Run("whitespace website id is invalid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
blank := " "
|
||||
err := newResolver(true).verifyCrispOwnershipWith(context.Background(), newInput(&blank), fetchCode(validCode))
|
||||
assertCode(t, err, "INVALID")
|
||||
})
|
||||
|
||||
t.Run("missing plugin id is internal", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := newResolver(false).verifyCrispOwnershipWith(context.Background(), newInput(&websiteID), fetchCode(validCode))
|
||||
assertCode(t, err, "INTERNAL")
|
||||
})
|
||||
|
||||
t.Run("plugin not subscribed is invalid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fetch := func(context.Context, *http.Client, string, string) (*drivers.CrispSubscriptionSettings, error) {
|
||||
return nil, drivers.ErrCrispPluginNotSubscribed
|
||||
}
|
||||
err := newResolver(true).verifyCrispOwnershipWith(context.Background(), newInput(&websiteID), fetch)
|
||||
assertCode(t, err, "INVALID")
|
||||
})
|
||||
|
||||
t.Run("other fetch error is internal and leaks nothing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fetch := func(context.Context, *http.Client, string, string) (*drivers.CrispSubscriptionSettings, error) {
|
||||
return nil, errors.New("boom: plugin token 12345 rejected")
|
||||
}
|
||||
err := newResolver(true).verifyCrispOwnershipWith(context.Background(), newInput(&websiteID), fetch)
|
||||
assertCode(t, err, "INTERNAL")
|
||||
assert.NotContains(t, err.Error(), "boom")
|
||||
assert.NotContains(t, err.Error(), "12345")
|
||||
})
|
||||
|
||||
t.Run("mismatched code is invalid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := newResolver(true).verifyCrispOwnershipWith(context.Background(), newInput(&websiteID), fetchCode("WRONGCODE000"))
|
||||
assertCode(t, err, "INVALID")
|
||||
})
|
||||
|
||||
t.Run("matching code passes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := newResolver(true).verifyCrispOwnershipWith(context.Background(), newInput(&websiteID), fetchCode(validCode))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
@@ -27,6 +27,8 @@ type Query {
|
||||
viewer: Viewer!
|
||||
commonThirdParties(name: String!): [CommonThirdParty!]!
|
||||
accessReviewDrivers: [ConnectorProviderInfo!]! @goField(forceResolver: true)
|
||||
crispVerificationCode(organizationId: ID!, websiteId: String!): String!
|
||||
@goField(forceResolver: true)
|
||||
}
|
||||
|
||||
type Mutation
|
||||
|
||||
@@ -106,6 +106,14 @@ type ConnectorProviderInfo {
|
||||
displayName: String!
|
||||
oauthConfigured: Boolean!
|
||||
apiKeySupported: Boolean!
|
||||
"""
|
||||
apiKeyManaged is true when the provider connects with a Probo-supplied
|
||||
API key (Model B, e.g. Crisp's marketplace plugin token) that the
|
||||
operator has configured. The customer supplies only the extra settings
|
||||
(e.g. a Website ID), not the key. False until the key is configured,
|
||||
which keeps such a provider out of the catalog.
|
||||
"""
|
||||
apiKeyManaged: Boolean!
|
||||
clientCredentialsSupported: Boolean!
|
||||
oauth2Scopes: [String!]!
|
||||
extraSettings: [ConnectorProviderSettingInfo!]!
|
||||
@@ -164,7 +172,13 @@ extend type Mutation {
|
||||
input CreateAPIKeyConnectorInput {
|
||||
organizationId: ID!
|
||||
provider: ConnectorProvider!
|
||||
apiKey: String!
|
||||
"""
|
||||
apiKey is the customer-supplied credential. It is null for
|
||||
ManagedAPIKey (Model B) providers such as Crisp, where the server
|
||||
injects Probo's own key and the customer provides only the extra
|
||||
settings.
|
||||
"""
|
||||
apiKey: String
|
||||
tallyOrganizationId: String
|
||||
sentryOrganizationSlug: String
|
||||
supabaseOrganizationSlug: String
|
||||
|
||||
@@ -50,6 +50,7 @@ func NewGraphQLHandler(
|
||||
connectorRegistry *connector.ConnectorRegistry,
|
||||
providerRegistry *provider.Registry,
|
||||
customDomainCname string,
|
||||
tokenSecret string,
|
||||
logger *log.Logger,
|
||||
thirdPartySvc *thirdparty.Service,
|
||||
riskManagementSvc *riskmanagement.Service,
|
||||
@@ -74,6 +75,7 @@ func NewGraphQLHandler(
|
||||
riskManagement: riskManagementSvc,
|
||||
thirdParty: thirdPartySvc,
|
||||
customDomainCname: customDomainCname,
|
||||
tokenSecret: tokenSecret,
|
||||
fileManager: fileManagerSvc,
|
||||
baseURL: baseURL,
|
||||
logger: logger,
|
||||
|
||||
@@ -71,6 +71,7 @@ type (
|
||||
fileManager *filemanager.Service
|
||||
baseURL *baseurl.BaseURL
|
||||
customDomainCname string
|
||||
tokenSecret string
|
||||
}
|
||||
)
|
||||
|
||||
@@ -111,6 +112,7 @@ func NewMux(
|
||||
connectorRegistry,
|
||||
providerRegistry,
|
||||
customDomainCname,
|
||||
tokenSecret,
|
||||
logger,
|
||||
thirdPartySvc,
|
||||
riskManagementSvc,
|
||||
|
||||
Reference in New Issue
Block a user