-
Notifications
You must be signed in to change notification settings - Fork 4.8k
Expand file tree
/
Copy pathstore.ts
More file actions
353 lines (309 loc) · 12.5 KB
/
Copy pathstore.ts
File metadata and controls
353 lines (309 loc) · 12.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
import { randomUUID } from "node:crypto";
import { MongoClient, type Collection } from "mongodb";
import { createClient, createCluster } from "redis";
export interface McpChangeRecord {
id: string;
actorId: string;
// The caller's Appsmith organization (tenant). Every read is scoped to it so that on a multi-org (EE)
// deployment one tenant's admin can never see another tenant's change history — actorId (email/username) is only
// unique PER organization (Migration067_UpdateUserEmailIndex), so neither the admin nor the actor reads are safe
// without this scope. Optional only so records written before this field existed deserialize; such records carry
// no org and are therefore invisible to every org-scoped read (fail-closed).
organizationId?: string;
entityKey: string;
operation: string;
revisionBefore: string;
revisionAfter: string;
createdAt: Date;
expiresAt?: Date;
rollback: Record<string, unknown>;
summary: Record<string, unknown>;
}
export interface PreparedConfirmation {
id: string;
actorId: string;
entityKey: string;
operation: string;
revision: string;
digest: string;
expiresAt: Date;
}
export interface McpGovernanceStore {
acquireLock(entityKey: string, ttlMs: number): Promise<string | undefined>;
releaseLock(entityKey: string, lockId: string): Promise<void>;
createConfirmation(
confirmation: PreparedConfirmation,
ttlMs: number,
): Promise<void>;
consumeConfirmation(id: string): Promise<PreparedConfirmation | undefined>;
// NON-consuming read of a prepared confirmation [SECURITY F1]: lets the elicitation layer verify a confirmation
// exists and belongs to the calling actor BEFORE prompting the human, without spending the one-time token.
peekConfirmation(id: string): Promise<PreparedConfirmation | undefined>;
saveChange(change: McpChangeRecord): Promise<void>;
// Every read is scoped to the caller's organization (tenant). getChange/listChanges are additionally actor-scoped
// (a normal user sees only their own records); getAnyChange/listAllChanges are the admin cross-actor reads, gated
// on isSuperUser at the tool layer. All four take organizationId because isSuperUser is a PER-ORG signal in EE
// (MANAGE_ORGANIZATION on the caller's own org) and email/actorId is only per-org unique, so without the org
// predicate one tenant's admin — or a colliding email — would read another tenant's history.
getChange(
id: string,
actorId: string,
organizationId: string,
): Promise<McpChangeRecord | undefined>;
listChanges(
actorId: string,
organizationId: string,
limit: number,
): Promise<McpChangeRecord[]>;
getAnyChange(
id: string,
organizationId: string,
): Promise<McpChangeRecord | undefined>;
listAllChanges(
organizationId: string,
limit: number,
): Promise<McpChangeRecord[]>;
}
const LOCK_PREFIX = "appsmith:mcp:lock:";
const CONFIRM_PREFIX = "appsmith:mcp:confirm:";
const RELEASE_LOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
const CONSUME_CONFIRMATION_SCRIPT =
"local value = redis.call('get', KEYS[1]); if value then redis.call('del', KEYS[1]); end; return value";
// node-redis standalone vs cluster clients share this surface (connect/close/set/get/eval). Keep the store
// typed to the methods it actually calls so a redis-cluster:// URL can use createCluster without widening to any.
interface GovernanceRedis {
connect(): Promise<unknown>;
close(): Promise<unknown>;
set(
key: string,
value: string,
options: { NX: true; PX: number },
): Promise<unknown>;
get(key: string): Promise<string | null>;
eval(
script: string,
options: { keys: string[]; arguments: string[] },
): Promise<unknown>;
}
// MCP owns these collections and keys. It never writes Appsmith product documents directly; it only records
// governance metadata around authorized REST mutations made by the MCP service.
export class MongoRedisGovernanceStore implements McpGovernanceStore {
private readonly changes: Collection<McpChangeRecord>;
constructor(
private readonly mongo: MongoClient,
private readonly redis: GovernanceRedis,
// Defaults to undefined so mongo.db() honours the database named in the connection URI. Hardcoding
// "appsmith" matches the bundled container but silently diverges on an external/Atlas deployment whose URI
// names a different database — governance records would land in a stray db outside the operator's backups.
databaseName?: string,
) {
this.changes = mongo
.db(databaseName)
.collection<McpChangeRecord>("mcp_changes");
}
async connect(): Promise<void> {
await Promise.all([this.mongo.connect(), this.redis.connect()]);
// Org-prefixed so every read's organizationId predicate is index-covered. The actor-scoped read
// (listChanges) uses the first; the admin cross-actor read (listAllChanges: org predicate only, sort by
// createdAt desc) uses the second.
await this.changes.createIndex({
organizationId: 1,
actorId: 1,
createdAt: -1,
});
await this.changes.createIndex({ organizationId: 1, createdAt: -1 });
// Point lookup for getChange/getAnyChange. Without it those reads can only use the organizationId prefix
// and then filter, scanning the org's entire change history to find one id.
await this.changes.createIndex({ organizationId: 1, id: 1 });
// Reclaims records whose stamped expiresAt has passed (see DEFAULT_CHANGE_RETENTION_MS in coordinator.ts).
// The coordinator MUST stamp expiresAt — Mongo's TTL silently skips documents that lack the field.
await this.changes.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
}
async close(): Promise<void> {
await Promise.allSettled([this.mongo.close(), this.redis.close()]);
}
async acquireLock(
entityKey: string,
ttlMs: number,
): Promise<string | undefined> {
const lockId = randomUUID();
const acquired = await this.redis.set(
`${LOCK_PREFIX}${entityKey}`,
lockId,
{
NX: true,
PX: ttlMs,
},
);
return acquired === "OK" ? lockId : undefined;
}
async releaseLock(entityKey: string, lockId: string): Promise<void> {
await this.redis.eval(RELEASE_LOCK_SCRIPT, {
keys: [`${LOCK_PREFIX}${entityKey}`],
arguments: [lockId],
});
}
async createConfirmation(
confirmation: PreparedConfirmation,
ttlMs: number,
): Promise<void> {
const saved = await this.redis.set(
`${CONFIRM_PREFIX}${confirmation.id}`,
JSON.stringify(confirmation),
{ NX: true, PX: ttlMs },
);
if (saved !== "OK") {
throw new Error("confirmation identifier collision");
}
}
async consumeConfirmation(
id: string,
): Promise<PreparedConfirmation | undefined> {
const value = await this.redis.eval(CONSUME_CONFIRMATION_SCRIPT, {
keys: [`${CONFIRM_PREFIX}${id}`],
arguments: [],
});
if (typeof value !== "string") return undefined;
return JSON.parse(value) as PreparedConfirmation;
}
async peekConfirmation(
id: string,
): Promise<PreparedConfirmation | undefined> {
// consumeConfirmation minus the delete: a plain GET, so the one-time token survives the read.
const value = await this.redis.get(`${CONFIRM_PREFIX}${id}`);
if (typeof value !== "string") return undefined;
return JSON.parse(value) as PreparedConfirmation;
}
async saveChange(change: McpChangeRecord): Promise<void> {
await this.changes.insertOne(change);
}
async getChange(
id: string,
actorId: string,
organizationId: string,
): Promise<McpChangeRecord | undefined> {
return (
(await this.changes.findOne({ id, actorId, organizationId })) ?? undefined
);
}
async listChanges(
actorId: string,
organizationId: string,
limit: number,
): Promise<McpChangeRecord[]> {
return this.changes
.find({ actorId, organizationId })
.sort({ createdAt: -1 })
.limit(limit)
.toArray();
}
async getAnyChange(
id: string,
organizationId: string,
): Promise<McpChangeRecord | undefined> {
// Cross-actor but STILL org-scoped: an admin's isSuperUser is only per-org in EE, so the org predicate is what
// keeps one tenant's admin from reading another tenant's record.
return (await this.changes.findOne({ id, organizationId })) ?? undefined;
}
async listAllChanges(
organizationId: string,
limit: number,
): Promise<McpChangeRecord[]> {
// No actorId filter (cross-actor admin audit) but scoped to the caller's organization — records from other
// tenants, and pre-scope records that carry no organizationId, never match.
return this.changes
.find({ organizationId })
.sort({ createdAt: -1 })
.limit(limit)
.toArray();
}
}
// Governance needs a MongoDB connection. On `release`, APPSMITH_DB_URL is the Mongo URL, but on deployments where it
// points at Postgres (or the value is otherwise not a Mongo URL) handing it to `new MongoClient` would throw at
// startup. Treat the URL as Mongo only when its scheme is mongodb:/mongodb+srv:; otherwise skip governance (the same
// fail-safe as when the env vars are absent) so the server still starts with governed tools simply unavailable.
function isMongoUrl(url: string): boolean {
return /^mongodb(\+srv)?:\/\//i.test(url.trim());
}
function redisScheme(url: string): string | undefined {
return url
.trim()
.match(/^([a-z][a-z0-9+.-]*):\/\//i)?.[1]
?.toLowerCase();
}
// Java RedisConfig accepts redis / rediss / redis-cluster. node-redis createClient only accepts redis:// and
// rediss:// and throws TypeError("Invalid protocol") on redis-cluster://, which used to crash MCP at startup
// (Caddy then 502s /mcp). Adapt the cluster scheme for node-redis, securing credentialed URLs below, and skip
// governance for any other scheme.
export function createRedisClientFromUrl(
redisUrl: string,
): GovernanceRedis | undefined {
const trimmed = redisUrl.trim();
const scheme = redisScheme(trimmed);
if (scheme === "redis" || scheme === "rediss") {
return createClient({ url: trimmed }) as GovernanceRedis;
}
if (scheme === "redis-cluster") {
let clusterUrl: URL;
let username: string;
let password: string;
try {
clusterUrl = new URL(trimmed);
username = decodeURIComponent(clusterUrl.username);
password = decodeURIComponent(clusterUrl.password);
} catch {
return undefined;
}
const hasCredentials = username.length > 0 || password.length > 0;
if (!hasCredentials) {
return createCluster({
rootNodes: [
{ url: trimmed.replace(/^redis-cluster:\/\//i, "redis://") },
],
}) as GovernanceRedis;
}
// Credentials in a root-node URL apply only to topology discovery. Put them in defaults so every discovered
// node authenticates, and require TLS for both the root and discovered nodes so credentials are never sent in
// cleartext.
clusterUrl.protocol = "rediss:";
clusterUrl.username = "";
clusterUrl.password = "";
return createCluster({
rootNodes: [{ url: clusterUrl.toString() }],
defaults: {
...(username ? { username } : {}),
...(password ? { password } : {}),
socket: { tls: true },
},
}) as GovernanceRedis;
}
return undefined;
}
export function createGovernanceStoreFromEnv():
| MongoRedisGovernanceStore
| undefined {
// Same precedence as Java (`appsmith.db.url=${APPSMITH_DB_URL:${APPSMITH_MONGODB_URI}}`) and RTS:
// product DB URL first, legacy Mongo URI only as fallback. Preferring MONGODB_URI used to ignore a
// real APPSMITH_DB_URL and still connect to a leftover localhost Mongo URI from docker.env.
const mongoUrl =
process.env.APPSMITH_DB_URL || process.env.APPSMITH_MONGODB_URI;
const redisUrl = process.env.APPSMITH_REDIS_URL;
if (!mongoUrl || !redisUrl) return undefined;
if (!isMongoUrl(mongoUrl)) {
process.stderr.write(
"Appsmith MCP governance disabled: the configured database URL is not a MongoDB URL " +
"(expected mongodb:// or mongodb+srv://). Governed tools will be unavailable.\n",
);
return undefined;
}
const redis = createRedisClientFromUrl(redisUrl);
if (!redis) {
process.stderr.write(
"Appsmith MCP governance disabled: the configured Redis URL is not a redis://, rediss://, or redis-cluster:// URL. " +
"Governed tools will be unavailable.\n",
);
return undefined;
}
return new MongoRedisGovernanceStore(new MongoClient(mongoUrl), redis);
}