Browse Source

Add scopes to access

pull/7603/head
Thomas Kaul 3 weeks ago
parent
commit
c9dcf6c4f7
  1. 3
      apps/api/src/app/access/access.controller.ts
  2. 27
      apps/api/src/app/account/account.controller.ts
  3. 19
      apps/api/src/app/activities/activities.controller.ts
  4. 11
      apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
  5. 11
      apps/api/src/app/endpoints/watchlist/watchlist.controller.ts
  6. 112
      apps/api/src/app/portfolio/portfolio.controller.ts
  7. 3
      apps/api/src/app/user/user.controller.ts
  8. 7
      apps/api/src/app/user/user.service.ts
  9. 2
      apps/api/src/decorators/impersonation.decorator.ts
  10. 12
      apps/api/src/decorators/requires-scope.decorator.ts
  11. 50
      apps/api/src/guards/scope.guard.ts
  12. 28
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  13. 43
      apps/api/src/services/impersonation/impersonation.service.ts
  14. 13
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  15. 13
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  16. 13
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  17. 3
      libs/common/src/lib/interfaces/user.interface.ts
  18. 75
      libs/common/src/lib/scopes.spec.ts
  19. 65
      libs/common/src/lib/scopes.ts
  20. 1
      libs/common/src/lib/types/impersonation-context.type.ts
  21. 20
      prisma/migrations/20260811120000_added_scopes_to_access/migration.sql
  22. 1
      prisma/schema.prisma

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

@ -6,6 +6,7 @@ import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { Access, AccessSettings } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { getScopesOfAccess } from '@ghostfolio/common/scopes';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -95,6 +96,7 @@ export class AccessController {
? { connect: { id: data.granteeUserId } }
: undefined,
permissions: data.permissions,
scopes: getScopesOfAccess({ permissions: data.permissions }),
settings: this.accessService.buildSettings(data.filters),
user: { connect: { id: this.request.user.id } }
});
@ -164,6 +166,7 @@ export class AccessController {
? { connect: { id: data.granteeUserId } }
: { disconnect: true },
permissions: data.permissions,
scopes: getScopesOfAccess({ permissions: data.permissions }),
settings: this.accessService.buildSettings(data.filters)
},
where: { id }

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

@ -2,8 +2,10 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/accou
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator';
import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard';
import { ScopeGuard } from '@ghostfolio/api/guards/scope.guard';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
@ -18,6 +20,7 @@ import {
AccountsResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { scopes } from '@ghostfolio/common/scopes';
import type {
ImpersonationContext,
RequestWithUser
@ -84,7 +87,13 @@ export class AccountController {
}
@Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@RequiresScope(scopes.accountRead)
@UseGuards(
AuthGuard('jwt'),
HasPermissionGuard,
ImpersonationGuard,
ScopeGuard
)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getAllAccounts(
@ -107,7 +116,13 @@ export class AccountController {
}
@Get(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@RequiresScope(scopes.accountRead)
@UseGuards(
AuthGuard('jwt'),
HasPermissionGuard,
ImpersonationGuard,
ScopeGuard
)
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountById(
@Impersonation() { userId }: ImpersonationContext,
@ -124,7 +139,13 @@ export class AccountController {
}
@Get(':id/balances')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@RequiresScope(scopes.accountRead)
@UseGuards(
AuthGuard('jwt'),
HasPermissionGuard,
ImpersonationGuard,
ScopeGuard
)
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountBalancesById(
@Impersonation() { userId, userSettings }: ImpersonationContext,

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

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

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

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

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

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

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

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

3
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<User> {
return this.userService.getUser({
scopes,
impersonationUserId: isActive ? userId : undefined,
locale: acceptLanguage?.split(',')?.[0],
user: this.request.user

7
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<IUser> {
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) => {

2
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 ?? {}
}

12
apps/api/src/decorators/requires-scope.decorator.ts

@ -0,0 +1,12 @@
import { SetMetadata } from '@nestjs/common';
export const REQUIRES_SCOPE_KEY = 'requires_scope';
/**
* Marks a controller or a route which requires the given scopes of the
* impersonation context, which requires the ImpersonationGuard and the
* ScopeGuard to be applied to the route
*/
export function RequiresScope(...requiredScopes: string[]) {
return SetMetadata(REQUIRES_SCOPE_KEY, requiredScopes);
}

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 } from '@ghostfolio/common/scopes';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
CanActivate,
ExecutionContext,
HttpException,
Injectable
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
/**
* Denies a request whose impersonation context does not cover the scopes
* required by the route. It has to be applied after the ImpersonationGuard,
* which resolves the context.
*/
@Injectable()
export class ScopeGuard implements CanActivate {
public constructor(private readonly reflector: Reflector) {}
public canActivate(context: ExecutionContext): boolean {
const requiredScopes = this.reflector.getAllAndOverride<string[]>(
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;
}
}

28
apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts

@ -1,13 +1,8 @@
import { redactPaths } from '@ghostfolio/api/helper/object.helper';
import {
DEFAULT_REDACTED_PATHS,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import {
hasReadRestrictedAccessPermission,
isRestrictedView
} from '@ghostfolio/common/permissions';
import { UserWithSettings } from '@ghostfolio/common/types';
import { DEFAULT_REDACTED_PATHS } from '@ghostfolio/common/config';
import { isRestrictedView } from '@ghostfolio/common/permissions';
import { hasScope, scopes } from '@ghostfolio/common/scopes';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
CallHandler,
@ -29,17 +24,14 @@ export class RedactValuesInResponseInterceptor<T> implements NestInterceptor<
): Observable<any> {
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<RequestWithUser>();
// A missing impersonation context originates from a public request,
// which must not expose any monetary value
if (
hasReadRestrictedAccessPermission({
impersonationId,
accesses: user?.accessesGet
}) ||
!hasScope(impersonation?.scopes, scopes.portfolioReadValues) ||
isRestrictedView(user)
) {
data = redactPaths({

43
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<ImpersonationContext> {
const impersonatedUserId = await this.validateImpersonationId({
impersonationId,
user
});
const { access, userId: impersonatedUserId } =
await this.validateImpersonation({ impersonationId, user });
if (!impersonatedUserId) {
return {
isActive: false,
scopes: getScopesOfOwnAccess(),
userId: user?.id,
userSettings: user?.settings?.settings ?? {}
};
@ -40,6 +45,11 @@ export class ImpersonationService {
return {
accessId: impersonationId,
isActive: true,
// An access which has not been granted explicitly originates from the
// permission to impersonate all users
scopes: access
? getScopesOfAccess(access)
: getScopesOfUnrestrictedImpersonation(),
userId: impersonatedUserId,
userSettings: {
...((settings?.settings ?? {}) as UserSettings),
@ -56,8 +66,23 @@ export class ImpersonationService {
impersonationId?: string;
user?: UserWithSettings;
}) {
const { userId } = await this.validateImpersonation({
impersonationId,
user
});
return userId;
}
private async validateImpersonation({
impersonationId,
user
}: {
impersonationId?: string;
user?: UserWithSettings;
}): Promise<{ access?: Access; userId: string | null }> {
if (!impersonationId) {
return null;
return { userId: null };
}
if (user) {
@ -69,7 +94,7 @@ export class ImpersonationService {
});
if (accessObject?.userId) {
return accessObject.userId;
return { access: accessObject, userId: accessObject.userId };
} else if (
hasPermission(user.permissions, permissions.impersonateAllUsers)
) {
@ -79,7 +104,7 @@ export class ImpersonationService {
where: { id: impersonationId }
});
return impersonatedUser?.id ?? null;
return { userId: impersonatedUser?.id ?? null };
}
} else {
// Public access
@ -91,10 +116,10 @@ export class ImpersonationService {
});
if (accessObject?.userId) {
return accessObject.userId;
return { access: accessObject, userId: accessObject.userId };
}
}
return null;
return { userId: null };
}
}

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

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

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

@ -15,11 +15,8 @@ import {
PortfolioPosition,
User
} from '@ghostfolio/common/interfaces';
import {
hasPermission,
hasReadRestrictedAccessPermission,
permissions
} from '@ghostfolio/common/permissions';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { hasScope, scopes } from '@ghostfolio/common/scopes';
import type {
AiPromptMode,
GroupBy,
@ -247,10 +244,8 @@ export class GfAnalysisPageComponent implements OnInit {
protected showValuesInPercentage() {
return (
hasReadRestrictedAccessPermission({
accesses: this.user?.access,
impersonationId: this.impersonationId
}) || this.user?.settings?.isRestrictedView
!hasScope(this.user?.scopes, scopes.portfolioReadValues) ||
this.user?.settings?.isRestrictedView
);
}

3
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, 'alias' | 'id' | 'permissions'>[];
access: Pick<Access, 'alias' | 'id' | 'permissions' | 'scopes'>[];
accounts: AccountWithPlatform[];
activitiesCount: number;
dateOfFirstActivity: Date;
id: string;
permissions: string[];
referralPartners?: ReferralPartner[];
scopes: string[];
settings: UserSettings;
systemMessage?: SystemMessage;
subscription: {

75
libs/common/src/lib/scopes.spec.ts

@ -0,0 +1,75 @@
import {
getScopesOfAccess,
getScopesOfOwnAccess,
getScopesOfUnrestrictedImpersonation,
hasScope,
scopes
} from '@ghostfolio/common/scopes';
describe('Scopes', () => {
describe('Get scopes of access', () => {
it('Scopes take precedence over the permissions', () => {
expect(
getScopesOfAccess({
permissions: ['READ'],
scopes: [scopes.portfolioRead]
})
).toEqual([scopes.portfolioRead]);
});
it('Derive from the permission to read', () => {
// An access created before the scopes have been introduced has no scopes
expect(
getScopesOfAccess({ permissions: ['READ'], scopes: [] })
).toContain(scopes.portfolioReadValues);
});
it('Derive from the permission to read restricted', () => {
expect(
getScopesOfAccess({ permissions: ['READ_RESTRICTED'], scopes: [] })
).not.toContain(scopes.portfolioReadValues);
});
it('Without permissions and scopes', () => {
expect(getScopesOfAccess({})).not.toContain(scopes.portfolioReadValues);
});
});
describe('Get scopes of own access', () => {
it('Covers every scope', () => {
expect(getScopesOfOwnAccess()).toEqual(Object.values(scopes));
});
});
describe('Get scopes of unrestricted impersonation', () => {
it('Excludes the monetary values', () => {
expect(getScopesOfUnrestrictedImpersonation()).not.toContain(
scopes.portfolioReadValues
);
});
it('Allows reading the portfolio', () => {
expect(getScopesOfUnrestrictedImpersonation()).toContain(
scopes.portfolioRead
);
});
});
describe('Has scope', () => {
it('Present scope', () => {
expect(hasScope([scopes.portfolioRead], scopes.portfolioRead)).toEqual(
true
);
});
it('Absent scope', () => {
expect(
hasScope([scopes.portfolioRead], scopes.portfolioReadValues)
).toEqual(false);
});
it('Without scopes', () => {
expect(hasScope(undefined, scopes.portfolioRead)).toEqual(false);
});
});
});

65
libs/common/src/lib/scopes.ts

@ -0,0 +1,65 @@
import { AccessPermission } from '@prisma/client';
/**
* Scopes describe what a grantee may do on behalf of the granting user. They
* are a separate axis from the permissions, which describe the capabilities of
* a role. Both are evaluated, hence a delegation can only narrow the access of
* the authenticated user and never widen it.
*/
export const scopes = {
accountRead: 'account:read',
activityRead: 'activity:read',
portfolioRead: 'portfolio:read',
portfolioReadValues: 'portfolio:read:values',
watchlistRead: 'watchlist:read'
} as const;
const SCOPES_OF_READ_RESTRICTED_ACCESS = [
scopes.accountRead,
scopes.activityRead,
scopes.portfolioRead,
scopes.watchlistRead
];
const SCOPES_OF_READ_ACCESS = [
...SCOPES_OF_READ_RESTRICTED_ACCESS,
scopes.portfolioReadValues
];
export function getScopesOfAccess({
permissions,
scopes
}: {
permissions?: AccessPermission[];
scopes?: string[];
}): string[] {
if (scopes?.length) {
return scopes;
}
// TODO: Remove the derivation from the permissions once they have been
// dropped from the access
return permissions?.includes('READ')
? SCOPES_OF_READ_ACCESS
: SCOPES_OF_READ_RESTRICTED_ACCESS;
}
/**
* Scopes of a user acting on their own data, which is unrestricted. The
* permissions of the role are evaluated separately.
*/
export function getScopesOfOwnAccess(): string[] {
return Object.values(scopes);
}
/**
* Scopes of an administrator impersonating an arbitrary user, which excludes
* the monetary values
*/
export function getScopesOfUnrestrictedImpersonation(): string[] {
return SCOPES_OF_READ_RESTRICTED_ACCESS;
}
export function hasScope(aScopes: string[] = [], aScope: string) {
return aScopes.includes(aScope);
}

1
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;
}

20
prisma/migrations/20260811120000_added_scopes_to_access/migration.sql

@ -0,0 +1,20 @@
-- AlterTable
ALTER TABLE "Access" ADD COLUMN "scopes" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- Derive the scopes from the permissions of the existing accesses
UPDATE "Access"
SET "scopes" = CASE
WHEN 'READ' = ANY("permissions") THEN ARRAY[
'account:read',
'activity:read',
'portfolio:read',
'portfolio:read:values',
'watchlist:read'
]
ELSE ARRAY[
'account:read',
'activity:read',
'portfolio:read',
'watchlist:read'
]
END;

1
prisma/schema.prisma

@ -15,6 +15,7 @@ model Access {
granteeUserId String?
id String @id @default(uuid())
permissions AccessPermission[] @default([READ_RESTRICTED])
scopes String[] @default([])
settings Json @default("{}")
updatedAt DateTime @updatedAt
userId String

Loading…
Cancel
Save