mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
27 changed files with 1028 additions and 173 deletions
@ -1,28 +1,32 @@ |
|||
import { getScopesOfOwnAccess } from '@ghostfolio/common/scopes'; |
|||
import type { |
|||
ImpersonationContext, |
|||
RequestWithUser |
|||
} from '@ghostfolio/common/types'; |
|||
|
|||
import { createParamDecorator, ExecutionContext } from '@nestjs/common'; |
|||
import { |
|||
createParamDecorator, |
|||
ExecutionContext, |
|||
InternalServerErrorException |
|||
} from '@nestjs/common'; |
|||
|
|||
/** |
|||
* Provides the impersonation context of the request, which requires the |
|||
* ImpersonationGuard to be applied to the route |
|||
* Provides the impersonation context of the request, which the |
|||
* ImpersonationGuard resolves. A missing context is a mistake in the setup of |
|||
* the route and fails loudly, because a fallback to the own access would let a |
|||
* handler change data without any scope being evaluated. |
|||
*/ |
|||
export const Impersonation = createParamDecorator( |
|||
(_data: unknown, context: ExecutionContext): ImpersonationContext => { |
|||
const { impersonation, user } = context |
|||
const { impersonation } = context |
|||
.switchToHttp() |
|||
.getRequest<RequestWithUser>(); |
|||
|
|||
return ( |
|||
impersonation ?? { |
|||
isActive: false, |
|||
scopes: getScopesOfOwnAccess(), |
|||
userId: user?.id, |
|||
userSettings: user?.settings?.settings ?? {} |
|||
} |
|||
if (!impersonation) { |
|||
throw new InternalServerErrorException( |
|||
'The impersonation context is missing. Apply the RequiresScope decorator or the ImpersonationGuard to the route.' |
|||
); |
|||
} |
|||
|
|||
return impersonation; |
|||
} |
|||
); |
|||
|
|||
@ -0,0 +1,112 @@ |
|||
import { ALLOW_DURING_IMPERSONATION_KEY } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; |
|||
import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator'; |
|||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; |
|||
import { Scope, scopes } from '@ghostfolio/common/scopes'; |
|||
|
|||
import { HttpException } from '@nestjs/common'; |
|||
import { Reflector } from '@nestjs/core'; |
|||
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host'; |
|||
|
|||
import { ImpersonationWriteGuard } from './impersonation-write.guard'; |
|||
|
|||
describe('Impersonation write guard', () => { |
|||
function createGuard({ |
|||
isAllowedDuringImpersonation, |
|||
requiredScopes |
|||
}: { |
|||
isAllowedDuringImpersonation?: boolean; |
|||
requiredScopes?: Scope[]; |
|||
} = {}) { |
|||
const reflector = { |
|||
getAllAndOverride: (key: string) => { |
|||
if (key === ALLOW_DURING_IMPERSONATION_KEY) { |
|||
return isAllowedDuringImpersonation; |
|||
} |
|||
|
|||
if (key === REQUIRES_SCOPE_KEY) { |
|||
return requiredScopes; |
|||
} |
|||
|
|||
return undefined; |
|||
} |
|||
} as unknown as Reflector; |
|||
|
|||
return new ImpersonationWriteGuard(reflector); |
|||
} |
|||
|
|||
function createExecutionContext({ |
|||
isImpersonating, |
|||
method |
|||
}: { |
|||
isImpersonating: boolean; |
|||
method: string; |
|||
}) { |
|||
return new ExecutionContextHost([ |
|||
{ |
|||
method, |
|||
headers: isImpersonating |
|||
? { |
|||
[HEADER_KEY_IMPERSONATION.toLowerCase()]: |
|||
'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d' |
|||
} |
|||
: {} |
|||
} |
|||
]); |
|||
} |
|||
|
|||
it('Allows a read request during an impersonation', () => { |
|||
expect( |
|||
createGuard().canActivate( |
|||
createExecutionContext({ isImpersonating: true, method: 'GET' }) |
|||
) |
|||
).toEqual(true); |
|||
}); |
|||
|
|||
it('Allows a write request without an impersonation', () => { |
|||
expect( |
|||
createGuard().canActivate( |
|||
createExecutionContext({ isImpersonating: false, method: 'POST' }) |
|||
) |
|||
).toEqual(true); |
|||
}); |
|||
|
|||
it('Blocks a write request of a route without scopes', () => { |
|||
const guard = createGuard(); |
|||
|
|||
expect(() => { |
|||
return guard.canActivate( |
|||
createExecutionContext({ isImpersonating: true, method: 'POST' }) |
|||
); |
|||
}).toThrow(HttpException); |
|||
}); |
|||
|
|||
// A read scope must not open a route which changes data, because the
|
|||
// ScopeGuard grants it to every read access
|
|||
it('Blocks a write request of a route with read scopes only', () => { |
|||
const guard = createGuard({ requiredScopes: [scopes.portfolioRead] }); |
|||
|
|||
expect(() => { |
|||
return guard.canActivate( |
|||
createExecutionContext({ isImpersonating: true, method: 'POST' }) |
|||
); |
|||
}).toThrow(HttpException); |
|||
}); |
|||
|
|||
it('Leaves a write request of a route with a write scope to the ScopeGuard', () => { |
|||
expect( |
|||
createGuard({ |
|||
requiredScopes: [scopes.activityCreate] |
|||
}).canActivate( |
|||
createExecutionContext({ isImpersonating: true, method: 'POST' }) |
|||
) |
|||
).toEqual(true); |
|||
}); |
|||
|
|||
it('Allows a write request of a route which is allowed during an impersonation', () => { |
|||
expect( |
|||
createGuard({ isAllowedDuringImpersonation: true }).canActivate( |
|||
createExecutionContext({ isImpersonating: true, method: 'POST' }) |
|||
) |
|||
).toEqual(true); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,98 @@ |
|||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; |
|||
import { |
|||
HEADER_KEY_IMPERSONATION, |
|||
HTTP_RESPONSE_MESSAGE_IMPERSONATION_UNRESOLVED |
|||
} from '@ghostfolio/common/config'; |
|||
import { getScopesOfOwnAccess, scopes } from '@ghostfolio/common/scopes'; |
|||
import type { ImpersonationContext } from '@ghostfolio/common/types'; |
|||
|
|||
import { HttpException } from '@nestjs/common'; |
|||
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host'; |
|||
import { StatusCodes } from 'http-status-codes'; |
|||
|
|||
import { ImpersonationGuard } from './impersonation.guard'; |
|||
|
|||
describe('Impersonation guard', () => { |
|||
const userId = 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d'; |
|||
|
|||
function createGuard(impersonation: ImpersonationContext) { |
|||
const impersonationService = { |
|||
resolve: async () => { |
|||
return impersonation; |
|||
} |
|||
} as unknown as ImpersonationService; |
|||
|
|||
return new ImpersonationGuard(impersonationService); |
|||
} |
|||
|
|||
function createExecutionContext(impersonationId?: string) { |
|||
const request = { |
|||
headers: impersonationId |
|||
? { [HEADER_KEY_IMPERSONATION.toLowerCase()]: impersonationId } |
|||
: {}, |
|||
user: { id: userId } |
|||
}; |
|||
|
|||
return { context: new ExecutionContextHost([request]), request }; |
|||
} |
|||
|
|||
it('Resolves the own access without an identifier', async () => { |
|||
const { context, request } = createExecutionContext(); |
|||
|
|||
const guard = createGuard({ |
|||
userId, |
|||
isActive: false, |
|||
scopes: getScopesOfOwnAccess(), |
|||
userSettings: {} |
|||
}); |
|||
|
|||
expect(await guard.canActivate(context)).toEqual(true); |
|||
expect(request['impersonation'].isActive).toEqual(false); |
|||
}); |
|||
|
|||
it('Resolves an identifier of a granted access', async () => { |
|||
const { context, request } = createExecutionContext('an-access-id'); |
|||
|
|||
const guard = createGuard({ |
|||
isActive: true, |
|||
scopes: [scopes.portfolioRead], |
|||
userId: 'e2d43f0d-1a41-4b6e-9d5b-6f9a2b7c8d1e', |
|||
userSettings: {} |
|||
}); |
|||
|
|||
expect(await guard.canActivate(context)).toEqual(true); |
|||
expect(request['impersonation'].scopes).toEqual([scopes.portfolioRead]); |
|||
}); |
|||
|
|||
// A revoked or stale identifier must not fall back to the own access,
|
|||
// because the client keeps presenting the data as the impersonated data
|
|||
it('Denies an identifier which cannot be resolved', async () => { |
|||
const { context } = createExecutionContext('a-revoked-access-id'); |
|||
|
|||
const guard = createGuard({ |
|||
userId, |
|||
isActive: false, |
|||
scopes: getScopesOfOwnAccess(), |
|||
userSettings: {} |
|||
}); |
|||
|
|||
await expect(guard.canActivate(context)).rejects.toThrow(HttpException); |
|||
}); |
|||
|
|||
// The client relies on this message to remove the stale identifier
|
|||
it('Denies an identifier which cannot be resolved with a distinct message', async () => { |
|||
const { context } = createExecutionContext('a-revoked-access-id'); |
|||
|
|||
const guard = createGuard({ |
|||
userId, |
|||
isActive: false, |
|||
scopes: getScopesOfOwnAccess(), |
|||
userSettings: {} |
|||
}); |
|||
|
|||
await expect(guard.canActivate(context)).rejects.toMatchObject({ |
|||
response: { message: HTTP_RESPONSE_MESSAGE_IMPERSONATION_UNRESOLVED }, |
|||
status: StatusCodes.FORBIDDEN |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,61 @@ |
|||
import { Scope, scopes } from '@ghostfolio/common/scopes'; |
|||
|
|||
import { HttpException } from '@nestjs/common'; |
|||
import { Reflector } from '@nestjs/core'; |
|||
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host'; |
|||
|
|||
import { ScopeGuard } from './scope.guard'; |
|||
|
|||
describe('Scope guard', () => { |
|||
function createGuard(requiredScopes?: Scope[]) { |
|||
const reflector = { |
|||
getAllAndOverride: () => { |
|||
return requiredScopes; |
|||
} |
|||
} as unknown as Reflector; |
|||
|
|||
return new ScopeGuard(reflector); |
|||
} |
|||
|
|||
function createExecutionContext(scopesOfImpersonation?: string[]) { |
|||
return new ExecutionContextHost([ |
|||
{ |
|||
impersonation: scopesOfImpersonation |
|||
? { scopes: scopesOfImpersonation } |
|||
: undefined |
|||
} |
|||
]); |
|||
} |
|||
|
|||
it('Allows a route without required scopes', () => { |
|||
expect(createGuard().canActivate(createExecutionContext())).toEqual(true); |
|||
}); |
|||
|
|||
it('Allows a context which covers every required scope', () => { |
|||
expect( |
|||
createGuard([scopes.accountRead, scopes.accountUpdate]).canActivate( |
|||
createExecutionContext([ |
|||
scopes.accountRead, |
|||
scopes.accountUpdate, |
|||
scopes.portfolioRead |
|||
]) |
|||
) |
|||
).toEqual(true); |
|||
}); |
|||
|
|||
it('Denies a context which covers one of two required scopes', () => { |
|||
const guard = createGuard([scopes.accountRead, scopes.accountUpdate]); |
|||
|
|||
expect(() => { |
|||
return guard.canActivate(createExecutionContext([scopes.accountRead])); |
|||
}).toThrow(HttpException); |
|||
}); |
|||
|
|||
it('Denies a missing context', () => { |
|||
const guard = createGuard([scopes.accountRead]); |
|||
|
|||
expect(() => { |
|||
return guard.canActivate(createExecutionContext()); |
|||
}).toThrow(HttpException); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,245 @@ |
|||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
|||
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; |
|||
import { SubscriptionType } from '@ghostfolio/common/enums'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
import { |
|||
getScopesOfOwnAccess, |
|||
getScopesOfUnrestrictedImpersonation, |
|||
scopes |
|||
} from '@ghostfolio/common/scopes'; |
|||
import type { UserWithSettings } from '@ghostfolio/common/types'; |
|||
|
|||
import { Access } from '@prisma/client'; |
|||
|
|||
import { ImpersonationService } from './impersonation.service'; |
|||
|
|||
describe('Impersonation service', () => { |
|||
const accessId = 'a5d3f2c1-9b4e-4c8a-8f2d-1e6b7c9a0d3f'; |
|||
const authenticatedUserId = 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d'; |
|||
const impersonatedUserId = 'e2d43f0d-1a41-4b6e-9d5b-6f9a2b7c8d1e'; |
|||
|
|||
const authenticatedUser = { |
|||
id: authenticatedUserId, |
|||
permissions: [], |
|||
settings: { settings: { baseCurrency: 'CHF' } }, |
|||
subscription: { type: SubscriptionType.Premium } |
|||
} as unknown as UserWithSettings; |
|||
|
|||
function createService({ |
|||
access, |
|||
impersonatedUser, |
|||
isSubscriptionEnabled = false |
|||
}: { |
|||
access?: Partial<Access>; |
|||
impersonatedUser?: unknown; |
|||
isSubscriptionEnabled?: boolean; |
|||
} = {}) { |
|||
const getSubscription = jest.fn().mockResolvedValue({ |
|||
type: SubscriptionType.Basic |
|||
}); |
|||
|
|||
const configurationService = { |
|||
get: (key: string) => { |
|||
return key === 'ENABLE_FEATURE_SUBSCRIPTION' |
|||
? isSubscriptionEnabled |
|||
: undefined; |
|||
} |
|||
} as unknown as ConfigurationService; |
|||
|
|||
const prismaService = { |
|||
access: { |
|||
findFirst: async () => { |
|||
return access ?? null; |
|||
} |
|||
}, |
|||
user: { |
|||
findUnique: async () => { |
|||
return impersonatedUser ?? null; |
|||
} |
|||
} |
|||
} as unknown as PrismaService; |
|||
|
|||
const subscriptionService = { |
|||
getSubscription |
|||
} as unknown as SubscriptionService; |
|||
|
|||
return { |
|||
getSubscription, |
|||
service: new ImpersonationService( |
|||
configurationService, |
|||
prismaService, |
|||
subscriptionService |
|||
) |
|||
}; |
|||
} |
|||
|
|||
describe('Without an impersonation', () => { |
|||
it('Resolves the own access of the authenticated user', async () => { |
|||
const { service } = createService(); |
|||
|
|||
expect(await service.resolve({ user: authenticatedUser })).toEqual({ |
|||
authenticatedUserSubscription: authenticatedUser.subscription, |
|||
isActive: false, |
|||
scopes: getScopesOfOwnAccess(), |
|||
userId: authenticatedUserId, |
|||
userSettings: { baseCurrency: 'CHF' }, |
|||
userSubscription: authenticatedUser.subscription |
|||
}); |
|||
}); |
|||
|
|||
it('Resolves a user without settings', async () => { |
|||
const { service } = createService(); |
|||
|
|||
const { userSettings } = await service.resolve({ |
|||
user: { id: authenticatedUserId } as UserWithSettings |
|||
}); |
|||
|
|||
expect(userSettings).toEqual({}); |
|||
}); |
|||
}); |
|||
|
|||
describe('With an impersonation', () => { |
|||
const grantedAccess = { |
|||
granteeUserId: authenticatedUserId, |
|||
id: accessId, |
|||
permissions: ['READ'], |
|||
scopes: [scopes.portfolioRead], |
|||
userId: impersonatedUserId |
|||
} as unknown as Access; |
|||
|
|||
const impersonatedUser = { |
|||
createdAt: new Date('2024-01-01'), |
|||
id: impersonatedUserId, |
|||
settings: { settings: { baseCurrency: 'USD' } }, |
|||
subscriptions: [] |
|||
}; |
|||
|
|||
it('Resolves the scopes of the granted access', async () => { |
|||
const { service } = createService({ |
|||
access: grantedAccess, |
|||
impersonatedUser |
|||
}); |
|||
|
|||
expect( |
|||
await service.resolve({ |
|||
impersonationId: accessId, |
|||
user: authenticatedUser |
|||
}) |
|||
).toEqual({ |
|||
accessId, |
|||
authenticatedUserSubscription: authenticatedUser.subscription, |
|||
isActive: true, |
|||
scopes: [scopes.portfolioRead], |
|||
userId: impersonatedUserId, |
|||
userSettings: { baseCurrency: 'USD' }, |
|||
userSubscription: undefined |
|||
}); |
|||
}); |
|||
|
|||
// The subscription of the authenticated user is required to evaluate the
|
|||
// more restrictive of the two subscriptions
|
|||
it('Keeps the subscription of the authenticated user', async () => { |
|||
const { service } = createService({ |
|||
access: grantedAccess, |
|||
impersonatedUser |
|||
}); |
|||
|
|||
const { authenticatedUserSubscription } = await service.resolve({ |
|||
impersonationId: accessId, |
|||
user: authenticatedUser |
|||
}); |
|||
|
|||
expect(authenticatedUserSubscription).toEqual( |
|||
authenticatedUser.subscription |
|||
); |
|||
}); |
|||
|
|||
it('Falls back to the default currency without settings', async () => { |
|||
const { service } = createService({ |
|||
access: grantedAccess, |
|||
impersonatedUser: { ...impersonatedUser, settings: null } |
|||
}); |
|||
|
|||
const { userSettings } = await service.resolve({ |
|||
impersonationId: accessId, |
|||
user: authenticatedUser |
|||
}); |
|||
|
|||
expect(userSettings).toEqual({ baseCurrency: DEFAULT_CURRENCY }); |
|||
}); |
|||
|
|||
it('Omits the subscription while the feature is disabled', async () => { |
|||
const { getSubscription, service } = createService({ |
|||
access: grantedAccess, |
|||
impersonatedUser |
|||
}); |
|||
|
|||
const { userSubscription } = await service.resolve({ |
|||
impersonationId: accessId, |
|||
user: authenticatedUser |
|||
}); |
|||
|
|||
expect(userSubscription).toBeUndefined(); |
|||
expect(getSubscription).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it('Resolves the subscription while the feature is enabled', async () => { |
|||
const { getSubscription, service } = createService({ |
|||
access: grantedAccess, |
|||
impersonatedUser, |
|||
isSubscriptionEnabled: true |
|||
}); |
|||
|
|||
const { userSubscription } = await service.resolve({ |
|||
impersonationId: accessId, |
|||
user: authenticatedUser |
|||
}); |
|||
|
|||
expect(userSubscription).toEqual({ type: SubscriptionType.Basic }); |
|||
expect(getSubscription).toHaveBeenCalledWith({ |
|||
createdAt: impersonatedUser.createdAt, |
|||
subscriptions: [] |
|||
}); |
|||
}); |
|||
|
|||
// An administrator impersonates by a user id instead of an access id
|
|||
it('Resolves the unrestricted scopes of an administrator', async () => { |
|||
const { service } = createService({ |
|||
impersonatedUser: { id: impersonatedUserId } |
|||
}); |
|||
|
|||
const { isActive, scopes: scopesOfImpersonation } = await service.resolve( |
|||
{ |
|||
impersonationId: impersonatedUserId, |
|||
user: { |
|||
...authenticatedUser, |
|||
permissions: [permissions.impersonateAllUsers] |
|||
} as UserWithSettings |
|||
} |
|||
); |
|||
|
|||
expect(isActive).toEqual(true); |
|||
expect(scopesOfImpersonation).toEqual( |
|||
getScopesOfUnrestrictedImpersonation() |
|||
); |
|||
}); |
|||
}); |
|||
|
|||
// The guard rejects the request in this case, hence the context must not
|
|||
// present the data of the authenticated user as impersonated data
|
|||
describe('With an identifier which cannot be resolved', () => { |
|||
it('Resolves the own access instead', async () => { |
|||
const { service } = createService(); |
|||
|
|||
const { isActive, userId } = await service.resolve({ |
|||
impersonationId: 'a-revoked-access-id', |
|||
user: authenticatedUser |
|||
}); |
|||
|
|||
expect(isActive).toEqual(false); |
|||
expect(userId).toEqual(authenticatedUserId); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -1,14 +1,18 @@ |
|||
import { UserSettings } from '@ghostfolio/common/interfaces'; |
|||
import { UserWithSettings } from '@ghostfolio/common/types'; |
|||
|
|||
/** |
|||
* Describes whose data a request presents. The user id and the settings belong |
|||
* to the impersonated user while an impersonation is active and to the |
|||
* authenticated user otherwise, so a handler can use them unconditionally. |
|||
* Describes whose data a request presents. The user id, the settings and the |
|||
* subscription belong to the impersonated user while an impersonation is |
|||
* active and to the authenticated user otherwise, so a handler can use them |
|||
* unconditionally. |
|||
*/ |
|||
export interface ImpersonationContext { |
|||
accessId?: string; |
|||
authenticatedUserSubscription?: UserWithSettings['subscription']; |
|||
isActive: boolean; |
|||
scopes: string[]; |
|||
userId: string; |
|||
userSettings: UserSettings; |
|||
userSubscription?: UserWithSettings['subscription']; |
|||
} |
|||
|
|||
Loading…
Reference in new issue