Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BC-3990 add authorisation api #5024

Merged
merged 14 commits into from
May 28, 2024
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication';
import { Body, Controller, InternalServerErrorException, Post, UnauthorizedException } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ApiValidationError } from '@shared/common';
import { AuthorizationReferenceUc } from './authorization-reference.uc';
import { AuthorizationBodyParams, AuthorizedReponse } from './dto';

@Authenticate('jwt')
@ApiTags('Authorization')
@Controller('authorization')
export class AuthorizationReferenceController {
constructor(private readonly authorizationReferenceUc: AuthorizationReferenceUc) {}

@ApiOperation({ summary: 'Checks if user is authorized to perform the given operation.' })
@ApiResponse({ status: 200, type: AuthorizedReponse })
@ApiResponse({ status: 400, type: ApiValidationError })
@ApiResponse({ status: 401, type: UnauthorizedException })
@ApiResponse({ status: 500, type: InternalServerErrorException })
@Post('by-reference')
public async authorizeByReference(
@Body() body: AuthorizationBodyParams,
@CurrentUser() user: ICurrentUser
): Promise<AuthorizedReponse> {
const authorizationReponse = await this.authorizationReferenceUc.authorizeByReference(
user.userId,
body.referenceType,
body.referenceId,
body.context
);

return authorizationReponse;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { EntityId } from '@shared/domain/types';
import { Injectable } from '@nestjs/common';
import { AuthorizableReferenceType, AuthorizationContext, AuthorizationReferenceService } from '../domain';
import { AuthorizedReponse } from './dto';
import { AuthorizationReponseMapper } from './mapper';

@Injectable()
export class AuthorizationReferenceUc {
constructor(private readonly authorizationReferenceService: AuthorizationReferenceService) {}

public async authorizeByReference(
userId: EntityId,
authorizableReferenceType: AuthorizableReferenceType,
authorizableReferenceId: EntityId,
context: AuthorizationContext
): Promise<AuthorizedReponse> {
const hasPermission = await this.authorizationReferenceService.hasPermissionByReferences(
userId,
authorizableReferenceType,
authorizableReferenceId,
context
);

const authorizationReponse = AuthorizationReponseMapper.mapToResponse(userId, hasPermission);

return authorizationReponse;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Permission } from '@shared/domain/interface';
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsEnum, IsMongoId, ValidateNested } from 'class-validator';
import { Action, AuthorizableReferenceType, AuthorizationContext } from '../../domain';

class AuthorizationContextParams implements AuthorizationContext {
@IsEnum(Action)
@ApiProperty({
description: 'Define for which action the operation should be performend.',
enum: Action,
example: Action.read,
})
action!: Action;

@IsArray()
@IsEnum(Permission, { each: true })
@ApiProperty({
enum: Permission,
isArray: true,
description: 'Needed user permissions based on user role, that are needed to execute the operation.',
CeEv marked this conversation as resolved.
Show resolved Hide resolved
example: Permission.USER_UPDATE,
})
requiredPermissions!: Permission[];
}

export class AuthorizationBodyParams {
@ValidateNested({ each: true })
dyedwiper marked this conversation as resolved.
Show resolved Hide resolved
@ApiProperty({
type: AuthorizationContextParams,
})
context!: AuthorizationContextParams;

@IsEnum(AuthorizableReferenceType)
@ApiProperty({
enum: AuthorizableReferenceType,
description: 'Define for which known entity, or domain object the operation should be peformend.',
CeEv marked this conversation as resolved.
Show resolved Hide resolved
example: AuthorizableReferenceType.User,
})
referenceType!: AuthorizableReferenceType;

@IsMongoId()
@ApiProperty({ description: 'The id of the entity/domain object of the defined referenceType.' })
referenceId!: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';

export class AuthorizedReponse {
@ApiProperty()
userId: string;

@ApiProperty()
isAuthorized: boolean;

constructor(props: AuthorizedReponse) {
this.userId = props.userId;
this.isAuthorized = props.isAuthorized;
}
}
2 changes: 2 additions & 0 deletions apps/server/src/modules/authorization/api/dto/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './authorization-body.params';
export * from './authorization.reponse';
2 changes: 2 additions & 0 deletions apps/server/src/modules/authorization/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './authorization-reference.controller';
export * from './authorization-reference.uc';
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { EntityId } from '@shared/domain/types';
import { AuthorizedReponse } from '../dto/authorization.reponse';

export class AuthorizationReponseMapper {
public static mapToResponse(userId: EntityId, isAuthorized: boolean): AuthorizedReponse {
const authorizationReponse = new AuthorizedReponse({
userId,
isAuthorized,
});

return authorizationReponse;
}
}
1 change: 1 addition & 0 deletions apps/server/src/modules/authorization/api/mapper/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './authorization.response.mapper';
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import { EntityManager } from '@mikro-orm/core';
import { ServerTestModule } from '@modules/server';
import { HttpStatus, INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { TestApiClient, UserAndAccountTestFactory } from '@shared/testing';
import { Permission } from '@shared/domain/interface';
import { Action, AuthorizableReferenceType, AuthorizationContext, AuthorizationContextBuilder } from '../../domain';
import { AuthorizationReponseMapper } from '../mapper';
import { AuthorizationBodyParams } from '../dto';

const createExamplePostData = (userId: string): AuthorizationBodyParams => {
const referenceType = AuthorizableReferenceType.User;
const context = AuthorizationContextBuilder.read([]);

const postData: AuthorizationBodyParams = {
referenceId: userId,
referenceType,
context,
};

return postData;
};

describe('Authorization Controller (API)', () => {
let app: INestApplication;
let em: EntityManager;
let testApiClient: TestApiClient;

beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [ServerTestModule],
}).compile();

app = moduleFixture.createNestApplication();
await app.init();
em = app.get(EntityManager);
testApiClient = new TestApiClient(app, 'authorization');
});

afterAll(async () => {
await app.close();
});

describe('authorizeByReference', () => {
describe('When user is not logged in', () => {
const setup = async () => {
const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher();

await em.persistAndFlush([teacherAccount, teacherUser]);
em.clear();

const postData = createExamplePostData(teacherUser.id);

return {
postData,
};
};

it('should response with unauthorized exception', async () => {
const { postData } = await setup();

const response = await testApiClient.post(`by-reference`, postData);

expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED);
expect(response.body).toEqual({
type: 'UNAUTHORIZED',
title: 'Unauthorized',
message: 'Unauthorized',
code: 401,
});
});
});

describe('When invalid data passed', () => {
const setup = async () => {
const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher();

await em.persistAndFlush([teacherAccount, teacherUser]);
em.clear();

const loggedInClient = await testApiClient.login(teacherAccount);
const postData = createExamplePostData(teacherUser.id);

return {
loggedInClient,
postData,
};
};

it('should response with api validation error for invalid reference type', async () => {
const { loggedInClient, postData } = await setup();
const invalidReferenceType = 'abc' as AuthorizableReferenceType;
postData.referenceType = invalidReferenceType;

const response = await loggedInClient.post(`/by-reference/`, postData);

expect(response.statusCode).toEqual(HttpStatus.BAD_REQUEST);
expect(response.body).toEqual({
type: 'API_VALIDATION_ERROR',
title: 'API Validation Error',
message: 'API validation failed, see validationErrors for details',
code: 400,
validationErrors: [{ field: ['referenceType'], errors: [expect.any(String)] }],
});
});

it('should response with api validation error for invalid reference id', async () => {
const { loggedInClient, postData } = await setup();
const invalidReferenceId = 'abc';
postData.referenceId = invalidReferenceId;

const response = await loggedInClient.post(`/by-reference`, postData);

expect(response.statusCode).toEqual(HttpStatus.BAD_REQUEST);
expect(response.body).toEqual({
type: 'API_VALIDATION_ERROR',
title: 'API Validation Error',
message: 'API validation failed, see validationErrors for details',
code: 400,
validationErrors: [{ field: ['referenceId'], errors: [expect.any(String)] }],
});
});

it('should response with api validation error for invalid action in body', async () => {
const { loggedInClient, postData } = await setup();
const invalidActionContext = { requiredPermissions: [] } as unknown as AuthorizationContext;
postData.context = invalidActionContext;

const response = await loggedInClient.post(`by-reference`, postData);

expect(response.statusCode).toEqual(HttpStatus.BAD_REQUEST);
expect(response.body).toEqual({
type: 'API_VALIDATION_ERROR',
title: 'API Validation Error',
message: 'API validation failed, see validationErrors for details',
code: 400,
validationErrors: [{ field: ['context', 'action'], errors: [expect.any(String)] }],
});
});

it('should response with api validation error for invalid requiredPermissions in body', async () => {
const { loggedInClient, postData } = await setup();
const invalidRequiredPermissionContext = { action: Action.read } as unknown as AuthorizationContext;
postData.context = invalidRequiredPermissionContext;

const response = await loggedInClient.post(`by-reference`, postData);

expect(response.statusCode).toEqual(HttpStatus.BAD_REQUEST);
expect(response.body).toEqual({
type: 'API_VALIDATION_ERROR',
title: 'API Validation Error',
message: 'API validation failed, see validationErrors for details',
code: 400,
validationErrors: [
{
field: ['context', 'requiredPermissions'],
errors: [expect.any(String), 'requiredPermissions must be an array'],
},
],
});
});

it('should response with api validation error for wrong permission in requiredPermissions in body', async () => {
const { loggedInClient, postData } = await setup();
const invalidPermissionContext = AuthorizationContextBuilder.read([
Permission.USER_UPDATE,
'INVALID_PERMISSION' as Permission,
]);
postData.context = invalidPermissionContext;

const response = await loggedInClient.post(`by-reference`, postData);

expect(response.statusCode).toEqual(HttpStatus.BAD_REQUEST);
expect(response.body).toEqual({
type: 'API_VALIDATION_ERROR',
title: 'API Validation Error',
message: 'API validation failed, see validationErrors for details',
code: 400,
validationErrors: [{ field: ['context', 'requiredPermissions'], errors: [expect.any(String)] }],
});
});
});

describe('When operation is not authorized', () => {
const setup = async () => {
const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher();
const { teacherUser: otherUser } = UserAndAccountTestFactory.buildTeacher();

await em.persistAndFlush([teacherAccount, teacherUser, otherUser]);
em.clear();

const loggedInClient = await testApiClient.login(teacherAccount);
const postData = createExamplePostData(otherUser.id);
postData.context.requiredPermissions = [Permission.ADMIN_EDIT];
const expectedResult = AuthorizationReponseMapper.mapToResponse(teacherUser.id, false);

return {
loggedInClient,
expectedResult,
postData,
};
};

it('should response with unsuccess authorisation response', async () => {
const { loggedInClient, expectedResult, postData } = await setup();

const response = await loggedInClient.post(`by-reference`, postData);

expect(response.statusCode).toEqual(HttpStatus.CREATED);
expect(response.body).toEqual(expectedResult);
});
});

describe('When operation is authorized', () => {
const setup = async () => {
const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher();

await em.persistAndFlush([teacherAccount, teacherUser]);
em.clear();

const loggedInClient = await testApiClient.login(teacherAccount);
const postData = createExamplePostData(teacherUser.id);
const expectedResult = AuthorizationReponseMapper.mapToResponse(teacherUser.id, true);

return {
loggedInClient,
expectedResult,
postData,
};
};

it('should response with success authorisation response', async () => {
const { loggedInClient, expectedResult, postData } = await setup();

const response = await loggedInClient.post(`by-reference`, postData);

expect(response.statusCode).toEqual(HttpStatus.CREATED);
expect(response.body).toEqual(expectedResult);
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AuthorizationReferenceUc, AuthorizationReferenceController } from './api';
import { AuthorizationReferenceModule } from './authorization-reference.module';

@Module({
imports: [AuthorizationReferenceModule],
providers: [AuthorizationReferenceUc],
controllers: [AuthorizationReferenceController],
})
export class AuthorizationReferenceApiModule {}
Loading
Loading