From 2b65717f6691db30963cbd13a04274d831104a0b Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Thu, 15 May 2025 08:48:33 -0400 Subject: [PATCH 1/4] LF-4765 move checkscope to ts --- .../acl/{checkScope.js => checkScope.ts} | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) rename packages/api/src/middleware/acl/{checkScope.js => checkScope.ts} (76%) diff --git a/packages/api/src/middleware/acl/checkScope.js b/packages/api/src/middleware/acl/checkScope.ts similarity index 76% rename from packages/api/src/middleware/acl/checkScope.js rename to packages/api/src/middleware/acl/checkScope.ts index 7688c1efb9..ec54a217a7 100644 --- a/packages/api/src/middleware/acl/checkScope.js +++ b/packages/api/src/middleware/acl/checkScope.ts @@ -1,5 +1,5 @@ /* - * Copyright 2019, 2020, 2021, 2022 LiteFarm.org + * Copyright 2019, 2020, 2021, 2022, 2025 LiteFarm.org * This file is part of LiteFarm. * * LiteFarm is free software: you can redistribute it and/or modify @@ -13,13 +13,23 @@ * GNU General Public License for more details, see . */ +import { NextFunction, Response } from 'express'; +import { Farm, Permission, RolePermission, User, UserFarm } from '../../models/types.js'; import userFarmModel from '../../models/userFarmModel.js'; +import { LiteFarmRequest } from '../../types.js'; -const getScopes = async (user_id, farm_id, { checkConsent }) => { +type Scope = UserFarm & RolePermission & Permission; + +const getScopes = async ( + user_id: User['user_id'], + farm_id: Farm['farm_id'], + { checkConsent }: { checkConsent: boolean }, +): Promise => { // essential to fetch the most updated userFarm info to know user's most updated granted access try { const permissionQuery = userFarmModel .query() + .castTo() .distinct('permissions.name', 'userFarm.role_id') .join('rolePermissions', 'userFarm.role_id', 'rolePermissions.role_id') .join('permissions', 'permissions.permission_id', 'rolePermissions.permission_id') @@ -40,23 +50,34 @@ const getScopes = async (user_id, farm_id, { checkConsent }) => { * @param expectedScopes - array of required scopes to make request [ 'get:crops', 'add:sales' ] * @param checkConsent {boolean} */ -const checkScope = (expectedScopes, { checkConsent = true } = {}) => { +const checkScope = ( + expectedScopes?: string[], + { checkConsent = true }: { checkConsent?: boolean } = {}, +) => { if (!Array.isArray(expectedScopes)) { throw new Error( 'Parameter expectedScopes must be an array of strings representing the scopes for the endpoint(s)', ); } - return async (req, res, next) => { + return async (req: LiteFarmRequest, res: Response, next: NextFunction) => { if (expectedScopes.length === 0) { return next(); } - const { headers } = req; + + // Consider making this a separate middleware with checkJwt + if (!req.auth) { + return res.status(400).send('No Auth provided'); + } + const { user_id } = req.auth; + if (!user_id || user_id === 'undefined') { + return res.status(400).send('Missing user_id in auth'); + } + + const { headers } = req; const { farm_id } = headers; // these are the minimum props needed for most endpoints' authorization - if (!user_id || user_id === 'undefined') - return res.status(400).send('Missing user_id in headers'); if (!farm_id || farm_id === 'undefined') return res.status(400).send('Missing farm_id in headers'); try { From 3378a571b38587cb665237c937fe8418a15ba783 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Thu, 15 May 2025 09:02:56 -0400 Subject: [PATCH 2/4] LF-4765 Add needed types --- packages/api/src/models/types.ts | 69 ++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/packages/api/src/models/types.ts b/packages/api/src/models/types.ts index dc38b46922..80b654925a 100644 --- a/packages/api/src/models/types.ts +++ b/packages/api/src/models/types.ts @@ -49,6 +49,11 @@ export enum GENDER { FEMALE = 'FEMALE', } +// Table with no model +type UserStatus = { + status_id: number; + status_description: string; +}; export interface User extends Timestamps { user_id: string; first_name: string; @@ -60,7 +65,7 @@ export interface User extends Timestamps { sandbox_user: boolean; notification_setting: UserNotificationSetting; language_preference: string; - status_id: number; // TODO: user status model does not exist + status_id: UserStatus['status_id']; gender: GENDER; birth_year: number; do_not_email: boolean; @@ -71,9 +76,11 @@ interface UserTimeStamps extends Timestamps { updated_by_user_id: User['user_id']; } -interface BaseProperties extends UserTimeStamps { +type SoftDelete = { deleted: boolean; -} +}; + +interface BaseProperties extends UserTimeStamps, SoftDelete {} enum FarmUnitSystem { IMPERIAL = 'imperial', @@ -594,3 +601,59 @@ export interface FarmAddon extends BaseProperties { org_uuid: string; org_pk: number; } + +export interface Role extends SoftDelete { + role_id: number; + role: string; +} + +// Table with no model +export type Permission = { + permission_id: number; + name: string; + description: string; +}; + +// Table with no model +export type RolePermission = { + role_id: Role['role_id']; + permission_id: Permission['permission_id']; +}; + +enum UserFarmStatus { + ACTIVE = 'Active', + INACTIVE = 'Inactive', + INVITED = 'Invited', +} + +enum WageRateUnit { + HOURLY = 'hourly', + ANNUALLY = 'annually', +} + +type Wage = { + type: WageRateUnit; + amount?: number; +}; + +// NOTE: Why does userFarm not have updated_at? +export interface UserFarm extends Pick { + user_id: User['user_id']; + farm_id: Farm['farm_id']; + role_id: Role['role_id']; + has_consent: boolean; + status: UserFarmStatus; + consent_version: string; + wage?: Wage; + step_one?: boolean; + step_one_end?: string; + step_two?: boolean; + step_two_end?: string; + step_three?: boolean; + step_three_end?: string; + step_four?: boolean; + step_four_end?: string; + step_five?: boolean; + step_five_end?: string; + wage_do_not_ask_again?: boolean; +} From eac6d1c9a6a6a45709c84457de2126fd010c2edb Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 23 May 2025 11:06:50 -0400 Subject: [PATCH 3/4] Rebase commit to latest LF-4765 --- .../irrigationPrescriptionController.ts | 12 +++-- ...irrigationPrescriptionRequestController.ts | 9 +--- packages/api/src/middleware/acl/checkScope.ts | 16 ++++-- .../irrigationPrescriptionRequestRoute.ts | 15 +++--- .../src/routes/irrigationPrescriptionRoute.ts | 10 +++- packages/api/src/types.ts | 54 +++++++++++++++---- 6 files changed, 83 insertions(+), 33 deletions(-) diff --git a/packages/api/src/controllers/irrigationPrescriptionController.ts b/packages/api/src/controllers/irrigationPrescriptionController.ts index dd8f68e212..96514adfb1 100644 --- a/packages/api/src/controllers/irrigationPrescriptionController.ts +++ b/packages/api/src/controllers/irrigationPrescriptionController.ts @@ -14,14 +14,19 @@ */ import { Response } from 'express'; -import { LiteFarmRequest, HttpError } from '../types.js'; -import { IrrigationPrescriptionQueryParams } from '../middleware/validation/checkIrrigationPrescription.js'; +import { HttpError, ScopeCheckedLiteFarmRequest } from '../types.js'; import { getAddonPartnerIrrigationPrescriptions } from '../services/addonPartner.js'; +export interface IrrigationPrescriptionQueryParams { + startTime: string; + endTime: string; + shouldSend: string; +} + const irrigationPrescriptionController = { getPrescriptions() { return async ( - req: LiteFarmRequest>, + req: ScopeCheckedLiteFarmRequest, res: Response, ) => { try { @@ -29,7 +34,6 @@ const irrigationPrescriptionController = { const { startTime, endTime, shouldSend } = req.query; const irrigationPrescriptions = await getAddonPartnerIrrigationPrescriptions( - // @ts-expect-error - farm_id is guaranteed here by the checkScope middleware with single argument farm_id, startTime, endTime, diff --git a/packages/api/src/controllers/irrigationPrescriptionRequestController.ts b/packages/api/src/controllers/irrigationPrescriptionRequestController.ts index 4cf4fd859a..dab696cedd 100644 --- a/packages/api/src/controllers/irrigationPrescriptionRequestController.ts +++ b/packages/api/src/controllers/irrigationPrescriptionRequestController.ts @@ -15,14 +15,9 @@ import { Response } from 'express'; import { getOrgLocationAndCropData, sendFieldAndCropDataToEsci } from '../util/ensembleService.js'; -import { LiteFarmRequest } from '../types.js'; +import { HttpError, LiteFarmRequest } from '../types.js'; -interface HttpError extends Error { - status?: number; - code?: number; // LF custom error -} - -interface InitiateFarmIrrigationPrescriptionQueryParams { +export interface InitiateFarmIrrigationPrescriptionQueryParams { allOrgs?: string; shouldSend?: string; } diff --git a/packages/api/src/middleware/acl/checkScope.ts b/packages/api/src/middleware/acl/checkScope.ts index ec54a217a7..3ed894f227 100644 --- a/packages/api/src/middleware/acl/checkScope.ts +++ b/packages/api/src/middleware/acl/checkScope.ts @@ -65,21 +65,27 @@ const checkScope = ( return next(); } - // Consider making this a separate middleware with checkJwt + // Check auth + // NOTE: Consider making this a separate middleware with checkJwt if (!req.auth) { return res.status(400).send('No Auth provided'); } - const { user_id } = req.auth; if (!user_id || user_id === 'undefined') { return res.status(400).send('Missing user_id in auth'); } - const { headers } = req; - const { farm_id } = headers; // these are the minimum props needed for most endpoints' authorization + // Check headers + if (!req.headers) { + return res.status(400).send('Missing headers'); + } + + const { farm_id } = req.headers; // these are the minimum props needed for most endpoints' authorization - if (!farm_id || farm_id === 'undefined') + if (!farm_id || farm_id === 'undefined') { return res.status(400).send('Missing farm_id in headers'); + } + try { const scopes = await getScopes(user_id, farm_id, { checkConsent }); diff --git a/packages/api/src/routes/irrigationPrescriptionRequestRoute.ts b/packages/api/src/routes/irrigationPrescriptionRequestRoute.ts index 578a8e7d49..adc297d461 100644 --- a/packages/api/src/routes/irrigationPrescriptionRequestRoute.ts +++ b/packages/api/src/routes/irrigationPrescriptionRequestRoute.ts @@ -15,17 +15,20 @@ import express from 'express'; import checkScope from '../middleware/acl/checkScope.js'; -import IrrigationPrescriptionRequestController from '../controllers/irrigationPrescriptionRequestController.js'; +import IrrigationPrescriptionRequestController, { + InitiateFarmIrrigationPrescriptionQueryParams, +} from '../controllers/irrigationPrescriptionRequestController.js'; +import { ScopeCheckedLiteFarmRequest } from '../types.js'; import checkSchedulerJwt from '../middleware/acl/checkSchedulerJwt.js'; import checkSchedulerPermission from '../middleware/acl/checkSchedulerPermission.js'; const router = express.Router(); -router.post( - '/', - checkScope(['get:smart_irrigation']), - IrrigationPrescriptionRequestController.initiateFarmIrrigationPrescription(), -); +router.post('/', checkScope(['get:smart_irrigation']), (req, res) => { + const typedReq = + req as ScopeCheckedLiteFarmRequest; + IrrigationPrescriptionRequestController.initiateFarmIrrigationPrescription()(typedReq, res); +}); router.post( '/scheduler', diff --git a/packages/api/src/routes/irrigationPrescriptionRoute.ts b/packages/api/src/routes/irrigationPrescriptionRoute.ts index e895a0016c..6c982fc8e6 100644 --- a/packages/api/src/routes/irrigationPrescriptionRoute.ts +++ b/packages/api/src/routes/irrigationPrescriptionRoute.ts @@ -15,7 +15,10 @@ import express from 'express'; import checkScope from '../middleware/acl/checkScope.js'; -import IrrigationPrescriptionController from '../controllers/irrigationPrescriptionController.js'; +import IrrigationPrescriptionController, { + IrrigationPrescriptionQueryParams, +} from '../controllers/irrigationPrescriptionController.js'; +import { ScopeCheckedLiteFarmRequest } from '../types.js'; import { checkGetIrrigationPrescription } from '../middleware/validation/checkIrrigationPrescription.js'; const router = express.Router(); @@ -24,7 +27,10 @@ router.get( '/', checkScope(['get:smart_irrigation']), checkGetIrrigationPrescription(), - IrrigationPrescriptionController.getPrescriptions(), + (req, res) => { + const typedReq = req as ScopeCheckedLiteFarmRequest; + IrrigationPrescriptionController.getPrescriptions()(typedReq, res); + }, ); export default router; diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index 8e1baa586a..f73d64a108 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -13,24 +13,60 @@ * GNU General Public License for more details, see . */ -import { NextFunction, Request, Response } from 'express'; +import { Request } from 'express'; +import { Farm, Point, Role, Task, TaskType, User } from './models/types.js'; export interface HttpError extends Error { status?: number; code?: number; // LF custom error } -// TODO: Remove farm_id conditional and cast this in a checkScope() that takes the function and casts this to req +/** + * For use with unchecked requests + * + * All possible shapes of a litefarm 'req' object + */ export interface LiteFarmRequest extends Request { + auth?: { + user_id?: User['user_id']; + farm_id?: Farm['farm_id']; + sub?: User['user_id']; + email?: User['email']; + given_name?: User['first_name']; + family_name?: User['last_name']; + first_name?: User['first_name']; + language_preference?: string; + }; headers: Request['headers'] & { - farm_id?: string; + user_id?: User['user_id']; + farm_id?: Farm['farm_id']; + }; + role?: Role['role_id']; + isMinimized?: boolean; + isTextDocument?: boolean; + isNotMinimized?: boolean; + field?: { fieldId?: string | number; point?: Point }; + file?: unknown; + checkTaskStatus?: { + complete_date?: Task['complete_date']; + abandon_date?: Task['abandon_date']; + assignee_user_id?: Task['assignee_user_id']; + task_translation_key?: TaskType['task_translation_key']; }; } -// Can be used to cast after checkScope() succeeds -export type LiteFarmHandler = ( - req: LiteFarmRequest, - res: Response, - next: NextFunction, -) => void | Promise; +/** + * For use after checkScope() middleware. + * + * DO NOT add more required props unless it is auth related, make a new type + */ + +export interface ScopeCheckedLiteFarmRequest extends LiteFarmRequest { + auth: { + user_id: User['user_id']; + }; + headers: Request['headers'] & { + farm_id: string; + }; +} From 39cb768cd0a6f683317761bdde4a0a659d0327cc Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 23 May 2025 11:20:31 -0400 Subject: [PATCH 4/4] Update typings based upon new middleware --- .../validation/checkIrrigationPrescription.ts | 12 ++++-------- .../api/src/routes/irrigationPrescriptionRoute.ts | 5 ++++- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/api/src/middleware/validation/checkIrrigationPrescription.ts b/packages/api/src/middleware/validation/checkIrrigationPrescription.ts index b4ff5dd040..ec96401f5b 100644 --- a/packages/api/src/middleware/validation/checkIrrigationPrescription.ts +++ b/packages/api/src/middleware/validation/checkIrrigationPrescription.ts @@ -13,18 +13,14 @@ * GNU General Public License for more details, see . */ -import { Request, Response, NextFunction } from 'express'; +import { Response, NextFunction } from 'express'; import { isISO8601Format } from '../../util/validation.js'; - -export interface IrrigationPrescriptionQueryParams { - startTime?: string; - endTime?: string; - shouldSend?: string; -} +import { IrrigationPrescriptionQueryParams } from '../../controllers/irrigationPrescriptionController.js'; +import { ScopeCheckedLiteFarmRequest } from '../../types.js'; export function checkGetIrrigationPrescription() { return async ( - req: Request, + req: ScopeCheckedLiteFarmRequest>, res: Response, next: NextFunction, ) => { diff --git a/packages/api/src/routes/irrigationPrescriptionRoute.ts b/packages/api/src/routes/irrigationPrescriptionRoute.ts index 6c982fc8e6..8bb109e695 100644 --- a/packages/api/src/routes/irrigationPrescriptionRoute.ts +++ b/packages/api/src/routes/irrigationPrescriptionRoute.ts @@ -26,7 +26,10 @@ const router = express.Router(); router.get( '/', checkScope(['get:smart_irrigation']), - checkGetIrrigationPrescription(), + (req, res, next) => { + const typedReq = req as ScopeCheckedLiteFarmRequest>; + checkGetIrrigationPrescription()(typedReq, res, next); + }, (req, res) => { const typedReq = req as ScopeCheckedLiteFarmRequest; IrrigationPrescriptionController.getPrescriptions()(typedReq, res);