Merge branch 'main' into feature-rate-limiting

This commit is contained in:
rijkvanzanten
2020-09-08 12:31:57 -04:00
248 changed files with 3387 additions and 34724 deletions

View File

@@ -16,6 +16,7 @@ import { ForbiddenException, InvalidPayloadException } from '../exceptions';
import { uniq, merge } from 'lodash';
import generateJoi from '../utils/generate-joi';
import ItemsService from './items';
import { parseFilter } from '../utils/parse-filter';
export default class AuthorizationService {
knex: Knex;
@@ -64,8 +65,7 @@ export default class AuthorizationService {
}
validateFields(ast);
applyFilters(ast);
applyFilters(ast, this.accountability);
return ast;
@@ -126,7 +126,8 @@ export default class AuthorizationService {
}
function applyFilters(
ast: AST | NestedCollectionAST | FieldAST
ast: AST | NestedCollectionAST | FieldAST,
accountability: Accountability | null
): AST | NestedCollectionAST | FieldAST {
if (ast.type === 'collection') {
const collection = ast.name;
@@ -136,11 +137,12 @@ export default class AuthorizationService {
(permission) => permission.collection === collection
)!;
const parsedPermissions = parseFilter(permissions.permissions, accountability);
ast.query = {
...ast.query,
filter: {
...(ast.query.filter || {}),
...permissions.permissions,
_and: [ast.query.filter || {}, parsedPermissions],
},
};
@@ -155,7 +157,10 @@ export default class AuthorizationService {
ast.query.limit = permissions.limit;
}
ast.children = ast.children.map(applyFilters) as (NestedCollectionAST | FieldAST)[];
ast.children = ast.children.map((child) => applyFilters(child, accountability)) as (
| NestedCollectionAST
| FieldAST
)[];
}
return ast;
@@ -202,7 +207,7 @@ export default class AuthorizationService {
if (invalidKeys.length > 0) {
throw new ForbiddenException(
`You're not allowed to ${action} field "${invalidKeys[0]}" in collection "${collection}".`
)
);
}
}
}
@@ -228,7 +233,11 @@ export default class AuthorizationService {
}
}
async checkAccess(action: PermissionsAction, collection: string, pk: PrimaryKey | PrimaryKey[]) {
async checkAccess(
action: PermissionsAction,
collection: string,
pk: PrimaryKey | PrimaryKey[]
) {
const itemsService = new ItemsService(collection, { accountability: this.accountability });
try {
@@ -242,8 +251,11 @@ export default class AuthorizationService {
if (Array.isArray(pk) && result.length !== pk.length) throw '';
} catch {
throw new ForbiddenException(
`You're not allowed to ${action} item "${pk}" in collection "${collection}".`, {
collection, item: pk, action
`You're not allowed to ${action} item "${pk}" in collection "${collection}".`,
{
collection,
item: pk,
action,
}
);
}

View File

@@ -199,12 +199,27 @@ export default class CollectionsService {
const payload = data as Partial<Collection>;
if (!payload.meta) {
throw new InvalidPayloadException(`"system" key is required`);
throw new InvalidPayloadException(`"meta" key is required`);
}
return (await collectionItemsService.update(payload.meta!, key as any)) as
| string
| string[];
const keys = Array.isArray(key) ? key : [key];
for (const key of keys) {
const exists =
(await this.knex
.select('collection')
.from('directus_collections')
.where({ collection: key })
.first()) !== undefined;
if (exists) {
await collectionItemsService.update(payload.meta, key);
} else {
await collectionItemsService.create({ ...payload.meta, collection: key });
}
}
return key;
}
const payloads = Array.isArray(data) ? data : [data];
@@ -228,7 +243,10 @@ export default class CollectionsService {
throw new ForbiddenException('Only admins can perform this action.');
}
const fieldsService = new FieldsService({ knex: this.knex, accountability: this.accountability });
const fieldsService = new FieldsService({
knex: this.knex,
accountability: this.accountability,
});
const tablesInDatabase = await schemaInspector.tables();
@@ -256,10 +274,14 @@ export default class CollectionsService {
const isM2O = relation.many_collection === collection;
if (isM2O) {
await this.knex('directus_relations').delete().where({ many_collection: collection, many_field: relation.many_field });
await this.knex('directus_relations')
.delete()
.where({ many_collection: collection, many_field: relation.many_field });
await fieldsService.deleteField(relation.one_collection, relation.one_field);
} else {
await this.knex('directus_relations').update({ one_field: null }).where({ one_collection: collection, field: relation.one_field });
await this.knex('directus_relations')
.update({ one_field: null })
.where({ one_collection: collection, field: relation.one_field });
await fieldsService.deleteField(relation.many_collection, relation.many_field);
}
}

View File

@@ -40,9 +40,10 @@ export default class FieldsService {
if (collection) {
fields = (await nonAuthorizedItemsService.readByQuery({
filter: { collection: { _eq: collection } },
limit: -1,
})) as FieldMeta[];
} else {
fields = (await nonAuthorizedItemsService.readByQuery({})) as FieldMeta[];
fields = (await nonAuthorizedItemsService.readByQuery({ limit: -1 })) as FieldMeta[];
}
fields = (await this.payloadService.processValues('read', fields)) as FieldMeta[];
@@ -101,11 +102,16 @@ export default 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 = await this.knex
.select('collection', 'fields')
.from('directus_permissions')
.where({ role: this.accountability.role, action: 'read' });
const allowedFieldsInCollection: Record<string, string[]> = {};
permissions.forEach((permission) => {
allowedFieldsInCollection[permission.collection] = (permission.fields || '').split(',');
allowedFieldsInCollection[permission.collection] = (permission.fields || '').split(
','
);
});
if (collection && allowedFieldsInCollection.hasOwnProperty(collection) === false) {
@@ -113,7 +119,8 @@ export default class FieldsService {
}
return result.filter((field) => {
if (allowedFieldsInCollection.hasOwnProperty(field.collection) === false) return false;
if (allowedFieldsInCollection.hasOwnProperty(field.collection) === false)
return false;
const allowedFields = allowedFieldsInCollection[field.collection];
if (allowedFields[0] === '*') return true;
return allowedFields.includes(field.field);
@@ -131,8 +138,9 @@ export default class FieldsService {
.where({
role: this.accountability.role,
collection,
action: 'read'
}).first();
action: 'read',
})
.first();
if (!permissions) throw new ForbiddenException();
if (permissions.fields !== '*') {
@@ -245,13 +253,23 @@ export default class FieldsService {
.from('directus_fields')
.where({ collection, field: field.field })
.first();
if (!record) throw new FieldNotFoundException(collection, field.field);
await this.itemsService.update({
...field.meta,
collection: collection,
field: field.field,
}, record.id);
if (record) {
await this.itemsService.update(
{
...field.meta,
collection: collection,
field: field.field,
},
record.id
);
} else {
await this.itemsService.create({
...field.meta,
collection: collection,
field: field.field,
});
}
}
return field.field;
@@ -281,10 +299,14 @@ export default class FieldsService {
const isM2O = relation.many_collection === collection && relation.many_field === field;
if (isM2O) {
await this.knex('directus_relations').delete().where({ many_collection: collection, many_field: field });
await this.knex('directus_relations')
.delete()
.where({ many_collection: collection, many_field: field });
await this.deleteField(relation.one_collection, relation.one_field);
} else {
await this.knex('directus_relations').update({ one_field: null }).where({ one_collection: collection, one_field: field });
await this.knex('directus_relations')
.update({ one_field: null })
.where({ one_collection: collection, one_field: field });
}
}
}

View File

@@ -17,7 +17,12 @@ import env from '../env';
type Action = 'create' | 'read' | 'update';
type Transformers = {
[type: string]: (action: Action, value: any, payload: Partial<Item>) => Promise<any>;
[type: string]: (
action: Action,
value: any,
payload: Partial<Item>,
accountability: Accountability | null
) => Promise<any>;
};
export default class PayloadService {
@@ -91,9 +96,33 @@ export default class PayloadService {
return value;
},
async conceal(action, value) {
if (action === 'read') return '**********';
if (action === 'read') return value ? '**********' : null;
return value;
}
},
async 'user-created'(action, value, payload, accountability) {
if (action === 'create') return accountability?.user || null;
return value;
},
async 'user-updated'(action, value, payload, accountability) {
if (action === 'update') return accountability?.user || null;
return value;
},
async 'role-created'(action, value, payload, accountability) {
if (action === 'create') return accountability?.role || null;
return value;
},
async 'role-updated'(action, value, payload, accountability) {
if (action === 'update') return accountability?.role || null;
return value;
},
async 'date-created'(action, value) {
if (action === 'create') return new Date();
return value;
},
async 'date-updated'(action, value) {
if (action === 'update') return new Date();
return value;
},
};
processValues(action: Action, payloads: Partial<Item>[]): Promise<Partial<Item>[]>;
@@ -124,7 +153,12 @@ export default class PayloadService {
processedPayload.map(async (record: any) => {
await Promise.all(
specialFieldsInCollection.map(async (field) => {
const newValue = await this.processField(field, record, action);
const newValue = await this.processField(
field,
record,
action,
this.accountability
);
if (newValue !== undefined) record[field.field] = newValue;
})
);
@@ -151,18 +185,18 @@ export default class PayloadService {
async processField(
field: Pick<FieldMeta, 'field' | 'special'>,
payload: Partial<Item>,
action: Action
action: Action,
accountability: Accountability | null
) {
if (!field.special) return payload[field.field];
const fieldSpecials = field.special.split(',').map(s => s.trim());
const fieldSpecials = field.special.split(',').map((s) => s.trim());
let value = clone(payload[field.field]);
for (const special of fieldSpecials) {
if (this.transformers.hasOwnProperty(special)) {
value = await this.transformers[special](action, value, payload);
value = await this.transformers[special](action, value, payload, accountability);
}
}
@@ -266,12 +300,19 @@ export default class PayloadService {
);
const toBeUpdated = relatedRecords.filter(
(record) => record.hasOwnProperty(relation.many_primary) === true && record.hasOwnProperty('$delete') === false
(record) =>
record.hasOwnProperty(relation.many_primary) === true &&
record.hasOwnProperty('$delete') === false
);
const toBeDeleted = relatedRecords
.filter(record => record.hasOwnProperty(relation.many_primary) === true && record.hasOwnProperty('$delete') && record.$delete === true)
.map(record => record[relation.many_primary]);
.filter(
(record) =>
record.hasOwnProperty(relation.many_primary) === true &&
record.hasOwnProperty('$delete') &&
record.$delete === true
)
.map((record) => record[relation.many_primary]);
await itemsService.create(toBeCreated);
await itemsService.update(toBeUpdated);
@@ -280,4 +321,3 @@ export default class PayloadService {
}
}
}
0

View File

@@ -0,0 +1,46 @@
import { AbstractServiceOptions, Accountability } from '../types';
import Knex from 'knex';
import database from '../database';
import os from 'os';
import { ForbiddenException } from '../exceptions';
// @ts-ignore
import { version } from '../../package.json';
import macosRelease from 'macos-release';
export default class ServerService {
knex: Knex;
accountability: Accountability | null;
constructor(options?: AbstractServiceOptions) {
this.knex = options?.knex || database;
this.accountability = options?.accountability || null;
}
serverInfo() {
if (this.accountability?.admin !== true) {
throw new ForbiddenException();
}
const osType = os.type() === 'Darwin' ? 'macOS' : os.type();
const osVersion =
osType === 'macOS'
? `${macosRelease().name} (${macosRelease().version})`
: os.release();
return {
directus: {
version,
},
node: {
version: process.versions.node,
uptime: Math.round(process.uptime()),
},
os: {
type: osType,
version: osVersion,
uptime: Math.round(os.uptime()),
totalmem: os.totalmem(),
},
};
}
}

View File

@@ -1,10 +1,10 @@
import AuthService from './authentication';
import ItemsService from './items';
import jwt from 'jsonwebtoken';
import { sendInviteMail } from '../mail';
import { sendInviteMail, sendPasswordResetMail } from '../mail';
import database from '../database';
import argon2 from 'argon2';
import { InvalidPayloadException } from '../exceptions';
import { InvalidPayloadException, ForbiddenException } from '../exceptions';
import { Accountability, PrimaryKey, Item, AbstractServiceOptions } from '../types';
import Knex from 'knex';
import env from '../env';
@@ -48,7 +48,7 @@ export default class UsersService extends ItemsService {
async inviteUser(email: string, role: string) {
await this.service.create({ email, role, status: 'invited' });
const payload = { email };
const payload = { email, scope: 'invite' };
const token = jwt.sign(payload, env.SECRET as string, { expiresIn: '7d' });
const acceptURL = env.PUBLIC_URL + '/admin/accept-invite?token=' + token;
@@ -56,9 +56,14 @@ export default class UsersService extends ItemsService {
}
async acceptInvite(token: string, password: string) {
const { email } = jwt.verify(token, env.SECRET as string) as { email: string };
const { email, scope } = jwt.verify(token, env.SECRET as string) as {
email: string;
scope: string;
};
const user = await database
if (scope !== 'invite') throw new ForbiddenException();
const user = await this.knex
.select('id', 'status')
.from('directus_users')
.where({ email })
@@ -70,13 +75,53 @@ export default class UsersService extends ItemsService {
const passwordHashed = await argon2.hash(password);
await database('directus_users')
await this.knex('directus_users')
.update({ password: passwordHashed, status: 'active' })
.where({ id: user.id });
}
async requestPasswordReset(email: string) {
const user = await this.knex.select('id').from('directus_users').where({ email }).first();
if (!user) throw new ForbiddenException();
const payload = { email, scope: 'password-reset' };
const token = jwt.sign(payload, env.SECRET as string, { expiresIn: '7d' });
const acceptURL = env.PUBLIC_URL + '/admin/reset-password?token=' + token;
await sendPasswordResetMail(email, acceptURL);
}
async resetPassword(token: string, password: string) {
const { email, scope } = jwt.verify(token, env.SECRET as string) as {
email: string;
scope: string;
};
if (scope !== 'password-reset') throw new ForbiddenException();
const user = await this.knex
.select('id', 'status')
.from('directus_users')
.where({ email })
.first();
if (!user || user.status !== 'active') {
throw new ForbiddenException();
}
const passwordHashed = await argon2.hash(password);
await this.knex('directus_users')
.update({ password: passwordHashed, status: 'active' })
.where({ id: user.id });
}
async enableTFA(pk: string) {
const user = await this.knex.select('tfa_secret').from('directus_users').where({ id: pk }).first();
const user = await this.knex
.select('tfa_secret')
.from('directus_users')
.where({ id: pk })
.first();
if (user?.tfa_secret !== null) {
throw new InvalidPayloadException('TFA Secret is already set for this user');