Skip to content

orb(github): brokered tokens are cached under the caller's installationId even when the broker minted them for a different install, and the 'maintain' permission tier is unreachable #9152

Description

@JSONbored

Problem

Two identity/permission defects in the GitHub client layer.

1. In broker mode the brokered token is cached under the CALLER's installationId

src/github/app.ts:241-260:

const brokered = await fetchBrokeredInstallationToken(env, fetch, { forceRefresh });
await writeCachedToken(installationId, { token: brokered.token, expiresAtMs: brokered.expiresAtMs });
if (brokered.installationId === installationId && Object.keys(brokered.permissions).length > 0) {  }

The permissions write is guarded on brokered.installationId === installationId — the author
explicitly anticipated divergence — but the cache write and the return value are not.
fetchBrokeredInstallationToken also defaults a missing installationId to 0
(src/orb/broker-client.ts:87), so a broker response that omits the field always diverges.

Trigger: a stale repositories.installation_id row (the maintainer uninstalled and reinstalled the App —
GitHub issues a new installation id, and installed-repos-sync never rewrites the old rows) drives
createInstallationToken(env, oldId). The brokered token for the current install is cached under
oldId and returned. In a hosted/multi-tenant broker, the same code caches a token minted for tenant B
under tenant A's key.

Today (single-install self-host) this fails benignly; it is a latent cross-tenant token-serving bug that
the surrounding code already treats as possible.

Fix: cache under brokered.installationId, and throw when it is non-zero and differs from the
requested id rather than silently returning another install's token.

2. The "maintain" permission tier is unreachable

getRepositoryCollaboratorPermission returns payload.permission from
GET /repos/{o}/{r}/collaborators/{u}/permission, whose permission field is only ever
admin | write | read | none — Maintain collapses to write, Triage to read. The granular value lives
in role_name, which this code never requests or reads.

So permission === "maintain" is dead in both consumers:

  • isPerTenantAdmin (src/auth/security.ts:198): return permission === "admin" || permission === "maintain";
    → a repo Maintain-role user is denied per-repo admin trust in hosted mode.
  • resolveRealRepoPermissionAssociation (src/queue/processors.ts:13512):
    if (permission === "admin" || permission === "maintain") return "MEMBER"; → a Maintain-role user is
    downgraded to COLLABORATOR, which denies them generate-tests (the one ["maintainer"]-only command).

The union type at src/github/app.ts:431-438 listing "maintain" | "triage" shows the author assumed
role_name semantics.

This fails closed (no privilege escalation), but a documented trust tier silently never activates —
worth fixing before anyone relies on it in hosted per-repo-admin mode.

Fix: request and match on role_name, or delete the dead "maintain"/"triage" arms from the union
and both comparisons so the code stops implying a tier it cannot see.

Test Coverage Requirements

99%+ patch coverage, branch-counted. For (1): both arms of the id-match check including the 0 default.
For (2): a Maintain-role fixture resolving to the intended tier.

Links & Resources

  • src/github/app.ts ~241-260, ~431-438; src/orb/broker-client.ts ~87; src/auth/security.ts ~198;
    src/queue/processors.ts ~13512

maintainer-only — credential and permission correctness.

Metadata

Metadata

Assignees

Labels

maintainer-onlyOwner-only work — yields no Gittensor points.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions