• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

teableio / teable / 21558590690

01 Feb 2026 07:04AM UTC coverage: 64.232% (-0.06%) from 64.292%
21558590690

push

github

web-flow
[sync] feat: tool activate T1783 (#1117) (#2510)

Synced from teableio/teable-ee@046f541

Co-authored-by: teable-bot <bot@teable.io>

4758 of 6183 branches covered (76.95%)

27 of 55 new or added lines in 6 files covered. (49.09%)

14 existing lines in 2 files now uncovered.

21948 of 34170 relevant lines covered (64.23%)

5389.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

73.63
/apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.service.ts
1
import { Injectable, Logger } from '@nestjs/common';
2
import { ConfigService } from '@nestjs/config';
3
import type { IDsn } from '@teable/core';
4
import { DriverClient, HttpErrorCode, parseDsn } from '@teable/core';
5
import { Prisma, PrismaService, PrismaClient } from '@teable/db-main-prisma';
6
import { Knex } from 'knex';
7
import { InjectModel } from 'nest-knexjs';
8
import { CustomHttpException } from '../../custom.exception';
9
import { BASE_READ_ONLY_ROLE_PREFIX, BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME } from './const';
10
import { checkTableAccess, validateRoleOperations } from './utils';
11

12
@Injectable()
13
export class BaseSqlExecutorService {
14
  private db?: PrismaClient;
15
  private readonly dsn: IDsn;
16
  readonly driver: DriverClient;
17
  private hasPgReadAllDataRole?: boolean;
18
  private readonly logger = new Logger(BaseSqlExecutorService.name);
133✔
19

20
  constructor(
21
    private readonly prismaService: PrismaService,
133✔
22
    private readonly configService: ConfigService,
133✔
23
    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex
133✔
24
  ) {
25
    this.dsn = parseDsn(this.getDatabaseUrl());
133✔
26
    this.driver = this.dsn.driver as DriverClient;
133✔
27
  }
28

29
  private getDatabaseUrl() {
30
    return (
133✔
31
      this.configService.get<string>('PRISMA_DATABASE_URL_FOR_SQL_EXECUTOR') ||
32
      this.configService.getOrThrow<string>('PRISMA_DATABASE_URL')
33
    );
34
  }
35

36
  private getDisablePreSqlExecutorCheck() {
37
    return this.configService.get<string>('DISABLE_PRE_SQL_EXECUTOR_CHECK') === 'true';
133✔
38
  }
39

40
  private async getReadOnlyDatabaseConnectionConfig(): Promise<string | undefined> {
41
    if (this.driver === DriverClient.Sqlite) {
133✔
42
      return;
×
43
    }
44
    if (!this.hasPgReadAllDataRole) {
133✔
45
      return;
×
46
    }
47
    const isExistReadOnlyRole = await this.roleExits(BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME);
133✔
48
    if (!isExistReadOnlyRole) {
133✔
49
      await this.prismaService.$tx(async (prisma) => {
4✔
50
        try {
4✔
51
          await prisma.$executeRawUnsafe(
4✔
52
            this.knex
53
              .raw(
54
                `CREATE ROLE ?? WITH LOGIN PASSWORD ? NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION`,
55
                [BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME, this.dsn.pass]
56
              )
57
              .toQuery()
58
          );
59
          await prisma.$executeRawUnsafe(
4✔
60
            this.knex
61
              .raw(`GRANT pg_read_all_data TO ??`, [BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME])
62
              .toQuery()
63
          );
64
        } catch (error) {
65
          if (
×
66
            error instanceof Prisma.PrismaClientKnownRequestError &&
67
            (error?.meta?.code === '42710' ||
68
              error?.meta?.code === '23505' ||
69
              error?.meta?.code === 'XX000')
70
          ) {
71
            this.logger.warn(
×
72
              `read only role ${BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME} already exists or concurrent update detected, error code: ${error?.meta?.code}`
73
            );
74
            return;
×
75
          }
76
          throw error;
×
77
        }
78
      });
79
    }
80
    return `postgresql://${BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME}:${this.dsn.pass}@${this.dsn.host}:${this.dsn.port}/${this.dsn.db}${
133✔
81
      this.dsn.params
82
        ? `?${Object.entries(this.dsn.params)
83
            .map(([key, value]) => `${key}=${value}`)
399✔
84
            .join('&')}`
85
        : ''
86
    }`;
87
  }
88

89
  async onModuleInit() {
90
    if (this.driver !== DriverClient.Pg) {
133✔
91
      return;
×
92
    }
93
    if (this.getDisablePreSqlExecutorCheck()) {
133✔
94
      return;
×
95
    }
96
    // if pg_read_all_data role not exist, no need to create read only role
97
    this.hasPgReadAllDataRole = await this.roleExits('pg_read_all_data');
133✔
98
    if (!this.hasPgReadAllDataRole) {
133✔
99
      return;
×
100
    }
101
    this.db = await this.createConnection();
133✔
102
  }
103

104
  async onModuleDestroy() {
105
    await this.db?.$disconnect();
130✔
106
  }
107

108
  private async createConnection(): Promise<PrismaClient | undefined> {
109
    if (this.db) {
133✔
110
      return this.db;
×
111
    }
112
    const connectionConfig = await this.getReadOnlyDatabaseConnectionConfig();
133✔
113
    if (!connectionConfig) {
133✔
114
      return;
×
115
    }
116
    const connection = new PrismaClient({
133✔
117
      datasources: {
118
        db: {
119
          url: connectionConfig,
120
        },
121
      },
122
    });
123
    await connection.$connect();
133✔
124

125
    // validate connection
126
    try {
133✔
127
      await connection.$queryRawUnsafe('SELECT 1');
133✔
128
      return connection;
133✔
129
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
130
    } catch (error: any) {
131
      await connection.$disconnect();
×
132
      throw new CustomHttpException(
×
133
        `database connection failed: ${error.message}`,
134
        HttpErrorCode.VALIDATION_ERROR,
135
        {
136
          localization: {
137
            i18nKey: 'httpErrors.baseSqlExecutor.databaseConnectionFailed',
138
            context: {
139
              message: error.message,
140
            },
141
          },
142
        }
143
      );
144
    }
145
  }
146

147
  private getReadOnlyRoleName(baseId: string) {
148
    return `${BASE_READ_ONLY_ROLE_PREFIX}${baseId}`;
6✔
149
  }
150

151
  async createReadOnlyRole(baseId: string) {
152
    const roleName = this.getReadOnlyRoleName(baseId);
2✔
153
    await this.prismaService
2✔
154
      .txClient()
155
      .$executeRawUnsafe(
156
        this.knex
157
          .raw(
158
            `CREATE ROLE ?? WITH NOLOGIN NOSUPERUSER NOINHERIT NOCREATEDB NOCREATEROLE NOREPLICATION`,
159
            [roleName]
160
          )
161
          .toQuery()
162
      );
163
    await this.prismaService
2✔
164
      .txClient()
165
      .$executeRawUnsafe(
166
        this.knex.raw(`GRANT USAGE ON SCHEMA ?? TO ??`, [baseId, roleName]).toQuery()
167
      );
168
    await this.prismaService
2✔
169
      .txClient()
170
      .$executeRawUnsafe(
171
        this.knex.raw(`GRANT SELECT ON ALL TABLES IN SCHEMA ?? TO ??`, [baseId, roleName]).toQuery()
172
      );
173
    await this.prismaService
2✔
174
      .txClient()
175
      .$executeRawUnsafe(
176
        this.knex
177
          .raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA ?? GRANT SELECT ON TABLES TO ??`, [
178
            baseId,
179
            roleName,
180
          ])
181
          .toQuery()
182
      );
183
  }
184

185
  async dropReadOnlyRole(baseId: string) {
186
    const roleName = this.getReadOnlyRoleName(baseId);
×
187
    await this.prismaService
×
188
      .txClient()
189
      .$executeRawUnsafe(
190
        this.knex.raw(`REVOKE USAGE ON SCHEMA ?? FROM ??`, [baseId, roleName]).toQuery()
191
      );
192
    await this.prismaService
×
193
      .txClient()
194
      .$executeRawUnsafe(
195
        this.knex
196
          .raw(`REVOKE SELECT ON ALL TABLES IN SCHEMA ?? FROM ??`, [baseId, roleName])
197
          .toQuery()
198
      );
199
    await this.prismaService
×
200
      .txClient()
201
      .$executeRawUnsafe(
202
        this.knex
203
          .raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA ?? REVOKE ALL ON TABLES FROM ??`, [
204
            baseId,
205
            roleName,
206
          ])
207
          .toQuery()
208
      );
209
    await this.prismaService
×
210
      .txClient()
211
      .$executeRawUnsafe(this.knex.raw(`DROP ROLE IF EXISTS ??`, [roleName]).toQuery());
212
  }
213

214
  async grantReadOnlyRole(baseId: string) {
215
    const roleName = this.getReadOnlyRoleName(baseId);
×
216
    await this.prismaService
×
217
      .txClient()
218
      .$executeRawUnsafe(
219
        this.knex.raw(`GRANT USAGE ON SCHEMA ?? TO ??`, [baseId, roleName]).toQuery()
220
      );
221
    await this.prismaService
×
222
      .txClient()
223
      .$executeRawUnsafe(
224
        this.knex.raw(`GRANT SELECT ON ALL TABLES IN SCHEMA ?? TO ??`, [baseId, roleName]).toQuery()
225
      );
226
    await this.prismaService
×
227
      .txClient()
228
      .$executeRawUnsafe(
229
        this.knex
230
          .raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA ?? GRANT SELECT ON TABLES TO ??`, [
231
            baseId,
232
            roleName,
233
          ])
234
          .toQuery()
235
      );
236
  }
237

238
  private async roleExits(role: string): Promise<boolean> {
239
    const roleExists = await this.prismaService.$queryRaw<
268✔
240
      { count: bigint }[]
241
    >`SELECT count(*) FROM pg_roles WHERE rolname=${role}`;
242
    return Boolean(roleExists[0].count);
268✔
243
  }
244

245
  private async roleCheckAndCreate(baseId: string) {
246
    if (this.driver !== DriverClient.Pg) {
2✔
247
      return;
×
248
    }
249
    const roleName = this.getReadOnlyRoleName(baseId);
2✔
250
    if (!(await this.roleExits(roleName))) {
2✔
251
      await this.createReadOnlyRole(baseId);
2✔
252
    }
253
  }
254

255
  private async setRole(prisma: Prisma.TransactionClient, baseId: string) {
256
    const roleName = this.getReadOnlyRoleName(baseId);
2✔
257
    await prisma.$executeRawUnsafe(this.knex.raw(`SET ROLE ??`, [roleName]).toQuery());
2✔
258
  }
259

260
  private async resetRole(prisma: Prisma.TransactionClient) {
261
    await prisma.$executeRawUnsafe(this.knex.raw(`RESET ROLE`).toQuery());
2✔
262
  }
263

264
  private async readonlyExecuteSql(sql: string) {
265
    return this.db?.$queryRawUnsafe(sql);
2✔
266
  }
267

268
  /**
269
   * check sql is safe
270
   * 1. role operations validation
271
   * 2. parse sql to valid table names
272
   * 3. read only role check table access
273
   */
274
  private async safeCheckSql(
275
    baseId: string,
276
    sql: string,
277
    opts?: { projectionTableDbNames?: string[]; projectionTableIds?: string[] }
278
  ) {
279
    const { projectionTableDbNames = [] } = opts ?? {};
2✔
280
    // 1. role operations keywords validation, only pg support
281
    if (this.driver == DriverClient.Pg) {
2✔
282
      validateRoleOperations(sql);
2✔
283
    }
284
    let tableNames = projectionTableDbNames;
2✔
285
    if (!projectionTableDbNames.length) {
2✔
286
      const tables = await this.prismaService.tableMeta.findMany({
1✔
287
        where: {
288
          baseId,
289
        },
290
        select: {
291
          dbTableName: true,
292
        },
293
      });
294
      tableNames = tables.map((table) => table.dbTableName);
1✔
295
    }
296
    // 2. parse sql to valid table names
297
    checkTableAccess(sql, {
2✔
298
      tableNames,
299
      database: this.driver,
300
    });
301
    // 3. read only role check table access, only pg and pg version > 14 support
302
    try {
2✔
303
      await this.readonlyExecuteSql(sql);
2✔
304
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
305
    } catch (error: any) {
NEW
306
      throw new CustomHttpException(
×
307
        `read only check failed: ${error?.meta?.message || error?.message}`,
308
        HttpErrorCode.VALIDATION_ERROR,
309
        {
310
          localization: {
311
            i18nKey: 'httpErrors.baseSqlExecutor.readOnlyCheckFailed',
312
            context: {
313
              message: error?.meta?.message || error?.message,
314
            },
315
          },
316
        }
317
      );
318
    }
319
  }
320

321
  async executeQuerySql<T = unknown>(
322
    baseId: string,
323
    sql: string,
324
    opts?: {
325
      projectionTableDbNames?: string[];
326
      projectionTableIds?: string[];
327
    }
328
  ) {
329
    await this.safeCheckSql(baseId, sql, opts);
2✔
330
    await this.roleCheckAndCreate(baseId);
2✔
331
    return this.prismaService.$tx(async (prisma) => {
2✔
332
      try {
2✔
333
        await this.setRole(prisma, baseId);
2✔
334
        return await prisma.$queryRawUnsafe<T>(sql);
2✔
335
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
336
      } catch (error: any) {
337
        throw new CustomHttpException(
1✔
338
          `execute query sql failed: ${error?.meta?.message || error?.message}`,
339
          HttpErrorCode.VALIDATION_ERROR,
340
          {
341
            localization: {
342
              i18nKey: 'httpErrors.baseSqlExecutor.executeQuerySqlFailed',
343
              context: {
344
                message: error?.meta?.message || error?.message,
345
              },
346
            },
347
          }
348
        );
349
      } finally {
350
        await this.resetRole(prisma).catch((error) => {
2✔
351
          console.log('resetRole error', error);
1✔
352
        });
353
      }
354
    });
355
  }
356
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc