From c9dcf6c4f7f2c92febf377e603e133945ec459dd Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:20:47 +0200 Subject: [PATCH] Add scopes to access --- apps/api/src/app/access/access.controller.ts | 3 + .../api/src/app/account/account.controller.ts | 27 ++++- .../app/activities/activities.controller.ts | 19 ++- .../benchmarks/benchmarks.controller.ts | 11 +- .../watchlist/watchlist.controller.ts | 11 +- .../src/app/portfolio/portfolio.controller.ts | 112 +++++++++++------- apps/api/src/app/user/user.controller.ts | 3 +- apps/api/src/app/user/user.service.ts | 7 +- .../src/decorators/impersonation.decorator.ts | 2 + .../decorators/requires-scope.decorator.ts | 12 ++ apps/api/src/guards/scope.guard.ts | 50 ++++++++ .../redact-values-in-response.interceptor.ts | 28 ++--- .../impersonation/impersonation.service.ts | 43 +++++-- .../account-detail-dialog.component.ts | 13 +- .../allocations/allocations-page.component.ts | 13 +- .../analysis/analysis-page.component.ts | 13 +- .../src/lib/interfaces/user.interface.ts | 3 +- libs/common/src/lib/scopes.spec.ts | 75 ++++++++++++ libs/common/src/lib/scopes.ts | 65 ++++++++++ .../lib/types/impersonation-context.type.ts | 1 + .../migration.sql | 20 ++++ prisma/schema.prisma | 1 + 22 files changed, 426 insertions(+), 106 deletions(-) create mode 100644 apps/api/src/decorators/requires-scope.decorator.ts create mode 100644 apps/api/src/guards/scope.guard.ts create mode 100644 libs/common/src/lib/scopes.spec.ts create mode 100644 libs/common/src/lib/scopes.ts create mode 100644 prisma/migrations/20260811120000_added_scopes_to_access/migration.sql diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index 54fadec68b..ab2a353d22 100644 --- a/apps/api/src/app/access/access.controller.ts +++ b/apps/api/src/app/access/access.controller.ts @@ -6,6 +6,7 @@ import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; import { SubscriptionType } from '@ghostfolio/common/enums'; import { Access, AccessSettings } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; +import { getScopesOfAccess } from '@ghostfolio/common/scopes'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { @@ -95,6 +96,7 @@ export class AccessController { ? { connect: { id: data.granteeUserId } } : undefined, permissions: data.permissions, + scopes: getScopesOfAccess({ permissions: data.permissions }), settings: this.accessService.buildSettings(data.filters), user: { connect: { id: this.request.user.id } } }); @@ -164,6 +166,7 @@ export class AccessController { ? { connect: { id: data.granteeUserId } } : { disconnect: true }, permissions: data.permissions, + scopes: getScopesOfAccess({ permissions: data.permissions }), settings: this.accessService.buildSettings(data.filters) }, where: { id } diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index ffb8ec6ab9..5f0d03fce5 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -2,8 +2,10 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/accou import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; +import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard'; +import { ScopeGuard } from '@ghostfolio/api/guards/scope.guard'; 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 { ApiService } from '@ghostfolio/api/services/api/api.service'; @@ -18,6 +20,7 @@ import { AccountsResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; +import { scopes } from '@ghostfolio/common/scopes'; import type { ImpersonationContext, RequestWithUser @@ -84,7 +87,13 @@ export class AccountController { } @Get() - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.accountRead) + @UseGuards( + AuthGuard('jwt'), + HasPermissionGuard, + ImpersonationGuard, + ScopeGuard + ) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getAllAccounts( @@ -107,7 +116,13 @@ export class AccountController { } @Get(':id') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.accountRead) + @UseGuards( + AuthGuard('jwt'), + HasPermissionGuard, + ImpersonationGuard, + ScopeGuard + ) @UseInterceptors(RedactValuesInResponseInterceptor) public async getAccountById( @Impersonation() { userId }: ImpersonationContext, @@ -124,7 +139,13 @@ export class AccountController { } @Get(':id/balances') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.accountRead) + @UseGuards( + AuthGuard('jwt'), + HasPermissionGuard, + ImpersonationGuard, + ScopeGuard + ) @UseInterceptors(RedactValuesInResponseInterceptor) public async getAccountBalancesById( @Impersonation() { userId, userSettings }: ImpersonationContext, diff --git a/apps/api/src/app/activities/activities.controller.ts b/apps/api/src/app/activities/activities.controller.ts index aa44857ad2..9b0a78c52d 100644 --- a/apps/api/src/app/activities/activities.controller.ts +++ b/apps/api/src/app/activities/activities.controller.ts @@ -1,7 +1,9 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; +import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard'; +import { ScopeGuard } from '@ghostfolio/api/guards/scope.guard'; import { isActivityInFuture } from '@ghostfolio/api/helper/activity.helper'; 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'; @@ -17,6 +19,7 @@ import { ActivityResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; +import { scopes } from '@ghostfolio/common/scopes'; import type { ImpersonationContext, RequestWithUser @@ -120,7 +123,13 @@ export class ActivitiesController { } @Get() - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.activityRead) + @UseGuards( + AuthGuard('jwt'), + HasPermissionGuard, + ImpersonationGuard, + ScopeGuard + ) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) @@ -177,7 +186,13 @@ export class ActivitiesController { } @Get(':id') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.activityRead) + @UseGuards( + AuthGuard('jwt'), + HasPermissionGuard, + ImpersonationGuard, + ScopeGuard + ) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getActivityById( diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts index 79949a7159..44571c6c6e 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts @@ -1,8 +1,10 @@ import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; +import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard'; +import { ScopeGuard } from '@ghostfolio/api/guards/scope.guard'; 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 { ApiService } from '@ghostfolio/api/services/api/api.service'; @@ -14,6 +16,7 @@ import type { BenchmarkResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; +import { scopes } from '@ghostfolio/common/scopes'; import type { ImpersonationContext } from '@ghostfolio/common/types'; import { @@ -111,7 +114,13 @@ export class BenchmarksController { } @Get(':dataSource/:symbol/:startDateString') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) + @UseGuards( + AuthGuard('jwt'), + HasPermissionGuard, + ImpersonationGuard, + ScopeGuard + ) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getBenchmarkMarketDataForUser( @Impersonation() { userId, userSettings }: ImpersonationContext, diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts b/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts index 947612cb18..ba9d6b48c3 100644 --- a/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts +++ b/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts @@ -1,12 +1,15 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; +import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard'; +import { ScopeGuard } from '@ghostfolio/api/guards/scope.guard'; 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 { CreateWatchlistItemDto } from '@ghostfolio/common/dtos'; import { WatchlistResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; +import { scopes } from '@ghostfolio/common/scopes'; import { ImpersonationContext, RequestWithUser @@ -82,7 +85,13 @@ export class WatchlistController { @Get() @HasPermission(permissions.readWatchlist) - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.watchlistRead) + @UseGuards( + AuthGuard('jwt'), + HasPermissionGuard, + ImpersonationGuard, + ScopeGuard + ) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getWatchlistItems( @Impersonation() { userId }: ImpersonationContext diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 2edffc8d3c..b52966d44c 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -1,8 +1,10 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; +import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard'; +import { ScopeGuard } from '@ghostfolio/api/guards/scope.guard'; import { hasNotDefinedValuesInObject, nullifyValuesInObject @@ -25,11 +27,8 @@ import { PortfolioPerformanceResponse, PortfolioReportResponse } from '@ghostfolio/common/interfaces'; -import { - hasReadRestrictedAccessPermission, - isRestrictedView, - permissions -} from '@ghostfolio/common/permissions'; +import { isRestrictedView, permissions } from '@ghostfolio/common/permissions'; +import { hasScope, scopes } from '@ghostfolio/common/scopes'; import type { ImpersonationContext, RequestWithUser @@ -73,12 +72,19 @@ export class PortfolioController { ) {} @Get('details') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) + @UseGuards( + AuthGuard('jwt'), + HasPermissionGuard, + ImpersonationGuard, + ScopeGuard + ) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getDetails( - @Impersonation() { accessId, userId }: ImpersonationContext, + @Impersonation() + { scopes: impersonationScopes, userId }: ImpersonationContext, @Query() { accounts: filterByAccounts, @@ -130,10 +136,7 @@ export class PortfolioController { let portfolioSummary = summary; if ( - hasReadRestrictedAccessPermission({ - accesses: this.request.user?.accessesGet, - impersonationId: accessId - }) || + !hasScope(impersonationScopes, scopes.portfolioReadValues) || isRestrictedView(this.request.user) ) { const totalInvestment = Object.values(holdings) @@ -174,10 +177,7 @@ export class PortfolioController { if ( hasDetails === false || - hasReadRestrictedAccessPermission({ - accesses: this.request.user?.accessesGet, - impersonationId: accessId - }) || + !hasScope(impersonationScopes, scopes.portfolioReadValues) || isRestrictedView(this.request.user) ) { Object.values(markets ?? {}).forEach((market) => { @@ -319,10 +319,17 @@ export class PortfolioController { } @Get('dividends') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) + @UseGuards( + AuthGuard('jwt'), + HasPermissionGuard, + ImpersonationGuard, + ScopeGuard + ) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getDividends( - @Impersonation() { accessId, userId, userSettings }: ImpersonationContext, + @Impersonation() + { scopes: impersonationScopes, userId, userSettings }: ImpersonationContext, @Query() { accounts, @@ -364,10 +371,7 @@ export class PortfolioController { }); if ( - hasReadRestrictedAccessPermission({ - accesses: this.request.user?.accessesGet, - impersonationId: accessId - }) || + !hasScope(impersonationScopes, scopes.portfolioReadValues) || isRestrictedView(this.request.user) ) { const maxDividend = dividends.reduce( @@ -397,7 +401,13 @@ export class PortfolioController { @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) + @UseGuards( + AuthGuard('jwt'), + HasPermissionGuard, + ImpersonationGuard, + ScopeGuard + ) public async getHolding( @Impersonation() { userId }: ImpersonationContext, @Param('dataSource') dataSource: DataSource, @@ -420,7 +430,13 @@ export class PortfolioController { } @Get('holdings') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) + @UseGuards( + AuthGuard('jwt'), + HasPermissionGuard, + ImpersonationGuard, + ScopeGuard + ) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) @@ -458,10 +474,17 @@ export class PortfolioController { } @Get('investments') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) + @UseGuards( + AuthGuard('jwt'), + HasPermissionGuard, + ImpersonationGuard, + ScopeGuard + ) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getInvestments( - @Impersonation() { accessId, userId }: ImpersonationContext, + @Impersonation() + { scopes: impersonationScopes, userId }: ImpersonationContext, @Query() { accounts, @@ -490,10 +513,7 @@ export class PortfolioController { }); if ( - hasReadRestrictedAccessPermission({ - accesses: this.request.user?.accessesGet, - impersonationId: accessId - }) || + !hasScope(impersonationScopes, scopes.portfolioReadValues) || isRestrictedView(this.request.user) ) { const maxInvestment = investments.reduce( @@ -532,13 +552,20 @@ export class PortfolioController { } @Get('performance') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) + @UseGuards( + AuthGuard('jwt'), + HasPermissionGuard, + ImpersonationGuard, + ScopeGuard + ) @UseInterceptors(PerformanceLoggingInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) @Version('2') public async getPerformanceV2( - @Impersonation() { accessId, userId }: ImpersonationContext, + @Impersonation() + { scopes: impersonationScopes, userId }: ImpersonationContext, @Query() { accounts, @@ -566,10 +593,7 @@ export class PortfolioController { }); if ( - hasReadRestrictedAccessPermission({ - accesses: this.request.user?.accessesGet, - impersonationId: accessId - }) || + !hasScope(impersonationScopes, scopes.portfolioReadValues) || isRestrictedView(this.request.user) || this.request.user.settings.settings.viewMode === 'ZEN' ) { @@ -645,17 +669,21 @@ export class PortfolioController { } @Get('report') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) + @UseGuards( + AuthGuard('jwt'), + HasPermissionGuard, + ImpersonationGuard, + ScopeGuard + ) public async getReport( - @Impersonation() { accessId, userId }: ImpersonationContext + @Impersonation() + { scopes: impersonationScopes, userId }: ImpersonationContext ): Promise { const report = await this.portfolioService.getReport({ userId }); if ( - hasReadRestrictedAccessPermission({ - accesses: this.request.user?.accessesGet, - impersonationId: accessId - }) || + !hasScope(impersonationScopes, scopes.portfolioReadValues) || isRestrictedView(this.request.user) || (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.request.user.subscription?.type === SubscriptionType.Basic) @@ -676,7 +704,7 @@ export class PortfolioController { @HasPermission(permissions.updateActivity) @Put('holding/:dataSource/:symbol/tags') @UseInterceptors(TransformDataSourceInRequestInterceptor) - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async updateHoldingTags( @Body() data: UpdateHoldingTagsDto, @Param('dataSource') dataSource: DataSource, diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 50471c8bca..cf9043910a 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -129,9 +129,10 @@ export class UserController { @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getUser( @Headers('accept-language') acceptLanguage: string, - @Impersonation() { isActive, userId }: ImpersonationContext + @Impersonation() { isActive, scopes, userId }: ImpersonationContext ): Promise { return this.userService.getUser({ + scopes, impersonationUserId: isActive ? userId : undefined, locale: acceptLanguage?.split(',')?.[0], user: this.request.user diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index d74b90ede6..81fdca2a40 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -53,6 +53,7 @@ import { hasRole, permissions } from '@ghostfolio/common/permissions'; +import { getScopesOfAccess } from '@ghostfolio/common/scopes'; import { UserWithSettings } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; @@ -115,10 +116,12 @@ export class UserService { public async getUser({ impersonationUserId, locale = DEFAULT_LOCALE, + scopes, user }: { impersonationUserId: string; locale?: string; + scopes: string[]; user: UserWithSettings; }): Promise { const { id, permissions, settings, subscription } = user; @@ -206,6 +209,7 @@ export class UserService { id, permissions, referralPartners, + scopes, subscription, systemMessage, tags, @@ -213,7 +217,8 @@ export class UserService { return { alias: accessItem.alias, id: accessItem.id, - permissions: accessItem.permissions + permissions: accessItem.permissions, + scopes: getScopesOfAccess(accessItem) }; }), accounts: accounts.sort((a, b) => { diff --git a/apps/api/src/decorators/impersonation.decorator.ts b/apps/api/src/decorators/impersonation.decorator.ts index 190542459b..3964388e54 100644 --- a/apps/api/src/decorators/impersonation.decorator.ts +++ b/apps/api/src/decorators/impersonation.decorator.ts @@ -1,3 +1,4 @@ +import { getScopesOfOwnAccess } from '@ghostfolio/common/scopes'; import type { ImpersonationContext, RequestWithUser @@ -18,6 +19,7 @@ export const Impersonation = createParamDecorator( return ( impersonation ?? { isActive: false, + scopes: getScopesOfOwnAccess(), userId: user?.id, userSettings: user?.settings?.settings ?? {} } diff --git a/apps/api/src/decorators/requires-scope.decorator.ts b/apps/api/src/decorators/requires-scope.decorator.ts new file mode 100644 index 0000000000..62965b69d3 --- /dev/null +++ b/apps/api/src/decorators/requires-scope.decorator.ts @@ -0,0 +1,12 @@ +import { SetMetadata } from '@nestjs/common'; + +export const REQUIRES_SCOPE_KEY = 'requires_scope'; + +/** + * Marks a controller or a route which requires the given scopes of the + * impersonation context, which requires the ImpersonationGuard and the + * ScopeGuard to be applied to the route + */ +export function RequiresScope(...requiredScopes: string[]) { + return SetMetadata(REQUIRES_SCOPE_KEY, requiredScopes); +} diff --git a/apps/api/src/guards/scope.guard.ts b/apps/api/src/guards/scope.guard.ts new file mode 100644 index 0000000000..e2b8afd535 --- /dev/null +++ b/apps/api/src/guards/scope.guard.ts @@ -0,0 +1,50 @@ +import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator'; +import { hasScope } from '@ghostfolio/common/scopes'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { + CanActivate, + ExecutionContext, + HttpException, + Injectable +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +/** + * Denies a request whose impersonation context does not cover the scopes + * required by the route. It has to be applied after the ImpersonationGuard, + * which resolves the context. + */ +@Injectable() +export class ScopeGuard implements CanActivate { + public constructor(private readonly reflector: Reflector) {} + + public canActivate(context: ExecutionContext): boolean { + const requiredScopes = this.reflector.getAllAndOverride( + REQUIRES_SCOPE_KEY, + [context.getHandler(), context.getClass()] + ); + + if (!requiredScopes?.length) { + return true; + } + + const { impersonation } = context + .switchToHttp() + .getRequest(); + + const hasRequiredScopes = requiredScopes.every((scope) => { + return hasScope(impersonation?.scopes, scope); + }); + + if (!hasRequiredScopes) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return true; + } +} diff --git a/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts b/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts index 6a95962989..b4a2b0b84b 100644 --- a/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts +++ b/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts @@ -1,13 +1,8 @@ import { redactPaths } from '@ghostfolio/api/helper/object.helper'; -import { - DEFAULT_REDACTED_PATHS, - HEADER_KEY_IMPERSONATION -} from '@ghostfolio/common/config'; -import { - hasReadRestrictedAccessPermission, - isRestrictedView -} from '@ghostfolio/common/permissions'; -import { UserWithSettings } from '@ghostfolio/common/types'; +import { DEFAULT_REDACTED_PATHS } from '@ghostfolio/common/config'; +import { isRestrictedView } from '@ghostfolio/common/permissions'; +import { hasScope, scopes } from '@ghostfolio/common/scopes'; +import type { RequestWithUser } from '@ghostfolio/common/types'; import { CallHandler, @@ -29,17 +24,14 @@ export class RedactValuesInResponseInterceptor implements NestInterceptor< ): Observable { return next.handle().pipe( map((data: any) => { - const { headers, user }: { headers: Headers; user: UserWithSettings } = - context.switchToHttp().getRequest(); - - const impersonationId = - headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()]; + const { impersonation, user } = context + .switchToHttp() + .getRequest(); + // A missing impersonation context originates from a public request, + // which must not expose any monetary value if ( - hasReadRestrictedAccessPermission({ - impersonationId, - accesses: user?.accessesGet - }) || + !hasScope(impersonation?.scopes, scopes.portfolioReadValues) || isRestrictedView(user) ) { data = redactPaths({ diff --git a/apps/api/src/services/impersonation/impersonation.service.ts b/apps/api/src/services/impersonation/impersonation.service.ts index f3083e96c7..bb41ed7591 100644 --- a/apps/api/src/services/impersonation/impersonation.service.ts +++ b/apps/api/src/services/impersonation/impersonation.service.ts @@ -2,12 +2,18 @@ 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 { + getScopesOfAccess, + getScopesOfOwnAccess, + getScopesOfUnrestrictedImpersonation +} from '@ghostfolio/common/scopes'; import type { ImpersonationContext, UserWithSettings } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; +import { Access } from '@prisma/client'; @Injectable() export class ImpersonationService { @@ -20,14 +26,13 @@ export class ImpersonationService { impersonationId?: string; user?: UserWithSettings; }): Promise { - const impersonatedUserId = await this.validateImpersonationId({ - impersonationId, - user - }); + const { access, userId: impersonatedUserId } = + await this.validateImpersonation({ impersonationId, user }); if (!impersonatedUserId) { return { isActive: false, + scopes: getScopesOfOwnAccess(), userId: user?.id, userSettings: user?.settings?.settings ?? {} }; @@ -40,6 +45,11 @@ export class ImpersonationService { return { accessId: impersonationId, isActive: true, + // An access which has not been granted explicitly originates from the + // permission to impersonate all users + scopes: access + ? getScopesOfAccess(access) + : getScopesOfUnrestrictedImpersonation(), userId: impersonatedUserId, userSettings: { ...((settings?.settings ?? {}) as UserSettings), @@ -56,8 +66,23 @@ export class ImpersonationService { impersonationId?: string; user?: UserWithSettings; }) { + const { userId } = await this.validateImpersonation({ + impersonationId, + user + }); + + return userId; + } + + private async validateImpersonation({ + impersonationId, + user + }: { + impersonationId?: string; + user?: UserWithSettings; + }): Promise<{ access?: Access; userId: string | null }> { if (!impersonationId) { - return null; + return { userId: null }; } if (user) { @@ -69,7 +94,7 @@ export class ImpersonationService { }); if (accessObject?.userId) { - return accessObject.userId; + return { access: accessObject, userId: accessObject.userId }; } else if ( hasPermission(user.permissions, permissions.impersonateAllUsers) ) { @@ -79,7 +104,7 @@ export class ImpersonationService { where: { id: impersonationId } }); - return impersonatedUser?.id ?? null; + return { userId: impersonatedUser?.id ?? null }; } } else { // Public access @@ -91,10 +116,10 @@ export class ImpersonationService { }); if (accessObject?.userId) { - return accessObject.userId; + return { access: accessObject, userId: accessObject.userId }; } } - return null; + return { userId: null }; } } diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts index 9aa0f69646..518458e8a4 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts @@ -14,11 +14,8 @@ import { PortfolioPosition, User } from '@ghostfolio/common/interfaces'; -import { - hasPermission, - hasReadRestrictedAccessPermission, - permissions -} from '@ghostfolio/common/permissions'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { hasScope, scopes } from '@ghostfolio/common/scopes'; import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; @@ -233,10 +230,8 @@ export class GfAccountDetailDialogComponent implements OnInit { protected showValuesInPercentage() { return ( - hasReadRestrictedAccessPermission({ - accesses: this.user?.access, - impersonationId: this.data.impersonationId - }) || this.user?.settings?.isRestrictedView + !hasScope(this.user?.scopes, scopes.portfolioReadValues) || + this.user?.settings?.isRestrictedView ); } diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts index be7d49beaf..7d3c926452 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts @@ -17,11 +17,8 @@ import { PortfolioPosition, User } from '@ghostfolio/common/interfaces'; -import { - hasPermission, - hasReadRestrictedAccessPermission, - permissions -} from '@ghostfolio/common/permissions'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { hasScope, scopes } from '@ghostfolio/common/scopes'; import { MarketAdvanced } from '@ghostfolio/common/types'; import { translate } from '@ghostfolio/ui/i18n'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; @@ -229,10 +226,8 @@ export class GfAllocationsPageComponent implements OnInit { protected showValuesInPercentage() { return ( - hasReadRestrictedAccessPermission({ - accesses: this.user?.access, - impersonationId: this.impersonationId - }) || this.user?.settings?.isRestrictedView + !hasScope(this.user?.scopes, scopes.portfolioReadValues) || + this.user?.settings?.isRestrictedView ); } diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts index 2c34dbcd7f..7c7a2631a4 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts @@ -15,11 +15,8 @@ import { PortfolioPosition, User } from '@ghostfolio/common/interfaces'; -import { - hasPermission, - hasReadRestrictedAccessPermission, - permissions -} from '@ghostfolio/common/permissions'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { hasScope, scopes } from '@ghostfolio/common/scopes'; import type { AiPromptMode, GroupBy, @@ -247,10 +244,8 @@ export class GfAnalysisPageComponent implements OnInit { protected showValuesInPercentage() { return ( - hasReadRestrictedAccessPermission({ - accesses: this.user?.access, - impersonationId: this.impersonationId - }) || this.user?.settings?.isRestrictedView + !hasScope(this.user?.scopes, scopes.portfolioReadValues) || + this.user?.settings?.isRestrictedView ); } diff --git a/libs/common/src/lib/interfaces/user.interface.ts b/libs/common/src/lib/interfaces/user.interface.ts index 619d4ee714..8ce8cbaa6b 100644 --- a/libs/common/src/lib/interfaces/user.interface.ts +++ b/libs/common/src/lib/interfaces/user.interface.ts @@ -10,13 +10,14 @@ import { UserSettings } from './user-settings.interface'; // TODO: Compare with UserWithSettings export interface User { - access: Pick[]; + access: Pick[]; accounts: AccountWithPlatform[]; activitiesCount: number; dateOfFirstActivity: Date; id: string; permissions: string[]; referralPartners?: ReferralPartner[]; + scopes: string[]; settings: UserSettings; systemMessage?: SystemMessage; subscription: { diff --git a/libs/common/src/lib/scopes.spec.ts b/libs/common/src/lib/scopes.spec.ts new file mode 100644 index 0000000000..1bb998de99 --- /dev/null +++ b/libs/common/src/lib/scopes.spec.ts @@ -0,0 +1,75 @@ +import { + getScopesOfAccess, + getScopesOfOwnAccess, + getScopesOfUnrestrictedImpersonation, + hasScope, + scopes +} from '@ghostfolio/common/scopes'; + +describe('Scopes', () => { + describe('Get scopes of access', () => { + it('Scopes take precedence over the permissions', () => { + expect( + getScopesOfAccess({ + permissions: ['READ'], + scopes: [scopes.portfolioRead] + }) + ).toEqual([scopes.portfolioRead]); + }); + + it('Derive from the permission to read', () => { + // An access created before the scopes have been introduced has no scopes + expect( + getScopesOfAccess({ permissions: ['READ'], scopes: [] }) + ).toContain(scopes.portfolioReadValues); + }); + + it('Derive from the permission to read restricted', () => { + expect( + getScopesOfAccess({ permissions: ['READ_RESTRICTED'], scopes: [] }) + ).not.toContain(scopes.portfolioReadValues); + }); + + it('Without permissions and scopes', () => { + expect(getScopesOfAccess({})).not.toContain(scopes.portfolioReadValues); + }); + }); + + describe('Get scopes of own access', () => { + it('Covers every scope', () => { + expect(getScopesOfOwnAccess()).toEqual(Object.values(scopes)); + }); + }); + + describe('Get scopes of unrestricted impersonation', () => { + it('Excludes the monetary values', () => { + expect(getScopesOfUnrestrictedImpersonation()).not.toContain( + scopes.portfolioReadValues + ); + }); + + it('Allows reading the portfolio', () => { + expect(getScopesOfUnrestrictedImpersonation()).toContain( + scopes.portfolioRead + ); + }); + }); + + describe('Has scope', () => { + it('Present scope', () => { + expect(hasScope([scopes.portfolioRead], scopes.portfolioRead)).toEqual( + true + ); + }); + + it('Absent scope', () => { + expect( + hasScope([scopes.portfolioRead], scopes.portfolioReadValues) + ).toEqual(false); + }); + + it('Without scopes', () => { + expect(hasScope(undefined, scopes.portfolioRead)).toEqual(false); + }); + }); +}); diff --git a/libs/common/src/lib/scopes.ts b/libs/common/src/lib/scopes.ts new file mode 100644 index 0000000000..20b04d342b --- /dev/null +++ b/libs/common/src/lib/scopes.ts @@ -0,0 +1,65 @@ +import { AccessPermission } from '@prisma/client'; + +/** + * Scopes describe what a grantee may do on behalf of the granting user. They + * are a separate axis from the permissions, which describe the capabilities of + * a role. Both are evaluated, hence a delegation can only narrow the access of + * the authenticated user and never widen it. + */ +export const scopes = { + accountRead: 'account:read', + activityRead: 'activity:read', + portfolioRead: 'portfolio:read', + portfolioReadValues: 'portfolio:read:values', + watchlistRead: 'watchlist:read' +} as const; + +const SCOPES_OF_READ_RESTRICTED_ACCESS = [ + scopes.accountRead, + scopes.activityRead, + scopes.portfolioRead, + scopes.watchlistRead +]; + +const SCOPES_OF_READ_ACCESS = [ + ...SCOPES_OF_READ_RESTRICTED_ACCESS, + scopes.portfolioReadValues +]; + +export function getScopesOfAccess({ + permissions, + scopes +}: { + permissions?: AccessPermission[]; + scopes?: string[]; +}): string[] { + if (scopes?.length) { + return scopes; + } + + // TODO: Remove the derivation from the permissions once they have been + // dropped from the access + return permissions?.includes('READ') + ? SCOPES_OF_READ_ACCESS + : SCOPES_OF_READ_RESTRICTED_ACCESS; +} + +/** + * Scopes of a user acting on their own data, which is unrestricted. The + * permissions of the role are evaluated separately. + */ +export function getScopesOfOwnAccess(): string[] { + return Object.values(scopes); +} + +/** + * Scopes of an administrator impersonating an arbitrary user, which excludes + * the monetary values + */ +export function getScopesOfUnrestrictedImpersonation(): string[] { + return SCOPES_OF_READ_RESTRICTED_ACCESS; +} + +export function hasScope(aScopes: string[] = [], aScope: string) { + return aScopes.includes(aScope); +} diff --git a/libs/common/src/lib/types/impersonation-context.type.ts b/libs/common/src/lib/types/impersonation-context.type.ts index f5afb6bc7b..8bdfb9bb33 100644 --- a/libs/common/src/lib/types/impersonation-context.type.ts +++ b/libs/common/src/lib/types/impersonation-context.type.ts @@ -8,6 +8,7 @@ import { UserSettings } from '@ghostfolio/common/interfaces'; export interface ImpersonationContext { accessId?: string; isActive: boolean; + scopes: string[]; userId: string; userSettings: UserSettings; } diff --git a/prisma/migrations/20260811120000_added_scopes_to_access/migration.sql b/prisma/migrations/20260811120000_added_scopes_to_access/migration.sql new file mode 100644 index 0000000000..751c47ca8d --- /dev/null +++ b/prisma/migrations/20260811120000_added_scopes_to_access/migration.sql @@ -0,0 +1,20 @@ +-- AlterTable +ALTER TABLE "Access" ADD COLUMN "scopes" TEXT[] DEFAULT ARRAY[]::TEXT[]; + +-- Derive the scopes from the permissions of the existing accesses +UPDATE "Access" +SET "scopes" = CASE + WHEN 'READ' = ANY("permissions") THEN ARRAY[ + 'account:read', + 'activity:read', + 'portfolio:read', + 'portfolio:read:values', + 'watchlist:read' + ] + ELSE ARRAY[ + 'account:read', + 'activity:read', + 'portfolio:read', + 'watchlist:read' + ] +END; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 14cbb72fe7..27a7da0b2b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -15,6 +15,7 @@ model Access { granteeUserId String? id String @id @default(uuid()) permissions AccessPermission[] @default([READ_RESTRICTED]) + scopes String[] @default([]) settings Json @default("{}") updatedAt DateTime @updatedAt userId String