perf: 新增AI插件开发受限令牌与隔离API

This commit is contained in:
xiaojunnuo
2026-08-05 21:13:40 +08:00
parent 01abf348d2
commit 3a9069f6e6
24 changed files with 238 additions and 47 deletions
@@ -43,7 +43,7 @@ Certd 插件按来源分为三类:
## API 认证
提示词会提供 Certd API 地址和当前用户 Token。调用 API 时使用:
提示词会提供 Certd API 地址和仅限 AI 插件开发接口的受限 Token。调用 API 时使用:
```http
Authorization: <token>
@@ -57,7 +57,7 @@ Content-Type: application/json
Node 请求须直接读取 UTF-8 文件或在 Node 内构造 JSON,并使用 `JSON.stringify`
```javascript
const response = await fetch(`${apiBase}/sys/plugin/find`, {
const response = await fetch(`${apiBase}/scoped/sys/ai/plugin/find`, {
method: "POST",
headers: { Authorization: token, "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ keywords: ["nginx"], includeBuiltIn: true, includeStore: true }),
@@ -66,28 +66,28 @@ const response = await fetch(`${apiBase}/sys/plugin/find`, {
## UTF-8 保存
在 Windows 上,Node 直接以 UTF-8 读取 YAML 并用 `JSON.stringify` 发送;不要让 PowerShell 转发含中文的 YAML/JSON。保存后检查中文字段不含 `?`,再调用 `/sys/plugin/find``/sys/plugin/info` 验证。
在 Windows 上,Node 直接以 UTF-8 读取 YAML 并用 `JSON.stringify` 发送;不要让 PowerShell 转发含中文的 YAML/JSON。保存后检查中文字段不含 `?`,再调用 `/scoped/sys/ai/plugin/find``/scoped/sys/ai/plugin/info` 验证。
## API 工作流
1. 使用 `/sys/plugin/find` 查询插件和 Access,可通过 `keywords` 数组传递多个关键词。
1. 使用 `/scoped/sys/ai/plugin/find` 查询插件和 Access,可通过 `keywords` 数组传递多个关键词。
2. 查询结果包含 `editable`
- `editable: true`:允许当前 Agent 修改并保存。
- `editable: false`:只能读取和使用,不能修改。
3. 读取完整 YAML 时调用 `/sys/plugin/export`
4. 所有插件保存统一调用 `/sys/plugin/import`,并始终传递完整 YAML
3. 读取完整 YAML 时调用 `/scoped/sys/ai/plugin/export`
4. 所有插件保存统一调用 `/scoped/sys/ai/plugin/import`,并始终传递完整 YAML
- 新插件使用 `override: false`
- 已有插件使用 `override: true`;导入接口根据 `author``name` 定位并覆盖已有记录。
5. 不使用 `/sys/plugin/add``/sys/plugin/update` 保存插件,避免保存路径分叉、字段丢失和 Windows 请求兼容性问题。
6. 保存完成后重新调用 `/sys/plugin/find``/sys/plugin/info` 验证结果。
6. 保存完成后重新调用 `/scoped/sys/ai/plugin/find``/scoped/sys/ai/plugin/info` 验证结果。
`/sys/plugin/find` 会在一次请求中分别查询内置插件和 `store` 插件,再合并返回;`store` 插件需按上述字段区分市场插件与本地插件。
`/scoped/sys/ai/plugin/find` 会在一次请求中分别查询内置插件和 `store` 插件,再合并返回;`store` 插件需按上述字段区分市场插件与本地插件。
详细请求字段见 `references/certd-api.md`
## Access 协作
开发 Task 或 DNS Provider 前,先用 `/sys/plugin/find` 查询对应 Access
开发 Task 或 DNS Provider 前,先用 `/scoped/sys/ai/plugin/find` 查询对应 Access
1. 如果没有对应 Access,先创建 Access 插件,再创建业务插件。
2. 如果已有 Access,先读取它的完整 YAML 和 `content`
@@ -4,9 +4,9 @@ Access 插件负责保存授权配置,也负责封装平台 API/SDK,供 Task
## 查询顺序
1. 调用 `/sys/plugin/find`,使用 `pluginType: access`
1. 调用 `/scoped/sys/ai/plugin/find`,使用 `pluginType: access`
2. 根据 `name``author``fullName` 识别目标 Access。
3. 使用 `/sys/plugin/export` 读取完整 YAML。
3. 使用 `/scoped/sys/ai/plugin/export` 读取完整 YAML。
4. 检查 `content` 中已经提供的方法。
## 修改规则
@@ -5,7 +5,7 @@
## 查询插件
```http
POST /sys/plugin/find
POST /scoped/sys/ai/plugin/find
Content-Type: application/json
Authorization: <token>
```
@@ -28,19 +28,19 @@ Authorization: <token>
- `type: "store"` 且没有 `appId``developerId`:本地插件。
结果中的 `editable` 是唯一的编辑权限依据;不能只按插件来源判断是否可修改。
列表结果只返回插件基础信息,不返回 `content``setting``sysSetting``metadata``extra`。需要完整 YAML 时再调用 `/sys/plugin/export`
列表结果只返回插件基础信息,不返回 `content``setting``sysSetting``metadata``extra`。需要完整 YAML 时再调用 `/scoped/sys/ai/plugin/export`
## 读取插件信息
```http
POST /sys/plugin/info?id=12
POST /scoped/sys/ai/plugin/info?id=12
Authorization: <token>
```
## 导出完整 YAML
```http
POST /sys/plugin/export
POST /scoped/sys/ai/plugin/export
Content-Type: application/json
Authorization: <token>
```
@@ -53,22 +53,10 @@ Authorization: <token>
## 保存插件
已有插件使用:
使用完整 YAML 导入
```http
POST /sys/plugin/update
```
新插件使用:
```http
POST /sys/plugin/add
```
也可以使用完整 YAML 导入:
```http
POST /sys/plugin/import
POST /scoped/sys/ai/plugin/import
```
```json
@@ -79,4 +67,4 @@ POST /sys/plugin/import
}
```
保存后重新调用 `/sys/plugin/find``/sys/plugin/info` 验证。
保存后重新调用 `/scoped/sys/ai/plugin/find``/scoped/sys/ai/plugin/info` 验证。
@@ -22,4 +22,4 @@
- `change.md` 只记录插件 ID、版本、时间和脱敏修改摘要。
- 不保存 Token、证书、私钥、Cookie、环境变量和真实授权值。
- 恢复历史版本前先备份当前 YAML。
- 恢复后通过 `/sys/plugin/update``/sys/plugin/import` 写回 Certd。
- 恢复后通过 `/scoped/sys/ai/plugin/import` 写回 Certd。
@@ -51,4 +51,4 @@ return class DemoTask extends AbstractTaskPlugin {
-`this.ctx.http` 请求远程 API,用 `this.getAccess` 获取授权。
- 外部 API 返回失败或业务失败时抛出异常。
- 对重复执行保持幂等,避免把真实 Token、证书和私钥写入日志。
- 修改完成后把整个 YAML 通过 Certd `/sys/plugin/update``/sys/plugin/import` 保存。
- 修改完成后把整个 YAML 通过 Certd `/scoped/sys/ai/plugin/import` 保存。
+1
View File
@@ -85,6 +85,7 @@ Certd 是可私有化部署的 SSL/TLS 证书自动化管理平台,提供 Web
- 务必写单元测试,覆盖主要业务逻辑。
- 实现新功能或修复行为缺陷前,优先补单元测试并先确认红灯,再实现并跑聚焦验证。
- 单元测试应优先直接测试原始业务方法,不要为了方便测试而抽取没有业务价值的 helper;数据库、RPC 和其他外部依赖可以使用 mock 隔离。
- 确实不适合先写测试时,在回复中说明原因和替代验证方式。
- 后补单元测试时,按正确行为写预期;若红灯需要修改既有实现,先向用户确认这是 bug 还是既有需求,避免未经确认改变行为。
- 后端纯单测放在 `src/**/*.test.ts`,尽量与被测文件相邻;`test:unit` 只跑这些文件,构建/打包应排除 `*.test.ts`
@@ -28,6 +28,14 @@ export async function FindPlugins(query: {
});
}
export async function GetScopedAccessToken(scoped: string[]): Promise<{ token: string; expire: number; scoped: string[] }> {
return await request({
url: "/sys/basic/getScopedAccessToken",
method: "post",
data: { scoped },
});
}
export async function AddObj(obj: any) {
return await request({
url: apiPrefix + "/add",
@@ -21,7 +21,7 @@
<a-tag color="blue">API 模式</a-tag>
</div>
<a-textarea class="plugin-ai-dev__prompt-text" :value="prompt" readonly :rows="24" placeholder="生成后复制到 Codex 或 Trae 中运行。" />
<div class="plugin-ai-dev__prompt-warning">提示词包含 Certd 的访问 Token请勿泄露给他人</div>
<div class="plugin-ai-dev__prompt-warning">提示词使用受限且短时有效的 AI 开发 Token可正常提供给可信 Agent请勿公开或随意转发</div>
</div>
</section>
</div>
@@ -31,7 +31,6 @@
import { onMounted, ref } from "vue";
import { message } from "ant-design-vue";
import * as api from "../api";
import { useUserStore } from "/@/store/user";
import { env } from "/src/utils/util.env";
defineOptions({
@@ -43,7 +42,6 @@ const props = defineProps<{
pluginName?: string;
}>();
const userStore = useUserStore();
const requirement = ref("");
const selectedPluginId = ref<number | string | undefined>(props.pluginId);
const pluginOptions = ref<{ label: string; value: number | string }[]>([]);
@@ -93,17 +91,17 @@ async function createPrompt() {
}
creating.value = true;
try {
prompt.value = buildPrompt();
const accessToken = await api.GetScopedAccessToken(["sys/ai"]);
prompt.value = buildPrompt(accessToken.token);
message.success("启动提示词已生成");
} finally {
creating.value = false;
}
}
function buildPrompt() {
function buildPrompt(token: string) {
const pluginLabel = props.pluginName || selectedPluginId.value;
const pluginText = pluginLabel ? `当前插件:${pluginLabel}` : "当前为新插件开发";
const token = userStore.getToken || "";
const certdUrl = window.location.origin;
const apiBase = new URL(env.API || "/api", certdUrl).toString().replace(/\/$/, "");
return `你是 Certd 在线插件开发 Agent。
@@ -119,21 +117,23 @@ ${certdUrl}
Certd API 地址:
${apiBase}
当前用户 Token
受限的 Certd AccessToken(仅能访问插件查询、编辑相关的几个接口)
${token}
如果token过期,请向用户重新申请AccessToken. (让用户重新生成提示词,到里面复制上面这一串新AccessToken给你)
开发流程:
1. 开始开发前,先检查当前工作目录是否已经是 certd 项目:应能看到 package.json、packages/ui/certd-server/src/plugins/、.trae/skills/ 等特征。
2. 检查 .trae/skills/ 下是否已经有 certd-online-plugin-dev 技能。
3. 如果当前目录不是 certd 项目,或缺少该技能,则先拉取 certd 仓库代码:优先 https://atomgit.com/certd/certd/,如果 AtomGit 拉取失败,再使用 https://github.com/certd/certd。
4. 加载 .trae/skills/certd-online-plugin-dev/SKILL.md,并按插件类型加载对应子 Skill。
5. 参考 certd 项目下已有内置插件 packages/ui/certd-server/src/plugins/ 的实现方式进行开发。
6. 使用当前 Token 调用 Certd API通过 /sys/plugin/find 查询插件和 Access。
6. 使用受限 Token 调用 Certd API只能访问 /scoped/sys/ai/plugin/ 前缀接口;通过 /scoped/sys/ai/plugin/find 查询插件和 Access。
7. 开发 Task 或 DNS 插件前,先查询对应 Access,优先复用 Access 提供的 API/SDK 能力。
8. 如果没有 Access,先创建 Access 插件;如果 Access 的 editable 为 true 且缺少能力,可以先修改 Access。
9. 在当前工作区创建并使用 .tmp/online-plugin-dev 作为本次插件开发临时目录,历史记录、临时 YAML、脚本草稿和调试记录都放在该目录下。
10. 修改任何插件前,先在 .tmp/online-plugin-dev/history 下保存完整 YAML 历史记录,便于恢复。
11. 通过 Certd API 读取和保存完整插件 YAML不使用 WebSocket,不依赖浏览器草稿。
11. 读取完整 YAML 使用 /scoped/sys/ai/plugin/export保存完整 YAML 使用 /scoped/sys/ai/plugin/import不使用 WebSocket,不依赖浏览器草稿。
12. 保存完成后向用户报告 API 操作结果,不自动发布。
认证请求要求:
@@ -44,7 +44,6 @@ process.on("uncaughtException", error => {
// log()
// }127.0.0.1
// startHeapLog();
@Configuration({
detectorOptions: {
@@ -0,0 +1,40 @@
import { ALL, Body, Controller, Inject, Post, Provide, Query } from "@midwayjs/core";
import { BaseController } from "@certd/lib-server";
import { PluginFindReq, PluginImportReq, PluginService } from "../../../modules/plugin/service/plugin-service.js";
import { AuditType } from "../../../modules/sys/enterprise/service/audit-constants.js";
@Provide()
@Controller("/api/scoped/sys/ai/plugin")
export class AiPluginController extends BaseController {
@Inject()
service: PluginService;
getAuditType(): string {
return AuditType.plugin.value;
}
@Post("/find", { description: "sys:settings:view", summary: "AI 查询插件" })
async find(@Body(ALL) body: PluginFindReq) {
const res = await this.service.findPlugins(body || {});
return this.ok(res);
}
@Post("/info", { description: "sys:settings:view", summary: "AI 查询插件信息" })
async info(@Query("id") id: number) {
const res = await this.service.info(id);
return this.ok(res);
}
@Post("/export", { description: "sys:settings:view", summary: "AI 导出插件" })
async export(@Body("id") id: number) {
const res = await this.service.exportPlugin(id);
return this.ok(res);
}
@Post("/import", { description: "sys:settings:edit", summary: "AI 导入插件" })
async import(@Body(ALL) body: PluginImportReq) {
const res = await this.service.importPlugin(body);
this.auditLog({ content: "AI 导入了插件配置" });
return this.ok(res);
}
}
@@ -0,0 +1,32 @@
import { ALL, Body, Controller, Inject, Post, Provide } from "@midwayjs/core";
import { BaseController } from "@certd/lib-server";
import { LoginService } from "../../../modules/login/service/login-service.js";
import { AuditType } from "../../../modules/sys/enterprise/service/audit-constants.js";
const AI_PLUGIN_TOKEN_SCOPE = "sys/ai";
@Provide()
@Controller("/api/sys/basic")
export class SysBasicController extends BaseController {
@Inject()
loginService: LoginService;
getAuditType(): string {
return AuditType.settings.value;
}
@Post("/getScopedAccessToken", { description: "sys:settings:edit", summary: "获取 AI 插件开发访问令牌" })
async getScopedAccessToken(@Body(ALL) body: { scoped?: string[] }) {
const scoped = body?.scoped || [];
if (!Array.isArray(scoped) || scoped.length !== 1 || scoped[0] !== AI_PLUGIN_TOKEN_SCOPE) {
throw new Error(`仅支持申请 ${AI_PLUGIN_TOKEN_SCOPE} 范围的访问令牌`);
}
const user = this.ctx.user;
if (!user?.id || !user.username || !Array.isArray(user.roles)) {
throw new Error("当前登录令牌不支持申请受限访问令牌");
}
const res = await this.loginService.generateScopedAccessToken(user, scoped);
this.auditLog({ content: "获取了 AI 插件开发受限访问令牌" });
return this.ok(res);
}
}
@@ -55,7 +55,7 @@ export class PluginController extends CrudController<PluginService> {
};
merge(bean, def);
bean.fullName = bean.name;
if (bean.author){
if (bean.author) {
bean.fullName = bean.author + "/" + bean.name;
}
const res = await super.add(bean);
@@ -189,5 +189,4 @@ export class PluginController extends CrudController<PluginService> {
const res = await this.service.exportPlugin(id);
return this.ok(res);
}
}
@@ -55,3 +55,36 @@ describe("AuthorityMiddleware guestOptionalAuth", () => {
assert.deepEqual(ctx.user.roles, [1]);
});
});
describe("AuthorityMiddleware scoped token", () => {
it("rejects a scoped token outside its API prefix", async () => {
const middleware = createMiddleware(Constants.per.authOnly);
const ctx = createCtx();
ctx.path = "/api/sys/plugin/find";
const token = jwt.sign({ id: 1, roles: [1], scoped: ["sys/ai"] }, middleware.secret);
ctx.get = (name: string) => (name === "Authorization" ? `Bearer ${token}` : "");
let called = false;
await middleware.resolve()(ctx, async () => {
called = true;
});
assert.equal(called, false);
assert.equal(ctx.status, 403);
});
it("allows a scoped token within its API prefix", async () => {
const middleware = createMiddleware(Constants.per.authOnly);
const ctx = createCtx();
ctx.path = "/api/scoped/sys/ai/plugin/find";
const token = jwt.sign({ id: 1, roles: [1], scoped: ["sys/ai"] }, middleware.secret);
ctx.get = (name: string) => (name === "Authorization" ? `Bearer ${token}` : "");
let called = false;
await middleware.resolve()(ctx, async () => {
called = true;
});
assert.equal(called, true);
});
});
@@ -63,6 +63,9 @@ export class AuthorityMiddleware implements IWebMiddleware {
logger.error("token verify error: ", err);
return this.notAuth(ctx);
}
if (!this.isScopedTokenPathAllowed(ctx)) {
return this.notScoped(ctx);
}
} else {
if (permission === Constants.per.guestOptionalAuth) {
await next();
@@ -111,6 +114,35 @@ export class AuthorityMiddleware implements IWebMiddleware {
return;
}
private notScoped(ctx: IMidwayKoaContext) {
ctx.status = 403;
ctx.body = Constants.res.permission;
return;
}
private isScopedTokenPathAllowed(ctx: IMidwayKoaContext) {
const user = ctx.user as { scoped?: unknown } | undefined;
if (!user || !("scoped" in user)) {
return true;
}
if (!Array.isArray(user.scoped) || user.scoped.length === 0) {
return false;
}
const requestPath = ctx.path.replace(/^\/+|\/+$/g, "");
return user.scoped.some(scope => {
if (typeof scope !== "string") {
return false;
}
const normalizedScope = scope.trim().replace(/^\/+|\/+$/g, "");
if (!/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/.test(normalizedScope)) {
return false;
}
const scopedPrefix = `api/scoped/${normalizedScope}`;
return requestPath === scopedPrefix || requestPath.startsWith(`${scopedPrefix}/`);
});
}
private extractProjectId(ctx: IMidwayKoaContext) {
const headerVal = ctx.headers["project-id"] as string;
const queryVal = (ctx.request as any)?.query?.projectId;
@@ -3,3 +3,5 @@ export { CronConfiguration as Configuration } from "./configuration.js";
// export * from './controller/user';
// export * from './controller/api';
// export * from './service/user';
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import jwt from "jsonwebtoken";
import { LoginService } from "./login-service.js";
function createLoginService() {
@@ -76,3 +77,28 @@ describe("LoginService.register", () => {
assert.equal(calls.bindInvitee.length, 0);
});
});
describe("LoginService.generateScopedAccessToken", () => {
it("adds the requested scopes to a short-lived JWT", async () => {
const service = new LoginService();
(service as any).jwt = { expire: 7200 };
service.sysSettingsService = {
async getSetting() {
return { jwtKey: "test-secret" };
},
} as any;
const result = await service.generateScopedAccessToken(
{
id: 1,
username: "admin",
roles: [1],
},
["sys/ai"]
);
const payload = jwt.verify(result.token, "test-secret") as jwt.JwtPayload;
assert.deepEqual(payload.scoped, ["sys/ai"]);
assert.equal(result.expire, 3600);
});
});
@@ -256,6 +256,33 @@ export class LoginService {
};
}
async generateScopedAccessToken(user: { id: number; username: string; roles: number[] }, scoped: string[]) {
const normalizedScopes = [...new Set((scoped || []).map(item => `${item || ""}`.trim()).filter(Boolean))];
if (normalizedScopes.length === 0) {
throw new CommonException("scoped不能为空");
}
const setting = await this.sysSettingsService.getSetting<SysPrivateSettings>(SysPrivateSettings);
const expire = Math.min(this.jwt.expire, 6 * 60 * 60);
const token = jwt.sign(
{
username: user.username,
id: user.id,
roles: user.roles,
scoped: normalizedScopes,
},
setting.jwtKey,
{ expiresIn: expire }
);
return {
token,
expire,
userId: user.id,
username: user.username,
scoped: normalizedScopes,
};
}
async loginByOpenId(req: { openId: string; type: string }) {
const { openId, type } = req;
const oauthBound = await this.oauthBoundService.findOne({
@@ -24,6 +24,7 @@ export type CloudflareRecord = {
icon: "simple-icons:cloudflare",
// 这里是对应的 cloudflare的access类型名称
accessType: "cloudflare",
order: 1,
})
export class CloudflareDnsProvider extends AbstractDnsProvider<CloudflareRecord> {
access!: CloudflareAccess;
@@ -19,7 +19,7 @@ export type GoogleCloudDnsRecord = {
desc: "Google Cloud DNS提供商",
icon: "flat-color-icons:google",
accessType: "google",
order: 50,
order: 2,
})
export class GoogleCloudDnsProvider extends AbstractDnsProvider<GoogleCloudDnsRecord> {
access!: GoogleAccess;
@@ -15,6 +15,7 @@ export type SearchRecordOptions = {
desc: "华为云DNS解析提供商",
accessType: "huawei",
icon: "svg:icon-huawei",
order: 1,
})
export class HuaweiDnsProvider extends AbstractDnsProvider {
client!: HuaweiYunClient;
@@ -8,6 +8,7 @@ const tencentDnsProviderDefine: any = {
desc: "腾讯云域名DNS解析提供者",
accessType: "tencent",
icon: "svg:icon-tencentcloud",
order: 0,
dependPlugins: {
"access:tencent": "*",
},
@@ -10,6 +10,7 @@ import { TencentAccess } from "../../plugin-lib/tencent/access.js";
dependPlugins: {
"access:tencent": "*",
},
order: 1,
})
export class TencentEoDnsProvider extends AbstractDnsProvider {
access!: TencentAccess;
@@ -10,7 +10,7 @@ import { PageSearch } from "@certd/pipeline";
desc: "火山引擎DNS解析提供商",
accessType: "volcengine",
icon: "svg:icon-volcengine",
order: 2,
order: 1,
})
export class VolcengineDnsProvider extends AbstractDnsProvider {
client: VolcengineDnsClient;
@@ -10,7 +10,7 @@ type westRecord = {
record_id: number;
};
};
// 这个别删,在WestDnsProvider有使用
export class WestDnsProviderDomain extends AbstractDnsProvider<westRecord> {
access!: WestAccess;