diff --git a/adminforth/dataConnectors/baseConnector.ts b/adminforth/dataConnectors/baseConnector.ts index 55fbf9f03..f52c5e03a 100644 --- a/adminforth/dataConnectors/baseConnector.ts +++ b/adminforth/dataConnectors/baseConnector.ts @@ -10,7 +10,7 @@ import type { AdminUser } from "../types/Common.js" import { suggestIfTypo } from "../modules/utils.js"; import { decodeRecordId, encodeRecordId, isCompositePrimaryKey, primaryKeyColumnNames, primaryKeyColumns } from "../modules/recordId.js"; -import { interpretResource } from "../modules/restApi.js"; +import { interpretResource } from "../modules/resourceAccess.js"; import { ActionCheckSource, AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections, AllowedActionsEnum } from "../types/Common.js"; import { randomUUID } from "crypto"; import dayjs from "dayjs"; diff --git a/adminforth/documentation/docs/tutorial/03-Customization/11-dataApi.md b/adminforth/documentation/docs/tutorial/03-Customization/11-dataApi.md index 37e368f55..626fe2ba7 100644 --- a/adminforth/documentation/docs/tutorial/03-Customization/11-dataApi.md +++ b/adminforth/documentation/docs/tutorial/03-Customization/11-dataApi.md @@ -34,6 +34,65 @@ await admin.resource('adminuser').get(Filters.EQ('id', '1234')); Here we will show you how to use the Data API with simple examples. +## Access levels + +Resource data can be reached at two levels, and the difference is who asked for +the operation. + +```ts +const users = admin.resource('adminuser'); + +// a user asked for this +await users.asUser(adminUser, { meta }).create(record); + +// plain data access the user did not ask for +await users.create(record); +``` + +`asUser()` is the level for anything that came from a request. It enforces the +resource ACL, applies the column access rules (`backendOnly`, `editReadonly`, +`showIn` and its `allowModifyWhenNotShowIn*` / `fillOnCreate` escapes), strips +columns the user may not read out of results, and runs the resource lifecycle +hooks — including the row-scoping `beforeDatasourceRequest` hooks that express +multi-tenancy. Use it in plugin endpoints: you do not have to remember the +individual checks, and you cannot forget one. + +The bare methods are plain data access for internal bookkeeping: no permission +checks, no column access rules, no hooks. Writes are still normalized; the +connector remains responsible for its own constraints. + +`admin.createResourceRecord`, `admin.updateResourceRecord` and +`admin.deleteResourceRecord` are the older entry points which this API replaces. +They still work and still run hooks, but they are deprecated: move calls to +`asUser()` when a user asked for the operation, and to the bare methods when +nothing did. + +A denied or failed operation is always visible. `get`, `list`, `count`, +`aggregate` and `delete` throw, since their return value carries no room for an +error; `create` and `update` resolve to `{ ok: false, error }`. `delete` returns +`false` when the record simply did not exist: + +```ts +const { ok, error } = await users.asUser(adminUser, { meta }).update(id, updates); + +try { + const deleted = await users.asUser(adminUser, { meta }).delete('1234'); + // deleted === false means there was no such record +} catch (e) { + // ACL denial, cascade failure, or a hook rejection +} +``` + +When the caller has already loaded the record, pass its snapshot to the save +hooks. ACL and row scope use the current record from a scoped lookup before +mutating it: + +```ts +await users.asUser(adminUser, { meta, oldRecord }).update(recordId, updates); +await users.asUser(adminUser, { meta, record }).delete(recordId); +``` + + ## Get one item from database diff --git a/adminforth/documentation/docs/tutorial/03-Customization/12-security.md b/adminforth/documentation/docs/tutorial/03-Customization/12-security.md index 014921526..50b104b00 100644 --- a/adminforth/documentation/docs/tutorial/03-Customization/12-security.md +++ b/adminforth/documentation/docs/tutorial/03-Customization/12-security.md @@ -118,8 +118,9 @@ This is opt-in. It is especially important for the column configured as `auth.us | Path | When `normalize` runs | | --- | --- | -| AdminForth CRUD (`createResourceRecord`, `updateResourceRecord`) | Before validation and `beforeSave` hooks | -| Data API (`admin.resource(...).create()` and `.update()`) | Before the record reaches the connector | +| User-scoped Data API (`admin.resource(...).asUser(...)`) and `admin.createResourceRecord` | Before validation and `beforeSave` hooks | +| Bare Data API (`admin.resource(...).create(...)` and siblings) | Before the connector operation | +| Deprecated AdminForth CRUD (`createResourceRecord`, `updateResourceRecord`) | Before validation and `beforeSave` hooks | | Core password login | On the submitted value of `auth.usernameField`, before the user lookup | | Reads and filters | Never — this includes `get`, `list`, `count`, search, and `Filters.EQ` | diff --git a/adminforth/index.ts b/adminforth/index.ts index 4e0f0b48a..67b39682f 100644 --- a/adminforth/index.ts +++ b/adminforth/index.ts @@ -4,7 +4,7 @@ import CodeInjector from './modules/codeInjector.js'; import ExpressServer from './servers/express.js'; import OpenApiRegistry from './servers/openapi.js'; // import FastifyServer from './servers/fastify.js'; -import { ADMINFORTH_VERSION, listify, suggestIfTypo, RateLimiter, RAMLock, getClientIp, isProbablyUUIDColumn, convertPeriodToSeconds, hookResponseError, md5hash, applyRegexValidation, formatHugePluginError } from './modules/utils.js'; +import { ADMINFORTH_VERSION, cascadeChildrenDelete, listify, suggestIfTypo, RateLimiter, RAMLock, getClientIp, isProbablyUUIDColumn, convertPeriodToSeconds, hookResponseError, md5hash, applyRegexValidation, formatHugePluginError } from './modules/utils.js'; import { type AdminForthConfig, type IAdminForth, @@ -36,8 +36,11 @@ import { import AdminForthPlugin from './basePlugin.js'; import ConfigValidator from './modules/configValidator.js'; -import AdminForthRestAPI, { interpretResource, rejectApiRawFilters } from './modules/restApi.js'; +import AdminForthRestAPI, { rejectApiRawFilters } from './modules/restApi.js'; +import { interpretResource } from './modules/resourceAccess.js'; import OperationalResource from './modules/operationalResource.js'; +import UserScopedResource from './modules/userScopedResource.js'; +import { validateRecordValues } from './modules/recordValidator.js'; import SocketBroker from './modules/socketBroker.js'; import { afLogger } from './modules/logger.js'; import { normalizeRecordValues } from './modules/columnValueNormalizer.js'; @@ -425,59 +428,8 @@ class AdminForth implements IAdminForth { }); } - validateRecordValues(resource: AdminForthResource, record: any, mode: 'create' | 'edit'): any { - // check if record with validation is valid - for (const column of resource.columns.filter((col) => col.name in record && col.validation)) { - const required = typeof column.required === 'object' - ? column.required[mode] - : true; - - if (!required && !record[column.name]) continue; - - let error = null; - if (column.isArray?.enabled) { - error = record[column.name].reduce((err, item) => { - return err || AdminForth.Utils.applyRegexValidation(item, column.validation); - }, null); - } else { - error = AdminForth.Utils.applyRegexValidation(record[column.name], column.validation); - } - if (error) { - return error; - } - } - - // check if record with minValue or maxValue is within limits - for (const column of resource.columns.filter((col) => col.name in record - && ['integer', 'decimal', 'float'].includes(col.isArray?.enabled ? col.isArray.itemType : col.type) - && (col.minValue !== undefined || col.maxValue !== undefined))) { - if (column.isArray?.enabled) { - const error = record[column.name].reduce((err, item) => { - if (err) return err; - - if (column.minValue !== undefined && item < column.minValue) { - return `Value in "${column.name}" must be greater than ${column.minValue}`; - } - if (column.maxValue !== undefined && item > column.maxValue) { - return `Value in "${column.name}" must be less than ${column.maxValue}`; - } - - return null; - }, null); - if (error) { - return error; - } - } else { - if (column.minValue !== undefined && record[column.name] && record[column.name] < column.minValue) { - return `Value in "${column.name}" must be greater than ${column.minValue}`; - } - if (column.maxValue !== undefined && record[column.name] && record[column.name] > column.maxValue) { - return `Value in "${column.name}" must be less than ${column.maxValue}`; - } - } - } - - return null; + validateRecordValues(resource: AdminForthResource, record: any, mode: 'create' | 'edit'): string | null { + return validateRecordValues(resource, record, mode); } async tryToImportConnector(connectorName: string, doesUserHavePnpmLock: boolean) { @@ -663,7 +615,21 @@ class AdminForth implements IAdminForth { this.operationalResources = {}; this.config.resources.forEach((resource) => { - this.operationalResources[resource.resourceId] = new OperationalResource(this.connectors[resource.dataSource], resource); + this.operationalResources[resource.resourceId] = new OperationalResource( + this.connectors[resource.dataSource], + resource, + (data, adminUser, options) => new UserScopedResource( + data, + this, + { + create: (params) => this.executeCreateResourceRecord(params), + update: (params) => this.executeUpdateResourceRecord(params), + delete: (params, cascadeChildren, bulkHooks) => this.executeDeleteResourceRecord(params, cascadeChildren, bulkHooks), + }, + adminUser, + options, + ), + ); }); const adminforthSecret = process.env.ADMINFORTH_SECRET; @@ -776,18 +742,28 @@ class AdminForth implements IAdminForth { } /** - * Create record and execute hooks + * Create record and execute hooks. + * @deprecated Being replaced by the Data API. Use + * `adminforth.resource(id).asUser(adminUser, { meta }).create(record)` for anything a user + * requested, or `adminforth.resource(id).create(record)` for plain data access. * @param params - Parameters for record creation. See CreateResourceRecordParams. * @returns Result of record creation. See CreateResourceRecordResult. */ async createResourceRecord( params: CreateResourceRecordParams, + ): Promise { + this.warnDeprecatedResourceMutation('createResourceRecord', params.resource.resourceId, 'create'); + return this.executeCreateResourceRecord(params); + } + + private async executeCreateResourceRecord( + params: CreateResourceRecordParams, ): Promise { const { resource, record, adminUser, extra, response } = params; normalizeRecordValues(resource, record); - const err = this.validateRecordValues(resource, record, 'create'); + const err = validateRecordValues(resource, record, 'create'); if (err) { return { error: err }; } @@ -870,17 +846,27 @@ class AdminForth implements IAdminForth { /** * record is partial record with only changed fields * - * Update record by id and execute hooks - * @param params - Parameters for record update. See UpdateResourceRecordParams. - * @returns Result of record update. See UpdateResourceRecordResult. + * Update record by id and execute hooks. + * @deprecated Being replaced by the Data API. Use + * `adminforth.resource(id).asUser(adminUser, { meta }).update(pk, updates)` for anything a + * user requested, or `adminforth.resource(id).update(pk, updates)` for plain data access. + * @param params - Parameters for record update. See UpdateResourceRecordParams. + * @returns Result of record update. See UpdateResourceRecordResult. */ async updateResourceRecord( params: UpdateResourceRecordParams, + ): Promise { + this.warnDeprecatedResourceMutation('updateResourceRecord', params.resource.resourceId, 'update'); + return this.executeUpdateResourceRecord(params); + } + + private async executeUpdateResourceRecord( + params: UpdateResourceRecordParams, ): Promise { const { resource, recordId, record, oldRecord, adminUser, response, extra, updates } = params; const dataToUse = updates || record; normalizeRecordValues(resource, dataToUse); - const err = this.validateRecordValues(resource, dataToUse, 'edit'); + const err = validateRecordValues(resource, dataToUse, 'edit'); if (err) { return { error: err }; } @@ -890,9 +876,10 @@ class AdminForth implements IAdminForth { } // remove editReadonly columns from record - for (const column of resource.columns.filter((col) => col.editReadonly)) { - if (column.name in dataToUse) + for (const column of resource.columns.filter((candidate) => candidate.editReadonly)) { + if (column.name in dataToUse) { delete dataToUse[column.name]; + } } // execute hook if needed @@ -958,16 +945,31 @@ class AdminForth implements IAdminForth { } /** - * Delete record by id and execute hooks + * Delete record by id and execute hooks. + * @deprecated Being replaced by the Data API. Use + * `adminforth.resource(id).asUser(adminUser, { meta }).delete(pk)` for anything a user + * requested, or `adminforth.resource(id).delete(pk)` for plain data access. * @param params - Parameters for record deletion. See DeleteResourceRecordParams. * @returns Result of record deletion. See DeleteResourceRecordResult. */ async deleteResourceRecord( params: DeleteResourceRecordParams, + cascadeChildren = false, + ): Promise { + if (!cascadeChildren) { + this.warnDeprecatedResourceMutation('deleteResourceRecord', params.resource.resourceId, 'delete'); + } + return this.executeDeleteResourceRecord(params, cascadeChildren); + } + + private async executeDeleteResourceRecord( + params: DeleteResourceRecordParams, + cascadeChildren = false, + bulkHooks = false, ): Promise { const { resource, recordId, adminUser, record, response, extra } = params; - // execute hook if needed - for (const hook of listify(resource.hooks?.delete?.beforeSave)) { + const beforeHooks = listify(resource.hooks?.delete?.beforeSave); + const runBeforeHook = async (hook: typeof beforeHooks[number]) => { const resp = await hook({ resource, record, @@ -977,18 +979,43 @@ class AdminForth implements IAdminForth { response, extra, }); - const hookRespError = hookResponseError(resp); - if (hookRespError) { - return hookRespError; + return bulkHooks ? resp.error : hookResponseError(resp)?.error; + }; + if (bulkHooks) { + // The old default bulk action ran every beforeSave hook, even if one vetoed deletion. + const errors = await Promise.all(beforeHooks.map(runBeforeHook)); + const error = errors.find(Boolean); + if (error) { + return { error }; + } + } else { + for (const hook of beforeHooks) { + const error = await runBeforeHook(hook); + if (error) { + return { error }; + } + } + } + + if (cascadeChildren) { + const cascadeResult = await cascadeChildrenDelete( + resource, + recordId, + { adminUser, response }, + this, + (childParams) => this.executeDeleteResourceRecord(childParams), + ); + if (cascadeResult.error) { + return cascadeResult; } } const connector = this.connectors[resource.dataSource]; await connector.deleteRecord({ resource, recordId, pkValues: compositePkValues(connector, resource, recordId) }); - // execute hook if needed - for (const hook of listify(resource.hooks?.delete?.afterSave)) { - const resp = await hook({ + const afterHooks = listify(resource.hooks?.delete?.afterSave); + const runAfterHook = async (hook: typeof afterHooks[number]) => { + const resp = await hook({ resource, record, adminUser, @@ -997,15 +1024,44 @@ class AdminForth implements IAdminForth { response, extra, }); - const hookRespError = hookResponseError(resp); - if (hookRespError) { - return hookRespError; + return bulkHooks ? null : hookResponseError(resp)?.error; + }; + if (bulkHooks) { + // Returned afterSave errors never changed the legacy bulk action result. + await Promise.all(afterHooks.map(runAfterHook)); + } else { + for (const hook of afterHooks) { + const error = await runAfterHook(hook); + if (error) { + return { error }; + } } } return { error: null }; } + private warnedDeprecatedResourceMutations = new Set(); + + private warnDeprecatedResourceMutation( + method: 'createResourceRecord' | 'updateResourceRecord' | 'deleteResourceRecord', + resourceId: string, + operation: 'create' | 'update' | 'delete', + ): void { + // these run on every CRUD action of every plugin, so warn once per resource and method + const warnKey = `${resourceId}.${method}`; + if (this.warnedDeprecatedResourceMutations.has(warnKey)) { + return; + } + this.warnedDeprecatedResourceMutations.add(warnKey); + afLogger.trace( + `${method} is deprecated and will be removed in the next major version. ` + + `Use adminforth.resource('${resourceId}').asUser(adminUser, { meta }).${operation}(...) ` + + `for anything a user requested, or adminforth.resource('${resourceId}').${operation}(...) ` + + `for plain data access.`, + ); + } + async runAction({ resourceId, actionId, diff --git a/adminforth/modules/columnAccess.ts b/adminforth/modules/columnAccess.ts new file mode 100644 index 000000000..de30f1ccf --- /dev/null +++ b/adminforth/modules/columnAccess.ts @@ -0,0 +1,237 @@ +import type { + AdminForthResource, + AllowedActionValue, + BackendOnlyInput, + IAdminForth, + IAdminForthSort, +} from '../types/Back.js'; +import { + ActionCheckSource, + type AdminUser, +} from '../types/Common.js'; + +/** + * Everything a column rule needs to resolve itself. Column rules may be plain booleans or + * functions of the current user and request, so they can only be answered in a context. + */ +export interface ColumnAccessContext { + adminUser: AdminUser; + resource: AdminForthResource; + meta: any; + source: ActionCheckSource; + adminforth: IAdminForth; +} + +export async function resolveBoolOrFn( + value: BackendOnlyInput | AllowedActionValue | undefined, + ctx: ColumnAccessContext, +): Promise { + if (typeof value === 'function') { + return !!(await value(ctx)); + } + return !!value; +} + +export async function isBackendOnly( + column: AdminForthResource['columns'][number], + ctx: ColumnAccessContext, +): Promise { + return resolveBoolOrFn(column.backendOnly, ctx); +} + +export async function isShown( + column: AdminForthResource['columns'][number], + page: 'list' | 'show' | 'edit' | 'create' | 'filter', + ctx: ColumnAccessContext, +): Promise { + const showIn = column.showIn as Record | undefined; + if (showIn?.[page] !== undefined) { + return resolveBoolOrFn(showIn[page], ctx); + } + if (showIn?.all !== undefined) { + return resolveBoolOrFn(showIn.all, ctx); + } + return true; +} + +/** + * Checks every field the caller wants to write against the column rules which restrict writing: + * backendOnly, editReadonly, showIn plus its allowModifyWhenNotShowIn* / fillOnCreate escapes. + * + * @returns the reason the record cannot be written, or null when it can. + */ +export async function recordWriteError( + ctx: ColumnAccessContext, + record: Record, + mode: 'create' | 'edit', +): Promise { + for (const column of ctx.resource.columns) { + const fieldName = column.name; + if (!(fieldName in record)) { + continue; + } + + if (await isBackendOnly(column, ctx)) { + return `Field "${fieldName}" cannot be modified as it is restricted from ` + + `${mode === 'create' ? 'creation' : 'editing'} (backendOnly is true).`; + } + + const shown = await isShown(column, mode, ctx); + + if (mode === 'create') { + if (!shown && !column.fillOnCreate && !column.allowModifyWhenNotShowInCreate) { + return `Field "${fieldName}" cannot be modified as it is restricted from creation ` + + `(showIn.create is false). If you need to set this hidden field during creation, either ` + + `configure column.fillOnCreate or set column.allowModifyWhenNotShowInCreate = true.`; + } + continue; + } + + if (column.editReadonly) { + return `Field "${fieldName}" cannot be modified as it is restricted from editing ` + + `(editReadonly is true).`; + } + + if (!shown && !column.allowModifyWhenNotShowInEdit) { + return `Field "${fieldName}" cannot be modified as it is restricted from editing ` + + `(showIn.edit is false). If you need to allow updating this hidden field during editing, ` + + `set column.allowModifyWhenNotShowInEdit = true.`; + } + } + + return null; +} + +/** + * Drops in place every key the user is not allowed to read: backendOnly columns, and keys which + * are not described in the resource at all. + */ +export async function stripReadForbiddenColumns( + ctx: ColumnAccessContext, + record: Record, +): Promise> { + for (const key of Object.keys(record)) { + const column = ctx.resource.columns.find((candidate) => candidate.name === key); + if (!column || await isBackendOnly(column, ctx)) { + delete record[key]; + } + } + + return record; +} + +/** + * Collects every column name referenced anywhere in a (possibly nested) filter tree, + * so the caller can check those columns against the visibility rules. + */ +export function collectFilterFields(filters: any, fields: Set = new Set()): Set { + if (!filters || typeof filters !== 'object') { + return fields; + } + + if (Array.isArray(filters)) { + filters.forEach((filter) => collectFilterFields(filter, fields)); + return fields; + } + + if (typeof filters.field === 'string') { + fields.add(filters.field); + } + if (typeof filters.rightField === 'string') { + fields.add(filters.rightField); + } + if (Array.isArray(filters.subFilters)) { + filters.subFilters.forEach((filter) => collectFilterFields(filter, fields)); + } + + return fields; +} + +/** + * Filter values are never echoed back, but combined with any readable output they turn into an + * oracle which reads a hidden value out one comparison at a time, so backendOnly columns must not + * be filterable either. + * + * @returns the reason the filter cannot be used, or null when it can. + */ +export async function filterColumnsReadableError( + ctx: ColumnAccessContext, + filters: any, +): Promise { + for (const fieldName of collectFilterFields(filters)) { + const column = ctx.resource.columns.find((candidate) => candidate.name === fieldName); + if (column && await isBackendOnly(column, ctx)) { + return `Filter: column "${fieldName}" cannot be used (backendOnly is true).`; + } + } + + return null; +} + +/** Sorting can reveal a hidden field through the order of otherwise readable rows. */ +export async function sortColumnsReadableError( + ctx: ColumnAccessContext, + sort: IAdminForthSort | IAdminForthSort[], +): Promise { + const rules = Array.isArray(sort) ? sort : [sort]; + for (const rule of rules) { + const column = ctx.resource.columns.find((candidate) => candidate.name === rule.field); + if (column && await isBackendOnly(column, ctx)) { + return `Sort: column "${rule.field}" cannot be used (backendOnly is true).`; + } + } + return null; +} + +/** + * A column may only take part in an aggregation if the user could have read the very same value + * from the show view, otherwise min/max/groupBy become a way to read hidden columns. + * + * @returns the reason the aggregation cannot run, or null when it can. + */ +export async function columnsAggregatableError( + ctx: ColumnAccessContext, + query: { + aggregations?: { [alias: string]: { field?: string } }; + groupBy?: { field?: string } | Array<{ field?: string }>; + filters?: any; + }, +): Promise { + const exposureError = async (fieldName: string, label: string): Promise => { + const column = ctx.resource.columns.find((candidate) => candidate.name === fieldName); + if (!column) { + return `${label}: unknown column "${fieldName}"`; + } + if (await isBackendOnly(column, ctx)) { + return `${label}: column "${fieldName}" cannot be aggregated (backendOnly is true).`; + } + if (!await isShown(column, 'show', ctx)) { + return `${label}: column "${fieldName}" cannot be aggregated (showIn.show is false).`; + } + return null; + }; + + for (const [alias, rule] of Object.entries(query.aggregations || {})) { + // plain count does not reference any column + if (!rule?.field) { + continue; + } + const error = await exposureError(rule.field, `Aggregation "${alias}"`); + if (error) { + return error; + } + } + + const groupByRules = Array.isArray(query.groupBy) ? query.groupBy : (query.groupBy ? [query.groupBy] : []); + for (const groupByRule of groupByRules) { + if (!groupByRule?.field) { + continue; + } + const error = await exposureError(groupByRule.field, 'GroupBy'); + if (error) { + return error; + } + } + + return filterColumnsReadableError(ctx, query.filters); +} diff --git a/adminforth/modules/configValidator.ts b/adminforth/modules/configValidator.ts index 88859e435..bba1c8220 100644 --- a/adminforth/modules/configValidator.ts +++ b/adminforth/modules/configValidator.ts @@ -34,7 +34,6 @@ import { import AdminForth from "adminforth"; import { AdminForthConfigMenuItem } from "adminforth"; import { afLogger } from "./logger.js"; -import {cascadeChildrenDelete} from './utils.js' const DEBOUNCE_TIME_MS = 300; const DEFAULT_AUTH_RATE_LIMIT: RateLimitString[] = ['500/5m', '5000/1h', '10000/1d']; @@ -261,60 +260,29 @@ export default class ConfigValidator implements IConfigValidator { }, dangerous: true, allowed: async ({ resource, adminUser, allowedActions }) => { return allowedActions.delete }, - action: async ({ selectedIds, adminUser, response }) => { - const connector = this.adminforth.connectors[res.dataSource]; - - // for now if at least one error, stop and return error + action: async ({ selectedIds, adminUser, response, extra }) => { let error = null; await Promise.all( selectedIds.map(async (recordId) => { - const record = await connector.getRecordByPrimaryKey(res as AdminForthResource, recordId); - - await Promise.all( - (res.hooks.delete.beforeSave).map( - async (hook) => { - const resp = await hook({ - recordId: recordId, - resource: res as AdminForthResource, - record, - adminUser, - response, - adminforth: this.adminforth - }); - if (!error && resp.error) { - error = resp.error; - } - } - ) - ) - - if (error) { - return; + try { + const deleted = await this.adminforth + .resource(res.resourceId) + .asUser(adminUser, { + meta: { requestBody: extra?.body }, + response, + extra, + bulkDeleteHooks: true, + }) + .delete(recordId); + if (!deleted) { + throw new Error(`Record with ${recordId} not found`); + } + } catch (e) { + if (!error) { + error = (e as Error).message; + } } - - await cascadeChildrenDelete(res as AdminForthResource, recordId, { adminUser, response}, this.adminforth); - await connector.deleteRecord({ - resource: res as AdminForthResource, - recordId, - pkValues: compositePkValues(connector, res as AdminForthResource, recordId), - }); - - await Promise.all( - (res.hooks.delete.afterSave).map( - async (hook) => { - await hook({ - resource: res as AdminForthResource, - record, - adminUser, - recordId: recordId, - response, - adminforth: this.adminforth, - }); - } - ) - ) - }) ); diff --git a/adminforth/modules/operationalResource.ts b/adminforth/modules/operationalResource.ts index be8006ab6..d18635131 100644 --- a/adminforth/modules/operationalResource.ts +++ b/adminforth/modules/operationalResource.ts @@ -1,21 +1,59 @@ -import { IAdminForthSingleFilter, IAdminForthAndOrFilter, IAdminForthSort, IOperationalResource, IAdminForthDataSourceConnectorBase, AdminForthResource, IAggregationRule, IGroupByRule } from '../types/Back.js'; +import type { + AdminForthResource, + CreateResourceRecordResult, + IAdminForthAndOrFilter, + IAdminForthDataSourceConnectorBase, + IAdminForthSingleFilter, + IAdminForthSort, + IAggregationRule, + IGroupByRule, + IOperationalResource, + IScopedOperationalResource, + OperationalResourceUserOptions, +} from '../types/Back.js'; +import type { AdminUser } from '../types/Common.js'; import { compositePkValues } from './recordId.js'; -import { AdminForthFilterOperators } from '../types/Common.js'; import { normalizeRecordValues } from './columnValueNormalizer.js'; +/** + * Builds the user-scoped layer on top of a data-access resource. Injected by AdminForth so this + * module stays at the bottom of the stack: it knows the connector and the resource columns, and + * nothing about permissions, actions or lifecycle hooks. + */ +export type UserScopeFactory = ( + data: OperationalResource, + adminUser: AdminUser, + options: OperationalResourceUserOptions, +) => IScopedOperationalResource; + function sortsIfSort(sort: IAdminForthSort | IAdminForthSort[]): IAdminForthSort[] { return (Array.isArray(sort) ? sort : [sort]) as IAdminForthSort[]; } +/** + * Plain data access for one resource: talks to the connector and normalizes values. + * It has no notion of who is asking — no permissions, no column access + * rules, no lifecycle hooks. + * + * For anything a user asked for, take {@link asUser}, which adds those on top. + */ export default class OperationalResource implements IOperationalResource { dataConnector: IAdminForthDataSourceConnectorBase; resourceConfig: AdminForthResource; - constructor(dataConnector: IAdminForthDataSourceConnectorBase, resourceConfig: AdminForthResource) { + constructor( + dataConnector: IAdminForthDataSourceConnectorBase, + resourceConfig: AdminForthResource, + private readonly scopeForUser: UserScopeFactory, + ) { this.dataConnector = dataConnector; this.resourceConfig = resourceConfig; } + asUser(adminUser: AdminUser, options: OperationalResourceUserOptions = {}): IScopedOperationalResource { + return this.scopeForUser(this, adminUser, options); + } + async get(filter: IAdminForthSingleFilter | IAdminForthAndOrFilter | Array): Promise { return ( await this.dataConnector.getData({ @@ -29,13 +67,12 @@ export default class OperationalResource implements IOperationalResource { } async list( - filter: IAdminForthSingleFilter | IAdminForthAndOrFilter | Array, - limit: number | null = null, + filter: IAdminForthSingleFilter | IAdminForthAndOrFilter | Array, + limit: number | null = null, offset: number | null = null, sort: IAdminForthSort | IAdminForthSort[] = [], columns?: string[] ): Promise { - // check if type of limit and offset is number if (limit !== null && typeof limit !== 'number') { throw new Error('Limit must be a number'); } @@ -43,20 +80,11 @@ export default class OperationalResource implements IOperationalResource { throw new Error('Offset must be a number'); } - let appliedLimit = limit; - if (limit === null) { - appliedLimit = 1000000000; - } - let appliedOffset = offset; - if (offset === null) { - appliedOffset = 0; - } - const { data } = await this.dataConnector.getData({ resource: this.resourceConfig, filters: this.dataConnector.validateAndNormalizeInputFilters(filter), - limit: appliedLimit, - offset: appliedOffset, + limit: limit === null ? 1000000000 : limit, + offset: offset === null ? 0 : offset, sort: sortsIfSort(sort), getTotals: false, columns: columns ? this.resourceConfig.dataSourceColumns.filter((column) => columns.includes(column.name)) : undefined, @@ -64,7 +92,6 @@ export default class OperationalResource implements IOperationalResource { return data; } - async aggregate( filter: IAdminForthSingleFilter | IAdminForthAndOrFilter | Array, aggregations: { [alias: string]: IAggregationRule }, @@ -85,13 +112,13 @@ export default class OperationalResource implements IOperationalResource { }); } - async create(recordValues: any): Promise<{ ok: boolean; createdRecord: any; error?: string; }> { + async create(recordValues: any): Promise { const normalizedRecord = { ...recordValues }; normalizeRecordValues(this.resourceConfig, normalizedRecord); - const { ok, createdRecord, error } = await this.dataConnector.createRecord({ - resource: this.resourceConfig, + const { ok, createdRecord, error } = await this.dataConnector.createRecord({ + resource: this.resourceConfig, record: normalizedRecord, - adminUser: null + adminUser: null, }); return { ok, createdRecord, error }; } @@ -103,11 +130,10 @@ export default class OperationalResource implements IOperationalResource { const normalizedRecord = { ...record }; normalizeRecordValues(this.resourceConfig, normalizedRecord); - - return await this.dataConnector.updateRecord({ + return await this.dataConnector.updateRecord({ resource: this.resourceConfig, recordId: primaryKey, - newValues: normalizedRecord + newValues: normalizedRecord, }); } @@ -118,5 +144,4 @@ export default class OperationalResource implements IOperationalResource { pkValues: compositePkValues(this.dataConnector, this.resourceConfig, primaryKey), }); } - } diff --git a/adminforth/modules/polymorphicReferences.ts b/adminforth/modules/polymorphicReferences.ts new file mode 100644 index 000000000..b428fa8e7 --- /dev/null +++ b/adminforth/modules/polymorphicReferences.ts @@ -0,0 +1,52 @@ +import type { AdminForthResource, IAdminForth } from '../types/Back.js'; +import { Filters } from '../types/Back.js'; + +/** Fill discriminator columns only after user-provided columns have passed access checks. */ +export async function resolvePolymorphicReferences( + resource: AdminForthResource, + record: Record, + adminforth: IAdminForth, + oldRecord?: Record, +): Promise { + for (const column of resource.columns) { + const foreignResource = column.foreignResource; + if (!foreignResource?.polymorphicOn || !(column.name in record)) { + continue; + } + + let discriminator: string | null | undefined = oldRecord ? null : undefined; + if (record[column.name] === null) { + record[foreignResource.polymorphicOn] = foreignResource.polymorphicResources.find((target) => target.resourceId === null).whenValue; + continue; + } + if (record[column.name]) { + for (const target of foreignResource.polymorphicResources) { + if (target.resourceId === null) { + continue; + } + const targetResource = adminforth.config.resources.find((candidate) => candidate.resourceId === target.resourceId); + if (!targetResource) { + continue; + } + const targetPrimaryKey = targetResource.columns.find((candidate) => candidate.primaryKey).name; + const { data } = await adminforth.connectors[targetResource.dataSource].getData({ + resource: targetResource, + limit: 1, + offset: 0, + filters: Filters.AND(Filters.EQ(targetPrimaryKey, record[column.name])), + sort: [], + }); + if (data.length) { + discriminator = target.whenValue; + break; + } + } + } else { + continue; + } + + if (!oldRecord || oldRecord[foreignResource.polymorphicOn] !== discriminator) { + record[foreignResource.polymorphicOn] = discriminator; + } + } +} diff --git a/adminforth/modules/recordValidator.ts b/adminforth/modules/recordValidator.ts new file mode 100644 index 000000000..acf0e28b1 --- /dev/null +++ b/adminforth/modules/recordValidator.ts @@ -0,0 +1,63 @@ +import type { AdminForthResource } from '../types/Back.js'; +import { applyRegexValidation } from './utils.js'; + +/** + * Column-level value rules: `validation` patterns and `minValue`/`maxValue` bounds. + * Depends on nothing but the resource columns, so any layer can apply it. + * + * @returns the first validation error, or null when the record passes. + */ +export function validateRecordValues(resource: AdminForthResource, record: any, mode: 'create' | 'edit'): string | null { + // check if record with validation is valid + for (const column of resource.columns.filter((col) => col.name in record && col.validation)) { + const required = typeof column.required === 'object' + ? column.required[mode] + : true; + + if (!required && !record[column.name]) continue; + + let error = null; + if (column.isArray?.enabled) { + error = record[column.name].reduce((err, item) => { + return err || applyRegexValidation(item, column.validation); + }, null); + } else { + error = applyRegexValidation(record[column.name], column.validation); + } + if (error) { + return error; + } + } + + // check if record with minValue or maxValue is within limits + for (const column of resource.columns.filter((col) => col.name in record + && ['integer', 'decimal', 'float'].includes(col.isArray?.enabled ? col.isArray.itemType : col.type) + && (col.minValue !== undefined || col.maxValue !== undefined))) { + if (column.isArray?.enabled) { + const error = record[column.name].reduce((err, item) => { + if (err) return err; + + if (column.minValue !== undefined && item < column.minValue) { + return `Value in "${column.name}" must be greater than ${column.minValue}`; + } + if (column.maxValue !== undefined && item > column.maxValue) { + return `Value in "${column.name}" must be less than ${column.maxValue}`; + } + + return null; + }, null); + if (error) { + return error; + } + } else { + if (column.minValue !== undefined && record[column.name] && record[column.name] < column.minValue) { + return `Value in "${column.name}" must be greater than ${column.minValue}`; + } + if (column.maxValue !== undefined && record[column.name] && record[column.name] > column.maxValue) { + return `Value in "${column.name}" must be less than ${column.maxValue}`; + } + } + } + + return null; +} diff --git a/adminforth/modules/resourceAccess.ts b/adminforth/modules/resourceAccess.ts new file mode 100644 index 000000000..e56218506 --- /dev/null +++ b/adminforth/modules/resourceAccess.ts @@ -0,0 +1,102 @@ +import type { AdminForthResource, AllowedActionValue, IAdminForth } from '../types/Back.js'; +import { + ActionCheckSource, + AllowedActionsEnum, + type AdminUser, + type AllowedActionsResolved, +} from '../types/Common.js'; +import { afLogger } from './logger.js'; + +export async function interpretResource( + adminUser: AdminUser, + resource: AdminForthResource, + meta: any, + source: ActionCheckSource, + adminforth: IAdminForth, +): Promise<{ allowedActions: AllowedActionsResolved }> { + afLogger.trace(`🪲Interpreting resource, ${resource.resourceId}, ${source}, 'adminUser', ${adminUser}`); + const allowedActions = {} as AllowedActionsResolved; + const neededActions = { + [ActionCheckSource.ShowRequest]: ['show'], + [ActionCheckSource.EditRequest]: ['edit'], + [ActionCheckSource.EditLoadRequest]: ['show'], + [ActionCheckSource.DeleteRequest]: ['delete'], + [ActionCheckSource.ListRequest]: ['list'], + [ActionCheckSource.CreateRequest]: ['create'], + [ActionCheckSource.DisplayButtons]: ['show', 'edit', 'delete', 'create', 'filter'], + [ActionCheckSource.BulkActionRequest]: ['show', 'edit', 'delete', 'create', 'filter'], + [ActionCheckSource.CustomActionRequest]: ['show', 'edit', 'delete', 'create', 'filter'], + }[source]; + + await Promise.all( + Object.entries(resource.options.allowedActions).map( + async ([key, value]: [string, AllowedActionValue]) => { + if (!neededActions.includes(key as AllowedActionsEnum)) { + allowedActions[key] = false; + return; + } + + allowedActions[key] = typeof value === 'function' + ? await value({ adminUser, resource, meta, source, adminforth }) + : value; + }, + ), + ); + + return { allowedActions }; +} + +export const RESOURCE_ACCESS_GRANT = Symbol('resourceAccessGrant'); + +type ResourceAccessGrant = object; + +const grantedOperations = new WeakMap(); + +/** The REST preflight checks access before returning field or existence errors. */ +export async function authorizeResourceOperation( + adminUser: AdminUser, + resource: AdminForthResource, + meta: any, + source: ActionCheckSource, + action: AllowedActionsEnum, + adminforth: IAdminForth, + record: any, + primaryKey?: any, +): Promise<{ error: string | null; grant?: ResourceAccessGrant }> { + const { allowedActions } = await interpretResource(adminUser, resource, meta, source, adminforth); + const allowed = allowedActions[action] as boolean | string | undefined; + if (allowed !== true) { + return { error: typeof allowed === 'string' ? allowed : 'Action is not allowed' }; + } + + const grant = {}; + grantedOperations.set(grant, { adminUser, resource, action, record, primaryKey }); + return { error: null, grant }; +} + +/** A grant can only skip the matching scoped ACL check once; row scope still runs normally. */ +export function consumeResourceAccessGrant( + grant: ResourceAccessGrant | undefined, + adminUser: AdminUser, + resource: AdminForthResource, + action: AllowedActionsEnum, + record: any, + primaryKey?: any, +): boolean { + if (!grant) { + return false; + } + const granted = grantedOperations.get(grant); + grantedOperations.delete(grant); + return granted?.adminUser === adminUser + && granted.resource === resource + && granted.action === action + && granted.record === record + && granted.primaryKey === primaryKey; +} diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index fe56b5af4..bac056c8d 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -14,15 +14,11 @@ import { IAdminForthSort, HttpExtra, IAdminForthAndOrFilter, - IAggregationRule, - BackendOnlyInput, Filters, } from "../types/Back.js"; import type { AnySchemaObject } from 'ajv'; -import {cascadeChildrenDelete} from './utils.js' import { encodeRecordId, isCompositePrimaryKey, primaryKeyColumnNames } from './recordId.js'; - import { afLogger } from "./logger.js"; import { ADMINFORTH_VERSION, listify, md5hash, getLoginPromptHTML, hookResponseError, parseLooseJson, RateLimiter } from './utils.js'; @@ -30,58 +26,18 @@ import { ADMINFORTH_VERSION, listify, md5hash, getLoginPromptHTML, hookResponseE import AdminForthAuth from "../auth.js"; import { ActionCheckSource, AdminForthActionFront, AdminForthConfigMenuItem, AdminForthDataTypes, AdminForthFilterOperators, AdminForthResourceColumnInputCommon, AdminForthResourceFrontend, AdminForthResourcePages, AdminForthSortDirections, - AdminUser, AllowedActionsEnum, AllowedActionsResolved, + AdminUser, AllowedActionsEnum, AnnouncementBadgeResponse, GetConfigResponse, ShowInResolved} from "../types/Common.js"; import { filtersTools } from "../modules/filtersTools.js"; import { normalizeColumnValue } from './columnValueNormalizer.js'; - - -async function resolveBoolOrFn( - val: BackendOnlyInput | undefined, - ctx: { - adminUser: AdminUser; - resource: AdminForthResource; - meta: any; - source: ActionCheckSource; - adminforth: IAdminForth; - } -): Promise { - if (typeof val === 'function') { - return !!(await (val)(ctx)); - } - return !!val; -} - -async function isBackendOnly( - col: AdminForthResource['columns'][number], - ctx: { - adminUser: AdminUser; - resource: AdminForthResource; - meta: any; - source: ActionCheckSource; - adminforth: IAdminForth; - } -): Promise { - return await resolveBoolOrFn(col.backendOnly, ctx); -} - -async function isShown( - col: AdminForthResource['columns'][number], - page: 'list' | 'show' | 'edit' | 'create' | 'filter', - ctx: Parameters[1] -): Promise { - const s = (col.showIn as any) || {}; - if (s[page] !== undefined) return await resolveBoolOrFn(s[page], ctx); - if (s.all !== undefined) return await resolveBoolOrFn(s.all, ctx); - return true; -} - -async function isFilledOnCreate( col: AdminForthResource['columns'][number] ): Promise { - const fillOnCreate = !!col.fillOnCreate; - return fillOnCreate; -} +import { + isShown, + sortColumnsReadableError, + stripReadForbiddenColumns, +} from './columnAccess.js'; +import { authorizeResourceOperation, interpretResource, RESOURCE_ACCESS_GRANT } from './resourceAccess.js'; function stripResourceColumnFrontendMeta(column: Record) { const { default: _default, _baseTypeDebug, ...sanitizedColumn } = column; @@ -270,33 +226,6 @@ export function rejectApiRawFilters(filters: any): { error: string } | undefined } } -/** - * Collects every column name referenced anywhere in a (possibly nested) filter tree, - * so the caller can check those columns against the visibility rules. - */ -function collectFilterFields(filters: any, fields: Set = new Set()): Set { - if (!filters || typeof filters !== 'object') { - return fields; - } - - if (Array.isArray(filters)) { - filters.forEach((filter) => collectFilterFields(filter, fields)); - return fields; - } - - if (typeof filters.field === 'string') { - fields.add(filters.field); - } - if (typeof filters.rightField === 'string') { - fields.add(filters.rightField); - } - if (Array.isArray(filters.subFilters)) { - filters.subFilters.forEach((filter) => collectFilterFields(filter, fields)); - } - - return fields; -} - function createErrorOrSuccessSchema(successSchema: AnySchemaObject): AnySchemaObject { return { anyOf: [ @@ -646,55 +575,6 @@ const validateColumnsResponseSchema: AnySchemaObject = createErrorOrSuccessSchem additionalProperties: true, }); -export async function interpretResource( - adminUser: AdminUser, - resource: AdminForthResource, - meta: any, - source: ActionCheckSource, - adminforth: IAdminForth -): Promise<{allowedActions: AllowedActionsResolved}> { - afLogger.trace(`🪲Interpreting resource, ${resource.resourceId}, ${source}, 'adminUser', ${adminUser}`); - const allowedActions = {} as AllowedActionsResolved; - - // we need to compute only allowed actions for this source: - // 'show' needed for ActionCheckSource.showRequest and ActionCheckSource.editLoadRequest and ActionCheckSource.displayButtons - // 'edit' needed for ActionCheckSource.editRequest and ActionCheckSource.displayButtons - // 'delete' needed for ActionCheckSource.deleteRequest and ActionCheckSource.displayButtons and ActionCheckSource.bulkActionRequest - // 'list' needed for ActionCheckSource.listRequest - // 'create' needed for ActionCheckSource.createRequest and ActionCheckSource.displayButtons - // for bulk actions we need to check all actions because bulk action can use any of them e.g sync allowed with edit - const neededActions = { - [ActionCheckSource.ShowRequest]: ['show'], - [ActionCheckSource.EditRequest]: ['edit'], - [ActionCheckSource.EditLoadRequest]: ['show'], - [ActionCheckSource.DeleteRequest]: ['delete'], - [ActionCheckSource.ListRequest]: ['list'], - [ActionCheckSource.CreateRequest]: ['create'], - [ActionCheckSource.DisplayButtons]: ['show', 'edit', 'delete', 'create', 'filter'], - [ActionCheckSource.BulkActionRequest]: ['show', 'edit', 'delete', 'create', 'filter'], - [ActionCheckSource.CustomActionRequest]: ['show', 'edit', 'delete', 'create', 'filter'], - }[source]; - - await Promise.all( - Object.entries(resource.options.allowedActions).map( - async ([key, value]: [string, AllowedActionValue]) => { - if (!neededActions.includes(key as AllowedActionsEnum)) { - allowedActions[key] = false; - return; - } - - // if callable then call - if (typeof value === 'function') { - allowedActions[key] = await value({ adminUser, resource, meta, source, adminforth }); - } else { - allowedActions[key] = value; - } - }) - ); - - return { allowedActions }; -} - export default class AdminForthRestAPI implements IAdminForthRestAPI { adminforth: IAdminForth; @@ -1021,7 +901,9 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } const usersResource = this.adminforth.config.resources.find((res) => res.resourceId === this.adminforth.config.auth.usersResourceId); - const defaultUserExists = await this.adminforth.resource(usersResource.resourceId).get(Filters.EQ(usernameField, 'adminforth')) ? true : false; + const defaultUserExists = await this.adminforth + .resource(usersResource.resourceId) + .get(Filters.EQ(usernameField, 'adminforth')) ? true : false; const loggedInPart = { showBrandNameInSidebar: this.adminforth.config.customization.showBrandNameInSidebar, @@ -1090,13 +972,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { source: ActionCheckSource.ShowRequest, adminforth: this.adminforth, }; - for (const key of Object.keys(adminUser.dbUser)) { - const col = userResource.columns.find((c) => c.name === key); - const bo = col ? await isBackendOnly(col, ctx) : true; - if (!col || bo) { - delete adminUser.dbUser[key]; - } - } + await stripReadForbiddenColumns(ctx, adminUser.dbUser); return { loggedIn: true, @@ -1510,6 +1386,20 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { const col = resource.columns.find((col) => col.name === sortItem.field); return col && !col.virtual; }); + const sortError = await sortColumnsReadableError({ + adminUser, + resource, + meta, + source: { + show: ActionCheckSource.ShowRequest, + list: ActionCheckSource.ListRequest, + edit: ActionCheckSource.EditLoadRequest, + }[source], + adminforth: this.adminforth, + }, sortFiltered); + if (sortError) { + return { error: sortError }; + } // after beforeDatasourceRequest hook, filter can be anything // so, we need to turn it into AndOr filter @@ -1685,15 +1575,10 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { }; for (const item of data.data) { - for (const key of Object.keys(item)) { - if (key === '_primaryKeyValue') { - continue; - } - const col = resource.columns.find((c) => c.name === key); - const bo = col ? await isBackendOnly(col, ctx) : true; - if (!col || bo) { - delete item[key]; - } + const encodedId = item._primaryKeyValue; + await stripReadForbiddenColumns(ctx, item); + if (encodedId !== undefined) { + item._primaryKeyValue = encodedId; } if (!selectedColumnNameSet || shouldAddListHelpers) { item._label = resource.recordLabel(item); @@ -1845,82 +1730,6 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } const meta = { requestBody: body, pk: undefined }; - const { allowedActions } = await interpretResource( - adminUser, - resource, - meta, - ActionCheckSource.ListRequest, - this.adminforth - ); - - // aggregation reads a whole set of records at once, so it needs list access - const { allowed, error } = checkAccess(AllowedActionsEnum.list, allowedActions); - if (!allowed) { - return { error }; - } - - // ...and min/max/groupBy return raw per-field values, which is what the show view does, - // so a resource with no reachable show view must not be aggregatable either - const { allowed: showAllowed, error: showError } = checkAccess(AllowedActionsEnum.show, allowedActions); - if (!showAllowed) { - return { error: showError }; - } - - const columnCtx = { - adminUser, - resource, - meta, - source: ActionCheckSource.ShowRequest, - adminforth: this.adminforth, - }; - - // a column may only take part in an aggregation if the user could have read the - // very same value from the show view - const columnExposureError = async (fieldName: string, context: string): Promise => { - const column = resource.columns.find((col) => col.name === fieldName); - if (!column) { - return `${context}: unknown column "${fieldName}"`; - } - if (await isBackendOnly(column, columnCtx)) { - return `${context}: column "${fieldName}" cannot be aggregated (backendOnly is true).`; - } - if (!await isShown(column, 'show', columnCtx)) { - return `${context}: column "${fieldName}" cannot be aggregated (showIn.show is false).`; - } - return null; - }; - - for (const [alias, rule] of Object.entries((aggregations || {}) as { [alias: string]: IAggregationRule })) { - // plain count does not reference any column - if (!rule?.field) { - continue; - } - const fieldError = await columnExposureError(rule.field, `Aggregation "${alias}"`); - if (fieldError) { - return { error: fieldError }; - } - } - - const groupByRules = Array.isArray(groupBy) ? groupBy : (groupBy ? [groupBy] : []); - for (const groupByRule of groupByRules) { - if (!groupByRule?.field) { - continue; - } - const fieldError = await columnExposureError(groupByRule.field, 'GroupBy'); - if (fieldError) { - return { error: fieldError }; - } - } - - // filters are not returned to the caller, but combined with an aggregation they turn - // into an oracle which reads a value out one comparison at a time, so backendOnly - // columns are off limits here as well - for (const fieldName of collectFilterFields(filters)) { - const column = resource.columns.find((col) => col.name === fieldName); - if (column && await isBackendOnly(column, columnCtx)) { - return { error: `Filter: column "${fieldName}" cannot be used (backendOnly is true).` }; - } - } // normalize filters same way as get_resource_data const normalizedFilters = { operator: AdminForthFilterOperators.AND, subFilters: [] }; @@ -1949,12 +1758,10 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { ? groupBy.map(applyUserTimeZone) : applyUserTimeZone(groupBy); - const data = await this.adminforth.connectors[resource.dataSource].aggregate({ - resource, - filters: normalizedFilters as IAdminForthAndOrFilter, - aggregations, - groupBy: aggregateGroupBy, - }); + const data = await this.adminforth + .resource(resource.resourceId) + .asUser(adminUser, { meta }) + .aggregate(normalizedFilters as IAdminForthAndOrFilter, aggregations, aggregateGroupBy); return { data }; } catch (e) { return { error: e.message }; @@ -2205,13 +2012,14 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { if (!resource) { return { error: `Resource '${body['resourceId']}' not found` }; } - const { allowedActions } = await interpretResource( - adminUser, resource, { requestBody: body }, ActionCheckSource.CreateRequest, this.adminforth + // Check before revealing existing records or required columns. The scoped write + // consumes this one-use grant, so the ACL callback is not invoked twice. + const createAccess = await authorizeResourceOperation( + adminUser, resource, { requestBody: body }, ActionCheckSource.CreateRequest, + AllowedActionsEnum.create, this.adminforth, body.record, ); - - const { allowed, error } = checkAccess(AllowedActionsEnum.create, allowedActions); - if (!allowed) { - return { error }; + if (createAccess.error) { + return { error: createAccess.error }; } const { record, requiredColumnsToSkip } = body; @@ -2246,7 +2054,8 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } else { const primaryKeyColumn = resource.columns.find((col) => col.primaryKey); if (record[primaryKeyColumn.name] !== undefined) { - const existingRecord = await this.adminforth.resource(resource.resourceId).get([Filters.EQ(primaryKeyColumn.name, record[primaryKeyColumn.name])]); + const existingRecord = await this.adminforth.resource(resource.resourceId) + .get([Filters.EQ(primaryKeyColumn.name, record[primaryKeyColumn.name])]); if (existingRecord) { return { error: `Record with ${primaryKeyColumn.name} '${record[primaryKeyColumn.name]}' already exists`, ok: false }; } @@ -2273,80 +2082,22 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } } - for (const column of resource.columns) { - const fieldName = column.name; - if (fieldName in record) { - const shown = await isShown(column, 'create', ctxCreate); // - const bo = await isBackendOnly(column, ctxCreate); - const filledOnCreate = await isFilledOnCreate(column); - if (bo) { - return { - error: `Field "${fieldName}" cannot be modified as it is restricted from creation (backendOnly is true).`, - ok: false, - }; - } - - if (!shown && !filledOnCreate && !column.allowModifyWhenNotShowInCreate) { - return { - error: `Field "${fieldName}" cannot be modified as it is restricted from creation (showIn.create is false). If you need to set this hidden field during creation, either configure column.fillOnCreate or set column.allowModifyWhenNotShowInCreate = true.`, - ok: false, - }; - } - } - } - - // for polymorphic foreign resources, we need to find out the value for polymorphicOn column - for (const column of resource.columns) { - if (column.foreignResource?.polymorphicOn && record[column.name] === null) { - const systemResource = column.foreignResource.polymorphicResources.find(pr => pr.resourceId === null); - record[column.foreignResource.polymorphicOn] = systemResource.whenValue; - } else if (column.foreignResource?.polymorphicOn && record[column.name]) { - const targetResources = {}; - const targetConnectors = {}; - const targetResourcePkFields = {}; - column.foreignResource.polymorphicResources.forEach((pr) => { - if (pr.resourceId === null) { - return; - } - const targetResource = this.adminforth.config.resources.find((res) => res.resourceId == pr.resourceId); - if (!targetResource) { - return; - } - targetResources[pr.whenValue] = targetResource; - targetConnectors[pr.whenValue] = this.adminforth.connectors[targetResources[pr.whenValue].dataSource]; - targetResourcePkFields[pr.whenValue] = targetResources[pr.whenValue].columns.find((col) => col.primaryKey).name; - }); - const targetData = (await Promise.all(Object.keys(targetResources).map((polymorphicOnValue) => - targetConnectors[polymorphicOnValue].getData({ - resource: targetResources[polymorphicOnValue], - limit: 1, - offset: 0, - filters: { operator: AdminForthFilterOperators.AND, subFilters: [ - { - field: targetResourcePkFields[polymorphicOnValue], - operator: AdminForthFilterOperators.EQ, - value: record[column.name], - } - ]}, - sort: [], - }) - ))).reduce((acc: any, td: any, tdi) => ({ - ...acc, - [Object.keys(targetResources)[tdi]]: td.data, - }), {}); - record[column.foreignResource.polymorphicOn] = Object.keys(targetData).find((tdk) => targetData[tdk].length); - } - } const jsonError = this.normalizeJsonColumns(resource, record); if (jsonError) { return { error: jsonError, ok: false }; } - const createRecordResponse = await this.adminforth.createResourceRecord({ - resource, record, adminUser, response, - extra: { body, query, headers, cookies, requestUrl, response } - }); + const scopedCreateOptions = { + meta: ctxCreate.meta, + response, + extra: { body, query, headers, cookies, requestUrl, response }, + [RESOURCE_ACCESS_GRANT]: createAccess.grant, + }; + const createRecordResponse = await this.adminforth + .resource(resource.resourceId) + .asUser(adminUser, scopedCreateOptions) + .create(record); if (createRecordResponse.error) { return { error: createRecordResponse.error, @@ -2386,23 +2137,25 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { const recordId = body['recordId']; const connector = this.adminforth.connectors[resource.dataSource]; const oldRecord = await connector.getRecordByPrimaryKey(resource, recordId) - if (!oldRecord) { - const primaryKeyColumn = resource.columns.find((col) => col.primaryKey); - return { error: `Record with ${isCompositePrimaryKey(resource) ? primaryKeyColumnNames(resource).join(', ') : primaryKeyColumn.name} ${recordId} not found` }; - } const record = body['record']; - const { allowedActions } = await interpretResource( - adminUser, - resource, - { requestBody: body, newRecord: record, oldRecord, pk: recordId }, + // Check before revealing whether another record has the requested key. + const editAccess = await authorizeResourceOperation( + adminUser, + resource, + { requestBody: body, newRecord: record, oldRecord, pk: recordId }, ActionCheckSource.EditRequest, - this.adminforth + AllowedActionsEnum.edit, + this.adminforth, + record, + recordId, ); - - const { allowed, error: allowedError } = checkAccess(AllowedActionsEnum.edit, allowedActions); - if (!allowed) { - return { error: allowedError }; + if (editAccess.error) { + return { error: editAccess.error }; + } + if (!oldRecord) { + const primaryKeyColumn = resource.columns.find((col) => col.primaryKey); + return { error: `Record with ${isCompositePrimaryKey(resource) ? primaryKeyColumnNames(resource).join(', ') : primaryKeyColumn.name} ${recordId} not found` }; } if (isCompositePrimaryKey(resource)) { @@ -2428,107 +2181,30 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } else { const primaryKeyColumn = resource.columns.find((col) => col.primaryKey); if (record[primaryKeyColumn.name] !== undefined) { - const existingRecord = await this.adminforth.resource(resource.resourceId).get([Filters.EQ(primaryKeyColumn.name, record[primaryKeyColumn.name])]); + const existingRecord = await this.adminforth.resource(resource.resourceId) + .get([Filters.EQ(primaryKeyColumn.name, record[primaryKeyColumn.name])]); if (existingRecord) { return { error: `Record with ${primaryKeyColumn.name} '${record[primaryKeyColumn.name]}' already exists`, ok: false }; } } } - const ctxEdit = { - adminUser, - resource, - meta: { requestBody: body, newRecord: record, oldRecord, pk: recordId }, - source: ActionCheckSource.EditRequest, - adminforth: this.adminforth, - }; - - for (const column of resource.columns) { - const fieldName = column.name; - if (fieldName in record) { - const shown = await isShown(column, 'edit', ctxEdit); - const bo = await isBackendOnly(column, ctxEdit); - if (bo) { - return { - error: `Field "${fieldName}" cannot be modified as it is restricted from editing (backendOnly is true).`, - ok: false, - }; - } - - if (column.editReadonly) { - return { - error: `Field "${fieldName}" cannot be modified as it is restricted from editing (editReadonly is true).`, - ok: false, - }; - } - - if (!shown && !column.allowModifyWhenNotShowInEdit) { - return { - error: `Field "${fieldName}" cannot be modified as it is restricted from editing (showIn.edit is false). If you need to allow updating this hidden field during editing, set column.allowModifyWhenNotShowInEdit = true.`, - ok: false, - }; - } - } - } - // for polymorphic foreign resources, we need to find out the value for polymorphicOn column - for (const column of resource.columns) { - if (column.foreignResource?.polymorphicOn && record[column.name] === null) { - const systemResource = column.foreignResource.polymorphicResources.find(pr => pr.resourceId === null); - record[column.foreignResource.polymorphicOn] = systemResource.whenValue; - } else if (column.foreignResource?.polymorphicOn && record[column.name]) { - let newPolymorphicOnValue = null; - if (record[column.name]) { - const targetResources = {}; - const targetConnectors = {}; - const targetResourcePkFields = {}; - column.foreignResource.polymorphicResources.forEach((pr) => { - if (pr.resourceId === null) { - return; - } - const targetResource = this.adminforth.config.resources.find((res) => res.resourceId == pr.resourceId); - if (!targetResource) { - return; - } - targetResources[pr.whenValue] = targetResource; - targetConnectors[pr.whenValue] = this.adminforth.connectors[targetResources[pr.whenValue].dataSource]; - targetResourcePkFields[pr.whenValue] = targetResources[pr.whenValue].columns.find((col) => col.primaryKey).name; - }); - const targetData = (await Promise.all(Object.keys(targetResources).map((polymorphicOnValue) => - targetConnectors[polymorphicOnValue].getData({ - resource: targetResources[polymorphicOnValue], - limit: 1, - offset: 0, - filters: { operator: AdminForthFilterOperators.AND, subFilters: [ - { - field: targetResourcePkFields[polymorphicOnValue], - operator: AdminForthFilterOperators.EQ, - value: record[column.name], - } - ]}, - sort: [], - }) - ))).reduce((acc: any, td: any, tdi) => ({ - ...acc, - [Object.keys(targetResources)[tdi]]: td.data, - }), {}); - newPolymorphicOnValue = Object.keys(targetData).find((tdk) => targetData[tdk].length); - } - - if (oldRecord[column.foreignResource.polymorphicOn] !== newPolymorphicOnValue) { - record[column.foreignResource.polymorphicOn] = newPolymorphicOnValue; - } - } - } - const jsonError = this.normalizeJsonColumns(resource, record); if (jsonError) { return { error: jsonError, ok: false }; } - const { error } = await this.adminforth.updateResourceRecord({ - resource, updates: record, adminUser, oldRecord, recordId, response, - extra: { body, query, headers, cookies, requestUrl, response } - }); + const scopedEditOptions = { + meta: { requestBody: body, newRecord: record, oldRecord, pk: recordId }, + oldRecord, + response, + extra: { body, query, headers, cookies, requestUrl, response }, + [RESOURCE_ACCESS_GRANT]: editAccess.grant, + }; + const { error } = await this.adminforth + .resource(resource.resourceId) + .asUser(adminUser, scopedEditOptions) + .update(recordId, record); if (error) { return { error }; } @@ -2555,34 +2231,40 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { return { error: `Resource '${body['resourceId']}' not found` }; } const record = await this.adminforth.connectors[resource.dataSource].getRecordByPrimaryKey(resource, body['primaryKey']); - if (!record){ - return { error: `Record with ${body['primaryKey']} not found` }; - } - - const { allowedActions } = await interpretResource( - adminUser, - resource, - { requestBody: body, record: record }, + const deleteAccess = await authorizeResourceOperation( + adminUser, + resource, + { requestBody: body, record }, ActionCheckSource.DeleteRequest, - this.adminforth + AllowedActionsEnum.delete, + this.adminforth, + record, + body.primaryKey, ); - - const { allowed, error } = checkAccess(AllowedActionsEnum.delete, allowedActions); - if (!allowed) { - return { error }; + if (deleteAccess.error) { + return { error: deleteAccess.error }; } - - const { error: cascadeError } = await cascadeChildrenDelete(resource, body.primaryKey, {adminUser, response}, this.adminforth); - if (cascadeError) { - return { error: cascadeError }; + if (!record) { + return { error: `Record with ${body['primaryKey']} not found` }; } - const { error: deleteError } = await this.adminforth.deleteResourceRecord({ - resource, record, adminUser, recordId: body['primaryKey'], response, - extra: { body, query, headers, cookies, requestUrl, response } - }); - if (deleteError) { - return { error: deleteError }; + try { + const scopedDeleteOptions = { + meta: { requestBody: body, record }, + record, + response, + extra: { body, query, headers, cookies, requestUrl, response }, + [RESOURCE_ACCESS_GRANT]: deleteAccess.grant, + }; + const deleted = await this.adminforth + .resource(resource.resourceId) + .asUser(adminUser, scopedDeleteOptions) + .delete(body.primaryKey); + if (!deleted) { + return { error: `Record with ${body.primaryKey} not found` }; + } + } catch (error) { + return { error: (error as Error).message }; } return { ok: true, @@ -2593,7 +2275,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { server.endpoint({ method: 'POST', path: '/start_bulk_action', - handler: async ({ body, adminUser, tr, response }) => { + handler: async ({ body, adminUser, tr, response, query, headers, cookies, requestUrl }) => { const { resourceId, actionId, recordIds } = body; const resource = this.adminforth.config.resources.find((res) => res.resourceId == resourceId); if (!resource) { @@ -2618,7 +2300,14 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { return { error: await tr(`Action "{actionId}" not allowed`, 'errors', { actionId: action.label }) }; } } - const bulkActionResponse = await action.action({selectedIds: recordIds, adminUser, resource, response, tr}); + const bulkActionResponse = await action.action({ + selectedIds: recordIds, + adminUser, + resource, + response, + tr, + extra: { body, query, headers, cookies, requestUrl, response }, + }); return { actionId, diff --git a/adminforth/modules/userScopedResource.ts b/adminforth/modules/userScopedResource.ts new file mode 100644 index 000000000..c266d4710 --- /dev/null +++ b/adminforth/modules/userScopedResource.ts @@ -0,0 +1,413 @@ +import type { + AdminForthResource, + CreateResourceRecordParams, + CreateResourceRecordResult, + DeleteResourceRecordParams, + DeleteResourceRecordResult, + IAdminForth, + IAdminForthAndOrFilter, + IAdminForthDataSourceConnectorBase, + IAdminForthSingleFilter, + IAdminForthSort, + IAggregationRule, + IGroupByRule, + IScopedOperationalResource, + OperationalResourceUserOptions, + UpdateResourceRecordParams, + UpdateResourceRecordResult, +} from '../types/Back.js'; +import { Filters } from '../types/Back.js'; +import { ActionCheckSource, AllowedActionsEnum, type AdminUser } from '../types/Common.js'; +import { + columnsAggregatableError, + filterColumnsReadableError, + recordWriteError, + sortColumnsReadableError, + stripReadForbiddenColumns, + type ColumnAccessContext, +} from './columnAccess.js'; +import { consumeResourceAccessGrant, interpretResource, RESOURCE_ACCESS_GRANT } from './resourceAccess.js'; +import { filtersTools } from './filtersTools.js'; +import { hookResponseError, listify } from './utils.js'; +import { resolvePolymorphicReferences } from './polymorphicReferences.js'; +import type OperationalResource from './operationalResource.js'; + +/** + * Which resource permission guards which operation, and which check source the permission + * callbacks are told about. Kept as one table so the whole mapping can be audited at a glance + * instead of being read out of seven method bodies. + */ +const OPERATION_ACCESS = { + get: [AllowedActionsEnum.show, ActionCheckSource.ShowRequest], + list: [AllowedActionsEnum.list, ActionCheckSource.ListRequest], + count: [AllowedActionsEnum.list, ActionCheckSource.ListRequest], + create: [AllowedActionsEnum.create, ActionCheckSource.CreateRequest], + update: [AllowedActionsEnum.edit, ActionCheckSource.EditRequest], + delete: [AllowedActionsEnum.delete, ActionCheckSource.DeleteRequest], +} as const; + +type GuardedOperation = keyof typeof OPERATION_ACCESS; + +/** Hook-running write paths, supplied by AdminForth. */ +export interface ResourceHookExecutors { + create(params: CreateResourceRecordParams): Promise; + update(params: UpdateResourceRecordParams): Promise; + delete(params: DeleteResourceRecordParams, cascadeChildren?: boolean, bulkHooks?: boolean): Promise; +} + + +/** + * Resource API bound to an authenticated admin user. Every operation enforces the resource ACL + * and the column access rules, and runs the resource lifecycle hooks. + */ +export default class UserScopedResource implements IScopedOperationalResource { + readonly dataConnector: IAdminForthDataSourceConnectorBase; + readonly resourceConfig: AdminForthResource; + + constructor( + private readonly data: OperationalResource, + private readonly adminforth: IAdminForth, + private readonly executors: ResourceHookExecutors, + private readonly adminUser: AdminUser, + private readonly options: OperationalResourceUserOptions & { [RESOURCE_ACCESS_GRANT]?: object }, + ) { + this.dataConnector = data.dataConnector; + this.resourceConfig = data.resourceConfig; + } + + private get meta(): any { + return this.options.meta ?? {}; + } + + private columnCtx(source: ActionCheckSource, meta: any = this.meta): ColumnAccessContext { + return { + adminUser: this.adminUser, + resource: this.resourceConfig, + meta, + source, + adminforth: this.adminforth, + }; + } + + /** @returns the reason the operation is not allowed, or null when it is. */ + private async accessError(operation: GuardedOperation, meta: any = this.meta): Promise { + const [action, source] = OPERATION_ACCESS[operation]; + const { allowedActions } = await interpretResource( + this.adminUser, + this.resourceConfig, + meta, + source, + this.adminforth, + ); + const allowed = allowedActions[action] as boolean | string | undefined; + return allowed === true ? null : typeof allowed === 'string' ? allowed : 'Action is not allowed'; + } + + /** + * Runs one phase of the resource read hooks. `beforeDatasourceRequest` may narrow `query` + * (this is how row-level multi-tenancy is expressed); `afterDatasourceResponse` may rewrite + * the records it is handed. + */ + private async runReadHooks( + page: 'show' | 'list', + phase: 'beforeDatasourceRequest' | 'afterDatasourceResponse', + query: any, + records?: any[], + extra = this.options.extra, + ): Promise { + const hooks = listify(this.resourceConfig.hooks?.[page]?.[phase]); + if (!hooks.length) { + return; + } + + for (const hook of hooks) { + const payload: any = { + resource: this.resourceConfig, + query, + adminUser: this.adminUser, + extra: extra ?? { + body: query, + query: {}, + headers: {}, + cookies: [], + requestUrl: '', + response: this.options.response, + }, + adminforth: this.adminforth, + }; + + if (phase === 'beforeDatasourceRequest') { + // hooks reach these either as their own argument or off the query, and the documented + // spelling is query.filtersTools — so both have to be present, same as the REST path + payload.filtersTools = filtersTools.get(query); + query.filtersTools = payload.filtersTools; + } else { + payload.response = records; + } + + const error = hookResponseError(await hook(payload)); + if (error) { + throw new Error(error.error); + } + } + } + + /** + * Finds a record through the list scope before a mutation. A primary-key connector lookup + * would bypass tenant filters installed by `beforeDatasourceRequest` hooks. + */ + private async findScopedRecord(primaryKey: any, candidate?: any): Promise { + const keyColumns = this.resourceConfig.columns.filter((column) => column.primaryKey); + // Connectors own composite recordId interpretation. A scalar key needs no extra lookup. + let identityFilters: ReturnType[]; + if (keyColumns.length === 1) { + identityFilters = [Filters.EQ(keyColumns[0].name, primaryKey)]; + } else { + const compositeRecord = candidate + ?? await this.dataConnector.getRecordByPrimaryKey(this.resourceConfig, primaryKey); + if (!compositeRecord) { + return null; + } + identityFilters = keyColumns.map((column) => Filters.EQ(column.name, compositeRecord[column.name])); + } + const query = { + resourceId: this.resourceConfig.resourceId, + source: 'list', + filters: identityFilters.map((filter) => ({ ...filter })), + limit: 1, + offset: 0, + sort: [], + }; + const listExtra = this.options.extra + ? { ...this.options.extra, body: query } + : undefined; + await this.runReadHooks('list', 'beforeDatasourceRequest', query, undefined, listExtra); + const scopedFilters = this.dataConnector.validateAndNormalizeInputFilters(query.filters); + return this.data.get(Filters.AND(...identityFilters, scopedFilters)); + } + + async get(filter: IAdminForthSingleFilter | IAdminForthAndOrFilter | Array): Promise { + const accessError = await this.accessError('get'); + if (accessError) { + throw new Error(accessError); + } + + const filterError = await filterColumnsReadableError( + this.columnCtx(ActionCheckSource.ShowRequest), + filter, + ); + if (filterError) { + throw new Error(filterError); + } + + const query = { filters: filter, limit: 1, offset: 0, sort: [] }; + await this.runReadHooks('show', 'beforeDatasourceRequest', query); + const record = await this.data.get(query.filters); + const records = record ? [record] : []; + + if (record) { + await stripReadForbiddenColumns(this.columnCtx(ActionCheckSource.ShowRequest), record); + } + await this.runReadHooks('show', 'afterDatasourceResponse', query, records); + return record; + } + + async list( + filter: IAdminForthSingleFilter | IAdminForthAndOrFilter | Array, + limit: number | null = null, + offset: number | null = null, + sort: IAdminForthSort | IAdminForthSort[] = [], + columns?: string[] + ): Promise { + const accessError = await this.accessError('list'); + if (accessError) { + throw new Error(accessError); + } + + const filterError = await filterColumnsReadableError( + this.columnCtx(ActionCheckSource.ListRequest), + filter, + ); + if (filterError) { + throw new Error(filterError); + } + + const sortError = await sortColumnsReadableError( + this.columnCtx(ActionCheckSource.ListRequest), + sort, + ); + if (sortError) { + throw new Error(sortError); + } + + const query = { filters: filter, limit, offset, sort }; + await this.runReadHooks('list', 'beforeDatasourceRequest', query); + const data = await this.data.list(query.filters, query.limit, query.offset, query.sort, columns); + + const ctx = this.columnCtx(ActionCheckSource.ListRequest); + for (const record of data) { + await stripReadForbiddenColumns(ctx, record); + } + await this.runReadHooks('list', 'afterDatasourceResponse', query, data); + return data; + } + + async aggregate( + filter: IAdminForthSingleFilter | IAdminForthAndOrFilter | Array, + aggregations: { [alias: string]: IAggregationRule }, + groupBy?: IGroupByRule | IGroupByRule[] + ): Promise> { + // an aggregation reads a whole set of records at once, so it needs list access, and its + // min/max/groupBy return raw per-field values, which is what the show view does + const accessError = (await this.accessError('list')) ?? (await this.accessError('get')); + if (accessError) { + throw new Error(accessError); + } + + const columnError = await columnsAggregatableError( + this.columnCtx(ActionCheckSource.ShowRequest), + { aggregations, groupBy, filters: filter }, + ); + if (columnError) { + throw new Error(columnError); + } + + // An aggregation reads the same rows a list does, so the row-scoping hooks have to narrow + // it too, or groupBy/min/max/sum report across every tenant. They run after the column check + // above, so that check still sees the caller's own filters. Only the request phase runs: the + // response here is aggregated rows, not records an after hook could process. + const query = { filters: filter, aggregations, groupBy, limit: null, offset: 0, sort: [] }; + await this.runReadHooks('list', 'beforeDatasourceRequest', query); + + return this.data.aggregate(query.filters, aggregations, groupBy); + } + + async count(filter?: IAdminForthSingleFilter | IAdminForthAndOrFilter | Array | undefined): Promise { + const accessError = await this.accessError('count'); + if (accessError) { + throw new Error(accessError); + } + + const filterError = await filterColumnsReadableError( + this.columnCtx(ActionCheckSource.ListRequest), + filter, + ); + if (filterError) { + throw new Error(filterError); + } + + // a count is a list the caller only learns the size of, so it is row-scoped the same way + const query = { filters: filter, limit: null, offset: 0, sort: [] }; + await this.runReadHooks('list', 'beforeDatasourceRequest', query); + + return this.data.count(query.filters); + } + + async create(recordValues: any): Promise { + const accessError = consumeResourceAccessGrant( + this.options[RESOURCE_ACCESS_GRANT], this.adminUser, this.resourceConfig, + AllowedActionsEnum.create, recordValues, + ) ? null : await this.accessError('create'); + if (accessError) { + return { ok: false, createdRecord: undefined, error: accessError }; + } + + const columnError = await recordWriteError( + this.columnCtx(ActionCheckSource.CreateRequest), + recordValues, + 'create', + ); + if (columnError) { + return { ok: false, createdRecord: undefined, error: columnError }; + } + + await resolvePolymorphicReferences(this.resourceConfig, recordValues, this.adminforth); + + const result = await this.executors.create({ + resource: this.resourceConfig, + record: recordValues, + adminUser: this.adminUser, + extra: this.options.extra, + response: this.options.response, + }); + return { ...result, ok: !result.error, createdRecord: result.createdRecord }; + } + + async update(primaryKey: any, record: any): Promise { + const currentRecord = await this.dataConnector.getRecordByPrimaryKey(this.resourceConfig, primaryKey); + const meta = { ...this.meta, newRecord: record, oldRecord: currentRecord, pk: primaryKey }; + const accessError = consumeResourceAccessGrant( + this.options[RESOURCE_ACCESS_GRANT], this.adminUser, this.resourceConfig, + AllowedActionsEnum.edit, record, primaryKey, + ) ? null : await this.accessError('update', meta); + if (accessError) { + return { ok: false, error: accessError }; + } + + if (!currentRecord) { + const primaryKeyColumn = this.resourceConfig.columns.find((column) => column.primaryKey); + return { ok: false, error: `Record with ${primaryKeyColumn.name} ${primaryKey} not found` }; + } + const scopedRecord = await this.findScopedRecord(primaryKey, currentRecord); + if (!scopedRecord) { + const primaryKeyColumn = this.resourceConfig.columns.find((column) => column.primaryKey); + return { ok: false, error: `Record with ${primaryKeyColumn.name} ${primaryKey} not found` }; + } + const oldRecord = this.options.oldRecord ?? scopedRecord; + + const columnError = await recordWriteError( + this.columnCtx(ActionCheckSource.EditRequest, meta), + record, + 'edit', + ); + if (columnError) { + return { ok: false, error: columnError }; + } + + await resolvePolymorphicReferences(this.resourceConfig, record, this.adminforth, scopedRecord); + + const result = await this.executors.update({ + resource: this.resourceConfig, + recordId: primaryKey, + updates: record, + oldRecord, + adminUser: this.adminUser, + extra: this.options.extra, + response: this.options.response, + }); + return { ...result, ok: !result.error }; + } + + async delete(primaryKey: any): Promise { + const currentRecord = await this.dataConnector.getRecordByPrimaryKey(this.resourceConfig, primaryKey); + const accessError = consumeResourceAccessGrant( + this.options[RESOURCE_ACCESS_GRANT], this.adminUser, this.resourceConfig, + AllowedActionsEnum.delete, this.options.record ?? currentRecord, primaryKey, + ) ? null : await this.accessError('delete', { ...this.meta, record: currentRecord, pk: primaryKey }); + if (accessError) { + throw new Error(accessError); + } + + if (!currentRecord) { + return false; + } + const scopedRecord = await this.findScopedRecord(primaryKey, currentRecord); + if (!scopedRecord) { + return false; + } + const record = this.options.record ?? scopedRecord; + + const { error } = await this.executors.delete({ + resource: this.resourceConfig, + recordId: primaryKey, + record, + adminUser: this.adminUser, + extra: this.options.extra, + response: this.options.response, + }, true, this.options.bulkDeleteHooks); + if (error) { + throw new Error(error); + } + return true; + } +} diff --git a/adminforth/modules/utils.ts b/adminforth/modules/utils.ts index 6ce7b8d5a..749c31826 100644 --- a/adminforth/modules/utils.ts +++ b/adminforth/modules/utils.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from 'url'; import fs from 'fs'; import Fuse from 'fuse.js'; import crypto from 'crypto'; -import { AdminForthConfig, AdminForthResource, AdminForthResourceColumnInputCommon,Filters, IAdminForth, Predicate } from '../index.js'; +import { Filters, type AdminForthResource, type IAdminForth } from '../types/Back.js'; import { RateLimiterMemory, RateLimiterAbstract } from "rate-limiter-flexible"; import { encodeRecordId, isCompositePrimaryKey } from './recordId.js'; import { PERIOD_UNITS, type PeriodString, type PeriodUnit } from '../types/Back.js'; @@ -534,8 +534,27 @@ export function slugifyString(str: string): string { .replace(/[^a-z0-9-_]/g, '-'); } -export async function cascadeChildrenDelete(resource: AdminForthResource, primaryKey: string, context: {adminUser: any, response: any}, adminforth: IAdminForth): Promise<{ error: string | null }> { +/** + * Applies the configured onDelete strategy to every child record pointing at `primaryKey`. + * + * Deleting a child has to run that resource's delete hooks — upload releases its S3 objects, + * many2many drops junction rows, audit-log records the deletion from there. `deleteWithHooks` + * supplies that, so this does not depend on any particular public entry point. + */ +export async function cascadeChildrenDelete( + resource: AdminForthResource, + primaryKey: string, + context: {adminUser: any, response: any}, + adminforth: IAdminForth, + deleteWithHooks?: (params: { + resource: AdminForthResource, recordId: any, record: any, adminUser: any, response: any, + }) => Promise<{ error?: string }>, +): Promise<{ error: string | null }> { const { adminUser, response } = context; + // Preserve the public four-argument contract. Core callers inject the non-deprecated executor; + // legacy external callers retain the hook-aware behavior they had before that executor existed. + const deleteChildWithHooks = deleteWithHooks + ?? ((params) => adminforth.deleteResourceRecord(params)); const childResources = adminforth.config.resources.filter(r =>r.columns.some(c => c.foreignResource?.resourceId === resource.resourceId)); @@ -546,7 +565,9 @@ export async function cascadeChildrenDelete(resource: AdminForthResource, primar const strategy = foreignColumn.foreignResource.onDelete; - const childRecords = await adminforth.resource(childRes.resourceId).list(Filters.EQ(foreignColumn.name, primaryKey)); + const childRecords = await adminforth + .resource(childRes.resourceId) + .list(Filters.EQ(foreignColumn.name, primaryKey)); const childPk = childRes.columns.find(c => c.primaryKey)?.name; const childRecordId = (childRecord: any) => isCompositePrimaryKey(childRes) @@ -555,21 +576,31 @@ export async function cascadeChildrenDelete(resource: AdminForthResource, primar if (strategy === 'cascade') { for (const childRecord of childRecords) { - const childResult = await cascadeChildrenDelete(childRes, childRecordId(childRecord), context, adminforth); + // Grandchildren first, then the child itself. + const childResult = await cascadeChildrenDelete( + childRes, childRecordId(childRecord), context, adminforth, deleteChildWithHooks, + ); if (childResult?.error) { return childResult; } - const deleteChild = await adminforth.deleteResourceRecord({resource: childRes, record: childRecord, adminUser, recordId: childRecordId(childRecord), response}); - if (deleteChild.error) return { error: deleteChild.error }; - if (childResult?.error) { - return childResult; + const deleteChild = await deleteChildWithHooks({ + resource: childRes, record: childRecord, adminUser, recordId: childRecordId(childRecord), response, + }); + if (deleteChild.error) { + return { error: deleteChild.error }; } } } if (strategy === 'setNull') { for (const childRecord of childRecords) { - await adminforth.resource(childRes.resourceId).update(childRecordId(childRecord), {[foreignColumn.name]: null}); + const result = await adminforth.resource(childRes.resourceId).update( + childRecordId(childRecord), + { [foreignColumn.name]: null }, + ); + if (result.error) { + return { error: result.error }; + } } } } @@ -671,4 +702,4 @@ export function checkIfLinkInAllowedHosts(url: string, allowedHosts: string[]) { if (!allowed) { throw new Error(`Attachment host "${hostname}" is not in attachImagesAllowedHosts`); } -} \ No newline at end of file +} diff --git a/adminforth/types/Back.ts b/adminforth/types/Back.ts index da2147e00..fcde20b75 100644 --- a/adminforth/types/Back.ts +++ b/adminforth/types/Back.ts @@ -612,16 +612,31 @@ export interface IAdminForth { tr(msg: string, category: string, lang: string, params: any, pluralizationNumber?: number): Promise; + /** + * Creates a record and runs the resource create hooks, without checking permissions. + * For an operation a user requested, use `resource(resourceId).asUser(adminUser, { meta })`. + */ createResourceRecord( params: CreateResourceRecordParams, ): Promise; + /** + * Updates a record and runs the resource edit hooks, without checking permissions. + * For an operation a user requested, use `resource(resourceId).asUser(adminUser, { meta })`. + */ updateResourceRecord( params: UpdateResourceRecordParams, ): Promise; + /** + * Deletes a record and runs the resource delete hooks, without checking permissions. + * For an operation a user requested, use `resource(resourceId).asUser(adminUser, { meta })`. + * Pass `true` as the second argument to apply configured child deletion after the parent + * `beforeSave` hooks have allowed the deletion. + */ deleteResourceRecord( params: DeleteResourceRecordParams, + cascadeChildren?: boolean, ): Promise; auth: IAdminForthAuth; @@ -2209,7 +2224,17 @@ export class Sorts { } } -export interface IOperationalResource { +/** + * Resource API bound to an authenticated admin user by {@link IOperationalResource.asUser}. + * Every operation enforces the resource ACL and the column access rules, and runs the resource + * lifecycle hooks. + * + * Error contract: a denied or failed operation is always visible. `get`, `list`, `count`, + * `aggregate` and `delete` throw, since their return value carries no room for an error; + * `create` and `update` resolve to `{ ok: false, error }`. `delete` resolves to `false` when the + * record simply did not exist. + */ +export interface IScopedOperationalResource { get: (filter: IAdminForthSingleFilter | IAdminForthAndOrFilter | Array) => Promise; list: (filter: IAdminForthSingleFilter | IAdminForthAndOrFilter | Array, limit?: number, offset?: number, sort?: IAdminForthSort | IAdminForthSort[], columns?: string[]) => Promise; @@ -2222,7 +2247,7 @@ export interface IOperationalResource { groupBy?: IGroupByRule | IGroupByRule[] ) => Promise>; - create: (record: any) => Promise<{ ok: boolean; createdRecord: any; error?: string; }>; + create: (record: any) => Promise; update: (primaryKey: any, record: any) => Promise; @@ -2231,6 +2256,66 @@ export interface IOperationalResource { dataConnector: IAdminForthDataSourceConnectorBase; } +export interface IOperationalResource { + /** + * Returns a resource API scoped to an authenticated admin user. Operations enforce the + * resource ACL and the column access rules, run lifecycle hooks, and validate records. + * Use it for everything a user asked for. + */ + asUser: (adminUser: AdminUser, options?: OperationalResourceUserOptions) => IScopedOperationalResource; + + /** + * Plain data access: no permission checks, no column access rules, no lifecycle hooks. + * Writes are still normalized. Use it for internal bookkeeping the user did not + * ask for; to run hooks without permission checks, use {@link IAdminForth.createResourceRecord} + * and its siblings. + */ + get: IScopedOperationalResource['get']; + + /** Plain data access — see {@link IOperationalResource.get}. */ + list: IScopedOperationalResource['list']; + + /** Plain data access — see {@link IOperationalResource.get}. */ + count: IScopedOperationalResource['count']; + + /** Plain data access — see {@link IOperationalResource.get}. */ + aggregate: IScopedOperationalResource['aggregate']; + + /** Plain data access — see {@link IOperationalResource.get}. */ + create: IScopedOperationalResource['create']; + + /** Plain data access — see {@link IOperationalResource.get}. */ + update: IScopedOperationalResource['update']; + + /** Plain data access — see {@link IOperationalResource.get}. */ + delete: IScopedOperationalResource['delete']; + + dataConnector: IAdminForthDataSourceConnectorBase; +} + +export interface OperationalResourceContextOptions { + meta?: any; + extra?: HttpExtra; + response?: IAdminForthHttpResponse; + /** Preserve the legacy default bulk action's hook result contract. */ + bulkDeleteHooks?: boolean; + + /** + * Snapshot passed to edit save hooks when the caller has already loaded the record. + * `update()` still performs a scoped lookup to confirm access to the current row. + */ + oldRecord?: any; + + /** + * Snapshot passed to delete save hooks when the caller has already loaded the record. + * `delete()` still performs a scoped lookup to confirm access to the current row. + */ + record?: any; +} + +export type OperationalResourceUserOptions = OperationalResourceContextOptions; + + /** @@ -2448,8 +2533,9 @@ export interface AdminForthBulkAction extends AdminForthBulkActionCommon { * Callback which will be called on backend when user clicks on action button. * It should return Promise which will be resolved when action is done. */ - action: ({ resource, selectedIds, adminUser, response, tr }: { - resource: AdminForthResource, selectedIds: Array, adminUser: AdminUser, response: IAdminForthHttpResponse, tr: ITranslateFunction + action: ({ resource, selectedIds, adminUser, response, tr, extra }: { + resource: AdminForthResource, selectedIds: Array, adminUser: AdminUser, response: IAdminForthHttpResponse, tr: ITranslateFunction, + extra?: HttpExtra, }) => Promise<{ ok: boolean, error?: string, successMessage?: string }>, /** diff --git a/tests/jest_tests/operational_resource_scope.test.ts b/tests/jest_tests/operational_resource_scope.test.ts new file mode 100644 index 000000000..1b07a1be2 --- /dev/null +++ b/tests/jest_tests/operational_resource_scope.test.ts @@ -0,0 +1,682 @@ +import OperationalResource from '../../adminforth/modules/operationalResource.js'; +import UserScopedResource from '../../adminforth/modules/userScopedResource.js'; +import AdminForthRestAPI from '../../adminforth/modules/restApi.js'; +import AdminForth from '../../adminforth/index.js'; +import { authorizeResourceOperation, RESOURCE_ACCESS_GRANT } from '../../adminforth/modules/resourceAccess.js'; +import { ActionCheckSource, AllowedActionsEnum } from '../../adminforth/types/Common.js'; + +function singleFilters(filters: any): any[] { + if (Array.isArray(filters)) { + return filters.flatMap(singleFilters); + } + return filters.subFilters ? singleFilters(filters.subFilters) : [filters]; +} + +function setup(resourceId = 'users') { + const calls = { + acl: 0, + createExecutor: 0, + updateExecutor: 0, + connectorCreate: 0, + connectorUpdate: 0, + connectorDelete: 0, + connectorGetData: 0, + connectorGetByPk: 0, + connectorCount: 0, + connectorAggregate: 0, + beforeList: 0, + afterList: 0, + }; + const resource = { + resourceId, + dataSource: 'main', + columns: [ + { name: 'id', primaryKey: true }, + { name: 'name', showIn: { create: true, edit: true } }, + { name: 'readonly', showIn: { create: true, edit: true }, editReadonly: true }, + { name: 'private', backendOnly: true }, + { + name: 'secret', + showIn: { create: true, edit: true }, + backendOnly: ({ source }) => source === ActionCheckSource.CreateRequest, + }, + ], + dataSourceColumns: [], + options: { + allowedActions: { + create: ({ meta }) => { + calls.acl++; + return Promise.resolve(meta.allowed === true); + }, + edit: ({ meta }) => { + calls.acl++; + return Promise.resolve(meta.allowed === true); + }, + show: true, + list: ({ meta }) => { + calls.acl++; + return Promise.resolve(meta.allowed === true); + }, + delete: ({ meta }) => { + calls.acl++; + return Promise.resolve(meta.allowed === true); + }, + }, + }, + hooks: { + list: { + beforeDatasourceRequest: [async ({ query }) => { + calls.beforeList++; + query.filtersTools.replaceOrAddTopFilter({ field: 'tenant', operator: 'eq', value: 't1' }); + return { ok: true }; + }], + afterDatasourceResponse: [async () => { + calls.afterList++; + return { ok: true }; + }], + }, + }, + } as any; + resource.dataSourceColumns = resource.columns; + + const seenFilters: Record = {}; + const seenWrites: Record = {}; + const connector = { + createRecord: async ({ record }) => { + calls.connectorCreate++; + return { ok: true, createdRecord: { id: 1, ...record } }; + }, + updateRecord: async () => { + calls.connectorUpdate++; + return { ok: true }; + }, + deleteRecord: async () => { + calls.connectorDelete++; + return true; + }, + getData: async ({ filters }) => { + calls.connectorGetData++; + seenFilters.getData = filters; + const requestedFilters = singleFilters(filters); + if (requestedFilters.some((filter) => filter.field === 'tenant' && filter.value === 'not-owned') + || requestedFilters.some((filter) => filter.field === 'id' && filter.value !== 1)) { + return { data: [], total: 0 }; + } + return { data: [{ id: 1, name: 'John', private: 'hidden' }], total: 1 }; + }, + getCount: async ({ filters }) => { + calls.connectorCount++; + seenFilters.count = filters; + return 1; + }, + aggregate: async ({ filters }) => { + calls.connectorAggregate++; + seenFilters.aggregate = filters; + return [{ total: 1 }]; + }, + validateAndNormalizeInputFilters: (filter) => filter, + getRecordByPrimaryKey: async (_resource, recordId) => { + calls.connectorGetByPk++; + if (recordId === 'missing') { + return null; + } + return { id: 1, name: 'Old name', readonly: 'old' }; + }, + getPrimaryKey: () => 'id', + } as any; + const adminforth = { config: { resources: [resource] } } as any; + const executors = { + create: async ({ record }) => { + calls.createExecutor++; + return { createdRecord: { id: 1, ...record } }; + }, + update: async ({ oldRecord, updates }) => { + calls.updateExecutor++; + seenWrites.update = { oldRecord, updates }; + return { error: null }; + }, + delete: async ({ record }, cascadeChildren, bulkHooks) => { + seenWrites.delete = { record, cascadeChildren, bulkHooks }; + return { error: null }; + }, + } as any; + + const operationalResource = new OperationalResource( + connector, + resource, + (data, adminUser, options) => new UserScopedResource(data, adminforth, executors, adminUser, options), + ); + adminforth.resource = () => operationalResource; + + return { + calls, + seenFilters, + seenWrites, + adminforth, + resource: operationalResource, + }; +} + +describe('OperationalResource access tiers', () => { + it('enforces ACL, column access, validation, and hooks for asUser()', async () => { + const { calls, resource } = setup(); + const denied = await resource.asUser({} as any, { meta: { allowed: false } }).create({ name: 'John' }); + expect(denied).toMatchObject({ ok: false, error: 'Action is not allowed' }); + + const forbidden = await resource.asUser({} as any, { meta: { allowed: true } }).create({ secret: 'value' }); + expect(forbidden).toMatchObject({ ok: false }); + expect(forbidden.error).toContain('backendOnly is true'); + + const created = await resource.asUser({} as any, { meta: { allowed: true } }).create({ name: 'John' }); + expect(created).toMatchObject({ ok: true, createdRecord: { id: 1, name: 'John' } }); + expect(calls).toMatchObject({ acl: 3, createExecutor: 1, connectorCreate: 0 }); + }); + + it('runs no permission checks, no column checks and no hooks on the bare API', async () => { + const { calls, resource } = setup(); + const created = await resource.create({ secret: 'value' }); + + expect(created).toMatchObject({ ok: true, createdRecord: { id: 1, secret: 'value' } }); + expect(calls).toMatchObject({ acl: 0, createExecutor: 0, connectorCreate: 1 }); + }); + + it('keeps bare writes free of resource validation', async () => { + const { calls, resource } = setup(); + resource.resourceConfig.columns.push({ name: 'score', type: 'integer', minValue: 10 } as any); + + await expect(resource.create({ score: 1 })).resolves.toMatchObject({ ok: true }); + await expect(resource.update(1, { score: 1 })).resolves.toMatchObject({ ok: true }); + expect(calls).toMatchObject({ connectorCreate: 1, connectorUpdate: 1 }); + }); + + it('preserves validation before editReadonly stripping in the deprecated update executor', async () => { + let connectorUpdates = 0; + const admin = Object.create(AdminForth.prototype) as any; + admin.warnedDeprecatedResourceMutations = new Set(); + admin.connectors = { + main: { + updateRecord: async () => { + connectorUpdates++; + return { ok: true }; + }, + }, + }; + const resource = { + resourceId: 'users', + dataSource: 'main', + columns: [{ + name: 'readonly', + editReadonly: true, + validation: [{ regExp: '^valid$', message: 'readonly validation failed' }], + }], + hooks: {}, + } as any; + const updates = { readonly: 'invalid' }; + + await expect(admin.updateResourceRecord({ + resource, + recordId: 1, + updates, + oldRecord: { readonly: 'old' }, + adminUser: {}, + })).resolves.toEqual({ error: 'readonly validation failed' }); + + expect(updates).toEqual({ readonly: 'invalid' }); + expect(connectorUpdates).toBe(0); + }); + + it('rejects editReadonly for asUser()', async () => { + const { calls, resource } = setup(); + + const forbidden = await resource + .asUser({} as any, { meta: { allowed: true } }) + .update(1, { readonly: 'new' }); + + expect(forbidden.error).toContain('editReadonly is true'); + expect(calls).toMatchObject({ acl: 1, updateExecutor: 0, connectorUpdate: 0 }); + }); + + it('applies read ACL, column access, and hooks for asUser()', async () => { + const { calls, resource } = setup(); + + await expect(resource.asUser({} as any, { meta: { allowed: false } }).list([])) + .rejects.toThrow('Action is not allowed'); + + const records = await resource.asUser({} as any, { meta: { allowed: true } }).list([]); + + expect(records).toEqual([{ id: 1, name: 'John' }]); + expect(calls).toMatchObject({ acl: 2, beforeList: 1, afterList: 1 }); + }); + + it('returns backendOnly columns on the bare API and strips them for asUser()', async () => { + const bare = setup(); + const scoped = setup(); + + const bareRecords = await bare.resource.list([]); + const userRecords = await scoped.resource.asUser({} as any, { meta: { allowed: true } }).list([]); + + expect(bareRecords).toEqual([{ id: 1, name: 'John', private: 'hidden' }]); + expect(userRecords).toEqual([{ id: 1, name: 'John' }]); + expect(bare.calls).toMatchObject({ acl: 0, beforeList: 0, afterList: 0 }); + expect(scoped.calls).toMatchObject({ acl: 1, beforeList: 1, afterList: 1 }); + }); + + it('rejects denied deletes and leaves the connector untouched', async () => { + const { calls, resource } = setup(); + + await expect(resource.asUser({} as any, { meta: { allowed: false } }).delete(1)) + .rejects.toThrow('Action is not allowed'); + expect(calls).toMatchObject({ acl: 1, connectorDelete: 0 }); + }); + + it('does not reveal whether a denied update or delete target exists', async () => { + const { calls, resource } = setup(); + const scoped = resource.asUser({} as any, { meta: { allowed: false } }); + + await expect(scoped.update('missing', { name: 'Jane' })) + .resolves.toMatchObject({ ok: false, error: 'Action is not allowed' }); + await expect(scoped.delete('missing')).rejects.toThrow('Action is not allowed'); + expect(calls).toMatchObject({ acl: 2, beforeList: 0, updateExecutor: 0, connectorDelete: 0 }); + }); + + it('passes the caller delete snapshot to hooks after the scoped lookup', async () => { + const { seenWrites, resource } = setup(); + const record = { id: 1, name: 'Earlier snapshot' }; + let aclRecord: any; + resource.resourceConfig.options.allowedActions.delete = ({ meta }) => { + aclRecord = meta.record; + return true; + }; + + await expect(resource.asUser({} as any, { meta: { allowed: true }, record }).delete(1)) + .resolves.toBe(true); + expect(aclRecord.name).toBe('Old name'); + expect(seenWrites.delete).toEqual({ record, cascadeChildren: true }); + }); + + it('passes the caller snapshot to hooks after checking current row scope', async () => { + const { calls, seenWrites, resource } = setup(); + let aclRecord: any; + resource.resourceConfig.options.allowedActions.edit = ({ meta }) => { + aclRecord = meta.oldRecord; + return true; + }; + + const updated = await resource + .asUser({} as any, { meta: { allowed: true }, oldRecord: { id: 1, name: 'Earlier snapshot' } }) + .update(1, { name: 'Jane' }); + + expect(updated).toMatchObject({ ok: true }); + expect(aclRecord.name).toBe('Old name'); + expect(seenWrites.update.oldRecord).toEqual({ id: 1, name: 'Earlier snapshot' }); + expect(calls).toMatchObject({ connectorGetByPk: 1, updateExecutor: 1, beforeList: 1 }); + }); + + it('row-scopes updates and deletes before mutating the target record', async () => { + const { calls, seenFilters, resource } = setup(); + resource.resourceConfig.hooks.list.beforeDatasourceRequest = [async ({ query }) => { + calls.beforeList++; + query.filtersTools.replaceOrAddTopFilter({ field: 'tenant', operator: 'eq', value: 'not-owned' }); + return { ok: true }; + }]; + const scoped = resource.asUser({} as any, { meta: { allowed: true } }); + + await expect(scoped.update(1, { name: 'Jane' })).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining('not found'), + }); + await expect(scoped.delete(1)).resolves.toBe(false); + + expect(singleFilters(seenFilters.getData)).toContainEqual({ field: 'tenant', operator: 'eq', value: 'not-owned' }); + expect(calls).toMatchObject({ beforeList: 2, updateExecutor: 0, connectorDelete: 0 }); + }); + + it('gives mutation row-scope hooks list-shaped input with the original request context', async () => { + const { resource } = setup(); + let hookPayload: any; + resource.resourceConfig.hooks.list.beforeDatasourceRequest = [async (payload) => { + hookPayload = payload; + return { ok: true }; + }]; + const requestBody = { resourceId: 'users', recordId: 1, record: { name: 'Jane' } }; + + await resource.asUser({} as any, { + meta: { allowed: true }, + extra: { + body: requestBody, + query: { locale: 'en' }, + headers: { 'x-tenant': 't1' }, + cookies: [], + requestUrl: '/update_record', + response: {} as any, + }, + }).update(1, { name: 'Jane' }); + + expect(hookPayload.extra.headers).toEqual({ 'x-tenant': 't1' }); + expect(hookPayload.extra.query).toEqual({ locale: 'en' }); + expect(hookPayload.extra.body).toBe(hookPayload.query); + expect(hookPayload.extra.body).toMatchObject({ limit: 1, offset: 0, sort: [] }); + expect(hookPayload.extra.body).not.toBe(requestBody); + }); + + it('keeps the requested primary key when a scope hook replaces its filters', async () => { + const { calls, seenFilters, resource } = setup(); + resource.resourceConfig.hooks.list.beforeDatasourceRequest = [async ({ query }) => { + query.filters = [{ field: 'tenant', operator: 'eq', value: 't1' }]; + return { ok: true }; + }]; + const scoped = resource.asUser({} as any, { meta: { allowed: true } }); + + await expect(scoped.update(2, { name: 'Jane' })).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining('not found'), + }); + await expect(scoped.delete(2)).resolves.toBe(false); + + expect(singleFilters(seenFilters.getData)).toContainEqual({ field: 'id', operator: 'eq', value: 2 }); + expect(calls).toMatchObject({ updateExecutor: 0 }); + }); + + it('uses connector identity for composite keys and scopes every key column', async () => { + const { resource, seenWrites } = setup(); + resource.resourceConfig.columns.push({ name: 'partition', primaryKey: true } as any); + resource.resourceConfig.hooks.list.beforeDatasourceRequest = [async ({ query }) => { + query.filters.splice(0); + return { ok: true }; + }]; + const connector = resource.dataConnector as any; + const recordId = { id: 1, partition: 'second' }; + let lookedUpId: any; + let scopedFilters: any; + connector.getRecordByPrimaryKey = async (_resource, id) => { + lookedUpId = id; + return { ...recordId, name: 'Old name' }; + }; + connector.getData = async ({ filters }) => { + scopedFilters = filters; + const fields = singleFilters(filters); + const matches = fields.some(({ field, value }) => field === 'id' && value === 1) + && fields.some(({ field, value }) => field === 'partition' && value === 'second'); + return { data: matches ? [{ ...recordId, name: 'Old name' }] : [] }; + }; + + const result = await resource.asUser({} as any, { meta: { allowed: true } }) + .update(recordId, { name: 'New name' }); + + expect(result).toMatchObject({ ok: true }); + expect(lookedUpId).toBe(recordId); + expect(seenWrites.update.oldRecord).toMatchObject(recordId); + expect(singleFilters(scopedFilters)) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ field: 'id', value: 1 }), + expect.objectContaining({ field: 'partition', value: 'second' }), + ])); + + const deleted = await resource.asUser({} as any, { meta: { allowed: true } }).delete(recordId); + expect(deleted).toBe(true); + expect(seenWrites.delete.record).toMatchObject(recordId); + }); + + it('passes the legacy bulk hook mode through scoped delete', async () => { + const { resource, seenWrites } = setup(); + + await resource.asUser({} as any, { meta: { allowed: true }, bulkDeleteHooks: true }).delete(1); + + expect(seenWrites.delete).toMatchObject({ cascadeChildren: true, bulkHooks: true }); + }); + + it('derives a hidden polymorphic discriminator after validating user fields', async () => { + const { calls, seenWrites, adminforth, resource } = setup(); + resource.resourceConfig.columns.push( + { name: 'resource_id', showIn: { create: false, edit: false } }, + { + name: 'record_id', + foreignResource: { + polymorphicOn: 'resource_id', + polymorphicResources: [ + { resourceId: 'targets', whenValue: 'target' }, + { resourceId: null, whenValue: 'system' }, + ], + }, + }, + ); + adminforth.config.resources.push({ + resourceId: 'targets', + dataSource: 'targets', + columns: [{ name: 'id', primaryKey: true }], + }); + adminforth.connectors = { + targets: { getData: async () => ({ data: [{ id: 'target-1' }] }) }, + }; + const scoped = resource.asUser({} as any, { meta: { allowed: true } }); + + const created = await scoped.create({ record_id: 'target-1' }); + const updated = await scoped.update(1, { record_id: 'target-1' }); + const forbidden = await scoped.create({ record_id: 'target-1', resource_id: 'target' }); + + expect(created.createdRecord.resource_id).toBe('target'); + expect(updated).toMatchObject({ ok: true }); + expect(seenWrites.update.updates.resource_id).toBe('target'); + expect(forbidden).toMatchObject({ ok: false, error: expect.stringContaining('showIn.create is false') }); + expect(calls).toMatchObject({ createExecutor: 1, updateExecutor: 1 }); + + let createChecks = 0; + resource.resourceConfig.options.allowedActions.create = () => ++createChecks === 1; + adminforth.config.auth = { rateLimit: [] }; + adminforth.activatedPlugins = []; + adminforth.connectors.main = resource.dataConnector; + const endpoints: Record = {}; + new AdminForthRestAPI(adminforth).registerEndpoints({ + endpoint: (endpoint: any) => { endpoints[endpoint.path] = endpoint; }, + } as any); + const requestRecord = { record_id: 'target-1' } as any; + const restResult = await endpoints['/create_record'].handler({ + body: { resourceId: 'users', record: requestRecord, requiredColumnsToSkip: [] }, + adminUser: {}, + query: {}, + headers: {}, + cookies: [], + requestUrl: '', + response: {}, + }); + + expect(restResult).toMatchObject({ ok: true, newRecordId: 1 }); + expect(requestRecord.resource_id).toBe('target'); + expect(createChecks).toBe(1); + + let editChecks = 0; + resource.resourceConfig.options.allowedActions.edit = () => ++editChecks === 1; + const editRecord = { record_id: 'target-1' } as any; + const editResult = await endpoints['/update_record'].handler({ + body: { resourceId: 'users', recordId: 1, record: editRecord }, + adminUser: {}, + query: {}, + headers: {}, + cookies: [], + requestUrl: '', + response: {}, + }); + + expect(editResult).toMatchObject({ ok: true }); + expect(editRecord.resource_id).toBe('target'); + expect(editChecks).toBe(1); + + resource.resourceConfig.options.allowedActions.edit = true; + await scoped.update(1, { record_id: null }); + expect(seenWrites.update.updates.resource_id).toBe('system'); + }); + + it('keeps a null polymorphic discriminator when no target matches', async () => { + const { adminforth, resource, seenWrites } = setup(); + resource.resourceConfig.columns.push( + { name: 'resource_id', showIn: { edit: false } }, + { + name: 'record_id', + foreignResource: { + polymorphicOn: 'resource_id', + polymorphicResources: [{ resourceId: 'targets', whenValue: 'target' }], + }, + }, + ); + adminforth.config.resources.push({ + resourceId: 'targets', dataSource: 'targets', columns: [{ name: 'id', primaryKey: true }], + }); + adminforth.connectors = { targets: { getData: async () => ({ data: [] }) } }; + (resource.dataConnector as any).getData = async () => ({ + data: [{ id: 1, record_id: null, resource_id: null }], + }); + + await resource.asUser({} as any, { meta: { allowed: true } }) + .update(1, { record_id: 'missing' }); + + expect(seenWrites.update.updates).toEqual({ record_id: 'missing' }); + + resource.resourceConfig.columns.find((column) => column.name === 'record_id') + .foreignResource.polymorphicResources.unshift({ resourceId: 'removed-target', whenValue: 'removed' }); + (resource.dataConnector as any).getData = async () => ({ + data: [{ id: 1, record_id: 'old', resource_id: 'target' }], + }); + + await resource.asUser({} as any, { meta: { allowed: true } }) + .update(1, { record_id: 'missing' }); + + expect(seenWrites.update.updates).toEqual({ record_id: 'missing', resource_id: null }); + + const created = await resource.asUser({} as any, { meta: { allowed: true } }) + .create({ record_id: 'missing' }); + expect(created.createdRecord).toHaveProperty('resource_id', undefined); + }); + + it('consumes a REST ACL grant only once for the same user, resource, and record', async () => { + const { adminforth, calls, resource } = setup(); + const adminUser = {} as any; + const record = { name: 'Jane' }; + const meta = { allowed: true }; + const access = await authorizeResourceOperation( + adminUser, resource.resourceConfig, meta, ActionCheckSource.CreateRequest, + AllowedActionsEnum.create, adminforth, record, + ); + expect(access.error).toBeNull(); + + resource.resourceConfig.options.allowedActions.create = false; + const scopedOptions = { meta, [RESOURCE_ACCESS_GRANT]: access.grant }; + const scoped = resource.asUser(adminUser, scopedOptions); + await expect(scoped.create(record)).resolves.toMatchObject({ ok: true }); + await expect(scoped.create(record)).resolves.toMatchObject({ ok: false, error: 'Action is not allowed' }); + expect(calls.createExecutor).toBe(1); + }); + + it('applies the full user-scoped update path to an empty update', async () => { + const denied = setup(); + const deniedResult = await denied.resource.asUser({} as any, { meta: { allowed: false } }).update(1, {}); + + expect(deniedResult).toMatchObject({ ok: false, error: 'Action is not allowed' }); + expect(denied.calls).toMatchObject({ beforeList: 0, updateExecutor: 0 }); + + const allowed = setup(); + const allowedResult = await allowed.resource.asUser({} as any, { meta: { allowed: true } }).update(1, {}); + + expect(allowedResult).toMatchObject({ ok: true }); + expect(allowed.calls).toMatchObject({ beforeList: 1, updateExecutor: 1 }); + }); + + it('requires list and show access for asUser() aggregations', async () => { + const { calls, resource } = setup(); + + await expect( + resource.asUser({} as any, { meta: { allowed: false } }).aggregate([], { total: { operation: 'count' } } as any), + ).rejects.toThrow('Action is not allowed'); + expect(calls).toMatchObject({ connectorAggregate: 0 }); + }); + + it('refuses to aggregate, group by, or filter on columns the user cannot read', async () => { + const { calls, resource } = setup(); + const scoped = resource.asUser({} as any, { meta: { allowed: true } }); + + await expect(scoped.aggregate([], { max: { operation: 'max', field: 'private' } } as any)) + .rejects.toThrow('cannot be aggregated (backendOnly is true)'); + await expect(scoped.aggregate([], { total: { operation: 'count' } } as any, { field: 'private' } as any)) + .rejects.toThrow('cannot be aggregated (backendOnly is true)'); + await expect(scoped.aggregate( + { field: 'private', operator: 'eq', value: 'hidden' } as any, + { total: { operation: 'count' } } as any, + )).rejects.toThrow('Filter: column "private" cannot be used'); + + expect(calls).toMatchObject({ connectorAggregate: 0 }); + + const allowed = await scoped.aggregate([], { max: { operation: 'max', field: 'name' } } as any); + expect(allowed).toEqual([{ total: 1 }]); + expect(calls).toMatchObject({ connectorAggregate: 1 }); + }); + + it('refuses to filter backendOnly columns through user-scoped reads', async () => { + const { calls, resource } = setup(); + const scoped = resource.asUser({} as any, { meta: { allowed: true } }); + const privateFilter = { field: 'private', operator: 'eq', value: 'hidden' } as any; + + await expect(scoped.get(privateFilter)).rejects.toThrow('Filter: column "private" cannot be used'); + await expect(scoped.list(privateFilter)).rejects.toThrow('Filter: column "private" cannot be used'); + await expect(scoped.count(privateFilter)).rejects.toThrow('Filter: column "private" cannot be used'); + + expect(calls).toMatchObject({ connectorGetData: 0, connectorCount: 0, beforeList: 0 }); + }); + + it('refuses to sort by backendOnly columns through user-scoped lists', async () => { + const { calls, resource } = setup(); + + await expect(resource.asUser({} as any, { meta: { allowed: true } }) + .list([], null, null, { field: 'private', direction: 'asc' } as any)) + .rejects.toThrow('backendOnly is true'); + expect(calls.connectorGetData).toBe(0); + }); + + it('refuses to sort by backendOnly columns through the REST list endpoint', async () => { + const { adminforth, calls, resource } = setup(); + resource.resourceConfig.options.allowedActions.list = true; + adminforth.config.auth = { rateLimit: [] }; + adminforth.activatedPlugins = []; + adminforth.statuses = { dbDiscover: 'done' }; + adminforth.connectors = { main: resource.dataConnector }; + const endpoints: Record = {}; + new AdminForthRestAPI(adminforth).registerEndpoints({ + endpoint: (endpoint: any) => { endpoints[endpoint.path] = endpoint; }, + } as any); + + const result = await endpoints['/get_resource_data'].handler({ + body: { + resourceId: 'users', source: 'list', filters: [], limit: 10, offset: 0, + sort: [{ field: 'private', direction: 'asc' }], + }, + adminUser: {}, headers: {}, query: {}, cookies: [], requestUrl: '', + abortSignal: new AbortController().signal, + }); + + expect(result.error).toContain('backendOnly is true'); + expect(calls.connectorGetData).toBe(0); + }); + + it('row-scopes aggregate and count through the same read hooks as list', async () => { + const { calls, seenFilters, resource } = setup(); + const scoped = resource.asUser({} as any, { meta: { allowed: true } }); + const tenantFilter = { field: 'tenant', operator: 'eq', value: 't1' }; + + await scoped.aggregate([], { total: { operation: 'count' } } as any, { field: 'name' } as any); + await scoped.count([]); + + // without this an aggregation reports across every tenant's rows + expect(seenFilters.aggregate).toContainEqual(tenantFilter); + expect(seenFilters.count).toContainEqual(tenantFilter); + expect(calls).toMatchObject({ beforeList: 2, connectorAggregate: 1, connectorCount: 1 }); + }); + + it('does not row-scope reads on the bare API', async () => { + const { calls, seenFilters, resource } = setup(); + await resource.aggregate([], { total: { operation: 'count' } } as any); + await resource.count([]); + + expect(seenFilters.aggregate).toEqual([]); + expect(seenFilters.count).toEqual([]); + expect(calls).toMatchObject({ beforeList: 0 }); + }); + +}); diff --git a/tests/jest_tests/resource_bulk_delete_order.test.ts b/tests/jest_tests/resource_bulk_delete_order.test.ts new file mode 100644 index 000000000..7c1cd6be3 --- /dev/null +++ b/tests/jest_tests/resource_bulk_delete_order.test.ts @@ -0,0 +1,216 @@ +import AdminForth from '../../adminforth/index.js'; +import ConfigValidator from '../../adminforth/modules/configValidator.js'; +import OperationalResource from '../../adminforth/modules/operationalResource.js'; +import UserScopedResource from '../../adminforth/modules/userScopedResource.js'; +import { cascadeChildrenDelete } from '../../adminforth/modules/utils.js'; + +function setup(parentBeforeSave: () => Promise<{ ok: boolean; error?: string }>) { + const events: string[] = []; + let scopedDeleteCalls = 0; + const parent = { + resourceId: 'parents', + dataSource: 'main', + columns: [{ name: 'id', primaryKey: true }], + options: { allowedActions: { delete: true } }, + hooks: { + delete: { + beforeSave: [async () => { + events.push('parent-before'); + return parentBeforeSave(); + }], + afterSave: [async () => { + events.push('parent-after'); + return { ok: true }; + }], + }, + }, + } as any; + parent.dataSourceColumns = parent.columns; + const child = { + resourceId: 'children', + dataSource: 'main', + columns: [ + { name: 'id', primaryKey: true }, + { name: 'parent_id', foreignResource: { resourceId: 'parents', onDelete: 'cascade' } }, + ], + options: { allowedActions: { delete: true } }, + hooks: { + delete: { + beforeSave: [async () => { + events.push('child-before'); + return { ok: true }; + }], + afterSave: [async () => { + events.push('child-after'); + return { ok: true }; + }], + }, + }, + } as any; + child.dataSourceColumns = child.columns; + const admin = Object.create(AdminForth.prototype) as any; + admin.config = { resources: [parent, child] }; + admin.statuses = { dbDiscover: 'done' }; + admin.warnedDeprecatedResourceMutations = new Set(); + const connector = { + validateAndNormalizeInputFilters: (filters: any) => filters, + getRecordByPrimaryKey: async (resource: any) => resource.resourceId === 'parents' + ? { id: 'p1' } + : { id: 'c1', parent_id: 'p1' }, + getData: async ({ resource }: any) => { + if (resource.resourceId === 'children') { + events.push('child-list'); + return { data: [{ id: 'c1', parent_id: 'p1' }], total: 1 }; + } + return { data: [{ id: 'p1' }], total: 1 }; + }, + deleteRecord: async ({ resource }: any) => { + events.push(resource.resourceId === 'parents' ? 'parent-delete' : 'child-delete'); + return true; + }, + }; + admin.connectors = { main: connector }; + const executors = { + create: (params: any) => admin.executeCreateResourceRecord(params), + update: (params: any) => admin.executeUpdateResourceRecord(params), + delete: (params: any, cascadeChildren?: boolean, bulkHooks?: boolean) => { + scopedDeleteCalls += 1; + return admin.executeDeleteResourceRecord(params, cascadeChildren, bulkHooks); + }, + }; + const operationalResource = (resource: any) => new OperationalResource( + connector as any, + resource, + (data, adminUser, options) => new UserScopedResource(data, admin, executors, adminUser, options), + ); + admin.operationalResources = { + parents: operationalResource(parent), + children: operationalResource(child), + }; + const actions = new ConfigValidator(admin, {} as any) + .validateAndNormalizeBulkActions({ options: { bulkActions: [] } } as any, parent, []); + const deleteChecked = actions[actions.length - 1]; + + return { + events, + parent, + scopedDeleteCalls: () => scopedDeleteCalls, + deleteChecked: (extra?: any) => deleteChecked.action({ + selectedIds: ['p1'], + adminUser: {} as any, + response: {} as any, + extra, + } as any), + }; +} + +describe('default bulk delete', () => { + it('does not cascade when the parent beforeSave hook vetoes deletion', async () => { + const { events, scopedDeleteCalls, deleteChecked } = setup(async () => ({ ok: false, error: 'blocked' })); + + await expect(deleteChecked()).resolves.toMatchObject({ ok: false, error: 'blocked' }); + expect(scopedDeleteCalls()).toBe(1); + expect(events).toEqual(['parent-before']); + }); + + it('runs the parent veto hook before cascading and deletes each record once', async () => { + const { events, scopedDeleteCalls, deleteChecked } = setup(async () => ({ ok: true })); + + await expect(deleteChecked()).resolves.toMatchObject({ ok: true }); + expect(scopedDeleteCalls()).toBe(1); + expect(events).toEqual([ + 'parent-before', + 'child-list', + 'child-before', + 'child-delete', + 'child-after', + 'parent-delete', + 'parent-after', + ]); + }); + + it('runs every beforeSave hook when one vetoes deletion', async () => { + const { events, parent, deleteChecked } = setup(async () => ({ ok: false, error: 'blocked' })); + parent.hooks.delete.beforeSave.push(async () => { + events.push('parent-before-second'); + return { ok: true }; + }); + + await expect(deleteChecked()).resolves.toMatchObject({ ok: false, error: 'blocked' }); + expect(events).toEqual(['parent-before', 'parent-before-second']); + }); + + it('runs every afterSave hook and ignores returned errors', async () => { + const { events, parent, deleteChecked } = setup(async () => ({ ok: true })); + parent.hooks.delete.afterSave[0] = async () => { + events.push('parent-after'); + return { ok: false, error: 'after failed' }; + }; + parent.hooks.delete.afterSave.push(async () => { + events.push('parent-after-second'); + return { ok: true }; + }); + + await expect(deleteChecked()).resolves.toMatchObject({ ok: true }); + expect(events).toContain('parent-after-second'); + expect(events).toContain('parent-delete'); + }); + + it('passes bulk request context to the row-scope hook using a list-shaped body', async () => { + const { parent, deleteChecked } = setup(async () => ({ ok: true })); + let hookPayload: any; + parent.hooks.list = { + beforeDatasourceRequest: [async (payload: any) => { + hookPayload = payload; + return { ok: true }; + }], + }; + const requestBody = { resourceId: 'parents', actionId: 'delete', recordIds: ['p1'] }; + + await expect(deleteChecked({ + body: requestBody, + query: {}, + headers: { 'x-tenant': 't1' }, + cookies: [], + requestUrl: '/start_bulk_action', + response: {}, + })).resolves.toMatchObject({ ok: true }); + + expect(hookPayload.extra.headers).toEqual({ 'x-tenant': 't1' }); + expect(hookPayload.extra.body).toBe(hookPayload.query); + expect(hookPayload.extra.body).not.toBe(requestBody); + }); +}); + +it('keeps the legacy four-argument cascade helper hook-aware', async () => { + const parent = { + resourceId: 'parents', + columns: [{ name: 'id', primaryKey: true }], + } as any; + const child = { + resourceId: 'children', + columns: [ + { name: 'id', primaryKey: true }, + { name: 'parent_id', foreignResource: { resourceId: 'parents', onDelete: 'cascade' } }, + ], + } as any; + const deleted: any[] = []; + const adminforth = { + config: { resources: [parent, child] }, + resource: () => ({ list: async () => [{ id: 'c1', parent_id: 'p1' }] }), + deleteResourceRecord: async (params: any) => { + deleted.push(params); + return { error: null }; + }, + } as any; + + await expect(cascadeChildrenDelete( + parent, + 'p1', + { adminUser: { id: 'admin' }, response: {} }, + adminforth, + )).resolves.toEqual({ error: null }); + + expect(deleted).toHaveLength(1); + expect(deleted[0]).toMatchObject({ resource: child, recordId: 'c1' }); +}); diff --git a/tests/jest_tests/rest_resource_delete.test.ts b/tests/jest_tests/rest_resource_delete.test.ts new file mode 100644 index 000000000..2fc957fa1 --- /dev/null +++ b/tests/jest_tests/rest_resource_delete.test.ts @@ -0,0 +1,79 @@ +import AdminForthRestAPI from '../../adminforth/modules/restApi.js'; + +it('does not report a successful REST deletion when the row is outside user scope', async () => { + const endpoints: Record = {}; + const resource = { + resourceId: 'items', + dataSource: 'main', + options: { allowedActions: { delete: true } }, + }; + const adminforth = { + config: { auth: { rateLimit: [] }, resources: [resource] }, + activatedPlugins: [], + connectors: { main: { getRecordByPrimaryKey: async () => ({ id: 'item-1' }) } }, + resource: () => ({ asUser: () => ({ delete: async () => false }) }), + } as any; + new AdminForthRestAPI(adminforth).registerEndpoints({ + endpoint: (endpoint: any) => { endpoints[endpoint.path] = endpoint; }, + } as any); + + const result = await endpoints['/delete_record'].handler({ + body: { resourceId: 'items', primaryKey: 'item-1' }, + adminUser: {}, + query: {}, + headers: {}, + cookies: [], + requestUrl: '', + response: {}, + }); + + expect(result).toEqual({ error: 'Record with item-1 not found' }); +}); + +it('checks delete permission before reporting that a REST record is missing', async () => { + const endpoints: Record = {}; + const resource = { + resourceId: 'items', + dataSource: 'main', + options: { allowedActions: { delete: false } }, + }; + const adminforth = { + config: { auth: { rateLimit: [] }, resources: [resource] }, + activatedPlugins: [], + connectors: { main: { getRecordByPrimaryKey: async () => null } }, + } as any; + new AdminForthRestAPI(adminforth).registerEndpoints({ + endpoint: (endpoint: any) => { endpoints[endpoint.path] = endpoint; }, + } as any); + + const result = await endpoints['/delete_record'].handler({ + body: { resourceId: 'items', primaryKey: 'missing' }, + adminUser: {}, query: {}, headers: {}, cookies: [], requestUrl: '', response: {}, + }); + + expect(result).toEqual({ error: 'Action is not allowed' }); +}); + +it('checks edit permission before reporting that a REST record is missing', async () => { + const endpoints: Record = {}; + const resource = { + resourceId: 'items', + dataSource: 'main', + options: { allowedActions: { edit: false } }, + }; + const adminforth = { + config: { auth: { rateLimit: [] }, resources: [resource] }, + activatedPlugins: [], + connectors: { main: { getRecordByPrimaryKey: async () => null } }, + } as any; + new AdminForthRestAPI(adminforth).registerEndpoints({ + endpoint: (endpoint: any) => { endpoints[endpoint.path] = endpoint; }, + } as any); + + const result = await endpoints['/update_record'].handler({ + body: { resourceId: 'items', recordId: 'missing', record: { name: 'Jane' } }, + adminUser: {}, query: {}, headers: {}, cookies: [], requestUrl: '', response: {}, + }); + + expect(result).toEqual({ error: 'Action is not allowed' }); +});