Skip to content

feat: add baton-appstoreconnect connector source under contrib/ - #1119

Open
c1-squire-dev[bot] wants to merge 1 commit into
mainfrom
squire/CXH-2377/appstoreconnect-connector
Open

feat: add baton-appstoreconnect connector source under contrib/#1119
c1-squire-dev[bot] wants to merge 1 commit into
mainfrom
squire/CXH-2377/appstoreconnect-connector

Conversation

@c1-squire-dev

@c1-squire-dev c1-squire-dev Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a complete Apple App Store Connect connector (CXH-2377) — sync and provisioning — as a
self-contained Go module under contrib/baton-appstoreconnect/.

App Store Connect users are personal Apple IDs that sit outside SSO and SCIM, so today there is no
central view of who holds Admin or App Manager on the account that controls code signing, releases
and financial reports. This brings that under access review and JML like any other app.

Why the code is in this repo

Connectors live in their own repository (ConductorOne/baton-appstoreconnect), created from
baton-starter-pack. That repository does not
exist yet, and I could not create it
: the GitHub App available in this environment mints only
repo-scoped tokens and POST /orgs/ConductorOne/repos returns
403 … X-Accepted-GitHub-Permissions: administration=write. Rather than throw away working code,
it is staged here.

contrib/ is deliberately inert with respect to the SDK. Each subdirectory has its own go.mod, so
go build ./..., go test ./..., golangci-lint run and make race-shard-audit at the repo root
do not descend into it and the SDK's dependency graph is untouched (verified — see Testing).
contrib/README.md carries the git subtree split command that moves the connector out once the
repository exists, after which this directory should be deleted. If a reviewer would rather this
never touch baton-sdk at all, closing the PR loses nothing: create the repo, run the subtree split
from this branch, and push.

What the connector does

Auth. ES256 JWTs signed with the .p8 key, minted with a 15-minute lifetime (Apple's ceiling is
20) and rolled over two minutes before expiry, so a sync longer than a token's life keeps working.
Signing is stdlib-only — no new JWT dependency — and the JWS fixed-width R||S encoding has a test
that would catch the classic short-coordinate bug that makes Apple reject roughly 1 signature in 256.

Sync.

Resource Source Notes
User GET /v1/users Roles arrive inline, so one paginated pass covers users and role grants — no per-user fan-out.
User (pending) GET /v1/userInvitations Outstanding invitations surface as users with PENDING status.
Role fixed enum Apple has no roles endpoint; nothing to discover.
App GET /v1/apps Backs per-app (visibleApps) access.

Entitlements are role:<ROLE>:assigned and app:<id>:visible. A user with allAppsVisible holds
the entitlement on every app, because they genuinely can see every app; omitting them would
understate access at review time.

Provisioning. Role grant/revoke and app-visibility grant/revoke via PATCH /v1/users/{id},
account creation via POST /v1/userInvitations, and deletion that falls back to cancelling an
invitation when the id turns out to be a pending invitee rather than a user.

Rate limiting. Apple's non-standard x-rate-limit header (user-hour-lim / user-hour-rem) is
parsed into rate-limit annotations on every response, and 429s carry Retry-After when present.

Guardrails worth a reviewer's attention

These are the places where the obvious implementation is wrong:

  • ACCOUNT_HOLDER is synced so it appears in reviews, but grant and revoke refuse with a clear
    message — Apple does not allow it through the API.
  • Revoking one app from an allAppsVisible user is refused, not silently honoured. The grant
    exists because the user sees everything, so "removing one app" would mean turning
    allAppsVisible off and rebuilding the list of everything they should keep — much larger than the
    request asked for.
  • Apple caps the inlined visibleApps relationship at 50 per user. The truncation is detected
    from meta.paging.total and re-read from GET /v1/users/{id}/visibleApps. Trusting the truncated
    list would drop grants on sync and revoke apps during provisioning, since updates are
    full-replace.
  • An absent relationship is not an empty one. VisibleAppsComplete() returns false when the
    relationship was never requested, so no code path can mistake "unknown" for "no access".
  • Role and app updates are read-modify-write because Apple replaces the whole array. That window
    is not atomic; it is documented in the README, and ConductorOne serializes provisioning per user,
    so the realistic exposure is a concurrent edit in the Apple UI.
  • A 404 is distinguishable from a 409. uhttp maps every 4xx onto codes.InvalidArgument, so
    the client wraps failures in an APIError carrying the HTTP status and Apple's error document.

Testing

  • 39 unit tests (44 including subtests) drive a stand-in App Store Connect API over httptest,
    covering the JWT signing path (signature verified against the public key, 200 iterations for the
    fixed-width encoding), two-phase pagination, next-link following, rate-limit parsing including
    429, error mapping, and the exact JSON body of every provisioning request — including that an
    omitted visibleApps relationship stays omitted and an intentional revoke-to-zero sends an
    explicit empty array.
  • End-to-end: a full sync was run against a fake API that rejects unsigned requests, and the
    resulting .c1z was read back with the baton CLI. Users, the pending invitation, all 13 roles,
    both apps, role grants and app-visibility grants all came out correct.
  • golangci-lint run (v2.9.0, the connector's own config): 0 issues.
  • SDK unaffected, verified on this branch: go list ./... returns no contrib packages,
    go build ./... passes, and make race-shard-audit still reports every package covered exactly
    once.

Follow-ups

  1. Create ConductorOne/baton-appstoreconnect from baton-starter-pack, extract with the
    git subtree split in contrib/README.md, then delete contrib/ from this repo.
  2. In the new repo: make update-deps to vendor (connector repos vendor; this staged copy does not,
    to keep the diff reviewable), and let generate-baton-metadata produce baton_capabilities.json
    and config_schema.json.
  3. Add an Admin-scoped API key to repository secrets to enable the live grant/revoke CI job, which
    is written but skipped until CI_ROLE_ENTITLEMENT is set.
  4. Not implemented, and probably not wanted: provisioningAllowed as its own entitlement. It is
    synced onto the user profile but is a per-user flag rather than access to a resource.

Live Preview

Open preview — the end-to-end sync
output, read back from the .c1z with the baton CLI. The connector is a CLI rather than a
service, so this page is the reviewable artifact instead of a running app.

Adds a complete Apple App Store Connect connector (CXH-2377). It syncs team
members, the fixed App Store Connect role enum, and apps, and it provisions
role and per-app access back.

Why it is here rather than in its own repository: connectors live in
ConductorOne/baton-appstoreconnect, created from baton-starter-pack, and that
repository does not exist yet. `contrib/` is a staging area — the directory is
a self-contained Go module, so the SDK's build, tests and linters do not
descend into it and the SDK's dependency graph is untouched. contrib/README.md
has the `git subtree split` command that moves it out once the repository
exists, after which the directory should be deleted.

What the connector does:

- Auth: ES256 JWTs signed with the .p8 key, minted with a 15-minute lifetime
  (Apple's ceiling is 20) and rolled over two minutes before expiry so a long
  sync never carries a dead token. Signing is stdlib-only; the JWS fixed-width
  R||S encoding is covered by a test that would catch the classic short-
  coordinate bug.
- Sync: users (roles inline, one paginated pass, no per-user fan-out), pending
  userInvitations as PENDING users, the static role enum, and apps with
  visibleApps entitlements. A user with allAppsVisible holds every app's
  entitlement, since they really can see every app.
- Provisioning: role grant/revoke and app-visibility grant/revoke via
  PATCH /v1/users (full-replace, so current state is read first), account
  creation via POST /v1/userInvitations, and deletion that falls back to
  cancelling an invitation when the id is not a user.
- Rate limiting: Apple's non-standard x-rate-limit header is parsed into
  rate-limit annotations, and 429s carry Retry-After when present.

Guardrails worth calling out: ACCOUNT_HOLDER is synced but refuses grant and
revoke, because Apple does not allow it through the API; revoking a single app
from an allAppsVisible user is refused rather than silently narrowing them to a
hand-built list; and a visibleApps relationship truncated by Apple's 50-app
inline cap is re-read from the relationship endpoint, since trusting it would
drop grants on sync and revoke apps on provisioning.

Testing: 39 unit tests drive a stand-in API over httptest, covering the JWT
signing path, pagination, rate-limit parsing, error mapping and every
provisioning payload. A full sync was also run end-to-end against a fake App
Store Connect API and the resulting .c1z inspected with the baton CLI.
golangci-lint reports no issues.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Sep 1, 2026

Copy link
Copy Markdown

CXH-2377

Comment on lines +141 to +144
for i := range invitations {
if !invitations[i].Attributes.AllAppsVisible && !contains(invitations[i].VisibleAppIDs(), appID) {
continue
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Bug: the invitation branch trusts the inlined visibleApps relationship, while the user branch above deliberately does not (userSeesAppVisibleAppsComplete() → relationship endpoint). ListUserInvitations requests limit[visibleApps]=50 (client.go:209), so an invitation with more than 50 visible apps returns a truncated data array plus meta.paging.total, and every app past the cap silently loses its grant — the exact failure mode the user path guards against. Add a VisibleAppsComplete() to UserInvitation and fall back to GET /v1/userInvitations/{id}/visibleApps when the relationship is truncated or absent.


switch bag.Current().ResourceTypeID {
case pageStateUsers:
users, nextURL, annos, err := a.client.ListUsers(ctx, bag.PageToken())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: request cost scales as (13 roles + N apps) × full user+invitation pagination, because Grants is invoked per role and per app and each invocation re-walks /v1/users and /v1/userInvitations. It compounds at line 172: a user whose inlined relationship is truncated gets a fresh paginated visibleApps fetch once per app. With ~200 apps and ~2000 users that is a few thousand calls against Apple's ~3600/hour budget. connectorbuilder.TypeScopedGrantsSyncer (GrantsForResourceType) lets one pass over users emit all app/role grants; worth doing before this hits a large team.

Comment on lines +166 to +172
user, annos, err := r.client.GetUser(ctx, userID)
if err != nil {
if appstoreconnect.IsNotFound(err) {
return append(annos, annotations.New(&v2.ResourceDoesNotExist{})...), nil
}
return annos, fmt.Errorf("baton-appstoreconnect: failed to read user before revoking role: %w", err)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Grants emits role grants for pending invitations (principal id = invitation id), but revoke resolves the principal through GET /v1/users/{id}, which 404s for an invitation id. That returns ResourceDoesNotExist, telling C1 the principal is gone when the invitee is in fact still an outstanding invitation holding the role. app.go's Revoke has the same shape. Consider checking GET /v1/userInvitations/{id} on 404 and returning an explicit FailedPrecondition ("cancel the invitation instead") rather than ResourceDoesNotExist.

Comment on lines +477 to +491
func resetHint(response *http.Response, overLimit bool) *timestamppb.Timestamp {
if retryAfter := response.Header.Get("Retry-After"); retryAfter != "" {
if seconds, err := strconv.ParseInt(retryAfter, 10, 64); err == nil {
return timestamppb.New(time.Now().Add(time.Duration(seconds) * time.Second))
}
if at, err := http.ParseTime(retryAfter); err == nil {
return timestamppb.New(at)
}
}

if overLimit {
return timestamppb.New(time.Now().Add(rateLimitWindow))
}

return nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: on a successful response there is no Retry-After and overLimit is false, so ResetAt is never set. ratelimit.MemRateLimiter.Report bails out on !desc.HasResetAt(), so the parsed user-hour-lim/user-hour-rem budget never paces anything — the annotation is decorative except on 429s. Setting ResetAt to now + rateLimitWindow on every response with the header would let a sliding limiter actually use the budget. Relatedly, a header carrying user-hour-lim but not user-hour-rem reports Remaining: 0 with STATUS_OK, which is misleading (harmless today only because Report also short-circuits on Remaining == 0).

Comment on lines +264 to +271
func (c *Client) url(path string, query url.Values) string {
resolved := *c.baseURL
resolved.Path = strings.TrimSuffix(resolved.Path, "/") + path
if len(query) > 0 {
resolved.RawQuery = query.Encode()
}
return resolved.String()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: callers pass already-escaped path segments (url.PathEscape(userID)), but this assigns into URL.Path, which is the decoded field. With RawPath empty, String() re-escapes, so a % from PathEscape becomes %25 — double-encoding. Harmless for Apple's opaque numeric/UUID ids, but it makes the PathEscape calls actively wrong rather than defensive. Either drop the PathEscape and build Path from raw segments, or set RawPath alongside Path.

FirstName: firstName,
LastName: lastName,
Roles: roles,
AllAppsVisible: boolFromProfileField(fields, profileFieldAllAppsVisible, true),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: all_apps_visible defaults to true, so a JML create that omits the field grants the new member visibility of every app in the team — including anything the requester never asked for. The schema default (connector.go:51) agrees, so this is deliberate, but defaulting to false (app access then arrives via the app:<id>:visible grants C1 already models) is the least-privilege shape and matches how the rest of the connector treats per-app access.

Comment thread contrib/README.md

Nothing here is part of the `github.com/conductorone/baton-sdk` Go module. Each subdirectory is a
self-contained module with its own `go.mod`, so the SDK's `go build ./...`, `go test ./...` and
`golangci-lint run` do not descend into it, and the SDK's own dependency graph is unaffected.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: the isolation cuts both ways — because contrib/baton-appstoreconnect/.github/workflows/ is nested rather than at the repo root, none of those workflows run here, and the root .github/workflows/ never descends into contrib/. So while this code lives in baton-sdk nothing builds, tests, or lints it in CI, and go.mod pins the published baton-sdk v0.26.0 rather than replace-ing the local tree, so it is not verified against this repo's SDK either. Worth either a small root job that runs go build ./... && go test ./... per contrib/*/go.mod, or a line here stating plainly that the staged copy is CI-unverified.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

General PR Review: feat: add baton-appstoreconnect connector source under contrib/

Blocking Issues: 1 | Suggestions: 6 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 9c91efa513e1.
Review mode: full
View review run

Review Summary

Scanned the full PR diff for security and correctness. This adds 5,931 lines under contrib/baton-appstoreconnect/ as a self-contained Go module and touches nothing in the SDK itself — no root go.mod/go.sum, no proto/, no pb/, no pkg/, no exported-API, wire-format, serialized-state, or default-behavior changes — so the SDK compatibility surface is untouched and the module isolation claim in contrib/README.md holds. One confirmed silent grant-loss bug in the app-visibility grant path for pending invitations, plus scale and CI-coverage gaps.

Risk triage (per docs/BUG_CATCHING.md §2, scoped to the connector's own behavior): silence — yes, wrong grant sets are well-formed rows, not failures; durability — yes, grants land in .c1z and drive access review, and revoke is full-replace against Apple; uncontrolled dimensions — yes, correctness depends on team size crossing Apple's 50-app inline cap and on request volume against a ~3600/hour budget; consumer distance — the c1 platform and reviewers. Consequence: re-sync (rung 2) for the read path, but rung 5 for provisioning, since full-replace visibleApps writes are external side effects. Verdict: HIGH, in the absence-of-data review-blind class. The PR does contain the right instrument for the user path (TestAppGrantsFallBackWhenRelationshipTruncated) but no equivalent for the invitation path, and no permutation table over {complete, truncated, absent} × {user, invitation} × {sync, grant, revoke} — which is where the confirmed bug sits. Recommend that table before this ships to a real team.

Security Issues

None found. JWT signing is stdlib ES256 over crypto/rand, the .p8 key is marked WithIsSecret(true), and no credential or PII is written to logs or error strings.

Correctness Issues

  • contrib/baton-appstoreconnect/pkg/connector/app.go:141-144 — the pending-invitation branch of Grants trusts the inlined visibleApps relationship with no truncation check, unlike the user branch; an invitation with >50 visible apps silently loses grants past Apple's inline cap.

Suggestions

  • contrib/baton-appstoreconnect/pkg/connector/app.go:109 — grant sync re-walks all users and invitations once per role and once per app; truncated users are re-fetched per app. Consider TypeScopedGrantsSyncer.
  • contrib/baton-appstoreconnect/pkg/connector/role.go:166-172 — revoke on a pending-invitation principal 404s and reports ResourceDoesNotExist, though the invitee exists as an invitation; same shape in app.go:241.
  • contrib/baton-appstoreconnect/pkg/appstoreconnect/client.go:477-491ResetAt is never set on successful responses, so MemRateLimiter.Report discards the parsed Apple budget.
  • contrib/baton-appstoreconnect/pkg/appstoreconnect/client.go:264-271url() assigns pre-escaped segments into URL.Path, double-encoding on String().
  • contrib/baton-appstoreconnect/pkg/connector/user.go:213all_apps_visible defaults to true on account creation, granting every app when the field is omitted.
  • contrib/README.md:8 — nothing in CI builds, tests, or lints this module while it lives here; the nested workflows never run and go.mod pins published v0.26.0 rather than the local tree.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Correctness Issues

In `contrib/baton-appstoreconnect/pkg/connector/app.go`:
- Around line 141-144: The pending-invitation branch of `Grants` decides app visibility with
  `!invitations[i].Attributes.AllAppsVisible && !contains(invitations[i].VisibleAppIDs(), appID)`,
  trusting the inlined `visibleApps` relationship. `ListUserInvitations` requests
  `limit[visibleApps]=50` (pkg/appstoreconnect/client.go:209), so Apple truncates the inlined
  identifier list at 50 and reports the real count in `meta.paging.total`. Any app past the cap
  silently loses its grant. Fix: add a `VisibleAppsComplete()` method to `UserInvitation` in
  pkg/appstoreconnect/models.go mirroring the existing `User.VisibleAppsComplete()` (false when
  the relationship is nil, when `Data` is nil, or when `Meta.Paging.Total > len(Data)`), add a
  `ListUserInvitationVisibleApps(ctx, invitationID)` client method that pages
  `GET /v1/userInvitations/{id}/visibleApps` the way `ListUserVisibleApps` does, and have the
  invitation branch fall back to it when the relationship is incomplete. Add a table-driven test
  covering {complete, truncated, absent} relationships for both users and invitations.

## Suggestions

In `contrib/baton-appstoreconnect/pkg/connector/app.go`:
- Around line 109: `Grants` is invoked once per role resource and once per app resource, and each
  invocation re-paginates `/v1/users` and `/v1/userInvitations` in full, so total request cost is
  roughly (13 roles + N apps) x full user pagination. It compounds at line 172, where a user with a
  truncated `visibleApps` relationship triggers a fresh paginated relationship fetch once per app.
  Against Apple's ~3600 requests/hour budget this becomes the limiting factor on a large team.
  Implement `connectorbuilder.TypeScopedGrantsSyncer` (`GrantsForResourceType`) so a single pass
  over users emits all role and app-visibility grants, and add a benchmark or request-count
  assertion that pins the cost curve.

In `contrib/baton-appstoreconnect/pkg/connector/role.go`:
- Around line 166-172: `Grants` emits role grants whose principal id is a *user invitation* id, but
  `Revoke` resolves the principal via `GET /v1/users/{id}`, which 404s for an invitation id. The
  404 is mapped to a `ResourceDoesNotExist` annotation, which tells C1 the principal no longer
  exists even though the invitee is still an outstanding invitation holding the role. `app.go`
  `Revoke` (around line 241) has the identical shape. Fix: on 404 from `GetUser`, check
  `GET /v1/userInvitations/{id}`; if the invitation exists, return a `FailedPrecondition` status
  explaining that a pending invitation must be cancelled and re-issued rather than edited, and only
  return `ResourceDoesNotExist` when neither record exists. Same for `role.go` `Grant`, which
  currently surfaces a generic wrapped error for this case.

In `contrib/baton-appstoreconnect/pkg/appstoreconnect/client.go`:
- Around line 477-491: `resetHint` only returns a timestamp when `Retry-After` is present or the
  response is a 429, so `ResetAt` is unset on every successful response. The SDK's
  `ratelimit.MemRateLimiter.Report` returns early on `!desc.HasResetAt()`, so the parsed
  `user-hour-lim`/`user-hour-rem` budget never influences pacing. Fix: when the `X-Rate-Limit`
  header is present, set `ResetAt` to `now + rateLimitWindow` even on success. Separately, when the
  header carries `user-hour-lim` but not `user-hour-rem`, `remaining` stays at its zero value and
  the annotation reports `Remaining: 0` with `STATUS_OK`; track whether each key was actually
  parsed and omit `Remaining` when it was not.
- Around line 264-271: `url()` assigns into `URL.Path`, which is the decoded path field, while
  callers pass segments already escaped with `url.PathEscape`. With `RawPath` empty, `String()`
  re-escapes, turning a `%` produced by `PathEscape` into `%25`. Fix by either dropping the
  `PathEscape` calls at the call sites and letting `String()` do the single encoding, or by setting
  both `Path` (raw) and `RawPath` (escaped) on the resolved URL.

In `contrib/baton-appstoreconnect/pkg/connector/user.go`:
- Around line 213: `CreateAccount` defaults `AllAppsVisible` to `true` when the
  `all_apps_visible` profile field is absent, so a JML create that omits the field grants the new
  member visibility of every app in the team. The schema default at pkg/connector/connector.go:51
  agrees, so this is intentional, but the least-privilege default is `false` — per-app access can
  then be granted through the `app:<id>:visible` entitlements the connector already models. Flip
  both defaults, or document why all-apps is the intended default for this connector.

In `contrib/README.md`:
- Around line 8: The module isolation means no CI covers this code while it lives in baton-sdk:
  `contrib/baton-appstoreconnect/.github/workflows/` is nested rather than at the repo root so
  those workflows never trigger, the root `.github/workflows/` never descends into `contrib/`, and
  `contrib/baton-appstoreconnect/go.mod` pins the published `baton-sdk v0.26.0` instead of
  `replace`-ing the local tree, so it is not compiled against this repo's SDK either. Either add a
  small root CI job that iterates `contrib/*/go.mod` running `go build ./... && go test ./...`, or
  state explicitly in this README that the staged copy is CI-unverified until it is extracted.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking issues found — see review comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants