Browse Source

Refactor impersonation mode into guard

pull/7593/head
Thomas Kaul 3 weeks ago
parent
commit
173805ab62
  1. 45
      apps/api/src/app/account/account.controller.ts
  2. 67
      apps/api/src/app/activities/activities.controller.ts
  3. 2
      apps/api/src/app/admin/admin.controller.ts
  4. 2
      apps/api/src/app/admin/queue/queue.controller.ts
  5. 7
      apps/api/src/app/app.module.ts
  6. 2
      apps/api/src/app/auth-device/auth-device.controller.ts
  7. 2
      apps/api/src/app/auth/auth.controller.ts
  8. 2
      apps/api/src/app/cache/cache.controller.ts
  9. 1
      apps/api/src/app/endpoints/ai/ai.controller.ts
  10. 3
      apps/api/src/app/endpoints/ai/ai.service.ts
  11. 2
      apps/api/src/app/endpoints/api-keys/api-keys.controller.ts
  12. 2
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts
  13. 23
      apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
  14. 17
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
  15. 2
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  16. 2
      apps/api/src/app/endpoints/public/public.controller.ts
  17. 22
      apps/api/src/app/endpoints/watchlist/watchlist.controller.ts
  18. 2
      apps/api/src/app/platform/platform.controller.ts
  19. 102
      apps/api/src/app/portfolio/portfolio.controller.ts
  20. 39
      apps/api/src/app/portfolio/portfolio.service.ts
  21. 2
      apps/api/src/app/subscription/subscription.controller.ts
  22. 43
      apps/api/src/app/user/user.controller.ts
  23. 10
      apps/api/src/decorators/allow-during-impersonation.decorator.ts
  24. 24
      apps/api/src/decorators/impersonation.decorator.ts
  25. 51
      apps/api/src/guards/impersonation-write.guard.ts
  26. 25
      apps/api/src/guards/impersonation.guard.ts
  27. 80
      apps/api/src/services/impersonation/impersonation.service.ts
  28. 10
      libs/common/src/lib/helper.ts
  29. 11
      libs/common/src/lib/types/impersonation-context.type.ts
  30. 2
      libs/common/src/lib/types/index.ts
  31. 10
      libs/common/src/lib/types/request-with-user.type.ts

45
apps/api/src/app/account/account.controller.ts

@ -1,13 +1,12 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { import {
CreateAccountDto, CreateAccountDto,
TransferBalanceDto, TransferBalanceDto,
@ -19,14 +18,16 @@ import {
AccountsResponse AccountsResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type {
ImpersonationContext,
RequestWithUser
} from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
HttpException, HttpException,
Inject, Inject,
Param, Param,
@ -49,10 +50,8 @@ export class AccountController {
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser
private readonly userService: UserService
) {} ) {}
@Delete(':id') @Delete(':id')
@ -85,18 +84,15 @@ export class AccountController {
} }
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getAllAccounts( public async getAllAccounts(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId }: ImpersonationContext,
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@Query('query') filterBySearchQuery?: string, @Query('query') filterBySearchQuery?: string,
@Query('symbol') filterBySymbol?: string @Query('symbol') filterBySymbol?: string
): Promise<AccountsResponse> { ): Promise<AccountsResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByDataSource, filterByDataSource,
filterBySearchQuery, filterBySearchQuery,
@ -105,25 +101,22 @@ export class AccountController {
return this.portfolioService.getAccountsWithAggregations({ return this.portfolioService.getAccountsWithAggregations({
filters, filters,
userId: impersonationUserId || this.request.user.id, userId,
withExcludedAccounts: true withExcludedAccounts: true
}); });
} }
@Get(':id') @Get(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountById( public async getAccountById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId }: ImpersonationContext,
@Param('id') id: string @Param('id') id: string
): Promise<AccountResponse> { ): Promise<AccountResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const accountsWithAggregations = const accountsWithAggregations =
await this.portfolioService.getAccountsWithAggregations({ await this.portfolioService.getAccountsWithAggregations({
userId,
filters: [{ id, type: 'ACCOUNT' }], filters: [{ id, type: 'ACCOUNT' }],
userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true withExcludedAccounts: true
}); });
@ -131,22 +124,16 @@ export class AccountController {
} }
@Get(':id/balances') @Get(':id/balances')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountBalancesById( public async getAccountBalancesById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId, userSettings }: ImpersonationContext,
@Param('id') id: string @Param('id') id: string
): Promise<AccountBalancesResponse> { ): Promise<AccountBalancesResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const userId = impersonationUserId || this.request.user.id;
const { settings } = await this.userService.user({ id: userId });
return this.accountBalanceService.getAccountBalances({ return this.accountBalanceService.getAccountBalances({
userId, userId,
filters: [{ id, type: 'ACCOUNT' }], filters: [{ id, type: 'ACCOUNT' }],
userCurrency: settings.settings.baseCurrency userCurrency: userSettings.baseCurrency
}); });
} }

67
apps/api/src/app/activities/activities.controller.ts

@ -1,35 +1,32 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard';
import { isActivityInFuture } from '@ghostfolio/api/helper/activity.helper'; import { isActivityInFuture } from '@ghostfolio/api/helper/activity.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DEFAULT_CURRENCY,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { import {
ActivitiesResponse, ActivitiesResponse,
ActivityResponse, ActivityResponse
UserSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type {
ImpersonationContext,
RequestWithUser
} from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
HttpException, HttpException,
Inject, Inject,
Param, Param,
@ -56,8 +53,6 @@ export class ActivitiesController {
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly impersonationService: ImpersonationService,
private readonly prismaService: PrismaService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -66,7 +61,6 @@ export class ActivitiesController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteActivities( public async deleteActivities(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query() @Query()
{ {
accounts, accounts,
@ -78,13 +72,6 @@ export class ActivitiesController {
tags tags
}: ActivitiesFilterDto }: ActivitiesFilterDto
): Promise<number> { ): Promise<number> {
if (impersonationId) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let endDate: Date; let endDate: Date;
let startDate: Date; let startDate: Date;
@ -133,12 +120,12 @@ export class ActivitiesController {
} }
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllActivities( public async getAllActivities(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId, userSettings }: ImpersonationContext,
@Query() @Query()
{ {
accounts, accounts,
@ -171,12 +158,6 @@ export class ActivitiesController {
filterByTags: tags filterByTags: tags
}); });
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const userId = impersonationUserId || this.request.user.id;
const userCurrency = await this.getUserCurrency(impersonationUserId);
const { activities, count } = await this.activitiesService.getActivities({ const { activities, count } = await this.activitiesService.getActivities({
endDate, endDate,
filters, filters,
@ -185,10 +166,10 @@ export class ActivitiesController {
sortDirection, sortDirection,
startDate, startDate,
take, take,
userCurrency,
userId, userId,
includeDrafts: true, includeDrafts: true,
types: activityTypes, types: activityTypes,
userCurrency: userSettings.baseCurrency,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });
@ -196,23 +177,17 @@ export class ActivitiesController {
} }
@Get(':id') @Get(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getActivityById( public async getActivityById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId, userSettings }: ImpersonationContext,
@Param('id') id: string @Param('id') id: string
): Promise<ActivityResponse> { ): Promise<ActivityResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const userId = impersonationUserId || this.request.user.id;
const userCurrency = await this.getUserCurrency(impersonationUserId);
const { activities } = await this.activitiesService.getActivities({ const { activities } = await this.activitiesService.getActivities({
userCurrency,
userId, userId,
includeDrafts: true, includeDrafts: true,
userCurrency: userSettings.baseCurrency,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });
@ -384,18 +359,4 @@ export class ActivitiesController {
} }
}); });
} }
private async getUserCurrency(impersonationUserId: string) {
if (!impersonationUserId) {
return this.request.user.settings.settings.baseCurrency;
}
const settings = await this.prismaService.settings.findUnique({
where: { userId: impersonationUserId }
});
return (
(settings?.settings as UserSettings)?.baseCurrency ?? DEFAULT_CURRENCY
);
}
} }

2
apps/api/src/app/admin/admin.controller.ts

@ -1,3 +1,4 @@
import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
@ -61,6 +62,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { PropertyKeyPipe } from './pipes/property-key.pipe'; import { PropertyKeyPipe } from './pipes/property-key.pipe';
@AllowDuringImpersonation()
@Controller('admin') @Controller('admin')
export class AdminController { export class AdminController {
private readonly logger = new Logger(AdminController.name); private readonly logger = new Logger(AdminController.name);

2
apps/api/src/app/admin/queue/queue.controller.ts

@ -1,3 +1,4 @@
import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { AdminJobs } from '@ghostfolio/common/interfaces'; import { AdminJobs } from '@ghostfolio/common/interfaces';
@ -16,6 +17,7 @@ import { JobStatus } from 'bull';
import { QueueService } from './queue.service'; import { QueueService } from './queue.service';
@AllowDuringImpersonation()
@Controller('admin/queue') @Controller('admin/queue')
export class QueueController { export class QueueController {
public constructor(private readonly queueService: QueueService) {} public constructor(private readonly queueService: QueueService) {}

7
apps/api/src/app/app.module.ts

@ -1,5 +1,6 @@
import { EventsModule } from '@ghostfolio/api/events/events.module'; import { EventsModule } from '@ghostfolio/api/events/events.module';
import { PortfolioSnapshotComputationExceptionFilter } from '@ghostfolio/api/filters/portfolio-snapshot-computation-exception.filter'; import { PortfolioSnapshotComputationExceptionFilter } from '@ghostfolio/api/filters/portfolio-snapshot-computation-exception.filter';
import { ImpersonationWriteGuard } from '@ghostfolio/api/guards/impersonation-write.guard';
import { getRedisConnectionOptions } from '@ghostfolio/api/helper/redis.helper'; import { getRedisConnectionOptions } from '@ghostfolio/api/helper/redis.helper';
import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware'; import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware';
import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware'; import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware';
@ -25,7 +26,7 @@ import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis'
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { APP_FILTER } from '@nestjs/core'; import { APP_FILTER, APP_GUARD } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
@ -191,6 +192,10 @@ import { UserModule } from './user/user.module';
{ {
provide: APP_FILTER, provide: APP_FILTER,
useClass: PortfolioSnapshotComputationExceptionFilter useClass: PortfolioSnapshotComputationExceptionFilter
},
{
provide: APP_GUARD,
useClass: ImpersonationWriteGuard
} }
] ]
}) })

2
apps/api/src/app/auth-device/auth-device.controller.ts

@ -1,4 +1,5 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
@ -16,6 +17,7 @@ import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { getReasonPhrase, StatusCodes } from 'http-status-codes';
@AllowDuringImpersonation()
@Controller('auth-device') @Controller('auth-device')
export class AuthDeviceController { export class AuthDeviceController {
public constructor( public constructor(

2
apps/api/src/app/auth/auth.controller.ts

@ -1,4 +1,5 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator';
import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard'; import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -27,6 +28,7 @@ import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
@AllowDuringImpersonation()
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
public constructor( public constructor(

2
apps/api/src/app/cache/cache.controller.ts

@ -1,4 +1,5 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
@ -6,6 +7,7 @@ import { permissions } from '@ghostfolio/common/permissions';
import { Controller, Post, UseGuards } from '@nestjs/common'; import { Controller, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@AllowDuringImpersonation()
@Controller('cache') @Controller('cache')
export class CacheController { export class CacheController {
public constructor(private readonly redisCacheService: RedisCacheService) {} public constructor(private readonly redisCacheService: RedisCacheService) {}

1
apps/api/src/app/endpoints/ai/ai.controller.ts

@ -46,7 +46,6 @@ export class AiController {
const prompt = await this.aiService.getPrompt({ const prompt = await this.aiService.getPrompt({
filters, filters,
mode, mode,
impersonationId: undefined,
languageCode: this.request.user.settings.settings.language, languageCode: this.request.user.settings.settings.language,
userCurrency: this.request.user.settings.settings.baseCurrency, userCurrency: this.request.user.settings.settings.baseCurrency,
userId: this.request.user.id userId: this.request.user.id

3
apps/api/src/app/endpoints/ai/ai.service.ts

@ -70,14 +70,12 @@ export class AiService {
public async getPrompt({ public async getPrompt({
filters, filters,
impersonationId,
languageCode, languageCode,
mode, mode,
userCurrency, userCurrency,
userId userId
}: { }: {
filters?: Filter[]; filters?: Filter[];
impersonationId: string;
languageCode: string; languageCode: string;
mode: AiPromptMode; mode: AiPromptMode;
userCurrency: string; userCurrency: string;
@ -85,7 +83,6 @@ export class AiService {
}) { }) {
const { holdings } = await this.portfolioService.getDetails({ const { holdings } = await this.portfolioService.getDetails({
filters, filters,
impersonationId,
userId userId
}); });

2
apps/api/src/app/endpoints/api-keys/api-keys.controller.ts

@ -1,3 +1,4 @@
import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
@ -9,6 +10,7 @@ import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@AllowDuringImpersonation()
@Controller('api-keys') @Controller('api-keys')
export class ApiKeysController { export class ApiKeysController {
public constructor( public constructor(

2
apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts

@ -1,3 +1,4 @@
import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
@ -41,6 +42,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AssetProfilesService } from './asset-profiles.service'; import { AssetProfilesService } from './asset-profiles.service';
@AllowDuringImpersonation()
@Controller('asset-profiles') @Controller('asset-profiles')
export class AssetProfilesController { export class AssetProfilesController {
public constructor( public constructor(

23
apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts

@ -1,34 +1,33 @@
import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import type { import type {
AssetProfileIdentifier, AssetProfileIdentifier,
BenchmarkMarketDataDetailsResponse, BenchmarkMarketDataDetailsResponse,
BenchmarkResponse BenchmarkResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { ImpersonationContext } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
HttpException, HttpException,
Inject,
Param, Param,
Post, Post,
Query, Query,
UseGuards, UseGuards,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -36,13 +35,13 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { BenchmarksService } from './benchmarks.service'; import { BenchmarksService } from './benchmarks.service';
import { GetBenchmarkMarketDataDto } from './get-benchmark-market-data.dto'; import { GetBenchmarkMarketDataDto } from './get-benchmark-market-data.dto';
@AllowDuringImpersonation()
@Controller('benchmarks') @Controller('benchmarks')
export class BenchmarksController { export class BenchmarksController {
public constructor( public constructor(
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly benchmarksService: BenchmarksService, private readonly benchmarksService: BenchmarksService
@Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@ -112,10 +111,10 @@ export class BenchmarksController {
} }
@Get(':dataSource/:symbol/:startDateString') @Get(':dataSource/:symbol/:startDateString')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataForUser( public async getBenchmarkMarketDataForUser(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId, userSettings }: ImpersonationContext,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string, @Param('startDateString') startDateString: string,
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@ -147,12 +146,12 @@ export class BenchmarksController {
dataSource, dataSource,
endDate, endDate,
filters, filters,
impersonationId,
startDate, startDate,
symbol, symbol,
userId,
userSettings,
withExcludedAccounts, withExcludedAccounts,
dateRange: range, dateRange: range
user: this.request.user
}); });
} }
} }

17
apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts

@ -7,9 +7,10 @@ import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
BenchmarkMarketDataDetailsResponse, BenchmarkMarketDataDetailsResponse,
Filter Filter,
UserSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { DateRange, UserWithSettings } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { format, isSameDay } from 'date-fns'; import { format, isSameDay } from 'date-fns';
@ -32,28 +33,26 @@ export class BenchmarksService {
dateRange, dateRange,
endDate = new Date(), endDate = new Date(),
filters, filters,
impersonationId,
startDate, startDate,
symbol, symbol,
user, userId,
userSettings,
withExcludedAccounts withExcludedAccounts
}: { }: {
dateRange: DateRange; dateRange: DateRange;
endDate?: Date; endDate?: Date;
filters?: Filter[]; filters?: Filter[];
impersonationId: string;
startDate: Date; startDate: Date;
user: UserWithSettings; userId: string;
userSettings: UserSettings;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetailsResponse> { } & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetailsResponse> {
const marketData: { date: string; value: number }[] = []; const marketData: { date: string; value: number }[] = [];
const userCurrency = user.settings.settings.baseCurrency; const userCurrency = userSettings.baseCurrency;
const userId = user.id;
const { chart } = await this.portfolioService.getPerformance({ const { chart } = await this.portfolioService.getPerformance({
dateRange, dateRange,
filters, filters,
impersonationId,
userId, userId,
withExcludedAccounts withExcludedAccounts
}); });

2
apps/api/src/app/endpoints/market-data/market-data.controller.ts

@ -1,4 +1,5 @@
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
@ -27,6 +28,7 @@ import { DataSource, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { getReasonPhrase, StatusCodes } from 'http-status-codes';
@AllowDuringImpersonation()
@Controller('market-data') @Controller('market-data')
export class MarketDataController { export class MarketDataController {
public constructor( public constructor(

2
apps/api/src/app/endpoints/public/public.controller.ts

@ -78,7 +78,6 @@ export class PublicController {
] = await Promise.all([ ] = await Promise.all([
this.portfolioService.getDetails({ this.portfolioService.getDetails({
filters, filters,
impersonationId: undefined,
userId: user.id, userId: user.id,
withMarkets: true withMarkets: true
}), }),
@ -86,7 +85,6 @@ export class PublicController {
return this.portfolioService.getPerformance({ return this.portfolioService.getPerformance({
dateRange, dateRange,
filters, filters,
impersonationId: undefined,
userId: user.id userId: user.id
}); });
}) })

22
apps/api/src/app/endpoints/watchlist/watchlist.controller.ts

@ -1,20 +1,22 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { CreateWatchlistItemDto } from '@ghostfolio/common/dtos'; import { CreateWatchlistItemDto } from '@ghostfolio/common/dtos';
import { WatchlistResponse } from '@ghostfolio/common/interfaces'; import { WatchlistResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import {
ImpersonationContext,
RequestWithUser
} from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
HttpException, HttpException,
Inject, Inject,
Param, Param,
@ -32,7 +34,6 @@ import { WatchlistService } from './watchlist.service';
@Controller('watchlist') @Controller('watchlist')
export class WatchlistController { export class WatchlistController {
public constructor( public constructor(
private readonly impersonationService: ImpersonationService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly watchlistService: WatchlistService private readonly watchlistService: WatchlistService
) {} ) {}
@ -81,17 +82,12 @@ export class WatchlistController {
@Get() @Get()
@HasPermission(permissions.readWatchlist) @HasPermission(permissions.readWatchlist)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getWatchlistItems( public async getWatchlistItems(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string @Impersonation() { userId }: ImpersonationContext
): Promise<WatchlistResponse> { ): Promise<WatchlistResponse> {
const impersonationUserId = const watchlist = await this.watchlistService.getWatchlistItems(userId);
await this.impersonationService.validateImpersonationId(impersonationId);
const watchlist = await this.watchlistService.getWatchlistItems(
impersonationUserId || this.request.user.id
);
return { return {
watchlist watchlist

2
apps/api/src/app/platform/platform.controller.ts

@ -1,3 +1,4 @@
import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos'; import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos';
@ -20,6 +21,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PlatformService } from './platform.service'; import { PlatformService } from './platform.service';
@AllowDuringImpersonation()
@Controller('platform') @Controller('platform')
export class PlatformController { export class PlatformController {
public constructor(private readonly platformService: PlatformService) {} public constructor(private readonly platformService: PlatformService) {}

102
apps/api/src/app/portfolio/portfolio.controller.ts

@ -1,7 +1,8 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard';
import { import {
hasNotDefinedValuesInObject, hasNotDefinedValuesInObject,
nullifyValuesInObject nullifyValuesInObject
@ -12,12 +13,8 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import { UNKNOWN_KEY } from '@ghostfolio/common/config';
HEADER_KEY_IMPERSONATION,
UNKNOWN_KEY
} from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { import {
PortfolioDetails, PortfolioDetails,
@ -33,13 +30,15 @@ import {
isRestrictedView, isRestrictedView,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type {
ImpersonationContext,
RequestWithUser
} from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Get, Get,
Headers,
HttpException, HttpException,
Inject, Inject,
Param, Param,
@ -69,19 +68,17 @@ export class PortfolioController {
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser
private readonly userService: UserService
) {} ) {}
@Get('details') @Get('details')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails( public async getDetails(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { accessId, userId }: ImpersonationContext,
@Query() @Query()
{ {
accounts: filterByAccounts, accounts: filterByAccounts,
@ -120,10 +117,9 @@ export class PortfolioController {
summary summary
} = await this.portfolioService.getDetails({ } = await this.portfolioService.getDetails({
filters, filters,
impersonationId, userId,
withMarkets, withMarkets,
dateRange: range, dateRange: range,
userId: this.request.user.id,
withSummary: true withSummary: true
}); });
@ -135,8 +131,8 @@ export class PortfolioController {
if ( if (
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, accesses: this.request.user?.accessesGet,
accesses: this.request.user?.accessesGet impersonationId: accessId
}) || }) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
@ -179,8 +175,8 @@ export class PortfolioController {
if ( if (
hasDetails === false || hasDetails === false ||
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, accesses: this.request.user?.accessesGet,
accesses: this.request.user?.accessesGet impersonationId: accessId
}) || }) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
@ -323,10 +319,10 @@ export class PortfolioController {
} }
@Get('dividends') @Get('dividends')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getDividends( public async getDividends(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { accessId, userId, userSettings }: ImpersonationContext,
@Query() @Query()
{ {
accounts, accounts,
@ -346,12 +342,7 @@ export class PortfolioController {
filterByTags: tags filterByTags: tags
}); });
const impersonationUserId = const userCurrency = userSettings.baseCurrency;
await this.impersonationService.validateImpersonationId(impersonationId);
const userId = impersonationUserId || this.request.user.id;
const { settings } = await this.userService.user({ id: userId });
const userCurrency = settings.settings.baseCurrency;
const { endDate, startDate } = getIntervalFromDateRange({ const { endDate, startDate } = getIntervalFromDateRange({
dateRange: range dateRange: range
@ -374,8 +365,8 @@ export class PortfolioController {
if ( if (
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, accesses: this.request.user?.accessesGet,
accesses: this.request.user?.accessesGet impersonationId: accessId
}) || }) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
@ -406,17 +397,16 @@ export class PortfolioController {
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
public async getHolding( public async getHolding(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId }: ImpersonationContext,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<PortfolioHoldingResponse> { ): Promise<PortfolioHoldingResponse> {
const holding = await this.portfolioService.getHolding({ const holding = await this.portfolioService.getHolding({
dataSource, dataSource,
impersonationId,
symbol, symbol,
userId: this.request.user.id userId
}); });
if (!holding) { if (!holding) {
@ -430,12 +420,12 @@ export class PortfolioController {
} }
@Get('holdings') @Get('holdings')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getHoldings( public async getHoldings(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId }: ImpersonationContext,
@Query() @Query()
{ {
accounts, accounts,
@ -460,19 +450,18 @@ export class PortfolioController {
const holdings = await this.portfolioService.getHoldings({ const holdings = await this.portfolioService.getHoldings({
filters, filters,
impersonationId, userId,
dateRange: range, dateRange: range
userId: this.request.user.id
}); });
return { holdings }; return { holdings };
} }
@Get('investments') @Get('investments')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getInvestments( public async getInvestments(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { accessId, userId }: ImpersonationContext,
@Query() @Query()
{ {
accounts, accounts,
@ -496,15 +485,14 @@ export class PortfolioController {
await this.portfolioService.getInvestments({ await this.portfolioService.getInvestments({
filters, filters,
groupBy, groupBy,
impersonationId, userId,
dateRange: range, dateRange: range
userId: this.request.user.id
}); });
if ( if (
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, accesses: this.request.user?.accessesGet,
accesses: this.request.user?.accessesGet impersonationId: accessId
}) || }) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
@ -544,13 +532,13 @@ export class PortfolioController {
} }
@Get('performance') @Get('performance')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(PerformanceLoggingInterceptor) @UseInterceptors(PerformanceLoggingInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@Version('2') @Version('2')
public async getPerformanceV2( public async getPerformanceV2(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { accessId, userId }: ImpersonationContext,
@Query() @Query()
{ {
accounts, accounts,
@ -572,16 +560,15 @@ export class PortfolioController {
const performanceInformation = await this.portfolioService.getPerformance({ const performanceInformation = await this.portfolioService.getPerformance({
filters, filters,
impersonationId, userId,
withExcludedAccounts, withExcludedAccounts,
dateRange: range, dateRange: range
userId: this.request.user.id
}); });
if ( if (
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, accesses: this.request.user?.accessesGet,
accesses: this.request.user?.accessesGet impersonationId: accessId
}) || }) ||
isRestrictedView(this.request.user) || isRestrictedView(this.request.user) ||
this.request.user.settings.settings.viewMode === 'ZEN' this.request.user.settings.settings.viewMode === 'ZEN'
@ -658,14 +645,11 @@ export class PortfolioController {
} }
@Get('report') @Get('report')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
public async getReport( public async getReport(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string @Impersonation() { userId }: ImpersonationContext
): Promise<PortfolioReportResponse> { ): Promise<PortfolioReportResponse> {
const report = await this.portfolioService.getReport({ const report = await this.portfolioService.getReport({ userId });
impersonationId,
userId: this.request.user.id
});
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
@ -687,7 +671,7 @@ export class PortfolioController {
@HasPermission(permissions.updateActivity) @HasPermission(permissions.updateActivity)
@Put('holding/:dataSource/:symbol/tags') @Put('holding/:dataSource/:symbol/tags')
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
public async updateHoldingTags( public async updateHoldingTags(
@Body() data: UpdateHoldingTagsDto, @Body() data: UpdateHoldingTagsDto,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,

39
apps/api/src/app/portfolio/portfolio.service.ts

@ -25,7 +25,6 @@ import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.s
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
getAnnualizedPerformancePercent, getAnnualizedPerformancePercent,
@ -126,7 +125,6 @@ export class PortfolioService {
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly i18nService: I18nService, private readonly i18nService: I18nService,
private readonly impersonationService: ImpersonationService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly rulesService: RulesService, private readonly rulesService: RulesService,
private readonly symbolProfileService: SymbolProfileService, private readonly symbolProfileService: SymbolProfileService,
@ -198,8 +196,7 @@ export class PortfolioService {
this.getDetails({ this.getDetails({
userId, userId,
withExcludedAccounts, withExcludedAccounts,
filters: filtersWithoutSearchQueryFilter, filters: filtersWithoutSearchQueryFilter
impersonationId: undefined
}), }),
this.userService.user({ id: userId }) this.userService.user({ id: userId })
]); ]);
@ -384,16 +381,12 @@ export class PortfolioService {
public async getHoldings({ public async getHoldings({
dateRange, dateRange,
filters, filters,
impersonationId,
userId userId
}: { }: {
dateRange: DateRange; dateRange: DateRange;
filters?: Filter[]; filters?: Filter[];
impersonationId: string;
userId: string; userId: string;
}) { }) {
userId = await this.getUserId(impersonationId, userId);
const { SEARCH_QUERY: [filterBySearchQuery] = [] } = groupBy( const { SEARCH_QUERY: [filterBySearchQuery] = [] } = groupBy(
filters, filters,
({ type }) => { ({ type }) => {
@ -407,7 +400,6 @@ export class PortfolioService {
const { holdings: holdingsMap } = await this.getDetails({ const { holdings: holdingsMap } = await this.getDetails({
dateRange, dateRange,
impersonationId,
userId, userId,
filters: filtersWithoutSearchQueryFilter filters: filtersWithoutSearchQueryFilter
}); });
@ -432,16 +424,13 @@ export class PortfolioService {
dateRange, dateRange,
filters, filters,
groupBy, groupBy,
impersonationId,
userId userId
}: { }: {
dateRange: DateRange; dateRange: DateRange;
filters?: Filter[]; filters?: Filter[];
groupBy?: GroupBy; groupBy?: GroupBy;
impersonationId: string;
userId: string; userId: string;
}): Promise<PortfolioInvestmentsResponse> { }): Promise<PortfolioInvestmentsResponse> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
const savingsRate = (user.settings?.settings as UserSettings)?.savingsRate; const savingsRate = (user.settings?.settings as UserSettings)?.savingsRate;
@ -512,7 +501,6 @@ export class PortfolioService {
public async getDetails({ public async getDetails({
dateRange = DEFAULT_DATE_RANGE, dateRange = DEFAULT_DATE_RANGE,
filters, filters,
impersonationId,
userId, userId,
withExcludedAccounts = false, withExcludedAccounts = false,
withMarkets = false, withMarkets = false,
@ -520,13 +508,11 @@ export class PortfolioService {
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
impersonationId: string;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
withMarkets?: boolean; withMarkets?: boolean;
withSummary?: boolean; withSummary?: boolean;
}): Promise<PortfolioDetails & { hasErrors: boolean }> { }): Promise<PortfolioDetails & { hasErrors: boolean }> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
@ -761,7 +747,6 @@ export class PortfolioService {
if (withSummary) { if (withSummary) {
summary = await this.getSummary({ summary = await this.getSummary({
filteredValueInBaseCurrency, filteredValueInBaseCurrency,
impersonationId,
portfolioCalculator, portfolioCalculator,
userCurrency, userCurrency,
userId, userId,
@ -787,14 +772,11 @@ export class PortfolioService {
public async getHolding({ public async getHolding({
dataSource, dataSource,
impersonationId,
symbol, symbol,
userId userId
}: { }: {
impersonationId?: string;
userId: string; userId: string;
} & AssetProfileIdentifier): Promise<PortfolioHoldingResponse> { } & AssetProfileIdentifier): Promise<PortfolioHoldingResponse> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
@ -1036,16 +1018,13 @@ export class PortfolioService {
public async getPerformance({ public async getPerformance({
dateRange = DEFAULT_DATE_RANGE, dateRange = DEFAULT_DATE_RANGE,
filters, filters,
impersonationId,
userId userId
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
impersonationId: string;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<PortfolioPerformanceResponse> { }): Promise<PortfolioPerformanceResponse> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
@ -1138,14 +1117,10 @@ export class PortfolioService {
} }
public async getReport({ public async getReport({
impersonationId,
userId userId
}: { }: {
impersonationId: string;
userId: string; userId: string;
}): Promise<PortfolioReportResponse> { }): Promise<PortfolioReportResponse> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
// The rules are evaluated against the portfolio of the (potentially // The rules are evaluated against the portfolio of the (potentially
@ -1158,7 +1133,6 @@ export class PortfolioService {
const { accounts, holdings, markets, marketsAdvanced, summary } = const { accounts, holdings, markets, marketsAdvanced, summary } =
await this.getDetails({ await this.getDetails({
impersonationId,
userId, userId,
withMarkets: true, withMarkets: true,
withSummary: true withSummary: true
@ -1920,7 +1894,6 @@ export class PortfolioService {
balanceInBaseCurrency, balanceInBaseCurrency,
emergencyFundHoldingsValueInBaseCurrency, emergencyFundHoldingsValueInBaseCurrency,
filteredValueInBaseCurrency, filteredValueInBaseCurrency,
impersonationId,
portfolioCalculator, portfolioCalculator,
userCurrency, userCurrency,
userId userId
@ -1928,12 +1901,10 @@ export class PortfolioService {
balanceInBaseCurrency: number; balanceInBaseCurrency: number;
emergencyFundHoldingsValueInBaseCurrency: number; emergencyFundHoldingsValueInBaseCurrency: number;
filteredValueInBaseCurrency: Big; filteredValueInBaseCurrency: Big;
impersonationId: string;
portfolioCalculator: PortfolioCalculator; portfolioCalculator: PortfolioCalculator;
userCurrency: string; userCurrency: string;
userId: string; userId: string;
}): Promise<PortfolioSummary> { }): Promise<PortfolioSummary> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const { activities } = await this.activitiesService.getActivities({ const { activities } = await this.activitiesService.getActivities({
@ -1966,7 +1937,6 @@ export class PortfolioService {
} = await portfolioCalculator.getSnapshot(); } = await portfolioCalculator.getSnapshot();
const { performance } = await this.getPerformance({ const { performance } = await this.getPerformance({
impersonationId,
userId userId
}); });
@ -2163,13 +2133,6 @@ export class PortfolioService {
return aUser?.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY; return aUser?.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY;
} }
private async getUserId(aImpersonationId: string, aUserId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(aImpersonationId);
return impersonationUserId || aUserId;
}
private getUserPerformanceCalculationType( private getUserPerformanceCalculationType(
aUser: UserWithSettings aUser: UserWithSettings
): PerformanceCalculationType { ): PerformanceCalculationType {

2
apps/api/src/app/subscription/subscription.controller.ts

@ -1,3 +1,4 @@
import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
@ -31,6 +32,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { SubscriptionService } from './subscription.service'; import { SubscriptionService } from './subscription.service';
@AllowDuringImpersonation()
@Controller('subscription') @Controller('subscription')
export class SubscriptionController { export class SubscriptionController {
private readonly logger = new Logger(SubscriptionController.name); private readonly logger = new Logger(SubscriptionController.name);

43
apps/api/src/app/user/user.controller.ts

@ -1,15 +1,16 @@
import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator';
import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard'; import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard';
import { decodeDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { decodeDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
HEADER_KEY_IMPERSONATION,
THROTTLE_SIGNUP_LIMIT, THROTTLE_SIGNUP_LIMIT,
THROTTLE_SIGNUP_TTL THROTTLE_SIGNUP_TTL
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -18,6 +19,7 @@ import {
UpdateOwnAccessTokenDto, UpdateOwnAccessTokenDto,
UpdateUserSettingDto UpdateUserSettingDto
} from '@ghostfolio/common/dtos'; } from '@ghostfolio/common/dtos';
import { isUserSettingOfAuthenticatedUser } from '@ghostfolio/common/helper';
import { import {
AccessTokenResponse, AccessTokenResponse,
User, User,
@ -25,7 +27,10 @@ import {
UserSettings UserSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type {
ImpersonationContext,
RequestWithUser
} from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -51,11 +56,11 @@ import { merge, size } from 'lodash';
import { UserService } from './user.service'; import { UserService } from './user.service';
@AllowDuringImpersonation()
@Controller('user') @Controller('user')
export class UserController { export class UserController {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly impersonationService: ImpersonationService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@ -119,18 +124,15 @@ export class UserController {
} }
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getUser( public async getUser(
@Headers('accept-language') acceptLanguage: string, @Headers('accept-language') acceptLanguage: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string @Impersonation() { isActive, userId }: ImpersonationContext
): Promise<User> { ): Promise<User> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
return this.userService.getUser({ return this.userService.getUser({
impersonationUserId, impersonationUserId: isActive ? userId : undefined,
locale: acceptLanguage?.split(',')?.[0], locale: acceptLanguage?.split(',')?.[0],
user: this.request.user user: this.request.user
}); });
@ -167,9 +169,26 @@ export class UserController {
} }
@Put('setting') @Put('setting')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async updateUserSetting(@Body() data: UpdateUserSettingDto) { public async updateUserSetting(
@Body() data: UpdateUserSettingDto,
@Impersonation() { isActive }: ImpersonationContext
) {
if (
isActive &&
Object.keys(data).some((key) => {
return !isUserSettingOfAuthenticatedUser(key);
})
) {
// While impersonating, the presented settings of the impersonated user
// must not be written back to the authenticated user
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
if ( if (
size(data) === 1 && size(data) === 1 &&
(data.benchmark || data.dateRange) && (data.benchmark || data.dateRange) &&

10
apps/api/src/decorators/allow-during-impersonation.decorator.ts

@ -0,0 +1,10 @@
import { SetMetadata } from '@nestjs/common';
export const ALLOW_DURING_IMPERSONATION_KEY = 'allow_during_impersonation';
// Marks a controller or a route which modifies data of the authenticated user
// instead of data of the impersonated user, hence it stays available while an
// impersonation is active
export function AllowDuringImpersonation() {
return SetMetadata(ALLOW_DURING_IMPERSONATION_KEY, true);
}

24
apps/api/src/decorators/impersonation.decorator.ts

@ -0,0 +1,24 @@
import type {
ImpersonationContext,
RequestWithUser
} from '@ghostfolio/common/types';
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
// Provides the impersonation context of the request, which requires the
// ImpersonationGuard to be applied to the route
export const Impersonation = createParamDecorator(
(_data: unknown, context: ExecutionContext): ImpersonationContext => {
const { impersonation, user } = context
.switchToHttp()
.getRequest<RequestWithUser>();
return (
impersonation ?? {
isActive: false,
userId: user?.id,
userSettings: user?.settings?.settings ?? {}
}
);
}
);

51
apps/api/src/guards/impersonation-write.guard.ts

@ -0,0 +1,51 @@
import { ALLOW_DURING_IMPERSONATION_KEY } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
CanActivate,
ExecutionContext,
HttpException,
Injectable
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
// Denies modifying requests while an impersonation is active, so that data of
// the authenticated user cannot be changed from a view presenting data of the
// impersonated user. The header is evaluated instead of the resolved context to
// fail closed, also for an identifier which cannot be resolved.
@Injectable()
export class ImpersonationWriteGuard implements CanActivate {
public constructor(private readonly reflector: Reflector) {}
public canActivate(context: ExecutionContext): boolean {
if (context.getType() !== 'http') {
return true;
}
const request = context.switchToHttp().getRequest();
if (request.method === 'GET') {
return true;
}
if (!request.headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()]) {
return true;
}
const isAllowedDuringImpersonation =
this.reflector.getAllAndOverride<boolean>(
ALLOW_DURING_IMPERSONATION_KEY,
[context.getHandler(), context.getClass()]
);
if (isAllowedDuringImpersonation) {
return true;
}
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}

25
apps/api/src/guards/impersonation.guard.ts

@ -0,0 +1,25 @@
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
@Injectable()
export class ImpersonationGuard implements CanActivate {
public constructor(
private readonly impersonationService: ImpersonationService
) {}
public async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<RequestWithUser>();
request.impersonation = await this.impersonationService.resolve({
impersonationId: request.headers?.[
HEADER_KEY_IMPERSONATION.toLowerCase()
] as string,
user: request.user
});
return true;
}
}

80
apps/api/src/services/impersonation/impersonation.service.ts

@ -1,52 +1,92 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type {
ImpersonationContext,
UserWithSettings
} from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
@Injectable() @Injectable()
export class ImpersonationService { export class ImpersonationService {
public constructor( public constructor(private readonly prismaService: PrismaService) {}
private readonly prismaService: PrismaService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
public async validateImpersonationId(aId?: string) { public async resolve({
if (!aId) { impersonationId,
user
}: {
impersonationId?: string;
user?: UserWithSettings;
}): Promise<ImpersonationContext> {
const impersonatedUserId = await this.validateImpersonationId({
impersonationId,
user
});
if (!impersonatedUserId) {
return {
isActive: false,
userId: user?.id,
userSettings: user?.settings?.settings ?? {}
};
}
const settings = await this.prismaService.settings.findUnique({
where: { userId: impersonatedUserId }
});
return {
isActive: true,
accessId: impersonationId,
userId: impersonatedUserId,
userSettings: {
...((settings?.settings ?? {}) as UserSettings),
baseCurrency:
(settings?.settings as UserSettings)?.baseCurrency ?? DEFAULT_CURRENCY
}
};
}
public async validateImpersonationId({
impersonationId,
user
}: {
impersonationId?: string;
user?: UserWithSettings;
}) {
if (!impersonationId) {
return null; return null;
} }
if (this.request.user) { if (user) {
const accessObject = await this.prismaService.access.findFirst({ const accessObject = await this.prismaService.access.findFirst({
where: { where: {
granteeUserId: this.request.user.id, granteeUserId: user.id,
id: aId id: impersonationId
} }
}); });
if (accessObject?.userId) { if (accessObject?.userId) {
return accessObject.userId; return accessObject.userId;
} else if ( } else if (
hasPermission( hasPermission(user.permissions, permissions.impersonateAllUsers)
this.request.user.permissions,
permissions.impersonateAllUsers
)
) { ) {
// The identifier is a user id in this case, hence verify its existence // The identifier is a user id in this case, hence verify its existence
const user = await this.prismaService.user.findUnique({ const impersonatedUser = await this.prismaService.user.findUnique({
select: { id: true }, select: { id: true },
where: { id: aId } where: { id: impersonationId }
}); });
return user?.id ?? null; return impersonatedUser?.id ?? null;
} }
} else { } else {
// Public access // Public access
const accessObject = await this.prismaService.access.findFirst({ const accessObject = await this.prismaService.access.findFirst({
where: { where: {
granteeUserId: null, granteeUserId: null,
user: { id: aId } user: { id: impersonationId }
} }
}); });

10
libs/common/src/lib/helper.ts

@ -66,7 +66,7 @@ export const DATE_FORMAT_YEARLY = 'yyyy';
// The filters are included because they are always written back to the // The filters are included because they are always written back to the
// authenticated user, so reading them from the impersonated user would // authenticated user, so reading them from the impersonated user would
// overwrite the filters of the authenticated user. // overwrite the filters of the authenticated user.
const PRESENTATION_USER_SETTINGS_KEYS: (keyof UserSettings)[] = [ const USER_SETTINGS_KEYS_OF_AUTHENTICATED_USER: (keyof UserSettings)[] = [
'colorScheme', 'colorScheme',
'dateRange', 'dateRange',
'filters.accounts', 'filters.accounts',
@ -607,6 +607,12 @@ export function isSystemTag(tag?: { id: string }) {
}); });
} }
export function isUserSettingOfAuthenticatedUser(aKey: string) {
return USER_SETTINGS_KEYS_OF_AUTHENTICATED_USER.includes(
aKey as keyof UserSettings
);
}
export function isValidCustomAssetProfileSymbol(aSymbol: string) { export function isValidCustomAssetProfileSymbol(aSymbol: string) {
return hasGhostfolioPrefix(aSymbol) || isUUID(aSymbol); return hasGhostfolioPrefix(aSymbol) || isUUID(aSymbol);
} }
@ -708,7 +714,7 @@ export function resolveUserSettings({
return { return {
...impersonationUserSettings, ...impersonationUserSettings,
...Object.fromEntries( ...Object.fromEntries(
PRESENTATION_USER_SETTINGS_KEYS.map((key) => { USER_SETTINGS_KEYS_OF_AUTHENTICATED_USER.map((key) => {
return [key, userSettings?.[key]]; return [key, userSettings?.[key]];
}) })
) )

11
libs/common/src/lib/types/impersonation-context.type.ts

@ -0,0 +1,11 @@
import { UserSettings } from '@ghostfolio/common/interfaces';
// Describes whose data a request presents. The user id and the settings belong
// to the impersonated user while an impersonation is active and to the
// authenticated user otherwise, so a handler can use them unconditionally.
export interface ImpersonationContext {
accessId?: string;
isActive: boolean;
userId: string;
userSettings: UserSettings;
}

2
libs/common/src/lib/types/index.ts

@ -12,6 +12,7 @@ import type { Granularity } from './granularity.type';
import type { GroupBy } from './group-by.type'; import type { GroupBy } from './group-by.type';
import type { HoldingType } from './holding-type.type'; import type { HoldingType } from './holding-type.type';
import type { HoldingsViewMode } from './holdings-view-mode.type'; import type { HoldingsViewMode } from './holdings-view-mode.type';
import type { ImpersonationContext } from './impersonation-context.type';
import type { MarketAdvanced } from './market-advanced.type'; import type { MarketAdvanced } from './market-advanced.type';
import type { MarketDataPreset } from './market-data-preset.type'; import type { MarketDataPreset } from './market-data-preset.type';
import type { MarketState } from './market-state.type'; import type { MarketState } from './market-state.type';
@ -42,6 +43,7 @@ export type {
GroupBy, GroupBy,
HoldingType, HoldingType,
HoldingsViewMode, HoldingsViewMode,
ImpersonationContext,
Market, Market,
MarketAdvanced, MarketAdvanced,
MarketDataPreset, MarketDataPreset,

10
libs/common/src/lib/types/request-with-user.type.ts

@ -1,3 +1,9 @@
import { UserWithSettings } from '@ghostfolio/common/types'; import {
ImpersonationContext,
UserWithSettings
} from '@ghostfolio/common/types';
export type RequestWithUser = Request & { user: UserWithSettings }; export type RequestWithUser = Request & {
impersonation?: ImpersonationContext;
user: UserWithSettings;
};

Loading…
Cancel
Save