Skip to content

Commit

Permalink
feat: team.getIntegrations() function (#281)
Browse files Browse the repository at this point in the history
## What does this PR do?
- Replaces `team.getBopsSubmissionURL()` for the more generic
`team.getIntegrations()`
- Handles decryption of secrets from db to plaintext for use in
`planx-new`
- Implemented in
theopensystemslab/planx-new#2703
  • Loading branch information
DafyddLlyr authored Jan 29, 2024
1 parent 1c88948 commit 02c3999
Show file tree
Hide file tree
Showing 6 changed files with 124 additions and 15 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The package is structured by functional responsibility. Here is a summary of wha
├── exports # data processing functions to convert application data into third-party data formats
├── templates # data processing functions to convert application data into various document formats
└── types # a set of Typescript types for the exported functions and structures of this package
└── utils # utility functions

## Conventions

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from "./export";
export * from "./models";
export * from "./requests";
export * from "./templates";
export * from "./utils";
62 changes: 47 additions & 15 deletions src/requests/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { gql } from "graphql-request";

import { TeamRole } from "../types/roles";
import { Team, TeamTheme } from "../types/team";
import { decrypt } from "../utils/encryption";

interface UpsertMember {
userId: number;
Expand Down Expand Up @@ -65,11 +66,12 @@ export class TeamClient {
return getBySlug(this.client, slug);
}

async getBopsSubmissionURL(
slug: string,
env: PlanXEnv,
): Promise<string | null> {
return getBopsSubmissionURL(this.client, slug, env);
async getIntegrations(args: {
slug: string;
env: PlanXEnv;
encryptionKey: string;
}): Promise<DecryptedIntegrations> {
return getIntegrations({ client: this.client, ...args });
}

async updateTheme(
Expand Down Expand Up @@ -271,34 +273,55 @@ async function getBySlug(client: GraphQLClient, slug: string) {
return response.teams[0];
}

interface GetBopsSubmissionURL {
interface GetEncryptedIntegrations {
teams: {
integrations: {
bopsSubmissionURL: string | null;
bopsSecret: string | null;
} | null;
}[];
}

async function getBopsSubmissionURL(
client: GraphQLClient,
slug: string,
env: PlanXEnv,
) {
interface DecryptedIntegrations {
bopsSubmissionURL?: string;
bopsToken?: string;
}

/**
* Return integration details for a team
*
* XXX: Why do we need env? Why are there columns for staging and production secrets?
* Please see https://github.com/theopensystemslab/planx-new/pull/2499
* @returns Integration details, decrypted
*/
async function getIntegrations({
client,
slug,
env,
encryptionKey,
}: {
client: GraphQLClient;
slug: string;
env: PlanXEnv;
encryptionKey: string;
}): Promise<DecryptedIntegrations> {
const stagingQuery = gql`
query GetStagingBopsSubmissionURL($slug: String!) {
query GetStagingIntegrations($slug: String!) {
teams(where: { slug: { _eq: $slug } }) {
integrations {
bopsSubmissionURL: staging_bops_submission_url
bopsSecret: staging_bops_secret
}
}
}
`;

const productionQuery = gql`
query GetProductionBopsSubmissionURL($slug: String!) {
query GetProductionIntegrations($slug: String!) {
teams(where: { slug: { _eq: $slug } }) {
integrations {
bopsSubmissionURL: production_bops_submission_url
bopsSecret: production_bops_secret
}
}
}
Expand All @@ -308,9 +331,18 @@ async function getBopsSubmissionURL(

const {
teams: [team],
} = await client.request<GetBopsSubmissionURL>(query, { slug });
} = await client.request<GetEncryptedIntegrations>(query, { slug });

if (!team) throw Error(`No team matching "${slug}" found.`);
if (!team.integrations)
throw Error(`Integrations not set up for team "${slug}".`);

const decryptedIntegrations: DecryptedIntegrations = {
bopsSubmissionURL: team.integrations.bopsSubmissionURL ?? undefined,
bopsToken: decrypt(team.integrations.bopsSecret, encryptionKey),
};

return team?.integrations?.bopsSubmissionURL ?? null;
return decryptedIntegrations;
}

async function updateTheme(
Expand Down
30 changes: 30 additions & 0 deletions src/utils/encryption.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { decrypt, encrypt } from "./encryption";

const key = "mySecretKey".padEnd(32, "0");

describe("encrypt function", () => {
it("should correctly encrypt a secret", () => {
const originalSecret = "sensitiveInformation";

const encrypted = encrypt(originalSecret, key);
expect(encrypted).toMatch(/:/);

const decryptedResult = decrypt(encrypted, key);
expect(decryptedResult).toBe(originalSecret);
});
});

describe("decrypt function", () => {
it("should return undefined when secret is null", () => {
const result = decrypt(null, "someKey");
expect(result).toBeUndefined();
});

it("should correctly decrypt a valid secret", () => {
const originalSecret = "sensitiveInformation";
const encryptedSecret = encrypt(originalSecret, key);

const result = decrypt(encryptedSecret, key);
expect(result).toBe(originalSecret);
});
});
44 changes: 44 additions & 0 deletions src/utils/encryption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import * as crypto from "crypto";

/**
* Encrypts a secret using AES-256-CBC encryption
*
* @param secret - The secret to be encrypted
* @param key - The encryption key - a 32-byte string
* @returns The encrypted secret along with the initialization vector (IV), separated by a colon
*/
export function encrypt(secret: string, key: string): string {
const keyBuffer = Buffer.from(key);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("AES-256-CBC", keyBuffer, iv);
let encrypted = cipher.update(secret, "utf-8", "hex");
encrypted += cipher.final("hex");
const ivString = iv.toString("hex");

return `${encrypted}:${ivString}`;
}

/**
* Decrypts a secret that was encrypted using AES-256-CBC encryption
*
* @param secret - The secret to be decrypted. If null, returns undefined.
* @param key - The encryption key - a 32-byte string
* @returns The decrypted secret
*/
export function decrypt(
secret: string | null,
key: string,
): string | undefined {
if (!secret) return undefined;

const [encryptedToken, iv] = secret.split(":");
const decipher = crypto.createDecipheriv(
"AES-256-CBC",
Buffer.from(key, "utf-8"),
Buffer.from(iv, "hex"),
);
let decryptedToken = decipher.update(encryptedToken, "hex", "utf-8");
decryptedToken += decipher.final("utf-8");

return decryptedToken;
}
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./encryption";

0 comments on commit 02c3999

Please sign in to comment.