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/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/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/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/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/server.ts b/api.planx.uk/server.ts index e6d5910e9f..6264fdf11b 100644 --- a/api.planx.uk/server.ts +++ b/api.planx.uk/server.ts @@ -40,7 +40,6 @@ import { } from "./modules/auth/middleware"; import airbrake from "./airbrake"; -import { adminGraphQLClient as adminClient } from "./hasura"; import { sendEmailLimiter, apiLimiter } from "./rateLimit"; import { privateDownloadController, @@ -73,6 +72,7 @@ import webhookRoutes from "./modules/webhooks/routes"; import analyticsRoutes from "./modules/analytics/routes"; import { useSwaggerDocs } from "./docs"; import { Role } from "@opensystemslab/planx-core/types"; +import { $public } from "./client"; const router = express.Router(); @@ -279,13 +279,21 @@ app.get( copyPortalAsFlow, ); -// unauthenticated because accessing flow schema only, no user data +interface FlowSchema { + node: string; + type: string; + text: string; + planx_variable: string; +} + app.get("/flows/:flowId/download-schema", async (req, res, next) => { try { - const schema = await adminClient.request( + const { flowSchema } = await $public.client.request<{ + flowSchema: FlowSchema[]; + }>( gql` query ($flow_id: String!) { - get_flow_schema(args: { published_flow_id: $flow_id }) { + flowSchema: get_flow_schema(args: { published_flow_id: $flow_id }) { node type text @@ -296,7 +304,7 @@ app.get("/flows/:flowId/download-schema", async (req, res, next) => { { flow_id: req.params.flowId }, ); - if (schema.get_flow_schema.length < 1) { + if (!flowSchema.length) { next({ status: 404, message: @@ -304,7 +312,7 @@ app.get("/flows/:flowId/download-schema", async (req, res, next) => { }); } else { // build a CSV and stream it - stringify(schema.get_flow_schema, { header: true }).pipe(res); + stringify(flowSchema, { header: true }).pipe(res); res.header("Content-type", "text/csv"); res.attachment(`${req.params.flowId}.csv`); 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();