@graphoria/server 0.3.0 → 0.4.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/README.md +24 -20
- package/dist/src/ai/mcp/index.d.ts +9 -2
- package/dist/src/ai/mcp/index.d.ts.map +1 -1
- package/dist/src/analyzeQuery/analyzers/selectionAnalyzer.d.ts.map +1 -1
- package/dist/src/authentication/capabilities.d.ts +18 -0
- package/dist/src/authentication/capabilities.d.ts.map +1 -0
- package/dist/src/authentication/capabilities.test.d.ts +2 -0
- package/dist/src/authentication/capabilities.test.d.ts.map +1 -0
- package/dist/src/authentication/jwt.d.ts.map +1 -1
- package/dist/src/authentication/paseto.d.ts.map +1 -1
- package/dist/src/authentication/secrets.d.ts +9 -0
- package/dist/src/authentication/secrets.d.ts.map +1 -0
- package/dist/src/authentication/secrets.test.d.ts +2 -0
- package/dist/src/authentication/secrets.test.d.ts.map +1 -0
- package/dist/src/configuration/getSchemas/index.d.ts +0 -2
- package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +0 -1
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
- package/dist/src/configuration/gql/gqlAuthOperations.d.ts.map +1 -1
- package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts.map +1 -1
- package/dist/src/configuration/gql/handleGraphQLSubscriptionFactory.d.ts.map +1 -1
- package/dist/src/configuration/index.d.ts +0 -1
- package/dist/src/configuration/index.d.ts.map +1 -1
- package/dist/src/configuration/rest/generateOpenAPI.d.ts.map +1 -1
- package/dist/src/configuration/rest/generateOpenAPI.test.d.ts +2 -0
- package/dist/src/configuration/rest/generateOpenAPI.test.d.ts.map +1 -0
- package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
- package/dist/src/console/api.d.ts.map +1 -1
- package/dist/src/console/session.d.ts +12 -4
- package/dist/src/console/session.d.ts.map +1 -1
- package/dist/src/databases/core/executor.d.ts +1 -1
- package/dist/src/databases/core/executor.d.ts.map +1 -1
- package/dist/src/databases/core/function-mapping.d.ts +1 -1
- package/dist/src/databases/core/function-mapping.d.ts.map +1 -1
- package/dist/src/databases/core/procedure-arguments.d.ts +17 -0
- package/dist/src/databases/core/procedure-arguments.d.ts.map +1 -0
- package/dist/src/databases/core/procedure-arguments.test.d.ts +2 -0
- package/dist/src/databases/core/procedure-arguments.test.d.ts.map +1 -0
- package/dist/src/databases/engines/mssql/connection.d.ts +1 -1
- package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/connection.d.ts +1 -1
- package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/connection.d.ts +1 -1
- package/dist/src/databases/engines/postgresql/connection.d.ts.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/logging/audit.d.ts +27 -0
- package/dist/src/logging/audit.d.ts.map +1 -0
- package/dist/src/logging/audit.test.d.ts +2 -0
- package/dist/src/logging/audit.test.d.ts.map +1 -0
- package/dist/src/singletons/env.d.ts +8 -4
- package/dist/src/singletons/env.d.ts.map +1 -1
- package/dist/src/types/env.d.ts +24 -12
- package/dist/src/types/env.d.ts.map +1 -1
- package/dist/src/utils/responses.d.ts +3 -0
- package/dist/src/utils/responses.d.ts.map +1 -1
- package/dist/src/utils/sessionVariables.d.ts +2 -0
- package/dist/src/utils/sessionVariables.d.ts.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/playgrounds/console/index.html +7 -7
- package/playgrounds/graphiql/index.html +548 -685
- package/src/ai/mcp/index.ts +19 -14
- package/src/analyzeQuery/analyzers/selectionAnalyzer.ts +2 -1
- package/src/authentication/capabilities.ts +54 -0
- package/src/authentication/index.ts +3 -3
- package/src/authentication/jwt.ts +30 -25
- package/src/authentication/paseto.ts +36 -26
- package/src/authentication/secrets.ts +30 -0
- package/src/configuration/getSchemas/mergeEntities/index.ts +0 -26
- package/src/configuration/gql/gqlAuthOperations.ts +26 -0
- package/src/configuration/gql/handleGraphQLRequestFactory.ts +17 -5
- package/src/configuration/gql/handleGraphQLSubscriptionFactory.ts +9 -0
- package/src/configuration/rest/generateOpenAPI.ts +61 -11
- package/src/configuration/rest/handleRESTRequestFactory.ts +26 -0
- package/src/console/api.ts +63 -18
- package/src/console/session.ts +30 -26
- package/src/databases/core/executor.ts +1 -2
- package/src/databases/core/function-mapping.ts +0 -1
- package/src/databases/core/procedure-arguments.ts +25 -0
- package/src/databases/engines/mssql/connection.ts +0 -3
- package/src/databases/engines/mysql/connection.ts +10 -7
- package/src/databases/engines/postgresql/connection.ts +10 -7
- package/src/index.ts +51 -12
- package/src/logging/audit.ts +86 -0
- package/src/types/env.ts +26 -8
- package/src/utils/responses.ts +6 -0
- package/src/utils/sessionVariables.ts +2 -0
|
@@ -1,38 +1,77 @@
|
|
|
1
|
+
import { GraphQLObjectType } from "graphql";
|
|
2
|
+
|
|
3
|
+
import type { GraphQLType } from "graphql";
|
|
1
4
|
import type { OpenAPIV3_1 } from "openapi-types";
|
|
2
5
|
import type { SelectionAnalysis } from "../../analyzeQuery/types";
|
|
3
6
|
import type { GetSchemaReturn } from "../../configuration/getSchemas";
|
|
4
7
|
import type { RemoteRESTResolved } from "../../remoteREST/types";
|
|
5
8
|
import type { Env } from "../../types/env";
|
|
6
9
|
|
|
10
|
+
import { getFieldType, unwrapType } from "../../analyzeQuery/typeUtils";
|
|
7
11
|
import { convertFromZod, errors } from "./openApiErrors";
|
|
8
12
|
|
|
13
|
+
// Deliberately coarse: Int and Float both map to "number", so a column keeps
|
|
14
|
+
// the OpenAPI type it had when types were read off the SQL data type.
|
|
15
|
+
const openApiTypeOf = (namedType: GraphQLType | undefined) => {
|
|
16
|
+
const name = namedType && "name" in namedType ? namedType.name : undefined;
|
|
17
|
+
|
|
18
|
+
switch (name) {
|
|
19
|
+
case "Int":
|
|
20
|
+
case "Float":
|
|
21
|
+
return "number";
|
|
22
|
+
case "Boolean":
|
|
23
|
+
return "boolean";
|
|
24
|
+
default:
|
|
25
|
+
return "string";
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const resolveFieldType = (parentType: GraphQLObjectType | undefined, fieldName: string) => {
|
|
30
|
+
const fieldType = parentType && getFieldType(parentType, fieldName);
|
|
31
|
+
const namedType = fieldType ? unwrapType(fieldType).type : undefined;
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
namedType,
|
|
35
|
+
objectType: namedType instanceof GraphQLObjectType ? namedType : undefined,
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolves selections against the GraphQL schema rather than against a table
|
|
41
|
+
* name. `<table>_aggregate` returns a GroupBy wrapper whose fields — `key`,
|
|
42
|
+
* `count`, `items`, `min`/`max`/`sum`/`avg` — are not tables, so looking a
|
|
43
|
+
* selection's parent name up in the entity map typed everything under an
|
|
44
|
+
* aggregate as a string. Walking the schema also makes sanitised column names
|
|
45
|
+
* resolve, since the schema is what the client actually queries.
|
|
46
|
+
*/
|
|
9
47
|
const processSelections = (
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
selections: SelectionAnalysis[] = [],
|
|
48
|
+
parentType: GraphQLObjectType | undefined,
|
|
49
|
+
fieldParent: SelectionAnalysis,
|
|
13
50
|
) =>
|
|
14
|
-
selections
|
|
15
|
-
const
|
|
51
|
+
fieldParent.selections?.reduce<Record<string, unknown>>((acc, field) => {
|
|
52
|
+
const { namedType, objectType } = resolveFieldType(parentType, field.name);
|
|
16
53
|
|
|
17
54
|
if (field.isArray) {
|
|
18
55
|
acc[field.alias || field.name] = {
|
|
19
56
|
type: "array",
|
|
20
57
|
items: {
|
|
21
58
|
type: "object",
|
|
22
|
-
properties: processSelections(
|
|
59
|
+
properties: processSelections(objectType, field),
|
|
23
60
|
required: (field.selections ?? [])
|
|
24
61
|
.filter((f) => f.isRequired)
|
|
25
62
|
.map((f) => f.alias || f.name),
|
|
63
|
+
nullable: !field.isRequired,
|
|
26
64
|
},
|
|
27
65
|
};
|
|
28
66
|
} else {
|
|
29
67
|
acc[field.alias || field.name] = field.selections
|
|
30
68
|
? {
|
|
31
69
|
type: "object",
|
|
32
|
-
properties: processSelections(
|
|
70
|
+
properties: processSelections(objectType, field),
|
|
33
71
|
required: field.selections.filter((f) => f.isRequired).map((f) => f.alias || f.name),
|
|
72
|
+
nullable: !field.isRequired,
|
|
34
73
|
}
|
|
35
|
-
: { type };
|
|
74
|
+
: { type: openApiTypeOf(namedType), nullable: !field.isRequired };
|
|
36
75
|
}
|
|
37
76
|
|
|
38
77
|
return acc;
|
|
@@ -137,7 +176,12 @@ export const generateOpenAPI = ({
|
|
|
137
176
|
const responseDataSchema = value.output
|
|
138
177
|
? convertFromZod(value.output)
|
|
139
178
|
: (() => {
|
|
140
|
-
const
|
|
179
|
+
const operation = value.queryStructure!.operations[0];
|
|
180
|
+
const fields = operation.fields;
|
|
181
|
+
const rootType =
|
|
182
|
+
(operation.operation === "mutation"
|
|
183
|
+
? schema.schema.getMutationType()
|
|
184
|
+
: schema.schema.getQueryType()) ?? undefined;
|
|
141
185
|
return {
|
|
142
186
|
type: "object" as const,
|
|
143
187
|
properties: fields.reduce<Record<string, unknown>>((acc, field) => {
|
|
@@ -146,7 +190,10 @@ export const generateOpenAPI = ({
|
|
|
146
190
|
type: "array",
|
|
147
191
|
items: {
|
|
148
192
|
type: "object",
|
|
149
|
-
properties: processSelections(
|
|
193
|
+
properties: processSelections(
|
|
194
|
+
resolveFieldType(rootType, field.name).objectType,
|
|
195
|
+
field,
|
|
196
|
+
),
|
|
150
197
|
required: (field.selections ?? [])
|
|
151
198
|
.filter((f) => f.isRequired)
|
|
152
199
|
.map((f) => f.alias || f.name),
|
|
@@ -155,7 +202,10 @@ export const generateOpenAPI = ({
|
|
|
155
202
|
} else {
|
|
156
203
|
acc[field.alias || field.name] = {
|
|
157
204
|
type: "object",
|
|
158
|
-
properties: processSelections(
|
|
205
|
+
properties: processSelections(
|
|
206
|
+
resolveFieldType(rootType, field.name).objectType,
|
|
207
|
+
field,
|
|
208
|
+
),
|
|
159
209
|
required: (field.selections ?? [])
|
|
160
210
|
.filter((f) => f.isRequired)
|
|
161
211
|
.map((f) => f.alias || f.name),
|
|
@@ -12,6 +12,7 @@ import type { HandleGraphQLRequest } from "../gql/handleGraphQLRequestFactory";
|
|
|
12
12
|
import { analyzeQuery } from "../../analyzeQuery";
|
|
13
13
|
import { checkUserCredentials } from "../../databases";
|
|
14
14
|
import { proxyRemoteRESTRequest } from "../../remoteREST/proxy";
|
|
15
|
+
import { actorFromSession, audit } from "../../logging/audit";
|
|
15
16
|
import { getTokenService } from "../../singletons/authentication";
|
|
16
17
|
import { getCache } from "../../singletons/cache";
|
|
17
18
|
import { databasesConnections, repositoryMap } from "../../singletons/databases";
|
|
@@ -174,9 +175,23 @@ export const handleRESTRequestFactory = (
|
|
|
174
175
|
|
|
175
176
|
const result = await checkUserCredentials(auth, username, password);
|
|
176
177
|
|
|
178
|
+
const actor = { type: "credentials", sub: username } as const;
|
|
177
179
|
if (!result.valid) {
|
|
180
|
+
audit().emit({
|
|
181
|
+
action: "auth.login",
|
|
182
|
+
outcome: "failure",
|
|
183
|
+
actor,
|
|
184
|
+
target: { kind: "auth", via: "rest" },
|
|
185
|
+
reason: "Invalid username or password",
|
|
186
|
+
});
|
|
178
187
|
return new S401({ errors: ["Invalid username or password"] });
|
|
179
188
|
}
|
|
189
|
+
audit().emit({
|
|
190
|
+
action: "auth.login",
|
|
191
|
+
outcome: "success",
|
|
192
|
+
actor,
|
|
193
|
+
target: { kind: "auth", via: "rest", role: result.role },
|
|
194
|
+
});
|
|
180
195
|
|
|
181
196
|
const data = await getTokenService().createTokenPair({
|
|
182
197
|
sub: username,
|
|
@@ -229,9 +244,11 @@ export const handleRESTRequestFactory = (
|
|
|
229
244
|
|
|
230
245
|
if (route.authOperation === "logout") {
|
|
231
246
|
const tokenService = getTokenService();
|
|
247
|
+
const revoked: string[] = [];
|
|
232
248
|
|
|
233
249
|
if (session?.jti) {
|
|
234
250
|
await tokenService.revoke(session.jti);
|
|
251
|
+
revoked.push(session.jti);
|
|
235
252
|
}
|
|
236
253
|
|
|
237
254
|
const refreshCookie = req?.cookies?.get("refresh_token");
|
|
@@ -241,6 +258,7 @@ export const handleRESTRequestFactory = (
|
|
|
241
258
|
audience: "refresh",
|
|
242
259
|
});
|
|
243
260
|
await tokenService.revoke(refreshPayload.jti);
|
|
261
|
+
revoked.push(refreshPayload.jti);
|
|
244
262
|
} catch {
|
|
245
263
|
// tampered or expired cookie — nothing to revoke
|
|
246
264
|
}
|
|
@@ -250,6 +268,14 @@ export const handleRESTRequestFactory = (
|
|
|
250
268
|
req.cookies.delete("refresh_token");
|
|
251
269
|
}
|
|
252
270
|
|
|
271
|
+
if (revoked.length > 0) {
|
|
272
|
+
audit().emit({
|
|
273
|
+
action: "auth.logout",
|
|
274
|
+
actor: actorFromSession(session),
|
|
275
|
+
target: { kind: "auth", via: "rest", revoked },
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
253
279
|
return new S200({ data: true });
|
|
254
280
|
}
|
|
255
281
|
|
package/src/console/api.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ConsoleScope } from "./session";
|
|
1
2
|
import type { TokenService } from "../authentication/types";
|
|
2
3
|
import type { AnalyzedConfiguration } from "../configuration";
|
|
3
4
|
import type { Configuration } from "../types/configuration";
|
|
@@ -8,11 +9,12 @@ import type { ConnectionPool } from "mssql";
|
|
|
8
9
|
|
|
9
10
|
import { createConsoleSessions } from "./session";
|
|
10
11
|
import { getTags } from "../configuration/rest/generateOpenAPI";
|
|
12
|
+
import { audit } from "../logging/audit";
|
|
11
13
|
import { getCronJobs } from "../singletons/cron";
|
|
12
14
|
import { databasesConnections } from "../singletons/databases";
|
|
13
15
|
import { queueManager } from "../singletons/queues";
|
|
14
16
|
import { resolveClientAddress } from "../utils/rateLimit";
|
|
15
|
-
import { S200, S400, S401, S404, S429 } from "../utils/responses";
|
|
17
|
+
import { S200, S400, S401, S403, S404, S429 } from "../utils/responses";
|
|
16
18
|
|
|
17
19
|
type ConsoleRoutesFactoryOptions = {
|
|
18
20
|
env: Env;
|
|
@@ -67,12 +69,25 @@ export const consoleRoutesFactory = ({
|
|
|
67
69
|
}: ConsoleRoutesFactoryOptions): Record<string, Record<string, ConsoleRouteHandler>> => {
|
|
68
70
|
const sessions = createConsoleSessions({ env, consolePath, tokenService });
|
|
69
71
|
|
|
72
|
+
const clientAddress = (req: BunRequest, server?: Bun.Server<unknown>) =>
|
|
73
|
+
resolveClientAddress(req, server, env.rateLimit.trustProxy);
|
|
74
|
+
|
|
70
75
|
const guarded =
|
|
71
|
-
(
|
|
72
|
-
|
|
76
|
+
(
|
|
77
|
+
handler: (
|
|
78
|
+
req: BunRequest,
|
|
79
|
+
server: Bun.Server<unknown> | undefined,
|
|
80
|
+
scope: ConsoleScope,
|
|
81
|
+
) => object | Promise<object>,
|
|
82
|
+
required: ConsoleScope = "read",
|
|
83
|
+
): ConsoleRouteHandler =>
|
|
84
|
+
async (req: BunRequest, server?: Bun.Server<unknown>) => {
|
|
73
85
|
try {
|
|
74
|
-
|
|
75
|
-
return new
|
|
86
|
+
const scope = await sessions.authorize(req);
|
|
87
|
+
if (!scope) return new S404({ error: "Not Found" });
|
|
88
|
+
if (required === "write" && scope !== "write")
|
|
89
|
+
return new S403({ errors: [{ message: "Console session is read-only" }] });
|
|
90
|
+
return new S200(await handler(req, server, scope));
|
|
76
91
|
} catch (error) {
|
|
77
92
|
return new S400({ errors: [{ message: (error as Error)?.message }] });
|
|
78
93
|
}
|
|
@@ -99,15 +114,28 @@ export const consoleRoutesFactory = ({
|
|
|
99
114
|
// Keyed by address against the anonymous ceiling: there is no session
|
|
100
115
|
// yet, and the attempt is charged before the secret is compared, so a
|
|
101
116
|
// guesser cannot spend attempts for free.
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
resolveClientAddress(req, server, env.rateLimit.trustProxy),
|
|
105
|
-
);
|
|
117
|
+
const ip = clientAddress(req, server);
|
|
118
|
+
const limit = await rateLimiter?.check(null, ip);
|
|
106
119
|
if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
|
|
107
120
|
|
|
121
|
+
const actor = { type: "admin_secret", ip } as const;
|
|
108
122
|
try {
|
|
109
|
-
|
|
110
|
-
|
|
123
|
+
const { expiresIn, scope, superset } = await sessions.login(req);
|
|
124
|
+
audit().emit({
|
|
125
|
+
action: "console.login",
|
|
126
|
+
outcome: "success",
|
|
127
|
+
actor: { ...actor, scope: superset ? "all" : `console:${scope}` },
|
|
128
|
+
target: { kind: "console" },
|
|
129
|
+
});
|
|
130
|
+
return new S200({ expiresIn, scope });
|
|
131
|
+
} catch (error) {
|
|
132
|
+
audit().emit({
|
|
133
|
+
action: "console.login",
|
|
134
|
+
outcome: "failure",
|
|
135
|
+
actor,
|
|
136
|
+
target: { kind: "console" },
|
|
137
|
+
reason: (error as Error)?.message,
|
|
138
|
+
});
|
|
111
139
|
// One message for a bad secret and for a malformed body alike: the
|
|
112
140
|
// caller learns nothing about which it was.
|
|
113
141
|
return new S401({ errors: [{ message: "Invalid admin secret" }] });
|
|
@@ -117,8 +145,14 @@ export const consoleRoutesFactory = ({
|
|
|
117
145
|
|
|
118
146
|
[`${base}/logout`]: {
|
|
119
147
|
...cors,
|
|
120
|
-
POST: async (req) => {
|
|
121
|
-
await sessions.logout(req)
|
|
148
|
+
POST: async (req, server) => {
|
|
149
|
+
if (await sessions.logout(req)) {
|
|
150
|
+
audit().emit({
|
|
151
|
+
action: "console.logout",
|
|
152
|
+
actor: { type: "console", ip: clientAddress(req, server) },
|
|
153
|
+
target: { kind: "console" },
|
|
154
|
+
});
|
|
155
|
+
}
|
|
122
156
|
return new S200({ ok: true });
|
|
123
157
|
},
|
|
124
158
|
},
|
|
@@ -264,7 +298,7 @@ export const consoleRoutesFactory = ({
|
|
|
264
298
|
|
|
265
299
|
[`${base}/config`]: {
|
|
266
300
|
...cors,
|
|
267
|
-
GET: guarded(() => ({
|
|
301
|
+
GET: guarded((_req, _server, scope) => ({
|
|
268
302
|
name: projectConfiguration.name,
|
|
269
303
|
version: projectConfiguration.version,
|
|
270
304
|
prefixes,
|
|
@@ -274,12 +308,13 @@ export const consoleRoutesFactory = ({
|
|
|
274
308
|
mcp: projectConfiguration.ai?.mcp?.enabled ?? false,
|
|
275
309
|
cors: env.enableCors,
|
|
276
310
|
},
|
|
311
|
+
session: { scope },
|
|
277
312
|
})),
|
|
278
313
|
},
|
|
279
314
|
|
|
280
315
|
[`${base}/queues/publish`]: {
|
|
281
316
|
...cors,
|
|
282
|
-
POST: guarded(async (req) => {
|
|
317
|
+
POST: guarded(async (req, server) => {
|
|
283
318
|
const { publisher, message, key } = (await req.json()) as {
|
|
284
319
|
publisher?: string;
|
|
285
320
|
message?: string | object;
|
|
@@ -288,13 +323,18 @@ export const consoleRoutesFactory = ({
|
|
|
288
323
|
if (!publisher || message == null) throw new Error("publisher and message are required");
|
|
289
324
|
if (!Object.hasOwn(queueManager?.publisherMap() ?? {}, publisher))
|
|
290
325
|
throw new Error(`Unknown publisher "${publisher}"`);
|
|
326
|
+
audit().emit({
|
|
327
|
+
action: "console.queue.publish",
|
|
328
|
+
actor: { type: "console", ip: clientAddress(req, server) },
|
|
329
|
+
target: { kind: "publisher", name: publisher, key },
|
|
330
|
+
});
|
|
291
331
|
return { ok: await queueManager!.sendMessage(publisher, message, key) };
|
|
292
|
-
}),
|
|
332
|
+
}, "write"),
|
|
293
333
|
},
|
|
294
334
|
|
|
295
335
|
[`${base}/cron`]: {
|
|
296
336
|
...cors,
|
|
297
|
-
POST: guarded(async (req) => {
|
|
337
|
+
POST: guarded(async (req, server) => {
|
|
298
338
|
const { name, action } = (await req.json()) as { name?: string; action?: string };
|
|
299
339
|
const cron = getCronJobs();
|
|
300
340
|
if (!cron) throw new Error("Cron is not enabled");
|
|
@@ -302,10 +342,15 @@ export const consoleRoutesFactory = ({
|
|
|
302
342
|
if (action !== "trigger" && action !== "pause" && action !== "resume")
|
|
303
343
|
throw new Error(`Unknown action "${action}"`);
|
|
304
344
|
if (!cron.getJob(name)) throw new Error(`Unknown job "${name}"`);
|
|
345
|
+
audit().emit({
|
|
346
|
+
action: `console.cron.${action}`,
|
|
347
|
+
actor: { type: "console", ip: clientAddress(req, server) },
|
|
348
|
+
target: { kind: "cron", name },
|
|
349
|
+
});
|
|
305
350
|
if (action === "trigger") await cron.trigger(name);
|
|
306
351
|
else cron[action](name);
|
|
307
352
|
return { ok: true };
|
|
308
|
-
}),
|
|
353
|
+
}, "write"),
|
|
309
354
|
},
|
|
310
355
|
};
|
|
311
356
|
};
|
package/src/console/session.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { timingSafeEqual } from "crypto";
|
|
2
|
-
|
|
3
1
|
import type { BunRequest } from "bun";
|
|
4
2
|
import type { TokenService } from "../authentication/types";
|
|
5
3
|
import type { Env } from "../types/env";
|
|
6
4
|
|
|
5
|
+
import { createCapabilityAuthorizer } from "../authentication/capabilities";
|
|
7
6
|
import { parseDurationToMs, parseDurationToSeconds } from "../authentication/duration";
|
|
8
7
|
import { logger } from "../logging";
|
|
9
8
|
|
|
@@ -14,23 +13,21 @@ export const CONSOLE_SESSION_COOKIE = "graphoria_console_session";
|
|
|
14
13
|
const CONSOLE_AUDIENCE = "console";
|
|
15
14
|
const CONSOLE_SUBJECT = "console";
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (bufA.length !== bufB.length) return false;
|
|
22
|
-
return timingSafeEqual(bufA, bufB);
|
|
23
|
-
} catch {
|
|
24
|
-
return false;
|
|
25
|
-
}
|
|
26
|
-
};
|
|
16
|
+
/** `read` sees state; `write` may also publish to queues and control cron. */
|
|
17
|
+
export type ConsoleScope = "read" | "write";
|
|
18
|
+
|
|
19
|
+
const isScope = (value: unknown): value is ConsoleScope => value === "read" || value === "write";
|
|
27
20
|
|
|
28
21
|
export type ConsoleSessions = {
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Exchange a secret for a session cookie. Throws if none matches. `superset`
|
|
24
|
+
* is true when it was the admin secret rather than a console credential.
|
|
25
|
+
*/
|
|
26
|
+
login(req: BunRequest): Promise<{ expiresIn: number; scope: ConsoleScope; superset: boolean }>;
|
|
27
|
+
/** The scope of a live, unrevoked console session cookie; null when there is none. */
|
|
28
|
+
authorize(req: BunRequest): Promise<ConsoleScope | null>;
|
|
29
|
+
/** Resolves true when a live session was revoked; the cookie is cleared either way. */
|
|
30
|
+
logout(req: BunRequest): Promise<boolean>;
|
|
34
31
|
};
|
|
35
32
|
|
|
36
33
|
type ConsoleSessionsOptions = {
|
|
@@ -45,6 +42,7 @@ export const createConsoleSessions = ({
|
|
|
45
42
|
tokenService,
|
|
46
43
|
}: ConsoleSessionsOptions): ConsoleSessions => {
|
|
47
44
|
const log = logger("console");
|
|
45
|
+
const authorizeCapability = createCapabilityAuthorizer(env);
|
|
48
46
|
const expiresIn = env.console.sessionExpiresIn;
|
|
49
47
|
const sessionMs = parseDurationToMs(expiresIn);
|
|
50
48
|
|
|
@@ -73,7 +71,9 @@ export const createConsoleSessions = ({
|
|
|
73
71
|
try {
|
|
74
72
|
const payload = await tokenService.verifyToken(cookie, { audience: CONSOLE_AUDIENCE });
|
|
75
73
|
if (payload.role !== env.superadmin.role) return null;
|
|
76
|
-
|
|
74
|
+
const scope = payload.claims?.scope;
|
|
75
|
+
if (!isScope(scope)) return null;
|
|
76
|
+
return { ...payload, scope };
|
|
77
77
|
} catch {
|
|
78
78
|
return null;
|
|
79
79
|
}
|
|
@@ -83,12 +83,14 @@ export const createConsoleSessions = ({
|
|
|
83
83
|
login: async (req) => {
|
|
84
84
|
const { secret: submitted } = (await req.json()) as { secret?: string };
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
const candidate = submitted ?? null;
|
|
87
|
+
const write = authorizeCapability(candidate, "console:write");
|
|
88
|
+
const grant = write ?? authorizeCapability(candidate, "console:read");
|
|
89
|
+
if (!grant) throw new Error("Invalid admin secret");
|
|
90
|
+
const scope: ConsoleScope = write ? "write" : "read";
|
|
89
91
|
|
|
90
92
|
const token = await tokenService.createToken(
|
|
91
|
-
{ sub: CONSOLE_SUBJECT, role: env.superadmin.role },
|
|
93
|
+
{ sub: CONSOLE_SUBJECT, role: env.superadmin.role, claims: { scope } },
|
|
92
94
|
{ audience: CONSOLE_AUDIENCE, expiresIn },
|
|
93
95
|
);
|
|
94
96
|
|
|
@@ -97,17 +99,17 @@ export const createConsoleSessions = ({
|
|
|
97
99
|
maxAge: parseDurationToSeconds(expiresIn),
|
|
98
100
|
});
|
|
99
101
|
|
|
100
|
-
log.info("console session issued");
|
|
102
|
+
log.info({ scope }, "console session issued");
|
|
101
103
|
|
|
102
|
-
return { expiresIn: parseDurationToSeconds(expiresIn) };
|
|
104
|
+
return { expiresIn: parseDurationToSeconds(expiresIn), scope, superset: grant.superset };
|
|
103
105
|
},
|
|
104
106
|
|
|
105
107
|
authorize: async (req) => {
|
|
106
108
|
const payload = await readSession(req);
|
|
107
|
-
if (!payload) return
|
|
109
|
+
if (!payload) return null;
|
|
108
110
|
|
|
109
111
|
prune();
|
|
110
|
-
return
|
|
112
|
+
return revoked.has(payload.jti) ? null : payload.scope;
|
|
111
113
|
},
|
|
112
114
|
|
|
113
115
|
logout: async (req) => {
|
|
@@ -120,6 +122,8 @@ export const createConsoleSessions = ({
|
|
|
120
122
|
}
|
|
121
123
|
|
|
122
124
|
req.cookies.delete({ name: CONSOLE_SESSION_COOKIE, path: consolePath });
|
|
125
|
+
|
|
126
|
+
return payload !== null;
|
|
123
127
|
},
|
|
124
128
|
};
|
|
125
129
|
};
|
|
@@ -78,7 +78,6 @@ export const executeQueryJSON = async <T>(
|
|
|
78
78
|
|
|
79
79
|
export const callStoredProcedure = async (
|
|
80
80
|
sp: ProcedureResolver,
|
|
81
|
-
variablesDefinition: VariableDefinition[],
|
|
82
81
|
variables: Record<string, unknown> = {},
|
|
83
82
|
) => {
|
|
84
83
|
const adapter = databaseAdapters[sp.db!.type];
|
|
@@ -94,7 +93,7 @@ export const callStoredProcedure = async (
|
|
|
94
93
|
const startTime = Bun.nanoseconds();
|
|
95
94
|
|
|
96
95
|
try {
|
|
97
|
-
const result = await adapter.callStoredProcedure(sp,
|
|
96
|
+
const result = await adapter.callStoredProcedure(sp, variables);
|
|
98
97
|
log.debug({ durationMs: (Bun.nanoseconds() - startTime) / 1e6 }, "stored procedure executed");
|
|
99
98
|
return result;
|
|
100
99
|
} catch (error) {
|
|
@@ -105,7 +105,6 @@ type DatabaseFunctions = {
|
|
|
105
105
|
) => Promise<T>;
|
|
106
106
|
callStoredProcedure: (
|
|
107
107
|
sp: ProcedureResolver,
|
|
108
|
-
variablesDefinition: VariableDefinition[],
|
|
109
108
|
parameters: Record<string, unknown>,
|
|
110
109
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
111
110
|
) => Promise<any>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { VariableDefinition } from "../../analyzeQuery/types";
|
|
2
|
+
import type { ProcedureResolver } from "../../types/db";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Orders a procedure call's arguments for the engines that bind positionally.
|
|
6
|
+
*
|
|
7
|
+
* `$1, $2, …` are numbered by one list and filled from another, so the two have
|
|
8
|
+
* to be the same list or the values land on the wrong parameters. Signature
|
|
9
|
+
* order comes from the introspected metadata; the caller's argument object is
|
|
10
|
+
* unordered and reflects the GraphQL query text.
|
|
11
|
+
*
|
|
12
|
+
* Arguments the metadata doesn't account for keep their supplied order and
|
|
13
|
+
* follow: an unnamed procedure parameter is introspected under a synthesized
|
|
14
|
+
* name that no caller can match, and dropping the value there would be worse
|
|
15
|
+
* than binding it where it was already going.
|
|
16
|
+
*/
|
|
17
|
+
export const orderProcedureArguments = (
|
|
18
|
+
sp: ProcedureResolver,
|
|
19
|
+
variables: Record<string, unknown>,
|
|
20
|
+
): VariableDefinition[] => {
|
|
21
|
+
const declared = sp.parameters.map((p) => p.name).filter((name) => name in variables);
|
|
22
|
+
const undeclared = Object.keys(variables).filter((name) => !declared.includes(name));
|
|
23
|
+
|
|
24
|
+
return [...declared, ...undeclared].map((name) => ({ name, type: "", required: false }));
|
|
25
|
+
};
|
|
@@ -161,7 +161,6 @@ export const executeQueryJSONSingle = executeQueryJSONFactory(true);
|
|
|
161
161
|
|
|
162
162
|
export const callStoredProcedure = async (
|
|
163
163
|
sp: ProcedureResolver,
|
|
164
|
-
variablesDefinition: VariableDefinition[],
|
|
165
164
|
variables: Record<string, unknown> = {},
|
|
166
165
|
) => {
|
|
167
166
|
try {
|
|
@@ -170,8 +169,6 @@ export const callStoredProcedure = async (
|
|
|
170
169
|
const request = pool!.request();
|
|
171
170
|
|
|
172
171
|
for (const [key, value] of Object.entries(variables)) {
|
|
173
|
-
if (!value) continue;
|
|
174
|
-
|
|
175
172
|
const paramFound = sp.parameters.find((p) => p.name === `@${key}`);
|
|
176
173
|
|
|
177
174
|
if (paramFound?.dataType === "varchar") {
|
|
@@ -7,6 +7,7 @@ import type { ProcedureResolver } from "../../../types/db";
|
|
|
7
7
|
|
|
8
8
|
import { BunSQLConnectionOptionsZod } from "../../../config/types/db";
|
|
9
9
|
import { databasesConnections } from "../../../singletons/databases";
|
|
10
|
+
import { orderProcedureArguments } from "../../core/procedure-arguments";
|
|
10
11
|
import { toMySQLPlaceholders } from "./placeholders";
|
|
11
12
|
|
|
12
13
|
// Every pool bound comes from the schema, which is the only place they are
|
|
@@ -97,22 +98,24 @@ export const executeQueryJSONFactory =
|
|
|
97
98
|
export const executeQueryJSON = executeQueryJSONFactory();
|
|
98
99
|
export const executeQueryJSONSingle = executeQueryJSONFactory(true);
|
|
99
100
|
|
|
101
|
+
// The generated schema types every stored procedure mutation as `Boolean!`, so
|
|
102
|
+
// the call reports whether it ran, not what it selected. Returning the rows here
|
|
103
|
+
// would put an array behind a field the schema promises is a boolean.
|
|
100
104
|
export const callStoredProcedure = async (
|
|
101
105
|
sp: ProcedureResolver,
|
|
102
|
-
variablesDefinition: VariableDefinition[],
|
|
103
106
|
variables: Record<string, unknown>,
|
|
104
107
|
) => {
|
|
105
108
|
try {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
const args = orderProcedureArguments(sp, variables);
|
|
110
|
+
|
|
111
|
+
await executeQuery(
|
|
112
|
+
`CALL ${sp.dottedQuotedName}(${args.map((_, i) => `$${i + 1}`).join(", ")});`,
|
|
110
113
|
sp.db!,
|
|
111
|
-
|
|
114
|
+
args,
|
|
112
115
|
variables,
|
|
113
116
|
);
|
|
114
117
|
|
|
115
|
-
return
|
|
118
|
+
return true;
|
|
116
119
|
} catch {
|
|
117
120
|
return false;
|
|
118
121
|
}
|
|
@@ -7,6 +7,7 @@ import type { ProcedureResolver } from "../../../types/db";
|
|
|
7
7
|
|
|
8
8
|
import { BunSQLConnectionOptionsZod } from "../../../config/types/db";
|
|
9
9
|
import { databasesConnections } from "../../../singletons/databases";
|
|
10
|
+
import { orderProcedureArguments } from "../../core/procedure-arguments";
|
|
10
11
|
import { getQueryTimeoutMs } from "../../../singletons/queryTimeout";
|
|
11
12
|
|
|
12
13
|
// Every pool bound comes from the schema, which is the only place they are
|
|
@@ -138,22 +139,24 @@ export const executeQueryJSONFactory =
|
|
|
138
139
|
export const executeQueryJSON = executeQueryJSONFactory();
|
|
139
140
|
export const executeQueryJSONSingle = executeQueryJSONFactory(true);
|
|
140
141
|
|
|
142
|
+
// The generated schema types every stored procedure mutation as `Boolean!`, so
|
|
143
|
+
// the call reports whether it ran, not what it selected. Returning the rows here
|
|
144
|
+
// would put an array behind a field the schema promises is a boolean.
|
|
141
145
|
export const callStoredProcedure = async (
|
|
142
146
|
sp: ProcedureResolver,
|
|
143
|
-
variablesDefinition: VariableDefinition[],
|
|
144
147
|
variables: Record<string, unknown>,
|
|
145
148
|
) => {
|
|
146
149
|
try {
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
150
|
+
const args = orderProcedureArguments(sp, variables);
|
|
151
|
+
|
|
152
|
+
await executeQuery(
|
|
153
|
+
`SELECT * FROM ${sp.dottedQuotedName}(${args.map((_, i) => `$${i + 1}`).join(", ")});`,
|
|
151
154
|
sp.db!,
|
|
152
|
-
|
|
155
|
+
args,
|
|
153
156
|
variables,
|
|
154
157
|
);
|
|
155
158
|
|
|
156
|
-
return
|
|
159
|
+
return true;
|
|
157
160
|
} catch {
|
|
158
161
|
return false;
|
|
159
162
|
}
|