diff --git a/demo/databases/airtable.ts b/demo/databases/airtable.ts index 286e584..db35889 100644 --- a/demo/databases/airtable.ts +++ b/demo/databases/airtable.ts @@ -1,6 +1,6 @@ import 'dotenv/config' import { Logger } from '../../src/helpers/Logger' -import axios from 'axios' +import axios, { AxiosRequestConfig } from 'axios' // Data const Customers = require('./json/Customer.json') @@ -13,6 +13,15 @@ const DOMAIN = 'AIRTABLE' const [apiKey, baseId] = AIRTABLE.split('://')[1].split('@') const logger = new Logger() +const user = { + userId: 1, + email: 'test@test.com', + password: '$2a$10$jm6bM7acpRa18Vdy8FSqIu4yzWAdSgZgRtRrx8zknIeZhSqPJjJU.', + role: 'ADMIN', + firstName: 'Jon', + lastName: 'Doe', +} + const buildUsers = async () => { const table = 'User' @@ -48,6 +57,43 @@ const buildUsers = async () => { }, } + const recordsRequest = { + method: 'POST', + url: `${ENDPOINT}/${baseId}/${table}`, + data: { + records: [ + { + fields: user, + }, + ], + }, + headers: { + Authorization: `Bearer ${apiKey}`, + }, + } + + return await build(table, tableRequest, recordsRequest) +} + +const buildUserApiKey = async userTable => { + const table = 'UserApiKey' + + const tableRequest = { + method: 'POST', + url: `${ENDPOINT}/meta/bases/${baseId}/tables`, + data: { + name: table, + fields: [ + { name: 'id', type: 'number', options: { precision: 0 } }, + { name: 'userId', type: 'multipleRecordLinks', options: { linkedTableId: userTable.id } }, + { name: 'apiKey', type: 'singleLineText' }, + ], + }, + headers: { + Authorization: `Bearer ${apiKey}`, + }, + } + const recordsRequest = { method: 'POST', url: `${ENDPOINT}/${baseId}/${table}`, @@ -55,12 +101,9 @@ const buildUsers = async () => { records: [ { fields: { - userId: 1, - email: 'test@test.com', - password: '$2a$10$jm6bM7acpRa18Vdy8FSqIu4yzWAdSgZgRtRrx8zknIeZhSqPJjJU.', - role: 'ADMIN', - firstName: 'Jon', - lastName: 'Doe', + id: 1, + userId: [userTable.records[0].id], + apiKey: 'Ex@mp1eS$Cu7eAp!K3y', }, }, ], @@ -724,11 +767,7 @@ const buildSalesOrders = async (shipperTable, customerTable, employeeTable) => { return await build(table, tableRequest, recordsRequest) } -const build = async ( - table: string, - tableRequest: axios.AxiosRequestConfig, - recordsRequest: axios.AxiosRequestConfig, -) => { +const build = async (table: string, tableRequest: AxiosRequestConfig, recordsRequest: AxiosRequestConfig) => { let tableResponse try { @@ -814,7 +853,8 @@ const build = async ( const seed = async () => { logger.log('Seeding Airtable database', DOMAIN) - await buildUsers() + const userTable = await buildUsers() + const userApiKeyTable = await buildUserApiKey(userTable) const customerTable = await buildCustomers() const employeeTable = await buildEmployees() const shipperTable = await buildShippers() diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index d86a52c..64bc46e 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -1,4 +1,3 @@ -version: '3.8' name: llana networks: diff --git a/src/app.controller.auth.test.spec.ts b/src/app.controller.auth.test.spec.ts new file mode 100644 index 0000000..bc7054a --- /dev/null +++ b/src/app.controller.auth.test.spec.ts @@ -0,0 +1,152 @@ +import { INestApplication } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { JwtModule } from '@nestjs/jwt' +import { ConfigModule, ConfigService, ConfigFactory } from '@nestjs/config' +import * as request from 'supertest' + +import { AppModule } from './app.module' +import { TIMEOUT } from './testing/testing.const' +import { Logger } from './helpers/Logger' + +// Import configs +import auth from './config/auth.config' +import database from './config/database.config' +import hosts from './config/hosts.config' +import jwt from './config/jwt.config' +import roles from './config/roles.config' +import { envValidationSchema } from './config/env.validation' + +// Type the config imports +const configs: ConfigFactory[] = [auth, database, hosts, jwt, roles] + +describe('App > Controller > Auth', () => { + let app: INestApplication + + let access_token: string + let logger = new Logger() + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + load: configs, + validationSchema: envValidationSchema, + isGlobal: true, + }), + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + secret: configService.get('jwt.secret'), + signOptions: configService.get('jwt.signOptions'), + }), + inject: [ConfigService], + }), + AppModule, + ], + }).compile() + app = moduleRef.createNestApplication() + await app.init() + }, TIMEOUT) + + beforeEach(() => { + logger.debug('===========================================') + logger.log('🧪 ' + expect.getState().currentTestName) + logger.debug('===========================================') + }) + + describe('Failed Login', () => { + it('Missing username', async function () { + const result = await request(app.getHttpServer()) + .post(`/auth/login`) + .send({ + password: 'test', + }) + .expect(400) + expect(result.body).toBeDefined() + expect(result.body.statusCode).toEqual(400) + expect(result.body.message).toEqual('Username is required') + expect(result.body.error).toEqual('Bad Request') + }) + + it('Missing password', async () => { + const result = await request(app.getHttpServer()) + .post(`/auth/login`) + .send({ + username: 'test@test.com', + }) + .expect(400) + expect(result.body).toBeDefined() + expect(result.body.statusCode).toEqual(400) + expect(result.body.message).toEqual('Password is required') + expect(result.body.error).toEqual('Bad Request') + }) + + it('Wrong username', async () => { + const result = await request(app.getHttpServer()) + .post(`/auth/login`) + .send({ + username: 'wrong@username.com', + password: 'test', + }) + .expect(401) + expect(result.body).toBeDefined() + expect(result.body.statusCode).toEqual(401) + expect(result.body.message).toEqual('Unauthorized') + }) + + it('Wrong password', async () => { + const result = await request(app.getHttpServer()) + .post(`/auth/login`) + .send({ + username: 'wrong@username.com', + password: 'wrong', + }) + .expect(401) + expect(result.body).toBeDefined() + expect(result.body.statusCode).toEqual(401) + expect(result.body.message).toEqual('Unauthorized') + }) + }) + + describe('Successful Login', () => { + it('Correct username & password', async () => { + const result = await request(app.getHttpServer()) + .post(`/auth/login`) + .send({ + username: 'test@test.com', + password: 'test', + }) + .expect(200) + expect(result.body).toBeDefined() + expect(result.body.access_token).toBeDefined() + access_token = result.body.access_token + }) + }) + + describe('Access Token Works', () => { + it('Get Profile', async () => { + const result = await request(app.getHttpServer()) + .get(`/auth/profile`) + .set('Authorization', `Bearer ${access_token}`) + .expect(200) + expect(result.body).toBeDefined() + expect(result.body.email).toBeDefined() + }) + + it('Get Profile With Relations', async () => { + const result = await request(app.getHttpServer()) + .get(`/auth/profile?relations=UserApiKey`) + .set('Authorization', `Bearer ${access_token}`) + .expect(200) + expect(result.body).toBeDefined() + expect(result.body.email).toBeDefined() + expect(result.body.UserApiKey).toBeDefined() + expect(result.body.UserApiKey.length).toBeGreaterThan(0) + expect(result.body.UserApiKey[0].apiKey).toBeDefined() + }) + }) + + afterAll(async () => { + await app.close() + }, TIMEOUT) +}) diff --git a/src/app.controller.auth.ts b/src/app.controller.auth.ts index 911efe9..c9088ad 100644 --- a/src/app.controller.auth.ts +++ b/src/app.controller.auth.ts @@ -1,7 +1,19 @@ -import { BadRequestException, Body, Controller, Get, Headers, Post, Req, Res } from '@nestjs/common' +import { + BadRequestException, + Body, + Controller, + Get, + Headers, + ParseArrayPipe, + Post, + Query as QueryParams, + Req, + Res, +} from '@nestjs/common' import { AuthService } from './app.service.auth' import { HeaderParams } from './dtos/requests.dto' +import { FindOneResponseObject } from './dtos/response.dto' import { Authentication } from './helpers/Authentication' import { Query } from './helpers/Query' import { Response } from './helpers/Response' @@ -42,7 +54,13 @@ export class AuthController { */ @Get('/profile') - async profile(@Req() req, @Res() res, @Headers() headers: HeaderParams): Promise { + async profile( + @Req() req, + @Res() res, + @Headers() headers: HeaderParams, + @QueryParams('relations', new ParseArrayPipe({ items: String, separator: ',', optional: true })) + queryRelations?: string[], + ): Promise { if (this.authentication.skipAuth()) { throw new BadRequestException('Authentication is disabled') } @@ -65,6 +83,31 @@ export class AuthController { const schema = await this.schema.getSchema({ table, x_request_id }) const identity_column = await this.authentication.getIdentityColumn(x_request_id) + const postQueryRelations = [] + + try { + if (queryRelations?.length) { + const { valid, message, relations } = await this.schema.validateRelations({ + schema, + relation_query: queryRelations, + existing_relations: [], + x_request_id, + }) + + if (!valid) { + return res.status(400).send(this.response.text(message)) + } + + for (const relation of relations) { + if (!postQueryRelations.find(r => r.table === relation.table)) { + postQueryRelations.push(relation) + } + } + } + } catch (e) { + return res.status(400).send(this.response.text(e.message)) + } + const databaseQuery: DataSourceFindOneOptions = { schema, where: [ @@ -74,9 +117,26 @@ export class AuthController { value: auth.user_identifier, }, ], + relations: postQueryRelations, + } + + let user = (await this.query.perform( + QueryPerform.FIND_ONE, + databaseQuery, + x_request_id, + )) as FindOneResponseObject + + if (postQueryRelations?.length) { + user = await this.query.buildRelations( + { + schema, + relations: postQueryRelations, + } as DataSourceFindOneOptions, + user, + x_request_id, + ) } - const user = await this.query.perform(QueryPerform.FIND_ONE, databaseQuery, x_request_id) return res.status(200).send(user) } } diff --git a/src/app.controller.get.ts b/src/app.controller.get.ts index 6bce737..83d9893 100644 --- a/src/app.controller.get.ts +++ b/src/app.controller.get.ts @@ -52,7 +52,7 @@ export class GetController { return res.status(200).send(await this.query.perform(QueryPerform.LIST_TABLES, undefined, x_request_id)) } - + @Get('*/schema') async getSchema(@Req() req, @Res() res, @Headers() headers: HeaderParams): Promise { const x_request_id = headers['x-request-id'] diff --git a/src/app.service.auth.test.spec.ts b/src/app.service.auth.test.spec.ts deleted file mode 100644 index fc99cb1..0000000 --- a/src/app.service.auth.test.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { INestApplication } from '@nestjs/common' -import { Test } from '@nestjs/testing' -import { ConfigModule, ConfigService, ConfigFactory } from '@nestjs/config' -import { JwtModule } from '@nestjs/jwt' - -import { AppModule } from './app.module' -import { AuthService } from './app.service.auth' -import { Logger } from './helpers/Logger' -import { TIMEOUT } from './testing/testing.const' -import { RolePermission } from './types/roles.types' -import { Authentication } from './helpers/Authentication' - -// Import configs -import auth from './config/auth.config' -import database from './config/database.config' -import hosts from './config/hosts.config' -import jwt from './config/jwt.config' -import roles from './config/roles.config' -import { envValidationSchema } from './config/env.validation' - -// Type the config imports -const configs: ConfigFactory[] = [auth, database, hosts, jwt, roles] - -describe('Login Service', () => { - let app: INestApplication - let service: AuthService - let authentication: Authentication - let logger = new Logger() - - beforeAll(async () => { - const moduleRef = await Test.createTestingModule({ - imports: [ - ConfigModule.forRoot({ - load: configs, - validationSchema: envValidationSchema, - isGlobal: true, - }), - JwtModule.registerAsync({ - imports: [ConfigModule], - useFactory: async (configService: ConfigService) => ({ - secret: configService.get('jwt.secret'), - signOptions: configService.get('jwt.signOptions'), - }), - inject: [ConfigService], - }), - AppModule, - ], - }).compile() - - app = moduleRef.createNestApplication() - await app.init() - - service = app.get(AuthService) - authentication = app.get(Authentication) - }, TIMEOUT) - - beforeEach(() => { - logger.debug('===========================================') - logger.log('🧪 ' + expect.getState().currentTestName) - logger.debug('===========================================') - }) - - describe('Failed Login', () => { - it('Missing username', async () => { - try { - const access_token = await service.signIn('', 'password') - expect(access_token).toBeUndefined() - } catch (e) { - expect(e.message).toBe('Username is required') - } - }) - - it('Missing password', async () => { - try { - const access_token = await service.signIn('test@test.com', '') - expect(access_token).toBeUndefined() - } catch (e) { - expect(e.message).toBe('Password is required') - } - }) - - it('Wrong username', async () => { - try { - const access_token = await service.signIn('wrong@username.com', 'test') - expect(access_token).toBeUndefined() - } catch (e) { - expect(e.message).toBe('Unauthorized') - } - }) - - it('Wrong password', async () => { - try { - const access_token = await service.signIn('test@test.com', 'wrong') - expect(access_token).toBeUndefined() - } catch (e) { - expect(e.message).toBe('Unauthorized') - } - }) - }) - - describe('Successful Login', () => { - it('Correct username & password', async () => { - try { - const result = await service.signIn('test@test.com', 'test') - expect(result).toBeDefined() - expect(result.access_token).toBeDefined() - expect(result.id).toBeDefined() - } catch (e) { - logger.error(e.message) - expect(e).toBeUndefined() - } - }) - }) - - describe('Public Access Integration', () => { - it('should allow public access to Employee table', async () => { - const result = await authentication.auth({ - table: 'Employee', - access: RolePermission.READ, - headers: {}, - body: {}, - query: {}, - }) - expect(result.valid).toBe(true) - }) - - it('should respect permission levels for public access', async () => { - const writeResult = await authentication.auth({ - table: 'Employee', - access: RolePermission.WRITE, - headers: {}, - body: {}, - query: {}, - }) - expect(writeResult.valid).toBe(false) - }) - }) - - afterAll(async () => { - await app.close() - }) -}) diff --git a/src/app.service.bootup.ts b/src/app.service.bootup.ts index 5615a66..e3fc3c4 100644 --- a/src/app.service.bootup.ts +++ b/src/app.service.bootup.ts @@ -286,12 +286,7 @@ export class AppBootup implements OnApplicationBootstrap { }, { custom: false, - role: 'EDITOR', - records: RolePermission.WRITE, - }, - { - custom: false, - role: 'VIEWER', + role: 'USER', records: RolePermission.READ, }, ] @@ -305,14 +300,7 @@ export class AppBootup implements OnApplicationBootstrap { }, { custom: true, - role: 'EDITOR', - table: this.authentication.getIdentityTable(), - records: RolePermission.NONE, - own_records: RolePermission.WRITE, - }, - { - custom: true, - role: 'VIEWER', + role: 'USER', table: this.authentication.getIdentityTable(), records: RolePermission.NONE, own_records: RolePermission.WRITE, @@ -328,16 +316,7 @@ export class AppBootup implements OnApplicationBootstrap { }, { custom: true, - role: 'EDITOR', - table: this.configService.get('AUTH_USER_API_KEY_TABLE_NAME') ?? 'UserApiKey', - identity_column: - this.configService.get('AUTH_USER_API_KEY_TABLE_IDENTITY_COLUMN') ?? 'UserId', - records: RolePermission.NONE, - own_records: RolePermission.WRITE, - }, - { - custom: true, - role: 'VIEWER', + role: 'USER', table: this.configService.get('AUTH_USER_API_KEY_TABLE_NAME') ?? 'UserApiKey', identity_column: this.configService.get('AUTH_USER_API_KEY_TABLE_IDENTITY_COLUMN') ?? 'UserId', diff --git a/src/datasources/airtable.datasource.ts b/src/datasources/airtable.datasource.ts index 8081273..68c331c 100644 --- a/src/datasources/airtable.datasource.ts +++ b/src/datasources/airtable.datasource.ts @@ -189,6 +189,22 @@ export class Airtable { }) } + //Build reverse relations + for (const table of response.tables) { + for (const field of table.fields) { + if (field.type === AirtableColumnType.MULTIPLE_RECORD_LINKS) { + if (field.options.linkedTableId === table.id) { + relations.push({ + table: options.table, + column: field.name, + org_table: table.name, + org_column: 'id', + }) + } + } + } + } + const schema = { table: options.table, columns, diff --git a/src/datasources/mongo.datasource.ts b/src/datasources/mongo.datasource.ts index 8b3a8be..7fec896 100644 --- a/src/datasources/mongo.datasource.ts +++ b/src/datasources/mongo.datasource.ts @@ -186,6 +186,20 @@ export class Mongo { }) } + const relations_back = await mongo.db + .collection(LLANA_RELATION_TABLE) + .find({ table: options.table }) + .toArray() + + for (const relation of relations_back) { + relations.push({ + table: relation.org_table, + column: relation.org_column, + org_table: relation.table, + org_column: relation.column, + }) + } + this.logger.debug( `[${DATABASE_TYPE}] Relations built for collection ${options.table}, relations: ${JSON.stringify(relations.map(r => r.table))}`, ) diff --git a/src/datasources/mssql.datasource.ts b/src/datasources/mssql.datasource.ts index 44079f7..2019d7f 100644 --- a/src/datasources/mssql.datasource.ts +++ b/src/datasources/mssql.datasource.ts @@ -232,15 +232,43 @@ export class MSSQL { } relations.push(relation) + } + + const relation_query_back = `select tab.name as [table], + col.name as [column], + pk_tab.name as org_table, + pk_col.name as org_column + from sys.tables tab + inner join sys.columns col + on col.object_id = tab.object_id + left outer join sys.foreign_key_columns fk_cols + on fk_cols.parent_object_id = tab.object_id + and fk_cols.parent_column_id = col.column_id + left outer join sys.foreign_keys fk + on fk.object_id = fk_cols.constraint_object_id + left outer join sys.tables pk_tab + on pk_tab.object_id = fk_cols.referenced_object_id + left outer join sys.columns pk_col + on pk_col.column_id = fk_cols.referenced_column_id + and pk_col.object_id = fk_cols.referenced_object_id + where pk_tab.name = '${options.table}' AND fk_cols.constraint_column_id = 1;` + + const relation_result_back = ( + await this.performQuery({ + sql: relation_query_back, + x_request_id: options.x_request_id, + }) + ).recordset - const relation_back: DataSourceSchemaRelation = { - table: r.org_table, - column: r.org_column, - org_table: r.table, - org_column: r.column, + for (const r of relation_result_back) { + const relation: DataSourceSchemaRelation = { + table: r.table, + column: r.column, + org_table: r.org_table, + org_column: r.org_column, } - relations.push(relation_back) + relations.push(relation) } return { @@ -605,26 +633,10 @@ export class MSSQL { } else { command += ` ${this.reserveWordFix(options.schema.table)}.* ` } - - if (options.relations?.length) { - for (const r in options.relations) { - if (options.relations[r].columns?.length) { - for (const c in options.relations[r].columns) { - command += `, ${this.reserveWordFix(options.relations[r].table)}.${options.relations[r].columns[c]} as ${this.reserveWordFix(options.relations[r].table)}.${options.relations[r].columns[c]} ` - } - } - } - } } command += ` FROM ${this.reserveWordFix(table_name)} ` - if (options.relations?.length) { - for (const relation of options.relations) { - command += `${relation.join?.type ?? 'INNER JOIN'} ${this.reserveWordFix(relation.join.table)} ON ${this.reserveWordFix(relation.join.org_table)}.${this.reserveWordFix(relation.join.org_column)} = ${this.reserveWordFix(relation.join.table)}.${this.reserveWordFix(relation.join.column)} ` - } - } - if (options.where?.length) { command += `WHERE ` @@ -646,28 +658,6 @@ export class MSSQL { } } - for (const r in options.relations) { - if (options.relations[r].where) { - const items = options.relations[r].where.column.split('.') - - switch (items.length) { - case 1: - command += `AND \`${this.reserveWordFix(options.relations[r].table)}\`.\`${this.reserveWordFix(options.relations[r].where.column)}\` ${options.relations[r].where.operator} ? ` - break - case 2: - command += `AND \`${items[0]}\`.\`${items[1]}\` ${options.relations[r].where.operator} ? ` - break - default: - command += `AND \`${items[items.length - 2]}\`.\`${items[items.length - 1]}\` ${options.relations[r].where.operator} ? ` - break - } - - if (options.relations[r].where.value) { - values.push(options.relations[r].where.value) - } - } - } - if (!count) { let sort: SortCondition[] = [] diff --git a/src/datasources/mysql.datasource.ts b/src/datasources/mysql.datasource.ts index cce3893..b2e2cbb 100644 --- a/src/datasources/mysql.datasource.ts +++ b/src/datasources/mysql.datasource.ts @@ -171,11 +171,11 @@ export class MySQL { sql: relation_back_query, x_request_id: options.x_request_id, }) - const relation_back = relation_back_result + const relations_back = relation_back_result .filter((row: DataSourceSchemaRelation) => row.table !== null) .map((row: DataSourceSchemaRelation) => row) - relations.push(...relation_back) + relations.push(...relations_back) return { table: options.table, @@ -645,26 +645,10 @@ export class MySQL { } else { command += ` \`${options.schema.table}\`.* ` } - - if (options.relations?.length) { - for (const r in options.relations) { - if (options.relations[r].columns?.length) { - for (const c in options.relations[r].columns) { - command += `, \`${options.relations[r].table}\`.\`${options.relations[r].columns[c]}\` as \`${options.relations[r].table}.${options.relations[r].columns[c]}\` ` - } - } - } - } } command += ` FROM ${table_name} ` - if (options.relations?.length) { - for (const relation of options.relations) { - command += `${relation.join.type ?? 'INNER JOIN'} ${relation.join.table} ON ${relation.join.org_table}.${relation.join.org_column} = ${relation.join.table}.${relation.join.column} ` - } - } - if (options.where?.length) { command += `WHERE ` @@ -686,28 +670,6 @@ export class MySQL { } } - for (const r in options.relations) { - if (options.relations[r].where) { - const items = options.relations[r].where.column.split('.') - - switch (items.length) { - case 1: - command += `AND \`${options.relations[r].table}\`.\`${options.relations[r].where.column}\` ${options.relations[r].where.operator} ? ` - break - case 2: - command += `AND \`${items[0]}\`.\`${items[1]}\` ${options.relations[r].where.operator} ? ` - break - default: - command += `AND \`${items[items.length - 2]}\`.\`${items[items.length - 1]}\` ${options.relations[r].where.operator} ? ` - break - } - - if (options.relations[r].where.value) { - values.push(options.relations[r].where.value) - } - } - } - return [command.trim(), values] } } diff --git a/src/datasources/postgres.datasource.ts b/src/datasources/postgres.datasource.ts index 25b0421..b29d75a 100644 --- a/src/datasources/postgres.datasource.ts +++ b/src/datasources/postgres.datasource.ts @@ -201,19 +201,21 @@ export class Postgres { .filter((row: DataSourceSchemaRelation) => row.table !== null) .map((row: DataSourceSchemaRelation) => row) - for (const relation of relations) { - const existingRelation = relations.find( - r => - r.table === relation.table && - r.column === relation.column && - r.org_table === relation.org_table && - r.org_column === relation.org_column, - ) + const relations_back_query = `SELECT tc.table_name AS "table", kcu.column_name AS "column", ccu.table_name AS "org_table", ccu.column_name AS "org_column" + FROM information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name + JOIN information_schema.constraint_column_usage AS ccu ON ccu.constraint_name = tc.constraint_name + WHERE tc.constraint_type = 'FOREIGN KEY' AND ccu.table_name = '${options.table}';` - if (!existingRelation) { - relations.push(relation) - } - } + const relation_back_result = await this.performQuery({ + sql: relations_back_query, + x_request_id: options.x_request_id, + }) + const relations_back = relation_back_result + .filter((row: DataSourceSchemaRelation) => row.table !== null) + .map((row: DataSourceSchemaRelation) => row) + + relations.push(...relations_back) return { table: options.table, @@ -512,26 +514,10 @@ export class Postgres { } else { command += ` "${options.schema.table}".* ` } - - if (options.relations?.length) { - for (const r in options.relations) { - if (options.relations[r].columns?.length) { - for (const c in options.relations[r].columns) { - command += `, "${options.relations[r].table}"."${options.relations[r].columns[c]}" as "${options.relations[r].table}.${options.relations[r].columns[c]}" ` - } - } - } - } } command += ` FROM "${table_name}" ` - if (options.relations?.length) { - for (const relation of options.relations) { - command += `${relation.join.type ?? 'INNER JOIN'} "${relation.join.table}" ON "${relation.join.org_table}"."${relation.join.org_column}" = "${relation.join.table}"."${relation.join.column}" ` - } - } - if (options.where?.length) { command += `WHERE ` @@ -567,30 +553,6 @@ export class Postgres { } } - for (const r in options.relations) { - if (options.relations[r].where) { - const items = options.relations[r].where.column.split('.') - - switch (items.length) { - case 1: - command += `AND "${options.relations[r].table}"."${options.relations[r].where.column}" ${options.relations[r].where.operator} $${index} ` - break - case 2: - command += `AND "${items[0]}"."${items[1]}" ${options.relations[r].where.operator} $${index} ` - break - default: - command += `AND "${items[items.length - 2]}"."${items[items.length - 1]}" ${options.relations[r].where.operator} $${index} ` - break - } - - if (options.relations[r].where.value) { - values.push(options.relations[r].where.value) - } - - index++ - } - } - return [command.trim(), values] } diff --git a/src/helpers/Authentication.ts b/src/helpers/Authentication.ts index 68f3201..532c786 100644 --- a/src/helpers/Authentication.ts +++ b/src/helpers/Authentication.ts @@ -63,8 +63,8 @@ export class Authentication { } // Check if table exists first - - if(!options.skip_table_checks){ + + if (!options.skip_table_checks) { try { await this.schema.getSchema({ table: options.table, x_request_id: options.x_request_id }) } catch (error) { @@ -73,15 +73,14 @@ export class Authentication { } } - const authentications = this.configService.get('auth') - const auth_schema = await this.schema.getSchema({ table: LLANA_AUTH_TABLE, x_request_id: options.x_request_id }) + const authentications = this.configService.get('auth') + const auth_schema = await this.schema.getSchema({ table: LLANA_AUTH_TABLE, x_request_id: options.x_request_id }) - let auth_passed: AuthRestrictionsResponse = { - valid: false, - message: 'Unauthorized', - } + let auth_passed: AuthRestrictionsResponse = { + valid: false, + message: 'Unauthorized', + } - for (const auth of authentications) { if (auth_passed.valid) continue @@ -121,8 +120,7 @@ export class Authentication { ) } - if(!options.skip_table_checks){ - + if (!options.skip_table_checks) { const excludes = rules.data.filter(rule => rule.type === 'EXCLUDE') const includes = rules.data.filter(rule => rule.type === 'INCLUDE') @@ -151,7 +149,9 @@ export class Authentication { } else { // For non-READ operations on excluded tables, require authentication const authHeader = options.headers['Authorization'] || options.headers['authorization'] - this.logger.debug(`[Authentication][auth] Auth header for write operation: ${authHeader}`) + this.logger.debug( + `[Authentication][auth] Auth header for write operation: ${authHeader}`, + ) if (!authHeader) { return { @@ -180,7 +180,9 @@ export class Authentication { return auth_passed } } catch (error) { - this.logger.debug(`[Authentication][auth] JWT verification failed: ${error.message}`) + this.logger.debug( + `[Authentication][auth] JWT verification failed: ${error.message}`, + ) return { valid: false, message: 'JWT Authentication Failed', @@ -199,7 +201,6 @@ export class Authentication { } } } - } if (!check_required) continue @@ -214,8 +215,7 @@ export class Authentication { let schema: DataSourceSchema | null = null let identity_column: string | null = null - if(!options.skip_table_checks){ - + if (!options.skip_table_checks) { try { schema = await this.schema.getSchema({ table: options.table, x_request_id: options.x_request_id }) if (!schema) { @@ -241,7 +241,6 @@ export class Authentication { this.logger.debug(`[Authentication][auth] No identity column found for table ${options.table}`) return { valid: false, message: `No identity column found for table ${options.table}` } } - } switch (auth.type) { diff --git a/src/helpers/Schema.ts b/src/helpers/Schema.ts index 094c88c..2afc28d 100644 --- a/src/helpers/Schema.ts +++ b/src/helpers/Schema.ts @@ -15,7 +15,6 @@ import { Postgres } from '../datasources/postgres.datasource' import { DataSourceColumnType, DataSourceFindOneOptions, - DataSourceoinType, DataSourceRelations, DataSourceSchema, DataSourceType, @@ -402,7 +401,10 @@ export class Schema { validated.push(rel) } } else { - if (!options.schema.relations.find(col => col.table === relation)) { + if ( + !options.schema.relations.find(col => col.table === relation) && + !options.schema.relations.find(col => col.org_table === relation) + ) { return { valid: false, message: `Relation ${relation} not found in table schema for ${options.schema.table} `, @@ -418,12 +420,17 @@ export class Schema { x_request_id: options.x_request_id, }) + let join + + if (options.schema.relations.find(col => col.table === relation)) { + join = options.schema.relations.find(col => col.table === relation) + } else if (options.schema.relations.find(col => col.org_table === relation)) { + join = options.schema.relations.find(col => col.org_table === relation) + } + validated.push({ table: relation, - join: { - ...options.schema.relations.find(col => col.table === relation), - type: DataSourceoinType.INNER, - }, + join, columns: relation_schema.columns.map(col => col.field), schema: relation_schema, }) @@ -596,7 +603,6 @@ export class Schema { table: items[i], join: { ...options.schema.relations.find(col => col.table === items[i]), - type: DataSourceoinType.INNER, }, where: i === items.length - 2 ? options.where : undefined, schema: relation_schema, @@ -641,12 +647,17 @@ export class Schema { options.relations[index].columns.push(items[items.length - 1]) } } else { + let join + + if (options.schema.relations.find(col => col.table === items[i])) { + join = options.schema.relations.find(col => col.table === items[i]) + } else if (options.schema.relations.find(col => col.org_table === items[i])) { + join = options.schema.relations.find(col => col.org_table === items[i]) + } + options.relations.push({ table: items[i], - join: { - ...options.schema.relations.find(col => col.table === items[i]), - type: DataSourceoinType.INNER, - }, + join, columns: i === items.length - 2 ? [items[items.length - 1]] : undefined, schema: relation_schema, }) @@ -684,12 +695,17 @@ export class Schema { const relation_schema = await this.getSchema({ table: items[i], x_request_id: options.x_request_id }) + let join + + if (options.schema.relations.find(col => col.table === items[i])) { + join = options.schema.relations.find(col => col.table === items[i]) + } else if (options.schema.relations.find(col => col.org_table === items[i])) { + join = options.schema.relations.find(col => col.org_table === items[i]) + } + relations.push({ table: items[i], - join: { - ...options.schema.relations.find(col => col.table === items[i]), - type: DataSourceoinType.INNER, - }, + join, columns: i === items.length - 1 ? [items[items.length]] : undefined, schema: relation_schema, }) diff --git a/src/main.ts b/src/main.ts index 27b973d..189b7b7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -26,7 +26,7 @@ async function bootstrap() { } app.enableCors({ - origin: true + origin: true, }) await app.listen(process.env.PORT) diff --git a/src/testing/auth.testing.service.ts b/src/testing/auth.testing.service.ts index 951a9a4..9a3ee0f 100644 --- a/src/testing/auth.testing.service.ts +++ b/src/testing/auth.testing.service.ts @@ -1,11 +1,10 @@ import { Injectable } from '@nestjs/common' + import { AuthService } from '../app.service.auth' @Injectable() export class AuthTestingService { - constructor( - private readonly authService: AuthService - ) {} + constructor(private readonly authService: AuthService) {} async login(): Promise { try { @@ -13,7 +12,6 @@ export class AuthTestingService { const password = 'test' const payload = await this.authService.signIn(username, password) return payload.access_token - } catch (error) { console.error('Login failed:', error) throw error diff --git a/src/types/datasource.types.ts b/src/types/datasource.types.ts index d1747cf..516ab7a 100644 --- a/src/types/datasource.types.ts +++ b/src/types/datasource.types.ts @@ -76,17 +76,6 @@ export declare enum ChartsPeriod { YEAR = 'YEAR', } -export enum DataSourceoinType { - INNER = 'INNER JOIN', - LEFT = 'LEFT JOIN', - RIGHT = 'RIGHT JOIN', -} - -export enum DataSourceJoinStage { - WITH_QUERY = 'WITH_QUERY', - POST_QUERY = 'POST_QUERY', -} - export interface ChartResult { count: number [key: string]: any @@ -137,13 +126,14 @@ export interface DataSourceCreateOneOptions { data: object } -export interface DataSourceJoin extends DataSourceSchemaRelation { - type?: DataSourceoinType -} - export interface DataSourceRelations { table: string - join: DataSourceJoin + join: { + table: string + column: string + org_table: string + org_column: string + } columns?: string[] where?: DataSourceWhere schema: DataSourceSchema