@directus/api
Version:
Directus is a real-time API and App dashboard for managing SQL database content
159 lines (158 loc) • 7.35 kB
JavaScript
import { ForbiddenError, InvalidPayloadError } from '@directus/errors';
import { deepMapWithSchema, isDetailedUpdateSyntax } from '@directus/utils';
import { verifyPermissions } from './verify-permissions.js';
/**
* Validates a changes payload against the user's update/create permissions and errors if unauthorized field is encountered
*/
export async function validateChanges(payload, collection, itemId, context) {
return processPermissions(payload, collection, { ...context, itemId, direction: 'inbound' });
}
/**
* Sanitizes a payload based on the recipient's read permissions and the schema
*/
export async function sanitizePayload(payload, collection, context) {
return processPermissions(payload, collection, { ...context, direction: 'outbound' });
}
/**
* Core utility to walk a payload and apply permissions
*/
async function processPermissions(payload, collection, context) {
const { direction, accountability, schema, knex, itemId } = context;
// Local cache for permissions to avoid redundant verifyPermissions calls for the same item:action pair
// The promise is cached, so concurrent field lookups for the same item wait for the same result
const permissionsCache = new Map();
const getPermissions = (col, id, action) => {
const cacheKey = `${col}:${id}:${action}`;
let cached = permissionsCache.get(cacheKey);
if (!cached) {
cached = verifyPermissions(accountability, col, id, action, { knex, schema });
permissionsCache.set(cacheKey, cached);
}
return cached;
};
return deepMapWithSchema(payload, async (entry, deepMapContext) => {
const [key, value] = entry;
if (direction === 'outbound') {
// Strip sensitive fields
if (deepMapContext.field?.special?.some((v) => v === 'conceal' || v === 'hash' || v === 'encrypt')) {
return undefined;
}
// Strip unknown leaf fields
if (deepMapContext.leaf && !deepMapContext.relation && !deepMapContext.field) {
return undefined;
}
}
if (value === undefined)
return undefined;
// Resolve the action (CRUD) and the ID to check against
const currentCollection = deepMapContext.collection.collection;
const pkField = deepMapContext.collection.primary;
const primaryKeyInObject = (deepMapContext.object[pkField] ?? null);
let action = direction === 'inbound' ? 'update' : 'read';
let effectiveItemId = primaryKeyInObject;
if (direction === 'inbound') {
const isTopLevel = deepMapContext.object === payload;
// At the top level, we use the ID from the request context (itemId)
// Deeply nested objects must provide their own ID for update checks
if (isTopLevel) {
effectiveItemId = itemId ?? null;
action = itemId ? 'update' : 'create';
}
else if (!primaryKeyInObject) {
action = 'create';
}
if (deepMapContext.action) {
action = deepMapContext.action;
}
}
else {
// sanitizePayload uses context.itemId as a fallback for the root item
if (deepMapContext.object === payload) {
effectiveItemId = primaryKeyInObject ?? itemId ?? null;
}
}
// Ensure no unexpected fields sneak into a delete operation
if (direction === 'inbound' && action === 'delete') {
if (key !== pkField) {
throw new InvalidPayloadError({ reason: `Unexpected field ${key} in delete payload` });
}
const allowed = await getPermissions(currentCollection, primaryKeyInObject, 'delete');
if (allowed === null || (allowed.length === 0 && !accountability?.admin)) {
throw new ForbiddenError({ reason: `No permission to delete item in collection ${currentCollection}` });
}
return;
}
// Allow PK field for identification on updates
if (direction === 'inbound' && action === 'update' && key === pkField) {
return;
}
let allowedFields = await getPermissions(currentCollection, effectiveItemId, action);
// Fallbacks
if (!allowedFields) {
if (direction === 'inbound' && action === 'update') {
// Toggle to create if update fails due to non-existence
action = 'create';
allowedFields = await getPermissions(currentCollection, effectiveItemId, action);
}
else if (direction === 'outbound') {
// Fall back to collection-wide read
allowedFields = (await getPermissions(currentCollection, null, 'read')) ?? [];
}
}
const isAllowed = allowedFields && (accountability?.admin || allowedFields.includes('*') || allowedFields.includes(String(key)));
if (!isAllowed) {
if (direction === 'inbound') {
throw new ForbiddenError({ reason: `No permission to ${action} field ${key} or field does not exist` });
}
return undefined;
}
// Remove the relation field entirely from the payload if it's empty after sanitizing its children
if (direction === 'outbound' && deepMapContext.relationType) {
if (Array.isArray(value)) {
const items = value.filter(isVisible);
if (items.length === 0)
return undefined;
return [key, items];
}
else if (isDetailedUpdateSyntax(value)) {
const filtered = {
...value,
create: value.create.filter(isVisible),
update: value.update.filter(isVisible),
delete: value.delete.filter(isVisible),
};
if (filtered.create.length === 0 && filtered.update.length === 0 && filtered.delete.length === 0) {
return undefined;
}
return [key, filtered];
}
else if (!isVisible(value)) {
return undefined;
}
}
return [key, value];
}, {
schema,
collection,
}, {
detailedUpdateSyntax: true,
omitUnknownFields: direction === 'outbound',
mapPrimaryKeys: true,
processAsync: true,
iterateOnly: direction === 'inbound', // Validation only needs to check permissions, not rebuild the payload
onUnknownField: (entry) => {
const [key] = entry;
// Allow Directus internal metadata keys like $type
if (String(key).startsWith('$'))
return entry;
if (direction === 'inbound') {
throw new ForbiddenError({ reason: `No permission to update field ${key} or field does not exist` });
}
return undefined;
},
});
}
// Identifies non-empty or defined actionable content to avoid processing invalid relation links
function isVisible(item) {
return item !== undefined && !(typeof item === 'object' && item !== null && Object.keys(item).length === 0);
}