plugin-cluster-manager 1.1.10 → 1.1.13
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/client-v2.d.ts +2 -0
- package/client-v2.js +1 -0
- package/client.js +1 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/914.5dc1105cf3ada6a6.js +10 -0
- package/dist/client-v2/index.js +10 -0
- package/dist/externalVersion.js +6 -5
- package/dist/locale/en-US.json +138 -28
- package/dist/locale/vi-VN.json +139 -28
- package/dist/locale/zh-CN.json +140 -28
- package/dist/server/actions/cache-monitor.js +301 -0
- package/dist/server/actions/cluster-nodes.js +391 -11
- package/dist/server/actions/doctor.js +1246 -0
- package/dist/server/actions/orchestrator.js +37 -0
- package/dist/server/actions/queue-mappings.js +107 -0
- package/dist/server/collections/cluster-manager-doctor-runs.js +52 -0
- package/dist/server/collections/cluster-manager-doctor.js +44 -0
- package/dist/server/collections/worker-queue-mappings.js +106 -0
- package/dist/server/hooks/cacheInvalidationHooks.js +81 -0
- package/dist/server/middlewares/listMetaCacheMiddleware.js +79 -0
- package/dist/server/orchestrator/PackageManager.js +21 -24
- package/dist/server/orchestrator/docker-adapter.js +49 -27
- package/dist/server/plugin.js +71 -16
- package/dist/server/queue-scanner.js +141 -0
- package/dist/server/utils/node.js +30 -2
- package/dist/server/utils/versionManager.js +91 -0
- package/package.json +9 -5
- package/server.js +1 -0
- package/src/client/AclCacheManager.tsx +292 -287
- package/src/client/CacheMonitor.tsx +166 -179
- package/src/client/ClusterManagerLayout.tsx +54 -42
- package/src/client/ClusterNodes.tsx +698 -418
- package/src/client/ContainerOrchestrator.tsx +184 -102
- package/src/client/Doctor.tsx +559 -0
- package/src/client/NginxCacheManager.tsx +415 -0
- package/src/client/PluginOperations.tsx +234 -234
- package/src/client/QueueAssignment.tsx +355 -0
- package/src/client/TaskManager.tsx +194 -187
- package/src/client/WorkflowExecutions.tsx +243 -238
- package/src/client/index.tsx +22 -14
- package/src/client/utils/clientSafeCache.ts +41 -0
- package/src/client/utils/requestDedupInterceptor.ts +213 -0
- package/src/client-v2/plugin.tsx +24 -0
- package/src/locale/en-US.json +138 -28
- package/src/locale/vi-VN.json +139 -28
- package/src/locale/zh-CN.json +140 -28
- package/src/server/__tests__/doctor.test.ts +53 -0
- package/src/server/actions/acl-cache.ts +272 -272
- package/src/server/actions/cache-monitor.ts +453 -116
- package/src/server/actions/cluster-nodes.ts +878 -378
- package/src/server/actions/doctor.ts +1536 -0
- package/src/server/actions/orchestrator.ts +54 -2
- package/src/server/actions/queue-mappings.ts +94 -0
- package/src/server/collections/cluster-manager-doctor-runs.ts +23 -0
- package/src/server/collections/cluster-manager-doctor.ts +19 -0
- package/src/server/collections/worker-queue-mappings.ts +85 -0
- package/src/server/hooks/cacheInvalidationHooks.ts +58 -0
- package/src/server/middlewares/listMetaCacheMiddleware.ts +55 -0
- package/src/server/orchestrator/PackageManager.ts +20 -24
- package/src/server/orchestrator/docker-adapter.ts +74 -37
- package/src/server/plugin.ts +347 -270
- package/src/server/queue-scanner.ts +154 -0
- package/src/server/utils/node.ts +48 -0
- package/src/server/utils/versionManager.ts +69 -0
- package/dist/client/AclCacheManager.d.ts +0 -2
- package/dist/client/CacheMonitor.d.ts +0 -2
- package/dist/client/ClusterManagerLayout.d.ts +0 -2
- package/dist/client/ClusterNodes.d.ts +0 -2
- package/dist/client/ContainerOrchestrator.d.ts +0 -2
- package/dist/client/EventQueueMonitor.d.ts +0 -2
- package/dist/client/LockMonitor.d.ts +0 -2
- package/dist/client/PackageInstaller.d.ts +0 -2
- package/dist/client/PluginOperations.d.ts +0 -2
- package/dist/client/RedisMonitor.d.ts +0 -2
- package/dist/client/TaskManager.d.ts +0 -2
- package/dist/client/WorkflowExecutions.d.ts +0 -2
- package/dist/client/index.d.ts +0 -5
- package/dist/client/utils.d.ts +0 -12
- package/dist/index.d.ts +0 -2
- package/dist/server/actions/acl-cache.d.ts +0 -53
- package/dist/server/actions/cache-monitor.d.ts +0 -23
- package/dist/server/actions/cluster-nodes.d.ts +0 -49
- package/dist/server/actions/event-queue-monitor.d.ts +0 -13
- package/dist/server/actions/lock-monitor.d.ts +0 -19
- package/dist/server/actions/orchestrator.d.ts +0 -58
- package/dist/server/actions/package-manager.d.ts +0 -6
- package/dist/server/actions/plugin-operations.d.ts +0 -6
- package/dist/server/actions/redis-monitor.d.ts +0 -12
- package/dist/server/actions/tasks.d.ts +0 -7
- package/dist/server/actions/workflow-executions.d.ts +0 -7
- package/dist/server/adapters/redis-lock-adapter.d.ts +0 -15
- package/dist/server/adapters/redis-node-registry.d.ts +0 -12
- package/dist/server/adapters/redis-pubsub-adapter.d.ts +0 -16
- package/dist/server/collections/app.d.ts +0 -8
- package/dist/server/collections/cluster-manager-acl-cache.d.ts +0 -22
- package/dist/server/collections/cluster-manager-cache-mgr.d.ts +0 -22
- package/dist/server/collections/cluster-manager-cluster.d.ts +0 -22
- package/dist/server/collections/cluster-manager-lock.d.ts +0 -22
- package/dist/server/collections/cluster-manager-plugins.d.ts +0 -18
- package/dist/server/collections/cluster-manager-queue.d.ts +0 -22
- package/dist/server/collections/cluster-manager-redis.d.ts +0 -22
- package/dist/server/collections/cluster-manager-workflow.d.ts +0 -22
- package/dist/server/collections/cluster-manager.d.ts +0 -22
- package/dist/server/collections/orchestrator-settings.d.ts +0 -59
- package/dist/server/collections/orchestrator-stacks.d.ts +0 -102
- package/dist/server/collections/worker-orchestrator.d.ts +0 -22
- package/dist/server/collections/worker-packages-configs.d.ts +0 -3
- package/dist/server/collections/worker-packages.d.ts +0 -22
- package/dist/server/orchestrator/PackageManager.d.ts +0 -39
- package/dist/server/orchestrator/docker-adapter.d.ts +0 -41
- package/dist/server/orchestrator/index.d.ts +0 -4
- package/dist/server/orchestrator/k8s-adapter.d.ts +0 -50
- package/dist/server/orchestrator/leader-election.d.ts +0 -48
- package/dist/server/orchestrator/types.d.ts +0 -84
- package/dist/server/plugin.d.ts +0 -26
- package/dist/server/utils/node.d.ts +0 -6
- package/dist/server/utils/redis.d.ts +0 -29
- package/dist/shared/packages.d.ts +0 -23
- /package/{dist/server/index.d.ts → src/client-v2/index.tsx} +0 -0
|
@@ -1,272 +1,272 @@
|
|
|
1
|
-
import { Context } from '@nocobase/actions';
|
|
2
|
-
import { scanKeys, deleteKeysChunked } from '../utils/redis';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* In-memory ACL stats counter.
|
|
6
|
-
* Tracks total checks, cache hits/misses per role:resource:action.
|
|
7
|
-
*/
|
|
8
|
-
export interface AclCacheStats {
|
|
9
|
-
totalChecks: number;
|
|
10
|
-
cacheHits: number;
|
|
11
|
-
cacheMisses: number;
|
|
12
|
-
startedAt: string;
|
|
13
|
-
detailByRole: Record<string, { checks: number; hits: number; misses: number }>;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const stats: AclCacheStats = {
|
|
17
|
-
totalChecks: 0,
|
|
18
|
-
cacheHits: 0,
|
|
19
|
-
cacheMisses: 0,
|
|
20
|
-
startedAt: new Date().toISOString(),
|
|
21
|
-
detailByRole: {},
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
const ACL_CACHE_PREFIX = 'acl:can:';
|
|
25
|
-
const ACL_CACHE_TTL = 60 * 5; // 5 minutes default
|
|
26
|
-
|
|
27
|
-
function getCacheKey(role: string, resource: string, action: string): string {
|
|
28
|
-
return `${ACL_CACHE_PREFIX}${role}:${resource}:${action}`;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function recordStat(role: string, hit: boolean) {
|
|
32
|
-
stats.totalChecks++;
|
|
33
|
-
if (hit) {
|
|
34
|
-
stats.cacheHits++;
|
|
35
|
-
} else {
|
|
36
|
-
stats.cacheMisses++;
|
|
37
|
-
}
|
|
38
|
-
if (!stats.detailByRole[role]) {
|
|
39
|
-
stats.detailByRole[role] = { checks: 0, hits: 0, misses: 0 };
|
|
40
|
-
}
|
|
41
|
-
stats.detailByRole[role].checks++;
|
|
42
|
-
if (hit) {
|
|
43
|
-
stats.detailByRole[role].hits++;
|
|
44
|
-
} else {
|
|
45
|
-
stats.detailByRole[role].misses++;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Middleware that caches the permission object computed by the ACL middleware.
|
|
51
|
-
* Install via: app.acl.use(aclCacheMiddleware, { tag: 'aclCache', before: 'core', after: 'allow-manager' })
|
|
52
|
-
*
|
|
53
|
-
* FIX: Previously monkey-patched ctx.app.acl.can per-request, which is a race condition
|
|
54
|
-
* because acl is a shared singleton across concurrent requests. Now we use a post-check
|
|
55
|
-
* approach that reads from cache first and writes after the ACL middleware runs, without
|
|
56
|
-
* ever replacing the shared acl.can method.
|
|
57
|
-
*/
|
|
58
|
-
export function createAclCacheMiddleware(app: any) {
|
|
59
|
-
return async function aclCacheMiddleware(ctx: Context, next: () => Promise<void>) {
|
|
60
|
-
const cache = app.cache;
|
|
61
|
-
if (!cache) {
|
|
62
|
-
return next();
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const role = ctx.state?.currentRole;
|
|
66
|
-
const resourceName = ctx.permission?.resourceName || ctx.action?.resourceName;
|
|
67
|
-
const actionName = ctx.permission?.actionName || ctx.action?.actionName;
|
|
68
|
-
|
|
69
|
-
if (!role || !resourceName || !actionName) {
|
|
70
|
-
return next();
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const cacheKey = getCacheKey(role, resourceName, actionName);
|
|
74
|
-
|
|
75
|
-
// Try reading from cache first
|
|
76
|
-
try {
|
|
77
|
-
const cached = await cache.get(cacheKey);
|
|
78
|
-
if (cached !== undefined && cached !== null) {
|
|
79
|
-
recordStat(role, true);
|
|
80
|
-
if (cached === '__DENIED__') {
|
|
81
|
-
ctx.throw(403, 'No permissions');
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Replace the permission object computed for this request with the cached value.
|
|
86
|
-
ctx.permission = ctx.permission || {};
|
|
87
|
-
ctx.permission.can = JSON.parse(cached);
|
|
88
|
-
return next();
|
|
89
|
-
}
|
|
90
|
-
} catch {
|
|
91
|
-
// Cache read failed, proceed normally
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
recordStat(role, false);
|
|
95
|
-
|
|
96
|
-
// Let the rest of the ACL middleware run normally. Cache explicit denials too,
|
|
97
|
-
// but never turn a cached denial into a skipped permission check.
|
|
98
|
-
try {
|
|
99
|
-
await next();
|
|
100
|
-
} catch (error: any) {
|
|
101
|
-
if (error?.status === 403 || error?.statusCode === 403) {
|
|
102
|
-
cache.set(cacheKey, '__DENIED__', ACL_CACHE_TTL).catch(() => {});
|
|
103
|
-
}
|
|
104
|
-
throw error;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// After ACL ran, cache the permission result for future requests
|
|
108
|
-
// This is safe because we read ctx.permission AFTER next() completes —
|
|
109
|
-
// no monkey-patching of shared singletons.
|
|
110
|
-
try {
|
|
111
|
-
const result = ctx.permission?.can as any;
|
|
112
|
-
const valueToCache = JSON.stringify(result !== undefined && result !== null ? result : true);
|
|
113
|
-
cache.set(cacheKey, valueToCache, ACL_CACHE_TTL).catch(() => {});
|
|
114
|
-
} catch {
|
|
115
|
-
// Ignore cache write errors
|
|
116
|
-
}
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export const aclCacheActions = {
|
|
121
|
-
/**
|
|
122
|
-
* GET /clusterManagerAclCache:stats
|
|
123
|
-
* Returns ACL cache hit/miss statistics
|
|
124
|
-
*/
|
|
125
|
-
async stats(ctx: Context, next: () => Promise<void>) {
|
|
126
|
-
const hitRate =
|
|
127
|
-
stats.totalChecks > 0 ? Math.round((stats.cacheHits / stats.totalChecks) * 10000) / 100 : 0;
|
|
128
|
-
|
|
129
|
-
// Count cached keys using SCAN (production-safe)
|
|
130
|
-
let cachedKeys = 0;
|
|
131
|
-
try {
|
|
132
|
-
const redis = (ctx.app as any).redisConnectionManager?.getConnection();
|
|
133
|
-
if (redis) {
|
|
134
|
-
const keys = await scanKeys(redis, `*${ACL_CACHE_PREFIX}*`);
|
|
135
|
-
cachedKeys = keys.length;
|
|
136
|
-
}
|
|
137
|
-
} catch {
|
|
138
|
-
// Redis not available
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
ctx.body = {
|
|
142
|
-
...stats,
|
|
143
|
-
hitRate,
|
|
144
|
-
cachedKeys,
|
|
145
|
-
ttlSeconds: ACL_CACHE_TTL,
|
|
146
|
-
_note: 'Stats are per-node. In HA clusters each node tracks its own counters independently.',
|
|
147
|
-
};
|
|
148
|
-
await next();
|
|
149
|
-
},
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* GET /clusterManagerAclCache:listKeys
|
|
153
|
-
* Lists all cached ACL permission keys
|
|
154
|
-
*/
|
|
155
|
-
async listKeys(ctx: Context, next: () => Promise<void>) {
|
|
156
|
-
const keys: { key: string; role: string; resource: string; action: string }[] = [];
|
|
157
|
-
|
|
158
|
-
try {
|
|
159
|
-
const redis = (ctx.app as any).redisConnectionManager?.getConnection();
|
|
160
|
-
if (redis) {
|
|
161
|
-
const rawKeys = await scanKeys(redis, `*${ACL_CACHE_PREFIX}*`);
|
|
162
|
-
for (const key of rawKeys) {
|
|
163
|
-
const parts = key.replace(ACL_CACHE_PREFIX, '').split(':');
|
|
164
|
-
keys.push({
|
|
165
|
-
key,
|
|
166
|
-
role: parts[0] || '',
|
|
167
|
-
resource: parts[1] || '',
|
|
168
|
-
action: parts[2] || '',
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
} catch {
|
|
173
|
-
// Redis not available
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
ctx.body = { data: keys, meta: { count: keys.length } };
|
|
177
|
-
await next();
|
|
178
|
-
},
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* POST /clusterManagerAclCache:clear
|
|
182
|
-
* Clear all ACL cache entries and reset stats
|
|
183
|
-
*/
|
|
184
|
-
async clear(ctx: Context, next: () => Promise<void>) {
|
|
185
|
-
const user = ctx.state?.currentUser?.nickname || ctx.state?.currentUser?.id || 'unknown';
|
|
186
|
-
ctx.app.logger.info(`[cluster-manager] Clearing all ACL cache by user ${user}`);
|
|
187
|
-
let deletedCount = 0;
|
|
188
|
-
|
|
189
|
-
try {
|
|
190
|
-
const redis = (ctx.app as any).redisConnectionManager?.getConnection();
|
|
191
|
-
if (redis) {
|
|
192
|
-
const rawKeys = await scanKeys(redis, `*${ACL_CACHE_PREFIX}*`);
|
|
193
|
-
if (rawKeys.length > 0) {
|
|
194
|
-
deletedCount = await deleteKeysChunked(redis, rawKeys);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
} catch {
|
|
198
|
-
// Fallback: clear through app cache
|
|
199
|
-
try {
|
|
200
|
-
await ctx.app.cache.reset?.();
|
|
201
|
-
} catch {
|
|
202
|
-
// Ignore
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Also clear role caches
|
|
207
|
-
try {
|
|
208
|
-
const redis = (ctx.app as any).redisConnectionManager?.getConnection();
|
|
209
|
-
if (redis) {
|
|
210
|
-
const roleKeys = await scanKeys(redis, 'roles:*');
|
|
211
|
-
if (roleKeys.length > 0) {
|
|
212
|
-
deletedCount += await deleteKeysChunked(redis, roleKeys);
|
|
213
|
-
}
|
|
214
|
-
// Also clear system settings cache
|
|
215
|
-
try {
|
|
216
|
-
await redis.sendCommand(['DEL', 'app:systemSettings']);
|
|
217
|
-
deletedCount++;
|
|
218
|
-
} catch {
|
|
219
|
-
// Ignore if clear fails
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
} catch {
|
|
223
|
-
// Redis not available
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
ctx.body = { success: true, deletedCount };
|
|
227
|
-
await next();
|
|
228
|
-
},
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* POST /clusterManagerAclCache:resetStats
|
|
232
|
-
* Reset the in-memory ACL stats counters
|
|
233
|
-
*/
|
|
234
|
-
async resetStats(ctx: Context, next: () => Promise<void>) {
|
|
235
|
-
stats.totalChecks = 0;
|
|
236
|
-
stats.cacheHits = 0;
|
|
237
|
-
stats.cacheMisses = 0;
|
|
238
|
-
stats.startedAt = new Date().toISOString();
|
|
239
|
-
stats.detailByRole = {};
|
|
240
|
-
|
|
241
|
-
ctx.body = { success: true };
|
|
242
|
-
await next();
|
|
243
|
-
},
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* POST /clusterManagerAclCache:clearRole
|
|
247
|
-
* Clear ACL cache entries for a specific role
|
|
248
|
-
*/
|
|
249
|
-
async clearRole(ctx: Context, next: () => Promise<void>) {
|
|
250
|
-
const { roleName } = ctx.action.params.values || ctx.action.params;
|
|
251
|
-
if (!roleName) {
|
|
252
|
-
ctx.throw(400, 'roleName is required');
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
let deletedCount = 0;
|
|
256
|
-
try {
|
|
257
|
-
const redis = (ctx.app as any).redisConnectionManager?.getConnection();
|
|
258
|
-
if (redis) {
|
|
259
|
-
const pattern = `*${ACL_CACHE_PREFIX}${roleName}:*`;
|
|
260
|
-
const rawKeys = await scanKeys(redis, pattern);
|
|
261
|
-
if (rawKeys.length > 0) {
|
|
262
|
-
deletedCount = await deleteKeysChunked(redis, rawKeys);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
} catch {
|
|
266
|
-
// Redis not available
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
ctx.body = { success: true, deletedCount };
|
|
270
|
-
await next();
|
|
271
|
-
},
|
|
272
|
-
};
|
|
1
|
+
import { Context } from '@nocobase/actions';
|
|
2
|
+
import { scanKeys, deleteKeysChunked } from '../utils/redis';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* In-memory ACL stats counter.
|
|
6
|
+
* Tracks total checks, cache hits/misses per role:resource:action.
|
|
7
|
+
*/
|
|
8
|
+
export interface AclCacheStats {
|
|
9
|
+
totalChecks: number;
|
|
10
|
+
cacheHits: number;
|
|
11
|
+
cacheMisses: number;
|
|
12
|
+
startedAt: string;
|
|
13
|
+
detailByRole: Record<string, { checks: number; hits: number; misses: number }>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const stats: AclCacheStats = {
|
|
17
|
+
totalChecks: 0,
|
|
18
|
+
cacheHits: 0,
|
|
19
|
+
cacheMisses: 0,
|
|
20
|
+
startedAt: new Date().toISOString(),
|
|
21
|
+
detailByRole: {},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const ACL_CACHE_PREFIX = 'acl:can:';
|
|
25
|
+
const ACL_CACHE_TTL = 60 * 5; // 5 minutes default
|
|
26
|
+
|
|
27
|
+
function getCacheKey(role: string, resource: string, action: string): string {
|
|
28
|
+
return `${ACL_CACHE_PREFIX}${role}:${resource}:${action}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function recordStat(role: string, hit: boolean) {
|
|
32
|
+
stats.totalChecks++;
|
|
33
|
+
if (hit) {
|
|
34
|
+
stats.cacheHits++;
|
|
35
|
+
} else {
|
|
36
|
+
stats.cacheMisses++;
|
|
37
|
+
}
|
|
38
|
+
if (!stats.detailByRole[role]) {
|
|
39
|
+
stats.detailByRole[role] = { checks: 0, hits: 0, misses: 0 };
|
|
40
|
+
}
|
|
41
|
+
stats.detailByRole[role].checks++;
|
|
42
|
+
if (hit) {
|
|
43
|
+
stats.detailByRole[role].hits++;
|
|
44
|
+
} else {
|
|
45
|
+
stats.detailByRole[role].misses++;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Middleware that caches the permission object computed by the ACL middleware.
|
|
51
|
+
* Install via: app.acl.use(aclCacheMiddleware, { tag: 'aclCache', before: 'core', after: 'allow-manager' })
|
|
52
|
+
*
|
|
53
|
+
* FIX: Previously monkey-patched ctx.app.acl.can per-request, which is a race condition
|
|
54
|
+
* because acl is a shared singleton across concurrent requests. Now we use a post-check
|
|
55
|
+
* approach that reads from cache first and writes after the ACL middleware runs, without
|
|
56
|
+
* ever replacing the shared acl.can method.
|
|
57
|
+
*/
|
|
58
|
+
export function createAclCacheMiddleware(app: any) {
|
|
59
|
+
return async function aclCacheMiddleware(ctx: Context, next: () => Promise<void>) {
|
|
60
|
+
const cache = app.cache;
|
|
61
|
+
if (!cache) {
|
|
62
|
+
return next();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const role = ctx.state?.currentRole;
|
|
66
|
+
const resourceName = ctx.permission?.resourceName || ctx.action?.resourceName;
|
|
67
|
+
const actionName = ctx.permission?.actionName || ctx.action?.actionName;
|
|
68
|
+
|
|
69
|
+
if (!role || !resourceName || !actionName) {
|
|
70
|
+
return next();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const cacheKey = getCacheKey(role, resourceName, actionName);
|
|
74
|
+
|
|
75
|
+
// Try reading from cache first
|
|
76
|
+
try {
|
|
77
|
+
const cached = await cache.get(cacheKey);
|
|
78
|
+
if (cached !== undefined && cached !== null) {
|
|
79
|
+
recordStat(role, true);
|
|
80
|
+
if (cached === '__DENIED__') {
|
|
81
|
+
ctx.throw(403, 'No permissions');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Replace the permission object computed for this request with the cached value.
|
|
86
|
+
ctx.permission = ctx.permission || {};
|
|
87
|
+
ctx.permission.can = JSON.parse(cached);
|
|
88
|
+
return next();
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// Cache read failed, proceed normally
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
recordStat(role, false);
|
|
95
|
+
|
|
96
|
+
// Let the rest of the ACL middleware run normally. Cache explicit denials too,
|
|
97
|
+
// but never turn a cached denial into a skipped permission check.
|
|
98
|
+
try {
|
|
99
|
+
await next();
|
|
100
|
+
} catch (error: any) {
|
|
101
|
+
if (error?.status === 403 || error?.statusCode === 403) {
|
|
102
|
+
cache.set(cacheKey, '__DENIED__', ACL_CACHE_TTL).catch(() => {});
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// After ACL ran, cache the permission result for future requests
|
|
108
|
+
// This is safe because we read ctx.permission AFTER next() completes —
|
|
109
|
+
// no monkey-patching of shared singletons.
|
|
110
|
+
try {
|
|
111
|
+
const result = ctx.permission?.can as any;
|
|
112
|
+
const valueToCache = JSON.stringify(result !== undefined && result !== null ? result : true);
|
|
113
|
+
cache.set(cacheKey, valueToCache, ACL_CACHE_TTL).catch(() => {});
|
|
114
|
+
} catch {
|
|
115
|
+
// Ignore cache write errors
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const aclCacheActions = {
|
|
121
|
+
/**
|
|
122
|
+
* GET /clusterManagerAclCache:stats
|
|
123
|
+
* Returns ACL cache hit/miss statistics
|
|
124
|
+
*/
|
|
125
|
+
async stats(ctx: Context, next: () => Promise<void>) {
|
|
126
|
+
const hitRate =
|
|
127
|
+
stats.totalChecks > 0 ? Math.round((stats.cacheHits / stats.totalChecks) * 10000) / 100 : 0;
|
|
128
|
+
|
|
129
|
+
// Count cached keys using SCAN (production-safe)
|
|
130
|
+
let cachedKeys = 0;
|
|
131
|
+
try {
|
|
132
|
+
const redis = (ctx.app as any).redisConnectionManager?.getConnection();
|
|
133
|
+
if (redis) {
|
|
134
|
+
const keys = await scanKeys(redis, `*${ACL_CACHE_PREFIX}*`);
|
|
135
|
+
cachedKeys = keys.length;
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
// Redis not available
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
ctx.body = {
|
|
142
|
+
...stats,
|
|
143
|
+
hitRate,
|
|
144
|
+
cachedKeys,
|
|
145
|
+
ttlSeconds: ACL_CACHE_TTL,
|
|
146
|
+
_note: 'Stats are per-node. In HA clusters each node tracks its own counters independently.',
|
|
147
|
+
};
|
|
148
|
+
await next();
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* GET /clusterManagerAclCache:listKeys
|
|
153
|
+
* Lists all cached ACL permission keys
|
|
154
|
+
*/
|
|
155
|
+
async listKeys(ctx: Context, next: () => Promise<void>) {
|
|
156
|
+
const keys: { key: string; role: string; resource: string; action: string }[] = [];
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const redis = (ctx.app as any).redisConnectionManager?.getConnection();
|
|
160
|
+
if (redis) {
|
|
161
|
+
const rawKeys = await scanKeys(redis, `*${ACL_CACHE_PREFIX}*`);
|
|
162
|
+
for (const key of rawKeys) {
|
|
163
|
+
const parts = key.replace(ACL_CACHE_PREFIX, '').split(':');
|
|
164
|
+
keys.push({
|
|
165
|
+
key,
|
|
166
|
+
role: parts[0] || '',
|
|
167
|
+
resource: parts[1] || '',
|
|
168
|
+
action: parts[2] || '',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
// Redis not available
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
ctx.body = { data: keys, meta: { count: keys.length } };
|
|
177
|
+
await next();
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* POST /clusterManagerAclCache:clear
|
|
182
|
+
* Clear all ACL cache entries and reset stats
|
|
183
|
+
*/
|
|
184
|
+
async clear(ctx: Context, next: () => Promise<void>) {
|
|
185
|
+
const user = ctx.state?.currentUser?.nickname || ctx.state?.currentUser?.id || 'unknown';
|
|
186
|
+
ctx.app.logger.info(`[cluster-manager] Clearing all ACL cache by user ${user}`);
|
|
187
|
+
let deletedCount = 0;
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const redis = (ctx.app as any).redisConnectionManager?.getConnection();
|
|
191
|
+
if (redis) {
|
|
192
|
+
const rawKeys = await scanKeys(redis, `*${ACL_CACHE_PREFIX}*`);
|
|
193
|
+
if (rawKeys.length > 0) {
|
|
194
|
+
deletedCount = await deleteKeysChunked(redis, rawKeys);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
// Fallback: clear through app cache
|
|
199
|
+
try {
|
|
200
|
+
await ctx.app.cache.reset?.();
|
|
201
|
+
} catch {
|
|
202
|
+
// Ignore
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Also clear role caches
|
|
207
|
+
try {
|
|
208
|
+
const redis = (ctx.app as any).redisConnectionManager?.getConnection();
|
|
209
|
+
if (redis) {
|
|
210
|
+
const roleKeys = await scanKeys(redis, 'roles:*');
|
|
211
|
+
if (roleKeys.length > 0) {
|
|
212
|
+
deletedCount += await deleteKeysChunked(redis, roleKeys);
|
|
213
|
+
}
|
|
214
|
+
// Also clear system settings cache
|
|
215
|
+
try {
|
|
216
|
+
await redis.sendCommand(['DEL', 'app:systemSettings']);
|
|
217
|
+
deletedCount++;
|
|
218
|
+
} catch {
|
|
219
|
+
// Ignore if clear fails
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} catch {
|
|
223
|
+
// Redis not available
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
ctx.body = { success: true, deletedCount };
|
|
227
|
+
await next();
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* POST /clusterManagerAclCache:resetStats
|
|
232
|
+
* Reset the in-memory ACL stats counters
|
|
233
|
+
*/
|
|
234
|
+
async resetStats(ctx: Context, next: () => Promise<void>) {
|
|
235
|
+
stats.totalChecks = 0;
|
|
236
|
+
stats.cacheHits = 0;
|
|
237
|
+
stats.cacheMisses = 0;
|
|
238
|
+
stats.startedAt = new Date().toISOString();
|
|
239
|
+
stats.detailByRole = {};
|
|
240
|
+
|
|
241
|
+
ctx.body = { success: true };
|
|
242
|
+
await next();
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* POST /clusterManagerAclCache:clearRole
|
|
247
|
+
* Clear ACL cache entries for a specific role
|
|
248
|
+
*/
|
|
249
|
+
async clearRole(ctx: Context, next: () => Promise<void>) {
|
|
250
|
+
const { roleName } = ctx.action.params.values || ctx.action.params;
|
|
251
|
+
if (!roleName) {
|
|
252
|
+
ctx.throw(400, 'roleName is required');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let deletedCount = 0;
|
|
256
|
+
try {
|
|
257
|
+
const redis = (ctx.app as any).redisConnectionManager?.getConnection();
|
|
258
|
+
if (redis) {
|
|
259
|
+
const pattern = `*${ACL_CACHE_PREFIX}${roleName}:*`;
|
|
260
|
+
const rawKeys = await scanKeys(redis, pattern);
|
|
261
|
+
if (rawKeys.length > 0) {
|
|
262
|
+
deletedCount = await deleteKeysChunked(redis, rawKeys);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
} catch {
|
|
266
|
+
// Redis not available
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
ctx.body = { success: true, deletedCount };
|
|
270
|
+
await next();
|
|
271
|
+
},
|
|
272
|
+
};
|