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 { |
import type { |
||||
ImpersonationContext, |
ImpersonationContext, |
||||
RequestWithUser |
RequestWithUser |
||||
} from '@ghostfolio/common/types'; |
} 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 |
* Provides the impersonation context of the request, which the |
||||
* ImpersonationGuard to be applied to the route |
* 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( |
export const Impersonation = createParamDecorator( |
||||
(_data: unknown, context: ExecutionContext): ImpersonationContext => { |
(_data: unknown, context: ExecutionContext): ImpersonationContext => { |
||||
const { impersonation, user } = context |
const { impersonation } = context |
||||
.switchToHttp() |
.switchToHttp() |
||||
.getRequest<RequestWithUser>(); |
.getRequest<RequestWithUser>(); |
||||
|
|
||||
return ( |
if (!impersonation) { |
||||
impersonation ?? { |
throw new InternalServerErrorException( |
||||
isActive: false, |
'The impersonation context is missing. Apply the RequiresScope decorator or the ImpersonationGuard to the route.' |
||||
scopes: getScopesOfOwnAccess(), |
|
||||
userId: user?.id, |
|
||||
userSettings: user?.settings?.settings ?? {} |
|
||||
} |
|
||||
); |
); |
||||
} |
} |
||||
|
|
||||
|
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 { UserSettings } from '@ghostfolio/common/interfaces'; |
||||
|
import { UserWithSettings } from '@ghostfolio/common/types'; |
||||
|
|
||||
/** |
/** |
||||
* Describes whose data a request presents. The user id and the settings belong |
* Describes whose data a request presents. The user id, the settings and the |
||||
* to the impersonated user while an impersonation is active and to the |
* subscription belong to the impersonated user while an impersonation is |
||||
* authenticated user otherwise, so a handler can use them unconditionally. |
* active and to the authenticated user otherwise, so a handler can use them |
||||
|
* unconditionally. |
||||
*/ |
*/ |
||||
export interface ImpersonationContext { |
export interface ImpersonationContext { |
||||
accessId?: string; |
accessId?: string; |
||||
|
authenticatedUserSubscription?: UserWithSettings['subscription']; |
||||
isActive: boolean; |
isActive: boolean; |
||||
scopes: string[]; |
scopes: string[]; |
||||
userId: string; |
userId: string; |
||||
userSettings: UserSettings; |
userSettings: UserSettings; |
||||
|
userSubscription?: UserWithSettings['subscription']; |
||||
} |
} |
||||
|
|||||
Loading…
Reference in new issue