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 @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string> | null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> | null;
}
Original file line number Diff line number Diff line change
@@ -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.' })
Expand Down Expand Up @@ -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<string>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
return await this.getOrCreateSigningKey(connectionId);
}

private secretSlug(connectionId: string): string {
return `sitenova:enduser-jwt:${connectionId}`;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand All @@ -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<SitenovaConnectionForSiteRuntimeRO> {
return await this.getConnectionUseCase.execute({ connectionId }, InTransactionEnum.OFF);
Comment on lines +49 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

controller="$(fd -a 'sitenova-internal.controller.ts' | head -n1)"
sed -n '1,120p' "$controller"

rg -n -C5 \
  'UseGuards|UseInterceptors|Jwt|JWT|microservice|SitenovaInternalController|connectionId' \
  backend/src

Repository: rocket-admin/rocketadmin

Length of output: 50381


🏁 Script executed:

#!/bin/bash
set -euo pipefail

controller="$(fd -a 'sitenova-internal.controller.ts' | head -n1)"
module="$(fd -a 'sitenova.module.ts' | head -n1)"

echo "== controller =="
sed -n '1,140p' "$controller"

echo
echo "== sitenova module =="
sed -n '1,180p' "$module"

echo
echo "== auth guard decorators/modules in sitenova internal area =="
rg -n --iglob '*.ts' \
  'UseGuards|Guard|HttpUnauthorized|UnauthorizedException|Jwt|jwt|SitenovaInternal|`@Controller`' \
  backend/src/microservices/sitenova-microservice backend/src/common backend/src/decorators backend/src/guards backend/src/filters 2>/dev/null \
  | head -n 200

echo
echo "== focused UseGuards/Jwt/JWT in relevant files =="
while IFS= read -r f; do
  echo "--- $f"
  rg -n -C3 '(UseGuards|AuthGuard|jwt|jwt|JWT|microservice|sitenova-internal|ConnectionReadGuard|Sitenova' "$f" \
    | head -n 120
done < <(fd -a --iglob '*.ts' backend/src | rg 'sitenova|auth-guard|connection|microservice|jwt|interceptor|guard' | head -n 80)

Repository: rocket-admin/rocketadmin

Length of output: 18051


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== SaaSAuthMiddleware =="
fd -a 'saas-auth.middleware.ts' backend/src | sed -n '1,20p' | while read -r f; do
  echo "--- $f"
  sed -n '1,240p' "$f"
done

echo
echo "== SitenovaGetConnectionUseCase =="
usecase="$(fd -a 'sitenova-get-connection.use.case.ts' backend/src | head -n1)"
sed -n '1,160p' "$usecase"

echo
echo "== connection decrypt/signing key methods =="
rg -n -C4 'getDecrypted|decrypt.*password|endUserJwtKey|getEndUserSigningKey|secret-access-log|secret.*access|SitenovaGetConnection' backend/src/microservices/sitenova-microservice backend/src/common backend/src/authorization backend/src/entities -g '*.ts' | head -n 240

echo
echo "== SaaSAuthMiddleware focused symbols/usages =="
rg -n -C4 'SaaSAuthMiddleware|jwt|connection|companyId|tenant|internal|microservice|connectionId|Authorization|bearer|secret-access-log|secretAccess' backend/src -g '*.ts' | head -n 300

Repository: rocket-admin/rocketadmin

Length of output: 50380


IDOR (CWE-639): Authorization Bypass Through User-Controlled Key (IDOR)

Reachability: External

Reachability path
● Entry
  backend/src/microservices/sitenova-microservice/sitenova.module.ts:106
  SitenovaInternalController
│
▼
● Sink
  backend/src/microservices/sitenova-microservice/sitenova-internal.controller.ts

Add connection-level authorization to the SitNova connection-credentials endpoint.

SaaSAuthMiddleware only checks the microservice JWT and accepts request_id; it does not scope the request to a connection or tenant. getConnection() passes the caller-controlled connectionId directly to findAndDecryptConnection(), then returns decrypted credentials and the HS256 signing key. Require an explicit connections[*]/service authorization claim or a matching connection guard before returning connection secrets.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/src/microservices/sitenova-microservice/sitenova-internal.controller.ts`
around lines 49 - 53, The getConnection endpoint currently trusts the
caller-controlled connectionId and returns decrypted credentials without
connection-level authorization. Update getConnection and its authorization flow
to require a valid connections[*] or service authorization claim, or an
equivalent guard proving access to that specific connection, before invoking
getConnectionUseCase.execute and returning secrets; reject unauthorized or
mismatched connection requests.

}

@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<SitenovaPublicReadValidationRO> {
return await this.validatePublicReadUseCase.execute(
{
connectionId,
tableName: body.tableName,
columnNames: body.columnNames ?? null,
},
InTransactionEnum.OFF,
);
}
Comment on lines +42 to +78

@ApiOperation({
summary: 'Execute a raw, write-capable SQL statement against the connected database.',
description:
Expand Down
10 changes: 10 additions & 0 deletions backend/src/microservices/sitenova-microservice/sitenova.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SitenovaGetConnectionDs, SitenovaConnectionForSiteRuntimeRO>
implements ISitenovaGetConnection
{
constructor(
@Inject(BaseType.GLOBAL_DB_CONTEXT)
protected _dbContext: IGlobalDatabaseContext,
private readonly endUserAuthService: SitenovaEndUserAuthService,
) {
super();
}

protected async implementation(inputData: SitenovaGetConnectionDs): Promise<SitenovaConnectionForSiteRuntimeRO> {
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,
};
}
}
Original file line number Diff line number Diff line change
@@ -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<SitenovaRawQueryResultRO>;
}

export interface ISitenovaGetConnection {
execute(
inputData: SitenovaGetConnectionDs,
inTransaction: InTransactionEnum,
): Promise<SitenovaConnectionForSiteRuntimeRO>;
}

export interface ISitenovaValidatePublicRead {
execute(
inputData: SitenovaValidatePublicReadDs,
inTransaction: InTransactionEnum,
): Promise<SitenovaPublicReadValidationRO>;
}
Original file line number Diff line number Diff line change
@@ -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<SitenovaValidatePublicReadDs, SitenovaPublicReadValidationRO>
implements ISitenovaValidatePublicRead
{
protected _dbContext: IDatabaseContext | null = null;

constructor(
private readonly cedarAuthService: CedarAuthorizationService,
private readonly cedarPermissions: CedarPermissionsService,
) {
super();
}
Comment on lines +1 to +26

protected async implementation(inputData: SitenovaValidatePublicReadDs): Promise<SitenovaPublicReadValidationRO> {
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<string> | 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 };
}
}
Loading