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:
Aurélien Sibiril
2026-07-11 16:50:32 +02:00
parent 6435a52f47
commit 37121e7bac
24 changed files with 1294 additions and 18 deletions

View File

@@ -72,6 +72,36 @@ func (r *Registry) ApplyOAuth2Defaults(p string, redirectURI string, c *connecto
return nil
}
// ApplyManagedAPIKey injects the Probo-held API key into a freshly loaded
// ManagedAPIKey connector's connection, so the credential is resolved at
// use time — surviving key rotation and never persisted on the connection
// row (only the extra settings, e.g. a Crisp Website ID, are stored). It is
// a no-op for every non-managed provider, so callers may invoke it
// unconditionally before building a connection's HTTP client. It errors
// when a managed provider's key is unconfigured (the connector was
// deactivated after the connection was created) or when the connection is
// not an API-key connection.
func (r *Registry) ApplyManagedAPIKey(dbConnector *coredata.Connector) error {
reg, ok := r.Get(dbConnector.Provider)
if !ok || !reg.ManagedAPIKey {
return nil
}
apiKeyConn, ok := dbConnector.Connection.(*connector.APIKeyConnection)
if !ok {
return fmt.Errorf("cannot apply managed api key for provider %q: connection is not an api-key connection", dbConnector.Provider)
}
key, ok := r.ManagedAPIKey(dbConnector.Provider)
if !ok {
return fmt.Errorf("cannot apply managed api key for provider %q: not configured", dbConnector.Provider)
}
apiKeyConn.APIKey = key
return nil
}
// ProbeURL returns the registered probe URL for provider p, or the
// empty string if no probe URL is configured.
func (r *Registry) ProbeURL(p string) string {

View File

@@ -26,11 +26,19 @@ import (
func crispRegistration() *Registration {
return &Registration{
Provider: coredata.ConnectorProviderCrisp,
DisplayName: "Crisp",
SupportsAPIKey: true,
// Crisp authenticates with a plugin token presented as HTTP Basic, the
// credential being the verbatim "identifier:key" pair.
Provider: coredata.ConnectorProviderCrisp,
DisplayName: "Crisp",
// Model B: the plugin token is Probo's own Crisp Marketplace plugin
// credential, held server-side in bootstrap config, not pasted by
// the customer. ManagedAPIKey injects it at connect time; the
// customer supplies only the Website ID. SupportsAPIKey stays false
// so the provider is hidden from the driver catalog until the
// operator configures PROBOD_CONNECTOR_CRISP_PLUGIN_TOKEN — it ships
// deactivated until Crisp validates the production plugin and
// activates with no code change once the token is set.
ManagedAPIKey: true,
// Crisp authenticates with the plugin token presented as HTTP Basic,
// the credential being the verbatim "identifier:key" pair.
// APIKeyBasicAuthUserPass base64-encodes it (the empty-password
// APIKeyBasicAuth cannot carry the key). A plugin token can serve
// several websites, so the reviewed website is captured via

View File

@@ -42,6 +42,18 @@ import (
type Registry struct {
mu sync.RWMutex
providers map[coredata.ConnectorProvider]*Registration
// managedAPIKeys holds the Probo-supplied API key for providers with
// ManagedAPIKey registrations (e.g. Crisp's marketplace plugin token).
// Populated by probod from bootstrap config via SetManagedAPIKey; empty
// until the operator configures the credential.
managedAPIKeys map[coredata.ConnectorProvider]string
// managedResourceIDs holds an optional Probo-supplied resource identifier
// for a ManagedAPIKey provider, distinct from the credential. Crisp needs
// it: the plugin token's Basic identifier is not the plugin ID, yet the
// per-website plugin API (used for ownership verification) requires the
// plugin ID in the path. Populated by probod via SetManagedResourceID;
// empty for providers that need no such identifier.
managedResourceIDs map[coredata.ConnectorProvider]string
}
// NewRegistry returns an empty *Registry. Production code uses
@@ -49,7 +61,9 @@ type Registry struct {
// empty Registry and register only the providers they need.
func NewRegistry() *Registry {
return &Registry{
providers: make(map[coredata.ConnectorProvider]*Registration),
providers: make(map[coredata.ConnectorProvider]*Registration),
managedAPIKeys: make(map[coredata.ConnectorProvider]string),
managedResourceIDs: make(map[coredata.ConnectorProvider]string),
}
}
@@ -98,6 +112,14 @@ func (r *Registry) Register(reg *Registration) error {
return fmt.Errorf("cannot register connector provider %q: APIKeyBasicAuth, APIKeyBasicAuthUserPass, APIKeyHeader, and APIKeyAuthScheme are mutually exclusive", reg.Provider)
}
// ManagedAPIKey injects a Probo-held key and ignores any customer
// credential, so pairing it with SupportsAPIKey/SupportsClientCredentials
// would advertise a credential field whose value is silently discarded —
// the same silent-winner class rejected above. Reject it at startup.
if reg.ManagedAPIKey && (reg.SupportsAPIKey || reg.SupportsClientCredentials) {
return fmt.Errorf("cannot register connector provider %q: ManagedAPIKey is mutually exclusive with SupportsAPIKey and SupportsClientCredentials", reg.Provider)
}
// BuildTokenURLForDomain and BuildTokenURLForSite both build the token
// endpoint host, but from different sources (a callback param vs. the
// signed state). CompleteWithState checks them in order, so setting both
@@ -224,6 +246,64 @@ func (r *Registry) APIKeyUsesBasicAuthUserPass(p coredata.ConnectorProvider) boo
return false
}
// SetManagedAPIKey records the Probo-supplied API key for a
// ManagedAPIKey provider (e.g. Crisp). probod calls this from bootstrap
// config so the create-connector resolver can inject the key and the
// driver catalog can surface the provider. An empty key is treated as
// "not configured": it is not stored, keeping the provider hidden.
func (r *Registry) SetManagedAPIKey(p coredata.ConnectorProvider, key string) {
if key == "" {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.managedAPIKeys[p] = key
}
// ManagedAPIKey returns the Probo-supplied API key configured for a
// ManagedAPIKey provider and whether one is set. The boolean is false
// (and the string empty) until the operator configures the credential
// via bootstrap, which is what keeps such a provider deactivated.
func (r *Registry) ManagedAPIKey(p coredata.ConnectorProvider) (string, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
key, ok := r.managedAPIKeys[p]
return key, ok
}
// SetManagedResourceID records an optional Probo-supplied resource
// identifier for a ManagedAPIKey provider (e.g. the Crisp plugin ID used
// by the per-website plugin API). probod calls this from bootstrap config
// alongside SetManagedAPIKey. An empty id is treated as "not configured":
// it is not stored.
func (r *Registry) SetManagedResourceID(p coredata.ConnectorProvider, id string) {
if id == "" {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.managedResourceIDs[p] = id
}
// ManagedResourceID returns the Probo-supplied resource identifier
// configured for a ManagedAPIKey provider and whether one is set. The
// boolean is false (and the string empty) until the operator configures it
// via bootstrap.
func (r *Registry) ManagedResourceID(p coredata.ConnectorProvider) (string, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
id, ok := r.managedResourceIDs[p]
return id, ok
}
// ProviderOAuth2Scopes returns the OAuth2 scopes the access review
// driver for the given provider needs to list user accounts. Returns
// nil for providers that do not need any scopes (Notion, Intercom)

View File

@@ -19,6 +19,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/connector"
"go.probo.inc/probo/pkg/connector/provider"
"go.probo.inc/probo/pkg/coredata"
)
@@ -210,3 +211,117 @@ func TestRegistry_ProbeURL(t *testing.T) {
assert.NotEmpty(t, r.ProbeURL("SLACK"))
assert.Empty(t, r.ProbeURL("UNKNOWN"))
}
// TestRegistry_ManagedAPIKey covers the deactivated default (no key
// configured), a configured key, and that an empty key is a no-op so
// the provider stays deactivated.
func TestRegistry_ManagedAPIKey(t *testing.T) {
t.Parallel()
r := provider.NewRegistry()
key, ok := r.ManagedAPIKey(coredata.ConnectorProviderCrisp)
assert.False(t, ok)
assert.Empty(t, key)
r.SetManagedAPIKey(coredata.ConnectorProviderCrisp, "")
_, ok = r.ManagedAPIKey(coredata.ConnectorProviderCrisp)
assert.False(t, ok, "empty key must not configure the provider")
r.SetManagedAPIKey(coredata.ConnectorProviderCrisp, "identifier:secret")
key, ok = r.ManagedAPIKey(coredata.ConnectorProviderCrisp)
assert.True(t, ok)
assert.Equal(t, "identifier:secret", key)
}
func TestRegistry_ManagedResourceID(t *testing.T) {
t.Parallel()
r := provider.NewRegistry()
id, ok := r.ManagedResourceID(coredata.ConnectorProviderCrisp)
assert.False(t, ok)
assert.Empty(t, id)
r.SetManagedResourceID(coredata.ConnectorProviderCrisp, "")
_, ok = r.ManagedResourceID(coredata.ConnectorProviderCrisp)
assert.False(t, ok, "empty resource id must not configure the provider")
r.SetManagedResourceID(coredata.ConnectorProviderCrisp, "plugin-id")
id, ok = r.ManagedResourceID(coredata.ConnectorProviderCrisp)
assert.True(t, ok)
assert.Equal(t, "plugin-id", id)
}
// TestCrispIsManagedAPIKey pins Crisp's Model B shape: it is a managed
// API-key provider that does not accept a customer-pasted key, so the
// driver catalog hides it until the operator configures the plugin
// token.
func TestCrispIsManagedAPIKey(t *testing.T) {
t.Parallel()
r := provider.NewBuiltinRegistry()
reg, ok := r.Get(coredata.ConnectorProviderCrisp)
require.True(t, ok)
assert.True(t, reg.ManagedAPIKey)
assert.False(t, reg.SupportsAPIKey)
assert.True(t, reg.APIKeyBasicAuthUserPass)
}
// TestRegistry_RejectsManagedPlusCustomerCredential pins that a
// ManagedAPIKey registration cannot also advertise a customer-supplied
// credential path, whose value would be silently discarded.
func TestRegistry_RejectsManagedPlusCustomerCredential(t *testing.T) {
t.Parallel()
r := provider.NewRegistry()
err := r.Register(&provider.Registration{
Provider: coredata.ConnectorProviderCrisp,
DisplayName: "Crisp",
ManagedAPIKey: true,
SupportsAPIKey: true,
})
require.Error(t, err)
assert.Contains(t, err.Error(), "mutually exclusive")
}
// TestRegistry_ApplyManagedAPIKey verifies the key is injected fresh into a
// managed provider's connection (so rotation propagates and the key is not
// persisted), while non-managed providers are left untouched.
func TestRegistry_ApplyManagedAPIKey(t *testing.T) {
t.Parallel()
r := provider.NewBuiltinRegistry()
r.SetManagedAPIKey(coredata.ConnectorProviderCrisp, "identifier:secret")
managed := &coredata.Connector{
Provider: coredata.ConnectorProviderCrisp,
Connection: &connector.APIKeyConnection{BasicAuthUserPass: true},
}
require.NoError(t, r.ApplyManagedAPIKey(managed))
assert.Equal(t, "identifier:secret", managed.Connection.(*connector.APIKeyConnection).APIKey)
// Non-managed provider: the connection is left untouched.
other := &coredata.Connector{
Provider: coredata.ConnectorProviderSlack,
Connection: &connector.APIKeyConnection{APIKey: "customer-key"},
}
require.NoError(t, r.ApplyManagedAPIKey(other))
assert.Equal(t, "customer-key", other.Connection.(*connector.APIKeyConnection).APIKey)
}
// TestRegistry_ApplyManagedAPIKey_Unconfigured verifies that a managed
// provider whose key was never configured (deactivated) errors rather than
// silently building a keyless client.
func TestRegistry_ApplyManagedAPIKey_Unconfigured(t *testing.T) {
t.Parallel()
r := provider.NewBuiltinRegistry()
managed := &coredata.Connector{
Provider: coredata.ConnectorProviderCrisp,
Connection: &connector.APIKeyConnection{BasicAuthUserPass: true},
}
err := r.ApplyManagedAPIKey(managed)
require.Error(t, err)
assert.Contains(t, err.Error(), "not configured")
}

View File

@@ -112,6 +112,17 @@ type Registration struct {
// exclusive with the other API-key auth modes. Consumed when the
// create-connector resolver builds the APIKeyConnection.
APIKeyBasicAuthUserPass bool
// ManagedAPIKey marks a provider whose API key is supplied by Probo
// from bootstrap config (a single, Probo-held credential shared across
// all connections) rather than pasted per-connection by the customer.
// The connection carries only the ExtraSettings (e.g. a Crisp Website
// ID); the create-connector resolver injects the managed key registered
// via (*Registry).SetManagedAPIKey. Such a provider stays hidden from
// the driver catalog until the operator configures the key, so it ships
// deactivated and activates with no code change. Orthogonal to the
// APIKey*/SupportsAPIKey auth-mode flags, which still select how the
// injected key is presented on the wire.
ManagedAPIKey bool
// BuildProbeURL derives a per-connector probe URL when the API host or
// path depends on connector settings (e.g. a customer subdomain or