plugin-ai-api 1.0.21 → 1.0.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/123.e6fe04c856ce6417.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/123.05f1f649923f93eb.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/constants.js +5 -2
- package/dist/locale/en-US.json +12 -1
- package/dist/locale/vi-VN.json +12 -1
- package/dist/locale/zh-CN.json +12 -1
- package/dist/server/collections/ai-api-user-permissions.js +67 -0
- package/dist/server/plugin.js +32 -0
- package/dist/server/resource/ai-api-user-permissions.js +75 -0
- package/dist/server/routes/agent-completions.js +5 -0
- package/dist/server/routes/chat-completions.js +29 -16
- package/dist/server/routes/completions.js +33 -20
- package/dist/server/routes/embeddings.js +6 -14
- package/dist/server/routes/models.js +24 -0
- package/dist/server/utils/openai-format.js +17 -3
- package/dist/server/utils/user-permissions.js +160 -0
- package/dist/swagger.js +4 -3
- package/package.json +2 -2
- package/src/client/__tests__/settings-registration.test.tsx +69 -0
- package/src/client/plugin.tsx +14 -3
- package/src/client-v2/__tests__/settings-registration.test.tsx +33 -4
- package/src/client-v2/pages/UserPermissionsPage.tsx +322 -0
- package/src/client-v2/plugin.tsx +12 -3
- package/src/constants.ts +7 -0
- package/src/locale/en-US.json +12 -1
- package/src/locale/vi-VN.json +12 -1
- package/src/locale/zh-CN.json +12 -1
- package/src/server/__tests__/models.test.ts +44 -2
- package/src/server/__tests__/openai-format.test.ts +52 -1
- package/src/server/__tests__/permission-sync.test.ts +109 -0
- package/src/server/__tests__/usage-route.test.ts +213 -0
- package/src/server/__tests__/user-permissions-resource.test.ts +66 -0
- package/src/server/__tests__/user-permissions.test.ts +284 -0
- package/src/server/collections/ai-api-user-permissions.ts +46 -0
- package/src/server/plugin.ts +42 -1
- package/src/server/resource/ai-api-user-permissions.ts +76 -0
- package/src/server/routes/agent-completions.ts +7 -0
- package/src/server/routes/chat-completions.ts +32 -16
- package/src/server/routes/completions.ts +40 -18
- package/src/server/routes/embeddings.ts +10 -15
- package/src/server/routes/models.ts +28 -0
- package/src/server/utils/openai-format.ts +26 -0
- package/src/server/utils/user-permissions.ts +218 -0
- package/src/swagger.ts +9 -3
|
@@ -2,6 +2,7 @@ import type { Context } from '@nocobase/actions';
|
|
|
2
2
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
3
|
import type PluginAiApiServer from '../plugin';
|
|
4
4
|
import { handleChatCompletions } from '../routes/chat-completions';
|
|
5
|
+
import { handleCompletions } from '../routes/completions';
|
|
5
6
|
import { resolveModelString } from '../utils/resolve-service';
|
|
6
7
|
|
|
7
8
|
vi.mock('../utils/resolve-service', () => ({
|
|
@@ -51,6 +52,114 @@ function createContext(result: ModelResult): Context {
|
|
|
51
52
|
} as unknown as Context;
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
class ListenerTarget {
|
|
56
|
+
private listeners = new Map<string, Set<() => void>>();
|
|
57
|
+
aborted = false;
|
|
58
|
+
writableEnded = false;
|
|
59
|
+
|
|
60
|
+
once(event: string, listener: () => void) {
|
|
61
|
+
const wrapped = () => {
|
|
62
|
+
this.off(event, wrapped);
|
|
63
|
+
listener();
|
|
64
|
+
};
|
|
65
|
+
const group = this.listeners.get(event) ?? new Set();
|
|
66
|
+
group.add(wrapped);
|
|
67
|
+
this.listeners.set(event, group);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
off(event: string, listener: () => void) {
|
|
71
|
+
this.listeners.get(event)?.delete(listener);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
emit(event: string) {
|
|
75
|
+
for (const listener of [...(this.listeners.get(event) ?? [])]) listener();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function createStreamingContext(
|
|
80
|
+
result: ModelResult,
|
|
81
|
+
streamOptions?: Record<string, unknown>,
|
|
82
|
+
requestBody?: Record<string, unknown>,
|
|
83
|
+
) {
|
|
84
|
+
const req = new ListenerTarget();
|
|
85
|
+
const res = new ListenerTarget();
|
|
86
|
+
const writes: string[] = [];
|
|
87
|
+
|
|
88
|
+
res.write = vi.fn((data: unknown) => {
|
|
89
|
+
writes.push(String(data));
|
|
90
|
+
return true;
|
|
91
|
+
});
|
|
92
|
+
res.end = vi.fn(() => {
|
|
93
|
+
res.writableEnded = true;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const contentChunks = (typeof result.content === 'string' ? [result.content] : []).filter(Boolean);
|
|
97
|
+
const chunks = [
|
|
98
|
+
...contentChunks.map((content) => ({ content })),
|
|
99
|
+
{ content: '', usage_metadata: result.usage_metadata, response_metadata: result.response_metadata },
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const model = {
|
|
103
|
+
invoke: vi.fn().mockResolvedValue({
|
|
104
|
+
content: result.content,
|
|
105
|
+
usage_metadata: result.usage_metadata,
|
|
106
|
+
}),
|
|
107
|
+
stream: vi.fn().mockResolvedValue({
|
|
108
|
+
[Symbol.asyncIterator]() {
|
|
109
|
+
let index = 0;
|
|
110
|
+
return {
|
|
111
|
+
async next() {
|
|
112
|
+
if (index >= chunks.length) return { done: true, value: undefined };
|
|
113
|
+
return { done: false, value: chunks[index++] };
|
|
114
|
+
},
|
|
115
|
+
async return() {
|
|
116
|
+
index = chunks.length;
|
|
117
|
+
return { done: true, value: undefined };
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
}),
|
|
122
|
+
modelKwargs: {},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
class TestProvider {
|
|
126
|
+
createModel() {
|
|
127
|
+
return model;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const ctx = {
|
|
132
|
+
app: {
|
|
133
|
+
pm: {
|
|
134
|
+
get: vi.fn().mockReturnValue({
|
|
135
|
+
aiManager: {
|
|
136
|
+
llmProviders: new Map([['test-provider', { provider: TestProvider }]]),
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
db: {
|
|
142
|
+
getRepository: vi.fn().mockReturnValue({ findOne: vi.fn().mockResolvedValue(null) }),
|
|
143
|
+
},
|
|
144
|
+
log: { error: vi.fn() },
|
|
145
|
+
req,
|
|
146
|
+
res,
|
|
147
|
+
request: {
|
|
148
|
+
body: {
|
|
149
|
+
model: 'test-service/test-model',
|
|
150
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
151
|
+
stream: true,
|
|
152
|
+
stream_options: streamOptions,
|
|
153
|
+
...requestBody,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
state: {} as Record<string, unknown>,
|
|
157
|
+
set: vi.fn(),
|
|
158
|
+
} as unknown as Context;
|
|
159
|
+
|
|
160
|
+
return { ctx, model, writes };
|
|
161
|
+
}
|
|
162
|
+
|
|
54
163
|
describe('AI API chat usage collection', () => {
|
|
55
164
|
beforeEach(() => {
|
|
56
165
|
vi.mocked(resolveModelString).mockResolvedValue({
|
|
@@ -106,4 +215,108 @@ describe('AI API chat usage collection', () => {
|
|
|
106
215
|
usage: { prompt_tokens: 8, completion_tokens: 3, total_tokens: 11 },
|
|
107
216
|
});
|
|
108
217
|
});
|
|
218
|
+
|
|
219
|
+
it('emits a usage-only chunk immediately before [DONE] for streaming chat completions', async () => {
|
|
220
|
+
const { ctx, writes } = createStreamingContext(
|
|
221
|
+
{
|
|
222
|
+
content: 'Hi',
|
|
223
|
+
response_metadata: { request_id: 'provider-request-stream-1' },
|
|
224
|
+
usage_metadata: { input_tokens: 5, output_tokens: 4, total_tokens: 9 },
|
|
225
|
+
},
|
|
226
|
+
{ include_usage: true },
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
await handleChatCompletions(ctx, {} as PluginAiApiServer);
|
|
230
|
+
|
|
231
|
+
const dataLines = writes.filter((line) => line.startsWith('data: '));
|
|
232
|
+
const doneFrame = dataLines.find((line) => line.includes('[DONE]'));
|
|
233
|
+
expect(doneFrame).toBeDefined();
|
|
234
|
+
const frames = dataLines.filter((line) => !line.includes('[DONE]')).map((line) => JSON.parse(line.slice(6)));
|
|
235
|
+
const doneIndex = frames.length;
|
|
236
|
+
|
|
237
|
+
const finishChunk = frames[doneIndex - 2];
|
|
238
|
+
const usageChunk = frames[doneIndex - 1];
|
|
239
|
+
|
|
240
|
+
expect(finishChunk.choices[0].finish_reason).toBe('stop');
|
|
241
|
+
expect(usageChunk.choices).toEqual([]);
|
|
242
|
+
expect(usageChunk.usage).toEqual({ prompt_tokens: 5, completion_tokens: 4, total_tokens: 9 });
|
|
243
|
+
expect(usageChunk).toHaveProperty('usage.prompt_tokens', 5);
|
|
244
|
+
expect(ctx.state.aiApiUsageResult).toMatchObject({
|
|
245
|
+
source: 'provider',
|
|
246
|
+
providerRequestId: 'provider-request-stream-1',
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('always emits chat usage and forces provider collection when include_usage is false', async () => {
|
|
251
|
+
const { ctx, model, writes } = createStreamingContext(
|
|
252
|
+
{
|
|
253
|
+
content: 'Hi',
|
|
254
|
+
usage_metadata: { input_tokens: 5, output_tokens: 4, total_tokens: 9 },
|
|
255
|
+
},
|
|
256
|
+
{ include_usage: false, include_obfuscation: false },
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
await handleChatCompletions(ctx, {} as PluginAiApiServer);
|
|
260
|
+
|
|
261
|
+
const frames = writes
|
|
262
|
+
.filter((line) => line.startsWith('data: ') && !line.includes('[DONE]'))
|
|
263
|
+
.map((line) => JSON.parse(line.slice(6)));
|
|
264
|
+
const usageChunk = frames[frames.length - 1];
|
|
265
|
+
|
|
266
|
+
expect(frames.slice(0, -1).every((frame) => frame.usage === null)).toBe(true);
|
|
267
|
+
expect(usageChunk.choices).toEqual([]);
|
|
268
|
+
expect(usageChunk.usage).toEqual({ prompt_tokens: 5, completion_tokens: 4, total_tokens: 9 });
|
|
269
|
+
expect(model.stream).toHaveBeenCalledWith(
|
|
270
|
+
expect.anything(),
|
|
271
|
+
expect.objectContaining({
|
|
272
|
+
stream_options: { include_usage: true, include_obfuscation: false },
|
|
273
|
+
}),
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('always emits legacy completion usage and forwards all stream options', async () => {
|
|
278
|
+
const { ctx, model, writes } = createStreamingContext(
|
|
279
|
+
{
|
|
280
|
+
content: 'Hi',
|
|
281
|
+
usage_metadata: { input_tokens: 2, output_tokens: 5, total_tokens: 7 },
|
|
282
|
+
},
|
|
283
|
+
{ include_usage: false, include_obfuscation: false },
|
|
284
|
+
{ prompt: 'Hello' },
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
await handleCompletions(ctx, {} as PluginAiApiServer);
|
|
288
|
+
|
|
289
|
+
const frames = writes
|
|
290
|
+
.filter((line) => line.startsWith('data: ') && !line.includes('[DONE]'))
|
|
291
|
+
.map((line) => JSON.parse(line.slice(6)));
|
|
292
|
+
const usageChunk = frames[frames.length - 1];
|
|
293
|
+
|
|
294
|
+
expect(frames.slice(0, -1).every((frame) => frame.usage === null)).toBe(true);
|
|
295
|
+
expect(usageChunk.object).toBe('text_completion');
|
|
296
|
+
expect(usageChunk.choices).toEqual([]);
|
|
297
|
+
expect(usageChunk.usage).toEqual({ prompt_tokens: 2, completion_tokens: 5, total_tokens: 7 });
|
|
298
|
+
expect(model.stream).toHaveBeenCalledWith(
|
|
299
|
+
expect.anything(),
|
|
300
|
+
expect.objectContaining({
|
|
301
|
+
stream_options: { include_usage: true, include_obfuscation: false },
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('does not emit a usage-only chunk when the provider omits usage metadata', async () => {
|
|
307
|
+
const { ctx, writes } = createStreamingContext({ content: 'Silent' }, { include_usage: true });
|
|
308
|
+
|
|
309
|
+
await handleChatCompletions(ctx, {} as PluginAiApiServer);
|
|
310
|
+
|
|
311
|
+
const dataLines = writes.filter((line) => line.startsWith('data: '));
|
|
312
|
+
const doneFrame = dataLines.find((line) => line.includes('[DONE]'));
|
|
313
|
+
expect(doneFrame).toBeDefined();
|
|
314
|
+
const frames = dataLines.filter((line) => !line.includes('[DONE]')).map((line) => JSON.parse(line.slice(6)));
|
|
315
|
+
const doneIndex = frames.length;
|
|
316
|
+
|
|
317
|
+
const precedingChunk = frames[doneIndex - 1];
|
|
318
|
+
expect(precedingChunk.choices[0].finish_reason).toBe('stop');
|
|
319
|
+
expect(precedingChunk.usage).toBeNull();
|
|
320
|
+
expect(ctx.state.aiApiUsageResult).toMatchObject({ source: 'unavailable' });
|
|
321
|
+
});
|
|
109
322
|
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Context, Next } from '@nocobase/actions';
|
|
11
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import aiApiUserPermissionsResource from '../resource/ai-api-user-permissions';
|
|
13
|
+
|
|
14
|
+
type ActionHandler = (ctx: Context, next: Next) => Promise<void>;
|
|
15
|
+
|
|
16
|
+
const listUsers = aiApiUserPermissionsResource.actions?.listUsers as ActionHandler;
|
|
17
|
+
|
|
18
|
+
function model(values: Record<string, unknown>) {
|
|
19
|
+
return { get: (key: string) => values[key] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('aiApiUserPermissions:listUsers', () => {
|
|
23
|
+
it('returns the canonical rows shape for NocoBase data wrapping', async () => {
|
|
24
|
+
const user = model({
|
|
25
|
+
id: 7,
|
|
26
|
+
nickname: 'Ada',
|
|
27
|
+
username: 'ada',
|
|
28
|
+
email: 'ada@example.com',
|
|
29
|
+
password: 'must-not-leak',
|
|
30
|
+
});
|
|
31
|
+
const findAndCount = vi.fn(async () => [[user], 1] as const);
|
|
32
|
+
const ctx = {
|
|
33
|
+
action: { params: {} },
|
|
34
|
+
db: { getRepository: () => ({ findAndCount }) },
|
|
35
|
+
} as unknown as Context;
|
|
36
|
+
|
|
37
|
+
await listUsers(ctx, async () => undefined);
|
|
38
|
+
|
|
39
|
+
expect(ctx.body).toEqual({
|
|
40
|
+
rows: [{ id: 7, nickname: 'Ada', username: 'ada', email: 'ada@example.com' }],
|
|
41
|
+
count: 1,
|
|
42
|
+
page: 1,
|
|
43
|
+
pageSize: 50,
|
|
44
|
+
});
|
|
45
|
+
expect(findAndCount).toHaveBeenCalledWith(
|
|
46
|
+
expect.objectContaining({ fields: ['id', 'nickname', 'username', 'email'] }),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('excludes users that already hold a grant when requested', async () => {
|
|
51
|
+
const findAndCount = vi.fn(async () => [[], 0] as const);
|
|
52
|
+
const ctx = {
|
|
53
|
+
action: { params: { excludeGranted: true } },
|
|
54
|
+
db: {
|
|
55
|
+
getRepository: (name: string) =>
|
|
56
|
+
name === 'aiApiUserPermissions'
|
|
57
|
+
? { find: vi.fn(async () => [model({ userId: 3 }), model({ userId: 8 })]) }
|
|
58
|
+
: { findAndCount },
|
|
59
|
+
},
|
|
60
|
+
} as unknown as Context;
|
|
61
|
+
|
|
62
|
+
await listUsers(ctx, async () => undefined);
|
|
63
|
+
|
|
64
|
+
expect(findAndCount).toHaveBeenCalledWith(expect.objectContaining({ filter: { id: { $notIn: [3, 8] } } }));
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Context } from '@nocobase/actions';
|
|
11
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import {
|
|
13
|
+
buildAccessScope,
|
|
14
|
+
enforceModelAccess,
|
|
15
|
+
invalidateUserPermissionCache,
|
|
16
|
+
isModelAllowed,
|
|
17
|
+
isServiceAllowed,
|
|
18
|
+
resolveUserAccessScope,
|
|
19
|
+
} from '../utils/user-permissions';
|
|
20
|
+
|
|
21
|
+
const OPENAI = { name: 'openai', title: 'OpenAI' };
|
|
22
|
+
const ANTHROPIC = { name: 'anthropic', title: 'Anthropic' };
|
|
23
|
+
|
|
24
|
+
/** Sequelize instances only expose columns via .get(); rows must be shaped that way. */
|
|
25
|
+
function row(values: Record<string, unknown>) {
|
|
26
|
+
return { get: (key: string) => values[key] };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mockContext(options: { userId?: number | null; row?: unknown; throws?: boolean; appName?: string } = {}) {
|
|
30
|
+
const findOne = vi.fn(async () => {
|
|
31
|
+
if (options.throws) throw new Error('collection unavailable');
|
|
32
|
+
return options.row ?? null;
|
|
33
|
+
});
|
|
34
|
+
const ctx = {
|
|
35
|
+
state: { currentUser: options.userId === null ? undefined : { id: options.userId ?? 1 } },
|
|
36
|
+
db: { getRepository: () => ({ findOne }) },
|
|
37
|
+
app: { name: options.appName ?? 'main' },
|
|
38
|
+
log: { warn: vi.fn(), error: vi.fn() },
|
|
39
|
+
status: 200,
|
|
40
|
+
body: undefined,
|
|
41
|
+
} as unknown as Context;
|
|
42
|
+
return { ctx, findOne };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
invalidateUserPermissionCache();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('buildAccessScope', () => {
|
|
50
|
+
it('treats a missing row as "no user-level narrowing"', () => {
|
|
51
|
+
const scope = buildAccessScope(null);
|
|
52
|
+
expect(scope.hasUserRecord).toBe(false);
|
|
53
|
+
expect(scope.denyAll).toBe(false);
|
|
54
|
+
expect(scope.allowedServices).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('marks a disabled row as deny-all', () => {
|
|
58
|
+
const scope = buildAccessScope(row({ enabled: false, allowedLlmServices: ['openai'] }));
|
|
59
|
+
expect(scope.denyAll).toBe(true);
|
|
60
|
+
expect(scope.allowedServices).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('reads columns through .get() rather than plain property access', () => {
|
|
64
|
+
const scope = buildAccessScope(
|
|
65
|
+
row({ allowedLlmServices: ['openai'], allowAllModels: false, allowedModels: ['openai/gpt-4o'] }),
|
|
66
|
+
);
|
|
67
|
+
expect(scope.allowedServices).toEqual(['openai']);
|
|
68
|
+
expect(scope.allowAllModels).toBe(false);
|
|
69
|
+
expect(scope.allowedModels.has('openai/gpt-4o')).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('defaults allowAllModels to true when the column is unset', () => {
|
|
73
|
+
expect(buildAccessScope(row({ allowedLlmServices: [] })).allowAllModels).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('discards non-string entries in the json arrays', () => {
|
|
77
|
+
const scope = buildAccessScope(
|
|
78
|
+
row({ allowedLlmServices: ['openai', null, 42, ''], allowedModels: [{}, 'openai/gpt-4o'] }),
|
|
79
|
+
);
|
|
80
|
+
expect(scope.allowedServices).toEqual(['openai']);
|
|
81
|
+
expect([...scope.allowedModels]).toEqual(['openai/gpt-4o']);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('isServiceAllowed', () => {
|
|
86
|
+
const noRecord = buildAccessScope(null);
|
|
87
|
+
|
|
88
|
+
it('leaves behaviour unchanged for a user with no record', () => {
|
|
89
|
+
expect(isServiceAllowed(noRecord, ['openai'], OPENAI)).toBe(true);
|
|
90
|
+
expect(isServiceAllowed(noRecord, ['openai'], ANTHROPIC)).toBe(false);
|
|
91
|
+
expect(isServiceAllowed(noRecord, [], ANTHROPIC)).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('denies everything when the record is disabled', () => {
|
|
95
|
+
const scope = buildAccessScope(row({ enabled: false, allowedLlmServices: ['openai'] }));
|
|
96
|
+
expect(isServiceAllowed(scope, ['openai'], OPENAI)).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('denies everything when the service list is empty', () => {
|
|
100
|
+
const scope = buildAccessScope(row({ allowedLlmServices: [] }));
|
|
101
|
+
expect(isServiceAllowed(scope, ['openai'], OPENAI)).toBe(false);
|
|
102
|
+
expect(isServiceAllowed(scope, [], OPENAI)).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('never widens the global whitelist (strict subset)', () => {
|
|
106
|
+
const scope = buildAccessScope(row({ allowedLlmServices: ['openai', 'anthropic'] }));
|
|
107
|
+
expect(isServiceAllowed(scope, ['openai'], OPENAI)).toBe(true);
|
|
108
|
+
// Granted to the user, but absent from the global whitelist → still denied.
|
|
109
|
+
expect(isServiceAllowed(scope, ['openai'], ANTHROPIC)).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('matches a service by title as well as by name', () => {
|
|
113
|
+
const byTitle = buildAccessScope(row({ allowedLlmServices: ['OpenAI'] }));
|
|
114
|
+
expect(isServiceAllowed(byTitle, ['OpenAI'], OPENAI)).toBe(true);
|
|
115
|
+
expect(isServiceAllowed(byTitle, [], OPENAI)).toBe(true);
|
|
116
|
+
expect(isServiceAllowed(byTitle, [], ANTHROPIC)).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('narrows within an empty global whitelist', () => {
|
|
120
|
+
const scope = buildAccessScope(row({ allowedLlmServices: ['openai'] }));
|
|
121
|
+
expect(isServiceAllowed(scope, [], OPENAI)).toBe(true);
|
|
122
|
+
expect(isServiceAllowed(scope, [], ANTHROPIC)).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('tolerates a null or malformed global whitelist', () => {
|
|
126
|
+
expect(isServiceAllowed(noRecord, null, OPENAI)).toBe(true);
|
|
127
|
+
expect(isServiceAllowed(noRecord, 'openai', OPENAI)).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('isModelAllowed', () => {
|
|
132
|
+
it('allows every model when the user has no record', () => {
|
|
133
|
+
expect(isModelAllowed(buildAccessScope(null), 'openai/gpt-4o')).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('allows every model of the granted services when allowAllModels is true', () => {
|
|
137
|
+
const scope = buildAccessScope(row({ allowedLlmServices: ['openai'], allowAllModels: true }));
|
|
138
|
+
expect(isModelAllowed(scope, 'openai/anything')).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('restricts to the listed models when allowAllModels is false', () => {
|
|
142
|
+
const scope = buildAccessScope(
|
|
143
|
+
row({ allowedLlmServices: ['openai'], allowAllModels: false, allowedModels: ['openai/gpt-4o'] }),
|
|
144
|
+
);
|
|
145
|
+
expect(isModelAllowed(scope, 'openai/gpt-4o')).toBe(true);
|
|
146
|
+
expect(isModelAllowed(scope, 'openai/gpt-4o-mini')).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('denies every model when the record is disabled', () => {
|
|
150
|
+
const scope = buildAccessScope(row({ enabled: false, allowAllModels: true }));
|
|
151
|
+
expect(isModelAllowed(scope, 'openai/gpt-4o')).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('resolveUserAccessScope', () => {
|
|
156
|
+
it('returns the no-record scope for an unauthenticated context', async () => {
|
|
157
|
+
const { ctx, findOne } = mockContext({ userId: null });
|
|
158
|
+
expect((await resolveUserAccessScope(ctx)).hasUserRecord).toBe(false);
|
|
159
|
+
expect(findOne).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('caches the scope per user instead of querying on every request', async () => {
|
|
163
|
+
const { ctx, findOne } = mockContext({ row: row({ allowedLlmServices: ['openai'] }) });
|
|
164
|
+
await resolveUserAccessScope(ctx);
|
|
165
|
+
await resolveUserAccessScope(ctx);
|
|
166
|
+
expect(findOne).toHaveBeenCalledTimes(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('re-queries after the cache is invalidated for that user', async () => {
|
|
170
|
+
const { ctx, findOne } = mockContext({ userId: 7, row: row({ allowedLlmServices: ['openai'] }) });
|
|
171
|
+
await resolveUserAccessScope(ctx);
|
|
172
|
+
invalidateUserPermissionCache(7);
|
|
173
|
+
await resolveUserAccessScope(ctx);
|
|
174
|
+
expect(findOne).toHaveBeenCalledTimes(2);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('keeps other users cached when one user is invalidated', async () => {
|
|
178
|
+
const first = mockContext({ userId: 1, row: row({ allowedLlmServices: ['openai'] }) });
|
|
179
|
+
const second = mockContext({ userId: 2, row: row({ allowedLlmServices: ['openai'] }) });
|
|
180
|
+
await resolveUserAccessScope(first.ctx);
|
|
181
|
+
await resolveUserAccessScope(second.ctx);
|
|
182
|
+
invalidateUserPermissionCache(2);
|
|
183
|
+
await resolveUserAccessScope(first.ctx);
|
|
184
|
+
expect(first.findOne).toHaveBeenCalledTimes(1);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('fails closed when the collection is unavailable', async () => {
|
|
188
|
+
const { ctx } = mockContext({ throws: true });
|
|
189
|
+
const scope = await resolveUserAccessScope(ctx);
|
|
190
|
+
// Treating a failed lookup as "no record" would silently lift every user's restrictions
|
|
191
|
+
// during a rolling upgrade where the table does not exist yet.
|
|
192
|
+
expect(scope.lookupFailed).toBe(true);
|
|
193
|
+
expect(scope.denyAll).toBe(true);
|
|
194
|
+
expect(ctx.log.error).toHaveBeenCalled();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('denies every service and model when the lookup failed', async () => {
|
|
198
|
+
const { ctx } = mockContext({ throws: true });
|
|
199
|
+
const scope = await resolveUserAccessScope(ctx);
|
|
200
|
+
expect(isServiceAllowed(scope, [], OPENAI)).toBe(false);
|
|
201
|
+
expect(isModelAllowed(scope, 'openai/gpt-4o')).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('does not cache a failed lookup', async () => {
|
|
205
|
+
const { ctx, findOne } = mockContext({ throws: true });
|
|
206
|
+
await resolveUserAccessScope(ctx);
|
|
207
|
+
await resolveUserAccessScope(ctx);
|
|
208
|
+
expect(findOne).toHaveBeenCalledTimes(2);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('does not share a cache entry between apps with the same user id', async () => {
|
|
212
|
+
const main = mockContext({ userId: 1, appName: 'main', row: row({ allowedLlmServices: ['openai'] }) });
|
|
213
|
+
const sub = mockContext({ userId: 1, appName: 'sub', row: row({ allowedLlmServices: ['anthropic'] }) });
|
|
214
|
+
await resolveUserAccessScope(main.ctx);
|
|
215
|
+
const subScope = await resolveUserAccessScope(sub.ctx);
|
|
216
|
+
// Sub-apps share this process but have separate databases, so user 1 in "sub" is a
|
|
217
|
+
// different person than user 1 in "main" and must not inherit their grant.
|
|
218
|
+
expect(sub.findOne).toHaveBeenCalledTimes(1);
|
|
219
|
+
expect(subScope.allowedServices).toEqual(['anthropic']);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('invalidates a user across every app', async () => {
|
|
223
|
+
const main = mockContext({ userId: 1, appName: 'main', row: row({ allowedLlmServices: ['openai'] }) });
|
|
224
|
+
const sub = mockContext({ userId: 1, appName: 'sub', row: row({ allowedLlmServices: ['openai'] }) });
|
|
225
|
+
await resolveUserAccessScope(main.ctx);
|
|
226
|
+
await resolveUserAccessScope(sub.ctx);
|
|
227
|
+
invalidateUserPermissionCache(1);
|
|
228
|
+
await resolveUserAccessScope(main.ctx);
|
|
229
|
+
await resolveUserAccessScope(sub.ctx);
|
|
230
|
+
expect(main.findOne).toHaveBeenCalledTimes(2);
|
|
231
|
+
expect(sub.findOne).toHaveBeenCalledTimes(2);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('does not invalidate a user whose id is a suffix of another', async () => {
|
|
235
|
+
const first = mockContext({ userId: 1, row: row({ allowedLlmServices: ['openai'] }) });
|
|
236
|
+
const second = mockContext({ userId: 21, row: row({ allowedLlmServices: ['openai'] }) });
|
|
237
|
+
await resolveUserAccessScope(first.ctx);
|
|
238
|
+
await resolveUserAccessScope(second.ctx);
|
|
239
|
+
invalidateUserPermissionCache(1);
|
|
240
|
+
await resolveUserAccessScope(second.ctx);
|
|
241
|
+
expect(second.findOne).toHaveBeenCalledTimes(1);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('enforceModelAccess', () => {
|
|
246
|
+
it('passes a permitted service and model through untouched', async () => {
|
|
247
|
+
const { ctx } = mockContext({ row: row({ allowedLlmServices: ['openai'] }) });
|
|
248
|
+
expect(await enforceModelAccess(ctx, ['openai'], OPENAI, 'gpt-4o')).toBe(true);
|
|
249
|
+
expect(ctx.status).toBe(200);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('returns 403 model_not_available for a denied service', async () => {
|
|
253
|
+
const { ctx } = mockContext({ row: row({ allowedLlmServices: ['openai'] }) });
|
|
254
|
+
expect(await enforceModelAccess(ctx, ['openai', 'anthropic'], ANTHROPIC, 'claude')).toBe(false);
|
|
255
|
+
expect(ctx.status).toBe(403);
|
|
256
|
+
// `permission_denied` is what every other 403 in this plugin reports; keeping the type
|
|
257
|
+
// consistent means OpenAI clients can branch on it uniformly.
|
|
258
|
+
expect(ctx.body).toMatchObject({ error: { code: 'model_not_available', type: 'permission_denied' } });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('returns 403 model_not_available for a denied model of a granted service', async () => {
|
|
262
|
+
const { ctx } = mockContext({
|
|
263
|
+
row: row({ allowedLlmServices: ['openai'], allowAllModels: false, allowedModels: ['openai/gpt-4o'] }),
|
|
264
|
+
});
|
|
265
|
+
expect(await enforceModelAccess(ctx, ['openai'], OPENAI, 'gpt-4o-mini')).toBe(false);
|
|
266
|
+
expect(ctx.status).toBe(403);
|
|
267
|
+
expect(ctx.body).toMatchObject({ error: { code: 'model_not_available', type: 'permission_denied' } });
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('denies a user-granted service that the global whitelist excludes', async () => {
|
|
271
|
+
const { ctx } = mockContext({ row: row({ allowedLlmServices: ['anthropic'] }) });
|
|
272
|
+
expect(await enforceModelAccess(ctx, ['openai'], ANTHROPIC, 'claude')).toBe(false);
|
|
273
|
+
expect(ctx.status).toBe(403);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('returns a retryable 503 rather than allowing access when the lookup fails', async () => {
|
|
277
|
+
const { ctx } = mockContext({ throws: true });
|
|
278
|
+
expect(await enforceModelAccess(ctx, [], OPENAI, 'gpt-4o')).toBe(false);
|
|
279
|
+
// 503 not 403: the failure is ours, so clients should back off rather than treat the
|
|
280
|
+
// grant as permanently revoked.
|
|
281
|
+
expect(ctx.status).toBe(503);
|
|
282
|
+
expect(ctx.body).toMatchObject({ error: { code: 'permission_check_failed' } });
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { defineCollection } from '@nocobase/database';
|
|
11
|
+
|
|
12
|
+
export default defineCollection({
|
|
13
|
+
name: 'aiApiUserPermissions',
|
|
14
|
+
autoGenId: true,
|
|
15
|
+
fields: [
|
|
16
|
+
{ name: 'userId', type: 'bigInt', allowNull: false, index: true },
|
|
17
|
+
{
|
|
18
|
+
name: 'user',
|
|
19
|
+
type: 'belongsTo',
|
|
20
|
+
target: 'users',
|
|
21
|
+
targetKey: 'id',
|
|
22
|
+
foreignKey: 'userId',
|
|
23
|
+
constraints: false,
|
|
24
|
+
},
|
|
25
|
+
{ name: 'enabled', type: 'boolean', defaultValue: true, index: true },
|
|
26
|
+
{
|
|
27
|
+
name: 'allowedLlmServices',
|
|
28
|
+
type: 'json',
|
|
29
|
+
defaultValue: [],
|
|
30
|
+
comment: 'LLM service names/titles this user may use. Empty means the user is denied every service.',
|
|
31
|
+
},
|
|
32
|
+
{ name: 'allowAllModels', type: 'boolean', defaultValue: true },
|
|
33
|
+
{
|
|
34
|
+
name: 'allowedModels',
|
|
35
|
+
type: 'json',
|
|
36
|
+
defaultValue: [],
|
|
37
|
+
comment: 'Array of "serviceName/modelId" this user may use (when allowAllModels=false)',
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
indexes: [
|
|
41
|
+
{
|
|
42
|
+
fields: ['userId'],
|
|
43
|
+
unique: true,
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
});
|