diff --git a/models/rag_api_keys.json b/models/rag_api_keys.json deleted file mode 100644 index 937fbf5..0000000 --- a/models/rag_api_keys.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "summary": [ - { - "name": "rag_api_keys", - "title": "对外API密钥", - "primary": [ - "id" - ] - } - ], - "fields": [ - { - "name": "id", - "title": "ID", - "type": "str", - "length": 32, - "nullable": "no" - }, - { - "name": "org_id", - "title": "所属机构", - "type": "str", - "length": 32, - "nullable": "no" - }, - { - "name": "name", - "title": "密钥名称", - "type": "str", - "length": 100, - "nullable": "yes" - }, - { - "name": "key_hash", - "title": "密钥哈希(SHA256)", - "type": "str", - "length": 64, - "nullable": "no" - }, - { - "name": "prefix", - "title": "密钥前缀(展示)", - "type": "str", - "length": 16, - "nullable": "yes" - }, - { - "name": "scopes", - "title": "权限范围", - "type": "str", - "length": 500, - "nullable": "yes", - "default": "" - }, - { - "name": "status", - "title": "状态", - "type": "str", - "length": 16, - "nullable": "yes", - "default": "active" - }, - { - "name": "expires_at", - "title": "过期时间", - "type": "datetime", - "nullable": "yes" - }, - { - "name": "last_used_at", - "title": "最近使用", - "type": "datetime", - "nullable": "yes" - }, - { - "name": "created_at", - "title": "创建时间", - "type": "datetime", - "nullable": "yes" - } - ], - "indexes": [ - { - "name": "idx_ak_hash", - "idxtype": "unique", - "idxfields": [ - "key_hash" - ] - }, - { - "name": "idx_ak_org", - "idxtype": "index", - "idxfields": [ - "org_id" - ] - } - ] -} diff --git a/rag/api_core.py b/rag/api_core.py index 16414b6..6e7015c 100644 --- a/rag/api_core.py +++ b/rag/api_core.py @@ -1,89 +1,68 @@ # -*- coding:utf-8 -*- -"""RAG 对外 API 核心(B2B 机器接口)— Bearer Key 鉴权 + org 注入。 +"""RAG 对外 API 核心(B2B 机器接口)— dapi Bearer Key 鉴权 + org 注入。 与 UI 通道的关系: - UI 通道(wwwroot/knowledge_bases_list/*.dspy):RBAC 登录会话鉴权,org/user 取自会话; - API 通道(wwwroot/api/*.dspy → 本模块):路由/权限照常由 RBAC 控制 - (端点授 any),业务身份由 rag_api_keys 的 Bearer Key 决定(绑定 org_id)。 + (端点授 any),业务身份由 **dapi 模块**的 Bearer Key 决定(平台统一 key 管理, + rag 不自建 key 表)。 -org 注入方式:构造一个轻量 env(DictObject),get_userorgid/get_user 返回 key 绑定的 +鉴权链路:调用方在 dapi 申请 downapikey(绑定 users 账号)→ 本模块 verify_api_key() +调 dapi.get_apikey_user 校验(有效性/过期/IP白名单都在 dapi)→ 从认证用户取 orgid +→ 机构隔离以该 org 为边界。 + +org 注入方式:构造一个轻量 env(DictObject),get_userorgid/get_user 返回 key 用户所属 机构,get_module_dbname 等透传全局 ServerEnv —— 复用 init.py 的底层能力 (_resolve_search_kbs/_build_search_vector/_call_uapi 等),杜绝双实现分叉。 权限语义(v1): -- key 即机构级凭据:org 隔离强制(只能操作本机构知识库),KB 级 maintain_roles/ - search_roles 是人类通道的门槛,对 API key 不生效(key 的授权面由 scopes 控制); -- scopes 逗号分隔:kb.create / kb.delete / doc.upload / doc.delete / tag.write / search; - '*' 全通。 +- key 即用户级凭据:org 隔离强制(只能操作本机构知识库),能力面等于该用户在本机构 + 的知识库操作范围;不再做 rag 侧 scopes(授权粒度归 dapi 账号管理)。 -2026-09-03 新增(配合 wwwroot/api/ 六个对外端点)。 +2026-09-03 新增(配合 wwwroot/api/ 七个对外端点;key 管理复用 dapi 模块)。 """ import json import os -import time import uuid as _uuid -from hashlib import sha256 from ahserver.serverenv import ServerEnv from appPublic.dictObject import DictObject from sqlor.dbpools import get_sor_context -# ────────────────────────── API Key 鉴权 ────────────────────────── - -def hash_key(plain): - return sha256((plain or '').encode('utf-8')).hexdigest() - +# ────────────────────────── API Key 鉴权(dapi 统一管理) ────────────────────────── async def verify_api_key(request): - """从 Authorization: Bearer 或 x-api-key: 验证调用方。 + """用 dapi 模块验证调用方 Bearer Key(downapp/downapikey/users 三表)。 + + 平台级机制:key 有效性、过期、IP 白名单、登录态、downappuser 角色 全由 + dapi.apikey_user 负责;rag 只消费鉴权结果,从认证用户反查 org_id。 返回 (ctx_dict, None) 或 (None, error_message)。 - ctx = {org_id, key_id, name, scopes, user_id} + ctx = {org_id, user_id, username} """ - token = '' auth = request.headers.get('Authorization', '') or '' - if auth.startswith('Bearer '): - token = auth[7:].strip() + token = auth[7:].strip() if auth.startswith('Bearer ') else '' if not token: token = (request.headers.get('x-api-key', '') or '').strip() if not token: - return None, "missing api key(Authorization: Bearer 或 x-api-key 头)" + return None, "missing api key(Authorization: Bearer *** 或 x-api-key 头)" + + from dapi.dapi import get_apikey_user env = ServerEnv() - async with get_sor_context(env, 'rag') as sor: - recs = await sor.sqlExe( - "SELECT id, org_id, name, scopes, status, expires_at FROM rag_api_keys " - "WHERE key_hash=${h}$ LIMIT 1", {"h": hash_key(token)}) + dbname = env.get_module_dbname('dapi') + client_ip = (request.get('client_ip') if hasattr(request, 'get') else '') or '' + async with get_sor_context(env, dbname) as sor: + user = await get_apikey_user(sor, token, client_ip) await sor.sqlExe("COMMIT", {}) - if not recs: - return None, "invalid api key" - r = recs[0] - if (getattr(r, 'status', '') or '') != 'active': - return None, "api key disabled" - exp = getattr(r, 'expires_at', None) - if exp is not None and str(exp).strip() and time.strftime('%Y-%m-%d %H:%M:%S') >= str(exp): - return None, "api key expired" - scopes = [s.strip() for s in str(getattr(r, 'scopes', '') or '').split(',') if s.strip()] - key_id = getattr(r, 'id', '') - # 最近使用时间(best-effort,失败不影响调用) - try: - await sor.sqlExe( - "UPDATE rag_api_keys SET last_used_at=NOW() WHERE id=${i}$", {"i": key_id}) - await sor.sqlExe("COMMIT", {}) - except Exception: - try: - await sor.sqlExe("COMMIT", {}) - except Exception: - pass - return {"org_id": str(getattr(r, 'org_id', '') or ''), "key_id": key_id, - "name": str(getattr(r, 'name', '') or ''), "scopes": scopes, - "user_id": ''}, None - - -def require_scope(ctx, scope): - sc = ctx.get('scopes') or [] - return '*' in sc or scope in sc + if user is None: + return None, "invalid api key" + org_id = str(getattr(user, 'orgid', '') or getattr(user, 'org_id', '') or '') + if not org_id: + return None, "api key user has no org" + return {"org_id": org_id, "user_id": str(getattr(user, 'id', '') or ''), + "username": str(getattr(user, 'username', '') or '')}, None async def read_json_body(request, params_kw): diff --git a/scripts/load_path.py b/scripts/load_path.py index e59f5bd..9deb011 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -13,8 +13,8 @@ PATHS_ANY = [ f"/{MOD}/knowledge_bases_list/upload.js", ] -# 对外 B2B API 端点(路由权限授 any;业务鉴权靠 rag_api_keys 的 Bearer Key, -# org 隔离与 scopes 在 rag/api_core.py 内强制——见模块 README「对外 API」) +# 对外 B2B API 端点(路由权限授 any;业务鉴权靠 dapi 模块统一管理的 Bearer Key, +# org 隔离在 rag/api_core.py 内强制——见模块 README「对外 API」) PATHS_API_ANY = [ f"/{MOD}/api/kb_create.dspy", f"/{MOD}/api/kb_delete.dspy", @@ -63,10 +63,7 @@ PATHS_LOGINED = [ # 存储用量 f"/{MOD}/knowledge_bases_list/storage_card.dspy", f"/{MOD}/knowledge_bases_list/storage_stats.dspy", - # 对外 API Key 管理(管理端,登录会话鉴权) - f"/{MOD}/knowledge_bases_list/api_key_create.dspy", - f"/{MOD}/knowledge_bases_list/api_key_list.dspy", - f"/{MOD}/knowledge_bases_list/api_key_revoke.dspy", + # (API Key 管理走平台 dapi 模块:/dapi/ 的 key 申请/管理 UI,rag 不自建 key 端点) # CRUD 管理页 f"/{MOD}/documents_list/index.ui", f"/{MOD}/engine_configs_list/index.ui", diff --git a/wwwroot/api/doc_delete.dspy b/wwwroot/api/doc_delete.dspy index 5507efe..7a766dd 100644 --- a/wwwroot/api/doc_delete.dspy +++ b/wwwroot/api/doc_delete.dspy @@ -6,7 +6,5 @@ _ctx, _e = await C.verify_api_key(request) if _e: return C._err(_e, code="unauthorized") _ns = await C.read_json_body(request, params_kw) -if not C.require_scope(_ctx, "doc.delete"): - return C._err("api key lacks scope: doc.delete", code="forbidden") env = C.make_api_env(_ctx) return await C.doc_delete(env, _ns) diff --git a/wwwroot/api/doc_set_tags.dspy b/wwwroot/api/doc_set_tags.dspy index 3008121..3ce88f5 100644 --- a/wwwroot/api/doc_set_tags.dspy +++ b/wwwroot/api/doc_set_tags.dspy @@ -6,7 +6,5 @@ _ctx, _e = await C.verify_api_key(request) if _e: return C._err(_e, code="unauthorized") _ns = await C.read_json_body(request, params_kw) -if not C.require_scope(_ctx, "tag.write"): - return C._err("api key lacks scope: tag.write", code="forbidden") env = C.make_api_env(_ctx) return await C.doc_set_tags(env, _ns) diff --git a/wwwroot/api/doc_upload.dspy b/wwwroot/api/doc_upload.dspy index 23a6b11..135309b 100644 --- a/wwwroot/api/doc_upload.dspy +++ b/wwwroot/api/doc_upload.dspy @@ -5,8 +5,6 @@ from rag import api_core as C _ctx, _e = await C.verify_api_key(request) if _e: return C._err(_e, code="unauthorized") -if not C.require_scope(_ctx, "doc.upload"): - return C._err("api key lacks scope: doc.upload", code="forbidden") _ns = dict(params_kw or {}) _file_data = await request.read() _file_name = _ns.get('file_name', '') or 'upload.bin' diff --git a/wwwroot/api/kb_create.dspy b/wwwroot/api/kb_create.dspy index 45359b9..4d7642d 100644 --- a/wwwroot/api/kb_create.dspy +++ b/wwwroot/api/kb_create.dspy @@ -6,7 +6,5 @@ _ctx, _e = await C.verify_api_key(request) if _e: return C._err(_e, code="unauthorized") _ns = await C.read_json_body(request, params_kw) -if not C.require_scope(_ctx, "kb.create"): - return C._err("api key lacks scope: kb.create", code="forbidden") env = C.make_api_env(_ctx) return await C.kb_create(env, _ns) diff --git a/wwwroot/api/kb_delete.dspy b/wwwroot/api/kb_delete.dspy index ee9f7b2..5827b7d 100644 --- a/wwwroot/api/kb_delete.dspy +++ b/wwwroot/api/kb_delete.dspy @@ -6,7 +6,5 @@ _ctx, _e = await C.verify_api_key(request) if _e: return C._err(_e, code="unauthorized") _ns = await C.read_json_body(request, params_kw) -if not C.require_scope(_ctx, "kb.delete"): - return C._err("api key lacks scope: kb.delete", code="forbidden") env = C.make_api_env(_ctx) return await C.kb_delete(env, _ns) diff --git a/wwwroot/api/search.dspy b/wwwroot/api/search.dspy index 13ebe20..5e9ddbb 100644 --- a/wwwroot/api/search.dspy +++ b/wwwroot/api/search.dspy @@ -6,7 +6,5 @@ _ctx, _e = await C.verify_api_key(request) if _e: return C._err(_e, code="unauthorized") _ns = await C.read_json_body(request, params_kw) -if not C.require_scope(_ctx, "search"): - return C._err("api key lacks scope: search", code="forbidden") env = C.make_api_env(_ctx) return await C.search(env, _ns) diff --git a/wwwroot/api/tag_create.dspy b/wwwroot/api/tag_create.dspy index 240c9fe..9642149 100644 --- a/wwwroot/api/tag_create.dspy +++ b/wwwroot/api/tag_create.dspy @@ -6,7 +6,5 @@ _ctx, _e = await C.verify_api_key(request) if _e: return C._err(_e, code="unauthorized") _ns = await C.read_json_body(request, params_kw) -if not C.require_scope(_ctx, "tag.write"): - return C._err("api key lacks scope: tag.write", code="forbidden") env = C.make_api_env(_ctx) return await C.tag_create(env, _ns) diff --git a/wwwroot/knowledge_bases_list/api_key_create.dspy b/wwwroot/knowledge_bases_list/api_key_create.dspy deleted file mode 100644 index 0632568..0000000 --- a/wwwroot/knowledge_bases_list/api_key_create.dspy +++ /dev/null @@ -1,42 +0,0 @@ -# 管理端:创建对外 API Key(登录用户;org 取自会话) -# POST /rag/knowledge_bases_list/api_key_create.dspy -# params: {name, scopes?, expires_days?} -# 返回 widget:一次性展示明文 key(库里只存 SHA256) -import secrets -from rag import api_core as C - -ns = params_kw.copy() -env = request._run_ns -org_id = await env.get_userorgid() -name = (ns.get('name') or '默认密钥').strip() -scopes = ns.get('scopes') or '' -if isinstance(scopes, (list, tuple)): - scopes = ','.join(str(x) for x in scopes) -scopes = str(scopes).strip() or 'kb.create,kb.delete,doc.upload,doc.delete,tag.write,search' -try: - exp_days = int(ns.get('expires_days') or 0) -except (TypeError, ValueError): - exp_days = 0 - -plain = 'rag-' + secrets.token_hex(24) -kid = str(uuid()).replace('-', '')[:16] -db = DBPools() -async with db.sqlorContext(get_module_dbname('rag')) as sor: - if exp_days > 0: - await sor.sqlExe( - "INSERT INTO rag_api_keys (id, org_id, name, key_hash, prefix, scopes, status, expires_at, created_at) " - "VALUES (${id}$, ${o}$, ${n}$, ${h}$, ${p}$, ${s}$, 'active', DATE_ADD(NOW(), INTERVAL ${d}$ DAY), NOW())", - {"id": kid, "o": org_id, "n": name, "h": C.hash_key(plain), - "p": plain[:12], "s": scopes, "d": exp_days}) - else: - await sor.sqlExe( - "INSERT INTO rag_api_keys (id, org_id, name, key_hash, prefix, scopes, status, created_at) " - "VALUES (${id}$, ${o}$, ${n}$, ${h}$, ${p}$, ${s}$, 'active', NOW())", - {"id": kid, "o": org_id, "n": name, "h": C.hash_key(plain), "p": plain[:12], "s": scopes}) - await sor.sqlExe("COMMIT", {}) - -return {"widgettype": "VBox", "options": {"padding": "12px", "spacing": "8px"}, "subwidgets": [ - {"widgettype": "Text", "options": {"text": "✅ API Key 已创建(仅此一次展示,请立即保存)", "cfontsize": 15, "color": "#10b981"}}, - {"widgettype": "Text", "options": {"text": plain, "cfontsize": 13, "bgcolor": "#f5f7fa", "padding": "8px", "css": "selectable monospace"}}, - {"widgettype": "Text", "options": {"text": "名称: " + name + " · 范围: " + scopes, "cfontsize": 12, "color": "#888"}}, -]} diff --git a/wwwroot/knowledge_bases_list/api_key_list.dspy b/wwwroot/knowledge_bases_list/api_key_list.dspy deleted file mode 100644 index 3babf50..0000000 --- a/wwwroot/knowledge_bases_list/api_key_list.dspy +++ /dev/null @@ -1,35 +0,0 @@ -# 管理端:API Key 列表(本机构;只展示前缀,不回显明文) -# GET /rag/knowledge_bases_list/api_key_list.dspy -ns = params_kw.copy() -env = request._run_ns -org_id = await env.get_userorgid() -rows = [] -db = DBPools() -async with db.sqlorContext(get_module_dbname('rag')) as sor: - recs = await sor.sqlExe( - "SELECT id, name, prefix, scopes, status, last_used_at, expires_at, created_at " - "FROM rag_api_keys WHERE org_id=${o}$ ORDER BY created_at DESC", {"o": org_id}) - await sor.sqlExe("COMMIT", {}) - for r in (recs or []): - st = getattr(r, 'status', '') - badge = '🟢' if st == 'active' else '⛔' - exp = str(getattr(r, 'expires_at', '') or '') - exp_s = ('至 ' + exp[:10]) if exp and exp != 'None' else '永久' - used = str(getattr(r, 'last_used_at', '') or '') - used_s = used[:16] if used and used != 'None' else '从未使用' - rows.append({"widgettype": "HBox", "options": {"spacing": "12px", "padding": "8px 12px", "borderBottom": "1px solid #eee"}, - "subwidgets": [ - {"widgettype": "Text", "options": {"text": badge + " " + str(getattr(r, 'name', '') or ''), "cfontsize": 13}}, - {"widgettype": "Text", "options": {"text": str(getattr(r, 'prefix', '')) + '…', "cfontsize": 12, "color": "#888"}}, - {"widgettype": "Text", "options": {"text": str(getattr(r, 'scopes', '') or ''), "cfontsize": 11, "color": "#aaa"}}, - {"widgettype": "Text", "options": {"text": exp_s + " · " + used_s, "cfontsize": 11, "color": "#aaa"}}, - {"widgettype": "Button", "options": {"label": ("禁用" if st == 'active' else "启用"), "cfontsize": 11}, - "binds": [{"wid": "self", "event": "click", "actiontype": "ajax", - "options": {"url": entire_url('./api_key_revoke.dspy'), - "params": {"key_id": str(getattr(r, 'id', '')), - "action": ("disable" if st == 'active' else "enable")}, - "success": "refresh"}}]}, - ]}) -if not rows: - rows = [{"widgettype": "Text", "options": {"text": "暂无 API Key", "color": "#aaa"}}] -return {"widgettype": "VBox", "options": {"width": "100%"}, "subwidgets": rows} diff --git a/wwwroot/knowledge_bases_list/api_key_revoke.dspy b/wwwroot/knowledge_bases_list/api_key_revoke.dspy deleted file mode 100644 index fe0a679..0000000 --- a/wwwroot/knowledge_bases_list/api_key_revoke.dspy +++ /dev/null @@ -1,24 +0,0 @@ -# 管理端:启用/禁用 API Key(只能操作本机构) -# POST /rag/knowledge_bases_list/api_key_revoke.dspy params: {key_id, action=disable|enable|delete} -ns = params_kw.copy() -env = request._run_ns -org_id = await env.get_userorgid() -key_id = (ns.get('key_id') or '').strip() -action = (ns.get('action') or 'disable').strip() -if not key_id: - return json.dumps({"status": "error", "error": "key_id required"}) -db = DBPools() -async with db.sqlorContext(get_module_dbname('rag')) as sor: - recs = await sor.sqlExe( - "SELECT id FROM rag_api_keys WHERE id=${k}$ AND org_id=${o}$", {"k": key_id, "o": org_id}) - await sor.sqlExe("COMMIT", {}) - if not recs: - return json.dumps({"status": "error", "error": "key not found in your org"}) - if action == 'delete': - await sor.sqlExe("DELETE FROM rag_api_keys WHERE id=${k}$", {"k": key_id}) - elif action == 'enable': - await sor.sqlExe("UPDATE rag_api_keys SET status='active' WHERE id=${k}$", {"k": key_id}) - else: - await sor.sqlExe("UPDATE rag_api_keys SET status='disabled' WHERE id=${k}$", {"k": key_id}) - await sor.sqlExe("COMMIT", {}) -return json.dumps({"status": "SUCCEEDED", "action": action, "key_id": key_id})