Add ops runtime secret rotation
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const BACKEND = API_BASE ? `${API_BASE}/api/ops/sensitive-config` : "";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Sensitive config fetch failed" }); }
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.text();
|
||||
const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Sensitive config update failed" }); }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCcw, Save } from "lucide-react";
|
||||
import { KeyRound, RefreshCcw, Save } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -11,19 +11,52 @@ type EditableConfig = {
|
||||
description: string;
|
||||
};
|
||||
|
||||
type SensitiveConfig = {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
configured: boolean;
|
||||
masked: string;
|
||||
length: number;
|
||||
updated_at: string;
|
||||
updated_by: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
type SensitiveHealth = {
|
||||
ok?: boolean;
|
||||
status?: number;
|
||||
latency_ms?: number;
|
||||
points?: number;
|
||||
observation_time_local?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function ConfigPageClient() {
|
||||
const [configs, setConfigs] = useState<EditableConfig[]>([]);
|
||||
const [sensitiveConfigs, setSensitiveConfigs] = useState<SensitiveConfig[]>([]);
|
||||
const [editing, setEditing] = useState<Record<string, string>>({});
|
||||
const [sensitiveEditing, setSensitiveEditing] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [sensitiveSaving, setSensitiveSaving] = useState(false);
|
||||
const [result, setResult] = useState("");
|
||||
const [sensitiveResult, setSensitiveResult] = useState("");
|
||||
const [sensitiveHealth, setSensitiveHealth] = useState<SensitiveHealth | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/ops/config");
|
||||
const [res, sensitiveRes] = await Promise.all([
|
||||
fetch("/api/ops/config"),
|
||||
fetch("/api/ops/sensitive-config"),
|
||||
]);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { configs?: EditableConfig[] };
|
||||
setConfigs(data.configs ?? []);
|
||||
}
|
||||
if (sensitiveRes.ok) {
|
||||
const data = (await sensitiveRes.json()) as { configs?: SensitiveConfig[] };
|
||||
setSensitiveConfigs(data.configs ?? []);
|
||||
}
|
||||
} catch { /* backend not ready yet */ }
|
||||
};
|
||||
|
||||
@@ -51,6 +84,38 @@ export function ConfigPageClient() {
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleSensitiveSave = async (key: string) => {
|
||||
const newVal = sensitiveEditing[key]?.trim();
|
||||
if (!newVal) return;
|
||||
setSensitiveSaving(true);
|
||||
setSensitiveResult("");
|
||||
setSensitiveHealth(null);
|
||||
try {
|
||||
const res = await fetch("/api/ops/sensitive-config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key, value: newVal }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as {
|
||||
config?: SensitiveConfig;
|
||||
health?: SensitiveHealth | null;
|
||||
};
|
||||
if (data.config) {
|
||||
setSensitiveConfigs((prev) => prev.map((cfg) => (cfg.key === key ? data.config as SensitiveConfig : cfg)));
|
||||
}
|
||||
setSensitiveEditing((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||
setSensitiveHealth(data.health ?? null);
|
||||
setSensitiveResult(`${key} 已轮换`);
|
||||
} else {
|
||||
setSensitiveResult(`轮换失败: ${await res.text().catch(() => "")}`);
|
||||
}
|
||||
} catch {
|
||||
setSensitiveResult("轮换失败");
|
||||
}
|
||||
setSensitiveSaving(false);
|
||||
};
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
return (
|
||||
@@ -101,7 +166,91 @@ export function ConfigPageClient() {
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-4 text-xs text-slate-500">
|
||||
仅显示非敏感配置项。API Key 等密钥不在此处暴露。修改后立即生效,建议重启服务以确保持久化。
|
||||
仅显示非敏感配置项。修改后立即影响当前后端进程;需要跨重启持久化的密钥请使用下方凭证轮换模块。
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyRound className="h-4 w-4 text-cyan-300" />
|
||||
敏感凭证轮换
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sensitiveConfigs.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm">敏感配置 API 尚未就绪。</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sensitiveConfigs.map((cfg) => (
|
||||
<div key={cfg.key} className="rounded-lg border border-white/5 bg-white/5 px-4 py-4">
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-start">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-white text-sm font-semibold">{cfg.label}</div>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] ${cfg.configured ? "bg-emerald-400/10 text-emerald-300" : "bg-amber-400/10 text-amber-300"}`}>
|
||||
{cfg.configured ? "已配置" : "未配置"}
|
||||
</span>
|
||||
<span className="rounded-full bg-slate-500/10 px-2 py-0.5 text-[11px] text-slate-400">
|
||||
{cfg.source === "environment" ? "环境变量兜底" : "DB 持久化"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500">{cfg.description}</div>
|
||||
<div className="mt-3 grid gap-2 text-xs text-slate-400 sm:grid-cols-3">
|
||||
<div>
|
||||
<span className="text-slate-500">当前值 </span>
|
||||
<span className="font-mono text-slate-200">{cfg.masked || "未设置"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">更新人 </span>
|
||||
<span className="font-mono text-slate-200">{cfg.updated_by || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">更新时间 </span>
|
||||
<span className="font-mono text-slate-200">{cfg.updated_at || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row xl:w-[520px]">
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder="输入新的 sessionId,不会回显"
|
||||
value={sensitiveEditing[cfg.key] ?? ""}
|
||||
onChange={(e) => setSensitiveEditing((prev) => ({ ...prev, [cfg.key]: e.target.value }))}
|
||||
className="min-w-0 flex-1 rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white font-mono outline-none focus:border-cyan-400/50"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={sensitiveSaving || !sensitiveEditing[cfg.key]?.trim()}
|
||||
onClick={() => handleSensitiveSave(cfg.key)}
|
||||
className="gap-1"
|
||||
>
|
||||
<Save className="h-3 w-3" /> 轮换
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{sensitiveResult && (
|
||||
<p className={`mt-3 text-sm ${sensitiveResult.includes("失败") ? "text-amber-400" : "text-emerald-400"}`}>
|
||||
{sensitiveResult}
|
||||
</p>
|
||||
)}
|
||||
{sensitiveHealth && (
|
||||
<div className={`mt-3 rounded-lg border px-3 py-2 text-xs ${sensitiveHealth.ok ? "border-emerald-400/20 bg-emerald-400/10 text-emerald-200" : "border-amber-400/20 bg-amber-400/10 text-amber-200"}`}>
|
||||
AMSC 健康检查:{sensitiveHealth.ok ? "通过" : "失败"}
|
||||
{typeof sensitiveHealth.points === "number" ? ` · 跑道点 ${sensitiveHealth.points}` : ""}
|
||||
{sensitiveHealth.observation_time_local ? ` · 观测 ${sensitiveHealth.observation_time_local}` : ""}
|
||||
{sensitiveHealth.error ? ` · ${sensitiveHealth.error}` : ""}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-4 text-xs text-slate-500">
|
||||
这里不会返回或展示明文。轮换值写入共享运行时数据库,后端和 Bot 会优先读取该值,环境变量仅作为兜底。
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -20,6 +20,7 @@ from urllib.request import Request, urlopen
|
||||
from loguru import logger
|
||||
|
||||
from src.utils.metrics import record_source_call
|
||||
from src.utils.runtime_secrets import get_runtime_secret
|
||||
|
||||
AMSC_AWOS_BASE_URL = os.getenv("AMSC_AWOS_BASE_URL", "").strip()
|
||||
|
||||
@@ -316,8 +317,8 @@ class AmscAwosSourceMixin:
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36"
|
||||
),
|
||||
}
|
||||
cookie = os.getenv("POLYWEATHER_AMSC_COOKIE", "").strip()
|
||||
session_id = os.getenv("POLYWEATHER_AMSC_SESSION_ID", "").strip()
|
||||
cookie = get_runtime_secret("POLYWEATHER_AMSC_COOKIE")
|
||||
session_id = get_runtime_secret("POLYWEATHER_AMSC_SESSION_ID")
|
||||
if cookie:
|
||||
headers["Cookie"] = cookie
|
||||
elif session_id:
|
||||
|
||||
@@ -427,6 +427,14 @@ class DBManager:
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS runtime_secrets (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS city_summary_cache (
|
||||
city TEXT PRIMARY KEY,
|
||||
@@ -831,6 +839,107 @@ class DBManager:
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@staticmethod
|
||||
def _mask_secret_value(value: str) -> str:
|
||||
text = str(value or "")
|
||||
if not text:
|
||||
return ""
|
||||
if len(text) <= 8:
|
||||
return "***"
|
||||
return f"{text[:4]}...{text[-4:]}"
|
||||
|
||||
def get_runtime_secret(self, key: str) -> Optional[str]:
|
||||
normalized_key = str(key or "").strip()
|
||||
if not normalized_key:
|
||||
return None
|
||||
with self._get_connection() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT value
|
||||
FROM runtime_secrets
|
||||
WHERE key = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(normalized_key,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
value = str(row["value"] or "")
|
||||
return value if value else None
|
||||
|
||||
def get_runtime_secret_metadata(self, key: str) -> Dict[str, Any]:
|
||||
normalized_key = str(key or "").strip()
|
||||
if not normalized_key:
|
||||
return {
|
||||
"key": "",
|
||||
"configured": False,
|
||||
"masked": "",
|
||||
"updated_at": "",
|
||||
"updated_by": "",
|
||||
"source": "runtime_store",
|
||||
}
|
||||
with self._get_connection() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT key, value, updated_at, updated_by
|
||||
FROM runtime_secrets
|
||||
WHERE key = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(normalized_key,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return {
|
||||
"key": normalized_key,
|
||||
"configured": False,
|
||||
"masked": "",
|
||||
"updated_at": "",
|
||||
"updated_by": "",
|
||||
"source": "runtime_store",
|
||||
}
|
||||
value = str(row["value"] or "")
|
||||
return {
|
||||
"key": normalized_key,
|
||||
"configured": bool(value),
|
||||
"masked": self._mask_secret_value(value),
|
||||
"length": len(value),
|
||||
"updated_at": str(row["updated_at"] or ""),
|
||||
"updated_by": str(row["updated_by"] or ""),
|
||||
"source": "runtime_store",
|
||||
}
|
||||
|
||||
def set_runtime_secret(
|
||||
self,
|
||||
key: str,
|
||||
value: str,
|
||||
*,
|
||||
updated_by: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
normalized_key = str(key or "").strip()
|
||||
secret_value = str(value or "").strip()
|
||||
if not normalized_key:
|
||||
raise ValueError("runtime secret key is required")
|
||||
if not secret_value:
|
||||
raise ValueError("runtime secret value is required")
|
||||
now = datetime.now().isoformat()
|
||||
operator = str(updated_by or "").strip()
|
||||
with self._get_connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO runtime_secrets (key, value, updated_at, updated_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = excluded.updated_at,
|
||||
updated_by = excluded.updated_by
|
||||
""",
|
||||
(normalized_key, secret_value, now, operator),
|
||||
)
|
||||
conn.commit()
|
||||
return self.get_runtime_secret_metadata(normalized_key)
|
||||
|
||||
def append_payment_audit_event(self, event_type: str, payload: Dict[str, Any]) -> None:
|
||||
kind = str(event_type or "").strip().lower()
|
||||
if not kind:
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Runtime secret lookup helpers.
|
||||
|
||||
Secrets rotated from the ops UI live in the shared SQLite database so backend
|
||||
processes can pick them up without host-level Docker access. Environment
|
||||
variables remain the fallback source for bootstrapping and local development.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from src.database.db_manager import DBManager
|
||||
|
||||
|
||||
def get_runtime_secret(key: str) -> str:
|
||||
normalized_key = str(key or "").strip()
|
||||
if not normalized_key:
|
||||
return ""
|
||||
try:
|
||||
stored = DBManager().get_runtime_secret(normalized_key)
|
||||
except Exception:
|
||||
stored = None
|
||||
if stored:
|
||||
return str(stored).strip()
|
||||
return str(os.getenv(normalized_key) or "").strip()
|
||||
|
||||
|
||||
def get_runtime_secret_status(key: str) -> dict[str, Any]:
|
||||
normalized_key = str(key or "").strip()
|
||||
if not normalized_key:
|
||||
return {
|
||||
"key": "",
|
||||
"configured": False,
|
||||
"masked": "",
|
||||
"updated_at": "",
|
||||
"updated_by": "",
|
||||
"source": "runtime_store",
|
||||
}
|
||||
try:
|
||||
metadata = DBManager().get_runtime_secret_metadata(normalized_key)
|
||||
except Exception:
|
||||
metadata = {}
|
||||
if isinstance(metadata, dict) and metadata.get("configured"):
|
||||
return metadata
|
||||
|
||||
env_value = str(os.getenv(normalized_key) or "").strip()
|
||||
if not env_value:
|
||||
return {
|
||||
"key": normalized_key,
|
||||
"configured": False,
|
||||
"masked": "",
|
||||
"updated_at": "",
|
||||
"updated_by": "",
|
||||
"source": "runtime_store",
|
||||
}
|
||||
return {
|
||||
"key": normalized_key,
|
||||
"configured": True,
|
||||
"masked": DBManager._mask_secret_value(env_value),
|
||||
"length": len(env_value),
|
||||
"updated_at": "",
|
||||
"updated_by": "",
|
||||
"source": "environment",
|
||||
}
|
||||
@@ -1,4 +1,103 @@
|
||||
from web.services import ops_api
|
||||
from src.database.db_manager import DBManager
|
||||
from src.data_collection.amsc_awos_sources import AmscAwosSourceMixin
|
||||
|
||||
|
||||
def test_runtime_secret_metadata_masks_value(tmp_path):
|
||||
db = DBManager(str(tmp_path / "polyweather.db"))
|
||||
secret = "9153$$example-session"
|
||||
|
||||
saved = db.set_runtime_secret(
|
||||
"POLYWEATHER_AMSC_SESSION_ID",
|
||||
secret,
|
||||
updated_by="ops@example.com",
|
||||
)
|
||||
|
||||
assert saved["configured"] is True
|
||||
assert saved["masked"] == "9153...sion"
|
||||
assert "value" not in saved
|
||||
assert db.get_runtime_secret("POLYWEATHER_AMSC_SESSION_ID") == secret
|
||||
|
||||
metadata = db.get_runtime_secret_metadata("POLYWEATHER_AMSC_SESSION_ID")
|
||||
|
||||
assert metadata["configured"] is True
|
||||
assert metadata["masked"] == "9153...sion"
|
||||
assert metadata["updated_by"] == "ops@example.com"
|
||||
assert "value" not in metadata
|
||||
|
||||
|
||||
def test_amsc_headers_prefer_runtime_secret_over_env(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "polyweather.db"))
|
||||
monkeypatch.setenv("POLYWEATHER_AMSC_SESSION_ID", "env-session")
|
||||
monkeypatch.delenv("POLYWEATHER_AMSC_COOKIE", raising=False)
|
||||
DBManager().set_runtime_secret(
|
||||
"POLYWEATHER_AMSC_SESSION_ID",
|
||||
"db-session-1234",
|
||||
updated_by="ops@example.com",
|
||||
)
|
||||
|
||||
class FakeSource(AmscAwosSourceMixin):
|
||||
timeout = 1.0
|
||||
|
||||
headers = FakeSource()._amsc_headers()
|
||||
|
||||
assert headers["sessionId"] == "db-session-1234"
|
||||
assert headers["app"] == "AMS"
|
||||
|
||||
|
||||
def test_ops_sensitive_config_update_rotates_session_without_echoing_secret(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "polyweather.db"))
|
||||
monkeypatch.setattr(
|
||||
ops_api,
|
||||
"_require_ops",
|
||||
lambda request: {"email": "ops@example.com"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
ops_api,
|
||||
"_check_amsc_awos_health",
|
||||
lambda timeout=8: {"ok": True, "credential_configured": True, "points": 4},
|
||||
)
|
||||
secret = "9153$$rotated-session"
|
||||
|
||||
result = ops_api.update_ops_sensitive_config(
|
||||
object(),
|
||||
"POLYWEATHER_AMSC_SESSION_ID",
|
||||
secret,
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["config"]["configured"] is True
|
||||
assert result["config"]["masked"] == "9153...sion"
|
||||
assert result["health"]["ok"] is True
|
||||
assert DBManager().get_runtime_secret("POLYWEATHER_AMSC_SESSION_ID") == secret
|
||||
assert "value" not in result["config"]
|
||||
assert secret not in str(result)
|
||||
|
||||
|
||||
def test_ops_sensitive_config_status_uses_metadata_not_plaintext(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "polyweather.db"))
|
||||
monkeypatch.setattr(
|
||||
ops_api,
|
||||
"_require_ops",
|
||||
lambda request: {"email": "ops@example.com"},
|
||||
)
|
||||
secret = "9153$$stored-session"
|
||||
DBManager().set_runtime_secret(
|
||||
"POLYWEATHER_AMSC_SESSION_ID",
|
||||
secret,
|
||||
updated_by="ops@example.com",
|
||||
)
|
||||
|
||||
result = ops_api.get_ops_sensitive_config(object())
|
||||
|
||||
assert result["configs"][0]["key"] == "POLYWEATHER_AMSC_SESSION_ID"
|
||||
assert result["configs"][0]["configured"] is True
|
||||
assert result["configs"][0]["masked"] == "9153...sion"
|
||||
assert "value" not in result["configs"][0]
|
||||
assert secret not in str(result)
|
||||
|
||||
|
||||
def test_ops_amsc_health_uses_configured_session_header(monkeypatch):
|
||||
|
||||
@@ -7,6 +7,7 @@ from web.services.ops_api import (
|
||||
extend_ops_subscription,
|
||||
get_ops_analytics_funnel,
|
||||
get_ops_config,
|
||||
get_ops_sensitive_config,
|
||||
get_ops_memberships_growth,
|
||||
get_ops_memberships_overview,
|
||||
get_ops_health_check,
|
||||
@@ -23,6 +24,7 @@ from web.services.ops_api import (
|
||||
list_ops_payments,
|
||||
search_ops_users,
|
||||
update_ops_config,
|
||||
update_ops_sensitive_config,
|
||||
get_ops_training_accuracy,
|
||||
get_ops_telegram_audit,
|
||||
)
|
||||
@@ -148,6 +150,24 @@ async def ops_update_config(request: Request):
|
||||
return update_ops_config(request, key, value)
|
||||
|
||||
|
||||
@router.get("/api/ops/sensitive-config")
|
||||
async def ops_sensitive_config(request: Request):
|
||||
return get_ops_sensitive_config(request)
|
||||
|
||||
|
||||
@router.put("/api/ops/sensitive-config")
|
||||
async def ops_update_sensitive_config(request: Request):
|
||||
import json as _json
|
||||
body_bytes = await request.body()
|
||||
body = _json.loads(body_bytes.decode("utf-8"))
|
||||
key = str(body.get("key") or "").strip()
|
||||
value = str(body.get("value") or "")
|
||||
if not key:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=400, detail="key is required")
|
||||
return update_ops_sensitive_config(request, key, value)
|
||||
|
||||
|
||||
# ── Subscriptions ───────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/ops/subscriptions/grant")
|
||||
|
||||
+87
-4
@@ -9,6 +9,7 @@ from fastapi import HTTPException, Request
|
||||
import requests as _requests
|
||||
|
||||
from src.database.db_manager import DBManager
|
||||
from src.utils.runtime_secrets import get_runtime_secret, get_runtime_secret_status
|
||||
from web.core import GrantPointsRequest
|
||||
import web.routes as legacy_routes
|
||||
|
||||
@@ -481,6 +482,13 @@ _EDITABLE_CONFIG_KEYS: dict[str, str] = {
|
||||
"POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS": "手动转账收款钱包地址",
|
||||
}
|
||||
|
||||
_SENSITIVE_CONFIG_KEYS: dict[str, dict[str, str]] = {
|
||||
"POLYWEATHER_AMSC_SESSION_ID": {
|
||||
"label": "AMSC AWOS sessionId",
|
||||
"description": "中国跑道观测接口 sessionId,用于上海/北京/广州等 AMSC AWOS 数据源。",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_ops_config(request: Request) -> dict[str, Any]:
|
||||
_require_ops(request)
|
||||
@@ -510,6 +518,81 @@ def update_ops_config(request: Request, key: str, value: str) -> dict[str, Any]:
|
||||
return {"key": key, "value": value, "ok": True}
|
||||
|
||||
|
||||
def _sensitive_config_payload(key: str) -> dict[str, Any]:
|
||||
definition = _SENSITIVE_CONFIG_KEYS.get(key) or {}
|
||||
metadata = get_runtime_secret_status(key)
|
||||
return {
|
||||
"key": key,
|
||||
"label": definition.get("label") or key,
|
||||
"description": definition.get("description") or "",
|
||||
"configured": bool(metadata.get("configured")),
|
||||
"masked": str(metadata.get("masked") or ""),
|
||||
"length": int(metadata.get("length") or 0),
|
||||
"updated_at": str(metadata.get("updated_at") or ""),
|
||||
"updated_by": str(metadata.get("updated_by") or ""),
|
||||
"source": str(metadata.get("source") or "runtime_store"),
|
||||
}
|
||||
|
||||
|
||||
def get_ops_sensitive_config(request: Request) -> dict[str, Any]:
|
||||
_require_ops(request)
|
||||
return {
|
||||
"configs": [
|
||||
_sensitive_config_payload(key)
|
||||
for key in _SENSITIVE_CONFIG_KEYS
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def update_ops_sensitive_config(
|
||||
request: Request,
|
||||
key: str,
|
||||
value: str,
|
||||
) -> dict[str, Any]:
|
||||
admin = _require_ops(request) or {}
|
||||
normalized_key = str(key or "").strip()
|
||||
if normalized_key not in _SENSITIVE_CONFIG_KEYS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"sensitive config key '{normalized_key}' is not editable",
|
||||
)
|
||||
secret_value = str(value or "").strip()
|
||||
if not 12 <= len(secret_value) <= 256 or any(ch.isspace() for ch in secret_value):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="sessionId must be 12-256 non-whitespace characters",
|
||||
)
|
||||
|
||||
db = DBManager()
|
||||
try:
|
||||
config = db.set_runtime_secret(
|
||||
normalized_key,
|
||||
secret_value,
|
||||
updated_by=str(admin.get("email") or ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
os.environ[normalized_key] = secret_value
|
||||
response_config = _sensitive_config_payload(normalized_key)
|
||||
response_config.update(
|
||||
{
|
||||
"configured": bool(config.get("configured")),
|
||||
"masked": str(config.get("masked") or ""),
|
||||
"length": int(config.get("length") or 0),
|
||||
"updated_at": str(config.get("updated_at") or ""),
|
||||
"updated_by": str(config.get("updated_by") or ""),
|
||||
"source": str(config.get("source") or "runtime_store"),
|
||||
}
|
||||
)
|
||||
health = (
|
||||
_check_amsc_awos_health(timeout=8)
|
||||
if normalized_key == "POLYWEATHER_AMSC_SESSION_ID"
|
||||
else None
|
||||
)
|
||||
return {"ok": True, "config": response_config, "health": health}
|
||||
|
||||
|
||||
def _build_amsc_awos_headers() -> dict[str, str]:
|
||||
headers = {
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
@@ -519,8 +602,8 @@ def _build_amsc_awos_headers() -> dict[str, str]:
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36"
|
||||
),
|
||||
}
|
||||
cookie = str(os.getenv("POLYWEATHER_AMSC_COOKIE") or "").strip()
|
||||
session_id = str(os.getenv("POLYWEATHER_AMSC_SESSION_ID") or "").strip()
|
||||
cookie = get_runtime_secret("POLYWEATHER_AMSC_COOKIE")
|
||||
session_id = get_runtime_secret("POLYWEATHER_AMSC_SESSION_ID")
|
||||
if cookie:
|
||||
headers["Cookie"] = cookie
|
||||
elif session_id:
|
||||
@@ -539,8 +622,8 @@ def _check_amsc_awos_health(timeout: int = 8) -> dict[str, Any]:
|
||||
return {"ok": False, "error": "not configured"}
|
||||
|
||||
credential_configured = bool(
|
||||
str(os.getenv("POLYWEATHER_AMSC_COOKIE") or "").strip()
|
||||
or str(os.getenv("POLYWEATHER_AMSC_SESSION_ID") or "").strip()
|
||||
get_runtime_secret("POLYWEATHER_AMSC_COOKIE")
|
||||
or get_runtime_secret("POLYWEATHER_AMSC_SESSION_ID")
|
||||
)
|
||||
try:
|
||||
t0 = _time.perf_counter()
|
||||
|
||||
Reference in New Issue
Block a user