Restrict observability endpoints to ops admins

This commit is contained in:
2569718930@qq.com
2026-06-13 02:35:30 +08:00
parent be1d93fc09
commit 9d120d7215
6 changed files with 75 additions and 25 deletions
+30 -8
View File
@@ -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) {
-1
View File
@@ -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) ||
+32 -2
View File
@@ -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",
+4 -4
View File
@@ -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")
+5 -3
View File
@@ -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",