@simonsbs/keylore 1.0.0-rc4
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/.env.example +64 -0
- package/LICENSE +176 -0
- package/NOTICE +5 -0
- package/README.md +424 -0
- package/bin/keylore-http.js +3 -0
- package/bin/keylore-stdio.js +3 -0
- package/data/auth-clients.json +54 -0
- package/data/catalog.json +53 -0
- package/data/policies.json +25 -0
- package/dist/adapters/adapter-registry.js +143 -0
- package/dist/adapters/aws-secrets-manager-adapter.js +99 -0
- package/dist/adapters/command-runner.js +17 -0
- package/dist/adapters/env-secret-adapter.js +42 -0
- package/dist/adapters/gcp-secret-manager-adapter.js +129 -0
- package/dist/adapters/local-secret-adapter.js +54 -0
- package/dist/adapters/onepassword-secret-adapter.js +83 -0
- package/dist/adapters/reference-utils.js +44 -0
- package/dist/adapters/types.js +1 -0
- package/dist/adapters/vault-secret-adapter.js +103 -0
- package/dist/app.js +132 -0
- package/dist/cli/args.js +51 -0
- package/dist/cli/run.js +483 -0
- package/dist/cli.js +18 -0
- package/dist/config.js +295 -0
- package/dist/domain/types.js +967 -0
- package/dist/http/admin-ui.js +3010 -0
- package/dist/http/server.js +1210 -0
- package/dist/index.js +40 -0
- package/dist/mcp/create-server.js +388 -0
- package/dist/mcp/stdio.js +7 -0
- package/dist/repositories/credential-repository.js +109 -0
- package/dist/repositories/interfaces.js +1 -0
- package/dist/repositories/json-file.js +20 -0
- package/dist/repositories/pg-access-token-repository.js +118 -0
- package/dist/repositories/pg-approval-repository.js +157 -0
- package/dist/repositories/pg-audit-log.js +62 -0
- package/dist/repositories/pg-auth-client-repository.js +98 -0
- package/dist/repositories/pg-authorization-code-repository.js +95 -0
- package/dist/repositories/pg-break-glass-repository.js +174 -0
- package/dist/repositories/pg-credential-repository.js +163 -0
- package/dist/repositories/pg-oauth-client-assertion-repository.js +25 -0
- package/dist/repositories/pg-policy-repository.js +62 -0
- package/dist/repositories/pg-refresh-token-repository.js +125 -0
- package/dist/repositories/pg-rotation-run-repository.js +127 -0
- package/dist/repositories/pg-tenant-repository.js +56 -0
- package/dist/repositories/policy-repository.js +24 -0
- package/dist/runtime/sandbox-runner.js +114 -0
- package/dist/services/access-fingerprint.js +13 -0
- package/dist/services/approval-service.js +148 -0
- package/dist/services/audit-log.js +38 -0
- package/dist/services/auth-context.js +43 -0
- package/dist/services/auth-secrets.js +14 -0
- package/dist/services/auth-service.js +784 -0
- package/dist/services/backup-service.js +610 -0
- package/dist/services/break-glass-service.js +207 -0
- package/dist/services/broker-service.js +557 -0
- package/dist/services/core-mode-service.js +154 -0
- package/dist/services/egress-policy.js +119 -0
- package/dist/services/local-secret-store.js +119 -0
- package/dist/services/maintenance-service.js +99 -0
- package/dist/services/notification-service.js +83 -0
- package/dist/services/policy-engine.js +85 -0
- package/dist/services/rate-limit-service.js +80 -0
- package/dist/services/rotation-service.js +271 -0
- package/dist/services/telemetry.js +149 -0
- package/dist/services/tenant-service.js +127 -0
- package/dist/services/trace-export-service.js +126 -0
- package/dist/services/trace-service.js +87 -0
- package/dist/storage/bootstrap.js +68 -0
- package/dist/storage/database.js +39 -0
- package/dist/storage/in-memory-database.js +40 -0
- package/dist/storage/migrations.js +27 -0
- package/migrations/001_init.sql +49 -0
- package/migrations/002_phase2_auth.sql +53 -0
- package/migrations/003_v05_operations.sql +9 -0
- package/migrations/004_v07_security.sql +28 -0
- package/migrations/005_v08_reviews.sql +11 -0
- package/migrations/006_v09_auth_trace_rotation.sql +51 -0
- package/migrations/007_v010_multi_tenant.sql +32 -0
- package/migrations/008_v011_auth_tenant_ops.sql +95 -0
- package/package.json +78 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createKeyLoreApp } from "./app.js";
|
|
2
|
+
import { startHttpServer } from "./http/server.js";
|
|
3
|
+
import { runStdioServer } from "./mcp/stdio.js";
|
|
4
|
+
function readTransportArg(argv) {
|
|
5
|
+
const transportIndex = argv.findIndex((value) => value === "--transport");
|
|
6
|
+
if (transportIndex >= 0) {
|
|
7
|
+
const value = argv[transportIndex + 1];
|
|
8
|
+
if (value === "http" || value === "stdio") {
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const inline = argv.find((value) => value.startsWith("--transport="));
|
|
13
|
+
if (inline) {
|
|
14
|
+
const [, value] = inline.split("=", 2);
|
|
15
|
+
if (value === "http" || value === "stdio") {
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return "http";
|
|
20
|
+
}
|
|
21
|
+
async function main() {
|
|
22
|
+
const app = await createKeyLoreApp();
|
|
23
|
+
const transport = readTransportArg(process.argv.slice(2));
|
|
24
|
+
if (transport === "stdio") {
|
|
25
|
+
await runStdioServer(app);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const server = await startHttpServer(app);
|
|
29
|
+
const shutdown = async () => {
|
|
30
|
+
await server.close();
|
|
31
|
+
await app.close();
|
|
32
|
+
process.exit(0);
|
|
33
|
+
};
|
|
34
|
+
process.on("SIGINT", shutdown);
|
|
35
|
+
process.on("SIGTERM", shutdown);
|
|
36
|
+
}
|
|
37
|
+
main().catch((error) => {
|
|
38
|
+
console.error(error);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
});
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import * as z from "zod/v4";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { accessDecisionSchema, adapterHealthListOutputSchema, auditRecentOutputSchema, breakGlassListOutputSchema, breakGlassRequestInputSchema, breakGlassRequestSchema, breakGlassReviewInputSchema, catalogGetOutputSchema, credentialStatusReportListOutputSchema, catalogSearchInputSchema, catalogSearchOutputSchema, maintenanceStatusOutputSchema, operationSchema, rotationRunListOutputSchema, rotationPlanInputSchema, rotationCreateInputSchema, rotationCompleteInputSchema, runtimeExecutionResultSchema, rotationRunSchema, traceExportStatusOutputSchema, traceListOutputSchema, sensitivitySchema, scopeTierSchema, } from "../domain/types.js";
|
|
4
|
+
import { authContextFromToken, localOperatorContext } from "../services/auth-context.js";
|
|
5
|
+
function makeText(value) {
|
|
6
|
+
return JSON.stringify(value, null, 2);
|
|
7
|
+
}
|
|
8
|
+
function contextFromExtra(app, extra) {
|
|
9
|
+
if (!extra?.authInfo) {
|
|
10
|
+
return localOperatorContext(app.config.defaultPrincipal);
|
|
11
|
+
}
|
|
12
|
+
return authContextFromToken({
|
|
13
|
+
principal: typeof extra.authInfo.extra?.principal === "string"
|
|
14
|
+
? extra.authInfo.extra.principal
|
|
15
|
+
: extra.authInfo.clientId,
|
|
16
|
+
clientId: extra.authInfo.clientId,
|
|
17
|
+
tenantId: typeof extra.authInfo.extra?.tenantId === "string" ? extra.authInfo.extra.tenantId : undefined,
|
|
18
|
+
scopes: extra.authInfo.scopes,
|
|
19
|
+
roles: (Array.isArray(extra.authInfo.extra?.roles)
|
|
20
|
+
? extra.authInfo.extra.roles
|
|
21
|
+
: []),
|
|
22
|
+
resource: extra.authInfo.resource?.href,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
export function createKeyLoreMcpServer(app) {
|
|
26
|
+
const server = new McpServer({
|
|
27
|
+
name: "keylore-mcp",
|
|
28
|
+
version: app.config.version,
|
|
29
|
+
});
|
|
30
|
+
server.registerTool("catalog_search", {
|
|
31
|
+
description: "Search credential metadata without exposing secret values. Use this before attempting access.",
|
|
32
|
+
inputSchema: {
|
|
33
|
+
query: z.string().optional(),
|
|
34
|
+
service: z.string().optional(),
|
|
35
|
+
owner: z.string().optional(),
|
|
36
|
+
scopeTier: scopeTierSchema.optional(),
|
|
37
|
+
sensitivity: sensitivitySchema.optional(),
|
|
38
|
+
status: z.enum(["active", "disabled"]).optional(),
|
|
39
|
+
tag: z.string().optional(),
|
|
40
|
+
limit: z.number().int().min(1).max(50).default(10),
|
|
41
|
+
},
|
|
42
|
+
outputSchema: catalogSearchOutputSchema,
|
|
43
|
+
}, async (input, extra) => {
|
|
44
|
+
const parsed = catalogSearchInputSchema.parse(input);
|
|
45
|
+
const context = contextFromExtra(app, extra);
|
|
46
|
+
app.auth.requireScopes(context, ["catalog:read"]);
|
|
47
|
+
const results = await app.broker.searchCatalog(context, parsed);
|
|
48
|
+
return {
|
|
49
|
+
content: [{ type: "text", text: makeText(results) }],
|
|
50
|
+
structuredContent: {
|
|
51
|
+
results,
|
|
52
|
+
count: results.length,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
server.registerTool("catalog_get", {
|
|
57
|
+
description: "Return one credential metadata record by identifier, still without secrets.",
|
|
58
|
+
inputSchema: {
|
|
59
|
+
credentialId: z.string().min(1),
|
|
60
|
+
},
|
|
61
|
+
outputSchema: catalogGetOutputSchema,
|
|
62
|
+
}, async ({ credentialId }, extra) => {
|
|
63
|
+
const context = contextFromExtra(app, extra);
|
|
64
|
+
app.auth.requireScopes(context, ["catalog:read"]);
|
|
65
|
+
const result = await app.broker.getCredential(context, credentialId);
|
|
66
|
+
return {
|
|
67
|
+
content: [
|
|
68
|
+
{
|
|
69
|
+
type: "text",
|
|
70
|
+
text: makeText(result ?? { error: "Credential not found." }),
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
structuredContent: {
|
|
74
|
+
result: result ?? null,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
server.registerTool("catalog_report", {
|
|
79
|
+
description: "Inspect credential rotation and expiry status without exposing secret values.",
|
|
80
|
+
inputSchema: {
|
|
81
|
+
credentialId: z.string().min(1).optional(),
|
|
82
|
+
},
|
|
83
|
+
outputSchema: credentialStatusReportListOutputSchema,
|
|
84
|
+
}, async ({ credentialId }, extra) => {
|
|
85
|
+
const context = contextFromExtra(app, extra);
|
|
86
|
+
app.auth.requireScopes(context, ["catalog:read"]);
|
|
87
|
+
app.auth.requireRoles(context, ["admin", "operator", "auditor"]);
|
|
88
|
+
const reports = await app.broker.listCredentialReports(context, credentialId);
|
|
89
|
+
return {
|
|
90
|
+
content: [{ type: "text", text: makeText(reports) }],
|
|
91
|
+
structuredContent: {
|
|
92
|
+
reports,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
server.registerTool("access_request", {
|
|
97
|
+
description: "Evaluate policy and, if allowed, execute a constrained authenticated proxy request without returning secret material.",
|
|
98
|
+
inputSchema: {
|
|
99
|
+
credentialId: z.string().min(1),
|
|
100
|
+
operation: operationSchema,
|
|
101
|
+
targetUrl: z.string().url(),
|
|
102
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
103
|
+
payload: z.string().optional(),
|
|
104
|
+
approvalId: z.string().uuid().optional(),
|
|
105
|
+
dryRun: z.boolean().optional(),
|
|
106
|
+
},
|
|
107
|
+
outputSchema: accessDecisionSchema,
|
|
108
|
+
}, async (input, extra) => {
|
|
109
|
+
const context = contextFromExtra(app, extra);
|
|
110
|
+
app.auth.requireScopes(context, ["broker:use"]);
|
|
111
|
+
const decision = await app.broker.requestAccess(context, input);
|
|
112
|
+
return {
|
|
113
|
+
content: [{ type: "text", text: makeText(decision) }],
|
|
114
|
+
structuredContent: decision,
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
server.registerTool("policy_simulate", {
|
|
118
|
+
description: "Evaluate policy for a proposed access request without executing the outbound call or creating approval side effects.",
|
|
119
|
+
inputSchema: {
|
|
120
|
+
credentialId: z.string().min(1),
|
|
121
|
+
operation: operationSchema,
|
|
122
|
+
targetUrl: z.string().url(),
|
|
123
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
124
|
+
payload: z.string().optional(),
|
|
125
|
+
approvalId: z.string().uuid().optional(),
|
|
126
|
+
},
|
|
127
|
+
outputSchema: accessDecisionSchema,
|
|
128
|
+
}, async (input, extra) => {
|
|
129
|
+
const context = contextFromExtra(app, extra);
|
|
130
|
+
app.auth.requireScopes(context, ["broker:use"]);
|
|
131
|
+
const decision = await app.broker.simulateAccess(context, input);
|
|
132
|
+
return {
|
|
133
|
+
content: [{ type: "text", text: makeText(decision) }],
|
|
134
|
+
structuredContent: decision,
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
server.registerTool("audit_recent", {
|
|
138
|
+
description: "Read recent audit events for search, authorization, and credential use.",
|
|
139
|
+
inputSchema: {
|
|
140
|
+
limit: z.number().int().min(1).max(100).default(20),
|
|
141
|
+
},
|
|
142
|
+
outputSchema: auditRecentOutputSchema,
|
|
143
|
+
}, async ({ limit }, extra) => {
|
|
144
|
+
const context = contextFromExtra(app, extra);
|
|
145
|
+
app.auth.requireScopes(context, ["audit:read"]);
|
|
146
|
+
app.auth.requireRoles(context, ["admin", "auditor"]);
|
|
147
|
+
const events = await app.broker.listRecentAuditEvents(context, limit);
|
|
148
|
+
return {
|
|
149
|
+
content: [{ type: "text", text: makeText(events) }],
|
|
150
|
+
structuredContent: {
|
|
151
|
+
events,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
server.registerTool("system_adapters", {
|
|
156
|
+
description: "Read adapter availability and health for configured secret backends.",
|
|
157
|
+
inputSchema: {},
|
|
158
|
+
outputSchema: adapterHealthListOutputSchema,
|
|
159
|
+
}, async (_input, extra) => {
|
|
160
|
+
const context = contextFromExtra(app, extra);
|
|
161
|
+
app.auth.requireScopes(context, ["system:read"]);
|
|
162
|
+
app.auth.requireRoles(context, ["admin", "maintenance_operator", "auditor"]);
|
|
163
|
+
const adapters = await app.broker.adapterHealth();
|
|
164
|
+
return {
|
|
165
|
+
content: [{ type: "text", text: makeText(adapters) }],
|
|
166
|
+
structuredContent: {
|
|
167
|
+
adapters,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
server.registerTool("system_maintenance_status", {
|
|
172
|
+
description: "Read background maintenance loop status and last cleanup result.",
|
|
173
|
+
inputSchema: {},
|
|
174
|
+
outputSchema: maintenanceStatusOutputSchema,
|
|
175
|
+
}, async (_input, extra) => {
|
|
176
|
+
const context = contextFromExtra(app, extra);
|
|
177
|
+
app.auth.requireScopes(context, ["system:read"]);
|
|
178
|
+
app.auth.requireRoles(context, ["admin", "maintenance_operator", "auditor"]);
|
|
179
|
+
return {
|
|
180
|
+
content: [{ type: "text", text: makeText(app.maintenance.status()) }],
|
|
181
|
+
structuredContent: {
|
|
182
|
+
maintenance: app.maintenance.status(),
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
server.registerTool("system_recent_traces", {
|
|
187
|
+
description: "Inspect recent in-memory trace spans for HTTP, review, notification, and operator flows.",
|
|
188
|
+
inputSchema: {
|
|
189
|
+
limit: z.number().int().min(1).max(100).default(20),
|
|
190
|
+
traceId: z.string().optional(),
|
|
191
|
+
},
|
|
192
|
+
outputSchema: traceListOutputSchema,
|
|
193
|
+
}, async ({ limit, traceId }, extra) => {
|
|
194
|
+
const context = contextFromExtra(app, extra);
|
|
195
|
+
app.auth.requireScopes(context, ["system:read"]);
|
|
196
|
+
app.auth.requireRoles(context, ["admin", "maintenance_operator", "auditor"]);
|
|
197
|
+
return {
|
|
198
|
+
content: [{ type: "text", text: makeText(app.traces.recent(limit, traceId)) }],
|
|
199
|
+
structuredContent: {
|
|
200
|
+
traces: app.traces.recent(limit, traceId),
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
});
|
|
204
|
+
server.registerTool("system_trace_exporter_status", {
|
|
205
|
+
description: "Read external trace-export pipeline status and pending queue depth.",
|
|
206
|
+
inputSchema: {},
|
|
207
|
+
outputSchema: traceExportStatusOutputSchema,
|
|
208
|
+
}, async (_input, extra) => {
|
|
209
|
+
const context = contextFromExtra(app, extra);
|
|
210
|
+
app.auth.requireScopes(context, ["system:read"]);
|
|
211
|
+
app.auth.requireRoles(context, ["admin", "maintenance_operator", "auditor"]);
|
|
212
|
+
return {
|
|
213
|
+
content: [{ type: "text", text: makeText(app.traceExports.status()) }],
|
|
214
|
+
structuredContent: {
|
|
215
|
+
exporter: app.traceExports.status(),
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
server.registerTool("system_rotation_list", {
|
|
220
|
+
description: "List credential rotation workflow runs and their current status.",
|
|
221
|
+
inputSchema: {
|
|
222
|
+
status: z.enum(["pending", "in_progress", "completed", "failed", "cancelled"]).optional(),
|
|
223
|
+
credentialId: z.string().optional(),
|
|
224
|
+
},
|
|
225
|
+
outputSchema: rotationRunListOutputSchema,
|
|
226
|
+
}, async ({ status, credentialId }, extra) => {
|
|
227
|
+
const context = contextFromExtra(app, extra);
|
|
228
|
+
app.auth.requireScopes(context, ["system:read"]);
|
|
229
|
+
app.auth.requireRoles(context, ["admin", "operator", "maintenance_operator", "auditor"]);
|
|
230
|
+
const rotations = await app.rotations.list({ tenantId: context.tenantId, status, credentialId });
|
|
231
|
+
return {
|
|
232
|
+
content: [{ type: "text", text: makeText(rotations) }],
|
|
233
|
+
structuredContent: {
|
|
234
|
+
rotations,
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
});
|
|
238
|
+
server.registerTool("system_rotation_plan", {
|
|
239
|
+
description: "Create pending rotation runs for credentials approaching expiry or backend rotation windows.",
|
|
240
|
+
inputSchema: {
|
|
241
|
+
horizonDays: z.number().int().min(1).max(365).default(14),
|
|
242
|
+
credentialIds: z.array(z.string()).optional(),
|
|
243
|
+
},
|
|
244
|
+
outputSchema: rotationRunListOutputSchema,
|
|
245
|
+
}, async (input, extra) => {
|
|
246
|
+
const parsed = rotationPlanInputSchema.parse(input);
|
|
247
|
+
const context = contextFromExtra(app, extra);
|
|
248
|
+
app.auth.requireScopes(context, ["system:write"]);
|
|
249
|
+
app.auth.requireRoles(context, ["admin", "operator", "maintenance_operator"]);
|
|
250
|
+
const rotations = await app.rotations.planDue(context, parsed);
|
|
251
|
+
return {
|
|
252
|
+
content: [{ type: "text", text: makeText(rotations) }],
|
|
253
|
+
structuredContent: {
|
|
254
|
+
rotations,
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
server.registerTool("system_rotation_create", {
|
|
259
|
+
description: "Create a manual rotation workflow run for a specific credential.",
|
|
260
|
+
inputSchema: {
|
|
261
|
+
credentialId: z.string().min(1),
|
|
262
|
+
reason: z.string().min(8).max(2000),
|
|
263
|
+
dueAt: z.string().datetime().optional(),
|
|
264
|
+
note: z.string().max(2000).optional(),
|
|
265
|
+
},
|
|
266
|
+
outputSchema: z.object({ rotation: rotationRunSchema }),
|
|
267
|
+
}, async (input, extra) => {
|
|
268
|
+
const parsed = rotationCreateInputSchema.parse(input);
|
|
269
|
+
const context = contextFromExtra(app, extra);
|
|
270
|
+
app.auth.requireScopes(context, ["system:write"]);
|
|
271
|
+
app.auth.requireRoles(context, ["admin", "operator", "maintenance_operator"]);
|
|
272
|
+
const rotation = await app.rotations.createManual(context, parsed);
|
|
273
|
+
return {
|
|
274
|
+
content: [{ type: "text", text: makeText(rotation) }],
|
|
275
|
+
structuredContent: {
|
|
276
|
+
rotation,
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
});
|
|
280
|
+
server.registerTool("system_rotation_complete", {
|
|
281
|
+
description: "Mark a rotation workflow run completed and optionally update the credential binding reference.",
|
|
282
|
+
inputSchema: {
|
|
283
|
+
rotationId: z.string().uuid(),
|
|
284
|
+
note: z.string().max(2000).optional(),
|
|
285
|
+
targetRef: z.string().optional(),
|
|
286
|
+
expiresAt: z.string().datetime().nullable().optional(),
|
|
287
|
+
lastValidatedAt: z.string().datetime().optional(),
|
|
288
|
+
},
|
|
289
|
+
outputSchema: z.object({ rotation: rotationRunSchema.nullable() }),
|
|
290
|
+
}, async ({ rotationId, ...input }, extra) => {
|
|
291
|
+
const parsed = rotationCompleteInputSchema.parse(input);
|
|
292
|
+
const context = contextFromExtra(app, extra);
|
|
293
|
+
app.auth.requireScopes(context, ["system:write"]);
|
|
294
|
+
app.auth.requireRoles(context, ["admin", "operator", "maintenance_operator"]);
|
|
295
|
+
const rotation = await app.rotations.complete(rotationId, context, parsed);
|
|
296
|
+
return {
|
|
297
|
+
content: [{ type: "text", text: makeText(rotation ?? { error: "Rotation not found." }) }],
|
|
298
|
+
structuredContent: {
|
|
299
|
+
rotation: rotation ?? null,
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
});
|
|
303
|
+
server.registerTool("break_glass_request", {
|
|
304
|
+
description: "Create an audited emergency-access request for a specific credential and target.",
|
|
305
|
+
inputSchema: {
|
|
306
|
+
credentialId: z.string().min(1),
|
|
307
|
+
operation: operationSchema,
|
|
308
|
+
targetUrl: z.string().url(),
|
|
309
|
+
justification: z.string().min(12).max(2000),
|
|
310
|
+
requestedDurationSeconds: z.number().int().min(60).max(86400).optional(),
|
|
311
|
+
},
|
|
312
|
+
outputSchema: z.object({ request: breakGlassRequestSchema }),
|
|
313
|
+
}, async (input, extra) => {
|
|
314
|
+
const parsed = breakGlassRequestInputSchema.parse(input);
|
|
315
|
+
const context = contextFromExtra(app, extra);
|
|
316
|
+
app.auth.requireScopes(context, ["breakglass:request"]);
|
|
317
|
+
app.auth.requireRoles(context, ["admin", "breakglass_operator"]);
|
|
318
|
+
const request = await app.broker.createBreakGlassRequest(context, parsed);
|
|
319
|
+
return {
|
|
320
|
+
content: [{ type: "text", text: makeText(request) }],
|
|
321
|
+
structuredContent: { request },
|
|
322
|
+
};
|
|
323
|
+
});
|
|
324
|
+
server.registerTool("break_glass_list", {
|
|
325
|
+
description: "List emergency-access requests and their review status.",
|
|
326
|
+
inputSchema: {
|
|
327
|
+
status: z.enum(["pending", "active", "denied", "expired", "revoked"]).optional(),
|
|
328
|
+
requestedBy: z.string().optional(),
|
|
329
|
+
},
|
|
330
|
+
outputSchema: breakGlassListOutputSchema,
|
|
331
|
+
}, async ({ status, requestedBy }, extra) => {
|
|
332
|
+
const context = contextFromExtra(app, extra);
|
|
333
|
+
app.auth.requireScopes(context, ["breakglass:read"]);
|
|
334
|
+
app.auth.requireRoles(context, ["admin", "approver", "auditor", "breakglass_operator"]);
|
|
335
|
+
const requests = await app.broker.listBreakGlassRequests(context, { status, requestedBy });
|
|
336
|
+
return {
|
|
337
|
+
content: [{ type: "text", text: makeText(requests) }],
|
|
338
|
+
structuredContent: {
|
|
339
|
+
requests,
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
});
|
|
343
|
+
server.registerTool("break_glass_review", {
|
|
344
|
+
description: "Approve, deny, or revoke an emergency-access request.",
|
|
345
|
+
inputSchema: {
|
|
346
|
+
requestId: z.string().uuid(),
|
|
347
|
+
action: z.enum(["approve", "deny", "revoke"]),
|
|
348
|
+
note: z.string().max(1000).optional(),
|
|
349
|
+
},
|
|
350
|
+
outputSchema: z.object({ request: breakGlassRequestSchema.nullable() }),
|
|
351
|
+
}, async ({ requestId, action, note }, extra) => {
|
|
352
|
+
const parsedNote = breakGlassReviewInputSchema.parse({ note }).note;
|
|
353
|
+
const context = contextFromExtra(app, extra);
|
|
354
|
+
app.auth.requireScopes(context, ["breakglass:review"]);
|
|
355
|
+
app.auth.requireRoles(context, action === "revoke" ? ["admin", "approver", "breakglass_operator"] : ["admin", "approver"]);
|
|
356
|
+
const request = action === "approve"
|
|
357
|
+
? await app.broker.reviewBreakGlassRequest(context, requestId, "active", parsedNote)
|
|
358
|
+
: action === "deny"
|
|
359
|
+
? await app.broker.reviewBreakGlassRequest(context, requestId, "denied", parsedNote)
|
|
360
|
+
: await app.broker.revokeBreakGlassRequest(context, requestId, parsedNote);
|
|
361
|
+
return {
|
|
362
|
+
content: [{ type: "text", text: makeText(request ?? { error: "Request not found." }) }],
|
|
363
|
+
structuredContent: { request: request ?? null },
|
|
364
|
+
};
|
|
365
|
+
});
|
|
366
|
+
server.registerTool("runtime_run_sandboxed", {
|
|
367
|
+
description: "Run a tightly allowlisted command with a credential injected into a temporary process environment, with output scrubbing.",
|
|
368
|
+
inputSchema: {
|
|
369
|
+
credentialId: z.string().min(1),
|
|
370
|
+
command: z.string().min(1),
|
|
371
|
+
args: z.array(z.string()).max(32).default([]),
|
|
372
|
+
secretEnvName: z.string().regex(/^[A-Z_][A-Z0-9_]*$/).optional(),
|
|
373
|
+
env: z.record(z.string().regex(/^[A-Z_][A-Z0-9_]*$/), z.string()).optional(),
|
|
374
|
+
timeoutMs: z.number().int().min(100).max(60000).optional(),
|
|
375
|
+
},
|
|
376
|
+
outputSchema: runtimeExecutionResultSchema,
|
|
377
|
+
}, async (input, extra) => {
|
|
378
|
+
const context = contextFromExtra(app, extra);
|
|
379
|
+
app.auth.requireScopes(context, ["sandbox:run"]);
|
|
380
|
+
app.auth.requireRoles(context, ["admin", "operator"]);
|
|
381
|
+
const result = await app.broker.runSandboxed(context, input);
|
|
382
|
+
return {
|
|
383
|
+
content: [{ type: "text", text: makeText(result) }],
|
|
384
|
+
structuredContent: result,
|
|
385
|
+
};
|
|
386
|
+
});
|
|
387
|
+
return server;
|
|
388
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2
|
+
import { createKeyLoreMcpServer } from "./create-server.js";
|
|
3
|
+
export async function runStdioServer(app) {
|
|
4
|
+
const server = createKeyLoreMcpServer(app);
|
|
5
|
+
const transport = new StdioServerTransport();
|
|
6
|
+
await server.connect(transport);
|
|
7
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { catalogFileSchema, createCredentialInputSchema, updateCredentialInputSchema, } from "../domain/types.js";
|
|
3
|
+
import { readTextFile, writeTextFile } from "./json-file.js";
|
|
4
|
+
function normalizeText(value) {
|
|
5
|
+
return value.trim().toLowerCase();
|
|
6
|
+
}
|
|
7
|
+
function matchesQuery(credential, query) {
|
|
8
|
+
if (!query) {
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
const haystack = [
|
|
12
|
+
credential.id,
|
|
13
|
+
credential.displayName,
|
|
14
|
+
credential.service,
|
|
15
|
+
credential.owner,
|
|
16
|
+
credential.selectionNotes,
|
|
17
|
+
...credential.tags,
|
|
18
|
+
]
|
|
19
|
+
.join(" ")
|
|
20
|
+
.toLowerCase();
|
|
21
|
+
return normalizeText(query)
|
|
22
|
+
.split(/\s+/)
|
|
23
|
+
.every((token) => haystack.includes(token));
|
|
24
|
+
}
|
|
25
|
+
export class JsonCredentialRepository {
|
|
26
|
+
filePath;
|
|
27
|
+
constructor(filePath) {
|
|
28
|
+
this.filePath = filePath;
|
|
29
|
+
}
|
|
30
|
+
async ensureInitialized() {
|
|
31
|
+
const file = await readTextFile(this.filePath);
|
|
32
|
+
if (file) {
|
|
33
|
+
catalogFileSchema.parse(JSON.parse(file));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const emptyCatalog = { version: 1, credentials: [] };
|
|
37
|
+
await writeTextFile(this.filePath, `${JSON.stringify(emptyCatalog, null, 2)}\n`);
|
|
38
|
+
}
|
|
39
|
+
async list() {
|
|
40
|
+
return (await this.readCatalog()).credentials;
|
|
41
|
+
}
|
|
42
|
+
async getById(id) {
|
|
43
|
+
return (await this.list()).find((credential) => credential.id === id);
|
|
44
|
+
}
|
|
45
|
+
async search(input) {
|
|
46
|
+
const credentials = await this.list();
|
|
47
|
+
return credentials
|
|
48
|
+
.filter((credential) => matchesQuery(credential, input.query))
|
|
49
|
+
.filter((credential) => (input.service ? credential.service === input.service : true))
|
|
50
|
+
.filter((credential) => (input.owner ? credential.owner === input.owner : true))
|
|
51
|
+
.filter((credential) => (input.scopeTier ? credential.scopeTier === input.scopeTier : true))
|
|
52
|
+
.filter((credential) => (input.sensitivity ? credential.sensitivity === input.sensitivity : true))
|
|
53
|
+
.filter((credential) => (input.status ? credential.status === input.status : true))
|
|
54
|
+
.filter((credential) => (input.tag ? credential.tags.includes(input.tag) : true))
|
|
55
|
+
.slice(0, input.limit);
|
|
56
|
+
}
|
|
57
|
+
async create(record) {
|
|
58
|
+
const parsed = createCredentialInputSchema.parse(record);
|
|
59
|
+
const catalog = await this.readCatalog();
|
|
60
|
+
if (catalog.credentials.some((credential) => credential.id === parsed.id)) {
|
|
61
|
+
throw new Error(`Credential ${parsed.id} already exists.`);
|
|
62
|
+
}
|
|
63
|
+
catalog.credentials.push(parsed);
|
|
64
|
+
await this.writeCatalog(catalog);
|
|
65
|
+
return parsed;
|
|
66
|
+
}
|
|
67
|
+
async createWithDefaults(record) {
|
|
68
|
+
return this.create({
|
|
69
|
+
id: record.id ?? randomUUID(),
|
|
70
|
+
...record,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
async update(id, patch) {
|
|
74
|
+
const parsedPatch = updateCredentialInputSchema.parse(patch);
|
|
75
|
+
const catalog = await this.readCatalog();
|
|
76
|
+
const index = catalog.credentials.findIndex((credential) => credential.id === id);
|
|
77
|
+
if (index === -1) {
|
|
78
|
+
throw new Error(`Credential ${id} was not found.`);
|
|
79
|
+
}
|
|
80
|
+
const merged = createCredentialInputSchema.parse({
|
|
81
|
+
...catalog.credentials[index],
|
|
82
|
+
...parsedPatch,
|
|
83
|
+
id,
|
|
84
|
+
});
|
|
85
|
+
catalog.credentials[index] = merged;
|
|
86
|
+
await this.writeCatalog(catalog);
|
|
87
|
+
return merged;
|
|
88
|
+
}
|
|
89
|
+
async delete(id) {
|
|
90
|
+
const catalog = await this.readCatalog();
|
|
91
|
+
const initialLength = catalog.credentials.length;
|
|
92
|
+
catalog.credentials = catalog.credentials.filter((credential) => credential.id !== id);
|
|
93
|
+
if (catalog.credentials.length === initialLength) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
await this.writeCatalog(catalog);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
async readCatalog() {
|
|
100
|
+
const text = await readTextFile(this.filePath);
|
|
101
|
+
if (!text) {
|
|
102
|
+
return { version: 1, credentials: [] };
|
|
103
|
+
}
|
|
104
|
+
return catalogFileSchema.parse(JSON.parse(text));
|
|
105
|
+
}
|
|
106
|
+
async writeCatalog(catalog) {
|
|
107
|
+
await writeTextFile(this.filePath, `${JSON.stringify(catalog, null, 2)}\n`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export async function ensureParentDirectory(filePath) {
|
|
4
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
5
|
+
}
|
|
6
|
+
export async function readTextFile(filePath) {
|
|
7
|
+
try {
|
|
8
|
+
return await fs.readFile(filePath, "utf8");
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
if (error.code === "ENOENT") {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function writeTextFile(filePath, text) {
|
|
18
|
+
await ensureParentDirectory(filePath);
|
|
19
|
+
await fs.writeFile(filePath, text, "utf8");
|
|
20
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { accessTokenRecordSchema, } from "../domain/types.js";
|
|
3
|
+
function toIso(value) {
|
|
4
|
+
if (value === null) {
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|
|
7
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
8
|
+
}
|
|
9
|
+
function mapRow(row) {
|
|
10
|
+
const record = accessTokenRecordSchema.parse({
|
|
11
|
+
tokenId: row.token_id,
|
|
12
|
+
clientId: row.client_id,
|
|
13
|
+
tenantId: row.tenant_id,
|
|
14
|
+
subject: row.subject,
|
|
15
|
+
scopes: row.scopes,
|
|
16
|
+
roles: row.roles,
|
|
17
|
+
resource: row.resource ?? undefined,
|
|
18
|
+
expiresAt: toIso(row.expires_at),
|
|
19
|
+
status: row.status,
|
|
20
|
+
createdAt: toIso(row.created_at),
|
|
21
|
+
lastUsedAt: toIso(row.last_used_at),
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
...record,
|
|
25
|
+
tokenHash: row.token_hash,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export class PgAccessTokenRepository {
|
|
29
|
+
database;
|
|
30
|
+
constructor(database) {
|
|
31
|
+
this.database = database;
|
|
32
|
+
}
|
|
33
|
+
async issue(input) {
|
|
34
|
+
await this.database.query(`INSERT INTO access_tokens (
|
|
35
|
+
token_id, token_hash, client_id, tenant_id, subject, scopes, roles, resource, expires_at, status
|
|
36
|
+
) VALUES (
|
|
37
|
+
$1, $2, $3, $4, $5, $6, $7, $8, $9, 'active'
|
|
38
|
+
)`, [
|
|
39
|
+
randomUUID(),
|
|
40
|
+
input.tokenHash,
|
|
41
|
+
input.clientId,
|
|
42
|
+
input.tenantId,
|
|
43
|
+
input.subject,
|
|
44
|
+
input.scopes,
|
|
45
|
+
input.roles,
|
|
46
|
+
input.resource ?? null,
|
|
47
|
+
input.expiresAt,
|
|
48
|
+
]);
|
|
49
|
+
}
|
|
50
|
+
async getByHash(tokenHash) {
|
|
51
|
+
const result = await this.database.query("SELECT * FROM access_tokens WHERE token_hash = $1", [tokenHash]);
|
|
52
|
+
return result.rows[0] ? mapRow(result.rows[0]) : undefined;
|
|
53
|
+
}
|
|
54
|
+
async getById(tokenId) {
|
|
55
|
+
const result = await this.database.query("SELECT * FROM access_tokens WHERE token_id = $1", [tokenId]);
|
|
56
|
+
if (!result.rows[0]) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const { tokenHash: _tokenHash, ...record } = mapRow(result.rows[0]);
|
|
60
|
+
return record;
|
|
61
|
+
}
|
|
62
|
+
async touch(tokenHash) {
|
|
63
|
+
await this.database.query("UPDATE access_tokens SET last_used_at = NOW() WHERE token_hash = $1", [tokenHash]);
|
|
64
|
+
}
|
|
65
|
+
async list(filter) {
|
|
66
|
+
const clauses = [];
|
|
67
|
+
const values = [];
|
|
68
|
+
if (filter?.clientId) {
|
|
69
|
+
values.push(filter.clientId);
|
|
70
|
+
clauses.push(`client_id = $${values.length}`);
|
|
71
|
+
}
|
|
72
|
+
if (filter?.status) {
|
|
73
|
+
values.push(filter.status);
|
|
74
|
+
clauses.push(`status = $${values.length}`);
|
|
75
|
+
}
|
|
76
|
+
if (filter?.tenantId) {
|
|
77
|
+
values.push(filter.tenantId);
|
|
78
|
+
clauses.push(`tenant_id = $${values.length}`);
|
|
79
|
+
}
|
|
80
|
+
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
81
|
+
const result = await this.database.query(`SELECT * FROM access_tokens ${where} ORDER BY created_at DESC`, values);
|
|
82
|
+
return result.rows.map((row) => {
|
|
83
|
+
const { tokenHash: _tokenHash, ...record } = mapRow(row);
|
|
84
|
+
return record;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async expireStale() {
|
|
88
|
+
const result = await this.database.query(`WITH expired AS (
|
|
89
|
+
UPDATE access_tokens
|
|
90
|
+
SET status = 'revoked'
|
|
91
|
+
WHERE status = 'active' AND expires_at <= NOW()
|
|
92
|
+
RETURNING 1
|
|
93
|
+
)
|
|
94
|
+
SELECT COUNT(*)::text AS count FROM expired`);
|
|
95
|
+
return Number.parseInt(result.rows[0]?.count ?? "0", 10);
|
|
96
|
+
}
|
|
97
|
+
async revokeById(tokenId) {
|
|
98
|
+
const result = await this.database.query(`UPDATE access_tokens
|
|
99
|
+
SET status = 'revoked'
|
|
100
|
+
WHERE token_id = $1 AND status = 'active'
|
|
101
|
+
RETURNING *`, [tokenId]);
|
|
102
|
+
if (!result.rows[0]) {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
const { tokenHash: _tokenHash, ...record } = mapRow(result.rows[0]);
|
|
106
|
+
return record;
|
|
107
|
+
}
|
|
108
|
+
async revokeByClientId(clientId) {
|
|
109
|
+
const result = await this.database.query(`WITH revoked AS (
|
|
110
|
+
UPDATE access_tokens
|
|
111
|
+
SET status = 'revoked'
|
|
112
|
+
WHERE client_id = $1 AND status = 'active'
|
|
113
|
+
RETURNING 1
|
|
114
|
+
)
|
|
115
|
+
SELECT COUNT(*)::text AS count FROM revoked`, [clientId]);
|
|
116
|
+
return Number.parseInt(result.rows[0]?.count ?? "0", 10);
|
|
117
|
+
}
|
|
118
|
+
}
|