diff --git a/backend/src/common/data-injection.tokens.ts b/backend/src/common/data-injection.tokens.ts index d13b3a112..f3b9bc321 100644 --- a/backend/src/common/data-injection.tokens.ts +++ b/backend/src/common/data-injection.tokens.ts @@ -195,6 +195,8 @@ export enum UseCaseType { SITENOVA_EXECUTE_RAW_QUERY = 'SITENOVA_EXECUTE_RAW_QUERY', SITENOVA_REGISTER_ENDUSER = 'SITENOVA_REGISTER_ENDUSER', SITENOVA_LOGIN_ENDUSER = 'SITENOVA_LOGIN_ENDUSER', + SITENOVA_GET_CONNECTION = 'SITENOVA_GET_CONNECTION', + SITENOVA_VALIDATE_PUBLIC_READ = 'SITENOVA_VALIDATE_PUBLIC_READ', CREATE_TABLE_FILTERS = 'CREATE_TABLE_FILTERS', FIND_TABLE_FILTERS = 'FIND_TABLE_FILTERS', diff --git a/backend/src/microservices/sitenova-microservice/data-structures/sitenova-internal-responses.ds.ts b/backend/src/microservices/sitenova-microservice/data-structures/sitenova-internal-responses.ds.ts new file mode 100644 index 000000000..ef8b49917 --- /dev/null +++ b/backend/src/microservices/sitenova-microservice/data-structures/sitenova-internal-responses.ds.ts @@ -0,0 +1,76 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +// Decrypted connection credentials handed to the universal-backend service so it can open the +// customer database itself (with Knex). Serving this over the microservice-JWT channel keeps the +// core the sole owner of credential storage/decryption; the satellite never sees the core DB or +// the encryption keys. Same trust level as the existing write-capable raw-query edge. +export class SitenovaDecryptedConnectionRO { + @ApiProperty() + id: string; + + @ApiProperty({ description: 'Connection engine type (e.g. postgres, mysql).' }) + type: string; + + @ApiPropertyOptional() + host?: string | null; + + @ApiPropertyOptional() + port?: number | null; + + @ApiPropertyOptional() + username?: string | null; + + @ApiPropertyOptional() + password?: string | null; + + @ApiPropertyOptional() + database?: string | null; + + @ApiPropertyOptional() + schema?: string | null; + + @ApiPropertyOptional() + ssl?: boolean | null; + + @ApiPropertyOptional() + cert?: string | null; + + @ApiPropertyOptional({ description: 'True when the connection reaches the DB over an SSH tunnel.' }) + ssh?: boolean; + + @ApiPropertyOptional() + azure_encryption?: boolean; + + @ApiPropertyOptional() + sid?: string | null; + + @ApiPropertyOptional() + authSource?: string | null; + + @ApiPropertyOptional() + dataCenter?: string | null; +} + +export class SitenovaConnectionForSiteRuntimeRO { + @ApiProperty({ type: SitenovaDecryptedConnectionRO }) + connection: SitenovaDecryptedConnectionRO; + + @ApiProperty({ + description: + 'Per-connection HS256 key for generated-site end-user JWTs (created on first request). ' + + 'Lets the caller sign/verify the same tokens SitenovaEndUserAuthService does.', + }) + endUserJwtKey: string; +} + +export class SitenovaPublicReadValidationRO { + @ApiProperty({ description: 'True when the connection public policy grants table:query on the table.' }) + allowed: boolean; + + @ApiProperty({ + type: [String], + nullable: true, + description: 'Publicly readable subset of the submitted columnNames; null when columnNames was not submitted.', + }) + readableColumns: Array | null; +} diff --git a/backend/src/microservices/sitenova-microservice/data-structures/sitenova.ds.ts b/backend/src/microservices/sitenova-microservice/data-structures/sitenova.ds.ts index ef8d5776c..19636c3de 100644 --- a/backend/src/microservices/sitenova-microservice/data-structures/sitenova.ds.ts +++ b/backend/src/microservices/sitenova-microservice/data-structures/sitenova.ds.ts @@ -5,3 +5,13 @@ export class SitenovaExecuteRawQueryDs { query: string; tableName: string | null; } + +export class SitenovaGetConnectionDs { + connectionId: string; +} + +export class SitenovaValidatePublicReadDs { + connectionId: string; + tableName: string; + columnNames: Array | null; +} diff --git a/backend/src/microservices/sitenova-microservice/dto/sitenova.dtos.ts b/backend/src/microservices/sitenova-microservice/dto/sitenova.dtos.ts index e11c81533..d3db6ca90 100644 --- a/backend/src/microservices/sitenova-microservice/dto/sitenova.dtos.ts +++ b/backend/src/microservices/sitenova-microservice/dto/sitenova.dtos.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; +import { IsArray, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; export class SitenovaBaseDto { @ApiProperty({ description: 'Id of the RocketAdmin user the operation is performed on behalf of.' }) @@ -27,3 +27,19 @@ export class SitenovaExecuteRawQueryDto extends SitenovaBaseDto { @IsString() tableName?: string | null; } + +export class SitenovaValidatePublicReadDto { + @ApiProperty({ description: 'Table the anonymous read targets.' }) + @IsString() + @IsNotEmpty() + tableName: string; + + @ApiPropertyOptional({ + type: [String], + description: 'When provided, the response includes the publicly readable subset of these columns.', + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + columnNames?: Array; +} 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 index 467dd72f5..91fbe0696 100644 --- a/backend/src/microservices/sitenova-microservice/services/sitenova-enduser-auth.service.ts +++ b/backend/src/microservices/sitenova-microservice/services/sitenova-enduser-auth.service.ts @@ -53,6 +53,12 @@ export class SitenovaEndUserAuthService { } } + // Exposes the signing key to the internal (microservice-JWT) connection-credentials endpoint, + // so the universal-backend service can sign/verify the same end-user tokens locally. + public async getEndUserSigningKey(connectionId: string): Promise { + return await this.getOrCreateSigningKey(connectionId); + } + private secretSlug(connectionId: string): string { return `sitenova:enduser-jwt:${connectionId}`; } diff --git a/backend/src/microservices/sitenova-microservice/sitenova-internal.controller.ts b/backend/src/microservices/sitenova-microservice/sitenova-internal.controller.ts index b5ef76cb3..2367f2ab2 100644 --- a/backend/src/microservices/sitenova-microservice/sitenova-internal.controller.ts +++ b/backend/src/microservices/sitenova-microservice/sitenova-internal.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Inject, Injectable, Post, UseInterceptors } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, HttpStatus, 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'; @@ -7,9 +7,17 @@ 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 { + SitenovaConnectionForSiteRuntimeRO, + SitenovaPublicReadValidationRO, +} from './data-structures/sitenova-internal-responses.ds.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'; +import { SitenovaExecuteRawQueryDto, SitenovaValidatePublicReadDto } from './dto/sitenova.dtos.js'; +import { + ISitenovaExecuteRawQuery, + ISitenovaGetConnection, + ISitenovaValidatePublicRead, +} 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 @@ -25,8 +33,50 @@ export class SitenovaInternalController { constructor( @Inject(UseCaseType.SITENOVA_EXECUTE_RAW_QUERY) private readonly executeRawQueryUseCase: ISitenovaExecuteRawQuery, + @Inject(UseCaseType.SITENOVA_GET_CONNECTION) + private readonly getConnectionUseCase: ISitenovaGetConnection, + @Inject(UseCaseType.SITENOVA_VALIDATE_PUBLIC_READ) + private readonly validatePublicReadUseCase: ISitenovaValidatePublicRead, ) {} + @ApiOperation({ + summary: 'Return decrypted connection credentials plus the end-user JWT signing key.', + description: + 'Serves the universal-backend service so it can open the customer database itself and ' + + 'sign/verify generated-site end-user tokens. Agent connections are rejected (no stored credentials).', + }) + @ApiResponse({ status: 200, type: SitenovaConnectionForSiteRuntimeRO }) + @Get('/connection/:connectionId') + public async getConnection( + @SlugUuid('connectionId') connectionId: string, + ): Promise { + return await this.getConnectionUseCase.execute({ connectionId }, InTransactionEnum.OFF); + } + + @ApiOperation({ + summary: 'Evaluate the connection public policy for an anonymous table read.', + description: + 'Same Cedar evaluation SitenovaPublicReadGuard applies on the core /sitenova data routes, plus ' + + 'the publicly readable subset of the submitted columns — for satellites running the query themselves.', + }) + @ApiResponse({ status: 200, type: SitenovaPublicReadValidationRO }) + @ApiBody({ type: SitenovaValidatePublicReadDto }) + @HttpCode(HttpStatus.OK) + @Post('/validate-public-read/:connectionId') + public async validatePublicRead( + @SlugUuid('connectionId') connectionId: string, + @Body() body: SitenovaValidatePublicReadDto, + ): Promise { + return await this.validatePublicReadUseCase.execute( + { + connectionId, + tableName: body.tableName, + columnNames: body.columnNames ?? null, + }, + InTransactionEnum.OFF, + ); + } + @ApiOperation({ summary: 'Execute a raw, write-capable SQL statement against the connected database.', description: diff --git a/backend/src/microservices/sitenova-microservice/sitenova.module.ts b/backend/src/microservices/sitenova-microservice/sitenova.module.ts index c7f551ee1..2d39fd0de 100644 --- a/backend/src/microservices/sitenova-microservice/sitenova.module.ts +++ b/backend/src/microservices/sitenova-microservice/sitenova.module.ts @@ -29,8 +29,10 @@ import { SitenovaEndUserAuthService } from './services/sitenova-enduser-auth.ser 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 { SitenovaGetConnectionUseCase } from './use-cases/sitenova-get-connection.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'; +import { SitenovaValidatePublicReadUseCase } from './use-cases/sitenova-validate-public-read.use.case.js'; @Module({ imports: [ @@ -64,6 +66,14 @@ import { SitenovaRegisterEndUserUseCase } from './use-cases/sitenova-register-en provide: UseCaseType.SITENOVA_EXECUTE_RAW_QUERY, useClass: SitenovaExecuteRawQueryUseCase, }, + { + provide: UseCaseType.SITENOVA_GET_CONNECTION, + useClass: SitenovaGetConnectionUseCase, + }, + { + provide: UseCaseType.SITENOVA_VALIDATE_PUBLIC_READ, + useClass: SitenovaValidatePublicReadUseCase, + }, { provide: UseCaseType.SITENOVA_REGISTER_ENDUSER, useClass: SitenovaRegisterEndUserUseCase, diff --git a/backend/src/microservices/sitenova-microservice/use-cases/sitenova-get-connection.use.case.ts b/backend/src/microservices/sitenova-microservice/use-cases/sitenova-get-connection.use.case.ts new file mode 100644 index 000000000..3d6800838 --- /dev/null +++ b/backend/src/microservices/sitenova-microservice/use-cases/sitenova-get-connection.use.case.ts @@ -0,0 +1,60 @@ +import { BadRequestException, Inject, Injectable } 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 { validateConnection } from '../../../entities/table/utils/validate-connection.util.js'; +import { isConnectionTypeAgent } from '../../../helpers/is-connection-entity-agent.js'; +import { SitenovaGetConnectionDs } from '../data-structures/sitenova.ds.js'; +import { SitenovaConnectionForSiteRuntimeRO } from '../data-structures/sitenova-internal-responses.ds.js'; +import { SitenovaEndUserAuthService } from '../services/sitenova-enduser-auth.service.js'; +import { ISitenovaGetConnection } from './sitenova-use-cases.interface.js'; + +@Injectable() +export class SitenovaGetConnectionUseCase + extends AbstractUseCase + implements ISitenovaGetConnection +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + private readonly endUserAuthService: SitenovaEndUserAuthService, + ) { + super(); + } + + protected async implementation(inputData: SitenovaGetConnectionDs): Promise { + const { connectionId } = inputData; + + const connection = await this._dbContext.connectionRepository.findAndDecryptConnection(connectionId, ''); + validateConnection(connection); + + // Agent connections store no credentials on the core (queries run inside the customer + // network); the universal-backend service cannot open them with the returned config. + if (isConnectionTypeAgent(connection.type)) { + throw new BadRequestException('Agent connections have no stored credentials to hand out.'); + } + + const endUserJwtKey = await this.endUserAuthService.getEndUserSigningKey(connectionId); + + return { + connection: { + id: connection.id, + type: connection.type as string, + host: connection.host, + port: connection.port ?? null, + username: connection.username, + password: connection.password, + database: connection.database, + schema: connection.schema, + ssl: connection.ssl, + cert: connection.cert, + ssh: connection.ssh, + azure_encryption: connection.azure_encryption, + sid: connection.sid, + authSource: connection.authSource, + dataCenter: connection.dataCenter, + }, + endUserJwtKey, + }; + } +} 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 index dc613a3ff..164e32c48 100644 --- 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 @@ -1,7 +1,29 @@ import { InTransactionEnum } from '../../../enums/in-transaction.enum.js'; -import { SitenovaExecuteRawQueryDs } from '../data-structures/sitenova.ds.js'; +import { + SitenovaExecuteRawQueryDs, + SitenovaGetConnectionDs, + SitenovaValidatePublicReadDs, +} from '../data-structures/sitenova.ds.js'; +import { + SitenovaConnectionForSiteRuntimeRO, + SitenovaPublicReadValidationRO, +} from '../data-structures/sitenova-internal-responses.ds.js'; import { SitenovaRawQueryResultRO } from '../data-structures/sitenova-responses.ds.js'; export interface ISitenovaExecuteRawQuery { execute(inputData: SitenovaExecuteRawQueryDs, inTransaction: InTransactionEnum): Promise; } + +export interface ISitenovaGetConnection { + execute( + inputData: SitenovaGetConnectionDs, + inTransaction: InTransactionEnum, + ): Promise; +} + +export interface ISitenovaValidatePublicRead { + execute( + inputData: SitenovaValidatePublicReadDs, + inTransaction: InTransactionEnum, + ): Promise; +} diff --git a/backend/src/microservices/sitenova-microservice/use-cases/sitenova-validate-public-read.use.case.ts b/backend/src/microservices/sitenova-microservice/use-cases/sitenova-validate-public-read.use.case.ts new file mode 100644 index 000000000..405209785 --- /dev/null +++ b/backend/src/microservices/sitenova-microservice/use-cases/sitenova-validate-public-read.use.case.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; +import AbstractUseCase from '../../../common/abstract-use.case.js'; +import { IDatabaseContext } from '../../../common/database-context.interface.js'; +import { CedarAction, PUBLIC_USER_ID } from '../../../entities/cedar-authorization/cedar-action-map.js'; +import { CedarAuthorizationService } from '../../../entities/cedar-authorization/cedar-authorization.service.js'; +import { CedarPermissionsService } from '../../../entities/cedar-authorization/cedar-permissions.service.js'; +import { SitenovaValidatePublicReadDs } from '../data-structures/sitenova.ds.js'; +import { SitenovaPublicReadValidationRO } from '../data-structures/sitenova-internal-responses.ds.js'; +import { ISitenovaValidatePublicRead } from './sitenova-use-cases.interface.js'; + +// Bridges SitenovaPublicReadGuard's Cedar evaluation (allowed?) and the public readable-columns +// projection to the universal-backend service, which runs the actual query itself but must apply +// the exact same public-read policy the core applies on its own /sitenova data routes. +@Injectable() +export class SitenovaValidatePublicReadUseCase + extends AbstractUseCase + implements ISitenovaValidatePublicRead +{ + protected _dbContext: IDatabaseContext | null = null; + + constructor( + private readonly cedarAuthService: CedarAuthorizationService, + private readonly cedarPermissions: CedarPermissionsService, + ) { + super(); + } + + protected async implementation(inputData: SitenovaValidatePublicReadDs): Promise { + const { connectionId, tableName, columnNames } = inputData; + + const publicEnabled = await this.cedarAuthService.isPublicAccessEnabled(connectionId); + if (!publicEnabled) { + return { allowed: false, readableColumns: null }; + } + const allowed = await this.cedarAuthService.validate({ + userId: PUBLIC_USER_ID, + action: CedarAction.TableQuery, + connectionId, + tableName, + publicAccess: true, + }); + if (!allowed) { + return { allowed: false, readableColumns: null }; + } + + let readableColumns: Array | null = null; + if (columnNames && columnNames.length > 0) { + const readable = await this.cedarPermissions.getReadableColumnsForPublic(connectionId, tableName, columnNames); + readableColumns = columnNames.filter((columnName) => readable.has(columnName)); + } + return { allowed: true, readableColumns }; + } +}