diff --git a/packages/authorization/src/__tests__/acceptance/authorization-casbin.acceptance.ts b/packages/authorization/src/__tests__/acceptance/authorization-casbin.acceptance.ts index dba3eae26301..8acc790d4ffb 100644 --- a/packages/authorization/src/__tests__/acceptance/authorization-casbin.acceptance.ts +++ b/packages/authorization/src/__tests__/acceptance/authorization-casbin.acceptance.ts @@ -17,6 +17,7 @@ import { authorize, Authorizer, } from '../..'; +import {AuthorizationTags} from '../../keys'; describe('Authorization', () => { let app: Application; @@ -24,7 +25,7 @@ describe('Authorization', () => { let reqCtx: Context; let events: string[]; - before(givenApplication); + before(givenApplicationAndAuthorizer); beforeEach(givenRequestContext); it('allows placeOrder for everyone', async () => { @@ -100,14 +101,14 @@ describe('Authorization', () => { } } - function givenApplication() { + function givenApplicationAndAuthorizer() { app = new Application(); app.component(AuthorizationComponent); app.bind('casbin.enforcer').toDynamicValue(createEnforcer); app .bind('authorizationProviders.casbin-provider') .toProvider(CasbinAuthorizationProvider) - .tag('authorizationProvider'); + .tag(AuthorizationTags.AUTHORIZER); } function givenRequestContext(user = {name: 'alice'}) { @@ -127,22 +128,17 @@ describe('Authorization', () => { * @returns authenticateFn */ value(): Authorizer { - return async ( - authzCtx: AuthorizationContext, - metadata: AuthorizationMetadata, - ) => { - return this.authorize(authzCtx, metadata); - }; + return this.authorize.bind(this); } async authorize( - authzCtx: AuthorizationContext, + authorizationCtx: AuthorizationContext, metadata: AuthorizationMetadata, ) { - events.push(authzCtx.resource); + events.push(authorizationCtx.resource); const request: AuthorizationRequest = { - subject: authzCtx.principals[0].name, - object: metadata.resource || authzCtx.resource, + subject: authorizationCtx.principals[0].name, + object: metadata.resource || authorizationCtx.resource, action: (metadata.scopes && metadata.scopes[0]) || 'execute', }; const allow = await this.enforcer.enforce( diff --git a/packages/authorization/src/__tests__/acceptance/authorization.acceptance.ts b/packages/authorization/src/__tests__/acceptance/authorization.acceptance.ts index ca2582646791..144470748cf8 100644 --- a/packages/authorization/src/__tests__/acceptance/authorization.acceptance.ts +++ b/packages/authorization/src/__tests__/acceptance/authorization.acceptance.ts @@ -15,6 +15,7 @@ import { Authorizer, EVERYONE, } from '../..'; +import {AuthorizationTags} from '../../keys'; describe('Authorization', () => { let app: Application; @@ -22,7 +23,7 @@ describe('Authorization', () => { let reqCtx: Context; let events: string[]; - before(givenApplication); + before(givenApplicationAndAuthorizer); beforeEach(givenRequestContext); it('allows placeOrder for everyone', async () => { @@ -73,13 +74,13 @@ describe('Authorization', () => { } } - function givenApplication() { + function givenApplicationAndAuthorizer() { app = new Application(); app.component(AuthorizationComponent); app .bind('authorizationProviders.my-provider') .toProvider(MyAuthorizationProvider) - .tag('authorizationProvider'); + .tag(AuthorizationTags.AUTHORIZER); } function givenRequestContext() { @@ -93,21 +94,17 @@ describe('Authorization', () => { * Provider of a function which authenticates */ class MyAuthorizationProvider implements Provider { - constructor() {} - /** * @returns authenticateFn */ value(): Authorizer { - return async ( - context: AuthorizationContext, - metadata: AuthorizationMetadata, - ) => { - return this.authorize(context, metadata); - }; + return this.authorize.bind(this); } - authorize(context: AuthorizationContext, metadata: AuthorizationMetadata) { + async authorize( + context: AuthorizationContext, + metadata: AuthorizationMetadata, + ) { events.push(context.resource); if ( context.resource === 'OrderController.prototype.cancelOrder' && diff --git a/packages/authorization/src/authorize-interceptor.ts b/packages/authorization/src/authorize-interceptor.ts index 7f52ec189014..18c8bee43dc7 100644 --- a/packages/authorization/src/authorize-interceptor.ts +++ b/packages/authorization/src/authorize-interceptor.ts @@ -6,13 +6,18 @@ import { asGlobalInterceptor, bind, + BindingAddress, + Context, filterByTag, inject, Interceptor, + InvocationContext, + Next, Provider, } from '@loopback/context'; import * as debugFactory from 'debug'; import {getAuthorizeMetadata} from './decorators/authorize'; +import {AuthorizationTags} from './keys'; import {AuthorizationContext, AuthorizationDecision, Authorizer} from './types'; const debug = debugFactory('loopback:authorization:interceptor'); @@ -20,41 +25,66 @@ const debug = debugFactory('loopback:authorization:interceptor'); @bind(asGlobalInterceptor('authorization')) export class AuthorizationInterceptor implements Provider { constructor( - @inject(filterByTag('authorizationProvider')) - private authorizationFunctions: Authorizer[], + @inject(filterByTag(AuthorizationTags.AUTHORIZER)) + private authorizers: Authorizer[], ) {} value(): Interceptor { - return async (invocationCtx, next) => { - const description = debug.enabled ? invocationCtx.description : ''; - const metadata = getAuthorizeMetadata( - invocationCtx.target, - invocationCtx.methodName, - ); - if (!metadata) { - debug('No authorization metadata is found %s', description); - return await next(); - } - debug('Authorization metadata for %s', description, metadata); - const user = await invocationCtx.get<{name: string}>('current.user', { - optional: true, - }); - debug('Current user', user); - const authCtx: AuthorizationContext = { - principals: user ? [{name: user.name, type: 'USER'}] : [], - roles: [], - scopes: [], - resource: invocationCtx.targetName, - invocationContext: invocationCtx, - }; - debug('Security context for %s', description, authCtx); - for (const fn of this.authorizationFunctions) { - const decision = await fn(authCtx, metadata); - if (decision === AuthorizationDecision.DENY) { - throw new Error('Access denied'); - } - } + return this.intercept.bind(this); + } + + async intercept(invocationCtx: InvocationContext, next: Next) { + const description = debug.enabled ? invocationCtx.description : ''; + const metadata = getAuthorizeMetadata( + invocationCtx.target, + invocationCtx.methodName, + ); + if (!metadata) { + debug('No authorization metadata is found %s', description); return await next(); + } + debug('Authorization metadata for %s', description, metadata); + const user = await invocationCtx.get<{name: string}>('current.user', { + optional: true, + }); + debug('Current user', user); + const authorizationCtx: AuthorizationContext = { + principals: user ? [{name: user.name, type: 'USER'}] : [], + roles: [], + scopes: [], + resource: invocationCtx.targetName, + invocationContext: invocationCtx, }; + debug('Security context for %s', description, authorizationCtx); + let authorizers = await loadAuthorizers( + invocationCtx, + metadata.voters || [], + ); + authorizers = authorizers.concat(this.authorizers); + for (const fn of authorizers) { + const decision = await fn(authorizationCtx, metadata); + if (decision === AuthorizationDecision.DENY) { + throw new Error('Access denied'); + } + } + return await next(); + } +} + +async function loadAuthorizers( + ctx: Context, + authorizers: (Authorizer | BindingAddress)[], +) { + const authorizerFunctions: Authorizer[] = []; + const bindings = ctx.findByTag(AuthorizationTags.AUTHORIZER); + authorizers = authorizers.concat(bindings.map(b => b.key)); + for (const keyOrFn of authorizers) { + if (typeof keyOrFn === 'function') { + authorizerFunctions.push(keyOrFn); + } else { + const fn = await ctx.get(keyOrFn); + authorizerFunctions.push(fn); + } } + return authorizerFunctions; } diff --git a/packages/authorization/src/decorators/authorize.ts b/packages/authorization/src/decorators/authorize.ts index cba0866fa99b..6ea97a7e6355 100644 --- a/packages/authorization/src/decorators/authorize.ts +++ b/packages/authorization/src/decorators/authorize.ts @@ -15,9 +15,9 @@ import { import { AUTHENTICATED, AuthorizationMetadata, + Authorizer, EVERYONE, UNAUTHENTICATED, - Voter, } from '../types'; export const AUTHORIZATION_METHOD_KEY = MetadataAccessor.create< @@ -147,8 +147,9 @@ export namespace authorize { * Shortcut to configure voters * @param voters */ - export const vote = (...voters: (Voter | BindingAddress)[]) => - authorize({voters}); + export const vote = ( + ...voters: (Authorizer | BindingAddress)[] + ) => authorize({voters}); /** * Allows all diff --git a/packages/authorization/src/keys.ts b/packages/authorization/src/keys.ts index 01b950d87001..3de0b5b3a8a6 100644 --- a/packages/authorization/src/keys.ts +++ b/packages/authorization/src/keys.ts @@ -4,8 +4,18 @@ // License text available at https://opensource.org/licenses/MIT /** - * Binding keys used by this component. + * Binding keys used by authorization component. */ export namespace AuthorizationBindings { export const METADATA = 'authorization.operationMetadata'; } + +/** + * Binding tags used by authorization component + */ +export namespace AuthorizationTags { + /** + * A tag for authorizers + */ + export const AUTHORIZER = 'authorizer'; +} diff --git a/packages/authorization/src/types.ts b/packages/authorization/src/types.ts index a1c74b15dd61..009edc13f35b 100644 --- a/packages/authorization/src/types.ts +++ b/packages/authorization/src/types.ts @@ -14,23 +14,25 @@ export const UNAUTHENTICATED = '$unauthenticated'; export const ANONYMOUS = '$anonymous'; /** - * Voting decision for the authorization decision + * Decisions for authorization */ -export enum VotingDecision { +export enum AuthorizationDecision { + /** + * Access allowed + */ ALLOW = 'Allow', + /** + * Access denied + */ DENY = 'Deny', + /** + * No decision + */ ABSTAIN = 'Abstain', } /** - * A voter function - */ -export type Voter = ( - authorizationCtx: AuthorizationContext, -) => Promise; - -/** - * Authorization metadata stored via Reflection API + * Authorization metadata supplied via `@authorize` decorator */ export interface AuthorizationMetadata { /** @@ -41,10 +43,11 @@ export interface AuthorizationMetadata { * Roles that are denied access */ deniedRoles?: string[]; + /** * Voters that help make the authorization decision */ - voters?: (Voter | BindingAddress)[]; + voters?: (Authorizer | BindingAddress)[]; /** * Name of the resource, default to the method name @@ -56,24 +59,6 @@ export interface AuthorizationMetadata { scopes?: string[]; } -/** - * Decisions for authorization - */ -export enum AuthorizationDecision { - /** - * Access allowed - */ - ALLOW = 'Allow', - /** - * Access denied - */ - DENY = 'Deny', - /** - * No decision - */ - ABSTAIN = 'Abstain', -} - /** * Represent a user, an application, or a device */ @@ -87,7 +72,7 @@ export interface Principal { */ type: string; - // organization + // organization/realm/domain/tenant // team/group // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -212,6 +197,10 @@ export type Authorizer = * Inspired by https://github.com/casbin/node-casbin */ export interface AuthorizationRequest { + /** + * The domain (realm/tenant) + */ + domain?: string; /** * The requestor that wants to access a resource. */