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/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -86,6 +87,7 @@ import { GetHelloUseCase } from './use-cases-app/get-hello.use.case.js';
TableActionModule,
SaasModule,
AgentsModule,
SitenovaModule,
CompanyInfoModule,
SaaSGatewayModule,
TableTriggersModule,
Expand Down
4 changes: 4 additions & 0 deletions backend/src/common/data-injection.tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

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<string, unknown>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class SitenovaExecuteRawQueryDs {
connectionId: string;
userId: string;
masterPassword: string | null;
query: string;
tableName: string | null;
}
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

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<string, unknown>;
}

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<string, unknown>;
}

export class SitenovaSiteRowByPrimaryKeyDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
tableName: string;

@ApiProperty({ type: Object })
@IsObject()
primaryKey: Record<string, unknown>;
}

export class SitenovaSiteUpdateRowDto extends SitenovaSiteRowByPrimaryKeyDto {
@ApiProperty({ type: Object })
@IsObject()
row: Record<string, unknown>;
}

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<string, unknown>;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<boolean> {
const request = context.switchToHttp().getRequest<RequestWithSitenovaEndUser>();
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;
}
}
Original file line number Diff line number Diff line change
@@ -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<boolean> {
const request = context.switchToHttp().getRequest<Request>();
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;
}
}
Loading
Loading