@oneuptime/common 11.5.7 → 11.5.8
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/Models/DatabaseModels/AIRun.ts +31 -0
- package/Server/API/CodeFixRunAPI.ts +25 -74
- package/Server/Infrastructure/Postgres/SchemaMigrations/1784105912819-BackfillCodeFixTaskType.ts +41 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Middleware/MasterAdminAuthorization.ts +1 -1
- package/Server/Middleware/ProjectAuthorization.ts +1 -1
- package/Server/Services/AIRunService.ts +10 -11
- package/Server/Utils/AI/AIRunPrivacyFilter.ts +106 -0
- package/Tests/Server/Utils/AI/AIRunPrivacyFilter.test.ts +295 -0
- package/Types/Email/EmailTemplateType.ts +2 -0
- package/UI/Components/EditionLabel/EditionLabel.tsx +821 -331
- package/build/dist/Models/DatabaseModels/AIRun.js +31 -0
- package/build/dist/Models/DatabaseModels/AIRun.js.map +1 -1
- package/build/dist/Server/API/CodeFixRunAPI.js +25 -53
- package/build/dist/Server/API/CodeFixRunAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784105912819-BackfillCodeFixTaskType.js +36 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784105912819-BackfillCodeFixTaskType.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Middleware/MasterAdminAuthorization.js +1 -1
- package/build/dist/Server/Middleware/ProjectAuthorization.js +1 -1
- package/build/dist/Server/Services/AIRunService.js +10 -3
- package/build/dist/Server/Services/AIRunService.js.map +1 -1
- package/build/dist/Server/Utils/AI/AIRunPrivacyFilter.js +82 -0
- package/build/dist/Server/Utils/AI/AIRunPrivacyFilter.js.map +1 -0
- package/build/dist/Types/Email/EmailTemplateType.js +1 -0
- package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
- package/build/dist/UI/Components/EditionLabel/EditionLabel.js +424 -139
- package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyAIRunPrivacyFilter,
|
|
3
|
+
getAIRunPrivacyRaw,
|
|
4
|
+
} from "../../../../Server/Utils/AI/AIRunPrivacyFilter";
|
|
5
|
+
import QueryUtil from "../../../../Server/Types/Database/QueryUtil";
|
|
6
|
+
import AIRun from "../../../../Models/DatabaseModels/AIRun";
|
|
7
|
+
import AIRunType from "../../../../Types/AI/AIRunType";
|
|
8
|
+
import Query from "../../../../Types/BaseDatabase/Query";
|
|
9
|
+
import EqualToOrNull from "../../../../Types/BaseDatabase/EqualToOrNull";
|
|
10
|
+
import Includes from "../../../../Types/BaseDatabase/Includes";
|
|
11
|
+
import IsNull from "../../../../Types/BaseDatabase/IsNull";
|
|
12
|
+
import LessThan from "../../../../Types/BaseDatabase/LessThan";
|
|
13
|
+
import MultiSearch from "../../../../Types/BaseDatabase/MultiSearch";
|
|
14
|
+
import NotContains from "../../../../Types/BaseDatabase/NotContains";
|
|
15
|
+
import NotEqual from "../../../../Types/BaseDatabase/NotEqual";
|
|
16
|
+
import NotNull from "../../../../Types/BaseDatabase/NotNull";
|
|
17
|
+
import Search from "../../../../Types/BaseDatabase/Search";
|
|
18
|
+
import DatabaseCommonInteractionProps from "../../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
|
|
19
|
+
import NotAuthorizedException from "../../../../Types/Exception/NotAuthorizedException";
|
|
20
|
+
import ObjectID from "../../../../Types/ObjectID";
|
|
21
|
+
import { UserTenantAccessPermission } from "../../../../Types/Permission";
|
|
22
|
+
import { describe, expect, test } from "@jest/globals";
|
|
23
|
+
import { FindOperator } from "typeorm";
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
* The forced privacy clause must survive every operator a client can smuggle
|
|
27
|
+
* into query.runType. BaseAPI.getList takes `query` straight from the request
|
|
28
|
+
* body and JSONFunctions.deserializeValue rebuilds any operator named via
|
|
29
|
+
* `_type`, so these are all reachable from an ordinary HTTP call.
|
|
30
|
+
*
|
|
31
|
+
* Every assertion runs against the FINAL query — after QueryUtil.serializeQuery
|
|
32
|
+
* — because that is what actually reaches the database. Asserting on the
|
|
33
|
+
* pre-hook object would pass even for an implementation that reads (and is
|
|
34
|
+
* fooled by) the caller's runType.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const userId: ObjectID = ObjectID.generate();
|
|
38
|
+
|
|
39
|
+
type SerializeFunction = (
|
|
40
|
+
query: Record<string, unknown>,
|
|
41
|
+
) => Record<string, unknown>;
|
|
42
|
+
|
|
43
|
+
const serialize: SerializeFunction = (
|
|
44
|
+
query: Record<string, unknown>,
|
|
45
|
+
): Record<string, unknown> => {
|
|
46
|
+
const filtered: Record<string, unknown> = applyAIRunPrivacyFilter(query, {
|
|
47
|
+
userId: userId,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return QueryUtil.serializeQuery(
|
|
51
|
+
AIRun,
|
|
52
|
+
filtered as Query<AIRun>,
|
|
53
|
+
) as unknown as Record<string, unknown>;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/*
|
|
57
|
+
* Renders a (possibly nested) TypeORM FindOperator tree to text so the
|
|
58
|
+
* assertions can look for the forced predicate inside an And(...).
|
|
59
|
+
*/
|
|
60
|
+
type RenderFunction = (value: unknown) => string;
|
|
61
|
+
|
|
62
|
+
const render: RenderFunction = (value: unknown): string => {
|
|
63
|
+
const parts: Array<string> = [];
|
|
64
|
+
|
|
65
|
+
const walk: (node: unknown) => void = (node: unknown): void => {
|
|
66
|
+
if (!(node instanceof FindOperator)) {
|
|
67
|
+
if (node !== undefined && node !== null) {
|
|
68
|
+
parts.push(String(node));
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
74
|
+
const operator: any = node as any;
|
|
75
|
+
|
|
76
|
+
parts.push(String(operator._type));
|
|
77
|
+
|
|
78
|
+
if (typeof operator._getSql === "function") {
|
|
79
|
+
try {
|
|
80
|
+
parts.push(String(operator._getSql("COLUMN")));
|
|
81
|
+
} catch {
|
|
82
|
+
// Raw's sql builder only needs the alias; ignore anything that throws.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const innerValue: any = operator._value;
|
|
87
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
88
|
+
|
|
89
|
+
if (Array.isArray(innerValue)) {
|
|
90
|
+
innerValue.forEach(walk);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
walk(innerValue);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
walk(value);
|
|
98
|
+
|
|
99
|
+
return parts.join(" | ");
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
type AssertFunction = (serialized: Record<string, unknown>) => void;
|
|
103
|
+
|
|
104
|
+
// The forced disjunction must be present in whatever finally lands on runType.
|
|
105
|
+
const expectPrivacyClauseSurvives: AssertFunction = (
|
|
106
|
+
serialized: Record<string, unknown>,
|
|
107
|
+
): void => {
|
|
108
|
+
const runType: unknown = serialized["runType"];
|
|
109
|
+
|
|
110
|
+
expect(runType).toBeInstanceOf(FindOperator);
|
|
111
|
+
expect(render(runType)).toContain(`"AIRun"."userId"`);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
describe("getAIRunPrivacyRaw", () => {
|
|
115
|
+
test("root and master admin bypass the filter entirely", () => {
|
|
116
|
+
expect(getAIRunPrivacyRaw({ isRoot: true })).toBeUndefined();
|
|
117
|
+
expect(getAIRunPrivacyRaw({ isMasterAdmin: true })).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/*
|
|
121
|
+
* BLOCKING guarantee. Project API keys are given userTenantAccessPermission
|
|
122
|
+
* but never userAuthorization, so props.userId is undefined for them and
|
|
123
|
+
* this throw is their hard block on AIRun. Letting CodeFix rows through for
|
|
124
|
+
* a caller with no user would hand every ProjectMember API key the whole
|
|
125
|
+
* project fix history — access neither /ai-run nor /code-fix-run grants.
|
|
126
|
+
*/
|
|
127
|
+
test("rejects a caller with no user, even though CodeFix runs are shared", () => {
|
|
128
|
+
const apiKeyShapedProps: DatabaseCommonInteractionProps = {
|
|
129
|
+
tenantId: ObjectID.generate(),
|
|
130
|
+
userTenantAccessPermission: {} as {
|
|
131
|
+
[tenantId: string]: UserTenantAccessPermission;
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
expect(() => {
|
|
136
|
+
return getAIRunPrivacyRaw(apiKeyShapedProps);
|
|
137
|
+
}).toThrow(NotAuthorizedException);
|
|
138
|
+
|
|
139
|
+
expect(() => {
|
|
140
|
+
return applyAIRunPrivacyFilter(
|
|
141
|
+
{ runType: AIRunType.CodeFix },
|
|
142
|
+
apiKeyShapedProps,
|
|
143
|
+
);
|
|
144
|
+
}).toThrow(NotAuthorizedException);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("getAIRunPrivacyRaw — the predicate itself", () => {
|
|
149
|
+
/*
|
|
150
|
+
* The structural assertions below only prove the clause is PRESENT. This one
|
|
151
|
+
* pins what it actually says, so a mis-bound parameter (the wrong run type,
|
|
152
|
+
* or somebody else's id) cannot pass as "the clause survived".
|
|
153
|
+
*/
|
|
154
|
+
test("binds exactly CodeFix and the calling user, and ORs them on the aliased column", () => {
|
|
155
|
+
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
|
156
|
+
const raw: any = getAIRunPrivacyRaw({ userId: userId });
|
|
157
|
+
|
|
158
|
+
const sql: string = raw._getSql("COLUMN");
|
|
159
|
+
const parameters: Record<string, string> = raw._objectLiteralParameters;
|
|
160
|
+
const values: Array<string> = Object.values(parameters);
|
|
161
|
+
|
|
162
|
+
expect(values).toHaveLength(2);
|
|
163
|
+
expect(values).toContain(AIRunType.CodeFix);
|
|
164
|
+
expect(values).toContain(userId.toString());
|
|
165
|
+
|
|
166
|
+
// `<column> = :runType OR "AIRun"."userId" = :callerId`
|
|
167
|
+
expect(sql).toContain("COLUMN = :");
|
|
168
|
+
expect(sql).toContain(`OR "AIRun"."userId" = :`);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("uses fresh parameter names per call so two clauses cannot collide", () => {
|
|
172
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
173
|
+
const first: any = getAIRunPrivacyRaw({ userId: userId });
|
|
174
|
+
const second: any = getAIRunPrivacyRaw({ userId: userId });
|
|
175
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
176
|
+
|
|
177
|
+
expect(Object.keys(first._objectLiteralParameters)).not.toEqual(
|
|
178
|
+
Object.keys(second._objectLiteralParameters),
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("applyAIRunPrivacyFilter — the clause is forced, never read from the query", () => {
|
|
184
|
+
/*
|
|
185
|
+
* findOneById builds { _id } with no runType at all. A dispatch that keyed
|
|
186
|
+
* off the caller's runType would leave this query unfiltered (or pin it to
|
|
187
|
+
* the user and break every code-fix detail page).
|
|
188
|
+
*/
|
|
189
|
+
test("applies to a query with no runType at all (get-item)", () => {
|
|
190
|
+
expectPrivacyClauseSurvives(serialize({ _id: ObjectID.generate() }));
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("a plain CodeFix filter is ANDed onto the clause, not trusted in place of it", () => {
|
|
194
|
+
expectPrivacyClauseSurvives(serialize({ runType: AIRunType.CodeFix }));
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("leaves other query keys untouched", () => {
|
|
198
|
+
const projectId: ObjectID = ObjectID.generate();
|
|
199
|
+
const serialized: Record<string, unknown> = serialize({
|
|
200
|
+
projectId: projectId,
|
|
201
|
+
runType: AIRunType.CodeFix,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(serialized["projectId"]).toBeDefined();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("applyAIRunPrivacyFilter — hostile operator smuggling", () => {
|
|
209
|
+
/*
|
|
210
|
+
* Each of these reads as "CodeFix" to a naive check but compiles to a
|
|
211
|
+
* predicate that matches Chat rows, or erases the runType predicate
|
|
212
|
+
* altogether. The clause must survive all of them.
|
|
213
|
+
*/
|
|
214
|
+
const hostileValues: Array<[string, unknown]> = [
|
|
215
|
+
[
|
|
216
|
+
"NotEqual(CodeFix) inverts the predicate",
|
|
217
|
+
new NotEqual(AIRunType.CodeFix),
|
|
218
|
+
],
|
|
219
|
+
[
|
|
220
|
+
"LessThan(CodeFix) escapes lexicographically ('Chat' < 'CodeFix')",
|
|
221
|
+
new LessThan(AIRunType.CodeFix),
|
|
222
|
+
],
|
|
223
|
+
[
|
|
224
|
+
"NotContains(CodeFix) negates an ILIKE",
|
|
225
|
+
new NotContains(AIRunType.CodeFix),
|
|
226
|
+
],
|
|
227
|
+
["Search(CodeFix)", new Search(AIRunType.CodeFix)],
|
|
228
|
+
["EqualToOrNull(CodeFix)", new EqualToOrNull(AIRunType.CodeFix)],
|
|
229
|
+
["IsNull()", new IsNull()],
|
|
230
|
+
["NotNull()", new NotNull()],
|
|
231
|
+
["a bare Chat string", AIRunType.Chat],
|
|
232
|
+
["a bare array of Chat", [AIRunType.Chat]],
|
|
233
|
+
[
|
|
234
|
+
"Includes([CodeFix, Chat]) — the shape ModelTable's own dropdown emits",
|
|
235
|
+
new Includes([AIRunType.CodeFix, AIRunType.Chat]),
|
|
236
|
+
],
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
test.each(hostileValues)(
|
|
240
|
+
"privacy clause survives %s",
|
|
241
|
+
(_name: string, value: unknown) => {
|
|
242
|
+
expectPrivacyClauseSurvives(serialize({ runType: value as AIRunType }));
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
/*
|
|
247
|
+
* The sharpest one. MultiSearch stringifies to exactly "CodeFix", and
|
|
248
|
+
* serializeQuery DELETES a MultiSearch key outright when its field list is
|
|
249
|
+
* empty — substituting nothing. combineWithPrivacyClause has to fail closed
|
|
250
|
+
* on this unrecognized shape so the forced clause is what remains.
|
|
251
|
+
*/
|
|
252
|
+
test("MultiSearch with an empty field list cannot delete the clause", () => {
|
|
253
|
+
const multiSearch: MultiSearch = new MultiSearch({
|
|
254
|
+
value: AIRunType.CodeFix,
|
|
255
|
+
fields: [],
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// It reads as CodeFix to anything that normalizes via toString().
|
|
259
|
+
expect(multiSearch.toString()).toBe(AIRunType.CodeFix);
|
|
260
|
+
|
|
261
|
+
expectPrivacyClauseSurvives(
|
|
262
|
+
serialize({ runType: multiSearch as unknown as AIRunType }),
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
/*
|
|
267
|
+
* A legitimate dropdown filter must still NARROW the result, not be thrown
|
|
268
|
+
* away — otherwise "show me only regression tests" would silently return
|
|
269
|
+
* everything. Includes is the one client shape combineWithPrivacyClause
|
|
270
|
+
* keeps (as QueryHelper.any) rather than failing closed on.
|
|
271
|
+
*/
|
|
272
|
+
test("Includes([CodeFix]) still narrows: both the IN and the clause survive", () => {
|
|
273
|
+
const serialized: Record<string, unknown> = serialize({
|
|
274
|
+
runType: new Includes([AIRunType.CodeFix]) as unknown as AIRunType,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const rendered: string = render(serialized["runType"]);
|
|
278
|
+
|
|
279
|
+
expect(rendered).toContain(`"AIRun"."userId"`); // the forced clause
|
|
280
|
+
expect(rendered.toLowerCase()).toContain("in"); // the caller's IN filter
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
/*
|
|
284
|
+
* Negative control: proves the assertion can actually fail, so a green suite
|
|
285
|
+
* means something. Without the filter the caller's operator stands alone.
|
|
286
|
+
*/
|
|
287
|
+
test("control — an unfiltered query does NOT carry the clause", () => {
|
|
288
|
+
const unfiltered: Record<string, unknown> = QueryUtil.serializeQuery(
|
|
289
|
+
AIRun,
|
|
290
|
+
{ runType: new NotEqual(AIRunType.CodeFix) } as unknown as Query<AIRun>,
|
|
291
|
+
) as unknown as Record<string, unknown>;
|
|
292
|
+
|
|
293
|
+
expect(render(unfiltered["runType"])).not.toContain(`"AIRun"."userId"`);
|
|
294
|
+
});
|
|
295
|
+
});
|