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>
A fetch runs under a 30-second per-source budget, but the retry transport
slept without reference to it, so backoff could convert a reportable
provider status into an opaque "context deadline exceeded".
Three changes. The final attempt no longer sleeps: nothing follows it, so
the wait only spent the caller's deadline to return a response already in
hand — up to a second per failed request, across sixteen drivers.
Retry-After is now honoured, in both the delta-seconds and HTTP-date
forms; ignoring it meant retrying a 429 after 250ms and earning another
429, spending the whole retry budget in under a second. And a wait is
skipped entirely when it exceeds the remaining deadline or a 5s cap,
because a retry that lands after the deadline cannot succeed — the
throttled response is surfaced instead so the caller reports what the
provider actually said.
The type moves from google_workspace.go to driver.go, which is where the
other shared driver machinery lives; sixteen drivers construct it and
none of them are Google Workspace. It had no tests, so it has them now.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
The cassettes cannot reach these branches. Every fixture email is
already lowercase, so nothing exercised the trim-and-lowercase that is
the Google Analytics driver's whole merge key; likewise the Dotfile role
casing and the Segment role dedup and case-insensitive owner match.
The Dotfile and Square tests never asserted CreatedAt, which
parseRFC3339Ptr silently nils on any format mismatch — both cassettes
carry usable timestamps, so a dropped field looked like a clean pass.
Also gives the Square OAuth variables their own comment in the example
env file; they sat under the Google Analytics one.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
The region is a two-value allow-list the server resolves to an API host,
but it rendered as a free-text field: only PostHog is special-cased in
the API-key dialog, everything else falls through to a generic Field.
Typing "EU1" — the region Segment's own UI shows for the EU workspace —
passed the non-empty check, then failed the mutation, and the dialog's
generic error blamed the API key. It is a select now, so the label no
longer has to spell the accepted values out.
An invite that has already been accepted can still be listed, and the
member and the invite were keyed differently (user ID vs email), so the
same person surfaced as two rows — one active with roles, one inactive
without. Invites for an email already seen among members are dropped.
Per-user permission errors now name the user, and the probe URL builds
its query with url.Values rather than a hand-written string.
The region-to-host mapping is the only API-key setting that derives a
value instead of storing input verbatim, and it had no test; a typo in
either host would only have surfaced as a live 404.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
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>
The four connectors landed with the ISC header. contrib/claude/license.md
mandates MIT for every source file, and these were the only ISC files
left in pkg/accessreview/drivers and pkg/connector/provider — the two
"Make license declarations consistently MIT" commits had already cleared
both packages, so this re-opened a closed cleanup.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
ExternalID is normally a stable provider-side ID, not an email. GA4 access
bindings identify a user only by email — no per-user ID and no display name
are exposed — so email is the only stable key available. Document that on
googleAnalyticsRecords so the choice reads as deliberate.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
The Square, Segment and Google Analytics drivers all wrap their transport
with retryRoundTripper for flaky 5xx responses, but the Dotfile driver used
the client directly. Wrap it the same way so all four connectors handle
transient upstream failures consistently.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
ListGoogleAnalyticsOrganizations decoded the response body into the success
struct before inspecting the HTTP status, unlike every other lister in the
file. Check the status first so a non-2xx no longer wastes a decode against
an error body and the ordering matches the sibling functions.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
Segment's /users API exposes no active/suspended field, so reporting every
confirmed member as Active=true fabricated a status the source never
provides, contrary to the AccountRecord contract (nil = no explicit signal).
Leave Active nil for confirmed members; pending invites keep Active=false,
which is a real signal from /invites.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
listProperties filtered properties with parent:accounts/{id}, which returns
only properties whose direct parent is the account and silently drops
subproperties and roll-up properties (parented to another property). A member
holding a binding only on such a subproperty was omitted from the review.
Switch to the ancestor:accounts/{id} filter, which walks the whole account
hierarchy and is a strict superset, so no property is lost.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
Cover the 2xx business/display-name path, the terminal non-2xx branches
(401/403/404/500 keep the generic source name), the Square-Version header,
and Google Analytics' empty-account-id short-circuit, matching the existing
resolver tests.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
The shared VCR BeforeSave hook scrubs every other header-auth provider's key
(X-Api-Key, Api-Key, Signoz-Api-Key, X-Auth-Token) but was not updated for
Dotfile, so re-recording testdata/dotfile.yaml with a real X-DOTFILE-API-KEY
would persist the key into the committed cassette. Delete the canonicalized
X-Dotfile-Api-Key header alongside the others.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
The GA4 driver passed the account and property IDs to url.JoinPath as raw
segments, and the Segment driver built its per-user endpoint by
concatenating the user ID into url.URL.Path — both bypass the url.PathEscape
rule that every sibling driver (and the matching name resolvers) already
follow. The IDs are numeric today so there is no behaviour change, but this
keeps the drivers consistent and safe if a provider ever returns a segment
with a reserved character.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
Two OAuth2 and two API-key connectors:
- Google Analytics (GA4): OAuth2 with both analytics.readonly and
analytics.manage.users.readonly (readonly alone 403s on the accounts
list); v1alpha accessBindings enumerated at account and property level
and merged by email; manual account picker (Pattern 1) with a
per-connection probe and name resolver; distinct from Google Workspace.
- Dotfile: API key in the X-DOTFILE-API-KEY header (Pattern 3); GET
/v1/users (owner/admin, suspended_at) with a static probe.
- Segment (Twilio): Public API token as Bearer with a required Region
setting (US or EU) mapped to the regional host; GET /users plus per-user
GET /users/{id} for roles and /invites for pending members; per-connection
BuildProbeURL.
- Square: OAuth2 (EMPLOYEES_READ) or a personal access token (Pattern 3);
POST /v2/team-members/search returns email/status/is_owner directly, so no
role resolution; custom probe and name resolver.
Google Analytics and Square are confidential OAuth clients, wired into the
bootstrap OAuth provider list and .env.example. Segment carries a required
extra setting, so the console add-source dialog maps region onto its
segmentRegion API-key input; without that mapping the value is silently
dropped and the create is rejected.
Cassette-backed driver tests plus unit tests for the Segment probe URL and
the bootstrap OAuth provider list.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
All SAML assertion failures now share authentication_failed, so
the dedicated mapper and its tests are no longer needed.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
SAML refusals are shown as generic authentication_failed, so log
them at Error for investigation and drop the unused SAML error
code mapper.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Expected refusals already have a dedicated auth error page, so
warn logs just add noise. Keep Error logging for generic
authentication failures that still need investigation.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
TenantID is an [8]byte, so casting 1 does not compile. Use
NewTenantID like the other tests.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Specific SAML refusal reasons leak organization and account state.
Still redirect to the shared auth error page, but always use
authentication_failed while logging the real cause server-side.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Missing, expired, or reused OIDC state left users on a JSON error
or a generic failure. Map those cases to invalid_state so they get
a clear prompt to restart sign-in.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Browser auth callbacks still returned JSON or used one-off pages
for several refusal reasons. Route OIDC email verification,
magic-link, and SAML ACS failures through /auth/error with stable
error codes so users always see an explanation.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
The check order is clear from the code and covered by tests.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
A dedicated personal-account page does not scale as more refusal
reasons appear. Redirect the OIDC callback to /auth/error with an
error code query parameter so the frontend can map codes to clear
user-facing messages.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Personal Google and Microsoft accounts were rejected with a raw
JSON unauthorized response after the OIDC callback. Redirect to a
dedicated auth page that explains the enterprise-account
requirement, and check enterprise eligibility before xms_edov so
Microsoft consumer accounts get the same clear error.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
go fix on Go 1.26 rewrites strings.Split range loops to
SplitSeq; apply that so lint-go passes.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
A single dnsExchangeTimeout around CheckCAA let slow empty
answers at child names consume the budget before parent
policy was queried. Give each label its own exchange timeout
inside the climb instead.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Keep the previous per-lookup dnsExchangeTimeout around CheckCAA
so rebased dnsclient calls do not drop main's deadline.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Extract shared DNS checks into dnsclient and fail closed on
truncated or non-success CAA responses. Climb past eTLD+1,
validate RFC 8659 issue-value syntax, and map NXDOMAIN TXT
lookups to the pending-verification path.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
The check read only AutomaticCheckEnabled from /Library/Preferences,
falling back to softwareupdate --schedule when that key was absent.
Both describe automatic checking alone, so a Mac with downloads or
installs turned off still reported PASS. The key is also absent when
a configuration profile manages it, since the value then lives in
/Library/Managed Preferences, a layer never consulted.
Read the five Software Update preferences backing the System Settings
toggles, resolving each from the managed layer before the system one.
macOS treats them as enabled when unset, so only an explicit disabled
value fails. The now-unused softwareupdate binary leaves the command
allowlist.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
The macOS URL-handler bundle had no CFBundleIconFile, so
Finder showed the generic app icon after PKG install. Keep a
single master PNG and generate AppIcon.icns at build time
with sips/iconutil, matching the auditor-mode approach.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
Custom-domain CNAME and SAML TXT checks accepted answers without
confirming the record owner matched the hostname being verified, so
an apex record could satisfy verification for a subdomain. CAA checks
also looked only at the exact hostname and ignored parent policy.
Add dnsverify helpers to compare owner names and walk from the
requested hostname up to the registrable apex for CAA (RFC 6844).
Require matching record owners for CNAME and TXT answers.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
When category was omitted from an MCP, GraphQL, or CLI update payload,
ThirdPartyService.Update overwrote the stored category with OTHER. Only
apply category when the request explicitly includes it, matching other
optional fields on the same update path.
Add an MCP e2e test that updates name without category and asserts the
existing category is unchanged.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Start-Process failures left $p null, then exit $null made
PowerShell return 0, so install/uninstall looked successful.
Make launch errors terminating and exit nonzero before
reading ExitCode.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
viewer.enrolledDevices shared DeviceConnection with the admin
org list, so totalCount had to authorize with both
employee-device:list and device:list. Mirror
EmployeeDocumentConnection: a dedicated EmployeeDeviceConnection
without totalCount, and keep DeviceConnection.totalCount for
the org fleet only. Cover assumed-session device:get IDOR in e2e.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
Browser enrollment used osascript on every elevate. Ship a signed
privileged helper installed at PKG time so probo:// can enroll over
XPC with no second admin prompt. Add make install/uninstall/clean for
local PKG test loops, and show alerts only on failure.
Mirror the Go lint path for the macOS SPM package: Make
targets, root configs, and a Linux CI job. Keep checks
syntax-only so they do not need a macOS SDK. Format the
existing sources so the new gates start clean.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
Browser enrollment succeeds once the device is ACTIVE, but the
macOS URL handler failed whenever install re-bootstrapped a tray
LaunchAgent the PKG had already installed. Skip registration when
the plist is current, treat live bootstrap as best-effort, and
exit successfully if the device is already enrolled so retries
do not show "Enrollment failed".
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
The /enroll wait UI polled device state via node(), which
requires an assumed org session, so confirmation never
succeeded for unassumed viewers. Expose viewer.enrolledDevice
behind itam:employee-device:get (own-device, skip assumption)
and point the poller at it.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
Publish a notarized arm64+x86_64 .pkg from CI with the CGO tray
binary, Probo Agent.app, and global LaunchAgent. Keep the
LaunchDaemon enrollment-gated, align its plist path with the
launchd label, and document the Apple signing secrets.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
Replace the placeholder orange-circle tray icon with the monochrome
Probo logo used in auditor-mode. On macOS, show the icon only (no
menu bar title); keep the title on Windows.
Restructure the context menu: status rows with colored indicators,
an Enroll via… submenu for region selection, and a clearer About
label. Surface enrollment errors in native dialogs instead of stderr.
Fix self-hosted enrollment on macOS by reading the hostname from
osascript's returned value; the default dialog output order broke
URL parsing and silently skipped opening the browser.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
Concurrent enroll-url launches could both pass the enrollment
marker check and run overlapping elevated installs, racing on
LoadOrExchangeAPIKey and overwriting agent.key.
Add an exclusive flock on {configDir}/enrolling.lock for the
full install path and re-check IsEnrolled under that lock so
only one install exchanges a token and configures the device.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
The tray helper carried a ServerURL default that nothing read.
Unenrolled users can now open the console /enroll page from the
menu: US, EU, or self-hosted in production, or --server for dev.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Provide enrollment, elevated install, posture checks, keystore, and
system-tray helpers shared by the probo-agent binary.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
Introduce device, posture, and enrollment-token entities with ITAM
service policies for agent-managed fleet inventory.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
The access review Add Source dialog listed each connector with no
path to its setup documentation. Connectors that have a published
docs page on probo.com now surface a "Documentation" link on the
card, opening the page in a new tab; connectors without a page show
nothing extra.
The link is data-driven from the connector registry: a new
DocumentationURL on the provider Registration, populated for the 12
documented providers via a single accessReviewDocsURL helper, is
surfaced as a nullable documentationUrl on ConnectorProviderInfo and
rendered by the console only when present. This keeps the registry
the single source of truth and adds no client-side provider map.
The links resolve once the probo.com access-review docs pages are
deployed; until then they 404, so deploy the docs alongside this
change.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>