plugin-ai-api 1.0.21 → 1.0.24

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.
Files changed (59) hide show
  1. package/dist/client/123.e6fe04c856ce6417.js +10 -0
  2. package/dist/client/902.e74518750f1e4201.js +10 -0
  3. package/dist/client/index.js +1 -1
  4. package/dist/client-v2/123.05f1f649923f93eb.js +10 -0
  5. package/dist/client-v2/902.c7c00a565085438a.js +10 -0
  6. package/dist/client-v2/index.js +1 -1
  7. package/dist/constants.js +5 -2
  8. package/dist/locale/en-US.json +15 -1
  9. package/dist/locale/vi-VN.json +15 -1
  10. package/dist/locale/zh-CN.json +15 -1
  11. package/dist/server/collections/ai-api-user-permissions.js +67 -0
  12. package/dist/server/collections/ai-api-user-quota-policies.js +2 -1
  13. package/dist/server/plugin.js +32 -0
  14. package/dist/server/resource/ai-api-user-permissions.js +75 -0
  15. package/dist/server/routes/agent-completions.js +5 -0
  16. package/dist/server/routes/chat-completions.js +52 -27
  17. package/dist/server/routes/completions.js +59 -33
  18. package/dist/server/routes/embeddings.js +6 -14
  19. package/dist/server/routes/models.js +24 -0
  20. package/dist/server/utils/direct-llm-context.js +184 -0
  21. package/dist/server/utils/openai-format.js +17 -3
  22. package/dist/server/utils/user-permissions.js +160 -0
  23. package/dist/server/validation.js +3 -0
  24. package/dist/swagger.js +4 -3
  25. package/package.json +2 -2
  26. package/src/client/__tests__/settings-registration.test.tsx +69 -0
  27. package/src/client/plugin.tsx +14 -3
  28. package/src/client-v2/__tests__/settings-registration.test.tsx +33 -4
  29. package/src/client-v2/pages/UserPermissionsPage.tsx +322 -0
  30. package/src/client-v2/pages/UserQuotasPage.tsx +18 -0
  31. package/src/client-v2/plugin.tsx +12 -3
  32. package/src/constants.ts +7 -0
  33. package/src/locale/en-US.json +15 -1
  34. package/src/locale/vi-VN.json +15 -1
  35. package/src/locale/zh-CN.json +15 -1
  36. package/src/server/__tests__/direct-llm-context.test.ts +125 -0
  37. package/src/server/__tests__/models.test.ts +44 -2
  38. package/src/server/__tests__/openai-format.test.ts +52 -1
  39. package/src/server/__tests__/permission-sync.test.ts +109 -0
  40. package/src/server/__tests__/usage-route.test.ts +265 -5
  41. package/src/server/__tests__/user-permissions-resource.test.ts +66 -0
  42. package/src/server/__tests__/user-permissions.test.ts +284 -0
  43. package/src/server/__tests__/validation.test.ts +36 -0
  44. package/src/server/collections/ai-api-user-permissions.ts +46 -0
  45. package/src/server/collections/ai-api-user-quota-policies.ts +1 -0
  46. package/src/server/plugin.ts +42 -1
  47. package/src/server/resource/ai-api-user-permissions.ts +76 -0
  48. package/src/server/routes/agent-completions.ts +7 -0
  49. package/src/server/routes/chat-completions.ts +58 -30
  50. package/src/server/routes/completions.ts +68 -34
  51. package/src/server/routes/embeddings.ts +10 -15
  52. package/src/server/routes/models.ts +28 -0
  53. package/src/server/utils/direct-llm-context.ts +216 -0
  54. package/src/server/utils/openai-format.ts +26 -0
  55. package/src/server/utils/user-permissions.ts +218 -0
  56. package/src/server/validation.ts +3 -0
  57. package/src/swagger.ts +9 -3
  58. package/dist/client/902.92e1daaf1ab16ebf.js +0 -10
  59. package/dist/client-v2/902.9054d990ddc223ac.js +0 -10
@@ -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,36 @@
1
+ import type { Model } from '@nocobase/database';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ vi.mock('dayjs', () => ({
5
+ default: () => ({ tz: vi.fn() }),
6
+ }));
7
+
8
+ import { validateQuotaPolicy } from '../validation';
9
+
10
+ function quotaPolicy(values: Record<string, unknown>): Model {
11
+ return {
12
+ get: (key: string) => values[key],
13
+ } as Model;
14
+ }
15
+
16
+ const requiredPolicy = {
17
+ periodType: 'monthly',
18
+ missingUsageBehavior: 'use_reserved',
19
+ timezone: 'UTC',
20
+ };
21
+
22
+ describe('AI API quota policy validation', () => {
23
+ it('uses reject when context overflow behavior is absent', () => {
24
+ expect(() => validateQuotaPolicy(quotaPolicy(requiredPolicy))).not.toThrow();
25
+ });
26
+
27
+ it.each(['reject', 'truncate'] as const)('accepts %s context overflow behavior', (contextOverflowBehavior) => {
28
+ expect(() => validateQuotaPolicy(quotaPolicy({ ...requiredPolicy, contextOverflowBehavior }))).not.toThrow();
29
+ });
30
+
31
+ it('rejects an unsupported context overflow behavior', () => {
32
+ expect(() => validateQuotaPolicy(quotaPolicy({ ...requiredPolicy, contextOverflowBehavior: 'compact' }))).toThrow(
33
+ 'contextOverflowBehavior must be reject or truncate.',
34
+ );
35
+ });
36
+ });
@@ -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
+ });
@@ -22,6 +22,7 @@ export default defineCollection({
22
22
  { name: 'currency', type: 'string', allowNull: false, defaultValue: 'USD' },
23
23
  { name: 'rejectUnpricedModel', type: 'boolean', defaultValue: true },
24
24
  { name: 'missingUsageBehavior', type: 'string', allowNull: false, defaultValue: 'use_reserved' },
25
+ { name: 'contextOverflowBehavior', type: 'string', allowNull: false, defaultValue: 'reject' },
25
26
  ],
26
27
  indexes: [
27
28
  {
@@ -8,13 +8,16 @@
8
8
  */
9
9
 
10
10
  import { Plugin } from '@nocobase/server';
11
+ import type { Transactionable } from '@nocobase/database';
11
12
  import { createAiLlmRouter, AI_LLM_PREFIX } from './routes/router';
12
13
  import aiApiConfigResource from './resource/ai-api-config';
13
14
  import aiApiUsageMonitorResource from './resource/ai-api-usage-monitor';
15
+ import aiApiUserPermissionsResource from './resource/ai-api-user-permissions';
14
16
  import { RateLimiter } from './utils/rate-limiter';
15
17
  import { invalidateRolePermissionCache } from './middleware/role-permission';
18
+ import { invalidateUserPermissionCache } from './utils/user-permissions';
16
19
  import { validateModelPrice, validateModelMetadata, validateQuotaPolicy } from './validation';
17
- import { AI_API_ACL_SNIPPET } from '../constants';
20
+ import { AI_API_ACL_SNIPPET, AI_API_USER_PERMISSIONS_SNIPPET } from '../constants';
18
21
 
19
22
  // Ensure dayjs timezone + utc plugins are loaded.
20
23
  // Some Docker builds ship an older @nocobase/utils whose dayjs.js does not
@@ -77,6 +80,7 @@ export class PluginAiApiServer extends Plugin {
77
80
  // 2. Register admin config resource
78
81
  this.app.resourceManager.define(aiApiConfigResource);
79
82
  this.app.resourceManager.define(aiApiUsageMonitorResource);
83
+ this.app.resourceManager.define(aiApiUserPermissionsResource);
80
84
 
81
85
  this.app.db.on('aiApiRolePermissions.afterSave', (model) => {
82
86
  invalidateRolePermissionCache(model.get('roleName'));
@@ -85,6 +89,13 @@ export class PluginAiApiServer extends Plugin {
85
89
  invalidateRolePermissionCache(model.get('roleName'));
86
90
  });
87
91
 
92
+ this.app.db.on('aiApiUserPermissions.afterSave', (model, options) => {
93
+ this.revokeUserPermissions(model.get('userId'), options?.transaction);
94
+ });
95
+ this.app.db.on('aiApiUserPermissions.afterDestroy', (model, options) => {
96
+ this.revokeUserPermissions(model.get('userId'), options?.transaction);
97
+ });
98
+
88
99
  // 3. Set ACL permissions for admin config + role permissions management
89
100
  this.app.acl.registerSnippet({
90
101
  name: AI_API_ACL_SNIPPET,
@@ -102,12 +113,42 @@ export class PluginAiApiServer extends Plugin {
102
113
  ],
103
114
  });
104
115
 
116
+ // Per-user LLM grants are a separate child permission: handing out model access is a
117
+ // stronger capability than editing gateway settings, so it ticks independently.
118
+ // The wildcard also covers `listUsers`, which backs the page's user picker — without it
119
+ // the page would depend on `pm.plugin-users` and break for a role holding only this snippet.
120
+ this.app.acl.registerSnippet({
121
+ name: AI_API_USER_PERMISSIONS_SNIPPET,
122
+ actions: ['aiApiUserPermissions:*'],
123
+ });
124
+
105
125
  // 4. GC the rate limiter every 5 minutes to evict stale user entries.
106
126
  // .unref() prevents this timer from keeping the process alive on shutdown.
107
127
  this.gcInterval = setInterval(() => this.rateLimiter.gc(), 5 * 60 * 1000);
108
128
  this.gcInterval.unref();
109
129
  }
110
130
 
131
+ /**
132
+ * Drop a user's cached LLM scope on every node.
133
+ *
134
+ * The local call is not redundant: syncMessageManager hardcodes skipSelf, so the publishing
135
+ * node never receives its own message. Passing the transaction defers the broadcast until
136
+ * the write commits, so other nodes cannot re-read the old row and re-cache it.
137
+ */
138
+ private revokeUserPermissions(userId: unknown, transaction?: Transactionable['transaction']) {
139
+ invalidateUserPermissionCache(userId as string | number | bigint);
140
+ this.sendSyncMessage({ type: 'invalidateUserPermissions', userId }, { transaction });
141
+ }
142
+
143
+ /**
144
+ * Received only on the *other* nodes (skipSelf), so this must not re-broadcast.
145
+ */
146
+ async handleSyncMessage(message: { type?: string; userId?: unknown }) {
147
+ if (message?.type === 'invalidateUserPermissions') {
148
+ invalidateUserPermissionCache(message.userId as string | number | bigint);
149
+ }
150
+ }
151
+
111
152
  async install() {
112
153
  // Create default config record on first install
113
154
  const existing = await this.db.getRepository('aiApiConfig').findOne();
@@ -0,0 +1,76 @@
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 { ResourceOptions } from '@nocobase/resourcer';
11
+
12
+ const MAX_PAGE_SIZE = 100;
13
+
14
+ /**
15
+ * Minimal user directory for the permission page's user picker.
16
+ *
17
+ * The page is gated by AI_API_USER_PERMISSIONS_SNIPPET, but `users:list` belongs to
18
+ * `pm.plugin-users`. Calling it would force every permission admin to also be a user admin,
19
+ * so this action exposes only the identity fields the picker renders — never password hashes,
20
+ * roles or any other user column.
21
+ */
22
+ const aiApiUserPermissionsResource: ResourceOptions = {
23
+ name: 'aiApiUserPermissions',
24
+ actions: {
25
+ async listUsers(ctx, next) {
26
+ const params = ctx.action.params || {};
27
+ const keyword = typeof params.keyword === 'string' ? params.keyword.trim() : '';
28
+ const page = Math.max(1, Number(params.page) || 1);
29
+ const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number(params.pageSize) || 50));
30
+
31
+ const filter: Record<string, unknown> = keyword
32
+ ? {
33
+ $or: [
34
+ { nickname: { $includes: keyword } },
35
+ { username: { $includes: keyword } },
36
+ { email: { $includes: keyword } },
37
+ ],
38
+ }
39
+ : {};
40
+
41
+ // A user already holding a grant is excluded so the picker cannot produce a duplicate
42
+ // that the unique index on userId would reject at save time.
43
+ if (params.excludeGranted) {
44
+ const granted = await ctx.db.getRepository('aiApiUserPermissions').find({ fields: ['userId'] });
45
+ const ids = granted.map((row) => row.get('userId')).filter((id) => id !== null && id !== undefined);
46
+ if (ids.length) filter.id = { $notIn: ids };
47
+ }
48
+
49
+ const [rows, count] = await ctx.db.getRepository('users').findAndCount({
50
+ filter,
51
+ fields: ['id', 'nickname', 'username', 'email'],
52
+ sort: ['nickname', 'id'],
53
+ offset: (page - 1) * pageSize,
54
+ limit: pageSize,
55
+ });
56
+
57
+ // Use the canonical { rows, ...meta } action shape. NocoBase's dataWrapping
58
+ // middleware turns this into { data, meta } on the wire; returning that wire
59
+ // shape here would make it wrap a second time.
60
+ ctx.body = {
61
+ rows: rows.map((row) => ({
62
+ id: row.get('id'),
63
+ nickname: row.get('nickname'),
64
+ username: row.get('username'),
65
+ email: row.get('email'),
66
+ })),
67
+ count,
68
+ page,
69
+ pageSize,
70
+ };
71
+ await next();
72
+ },
73
+ },
74
+ };
75
+
76
+ export default aiApiUserPermissionsResource;
@@ -18,6 +18,7 @@ import {
18
18
  } from '../utils/openai-format';
19
19
  import { resolveModelString } from '../utils/resolve-service';
20
20
  import { checkEmployeeAccess } from '../middleware/role-permission';
21
+ import { enforceModelAccess } from '../utils/user-permissions';
21
22
  import { isStreamingRequested } from '../utils/streaming';
22
23
  import {
23
24
  AgentRuntimeContext,
@@ -121,6 +122,12 @@ export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiSe
121
122
  return;
122
123
  }
123
124
 
125
+ // ─── Check whitelist (global config ∩ per-user grant) ──────────────────────
126
+ const globalEnabledServices = config ? config.get('enabledLlmServices') || config.enabledLlmServices : [];
127
+ if (!(await enforceModelAccess(ctx, globalEnabledServices, service, modelId))) {
128
+ return;
129
+ }
130
+
124
131
  const employeeUsername = defaultAiEmployee;
125
132
 
126
133
  // ─── Check role is allowed to use this employee ────────────────────────────