diff --git a/config/config.ts b/config/config.ts index e81afe71..70e8e89e 100644 --- a/config/config.ts +++ b/config/config.ts @@ -56,6 +56,9 @@ export function loadEnv(env: env, fromWorkerEnv: boolean): env { IDENTITY_SERVICE_PUBLIC_KEY: fromWorkerEnv ? env.IDENTITY_SERVICE_PUBLIC_KEY : process.env.IDENTITY_SERVICE_PUBLIC_KEY || "", + AWS_READ_ACCESS_GROUP_ID: fromWorkerEnv + ? env.AWS_READ_ACCESS_GROUP_ID + : process.env.AWS_READ_ACCESS_GROUP_ID || "", }; return Env; } diff --git a/src/constants/commands.ts b/src/constants/commands.ts index d64a965f..5d7d9e31 100644 --- a/src/constants/commands.ts +++ b/src/constants/commands.ts @@ -1,3 +1,7 @@ +import { config } from "dotenv"; + +config(); + export const HELLO = { name: "hello", description: "Replies with hello in the channel", @@ -27,6 +31,36 @@ export const GROUP_INVITE = { }, ], }; +export const GRANT_AWS_ACCESS = { + name: "grant-aws-access", + description: "This command is to grant AWS access to the discord users.", + options: [ + { + name: "user-name", + description: "User to be granted the AWS access", + type: 6, //user Id to be grant the access + required: true, + }, + { + name: "aws-group-name", + description: "AWS group name", + type: 3, + required: true, + choices: [ + { + name: "AWS read access", + value: process.env.AWS_READ_ACCESS_GROUP_ID, + }, + ], + }, + { + name: "dev", + description: "Feature flag", + type: 5, + required: true, + }, + ], +}; export const MENTION_EACH = { name: "mention-each", diff --git a/src/constants/urls.ts b/src/constants/urls.ts index a86b355f..6ebf15e2 100644 --- a/src/constants/urls.ts +++ b/src/constants/urls.ts @@ -3,6 +3,7 @@ export const RDS_BASE_STAGING_API_URL = "https://staging-api.realdevsquad.com"; export const RDS_BASE_DEVELOPMENT_API_URL = "http://localhost:3000"; // If needed, modify the URL to your local API server run through ngrok export const DISCORD_BASE_URL = "https://discord.com/api/v10"; +export const AWS_IAM_SIGNIN_URL = "https://realdevsquad.awsapps.com/start#/"; export const DISCORD_AVATAR_BASE_URL = "https://cdn.discordapp.com/avatars"; export const VERIFICATION_SITE_URL = "https://my.realdevsquad.com"; diff --git a/src/controllers/baseHandler.ts b/src/controllers/baseHandler.ts index 61c5b765..0593f3a3 100644 --- a/src/controllers/baseHandler.ts +++ b/src/controllers/baseHandler.ts @@ -29,6 +29,7 @@ import { USER, REMOVE, GROUP_INVITE, + GRANT_AWS_ACCESS, } from "../constants/commands"; import { updateNickName } from "../utils/updateNickname"; import { discordEphemeralResponse } from "../utils/discordEphemeralResponse"; @@ -44,6 +45,7 @@ import { import { DevFlag } from "../typeDefinitions/filterUsersByRole"; import { kickEachUser } from "./kickEachUser"; import { groupInvite } from "./groupInvite"; +import { grantAWSAccessCommand } from "./grantAWSAccessCommand"; export async function baseHandler( message: discordMessageRequest, @@ -82,6 +84,19 @@ export async function baseHandler( return await mentionEachUser(transformedArgument, env, ctx); } + case getCommandName(GRANT_AWS_ACCESS): { + const data = message.data?.options as Array; + const transformedArgument = { + member: message.member, + userDetails: data[0], + awsGroupDetails: data[1], + channelId: message.channel_id, + dev: data.find((item) => item.name === "dev") as unknown as DevFlag, + }; + + return await grantAWSAccessCommand(transformedArgument, env, ctx); + } + case getCommandName(REMOVE): { const data = message.data?.options as Array; const transformedArgument = { diff --git a/src/controllers/grantAWSAccessCommand.ts b/src/controllers/grantAWSAccessCommand.ts new file mode 100644 index 00000000..86fe9884 --- /dev/null +++ b/src/controllers/grantAWSAccessCommand.ts @@ -0,0 +1,38 @@ +import { discordTextResponse } from "../utils/discordResponse"; +import { SUPER_USER_ONE, SUPER_USER_TWO } from "../constants/variables"; +import { env } from "../typeDefinitions/default.types"; +import { + messageRequestMember, + messageRequestDataOptions, +} from "../typeDefinitions/discordMessage.types"; +import { grantAWSAccess } from "../utils/awsAccess"; +import { DevFlag } from "../typeDefinitions/filterUsersByRole"; + +export async function grantAWSAccessCommand( + transformedArgument: { + member: messageRequestMember; + userDetails: messageRequestDataOptions; + awsGroupDetails: messageRequestDataOptions; + channelId: number; + dev?: DevFlag; + }, + env: env, + ctx: ExecutionContext +) { + const dev = transformedArgument?.dev?.value || false; + if (!dev) { + return discordTextResponse("Please enable feature flag to make this work"); + } + const isUserSuperUser = [SUPER_USER_ONE, SUPER_USER_TWO].includes( + transformedArgument.member.user.id.toString() + ); + if (!isUserSuperUser) { + const responseText = `You're not authorized to make this request.`; + return discordTextResponse(responseText); + } + const roleId = transformedArgument.userDetails.value; + const groupId = transformedArgument.awsGroupDetails.value; + const channelId = transformedArgument.channelId; + + return grantAWSAccess(roleId, groupId, env, ctx, channelId); +} diff --git a/src/register.ts b/src/register.ts index 8602d543..76786999 100644 --- a/src/register.ts +++ b/src/register.ts @@ -10,6 +10,7 @@ import { USER, REMOVE, GROUP_INVITE, + GRANT_AWS_ACCESS, } from "./constants/commands"; import { config } from "dotenv"; import { DISCORD_BASE_URL } from "./constants/urls"; @@ -42,6 +43,7 @@ async function registerGuildCommands( NOTIFY_ONBOARDING, REMOVE, GROUP_INVITE, + GRANT_AWS_ACCESS, ]; try { diff --git a/src/utils/authTokenGenerator.ts b/src/utils/authTokenGenerator.ts new file mode 100644 index 00000000..ecfd462f --- /dev/null +++ b/src/utils/authTokenGenerator.ts @@ -0,0 +1,12 @@ +import jwt from "@tsndr/cloudflare-worker-jwt"; + +export async function generateDiscordAuthToken( + name: string, + expiry: number, + privateKey: string, + algorithm: string +) { + return await jwt.sign({ name: name, exp: expiry }, privateKey, { + algorithm: algorithm, + }); +} diff --git a/src/utils/awsAccess.ts b/src/utils/awsAccess.ts new file mode 100644 index 00000000..84fb1569 --- /dev/null +++ b/src/utils/awsAccess.ts @@ -0,0 +1,98 @@ +import jwt from "@tsndr/cloudflare-worker-jwt"; +import { env } from "../typeDefinitions/default.types"; +import config from "../../config/config"; +import { discordTextResponse } from "./discordResponse"; +import { DISCORD_BASE_URL, AWS_IAM_SIGNIN_URL } from "../constants/urls"; +import { generateDiscordAuthToken } from "./authTokenGenerator"; + +export async function processAWSAccessRequest( + discordUserId: string, + awsGroupId: string, + env: env, + channelId: number +): Promise { + const authToken = await generateDiscordAuthToken( + "Cloudflare Worker", + Math.floor(Date.now() / 1000) + 2, + env.BOT_PRIVATE_KEY, + "RS256" + ); + const discordReplyUrl = `${DISCORD_BASE_URL}/channels/${channelId}/messages`; + const base_url = config(env).RDS_BASE_API_URL; + const grantAWSAccessAPIUrl = `${base_url}/aws/groups/access?dev=true`; + + try { + const requestData = { + groupId: awsGroupId, + userId: discordUserId, + }; + + /** + * Grant AWS access is the API in website backend, + * which takes the discordId and AWS groupId, it fetches the + * user based on the discordId, checks if the user is part of AWS account + * if not creates a new user and adds user to the AWS group. + */ + + const response = await fetch(grantAWSAccessAPIUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify(requestData), + }); + + let content = ""; + if (!response.ok) { + const responseText = await response.text(); + const errorData = JSON.parse(responseText); + content = `<@${discordUserId}> Error occurred while granting AWS access: ${errorData.error}`; + } else { + content = `AWS access granted successfully <@${discordUserId}>! Please head over to AWS - ${AWS_IAM_SIGNIN_URL}.`; + } + await fetch(discordReplyUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${env.DISCORD_TOKEN}`, + }, + body: JSON.stringify({ + content: content, + }), + }); + } catch (err) { + const content = `<@${discordUserId}> Error occurred while granting AWS access.`; + await fetch(discordReplyUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${env.DISCORD_TOKEN}`, + }, + body: JSON.stringify({ + content: content, + }), + }); + } +} + +export async function grantAWSAccess( + discordUserId: string, + awsGroupId: string, + env: env, + ctx: ExecutionContext, + channelId: number +) { + // Immediately send a Discord response to acknowledge the command, as the cloudfare workers have a limit of response time equals to 3s + const initialResponse = discordTextResponse( + `<@${discordUserId}> Processing your request to grant AWS access.` + ); + + ctx.waitUntil( + // Asynchronously call the function to grant AWS access + processAWSAccessRequest(discordUserId, awsGroupId, env, channelId) + ); + + // Return the immediate response within 3 seconds + return initialResponse; +} diff --git a/src/utils/sendUserDiscordData.ts b/src/utils/sendUserDiscordData.ts index 29153f36..06a658c0 100644 --- a/src/utils/sendUserDiscordData.ts +++ b/src/utils/sendUserDiscordData.ts @@ -2,6 +2,7 @@ import { env } from "../typeDefinitions/default.types"; import jwt from "@tsndr/cloudflare-worker-jwt"; import { DISCORD_AVATAR_BASE_URL } from "../constants/urls"; import config from "../../config/config"; +import { generateDiscordAuthToken } from "./authTokenGenerator"; export const sendUserDiscordData = async ( token: string, @@ -12,10 +13,11 @@ export const sendUserDiscordData = async ( discordJoinedAt: string, env: env ) => { - const authToken = await jwt.sign( - { name: "Cloudflare Worker", exp: Math.floor(Date.now() / 1000) + 2 }, + const authToken = await generateDiscordAuthToken( + "Cloudflare Worker", + Math.floor(Date.now() / 1000) + 2, env.BOT_PRIVATE_KEY, - { algorithm: "RS256" } + "RS256" ); const data = { type: "discord", diff --git a/tests/unit/handlers/grantAwsAccessCommand.test.ts b/tests/unit/handlers/grantAwsAccessCommand.test.ts new file mode 100644 index 00000000..fed5ae93 --- /dev/null +++ b/tests/unit/handlers/grantAwsAccessCommand.test.ts @@ -0,0 +1,112 @@ +import { + grantAWSAccess, + processAWSAccessRequest, +} from "../../../src/utils/awsAccess"; +import { discordTextResponse } from "../../../src/utils/discordResponse"; +import jwt from "@tsndr/cloudflare-worker-jwt"; + +jest.mock("node-fetch"); +jest.mock("@tsndr/cloudflare-worker-jwt"); +jest.mock("../../../src/utils/discordResponse", () => ({ + discordTextResponse: jest.fn(), +})); + +const discordUserId = "test-user"; +const awsGroupId = "test-group"; +const env = { + BOT_PRIVATE_KEY: "mock-bot-private-key", + DISCORD_TOKEN: "mock-discord-token", + RDS_BASE_API_URL: "https://mock-api-url.com", +}; +const channelId = 123456789; +const ctx = { + waitUntil: jest.fn(), + passThroughOnException: jest.fn(), +}; +let fetchSpy: jest.SpyInstance; + +beforeEach(() => { + fetchSpy = jest.spyOn(global, "fetch"); + jest.spyOn(jwt, "sign").mockResolvedValue("mockJwtToken"); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe("ProcessAWSAccessRequest", () => { + it("Should be a JSON response", async () => { + const mockResponse = { content: "Processing your request..." }; + (discordTextResponse as jest.Mock).mockReturnValue(mockResponse); + const response = await grantAWSAccess( + discordUserId, + awsGroupId, + env, + ctx, + channelId + ); + expect(discordTextResponse).toHaveBeenCalledWith( + `<@${discordUserId}> Processing your request to grant AWS access.` + ); + + // Ensure the function returns the mocked response + expect(response).toEqual(mockResponse); + expect(ctx.waitUntil).toHaveBeenCalled(); // Ensure waitUntil is called + }); + + it("should handle succesful API call and grant access", async () => { + const fetchCalls: string[] = []; + fetchSpy.mockImplementation((url, options) => { + fetchCalls.push(`Fetch call to: ${url}`); + if (url.includes("/aws/groups/access")) { + return Promise.resolve({ ok: true } as Response); + } else if (url.includes("/channels/123456789/messages")) { + return Promise.resolve({ ok: true } as Response); + } + return Promise.reject(new Error("Unexpected URL")); + }); + + await processAWSAccessRequest( + discordUserId, + awsGroupId, + env as any, + channelId + ); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(fetchCalls).toHaveLength(2); + + expect(fetchCalls[0]).toContain("/aws/groups/access"); + expect(fetchCalls[1]).toContain("/channels/123456789/messages"); + }); + + it("should handle API error", async () => { + const fetchCalls: string[] = []; + fetchSpy.mockImplementation((url, options) => { + fetchCalls.push(`Fetch call to: ${url}`); + if (url.includes("/aws/groups/access")) { + return Promise.resolve({ + ok: false, + status: 500, + statusText: "Internal Server Error", + } as Response); + } else if (url.includes(`/channels/123456789/messages`)) { + return Promise.resolve({ ok: true } as Response); + } + return Promise.reject(new Error("Unexpected URL")); + }); + + await processAWSAccessRequest( + discordUserId, + awsGroupId, + env as any, + channelId + ); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(fetchCalls).toHaveLength(2); + + expect(fetchCalls[0]).toContain("/aws/groups/access"); + expect(fetchCalls[1]).toContain("/channels/123456789/messages"); + }); +});