Skip to content

Commit fed9869

Browse files
authored
Add /api/gcx-token: self-serve read-only Grafana token for gcx (#8154)
## What New `GET/POST /api/gcx-token` endpoint that mints a **read-only (Viewer)** Grafana service-account token for the [`gcx`](https://github.com/grafana/gcx) CLI, so contributors can self-serve a `GRAFANA_TOKEN` instead of creating one by hand in the Grafana UI. ## Auth Gated by GitHub identity **exactly like Flambeau**: the caller must have write access to `pytorch/pytorch` (or be on the Flambeau allow list). Reuses `getOctokitWithUserToken` + `hasWritePermissionsUsingOctokit`. ## Usage (no browser, nothing to install) ```bash export GRAFANA_TOKEN=$(curl -fsSL \ -H "Authorization: Bearer $(gh auth token)" \ https://hud.pytorch.org/api/gcx-token) ``` Also accepts a browser NextAuth session as a fallback. Returns the token as `text/plain` by default, or JSON with `Accept: application/json` / `?format=json`. ## Token model Each GitHub user gets a dedicated service account `gcx-<github-login>` (Viewer role). Tokens are **long-lived**; revocation is manual via the Grafana UI. Viewer = read-only: good for `gcx resources validate`/dry-run and querying, **not** for `push` (publishing needs Editor). ## Deploy prerequisite (not in this PR) Set in Vercel env: - `GRAFANA_ADMIN_TOKEN` — Grafana Admin SA token (`serviceaccounts:write` / `serviceaccounts.tokens:write`). Server-side only; never returned. - `GRAFANA_SERVER` — optional, defaults to `https://pytorchci.grafana.net`. ## Validation - Grafana mint path verified end-to-end against pytorchci.grafana.net (create Viewer SA → create token → token reads OK, writes 403 → cleanup). - Unit tests (`test/gcxToken.test.ts`): 405 / 401 / 403 / 200 text / 200 JSON. `tsc` clean. Draft until reviewed.
1 parent 35cd0d7 commit fed9869

6 files changed

Lines changed: 433 additions & 52 deletions

File tree

torchci/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,33 @@ We use [Vercel](https://vercel.com/torchci) as our deployment platform. Pushes
7676
to `main` and any other branches will automatically be deployed to Vercel; check out
7777
the bot comments for how to view.
7878

79+
## Grafana CLI token (`gcx`)
80+
81+
`GET /api/gcx-token` mints a **read-only (Viewer)** Grafana service-account
82+
token for the [`gcx`](https://github.com/grafana/gcx) CLI, so contributors can
83+
self-serve a `GRAFANA_TOKEN` instead of creating one by hand in the Grafana UI.
84+
Access is gated by GitHub identity exactly like Flambeau: the caller must have
85+
write access to `pytorch/pytorch` (or be on the Flambeau allow list).
86+
87+
Primary usage — reuse an existing GitHub token (no browser, nothing to install):
88+
89+
```bash
90+
export GRAFANA_TOKEN=$(curl -fsSL \
91+
-H "Authorization: Bearer $(gh auth token)" \
92+
https://hud.pytorch.org/api/gcx-token)
93+
```
94+
95+
Each GitHub user gets a dedicated service account named `gcx-<github-login>`
96+
with the Viewer role. Tokens are long-lived; revoke them manually in the Grafana
97+
UI (delete the token or the `gcx-<login>` service account).
98+
99+
Required server-side env vars (Vercel):
100+
101+
- `GRAFANA_ADMIN_TOKEN` — a Grafana service-account token with Admin role
102+
(`serviceaccounts:write` / `serviceaccounts.tokens:write`). Used only
103+
server-side to mint Viewer tokens; never returned to callers.
104+
- `GRAFANA_SERVER` — optional, defaults to `https://pytorchci.grafana.net`.
105+
79106
## How to edit ClickHouse queries
80107

81108
If you are familiar with the old setup for Rockset, ClickHouse does not have

torchci/lib/auth/githubAuth.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { NextApiRequest, NextApiResponse } from "next";
2+
import { getServerSession } from "next-auth";
3+
import { hasWritePermissionsUsingOctokit } from "../GeneralUtils";
4+
import { getOctokitWithUserToken } from "../github";
5+
// Give access to people who do not have write permissions to pytorch/pytorch
6+
import allowList from "../torchagent/allowList.json";
7+
8+
const REPO_OWNER = "pytorch";
9+
const REPO_NAME = "pytorch";
10+
11+
export type GithubAuthResult =
12+
| { ok: true; login: string }
13+
| { ok: false; status: number; error: string };
14+
15+
/**
16+
* Resolve a GitHub token from the request: an `Authorization: Bearer <token>`
17+
* header takes precedence (used by CLI/curl callers), otherwise fall back to
18+
* the browser NextAuth session's accessToken. Returns null if neither is present.
19+
*/
20+
export function bearerToken(req: NextApiRequest): string | null {
21+
const auth = req.headers["authorization"];
22+
if (typeof auth === "string" && auth.toLowerCase().startsWith("bearer ")) {
23+
return auth.slice("bearer ".length).trim() || null;
24+
}
25+
return null;
26+
}
27+
28+
export async function resolveGithubToken(
29+
req: NextApiRequest,
30+
res: NextApiResponse,
31+
authOptions: any
32+
): Promise<string | null> {
33+
const header = bearerToken(req);
34+
if (header) {
35+
return header;
36+
}
37+
const session = await getServerSession(req, res, authOptions);
38+
// @ts-ignore – next-auth's Session type is not exported here
39+
return (session?.accessToken as string) ?? null;
40+
}
41+
42+
/**
43+
* The shared Flambeau gate: given a GitHub token, return the login if the user
44+
* has write access to pytorch/pytorch (or is on the allow list), otherwise a
45+
* tagged failure with the HTTP status the caller should return.
46+
*/
47+
export async function authorizeGithubToken(
48+
token: string
49+
): Promise<GithubAuthResult> {
50+
try {
51+
const octokit = await getOctokitWithUserToken(token);
52+
const user = await octokit.rest.users.getAuthenticated();
53+
const login = user?.data?.login;
54+
if (!login) {
55+
return { ok: false, status: 401, error: "GitHub authentication failed" };
56+
}
57+
if (allowList.includes(login)) {
58+
return { ok: true, login };
59+
}
60+
const hasWrite = await hasWritePermissionsUsingOctokit(
61+
octokit,
62+
login,
63+
REPO_OWNER,
64+
REPO_NAME
65+
);
66+
if (!hasWrite) {
67+
return {
68+
ok: false,
69+
status: 403,
70+
error: `Write permissions to ${REPO_OWNER}/${REPO_NAME} repository required`,
71+
};
72+
}
73+
return { ok: true, login };
74+
} catch (error) {
75+
console.error("authorizeGithubToken: permission check failed", error);
76+
return { ok: false, status: 500, error: "Permission check failed" };
77+
}
78+
}
Lines changed: 12 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import { NextApiRequest, NextApiResponse } from "next";
22
import { getServerSession } from "next-auth";
3-
import { hasWritePermissionsUsingOctokit } from "./GeneralUtils";
4-
import { getOctokitWithUserToken } from "./github";
5-
// Give access to people who do not have write permissions to pytorch/pytorch
6-
import allowList from "./torchagent/allowList.json";
3+
import { authorizeGithubToken } from "./auth/githubAuth";
74

85
/**
96
* Helper that implements the common auth logic shared by the TorchAgent
@@ -16,7 +13,7 @@ import allowList from "./torchagent/allowList.json";
1613
* request immediately and return the special placeholder user
1714
* "grafana-bypass-user".
1815
* 2. Otherwise ensure that the caller is authenticated with GitHub and
19-
* has write-level access to pytorch/pytorch.
16+
* has write-level access to pytorch/pytorch (see `authorizeGithubToken`).
2017
*
2118
* Each API route should call this function early. If the function returns
2219
* `null` the route must `return` immediately because the HTTP response has
@@ -27,7 +24,7 @@ export async function getAuthorizedUsername(
2724
res: NextApiResponse,
2825
authOptions: any
2926
): Promise<string | null> {
30-
// 1. Cookie bypass logic -------------------------------------------------
27+
// 1. Cookie bypass logic
3128
const AUTH_TOKEN = process.env.GRAFANA_MCP_AUTH_TOKEN || "";
3229
const authCookie = req.cookies["GRAFANA_MCP_AUTH_TOKEN"];
3330

@@ -36,7 +33,7 @@ export async function getAuthorizedUsername(
3633
return "grafana-bypass-user";
3734
}
3835

39-
// 2. Standard GitHub authentication flow --------------------------------
36+
// 2. Standard GitHub authentication flow
4037
// @ts-ignore – next-auth's Session type is not exported client-side
4138
const session = await getServerSession(req, res, authOptions);
4239

@@ -47,51 +44,14 @@ export async function getAuthorizedUsername(
4744
return null;
4845
}
4946

50-
const repoOwner = "pytorch";
51-
const repoName = "pytorch";
52-
53-
try {
54-
const octokit = await getOctokitWithUserToken(
55-
// @ts-ignore – next-auth's Session type is not exported client-side
56-
session.accessToken as string
57-
);
58-
const user = await octokit.rest.users.getAuthenticated();
59-
60-
if (!user?.data?.login) {
61-
console.log("Rejected: Could not authenticate user with GitHub");
62-
res.status(401).json({ error: "GitHub authentication failed" });
63-
return null;
64-
}
65-
66-
if (allowList.includes(user.data.login)) {
67-
console.log(
68-
`Authorized: User ${user.data.login} is in the flambeau allow list`
69-
);
70-
return user.data.login;
71-
}
72-
73-
const hasWritePermissions = await hasWritePermissionsUsingOctokit(
74-
octokit,
75-
user.data.login,
76-
repoOwner,
77-
repoName
78-
);
79-
80-
if (!hasWritePermissions) {
81-
console.log(
82-
`Rejected: User ${user.data.login} does not have write permissions to ${repoOwner}/${repoName}`
83-
);
84-
res.status(403).json({
85-
error: "Write permissions to pytorch/pytorch repository required",
86-
});
87-
return null;
88-
}
89-
90-
console.log(`Authorized: User ${user.data.login} has write permissions`);
91-
return user.data.login;
92-
} catch (error) {
93-
console.error("Error checking permissions:", error);
94-
res.status(500).json({ error: "Permission check failed" });
47+
// @ts-ignore – next-auth's Session type is not exported client-side
48+
const result = await authorizeGithubToken(session.accessToken as string);
49+
if (!result.ok) {
50+
console.log(`Rejected: ${result.error}`);
51+
res.status(result.status).json({ error: result.error });
9552
return null;
9653
}
54+
55+
console.log(`Authorized: User ${result.login}`);
56+
return result.login;
9757
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* Helpers for minting per-user, read-only (Viewer) Grafana service-account
3+
* tokens against pytorchci.grafana.net.
4+
*
5+
* Used by the `/api/gcx-token` route so contributors can self-serve a
6+
* `GRAFANA_TOKEN` for the `gcx` CLI without manually creating one in the
7+
* Grafana UI. Each GitHub user gets a dedicated service account named
8+
* `gcx-<github-login>` with the Viewer role.
9+
*
10+
* Requires the server-side env var `GRAFANA_ADMIN_TOKEN`: a Grafana
11+
* service-account token with `serviceaccounts:write` /
12+
* `serviceaccounts.tokens:write` (Admin role). It is NEVER returned to callers.
13+
*/
14+
15+
const DEFAULT_GRAFANA_SERVER = "https://pytorchci.grafana.net";
16+
17+
export function grafanaServer(): string {
18+
return process.env.GRAFANA_SERVER || DEFAULT_GRAFANA_SERVER;
19+
}
20+
21+
async function grafanaFetch(
22+
path: string,
23+
init?: RequestInit
24+
): Promise<Response> {
25+
const adminToken = process.env.GRAFANA_ADMIN_TOKEN;
26+
if (!adminToken) {
27+
throw new Error("GRAFANA_ADMIN_TOKEN is not configured");
28+
}
29+
return fetch(`${grafanaServer()}${path}`, {
30+
...init,
31+
headers: {
32+
Authorization: `Bearer ${adminToken}`,
33+
"Content-Type": "application/json",
34+
...(init?.headers || {}),
35+
},
36+
});
37+
}
38+
39+
// GitHub logins are [A-Za-z0-9-]; strip anything else defensively so the
40+
// service-account name can never be used to smuggle unexpected characters.
41+
function serviceAccountName(login: string): string {
42+
const safe = login.replace(/[^A-Za-z0-9-]/g, "");
43+
if (!safe) {
44+
throw new Error("Invalid GitHub login");
45+
}
46+
return `gcx-${safe}`;
47+
}
48+
49+
async function findServiceAccountIdByName(
50+
name: string
51+
): Promise<number | null> {
52+
const res = await grafanaFetch(
53+
`/api/serviceaccounts/search?perpage=100&page=1&query=${encodeURIComponent(
54+
name
55+
)}`
56+
);
57+
if (!res.ok) {
58+
throw new Error(
59+
`Grafana service-account search failed: ${res.status} ${await res.text()}`
60+
);
61+
}
62+
const data = await res.json();
63+
const match = (data?.serviceAccounts || []).find(
64+
(sa: { id: number; name: string }) => sa.name === name
65+
);
66+
return match ? match.id : null;
67+
}
68+
69+
async function createViewerServiceAccount(name: string): Promise<number> {
70+
const res = await grafanaFetch("/api/serviceaccounts", {
71+
method: "POST",
72+
body: JSON.stringify({ name, role: "Viewer", isDisabled: false }),
73+
});
74+
if (!res.ok) {
75+
throw new Error(
76+
`Grafana service-account create failed: ${res.status} ${await res.text()}`
77+
);
78+
}
79+
const data = await res.json();
80+
return data.id;
81+
}
82+
83+
/**
84+
* Find-or-create the Viewer service account for `login` and mint a token on it.
85+
* Returns the raw token key (the only time Grafana exposes it).
86+
*/
87+
export async function mintGcxViewerToken(login: string): Promise<string> {
88+
const name = serviceAccountName(login);
89+
90+
let saId = await findServiceAccountIdByName(name);
91+
if (saId == null) {
92+
saId = await createViewerServiceAccount(name);
93+
}
94+
95+
// Timestamp keeps the token name unique per service account.
96+
const tokenName = `${name}-${Date.now()}`;
97+
const res = await grafanaFetch(`/api/serviceaccounts/${saId}/tokens`, {
98+
method: "POST",
99+
body: JSON.stringify({ name: tokenName }),
100+
});
101+
if (!res.ok) {
102+
throw new Error(
103+
`Grafana token create failed: ${res.status} ${await res.text()}`
104+
);
105+
}
106+
const data = await res.json();
107+
if (!data?.key) {
108+
throw new Error("Grafana token create returned no key");
109+
}
110+
return data.key;
111+
}

torchci/pages/api/gcx-token.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* GET/POST /api/gcx-token
3+
*
4+
* Self-serve endpoint that mints a read-only (Viewer) Grafana service-account
5+
* token for the `gcx` CLI, gated by GitHub identity the same way Flambeau is:
6+
* the caller must have write access to pytorch/pytorch (or be on the
7+
* Flambeau allow list).
8+
*
9+
* Intended use: one-time setup. Run it once, save the returned token in
10+
* GRAFANA_TOKEN, and reuse it for read-only `gcx` access. Not meant to be
11+
* called on every gcx invocation.
12+
*
13+
* Primary usage (no browser, no extra CLI to install) reuses an existing
14+
* GitHub token:
15+
*
16+
* export GRAFANA_TOKEN=$(curl -fsSL \
17+
* -H "Authorization: Bearer $(gh auth token)" \
18+
* https://hud.pytorch.org/api/gcx-token)
19+
*
20+
* Browser-authenticated users (a live NextAuth session) are also accepted as a
21+
* fallback. Returns the raw token as text/plain by default, or JSON when the
22+
* caller sends `Accept: application/json` or `?format=json`.
23+
*/
24+
import { NextApiRequest, NextApiResponse } from "next";
25+
import {
26+
authorizeGithubToken,
27+
resolveGithubToken,
28+
} from "../../lib/auth/githubAuth";
29+
import {
30+
grafanaServer,
31+
mintGcxViewerToken,
32+
} from "../../lib/grafana/serviceAccount";
33+
import { authOptions } from "./auth/[...nextauth]";
34+
35+
export default async function handler(
36+
req: NextApiRequest,
37+
res: NextApiResponse
38+
) {
39+
if (req.method !== "GET" && req.method !== "POST") {
40+
return res.status(405).json({ error: "Method not allowed" });
41+
}
42+
43+
// 1. Resolve a GitHub token (bearer header, else NextAuth session).
44+
const githubToken = await resolveGithubToken(req, res, authOptions);
45+
if (!githubToken) {
46+
return res.status(401).json({
47+
error:
48+
"Authentication required: pass 'Authorization: Bearer <github_token>' " +
49+
"(e.g. $(gh auth token)) or sign in to hud.pytorch.org.",
50+
});
51+
}
52+
53+
// 2. Validate GitHub identity + pytorch/pytorch write access (Flambeau gate).
54+
const auth = await authorizeGithubToken(githubToken);
55+
if (!auth.ok) {
56+
return res.status(auth.status).json({ error: auth.error });
57+
}
58+
59+
// 3. Mint a read-only (Viewer) Grafana token for this user.
60+
try {
61+
const token = await mintGcxViewerToken(auth.login);
62+
console.log(`gcx-token: minted Viewer token for ${auth.login}`);
63+
64+
const accept = (req.headers["accept"] as string) || "";
65+
if (accept.includes("application/json") || req.query.format === "json") {
66+
return res.status(200).json({ token, grafanaServer: grafanaServer() });
67+
}
68+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
69+
return res.status(200).send(token);
70+
} catch (error) {
71+
console.error("gcx-token: minting failed", error);
72+
return res.status(500).json({ error: "Failed to mint Grafana token" });
73+
}
74+
}

0 commit comments

Comments
 (0)