From c01996476bd5b21d06cc967e5305e55830d1bf79 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:31:40 -0400 Subject: [PATCH 1/3] Identity foundation: GitHub OAuth sign-in + developer/admin role in session (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * Add GitHub OAuth identity layer with developer/admin roles Co-authored-by: robpitcher <13648061+robpitcher@users.noreply.github.com> * Handle OAuth denial and add try/catch to login route Address PR #8 review: - callback: validate CSRF state first, then handle GitHub's rror query param (e.g. access_denied) explicitly with an accurate identity_login_denied audit entry and 401 response, and report a distinct identity_login_missing_code case instead of mislabeling cancelled logins as invalid state. - login: wrap handler in try/catch with console.error and a generic 500 JSON response, matching the repo API route convention. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert unrelated next-env.d.ts churn This auto-generated file (managed by Next.js, 'should not be edited') was changed in the PR's base commit only because the authoring environment ran `next build` (writes .next/types/) while main and local `next dev` produce .next/dev/types/. Restore main's version so PR #8 contains only the intentional identity changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: robpitcher <13648061+robpitcher@users.noreply.github.com> Co-authored-by: robpitcher Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/.env.example | 15 ++ app/src/app/api/auth/github/callback/route.ts | 170 ++++++++++++++++++ app/src/app/api/auth/github/login/route.ts | 45 +++++ app/src/lib/__tests__/auth.test.ts | 127 ++++++++++++- app/src/lib/auth.ts | 119 +++++++++++- app/src/proxy.ts | 89 +++++++++ infra/main.bicep | 19 ++ infra/main.json | 104 ++++++++++- infra/main.parameters.json | 12 ++ infra/resources.bicep | 68 ++++++- 10 files changed, 757 insertions(+), 11 deletions(-) create mode 100644 app/src/app/api/auth/github/callback/route.ts create mode 100644 app/src/app/api/auth/github/login/route.ts diff --git a/app/.env.example b/app/.env.example index 8e0dc5e..d9179d2 100644 --- a/app/.env.example +++ b/app/.env.example @@ -6,3 +6,18 @@ DATABASE_URL=postgresql://user:password@localhost:5432/copilot_insights # Admin password for the Settings page (minimum 8 characters) ADMIN_PASSWORD=changeme123 + +# ── Identity mode (optional) ── +# When all of GITHUB_OAUTH_CLIENT_ID, GITHUB_OAUTH_CLIENT_SECRET and +# SESSION_SECRET are set, the dashboard enables GitHub OAuth sign-in and gives +# each session an identity (login) and role (admin/developer). Leave these +# unset to keep the existing open or shared-password behavior unchanged. + +# GitHub OAuth App client id (callback: https:///api/auth/github/callback) +GITHUB_OAUTH_CLIENT_ID= +# GitHub OAuth App client secret +GITHUB_OAUTH_CLIENT_SECRET= +# Secret used to sign identity session cookies (use a long random string) +SESSION_SECRET= +# Comma-separated GitHub logins granted the admin role (case-insensitive) +ADMIN_LOGINS= diff --git a/app/src/app/api/auth/github/callback/route.ts b/app/src/app/api/auth/github/callback/route.ts new file mode 100644 index 0000000..2819c4c --- /dev/null +++ b/app/src/app/api/auth/github/callback/route.ts @@ -0,0 +1,170 @@ +import { NextRequest, NextResponse } from "next/server"; +import { logAudit, getClientIp } from "@/lib/audit"; +import { + isIdentityModeEnabled, + resolveRole, + createIdentitySession, + sessionCookieOptions, + safeCompare, + COOKIE_NAMES, +} from "@/lib/auth"; + +const COOKIE_OAUTH_STATE = "oauth_state"; + +/** Clear the transient OAuth state cookie on the given response. */ +function clearStateCookie(response: NextResponse): void { + response.cookies.set(COOKIE_OAUTH_STATE, "", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 0, + }); +} + +/** + * GitHub OAuth callback. Validates the CSRF `state`, exchanges the `code` for an + * access token, fetches the user's GitHub identity, resolves their role, and + * mints a signed identity session cookie. Only available in identity mode. + */ +export async function GET(request: NextRequest) { + if (!isIdentityModeEnabled()) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const ip = getClientIp(request) ?? "unknown"; + + try { + const sp = request.nextUrl.searchParams; + const code = sp.get("code"); + const state = sp.get("state"); + const oauthError = sp.get("error"); + const expectedState = request.cookies.get(COOKIE_OAUTH_STATE)?.value; + + // Validate the CSRF state on every callback, including error returns. + if (!state || !expectedState || !safeCompare(state, expectedState)) { + logAudit({ action: "identity_login_invalid_state", category: "auth", ipAddress: ip }); + const response = NextResponse.json( + { error: "Invalid OAuth state" }, + { status: 400 }, + ); + clearStateCookie(response); + return response; + } + + // GitHub returned an error (e.g. the user denied consent) instead of a code. + if (oauthError) { + logAudit({ + action: "identity_login_denied", + category: "auth", + details: { error: oauthError }, + ipAddress: ip, + }); + const response = NextResponse.json( + { error: "GitHub authorization was denied or cancelled" }, + { status: 401 }, + ); + clearStateCookie(response); + return response; + } + + if (!code) { + logAudit({ action: "identity_login_missing_code", category: "auth", ipAddress: ip }); + const response = NextResponse.json( + { error: "Missing authorization code" }, + { status: 400 }, + ); + clearStateCookie(response); + return response; + } + + const clientId = process.env.GITHUB_OAUTH_CLIENT_ID!; + const clientSecret = process.env.GITHUB_OAUTH_CLIENT_SECRET!; + const redirectUri = `${request.nextUrl.origin}/api/auth/github/callback`; + + // Exchange the authorization code for an access token. + const tokenRes = await fetch( + "https://github.com/login/oauth/access_token", + { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri, + }), + }, + ); + + if (!tokenRes.ok) { + throw new Error(`Token exchange failed with status ${tokenRes.status}`); + } + + const tokenData = (await tokenRes.json()) as { + access_token?: string; + error?: string; + }; + + if (!tokenData.access_token) { + logAudit({ action: "identity_login_token_error", category: "auth", ipAddress: ip }); + const response = NextResponse.json( + { error: "OAuth authorization failed" }, + { status: 401 }, + ); + clearStateCookie(response); + return response; + } + + // Fetch the authenticated user's GitHub identity. + const userRes = await fetch("https://api.github.com/user", { + headers: { + Authorization: "Bearer " + tokenData.access_token, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2026-03-10", + }, + }); + + if (!userRes.ok) { + throw new Error(`User fetch failed with status ${userRes.status}`); + } + + const user = (await userRes.json()) as { login?: string; id?: number }; + + if (!user.login || typeof user.id !== "number") { + throw new Error("GitHub user response missing login or id"); + } + + const role = resolveRole(user.login); + const token = createIdentitySession({ + login: user.login, + id: user.id, + role, + }); + + logAudit({ + action: "identity_login_success", + category: "auth", + actor: user.login, + details: { role }, + ipAddress: ip, + }); + + const response = NextResponse.redirect(new URL("/", request.url)); + response.cookies.set(COOKIE_NAMES.identity, token, sessionCookieOptions()); + clearStateCookie(response); + return response; + } catch (err) { + console.error("GitHub OAuth callback error:", err); + logAudit({ action: "identity_login_error", category: "auth", ipAddress: ip }); + const response = NextResponse.json( + { error: "Authentication failed" }, + { status: 500 }, + ); + clearStateCookie(response); + return response; + } +} diff --git a/app/src/app/api/auth/github/login/route.ts b/app/src/app/api/auth/github/login/route.ts new file mode 100644 index 0000000..ab07272 --- /dev/null +++ b/app/src/app/api/auth/github/login/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { randomBytes } from "crypto"; +import { isIdentityModeEnabled } from "@/lib/auth"; + +const COOKIE_OAUTH_STATE = "oauth_state"; +const STATE_MAX_AGE_S = 600; // 10 minutes + +/** + * Begin the GitHub OAuth web flow. Redirects the browser to GitHub's + * authorization screen with a CSRF `state` value stored in a short-lived cookie. + * Only available in identity mode (GitHub OAuth env vars configured). + */ +export async function GET(request: NextRequest) { + if (!isIdentityModeEnabled()) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + try { + const clientId = process.env.GITHUB_OAUTH_CLIENT_ID!; + const redirectUri = `${request.nextUrl.origin}/api/auth/github/callback`; + const state = randomBytes(16).toString("hex"); + + const authorizeUrl = new URL("https://github.com/login/oauth/authorize"); + authorizeUrl.searchParams.set("client_id", clientId); + authorizeUrl.searchParams.set("redirect_uri", redirectUri); + authorizeUrl.searchParams.set("scope", "read:user"); + authorizeUrl.searchParams.set("state", state); + + const response = NextResponse.redirect(authorizeUrl.toString()); + response.cookies.set(COOKIE_OAUTH_STATE, state, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: STATE_MAX_AGE_S, + }); + return response; + } catch (error) { + console.error("GitHub OAuth login error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/app/src/lib/__tests__/auth.test.ts b/app/src/lib/__tests__/auth.test.ts index 9057a99..e98ef91 100644 --- a/app/src/lib/__tests__/auth.test.ts +++ b/app/src/lib/__tests__/auth.test.ts @@ -1,10 +1,14 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { safeCompare, checkRateLimit, getLockoutRemainingMs, recordFailedAttempt, clearFailedAttempts, + createIdentitySession, + verifyIdentitySession, + resolveRole, + isIdentityModeEnabled, } from "@/lib/auth"; describe("safeCompare", () => { @@ -76,3 +80,124 @@ describe("failed-attempt lockout", () => { expect(getLockoutRemainingMs(b)).toBe(0); }); }); + +describe("resolveRole", () => { + const original = process.env.ADMIN_LOGINS; + afterEach(() => { + if (original === undefined) delete process.env.ADMIN_LOGINS; + else process.env.ADMIN_LOGINS = original; + }); + + it("defaults to developer when ADMIN_LOGINS is unset", () => { + delete process.env.ADMIN_LOGINS; + expect(resolveRole("alice")).toBe("developer"); + }); + + it("grants admin for an allowlisted login", () => { + process.env.ADMIN_LOGINS = "alice,bob"; + expect(resolveRole("alice")).toBe("admin"); + expect(resolveRole("bob")).toBe("admin"); + }); + + it("matches the allowlist case-insensitively", () => { + process.env.ADMIN_LOGINS = "Alice,BOB"; + expect(resolveRole("alice")).toBe("admin"); + expect(resolveRole("BoB")).toBe("admin"); + }); + + it("defaults to developer for a login not in the allowlist", () => { + process.env.ADMIN_LOGINS = "alice,bob"; + expect(resolveRole("carol")).toBe("developer"); + }); + + it("tolerates whitespace around allowlist entries", () => { + process.env.ADMIN_LOGINS = " alice , bob "; + expect(resolveRole("bob")).toBe("admin"); + }); +}); + +describe("isIdentityModeEnabled", () => { + const orig = { + id: process.env.GITHUB_OAUTH_CLIENT_ID, + secret: process.env.GITHUB_OAUTH_CLIENT_SECRET, + session: process.env.SESSION_SECRET, + }; + afterEach(() => { + for (const [k, v] of [ + ["GITHUB_OAUTH_CLIENT_ID", orig.id], + ["GITHUB_OAUTH_CLIENT_SECRET", orig.secret], + ["SESSION_SECRET", orig.session], + ] as const) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + + it("is disabled unless all identity env vars are set", () => { + delete process.env.GITHUB_OAUTH_CLIENT_ID; + delete process.env.GITHUB_OAUTH_CLIENT_SECRET; + delete process.env.SESSION_SECRET; + expect(isIdentityModeEnabled()).toBe(false); + + process.env.GITHUB_OAUTH_CLIENT_ID = "id"; + expect(isIdentityModeEnabled()).toBe(false); + + process.env.GITHUB_OAUTH_CLIENT_SECRET = "secret"; + expect(isIdentityModeEnabled()).toBe(false); + + process.env.SESSION_SECRET = "signing-secret"; + expect(isIdentityModeEnabled()).toBe(true); + }); +}); + +describe("identity session mint/verify", () => { + const original = process.env.SESSION_SECRET; + beforeEach(() => { + process.env.SESSION_SECRET = "test-session-secret"; + }); + afterEach(() => { + if (original === undefined) delete process.env.SESSION_SECRET; + else process.env.SESSION_SECRET = original; + }); + + it("round-trips a valid session", () => { + const token = createIdentitySession({ login: "alice", id: 42, role: "admin" }); + expect(token).not.toBe(""); + const session = verifyIdentitySession(token); + expect(session).toEqual({ login: "alice", id: 42, role: "admin" }); + }); + + it("returns null for a tampered payload", () => { + const token = createIdentitySession({ login: "alice", id: 42, role: "developer" }); + const [, signature] = token.split("."); + const forged = Buffer.from( + JSON.stringify({ login: "alice", id: 42, role: "admin", iat: Date.now() }), + ).toString("base64url"); + expect(verifyIdentitySession(`${forged}.${signature}`)).toBeNull(); + }); + + it("returns null for a tampered signature", () => { + const token = createIdentitySession({ login: "alice", id: 42, role: "developer" }); + const [payload] = token.split("."); + expect(verifyIdentitySession(`${payload}.deadbeef`)).toBeNull(); + }); + + it("returns null for an expired session", () => { + const token = createIdentitySession({ login: "alice", id: 42, role: "developer" }); + expect(verifyIdentitySession(token)).not.toBeNull(); + // Advance time beyond the 24h expiry window. + const realNow = Date.now(); + const spy = vi.spyOn(Date, "now").mockReturnValue(realNow + 25 * 60 * 60 * 1000); + try { + expect(verifyIdentitySession(token)).toBeNull(); + } finally { + spy.mockRestore(); + } + }); + + it("returns null when SESSION_SECRET is unset", () => { + const token = createIdentitySession({ login: "alice", id: 1, role: "developer" }); + delete process.env.SESSION_SECRET; + expect(verifyIdentitySession(token)).toBeNull(); + }); +}); diff --git a/app/src/lib/auth.ts b/app/src/lib/auth.ts index 3dea6d1..e4e1d3d 100644 --- a/app/src/lib/auth.ts +++ b/app/src/lib/auth.ts @@ -5,6 +5,7 @@ import { NextResponse } from "next/server"; const COOKIE_DASHBOARD = "dashboard_session"; const COOKIE_ADMIN = "admin_session"; +const COOKIE_IDENTITY = "identity_session"; const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours const SIGNING_SALT = "copilot-insights-session-v1"; @@ -205,7 +206,123 @@ export function sessionCookieOptions() { }; } -export const COOKIE_NAMES = { dashboard: COOKIE_DASHBOARD, admin: COOKIE_ADMIN } as const; +export const COOKIE_NAMES = { + dashboard: COOKIE_DASHBOARD, + admin: COOKIE_ADMIN, + identity: COOKIE_IDENTITY, +} as const; + +/* ── Identity Session (GitHub OAuth, HMAC-signed) ── */ + +export type Role = "admin" | "developer"; + +export interface IdentitySession { + /** GitHub login (handle). */ + login: string; + /** GitHub numeric user id. */ + id: number; + /** Resolved role for this session. */ + role: Role; +} + +/** + * Identity mode is active only when the GitHub OAuth + session-signing env vars + * are all set. When unset, open and shared-password modes are unaffected. + */ +export function isIdentityModeEnabled(): boolean { + return ( + !!process.env.GITHUB_OAUTH_CLIENT_ID && + !!process.env.GITHUB_OAUTH_CLIENT_SECRET && + !!process.env.SESSION_SECRET + ); +} + +/** + * Resolve a signed-in user's role from the `ADMIN_LOGINS` allowlist. + * Comparison is case-insensitive; anyone not listed defaults to `developer`. + */ +export function resolveRole(login: string): Role { + const allow = (process.env.ADMIN_LOGINS ?? "") + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + return allow.includes(login.trim().toLowerCase()) ? "admin" : "developer"; +} + +/** + * Mint a signed identity session token carrying the user's login, id and role. + * Signed with `SESSION_SECRET` using the same HMAC scheme as password sessions. + * Returns "" when no signing secret is configured. + */ +export function createIdentitySession(session: IdentitySession): string { + const secret = process.env.SESSION_SECRET; + if (!secret) return ""; + + const payloadObj = { + login: session.login, + id: session.id, + role: session.role, + iat: Date.now(), + }; + const payload = JSON.stringify(payloadObj); + const key = getSigningKey(secret); + const signature = createHmac("sha256", key).update(payload).digest("hex"); + + return `${Buffer.from(payload).toString("base64url")}.${signature}`; +} + +/** + * Verify an identity session token. Returns the decoded session when the + * signature is valid and the token is not expired, otherwise null. + */ +export function verifyIdentitySession(token: string): IdentitySession | null { + const secret = process.env.SESSION_SECRET; + if (!secret || !token) return null; + + const dotIdx = token.indexOf("."); + if (dotIdx < 0) return null; + + const payloadB64 = token.slice(0, dotIdx); + const signature = token.slice(dotIdx + 1); + if (!payloadB64 || !signature) return null; + + let payload: string; + try { + payload = Buffer.from(payloadB64, "base64url").toString(); + } catch { + return null; + } + + const key = getSigningKey(secret); + const expectedSignature = createHmac("sha256", key) + .update(payload) + .digest("hex"); + + if (!safeCompare(signature, expectedSignature)) return null; + + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + return null; + } + + if (typeof parsed !== "object" || parsed === null) return null; + const { login, id, role, iat } = parsed as Record; + + if ( + typeof login !== "string" || + typeof id !== "number" || + (role !== "admin" && role !== "developer") || + typeof iat !== "number" + ) { + return null; + } + + if (Date.now() - iat > TOKEN_EXPIRY_MS) return null; + + return { login, id, role }; +} /* ── Auth Guards ── */ diff --git a/app/src/proxy.ts b/app/src/proxy.ts index cb4da7f..8890faa 100644 --- a/app/src/proxy.ts +++ b/app/src/proxy.ts @@ -12,6 +12,7 @@ import { NextRequest, NextResponse } from "next/server"; const COOKIE_DASHBOARD = "dashboard_session"; const COOKIE_ADMIN = "admin_session"; +const COOKIE_IDENTITY = "identity_session"; const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; const SIGNING_SALT = "copilot-insights-session-v1"; @@ -115,6 +116,80 @@ async function verifyToken( return constantTimeEqual(signature, expectedSignature); } +/* ── Identity session verification (Edge-compatible) ── */ + +type Role = "admin" | "developer"; + +interface IdentityPayload { + login: string; + id: number; + role: Role; +} + +/** + * Identity mode is active only when the GitHub OAuth + session-signing env vars + * are all set. When unset, open and shared-password modes are unaffected. + */ +function isIdentityModeEnabled(): boolean { + return ( + !!process.env.GITHUB_OAUTH_CLIENT_ID && + !!process.env.GITHUB_OAUTH_CLIENT_SECRET && + !!process.env.SESSION_SECRET + ); +} + +/** + * Verify an identity session token signed with `SESSION_SECRET`. + * Returns the decoded payload when valid and unexpired, otherwise null. + */ +async function verifyIdentityToken( + token: string, +): Promise { + const secret = process.env.SESSION_SECRET; + if (!secret || !token) return null; + + const dotIdx = token.indexOf("."); + if (dotIdx < 0) return null; + + const payloadB64 = token.slice(0, dotIdx); + const signature = token.slice(dotIdx + 1); + if (!payloadB64 || !signature) return null; + + let payload: string; + try { + payload = base64UrlDecode(payloadB64); + } catch { + return null; + } + + const key = await deriveKey(secret); + const expectedSignature = await hmacSign(key, payload); + if (!constantTimeEqual(signature, expectedSignature)) return null; + + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + return null; + } + + if (typeof parsed !== "object" || parsed === null) return null; + const { login, id, role, iat } = parsed as Record; + + if ( + typeof login !== "string" || + typeof id !== "number" || + (role !== "admin" && role !== "developer") || + typeof iat !== "number" + ) { + return null; + } + + if (Date.now() - iat > TOKEN_EXPIRY_MS) return null; + + return { login, id, role }; +} + /* ── Proxy handler ── */ export async function proxy(request: NextRequest) { @@ -133,6 +208,20 @@ export async function proxy(request: NextRequest) { // Check if this is an admin-level route const isAdminRoute = ADMIN_PREFIXES.some((p) => pathname.startsWith(p)); + // Identity mode — when GitHub OAuth is configured, gate by identity session. + if (isIdentityModeEnabled()) { + const token = request.cookies.get(COOKIE_IDENTITY)?.value; + const session = token ? await verifyIdentityToken(token) : null; + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (isAdminRoute && session.role !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + return NextResponse.next(); + } + if (isAdminRoute) { if (!process.env.ADMIN_PASSWORD) return NextResponse.next(); diff --git a/infra/main.bicep b/infra/main.bicep index 2c9ae49..ed60164 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -29,6 +29,20 @@ param adminPassword string = '' @description('(Optional) Dashboard viewer password. When set, all dashboard pages require this password to view. Separate from the admin password used for Settings. Leave empty for unrestricted viewing.') param dashboardPassword string = '' +@description('(Optional) GitHub OAuth App client id. Set together with githubOauthClientSecret and sessionSecret to enable identity mode (GitHub sign-in + roles). Leave empty to keep open/shared-password behavior.') +param githubOauthClientId string = '' + +@secure() +@description('(Optional) GitHub OAuth App client secret. Required for identity mode. Stored in Key Vault.') +param githubOauthClientSecret string = '' + +@secure() +@description('(Optional) Secret used to sign identity session cookies. Required for identity mode. Stored in Key Vault.') +param sessionSecret string = '' + +@description('(Optional) Comma-separated GitHub logins granted the admin role (case-insensitive). Used only in identity mode.') +param adminLogins string = '' + resource rg 'Microsoft.Resources/resourceGroups@2024-03-01' = { name: resourceGroupName location: location @@ -49,6 +63,11 @@ module resources 'resources.bicep' = { adminPassword: adminPassword dashboardPassword: dashboardPassword hasDashboardPassword: !empty(dashboardPassword) + githubOauthClientId: githubOauthClientId + githubOauthClientSecret: githubOauthClientSecret + sessionSecret: sessionSecret + adminLogins: adminLogins + hasIdentity: !empty(githubOauthClientId) && !empty(githubOauthClientSecret) && !empty(sessionSecret) } } diff --git a/infra/main.json b/infra/main.json index dfce953..ab26e6a 100644 --- a/infra/main.json +++ b/infra/main.json @@ -4,8 +4,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "4265432977620426541" + "version": "0.43.8.12551", + "templateHash": "9860957415455261051" } }, "parameters": { @@ -60,6 +60,34 @@ "metadata": { "description": "(Optional) Dashboard viewer password. When set, all dashboard pages require this password to view. Separate from the admin password used for Settings. Leave empty for unrestricted viewing." } + }, + "githubOauthClientId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "(Optional) GitHub OAuth App client id. Set together with githubOauthClientSecret and sessionSecret to enable identity mode (GitHub sign-in + roles). Leave empty to keep open/shared-password behavior." + } + }, + "githubOauthClientSecret": { + "type": "securestring", + "defaultValue": "", + "metadata": { + "description": "(Optional) GitHub OAuth App client secret. Required for identity mode. Stored in Key Vault." + } + }, + "sessionSecret": { + "type": "securestring", + "defaultValue": "", + "metadata": { + "description": "(Optional) Secret used to sign identity session cookies. Required for identity mode. Stored in Key Vault." + } + }, + "adminLogins": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "(Optional) Comma-separated GitHub logins granted the admin role (case-insensitive). Used only in identity mode." + } } }, "resources": [ @@ -106,6 +134,21 @@ }, "hasDashboardPassword": { "value": "[not(empty(parameters('dashboardPassword')))]" + }, + "githubOauthClientId": { + "value": "[parameters('githubOauthClientId')]" + }, + "githubOauthClientSecret": { + "value": "[parameters('githubOauthClientSecret')]" + }, + "sessionSecret": { + "value": "[parameters('sessionSecret')]" + }, + "adminLogins": { + "value": "[parameters('adminLogins')]" + }, + "hasIdentity": { + "value": "[and(and(not(empty(parameters('githubOauthClientId'))), not(empty(parameters('githubOauthClientSecret')))), not(empty(parameters('sessionSecret'))))]" } }, "template": { @@ -114,8 +157,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "13603401391942393427" + "version": "0.43.8.12551", + "templateHash": "18252134873898376296" } }, "parameters": { @@ -146,12 +189,33 @@ "hasDashboardPassword": { "type": "bool", "defaultValue": false + }, + "githubOauthClientId": { + "type": "string", + "defaultValue": "" + }, + "githubOauthClientSecret": { + "type": "securestring", + "defaultValue": "" + }, + "sessionSecret": { + "type": "securestring", + "defaultValue": "" + }, + "adminLogins": { + "type": "string", + "defaultValue": "" + }, + "hasIdentity": { + "type": "bool", + "defaultValue": false } }, "variables": { "resourceToken": "[uniqueString(subscription().id, resourceGroup().id, parameters('location'), parameters('environmentName'))]", "postgresAdminPassword": "[format('{0}P@1{1}', uniqueString(subscription().id, resourceGroup().id, 'pg'), take(uniqueString(parameters('environmentName'), 'salt'), 8))]", - "dashboardPasswordEnvVar": "[if(parameters('hasDashboardPassword'), createArray(createObject('name', 'DASHBOARD_PASSWORD', 'secretRef', 'dashboard-password')), createArray())]" + "dashboardPasswordEnvVar": "[if(parameters('hasDashboardPassword'), createArray(createObject('name', 'DASHBOARD_PASSWORD', 'secretRef', 'dashboard-password')), createArray())]", + "identityEnvVars": "[if(parameters('hasIdentity'), createArray(createObject('name', 'GITHUB_OAUTH_CLIENT_ID', 'value', parameters('githubOauthClientId')), createObject('name', 'GITHUB_OAUTH_CLIENT_SECRET', 'secretRef', 'github-oauth-client-secret'), createObject('name', 'SESSION_SECRET', 'secretRef', 'session-secret'), createObject('name', 'ADMIN_LOGINS', 'value', parameters('adminLogins'))), createArray())]" }, "resources": [ { @@ -340,6 +404,30 @@ "[resourceId('Microsoft.KeyVault/vaults', format('azkv{0}', variables('resourceToken')))]" ] }, + { + "condition": "[parameters('hasIdentity')]", + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2023-07-01", + "name": "[format('{0}/{1}', format('azkv{0}', variables('resourceToken')), 'GITHUB-OAUTH-CLIENT-SECRET')]", + "properties": { + "value": "[parameters('githubOauthClientSecret')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.KeyVault/vaults', format('azkv{0}', variables('resourceToken')))]" + ] + }, + { + "condition": "[parameters('hasIdentity')]", + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2023-07-01", + "name": "[format('{0}/{1}', format('azkv{0}', variables('resourceToken')), 'SESSION-SECRET')]", + "properties": { + "value": "[parameters('sessionSecret')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.KeyVault/vaults', format('azkv{0}', variables('resourceToken')))]" + ] + }, { "type": "Microsoft.App/managedEnvironments", "apiVersion": "2024-03-01", @@ -401,7 +489,7 @@ "identity": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('azid{0}', variables('resourceToken')))]" } ], - "secrets": "[concat(createArray(createObject('name', 'database-url', 'keyVaultUrl', reference(resourceId('Microsoft.KeyVault/vaults/secrets', format('azkv{0}', variables('resourceToken')), 'DATABASE-URL'), '2023-07-01').secretUri, 'identity', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('azid{0}', variables('resourceToken')))), createObject('name', 'admin-password', 'keyVaultUrl', reference(resourceId('Microsoft.KeyVault/vaults/secrets', format('azkv{0}', variables('resourceToken')), 'ADMIN-PASSWORD'), '2023-07-01').secretUri, 'identity', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('azid{0}', variables('resourceToken'))))), if(parameters('hasDashboardPassword'), createArray(createObject('name', 'dashboard-password', 'keyVaultUrl', reference(resourceId('Microsoft.KeyVault/vaults/secrets', format('azkv{0}', variables('resourceToken')), 'DASHBOARD-PASSWORD'), '2023-07-01').secretUri, 'identity', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('azid{0}', variables('resourceToken'))))), createArray()))]" + "secrets": "[concat(createArray(createObject('name', 'database-url', 'keyVaultUrl', reference(resourceId('Microsoft.KeyVault/vaults/secrets', format('azkv{0}', variables('resourceToken')), 'DATABASE-URL'), '2023-07-01').secretUri, 'identity', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('azid{0}', variables('resourceToken')))), createObject('name', 'admin-password', 'keyVaultUrl', reference(resourceId('Microsoft.KeyVault/vaults/secrets', format('azkv{0}', variables('resourceToken')), 'ADMIN-PASSWORD'), '2023-07-01').secretUri, 'identity', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('azid{0}', variables('resourceToken'))))), if(parameters('hasDashboardPassword'), createArray(createObject('name', 'dashboard-password', 'keyVaultUrl', reference(resourceId('Microsoft.KeyVault/vaults/secrets', format('azkv{0}', variables('resourceToken')), 'DASHBOARD-PASSWORD'), '2023-07-01').secretUri, 'identity', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('azid{0}', variables('resourceToken'))))), createArray()), if(parameters('hasIdentity'), createArray(createObject('name', 'github-oauth-client-secret', 'keyVaultUrl', reference(resourceId('Microsoft.KeyVault/vaults/secrets', format('azkv{0}', variables('resourceToken')), 'GITHUB-OAUTH-CLIENT-SECRET'), '2023-07-01').secretUri, 'identity', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('azid{0}', variables('resourceToken')))), createObject('name', 'session-secret', 'keyVaultUrl', reference(resourceId('Microsoft.KeyVault/vaults/secrets', format('azkv{0}', variables('resourceToken')), 'SESSION-SECRET'), '2023-07-01').secretUri, 'identity', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('azid{0}', variables('resourceToken'))))), createArray()))]" }, "template": { "containers": [ @@ -412,7 +500,7 @@ "cpu": "[json('0.5')]", "memory": "1Gi" }, - "env": "[concat(createArray(createObject('name', 'DATABASE_URL', 'secretRef', 'database-url'), createObject('name', 'NODE_ENV', 'value', 'production'), createObject('name', 'APPLICATIONINSIGHTS_CONNECTION_STRING', 'value', reference(resourceId('Microsoft.Insights/components', format('azmon{0}', variables('resourceToken'))), '2020-02-02').ConnectionString), createObject('name', 'ADMIN_PASSWORD', 'secretRef', 'admin-password')), variables('dashboardPasswordEnvVar'))]" + "env": "[concat(createArray(createObject('name', 'DATABASE_URL', 'secretRef', 'database-url'), createObject('name', 'NODE_ENV', 'value', 'production'), createObject('name', 'APPLICATIONINSIGHTS_CONNECTION_STRING', 'value', reference(resourceId('Microsoft.Insights/components', format('azmon{0}', variables('resourceToken'))), '2020-02-02').ConnectionString), createObject('name', 'ADMIN_PASSWORD', 'secretRef', 'admin-password')), variables('dashboardPasswordEnvVar'), variables('identityEnvVars'))]" } ], "scale": { @@ -429,6 +517,8 @@ "[resourceId('Microsoft.KeyVault/vaults/secrets', format('azkv{0}', variables('resourceToken')), 'ADMIN-PASSWORD')]", "[resourceId('Microsoft.KeyVault/vaults/secrets', format('azkv{0}', variables('resourceToken')), 'DASHBOARD-PASSWORD')]", "[resourceId('Microsoft.KeyVault/vaults/secrets', format('azkv{0}', variables('resourceToken')), 'DATABASE-URL')]", + "[resourceId('Microsoft.KeyVault/vaults/secrets', format('azkv{0}', variables('resourceToken')), 'GITHUB-OAUTH-CLIENT-SECRET')]", + "[resourceId('Microsoft.KeyVault/vaults/secrets', format('azkv{0}', variables('resourceToken')), 'SESSION-SECRET')]", "[extensionResourceId(resourceId('Microsoft.KeyVault/vaults', format('azkv{0}', variables('resourceToken'))), 'Microsoft.Authorization/roleAssignments', guid(resourceId('Microsoft.KeyVault/vaults', format('azkv{0}', variables('resourceToken'))), resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('azid{0}', variables('resourceToken'))), '4633458b-17de-408a-b874-0445c86b69e6'))]", "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('azid{0}', variables('resourceToken')))]" ] diff --git a/infra/main.parameters.json b/infra/main.parameters.json index 051ef3e..9067624 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -13,6 +13,18 @@ }, "dashboardPassword": { "value": "${DASHBOARD_PASSWORD}" + }, + "githubOauthClientId": { + "value": "${GITHUB_OAUTH_CLIENT_ID}" + }, + "githubOauthClientSecret": { + "value": "${GITHUB_OAUTH_CLIENT_SECRET}" + }, + "sessionSecret": { + "value": "${SESSION_SECRET}" + }, + "adminLogins": { + "value": "${ADMIN_LOGINS}" } } } diff --git a/infra/resources.bicep b/infra/resources.bicep index 8dd36c1..8240076 100644 --- a/infra/resources.bicep +++ b/infra/resources.bicep @@ -15,6 +15,18 @@ param dashboardPassword string = '' param hasDashboardPassword bool = false +param githubOauthClientId string = '' + +@secure() +param githubOauthClientSecret string = '' + +@secure() +param sessionSecret string = '' + +param adminLogins string = '' + +param hasIdentity bool = false + var resourceToken = uniqueString(subscription().id, resourceGroup().id, location, environmentName) var postgresAdminPassword = '${uniqueString(subscription().id, resourceGroup().id, 'pg')}P@1${take(uniqueString(environmentName, 'salt'), 8)}' @@ -207,6 +219,22 @@ resource kvSecretDashboardPassword 'Microsoft.KeyVault/vaults/secrets@2023-07-01 } } +resource kvSecretOauthClientSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (hasIdentity) { + parent: keyVault + name: 'GITHUB-OAUTH-CLIENT-SECRET' + properties: { + value: githubOauthClientSecret + } +} + +resource kvSecretSessionSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (hasIdentity) { + parent: keyVault + name: 'SESSION-SECRET' + properties: { + value: sessionSecret + } +} + // ────────────────────────────────────────────── // 12. Container App Environment // ────────────────────────────────────────────── @@ -251,6 +279,21 @@ var dashboardPasswordSecret = hasDashboardPassword ] : [] +var identitySecrets = hasIdentity + ? [ + { + name: 'github-oauth-client-secret' + keyVaultUrl: kvSecretOauthClientSecret!.properties.secretUri + identity: managedIdentity.id + } + { + name: 'session-secret' + keyVaultUrl: kvSecretSessionSecret!.properties.secretUri + identity: managedIdentity.id + } + ] + : [] + var baseEnvVars = [ { name: 'DATABASE_URL' @@ -279,6 +322,27 @@ var dashboardPasswordEnvVar = hasDashboardPassword ] : [] +var identityEnvVars = hasIdentity + ? [ + { + name: 'GITHUB_OAUTH_CLIENT_ID' + value: githubOauthClientId + } + { + name: 'GITHUB_OAUTH_CLIENT_SECRET' + secretRef: 'github-oauth-client-secret' + } + { + name: 'SESSION_SECRET' + secretRef: 'session-secret' + } + { + name: 'ADMIN_LOGINS' + value: adminLogins + } + ] + : [] + resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { name: 'azca${resourceToken}' location: location @@ -310,7 +374,7 @@ resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { identity: managedIdentity.id } ] - secrets: concat(baseSecrets, dashboardPasswordSecret) + secrets: concat(baseSecrets, dashboardPasswordSecret, identitySecrets) } template: { containers: [ @@ -321,7 +385,7 @@ resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { cpu: json('0.5') memory: '1Gi' } - env: concat(baseEnvVars, dashboardPasswordEnvVar) + env: concat(baseEnvVars, dashboardPasswordEnvVar, identityEnvVars) } ] scale: { From 12bbc58bfde92209179e3f944f5cb3149802d923 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:54:02 -0400 Subject: [PATCH 2/3] Persist per-user AI credit rows via async report export + server-side row-level scoping (#11) * Initial plan * Persist per-user AI credit rows via async report export + server-side row-level scoping Co-authored-by: robpitcher <13648061+robpitcher@users.noreply.github.com> * Revert unrelated next-env.d.ts build-artifact churn Co-authored-by: robpitcher <13648061+robpitcher@users.noreply.github.com> * feat(settings): update GitHub token requirements and enhance documentation Updated the required scopes for GitHub Personal Access Tokens to include 'manage_billing:enterprise'. Enhanced documentation to reflect these changes and added details about the AI credit usage report export functionality. * feat(auth, api, db, etl): enhance user scope handling and error reporting Refactor user scope resolution to ensure consistent case-insensitive matching. Update API routes to enforce row-level security for developer roles. Improve error handling in ETL processes to prevent silent failures when reports complete without download URLs. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: robpitcher <13648061+robpitcher@users.noreply.github.com> Co-authored-by: robpitcher --- README.md | 4 +- .../ai-credits/__tests__/route.test.ts | 194 ++++++ app/src/app/api/metrics/ai-credits/route.ts | 209 ++----- .../app/api/metrics/premium-requests/route.ts | 20 +- app/src/app/api/users/route.ts | 16 +- app/src/app/settings/page.tsx | 10 +- app/src/lib/__tests__/auth.test.ts | 71 +++ app/src/lib/auth.ts | 36 ++ app/src/lib/db/ai-credit-consumption.ts | 2 +- app/src/lib/db/ai-credit-usage.ts | 107 +++- .../etl/__tests__/ai-credit-report.test.ts | 111 ++++ app/src/lib/etl/ai-credit-report.ts | 578 ++++++++++++++++++ app/src/lib/etl/scheduler.ts | 14 + docs/architecture.md | 2 +- 14 files changed, 1202 insertions(+), 172 deletions(-) create mode 100644 app/src/app/api/metrics/ai-credits/__tests__/route.test.ts create mode 100644 app/src/lib/etl/__tests__/ai-credit-report.test.ts create mode 100644 app/src/lib/etl/ai-credit-report.ts diff --git a/README.md b/README.md index 809ba4f..6ca6de3 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ prompts it uses, and how analysis is generated, cached, and secured. - **Node.js** 24+ and **pnpm** (`corepack enable`) - **PostgreSQL** 18+ (local or cloud) - **GitHub Enterprise Cloud** with Copilot enabled -- **GitHub Personal Access Token** with `manage_billing:copilot`, `read:enterprise`, `read:org` scopes +- **GitHub Personal Access Token** with `manage_billing:copilot`, `manage_billing:enterprise`, `read:enterprise`, `read:org` scopes ## 🚀 Quick Start @@ -266,7 +266,7 @@ Run the dashboard anywhere you can host a container — your own VM, Kubernetes, - **PostgreSQL** 18+ (managed or self-run), reachable from the app - **Docker** — the app ships a production `Dockerfile` with Next.js standalone output (or use **Node.js** 24+ and **pnpm** for local development) - **TLS / reverse proxy** (e.g. nginx, Caddy) in front of the app for any shared deployment -- **GitHub Personal Access Token** with `manage_billing:copilot`, `read:enterprise`, `read:org` (added later via the Settings UI) +- **GitHub Personal Access Token** with `manage_billing:copilot`, `manage_billing:enterprise`, `read:enterprise`, `read:org` (added later via the Settings UI) - Environment variables (see [Environment Variables](#environment-variables)): - `DATABASE_URL` — **required** - `ADMIN_PASSWORD` — **required** for any shared/non-local deployment diff --git a/app/src/app/api/metrics/ai-credits/__tests__/route.test.ts b/app/src/app/api/metrics/ai-credits/__tests__/route.test.ts new file mode 100644 index 0000000..6f11845 --- /dev/null +++ b/app/src/app/api/metrics/ai-credits/__tests__/route.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { createIdentitySession } from "@/lib/auth"; +import type { NormalizedAiCreditItem } from "@/lib/db/ai-credit-usage"; + +/** + * Server-side row-level scoping tests for `/api/metrics/ai-credits`. + * + * The DB reader is mocked to mirror the real `userScope` filtering so we can + * prove the route never returns another user's rows to a `developer`, while an + * `admin` retains cross-user access. + */ + +const SAMPLE_ITEMS: NormalizedAiCreditItem[] = [ + { + usageDate: "2026-06-01", + product: "Copilot", + sku: "copilot", + model: "gpt-4o", + costCenter: null, + orgName: "acme", + userLogin: "alice", + teamName: null, + unitType: "ai-credits", + pricePerUnit: 0.1, + grossQuantity: 10, + discountQuantity: 4, + netQuantity: 6, + grossAmount: 1, + discountAmount: 0.4, + netAmount: 0.6, + }, + { + usageDate: "2026-06-01", + product: "Copilot", + sku: "copilot", + model: "claude", + costCenter: null, + orgName: "acme", + userLogin: "bob", + teamName: null, + unitType: "ai-credits", + pricePerUnit: 0.1, + grossQuantity: 20, + discountQuantity: 0, + netQuantity: 20, + grossAmount: 2, + discountAmount: 0, + netAmount: 2, + }, +]; + +const getAiCreditItemsByMonthFromDb = vi.fn(); + +vi.mock("@/lib/db/ai-credit-usage", async () => { + const actual = await vi.importActual( + "@/lib/db/ai-credit-usage", + ); + return { + ...actual, + getAiCreditItemsByMonthFromDb: (...args: unknown[]) => + getAiCreditItemsByMonthFromDb(...args), + }; +}); + +vi.mock("@/lib/db/settings", () => ({ + getGitHubConfig: vi.fn(async () => ({ token: "t", enterpriseSlug: "acme-inc" })), +})); + +vi.mock("@/lib/github/resolve-display-names", () => ({ + resolveUserNames: vi.fn(async () => ({ + name: (login: string) => login, + label: (login: string) => login, + map: new Map(), + })), +})); + +// The org-wide consumption layer (upstream) is suppressed for forced developer +// scopes by the route's RLS guard; mock it so tests are deterministic and never +// touch a real DB connection. +vi.mock("@/lib/db/ai-credit-consumption", () => { + const empty = () => ({ + available: false, + totalCreditsUsed: 0, + activeUsers: 0, + perUser: [], + perOrg: [], + perTeam: [], + options: { users: [], orgs: [], teams: [] }, + }); + return { + getCreditConsumption: vi.fn(async () => empty()), + emptyConsumption: vi.fn(() => empty()), + }; +}); + +let GET: typeof import("@/app/api/metrics/ai-credits/route").GET; + +function buildRequest(query: string, identityToken?: string): NextRequest { + const headers: Record = {}; + if (identityToken) headers.cookie = `identity_session=${identityToken}`; + return new NextRequest(`https://example.test/api/metrics/ai-credits${query}`, { + headers, + }); +} + +describe("/api/metrics/ai-credits row-level scoping", () => { + const original = { ...process.env }; + + beforeEach(async () => { + process.env.GITHUB_OAUTH_CLIENT_ID = "id"; + process.env.GITHUB_OAUTH_CLIENT_SECRET = "secret"; + process.env.SESSION_SECRET = "test-session-secret"; + + // Seats fetch — return not-ok so the pagination loop exits immediately. + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("", { status: 404 })), + ); + + // Mirror the real DB scope filter: when userScope is set, only that user's rows. + getAiCreditItemsByMonthFromDb.mockImplementation( + async ( + _slug: string, + points: Array<{ year: number; month: number }>, + userScope?: string | null, + ) => { + const map = new Map(); + const scope = userScope ? userScope.toLowerCase() : null; + for (const p of points) { + if (p.year === 2026 && p.month === 6) { + const rows = scope + ? SAMPLE_ITEMS.filter((i) => (i.userLogin ?? "") === scope) + : SAMPLE_ITEMS; + map.set(`${p.year}-06`, rows); + } + } + return map; + }, + ); + + ({ GET } = await import("@/app/api/metrics/ai-credits/route")); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + process.env = { ...original }; + }); + + it("forces a developer to their own rows even with a crafted ?user=", async () => { + const token = createIdentitySession({ login: "alice", id: 1, role: "developer" }); + const res = await GET(buildRequest("?year=2026&month=6&user=bob", token)); + expect(res.status).toBe(200); + const body = await res.json(); + + // DB read was scoped to the session login, ignoring ?user=bob. + const [, , userScope] = getAiCreditItemsByMonthFromDb.mock.calls[0]; + expect(userScope).toBe("alice"); + + const users = body.perUserBreakdown.map((u: { user: string }) => u.user); + expect(users).toEqual(["alice"]); + expect(users).not.toContain("bob"); + // A scoped developer gets no cross-user filter options — the org-wide + // consumption layer (which feeds the user dropdown) is suppressed for + // forced scopes, so it can never surface another user. + expect(body.filters.options.users).toEqual([]); + }); + + it("scopes a developer with no ?user= to their own rows", async () => { + const token = createIdentitySession({ login: "alice", id: 1, role: "developer" }); + const res = await GET(buildRequest("?year=2026&month=6", token)); + const body = await res.json(); + const [, , userScope] = getAiCreditItemsByMonthFromDb.mock.calls[0]; + expect(userScope).toBe("alice"); + expect(body.perUserBreakdown.map((u: { user: string }) => u.user)).toEqual(["alice"]); + }); + + it("lets an admin read every user's rows (cross-user access retained)", async () => { + const token = createIdentitySession({ login: "boss", id: 9, role: "admin" }); + const res = await GET(buildRequest("?year=2026&month=6", token)); + expect(res.status).toBe(200); + const body = await res.json(); + + // Admin reads are not user-scoped at the DB layer. + const [, , userScope] = getAiCreditItemsByMonthFromDb.mock.calls[0]; + expect(userScope).toBeNull(); + + // Cross-user access retained: the admin sees every user's billing rows. + const users = body.perUserBreakdown.map((u: { user: string }) => u.user); + expect(users).toContain("alice"); + expect(users).toContain("bob"); + }); +}); diff --git a/app/src/app/api/metrics/ai-credits/route.ts b/app/src/app/api/metrics/ai-credits/route.ts index ae80758..db8ed73 100644 --- a/app/src/app/api/metrics/ai-credits/route.ts +++ b/app/src/app/api/metrics/ai-credits/route.ts @@ -3,54 +3,23 @@ import { z } from "zod"; import { getGitHubConfig } from "@/lib/db/settings"; import { resolveUserNames } from "@/lib/github/resolve-display-names"; import { getModelDisplayName } from "@/lib/utils/model-display-names"; -import { safeErrorMessage } from "@/lib/auth"; import { - persistAiCreditSnapshot, - getAiCreditMonthlyTotalsFromDb, + safeErrorMessage, + getIdentitySessionFromRequest, + resolveUserScope, +} from "@/lib/auth"; +import { + getAiCreditItemsByMonthFromDb, monthKey, type NormalizedAiCreditItem, } from "@/lib/db/ai-credit-usage"; -import { getCreditConsumption } from "@/lib/db/ai-credit-consumption"; +import { getCreditConsumption, emptyConsumption } from "@/lib/db/ai-credit-consumption"; export const dynamic = "force-dynamic"; const GITHUB_API_BASE = "https://api.github.com"; const API_VERSION = "2026-03-10"; -/** - * Raw AI Credit usage item as returned by - * `/enterprises/{enterprise}/settings/billing/ai_credit/usage`. - * The aggregated form keys on (model, sku); enriched line items may also carry - * date / organization / user / team / cost-center dimensions. - */ -interface AiCreditUsageItem { - product?: string; - sku?: string; - model?: string; - unitType?: string; - unitTypeString?: string; - pricePerUnit?: number; - grossQuantity?: number; - grossAmount?: number; - discountQuantity?: number; - discountAmount?: number; - netQuantity?: number; - netAmount?: number; - // Optional dimensions present on enriched (non-aggregated) responses. - date?: string; - organizationName?: string; - user?: string; - team?: string; - costCenterName?: string; - costCenter?: string; -} - -interface AiCreditUsageResponse { - timePeriod?: { year: number; month: number }; - enterprise?: string; - usageItems: AiCreditUsageItem[]; -} - interface SeatInfo { plan_type: string; assignee: { login: string }; @@ -70,20 +39,6 @@ const querySchema = z.object({ teamId: z.string().optional(), }); -class GitHubHttpError extends Error { - status: number; - statusText: string; - body: string; - - constructor(status: number, statusText: string, body: string) { - super(`GitHub API error: ${status} ${statusText}`); - this.name = "GitHubHttpError"; - this.status = status; - this.statusText = statusText; - this.body = body; - } -} - function parseCsvSet(value?: string): Set | null { if (!value) return null; const values = value @@ -98,10 +53,6 @@ function shiftMonth(year: number, month: number, delta: number): { year: number; return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1 }; } -function num(v: number | undefined): number { - return typeof v === "number" && Number.isFinite(v) ? v : 0; -} - /** * Per-seat monthly included AI Credit entitlement (USD) under usage-based * billing, as published in the GitHub announcement: @@ -153,52 +104,6 @@ function computeCreditPool( } /** Normalize a raw API item into the shared snapshot shape. */ -function normalizeItem(item: AiCreditUsageItem): NormalizedAiCreditItem { - return { - usageDate: item.date ?? null, - product: item.product ?? "Copilot", - sku: item.sku ?? "", - model: item.model ?? item.sku ?? "", - costCenter: item.costCenterName ?? item.costCenter ?? null, - orgName: item.organizationName ?? null, - userLogin: item.user ?? null, - teamName: item.team ?? null, - unitType: item.unitTypeString ?? item.unitType ?? "ai-credits", - pricePerUnit: num(item.pricePerUnit), - grossQuantity: num(item.grossQuantity), - discountQuantity: num(item.discountQuantity), - netQuantity: num(item.netQuantity), - grossAmount: num(item.grossAmount), - discountAmount: num(item.discountAmount), - netAmount: num(item.netAmount), - }; -} - -async function fetchUsageItems( - token: string, - enterpriseSlug: string, - year: number, - month: number -): Promise { - const usageUrl = `${GITHUB_API_BASE}/enterprises/${encodeURIComponent(enterpriseSlug)}/settings/billing/ai_credit/usage?year=${year}&month=${month}`; - const usageRes = await fetch(usageUrl, { - headers: { - Accept: "application/vnd.github+json", - Authorization: "Bearer " + token, - "X-GitHub-Api-Version": API_VERSION, - }, - next: { revalidate: 0 }, - }); - - if (!usageRes.ok) { - const body = await usageRes.text(); - throw new GitHubHttpError(usageRes.status, usageRes.statusText, body); - } - - const data: AiCreditUsageResponse = await usageRes.json(); - return (data.usageItems ?? []).map(normalizeItem); -} - interface MatchFilters { model: Set | null; costCenter: Set | null; @@ -285,40 +190,29 @@ export async function GET(request: NextRequest) { const year = parsed.year ?? now.getFullYear(); const month = parsed.month ?? now.getMonth() + 1; + // Server-side row-level scoping: a `developer` is forced to their own login + // at the billing layer (derived from their identity session, never the UI). + // Admins and open/shared-password modes read all users' rows; per-user + // filtering for those roles is served by the consumption layer (userId) below. + const session = getIdentitySessionFromRequest(request); + const scope = resolveUserScope(session, null); + const scopedUser = scope.forced ? scope.user : null; + const selectedFilters: MatchFilters = { model: parseCsvSet(parsed.model), costCenter: parseCsvSet(parsed.costCenter), }; - // 1. Fetch selected-month AI Credit usage. - let usageItems: NormalizedAiCreditItem[] = []; - try { - usageItems = await fetchUsageItems(token, enterpriseSlug, year, month); - } catch (err) { - if (err instanceof GitHubHttpError) { - console.error(`AI Credit billing API error: ${err.status}`, err.body); - if (err.status === 403) { - return NextResponse.json( - { error: "Access denied. Your PAT may not have the required scopes. Please ensure it has: manage_billing:copilot (read) or manage_billing:enterprise (read). Update scopes at https://github.com/settings/tokens" }, - { status: 403 } - ); - } - if (err.status === 404) { - return NextResponse.json( - { error: "Enterprise not found, or AI Credit usage is not yet available for this period (the ai_credit/usage endpoint only returns activity after June 1, 2026). Verify the enterprise slug in Settings." }, - { status: 404 } - ); - } - return NextResponse.json( - { error: `GitHub AI Credit Billing API error: ${err.status} ${err.statusText}` }, - { status: err.status } - ); - } - throw err; - } - - // Persist this month's snapshot (best-effort) for trend continuity. - await persistAiCreditSnapshot(enterpriseSlug, year, month, usageItems); + // 1. Read AI Credit usage from persisted rows (no live export on page load). + // The selected month + a trailing window are read in one pass. When a + // developer scope is active, only that user's rows are returned from the DB. + const monthPoints = Array.from({ length: 6 }, (_, idx) => shiftMonth(year, month, idx - 5)); + const itemsByMonth = await getAiCreditItemsByMonthFromDb( + enterpriseSlug, + monthPoints, + scopedUser + ); + const usageItems: NormalizedAiCreditItem[] = itemsByMonth.get(monthKey(year, month)) ?? []; // 2. Fetch seat data for "credits per seat" context (deduped, highest plan wins). const allSeats: SeatInfo[] = []; @@ -416,14 +310,19 @@ export async function GET(request: NextRequest) { const windowStart = `${year}-${String(month).padStart(2, "0")}-01`; const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); const windowEnd = `${year}-${String(month).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`; - const consumption = await getCreditConsumption(windowStart, windowEnd, { - userIds: (parsed.userId ?? "") - .split(",") - .map((s) => parseInt(s.trim(), 10)) - .filter((n) => Number.isInteger(n)), - orgId: parsed.orgId, - teamId: parsed.teamId, - }); + // Row-level security: the consumption layer is org-wide (per-user across the + // enterprise), so a forced `developer` scope never receives it — they only + // ever see their own billing rows (scoped above). Admins/open read the full set. + const consumption = scope.forced + ? emptyConsumption() + : await getCreditConsumption(windowStart, windowEnd, { + userIds: (parsed.userId ?? "") + .split(",") + .map((s) => parseInt(s.trim(), 10)) + .filter((n) => Number.isInteger(n)), + orgId: parsed.orgId, + teamId: parsed.teamId, + }); // Resolve display names for every login surfaced in the response: the // rendered breakdown rows (billing + consumption) and the user filter @@ -464,32 +363,21 @@ export async function GET(request: NextRequest) { poolBase.total > 0 ? Math.round((poolConsumedAmount / poolBase.total) * 100) : 0, }; - // 6. Trailing 6-month trend — DB snapshots first, live API fallback per month. - const monthPoints = Array.from({ length: 6 }, (_, idx) => shiftMonth(year, month, idx - 5)); - const dbTotals = await getAiCreditMonthlyTotalsFromDb(enterpriseSlug, monthPoints); - const hasActiveFilters = Object.values(selectedFilters).some((f) => f !== null); - - const monthlyTrend = await Promise.all( - monthPoints.map(async (point) => { + // 6. Trailing 6-month trend — computed from persisted rows (already scoped). + const monthlyTrend = monthPoints.map((point) => { const key = monthKey(point.year, point.month); const isCurrent = point.year === year && point.month === month; - let bucket: CreditBucket | null = null; + let bucket: CreditBucket; if (isCurrent) { bucket = totals; - } else if (!hasActiveFilters && dbTotals.has(key)) { - // Snapshots store unfiltered totals; only use them when no filter is applied. - bucket = dbTotals.get(key)!; } else { - try { - const monthItems = await fetchUsageItems(token, enterpriseSlug, point.year, point.month); - const filtered = monthItems.filter((item) => usageMatchesFilters(item, selectedFilters)); - const b = emptyBucket(); - for (const item of filtered) addToBucket(b, item); - bucket = b; - } catch { - bucket = emptyBucket(); - } + const monthItems = (itemsByMonth.get(key) ?? []).filter((item) => + usageMatchesFilters(item, selectedFilters) + ); + const b = emptyBucket(); + for (const item of monthItems) addToBucket(b, item); + bucket = b; } return { @@ -501,8 +389,7 @@ export async function GET(request: NextRequest) { grossAmount: round2(bucket.grossAmount), netAmount: round2(bucket.netAmount), }; - }) - ); + }); const currentTrendPoint = monthlyTrend[monthlyTrend.length - 1]; const previousTrendPoint = monthlyTrend[monthlyTrend.length - 2] ?? null; diff --git a/app/src/app/api/metrics/premium-requests/route.ts b/app/src/app/api/metrics/premium-requests/route.ts index 70cd0ab..2a918c1 100644 --- a/app/src/app/api/metrics/premium-requests/route.ts +++ b/app/src/app/api/metrics/premium-requests/route.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { getGitHubConfig } from "@/lib/db/settings"; import { resolveUserNames } from "@/lib/github/resolve-display-names"; import { getModelDisplayName } from "@/lib/utils/model-display-names"; -import { safeErrorMessage } from "@/lib/auth"; +import { safeErrorMessage, getIdentitySessionFromRequest, resolveUserScope } from "@/lib/auth"; export const dynamic = "force-dynamic"; @@ -142,10 +142,16 @@ export async function GET(request: NextRequest) { const year = parsed.year ?? now.getFullYear(); const month = parsed.month ?? now.getMonth() + 1; + // Server-side row-level scoping: a `developer` is forced to their own login + // regardless of any client-supplied `user=`; admins (and open/shared-password + // modes) keep cross-user access. Enforced here, never in the UI alone. + const session = getIdentitySessionFromRequest(request); + const scope = resolveUserScope(session, parsed.user ?? null); + const selectedFilters = { model: parseCsvSet(parsed.model), org: parseCsvSet(parsed.org), - user: parseCsvSet(parsed.user), + user: scope.forced ? new Set([scope.user!]) : parseCsvSet(parsed.user), team: parseCsvSet(parsed.team), }; @@ -176,6 +182,14 @@ export async function GET(request: NextRequest) { throw err; } + // Scope live usage rows to the developer's own login so neither the filter + // option lists nor the breakdowns can expose another user's data. + if (scope.forced) { + usageItems = usageItems.filter( + (item) => (item.user ?? "").toLowerCase() === scope.user + ); + } + // 2. Fetch seat data for plan quotas (deduplicated by user — highest plan wins) const allSeats: SeatInfo[] = []; let page = 1; @@ -388,7 +402,7 @@ export async function GET(request: NextRequest) { selected: { model: parsed.model ?? "", org: parsed.org ?? "", - user: parsed.user ?? "", + user: scope.forced ? scope.user! : (parsed.user ?? ""), team: parsed.team ?? "", }, }, diff --git a/app/src/app/api/users/route.ts b/app/src/app/api/users/route.ts index cc5e2e2..c7c2ca9 100644 --- a/app/src/app/api/users/route.ts +++ b/app/src/app/api/users/route.ts @@ -1,11 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; import { factCopilotUsageDaily, dimUser, dimOrg } from "@/lib/db/schema"; -import { sql, and, gte, lte, eq, ilike } from "drizzle-orm"; +import { sql, and, gte, lte, eq } from "drizzle-orm"; import { daysAgo, isValidDate } from "@/lib/utils"; import { z } from "zod"; import { resolveUserNames } from "@/lib/github/resolve-display-names"; -import { safeErrorMessage } from "@/lib/auth"; +import { safeErrorMessage, getIdentitySessionFromRequest } from "@/lib/auth"; const querySchema = z.object({ start: z.string().refine(isValidDate).optional(), @@ -50,6 +50,18 @@ export async function GET(request: NextRequest) { conditions.push(eq(factCopilotUsageDaily.orgId, params.orgId)); } + // Server-side row-level scoping: a `developer` may only ever read their own + // row. Enforced here (not the UI); admins and open/shared-password modes + // retain full cross-user access. + const session = getIdentitySessionFromRequest(request); + if (session?.role === "developer") { + // Exact, case-insensitive match (never a LIKE pattern), so a login + // containing `_`/`%` can't widen the scope to other users' rows. + conditions.push( + eq(sql`lower(${factCopilotUsageDaily.userLogin})`, session.login.toLowerCase()) + ); + } + const users = await db .select({ userId: factCopilotUsageDaily.userId, diff --git a/app/src/app/settings/page.tsx b/app/src/app/settings/page.tsx index 0ff8d54..9a2b6f5 100644 --- a/app/src/app/settings/page.tsx +++ b/app/src/app/settings/page.tsx @@ -207,10 +207,11 @@ export default function ConfigurationPage() {

Classic (recommended for enterprise):{" "} manage_billing:copilot,{" "} + manage_billing:enterprise,{" "} read:enterprise,{" "} read:org.{" "} 2026-03-10 No scope required (any valid token) + + POST/GET /enterprises/{"{slug}"}/settings/billing/reports + AI Credit usage report export (async; persists per-user rows after June 1, 2026) + 2026-03-10 + manage_billing:enterprise + GET /user/orgs Discover organizations accessible to the token @@ -451,6 +458,7 @@ export default function ConfigurationPage() { All endpoints use Bearer token authentication. For full coverage, the recommended classic PAT scopes are:{" "} manage_billing:copilot,{" "} + manage_billing:enterprise,{" "} read:enterprise,{" "} read:org.

diff --git a/app/src/lib/__tests__/auth.test.ts b/app/src/lib/__tests__/auth.test.ts index e98ef91..273c982 100644 --- a/app/src/lib/__tests__/auth.test.ts +++ b/app/src/lib/__tests__/auth.test.ts @@ -9,6 +9,8 @@ import { verifyIdentitySession, resolveRole, isIdentityModeEnabled, + resolveUserScope, + getIdentitySessionFromRequest, } from "@/lib/auth"; describe("safeCompare", () => { @@ -201,3 +203,72 @@ describe("identity session mint/verify", () => { expect(verifyIdentitySession(token)).toBeNull(); }); }); + +describe("resolveUserScope (server-side row-level scoping)", () => { + it("forces a developer to their own login, ignoring a crafted ?user=", () => { + const scope = resolveUserScope({ login: "Alice", id: 1, role: "developer" }, "bob"); + expect(scope).toEqual({ user: "alice", forced: true }); + }); + + it("scopes a developer with no ?user= to their own login", () => { + const scope = resolveUserScope({ login: "Alice", id: 1, role: "developer" }, null); + expect(scope).toEqual({ user: "alice", forced: true }); + }); + + it("lets an admin keep the requested user (cross-user access)", () => { + const scope = resolveUserScope({ login: "admin", id: 2, role: "admin" }, "bob"); + expect(scope).toEqual({ user: "bob", forced: false }); + }); + + it("lowercases the requested user for consistent case-insensitive matching", () => { + expect(resolveUserScope({ login: "admin", id: 2, role: "admin" }, "Bob_Dev")).toEqual({ + user: "bob_dev", + forced: false, + }); + }); + + it("leaves the requested value untouched when there is no identity session", () => { + expect(resolveUserScope(null, "bob")).toEqual({ user: "bob", forced: false }); + expect(resolveUserScope(null, null)).toEqual({ user: null, forced: false }); + }); +}); + +describe("getIdentitySessionFromRequest", () => { + const original = { ...process.env }; + beforeEach(() => { + process.env.GITHUB_OAUTH_CLIENT_ID = "id"; + process.env.GITHUB_OAUTH_CLIENT_SECRET = "secret"; + process.env.SESSION_SECRET = "test-session-secret"; + }); + afterEach(() => { + process.env = { ...original }; + }); + + function requestWithCookie(name: string, value: string): Request { + return new Request("https://example.test/api/metrics/ai-credits", { + headers: { cookie: `${name}=${value}` }, + }); + } + + it("returns the verified session from the identity cookie", () => { + const token = createIdentitySession({ login: "alice", id: 1, role: "developer" }); + const session = getIdentitySessionFromRequest( + requestWithCookie("identity_session", token), + ); + expect(session).toEqual({ login: "alice", id: 1, role: "developer" }); + }); + + it("returns null when identity mode is disabled", () => { + delete process.env.GITHUB_OAUTH_CLIENT_ID; + const token = createIdentitySession({ login: "alice", id: 1, role: "developer" }); + expect( + getIdentitySessionFromRequest(requestWithCookie("identity_session", token)), + ).toBeNull(); + }); + + it("returns null when no identity cookie is present", () => { + const req = new Request("https://example.test/api/metrics/ai-credits"); + expect(getIdentitySessionFromRequest(req)).toBeNull(); + }); +}); + diff --git a/app/src/lib/auth.ts b/app/src/lib/auth.ts index e4e1d3d..a4ba652 100644 --- a/app/src/lib/auth.ts +++ b/app/src/lib/auth.ts @@ -324,6 +324,42 @@ export function verifyIdentitySession(token: string): IdentitySession | null { return { login, id, role }; } +/** + * Extract and verify the identity session carried on a request's + * `identity_session` cookie. Returns null when identity mode is disabled, no + * cookie is present, or the token is invalid/expired. Use this in API route + * handlers to enforce server-side, row-level scoping. + */ +export function getIdentitySessionFromRequest( + request: Request, +): IdentitySession | null { + if (!isIdentityModeEnabled()) return null; + const token = getCookieValue(request, COOKIE_IDENTITY); + if (!token) return null; + return verifyIdentitySession(token); +} + +/** + * Resolve the effective per-user scope for a request. A `developer` is always + * forced to their own login regardless of any client-supplied `user` value, so + * a crafted `?user=` can never read another user's rows. Admins + * (and open / shared-password modes where there is no identity session) keep the + * requested value. The returned `user` is lowercased for case-insensitive + * matching against stored `user_login` values. + */ +export function resolveUserScope( + session: IdentitySession | null, + requestedUser: string | null | undefined, +): { user: string | null; forced: boolean } { + if (session && session.role === "developer") { + return { user: session.login.toLowerCase(), forced: true }; + } + return { + user: requestedUser == null ? null : requestedUser.toLowerCase(), + forced: false, + }; +} + /* ── Auth Guards ── */ /** diff --git a/app/src/lib/db/ai-credit-consumption.ts b/app/src/lib/db/ai-credit-consumption.ts index d80f1c1..d343113 100644 --- a/app/src/lib/db/ai-credit-consumption.ts +++ b/app/src/lib/db/ai-credit-consumption.ts @@ -63,7 +63,7 @@ export interface CreditConsumptionResult { const round2 = (v: number): number => Math.round(v * 100) / 100; -function emptyConsumption(): CreditConsumptionResult { +export function emptyConsumption(): CreditConsumptionResult { return { available: false, totalCreditsUsed: 0, diff --git a/app/src/lib/db/ai-credit-usage.ts b/app/src/lib/db/ai-credit-usage.ts index bb2e631..ab09f58 100644 --- a/app/src/lib/db/ai-credit-usage.ts +++ b/app/src/lib/db/ai-credit-usage.ts @@ -1,4 +1,4 @@ -import { and, eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { db } from "./index"; import { factAiCreditUsage } from "./schema"; @@ -155,3 +155,108 @@ export async function getAiCreditMonthlyTotalsFromDb( return result; } + +/** Convert a persisted fact row into the shared normalized item shape. */ +function rowToNormalizedItem(row: { + usageDate: string | null; + product: string; + sku: string; + model: string; + costCenter: string | null; + orgName: string | null; + userLogin: string | null; + teamName: string | null; + unitType: string; + pricePerUnit: string; + grossQuantity: string; + discountQuantity: string; + netQuantity: string; + grossAmount: string; + discountAmount: string; + netAmount: string; +}): NormalizedAiCreditItem { + return { + usageDate: row.usageDate, + product: row.product, + sku: row.sku, + model: row.model, + costCenter: row.costCenter, + orgName: row.orgName, + userLogin: row.userLogin, + teamName: row.teamName, + unitType: row.unitType, + pricePerUnit: Number(row.pricePerUnit), + grossQuantity: Number(row.grossQuantity), + discountQuantity: Number(row.discountQuantity), + netQuantity: Number(row.netQuantity), + grossAmount: Number(row.grossAmount), + discountAmount: Number(row.discountAmount), + netAmount: Number(row.netAmount), + }; +} + +const itemColumns = { + periodYear: factAiCreditUsage.periodYear, + periodMonth: factAiCreditUsage.periodMonth, + usageDate: factAiCreditUsage.usageDate, + product: factAiCreditUsage.product, + sku: factAiCreditUsage.sku, + model: factAiCreditUsage.model, + costCenter: factAiCreditUsage.costCenter, + orgName: factAiCreditUsage.orgName, + userLogin: factAiCreditUsage.userLogin, + teamName: factAiCreditUsage.teamName, + unitType: factAiCreditUsage.unitType, + pricePerUnit: factAiCreditUsage.pricePerUnit, + grossQuantity: factAiCreditUsage.grossQuantity, + discountQuantity: factAiCreditUsage.discountQuantity, + netQuantity: factAiCreditUsage.netQuantity, + grossAmount: factAiCreditUsage.grossAmount, + discountAmount: factAiCreditUsage.discountAmount, + netAmount: factAiCreditUsage.netAmount, +} as const; + +/** + * Read persisted AI Credit line items for the given periods, bucketed by + * `${year}-${month}`. When `userScope` is provided, only that user's rows are + * returned — the row-level guard that prevents a developer from ever reading + * another user's data. Comparison against stored `user_login` is + * case-insensitive. Best-effort: returns an empty map on failure. + */ +export async function getAiCreditItemsByMonthFromDb( + enterpriseSlug: string, + points: Array<{ year: number; month: number }>, + userScope?: string | null +): Promise> { + const result = new Map(); + if (points.length === 0) return result; + + const wanted = new Set(points.map((p) => monthKey(p.year, p.month))); + const scope = userScope ? userScope.toLowerCase() : null; + + try { + const conditions = [eq(factAiCreditUsage.enterpriseSlug, enterpriseSlug)]; + // Enforce the developer row-level scope in SQL so other users' rows are + // never read from the DB (defense in depth + less data over the wire). + if (scope) { + conditions.push(eq(sql`lower(${factAiCreditUsage.userLogin})`, scope)); + } + const rows = await db + .select(itemColumns) + .from(factAiCreditUsage) + .where(and(...conditions)); + + for (const row of rows) { + const key = monthKey(row.periodYear, row.periodMonth); + if (!wanted.has(key)) continue; + const bucket = result.get(key) ?? []; + bucket.push(rowToNormalizedItem(row)); + result.set(key, bucket); + } + } catch (err) { + console.warn("getAiCreditItemsByMonthFromDb failed:", err); + } + + return result; +} + diff --git a/app/src/lib/etl/__tests__/ai-credit-report.test.ts b/app/src/lib/etl/__tests__/ai-credit-report.test.ts new file mode 100644 index 0000000..85b0399 --- /dev/null +++ b/app/src/lib/etl/__tests__/ai-credit-report.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from "vitest"; +import { + parseAiCreditCsv, + splitWindows, + firstOfMonthUtc, + yesterdayUtc, +} from "../ai-credit-report"; + +describe("parseAiCreditCsv", () => { + it("parses a date × model × username CSV into normalized rows", () => { + const csv = [ + "date,username,model,sku,gross_quantity,discount_quantity,net_quantity,gross_amount,discount_amount,net_amount", + "2026-06-01,Alice,gpt-4o,copilot,10,4,6,1.00,0.40,0.60", + "2026-06-01,BOB,claude,copilot,5,0,5,0.50,0,0.50", + ].join("\n"); + + const rows = parseAiCreditCsv(csv); + expect(rows).toHaveLength(2); + + expect(rows[0]).toMatchObject({ + usageDate: "2026-06-01", + userLogin: "alice", + model: "gpt-4o", + sku: "copilot", + grossQuantity: 10, + discountQuantity: 4, + netQuantity: 6, + grossAmount: 1, + discountAmount: 0.4, + netAmount: 0.6, + }); + // user_login is lowercased for consistent row-level scoping. + expect(rows[1].userLogin).toBe("bob"); + }); + + it("lowercases usernames and tolerates quoted fields", () => { + const csv = [ + "Date,User,Model,Quantity,Net Amount", + '2026-06-02,"Carol, Jr.",gpt-4o,3,0.30', + ].join("\n"); + const rows = parseAiCreditCsv(csv); + expect(rows).toHaveLength(1); + expect(rows[0].userLogin).toBe("carol, jr."); + // A generic "quantity" column maps to netQuantity, mirrored to grossQuantity. + expect(rows[0].netQuantity).toBe(3); + expect(rows[0].grossQuantity).toBe(3); + expect(rows[0].netAmount).toBe(0.3); + }); + + it("maps alternate header aliases (snake/space/case-insensitive)", () => { + const csv = [ + "usage_date,user_login,model,net_credits,amount", + "2026-06-03,Dave,gpt-4o,7,0.70", + ].join("\n"); + const rows = parseAiCreditCsv(csv); + expect(rows[0]).toMatchObject({ + usageDate: "2026-06-03", + userLogin: "dave", + netQuantity: 7, + netAmount: 0.7, + }); + }); + + it("returns an empty array for empty or header-only content", () => { + expect(parseAiCreditCsv("")).toEqual([]); + expect(parseAiCreditCsv("date,username,model")).toEqual([]); + }); +}); + +describe("splitWindows", () => { + it("returns a single window for a within-month range", () => { + expect(splitWindows("2026-06-01", "2026-06-18")).toEqual([ + { start: "2026-06-01", end: "2026-06-18" }, + ]); + }); + + it("splits a multi-month backfill on calendar-month boundaries", () => { + const windows = splitWindows("2026-05-15", "2026-07-10"); + expect(windows).toEqual([ + { start: "2026-05-15", end: "2026-05-31" }, + { start: "2026-06-01", end: "2026-06-30" }, + { start: "2026-07-01", end: "2026-07-10" }, + ]); + }); + + it("caps any single window at 31 days", () => { + const windows = splitWindows("2026-01-01", "2026-01-31"); + for (const w of windows) { + const days = + (new Date(`${w.end}T00:00:00Z`).getTime() - + new Date(`${w.start}T00:00:00Z`).getTime()) / + 86_400_000 + + 1; + expect(days).toBeLessThanOrEqual(31); + } + }); + + it("returns no windows for an inverted range", () => { + expect(splitWindows("2026-06-10", "2026-06-01")).toEqual([]); + }); +}); + +describe("UTC window defaults", () => { + it("firstOfMonthUtc returns the first day of the month", () => { + expect(firstOfMonthUtc(new Date("2026-06-19T16:35:00Z"))).toBe("2026-06-01"); + }); + + it("yesterdayUtc returns the prior UTC day", () => { + expect(yesterdayUtc(new Date("2026-06-19T16:35:00Z"))).toBe("2026-06-18"); + }); +}); diff --git a/app/src/lib/etl/ai-credit-report.ts b/app/src/lib/etl/ai-credit-report.ts new file mode 100644 index 0000000..2ca25f7 --- /dev/null +++ b/app/src/lib/etl/ai-credit-report.ts @@ -0,0 +1,578 @@ +/** + * AI Credit usage ingestion via the GitHub enterprise async **report export**. + * + * Flow (per the self-service epic, issue C1): + * 1. `POST /enterprises/{enterprise}/settings/billing/reports` with + * `report_type: ai_credit` and a `[start_date, end_date]` window. + * 2. Poll the returned report resource until it completes. + * 3. Download the signed CSV(s) from `download_urls` (short-lived secrets — + * downloaded immediately, never persisted or logged). + * 4. Parse the CSV (grouped by `date × model × username`, with credits + USD), + * normalize rows (lowercased `user_login`), and persist per calendar month + * to `fact_ai_credit_usage` using the existing delete+reinsert snapshot + * semantics so month-to-date refreshes stay idempotent. + * + * Ingestion runs on the existing in-process scheduler for now; issue C2 moves it + * to a dedicated Container Apps cron Job. Because the scheduler runs on every + * replica, a best-effort cross-replica lock (a TTL'd `app_settings` row) plus an + * in-process flag guard against concurrent report creation until C2 lands. + * + * A per-user `?user=` live-usage path is kept purely as a diagnostic fallback. + */ + +import { + persistAiCreditSnapshot, + type NormalizedAiCreditItem, +} from "@/lib/db/ai-credit-usage"; +import { getSetting, setSetting, deleteSetting } from "@/lib/db/settings"; +import { randomUUID } from "node:crypto"; + +const GITHUB_API_BASE = "https://api.github.com"; +const API_VERSION = "2026-03-10"; + +/** Largest report window GitHub accepts per request; longer ranges are split. */ +const MAX_WINDOW_DAYS = 31; + +/** Default poll cadence and ceiling while waiting for a report to complete. */ +const DEFAULT_POLL_INTERVAL_MS = 5_000; +const DEFAULT_MAX_POLL_ATTEMPTS = 60; // ~5 minutes + +/** Cross-replica ingest lock (transitional — removed when C2 lands). */ +const INGEST_LOCK_KEY = "ai_credit_ingest_lock"; +const INGEST_LOCK_TTL_MS = 30 * 60 * 1000; // 30 minutes + +/** In-process guard so a single replica never starts two runs at once. */ +let inProgress = false; + +export interface IngestAiCreditOptions { + token: string; + enterpriseSlug: string; + /** Inclusive window start (YYYY-MM-DD). Defaults to first-of-month UTC. */ + startDate?: string; + /** Inclusive window end (YYYY-MM-DD). Defaults to yesterday UTC. */ + endDate?: string; + pollIntervalMs?: number; + maxPollAttempts?: number; +} + +export interface IngestAiCreditResult { + rowsPersisted: number; + monthsPersisted: number; + windowsProcessed: number; +} + +/* ── Date helpers ── */ + +function toIsoDate(d: Date): string { + return d.toISOString().split("T")[0]; +} + +/** First day of the current month, in UTC. */ +export function firstOfMonthUtc(now = new Date()): string { + return toIsoDate(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))); +} + +/** Yesterday, in UTC. */ +export function yesterdayUtc(now = new Date()): string { + const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + d.setUTCDate(d.getUTCDate() - 1); + return toIsoDate(d); +} + +/** + * Split an inclusive `[start, end]` window into chunks no longer than + * `MAX_WINDOW_DAYS`, broken on calendar-month boundaries so each chunk maps to a + * single `(year, month)` snapshot. + */ +export function splitWindows( + start: string, + end: string +): Array<{ start: string; end: string }> { + const windows: Array<{ start: string; end: string }> = []; + let cursor = new Date(`${start}T00:00:00Z`); + const endDate = new Date(`${end}T00:00:00Z`); + if (Number.isNaN(cursor.getTime()) || Number.isNaN(endDate.getTime()) || cursor > endDate) { + return windows; + } + + while (cursor <= endDate) { + const monthEnd = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, 0)); + let chunkEnd = monthEnd < endDate ? monthEnd : endDate; + + // Enforce the hard window cap within a month as well. + const maxEnd = new Date(cursor); + maxEnd.setUTCDate(maxEnd.getUTCDate() + MAX_WINDOW_DAYS - 1); + if (maxEnd < chunkEnd) chunkEnd = maxEnd; + + windows.push({ start: toIsoDate(cursor), end: toIsoDate(chunkEnd) }); + + cursor = new Date(chunkEnd); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + + return windows; +} + +/* ── CSV parsing ── */ + +/** Parse a single RFC4180-style CSV line, honoring quoted fields. */ +function parseCsvLine(line: string): string[] { + const fields: string[] = []; + let current = ""; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (inQuotes) { + if (char === '"') { + if (line[i + 1] === '"') { + current += '"'; + i++; + } else { + inQuotes = false; + } + } else { + current += char; + } + } else if (char === '"') { + inQuotes = true; + } else if (char === ",") { + fields.push(current); + current = ""; + } else { + current += char; + } + } + fields.push(current); + return fields; +} + +/** Canonicalize a header cell to `snake_case` for alias matching. */ +function canonicalizeHeader(header: string): string { + return header.trim().toLowerCase().replace(/[\s-]+/g, "_"); +} + +/** Map a canonical header to a normalized field, or null when unrecognized. */ +function fieldForHeader(header: string): keyof NormalizedAiCreditItem | "quantity" | null { + switch (header) { + case "date": + case "usage_date": + case "day": + return "usageDate"; + case "username": + case "user": + case "user_login": + case "user_name": + case "login": + return "userLogin"; + case "model": + return "model"; + case "sku": + case "product_sku": + return "sku"; + case "product": + return "product"; + case "organization": + case "organization_name": + case "org": + case "org_name": + return "orgName"; + case "team": + case "team_name": + return "teamName"; + case "cost_center": + case "cost_center_name": + return "costCenter"; + case "unit_type": + case "unit_type_string": + return "unitType"; + case "price_per_unit": + case "unit_price": + return "pricePerUnit"; + case "gross_quantity": + return "grossQuantity"; + case "discount_quantity": + return "discountQuantity"; + case "net_quantity": + case "quantity": + case "credits": + case "net_credits": + return "quantity"; + case "gross_amount": + return "grossAmount"; + case "discount_amount": + return "discountAmount"; + case "net_amount": + case "amount": + return "netAmount"; + default: + return null; + } +} + +function toNumber(value: string | undefined): number { + if (!value) return 0; + const n = Number(value.trim()); + return Number.isFinite(n) ? n : 0; +} + +/** + * Parse an `ai_credit` report CSV into normalized line items. + * + * - `user_login` is lowercased for consistent row-level scoping. + * - A generic `quantity` / `credits` column maps to `netQuantity`; when no + * explicit `gross_quantity` column is present, `grossQuantity` mirrors it so + * downstream headline metrics stay meaningful. `grossAmount` mirrors + * `netAmount` likewise when not provided separately. + */ +export function parseAiCreditCsv(content: string): NormalizedAiCreditItem[] { + const lines = content.split(/\r?\n/).filter((l) => l.trim().length > 0); + if (lines.length < 2) return []; + + const headers = parseCsvLine(lines[0]).map((h) => fieldForHeader(canonicalizeHeader(h))); + const items: NormalizedAiCreditItem[] = []; + + for (let i = 1; i < lines.length; i++) { + const cells = parseCsvLine(lines[i]); + const raw: Record = {}; + headers.forEach((field, idx) => { + if (field) raw[field] = cells[idx] ?? ""; + }); + + const hasExplicitGrossQty = "grossQuantity" in raw; + const hasExplicitGrossAmt = "grossAmount" in raw; + const netQuantity = toNumber(raw.netQuantity ?? raw.quantity); + const netAmount = toNumber(raw.netAmount); + const userLogin = raw.userLogin?.trim() ? raw.userLogin.trim().toLowerCase() : null; + + items.push({ + usageDate: raw.usageDate?.trim() || null, + product: raw.product?.trim() || "Copilot", + sku: raw.sku?.trim() || "", + model: raw.model?.trim() || raw.sku?.trim() || "", + costCenter: raw.costCenter?.trim() || null, + orgName: raw.orgName?.trim() || null, + userLogin, + teamName: raw.teamName?.trim() || null, + unitType: raw.unitType?.trim() || "ai-credits", + pricePerUnit: toNumber(raw.pricePerUnit), + grossQuantity: hasExplicitGrossQty ? toNumber(raw.grossQuantity) : netQuantity, + discountQuantity: toNumber(raw.discountQuantity), + netQuantity, + grossAmount: hasExplicitGrossAmt ? toNumber(raw.grossAmount) : netAmount, + discountAmount: toNumber(raw.discountAmount), + netAmount, + }); + } + + return items; +} + +/* ── Report export orchestration ── */ + +interface ReportResource { + id?: string | number; + report_id?: string | number; + status?: string; + state?: string; + download_urls?: string[]; + download_url?: string; + url?: string; +} + +const COMPLETE_STATUSES = new Set(["completed", "complete", "finished", "succeeded", "done"]); +const FAILED_STATUSES = new Set(["failed", "error", "cancelled", "canceled"]); + +function githubHeaders(token: string): HeadersInit { + return { + Accept: "application/vnd.github+json", + Authorization: "Bearer " + token, + "X-GitHub-Api-Version": API_VERSION, + }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function reportId(resource: ReportResource): string | number | undefined { + return resource.id ?? resource.report_id; +} + +function downloadUrls(resource: ReportResource): string[] { + if (Array.isArray(resource.download_urls)) return resource.download_urls; + if (resource.download_url) return [resource.download_url]; + return []; +} + +/** + * Create one `ai_credit` report for a window, poll to completion and download + + * parse its CSV(s). Returns the normalized rows. Signed download URLs are used + * immediately and never persisted or logged. + */ +async function fetchWindowItems( + opts: IngestAiCreditOptions, + window: { start: string; end: string } +): Promise { + const { token, enterpriseSlug } = opts; + const base = `${GITHUB_API_BASE}/enterprises/${encodeURIComponent(enterpriseSlug)}/settings/billing/reports`; + const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const maxPollAttempts = opts.maxPollAttempts ?? DEFAULT_MAX_POLL_ATTEMPTS; + + console.info( + `AI Credit report export requested (window: ${window.start} → ${window.end})` + ); + + const createRes = await fetch(base, { + method: "POST", + headers: { ...githubHeaders(token), "Content-Type": "application/json" }, + body: JSON.stringify({ + report_type: "ai_credit", + start_date: window.start, + end_date: window.end, + }), + }); + + if (!createRes.ok) { + const body = await createRes.text().catch(() => ""); + if (createRes.status === 403 || createRes.status === 404) { + // The usage report export API enforces enterprise billing access. GitHub + // masks a missing scope/role as a 404 (not 403) for billing resources, so + // surface the actionable cause instead of a bare status code. + throw new Error( + `Failed to create ai_credit report: ${createRes.status} ${createRes.statusText}. ` + + `This endpoint requires the classic PAT scope 'manage_billing:enterprise' (read) and an ` + + `enterprise admin or billing manager role — GitHub returns 404 when the scope or permission ` + + `is missing. Update scopes at https://github.com/settings/tokens. Details: ${body}` + ); + } + throw new Error( + `Failed to create ai_credit report: ${createRes.status} ${createRes.statusText} ${body}` + ); + } + + let resource: ReportResource = await createRes.json(); + let urls = downloadUrls(resource); + let attempts = 0; + + while (urls.length === 0) { + const status = (resource.status ?? resource.state ?? "").toLowerCase(); + if (FAILED_STATUSES.has(status)) { + throw new Error(`ai_credit report export ${status} for window ${window.start} → ${window.end}`); + } + if (attempts >= maxPollAttempts) { + throw new Error( + `ai_credit report export timed out after ${attempts} polls (window ${window.start} → ${window.end})` + ); + } + + await sleep(pollIntervalMs); + attempts++; + + const id = reportId(resource); + const pollUrl = resource.url ?? (id !== undefined ? `${base}/${encodeURIComponent(String(id))}` : null); + if (!pollUrl) { + throw new Error("ai_credit report export response missing id and poll url"); + } + + const pollRes = await fetch(pollUrl, { headers: githubHeaders(token) }); + if (!pollRes.ok) { + const body = await pollRes.text().catch(() => ""); + throw new Error(`Failed to poll ai_credit report: ${pollRes.status} ${pollRes.statusText} ${body}`); + } + resource = await pollRes.json(); + urls = downloadUrls(resource); + + if (urls.length === 0 && COMPLETE_STATUSES.has((resource.status ?? resource.state ?? "").toLowerCase())) { + // A completed report with no download URLs would silently persist nothing + // while looking successful — treat it as a hard failure so the scheduler + // logs an error and the run can be retried/investigated. + throw new Error( + `ai_credit report export completed without download URLs for window ${window.start} → ${window.end}` + ); + } + } + + // Download signed CSV(s) immediately. URLs are short-lived secrets — never logged. + const items: NormalizedAiCreditItem[] = []; + for (const url of urls) { + const fileRes = await fetch(url); + if (!fileRes.ok) { + console.warn(`Failed to download ai_credit report CSV: ${fileRes.status} ${fileRes.statusText}`); + continue; + } + const csv = await fileRes.text(); + items.push(...parseAiCreditCsv(csv)); + } + + console.info(`AI Credit report export parsed ${items.length} row(s) for window ${window.start} → ${window.end}`); + return items; +} + +/** Group normalized items by the calendar month of their `usageDate`. */ +function groupByMonth( + items: NormalizedAiCreditItem[] +): Map { + const groups = new Map(); + for (const item of items) { + if (!item.usageDate) continue; + const [y, m] = item.usageDate.split("-"); + const year = Number(y); + const month = Number(m); + if (!Number.isFinite(year) || !Number.isFinite(month)) continue; + const key = `${year}-${month}`; + const group = groups.get(key) ?? { year, month, items: [] }; + group.items.push(item); + groups.set(key, group); + } + return groups; +} + +/* ── Cross-replica ingest lock (transitional) ── */ + +async function acquireIngestLock(): Promise { + if (inProgress) return false; + try { + const existing = await getSetting(INGEST_LOCK_KEY); + if (existing) { + const ts = Number(existing.split(":")[0]); + if (Number.isFinite(ts) && Date.now() - ts < INGEST_LOCK_TTL_MS) return false; + } + // Write a unique token, then re-read to confirm we still own the lock. If a + // racing replica wrote after us, its token wins and we back off. Best-effort + // (not atomic) but resistant to the simple read→write race — superseded when + // C2 moves ingestion to a single dedicated cron Job. + const ownerToken = `${Date.now()}:${randomUUID()}`; + await setSetting(INGEST_LOCK_KEY, ownerToken); + if ((await getSetting(INGEST_LOCK_KEY)) !== ownerToken) return false; + inProgress = true; + return true; + } catch (err) { + console.warn("AI Credit ingest lock acquisition failed:", err); + return false; + } +} + +async function releaseIngestLock(): Promise { + inProgress = false; + try { + await deleteSetting(INGEST_LOCK_KEY); + } catch (err) { + console.warn("AI Credit ingest lock release failed:", err); + } +} + +/** + * Ingest AI Credit usage via the async report export and persist per-user rows + * to `fact_ai_credit_usage`. Defaults to a first-of-month → yesterday (UTC) + * month-to-date window. Guards against concurrent runs across replicas. + */ +export async function ingestAiCreditUsage( + opts: IngestAiCreditOptions +): Promise { + const result: IngestAiCreditResult = { rowsPersisted: 0, monthsPersisted: 0, windowsProcessed: 0 }; + + const acquired = await acquireIngestLock(); + if (!acquired) { + console.info("AI Credit ingestion skipped — another run holds the lock"); + return result; + } + + try { + const start = opts.startDate ?? firstOfMonthUtc(); + const end = opts.endDate ?? yesterdayUtc(); + const windows = splitWindows(start, end); + if (windows.length === 0) { + console.warn(`AI Credit ingestion skipped — invalid window ${start} → ${end}`); + return result; + } + + console.info(`AI Credit ingestion started (${start} → ${end}, ${windows.length} window(s))`); + + for (const window of windows) { + const items = await fetchWindowItems(opts, window); + result.windowsProcessed++; + + for (const { year, month, items: monthItems } of groupByMonth(items).values()) { + await persistAiCreditSnapshot(opts.enterpriseSlug, year, month, monthItems); + result.monthsPersisted++; + result.rowsPersisted += monthItems.length; + } + } + + console.info( + `AI Credit ingestion complete — rows: ${result.rowsPersisted}, ` + + `months: ${result.monthsPersisted}, windows: ${result.windowsProcessed}` + ); + return result; + } finally { + await releaseIngestLock(); + } +} + +/* ── Diagnostic fallback (per-user live usage) ── */ + +interface AiCreditUsageItem { + product?: string; + sku?: string; + model?: string; + unitType?: string; + unitTypeString?: string; + pricePerUnit?: number; + grossQuantity?: number; + grossAmount?: number; + discountQuantity?: number; + discountAmount?: number; + netQuantity?: number; + netAmount?: number; + date?: string; + organizationName?: string; + user?: string; + team?: string; + costCenterName?: string; + costCenter?: string; +} + +/** + * Diagnostic-only fallback: fetch a single user's AI Credit usage live from the + * per-user `?user=` billing endpoint. Not used by the scheduled ingestion path — + * kept for troubleshooting when the async report export is unavailable. + */ +export async function fetchAiCreditUsageForUser(opts: { + token: string; + enterpriseSlug: string; + year: number; + month: number; + user: string; +}): Promise { + const { token, enterpriseSlug, year, month, user } = opts; + const url = + `${GITHUB_API_BASE}/enterprises/${encodeURIComponent(enterpriseSlug)}` + + `/settings/billing/ai_credit/usage?year=${year}&month=${month}&user=${encodeURIComponent(user)}`; + + const res = await fetch(url, { headers: githubHeaders(token) }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Failed to fetch per-user ai_credit usage: ${res.status} ${res.statusText} ${body}`); + } + + const data: { usageItems?: AiCreditUsageItem[] } = await res.json(); + return (data.usageItems ?? []).map((item) => ({ + usageDate: item.date ?? null, + product: item.product ?? "Copilot", + sku: item.sku ?? "", + model: item.model ?? item.sku ?? "", + costCenter: item.costCenterName ?? item.costCenter ?? null, + orgName: item.organizationName ?? null, + userLogin: item.user ? item.user.toLowerCase() : user.toLowerCase(), + teamName: item.team ?? null, + unitType: item.unitTypeString ?? item.unitType ?? "ai-credits", + pricePerUnit: item.pricePerUnit ?? 0, + grossQuantity: item.grossQuantity ?? 0, + discountQuantity: item.discountQuantity ?? 0, + netQuantity: item.netQuantity ?? 0, + grossAmount: item.grossAmount ?? 0, + discountAmount: item.discountAmount ?? 0, + netAmount: item.netAmount ?? 0, + })); +} diff --git a/app/src/lib/etl/scheduler.ts b/app/src/lib/etl/scheduler.ts index ac0d2f4..be78e47 100644 --- a/app/src/lib/etl/scheduler.ts +++ b/app/src/lib/etl/scheduler.ts @@ -86,6 +86,20 @@ async function runIngestion() { console.error("Scheduled enterprise context sync failed:", contextErr); } + // Persist per-user AI Credit usage via the async report export so the + // dashboard can read rows from the DB instead of a live export on page load. + // (Issue C2 moves this to a dedicated cron Job and removes it from here.) + try { + const { ingestAiCreditUsage } = await import("@/lib/etl/ai-credit-report"); + console.info("Scheduled AI Credit ingestion started"); + const creditResult = await ingestAiCreditUsage({ token, enterpriseSlug }); + console.info( + `Scheduled AI Credit ingestion complete — rows: ${creditResult.rowsPersisted}, months: ${creditResult.monthsPersisted}` + ); + } catch (creditErr) { + console.error("Scheduled AI Credit ingestion failed:", creditErr); + } + // Enforce audit-log retention so the table doesn't grow unbounded. try { const { pruneAuditLog } = await import("@/lib/audit"); diff --git a/docs/architecture.md b/docs/architecture.md index 4a552cd..ca0c068 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -293,7 +293,7 @@ Application settings are stored in the `app_settings` database table and managed | Setting | Description | |---|---| -| GitHub Token | PAT for API access (`manage_billing:copilot`, `read:enterprise`, `read:org`) | +| GitHub Token | PAT for API access (`manage_billing:copilot`, `manage_billing:enterprise`, `read:enterprise`, `read:org`) | | Enterprise Slug | Enterprise identifier for API queries | | Sync Scope | Enterprise-wide or org-specific data ingestion | | Sync Org Logins | Comma-separated org logins (when scope is org-specific) | From d96415d5de15244d861799824261147835221da9 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:27:03 -0400 Subject: [PATCH 3/3] Add My Usage page with role-based navigation gating in identity mode (#12) * Initial plan * Add My Usage page, session endpoint, role-based nav gating, i18n Co-authored-by: robpitcher <13648061+robpitcher@users.noreply.github.com> * Document auth modes + billing source in README; add session endpoint test Co-authored-by: robpitcher <13648061+robpitcher@users.noreply.github.com> * Restore dev next-env.d.ts (revert build artifact change) Co-authored-by: robpitcher <13648061+robpitcher@users.noreply.github.com> * feat(sidebar, translations): add user session display and translations Implement user session display in the sidebar for signed-in users. Add translations for "signed in as" in Arabic, English, Spanish, and French to support localization. * Address PR #12 review: locale-aware My Usage formatting + nav gating polish - My Usage: format currency/numbers and the month label with the active locale (Intl.NumberFormat/DateTimeFormat) instead of hardcoded en-US, so they follow the LocaleProvider language (en/ar/es/fr). - My Usage: clamp month navigation to the API's supported lower bound (year >= 2020) and disable the Prev button at the floor, avoiding an error state from out-of-range requests. - Sidebar: track session load state and render a nav skeleton (and defer the Settings link) until /api/auth/session resolves, preventing identity-mode developers from briefly seeing org-wide links. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: robpitcher <13648061+robpitcher@users.noreply.github.com> Co-authored-by: robpitcher Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 35 ++ .../api/auth/session/__tests__/route.test.ts | 83 +++++ app/src/app/api/auth/session/route.ts | 36 +++ app/src/app/my-usage/page.tsx | 299 ++++++++++++++++++ app/src/components/layout/sidebar.tsx | 123 +++++-- app/src/lib/i18n/translations/ar.ts | 27 ++ app/src/lib/i18n/translations/en.ts | 28 ++ app/src/lib/i18n/translations/es.ts | 27 ++ app/src/lib/i18n/translations/fr.ts | 27 ++ 9 files changed, 657 insertions(+), 28 deletions(-) create mode 100644 app/src/app/api/auth/session/__tests__/route.test.ts create mode 100644 app/src/app/api/auth/session/route.ts create mode 100644 app/src/app/my-usage/page.tsx diff --git a/README.md b/README.md index 6ca6de3..f9faa6d 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ GitHub Copilot is transforming how teams write code — but without visibility i | Page | Route | Description | |---|---|---| | **AI Analyst** | `/ai-analyst` | AI-generated executive, cost, adoption, delivery, ROI, and team analysis grounded in synced dashboard data | +| **My Usage** | `/my-usage` | A signed-in developer's **own** AI Credit consumption, broken down by model, with a month selector and trend (identity mode only) | | **Copilot Usage** | `/metrics` | Daily/weekly active users, code completions, chat mode breakdown, model & language analytics | | **Code Generation** | `/code-generation` | LOC added/deleted by user vs agent, breakdowns by feature, model, and language | | **PR & Autofix** | `/pull-requests` | AI-assisted PR creation, Copilot code review suggestions, autofix analytics, and merge metrics | @@ -220,6 +221,10 @@ Open [http://localhost:3000](http://localhost:3000) and navigate to **Settings** | `DATABASE_URL` | Yes | PostgreSQL connection string (e.g. `postgresql://user:pass@host:5432/db`) | | `ADMIN_PASSWORD` | No | Password for Settings page access. If unset, settings are open to all. Only leave this unset for local development on a trusted machine | | `DASHBOARD_PASSWORD` | No | Password gate for all dashboard pages. If unset, dashboards are open to all. Only leave this unset for local development or intentionally public dashboards | +| `GITHUB_OAUTH_CLIENT_ID` | No | GitHub OAuth App client id. Enables **identity mode** when set together with the two vars below | +| `GITHUB_OAUTH_CLIENT_SECRET` | No | GitHub OAuth App client secret (pair with `GITHUB_OAUTH_CLIENT_ID`) | +| `SESSION_SECRET` | No | Long random string used to HMAC-sign identity session cookies. Required for identity mode | +| `ADMIN_LOGINS` | No | Comma-separated GitHub logins granted the **admin** role in identity mode (case-insensitive). Everyone else signs in as a **developer** | | `NEXT_PUBLIC_BUILD_ID` | No | Git commit SHA shown in sidebar footer (auto-set in Docker) | | `NEXT_PUBLIC_BUILD_TIME` | No | Build timestamp shown in sidebar footer (auto-set in Docker) | @@ -227,6 +232,36 @@ Open [http://localhost:3000](http://localhost:3000) and navigate to **Settings** > For any non-local deployment, set `ADMIN_PASSWORD` to protect the **Settings** page. The Settings UI can configure the GitHub token, Enterprise slug, and sync interval. If dashboard pages should not be publicly accessible, also set `DASHBOARD_PASSWORD`. Leaving these unset is only recommended for local development on a trusted machine. The **GitHub token**, **Enterprise slug**, and **sync interval** are configured via the Settings UI and stored in the database. +A complete, annotated template of every variable lives in [`app/.env.example`](app/.env.example) — copy it to `app/.env` and fill in the values. + +## 🔐 Authentication & Access Modes + +The dashboard supports three **additive** authentication modes. They are selected purely by which environment variables are set, so you can adopt them incrementally without code changes: + +| Mode | How it's enabled | Who can access what | +|---|---|---| +| **Open** | No auth vars set | Everyone sees every page. Use only for local development on a trusted machine | +| **Shared-password** | `DASHBOARD_PASSWORD` and/or `ADMIN_PASSWORD` set | A single password gates the dashboard (`DASHBOARD_PASSWORD`) and/or the Settings pages (`ADMIN_PASSWORD`). There is no per-user identity | +| **Identity (GitHub OAuth)** | `GITHUB_OAUTH_CLIENT_ID` **+** `GITHUB_OAUTH_CLIENT_SECRET` **+** `SESSION_SECRET` all set | Each user signs in with GitHub and receives a signed, stateless session cookie carrying their **login** and **role** (`admin` or `developer`, resolved from `ADMIN_LOGINS`) | + +In **identity mode**: + +- **Developers** see only the **My Usage** page (`/my-usage`) — their own AI Credit consumption, broken down by model, scoped server-side to their signed-in login. Org-wide and cross-user reports (`/ai-credits`, `/seats`, `/users`, etc.) and the Settings pages are hidden from their navigation **and** enforced server-side: a crafted `?user=` can never read another user's rows. +- **Admins** (logins listed in `ADMIN_LOGINS`) see everything, exactly as before. + +### GitHub OAuth App setup + +1. Create a GitHub **OAuth App** (Settings → Developer settings → OAuth Apps → *New OAuth App*). +2. Set the **Authorization callback URL** to `https:///api/auth/github/callback` (use `http://localhost:3000/api/auth/github/callback` for local development). +3. Copy the generated **Client ID** and a **Client secret** into `GITHUB_OAUTH_CLIENT_ID` and `GITHUB_OAUTH_CLIENT_SECRET`. +4. Set `SESSION_SECRET` to a long random string and list your admin GitHub logins in `ADMIN_LOGINS`. + +> The OAuth App only reads each user's GitHub identity (login + id) to establish a session — developers never receive billing or enterprise scopes. + +### Billing data source & ingestion + +AI Credit data is sourced from the validated **async `ai_credit` report export** — `POST /enterprises/{enterprise}/settings/billing/reports` — which returns a download URL once GitHub finishes generating the report. This export requires an **admin enterprise PAT** (classic PAT with `manage_billing:enterprise` read); without that scope GitHub returns `404`. Ingestion is decoupled into a **scheduled Azure Container Apps cron Job** that runs the export, downloads the report, and upserts per-user rows into the database. The dashboard (including **My Usage**) only reads those pre-ingested rows — developers never receive billing scopes and only ever see rows scoped to themselves. + ## 🚢 Deployment **Azure is the recommended — and simplest — way to get the dashboard up and running.** A single `azd up` provisions every resource, wires up secrets and managed identity, and deploys the app. If you'd rather run it yourself, a self-hosted option is fully supported too. diff --git a/app/src/app/api/auth/session/__tests__/route.test.ts b/app/src/app/api/auth/session/__tests__/route.test.ts new file mode 100644 index 0000000..e6cb4e9 --- /dev/null +++ b/app/src/app/api/auth/session/__tests__/route.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; +import { createIdentitySession } from "@/lib/auth"; +import { GET } from "@/app/api/auth/session/route"; + +/** + * Tests for `/api/auth/session`, the client-facing endpoint the sidebar uses to + * gate navigation by role. It must never leak secrets and must reflect the + * active auth mode + the signed-in user's role. + */ + +function buildRequest(identityToken?: string): NextRequest { + const headers: Record = {}; + if (identityToken) headers.cookie = `identity_session=${identityToken}`; + return new NextRequest("https://example.test/api/auth/session", { headers }); +} + +describe("/api/auth/session", () => { + const original = { ...process.env }; + + afterEach(() => { + process.env = { ...original }; + }); + + describe("when identity mode is disabled", () => { + beforeEach(() => { + delete process.env.GITHUB_OAUTH_CLIENT_ID; + delete process.env.GITHUB_OAUTH_CLIENT_SECRET; + delete process.env.SESSION_SECRET; + }); + + it("reports identityMode false and no session", async () => { + const res = await GET(buildRequest()); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ + identityMode: false, + authenticated: false, + login: null, + role: null, + }); + }); + }); + + describe("when identity mode is enabled", () => { + beforeEach(() => { + process.env.GITHUB_OAUTH_CLIENT_ID = "id"; + process.env.GITHUB_OAUTH_CLIENT_SECRET = "secret"; + process.env.SESSION_SECRET = "test-session-secret"; + }); + + it("returns the developer role for a signed-in developer", async () => { + const token = createIdentitySession({ login: "alice", id: 1, role: "developer" }); + const res = await GET(buildRequest(token)); + const body = await res.json(); + expect(body).toEqual({ + identityMode: true, + authenticated: true, + login: "alice", + role: "developer", + }); + }); + + it("returns the admin role for a signed-in admin", async () => { + const token = createIdentitySession({ login: "boss", id: 9, role: "admin" }); + const res = await GET(buildRequest(token)); + const body = await res.json(); + expect(body.role).toBe("admin"); + expect(body.login).toBe("boss"); + }); + + it("reports unauthenticated when no cookie is present", async () => { + const res = await GET(buildRequest()); + const body = await res.json(); + expect(body).toEqual({ + identityMode: true, + authenticated: false, + login: null, + role: null, + }); + }); + }); +}); diff --git a/app/src/app/api/auth/session/route.ts b/app/src/app/api/auth/session/route.ts new file mode 100644 index 0000000..5924ee1 --- /dev/null +++ b/app/src/app/api/auth/session/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + isIdentityModeEnabled, + getIdentitySessionFromRequest, + safeErrorMessage, +} from "@/lib/auth"; + +export const dynamic = "force-dynamic"; + +/** + * Expose the current identity session to client components so the UI can gate + * navigation by role. Only meaningful in identity mode (GitHub OAuth); in open + * and shared-password modes `identityMode` is false and the client keeps its + * existing behavior. This endpoint never returns secrets — only the signed-in + * login and resolved role. Server-side row-level scoping is enforced + * independently in each data route (see `resolveUserScope`). + */ +export async function GET(request: NextRequest) { + try { + const identityMode = isIdentityModeEnabled(); + const session = identityMode ? getIdentitySessionFromRequest(request) : null; + + return NextResponse.json({ + identityMode, + authenticated: !!session, + login: session?.login ?? null, + role: session?.role ?? null, + }); + } catch (error) { + console.error("Failed to resolve identity session:", error); + return NextResponse.json( + { error: safeErrorMessage(error, "Internal server error") }, + { status: 500 }, + ); + } +} diff --git a/app/src/app/my-usage/page.tsx b/app/src/app/my-usage/page.tsx new file mode 100644 index 0000000..8fa0c96 --- /dev/null +++ b/app/src/app/my-usage/page.tsx @@ -0,0 +1,299 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import "@/lib/chart-registry"; +import { Bar, Line } from "react-chartjs-2"; +import { useChartOptions } from "@/lib/theme/chart-theme"; +import { useTranslation } from "@/lib/i18n/locale-provider"; +import { PageHeader } from "@/components/layout/page-header"; +import { ReportBanner } from "@/components/layout/report-banner"; +import { DataTable } from "@/components/ui/data-table"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { EmptyState } from "@/components/ui/empty-state"; +import { ConfigurationBanner } from "@/components/layout/configuration-banner"; +import { DataSourceBanner } from "@/components/layout/report-filters"; + +interface CreditBucket { + grossQuantity: number; + discountQuantity: number; + netQuantity: number; + grossAmount: number; + discountAmount: number; + netAmount: number; +} + +interface MyUsageTotals { + grossCredits: number; + includedCredits: number; + billableCredits: number; + grossAmount: number; + discountAmount: number; + netAmount: number; + discountCoveragePct: number; + effectivePricePerCredit: number; +} + +type ModelBreakdown = CreditBucket & { model: string }; + +interface MonthlyTrendPoint { + year: number; + month: number; + label: string; + grossCredits: number; + billableCredits: number; + grossAmount: number; + netAmount: number; +} + +interface AiCreditData { + period: { year: number; month: number }; + totals: MyUsageTotals; + perModelBreakdown: ModelBreakdown[]; + monthlyTrend: MonthlyTrendPoint[]; +} + +// Lower bound supported by /api/metrics/ai-credits (the query schema enforces +// year >= 2020). Month navigation is clamped to this so the page never requests +// a period the API rejects. +const MIN_YEAR = 2020; + +const MODEL_COLORS = [ + "#8b5cf6", "#a855f7", "#c084fc", "#d8b4fe", "#7c3aed", + "#6d28d9", "#5b21b6", "#4c1d95", "#ec4899", "#f43f5e", + "#3b82f6", "#6366f1", "#14b8a6", "#f59e0b", "#10b981", +]; + +export default function MyUsagePage() { + const { commonOptions: barOpts } = useChartOptions(); + const { t, locale } = useTranslation(); + const now = new Date(); + const [year, setYear] = useState(now.getFullYear()); + const [month, setMonth] = useState(now.getMonth() + 1); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [notConfigured, setNotConfigured] = useState(false); + + useEffect(() => { + setLoading(true); + setError(null); + setNotConfigured(false); + + // Identity is implicit: no `user` param is sent. The server forces the + // scope to the signed-in developer's own login (issue C1), so this page + // can never read another user's rows. + const params = new URLSearchParams({ year: String(year), month: String(month) }); + + fetch(`/api/metrics/ai-credits?${params.toString()}`) + .then(async (res) => { + if (res.status === 400) { + setNotConfigured(true); + return null; + } + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${res.status}`); + } + return res.json() as Promise; + }) + .then((credits) => setData(credits)) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, [year, month]); + + // Locale-aware formatters follow the dashboard language selected via + // LocaleProvider (en/ar/es/fr) instead of a hardcoded en-US format. + const currencyFmt = useMemo( + () => + new Intl.NumberFormat(locale, { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), + [locale] + ); + const numberFmt = useMemo( + () => new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }), + [locale] + ); + const fmt$ = (v: number) => currencyFmt.format(v); + const fmtNum = (v: number) => numberFmt.format(v); + + const monthLabel = useMemo( + () => + new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format( + new Date(year, month - 1, 1) + ), + [locale, year, month] + ); + + const modelBar = useMemo(() => { + if (!data || data.perModelBreakdown.length === 0) return null; + const top = data.perModelBreakdown.slice(0, 15); + return { + labels: top.map((m) => m.model), + datasets: [{ + label: t("myUsage.creditsLabel"), + data: top.map((m) => m.grossQuantity), + backgroundColor: top.map((_, i) => MODEL_COLORS[i % MODEL_COLORS.length]), + borderRadius: 6, + }], + }; + }, [data, t]); + + const trendLine = useMemo(() => { + if (!data || data.monthlyTrend.length === 0) return null; + return { + labels: data.monthlyTrend.map((m) => m.label), + datasets: [{ + label: t("myUsage.netSpend"), + data: data.monthlyTrend.map((m) => m.netAmount), + borderColor: "#6366f1", + backgroundColor: "rgba(99,102,241,0.2)", + fill: true, + tension: 0.3, + }], + }; + }, [data, t]); + + const goMonth = (delta: number) => { + let m = month + delta; + let y = year; + if (m < 1) { m = 12; y--; } + if (m > 12) { m = 1; y++; } + // Clamp to the API's supported lower bound so navigation can't request a + // year the query schema rejects (which would surface as an error state). + if (y < MIN_YEAR) { y = MIN_YEAR; m = 1; } + setYear(y); + setMonth(m); + }; + + if (loading) { + return ; + } + + if (notConfigured) { + return ( +
+ + +
+ ); + } + + if (error) { + return ( +
+ + +
+ ); + } + + if (!data) return null; + + const { totals } = data; + const hasUsage = data.perModelBreakdown.length > 0 || totals.grossCredits > 0; + + return ( +
+ + + + + +
+ + + {monthLabel} + + +
+ + {hasUsage ? ( + <> +
+ + + 0 ? "text-indigo-600" : "text-gray-900"} /> + 0 ? "text-indigo-600" : "text-gray-900"} /> +
+ +
+ + {modelBar ? ( +
+ ) : ( +

{t("myUsage.noModelData")}

+ )} +
+ + {trendLine ? ( +
+ ) : ( +

{t("myUsage.noTrend")}

+ )} +
+
+ + {data.perModelBreakdown.length > 0 && ( + + {String(value)} }, + { key: "grossQuantity", header: t("myUsage.grossCredits"), align: "right", render: (value: unknown) => fmtNum(Number(value)) }, + { key: "netQuantity", header: t("myUsage.billableCredits"), align: "right", render: (value: unknown) => fmtNum(Number(value)) }, + { key: "grossAmount", header: t("myUsage.grossAmount"), align: "right", render: (value: unknown) => fmt$(Number(value)) }, + { key: "netAmount", header: t("myUsage.netAmount"), align: "right", render: (value: unknown) => {fmt$(Number(value))} }, + ]} + data={(data.perModelBreakdown) as unknown as Record[]} + emptyMessage={t("myUsage.noModelData")} + pageSize={25} + defaultSortKey="grossQuantity" + defaultSortDir="desc" + /> + + )} + + ) : ( + + )} +
+ ); +} + +function Card({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+
{children}
+
+ ); +} + +function Kpi({ label, value, color }: { label: string; value: string | number; color?: string }) { + return ( +
+

{label}

+

+ {typeof value === "number" ? value.toLocaleString() : value} +

+
+ ); +} diff --git a/app/src/components/layout/sidebar.tsx b/app/src/components/layout/sidebar.tsx index a31a1b7..17b4135 100644 --- a/app/src/components/layout/sidebar.tsx +++ b/app/src/components/layout/sidebar.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import Image from "next/image"; import { usePathname } from "next/navigation"; -import { useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { useGSAP } from "@gsap/react"; import gsap from "gsap"; import { cn } from "@/lib/utils"; @@ -27,29 +27,48 @@ import { Moon, Monitor, Globe, + UserCircle, } from "lucide-react"; import { AgentIcon } from "@/components/icons/agent-icon"; import { CliIcon } from "@/components/icons/cli-icon"; -// Reports are ordered from highest impact (top) to lowest. The deprecated -// Premium Requests report sits at the bottom, followed by a separator and the -// Metrics Reference. +/** + * Navigation entries. Flags drive role-based gating in identity mode: + * - `identityOnly`: only shown when identity mode is enabled (e.g. "My Usage", + * which scopes to the signed-in developer's own rows). + * - `orgWide`: a cross-user / org-wide report hidden from developers; admins + * (and open / shared-password modes) keep the current behavior. + * Server-side enforcement (issue C1) is the source of truth — this only hides + * nav entries that a developer cannot meaningfully use. + * + * Reports are ordered from highest impact (top) to lowest. The deprecated + * Premium Requests report sits at the bottom, followed by a separator and the + * Metrics Reference. + */ const NAV_KEYS = [ - { key: "aiAnalyst.nav", href: "/ai-analyst", icon: Sparkles }, - { key: "nav.copilotUsage", href: "/metrics", icon: BarChart3 }, - { key: "nav.codeGeneration", href: "/code-generation", icon: Code }, - { key: "nav.pullRequests", href: "/pull-requests", icon: GitPullRequest }, - { key: "nav.agentImpact", href: "/agents", icon: AgentIcon }, - { key: "nav.aiAdoption", href: "/ai-adoption", icon: Layers }, - { key: "nav.cliImpact", href: "/cli", icon: CliIcon }, - { key: "nav.copilotLicensing", href: "/seats", icon: CreditCard }, - { key: "nav.aiCredits", href: "/ai-credits", icon: Coins }, - { key: "nav.usersData", href: "/users", icon: Contact }, - { key: "nav.enterpriseTeams", href: "/enterprise-teams", icon: Network }, - { key: "nav.premiumRequests", href: "/premium-requests", icon: Gem }, - { key: "nav.metricsReference", href: "/reference", icon: BookOpen, separatorBefore: true }, + { key: "aiAnalyst.nav", href: "/ai-analyst", icon: Sparkles, orgWide: true }, + { key: "nav.myUsage", href: "/my-usage", icon: UserCircle, identityOnly: true }, + { key: "nav.copilotUsage", href: "/metrics", icon: BarChart3, orgWide: true }, + { key: "nav.codeGeneration", href: "/code-generation", icon: Code, orgWide: true }, + { key: "nav.pullRequests", href: "/pull-requests", icon: GitPullRequest, orgWide: true }, + { key: "nav.agentImpact", href: "/agents", icon: AgentIcon, orgWide: true }, + { key: "nav.aiAdoption", href: "/ai-adoption", icon: Layers, orgWide: true }, + { key: "nav.cliImpact", href: "/cli", icon: CliIcon, orgWide: true }, + { key: "nav.copilotLicensing", href: "/seats", icon: CreditCard, orgWide: true }, + { key: "nav.aiCredits", href: "/ai-credits", icon: Coins, orgWide: true }, + { key: "nav.usersData", href: "/users", icon: Contact, orgWide: true }, + { key: "nav.enterpriseTeams", href: "/enterprise-teams", icon: Network, orgWide: true }, + { key: "nav.premiumRequests", href: "/premium-requests", icon: Gem, orgWide: true }, + { key: "nav.metricsReference", href: "/reference", icon: BookOpen, orgWide: true, separatorBefore: true }, ]; +interface SessionInfo { + identityMode: boolean; + authenticated: boolean; + login: string | null; + role: "admin" | "developer" | null; +} + const THEME_ICONS = { light: Sun, dark: Moon, system: Monitor } as const; export function Sidebar() { @@ -57,9 +76,35 @@ export function Sidebar() { const { theme, setTheme } = useTheme(); const { t, locale, setLocale, locales, dir } = useTranslation(); const navRef = useRef(null); + const [session, setSession] = useState(null); + const [sessionLoaded, setSessionLoaded] = useState(false); + + useEffect(() => { + fetch("/api/auth/session") + .then((r) => (r.ok ? (r.json() as Promise) : null)) + .then((data) => { + if (data) setSession(data); + }) + .catch(() => { + // Fail open: keep current (non-gated) behavior if the check fails. + }) + .finally(() => setSessionLoaded(true)); + }, []); + + // Gating only applies in identity mode. A developer sees "My Usage" and no + // org-wide reports; admins (and open / shared-password modes) see everything. + const isIdentityMode = session?.identityMode === true; + const isDeveloper = isIdentityMode && session?.role === "developer"; + + const navItems = NAV_KEYS.filter((item) => { + if ("identityOnly" in item && item.identityOnly && !isIdentityMode) return false; + if ("orgWide" in item && item.orgWide && isDeveloper) return false; + return true; + }); // Stagger the navigation links into view on mount. They slide in from the - // inline-start edge so the motion reads naturally in both LTR and RTL. + // inline-start edge so the motion reads naturally in both LTR and RTL. Re-runs + // when the gated nav list resolves so the real items animate (not the skeleton). useGSAP( () => { const root = navRef.current; @@ -73,7 +118,7 @@ export function Sidebar() { stagger: 0.04, }); }, - { scope: navRef, dependencies: [dir] } + { scope: navRef, dependencies: [dir, sessionLoaded] } ); const cycleTheme = () => { @@ -94,7 +139,7 @@ export function Sidebar() {
- - - {t("common.settings")} - + {session?.login && ( +
+ +
+

+ {t("common.signedInAs")} +

+

+ {session.login} +

+
+
+ )} + {sessionLoaded && !isDeveloper && ( + + + {t("common.settings")} + + )}