mirror of
https://github.com/directus/directus.git
synced 2026-04-03 03:00:39 -04:00
System permissions for app access (#4004)
* Pass relations through schema, instead of individual reads * Fetch field transforms upfront * Fix length check * List if user has app access or not in accountability * Load permissions up front, merge app access minimal permissions * Show app access required permissions in permissions overview * Show system minimal permissions in permissions detail * Fix app access check in authenticate for jwt use * Fix minimal permissions for presets * Remove /permissions/me in favor of root use w/ permissions * Fix logical nested OR in an AND * Use root permissions endpoint with filter instead of /me * Allow filter query on /permissions * Add system minimal app access permissions into result of /permissions * Remove stray console log * Remove stray console.dir * Set current role as role for minimal permissions * Fix no-permissions state for user detail * Add filter items function that allows altering existing result set
This commit is contained in:
@@ -2,6 +2,7 @@ import env from '../../../env';
|
||||
import logger from '../../../logger';
|
||||
import installDatabase from '../../../database/seeds/run';
|
||||
import runMigrations from '../../../database/migrations/run';
|
||||
import { getSchema } from '../../../utils/get-schema';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export default async function bootstrap() {
|
||||
@@ -22,7 +23,7 @@ export default async function bootstrap() {
|
||||
|
||||
await installDatabase(database);
|
||||
|
||||
const schema = await schemaInspector.overview();
|
||||
const schema = await getSchema();
|
||||
|
||||
logger.info('Setting up first admin role...');
|
||||
const rolesService = new RolesService({ schema });
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { getSchema } from '../../../utils/get-schema';
|
||||
|
||||
export default async function rolesCreate({ name, admin }: any) {
|
||||
const { default: database, schemaInspector } = require('../../../database/index');
|
||||
const { default: database } = require('../../../database/index');
|
||||
const { RolesService } = require('../../../services/roles');
|
||||
|
||||
if (!name) {
|
||||
@@ -8,7 +10,7 @@ export default async function rolesCreate({ name, admin }: any) {
|
||||
}
|
||||
|
||||
try {
|
||||
const schema = await schemaInspector.overview();
|
||||
const schema = await getSchema();
|
||||
const service = new RolesService({ schema: schema, knex: database });
|
||||
|
||||
const id = await service.create({ name, admin_access: admin });
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getSchema } from '../../../utils/get-schema';
|
||||
|
||||
export default async function usersCreate({ email, password, role }: any) {
|
||||
const { default: database, schemaInspector } = require('../../../database/index');
|
||||
const { UsersService } = require('../../../services/users');
|
||||
@@ -8,7 +10,7 @@ export default async function usersCreate({ email, password, role }: any) {
|
||||
}
|
||||
|
||||
try {
|
||||
const schema = await schemaInspector.overview();
|
||||
const schema = await getSchema();
|
||||
const service = new UsersService({ schema, knex: database });
|
||||
|
||||
const id = await service.create({ email, password, role, status: 'active' });
|
||||
|
||||
@@ -52,7 +52,7 @@ router.get(
|
||||
schema: req.schema,
|
||||
});
|
||||
|
||||
if (req.params.field in req.schema[req.params.collection].columns === false) throw new ForbiddenException();
|
||||
if (req.params.field in req.schema.tables[req.params.collection].columns === false) throw new ForbiddenException();
|
||||
|
||||
const field = await service.readOne(req.params.collection, req.params.field);
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import express from 'express';
|
||||
import asyncHandler from '../utils/async-handler';
|
||||
import { PermissionsService, MetaService } from '../services';
|
||||
import { clone } from 'lodash';
|
||||
import { InvalidCredentialsException, ForbiddenException, InvalidPayloadException } from '../exceptions';
|
||||
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import { respond } from '../middleware/respond';
|
||||
import { PrimaryKey } from '../types';
|
||||
@@ -42,6 +41,7 @@ router.get(
|
||||
accountability: req.accountability,
|
||||
schema: req.schema,
|
||||
});
|
||||
|
||||
const metaService = new MetaService({
|
||||
accountability: req.accountability,
|
||||
schema: req.schema,
|
||||
@@ -56,36 +56,6 @@ router.get(
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/me',
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new PermissionsService({ schema: req.schema });
|
||||
const query = clone(req.sanitizedQuery || {});
|
||||
|
||||
if (req.accountability?.role) {
|
||||
query.filter = {
|
||||
...(query.filter || {}),
|
||||
role: {
|
||||
_eq: req.accountability.role,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
query.filter = {
|
||||
...(query.filter || {}),
|
||||
role: {
|
||||
_null: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const items = await service.readByQuery(query);
|
||||
|
||||
res.locals.payload = { data: items || null };
|
||||
return next();
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:pk',
|
||||
asyncHandler(async (req, res, next) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import knex, { Config } from 'knex';
|
||||
import dotenv from 'dotenv';
|
||||
import camelCase from 'camelcase';
|
||||
import path from 'path';
|
||||
import logger from '../logger';
|
||||
import env from '../env';
|
||||
|
||||
@@ -93,8 +93,8 @@ async function parseCurrentLevel(
|
||||
children: (NestedCollectionNode | FieldNode)[],
|
||||
schema: SchemaOverview
|
||||
) {
|
||||
const primaryKeyField = schema[collection].primary;
|
||||
const columnsInCollection = Object.keys(schema[collection].columns);
|
||||
const primaryKeyField = schema.tables[collection].primary;
|
||||
const columnsInCollection = Object.keys(schema.tables[collection].columns);
|
||||
|
||||
const columnsToSelect: string[] = [];
|
||||
const nestedCollectionNodes: NestedCollectionNode[] = [];
|
||||
@@ -154,7 +154,7 @@ async function getDBQuery(
|
||||
|
||||
query.sort = query.sort || [{ column: primaryKeyField, order: 'asc' }];
|
||||
|
||||
await applyQuery(knex, table, dbQuery, queryCopy, schema);
|
||||
await applyQuery(table, dbQuery, queryCopy, schema);
|
||||
|
||||
// Nested filters use joins to filter on the parent level, to prevent duplicate
|
||||
// parents, we group the query by the current tables primary key (which is unique)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# NOTE: Activity/collections/fields/presets/relations/revisions will have an extra hardcoded filter
|
||||
# to filter out collections you don't have read access
|
||||
|
||||
- collection: directus_activity
|
||||
action: read
|
||||
permissions:
|
||||
user:
|
||||
_eq: $CURRENT_USER
|
||||
|
||||
- collection: directus_activity
|
||||
action: create
|
||||
validation:
|
||||
comment:
|
||||
_nnull: true
|
||||
|
||||
- collection: directus_collections
|
||||
action: read
|
||||
|
||||
- collection: directus_fields
|
||||
action: read
|
||||
|
||||
- collection: directus_permissions
|
||||
action: read
|
||||
permissions:
|
||||
role:
|
||||
_eq: $CURRENT_ROLE
|
||||
|
||||
- collection: directus_presets
|
||||
action: read
|
||||
permissions:
|
||||
_or:
|
||||
- user:
|
||||
_eq: $CURRENT_USER
|
||||
- _and:
|
||||
- user:
|
||||
_null: true
|
||||
- role:
|
||||
_eq: $CURRENT_ROLE
|
||||
- _and:
|
||||
- user:
|
||||
_null: true
|
||||
- role:
|
||||
_null: true
|
||||
|
||||
- collection: directus_presets
|
||||
action: create
|
||||
validation:
|
||||
- user:
|
||||
_eq: $CURRENT_USER
|
||||
|
||||
- collection: directus_presets
|
||||
action: update
|
||||
permissions:
|
||||
user:
|
||||
_eq: $CURRENT_USER
|
||||
|
||||
- collection: directus_presets
|
||||
action: delete
|
||||
permissions:
|
||||
user:
|
||||
_eq: $CURRENT_USER
|
||||
|
||||
- collection: directus_relations
|
||||
action: read
|
||||
|
||||
- collection: directus_roles
|
||||
action: read
|
||||
permissions:
|
||||
id:
|
||||
_eq: $CURRENT_ROLE
|
||||
|
||||
- collection: directus_settings
|
||||
action: read
|
||||
|
||||
- collection: directus_users
|
||||
action: read
|
||||
permissions:
|
||||
id:
|
||||
_eq: $CURRENT_USER
|
||||
fields:
|
||||
- id
|
||||
- first_name
|
||||
- last_name
|
||||
- email
|
||||
- password
|
||||
- location
|
||||
- title
|
||||
- description
|
||||
- tags
|
||||
- preferences_divider
|
||||
- avatar
|
||||
- language
|
||||
- theme
|
||||
- tfa_secret
|
||||
- status
|
||||
- role
|
||||
17
api/src/database/system-data/app-access-permissions/index.ts
Normal file
17
api/src/database/system-data/app-access-permissions/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { requireYAML } from '../../../utils/require-yaml';
|
||||
import { Permission } from '../../../types';
|
||||
import { merge } from 'lodash';
|
||||
|
||||
const defaults: Partial<Permission> = {
|
||||
role: null,
|
||||
permissions: {},
|
||||
validation: null,
|
||||
presets: null,
|
||||
fields: ['*'],
|
||||
limit: null,
|
||||
system: true,
|
||||
};
|
||||
|
||||
const permissions = requireYAML(require.resolve('./app-access-permissions.yaml')) as Permission[];
|
||||
|
||||
export const appAccessMinimalPermissions: Permission[] = permissions.map((row) => merge({}, defaults, row));
|
||||
@@ -14,6 +14,7 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
|
||||
user: null,
|
||||
role: null,
|
||||
admin: false,
|
||||
app: false,
|
||||
ip: req.ip.startsWith('::ffff:') ? req.ip.substring(7) : req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
};
|
||||
@@ -36,7 +37,7 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
|
||||
}
|
||||
|
||||
const user = await database
|
||||
.select('role', 'directus_roles.admin_access')
|
||||
.select('role', 'directus_roles.admin_access', 'directus_roles.app_access')
|
||||
.from('directus_users')
|
||||
.leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id')
|
||||
.where({
|
||||
@@ -52,10 +53,11 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
|
||||
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')
|
||||
.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({
|
||||
@@ -71,6 +73,7 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
|
||||
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;
|
||||
}
|
||||
|
||||
if (req.accountability?.user) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { systemCollectionRows } from '../database/system-data/collections';
|
||||
const collectionExists: RequestHandler = asyncHandler(async (req, res, next) => {
|
||||
if (!req.params.collection) return next();
|
||||
|
||||
if (req.params.collection in req.schema === false) {
|
||||
if (req.params.collection in req.schema.tables === false) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import asyncHandler from '../utils/async-handler';
|
||||
import { schemaInspector } from '../database';
|
||||
import logger from '../logger';
|
||||
|
||||
const getSchema: RequestHandler = asyncHandler(async (req, res, next) => {
|
||||
const schemaOverview = await schemaInspector.overview();
|
||||
|
||||
for (const [collection, info] of Object.entries(schemaOverview)) {
|
||||
if (!info.primary) {
|
||||
logger.warn(`Collection "${collection}" doesn't have a primary key column and will be ignored`);
|
||||
delete schemaOverview[collection];
|
||||
}
|
||||
}
|
||||
|
||||
req.schema = schemaOverview;
|
||||
import { getSchema } from '../utils/get-schema';
|
||||
|
||||
const schema: RequestHandler = asyncHandler(async (req, res, next) => {
|
||||
req.schema = await getSchema(req.accountability);
|
||||
return next();
|
||||
});
|
||||
|
||||
export default getSchema;
|
||||
export default schema;
|
||||
|
||||
@@ -41,19 +41,12 @@ export class AuthorizationService {
|
||||
async processAST(ast: AST, action: PermissionsAction = 'read'): Promise<AST> {
|
||||
const collectionsRequested = getCollectionsFromAST(ast);
|
||||
|
||||
let permissionsForCollections = await this.knex
|
||||
.select<Permission[]>('*')
|
||||
.from('directus_permissions')
|
||||
.where({ action, role: this.accountability?.role })
|
||||
.whereIn(
|
||||
'collection',
|
||||
collectionsRequested.map(({ collection }) => collection)
|
||||
let permissionsForCollections = this.schema.permissions.filter((permission) => {
|
||||
return (
|
||||
permission.action === action &&
|
||||
collectionsRequested.map(({ collection }) => collection).includes(permission.collection)
|
||||
);
|
||||
|
||||
permissionsForCollections = (await this.payloadService.processValues(
|
||||
'read',
|
||||
permissionsForCollections
|
||||
)) as Permission[];
|
||||
});
|
||||
|
||||
// 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;
|
||||
@@ -203,17 +196,13 @@ export class AuthorizationService {
|
||||
presets: {},
|
||||
};
|
||||
} else {
|
||||
permission = await this.knex
|
||||
.select('*')
|
||||
.from('directus_permissions')
|
||||
.where({ action, collection, role: this.accountability?.role || null })
|
||||
.first();
|
||||
permission = this.schema.permissions.find((permission) => {
|
||||
return permission.action === action;
|
||||
});
|
||||
|
||||
if (!permission) throw new ForbiddenException();
|
||||
|
||||
permission = (await this.payloadService.processValues('read', permission as Item)) as Permission;
|
||||
|
||||
// Check if you have permission to access the fields you're trying to acces
|
||||
// Check if you have permission to access the fields you're trying to access
|
||||
|
||||
const allowedFields = permission.fields || [];
|
||||
|
||||
@@ -235,22 +224,18 @@ export class AuthorizationService {
|
||||
|
||||
payloads = payloads.map((payload) => merge({}, preset, payload));
|
||||
|
||||
const columns = Object.values(this.schema[collection].columns);
|
||||
const columns = Object.values(this.schema.tables[collection].columns);
|
||||
|
||||
let requiredColumns: string[] = [];
|
||||
|
||||
for (const column of columns) {
|
||||
const field =
|
||||
(await this.knex
|
||||
.select<{ special: string }>('special')
|
||||
.from('directus_fields')
|
||||
.where({ collection, field: column.column_name })
|
||||
.first()) ||
|
||||
this.schema.fields.find((field) => field.collection === collection && field.field === column.column_name) ||
|
||||
systemFieldRows.find(
|
||||
(fieldMeta) => fieldMeta.field === column.column_name && fieldMeta.collection === collection
|
||||
);
|
||||
|
||||
const specials = field?.special ? toArray(field.special) : [];
|
||||
const specials = field?.special ?? [];
|
||||
|
||||
const hasGenerateSpecial = ['uuid', 'date-created', 'role-created', 'user-created'].some((name) =>
|
||||
specials.includes(name)
|
||||
|
||||
@@ -71,7 +71,7 @@ export class CollectionsService {
|
||||
throw new InvalidPayloadException(`Collections can't start with "directus_"`);
|
||||
}
|
||||
|
||||
if (payload.collection in this.schema) {
|
||||
if (payload.collection in this.schema.tables) {
|
||||
throw new InvalidPayloadException(`Collection "${payload.collection}" already exists.`);
|
||||
}
|
||||
|
||||
@@ -113,12 +113,9 @@ export class CollectionsService {
|
||||
const collectionKeys = toArray(collection);
|
||||
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
const permissions = await this.knex
|
||||
.select('collection')
|
||||
.from('directus_permissions')
|
||||
.where({ action: 'read' })
|
||||
.where({ role: this.accountability.role })
|
||||
.whereIn('collection', collectionKeys);
|
||||
const permissions = this.schema.permissions.filter((permission) => {
|
||||
return permission.action === 'read' && collectionKeys.includes(permission.collection);
|
||||
});
|
||||
|
||||
if (collectionKeys.length !== permissions.length) {
|
||||
const collectionsYouHavePermissionToRead = permissions.map(({ collection }) => collection);
|
||||
@@ -163,12 +160,11 @@ export class CollectionsService {
|
||||
let tablesInDatabase = await schemaInspector.tableInfo();
|
||||
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
const collectionsYouHavePermissionToRead: string[] = (
|
||||
await this.knex.select('collection').from('directus_permissions').where({
|
||||
role: this.accountability.role,
|
||||
action: 'read',
|
||||
const collectionsYouHavePermissionToRead: string[] = this.schema.permissions
|
||||
.filter((permission) => {
|
||||
return permission.action === 'read';
|
||||
})
|
||||
).map(({ collection }) => collection);
|
||||
.map(({ collection }) => collection);
|
||||
|
||||
tablesInDatabase = tablesInDatabase.filter((table) => {
|
||||
return collectionsYouHavePermissionToRead.includes(table.name);
|
||||
@@ -272,7 +268,7 @@ export class CollectionsService {
|
||||
schema: this.schema,
|
||||
});
|
||||
|
||||
const tablesInDatabase = Object.keys(this.schema);
|
||||
const tablesInDatabase = Object.keys(this.schema.tables);
|
||||
|
||||
const collectionKeys = toArray(collection);
|
||||
|
||||
@@ -290,11 +286,9 @@ export class CollectionsService {
|
||||
await this.knex('directus_activity').delete().whereIn('collection', collectionKeys);
|
||||
await this.knex('directus_permissions').delete().whereIn('collection', collectionKeys);
|
||||
|
||||
const relations = await this.knex
|
||||
.select<Relation[]>('*')
|
||||
.from('directus_relations')
|
||||
.where({ many_collection: collection })
|
||||
.orWhere({ one_collection: collection });
|
||||
const relations = this.schema.relations.filter((relation) => {
|
||||
return relation.many_collection === collection || relation.one_collection === collection;
|
||||
});
|
||||
|
||||
for (const relation of relations) {
|
||||
const isM2O = relation.many_collection === collection;
|
||||
|
||||
@@ -120,14 +120,14 @@ 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 = await this.knex
|
||||
.select('collection', 'fields')
|
||||
.from('directus_permissions')
|
||||
.where({ role: this.accountability.role, action: 'read' });
|
||||
const permissions = this.schema.permissions.filter((permission) => {
|
||||
return permission.action === 'read';
|
||||
});
|
||||
|
||||
const allowedFieldsInCollection: Record<string, string[]> = {};
|
||||
|
||||
permissions.forEach((permission) => {
|
||||
allowedFieldsInCollection[permission.collection] = (permission.fields || '').split(',');
|
||||
allowedFieldsInCollection[permission.collection] = permission.fields ?? [];
|
||||
});
|
||||
|
||||
if (collection && allowedFieldsInCollection.hasOwnProperty(collection) === false) {
|
||||
@@ -147,19 +147,13 @@ export class FieldsService {
|
||||
|
||||
async readOne(collection: string, field: string) {
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
const permissions = await this.knex
|
||||
.select('fields')
|
||||
.from('directus_permissions')
|
||||
.where({
|
||||
role: this.accountability.role,
|
||||
collection,
|
||||
action: 'read',
|
||||
})
|
||||
.first();
|
||||
const permissions = this.schema.permissions.find((permission) => {
|
||||
return permission.action === 'read' && permission.collection === collection;
|
||||
});
|
||||
|
||||
if (!permissions) throw new ForbiddenException();
|
||||
if (permissions.fields !== '*') {
|
||||
const allowedFields = (permissions.fields || '').split(',');
|
||||
if (!permissions || !permissions.fields) throw new ForbiddenException();
|
||||
if (permissions.fields.includes('*') === false) {
|
||||
const allowedFields = permissions.fields;
|
||||
if (allowedFields.includes(field) === false) throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
@@ -201,10 +195,10 @@ export class FieldsService {
|
||||
}
|
||||
|
||||
// Check if field already exists, either as a column, or as a row in directus_fields
|
||||
if (field.field in this.schema[collection].columns) {
|
||||
if (field.field in this.schema.tables[collection].columns) {
|
||||
throw new InvalidPayloadException(`Field "${field.field}" already exists in collection "${collection}"`);
|
||||
} else if (
|
||||
!!(await this.knex.select('id').from('directus_fields').where({ collection, field: field.field }).first())
|
||||
!!this.schema.fields.find((fieldMeta) => fieldMeta.collection === collection && fieldMeta.field === field.field)
|
||||
) {
|
||||
throw new InvalidPayloadException(`Field "${field.field}" already exists in collection "${collection}"`);
|
||||
}
|
||||
@@ -245,11 +239,9 @@ export class FieldsService {
|
||||
}
|
||||
|
||||
if (field.meta) {
|
||||
const record = await database
|
||||
.select<{ id: number }>('id')
|
||||
.from('directus_fields')
|
||||
.where({ collection, field: field.field })
|
||||
.first();
|
||||
const record = this.schema.fields.find(
|
||||
(fieldMeta) => fieldMeta.field === field.field && fieldMeta.collection === collection
|
||||
);
|
||||
|
||||
if (record) {
|
||||
await this.itemsService.update(
|
||||
@@ -284,17 +276,18 @@ export class FieldsService {
|
||||
|
||||
await this.knex('directus_fields').delete().where({ collection, field });
|
||||
|
||||
if (field in this.schema[collection].columns) {
|
||||
if (field in this.schema.tables[collection].columns) {
|
||||
await this.knex.schema.table(collection, (table) => {
|
||||
table.dropColumn(field);
|
||||
});
|
||||
}
|
||||
|
||||
const relations = await this.knex
|
||||
.select<Relation[]>('*')
|
||||
.from('directus_relations')
|
||||
.where({ many_collection: collection, many_field: field })
|
||||
.orWhere({ one_collection: collection, one_field: field });
|
||||
const relations = this.schema.relations.filter((relation) => {
|
||||
return (
|
||||
(relation.many_collection === collection && relation.many_field === field) ||
|
||||
(relation.one_collection === collection && relation.one_field === field)
|
||||
);
|
||||
});
|
||||
|
||||
for (const relation of relations) {
|
||||
const isM2O = relation.many_collection === collection && relation.many_field === field;
|
||||
|
||||
@@ -46,8 +46,8 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
|
||||
async create(data: Partial<Item>[]): Promise<PrimaryKey[]>;
|
||||
async create(data: Partial<Item>): Promise<PrimaryKey>;
|
||||
async create(data: Partial<Item> | Partial<Item>[]): Promise<PrimaryKey | PrimaryKey[]> {
|
||||
const primaryKeyField = this.schema[this.collection].primary;
|
||||
const columns = Object.keys(this.schema[this.collection].columns);
|
||||
const primaryKeyField = this.schema.tables[this.collection].primary;
|
||||
const columns = Object.keys(this.schema.tables[this.collection].columns);
|
||||
|
||||
let payloads: AnyItem[] = clone(toArray(data));
|
||||
|
||||
@@ -210,7 +210,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
|
||||
action: PermissionsAction = 'read'
|
||||
): Promise<null | Partial<Item> | Partial<Item>[]> {
|
||||
query = clone(query);
|
||||
const primaryKeyField = this.schema[this.collection].primary;
|
||||
const primaryKeyField = this.schema.tables[this.collection].primary;
|
||||
const keys = toArray(key);
|
||||
|
||||
if (keys.length === 1) {
|
||||
@@ -257,8 +257,8 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
|
||||
data: Partial<Item> | Partial<Item>[],
|
||||
key?: PrimaryKey | PrimaryKey[]
|
||||
): Promise<PrimaryKey | PrimaryKey[]> {
|
||||
const primaryKeyField = this.schema[this.collection].primary;
|
||||
const columns = Object.keys(this.schema[this.collection].columns);
|
||||
const primaryKeyField = this.schema.tables[this.collection].primary;
|
||||
const columns = Object.keys(this.schema.tables[this.collection].columns);
|
||||
|
||||
// Updating one or more items to the same payload
|
||||
if (data && key) {
|
||||
@@ -401,7 +401,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
|
||||
}
|
||||
|
||||
async updateByQuery(data: Partial<Item>, query: Query): Promise<PrimaryKey[]> {
|
||||
const primaryKeyField = this.schema[this.collection].primary;
|
||||
const primaryKeyField = this.schema.tables[this.collection].primary;
|
||||
const readQuery = cloneDeep(query);
|
||||
readQuery.fields = [primaryKeyField];
|
||||
|
||||
@@ -422,7 +422,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
|
||||
upsert(data: Partial<Item>[]): Promise<PrimaryKey[]>;
|
||||
upsert(data: Partial<Item>): Promise<PrimaryKey>;
|
||||
async upsert(data: Partial<Item> | Partial<Item>[]): Promise<PrimaryKey | PrimaryKey[]> {
|
||||
const primaryKeyField = this.schema[this.collection].primary;
|
||||
const primaryKeyField = this.schema.tables[this.collection].primary;
|
||||
const payloads = toArray(data);
|
||||
const primaryKeys: PrimaryKey[] = [];
|
||||
|
||||
@@ -452,7 +452,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
|
||||
delete(keys: PrimaryKey[]): Promise<PrimaryKey[]>;
|
||||
async delete(key: PrimaryKey | PrimaryKey[]): Promise<PrimaryKey | PrimaryKey[]> {
|
||||
const keys = toArray(key);
|
||||
const primaryKeyField = this.schema[this.collection].primary;
|
||||
const primaryKeyField = this.schema.tables[this.collection].primary;
|
||||
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
const authorizationService = new AuthorizationService({
|
||||
@@ -508,7 +508,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
|
||||
}
|
||||
|
||||
async deleteByQuery(query: Query): Promise<PrimaryKey[]> {
|
||||
const primaryKeyField = this.schema[this.collection].primary;
|
||||
const primaryKeyField = this.schema.tables[this.collection].primary;
|
||||
const readQuery = cloneDeep(query);
|
||||
readQuery.fields = [primaryKeyField];
|
||||
|
||||
@@ -532,7 +532,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
|
||||
const record = (await this.readByQuery(query, opts)) as Partial<Item>;
|
||||
|
||||
if (!record) {
|
||||
let columns = Object.values(this.schema[this.collection].columns);
|
||||
let columns = Object.values(this.schema.tables[this.collection].columns);
|
||||
const defaults: Record<string, any> = {};
|
||||
|
||||
if (query.fields && query.fields.includes('*') === false) {
|
||||
@@ -552,7 +552,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
|
||||
}
|
||||
|
||||
async upsertSingleton(data: Partial<Item>) {
|
||||
const primaryKeyField = this.schema[this.collection].primary;
|
||||
const primaryKeyField = this.schema.tables[this.collection].primary;
|
||||
|
||||
const record = await this.knex.select(primaryKeyField).from(this.collection).limit(1).first();
|
||||
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { Query } from '../types/query';
|
||||
import database from '../database';
|
||||
import { AbstractServiceOptions, Accountability } from '../types';
|
||||
import { AbstractServiceOptions, Accountability, SchemaOverview } from '../types';
|
||||
import Knex from 'knex';
|
||||
import { applyFilter } from '../utils/apply-query';
|
||||
|
||||
export class MetaService {
|
||||
knex: Knex;
|
||||
accountability: Accountability | null;
|
||||
schema: SchemaOverview;
|
||||
|
||||
constructor(options?: AbstractServiceOptions) {
|
||||
this.knex = options?.knex || database;
|
||||
this.accountability = options?.accountability || null;
|
||||
constructor(options: AbstractServiceOptions) {
|
||||
this.knex = options.knex || database;
|
||||
this.accountability = options.accountability || null;
|
||||
this.schema = options.schema;
|
||||
}
|
||||
|
||||
async getMetaForQuery(collection: string, query: Query) {
|
||||
@@ -40,7 +42,7 @@ export class MetaService {
|
||||
const dbQuery = this.knex(collection).count('*', { as: 'count' });
|
||||
|
||||
if (query.filter) {
|
||||
await applyFilter(this.knex, dbQuery, query.filter, collection);
|
||||
await applyFilter(this.schema, dbQuery, query.filter, collection);
|
||||
}
|
||||
|
||||
const records = await dbQuery;
|
||||
|
||||
@@ -7,16 +7,13 @@ import argon2 from 'argon2';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import database from '../database';
|
||||
import { clone, isObject, cloneDeep } from 'lodash';
|
||||
import { Relation, Item, AbstractServiceOptions, Accountability, PrimaryKey, SchemaOverview } from '../types';
|
||||
import { Item, AbstractServiceOptions, Accountability, PrimaryKey, SchemaOverview } from '../types';
|
||||
import { ItemsService } from './items';
|
||||
import { URL } from 'url';
|
||||
import Knex from 'knex';
|
||||
import env from '../env';
|
||||
import getLocalType from '../utils/get-local-type';
|
||||
import { format, formatISO } from 'date-fns';
|
||||
import { ForbiddenException } from '../exceptions';
|
||||
import { toArray } from '../utils/to-array';
|
||||
import { FieldMeta } from '../types';
|
||||
import { systemFieldRows } from '../database/system-data/fields';
|
||||
import { systemRelationRows } from '../database/system-data/relations';
|
||||
import { InvalidPayloadException } from '../exceptions';
|
||||
@@ -141,13 +138,20 @@ export class PayloadService {
|
||||
|
||||
const fieldsInPayload = Object.keys(processedPayload[0]);
|
||||
|
||||
let specialFieldsInCollection: FieldMeta[] = await this.knex
|
||||
.select('field', 'special')
|
||||
.from('directus_fields')
|
||||
.where({ collection: this.collection })
|
||||
.whereNotNull('special');
|
||||
let specialFieldsInCollection = this.schema.fields.filter(
|
||||
(field) => field.collection === this.collection && field.special && field.special.length > 0
|
||||
);
|
||||
|
||||
specialFieldsInCollection.push(...systemFieldRows.filter((fieldMeta) => fieldMeta.collection === this.collection));
|
||||
specialFieldsInCollection.push(
|
||||
...systemFieldRows
|
||||
.filter((fieldMeta) => fieldMeta.collection === this.collection)
|
||||
.map((fieldMeta) => ({
|
||||
id: fieldMeta.id,
|
||||
collection: fieldMeta.collection,
|
||||
field: fieldMeta.field,
|
||||
special: fieldMeta.special ?? [],
|
||||
}))
|
||||
);
|
||||
|
||||
if (action === 'read') {
|
||||
specialFieldsInCollection = specialFieldsInCollection.filter((fieldMeta) => {
|
||||
@@ -187,7 +191,12 @@ export class PayloadService {
|
||||
return processedPayload[0];
|
||||
}
|
||||
|
||||
async processField(field: FieldMeta, payload: Partial<Item>, action: Action, accountability: Accountability | null) {
|
||||
async processField(
|
||||
field: SchemaOverview['fields'][number],
|
||||
payload: Partial<Item>,
|
||||
action: Action,
|
||||
accountability: Accountability | null
|
||||
) {
|
||||
if (!field.special) return payload[field.field];
|
||||
const fieldSpecials = field.special ? toArray(field.special) : [];
|
||||
|
||||
@@ -212,7 +221,7 @@ export class PayloadService {
|
||||
* shouldn't return with time / timezone info respectively
|
||||
*/
|
||||
async processDates(payloads: Partial<Record<string, any>>[]) {
|
||||
const columnsInCollection = Object.values(this.schema[this.collection].columns);
|
||||
const columnsInCollection = Object.values(this.schema.tables[this.collection].columns);
|
||||
|
||||
const columnsWithType = columnsInCollection.map((column) => ({
|
||||
name: column.column_name,
|
||||
@@ -265,10 +274,9 @@ export class PayloadService {
|
||||
processA2O(payloads: Partial<Item>): Promise<Partial<Item>>;
|
||||
async processA2O(payload: Partial<Item> | Partial<Item>[]): Promise<Partial<Item> | Partial<Item>[]> {
|
||||
const relations = [
|
||||
...(await this.knex
|
||||
.select<Relation[]>('*')
|
||||
.from('directus_relations')
|
||||
.where({ many_collection: this.collection })),
|
||||
...this.schema.relations.filter((relation) => {
|
||||
return relation.many_collection === this.collection;
|
||||
}),
|
||||
...systemRelationRows.filter((systemRelation) => systemRelation.many_collection === this.collection),
|
||||
];
|
||||
|
||||
@@ -309,7 +317,7 @@ export class PayloadService {
|
||||
schema: this.schema,
|
||||
});
|
||||
|
||||
const relatedPrimary = this.schema[relatedCollection].primary;
|
||||
const relatedPrimary = this.schema.tables[relatedCollection].primary;
|
||||
const relatedRecord: Partial<Item> = payload[relation.many_field];
|
||||
const hasPrimaryKey = relatedRecord.hasOwnProperty(relatedPrimary);
|
||||
|
||||
@@ -337,10 +345,9 @@ export class PayloadService {
|
||||
processM2O(payloads: Partial<Item>): Promise<Partial<Item>>;
|
||||
async processM2O(payload: Partial<Item> | Partial<Item>[]): Promise<Partial<Item> | Partial<Item>[]> {
|
||||
const relations = [
|
||||
...(await this.knex
|
||||
.select<Relation[]>('*')
|
||||
.from('directus_relations')
|
||||
.where({ many_collection: this.collection })),
|
||||
...this.schema.relations.filter((relation) => {
|
||||
return relation.many_collection === this.collection;
|
||||
}),
|
||||
...systemRelationRows.filter((systemRelation) => systemRelation.many_collection === this.collection),
|
||||
];
|
||||
|
||||
@@ -391,10 +398,9 @@ export class PayloadService {
|
||||
*/
|
||||
async processO2M(payload: Partial<Item> | Partial<Item>[], parent?: PrimaryKey) {
|
||||
const relations = [
|
||||
...(await this.knex
|
||||
.select<Relation[]>('*')
|
||||
.from('directus_relations')
|
||||
.where({ one_collection: this.collection })),
|
||||
...this.schema.relations.filter((relation) => {
|
||||
return relation.one_collection === this.collection;
|
||||
}),
|
||||
...systemRelationRows.filter((systemRelation) => systemRelation.one_collection === this.collection),
|
||||
];
|
||||
|
||||
|
||||
@@ -1,34 +1,78 @@
|
||||
import { AbstractServiceOptions, PermissionsAction } from '../types';
|
||||
import { AbstractServiceOptions, PermissionsAction, Query, Item, PrimaryKey } from '../types';
|
||||
import { ItemsService } from '../services/items';
|
||||
import { filterItems } from '../utils/filter-items';
|
||||
|
||||
import { appAccessMinimalPermissions } from '../database/system-data/app-access-permissions';
|
||||
|
||||
export class PermissionsService extends ItemsService {
|
||||
constructor(options: AbstractServiceOptions) {
|
||||
super('directus_permissions', options);
|
||||
}
|
||||
|
||||
async getAllowedCollections(role: string | null, action: PermissionsAction) {
|
||||
const query = this.knex.select('collection').from('directus_permissions').where({ role, action });
|
||||
const results = await query;
|
||||
return results.map((result) => result.collection);
|
||||
}
|
||||
getAllowedFields(action: PermissionsAction, collection?: string) {
|
||||
const results = this.schema.permissions.filter((permission) => {
|
||||
let matchesCollection = true;
|
||||
|
||||
async getAllowedFields(role: string | null, action: PermissionsAction, collection?: string) {
|
||||
const query = this.knex.select('collection', 'fields').from('directus_permissions').where({ role, action });
|
||||
if (collection) {
|
||||
matchesCollection = permission.collection === collection;
|
||||
}
|
||||
|
||||
if (collection) {
|
||||
query.andWhere({ collection });
|
||||
}
|
||||
|
||||
const results = await query;
|
||||
return permission.action === action;
|
||||
});
|
||||
|
||||
const fieldsPerCollection: Record<string, string[]> = {};
|
||||
|
||||
for (const result of results) {
|
||||
const { collection, fields } = result;
|
||||
if (!fieldsPerCollection[collection]) fieldsPerCollection[collection] = [];
|
||||
fieldsPerCollection[collection].push(...(fields || '').split(','));
|
||||
fieldsPerCollection[collection].push(...(fields ?? []));
|
||||
}
|
||||
|
||||
return fieldsPerCollection;
|
||||
}
|
||||
|
||||
async readByQuery(
|
||||
query: Query,
|
||||
opts?: { stripNonRequested?: boolean }
|
||||
): Promise<null | Partial<Item> | Partial<Item>[]> {
|
||||
const result = await super.readByQuery(query, opts);
|
||||
|
||||
if (Array.isArray(result) && this.accountability && this.accountability.app === true) {
|
||||
result.push(
|
||||
...filterItems(
|
||||
appAccessMinimalPermissions.map((permission) => ({
|
||||
...permission,
|
||||
role: this.accountability!.role,
|
||||
})),
|
||||
query.filter
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
readByKey(keys: PrimaryKey[], query?: Query, action?: PermissionsAction): Promise<null | Partial<Item>[]>;
|
||||
readByKey(key: PrimaryKey, query?: Query, action?: PermissionsAction): Promise<null | Partial<Item>>;
|
||||
async readByKey(
|
||||
key: PrimaryKey | PrimaryKey[],
|
||||
query: Query = {},
|
||||
action: PermissionsAction = 'read'
|
||||
): Promise<null | Partial<Item> | Partial<Item>[]> {
|
||||
const result = await super.readByKey(key as any, query, action);
|
||||
|
||||
if (Array.isArray(result) && this.accountability && this.accountability.app === true) {
|
||||
result.push(
|
||||
...filterItems(
|
||||
appAccessMinimalPermissions.map((permission) => ({
|
||||
...permission,
|
||||
role: this.accountability!.role,
|
||||
})),
|
||||
query.filter
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,12 +61,13 @@ export class RelationsService extends ItemsService {
|
||||
if (relations === null) return null;
|
||||
if (this.accountability === null || this.accountability?.admin === true) return relations;
|
||||
|
||||
const allowedCollections = await this.permissionsService.getAllowedCollections(
|
||||
this.accountability?.role || null,
|
||||
'read'
|
||||
);
|
||||
const allowedCollections = this.schema.permissions
|
||||
.filter((permission) => {
|
||||
return permission.action === 'read';
|
||||
})
|
||||
.map(({ collection }) => collection);
|
||||
|
||||
const allowedFields = await this.permissionsService.getAllowedFields(this.accountability?.role || null, 'read');
|
||||
const allowedFields = this.permissionsService.getAllowedFields('read');
|
||||
|
||||
relations = toArray(relations);
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ interface SpecificationSubService {
|
||||
class OASService implements SpecificationSubService {
|
||||
accountability: Accountability | null;
|
||||
knex: Knex;
|
||||
schema: SchemaOverview;
|
||||
|
||||
fieldsService: FieldsService;
|
||||
collectionsService: CollectionsService;
|
||||
@@ -81,6 +82,7 @@ class OASService implements SpecificationSubService {
|
||||
) {
|
||||
this.accountability = options.accountability || null;
|
||||
this.knex = options.knex || database;
|
||||
this.schema = options.schema;
|
||||
|
||||
this.fieldsService = fieldsService;
|
||||
this.collectionsService = collectionsService;
|
||||
@@ -91,10 +93,7 @@ class OASService implements SpecificationSubService {
|
||||
const collections = await this.collectionsService.readByQuery();
|
||||
const fields = await this.fieldsService.readAll();
|
||||
const relations = (await this.relationsService.readByQuery({})) as Relation[];
|
||||
const permissions: Permission[] = await this.knex
|
||||
.select('*')
|
||||
.from('directus_permissions')
|
||||
.where({ role: this.accountability?.role || null });
|
||||
const permissions = this.schema.permissions;
|
||||
|
||||
const tags = await this.generateTags(collections);
|
||||
const paths = await this.generatePaths(permissions, tags);
|
||||
@@ -104,7 +103,8 @@ class OASService implements SpecificationSubService {
|
||||
openapi: '3.0.1',
|
||||
info: {
|
||||
title: 'Dynamic API Specification',
|
||||
description: 'This is a dynamicly generated API specification for all endpoints existing on the current .',
|
||||
description:
|
||||
'This is a dynamically generated API specification for all endpoints existing on the current project.',
|
||||
version: version,
|
||||
},
|
||||
servers: [
|
||||
|
||||
@@ -27,28 +27,22 @@ export class UtilsService {
|
||||
}
|
||||
|
||||
if (this.accountability?.admin !== true) {
|
||||
const permissions = await this.knex
|
||||
.select('fields')
|
||||
.from('directus_permissions')
|
||||
.where({
|
||||
collection,
|
||||
action: 'update',
|
||||
role: this.accountability?.role || null,
|
||||
})
|
||||
.first();
|
||||
const permissions = this.schema.permissions.find((permission) => {
|
||||
return permission.collection === collection && permission.action === 'update';
|
||||
});
|
||||
|
||||
if (!permissions) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const allowedFields = permissions.fields.split(',');
|
||||
const allowedFields = permissions.fields ?? [];
|
||||
|
||||
if (allowedFields[0] !== '*' && allowedFields.includes(sortField) === false) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
const primaryKeyField = this.schema[collection].primary;
|
||||
const primaryKeyField = this.schema.tables[collection].primary;
|
||||
|
||||
// Make sure all rows have a sort value
|
||||
const countResponse = await this.knex.count('* as count').from(collection).whereNull(sortField).first();
|
||||
|
||||
@@ -2,6 +2,7 @@ export type Accountability = {
|
||||
role: string | null;
|
||||
user?: string | null;
|
||||
admin?: boolean;
|
||||
app?: boolean;
|
||||
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
|
||||
5
api/src/types/express.d.ts
vendored
5
api/src/types/express.d.ts
vendored
@@ -3,9 +3,11 @@
|
||||
*/
|
||||
|
||||
import { Permission } from './permissions';
|
||||
import { Relation } from './relation';
|
||||
|
||||
import { Query } from './query';
|
||||
import { Accountability } from './accountability';
|
||||
import { SchemaOverview } from '@directus/schema/dist/types/overview';
|
||||
import { SchemaOverview } from './schema';
|
||||
|
||||
export {};
|
||||
|
||||
@@ -19,7 +21,6 @@ declare global {
|
||||
|
||||
accountability?: Accountability;
|
||||
singleton?: boolean;
|
||||
permissions?: Permission;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
export type PermissionsAction = 'create' | 'read' | 'update' | 'delete' | 'comment' | 'explain';
|
||||
|
||||
export type Permission = {
|
||||
id: number;
|
||||
id?: number;
|
||||
role: string | null;
|
||||
collection: string;
|
||||
action: PermissionsAction;
|
||||
permissions: Record<string, any>;
|
||||
validation: Record<string, any>;
|
||||
validation: Record<string, any> | null;
|
||||
limit: number | null;
|
||||
presets: Record<string, any> | null;
|
||||
fields: string[] | null;
|
||||
system?: true;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
import { SchemaOverview as SO } from '@directus/schema/dist/types/overview';
|
||||
import { Relation } from './relation';
|
||||
import { Permission } from './permissions';
|
||||
|
||||
export type SchemaOverview = SO;
|
||||
export type SchemaOverview = {
|
||||
tables: SO;
|
||||
relations: Relation[];
|
||||
fields: {
|
||||
id: number;
|
||||
collection: string;
|
||||
field: string;
|
||||
special: string[];
|
||||
}[];
|
||||
permissions: Permission[];
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { QueryBuilder } from 'knex';
|
||||
import { Query, Filter, Relation, SchemaOverview } from '../types';
|
||||
import Knex from 'knex';
|
||||
import { clone, isPlainObject } from 'lodash';
|
||||
import { systemRelationRows } from '../database/system-data/relations';
|
||||
import { nanoid } from 'nanoid';
|
||||
@@ -8,14 +7,13 @@ import getLocalType from './get-local-type';
|
||||
import validate from 'uuid-validate';
|
||||
|
||||
export default async function applyQuery(
|
||||
knex: Knex,
|
||||
collection: string,
|
||||
dbQuery: QueryBuilder,
|
||||
query: Query,
|
||||
schema: SchemaOverview
|
||||
) {
|
||||
if (query.filter) {
|
||||
await applyFilter(knex, dbQuery, query.filter, collection);
|
||||
await applyFilter(schema, dbQuery, query.filter, collection);
|
||||
}
|
||||
|
||||
if (query.sort) {
|
||||
@@ -39,7 +37,7 @@ export default async function applyQuery(
|
||||
}
|
||||
|
||||
if (query.search) {
|
||||
const columns = Object.values(schema[collection].columns);
|
||||
const columns = Object.values(schema.tables[collection].columns);
|
||||
|
||||
dbQuery.andWhere(function () {
|
||||
columns
|
||||
@@ -64,8 +62,13 @@ export default async function applyQuery(
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyFilter(knex: Knex, rootQuery: QueryBuilder, rootFilter: Filter, collection: string) {
|
||||
const relations: Relation[] = [...(await knex.select('*').from('directus_relations')), ...systemRelationRows];
|
||||
export async function applyFilter(
|
||||
schema: SchemaOverview,
|
||||
rootQuery: QueryBuilder,
|
||||
rootFilter: Filter,
|
||||
collection: string
|
||||
) {
|
||||
const relations: Relation[] = [...schema.relations, ...systemRelationRows];
|
||||
|
||||
const aliasMap: Record<string, string> = {};
|
||||
|
||||
@@ -75,6 +78,11 @@ export async function applyFilter(knex: Knex, rootQuery: QueryBuilder, rootFilte
|
||||
function addJoins(dbQuery: QueryBuilder, filter: Filter, collection: string) {
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (key === '_or' || key === '_and') {
|
||||
// If the _or array contains an empty object (full permissions), we should short-circuit and ignore all other
|
||||
// permission checks, as {} already matches full permissions.
|
||||
if (key === '_or' && value.some((subFilter: Record<string, any>) => Object.keys(subFilter).length === 0))
|
||||
continue;
|
||||
|
||||
value.forEach((subFilter: Record<string, any>) => {
|
||||
addJoins(dbQuery, subFilter, collection);
|
||||
});
|
||||
@@ -137,8 +145,13 @@ export async function applyFilter(knex: Knex, rootQuery: QueryBuilder, rootFilte
|
||||
function addWhereClauses(dbQuery: QueryBuilder, filter: Filter, collection: string, logical: 'and' | 'or' = 'and') {
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (key === '_or' || key === '_and') {
|
||||
// If the _or array contains an empty object (full permissions), we should short-circuit and ignore all other
|
||||
// permission checks, as {} already matches full permissions.
|
||||
if (key === '_or' && value.some((subFilter: Record<string, any>) => Object.keys(subFilter).length === 0))
|
||||
continue;
|
||||
|
||||
/** @NOTE this callback function isn't called until Knex runs the query */
|
||||
dbQuery.where((subQuery) => {
|
||||
dbQuery[logical].where((subQuery) => {
|
||||
value.forEach((subFilter: Record<string, any>) => {
|
||||
addWhereClauses(subQuery, subFilter, collection, key === '_and' ? 'and' : 'or');
|
||||
});
|
||||
|
||||
38
api/src/utils/filter-items.ts
Normal file
38
api/src/utils/filter-items.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Query } from '../types';
|
||||
import generateJoi from './generate-joi';
|
||||
|
||||
/*
|
||||
Note: Filtering is normally done through SQL in run-ast. This function can be used in case an already
|
||||
existing array of items has to be filtered using the same filter syntax as used in the ast-to-sql flow
|
||||
*/
|
||||
|
||||
export function filterItems(items: Record<string, any>[], filter: Query['filter']) {
|
||||
if (!filter) return items;
|
||||
|
||||
return items.filter((item) => {
|
||||
return passesFilter(item, filter);
|
||||
});
|
||||
|
||||
function passesFilter(item: Record<string, any>, filter: Query['filter']): boolean {
|
||||
if (!filter) return true;
|
||||
|
||||
if (Object.keys(filter)[0] === '_and') {
|
||||
const subfilter = Object.values(filter)[0] as Query['filter'][];
|
||||
|
||||
return subfilter.every((subFilter) => {
|
||||
return passesFilter(item, subFilter);
|
||||
});
|
||||
} else if (Object.keys(filter)[0] === '_or') {
|
||||
const subfilter = Object.values(filter)[0] as Query['filter'][];
|
||||
|
||||
return subfilter.some((subFilter) => {
|
||||
return passesFilter(item, subFilter);
|
||||
});
|
||||
} else {
|
||||
const schema = generateJoi(filter);
|
||||
|
||||
const { error } = schema.validate(item);
|
||||
return error === undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
NestedCollectionNode,
|
||||
FieldNode,
|
||||
Query,
|
||||
Relation,
|
||||
PermissionsAction,
|
||||
Accountability,
|
||||
SchemaOverview,
|
||||
@@ -39,20 +38,14 @@ export default async function getASTFromQuery(
|
||||
|
||||
const accountability = options?.accountability;
|
||||
const action = options?.action || 'read';
|
||||
const knex = options?.knex || database;
|
||||
|
||||
/**
|
||||
* we might not need al this info at all times, but it's easier to fetch it all once, than trying to fetch it for every
|
||||
* requested field. @todo look into utilizing graphql/dataloader for this purpose
|
||||
*/
|
||||
const relations = [...(await knex.select<Relation[]>('*').from('directus_relations')), ...systemRelationRows];
|
||||
const relations = [...schema.relations, ...systemRelationRows];
|
||||
|
||||
const permissions =
|
||||
accountability && accountability.admin !== true
|
||||
? await knex
|
||||
.select<{ collection: string; fields: string }[]>('collection', 'fields')
|
||||
.from('directus_permissions')
|
||||
.where({ role: accountability.role, action: action })
|
||||
? schema.permissions.filter((permission) => {
|
||||
return permission.action === action;
|
||||
})
|
||||
: null;
|
||||
|
||||
const ast: AST = {
|
||||
@@ -159,7 +152,7 @@ export default async function getASTFromQuery(
|
||||
children: {},
|
||||
query: {},
|
||||
relatedKey: {},
|
||||
parentKey: schema[parentCollection].primary,
|
||||
parentKey: schema.tables[parentCollection].primary,
|
||||
fieldKey: relationalField,
|
||||
relation: relation,
|
||||
};
|
||||
@@ -171,7 +164,7 @@ export default async function getASTFromQuery(
|
||||
);
|
||||
|
||||
child.query[relatedCollection] = {};
|
||||
child.relatedKey[relatedCollection] = schema[relatedCollection].primary;
|
||||
child.relatedKey[relatedCollection] = schema.tables[relatedCollection].primary;
|
||||
}
|
||||
} else if (relatedCollection) {
|
||||
if (permissions && permissions.some((permission) => permission.collection === relatedCollection) === false) {
|
||||
@@ -182,8 +175,8 @@ export default async function getASTFromQuery(
|
||||
type: relationType,
|
||||
name: relatedCollection,
|
||||
fieldKey: relationalField,
|
||||
parentKey: schema[parentCollection].primary,
|
||||
relatedKey: schema[relatedCollection].primary,
|
||||
parentKey: schema.tables[parentCollection].primary,
|
||||
relatedKey: schema.tables[relatedCollection].primary,
|
||||
relation: relation,
|
||||
query: deep?.[relationalField] || {},
|
||||
children: await parseFields(relatedCollection, nestedFields as string[]),
|
||||
@@ -206,9 +199,7 @@ export default async function getASTFromQuery(
|
||||
let allowedFields = fieldsInCollection;
|
||||
|
||||
if (permissions) {
|
||||
const permittedFields = permissions
|
||||
.find((permission) => parentCollection === permission.collection)
|
||||
?.fields?.split(',');
|
||||
const permittedFields = permissions.find((permission) => parentCollection === permission.collection)?.fields;
|
||||
if (permittedFields) allowedFields = permittedFields;
|
||||
}
|
||||
|
||||
@@ -294,9 +285,9 @@ export default async function getASTFromQuery(
|
||||
}
|
||||
|
||||
async function getFieldsInCollection(collection: string) {
|
||||
const columns = Object.keys(schema[collection].columns);
|
||||
const columns = Object.keys(schema.tables[collection].columns);
|
||||
const fields = [
|
||||
...(await knex.select('field').from('directus_fields').where({ collection })).map((field) => field.field),
|
||||
...schema.fields.filter((field) => field.collection === collection).map((field) => field.field),
|
||||
...systemFieldRows.filter((fieldMeta) => fieldMeta.collection === collection).map((fieldMeta) => fieldMeta.field),
|
||||
];
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import getLocalType from './get-local-type';
|
||||
import { Column } from '@directus/schema/dist/types/column';
|
||||
import { SchemaOverview } from '../types';
|
||||
|
||||
export default function getDefaultValue(column: SchemaOverview[string]['columns'][string] | Column) {
|
||||
export default function getDefaultValue(column: SchemaOverview['tables'][string]['columns'][string] | Column) {
|
||||
const type = getLocalType(column);
|
||||
|
||||
let defaultValue = column.default_value || null;
|
||||
|
||||
@@ -81,7 +81,7 @@ const localTypeMap: Record<string, { type: typeof types[number]; useTimezone?: b
|
||||
};
|
||||
|
||||
export default function getLocalType(
|
||||
column: SchemaOverview[string]['columns'][string] | Column,
|
||||
column: SchemaOverview['tables'][string]['columns'][string] | Column,
|
||||
field?: FieldMeta
|
||||
): typeof types[number] | 'unknown' {
|
||||
const type = localTypeMap[column.data_type.toLowerCase().split('(')[0]];
|
||||
|
||||
69
api/src/utils/get-schema.ts
Normal file
69
api/src/utils/get-schema.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { appAccessMinimalPermissions } from '../database/system-data/app-access-permissions';
|
||||
import { Accountability, SchemaOverview, Permission } from '../types';
|
||||
import database, { schemaInspector } from '../database';
|
||||
import logger from '../logger';
|
||||
import { mergePermissions } from './merge-permissions';
|
||||
|
||||
export async function getSchema(accountability?: Accountability): Promise<SchemaOverview> {
|
||||
const schemaOverview = await schemaInspector.overview();
|
||||
|
||||
for (const [collection, info] of Object.entries(schemaOverview)) {
|
||||
if (!info.primary) {
|
||||
logger.warn(`Collection "${collection}" doesn't have a primary key column and will be ignored`);
|
||||
delete schemaOverview[collection];
|
||||
}
|
||||
}
|
||||
|
||||
const relations = await database.select('*').from('directus_relations');
|
||||
|
||||
const fields = await database
|
||||
.select<{ id: number; collection: string; field: string; special: string }[]>(
|
||||
'id',
|
||||
'collection',
|
||||
'field',
|
||||
'special'
|
||||
)
|
||||
.from('directus_fields');
|
||||
|
||||
let permissions: Permission[] = [];
|
||||
|
||||
if (accountability && accountability.admin !== true) {
|
||||
const permissionsForRole = await database
|
||||
.select('*')
|
||||
.from('directus_permissions')
|
||||
.where({ role: accountability.role });
|
||||
|
||||
permissions = permissionsForRole.map((permissionRaw) => {
|
||||
if (permissionRaw.permissions && typeof permissionRaw.permissions === 'string') {
|
||||
permissionRaw.permissions = JSON.parse(permissionRaw.permissions);
|
||||
}
|
||||
|
||||
if (permissionRaw.validation && typeof permissionRaw.validation === 'string') {
|
||||
permissionRaw.validation = JSON.parse(permissionRaw.validation);
|
||||
}
|
||||
|
||||
if (permissionRaw.fields && typeof permissionRaw.fields === 'string') {
|
||||
permissionRaw.fields = permissionRaw.fields.split(',');
|
||||
}
|
||||
|
||||
return permissionRaw;
|
||||
});
|
||||
|
||||
if (accountability.app === true) {
|
||||
permissions = mergePermissions(
|
||||
permissions,
|
||||
appAccessMinimalPermissions.map((perm) => ({ ...perm, role: accountability.role }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tables: schemaOverview,
|
||||
relations: relations,
|
||||
fields: fields.map((transform) => ({
|
||||
...transform,
|
||||
special: transform.special?.split(','),
|
||||
})),
|
||||
permissions: permissions,
|
||||
};
|
||||
}
|
||||
88
api/src/utils/merge-permissions.ts
Normal file
88
api/src/utils/merge-permissions.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Permission } from '../types';
|
||||
import { merge, omit } from 'lodash';
|
||||
|
||||
export function mergePermissions(...permissions: Permission[][]): Permission[] {
|
||||
const allPermissions = permissions.flat();
|
||||
|
||||
const mergedPermissions = allPermissions
|
||||
.reduce((acc, val) => {
|
||||
const key = `${val.collection}__${val.action}__${val.role || '$PUBLIC'}`;
|
||||
const current = acc.get(key);
|
||||
acc.set(key, current ? mergePerm(current, val) : val);
|
||||
return acc;
|
||||
}, new Map())
|
||||
.values();
|
||||
|
||||
const result = Array.from(mergedPermissions).map((perm) => {
|
||||
return omit(perm, ['id', 'system']) as Permission;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function mergePerm(currentPerm: Permission, newPerm: Permission) {
|
||||
let permissions = currentPerm.permissions;
|
||||
let validation = currentPerm.validation;
|
||||
let fields = currentPerm.fields;
|
||||
let presets = currentPerm.presets;
|
||||
let limit = currentPerm.limit;
|
||||
|
||||
if (newPerm.permissions) {
|
||||
if (currentPerm.permissions && Object.keys(currentPerm.permissions)[0] === '_or') {
|
||||
permissions = {
|
||||
_or: [...currentPerm.permissions._or, newPerm.permissions],
|
||||
};
|
||||
} else if (currentPerm.permissions) {
|
||||
permissions = {
|
||||
_or: [currentPerm.permissions, newPerm.permissions],
|
||||
};
|
||||
} else {
|
||||
permissions = {
|
||||
_or: [newPerm.permissions],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (newPerm.validation) {
|
||||
if (currentPerm.validation && Object.keys(currentPerm.validation)[0] === '_or') {
|
||||
validation = {
|
||||
_or: [...currentPerm.validation._or, newPerm.validation],
|
||||
};
|
||||
} else if (currentPerm.validation) {
|
||||
validation = {
|
||||
_or: [currentPerm.validation, newPerm.validation],
|
||||
};
|
||||
} else {
|
||||
validation = {
|
||||
_or: [newPerm.validation],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (newPerm.fields) {
|
||||
if (Array.isArray(currentPerm.fields)) {
|
||||
fields = [...new Set([...currentPerm.fields, ...newPerm.fields])];
|
||||
} else {
|
||||
fields = newPerm.fields;
|
||||
}
|
||||
|
||||
if (fields.includes('*')) fields = ['*'];
|
||||
}
|
||||
|
||||
if (newPerm.presets) {
|
||||
presets = merge({}, presets, newPerm.presets);
|
||||
}
|
||||
|
||||
if (newPerm.limit && newPerm.limit > (currentPerm.limit || 0)) {
|
||||
limit = newPerm.limit;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentPerm,
|
||||
permissions,
|
||||
validation,
|
||||
fields,
|
||||
presets,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user