Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
useGitHubAccessPrompt,
} from "#/lib/github-access-modal-store";
import { useHasMounted } from "#/lib/use-has-mounted";
import { useRefreshOnReturn } from "#/lib/use-refresh-on-return";

function getExternalLinkProps(href: string) {
if (href.startsWith("http://") || href.startsWith("https://")) {
Expand All @@ -39,6 +40,8 @@ export function GitHubAccessDialog({ userId }: { userId: string }) {
const [showOrgSetup, setShowOrgSetup] = useShowOrgSetupQueryState();

const isOpen = showOrgSetup;
useRefreshOnReturn({ enabled: isOpen });

const accessQuery = useQuery({
queryKey: ["github-app-access-state", userId],
queryFn: () => getGitHubAppAccessState(),
Expand Down
92 changes: 92 additions & 0 deletions apps/dashboard/src/lib/github-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import {
buildGitHubOrganizationInstallationsUrl,
findInstallationForOwner,
type GitHubAppAccessState,
type GitHubInstallationAccessIndex,
getAccessHrefForOwner,
isRepoVisibleWithInstallationAccess,
} from "./github-access";

const state: GitHubAppAccessState = {
Expand Down Expand Up @@ -106,3 +108,93 @@ describe("buildGitHubOrganizationInstallationsUrl", () => {
);
});
});

describe("isRepoVisibleWithInstallationAccess", () => {
const index: GitHubInstallationAccessIndex = {
available: true,
allAccessOwners: new Set(["supabase"]),
selectedRepos: new Set(["adn/private-app", "adn/secret-tool"]),
};

it("always allows public repos regardless of index", () => {
expect(
isRepoVisibleWithInstallationAccess(index, "random-org", "repo", false),
).toBe(true);
});

it("allows private repos from an owner with 'all' access", () => {
expect(
isRepoVisibleWithInstallationAccess(
index,
"supabase",
"private-repo",
true,
),
).toBe(true);
});

it("allows private repos in the selected set", () => {
expect(
isRepoVisibleWithInstallationAccess(index, "adn", "private-app", true),
).toBe(true);
});

it("blocks private repos not in the selected set", () => {
expect(
isRepoVisibleWithInstallationAccess(index, "adn", "other-private", true),
).toBe(false);
});

it("blocks private repos from owners without any installation", () => {
expect(
isRepoVisibleWithInstallationAccess(
index,
"vercel",
"private-repo",
true,
),
).toBe(false);
});

it("fails open when the index is unavailable", () => {
const unavailable: GitHubInstallationAccessIndex = {
available: false,
allAccessOwners: new Set(),
selectedRepos: new Set(),
};
expect(
isRepoVisibleWithInstallationAccess(
unavailable,
"any-org",
"private-repo",
true,
),
).toBe(true);
});

it("treats unknown visibility (null) as potentially private", () => {
expect(
isRepoVisibleWithInstallationAccess(index, "adn", "private-app", null),
).toBe(true);
expect(
isRepoVisibleWithInstallationAccess(index, "adn", "other-private", null),
).toBe(false);
expect(
isRepoVisibleWithInstallationAccess(index, "vercel", "some-repo", null),
).toBe(false);
});

it("is case-insensitive for owner and repo matching", () => {
expect(
isRepoVisibleWithInstallationAccess(
index,
"Supabase",
"Private-Repo",
true,
),
).toBe(true);
expect(
isRepoVisibleWithInstallationAccess(index, "ADN", "Private-App", true),
).toBe(true);
});
});
54 changes: 54 additions & 0 deletions apps/dashboard/src/lib/github-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,57 @@ export function getAccessHrefForOwner(

return state.publicInstallUrl ?? fallbackHref ?? null;
}

// ---------------------------------------------------------------------------
// Installation access index
// ---------------------------------------------------------------------------

/**
* A pre-computed index of which repos/owners are accessible via the GitHub App
* installations. Used to filter private repos so the OAuth token doesn't leak
* access beyond what the user configured in their App installation.
*/
export type GitHubInstallationAccessIndex = {
/** `false` when the app-user token isn't available (no app auth yet). */
available: boolean;
/** Normalized owner logins with `repositorySelection: "all"`. */
allAccessOwners: Set<string>;
/** Normalized `owner/repo` strings for `repositorySelection: "selected"`. */
selectedRepos: Set<string>;
};

const EMPTY_INSTALLATION_ACCESS_INDEX: GitHubInstallationAccessIndex = {
available: false,
allAccessOwners: new Set(),
selectedRepos: new Set(),
};

export function emptyInstallationAccessIndex(): GitHubInstallationAccessIndex {
return EMPTY_INSTALLATION_ACCESS_INDEX;
}

/**
* Returns `true` when a repo should be visible given the current installation
* access index.
*
* Rules:
* - Public repos always pass.
* - When the index isn't available (no app setup), all repos pass (fail-open).
* - Private repos pass only when the owning account has an "all" installation
* **or** the specific repo is in the "selected" set.
*/
export function isRepoVisibleWithInstallationAccess(
index: GitHubInstallationAccessIndex,
owner: string,
repo: string,
isPrivate: boolean | null,
): boolean {
// Only skip the check when the repo is *explicitly* public.
// `null` (unknown visibility) is treated as potentially private.
if (isPrivate === false) return true;
if (!index.available) return true;

const normalizedOwner = normalizeLogin(owner);
if (index.allAccessOwners.has(normalizedOwner)) return true;
return index.selectedRepos.has(`${normalizedOwner}/${repo.toLowerCase()}`);
}
4 changes: 4 additions & 0 deletions apps/dashboard/src/lib/github-cache-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,8 @@ export const githubCachePolicy = {
staleTimeMs: 30 * 60 * 1000,
gcTimeMs: 24 * 60 * 60 * 1000,
},
installationAccess: {
staleTimeMs: 30 * 60 * 1000,
gcTimeMs: 24 * 60 * 60 * 1000,
},
} as const;
9 changes: 9 additions & 0 deletions apps/dashboard/src/lib/github-revalidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const githubRevalidationSignalKeys = {
`workflowJob:${input.owner}/${input.repo}#${input.jobId}`,
repoCode: (input: { owner: string; repo: string }) =>
`repoCode:${input.owner}/${input.repo}`,
installationAccess: "installationAccess",
} as const;

function isRecord(value: unknown): value is Record<string, unknown> {
Expand Down Expand Up @@ -161,6 +162,14 @@ export function getGitHubWebhookRevalidationSignalKeys(
event: string,
payload: unknown,
) {
if (
event === "installation" ||
event === "installation_repositories" ||
event === "github_app_authorization"
) {
return [githubRevalidationSignalKeys.installationAccess];
}

const repository = getRepositoryIdentity(payload);
if (!repository) {
return [];
Expand Down
Loading
Loading