Skip to content

Commit 059bd78

Browse files
Merge branch 'main' into intercom-for-mobile
2 parents 9dd5c7d + 8c0225a commit 059bd78

288 files changed

Lines changed: 3355 additions & 860 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { CollectQueryTablesResult } from '../../entities/visualizations/panel/utils/collect-query-tables.util.js';
2+
import { getErrorMessage } from '../../helpers/get-error-message.js';
3+
4+
/**
5+
* Recursively collects collection names referenced by stages that read from
6+
* other collections (`$lookup`, `$graphLookup`, `$unionWith`) anywhere in a
7+
* MongoDB aggregation pipeline, including nested sub-pipelines.
8+
*/
9+
function collectReferencedCollections(node: unknown, collected: Set<string>): void {
10+
if (Array.isArray(node)) {
11+
for (const item of node) {
12+
collectReferencedCollections(item, collected);
13+
}
14+
return;
15+
}
16+
if (!node || typeof node !== 'object') {
17+
return;
18+
}
19+
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
20+
if (key === '$lookup' || key === '$graphLookup') {
21+
const from = (value as { from?: unknown })?.from;
22+
if (typeof from === 'string' && from.length > 0) {
23+
collected.add(from);
24+
}
25+
} else if (key === '$unionWith') {
26+
// `$unionWith` accepts either a collection-name string or `{ coll: <name>, pipeline: [...] }`.
27+
if (typeof value === 'string' && value.length > 0) {
28+
collected.add(value);
29+
} else {
30+
const coll = (value as { coll?: unknown })?.coll;
31+
if (typeof coll === 'string' && coll.length > 0) {
32+
collected.add(coll);
33+
}
34+
}
35+
}
36+
collectReferencedCollections(value, collected);
37+
}
38+
}
39+
40+
/**
41+
* Resolves the collections a MongoDB aggregation pipeline reads from besides
42+
* its base collection (the `$lookup` / `$graphLookup` / `$unionWith` targets),
43+
* so the caller can verify the user has read permission on each.
44+
*
45+
* Returns `{ kind: 'tables' }` (possibly empty) when the pipeline parses, and
46+
* `{ kind: 'indeterminate' }` when it cannot be parsed — in which case the
47+
* caller must fall back to a stricter check rather than assume it is harmless.
48+
*/
49+
export function collectMongoPipelineCollections(pipeline: string): CollectQueryTablesResult {
50+
let parsedPipeline: unknown;
51+
try {
52+
parsedPipeline = JSON.parse(pipeline);
53+
} catch (error) {
54+
return { kind: 'indeterminate', reason: `pipeline parse error: ${getErrorMessage(error)}` };
55+
}
56+
const collected = new Set<string>();
57+
collectReferencedCollections(parsedPipeline, collected);
58+
return { kind: 'tables', tables: Array.from(collected) };
59+
}

backend/src/ai-core/tools/query-validators.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,53 @@ export function isValidSQLQuery(query: string): boolean {
2828
return true;
2929
}
3030

31+
// Aggregation operators that either write to a collection ($out, $merge) or execute
32+
// server-side JavaScript ($function, $accumulator, $where). None of these belong in a
33+
// read-only AI query, and the substring blocklist in `isValidMongoDbCommand` cannot
34+
// detect them, so they must be rejected by walking the parsed pipeline.
35+
const FORBIDDEN_MONGO_OPERATORS: ReadonlySet<string> = new Set([
36+
'$out',
37+
'$merge',
38+
'$function',
39+
'$accumulator',
40+
'$where',
41+
]);
42+
43+
function pipelineContainsForbiddenOperator(node: unknown): boolean {
44+
if (Array.isArray(node)) {
45+
return node.some((item) => pipelineContainsForbiddenOperator(item));
46+
}
47+
if (!node || typeof node !== 'object') {
48+
return false;
49+
}
50+
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
51+
if (FORBIDDEN_MONGO_OPERATORS.has(key)) {
52+
return true;
53+
}
54+
if (pipelineContainsForbiddenOperator(value)) {
55+
return true;
56+
}
57+
}
58+
return false;
59+
}
60+
61+
/**
62+
* Ensures a MongoDB aggregation pipeline is read-only: it must parse as JSON and contain
63+
* no write stages (`$out`, `$merge`) or server-side JavaScript operators (`$function`,
64+
* `$accumulator`, `$where`) at any nesting depth (including `$lookup` sub-pipelines). This
65+
* AST-level check complements the substring-based `isValidMongoDbCommand`, which cannot
66+
* detect these stages. Returns false for unparseable pipelines (fail-closed).
67+
*/
68+
export function isReadOnlyMongoAggregationPipeline(pipeline: string): boolean {
69+
let parsedPipeline: unknown;
70+
try {
71+
parsedPipeline = JSON.parse(pipeline);
72+
} catch {
73+
return false;
74+
}
75+
return !pipelineContainsForbiddenOperator(parsedPipeline);
76+
}
77+
3178
export function isValidMongoDbCommand(command: string): boolean {
3279
const upperCaseCommand = command.toUpperCase();
3380
const forbiddenKeywords = ['DROP', 'REMOVE', 'UPDATE', 'INSERT', 'DELETE'];

backend/src/authorization/auth-with-api.middleware.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from '@nestjs/common';
1010
import { InjectRepository } from '@nestjs/typeorm';
1111
import Sentry from '@sentry/minimal';
12-
import { NextFunction, Request, Response } from 'express';
12+
import { NextFunction, Response } from 'express';
1313
import jwt from 'jsonwebtoken';
1414
import { Repository } from 'typeorm';
1515
import { JwtScopesEnum } from '../entities/user/enums/jwt-scopes.enum.js';
@@ -48,7 +48,7 @@ export class AuthWithApiMiddleware implements NestMiddleware {
4848
}
4949
}
5050

51-
private getTokenFromCookie(req: Request): string | undefined {
51+
private getTokenFromCookie(req: IRequestWithCognitoInfo): string | undefined {
5252
return req.cookies?.[Constants.JWT_COOKIE_KEY_NAME];
5353
}
5454

@@ -62,6 +62,9 @@ export class AuthWithApiMiddleware implements NestMiddleware {
6262
private async authenticateWithToken(tokenFromCookie: string, req: IRequestWithCognitoInfo): Promise<void> {
6363
try {
6464
const jwtSecret = appConfig.auth.jwtSecret;
65+
if (!jwtSecret) {
66+
throw new UnauthorizedException('JWT verification failed');
67+
}
6568
const data = jwt.verify(tokenFromCookie, jwtSecret) as jwt.JwtPayload;
6669
const userId = data.id;
6770

backend/src/authorization/auth.middleware.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export class AuthMiddleware implements NestMiddleware {
3030
private readonly logOutRepository: Repository<LogOutEntity>,
3131
) {}
3232
async use(req: IRequestWithCognitoInfo, _res: Response, next: NextFunction): Promise<void> {
33-
let token: string;
33+
let token: string | undefined;
3434
try {
3535
token = req.cookies[Constants.JWT_COOKIE_KEY_NAME];
3636
} catch (_e) {
@@ -50,6 +50,9 @@ export class AuthMiddleware implements NestMiddleware {
5050

5151
try {
5252
const jwtSecret = appConfig.auth.jwtSecret;
53+
if (!jwtSecret) {
54+
throw new UnauthorizedException('JWT verification failed');
55+
}
5356
const data = jwt.verify(token, jwtSecret) as jwt.JwtPayload;
5457
const userId = data.id;
5558

backend/src/authorization/non-scoped-auth.middleware.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export class NonScopedAuthMiddleware implements NestMiddleware {
2626
) {}
2727
async use(req: IRequestWithCognitoInfo, _res: Response, next: NextFunction): Promise<void> {
2828
console.log(`auth middleware triggered ->: ${new Date().toISOString()}`);
29-
let token: string;
29+
let token: string | undefined;
3030
try {
3131
token = req.cookies[Constants.JWT_COOKIE_KEY_NAME];
3232
} catch (_e) {
@@ -46,6 +46,9 @@ export class NonScopedAuthMiddleware implements NestMiddleware {
4646

4747
try {
4848
const jwtSecret = appConfig.auth.jwtSecret;
49+
if (!jwtSecret) {
50+
throw new UnauthorizedException('JWT verification failed');
51+
}
4952
const data = jwt.verify(token, jwtSecret) as jwt.JwtPayload;
5053
const userId = data.id;
5154
if (!userId) {

backend/src/authorization/saas-auth.middleware.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ export class SaaSAuthMiddleware implements NestMiddleware {
1616
}
1717
try {
1818
const jwtSecret = appConfig.auth.microserviceJwtSecret;
19+
if (!jwtSecret) {
20+
throw new UnauthorizedException(Messages.AUTHORIZATION_REJECTED);
21+
}
1922
const data = jwt.verify(token, jwtSecret) as jwt.JwtPayload;
2023
const requestId = data.request_id;
2124

backend/src/authorization/temporary-auth.middleware.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export class TemporaryAuthMiddleware implements NestMiddleware {
2828
) {}
2929
async use(req: IRequestWithCognitoInfo, _res: Response, next: NextFunction): Promise<void> {
3030
console.log(`temporary auth middleware triggered ->: ${new Date().toISOString()}`);
31-
let token: string;
31+
let token: string | undefined;
3232
try {
3333
token = req.cookies[Constants.JWT_COOKIE_KEY_NAME];
3434
} catch (_e) {
@@ -48,6 +48,9 @@ export class TemporaryAuthMiddleware implements NestMiddleware {
4848

4949
try {
5050
const jwtSecret = appConfig.auth.temporaryJwtSecret;
51+
if (!jwtSecret) {
52+
throw new UnauthorizedException('JWT verification failed');
53+
}
5154
const data = jwt.verify(token, jwtSecret) as jwt.JwtPayload;
5255
const userId = data.id;
5356
if (!userId) {
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Request } from 'express';
2-
export const extractTokenFromHeader = (request: Request): string | null => {
2+
export const extractTokenFromHeader = (request: Pick<Request, 'headers'>): string | null => {
33
const [type, token] = request.headers.authorization?.split(' ') ?? [];
44
return type === 'Bearer' ? token : null;
55
};

backend/src/common/abstract-use.case.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,20 +30,27 @@ abstract class AbstractUseCase<TInputData = void, TOutputData = void> {
3030

3131
protected abstract implementation(inputData: TInputData): Promise<TOutputData> | TOutputData;
3232

33+
private getDbContext(): IDatabaseContext {
34+
if (!this._dbContext) {
35+
throw new Error('Database context is not initialized for this use case.');
36+
}
37+
return this._dbContext;
38+
}
39+
3340
private async startTransaction(): Promise<void> {
34-
await this._dbContext.startTransaction();
41+
await this.getDbContext().startTransaction();
3542
}
3643

3744
private async commitTransaction(): Promise<void> {
38-
await this._dbContext.commitTransaction();
45+
await this.getDbContext().commitTransaction();
3946
}
4047

4148
private async rollbackTransaction(): Promise<void> {
42-
await this._dbContext.rollbackTransaction();
49+
await this.getDbContext().rollbackTransaction();
4350
}
4451

4552
private async releaseQueryRunner(): Promise<void> {
46-
await this._dbContext.releaseQueryRunner();
53+
await this.getDbContext().releaseQueryRunner();
4754
}
4855
}
4956

backend/src/common/application/global-database-context.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -452,10 +452,9 @@ export class GlobalDatabaseContext implements IGlobalDatabaseContext {
452452
return this._schemaChangeChatMessageRepository;
453453
}
454454

455-
public startTransaction(): Promise<void> {
455+
public async startTransaction(): Promise<void> {
456456
this._queryRunner = this.appDataSource.createQueryRunner();
457-
this._queryRunner.startTransaction();
458-
return;
457+
await this._queryRunner.startTransaction();
459458
}
460459

461460
public async commitTransaction(): Promise<void> {

0 commit comments

Comments
 (0)