Three defects, all found reviewing the rebased branch. The connection probe hit /v1alpha/accounts, which any analytics.readonly grant can call, while the driver's first request is the account's accessBindings — that additionally needs Administrator on the account and the manage.users.readonly scope. An Editor connecting, or a user declining the second scope on Google's granular consent screen, probed green and then 403'd on every campaign fetch, leaving the source permanently "Connected" with no rows. The probe now targets the same accessBindings collection the driver reads. A single unreadable property aborted the whole account. A property the token cannot see, or one deleted between the list and the read, threw away every binding already collected; 49 of 50 readable properties are still worth reviewing, so 403 and 404 now skip that property. Anything else still fails the fetch. Fan-out errors named no resource: the account call, the property list and each per-property call all returned the same "unexpected status" string, so a 403 on one subproperty out of forty was unattributable. Errors now carry the account or property ID. The cassette gains a subproperty parented to another property (only reachable through the ancestor filter, so it pins the hierarchy walk that the filter change claimed) and a property returning 403. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
545 lines
16 KiB
Go
545 lines
16 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// SOFTWARE.
|
|
|
|
package drivers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Organization represents a tenant/workspace/team/group surfaced by a
|
|
// provider's "list orgs the authenticated user can access" endpoint.
|
|
// The OAuth picker UI consumes this to let the user choose which one
|
|
// scopes the access source.
|
|
type Organization struct {
|
|
Slug string
|
|
DisplayName string
|
|
}
|
|
|
|
// ListGitHubOrganizations fetches the organizations the authenticated
|
|
// GitHub user belongs to.
|
|
func ListGitHubOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user/orgs?per_page=100", nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot create github organizations request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot fetch github organizations: %w", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("cannot fetch github organizations: unexpected status %d", resp.StatusCode)
|
|
}
|
|
|
|
var orgs []struct {
|
|
Login string `json:"login"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&orgs); err != nil {
|
|
return nil, fmt.Errorf("cannot decode github organizations response: %w", err)
|
|
}
|
|
|
|
result := make([]Organization, len(orgs))
|
|
for i, org := range orgs {
|
|
displayName := org.Name
|
|
if displayName == "" {
|
|
displayName = org.Login
|
|
}
|
|
|
|
result[i] = Organization{Slug: org.Login, DisplayName: displayName}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// ListSentryOrganizations fetches the organizations the authenticated
|
|
// Sentry user belongs to.
|
|
func ListSentryOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) {
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodGet,
|
|
"https://sentry.io/api/0/organizations/?member=true",
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot create sentry organizations request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot fetch sentry organizations: %w", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("cannot fetch sentry organizations: unexpected status %d", resp.StatusCode)
|
|
}
|
|
|
|
var orgs []struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&orgs); err != nil {
|
|
return nil, fmt.Errorf("cannot decode sentry organizations response: %w", err)
|
|
}
|
|
|
|
result := make([]Organization, len(orgs))
|
|
for i, org := range orgs {
|
|
displayName := org.Name
|
|
if displayName == "" {
|
|
displayName = org.Slug
|
|
}
|
|
|
|
result[i] = Organization{Slug: org.Slug, DisplayName: displayName}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// ListGitLabOrganizations fetches the GitLab groups the authenticated
|
|
// user owns. Group IDs are int64; we surface them as strings so they fit
|
|
// the Organization.Slug shape.
|
|
func ListGitLabOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) {
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodGet,
|
|
"https://gitlab.com/api/v4/groups?min_access_level=50&per_page=100",
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot create gitlab organizations request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot fetch gitlab organizations: %w", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("cannot fetch gitlab organizations: unexpected status %d", resp.StatusCode)
|
|
}
|
|
|
|
var groups []struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
FullPath string `json:"full_path"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&groups); err != nil {
|
|
return nil, fmt.Errorf("cannot decode gitlab organizations response: %w", err)
|
|
}
|
|
|
|
result := make([]Organization, len(groups))
|
|
for i, g := range groups {
|
|
displayName := g.Name
|
|
if displayName == "" {
|
|
displayName = g.FullPath
|
|
}
|
|
|
|
result[i] = Organization{
|
|
Slug: strconv.FormatInt(g.ID, 10),
|
|
DisplayName: displayName,
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// ListBitbucketOrganizations fetches the workspaces the authenticated
|
|
// Bitbucket user belongs to. The legacy /2.0/workspaces endpoint was
|
|
// sunset by CHANGE-2770 (April 2026); /2.0/user/workspaces is the
|
|
// supported cross-workspace replacement (CHANGE-3022). Bitbucket pages
|
|
// via an absolute `next` URL on each response; follow until exhausted.
|
|
func ListBitbucketOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) {
|
|
pageURL := "https://api.bitbucket.org/2.0/user/workspaces?pagelen=100"
|
|
result := make([]Organization, 0)
|
|
|
|
for range maxPaginationPages {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot create bitbucket organizations request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot fetch bitbucket organizations: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
_ = resp.Body.Close()
|
|
return nil, fmt.Errorf("cannot fetch bitbucket organizations: unexpected status %d", resp.StatusCode)
|
|
}
|
|
|
|
// We tolerate both shapes (flat and nested under `workspace`) since
|
|
// Atlassian has shipped variants of similar endpoints with both.
|
|
var body struct {
|
|
Values []struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Workspace struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
} `json:"workspace"`
|
|
} `json:"values"`
|
|
Next string `json:"next"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
_ = resp.Body.Close()
|
|
return nil, fmt.Errorf("cannot decode bitbucket organizations response: %w", err)
|
|
}
|
|
|
|
_ = resp.Body.Close()
|
|
|
|
for _, v := range body.Values {
|
|
slug, name := v.Slug, v.Name
|
|
if slug == "" {
|
|
slug = v.Workspace.Slug
|
|
name = v.Workspace.Name
|
|
}
|
|
|
|
displayName := name
|
|
if displayName == "" {
|
|
displayName = slug
|
|
}
|
|
|
|
result = append(result, Organization{Slug: slug, DisplayName: displayName})
|
|
}
|
|
|
|
if body.Next == "" {
|
|
return result, nil
|
|
}
|
|
|
|
pageURL = body.Next
|
|
}
|
|
|
|
return nil, fmt.Errorf("cannot list all bitbucket organizations: %w", ErrPaginationLimitReached)
|
|
}
|
|
|
|
// ListHerokuOrganizations fetches the teams the authenticated Heroku
|
|
// user belongs to, and always appends a synthetic "Personal account"
|
|
// entry. Heroku Teams are an opt-in paid construct, so a solo account has
|
|
// no team to discover; the personal entry lets the picker offer personal
|
|
// mode (app owner + collaborators) instead of dead-ending at a free-text
|
|
// slug the user cannot fill.
|
|
func ListHerokuOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.heroku.com/teams", nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot create heroku organizations request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/vnd.heroku+json; version=3")
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot fetch heroku organizations: %w", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("cannot fetch heroku organizations: unexpected status %d", resp.StatusCode)
|
|
}
|
|
|
|
var teams []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&teams); err != nil {
|
|
return nil, fmt.Errorf("cannot decode heroku organizations response: %w", err)
|
|
}
|
|
|
|
result := make([]Organization, 0, len(teams)+1)
|
|
for _, t := range teams {
|
|
displayName := t.Name
|
|
if displayName == "" {
|
|
displayName = t.ID
|
|
}
|
|
|
|
result = append(result, Organization{Slug: t.ID, DisplayName: displayName})
|
|
}
|
|
|
|
result = append(result, Organization{
|
|
Slug: herokuPersonalAccountSlug,
|
|
DisplayName: herokuPersonalAccountDisplayName,
|
|
})
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// ListAsanaOrganizations fetches the workspaces the authenticated Asana
|
|
// user belongs to.
|
|
func ListAsanaOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) {
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodGet,
|
|
"https://app.asana.com/api/1.0/workspaces?limit=100",
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot create asana organizations request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot fetch asana organizations: %w", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("cannot fetch asana organizations: unexpected status %d", resp.StatusCode)
|
|
}
|
|
|
|
var body struct {
|
|
Data []struct {
|
|
GID string `json:"gid"`
|
|
Name string `json:"name"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
return nil, fmt.Errorf("cannot decode asana organizations response: %w", err)
|
|
}
|
|
|
|
result := make([]Organization, len(body.Data))
|
|
for i, w := range body.Data {
|
|
displayName := w.Name
|
|
if displayName == "" {
|
|
displayName = w.GID
|
|
}
|
|
|
|
result[i] = Organization{Slug: w.GID, DisplayName: displayName}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// ListNetlifyOrganizations fetches the Netlify accounts the authenticated
|
|
// user belongs to.
|
|
func ListNetlifyOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) {
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodGet,
|
|
"https://api.netlify.com/api/v1/accounts",
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot create netlify organizations request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot fetch netlify organizations: %w", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("cannot fetch netlify organizations: unexpected status %d", resp.StatusCode)
|
|
}
|
|
|
|
var accounts []struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&accounts); err != nil {
|
|
return nil, fmt.Errorf("cannot decode netlify organizations response: %w", err)
|
|
}
|
|
|
|
result := make([]Organization, len(accounts))
|
|
for i, a := range accounts {
|
|
displayName := a.Name
|
|
if displayName == "" {
|
|
displayName = a.Slug
|
|
}
|
|
|
|
result[i] = Organization{Slug: a.Slug, DisplayName: displayName}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// ListDocuSignOrganizations fetches the DocuSign accounts the authenticated
|
|
// user can access, from the OAuth2 userinfo endpoint. A user may belong to
|
|
// several accounts; the picker scopes the access source to one. The account
|
|
// UUID is surfaced as the Organization slug (it is what the driver and name
|
|
// resolver key off).
|
|
func ListDocuSignOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) {
|
|
accounts, err := fetchDocuSignAccounts(ctx, httpClient)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]Organization, len(accounts))
|
|
for i, a := range accounts {
|
|
displayName := a.AccountName
|
|
if displayName == "" {
|
|
displayName = a.AccountID
|
|
}
|
|
|
|
result[i] = Organization{Slug: a.AccountID, DisplayName: displayName}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// ListClickUpOrganizations fetches the ClickUp teams (workspaces) the
|
|
// authenticated user belongs to.
|
|
func ListClickUpOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) {
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodGet,
|
|
"https://api.clickup.com/api/v2/team",
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot create clickup organizations request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot fetch clickup organizations: %w", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("cannot fetch clickup organizations: unexpected status %d", resp.StatusCode)
|
|
}
|
|
|
|
var body struct {
|
|
Teams []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
} `json:"teams"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
return nil, fmt.Errorf("cannot decode clickup organizations response: %w", err)
|
|
}
|
|
|
|
result := make([]Organization, len(body.Teams))
|
|
for i, t := range body.Teams {
|
|
displayName := t.Name
|
|
if displayName == "" {
|
|
displayName = t.ID
|
|
}
|
|
|
|
result[i] = Organization{Slug: t.ID, DisplayName: displayName}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// ListGoogleAnalyticsOrganizations fetches the GA4 accounts the authenticated
|
|
// Google user can access, surfacing each account's numeric ID as the picker
|
|
// slug. Listing accounts requires the analytics.readonly scope.
|
|
func ListGoogleAnalyticsOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) {
|
|
var orgs []Organization
|
|
|
|
pageToken := ""
|
|
|
|
for range maxPaginationPages {
|
|
endpoint, err := googleAnalyticsURL(pageToken, nil, "v1alpha", "accounts")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot create google analytics accounts request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot fetch google analytics accounts: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
_ = resp.Body.Close()
|
|
|
|
return nil, fmt.Errorf("cannot fetch google analytics accounts: unexpected status %d", resp.StatusCode)
|
|
}
|
|
|
|
var out struct {
|
|
Accounts []struct {
|
|
Name string `json:"name"`
|
|
DisplayName string `json:"displayName"`
|
|
} `json:"accounts"`
|
|
NextPageToken string `json:"nextPageToken"`
|
|
}
|
|
|
|
decodeErr := json.NewDecoder(resp.Body).Decode(&out)
|
|
_ = resp.Body.Close()
|
|
|
|
if decodeErr != nil {
|
|
return nil, fmt.Errorf("cannot decode google analytics accounts response: %w", decodeErr)
|
|
}
|
|
|
|
for _, a := range out.Accounts {
|
|
id := strings.TrimPrefix(a.Name, "accounts/")
|
|
if id == "" {
|
|
continue
|
|
}
|
|
|
|
displayName := a.DisplayName
|
|
if displayName == "" {
|
|
displayName = id
|
|
}
|
|
|
|
orgs = append(orgs, Organization{Slug: id, DisplayName: displayName})
|
|
}
|
|
|
|
if out.NextPageToken == "" {
|
|
return orgs, nil
|
|
}
|
|
|
|
pageToken = out.NextPageToken
|
|
}
|
|
|
|
return nil, fmt.Errorf("cannot list all google analytics accounts: %w", ErrPaginationLimitReached)
|
|
}
|