diff --git a/backend/functions/package-lock.json b/backend/functions/package-lock.json index c9cf83d..b41c45d 100644 --- a/backend/functions/package-lock.json +++ b/backend/functions/package-lock.json @@ -2074,9 +2074,9 @@ "integrity": "sha512-9jb7AW5p3in+IiJWhQiZmmwkpLaR/ccTWdWQCtZM66HJcHHLegowh4q4tSD7gouUyeNvFWRavfK9GXosQHDpFA==" }, "giraffeql": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/giraffeql/-/giraffeql-2.0.1.tgz", - "integrity": "sha512-2JK42L/JfxLCZ7QsroRlw5xuyi1qkB5uXElBTRvN2NSQ0M3PNQNjRPWkkikOWcuRyXv7iqFUrpExaJfOdKHHqQ==" + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/giraffeql/-/giraffeql-2.0.3.tgz", + "integrity": "sha512-iO4k2Xg7M7B//65ChYxaxmEiNlNF8T2mHWcHCMD0BvLkfg3YdHRmgekhoFtWZIrV5AmDLENq8RR4W3ZrSGw41w==" }, "glob": { "version": "7.1.6", diff --git a/backend/functions/package.json b/backend/functions/package.json index f947559..94b7e3e 100644 --- a/backend/functions/package.json +++ b/backend/functions/package.json @@ -22,7 +22,7 @@ "express": "^4.17.1", "firebase-admin": "^9.2.0", "firebase-functions": "^3.13.1", - "giraffeql": "^2.0.1", + "giraffeql": "^2.0.3", "jsonwebtoken": "^8.5.1", "knex": "^0.21.17", "pg": "^8.5.1", diff --git a/backend/functions/src/schema/core/generators/link.ts b/backend/functions/src/schema/core/generators/link.ts index bad0b6f..677cbce 100644 --- a/backend/functions/src/schema/core/generators/link.ts +++ b/backend/functions/src/schema/core/generators/link.ts @@ -11,7 +11,10 @@ import { import { ObjectTypeDefinition, ObjectTypeDefinitionField } from "giraffeql"; type ServicesObjectMap = { - [x: string]: NormalService; + [x: string]: { + allowNull?: boolean; + service: NormalService; + }; }; export function generateLinkTypeDef( @@ -23,8 +26,8 @@ export function generateLinkTypeDef( for (const field in servicesObjectMap) { typeDefFields[field] = generateJoinableField({ - allowNull: false, - service: servicesObjectMap[field], + service: servicesObjectMap[field].service, + allowNull: servicesObjectMap[field].allowNull ?? false, sqlOptions: { unique: "compositeIndex" }, }); } diff --git a/backend/functions/src/schema/core/helpers/enum.ts b/backend/functions/src/schema/core/helpers/enum.ts index c0af02f..48fc1e8 100644 --- a/backend/functions/src/schema/core/helpers/enum.ts +++ b/backend/functions/src/schema/core/helpers/enum.ts @@ -29,6 +29,16 @@ export abstract class Kenum { ); } + static fromUnknown(key: unknown): Kenum { + if (typeof key === "number") return this.fromIndex(key); + + if (typeof key === "string") return this.fromName(key); + + throw new Error( + "Invalid key type for kenum. Only Number or String allowed" + ); + } + public get parsed(): number { return this.index; } diff --git a/backend/functions/src/schema/core/helpers/error.ts b/backend/functions/src/schema/core/helpers/error.ts index 767ef15..c7afdf0 100644 --- a/backend/functions/src/schema/core/helpers/error.ts +++ b/backend/functions/src/schema/core/helpers/error.ts @@ -1,5 +1,17 @@ import { GiraffeqlBaseError } from "giraffeql"; +export class PermissionsError extends GiraffeqlBaseError { + constructor(params: { message: string; fieldPath: string[] }) { + const { message, fieldPath } = params; + super({ + errorName: "PermissionsError", + message, + fieldPath, + statusCode: 401, + }); + } +} + export function generateError( message: string, fieldPath: string[], @@ -16,8 +28,11 @@ export function itemNotFoundError(fieldPath: string[]): GiraffeqlBaseError { return generateError("Record was not found", fieldPath, 404); } -export function badPermissionsError(fieldPath: string[]): GiraffeqlBaseError { - return generateError("Insufficient permissions", fieldPath, 401); +export function badPermissionsError( + fieldPath: string[], + message?: string +): PermissionsError { + return generateError(message ?? "Insufficient permissions", fieldPath, 401); } export function invalidSqlError(): GiraffeqlBaseError { diff --git a/backend/functions/src/schema/core/helpers/permissions.ts b/backend/functions/src/schema/core/helpers/permissions.ts index ff3f78c..dee6851 100644 --- a/backend/functions/src/schema/core/helpers/permissions.ts +++ b/backend/functions/src/schema/core/helpers/permissions.ts @@ -1,100 +1,6 @@ -import { userRoleKenum } from "../../enums"; import { BaseService, NormalService } from "../services"; import * as errorHelper from "./error"; import { ServiceFunctionInputs, AccessControlFunction } from "../../../types"; -import { StringKeyObject } from "giraffeql"; - -export function generateItemCreatedByUserGuard( - service: NormalService -): AccessControlFunction { - return async function ({ req, args, fieldPath }) { - // args should be validated already - const validatedArgs = args; - //check if logged in - if (!req.user) return false; - - try { - const itemRecord = await service.lookupRecord( - [{ field: "createdBy" }], - validatedArgs.item ?? validatedArgs, - fieldPath - ); - - return itemRecord?.createdBy === req.user.id; - } catch (err) { - return false; - } - }; -} - -export function generateUserAdminGuard(): AccessControlFunction { - return generateUserRoleGuard([userRoleKenum.ADMIN]); -} - -export function generateUserRoleGuard( - allowedRoles: userRoleKenum[] -): AccessControlFunction { - return async function ({ req }) { - //check if logged in - if (!req.user) return false; - - try { - // role is loaded in helpers/auth on token decode - /* - const userRecords = await sqlHelper.fetchTableRows({ - select: [{ field: "role" }], - from: User.typename, - where: { - fields: [{ field: "id", value: req.user.id }], - }, - }); - */ - - if (!req.user.role) return false; - return allowedRoles.includes(req.user.role); - } catch (err) { - return false; - } - }; -} - -/* -export function userRoleGuard(allowedRoles: userRoleKenum[]) { - return function ( - target: BaseService, - propertyName: string, - propertyDescriptor: PropertyDescriptor - ): PropertyDescriptor { - // target === Employee.prototype - // propertyName === "greet" - // propertyDesciptor === Object.getOwnPropertyDescriptor(Employee.prototype, "greet") - const method = propertyDescriptor.value; - - propertyDescriptor.value = async function (req, args, query) { - // convert list of greet arguments to string - //const params = args.map((a) => JSON.stringify(a)).join(); - const params = "bar"; - //if it does not pass the access control, throw an error - if (!(await target.testPermissions("get", req, args, query))) { - throw errorHelper.badPermissionsError(); - } - - // invoke greet() and get its return value - const result = await method.apply(this, [req, args, query]); - - // convert result to string - const r = JSON.stringify(result); - - // display in console the function call details - console.log(`Call: ${propertyName}(${params}) => ${r}`); - - // return the result of invoking the method - return result; - }; - return propertyDescriptor; - }; -} -*/ export function permissionsCheck(methodKey: string) { return function ( @@ -116,6 +22,7 @@ export function permissionsCheck(methodKey: string) { isAdmin = false, }: ServiceFunctionInputs) { //if it does not pass the access control, throw an error + if ( !(await target.testPermissions.apply(this, [ methodKey, @@ -129,8 +36,10 @@ export function permissionsCheck(methodKey: string) { }, ])) ) { + // if returns false, fallback to a generic bad permissions error throw errorHelper.badPermissionsError(fieldPath); } + // invoke greet() and get its return value const result = await method.apply(this, [ { diff --git a/backend/functions/src/schema/core/helpers/resolver.ts b/backend/functions/src/schema/core/helpers/resolver.ts index a5c29e4..3650e34 100644 --- a/backend/functions/src/schema/core/helpers/resolver.ts +++ b/backend/functions/src/schema/core/helpers/resolver.ts @@ -113,7 +113,7 @@ export async function createObjectType({ for (const field in addFields) { if (!(field in typeDef.definition.fields)) { throw new GiraffeqlBaseError({ - message: `Invalid field`, + message: `Invalid add field: ${field}`, fieldPath, }); } diff --git a/backend/functions/src/schema/core/helpers/shared.ts b/backend/functions/src/schema/core/helpers/shared.ts index f814e4a..92291e3 100644 --- a/backend/functions/src/schema/core/helpers/shared.ts +++ b/backend/functions/src/schema/core/helpers/shared.ts @@ -55,3 +55,16 @@ export function capitalizeString(str: string): string { export function lowercaseString(str: string): string { return str.charAt(0).toLowerCase() + str.slice(1); } + +export function objectOnlyHasFields( + obj: StringKeyObject, + fields: string[], + allFieldsRequired = false +) { + const objKeys = Object.keys(obj); + + return allFieldsRequired + ? objKeys.length === fields.length && + objKeys.every((key) => fields.includes(key)) + : objKeys.every((key) => fields.includes(key)); +} diff --git a/backend/functions/src/schema/core/helpers/sql.ts b/backend/functions/src/schema/core/helpers/sql.ts index 0c91a26..9df6b8f 100644 --- a/backend/functions/src/schema/core/helpers/sql.ts +++ b/backend/functions/src/schema/core/helpers/sql.ts @@ -54,9 +54,13 @@ export type SqlWhereFieldOperator = | "regex" | "like" | "gt" + | "gtornull" | "gte" + | "gteornull" | "lt" - | "lte"; + | "ltornull" + | "lte" + | "lteornull"; export type SqlSelectQuery = { select: SqlSelectQueryObject[]; @@ -102,9 +106,18 @@ export type SqlDeleteQuery = { extendFn?: KnexExtendFunction; }; -function generateError(err: Error, fieldPath?: string[]) { - const errMessage = isDev ? err.message : "A SQL error has occurred"; +function generateError(err: unknown, fieldPath?: string[]) { console.log(err); + + // double check if err is type Error + let errMessage: string; + if (err instanceof Error) { + errMessage = isDev ? err.message : "A SQL error has occurred"; + } else { + console.log("Invalid error was thrown"); + errMessage = "A SQL error has occurred"; + } + return new GiraffeqlBaseError({ message: errMessage, fieldPath, @@ -197,21 +210,26 @@ function processFields(relevantFields: Set, table: string) { // set the actualFieldPart to the 2nd part actualFieldPart = subParts[1]; - const linkTableAlias = acquireTableAlias(tableIndexMap, linkJoinType); - - // set and advance the join table - currentJoinObject[fieldPart] = { - table: linkJoinType, - alias: linkTableAlias, - field: "id", - joinField, - nested: {}, - }; - - currentJoinObject = currentJoinObject[fieldPart].nested; + // only proceed IF the ${linkJoinType}/* is not already in the joinObject + const linkJoinTypeStr = linkJoinType + "/*"; + if (!(linkJoinTypeStr in currentJoinObject)) { + const linkTableAlias = acquireTableAlias(tableIndexMap, linkJoinType); + + // set and advance the join table + currentJoinObject[linkJoinTypeStr] = { + table: linkJoinType, + alias: linkTableAlias, + field: "id", + joinField, + nested: {}, + }; + } // set currentTableAlias - currentTableAlias = linkTableAlias; + currentTableAlias = currentJoinObject[linkJoinTypeStr].alias; + + // advance the join object + currentJoinObject = currentJoinObject[linkJoinTypeStr].nested; } // find the field on the currentTypeDef @@ -327,6 +345,14 @@ function applyWhere( bindings.push(whereSubObject.value); } break; + case "gtornull": + if (whereSubObject.value === null) { + throw new Error("Can't use this operator with null"); + } else { + whereSubstatement = `(${whereSubstatement} > ? OR ${whereSubstatement} IS NULL)`; + bindings.push(whereSubObject.value); + } + break; case "gte": if (whereSubObject.value === null) { throw new Error("Can't use this operator with null"); @@ -335,6 +361,14 @@ function applyWhere( bindings.push(whereSubObject.value); } break; + case "gteornull": + if (whereSubObject.value === null) { + throw new Error("Can't use this operator with null"); + } else { + whereSubstatement = `(${whereSubstatement} >= ? OR ${whereSubstatement} IS NULL)`; + bindings.push(whereSubObject.value); + } + break; case "lt": if (whereSubObject.value === null) { throw new Error("Can't use this operator with null"); @@ -343,6 +377,14 @@ function applyWhere( bindings.push(whereSubObject.value); } break; + case "ltornull": + if (whereSubObject.value === null) { + throw new Error("Can't use this operator with null"); + } else { + whereSubstatement = `(${whereSubstatement} < ? OR ${whereSubstatement} IS NULL)`; + bindings.push(whereSubObject.value); + } + break; case "lte": if (whereSubObject.value === null) { throw new Error("Can't use this operator with null"); @@ -351,6 +393,14 @@ function applyWhere( bindings.push(whereSubObject.value); } break; + case "lteornull": + if (whereSubObject.value === null) { + throw new Error("Can't use this operator with null"); + } else { + whereSubstatement = `(${whereSubstatement} <= ? OR ${whereSubstatement} IS NULL)`; + bindings.push(whereSubObject.value); + } + break; case "in": if (Array.isArray(whereSubObject.value)) { // if array is empty, is equivalent of FALSE diff --git a/backend/functions/src/schema/core/helpers/typeDef.ts b/backend/functions/src/schema/core/helpers/typeDef.ts index 86619da..bab45b8 100644 --- a/backend/functions/src/schema/core/helpers/typeDef.ts +++ b/backend/functions/src/schema/core/helpers/typeDef.ts @@ -169,7 +169,10 @@ export function generateUnixTimestampField( parseValue: nowOnly ? () => knex.fn.now() : (value: unknown) => { - if (typeof value !== "number") throw 1; // should never happen + // if null, allow null value + if (value === null) return null; + if (typeof value !== "number") + throw new Error("Unix timestamp must be sent in seconds"); // should never happen // assuming the timestamp is being sent in seconds return new Date(value * 1000); }, @@ -784,11 +787,41 @@ export function generatePaginatorPivotResolverObject(params: { required: false, allowNull: false, }), + gtornull: new GiraffeqlInputFieldType({ + type: currentType, + required: false, + allowNull: false, + }), lt: new GiraffeqlInputFieldType({ type: currentType, required: false, allowNull: false, }), + ltornull: new GiraffeqlInputFieldType({ + type: currentType, + required: false, + allowNull: false, + }), + gte: new GiraffeqlInputFieldType({ + type: currentType, + required: false, + allowNull: false, + }), + gteornull: new GiraffeqlInputFieldType({ + type: currentType, + required: false, + allowNull: false, + }), + lte: new GiraffeqlInputFieldType({ + type: currentType, + required: false, + allowNull: false, + }), + lteornull: new GiraffeqlInputFieldType({ + type: currentType, + required: false, + allowNull: false, + }), in: new GiraffeqlInputFieldType({ type: currentType, arrayOptions: { diff --git a/backend/functions/src/schema/core/services/base.ts b/backend/functions/src/schema/core/services/base.ts index c9aef4a..a827715 100644 --- a/backend/functions/src/schema/core/services/base.ts +++ b/backend/functions/src/schema/core/services/base.ts @@ -5,6 +5,7 @@ import { } from "../../../types"; import { userPermissionEnum } from "../../enums"; import { lookupSymbol, GiraffeqlRootResolverType } from "giraffeql"; +import { badPermissionsError, PermissionsError } from "../helpers/error"; export abstract class BaseService { typename: string; @@ -85,10 +86,15 @@ export abstract class BaseService { : false; } + if (!allowed) throw badPermissionsError(fieldPath); + return allowed; - } catch { - // if any error is thrown, return false - return false; + } catch (err: unknown) { + if (err instanceof Error && !(err instanceof PermissionsError)) { + throw badPermissionsError(fieldPath, err.message); + } + + throw err; } } } diff --git a/backend/functions/src/schema/core/services/link.ts b/backend/functions/src/schema/core/services/link.ts index 14632ef..849f425 100644 --- a/backend/functions/src/schema/core/services/link.ts +++ b/backend/functions/src/schema/core/services/link.ts @@ -5,7 +5,10 @@ import { GiraffeqlObjectType } from "giraffeql"; import { PaginatedService } from "./paginated"; type ServicesObjectMap = { - [x: string]: NormalService; + [x: string]: { + allowNull?: boolean; + service: NormalService; + }; }; type JoinFieldMap = { diff --git a/backend/functions/src/schema/core/services/normal.ts b/backend/functions/src/schema/core/services/normal.ts index 7391c80..b6bcbde 100644 --- a/backend/functions/src/schema/core/services/normal.ts +++ b/backend/functions/src/schema/core/services/normal.ts @@ -81,14 +81,20 @@ export class NormalService extends BaseService { const uniqueKeyMap = {}; Object.entries(this.uniqueKeyMap).forEach(([uniqueKeyName, entry]) => { entry.forEach((key) => { - const fieldType = this.getTypeDef().definition.fields[key].type; - if (!(fieldType instanceof GiraffeqlScalarType)) { + const typeDefField = this.getTypeDef().definition.fields[key]; + if (!typeDefField) { throw new GiraffeqlInitializationError({ - message: `Unique key map must lead to scalar value`, + message: `Unique key map field not found. Nested values not allowed`, }); } + + this.getTypeDef().definition.fields[key].allowNull; uniqueKeyMap[key] = new GiraffeqlInputFieldType({ - type: fieldType, + type: + typeDefField.type instanceof GiraffeqlScalarType + ? typeDefField.type + : new GiraffeqlInputTypeLookup(key), + allowNull: typeDefField.allowNull, }); }); }); @@ -249,6 +255,7 @@ export class NormalService extends BaseService { }: ServiceFunctionInputs) { // args should be validated already const validatedArgs = args; + await this.handleLookupArgs(args, fieldPath); const selectQuery = query ?? Object.assign({}, this.presets.default); @@ -299,6 +306,7 @@ export class NormalService extends BaseService { }: ServiceFunctionInputs) { // args should be validated already const validatedArgs = args; + const whereObject: SqlWhereObject = { connective: "AND", fields: [], @@ -646,12 +654,15 @@ export class NormalService extends BaseService { // looks up a record using its keys async lookupRecord( - selectFields: SqlSelectQueryObject[], + selectFields: string[], args: any, fieldPath: string[] ): Promise { const results = await fetchTableRows({ - select: selectFields ?? [{ field: "id" }], + select: + selectFields.length > 0 + ? selectFields.map((field) => ({ field })) + : [{ field: "id" }], from: this.typename, where: { connective: "AND", @@ -672,6 +683,28 @@ export class NormalService extends BaseService { return results[0]; } + // look up multiple records + async lookupMultipleRecord( + selectFields: string[], + whereObject: SqlWhereObject, + fieldPath: string[] + ): Promise { + const results = await fetchTableRows({ + select: + selectFields.length > 0 + ? selectFields.map((field) => ({ field })) + : [{ field: "id" }], + from: this.typename, + where: whereObject, + }); + + return results; + } + + isEmptyQuery(query: unknown) { + return isObject(query) && Object.keys(query).length < 1; + } + @permissionsCheck("create") async createRecord({ req, @@ -695,37 +728,33 @@ export class NormalService extends BaseService { fieldPath, }); - // args that will be compared with subscription args - /* const subscriptionFilterableArgs = { - createdBy: req.user?.id, - }; - - handleJqlSubscriptionTriggerIterative( - req, - this, - this.typename + "Created", - subscriptionFilterableArgs, - { id: addResults.id } + // do post-create fn, if any + await this.afterCreateProcess( + { + req, + fieldPath, + args, + query, + data, + isAdmin, + }, + addResults.id ); - handleJqlSubscriptionTriggerIterative( - req, - this, - this.typename + "ListUpdated", - subscriptionFilterableArgs, - { id: addResults.id } - ); */ - - return this.getRecord({ - req, - args: { id: addResults.id }, - query, - fieldPath, - isAdmin, - data, - }); + return this.isEmptyQuery(query) + ? {} + : await this.getRecord({ + req, + args: { id: addResults.id }, + query, + fieldPath, + isAdmin, + data, + }); } + async afterCreateProcess(inputs: ServiceFunctionInputs, itemId: number) {} + @permissionsCheck("update") async updateRecord({ req, @@ -738,11 +767,7 @@ export class NormalService extends BaseService { // args should be validated already const validatedArgs = args; - const item = await this.lookupRecord( - [{ field: "id" }], - validatedArgs.item, - fieldPath - ); + const item = await this.lookupRecord(["id"], validatedArgs.item, fieldPath); // convert any lookup/joined fields into IDs await this.handleLookupArgs(validatedArgs.fields, fieldPath); @@ -758,18 +783,33 @@ export class NormalService extends BaseService { fieldPath, }); - const returnData = await this.getRecord({ - req, - args: { id: item.id }, - query, - fieldPath, - isAdmin, - data, - }); + // do post-update fn, if any + await this.afterUpdateProcess( + { + req, + fieldPath, + args, + query, + data, + isAdmin, + }, + item.id + ); - return returnData; + return this.isEmptyQuery(query) + ? {} + : await this.getRecord({ + req, + args: { id: item.id }, + query, + fieldPath, + isAdmin, + data, + }); } + async afterUpdateProcess(inputs: ServiceFunctionInputs, itemId: number) {} + @permissionsCheck("delete") async deleteRecord({ req, @@ -782,21 +822,19 @@ export class NormalService extends BaseService { // args should be validated already const validatedArgs = args; // confirm existence of item and get ID - const item = await this.lookupRecord( - [{ field: "id" }], - validatedArgs, - fieldPath - ); + const item = await this.lookupRecord(["id"], validatedArgs, fieldPath); // first, fetch the requested query, if any - const requestedResults = await this.getRecord({ - req, - args, - query, - fieldPath, - isAdmin, - data, - }); + const requestedResults = this.isEmptyQuery(query) + ? {} + : await this.getRecord({ + req, + args, + query, + fieldPath, + isAdmin, + data, + }); await Resolver.deleteObjectType({ typename: this.typename,