From 938c3af440fb63c07a2e334b3086e1170bafe566 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:14:57 +0200 Subject: [PATCH] Add scopes to access --- .../api/src/app/account/account.controller.ts | 20 --------- .../app/activities/activities.controller.ts | 14 ------ .../benchmarks/benchmarks.controller.ts | 8 ---- .../watchlist/watchlist.controller.ts | 8 ---- .../src/app/portfolio/portfolio.controller.ts | 44 ------------------- .../app/portfolio/portfolio.service.spec.ts | 1 - apps/api/src/app/user/user.service.ts | 3 -- .../requires-scope.decorator.spec.ts | 41 +++++++++++++++++ .../decorators/requires-scope.decorator.ts | 26 ++++++++--- apps/api/src/guards/scope.guard.ts | 6 +-- .../redact-values-in-response.interceptor.ts | 5 ++- .../impersonation/impersonation.service.ts | 15 ------- .../access-table/access-table.component.html | 4 +- .../access-table/access-table.component.ts | 5 +++ .../interfaces/interfaces.ts | 3 +- .../user-account-access.component.ts | 1 - .../analysis/analysis-page.component.ts | 14 ------ libs/common/src/lib/permissions.ts | 20 +-------- libs/common/src/lib/scopes.spec.ts | 29 +++++++----- libs/common/src/lib/scopes.ts | 34 +++++++------- .../src/lib/types/user-with-settings.type.ts | 3 +- 21 files changed, 111 insertions(+), 193 deletions(-) create mode 100644 apps/api/src/decorators/requires-scope.decorator.spec.ts diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index 5f0d03fce5..8b6692503a 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -4,8 +4,6 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat 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'; @@ -88,12 +86,6 @@ export class AccountController { @Get() @RequiresScope(scopes.accountRead) - @UseGuards( - AuthGuard('jwt'), - HasPermissionGuard, - ImpersonationGuard, - ScopeGuard - ) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getAllAccounts( @@ -117,12 +109,6 @@ export class AccountController { @Get(':id') @RequiresScope(scopes.accountRead) - @UseGuards( - AuthGuard('jwt'), - HasPermissionGuard, - ImpersonationGuard, - ScopeGuard - ) @UseInterceptors(RedactValuesInResponseInterceptor) public async getAccountById( @Impersonation() { userId }: ImpersonationContext, @@ -140,12 +126,6 @@ export class AccountController { @Get(':id/balances') @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 9b0a78c52d..21adf7c199 100644 --- a/apps/api/src/app/activities/activities.controller.ts +++ b/apps/api/src/app/activities/activities.controller.ts @@ -2,8 +2,6 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat 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'; @@ -124,12 +122,6 @@ export class ActivitiesController { @Get() @RequiresScope(scopes.activityRead) - @UseGuards( - AuthGuard('jwt'), - HasPermissionGuard, - ImpersonationGuard, - ScopeGuard - ) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) @@ -187,12 +179,6 @@ export class ActivitiesController { @Get(':id') @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 44571c6c6e..0930eb5b17 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts @@ -3,8 +3,6 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat 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'; @@ -115,12 +113,6 @@ export class BenchmarksController { @Get(':dataSource/:symbol/:startDateString') @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 ba9d6b48c3..11f3ff84ed 100644 --- a/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts +++ b/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts @@ -2,8 +2,6 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat 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'; @@ -86,12 +84,6 @@ export class WatchlistController { @Get() @HasPermission(permissions.readWatchlist) @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 b52966d44c..3b34ae83e9 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -3,8 +3,6 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat 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 @@ -73,12 +71,6 @@ export class PortfolioController { @Get('details') @RequiresScope(scopes.portfolioRead) - @UseGuards( - AuthGuard('jwt'), - HasPermissionGuard, - ImpersonationGuard, - ScopeGuard - ) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) @@ -320,12 +312,6 @@ export class PortfolioController { @Get('dividends') @RequiresScope(scopes.portfolioRead) - @UseGuards( - AuthGuard('jwt'), - HasPermissionGuard, - ImpersonationGuard, - ScopeGuard - ) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getDividends( @Impersonation() @@ -402,12 +388,6 @@ export class PortfolioController { @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) @RequiresScope(scopes.portfolioRead) - @UseGuards( - AuthGuard('jwt'), - HasPermissionGuard, - ImpersonationGuard, - ScopeGuard - ) public async getHolding( @Impersonation() { userId }: ImpersonationContext, @Param('dataSource') dataSource: DataSource, @@ -431,12 +411,6 @@ export class PortfolioController { @Get('holdings') @RequiresScope(scopes.portfolioRead) - @UseGuards( - AuthGuard('jwt'), - HasPermissionGuard, - ImpersonationGuard, - ScopeGuard - ) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) @@ -475,12 +449,6 @@ export class PortfolioController { @Get('investments') @RequiresScope(scopes.portfolioRead) - @UseGuards( - AuthGuard('jwt'), - HasPermissionGuard, - ImpersonationGuard, - ScopeGuard - ) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getInvestments( @Impersonation() @@ -553,12 +521,6 @@ export class PortfolioController { @Get('performance') @RequiresScope(scopes.portfolioRead) - @UseGuards( - AuthGuard('jwt'), - HasPermissionGuard, - ImpersonationGuard, - ScopeGuard - ) @UseInterceptors(PerformanceLoggingInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) @@ -670,12 +632,6 @@ export class PortfolioController { @Get('report') @RequiresScope(scopes.portfolioRead) - @UseGuards( - AuthGuard('jwt'), - HasPermissionGuard, - ImpersonationGuard, - ScopeGuard - ) public async getReport( @Impersonation() { scopes: impersonationScopes, userId }: ImpersonationContext diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index 9b6bacd444..2d1ef8ff43 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -245,7 +245,6 @@ describe('PortfolioService', () => { .mockResolvedValue([]); jest.spyOn(userService, 'user').mockResolvedValue({ - accessesGet: [], accounts: [], activityCount: 0, dataProviderGhostfolioDailyRequests: 0, diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 81fdca2a40..1175662eb2 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -287,7 +287,6 @@ export class UserService { activities: true } }, - accessesGet: true, accounts: { include: { platform: true } }, @@ -304,7 +303,6 @@ export class UserService { const { _count, - accessesGet, accessToken, accounts, analytics, @@ -322,7 +320,6 @@ export class UserService { const activitiesCount = _count?.activities ?? 0; const user: UserWithSettings = { - accessesGet, accessToken, accounts, authChallenge, diff --git a/apps/api/src/decorators/requires-scope.decorator.spec.ts b/apps/api/src/decorators/requires-scope.decorator.spec.ts new file mode 100644 index 0000000000..a36ff92015 --- /dev/null +++ b/apps/api/src/decorators/requires-scope.decorator.spec.ts @@ -0,0 +1,41 @@ +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 { scopes } from '@ghostfolio/common/scopes'; + +import { GUARDS_METADATA } from '@nestjs/common/constants'; +import { AuthGuard } from '@nestjs/passport'; + +import { REQUIRES_SCOPE_KEY, RequiresScope } from './requires-scope.decorator'; + +class TestController { + @RequiresScope(scopes.portfolioRead) + public getPortfolio() { + return null; + } +} + +describe('Requires scope', () => { + it('Sets the required scopes', () => { + expect( + Reflect.getMetadata( + REQUIRES_SCOPE_KEY, + TestController.prototype.getPortfolio + ) + ).toEqual([scopes.portfolioRead]); + }); + + it('Applies the guards in the required order', () => { + expect( + Reflect.getMetadata( + GUARDS_METADATA, + TestController.prototype.getPortfolio + ) + ).toEqual([ + AuthGuard('jwt'), + HasPermissionGuard, + ImpersonationGuard, + ScopeGuard + ]); + }); +}); diff --git a/apps/api/src/decorators/requires-scope.decorator.ts b/apps/api/src/decorators/requires-scope.decorator.ts index 62965b69d3..dab8379550 100644 --- a/apps/api/src/decorators/requires-scope.decorator.ts +++ b/apps/api/src/decorators/requires-scope.decorator.ts @@ -1,12 +1,26 @@ -import { SetMetadata } from '@nestjs/common'; +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 { Scope } from '@ghostfolio/common/scopes'; + +import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; 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 + * Marks a route which requires the given scopes and applies the guards which + * resolve the impersonation context and evaluate it, hence the ScopeGuard + * cannot be applied without the ImpersonationGuard preceding it */ -export function RequiresScope(...requiredScopes: string[]) { - return SetMetadata(REQUIRES_SCOPE_KEY, requiredScopes); +export function RequiresScope(...requiredScopes: Scope[]) { + return applyDecorators( + SetMetadata(REQUIRES_SCOPE_KEY, requiredScopes), + UseGuards( + AuthGuard('jwt'), + HasPermissionGuard, + ImpersonationGuard, + ScopeGuard + ) + ); } diff --git a/apps/api/src/guards/scope.guard.ts b/apps/api/src/guards/scope.guard.ts index e2b8afd535..025a85502f 100644 --- a/apps/api/src/guards/scope.guard.ts +++ b/apps/api/src/guards/scope.guard.ts @@ -1,5 +1,5 @@ import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator'; -import { hasScope } from '@ghostfolio/common/scopes'; +import { hasScope, Scope } from '@ghostfolio/common/scopes'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { @@ -14,14 +14,14 @@ 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. + * which resolves the context, hence the RequiresScope decorator applies both. */ @Injectable() export class ScopeGuard implements CanActivate { public constructor(private readonly reflector: Reflector) {} public canActivate(context: ExecutionContext): boolean { - const requiredScopes = this.reflector.getAllAndOverride( + const requiredScopes = this.reflector.getAllAndOverride( REQUIRES_SCOPE_KEY, [context.getHandler(), context.getClass()] ); 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 b4a2b0b84b..e575cbacda 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 @@ -28,8 +28,9 @@ export class RedactValuesInResponseInterceptor implements NestInterceptor< .switchToHttp() .getRequest(); - // A missing impersonation context originates from a public request, - // which must not expose any monetary value + // A missing impersonation context originates from a public request or + // from a route without the ImpersonationGuard, hence the monetary + // values are redacted to never expose them unintentionally if ( !hasScope(impersonation?.scopes, scopes.portfolioReadValues) || isRestrictedView(user) diff --git a/apps/api/src/services/impersonation/impersonation.service.ts b/apps/api/src/services/impersonation/impersonation.service.ts index bb41ed7591..1432c14aab 100644 --- a/apps/api/src/services/impersonation/impersonation.service.ts +++ b/apps/api/src/services/impersonation/impersonation.service.ts @@ -59,21 +59,6 @@ export class ImpersonationService { }; } - public async validateImpersonationId({ - impersonationId, - user - }: { - impersonationId?: string; - user?: UserWithSettings; - }) { - const { userId } = await this.validateImpersonation({ - impersonationId, - user - }); - - return userId; - } - private async validateImpersonation({ impersonationId, user diff --git a/apps/client/src/app/components/access-table/access-table.component.html b/apps/client/src/app/components/access-table/access-table.component.html index 9d87cbd799..77da69d237 100644 --- a/apps/client/src/app/components/access-table/access-table.component.html +++ b/apps/client/src/app/components/access-table/access-table.component.html @@ -18,10 +18,10 @@ Permission
- @if (element.permissions.includes('READ')) { + @if (hasScopeToReadValues(element)) { View - } @else if (element.permissions.includes('READ_RESTRICTED')) { + } @else { Restricted view } diff --git a/apps/client/src/app/components/access-table/access-table.component.ts b/apps/client/src/app/components/access-table/access-table.component.ts index 00c5c1a286..3886fc59c3 100644 --- a/apps/client/src/app/components/access-table/access-table.component.ts +++ b/apps/client/src/app/components/access-table/access-table.component.ts @@ -1,6 +1,7 @@ import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { Access, User } from '@ghostfolio/common/interfaces'; import { publicRoutes } from '@ghostfolio/common/routes/routes'; +import { hasScope, scopes } from '@ghostfolio/common/scopes'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'; @@ -100,6 +101,10 @@ export class GfAccessTableComponent { return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`; } + protected hasScopeToReadValues({ scopes: scopesOfAccess }: Access) { + return hasScope(scopesOfAccess, scopes.portfolioReadValues); + } + protected onCopyUrlToClipboard(aId: string) { this.clipboard.copy(this.getPublicUrl(aId)); diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts index 8d1ac0ba94..b4fdd76d12 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts @@ -1,5 +1,6 @@ import { Access } from '@ghostfolio/common/interfaces'; export interface CreateOrUpdateAccessDialogParams { - access?: Access; + // TODO: Include the scopes once the dialog allows to configure them + access?: Omit; } diff --git a/apps/client/src/app/components/user-account-access/user-account-access.component.ts b/apps/client/src/app/components/user-account-access/user-account-access.component.ts index 4f468c945c..b481d160a9 100644 --- a/apps/client/src/app/components/user-account-access/user-account-access.component.ts +++ b/apps/client/src/app/components/user-account-access/user-account-access.component.ts @@ -229,7 +229,6 @@ export class GfUserAccountAccessComponent implements OnInit { grantee: access.grantee === 'Public' ? undefined : access.grantee, id: access.id, permissions: access.permissions, - scopes: access.scopes, settings: access.settings, type: access.type } 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 7c7a2631a4..970f1379c4 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 @@ -1,6 +1,5 @@ import { GfBenchmarkComparatorComponent } from '@ghostfolio/client/components/benchmark-comparator/benchmark-comparator.component'; import { GfInvestmentChartComponent } from '@ghostfolio/client/components/investment-chart/investment-chart.component'; -import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { DEFAULT_DATE_RANGE, @@ -85,7 +84,6 @@ export class GfAnalysisPageComponent implements OnInit { protected dividendsByGroup: InvestmentItem[]; protected readonly dividendTimelineDataLabel = $localize`Dividend`; protected hasPermissionToReadAiPrompt: boolean; - protected impersonationId: string | null; protected investments: InvestmentItem[]; protected readonly investmentTimelineDataLabel = $localize`Invested Capital`; protected investmentsByGroup: InvestmentItem[]; @@ -123,9 +121,6 @@ export class GfAnalysisPageComponent implements OnInit { private readonly dataService = inject(DataService); private readonly destroyRef = inject(DestroyRef); private readonly deviceDetectorService = inject(DeviceDetectorService); - private readonly impersonationStorageService = inject( - ImpersonationStorageService - ); private readonly snackBar = inject(MatSnackBar); private readonly userService = inject(UserService); @@ -147,15 +142,6 @@ export class GfAnalysisPageComponent implements OnInit { } public ngOnInit() { - this.impersonationStorageService - .onChangeHasImpersonation() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((impersonationId) => { - this.impersonationId = impersonationId; - - this.changeDetectorRef.markForCheck(); - }); - this.userService.stateChanged .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((state) => { diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 96533a9e72..64c9dd890f 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -1,6 +1,6 @@ import { UserWithSettings } from '@ghostfolio/common/types'; -import { Access, Role } from '@prisma/client'; +import { Role } from '@prisma/client'; export const permissions = { accessAdminControl: 'accessAdminControl', @@ -197,24 +197,6 @@ export function hasPermission( return aPermissions.includes(aPermission); } -export function hasReadRestrictedAccessPermission({ - accesses = [], - impersonationId -}: { - accesses?: Pick[]; - impersonationId: string | null; -}) { - if (!impersonationId) { - return false; - } - - const access = accesses.find(({ id }) => { - return id === impersonationId; - }); - - return access?.permissions?.includes('READ_RESTRICTED') ?? true; -} - export function hasRole(aUser: UserWithSettings, aRole: Role) { return aUser?.role === aRole; } diff --git a/libs/common/src/lib/scopes.spec.ts b/libs/common/src/lib/scopes.spec.ts index 88b2ffffbb..feac970287 100644 --- a/libs/common/src/lib/scopes.spec.ts +++ b/libs/common/src/lib/scopes.spec.ts @@ -84,22 +84,29 @@ describe('Scopes', () => { }); describe('Get scopes of own access', () => { + // A new scope has to be added here deliberately to confirm that it is + // granted to the owner of the data it('Covers every scope', () => { - expect(getScopesOfOwnAccess()).toEqual(Object.values(scopes)); + expect(getScopesOfOwnAccess()).toEqual([ + scopes.accountRead, + scopes.activityRead, + scopes.portfolioRead, + scopes.portfolioReadValues, + scopes.watchlistRead + ]); }); }); 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 - ); + // A new scope has to be added here deliberately to confirm that it is + // granted to an administrator impersonating an arbitrary user + it('Covers every scope but the monetary values', () => { + expect(getScopesOfUnrestrictedImpersonation()).toEqual([ + scopes.accountRead, + scopes.activityRead, + scopes.portfolioRead, + scopes.watchlistRead + ]); }); }); diff --git a/libs/common/src/lib/scopes.ts b/libs/common/src/lib/scopes.ts index 5fe97d4a6f..fda81b4a9b 100644 --- a/libs/common/src/lib/scopes.ts +++ b/libs/common/src/lib/scopes.ts @@ -14,34 +14,30 @@ export const scopes = { watchlistRead: 'watchlist:read' } as const; -const SCOPES_OF_PUBLIC_ACCESS: string[] = [ +export type Scope = (typeof scopes)[keyof typeof scopes]; + +const SCOPES_OF_PUBLIC_ACCESS: Scope[] = [ scopes.activityRead, scopes.portfolioRead ]; -const SCOPES_OF_READ_RESTRICTED_ACCESS = [ - scopes.accountRead, - scopes.activityRead, - scopes.portfolioRead, - scopes.watchlistRead -]; +const SCOPES_OF_READ_ACCESS = Object.values(scopes); -const SCOPES_OF_READ_ACCESS = [ - ...SCOPES_OF_READ_RESTRICTED_ACCESS, - scopes.portfolioReadValues -]; +const SCOPES_OF_READ_RESTRICTED_ACCESS = SCOPES_OF_READ_ACCESS.filter( + (scope) => { + return scope !== scopes.portfolioReadValues; + } +); export function getScopesOfAccess({ granteeUserId, permissions, - scopes + scopes: scopesOfAccess }: { granteeUserId?: string | null; permissions?: AccessPermission[]; scopes?: string[]; }): string[] { - let scopesOfAccess = scopes; - if (!scopesOfAccess?.length) { // TODO: Remove the derivation from the permissions once they have been // dropped from the access @@ -51,13 +47,13 @@ export function getScopesOfAccess({ } if (granteeUserId) { - return scopesOfAccess; + return [...scopesOfAccess]; } // An access which has not been granted to a user is public, hence it is // narrowed to the scopes exposed by the public endpoints - return scopesOfAccess.filter((scope) => { - return SCOPES_OF_PUBLIC_ACCESS.includes(scope); + return SCOPES_OF_PUBLIC_ACCESS.filter((scope) => { + return scopesOfAccess.includes(scope); }); } @@ -74,9 +70,9 @@ export function getScopesOfOwnAccess(): string[] { * the monetary values */ export function getScopesOfUnrestrictedImpersonation(): string[] { - return SCOPES_OF_READ_RESTRICTED_ACCESS; + return [...SCOPES_OF_READ_RESTRICTED_ACCESS]; } -export function hasScope(aScopes: string[] = [], aScope: string) { +export function hasScope(aScopes: string[] = [], aScope: Scope) { return aScopes.includes(aScope); } diff --git a/libs/common/src/lib/types/user-with-settings.type.ts b/libs/common/src/lib/types/user-with-settings.type.ts index 3c6adfec00..37b2b83e9c 100644 --- a/libs/common/src/lib/types/user-with-settings.type.ts +++ b/libs/common/src/lib/types/user-with-settings.type.ts @@ -1,11 +1,10 @@ import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionOffer, UserSettings } from '@ghostfolio/common/interfaces'; -import { Access, Account, Settings, User } from '@prisma/client'; +import { Account, Settings, User } from '@prisma/client'; // TODO: Compare with User interface export type UserWithSettings = User & { - accessesGet: Access[]; accounts: Account[]; activityCount: number; dataProviderGhostfolioDailyRequests: number;