Skip to content

Commit 867fc5a

Browse files
authored
Merge pull request #1844 from rocket-admin/backend_public_permissions
feat: implement public access permissions for unauthenticated users
2 parents 4433ae3 + c1fb4f3 commit 867fc5a

27 files changed

Lines changed: 1036 additions & 41 deletions
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import {
2+
HttpException,
3+
Injectable,
4+
InternalServerErrorException,
5+
NestMiddleware,
6+
NotFoundException,
7+
UnauthorizedException,
8+
} from '@nestjs/common';
9+
import { InjectRepository } from '@nestjs/typeorm';
10+
import Sentry from '@sentry/minimal';
11+
import { NextFunction, Response } from 'express';
12+
import jwt from 'jsonwebtoken';
13+
import { Repository } from 'typeorm';
14+
import { JwtScopesEnum } from '../entities/user/enums/jwt-scopes.enum.js';
15+
import { UserEntity } from '../entities/user/user.entity.js';
16+
import { EncryptionAlgorithmEnum } from '../enums/encryption-algorithm.enum.js';
17+
import { TwoFaRequiredException } from '../exceptions/custom-exceptions/two-fa-required-exception.js';
18+
import { Messages } from '../exceptions/text/messages.js';
19+
import { Constants } from '../helpers/constants/constants.js';
20+
import { Encryptor } from '../helpers/encryption/encryptor.js';
21+
import { isObjectEmpty } from '../helpers/is-object-empty.js';
22+
import { appConfig } from '../shared/config/app-config.js';
23+
import { IRequestWithCognitoInfo } from './cognito-decoded.interface.js';
24+
25+
/**
26+
* Authentication middleware that ALSO allows anonymous ("public") requests through.
27+
*
28+
* - A JWT cookie or an `x-api-key` header is authenticated exactly like AuthWithApiMiddleware and
29+
* populates `req.decoded`.
30+
* - When neither is present, the request is treated as public: `req.decoded` is left empty and the
31+
* request continues. Downstream guards then decide whether the connection's public policy grants
32+
* access. An invalid/expired credential still fails fast.
33+
*
34+
* This is applied only to read-capable pure CRUD routes; write routes keep AuthWithApiMiddleware.
35+
*/
36+
@Injectable()
37+
export class PublicOrAuthMiddleware implements NestMiddleware {
38+
public constructor(
39+
@InjectRepository(UserEntity)
40+
private readonly userRepository: Repository<UserEntity>,
41+
) {}
42+
43+
async use(req: IRequestWithCognitoInfo, _res: Response, next: NextFunction): Promise<void> {
44+
try {
45+
const tokenFromCookie = req.cookies?.[Constants.JWT_COOKIE_KEY_NAME];
46+
let apiKey = req.headers?.['x-api-key'];
47+
if (Array.isArray(apiKey)) {
48+
apiKey = apiKey[0];
49+
}
50+
51+
if (tokenFromCookie) {
52+
await this.authenticateWithToken(tokenFromCookie, req);
53+
} else if (apiKey) {
54+
await this.authenticateWithApiKey(apiKey, req);
55+
} else {
56+
req.decoded = {};
57+
}
58+
next();
59+
} catch (error) {
60+
Sentry.captureException(error);
61+
if (error instanceof HttpException || error instanceof UnauthorizedException) {
62+
throw error;
63+
}
64+
throw new InternalServerErrorException(Messages.AUTHORIZATION_REJECTED);
65+
}
66+
}
67+
68+
private async authenticateWithToken(tokenFromCookie: string, req: IRequestWithCognitoInfo): Promise<void> {
69+
const jwtSecret = appConfig.auth.jwtSecret;
70+
if (!jwtSecret) {
71+
throw new UnauthorizedException('JWT verification failed');
72+
}
73+
const data = jwt.verify(tokenFromCookie, jwtSecret) as jwt.JwtPayload;
74+
const userId = data.id;
75+
76+
if (!userId) {
77+
throw new UnauthorizedException('JWT verification failed');
78+
}
79+
80+
const userExists = await this.userRepository.findOne({ where: { id: userId } });
81+
if (!userExists) {
82+
throw new UnauthorizedException('JWT verification failed');
83+
}
84+
85+
if (userExists.suspended) {
86+
throw new UnauthorizedException(Messages.ACCOUNT_SUSPENDED);
87+
}
88+
89+
const addedScope: Array<JwtScopesEnum> = data.scope;
90+
if (addedScope && addedScope.length > 0) {
91+
if (addedScope.includes(JwtScopesEnum.TWO_FA_ENABLE)) {
92+
throw new TwoFaRequiredException();
93+
}
94+
}
95+
96+
const payload = {
97+
sub: userId,
98+
email: data.email,
99+
exp: data.exp,
100+
iat: data.iat,
101+
};
102+
if (!payload || isObjectEmpty(payload)) {
103+
throw new UnauthorizedException('JWT verification failed');
104+
}
105+
req.decoded = payload;
106+
}
107+
108+
private async authenticateWithApiKey(apiKey: string, req: IRequestWithCognitoInfo): Promise<void> {
109+
const apiKeyHash = await Encryptor.processDataWithAlgorithm(apiKey, EncryptionAlgorithmEnum.sha256);
110+
const foundUserByApiKey = await this.userRepository
111+
.createQueryBuilder('user')
112+
.innerJoinAndSelect('user.api_keys', 'api_key')
113+
.where('api_key.hash = :hash', { hash: apiKeyHash })
114+
.getOne();
115+
116+
if (!foundUserByApiKey) {
117+
throw new NotFoundException(Messages.NO_AUTH_KEYS_FOUND);
118+
}
119+
120+
if (foundUserByApiKey.suspended) {
121+
throw new UnauthorizedException(Messages.API_KEY_SUSPENDED);
122+
}
123+
124+
req.decoded = {
125+
sub: foundUserByApiKey.id,
126+
email: foundUserByApiKey.email,
127+
};
128+
}
129+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { BadRequestException, createParamDecorator, ExecutionContext } from '@nestjs/common';
2+
import { IRequestWithCognitoInfo } from '../authorization/cognito-decoded.interface.js';
3+
import { Messages } from '../exceptions/text/messages.js';
4+
import { ValidationHelper } from '../helpers/validators/validation-helper.js';
5+
6+
/**
7+
* Like @UserId(), but tolerates an unauthenticated ("public") request: when no user is present it
8+
* returns undefined instead of throwing. Use on endpoints that may be reached by public users
9+
* (the guard decides whether public access is allowed); the handler then treats a missing userId
10+
* as a public request.
11+
*/
12+
export const OptionalUserId = createParamDecorator((_data: unknown, ctx: ExecutionContext): string | undefined => {
13+
const request: IRequestWithCognitoInfo = ctx.switchToHttp().getRequest();
14+
const userId = request.decoded?.sub;
15+
if (!userId) {
16+
return undefined;
17+
}
18+
if (ValidationHelper.isValidUUID(userId)) {
19+
return userId;
20+
}
21+
throw new BadRequestException(Messages.UUID_INVALID);
22+
});

backend/src/entities/cedar-authorization/cedar-action-map.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export const ACTION_EVENT_PROBE_ID = '__probe__';
4040

4141
export const COLUMN_PROBE_ID = '__probe__';
4242

43+
export const PUBLIC_USER_ID = '__public__';
44+
4345
export interface CedarValidationRequest {
4446
userId: string;
4547
action: CedarAction;
@@ -50,4 +52,5 @@ export interface CedarValidationRequest {
5052
actionEventId?: string;
5153
dashboardId?: string;
5254
panelId?: string;
55+
publicAccess?: boolean;
5356
}

backend/src/entities/cedar-authorization/cedar-authorization.controller.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
HttpStatus,
77
Injectable,
88
Post,
9+
Put,
910
UseGuards,
1011
UseInterceptors,
1112
} from '@nestjs/common';
@@ -18,6 +19,7 @@ import { ConnectionReadGuard } from '../../guards/connection-read.guard.js';
1819
import { SentryInterceptor } from '../../interceptors/sentry.interceptor.js';
1920
import { IComplexPermission } from '../permission/permission.interface.js';
2021
import { CedarAuthorizationService } from './cedar-authorization.service.js';
22+
import { PublicPermissionsResponseDto, SetPublicPermissionsDto } from './dto/public-permissions.dto.js';
2123
import { SaveCedarPolicyDto } from './dto/save-cedar-policy.dto.js';
2224
import { ValidateCedarSchemaDto } from './dto/validate-cedar-schema.dto.js';
2325

@@ -87,4 +89,40 @@ export class CedarAuthorizationController {
8789
}
8890
return this.cedarAuthService.saveCedarPolicy(connectionId, dto.groupId, dto.cedarPolicy);
8991
}
92+
93+
@ApiOperation({
94+
summary: 'Get the public (unauthenticated) read permissions configured for a connection',
95+
})
96+
@ApiResponse({ status: 200, description: 'Public permissions returned.', type: PublicPermissionsResponseDto })
97+
@ApiParam({ name: 'connectionId', required: true })
98+
@UseGuards(ConnectionEditGuard)
99+
@Get('/connection/public-permissions/:connectionId')
100+
async getPublicPermissions(@SlugUuid('connectionId') connectionId: string): Promise<PublicPermissionsResponseDto> {
101+
if (!connectionId) {
102+
throw new HttpException({ message: Messages.CONNECTION_ID_MISSING }, HttpStatus.BAD_REQUEST);
103+
}
104+
return this.cedarAuthService.getPublicPermissions(connectionId);
105+
}
106+
107+
@ApiOperation({
108+
summary: 'Set the public (unauthenticated) read permissions for a connection',
109+
description:
110+
'Generates and stores a Cedar policy granting public users QueryTable + ColumnRead on the listed tables. ' +
111+
'Pass an empty "tables" array to disable public access.',
112+
})
113+
@ApiResponse({ status: 200, description: 'Public permissions saved.', type: PublicPermissionsResponseDto })
114+
@ApiBody({ type: SetPublicPermissionsDto })
115+
@ApiParam({ name: 'connectionId', required: true })
116+
@UseGuards(ConnectionEditGuard)
117+
@Put('/connection/public-permissions/:connectionId')
118+
async setPublicPermissions(
119+
@SlugUuid('connectionId') connectionId: string,
120+
@Body() dto: SetPublicPermissionsDto,
121+
): Promise<PublicPermissionsResponseDto> {
122+
if (!connectionId) {
123+
throw new HttpException({ message: Messages.CONNECTION_ID_MISSING }, HttpStatus.BAD_REQUEST);
124+
}
125+
const { enabled, tables } = await this.cedarAuthService.savePublicPermissions(connectionId, dto.tables ?? []);
126+
return { enabled, tables };
127+
}
90128
}

backend/src/entities/cedar-authorization/cedar-authorization.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export class CedarAuthorizationModule implements NestModule {
3131
{ path: '/connection/cedar-schema/:connectionId', method: RequestMethod.GET },
3232
{ path: '/connection/cedar-schema/validate/:connectionId', method: RequestMethod.POST },
3333
{ path: '/connection/cedar-policy/:connectionId', method: RequestMethod.POST },
34+
{ path: '/connection/public-permissions/:connectionId', method: RequestMethod.GET },
35+
{ path: '/connection/public-permissions/:connectionId', method: RequestMethod.PUT },
3436
);
3537
}
3638
}

0 commit comments

Comments
 (0)