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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,28 @@ ENVIRONMENT=development
# API port (default: 8000)
PORT=8000

# ============================================================================
# Admin Auth (for /debug/* endpoints)
# ============================================================================

# GET /debug/status and GET /debug/ping can be authorized either by a valid
# Cloudflare Access JWT (production browser flow) or by ADMIN_TOKEN.
# Leave ADMIN_TOKEN unset if you only want Cloudflare Access auth.
# Set it for local development, CI, or as a break-glass fallback when
# Cloudflare Access is unavailable. Locally, any non-empty string works
# because these endpoints are only reachable on localhost.
# ADMIN_TOKEN=

# Cloudflare Access (Zero Trust) settings — only needed when verifying the
# Cf-Access-Jwt-Assertion header in production. Leave unset locally.
# CF_ACCESS_TEAM_DOMAIN=anyplot.cloudflareaccess.com
# CF_ACCESS_AUD=

# Comma-separated list of allowed Google account email addresses for the
# Cloudflare Access JWT path. Required for the JWT path to authorize anyone;
# requests with a valid JWT but an unlisted email return 403.
# ADMIN_ALLOWED_EMAILS=alice@example.com,bob@example.com

# ============================================================================
# AI Services (optional)
# ============================================================================
Expand Down
16 changes: 15 additions & 1 deletion api/cloudbuild.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ substitutions:
_CPU: "1"
_MIN_INSTANCES: "1"
_MAX_INSTANCES: "3"
# Cloudflare Access (Zero Trust) for /debug/* — the team domain is stable;
# the AUD must be filled in once the Self-hosted Application is created in
# the Cloudflare Zero Trust dashboard. With AUD unset (or with no email
# in _ADMIN_ALLOWED_EMAILS), the JWT path in require_admin denies all and
# only the X-Admin-Token fallback is active.
Comment on lines +12 to +13
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says that with no email in _ADMIN_ALLOWED_EMAILS “only the X-Admin-Token fallback is active”. With the current require_admin logic, a valid Cloudflare JWT with an unlisted email yields a 403 and does not fall through to the token path, so the token fallback is not active for requests that include Cf-Access-Jwt-Assertion. Please update the comment to match the implemented behavior (or adjust the code to match the comment).

Suggested change
# in _ADMIN_ALLOWED_EMAILS), the JWT path in require_admin denies all and
# only the X-Admin-Token fallback is active.
# in _ADMIN_ALLOWED_EMAILS), the JWT path in require_admin does not
# authorize anyone. Requests that include a Cloudflare JWT but fail the
# email allowlist check are denied with 403 rather than falling back to
# X-Admin-Token.

Copilot uses AI. Check for mistakes.
_CF_ACCESS_TEAM_DOMAIN: "anyplot.cloudflareaccess.com"
_CF_ACCESS_AUD: ""
# Comma-separated. Browser users with a valid Cloudflare Access JWT but an
# email not in this list get 403. Required for the JWT path to authorize
# anyone.
_ADMIN_ALLOWED_EMAILS: ""

steps:
# Build the container image
Expand Down Expand Up @@ -53,11 +64,14 @@ steps:
- "--port=8000"
- "--allow-unauthenticated"
- "--add-cloudsql-instances=anyplot:europe-west4:anyplot-db"
- "--set-secrets=DATABASE_URL=DATABASE_URL:latest,CACHE_INVALIDATE_TOKEN=CACHE_INVALIDATE_TOKEN:latest"
- "--set-secrets=DATABASE_URL=DATABASE_URL:latest,CACHE_INVALIDATE_TOKEN=CACHE_INVALIDATE_TOKEN:latest,ADMIN_TOKEN=ADMIN_TOKEN:latest"
- "--set-env-vars=ENVIRONMENT=production"
- "--execution-environment=gen2"
- "--set-env-vars=GOOGLE_CLOUD_PROJECT=$PROJECT_ID"
- "--set-env-vars=GCS_BUCKET=anyplot-images"
- "--set-env-vars=CF_ACCESS_TEAM_DOMAIN=${_CF_ACCESS_TEAM_DOMAIN}"
- "--set-env-vars=CF_ACCESS_AUD=${_CF_ACCESS_AUD}"
- "--set-env-vars=^@^ADMIN_ALLOWED_EMAILS=${_ADMIN_ALLOWED_EMAILS}"
- "--cpu-throttling"
- "--concurrency=15"
- "--timeout=600"
Expand Down
69 changes: 63 additions & 6 deletions api/routers/debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
import time
from collections import Counter
from datetime import datetime, timedelta, timezone
from functools import lru_cache

import jwt as pyjwt
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from pydantic import BaseModel
from sqlalchemy import text
Expand All @@ -22,14 +24,69 @@
router = APIRouter(prefix="/debug", tags=["debug"])


def require_admin(x_admin_token: str | None = Header(default=None)) -> None:
"""Gate sensitive /debug/* endpoints behind a shared secret.
@lru_cache(maxsize=1)
def _jwks_client() -> pyjwt.PyJWKClient | None:
"""Lazy, process-wide JWKS client for Cloudflare Access JWT verification.

Without this gate, /debug/status and /debug/ping reflect quality scores,
weakness aggregates, and DB latency to the public internet. When
settings.admin_token is unset the endpoint is disabled (503), so a
misconfigured prod deploy fails closed instead of fails open.
PyJWKClient caches keys per instance; we cache the instance itself so a
single Cloud Run worker only fetches the JWKS endpoint once at first
/debug request after cold start.
"""
if not settings.cf_access_team_domain:
return None
return pyjwt.PyJWKClient(f"https://{settings.cf_access_team_domain}/cdn-cgi/access/certs")


def _verify_cf_access_jwt(token: str) -> str | None:
"""Verify a Cloudflare Access JWT and return the authenticated email.

Returns None when the JWT path is unconfigured, the signature is invalid,
the token is expired, or the aud/iss claims don't match.
"""
client = _jwks_client()
if client is None or not settings.cf_access_aud:
return None
try:
signing_key = client.get_signing_key_from_jwt(token)
claims = pyjwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience=settings.cf_access_aud,
issuer=f"https://{settings.cf_access_team_domain}",
)
except pyjwt.PyJWTError:
return None
email = claims.get("email")
return email if isinstance(email, str) else None


def require_admin(
x_admin_token: str | None = Header(default=None),
cf_access_jwt: str | None = Header(default=None, alias="Cf-Access-Jwt-Assertion"),
) -> None:
"""Gate sensitive /debug/* endpoints behind Cloudflare Access OR a shared secret.

Two paths:
1. Browser path — Cloudflare Access verifies a Google identity at the edge,
forwards the request with `Cf-Access-Jwt-Assertion`. We verify the JWT
against Cloudflare's JWKS and check the email is on the allow-list.
2. Token path — `X-Admin-Token` against `settings.admin_token`. Used by CI,
local dev, and break-glass access via the Cloud Run direct URL (which
bypasses Cloudflare).

Without `settings.admin_token` configured the token path is disabled (503),
so a misconfigured prod deploy without Cloudflare Access still fails closed.
"""
if cf_access_jwt:
email = _verify_cf_access_jwt(cf_access_jwt)
if email and email in settings.admin_allowed_emails:
return
if email:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"User {email} not authorized")
# Invalid JWT (signature/aud/iss/expiry) — fall through to token path so
Comment on lines +81 to +87
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When Cf-Access-Jwt-Assertion is present and _verify_cf_access_jwt returns an email that is not allow-listed, require_admin raises 403 before checking X-Admin-Token. This means the documented “token fallback” cannot work from a browser session that is signed into Cloudflare Access with the wrong account (the edge will still inject the JWT header). Consider allowing a valid X-Admin-Token to override the 403 case (e.g., only raise 403 if the admin token is missing/invalid), so the fallback truly behaves as an OR condition.

Copilot uses AI. Check for mistakes.
# a misconfigured edge never strands the operator without break-glass access.

expected = settings.admin_token
if not expected:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Debug endpoints not configured")
Expand Down
31 changes: 28 additions & 3 deletions app/src/pages/DebugPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ describe('DebugPage', () => {
render(<DebugPage />);

await waitFor(() => {
expect(screen.getByPlaceholderText('X-Admin-Token')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Admin token (fallback)')).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /unlock/i })).toBeInTheDocument();
expect(screen.getByText(/admin token required/i)).toBeInTheDocument();
Expand All @@ -112,6 +112,31 @@ describe('DebugPage', () => {
});
});

it('surfaces the server message on 403 (Cloudflare Access denial)', async () => {
// Cloudflare Access path: signed-in Google account not on the
// admin_allowed_emails allow-list. Backend returns 403 with the email
// in the detail; the page should switch to the auth-required screen and
// show that message instead of falling through to "failed to load: 403".
sessionStorage.clear();
vi.stubGlobal(
'fetch',
vi.fn(() =>
Promise.resolve({
ok: false,
status: 403,
json: () => Promise.resolve({ status: 403, message: 'User stranger@example.com not authorized', path: '/debug/status' }),
}),
),
);

render(<DebugPage />);

await waitFor(() => {
expect(screen.getByText(/stranger@example\.com not authorized/i)).toBeInTheDocument();
});
expect(screen.getByPlaceholderText('Admin token (fallback)')).toBeInTheDocument();
});

it('submits the token, sends X-Admin-Token, and persists to sessionStorage', async () => {
sessionStorage.clear();
let callIndex = 0;
Expand All @@ -136,7 +161,7 @@ describe('DebugPage', () => {

render(<DebugPage />);

const input = await screen.findByPlaceholderText('X-Admin-Token');
const input = await screen.findByPlaceholderText('Admin token (fallback)');
fireEvent.change(input, { target: { value: 'secret-xyz' } });
fireEvent.click(screen.getByRole('button', { name: /unlock/i }));

Expand All @@ -159,7 +184,7 @@ describe('DebugPage', () => {
expect(sessionStorage.getItem('anyplot.adminToken')).toBeNull();
});
// Form is still on screen because fetch still returns 401.
expect(screen.getByPlaceholderText('X-Admin-Token')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Admin token (fallback)')).toBeInTheDocument();
});

});
33 changes: 24 additions & 9 deletions app/src/pages/DebugPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,14 @@ function pingColor(ms: number): string {
return colors.error;
}

// Admin-token storage. /debug endpoints require X-Admin-Token in prod
// (require_admin gate, api/routers/debug.py); we keep the token in
// sessionStorage so it survives reloads of the same tab without persisting
// across browser sessions.
// Admin auth for /debug endpoints (require_admin in api/routers/debug.py).
// Two paths:
// - Cloudflare Access cookie set on .anyplot.ai → forwarded as Cf-Access-Jwt-Assertion
// to api.anyplot.ai. credentials: 'include' is required for the cookie to
// travel cross-origin to the API.
// - X-Admin-Token header as a fallback (CI, break-glass, local dev). Stored
// in sessionStorage so it survives reloads of the same tab without
// persisting across browser sessions.
const ADMIN_TOKEN_KEY = 'anyplot.adminToken';
const readAdminToken = (): string => {
try { return sessionStorage.getItem(ADMIN_TOKEN_KEY) ?? ''; } catch { return ''; }
Expand All @@ -176,7 +180,10 @@ const clearAdminToken = (): void => {
};

const adminFetch = (url: string, token: string): Promise<Response> =>
fetch(url, token ? { headers: { 'X-Admin-Token': token } } : undefined);
fetch(url, {
credentials: 'include',
headers: token ? { 'X-Admin-Token': token } : undefined,
});
Comment on lines 182 to +186
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that credentials: 'include' is always set, the page will start exercising the Cloudflare Access path, where the backend can return a 403 (valid JWT but email not allow-listed). The current UI only treats 401/503 as "auth required"; a 403 will fall through to the generic "failed to load: 403" screen. Consider handling 403 explicitly (e.g., show the server message / prompt to sign in with an allowed account) so the new denial mode is understandable.

Copilot uses AI. Check for mistakes.

export function DebugPage() {
const [data, setData] = useState<DebugStatus | null>(null);
Expand All @@ -200,9 +207,17 @@ export function DebugPage() {
setLoading(true);
setError(null);
adminFetch(`${API_URL}/debug/status`, adminToken)
.then(r => {
if (r.status === 401 || r.status === 503) {
.then(async r => {
// 403 is the Cloudflare Access JWT path's denial: a signed-in Google
// account that isn't on the admin_allowed_emails allow-list. Surface
// it on the auth-required screen with the server's message so the
// user knows to sign in with a different account or ask for access.
if (r.status === 401 || r.status === 403 || r.status === 503) {
setAuthRequired(true);
if (r.status === 403) {
const body = await r.json().catch(() => ({}));
throw new Error(body?.message || 'this account is not authorized for /debug');
}
throw new Error(r.status === 503 ? 'admin auth not configured on server' : 'admin token required');
}
if (!r.ok) throw new Error(`${r.status}`);
Expand Down Expand Up @@ -301,14 +316,14 @@ export function DebugPage() {
<Box sx={{ py: 4, maxWidth: 420, mx: 'auto' }}>
<SectionHeader prompt="❯" title={<em>debug · admin auth</em>} />
<Typography sx={{ fontFamily: typography.fontFamily, fontSize: fontSize.xs, color: semanticColors.mutedText, mb: 2 }}>
{error || 'admin token required'}
{error || 'sign in via your browser session, or paste an admin token as a fallback.'}
</Typography>
<Box component="form" onSubmit={handleTokenSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box
component="input"
type="password"
autoFocus
placeholder="X-Admin-Token"
placeholder="Admin token (fallback)"
value={tokenInput}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTokenInput(e.target.value)}
sx={nativeControlSx}
Expand Down
36 changes: 35 additions & 1 deletion core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
defaults, and documentation.
"""

from typing import Optional
from typing import Any, Optional

from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


Expand Down Expand Up @@ -156,6 +157,39 @@ class Settings(BaseSettings):
weakness aggregates, and DB latency to the public internet. Set via Secret
Manager in Cloud Run; pass via the `X-Admin-Token` request header."""

cf_access_team_domain: Optional[str] = None
"""Cloudflare Access team domain, e.g. `anyplot.cloudflareaccess.com`.
Used as the JWT issuer and for fetching JWKS keys at
`https://{team_domain}/cdn-cgi/access/certs`. When unset, the JWT path in
require_admin is skipped (token-only mode)."""

cf_access_aud: Optional[str] = None
"""Cloudflare Access Application AUD tag (UUID from the Zero Trust
dashboard). Validated as the JWT `aud` claim."""

admin_allowed_emails: list[str] = []
"""Email addresses allowed to authenticate via Cloudflare Access for
/debug/* endpoints. Defaults to an empty list — must be set explicitly
in production. Accepts either a JSON array (`["a@b.com","c@d.com"]`)
or a comma-separated string (`a@b.com,c@d.com`) when set via env var."""

@field_validator("admin_allowed_emails", mode="before")
@classmethod
def _parse_admin_allowed_emails(cls, value: Any) -> Any:
"""Allow `ADMIN_ALLOWED_EMAILS=foo@bar.com,baz@qux.com` in addition to
the JSON-array form pydantic-settings expects by default. A bare
string with no commas is treated as a single-element list."""
import json

if isinstance(value, str):
stripped = value.strip()
if not stripped:
return []
if stripped.startswith("["):
return json.loads(stripped)
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ADMIN_ALLOWED_EMAILS parsing uses json.loads for any string starting with [. If an operator accidentally provides a malformed JSON array, the app will fail fast with a JSON decode error at startup. Consider catching json.JSONDecodeError and either (a) raising a clearer ValueError mentioning ADMIN_ALLOWED_EMAILS format, or (b) falling back to comma-splitting when JSON parsing fails.

Suggested change
return json.loads(stripped)
try:
return json.loads(stripped)
except json.JSONDecodeError as exc:
raise ValueError(
"Invalid ADMIN_ALLOWED_EMAILS format. Use either a JSON array "
'like ["a@b.com", "c@d.com"] or a comma-separated string like '
'"a@b.com,c@d.com".'
) from exc

Copilot uses AI. Check for mistakes.
return [item.strip() for item in stripped.split(",") if item.strip()]
return value

# =============================================================================
# CORS
# =============================================================================
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ dependencies = [
"pandas-stubs>=2.3.0",
# Caching
"cachetools>=7.0.6",
# Auth — Cloudflare Access JWT verification on /debug/*
"pyjwt[crypto]>=2.10.0",
]

[project.optional-dependencies]
Expand Down
Loading
Loading