plug opa in auth

This commit is contained in:
Gsk54
2023-01-17 16:39:24 +01:00
parent 3d2bb613bd
commit 972d43ac30
27 changed files with 473 additions and 101 deletions

View File

@@ -9,11 +9,17 @@ async function bootstrap() {
{
transport: Transport.GRPC,
options: {
package: 'authentication',
protoPath: join(
__dirname,
'modules/authentication/adapters/primaries/authentication.proto',
),
package: ['authentication', 'authorization'],
protoPath: [
join(
__dirname,
'modules/authentication/adapters/primaries/authentication.proto',
),
join(
__dirname,
'modules/authorization/adapters/primaries/authorization.proto',
),
],
url: process.env.SERVICE_URL + ':' + process.env.SERVICE_PORT,
loader: { keepCase: true, enums: String },
},

View File

@@ -0,0 +1,28 @@
import { Controller } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { DecisionRequest } from '../../domain/dtos/decision.request';
import { DecisionQuery } from '../../queries/decision.query';
import { DecisionResult } from '../secondaries/decision-result';
@Controller()
export class AuthorizationController {
constructor(private readonly _queryBus: QueryBus) {}
@GrpcMethod('AuthorizationService', 'Decide')
async decide(data: DecisionRequest): Promise<DecisionResult> {
try {
const decision: boolean = await this._queryBus.execute(
new DecisionQuery(data.uuid, data.domain, data.action, data.context),
);
return {
allow: decision,
};
} catch (e) {
throw new RpcException({
code: 7,
message: 'Permission denied',
});
}
}
}

View File

@@ -0,0 +1,35 @@
syntax = "proto3";
package authorization;
service AuthorizationService {
rpc Decide(AuthorizationRequest) returns (Decision);
}
message AuthorizationRequest {
string uuid = 1;
Domain domain = 2;
Action action = 3;
repeated Item context = 4;
}
enum Domain {
user = 0;
}
enum Action {
create = 0;
read = 1;
update = 2;
delete = 3;
list = 4;
}
message Item {
string name = 1;
string value = 2;
}
message Decision {
bool allow = 1;
}

View File

@@ -0,0 +1,3 @@
export class DecisionResult {
allow: boolean;
}

View File

@@ -0,0 +1,6 @@
import { DecisionResult } from './decision-result';
export class Decision {
decision_id: string;
result: DecisionResult;
}

View File

@@ -0,0 +1,38 @@
import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { lastValueFrom } from 'rxjs';
import { Action } from '../../domain/dtos/action.enum';
import { Domain } from '../../domain/dtos/domain.enum';
import { IMakeDecision } from '../../domain/interfaces/decision-maker';
import { Decision } from './decision';
@Injectable()
export class OpaDecisionMaker extends IMakeDecision {
constructor(
private readonly configService: ConfigService,
private readonly httpService: HttpService,
) {
super();
}
async decide(
uuid: string,
domain: Domain,
action: Action,
context: Array<{ name: string; value: string }>,
): Promise<boolean> {
const { data } = await lastValueFrom(
this.httpService.post<Decision>(
this.configService.get<string>('OPA_URL') + domain + '/' + action,
{
input: {
uuid,
...context,
},
},
),
);
return data.result.allow;
}
}

View File

@@ -1,11 +1,15 @@
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { ValidateAuthenticationUseCase } from '../authentication/domain/usecases/validate-authentication.usecase';
import { DatabaseModule } from '../database/database.module';
import { AuthorizationController } from './adapters/primaries/authorization.controller';
import { OpaDecisionMaker } from './adapters/secondaries/opa.decision-maker';
import { DecisionUseCase } from './domain/usecases/decision.usecase';
@Module({
imports: [DatabaseModule, CqrsModule],
imports: [DatabaseModule, CqrsModule, HttpModule],
exports: [],
providers: [ValidateAuthenticationUseCase],
controllers: [AuthorizationController],
providers: [OpaDecisionMaker, DecisionUseCase],
})
export class AuthorizationModule {}

View File

@@ -0,0 +1,7 @@
export enum Action {
create = 'create',
read = 'read',
update = 'update',
delete = 'delete',
list = 'list',
}

View File

@@ -0,0 +1,20 @@
import { IsArray, IsNotEmpty, IsString } from 'class-validator';
import { Action } from './action.enum';
import { Domain } from './domain.enum';
export class DecisionRequest {
@IsString()
@IsNotEmpty()
uuid: string;
@IsString()
@IsNotEmpty()
domain: Domain;
@IsString()
@IsNotEmpty()
action: Action;
@IsArray()
context?: Array<{ name: string; value: string }>;
}

View File

@@ -0,0 +1,3 @@
export enum Domain {
user = 'user',
}

View File

@@ -1,11 +0,0 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class ValidateAuthorizationRequest {
@IsString()
@IsNotEmpty()
uuid: string;
@IsString()
@IsNotEmpty()
action: string;
}

View File

@@ -1,4 +0,0 @@
export class Authorization {
uuid: string;
action: string;
}

View File

@@ -0,0 +1,13 @@
import { Injectable } from '@nestjs/common';
import { Action } from '../dtos/action.enum';
import { Domain } from '../dtos/domain.enum';
@Injectable()
export abstract class IMakeDecision {
abstract decide(
uuid: string,
domain: Domain,
action: Action,
context: Array<{ name: string; value: string }>,
): Promise<boolean>;
}

View File

@@ -0,0 +1,17 @@
import { QueryHandler } from '@nestjs/cqrs';
import { OpaDecisionMaker } from '../../adapters/secondaries/opa.decision-maker';
import { DecisionQuery } from '../../queries/decision.query';
@QueryHandler(DecisionQuery)
export class DecisionUseCase {
constructor(private readonly _decisionMaker: OpaDecisionMaker) {}
async execute(decisionQuery: DecisionQuery): Promise<boolean> {
return this._decisionMaker.decide(
decisionQuery.uuid,
decisionQuery.domain,
decisionQuery.action,
decisionQuery.context,
);
}
}

View File

@@ -1,9 +0,0 @@
import { QueryHandler } from '@nestjs/cqrs';
import { ValidateAuthorizationQuery } from '../../queries/validate-authorization.query';
@QueryHandler(ValidateAuthorizationQuery)
export class ValidateAuthorizationUseCase {
async execute(validate: ValidateAuthorizationQuery): Promise<boolean> {
return Promise.resolve(validate.action == 'authorized');
}
}

View File

@@ -0,0 +1,21 @@
import { Action } from '../domain/dtos/action.enum';
import { Domain } from '../domain/dtos/domain.enum';
export class DecisionQuery {
readonly uuid: string;
readonly domain: Domain;
readonly action: Action;
readonly context: Array<{ name: string; value: string }>;
constructor(
uuid: string,
domain: Domain,
action: Action,
context?: Array<{ name: string; value: string }>,
) {
this.uuid = uuid;
this.domain = domain;
this.action = action;
this.context = context;
}
}

View File

@@ -1,9 +0,0 @@
export class ValidateAuthorizationQuery {
readonly uuid: string;
readonly action: string;
constructor(uuid: string, action: string) {
this.uuid = uuid;
this.action = action;
}
}

View File

@@ -0,0 +1,62 @@
import { classes } from '@automapper/classes';
import { AutomapperModule } from '@automapper/nestjs';
import { Test, TestingModule } from '@nestjs/testing';
import { OpaDecisionMaker } from '../../adapters/secondaries/opa.decision-maker';
import { Action } from '../../domain/dtos/action.enum';
import { DecisionRequest } from '../../domain/dtos/decision.request';
import { Domain } from '../../domain/dtos/domain.enum';
import { DecisionUseCase } from '../../domain/usecases/decision.usecase';
import { DecisionQuery } from '../../queries/decision.query';
const mockOpaDecisionMaker = {
decide: jest.fn().mockResolvedValue(Promise.resolve(true)),
};
describe('DecisionUseCase', () => {
let decisionUseCase: DecisionUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })],
providers: [
{
provide: OpaDecisionMaker,
useValue: mockOpaDecisionMaker,
},
DecisionUseCase,
],
}).compile();
decisionUseCase = module.get<DecisionUseCase>(DecisionUseCase);
});
it('should be defined', () => {
expect(decisionUseCase).toBeDefined();
});
describe('execute', () => {
it('should validate an authorization', async () => {
const decisionRequest: DecisionRequest = new DecisionRequest();
decisionRequest.uuid = 'bb281075-1b98-4456-89d6-c643d3044a91';
decisionRequest.domain = Domain.user;
decisionRequest.action = Action.create;
decisionRequest.context = [
{
name: 'context1',
value: 'value1',
},
];
expect(
decisionUseCase.execute(
new DecisionQuery(
decisionRequest.uuid,
decisionRequest.domain,
decisionRequest.action,
decisionRequest.context,
),
),
).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,88 @@
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { of } from 'rxjs';
import { OpaDecisionMaker } from '../../adapters/secondaries/opa.decision-maker';
import { Action } from '../../domain/dtos/action.enum';
import { Domain } from '../../domain/dtos/domain.enum';
const mockHttpService = {
post: jest
.fn()
.mockImplementationOnce(() => {
return of({
status: 200,
data: {
decision_id: '96d99d44-e0a6-458e-a656-de2a400d60a8',
result: {
allow: true,
},
},
});
})
.mockImplementationOnce(() => {
return of({
status: 200,
data: {
decision_id: '96d99d44-e0a6-458e-a656-de2a400d60a9',
result: {
allow: false,
},
},
});
}),
};
const mockConfigService = {
get: jest.fn().mockResolvedValue({
OPA_URL: 'http://url/',
}),
};
describe('OpaDecisionMaker', () => {
let opaDecisionMaker: OpaDecisionMaker;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [],
providers: [
{
provide: HttpService,
useValue: mockHttpService,
},
{
provide: ConfigService,
useValue: mockConfigService,
},
OpaDecisionMaker,
],
}).compile();
opaDecisionMaker = module.get<OpaDecisionMaker>(OpaDecisionMaker);
});
it('should be defined', () => {
expect(opaDecisionMaker).toBeDefined();
});
describe('execute', () => {
it('should return a truthy decision', async () => {
const decision = await opaDecisionMaker.decide(
'bb281075-1b98-4456-89d6-c643d3044a91',
Domain.user,
Action.read,
[],
);
expect(decision).toBeTruthy();
});
it('should return a falsy decision', async () => {
const decision = await opaDecisionMaker.decide(
'bb281075-1b98-4456-89d6-c643d3044a91',
Domain.user,
Action.read,
[],
);
expect(decision).toBeFalsy();
});
});
});

View File

@@ -1,44 +0,0 @@
import { classes } from '@automapper/classes';
import { AutomapperModule } from '@automapper/nestjs';
import { Test, TestingModule } from '@nestjs/testing';
import { ValidateAuthorizationRequest } from '../../domain/dtos/validate-authorization.request';
import { ValidateAuthorizationUseCase } from '../../domain/usecases/validate-authorization.usecase';
import { ValidateAuthorizationQuery } from '../../queries/validate-authorization.query';
describe('ValidateAuthorizationUseCase', () => {
let validateAuthorizationUseCase: ValidateAuthorizationUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })],
providers: [ValidateAuthorizationUseCase],
}).compile();
validateAuthorizationUseCase = module.get<ValidateAuthorizationUseCase>(
ValidateAuthorizationUseCase,
);
});
it('should be defined', () => {
expect(validateAuthorizationUseCase).toBeDefined();
});
describe('execute', () => {
it('should validate an authorization', async () => {
const validateAuthorizationRequest: ValidateAuthorizationRequest =
new ValidateAuthorizationRequest();
validateAuthorizationRequest.uuid =
'bb281075-1b98-4456-89d6-c643d3044a91';
validateAuthorizationRequest.action = 'authorized';
expect(
validateAuthorizationUseCase.execute(
new ValidateAuthorizationQuery(
validateAuthorizationRequest.uuid,
validateAuthorizationRequest.action,
),
),
).toBeTruthy();
});
});
});