diff --git a/src/services/control-panel-roles.ts b/src/services/control-panel-roles.ts index e143cfe5c5..a793860791 100644 --- a/src/services/control-panel-roles.ts +++ b/src/services/control-panel-roles.ts @@ -74,7 +74,16 @@ export function buildControlPanelAccessScope(args: RoleSummaryInputs): ControlPa const installedRepos = args.repositories.filter((repo) => repo.isInstalled); const accountInstallations = args.installations.filter((installation) => !installation.suspendedAt && sameLogin(installation.accountLogin, args.login)); const accountInstallationIds = new Set(accountInstallations.map((installation) => installation.id)); - const ownedInstalledRepos = installedRepos.filter((repo) => sameLogin(repo.owner, args.login) || (repo.installationId !== undefined && repo.installationId !== null && accountInstallationIds.has(repo.installationId))); + // A suspended installation revokes the App's access, so a repo under a suspended account installation must not grant + // control-panel scope — including via the owner-match branch, which previously ignored suspendedAt (#953). + const suspendedAccountInstallationIds = new Set( + args.installations.filter((installation) => installation.suspendedAt && sameLogin(installation.accountLogin, args.login)).map((installation) => installation.id), + ); + const ownedInstalledRepos = installedRepos.filter( + (repo) => + !(repo.installationId !== undefined && repo.installationId !== null && suspendedAccountInstallationIds.has(repo.installationId)) && + (sameLogin(repo.owner, args.login) || (repo.installationId !== undefined && repo.installationId !== null && accountInstallationIds.has(repo.installationId))), + ); const maintainerRepos = uniqueRepoNames( args.pullRequests .filter((pull) => sameLogin(pull.authorLogin, args.login) && isMaintainerAssociation(pull.authorAssociation)) @@ -101,7 +110,16 @@ export function buildControlPanelRoleSummary(args: RoleSummaryInputs): ControlPa const installedRepos = args.repositories.filter((repo) => repo.isInstalled); const accountInstallations = args.installations.filter((installation) => !installation.suspendedAt && sameLogin(installation.accountLogin, args.login)); const accountInstallationIds = new Set(accountInstallations.map((installation) => installation.id)); - const ownedInstalledRepos = installedRepos.filter((repo) => sameLogin(repo.owner, args.login) || (repo.installationId !== undefined && repo.installationId !== null && accountInstallationIds.has(repo.installationId))); + // A suspended installation revokes the App's access, so a repo under a suspended account installation must not grant + // control-panel scope — including via the owner-match branch, which previously ignored suspendedAt (#953). + const suspendedAccountInstallationIds = new Set( + args.installations.filter((installation) => installation.suspendedAt && sameLogin(installation.accountLogin, args.login)).map((installation) => installation.id), + ); + const ownedInstalledRepos = installedRepos.filter( + (repo) => + !(repo.installationId !== undefined && repo.installationId !== null && suspendedAccountInstallationIds.has(repo.installationId)) && + (sameLogin(repo.owner, args.login) || (repo.installationId !== undefined && repo.installationId !== null && accountInstallationIds.has(repo.installationId))), + ); const maintainerRepos = uniqueRepos( args.pullRequests .filter((pull) => sameLogin(pull.authorLogin, args.login) && isMaintainerAssociation(pull.authorAssociation)) diff --git a/test/unit/control-panel-roles.test.ts b/test/unit/control-panel-roles.test.ts index fb3bdcbd1d..8bca463095 100644 --- a/test/unit/control-panel-roles.test.ts +++ b/test/unit/control-panel-roles.test.ts @@ -112,6 +112,35 @@ describe("control panel role summaries", () => { }); }); + it("revokes owner-match control-panel scope for a repo under a suspended account installation (#953)", () => { + const scope = buildControlPanelAccessScope({ + login: "repo-owner", + generatedAt: "2026-06-01T12:00:00.000Z", + confirmedMiner: false, + operator: false, + repositories: [repo("repo-owner/owned-repo", "repo-owner", 21)], + installations: [{ ...installation(21, "repo-owner"), suspendedAt: "2026-06-10T00:00:00.000Z" }], + pullRequests: [], + }); + + expect(scope).toMatchObject({ operator: false, repositoryFullNames: [], installationIds: [], accountLogins: [] }); + }); + + it("drops owner and maintainer roles when the owner's installation is suspended (#953)", () => { + const summary = buildControlPanelRoleSummary({ + login: "repo-owner", + generatedAt: "2026-06-01T12:00:00.000Z", + confirmedMiner: false, + operator: false, + repositories: [repo("repo-owner/owned-repo", "repo-owner", 21)], + installations: [{ ...installation(21, "repo-owner"), suspendedAt: "2026-06-10T00:00:00.000Z" }], + pullRequests: [], + }); + + expect(summary.roles).toEqual([]); + expect(summary.evidence).toMatchObject({ ownedInstalledRepos: 0, accountInstallations: 0 }); + }); + it("includes installed maintainer repositories from cached PR evidence", () => { const scope = buildControlPanelAccessScope({ login: "maintainer",