Two corrections, both settled from Segment's published OpenAPI document
and their own client code rather than guessed.
The registration claimed the Public API exposes no workspace-name
endpoint on the token's scope. That is wrong: Get Workspace is the API
root, GET /, returning data.workspace.name for the workspace the token
is bound to — the base URL already encodes the US/EU region, so the URL
is the whole request. Without a resolver an organization running a prod
and a staging workspace saw two rows both named "Segment".
The per-user GET /users/{id} is what makes a large workspace exceed the
per-source budget, and it exists only to read permissions[].roleName.
Both endpoints return the same UserV1 schema, on which permissions is
declared but optional, so whether the list populates it is a server
behaviour no specification settles. Rather than assume, the list
response is now decoded for permissions and the per-user request is
issued only when the field is absent. Today Segment omits it — their own
Terraform provider's mock returns /users without permissions and
/users/{id} with them — so behaviour is unchanged; if that ever changes
the extra round trip disappears on its own. An empty-but-present array
is authoritative, meaning a user with no roles, not a missing field.
Page size stays at 200: the 1-1000 range is prose in the pagination
guide, the schema sets no maximum, and the migration guide says 200.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
350 lines
9.5 KiB
Go
350 lines
9.5 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"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"go.probo.inc/probo/pkg/coredata"
|
|
)
|
|
|
|
const (
|
|
segmentPageSize = 200
|
|
// segmentWorkspaceOwnerRole is the only Segment role that grants
|
|
// workspace-wide administrative access; the resource-scoped *Admin roles
|
|
// (Source Admin, Warehouse Admin, …) administer a single resource, not the
|
|
// workspace.
|
|
segmentWorkspaceOwnerRole = "Workspace Owner"
|
|
)
|
|
|
|
// SegmentDriver lists the members of a single Twilio Segment workspace. The
|
|
// Public API token (sent as Authorization: Bearer by the connection transport)
|
|
// is bound to one workspace on one regional host. GET /users returns members
|
|
// without their roles, so each member's roles are fetched with a follow-up GET
|
|
// /users/{id} (an unavoidable N+1); GET /invites adds pending invitations as
|
|
// inactive members.
|
|
type SegmentDriver struct {
|
|
httpClient *http.Client
|
|
baseURL string
|
|
}
|
|
|
|
var _ Driver = (*SegmentDriver)(nil)
|
|
|
|
type segmentUser struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
// Permissions is declared on the shared UserV1 schema that both /users
|
|
// and /users/{id} return, but is optional and today only populated by
|
|
// the single-user read. Decoding it here means the driver uses whatever
|
|
// the list gives it rather than assuming: nil (field absent) triggers the
|
|
// per-user fetch, non-nil — including an empty array for a user with no
|
|
// roles — is taken as authoritative.
|
|
Permissions []segmentPermission `json:"permissions"`
|
|
}
|
|
|
|
type segmentPermission struct {
|
|
RoleName string `json:"roleName"`
|
|
}
|
|
|
|
type segmentPaginationOut struct {
|
|
// Next is absent (nil) on the last page.
|
|
Next *string `json:"next"`
|
|
}
|
|
|
|
type segmentUsersResponse struct {
|
|
Data struct {
|
|
Users []segmentUser `json:"users"`
|
|
Pagination segmentPaginationOut `json:"pagination"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
type segmentUserResponse struct {
|
|
Data struct {
|
|
User struct {
|
|
Permissions []segmentPermission `json:"permissions"`
|
|
} `json:"user"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
type segmentInvitesResponse struct {
|
|
Data struct {
|
|
Invites []string `json:"invites"`
|
|
Pagination segmentPaginationOut `json:"pagination"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
func NewSegmentDriver(httpClient *http.Client, baseURL string) *SegmentDriver {
|
|
return &SegmentDriver{
|
|
httpClient: &http.Client{
|
|
Transport: &retryRoundTripper{
|
|
next: httpClient.Transport,
|
|
maxRetries: 3,
|
|
},
|
|
},
|
|
baseURL: baseURL,
|
|
}
|
|
}
|
|
|
|
func (d *SegmentDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
|
base, err := url.Parse(d.baseURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot parse segment base URL: %w", err)
|
|
}
|
|
|
|
users, err := d.listUsers(ctx, base)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
records := make([]AccountRecord, 0, len(users))
|
|
seen := make(map[string]struct{}, len(users))
|
|
|
|
for _, u := range users {
|
|
email := strings.TrimSpace(u.Email)
|
|
if email == "" {
|
|
continue
|
|
}
|
|
|
|
seen[strings.ToLower(email)] = struct{}{}
|
|
|
|
perms := u.Permissions
|
|
if perms == nil {
|
|
perms, err = d.userPermissions(ctx, base, u.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot list segment permissions for user %q: %w", u.ID, err)
|
|
}
|
|
}
|
|
|
|
roles, isAdmin := segmentRolesAndAdmin(perms)
|
|
|
|
// Segment's user API exposes no active/suspended status field, so
|
|
// leave Active nil (unknown) rather than fabricate a value, per the
|
|
// AccountRecord contract.
|
|
records = append(records, AccountRecord{
|
|
Email: email,
|
|
FullName: segmentFullName(u.Name, email),
|
|
Roles: roles,
|
|
Active: nil,
|
|
IsAdmin: isAdmin,
|
|
MFAStatus: coredata.MFAStatusUnknown,
|
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
|
ExternalID: u.ID,
|
|
})
|
|
}
|
|
|
|
invites, err := d.listInvites(ctx, base)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, email := range invites {
|
|
email = strings.TrimSpace(email)
|
|
if email == "" {
|
|
continue
|
|
}
|
|
|
|
// An invite that has already been accepted can still be listed; the
|
|
// member record above is the authoritative one, so skip the duplicate
|
|
// rather than emit two rows for the same person.
|
|
if _, ok := seen[strings.ToLower(email)]; ok {
|
|
continue
|
|
}
|
|
|
|
// A pending invite carries no role and no stable id at the workspace
|
|
// level, so it is surfaced as an inactive member keyed by email.
|
|
active := false
|
|
|
|
records = append(records, AccountRecord{
|
|
Email: email,
|
|
FullName: email,
|
|
Roles: []string{},
|
|
Active: &active,
|
|
MFAStatus: coredata.MFAStatusUnknown,
|
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
|
ExternalID: email,
|
|
})
|
|
}
|
|
|
|
return records, nil
|
|
}
|
|
|
|
func (d *SegmentDriver) listUsers(ctx context.Context, base *url.URL) ([]segmentUser, error) {
|
|
var users []segmentUser
|
|
|
|
cursor := ""
|
|
|
|
for range maxPaginationPages {
|
|
endpoint := *base
|
|
endpoint.Path = "/users"
|
|
|
|
q := url.Values{}
|
|
q.Set("pagination.count", strconv.Itoa(segmentPageSize))
|
|
|
|
if cursor != "" {
|
|
q.Set("pagination.cursor", cursor)
|
|
}
|
|
|
|
endpoint.RawQuery = q.Encode()
|
|
|
|
var resp segmentUsersResponse
|
|
if err := d.getJSON(ctx, endpoint.String(), &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
users = append(users, resp.Data.Users...)
|
|
|
|
if resp.Data.Pagination.Next == nil || *resp.Data.Pagination.Next == "" {
|
|
return users, nil
|
|
}
|
|
|
|
cursor = *resp.Data.Pagination.Next
|
|
}
|
|
|
|
return nil, fmt.Errorf("cannot list all segment users: %w", ErrPaginationLimitReached)
|
|
}
|
|
|
|
func (d *SegmentDriver) userPermissions(ctx context.Context, base *url.URL, userID string) ([]segmentPermission, error) {
|
|
endpoint, err := url.JoinPath(base.String(), "users", url.PathEscape(userID))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot build segment user URL: %w", err)
|
|
}
|
|
|
|
var resp segmentUserResponse
|
|
if err := d.getJSON(ctx, endpoint, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return resp.Data.User.Permissions, nil
|
|
}
|
|
|
|
func (d *SegmentDriver) listInvites(ctx context.Context, base *url.URL) ([]string, error) {
|
|
var invites []string
|
|
|
|
cursor := ""
|
|
|
|
for range maxPaginationPages {
|
|
endpoint := *base
|
|
endpoint.Path = "/invites"
|
|
|
|
q := url.Values{}
|
|
q.Set("pagination.count", strconv.Itoa(segmentPageSize))
|
|
|
|
if cursor != "" {
|
|
q.Set("pagination.cursor", cursor)
|
|
}
|
|
|
|
endpoint.RawQuery = q.Encode()
|
|
|
|
var resp segmentInvitesResponse
|
|
if err := d.getJSON(ctx, endpoint.String(), &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
invites = append(invites, resp.Data.Invites...)
|
|
|
|
if resp.Data.Pagination.Next == nil || *resp.Data.Pagination.Next == "" {
|
|
return invites, nil
|
|
}
|
|
|
|
cursor = *resp.Data.Pagination.Next
|
|
}
|
|
|
|
return nil, fmt.Errorf("cannot list all segment invites: %w", ErrPaginationLimitReached)
|
|
}
|
|
|
|
func (d *SegmentDriver) getJSON(ctx context.Context, endpoint string, out any) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot create segment request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := d.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot execute segment request: %w", err)
|
|
}
|
|
|
|
defer func() {
|
|
_ = httpResp.Body.Close()
|
|
}()
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return fmt.Errorf("cannot fetch segment resource: unexpected status %d", httpResp.StatusCode)
|
|
}
|
|
|
|
if err := json.NewDecoder(httpResp.Body).Decode(out); err != nil {
|
|
return fmt.Errorf("cannot decode segment response: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// segmentFullName returns the member's name, falling back to the email when
|
|
// Segment stores an empty name.
|
|
func segmentFullName(name, email string) string {
|
|
if n := strings.TrimSpace(name); n != "" {
|
|
return n
|
|
}
|
|
|
|
return email
|
|
}
|
|
|
|
// segmentRolesAndAdmin collapses a member's permission entries into a
|
|
// de-duplicated, sorted set of role names and reports whether any of them is
|
|
// the workspace-level Workspace Owner role.
|
|
func segmentRolesAndAdmin(perms []segmentPermission) ([]string, bool) {
|
|
seen := make(map[string]struct{})
|
|
isAdmin := false
|
|
|
|
for _, p := range perms {
|
|
name := strings.TrimSpace(p.RoleName)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
|
|
seen[name] = struct{}{}
|
|
|
|
if strings.EqualFold(name, segmentWorkspaceOwnerRole) {
|
|
isAdmin = true
|
|
}
|
|
}
|
|
|
|
roles := make([]string, 0, len(seen))
|
|
for name := range seen {
|
|
roles = append(roles, name)
|
|
}
|
|
|
|
sort.Strings(roles)
|
|
|
|
return roles, isAdmin
|
|
}
|