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

feat(tenant-management): provision to add tenant offboarding #18

Draft
wants to merge 1 commit into
base: release
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,18 @@ export const mockWebhookPayload: WebhookPayload = {
}),
],
appPlaneUrl: 'redirectUrl',
tier: PlanTier.SILO,
},
};

export const mockOffboardingWebhookPayload: WebhookPayload = {
initiatorId: 'user-id-1',
type: WebhookType.TENANT_OFFBOARDING,
data: {
status: WebhookStatus.SUCCESS,
resources: [],
appPlaneUrl: '',
tier: PlanTier.SILO,
},
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import {BindingScope} from '@loopback/context';
import {AWS_CODEBUILD_CLIENT} from '../../services';
import {CodeBuildClient, StartBuildCommand} from '@aws-sdk/client-codebuild';
import {PlanTier} from '../../enums';
import {PIPELINES} from '../../keys';
import {OFFBOARDING_PIPELINES, PIPELINES} from '../../keys';
import {OffBoard} from '../../enums/off-board.enum';

describe('TenantController', () => {
let app: TenantMgmtServiceApplication;
Expand Down Expand Up @@ -50,6 +51,10 @@ describe('TenantController', () => {
[PlanTier.POOLED]: 'free-pipeline',
[PlanTier.SILO]: '',
});
app.bind(OFFBOARDING_PIPELINES).to({
[OffBoard.POOLED]: 'free-offboard-pipeline',
[OffBoard.SILO]: '',
});
secretRepo = await getRepo(app, 'repositories.WebhookSecretRepository');
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,23 @@ import {Client, expect, sinon} from '@loopback/testlab';
import {ILogger, LOGGER, STATUS_CODE} from '@sourceloop/core';
import {createHmac, randomBytes} from 'crypto';
import {TenantMgmtServiceApplication} from '../../application';
import {TenantStatus} from '../../enums';
import {WEBHOOK_CONFIG} from '../../keys';
import {TenantStatus, WebhookType} from '../../enums';
import {OFFBOARDING_PIPELINES, WEBHOOK_CONFIG} from '../../keys';
import {
ContactRepository,
ResourceRepository,
TenantRepository,
WebhookSecretRepository,
} from '../../repositories';
import {ResourceData, WebhookConfig, WebhookPayload} from '../../types';
import {mockContact, mockWebhookPayload, testTemplates} from './mock-data';
import {
mockContact,
mockOffboardingWebhookPayload,
mockWebhookPayload,
testTemplates,
} from './mock-data';
import {getRepo, setupApplication} from './test-helper';
import {OffBoard} from '../../enums/off-board.enum';

describe('WebhookController', () => {
let app: TenantMgmtServiceApplication;
Expand All @@ -24,6 +30,7 @@ describe('WebhookController', () => {
let tenantRepo: TenantRepository;
let contactRepo: ContactRepository;
let webhookPayload: WebhookPayload;
let offboardWebhookPayload: WebhookPayload;
const nonExistantTenant = 'non-existant-tenant';
const notifStub = sinon.stub();

Expand All @@ -49,6 +56,10 @@ describe('WebhookController', () => {
createNotification: notifStub,
getTemplateByName: (name: string) => testTemplates[name],
});
app.bind(OFFBOARDING_PIPELINES).to({
[OffBoard.POOLED]: 'free-offboard-pipeline',
[OffBoard.SILO]: '',
});
});

after(async () => {
Expand All @@ -62,10 +73,14 @@ describe('WebhookController', () => {
notifStub.resolves();
await resourceRepo.deleteAllHard();
await tenantRepo.deleteAllHard();
const tenant = await seedTenant();
const {newTenant, newTenant2} = await seedTenant();
webhookPayload = {
...mockWebhookPayload,
initiatorId: tenant.id,
initiatorId: newTenant.id,
};
offboardWebhookPayload = {
...mockOffboardingWebhookPayload,
initiatorId: newTenant2.id,
};
});

Expand Down Expand Up @@ -158,6 +173,95 @@ describe('WebhookController', () => {
});
});

describe('For Offboarding', () => {
it('should successfully call the webhook handler for a valid payload', async () => {
const headers = await buildHeaders(offboardWebhookPayload);
await client
.post('/webhook')
.set(webhookConfig.signatureHeaderName, headers.signature)
.set(webhookConfig.timestampHeaderName, headers.timestamp)
.send(offboardWebhookPayload)
.expect(STATUS_CODE.NO_CONTENT);

const tenant = await tenantRepo.findById(
offboardWebhookPayload.initiatorId,
);
expect(tenant.status).to.equal(TenantStatus.INACTIVE);

// should send an email to primary contact as well for successful provisioning
const calls = notifStub.getCalls();
expect(calls).to.have.length(1);
// extract and validate data from the email
const emailData = calls[0].args[2];
const receiver = calls[0].args[0];
expect(emailData.link).to.be.String();
expect(emailData.name).to.equal(tenant.name);
expect(emailData.user).to.equal(mockContact.firstName);
expect(receiver).to.equal(mockContact.email);

// verify the resource was deleted
const resources = await resourceRepo.find({
where: {
tenantId: offboardWebhookPayload.initiatorId,
},
});
expect(resources).to.have.length(0);
});

it('should successfully call the provisioning handler but skips mail for a valid payload but contact missing', async () => {
const headers = await buildHeaders(offboardWebhookPayload);
// delete contact to avoid sending email
await contactRepo.deleteAllHard();

await client
.post('/webhook')
.set(webhookConfig.signatureHeaderName, headers.signature)
.set(webhookConfig.timestampHeaderName, headers.timestamp)
.send(offboardWebhookPayload)
.expect(STATUS_CODE.NO_CONTENT);

const tenant = await tenantRepo.findById(
offboardWebhookPayload.initiatorId,
);
expect(tenant.status).to.equal(TenantStatus.INACTIVE);

// should throw an error if contact not found for the tenant
const calls = notifStub.getCalls();
expect(calls).to.have.length(0);
// extract and validate data from the email
sinon.assert.calledWith(
loggerSpy.error,
`No email found to notify tenant: ${tenant.id}`,
);
});

it('should return 401 if the initiator id is for tenant that does not exist', async () => {
const newPayload = {
...offboardWebhookPayload,
initiatorId: nonExistantTenant,
};
const headers = await buildHeaders(newPayload);
await client
.post('/webhook')
.set(webhookConfig.signatureHeaderName, headers.signature)
.set(webhookConfig.timestampHeaderName, headers.timestamp)
.send(newPayload)
.expect(STATUS_CODE.UNAUTHORISED);

const resources = await resourceRepo.find({
where: {
tenantId: newPayload.initiatorId,
},
});

expect(resources).to.have.length(0);
const tenant = await tenantRepo.findById(
offboardWebhookPayload.initiatorId,
);
expect(tenant.status).to.equal(TenantStatus.OFFBOARDING);
});
});

describe('For Provisioning', () => {
it('should successfully call the provisioning handler for a valid payload', async () => {
const headers = await buildHeaders(webhookPayload);
Expand Down Expand Up @@ -276,14 +380,27 @@ describe('WebhookController', () => {
key: 'test-tenant-key',
domains: ['test.com'],
});
const newTenant2 = await tenantRepo.create({
name: 'test-tenant-offboarding',
status: TenantStatus.OFFBOARDING,
key: 'test-tenant-key-offboarding',
domains: ['test-offboard.com'],
});
await contactRepo.createAll([
{
...mockContact,
isPrimary: true,
tenantId: newTenant.id,
},
]);
return newTenant;
await contactRepo.createAll([
{
...mockContact,
isPrimary: true,
tenantId: newTenant2.id,
},
]);
return {newTenant, newTenant2};
}

async function buildHeaders(payload: WebhookPayload, tmp?: number) {
Expand All @@ -296,10 +413,17 @@ describe('WebhookController', () => {
const secretRepo = app.getSync<WebhookSecretRepository>(
'repositories.WebhookSecretRepository',
);
await secretRepo.set(context, {
secret,
context: payload.initiatorId,
});
if (payload.type === WebhookType.TENANT_OFFBOARDING) {
await secretRepo.set(`${context}:offboarding`, {
secret,
context: payload.initiatorId,
});
} else {
await secretRepo.set(context, {
secret,
context: payload.initiatorId,
});
}
return {
timestamp,
signature,
Expand Down
4 changes: 4 additions & 0 deletions services/tenant-management-service/src/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,11 @@ import {
InvoicePDFGenerator,
LeadAuthenticator,
NotificationService,
OffBoardService,
OnboardingService,
ProvisioningService,
} from './services';
import {OffBoardingWebhookHandler} from './services/webhook';
export class TenantManagementServiceComponent implements Component {
constructor(
@inject(CoreBindings.APPLICATION_INSTANCE)
Expand Down Expand Up @@ -153,6 +155,8 @@ export class TenantManagementServiceComponent implements Component {
Binding.bind(AWS_CODEBUILD_CLIENT).toProvider(CodebuildClientProvider),
createServiceBinding(ProvisioningService),
createServiceBinding(OnboardingService),
createServiceBinding(OffBoardService),
createServiceBinding(OffBoardingWebhookHandler),
createServiceBinding(LeadAuthenticator),
createServiceBinding(CryptoHelperService),
Binding.bind('services.NotificationService').toClass(NotificationService),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,14 @@ import {TenantRepository} from '../repositories/tenant.repository';
import {SubscriptionDTO, Tenant, TenantOnboardDTO} from '../models';
import {PermissionKey} from '../permissions';
import {service} from '@loopback/core';
import {OnboardingService, ProvisioningService} from '../services';
import {
OffBoardService,
OnboardingService,
ProvisioningService,
} from '../services';
import {IProvisioningService} from '../types';
import {TenantTierDTO} from '../models/dtos/tenant-tier-dto.model';
import {TenantStatus} from '../enums';

const basePath = '/tenants';

Expand All @@ -39,6 +45,8 @@ export class TenantController {
private readonly onboarding: OnboardingService,
@service(ProvisioningService)
private readonly provisioningService: IProvisioningService<SubscriptionDTO>,
@service(OffBoardService)
private readonly offBoardingService: OffBoardService,
) {}

@authorize({
Expand Down Expand Up @@ -107,6 +115,39 @@ export class TenantController {
return this.provisioningService.provisionTenant(existing, dto);
}

@authorize({
permissions: [PermissionKey.OffBoardTenant],
})
@authenticate(STRATEGY.BEARER, {
passReqToCallback: true,
})
@post(`${basePath}/{id}/off-board`, {
security: OPERATION_SECURITY_SPEC,
responses: {
[STATUS_CODE.NO_CONTENT]: {
description: 'offboarding success',
},
},
})
async offboard(
@requestBody({
content: {
[CONTENT_TYPE.JSON]: {
schema: getModelSchemaRef(TenantTierDTO, {
title: 'TenantTierDTO',
}),
},
},
})
dto: TenantTierDTO,
@param.path.string('id') id: string,
): Promise<void> {
await this.tenantRepository.updateById(id, {
status: TenantStatus.OFFBOARDING,
});
return this.offBoardingService.offBoardTenant(id, dto);
}

@authorize({
permissions: [PermissionKey.ViewTenant],
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum OffBoard {
POOLED,
SILO,
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ export enum TenantStatus {
PROVISIONFAILED,
DEPROVISIONING,
INACTIVE,
OFFBOARDING,
OFFBOARDING_RETRY,
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export enum WebhookType {
RESOURCES_PROVISIONED,
TENANT_OFFBOARDING,
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
inject,
service,
} from '@loopback/core';
import {WebhookConfig, WebhookPayload} from '../types';
import {SecretInfo, WebhookConfig, WebhookPayload} from '../types';
import {HttpErrors, RequestContext} from '@loopback/rest';
import {SYSTEM_USER, WEBHOOK_CONFIG} from '../keys';
import {CryptoHelperService} from '../services';
Expand All @@ -16,6 +16,7 @@ import {WebhookSecretRepository} from '../repositories';
import {ILogger, LOGGER} from '@sourceloop/core';
import {timingSafeEqual} from 'crypto';
import {AuthenticationBindings, IAuthUser} from 'loopback4-authentication';
import {WebhookType} from '../enums';

export class WebhookVerifierProvider implements Provider<Interceptor> {
constructor(
Expand Down Expand Up @@ -56,8 +57,15 @@ export class WebhookVerifierProvider implements Provider<Interceptor> {
throw new HttpErrors.Unauthorized();
}
const initiatorId = value.initiatorId;
let secretInfo: SecretInfo;
if (value.type === WebhookType.TENANT_OFFBOARDING) {
secretInfo = await this.webhookSecretRepo.get(
`${initiatorId}:offboarding`,
);
} else {
secretInfo = await this.webhookSecretRepo.get(initiatorId);
}

const secretInfo = await this.webhookSecretRepo.get(initiatorId);
if (!secretInfo) {
this.logger.error('No secret found for this initiator');
throw new HttpErrors.Unauthorized();
Expand Down
3 changes: 3 additions & 0 deletions services/tenant-management-service/src/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export const LEAD_TOKEN_VERIFIER = BindingKey.create<
export const PIPELINES = BindingKey.create<Record<string, string>>(
'sf.tenant.pipelines',
);
export const OFFBOARDING_PIPELINES = BindingKey.create<Record<string, string>>(
'sf.tenant.offboarding.pipelines',
);

/**
* Binding key for the system user.
Expand Down
Loading