@aalis/plugin-authority 0.1.0

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/index.js ADDED
@@ -0,0 +1,1308 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { useCommandService } from '@aalis/plugin-commands-api';
3
+ import { getPlatformNames } from '@aalis/plugin-platform-api';
4
+ import { createStorageGateway } from '@aalis/plugin-storage-api';
5
+ import { useWebuiService } from '@aalis/plugin-webui-api';
6
+ const BIND_CODE_TTL_MS = 5 * 60 * 1000;
7
+ // 8 位、去易混淆字符(0O1IL)的码空间 ≈ 31^8 ≈ 8.5e11,无需额外限流
8
+ const BIND_CODE_ALPHABET = '23456789ABCDEFGHJKMNPQRSTUVWXYZ';
9
+ function generateBindCode() {
10
+ const bytes = crypto.getRandomValues(new Uint8Array(8));
11
+ let code = '';
12
+ for (const b of bytes)
13
+ code += BIND_CODE_ALPHABET[b % BIND_CODE_ALPHABET.length];
14
+ return code;
15
+ }
16
+ // 密码哈希:Web Crypto PBKDF2-SHA256(迭代数随凭据存储,便于将来上调不破坏旧凭据)
17
+ const PBKDF2_ITERATIONS = 310_000;
18
+ async function deriveHash(password, saltHex, iterations) {
19
+ const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']);
20
+ const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt: Buffer.from(saltHex, 'hex'), iterations }, key, 256);
21
+ return Buffer.from(bits).toString('hex');
22
+ }
23
+ /** 恒定时间字符串比较(长度不同直接 false;不早退) */
24
+ function timingSafeEqualStr(a, b) {
25
+ if (a.length !== b.length)
26
+ return false;
27
+ let diff = 0;
28
+ for (let i = 0; i < a.length; i++)
29
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
30
+ return diff === 0;
31
+ }
32
+ export class AuthorityManager {
33
+ users = new Map();
34
+ /** 反向绑定索引:被绑平台身份键 → 主账户键(从 users[].links 重建) */
35
+ linkIndex = new Map();
36
+ /** 待消费绑定码(内存态) */
37
+ bindCodes = new Map();
38
+ config;
39
+ logger;
40
+ storage;
41
+ fileUri;
42
+ dirty = false;
43
+ saveChain = Promise.resolve();
44
+ confirmHandlers = new Map();
45
+ dangerousGrants = new Map();
46
+ grantSeq = 0;
47
+ /**
48
+ * dangerous 策略的开启时间(运行时状态,不序列化到 config)。
49
+ * 进程重启后重置为 null,对限时策略而言等同于「重启即失效」。
50
+ */
51
+ dangerousEnabledAt = null;
52
+ constructor(config, logger, storage) {
53
+ this.config = config;
54
+ this.logger = logger.child('authority');
55
+ this.storage = storage;
56
+ this.fileUri = 'data:/users.json';
57
+ }
58
+ getAuthority(platform, userId) {
59
+ if (!userId)
60
+ return this.config.get('defaultAuthority') ?? 1;
61
+ if ((platform === 'webui' || platform === 'cli') && userId === 'console') {
62
+ return this.config.get('ownerAuthority') ?? 5;
63
+ }
64
+ const owners = this.config.get('owners') ?? [];
65
+ if (owners.some((o) => o.platform === platform && o.userId === userId)) {
66
+ return this.config.get('ownerAuthority') ?? 5;
67
+ }
68
+ // 被绑身份解析到主账户(零合并单一真源)。递归至多一层:linkIndex 的
69
+ // 键只会是外部平台身份(consumeBindCode 拒绝 webui/cli),值是 webui 账户。
70
+ const linked = this.linkIndex.get(`${platform}:${userId}`);
71
+ if (linked) {
72
+ const idx = linked.indexOf(':');
73
+ return this.getAuthority(linked.slice(0, idx), linked.slice(idx + 1));
74
+ }
75
+ return this.users.get(`${platform}:${userId}`)?.level ?? this.config.get('defaultAuthority') ?? 1;
76
+ }
77
+ setAuthority(platform, userId, level) {
78
+ const key = `${platform}:${userId}`;
79
+ this.users.set(key, { ...this.users.get(key), level });
80
+ this.dirty = true;
81
+ this.logger.debug(`设置用户权限: ${key} → ${level}`);
82
+ }
83
+ removeUser(platform, userId) {
84
+ if (this.users.delete(`${platform}:${userId}`)) {
85
+ this.dirty = true;
86
+ this.rebuildLinkIndex();
87
+ this.logger.debug(`删除用户权限记录: ${platform}:${userId}`);
88
+ }
89
+ }
90
+ // ── 跨平台身份绑定 ──────────────────────────────────────
91
+ /** 从 users[].links 重建反向索引(表很小,全量重建即可) */
92
+ rebuildLinkIndex() {
93
+ this.linkIndex.clear();
94
+ for (const [key, record] of this.users) {
95
+ for (const linked of record.links ?? [])
96
+ this.linkIndex.set(linked, key);
97
+ }
98
+ }
99
+ createBindCode(platform, userId) {
100
+ if (platform !== 'webui')
101
+ throw new Error('绑定码只能由 WebUI 主账户发起');
102
+ const account = `${platform}:${userId}`;
103
+ // 同账户重新生成作废旧码;顺手清理过期码
104
+ const now = Date.now();
105
+ for (const [code, pending] of this.bindCodes) {
106
+ if (pending.account === account || pending.expiresAt <= now)
107
+ this.bindCodes.delete(code);
108
+ }
109
+ const code = generateBindCode();
110
+ const expiresAt = now + BIND_CODE_TTL_MS;
111
+ this.bindCodes.set(code, { account, expiresAt });
112
+ this.logger.info(`生成绑定码: 账户 ${account}(5 分钟有效)`);
113
+ return { code, expiresAt };
114
+ }
115
+ consumeBindCode(code, identity) {
116
+ if (identity.platform === 'webui' || identity.platform === 'cli') {
117
+ throw new Error('请在外部平台(如 QQ)私聊中向机器人发送绑定码');
118
+ }
119
+ const pending = this.bindCodes.get(code);
120
+ if (!pending || pending.expiresAt <= Date.now()) {
121
+ this.bindCodes.delete(code);
122
+ throw new Error('绑定码无效或已过期,请在 WebUI 重新生成');
123
+ }
124
+ const identityKey = `${identity.platform}:${identity.userId}`;
125
+ const existing = this.linkIndex.get(identityKey);
126
+ if (existing)
127
+ throw new Error(`该平台身份已绑定到 ${existing},请先解绑`);
128
+ this.bindCodes.delete(code); // 一次性
129
+ const accountRecord = { ...this.users.get(pending.account) };
130
+ // 绑时一次性合并(运行时零合并的前提):等级取 max、grants/denies 并集
131
+ // 写入账户;平台身份原记录原样留底,解绑即还原。
132
+ const identityRecord = this.users.get(identityKey);
133
+ if (identityRecord) {
134
+ if (identityRecord.level !== undefined && identityRecord.level > (accountRecord.level ?? 0)) {
135
+ accountRecord.level = identityRecord.level;
136
+ }
137
+ const union = (a, b) => {
138
+ const merged = [...new Set([...(a ?? []), ...(b ?? [])])];
139
+ return merged.length > 0 ? merged : undefined;
140
+ };
141
+ accountRecord.grants = union(accountRecord.grants, identityRecord.grants);
142
+ accountRecord.denies = union(accountRecord.denies, identityRecord.denies);
143
+ }
144
+ accountRecord.links = [...new Set([...(accountRecord.links ?? []), identityKey])];
145
+ this.users.set(pending.account, accountRecord);
146
+ this.dirty = true;
147
+ this.rebuildLinkIndex();
148
+ const idx = pending.account.indexOf(':');
149
+ this.logger.info(`身份绑定成功: ${identityKey} → ${pending.account}`);
150
+ return { platform: pending.account.slice(0, idx), userId: pending.account.slice(idx + 1) };
151
+ }
152
+ unlinkIdentity(platform, userId) {
153
+ const identityKey = `${platform}:${userId}`;
154
+ const accountKey = this.linkIndex.get(identityKey);
155
+ if (!accountKey)
156
+ return false;
157
+ const record = this.users.get(accountKey);
158
+ if (record?.links) {
159
+ const links = record.links.filter(k => k !== identityKey);
160
+ this.users.set(accountKey, { ...record, links: links.length > 0 ? links : undefined });
161
+ }
162
+ this.dirty = true;
163
+ this.rebuildLinkIndex();
164
+ this.logger.info(`身份解绑: ${identityKey} ↮ ${accountKey}`);
165
+ return true;
166
+ }
167
+ async setPassword(platform, userId, password) {
168
+ if (!password)
169
+ throw new Error('密码不能为空');
170
+ const key = `${platform}:${userId}`;
171
+ const salt = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('hex');
172
+ const hash = await deriveHash(password, salt, PBKDF2_ITERATIONS);
173
+ this.users.set(key, { ...this.users.get(key), secret: `pbkdf2:${PBKDF2_ITERATIONS}:${salt}:${hash}` });
174
+ this.dirty = true;
175
+ this.logger.info(`账户密码已更新: ${key}`);
176
+ }
177
+ async verifyPassword(platform, userId, password) {
178
+ const secret = this.users.get(`${platform}:${userId}`)?.secret;
179
+ if (!secret || !password)
180
+ return false;
181
+ const [scheme, iterStr, salt, hash] = secret.split(':');
182
+ const iterations = Number(iterStr);
183
+ if (scheme !== 'pbkdf2' || !Number.isFinite(iterations) || iterations < 1 || !salt || !hash)
184
+ return false;
185
+ const actual = await deriveHash(password, salt, iterations);
186
+ return timingSafeEqualStr(actual, hash);
187
+ }
188
+ hasPassword(platform, userId) {
189
+ return !!this.users.get(`${platform}:${userId}`)?.secret;
190
+ }
191
+ setUserCapabilities(platform, userId, overrides) {
192
+ const key = `${platform}:${userId}`;
193
+ const normalize = (list) => {
194
+ const cleaned = [...new Set((list ?? []).map(p => p.trim()).filter(Boolean))];
195
+ return cleaned.length > 0 ? cleaned : undefined;
196
+ };
197
+ const next = {
198
+ ...this.users.get(key),
199
+ grants: normalize(overrides.grants),
200
+ denies: normalize(overrides.denies),
201
+ };
202
+ if (next.level === undefined && !next.grants && !next.denies && !next.secret && !next.links) {
203
+ this.users.delete(key);
204
+ }
205
+ else {
206
+ this.users.set(key, next);
207
+ }
208
+ this.dirty = true;
209
+ this.logger.debug(`设置用户 capability 覆盖: ${key} grants=${next.grants?.length ?? 0} denies=${next.denies?.length ?? 0}`);
210
+ }
211
+ /**
212
+ * capability 中心统一闸。裁决优先级(per-capability):
213
+ * 全局 permissionPolicy > 用户 deny > 用户 grant > 角色链等级门槛。
214
+ *
215
+ * 等级门槛 = max(declaredAuthority, requiredAuthorityFor([cap]))——
216
+ * 即"操作声明的基础等级"与"capability 归属角色包"取较高者,只升不降。
217
+ */
218
+ authorize(identity, request) {
219
+ const level = this.getAuthority(identity.platform, identity.userId);
220
+ const declared = request.declaredAuthority ?? 0;
221
+ if (request.capabilities.length === 0) {
222
+ if (level < declared)
223
+ return `权限不足: 需要权限等级 ${declared},当前用户等级 ${level}`;
224
+ return null;
225
+ }
226
+ const policyDenied = this.checkPermissionPolicy(request.capabilities);
227
+ if (policyDenied)
228
+ return policyDenied;
229
+ // 被绑身份零合并解析:grants 以主账户为唯一真源;denies 取自身∪账户并集
230
+ // (自身记录的 deny 在绑定后仍生效——防"绑定洗白封禁")。
231
+ const ownKey = identity.userId ? `${identity.platform}:${identity.userId}` : undefined;
232
+ const ownRecord = ownKey ? this.users.get(ownKey) : undefined;
233
+ const accountKey = ownKey ? this.linkIndex.get(ownKey) : undefined;
234
+ const accountRecord = accountKey ? this.users.get(accountKey) : undefined;
235
+ const grants = accountKey ? accountRecord?.grants : ownRecord?.grants;
236
+ const denies = [...(ownRecord?.denies ?? []), ...(accountRecord?.denies ?? [])];
237
+ for (const cap of request.capabilities) {
238
+ if (denies.length > 0 && this.matchAny(denies, [cap])) {
239
+ return `已被禁止: ${cap}`;
240
+ }
241
+ if (grants && this.matchAny(grants, [cap]))
242
+ continue;
243
+ const required = Math.max(declared, this.requiredAuthorityFor([cap]));
244
+ if (level < required) {
245
+ return `权限不足: "${cap}" 需要权限等级 ${required},当前用户等级 ${level}`;
246
+ }
247
+ }
248
+ return null;
249
+ }
250
+ isDangerousAllowed(name, permissions = []) {
251
+ const policy = this.config.get('dangerousPolicy');
252
+ if (!policy?.allow || policy.allow.length === 0)
253
+ return false;
254
+ // 有限时策略时检查过期;未记录 enabledAt 视为未启用(重启后自动失效)
255
+ if (policy.duration && policy.duration > 0) {
256
+ if (!this.dangerousEnabledAt)
257
+ return false;
258
+ const elapsed = (Date.now() - this.dangerousEnabledAt) / 1000;
259
+ if (elapsed > policy.duration) {
260
+ this.logger.info('dangerous 白名单已过期');
261
+ return false;
262
+ }
263
+ }
264
+ return this.matchAny(policy.allow, [name, ...permissions]);
265
+ }
266
+ /** 刷新 dangerous 策略启动时间戳(运行时状态) */
267
+ markDangerousEnabled() {
268
+ this.dangerousEnabledAt = Date.now();
269
+ }
270
+ /** 清除 dangerous 策略启动时间戳 */
271
+ clearDangerousEnabled() {
272
+ this.dangerousEnabledAt = null;
273
+ }
274
+ setConfirmHandler(platform, handler) {
275
+ this.confirmHandlers.set(platform, handler);
276
+ }
277
+ async confirmDangerous(request) {
278
+ if (this.isDangerousAllowed(request.name, request.permissions))
279
+ return true;
280
+ const grant = this.consumeDangerousGrant(request);
281
+ if (grant) {
282
+ this.logger.info(`命中高危会话授权: ${request.type}:${request.name} session=${request.sessionId} grant=${grant.id} used=${grant.used}${grant.maxUses ? `/${grant.maxUses}` : ''}`);
283
+ return true;
284
+ }
285
+ const handler = this.confirmHandlers.get(request.platform);
286
+ if (handler) {
287
+ try {
288
+ const result = await handler(request);
289
+ const normalized = this.normalizeConfirmResult(result);
290
+ if (normalized.allowed && normalized.grant?.scope === 'session') {
291
+ this.createDangerousGrant(request, normalized);
292
+ }
293
+ return normalized.allowed;
294
+ }
295
+ catch (err) {
296
+ this.logger.warn(`高危确认回调异常: ${err}`);
297
+ return false;
298
+ }
299
+ }
300
+ return false;
301
+ }
302
+ /**
303
+ * 计算一组细粒度权限所要求的最低权限等级(参数级动态提权)。
304
+ *
305
+ * 例如 file_write 写普通文件只需声明的 authority:3,但写 data:/users.json
306
+ * (用户权限表)或 data:/scheduler-jobs.json(计划任务,可注入 owner 身份的
307
+ * actor)这类敏感文件、或写 aalis:/ 源码根(重启后即任意代码执行)时要求
308
+ * owner 等级,防止低权限用户借文件写入自我提权。
309
+ *
310
+ * 默认保护清单可被 config.permissionAuthority 覆盖/扩展(同模式取配置值,
311
+ * 新模式叠加;命中多个模式时取最大要求)。只提高门槛,不降低声明值。
312
+ */
313
+ /** 参数级提权完整清单(内置保护 + config.permissionAuthority 合并后;展示与裁决共用同一真源) */
314
+ getEscalationMap() {
315
+ const ownerLevel = this.config.get('ownerAuthority') ?? 5;
316
+ return {
317
+ 'storage:path:data:/users.json:write': ownerLevel,
318
+ 'storage:path:data:/users.json:delete': ownerLevel,
319
+ 'storage:path:data:/scheduler-jobs.json:write': ownerLevel,
320
+ 'storage:path:data:/scheduler-jobs.json:delete': ownerLevel,
321
+ 'storage:aalis:write': ownerLevel,
322
+ 'storage:aalis:delete': ownerLevel,
323
+ ...(this.config.get('permissionAuthority') ?? {}),
324
+ };
325
+ }
326
+ requiredAuthorityFor(permissions) {
327
+ if (permissions.length === 0)
328
+ return 0;
329
+ let required = 0;
330
+ for (const [pattern, level] of Object.entries(this.getEscalationMap())) {
331
+ if (level > required && this.matchAny([pattern], permissions))
332
+ required = level;
333
+ }
334
+ return required;
335
+ }
336
+ checkPermissionPolicy(permissions) {
337
+ const policy = this.config.get('permissionPolicy');
338
+ if (!policy)
339
+ return null;
340
+ const deny = policy.deny ?? [];
341
+ if (deny.length > 0 && this.matchAny(deny, permissions)) {
342
+ return `权限策略拒绝: ${permissions.join(', ')}`;
343
+ }
344
+ const allow = policy.allow ?? [];
345
+ if (allow.length > 0 && !this.matchAny(allow, permissions)) {
346
+ return `权限策略未允许: ${permissions.join(', ')}`;
347
+ }
348
+ return null;
349
+ }
350
+ matchAny(patterns, values) {
351
+ return patterns.some(pattern => values.some(value => this.matchPattern(pattern, value)));
352
+ }
353
+ matchPattern(pattern, value) {
354
+ if (pattern === '*' || pattern === value)
355
+ return true;
356
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*');
357
+ return new RegExp(`^${escaped}$`).test(value);
358
+ }
359
+ listDangerousGrants() {
360
+ this.pruneDangerousGrants();
361
+ return [...this.dangerousGrants.values()].map(grant => ({ ...grant }));
362
+ }
363
+ revokeDangerousGrant(id) {
364
+ const ok = this.dangerousGrants.delete(id);
365
+ if (ok)
366
+ this.logger.info(`已撤销高危会话授权: ${id}`);
367
+ return ok;
368
+ }
369
+ normalizeConfirmResult(result) {
370
+ return typeof result === 'boolean' ? { allowed: result } : result;
371
+ }
372
+ consumeDangerousGrant(request) {
373
+ this.pruneDangerousGrants();
374
+ for (const grant of this.dangerousGrants.values()) {
375
+ if (grant.type !== request.type)
376
+ continue;
377
+ if (grant.name !== request.name)
378
+ continue;
379
+ if (grant.sessionId !== request.sessionId)
380
+ continue;
381
+ if (grant.platform !== request.platform)
382
+ continue;
383
+ if (grant.userId && request.userId && grant.userId !== request.userId)
384
+ continue;
385
+ if (!this.samePermissions(grant.permissions, request.permissions))
386
+ continue;
387
+ grant.used++;
388
+ if (grant.maxUses && grant.used >= grant.maxUses) {
389
+ this.dangerousGrants.delete(grant.id);
390
+ }
391
+ return grant;
392
+ }
393
+ return undefined;
394
+ }
395
+ createDangerousGrant(request, result) {
396
+ const grantRequest = result.grant;
397
+ if (!grantRequest || grantRequest.scope !== 'session')
398
+ return;
399
+ // 创建新授权时顺带清扫过期/用尽的(对称 bindCodes 的发码即清扫——审计 MEDIUM #11)
400
+ this.pruneDangerousGrants();
401
+ const durationSeconds = Math.max(1, Math.min(grantRequest.durationSeconds ?? 600, 3600));
402
+ const grant = {
403
+ id: `grant_${Date.now()}_${++this.grantSeq}`,
404
+ name: request.name,
405
+ type: request.type,
406
+ permissions: request.permissions,
407
+ sessionId: request.sessionId,
408
+ platform: request.platform,
409
+ userId: request.userId,
410
+ expiresAt: Date.now() + durationSeconds * 1000,
411
+ maxUses: grantRequest.maxUses,
412
+ used: 0,
413
+ createdAt: Date.now(),
414
+ };
415
+ this.dangerousGrants.set(grant.id, grant);
416
+ this.logger.info(`创建高危会话授权: ${request.type}:${request.name} session=${request.sessionId} duration=${durationSeconds}s maxUses=${grant.maxUses ?? 'unlimited'} grant=${grant.id}`);
417
+ }
418
+ pruneDangerousGrants() {
419
+ const now = Date.now();
420
+ for (const [id, grant] of this.dangerousGrants) {
421
+ if (grant.expiresAt <= now || (grant.maxUses && grant.used >= grant.maxUses)) {
422
+ this.dangerousGrants.delete(id);
423
+ this.logger.debug(`高危会话授权已过期: ${id}`);
424
+ }
425
+ }
426
+ }
427
+ samePermissions(a, b) {
428
+ const left = [...new Set(a ?? [])].sort();
429
+ const right = [...new Set(b ?? [])].sort();
430
+ if (left.length !== right.length)
431
+ return false;
432
+ return left.every((value, index) => value === right[index]);
433
+ }
434
+ isOwner(platform, userId) {
435
+ if (!userId)
436
+ return false;
437
+ if ((platform === 'webui' || platform === 'cli') && userId === 'console')
438
+ return true;
439
+ const owners = this.config.get('owners') ?? [];
440
+ return owners.some((o) => o.platform === platform && o.userId === userId);
441
+ }
442
+ listUsers() {
443
+ const result = [];
444
+ const defaultLevel = this.config.get('defaultAuthority') ?? 1;
445
+ for (const [key, record] of this.users) {
446
+ const idx = key.indexOf(':');
447
+ const platform = key.slice(0, idx);
448
+ const userId = key.slice(idx + 1);
449
+ const linkedTo = this.linkIndex.get(key);
450
+ result.push({
451
+ platform,
452
+ userId,
453
+ // 被绑身份显示运行时有效等级(解析到主账户);自身记录被遮蔽留底
454
+ authority: linkedTo ? this.getAuthority(platform, userId) : (record.level ?? defaultLevel),
455
+ grants: record.grants,
456
+ denies: record.denies,
457
+ hasPassword: record.secret ? true : undefined,
458
+ links: record.links,
459
+ linkedTo,
460
+ });
461
+ }
462
+ // 无自身记录的被绑身份也要可见(绑定关系本身就是一条用户事实)
463
+ for (const [identityKey, accountKey] of this.linkIndex) {
464
+ if (this.users.has(identityKey))
465
+ continue;
466
+ const idx = identityKey.indexOf(':');
467
+ const platform = identityKey.slice(0, idx);
468
+ const userId = identityKey.slice(idx + 1);
469
+ result.push({ platform, userId, authority: this.getAuthority(platform, userId), linkedTo: accountKey });
470
+ }
471
+ return result;
472
+ }
473
+ save() {
474
+ if (!this.dirty)
475
+ return;
476
+ const users = {};
477
+ for (const [key, record] of this.users)
478
+ users[key] = record;
479
+ const payload = JSON.stringify({ version: 2, users }, null, 2);
480
+ this.dirty = false;
481
+ this.saveChain = this.saveChain
482
+ .then(() => this.storage.writeFile(this.fileUri, payload))
483
+ .then(() => {
484
+ this.logger.debug('用户权限数据已保存');
485
+ }, err => {
486
+ this.logger.warn(`保存用户权限数据失败: ${err}`);
487
+ this.dirty = true;
488
+ });
489
+ }
490
+ async init() {
491
+ try {
492
+ let raw;
493
+ try {
494
+ raw = (await this.storage.readFile(this.fileUri, 'utf-8'));
495
+ }
496
+ catch {
497
+ return;
498
+ }
499
+ const data = JSON.parse(raw);
500
+ if (data.version === 2 && typeof data.users === 'object' && data.users !== null) {
501
+ for (const [key, record] of Object.entries(data.users)) {
502
+ if (record && typeof record === 'object')
503
+ this.users.set(key, record);
504
+ }
505
+ }
506
+ else {
507
+ // v1 平面格式({"platform:userId": level}):就地迁移,下次 save 写 v2
508
+ for (const [key, level] of Object.entries(data)) {
509
+ if (typeof level === 'number')
510
+ this.users.set(key, { level });
511
+ }
512
+ if (this.users.size > 0) {
513
+ this.dirty = true;
514
+ this.logger.info(`users.json v1 → v2 迁移:${this.users.size} 条记录`);
515
+ }
516
+ }
517
+ this.rebuildLinkIndex();
518
+ this.logger.debug(`加载了 ${this.users.size} 条用户权限记录(绑定 ${this.linkIndex.size} 条)`);
519
+ }
520
+ catch (err) {
521
+ this.logger.warn(`加载用户权限数据失败: ${err}`);
522
+ }
523
+ }
524
+ }
525
+ // ===== 插件元数据 =====
526
+ export const name = '@aalis/plugin-authority';
527
+ export const displayName = '权限管理';
528
+ export const subsystem = 'authority';
529
+ export const provides = ['authority'];
530
+ export const inject = {
531
+ optional: ['commands', 'tools'],
532
+ };
533
+ const webuiPages = [
534
+ { key: 'authority', label: '权限管理', icon: 'authority', order: 50, renderer: 'authority' },
535
+ {
536
+ key: 'authority-graph',
537
+ label: '权限图',
538
+ icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7z"/><circle cx="12" cy="10" r="1.6"/><circle cx="8.5" cy="14.5" r="1.3"/><circle cx="15.5" cy="14.5" r="1.3"/><line x1="12" y1="11.5" x2="8.5" y2="13.3"/><line x1="12" y1="11.5" x2="15.5" y2="13.3"/></svg>',
539
+ order: 51,
540
+ content: [
541
+ {
542
+ type: 'graph',
543
+ label: '权限依赖图:用户 → 角色链 ← capability / 指令 / 工具(点击节点查看详情)',
544
+ source: 'getPermissionGraph',
545
+ detailSource: 'getPermissionNode',
546
+ defaultMaxDepth: 2,
547
+ defaultMaxBreadth: 30,
548
+ refresh: 0,
549
+ // 权限图自有图例(声明后组件不再用人物关系图的 person/event/entity 语义)
550
+ nodeKinds: [
551
+ { kind: 'user', label: '用户', shape: 'circle', color: '#60a5fa' },
552
+ { kind: 'role', label: '角色(等级)', shape: 'round-rect', color: '#f59e0b' },
553
+ { kind: 'capability', label: 'capability', shape: 'diamond', color: '#34d399' },
554
+ { kind: 'command', label: '指令', shape: 'round-rect', color: '#a855f7' },
555
+ { kind: 'tool', label: '工具', shape: 'round-rect', color: '#06b6d4' },
556
+ ],
557
+ edgeKinds: [
558
+ { kind: 'inherit', label: '继承', color: '#f59e0b' },
559
+ { kind: 'level', label: '等级归属', color: '#60a5fa' },
560
+ { kind: 'bind', label: '绑定', color: '#f472b6', dashed: true },
561
+ { kind: 'grant', label: '授予', color: '#34d399', dashed: true },
562
+ { kind: 'deny', label: '拒绝', color: '#ef4444' },
563
+ { kind: 'escalation', label: '提权要求', color: '#ef4444', dashed: true },
564
+ { kind: 'belongs', label: '归入角色包', color: '#6b7280' },
565
+ ],
566
+ },
567
+ ],
568
+ },
569
+ ];
570
+ // ===== 插件入口 =====
571
+ export async function apply(ctx, _config) {
572
+ // 注册 WebUI 页面
573
+ const webui = useWebuiService(ctx);
574
+ for (const page of webuiPages)
575
+ webui.registerPage(page);
576
+ const cmds = useCommandService(ctx);
577
+ const storage = createStorageGateway(ctx);
578
+ const authority = new AuthorityManager(ctx.config, ctx.logger, storage);
579
+ await authority.init();
580
+ ctx.provide('authority', authority);
581
+ // ===== 向 tools/commands 注入执行守卫 =====
582
+ const guard = async (guardCtx) => {
583
+ // ExecutionGuard 是 tool/command surface 的适配器:等级门槛、参数级提权、
584
+ // 全局策略与用户 grant/deny 全部收进 authorize 统一闸;dangerous 确认是
585
+ // 交互流程(弹窗/会话授权),保留在适配器层。
586
+ const capabilities = guardCtx.permissions?.length ? guardCtx.permissions : [`${guardCtx.type}:${guardCtx.name}`];
587
+ const denied = authority.authorize({ platform: guardCtx.platform, userId: guardCtx.userId }, { capabilities, declaredAuthority: guardCtx.authority });
588
+ if (denied)
589
+ return denied;
590
+ if (guardCtx.safety === 'dangerous' && !guardCtx.skipSafetyCheck) {
591
+ const confirmed = await authority.confirmDangerous({
592
+ name: guardCtx.name,
593
+ type: guardCtx.type,
594
+ args: guardCtx.args,
595
+ permissions: guardCtx.permissions,
596
+ sessionId: guardCtx.sessionId,
597
+ platform: guardCtx.platform,
598
+ userId: guardCtx.userId,
599
+ });
600
+ if (!confirmed) {
601
+ return `已取消执行${guardCtx.type === 'command' ? '指令' : '工具'} ${guardCtx.name}。`;
602
+ }
603
+ }
604
+ return null;
605
+ };
606
+ // 注入到 commands / tools 服务。whenService 会在 provider 上线(含 bounce 后
607
+ // 重新 provide)时各调一次,自动覆盖"authority 早于 provider"和"provider 重启"两种场景。
608
+ ctx.whenService('commands', svc => {
609
+ if (svc.setExecutionGuard) {
610
+ svc.setExecutionGuard(guard);
611
+ ctx.logger.debug('权限守卫已注入: commands');
612
+ }
613
+ });
614
+ ctx.whenService('tools', svc => {
615
+ if (svc.setExecutionGuard) {
616
+ svc.setExecutionGuard(guard);
617
+ ctx.logger.debug('权限守卫已注入: tools');
618
+ }
619
+ const toolOvr = ctx.config.get('toolOverrides');
620
+ if (toolOvr && svc.loadOverrides) {
621
+ svc.loadOverrides(toolOvr);
622
+ }
623
+ });
624
+ // ===== 应用停止时保存 =====
625
+ ctx.on('app:stopping', () => {
626
+ authority.save();
627
+ });
628
+ // ===== 权限指令 =====
629
+ // /grant — 设置用户权限等级
630
+ cmds
631
+ .command('grant <target:string> <level:number>', '设置用户权限等级', { authority: 2 })
632
+ .example('/grant onebot:12345 2')
633
+ .action(async (argv, target, level) => {
634
+ const t = target;
635
+ const lvl = level;
636
+ if (Number.isNaN(lvl) || lvl < 0)
637
+ return '权限等级必须是非负整数。';
638
+ const callerAuth = authority.getAuthority(argv.session.platform, argv.session.userId);
639
+ if (lvl >= callerAuth)
640
+ return `不能将权限设置为 >= 您自身的等级 (${callerAuth})。`;
641
+ const sep = t.indexOf(':');
642
+ if (sep < 1)
643
+ return '目标格式: <platform:userId>,例如 onebot:12345';
644
+ const platform = t.slice(0, sep);
645
+ const userId = t.slice(sep + 1);
646
+ authority.setAuthority(platform, userId, lvl);
647
+ authority.save();
648
+ return `已将 ${t} 的权限等级设置为 ${lvl}。`;
649
+ });
650
+ // /authority — 查看权限等级 + 个别授予/拒绝 + 绑定关系
651
+ const describeIdentity = (platform, userId, self) => {
652
+ const level = authority.getAuthority(platform, userId);
653
+ const isOwner = authority.isOwner(platform, userId);
654
+ const lines = [`${self ? '您' : `${platform}:${userId}`}的权限等级: ${level}${isOwner ? ' (owner)' : ''}`];
655
+ const entry = userId ? authority.listUsers().find(u => u.platform === platform && u.userId === userId) : undefined;
656
+ if (entry?.linkedTo)
657
+ lines.push(`已绑定到主账户 ${entry.linkedTo}(权限以账户为准)`);
658
+ if (entry?.links?.length)
659
+ lines.push(`已绑定身份: ${entry.links.join(', ')}`);
660
+ if (entry?.grants?.length)
661
+ lines.push(`个别授予: ${entry.grants.join(', ')}`);
662
+ if (entry?.denies?.length)
663
+ lines.push(`个别拒绝: ${entry.denies.join(', ')}`);
664
+ return lines.join('\n');
665
+ };
666
+ cmds.command('authority [target:string]', '查看自己或指定用户的权限等级与授予').action(async (argv, target) => {
667
+ const t = target;
668
+ if (t) {
669
+ const sep = t.indexOf(':');
670
+ if (sep < 1)
671
+ return '目标格式: <platform:userId>';
672
+ return describeIdentity(t.slice(0, sep), t.slice(sep + 1), false);
673
+ }
674
+ return describeIdentity(argv.session.platform, argv.session.userId, true);
675
+ });
676
+ // /bind — 把当前平台账号绑定到 WebUI 主账户(码在 WebUI 权限页生成)。
677
+ // 仅限私聊:群聊发码会把绑定码暴露给旁观者(Koishi 对公开信道需双 token
678
+ // 握手,我们直接限定私聊信道,等价其私聊路径)。
679
+ cmds
680
+ .command('bind <code:string>', '将当前平台账号绑定到 WebUI 账户', { authority: 1 })
681
+ .example('/bind AB12CD34')
682
+ .action(async (argv, code) => {
683
+ const { platform, userId, sessionType } = argv.session;
684
+ if (!userId)
685
+ return '无法识别您的身份,无法绑定。';
686
+ if (platform === 'webui' || platform === 'cli') {
687
+ return '请在外部平台(如 QQ)私聊中向机器人发送本指令。';
688
+ }
689
+ if (sessionType !== 'private') {
690
+ return '为防止绑定码泄露,请在私聊中使用本指令。';
691
+ }
692
+ try {
693
+ const account = authority.consumeBindCode(String(code).trim().toUpperCase(), { platform, userId });
694
+ authority.save();
695
+ return `绑定成功:${platform}:${userId} ↔ ${account.platform}:${account.userId}。您现在以该账户的权限行事,可在 WebUI 权限页解绑。`;
696
+ }
697
+ catch (err) {
698
+ return err instanceof Error ? err.message : String(err);
699
+ }
700
+ });
701
+ }
702
+ // ===== WebUI 操作处理器 =====
703
+ export const actions = {
704
+ /** 获取权限概览 */
705
+ async getOverview(ctx) {
706
+ const auth = ctx.getService('authority');
707
+ const users = auth?.listUsers() ?? [];
708
+ const owners = ctx.config.get('owners') ?? [];
709
+ const overrides = ctx.getService('commands')?.getOverrides() ?? {};
710
+ // 扁平化所有指令节点,按 dot 名顺序排列,便于 UI 表格渲染
711
+ const cmdNodes = ctx.getService('commands')?.getAll() ?? [];
712
+ const commandPrefix = ctx.getService('commands')?.prefix ?? '/';
713
+ const cmdNames = new Set(cmdNodes.map(n => n.name));
714
+ const tools = ctx.getService('tools')?.getAll() ?? [];
715
+ // 平台候选(WebUI 下拉用):身份系统的平台名 = 消息上的 platform 字段。
716
+ // 取 adapter.platform(getPlatformNames)而非服务提供者 contextId——后者是
717
+ // 插件实例名(如 @aalis/plugin-adapter-onebot),按它设的权限永远不会命中
718
+ // 任何真实调用者。webui/cli 是内置 surface,无 adapter,显式列入。
719
+ const platforms = Array.from(new Set([
720
+ ...getPlatformNames(ctx),
721
+ 'webui',
722
+ 'cli',
723
+ ...users.map(u => u.platform),
724
+ ...owners.map(o => o.platform),
725
+ ])).filter(Boolean);
726
+ return {
727
+ users,
728
+ owners,
729
+ platforms,
730
+ defaultAuthority: ctx.config.get('defaultAuthority') ?? 1,
731
+ ownerAuthority: ctx.config.get('ownerAuthority') ?? 5,
732
+ dangerousPolicy: ctx.config.get('dangerousPolicy') ?? {},
733
+ permissionPolicy: ctx.config.get('permissionPolicy') ?? {},
734
+ // 参数级动态提权清单(glob→等级;内置保护清单见 requiredAuthorityFor 文档)
735
+ permissionAuthority: ctx.config.get('permissionAuthority') ?? {},
736
+ dangerousGrants: auth?.listDangerousGrants() ?? [],
737
+ commandPrefix,
738
+ commands: cmdNodes.map(n => {
739
+ const path = n.name.split('.');
740
+ const depth = path.length - 1;
741
+ const hasSubcommands = cmdNodes.some(other => other.name.startsWith(`${n.name}.`));
742
+ return {
743
+ // key 同时是 override 的查找键与 setCommandOverride 的入参;如 'profile.clear.nuke'
744
+ key: n.name,
745
+ // 兼容旧前端:以 name 作 React key
746
+ name: n.name,
747
+ // 用于显示,如 '/profile clear nuke'
748
+ displayName: `${commandPrefix}${path.join(' ')}`,
749
+ // 叶子段名('nuke')用于子行紧凑显示
750
+ leafName: path[path.length - 1],
751
+ path,
752
+ depth,
753
+ isRoot: depth === 0,
754
+ hasSubcommands,
755
+ hasAction: !!n.handler,
756
+ description: n.description,
757
+ authority: n.authority,
758
+ safety: n.safety,
759
+ permissions: n.permissions,
760
+ baseAuthority: n.baseAuthority,
761
+ baseSafety: n.baseSafety,
762
+ basePermissions: n.basePermissions,
763
+ overridden: n.overridden,
764
+ pluginName: n.pluginName,
765
+ };
766
+ }),
767
+ commandOverrides: overrides,
768
+ orphanCommandOverrides: Object.keys(overrides).filter(k => !cmdNames.has(k)),
769
+ tools,
770
+ toolOverrides: ctx.getService('tools')?.getOverrides?.() ?? {},
771
+ };
772
+ },
773
+ /** 设置用户权限等级 */
774
+ async setUser(ctx, args, caller) {
775
+ const { platform, userId, authority } = args;
776
+ if (!platform || !userId || typeof authority !== 'number') {
777
+ throw new Error('platform, userId, authority(number) 必填');
778
+ }
779
+ if (authority < 0)
780
+ throw new Error('权限等级必须 >= 0');
781
+ const auth = ctx.getService('authority');
782
+ // 与 /grant 指令同语义的防越权检查:不能把任何人设到 >= 自身等级
783
+ // (caller 为登录账户的真实身份;单 token 模式为 webui:console=owner)。
784
+ if (caller && auth) {
785
+ const callerLevel = auth.getAuthority(caller.platform, caller.userId);
786
+ if (authority >= callerLevel) {
787
+ throw new Error(`不能将权限设置为 >= 您自身的等级 (${callerLevel})`);
788
+ }
789
+ }
790
+ auth?.setAuthority(platform, userId, authority);
791
+ auth?.save();
792
+ return { message: `${platform}:${userId} 权限已设为 ${authority}` };
793
+ },
794
+ /** 设置用户的 capability 个别授予/拒绝(deny > grant > 角色链) */
795
+ async setUserCapabilities(ctx, args, caller) {
796
+ const { platform, userId, grants, denies } = args;
797
+ if (!platform || !userId)
798
+ throw new Error('platform, userId 必填');
799
+ const asList = (v, label) => {
800
+ if (v === undefined || v === null)
801
+ return undefined;
802
+ if (!Array.isArray(v) || v.some(x => typeof x !== 'string'))
803
+ throw new Error(`${label} 必须是字符串数组`);
804
+ return v;
805
+ };
806
+ const auth = ctx.getService('authority');
807
+ if (!auth)
808
+ throw new Error('Authority 服务不可用');
809
+ // 防越权:不能改动等级 >= 自身的用户(与 setUser 同思路;改授予=改实际权力)
810
+ if (caller) {
811
+ const callerLevel = auth.getAuthority(caller.platform, caller.userId);
812
+ const targetLevel = auth.getAuthority(platform, userId);
813
+ const isSelf = caller.platform === platform && caller.userId === userId;
814
+ if (!isSelf && targetLevel >= callerLevel) {
815
+ throw new Error(`不能修改等级 >= 您自身 (${callerLevel}) 的用户的 capability 授予`);
816
+ }
817
+ }
818
+ auth.setUserCapabilities(platform, userId, {
819
+ grants: asList(grants, 'grants'),
820
+ denies: asList(denies, 'denies'),
821
+ });
822
+ auth.save();
823
+ return { message: `${platform}:${userId} 的 capability 授予已更新` };
824
+ },
825
+ /** 设置/重置账户密码(webui 登录凭据;仅本人或更高等级者可操作) */
826
+ async setPassword(ctx, args, caller) {
827
+ const { platform, userId, password } = args;
828
+ if (!platform || !userId || typeof password !== 'string')
829
+ throw new Error('platform, userId, password 必填');
830
+ if (password.length < 6)
831
+ throw new Error('密码长度至少 6 位');
832
+ const auth = ctx.getService('authority');
833
+ if (!auth)
834
+ throw new Error('Authority 服务不可用');
835
+ if (caller) {
836
+ const callerLevel = auth.getAuthority(caller.platform, caller.userId);
837
+ const targetLevel = auth.getAuthority(platform, userId);
838
+ const isSelf = caller.platform === platform && caller.userId === userId;
839
+ if (!isSelf && targetLevel >= callerLevel) {
840
+ throw new Error(`不能为等级 >= 您自身 (${callerLevel}) 的用户设置密码`);
841
+ }
842
+ }
843
+ await auth.setPassword(platform, userId, password);
844
+ auth.save();
845
+ return { message: `${platform}:${userId} 密码已更新` };
846
+ },
847
+ /** 权限依赖图(graph 组件数据源):用户 → 角色链 ← capability / 指令 / 工具 */
848
+ async getPermissionGraph(ctx) {
849
+ const auth = ctx.getService('authority');
850
+ if (!auth)
851
+ throw new Error('Authority 服务不可用');
852
+ const ownerLevel = ctx.config.get('ownerAuthority') ?? 5;
853
+ const clamp = (n) => Math.max(0, Math.min(ownerLevel, Math.round(n)));
854
+ const nodes = [];
855
+ const edges = [];
856
+ // 角色链(kind=role:圆角矩形;尺寸随等级增大)
857
+ for (let n = 0; n <= ownerLevel; n++) {
858
+ nodes.push({
859
+ data: {
860
+ id: `role:${n}`,
861
+ label: n === ownerLevel ? `owner (${n})` : `等级 ${n}`,
862
+ kind: 'role',
863
+ pageRankScale: 0.35 + (0.65 * n) / ownerLevel,
864
+ },
865
+ });
866
+ if (n > 0) {
867
+ edges.push({
868
+ data: {
869
+ id: `inherit:${n}`,
870
+ source: `role:${n}`,
871
+ target: `role:${n - 1}`,
872
+ label: '继承',
873
+ kind: 'inherit',
874
+ directed: true,
875
+ },
876
+ });
877
+ }
878
+ }
879
+ // capability 节点(kind=capability:菱形),按模式去重
880
+ const capIds = new Set();
881
+ const capNode = (pattern) => {
882
+ if (!capIds.has(pattern)) {
883
+ capIds.add(pattern);
884
+ nodes.push({ data: { id: `cap:${pattern}`, label: pattern, kind: 'capability', pageRankScale: 0.3 } });
885
+ }
886
+ return `cap:${pattern}`;
887
+ };
888
+ // 用户(users.json + owners 配置 + 单 token 模式的 console)
889
+ const users = auth.listUsers();
890
+ const owners = ctx.config.get('owners') ?? [];
891
+ const userIds = new Set();
892
+ const userNode = (platform, userId) => {
893
+ const key = `${platform}:${userId}`;
894
+ if (!userIds.has(key)) {
895
+ userIds.add(key);
896
+ nodes.push({ data: { id: `user:${key}`, label: key, kind: 'user', pageRankScale: 0.55 } });
897
+ }
898
+ return `user:${key}`;
899
+ };
900
+ for (const u of users) {
901
+ const id = userNode(u.platform, u.userId);
902
+ if (u.linkedTo) {
903
+ // 被绑身份:权限随主账户走,只画绑定边(等级边由账户承担)
904
+ const idx = u.linkedTo.indexOf(':');
905
+ edges.push({
906
+ data: {
907
+ id: `bind:${u.platform}:${u.userId}`,
908
+ source: id,
909
+ target: userNode(u.linkedTo.slice(0, idx), u.linkedTo.slice(idx + 1)),
910
+ label: '绑定',
911
+ kind: 'bind',
912
+ directed: true,
913
+ },
914
+ });
915
+ }
916
+ else {
917
+ edges.push({
918
+ data: {
919
+ id: `lvl:${u.platform}:${u.userId}`,
920
+ source: id,
921
+ target: `role:${clamp(u.authority)}`,
922
+ label: '等级',
923
+ kind: 'level',
924
+ directed: true,
925
+ },
926
+ });
927
+ }
928
+ for (const g of u.grants ?? []) {
929
+ edges.push({
930
+ data: {
931
+ id: `grant:${u.platform}:${u.userId}:${g}`,
932
+ source: id,
933
+ target: capNode(g),
934
+ label: '授予',
935
+ kind: 'grant',
936
+ directed: true,
937
+ },
938
+ });
939
+ }
940
+ for (const d of u.denies ?? []) {
941
+ edges.push({
942
+ data: {
943
+ id: `deny:${u.platform}:${u.userId}:${d}`,
944
+ source: id,
945
+ target: capNode(d),
946
+ label: '拒绝',
947
+ kind: 'deny',
948
+ directed: true,
949
+ },
950
+ });
951
+ }
952
+ }
953
+ for (const o of owners) {
954
+ edges.push({
955
+ data: {
956
+ id: `owner:${o.platform}:${o.userId}`,
957
+ source: userNode(o.platform, o.userId),
958
+ target: `role:${ownerLevel}`,
959
+ label: 'owner',
960
+ kind: 'level',
961
+ directed: true,
962
+ },
963
+ });
964
+ }
965
+ edges.push({
966
+ data: {
967
+ id: 'console-owner',
968
+ source: userNode('webui', 'console'),
969
+ target: `role:${ownerLevel}`,
970
+ label: '单 token/本地',
971
+ kind: 'level',
972
+ directed: true,
973
+ },
974
+ });
975
+ // 参数级提权清单(内置保护 + 配置,与裁决同源)
976
+ for (const [pattern, level] of Object.entries(auth.getEscalationMap())) {
977
+ edges.push({
978
+ data: {
979
+ id: `esc:${pattern}`,
980
+ source: capNode(pattern),
981
+ target: `role:${clamp(level)}`,
982
+ label: '需等级',
983
+ kind: 'escalation',
984
+ directed: true,
985
+ },
986
+ });
987
+ }
988
+ // 指令与工具(仅根指令控制规模)
989
+ const cmds = (ctx.getService('commands')?.getAll() ?? []).filter(c => !c.name.includes('.'));
990
+ for (const c of cmds) {
991
+ nodes.push({
992
+ data: {
993
+ id: `cmd:${c.name}`,
994
+ label: `/${c.name}${c.safety === 'dangerous' ? ' ⚠' : ''}`,
995
+ kind: 'command',
996
+ pageRankScale: 0.12,
997
+ },
998
+ });
999
+ edges.push({
1000
+ data: {
1001
+ id: `cmd-lvl:${c.name}`,
1002
+ source: `cmd:${c.name}`,
1003
+ target: `role:${clamp(c.authority ?? 1)}`,
1004
+ label: '归入',
1005
+ kind: 'belongs',
1006
+ directed: true,
1007
+ },
1008
+ });
1009
+ }
1010
+ const toolDefs = ctx.getService('tools')?.getAll() ?? [];
1011
+ for (const t of toolDefs) {
1012
+ nodes.push({
1013
+ data: {
1014
+ id: `tool:${t.name}`,
1015
+ label: `${t.name}${t.safety === 'dangerous' ? ' ⚠' : ''}`,
1016
+ kind: 'tool',
1017
+ pageRankScale: 0.12,
1018
+ },
1019
+ });
1020
+ edges.push({
1021
+ data: {
1022
+ id: `tool-lvl:${t.name}`,
1023
+ source: `tool:${t.name}`,
1024
+ target: `role:${clamp(t.authority ?? 1)}`,
1025
+ label: '归入',
1026
+ kind: 'belongs',
1027
+ directed: true,
1028
+ },
1029
+ });
1030
+ }
1031
+ return {
1032
+ nodes,
1033
+ edges,
1034
+ stats: {
1035
+ 用户: userIds.size,
1036
+ 角色: ownerLevel + 1,
1037
+ 指令根: cmds.length,
1038
+ 工具: toolDefs.length,
1039
+ capability: capIds.size,
1040
+ },
1041
+ };
1042
+ },
1043
+ /** 权限图节点详情(graph 组件 detailSource) */
1044
+ async getPermissionNode(ctx, args) {
1045
+ const nodeId = String(args.nodeId ?? '');
1046
+ const auth = ctx.getService('authority');
1047
+ if (!auth)
1048
+ throw new Error('Authority 服务不可用');
1049
+ const ownerLevel = ctx.config.get('ownerAuthority') ?? 5;
1050
+ if (nodeId.startsWith('user:')) {
1051
+ const key = nodeId.slice(5);
1052
+ const idx = key.indexOf(':');
1053
+ const platform = key.slice(0, idx);
1054
+ const userId = key.slice(idx + 1);
1055
+ const entry = auth.listUsers().find(u => u.platform === platform && u.userId === userId);
1056
+ return {
1057
+ 身份: key,
1058
+ 有效等级: auth.getAuthority(platform, userId),
1059
+ owner: auth.isOwner(platform, userId) || undefined,
1060
+ 可登录账户: entry?.hasPassword || undefined,
1061
+ 绑定到: entry?.linkedTo,
1062
+ 已绑身份: entry?.links?.join(', '),
1063
+ 个别授予: entry?.grants?.join(', '),
1064
+ 个别拒绝: entry?.denies?.join(', '),
1065
+ };
1066
+ }
1067
+ if (nodeId.startsWith('role:')) {
1068
+ const n = Number(nodeId.slice(5));
1069
+ const holders = auth.listUsers().filter(u => !u.linkedTo && u.authority === n).length;
1070
+ return {
1071
+ 角色: n === ownerLevel ? `owner(等级 ${n})` : `等级 ${n}`,
1072
+ 语义: '内置角色链:高等级继承低等级的全部授予;capability 图为唯一裁决',
1073
+ 显式持有用户数: holders,
1074
+ };
1075
+ }
1076
+ if (nodeId.startsWith('cap:')) {
1077
+ const pattern = nodeId.slice(4);
1078
+ const escalation = auth.getEscalationMap()[pattern];
1079
+ return {
1080
+ capability: pattern,
1081
+ 提权要求: escalation !== undefined ? `等级 ${escalation}` : undefined,
1082
+ 说明: 'glob 模式,按 PermissionId 匹配;裁决优先级 deny > grant > 角色链',
1083
+ };
1084
+ }
1085
+ if (nodeId.startsWith('cmd:')) {
1086
+ const cmdName = nodeId.slice(4);
1087
+ const c = ctx
1088
+ .getService('commands')
1089
+ ?.getAll()
1090
+ .find(x => x.name === cmdName);
1091
+ if (!c)
1092
+ return { error: `指令 ${cmdName} 不存在` };
1093
+ return {
1094
+ 指令: `/${cmdName}`,
1095
+ 描述: c.description,
1096
+ 所需等级: c.authority,
1097
+ 安全等级: c.safety,
1098
+ capability: c.permissions?.join(', '),
1099
+ 来源插件: c.pluginName,
1100
+ };
1101
+ }
1102
+ if (nodeId.startsWith('tool:')) {
1103
+ const toolName = nodeId.slice(5);
1104
+ const t = ctx
1105
+ .getService('tools')
1106
+ ?.getAll()
1107
+ .find(x => x.name === toolName);
1108
+ if (!t)
1109
+ return { error: `工具 ${toolName} 不存在` };
1110
+ return {
1111
+ 工具: toolName,
1112
+ 描述: t.description,
1113
+ 所需等级: t.authority ?? 1,
1114
+ 安全等级: t.safety ?? 'safe',
1115
+ capability: t.permissions?.join(', '),
1116
+ 来源插件: t.pluginName,
1117
+ };
1118
+ }
1119
+ return { error: `未知节点: ${nodeId}` };
1120
+ },
1121
+ /** 生成跨平台绑定码(绑定到调用者自己的账户;5 分钟内在外部平台私聊发 /bind <码>) */
1122
+ async createBindCode(ctx, _args, caller) {
1123
+ if (!caller)
1124
+ throw new Error('无法识别调用者身份');
1125
+ const auth = ctx.getService('authority');
1126
+ if (!auth)
1127
+ throw new Error('Authority 服务不可用');
1128
+ const { code, expiresAt } = auth.createBindCode(caller.platform, caller.userId);
1129
+ const prefix = ctx.getService('commands')?.prefix ?? '/';
1130
+ return {
1131
+ code,
1132
+ expiresAt,
1133
+ hint: `请在 5 分钟内,用要绑定的平台账号(如 QQ)私聊向机器人发送:${prefix}bind ${code}`,
1134
+ };
1135
+ },
1136
+ /** 解绑平台身份(owner 或该绑定所属账户本人) */
1137
+ async unlinkIdentity(ctx, args, caller) {
1138
+ const { platform, userId } = args;
1139
+ if (!platform || !userId)
1140
+ throw new Error('platform, userId 必填');
1141
+ const auth = ctx.getService('authority');
1142
+ if (!auth)
1143
+ throw new Error('Authority 服务不可用');
1144
+ if (caller) {
1145
+ const identityKey = `${platform}:${userId}`;
1146
+ const ownerLevel = ctx.config.get('ownerAuthority') ?? 5;
1147
+ const callerLevel = auth.getAuthority(caller.platform, caller.userId);
1148
+ const owningAccount = auth.listUsers().find(u => u.links?.includes(identityKey));
1149
+ const isSelf = owningAccount && owningAccount.platform === caller.platform && owningAccount.userId === caller.userId;
1150
+ if (!isSelf && callerLevel < ownerLevel) {
1151
+ throw new Error('只有绑定所属账户本人或 owner 可以解绑');
1152
+ }
1153
+ }
1154
+ const ok = auth.unlinkIdentity(platform, userId);
1155
+ auth.save();
1156
+ return { ok, message: ok ? `${platform}:${userId} 已解绑` : '该身份没有绑定记录' };
1157
+ },
1158
+ /** 删除用户权限记录(等级回退默认,grants/denies/密码一并清除) */
1159
+ async deleteUser(ctx, args) {
1160
+ const { platform, userId } = args;
1161
+ if (!platform || !userId)
1162
+ throw new Error('platform, userId 必填');
1163
+ const auth = ctx.getService('authority');
1164
+ auth?.removeUser(platform, userId);
1165
+ auth?.save();
1166
+ return { message: `${platform}:${userId} 权限已重置` };
1167
+ },
1168
+ /** 更新 owner 列表 */
1169
+ async setOwners(ctx, args) {
1170
+ const owners = args.owners;
1171
+ if (!Array.isArray(owners))
1172
+ throw new Error('owners 必须是数组');
1173
+ const app = ctx.getService('app');
1174
+ if (!app)
1175
+ throw new Error('App 不可用');
1176
+ ctx.config.set('owners', owners);
1177
+ app.saveConfig();
1178
+ return { message: 'Owner 列表已更新' };
1179
+ },
1180
+ /** 更新 dangerousPolicy */
1181
+ async setDangerousPolicy(ctx, args) {
1182
+ const policy = args.policy;
1183
+ if (!policy || typeof policy !== 'object')
1184
+ throw new Error('policy 必须是对象');
1185
+ const app = ctx.getService('app');
1186
+ if (!app)
1187
+ throw new Error('App 不可用');
1188
+ ctx.config.set('dangerousPolicy', policy);
1189
+ app.saveConfig();
1190
+ // 启用限时策略时,标记运行时启动时间戳(不写入 config)
1191
+ if (Array.isArray(policy.allow) && policy.allow.length > 0) {
1192
+ const auth = ctx.getService('authority');
1193
+ auth?.markDangerousEnabled?.();
1194
+ }
1195
+ return { message: '高危策略已更新' };
1196
+ },
1197
+ /** 撤销一个高危会话授权 */
1198
+ async revokeDangerousGrant(ctx, args) {
1199
+ const id = args.id;
1200
+ if (!id)
1201
+ throw new Error('id 必须是字符串');
1202
+ const auth = ctx.getService('authority');
1203
+ if (!auth)
1204
+ throw new Error('Authority 服务不可用');
1205
+ const ok = auth.revokeDangerousGrant(id);
1206
+ return { ok, message: ok ? '授权已撤销' : '授权不存在或已过期' };
1207
+ },
1208
+ /** 更新全局权限配置(defaultAuthority, ownerAuthority) */
1209
+ async setConfig(ctx, args) {
1210
+ const { defaultAuthority, ownerAuthority } = args;
1211
+ const app = ctx.getService('app');
1212
+ if (!app)
1213
+ throw new Error('App 不可用');
1214
+ if (typeof defaultAuthority === 'number')
1215
+ ctx.config.set('defaultAuthority', defaultAuthority);
1216
+ if (typeof ownerAuthority === 'number')
1217
+ ctx.config.set('ownerAuthority', ownerAuthority);
1218
+ app.saveConfig();
1219
+ return { message: '权限配置已更新' };
1220
+ },
1221
+ /** 更新单条指令的权限覆盖 */
1222
+ async setCommandOverride(ctx, args) {
1223
+ const { name, authority, safety } = args;
1224
+ if (!name || typeof name !== 'string')
1225
+ throw new Error('name 必填');
1226
+ const app = ctx.getService('app');
1227
+ if (!app)
1228
+ throw new Error('App 不可用');
1229
+ const override = {};
1230
+ if (typeof authority === 'number')
1231
+ override.authority = authority;
1232
+ if (typeof safety === 'string' && (safety === 'safe' || safety === 'dangerous'))
1233
+ override.safety = safety;
1234
+ if (Object.keys(override).length === 0) {
1235
+ ctx.getService('commands')?.removeOverride(name);
1236
+ }
1237
+ else {
1238
+ ctx.getService('commands')?.setOverride(name, override);
1239
+ }
1240
+ ctx.config.set('commandOverrides', ctx.getService('commands')?.getOverrides() ?? {});
1241
+ app.saveConfig();
1242
+ return { message: `指令 ${name} 权限已更新` };
1243
+ },
1244
+ /** 重置指令覆盖 */
1245
+ async resetCommandOverride(ctx, args) {
1246
+ const { name } = args;
1247
+ if (!name || typeof name !== 'string')
1248
+ throw new Error('name 必填');
1249
+ const app = ctx.getService('app');
1250
+ if (!app)
1251
+ throw new Error('App 不可用');
1252
+ ctx.getService('commands')?.removeOverride(name);
1253
+ ctx.config.set('commandOverrides', ctx.getService('commands')?.getOverrides() ?? {});
1254
+ app.saveConfig();
1255
+ return { message: `指令 ${name} 覆盖已重置` };
1256
+ },
1257
+ /** 更新单个工具的权限覆盖 */
1258
+ async setToolOverride(ctx, args) {
1259
+ const { name, authority, safety } = args;
1260
+ if (!name || typeof name !== 'string')
1261
+ throw new Error('name 必填');
1262
+ const app = ctx.getService('app');
1263
+ const tools = ctx.getService('tools');
1264
+ if (!app)
1265
+ throw new Error('App 不可用');
1266
+ if (!tools?.setOverride)
1267
+ throw new Error('ToolService 未支持 override');
1268
+ const override = {};
1269
+ if (typeof authority === 'number')
1270
+ override.authority = authority;
1271
+ if (typeof safety === 'string' && (safety === 'safe' || safety === 'dangerous'))
1272
+ override.safety = safety;
1273
+ if (Object.keys(override).length === 0) {
1274
+ tools.removeOverride?.(name);
1275
+ }
1276
+ else {
1277
+ tools.setOverride(name, override);
1278
+ }
1279
+ ctx.config.set('toolOverrides', tools.getOverrides?.() ?? {});
1280
+ app.saveConfig();
1281
+ return { message: `工具 ${name} 权限已更新` };
1282
+ },
1283
+ /** 重置工具覆盖 */
1284
+ async resetToolOverride(ctx, args) {
1285
+ const { name } = args;
1286
+ if (!name || typeof name !== 'string')
1287
+ throw new Error('name 必填');
1288
+ const app = ctx.getService('app');
1289
+ const tools = ctx.getService('tools');
1290
+ if (!app)
1291
+ throw new Error('App 不可用');
1292
+ tools?.removeOverride?.(name);
1293
+ ctx.config.set('toolOverrides', tools?.getOverrides?.() ?? {});
1294
+ app.saveConfig();
1295
+ return { message: `工具 ${name} 覆盖已重置` };
1296
+ },
1297
+ };
1298
+ // actions 权限标注:createBindCode / unlinkIdentity 对任何登录账户开放
1299
+ // (绑码只能绑到调用者自己;解绑有 handler 内的本人/owner 业务检查);
1300
+ // 权限图为管理读档(含用户表信息,与 REST 管理读同档=4)。
1301
+ // 其余 action 不声明 → 默认要求 owner(默认拒绝)。
1302
+ export const actionsMeta = {
1303
+ createBindCode: { authority: 1 },
1304
+ unlinkIdentity: { authority: 1 },
1305
+ getPermissionGraph: { authority: 4 },
1306
+ getPermissionNode: { authority: 4 },
1307
+ };
1308
+ //# sourceMappingURL=index.js.map