Browse Source

Add scopes to access

pull/7603/head
Thomas Kaul 2 weeks ago
parent
commit
938c3af440
  1. 20
      apps/api/src/app/account/account.controller.ts
  2. 14
      apps/api/src/app/activities/activities.controller.ts
  3. 8
      apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
  4. 8
      apps/api/src/app/endpoints/watchlist/watchlist.controller.ts
  5. 44
      apps/api/src/app/portfolio/portfolio.controller.ts
  6. 1
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  7. 3
      apps/api/src/app/user/user.service.ts
  8. 41
      apps/api/src/decorators/requires-scope.decorator.spec.ts
  9. 26
      apps/api/src/decorators/requires-scope.decorator.ts
  10. 6
      apps/api/src/guards/scope.guard.ts
  11. 5
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  12. 15
      apps/api/src/services/impersonation/impersonation.service.ts
  13. 4
      apps/client/src/app/components/access-table/access-table.component.html
  14. 5
      apps/client/src/app/components/access-table/access-table.component.ts
  15. 3
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts
  16. 1
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  17. 14
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  18. 20
      libs/common/src/lib/permissions.ts
  19. 29
      libs/common/src/lib/scopes.spec.ts
  20. 34
      libs/common/src/lib/scopes.ts
  21. 3
      libs/common/src/lib/types/user-with-settings.type.ts

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

@ -4,8 +4,6 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator';
import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.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 { ScopeGuard } from '@ghostfolio/api/guards/scope.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';
@ -88,12 +86,6 @@ export class AccountController {
@Get() @Get()
@RequiresScope(scopes.accountRead) @RequiresScope(scopes.accountRead)
@UseGuards(
AuthGuard('jwt'),
HasPermissionGuard,
ImpersonationGuard,
ScopeGuard
)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getAllAccounts( public async getAllAccounts(
@ -117,12 +109,6 @@ export class AccountController {
@Get(':id') @Get(':id')
@RequiresScope(scopes.accountRead) @RequiresScope(scopes.accountRead)
@UseGuards(
AuthGuard('jwt'),
HasPermissionGuard,
ImpersonationGuard,
ScopeGuard
)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountById( public async getAccountById(
@Impersonation() { userId }: ImpersonationContext, @Impersonation() { userId }: ImpersonationContext,
@ -140,12 +126,6 @@ export class AccountController {
@Get(':id/balances') @Get(':id/balances')
@RequiresScope(scopes.accountRead) @RequiresScope(scopes.accountRead)
@UseGuards(
AuthGuard('jwt'),
HasPermissionGuard,
ImpersonationGuard,
ScopeGuard
)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountBalancesById( public async getAccountBalancesById(
@Impersonation() { userId, userSettings }: ImpersonationContext, @Impersonation() { userId, userSettings }: ImpersonationContext,

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

@ -2,8 +2,6 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator';
import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.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 { ScopeGuard } from '@ghostfolio/api/guards/scope.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';
@ -124,12 +122,6 @@ export class ActivitiesController {
@Get() @Get()
@RequiresScope(scopes.activityRead) @RequiresScope(scopes.activityRead)
@UseGuards(
AuthGuard('jwt'),
HasPermissionGuard,
ImpersonationGuard,
ScopeGuard
)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@ -187,12 +179,6 @@ export class ActivitiesController {
@Get(':id') @Get(':id')
@RequiresScope(scopes.activityRead) @RequiresScope(scopes.activityRead)
@UseGuards(
AuthGuard('jwt'),
HasPermissionGuard,
ImpersonationGuard,
ScopeGuard
)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getActivityById( public async getActivityById(

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

@ -3,8 +3,6 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator';
import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.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 { 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 { 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';
@ -115,12 +113,6 @@ export class BenchmarksController {
@Get(':dataSource/:symbol/:startDateString') @Get(':dataSource/:symbol/:startDateString')
@RequiresScope(scopes.portfolioRead) @RequiresScope(scopes.portfolioRead)
@UseGuards(
AuthGuard('jwt'),
HasPermissionGuard,
ImpersonationGuard,
ScopeGuard
)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataForUser( public async getBenchmarkMarketDataForUser(
@Impersonation() { userId, userSettings }: ImpersonationContext, @Impersonation() { userId, userSettings }: ImpersonationContext,

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

@ -2,8 +2,6 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator';
import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.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 { 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 { 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';
@ -86,12 +84,6 @@ export class WatchlistController {
@Get() @Get()
@HasPermission(permissions.readWatchlist) @HasPermission(permissions.readWatchlist)
@RequiresScope(scopes.watchlistRead) @RequiresScope(scopes.watchlistRead)
@UseGuards(
AuthGuard('jwt'),
HasPermissionGuard,
ImpersonationGuard,
ScopeGuard
)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getWatchlistItems( public async getWatchlistItems(
@Impersonation() { userId }: ImpersonationContext @Impersonation() { userId }: ImpersonationContext

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

@ -3,8 +3,6 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator';
import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.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 { ScopeGuard } from '@ghostfolio/api/guards/scope.guard';
import { import {
hasNotDefinedValuesInObject, hasNotDefinedValuesInObject,
nullifyValuesInObject nullifyValuesInObject
@ -73,12 +71,6 @@ export class PortfolioController {
@Get('details') @Get('details')
@RequiresScope(scopes.portfolioRead) @RequiresScope(scopes.portfolioRead)
@UseGuards(
AuthGuard('jwt'),
HasPermissionGuard,
ImpersonationGuard,
ScopeGuard
)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@ -320,12 +312,6 @@ export class PortfolioController {
@Get('dividends') @Get('dividends')
@RequiresScope(scopes.portfolioRead) @RequiresScope(scopes.portfolioRead)
@UseGuards(
AuthGuard('jwt'),
HasPermissionGuard,
ImpersonationGuard,
ScopeGuard
)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getDividends( public async getDividends(
@Impersonation() @Impersonation()
@ -402,12 +388,6 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@RequiresScope(scopes.portfolioRead) @RequiresScope(scopes.portfolioRead)
@UseGuards(
AuthGuard('jwt'),
HasPermissionGuard,
ImpersonationGuard,
ScopeGuard
)
public async getHolding( public async getHolding(
@Impersonation() { userId }: ImpersonationContext, @Impersonation() { userId }: ImpersonationContext,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@ -431,12 +411,6 @@ export class PortfolioController {
@Get('holdings') @Get('holdings')
@RequiresScope(scopes.portfolioRead) @RequiresScope(scopes.portfolioRead)
@UseGuards(
AuthGuard('jwt'),
HasPermissionGuard,
ImpersonationGuard,
ScopeGuard
)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@ -475,12 +449,6 @@ export class PortfolioController {
@Get('investments') @Get('investments')
@RequiresScope(scopes.portfolioRead) @RequiresScope(scopes.portfolioRead)
@UseGuards(
AuthGuard('jwt'),
HasPermissionGuard,
ImpersonationGuard,
ScopeGuard
)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getInvestments( public async getInvestments(
@Impersonation() @Impersonation()
@ -553,12 +521,6 @@ export class PortfolioController {
@Get('performance') @Get('performance')
@RequiresScope(scopes.portfolioRead) @RequiresScope(scopes.portfolioRead)
@UseGuards(
AuthGuard('jwt'),
HasPermissionGuard,
ImpersonationGuard,
ScopeGuard
)
@UseInterceptors(PerformanceLoggingInterceptor) @UseInterceptors(PerformanceLoggingInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@ -670,12 +632,6 @@ export class PortfolioController {
@Get('report') @Get('report')
@RequiresScope(scopes.portfolioRead) @RequiresScope(scopes.portfolioRead)
@UseGuards(
AuthGuard('jwt'),
HasPermissionGuard,
ImpersonationGuard,
ScopeGuard
)
public async getReport( public async getReport(
@Impersonation() @Impersonation()
{ scopes: impersonationScopes, userId }: ImpersonationContext { scopes: impersonationScopes, userId }: ImpersonationContext

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

@ -245,7 +245,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.service.ts

@ -287,7 +287,6 @@ export class UserService {
activities: true activities: true
} }
}, },
accessesGet: true,
accounts: { accounts: {
include: { platform: true } include: { platform: true }
}, },
@ -304,7 +303,6 @@ export class UserService {
const { const {
_count, _count,
accessesGet,
accessToken, accessToken,
accounts, accounts,
analytics, analytics,
@ -322,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,

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

@ -1,12 +1,26 @@
import { SetMetadata } from '@nestjs/common'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard';
import { ScopeGuard } from '@ghostfolio/api/guards/scope.guard';
import { Scope } from '@ghostfolio/common/scopes';
import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
export const REQUIRES_SCOPE_KEY = 'requires_scope'; export const REQUIRES_SCOPE_KEY = 'requires_scope';
/** /**
* Marks a controller or a route which requires the given scopes of the * Marks a route which requires the given scopes and applies the guards which
* impersonation context, which requires the ImpersonationGuard and the * resolve the impersonation context and evaluate it, hence the ScopeGuard
* ScopeGuard to be applied to the route * cannot be applied without the ImpersonationGuard preceding it
*/ */
export function RequiresScope(...requiredScopes: string[]) { export function RequiresScope(...requiredScopes: Scope[]) {
return SetMetadata(REQUIRES_SCOPE_KEY, requiredScopes); return applyDecorators(
SetMetadata(REQUIRES_SCOPE_KEY, requiredScopes),
UseGuards(
AuthGuard('jwt'),
HasPermissionGuard,
ImpersonationGuard,
ScopeGuard
)
);
} }

6
apps/api/src/guards/scope.guard.ts

@ -1,5 +1,5 @@
import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator';
import { hasScope } from '@ghostfolio/common/scopes'; import { hasScope, Scope } from '@ghostfolio/common/scopes';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -14,14 +14,14 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
/** /**
* Denies a request whose impersonation context does not cover the scopes * Denies a request whose impersonation context does not cover the scopes
* required by the route. It has to be applied after the ImpersonationGuard, * required by the route. It has to be applied after the ImpersonationGuard,
* which resolves the context. * which resolves the context, hence the RequiresScope decorator applies both.
*/ */
@Injectable() @Injectable()
export class ScopeGuard implements CanActivate { export class ScopeGuard implements CanActivate {
public constructor(private readonly reflector: Reflector) {} public constructor(private readonly reflector: Reflector) {}
public canActivate(context: ExecutionContext): boolean { public canActivate(context: ExecutionContext): boolean {
const requiredScopes = this.reflector.getAllAndOverride<string[]>( const requiredScopes = this.reflector.getAllAndOverride<Scope[]>(
REQUIRES_SCOPE_KEY, REQUIRES_SCOPE_KEY,
[context.getHandler(), context.getClass()] [context.getHandler(), context.getClass()]
); );

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

@ -28,8 +28,9 @@ export class RedactValuesInResponseInterceptor<T> implements NestInterceptor<
.switchToHttp() .switchToHttp()
.getRequest<RequestWithUser>(); .getRequest<RequestWithUser>();
// A missing impersonation context originates from a public request, // A missing impersonation context originates from a public request or
// which must not expose any monetary value // from a route without the ImpersonationGuard, hence the monetary
// values are redacted to never expose them unintentionally
if ( if (
!hasScope(impersonation?.scopes, scopes.portfolioReadValues) || !hasScope(impersonation?.scopes, scopes.portfolioReadValues) ||
isRestrictedView(user) isRestrictedView(user)

15
apps/api/src/services/impersonation/impersonation.service.ts

@ -59,21 +59,6 @@ export class ImpersonationService {
}; };
} }
public async validateImpersonationId({
impersonationId,
user
}: {
impersonationId?: string;
user?: UserWithSettings;
}) {
const { userId } = await this.validateImpersonation({
impersonationId,
user
});
return userId;
}
private async validateImpersonation({ private async validateImpersonation({
impersonationId, impersonationId,
user user

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

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

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

@ -229,7 +229,6 @@ export class GfUserAccountAccessComponent implements OnInit {
grantee: access.grantee === 'Public' ? undefined : access.grantee, grantee: access.grantee === 'Public' ? undefined : access.grantee,
id: access.id, id: access.id,
permissions: access.permissions, permissions: access.permissions,
scopes: access.scopes,
settings: access.settings, settings: access.settings,
type: access.type type: access.type
} }

14
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,
@ -85,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[];
@ -123,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);
@ -147,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) => {

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',
@ -197,24 +197,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;
} }

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

@ -84,22 +84,29 @@ describe('Scopes', () => {
}); });
describe('Get scopes of own access', () => { 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', () => { it('Covers every scope', () => {
expect(getScopesOfOwnAccess()).toEqual(Object.values(scopes)); expect(getScopesOfOwnAccess()).toEqual([
scopes.accountRead,
scopes.activityRead,
scopes.portfolioRead,
scopes.portfolioReadValues,
scopes.watchlistRead
]);
}); });
}); });
describe('Get scopes of unrestricted impersonation', () => { describe('Get scopes of unrestricted impersonation', () => {
it('Excludes the monetary values', () => { // A new scope has to be added here deliberately to confirm that it is
expect(getScopesOfUnrestrictedImpersonation()).not.toContain( // granted to an administrator impersonating an arbitrary user
scopes.portfolioReadValues it('Covers every scope but the monetary values', () => {
); expect(getScopesOfUnrestrictedImpersonation()).toEqual([
}); scopes.accountRead,
scopes.activityRead,
it('Allows reading the portfolio', () => { scopes.portfolioRead,
expect(getScopesOfUnrestrictedImpersonation()).toContain( scopes.watchlistRead
scopes.portfolioRead ]);
);
}); });
}); });

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

@ -14,34 +14,30 @@ export const scopes = {
watchlistRead: 'watchlist:read' watchlistRead: 'watchlist:read'
} as const; } as const;
const SCOPES_OF_PUBLIC_ACCESS: string[] = [ export type Scope = (typeof scopes)[keyof typeof scopes];
const SCOPES_OF_PUBLIC_ACCESS: Scope[] = [
scopes.activityRead, scopes.activityRead,
scopes.portfolioRead scopes.portfolioRead
]; ];
const SCOPES_OF_READ_RESTRICTED_ACCESS = [ const SCOPES_OF_READ_ACCESS = Object.values(scopes);
scopes.accountRead,
scopes.activityRead,
scopes.portfolioRead,
scopes.watchlistRead
];
const SCOPES_OF_READ_ACCESS = [ const SCOPES_OF_READ_RESTRICTED_ACCESS = SCOPES_OF_READ_ACCESS.filter(
...SCOPES_OF_READ_RESTRICTED_ACCESS, (scope) => {
scopes.portfolioReadValues return scope !== scopes.portfolioReadValues;
]; }
);
export function getScopesOfAccess({ export function getScopesOfAccess({
granteeUserId, granteeUserId,
permissions, permissions,
scopes scopes: scopesOfAccess
}: { }: {
granteeUserId?: string | null; granteeUserId?: string | null;
permissions?: AccessPermission[]; permissions?: AccessPermission[];
scopes?: string[]; scopes?: string[];
}): string[] { }): string[] {
let scopesOfAccess = scopes;
if (!scopesOfAccess?.length) { if (!scopesOfAccess?.length) {
// TODO: Remove the derivation from the permissions once they have been // TODO: Remove the derivation from the permissions once they have been
// dropped from the access // dropped from the access
@ -51,13 +47,13 @@ export function getScopesOfAccess({
} }
if (granteeUserId) { if (granteeUserId) {
return scopesOfAccess; return [...scopesOfAccess];
} }
// An access which has not been granted to a user is public, hence it is // An access which has not been granted to a user is public, hence it is
// narrowed to the scopes exposed by the public endpoints // narrowed to the scopes exposed by the public endpoints
return scopesOfAccess.filter((scope) => { return SCOPES_OF_PUBLIC_ACCESS.filter((scope) => {
return SCOPES_OF_PUBLIC_ACCESS.includes(scope); return scopesOfAccess.includes(scope);
}); });
} }
@ -74,9 +70,9 @@ export function getScopesOfOwnAccess(): string[] {
* the monetary values * the monetary values
*/ */
export function getScopesOfUnrestrictedImpersonation(): string[] { export function getScopesOfUnrestrictedImpersonation(): string[] {
return SCOPES_OF_READ_RESTRICTED_ACCESS; return [...SCOPES_OF_READ_RESTRICTED_ACCESS];
} }
export function hasScope(aScopes: string[] = [], aScope: string) { export function hasScope(aScopes: string[] = [], aScope: Scope) {
return aScopes.includes(aScope); return aScopes.includes(aScope);
} }

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;

Loading…
Cancel
Save