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: platform api v1 - get and update webhook settings #6608

Merged
merged 16 commits into from
Aug 17, 2023
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 2 additions & 0 deletions shared/constants/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export const STORAGE_FORM_SETTINGS_FIELDS = <const>[
'business',
]

export const WEBHOOK_SETTINGS_FIELDS = <const>['responseMode', 'webhook']

export const ADMIN_FORM_META_FIELDS = <const>[
'admin',
'title',
Expand Down
10 changes: 10 additions & 0 deletions shared/types/form/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,18 @@ export type StorageFormSettings = Pick<

export type FormSettings = EmailFormSettings | StorageFormSettings

export type FormWebhookSettings = Pick<FormSettings, 'webhook'>

export type FormWebhookResponseModeSettings = Pick<
FormSettings,
'webhook' | 'responseMode'
>
export type SettingsUpdateDto = PartialDeep<FormSettings>

export type WebhookSettingsUpdateDto = Pick<SettingsUpdateDto, 'webhook'> & {
userEmail: string
}

/**
* Misnomer. More of a public form auth session.
*/
Expand Down
1 change: 1 addition & 0 deletions shared/types/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const UserBase = z.object({
keyHash: z.string(),
createdAt: z.date(),
lastUsedAt: z.date().optional(),
isPlatform: z.string().optional(),
Copy link
Contributor

@tshuli tshuli Aug 9, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a thought: if platform users are also a kind of formsg user, will this allow platforms to modify each other's user settings in future (e.g. contact number)? I was wondering if platform tokens could be issued without creating a user account for the platform

not a super major point, just food for thought

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if platform users are also a kind of formsg user, will this allow platforms to modify each other's user settings in future (e.g. contact number)?

Yes can, if we create an endpoint which allows them to modify other users' contact number settings. We currently only have get and update webhook settings (from this PR), but we might add more endpoints in the future depending on the need. This is potentially giving them a lot of power though, so might have to think about permissions as well. I'm intentionally ignoring that right now as this is just an MVP, but recognise that this is a possible future extension!

I was wondering if platform tokens could be issued without creating a user account for the platform

And yes valid point too, worth considering hm

wanlingt marked this conversation as resolved.
Show resolved Hide resolved
})
.optional(),
})
Expand Down
6 changes: 6 additions & 0 deletions src/app/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,12 @@ export const optionalVarsSchema: Schema<IOptionalVarsSchema> = {
default: 100,
env: 'PUBLIC_API_RATE_LIMIT',
},
platformApi: {
doc: 'Per-minute, per-IP, per-instance request limit for platform APIs',
wanlingt marked this conversation as resolved.
Show resolved Hide resolved
format: 'int',
default: 100,
env: 'PLATFORM_API_RATE_LIMIT',
},
},
reactMigration: {
useFetchForSubmissions: {
Expand Down
12 changes: 12 additions & 0 deletions src/app/models/form.server.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
MB,
STORAGE_FORM_SETTINGS_FIELDS,
STORAGE_PUBLIC_FORM_FIELDS,
WEBHOOK_SETTINGS_FIELDS,
} from '../../../shared/constants'
import {
AdminDashboardFormMetaDto,
Expand All @@ -36,6 +37,8 @@ import {
FormSettings,
FormStartPage,
FormStatus,
FormWebhookResponseModeSettings,
FormWebhookSettings,
LogicConditionState,
LogicDto,
LogicType,
Expand Down Expand Up @@ -700,6 +703,15 @@ const compileFormModel = (db: Mongoose): IFormModel => {
return formSettings
}

FormDocumentSchema.methods.getWebhookAndResponseModeSettings =
function (): FormWebhookSettings {
const formSettings = pick(
this,
WEBHOOK_SETTINGS_FIELDS,
) as FormWebhookResponseModeSettings
return formSettings
}

FormDocumentSchema.methods.getPublicView = function (): PublicForm {
const basePublicView =
this.responseMode === FormResponseMode.Encrypt
Expand Down
3 changes: 3 additions & 0 deletions src/app/models/user.server.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ const compileUserModel = (db: Mongoose) => {
},
createdAt: Date,
lastUsedAt: Date,
isPlatform: {
type: Boolean,
},
},
},
{
Expand Down
46 changes: 46 additions & 0 deletions src/app/modules/auth/auth.middlewares.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ import { StatusCodes } from 'http-status-codes'
import { createLoggerWithLabel } from '../../config/logger'
import { createReqMeta } from '../../utils/request'
import { ControllerHandler } from '../core/core.types'
import { UNAUTHORIZED_USER_MESSAGE } from '../user/user.constant'
import { getPopulatedUserById } from '../user/user.service'

import { getUserByApiKey } from './auth.service'
import {
getUserIdFromSession,
isCronPaymentAuthValid,
isUserInSession,
mapRouteError,
mapRoutePublicApiError,
} from './auth.utils'

Expand Down Expand Up @@ -182,3 +186,45 @@ export const authenticateApiKey: ControllerHandler = (req, res, next) => {
return res.status(statusCode).json({ message: errorMessage })
})
}

/**
* Middleware that only allows users with a valid bearer token and isPlatform flag to pass through to the next handler
*/
const isPlatformApiUser: ControllerHandler = (req, res, next) => {
const sessionUserId = getUserIdFromSession(req.session)
if (!sessionUserId) {
return res.status(StatusCodes.UNAUTHORIZED).json(UNAUTHORIZED_USER_MESSAGE)
}
return getPopulatedUserById(sessionUserId)
.map((retrievedUser) => {
if (!retrievedUser) {
return res
.status(StatusCodes.UNAUTHORIZED)
.json(UNAUTHORIZED_USER_MESSAGE)
}
if (!retrievedUser.apiToken?.isPlatform) {
return res
.status(StatusCodes.UNAUTHORIZED)
.json({ message: 'User is not a platform' })
}
return next()
})
.mapErr((error) => {
logger.error({
message: 'Error occurred whilst retrieving user',
meta: {
action: 'isPlatformApiUser',
userId: sessionUserId,
},
error,
})

const { errorMessage, statusCode } = mapRouteError(error)
return res.status(statusCode).json({ message: errorMessage })
})
}

export const authenticateApiKeyPlatformUser = [
authenticateApiKey,
isPlatformApiUser,
] as ControllerHandler[]
117 changes: 116 additions & 1 deletion src/app/modules/form/admin-form/admin-form.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import {
FormLogoState,
FormResponseMode,
FormSettings,
FormWebhookResponseModeSettings,
FormWebhookSettings,
LogicConditionState,
LogicDto,
LogicIfValue,
Expand All @@ -41,6 +43,7 @@ import {
SmsCountsDto,
StartPageUpdateDto,
SubmissionCountQueryDto,
WebhookSettingsUpdateDto,
} from '../../../../../shared/types'
import { IForm, IFormDocument, IPopulatedForm } from '../../../../types'
import {
Expand Down Expand Up @@ -89,7 +92,10 @@ import {
PREVIEW_SINGPASS_UINFIN,
} from './admin-form.constants'
import { EditFieldError, GoGovError } from './admin-form.errors'
import { updateSettingsValidator } from './admin-form.middlewares'
import {
updateSettingsValidator,
updateWebhookSettingsValidator,
} from './admin-form.middlewares'
import * as AdminFormService from './admin-form.service'
import { PermissionLevel } from './admin-form.types'
import { mapRouteError, verifyValidUnicodeString } from './admin-form.utils'
Expand Down Expand Up @@ -1295,6 +1301,49 @@ export const _handleUpdateSettings: ControllerHandler<
})
}

export const _handleUpdateWebhookSettings: ControllerHandler<
{ formId: string },
FormWebhookSettings | ErrorDto,
WebhookSettingsUpdateDto & { userEmail: string }
> = (req, res) => {
const { formId } = req.params
const { userEmail } = req.body
const settingsToPatch = req.body
wanlingt marked this conversation as resolved.
Show resolved Hide resolved

// Step 1: Retrieve currently logged in user.
return UserService.findUserByEmail(userEmail)
.andThen((user) =>
// Step 2: Retrieve form with write permission check.
AuthService.getFormAfterPermissionChecks({
user,
formId,
level: PermissionLevel.Write,
}),
)
.andThen((retrievedForm) =>
AdminFormService.updateFormSettings(retrievedForm, settingsToPatch),
)
.map((updatedSettings) => {
const webhookSettings = { webhook: updatedSettings.webhook }
res.status(StatusCodes.OK).json(webhookSettings)
})
.mapErr((error) => {
logger.error({
message: 'Error occurred when updating form settings',
meta: {
action: 'handleUpdateWebhookSettings',
...createReqMeta(req),
userEmail,
formId,
settingsKeysToUpdate: Object.keys(settingsToPatch),
},
error,
})
const { errorMessage, statusCode } = mapRouteError(error)
return res.status(statusCode).json({ message: errorMessage })
})
}

/**
* Handler for PATCH /forms/:formId/settings.
* @security session
Expand All @@ -1315,6 +1364,26 @@ export const handleUpdateSettings = [
_handleUpdateSettings,
] as ControllerHandler[]

/**
* Handler for PATCH api/platform/v1/admin/forms/:formId/webhooksettings.
wanlingt marked this conversation as resolved.
Show resolved Hide resolved
* @security session
*
* @returns 200 with updated form settings
* @returns 400 when body is malformed; can happen when email parameter is passed for encrypt-mode forms
* @returns 403 when current user does not have permissions to update form settings
* @returns 404 when form to update settings for cannot be found
* @returns 409 when saving form settings incurs a conflict in the database
* @returns 410 when updating settings for archived form
* @returns 413 when updating settings causes form to be too large to be saved in the database
* @returns 422 when an invalid settings update is attempted on the form
* @returns 422 when user in session cannot be retrieved from the database
* @returns 500 when database error occurs
*/
export const handleUpdateWebhookSettings = [
updateWebhookSettingsValidator,
_handleUpdateWebhookSettings,
] as ControllerHandler[]

/**
* NOTE: Exported for testing.
* Private handler for PUT /forms/:formId/fields/:fieldId
Expand Down Expand Up @@ -1417,6 +1486,52 @@ export const handleGetSettings: ControllerHandler<
})
}

/**
* Handler for GET api/platform/v1/admin/forms/:formId/webhookSettings.
wanlingt marked this conversation as resolved.
Show resolved Hide resolved
*
* @returns 200 with latest webhook and response mode settings
* @returns 401 when current user is not logged in
* @returns 403 when current user does not have permissions to obtain form settings
* @returns 404 when form to retrieve settings for cannot be found
* @returns 409 when saving form settings incurs a conflict in the database
wanlingt marked this conversation as resolved.
Show resolved Hide resolved
* @returns 500 when database error occurs
*/
export const handleGetWebhookSettings: ControllerHandler<
wanlingt marked this conversation as resolved.
Show resolved Hide resolved
{ formId: string },
FormWebhookResponseModeSettings | ErrorDto,
{ userEmail: string }
> = (req, res) => {
const { formId } = req.params
const { userEmail } = req.body

return UserService.findUserByEmail(userEmail)
.andThen((user) =>
// Retrieve form for settings as well as for permissions checking
FormService.retrieveFullFormById(formId).map((form) => ({
form,
user,
})),
)
.andThen(AuthService.checkFormForPermissions(PermissionLevel.Read))
.map((form) =>
res.status(StatusCodes.OK).json(form.getWebhookAndResponseModeSettings()),
)
.mapErr((error) => {
logger.error({
message: 'Error occurred when retrieving form settings',
meta: {
action: 'handleGetWebhookSettings',
...createReqMeta(req),
userEmail,
formId,
},
error,
})
const { errorMessage, statusCode } = mapRouteError(error)
return res.status(statusCode).json({ message: errorMessage })
})
}

/**
* Handler for POST /v2/submissions/encrypt/preview/:formId.
* @security session
Expand Down
21 changes: 17 additions & 4 deletions src/app/modules/form/admin-form/admin-form.middlewares.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@ import {
FormAuthType,
FormStatus,
SettingsUpdateDto,
WebhookSettingsUpdateDto,
} from '../../../../../shared/types'

import { verifyValidUnicodeString } from './admin-form.utils'

const webhookSettingsValidator = Joi.object({
url: Joi.string().uri().allow(''),
isRetryEnabled: Joi.boolean(),
}).min(1)

/**
* Joi validator for PATCH /forms/:formId/settings route.
*/
Expand All @@ -25,10 +31,7 @@ export const updateSettingsValidator = celebrate({
status: Joi.string().valid(...Object.values(FormStatus)),
submissionLimit: Joi.number().allow(null),
title: Joi.string(),
webhook: Joi.object({
url: Joi.string().uri().allow(''),
isRetryEnabled: Joi.boolean(),
}).min(1),
webhook: webhookSettingsValidator,
business: Joi.object({
address: Joi.string().allow(''),
gstRegNo: Joi.string().allow(''),
Expand All @@ -38,3 +41,13 @@ export const updateSettingsValidator = celebrate({
.min(1)
.custom((value, helpers) => verifyValidUnicodeString(value, helpers)),
})

/**
* Joi validator for PATCH api/platform/v1/admin/forms/:formId/webhookSettings route.
*/
export const updateWebhookSettingsValidator = celebrate({
[Segments.BODY]: Joi.object<WebhookSettingsUpdateDto>({
userEmail: Joi.string(),
wanlingt marked this conversation as resolved.
Show resolved Hide resolved
webhook: webhookSettingsValidator,
}),
})
2 changes: 2 additions & 0 deletions src/app/routes/api/api.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { Router } from 'express'

import { errorHandlerMiddlewares } from '../../loaders/express/error-handler'

import { V1PlatformRouter } from './platform/v1'
import { V1PublicRouter } from './public/v1'
import { V3Router } from './v3'

export const ApiRouter = Router()

ApiRouter.use('/v3', V3Router)
ApiRouter.use('/public/v1', V1PublicRouter)
ApiRouter.use('/platform/v1', V1PlatformRouter)
tshuli marked this conversation as resolved.
Show resolved Hide resolved
ApiRouter.use(errorHandlerMiddlewares())
7 changes: 7 additions & 0 deletions src/app/routes/api/platform/v1/admin/admin.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Router } from 'express'

import { AdminFormsPlatformRouter } from './forms'

export const AdminRouter = Router()

AdminRouter.use('/forms', AdminFormsPlatformRouter)
Loading
Loading