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:
Rijk van Zanten
2021-02-11 12:50:56 -05:00
committed by GitHub
parent 8c1402fb88
commit b7d87e581a
55 changed files with 897 additions and 524 deletions

View File

@@ -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 });

View File

@@ -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 });

View File

@@ -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' });

View File

@@ -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);

View File

@@ -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) => {

View File

@@ -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';

View File

@@ -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)

View File

@@ -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

View 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));

View File

@@ -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) {

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -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)

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();

View File

@@ -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;

View File

@@ -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),
];

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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: [

View File

@@ -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();

View File

@@ -2,6 +2,7 @@ export type Accountability = {
role: string | null;
user?: string | null;
admin?: boolean;
app?: boolean;
ip?: string;
userAgent?: string;

View File

@@ -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;
}
}
}

View File

@@ -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;
};

View File

@@ -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[];
};

View File

@@ -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');
});

View 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;
}
}
}

View File

@@ -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),
];

View File

@@ -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;

View File

@@ -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]];

View 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,
};
}

View 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,
};
}