diff --git a/api.planx.uk/admin/session/html.test.ts b/api.planx.uk/admin/session/html.test.ts index f6fdf385e2..3a35a62319 100644 --- a/api.planx.uk/admin/session/html.test.ts +++ b/api.planx.uk/admin/session/html.test.ts @@ -17,7 +17,7 @@ const mockGenerateHTMLData = jest.fn().mockResolvedValue({ }); jest.mock("../../client", () => { return { - $admin: { + $api: { export: { csvData: () => mockGenerateHTMLData(), }, diff --git a/api.planx.uk/client/index.ts b/api.planx.uk/client/index.ts index 4a8e99fdb8..6fb02ff19d 100644 --- a/api.planx.uk/client/index.ts +++ b/api.planx.uk/client/index.ts @@ -1,19 +1,24 @@ import { CoreDomainClient } from "@opensystemslab/planx-core"; import { userContext } from "../modules/auth/middleware"; import { ServerError } from "../errors"; +import { buildJWTForAPIRole } from "../modules/auth/service"; /** - * @deprecated This client's permissions set are higher than required. - * Should only be used by trusted service-to-service calls (e.g Hasura -> API). - * Calls made by users should be directed through $public or the role-scoped getClient(). + * Connects to Hasura using the "api" role * - * Consider removing this and replacing with an "api" role using "backend-only" operations in Hasura + * Should be used when a request is not initiated by a user, but another PlanX service (e.g. Hasura events). + * Can also be used for "side effects" triggered by a user (e.g. writing audit logs) */ -export const $admin = new CoreDomainClient({ - auth: { adminSecret: process.env.HASURA_GRAPHQL_ADMIN_SECRET! }, +export const $api = new CoreDomainClient({ + auth: { + jwt: buildJWTForAPIRole(), + }, targetURL: process.env.HASURA_GRAPHQL_URL!, }); +/** + * Connects to Hasura using the "public" role + */ export const $public = new CoreDomainClient({ targetURL: process.env.HASURA_GRAPHQL_URL!, }); @@ -31,12 +36,12 @@ export const getClient = () => { message: "Missing user context", }); - const client = new CoreDomainClient({ + const $client = new CoreDomainClient({ targetURL: process.env.HASURA_GRAPHQL_URL!, auth: { jwt: store.user.jwt, }, }); - return client; + return $client; }; diff --git a/api.planx.uk/hasura/index.ts b/api.planx.uk/hasura/index.ts index 2b6b085003..0837de56c4 100644 --- a/api.planx.uk/hasura/index.ts +++ b/api.planx.uk/hasura/index.ts @@ -9,9 +9,4 @@ const adminGraphQLClient = new GraphQLClient(process.env.HASURA_GRAPHQL_URL!, { }, }); -/** - * Connect to Hasura using the "Public" role - */ -const publicGraphQLClient = new GraphQLClient(process.env.HASURA_GRAPHQL_URL!); - -export { adminGraphQLClient, publicGraphQLClient }; +export { adminGraphQLClient }; diff --git a/api.planx.uk/inviteToPay/createPaymentSendEvents.ts b/api.planx.uk/inviteToPay/createPaymentSendEvents.ts index 7e2b7e05ef..8a6bee74b9 100644 --- a/api.planx.uk/inviteToPay/createPaymentSendEvents.ts +++ b/api.planx.uk/inviteToPay/createPaymentSendEvents.ts @@ -1,7 +1,7 @@ import { ComponentType } from "@opensystemslab/planx-core/types"; import { NextFunction, Request, Response } from "express"; import { gql } from "graphql-request"; -import { $admin } from "../client"; +import { $api } from "../client"; import { adminGraphQLClient as adminClient } from "../hasura"; import { ScheduledEventResponse, @@ -40,7 +40,7 @@ const createPaymentSendEvents = async ( const now = new Date(); const combinedResponse: CombinedResponse = {}; - const session = await $admin.getSessionById(payload.sessionId); + const session = await $api.getSessionById(payload.sessionId); if (!session) { return next({ status: 400, diff --git a/api.planx.uk/inviteToPay/inviteToPay.ts b/api.planx.uk/inviteToPay/inviteToPay.ts index 96d7fbd7f0..8d781ca6d5 100644 --- a/api.planx.uk/inviteToPay/inviteToPay.ts +++ b/api.planx.uk/inviteToPay/inviteToPay.ts @@ -2,7 +2,7 @@ import { NextFunction, Request, Response } from "express"; import type { PaymentRequest, KeyPath } from "@opensystemslab/planx-core/types"; import { ServerError } from "../errors"; -import { $admin } from "../client"; +import { $api } from "../client"; export async function inviteToPay( req: Request, @@ -39,7 +39,7 @@ export async function inviteToPay( } // lock session before creating a payment request - const locked = await $admin.lockSession(sessionId); + const locked = await $api.lockSession(sessionId); if (locked === null) { return next( new ServerError({ @@ -63,7 +63,7 @@ export async function inviteToPay( let paymentRequest: PaymentRequest | undefined; try { - paymentRequest = await $admin.createPaymentRequest({ + paymentRequest = await $api.createPaymentRequest({ sessionId, applicantName, payeeName, @@ -72,7 +72,7 @@ export async function inviteToPay( }); } catch (e: unknown) { // revert the session lock on failure - await $admin.unlockSession(sessionId); + await $api.unlockSession(sessionId); return next( new ServerError({ message: diff --git a/api.planx.uk/inviteToPay/sendConfirmationEmail.ts b/api.planx.uk/inviteToPay/sendConfirmationEmail.ts index 42f5d93f44..06a5cca1f3 100644 --- a/api.planx.uk/inviteToPay/sendConfirmationEmail.ts +++ b/api.planx.uk/inviteToPay/sendConfirmationEmail.ts @@ -1,4 +1,4 @@ -import { $public, $admin } from "../client"; +import { $public, $api } from "../client"; import { sendEmail } from "../notify"; import { gql } from "graphql-request"; import { convertSlugToName } from "../saveAndReturn/utils"; @@ -85,7 +85,7 @@ async function getDataForPayeeAndAgentEmails( applicantName: string; }[]; }[]; - } = await $admin.client.request(query, { sessionId }); + } = await $api.client.request(query, { sessionId }); const data = response.lowcal_sessions[0]; const { emailReplyToId, helpEmail, helpOpeningHours, helpPhone } = data.flow.team.notifyPersonalisation; diff --git a/api.planx.uk/inviteToPay/sendPaymentEmail.test.ts b/api.planx.uk/inviteToPay/sendPaymentEmail.test.ts index a4fff2b3ec..cc73b3cffc 100644 --- a/api.planx.uk/inviteToPay/sendPaymentEmail.test.ts +++ b/api.planx.uk/inviteToPay/sendPaymentEmail.test.ts @@ -6,16 +6,11 @@ import { validatePaymentRequestNotFoundQueryMock, validatePaymentRequestQueryMock, } from "../tests/mocks/inviteToPayMocks"; +import { CoreDomainClient } from "@opensystemslab/planx-core"; -jest.mock("@opensystemslab/planx-core", () => { - return { - CoreDomainClient: jest.fn().mockImplementation(() => ({ - formatRawProjectTypes: jest - .fn() - .mockResolvedValue(["New office premises"]), - })), - }; -}); +jest + .spyOn(CoreDomainClient.prototype, "formatRawProjectTypes") + .mockResolvedValue("New office premises"); const TEST_PAYMENT_REQUEST_ID = "09655c28-3f34-4619-9385-cd57312acc44"; diff --git a/api.planx.uk/modules/auth/service.ts b/api.planx.uk/modules/auth/service.ts index db8dd538f1..8488c22e0f 100644 --- a/api.planx.uk/modules/auth/service.ts +++ b/api.planx.uk/modules/auth/service.ts @@ -1,9 +1,9 @@ import { sign } from "jsonwebtoken"; -import { $admin } from "../../client"; +import { $api } from "../../client"; import { User, Role } from "@opensystemslab/planx-core/types"; export const buildJWT = async (email: string): Promise => { - const user = await $admin.user.getByEmail(email); + const user = await $api.user.getByEmail(email); if (!user) return; const data = { @@ -16,6 +16,17 @@ export const buildJWT = async (email: string): Promise => { return jwt; }; +export const buildJWTForAPIRole = () => + sign( + { + "https://hasura.io/jwt/claims": { + "x-hasura-allowed-roles": ["api"], + "x-hasura-default-role": "api", + }, + }, + process.env.JWT_SECRET!, + ); + const generateHasuraClaimsForUser = (user: User) => ({ "x-hasura-allowed-roles": getAllowedRolesForUser(user), "x-hasura-default-role": getDefaultRoleForUser(user), diff --git a/api.planx.uk/modules/webhooks/service/sanitiseApplicationData/operations.test.ts b/api.planx.uk/modules/webhooks/service/sanitiseApplicationData/operations.test.ts index 9e178f682e..eba539c049 100644 --- a/api.planx.uk/modules/webhooks/service/sanitiseApplicationData/operations.test.ts +++ b/api.planx.uk/modules/webhooks/service/sanitiseApplicationData/operations.test.ts @@ -31,7 +31,7 @@ const mockRunSQL = runSQL as jest.MockedFunction; const mockFindSession = jest.fn(); jest.mock("../../../../client", () => { return { - $admin: { + $api: { session: { find: jest.fn().mockImplementation(() => mockFindSession()), }, diff --git a/api.planx.uk/modules/webhooks/service/sendNotification/index.test.ts b/api.planx.uk/modules/webhooks/service/sendNotification/index.test.ts index b7d274c741..4fea47f742 100644 --- a/api.planx.uk/modules/webhooks/service/sendNotification/index.test.ts +++ b/api.planx.uk/modules/webhooks/service/sendNotification/index.test.ts @@ -2,7 +2,7 @@ import supertest from "supertest"; import app from "../../../../server"; import SlackNotify from "slack-notify"; import { BOPSBody, EmailBody, UniformBody } from "./types"; -import { $admin } from "../../../../client"; +import { $api } from "../../../../client"; import { CoreDomainClient } from "@opensystemslab/planx-core"; const mockSessionWithFee = { @@ -36,7 +36,7 @@ const mockSessionWithResubmissionExemption = { }; jest.mock("../../../../client"); -const mockAdmin = jest.mocked($admin); +const mockAdmin = jest.mocked($api); const mockSend = jest.fn(); jest.mock("slack-notify", () => diff --git a/api.planx.uk/modules/webhooks/service/sendNotification/index.ts b/api.planx.uk/modules/webhooks/service/sendNotification/index.ts index 57ffcc6862..8d70f6a84b 100644 --- a/api.planx.uk/modules/webhooks/service/sendNotification/index.ts +++ b/api.planx.uk/modules/webhooks/service/sendNotification/index.ts @@ -7,7 +7,7 @@ import { EventType, UniformEventData, } from "./types"; -import { $admin } from "../../../../client"; +import { $api } from "../../../../client"; export const sendSlackNotification = async ( data: EventData, @@ -51,7 +51,7 @@ const getSessionIdFromEvent = (data: EventData, type: EventType) => })[type]; const getExemptionStatusesForSession = async (sessionId: string) => { - const session = await $admin.session.find(sessionId); + const session = await $api.session.find(sessionId); if (!session) throw Error(`Unable to find session with ID ${sessionId}`); const passport = new Passport(session.data.passport); diff --git a/api.planx.uk/notify/notify.ts b/api.planx.uk/notify/notify.ts index 5d8315178e..2824506575 100644 --- a/api.planx.uk/notify/notify.ts +++ b/api.planx.uk/notify/notify.ts @@ -1,11 +1,7 @@ -import { GraphQLClient } from "graphql-request"; import { NotifyClient } from "notifications-node-client"; -import { - adminGraphQLClient as adminClient, - publicGraphQLClient as publicClient, -} from "../hasura"; import { softDeleteSession } from "../saveAndReturn/utils"; import { NotifyConfig } from "../types"; +import { $api, $public } from "../client"; const notifyClient = new NotifyClient(process.env.GOVUK_NOTIFY_API_KEY); @@ -84,7 +80,7 @@ const sendEmail = async ( } }; -const getClientForTemplate = (template: Template): GraphQLClient => - template in privateEmailTemplates ? adminClient : publicClient; +const getClientForTemplate = (template: Template) => + template in privateEmailTemplates ? $api.client : $public.client; export { sendEmail, getClientForTemplate }; diff --git a/api.planx.uk/notify/routeSendEmailRequest.test.ts b/api.planx.uk/notify/routeSendEmailRequest.test.ts index 748fd7e4e2..35bfbcbfb7 100644 --- a/api.planx.uk/notify/routeSendEmailRequest.test.ts +++ b/api.planx.uk/notify/routeSendEmailRequest.test.ts @@ -8,20 +8,15 @@ import { mockSoftDeleteLowcalSession, mockValidateSingleSessionRequest, } from "../tests/mocks/saveAndReturnMocks"; +import { CoreDomainClient } from "@opensystemslab/planx-core"; // https://docs.notifications.service.gov.uk/node.html#email-addresses const TEST_EMAIL = "simulate-delivered@notifications.service.gov.uk"; const SAVE_ENDPOINT = "/send-email/save"; -jest.mock("@opensystemslab/planx-core", () => { - return { - CoreDomainClient: jest.fn().mockImplementation(() => ({ - formatRawProjectTypes: jest - .fn() - .mockResolvedValue(["New office premises"]), - })), - }; -}); +jest + .spyOn(CoreDomainClient.prototype, "formatRawProjectTypes") + .mockResolvedValue("New office premises"); describe("Send Email endpoint", () => { beforeEach(() => { @@ -93,7 +88,7 @@ describe("Send Email endpoint", () => { name: "ValidateSingleSessionRequest", data: { flows_by_pk: mockFlow, - lowcal_sessions: [], + lowcalSessions: [], }, }); @@ -258,7 +253,7 @@ describe("Setting up send email events", () => { name: "ValidateSingleSessionRequest", data: { flows_by_pk: mockFlow, - lowcal_sessions: [{ ...mockLowcalSession, has_user_saved: true }], + lowcalSessions: [{ ...mockLowcalSession, has_user_saved: true }], }, matchOnVariables: false, }); diff --git a/api.planx.uk/pay/pay.ts b/api.planx.uk/pay/pay.ts index db7a529e71..ee7f818969 100644 --- a/api.planx.uk/pay/pay.ts +++ b/api.planx.uk/pay/pay.ts @@ -5,7 +5,7 @@ import SlackNotify from "slack-notify"; import { logPaymentStatus } from "../send/helpers"; import { usePayProxy } from "./proxy"; import { addGovPayPaymentIdToPaymentRequest } from "../inviteToPay"; -import { $admin } from "../client"; +import { $api } from "../client"; import { ServerError } from "../errors"; import { GovUKPayment } from "@opensystemslab/planx-core/types"; @@ -44,7 +44,7 @@ export async function makePaymentViaProxy( ); } - const session = await $admin.session.findDetails(sessionId); + const session = await $api.session.findDetails(sessionId); if (session?.lockedAt) { return next( diff --git a/api.planx.uk/saveAndReturn/utils.ts b/api.planx.uk/saveAndReturn/utils.ts index 2de72f806f..b9409058e0 100644 --- a/api.planx.uk/saveAndReturn/utils.ts +++ b/api.planx.uk/saveAndReturn/utils.ts @@ -92,7 +92,10 @@ const validateSingleSessionRequest = async ( try { const query = gql` query ValidateSingleSessionRequest($sessionId: uuid!) { - lowcal_sessions(where: { id: { _eq: $sessionId } }, limit: 1) { + lowcalSessions: lowcal_sessions( + where: { id: { _eq: $sessionId } } + limit: 1 + ) { id data created_at @@ -112,8 +115,12 @@ const validateSingleSessionRequest = async ( const client = getClientForTemplate(template); const headers = getSaveAndReturnPublicHeaders(sessionId, email); const { - lowcal_sessions: [session], - } = await client.request(query, { sessionId }, headers); + lowcalSessions: [session], + } = await client.request<{ lowcalSessions: LowCalSession[] }>( + query, + { sessionId }, + headers, + ); if (!session) throw Error(`Unable to find session: ${sessionId}`); diff --git a/api.planx.uk/send/bops.ts b/api.planx.uk/send/bops.ts index 5ce11c974a..b0e07058f4 100644 --- a/api.planx.uk/send/bops.ts +++ b/api.planx.uk/send/bops.ts @@ -3,7 +3,7 @@ import { adminGraphQLClient as adminClient } from "../hasura"; import { markSessionAsSubmitted } from "../saveAndReturn/utils"; import { NextFunction, Request, Response } from "express"; import { gql } from "graphql-request"; -import { $admin } from "../client"; +import { $api } from "../client"; import { ServerError } from "../errors"; interface SendToBOPSRequest { @@ -70,7 +70,7 @@ const sendToBOPS = async (req: Request, res: Response, next: NextFunction) => { ); } const target = `${bopsSubmissionURL}/api/v1/planning_applications`; - const { exportData } = await $admin.export.bopsPayload(payload?.sessionId); + const { exportData } = await $api.export.bopsPayload(payload?.sessionId); try { const bopsResponse = await axios({ diff --git a/api.planx.uk/send/exportZip.test.ts b/api.planx.uk/send/exportZip.test.ts index cb31258db3..39ce06e6b2 100644 --- a/api.planx.uk/send/exportZip.test.ts +++ b/api.planx.uk/send/exportZip.test.ts @@ -57,7 +57,7 @@ const mockGenerateOneAppXML = jest jest.mock("../client", () => { return { - $admin: { + $api: { generateOneAppXML: () => mockGenerateOneAppXML(), getDocumentTemplateNamesForSession: jest .fn() diff --git a/api.planx.uk/send/exportZip.ts b/api.planx.uk/send/exportZip.ts index 000c70c546..9c2e6ffbe6 100644 --- a/api.planx.uk/send/exportZip.ts +++ b/api.planx.uk/send/exportZip.ts @@ -1,6 +1,6 @@ import os from "os"; import path from "path"; -import { $admin } from "../client"; +import { $api } from "../client"; import { stringify } from "csv-stringify"; import fs from "fs"; import str from "string-to-stream"; @@ -28,7 +28,7 @@ export async function buildSubmissionExportZip({ const zip = new ExportZip(sessionId); // fetch session data - const sessionData = await $admin.session.find(sessionId); + const sessionData = await $api.session.find(sessionId); if (!sessionData) { throw new Error( `session ${sessionId} not found so could not create Uniform submission zip`, @@ -39,7 +39,7 @@ export async function buildSubmissionExportZip({ // add OneApp XML to the zip if (includeOneAppXML) { try { - const xml = await $admin.generateOneAppXML(sessionId); + const xml = await $api.generateOneAppXML(sessionId); const xmlStream = str(xml.trim()); await zip.addStream({ name: "proposal.xml", // must be named "proposal.xml" to be processed by Uniform @@ -65,8 +65,7 @@ export async function buildSubmissionExportZip({ } // generate csv data - const { responses, redactedResponses } = - await $admin.export.csvData(sessionId); + const { responses, redactedResponses } = await $api.export.csvData(sessionId); // write csv to the zip try { @@ -84,7 +83,7 @@ export async function buildSubmissionExportZip({ // add template files to zip const templateNames = - await $admin.getDocumentTemplateNamesForSession(sessionId); + await $api.getDocumentTemplateNamesForSession(sessionId); for (const templateName of templateNames || []) { try { const isTemplateSupported = hasRequiredDataForTemplate({ diff --git a/api.planx.uk/send/uniform.ts b/api.planx.uk/send/uniform.ts index df4a74eca9..4140a14146 100644 --- a/api.planx.uk/send/uniform.ts +++ b/api.planx.uk/send/uniform.ts @@ -6,7 +6,7 @@ import fs from "fs"; import { adminGraphQLClient as adminClient } from "../hasura"; import { markSessionAsSubmitted } from "../saveAndReturn/utils"; import { gql } from "graphql-request"; -import { $admin } from "../client"; +import { $api } from "../client"; import { buildSubmissionExportZip } from "./exportZip"; interface UniformClient { @@ -373,7 +373,7 @@ const createUniformApplicationAuditRecord = async ({ localAuthority: string; submissionDetails: UniformSubmissionResponse; }): Promise => { - const xml = await $admin.generateOneAppXML(payload?.sessionId); + const xml = await $api.generateOneAppXML(payload?.sessionId); const application: Record< "insert_uniform_applications_one", diff --git a/api.planx.uk/session/files.test.ts b/api.planx.uk/session/files.test.ts index 12a6adcceb..824df21f6e 100644 --- a/api.planx.uk/session/files.test.ts +++ b/api.planx.uk/session/files.test.ts @@ -4,7 +4,7 @@ const mockFindSession = jest.fn(); jest.mock("../client", () => { return { - $admin: { + $api: { session: { find: jest.fn().mockImplementation(() => mockFindSession()), }, diff --git a/api.planx.uk/session/files.ts b/api.planx.uk/session/files.ts index 2cb5d162c3..7f5550afc5 100644 --- a/api.planx.uk/session/files.ts +++ b/api.planx.uk/session/files.ts @@ -1,10 +1,10 @@ import { Passport } from "@opensystemslab/planx-core"; -import { $admin } from "../client"; +import { $api } from "../client"; export const getFilesForSession = async ( sessionId: string, ): Promise => { - const session = await $admin.session.find(sessionId); + const session = await $api.session.find(sessionId); if (!session?.data.passport?.data) return []; const files = new Passport(session.data.passport).files(); diff --git a/api.planx.uk/tests/mocks/saveAndReturnMocks.ts b/api.planx.uk/tests/mocks/saveAndReturnMocks.ts index 3a92bc01fc..02393437d2 100644 --- a/api.planx.uk/tests/mocks/saveAndReturnMocks.ts +++ b/api.planx.uk/tests/mocks/saveAndReturnMocks.ts @@ -144,7 +144,7 @@ export const mockValidateSingleSessionRequest = { name: "ValidateSingleSessionRequest", data: { flows_by_pk: mockFlow, - lowcal_sessions: [mockLowcalSession], + lowcalSessions: [mockLowcalSession], }, variables: { sessionId: mockLowcalSession.id, diff --git a/api.planx.uk/types.ts b/api.planx.uk/types.ts index ee8d0c46c9..b7e17693f8 100644 --- a/api.planx.uk/types.ts +++ b/api.planx.uk/types.ts @@ -75,7 +75,7 @@ export interface LowCalSession { has_user_saved: boolean; flow: { slug: string; - team?: Team; + team: Team; }; lockedAt?: string; paymentRequests?: Pick[];