diff --git a/backend/src/authorization/public-or-auth.middleware.ts b/backend/src/authorization/public-or-auth.middleware.ts new file mode 100644 index 000000000..7c717511f --- /dev/null +++ b/backend/src/authorization/public-or-auth.middleware.ts @@ -0,0 +1,129 @@ +import { + HttpException, + Injectable, + InternalServerErrorException, + NestMiddleware, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import Sentry from '@sentry/minimal'; +import { NextFunction, Response } from 'express'; +import jwt from 'jsonwebtoken'; +import { Repository } from 'typeorm'; +import { JwtScopesEnum } from '../entities/user/enums/jwt-scopes.enum.js'; +import { UserEntity } from '../entities/user/user.entity.js'; +import { EncryptionAlgorithmEnum } from '../enums/encryption-algorithm.enum.js'; +import { TwoFaRequiredException } from '../exceptions/custom-exceptions/two-fa-required-exception.js'; +import { Messages } from '../exceptions/text/messages.js'; +import { Constants } from '../helpers/constants/constants.js'; +import { Encryptor } from '../helpers/encryption/encryptor.js'; +import { isObjectEmpty } from '../helpers/is-object-empty.js'; +import { appConfig } from '../shared/config/app-config.js'; +import { IRequestWithCognitoInfo } from './cognito-decoded.interface.js'; + +/** + * Authentication middleware that ALSO allows anonymous ("public") requests through. + * + * - A JWT cookie or an `x-api-key` header is authenticated exactly like AuthWithApiMiddleware and + * populates `req.decoded`. + * - When neither is present, the request is treated as public: `req.decoded` is left empty and the + * request continues. Downstream guards then decide whether the connection's public policy grants + * access. An invalid/expired credential still fails fast. + * + * This is applied only to read-capable pure CRUD routes; write routes keep AuthWithApiMiddleware. + */ +@Injectable() +export class PublicOrAuthMiddleware implements NestMiddleware { + public constructor( + @InjectRepository(UserEntity) + private readonly userRepository: Repository, + ) {} + + async use(req: IRequestWithCognitoInfo, _res: Response, next: NextFunction): Promise { + try { + const tokenFromCookie = req.cookies?.[Constants.JWT_COOKIE_KEY_NAME]; + let apiKey = req.headers?.['x-api-key']; + if (Array.isArray(apiKey)) { + apiKey = apiKey[0]; + } + + if (tokenFromCookie) { + await this.authenticateWithToken(tokenFromCookie, req); + } else if (apiKey) { + await this.authenticateWithApiKey(apiKey, req); + } else { + req.decoded = {}; + } + next(); + } catch (error) { + Sentry.captureException(error); + if (error instanceof HttpException || error instanceof UnauthorizedException) { + throw error; + } + throw new InternalServerErrorException(Messages.AUTHORIZATION_REJECTED); + } + } + + private async authenticateWithToken(tokenFromCookie: string, req: IRequestWithCognitoInfo): Promise { + const jwtSecret = appConfig.auth.jwtSecret; + if (!jwtSecret) { + throw new UnauthorizedException('JWT verification failed'); + } + const data = jwt.verify(tokenFromCookie, jwtSecret) as jwt.JwtPayload; + const userId = data.id; + + if (!userId) { + throw new UnauthorizedException('JWT verification failed'); + } + + const userExists = await this.userRepository.findOne({ where: { id: userId } }); + if (!userExists) { + throw new UnauthorizedException('JWT verification failed'); + } + + if (userExists.suspended) { + throw new UnauthorizedException(Messages.ACCOUNT_SUSPENDED); + } + + const addedScope: Array = data.scope; + if (addedScope && addedScope.length > 0) { + if (addedScope.includes(JwtScopesEnum.TWO_FA_ENABLE)) { + throw new TwoFaRequiredException(); + } + } + + const payload = { + sub: userId, + email: data.email, + exp: data.exp, + iat: data.iat, + }; + if (!payload || isObjectEmpty(payload)) { + throw new UnauthorizedException('JWT verification failed'); + } + req.decoded = payload; + } + + private async authenticateWithApiKey(apiKey: string, req: IRequestWithCognitoInfo): Promise { + const apiKeyHash = await Encryptor.processDataWithAlgorithm(apiKey, EncryptionAlgorithmEnum.sha256); + const foundUserByApiKey = await this.userRepository + .createQueryBuilder('user') + .innerJoinAndSelect('user.api_keys', 'api_key') + .where('api_key.hash = :hash', { hash: apiKeyHash }) + .getOne(); + + if (!foundUserByApiKey) { + throw new NotFoundException(Messages.NO_AUTH_KEYS_FOUND); + } + + if (foundUserByApiKey.suspended) { + throw new UnauthorizedException(Messages.API_KEY_SUSPENDED); + } + + req.decoded = { + sub: foundUserByApiKey.id, + email: foundUserByApiKey.email, + }; + } +} diff --git a/backend/src/decorators/optional-user-id.decorator.ts b/backend/src/decorators/optional-user-id.decorator.ts new file mode 100644 index 000000000..b826cb0e2 --- /dev/null +++ b/backend/src/decorators/optional-user-id.decorator.ts @@ -0,0 +1,22 @@ +import { BadRequestException, createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { IRequestWithCognitoInfo } from '../authorization/cognito-decoded.interface.js'; +import { Messages } from '../exceptions/text/messages.js'; +import { ValidationHelper } from '../helpers/validators/validation-helper.js'; + +/** + * Like @UserId(), but tolerates an unauthenticated ("public") request: when no user is present it + * returns undefined instead of throwing. Use on endpoints that may be reached by public users + * (the guard decides whether public access is allowed); the handler then treats a missing userId + * as a public request. + */ +export const OptionalUserId = createParamDecorator((_data: unknown, ctx: ExecutionContext): string | undefined => { + const request: IRequestWithCognitoInfo = ctx.switchToHttp().getRequest(); + const userId = request.decoded?.sub; + if (!userId) { + return undefined; + } + if (ValidationHelper.isValidUUID(userId)) { + return userId; + } + throw new BadRequestException(Messages.UUID_INVALID); +}); diff --git a/backend/src/entities/cedar-authorization/cedar-action-map.ts b/backend/src/entities/cedar-authorization/cedar-action-map.ts index 559784ffa..b27513c69 100644 --- a/backend/src/entities/cedar-authorization/cedar-action-map.ts +++ b/backend/src/entities/cedar-authorization/cedar-action-map.ts @@ -40,6 +40,8 @@ export const ACTION_EVENT_PROBE_ID = '__probe__'; export const COLUMN_PROBE_ID = '__probe__'; +export const PUBLIC_USER_ID = '__public__'; + export interface CedarValidationRequest { userId: string; action: CedarAction; @@ -50,4 +52,5 @@ export interface CedarValidationRequest { actionEventId?: string; dashboardId?: string; panelId?: string; + publicAccess?: boolean; } diff --git a/backend/src/entities/cedar-authorization/cedar-authorization.controller.ts b/backend/src/entities/cedar-authorization/cedar-authorization.controller.ts index 35ed12e48..2d39b48b8 100644 --- a/backend/src/entities/cedar-authorization/cedar-authorization.controller.ts +++ b/backend/src/entities/cedar-authorization/cedar-authorization.controller.ts @@ -6,6 +6,7 @@ import { HttpStatus, Injectable, Post, + Put, UseGuards, UseInterceptors, } from '@nestjs/common'; @@ -18,6 +19,7 @@ import { ConnectionReadGuard } from '../../guards/connection-read.guard.js'; import { SentryInterceptor } from '../../interceptors/sentry.interceptor.js'; import { IComplexPermission } from '../permission/permission.interface.js'; import { CedarAuthorizationService } from './cedar-authorization.service.js'; +import { PublicPermissionsResponseDto, SetPublicPermissionsDto } from './dto/public-permissions.dto.js'; import { SaveCedarPolicyDto } from './dto/save-cedar-policy.dto.js'; import { ValidateCedarSchemaDto } from './dto/validate-cedar-schema.dto.js'; @@ -87,4 +89,40 @@ export class CedarAuthorizationController { } return this.cedarAuthService.saveCedarPolicy(connectionId, dto.groupId, dto.cedarPolicy); } + + @ApiOperation({ + summary: 'Get the public (unauthenticated) read permissions configured for a connection', + }) + @ApiResponse({ status: 200, description: 'Public permissions returned.', type: PublicPermissionsResponseDto }) + @ApiParam({ name: 'connectionId', required: true }) + @UseGuards(ConnectionEditGuard) + @Get('/connection/public-permissions/:connectionId') + async getPublicPermissions(@SlugUuid('connectionId') connectionId: string): Promise { + if (!connectionId) { + throw new HttpException({ message: Messages.CONNECTION_ID_MISSING }, HttpStatus.BAD_REQUEST); + } + return this.cedarAuthService.getPublicPermissions(connectionId); + } + + @ApiOperation({ + summary: 'Set the public (unauthenticated) read permissions for a connection', + description: + 'Generates and stores a Cedar policy granting public users QueryTable + ColumnRead on the listed tables. ' + + 'Pass an empty "tables" array to disable public access.', + }) + @ApiResponse({ status: 200, description: 'Public permissions saved.', type: PublicPermissionsResponseDto }) + @ApiBody({ type: SetPublicPermissionsDto }) + @ApiParam({ name: 'connectionId', required: true }) + @UseGuards(ConnectionEditGuard) + @Put('/connection/public-permissions/:connectionId') + async setPublicPermissions( + @SlugUuid('connectionId') connectionId: string, + @Body() dto: SetPublicPermissionsDto, + ): Promise { + if (!connectionId) { + throw new HttpException({ message: Messages.CONNECTION_ID_MISSING }, HttpStatus.BAD_REQUEST); + } + const { enabled, tables } = await this.cedarAuthService.savePublicPermissions(connectionId, dto.tables ?? []); + return { enabled, tables }; + } } diff --git a/backend/src/entities/cedar-authorization/cedar-authorization.module.ts b/backend/src/entities/cedar-authorization/cedar-authorization.module.ts index 220b44c48..3847e5290 100644 --- a/backend/src/entities/cedar-authorization/cedar-authorization.module.ts +++ b/backend/src/entities/cedar-authorization/cedar-authorization.module.ts @@ -31,6 +31,8 @@ export class CedarAuthorizationModule implements NestModule { { path: '/connection/cedar-schema/:connectionId', method: RequestMethod.GET }, { path: '/connection/cedar-schema/validate/:connectionId', method: RequestMethod.POST }, { path: '/connection/cedar-policy/:connectionId', method: RequestMethod.POST }, + { path: '/connection/public-permissions/:connectionId', method: RequestMethod.GET }, + { path: '/connection/public-permissions/:connectionId', method: RequestMethod.PUT }, ); } } diff --git a/backend/src/entities/cedar-authorization/cedar-authorization.service.ts b/backend/src/entities/cedar-authorization/cedar-authorization.service.ts index 9d2c39fa5..3b26a1ba1 100644 --- a/backend/src/entities/cedar-authorization/cedar-authorization.service.ts +++ b/backend/src/entities/cedar-authorization/cedar-authorization.service.ts @@ -13,9 +13,11 @@ import { CedarAction, CedarResourceType, CedarValidationRequest, + PUBLIC_USER_ID, } from './cedar-action-map.js'; import { ICedarAuthorizationService } from './cedar-authorization.service.interface.js'; import { buildCedarEntities } from './cedar-entity-builder.js'; +import { generatePublicCedarPolicy, IPublicTablePermission } from './cedar-policy-generator.js'; import { parseCedarPolicyToClassicalPermissions } from './cedar-policy-parser.js'; import { CEDAR_SCHEMA } from './cedar-schema.js'; @@ -35,6 +37,10 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On } async validate(request: CedarValidationRequest): Promise { + if (request.publicAccess) { + return this.validatePublic(request); + } + const { userId, action, groupId, tableName, columnName, dashboardId, panelId, actionEventId } = request; let { connectionId } = request; @@ -280,7 +286,18 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On columnName, ); - for (const policy of groupPolicies) { + return this.isAllowedByPolicies(groupPolicies, userId, action, resourceType, resourceId, entities); + } + + private isAllowedByPolicies( + policies: string[], + userId: string, + action: CedarAction, + resourceType: CedarResourceType, + resourceId: string, + entities: ReturnType, + ): boolean { + for (const policy of policies) { const call = { principal: { type: CEDAR_USER_TYPE, id: userId }, action: { type: CEDAR_ACTION_TYPE, id: action }, @@ -297,13 +314,129 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On return true; } } else { - this.logger.warn(`Cedar authorization error for group policy: ${JSON.stringify(result.errors)}`); + this.logger.warn(`Cedar authorization error for policy: ${JSON.stringify(result.errors)}`); } } return false; } + // Evaluates the connection's public policy (unauthenticated access). Public access only ever + // grants QueryTable + ColumnRead, so any other action is denied outright. + private async validatePublic(request: CedarValidationRequest): Promise { + const { action, tableName, columnName } = request; + const { connectionId } = request; + if (!connectionId) return false; + + const publicPolicy = await this.loadPublicPolicy(connectionId); + if (!publicPolicy) return false; + const policies = [publicPolicy]; + + switch (action) { + case CedarAction.TableQuery: + case CedarAction.TableRead: { + if (!tableName) return false; + const entities = buildCedarEntities(PUBLIC_USER_ID, [], connectionId, tableName); + return this.isAllowedByPolicies( + policies, + PUBLIC_USER_ID, + CedarAction.TableQuery, + CedarResourceType.Table, + `${connectionId}/${tableName}`, + entities, + ); + } + case CedarAction.ColumnRead: { + if (!tableName || !columnName) return false; + const entities = buildCedarEntities( + PUBLIC_USER_ID, + [], + connectionId, + tableName, + undefined, + undefined, + undefined, + columnName, + ); + return this.isAllowedByPolicies( + policies, + PUBLIC_USER_ID, + CedarAction.ColumnRead, + CedarResourceType.Column, + `${connectionId}/${tableName}/${columnName}`, + entities, + ); + } + default: + return false; + } + } + + async isPublicAccessEnabled(connectionId: string): Promise { + return (await this.loadPublicPolicy(connectionId)) !== null; + } + + async getPublicPermissions( + connectionId: string, + ): Promise<{ enabled: boolean; tables: Array }> { + const policy = await this.loadPublicPolicy(connectionId); + if (!policy) { + return { enabled: false, tables: [] }; + } + const parsed = parseCedarPolicyToClassicalPermissions(policy, connectionId, ''); + const tables = parsed.tables.map((table) => ({ + tableName: table.tableName, + readableColumns: table.readableColumns, + })); + return { enabled: true, tables }; + } + + async savePublicPermissions( + connectionId: string, + tables: Array, + ): Promise<{ enabled: boolean; publicCedarPolicy: string | null; tables: Array }> { + const policy = generatePublicCedarPolicy(connectionId, tables); + const hasPolicy = policy.trim().length > 0; + if (hasPolicy) { + this.validateCedarPolicyText(policy); + await this.validatePolicyReferences(policy, connectionId); + this.validatePublicPolicyActions(policy); + } + const storedPolicy = hasPolicy ? policy : null; + await this.globalDbContext.connectionRepository.updateConnectionPublicCedarPolicy(connectionId, storedPolicy); + Cacher.invalidateCedarPolicyCache(connectionId); + return { enabled: hasPolicy, publicCedarPolicy: storedPolicy, tables: hasPolicy ? tables : [] }; + } + + // Caches the connection's public policy under the existing cedar policy cache (keyed by the + // public sentinel principal). An empty string is cached to mean "no public access". + private async loadPublicPolicy(connectionId: string): Promise { + const cached = Cacher.getCedarPolicyCache(connectionId, PUBLIC_USER_ID); + if (cached !== null) { + return cached.trim().length > 0 ? cached : null; + } + const policy = await this.globalDbContext.connectionRepository.getConnectionPublicCedarPolicy(connectionId); + Cacher.setCedarPolicyCache(connectionId, PUBLIC_USER_ID, policy ?? ''); + return policy && policy.trim().length > 0 ? policy : null; + } + + private validatePublicPolicyActions(policyText: string): void { + // An unconstrained action (`permit(principal, action, resource)`) would grant everything. + if (/,\s*action\s*,/.test(policyText)) { + throw new HttpException({ message: Messages.PUBLIC_POLICY_ACTION_NOT_ALLOWED }, HttpStatus.BAD_REQUEST); + } + const allowed = new Set([CedarAction.TableQuery, CedarAction.ColumnRead]); + // Capture every action literal regardless of the operator used: `action == Action::"x"`, + // `action in [Action::"x", Action::"y"]`, or `action in Action::"x"`. The Action type only + // ever appears in the action clause, so matching it anywhere in the policy text is correct. + const actions = [...policyText.matchAll(/RocketAdmin::Action::"([^"]+)"/g)].map((m) => m[1]); + for (const action of actions) { + if (!allowed.has(action)) { + throw new HttpException({ message: Messages.PUBLIC_POLICY_ACTION_NOT_ALLOWED }, HttpStatus.BAD_REQUEST); + } + } + } + private loadPoliciesPerGroup(userGroups: Array): string[] { return userGroups.map((g) => g.cedarPolicy).filter((policy): policy is string => Boolean(policy)); } diff --git a/backend/src/entities/cedar-authorization/cedar-permissions.service.ts b/backend/src/entities/cedar-authorization/cedar-permissions.service.ts index 8a5389b4f..626a73e44 100644 --- a/backend/src/entities/cedar-authorization/cedar-permissions.service.ts +++ b/backend/src/entities/cedar-authorization/cedar-permissions.service.ts @@ -15,6 +15,7 @@ import { CedarAction, CedarResourceType, COLUMN_PROBE_ID, + PUBLIC_USER_ID, } from './cedar-action-map.js'; import { buildCedarEntities } from './cedar-entity-builder.js'; import { CEDAR_SCHEMA } from './cedar-schema.js'; @@ -308,6 +309,30 @@ export class CedarPermissionsService implements IUserAccessRepository { return readable; } + // Public (unauthenticated) counterpart of getReadableColumns: evaluates the connection's + // public_cedar_policy instead of any user's group policies. Returns the readable subset, or an + // empty set when public access is disabled. + async getReadableColumnsForPublic( + connectionId: string, + tableName: string, + allColumnNames: Array, + ): Promise> { + const ctx = await this.loadPublicContext(connectionId); + if (!ctx) return new Set(); + + if (this.evaluateColumnRead(PUBLIC_USER_ID, connectionId, tableName, COLUMN_PROBE_ID, ctx)) { + return new Set(allColumnNames); + } + + const readable = new Set(); + for (const columnName of allColumnNames) { + if (this.evaluateColumnRead(PUBLIC_USER_ID, connectionId, tableName, columnName, ctx)) { + readable.add(columnName); + } + } + return readable; + } + async checkTableAdd( cognitoUserName: string, connectionId: string, @@ -598,4 +623,23 @@ export class CedarPermissionsService implements IUserAccessRepository { return { userGroups, policies }; } + + // Public-access context: no groups, a single policy taken from the connection's + // public_cedar_policy. The synthetic principal is irrelevant since generated policies leave the + // principal unconstrained. + private async loadPublicContext(connectionId: string): Promise { + const policy = await this.loadPublicPolicy(connectionId); + if (!policy) return null; + return { userGroups: [], policies: [policy] }; + } + + private async loadPublicPolicy(connectionId: string): Promise { + const cached = Cacher.getCedarPolicyCache(connectionId, PUBLIC_USER_ID); + if (cached !== null) { + return cached.trim().length > 0 ? cached : null; + } + const policy = await this.globalDbContext.connectionRepository.getConnectionPublicCedarPolicy(connectionId); + Cacher.setCedarPolicyCache(connectionId, PUBLIC_USER_ID, policy ?? ''); + return policy && policy.trim().length > 0 ? policy : null; + } } diff --git a/backend/src/entities/cedar-authorization/cedar-policy-generator.ts b/backend/src/entities/cedar-authorization/cedar-policy-generator.ts index 4fd10a247..4695d1acd 100644 --- a/backend/src/entities/cedar-authorization/cedar-policy-generator.ts +++ b/backend/src/entities/cedar-authorization/cedar-policy-generator.ts @@ -1,13 +1,61 @@ import { AccessLevelEnum } from '../../enums/access-level.enum.js'; import { IComplexPermission } from '../permission/permission.interface.js'; +export interface IPublicTablePermission { + tableName: string; + // Whitelist of columns readable by public users. Omitted/empty ⇒ all columns readable. + readableColumns?: Array; +} + +function escapeCedarString(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); +} + +function cedarEntityRef(entityType: string, id: string): string { + return `${entityType}::"${escapeCedarString(id)}"`; +} + +export function generatePublicCedarPolicy(connectionId: string, tables: Array): string { + const policies: Array = []; + + for (const table of tables) { + if (!table.tableName) continue; + const tableRef = cedarEntityRef('RocketAdmin::Table', `${connectionId}/${table.tableName}`); + + policies.push( + `permit(\n principal,\n action == RocketAdmin::Action::"table:query",\n resource == ${tableRef}\n);`, + ); + + const readableColumns = table.readableColumns; + if (readableColumns && readableColumns.length > 0) { + for (const columnName of readableColumns) { + const columnRef = cedarEntityRef('RocketAdmin::Column', `${connectionId}/${table.tableName}/${columnName}`); + policies.push( + `permit(\n principal,\n action == RocketAdmin::Action::"column:read",\n resource == ${columnRef}\n);`, + ); + } + } else { + policies.push( + `permit(\n principal,\n action == RocketAdmin::Action::"column:read",\n resource in ${tableRef}\n);`, + ); + } + } + + return policies.join('\n\n'); +} + export function generateCedarPolicyForGroup( connectionId: string, isMain: boolean, permissions: IComplexPermission, ): string { const policies: Array = []; - const connectionRef = `RocketAdmin::Connection::"${connectionId}"`; + const connectionRef = cedarEntityRef('RocketAdmin::Connection', connectionId); if (isMain) { policies.push(`permit(\n principal,\n action,\n resource\n);`); @@ -34,7 +82,7 @@ export function generateCedarPolicyForGroup( // Group permissions const groupAccess = permissions.group.accessLevel; - const groupResourceRef = `RocketAdmin::Group::"${permissions.group.groupId}"`; + const groupResourceRef = cedarEntityRef('RocketAdmin::Group', permissions.group.groupId); if (groupAccess === AccessLevelEnum.edit) { policies.push( `permit(\n principal,\n action == RocketAdmin::Action::"group:read",\n resource == ${groupResourceRef}\n);`, @@ -52,7 +100,7 @@ export function generateCedarPolicyForGroup( let hasCreatePermission = false; let hasReadPermission = false; for (const dashboard of permissions.dashboards) { - const dashboardRef = `RocketAdmin::Dashboard::"${connectionId}/${dashboard.dashboardId}"`; + const dashboardRef = cedarEntityRef('RocketAdmin::Dashboard', `${connectionId}/${dashboard.dashboardId}`); const access = dashboard.accessLevel; if (access.read) { @@ -75,7 +123,7 @@ export function generateCedarPolicyForGroup( ); } } - const newDashboardRef = `RocketAdmin::Dashboard::"${connectionId}/__new__"`; + const newDashboardRef = cedarEntityRef('RocketAdmin::Dashboard', `${connectionId}/__new__`); if (hasReadPermission) { policies.push( `permit(\n principal,\n action == RocketAdmin::Action::"dashboard:read",\n resource == ${newDashboardRef}\n);`, @@ -92,7 +140,7 @@ export function generateCedarPolicyForGroup( let hasPanelCreatePermission = false; let hasPanelReadPermission = false; for (const panel of permissions.panels) { - const panelRef = `RocketAdmin::Panel::"${connectionId}/${panel.panelId}"`; + const panelRef = cedarEntityRef('RocketAdmin::Panel', `${connectionId}/${panel.panelId}`); const access = panel.accessLevel; if (access.read) { @@ -115,7 +163,7 @@ export function generateCedarPolicyForGroup( ); } } - const newPanelRef = `RocketAdmin::Panel::"${connectionId}/__new__"`; + const newPanelRef = cedarEntityRef('RocketAdmin::Panel', `${connectionId}/__new__`); if (hasPanelReadPermission) { policies.push( `permit(\n principal,\n action == RocketAdmin::Action::"panel:read",\n resource == ${newPanelRef}\n);`, @@ -129,7 +177,7 @@ export function generateCedarPolicyForGroup( } for (const table of permissions.tables) { - const tableRef = `RocketAdmin::Table::"${connectionId}/${table.tableName}"`; + const tableRef = cedarEntityRef('RocketAdmin::Table', `${connectionId}/${table.tableName}`); const access = table.accessLevel; // triggerCustomAction is intentionally excluded from hasAnyAccess: triggering custom @@ -146,7 +194,7 @@ export function generateCedarPolicyForGroup( const readableColumns = table.readableColumns; if (readableColumns && readableColumns.length > 0) { for (const columnName of readableColumns) { - const columnRef = `RocketAdmin::Column::"${connectionId}/${table.tableName}/${columnName}"`; + const columnRef = cedarEntityRef('RocketAdmin::Column', `${connectionId}/${table.tableName}/${columnName}`); policies.push( `permit(\n principal,\n action == RocketAdmin::Action::"column:read",\n resource == ${columnRef}\n);`, ); @@ -188,7 +236,10 @@ export function generateCedarPolicyForGroup( if (permissions.actionEvents) { for (const event of permissions.actionEvents) { if (!event.accessLevel?.trigger) continue; - const eventRef = `RocketAdmin::ActionEvent::"${connectionId}/${event.tableName}/${event.eventId}"`; + const eventRef = cedarEntityRef( + 'RocketAdmin::ActionEvent', + `${connectionId}/${event.tableName}/${event.eventId}`, + ); policies.push( `permit(\n principal,\n action == RocketAdmin::Action::"actionEvent:trigger",\n resource == ${eventRef}\n);`, ); diff --git a/backend/src/entities/cedar-authorization/dto/public-permissions.dto.ts b/backend/src/entities/cedar-authorization/dto/public-permissions.dto.ts new file mode 100644 index 000000000..9bbde0d4f --- /dev/null +++ b/backend/src/entities/cedar-authorization/dto/public-permissions.dto.ts @@ -0,0 +1,40 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsArray, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator'; + +export class PublicTablePermissionDto { + @ApiProperty({ description: 'Table the public users may query.' }) + @IsNotEmpty() + @IsString() + tableName: string; + + @ApiPropertyOptional({ + description: 'Whitelist of columns public users may read. Omit or leave empty to allow all columns.', + type: [String], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + readableColumns?: Array; +} + +export class SetPublicPermissionsDto { + @ApiProperty({ + description: + 'Tables exposed to unauthenticated (public) users. Each entry grants QueryTable and ColumnRead. ' + + 'Pass an empty array to disable public access for the connection.', + type: [PublicTablePermissionDto], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PublicTablePermissionDto) + tables: Array; +} + +export class PublicPermissionsResponseDto { + @ApiProperty({ description: 'Whether public access is enabled for this connection.' }) + enabled: boolean; + + @ApiProperty({ type: [PublicTablePermissionDto] }) + tables: Array; +} diff --git a/backend/src/entities/connection/connection.entity.ts b/backend/src/entities/connection/connection.entity.ts index b58e28b7a..907e1cd2a 100644 --- a/backend/src/entities/connection/connection.entity.ts +++ b/backend/src/entities/connection/connection.entity.ts @@ -117,6 +117,9 @@ export class ConnectionEntity { @Column({ type: 'varchar', default: null }) master_hash?: string | null; + @Column({ type: 'text', nullable: true, default: null }) + public_cedar_policy?: string | null; + /** * Non-persisted flag indicating whether credentials are currently in decrypted state. * Used by @BeforeUpdate to decide whether encryption is needed. diff --git a/backend/src/entities/connection/repository/connection.repository.interface.ts b/backend/src/entities/connection/repository/connection.repository.interface.ts index 69b161a7c..51bf9a9fd 100644 --- a/backend/src/entities/connection/repository/connection.repository.interface.ts +++ b/backend/src/entities/connection/repository/connection.repository.interface.ts @@ -28,6 +28,10 @@ export interface IConnectionRepository { saveUpdatedConnection(connection: ConnectionEntity): Promise; + getConnectionPublicCedarPolicy(connectionId: string): Promise; + + updateConnectionPublicCedarPolicy(connectionId: string, publicCedarPolicy: string | null): Promise; + findOneAgentConnectionByToken(connectionToken: string): Promise; decryptConnectionField(field: string): string; diff --git a/backend/src/entities/connection/repository/custom-connection-repository-extension.ts b/backend/src/entities/connection/repository/custom-connection-repository-extension.ts index de98e4b2f..5b354b9b2 100644 --- a/backend/src/entities/connection/repository/custom-connection-repository-extension.ts +++ b/backend/src/entities/connection/repository/custom-connection-repository-extension.ts @@ -200,6 +200,22 @@ export const customConnectionRepositoryExtension: IConnectionRepository & return this.save(connection); }, + async getConnectionPublicCedarPolicy(connectionId: string): Promise { + const connection = await this.createQueryBuilder('connection') + .select(['connection.id', 'connection.public_cedar_policy']) + .where('connection.id = :connectionId', { connectionId }) + .getOne(); + return connection?.public_cedar_policy ?? null; + }, + + async updateConnectionPublicCedarPolicy(connectionId: string, publicCedarPolicy: string | null): Promise { + await this.createQueryBuilder() + .update(ConnectionEntity) + .set({ public_cedar_policy: publicCedarPolicy }) + .where('id = :connectionId', { connectionId }) + .execute(); + }, + async isUserFromConnection(userId: string, connectionId: string): Promise { const qb = this.createQueryBuilder('connection') .leftJoin('connection.groups', 'group') diff --git a/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-get-rows.ds.ts b/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-get-rows.ds.ts index aeb370042..cbbc98016 100644 --- a/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-get-rows.ds.ts +++ b/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-get-rows.ds.ts @@ -6,6 +6,6 @@ export class PureGetRowsDs { query: Record; searchingFieldValue: string; tableName: string; - userId: string; + userId: string | undefined; filters?: Record; } diff --git a/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-read-row.ds.ts b/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-read-row.ds.ts index 948f29f72..b92c2ebec 100644 --- a/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-read-row.ds.ts +++ b/backend/src/entities/table/table-pure-crud-operations/application/data-structures/pure-read-row.ds.ts @@ -3,5 +3,5 @@ export class PureReadRowDs { masterPwd: string; primaryKey: Record; tableName: string; - userId: string; + userId: string | undefined; } diff --git a/backend/src/entities/table/table-pure-crud-operations/table-pure-crud-operations.controller.ts b/backend/src/entities/table/table-pure-crud-operations/table-pure-crud-operations.controller.ts index fcc2253ea..df96e5b03 100644 --- a/backend/src/entities/table/table-pure-crud-operations/table-pure-crud-operations.controller.ts +++ b/backend/src/entities/table/table-pure-crud-operations/table-pure-crud-operations.controller.ts @@ -20,6 +20,7 @@ import { getDataAccessObject } from '@rocketadmin/shared-code/dist/src/data-acce import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; import { BaseType, UseCaseType } from '../../../common/data-injection.tokens.js'; import { MasterPassword } from '../../../decorators/master-password.decorator.js'; +import { OptionalUserId } from '../../../decorators/optional-user-id.decorator.js'; import { QueryTableName } from '../../../decorators/query-table-name.decorator.js'; import { SlugUuid } from '../../../decorators/slug-uuid.decorator.js'; import { Timeout, TimeoutDefaults } from '../../../decorators/timeout.decorator.js'; @@ -125,7 +126,7 @@ export class TablePureCrudOperationsController { @Query('search') searchingFieldValue: string, @Query() query: Record, @SlugUuid('connectionId') connectionId: string, - @UserId() userId: string, + @OptionalUserId() userId: string | undefined, @MasterPassword() masterPwd: string, @Body() body: FindAllRowsWithBodyFiltersDto, ): Promise { @@ -167,7 +168,7 @@ export class TablePureCrudOperationsController { @Query() query: Record, @MasterPassword() masterPwd: string, @SlugUuid('connectionId') connectionId: string, - @UserId() userId: string, + @OptionalUserId() userId: string | undefined, @QueryTableName() tableName: string, ): Promise { const primaryKey = await this.extractPrimaryKeyFromQuery(userId, connectionId, tableName, query, masterPwd); @@ -249,7 +250,7 @@ export class TablePureCrudOperationsController { } private async extractPrimaryKeyFromQuery( - userId: string, + userId: string | undefined, connectionId: string, tableName: string, query: Record, @@ -260,7 +261,7 @@ export class TablePureCrudOperationsController { throw new ConnectionNotFoundException(HttpStatus.BAD_REQUEST); } let userEmail = ''; - if (isConnectionTypeAgent(connection.type)) { + if (userId && isConnectionTypeAgent(connection.type)) { userEmail = (await this._dbContext.userRepository.getUserEmailOrReturnNull(userId)) ?? ''; } const dao = getDataAccessObject(connection); diff --git a/backend/src/entities/table/table-pure-crud-operations/table-pure-crud-operations.module.ts b/backend/src/entities/table/table-pure-crud-operations/table-pure-crud-operations.module.ts index 359df02ac..bfeed8bc6 100644 --- a/backend/src/entities/table/table-pure-crud-operations/table-pure-crud-operations.module.ts +++ b/backend/src/entities/table/table-pure-crud-operations/table-pure-crud-operations.module.ts @@ -1,6 +1,6 @@ import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { AuthWithApiMiddleware } from '../../../authorization/auth-with-api.middleware.js'; +import { PublicOrAuthMiddleware } from '../../../authorization/public-or-auth.middleware.js'; import { GlobalDatabaseContext } from '../../../common/application/global-database-context.js'; import { BaseType, UseCaseType } from '../../../common/data-injection.tokens.js'; import { AgentModule } from '../../agent/agent.module.js'; @@ -69,8 +69,12 @@ import { PureUpdateRowInTableUseCase } from './use-cases/pure-update-row-in-tabl }) export class TablePureCrudOperationsModule { public configure(consumer: MiddlewareConsumer): void { + // Allow public (unauthenticated) requests through; the per-operation guards enforce the + // connection's public policy. Authenticated JWT / api-key requests behave as before. + // Write operations can never be granted publicly (only QueryTable + ColumnRead are allowed), + // so their guards reject public requests. consumer - .apply(AuthWithApiMiddleware) + .apply(PublicOrAuthMiddleware) .forRoutes( { path: '/table/crud/:connectionId', method: RequestMethod.POST }, { path: '/table/crud/rows/:connectionId', method: RequestMethod.POST }, diff --git a/backend/src/entities/table/table-pure-crud-operations/use-cases/pure-get-rows-from-table.use.case.ts b/backend/src/entities/table/table-pure-crud-operations/use-cases/pure-get-rows-from-table.use.case.ts index 6bdf86926..6380b431b 100644 --- a/backend/src/entities/table/table-pure-crud-operations/use-cases/pure-get-rows-from-table.use.case.ts +++ b/backend/src/entities/table/table-pure-crud-operations/use-cases/pure-get-rows-from-table.use.case.ts @@ -13,9 +13,14 @@ import { UnknownSQLException } from '../../../../exceptions/custom-exceptions/un import { hexToBinary, isBinary } from '../../../../helpers/binary-to-hex.js'; import { getErrorMessage } from '../../../../helpers/get-error-message.js'; import { isObjectEmpty } from '../../../../helpers/is-object-empty.js'; +import { CedarPermissionsService } from '../../../cedar-authorization/cedar-permissions.service.js'; import { TableSettingsEntity } from '../../../table-settings/common-table-settings/table-settings.entity.js'; import { FilteringFieldsDs } from '../../table-datastructures.js'; import { buildCommonTableSettingsInput } from '../../utils/build-common-table-settings-input.util.js'; +import { + filterRowsByReadableColumns, + isAllColumnsReadable, +} from '../../utils/filter-columns-by-read-permission.util.js'; import { findFilteringFieldsUtil, parseFilteringFieldsFromBodyData } from '../../utils/find-filtering-fields.util.js'; import { findOrderingFieldUtil } from '../../utils/find-ordering-field.util.js'; import { isHexString } from '../../utils/is-hex-string.js'; @@ -33,6 +38,7 @@ export class PureGetRowsFromTableUseCase constructor( @Inject(BaseType.GLOBAL_DB_CONTEXT) protected _dbContext: IGlobalDatabaseContext, + private readonly cedarPermissions: CedarPermissionsService, ) { super(); } @@ -105,6 +111,14 @@ export class PureGetRowsFromTableUseCase rows = processRowsUtil(rows, tableWidgets, tableCustomFields); + const allColumnNames = tableStructure.map((column) => column.column_name); + const readableColumns = userId + ? await this.cedarPermissions.getReadableColumns(userId, connectionId, tableName, allColumnNames) + : await this.cedarPermissions.getReadableColumnsForPublic(connectionId, tableName, allColumnNames); + if (!isAllColumnsReadable(readableColumns, allColumnNames)) { + rows.data = filterRowsByReadableColumns(rows.data, readableColumns); + } + return { rows: rows.data, pagination: rows.pagination, diff --git a/backend/src/entities/table/table-pure-crud-operations/use-cases/pure-read-row-from-table.use.case.ts b/backend/src/entities/table/table-pure-crud-operations/use-cases/pure-read-row-from-table.use.case.ts index 7f3838c51..6a9f5d1c7 100644 --- a/backend/src/entities/table/table-pure-crud-operations/use-cases/pure-read-row-from-table.use.case.ts +++ b/backend/src/entities/table/table-pure-crud-operations/use-cases/pure-read-row-from-table.use.case.ts @@ -11,8 +11,13 @@ import { UnknownSQLException } from '../../../../exceptions/custom-exceptions/un import { Messages } from '../../../../exceptions/text/messages.js'; import { compareArrayElements } from '../../../../helpers/compare-array-elements.js'; import { getErrorMessage } from '../../../../helpers/get-error-message.js'; +import { CedarPermissionsService } from '../../../cedar-authorization/cedar-permissions.service.js'; import { buildCommonTableSettingsInput } from '../../utils/build-common-table-settings-input.util.js'; import { convertHexDataInPrimaryKeyUtil } from '../../utils/convert-hex-data-in-primary-key.util.js'; +import { + filterRowByReadableColumns, + isAllColumnsReadable, +} from '../../utils/filter-columns-by-read-permission.util.js'; import { removePasswordsFromRowsUtil } from '../../utils/remove-password-from-row.util.js'; import { getUserEmailForAgent, validateConnection } from '../../utils/validate-connection.util.js'; import { PureCrudRowResponseDs } from '../application/data-structures/pure-crud-row-response.ds.js'; @@ -27,6 +32,7 @@ export class PureReadRowFromTableUseCase constructor( @Inject(BaseType.GLOBAL_DB_CONTEXT) protected _dbContext: IGlobalDatabaseContext, + private readonly cedarPermissions: CedarPermissionsService, ) { super(); } @@ -77,6 +83,15 @@ export class PureReadRowFromTableUseCase } rowData = removePasswordsFromRowsUtil(rowData, tableWidgets); + + const allColumnNames = tableStructure.map((column) => column.column_name); + const readableColumns = userId + ? await this.cedarPermissions.getReadableColumns(userId, connectionId, tableName, allColumnNames) + : await this.cedarPermissions.getReadableColumnsForPublic(connectionId, tableName, allColumnNames); + if (!isAllColumnsReadable(readableColumns, allColumnNames)) { + rowData = filterRowByReadableColumns(rowData, readableColumns); + } + return { row: rowData }; } } diff --git a/backend/src/entities/table/utils/validate-connection.util.ts b/backend/src/entities/table/utils/validate-connection.util.ts index f84f82529..04a32cd9d 100644 --- a/backend/src/entities/table/utils/validate-connection.util.ts +++ b/backend/src/entities/table/utils/validate-connection.util.ts @@ -16,10 +16,10 @@ export function validateConnection(connection: ConnectionEntity | null): asserts export async function getUserEmailForAgent( connection: ConnectionEntity, - userId: string, + userId: string | undefined, userRepository: { getUserEmailOrReturnNull(userId: string): Promise }, ): Promise { - if (isConnectionTypeAgent(connection.type)) { + if (userId && isConnectionTypeAgent(connection.type)) { return (await userRepository.getUserEmailOrReturnNull(userId)) ?? ''; } return ''; diff --git a/backend/src/exceptions/text/messages.ts b/backend/src/exceptions/text/messages.ts index 7c25fe413..13f87efbf 100644 --- a/backend/src/exceptions/text/messages.ts +++ b/backend/src/exceptions/text/messages.ts @@ -102,6 +102,9 @@ export const Messages = { CEDAR_POLICY_REFERENCES_FOREIGN_CONNECTION: 'Cedar policy references a connection that does not match the target connection', CEDAR_POLICY_REFERENCES_FOREIGN_PRINCIPAL: 'Cedar policy principal must reference the target group', + PUBLIC_POLICY_ACTION_NOT_ALLOWED: + 'Public permissions may only grant QueryTable (table:query) and ColumnRead (column:read)', + PUBLIC_ACCESS_NOT_CONFIGURED: 'Public access is not configured for this connection', CSV_EXPORT_FAILED: 'CSV export failed', CSV_EXPORT_DISABLED: 'CSV export is disabled', CSV_IMPORT_FAILED: 'CSV import failed', diff --git a/backend/src/guards/query-table.guard.ts b/backend/src/guards/query-table.guard.ts index 5e03e0a77..af84e4e01 100644 --- a/backend/src/guards/query-table.guard.ts +++ b/backend/src/guards/query-table.guard.ts @@ -1,7 +1,7 @@ import { BadRequestException, CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; import { Observable } from 'rxjs'; import { IRequestWithCognitoInfo } from '../authorization/cognito-decoded.interface.js'; -import { CedarAction } from '../entities/cedar-authorization/cedar-action-map.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 { Messages } from '../exceptions/text/messages.js'; import { ValidationHelper } from '../helpers/validators/validation-helper.js'; @@ -15,10 +15,6 @@ export class QueryTableGuard implements CanActivate { return new Promise(async (resolve, reject) => { const request: IRequestWithCognitoInfo = context.switchToHttp().getRequest(); const cognitoUserName = request.decoded.sub; - if (!cognitoUserName) { - reject(new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS)); - return; - } const connectionId: string | undefined = request.params?.slug || request.params?.connectionId; const tableName: string | undefined = request.query?.tableName; if (!tableName) { @@ -31,6 +27,29 @@ export class QueryTableGuard implements CanActivate { } try { + // Unauthenticated (public) request: refuse unless the connection defines a public policy, + // then evaluate that policy. QueryTable is one of the two grantable public actions. + if (!cognitoUserName) { + const publicEnabled = await this.cedarAuthService.isPublicAccessEnabled(connectionId); + if (!publicEnabled) { + reject(new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS)); + return; + } + const allowedPublic = await this.cedarAuthService.validate({ + userId: PUBLIC_USER_ID, + action: CedarAction.TableQuery, + connectionId, + tableName, + publicAccess: true, + }); + if (allowedPublic) { + resolve(true); + return; + } + reject(new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS)); + return; + } + const allowed = await this.cedarAuthService.validate({ userId: cognitoUserName, action: CedarAction.TableQuery, diff --git a/backend/src/guards/table-add.guard.ts b/backend/src/guards/table-add.guard.ts index dfb823948..a5eba406e 100644 --- a/backend/src/guards/table-add.guard.ts +++ b/backend/src/guards/table-add.guard.ts @@ -1,7 +1,7 @@ import { BadRequestException, CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; import { Observable } from 'rxjs'; import { IRequestWithCognitoInfo } from '../authorization/cognito-decoded.interface.js'; -import { CedarAction } from '../entities/cedar-authorization/cedar-action-map.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 { Messages } from '../exceptions/text/messages.js'; import { ValidationHelper } from '../helpers/validators/validation-helper.js'; @@ -15,10 +15,6 @@ export class TableAddGuard implements CanActivate { return new Promise(async (resolve, reject) => { const request: IRequestWithCognitoInfo = context.switchToHttp().getRequest(); const cognitoUserName = request.decoded.sub; - if (!cognitoUserName) { - reject(new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS)); - return; - } const connectionId: string | undefined = request.params?.slug || request.params?.connectionId; const tableName: string | undefined = request.query?.tableName; if (!tableName) { @@ -31,6 +27,29 @@ export class TableAddGuard implements CanActivate { } try { + // Public requests can never write: refuse outright when unauthenticated. The public + // policy may only grant QueryTable + ColumnRead, so TableAdd is never permitted. + if (!cognitoUserName) { + const publicEnabled = await this.cedarAuthService.isPublicAccessEnabled(connectionId); + if (!publicEnabled) { + reject(new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS)); + return; + } + const allowedPublic = await this.cedarAuthService.validate({ + userId: PUBLIC_USER_ID, + action: CedarAction.TableAdd, + connectionId, + tableName, + publicAccess: true, + }); + if (allowedPublic) { + resolve(true); + return; + } + reject(new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS)); + return; + } + const allowed = await this.cedarAuthService.validate({ userId: cognitoUserName, action: CedarAction.TableAdd, diff --git a/backend/src/guards/table-delete.guard.ts b/backend/src/guards/table-delete.guard.ts index 4f8577c77..55917e27a 100644 --- a/backend/src/guards/table-delete.guard.ts +++ b/backend/src/guards/table-delete.guard.ts @@ -1,7 +1,7 @@ import { BadRequestException, CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; import { Observable } from 'rxjs'; import { IRequestWithCognitoInfo } from '../authorization/cognito-decoded.interface.js'; -import { CedarAction } from '../entities/cedar-authorization/cedar-action-map.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 { Messages } from '../exceptions/text/messages.js'; import { ValidationHelper } from '../helpers/validators/validation-helper.js'; @@ -15,10 +15,6 @@ export class TableDeleteGuard implements CanActivate { return new Promise(async (resolve, reject) => { const request: IRequestWithCognitoInfo = context.switchToHttp().getRequest(); const cognitoUserName = request.decoded.sub; - if (!cognitoUserName) { - reject(new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS)); - return; - } const connectionId: string | undefined = request.params?.slug || request.params?.connectionId; const tableName: string | undefined = request.query?.tableName; if (!tableName) { @@ -31,6 +27,29 @@ export class TableDeleteGuard implements CanActivate { } try { + // Public requests can never write: refuse outright when unauthenticated. The public + // policy may only grant QueryTable + ColumnRead, so TableDelete is never permitted. + if (!cognitoUserName) { + const publicEnabled = await this.cedarAuthService.isPublicAccessEnabled(connectionId); + if (!publicEnabled) { + reject(new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS)); + return; + } + const allowedPublic = await this.cedarAuthService.validate({ + userId: PUBLIC_USER_ID, + action: CedarAction.TableDelete, + connectionId, + tableName, + publicAccess: true, + }); + if (allowedPublic) { + resolve(true); + return; + } + reject(new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS)); + return; + } + const allowed = await this.cedarAuthService.validate({ userId: cognitoUserName, action: CedarAction.TableDelete, diff --git a/backend/src/guards/table-edit.guard.ts b/backend/src/guards/table-edit.guard.ts index 6d6da2643..5d74c7067 100644 --- a/backend/src/guards/table-edit.guard.ts +++ b/backend/src/guards/table-edit.guard.ts @@ -1,7 +1,7 @@ import { BadRequestException, CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; import { Observable } from 'rxjs'; import { IRequestWithCognitoInfo } from '../authorization/cognito-decoded.interface.js'; -import { CedarAction } from '../entities/cedar-authorization/cedar-action-map.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 { Messages } from '../exceptions/text/messages.js'; import { ValidationHelper } from '../helpers/validators/validation-helper.js'; @@ -15,10 +15,6 @@ export class TableEditGuard implements CanActivate { return new Promise(async (resolve, reject) => { const request: IRequestWithCognitoInfo = context.switchToHttp().getRequest(); const cognitoUserName = request.decoded.sub; - if (!cognitoUserName) { - reject(new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS)); - return; - } const connectionId: string | undefined = request.params?.slug || request.params?.connectionId; const tableName: string | undefined = request.query?.tableName; if (!tableName) { @@ -31,6 +27,29 @@ export class TableEditGuard implements CanActivate { } try { + // Public requests can never write: refuse outright when unauthenticated. The public + // policy may only grant QueryTable + ColumnRead, so TableEdit is never permitted. + if (!cognitoUserName) { + const publicEnabled = await this.cedarAuthService.isPublicAccessEnabled(connectionId); + if (!publicEnabled) { + reject(new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS)); + return; + } + const allowedPublic = await this.cedarAuthService.validate({ + userId: PUBLIC_USER_ID, + action: CedarAction.TableEdit, + connectionId, + tableName, + publicAccess: true, + }); + if (allowedPublic) { + resolve(true); + return; + } + reject(new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS)); + return; + } + const allowed = await this.cedarAuthService.validate({ userId: cognitoUserName, action: CedarAction.TableEdit, diff --git a/backend/src/migrations/1781536092947-AddPublicCedarPolicyToConnection.ts b/backend/src/migrations/1781536092947-AddPublicCedarPolicyToConnection.ts new file mode 100644 index 000000000..287e2dfed --- /dev/null +++ b/backend/src/migrations/1781536092947-AddPublicCedarPolicyToConnection.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPublicCedarPolicyToConnection1781536092947 implements MigrationInterface { + name = 'AddPublicCedarPolicyToConnection1781536092947'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "connection" ADD "public_cedar_policy" text`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "connection" DROP COLUMN "public_cedar_policy"`); + } +} diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-generator.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-generator.test.ts index 7a5ebd155..196a9c797 100644 --- a/backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-generator.test.ts +++ b/backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-generator.test.ts @@ -328,3 +328,96 @@ test('resource ref format validation', (t) => { t.true(result.includes(`RocketAdmin::Connection::"${connectionId}"`)); t.true(result.includes(`RocketAdmin::Table::"${connectionId}/users"`)); }); + +import { generatePublicCedarPolicy } from '../../../src/entities/cedar-authorization/cedar-policy-generator.js'; + +test('generatePublicCedarPolicy: empty tables produces an empty policy (public access disabled)', (t) => { + const result = generatePublicCedarPolicy(connectionId, []); + t.is(result.trim(), ''); +}); + +test('generatePublicCedarPolicy: a table without readableColumns grants table:query + column:read on all columns', (t) => { + const result = generatePublicCedarPolicy(connectionId, [{ tableName: 'users' }]); + t.true(result.includes(`action == RocketAdmin::Action::"table:query"`)); + t.true(result.includes(`resource == RocketAdmin::Table::"${connectionId}/users"`)); + // all-columns alias uses `resource in Table` + t.true(result.includes(`action == RocketAdmin::Action::"column:read"`)); + t.true(result.includes(`resource in RocketAdmin::Table::"${connectionId}/users"`)); + // Only table:query + column:read may appear + t.false(result.includes('table:add')); + t.false(result.includes('table:edit')); + t.false(result.includes('table:delete')); +}); + +test('generatePublicCedarPolicy: readableColumns produces one column:read grant per column', (t) => { + const result = generatePublicCedarPolicy(connectionId, [{ tableName: 'users', readableColumns: ['id', 'name'] }]); + t.true(result.includes(`resource == RocketAdmin::Column::"${connectionId}/users/id"`)); + t.true(result.includes(`resource == RocketAdmin::Column::"${connectionId}/users/name"`)); + // no all-columns alias when an explicit whitelist is provided + t.false( + result.includes( + `action == RocketAdmin::Action::"column:read",\n resource in RocketAdmin::Table::"${connectionId}/users"`, + ), + ); + const permits = result.match(/permit\(/g); + // 1 table:query + 2 column:read + t.is(permits.length, 3); +}); + +test('generatePublicCedarPolicy: only ever emits table:query and column:read actions', (t) => { + const result = generatePublicCedarPolicy(connectionId, [ + { tableName: 'users', readableColumns: ['id'] }, + { tableName: 'orders' }, + ]); + const actions = [...result.matchAll(/RocketAdmin::Action::"([^"]+)"/g)].map((m) => m[1]); + for (const action of actions) { + t.true(action === 'table:query' || action === 'column:read', `unexpected action ${action}`); + } +}); + +test('generatePublicCedarPolicy escapes quotes/backslashes/newlines in table names (no policy injection)', (t) => { + const malicious = 'evil") ;\n\npermit(principal, action, resource);\n\n//'; + const result = generatePublicCedarPolicy(connectionId, [{ tableName: malicious }]); + t.is(result.split('\n\n').length, 2); + t.true(result.includes('\\"')); + t.true(result.includes('\\n')); + const actions = [...result.matchAll(/action\s*==\s*RocketAdmin::Action::"([^"]+)"/g)].map((m) => m[1]); + t.deepEqual(new Set(actions), new Set(['table:query', 'column:read'])); +}); + +test('generatePublicCedarPolicy escapes quotes/backslashes/newlines in column names (no policy injection)', (t) => { + const maliciousColumn = 'c") ;\npermit(principal, action, resource); //'; + const result = generatePublicCedarPolicy(connectionId, [{ tableName: 'users', readableColumns: [maliciousColumn] }]); + t.is(result.split('\n\n').length, 2); + t.true(result.includes('\\"')); + const actions = [...result.matchAll(/action\s*==\s*RocketAdmin::Action::"([^"]+)"/g)].map((m) => m[1]); + t.deepEqual(new Set(actions), new Set(['table:query', 'column:read'])); +}); + +test('generateCedarPolicyForGroup escapes malicious table names (no policy injection)', (t) => { + const malicious = 'evil") ;\n\npermit(principal, action, resource);\n\n//'; + const result = generateCedarPolicyForGroup( + connectionId, + false, + makePermissions({ + tables: [ + { + tableName: malicious, + accessLevel: { + visibility: true, + readonly: true, + add: false, + delete: false, + edit: false, + aiRequest: false, + triggerCustomAction: false, + }, + }, + ], + }), + ); + t.is(result.split('\n\n').length, 2); + t.true(result.includes('\\"')); + const actions = [...result.matchAll(/action\s*==\s*RocketAdmin::Action::"([^"]+)"/g)].map((m) => m[1]); + t.deepEqual(new Set(actions), new Set(['table:query', 'column:read'])); +}); diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-table-pure-crud-operations-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-table-pure-crud-operations-e2e.test.ts index 23c545808..a881016ae 100644 --- a/backend/test/ava-tests/non-saas-tests/non-saas-table-pure-crud-operations-e2e.test.ts +++ b/backend/test/ava-tests/non-saas-tests/non-saas-table-pure-crud-operations-e2e.test.ts @@ -383,3 +383,294 @@ test.serial(`${currentTest} should reject when connection does not exist`, async t.not(getRowsResponse.status, 200); }); + +currentTest = 'Public (unauthenticated) access'; + +async function setPublicPermissions( + connectionId: string, + token: string, + tables: Array<{ tableName: string; readableColumns?: Array }>, +) { + return request(app.getHttpServer()) + .put(`/connection/public-permissions/${connectionId}`) + .send({ tables }) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); +} + +test.serial(`${currentTest} getRows is refused (403) when no public policy is configured`, async (t) => { + const { connectionId, testTableName } = await createConnectionAndTable(); + + const res = await request(app.getHttpServer()) + .post(`/table/crud/rows/${connectionId}?tableName=${testTableName}&page=1&perPage=100`) + .send({}) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(res.status, 403); +}); + +test.serial( + `${currentTest} getRows returns rows to an unauthenticated user when public table:query is granted`, + async (t) => { + const { token, connectionId, testTableName, testTableColumnName } = await createConnectionAndTable(); + + const setRes = await setPublicPermissions(connectionId, token, [{ tableName: testTableName }]); + t.is(setRes.status, 200); + t.is(JSON.parse(setRes.text).enabled, true); + + const res = await request(app.getHttpServer()) + .post(`/table/crud/rows/${connectionId}?tableName=${testTableName}&page=1&perPage=100`) + .send({}) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(res.status, 200); + const ro = JSON.parse(res.text); + t.is(Array.isArray(ro.rows), true); + t.is(ro.rows.length, 42); + // all columns readable + t.is(Object.hasOwn(ro.rows[0], 'id'), true); + t.is(Object.hasOwn(ro.rows[0], testTableColumnName), true); + }, +); + +test.serial(`${currentTest} getRows strips non-readable columns when only some columns are granted`, async (t) => { + const { token, connectionId, testTableName, testTableColumnName, testTableSecondColumnName } = + await createConnectionAndTable(); + + const setRes = await setPublicPermissions(connectionId, token, [ + { tableName: testTableName, readableColumns: [testTableColumnName] }, + ]); + t.is(setRes.status, 200); + + const res = await request(app.getHttpServer()) + .post(`/table/crud/rows/${connectionId}?tableName=${testTableName}&page=1&perPage=10`) + .send({}) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(res.status, 200); + const ro = JSON.parse(res.text); + t.true(ro.rows.length > 0); + for (const row of ro.rows) { + t.is(Object.hasOwn(row, testTableColumnName), true); + t.is(Object.hasOwn(row, testTableSecondColumnName), false); + t.is(Object.hasOwn(row, 'id'), false); + } +}); + +test.serial( + `${currentTest} readRow returns a single row to an unauthenticated user when public access is granted`, + async (t) => { + const { token, connectionId, testTableName, testTableColumnName } = await createConnectionAndTable(); + + await setPublicPermissions(connectionId, token, [{ tableName: testTableName }]); + + const res = await request(app.getHttpServer()) + .get(`/table/crud/${connectionId}?tableName=${testTableName}&id=1`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(res.status, 200); + const ro = JSON.parse(res.text); + t.is(Object.hasOwn(ro, 'row'), true); + t.is(Object.hasOwn(ro.row, testTableColumnName), true); + }, +); + +test.serial( + `${currentTest} write operations are refused for unauthenticated users even with public access enabled`, + async (t) => { + const { token, connectionId, testTableName, testTableColumnName, testTableSecondColumnName } = + await createConnectionAndTable(); + + await setPublicPermissions(connectionId, token, [{ tableName: testTableName }]); + + const row = { + [testTableColumnName]: faker.person.firstName(), + [testTableSecondColumnName]: faker.internet.email(), + }; + + const createRes = await request(app.getHttpServer()) + .post(`/table/crud/${connectionId}?tableName=${testTableName}`) + .send(JSON.stringify(row)) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createRes.status, 403); + + const deleteRes = await request(app.getHttpServer()) + .delete(`/table/crud/${connectionId}?tableName=${testTableName}&id=1`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(deleteRes.status, 403); + }, +); + +test.serial(`${currentTest} setting an empty tables array disables public access again`, async (t) => { + const { token, connectionId, testTableName } = await createConnectionAndTable(); + + await setPublicPermissions(connectionId, token, [{ tableName: testTableName }]); + const disableRes = await setPublicPermissions(connectionId, token, []); + t.is(disableRes.status, 200); + t.is(JSON.parse(disableRes.text).enabled, false); + + const res = await request(app.getHttpServer()) + .post(`/table/crud/rows/${connectionId}?tableName=${testTableName}&page=1&perPage=10`) + .send({}) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(res.status, 403); +}); + +test.serial(`${currentTest} GET public-permissions returns the configured tables`, async (t) => { + const { token, connectionId, testTableName, testTableColumnName } = await createConnectionAndTable(); + + await setPublicPermissions(connectionId, token, [ + { tableName: testTableName, readableColumns: [testTableColumnName] }, + ]); + + const getRes = await request(app.getHttpServer()) + .get(`/connection/public-permissions/${connectionId}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getRes.status, 200); + const ro = JSON.parse(getRes.text); + t.is(ro.enabled, true); + t.is(Array.isArray(ro.tables), true); + const tableEntry = ro.tables.find((tbl) => tbl.tableName === testTableName); + t.truthy(tableEntry); + t.deepEqual(tableEntry.readableColumns, [testTableColumnName]); +}); + +currentTest = 'Public access security invariants'; + +test.serial( + `${currentTest} EVERY CRUD endpoint denies an unauthenticated user when no public policy is configured`, + async (t) => { + const { connectionId, testTableName, testTableColumnName, testTableSecondColumnName } = + await createConnectionAndTable(); + + const row = JSON.stringify({ + [testTableColumnName]: faker.person.firstName(), + [testTableSecondColumnName]: faker.internet.email(), + }); + + // getRows + const getRows = await request(app.getHttpServer()) + .post(`/table/crud/rows/${connectionId}?tableName=${testTableName}&page=1&perPage=100`) + .send({}) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(getRows.status, 403); + t.is(Object.hasOwn(JSON.parse(getRows.text), 'rows'), false); + + // readRow by primary key + const readRow = await request(app.getHttpServer()) + .get(`/table/crud/${connectionId}?tableName=${testTableName}&id=1`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(readRow.status, 403); + t.is(Object.hasOwn(JSON.parse(readRow.text), 'row'), false); + + // createRow + const createRow = await request(app.getHttpServer()) + .post(`/table/crud/${connectionId}?tableName=${testTableName}`) + .send(row) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createRow.status, 403); + + // updateRow + const updateRow = await request(app.getHttpServer()) + .put(`/table/crud/${connectionId}?tableName=${testTableName}&id=1`) + .send(row) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(updateRow.status, 403); + + // deleteRow + const deleteRow = await request(app.getHttpServer()) + .delete(`/table/crud/${connectionId}?tableName=${testTableName}&id=1`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(deleteRow.status, 403); + }, +); + +test.serial(`${currentTest} a public policy scoped to one table does NOT grant access to other tables`, async (t) => { + const { token, connectionId, testTableName } = await createConnectionAndTable(); + + // A second table living in the same database (same connection). + const connectionToTestDB = getTestData(mockFactory).connectionToMySQL; + const secondTable = await createTestTable(connectionToTestDB); + testTables.push(secondTable.testTableName); + + // Grant public access to the FIRST table only. + const setRes = await setPublicPermissions(connectionId, token, [{ tableName: testTableName }]); + t.is(setRes.status, 200); + + // The granted table is reachable... + const allowed = await request(app.getHttpServer()) + .post(`/table/crud/rows/${connectionId}?tableName=${testTableName}&page=1&perPage=10`) + .send({}) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(allowed.status, 200); + + // ...but the OTHER table must not be: no row data may leak. + const deniedRows = await request(app.getHttpServer()) + .post(`/table/crud/rows/${connectionId}?tableName=${secondTable.testTableName}&page=1&perPage=10`) + .send({}) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(deniedRows.status, 403); + t.is(Object.hasOwn(JSON.parse(deniedRows.text), 'rows'), false); + + const deniedRead = await request(app.getHttpServer()) + .get(`/table/crud/${connectionId}?tableName=${secondTable.testTableName}&id=1`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(deniedRead.status, 403); +}); + +test.serial(`${currentTest} readRow strips non-readable columns for an unauthenticated user`, async (t) => { + const { token, connectionId, testTableName, testTableColumnName, testTableSecondColumnName } = + await createConnectionAndTable(); + + await setPublicPermissions(connectionId, token, [ + { tableName: testTableName, readableColumns: [testTableColumnName] }, + ]); + + const res = await request(app.getHttpServer()) + .get(`/table/crud/${connectionId}?tableName=${testTableName}&id=1`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(res.status, 200); + const ro = JSON.parse(res.text); + t.is(Object.hasOwn(ro.row, testTableColumnName), true); + t.is(Object.hasOwn(ro.row, testTableSecondColumnName), false); + t.is(Object.hasOwn(ro.row, 'id'), false); +}); + +test.serial(`${currentTest} an invalid/expired JWT cookie is rejected, never treated as public`, async (t) => { + const { connectionId, token, testTableName } = await createConnectionAndTable(); + // Enable public access so that, if the bad token were silently ignored, the request would succeed. + await setPublicPermissions(connectionId, token, [{ tableName: testTableName }]); + + const res = await request(app.getHttpServer()) + .post(`/table/crud/rows/${connectionId}?tableName=${testTableName}&page=1&perPage=10`) + .send({}) + .set('Cookie', ['rocketadmin_cookie=this.is.not.a.valid.jwt']) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + // A malformed credential must be rejected, never silently downgraded to public access. + // (The existing middleware surfaces this as a 4xx/5xx; the invariant is "not 200, no data leaked".) + t.not(res.status, 200); + t.is(Object.hasOwn(JSON.parse(res.text), 'rows'), false); +});