@oneuptime/common 11.5.6 → 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.
Files changed (33) hide show
  1. package/Models/DatabaseModels/AIRun.ts +31 -0
  2. package/Server/API/CodeFixRunAPI.ts +25 -74
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/1784033837629-MigrationName.ts +11 -0
  4. package/Server/Infrastructure/Postgres/SchemaMigrations/1784105912819-BackfillCodeFixTaskType.ts +41 -0
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  6. package/Server/Middleware/MasterAdminAuthorization.ts +1 -1
  7. package/Server/Middleware/ProjectAuthorization.ts +1 -1
  8. package/Server/Services/AIRunService.ts +10 -11
  9. package/Server/Utils/AI/AIRunPrivacyFilter.ts +106 -0
  10. package/Tests/Server/Utils/AI/AIRunPrivacyFilter.test.ts +295 -0
  11. package/Types/Email/EmailTemplateType.ts +2 -0
  12. package/UI/Components/EditionLabel/EditionLabel.tsx +821 -331
  13. package/build/dist/Models/DatabaseModels/AIRun.js +31 -0
  14. package/build/dist/Models/DatabaseModels/AIRun.js.map +1 -1
  15. package/build/dist/Server/API/CodeFixRunAPI.js +25 -53
  16. package/build/dist/Server/API/CodeFixRunAPI.js.map +1 -1
  17. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784033837629-MigrationName.js +9 -0
  18. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784033837629-MigrationName.js.map +1 -1
  19. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784105912819-BackfillCodeFixTaskType.js +36 -0
  20. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784105912819-BackfillCodeFixTaskType.js.map +1 -0
  21. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  22. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  23. package/build/dist/Server/Middleware/MasterAdminAuthorization.js +1 -1
  24. package/build/dist/Server/Middleware/ProjectAuthorization.js +1 -1
  25. package/build/dist/Server/Services/AIRunService.js +10 -3
  26. package/build/dist/Server/Services/AIRunService.js.map +1 -1
  27. package/build/dist/Server/Utils/AI/AIRunPrivacyFilter.js +82 -0
  28. package/build/dist/Server/Utils/AI/AIRunPrivacyFilter.js.map +1 -0
  29. package/build/dist/Types/Email/EmailTemplateType.js +1 -0
  30. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  31. package/build/dist/UI/Components/EditionLabel/EditionLabel.js +424 -139
  32. package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
  33. 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
+ });
@@ -84,6 +84,8 @@ enum EmailTemplateType {
84
84
 
85
85
  ProjectSubscriptionOverdue = "ProjectSubscriptionOverdue.hbs",
86
86
  Invoice = "Invoice.hbs",
87
+
88
+ ClickhouseCapacityWarning = "ClickhouseCapacityWarning.hbs",
87
89
  }
88
90
 
89
91
  export default EmailTemplateType;