Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/common/data-injection.tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,12 @@ export enum UseCaseType {
AGENTS_VALIDATE_TABLE_AI_REQUEST = 'AGENTS_VALIDATE_TABLE_AI_REQUEST',
AGENTS_VALIDATE_CONNECTION_EDIT = 'AGENTS_VALIDATE_CONNECTION_EDIT',
AGENTS_GET_AI_CONNECTION_CONTEXT = 'AGENTS_GET_AI_CONNECTION_CONTEXT',
AGENTS_GET_AI_CONNECTION_TABLES = 'AGENTS_GET_AI_CONNECTION_TABLES',
AGENTS_GET_AI_TABLE_STRUCTURE = 'AGENTS_GET_AI_TABLE_STRUCTURE',
AGENTS_EXECUTE_AI_RAW_QUERY = 'AGENTS_EXECUTE_AI_RAW_QUERY',
AGENTS_EXECUTE_AI_AGGREGATION_PIPELINE = 'AGENTS_EXECUTE_AI_AGGREGATION_PIPELINE',
AGENTS_SCAN_AND_CREATE_SETTINGS = 'AGENTS_SCAN_AND_CREATE_SETTINGS',
AGENTS_GET_COMPANY_SUBSCRIPTION_INFO = 'AGENTS_GET_COMPANY_SUBSCRIPTION_INFO',

CREATE_TABLE_FILTERS = 'CREATE_TABLE_FILTERS',
FIND_TABLE_FILTERS = 'FIND_TABLE_FILTERS',
Expand Down
5 changes: 5 additions & 0 deletions backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { WinstonLogger } from './entities/logging/winston-logger.js';
import { AllExceptionsFilter } from './exceptions/all-exceptions.filter.js';
import { ValidationException } from './exceptions/custom-exceptions/validation-exception.js';
import { Constants } from './helpers/constants/constants.js';
import { publicCrudCorsMiddleware } from './middlewares/public-crud-cors.middleware.js';
import { appConfig } from './shared/config/app-config.js';

async function bootstrap() {
Expand All @@ -38,6 +39,10 @@ async function bootstrap() {

app.use(helmet());

// Wildcard CORS for the public table CRUD routes — registered before the global enableCors()
// so it owns these routes (including the OPTIONS preflight) before the global allowlist runs.
app.use(publicCrudCorsMiddleware);

app.enableCors({
origin: [
'https://app.autoadmin.org',
Expand Down
34 changes: 34 additions & 0 deletions backend/src/microservices/agents-microservice/agents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import { isTest } from '../../helpers/app/is-test.js';
import { SentryInterceptor } from '../../interceptors/sentry.interceptor.js';
import {
AiConnectionContextRO,
AiConnectionTablesRO,
AiQueryResultRO,
CompanySubscriptionInfoRO,
PermissionAllowedRO,
ValidatedUserTokenRO,
} from './data-structures/agents-responses.ds.js';
Expand All @@ -21,11 +23,14 @@ import {
GetAiTableStructureDto,
} from './dto/agents-ai-data.dtos.js';
import { ValidateConnectionEditDto, ValidateTableAiRequestDto, ValidateUserTokenDto } from './dto/agents-auth.dtos.js';
import { GetCompanySubscriptionInfoDto } from './dto/agents-company.dtos.js';
import {
IExecuteAiAggregationPipeline,
IExecuteAiRawQuery,
IGetAiConnectionContext,
IGetAiConnectionTables,
IGetAiTableStructure,
IGetCompanySubscriptionInfo,
IScanAndCreateSettings,
IValidateConnectionEdit,
IValidateTableAiRequest,
Expand All @@ -48,6 +53,8 @@ export class AgentsController {
private readonly validateConnectionEditUseCase: IValidateConnectionEdit,
@Inject(UseCaseType.AGENTS_GET_AI_CONNECTION_CONTEXT)
private readonly getAiConnectionContextUseCase: IGetAiConnectionContext,
@Inject(UseCaseType.AGENTS_GET_AI_CONNECTION_TABLES)
private readonly getAiConnectionTablesUseCase: IGetAiConnectionTables,
@Inject(UseCaseType.AGENTS_GET_AI_TABLE_STRUCTURE)
private readonly getAiTableStructureUseCase: IGetAiTableStructure,
@Inject(UseCaseType.AGENTS_EXECUTE_AI_RAW_QUERY)
Expand All @@ -56,6 +63,8 @@ export class AgentsController {
private readonly executeAiAggregationPipelineUseCase: IExecuteAiAggregationPipeline,
@Inject(UseCaseType.AGENTS_SCAN_AND_CREATE_SETTINGS)
private readonly scanAndCreateSettingsUseCase: IScanAndCreateSettings,
@Inject(UseCaseType.AGENTS_GET_COMPANY_SUBSCRIPTION_INFO)
private readonly getCompanySubscriptionInfoUseCase: IGetCompanySubscriptionInfo,
) {}

@ApiOperation({ summary: 'Validate an end-user JWT on behalf of the agents microservice' })
Expand Down Expand Up @@ -102,6 +111,21 @@ export class AgentsController {
);
}

@ApiOperation({ summary: 'List connection tables the user may read (grounds website feasibility)' })
@ApiResponse({ status: 201, type: AiConnectionTablesRO })
@ApiBody({ type: AiDataRequestBaseDto })
@Timeout(!isTest() ? TimeoutDefaults.EXTENDED : TimeoutDefaults.EXTENDED_TEST)
@Post('/ai/data/:connectionId/tables')
public async getAiConnectionTables(
@SlugUuid('connectionId') connectionId: string,
@Body() body: AiDataRequestBaseDto,
): Promise<AiConnectionTablesRO> {
return await this.getAiConnectionTablesUseCase.execute(
{ connectionId, userId: body.userId, masterPassword: body.masterPassword ?? null },
InTransactionEnum.OFF,
);
}

@ApiOperation({ summary: 'Get permission-aware table structure for the AI tool loop' })
@ApiResponse({ status: 201, description: 'Table structure with related tables.' })
@ApiBody({ type: GetAiTableStructureDto })
Expand Down Expand Up @@ -184,4 +208,14 @@ export class AgentsController {
InTransactionEnum.OFF,
);
}

@ApiOperation({ summary: "Read a user's company subscription metadata (agents-core owns all feature policy)" })
@ApiResponse({ status: 201, type: CompanySubscriptionInfoRO })
@ApiBody({ type: GetCompanySubscriptionInfoDto })
@Post('/company/subscription-info')
public async getCompanySubscriptionInfo(
@Body() body: GetCompanySubscriptionInfoDto,
): Promise<CompanySubscriptionInfoRO> {
return await this.getCompanySubscriptionInfoUseCase.execute({ userId: body.userId }, InTransactionEnum.OFF);
}
}
10 changes: 10 additions & 0 deletions backend/src/microservices/agents-microservice/agents.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { AgentsController } from './agents.controller.js';
import { ExecuteAiAggregationPipelineUseCase } from './use-cases/execute-ai-aggregation-pipeline.use.case.js';
import { ExecuteAiRawQueryUseCase } from './use-cases/execute-ai-raw-query.use.case.js';
import { GetAiConnectionContextUseCase } from './use-cases/get-ai-connection-context.use.case.js';
import { GetAiConnectionTablesUseCase } from './use-cases/get-ai-connection-tables.use.case.js';
import { GetAiTableStructureUseCase } from './use-cases/get-ai-table-structure.use.case.js';
import { GetCompanySubscriptionInfoUseCase } from './use-cases/get-company-subscription-info.use.case.js';
import { ScanAndCreateSettingsUseCase } from './use-cases/scan-and-create-settings.use.case.js';
import { ValidateConnectionEditUseCase } from './use-cases/validate-connection-edit.use.case.js';
import { ValidateTableAiRequestUseCase } from './use-cases/validate-table-ai-request.use.case.js';
Expand Down Expand Up @@ -36,6 +38,10 @@ import { ValidateUserTokenUseCase } from './use-cases/validate-user-token.use.ca
provide: UseCaseType.AGENTS_GET_AI_CONNECTION_CONTEXT,
useClass: GetAiConnectionContextUseCase,
},
{
provide: UseCaseType.AGENTS_GET_AI_CONNECTION_TABLES,
useClass: GetAiConnectionTablesUseCase,
},
{
provide: UseCaseType.AGENTS_GET_AI_TABLE_STRUCTURE,
useClass: GetAiTableStructureUseCase,
Expand All @@ -52,6 +58,10 @@ import { ValidateUserTokenUseCase } from './use-cases/validate-user-token.use.ca
provide: UseCaseType.AGENTS_SCAN_AND_CREATE_SETTINGS,
useClass: ScanAndCreateSettingsUseCase,
},
{
provide: UseCaseType.AGENTS_GET_COMPANY_SUBSCRIPTION_INFO,
useClass: GetCompanySubscriptionInfoUseCase,
},
],
controllers: [AgentsController],
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,22 @@ export class AiQueryResultRO {
@ApiProperty()
result: unknown;
}

export class AiConnectionTablesRO {
@ApiProperty({ type: [String], description: 'Table names the user is permitted to read on the connection.' })
tables: Array<string>;
}

export class CompanySubscriptionInfoRO {
@ApiProperty({ description: 'Whether the backend is running in SaaS mode. When false, no subscription applies.' })
isSaaS: boolean;

@ApiPropertyOptional({ nullable: true })
companyId: string | null;

@ApiPropertyOptional({ nullable: true, description: 'FREE_PLAN | TEAM_PLAN | ENTERPRISE_PLAN | ANNUAL_* | null' })
subscriptionLevel: string | null;
Comment on lines +56 to +57

@ApiProperty()
isPaymentMethodAdded: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,7 @@ export class ExecuteAiAggregationPipelineDs extends AiDataRequestDs {
export class ScanAndCreateSettingsDs extends AiDataRequestDs {
response: Response;
}

export class GetCompanySubscriptionInfoDs {
userId: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';

export class GetCompanySubscriptionInfoDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
userId: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ import {
ExecuteAiAggregationPipelineDs,
ExecuteAiRawQueryDs,
GetAiTableStructureDs,
GetCompanySubscriptionInfoDs,
ScanAndCreateSettingsDs,
ValidateConnectionEditDs,
ValidateTableAiRequestDs,
} from '../data-structures/agents.ds.js';
import {
AiConnectionContextRO,
AiConnectionTablesRO,
AiQueryResultRO,
CompanySubscriptionInfoRO,
PermissionAllowedRO,
ValidatedUserTokenRO,
} from '../data-structures/agents-responses.ds.js';
Expand All @@ -35,6 +38,10 @@ export interface IGetAiTableStructure {
execute(inputData: GetAiTableStructureDs, inTransaction: InTransactionEnum): Promise<Record<string, unknown>>;
}

export interface IGetAiConnectionTables {
execute(inputData: AiDataRequestDs, inTransaction: InTransactionEnum): Promise<AiConnectionTablesRO>;
}

export interface IExecuteAiRawQuery {
execute(inputData: ExecuteAiRawQueryDs, inTransaction: InTransactionEnum): Promise<AiQueryResultRO>;
}
Expand All @@ -46,3 +53,10 @@ export interface IExecuteAiAggregationPipeline {
export interface IScanAndCreateSettings {
execute(inputData: ScanAndCreateSettingsDs, inTransaction: InTransactionEnum): Promise<void>;
}

export interface IGetCompanySubscriptionInfo {
execute(
inputData: GetCompanySubscriptionInfoDs,
inTransaction: InTransactionEnum,
): Promise<CompanySubscriptionInfoRO>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Inject, Injectable, Scope } from '@nestjs/common';
import AbstractUseCase from '../../../common/abstract-use.case.js';
import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js';
import { BaseType } from '../../../common/data-injection.tokens.js';
import { CedarPermissionsService } from '../../../entities/cedar-authorization/cedar-permissions.service.js';
import { AiDataRequestDs } from '../data-structures/agents.ds.js';
import { AiConnectionTablesRO } from '../data-structures/agents-responses.ds.js';
import { setupAiConnection } from '../utils/ai-data-access.helpers.js';
import { IGetAiConnectionTables } from './agents-use-cases.interface.js';

@Injectable({ scope: Scope.REQUEST })
export class GetAiConnectionTablesUseCase
extends AbstractUseCase<AiDataRequestDs, AiConnectionTablesRO>
implements IGetAiConnectionTables
{
constructor(
@Inject(BaseType.GLOBAL_DB_CONTEXT)
protected _dbContext: IGlobalDatabaseContext,
private readonly cedarPermissions: CedarPermissionsService,
) {
super();
}

protected async implementation(inputData: AiDataRequestDs): Promise<AiConnectionTablesRO> {
const { connectionId, userId, masterPassword } = inputData;

const { foundConnection, dataAccessObject } = await setupAiConnection(
this._dbContext,
connectionId,
masterPassword,
userId,
);

const tables = await dataAccessObject.getTablesFromDB();
const tableNames = tables.map((table) => table.tableName?.trim()).filter((name): name is string => Boolean(name));
Comment on lines +27 to +35

const readableFlags = await Promise.all(
tableNames.map((tableName) =>
this.cedarPermissions.improvedCheckTableRead(userId, foundConnection.id, tableName),
),
);
Comment on lines +37 to +41
const readableTableNames = tableNames.filter((_name, index) => readableFlags[index]);

return { tables: readableTableNames };
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Inject, Injectable, NotFoundException, Scope } from '@nestjs/common';
import AbstractUseCase from '../../../common/abstract-use.case.js';
import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js';
import { BaseType } from '../../../common/data-injection.tokens.js';
import { SubscriptionLevelEnum } from '../../../enums/subscription-level.enum.js';
import { Messages } from '../../../exceptions/text/messages.js';
import { isSaaS } from '../../../helpers/app/is-saas.js';
import { isTest } from '../../../helpers/app/is-test.js';
import { SaasCompanyGatewayService } from '../../gateways/saas-gateway.ts/saas-company-gateway.service.js';
import { GetCompanySubscriptionInfoDs } from '../data-structures/agents.ds.js';
import { CompanySubscriptionInfoRO } from '../data-structures/agents-responses.ds.js';
import { IGetCompanySubscriptionInfo } from './agents-use-cases.interface.js';

/**
* Thin metadata provider for the agents microservice: resolves a user's company subscription level
* via the saas gateway. The agents service (agents-core) owns all website-generation policy
* (model tier, hosting caps, quota enforcement) and only reads this subscription metadata from here.
*/
@Injectable({ scope: Scope.REQUEST })
export class GetCompanySubscriptionInfoUseCase
extends AbstractUseCase<GetCompanySubscriptionInfoDs, CompanySubscriptionInfoRO>
implements IGetCompanySubscriptionInfo
{
constructor(
@Inject(BaseType.GLOBAL_DB_CONTEXT)
protected _dbContext: IGlobalDatabaseContext,
private readonly saasCompanyGatewayService: SaasCompanyGatewayService,
) {
super();
}

protected async implementation(inputData: GetCompanySubscriptionInfoDs): Promise<CompanySubscriptionInfoRO> {
const { userId } = inputData;

// Self-hosted / non-SaaS / test runs have no subscription concept.
if (!isSaaS() || isTest()) {
return { isSaaS: false, companyId: null, subscriptionLevel: null, isPaymentMethodAdded: false };
}

const company = await this._dbContext.companyInfoRepository.findCompanyInfoByUserId(userId);
if (!company) {
throw new NotFoundException(Messages.COMPANY_NOT_FOUND);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const companyInfo = await this.saasCompanyGatewayService.getCompanyInfo(company.id);
return {
isSaaS: true,
companyId: company.id,
subscriptionLevel: companyInfo?.subscriptionLevel ?? SubscriptionLevelEnum.FREE_PLAN,
isPaymentMethodAdded: companyInfo?.is_payment_method_added ?? false,
};
}
}
50 changes: 50 additions & 0 deletions backend/src/middlewares/public-crud-cors.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { NextFunction, Request, Response } from 'express';

const PUBLIC_CRUD_ROUTE_REGEX = /\/table\/crud(\/|$)/;

// `scheme://host[:port]` (or the literal "null" origin). Deliberately strict: any value that does
// not look like a real origin is dropped rather than reflected.
const VALID_ORIGIN_REGEX = /^(null|[a-z][a-z0-9+.-]*:\/\/[a-z0-9.-]+(:\d+)?)$/i;

// The charset allowed in an HTTP header field-name list (what a browser sends in
// Access-Control-Request-Headers). Excludes CR/LF and anything outside the token grammar.
const VALID_HEADER_LIST_REGEX = /^[a-z0-9,\- ]+$/i;

const DEFAULT_ALLOWED_HEADERS = 'Content-Type, Authorization, x-api-key, masterpwd';

/**
* Wildcard CORS for the public table CRUD routes (TablePureCrudOperationsController).
*
* These endpoints support public / api-key access and may be called from any origin.
* A literal `Access-Control-Allow-Origin: *` cannot be combined with credentials, so we reflect
* the request's Origin back instead — this allows any origin while still permitting cookie /
* credentialed requests. Must be registered before the global enableCors() so it owns these
* routes (including answering the OPTIONS preflight) before the global allowlist runs.
*
* Reflected request values (Origin, Access-Control-Request-Headers) are validated against strict
* allowlists before being echoed back, so no untrusted input reaches a response header
* (defense-in-depth against header injection / CWE-113).
*/
export function publicCrudCorsMiddleware(req: Request, res: Response, next: NextFunction): void {
if (PUBLIC_CRUD_ROUTE_REGEX.test(req.path)) {
const requestOrigin = req.headers.origin;
if (requestOrigin && VALID_ORIGIN_REGEX.test(requestOrigin)) {
const requestedHeaders = req.headers['access-control-request-headers'];
const allowedHeaders =
typeof requestedHeaders === 'string' && VALID_HEADER_LIST_REGEX.test(requestedHeaders)
? requestedHeaders
: DEFAULT_ALLOWED_HEADERS;

res.header('Access-Control-Allow-Origin', requestOrigin);
res.header('Access-Control-Allow-Credentials', 'true');
res.header('Vary', 'Origin');
res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE');
res.header('Access-Control-Allow-Headers', allowedHeaders);
}
if (req.method === 'OPTIONS') {
res.sendStatus(204);
return;
}
}
next();
}
Loading
Loading