diff --git a/api/src/app.ts b/api/src/app.ts index fc6757ee6cc3f..267b896130094 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -34,6 +34,7 @@ import { InvalidPayloadException } from './exceptions'; import { getExtensionManager } from './extensions'; import logger, { expressLogger } from './logger'; import authenticate from './middleware/authenticate'; +import getPermissions from './middleware/get-permissions'; import cache from './middleware/cache'; import { checkIP } from './middleware/check-ip'; import cors from './middleware/cors'; @@ -153,14 +154,16 @@ export default async function createApp(): Promise { app.use(sanitizeQuery); - await emitAsyncSafe('middlewares.init.after', { app }); - - await emitAsyncSafe('routes.init.before', { app }); - app.use(cache); app.use(schema); + app.use(getPermissions); + + await emitAsyncSafe('middlewares.init.after', { app }); + + await emitAsyncSafe('routes.init.before', { app }); + app.use('/auth', authRouter); app.use('/graphql', graphqlRouter); diff --git a/api/src/cache.ts b/api/src/cache.ts index 276f54b337892..f70dfb2c90e50 100644 --- a/api/src/cache.ts +++ b/api/src/cache.ts @@ -6,26 +6,26 @@ import { getConfigFromEnv } from './utils/get-config-from-env'; import { validateEnv } from './utils/validate-env'; let cache: Keyv | null = null; -let schemaCache: Keyv | null = null; +let systemCache: Keyv | null = null; -export function getCache(): { cache: Keyv | null; schemaCache: Keyv | null } { +export function getCache(): { cache: Keyv | null; systemCache: Keyv } { if (env.CACHE_ENABLED === true && cache === null) { validateEnv(['CACHE_NAMESPACE', 'CACHE_TTL', 'CACHE_STORE']); cache = getKeyvInstance(ms(env.CACHE_TTL as string)); cache.on('error', (err) => logger.warn(err, `[cache] ${err}`)); } - if (env.CACHE_SCHEMA !== false && schemaCache === null) { - schemaCache = getKeyvInstance(typeof env.CACHE_SCHEMA === 'string' ? ms(env.CACHE_SCHEMA) : undefined, '_schema'); - schemaCache.on('error', (err) => logger.warn(err, `[cache] ${err}`)); + if (systemCache === null) { + systemCache = getKeyvInstance(undefined, '_system'); + systemCache.on('error', (err) => logger.warn(err, `[cache] ${err}`)); } - return { cache, schemaCache }; + return { cache, systemCache }; } export async function flushCaches(): Promise { - const { schemaCache, cache } = getCache(); - await schemaCache?.clear(); + const { systemCache, cache } = getCache(); + await systemCache?.clear(); await cache?.clear(); } diff --git a/api/src/database/system-data/fields/activity.yaml b/api/src/database/system-data/fields/activity.yaml index acb1c801f5e01..a4cbb24056568 100644 --- a/api/src/database/system-data/fields/activity.yaml +++ b/api/src/database/system-data/fields/activity.yaml @@ -10,8 +10,6 @@ fields: - field: action display: labels display_options: - defaultForeground: 'var(--foreground-normal)' - defaultBackground: 'var(--background-normal-alt)' choices: - text: $t:field_options.directus_activity.create value: create diff --git a/api/src/database/system-data/fields/files.yaml b/api/src/database/system-data/fields/files.yaml index 8eeb56281c2a0..f9c0c866fecee 100644 --- a/api/src/database/system-data/fields/files.yaml +++ b/api/src/database/system-data/fields/files.yaml @@ -27,7 +27,6 @@ fields: width: full display: labels display_options: - defaultBackground: '#ECEFF1' choices: null format: false diff --git a/api/src/database/system-data/fields/users.yaml b/api/src/database/system-data/fields/users.yaml index 478083f09b3b7..eb1da07ea9227 100644 --- a/api/src/database/system-data/fields/users.yaml +++ b/api/src/database/system-data/fields/users.yaml @@ -56,7 +56,6 @@ fields: iconRight: local_offer display: labels display_options: - defaultBackground: '#ECEFF1' choices: null format: false diff --git a/api/src/database/system-data/fields/webhooks.yaml b/api/src/database/system-data/fields/webhooks.yaml index 69fe8e51bea2f..b3c254a3f444e 100644 --- a/api/src/database/system-data/fields/webhooks.yaml +++ b/api/src/database/system-data/fields/webhooks.yaml @@ -14,8 +14,6 @@ fields: interface: select-dropdown display: labels display_options: - defaultForeground: 'var(--foreground-normal)' - defaultBackground: 'var(--background-normal-alt)' choices: - value: POST foreground: 'var(--primary)' @@ -40,8 +38,6 @@ fields: interface: select-dropdown display: labels display_options: - defaultForeground: 'var(--foreground-normal)' - defaultBackground: 'var(--background-normal-alt)' showAsDot: true choices: - text: $t:active @@ -113,8 +109,6 @@ fields: width: full display: labels display_options: - defaultForeground: 'var(--foreground-normal)' - defaultBackground: 'var(--background-normal-alt)' choices: - text: $t:create value: create @@ -139,7 +133,5 @@ fields: width: full display: labels display_options: - defaultForeground: 'var(--foreground-normal)' - defaultBackground: 'var(--background-normal-alt)' choices: null format: false diff --git a/api/src/env.ts b/api/src/env.ts index e8ab5f093b0d9..8cada5c344785 100644 --- a/api/src/env.ts +++ b/api/src/env.ts @@ -53,6 +53,7 @@ const defaults: Record = { CACHE_AUTO_PURGE: false, CACHE_CONTROL_S_MAXAGE: '0', CACHE_SCHEMA: true, + CACHE_PERMISSIONS: true, AUTH_PROVIDERS: '', diff --git a/api/src/middleware/authenticate.ts b/api/src/middleware/authenticate.ts index 04bb82ff06fcb..02dbdde31dd8f 100644 --- a/api/src/middleware/authenticate.ts +++ b/api/src/middleware/authenticate.ts @@ -19,63 +19,63 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => { userAgent: req.get('user-agent'), }; - if (!req.token) return next(); - const database = getDatabase(); - if (isDirectusJWT(req.token)) { - let payload: { id: string }; + if (req.token) { + if (isDirectusJWT(req.token)) { + let payload: { id: string }; - try { - payload = jwt.verify(req.token, env.SECRET as string, { issuer: 'directus' }) as { id: string }; - } catch (err: any) { - if (err instanceof TokenExpiredError) { - throw new InvalidCredentialsException('Token expired.'); - } else if (err instanceof JsonWebTokenError) { - throw new InvalidCredentialsException('Token invalid.'); - } else { - throw err; + try { + payload = jwt.verify(req.token, env.SECRET as string, { issuer: 'directus' }) as { id: string }; + } catch (err: any) { + if (err instanceof TokenExpiredError) { + throw new InvalidCredentialsException('Token expired.'); + } else if (err instanceof JsonWebTokenError) { + throw new InvalidCredentialsException('Token invalid.'); + } else { + throw err; + } } - } - const user = await database - .select('directus_users.role', 'directus_roles.admin_access', 'directus_roles.app_access') - .from('directus_users') - .leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id') - .where({ - 'directus_users.id': payload.id, - status: 'active', - }) - .first(); + const user = await database + .select('directus_users.role', 'directus_roles.admin_access', 'directus_roles.app_access') + .from('directus_users') + .leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id') + .where({ + 'directus_users.id': payload.id, + status: 'active', + }) + .first(); - if (!user) { - throw new InvalidCredentialsException(); - } + if (!user) { + throw new InvalidCredentialsException(); + } - req.accountability.user = payload.id; - req.accountability.role = user.role; - req.accountability.admin = user.admin_access === true || user.admin_access == 1; - req.accountability.app = user.app_access === true || user.app_access == 1; - } else { - // Try finding the user with the provided token - const user = await database - .select('directus_users.id', 'directus_users.role', 'directus_roles.admin_access', 'directus_roles.app_access') - .from('directus_users') - .leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id') - .where({ - 'directus_users.token': req.token, - status: 'active', - }) - .first(); + req.accountability.user = payload.id; + req.accountability.role = user.role; + req.accountability.admin = user.admin_access === true || user.admin_access == 1; + req.accountability.app = user.app_access === true || user.app_access == 1; + } else { + // Try finding the user with the provided token + const user = await database + .select('directus_users.id', 'directus_users.role', 'directus_roles.admin_access', 'directus_roles.app_access') + .from('directus_users') + .leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id') + .where({ + 'directus_users.token': req.token, + status: 'active', + }) + .first(); - if (!user) { - throw new InvalidCredentialsException(); - } + if (!user) { + throw new InvalidCredentialsException(); + } - req.accountability.user = user.id; - req.accountability.role = user.role; - req.accountability.admin = user.admin_access === true || user.admin_access == 1; - req.accountability.app = user.app_access === true || user.app_access == 1; + req.accountability.user = user.id; + req.accountability.role = user.role; + req.accountability.admin = user.admin_access === true || user.admin_access == 1; + req.accountability.app = user.app_access === true || user.app_access == 1; + } } return next(); diff --git a/api/src/middleware/get-permissions.ts b/api/src/middleware/get-permissions.ts new file mode 100644 index 0000000000000..7b2ba94950c41 --- /dev/null +++ b/api/src/middleware/get-permissions.ts @@ -0,0 +1,140 @@ +import { Permission } from '@directus/shared/types'; +import { deepMap, parseFilter } from '@directus/shared/utils'; +import { RequestHandler } from 'express'; +import { cloneDeep } from 'lodash'; +import getDatabase from '../database'; +import { appAccessMinimalPermissions } from '../database/system-data/app-access-permissions'; +import asyncHandler from '../utils/async-handler'; +import { mergePermissions } from '../utils/merge-permissions'; +import { UsersService } from '../services/users'; +import { RolesService } from '../services/roles'; +import { getCache } from '../cache'; +import hash from 'object-hash'; +import env from '../env'; + +const getPermissions: RequestHandler = asyncHandler(async (req, res, next) => { + const database = getDatabase(); + const { systemCache } = getCache(); + + let permissions: Permission[] = []; + + if (!req.accountability) { + throw new Error('"getPermissions" needs to be used after the "authenticate" middleware'); + } + + if (!req.schema) { + throw new Error('"getPermissions" needs to be used after the "schema" middleware'); + } + + const { user, role, app, admin } = req.accountability; + const cacheKey = `permissions-${hash({ user, role, app, admin })}`; + + if (env.CACHE_PERMISSIONS !== false) { + const cachedPermissions = await systemCache.get(cacheKey); + + if (cachedPermissions) { + req.accountability.permissions = cachedPermissions; + return next(); + } + } + + if (req.accountability.admin !== true) { + const permissionsForRole = await database + .select('*') + .from('directus_permissions') + .where({ role: req.accountability.role }); + + const requiredPermissionData = { + $CURRENT_USER: [] as string[], + $CURRENT_ROLE: [] as string[], + }; + + permissions = permissionsForRole.map((permissionRaw) => { + const permission = cloneDeep(permissionRaw); + + if (permission.permissions && typeof permission.permissions === 'string') { + permission.permissions = JSON.parse(permission.permissions); + } else if (permission.permissions === null) { + permission.permissions = {}; + } + + if (permission.validation && typeof permission.validation === 'string') { + permission.validation = JSON.parse(permission.validation); + } else if (permission.validation === null) { + permission.validation = {}; + } + + if (permission.presets && typeof permission.presets === 'string') { + permission.presets = JSON.parse(permission.presets); + } else if (permission.presets === null) { + permission.presets = {}; + } + + if (permission.fields && typeof permission.fields === 'string') { + permission.fields = permission.fields.split(','); + } else if (permission.fields === null) { + permission.fields = []; + } + + const extractPermissionData = (val: any) => { + if (typeof val === 'string' && val.startsWith('$CURRENT_USER.')) { + requiredPermissionData.$CURRENT_USER.push(val.replace('$CURRENT_USER.', '')); + } + + if (typeof val === 'string' && val.startsWith('$CURRENT_ROLE.')) { + requiredPermissionData.$CURRENT_ROLE.push(val.replace('$CURRENT_ROLE.', '')); + } + + return val; + }; + + deepMap(permission.permissions, extractPermissionData); + deepMap(permission.validation, extractPermissionData); + deepMap(permission.presets, extractPermissionData); + + return permission; + }); + + if (req.accountability.app === true) { + permissions = mergePermissions( + permissions, + appAccessMinimalPermissions.map((perm) => ({ ...perm, role: req.accountability!.role })) + ); + } + + const usersService = new UsersService({ schema: req.schema }); + const rolesService = new RolesService({ schema: req.schema }); + + const filterContext: Record = {}; + + if (req.accountability.user && requiredPermissionData.$CURRENT_USER.length > 0) { + filterContext.$CURRENT_USER = await usersService.readOne(req.accountability.user, { + fields: requiredPermissionData.$CURRENT_USER, + }); + } + + if (req.accountability.role && requiredPermissionData.$CURRENT_ROLE.length > 0) { + filterContext.$CURRENT_ROLE = await rolesService.readOne(req.accountability.role, { + fields: requiredPermissionData.$CURRENT_ROLE, + }); + } + + permissions = permissions.map((permission) => { + permission.permissions = parseFilter(permission.permissions, req.accountability!, filterContext); + permission.validation = parseFilter(permission.validation, req.accountability!, filterContext); + permission.presets = parseFilter(permission.presets, req.accountability!, filterContext); + + return permission; + }); + + if (env.CACHE_PERMISSIONS !== false) { + await systemCache.set(cacheKey, permissions); + } + } + + req.accountability.permissions = permissions; + + return next(); +}); + +export default getPermissions; diff --git a/api/src/services/authorization.ts b/api/src/services/authorization.ts index 276516c015404..3dcab9b51b946 100644 --- a/api/src/services/authorization.ts +++ b/api/src/services/authorization.ts @@ -3,7 +3,7 @@ import { cloneDeep, merge, uniq, uniqWith, flatten, isNil } from 'lodash'; import getDatabase from '../database'; import { ForbiddenException } from '../exceptions'; import { FailedValidationException } from '@directus/shared/exceptions'; -import { validatePayload, parseFilter } from '@directus/shared/utils'; +import { validatePayload } from '@directus/shared/utils'; import { Accountability } from '@directus/shared/types'; import { AbstractServiceOptions, @@ -37,15 +37,16 @@ export class AuthorizationService { async processAST(ast: AST, action: PermissionsAction = 'read'): Promise { const collectionsRequested = getCollectionsFromAST(ast); - const permissionsForCollections = uniqWith( - this.schema.permissions.filter((permission) => { - return ( - permission.action === action && - collectionsRequested.map(({ collection }) => collection).includes(permission.collection) - ); - }), - (curr, prev) => curr.collection === prev.collection && curr.action === prev.action && curr.role === prev.role - ); + const permissionsForCollections = + uniqWith( + this.accountability?.permissions?.filter((permission) => { + return ( + permission.action === action && + collectionsRequested.map(({ collection }) => collection).includes(permission.collection) + ); + }), + (curr, prev) => curr.collection === prev.collection && curr.action === prev.action && curr.role === prev.role + ) ?? []; // If the permissions don't match the collections, you don't have permission to read all of them const uniqueCollectionsRequestedCount = uniq(collectionsRequested.map(({ collection }) => collection)).length; @@ -174,16 +175,14 @@ export class AuthorizationService { // We check the availability of the permissions in the step before this is run const permissions = permissionsForCollections.find((permission) => permission.collection === collection)!; - const parsedPermissions = parseFilter(permissions.permissions, accountability); - if (!query.filter || Object.keys(query.filter).length === 0) { query.filter = { _and: [] }; } else { query.filter = { _and: [query.filter] }; } - if (parsedPermissions && Object.keys(parsedPermissions).length > 0) { - query.filter._and.push(parsedPermissions); + if (permissions.permissions && Object.keys(permissions.permissions).length > 0) { + query.filter._and.push(permissions.permissions); } if (query.filter._and.length === 0) delete query.filter; @@ -194,7 +193,7 @@ export class AuthorizationService { /** * Checks if the provided payload matches the configured permissions, and adds the presets to the payload. */ - validatePayload(action: PermissionsAction, collection: string, data: Partial): Promise> { + validatePayload(action: PermissionsAction, collection: string, data: Partial): Partial { const payload = cloneDeep(data); let permission: Permission | undefined; @@ -211,7 +210,7 @@ export class AuthorizationService { presets: {}, }; } else { - permission = this.schema.permissions.find((permission) => { + permission = this.accountability?.permissions?.find((permission) => { return permission.collection === collection && permission.action === action; }); @@ -231,7 +230,7 @@ export class AuthorizationService { } } - const preset = parseFilter(permission.presets || {}, this.accountability); + const preset = permission.presets ?? {}; const payloadWithPresets = merge({}, preset, payload); @@ -282,7 +281,7 @@ export class AuthorizationService { validationErrors.push( ...flatten( - validatePayload(parseFilter(permission.validation!, this.accountability), payloadWithPresets).map((error) => + validatePayload(permission.validation!, payloadWithPresets).map((error) => error.details.map((details) => new FailedValidationException(details)) ) ) diff --git a/api/src/services/collections.ts b/api/src/services/collections.ts index 153dbc51ae177..c3f9d89d83a20 100644 --- a/api/src/services/collections.ts +++ b/api/src/services/collections.ts @@ -26,7 +26,7 @@ export class CollectionsService { schemaInspector: ReturnType; schema: SchemaOverview; cache: Keyv | null; - schemaCache: Keyv | null; + systemCache: Keyv; constructor(options: AbstractServiceOptions) { this.knex = options.knex || getDatabase(); @@ -34,9 +34,9 @@ export class CollectionsService { this.schemaInspector = options.knex ? SchemaInspector(options.knex) : getSchemaInspector(); this.schema = options.schema; - const { cache, schemaCache } = getCache(); + const { cache, systemCache } = getCache(); this.cache = cache; - this.schemaCache = schemaCache; + this.systemCache = systemCache; } /** @@ -141,9 +141,7 @@ export class CollectionsService { await this.cache.clear(); } - if (this.schemaCache) { - await this.schemaCache.clear(); - } + await this.systemCache.clear(); return payload.collection; } @@ -173,9 +171,7 @@ export class CollectionsService { await this.cache.clear(); } - if (this.schemaCache) { - await this.schemaCache.clear(); - } + await this.systemCache.clear(); return collections; } @@ -199,8 +195,8 @@ export class CollectionsService { meta.push(...systemCollectionRows); if (this.accountability && this.accountability.admin !== true) { - const collectionsYouHavePermissionToRead: string[] = this.schema.permissions - .filter((permission) => { + const collectionsYouHavePermissionToRead: string[] = this.accountability + .permissions!.filter((permission) => { return permission.action === 'read'; }) .map(({ collection }) => collection); @@ -258,7 +254,7 @@ export class CollectionsService { */ async readMany(collectionKeys: string[]): Promise { if (this.accountability && this.accountability.admin !== true) { - const permissions = this.schema.permissions.filter((permission) => { + const permissions = this.accountability.permissions!.filter((permission) => { return permission.action === 'read' && collectionKeys.includes(permission.collection); }); @@ -313,9 +309,7 @@ export class CollectionsService { await this.cache.clear(); } - if (this.schemaCache) { - await this.schemaCache.clear(); - } + await this.systemCache.clear(); return collectionKey; } @@ -344,9 +338,7 @@ export class CollectionsService { await this.cache.clear(); } - if (this.schemaCache) { - await this.schemaCache.clear(); - } + await this.systemCache.clear(); return collectionKeys; } @@ -445,9 +437,7 @@ export class CollectionsService { await this.cache.clear(); } - if (this.schemaCache) { - await this.schemaCache.clear(); - } + await this.systemCache.clear(); return collectionKey; } @@ -476,9 +466,7 @@ export class CollectionsService { await this.cache.clear(); } - if (this.schemaCache) { - await this.schemaCache.clear(); - } + await this.systemCache.clear(); return collectionKeys; } diff --git a/api/src/services/fields.ts b/api/src/services/fields.ts index c4db1c35880bc..5eb356d7b7f8c 100644 --- a/api/src/services/fields.ts +++ b/api/src/services/fields.ts @@ -30,7 +30,7 @@ export class FieldsService { schemaInspector: ReturnType; schema: SchemaOverview; cache: Keyv | null; - schemaCache: Keyv | null; + systemCache: Keyv; constructor(options: AbstractServiceOptions) { this.knex = options.knex || getDatabase(); @@ -40,13 +40,14 @@ export class FieldsService { this.payloadService = new PayloadService('directus_fields', options); this.schema = options.schema; - const { cache, schemaCache } = getCache(); + const { cache, systemCache } = getCache(); + this.cache = cache; - this.schemaCache = schemaCache; + this.systemCache = systemCache; } private get hasReadAccess() { - return !!this.schema.permissions.find((permission) => { + return !!this.accountability?.permissions?.find((permission) => { return permission.collection === 'directus_fields' && permission.action === 'read'; }); } @@ -144,7 +145,7 @@ export class FieldsService { // Filter the result so we only return the fields you have read access to if (this.accountability && this.accountability.admin !== true) { - const permissions = this.schema.permissions.filter((permission) => { + const permissions = this.accountability.permissions!.filter((permission) => { return permission.action === 'read'; }); @@ -175,7 +176,7 @@ export class FieldsService { throw new ForbiddenException(); } - const permissions = this.schema.permissions.find((permission) => { + const permissions = this.accountability.permissions!.find((permission) => { return permission.action === 'read' && permission.collection === collection; }); @@ -266,9 +267,7 @@ export class FieldsService { await this.cache.clear(); } - if (this.schemaCache) { - await this.schemaCache.clear(); - } + await this.systemCache.clear(); } async updateField(collection: string, field: RawField): Promise { @@ -317,9 +316,7 @@ export class FieldsService { await this.cache.clear(); } - if (this.schemaCache) { - await this.schemaCache.clear(); - } + await this.systemCache.clear(); return field.field; } @@ -431,9 +428,7 @@ export class FieldsService { await this.cache.clear(); } - if (this.schemaCache) { - await this.schemaCache.clear(); - } + await this.systemCache.clear(); emitAsyncSafe(`fields.delete`, { event: `fields.delete`, diff --git a/api/src/services/files.ts b/api/src/services/files.ts index 853f64ee81686..de9d6fbfea84d 100644 --- a/api/src/services/files.ts +++ b/api/src/services/files.ts @@ -142,7 +142,7 @@ export class FilesService extends ItemsService { * Import a single file from an external URL */ async importOne(importURL: string, body: Partial): Promise { - const fileCreatePermissions = this.schema.permissions.find( + const fileCreatePermissions = this.accountability?.permissions?.find( (permission) => permission.collection === 'directus_files' && permission.action === 'create' ); diff --git a/api/src/services/graphql.ts b/api/src/services/graphql.ts index b624b3496661b..8ac83ff3db9ed 100644 --- a/api/src/services/graphql.ts +++ b/api/src/services/graphql.ts @@ -186,10 +186,14 @@ export class GraphQLService { const schemaComposer = new SchemaComposer(); const schema = { - read: this.accountability?.admin === true ? this.schema : reduceSchema(this.schema, ['read']), - create: this.accountability?.admin === true ? this.schema : reduceSchema(this.schema, ['create']), - update: this.accountability?.admin === true ? this.schema : reduceSchema(this.schema, ['update']), - delete: this.accountability?.admin === true ? this.schema : reduceSchema(this.schema, ['delete']), + read: + this.accountability?.admin === true ? this.schema : reduceSchema(this.schema, this.accountability, ['read']), + create: + this.accountability?.admin === true ? this.schema : reduceSchema(this.schema, this.accountability, ['create']), + update: + this.accountability?.admin === true ? this.schema : reduceSchema(this.schema, this.accountability, ['update']), + delete: + this.accountability?.admin === true ? this.schema : reduceSchema(this.schema, this.accountability, ['delete']), }; const { ReadCollectionTypes } = getReadableTypes(); @@ -2006,10 +2010,10 @@ export class GraphQLService { throw new ForbiddenException(); } - const { cache, schemaCache } = getCache(); + const { cache, systemCache } = getCache(); await cache?.clear(); - await schemaCache?.clear(); + await systemCache.clear(); return; }, diff --git a/api/src/services/import.ts b/api/src/services/import.ts index 37c8f674cdb5c..6833265cdb108 100644 --- a/api/src/services/import.ts +++ b/api/src/services/import.ts @@ -24,11 +24,11 @@ export class ImportService { async import(collection: string, mimetype: string, stream: NodeJS.ReadableStream): Promise { if (collection.startsWith('directus_')) throw new ForbiddenException(); - const createPermissions = this.schema.permissions.find( + const createPermissions = this.accountability?.permissions?.find( (permission) => permission.collection === collection && permission.action === 'create' ); - const updatePermissions = this.schema.permissions.find( + const updatePermissions = this.accountability?.permissions?.find( (permission) => permission.collection === collection && permission.action === 'update' ); diff --git a/api/src/services/meta.ts b/api/src/services/meta.ts index a9d054ec3827c..7ef5accd992de 100644 --- a/api/src/services/meta.ts +++ b/api/src/services/meta.ts @@ -4,7 +4,6 @@ import { ForbiddenException } from '../exceptions'; import { AbstractServiceOptions, SchemaOverview } from '../types'; import { Accountability, Query } from '@directus/shared/types'; import { applyFilter, applySearch } from '../utils/apply-query'; -import { parseFilter } from '@directus/shared/utils'; export class MetaService { knex: Knex; @@ -40,13 +39,13 @@ export class MetaService { const dbQuery = this.knex(collection).count('*', { as: 'count' }).first(); if (this.accountability?.admin !== true) { - const permissionsRecord = this.schema.permissions.find((permission) => { + const permissionsRecord = this.accountability?.permissions?.find((permission) => { return permission.action === 'read' && permission.collection === collection; }); if (!permissionsRecord) throw new ForbiddenException(); - const permissions = parseFilter(permissionsRecord.permissions, this.accountability); + const permissions = permissionsRecord.permissions ?? {}; applyFilter(this.knex, this.schema, dbQuery, permissions, collection); } @@ -62,13 +61,13 @@ export class MetaService { let filter = query.filter || {}; if (this.accountability?.admin !== true) { - const permissionsRecord = this.schema.permissions.find((permission) => { + const permissionsRecord = this.accountability?.permissions?.find((permission) => { return permission.action === 'read' && permission.collection === collection; }); if (!permissionsRecord) throw new ForbiddenException(); - const permissions = parseFilter(permissionsRecord.permissions, this.accountability); + const permissions = permissionsRecord.permissions ?? {}; if (Object.keys(filter).length > 0) { filter = { _and: [permissions, filter] }; diff --git a/api/src/services/permissions.ts b/api/src/services/permissions.ts index 428ed1ea69546..2a3f3c3d7b63c 100644 --- a/api/src/services/permissions.ts +++ b/api/src/services/permissions.ts @@ -1,26 +1,35 @@ import { appAccessMinimalPermissions } from '../database/system-data/app-access-permissions'; -import { ItemsService, QueryOptions } from '../services/items'; +import { ItemsService, QueryOptions, MutationOptions } from '../services/items'; import { AbstractServiceOptions, Item, PrimaryKey } from '../types'; import { Query, PermissionsAction } from '@directus/shared/types'; import { filterItems } from '../utils/filter-items'; +import Keyv from 'keyv'; +import { getCache } from '../cache'; export class PermissionsService extends ItemsService { + systemCache: Keyv; + constructor(options: AbstractServiceOptions) { super('directus_permissions', options); + + const { systemCache } = getCache(); + + this.systemCache = systemCache; } getAllowedFields(action: PermissionsAction, collection?: string): Record { - const results = this.schema.permissions.filter((permission) => { - let matchesCollection = true; + const results = + this.accountability?.permissions?.filter((permission) => { + let matchesCollection = true; - if (collection) { - matchesCollection = permission.collection === collection; - } + if (collection) { + matchesCollection = permission.collection === collection; + } - const matchesAction = permission.action === action; + const matchesAction = permission.action === action; - return collection ? matchesCollection && matchesAction : matchesAction; - }); + return collection ? matchesCollection && matchesAction : matchesAction; + }) ?? []; const fieldsPerCollection: Record = {}; @@ -68,4 +77,64 @@ export class PermissionsService extends ItemsService { return result; } + + async createOne(data: Partial, opts?: MutationOptions) { + const res = await super.createOne(data, opts); + await this.systemCache.clear(); + return res; + } + + async createMany(data: Partial[], opts?: MutationOptions) { + const res = await super.createMany(data, opts); + await this.systemCache.clear(); + return res; + } + + async updateByQuery(query: Query, data: Partial, opts?: MutationOptions) { + const res = await super.updateByQuery(query, data, opts); + await this.systemCache.clear(); + return res; + } + + async updateOne(key: PrimaryKey, data: Partial, opts?: MutationOptions) { + const res = await super.updateOne(key, data, opts); + await this.systemCache.clear(); + return res; + } + + async updateMany(keys: PrimaryKey[], data: Partial, opts?: MutationOptions) { + const res = await super.updateMany(keys, data, opts); + await this.systemCache.clear(); + return res; + } + + async upsertOne(payload: Partial, opts?: MutationOptions) { + const res = await super.upsertOne(payload, opts); + await this.systemCache.clear(); + return res; + } + + async upsertMany(payloads: Partial[], opts?: MutationOptions) { + const res = await super.upsertMany(payloads, opts); + await this.systemCache.clear(); + return res; + } + + async deleteByQuery(query: Query, opts?: MutationOptions) { + const res = await super.deleteByQuery(query, opts); + await this.systemCache.clear(); + return res; + } + + async deleteOne(key: PrimaryKey, opts?: MutationOptions) { + const res = await super.deleteOne(key, opts); + await this.systemCache.clear(); + return res; + } + + async deleteMany(keys: PrimaryKey[], opts?: MutationOptions) { + const res = await super.deleteMany(keys, opts); + await this.systemCache.clear(); + return res; + } } diff --git a/api/src/services/relations.ts b/api/src/services/relations.ts index b9b988d7a07c2..8898d39a5067c 100644 --- a/api/src/services/relations.ts +++ b/api/src/services/relations.ts @@ -21,7 +21,7 @@ export class RelationsService { accountability: Accountability | null; schema: SchemaOverview; relationsItemService: ItemsService; - schemaCache: Keyv | null; + systemCache: Keyv; constructor(options: AbstractServiceOptions) { this.knex = options.knex || getDatabase(); @@ -37,7 +37,7 @@ export class RelationsService { // happens in `filterForbidden` down below }); - this.schemaCache = getCache().schemaCache; + this.systemCache = getCache().systemCache; } async readAll(collection?: string, opts?: QueryOptions): Promise { @@ -76,7 +76,7 @@ export class RelationsService { throw new ForbiddenException(); } - const permissions = this.schema.permissions.find((permission) => { + const permissions = this.accountability.permissions?.find((permission) => { return permission.action === 'read' && permission.collection === collection; }); @@ -195,9 +195,7 @@ export class RelationsService { await relationsItemService.createOne(metaRow); }); - if (this.schemaCache) { - await this.schemaCache.clear(); - } + await this.systemCache.clear(); } /** @@ -275,9 +273,7 @@ export class RelationsService { } }); - if (this.schemaCache) { - await this.schemaCache.clear(); - } + await this.systemCache.clear(); } /** @@ -316,16 +312,14 @@ export class RelationsService { } }); - if (this.schemaCache) { - await this.schemaCache.clear(); - } + await this.systemCache.clear(); } /** * Whether or not the current user has read access to relations */ private get hasReadAccess() { - return !!this.schema.permissions.find((permission) => { + return !!this.accountability?.permissions?.find((permission) => { return permission.collection === 'directus_relations' && permission.action === 'read'; }); } @@ -380,11 +374,12 @@ export class RelationsService { private async filterForbidden(relations: Relation[]): Promise { if (this.accountability === null || this.accountability?.admin === true) return relations; - const allowedCollections = this.schema.permissions - .filter((permission) => { - return permission.action === 'read'; - }) - .map(({ collection }) => collection); + const allowedCollections = + this.accountability.permissions + ?.filter((permission) => { + return permission.action === 'read'; + }) + .map(({ collection }) => collection) ?? []; const allowedFields = this.permissionsService.getAllowedFields('read'); diff --git a/api/src/services/specifications.ts b/api/src/services/specifications.ts index 08300156261b2..b07de69541a51 100644 --- a/api/src/services/specifications.ts +++ b/api/src/services/specifications.ts @@ -92,7 +92,7 @@ class OASSpecsService implements SpecificationSubService { const collections = await this.collectionsService.readByQuery(); const fields = await this.fieldsService.readAll(); const relations = (await this.relationsService.readAll()) as Relation[]; - const permissions = this.schema.permissions; + const permissions = this.accountability?.permissions ?? []; const tags = await this.generateTags(collections); const paths = await this.generatePaths(permissions, tags); diff --git a/api/src/services/utils.ts b/api/src/services/utils.ts index 4fbb6b4fbe50d..f17a719183569 100644 --- a/api/src/services/utils.ts +++ b/api/src/services/utils.ts @@ -28,7 +28,7 @@ export class UtilsService { } if (this.accountability?.admin !== true) { - const permissions = this.schema.permissions.find((permission) => { + const permissions = this.accountability?.permissions?.find((permission) => { return permission.collection === collection && permission.action === 'update'; }); diff --git a/api/src/types/schema.ts b/api/src/types/schema.ts index a6fa3f9d8b010..3fb67ac0e9b34 100644 --- a/api/src/types/schema.ts +++ b/api/src/types/schema.ts @@ -1,4 +1,4 @@ -import { Type, Permission } from '@directus/shared/types'; +import { Type } from '@directus/shared/types'; import { Relation } from './relation'; export type FieldOverview = { @@ -32,5 +32,4 @@ export type CollectionsOverview = { export type SchemaOverview = { collections: CollectionsOverview; relations: Relation[]; - permissions: Permission[]; }; diff --git a/api/src/utils/get-ast-from-query.ts b/api/src/utils/get-ast-from-query.ts index b9dd7a7d1b0e2..7e1084a327b18 100644 --- a/api/src/utils/get-ast-from-query.ts +++ b/api/src/utils/get-ast-from-query.ts @@ -32,9 +32,9 @@ export default async function getASTFromQuery( const permissions = accountability && accountability.admin !== true - ? schema.permissions.filter((permission) => { + ? accountability?.permissions?.filter((permission) => { return permission.action === action; - }) + }) ?? [] : null; const ast: AST = { diff --git a/api/src/utils/get-schema.ts b/api/src/utils/get-schema.ts index 852561761083f..a15191da5ad26 100644 --- a/api/src/utils/get-schema.ts +++ b/api/src/utils/get-schema.ts @@ -1,21 +1,18 @@ import SchemaInspector from '@directus/schema'; import { Knex } from 'knex'; import { mapValues } from 'lodash'; -import { appAccessMinimalPermissions } from '../database/system-data/app-access-permissions'; import { systemCollectionRows } from '../database/system-data/collections'; import { systemFieldRows } from '../database/system-data/fields'; import logger from '../logger'; import { RelationsService } from '../services'; import { SchemaOverview } from '../types'; -import { Accountability, Permission } from '@directus/shared/types'; +import { Accountability } from '@directus/shared/types'; import { toArray } from '@directus/shared/utils'; import getDefaultValue from './get-default-value'; import getLocalType from './get-local-type'; -import { mergePermissions } from './merge-permissions'; import getDatabase from '../database'; import { getCache } from '../cache'; import env from '../env'; -import ms from 'ms'; export async function getSchema(options?: { accountability?: Accountability; @@ -23,15 +20,15 @@ export async function getSchema(options?: { }): Promise { const database = options?.database || getDatabase(); const schemaInspector = SchemaInspector(database); - const { schemaCache } = getCache(); + const { systemCache } = getCache(); let result: SchemaOverview; - if (env.CACHE_SCHEMA !== false && schemaCache) { + if (env.CACHE_SCHEMA !== false) { let cachedSchema; try { - cachedSchema = (await schemaCache.get('schema')) as SchemaOverview; + cachedSchema = (await systemCache.get('schema')) as SchemaOverview; } catch (err: any) { logger.warn(err, `[schema-cache] Couldn't retrieve cache. ${err}`); } @@ -42,11 +39,7 @@ export async function getSchema(options?: { result = await getDatabaseSchema(database, schemaInspector); try { - await schemaCache.set( - 'schema', - result, - typeof env.CACHE_SCHEMA === 'string' ? ms(env.CACHE_SCHEMA) : undefined - ); + await systemCache.set('schema', result); } catch (err: any) { logger.warn(err, `[schema-cache] Couldn't save cache. ${err}`); } @@ -55,52 +48,6 @@ export async function getSchema(options?: { result = await getDatabaseSchema(database, schemaInspector); } - let permissions: Permission[] = []; - - if (options?.accountability && options.accountability.admin !== true) { - const permissionsForRole = await database - .select('*') - .from('directus_permissions') - .where({ role: options.accountability.role }); - - permissions = permissionsForRole.map((permissionRaw) => { - if (permissionRaw.permissions && typeof permissionRaw.permissions === 'string') { - permissionRaw.permissions = JSON.parse(permissionRaw.permissions); - } else if (permissionRaw.permissions === null) { - permissionRaw.permissions = {}; - } - - if (permissionRaw.validation && typeof permissionRaw.validation === 'string') { - permissionRaw.validation = JSON.parse(permissionRaw.validation); - } else if (permissionRaw.validation === null) { - permissionRaw.validation = {}; - } - - if (permissionRaw.presets && typeof permissionRaw.presets === 'string') { - permissionRaw.presets = JSON.parse(permissionRaw.presets); - } else if (permissionRaw.presets === null) { - permissionRaw.presets = {}; - } - - if (permissionRaw.fields && typeof permissionRaw.fields === 'string') { - permissionRaw.fields = permissionRaw.fields.split(','); - } else if (permissionRaw.fields === null) { - permissionRaw.fields = []; - } - - return permissionRaw; - }); - - if (options.accountability.app === true) { - permissions = mergePermissions( - permissions, - appAccessMinimalPermissions.map((perm) => ({ ...perm, role: options.accountability!.role })) - ); - } - } - - result.permissions = permissions; - return result; } @@ -111,7 +58,6 @@ async function getDatabaseSchema( const result: SchemaOverview = { collections: {}, relations: [], - permissions: [], }; const schemaOverview = await schemaInspector.overview(); diff --git a/api/src/utils/reduce-schema.ts b/api/src/utils/reduce-schema.ts index fd7a05b953282..dc46472d0f72e 100644 --- a/api/src/utils/reduce-schema.ts +++ b/api/src/utils/reduce-schema.ts @@ -1,6 +1,6 @@ import { uniq } from 'lodash'; import { SchemaOverview } from '../types'; -import { PermissionsAction } from '@directus/shared/types'; +import { Accountability, PermissionsAction } from '@directus/shared/types'; /** * Reduces the schema based on the included permissions. The resulting object is the schema structure, but with only @@ -11,31 +11,32 @@ import { PermissionsAction } from '@directus/shared/types'; */ export function reduceSchema( schema: SchemaOverview, + accountability: Accountability | null, actions: PermissionsAction[] = ['create', 'read', 'update', 'delete'] ): SchemaOverview { const reduced: SchemaOverview = { collections: {}, relations: [], - permissions: schema.permissions, }; - const allowedFieldsInCollection = schema.permissions - .filter((permission) => actions.includes(permission.action)) - .reduce((acc, permission) => { - if (!acc[permission.collection]) { - acc[permission.collection] = []; - } + const allowedFieldsInCollection = + accountability?.permissions + ?.filter((permission) => actions.includes(permission.action)) + .reduce((acc, permission) => { + if (!acc[permission.collection]) { + acc[permission.collection] = []; + } - if (permission.fields) { - acc[permission.collection] = uniq([...acc[permission.collection], ...permission.fields]); - } + if (permission.fields) { + acc[permission.collection] = uniq([...acc[permission.collection], ...permission.fields]); + } - return acc; - }, {} as { [collection: string]: string[] }); + return acc; + }, {} as { [collection: string]: string[] }) ?? {}; for (const [collectionName, collection] of Object.entries(schema.collections)) { if ( - schema.permissions.some( + accountability?.permissions?.some( (permission) => permission.collection === collectionName && actions.includes(permission.action) ) ) { diff --git a/app/src/displays/labels/labels.vue b/app/src/displays/labels/labels.vue index 236a0d2325176..59690091ef01d 100644 --- a/app/src/displays/labels/labels.vue +++ b/app/src/displays/labels/labels.vue @@ -16,13 +16,7 @@ diff --git a/app/src/interfaces/_system/system-filter/input-component.vue b/app/src/interfaces/_system/system-filter/input-component.vue index 1697842a5682f..d1b6e3fbd2762 100644 --- a/app/src/interfaces/_system/system-filter/input-component.vue +++ b/app/src/interfaces/_system/system-filter/input-component.vue @@ -15,16 +15,16 @@ :value="value" :style="{ width }" placeholder="--" - @input="emitValueDebounced($event.target.value)" + @input="emitValue($event.target.value)" />
- +
diff --git a/app/src/utils/translate-literal.ts b/app/src/utils/translate-literal.ts index fa7ba1e6ae376..30279d856c26e 100644 --- a/app/src/utils/translate-literal.ts +++ b/app/src/utils/translate-literal.ts @@ -1,9 +1,9 @@ import { i18n } from '@/lang'; -export function translate(literal: string): string { +export function translate(literal: any): string { let translated = literal; - if (literal.startsWith('$t:')) translated = i18n.global.t(literal.replace('$t:', '')); + if (typeof literal === 'string' && literal.startsWith('$t:')) translated = i18n.global.t(literal.replace('$t:', '')); return translated; } diff --git a/app/src/views/private/components/search-input/search-input.vue b/app/src/views/private/components/search-input/search-input.vue index 245fa974e0c3d..d5c837c509b96 100644 --- a/app/src/views/private/components/search-input/search-input.vue +++ b/app/src/views/private/components/search-input/search-input.vue @@ -10,13 +10,7 @@ @click="active = true" > - + emitValue(), 250); - return { t, active, disable, input, - emitValueDebounced, + emitValue, activeFilterCount, filterActive, onClickOutside, diff --git a/docs/configuration/config-options.md b/docs/configuration/config-options.md index 322a34be434e7..2b6f5163204f6 100644 --- a/docs/configuration/config-options.md +++ b/docs/configuration/config-options.md @@ -371,15 +371,15 @@ often possible to cache assets for far longer than you would cache database cont ::: -| Variable | Description | Default Value | -| -------------------------------- | -------------------------------------------------------------------------------------------- | ---------------- | -| `CACHE_ENABLED` | Whether or not caching is enabled. | `false` | -| `CACHE_TTL`[1] | How long the cache is persisted. | `30m` | -| `CACHE_CONTROL_S_MAXAGE` | Whether to not to add the `s-maxage` expiration flag. Set to a number for a custom value | `0` | -| `CACHE_AUTO_PURGE`[2] | Automatically purge the cache on `create`, `update`, and `delete` actions. | `false` | -| `CACHE_SCHEMA`[3] | Whether or not the database schema is cached. One of `false`, `true`, or a string time value | `true` | -| `CACHE_NAMESPACE` | How to scope the cache data. | `directus-cache` | -| `CACHE_STORE`[4] | Where to store the cache data. Either `memory`, `redis`, or `memcache`. | `memory` | +| Variable | Description | Default Value | +| -------------------------------- | ---------------------------------------------------------------------------------------- | ---------------- | +| `CACHE_ENABLED` | Whether or not caching is enabled. | `false` | +| `CACHE_TTL`[1] | How long the cache is persisted. | `30m` | +| `CACHE_CONTROL_S_MAXAGE` | Whether to not to add the `s-maxage` expiration flag. Set to a number for a custom value | `0` | +| `CACHE_AUTO_PURGE`[2] | Automatically purge the cache on `create`, `update`, and `delete` actions. | `false` | +| `CACHE_SCHEMA`[3] | Whether or not the database schema is cached. One of `false`, `true` | `true` | +| `CACHE_NAMESPACE` | How to scope the cache data. | `directus-cache` | +| `CACHE_STORE`[4] | Where to store the cache data. Either `memory`, `redis`, or `memcache`. | `memory` | [1] `CACHE_TTL` Based on your project's needs, you might be able to aggressively cache your data, only requiring new data to be fetched every hour or so. This allows you to squeeze the most performance out of your Directus diff --git a/docs/configuration/filter-rules.md b/docs/configuration/filter-rules.md index c5801ccef395b..773cc0c50ae04 100644 --- a/docs/configuration/filter-rules.md +++ b/docs/configuration/filter-rules.md @@ -148,3 +148,14 @@ In addition to static values, you can also filter against _dynamic_ values using - `$NOW` — The current timestamp - `$NOW()` - The current timestamp plus/minus a given distance, for example `$NOW(-1 year)`, `$NOW(+2 hours)` + +::: tip Nested User / Role variables in Permissions + +When configuring permissions, `$CURRENT_USER` and `$CURRENT_ROLE` allow you to specify any (nested) field under the +current user/role as well as the root ID. For example: `$CURRENT_ROLE.name` or `$CURRENT_USER.avatar.filesize`. This +includes custom fields that were added to the directus_users/directus_roles tables. + +Note: This feature is only available for permissions, validation, and presets. Regular filters and conditional fields +currently only support the root ID. + +::: diff --git a/packages/shared/src/composables/use-items.ts b/packages/shared/src/composables/use-items.ts index 9ea8062f4e7e8..006e0af73cc8e 100644 --- a/packages/shared/src/composables/use-items.ts +++ b/packages/shared/src/composables/use-items.ts @@ -61,7 +61,7 @@ export function useItems(collection: Ref, query: ComputedQuery, f let currentRequest: CancelTokenSource | null = null; let loadingTimeout: NodeJS.Timeout | null = null; - const fetchItems = throttle(getItems, 350); + const fetchItems = throttle(getItems, 500); if (fetchOnInit) { fetchItems(); diff --git a/packages/shared/src/types/accountability.ts b/packages/shared/src/types/accountability.ts index 19e1dd3f4dc59..915ccb3dc7ae7 100644 --- a/packages/shared/src/types/accountability.ts +++ b/packages/shared/src/types/accountability.ts @@ -1,8 +1,11 @@ +import { Permission } from '.'; + export type Accountability = { role: string | null; user?: string | null; admin?: boolean; app?: boolean; + permissions?: Permission[]; ip?: string; userAgent?: string; diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 05c174c885c9c..4741ac430a2cd 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -6,6 +6,7 @@ export * from './get-collection-type'; export * from './get-fields-from-template'; export * from './get-filter-operators-for-type'; export * from './get-relation-type'; +export * from './is-dynamic-variable'; export * from './is-extension'; export * from './merge-filters'; export * from './move-in-array'; diff --git a/packages/shared/src/utils/is-dynamic-variable.test.ts b/packages/shared/src/utils/is-dynamic-variable.test.ts new file mode 100644 index 0000000000000..6daf34ed075f6 --- /dev/null +++ b/packages/shared/src/utils/is-dynamic-variable.test.ts @@ -0,0 +1,20 @@ +import { isDynamicVariable } from './is-dynamic-variable'; + +const tests: [string, boolean][] = [ + ['$NOW', true], + ['$NOW(- 1 year)', true], + ['test', false], + ['$CUSTOM', false], + ['$CURRENT_USER', true], + ['$CURRENT_ROLE', true], + ['$CURRENT_USER.role.name', true], + ['$CURRENT_ROLE.users.id', true], +]; + +describe('is extension type', () => { + for (const [value, result] of tests) { + it(value, () => { + expect(isDynamicVariable(value)).toBe(result); + }); + } +}); diff --git a/packages/shared/src/utils/is-dynamic-variable.ts b/packages/shared/src/utils/is-dynamic-variable.ts new file mode 100644 index 0000000000000..ddc321168aab1 --- /dev/null +++ b/packages/shared/src/utils/is-dynamic-variable.ts @@ -0,0 +1,5 @@ +const dynamicVariablePrefixes = ['$NOW', '$CURRENT_USER', '$CURRENT_ROLE']; + +export function isDynamicVariable(value: any) { + return typeof value === 'string' && dynamicVariablePrefixes.some((prefix) => value.startsWith(prefix)); +} diff --git a/packages/shared/src/utils/parse-filter.ts b/packages/shared/src/utils/parse-filter.ts index 5d8b4f88c6054..c06706e65ffb9 100644 --- a/packages/shared/src/utils/parse-filter.ts +++ b/packages/shared/src/utils/parse-filter.ts @@ -1,10 +1,21 @@ import { REGEX_BETWEEN_PARENS } from '../constants'; -import { Accountability, Filter } from '../types'; +import { Accountability, Filter, User, Role } from '../types'; import { toArray } from './to-array'; import { adjustDate } from './adjust-date'; import { deepMap } from './deep-map'; +import { isDynamicVariable } from './is-dynamic-variable'; -export function parseFilter(filter: Filter | null, accountability: Accountability | null): any { +type ParseFilterContext = { + // The user can add any custom fields to user + $CURRENT_USER?: User & { [field: string]: any }; + $CURRENT_ROLE?: Role & { [field: string]: any }; +}; + +export function parseFilter( + filter: Filter | null, + accountability: Accountability | null, + context: ParseFilterContext = {} +): any { if (!filter) return filter; return deepMap(filter, applyFilter); @@ -19,19 +30,51 @@ export function parseFilter(filter: Filter | null, accountability: Accountabilit else return deepMap(toArray(val), applyFilter); } - if (val && typeof val === 'string' && val.startsWith('$NOW')) { - if (val.includes('(') && val.includes(')')) { - const adjustment = val.match(REGEX_BETWEEN_PARENS)?.[1]; - if (!adjustment) return new Date(); - return adjustDate(new Date(), adjustment); + if (isDynamicVariable(val)) { + if (val.startsWith('$NOW')) { + if (val.includes('(') && val.includes(')')) { + const adjustment = val.match(REGEX_BETWEEN_PARENS)?.[1]; + if (!adjustment) return new Date(); + return adjustDate(new Date(), adjustment); + } + + return new Date(); } - return new Date(); - } + if (val.startsWith('$CURRENT_USER')) { + if (val === '$CURRENT_USER') return accountability?.user ?? null; + return get(context, val, null); + } - if (val === '$CURRENT_USER') return accountability?.user || null; - if (val === '$CURRENT_ROLE') return accountability?.role || null; + if (val.startsWith('$CURRENT_ROLE')) { + if (val === '$CURRENT_ROLE') return accountability?.role ?? null; + return get(context, val, null); + } + } return val; } } + +function get(obj: Record | any[], path: string, defaultValue: any) { + const pathParts = path.split('.'); + let val = obj; + + while (pathParts.length) { + const key = pathParts.shift(); + + if (key) { + val = processLevel(val, key); + } + } + + return val || defaultValue; + + function processLevel(value: Record | any[], key: string) { + if (Array.isArray(value)) { + return value.map((subVal) => subVal[key]); + } else if (value && typeof value === 'object') { + return value[key]; + } + } +}