Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions adminforth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,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, listify, suggestIfTypo, RateLimiter, RAMLock, getClientIp, isProbablyUUIDColumn, convertPeriodToSeconds, hookResponseError, md5hash, applyRegexValidation, formatHugePluginError, recordWriteError } from './modules/utils.js';
import {
type AdminForthConfig,
type IAdminForth,
Expand Down Expand Up @@ -730,7 +730,16 @@ class AdminForth implements IAdminForth {
async createResourceRecord(
params: CreateResourceRecordParams,
): Promise<CreateResourceRecordResult> {
const { resource, record, adminUser, extra, response } = params;
const { resource, record, adminUser, extra, response, enforceColumnAccess } = params;

if (enforceColumnAccess) {
const accessError = await recordWriteError(record, 'create', {
adminUser, resource, meta: { requestBody: record }, source: ActionCheckSource.CreateRequest, adminforth: this,
});
if (accessError) {
return { error: accessError };
}
}

normalizeRecordValues(resource, record);

Expand Down Expand Up @@ -822,8 +831,19 @@ class AdminForth implements IAdminForth {
async updateResourceRecord(
params: UpdateResourceRecordParams,
): Promise<UpdateResourceRecordResult> {
const { resource, recordId, record, oldRecord, adminUser, response, extra, updates } = params;
const { resource, recordId, record, oldRecord, adminUser, response, extra, updates, enforceColumnAccess } = params;
const dataToUse = updates || record;

// before the editReadonly strip below, which would erase the field and hide the refusal
if (enforceColumnAccess) {
const accessError = await recordWriteError(dataToUse, 'edit', {
adminUser, resource, meta: { newRecord: dataToUse, oldRecord, pk: recordId }, source: ActionCheckSource.EditRequest, adminforth: this,
});
if (accessError) {
return { error: accessError };
}
}

normalizeRecordValues(resource, dataToUse);
const err = this.validateRecordValues(resource, dataToUse, 'edit');
if (err) {
Expand Down
130 changes: 10 additions & 120 deletions adminforth/modules/restApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import {cascadeChildrenDelete} from './utils.js'

import { afLogger } from "./logger.js";

import { ADMINFORTH_VERSION, listify, md5hash, getLoginPromptHTML, hookResponseError, parseLooseJson, RateLimiter } from './utils.js';
import { ADMINFORTH_VERSION, listify, md5hash, getLoginPromptHTML, hookResponseError, parseLooseJson, RateLimiter,
isBackendOnly, isShown, stripBackendOnly, recordWriteError } from './utils.js';

import AdminForthAuth from "../auth.js";
import { ActionCheckSource, AdminForthActionFront, AdminForthConfigMenuItem, AdminForthDataTypes, AdminForthFilterOperators, AdminForthResourceColumnInputCommon, AdminForthResourceFrontend, AdminForthResourcePages,
Expand All @@ -35,51 +36,6 @@ 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<boolean> {
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<boolean> {
return await resolveBoolOrFn(col.backendOnly, ctx);
}

async function isShown(
col: AdminForthResource['columns'][number],
page: 'list' | 'show' | 'edit' | 'create' | 'filter',
ctx: Parameters<typeof isBackendOnly>[1]
): Promise<boolean> {
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<boolean> {
const fillOnCreate = !!col.fillOnCreate;
return fillOnCreate;
}

function stripResourceColumnFrontendMeta(column: Record<string, any>) {
const { default: _default, _baseTypeDebug, ...sanitizedColumn } = column;
return sanitizedColumn;
Expand Down Expand Up @@ -1061,13 +1017,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 stripBackendOnly(adminUser.dbUser, ctx);

return {
loggedIn: true,
Expand Down Expand Up @@ -1632,13 +1582,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
};

for (const item of data.data) {
for (const key of Object.keys(item)) {
const col = resource.columns.find((c) => c.name === key);
const bo = col ? await isBackendOnly(col, ctx) : true;
if (!col || bo) {
delete item[key];
}
}
await stripBackendOnly(item, ctx);
if (!selectedColumnNameSet || shouldAddListHelpers) {
item._label = resource.recordLabel(item);
}
Expand Down Expand Up @@ -2156,20 +2100,6 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {

const { record, requiredColumnsToSkip } = body;

// todo if showIn.create is function, code below will be buggy (will not detect required fact)
for (const column of resource.columns) {
if (
(column.required as {create?: boolean, edit?: boolean})?.create &&
record[column.name] === undefined &&
column.showIn.create
) {
const shouldWeSkipColumn = requiredColumnsToSkip.find(reqColumnToSkip => reqColumnToSkip.name === column.name);
if (!shouldWeSkipColumn) {
return { error: `Column '${column.name}' is required`, ok: false };
}
}
}

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])]);
Expand Down Expand Up @@ -2198,26 +2128,9 @@ 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,
};
}
}
const createFieldError = await recordWriteError(record, 'create', ctxCreate);
if (createFieldError) {
return { error: createFieldError, ok: false };
}

// for polymorphic foreign resources, we need to find out the value for polymorphicOn column
Expand Down Expand Up @@ -2342,32 +2255,9 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
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,
};
}
}
const editFieldError = await recordWriteError(record, 'edit', ctxEdit);
if (editFieldError) {
return { error: editFieldError, ok: false };
}
// for polymorphic foreign resources, we need to find out the value for polymorphicOn column
for (const column of resource.columns) {
Expand Down
101 changes: 99 additions & 2 deletions adminforth/modules/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import Fuse from 'fuse.js';
import crypto from 'crypto';
import { AdminForthConfig, AdminForthResource, AdminForthResourceColumnInputCommon,Filters, IAdminForth, Predicate } from '../index.js';
import { RateLimiterMemory, RateLimiterAbstract } from "rate-limiter-flexible";
import { PERIOD_UNITS, type PeriodString, type PeriodUnit } from '../types/Back.js';
import { PERIOD_UNITS, type PeriodString, type PeriodUnit, type BackendOnlyInput } from '../types/Back.js';
import type { AdminUser, ActionCheckSource } from '../types/Common.js';

// @ts-ignore-next-line

Expand Down Expand Up @@ -667,4 +668,100 @@ export function checkIfLinkInAllowedHosts(url: string, allowedHosts: string[]) {
if (!allowed) {
throw new Error(`Attachment host "${hostname}" is not in attachImagesAllowedHosts`);
}
}
}


export type ColumnAccessContext = {
adminUser: AdminUser;
resource: AdminForthResource;
meta?: any;
source: ActionCheckSource;
adminforth: IAdminForth;
};

type Column = AdminForthResource['columns'][number];

export async function resolveBoolOrFn(val: BackendOnlyInput | undefined, ctx: ColumnAccessContext): Promise<boolean> {
if (typeof val === 'function') {
return !!(await (val as any)(ctx));
}
return !!val;
}

export async function isBackendOnly(col: Column, ctx: ColumnAccessContext): Promise<boolean> {
return await resolveBoolOrFn(col.backendOnly, ctx);
}

export async function isShown(
col: Column,
page: 'list' | 'show' | 'edit' | 'create' | 'filter',
ctx: ColumnAccessContext,
): Promise<boolean> {
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;
}

/**
* Removes every key that is not a declared column of the resource, or is backendOnly for this user.
* Apply it to any record before it leaves the server. Mutates and returns the record.
*/
export async function stripBackendOnly<T extends Record<string, any>>(record: T, ctx: ColumnAccessContext): Promise<T> {
for (const key of Object.keys(record)) {
const col = ctx.resource.columns.find((c) => c.name === key);
if (!col || await isBackendOnly(col, ctx)) {
delete record[key];
}
}
return record;
}

/**
* Returns null when the column may be written on this page, otherwise the message
* the REST create/update routes return.
*/
export async function columnWriteError(
col: Column,
page: 'create' | 'edit',
ctx: ColumnAccessContext,
): Promise<string | null> {
const action = page === 'edit' ? 'editing' : 'creation';
if (await isBackendOnly(col, ctx)) {
return `Field "${col.name}" cannot be modified as it is restricted from ${action} (backendOnly is true).`;
}
if (page === 'edit' && col.editReadonly) {
return `Field "${col.name}" cannot be modified as it is restricted from editing (editReadonly is true).`;
}
if (await isShown(col, page, ctx)) {
return null;
}
if (page === 'edit') {
return col.allowModifyWhenNotShowInEdit ? null
: `Field "${col.name}" 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 (col.fillOnCreate || col.allowModifyWhenNotShowInCreate) ? null
: `Field "${col.name}" 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.`;
}

/** Returns null when every declared column the record touches may be written on this page, otherwise the first error. */
export async function recordWriteError(
record: Record<string, any>,
page: 'create' | 'edit',
ctx: ColumnAccessContext,
): Promise<string | null> {
for (const col of ctx.resource.columns) {
if (!(col.name in record)) {
continue;
}
const error = await columnWriteError(col, page, ctx);
if (error) {
return error;
}
}
return null;
}
27 changes: 25 additions & 2 deletions adminforth/types/Back.ts
Original file line number Diff line number Diff line change
Expand Up @@ -849,12 +849,19 @@ export type CreateResourceRecordParams = {
* Extra HTTP information. Prefer using extra.response over the top-level response field.
*/
extra?: HttpExtra;

/**
* Apply the same column checks the REST routes apply: backendOnly, showIn, editReadonly and the
* allowModifyWhenNotShowIn* escapes. Off by default, because the programmatic API is a system-level
* API and plugins legitimately write columns that no user may write.
*/
enforceColumnAccess?: boolean;
};

/**
* Parameters for {@link IAdminForth.updateResourceRecord}.
*/
export type UpdateResourceRecordParams =
export type UpdateResourceRecordParams = (
| {
/**
* Resource configuration used to update a record.
Expand Down Expand Up @@ -944,7 +951,15 @@ export type UpdateResourceRecordParams =
* Partial record data with only changed fields. Mutually exclusive with record.
*/
updates: any;
};
}
) & {
/**
* Apply the same column checks the REST routes apply: backendOnly, showIn, editReadonly and the
* allowModifyWhenNotShowIn* escapes. Off by default, because the programmatic API is a system-level
* API and plugins legitimately write columns that no user may write.
*/
enforceColumnAccess?: boolean;
};

/**
* Parameters for {@link IAdminForth.deleteResourceRecord}.
Expand Down Expand Up @@ -2126,6 +2141,14 @@ export class Sorts {
}
}

/**
* Low-level system API. It takes no adminUser, so it applies no allowedActions, no backendOnly,
* no showIn, no editReadonly and no hooks: get() and list() return every non-virtual column,
* including backendOnly ones, and update()/create() write whatever they are given.
*
* Before returning a record to a browser call stripBackendOnly(record, ctx); before writing
* user-supplied fields call recordWriteError(record, 'edit' | 'create', ctx).
*/
export interface IOperationalResource {
get: (filter: IAdminForthSingleFilter | IAdminForthAndOrFilter | Array<IAdminForthSingleFilter | IAdminForthAndOrFilter>) => Promise<any | null>;

Expand Down
Loading