mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
34 changed files with 520 additions and 179 deletions
@ -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 |
|||
]); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,26 @@ |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard'; |
|||
import { ScopeGuard } from '@ghostfolio/api/guards/scope.guard'; |
|||
import { Scope } from '@ghostfolio/common/scopes'; |
|||
|
|||
import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
|
|||
export const REQUIRES_SCOPE_KEY = 'requires_scope'; |
|||
|
|||
/** |
|||
* Marks a route which requires the given scopes and applies the guards which |
|||
* resolve the impersonation context and evaluate it, hence the ScopeGuard |
|||
* cannot be applied without the ImpersonationGuard preceding it |
|||
*/ |
|||
export function RequiresScope(...requiredScopes: Scope[]) { |
|||
return applyDecorators( |
|||
SetMetadata(REQUIRES_SCOPE_KEY, requiredScopes), |
|||
UseGuards( |
|||
AuthGuard('jwt'), |
|||
HasPermissionGuard, |
|||
ImpersonationGuard, |
|||
ScopeGuard |
|||
) |
|||
); |
|||
} |
|||
@ -0,0 +1,50 @@ |
|||
import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator'; |
|||
import { hasScope, Scope } from '@ghostfolio/common/scopes'; |
|||
import type { RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
CanActivate, |
|||
ExecutionContext, |
|||
HttpException, |
|||
Injectable |
|||
} from '@nestjs/common'; |
|||
import { Reflector } from '@nestjs/core'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
/** |
|||
* Denies a request whose impersonation context does not cover the scopes |
|||
* required by the route. It has to be applied after the ImpersonationGuard, |
|||
* which resolves the context, hence the RequiresScope decorator applies both. |
|||
*/ |
|||
@Injectable() |
|||
export class ScopeGuard implements CanActivate { |
|||
public constructor(private readonly reflector: Reflector) {} |
|||
|
|||
public canActivate(context: ExecutionContext): boolean { |
|||
const requiredScopes = this.reflector.getAllAndOverride<Scope[]>( |
|||
REQUIRES_SCOPE_KEY, |
|||
[context.getHandler(), context.getClass()] |
|||
); |
|||
|
|||
if (!requiredScopes?.length) { |
|||
return true; |
|||
} |
|||
|
|||
const { impersonation } = context |
|||
.switchToHttp() |
|||
.getRequest<RequestWithUser>(); |
|||
|
|||
const hasRequiredScopes = requiredScopes.every((scope) => { |
|||
return hasScope(impersonation?.scopes, scope); |
|||
}); |
|||
|
|||
if (!hasRequiredScopes) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
return true; |
|||
} |
|||
} |
|||
@ -1,5 +1,6 @@ |
|||
import { Access } from '@ghostfolio/common/interfaces'; |
|||
|
|||
export interface CreateOrUpdateAccessDialogParams { |
|||
access?: Access; |
|||
// TODO: Include the scopes once the dialog allows to configure them
|
|||
access?: Omit<Access, 'scopes'>; |
|||
} |
|||
|
|||
@ -0,0 +1,130 @@ |
|||
import { |
|||
getScopesOfAccess, |
|||
getScopesOfOwnAccess, |
|||
getScopesOfUnrestrictedImpersonation, |
|||
hasScope, |
|||
scopes |
|||
} from '@ghostfolio/common/scopes'; |
|||
|
|||
describe('Scopes', () => { |
|||
describe('Get scopes of access', () => { |
|||
it('Scopes take precedence over the permissions', () => { |
|||
expect( |
|||
getScopesOfAccess({ |
|||
granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', |
|||
permissions: ['READ'], |
|||
scopes: [scopes.portfolioRead] |
|||
}) |
|||
).toEqual([scopes.portfolioRead]); |
|||
}); |
|||
|
|||
it('Derive from the permission to read', () => { |
|||
// An access created before the scopes have been introduced has no scopes
|
|||
expect( |
|||
getScopesOfAccess({ |
|||
granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', |
|||
permissions: ['READ'], |
|||
scopes: [] |
|||
}) |
|||
).toContain(scopes.portfolioReadValues); |
|||
}); |
|||
|
|||
it('Derive from the permission to read restricted', () => { |
|||
expect( |
|||
getScopesOfAccess({ |
|||
granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', |
|||
permissions: ['READ_RESTRICTED'], |
|||
scopes: [] |
|||
}) |
|||
).not.toContain(scopes.portfolioReadValues); |
|||
}); |
|||
|
|||
it('Without permissions and scopes', () => { |
|||
expect( |
|||
getScopesOfAccess({ |
|||
granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d' |
|||
}) |
|||
).not.toContain(scopes.portfolioReadValues); |
|||
}); |
|||
}); |
|||
|
|||
describe('Get scopes of public access', () => { |
|||
it('Allows reading the portfolio', () => { |
|||
expect(getScopesOfAccess({ permissions: ['READ_RESTRICTED'] })).toContain( |
|||
scopes.portfolioRead |
|||
); |
|||
}); |
|||
|
|||
it('Excludes the accounts and the watchlist', () => { |
|||
const scopesOfAccess = getScopesOfAccess({ |
|||
permissions: ['READ_RESTRICTED'] |
|||
}); |
|||
|
|||
expect(scopesOfAccess).not.toContain(scopes.accountRead); |
|||
expect(scopesOfAccess).not.toContain(scopes.watchlistRead); |
|||
}); |
|||
|
|||
it('Cannot be widened by the scopes', () => { |
|||
expect( |
|||
getScopesOfAccess({ |
|||
scopes: [ |
|||
scopes.portfolioRead, |
|||
scopes.portfolioReadValues, |
|||
scopes.watchlistRead |
|||
] |
|||
}) |
|||
).toEqual([scopes.portfolioRead]); |
|||
}); |
|||
|
|||
it('Cannot be widened by the permission to read', () => { |
|||
expect(getScopesOfAccess({ permissions: ['READ'] })).not.toContain( |
|||
scopes.portfolioReadValues |
|||
); |
|||
}); |
|||
}); |
|||
|
|||
describe('Get scopes of own access', () => { |
|||
// A new scope has to be added here deliberately to confirm that it is
|
|||
// granted to the owner of the data
|
|||
it('Covers every scope', () => { |
|||
expect(getScopesOfOwnAccess()).toEqual([ |
|||
scopes.accountRead, |
|||
scopes.activityRead, |
|||
scopes.portfolioRead, |
|||
scopes.portfolioReadValues, |
|||
scopes.watchlistRead |
|||
]); |
|||
}); |
|||
}); |
|||
|
|||
describe('Get scopes of unrestricted impersonation', () => { |
|||
// A new scope has to be added here deliberately to confirm that it is
|
|||
// granted to an administrator impersonating an arbitrary user
|
|||
it('Covers every scope but the monetary values', () => { |
|||
expect(getScopesOfUnrestrictedImpersonation()).toEqual([ |
|||
scopes.accountRead, |
|||
scopes.activityRead, |
|||
scopes.portfolioRead, |
|||
scopes.watchlistRead |
|||
]); |
|||
}); |
|||
}); |
|||
|
|||
describe('Has scope', () => { |
|||
it('Present scope', () => { |
|||
expect(hasScope([scopes.portfolioRead], scopes.portfolioRead)).toEqual( |
|||
true |
|||
); |
|||
}); |
|||
|
|||
it('Absent scope', () => { |
|||
expect( |
|||
hasScope([scopes.portfolioRead], scopes.portfolioReadValues) |
|||
).toEqual(false); |
|||
}); |
|||
|
|||
it('Without scopes', () => { |
|||
expect(hasScope(undefined, scopes.portfolioRead)).toEqual(false); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,78 @@ |
|||
import { AccessPermission } from '@prisma/client'; |
|||
|
|||
/** |
|||
* Scopes describe what a grantee may do on behalf of the granting user. They |
|||
* are a separate axis from the permissions, which describe the capabilities of |
|||
* a role. Both are evaluated, hence a delegation can only narrow the access of |
|||
* the authenticated user and never widen it. |
|||
*/ |
|||
export const scopes = { |
|||
accountRead: 'account:read', |
|||
activityRead: 'activity:read', |
|||
portfolioRead: 'portfolio:read', |
|||
portfolioReadValues: 'portfolio:read:values', |
|||
watchlistRead: 'watchlist:read' |
|||
} as const; |
|||
|
|||
export type Scope = (typeof scopes)[keyof typeof scopes]; |
|||
|
|||
const SCOPES_OF_PUBLIC_ACCESS: Scope[] = [ |
|||
scopes.activityRead, |
|||
scopes.portfolioRead |
|||
]; |
|||
|
|||
const SCOPES_OF_READ_ACCESS = Object.values(scopes); |
|||
|
|||
const SCOPES_OF_READ_RESTRICTED_ACCESS = SCOPES_OF_READ_ACCESS.filter( |
|||
(scope) => { |
|||
return scope !== scopes.portfolioReadValues; |
|||
} |
|||
); |
|||
|
|||
export function getScopesOfAccess({ |
|||
granteeUserId, |
|||
permissions, |
|||
scopes: scopesOfAccess |
|||
}: { |
|||
granteeUserId?: string | null; |
|||
permissions?: AccessPermission[]; |
|||
scopes?: string[]; |
|||
}): string[] { |
|||
if (!scopesOfAccess?.length) { |
|||
// TODO: Remove the derivation from the permissions once they have been
|
|||
// dropped from the access
|
|||
scopesOfAccess = permissions?.includes('READ') |
|||
? SCOPES_OF_READ_ACCESS |
|||
: SCOPES_OF_READ_RESTRICTED_ACCESS; |
|||
} |
|||
|
|||
if (granteeUserId) { |
|||
return [...scopesOfAccess]; |
|||
} |
|||
|
|||
// An access which has not been granted to a user is public, hence it is
|
|||
// narrowed to the scopes exposed by the public endpoints
|
|||
return SCOPES_OF_PUBLIC_ACCESS.filter((scope) => { |
|||
return scopesOfAccess.includes(scope); |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* Scopes of a user acting on their own data, which is unrestricted. The |
|||
* permissions of the role are evaluated separately. |
|||
*/ |
|||
export function getScopesOfOwnAccess(): string[] { |
|||
return Object.values(scopes); |
|||
} |
|||
|
|||
/** |
|||
* Scopes of an administrator impersonating an arbitrary user, which excludes |
|||
* the monetary values |
|||
*/ |
|||
export function getScopesOfUnrestrictedImpersonation(): string[] { |
|||
return [...SCOPES_OF_READ_RESTRICTED_ACCESS]; |
|||
} |
|||
|
|||
export function hasScope(aScopes: string[] = [], aScope: Scope) { |
|||
return aScopes.includes(aScope); |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
-- AlterTable |
|||
ALTER TABLE "Access" ADD COLUMN "scopes" TEXT[] DEFAULT ARRAY[]::TEXT[]; |
|||
|
|||
-- Derive the scopes from the permissions of the existing accesses |
|||
UPDATE "Access" |
|||
SET "scopes" = CASE |
|||
WHEN "granteeUserId" IS NULL THEN ARRAY[ |
|||
'activity:read', |
|||
'portfolio:read' |
|||
] |
|||
WHEN 'READ' = ANY("permissions") THEN ARRAY[ |
|||
'account:read', |
|||
'activity:read', |
|||
'portfolio:read', |
|||
'portfolio:read:values', |
|||
'watchlist:read' |
|||
] |
|||
ELSE ARRAY[ |
|||
'account:read', |
|||
'activity:read', |
|||
'portfolio:read', |
|||
'watchlist:read' |
|||
] |
|||
END; |
|||
Loading…
Reference in new issue