diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 97dd6939b..bab64c6d7 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -46,6 +46,7 @@ import { TableWidgetModule } from './entities/widget/table-widget.module.js'; import { AgentsModule } from './microservices/agents-microservice/agents.module.js'; import { SaaSGatewayModule } from './microservices/gateways/saas-gateway.ts/saas-gateway.module.js'; import { SaasModule } from './microservices/saas-microservice/saas.module.js'; +import { SitenovaModule } from './microservices/sitenova-microservice/sitenova.module.js'; import { AppLoggerMiddleware } from './middlewares/logging-middleware/app-logger-middlewate.js'; import { SelfHostedOperationsModule } from './selfhosted-operations/selhosted-operations.module.js'; import { ConfigModule } from './shared/config/config.module.js'; @@ -86,6 +87,7 @@ import { GetHelloUseCase } from './use-cases-app/get-hello.use.case.js'; TableActionModule, SaasModule, AgentsModule, + SitenovaModule, CompanyInfoModule, SaaSGatewayModule, TableTriggersModule, diff --git a/backend/src/common/data-injection.tokens.ts b/backend/src/common/data-injection.tokens.ts index 42baaa4fb..a37a440ae 100644 --- a/backend/src/common/data-injection.tokens.ts +++ b/backend/src/common/data-injection.tokens.ts @@ -188,6 +188,10 @@ export enum UseCaseType { AGENTS_SCAN_AND_CREATE_SETTINGS = 'AGENTS_SCAN_AND_CREATE_SETTINGS', AGENTS_GET_COMPANY_SUBSCRIPTION_INFO = 'AGENTS_GET_COMPANY_SUBSCRIPTION_INFO', + SITENOVA_EXECUTE_RAW_QUERY = 'SITENOVA_EXECUTE_RAW_QUERY', + SITENOVA_REGISTER_ENDUSER = 'SITENOVA_REGISTER_ENDUSER', + SITENOVA_LOGIN_ENDUSER = 'SITENOVA_LOGIN_ENDUSER', + CREATE_TABLE_FILTERS = 'CREATE_TABLE_FILTERS', FIND_TABLE_FILTERS = 'FIND_TABLE_FILTERS', DELETE_TABLE_FILTERS = 'DELETE_TABLE_FILTERS', diff --git a/backend/src/microservices/sitenova-microservice/data-structures/sitenova-responses.ds.ts b/backend/src/microservices/sitenova-microservice/data-structures/sitenova-responses.ds.ts new file mode 100644 index 000000000..29f9c512e --- /dev/null +++ b/backend/src/microservices/sitenova-microservice/data-structures/sitenova-responses.ds.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SitenovaRawQueryResultRO { + @ApiProperty({ + description: 'Raw result returned by the connected database for the executed statement.', + }) + result: unknown; +} diff --git a/backend/src/microservices/sitenova-microservice/data-structures/sitenova-site.ds.ts b/backend/src/microservices/sitenova-microservice/data-structures/sitenova-site.ds.ts new file mode 100644 index 000000000..212689e90 --- /dev/null +++ b/backend/src/microservices/sitenova-microservice/data-structures/sitenova-site.ds.ts @@ -0,0 +1,39 @@ +// Audience claim that marks a token as a SiteNova generated-site end-user token (a site visitor), +// keeping it cryptographically and logically separate from RocketAdmin platform user tokens. +export const SITENOVA_ENDUSER_AUDIENCE = 'sitenova:enduser'; + +// Token lifetime for generated-site visitors. +export const SITENOVA_ENDUSER_TOKEN_TTL = '7d'; + +// JWT payload issued to a generated-site end-user on register/login. +export interface SitenovaEndUserTokenPayload { + sub: string; // the user's identifier in the connection's users table (email by default) + cid: string; // connectionId the token is bound to + aud: string; // SITENOVA_ENDUSER_AUDIENCE +} + +// Input DS for the register/login use cases. +export class SitenovaRegisterEndUserDs { + connectionId: string; + tableName: string; + email: string; + password: string; + emailField: string; + passwordField: string; + extra: Record; +} + +export class SitenovaLoginEndUserDs { + connectionId: string; + tableName: string; + email: string; + password: string; + emailField: string; + passwordField: string; +} + +// Result of register/login: an end-user token plus the public (password-stripped) user row. +export class SitenovaEndUserAuthResultDs { + token: string; + user: Record; +} diff --git a/backend/src/microservices/sitenova-microservice/data-structures/sitenova.ds.ts b/backend/src/microservices/sitenova-microservice/data-structures/sitenova.ds.ts new file mode 100644 index 000000000..ef8d5776c --- /dev/null +++ b/backend/src/microservices/sitenova-microservice/data-structures/sitenova.ds.ts @@ -0,0 +1,7 @@ +export class SitenovaExecuteRawQueryDs { + connectionId: string; + userId: string; + masterPassword: string | null; + query: string; + tableName: string | null; +} diff --git a/backend/src/microservices/sitenova-microservice/dto/sitenova-site.dtos.ts b/backend/src/microservices/sitenova-microservice/dto/sitenova-site.dtos.ts new file mode 100644 index 000000000..7e731a923 --- /dev/null +++ b/backend/src/microservices/sitenova-microservice/dto/sitenova-site.dtos.ts @@ -0,0 +1,106 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsInt, IsNotEmpty, IsObject, IsOptional, IsString, MinLength } from 'class-validator'; + +class SitenovaAuthBaseDto { + @ApiProperty({ description: 'Name of the users/auth table in the connected database.' }) + @IsString() + @IsNotEmpty() + tableName: string; + + @ApiProperty({ description: 'End-user identifier (matched against the email column).' }) + @IsString() + @IsNotEmpty() + email: string; + + @ApiPropertyOptional({ description: 'Override the email column name (default: "email").' }) + @IsOptional() + @IsString() + emailField?: string; + + @ApiPropertyOptional({ description: 'Override the password column name (default: "password").' }) + @IsOptional() + @IsString() + passwordField?: string; +} + +export class SitenovaRegisterDto extends SitenovaAuthBaseDto { + @ApiProperty({ description: 'End-user password (stored hashed; min 6 chars).' }) + @IsString() + @MinLength(6) + password: string; + + @ApiPropertyOptional({ type: Object, description: 'Additional columns to set on the new user row.' }) + @IsOptional() + @IsObject() + extra?: Record; +} + +export class SitenovaLoginDto extends SitenovaAuthBaseDto { + @ApiProperty({ description: 'End-user password.' }) + @IsString() + @IsNotEmpty() + password: string; +} + +export class SitenovaSiteCreateRowDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + tableName: string; + + @ApiProperty({ type: Object }) + @IsObject() + row: Record; +} + +export class SitenovaSiteGetRowsDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + tableName: string; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + page?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + perPage?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ type: Object }) + @IsOptional() + @IsObject() + filters?: Record; +} + +export class SitenovaSiteRowByPrimaryKeyDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + tableName: string; + + @ApiProperty({ type: Object }) + @IsObject() + primaryKey: Record; +} + +export class SitenovaSiteUpdateRowDto extends SitenovaSiteRowByPrimaryKeyDto { + @ApiProperty({ type: Object }) + @IsObject() + row: Record; +} + +export class SitenovaEndUserAuthResponseDto { + @ApiProperty({ description: 'End-user JWT to send as Bearer on write requests.' }) + token: string; + + @ApiProperty({ type: Object, description: 'The authenticated user row (password field stripped).' }) + user: Record; +} diff --git a/backend/src/microservices/sitenova-microservice/dto/sitenova.dtos.ts b/backend/src/microservices/sitenova-microservice/dto/sitenova.dtos.ts new file mode 100644 index 000000000..e11c81533 --- /dev/null +++ b/backend/src/microservices/sitenova-microservice/dto/sitenova.dtos.ts @@ -0,0 +1,29 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; + +export class SitenovaBaseDto { + @ApiProperty({ description: 'Id of the RocketAdmin user the operation is performed on behalf of.' }) + @IsString() + @IsNotEmpty() + @IsUUID() + userId: string; + + @ApiPropertyOptional({ description: 'Master password for connections stored with encryption.' }) + @IsOptional() + @IsString() + masterPassword?: string | null; +} + +export class SitenovaExecuteRawQueryDto extends SitenovaBaseDto { + @ApiProperty({ description: 'Raw SQL statement to execute (DDL/DML allowed).' }) + @IsString() + @IsNotEmpty() + query: string; + + @ApiPropertyOptional({ + description: 'Optional table/collection name. Required only by engines whose raw-query API is table-scoped.', + }) + @IsOptional() + @IsString() + tableName?: string | null; +} diff --git a/backend/src/microservices/sitenova-microservice/guards/sitenova-enduser-auth.guard.ts b/backend/src/microservices/sitenova-microservice/guards/sitenova-enduser-auth.guard.ts new file mode 100644 index 000000000..866cdcb53 --- /dev/null +++ b/backend/src/microservices/sitenova-microservice/guards/sitenova-enduser-auth.guard.ts @@ -0,0 +1,33 @@ +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { Request } from 'express'; +import { extractTokenFromHeader } from '../../../authorization/utils/extract-token-from-header.js'; +import { Messages } from '../../../exceptions/text/messages.js'; +import { SitenovaEndUserTokenPayload } from '../data-structures/sitenova-site.ds.js'; +import { SitenovaEndUserAuthService } from '../services/sitenova-enduser-auth.service.js'; + +export interface RequestWithSitenovaEndUser extends Request { + sitenovaEndUser?: SitenovaEndUserTokenPayload; +} + +// Gates write operations on the generated-site data API. The site visitor must present a valid +// end-user JWT (issued by login/register) bound to the connection in the route. The connected DB +// user's privileges remain the outer bound; the trusted `tableName` allow-listing is deferred. +@Injectable() +export class SitenovaEndUserAuthGuard implements CanActivate { + constructor(private readonly endUserAuthService: SitenovaEndUserAuthService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const connectionId = request.params?.connectionId as string | undefined; + const token = extractTokenFromHeader(request); + if (!connectionId || !token) { + throw new UnauthorizedException(Messages.AUTHORIZATION_REJECTED); + } + const decoded = await this.endUserAuthService.verifyEndUserToken(connectionId, token); + if (!decoded) { + throw new UnauthorizedException(Messages.AUTHORIZATION_REJECTED); + } + request.sitenovaEndUser = decoded; + return true; + } +} diff --git a/backend/src/microservices/sitenova-microservice/guards/sitenova-public-read.guard.ts b/backend/src/microservices/sitenova-microservice/guards/sitenova-public-read.guard.ts new file mode 100644 index 000000000..458c5fd0f --- /dev/null +++ b/backend/src/microservices/sitenova-microservice/guards/sitenova-public-read.guard.ts @@ -0,0 +1,42 @@ +import { BadRequestException, CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import { Request } from 'express'; +import { CedarAction, PUBLIC_USER_ID } from '../../../entities/cedar-authorization/cedar-action-map.js'; +import { CedarAuthorizationService } from '../../../entities/cedar-authorization/cedar-authorization.service.js'; +import { Messages } from '../../../exceptions/text/messages.js'; + +// Gates read operations on the generated-site data API. Reads are allowed only when the connection +// has a public policy that grants table:query on the requested table — the same public-permissions +// mechanism the existing /table/crud routes use (see QueryTableGuard). The visitor is anonymous +// here; column visibility follows the connection's public-read policy downstream. +@Injectable() +export class SitenovaPublicReadGuard implements CanActivate { + constructor(private readonly cedarAuthService: CedarAuthorizationService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const connectionId = request.params?.connectionId as string | undefined; + const tableName = request.body?.tableName as string | undefined; + if (!connectionId) { + throw new BadRequestException(Messages.CONNECTION_ID_MISSING); + } + if (!tableName) { + throw new BadRequestException(Messages.TABLE_NAME_MISSING); + } + + const publicEnabled = await this.cedarAuthService.isPublicAccessEnabled(connectionId); + if (!publicEnabled) { + throw new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS); + } + const allowed = await this.cedarAuthService.validate({ + userId: PUBLIC_USER_ID, + action: CedarAction.TableQuery, + connectionId, + tableName, + publicAccess: true, + }); + if (!allowed) { + throw new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS); + } + return true; + } +} diff --git a/backend/src/microservices/sitenova-microservice/services/sitenova-enduser-auth.service.ts b/backend/src/microservices/sitenova-microservice/services/sitenova-enduser-auth.service.ts new file mode 100644 index 000000000..467dd72f5 --- /dev/null +++ b/backend/src/microservices/sitenova-microservice/services/sitenova-enduser-auth.service.ts @@ -0,0 +1,115 @@ +import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; +import crypto from 'crypto'; +import jwt from 'jsonwebtoken'; +import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../common/data-injection.tokens.js'; +import { Encryptor } from '../../../helpers/encryption/encryptor.js'; +import { appConfig } from '../../../shared/config/app-config.js'; +import { + SITENOVA_ENDUSER_AUDIENCE, + SITENOVA_ENDUSER_TOKEN_TTL, + SitenovaEndUserTokenPayload, +} from '../data-structures/sitenova-site.ds.js'; + +// Provisions and uses a per-connection HS256 signing key for generated-site end-user (site visitor) +// tokens, never exposed outside the backend. +// +// When the connection belongs to a company (SaaS), the key is a company-scoped UserSecret — +// encrypted at rest with the app key, no master-password layer so it decrypts unattended on public +// requests; rotating/deleting it invalidates every token for that site. When the connection has NO +// company (self-hosted / single-tenant), we deterministically DERIVE the key from the platform +// secret per connection instead — UserSecret requires a companyId, so storage isn't an option there. +@Injectable() +export class SitenovaEndUserAuthService { + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + private readonly _dbContext: IGlobalDatabaseContext, + ) {} + + public async signEndUserToken(connectionId: string, sub: string): Promise { + const key = await this.getOrCreateSigningKey(connectionId); + const payload: SitenovaEndUserTokenPayload = { sub, cid: connectionId, aud: SITENOVA_ENDUSER_AUDIENCE }; + return jwt.sign(payload, key, { algorithm: 'HS256', expiresIn: SITENOVA_ENDUSER_TOKEN_TTL }); + } + + // Returns the decoded payload when the token is a valid end-user token bound to this connection, + // or null otherwise. Never throws on an invalid token (the guard turns null into a 401). + public async verifyEndUserToken(connectionId: string, token: string): Promise { + const key = await this.getSigningKeyOrNull(connectionId); + if (!key) { + return null; + } + try { + const decoded = jwt.verify(token, key, { + algorithms: ['HS256'], + audience: SITENOVA_ENDUSER_AUDIENCE, + }) as SitenovaEndUserTokenPayload; + if (decoded.cid !== connectionId) { + return null; + } + return decoded; + } catch { + return null; + } + } + + private secretSlug(connectionId: string): string { + return `sitenova:enduser-jwt:${connectionId}`; + } + + private async resolveCompanyIdOrNull(connectionId: string): Promise { + const connection = await this._dbContext.connectionRepository.findOne({ + where: { id: connectionId }, + relations: { company: true }, + }); + return connection?.company?.id ?? null; + } + + // Deterministic per-connection key for connections with no company. HMAC of the platform secret + // keeps it distinct from the raw JWT_SECRET and isolated per connection, with no storage needed. + private deriveConnectionKey(connectionId: string): string { + const base = appConfig.auth.jwtSecret; + if (!base) { + throw new InternalServerErrorException('No signing secret configured for SiteNova end-user tokens.'); + } + return crypto.createHmac('sha256', base).update(`sitenova:enduser:${connectionId}`).digest('hex'); + } + + private async getSigningKeyOrNull(connectionId: string): Promise { + const companyId = await this.resolveCompanyIdOrNull(connectionId); + if (!companyId) { + return this.deriveConnectionKey(connectionId); + } + const secret = await this._dbContext.userSecretRepository.findSecretBySlugAndCompanyId( + this.secretSlug(connectionId), + companyId, + ); + if (!secret) { + return null; + } + return Encryptor.decryptData(secret.encryptedValue); + } + + private async getOrCreateSigningKey(connectionId: string): Promise { + const companyId = await this.resolveCompanyIdOrNull(connectionId); + if (!companyId) { + return this.deriveConnectionKey(connectionId); + } + const slug = this.secretSlug(connectionId); + const existing = await this._dbContext.userSecretRepository.findSecretBySlugAndCompanyId(slug, companyId); + if (existing) { + return Encryptor.decryptData(existing.encryptedValue); + } + const key = crypto.randomBytes(32).toString('hex'); + const secret = this._dbContext.userSecretRepository.create({ + slug, + encryptedValue: Encryptor.encryptData(key), + companyId, + expiresAt: null, + masterEncryption: false, + masterHash: null, + }); + await this._dbContext.userSecretRepository.save(secret); + return key; + } +} diff --git a/backend/src/microservices/sitenova-microservice/sitenova-internal.controller.ts b/backend/src/microservices/sitenova-microservice/sitenova-internal.controller.ts new file mode 100644 index 000000000..b5ef76cb3 --- /dev/null +++ b/backend/src/microservices/sitenova-microservice/sitenova-internal.controller.ts @@ -0,0 +1,56 @@ +import { Body, Controller, Inject, Injectable, Post, UseInterceptors } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { SkipThrottle } from '@nestjs/throttler'; +import { UseCaseType } from '../../common/data-injection.tokens.js'; +import { SlugUuid } from '../../decorators/slug-uuid.decorator.js'; +import { Timeout, TimeoutDefaults } from '../../decorators/timeout.decorator.js'; +import { InTransactionEnum } from '../../enums/in-transaction.enum.js'; +import { isTest } from '../../helpers/app/is-test.js'; +import { SentryInterceptor } from '../../interceptors/sentry.interceptor.js'; +import { SitenovaRawQueryResultRO } from './data-structures/sitenova-responses.ds.js'; +import { SitenovaExecuteRawQueryDto } from './dto/sitenova.dtos.js'; +import { ISitenovaExecuteRawQuery } from './use-cases/sitenova-use-cases.interface.js'; + +// Internal, microservice-authenticated controller (SaaSAuthMiddleware / microservice JWT), used by +// the SiteNova agents service only. The single endpoint runs write-capable raw SQL so the AI agent +// can provision schema (e.g. CREATE TABLE, including the users/auth table the generated site needs) +// in the user's connected database. Browser-facing CRUD + auth live in SitenovaSiteController. +@UseInterceptors(SentryInterceptor) +@SkipThrottle() +@Timeout() +@ApiTags('sitenova microservice') +@Controller('internal/sitenova') +@Injectable() +export class SitenovaInternalController { + constructor( + @Inject(UseCaseType.SITENOVA_EXECUTE_RAW_QUERY) + private readonly executeRawQueryUseCase: ISitenovaExecuteRawQuery, + ) {} + + @ApiOperation({ + summary: 'Execute a raw, write-capable SQL statement against the connected database.', + description: + 'Runs arbitrary SQL (DDL/DML) so the SiteNova agent can provision schema (e.g. CREATE TABLE) ' + + 'in the user connection. No read-only validation is applied; the connected DB user privileges ' + + 'are the only constraint.', + }) + @ApiResponse({ status: 201, type: SitenovaRawQueryResultRO }) + @ApiBody({ type: SitenovaExecuteRawQueryDto }) + @Timeout(!isTest() ? TimeoutDefaults.EXTENDED : TimeoutDefaults.EXTENDED_TEST) + @Post('/raw-query/:connectionId') + public async executeRawQuery( + @SlugUuid('connectionId') connectionId: string, + @Body() body: SitenovaExecuteRawQueryDto, + ): Promise { + return await this.executeRawQueryUseCase.execute( + { + connectionId, + userId: body.userId, + masterPassword: body.masterPassword ?? null, + query: body.query, + tableName: body.tableName ?? null, + }, + InTransactionEnum.OFF, + ); + } +} diff --git a/backend/src/microservices/sitenova-microservice/sitenova-site.controller.ts b/backend/src/microservices/sitenova-microservice/sitenova-site.controller.ts new file mode 100644 index 000000000..1961d285d --- /dev/null +++ b/backend/src/microservices/sitenova-microservice/sitenova-site.controller.ts @@ -0,0 +1,223 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Inject, + Injectable, + Post, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { UseCaseType } from '../../common/data-injection.tokens.js'; +import { SlugUuid } from '../../decorators/slug-uuid.decorator.js'; +import { Timeout, TimeoutDefaults } from '../../decorators/timeout.decorator.js'; +import { PureCreateRowDs } from '../../entities/table/table-pure-crud-operations/application/data-structures/pure-create-row.ds.js'; +import { PureCrudRowResponseDs } from '../../entities/table/table-pure-crud-operations/application/data-structures/pure-crud-row-response.ds.js'; +import { PureDeleteRowDs } from '../../entities/table/table-pure-crud-operations/application/data-structures/pure-delete-row.ds.js'; +import { PureFoundRowsResponseDs } from '../../entities/table/table-pure-crud-operations/application/data-structures/pure-found-rows-response.ds.js'; +import { PureGetRowsDs } from '../../entities/table/table-pure-crud-operations/application/data-structures/pure-get-rows.ds.js'; +import { PureReadRowDs } from '../../entities/table/table-pure-crud-operations/application/data-structures/pure-read-row.ds.js'; +import { PureUpdateRowDs } from '../../entities/table/table-pure-crud-operations/application/data-structures/pure-update-row.ds.js'; +import { + IPureCreateRowInTable, + IPureDeleteRowFromTable, + IPureGetRowsFromTable, + IPureReadRowFromTable, + IPureUpdateRowInTable, +} from '../../entities/table/table-pure-crud-operations/use-cases/table-pure-crud-use-cases.interface.js'; +import { InTransactionEnum } from '../../enums/in-transaction.enum.js'; +import { isTest } from '../../helpers/app/is-test.js'; +import { SentryInterceptor } from '../../interceptors/sentry.interceptor.js'; +import { + SitenovaEndUserAuthResponseDto, + SitenovaLoginDto, + SitenovaRegisterDto, + SitenovaSiteCreateRowDto, + SitenovaSiteGetRowsDto, + SitenovaSiteRowByPrimaryKeyDto, + SitenovaSiteUpdateRowDto, +} from './dto/sitenova-site.dtos.js'; +import { SitenovaEndUserAuthGuard } from './guards/sitenova-enduser-auth.guard.js'; +import { SitenovaPublicReadGuard } from './guards/sitenova-public-read.guard.js'; +import { ISitenovaLoginEndUser, ISitenovaRegisterEndUser } from './use-cases/sitenova-site-use-cases.interface.js'; + +const DEFAULT_EMAIL_FIELD = 'email'; +const DEFAULT_PASSWORD_FIELD = 'password'; + +@UseInterceptors(SentryInterceptor) +@Timeout() +@ApiTags('sitenova site') +@Controller('sitenova') +@Injectable() +export class SitenovaSiteController { + constructor( + @Inject(UseCaseType.SITENOVA_REGISTER_ENDUSER) + private readonly registerEndUserUseCase: ISitenovaRegisterEndUser, + @Inject(UseCaseType.SITENOVA_LOGIN_ENDUSER) + private readonly loginEndUserUseCase: ISitenovaLoginEndUser, + @Inject(UseCaseType.PURE_CREATE_ROW_IN_TABLE) + private readonly pureCreateRowInTableUseCase: IPureCreateRowInTable, + @Inject(UseCaseType.PURE_READ_ROW_FROM_TABLE) + private readonly pureReadRowFromTableUseCase: IPureReadRowFromTable, + @Inject(UseCaseType.PURE_UPDATE_ROW_IN_TABLE) + private readonly pureUpdateRowInTableUseCase: IPureUpdateRowInTable, + @Inject(UseCaseType.PURE_DELETE_ROW_FROM_TABLE) + private readonly pureDeleteRowFromTableUseCase: IPureDeleteRowFromTable, + @Inject(UseCaseType.PURE_GET_ROWS_FROM_TABLE) + private readonly pureGetRowsFromTableUseCase: IPureGetRowsFromTable, + ) {} + + @ApiOperation({ summary: 'Register a generated-site end-user (creates a row in the connection users table).' }) + @ApiResponse({ status: 201, type: SitenovaEndUserAuthResponseDto }) + @ApiBody({ type: SitenovaRegisterDto }) + @Throttle({ default: { limit: 30, ttl: 60000 } }) + @Post('/:connectionId/auth/register') + public async register( + @SlugUuid('connectionId') connectionId: string, + @Body() body: SitenovaRegisterDto, + ): Promise { + return await this.registerEndUserUseCase.execute( + { + connectionId, + tableName: body.tableName, + email: body.email, + password: body.password, + emailField: body.emailField || DEFAULT_EMAIL_FIELD, + passwordField: body.passwordField || DEFAULT_PASSWORD_FIELD, + extra: body.extra ?? {}, + }, + InTransactionEnum.OFF, + ); + } + + @ApiOperation({ summary: 'Log a generated-site end-user in and return a Bearer token.' }) + @ApiResponse({ status: 200, type: SitenovaEndUserAuthResponseDto }) + @ApiBody({ type: SitenovaLoginDto }) + @Throttle({ default: { limit: 30, ttl: 60000 } }) + @HttpCode(HttpStatus.OK) + @Post('/:connectionId/auth/login') + public async login( + @SlugUuid('connectionId') connectionId: string, + @Body() body: SitenovaLoginDto, + ): Promise { + return await this.loginEndUserUseCase.execute( + { + connectionId, + tableName: body.tableName, + email: body.email, + password: body.password, + emailField: body.emailField || DEFAULT_EMAIL_FIELD, + passwordField: body.passwordField || DEFAULT_PASSWORD_FIELD, + }, + InTransactionEnum.OFF, + ); + } + + @ApiOperation({ summary: 'List rows (public-read; gated by the connection public policy).' }) + @ApiResponse({ status: 200, type: PureFoundRowsResponseDs }) + @ApiBody({ type: SitenovaSiteGetRowsDto }) + @UseGuards(SitenovaPublicReadGuard) + @Timeout(!isTest() ? TimeoutDefaults.EXTENDED : TimeoutDefaults.EXTENDED_TEST) + @HttpCode(HttpStatus.OK) + @Post('/:connectionId/data/rows') + public async getRows( + @SlugUuid('connectionId') connectionId: string, + @Body() body: SitenovaSiteGetRowsDto, + ): Promise { + const inputData: PureGetRowsDs = { + connectionId, + masterPwd: '', + page: body.page ?? 0, + perPage: body.perPage ?? 0, + query: {}, + searchingFieldValue: body.search ?? '', + tableName: body.tableName, + userId: undefined, + filters: body.filters, + }; + return await this.pureGetRowsFromTableUseCase.execute(inputData, InTransactionEnum.OFF); + } + + @ApiOperation({ summary: 'Read a row by primary key (public-read; gated by the connection public policy).' }) + @ApiResponse({ status: 200, type: PureCrudRowResponseDs }) + @ApiBody({ type: SitenovaSiteRowByPrimaryKeyDto }) + @UseGuards(SitenovaPublicReadGuard) + @HttpCode(HttpStatus.OK) + @Post('/:connectionId/data/read') + public async readRow( + @SlugUuid('connectionId') connectionId: string, + @Body() body: SitenovaSiteRowByPrimaryKeyDto, + ): Promise { + const inputData: PureReadRowDs = { + connectionId, + masterPwd: '', + primaryKey: body.primaryKey, + tableName: body.tableName, + userId: undefined, + }; + return await this.pureReadRowFromTableUseCase.execute(inputData, InTransactionEnum.OFF); + } + + @ApiOperation({ summary: 'Create a row (requires a valid end-user token).' }) + @ApiResponse({ status: 201, type: PureCrudRowResponseDs }) + @ApiBody({ type: SitenovaSiteCreateRowDto }) + @UseGuards(SitenovaEndUserAuthGuard) + @Post('/:connectionId/data/create') + public async createRow( + @SlugUuid('connectionId') connectionId: string, + @Body() body: SitenovaSiteCreateRowDto, + ): Promise { + const inputData: PureCreateRowDs = { + connectionId, + masterPwd: '', + row: body.row, + tableName: body.tableName, + userId: '', + }; + return await this.pureCreateRowInTableUseCase.execute(inputData, InTransactionEnum.OFF); + } + + @ApiOperation({ summary: 'Update a row by primary key (requires a valid end-user token).' }) + @ApiResponse({ status: 200, type: PureCrudRowResponseDs }) + @ApiBody({ type: SitenovaSiteUpdateRowDto }) + @UseGuards(SitenovaEndUserAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('/:connectionId/data/update') + public async updateRow( + @SlugUuid('connectionId') connectionId: string, + @Body() body: SitenovaSiteUpdateRowDto, + ): Promise { + const inputData: PureUpdateRowDs = { + connectionId, + masterPwd: '', + primaryKey: body.primaryKey, + row: body.row, + tableName: body.tableName, + userId: '', + }; + return await this.pureUpdateRowInTableUseCase.execute(inputData, InTransactionEnum.OFF); + } + + @ApiOperation({ summary: 'Delete a row by primary key (requires a valid end-user token).' }) + @ApiResponse({ status: 200, type: PureCrudRowResponseDs }) + @ApiBody({ type: SitenovaSiteRowByPrimaryKeyDto }) + @UseGuards(SitenovaEndUserAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('/:connectionId/data/delete') + public async deleteRow( + @SlugUuid('connectionId') connectionId: string, + @Body() body: SitenovaSiteRowByPrimaryKeyDto, + ): Promise { + const inputData: PureDeleteRowDs = { + connectionId, + masterPwd: '', + primaryKey: body.primaryKey, + tableName: body.tableName, + userId: '', + }; + return await this.pureDeleteRowFromTableUseCase.execute(inputData, InTransactionEnum.OFF); + } +} diff --git a/backend/src/microservices/sitenova-microservice/sitenova.module.ts b/backend/src/microservices/sitenova-microservice/sitenova.module.ts new file mode 100644 index 000000000..c7f551ee1 --- /dev/null +++ b/backend/src/microservices/sitenova-microservice/sitenova.module.ts @@ -0,0 +1,102 @@ +import { MiddlewareConsumer, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { SaaSAuthMiddleware } from '../../authorization/saas-auth.middleware.js'; +import { GlobalDatabaseContext } from '../../common/application/global-database-context.js'; +import { BaseType, UseCaseType } from '../../common/data-injection.tokens.js'; +import { AgentModule } from '../../entities/agent/agent.module.js'; +import { CompanyInfoEntity } from '../../entities/company-info/company-info.entity.js'; +import { ConnectionEntity } from '../../entities/connection/connection.entity.js'; +import { ConnectionModule } from '../../entities/connection/connection.module.js'; +import { ConnectionPropertiesEntity } from '../../entities/connection-properties/connection-properties.entity.js'; +import { CustomFieldsEntity } from '../../entities/custom-field/custom-fields.entity.js'; +import { GroupEntity } from '../../entities/group/group.entity.js'; +import { LogOutEntity } from '../../entities/log-out/log-out.entity.js'; +import { SecretAccessLogEntity } from '../../entities/secret-access-log/secret-access-log.entity.js'; +import { PureCreateRowInTableUseCase } from '../../entities/table/table-pure-crud-operations/use-cases/pure-create-row-in-table.use.case.js'; +import { PureDeleteRowFromTableUseCase } from '../../entities/table/table-pure-crud-operations/use-cases/pure-delete-row-from-table.use.case.js'; +import { PureGetRowsFromTableUseCase } from '../../entities/table/table-pure-crud-operations/use-cases/pure-get-rows-from-table.use.case.js'; +import { PureReadRowFromTableUseCase } from '../../entities/table/table-pure-crud-operations/use-cases/pure-read-row-from-table.use.case.js'; +import { PureUpdateRowInTableUseCase } from '../../entities/table/table-pure-crud-operations/use-cases/pure-update-row-in-table.use.case.js'; +import { TableLogsEntity } from '../../entities/table-logs/table-logs.entity.js'; +import { TableSettingsEntity } from '../../entities/table-settings/common-table-settings/table-settings.entity.js'; +import { UserEntity } from '../../entities/user/user.entity.js'; +import { UserModule } from '../../entities/user/user.module.js'; +import { UserSecretEntity } from '../../entities/user-secret/user-secret.entity.js'; +import { TableWidgetEntity } from '../../entities/widget/table-widget.entity.js'; +import { SitenovaEndUserAuthGuard } from './guards/sitenova-enduser-auth.guard.js'; +import { SitenovaPublicReadGuard } from './guards/sitenova-public-read.guard.js'; +import { SitenovaEndUserAuthService } from './services/sitenova-enduser-auth.service.js'; +import { SitenovaInternalController } from './sitenova-internal.controller.js'; +import { SitenovaSiteController } from './sitenova-site.controller.js'; +import { SitenovaExecuteRawQueryUseCase } from './use-cases/sitenova-execute-raw-query.use.case.js'; +import { SitenovaLoginEndUserUseCase } from './use-cases/sitenova-login-enduser.use.case.js'; +import { SitenovaRegisterEndUserUseCase } from './use-cases/sitenova-register-enduser.use.case.js'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + ConnectionEntity, + CustomFieldsEntity, + GroupEntity, + TableLogsEntity, + TableSettingsEntity, + TableWidgetEntity, + UserEntity, + ConnectionPropertiesEntity, + LogOutEntity, + UserSecretEntity, + CompanyInfoEntity, + SecretAccessLogEntity, + ]), + AgentModule, + ConnectionModule, + UserModule, + ], + providers: [ + { + provide: BaseType.GLOBAL_DB_CONTEXT, + useClass: GlobalDatabaseContext, + }, + SitenovaEndUserAuthService, + SitenovaEndUserAuthGuard, + SitenovaPublicReadGuard, + { + provide: UseCaseType.SITENOVA_EXECUTE_RAW_QUERY, + useClass: SitenovaExecuteRawQueryUseCase, + }, + { + provide: UseCaseType.SITENOVA_REGISTER_ENDUSER, + useClass: SitenovaRegisterEndUserUseCase, + }, + { + provide: UseCaseType.SITENOVA_LOGIN_ENDUSER, + useClass: SitenovaLoginEndUserUseCase, + }, + { + provide: UseCaseType.PURE_CREATE_ROW_IN_TABLE, + useClass: PureCreateRowInTableUseCase, + }, + { + provide: UseCaseType.PURE_READ_ROW_FROM_TABLE, + useClass: PureReadRowFromTableUseCase, + }, + { + provide: UseCaseType.PURE_UPDATE_ROW_IN_TABLE, + useClass: PureUpdateRowInTableUseCase, + }, + { + provide: UseCaseType.PURE_DELETE_ROW_FROM_TABLE, + useClass: PureDeleteRowFromTableUseCase, + }, + { + provide: UseCaseType.PURE_GET_ROWS_FROM_TABLE, + useClass: PureGetRowsFromTableUseCase, + }, + ], + controllers: [SitenovaInternalController, SitenovaSiteController], +}) +export class SitenovaModule { + public configure(consumer: MiddlewareConsumer): void { + consumer.apply(SaaSAuthMiddleware).forRoutes(SitenovaInternalController); + } +} diff --git a/backend/src/microservices/sitenova-microservice/use-cases/sitenova-execute-raw-query.use.case.ts b/backend/src/microservices/sitenova-microservice/use-cases/sitenova-execute-raw-query.use.case.ts new file mode 100644 index 000000000..81dced31f --- /dev/null +++ b/backend/src/microservices/sitenova-microservice/use-cases/sitenova-execute-raw-query.use.case.ts @@ -0,0 +1,43 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { getDataAccessObject } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/create-data-access-object.js'; +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 { getUserEmailForAgent, validateConnection } from '../../../entities/table/utils/validate-connection.util.js'; +import { SitenovaExecuteRawQueryDs } from '../data-structures/sitenova.ds.js'; +import { SitenovaRawQueryResultRO } from '../data-structures/sitenova-responses.ds.js'; +import { ISitenovaExecuteRawQuery } from './sitenova-use-cases.interface.js'; + +@Injectable() +export class SitenovaExecuteRawQueryUseCase + extends AbstractUseCase + implements ISitenovaExecuteRawQuery +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + protected async implementation(inputData: SitenovaExecuteRawQueryDs): Promise { + const { connectionId, masterPassword, userId, query, tableName } = inputData; + + const connection = await this._dbContext.connectionRepository.findAndDecryptConnection( + connectionId, + masterPassword as string, + ); + validateConnection(connection); + + const dao = getDataAccessObject(connection); + const userEmail = await getUserEmailForAgent(connection, userId, this._dbContext.userRepository); + + // Intentionally write-capable: unlike the AI read path there is no read-only + // validation, no LIMIT wrapping and no Cedar permission check. The caller is + // authorized by the microservice JWT and is constrained only by the privileges + // of the connected database user, so it can run DDL/DML such as CREATE TABLE. + const result = await dao.executeRawQuery(query, tableName ?? '', userEmail); + + return { result }; + } +} diff --git a/backend/src/microservices/sitenova-microservice/use-cases/sitenova-login-enduser.use.case.ts b/backend/src/microservices/sitenova-microservice/use-cases/sitenova-login-enduser.use.case.ts new file mode 100644 index 000000000..704b7742d --- /dev/null +++ b/backend/src/microservices/sitenova-microservice/use-cases/sitenova-login-enduser.use.case.ts @@ -0,0 +1,66 @@ +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { getDataAccessObject } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/create-data-access-object.js'; +import { buildDAOsTableSettingsDs } from '@rocketadmin/shared-code/dist/src/helpers/data-structures-builders/table-settings.ds.builder.js'; +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 { buildCommonTableSettingsInput } from '../../../entities/table/utils/build-common-table-settings-input.util.js'; +import { parseFilteringFieldsFromBodyData } from '../../../entities/table/utils/find-filtering-fields.util.js'; +import { getUserEmailForAgent, validateConnection } from '../../../entities/table/utils/validate-connection.util.js'; +import { Encryptor } from '../../../helpers/encryption/encryptor.js'; +import { SitenovaEndUserAuthResultDs, SitenovaLoginEndUserDs } from '../data-structures/sitenova-site.ds.js'; +import { SitenovaEndUserAuthService } from '../services/sitenova-enduser-auth.service.js'; +import { ISitenovaLoginEndUser } from './sitenova-site-use-cases.interface.js'; + +@Injectable() +export class SitenovaLoginEndUserUseCase + extends AbstractUseCase + implements ISitenovaLoginEndUser +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + private readonly endUserAuthService: SitenovaEndUserAuthService, + ) { + super(); + } + + protected async implementation(inputData: SitenovaLoginEndUserDs): Promise { + const { connectionId, tableName, email, password, emailField, passwordField } = inputData; + + const connection = await this._dbContext.connectionRepository.findAndDecryptConnection(connectionId, ''); + validateConnection(connection); + const dao = getDataAccessObject(connection); + const userEmail = await getUserEmailForAgent(connection, '', this._dbContext.userRepository); + + const tableStructure = await dao.getTableStructure(tableName, userEmail); + + // Direct, parameterized DAO read (NOT the public column-filtered path) so the hashed password + // column is available to verify against. The hash is never returned to the caller. + const filteringFields = parseFilteringFieldsFromBodyData({ [emailField]: { eq: email } }, tableStructure); + const settings = buildDAOsTableSettingsDs(buildCommonTableSettingsInput(null), null); + const found = await dao.getRowsFromTable( + tableName, + settings, + 1, + 1, + '', + filteringFields, + { fields: [], value: '' }, + tableStructure, + userEmail, + ); + const row = found.data && found.data.length > 0 ? found.data[0] : null; + + // Generic failure for both "no such user" and "wrong password" — never leak which. + const valid = row ? await Encryptor.verifyUserPassword(password, row[passwordField] as string) : false; + if (!row || !valid) { + throw new UnauthorizedException('Invalid email or password.'); + } + + const token = await this.endUserAuthService.signEndUserToken(connectionId, email); + const user: Record = { ...row }; + delete user[passwordField]; + return { token, user }; + } +} diff --git a/backend/src/microservices/sitenova-microservice/use-cases/sitenova-register-enduser.use.case.ts b/backend/src/microservices/sitenova-microservice/use-cases/sitenova-register-enduser.use.case.ts new file mode 100644 index 000000000..8a4145c4f --- /dev/null +++ b/backend/src/microservices/sitenova-microservice/use-cases/sitenova-register-enduser.use.case.ts @@ -0,0 +1,63 @@ +import { ConflictException, Inject, Injectable } from '@nestjs/common'; +import { getDataAccessObject } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/create-data-access-object.js'; +import { buildDAOsTableSettingsDs } from '@rocketadmin/shared-code/dist/src/helpers/data-structures-builders/table-settings.ds.builder.js'; +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 { buildCommonTableSettingsInput } from '../../../entities/table/utils/build-common-table-settings-input.util.js'; +import { parseFilteringFieldsFromBodyData } from '../../../entities/table/utils/find-filtering-fields.util.js'; +import { getUserEmailForAgent, validateConnection } from '../../../entities/table/utils/validate-connection.util.js'; +import { Encryptor } from '../../../helpers/encryption/encryptor.js'; +import { SitenovaEndUserAuthResultDs, SitenovaRegisterEndUserDs } from '../data-structures/sitenova-site.ds.js'; +import { SitenovaEndUserAuthService } from '../services/sitenova-enduser-auth.service.js'; +import { ISitenovaRegisterEndUser } from './sitenova-site-use-cases.interface.js'; + +@Injectable() +export class SitenovaRegisterEndUserUseCase + extends AbstractUseCase + implements ISitenovaRegisterEndUser +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + private readonly endUserAuthService: SitenovaEndUserAuthService, + ) { + super(); + } + + protected async implementation(inputData: SitenovaRegisterEndUserDs): Promise { + const { connectionId, tableName, email, password, emailField, passwordField, extra } = inputData; + + const connection = await this._dbContext.connectionRepository.findAndDecryptConnection(connectionId, ''); + validateConnection(connection); + const dao = getDataAccessObject(connection); + const userEmail = await getUserEmailForAgent(connection, '', this._dbContext.userRepository); + + const tableStructure = await dao.getTableStructure(tableName, userEmail); + + const filteringFields = parseFilteringFieldsFromBodyData({ [emailField]: { eq: email } }, tableStructure); + const settings = buildDAOsTableSettingsDs(buildCommonTableSettingsInput(null), null); + const found = await dao.getRowsFromTable( + tableName, + settings, + 1, + 1, + '', + filteringFields, + { fields: [], value: '' }, + tableStructure, + userEmail, + ); + if (found.data && found.data.length > 0) { + throw new ConflictException('A user with this email already exists.'); + } + + const hashedPassword = await Encryptor.hashUserPassword(password); + const rowToInsert: Record = { ...extra, [emailField]: email, [passwordField]: hashedPassword }; + await dao.addRowInTable(tableName, rowToInsert, userEmail); + + const token = await this.endUserAuthService.signEndUserToken(connectionId, email); + const user: Record = { ...extra, [emailField]: email }; + return { token, user }; + } +} diff --git a/backend/src/microservices/sitenova-microservice/use-cases/sitenova-site-use-cases.interface.ts b/backend/src/microservices/sitenova-microservice/use-cases/sitenova-site-use-cases.interface.ts new file mode 100644 index 000000000..8f34a005f --- /dev/null +++ b/backend/src/microservices/sitenova-microservice/use-cases/sitenova-site-use-cases.interface.ts @@ -0,0 +1,14 @@ +import { InTransactionEnum } from '../../../enums/in-transaction.enum.js'; +import { + SitenovaEndUserAuthResultDs, + SitenovaLoginEndUserDs, + SitenovaRegisterEndUserDs, +} from '../data-structures/sitenova-site.ds.js'; + +export interface ISitenovaRegisterEndUser { + execute(inputData: SitenovaRegisterEndUserDs, inTransaction: InTransactionEnum): Promise; +} + +export interface ISitenovaLoginEndUser { + execute(inputData: SitenovaLoginEndUserDs, inTransaction: InTransactionEnum): Promise; +} diff --git a/backend/src/microservices/sitenova-microservice/use-cases/sitenova-use-cases.interface.ts b/backend/src/microservices/sitenova-microservice/use-cases/sitenova-use-cases.interface.ts new file mode 100644 index 000000000..dc613a3ff --- /dev/null +++ b/backend/src/microservices/sitenova-microservice/use-cases/sitenova-use-cases.interface.ts @@ -0,0 +1,7 @@ +import { InTransactionEnum } from '../../../enums/in-transaction.enum.js'; +import { SitenovaExecuteRawQueryDs } from '../data-structures/sitenova.ds.js'; +import { SitenovaRawQueryResultRO } from '../data-structures/sitenova-responses.ds.js'; + +export interface ISitenovaExecuteRawQuery { + execute(inputData: SitenovaExecuteRawQueryDs, inTransaction: InTransactionEnum): Promise; +} diff --git a/backend/src/middlewares/public-crud-cors.middleware.ts b/backend/src/middlewares/public-crud-cors.middleware.ts index 3f27a351a..680edbc3d 100644 --- a/backend/src/middlewares/public-crud-cors.middleware.ts +++ b/backend/src/middlewares/public-crud-cors.middleware.ts @@ -2,6 +2,11 @@ import { NextFunction, Request, Response } from 'express'; const PUBLIC_CRUD_ROUTE_REGEX = /\/table\/crud(\/|$)/; +// Browser-facing SiteNova site API (register/login + data CRUD) served to AI-generated sites from +// arbitrary CDN origins. Anchored at the start of the path so it matches `/sitenova/...` but never +// the server-to-server `/internal/sitenova/...` controller, which needs no CORS. +const SITENOVA_PUBLIC_ROUTE_REGEX = /^\/sitenova\//; + // `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; @@ -26,7 +31,7 @@ const DEFAULT_ALLOWED_HEADERS = 'Content-Type, Authorization, x-api-key, masterp * (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)) { + if (PUBLIC_CRUD_ROUTE_REGEX.test(req.path) || SITENOVA_PUBLIC_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']; diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-sitenova-microservice-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-sitenova-microservice-e2e.test.ts new file mode 100644 index 000000000..6a9945582 --- /dev/null +++ b/backend/test/ava-tests/non-saas-tests/non-saas-sitenova-microservice-e2e.test.ts @@ -0,0 +1,372 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable security/detect-object-injection */ +import { faker } from '@faker-js/faker'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import test from 'ava'; +import { ValidationError } from 'class-validator'; +import cookieParser from 'cookie-parser'; +import jwt from 'jsonwebtoken'; +import request from 'supertest'; +import { ApplicationModule } from '../../../src/app.module.js'; +import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; +import { AllExceptionsFilter } from '../../../src/exceptions/all-exceptions.filter.js'; +import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.js'; +import { Cacher } from '../../../src/helpers/cache/cacher.js'; +import { publicCrudCorsMiddleware } from '../../../src/middlewares/public-crud-cors.middleware.js'; +import { appConfig } from '../../../src/shared/config/app-config.js'; +import { DatabaseModule } from '../../../src/shared/database/database.module.js'; +import { DatabaseService } from '../../../src/shared/database/database.service.js'; +import { MockFactory } from '../../mock.factory.js'; +import { createTestTable } from '../../utils/create-test-table.js'; +import { dropTestTables } from '../../utils/drop-test-tables.js'; +import { getTestData } from '../../utils/get-test-data.js'; +import { + createInitialTestUser, + registerUserAndReturnUserInfo, +} from '../../utils/register-user-and-return-user-info.js'; +import { setSaasEnvVariable } from '../../utils/set-saas-env-variable.js'; +import { TestUtils } from '../../utils/test.utils.js'; + +const mockFactory = new MockFactory(); +let app: INestApplication; +let _testUtils: TestUtils; +const testTables: Array = []; +let currentTest; + +// Microservice JWT (request_id claim) for the internal raw-query controller. +function microserviceAuthHeader(): string { + const secret = appConfig.auth.microserviceJwtSecret as string; + return `Bearer ${jwt.sign({ request_id: faker.string.uuid() }, secret, { expiresIn: '1h' })}`; +} + +// The connection owner's user id is the `id` claim of the cookie token from registerUserAndReturnUserInfo. +function userIdFromCookieToken(cookieToken: string): string { + const decoded = jwt.decode(cookieToken.split('=')[1]) as { id: string }; + return decoded.id; +} + +test.before(async () => { + setSaasEnvVariable(); + const moduleFixture = await Test.createTestingModule({ + imports: [ApplicationModule, DatabaseModule], + providers: [DatabaseService, TestUtils], + }).compile(); + app = moduleFixture.createNestApplication(); + _testUtils = moduleFixture.get(TestUtils); + + app.use(cookieParser()); + app.use(publicCrudCorsMiddleware); + app.useGlobalFilters(new AllExceptionsFilter(app.get(WinstonLogger))); + app.useGlobalPipes( + new ValidationPipe({ + exceptionFactory(validationErrors: ValidationError[] = []) { + return new ValidationException(validationErrors); + }, + }), + ); + await app.init(); + await createInitialTestUser(app); + app.getHttpServer().listen(0); +}); + +test.after(async () => { + try { + const connectionToTestDB = getTestData(mockFactory).connectionToMySQL; + await dropTestTables(testTables, connectionToTestDB); + await Cacher.clearAllCache(); + await app.close(); + } catch (e) { + console.error('After tests error ' + e); + } +}); + +async function createConnectionAndTable(): Promise<{ + token: string; + userId: string; + connectionId: string; + testTableName: string; + testTableColumnName: string; + testTableSecondColumnName: string; +}> { + const connectionToTestDB = getTestData(mockFactory).connectionToMySQL; + const token = (await registerUserAndReturnUserInfo(app)).token; + const userId = userIdFromCookieToken(token); + const { testTableName, testTableColumnName, testTableSecondColumnName } = await createTestTable(connectionToTestDB); + testTables.push(testTableName); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(connectionToTestDB) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + return { + token, + userId, + connectionId: JSON.parse(createConnectionResponse.text).id, + testTableName, + testTableColumnName, + testTableSecondColumnName, + }; +} + +// Provision a users/auth table in the connected DB via the internal raw-query endpoint (this is the +// agents-core pipeline's job at site-creation time). Returns the table name. +async function createUsersTable(connectionId: string, userId: string): Promise { + const usersTable = `sitenova_users_${faker.string.alpha({ length: 10, casing: 'lower' })}`; + testTables.push(usersTable); + const res = await request(app.getHttpServer()) + .post(`/internal/sitenova/raw-query/${connectionId}`) + .send({ + userId, + query: `CREATE TABLE ${usersTable} (id INT AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255), password VARCHAR(1024))`, + }) + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + if (res.status !== 201) { + throw new Error(`Failed to create users table: ${res.status} ${res.text}`); + } + return usersTable; +} + +async function enablePublicRead(connectionId: string, token: string, tableName: string): Promise { + await request(app.getHttpServer()) + .put(`/connection/public-permissions/${connectionId}`) + .send({ tables: [{ tableName }] }) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); +} + +currentTest = 'POST /internal/sitenova/raw-query/:connectionId (internal, microservice JWT)'; + +test.serial( + `${currentTest} executes write-capable DDL/DML and rejects calls without the microservice JWT`, + async (t) => { + const { userId, connectionId } = await createConnectionAndTable(); + + const newTableName = `sitenova_raw_${faker.string.alpha({ length: 10, casing: 'lower' })}`; + testTables.push(newTableName); + const insertedName = faker.person.firstName(); + + const createTableResponse = await request(app.getHttpServer()) + .post(`/internal/sitenova/raw-query/${connectionId}`) + .send({ userId, query: `CREATE TABLE ${newTableName} (id INT PRIMARY KEY, name VARCHAR(255))` }) + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createTableResponse.status, 201); + + const insertResponse = await request(app.getHttpServer()) + .post(`/internal/sitenova/raw-query/${connectionId}`) + .send({ userId, query: `INSERT INTO ${newTableName} (id, name) VALUES (1, '${insertedName}')` }) + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(insertResponse.status, 201); + + const selectResponse = await request(app.getHttpServer()) + .post(`/internal/sitenova/raw-query/${connectionId}`) + .send({ userId, query: `SELECT id, name FROM ${newTableName}` }) + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(selectResponse.status, 201); + t.true(JSON.stringify(JSON.parse(selectResponse.text).result).includes(insertedName)); + + const noTokenResponse = await request(app.getHttpServer()) + .post(`/internal/sitenova/raw-query/${connectionId}`) + .send({ userId, query: 'SELECT 1' }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(noTokenResponse.status, 401); + }, +); + +currentTest = 'POST /sitenova/:connectionId/auth/register|login (public end-user auth)'; + +test.serial(`${currentTest} registers a user, auto-logs-in, and never returns the password`, async (t) => { + const { connectionId, userId } = await createConnectionAndTable(); + const usersTable = await createUsersTable(connectionId, userId); + const email = faker.internet.email().toLowerCase(); + + const registerResponse = await request(app.getHttpServer()) + .post(`/sitenova/${connectionId}/auth/register`) + .send({ tableName: usersTable, email, password: 'secret123' }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(registerResponse.status, 201); + const ro = JSON.parse(registerResponse.text); + t.is(typeof ro.token, 'string'); + t.is(ro.user.email, email); + t.is(Object.hasOwn(ro.user, 'password'), false); +}); + +test.serial(`${currentTest} rejects duplicate registration (409)`, async (t) => { + const { connectionId, userId } = await createConnectionAndTable(); + const usersTable = await createUsersTable(connectionId, userId); + const email = faker.internet.email().toLowerCase(); + const body = { tableName: usersTable, email, password: 'secret123' }; + + const first = await request(app.getHttpServer()) + .post(`/sitenova/${connectionId}/auth/register`) + .send(body) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(first.status, 201); + + const second = await request(app.getHttpServer()) + .post(`/sitenova/${connectionId}/auth/register`) + .send(body) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(second.status, 409); +}); + +test.serial(`${currentTest} login succeeds with correct creds and fails (401) otherwise`, async (t) => { + const { connectionId, userId } = await createConnectionAndTable(); + const usersTable = await createUsersTable(connectionId, userId); + const email = faker.internet.email().toLowerCase(); + await request(app.getHttpServer()) + .post(`/sitenova/${connectionId}/auth/register`) + .send({ tableName: usersTable, email, password: 'secret123' }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const ok = await request(app.getHttpServer()) + .post(`/sitenova/${connectionId}/auth/login`) + .send({ tableName: usersTable, email, password: 'secret123' }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(ok.status, 200); + t.is(typeof JSON.parse(ok.text).token, 'string'); + + const wrongPassword = await request(app.getHttpServer()) + .post(`/sitenova/${connectionId}/auth/login`) + .send({ tableName: usersTable, email, password: 'WRONG' }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(wrongPassword.status, 401); + + const unknownEmail = await request(app.getHttpServer()) + .post(`/sitenova/${connectionId}/auth/login`) + .send({ tableName: usersTable, email: 'nobody@example.com', password: 'secret123' }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(unknownEmail.status, 401); +}); + +currentTest = 'POST /sitenova/:connectionId/data/* writes (end-user JWT)'; + +async function registerAndGetToken(connectionId: string, usersTable: string): Promise { + const res = await request(app.getHttpServer()) + .post(`/sitenova/${connectionId}/auth/register`) + .send({ tableName: usersTable, email: faker.internet.email().toLowerCase(), password: 'secret123' }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + return JSON.parse(res.text).token; +} + +test.serial(`${currentTest} create/update/delete succeed with a valid token`, async (t) => { + const { connectionId, userId, testTableName, testTableColumnName, testTableSecondColumnName } = + await createConnectionAndTable(); + const usersTable = await createUsersTable(connectionId, userId); + const token = await registerAndGetToken(connectionId, usersTable); + + const fakeName = faker.person.firstName(); + const createRes = await request(app.getHttpServer()) + .post(`/sitenova/${connectionId}/data/create`) + .send({ + tableName: testTableName, + row: { [testTableColumnName]: fakeName, [testTableSecondColumnName]: faker.internet.email() }, + }) + .set('Authorization', `Bearer ${token}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createRes.status, 201); + t.is(JSON.parse(createRes.text).row[testTableColumnName], fakeName); + + const updatedName = faker.person.firstName(); + const updateRes = await request(app.getHttpServer()) + .post(`/sitenova/${connectionId}/data/update`) + .send({ tableName: testTableName, primaryKey: { id: 1 }, row: { [testTableColumnName]: updatedName } }) + .set('Authorization', `Bearer ${token}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(updateRes.status, 200); + t.is(JSON.parse(updateRes.text).row[testTableColumnName], updatedName); + + const deleteRes = await request(app.getHttpServer()) + .post(`/sitenova/${connectionId}/data/delete`) + .send({ tableName: testTableName, primaryKey: { id: 2 } }) + .set('Authorization', `Bearer ${token}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(deleteRes.status, 200); + t.is(JSON.parse(deleteRes.text).row.id, 2); +}); + +test.serial(`${currentTest} create is rejected (401) without a token`, async (t) => { + const { connectionId, testTableName, testTableColumnName } = await createConnectionAndTable(); + const res = await request(app.getHttpServer()) + .post(`/sitenova/${connectionId}/data/create`) + .send({ tableName: testTableName, row: { [testTableColumnName]: faker.person.firstName() } }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(res.status, 401); +}); + +test.serial(`${currentTest} a token minted for another connection is rejected (401)`, async (t) => { + const first = await createConnectionAndTable(); + const usersTable = await createUsersTable(first.connectionId, first.userId); + const tokenForFirst = await registerAndGetToken(first.connectionId, usersTable); + + const second = await createConnectionAndTable(); + + const res = await request(app.getHttpServer()) + .post(`/sitenova/${second.connectionId}/data/create`) + .send({ tableName: second.testTableName, row: { [second.testTableColumnName]: faker.person.firstName() } }) + .set('Authorization', `Bearer ${tokenForFirst}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(res.status, 401); +}); + +currentTest = 'POST /sitenova/:connectionId/data/rows|read (public read policy)'; + +test.serial(`${currentTest} reads are refused (403) until public access is enabled, then allowed`, async (t) => { + const { connectionId, token, testTableName, testTableColumnName } = await createConnectionAndTable(); + + const refused = await request(app.getHttpServer()) + .post(`/sitenova/${connectionId}/data/rows`) + .send({ tableName: testTableName, page: 1, perPage: 10 }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(refused.status, 403); + + await enablePublicRead(connectionId, token, testTableName); + + const rows = await request(app.getHttpServer()) + .post(`/sitenova/${connectionId}/data/rows`) + .send({ tableName: testTableName, page: 1, perPage: 10 }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(rows.status, 200); + const rowsRO = JSON.parse(rows.text); + t.is(Object.hasOwn(rowsRO, 'rows'), true); + t.is(Object.hasOwn(rowsRO, 'pagination'), true); + t.is(rowsRO.rows.length, 10); + + const read = await request(app.getHttpServer()) + .post(`/sitenova/${connectionId}/data/read`) + .send({ tableName: testTableName, primaryKey: { id: 1 } }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(read.status, 200); + t.is(JSON.parse(read.text).row.id, 1); + t.is(typeof JSON.parse(read.text).row[testTableColumnName], 'string'); +});