Restrict observability endpoints to ops admins
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -11,11 +16,28 @@ export async function GET(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: "public, max-age=0, s-maxage=30, stale-while-revalidate=120",
|
||||
detailLimit: 500,
|
||||
publicMessage: "Failed to fetch system status",
|
||||
revalidateSeconds: 30,
|
||||
url: `${API_BASE}/api/system/status`,
|
||||
});
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/system/status`, {
|
||||
cache: "no-store",
|
||||
headers: auth.headers,
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Cloudflare-CDN-Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
status: res.status,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch system status",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ export function runTests() {
|
||||
"app/api/ops/users/route.ts",
|
||||
"app/api/ops/users/grant-points/route.ts",
|
||||
"app/api/ops/view-logs/route.ts",
|
||||
"app/api/system/status/route.ts",
|
||||
]) {
|
||||
const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8");
|
||||
assert(
|
||||
@@ -211,14 +212,10 @@ export function runTests() {
|
||||
const optionalRefreshIndex = middlewareSource.indexOf(
|
||||
"function shouldRefreshOptionalSupabaseSession",
|
||||
);
|
||||
const systemStatusPublicIndex = middlewareSource.indexOf(
|
||||
'pathname === "/api/system/status"',
|
||||
);
|
||||
assert(
|
||||
systemStatusPublicIndex >= 0 &&
|
||||
optionalRefreshIndex >= 0 &&
|
||||
systemStatusPublicIndex < optionalRefreshIndex,
|
||||
"middleware must treat public system status as public API instead of optional Supabase session refresh",
|
||||
optionalRefreshIndex >= 0 &&
|
||||
!middlewareSource.includes('pathname === "/api/system/status"'),
|
||||
"middleware must not treat system status as public after it becomes an ops-only API",
|
||||
);
|
||||
|
||||
for (const route of paymentRoutes) {
|
||||
|
||||
@@ -36,7 +36,6 @@ function isPublicApi(pathname: string) {
|
||||
pathname === "/api/cities" ||
|
||||
pathname === "/api/payments/config" ||
|
||||
pathname === "/api/scan/terminal" ||
|
||||
pathname === "/api/system/status" ||
|
||||
pathname === "/api/vitals" ||
|
||||
/^\/api\/city\/[^/]+$/i.test(pathname) ||
|
||||
/^\/api\/city\/[^/]+\/summary$/i.test(pathname) ||
|
||||
|
||||
@@ -52,7 +52,18 @@ def test_healthz_keeps_liveness_200_when_db_health_is_degraded(monkeypatch):
|
||||
assert response.json()["status"] == "degraded"
|
||||
|
||||
|
||||
def test_system_status_returns_summary_shape():
|
||||
def test_system_status_requires_ops_admin():
|
||||
response = client.get('/api/system/status')
|
||||
assert response.status_code in {401, 403, 503}
|
||||
|
||||
|
||||
def test_system_status_returns_summary_shape_for_ops_admin(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_require_ops_admin",
|
||||
lambda request: {"user_id": "admin-user", "email": "admin@example.com"},
|
||||
)
|
||||
|
||||
response = client.get('/api/system/status')
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
@@ -93,12 +104,31 @@ def test_observation_freshness_accepts_epoch_seconds():
|
||||
assert payload["observed_at"].startswith("2026-")
|
||||
|
||||
|
||||
def test_metrics_endpoint_returns_prometheus_payload():
|
||||
def test_metrics_endpoint_requires_ops_admin():
|
||||
response = client.get('/metrics')
|
||||
assert response.status_code in {401, 403, 503}
|
||||
|
||||
|
||||
def test_metrics_endpoint_returns_prometheus_payload_for_ops_admin(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_require_ops_admin",
|
||||
lambda request: {"user_id": "admin-user", "email": "admin@example.com"},
|
||||
)
|
||||
|
||||
response = client.get('/metrics')
|
||||
assert response.status_code == 200
|
||||
assert 'polyweather_http_requests_total' in response.text
|
||||
|
||||
|
||||
def test_system_cache_status_requires_ops_admin(monkeypatch):
|
||||
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
|
||||
|
||||
response = client.get("/api/system/cache-status?cities=shanghai")
|
||||
|
||||
assert response.status_code in {401, 403, 503}
|
||||
|
||||
|
||||
def test_standard_growth_funnel_events_are_trackable():
|
||||
assert {
|
||||
"landing_view",
|
||||
|
||||
@@ -23,8 +23,8 @@ async def healthz():
|
||||
|
||||
|
||||
@router.get("/api/system/status")
|
||||
async def system_status():
|
||||
return await get_system_status_payload()
|
||||
async def system_status(request: Request):
|
||||
return await get_system_status_payload(request)
|
||||
|
||||
|
||||
@router.get("/api/system/cache-status")
|
||||
@@ -42,8 +42,8 @@ async def system_priority_warm(
|
||||
|
||||
|
||||
@router.get("/metrics", response_class=PlainTextResponse)
|
||||
async def metrics():
|
||||
return get_prometheus_metrics_response()
|
||||
async def metrics(request: Request):
|
||||
return get_prometheus_metrics_response(request)
|
||||
|
||||
|
||||
@router.get("/api/dashboard/init")
|
||||
|
||||
@@ -19,7 +19,8 @@ def get_health_payload() -> Dict[str, Any]:
|
||||
return build_health_payload()
|
||||
|
||||
|
||||
async def get_system_status_payload() -> Dict[str, Any]:
|
||||
async def get_system_status_payload(request: Request) -> Dict[str, Any]:
|
||||
legacy_routes._require_ops_admin(request)
|
||||
payload = await run_in_threadpool(build_system_status_payload)
|
||||
payload["realtime"] = await run_in_threadpool(_realtime_status_payload)
|
||||
return payload
|
||||
@@ -52,7 +53,7 @@ def _realtime_status_payload() -> Dict[str, Any]:
|
||||
|
||||
|
||||
def get_system_cache_status(request: Request, cities: Optional[str] = None) -> Dict[str, Any]:
|
||||
legacy_routes._assert_entitlement(request)
|
||||
legacy_routes._require_ops_admin(request)
|
||||
selected = legacy_routes._normalize_city_list(cities)
|
||||
if not selected:
|
||||
selected = legacy_routes._normalize_city_list(None)
|
||||
@@ -118,7 +119,8 @@ def run_system_priority_warm(
|
||||
}
|
||||
|
||||
|
||||
def get_prometheus_metrics_response() -> PlainTextResponse:
|
||||
def get_prometheus_metrics_response(request: Request) -> PlainTextResponse:
|
||||
legacy_routes._require_ops_admin(request)
|
||||
return PlainTextResponse(
|
||||
export_prometheus_metrics(),
|
||||
media_type="text/plain; version=0.0.4; charset=utf-8",
|
||||
|
||||
Reference in New Issue
Block a user