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

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