P1 security batch: quota execution checks, cost accounting, goal/coordinator semantics, PR channel, orphan signature, control-plane and console hardening
- Docker quota watchdog + fail-closed quota checks, system tasks need real owner - max_cost_usd actually accumulated, cancel records cost, estimated rows excluded from unpriced cap, cache_write declared non-billed - goal-resume keeps original verify on breakpoint resume, goal retry falls back to resume, goal_max_rounds capped at 100 at create and execution - coordinator children inherit parent model/effort, orphans marked interrupted with audit - PR jobs on docker workers return explicit needs_local_worker instead of silent empty - worker code signature covers 6 files and is checked before claim, fail-closed - sub2api lazy provisioning with rotation, backup whitelist + 0600, push SSRF block, repos/workers desensitized, key/project audits and limits, console buttons/quota/status wording, coordinator/automation quota reserve
This commit is contained in:
1 parent
d254240d84
commit
1402dc67fd
20 files changed
+2616
-179
No files matched your search
+329
-54
@@ -113,12 +113,15 @@ from cloud.oauth import (
|
||||
)
|
||||
from cloud.relay import RelayError as _RelayError
|
||||
from cloud.relay import get_relay_models as _get_relay_models
|
||||
from cloud.sub2api import Sub2apiError as _Sub2apiError
|
||||
from cloud.sub2api import ensure_gateway_keys_table as _ensure_gateway_keys
|
||||
from cloud.sub2api import ensure_user_gateway_key as _ensure_sub2api_key
|
||||
from cloud.sub2api import gateway_name as _gateway_name
|
||||
from cloud.sub2api import gateway_origin as _gateway_origin
|
||||
from cloud.sub2api import get_user_gateway_key as _get_sub2api_key
|
||||
from cloud.sub2api import list_gateway_models as _list_sub2api_models
|
||||
from cloud.sub2api import rotate_user_gateway_key as _rotate_sub2api_key
|
||||
from cloud.sub2api import validate_or_rotate_gateway_key as _validate_sub2api_key
|
||||
from cloud.repos import validate_repo_url
|
||||
from cloud.push import (
|
||||
create_subscription as _push_create,
|
||||
@@ -167,6 +170,31 @@ TRACE_STREAM_MAX_SECONDS = 3600
|
||||
TRACE_STREAM_POLL_SECONDS = 1.0
|
||||
TRACE_DEFAULT_DIR = "/opt/codex-app-server/work/host-traces"
|
||||
|
||||
# P1 控制面限额(建项目时校验,超限 400 并在错误里写明数值):
|
||||
# 单用户项目数上限、项目名长度上限、自定义项目 id 长度上限。
|
||||
# 均可经环境变量调(运维侧),缺省值见下方;改动只影响新项目,
|
||||
# 已有超限项目不受影响(不删、不改名)。
|
||||
def _env_int(name, default):
|
||||
try:
|
||||
value = int(os.environ.get(name, "") or default)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return value if value > 0 else default
|
||||
|
||||
|
||||
MAX_PROJECTS_PER_USER = _env_int("CODEX_MAX_PROJECTS_PER_USER", 50)
|
||||
MAX_PROJECT_NAME_LEN = _env_int("CODEX_MAX_PROJECT_NAME_LEN", 100)
|
||||
MAX_PROJECT_ID_LEN = _env_int("CODEX_MAX_PROJECT_ID_LEN", 64)
|
||||
# 协调员父任务预留:每分工至少预留的美元额度(微美元)。
|
||||
# 父任务准入只查一次时,N 个分工可解锁 N 倍消费;本预留要求
|
||||
# 剩余配额 >= 分工数 × 本值,否则父任务直接 402(见
|
||||
# _check_coordinator_reserve)。缺省 $1/分工,可经环境变量调;
|
||||
# 它是准入下限估计,不是实际计费——实际花费仍按子任务
|
||||
# created_by=父任务创建人记到创建人账上(worker 建子任务时
|
||||
# created_by 直接取父任务创建人,成本 JOIN 据此归属)。
|
||||
COORDINATOR_RESERVE_PER_PART_USD_MICROS = _env_int(
|
||||
"CODEX_COORDINATOR_RESERVE_PER_PART_USD_MICROS", 1000000)
|
||||
|
||||
|
||||
def _trace_file_name(task_id):
|
||||
"""云任务 id -> 链路文件名:与 cloud.sandbox 的 _host_name
|
||||
@@ -1451,6 +1479,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
if path == "/users/me/gateway/rotate" and method == "POST":
|
||||
self._user_gateway_rotate()
|
||||
return
|
||||
if path.startswith("/users/me/keys"):
|
||||
# API keys 管理:全部只操作认证用户自己的 key。
|
||||
if method == "GET" and path == "/users/me/keys":
|
||||
@@ -1830,10 +1861,35 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
if not name or not isinstance(name, str):
|
||||
self._send_json(400, {"error": "name required"})
|
||||
return
|
||||
if len(name) > MAX_PROJECT_NAME_LEN:
|
||||
self._send_json(400, {
|
||||
"error": "project name too long: max %d characters"
|
||||
% MAX_PROJECT_NAME_LEN})
|
||||
return
|
||||
project_id = body.get("id") or uuid.uuid4().hex
|
||||
if not isinstance(project_id, str):
|
||||
self._send_json(400, {"error": "id must be a string"})
|
||||
return
|
||||
if len(project_id) > MAX_PROJECT_ID_LEN:
|
||||
self._send_json(400, {
|
||||
"error": "project id too long: max %d characters"
|
||||
% MAX_PROJECT_ID_LEN})
|
||||
return
|
||||
# 单用户项目数上限:按 projects.owner_id 计(成员身份加入
|
||||
# 的项目不计入自己的上限,避免“被拉进很多项目就建不了
|
||||
# 项目”);超限 400 且不建项目。
|
||||
_count_conn = self._db()
|
||||
try:
|
||||
_owned = _count_conn.execute(
|
||||
"SELECT COUNT(*) FROM projects WHERE owner_id = ?",
|
||||
(user_id,)).fetchone()[0]
|
||||
finally:
|
||||
_count_conn.close()
|
||||
if _owned >= MAX_PROJECTS_PER_USER:
|
||||
self._send_json(400, {
|
||||
"error": "project limit exceeded: max %d projects"
|
||||
" per user" % MAX_PROJECTS_PER_USER})
|
||||
return
|
||||
# repo_url 可选:不传时为 None(老行为不变);传了则与 worker
|
||||
# 共用 cloud.repos.validate_repo_url 的同一套校验,不合法时
|
||||
# 400 且不建项目,绝不把带凭证的 URL 存进库。
|
||||
@@ -1947,43 +2003,78 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
self._send_json(200, {"project": _project_to_dict(row)})
|
||||
|
||||
def _github_repos_list(self, user_id):
|
||||
"""GET /github/repos:列出本服务 GitHub App 安装记录里的仓
|
||||
库(installation_repositories 事件同步进 github_app_
|
||||
installations 表),给控制台建项目/补仓库时的下拉菜单用。
|
||||
任何已认证用户可列(只含仓库名,不含密钥);选了哪个仓库
|
||||
只影响调用者自己有管理权的项目(PATCH 仍按 owner 校验)。
|
||||
"""GET /github/repos:只返回调用者成员项目对应的仓库。
|
||||
|
||||
P1 收紧前返回全部安装记录(任一已认证用户可看到全部
|
||||
安装仓库名,属跨租户信息泄露)。现按 project_members
|
||||
过滤:只保留调用者是成员的项目的 repo_url 对应的仓库
|
||||
(clone_url 归一化后比对:去 .git 后缀、比对小写);
|
||||
调用者无成员项目或项目未填 repo_url 时返回空列表。
|
||||
手填 URL 的能力不变——没进 App 安装的公开仓库仍可手动填。"""
|
||||
conn = self._db()
|
||||
try:
|
||||
rows = list_installations(conn)
|
||||
member_ids = {
|
||||
row["project_id"]
|
||||
for row in conn.execute(
|
||||
"SELECT project_id FROM project_members"
|
||||
" WHERE user_id = ?",
|
||||
(user_id,)).fetchall()
|
||||
}
|
||||
allowed = set()
|
||||
for row in conn.execute(
|
||||
"SELECT id, repo_url FROM projects").fetchall():
|
||||
if row["id"] not in member_ids or not row["repo_url"]:
|
||||
continue
|
||||
url = str(row["repo_url"]).strip().lower()
|
||||
if url.endswith(".git"):
|
||||
url = url[:-4]
|
||||
allowed.add(url)
|
||||
finally:
|
||||
conn.close()
|
||||
repos = [{
|
||||
"installation_id": r["installation_id"],
|
||||
"account_login": r["account_login"],
|
||||
"repo_full_name": r["repo_full_name"],
|
||||
"clone_url": "https://github.com/%s.git" % r["repo_full_name"],
|
||||
} for r in rows]
|
||||
repos = []
|
||||
for r in rows:
|
||||
clone_url = "https://github.com/%s.git" % r["repo_full_name"]
|
||||
normalized = clone_url.lower()
|
||||
if normalized.endswith(".git"):
|
||||
normalized = normalized[:-4]
|
||||
if normalized not in allowed:
|
||||
continue
|
||||
repos.append({
|
||||
"installation_id": r["installation_id"],
|
||||
"account_login": r["account_login"],
|
||||
"repo_full_name": r["repo_full_name"],
|
||||
"clone_url": clone_url,
|
||||
})
|
||||
self._send_json(200, {"repos": repos})
|
||||
|
||||
def _list_projects(self, user_id):
|
||||
conn = self._db()
|
||||
try:
|
||||
member_ids = {
|
||||
row["project_id"]
|
||||
for row in conn.execute(
|
||||
"SELECT project_id FROM project_members WHERE user_id = ?",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
}
|
||||
member_rows = conn.execute(
|
||||
"SELECT project_id, role FROM project_members"
|
||||
" WHERE user_id = ?",
|
||||
(user_id,)).fetchall()
|
||||
member_ids = {row["project_id"] for row in member_rows}
|
||||
roles = {row["project_id"]: row["role"]
|
||||
for row in member_rows}
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, owner_id, created_at, repo_url, auto_pr"
|
||||
" FROM projects ORDER BY created_at, id"
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
projects = [_project_to_dict(r) for r in rows
|
||||
if r["id"] in member_ids]
|
||||
projects = []
|
||||
for r in rows:
|
||||
if r["id"] not in member_ids:
|
||||
continue
|
||||
# 附加字段 my_role(只增字段,不改已有成功语义):
|
||||
# 控制台据此按角色禁用重试/合并/提 PR 等按钮;服务端
|
||||
# 权限仍以每次请求的 check_permission 为准,前端禁用
|
||||
# 只是防误点,不是鉴权。
|
||||
item = _project_to_dict(r)
|
||||
item["my_role"] = roles.get(r["id"])
|
||||
projects.append(item)
|
||||
self._send_json(200, {"projects": projects})
|
||||
|
||||
# -- 项目子资源:记忆 / 知识库 / 成本 -------------------------------
|
||||
@@ -2119,6 +2210,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
deleted = _push_delete(self.server.db_path,
|
||||
subscription_id=parts[3])
|
||||
if deleted:
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(
|
||||
user_id, project_id, "push_subscription_deleted",
|
||||
"subscription_id=%s" % parts[3])
|
||||
self._send_json(200, {"deleted": bool(deleted)})
|
||||
return
|
||||
# /projects/{id}/automations
|
||||
@@ -2389,31 +2485,33 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
# 缓存(见 cloud/relay.py)。单测可经 server.relay_fetcher
|
||||
# 注入假取数函数;中转挂了或未配 key 时 502,不编造列表。
|
||||
# 网关为 sub2api 时:模型目录因用户而异(key 绑定的组
|
||||
# 决定),取调用者自己的网关 key 现查;无 key 时返回空
|
||||
# 列表 + provisioning 标记(控制台据此提示开通中),不
|
||||
# 在页面加载时悄悄开通(开通在建号与任务时发生)。
|
||||
# 决定)。P1 收紧:首次调模型目录即做真正的懒开通/
|
||||
# 健康检查,不再只回 provisioning: pending——无行则开通,
|
||||
# 旧 key 经 /v1/models 实测已死(401/403,含余额为 0 的
|
||||
# 403)则轮换重开;网关本身不可用时回 502、保留旧行。
|
||||
# key 明文绝不进响应、日志、缓存。
|
||||
if _gateway_name() == "sub2api":
|
||||
conn = self._db()
|
||||
try:
|
||||
row = _get_sub2api_key(conn, user_id)
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
status, api_key, _sub, _kid = _validate_sub2api_key(
|
||||
conn, user_id)
|
||||
except _Sub2apiError as exc:
|
||||
self._send_json(502, {"error": str(exc)})
|
||||
return
|
||||
except Exception as exc:
|
||||
self._send_json(502, {"error": str(exc)})
|
||||
return
|
||||
finally:
|
||||
conn.close()
|
||||
if row is None or not row.get("api_key"):
|
||||
self._send_json(200, {"models": [], "gateway": "sub2api",
|
||||
"provisioning": "pending"})
|
||||
return
|
||||
try:
|
||||
models = _list_sub2api_models(_gateway_origin(),
|
||||
row["api_key"])
|
||||
models = _list_sub2api_models(_gateway_origin(), api_key)
|
||||
except Exception as exc:
|
||||
self._send_json(502, {"error": str(exc)})
|
||||
return
|
||||
self._send_json(200, {"models": models, "gateway": "sub2api",
|
||||
"cached": False, "updated_at": None})
|
||||
"cached": False, "updated_at": None,
|
||||
"provisioning": status})
|
||||
return
|
||||
fetcher = getattr(self.server, "relay_fetcher", None)
|
||||
try:
|
||||
@@ -2647,6 +2745,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
self._send_json(400, {
|
||||
"error": "goal_max_rounds must be >= 1"})
|
||||
return
|
||||
# P1:旧验收形态轮次设上限 100(与 worker/sandbox 两道门
|
||||
# 一致;新 goal_mode=1 不走本分支,仍由配额兜底)。
|
||||
if goal_max_rounds > 100:
|
||||
self._send_json(400, {
|
||||
"error": "goal_max_rounds 超过上限 100 轮"})
|
||||
return
|
||||
# 协调员:coordinator_parts 非空时任务本身不跑模型回合,
|
||||
# worker 认领后按分工建并行子任务、等齐、合流;与 goal/
|
||||
# plan/验收命令语义冲突(那些都是“本任务跑模型”的意思),
|
||||
@@ -2673,6 +2777,33 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
if self._reject_if_over_quota(user_id):
|
||||
return
|
||||
# 协调员预留(P1):父任务准入只查一次时,N 个分工会
|
||||
# 解锁 N 倍消费。父任务创建时要求剩余美元配额至少覆盖
|
||||
# 分工数 × 每分工预留额,否则直接 402、父任务不建;
|
||||
# 子任务归属 = 父任务创建人(worker 建子任务时
|
||||
# created_by 直接取父任务创建人,成本 JOIN 据此归属,
|
||||
# 子任务认领时 worker 侧配额门再查一次)。不限额用户
|
||||
# (quota 为 None)不受此预留约束。
|
||||
if coord_parts and not self._check_coordinator_reserve(
|
||||
user_id, len(coord_parts)):
|
||||
return
|
||||
# sub2api 懒开通/健康检查(P1):建任务前先真校验网关
|
||||
# key——无行则开通,死 key 则轮换重开;网关不可用时
|
||||
# 502 且不建任务(不把死 key/待开通当成已开通发给
|
||||
# worker)。xcmapi 网关下跳过本段。
|
||||
if _gateway_name() == "sub2api":
|
||||
_gw_conn = self._db()
|
||||
try:
|
||||
try:
|
||||
_validate_sub2api_key(_gw_conn, user_id)
|
||||
except _Sub2apiError as exc:
|
||||
self._send_json(502, {"error": str(exc)})
|
||||
return
|
||||
except Exception as exc:
|
||||
self._send_json(502, {"error": str(exc)})
|
||||
return
|
||||
finally:
|
||||
_gw_conn.close()
|
||||
# 环境绑定:environment_id 非空时必须是本项目、已 ready
|
||||
# 的环境;creating/failed/archived 一律拒绝并把状态写进
|
||||
# 报错(creating 时用户就知道该等 worker 准备好再建任务,
|
||||
@@ -2836,6 +2967,15 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
conn.close()
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, project_id, "create_task", task_id)
|
||||
if coord_parts:
|
||||
# 创建响应里写明子任务配额归属(与 worker 建子任务时
|
||||
# created_by=父任务创建人一致),不是只写文档宣称。
|
||||
task["quota_attribution"] = (
|
||||
"coordinator subtasks are charged to the parent"
|
||||
" task creator")
|
||||
task["quota_reserved_usd_micros"] = (
|
||||
len(coord_parts)
|
||||
* COORDINATOR_RESERVE_PER_PART_USD_MICROS)
|
||||
self._send_json(201, task)
|
||||
|
||||
def _list_tasks(self, user_id, query):
|
||||
@@ -4091,6 +4231,34 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _check_coordinator_reserve(self, user_id, n_parts):
|
||||
"""协调员预留准入:通过返回 True;被挡时已回 402 返回 False。
|
||||
|
||||
剩余美元配额(quota - used,quota 为 None = 不限额时放行)
|
||||
必须 >= 分工数 × COORDINATOR_RESERVE_PER_PART_USD_MICROS。
|
||||
这是准入下限估计,不是计费承诺:子任务实际花费仍按
|
||||
created_by=父任务创建人全额记创建人账;父任务被挡时
|
||||
一个子任务也不会建,不存在“第 2 个子任务才被挡”的
|
||||
半截状态。"""
|
||||
if not n_parts or n_parts <= 1:
|
||||
return True
|
||||
quota, used, month = self._quota_status(user_id)
|
||||
if quota is None:
|
||||
return True
|
||||
need = n_parts * COORDINATOR_RESERVE_PER_PART_USD_MICROS
|
||||
if quota - used < need:
|
||||
self._send_json(402, {
|
||||
"error": "coordinator_quota_reserve_exceeded",
|
||||
"month": month,
|
||||
"quota_usd_micros": quota,
|
||||
"used_usd_micros": used,
|
||||
"coordinator_parts": n_parts,
|
||||
"reserved_usd_micros": need,
|
||||
"quota_attribution": "coordinator subtasks are charged"
|
||||
" to the parent task creator"})
|
||||
return False
|
||||
return True
|
||||
|
||||
def _audit_admin(self, user_id, action, detail):
|
||||
"""admin 调用审计(成功与被拒都记,失败绝不挡主流程)。"""
|
||||
try:
|
||||
@@ -4125,10 +4293,17 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def _user_quota_self(self, user_id):
|
||||
quota, used, month = self._quota_status(user_id)
|
||||
self._send_json(200,
|
||||
self._quota_payload(
|
||||
user_id, quota, used, month,
|
||||
self._quota_unpriced_tokens(user_id)))
|
||||
# 合并取舍(P1 rebase 到 d254240):main 的 _quota_payload
|
||||
# 已带 unpriced_actual_tokens,P1 的附加字段也全留,供控制台
|
||||
# 同时显示美元与无价 token 两条进度;402 时控制台据
|
||||
# 响应字段区分“美元配额已满”与“无价 token 已满”。
|
||||
payload = self._quota_payload(
|
||||
user_id, quota, used, month,
|
||||
self._quota_unpriced_tokens(user_id))
|
||||
payload["unpriced_tokens_used"] = self._quota_unpriced_tokens(
|
||||
user_id, month)
|
||||
payload["unpriced_token_cap"] = unpriced_token_cap()
|
||||
self._send_json(200, payload)
|
||||
|
||||
def _admin_quota_get(self, user_id, query):
|
||||
if not self._is_global_admin(user_id):
|
||||
@@ -4204,10 +4379,20 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
|
||||
# -- worker 池 -----------------------------------------------------
|
||||
def _workers_list(self, user_id):
|
||||
"""列出注册 worker:任何已认证用户可读(worker 是基础设施
|
||||
信息,不属于某个项目的数据,不按项目成员过滤)。"""
|
||||
"""列出注册 worker:全局管理员看完整清单;其他已认证用户
|
||||
只看在线/离线聚合(P1 收紧:worker_id、执行器、容量、
|
||||
心跳时间等内部细节不对普通用户暴露,控制台的 Workers
|
||||
区据此只显示聚合数字)。"""
|
||||
workers = _registry_list(self.server.db_path)
|
||||
self._send_json(200, {"workers": workers})
|
||||
if self._is_global_admin(user_id):
|
||||
self._send_json(200, {"workers": workers})
|
||||
return
|
||||
online = sum(1 for w in workers if w.get("alive"))
|
||||
self._send_json(200, {
|
||||
"workers_online": online,
|
||||
"workers_offline": len(workers) - online,
|
||||
"workers_total": len(workers),
|
||||
})
|
||||
|
||||
def _workers_heartbeat(self, user_id):
|
||||
"""worker 心跳:body 含 worker_id(必填,缺了 400)与可选
|
||||
@@ -4429,11 +4614,15 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
"""POST /admin/backup:备份当前后端数据库,返回路径与规模信息。
|
||||
|
||||
权限:只认 CODEX_GLOBAL_ADMINS(见 _is_global_admin),
|
||||
未配置时一律 403。返回只含 backend、备份文件路径、
|
||||
文件大小与任务行数,绝不返回数据库密码、备份文件内容
|
||||
或任何凭据。out_dir 可由 body 指定,否则走环境变量
|
||||
CLOUD_BACKUP_DIR,未设则用 cloud-backups。成功与被拒
|
||||
都记审计(审计里只记路径与规模,不记库内容)。
|
||||
未配置时一律 403;非运维调用方根本到不了这里,绝对
|
||||
路径只回给全局管理员。out_dir 限白名单目录(P1 收紧):
|
||||
白名单根来自 CLOUD_BACKUP_ROOT(逗号分隔多根),未设
|
||||
则 CLOUD_BACKUP_DIR,未设则固定根 cloud-backups;请求
|
||||
的 out_dir 经实路径解析后必须等于某根或在其下,含 ..
|
||||
逃逸、指向 /、/etc、/opt 等系统目录的一律 400(白名单
|
||||
外的绝对路径即使存在也不放行)。备份文件落盘后 chmod
|
||||
0600。成功与被拒都记审计(调用人由审计的 user_id 列
|
||||
记,detail 含目标目录与大小,不记库内容)。
|
||||
"""
|
||||
if not self._is_global_admin(user_id):
|
||||
self._audit_admin(user_id, "admin_backup_denied", "backup")
|
||||
@@ -4443,11 +4632,33 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
out_dir = body.get("out_dir") or os.environ.get(
|
||||
"CLOUD_BACKUP_DIR") or "cloud-backups"
|
||||
if not isinstance(out_dir, str) or not out_dir:
|
||||
requested = body.get("out_dir")
|
||||
if requested is not None and (
|
||||
not isinstance(requested, str) or not requested):
|
||||
self._send_json(400, {"error": "out_dir must be a string"})
|
||||
return
|
||||
roots_raw = os.environ.get("CLOUD_BACKUP_ROOT", "")
|
||||
if roots_raw.strip():
|
||||
roots = [os.path.realpath(p.strip())
|
||||
for p in roots_raw.split(",") if p.strip()]
|
||||
else:
|
||||
roots = [os.path.realpath(
|
||||
os.environ.get("CLOUD_BACKUP_DIR") or "cloud-backups")]
|
||||
if requested:
|
||||
candidate = os.path.realpath(requested)
|
||||
else:
|
||||
candidate = roots[0]
|
||||
allowed = any(candidate == root
|
||||
or candidate.startswith(root + os.sep)
|
||||
for root in roots)
|
||||
if not allowed:
|
||||
self._audit_admin(
|
||||
user_id, "admin_backup_denied",
|
||||
"out_dir outside backup roots: %s" % requested)
|
||||
self._send_json(400, {
|
||||
"error": "out_dir outside allowed backup roots"})
|
||||
return
|
||||
out_dir = candidate
|
||||
backend = _db_backend_name()
|
||||
try:
|
||||
if backend == "postgres":
|
||||
@@ -4456,6 +4667,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
from cloud.backup import backup_db as _backup_sqlite
|
||||
dest = _backup_sqlite(self.server.db_path, out_dir, keep=3)
|
||||
try:
|
||||
os.chmod(dest, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
size_bytes = os.path.getsize(dest)
|
||||
conn = self._db()
|
||||
try:
|
||||
@@ -4468,8 +4683,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
self._audit_admin(
|
||||
user_id, "admin_backup",
|
||||
"backend=%s path=%s size=%d tasks=%d"
|
||||
% (backend, dest, size_bytes, task_count))
|
||||
"caller=%s backend=%s out_dir=%s path=%s size=%d tasks=%d"
|
||||
% (user_id, backend, out_dir, dest, size_bytes, task_count))
|
||||
self._send_json(200, {
|
||||
"backend": backend, "path": dest,
|
||||
"size_bytes": size_bytes, "task_count": task_count,
|
||||
@@ -5165,6 +5380,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
label = "api-key"
|
||||
with _auth_store(self.server.db_path) as auth:
|
||||
key_id, api_key_plain = auth.create_api_key(user_id, label)
|
||||
# 审计只记 key_id 与 label,绝不记明文与哈希。
|
||||
auth.audit(user_id, "", "api_key_created",
|
||||
"key_id=%s label=%s" % (key_id, label))
|
||||
self._send_json(201, {"id": key_id, "label": label,
|
||||
"hint": api_key_plain[-4:],
|
||||
"api_key": api_key_plain})
|
||||
@@ -5182,6 +5400,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
with _auth_store(self.server.db_path) as auth:
|
||||
ok = auth.revoke_api_key(user_id, key_id)
|
||||
if ok:
|
||||
auth.audit(user_id, "", "api_key_revoked",
|
||||
"key_id=%s" % key_id)
|
||||
if not ok:
|
||||
self._send_json(404, {"error": "key not found"})
|
||||
return
|
||||
@@ -5199,8 +5420,45 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
keep_hash = hashlib.sha256(api_key.encode()).hexdigest()
|
||||
with _auth_store(self.server.db_path) as auth:
|
||||
count = auth.revoke_other_api_keys(user_id, keep_hash)
|
||||
# 只记撤销数,不记任何 key 明文、哈希或保留 key 的身份。
|
||||
auth.audit(user_id, "", "api_key_revoke_others",
|
||||
"revoked_count=%d" % count)
|
||||
self._send_json(200, {"revoked_count": count})
|
||||
|
||||
def _user_gateway_rotate(self):
|
||||
"""POST /users/me/gateway/rotate:仅限本人重新开通/轮换
|
||||
网关 key。非 sub2api 网关时 400;网关不可用时 502 且
|
||||
旧行不动(rotate 内部先删旧行再重开,只在重开失败时
|
||||
旧 key 已丢——调用方须知:轮换是显式动作,失败后须
|
||||
重试本端点重新开通)。审计只记 sub2api_user_id 与
|
||||
key_id,绝不记 key 明文;响应不含 key。"""
|
||||
user_id = self._authenticate()
|
||||
if user_id is None:
|
||||
self._send_json(401, {"error": "unauthorized"})
|
||||
return
|
||||
if _gateway_name() != "sub2api":
|
||||
self._send_json(400, {"error": "gateway is not sub2api"})
|
||||
return
|
||||
conn = self._db()
|
||||
try:
|
||||
try:
|
||||
status, _key, sub_id, key_id = _rotate_sub2api_key(
|
||||
conn, user_id)
|
||||
except _Sub2apiError as exc:
|
||||
self._send_json(502, {"error": str(exc)})
|
||||
return
|
||||
except Exception as exc:
|
||||
self._send_json(502, {"error": str(exc)})
|
||||
return
|
||||
finally:
|
||||
conn.close()
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, "", "gateway_key_rotated",
|
||||
"sub2api_user_id=%s key_id=%s"
|
||||
% (sub_id, key_id))
|
||||
self._send_json(200, {"rotated": True, "provisioning": status,
|
||||
"sub2api_user_id": sub_id})
|
||||
|
||||
# -- 推送订阅与分发 ---------------------------------------------------
|
||||
def _push_create_endpoint(self, user_id, project_id):
|
||||
body, err = self._read_json()
|
||||
@@ -5215,6 +5473,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
except ValueError as e:
|
||||
self._send_json(400, {"error": str(e)})
|
||||
return
|
||||
# 审计只记订阅 id 与通道:target 全文不记(Slack webhook
|
||||
# URL 本身含 secret 路径,记进审计等于把 secret 落盘)。
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, project_id, "push_subscription_created",
|
||||
"subscription_id=%s channel=%s"
|
||||
% (sub["id"], sub["channel"]))
|
||||
self._send_json(201, sub)
|
||||
|
||||
def _push_dispatch_endpoint(self, user_id):
|
||||
@@ -5228,8 +5492,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
self._send_json(400, {"error": "event_type and project_id required"})
|
||||
return
|
||||
# P0 安全批后:/admin/push/dispatch 是管理端点,只认
|
||||
# CODEX_GLOBAL_ADMINS,不再只凭项目 read 就能让服务端
|
||||
# 向订阅目标(可能含内网地址)真 POST。被拒与成功都记审计。
|
||||
# CODEX_GLOBAL_ADMINS——该口径已强于 P1 要求的 create_task,
|
||||
# 不再降级。被拒与成功都记审计;目标 SSRF 校验在
|
||||
# push.dispatch_event / 真实发送层做。
|
||||
if not self._is_global_admin(user_id):
|
||||
self._audit_admin(user_id, "admin_push_denied",
|
||||
"dispatch %s %s" % (event_type, project_id))
|
||||
@@ -5258,11 +5523,21 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
"""创建自动化定义:只登记定义与到期状态,不跑模型、
|
||||
不另起调度器(执行器后续走 claim 门,见 automations
|
||||
模块文档)。body:name、prompt 必填;interval_seconds
|
||||
可选(默认 86400,最小 60);repo_url 可选。"""
|
||||
可选(默认 86400,最小 60);repo_url 可选。
|
||||
|
||||
P1 配额门:创建时先过与建任务同一道配额门(美元配额
|
||||
与无价 token cap,超限 402),避免“定义免费建、到期
|
||||
无限跑”;单自动化的 spend 上限即创建人配额本身——
|
||||
每次到期产出的任务以 created_by=创建人建任务,走
|
||||
POST /tasks 的配额门(触发路径若绕过服务端建任务,
|
||||
则不在本门覆盖内,见残留)。本服务端没有自动化触发
|
||||
端点,触发侧的门不在本次 server.py 改动内。"""
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
if self._reject_if_over_quota(user_id):
|
||||
return
|
||||
interval = body.get("interval_seconds", 86400)
|
||||
if interval is None:
|
||||
interval = 86400
|
||||
|
||||
Reference in new issue
Block a user