Skip to content

Commit

Permalink
feat: platform api v1 - get and update webhook settings (#6608)
Browse files Browse the repository at this point in the history
* feat: add isPlatform property to user model

* feat: add new endpoint for platform api routes

* feat: add routes to get and patch webhook settings

* feat: add rate limit config for platform API

* doc: update status codes

* fix: refine validator

* feat: create api user types

* fix: remove logging of api token

* feat: add logging to admin-form.controller

* fix: change isPlatform to boolean type

* fix: use POST instead of GET and add validator

* fix: allow non-platform users to exit isPlatformApiUser middleware

* fix: move routes from /platform/v1 to /public/v1

* fix: remove all references to /platform/v1

* fix: catch error from missing user
  • Loading branch information
wanlingt authored Aug 17, 2023
1 parent 940cc26 commit 753d1f2
Show file tree
Hide file tree
Showing 15 changed files with 442 additions and 11 deletions.
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 @@ -223,8 +223,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<FormSettings, '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.boolean().optional(),
})
.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',
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 @@ -708,6 +711,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 @@ -85,6 +85,9 @@ const compileUserModel = (db: Mongoose) => {
},
createdAt: Date,
lastUsedAt: Date,
isPlatform: {
type: Boolean,
},
},
},
},
Expand Down
87 changes: 86 additions & 1 deletion 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 * as UserService from '../user/user.service'

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

Expand Down Expand Up @@ -163,7 +167,6 @@ export const authenticateApiKey: ControllerHandler = (req, res, next) => {
meta: {
action: 'authenticateApiKey',
userId: apiKeyMatch.groups.userId,
token: bearerMatch.groups.token,
},
})
return getUserByApiKey(apiKeyMatch.groups.userId, bearerMatch.groups.token)
Expand All @@ -182,3 +185,85 @@ export const authenticateApiKey: ControllerHandler = (req, res, next) => {
return res.status(statusCode).json({ message: errorMessage })
})
}

/**
* Middleware that checks if user is a platform user
*/
const isPlatformApiUser: ControllerHandler<
unknown,
unknown,
{ userEmail: string }
> = (req, res, next) => {
const { userEmail } = req.body
const sessionUserId = getUserIdFromSession(req.session)
if (!sessionUserId) {
return res.status(StatusCodes.UNAUTHORIZED).json(UNAUTHORIZED_USER_MESSAGE)
}
return UserService.getPopulatedApiUserById(sessionUserId)
.map((retrievedUser) => {
if (!retrievedUser) {
return res
.status(StatusCodes.UNAUTHORIZED)
.json(UNAUTHORIZED_USER_MESSAGE)
}
if (!retrievedUser.apiToken?.isPlatform || !userEmail) {
// Exit this middleware if
// 1. User is not a platform
// 2. User is a platform but has no userEmail provided
return next()
} else {
return UserService.findUserByEmail(userEmail)
.map((emailUser) => {
if (!emailUser) {
return res
.status(StatusCodes.UNPROCESSABLE_ENTITY)
.json('User not found')
}
logger.info({
message: 'API user is a platform',
meta: {
action: 'isPlatformApiUser',
...createReqMeta(req),
reqBody: req.body,
apiUser: sessionUserId,
userEmail,
},
})
req.session.user = { _id: emailUser._id }
return next()
})
.mapErr((error) => {
logger.error({
message: 'Error occurred whilst retrieving user from userEmail',
meta: {
action: 'isPlatformApiUser',
apiUser: sessionUserId,
userEmail,
},
error,
})

const { errorMessage, statusCode } = mapRouteError(error)
return res.status(statusCode).json({ message: errorMessage })
})
}
})
.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 authenticateApiKeyAndPlatform = [
authenticateApiKey,
isPlatformApiUser,
] as ControllerHandler[]
4 changes: 2 additions & 2 deletions src/app/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
PrivateFormError,
} from '../form/form.errors'
import * as FormService from '../form/form.service'
import { findUserById } from '../user/user.service'
import { findApiUserById } from '../user/user.service'

import {
InvalidDomainError,
Expand Down Expand Up @@ -360,7 +360,7 @@ export const getUserByApiKey = (
userId: string,
token: string,
): ResultAsync<IUserSchema, Error> => {
return findUserById(userId).andThen((user) => {
return findApiUserById(userId).andThen((user) => {
if (!user.apiToken?.keyHash) {
return errAsync(new MissingTokenError())
}
Expand Down
Loading

0 comments on commit 753d1f2

Please sign in to comment.