Skip to content

Commit

Permalink
Allow dynamic user variables to be used with filter rules (cont.) (#9376
Browse files Browse the repository at this point in the history
)

* Move permissions extraction to accountability

* Fix permissions retrieval for public user

* Fetch user / role context in permissions middleware

* Remove unnecessary parseFilter

* Rename schemaCache to systemCache

* Add permissions caching

* Add system cache invalidation on permission changes

* Improve caching perf by reducing scope

* Add note to docs

* Clarify compatibility with conditional fields/filters

* Fix lint warning

* Allow nested vars in system-filter-input

* Add custom getter function that resolves arrays

* Add is-dynamic-variable util

* Export new util

* Cleanup parse filter

* Fix build

* Move debounce up to use-items

* Remove unused prop

* 🧹

* Fix input pattern usage w/ vars

* Remove debounce from search-input, increase throttle
  • Loading branch information
rijkvanzanten committed Nov 3, 2021
1 parent 72a8869 commit 65291b9
Show file tree
Hide file tree
Showing 37 changed files with 512 additions and 316 deletions.
11 changes: 7 additions & 4 deletions api/src/app.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -153,14 +154,16 @@ export default async function createApp(): Promise<express.Application> {

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);
Expand Down
16 changes: 8 additions & 8 deletions api/src/cache.ts
Expand Up @@ -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<void> {
const { schemaCache, cache } = getCache();
await schemaCache?.clear();
const { systemCache, cache } = getCache();
await systemCache?.clear();
await cache?.clear();
}

Expand Down
2 changes: 0 additions & 2 deletions api/src/database/system-data/fields/activity.yaml
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion api/src/database/system-data/fields/files.yaml
Expand Up @@ -27,7 +27,6 @@ fields:
width: full
display: labels
display_options:
defaultBackground: '#ECEFF1'
choices: null
format: false

Expand Down
1 change: 0 additions & 1 deletion api/src/database/system-data/fields/users.yaml
Expand Up @@ -56,7 +56,6 @@ fields:
iconRight: local_offer
display: labels
display_options:
defaultBackground: '#ECEFF1'
choices: null
format: false

Expand Down
8 changes: 0 additions & 8 deletions api/src/database/system-data/fields/webhooks.yaml
Expand Up @@ -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)'
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -139,7 +133,5 @@ fields:
width: full
display: labels
display_options:
defaultForeground: 'var(--foreground-normal)'
defaultBackground: 'var(--background-normal-alt)'
choices: null
format: false
1 change: 1 addition & 0 deletions api/src/env.ts
Expand Up @@ -53,6 +53,7 @@ const defaults: Record<string, any> = {
CACHE_AUTO_PURGE: false,
CACHE_CONTROL_S_MAXAGE: '0',
CACHE_SCHEMA: true,
CACHE_PERMISSIONS: true,

AUTH_PROVIDERS: '',

Expand Down
96 changes: 48 additions & 48 deletions api/src/middleware/authenticate.ts
Expand Up @@ -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();
Expand Down
140 changes: 140 additions & 0 deletions 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<string, any> = {};

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;

0 comments on commit 65291b9

Please sign in to comment.