taurusdb-mcp 0.5.0-rc.2 → 0.5.0-rc.3
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/README.md +6 -20
- package/dist/security/local-credential-login.d.ts +17 -0
- package/dist/security/local-credential-login.js +288 -33
- package/dist/security/session-credential-manager.d.ts +29 -0
- package/dist/security/session-credential-manager.js +85 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +12 -1
- package/dist/tools/query.d.ts +1 -1
- package/dist/tools/query.js +219 -37
- package/dist/tools/registry.d.ts +0 -1
- package/dist/tools/registry.js +33 -13
- package/dist/tools/taurus/cloud-context.js +11 -0
- package/dist/tools/taurus/recycle-bin.d.ts +0 -1
- package/dist/tools/taurus/recycle-bin.js +2 -119
- package/dist/tools/taurus/sql-login.js +111 -9
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -88,28 +88,14 @@ npx taurusdb-mcp credentials check
|
|
|
88
88
|
- Huawei DEW CSMS is recommended when the database password should be stored and retrieved from Huawei Cloud
|
|
89
89
|
- macOS Keychain, Linux Secret Service, or Windows Credential Manager cloud identity is enabled with `TAURUSDB_CLOUD_KEYCHAIN_SERVICE`
|
|
90
90
|
- SQL TLS with certificate verification is required by default
|
|
91
|
-
-
|
|
92
|
-
-
|
|
93
|
-
|
|
91
|
+
- Database mutation tools do not exist; the MCP remains read-only regardless of database account privileges
|
|
92
|
+
- `analyze_mutation_sql` returns evidence-backed SQL Advice with `execution_status: not_executed`
|
|
93
|
+
- Local SQL login validates the connection before binding credentials, allows at most three attempts per five-minute link, and never sends the password through Agent-visible tool arguments
|
|
94
|
+
- Session SQL credentials expire after 30 idle minutes and eight absolute hours; administrators may shorten these limits with `TAURUSDB_SQL_CREDENTIAL_IDLE_TTL_MINUTES` and `TAURUSDB_SQL_CREDENTIAL_MAX_TTL_MINUTES`
|
|
94
95
|
- MCP audit events are written to `~/.taurusdb-mcp/audit.jsonl` by default, with
|
|
95
96
|
configurable size rotation for collection into centralized immutable storage
|
|
96
97
|
- Run one stdio process per customer/client trust boundary; this package is not
|
|
97
98
|
a shared multi-tenant HTTP service
|
|
98
99
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
```bash
|
|
102
|
-
export TAURUSDB_ENABLE_MUTATIONS=true
|
|
103
|
-
export TAURUSDB_SQL_MUTATION_USER='<dedicated-writer>'
|
|
104
|
-
export TAURUSDB_SQL_MUTATION_PASSWORD='hw-csms:<writer-secret-name>'
|
|
105
|
-
export TAURUSDB_MUTATION_APPROVAL_SECRET_FILE='/run/secrets/taurusdb-approval'
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
Sign a returned `approval_request` outside the MCP client:
|
|
109
|
-
|
|
110
|
-
```bash
|
|
111
|
-
npx taurusdb-mcp approve \
|
|
112
|
-
--request '<approval_request>' \
|
|
113
|
-
--actor '<operator-identity>' \
|
|
114
|
-
--secret-file /run/secrets/taurusdb-approval
|
|
115
|
-
```
|
|
100
|
+
Customers execute reviewed `advised_sql` through their own controlled change process;
|
|
101
|
+
the MCP never executes database state changes.
|
|
@@ -3,14 +3,27 @@ export type SqlLoginCredentials = {
|
|
|
3
3
|
username: string;
|
|
4
4
|
password: string;
|
|
5
5
|
};
|
|
6
|
+
export type SqlLoginTarget = {
|
|
7
|
+
datasource: string;
|
|
8
|
+
instanceId?: string;
|
|
9
|
+
region?: string;
|
|
10
|
+
credentialIdleTtlMinutes?: number;
|
|
11
|
+
credentialMaxTtlMinutes?: number;
|
|
12
|
+
};
|
|
6
13
|
export type SqlLoginRequest = {
|
|
7
14
|
datasource: string;
|
|
15
|
+
target?: SqlLoginTarget;
|
|
8
16
|
bind: (credentials: SqlLoginCredentials) => Promise<void>;
|
|
9
17
|
};
|
|
10
18
|
export type IssuedSqlLogin = {
|
|
11
19
|
loginUrl: string;
|
|
12
20
|
expiresAt: string;
|
|
13
21
|
};
|
|
22
|
+
export type CredentialValidationFailure = "credentials" | "connectivity" | "tls" | "timeout";
|
|
23
|
+
export declare class SqlCredentialValidationError extends Error {
|
|
24
|
+
readonly kind: CredentialValidationFailure;
|
|
25
|
+
constructor(kind: CredentialValidationFailure);
|
|
26
|
+
}
|
|
14
27
|
export interface CredentialLoginService {
|
|
15
28
|
issueSqlLogin(request: SqlLoginRequest): Promise<IssuedSqlLogin>;
|
|
16
29
|
close(): Promise<void>;
|
|
@@ -18,10 +31,14 @@ export interface CredentialLoginService {
|
|
|
18
31
|
export type LocalCredentialLoginServiceOptions = {
|
|
19
32
|
now?: () => number;
|
|
20
33
|
tokenTtlMs?: number;
|
|
34
|
+
maxAttempts?: number;
|
|
35
|
+
failureDelayMs?: number;
|
|
21
36
|
};
|
|
22
37
|
export declare class LocalCredentialLoginService implements CredentialLoginService {
|
|
23
38
|
private readonly now;
|
|
24
39
|
private readonly tokenTtlMs;
|
|
40
|
+
private readonly maxAttempts;
|
|
41
|
+
private readonly failureDelayMs;
|
|
25
42
|
private readonly pending;
|
|
26
43
|
private server;
|
|
27
44
|
private port;
|
|
@@ -1,42 +1,247 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { createServer, } from "node:http";
|
|
3
3
|
const DEFAULT_TOKEN_TTL_MS = 5 * 60 * 1000;
|
|
4
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
5
|
+
const DEFAULT_FAILURE_DELAY_MS = 1_000;
|
|
4
6
|
const MAX_REQUEST_BODY_BYTES = 16 * 1024;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
export class SqlCredentialValidationError extends Error {
|
|
8
|
+
kind;
|
|
9
|
+
constructor(kind) {
|
|
10
|
+
super(`SQL credential validation failed: ${kind}`);
|
|
11
|
+
this.name = "SqlCredentialValidationError";
|
|
12
|
+
this.kind = kind;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const COPY = {
|
|
16
|
+
"zh-CN": {
|
|
17
|
+
product: "TaurusDB MCP",
|
|
18
|
+
secureSession: "安全会话",
|
|
19
|
+
title: "连接数据库",
|
|
20
|
+
intro: "输入数据库账号和密码以验证连接。",
|
|
21
|
+
target: "连接目标",
|
|
22
|
+
instance: "实例",
|
|
23
|
+
region: "区域",
|
|
24
|
+
datasource: "数据源",
|
|
25
|
+
configured: "已配置的数据源",
|
|
26
|
+
unknown: "未指定",
|
|
27
|
+
username: "数据库账号",
|
|
28
|
+
password: "数据库密码",
|
|
29
|
+
usernamePlaceholder: "请输入数据库账号",
|
|
30
|
+
passwordPlaceholder: "请输入数据库密码",
|
|
31
|
+
submit: "连接数据库",
|
|
32
|
+
submitting: "正在验证连接…",
|
|
33
|
+
privacy: "凭据对 Agent 不可见,仅用于连接您选择的数据库,不会由 MCP 持久化保存。",
|
|
34
|
+
retention: (idle, maximum) => `空闲 ${idle} 分钟后清除 · 最长保留 ${maximum >= 60 ? `${maximum / 60} 小时` : `${maximum} 分钟`}`,
|
|
35
|
+
attempts: (remaining) => `本链接还可尝试 ${remaining} 次`,
|
|
36
|
+
required: "请输入数据库账号和密码。",
|
|
37
|
+
credentials: "无法验证账号信息,请检查账号和密码。",
|
|
38
|
+
connectivity: "数据库服务暂时不可达,请检查网络和实例状态。",
|
|
39
|
+
tls: "TLS 安全连接验证失败,请联系管理员检查证书配置。",
|
|
40
|
+
timeout: "连接验证超时,请稍后重试。",
|
|
41
|
+
busy: "连接正在验证,请勿重复提交。",
|
|
42
|
+
successTitle: "账号验证成功",
|
|
43
|
+
successMessage: "您现在可以返回 MCP 会话并选择需要访问的数据库。",
|
|
44
|
+
expiredTitle: "登录链接已失效",
|
|
45
|
+
expiredMessage: "请返回 MCP 会话并重新调用 begin_sql_login。",
|
|
46
|
+
lockedTitle: "尝试次数已用完",
|
|
47
|
+
lockedMessage: "为保护数据库账号,此链接已失效。请返回 MCP 会话重新生成登录链接。",
|
|
48
|
+
methodTitle: "不支持此请求",
|
|
49
|
+
methodMessage: "请使用登录表单继续。",
|
|
50
|
+
invalidTitle: "无法处理请求",
|
|
51
|
+
invalidMessage: "登录请求无效,请重新生成登录链接。",
|
|
52
|
+
},
|
|
53
|
+
en: {
|
|
54
|
+
product: "TaurusDB MCP",
|
|
55
|
+
secureSession: "Secure session",
|
|
56
|
+
title: "Connect to database",
|
|
57
|
+
intro: "Enter your database credentials to validate the connection.",
|
|
58
|
+
target: "Connection target",
|
|
59
|
+
instance: "Instance",
|
|
60
|
+
region: "Region",
|
|
61
|
+
datasource: "Datasource",
|
|
62
|
+
configured: "Configured datasource",
|
|
63
|
+
unknown: "Not specified",
|
|
64
|
+
username: "Database username",
|
|
65
|
+
password: "Database password",
|
|
66
|
+
usernamePlaceholder: "Enter database username",
|
|
67
|
+
passwordPlaceholder: "Enter database password",
|
|
68
|
+
submit: "Connect to database",
|
|
69
|
+
submitting: "Validating connection…",
|
|
70
|
+
privacy: "Credentials are not visible to the Agent. They are used only to connect to your selected database and are not persisted by MCP.",
|
|
71
|
+
retention: (idle, maximum) => `Cleared after ${idle} idle minutes · ${maximum >= 60 ? `${maximum / 60} ${maximum === 60 ? "hour" : "hours"}` : `${maximum} ${maximum === 1 ? "minute" : "minutes"}`} maximum`,
|
|
72
|
+
attempts: (remaining) => `${remaining} attempts remaining for this link`,
|
|
73
|
+
required: "Enter both the database username and password.",
|
|
74
|
+
credentials: "The account could not be validated. Check the username and password.",
|
|
75
|
+
connectivity: "The database is currently unreachable. Check the network and instance status.",
|
|
76
|
+
tls: "The TLS connection could not be validated. Ask an administrator to check the certificate configuration.",
|
|
77
|
+
timeout: "Connection validation timed out. Try again shortly.",
|
|
78
|
+
busy: "Connection validation is already in progress. Do not submit again.",
|
|
79
|
+
successTitle: "Account validated",
|
|
80
|
+
successMessage: "Return to your MCP session and select the database you want to access.",
|
|
81
|
+
expiredTitle: "Login link expired",
|
|
82
|
+
expiredMessage: "Return to your MCP session and call begin_sql_login again.",
|
|
83
|
+
lockedTitle: "Attempt limit reached",
|
|
84
|
+
lockedMessage: "This link has expired to protect the database account. Return to your MCP session and create a new login link.",
|
|
85
|
+
methodTitle: "Unsupported request",
|
|
86
|
+
methodMessage: "Use the login form to continue.",
|
|
87
|
+
invalidTitle: "Request could not be processed",
|
|
88
|
+
invalidMessage: "The login request is invalid. Create a new login link.",
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
function localeFromRequest(req) {
|
|
92
|
+
return /(?:^|,)\s*zh(?:-|_|;|,|$)/i.test(req.headers["accept-language"] ?? "")
|
|
93
|
+
? "zh-CN"
|
|
94
|
+
: "en";
|
|
95
|
+
}
|
|
96
|
+
function escapeHtml(value) {
|
|
97
|
+
return value
|
|
98
|
+
.replaceAll("&", "&")
|
|
99
|
+
.replaceAll("<", "<")
|
|
100
|
+
.replaceAll(">", ">")
|
|
101
|
+
.replaceAll('"', """)
|
|
102
|
+
.replaceAll("'", "'");
|
|
103
|
+
}
|
|
104
|
+
function maskInstanceId(value, fallback) {
|
|
105
|
+
if (!value) {
|
|
106
|
+
return fallback;
|
|
107
|
+
}
|
|
108
|
+
if (value.length <= 10) {
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
return `${value.slice(0, 5)}…${value.slice(-4)}`;
|
|
112
|
+
}
|
|
113
|
+
function page(input) {
|
|
114
|
+
const copy = COPY[input.locale];
|
|
115
|
+
const isForm = input.state === "form";
|
|
116
|
+
const title = isForm
|
|
117
|
+
? copy.title
|
|
118
|
+
: input.state === "success"
|
|
119
|
+
? copy.successTitle
|
|
120
|
+
: input.state === "expired"
|
|
121
|
+
? copy.expiredTitle
|
|
122
|
+
: input.state === "locked"
|
|
123
|
+
? copy.lockedTitle
|
|
124
|
+
: input.state === "method"
|
|
125
|
+
? copy.methodTitle
|
|
126
|
+
: copy.invalidTitle;
|
|
127
|
+
const message = input.message ?? (isForm
|
|
128
|
+
? copy.intro
|
|
129
|
+
: input.state === "success"
|
|
130
|
+
? copy.successMessage
|
|
131
|
+
: input.state === "expired"
|
|
132
|
+
? copy.expiredMessage
|
|
133
|
+
: input.state === "locked"
|
|
134
|
+
? copy.lockedMessage
|
|
135
|
+
: input.state === "method"
|
|
136
|
+
? copy.methodMessage
|
|
137
|
+
: copy.invalidMessage);
|
|
138
|
+
const nonce = randomBytes(18).toString("base64url");
|
|
139
|
+
const target = input.target;
|
|
140
|
+
const formHtml = isForm
|
|
141
|
+
? `<form method="post" autocomplete="on" data-login-form>
|
|
142
|
+
${input.message ? `<div class="alert" role="alert">${escapeHtml(input.message)}</div>` : ""}
|
|
143
|
+
<label for="username">${copy.username}</label>
|
|
144
|
+
<input id="username" name="username" autocomplete="username" maxlength="256" value="${escapeHtml(input.username ?? "")}" placeholder="${copy.usernamePlaceholder}" required autofocus>
|
|
145
|
+
<label for="password">${copy.password}</label>
|
|
146
|
+
<input id="password" name="password" type="password" autocomplete="current-password" maxlength="4096" placeholder="${copy.passwordPlaceholder}" required>
|
|
147
|
+
<button type="submit" data-submit data-pending="${copy.submitting}">${copy.submit}</button>
|
|
148
|
+
${input.remainingAttempts !== undefined ? `<p class="attempts">${copy.attempts(input.remainingAttempts)}</p>` : ""}
|
|
149
|
+
</form>`
|
|
150
|
+
: `<section class="result ${input.state}" aria-live="polite">
|
|
151
|
+
<span class="result-mark" aria-hidden="true">${input.state === "success" ? "✓" : "!"}</span>
|
|
152
|
+
<h1>${escapeHtml(title)}</h1>
|
|
153
|
+
<p>${escapeHtml(message)}</p>
|
|
154
|
+
</section>`;
|
|
13
155
|
return `<!doctype html>
|
|
14
|
-
<html lang="
|
|
156
|
+
<html lang="${input.locale}">
|
|
15
157
|
<head>
|
|
16
158
|
<meta charset="utf-8">
|
|
17
159
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
18
|
-
<
|
|
160
|
+
<meta name="color-scheme" content="light">
|
|
161
|
+
<title>${escapeHtml(title)} · ${copy.product}</title>
|
|
19
162
|
<style>
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
163
|
+
:root { color-scheme: light; --ink:#172033; --muted:#637083; --cloud:#f3f6fa; --surface:#fff; --line:#dce3eb; --teal:#087f73; --teal-dark:#06665e; --teal-soft:#e7f5f2; --danger:#b42318; --danger-soft:#fff0ee; --shadow:0 22px 60px rgba(23,32,51,.12); }
|
|
164
|
+
* { box-sizing:border-box; }
|
|
165
|
+
body { margin:0; min-height:100vh; color:var(--ink); background:var(--cloud); font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
|
|
166
|
+
.shell { width:min(980px,calc(100% - 32px)); margin:clamp(24px,7vh,72px) auto; }
|
|
167
|
+
.masthead { display:flex; justify-content:space-between; align-items:center; margin-bottom:18px; font-size:13px; letter-spacing:.04em; }
|
|
168
|
+
.brand { font-weight:760; font-size:15px; letter-spacing:-.01em; }
|
|
169
|
+
.session { color:var(--teal-dark); background:var(--teal-soft); padding:7px 11px; border-radius:999px; font-weight:650; }
|
|
170
|
+
.panel { display:grid; grid-template-columns:minmax(270px,.82fr) minmax(340px,1.18fr); overflow:hidden; border:1px solid rgba(23,32,51,.08); border-radius:22px; background:var(--surface); box-shadow:var(--shadow); }
|
|
171
|
+
.context { position:relative; padding:44px 40px; color:#f7fbff; background:var(--ink); }
|
|
172
|
+
.context:after { content:""; position:absolute; width:240px; height:240px; right:-120px; bottom:-130px; border:1px solid rgba(255,255,255,.14); border-radius:50%; box-shadow:0 0 0 34px rgba(255,255,255,.035),0 0 0 72px rgba(255,255,255,.025); }
|
|
173
|
+
.eyebrow { margin:0 0 28px; color:#9edbd4; font-size:12px; font-weight:720; letter-spacing:.11em; text-transform:uppercase; }
|
|
174
|
+
.rail { margin:0; }
|
|
175
|
+
.rail-item { display:grid; grid-template-columns:14px 1fr; gap:0 14px; }
|
|
176
|
+
.rail dt,.rail dd { margin:0; padding-bottom:25px; }
|
|
177
|
+
.rail dt { color:#aeb8c8; font-size:12px; }
|
|
178
|
+
.rail dd { margin-top:18px; font-size:15px; font-weight:650; overflow-wrap:anywhere; }
|
|
179
|
+
.node { position:relative; width:10px; height:10px; margin-top:4px; border:2px solid #73d2c8; border-radius:50%; background:var(--ink); }
|
|
180
|
+
.node:not(.last):after { content:""; position:absolute; left:2px; top:10px; width:2px; height:54px; background:linear-gradient(#3b8f88,rgba(59,143,136,.16)); }
|
|
181
|
+
.target-row { grid-column:2; display:grid; grid-template-columns:76px 1fr; }
|
|
182
|
+
.retention { position:relative; z-index:1; margin:18px 0 0; padding-top:18px; border-top:1px solid rgba(255,255,255,.13); color:#aeb8c8; font-size:12px; line-height:1.55; }
|
|
183
|
+
.content { padding:44px 48px 36px; }
|
|
184
|
+
.content h1 { margin:0 0 10px; font-size:clamp(28px,4vw,38px); line-height:1.08; letter-spacing:-.035em; }
|
|
185
|
+
.intro { margin:0 0 30px; color:var(--muted); line-height:1.65; }
|
|
186
|
+
form { display:grid; gap:9px; }
|
|
187
|
+
label { margin-top:9px; font-size:13px; font-weight:700; }
|
|
188
|
+
input { width:100%; min-height:48px; border:1px solid #cbd4df; border-radius:10px; padding:11px 13px; color:var(--ink); background:#fbfcfe; font:inherit; outline:none; transition:border-color .16s,box-shadow .16s,background .16s; }
|
|
189
|
+
input:focus { border-color:var(--teal); background:#fff; box-shadow:0 0 0 4px rgba(8,127,115,.12); }
|
|
190
|
+
button { min-height:50px; margin-top:17px; border:0; border-radius:10px; color:#fff; background:var(--teal); font:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:background .16s,transform .16s; }
|
|
191
|
+
button:hover { background:var(--teal-dark); }
|
|
192
|
+
button:active { transform:translateY(1px); }
|
|
193
|
+
button:focus-visible { outline:3px solid rgba(8,127,115,.28); outline-offset:3px; }
|
|
194
|
+
button:disabled { cursor:wait; opacity:.72; }
|
|
195
|
+
.alert { margin-bottom:4px; border-left:3px solid var(--danger); border-radius:7px; padding:11px 13px; color:#8d1b13; background:var(--danger-soft); font-size:13px; line-height:1.5; }
|
|
196
|
+
.attempts { margin:2px 0 0; color:var(--muted); text-align:center; font-size:12px; }
|
|
197
|
+
.privacy { display:flex; gap:10px; align-items:flex-start; margin:27px 0 0; padding-top:20px; border-top:1px solid var(--line); color:var(--muted); font-size:12px; line-height:1.6; }
|
|
198
|
+
.shield { flex:0 0 auto; color:var(--teal); font-size:15px; line-height:1.3; }
|
|
199
|
+
.result { min-height:280px; display:flex; flex-direction:column; align-items:flex-start; justify-content:center; }
|
|
200
|
+
.result-mark { display:grid; place-items:center; width:46px; height:46px; margin-bottom:22px; border-radius:50%; color:#fff; background:var(--danger); font-size:22px; font-weight:800; }
|
|
201
|
+
.result.success .result-mark { background:var(--teal); }
|
|
202
|
+
.result p { max-width:35rem; color:var(--muted); line-height:1.7; }
|
|
203
|
+
@media (max-width:760px) { .shell{width:min(100% - 20px,560px);margin:18px auto}.panel{grid-template-columns:1fr;border-radius:17px}.context{padding:28px 26px 18px}.content{padding:30px 26px}.rail dt,.rail dd{padding-bottom:17px}.node:not(.last):after{height:44px}.content h1{font-size:30px} }
|
|
204
|
+
@media (prefers-reduced-motion:reduce) { *,*:before,*:after{scroll-behavior:auto!important;transition:none!important} }
|
|
24
205
|
</style>
|
|
25
206
|
</head>
|
|
26
207
|
<body>
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
208
|
+
<main class="shell">
|
|
209
|
+
<header class="masthead"><span class="brand">${copy.product}</span><span class="session">${copy.secureSession}</span></header>
|
|
210
|
+
<section class="panel">
|
|
211
|
+
<aside class="context">
|
|
212
|
+
<p class="eyebrow">${copy.target}</p>
|
|
213
|
+
<dl class="rail">
|
|
214
|
+
<div class="rail-item"><span class="node"></span><div class="target-row"><dt>${copy.instance}</dt><dd>${escapeHtml(maskInstanceId(target?.instanceId, copy.configured))}</dd></div></div>
|
|
215
|
+
<div class="rail-item"><span class="node"></span><div class="target-row"><dt>${copy.region}</dt><dd>${escapeHtml(target?.region ?? copy.unknown)}</dd></div></div>
|
|
216
|
+
<div class="rail-item"><span class="node last"></span><div class="target-row"><dt>${copy.datasource}</dt><dd>${escapeHtml(target?.datasource ?? copy.unknown)}</dd></div></div>
|
|
217
|
+
</dl>
|
|
218
|
+
${target?.credentialIdleTtlMinutes && target.credentialMaxTtlMinutes ? `<p class="retention">${copy.retention(target.credentialIdleTtlMinutes, target.credentialMaxTtlMinutes)}</p>` : ""}
|
|
219
|
+
</aside>
|
|
220
|
+
<div class="content">
|
|
221
|
+
${isForm ? `<h1>${copy.title}</h1><p class="intro">${copy.intro}</p>` : ""}
|
|
222
|
+
${formHtml}
|
|
223
|
+
${isForm ? `<p class="privacy"><span class="shield" aria-hidden="true">◆</span><span>${copy.privacy}</span></p>` : ""}
|
|
224
|
+
</div>
|
|
225
|
+
</section>
|
|
226
|
+
</main>
|
|
227
|
+
${isForm ? `<script nonce="${nonce}">document.querySelector('[data-login-form]').addEventListener('submit',()=>{const b=document.querySelector('[data-submit]');b.disabled=true;b.textContent=b.dataset.pending;});</script>` : ""}
|
|
30
228
|
</body>
|
|
31
229
|
</html>`;
|
|
32
230
|
}
|
|
33
231
|
function respond(res, status, body) {
|
|
232
|
+
const nonce = /<script nonce="([^"]+)"/.exec(body)?.[1];
|
|
34
233
|
res.writeHead(status, {
|
|
35
234
|
"cache-control": "no-store",
|
|
36
|
-
"content-security-policy":
|
|
235
|
+
"content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'${nonce ? `; script-src 'nonce-${nonce}'` : ""}`,
|
|
37
236
|
"content-type": "text/html; charset=utf-8",
|
|
237
|
+
"cross-origin-opener-policy": "same-origin",
|
|
238
|
+
"cross-origin-resource-policy": "same-origin",
|
|
239
|
+
expires: "0",
|
|
240
|
+
"permissions-policy": "camera=(), microphone=(), geolocation=()",
|
|
241
|
+
pragma: "no-cache",
|
|
38
242
|
"referrer-policy": "no-referrer",
|
|
39
243
|
"x-content-type-options": "nosniff",
|
|
244
|
+
"x-frame-options": "DENY",
|
|
40
245
|
});
|
|
41
246
|
res.end(body);
|
|
42
247
|
}
|
|
@@ -53,21 +258,28 @@ async function readBody(req) {
|
|
|
53
258
|
}
|
|
54
259
|
return Buffer.concat(chunks).toString("utf8");
|
|
55
260
|
}
|
|
261
|
+
function delay(ms) {
|
|
262
|
+
return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve();
|
|
263
|
+
}
|
|
56
264
|
export class LocalCredentialLoginService {
|
|
57
265
|
now;
|
|
58
266
|
tokenTtlMs;
|
|
267
|
+
maxAttempts;
|
|
268
|
+
failureDelayMs;
|
|
59
269
|
pending = new Map();
|
|
60
270
|
server;
|
|
61
271
|
port;
|
|
62
272
|
constructor(options = {}) {
|
|
63
273
|
this.now = options.now ?? Date.now;
|
|
64
274
|
this.tokenTtlMs = options.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS;
|
|
275
|
+
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
276
|
+
this.failureDelayMs = options.failureDelayMs ?? DEFAULT_FAILURE_DELAY_MS;
|
|
65
277
|
}
|
|
66
278
|
async issueSqlLogin(request) {
|
|
67
279
|
await this.ensureStarted();
|
|
68
280
|
const token = randomBytes(32).toString("base64url");
|
|
69
281
|
const expiresAtMs = this.now() + this.tokenTtlMs;
|
|
70
|
-
this.pending.set(token, { ...request, expiresAtMs });
|
|
282
|
+
this.pending.set(token, { ...request, expiresAtMs, failedAttempts: 0, inFlight: false });
|
|
71
283
|
return {
|
|
72
284
|
loginUrl: `http://127.0.0.1:${this.port}/sql-login/${token}`,
|
|
73
285
|
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
@@ -108,6 +320,7 @@ export class LocalCredentialLoginService {
|
|
|
108
320
|
this.port = address.port;
|
|
109
321
|
}
|
|
110
322
|
async handle(req, res) {
|
|
323
|
+
const locale = localeFromRequest(req);
|
|
111
324
|
try {
|
|
112
325
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
113
326
|
const match = url.pathname.match(/^\/sql-login\/([^/]+)$/);
|
|
@@ -117,40 +330,82 @@ export class LocalCredentialLoginService {
|
|
|
117
330
|
if (token) {
|
|
118
331
|
this.pending.delete(token);
|
|
119
332
|
}
|
|
120
|
-
respond(res, 410, page(
|
|
333
|
+
respond(res, 410, page({ locale, state: "expired", target: pending?.target }));
|
|
121
334
|
return;
|
|
122
335
|
}
|
|
123
336
|
if (req.method === "GET") {
|
|
124
|
-
respond(res, 200, page(
|
|
337
|
+
respond(res, 200, page({
|
|
338
|
+
locale,
|
|
339
|
+
state: "form",
|
|
340
|
+
target: pending.target,
|
|
341
|
+
remainingAttempts: this.maxAttempts - pending.failedAttempts,
|
|
342
|
+
}));
|
|
125
343
|
return;
|
|
126
344
|
}
|
|
127
345
|
if (req.method !== "POST") {
|
|
128
|
-
respond(res, 405, page(
|
|
346
|
+
respond(res, 405, page({ locale, state: "method", target: pending.target }));
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const origin = req.headers.origin;
|
|
350
|
+
if (origin && origin !== `http://${req.headers.host}`) {
|
|
351
|
+
respond(res, 403, page({ locale, state: "invalid", target: pending.target }));
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (pending.inFlight) {
|
|
355
|
+
respond(res, 409, page({
|
|
356
|
+
locale,
|
|
357
|
+
state: "form",
|
|
358
|
+
target: pending.target,
|
|
359
|
+
message: COPY[locale].busy,
|
|
360
|
+
remainingAttempts: this.maxAttempts - pending.failedAttempts,
|
|
361
|
+
}));
|
|
129
362
|
return;
|
|
130
363
|
}
|
|
131
364
|
const form = new URLSearchParams(await readBody(req));
|
|
132
365
|
const username = form.get("username")?.trim() ?? "";
|
|
133
366
|
const password = form.get("password") ?? "";
|
|
134
367
|
if (!username || !password) {
|
|
135
|
-
respond(res, 400, page(
|
|
368
|
+
respond(res, 400, page({
|
|
369
|
+
locale,
|
|
370
|
+
state: "form",
|
|
371
|
+
target: pending.target,
|
|
372
|
+
message: COPY[locale].required,
|
|
373
|
+
username,
|
|
374
|
+
remainingAttempts: this.maxAttempts - pending.failedAttempts,
|
|
375
|
+
}));
|
|
136
376
|
return;
|
|
137
377
|
}
|
|
138
|
-
this.pending.delete(token);
|
|
139
378
|
try {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
username,
|
|
143
|
-
password,
|
|
144
|
-
});
|
|
379
|
+
pending.inFlight = true;
|
|
380
|
+
await pending.bind({ datasource: pending.datasource, username, password });
|
|
145
381
|
}
|
|
146
|
-
catch {
|
|
147
|
-
|
|
382
|
+
catch (error) {
|
|
383
|
+
pending.inFlight = false;
|
|
384
|
+
const kind = error instanceof SqlCredentialValidationError ? error.kind : "connectivity";
|
|
385
|
+
if (kind === "credentials") {
|
|
386
|
+
pending.failedAttempts += 1;
|
|
387
|
+
await delay(this.failureDelayMs * 2 ** (pending.failedAttempts - 1));
|
|
388
|
+
if (pending.failedAttempts >= this.maxAttempts) {
|
|
389
|
+
this.pending.delete(token);
|
|
390
|
+
respond(res, 429, page({ locale, state: "locked", target: pending.target }));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
respond(res, kind === "credentials" ? 401 : kind === "timeout" ? 504 : 502, page({
|
|
395
|
+
locale,
|
|
396
|
+
state: "form",
|
|
397
|
+
target: pending.target,
|
|
398
|
+
message: COPY[locale][kind],
|
|
399
|
+
username,
|
|
400
|
+
remainingAttempts: this.maxAttempts - pending.failedAttempts,
|
|
401
|
+
}));
|
|
148
402
|
return;
|
|
149
403
|
}
|
|
150
|
-
|
|
404
|
+
this.pending.delete(token);
|
|
405
|
+
respond(res, 200, page({ locale, state: "success", target: pending.target }));
|
|
151
406
|
}
|
|
152
407
|
catch {
|
|
153
|
-
respond(res, 400, page(
|
|
408
|
+
respond(res, 400, page({ locale, state: "invalid" }));
|
|
154
409
|
}
|
|
155
410
|
}
|
|
156
411
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
type TimerHandle = ReturnType<typeof setTimeout>;
|
|
2
|
+
export type SessionCredentialManagerOptions = {
|
|
3
|
+
idleTtlMs?: number;
|
|
4
|
+
maxTtlMs?: number;
|
|
5
|
+
now?: () => number;
|
|
6
|
+
schedule?: (callback: () => void, delayMs: number) => TimerHandle;
|
|
7
|
+
cancel?: (timer: TimerHandle) => void;
|
|
8
|
+
onExpirationError?: (error: unknown, datasource: string) => void;
|
|
9
|
+
};
|
|
10
|
+
export declare class SessionCredentialManager {
|
|
11
|
+
private readonly idleTtlMs;
|
|
12
|
+
private readonly maxTtlMs;
|
|
13
|
+
private readonly now;
|
|
14
|
+
private readonly scheduleTimer;
|
|
15
|
+
private readonly cancelTimer;
|
|
16
|
+
private readonly onExpirationError;
|
|
17
|
+
private readonly sessions;
|
|
18
|
+
constructor(options?: SessionCredentialManagerOptions);
|
|
19
|
+
activate(datasource: string, onExpire: () => Promise<void>): void;
|
|
20
|
+
ensureFresh(datasource: string): Promise<void>;
|
|
21
|
+
touch(datasource: string): void;
|
|
22
|
+
clear(datasource: string): void;
|
|
23
|
+
clearAll(): void;
|
|
24
|
+
close(): Promise<void>;
|
|
25
|
+
private deadline;
|
|
26
|
+
private schedule;
|
|
27
|
+
private expireIfDue;
|
|
28
|
+
}
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const MINUTE_MS = 60_000;
|
|
2
|
+
export class SessionCredentialManager {
|
|
3
|
+
idleTtlMs;
|
|
4
|
+
maxTtlMs;
|
|
5
|
+
now;
|
|
6
|
+
scheduleTimer;
|
|
7
|
+
cancelTimer;
|
|
8
|
+
onExpirationError;
|
|
9
|
+
sessions = new Map();
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.idleTtlMs = options.idleTtlMs ?? 30 * MINUTE_MS;
|
|
12
|
+
this.maxTtlMs = options.maxTtlMs ?? 8 * 60 * MINUTE_MS;
|
|
13
|
+
this.now = options.now ?? Date.now;
|
|
14
|
+
this.scheduleTimer = options.schedule ?? setTimeout;
|
|
15
|
+
this.cancelTimer = options.cancel ?? clearTimeout;
|
|
16
|
+
this.onExpirationError = options.onExpirationError ?? (() => undefined);
|
|
17
|
+
}
|
|
18
|
+
activate(datasource, onExpire) {
|
|
19
|
+
this.clear(datasource);
|
|
20
|
+
const now = this.now();
|
|
21
|
+
const session = {
|
|
22
|
+
createdAtMs: now,
|
|
23
|
+
lastActivityAtMs: now,
|
|
24
|
+
onExpire,
|
|
25
|
+
};
|
|
26
|
+
this.sessions.set(datasource, session);
|
|
27
|
+
this.schedule(datasource, session);
|
|
28
|
+
}
|
|
29
|
+
async ensureFresh(datasource) {
|
|
30
|
+
const now = this.now();
|
|
31
|
+
const session = this.sessions.get(datasource);
|
|
32
|
+
if (session && now >= this.deadline(session)) {
|
|
33
|
+
await this.expireIfDue(datasource, session);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
touch(datasource) {
|
|
37
|
+
const session = this.sessions.get(datasource);
|
|
38
|
+
if (session) {
|
|
39
|
+
session.lastActivityAtMs = this.now();
|
|
40
|
+
this.schedule(datasource, session);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
clear(datasource) {
|
|
44
|
+
const session = this.sessions.get(datasource);
|
|
45
|
+
if (session?.timer) {
|
|
46
|
+
this.cancelTimer(session.timer);
|
|
47
|
+
}
|
|
48
|
+
this.sessions.delete(datasource);
|
|
49
|
+
}
|
|
50
|
+
clearAll() {
|
|
51
|
+
for (const datasource of [...this.sessions.keys()]) {
|
|
52
|
+
this.clear(datasource);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async close() {
|
|
56
|
+
this.clearAll();
|
|
57
|
+
}
|
|
58
|
+
deadline(session) {
|
|
59
|
+
return Math.min(session.lastActivityAtMs + this.idleTtlMs, session.createdAtMs + this.maxTtlMs);
|
|
60
|
+
}
|
|
61
|
+
schedule(datasource, session) {
|
|
62
|
+
if (session.timer) {
|
|
63
|
+
this.cancelTimer(session.timer);
|
|
64
|
+
}
|
|
65
|
+
const delayMs = Math.max(0, this.deadline(session) - this.now());
|
|
66
|
+
session.timer = this.scheduleTimer(() => {
|
|
67
|
+
void this.expireIfDue(datasource, session).catch((error) => {
|
|
68
|
+
this.onExpirationError(error, datasource);
|
|
69
|
+
});
|
|
70
|
+
}, delayMs);
|
|
71
|
+
session.timer.unref?.();
|
|
72
|
+
}
|
|
73
|
+
async expireIfDue(datasource, expected) {
|
|
74
|
+
const current = this.sessions.get(datasource);
|
|
75
|
+
if (current !== expected) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (this.now() < this.deadline(current)) {
|
|
79
|
+
this.schedule(datasource, current);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
this.sessions.delete(datasource);
|
|
83
|
+
await current.onExpire();
|
|
84
|
+
}
|
|
85
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { TaurusDBEngine, type Config, type AuditWriter, type RuntimeTargetProfileLoader } from "taurusdb-core";
|
|
3
3
|
import { type CredentialLoginService } from "./security/local-credential-login.js";
|
|
4
4
|
import { SessionCoordinator } from "./security/session-coordinator.js";
|
|
5
|
+
import { SessionCredentialManager } from "./security/session-credential-manager.js";
|
|
5
6
|
export interface ServerDeps {
|
|
6
7
|
config: Config;
|
|
7
8
|
profileLoader: RuntimeTargetProfileLoader;
|
|
@@ -9,6 +10,8 @@ export interface ServerDeps {
|
|
|
9
10
|
credentialLogin: CredentialLoginService;
|
|
10
11
|
auditWriter?: AuditWriter;
|
|
11
12
|
sessionCoordinator?: SessionCoordinator;
|
|
13
|
+
credentialSessions?: SessionCredentialManager;
|
|
14
|
+
sqlCredentialValidator?: (engine: TaurusDBEngine, datasource: string, taskId: string) => Promise<void>;
|
|
12
15
|
clientIdentityProvider?: () => {
|
|
13
16
|
name: string;
|
|
14
17
|
version: string;
|
package/dist/server.js
CHANGED
|
@@ -6,6 +6,7 @@ import { logger } from "taurusdb-core";
|
|
|
6
6
|
import { VERSION } from "./version.js";
|
|
7
7
|
import { LocalCredentialLoginService, } from "./security/local-credential-login.js";
|
|
8
8
|
import { SessionCoordinator } from "./security/session-coordinator.js";
|
|
9
|
+
import { SessionCredentialManager } from "./security/session-credential-manager.js";
|
|
9
10
|
export async function bootstrapDependencies() {
|
|
10
11
|
const config = getConfig();
|
|
11
12
|
const profileLoader = new RuntimeOverrideProfileLoader(createSqlProfileLoader({ config }));
|
|
@@ -15,13 +16,22 @@ export async function bootstrapDependencies() {
|
|
|
15
16
|
maxBytes: config.audit.maxBytes,
|
|
16
17
|
maxFiles: config.audit.maxFiles,
|
|
17
18
|
});
|
|
19
|
+
const sessionCoordinator = new SessionCoordinator();
|
|
20
|
+
const credentialSessions = new SessionCredentialManager({
|
|
21
|
+
idleTtlMs: config.security.credentialIdleTtlMinutes * 60_000,
|
|
22
|
+
maxTtlMs: config.security.credentialMaxTtlMinutes * 60_000,
|
|
23
|
+
onExpirationError: (error, datasource) => {
|
|
24
|
+
logger.error({ err: error, datasource }, "Failed to expire SQL credential session");
|
|
25
|
+
},
|
|
26
|
+
});
|
|
18
27
|
return {
|
|
19
28
|
config,
|
|
20
29
|
profileLoader,
|
|
21
30
|
engine,
|
|
22
31
|
credentialLogin: new LocalCredentialLoginService(),
|
|
23
32
|
auditWriter,
|
|
24
|
-
sessionCoordinator
|
|
33
|
+
sessionCoordinator,
|
|
34
|
+
credentialSessions,
|
|
25
35
|
pingResponse: "pong",
|
|
26
36
|
};
|
|
27
37
|
}
|
|
@@ -35,6 +45,7 @@ export function createServer(deps) {
|
|
|
35
45
|
const cleanup = () => {
|
|
36
46
|
cleanupPromise ??= Promise.all([
|
|
37
47
|
deps.credentialLogin.close(),
|
|
48
|
+
deps.credentialSessions?.close() ?? Promise.resolve(),
|
|
38
49
|
deps.engine.close(),
|
|
39
50
|
deps.auditWriter?.close() ?? Promise.resolve(),
|
|
40
51
|
]).then(() => undefined);
|