Restrict observability endpoints to ops admins
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
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;
|
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||||
|
|
||||||
@@ -11,11 +16,28 @@ export async function GET(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return proxyBackendJsonGet(req, {
|
try {
|
||||||
cacheControl: "public, max-age=0, s-maxage=30, stale-while-revalidate=120",
|
const auth = await buildBackendRequestHeaders(req);
|
||||||
detailLimit: 500,
|
const authError = requireOpsProxyAuth(req, auth);
|
||||||
publicMessage: "Failed to fetch system status",
|
if (authError) return authError;
|
||||||
revalidateSeconds: 30,
|
|
||||||
url: `${API_BASE}/api/system/status`,
|
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/route.ts",
|
||||||
"app/api/ops/users/grant-points/route.ts",
|
"app/api/ops/users/grant-points/route.ts",
|
||||||
"app/api/ops/view-logs/route.ts",
|
"app/api/ops/view-logs/route.ts",
|
||||||
|
"app/api/system/status/route.ts",
|
||||||
]) {
|
]) {
|
||||||
const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8");
|
const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8");
|
||||||
assert(
|
assert(
|
||||||
@@ -211,14 +212,10 @@ export function runTests() {
|
|||||||
const optionalRefreshIndex = middlewareSource.indexOf(
|
const optionalRefreshIndex = middlewareSource.indexOf(
|
||||||
"function shouldRefreshOptionalSupabaseSession",
|
"function shouldRefreshOptionalSupabaseSession",
|
||||||
);
|
);
|
||||||
const systemStatusPublicIndex = middlewareSource.indexOf(
|
|
||||||
'pathname === "/api/system/status"',
|
|
||||||
);
|
|
||||||
assert(
|
assert(
|
||||||
systemStatusPublicIndex >= 0 &&
|
optionalRefreshIndex >= 0 &&
|
||||||
optionalRefreshIndex >= 0 &&
|
!middlewareSource.includes('pathname === "/api/system/status"'),
|
||||||
systemStatusPublicIndex < optionalRefreshIndex,
|
"middleware must not treat system status as public after it becomes an ops-only API",
|
||||||
"middleware must treat public system status as public API instead of optional Supabase session refresh",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const route of paymentRoutes) {
|
for (const route of paymentRoutes) {
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ function isPublicApi(pathname: string) {
|
|||||||
pathname === "/api/cities" ||
|
pathname === "/api/cities" ||
|
||||||
pathname === "/api/payments/config" ||
|
pathname === "/api/payments/config" ||
|
||||||
pathname === "/api/scan/terminal" ||
|
pathname === "/api/scan/terminal" ||
|
||||||
pathname === "/api/system/status" ||
|
|
||||||
pathname === "/api/vitals" ||
|
pathname === "/api/vitals" ||
|
||||||
/^\/api\/city\/[^/]+$/i.test(pathname) ||
|
/^\/api\/city\/[^/]+$/i.test(pathname) ||
|
||||||
/^\/api\/city\/[^/]+\/summary$/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"
|
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')
|
response = client.get('/api/system/status')
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
@@ -93,12 +104,31 @@ def test_observation_freshness_accepts_epoch_seconds():
|
|||||||
assert payload["observed_at"].startswith("2026-")
|
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')
|
response = client.get('/metrics')
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert 'polyweather_http_requests_total' in response.text
|
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():
|
def test_standard_growth_funnel_events_are_trackable():
|
||||||
assert {
|
assert {
|
||||||
"landing_view",
|
"landing_view",
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ async def healthz():
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/system/status")
|
@router.get("/api/system/status")
|
||||||
async def system_status():
|
async def system_status(request: Request):
|
||||||
return await get_system_status_payload()
|
return await get_system_status_payload(request)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/system/cache-status")
|
@router.get("/api/system/cache-status")
|
||||||
@@ -42,8 +42,8 @@ async def system_priority_warm(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/metrics", response_class=PlainTextResponse)
|
@router.get("/metrics", response_class=PlainTextResponse)
|
||||||
async def metrics():
|
async def metrics(request: Request):
|
||||||
return get_prometheus_metrics_response()
|
return get_prometheus_metrics_response(request)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/dashboard/init")
|
@router.get("/api/dashboard/init")
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ def get_health_payload() -> Dict[str, Any]:
|
|||||||
return build_health_payload()
|
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 = await run_in_threadpool(build_system_status_payload)
|
||||||
payload["realtime"] = await run_in_threadpool(_realtime_status_payload)
|
payload["realtime"] = await run_in_threadpool(_realtime_status_payload)
|
||||||
return 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]:
|
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)
|
selected = legacy_routes._normalize_city_list(cities)
|
||||||
if not selected:
|
if not selected:
|
||||||
selected = legacy_routes._normalize_city_list(None)
|
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(
|
return PlainTextResponse(
|
||||||
export_prometheus_metrics(),
|
export_prometheus_metrics(),
|
||||||
media_type="text/plain; version=0.0.4; charset=utf-8",
|
media_type="text/plain; version=0.0.4; charset=utf-8",
|
||||||
|
|||||||
Reference in New Issue
Block a user