diff --git a/CHANGELOG.md b/CHANGELOG.md index 909420ce6..00bcc5498 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added the business logic and tests for stock splits of an asset profile (experimental) +- Added the scopes to the access - Added an index for `symbolProfileId` to the order database table +### Changed + +- Changed the redaction of the monetary values in impersonation mode to be based on the scopes of the access +- Deprecated the `permissions` attribute of the access in favor of the scopes +- Extended the `GET api/v1/access` endpoint by the scopes +- Extended the `GET api/v1/user` endpoint by the scopes + ## 3.51.0 - 2026-08-14 ### Changed diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index 54fadec68..8bd22fc25 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 { @@ -47,29 +48,32 @@ export class AccessController { where: { userId: this.request.user.id } }); - return accessesWithGranteeUser.map( - ({ alias, granteeUser, id, permissions, settings }) => { - if (granteeUser) { - return { - alias, - id, - permissions, - grantee: granteeUser?.id, - settings: settings as AccessSettings, - type: 'PRIVATE' - }; - } + return accessesWithGranteeUser.map((accessItem) => { + const { alias, granteeUser, id, permissions, settings } = accessItem; + const scopes = getScopesOfAccess(accessItem); + if (granteeUser) { return { alias, id, permissions, - grantee: 'Public', + scopes, + grantee: granteeUser?.id, settings: settings as AccessSettings, - type: 'PUBLIC' + type: 'PRIVATE' }; } - ); + + return { + alias, + id, + permissions, + scopes, + grantee: 'Public', + settings: settings as AccessSettings, + type: 'PUBLIC' + }; + }); } @HasPermission(permissions.createAccess) @@ -95,6 +99,10 @@ export class AccessController { ? { connect: { id: data.granteeUserId } } : undefined, permissions: data.permissions, + scopes: getScopesOfAccess({ + granteeUserId: data.granteeUserId, + permissions: data.permissions + }), settings: this.accessService.buildSettings(data.filters), user: { connect: { id: this.request.user.id } } }); @@ -164,6 +172,10 @@ export class AccessController { ? { connect: { id: data.granteeUserId } } : { disconnect: true }, permissions: data.permissions, + scopes: getScopesOfAccess({ + granteeUserId: data.granteeUserId, + permissions: data.permissions ?? originalAccess.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 ffb8ec6ab..8b6692503 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -2,8 +2,8 @@ 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 { 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 +18,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 +85,7 @@ export class AccountController { } @Get() - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.accountRead) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getAllAccounts( @@ -107,7 +108,7 @@ export class AccountController { } @Get(':id') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.accountRead) @UseInterceptors(RedactValuesInResponseInterceptor) public async getAccountById( @Impersonation() { userId }: ImpersonationContext, @@ -124,7 +125,7 @@ export class AccountController { } @Get(':id/balances') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.accountRead) @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 aa44857ad..21adf7c19 100644 --- a/apps/api/src/app/activities/activities.controller.ts +++ b/apps/api/src/app/activities/activities.controller.ts @@ -1,7 +1,7 @@ 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 { 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 +17,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 +121,7 @@ export class ActivitiesController { } @Get() - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.activityRead) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) @@ -177,7 +178,7 @@ export class ActivitiesController { } @Get(':id') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.activityRead) @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 79949a715..0930eb5b1 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts @@ -1,8 +1,8 @@ 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 { 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 +14,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 +112,7 @@ export class BenchmarksController { } @Get(':dataSource/:symbol/:startDateString') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) @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 947612cb1..11f3ff84e 100644 --- a/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts +++ b/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts @@ -1,12 +1,13 @@ 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 { 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 +83,7 @@ export class WatchlistController { @Get() @HasPermission(permissions.readWatchlist) - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.watchlistRead) @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 2edffc8d3..3b34ae83e 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -1,8 +1,8 @@ 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 { hasNotDefinedValuesInObject, nullifyValuesInObject @@ -25,11 +25,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 +70,13 @@ export class PortfolioController { ) {} @Get('details') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getDetails( - @Impersonation() { accessId, userId }: ImpersonationContext, + @Impersonation() + { scopes: impersonationScopes, userId }: ImpersonationContext, @Query() { accounts: filterByAccounts, @@ -130,10 +128,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 +169,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 +311,11 @@ export class PortfolioController { } @Get('dividends') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getDividends( - @Impersonation() { accessId, userId, userSettings }: ImpersonationContext, + @Impersonation() + { scopes: impersonationScopes, userId, userSettings }: ImpersonationContext, @Query() { accounts, @@ -364,10 +357,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 +387,7 @@ export class PortfolioController { @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) public async getHolding( @Impersonation() { userId }: ImpersonationContext, @Param('dataSource') dataSource: DataSource, @@ -420,7 +410,7 @@ export class PortfolioController { } @Get('holdings') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) @@ -458,10 +448,11 @@ export class PortfolioController { } @Get('investments') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getInvestments( - @Impersonation() { accessId, userId }: ImpersonationContext, + @Impersonation() + { scopes: impersonationScopes, userId }: ImpersonationContext, @Query() { accounts, @@ -490,10 +481,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 +520,14 @@ export class PortfolioController { } @Get('performance') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) @UseInterceptors(PerformanceLoggingInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) @Version('2') public async getPerformanceV2( - @Impersonation() { accessId, userId }: ImpersonationContext, + @Impersonation() + { scopes: impersonationScopes, userId }: ImpersonationContext, @Query() { accounts, @@ -566,10 +555,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 +631,15 @@ export class PortfolioController { } @Get('report') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) 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 +660,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/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index 29b6f22b5..970b13734 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -246,7 +246,6 @@ describe('PortfolioService', () => { .mockResolvedValue([]); jest.spyOn(userService, 'user').mockResolvedValue({ - accessesGet: [], accounts: [], activityCount: 0, dataProviderGhostfolioDailyRequests: 0, diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 50471c8bc..cf9043910 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 d74b90ede..1175662eb 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) => { @@ -282,7 +287,6 @@ export class UserService { activities: true } }, - accessesGet: true, accounts: { include: { platform: true } }, @@ -299,7 +303,6 @@ export class UserService { const { _count, - accessesGet, accessToken, accounts, analytics, @@ -317,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/impersonation.decorator.ts b/apps/api/src/decorators/impersonation.decorator.ts index 190542459..3964388e5 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.spec.ts b/apps/api/src/decorators/requires-scope.decorator.spec.ts new file mode 100644 index 000000000..a36ff9201 --- /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 new file mode 100644 index 000000000..dab837955 --- /dev/null +++ b/apps/api/src/decorators/requires-scope.decorator.ts @@ -0,0 +1,26 @@ +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 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: 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 new file mode 100644 index 000000000..025a85502 --- /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, Scope } 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, 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( + 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 6a9596298..e575cbacd 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,15 @@ 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 or + // from a route without the ImpersonationGuard, hence the monetary + // values are redacted to never expose them unintentionally 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 f3083e96c..1432c14aa 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), @@ -49,15 +59,15 @@ export class ImpersonationService { }; } - public async validateImpersonationId({ + 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 +79,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 +89,7 @@ export class ImpersonationService { where: { id: impersonationId } }); - return impersonatedUser?.id ?? null; + return { userId: impersonatedUser?.id ?? null }; } } else { // Public access @@ -91,10 +101,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/access-table/access-table.component.html b/apps/client/src/app/components/access-table/access-table.component.html index 9d87cbd79..77da69d23 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 00c5c1a28..3886fc59c 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/account-detail-dialog/account-detail-dialog.component.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts index 9aa0f6964..518458e8a 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/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 8d1ac0ba9..b4fdd76d1 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 c9d74b6b1..b481d160a 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 @@ -247,15 +247,18 @@ export class GfUserAccountAccessComponent implements OnInit { } private update() { - this.accessesGet = this.user.access.map(({ alias, id, permissions }) => { - return { - id, - permissions, - alias: alias ?? '', - grantee: $localize`Me`, - type: 'PRIVATE' - }; - }); + this.accessesGet = this.user.access.map( + ({ alias, id, permissions, scopes }) => { + return { + id, + permissions, + scopes, + alias: alias ?? '', + grantee: $localize`Me`, + type: 'PRIVATE' + }; + } + ); this.dataService .fetchAccesses() 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 be7d49bea..7d3c92645 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 2c34dbcd7..970f1379c 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, @@ -15,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 type { AiPromptMode, GroupBy, @@ -88,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[]; @@ -126,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); @@ -150,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) => { @@ -247,10 +230,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/dtos/create-access.dto.ts b/libs/common/src/lib/dtos/create-access.dto.ts index d4ba94fd2..370b1a2f8 100644 --- a/libs/common/src/lib/dtos/create-access.dto.ts +++ b/libs/common/src/lib/dtos/create-access.dto.ts @@ -16,6 +16,9 @@ export class CreateAccessDto { @IsUUID() granteeUserId?: string; + /** + * @deprecated Use the scopes instead + */ @IsEnum(AccessPermission, { each: true }) @IsOptional() permissions?: AccessPermission[]; diff --git a/libs/common/src/lib/dtos/update-access.dto.ts b/libs/common/src/lib/dtos/update-access.dto.ts index b5ed1db61..57d8fe9c0 100644 --- a/libs/common/src/lib/dtos/update-access.dto.ts +++ b/libs/common/src/lib/dtos/update-access.dto.ts @@ -19,6 +19,9 @@ export class UpdateAccessDto { @IsString() id: string; + /** + * @deprecated Use the scopes instead + */ @IsEnum(AccessPermission, { each: true }) @IsOptional() permissions?: AccessPermission[]; diff --git a/libs/common/src/lib/interfaces/access.interface.ts b/libs/common/src/lib/interfaces/access.interface.ts index 6b361d0b9..819dc60eb 100644 --- a/libs/common/src/lib/interfaces/access.interface.ts +++ b/libs/common/src/lib/interfaces/access.interface.ts @@ -8,7 +8,13 @@ export interface Access { alias: string | null; grantee?: string; id: string; + + /** + * @deprecated Use the scopes instead + */ permissions: AccessPermission[]; + + scopes: string[]; settings?: AccessSettings; type: AccessType; } diff --git a/libs/common/src/lib/interfaces/user.interface.ts b/libs/common/src/lib/interfaces/user.interface.ts index 619d4ee71..8ce8cbaa6 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/permissions.ts b/libs/common/src/lib/permissions.ts index 6dab85dad..8771f51dd 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', @@ -198,24 +198,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 new file mode 100644 index 000000000..feac97028 --- /dev/null +++ b/libs/common/src/lib/scopes.spec.ts @@ -0,0 +1,130 @@ +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({ + granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', + 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({ + granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', + permissions: ['READ'], + scopes: [] + }) + ).toContain(scopes.portfolioReadValues); + }); + + it('Derive from the permission to read restricted', () => { + expect( + getScopesOfAccess({ + granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', + permissions: ['READ_RESTRICTED'], + scopes: [] + }) + ).not.toContain(scopes.portfolioReadValues); + }); + + it('Without permissions and scopes', () => { + expect( + getScopesOfAccess({ + granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d' + }) + ).not.toContain(scopes.portfolioReadValues); + }); + }); + + describe('Get scopes of public access', () => { + it('Allows reading the portfolio', () => { + expect(getScopesOfAccess({ permissions: ['READ_RESTRICTED'] })).toContain( + scopes.portfolioRead + ); + }); + + it('Excludes the accounts and the watchlist', () => { + const scopesOfAccess = getScopesOfAccess({ + permissions: ['READ_RESTRICTED'] + }); + + expect(scopesOfAccess).not.toContain(scopes.accountRead); + expect(scopesOfAccess).not.toContain(scopes.watchlistRead); + }); + + it('Cannot be widened by the scopes', () => { + expect( + getScopesOfAccess({ + scopes: [ + scopes.portfolioRead, + scopes.portfolioReadValues, + scopes.watchlistRead + ] + }) + ).toEqual([scopes.portfolioRead]); + }); + + it('Cannot be widened by the permission to read', () => { + expect(getScopesOfAccess({ permissions: ['READ'] })).not.toContain( + scopes.portfolioReadValues + ); + }); + }); + + 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([ + scopes.accountRead, + scopes.activityRead, + scopes.portfolioRead, + scopes.portfolioReadValues, + scopes.watchlistRead + ]); + }); + }); + + describe('Get scopes of unrestricted impersonation', () => { + // 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 + ]); + }); + }); + + 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 000000000..fda81b4a9 --- /dev/null +++ b/libs/common/src/lib/scopes.ts @@ -0,0 +1,78 @@ +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; + +export type Scope = (typeof scopes)[keyof typeof scopes]; + +const SCOPES_OF_PUBLIC_ACCESS: Scope[] = [ + scopes.activityRead, + scopes.portfolioRead +]; + +const SCOPES_OF_READ_ACCESS = Object.values(scopes); + +const SCOPES_OF_READ_RESTRICTED_ACCESS = SCOPES_OF_READ_ACCESS.filter( + (scope) => { + return scope !== scopes.portfolioReadValues; + } +); + +export function getScopesOfAccess({ + granteeUserId, + permissions, + scopes: scopesOfAccess +}: { + granteeUserId?: string | null; + permissions?: AccessPermission[]; + scopes?: string[]; +}): string[] { + if (!scopesOfAccess?.length) { + // TODO: Remove the derivation from the permissions once they have been + // dropped from the access + scopesOfAccess = permissions?.includes('READ') + ? SCOPES_OF_READ_ACCESS + : SCOPES_OF_READ_RESTRICTED_ACCESS; + } + + if (granteeUserId) { + 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 SCOPES_OF_PUBLIC_ACCESS.filter((scope) => { + return scopesOfAccess.includes(scope); + }); +} + +/** + * 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: Scope) { + 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 f5afb6bc7..8bdfb9bb3 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/libs/common/src/lib/types/user-with-settings.type.ts b/libs/common/src/lib/types/user-with-settings.type.ts index 3c6adfec0..37b2b83e9 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; diff --git a/prisma/migrations/20260815130000_added_scopes_to_access/migration.sql b/prisma/migrations/20260815130000_added_scopes_to_access/migration.sql new file mode 100644 index 000000000..11f344421 --- /dev/null +++ b/prisma/migrations/20260815130000_added_scopes_to_access/migration.sql @@ -0,0 +1,24 @@ +-- 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 "granteeUserId" IS NULL THEN ARRAY[ + 'activity:read', + 'portfolio:read' + ] + 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 edfe783da..ca394887f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,7 +14,9 @@ model Access { granteeUser User? @relation("accessGet", fields: [granteeUserId], onDelete: Cascade, references: [id]) granteeUserId String? id String @id @default(uuid()) + /// @deprecated Use the scopes instead permissions AccessPermission[] @default([READ_RESTRICTED]) + scopes String[] @default([]) settings Json @default("{}") updatedAt DateTime @updatedAt userId String