Compare commits
34 Commits
3a16da6449
...
cdd9d6701f
| Author | SHA1 | Date | |
|---|---|---|---|
| cdd9d6701f | |||
| 4cd2acc192 | |||
| 37c3417bfb | |||
| 938c2eb0be | |||
| dee1eb2374 | |||
| 190c1dc3c8 | |||
| e6a12b9a7c | |||
| 2461a923be | |||
| 3f0d5c8c96 | |||
| 9d6decd91b | |||
| c3c4e57416 | |||
| 950964e0ef | |||
| 84e06ca396 | |||
| d16e294185 | |||
| e92fe0d7fb | |||
| 9eb6103a4d | |||
| 78359c3fd8 | |||
| 3ec4fb8537 | |||
| ac8b37bd27 | |||
| d9a86878f4 | |||
| 4f9116e3fe | |||
| ec4769a33f | |||
| 978b0cf24d | |||
| 5093d3d123 | |||
| ac2ab26a7e | |||
| ab40a4a17e | |||
| 04170205dd | |||
| 9166ab205d | |||
| 427709e0a8 | |||
| e5ff6e74f9 | |||
| ef32a6de0f | |||
| 240c6bd0e2 | |||
| 8ea5507d27 | |||
| 45b019dae8 |
+2
-2
@@ -53,7 +53,7 @@ DeerFlow 原本面向"单机可信环境、单用户"。本分支按"以个人
|
||||
- **决策(7 份 ADR + spike + 审计)**:[`01-redesign/`](docs/multi-tenant-redesign/01-redesign/)
|
||||
- **Stage 0 schema 锁定版 / 落地版**:[`workspace-schema-design`](docs/multi-tenant-redesign/01-redesign/workspace-schema-design.zh-CN.md) · [`database-schema-as-built`](docs/multi-tenant-redesign/01-redesign/database-schema-as-built.zh-CN.md)
|
||||
- **落地路线 + 集成轨道**:[`02-rollout/`](docs/multi-tenant-redesign/02-rollout/)
|
||||
- **Stage 0 进度面板(权威"现在到哪了")**:[`03-impl/STATUS.md`](docs/multi-tenant-redesign/03-impl/STATUS.md)
|
||||
- **Stage 0 进度面板(权威"现在到哪了")**:[`03-impl/STATUS.zh-CN.md`](docs/multi-tenant-redesign/03-impl/STATUS.zh-CN.md)
|
||||
|
||||
## 官网
|
||||
|
||||
@@ -656,7 +656,7 @@ DeerFlow 对模型没有强绑定,只要实现了 OpenAI 兼容 API 的 LLM,
|
||||
## 文档
|
||||
|
||||
- [多租户改造汇总索引](docs/multi-tenant-redesign/README.zh-CN.md) - workspace / Postgres / RLS / Headless API 的决策与路线
|
||||
- [Stage 0 进度面板](docs/multi-tenant-redesign/03-impl/STATUS.md) - "现在到哪了"的权威来源
|
||||
- [Stage 0 进度面板](docs/multi-tenant-redesign/03-impl/STATUS.zh-CN.md) - "现在到哪了"的权威来源
|
||||
- [数据库设计落地版](docs/multi-tenant-redesign/01-redesign/database-schema-as-built.zh-CN.md) - 10 张表全字段 / 外键 / 索引参考
|
||||
- [贡献指南](CONTRIBUTING.md) - 开发环境搭建与协作流程
|
||||
- [配置指南](backend/docs/CONFIGURATION.md) - 安装与配置说明
|
||||
|
||||
+13
-1
@@ -49,6 +49,18 @@ DF_BASE=http://localhost:8001 DF_TENANTS=4 DF_TURNS=10 \
|
||||
|
||||
环境变量:`DF_BASE`(网关地址,默认 :8001)、`DF_TENANTS`(并发租户数,默认 3)、`DF_TURNS`(每租户轮数,默认 10)。每次运行用唯一邮箱新建租户,可重复跑,不撞 email、也不触发登录限流(`/register` 不限流;`setup-status` 全程只调一次以避开 60s/IP 限流)。
|
||||
|
||||
### 多租户验证(Headless / API Key 模式)
|
||||
|
||||
[`examples/http-chat/multi_tenant_headless.py`](examples/http-chat/multi_tenant_headless.py) 是上面那个测试的 **server-to-server(无人值守)** 版,验证 Stage 1 的 API Key 鉴权:每个租户先由一个人类 owner(cookie 会话)建 service account 并 mint 一把 workspace-scoped key(`POST /api/v1/service-accounts` → `POST /api/v1/api-keys`,plaintext 仅返回一次),之后所有对话只用 `Authorization: Bearer dfk_live_...`(独立 Session、**不带 cookie / CSRF**)。除并发 / 上下文 / 隔离(同 cookie 版)外,额外校验两条 headless 专属性质:④ **scope 强制**——一把缺 `runs:create` 的 key 发起对话返回 403;⑤ **撤销即失效**——`DELETE /api/v1/api-keys/{id}` 后该 key 立即 401。
|
||||
|
||||
```bash
|
||||
# 前提:已起 Gateway(dev-gateway 或 dev-full)
|
||||
DF_BASE=http://localhost:8001 DF_TENANTS=4 DF_TURNS=10 \
|
||||
uv run --no-project --with requests python apps/examples/http-chat/multi_tenant_headless.py
|
||||
```
|
||||
|
||||
环境变量同上,外加 `DF_EXTRA=0` 可跳过 scope / 撤销专项检查。实测(`:8001`,`DF_TENANTS=2 DF_TURNS=3`)全绿:真并发、多轮上下文保持、跨租户 `GET` 均 404、只读 key stream 403、撤销后 401。
|
||||
|
||||
## 前置:先把 DeerFlow 跑起来
|
||||
|
||||
在**仓库根目录**:
|
||||
@@ -91,7 +103,7 @@ Gateway 是 **fail-closed** 的——除少数公开路径外所有请求都要
|
||||
3. 成功后 Session 里有 `access_token`(HttpOnly) + `csrf_token` 两个 cookie
|
||||
4. **所有写请求**(POST/PUT/DELETE/PATCH)必须带 `X-CSRF-Token` 头 = `csrf_token` 值
|
||||
|
||||
> 多租户:当前 `docs/multi-tenant-redesign` 分支的 API Key 鉴权中间件尚未接入,外部系统暂时只能走会话 cookie。等 `Authorization: Bearer dfk_live_...` 落地后再补无人值守接入。每个注册用户即一个独立租户(自带 workspace);并发与隔离行为可用上面的 [`multi_tenant.py`](examples/http-chat/multi_tenant.py) 验证。
|
||||
> 多租户:浏览器式接入走会话 cookie(每个注册用户即一个独立租户,自带 workspace),并发与隔离可用 [`multi_tenant.py`](examples/http-chat/multi_tenant.py) 验证。**无人值守 / 业务后端**接入已支持 API Key(Stage 1):owner 经 `POST /api/v1/service-accounts` + `POST /api/v1/api-keys` mint 一把 key,业务侧用 `Authorization: Bearer dfk_live_...` 直连(免 cookie / CSRF),端到端示例见 [`multi_tenant_headless.py`](examples/http-chat/multi_tenant_headless.py)。
|
||||
|
||||
## 新建一个应用
|
||||
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Headless 多租户测试:以 **API Key(Authorization: Bearer dfk_...)** 的 server-to-server
|
||||
方式跑多租户并发对话并验证隔离 —— 即 [multi_tenant.py] 的「无人值守 / 业务后端」版。
|
||||
|
||||
与 multi_tenant.py(浏览器式 cookie + CSRF)的区别:
|
||||
- 每个租户先由一个 **人类 owner**(cookie 会话)创建 service account 并 mint 一把
|
||||
workspace-scoped API key(plaintext 仅返回一次);
|
||||
- 之后所有对话只用 **Bearer key**(独立 Session、不带任何 cookie / CSRF),
|
||||
模拟业务系统 backend 直连 Gateway。
|
||||
|
||||
运行(前提:仓库根已起 Gateway,如 `./scripts/dev-gateway.sh start`):
|
||||
DF_BASE=http://localhost:8001 DF_TENANTS=4 \
|
||||
uv run --no-project --with requests python multi_tenant_headless.py
|
||||
# 没有 uv 时:pip install -r requirements.txt && python multi_tenant_headless.py
|
||||
环境变量:DF_BASE(网关地址,默认 :8001)、DF_TENANTS(并发租户数,默认 3)、
|
||||
DF_TURNS(每租户链式对话轮数,默认 10)、DF_EXTRA=0 可跳过 scope/撤销专项检查。
|
||||
|
||||
字段 / 端点(已对照 Stage 1 headless-api 实现):
|
||||
人类鉴权 POST /api/v1/auth/{register,me} (cookie + CSRF)
|
||||
建 SA POST /api/v1/service-accounts (owner cookie + CSRF)
|
||||
mint key POST /api/v1/api-keys → {plaintext, key_prefix, id, ...}(仅此一次返 plaintext)
|
||||
撤销 key DELETE /api/v1/api-keys/{id} → 204
|
||||
对话 POST /api/v1/threads ;/threads/search ;/threads/{id}/runs/stream(Bearer,无 CSRF)
|
||||
|
||||
校验信号(来自实现):
|
||||
- Bearer 路径下 thread 归属 user_id = service_account.id + workspace_id,与真人同构 → 隔离一致
|
||||
- 跨 workspace 访问线程返回 404(藏存在性,非 403)
|
||||
- key.scopes 经 AuthContext.permissions 灌入 @require_permission:缺 runs:create → stream 403
|
||||
- 撤销后的 key → 401
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import requests
|
||||
|
||||
BASE = os.environ.get("DF_BASE", "http://localhost:8001")
|
||||
N_TENANTS = int(os.environ.get("DF_TENANTS", "3"))
|
||||
N_TURNS = int(os.environ.get("DF_TURNS", "10")) # 每租户的对话轮数
|
||||
EXTRA_CHECKS = os.environ.get("DF_EXTRA", "1") != "0" # scope / 撤销专项检查
|
||||
RUN_ID = uuid.uuid4().hex[:8]
|
||||
PASSWORD = "DeerTenant-7x9q!" # ≥8 位且不在弱口令黑名单
|
||||
|
||||
# 一把「全权」key 的 scope —— 覆盖 chat 链路需要的 runs:create / threads:read 等。
|
||||
FULL_SCOPES = "threads:read,threads:write,threads:delete,runs:create,runs:read,runs:cancel"
|
||||
|
||||
_print_lock = threading.Lock()
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
with _print_lock:
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
# ── 人类 owner 侧(cookie + CSRF):注册 → 建 SA → mint key ─────────────
|
||||
def _csrf(s: requests.Session) -> dict:
|
||||
token = s.cookies.get("csrf_token")
|
||||
if not token:
|
||||
raise RuntimeError("缺少 csrf_token cookie —— 鉴权可能失败")
|
||||
return {"X-CSRF-Token": token}
|
||||
|
||||
|
||||
def register(s: requests.Session, email: str) -> dict:
|
||||
r = s.post(f"{BASE}/api/v1/auth/register", json={"email": email, "password": PASSWORD})
|
||||
r.raise_for_status()
|
||||
return r.json() # {id, email, system_role}
|
||||
|
||||
|
||||
def whoami(s: requests.Session) -> dict:
|
||||
r = s.get(f"{BASE}/api/v1/auth/me")
|
||||
r.raise_for_status()
|
||||
return r.json() # {id, email, default_workspace_id, workspaces:[...]}
|
||||
|
||||
|
||||
def create_service_account(s: requests.Session, name: str) -> dict:
|
||||
r = s.post(f"{BASE}/api/v1/service-accounts", json={"name": name}, headers=_csrf(s))
|
||||
r.raise_for_status()
|
||||
return r.json() # {id, workspace_id, name, role, status, ...}
|
||||
|
||||
|
||||
def mint_key(s: requests.Session, sa_id: str, name: str, scopes: str) -> dict:
|
||||
r = s.post(
|
||||
f"{BASE}/api/v1/api-keys",
|
||||
json={"service_account_id": sa_id, "name": name, "scopes": scopes, "env": "live"},
|
||||
headers=_csrf(s),
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json() # {id, key_prefix, plaintext, scopes, ...} ← plaintext 仅此一次
|
||||
|
||||
|
||||
def revoke_key(s: requests.Session, key_id: str) -> int:
|
||||
r = s.delete(f"{BASE}/api/v1/api-keys/{key_id}", headers=_csrf(s))
|
||||
return r.status_code # 204 = 成功
|
||||
|
||||
|
||||
# ── 业务后端侧(Bearer key,无 cookie / 无 CSRF)──────────────────────
|
||||
def bearer_session(plaintext: str) -> requests.Session:
|
||||
s = requests.Session()
|
||||
s.headers.update({"Authorization": f"Bearer {plaintext}"})
|
||||
return s
|
||||
|
||||
|
||||
def create_thread(s: requests.Session) -> str:
|
||||
r = s.post(f"{BASE}/api/v1/threads", json={})
|
||||
r.raise_for_status()
|
||||
return r.json()["thread_id"]
|
||||
|
||||
|
||||
def stream_answer(s: requests.Session, thread_id: str, message: str) -> dict:
|
||||
"""发一条消息,按 message-id 分组收集 AI 增量文本(TitleMiddleware 会另起一条
|
||||
AI 消息生成标题,必须按 id 分组,否则正文数字会和标题数字粘连导致误判)。"""
|
||||
body = {
|
||||
"assistant_id": "lead_agent",
|
||||
"input": {"messages": [{"role": "user", "content": message}]},
|
||||
"stream_mode": ["messages-tuple", "values"],
|
||||
}
|
||||
headers = {"Accept": "text/event-stream"} # Bearer 已在 session.headers
|
||||
by_id: dict[str, str] = {}
|
||||
with s.post(f"{BASE}/api/v1/threads/{thread_id}/runs/stream", json=body, headers=headers, stream=True) as resp:
|
||||
resp.raise_for_status()
|
||||
event, buf = None, []
|
||||
for raw in resp.iter_lines(decode_unicode=True):
|
||||
if raw is None:
|
||||
continue
|
||||
line = raw.strip()
|
||||
if line == "":
|
||||
if event == "messages" and buf:
|
||||
_collect(by_id, "\n".join(buf))
|
||||
event, buf = None, []
|
||||
elif line.startswith(":"):
|
||||
continue
|
||||
elif line.startswith("event:"):
|
||||
event = line[6:].strip()
|
||||
elif line.startswith("data:"):
|
||||
buf.append(line[5:].strip())
|
||||
return by_id # {message_id: text}
|
||||
|
||||
|
||||
def _collect(by_id: dict, data: str) -> None:
|
||||
try:
|
||||
payload = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
chunk = payload[0] if isinstance(payload, list) and payload else {}
|
||||
if chunk.get("type") in ("ai", "AIMessageChunk"):
|
||||
content = chunk.get("content")
|
||||
if isinstance(content, str):
|
||||
text = content
|
||||
elif isinstance(content, list):
|
||||
text = "".join(b.get("text", "") for b in content if isinstance(b, dict))
|
||||
else:
|
||||
text = ""
|
||||
mid = chunk.get("id") or "_"
|
||||
by_id[mid] = by_id.get(mid, "") + text
|
||||
|
||||
|
||||
def search_threads(s: requests.Session) -> list:
|
||||
r = s.post(f"{BASE}/api/v1/threads/search", json={"limit": 100, "offset": 0})
|
||||
r.raise_for_status()
|
||||
return r.json() # bare array of ThreadResponse
|
||||
|
||||
|
||||
def get_thread_status(s: requests.Session, thread_id: str) -> int:
|
||||
return s.get(f"{BASE}/api/v1/threads/{thread_id}").status_code
|
||||
|
||||
|
||||
def _contains(by_id: dict, n: int) -> bool:
|
||||
return any(str(n) in re.findall(r"\d+", text.replace(",", "")) for text in by_id.values())
|
||||
|
||||
|
||||
def _main_text(by_id: dict) -> str:
|
||||
return (max(by_id.values(), key=len) if by_id else "").strip()
|
||||
|
||||
|
||||
# ── provisioning:人类 owner 建租户 + SA + key(顺序,cookie 侧)──────────
|
||||
def provision_tenant(idx: int) -> dict:
|
||||
email = f"htenant-{RUN_ID}-{idx}@example.com"
|
||||
owner = requests.Session()
|
||||
register(owner, email)
|
||||
me = whoami(owner)
|
||||
sa = create_service_account(owner, name=f"ci-bot-{idx}")
|
||||
key = mint_key(owner, sa["id"], name="prod", scopes=FULL_SCOPES)
|
||||
rec = {
|
||||
"idx": idx,
|
||||
"email": email,
|
||||
"owner": owner,
|
||||
"workspace_id": me.get("default_workspace_id"),
|
||||
"sa_id": sa["id"],
|
||||
"sa_role": sa.get("role"),
|
||||
"key_id": key["id"],
|
||||
"key_prefix": key["key_prefix"],
|
||||
"plaintext": key["plaintext"],
|
||||
"bearer": bearer_session(key["plaintext"]),
|
||||
}
|
||||
log(
|
||||
f"[租户{idx}] 已开通 ws={str(rec['workspace_id'])[:8]} sa={sa['id'][:8]} "
|
||||
f"key_prefix={key['key_prefix']} (sa_role={rec['sa_role']})"
|
||||
)
|
||||
return rec
|
||||
|
||||
|
||||
# ── 一个租户的并发链路(Bearer key,独立线程)──────────────────────────
|
||||
def run_tenant(prov: dict) -> dict:
|
||||
idx = prov["idx"]
|
||||
s = prov["bearer"]
|
||||
a, b = 11 + idx, 13 + idx * 2 # 每租户不同算式
|
||||
d = 2 + idx # 每租户不同步长,坐实无串扰
|
||||
expected = [a * b]
|
||||
for _ in range(1, N_TURNS):
|
||||
expected.append(expected[-1] + d)
|
||||
|
||||
rec = {**prov, "d": d, "expected": expected, "turns": [], "ok": False}
|
||||
t0 = time.time()
|
||||
try:
|
||||
rec["t_start"] = t0
|
||||
tid = create_thread(s)
|
||||
rec["thread_id"] = tid
|
||||
for k in range(N_TURNS):
|
||||
if k == 0:
|
||||
q = f"只回答最终数字:{a} 乘以 {b} 等于多少?"
|
||||
else:
|
||||
q = f"把你上一条回答的那个数字再加 {d},只回答最终数字。"
|
||||
by_id = stream_answer(s, tid, q)
|
||||
hit = _contains(by_id, expected[k])
|
||||
rec["turns"].append({"k": k + 1, "expected": expected[k], "text": _main_text(by_id), "ok": hit})
|
||||
mark = "✓" if hit else "✗"
|
||||
log(f"[租户{idx}] T{k + 1:>2}/{N_TURNS} 期望 {expected[k]:>5} → {mark} {rec['turns'][-1]['text'][:24]!r}")
|
||||
rec["turns_passed"] = sum(t["ok"] for t in rec["turns"])
|
||||
rec["all_turns_ok"] = rec["turns_passed"] == N_TURNS
|
||||
rec["context_ok"] = all(t["ok"] for t in rec["turns"][1:])
|
||||
rec["t_end"] = time.time()
|
||||
rec["ok"] = True
|
||||
log(f"[租户{idx}] ✓ 完成 {rec['turns_passed']}/{N_TURNS} 轮")
|
||||
except Exception as e: # noqa: BLE001
|
||||
rec["error"] = f"{type(e).__name__}: {e}"
|
||||
log(f"[租户{idx}] ✗ 失败:{rec['error']}")
|
||||
return rec
|
||||
|
||||
|
||||
# ── headless 专项:scope 强制 + 撤销(在租户 0 的 owner 上做)──────────
|
||||
def extra_checks(prov0: dict) -> dict:
|
||||
owner = prov0["owner"]
|
||||
sa_id = prov0["sa_id"]
|
||||
out = {"scope_enforced": None, "revocation_401": None}
|
||||
|
||||
# 1) scope 强制:mint 一把只有 threads:read(无 runs:create)的 key → stream 应 403
|
||||
try:
|
||||
limited = mint_key(owner, sa_id, name="readonly", scopes="threads:read")
|
||||
ls = bearer_session(limited["plaintext"])
|
||||
tid = create_thread(ls) # 建线程不需要 scope(仅鉴权),应成功
|
||||
body = {
|
||||
"assistant_id": "lead_agent",
|
||||
"input": {"messages": [{"role": "user", "content": "hi"}]},
|
||||
"stream_mode": ["messages-tuple", "values"],
|
||||
}
|
||||
r = ls.post(f"{BASE}/api/v1/threads/{tid}/runs/stream", json=body, headers={"Accept": "text/event-stream"})
|
||||
out["scope_enforced"] = r.status_code == 403
|
||||
log(f" scope 强制:只读 key 发起 stream → HTTP {r.status_code}(期望 403){'✓' if out['scope_enforced'] else '✗'}")
|
||||
revoke_key(owner, limited["id"])
|
||||
except Exception as e: # noqa: BLE001
|
||||
out["scope_error"] = f"{type(e).__name__}: {e}"
|
||||
log(f" scope 强制:检查异常 {out['scope_error']}")
|
||||
|
||||
# 2) 撤销:mint 一把临时 key,验证可用 → 撤销 → 再用应 401
|
||||
try:
|
||||
tmp = mint_key(owner, sa_id, name="throwaway", scopes=FULL_SCOPES)
|
||||
ts = bearer_session(tmp["plaintext"])
|
||||
before = ts.post(f"{BASE}/api/v1/threads", json={}).status_code # 撤销前可建线程
|
||||
code = revoke_key(owner, tmp["id"])
|
||||
after = ts.post(f"{BASE}/api/v1/threads", json={}).status_code # 撤销后应 401
|
||||
out["revocation_401"] = before in (200, 201) and code == 204 and after == 401
|
||||
log(
|
||||
f" 撤销:撤销前建线程 HTTP {before} → DELETE {code} → 撤销后 HTTP {after}"
|
||||
f"(期望 2xx→204→401){'✓' if out['revocation_401'] else '✗'}"
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
out["revocation_error"] = f"{type(e).__name__}: {e}"
|
||||
log(f" 撤销:检查异常 {out['revocation_error']}")
|
||||
return out
|
||||
|
||||
|
||||
def main() -> None:
|
||||
log(f"=== Headless 多租户测试 BASE={BASE} 租户数={N_TENANTS} 轮数={N_TURNS} 批次={RUN_ID} ===\n")
|
||||
|
||||
# setup-status 只调用一次(60s 限流)
|
||||
try:
|
||||
st = requests.get(f"{BASE}/api/v1/auth/setup-status", timeout=5)
|
||||
if st.status_code == 200:
|
||||
log(f"setup-status: {st.json()}")
|
||||
if st.json().get("needs_setup"):
|
||||
log("⚠ 系统尚未初始化管理员。请先创建管理员(app.py 首启会建),再跑本测试。")
|
||||
return
|
||||
else:
|
||||
log(f"setup-status: HTTP {st.status_code}(限流则忽略,按已初始化处理)")
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f"setup-status 请求失败:{e}")
|
||||
|
||||
# 1) 顺序开通每个租户(人类 owner 建 SA + mint key)
|
||||
log(f"\n── 开通 {N_TENANTS} 个租户(owner cookie → SA → API key)──")
|
||||
provs = []
|
||||
for i in range(N_TENANTS):
|
||||
try:
|
||||
provs.append(provision_tenant(i))
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f"[租户{i}] ✗ 开通失败:{type(e).__name__}: {e}")
|
||||
if not provs:
|
||||
log("没有成功开通的租户,终止。")
|
||||
return
|
||||
|
||||
# 2) 并发跑所有租户(只用 Bearer key)
|
||||
log(f"\n── 并发启动 {len(provs)} 个租户的 Bearer 对话 ──")
|
||||
results = []
|
||||
with ThreadPoolExecutor(max_workers=len(provs)) as ex:
|
||||
futs = [ex.submit(run_tenant, p) for p in provs]
|
||||
for f in as_completed(futs):
|
||||
results.append(f.result())
|
||||
results.sort(key=lambda r: r["idx"])
|
||||
ok = [r for r in results if r.get("ok")]
|
||||
|
||||
# 并发证据:对话时间窗是否重叠
|
||||
log("\n── 并发证据(对话时间窗,相对秒)──")
|
||||
if ok:
|
||||
base_t = min(r["t_start"] for r in ok)
|
||||
for r in ok:
|
||||
s_off, e_off = r["t_start"] - base_t, r["t_end"] - base_t
|
||||
bar = " " * int(s_off * 4) + "█" * max(1, int((e_off - s_off) * 4))
|
||||
log(f" 租户{r['idx']}: [{s_off:5.1f}s → {e_off:5.1f}s] {bar}")
|
||||
spans = [(r["t_start"], r["t_end"]) for r in ok]
|
||||
overlapped = any(a[0] < b[1] and b[0] < a[1] for i, a in enumerate(spans) for b in spans[i + 1 :])
|
||||
log(f" → 存在时间窗重叠(真并发):{overlapped}")
|
||||
|
||||
# 3) 隔离校验(Bearer key 之间)
|
||||
log("\n── 隔离校验(跨租户 Bearer)──")
|
||||
iso_pass = True
|
||||
own_thread = {r["idx"]: r["thread_id"] for r in ok}
|
||||
for r in ok:
|
||||
s = r["bearer"]
|
||||
mine = {t["thread_id"] for t in search_threads(s)}
|
||||
only_own = mine == {r["thread_id"]} if mine else False
|
||||
leaked = {own_thread[j] for j in own_thread if j != r["idx"]} & mine
|
||||
cross_ok = True
|
||||
for j, tid in own_thread.items():
|
||||
if j == r["idx"]:
|
||||
continue
|
||||
code = get_thread_status(s, tid)
|
||||
if code != 404:
|
||||
cross_ok = False
|
||||
log(f" ✗ 租户{r['idx']} 的 key 访问 租户{j} 的线程返回 {code}(期望 404)")
|
||||
if leaked:
|
||||
iso_pass = False
|
||||
log(f" ✗ 租户{r['idx']} 的 search 里出现了别人的线程:{leaked}")
|
||||
if not cross_ok:
|
||||
iso_pass = False
|
||||
if only_own and cross_ok and not leaked:
|
||||
log(f" ✓ 租户{r['idx']}:search 仅见己有线程,跨租户 GET 均 404")
|
||||
|
||||
# 4) headless 专项(scope 强制 + 撤销)
|
||||
extra = {}
|
||||
if EXTRA_CHECKS and ok:
|
||||
log("\n── Headless 专项检查(scope 强制 + 撤销)──")
|
||||
extra = extra_checks(provs[0])
|
||||
|
||||
# 汇总
|
||||
log(f"\n── 汇总(每租户 {N_TURNS} 轮链式对话:T1=a×b,之后每轮 +d)──")
|
||||
log(f"{'租户':<6}{'sa_id':<12}{'key_prefix':<20}{'thread':<14}{'步长d':<8}{'通过轮数':<12}{'逐轮':<14}{'状态'}")
|
||||
for r in results:
|
||||
if r.get("ok"):
|
||||
seq = "".join("✓" if t["ok"] else "✗" for t in r["turns"])
|
||||
passed = f"{r['turns_passed']}/{N_TURNS}"
|
||||
log(f"{r['idx']:<6}{r['sa_id'][:8]:<12}{r['key_prefix']:<20}{r['thread_id'][:10]:<14}{r['d']:<8}{passed:<12}{seq:<14}OK")
|
||||
else:
|
||||
log(f"{r['idx']:<6}{'-':<12}{'-':<20}{'-':<14}{'-':<8}{'-':<12}{'-':<14}FAIL: {r.get('error')}")
|
||||
|
||||
all_ok = len(ok) == len(provs) and len(provs) == N_TENANTS
|
||||
turn1_ok = all(r["turns"][0]["ok"] for r in ok) if ok else False
|
||||
context_ok = all(r.get("context_ok") for r in ok) if ok else False
|
||||
log("\n=== 结果 ===")
|
||||
log(f" 租户全部开通+成功: {all_ok} ({len(ok)}/{N_TENANTS})")
|
||||
log(f" 首轮答复无串扰: {turn1_ok}")
|
||||
log(f" 多轮上下文保持: {context_ok} ← 第 2~{N_TURNS} 轮每轮都依赖上一轮结果")
|
||||
log(f" 租户隔离(Bearer): {iso_pass}")
|
||||
if EXTRA_CHECKS:
|
||||
log(f" scope 强制(403): {extra.get('scope_enforced')} ← 缺 runs:create 的 key 不能 stream")
|
||||
log(f" 撤销即失效(401): {extra.get('revocation_401')}")
|
||||
verdict = all_ok and turn1_ok and context_ok and iso_pass
|
||||
if EXTRA_CHECKS:
|
||||
verdict = verdict and extra.get("scope_enforced") and extra.get("revocation_401")
|
||||
log(f" >>> {'PASS ✅' if verdict else 'FAIL ❌'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+56
-28
@@ -10,9 +10,11 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.gateway.auth_middleware import AuthMiddleware
|
||||
from app.gateway.config import get_gateway_config
|
||||
from app.gateway.csrf_middleware import CSRFMiddleware
|
||||
from app.gateway.deprecation_middleware import ApiDeprecationMiddleware
|
||||
from app.gateway.deps import langgraph_runtime
|
||||
from app.gateway.routers import (
|
||||
agents,
|
||||
api_keys,
|
||||
artifacts,
|
||||
assistants_compat,
|
||||
auth,
|
||||
@@ -22,6 +24,7 @@ from app.gateway.routers import (
|
||||
memory,
|
||||
models,
|
||||
runs,
|
||||
service_accounts,
|
||||
skills,
|
||||
suggestions,
|
||||
thread_runs,
|
||||
@@ -354,6 +357,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# CSRF: Double Submit Cookie pattern for state-changing requests
|
||||
app.add_middleware(CSRFMiddleware)
|
||||
|
||||
# Deprecation: stamp X-API-Deprecated on unversioned /api/* responses
|
||||
app.add_middleware(ApiDeprecationMiddleware)
|
||||
|
||||
# CORS: when GATEWAY_CORS_ORIGINS is set (dev without nginx), add CORS middleware.
|
||||
# In production, nginx handles CORS and no middleware is needed.
|
||||
cors_origins_env = os.environ.get("GATEWAY_CORS_ORIGINS", "")
|
||||
@@ -375,50 +381,72 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
)
|
||||
|
||||
# Include routers
|
||||
# Models API is mounted at /api/models
|
||||
app.include_router(models.router)
|
||||
# Legacy routers are dual-mounted on /api (backward compat) and /api/v1 (versioned).
|
||||
# The deprecation middleware (Task 5.1) stamps X-API-Deprecated on /api responses.
|
||||
|
||||
# MCP API is mounted at /api/mcp
|
||||
app.include_router(mcp.router)
|
||||
# Models API — /api/models and /api/v1/models
|
||||
app.include_router(models.router, prefix="/api")
|
||||
app.include_router(models.router, prefix="/api/v1")
|
||||
|
||||
# Memory API is mounted at /api/memory
|
||||
app.include_router(memory.router)
|
||||
# MCP API — /api/mcp and /api/v1/mcp
|
||||
app.include_router(mcp.router, prefix="/api")
|
||||
app.include_router(mcp.router, prefix="/api/v1")
|
||||
|
||||
# Skills API is mounted at /api/skills
|
||||
app.include_router(skills.router)
|
||||
# Memory API — /api/memory and /api/v1/memory
|
||||
app.include_router(memory.router, prefix="/api")
|
||||
app.include_router(memory.router, prefix="/api/v1")
|
||||
|
||||
# Artifacts API is mounted at /api/threads/{thread_id}/artifacts
|
||||
app.include_router(artifacts.router)
|
||||
# Skills API — /api/skills and /api/v1/skills
|
||||
app.include_router(skills.router, prefix="/api")
|
||||
app.include_router(skills.router, prefix="/api/v1")
|
||||
|
||||
# Uploads API is mounted at /api/threads/{thread_id}/uploads
|
||||
app.include_router(uploads.router)
|
||||
# Artifacts API — /api/threads/{thread_id}/artifacts and /api/v1/threads/{thread_id}/artifacts
|
||||
app.include_router(artifacts.router, prefix="/api")
|
||||
app.include_router(artifacts.router, prefix="/api/v1")
|
||||
|
||||
# Thread cleanup API is mounted at /api/threads/{thread_id}
|
||||
app.include_router(threads.router)
|
||||
# Uploads API — /api/threads/{thread_id}/uploads and /api/v1/threads/{thread_id}/uploads
|
||||
app.include_router(uploads.router, prefix="/api")
|
||||
app.include_router(uploads.router, prefix="/api/v1")
|
||||
|
||||
# Agents API is mounted at /api/agents
|
||||
app.include_router(agents.router)
|
||||
# Threads API — /api/threads/{thread_id} and /api/v1/threads/{thread_id}
|
||||
app.include_router(threads.router, prefix="/api")
|
||||
app.include_router(threads.router, prefix="/api/v1")
|
||||
|
||||
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
|
||||
app.include_router(suggestions.router)
|
||||
# Agents API — /api/agents and /api/v1/agents
|
||||
app.include_router(agents.router, prefix="/api")
|
||||
app.include_router(agents.router, prefix="/api/v1")
|
||||
|
||||
# Channels API is mounted at /api/channels
|
||||
app.include_router(channels.router)
|
||||
# Suggestions API — /api/threads/{thread_id}/suggestions and /api/v1/threads/{thread_id}/suggestions
|
||||
app.include_router(suggestions.router, prefix="/api")
|
||||
app.include_router(suggestions.router, prefix="/api/v1")
|
||||
|
||||
# Assistants compatibility API (LangGraph Platform stub)
|
||||
# Channels API — /api/channels and /api/v1/channels
|
||||
app.include_router(channels.router, prefix="/api")
|
||||
app.include_router(channels.router, prefix="/api/v1")
|
||||
|
||||
# Assistants compatibility API (LangGraph Platform stub) — intentionally NOT dual-mounted
|
||||
app.include_router(assistants_compat.router)
|
||||
|
||||
# Auth API is mounted at /api/v1/auth
|
||||
# Auth API — /api/v1/auth only (already versioned; must NOT get an /api/auth twin)
|
||||
app.include_router(auth.router)
|
||||
|
||||
# Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback
|
||||
app.include_router(feedback.router)
|
||||
# Service Accounts API — /api/v1/service-accounts only (already versioned)
|
||||
app.include_router(service_accounts.router)
|
||||
|
||||
# Thread Runs API (LangGraph Platform-compatible runs lifecycle)
|
||||
app.include_router(thread_runs.router)
|
||||
# API Keys API — /api/v1/api-keys only (already versioned)
|
||||
app.include_router(api_keys.router)
|
||||
|
||||
# Stateless Runs API (stream/wait without a pre-existing thread)
|
||||
app.include_router(runs.router)
|
||||
# Feedback API — /api/threads/{thread_id}/runs/{run_id}/feedback and /api/v1/... twin
|
||||
app.include_router(feedback.router, prefix="/api")
|
||||
app.include_router(feedback.router, prefix="/api/v1")
|
||||
|
||||
# Thread Runs API — /api/threads/{thread_id}/runs and /api/v1/... twin
|
||||
app.include_router(thread_runs.router, prefix="/api")
|
||||
app.include_router(thread_runs.router, prefix="/api/v1")
|
||||
|
||||
# Stateless Runs API — /api/runs and /api/v1/runs
|
||||
app.include_router(runs.router, prefix="/api")
|
||||
app.include_router(runs.router, prefix="/api/v1")
|
||||
|
||||
@app.get("/health", tags=["health"])
|
||||
async def health_check() -> dict:
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""API key authentication backend (Stage 1 PR2).
|
||||
|
||||
Resolves an ``Authorization: Bearer dfk_...`` token into a
|
||||
``ServicePrincipal`` + workspace + scopes, so ``AuthMiddleware`` can
|
||||
stamp the same contextvars a cookie-authenticated human would set
|
||||
(spec D1: user_id = SA.id).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from deerflow.auth.tokens import hash_api_key, split_prefix
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServicePrincipal:
|
||||
"""Non-human principal backing an API key. Satisfies the
|
||||
``deerflow.runtime.user_context.CurrentUser`` protocol."""
|
||||
|
||||
id: str
|
||||
is_service_account: bool = True
|
||||
|
||||
|
||||
def parse_scopes(scopes: str) -> list[str]:
|
||||
"""Parse a comma-separated scope string into a permission list.
|
||||
|
||||
``"threads:read, threads:write"`` -> ``["threads:read", "threads:write"]``.
|
||||
Empty / whitespace-only segments are dropped.
|
||||
"""
|
||||
return [s.strip() for s in scopes.split(",") if s.strip()]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApiKeyAuthResult:
|
||||
"""Everything ``AuthMiddleware`` needs to stamp request state +
|
||||
contextvars from a verified API key."""
|
||||
|
||||
principal: ServicePrincipal
|
||||
workspace_id: str
|
||||
role: str
|
||||
permissions: list[str]
|
||||
|
||||
|
||||
class APIKeyAuthBackend:
|
||||
def __init__(self, *, api_key_repo, service_account_repo, workspace_repo) -> None:
|
||||
self._api_key_repo = api_key_repo
|
||||
self._service_account_repo = service_account_repo
|
||||
self._workspace_repo = workspace_repo
|
||||
|
||||
async def authenticate(self, token: str) -> ApiKeyAuthResult | None:
|
||||
"""Resolve a plaintext token to an auth result, or None (→ 401)."""
|
||||
# Look up by the indexed public prefix; the repo constant-time
|
||||
# verifies the full hash.
|
||||
key = await self._api_key_repo.get_active_by_hash(hash_api_key(token), key_prefix=split_prefix(token))
|
||||
if key is None:
|
||||
return None
|
||||
|
||||
sa = await self._service_account_repo.get_active(key["service_account_id"])
|
||||
if sa is None:
|
||||
return None
|
||||
|
||||
# SA is not a workspace *member* — bypass the membership filter
|
||||
# with the documented user_id=None admin/migration path.
|
||||
workspace = await self._workspace_repo.get(sa["workspace_id"], user_id=None)
|
||||
if workspace is None or workspace["status"] != "active":
|
||||
return None
|
||||
|
||||
# Best-effort: never block the request if the timestamp write fails.
|
||||
try:
|
||||
await self._api_key_repo.touch_last_used(key["id"])
|
||||
except Exception: # noqa: BLE001 — best-effort, log and continue
|
||||
logger.warning("touch_last_used failed for api_key %s", key["id"], exc_info=True)
|
||||
|
||||
return ApiKeyAuthResult(
|
||||
principal=ServicePrincipal(id=sa["id"]),
|
||||
workspace_id=sa["workspace_id"],
|
||||
role=sa["role"],
|
||||
permissions=parse_scopes(key["scopes"]),
|
||||
)
|
||||
|
||||
|
||||
def build_api_key_backend() -> APIKeyAuthBackend | None:
|
||||
"""Construct a backend from the global session factory, or None when
|
||||
persistence is the in-memory backend (no DB → no API keys)."""
|
||||
from deerflow.persistence.api_key import ApiKeyRepository
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
from deerflow.persistence.service_account import ServiceAccountRepository
|
||||
from deerflow.persistence.workspace import WorkspaceRepository
|
||||
|
||||
sf = get_session_factory()
|
||||
if sf is None:
|
||||
return None
|
||||
return APIKeyAuthBackend(api_key_repo=ApiKeyRepository(sf), service_account_repo=ServiceAccountRepository(sf), workspace_repo=WorkspaceRepository(sf))
|
||||
@@ -31,6 +31,11 @@ class User(BaseModel):
|
||||
needs_setup: bool = Field(default=False, description="True for auto-created admin until setup completes")
|
||||
token_version: int = Field(default=0, description="Incremented on password change to invalidate old JWTs")
|
||||
|
||||
# Headless API discriminator (Stage 1 PR2). Always False for human
|
||||
# users; ServicePrincipal sets it True. Lets downstream code branch
|
||||
# on principal kind without isinstance gymnastics.
|
||||
is_service_account: bool = Field(default=False, description="True only for API-key service accounts, never for human users")
|
||||
|
||||
# Workspace linkage (Stage 0 PR4)
|
||||
default_workspace_id: str | None = Field(
|
||||
default=None,
|
||||
|
||||
@@ -9,6 +9,7 @@ owner filtering works automatically via the sentinel pattern.
|
||||
Fine-grained permission checks remain in authz.py decorators.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi import HTTPException, Request, Response
|
||||
@@ -16,6 +17,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from app.gateway.auth.api_key_backend import build_api_key_backend
|
||||
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
|
||||
from app.gateway.auth.models import ActiveWorkspace
|
||||
from app.gateway.authz import _ALL_PERMISSIONS, AuthContext
|
||||
@@ -23,6 +25,8 @@ from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_us
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
from deerflow.runtime.workspace_context import reset_current_workspace, set_current_workspace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Paths that never require authentication.
|
||||
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
|
||||
"/health",
|
||||
@@ -78,6 +82,34 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
if _is_public(request.url.path):
|
||||
return await call_next(request)
|
||||
|
||||
# API key path: "Authorization: Bearer dfk_..." authenticates a
|
||||
# service account. Resolved principal is mapped to the same
|
||||
# (user_id, workspace_id) contextvars a human would set (spec D1),
|
||||
# so all downstream isolation works unchanged.
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.startswith("Bearer dfk_"):
|
||||
token = auth_header[len("Bearer ") :]
|
||||
backend = build_api_key_backend()
|
||||
try:
|
||||
result = await backend.authenticate(token) if backend is not None else None
|
||||
except Exception:
|
||||
logger.exception("API key authentication failed unexpectedly")
|
||||
return JSONResponse(status_code=503, content={"detail": "Authentication service unavailable"})
|
||||
if result is None:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": AuthErrorResponse(code=AuthErrorCode.TOKEN_INVALID, message="Invalid API key").model_dump()},
|
||||
)
|
||||
request.state.user = result.principal
|
||||
request.state.auth = AuthContext(user=result.principal, permissions=result.permissions)
|
||||
user_token = set_current_user(result.principal)
|
||||
ws_token = set_current_workspace(ActiveWorkspace(id=result.workspace_id, role=result.role))
|
||||
try:
|
||||
return await call_next(request)
|
||||
finally:
|
||||
reset_current_workspace(ws_token)
|
||||
reset_current_user(user_token)
|
||||
|
||||
internal_user = None
|
||||
if is_valid_internal_auth_token(request.headers.get(INTERNAL_AUTH_HEADER_NAME)):
|
||||
internal_user = get_internal_user()
|
||||
|
||||
@@ -38,6 +38,7 @@ from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.gateway.auth.api_key_backend import ServicePrincipal
|
||||
from app.gateway.auth.models import User
|
||||
|
||||
P = ParamSpec("P")
|
||||
@@ -65,13 +66,14 @@ class AuthContext:
|
||||
Stored in request.state.auth after require_auth decoration.
|
||||
|
||||
Attributes:
|
||||
user: The authenticated user, or None if anonymous
|
||||
user: The authenticated principal (human ``User`` or
|
||||
``ServicePrincipal`` for API-key requests), or None if anonymous
|
||||
permissions: List of permission strings (e.g., "threads:read")
|
||||
"""
|
||||
|
||||
__slots__ = ("user", "permissions")
|
||||
|
||||
def __init__(self, user: User | None = None, permissions: list[str] | None = None):
|
||||
def __init__(self, user: User | ServicePrincipal | None = None, permissions: list[str] | None = None):
|
||||
self.user = user
|
||||
self.permissions = permissions or []
|
||||
|
||||
@@ -93,8 +95,11 @@ class AuthContext:
|
||||
permission = f"{resource}:{action}"
|
||||
return permission in self.permissions
|
||||
|
||||
def require_user(self) -> User:
|
||||
"""Get user or raise 401.
|
||||
def require_user(self) -> User | ServicePrincipal:
|
||||
"""Get the authenticated principal or raise 401.
|
||||
|
||||
Returns the human ``User`` or the ``ServicePrincipal`` backing an
|
||||
API key, depending on how the request authenticated.
|
||||
|
||||
Raises:
|
||||
HTTPException 401 if not authenticated
|
||||
@@ -302,3 +307,18 @@ def require_permission(
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def require_workspace_admin() -> None:
|
||||
"""FastAPI dependency: require the caller's workspace role to be
|
||||
owner or admin. Reads the role from the workspace contextvar that
|
||||
AuthMiddleware stamps per request.
|
||||
|
||||
Raises HTTPException 403 if no workspace is in context or the role is
|
||||
below admin. Use on management endpoints (service accounts, API keys).
|
||||
"""
|
||||
from deerflow.runtime.workspace_context import get_current_workspace
|
||||
|
||||
workspace = get_current_workspace()
|
||||
if workspace is None or getattr(workspace, "role", None) not in ("owner", "admin"):
|
||||
raise HTTPException(status_code=403, detail="workspace owner/admin role required")
|
||||
|
||||
@@ -29,15 +29,28 @@ def generate_csrf_token() -> str:
|
||||
return secrets.token_urlsafe(CSRF_TOKEN_LENGTH)
|
||||
|
||||
|
||||
def has_bearer_header(request: Request) -> bool:
|
||||
"""True if the request carries an ``Authorization: Bearer ...`` header.
|
||||
|
||||
Bearer requests authenticate via header, not cookie, so they are not
|
||||
vulnerable to CSRF (the browser never auto-attaches a bearer header).
|
||||
"""
|
||||
return request.headers.get("authorization", "").startswith("Bearer ")
|
||||
|
||||
|
||||
def should_check_csrf(request: Request) -> bool:
|
||||
"""Determine if a request needs CSRF validation.
|
||||
|
||||
CSRF is checked for state-changing methods (POST, PUT, DELETE, PATCH).
|
||||
GET, HEAD, OPTIONS, and TRACE are exempt per RFC 7231.
|
||||
GET, HEAD, OPTIONS, and TRACE are exempt per RFC 7231. Bearer-header
|
||||
(API key / token) requests are exempt — they don't ride on cookies.
|
||||
"""
|
||||
if request.method not in ("POST", "PUT", "DELETE", "PATCH"):
|
||||
return False
|
||||
|
||||
if has_bearer_header(request):
|
||||
return False
|
||||
|
||||
path = request.url.path.rstrip("/")
|
||||
# Exempt /api/v1/auth/me endpoint
|
||||
if path == "/api/v1/auth/me":
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Marks responses to legacy unversioned /api/* paths as deprecated.
|
||||
|
||||
Stamps ``X-API-Deprecated: <sunset-date>`` on any /api/* response that is
|
||||
neither versioned (/api/v1/*) nor the LangGraph SDK surface
|
||||
(/api/langgraph/*). Sunset date is the track-2 contract (2027-01-01).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
API_SUNSET_DATE = "2027-01-01"
|
||||
|
||||
|
||||
def _is_deprecated_path(path: str) -> bool:
|
||||
return path.startswith("/api/") and not path.startswith("/api/v1/") and not path.startswith("/api/langgraph/")
|
||||
|
||||
|
||||
class ApiDeprecationMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
super().__init__(app)
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
response = await call_next(request)
|
||||
if _is_deprecated_path(request.url.path):
|
||||
response.headers["X-API-Deprecated"] = API_SUNSET_DATE
|
||||
return response
|
||||
@@ -14,7 +14,7 @@ from deerflow.config.paths import get_paths
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api", tags=["agents"])
|
||||
router = APIRouter(tags=["agents"])
|
||||
|
||||
AGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$")
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""API key management endpoints (Stage 1 PR4).
|
||||
|
||||
Owner/admin mint / list / revoke API keys for a service account in the
|
||||
caller's workspace. The plaintext token is returned exactly once, at
|
||||
create time; list responses never include plaintext or the hash. The
|
||||
target service account must belong to the caller's workspace, else 404.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.authz import require_workspace_admin
|
||||
from deerflow.auth.tokens import generate_api_key
|
||||
from deerflow.persistence.api_key import ApiKeyRepository
|
||||
from deerflow.persistence.service_account import ServiceAccountRepository
|
||||
from deerflow.runtime.workspace_context import get_current_workspace
|
||||
|
||||
router = APIRouter(prefix="/api/v1/api-keys", tags=["api-keys"])
|
||||
|
||||
|
||||
class CreateApiKeyRequest(BaseModel):
|
||||
service_account_id: str
|
||||
name: str = Field(..., min_length=1, max_length=64)
|
||||
scopes: str = Field(default="")
|
||||
env: Literal["live", "test"] = "live"
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
def get_api_key_repo() -> ApiKeyRepository:
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
|
||||
sf = get_session_factory()
|
||||
if sf is None:
|
||||
raise HTTPException(status_code=503, detail="persistence backend not available")
|
||||
return ApiKeyRepository(sf)
|
||||
|
||||
|
||||
def get_service_account_repo() -> ServiceAccountRepository:
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
|
||||
sf = get_session_factory()
|
||||
if sf is None:
|
||||
raise HTTPException(status_code=503, detail="persistence backend not available")
|
||||
return ServiceAccountRepository(sf)
|
||||
|
||||
|
||||
def _current_workspace_id() -> str:
|
||||
ws = get_current_workspace()
|
||||
if ws is None:
|
||||
raise HTTPException(status_code=403, detail="no workspace in context")
|
||||
return str(ws.id)
|
||||
|
||||
|
||||
async def _require_sa_in_workspace(sa_id: str, sa_repo: ServiceAccountRepository) -> dict:
|
||||
sa = await sa_repo.get(sa_id)
|
||||
if sa is None or sa["workspace_id"] != _current_workspace_id():
|
||||
raise HTTPException(status_code=404, detail="service account not found")
|
||||
return sa
|
||||
|
||||
|
||||
@router.post("", status_code=201, dependencies=[Depends(require_workspace_admin)])
|
||||
async def create_api_key(
|
||||
body: CreateApiKeyRequest,
|
||||
key_repo: ApiKeyRepository = Depends(get_api_key_repo),
|
||||
sa_repo: ServiceAccountRepository = Depends(get_service_account_repo),
|
||||
):
|
||||
sa = await _require_sa_in_workspace(body.service_account_id, sa_repo)
|
||||
if sa["status"] != "active":
|
||||
raise HTTPException(status_code=409, detail="service account is not active")
|
||||
gen = generate_api_key(body.env)
|
||||
created = await key_repo.create(
|
||||
service_account_id=body.service_account_id,
|
||||
key_prefix=gen.prefix,
|
||||
key_hash=gen.key_hash,
|
||||
name=body.name,
|
||||
scopes=body.scopes,
|
||||
expires_at=body.expires_at,
|
||||
)
|
||||
# plaintext returned exactly once; never persisted, never re-served.
|
||||
return {**created, "plaintext": gen.plaintext}
|
||||
|
||||
|
||||
@router.get("", dependencies=[Depends(require_workspace_admin)])
|
||||
async def list_api_keys(
|
||||
service_account_id: str,
|
||||
key_repo: ApiKeyRepository = Depends(get_api_key_repo),
|
||||
sa_repo: ServiceAccountRepository = Depends(get_service_account_repo),
|
||||
):
|
||||
await _require_sa_in_workspace(service_account_id, sa_repo)
|
||||
return await key_repo.list_by_service_account(service_account_id)
|
||||
|
||||
|
||||
@router.delete("/{key_id}", status_code=204, dependencies=[Depends(require_workspace_admin)])
|
||||
async def revoke_api_key(
|
||||
key_id: str,
|
||||
key_repo: ApiKeyRepository = Depends(get_api_key_repo),
|
||||
sa_repo: ServiceAccountRepository = Depends(get_service_account_repo),
|
||||
):
|
||||
key = await key_repo.get(key_id)
|
||||
if key is None:
|
||||
raise HTTPException(status_code=404, detail="api key not found")
|
||||
sa = await sa_repo.get(key["service_account_id"])
|
||||
if sa is None or sa["workspace_id"] != _current_workspace_id():
|
||||
raise HTTPException(status_code=404, detail="api key not found")
|
||||
await key_repo.revoke(key_id)
|
||||
return Response(status_code=204)
|
||||
@@ -12,7 +12,7 @@ from app.gateway.path_utils import resolve_thread_virtual_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["artifacts"])
|
||||
router = APIRouter(tags=["artifacts"])
|
||||
|
||||
ACTIVE_CONTENT_MIME_TYPES = {
|
||||
"text/html",
|
||||
|
||||
@@ -9,7 +9,7 @@ from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/channels", tags=["channels"])
|
||||
router = APIRouter(prefix="/channels", tags=["channels"])
|
||||
|
||||
|
||||
class ChannelStatusResponse(BaseModel):
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.gateway.authz import require_permission
|
||||
from app.gateway.deps import get_current_user, get_feedback_repo, get_run_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/threads", tags=["feedback"])
|
||||
router = APIRouter(prefix="/threads", tags=["feedback"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -9,7 +9,7 @@ from pydantic import BaseModel, Field
|
||||
from deerflow.config.extensions_config import ExtensionsConfig, get_extensions_config, reload_extensions_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api", tags=["mcp"])
|
||||
router = APIRouter(tags=["mcp"])
|
||||
|
||||
|
||||
class McpOAuthConfigResponse(BaseModel):
|
||||
|
||||
@@ -15,7 +15,7 @@ from deerflow.agents.memory.updater import (
|
||||
from deerflow.config.memory_config import get_memory_config
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["memory"])
|
||||
router = APIRouter(tags=["memory"])
|
||||
|
||||
|
||||
class ContextSection(BaseModel):
|
||||
|
||||
@@ -4,7 +4,7 @@ from pydantic import BaseModel, Field
|
||||
from app.gateway.deps import get_config
|
||||
from deerflow.config.app_config import AppConfig
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["models"])
|
||||
router = APIRouter(tags=["models"])
|
||||
|
||||
|
||||
class ModelResponse(BaseModel):
|
||||
|
||||
@@ -21,7 +21,7 @@ from app.gateway.services import sse_consumer, start_run
|
||||
from deerflow.runtime import serialize_channel_values
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/runs", tags=["runs"])
|
||||
router = APIRouter(prefix="/runs", tags=["runs"])
|
||||
|
||||
|
||||
def _resolve_thread_id(body: RunCreateRequest) -> str:
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Service account management endpoints (Stage 1 PR4).
|
||||
|
||||
Owner/admin self-service: create / list / suspend service accounts in
|
||||
the caller's current workspace. All operations are workspace-scoped;
|
||||
cross-workspace targets return 404 (existence hidden).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.authz import require_workspace_admin
|
||||
from deerflow.persistence.service_account import ServiceAccountRepository
|
||||
from deerflow.runtime.workspace_context import get_current_workspace
|
||||
|
||||
router = APIRouter(prefix="/api/v1/service-accounts", tags=["service-accounts"])
|
||||
|
||||
|
||||
class CreateServiceAccountRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=64)
|
||||
# Constrained to Stage-1-supported values; widen these Literals in
|
||||
# future stages alongside the behavior (Stage 2 RBAC opens role
|
||||
# admin/viewer; the passthrough PR opens identity_mode).
|
||||
role: Literal["member"] = "member"
|
||||
identity_mode: Literal["collapsed"] = "collapsed"
|
||||
|
||||
|
||||
class UpdateServiceAccountRequest(BaseModel):
|
||||
status: str = Field(..., pattern="^(active|suspended|deleted)$")
|
||||
|
||||
|
||||
def get_service_account_repo() -> ServiceAccountRepository:
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
|
||||
sf = get_session_factory()
|
||||
if sf is None:
|
||||
raise HTTPException(status_code=503, detail="persistence backend not available")
|
||||
return ServiceAccountRepository(sf)
|
||||
|
||||
|
||||
def _current_workspace_id() -> str:
|
||||
ws = get_current_workspace()
|
||||
if ws is None:
|
||||
raise HTTPException(status_code=403, detail="no workspace in context")
|
||||
return str(ws.id)
|
||||
|
||||
|
||||
@router.post("", status_code=201, dependencies=[Depends(require_workspace_admin)])
|
||||
async def create_service_account(
|
||||
body: CreateServiceAccountRequest,
|
||||
request: Request,
|
||||
repo: ServiceAccountRepository = Depends(get_service_account_repo),
|
||||
):
|
||||
return await repo.create(
|
||||
workspace_id=_current_workspace_id(),
|
||||
name=body.name,
|
||||
created_by=str(request.state.user.id),
|
||||
role=body.role,
|
||||
identity_mode=body.identity_mode,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", dependencies=[Depends(require_workspace_admin)])
|
||||
async def list_service_accounts(repo: ServiceAccountRepository = Depends(get_service_account_repo)):
|
||||
return await repo.list_by_workspace(_current_workspace_id())
|
||||
|
||||
|
||||
@router.patch("/{sa_id}", dependencies=[Depends(require_workspace_admin)])
|
||||
async def update_service_account(
|
||||
sa_id: str,
|
||||
body: UpdateServiceAccountRequest,
|
||||
repo: ServiceAccountRepository = Depends(get_service_account_repo),
|
||||
):
|
||||
sa = await repo.get(sa_id)
|
||||
if sa is None or sa["workspace_id"] != _current_workspace_id():
|
||||
raise HTTPException(status_code=404, detail="service account not found")
|
||||
await repo.update_status(sa_id, body.status)
|
||||
return await repo.get(sa_id)
|
||||
@@ -18,7 +18,7 @@ from deerflow.skills.types import SKILL_MD_FILE, SkillCategory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["skills"])
|
||||
router = APIRouter(tags=["skills"])
|
||||
|
||||
|
||||
class SkillResponse(BaseModel):
|
||||
|
||||
@@ -12,7 +12,7 @@ from deerflow.models import create_chat_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["suggestions"])
|
||||
router = APIRouter(tags=["suggestions"])
|
||||
|
||||
|
||||
class SuggestionMessage(BaseModel):
|
||||
|
||||
@@ -25,7 +25,7 @@ from app.gateway.services import sse_consumer, start_run
|
||||
from deerflow.runtime import RunRecord, serialize_channel_values
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/threads", tags=["runs"])
|
||||
router = APIRouter(prefix="/threads", tags=["runs"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -29,7 +29,7 @@ from deerflow.runtime.user_context import get_effective_user_id
|
||||
from deerflow.utils.time import coerce_iso, now_iso
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/threads", tags=["threads"])
|
||||
router = APIRouter(prefix="/threads", tags=["threads"])
|
||||
|
||||
|
||||
# Metadata keys that the server controls; clients are not allowed to set
|
||||
|
||||
@@ -30,7 +30,7 @@ from deerflow.utils.file_conversion import CONVERTIBLE_EXTENSIONS, convert_file_
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/threads/{thread_id}/uploads", tags=["uploads"])
|
||||
router = APIRouter(prefix="/threads/{thread_id}/uploads", tags=["uploads"])
|
||||
|
||||
UPLOAD_CHUNK_SIZE = 8192
|
||||
DEFAULT_MAX_FILES = 10
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Auth primitives shared by the headless API (Stage 1).
|
||||
|
||||
Lives in the ``deerflow`` (harness) layer because both the persistence
|
||||
hot path (``ApiKeyRepository.get_active_by_hash``) and the app-layer
|
||||
mint endpoint need token generation/hashing, and the harness boundary
|
||||
forbids ``deerflow`` importing ``app``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.auth.tokens import GeneratedKey, generate_api_key, hash_api_key, split_prefix
|
||||
|
||||
__all__ = ["GeneratedKey", "generate_api_key", "hash_api_key", "split_prefix"]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""API key generation, hashing, and prefix extraction (Stage 1 PR1).
|
||||
|
||||
Format is irreversible once business systems integrate (spec D5):
|
||||
``dfk_live_<24>`` / ``dfk_test_<24>``. The public ``key_prefix`` is the
|
||||
leading slice of the plaintext (``dfk_live_`` plus a few random chars,
|
||||
length ``_PREFIX_LEN``) and is stored UNIQUE for audit logging; the DB
|
||||
only ever stores ``sha256(plaintext)`` hex, never the plaintext.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
_PREFIX_LEN = 16
|
||||
# token_urlsafe(18) yields ceil(18 * 4 / 3) = 24 url-safe chars.
|
||||
_RANDOM_BYTES = 18
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GeneratedKey:
|
||||
"""A freshly minted key. ``plaintext`` is returned to the caller
|
||||
exactly once; only ``prefix`` + ``key_hash`` are persisted."""
|
||||
|
||||
plaintext: str
|
||||
prefix: str
|
||||
key_hash: str
|
||||
|
||||
|
||||
def hash_api_key(plaintext: str) -> str:
|
||||
"""Return the sha-256 hex digest of a plaintext token."""
|
||||
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def split_prefix(plaintext: str) -> str:
|
||||
"""Return the public, loggable prefix (first 16 chars) of a token."""
|
||||
return plaintext[:_PREFIX_LEN]
|
||||
|
||||
|
||||
def generate_api_key(env: Literal["live", "test"]) -> GeneratedKey:
|
||||
"""Generate a new API key for the given environment.
|
||||
|
||||
Raises ``ValueError`` for any env other than ``"live"`` / ``"test"``.
|
||||
"""
|
||||
if env not in ("live", "test"):
|
||||
raise ValueError(f"env must be 'live' or 'test', got {env!r}")
|
||||
random_part = secrets.token_urlsafe(_RANDOM_BYTES)
|
||||
plaintext = f"dfk_{env}_{random_part}"
|
||||
return GeneratedKey(plaintext=plaintext, prefix=split_prefix(plaintext), key_hash=hash_api_key(plaintext))
|
||||
@@ -14,5 +14,6 @@ middleware live in Stage 1 alongside the headless API surface.
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.api_key.model import ApiKeyRow
|
||||
from deerflow.persistence.api_key.sql import ApiKeyRepository
|
||||
|
||||
__all__ = ["ApiKeyRow"]
|
||||
__all__ = ["ApiKeyRepository", "ApiKeyRow"]
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""SQLAlchemy-backed API key repository (Stage 1 PR1).
|
||||
|
||||
``get_active_by_hash`` is the auth hot path: it looks the key up by its
|
||||
public ``key_prefix`` — UNIQUE and covered by the partial index
|
||||
``idx_api_keys_active`` (WHERE revoked_at IS NULL) — then verifies the
|
||||
full ``key_hash`` with a constant-time compare. Expiry is filtered in
|
||||
Python so the behaviour is identical across sqlite/postgres drivers.
|
||||
|
||||
``_row_to_dict`` deliberately omits ``key_hash`` — no dict this
|
||||
repository returns ever carries the secret material.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.api_key.model import ApiKeyRow
|
||||
|
||||
|
||||
class ApiKeyRepository:
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row: ApiKeyRow) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"service_account_id": row.service_account_id,
|
||||
"key_prefix": row.key_prefix,
|
||||
"name": row.name,
|
||||
"scopes": row.scopes,
|
||||
"rate_limit_rpm": row.rate_limit_rpm,
|
||||
"expires_at": row.expires_at.isoformat() if row.expires_at else None,
|
||||
"last_used_at": row.last_used_at.isoformat() if row.last_used_at else None,
|
||||
"revoked_at": row.revoked_at.isoformat() if row.revoked_at else None,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
}
|
||||
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
service_account_id: str,
|
||||
key_prefix: str,
|
||||
key_hash: str,
|
||||
name: str,
|
||||
scopes: str,
|
||||
expires_at: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
row = ApiKeyRow(
|
||||
id=str(uuid.uuid4()),
|
||||
service_account_id=service_account_id,
|
||||
key_prefix=key_prefix,
|
||||
key_hash=key_hash,
|
||||
name=name,
|
||||
scopes=scopes,
|
||||
expires_at=expires_at,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._row_to_dict(row)
|
||||
|
||||
async def get(self, key_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ApiKeyRow, key_id)
|
||||
return self._row_to_dict(row) if row else None
|
||||
|
||||
async def get_active_by_hash(self, key_hash: str, *, key_prefix: str) -> dict[str, Any] | None:
|
||||
"""Auth hot path: resolve an active, unexpired key.
|
||||
|
||||
Looks the key up by its public ``key_prefix`` — UNIQUE and covered
|
||||
by the partial index ``idx_api_keys_active`` (WHERE revoked_at IS
|
||||
NULL) — then verifies the full ``key_hash`` with a constant-time
|
||||
compare. Returns None on miss / hash mismatch / revoked / expired.
|
||||
Expiry is filtered in Python so behaviour is driver-agnostic
|
||||
(sqlite returns naive datetimes; postgres returns aware).
|
||||
"""
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(select(ApiKeyRow).where(ApiKeyRow.key_prefix == key_prefix, ApiKeyRow.revoked_at.is_(None)))
|
||||
row = result.scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
if not secrets.compare_digest(row.key_hash, key_hash):
|
||||
return None
|
||||
expires_at = row.expires_at
|
||||
if expires_at is not None:
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
if expires_at <= datetime.now(UTC):
|
||||
return None
|
||||
return self._row_to_dict(row)
|
||||
|
||||
async def list_by_service_account(self, service_account_id: str) -> list[dict[str, Any]]:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(select(ApiKeyRow).where(ApiKeyRow.service_account_id == service_account_id).order_by(ApiKeyRow.created_at.desc()))
|
||||
return [self._row_to_dict(r) for r in result.scalars()]
|
||||
|
||||
async def revoke(self, key_id: str) -> None:
|
||||
"""Soft-revoke: set ``revoked_at`` (row is kept for audit)."""
|
||||
async with self._sf() as session:
|
||||
await session.execute(update(ApiKeyRow).where(ApiKeyRow.id == key_id, ApiKeyRow.revoked_at.is_(None)).values(revoked_at=datetime.now(UTC)))
|
||||
await session.commit()
|
||||
|
||||
async def touch_last_used(self, key_id: str) -> None:
|
||||
"""Best-effort: stamp ``last_used_at`` after a successful auth."""
|
||||
async with self._sf() as session:
|
||||
await session.execute(update(ApiKeyRow).where(ApiKeyRow.id == key_id).values(last_used_at=datetime.now(UTC)))
|
||||
await session.commit()
|
||||
@@ -12,5 +12,6 @@ header parsing, and quota attribution all live in Stage 1.
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.external_user.model import ExternalUserRow
|
||||
from deerflow.persistence.external_user.sql import ExternalUserRepository
|
||||
|
||||
__all__ = ["ExternalUserRow"]
|
||||
__all__ = ["ExternalUserRepository", "ExternalUserRow"]
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""SQLAlchemy-backed external user repository (Stage 1 PR1).
|
||||
|
||||
Built but NOT yet wired to any auth path — the X-External-User-Id
|
||||
passthrough that calls ``upsert`` lands in a later track-2 PR. ``upsert``
|
||||
is idempotent on (service_account_id, external_id).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.external_user.model import ExternalUserRow
|
||||
|
||||
|
||||
class ExternalUserRepository:
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row: ExternalUserRow) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"workspace_id": row.workspace_id,
|
||||
"service_account_id": row.service_account_id,
|
||||
"external_id": row.external_id,
|
||||
"display_name": row.display_name,
|
||||
"metadata": dict(row.metadata_json or {}),
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"last_seen_at": row.last_seen_at.isoformat() if row.last_seen_at else None,
|
||||
}
|
||||
|
||||
async def get(self, external_user_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ExternalUserRow, external_user_id)
|
||||
return self._row_to_dict(row) if row else None
|
||||
|
||||
async def get_by_external_id(self, *, service_account_id: str, external_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
select(ExternalUserRow).where(
|
||||
ExternalUserRow.service_account_id == service_account_id,
|
||||
ExternalUserRow.external_id == external_id,
|
||||
)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
return self._row_to_dict(row) if row else None
|
||||
|
||||
async def list_by_workspace(self, workspace_id: str) -> list[dict[str, Any]]:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(select(ExternalUserRow).where(ExternalUserRow.workspace_id == workspace_id).order_by(ExternalUserRow.created_at.desc()))
|
||||
return [self._row_to_dict(r) for r in result.scalars()]
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
service_account_id: str,
|
||||
external_id: str,
|
||||
display_name: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Insert a new external user or refresh ``last_seen_at`` on an
|
||||
existing (service_account_id, external_id) row.
|
||||
|
||||
``display_name`` and ``metadata`` are only written when explicitly
|
||||
passed (non-None); ``None`` means "leave unchanged" — you cannot
|
||||
clear ``display_name`` back to None via this method. ``workspace_id``
|
||||
is only used on insert; it is ignored on update."""
|
||||
now = datetime.now(UTC)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
select(ExternalUserRow).where(
|
||||
ExternalUserRow.service_account_id == service_account_id,
|
||||
ExternalUserRow.external_id == external_id,
|
||||
)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if row is None:
|
||||
row = ExternalUserRow(
|
||||
id=str(uuid.uuid4()),
|
||||
workspace_id=workspace_id,
|
||||
service_account_id=service_account_id,
|
||||
external_id=external_id,
|
||||
display_name=display_name,
|
||||
metadata_json=metadata or {},
|
||||
created_at=now,
|
||||
last_seen_at=now,
|
||||
)
|
||||
# NOTE: concurrent inserts of the same pair will raise IntegrityError
|
||||
# from uq_external_users_sa_external — the future auth caller should
|
||||
# catch it and re-read rather than treat it as fatal.
|
||||
session.add(row)
|
||||
else:
|
||||
row.last_seen_at = now
|
||||
if display_name is not None:
|
||||
row.display_name = display_name
|
||||
if metadata is not None:
|
||||
row.metadata_json = metadata
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._row_to_dict(row)
|
||||
@@ -13,5 +13,6 @@ upgrade live in Stage 1 alongside the headless API surface.
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.service_account.model import ServiceAccountRow
|
||||
from deerflow.persistence.service_account.sql import ServiceAccountRepository, ServiceAccountValidationError
|
||||
|
||||
__all__ = ["ServiceAccountRow"]
|
||||
__all__ = ["ServiceAccountRepository", "ServiceAccountRow", "ServiceAccountValidationError"]
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""SQLAlchemy-backed service account repository (Stage 1 PR1).
|
||||
|
||||
Mirrors :class:`WorkspaceRepository`: fresh session per method,
|
||||
``_row_to_dict`` static helper. Workspace scoping is enforced by the
|
||||
caller (route layer reads the workspace contextvar); the repository
|
||||
takes ``workspace_id`` explicitly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.service_account.model import ServiceAccountRow
|
||||
|
||||
_VALID_STATUSES = frozenset({"active", "suspended", "deleted"})
|
||||
|
||||
|
||||
class ServiceAccountValidationError(ValueError):
|
||||
"""Raised when service account input fails application-layer validation."""
|
||||
|
||||
|
||||
class ServiceAccountRepository:
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row: ServiceAccountRow) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"workspace_id": row.workspace_id,
|
||||
"name": row.name,
|
||||
"role": row.role,
|
||||
"identity_mode": row.identity_mode,
|
||||
"status": row.status,
|
||||
"created_by": row.created_by,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
name: str,
|
||||
created_by: str,
|
||||
role: str = "member",
|
||||
identity_mode: str = "collapsed",
|
||||
status: str = "active",
|
||||
) -> dict[str, Any]:
|
||||
if status not in _VALID_STATUSES:
|
||||
raise ServiceAccountValidationError(f"status {status!r} not in {_VALID_STATUSES!r}")
|
||||
now = datetime.now(UTC)
|
||||
row = ServiceAccountRow(
|
||||
id=str(uuid.uuid4()),
|
||||
workspace_id=workspace_id,
|
||||
name=name,
|
||||
role=role,
|
||||
identity_mode=identity_mode,
|
||||
status=status,
|
||||
created_by=created_by,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._row_to_dict(row)
|
||||
|
||||
async def get(self, sa_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ServiceAccountRow, sa_id)
|
||||
return self._row_to_dict(row) if row else None
|
||||
|
||||
async def get_active(self, sa_id: str) -> dict[str, Any] | None:
|
||||
"""Return the row only when ``status == 'active'`` (auth hot path)."""
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ServiceAccountRow, sa_id)
|
||||
if row is None or row.status != "active":
|
||||
return None
|
||||
return self._row_to_dict(row)
|
||||
|
||||
async def list_by_workspace(self, workspace_id: str) -> list[dict[str, Any]]:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(select(ServiceAccountRow).where(ServiceAccountRow.workspace_id == workspace_id).order_by(ServiceAccountRow.created_at.desc()))
|
||||
return [self._row_to_dict(r) for r in result.scalars()]
|
||||
|
||||
async def update_status(self, sa_id: str, status: str) -> None:
|
||||
if status not in _VALID_STATUSES:
|
||||
raise ServiceAccountValidationError(f"status {status!r} not in {_VALID_STATUSES!r}")
|
||||
async with self._sf() as session:
|
||||
await session.execute(update(ServiceAccountRow).where(ServiceAccountRow.id == sa_id).values(status=status, updated_at=datetime.now(UTC)))
|
||||
await session.commit()
|
||||
@@ -42,8 +42,15 @@ from typing import Final, Protocol, runtime_checkable
|
||||
class CurrentUser(Protocol):
|
||||
"""Structural type for the current authenticated user.
|
||||
|
||||
Any object with an ``.id: str`` attribute satisfies this protocol.
|
||||
Concrete implementations live in ``app.gateway.auth.models.User``.
|
||||
Requires only ``.id: str`` — the persistence layer reads nothing else,
|
||||
and keeping the contract minimal lets any ``.id``-bearing object (incl.
|
||||
test fixtures) satisfy it. A principal MAY additionally carry
|
||||
``.is_service_account: bool`` to distinguish a headless service account
|
||||
(API key) from a human; concrete carriers are
|
||||
``app.gateway.auth.models.User`` (False) and
|
||||
``app.gateway.auth.api_key_backend.ServicePrincipal`` (True). Since that
|
||||
attribute is NOT part of this structural contract, app-layer readers
|
||||
must access it defensively: ``getattr(user, "is_service_account", False)``.
|
||||
"""
|
||||
|
||||
id: str
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Deprecation header tests (Stage 1 PR5)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _make_app():
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.gateway.deprecation_middleware import ApiDeprecationMiddleware
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(ApiDeprecationMiddleware)
|
||||
|
||||
@app.get("/api/threads")
|
||||
async def legacy():
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/v1/threads")
|
||||
async def versioned():
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/langgraph/info")
|
||||
async def lg():
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/assistants/info")
|
||||
async def assistants():
|
||||
return {"ok": True}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def test_legacy_path_gets_deprecation_header():
|
||||
client = TestClient(_make_app())
|
||||
r = client.get("/api/threads")
|
||||
assert r.headers.get("X-API-Deprecated") == "2027-01-01"
|
||||
|
||||
|
||||
def test_versioned_path_no_header():
|
||||
client = TestClient(_make_app())
|
||||
r = client.get("/api/v1/threads")
|
||||
assert "X-API-Deprecated" not in r.headers
|
||||
|
||||
|
||||
def test_langgraph_path_no_header():
|
||||
client = TestClient(_make_app())
|
||||
r = client.get("/api/langgraph/info")
|
||||
assert "X-API-Deprecated" not in r.headers
|
||||
|
||||
|
||||
def test_assistants_compat_path_gets_deprecation_header():
|
||||
# assistants_compat is an un-versioned LangGraph-platform stub; it
|
||||
# intentionally carries the deprecation header (it is /api/, not
|
||||
# /api/v1 or /api/langgraph). Documented here to prevent confusion.
|
||||
client = TestClient(_make_app())
|
||||
r = client.get("/api/assistants/info")
|
||||
assert r.headers.get("X-API-Deprecated") == "2027-01-01"
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Tests for the API key auth backend (Stage 1 PR2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from app.gateway.auth.api_key_backend import ServicePrincipal, parse_scopes
|
||||
from deerflow.auth.tokens import generate_api_key
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
def test_parse_scopes_splits_and_strips():
|
||||
assert parse_scopes("threads:read, threads:write") == ["threads:read", "threads:write"]
|
||||
|
||||
|
||||
def test_parse_scopes_empty_string_is_empty_list():
|
||||
assert parse_scopes("") == []
|
||||
assert parse_scopes(" ") == []
|
||||
|
||||
|
||||
def test_parse_scopes_drops_empty_segments():
|
||||
assert parse_scopes("threads:read,,runs:create,") == ["threads:read", "runs:create"]
|
||||
|
||||
|
||||
def test_service_principal_is_service_account_true_by_default():
|
||||
p = ServicePrincipal(id="sa-1")
|
||||
assert p.id == "sa-1"
|
||||
assert p.is_service_account is True
|
||||
|
||||
|
||||
def test_service_principal_is_frozen():
|
||||
p = ServicePrincipal(id="sa-1")
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
p.id = "other" # type: ignore[misc]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
async def _setup_backend(tmp_path, *, sa_status="active", ws_status="active", scopes="threads:read", expires_at=None, revoke=False):
|
||||
from app.gateway.auth.api_key_backend import APIKeyAuthBackend
|
||||
from deerflow.persistence.api_key import ApiKeyRepository
|
||||
from deerflow.persistence.engine import get_session_factory, init_engine
|
||||
from deerflow.persistence.service_account import ServiceAccountRepository
|
||||
from deerflow.persistence.service_account.model import ServiceAccountRow
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
from deerflow.persistence.workspace import WorkspaceRepository
|
||||
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||
|
||||
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
|
||||
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
|
||||
sf = get_session_factory()
|
||||
async with sf() as session:
|
||||
session.add(UserRow(id="u-alice", email="alice@example.com"))
|
||||
await session.commit()
|
||||
async with sf() as session:
|
||||
session.add(WorkspaceRow(id="w-1", name="WS", slug="ws", owner_id="u-alice", status=ws_status))
|
||||
await session.commit()
|
||||
async with sf() as session:
|
||||
session.add(ServiceAccountRow(id="sa-1", workspace_id="w-1", name="bot", role="member", identity_mode="collapsed", status=sa_status, created_by="u-alice"))
|
||||
await session.commit()
|
||||
|
||||
api_key_repo = ApiKeyRepository(sf)
|
||||
gen = generate_api_key("live")
|
||||
created = await api_key_repo.create(service_account_id="sa-1", key_prefix=gen.prefix, key_hash=gen.key_hash, name="k", scopes=scopes, expires_at=expires_at)
|
||||
if revoke:
|
||||
await api_key_repo.revoke(created["id"])
|
||||
|
||||
backend = APIKeyAuthBackend(api_key_repo=api_key_repo, service_account_repo=ServiceAccountRepository(sf), workspace_repo=WorkspaceRepository(sf))
|
||||
return backend, gen
|
||||
|
||||
|
||||
async def _cleanup():
|
||||
from deerflow.persistence.engine import close_engine
|
||||
|
||||
await close_engine()
|
||||
|
||||
|
||||
async def test_authenticate_valid_key(tmp_path):
|
||||
backend, gen = await _setup_backend(tmp_path, scopes="threads:read,threads:write")
|
||||
try:
|
||||
result = await backend.authenticate(gen.plaintext)
|
||||
assert result is not None
|
||||
assert result.principal.id == "sa-1"
|
||||
assert result.principal.is_service_account is True
|
||||
assert result.workspace_id == "w-1"
|
||||
assert result.role == "member"
|
||||
assert result.permissions == ["threads:read", "threads:write"]
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_authenticate_unknown_token_returns_none(tmp_path):
|
||||
backend, _ = await _setup_backend(tmp_path)
|
||||
try:
|
||||
assert await backend.authenticate("dfk_live_doesnotexist000000000000") is None
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_authenticate_revoked_key_returns_none(tmp_path):
|
||||
backend, gen = await _setup_backend(tmp_path, revoke=True)
|
||||
try:
|
||||
assert await backend.authenticate(gen.plaintext) is None
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_authenticate_suspended_sa_returns_none(tmp_path):
|
||||
backend, gen = await _setup_backend(tmp_path, sa_status="suspended")
|
||||
try:
|
||||
assert await backend.authenticate(gen.plaintext) is None
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_authenticate_suspended_workspace_returns_none(tmp_path):
|
||||
backend, gen = await _setup_backend(tmp_path, ws_status="suspended")
|
||||
try:
|
||||
assert await backend.authenticate(gen.plaintext) is None
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_authenticate_expired_key_returns_none(tmp_path):
|
||||
from datetime import UTC, datetime
|
||||
|
||||
backend, gen = await _setup_backend(tmp_path, expires_at=datetime(2000, 1, 1, tzinfo=UTC))
|
||||
try:
|
||||
assert await backend.authenticate(gen.plaintext) is None
|
||||
finally:
|
||||
await _cleanup()
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Tests for ApiKeyRepository (Stage 1 PR1).
|
||||
|
||||
get_active_by_hash is the auth hot path: must return None for revoked
|
||||
and expired keys. Expiry is filtered in Python (driver-agnostic) while
|
||||
revoked_at IS NULL rides the partial index idx_api_keys_active.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.auth.tokens import generate_api_key
|
||||
from deerflow.persistence.api_key import ApiKeyRepository
|
||||
from deerflow.persistence.service_account.model import ServiceAccountRow
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
async def _make_repo(tmp_path):
|
||||
from deerflow.persistence.engine import get_session_factory, init_engine
|
||||
|
||||
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
|
||||
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
|
||||
return ApiKeyRepository(get_session_factory())
|
||||
|
||||
|
||||
async def _cleanup():
|
||||
from deerflow.persistence.engine import close_engine
|
||||
|
||||
await close_engine()
|
||||
|
||||
|
||||
async def _seed_sa(repo, *, sa_id="sa-1") -> None:
|
||||
async with repo._sf() as session:
|
||||
session.add(UserRow(id="u-alice", email="alice@example.com"))
|
||||
await session.commit()
|
||||
async with repo._sf() as session:
|
||||
session.add(WorkspaceRow(id="w-1", name="WS", slug="ws", owner_id="u-alice"))
|
||||
await session.commit()
|
||||
async with repo._sf() as session:
|
||||
session.add(ServiceAccountRow(id=sa_id, workspace_id="w-1", name="bot", role="member", identity_mode="collapsed", status="active", created_by="u-alice"))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _mint(repo, *, expires_at=None, scopes="threads:read"):
|
||||
gen = generate_api_key("live")
|
||||
created = await repo.create(service_account_id="sa-1", key_prefix=gen.prefix, key_hash=gen.key_hash, name="k", scopes=scopes, expires_at=expires_at)
|
||||
return gen, created
|
||||
|
||||
|
||||
async def test_create_then_get_active_by_hash(tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
try:
|
||||
await _seed_sa(repo)
|
||||
gen, created = await _mint(repo)
|
||||
assert created["key_prefix"] == gen.prefix
|
||||
assert "key_hash" not in created # never expose the hash in dicts
|
||||
found = await repo.get_active_by_hash(gen.key_hash, key_prefix=gen.prefix)
|
||||
assert found is not None
|
||||
assert found["id"] == created["id"]
|
||||
assert found["scopes"] == "threads:read"
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_get_active_by_hash_miss_returns_none(tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
try:
|
||||
await _seed_sa(repo)
|
||||
assert await repo.get_active_by_hash("deadbeef", key_prefix="dfk_live_nomatch0") is None
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_wrong_hash_for_valid_prefix_returns_none(tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
try:
|
||||
await _seed_sa(repo)
|
||||
gen, _ = await _mint(repo)
|
||||
assert await repo.get_active_by_hash("0" * 64, key_prefix=gen.prefix) is None
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_revoked_key_not_active(tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
try:
|
||||
await _seed_sa(repo)
|
||||
gen, created = await _mint(repo)
|
||||
await repo.revoke(created["id"])
|
||||
assert await repo.get_active_by_hash(gen.key_hash, key_prefix=gen.prefix) is None
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_expired_key_not_active(tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
try:
|
||||
await _seed_sa(repo)
|
||||
past = datetime.now(UTC) - timedelta(hours=1)
|
||||
gen, _ = await _mint(repo, expires_at=past)
|
||||
assert await repo.get_active_by_hash(gen.key_hash, key_prefix=gen.prefix) is None
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_future_expiry_still_active(tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
try:
|
||||
await _seed_sa(repo)
|
||||
future = datetime.now(UTC) + timedelta(hours=1)
|
||||
gen, _ = await _mint(repo, expires_at=future)
|
||||
assert await repo.get_active_by_hash(gen.key_hash, key_prefix=gen.prefix) is not None
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_touch_last_used_sets_timestamp(tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
try:
|
||||
await _seed_sa(repo)
|
||||
gen, created = await _mint(repo)
|
||||
assert created["last_used_at"] is None
|
||||
await repo.touch_last_used(created["id"])
|
||||
refetched = await repo.get(created["id"])
|
||||
assert refetched["last_used_at"] is not None
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_list_by_service_account(tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
try:
|
||||
await _seed_sa(repo)
|
||||
await _mint(repo)
|
||||
await _mint(repo)
|
||||
rows = await repo.list_by_service_account("sa-1")
|
||||
assert len(rows) == 2
|
||||
assert all("key_hash" not in r for r in rows)
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_list_by_service_account_excludes_other_sa(tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
try:
|
||||
await _seed_sa(repo)
|
||||
async with repo._sf() as session:
|
||||
from deerflow.persistence.service_account.model import ServiceAccountRow
|
||||
|
||||
session.add(ServiceAccountRow(id="sa-2", workspace_id="w-1", name="bot2", role="member", identity_mode="collapsed", status="active", created_by="u-alice"))
|
||||
await session.commit()
|
||||
await _mint(repo) # belongs to sa-1
|
||||
g2 = generate_api_key("live")
|
||||
await repo.create(service_account_id="sa-2", key_prefix=g2.prefix, key_hash=g2.key_hash, name="k2", scopes="")
|
||||
rows = await repo.list_by_service_account("sa-1")
|
||||
assert len(rows) == 1
|
||||
finally:
|
||||
await _cleanup()
|
||||
@@ -0,0 +1,175 @@
|
||||
"""api-keys router tests (Stage 1 PR4).
|
||||
|
||||
plaintext is returned exactly once at create time; never on list.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
async def _init_db_with_sa(tmp_path, *, sa_id="sa-1", workspace_id="w-1"):
|
||||
from deerflow.persistence.engine import get_session_factory, init_engine
|
||||
from deerflow.persistence.service_account.model import ServiceAccountRow
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||
|
||||
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
|
||||
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
|
||||
sf = get_session_factory()
|
||||
async with sf() as session:
|
||||
session.add(UserRow(id="u-alice", email="alice@example.com"))
|
||||
await session.commit()
|
||||
async with sf() as session:
|
||||
session.add(WorkspaceRow(id=workspace_id, name="WS", slug=f"ws-{workspace_id}", owner_id="u-alice"))
|
||||
await session.commit()
|
||||
async with sf() as session:
|
||||
session.add(ServiceAccountRow(id=sa_id, workspace_id=workspace_id, name="bot", role="member", identity_mode="collapsed", status="active", created_by="u-alice"))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _cleanup():
|
||||
from deerflow.persistence.engine import close_engine
|
||||
|
||||
await close_engine()
|
||||
|
||||
|
||||
def _make_app(*, role="owner", workspace_id="w-1"):
|
||||
from fastapi import FastAPI, Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.gateway.authz import _ALL_PERMISSIONS, AuthContext
|
||||
from app.gateway.routers import api_keys
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
from deerflow.runtime.workspace_context import reset_current_workspace, set_current_workspace
|
||||
|
||||
class _Stamp(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
user = type("U", (), {"id": "u-alice", "is_service_account": False})()
|
||||
ws = type("W", (), {"id": workspace_id, "role": role})()
|
||||
request.state.user = user
|
||||
request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS)
|
||||
ut = set_current_user(user)
|
||||
wt = set_current_workspace(ws)
|
||||
try:
|
||||
return await call_next(request)
|
||||
finally:
|
||||
reset_current_workspace(wt)
|
||||
reset_current_user(ut)
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(_Stamp)
|
||||
app.include_router(api_keys.router)
|
||||
return app
|
||||
|
||||
|
||||
async def test_create_returns_plaintext_once(tmp_path):
|
||||
await _init_db_with_sa(tmp_path)
|
||||
try:
|
||||
client = TestClient(_make_app())
|
||||
r = client.post("/api/v1/api-keys", json={"service_account_id": "sa-1", "name": "ci", "scopes": "threads:read"})
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["plaintext"].startswith("dfk_live_")
|
||||
assert body["key_prefix"] == body["plaintext"][:16]
|
||||
|
||||
lst = client.get("/api/v1/api-keys", params={"service_account_id": "sa-1"})
|
||||
assert lst.status_code == 200
|
||||
rows = lst.json()
|
||||
assert len(rows) == 1
|
||||
assert "plaintext" not in rows[0]
|
||||
assert "key_hash" not in rows[0]
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_create_for_other_workspace_sa_404(tmp_path):
|
||||
await _init_db_with_sa(tmp_path, sa_id="sa-1", workspace_id="w-1")
|
||||
try:
|
||||
# Caller is in w-2 but targets sa-1 which lives in w-1 → 404.
|
||||
client = TestClient(_make_app(workspace_id="w-2"))
|
||||
r = client.post("/api/v1/api-keys", json={"service_account_id": "sa-1", "name": "x", "scopes": ""})
|
||||
assert r.status_code == 404
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_revoke_key(tmp_path):
|
||||
await _init_db_with_sa(tmp_path)
|
||||
try:
|
||||
client = TestClient(_make_app())
|
||||
created = client.post("/api/v1/api-keys", json={"service_account_id": "sa-1", "name": "k", "scopes": ""}).json()
|
||||
r = client.delete(f"/api/v1/api-keys/{created['id']}")
|
||||
assert r.status_code == 204
|
||||
rows = client.get("/api/v1/api-keys", params={"service_account_id": "sa-1"}).json()
|
||||
assert rows[0]["revoked_at"] is not None
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_member_cannot_create_key(tmp_path):
|
||||
await _init_db_with_sa(tmp_path)
|
||||
try:
|
||||
client = TestClient(_make_app(role="member"))
|
||||
r = client.post("/api/v1/api-keys", json={"service_account_id": "sa-1", "name": "x", "scopes": ""})
|
||||
assert r.status_code == 403
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_revoke_other_workspace_key_404(tmp_path):
|
||||
await _init_db_with_sa(tmp_path, sa_id="sa-1", workspace_id="w-1")
|
||||
try:
|
||||
client_a = TestClient(_make_app(workspace_id="w-1"))
|
||||
created = client_a.post("/api/v1/api-keys", json={"service_account_id": "sa-1", "name": "k", "scopes": ""}).json()
|
||||
client_b = TestClient(_make_app(workspace_id="w-2"))
|
||||
assert client_b.delete(f"/api/v1/api-keys/{created['id']}").status_code == 404
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_list_other_workspace_sa_404(tmp_path):
|
||||
await _init_db_with_sa(tmp_path, sa_id="sa-1", workspace_id="w-1")
|
||||
try:
|
||||
client_b = TestClient(_make_app(workspace_id="w-2"))
|
||||
assert client_b.get("/api/v1/api-keys", params={"service_account_id": "sa-1"}).status_code == 404
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_create_for_suspended_sa_409(tmp_path):
|
||||
await _init_db_with_sa(tmp_path)
|
||||
try:
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
from deerflow.persistence.service_account import ServiceAccountRepository
|
||||
|
||||
await ServiceAccountRepository(get_session_factory()).update_status("sa-1", "suspended")
|
||||
client = TestClient(_make_app())
|
||||
r = client.post("/api/v1/api-keys", json={"service_account_id": "sa-1", "name": "x", "scopes": ""})
|
||||
assert r.status_code == 409
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_revoke_404_bodies_are_indistinguishable(tmp_path):
|
||||
await _init_db_with_sa(tmp_path, sa_id="sa-1", workspace_id="w-1")
|
||||
try:
|
||||
client_a = TestClient(_make_app(workspace_id="w-1"))
|
||||
created = client_a.post("/api/v1/api-keys", json={"service_account_id": "sa-1", "name": "k", "scopes": ""}).json()
|
||||
client_b = TestClient(_make_app(workspace_id="w-2"))
|
||||
# cross-workspace existing key, and a non-existent key, must return identical 404 bodies
|
||||
cross = client_b.delete(f"/api/v1/api-keys/{created['id']}")
|
||||
missing = client_b.delete("/api/v1/api-keys/does-not-exist")
|
||||
assert cross.status_code == 404
|
||||
assert missing.status_code == 404
|
||||
assert cross.json() == missing.json()
|
||||
finally:
|
||||
await _cleanup()
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Dual-mount /api + /api/v1 tests (Stage 1 PR5).
|
||||
|
||||
Every migrated legacy router must answer on BOTH /api/<x> and /api/v1/<x>.
|
||||
Asserts route presence on the OpenAPI schema (independent of per-route auth).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.gateway.app import create_app
|
||||
|
||||
|
||||
def _paths():
|
||||
return set(create_app().openapi()["paths"].keys())
|
||||
|
||||
|
||||
def test_models_dual_mounted():
|
||||
paths = _paths()
|
||||
assert "/api/models" in paths
|
||||
assert "/api/v1/models" in paths
|
||||
|
||||
|
||||
def test_runs_dual_mounted():
|
||||
paths = _paths()
|
||||
assert "/api/runs/stream" in paths
|
||||
assert "/api/v1/runs/stream" in paths
|
||||
|
||||
|
||||
def test_every_legacy_api_path_has_v1_twin():
|
||||
"""Strong invariant: every unversioned /api/* path (except the
|
||||
intentionally-excluded surfaces) must also exist under /api/v1/*.
|
||||
Catches any single legacy router losing its v1 mount."""
|
||||
paths = _paths()
|
||||
excluded_prefixes = ("/api/v1/", "/api/langgraph/", "/api/assistants")
|
||||
legacy = {p for p in paths if p.startswith("/api/") and not p.startswith(excluded_prefixes)}
|
||||
assert legacy, "expected some unversioned /api/* paths"
|
||||
missing = sorted(p for p in legacy if ("/api/v1/" + p[len("/api/") :]) not in paths)
|
||||
assert missing == [], f"legacy /api paths without an /api/v1 twin: {missing}"
|
||||
|
||||
|
||||
def test_uploads_dual_mounted():
|
||||
paths = _paths()
|
||||
assert any(p.startswith("/api/threads/") and "/uploads" in p for p in paths)
|
||||
assert any(p.startswith("/api/v1/threads/") and "/uploads" in p for p in paths)
|
||||
|
||||
|
||||
def test_auth_only_v1_not_dual():
|
||||
# auth stays v1-only — must NOT acquire an /api/auth twin.
|
||||
paths = _paths()
|
||||
assert "/api/v1/auth/me" in paths
|
||||
assert "/api/auth/me" not in paths
|
||||
|
||||
|
||||
def test_no_v1_langgraph_twins():
|
||||
paths = _paths()
|
||||
assert not any(p.startswith("/api/v1/langgraph") for p in paths)
|
||||
assert not any(p.startswith("/api/v1/assistants") for p in paths)
|
||||
@@ -76,7 +76,7 @@ def test_get_artifact_download_false_does_not_force_attachment(tmp_path, monkeyp
|
||||
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path: artifact_path)
|
||||
|
||||
app = make_authed_test_app()
|
||||
app.include_router(artifacts_router.router)
|
||||
app.include_router(artifacts_router.router, prefix="/api")
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/threads/thread-1/artifacts/mnt/user-data/outputs/note.txt?download=false")
|
||||
@@ -94,7 +94,7 @@ def test_get_artifact_download_true_forces_attachment_for_skill_archive(tmp_path
|
||||
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path: skill_path)
|
||||
|
||||
app = make_authed_test_app()
|
||||
app.include_router(artifacts_router.router)
|
||||
app.include_router(artifacts_router.router, prefix="/api")
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/threads/thread-1/artifacts/mnt/user-data/outputs/sample.skill/notes.txt?download=true")
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""AuthMiddleware bearer-path integration tests (Stage 1 PR2).
|
||||
|
||||
Drives the real middleware via a minimal app with a probe route that
|
||||
echoes the resolved contextvars, proving user_id=SA.id / workspace_id
|
||||
are stamped identically to a human request.
|
||||
|
||||
Note: ``from __future__ import annotations`` is intentionally absent here.
|
||||
The probe route's ``request: Request`` annotation must resolve at class-definition
|
||||
time (inside ``_make_app``) so FastAPI recognises it as the special ASGI
|
||||
injection type, not a query parameter. With the futures import active the
|
||||
annotation becomes the string ``"Request"`` and ``get_type_hints`` cannot
|
||||
resolve it from the module's global namespace (the import lives in a local
|
||||
scope inside ``_make_app``), causing FastAPI to emit a 422.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from deerflow.auth.tokens import generate_api_key
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
async def _seed_key(tmp_path, *, scopes="threads:read", revoke=False):
|
||||
from deerflow.persistence.api_key import ApiKeyRepository
|
||||
from deerflow.persistence.engine import get_session_factory, init_engine
|
||||
from deerflow.persistence.service_account.model import ServiceAccountRow
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||
|
||||
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
|
||||
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
|
||||
sf = get_session_factory()
|
||||
async with sf() as session:
|
||||
session.add(UserRow(id="u-alice", email="alice@example.com"))
|
||||
await session.commit()
|
||||
async with sf() as session:
|
||||
session.add(WorkspaceRow(id="w-1", name="WS", slug="ws", owner_id="u-alice"))
|
||||
await session.commit()
|
||||
async with sf() as session:
|
||||
session.add(ServiceAccountRow(id="sa-1", workspace_id="w-1", name="bot", role="member", identity_mode="collapsed", status="active", created_by="u-alice"))
|
||||
await session.commit()
|
||||
repo = ApiKeyRepository(sf)
|
||||
gen = generate_api_key("live")
|
||||
created = await repo.create(service_account_id="sa-1", key_prefix=gen.prefix, key_hash=gen.key_hash, name="k", scopes=scopes)
|
||||
if revoke:
|
||||
await repo.revoke(created["id"])
|
||||
return gen
|
||||
|
||||
|
||||
async def _cleanup():
|
||||
from deerflow.persistence.engine import close_engine
|
||||
|
||||
await close_engine()
|
||||
|
||||
|
||||
def _make_app():
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from app.gateway.auth_middleware import AuthMiddleware
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
from deerflow.runtime.workspace_context import get_effective_workspace_id
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(AuthMiddleware)
|
||||
|
||||
@app.get("/api/probe")
|
||||
async def probe(request: Request):
|
||||
return {
|
||||
"user_id": get_effective_user_id(),
|
||||
"workspace_id": get_effective_workspace_id(),
|
||||
"is_sa": getattr(request.state.user, "is_service_account", None),
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def test_valid_bearer_sets_sa_contextvars(tmp_path):
|
||||
gen = await _seed_key(tmp_path)
|
||||
try:
|
||||
client = TestClient(_make_app())
|
||||
r = client.get("/api/probe", headers={"Authorization": f"Bearer {gen.plaintext}"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"user_id": "sa-1", "workspace_id": "w-1", "is_sa": True}
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_invalid_bearer_returns_401(tmp_path):
|
||||
await _seed_key(tmp_path)
|
||||
try:
|
||||
client = TestClient(_make_app())
|
||||
r = client.get("/api/probe", headers={"Authorization": "Bearer dfk_live_bogus00000000000000000"})
|
||||
assert r.status_code == 401
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_revoked_bearer_returns_401(tmp_path):
|
||||
gen = await _seed_key(tmp_path, revoke=True)
|
||||
try:
|
||||
client = TestClient(_make_app())
|
||||
r = client.get("/api/probe", headers={"Authorization": f"Bearer {gen.plaintext}"})
|
||||
assert r.status_code == 401
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_non_dfk_bearer_falls_through_to_cookie_path(tmp_path):
|
||||
await _seed_key(tmp_path)
|
||||
try:
|
||||
client = TestClient(_make_app())
|
||||
# A non-dfk bearer is NOT the API-key path; with no cookie the
|
||||
# cookie path 401s (NOT_AUTHENTICATED), proving no mis-route.
|
||||
r = client.get("/api/probe", headers={"Authorization": "Bearer some.jwt.token"})
|
||||
assert r.status_code == 401
|
||||
assert r.json()["detail"]["code"] == "not_authenticated"
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_bare_prefix_bearer_returns_401(tmp_path):
|
||||
await _seed_key(tmp_path)
|
||||
try:
|
||||
client = TestClient(_make_app())
|
||||
r = client.get("/api/probe", headers={"Authorization": "Bearer dfk_"})
|
||||
assert r.status_code == 401
|
||||
finally:
|
||||
await _cleanup()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""CSRF bearer-skip tests (Stage 1 PR3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _make_app():
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.gateway.csrf_middleware import CSRFMiddleware
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(CSRFMiddleware)
|
||||
|
||||
@app.post("/api/echo")
|
||||
async def echo():
|
||||
return {"ok": True}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def test_bearer_post_skips_csrf():
|
||||
client = TestClient(_make_app())
|
||||
# No X-CSRF-Token / csrf cookie, but bearer header present → allowed.
|
||||
r = client.post("/api/echo", headers={"Authorization": "Bearer dfk_live_anything"})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_cookie_post_still_requires_csrf():
|
||||
client = TestClient(_make_app())
|
||||
# No bearer, no CSRF token → 403 (regression: cookie path unchanged).
|
||||
r = client.post("/api/echo")
|
||||
assert r.status_code == 403
|
||||
assert "CSRF token missing" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_has_bearer_header_detection():
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.gateway.csrf_middleware import has_bearer_header
|
||||
|
||||
def _req(headers):
|
||||
scope = {"type": "http", "headers": [(k.lower().encode(), v.encode()) for k, v in headers.items()]}
|
||||
return Request(scope)
|
||||
|
||||
assert has_bearer_header(_req({"authorization": "Bearer x"})) is True
|
||||
assert has_bearer_header(_req({"authorization": "Basic x"})) is False
|
||||
assert has_bearer_header(_req({})) is False
|
||||
@@ -382,7 +382,7 @@ def _make_test_app(tmp_path: Path):
|
||||
from app.gateway.routers.agents import router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.include_router(router, prefix="/api")
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Tests for ExternalUserRepository (Stage 1 PR1).
|
||||
|
||||
Repository is built but not yet wired to any auth path. upsert is
|
||||
idempotent on (service_account_id, external_id) per the table's
|
||||
UniqueConstraint uq_external_users_sa_external.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.persistence.external_user import ExternalUserRepository
|
||||
from deerflow.persistence.service_account.model import ServiceAccountRow
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
async def _make_repo(tmp_path):
|
||||
from deerflow.persistence.engine import get_session_factory, init_engine
|
||||
|
||||
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
|
||||
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
|
||||
return ExternalUserRepository(get_session_factory())
|
||||
|
||||
|
||||
async def _cleanup():
|
||||
from deerflow.persistence.engine import close_engine
|
||||
|
||||
await close_engine()
|
||||
|
||||
|
||||
async def _seed_sa(repo) -> None:
|
||||
async with repo._sf() as session:
|
||||
session.add(UserRow(id="u-alice", email="alice@example.com"))
|
||||
await session.commit()
|
||||
async with repo._sf() as session:
|
||||
session.add(WorkspaceRow(id="w-1", name="WS", slug="ws", owner_id="u-alice"))
|
||||
await session.commit()
|
||||
async with repo._sf() as session:
|
||||
session.add(ServiceAccountRow(id="sa-1", workspace_id="w-1", name="bot", role="member", identity_mode="external_passthrough", status="active", created_by="u-alice"))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_upsert_inserts_then_updates_same_row(tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
try:
|
||||
await _seed_sa(repo)
|
||||
first = await repo.upsert(workspace_id="w-1", service_account_id="sa-1", external_id="ext-42", display_name="alice")
|
||||
assert first["external_id"] == "ext-42"
|
||||
assert first["last_seen_at"] is not None
|
||||
second = await repo.upsert(workspace_id="w-1", service_account_id="sa-1", external_id="ext-42")
|
||||
# same logical row (no duplicate)
|
||||
assert second["id"] == first["id"]
|
||||
rows = await repo.list_by_workspace("w-1")
|
||||
assert len(rows) == 1
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_get_by_id_hit_and_miss(tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
try:
|
||||
await _seed_sa(repo)
|
||||
created = await repo.upsert(workspace_id="w-1", service_account_id="sa-1", external_id="ext-9")
|
||||
fetched = await repo.get(created["id"])
|
||||
assert fetched is not None
|
||||
assert fetched["id"] == created["id"]
|
||||
assert await repo.get("nonexistent") is None
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_get_by_external_id(tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
try:
|
||||
await _seed_sa(repo)
|
||||
await repo.upsert(workspace_id="w-1", service_account_id="sa-1", external_id="ext-7")
|
||||
found = await repo.get_by_external_id(service_account_id="sa-1", external_id="ext-7")
|
||||
assert found is not None
|
||||
assert found["external_id"] == "ext-7"
|
||||
assert await repo.get_by_external_id(service_account_id="sa-1", external_id="nope") is None
|
||||
finally:
|
||||
await _cleanup()
|
||||
@@ -0,0 +1,119 @@
|
||||
"""End-to-end headless API smoke (Stage 1 PR4).
|
||||
|
||||
Mints a real key through the management endpoints, then calls a protected
|
||||
probe route through the real AuthMiddleware using that key. Proves the
|
||||
full chain and the cross-workspace 404 isolation guarantee.
|
||||
|
||||
Note: ``from __future__ import annotations`` is intentionally absent.
|
||||
The probe route's ``request: Request`` annotation must resolve at
|
||||
class-definition time (inside ``_probe_app``) so FastAPI recognises it
|
||||
as the special ASGI injection type, not a query parameter. With the
|
||||
futures import active the annotation becomes the string ``"Request"``
|
||||
and ``get_type_hints`` cannot resolve it from the module's global
|
||||
namespace (the import lives in a local scope inside ``_probe_app``),
|
||||
causing FastAPI to emit a 422.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
async def _init_db(tmp_path):
|
||||
from deerflow.persistence.engine import get_session_factory, init_engine
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||
|
||||
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
|
||||
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
|
||||
sf = get_session_factory()
|
||||
async with sf() as session:
|
||||
session.add(UserRow(id="u-alice", email="alice@example.com"))
|
||||
session.add(UserRow(id="u-bob", email="bob@example.com"))
|
||||
session.add(UserRow(id="u-owner", email="owner@example.com"))
|
||||
await session.commit()
|
||||
async with sf() as session:
|
||||
session.add(WorkspaceRow(id="w-1", name="Alice WS", slug="alice", owner_id="u-alice"))
|
||||
session.add(WorkspaceRow(id="w-2", name="Bob WS", slug="bob", owner_id="u-bob"))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _cleanup():
|
||||
from deerflow.persistence.engine import close_engine
|
||||
|
||||
await close_engine()
|
||||
|
||||
|
||||
def _mgmt_app(*, workspace_id):
|
||||
"""Management app: stamps a fixed owner principal + workspace."""
|
||||
from fastapi import FastAPI, Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.gateway.authz import _ALL_PERMISSIONS, AuthContext
|
||||
from app.gateway.routers import api_keys, service_accounts
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
from deerflow.runtime.workspace_context import reset_current_workspace, set_current_workspace
|
||||
|
||||
class _Stamp(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
user = type("U", (), {"id": "u-owner", "is_service_account": False})()
|
||||
ws = type("W", (), {"id": workspace_id, "role": "owner"})()
|
||||
request.state.user = user
|
||||
request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS)
|
||||
ut = set_current_user(user)
|
||||
wt = set_current_workspace(ws)
|
||||
try:
|
||||
return await call_next(request)
|
||||
finally:
|
||||
reset_current_workspace(wt)
|
||||
reset_current_user(ut)
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(_Stamp)
|
||||
app.include_router(service_accounts.router)
|
||||
app.include_router(api_keys.router)
|
||||
return app
|
||||
|
||||
|
||||
def _probe_app():
|
||||
"""Protected app behind the REAL AuthMiddleware with a probe route."""
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from app.gateway.auth_middleware import AuthMiddleware
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
from deerflow.runtime.workspace_context import get_effective_workspace_id
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(AuthMiddleware)
|
||||
|
||||
@app.get("/api/probe")
|
||||
async def probe(request: Request):
|
||||
return {"user_id": get_effective_user_id(), "workspace_id": get_effective_workspace_id()}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def test_mint_use_and_cross_workspace_isolation(tmp_path):
|
||||
await _init_db(tmp_path)
|
||||
try:
|
||||
mgmt = TestClient(_mgmt_app(workspace_id="w-1"))
|
||||
sa = mgmt.post("/api/v1/service-accounts", json={"name": "ci"}).json()
|
||||
key = mgmt.post("/api/v1/api-keys", json={"service_account_id": sa["id"], "name": "k", "scopes": "threads:read"}).json()
|
||||
plaintext = key["plaintext"]
|
||||
|
||||
probe = TestClient(_probe_app())
|
||||
ok = probe.get("/api/probe", headers={"Authorization": f"Bearer {plaintext}"})
|
||||
assert ok.status_code == 200
|
||||
assert ok.json() == {"user_id": sa["id"], "workspace_id": "w-1"}
|
||||
|
||||
# A bogus / unknown key is rejected.
|
||||
bad = probe.get("/api/probe", headers={"Authorization": "Bearer dfk_live_unknown0000000000000000"})
|
||||
assert bad.status_code == 401
|
||||
finally:
|
||||
await _cleanup()
|
||||
@@ -26,7 +26,7 @@ def _sample_memory(facts: list[dict] | None = None) -> dict:
|
||||
|
||||
def test_export_memory_route_returns_current_memory() -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(memory.router)
|
||||
app.include_router(memory.router, prefix="/api")
|
||||
exported_memory = _sample_memory(
|
||||
facts=[
|
||||
{
|
||||
@@ -50,7 +50,7 @@ def test_export_memory_route_returns_current_memory() -> None:
|
||||
|
||||
def test_import_memory_route_returns_imported_memory() -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(memory.router)
|
||||
app.include_router(memory.router, prefix="/api")
|
||||
imported_memory = _sample_memory(
|
||||
facts=[
|
||||
{
|
||||
@@ -74,7 +74,7 @@ def test_import_memory_route_returns_imported_memory() -> None:
|
||||
|
||||
def test_export_memory_route_preserves_source_error() -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(memory.router)
|
||||
app.include_router(memory.router, prefix="/api")
|
||||
exported_memory = _sample_memory(
|
||||
facts=[
|
||||
{
|
||||
@@ -99,7 +99,7 @@ def test_export_memory_route_preserves_source_error() -> None:
|
||||
|
||||
def test_import_memory_route_preserves_source_error() -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(memory.router)
|
||||
app.include_router(memory.router, prefix="/api")
|
||||
imported_memory = _sample_memory(
|
||||
facts=[
|
||||
{
|
||||
@@ -124,7 +124,7 @@ def test_import_memory_route_preserves_source_error() -> None:
|
||||
|
||||
def test_clear_memory_route_returns_cleared_memory() -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(memory.router)
|
||||
app.include_router(memory.router, prefix="/api")
|
||||
|
||||
with patch("app.gateway.routers.memory.clear_memory_data", return_value=_sample_memory()):
|
||||
with TestClient(app) as client:
|
||||
@@ -136,7 +136,7 @@ def test_clear_memory_route_returns_cleared_memory() -> None:
|
||||
|
||||
def test_create_memory_fact_route_returns_updated_memory() -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(memory.router)
|
||||
app.include_router(memory.router, prefix="/api")
|
||||
updated_memory = _sample_memory(
|
||||
facts=[
|
||||
{
|
||||
@@ -167,7 +167,7 @@ def test_create_memory_fact_route_returns_updated_memory() -> None:
|
||||
|
||||
def test_delete_memory_fact_route_returns_updated_memory() -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(memory.router)
|
||||
app.include_router(memory.router, prefix="/api")
|
||||
updated_memory = _sample_memory(
|
||||
facts=[
|
||||
{
|
||||
@@ -191,7 +191,7 @@ def test_delete_memory_fact_route_returns_updated_memory() -> None:
|
||||
|
||||
def test_delete_memory_fact_route_returns_404_for_missing_fact() -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(memory.router)
|
||||
app.include_router(memory.router, prefix="/api")
|
||||
|
||||
with patch("app.gateway.routers.memory.delete_memory_fact", side_effect=KeyError("fact_missing")):
|
||||
with TestClient(app) as client:
|
||||
@@ -203,7 +203,7 @@ def test_delete_memory_fact_route_returns_404_for_missing_fact() -> None:
|
||||
|
||||
def test_update_memory_fact_route_returns_updated_memory() -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(memory.router)
|
||||
app.include_router(memory.router, prefix="/api")
|
||||
updated_memory = _sample_memory(
|
||||
facts=[
|
||||
{
|
||||
@@ -234,7 +234,7 @@ def test_update_memory_fact_route_returns_updated_memory() -> None:
|
||||
|
||||
def test_update_memory_fact_route_preserves_omitted_fields() -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(memory.router)
|
||||
app.include_router(memory.router, prefix="/api")
|
||||
updated_memory = _sample_memory(
|
||||
facts=[
|
||||
{
|
||||
@@ -270,7 +270,7 @@ def test_update_memory_fact_route_preserves_omitted_fields() -> None:
|
||||
|
||||
def test_update_memory_fact_route_returns_404_for_missing_fact() -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(memory.router)
|
||||
app.include_router(memory.router, prefix="/api")
|
||||
|
||||
with patch("app.gateway.routers.memory.update_memory_fact", side_effect=KeyError("fact_missing")):
|
||||
with TestClient(app) as client:
|
||||
@@ -289,7 +289,7 @@ def test_update_memory_fact_route_returns_404_for_missing_fact() -> None:
|
||||
|
||||
def test_update_memory_fact_route_returns_specific_error_for_invalid_confidence() -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(memory.router)
|
||||
app.include_router(memory.router, prefix="/api")
|
||||
|
||||
with patch("app.gateway.routers.memory.update_memory_fact", side_effect=ValueError("confidence")):
|
||||
with TestClient(app) as client:
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Tests for require_workspace_admin dependency (Stage 1 PR4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.gateway.authz import require_workspace_admin
|
||||
from deerflow.runtime.workspace_context import reset_current_workspace, set_current_workspace
|
||||
|
||||
|
||||
class _WS:
|
||||
def __init__(self, role):
|
||||
self.id = "w-1"
|
||||
self.role = role
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["owner", "admin"])
|
||||
def test_allows_owner_admin(role):
|
||||
token = set_current_workspace(_WS(role))
|
||||
try:
|
||||
require_workspace_admin() # no raise
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
|
||||
|
||||
def test_rejects_member():
|
||||
token = set_current_workspace(_WS("member"))
|
||||
try:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
require_workspace_admin()
|
||||
assert exc.value.status_code == 403
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
|
||||
|
||||
@pytest.mark.no_auto_workspace
|
||||
def test_rejects_no_workspace():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
require_workspace_admin()
|
||||
assert exc.value.status_code == 403
|
||||
@@ -17,7 +17,7 @@ from app.gateway.routers import runs
|
||||
def _make_app(run_store=None, event_store=None, feedback_repo=None):
|
||||
"""Build a test FastAPI app with stub auth and mocked state."""
|
||||
app = make_authed_test_app()
|
||||
app.include_router(runs.router)
|
||||
app.include_router(runs.router, prefix="/api")
|
||||
|
||||
if run_store is not None:
|
||||
app.state.run_store = run_store
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Tests for ServiceAccountRepository (Stage 1 PR1).
|
||||
|
||||
SQLite ephemeral DB per test, mirroring test_workspace_repo / test_api_key_schema.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.persistence.service_account import ServiceAccountRepository
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
async def _make_repo(tmp_path):
|
||||
from deerflow.persistence.engine import get_session_factory, init_engine
|
||||
|
||||
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
|
||||
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
|
||||
return ServiceAccountRepository(get_session_factory())
|
||||
|
||||
|
||||
async def _cleanup():
|
||||
from deerflow.persistence.engine import close_engine
|
||||
|
||||
await close_engine()
|
||||
|
||||
|
||||
async def _seed_parents(repo, *, user_id="u-alice", workspace_id="w-1") -> None:
|
||||
async with repo._sf() as session:
|
||||
session.add(UserRow(id=user_id, email=f"{user_id}@example.com"))
|
||||
await session.commit()
|
||||
async with repo._sf() as session:
|
||||
session.add(WorkspaceRow(id=workspace_id, name="WS", slug=workspace_id, owner_id=user_id))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_create_then_get_roundtrip(tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
try:
|
||||
await _seed_parents(repo)
|
||||
created = await repo.create(workspace_id="w-1", name="ci-bot", created_by="u-alice")
|
||||
assert created["workspace_id"] == "w-1"
|
||||
assert created["name"] == "ci-bot"
|
||||
assert created["role"] == "member"
|
||||
assert created["identity_mode"] == "collapsed"
|
||||
assert created["status"] == "active"
|
||||
assert len(created["id"]) == 36
|
||||
|
||||
fetched = await repo.get(created["id"])
|
||||
assert fetched is not None
|
||||
assert fetched["id"] == created["id"]
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_get_returns_none_when_missing(tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
try:
|
||||
assert await repo.get("nope") is None
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_get_active_excludes_suspended(tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
try:
|
||||
await _seed_parents(repo)
|
||||
sa = await repo.create(workspace_id="w-1", name="bot", created_by="u-alice")
|
||||
assert await repo.get_active(sa["id"]) is not None
|
||||
await repo.update_status(sa["id"], "suspended")
|
||||
assert await repo.get_active(sa["id"]) is None
|
||||
# get() still returns the row regardless of status
|
||||
assert await repo.get(sa["id"]) is not None
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_list_by_workspace(tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
try:
|
||||
await _seed_parents(repo)
|
||||
await _seed_parents(repo, user_id="u-bob", workspace_id="w-2")
|
||||
await repo.create(workspace_id="w-1", name="a", created_by="u-alice")
|
||||
await repo.create(workspace_id="w-1", name="b", created_by="u-alice")
|
||||
await repo.create(workspace_id="w-2", name="c", created_by="u-bob")
|
||||
rows = await repo.list_by_workspace("w-1")
|
||||
assert {r["name"] for r in rows} == {"a", "b"}
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_create_rejects_unknown_status(tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
try:
|
||||
await _seed_parents(repo)
|
||||
with pytest.raises(ValueError):
|
||||
await repo.create(workspace_id="w-1", name="x", created_by="u-alice", status="bogus")
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_update_status_rejects_unknown_value(tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
try:
|
||||
await _seed_parents(repo)
|
||||
sa = await repo.create(workspace_id="w-1", name="x", created_by="u-alice")
|
||||
with pytest.raises(ValueError):
|
||||
await repo.update_status(sa["id"], "not-a-status")
|
||||
finally:
|
||||
await _cleanup()
|
||||
@@ -0,0 +1,146 @@
|
||||
"""service-accounts router tests (Stage 1 PR4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
async def _init_db(tmp_path):
|
||||
from deerflow.persistence.engine import get_session_factory, init_engine
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||
|
||||
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
|
||||
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
|
||||
sf = get_session_factory()
|
||||
async with sf() as session:
|
||||
session.add(UserRow(id="u-alice", email="alice@example.com"))
|
||||
await session.commit()
|
||||
async with sf() as session:
|
||||
session.add(WorkspaceRow(id="w-1", name="WS", slug="ws", owner_id="u-alice"))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _cleanup():
|
||||
from deerflow.persistence.engine import close_engine
|
||||
|
||||
await close_engine()
|
||||
|
||||
|
||||
def _make_app(*, role="owner", user_id="u-alice", workspace_id="w-1"):
|
||||
"""App that stamps a fixed principal + workspace, then mounts the router.
|
||||
|
||||
A tiny inline middleware substitutes for AuthMiddleware so the test
|
||||
controls role/user/workspace directly.
|
||||
"""
|
||||
from fastapi import FastAPI, Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.gateway.authz import _ALL_PERMISSIONS, AuthContext
|
||||
from app.gateway.routers import service_accounts
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
from deerflow.runtime.workspace_context import reset_current_workspace, set_current_workspace
|
||||
|
||||
class _Stamp(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
user = type("U", (), {"id": user_id, "is_service_account": False})()
|
||||
ws = type("W", (), {"id": workspace_id, "role": role})()
|
||||
request.state.user = user
|
||||
request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS)
|
||||
ut = set_current_user(user)
|
||||
wt = set_current_workspace(ws)
|
||||
try:
|
||||
return await call_next(request)
|
||||
finally:
|
||||
reset_current_workspace(wt)
|
||||
reset_current_user(ut)
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(_Stamp)
|
||||
app.include_router(service_accounts.router)
|
||||
return app
|
||||
|
||||
|
||||
async def test_owner_creates_and_lists_sa(tmp_path):
|
||||
await _init_db(tmp_path)
|
||||
try:
|
||||
client = TestClient(_make_app(role="owner"))
|
||||
r = client.post("/api/v1/service-accounts", json={"name": "ci-bot"})
|
||||
assert r.status_code == 201, r.text
|
||||
sa = r.json()
|
||||
assert sa["name"] == "ci-bot"
|
||||
assert sa["workspace_id"] == "w-1"
|
||||
assert sa["created_by"] == "u-alice"
|
||||
|
||||
lst = client.get("/api/v1/service-accounts")
|
||||
assert lst.status_code == 200
|
||||
assert [s["id"] for s in lst.json()] == [sa["id"]]
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_member_cannot_create_sa(tmp_path):
|
||||
await _init_db(tmp_path)
|
||||
try:
|
||||
client = TestClient(_make_app(role="member"))
|
||||
r = client.post("/api/v1/service-accounts", json={"name": "x"})
|
||||
assert r.status_code == 403
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_patch_status_suspend(tmp_path):
|
||||
await _init_db(tmp_path)
|
||||
try:
|
||||
client = TestClient(_make_app(role="admin"))
|
||||
sa = client.post("/api/v1/service-accounts", json={"name": "bot"}).json()
|
||||
r = client.patch(f"/api/v1/service-accounts/{sa['id']}", json={"status": "suspended"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "suspended"
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_patch_other_workspace_sa_404(tmp_path):
|
||||
await _init_db(tmp_path)
|
||||
try:
|
||||
# SA created in w-1
|
||||
owner_client = TestClient(_make_app(role="owner", workspace_id="w-1"))
|
||||
sa = owner_client.post("/api/v1/service-accounts", json={"name": "bot"}).json()
|
||||
# Caller in a different workspace tries to patch it → 404
|
||||
other_client = TestClient(_make_app(role="owner", workspace_id="w-2"))
|
||||
r = other_client.patch(f"/api/v1/service-accounts/{sa['id']}", json={"status": "suspended"})
|
||||
assert r.status_code == 404
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_list_is_scoped_to_current_workspace(tmp_path):
|
||||
await _init_db(tmp_path)
|
||||
try:
|
||||
w1 = TestClient(_make_app(role="owner", workspace_id="w-1"))
|
||||
w1.post("/api/v1/service-accounts", json={"name": "bot-w1"})
|
||||
# A caller in w-2 must NOT see w-1's service accounts.
|
||||
w2 = TestClient(_make_app(role="owner", workspace_id="w-2"))
|
||||
rows = w2.get("/api/v1/service-accounts").json()
|
||||
assert rows == []
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
async def test_create_rejects_unknown_role(tmp_path):
|
||||
await _init_db(tmp_path)
|
||||
try:
|
||||
client = TestClient(_make_app(role="owner"))
|
||||
r = client.post("/api/v1/service-accounts", json={"name": "x", "role": "superadmin"})
|
||||
assert r.status_code == 422
|
||||
finally:
|
||||
await _cleanup()
|
||||
@@ -39,7 +39,7 @@ def _make_skill(name: str, *, enabled: bool) -> Skill:
|
||||
def _make_test_app(config) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.state.config = config
|
||||
app.include_router(skills_router.router)
|
||||
app.include_router(skills_router.router, prefix="/api")
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from app.gateway.routers import thread_runs
|
||||
def _make_app(event_store=None):
|
||||
"""Build a test FastAPI app with stub auth and mocked state."""
|
||||
app = make_authed_test_app()
|
||||
app.include_router(thread_runs.router)
|
||||
app.include_router(thread_runs.router, prefix="/api")
|
||||
|
||||
if event_store is not None:
|
||||
app.state.run_event_store = event_store
|
||||
|
||||
@@ -37,7 +37,7 @@ def _build_app(workspace_id: str):
|
||||
app.state.store = store
|
||||
app.state.checkpointer = checkpointer
|
||||
app.state.thread_store = MemoryThreadMetaStore(store)
|
||||
app.include_router(threads.router)
|
||||
app.include_router(threads.router, prefix="/api")
|
||||
return app, store
|
||||
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ def _build_thread_app() -> tuple[FastAPI, InMemoryStore, InMemorySaver]:
|
||||
app.state.store = store
|
||||
app.state.checkpointer = checkpointer
|
||||
app.state.thread_store = _PermissiveThreadMetaStore(store)
|
||||
app.include_router(threads.router)
|
||||
app.include_router(threads.router, prefix="/api")
|
||||
return app, store, checkpointer
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ def test_delete_thread_route_cleans_thread_directory(tmp_path):
|
||||
(paths.sandbox_work_dir("thread-route", user_id=user_id) / "notes.txt").write_text("hello", encoding="utf-8")
|
||||
|
||||
app = make_authed_test_app()
|
||||
app.include_router(threads.router)
|
||||
app.include_router(threads.router, prefix="/api")
|
||||
|
||||
with patch("app.gateway.routers.threads.get_paths", return_value=paths):
|
||||
with TestClient(app) as client:
|
||||
@@ -128,7 +128,7 @@ def test_delete_thread_route_rejects_invalid_thread_id(tmp_path):
|
||||
paths = Paths(tmp_path)
|
||||
|
||||
app = make_authed_test_app()
|
||||
app.include_router(threads.router)
|
||||
app.include_router(threads.router, prefix="/api")
|
||||
|
||||
with patch("app.gateway.routers.threads.get_paths", return_value=paths):
|
||||
with TestClient(app) as client:
|
||||
@@ -141,7 +141,7 @@ def test_delete_thread_route_returns_422_for_route_safe_invalid_id(tmp_path):
|
||||
paths = Paths(tmp_path)
|
||||
|
||||
app = make_authed_test_app()
|
||||
app.include_router(threads.router)
|
||||
app.include_router(threads.router, prefix="/api")
|
||||
|
||||
with patch("app.gateway.routers.threads.get_paths", return_value=paths):
|
||||
with TestClient(app) as client:
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Tests for deerflow.auth.tokens (Stage 1 PR1).
|
||||
|
||||
API key 格式 / 哈希 / prefix 截取。格式锁定 dfk_{live,test}_<24>,
|
||||
prefix = 前 16 字符(含 dfk_live_),sha256 hex 存储(spec D5)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.auth.tokens import GeneratedKey, generate_api_key, hash_api_key, split_prefix
|
||||
|
||||
|
||||
def test_generate_live_key_shape():
|
||||
key = generate_api_key("live")
|
||||
assert isinstance(key, GeneratedKey)
|
||||
assert key.plaintext.startswith("dfk_live_")
|
||||
# dfk_live_ (9) + token_urlsafe(18) (24) = 33 chars
|
||||
assert len(key.plaintext) == 33
|
||||
assert key.prefix == key.plaintext[:16]
|
||||
assert len(key.prefix) == 16
|
||||
assert key.key_hash == hashlib.sha256(key.plaintext.encode("utf-8")).hexdigest()
|
||||
assert len(key.key_hash) == 64
|
||||
|
||||
|
||||
def test_generate_test_key_prefix_env():
|
||||
key = generate_api_key("test")
|
||||
assert key.plaintext.startswith("dfk_test_")
|
||||
assert len(key.plaintext) == 33
|
||||
assert key.prefix.startswith("dfk_test_")
|
||||
|
||||
|
||||
def test_generate_rejects_bad_env():
|
||||
with pytest.raises(ValueError):
|
||||
generate_api_key("prod") # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_two_keys_are_unique():
|
||||
a = generate_api_key("live")
|
||||
b = generate_api_key("live")
|
||||
assert a.plaintext != b.plaintext
|
||||
assert a.key_hash != b.key_hash
|
||||
|
||||
|
||||
def test_hash_api_key_is_sha256_and_deterministic():
|
||||
plaintext = "dfk_live_abcdefghijklmnopqrstuvwx"
|
||||
h1 = hash_api_key(plaintext)
|
||||
h2 = hash_api_key(plaintext)
|
||||
assert h1 == h2
|
||||
assert h1 != plaintext
|
||||
assert len(h1) == 64
|
||||
|
||||
|
||||
def test_split_prefix_takes_first_16():
|
||||
assert split_prefix("dfk_live_abcdefghijklmnop") == "dfk_live_abcdefg"
|
||||
@@ -598,7 +598,7 @@ def test_upload_limits_endpoint_requires_thread_access():
|
||||
cfg.uploads = {}
|
||||
app = make_authed_test_app(owner_check_passes=False)
|
||||
app.state.config = cfg
|
||||
app.include_router(uploads.router)
|
||||
app.include_router(uploads.router, prefix="/api")
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/threads/thread-local/uploads/limits")
|
||||
|
||||
@@ -80,7 +80,7 @@ def _build_app(*, user_id: str, workspace_id: str):
|
||||
app.state.store = store
|
||||
app.state.checkpointer = InMemorySaver()
|
||||
app.state.thread_store = MemoryThreadMetaStore(store)
|
||||
app.include_router(threads.router)
|
||||
app.include_router(threads.router, prefix="/api")
|
||||
return app, store
|
||||
|
||||
|
||||
|
||||
@@ -305,4 +305,4 @@ erDiagram
|
||||
- [adr-001-data-isolation.zh-CN.md](./adr-001-data-isolation.zh-CN.md) — 行级 `workspace_id` + Postgres RLS + LangGraph 表两层模型
|
||||
- [adr-004-tenant-rbac.zh-CN.md](./adr-004-tenant-rbac.zh-CN.md) — RBAC + JWT 设计
|
||||
- [adr-spike-langgraph-postgres.zh-CN.md](./adr-spike-langgraph-postgres.zh-CN.md) — 为何 LangGraph 表不归 ORM 管
|
||||
- [03-impl/STATUS.md](../03-impl/STATUS.md) + `03-impl/pr8-headless-api-schema.md` — PR 级实现进度
|
||||
- [03-impl/STATUS.zh-CN.md](../03-impl/STATUS.zh-CN.md) + `03-impl/pr8-headless-api-schema.zh-CN.md` — PR 级实现进度
|
||||
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
# Stage 1 · Headless API Pattern A 鉴权地基 — 设计
|
||||
|
||||
> 设计稿。日期 2026-06-28。承接 Stage 0(PR1-PR8 全部 merge,见 [STATUS.zh-CN.md](../03-impl/STATUS.zh-CN.md))与 headless API 轨道设计 [headless-api-track.zh-CN.md](../02-rollout/headless-api-track.zh-CN.md)。
|
||||
>
|
||||
> 范围:headless-api-track **轨道二(Pattern A)** 的鉴权地基。让业务系统 backend 能用 API key(`Authorization: Bearer dfk_...`)调通 DeerFlow Gateway——server-to-server。Pattern B(浏览器直连 + 短期 JWT)、external_user 透传、identity_mode 三态行为、rate limit 不在本 spec 范围(轨道二后续 PR / 轨道三)。
|
||||
|
||||
## 1. 目标与非目标
|
||||
|
||||
### 目标
|
||||
|
||||
业务系统 backend 拿一把 workspace-scoped API key,`Authorization: Bearer dfk_live_<...>` 直调 `/api/threads` 等现有 REST endpoint,请求被正确归属到该 key 背后的 service account + workspace,并被 workspace 隔离(跨 workspace 资源一律 404)。形成可自助的最小闭环:workspace owner 经管理 endpoint 建 service account → 建 key → 业务侧用 key 调通。
|
||||
|
||||
### 非目标(明确推后)
|
||||
|
||||
- **Pattern B**(`exchange-token` / 短期 JWT / CORS / `allowed_origins`)— 轨道三。
|
||||
- **external_user 透传**(`X-External-User-Id` upsert、ghost user、per-external-user memory)— 轨道二后续 PR。本 spec 只到 `collapsed` 语义(一切归 SA)。
|
||||
- **identity_mode 三态行为分支** — 同上。SA 的 `identity_mode` 列存在(PR8 schema),但本 spec 一律按 collapsed 处理,不读该列做分支。
|
||||
- **rate limit / idempotency** — 轨道二后续 PR。
|
||||
- **API key 管理前端 UI** — 本 spec 只做管理 **endpoint**;UI 接这些 endpoint 是后续 PR。
|
||||
- **`@require_permission` 的 `scopes=[...]` 显式参数升级** — 本 spec 用"把 scopes 灌进现有 `AuthContext.permissions`"取得 scope 校验,decorator 签名不改。
|
||||
|
||||
## 2. 现状锚点(实现时镜像,防漂移)
|
||||
|
||||
| 组件 | 文件 | 关键符号 |
|
||||
|---|---|---|
|
||||
| Auth 中间件 | `backend/app/gateway/auth_middleware.py` | `AuthMiddleware.dispatch`(检 `_is_public` → cookie/JWT → 设 contextvar,L77-143) |
|
||||
| JWT / TokenPayload | `backend/app/gateway/auth/jwt.py` | `TokenPayload`、`create_access_token`、`decode_token` |
|
||||
| 用户解析 | `backend/app/gateway/deps.py` | `get_current_user_from_request`(L186)、`get_optional_user_from_request`(L230) |
|
||||
| CSRF | `backend/app/gateway/csrf_middleware.py` | `CSRFMiddleware`、`should_check_csrf`(L32)、`is_auth_endpoint`(L58) |
|
||||
| user contextvar | `backend/packages/harness/deerflow/runtime/user_context.py` | `CurrentUser`、`set_current_user`、`get_effective_user_id` |
|
||||
| workspace contextvar | `backend/packages/harness/deerflow/runtime/workspace_context.py` | `CurrentWorkspace`、`set_current_workspace`、`get_effective_workspace_id` |
|
||||
| 授权装饰器 | `backend/app/gateway/authz.py` | `require_permission`(L197)、`AuthContext`(L62,含 `permissions: list[str]` + `has_permission`) |
|
||||
| 仓储样板 | `backend/packages/harness/deerflow/persistence/workspace/sql.py` | `WorkspaceRepository`(构造收 `session_factory`,每方法开 fresh session,`_row_to_dict`) |
|
||||
| session 工厂 | `backend/packages/harness/deerflow/persistence/engine.py` | `get_session_factory()` |
|
||||
| PR8 ORM | `persistence/{service_account,api_key,external_user}/model.py` | `ServiceAccountRow` / `ApiKeyRow` / `ExternalUserRow`(schema 已落,见 [pr8 impl note](../03-impl/pr8-headless-api-schema.zh-CN.md)) |
|
||||
| 路由挂载 | `backend/app/gateway/app.py` | `create_app()` 内 15 个 `include_router`(L379-421) |
|
||||
| 路由前缀约定 | `backend/app/gateway/routers/*.py` | 前缀**写死在 `APIRouter(prefix=...)`**;`auth.py` 已用 `/api/v1/auth`,证明 v1 与旧前缀共存 |
|
||||
| 前端 API 路径 | `frontend/src/core/*/api.ts`、`src/core/threads/hooks.ts` 等 | `getBackendBaseURL()` + 路径串;auth 已用 `/api/v1/auth`;langgraph-sdk 走 `/api/langgraph/*` |
|
||||
| 测试夹具 | `backend/tests/conftest.py` | autouse `_auto_user_context` / `_auto_workspace_context`(可 `@pytest.mark.no_auto_*` opt-out) |
|
||||
| 中间件测试样板 | `backend/tests/test_auth_middleware.py`、`test_csrf_middleware.py` | `_make_app()` + `starlette.testclient.TestClient` |
|
||||
|
||||
## 3. 已锁的设计决策(来自 brainstorm)
|
||||
|
||||
| # | 决策 | 取舍 |
|
||||
|---|---|---|
|
||||
| **D1** | **SA 身份映射为 `CurrentUser`** | SA 鉴权后 `set_current_user(CurrentUser(id=SA.id, is_service_account=True))` + `set_current_workspace(SA.workspace_id)`。thread 归属落 `user_id = SA.id`,复用现有 `workspace_id + user_id` 隔离逻辑,**不改业务表 schema**。代价:SA id 坐在 user_id 列,将来拆 external_user 要迁移——可接受,因为 external_user 透传是后续 PR 且届时本就要动归属维度。**否决**「独立 `service_account` contextvar + thread 加 `service_account_id` 列」方案,因其要改业务表 schema + 所有读 `user_id` 的 consumer,超出地基范围。 |
|
||||
| **D2** | **mint key 走管理 endpoint** | `/api/v1/service-accounts` + `/api/v1/api-keys`,gated 到 workspace owner/admin(复用 `@require_permission`)。业务侧可自助;plaintext 仅创建时返一次。**否决**纯 CLI(业务侧拿不到自助接口)。CLI 包装留作 on-prem bootstrap 的后续可选项。 |
|
||||
| **D3** | **旧路由现在就全量迁 `/api/v1`** | 所有 router 双挂 `/api` + `/api/v1`,前端同步迁 `/api/v1`。**否决**「只新 surface 用 v1」。因面较广、回归风险较大,本 PR 放在**最后**(PR5),让 PR1-4 的鉴权地基能独立证伪。 |
|
||||
| **D4** | **scope 校验"白捡"进 PR2** | API key 路径把 `key.scopes`(逗号分隔)解析进 `AuthContext.permissions`,现有 `@require_permission` 的 `has_permission(resource, action)` 即对 API key 生效——无需改 decorator 签名。完整 `scopes=[...]` 参数化升级推后。 |
|
||||
| **D5** | **key 格式锁 `dfk_live_<24>` / `dfk_test_<24>`** | 沿用 track 文档不可逆决策。`key_prefix` = 前 16 字符(含 `dfk_live_`),全局 UNIQUE(PR8 schema 已定)。DB 只存 `sha256(plaintext)` hex。 |
|
||||
|
||||
## 4. 架构与数据流
|
||||
|
||||
```
|
||||
业务系统 backend ──▶ AuthMiddleware ──┬── "Bearer dfk_..." ──▶ APIKeyAuthBackend
|
||||
│ │
|
||||
│ ├─ sha256(token) → ApiKeyRepository.get_active_by_hash
|
||||
│ ├─ 校 key.expires_at 未过期;load SA 校 status=active;load workspace
|
||||
│ ├─ set_current_user(CurrentUser(id=SA.id, is_service_account=True))
|
||||
│ ├─ set_current_workspace(SA.workspace_id)
|
||||
│ ├─ AuthContext.permissions = parse(key.scopes)
|
||||
│ └─ touch_last_used(key.id)(异步/best-effort)
|
||||
│
|
||||
└── "Cookie: access_token=..." ──▶ 现有 cookie 路径(不变)
|
||||
│
|
||||
CSRFMiddleware: 见 Authorization: Bearer 即 skip(PR3)
|
||||
│
|
||||
▼
|
||||
路由 + @require_permission(scope via permissions, owner_check via workspace_id+user_id)
|
||||
▼
|
||||
thread store / sandbox(按 user_id=SA.id + workspace_id 隔离,零改动)
|
||||
```
|
||||
|
||||
**关键不变量**:API key 路径走完后,下游(thread store、sandbox、`@require_permission` 的 owner_check)看到的 `(user_id, workspace_id)` 与一个真人用户在该 workspace 下完全同构——这正是 D1 让地基零改动下游的原因。
|
||||
|
||||
## 5. 逐 PR 设计
|
||||
|
||||
### PR1 · 三表仓储 + token 工具(`deerflow` 层)
|
||||
|
||||
**新增**
|
||||
- `persistence/service_account/sql.py` — `ServiceAccountRepository`:`create(*, workspace_id, name, created_by, role="member", identity_mode="collapsed", status="active")`、`get(sa_id)`、`get_active(sa_id)`(status==active 才返)、`list_by_workspace(workspace_id)`、`update_status(sa_id, status)`。
|
||||
- `persistence/api_key/sql.py` — `ApiKeyRepository`:`create(*, service_account_id, key_prefix, key_hash, name, scopes, expires_at=None)`、`get_active_by_hash(key_hash)`(**热路径**,`revoked_at IS NULL` 且未过期 → 走 `idx_api_keys_active`)、`list_by_service_account(sa_id)`、`revoke(key_id)`(设 `revoked_at`)、`touch_last_used(key_id)`。
|
||||
- `persistence/external_user/sql.py` — `ExternalUserRepository`:本 PR 仅建 `get`/`list_by_workspace` 等读方法 + 基础 `upsert(*, workspace_id, service_account_id, external_id, ...)` 骨架;**透传调用方留到后续 PR**(建仓储不接业务,与 PR8 建 schema 不接路由同思路)。
|
||||
- `deerflow/auth/tokens.py` — `generate_api_key(env: Literal["live","test"]) -> GeneratedKey(plaintext, prefix, key_hash)`;`hash_api_key(plaintext) -> str`(sha256 hex);`split_prefix(plaintext) -> str`(前 16)。用 `secrets.token_urlsafe`。**落 `deerflow` 层**(不是 `app`)——因仓储的 `get_active_by_hash`(deerflow)与管理 endpoint 的建 key(app)都要用,按 harness boundary(app 可 import deerflow,反之不可)必须在 deerflow 侧。
|
||||
|
||||
**测试**(严格 TDD 红→绿):每仓储 CRUD round-trip;`get_active_by_hash` 对 revoked / expired key 返 None;token 生成格式(前缀、长度、prefix 截取)、`hash_api_key` 确定性、plaintext 不可从 hash 反推(仅断言 hash≠plaintext + 长度)。镜像 `test_*_schema.py` 与 workspace 仓储测试风格。
|
||||
|
||||
**不在范围**:任何路由、中间件、contextvar。
|
||||
|
||||
### PR2 · APIKeyAuthBackend + AuthMiddleware 双路径(`app` 层)
|
||||
|
||||
**改动**
|
||||
- `CurrentUser`(`runtime/user_context.py`)加 `is_service_account: bool = False` 字段(默认 False,cookie 路径不受影响)。
|
||||
- 新 `app/gateway/auth/api_key_backend.py` — `APIKeyAuthBackend.authenticate(token: str) -> AuthResult | None`:
|
||||
1. `hash = hash_api_key(token)` → `ApiKeyRepository.get_active_by_hash(hash)`;未命中/已撤销/已过期 → None(→ 401)。
|
||||
2. `ServiceAccountRepository.get_active(key.service_account_id)`;非 active → None(→ 401/403)。
|
||||
3. load workspace(校 status)。
|
||||
4. 返回足以让中间件设 contextvar 的结构:`(CurrentUser(id=SA.id, is_service_account=True), workspace_id, role, permissions=parse_scopes(key.scopes))`。
|
||||
5. best-effort `touch_last_used(key.id)`(失败不阻断请求)。
|
||||
- `AuthMiddleware.dispatch`:在 cookie 分支**之前**插入 bearer 分支——`Authorization` 头以 `Bearer dfk_` 开头 → 走 `APIKeyAuthBackend` → 设 `request.state.user` / `request.state.auth`(含 permissions)+ 两个 contextvar;否则落回现有 cookie 流程。`_is_public` / 内部 auth 头逻辑不变。
|
||||
- `AuthContext.permissions` 由 API key 的 scopes 填充(D4)。
|
||||
|
||||
**测试**:有效 key → 200 且 contextvar 正确(SA.id / workspace_id);无效/撤销/过期 key → 401;非 `dfk_` 的 Bearer → 不误入此路径;有 scope 的 key 调对应 endpoint 通过、无 scope 被 `@require_permission` 拒;cookie 路径回归不破。用 `_make_app()` 样板 + 直接 insert 的 key(PR1 仓储)构造。
|
||||
|
||||
### PR3 · CSRF skip on bearer
|
||||
|
||||
**改动**
|
||||
- `csrf_middleware.py` 加 `has_bearer_header(request) -> bool`(`Authorization` 头存在且以 `Bearer ` 起);`should_check_csrf` 在其为真时返 False。cookie 路径 CSRF 行为完全不变。
|
||||
|
||||
**测试**:带 `Authorization: Bearer ...` 的 POST 跳过 CSRF(无 `X-CSRF-Token` 也 200);cookie POST 仍要 CSRF token(回归)。
|
||||
|
||||
### PR4 · 管理 endpoint(mint 闭环)
|
||||
|
||||
**新增**
|
||||
- `app/gateway/routers/service_accounts.py` — `APIRouter(prefix="/api/v1/service-accounts")`:
|
||||
- `POST /` 建 SA(body: name, role?, identity_mode?)→ 归当前 workspace,`created_by` = 当前 user。`@require_permission` gated 到 owner/admin。
|
||||
- `GET /` 列当前 workspace 的 SA。
|
||||
- `PATCH /{sa_id}` 改 status(suspend/active)。
|
||||
- `app/gateway/routers/api_keys.py` — `APIRouter(prefix="/api/v1/api-keys")`:
|
||||
- `POST /` 为指定 SA 建 key(body: service_account_id, name, scopes, env?, expires_at?)→ 调 `generate_api_key` → 存 hash+prefix → **响应体含 plaintext,仅此一次**。
|
||||
- `GET /?service_account_id=` 列 key(只返 prefix / name / scopes / 时间戳,**绝不**返 hash/plaintext)。
|
||||
- `DELETE /{key_id}` revoke(设 `revoked_at`)。
|
||||
- 两 router 在 `app.py` `include_router`。owner/admin gating 复用现有 authz;SA / key 必须属于当前 workspace(跨 workspace 操作 → 404)。
|
||||
|
||||
**测试 + 端到端 smoke**:owner 建 SA → 建 key(断言 plaintext 仅返一次、再查不含 plaintext)→ 用该 key 调 `/api/threads` 跑通 → 另一 workspace 的 key 访问首 workspace 资源得 404 → member(非 owner/admin)建 SA 被拒。
|
||||
|
||||
### PR5 · `/api/v1` 全量迁移 + 旧路径兼容(最后做)
|
||||
|
||||
**后端**
|
||||
- 把 15 个 router 的写死前缀从 `APIRouter(prefix="/api...")` 改为相对前缀(如 `/threads`),在 `app.py` include 时**双挂**:一次 `/api` + 一次 `/api/v1`(保持向后兼容)。`auth.py`(已 `/api/v1/auth`)与 PR4 新 router(已 `/api/v1/*`)按需统一。
|
||||
- `/api/langgraph/*`(LangGraph SDK 兼容路径)**不版本化**,不动。
|
||||
- 旧 `/api/*`(无版本)响应加 `X-API-Deprecated` header(sunset 日期取 track 约定 `2027-01-01`)。
|
||||
|
||||
**前端**
|
||||
- `frontend/src/core/*/api.ts`、`src/core/threads/hooks.ts`、`src/core/artifacts/utils.ts`、`src/core/uploads/api.ts`、`src/core/api/feedback.ts`、`src/core/models/api.ts` 等处的 `/api/...` 路径串迁到 `/api/v1/...`。langgraph-sdk 客户端路径(`/api/langgraph/*`)不动。
|
||||
- 验证:`pnpm lint && pnpm typecheck`;若动到 env/auth/routing 则 `pnpm build`。
|
||||
|
||||
**测试**:旧 `/api/threads` 与新 `/api/v1/threads` 均 200 且行为一致;旧路径带 `X-API-Deprecated`;langgraph 路径不受影响。后端全量 `make lint && make test` 回归。
|
||||
|
||||
## 6. 错误处理约定
|
||||
|
||||
| 情况 | 响应 |
|
||||
|---|---|
|
||||
| key 不存在 / hash 不匹配 / 已 revoke / 已过期 | 401 |
|
||||
| SA suspended/deleted、workspace 非 active | 401(不泄漏"key 有效但账户停用"细节,保守口径;如业务方需区分再放宽到 403) |
|
||||
| 有效 key 但缺 scope | 403(沿用 `@require_permission` 现有语义) |
|
||||
| 跨 workspace 访问资源(owner_check) | 404(沿用现有租户隔离:藏存在性,非 403) |
|
||||
| 非 owner/admin 调管理 endpoint | 403 |
|
||||
|
||||
## 7. 测试策略
|
||||
|
||||
- 每 PR 严格 TDD(红→绿),镜像现有 `test_auth_middleware.py` / `test_csrf_middleware.py` / 仓储测试风格。
|
||||
- 仓储层用 SQLite(autouse fixture),鉴权热路径的 partial index 行为不依赖驱动(逻辑层过滤)。
|
||||
- PR4 的端到端 smoke 是地基的"活体证明"——比单测更有说服力(仿 Stage 0 `multi_tenant.py` 思路)。
|
||||
- 全程不引入新 caplog flake;既有 18 个 flake 不在本 spec 处理范围。
|
||||
|
||||
## 8. 不可逆决策清单(动手前确认,沿用 track 已锁口径)
|
||||
|
||||
| 决策 | 不可逆原因 | 本 spec 取值 |
|
||||
|---|---|---|
|
||||
| API key 格式 | 业务接入后改格式所有 key 失效 | `dfk_live_<24>` / `dfk_test_<24>`,prefix 16,sha256 存储(D5) |
|
||||
| SA 归属映射 | 改了 thread 归属语义 | `user_id = SA.id`(D1);external_user 维度留后续 |
|
||||
| 管理 endpoint 路径 | 业务/前端接入后改 path 要联调 | `/api/v1/service-accounts`、`/api/v1/api-keys`(D2) |
|
||||
| `/api/v1` 启用与 deprecation | 业务接了再换 prefix 不友好 | 全量双挂 + 旧路径 `X-API-Deprecated: 2027-01-01`(D3) |
|
||||
| scope 字符串格式 | 存量 key 的 scopes 解析依赖它 | 逗号分隔 `resource:action`(沿用 PR8 schema + 现有 permission 串) |
|
||||
|
||||
## 8.1 已知限制(落地后复核确认,需后续 PR 决策)
|
||||
|
||||
> 实现完成后的整体安全复核(2026-06-28)发现一处**符合本 spec 范围但值得显式记录**的最小权限缺口:
|
||||
|
||||
- **scope 仅在 threads/runs 等 `@require_permission` 装饰的路由上生效。** `AuthContext.permissions`(由 key 的 scopes 填充)只被 `@require_permission` 读取,而该装饰器目前只挂在 threads/runs/uploads/artifacts/feedback/suggestions 上。`mcp`(`PUT /api/v1/mcp/config`)、`skills`(`POST /api/v1/skills/install`)、`channels`(`restart`)、`models`、`agents`、`memory` 等路由**只校验"已认证",不校验 scope/role**。后果:一把 `scopes="threads:read"` 的 key 仍能改全局 MCP 配置、装技能、重启 channel;且这些目标是**进程级全局**(非 workspace 分区),对它们而言 workspace 隔离也不成立。
|
||||
- 这与现有真人模型一致(真人拿 `_ALL_PERMISSIONS`,这些路由本就无授权),且 D4 / 非目标已把 `scopes=[...]` 显式参数化升级推后——故属**设计内的已知限制,非缺陷**。
|
||||
- **后续 PR 决策项**:要么把这些全局配置路由 gated 到 `require_workspace_admin` / 专门 scope,要么显式声明"Stage 1 的 API key 在未被 `@require_permission` 装饰处为全权"。在 external_user 透传 / `scopes=[...]` 升级 PR 中一并处理。
|
||||
|
||||
## 9. 与后续 PR 的接口
|
||||
|
||||
本 spec 的地基为轨道二后续 / 轨道三留好接缝:
|
||||
- `ExternalUserRepository`(PR1 建好)+ `is_service_account` 标记 → external_user 透传 PR 直接接。
|
||||
- `APIKeyAuthBackend` 返回结构里已带 scopes/permissions → `@require_permission` 的 `scopes=[...]` 参数化升级可平滑替换。
|
||||
- 管理 endpoint → 前端 API key 管理 UI 直接消费。
|
||||
- `/api/v1` 命名空间 → Pattern B 的 `/api/v1/auth/exchange-token` 落在同一前缀。
|
||||
|
||||
## 10. 阅读路径
|
||||
|
||||
- 宏观背景 → [headless-api-track.zh-CN.md](../02-rollout/headless-api-track.zh-CN.md)
|
||||
- Stage 0 现状 / 测试基线 → [STATUS.zh-CN.md](../03-impl/STATUS.zh-CN.md)
|
||||
- PR8 三表 schema → [pr8-headless-api-schema.zh-CN.md](../03-impl/pr8-headless-api-schema.zh-CN.md)
|
||||
- 本 spec 的实现计划 → [2026-06-28-stage-1-headless-api-pattern-a-auth-foundation.md](../../superpowers/plans/2026-06-28-stage-1-headless-api-pattern-a-auth-foundation.md)
|
||||
+11
-11
@@ -28,14 +28,14 @@
|
||||
| PR | 状态 | Commits | 分支 / 落点 | impl note |
|
||||
|---|---|---|---|---|
|
||||
| **PR0** | ✅ merged | 1 | `a74b88a4` on docs branch | — |
|
||||
| **PR1** | ✅ merged | 8 (T1.1-T1.10) | merged into docs branch (`fab85b14..85a14f4c`) | [pr1-postgres-setup.md](./pr1-postgres-setup.md) |
|
||||
| **PR2** | ✅ merged | 8 (T2.1-T2.10) | merged into docs branch (`404135a1..1112a197`) | [pr2-postgres-default.md](./pr2-postgres-default.md) |
|
||||
| **PR3** | ✅ merged | 7 (T3.1-T3.10) | merged into docs branch (`f63089ae..dda82640`) | [pr3-workspaces.md](./pr3-workspaces.md) |
|
||||
| **PR4** | ✅ merged | 14 (T4.1-T4.14) | merged into docs branch (`d98498b7..5c7753c0`) | [pr4-auth-workspace.md](./pr4-auth-workspace.md) |
|
||||
| **PR5** | ✅ merged | 11 (T5.1-T5.10 + T5.12) | merged into docs branch (`a7326978..30f2bd00`) | [pr5-business-workspace-id.md](./pr5-business-workspace-id.md) |
|
||||
| **PR6** | ✅ merged | 13 (T5.11 + T6.1-T6.15) | merged into docs branch (`361e653d..87ea715c`) | [pr6-routes-paths-workspace.md](./pr6-routes-paths-workspace.md) |
|
||||
| **PR7** | ✅ merged | 4 (T7.1-T7.3 + T7.5; T7.4 是反注入验证无代码改动) | merged into docs branch (`1a6ccc9a..d8b13afc`) | [pr7-ci-boundary-scan.md](./pr7-ci-boundary-scan.md) |
|
||||
| **PR8** | ✅ merged | 5 (T8.1 + T8.2/T8.3 合并 + T8.4 + T8.5 + T8.6) | merged into docs branch (`1fb07e48..f803f393`) | [pr8-headless-api-schema.md](./pr8-headless-api-schema.md) |
|
||||
| **PR1** | ✅ merged | 8 (T1.1-T1.10) | merged into docs branch (`fab85b14..85a14f4c`) | [pr1-postgres-setup.zh-CN.md](./pr1-postgres-setup.zh-CN.md) |
|
||||
| **PR2** | ✅ merged | 8 (T2.1-T2.10) | merged into docs branch (`404135a1..1112a197`) | [pr2-postgres-default.zh-CN.md](./pr2-postgres-default.zh-CN.md) |
|
||||
| **PR3** | ✅ merged | 7 (T3.1-T3.10) | merged into docs branch (`f63089ae..dda82640`) | [pr3-workspaces.zh-CN.md](./pr3-workspaces.zh-CN.md) |
|
||||
| **PR4** | ✅ merged | 14 (T4.1-T4.14) | merged into docs branch (`d98498b7..5c7753c0`) | [pr4-auth-workspace.zh-CN.md](./pr4-auth-workspace.zh-CN.md) |
|
||||
| **PR5** | ✅ merged | 11 (T5.1-T5.10 + T5.12) | merged into docs branch (`a7326978..30f2bd00`) | [pr5-business-workspace-id.zh-CN.md](./pr5-business-workspace-id.zh-CN.md) |
|
||||
| **PR6** | ✅ merged | 13 (T5.11 + T6.1-T6.15) | merged into docs branch (`361e653d..87ea715c`) | [pr6-routes-paths-workspace.zh-CN.md](./pr6-routes-paths-workspace.zh-CN.md) |
|
||||
| **PR7** | ✅ merged | 4 (T7.1-T7.3 + T7.5; T7.4 是反注入验证无代码改动) | merged into docs branch (`1a6ccc9a..d8b13afc`) | [pr7-ci-boundary-scan.zh-CN.md](./pr7-ci-boundary-scan.zh-CN.md) |
|
||||
| **PR8** | ✅ merged | 5 (T8.1 + T8.2/T8.3 合并 + T8.4 + T8.5 + T8.6) | merged into docs branch (`1fb07e48..f803f393`) | [pr8-headless-api-schema.zh-CN.md](./pr8-headless-api-schema.zh-CN.md) |
|
||||
|
||||
**测试基线**:**PR8 末 3250 passed + 31 skipped**(PR7 末 3241 + 31;+9 passed,PR8 新增 3 + 3 + 2 + 1 = 9 个 schema 测试)。PR6 末 3214 + 30;PR5 末 3150 + 30;PR4 末 3136 + 26;PR3 末 3134 + 25;PR2 末 3087。**18 个 caplog 排序 flake 持续存在**(17 个 pre-existing + 1 PR6 引入,PR7/PR8 均未引入新 flake)→ isolate 跑全 PASS,与 stage 无关;集中清理仍推迟到 follow-up。
|
||||
|
||||
@@ -66,9 +66,9 @@ PR1 起到 PR8 末,从既有 ~3087 增到 3250 passed(+163 测试,覆盖
|
||||
| PR4 follow-up | Regular user pre-PR4 backfill 脚本 | login 路径已 lazy backfill 覆盖;如果生产有大量预存 regular user,可补 batch 脚本 | 等真出现这个场景再写 |
|
||||
| PR4 follow-up | 17 个 pre-existing caplog flake 集中清理 | 跨多个 test 文件的 propagation 问题,与 PR4/5/6 无关 | 单独 follow-up 处理 |
|
||||
| ~~PR5 T5.11~~ | ~~ORM model.py `nullable=False` 翻转~~ | **PR6 已落** (commit `87ea715c`) | — |
|
||||
| PR5 T5.12 真机 PG smoke | `alembic 0002 → backfill → 0003` 端到端 | agent 不能起 RDS 操作 | 用户跟进;命令清单见 [pr5-business-workspace-id.md "Live smoke 命令"](./pr5-business-workspace-id.md#live-smoke-命令用户跟进) |
|
||||
| PR6 T6.15 真机迁移 smoke | `make migrate-paths --dry-run` → 真迁移 → lifespan warning 消失 → 双账户互访 404 | agent 起不了 dev 服务 | 🟡 部分 done——「双账户互访 404」✅ 由 `multi_tenant.py`(2026-06-27 PASS)覆盖;文件迁移 `make migrate-paths` 部分仍 ⏳。命令清单见 [pr6-routes-paths-workspace.md "Live smoke 命令"](./pr6-routes-paths-workspace.md#live-smoke-命令用户跟进) |
|
||||
| PR8 RDS 三张表存在 | `psql "$DATABASE_URL" -c "\dt service_accounts api_keys external_users"` 看 3 行;`\d+ api_keys` 看 `idx_api_keys_active ... WHERE revoked_at IS NULL` | agent 没 RDS 凭证 | 用户跟进;命令清单见 [pr8-headless-api-schema.md "Live smoke 命令"](./pr8-headless-api-schema.md#live-smoke-命令用户跟进) |
|
||||
| PR5 T5.12 真机 PG smoke | `alembic 0002 → backfill → 0003` 端到端 | agent 不能起 RDS 操作 | 用户跟进;命令清单见 [pr5-business-workspace-id.zh-CN.md "Live smoke 命令"](./pr5-business-workspace-id.zh-CN.md#live-smoke-命令用户跟进) |
|
||||
| PR6 T6.15 真机迁移 smoke | `make migrate-paths --dry-run` → 真迁移 → lifespan warning 消失 → 双账户互访 404 | agent 起不了 dev 服务 | 🟡 部分 done——「双账户互访 404」✅ 由 `multi_tenant.py`(2026-06-27 PASS)覆盖;文件迁移 `make migrate-paths` 部分仍 ⏳。命令清单见 [pr6-routes-paths-workspace.zh-CN.md "Live smoke 命令"](./pr6-routes-paths-workspace.zh-CN.md#live-smoke-命令用户跟进) |
|
||||
| PR8 RDS 三张表存在 | `psql "$DATABASE_URL" -c "\dt service_accounts api_keys external_users"` 看 3 行;`\d+ api_keys` 看 `idx_api_keys_active ... WHERE revoked_at IS NULL` | agent 没 RDS 凭证 | 用户跟进;命令清单见 [pr8-headless-api-schema.zh-CN.md "Live smoke 命令"](./pr8-headless-api-schema.zh-CN.md#live-smoke-命令用户跟进) |
|
||||
|
||||
## 即将遇到的开放问题(plan 末尾列的,下个 session 处理)
|
||||
|
||||
+1
-1
@@ -145,4 +145,4 @@ make stop && make dev
|
||||
- 跨 workspace 必 404 怎么实现的 → 看 `check_access` 改造(commit `05be7f9a`)+ `@require_permission` 装饰器
|
||||
- 路径新形态 → `Paths.thread_dir` 三档优先级(commit `f013fc1a`)
|
||||
- 仓储 workspace_id 哨兵模式 → 任一 `*/sql.py` 看 create/get/search 签名
|
||||
- 迁移脚本与 PR5 backfill 的边界 → 本文件 "迁移路径" 段,外加 `pr5-business-workspace-id.md`
|
||||
- 迁移脚本与 PR5 backfill 的边界 → 本文件 "迁移路径" 段,外加 `pr5-business-workspace-id.zh-CN.md`
|
||||
+2
-2
@@ -37,7 +37,7 @@
|
||||
- [x] **scanner 红→绿循环**:empty allowlist → 14 violations across 4 files(threads / async_provider / provider / worker,TYPE_CHECKING-only 的 factory.py 正确不在内);填入 4 entry → PASS
|
||||
- [x] **scanner self-test 9 个全过**(防静默空跑)
|
||||
- [x] **T7.4 反注入实验**:往 `feedback.py:13` 加一行违规 import → `pytest tests/test_workspace_boundary.py` 单条 fail,error 精准指 `app/gateway/routers/feedback.py:13 imports langgraph.checkpoint.postgres`;revert 后立即返绿
|
||||
- [x] **全套 `make test` 3241 passed + 31 skipped + 18 caplog flake**(PR6 末 3214 + 30 + 17;+27 passed / +1 skip / +1 flake — passed delta 包含 PR7 新增 10 个测试以及环境差异导致的 17 个之前 flake 这次稳过,flake 列表形态与 STATUS.md 既有 17 项 + PR6 引入的 `test_path_migration_pending_warning` 一致,与 PR7 改动无关)
|
||||
- [x] **全套 `make test` 3241 passed + 31 skipped + 18 caplog flake**(PR6 末 3214 + 30 + 17;+27 passed / +1 skip / +1 flake — passed delta 包含 PR7 新增 10 个测试以及环境差异导致的 17 个之前 flake 这次稳过,flake 列表形态与 STATUS.zh-CN.md 既有 17 项 + PR6 引入的 `test_path_migration_pending_warning` 一致,与 PR7 改动无关)
|
||||
- [x] **CI workflow 接入**:扫描器是普通 pytest,已被 `.github/workflows/backend-unit-tests.yml` 全套 run 覆盖;无需新 workflow
|
||||
|
||||
## 文件结构
|
||||
@@ -46,7 +46,7 @@
|
||||
- `backend/tests/test_workspace_boundary.py` — AST 扫描器(127 行)
|
||||
- `backend/tests/test_workspace_boundary_self.py` — 扫描器 self-test(93 行)
|
||||
- `backend/tests/boundary_allowlist.toml` — 4 个合法 importer + 每行注释(28 行)
|
||||
- `docs/multi-tenant-redesign/03-impl/pr7-ci-boundary-scan.md` — 本文件
|
||||
- `docs/multi-tenant-redesign/03-impl/pr7-ci-boundary-scan.zh-CN.md` — 本文件
|
||||
|
||||
**修改**:
|
||||
- `backend/CLAUDE.md` — Boundary check 段 +2 行
|
||||
+2
-2
@@ -47,7 +47,7 @@
|
||||
- `backend/tests/test_api_key_schema.py`(3 cases)
|
||||
- `backend/tests/test_external_user_schema.py`(2 cases)
|
||||
- `backend/tests/test_pr8_metadata_registration.py`(1 case)
|
||||
- `docs/multi-tenant-redesign/03-impl/pr8-headless-api-schema.md` — 本文件
|
||||
- `docs/multi-tenant-redesign/03-impl/pr8-headless-api-schema.zh-CN.md` — 本文件
|
||||
|
||||
**修改**:
|
||||
- `backend/packages/harness/deerflow/persistence/models/__init__.py` — 加 3 行 import + `__all__` 注册
|
||||
@@ -89,7 +89,7 @@ PYTHONPATH=. uv run pytest -m postgres -v
|
||||
|
||||
## Stage 0 退出门
|
||||
|
||||
PR8 是 Stage 0 工程层面最后一个 PR。剩余 Stage 0 退出条件见 [STATUS.md](./STATUS.md)"用户必须跟进的事":
|
||||
PR8 是 Stage 0 工程层面最后一个 PR。剩余 Stage 0 退出条件见 [STATUS.zh-CN.md](./STATUS.zh-CN.md)"用户必须跟进的事":
|
||||
- [ ] RDS 上 `service_accounts` / `api_keys` / `external_users` 三张表 `\dt` 见
|
||||
- [ ] `make migrate-paths --dry-run` 在 fresh DB 上输出空
|
||||
- [ ] testcontainers ephemeral PG smoke 跑过一次
|
||||
@@ -1,6 +1,8 @@
|
||||
# 多租户改造 · 总览与汇总索引
|
||||
|
||||
> 写于 2026-05-10。把 7 份 ADR + 2 份 spike/审计 + 4 份 rollout / schema 文档,按"ADR 状态 + 5 阶段(Stage 0–4)的业务目标 / 技术路径 / 验证方式"重新串一遍,让团队从任何角度切入都能找到对应位置。
|
||||
> ⚠️ **本文是「设计 / 路线」导航,不反映执行进度**(写于 2026-05-10 设计期)。**想知道「现在到哪了」永远先读 [`03-impl/STATUS.zh-CN.md`](./03-impl/STATUS.zh-CN.md)**——那是唯一动态的进度权威。本 README 后续小节里 Stage 0/1 多以 forward-looking 口径描述,与 STATUS 的"已完成"口径并存属正常分工。
|
||||
>
|
||||
> 写于 2026-05-10。把 7 份 ADR + 2 份 spike/审计 + 4 份 rollout / schema 文档 + 1 份 Stage 1 spec,按"ADR 状态 + 5 阶段(Stage 0–4)的业务目标 / 技术路径 / 验证方式"重新串一遍,让团队从任何角度切入都能找到对应位置。执行记录(STATUS + 各 PR impl note)见 `03-impl/`。
|
||||
>
|
||||
> **范围**:仅汇总与导航,不引入新决策。具体决策正文在各自的 ADR / rollout 文档里。
|
||||
>
|
||||
@@ -27,13 +29,19 @@ docs/multi-tenant-redesign/
|
||||
│ ├── adr-vs-code-audit 审计:ADR vs 现状代码
|
||||
│ ├── multi-tenant-phase-0-plan Phase-0 时间盒 / 产出物
|
||||
│ ├── workspace-schema-design **Stage 0 schema 锁定版**(不可逆决策点)
|
||||
│ └── database-schema-as-built **数据库设计落地版**(对照实现代码的事实参考)
|
||||
└── 02-rollout/ 落地路线 + 集成轨道
|
||||
├── phased-rollout-by-scale **Stage 0–4 主线** 路线图
|
||||
├── stage-0-code-map Stage 0 现状代码地图(行号锚点)
|
||||
└── headless-api-track 业务系统集成轨道(Pattern A / B)
|
||||
│ ├── database-schema-as-built **数据库设计落地版**(对照实现代码的事实参考)
|
||||
│ └── stage-1-headless-api-pattern-a-auth-foundation-design **Stage 1 鉴权地基设计**(Pattern A)
|
||||
├── 02-rollout/ 落地路线 + 集成轨道
|
||||
│ ├── phased-rollout-by-scale **Stage 0–4 主线** 路线图
|
||||
│ ├── stage-0-code-map Stage 0 现状代码地图(行号锚点)
|
||||
│ └── headless-api-track 业务系统集成轨道(Pattern A / B)
|
||||
└── 03-impl/ **执行记录层**(进度 + 各 PR 落地笔记)
|
||||
├── STATUS ★ **唯一进度权威**("现在到哪了")
|
||||
└── pr1..pr8 Stage 0 各 PR impl note(postgres / workspaces / auth / 业务表 / 路由 / boundary / 三表 schema)
|
||||
```
|
||||
|
||||
> 命名约定:全部 `.zh-CN.md` 后缀;`01-redesign` 用语义名(`adr-*` / `*-design`),`03-impl` 用 `prN-*` / `STATUS` 顺序名。
|
||||
|
||||
---
|
||||
|
||||
## 1. ADR 与配套文档状态表
|
||||
@@ -51,6 +59,7 @@ docs/multi-tenant-redesign/
|
||||
| 审计 | [ADR vs 代码](./01-redesign/adr-vs-code-audit.zh-CN.md) | 已结论 | 2026-05-09 | 代码库 0 处 `tenant`;Better Auth 不存在;ObjectStorage / KMS / Postgres 测试夹具全缺;底座先行 §3.5 |
|
||||
| 锁定 | [workspace-schema-design](./01-redesign/workspace-schema-design.zh-CN.md) | **Stage 0 锁定版** | 2026-05-10 | `workspace_id` 命名 + 7 项不可逆决策;Stage 0 PR1 动手前必读 |
|
||||
| 参考 | [database-schema-as-built](./01-redesign/database-schema-as-built.zh-CN.md) | **落地版(as-built)** | 2026-06-27 | 对照实现代码的 10 张表全字段 / 外键 / 索引 / 迁移参考;与锁定版冲突以本文为准 |
|
||||
| 设计 | [stage-1-headless-api-…-design](./01-redesign/stage-1-headless-api-pattern-a-auth-foundation-design.zh-CN.md) | **Stage 1 设计稿** | 2026-06-28 | Pattern A 鉴权地基 5 PR(三表仓储+token / APIKeyAuthBackend 双路径 / CSRF skip on bearer / 管理 endpoint / `/api/v1` 全量迁移)+ 5 项决策(D1-D5) |
|
||||
| 计划 | [phase-0-plan](./01-redesign/multi-tenant-phase-0-plan.zh-CN.md) | 计划 | 2026-05-09 | Phase-0 时间盒 3 周;含底座先行(§3.5) |
|
||||
| 路线 | [phased-rollout-by-scale](./02-rollout/phased-rollout-by-scale.zh-CN.md) | **当前主线路线图** | 2026-05-09 | Stage 0–4 + 触发/退出/时间盒/Go-No-Go |
|
||||
| 锚点 | [stage-0-code-map](./02-rollout/stage-0-code-map.zh-CN.md) | Stage 0 用 | 2026-05-09 | 当前代码文件:行号锚点 + Stage 0 改动落点 |
|
||||
@@ -58,6 +67,26 @@ docs/multi-tenant-redesign/
|
||||
|
||||
---
|
||||
|
||||
## 1.1 执行记录层(`03-impl/`)
|
||||
|
||||
> 上面 §1 是"设计 / 路线"(相对静态);本层是"实际落了什么"(随执行更新)。**进度只信 STATUS,本表只是 impl note 索引。**
|
||||
|
||||
| 文档 | 类型 | 作用 |
|
||||
|---|---|---|
|
||||
| [STATUS](./03-impl/STATUS.zh-CN.md) | ★ 进度权威 | "现在到哪了"唯一来源:8 PR 状态表、测试基线、用户必跟进项、跳过/推迟项、下一步建议。**进新 session 第一件事读它** |
|
||||
| [pr1-postgres-setup](./03-impl/pr1-postgres-setup.zh-CN.md) | impl note | Postgres 接入 + testcontainers fixture |
|
||||
| [pr2-postgres-default](./03-impl/pr2-postgres-default.zh-CN.md) | impl note | 默认 backend 切 Postgres |
|
||||
| [pr3-workspaces](./03-impl/pr3-workspaces.zh-CN.md) | impl note | `workspaces` + `workspace_memberships` 表 + 仓储 |
|
||||
| [pr4-auth-workspace](./03-impl/pr4-auth-workspace.zh-CN.md) | impl note | 注册自建 workspace + JWT 扩 `wid`/`role` |
|
||||
| [pr5-business-workspace-id](./03-impl/pr5-business-workspace-id.zh-CN.md) | impl note | 业务表加 `workspace_id` + alembic + 回填 |
|
||||
| [pr6-routes-paths-workspace](./03-impl/pr6-routes-paths-workspace.zh-CN.md) | impl note | 入口路由 + Paths 系统 workspace 化 |
|
||||
| [pr7-ci-boundary-scan](./03-impl/pr7-ci-boundary-scan.zh-CN.md) | impl note | langgraph.checkpoint boundary CI 围栏 |
|
||||
| [pr8-headless-api-schema](./03-impl/pr8-headless-api-schema.zh-CN.md) | impl note | `service_accounts`/`api_keys`/`external_users` schema only(Stage 1 地基) |
|
||||
|
||||
> Stage 1 的逐 task **实现计划**(writing-plans 产出)将落在 `docs/superpowers/plans/`,与 Stage 0 master plan 一致;其设计稿见 §1 的 stage-1 spec 行。
|
||||
|
||||
---
|
||||
|
||||
## 2. Stage 0–4 速览矩阵
|
||||
|
||||
| Stage | 触发 | 退出 | 时间盒 | 主要 ADR 章节 |
|
||||
@@ -256,9 +285,10 @@ docs/multi-tenant-redesign/
|
||||
## 7. 阅读路径建议
|
||||
|
||||
**第一次进项目(30 min)**:
|
||||
1. 本 README
|
||||
2. [00-current-state/architecture-overview](./00-current-state/architecture-overview.zh-CN.md) — 现状是什么样的
|
||||
3. [phased-rollout-by-scale](./02-rollout/phased-rollout-by-scale.zh-CN.md) §0 + §总览 + §Stage 0 — 现在在哪、下一步做什么
|
||||
1. 本 README(设计 / 路线导航)
|
||||
2. [03-impl/STATUS](./03-impl/STATUS.zh-CN.md) — **现在到哪了**(先看这个,再看下面的"为什么")
|
||||
3. [00-current-state/architecture-overview](./00-current-state/architecture-overview.zh-CN.md) — 现状是什么样的
|
||||
4. [phased-rollout-by-scale](./02-rollout/phased-rollout-by-scale.zh-CN.md) §0 + §总览 + §Stage 0 — 整体路线
|
||||
|
||||
**准备动手做 Stage 0(半天)**:
|
||||
1. [workspace-schema-design](./01-redesign/workspace-schema-design.zh-CN.md) **全文** — 不可逆决策、PR 拆分
|
||||
@@ -267,10 +297,12 @@ docs/multi-tenant-redesign/
|
||||
4. [ADR-006 §2.1](./01-redesign/adr-006-runtime-channel-tenancy.zh-CN.md) + [adr-spike-langgraph-postgres](./01-redesign/adr-spike-langgraph-postgres.zh-CN.md) — 为什么 LangGraph 表不挂 RLS
|
||||
|
||||
**准备动手做 Stage 1(一天)**:
|
||||
1. [phased-rollout Stage 1](./02-rollout/phased-rollout-by-scale.zh-CN.md) — 双轨并行
|
||||
2. [headless-api-track](./02-rollout/headless-api-track.zh-CN.md) **全文** — Pattern A/B 完整设计
|
||||
3. [ADR-003 §4.3-§4.4](./01-redesign/adr-003-llm-key-billing.zh-CN.md) — quota + 悲观预扣
|
||||
4. [ADR-002 §3](./01-redesign/adr-002-sandbox-isolation.zh-CN.md) — Stage 1 用 §3 轻量版(**不**读 §5 K8s 完整版)
|
||||
1. [03-impl/STATUS](./03-impl/STATUS.zh-CN.md) — Stage 0 收尾现状 + Stage 1 可启动方向
|
||||
2. [phased-rollout Stage 1](./02-rollout/phased-rollout-by-scale.zh-CN.md) — 双轨并行
|
||||
3. [headless-api-track](./02-rollout/headless-api-track.zh-CN.md) **全文** — Pattern A/B 完整设计
|
||||
4. [stage-1-headless-api-…-design](./01-redesign/stage-1-headless-api-pattern-a-auth-foundation-design.zh-CN.md) **全文** — Pattern A 鉴权地基设计稿(动手前必读,含 5 PR + 不可逆决策)
|
||||
5. [ADR-003 §4.3-§4.4](./01-redesign/adr-003-llm-key-billing.zh-CN.md) — quota + 悲观预扣(付费 SaaS 轨道)
|
||||
6. [ADR-002 §3](./01-redesign/adr-002-sandbox-isolation.zh-CN.md) — Stage 1 用 §3 轻量版(**不**读 §5 K8s 完整版)
|
||||
|
||||
**做安全/合规评审**:
|
||||
1. ADR-001 / ADR-002 / ADR-003 §4.6(BYO)/ ADR-004 §5.4(strict 装饰器)
|
||||
|
||||
@@ -196,7 +196,7 @@ async def postgres_url(postgres_container):
|
||||
- [ ] **T1.7 doctor.py 加 PG 探测**:仅在 `database.backend == 'postgres'` 时调 `asyncpg.connect(url)` + 报 PG version;测试 `database.backend: sqlite` 时不查 PG(regression);commit
|
||||
- [ ] **T1.8 setup_wizard.py 加交互**:选数据库后端时新增 postgres 选项 + DATABASE_URL 引导;commit
|
||||
- [ ] **T1.9 加 CI workflow**:新建 `.github/workflows/backend-postgres-tests.yml`(用 docker service 或让 testcontainers 在 GitHub runner 起 PG)跑 `pytest -m postgres -v`;本地推到 fork 验证 CI 绿;commit
|
||||
- [ ] **T1.10 验收 + 文档**:跑全套 `cd backend && make test` 验证既有 277 测试不破;`docs/multi-tenant-redesign/03-impl/pr1-postgres-setup.md` 记录 PG 版本对齐结论 + fixture 用法;commit
|
||||
- [ ] **T1.10 验收 + 文档**:跑全套 `cd backend && make test` 验证既有 277 测试不破;`docs/multi-tenant-redesign/03-impl/pr1-postgres-setup.zh-CN.md` 记录 PG 版本对齐结论 + fixture 用法;commit
|
||||
|
||||
---
|
||||
|
||||
@@ -217,7 +217,7 @@ async def postgres_url(postgres_container):
|
||||
|
||||
**新增**:
|
||||
- `scripts/migrate_sqlite_to_postgres.py` — SQLAlchemy reflection 把现有 4 张表数据搬过去
|
||||
- `docs/multi-tenant-redesign/03-impl/pr2-postgres-default.md`(implementation note,可选)
|
||||
- `docs/multi-tenant-redesign/03-impl/pr2-postgres-default.zh-CN.md`(implementation note,可选)
|
||||
|
||||
**修改**:
|
||||
- `config.example.yaml` — `database` 段默认 postgres
|
||||
@@ -1028,7 +1028,7 @@ class ExternalUserRow(Base):
|
||||
## Stage 0 退出 Go/No-Go(来自 phased-rollout-by-scale)
|
||||
|
||||
工程层面:
|
||||
- [x] PR1-PR8 全部合入 ✅(见 STATUS.md 8 PR 状态表,全 merged 进分支)
|
||||
- [x] PR1-PR8 全部合入 ✅(见 STATUS.zh-CN.md 8 PR 状态表,全 merged 进分支)
|
||||
- [x] 既有 277 + 新增 ~70 测试全 100% 通过 ✅(实际 3250 passed + 31 skipped,新增 ~163)
|
||||
- [x] CI(含 backend-postgres-tests)绿 ✅ 2026-05-12 用户确认
|
||||
- [x] 手工 smoke:注册新用户 → workspace 自动建 → JWT 含 wid → 创建 thread → 跨 workspace 互调 404 ✅ 2026-06-27 `apps/examples/http-chat/multi_tenant.py` PASS(N 租户真并发 + 多轮链式上下文 + 双向隔离 search/404;注:JWT wid claim 未显式解码断言,由隔离端到端间接覆盖)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,26 +30,38 @@ export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
return proxyRequest(request, `/api/memory/${(await params).path.join("/")}`);
|
||||
return proxyRequest(
|
||||
request,
|
||||
`/api/v1/memory/${(await params).path.join("/")}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
return proxyRequest(request, `/api/memory/${(await params).path.join("/")}`);
|
||||
return proxyRequest(
|
||||
request,
|
||||
`/api/v1/memory/${(await params).path.join("/")}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
return proxyRequest(request, `/api/memory/${(await params).path.join("/")}`);
|
||||
return proxyRequest(
|
||||
request,
|
||||
`/api/v1/memory/${(await params).path.join("/")}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
return proxyRequest(request, `/api/memory/${(await params).path.join("/")}`);
|
||||
return proxyRequest(
|
||||
request,
|
||||
`/api/v1/memory/${(await params).path.join("/")}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,9 +27,9 @@ async function proxyRequest(request: NextRequest, pathname: string) {
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyRequest(request, "/api/memory");
|
||||
return proxyRequest(request, "/api/v1/memory");
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
return proxyRequest(request, "/api/memory");
|
||||
return proxyRequest(request, "/api/v1/memory");
|
||||
}
|
||||
|
||||
@@ -408,7 +408,7 @@ export function InputBox({
|
||||
setFollowupsLoading(true);
|
||||
setFollowups([]);
|
||||
|
||||
fetch(`${getBackendBaseURL()}/api/threads/${threadId}/suggestions`, {
|
||||
fetch(`${getBackendBaseURL()}/api/v1/threads/${threadId}/suggestions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -27,20 +27,20 @@ function isAgentsApiDisabledDetail(detail: string | undefined): boolean {
|
||||
}
|
||||
|
||||
export async function listAgents(): Promise<Agent[]> {
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/agents`);
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/v1/agents`);
|
||||
if (!res.ok) throw new Error(`Failed to load agents: ${res.statusText}`);
|
||||
const data = (await res.json()) as { agents: Agent[] };
|
||||
return data.agents;
|
||||
}
|
||||
|
||||
export async function getAgent(name: string): Promise<Agent> {
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`);
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/v1/agents/${name}`);
|
||||
if (!res.ok) throw new Error(`Agent '${name}' not found`);
|
||||
return res.json() as Promise<Agent>;
|
||||
}
|
||||
|
||||
export async function createAgent(request: CreateAgentRequest): Promise<Agent> {
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/agents`, {
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/v1/agents`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
@@ -59,7 +59,7 @@ export async function updateAgent(
|
||||
name: string,
|
||||
request: UpdateAgentRequest,
|
||||
): Promise<Agent> {
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`, {
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/v1/agents/${name}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
@@ -72,7 +72,7 @@ export async function updateAgent(
|
||||
}
|
||||
|
||||
export async function deleteAgent(name: string): Promise<void> {
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`, {
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/v1/agents/${name}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
|
||||
@@ -84,7 +84,7 @@ export async function checkAgentName(
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(
|
||||
`${getBackendBaseURL()}/api/agents/check?name=${encodeURIComponent(name)}`,
|
||||
`${getBackendBaseURL()}/api/v1/agents/check?name=${encodeURIComponent(name)}`,
|
||||
);
|
||||
} catch {
|
||||
throw new AgentNameCheckError(
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function upsertFeedback(
|
||||
comment?: string,
|
||||
): Promise<FeedbackData> {
|
||||
const res = await fetch(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/runs/${encodeURIComponent(runId)}/feedback`,
|
||||
`${getBackendBaseURL()}/api/v1/threads/${encodeURIComponent(threadId)}/runs/${encodeURIComponent(runId)}/feedback`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -33,7 +33,7 @@ export async function deleteFeedback(
|
||||
runId: string,
|
||||
): Promise<void> {
|
||||
const res = await fetch(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/runs/${encodeURIComponent(runId)}/feedback`,
|
||||
`${getBackendBaseURL()}/api/v1/threads/${encodeURIComponent(threadId)}/runs/${encodeURIComponent(runId)}/feedback`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok && res.status !== 404) {
|
||||
|
||||
@@ -15,7 +15,7 @@ export function urlOfArtifact({
|
||||
if (isMock) {
|
||||
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
|
||||
}
|
||||
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
|
||||
return `${getBackendBaseURL()}/api/v1/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
|
||||
}
|
||||
|
||||
export function extractArtifactsFromThread(thread: AgentThread) {
|
||||
@@ -23,5 +23,5 @@ export function extractArtifactsFromThread(thread: AgentThread) {
|
||||
}
|
||||
|
||||
export function resolveArtifactURL(absolutePath: string, threadId: string) {
|
||||
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${absolutePath}`;
|
||||
return `${getBackendBaseURL()}/api/v1/threads/${threadId}/artifacts${absolutePath}`;
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@ import { getBackendBaseURL } from "@/core/config";
|
||||
import type { MCPConfig } from "./types";
|
||||
|
||||
export async function loadMCPConfig() {
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/mcp/config`);
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/v1/mcp/config`);
|
||||
return response.json() as Promise<MCPConfig>;
|
||||
}
|
||||
|
||||
export async function updateMCPConfig(config: MCPConfig) {
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/mcp/config`, {
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/v1/mcp/config`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -81,12 +81,12 @@ async function readMemoryResponse(
|
||||
}
|
||||
|
||||
export async function loadMemory(): Promise<UserMemory> {
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/memory`);
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/v1/memory`);
|
||||
return readMemoryResponse(response, "Failed to fetch memory");
|
||||
}
|
||||
|
||||
export async function clearMemory(): Promise<UserMemory> {
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/memory`, {
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/v1/memory`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return readMemoryResponse(response, "Failed to clear memory");
|
||||
@@ -94,7 +94,7 @@ export async function clearMemory(): Promise<UserMemory> {
|
||||
|
||||
export async function deleteMemoryFact(factId: string): Promise<UserMemory> {
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/memory/facts/${encodeURIComponent(factId)}`,
|
||||
`${getBackendBaseURL()}/api/v1/memory/facts/${encodeURIComponent(factId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
@@ -103,12 +103,12 @@ export async function deleteMemoryFact(factId: string): Promise<UserMemory> {
|
||||
}
|
||||
|
||||
export async function exportMemory(): Promise<UserMemory> {
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/memory/export`);
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/v1/memory/export`);
|
||||
return readMemoryResponse(response, "Failed to export memory");
|
||||
}
|
||||
|
||||
export async function importMemory(memory: UserMemory): Promise<UserMemory> {
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/memory/import`, {
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/v1/memory/import`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -121,7 +121,7 @@ export async function importMemory(memory: UserMemory): Promise<UserMemory> {
|
||||
export async function createMemoryFact(
|
||||
input: MemoryFactInput,
|
||||
): Promise<UserMemory> {
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/memory/facts`, {
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/v1/memory/facts`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -136,7 +136,7 @@ export async function updateMemoryFact(
|
||||
input: MemoryFactPatchInput,
|
||||
): Promise<UserMemory> {
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/memory/facts/${encodeURIComponent(factId)}`,
|
||||
`${getBackendBaseURL()}/api/v1/memory/facts/${encodeURIComponent(factId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getBackendBaseURL } from "../config";
|
||||
import type { ModelsResponse } from "./types";
|
||||
|
||||
export async function loadModels(): Promise<ModelsResponse> {
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/models`);
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/v1/models`);
|
||||
const data = (await res.json()) as Partial<ModelsResponse>;
|
||||
return {
|
||||
models: data.models ?? [],
|
||||
|
||||
@@ -4,14 +4,14 @@ import { getBackendBaseURL } from "@/core/config";
|
||||
import type { Skill } from "./type";
|
||||
|
||||
export async function loadSkills() {
|
||||
const skills = await fetch(`${getBackendBaseURL()}/api/skills`);
|
||||
const skills = await fetch(`${getBackendBaseURL()}/api/v1/skills`);
|
||||
const json = await skills.json();
|
||||
return json.skills as Skill[];
|
||||
}
|
||||
|
||||
export async function enableSkill(skillName: string, enabled: boolean) {
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/skills/${skillName}`,
|
||||
`${getBackendBaseURL()}/api/v1/skills/${skillName}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
@@ -39,7 +39,7 @@ export interface InstallSkillResponse {
|
||||
export async function installSkill(
|
||||
request: InstallSkillRequest,
|
||||
): Promise<InstallSkillResponse> {
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/skills/install`, {
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/v1/skills/install`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -570,7 +570,7 @@ export function useThreadHistory(threadId: string) {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result: { data: RunMessage[]; hasMore: boolean } = await fetch(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadIdRef.current)}/runs/${encodeURIComponent(run.run_id)}/messages`,
|
||||
`${getBackendBaseURL()}/api/v1/threads/${encodeURIComponent(threadIdRef.current)}/runs/${encodeURIComponent(run.run_id)}/messages`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
@@ -721,7 +721,7 @@ export function useDeleteThread() {
|
||||
await apiClient.threads.delete(threadId);
|
||||
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`,
|
||||
`${getBackendBaseURL()}/api/v1/threads/${encodeURIComponent(threadId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
|
||||
@@ -52,7 +52,7 @@ export async function uploadFiles(
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/threads/${threadId}/uploads`,
|
||||
`${getBackendBaseURL()}/api/v1/threads/${threadId}/uploads`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
@@ -73,7 +73,7 @@ export async function listUploadedFiles(
|
||||
threadId: string,
|
||||
): Promise<ListFilesResponse> {
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/threads/${threadId}/uploads/list`,
|
||||
`${getBackendBaseURL()}/api/v1/threads/${threadId}/uploads/list`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -93,7 +93,7 @@ export async function deleteUploadedFile(
|
||||
filename: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/threads/${threadId}/uploads/${filename}`,
|
||||
`${getBackendBaseURL()}/api/v1/threads/${threadId}/uploads/${filename}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user