From e5f4b9c58d6b5cb95f80332a300bc86f21d16b8e Mon Sep 17 00:00:00 2001 From: Steven4Hooisma <112615049+Steven4Hooisma@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:59:40 +0200 Subject: [PATCH 1/2] feat: add Front connector support - OAuth and API key authorization - will retrieve all teammates /teammates Signed-off-by: Steven4Hooisma <112615049+Steven4Hooisma@users.noreply.github.com> --- .env.example | 2 + packages/ui/src/Atoms/ThirdParties/Front.tsx | 13 + .../src/Atoms/ThirdParties/ThirdPartyLogo.tsx | 2 + packages/ui/src/Atoms/ThirdParties/index.ts | 1 + pkg/accessreview/drivers/front.go | 316 ++++++++++++++++++ pkg/accessreview/drivers/front_test.go | 303 +++++++++++++++++ .../drivers/pagination_guard_test.go | 10 + pkg/accessreview/drivers/testdata/front.yaml | 67 ++++ pkg/bootstrap/builder.go | 2 + pkg/bootstrap/builder_test.go | 2 +- pkg/connector/provider/builtin.go | 1 + pkg/connector/provider/front.go | 72 ++++ pkg/connector/provider/front_test.go | 84 +++++ pkg/coredata/connector_provider.go | 5 +- pkg/coredata/migrations/20260812T090000Z.sql | 21 ++ .../api/console/v1/graphql/connector.graphql | 1 + 16 files changed, 900 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/Atoms/ThirdParties/Front.tsx create mode 100644 pkg/accessreview/drivers/front.go create mode 100644 pkg/accessreview/drivers/front_test.go create mode 100644 pkg/accessreview/drivers/testdata/front.yaml create mode 100644 pkg/connector/provider/front.go create mode 100644 pkg/connector/provider/front_test.go create mode 100644 pkg/coredata/migrations/20260812T090000Z.sql diff --git a/.env.example b/.env.example index 0fb72feae0..56a7115eeb 100644 --- a/.env.example +++ b/.env.example @@ -175,6 +175,8 @@ # Square OAuth (EMPLOYEES_READ); customers may also use a personal access token. # PROBOD_CONNECTOR_SQUARE_CLIENT_ID= # PROBOD_CONNECTOR_SQUARE_CLIENT_SECRET= +# PROBOD_CONNECTOR_FRONT_CLIENT_ID= +# PROBOD_CONNECTOR_FRONT_CLIENT_SECRET= # PostHog Cloud (US + EU) OAuth needs NO config: it uses the CIMD public-client # flow (no app registration, no client_secret), auto-enabled when this # deployment is publicly reachable at PROBOD_BASE_URL. Self-hosted PostHog uses diff --git a/packages/ui/src/Atoms/ThirdParties/Front.tsx b/packages/ui/src/Atoms/ThirdParties/Front.tsx new file mode 100644 index 0000000000..184895563e --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/Front.tsx @@ -0,0 +1,13 @@ +import type { ComponentProps } from "react"; + +export function Front(props: ComponentProps<"svg">) { + return ( + + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx index 9bbc8d99c7..e228c8133c 100644 --- a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx +++ b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx @@ -38,6 +38,7 @@ import { Deepgram } from "./Deepgram"; import { DocuSign } from "./DocuSign"; import { Dotfile } from "./Dotfile"; import { Figma } from "./Figma"; +import { Front } from "./Front"; import { GitHub } from "./GitHub"; import { GitLab } from "./GitLab"; import { Google } from "./Google"; @@ -101,6 +102,7 @@ const thirdParties: Record>> = { DOCUSIGN: DocuSign, DOTFILE: Dotfile, FIGMA: Figma, + FRONT: Front, GITHUB: GitHub, GITLAB: GitLab, GOOGLE: Google, diff --git a/packages/ui/src/Atoms/ThirdParties/index.ts b/packages/ui/src/Atoms/ThirdParties/index.ts index b63a00fb6b..8eb683bc46 100644 --- a/packages/ui/src/Atoms/ThirdParties/index.ts +++ b/packages/ui/src/Atoms/ThirdParties/index.ts @@ -16,6 +16,7 @@ export { Deepgram } from "./Deepgram"; export { DocuSign } from "./DocuSign"; export { Dotfile } from "./Dotfile"; export { Figma } from "./Figma"; +export { Front } from "./Front"; export { GitHub } from "./GitHub"; export { GitLab } from "./GitLab"; export { Google } from "./Google"; diff --git a/pkg/accessreview/drivers/front.go b/pkg/accessreview/drivers/front.go new file mode 100644 index 0000000000..ae3ee22f75 --- /dev/null +++ b/pkg/accessreview/drivers/front.go @@ -0,0 +1,316 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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" + "strings" + + "go.probo.inc/probo/pkg/coredata" +) + +// FrontDriver reports who holds access to a Front company, from +// GET /teammates — the Core API's company-wide teammate list, which the +// connection's credential (OAuth token or API token) is already scoped to. +// +// What is reported: every teammate the company exposes, blocked ones included, +// since classification belongs to the reviewer. `is_blocked` is the API's only +// account-status signal and maps to Active; `is_admin` maps to IsAdmin and to +// an "Admin" role label. Front's bot teammates (rules, macros, integrations, +// OAuth clients — the non-"user"/"visitor" values of `type`) are access holders +// too and are reported as service accounts, with their type carried as a role +// qualifier so a reviewer can see which automation the grant belongs to. +// +// What is deliberately NOT reported: +// +// - Availability. `is_available` is a presence toggle ("away"), not an +// account state; reading it as Active would mark everyone who logged off +// as deactivated. +// - Inbox and team membership. GET /teammates/{id}/inboxes and the team +// endpoints would describe WHICH resources a teammate reaches, one request +// per teammate; the campaign question is who has access to Front, and the +// resources are not access holders. +// - Custom fields. They are customer-defined and may carry personal data, so +// they are left undecoded rather than folded into a record. +// +// What the API cannot answer: Front exposes no MFA state, no last-login, and +// no account-creation timestamp on a teammate, so MFAStatus stays UNKNOWN and +// LastLogin/CreatedAt stay nil rather than being invented. +type FrontDriver struct { + httpClient *http.Client + baseURL string +} + +var _ Driver = (*FrontDriver)(nil) + +const ( + frontTeammatesPath = "/teammates" + frontMePath = "/me" +) + +// NewFrontDriver builds a driver against baseURL, the Core API origin (e.g. +// https://api2.frontapp.com). +func NewFrontDriver(httpClient *http.Client, baseURL string) *FrontDriver { + // Copy the caller's client and swap only its transport: the connection's + // client carries SSRF protection in both its transport dial check and its + // CheckRedirect, and a fresh &http.Client{} would silently drop the second. + retryClient := *httpClient + retryClient.Transport = &retryRoundTripper{ + next: httpClient.Transport, + maxRetries: 3, + } + + return &FrontDriver{ + httpClient: &retryClient, + baseURL: baseURL, + } +} + +type ( + frontTeammatesPage struct { + Pagination *struct { + Next string `json:"next"` + } `json:"_pagination"` + Results []frontTeammate `json:"_results"` + } + + frontTeammate struct { + ID string `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + IsAdmin bool `json:"is_admin"` + IsBlocked bool `json:"is_blocked"` + Type string `json:"type"` + } +) + +func (d *FrontDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + next, err := url.JoinPath(d.baseURL, frontTeammatesPath) + if err != nil { + return nil, fmt.Errorf("cannot build front teammates URL: %w", err) + } + + var records []AccountRecord + + for range maxPaginationPages { + page, err := d.fetchTeammates(ctx, next) + if err != nil { + return nil, err + } + + for _, teammate := range page.Results { + if teammate.ID == "" { + continue + } + + records = append(records, frontAccountRecord(teammate)) + } + + // An empty page ends the walk regardless of the cursor: the Core API + // keeps `_pagination` on the response even where the collection is + // unpaginated, and trusting a stale cursor over an empty page would + // spin until the guard trips. + if page.Pagination == nil || page.Pagination.Next == "" || len(page.Results) == 0 { + return records, nil + } + + next, err = sameHostNextPageURL("front", d.baseURL, page.Pagination.Next) + if err != nil { + return nil, err + } + } + + return nil, fmt.Errorf("cannot list all front teammates: %w", ErrPaginationLimitReached) +} + +func (d *FrontDriver) fetchTeammates(ctx context.Context, endpoint string) (*frontTeammatesPage, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("cannot create front teammates request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + httpResp, err := d.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot execute front teammates request: %w", err) + } + + defer func() { _ = httpResp.Body.Close() }() + + if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("cannot fetch front teammates: unexpected status %d", httpResp.StatusCode) + } + + var page frontTeammatesPage + if err := json.NewDecoder(httpResp.Body).Decode(&page); err != nil { + return nil, fmt.Errorf("cannot decode front teammates response: %w", err) + } + + return &page, nil +} + +func frontAccountRecord(teammate frontTeammate) AccountRecord { + active := !teammate.IsBlocked + + return AccountRecord{ + Email: teammate.Email, + FullName: frontFullName(teammate), + Roles: frontRoles(teammate), + Active: &active, + IsAdmin: teammate.IsAdmin, + MFAStatus: coredata.MFAStatusUnknown, + AuthMethod: frontAuthMethod(teammate.Type), + AccountType: frontAccountType(teammate.Type), + ExternalID: teammate.ID, + } +} + +// frontFullName joins the name fields, falling back to the "@" mention +// username: Front's bot teammates carry no first/last name, and an entry with +// no display name at all is harder for a reviewer to place than one named after +// the automation. +func frontFullName(teammate frontTeammate) string { + name := strings.TrimSpace(strings.Join([]string{teammate.FirstName, teammate.LastName}, " ")) + if name != "" { + return name + } + + return teammate.Username +} + +// frontRoles labels the grant with the only permission Front exposes on a +// teammate (admin or not), plus the account's type for the non-human ones so a +// reviewer can tell an integration's access from a rule's. +func frontRoles(teammate frontTeammate) []string { + roles := []string{"Teammate"} + if teammate.IsAdmin { + roles = []string{"Admin"} + } + + if label := frontTypeLabel(teammate.Type); label != "" { + roles = append(roles, label) + } + + return roles +} + +// frontTypeLabel renders a bot teammate's type as a human-readable qualifier. +// A human teammate ("user") and a chat visitor ("visitor") need none: the base +// role already describes them. +func frontTypeLabel(accountType string) string { + switch strings.ToLower(strings.TrimSpace(accountType)) { + case "", "user", "visitor": + return "" + case "ai": + return "Type: AI" + case "api": + return "Type: API" + case "bulk_reply": + return "Type: Bulk reply" + case "csat": + return "Type: CSAT" + case "smart_csat": + return "Type: Smart CSAT" + default: + // The remaining documented values (application, integration, macro, + // rule) and any type Front adds later read fine capitalised. + return "Type: " + strings.ToUpper(accountType[:1]) + accountType[1:] + } +} + +// frontAccountType maps Front's teammate type to the account taxonomy. The +// documented enum is fully enumerated here so a value Front adds later lands on +// USER — the conservative side, since a wrongly-labelled service account drops +// a real person out of the human-review path. +func frontAccountType(accountType string) coredata.AccessReviewEntryAccountType { + switch strings.ToLower(strings.TrimSpace(accountType)) { + case "ai", "api", "application", "bulk_reply", "csat", "integration", "macro", "rule", "smart_csat": + return coredata.AccessReviewEntryAccountTypeServiceAccount + default: + return coredata.AccessReviewEntryAccountTypeUser + } +} + +// frontAuthMethod reports how the account authenticates. Front states nothing +// about how a human teammate signs in (SSO vs password), so that stays UNKNOWN; +// a bot teammate acts on behalf of a rule, macro or OAuth client rather than a +// person, which is exactly SERVICE_ACCOUNT. +func frontAuthMethod(accountType string) coredata.AccessReviewEntryAuthMethod { + if frontAccountType(accountType) == coredata.AccessReviewEntryAccountTypeServiceAccount { + return coredata.AccessReviewEntryAuthMethodServiceAccount + } + + return coredata.AccessReviewEntryAuthMethodUnknown +} + +// frontNameResolver names the source after the Front company the credential +// belongs to, from GET /me. +type frontNameResolver struct { + httpClient *http.Client + baseURL string +} + +var _ NameResolver = (*frontNameResolver)(nil) + +func NewFrontNameResolver(httpClient *http.Client, baseURL string) NameResolver { + return &frontNameResolver{httpClient: httpClient, baseURL: baseURL} +} + +func (r *frontNameResolver) ResolveInstanceName(ctx context.Context) (string, error) { + endpoint, err := url.JoinPath(r.baseURL, frontMePath) + if err != nil { + return "", fmt.Errorf("cannot build front identity URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", fmt.Errorf("cannot create front identity request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + httpResp, err := r.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("cannot execute front identity request: %w", err) + } + + defer func() { _ = httpResp.Body.Close() }() + + if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices { + return "", nameStatusError("front identity", httpResp.StatusCode) + } + + var resp struct { + Name string `json:"name"` + } + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return "", fmt.Errorf("cannot decode front identity response: %w", err) + } + + return resp.Name, nil +} diff --git a/pkg/accessreview/drivers/front_test.go b/pkg/accessreview/drivers/front_test.go new file mode 100644 index 0000000000..a0c273346e --- /dev/null +++ b/pkg/accessreview/drivers/front_test.go @@ -0,0 +1,303 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/coredata" +) + +const frontTestBaseURL = "https://api2.frontapp.com" + +func TestFrontDriver(t *testing.T) { + t.Parallel() + + rec := newRecorder(t, "testdata/front", "FRONT_API_KEY") + // Front accepts both an OAuth access token and a company API token as a + // Bearer credential. The matcher ignores Authorization, so replay needs none. + client := newVCRClient(rec, bearerAuth(os.Getenv("FRONT_API_KEY"))) + + records, err := NewFrontDriver(client, frontTestBaseURL).ListAccounts(context.Background()) + require.NoError(t, err) + require.Len(t, records, 6) + + seen := make(map[string]bool, len(records)) + + for _, record := range records { + require.NotEmpty(t, record.ExternalID, "every record must carry a stable external ID") + require.False(t, seen[record.ExternalID], "external IDs must be unique: %q", record.ExternalID) + seen[record.ExternalID] = true + + assert.Equal(t, coredata.MFAStatusUnknown, record.MFAStatus, "front exposes no MFA state") + assert.Nil(t, record.LastLogin, "front exposes no last-login") + assert.Nil(t, record.CreatedAt, "front exposes no creation timestamp") + } + + admin := records[0] + assert.Equal(t, "tea_1", admin.ExternalID) + assert.Equal(t, "alice@example.com", admin.Email) + assert.Equal(t, "Alice Admin", admin.FullName) + assert.Equal(t, []string{"Admin"}, admin.Roles) + assert.True(t, admin.IsAdmin) + require.NotNil(t, admin.Active) + assert.True(t, *admin.Active) + assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, admin.AccountType) + assert.Equal(t, coredata.AccessReviewEntryAuthMethodUnknown, admin.AuthMethod) + + member := records[1] + assert.Equal(t, "tea_2", member.ExternalID) + assert.Equal(t, []string{"Teammate"}, member.Roles) + assert.False(t, member.IsAdmin) + require.NotNil(t, member.Active) + // is_available false is a presence toggle, not an account state. + assert.True(t, *member.Active) + + // is_blocked true is Front's only account-status signal, and with no + // first/last name the display name falls back to the mention username. + blocked := records[2] + assert.Equal(t, "tea_3", blocked.ExternalID) + assert.Equal(t, "carol_offboarded", blocked.FullName) + require.NotNil(t, blocked.Active) + assert.False(t, *blocked.Active) + assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, blocked.AccountType) + + // A rule bot holds access too; its type qualifies the grant. + rule := records[3] + assert.Equal(t, "tea_4", rule.ExternalID) + assert.Equal(t, "triage_rule", rule.FullName) + assert.Empty(t, rule.Email) + assert.Equal(t, []string{"Teammate", "Type: Rule"}, rule.Roles) + assert.Equal(t, coredata.AccessReviewEntryAccountTypeServiceAccount, rule.AccountType) + assert.Equal(t, coredata.AccessReviewEntryAuthMethodServiceAccount, rule.AuthMethod) + + // Page 2, reached through _pagination.next. + oauthClient := records[4] + assert.Equal(t, "tea_5", oauthClient.ExternalID) + assert.Equal(t, []string{"Teammate", "Type: API"}, oauthClient.Roles) + assert.Equal(t, coredata.AccessReviewEntryAccountTypeServiceAccount, oauthClient.AccountType) + + application := records[5] + assert.Equal(t, "tea_6", application.ExternalID) + assert.Equal(t, []string{"Admin", "Type: Application"}, application.Roles) + assert.True(t, application.IsAdmin) + assert.Equal(t, coredata.AccessReviewEntryAccountTypeServiceAccount, application.AccountType) +} + +// TestFrontDriverTransientFailureAborts pins the completeness guarantee: a +// short answer returned as a success would mark every missing teammate removed +// on the next campaign. +func TestFrontDriverTransientFailureAborts(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + status int + body string + }{ + {name: "server error", status: http.StatusInternalServerError, body: `{"_error":{"status":500}}`}, + {name: "malformed body", status: http.StatusOK, body: `{"_results":`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: tc.status, + Body: io.NopCloser(strings.NewReader(tc.body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + })} + + records, err := NewFrontDriver(client, frontTestBaseURL).ListAccounts(context.Background()) + require.Error(t, err) + assert.Nil(t, records) + }) + } +} + +// TestFrontDriverContextCancellation verifies a cancelled context surfaces as +// the cancellation error rather than a truncated success. +func TestFrontDriverContextCancellation(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + cancel() + + return nil, ctx.Err() + })} + + records, err := NewFrontDriver(client, frontTestBaseURL).ListAccounts(ctx) + require.Error(t, err) + assert.True(t, errors.Is(err, context.Canceled)) + assert.Nil(t, records) +} + +// TestFrontDriverEmptyPageEndsWalk covers a cursor that never clears: Front +// keeps _pagination on every response, so an empty page must end the walk +// instead of spinning until the guard trips. +func TestFrontDriverEmptyPageEndsWalk(t *testing.T) { + t.Parallel() + + var requests int + + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + requests++ + + body := `{"_pagination":{"next":"https://api2.frontapp.com/teammates?page_token=always"},"_results":[]}` + if requests == 1 { + body = `{"_pagination":{"next":"https://api2.frontapp.com/teammates?page_token=always"},"_results":[{"id":"tea_1","email":"alice@example.com","first_name":"Alice","last_name":"Admin","type":"user"}]}` + } + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + })} + + records, err := NewFrontDriver(client, frontTestBaseURL).ListAccounts(context.Background()) + require.NoError(t, err) + require.Len(t, records, 1) + assert.Equal(t, 2, requests, "the empty second page must end the walk") +} + +// TestFrontDriverPaginationGuard covers a cursor that keeps returning results: +// the walk stops at maxPaginationPages with an explicit error rather than +// looping forever. +func TestFrontDriverPaginationGuard(t *testing.T) { + t.Parallel() + + var requests int + + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + requests++ + + body := fmt.Sprintf( + `{"_pagination":{"next":"https://api2.frontapp.com/teammates?page_token=p%d"},"_results":[{"id":"tea_%d","email":"user%d@example.com","type":"user"}]}`, + requests, requests, requests, + ) + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + })} + + records, err := NewFrontDriver(client, frontTestBaseURL).ListAccounts(context.Background()) + require.Error(t, err) + assert.ErrorIs(t, err, ErrPaginationLimitReached) + assert.Nil(t, records) + assert.Equal(t, maxPaginationPages, requests) +} + +func TestFrontAccountTypeAndAuthMethod(t *testing.T) { + t.Parallel() + + cases := []struct { + accountType string + want coredata.AccessReviewEntryAccountType + }{ + {accountType: "user", want: coredata.AccessReviewEntryAccountTypeUser}, + {accountType: "visitor", want: coredata.AccessReviewEntryAccountTypeUser}, + // Unset and unrecognised types fall to USER, keeping a real person in + // the human-review path. + {accountType: "", want: coredata.AccessReviewEntryAccountTypeUser}, + {accountType: "something_new", want: coredata.AccessReviewEntryAccountTypeUser}, + {accountType: "ai", want: coredata.AccessReviewEntryAccountTypeServiceAccount}, + {accountType: "api", want: coredata.AccessReviewEntryAccountTypeServiceAccount}, + {accountType: "APPLICATION", want: coredata.AccessReviewEntryAccountTypeServiceAccount}, + {accountType: "bulk_reply", want: coredata.AccessReviewEntryAccountTypeServiceAccount}, + {accountType: "csat", want: coredata.AccessReviewEntryAccountTypeServiceAccount}, + {accountType: "integration", want: coredata.AccessReviewEntryAccountTypeServiceAccount}, + {accountType: "macro", want: coredata.AccessReviewEntryAccountTypeServiceAccount}, + {accountType: "rule", want: coredata.AccessReviewEntryAccountTypeServiceAccount}, + {accountType: "smart_csat", want: coredata.AccessReviewEntryAccountTypeServiceAccount}, + } + + for _, tc := range cases { + t.Run(tc.accountType, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tc.want, frontAccountType(tc.accountType)) + + wantAuth := coredata.AccessReviewEntryAuthMethodUnknown + if tc.want == coredata.AccessReviewEntryAccountTypeServiceAccount { + wantAuth = coredata.AccessReviewEntryAuthMethodServiceAccount + } + + assert.Equal(t, wantAuth, frontAuthMethod(tc.accountType)) + }) + } +} + +func TestFrontNameResolver(t *testing.T) { + t.Parallel() + + t.Run("resolves the company name", func(t *testing.T) { + t.Parallel() + + client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + assert.Equal(t, "/me", req.URL.Path) + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"id":"cmp_k30","name":"Dunder Mifflin, Inc."}`)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + })} + + name, err := NewFrontNameResolver(client, frontTestBaseURL).ResolveInstanceName(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Dunder Mifflin, Inc.", name) + }) + + t.Run("unauthorized is terminal", func(t *testing.T) { + t.Parallel() + + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(strings.NewReader(`{}`)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + })} + + name, err := NewFrontNameResolver(client, frontTestBaseURL).ResolveInstanceName(context.Background()) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTerminalNameResolution) + assert.Empty(t, name) + }) +} diff --git a/pkg/accessreview/drivers/pagination_guard_test.go b/pkg/accessreview/drivers/pagination_guard_test.go index 6f3a1a4bd7..346d3c56f6 100644 --- a/pkg/accessreview/drivers/pagination_guard_test.go +++ b/pkg/accessreview/drivers/pagination_guard_test.go @@ -93,6 +93,16 @@ func TestDriversRefuseCrossHostPagination(t *testing.T) { return err }, }, + { + name: "front teammates", + baseURL: "https://api2.frontapp.com", + body: `{"_pagination":{"next":"https://evil.example.com/teammates?page_token=abc"},"_results":[{"id":"tea_1","email":"alice@example.com","type":"user"}]}`, + listFunc: func(ctx context.Context, client *http.Client, baseURL string) error { + _, err := NewFrontDriver(client, baseURL).ListAccounts(ctx) + + return err + }, + }, { name: "github organization members", baseURL: "https://api.github.com", diff --git a/pkg/accessreview/drivers/testdata/front.yaml b/pkg/accessreview/drivers/testdata/front.yaml new file mode 100644 index 0000000000..29a8d712ed --- /dev/null +++ b/pkg/accessreview/drivers/testdata/front.yaml @@ -0,0 +1,67 @@ +--- +# Hand-authored fixture for the Front access-review driver. Two interactions: +# +# 0. GET /teammates — page 1: a human admin, a plain teammate, a blocked +# teammate with no first/last name (display name falls back to the "@" +# mention username), and a rule bot. `_pagination.next` points at page 2 +# on the same host, so the driver follows it. +# 1. GET /teammates?page_token=... — page 2: one OAuth-client (api) bot and a +# null `next`, ending the walk. +# +# Expected: 6 records. Object shapes mirror the documented TeammateResponse +# schema. Synthetic IDs and .example.com emails only. +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api2.frontapp.com + headers: + Accept: + - application/json + url: https://api2.frontapp.com/teammates + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"_pagination":{"next":"https://api2.frontapp.com/teammates?page_token=9fa92a7f385fd7be43f7153055b30e6d"},"_links":{"self":"https://api2.frontapp.com/teammates"},"_results":[{"_links":{"self":"https://api2.frontapp.com/teammates/tea_1"},"id":"tea_1","email":"alice@example.com","username":"alice","first_name":"Alice","last_name":"Admin","is_admin":true,"is_available":true,"is_blocked":false,"type":"user","custom_fields":{}},{"_links":{"self":"https://api2.frontapp.com/teammates/tea_2"},"id":"tea_2","email":"bob@example.com","username":"bob","first_name":"Bob","last_name":"Member","is_admin":false,"is_available":false,"is_blocked":false,"type":"user","custom_fields":{}},{"_links":{"self":"https://api2.frontapp.com/teammates/tea_3"},"id":"tea_3","email":"carol@example.com","username":"carol_offboarded","first_name":"","last_name":"","is_admin":false,"is_available":false,"is_blocked":true,"type":"user","custom_fields":{}},{"_links":{"self":"https://api2.frontapp.com/teammates/tea_4"},"id":"tea_4","email":"","username":"triage_rule","first_name":"","last_name":"","is_admin":false,"is_available":true,"is_blocked":false,"type":"rule","custom_fields":{}}]}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 110ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api2.frontapp.com + form: + page_token: + - 9fa92a7f385fd7be43f7153055b30e6d + headers: + Accept: + - application/json + url: https://api2.frontapp.com/teammates?page_token=9fa92a7f385fd7be43f7153055b30e6d + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"_pagination":{"next":null},"_links":{"self":"https://api2.frontapp.com/teammates?page_token=9fa92a7f385fd7be43f7153055b30e6d"},"_results":[{"_links":{"self":"https://api2.frontapp.com/teammates/tea_5"},"id":"tea_5","email":"","username":"probo_integration","first_name":"","last_name":"","is_admin":false,"is_available":true,"is_blocked":false,"type":"api","custom_fields":{}},{"_links":{"self":"https://api2.frontapp.com/teammates/tea_6"},"id":"tea_6","email":"dave@example.com","username":"dave","first_name":"Dave","last_name":"Integration","is_admin":true,"is_available":true,"is_blocked":false,"type":"application","custom_fields":{}}]}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 95ms diff --git a/pkg/bootstrap/builder.go b/pkg/bootstrap/builder.go index 30d9c6444a..c7d82ad584 100644 --- a/pkg/bootstrap/builder.go +++ b/pkg/bootstrap/builder.go @@ -508,6 +508,7 @@ func (b *Builder) Build() (*probodconfig.FullConfig, error) { "LINEAR", "GOOGLE_ANALYTICS", "SQUARE", + "FRONT", } { clientID := b.resolver.getEnv("PROBOD_CONNECTOR_" + provider + "_CLIENT_ID") if clientID == "" { @@ -645,6 +646,7 @@ func (b *Builder) validateRequired() error { {"CONNECTOR_LINEAR", []string{"CLIENT_SECRET"}}, {"CONNECTOR_GOOGLE_ANALYTICS", []string{"CLIENT_SECRET"}}, {"CONNECTOR_SQUARE", []string{"CLIENT_SECRET"}}, + {"CONNECTOR_FRONT", []string{"CLIENT_SECRET"}}, {"CONNECTOR_VERCEL", []string{"CLIENT_SECRET", "INTEGRATION_SLUG"}}, } diff --git a/pkg/bootstrap/builder_test.go b/pkg/bootstrap/builder_test.go index 0403b0a97c..f6edf9816b 100644 --- a/pkg/bootstrap/builder_test.go +++ b/pkg/bootstrap/builder_test.go @@ -636,7 +636,7 @@ func TestBuilder_Build_AccessReviewConnectors(t *testing.T) { providers := []string{ "GITLAB", "BITBUCKET", "HEROKU", "PAGERDUTY", "ASANA", "NETLIFY", "CLICKUP", "MONDAY", "DATADOG", - "ZENDESK", "LINEAR", "GOOGLE_ANALYTICS", "SQUARE", + "ZENDESK", "LINEAR", "GOOGLE_ANALYTICS", "SQUARE", "FRONT", } env := requiredEnv() diff --git a/pkg/connector/provider/builtin.go b/pkg/connector/provider/builtin.go index 8398ca5ad7..8b8f00720f 100644 --- a/pkg/connector/provider/builtin.go +++ b/pkg/connector/provider/builtin.go @@ -73,6 +73,7 @@ func NewBuiltinRegistryWith(opts ...Option) (*Registry, error) { deepgramRegistration(), docusignRegistration(), dotfileRegistration(), + frontRegistration(), grafanaRegistration(), githubRegistration(), gitlabRegistration(), diff --git a/pkg/connector/provider/front.go b/pkg/connector/provider/front.go new file mode 100644 index 0000000000..af73d7dfc1 --- /dev/null +++ b/pkg/connector/provider/front.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 provider + +import ( + "context" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +// frontRegistration wires Front (the shared-inbox platform) as an access +// source. Both credential paths are offered because either is enough on its +// own: an OAuth app grants the `teammates:read` scope the driver needs, and a +// company API token (Settings > API) carries the same read access for customers +// who would rather not install an app. +// +// Front authorizes and exchanges on app.frontapp.com while the Core API lives +// on api2.frontapp.com — a different host, so neither is derived from the other. +// The token exchange authenticates with HTTP Basic client credentials (Front +// documents it as required), which "basic-form" covers with the RFC 6749 +// form-encoded body; Front's docs list the body parameters without naming a +// content type. +// +// Scopes are NOT requested per-authorization: Front resolves an OAuth token's +// scopes from the app's own configuration, so an authorize URL carrying a scope +// parameter would be neither honoured nor needed. The operator grants +// `teammates:read` when registering the app. +func frontRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderFront, + DisplayName: "Front", + // No probo.com docs page for Front yet; a 404-ing link is worse than + // none, so this stays empty until the page ships. + Endpoints: Endpoints{ + Auth: "https://app.frontapp.com/oauth/authorize", + Token: "https://app.frontapp.com/oauth/token", + // GET /me is the cheapest call that proves the credential reaches + // the company, and it is the same call the name resolver opens with. + Probe: "https://api2.frontapp.com/me", + APIBase: "https://api2.frontapp.com", + }, + TokenEndpointAuth: "basic-form", + SupportsAPIKey: true, + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger, ep Endpoints) (drivers.Driver, error) { + return drivers.NewFrontDriver(c, ep.APIBase), nil + }, + NewNameResolver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger, ep Endpoints) drivers.NameResolver { + return drivers.NewFrontNameResolver(c, ep.APIBase) + }, + } +} diff --git a/pkg/connector/provider/front_test.go b/pkg/connector/provider/front_test.go new file mode 100644 index 0000000000..46b9f950d1 --- /dev/null +++ b/pkg/connector/provider/front_test.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 provider_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.gearno.de/kit/httpclient" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/connector/provider" + "go.probo.inc/probo/pkg/coredata" +) + +func TestFrontRegistrationMetadata(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + reg, ok := r.Get(coredata.ConnectorProviderFront) + require.True(t, ok, "front provider must be registered") + + assert.Equal(t, "Front", reg.DisplayName) + + // Both credential paths are offered: OAuth and a company API token. + assert.Equal(t, "https://app.frontapp.com/oauth/authorize", reg.Endpoints.Auth) + assert.Equal(t, "https://app.frontapp.com/oauth/token", reg.Endpoints.Token) + assert.Equal(t, "basic-form", reg.TokenEndpointAuth, "front requires HTTP Basic client credentials on the token exchange") + assert.True(t, reg.SupportsAPIKey) + + // The Core API lives on a different host from the OAuth endpoints. + assert.Equal(t, "https://api2.frontapp.com", reg.Endpoints.APIBase) + assert.Equal(t, "https://api2.frontapp.com/me", reg.Endpoints.Probe) + + // Front resolves an OAuth token's scopes from the app configuration, so + // none are requested per-authorization. + assert.Empty(t, reg.OAuth2Scopes) + // The company API token is a plain Bearer credential and the token is + // already company-scoped, so there is nothing extra to collect. + assert.Empty(t, reg.APIKeyExtraSettings) + assert.Empty(t, reg.APIKeyHeader) + assert.Empty(t, reg.APIKeyAuthScheme) + assert.False(t, reg.APIKeyBasicAuth) + assert.False(t, reg.APIKeyBasicAuthUserPass) +} + +func TestFrontFactories(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + reg, ok := r.Get(coredata.ConnectorProviderFront) + require.True(t, ok, "front provider must be registered") + require.NotNil(t, reg.NewDriver, "front NewDriver closure must be wired") + require.NotNil(t, reg.NewNameResolver, "front NewNameResolver closure must be wired") + + conn := &coredata.Connector{Provider: coredata.ConnectorProviderFront} + client := httpclient.DefaultClient(httpclient.WithSSRFProtection()) + + drv, err := reg.NewDriver(context.Background(), client, conn, nil, reg.Endpoints) + require.NoError(t, err) + assert.IsType(t, &drivers.FrontDriver{}, drv) + + resolver := reg.NewNameResolver(context.Background(), client, conn, nil, reg.Endpoints) + require.NotNil(t, resolver, "front name resolver must be constructed") +} diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go index 51f0ec099b..246569389a 100644 --- a/pkg/coredata/connector_provider.go +++ b/pkg/coredata/connector_provider.go @@ -91,6 +91,7 @@ const ( ConnectorProviderSquare ConnectorProvider = "SQUARE" ConnectorProviderGoogleAnalytics ConnectorProvider = "GOOGLE_ANALYTICS" ConnectorProviderUpCloud ConnectorProvider = "UPCLOUD" + ConnectorProviderFront ConnectorProvider = "FRONT" ) var ( @@ -160,6 +161,7 @@ func ConnectorProviders() []ConnectorProvider { ConnectorProviderSquare, ConnectorProviderGoogleAnalytics, ConnectorProviderUpCloud, + ConnectorProviderFront, } } @@ -225,7 +227,8 @@ func (v ConnectorProvider) IsValid() bool { ConnectorProviderSegment, ConnectorProviderSquare, ConnectorProviderGoogleAnalytics, - ConnectorProviderUpCloud: + ConnectorProviderUpCloud, + ConnectorProviderFront: return true } diff --git a/pkg/coredata/migrations/20260812T090000Z.sql b/pkg/coredata/migrations/20260812T090000Z.sql new file mode 100644 index 0000000000..c5c7234cd6 --- /dev/null +++ b/pkg/coredata/migrations/20260812T090000Z.sql @@ -0,0 +1,21 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- 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. + +ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'FRONT'; diff --git a/pkg/server/api/console/v1/graphql/connector.graphql b/pkg/server/api/console/v1/graphql/connector.graphql index 9fbfcad1af..1bd2accba6 100644 --- a/pkg/server/api/console/v1/graphql/connector.graphql +++ b/pkg/server/api/console/v1/graphql/connector.graphql @@ -110,6 +110,7 @@ enum ConnectorProvider ) UPCLOUD @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderUpCloud") + FRONT @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderFront") } type ConnectorProviderInfo { From ed1d00f27d5dccd9e349d68282beefbb26296ca1 Mon Sep 17 00:00:00 2001 From: grootSH <112615049+Steven4Hooisma@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:41:24 +0200 Subject: [PATCH 2/2] Apply suggestion from @cubic-dev-ai[bot] Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Signed-off-by: grootSH <112615049+Steven4Hooisma@users.noreply.github.com> --- pkg/coredata/connector_provider.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go index f47f4590ae..176fdbf828 100644 --- a/pkg/coredata/connector_provider.go +++ b/pkg/coredata/connector_provider.go @@ -230,7 +230,7 @@ func (v ConnectorProvider) IsValid() bool { ConnectorProviderSquare, ConnectorProviderGoogleAnalytics, ConnectorProviderUpCloud, - ConnectorProviderFront: + ConnectorProviderFront, ConnectorProviderNuki: return true }