Add Zendesk access-review connector

Zendesk is a multi-tenant OAuth connector keyed by the customer
subdomain. The customer enters it at connect time; it rides the signed
state to the callback, is re-validated, and is stored on the connector
settings to build the API host.

List staff (agents and admins) via GET /api/v2/users.json with cursor
pagination, mapping role, active/suspended, and 2FA status; end-users
are excluded. The subdomain is validated as a single DNS label at every
trust boundary to close the SSRF vector, and the data client keeps the
SSRF-protected transport.

Zendesk OAuth across customer subdomains requires a Zendesk-approved
global OAuth client; the connector goes live once those credentials are
supplied via bootstrap.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
Aurélien Sibiril
2026-06-04 18:32:55 +02:00
parent 1c40121591
commit dbd920dc24
19 changed files with 891 additions and 14 deletions

View File

@@ -131,4 +131,12 @@ var providerOrgConfigs = map[coredata.ConnectorProvider]providerOrgConfig{
return s.Domain
},
},
// Pattern 2-auto: the subdomain is collected at initiate and persisted
// from the signed OAuth state on the callback; no picker UI.
coredata.ConnectorProviderZendesk: {
SelectedSlug: func(c *coredata.Connector) string {
s, _ := coredata.ConnectorSettings[coredata.ZendeskConnectorSettings](c)
return s.Subdomain
},
},
}

View File

@@ -61,6 +61,7 @@ enum ConnectorProvider
CURSOR @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderCursor")
DATADOG @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderDatadog")
OKTA @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderOkta")
ZENDESK @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderZendesk")
}
type ConnectorProviderInfo {

View File

@@ -189,12 +189,13 @@ func handleConnectorComplete(
var cnnctr *coredata.Connector
// Datadog returns the customer's API domain as a `domain` query
// parameter on every OAuth callback; it drives the driver's API
// host, so capture and validate it on both the create and the
// reconnect path (a reconnect from a different site must refresh
// the stored domain).
var datadogRawSettings json.RawMessage
// Some providers persist per-customer settings on the connector,
// captured here for both the create and the reconnect path: Datadog
// echoes its API domain as a `domain` callback param; Zendesk's
// subdomain rode the signed OAuth state from initiate (it is not
// echoed back). Both become a URL host, so each is re-validated
// before use. At most one block applies per callback.
var rawSettings json.RawMessage
if connectorProvider == coredata.ConnectorProviderDatadog {
domain := query.Get("domain")
@@ -220,7 +221,33 @@ func handleConnectorComplete(
return
}
datadogRawSettings = raw
rawSettings = raw
}
if connectorProvider == coredata.ConnectorProviderZendesk {
// The subdomain is HMAC-signed in the state (untamperable) and was
// validated at initiate, but re-validate it here too — it becomes
// a URL host on every API call (defense-in-depth).
if !connector.IsValidZendeskSubdomain(state.Site) {
logger.WarnCtx(r.Context(), "rejecting invalid zendesk subdomain",
log.String("provider", string(connectorProvider)),
)
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("invalid subdomain"))
return
}
raw, err := json.Marshal(&coredata.ZendeskConnectorSettings{
Subdomain: state.Site,
})
if err != nil {
logger.ErrorCtx(r.Context(), "cannot marshal zendesk settings", log.Error(err))
httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("internal error"))
return
}
rawSettings = raw
}
// If a connector_id was passed in the state, this is a
@@ -240,7 +267,7 @@ func handleConnectorComplete(
OrganizationID: organizationID,
Provider: connectorProvider,
Connection: connection,
RawSettings: datadogRawSettings,
RawSettings: rawSettings,
},
)
if err != nil {
@@ -328,11 +355,11 @@ func handleConnectorComplete(
}
}
// Datadog's per-customer settings were captured and validated
// above (the same block also feeds the reconnect path); apply
// them to the create request.
if datadogRawSettings != nil {
createReq.RawSettings = datadogRawSettings
// Per-customer settings captured above (Datadog's callback domain
// or Zendesk's state subdomain) apply to the create request; at
// most one provider populates them per callback.
if rawSettings != nil {
createReq.RawSettings = rawSettings
}
cnnctr, err = svc.Connectors.Create(r.Context(), scope, createReq)