@deepsql/mcp 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/src/cli.js +64 -1
- package/src/commands/_users.js +55 -0
- package/src/commands/access.js +225 -0
- package/src/commands/admin.test.js +135 -0
- package/src/commands/permissions.js +149 -0
- package/src/commands/setup.js +220 -0
- package/src/commands/slow-queries.js +340 -0
- package/src/commands/users.js +276 -0
- package/src/ui/editor.js +61 -0
- package/src/ui/prompts.js +60 -0
- package/src/ui/sse.js +97 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepsql/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "DeepSQL CLI and stdio MCP server for self-hosted deployments",
|
|
5
5
|
"bin": {
|
|
6
6
|
"deepsql": "./bin/deepsql.js",
|
|
@@ -22,5 +22,8 @@
|
|
|
22
22
|
"engines": {
|
|
23
23
|
"node": ">=20"
|
|
24
24
|
},
|
|
25
|
-
"license": "UNLICENSED"
|
|
25
|
+
"license": "UNLICENSED",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@inquirer/prompts": "^8.4.2"
|
|
28
|
+
}
|
|
26
29
|
}
|
package/src/cli.js
CHANGED
|
@@ -24,6 +24,11 @@ const COMMANDS = {
|
|
|
24
24
|
explain: () => require("./commands/explain"),
|
|
25
25
|
schema: () => require("./commands/schema"),
|
|
26
26
|
digest: () => require("./commands/digest"),
|
|
27
|
+
users: () => require("./commands/users"),
|
|
28
|
+
access: () => require("./commands/access"),
|
|
29
|
+
permissions: () => require("./commands/permissions"),
|
|
30
|
+
"slow-queries": () => require("./commands/slow-queries"),
|
|
31
|
+
setup: () => require("./commands/setup"),
|
|
27
32
|
};
|
|
28
33
|
|
|
29
34
|
const HELP = `deepsql — DeepSQL CLI
|
|
@@ -63,6 +68,35 @@ Commands:
|
|
|
63
68
|
digest show <id> [--connection <name>] [--json]
|
|
64
69
|
Show one digest by id.
|
|
65
70
|
|
|
71
|
+
Admin commands (require ADMIN role on the calling token):
|
|
72
|
+
users list | get <ref> | add [<email>] [--role <r>] [--name <n>] [--password-stdin]
|
|
73
|
+
| set-role <ref> <role> | lock|unlock|disable <ref>
|
|
74
|
+
| resend-invite <ref> | reset-password <ref> [--password-stdin]
|
|
75
|
+
| delete <ref> [--yes]
|
|
76
|
+
Manage workspace users.
|
|
77
|
+
access list --user <ref> | --connection <name>
|
|
78
|
+
| grant --user <ref> --connection <name> --level read|write|admin
|
|
79
|
+
| revoke --user <ref> --connection <name>
|
|
80
|
+
| policy <user> <connection> (opens $EDITOR)
|
|
81
|
+
Per-connection access grants and chat policies.
|
|
82
|
+
permissions list [--role <ROLE>] [--json]
|
|
83
|
+
| override --role <ROLE> --permission <PERM> --grant|--revoke [--reason "..."]
|
|
84
|
+
| reset --role <ROLE> --permission <PERM>
|
|
85
|
+
Role-based permission overrides.
|
|
86
|
+
slow-queries latest --connection <name>
|
|
87
|
+
| history --connection <name> [N]
|
|
88
|
+
| analyze --connection <name> [--time-range LAST_24_HOURS|LAST_HOUR]
|
|
89
|
+
[--threshold-ms <n>] [--limit <n>]
|
|
90
|
+
| optimize --connection <name> --query-id <id>
|
|
91
|
+
(streams AI optimization steps to stderr; result to stdout)
|
|
92
|
+
| delete (--history-id <id> | --connection <name>) [--yes]
|
|
93
|
+
Read, trigger, and clean up slow-query analyses.
|
|
94
|
+
setup [--skip-email] [--skip-slack] [--skip-complete]
|
|
95
|
+
Post-install wizard: SMTP/email, Slack
|
|
96
|
+
(digests + bot), then mark setup complete.
|
|
97
|
+
Org and LLM config are set at install time
|
|
98
|
+
and are NOT touched by this wizard.
|
|
99
|
+
|
|
66
100
|
Global options:
|
|
67
101
|
--url <url> Override the DeepSQL base URL.
|
|
68
102
|
--token <tok> Override the auth token (also: DEEPSQL_AUTH_TOKEN).
|
|
@@ -118,21 +152,50 @@ function buildOpts(parsed) {
|
|
|
118
152
|
url: f.url || null,
|
|
119
153
|
token: f.token || null,
|
|
120
154
|
json: !!f.json,
|
|
155
|
+
// Login-flow selectors
|
|
121
156
|
device: !!f.device,
|
|
122
157
|
browser: !!f.browser,
|
|
123
158
|
noBrowser: !!f.noBrowser,
|
|
124
|
-
password
|
|
159
|
+
// password may be `true` (login flow flag) OR a string value (`users add
|
|
160
|
+
// --password secret`, `users add --password=secret`). Each command
|
|
161
|
+
// interprets whichever shape it expects.
|
|
162
|
+
password: f.password ?? null,
|
|
125
163
|
passwordStdin: !!f.passwordStdin,
|
|
126
164
|
email: f.email || null,
|
|
127
165
|
label: f.label || null,
|
|
166
|
+
// Connection / users / chat
|
|
128
167
|
connection: f.connection || f.c || null,
|
|
129
168
|
chat: f.chat || null,
|
|
130
169
|
user: f.user || null,
|
|
131
170
|
project: f.project || null,
|
|
171
|
+
name: f.name || null,
|
|
172
|
+
username: f.username || null,
|
|
173
|
+
role: f.role || null,
|
|
174
|
+
// List / pagination
|
|
132
175
|
limit: f.limit,
|
|
133
176
|
count: f.count || f.n || null,
|
|
134
177
|
timeoutSeconds: f.timeoutSeconds,
|
|
135
178
|
file: f.file || null,
|
|
179
|
+
// RBAC / access
|
|
180
|
+
level: f.level || null,
|
|
181
|
+
permission: f.permission || null,
|
|
182
|
+
grant: !!f.grant,
|
|
183
|
+
revoke: !!f.revoke,
|
|
184
|
+
reason: f.reason || null,
|
|
185
|
+
// Slow queries
|
|
186
|
+
timeRange: f.timeRange || null,
|
|
187
|
+
thresholdMs: f.thresholdMs || null,
|
|
188
|
+
queryId: f.queryId || null,
|
|
189
|
+
queryText: f.queryText || null,
|
|
190
|
+
sampleQuery: f.sampleQuery || null,
|
|
191
|
+
historyId: f.historyId || null,
|
|
192
|
+
// Setup wizard
|
|
193
|
+
force: !!f.force,
|
|
194
|
+
skipEmail: !!f.skipEmail,
|
|
195
|
+
skipSlack: !!f.skipSlack,
|
|
196
|
+
skipComplete: !!f.skipComplete,
|
|
197
|
+
// Confirmations
|
|
198
|
+
yes: !!f.yes || !!f.y,
|
|
136
199
|
};
|
|
137
200
|
}
|
|
138
201
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a user reference (numeric id, email, or username) to a {id, email,
|
|
5
|
+
* username, role, ...} record.
|
|
6
|
+
*
|
|
7
|
+
* Backend `GET /admin/users` returns the full list, so we fetch once per
|
|
8
|
+
* invocation and match locally. Cheap for typical org sizes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { request } = require("../api/client");
|
|
12
|
+
|
|
13
|
+
let cachedUsers = null;
|
|
14
|
+
|
|
15
|
+
async function listUsers(session) {
|
|
16
|
+
if (cachedUsers) return cachedUsers;
|
|
17
|
+
cachedUsers = await request(session.baseUrl, "/admin/users", { token: session.token });
|
|
18
|
+
if (!Array.isArray(cachedUsers)) cachedUsers = [];
|
|
19
|
+
return cachedUsers;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function clearUserCache() {
|
|
23
|
+
cachedUsers = null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function resolveUser(session, ref) {
|
|
27
|
+
if (ref == null || String(ref).trim() === "") {
|
|
28
|
+
throw new Error("Pass a user email, username, or numeric id.");
|
|
29
|
+
}
|
|
30
|
+
const trimmed = String(ref).trim();
|
|
31
|
+
|
|
32
|
+
// Numeric id — short-circuit if list isn't already cached.
|
|
33
|
+
if (/^\d+$/.test(trimmed)) {
|
|
34
|
+
const users = await listUsers(session);
|
|
35
|
+
const hit = users.find((u) => String(u.id) === trimmed);
|
|
36
|
+
if (hit) return hit;
|
|
37
|
+
throw new Error(`User id ${trimmed} not found.`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const users = await listUsers(session);
|
|
41
|
+
const lower = trimmed.toLowerCase();
|
|
42
|
+
const exactEmail = users.find((u) => (u.email || "").toLowerCase() === lower);
|
|
43
|
+
if (exactEmail) return exactEmail;
|
|
44
|
+
const exactUsername = users.find((u) => (u.username || "").toLowerCase() === lower);
|
|
45
|
+
if (exactUsername) return exactUsername;
|
|
46
|
+
|
|
47
|
+
const available = users
|
|
48
|
+
.map((u) => u.email || u.username)
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
.slice(0, 20);
|
|
51
|
+
const hint = available.length ? ` Available: ${available.join(", ")}.` : "";
|
|
52
|
+
throw new Error(`User "${trimmed}" not found.${hint}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { listUsers, resolveUser, clearUserCache };
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `deepsql access` — per-connection access grants and chat-policy editing.
|
|
5
|
+
*
|
|
6
|
+
* deepsql access list --user <ref> → connections this user can see
|
|
7
|
+
* deepsql access list --connection <name> → users who can see this connection
|
|
8
|
+
* deepsql access grant --user <ref> --connection <name> --level read|write|admin
|
|
9
|
+
* deepsql access revoke --user <ref> --connection <name>
|
|
10
|
+
* deepsql access policy <user> <connection> → opens $EDITOR with the
|
|
11
|
+
* plain-English chat policy; on save, validates via the preview endpoint
|
|
12
|
+
* and PUTs the result.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { ApiError, request } = require("../api/client");
|
|
16
|
+
const { resolveSession } = require("./_session");
|
|
17
|
+
const { resolveUser } = require("./_users");
|
|
18
|
+
const { resolveConnectionId, listConnections } = require("./_connections");
|
|
19
|
+
const { editText } = require("../ui/editor");
|
|
20
|
+
|
|
21
|
+
const SUBCOMMANDS = {
|
|
22
|
+
list: cmdList,
|
|
23
|
+
grant: cmdGrant,
|
|
24
|
+
revoke: cmdRevoke,
|
|
25
|
+
policy: cmdPolicy,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
async function run(opts, io = {}) {
|
|
29
|
+
const sub = opts.positional[0];
|
|
30
|
+
if (!sub) throw new Error("Usage: deepsql access <list|grant|revoke|policy> ...");
|
|
31
|
+
const handler = SUBCOMMANDS[sub];
|
|
32
|
+
if (!handler) throw new Error(`Unknown access subcommand: ${sub}.`);
|
|
33
|
+
return wrap(handler)(
|
|
34
|
+
{ ...opts, positional: opts.positional.slice(1) },
|
|
35
|
+
io,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function wrap(handler) {
|
|
40
|
+
return async (opts, io) => {
|
|
41
|
+
try {
|
|
42
|
+
return await handler(opts, io);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err instanceof ApiError && err.status === 403) {
|
|
45
|
+
throw new Error("Access denied — managing access requires ADMIN role.");
|
|
46
|
+
}
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── list ──────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
async function cmdList(opts, { stdout = process.stdout } = {}) {
|
|
55
|
+
const session = resolveSession(opts);
|
|
56
|
+
if (opts.user) {
|
|
57
|
+
const user = await resolveUser(session, opts.user);
|
|
58
|
+
const grants = await request(session.baseUrl, `/admin/users/${user.id}/connection-access`, {
|
|
59
|
+
token: session.token,
|
|
60
|
+
});
|
|
61
|
+
if (opts.json) {
|
|
62
|
+
stdout.write(`${JSON.stringify(grants, null, 2)}\n`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!Array.isArray(grants) || grants.length === 0) {
|
|
66
|
+
stdout.write(`${user.email || user.username} has no connection grants.\n`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
printGrants(stdout, grants, "user");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (opts.connection) {
|
|
73
|
+
const connectionId = await resolveConnectionId(session, opts.connection);
|
|
74
|
+
const grants = await request(
|
|
75
|
+
session.baseUrl,
|
|
76
|
+
`/admin/connections/${encodeURIComponent(connectionId)}/connection-access`,
|
|
77
|
+
{ token: session.token },
|
|
78
|
+
);
|
|
79
|
+
if (opts.json) {
|
|
80
|
+
stdout.write(`${JSON.stringify(grants, null, 2)}\n`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!Array.isArray(grants) || grants.length === 0) {
|
|
84
|
+
stdout.write(`No grants on ${opts.connection}.\n`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
printGrants(stdout, grants, "connection");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
throw new Error("Pass --user <ref> or --connection <name>.");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── grant ─────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
async function cmdGrant(opts, { stdout = process.stdout } = {}) {
|
|
96
|
+
if (!opts.user) throw new Error("--user <ref> is required.");
|
|
97
|
+
if (!opts.connection) throw new Error("--connection <name> is required.");
|
|
98
|
+
const level = (opts.level || "READ").toUpperCase();
|
|
99
|
+
if (!["READ", "WRITE", "ADMIN"].includes(level)) {
|
|
100
|
+
throw new Error(`Invalid --level "${level}". Pick read, write, or admin.`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const session = resolveSession(opts);
|
|
104
|
+
const user = await resolveUser(session, opts.user);
|
|
105
|
+
const connectionId = await resolveConnectionId(session, opts.connection);
|
|
106
|
+
|
|
107
|
+
await request(
|
|
108
|
+
session.baseUrl,
|
|
109
|
+
`/admin/users/${user.id}/connection-access/${encodeURIComponent(connectionId)}`,
|
|
110
|
+
{
|
|
111
|
+
method: "PUT",
|
|
112
|
+
token: session.token,
|
|
113
|
+
json: { accessLevel: level },
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
stdout.write(
|
|
117
|
+
`Granted ${level} on ${opts.connection} to ${user.email || user.username}.\n`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── revoke ────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
async function cmdRevoke(opts, { stdout = process.stdout } = {}) {
|
|
124
|
+
if (!opts.user) throw new Error("--user <ref> is required.");
|
|
125
|
+
if (!opts.connection) throw new Error("--connection <name> is required.");
|
|
126
|
+
|
|
127
|
+
const session = resolveSession(opts);
|
|
128
|
+
const user = await resolveUser(session, opts.user);
|
|
129
|
+
const connectionId = await resolveConnectionId(session, opts.connection);
|
|
130
|
+
|
|
131
|
+
await request(
|
|
132
|
+
session.baseUrl,
|
|
133
|
+
`/admin/users/${user.id}/connection-access/${encodeURIComponent(connectionId)}`,
|
|
134
|
+
{ method: "DELETE", token: session.token },
|
|
135
|
+
);
|
|
136
|
+
stdout.write(`Revoked ${opts.connection} for ${user.email || user.username}.\n`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── policy ────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
const POLICY_HEADER =
|
|
142
|
+
"# Plain-English chat access policy. Lines starting with # are kept as-is —\n" +
|
|
143
|
+
"# DeepSQL doesn't strip them. Save and quit to commit; quit without changes\n" +
|
|
144
|
+
"# (e.g. :cq in vi) to abort.";
|
|
145
|
+
|
|
146
|
+
async function cmdPolicy(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
147
|
+
const userRef = opts.positional[0];
|
|
148
|
+
const connRef = opts.positional[1];
|
|
149
|
+
if (!userRef || !connRef) {
|
|
150
|
+
throw new Error("Usage: deepsql access policy <user> <connection>");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const session = resolveSession(opts);
|
|
154
|
+
const user = await resolveUser(session, userRef);
|
|
155
|
+
const connectionId = await resolveConnectionId(session, connRef);
|
|
156
|
+
|
|
157
|
+
const existing = await request(
|
|
158
|
+
session.baseUrl,
|
|
159
|
+
`/admin/users/${user.id}/connection-access/${encodeURIComponent(connectionId)}/chat-policy`,
|
|
160
|
+
{ token: session.token },
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const initial = (existing && existing.plainEnglishPolicy) || "";
|
|
164
|
+
|
|
165
|
+
stderr.write(
|
|
166
|
+
`Editing policy for ${user.email || user.username} on ${connRef}…\n`,
|
|
167
|
+
);
|
|
168
|
+
const { content, changed } = await editText(initial, {
|
|
169
|
+
suffix: ".policy.md",
|
|
170
|
+
header: POLICY_HEADER,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (!changed) {
|
|
174
|
+
stderr.write("No changes.\n");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Validate via preview before committing.
|
|
179
|
+
const preview = await request(session.baseUrl, "/admin/connection-chat-policies/preview", {
|
|
180
|
+
method: "POST",
|
|
181
|
+
token: session.token,
|
|
182
|
+
json: { connectionId, plainEnglishPolicy: content },
|
|
183
|
+
});
|
|
184
|
+
if (preview && preview.error) {
|
|
185
|
+
throw new Error(`Policy preview rejected: ${preview.error}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const saved = await request(
|
|
189
|
+
session.baseUrl,
|
|
190
|
+
`/admin/users/${user.id}/connection-access/${encodeURIComponent(connectionId)}/chat-policy`,
|
|
191
|
+
{
|
|
192
|
+
method: "PUT",
|
|
193
|
+
token: session.token,
|
|
194
|
+
json: { plainEnglishPolicy: content, active: true },
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
stdout.write(`Saved policy for ${user.email || user.username} on ${connRef}.\n`);
|
|
198
|
+
if (opts.json) {
|
|
199
|
+
stdout.write(`${JSON.stringify(saved, null, 2)}\n`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ─── helpers ───────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
function printGrants(stdout, grants, mode) {
|
|
206
|
+
const rows = grants.map((g) => ({
|
|
207
|
+
a: mode === "user" ? (g.connectionName || g.connectionId || "") : (g.email || g.username || ""),
|
|
208
|
+
level: (g.accessLevel || g.level || "").toUpperCase(),
|
|
209
|
+
grantedBy: g.grantedBy || "",
|
|
210
|
+
}));
|
|
211
|
+
const headerA = mode === "user" ? "CONNECTION" : "USER";
|
|
212
|
+
const widthA = Math.max(headerA.length, ...rows.map((r) => r.a.length));
|
|
213
|
+
const widthLevel = Math.max("LEVEL".length, ...rows.map((r) => r.level.length));
|
|
214
|
+
const widthGrantedBy = Math.max("GRANTED BY".length, ...rows.map((r) => r.grantedBy.length));
|
|
215
|
+
stdout.write(
|
|
216
|
+
`${headerA.padEnd(widthA)} ${"LEVEL".padEnd(widthLevel)} ${"GRANTED BY".padEnd(widthGrantedBy)}\n` +
|
|
217
|
+
`${"-".repeat(widthA)} ${"-".repeat(widthLevel)} ${"-".repeat(widthGrantedBy)}\n`,
|
|
218
|
+
);
|
|
219
|
+
for (const r of rows) {
|
|
220
|
+
stdout.write(`${r.a.padEnd(widthA)} ${r.level.padEnd(widthLevel)} ${r.grantedBy.padEnd(widthGrantedBy)}\n`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// listConnections used in __mocks elsewhere; expose for tests if needed
|
|
225
|
+
module.exports = { run, _internal: { listConnections } };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
|
|
6
|
+
const { parseArgs, buildOpts } = require("../cli");
|
|
7
|
+
|
|
8
|
+
function opts(argv) {
|
|
9
|
+
return buildOpts(parseArgs(argv));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// users -----------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
test("users add positional email + role/name flags", () => {
|
|
15
|
+
const o = opts(["add", "x@y.com", "--role", "DEVELOPER", "--name", "X"]);
|
|
16
|
+
assert.deepEqual(o.positional, ["add", "x@y.com"]);
|
|
17
|
+
assert.equal(o.role, "DEVELOPER");
|
|
18
|
+
assert.equal(o.name, "X");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("users delete --yes flag", () => {
|
|
22
|
+
const o = opts(["delete", "alice@x.com", "--yes"]);
|
|
23
|
+
assert.equal(o.yes, true);
|
|
24
|
+
assert.deepEqual(o.positional, ["delete", "alice@x.com"]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("users reset-password --password-stdin flag", () => {
|
|
28
|
+
const o = opts(["reset-password", "alice@x.com", "--password-stdin"]);
|
|
29
|
+
assert.equal(o.passwordStdin, true);
|
|
30
|
+
assert.equal(o.password, null); // stays null since it's a separate flag
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// access ----------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
test("access grant requires user/connection/level", () => {
|
|
36
|
+
const o = opts([
|
|
37
|
+
"grant",
|
|
38
|
+
"--user",
|
|
39
|
+
"alice@x.com",
|
|
40
|
+
"--connection",
|
|
41
|
+
"mylocalpg",
|
|
42
|
+
"--level",
|
|
43
|
+
"read",
|
|
44
|
+
]);
|
|
45
|
+
assert.equal(o.user, "alice@x.com");
|
|
46
|
+
assert.equal(o.connection, "mylocalpg");
|
|
47
|
+
assert.equal(o.level, "read");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("access policy passes positional user + connection", () => {
|
|
51
|
+
const o = opts(["policy", "alice@x.com", "mylocalpg"]);
|
|
52
|
+
assert.deepEqual(o.positional, ["policy", "alice@x.com", "mylocalpg"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// permissions -----------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
test("permissions override --grant + --reason", () => {
|
|
58
|
+
const o = opts([
|
|
59
|
+
"override",
|
|
60
|
+
"--role",
|
|
61
|
+
"DEVELOPER",
|
|
62
|
+
"--permission",
|
|
63
|
+
"USE_CHAT",
|
|
64
|
+
"--grant",
|
|
65
|
+
"--reason",
|
|
66
|
+
"Beta access",
|
|
67
|
+
]);
|
|
68
|
+
assert.equal(o.role, "DEVELOPER");
|
|
69
|
+
assert.equal(o.permission, "USE_CHAT");
|
|
70
|
+
assert.equal(o.grant, true);
|
|
71
|
+
assert.equal(o.revoke, false);
|
|
72
|
+
assert.equal(o.reason, "Beta access");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("permissions override --revoke flips to revoke", () => {
|
|
76
|
+
const o = opts(["override", "--role", "DEVELOPER", "--permission", "USE_CHAT", "--revoke"]);
|
|
77
|
+
assert.equal(o.grant, false);
|
|
78
|
+
assert.equal(o.revoke, true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// slow-queries ----------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
test("slow-queries history N positional", () => {
|
|
84
|
+
const o = opts(["history", "--connection", "mylocalpg", "5"]);
|
|
85
|
+
assert.deepEqual(o.positional, ["history", "5"]);
|
|
86
|
+
assert.equal(o.connection, "mylocalpg");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("slow-queries analyze flags", () => {
|
|
90
|
+
const o = opts([
|
|
91
|
+
"analyze",
|
|
92
|
+
"--connection",
|
|
93
|
+
"mylocalpg",
|
|
94
|
+
"--time-range",
|
|
95
|
+
"LAST_HOUR",
|
|
96
|
+
"--threshold-ms",
|
|
97
|
+
"200",
|
|
98
|
+
"--limit",
|
|
99
|
+
"20",
|
|
100
|
+
]);
|
|
101
|
+
assert.equal(o.timeRange, "LAST_HOUR");
|
|
102
|
+
assert.equal(o.thresholdMs, "200");
|
|
103
|
+
assert.equal(o.limit, "20");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("slow-queries optimize uses --query-id", () => {
|
|
107
|
+
const o = opts(["optimize", "--connection", "mylocalpg", "--query-id", "q-123"]);
|
|
108
|
+
assert.equal(o.queryId, "q-123");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("slow-queries delete --history-id with --yes", () => {
|
|
112
|
+
const o = opts(["delete", "--history-id", "42", "--yes"]);
|
|
113
|
+
assert.equal(o.historyId, "42");
|
|
114
|
+
assert.equal(o.yes, true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// setup -----------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
test("setup --force --skip-email", () => {
|
|
120
|
+
const o = opts(["--force", "--skip-email"]);
|
|
121
|
+
assert.equal(o.force, true);
|
|
122
|
+
assert.equal(o.skipEmail, true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// password flag stays a flag for login + value for users -----------------------
|
|
126
|
+
|
|
127
|
+
test("--password alone (login flow) leaves password truthy", () => {
|
|
128
|
+
const o = opts(["--password"]);
|
|
129
|
+
assert.equal(!!o.password, true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("--password=secret keeps the string value (still truthy)", () => {
|
|
133
|
+
const o = opts(["--password=secret"]);
|
|
134
|
+
assert.equal(o.password, "secret");
|
|
135
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `deepsql permissions` — global role-based permission overrides.
|
|
5
|
+
*
|
|
6
|
+
* deepsql permissions list [--role <r>] [--json]
|
|
7
|
+
* deepsql permissions override --role <r> --permission <p> --grant|--revoke [--reason "..."]
|
|
8
|
+
* deepsql permissions reset --role <r> --permission <p>
|
|
9
|
+
*
|
|
10
|
+
* Backed by /permissions/** (overrides require ROLE_ADMIN).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { ApiError, request } = require("../api/client");
|
|
14
|
+
const { resolveSession } = require("./_session");
|
|
15
|
+
|
|
16
|
+
const SUBCOMMANDS = {
|
|
17
|
+
list: cmdList,
|
|
18
|
+
override: cmdOverride,
|
|
19
|
+
reset: cmdReset,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
async function run(opts, io = {}) {
|
|
23
|
+
const sub = opts.positional[0];
|
|
24
|
+
if (!sub) throw new Error("Usage: deepsql permissions <list|override|reset> ...");
|
|
25
|
+
const handler = SUBCOMMANDS[sub];
|
|
26
|
+
if (!handler) throw new Error(`Unknown permissions subcommand: ${sub}.`);
|
|
27
|
+
return wrap(handler)(
|
|
28
|
+
{ ...opts, positional: opts.positional.slice(1) },
|
|
29
|
+
io,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function wrap(handler) {
|
|
34
|
+
return async (opts, io) => {
|
|
35
|
+
try {
|
|
36
|
+
return await handler(opts, io);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
if (err instanceof ApiError && err.status === 403) {
|
|
39
|
+
throw new Error("Access denied — managing permissions requires ADMIN role.");
|
|
40
|
+
}
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── list ──────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
async function cmdList(opts, { stdout = process.stdout } = {}) {
|
|
49
|
+
const session = resolveSession(opts);
|
|
50
|
+
const [registry, roles, overrides] = await Promise.all([
|
|
51
|
+
request(session.baseUrl, "/permissions/registry", { token: session.token }),
|
|
52
|
+
request(session.baseUrl, "/permissions/roles", { token: session.token }),
|
|
53
|
+
request(session.baseUrl, "/permissions/overrides", { token: session.token }),
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
if (opts.json) {
|
|
57
|
+
stdout.write(`${JSON.stringify({ registry, roles, overrides }, null, 2)}\n`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const roleList = Array.isArray(roles) ? roles : roles?.roles || [];
|
|
62
|
+
const overrideList = Array.isArray(overrides) ? overrides : overrides?.overrides || [];
|
|
63
|
+
|
|
64
|
+
if (opts.role) {
|
|
65
|
+
const wanted = String(opts.role).toUpperCase();
|
|
66
|
+
const role = roleList.find(
|
|
67
|
+
(r) => (r.code || r.role || r.name || "").toUpperCase() === wanted,
|
|
68
|
+
);
|
|
69
|
+
if (!role) {
|
|
70
|
+
const available = roleList.map((r) => r.code || r.role || r.name).filter(Boolean).join(", ");
|
|
71
|
+
throw new Error(`Role "${opts.role}" not found. Available: ${available}.`);
|
|
72
|
+
}
|
|
73
|
+
const code = role.code || role.role || role.name;
|
|
74
|
+
stdout.write(`${code} permissions (effective):\n`);
|
|
75
|
+
const perms = role.effectivePermissions || role.permissions || [];
|
|
76
|
+
for (const p of perms) {
|
|
77
|
+
stdout.write(` - ${typeof p === "string" ? p : p.code || p.name}\n`);
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (overrideList.length === 0) {
|
|
83
|
+
stdout.write("No active overrides — every role has its default permissions.\n");
|
|
84
|
+
} else {
|
|
85
|
+
stdout.write("Active overrides:\n");
|
|
86
|
+
for (const o of overrideList) {
|
|
87
|
+
const role = o.role || "";
|
|
88
|
+
const perm = o.permissionCode || o.permission || "";
|
|
89
|
+
const granted = o.granted ? "GRANT" : "REVOKE";
|
|
90
|
+
const reason = o.reason ? ` — ${o.reason}` : "";
|
|
91
|
+
stdout.write(` ${role.padEnd(12)} ${granted.padEnd(7)} ${perm}${reason}\n`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (roleList.length > 0) {
|
|
96
|
+
stdout.write("\nRoles:\n");
|
|
97
|
+
for (const r of roleList) {
|
|
98
|
+
const name = r.code || r.role || r.name || "?";
|
|
99
|
+
const count = (r.effectivePermissions || r.permissions || []).length;
|
|
100
|
+
stdout.write(` ${name.padEnd(12)} ${count} permission(s)\n`);
|
|
101
|
+
}
|
|
102
|
+
stdout.write("\nUse `--role <NAME>` to see one role's full permission list.\n");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── override ──────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
async function cmdOverride(opts, { stdout = process.stdout } = {}) {
|
|
109
|
+
if (!opts.role) throw new Error("--role <ROLE> is required.");
|
|
110
|
+
if (!opts.permission) throw new Error("--permission <PERMISSION> is required.");
|
|
111
|
+
if (!opts.grant && !opts.revoke) {
|
|
112
|
+
throw new Error("Pass --grant or --revoke.");
|
|
113
|
+
}
|
|
114
|
+
if (opts.grant && opts.revoke) {
|
|
115
|
+
throw new Error("--grant and --revoke are mutually exclusive.");
|
|
116
|
+
}
|
|
117
|
+
const granted = !!opts.grant;
|
|
118
|
+
const session = resolveSession(opts);
|
|
119
|
+
const result = await request(session.baseUrl, "/permissions/overrides", {
|
|
120
|
+
method: "POST",
|
|
121
|
+
token: session.token,
|
|
122
|
+
json: {
|
|
123
|
+
role: String(opts.role).toUpperCase(),
|
|
124
|
+
permission: String(opts.permission).toUpperCase(),
|
|
125
|
+
granted,
|
|
126
|
+
reason: opts.reason || null,
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
stdout.write(`${result?.message || "Override applied."}\n`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── reset ─────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
async function cmdReset(opts, { stdout = process.stdout } = {}) {
|
|
135
|
+
if (!opts.role) throw new Error("--role <ROLE> is required.");
|
|
136
|
+
if (!opts.permission) throw new Error("--permission <PERMISSION> is required.");
|
|
137
|
+
const session = resolveSession(opts);
|
|
138
|
+
const result = await request(session.baseUrl, "/permissions/overrides", {
|
|
139
|
+
method: "DELETE",
|
|
140
|
+
token: session.token,
|
|
141
|
+
json: {
|
|
142
|
+
role: String(opts.role).toUpperCase(),
|
|
143
|
+
permission: String(opts.permission).toUpperCase(),
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
stdout.write(`${result?.message || "Override removed."}\n`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { run };
|