Browse Source

Task/add scopes to access (#7603)

* Add scopes to access

* Update changelog
pull/7632/head
Thomas Kaul 3 days ago
committed by GitHub
parent
commit
108e29444a
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 8
      CHANGELOG.md
  2. 20
      apps/api/src/app/access/access.controller.ts
  3. 9
      apps/api/src/app/account/account.controller.ts
  4. 7
      apps/api/src/app/activities/activities.controller.ts
  5. 5
      apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
  6. 5
      apps/api/src/app/endpoints/watchlist/watchlist.controller.ts
  7. 70
      apps/api/src/app/portfolio/portfolio.controller.ts
  8. 1
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  9. 3
      apps/api/src/app/user/user.controller.ts
  10. 10
      apps/api/src/app/user/user.service.ts
  11. 2
      apps/api/src/decorators/impersonation.decorator.ts
  12. 41
      apps/api/src/decorators/requires-scope.decorator.spec.ts
  13. 26
      apps/api/src/decorators/requires-scope.decorator.ts
  14. 50
      apps/api/src/guards/scope.guard.ts
  15. 29
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  16. 32
      apps/api/src/services/impersonation/impersonation.service.ts
  17. 4
      apps/client/src/app/components/access-table/access-table.component.html
  18. 5
      apps/client/src/app/components/access-table/access-table.component.ts
  19. 13
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  20. 3
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts
  21. 7
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  22. 13
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  23. 27
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  24. 3
      libs/common/src/lib/dtos/create-access.dto.ts
  25. 3
      libs/common/src/lib/dtos/update-access.dto.ts
  26. 6
      libs/common/src/lib/interfaces/access.interface.ts
  27. 3
      libs/common/src/lib/interfaces/user.interface.ts
  28. 20
      libs/common/src/lib/permissions.ts
  29. 130
      libs/common/src/lib/scopes.spec.ts
  30. 78
      libs/common/src/lib/scopes.ts
  31. 1
      libs/common/src/lib/types/impersonation-context.type.ts
  32. 3
      libs/common/src/lib/types/user-with-settings.type.ts
  33. 24
      prisma/migrations/20260815130000_added_scopes_to_access/migration.sql
  34. 2
      prisma/schema.prisma

8
CHANGELOG.md

@ -10,8 +10,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added the business logic and tests for stock splits of an asset profile (experimental) - 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 - 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 ## 3.51.0 - 2026-08-14
### Changed ### Changed

20
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 { SubscriptionType } from '@ghostfolio/common/enums';
import { Access, AccessSettings } from '@ghostfolio/common/interfaces'; import { Access, AccessSettings } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { getScopesOfAccess } from '@ghostfolio/common/scopes';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -47,13 +48,16 @@ export class AccessController {
where: { userId: this.request.user.id } where: { userId: this.request.user.id }
}); });
return accessesWithGranteeUser.map( return accessesWithGranteeUser.map((accessItem) => {
({ alias, granteeUser, id, permissions, settings }) => { const { alias, granteeUser, id, permissions, settings } = accessItem;
const scopes = getScopesOfAccess(accessItem);
if (granteeUser) { if (granteeUser) {
return { return {
alias, alias,
id, id,
permissions, permissions,
scopes,
grantee: granteeUser?.id, grantee: granteeUser?.id,
settings: settings as AccessSettings, settings: settings as AccessSettings,
type: 'PRIVATE' type: 'PRIVATE'
@ -64,12 +68,12 @@ export class AccessController {
alias, alias,
id, id,
permissions, permissions,
scopes,
grantee: 'Public', grantee: 'Public',
settings: settings as AccessSettings, settings: settings as AccessSettings,
type: 'PUBLIC' type: 'PUBLIC'
}; };
} });
);
} }
@HasPermission(permissions.createAccess) @HasPermission(permissions.createAccess)
@ -95,6 +99,10 @@ export class AccessController {
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }
: undefined, : undefined,
permissions: data.permissions, permissions: data.permissions,
scopes: getScopesOfAccess({
granteeUserId: data.granteeUserId,
permissions: data.permissions
}),
settings: this.accessService.buildSettings(data.filters), settings: this.accessService.buildSettings(data.filters),
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}); });
@ -164,6 +172,10 @@ export class AccessController {
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }
: { disconnect: true }, : { disconnect: true },
permissions: data.permissions, permissions: data.permissions,
scopes: getScopesOfAccess({
granteeUserId: data.granteeUserId,
permissions: data.permissions ?? originalAccess.permissions
}),
settings: this.accessService.buildSettings(data.filters) settings: this.accessService.buildSettings(data.filters)
}, },
where: { id } where: { id }

9
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 { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { 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 { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
@ -18,6 +18,7 @@ import {
AccountsResponse AccountsResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { scopes } from '@ghostfolio/common/scopes';
import type { import type {
ImpersonationContext, ImpersonationContext,
RequestWithUser RequestWithUser
@ -84,7 +85,7 @@ export class AccountController {
} }
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @RequiresScope(scopes.accountRead)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getAllAccounts( public async getAllAccounts(
@ -107,7 +108,7 @@ export class AccountController {
} }
@Get(':id') @Get(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @RequiresScope(scopes.accountRead)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountById( public async getAccountById(
@Impersonation() { userId }: ImpersonationContext, @Impersonation() { userId }: ImpersonationContext,
@ -124,7 +125,7 @@ export class AccountController {
} }
@Get(':id/balances') @Get(':id/balances')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @RequiresScope(scopes.accountRead)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountBalancesById( public async getAccountBalancesById(
@Impersonation() { userId, userSettings }: ImpersonationContext, @Impersonation() { userId, userSettings }: ImpersonationContext,

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

@ -1,7 +1,7 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { 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 { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard';
import { isActivityInFuture } from '@ghostfolio/api/helper/activity.helper'; import { isActivityInFuture } from '@ghostfolio/api/helper/activity.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
@ -17,6 +17,7 @@ import {
ActivityResponse ActivityResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { scopes } from '@ghostfolio/common/scopes';
import type { import type {
ImpersonationContext, ImpersonationContext,
RequestWithUser RequestWithUser
@ -120,7 +121,7 @@ export class ActivitiesController {
} }
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @RequiresScope(scopes.activityRead)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@ -177,7 +178,7 @@ export class ActivitiesController {
} }
@Get(':id') @Get(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @RequiresScope(scopes.activityRead)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getActivityById( public async getActivityById(

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

@ -1,8 +1,8 @@
import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { 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 { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
@ -14,6 +14,7 @@ import type {
BenchmarkResponse BenchmarkResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { scopes } from '@ghostfolio/common/scopes';
import type { ImpersonationContext } from '@ghostfolio/common/types'; import type { ImpersonationContext } from '@ghostfolio/common/types';
import { import {
@ -111,7 +112,7 @@ export class BenchmarksController {
} }
@Get(':dataSource/:symbol/:startDateString') @Get(':dataSource/:symbol/:startDateString')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @RequiresScope(scopes.portfolioRead)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataForUser( public async getBenchmarkMarketDataForUser(
@Impersonation() { userId, userSettings }: ImpersonationContext, @Impersonation() { userId, userSettings }: ImpersonationContext,

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

@ -1,12 +1,13 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { 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 { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { CreateWatchlistItemDto } from '@ghostfolio/common/dtos'; import { CreateWatchlistItemDto } from '@ghostfolio/common/dtos';
import { WatchlistResponse } from '@ghostfolio/common/interfaces'; import { WatchlistResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { scopes } from '@ghostfolio/common/scopes';
import { import {
ImpersonationContext, ImpersonationContext,
RequestWithUser RequestWithUser
@ -82,7 +83,7 @@ export class WatchlistController {
@Get() @Get()
@HasPermission(permissions.readWatchlist) @HasPermission(permissions.readWatchlist)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @RequiresScope(scopes.watchlistRead)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getWatchlistItems( public async getWatchlistItems(
@Impersonation() { userId }: ImpersonationContext @Impersonation() { userId }: ImpersonationContext

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

@ -1,8 +1,8 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { 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 { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard';
import { import {
hasNotDefinedValuesInObject, hasNotDefinedValuesInObject,
nullifyValuesInObject nullifyValuesInObject
@ -25,11 +25,8 @@ import {
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioReportResponse PortfolioReportResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { import { isRestrictedView, permissions } from '@ghostfolio/common/permissions';
hasReadRestrictedAccessPermission, import { hasScope, scopes } from '@ghostfolio/common/scopes';
isRestrictedView,
permissions
} from '@ghostfolio/common/permissions';
import type { import type {
ImpersonationContext, ImpersonationContext,
RequestWithUser RequestWithUser
@ -73,12 +70,13 @@ export class PortfolioController {
) {} ) {}
@Get('details') @Get('details')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @RequiresScope(scopes.portfolioRead)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails( public async getDetails(
@Impersonation() { accessId, userId }: ImpersonationContext, @Impersonation()
{ scopes: impersonationScopes, userId }: ImpersonationContext,
@Query() @Query()
{ {
accounts: filterByAccounts, accounts: filterByAccounts,
@ -130,10 +128,7 @@ export class PortfolioController {
let portfolioSummary = summary; let portfolioSummary = summary;
if ( if (
hasReadRestrictedAccessPermission({ !hasScope(impersonationScopes, scopes.portfolioReadValues) ||
accesses: this.request.user?.accessesGet,
impersonationId: accessId
}) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
const totalInvestment = Object.values(holdings) const totalInvestment = Object.values(holdings)
@ -174,10 +169,7 @@ export class PortfolioController {
if ( if (
hasDetails === false || hasDetails === false ||
hasReadRestrictedAccessPermission({ !hasScope(impersonationScopes, scopes.portfolioReadValues) ||
accesses: this.request.user?.accessesGet,
impersonationId: accessId
}) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
Object.values(markets ?? {}).forEach((market) => { Object.values(markets ?? {}).forEach((market) => {
@ -319,10 +311,11 @@ export class PortfolioController {
} }
@Get('dividends') @Get('dividends')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @RequiresScope(scopes.portfolioRead)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getDividends( public async getDividends(
@Impersonation() { accessId, userId, userSettings }: ImpersonationContext, @Impersonation()
{ scopes: impersonationScopes, userId, userSettings }: ImpersonationContext,
@Query() @Query()
{ {
accounts, accounts,
@ -364,10 +357,7 @@ export class PortfolioController {
}); });
if ( if (
hasReadRestrictedAccessPermission({ !hasScope(impersonationScopes, scopes.portfolioReadValues) ||
accesses: this.request.user?.accessesGet,
impersonationId: accessId
}) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
const maxDividend = dividends.reduce( const maxDividend = dividends.reduce(
@ -397,7 +387,7 @@ export class PortfolioController {
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @RequiresScope(scopes.portfolioRead)
public async getHolding( public async getHolding(
@Impersonation() { userId }: ImpersonationContext, @Impersonation() { userId }: ImpersonationContext,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@ -420,7 +410,7 @@ export class PortfolioController {
} }
@Get('holdings') @Get('holdings')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @RequiresScope(scopes.portfolioRead)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@ -458,10 +448,11 @@ export class PortfolioController {
} }
@Get('investments') @Get('investments')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @RequiresScope(scopes.portfolioRead)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getInvestments( public async getInvestments(
@Impersonation() { accessId, userId }: ImpersonationContext, @Impersonation()
{ scopes: impersonationScopes, userId }: ImpersonationContext,
@Query() @Query()
{ {
accounts, accounts,
@ -490,10 +481,7 @@ export class PortfolioController {
}); });
if ( if (
hasReadRestrictedAccessPermission({ !hasScope(impersonationScopes, scopes.portfolioReadValues) ||
accesses: this.request.user?.accessesGet,
impersonationId: accessId
}) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
const maxInvestment = investments.reduce( const maxInvestment = investments.reduce(
@ -532,13 +520,14 @@ export class PortfolioController {
} }
@Get('performance') @Get('performance')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @RequiresScope(scopes.portfolioRead)
@UseInterceptors(PerformanceLoggingInterceptor) @UseInterceptors(PerformanceLoggingInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@Version('2') @Version('2')
public async getPerformanceV2( public async getPerformanceV2(
@Impersonation() { accessId, userId }: ImpersonationContext, @Impersonation()
{ scopes: impersonationScopes, userId }: ImpersonationContext,
@Query() @Query()
{ {
accounts, accounts,
@ -566,10 +555,7 @@ export class PortfolioController {
}); });
if ( if (
hasReadRestrictedAccessPermission({ !hasScope(impersonationScopes, scopes.portfolioReadValues) ||
accesses: this.request.user?.accessesGet,
impersonationId: accessId
}) ||
isRestrictedView(this.request.user) || isRestrictedView(this.request.user) ||
this.request.user.settings.settings.viewMode === 'ZEN' this.request.user.settings.settings.viewMode === 'ZEN'
) { ) {
@ -645,17 +631,15 @@ export class PortfolioController {
} }
@Get('report') @Get('report')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @RequiresScope(scopes.portfolioRead)
public async getReport( public async getReport(
@Impersonation() { accessId, userId }: ImpersonationContext @Impersonation()
{ scopes: impersonationScopes, userId }: ImpersonationContext
): Promise<PortfolioReportResponse> { ): Promise<PortfolioReportResponse> {
const report = await this.portfolioService.getReport({ userId }); const report = await this.portfolioService.getReport({ userId });
if ( if (
hasReadRestrictedAccessPermission({ !hasScope(impersonationScopes, scopes.portfolioReadValues) ||
accesses: this.request.user?.accessesGet,
impersonationId: accessId
}) ||
isRestrictedView(this.request.user) || isRestrictedView(this.request.user) ||
(this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription?.type === SubscriptionType.Basic) this.request.user.subscription?.type === SubscriptionType.Basic)
@ -676,7 +660,7 @@ export class PortfolioController {
@HasPermission(permissions.updateActivity) @HasPermission(permissions.updateActivity)
@Put('holding/:dataSource/:symbol/tags') @Put('holding/:dataSource/:symbol/tags')
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateHoldingTags( public async updateHoldingTags(
@Body() data: UpdateHoldingTagsDto, @Body() data: UpdateHoldingTagsDto,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,

1
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -246,7 +246,6 @@ describe('PortfolioService', () => {
.mockResolvedValue([]); .mockResolvedValue([]);
jest.spyOn(userService, 'user').mockResolvedValue({ jest.spyOn(userService, 'user').mockResolvedValue({
accessesGet: [],
accounts: [], accounts: [],
activityCount: 0, activityCount: 0,
dataProviderGhostfolioDailyRequests: 0, dataProviderGhostfolioDailyRequests: 0,

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

@ -129,9 +129,10 @@ export class UserController {
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getUser( public async getUser(
@Headers('accept-language') acceptLanguage: string, @Headers('accept-language') acceptLanguage: string,
@Impersonation() { isActive, userId }: ImpersonationContext @Impersonation() { isActive, scopes, userId }: ImpersonationContext
): Promise<User> { ): Promise<User> {
return this.userService.getUser({ return this.userService.getUser({
scopes,
impersonationUserId: isActive ? userId : undefined, impersonationUserId: isActive ? userId : undefined,
locale: acceptLanguage?.split(',')?.[0], locale: acceptLanguage?.split(',')?.[0],
user: this.request.user user: this.request.user

10
apps/api/src/app/user/user.service.ts

@ -53,6 +53,7 @@ import {
hasRole, hasRole,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { getScopesOfAccess } from '@ghostfolio/common/scopes';
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
@ -115,10 +116,12 @@ export class UserService {
public async getUser({ public async getUser({
impersonationUserId, impersonationUserId,
locale = DEFAULT_LOCALE, locale = DEFAULT_LOCALE,
scopes,
user user
}: { }: {
impersonationUserId: string; impersonationUserId: string;
locale?: string; locale?: string;
scopes: string[];
user: UserWithSettings; user: UserWithSettings;
}): Promise<IUser> { }): Promise<IUser> {
const { id, permissions, settings, subscription } = user; const { id, permissions, settings, subscription } = user;
@ -206,6 +209,7 @@ export class UserService {
id, id,
permissions, permissions,
referralPartners, referralPartners,
scopes,
subscription, subscription,
systemMessage, systemMessage,
tags, tags,
@ -213,7 +217,8 @@ export class UserService {
return { return {
alias: accessItem.alias, alias: accessItem.alias,
id: accessItem.id, id: accessItem.id,
permissions: accessItem.permissions permissions: accessItem.permissions,
scopes: getScopesOfAccess(accessItem)
}; };
}), }),
accounts: accounts.sort((a, b) => { accounts: accounts.sort((a, b) => {
@ -282,7 +287,6 @@ export class UserService {
activities: true activities: true
} }
}, },
accessesGet: true,
accounts: { accounts: {
include: { platform: true } include: { platform: true }
}, },
@ -299,7 +303,6 @@ export class UserService {
const { const {
_count, _count,
accessesGet,
accessToken, accessToken,
accounts, accounts,
analytics, analytics,
@ -317,7 +320,6 @@ export class UserService {
const activitiesCount = _count?.activities ?? 0; const activitiesCount = _count?.activities ?? 0;
const user: UserWithSettings = { const user: UserWithSettings = {
accessesGet,
accessToken, accessToken,
accounts, accounts,
authChallenge, authChallenge,

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

@ -1,3 +1,4 @@
import { getScopesOfOwnAccess } from '@ghostfolio/common/scopes';
import type { import type {
ImpersonationContext, ImpersonationContext,
RequestWithUser RequestWithUser
@ -18,6 +19,7 @@ export const Impersonation = createParamDecorator(
return ( return (
impersonation ?? { impersonation ?? {
isActive: false, isActive: false,
scopes: getScopesOfOwnAccess(),
userId: user?.id, userId: user?.id,
userSettings: user?.settings?.settings ?? {} userSettings: user?.settings?.settings ?? {}
} }

41
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
]);
});
});

26
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
)
);
}

50
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<Scope[]>(
REQUIRES_SCOPE_KEY,
[context.getHandler(), context.getClass()]
);
if (!requiredScopes?.length) {
return true;
}
const { impersonation } = context
.switchToHttp()
.getRequest<RequestWithUser>();
const hasRequiredScopes = requiredScopes.every((scope) => {
return hasScope(impersonation?.scopes, scope);
});
if (!hasRequiredScopes) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return true;
}
}

29
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 { redactPaths } from '@ghostfolio/api/helper/object.helper';
import { import { DEFAULT_REDACTED_PATHS } from '@ghostfolio/common/config';
DEFAULT_REDACTED_PATHS, import { isRestrictedView } from '@ghostfolio/common/permissions';
HEADER_KEY_IMPERSONATION import { hasScope, scopes } from '@ghostfolio/common/scopes';
} from '@ghostfolio/common/config'; import type { RequestWithUser } from '@ghostfolio/common/types';
import {
hasReadRestrictedAccessPermission,
isRestrictedView
} from '@ghostfolio/common/permissions';
import { UserWithSettings } from '@ghostfolio/common/types';
import { import {
CallHandler, CallHandler,
@ -29,17 +24,15 @@ export class RedactValuesInResponseInterceptor<T> implements NestInterceptor<
): Observable<any> { ): Observable<any> {
return next.handle().pipe( return next.handle().pipe(
map((data: any) => { map((data: any) => {
const { headers, user }: { headers: Headers; user: UserWithSettings } = const { impersonation, user } = context
context.switchToHttp().getRequest(); .switchToHttp()
.getRequest<RequestWithUser>();
const impersonationId =
headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()];
// 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 ( if (
hasReadRestrictedAccessPermission({ !hasScope(impersonation?.scopes, scopes.portfolioReadValues) ||
impersonationId,
accesses: user?.accessesGet
}) ||
isRestrictedView(user) isRestrictedView(user)
) { ) {
data = redactPaths({ data = redactPaths({

32
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 { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { UserSettings } from '@ghostfolio/common/interfaces'; import { UserSettings } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
getScopesOfAccess,
getScopesOfOwnAccess,
getScopesOfUnrestrictedImpersonation
} from '@ghostfolio/common/scopes';
import type { import type {
ImpersonationContext, ImpersonationContext,
UserWithSettings UserWithSettings
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Access } from '@prisma/client';
@Injectable() @Injectable()
export class ImpersonationService { export class ImpersonationService {
@ -20,14 +26,13 @@ export class ImpersonationService {
impersonationId?: string; impersonationId?: string;
user?: UserWithSettings; user?: UserWithSettings;
}): Promise<ImpersonationContext> { }): Promise<ImpersonationContext> {
const impersonatedUserId = await this.validateImpersonationId({ const { access, userId: impersonatedUserId } =
impersonationId, await this.validateImpersonation({ impersonationId, user });
user
});
if (!impersonatedUserId) { if (!impersonatedUserId) {
return { return {
isActive: false, isActive: false,
scopes: getScopesOfOwnAccess(),
userId: user?.id, userId: user?.id,
userSettings: user?.settings?.settings ?? {} userSettings: user?.settings?.settings ?? {}
}; };
@ -40,6 +45,11 @@ export class ImpersonationService {
return { return {
accessId: impersonationId, accessId: impersonationId,
isActive: true, 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, userId: impersonatedUserId,
userSettings: { userSettings: {
...((settings?.settings ?? {}) as UserSettings), ...((settings?.settings ?? {}) as UserSettings),
@ -49,15 +59,15 @@ export class ImpersonationService {
}; };
} }
public async validateImpersonationId({ private async validateImpersonation({
impersonationId, impersonationId,
user user
}: { }: {
impersonationId?: string; impersonationId?: string;
user?: UserWithSettings; user?: UserWithSettings;
}) { }): Promise<{ access?: Access; userId: string | null }> {
if (!impersonationId) { if (!impersonationId) {
return null; return { userId: null };
} }
if (user) { if (user) {
@ -69,7 +79,7 @@ export class ImpersonationService {
}); });
if (accessObject?.userId) { if (accessObject?.userId) {
return accessObject.userId; return { access: accessObject, userId: accessObject.userId };
} else if ( } else if (
hasPermission(user.permissions, permissions.impersonateAllUsers) hasPermission(user.permissions, permissions.impersonateAllUsers)
) { ) {
@ -79,7 +89,7 @@ export class ImpersonationService {
where: { id: impersonationId } where: { id: impersonationId }
}); });
return impersonatedUser?.id ?? null; return { userId: impersonatedUser?.id ?? null };
} }
} else { } else {
// Public access // Public access
@ -91,10 +101,10 @@ export class ImpersonationService {
}); });
if (accessObject?.userId) { if (accessObject?.userId) {
return accessObject.userId; return { access: accessObject, userId: accessObject.userId };
} }
} }
return null; return { userId: null };
} }
} }

4
apps/client/src/app/components/access-table/access-table.component.html

@ -18,10 +18,10 @@
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Permission</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Permission</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell> <td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<div class="align-items-center d-flex"> <div class="align-items-center d-flex">
@if (element.permissions.includes('READ')) { @if (hasScopeToReadValues(element)) {
<ion-icon class="mr-1" name="lock-open-outline" /> <ion-icon class="mr-1" name="lock-open-outline" />
<ng-container i18n>View</ng-container> <ng-container i18n>View</ng-container>
} @else if (element.permissions.includes('READ_RESTRICTED')) { } @else {
<ion-icon class="mr-1" name="lock-closed-outline" /> <ion-icon class="mr-1" name="lock-closed-outline" />
<ng-container i18n>Restricted view</ng-container> <ng-container i18n>Restricted view</ng-container>
} }

5
apps/client/src/app/components/access-table/access-table.component.ts

@ -1,6 +1,7 @@
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { Access, User } from '@ghostfolio/common/interfaces'; import { Access, User } from '@ghostfolio/common/interfaces';
import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { hasScope, scopes } from '@ghostfolio/common/scopes';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'; import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard';
@ -100,6 +101,10 @@ export class GfAccessTableComponent {
return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`; return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`;
} }
protected hasScopeToReadValues({ scopes: scopesOfAccess }: Access) {
return hasScope(scopesOfAccess, scopes.portfolioReadValues);
}
protected onCopyUrlToClipboard(aId: string) { protected onCopyUrlToClipboard(aId: string) {
this.clipboard.copy(this.getPublicUrl(aId)); this.clipboard.copy(this.getPublicUrl(aId));

13
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts

@ -14,11 +14,8 @@ import {
PortfolioPosition, PortfolioPosition,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
hasPermission, import { hasScope, scopes } from '@ghostfolio/common/scopes';
hasReadRestrictedAccessPermission,
permissions
} from '@ghostfolio/common/permissions';
import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances'; import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
@ -233,10 +230,8 @@ export class GfAccountDetailDialogComponent implements OnInit {
protected showValuesInPercentage() { protected showValuesInPercentage() {
return ( return (
hasReadRestrictedAccessPermission({ !hasScope(this.user?.scopes, scopes.portfolioReadValues) ||
accesses: this.user?.access, this.user?.settings?.isRestrictedView
impersonationId: this.data.impersonationId
}) || this.user?.settings?.isRestrictedView
); );
} }

3
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'; import { Access } from '@ghostfolio/common/interfaces';
export interface CreateOrUpdateAccessDialogParams { export interface CreateOrUpdateAccessDialogParams {
access?: Access; // TODO: Include the scopes once the dialog allows to configure them
access?: Omit<Access, 'scopes'>;
} }

7
apps/client/src/app/components/user-account-access/user-account-access.component.ts

@ -247,15 +247,18 @@ export class GfUserAccountAccessComponent implements OnInit {
} }
private update() { private update() {
this.accessesGet = this.user.access.map(({ alias, id, permissions }) => { this.accessesGet = this.user.access.map(
({ alias, id, permissions, scopes }) => {
return { return {
id, id,
permissions, permissions,
scopes,
alias: alias ?? '', alias: alias ?? '',
grantee: $localize`Me`, grantee: $localize`Me`,
type: 'PRIVATE' type: 'PRIVATE'
}; };
}); }
);
this.dataService this.dataService
.fetchAccesses() .fetchAccesses()

13
apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts

@ -17,11 +17,8 @@ import {
PortfolioPosition, PortfolioPosition,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
hasPermission, import { hasScope, scopes } from '@ghostfolio/common/scopes';
hasReadRestrictedAccessPermission,
permissions
} from '@ghostfolio/common/permissions';
import { MarketAdvanced } from '@ghostfolio/common/types'; import { MarketAdvanced } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
@ -229,10 +226,8 @@ export class GfAllocationsPageComponent implements OnInit {
protected showValuesInPercentage() { protected showValuesInPercentage() {
return ( return (
hasReadRestrictedAccessPermission({ !hasScope(this.user?.scopes, scopes.portfolioReadValues) ||
accesses: this.user?.access, this.user?.settings?.isRestrictedView
impersonationId: this.impersonationId
}) || this.user?.settings?.isRestrictedView
); );
} }

27
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 { GfBenchmarkComparatorComponent } from '@ghostfolio/client/components/benchmark-comparator/benchmark-comparator.component';
import { GfInvestmentChartComponent } from '@ghostfolio/client/components/investment-chart/investment-chart.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 { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
DEFAULT_DATE_RANGE, DEFAULT_DATE_RANGE,
@ -15,11 +14,8 @@ import {
PortfolioPosition, PortfolioPosition,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
hasPermission, import { hasScope, scopes } from '@ghostfolio/common/scopes';
hasReadRestrictedAccessPermission,
permissions
} from '@ghostfolio/common/permissions';
import type { import type {
AiPromptMode, AiPromptMode,
GroupBy, GroupBy,
@ -88,7 +84,6 @@ export class GfAnalysisPageComponent implements OnInit {
protected dividendsByGroup: InvestmentItem[]; protected dividendsByGroup: InvestmentItem[];
protected readonly dividendTimelineDataLabel = $localize`Dividend`; protected readonly dividendTimelineDataLabel = $localize`Dividend`;
protected hasPermissionToReadAiPrompt: boolean; protected hasPermissionToReadAiPrompt: boolean;
protected impersonationId: string | null;
protected investments: InvestmentItem[]; protected investments: InvestmentItem[];
protected readonly investmentTimelineDataLabel = $localize`Invested Capital`; protected readonly investmentTimelineDataLabel = $localize`Invested Capital`;
protected investmentsByGroup: InvestmentItem[]; protected investmentsByGroup: InvestmentItem[];
@ -126,9 +121,6 @@ export class GfAnalysisPageComponent implements OnInit {
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService); private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly snackBar = inject(MatSnackBar); private readonly snackBar = inject(MatSnackBar);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
@ -150,15 +142,6 @@ export class GfAnalysisPageComponent implements OnInit {
} }
public ngOnInit() { public ngOnInit() {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.impersonationId = impersonationId;
this.changeDetectorRef.markForCheck();
});
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
@ -247,10 +230,8 @@ export class GfAnalysisPageComponent implements OnInit {
protected showValuesInPercentage() { protected showValuesInPercentage() {
return ( return (
hasReadRestrictedAccessPermission({ !hasScope(this.user?.scopes, scopes.portfolioReadValues) ||
accesses: this.user?.access, this.user?.settings?.isRestrictedView
impersonationId: this.impersonationId
}) || this.user?.settings?.isRestrictedView
); );
} }

3
libs/common/src/lib/dtos/create-access.dto.ts

@ -16,6 +16,9 @@ export class CreateAccessDto {
@IsUUID() @IsUUID()
granteeUserId?: string; granteeUserId?: string;
/**
* @deprecated Use the scopes instead
*/
@IsEnum(AccessPermission, { each: true }) @IsEnum(AccessPermission, { each: true })
@IsOptional() @IsOptional()
permissions?: AccessPermission[]; permissions?: AccessPermission[];

3
libs/common/src/lib/dtos/update-access.dto.ts

@ -19,6 +19,9 @@ export class UpdateAccessDto {
@IsString() @IsString()
id: string; id: string;
/**
* @deprecated Use the scopes instead
*/
@IsEnum(AccessPermission, { each: true }) @IsEnum(AccessPermission, { each: true })
@IsOptional() @IsOptional()
permissions?: AccessPermission[]; permissions?: AccessPermission[];

6
libs/common/src/lib/interfaces/access.interface.ts

@ -8,7 +8,13 @@ export interface Access {
alias: string | null; alias: string | null;
grantee?: string; grantee?: string;
id: string; id: string;
/**
* @deprecated Use the scopes instead
*/
permissions: AccessPermission[]; permissions: AccessPermission[];
scopes: string[];
settings?: AccessSettings; settings?: AccessSettings;
type: AccessType; type: AccessType;
} }

3
libs/common/src/lib/interfaces/user.interface.ts

@ -10,13 +10,14 @@ import { UserSettings } from './user-settings.interface';
// TODO: Compare with UserWithSettings // TODO: Compare with UserWithSettings
export interface User { export interface User {
access: Pick<Access, 'alias' | 'id' | 'permissions'>[]; access: Pick<Access, 'alias' | 'id' | 'permissions' | 'scopes'>[];
accounts: AccountWithPlatform[]; accounts: AccountWithPlatform[];
activitiesCount: number; activitiesCount: number;
dateOfFirstActivity: Date; dateOfFirstActivity: Date;
id: string; id: string;
permissions: string[]; permissions: string[];
referralPartners?: ReferralPartner[]; referralPartners?: ReferralPartner[];
scopes: string[];
settings: UserSettings; settings: UserSettings;
systemMessage?: SystemMessage; systemMessage?: SystemMessage;
subscription: { subscription: {

20
libs/common/src/lib/permissions.ts

@ -1,6 +1,6 @@
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { Access, Role } from '@prisma/client'; import { Role } from '@prisma/client';
export const permissions = { export const permissions = {
accessAdminControl: 'accessAdminControl', accessAdminControl: 'accessAdminControl',
@ -198,24 +198,6 @@ export function hasPermission(
return aPermissions.includes(aPermission); return aPermissions.includes(aPermission);
} }
export function hasReadRestrictedAccessPermission({
accesses = [],
impersonationId
}: {
accesses?: Pick<Access, 'id' | 'permissions'>[];
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) { export function hasRole(aUser: UserWithSettings, aRole: Role) {
return aUser?.role === aRole; return aUser?.role === aRole;
} }

130
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);
});
});
});

78
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);
}

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

@ -8,6 +8,7 @@ import { UserSettings } from '@ghostfolio/common/interfaces';
export interface ImpersonationContext { export interface ImpersonationContext {
accessId?: string; accessId?: string;
isActive: boolean; isActive: boolean;
scopes: string[];
userId: string; userId: string;
userSettings: UserSettings; userSettings: UserSettings;
} }

3
libs/common/src/lib/types/user-with-settings.type.ts

@ -1,11 +1,10 @@
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { SubscriptionOffer, UserSettings } from '@ghostfolio/common/interfaces'; 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 // TODO: Compare with User interface
export type UserWithSettings = User & { export type UserWithSettings = User & {
accessesGet: Access[];
accounts: Account[]; accounts: Account[];
activityCount: number; activityCount: number;
dataProviderGhostfolioDailyRequests: number; dataProviderGhostfolioDailyRequests: number;

24
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;

2
prisma/schema.prisma

@ -14,7 +14,9 @@ model Access {
granteeUser User? @relation("accessGet", fields: [granteeUserId], onDelete: Cascade, references: [id]) granteeUser User? @relation("accessGet", fields: [granteeUserId], onDelete: Cascade, references: [id])
granteeUserId String? granteeUserId String?
id String @id @default(uuid()) id String @id @default(uuid())
/// @deprecated Use the scopes instead
permissions AccessPermission[] @default([READ_RESTRICTED]) permissions AccessPermission[] @default([READ_RESTRICTED])
scopes String[] @default([])
settings Json @default("{}") settings Json @default("{}")
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
userId String userId String

Loading…
Cancel
Save