Add ability to share items with people outside the platform (#10663)

* Add directus_shares

* Don't check for usage limit on refresh

* Add all endpoints to the shares controller

* Move route `/auth/shared` to `/shared/auth`

* Add password protection

* Add `share` action in permissions

* Add `shares/:pk/info`

* Start on shared-view

* Add basic styling for full shared view

* Fixed migrations

* Add inline style for shared view

* Allow title override

* Finish /info endpoint for shares

* Add basic UUID validation to share/info endpont

* Add UUID validation to other routes

* Add not found state

* Cleanup /extract/finish share login endpoint

* Cleanup auth

* Added `share_start` and `share_end`

* Add share sidebar details.

* Allow share permissions configuration

* Hide the `new_share` button for unauthorized users

* Fix uses_left displayed value

* Show expired / upcoming shares

* Improved expired/upcoming styling

* Fixed share login query

* Fix check-ip and get-permissions middlewares behaviour when role is null

* Simplify cache key

* Fix typescript linting issues

* Handle app auth flow for shared page

* Fixed /users/me response

* Show when user is authenticated

* Try showing item drawer in shared page

* Improved shared card styling

* Add shares permissions and change share card styling

* Pull in schema/permissions on share

* Create getPermissionForShare file

* Change getPermissionsForShare signature

* Render form + item on share after auth

* Finalize public front end

* Handle fake o2m field in applyQuery

* [WIP]

* New translations en-US.yaml (Bulgarian) (#10585)

* smaller label height (#10587)

* Update to the latest Material Icons (#10573)

The icons are based on https://fonts.google.com/icons

* New translations en-US.yaml (Arabic) (#10593)

* New translations en-US.yaml (Arabic) (#10594)

* New translations en-US.yaml (Portuguese, Brazilian) (#10604)

* New translations en-US.yaml (French) (#10605)

* New translations en-US.yaml (Italian) (#10613)

* fix M2A list not updating (#10617)

* Fix filters

* Add admin filter on m2o role selection

* Add admin filter on m2o role selection

* Add o2m permissions traversing

* Finish relational tree permissions generation

* Handle implicit a2o relation

* Update implicit relation regex

* Fix regex

* Fix implicitRelation unnesting for new regex

* Fix implicitRelation length check

* Rename m2a to a2o internally

* Add auto-gen permissions for a2o

* [WIP] Improve share UX

* Add ctx menu options

* Add share dialog

* Add email notifications

* Tweak endpoint

* Tweak file interface disabled state

* Add nicer invalid state to password input

* Dont return info for expired/upcoming shares

* Tweak disabled state for relational interfaces

* Fix share button for non admin roles

* Show/hide edit/delete based on permissions to shares

* Fix imports of mutationtype

* Resolve (my own) suggestions

* Fix migration for ms sql

* Resolve last suggestion

Co-authored-by: Oreilles <oreilles.github@nitoref.io>
Co-authored-by: Oreilles <33065839+oreilles@users.noreply.github.com>
Co-authored-by: Ben Haynes <ben@rngr.org>
Co-authored-by: Thien Nguyen <72242664+tatthien@users.noreply.github.com>
Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>
This commit is contained in:
Rijk van Zanten
2021-12-23 18:51:59 -05:00
committed by GitHub
parent d947c4f962
commit dbf35a1736
89 changed files with 2422 additions and 376 deletions

View File

@@ -28,6 +28,7 @@ import settingsRouter from './controllers/settings';
import usersRouter from './controllers/users';
import utilsRouter from './controllers/utils';
import webhooksRouter from './controllers/webhooks';
import sharesRouter from './controllers/shares';
import { isInstalled, validateDatabaseConnection, validateDatabaseExtensions, validateMigrations } from './database';
import emitter from './emitter';
import env from './env';
@@ -112,7 +113,7 @@ export default async function createApp(): Promise<express.Application> {
app.use(extractToken);
app.use((req, res, next) => {
app.use((_req, res, next) => {
res.setHeader('X-Powered-By', 'Directus');
next();
});
@@ -121,7 +122,7 @@ export default async function createApp(): Promise<express.Application> {
app.use(cors);
}
app.get('/', (req, res, next) => {
app.get('/', (_req, res, next) => {
if (env.ROOT_REDIRECT) {
res.redirect(env.ROOT_REDIRECT);
} else {
@@ -137,7 +138,7 @@ export default async function createApp(): Promise<express.Application> {
const html = await fse.readFile(adminPath, 'utf8');
const htmlWithBase = html.replace(/<base \/>/, `<base href="${adminUrl.toString({ rootRelative: true })}/" />`);
const noCacheIndexHtmlHandler = (req: Request, res: Response) => {
const noCacheIndexHtmlHandler = (_req: Request, res: Response) => {
res.setHeader('Cache-Control', 'no-cache');
res.send(htmlWithBase);
};
@@ -190,6 +191,7 @@ export default async function createApp(): Promise<express.Application> {
app.use('/roles', rolesRouter);
app.use('/server', serverRouter);
app.use('/settings', settingsRouter);
app.use('/shares', sharesRouter);
app.use('/users', usersRouter);
app.use('/utils', utilsRouter);
app.use('/webhooks', webhooksRouter);

View File

@@ -1,5 +1,5 @@
import { Knex } from 'knex';
import { AuthDriverOptions, SchemaOverview, User, SessionData } from '../types';
import { AuthDriverOptions, SchemaOverview, User } from '../types';
export abstract class AuthDriver {
knex: Knex;
@@ -36,30 +36,26 @@ export abstract class AuthDriver {
* @throws InvalidCredentialsException
* @returns Data to be stored with the session
*/
async login(_user: User, _payload: Record<string, any>): Promise<SessionData> {
/* Optional, though should probably be set */
return null;
async login(_user: User, _payload: Record<string, any>): Promise<void> {
return;
}
/**
* Handle user session refresh
*
* @param _user User information
* @param _sessionData Session data
* @throws InvalidCredentialsException
*/
async refresh(_user: User, sessionData: SessionData): Promise<SessionData> {
/* Optional */
return sessionData;
async refresh(_user: User): Promise<void> {
return;
}
/**
* Handle user session termination
*
* @param _user User information
* @param _sessionData Session data
*/
async logout(_user: User, _sessionData: SessionData): Promise<void> {
/* Optional */
async logout(_user: User): Promise<void> {
return;
}
}

View File

@@ -14,7 +14,7 @@ import ldap, {
import ms from 'ms';
import Joi from 'joi';
import { AuthDriver } from '../auth';
import { AuthDriverOptions, User, SessionData } from '../../types';
import { AuthDriverOptions, User } from '../../types';
import {
InvalidCredentialsException,
InvalidPayloadException,
@@ -318,12 +318,11 @@ export class LDAPAuthDriver extends AuthDriver {
});
}
async login(user: User, payload: Record<string, any>): Promise<SessionData> {
async login(user: User, payload: Record<string, any>): Promise<void> {
await this.verify(user, payload.password);
return null;
}
async refresh(user: User): Promise<SessionData> {
async refresh(user: User): Promise<void> {
await this.validateBindClient();
const userInfo = await this.fetchUserInfo(user.external_identifier!);
@@ -331,7 +330,6 @@ export class LDAPAuthDriver extends AuthDriver {
if (userInfo?.userAccountControl && userInfo.userAccountControl & INVALID_ACCOUNT_FLAGS) {
throw new InvalidCredentialsException();
}
return null;
}
}

View File

@@ -1,14 +1,14 @@
import { Router } from 'express';
import argon2 from 'argon2';
import ms from 'ms';
import Joi from 'joi';
import { AuthDriver } from '../auth';
import { User, SessionData } from '../../types';
import { User } from '../../types';
import { InvalidCredentialsException, InvalidPayloadException } from '../../exceptions';
import { AuthenticationService } from '../../services';
import asyncHandler from '../../utils/async-handler';
import env from '../../env';
import { respond } from '../../middleware/respond';
import { COOKIE_OPTIONS } from '../../constants';
export class LocalAuthDriver extends AuthDriver {
async getUserID(payload: Record<string, any>): Promise<string> {
@@ -35,16 +35,15 @@ export class LocalAuthDriver extends AuthDriver {
}
}
async login(user: User, payload: Record<string, any>): Promise<SessionData> {
async login(user: User, payload: Record<string, any>): Promise<void> {
await this.verify(user, payload.password);
return null;
}
}
export function createLocalAuthRouter(provider: string): Router {
const router = Router();
const loginSchema = Joi.object({
const userLoginSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().required(),
mode: Joi.string().valid('cookie', 'json'),
@@ -65,7 +64,7 @@ export function createLocalAuthRouter(provider: string): Router {
schema: req.schema,
});
const { error } = loginSchema.validate(req.body);
const { error } = userLoginSchema.validate(req.body);
if (error) {
throw new InvalidPayloadException(error.message);
@@ -88,13 +87,7 @@ export function createLocalAuthRouter(provider: string): Router {
}
if (mode === 'cookie') {
res.cookie(env.REFRESH_TOKEN_COOKIE_NAME, refreshToken, {
httpOnly: true,
domain: env.REFRESH_TOKEN_COOKIE_DOMAIN,
maxAge: ms(env.REFRESH_TOKEN_TTL as string),
secure: env.REFRESH_TOKEN_COOKIE_SECURE ?? false,
sameSite: (env.REFRESH_TOKEN_COOKIE_SAME_SITE as 'lax' | 'strict' | 'none') || 'strict',
});
res.cookie(env.REFRESH_TOKEN_COOKIE_NAME, refreshToken, COOKIE_OPTIONS);
}
res.locals.payload = payload;

View File

@@ -6,7 +6,7 @@ import { LocalAuthDriver } from './local';
import { getAuthProvider } from '../../auth';
import env from '../../env';
import { AuthenticationService, UsersService } from '../../services';
import { AuthDriverOptions, User, AuthData, SessionData } from '../../types';
import { AuthDriverOptions, User, AuthData } from '../../types';
import {
InvalidCredentialsException,
ServiceUnavailableException,
@@ -159,11 +159,11 @@ export class OAuth2AuthDriver extends LocalAuthDriver {
return (await this.fetchUserId(identifier)) as string;
}
async login(user: User): Promise<SessionData> {
return this.refresh(user, null);
async login(user: User): Promise<void> {
return this.refresh(user);
}
async refresh(user: User, sessionData: SessionData): Promise<SessionData> {
async refresh(user: User): Promise<void> {
let authData = user.auth_data as AuthData;
if (typeof authData === 'string') {
@@ -187,8 +187,6 @@ export class OAuth2AuthDriver extends LocalAuthDriver {
throw handleError(e);
}
}
return sessionData;
}
}

View File

@@ -6,7 +6,7 @@ import { LocalAuthDriver } from './local';
import { getAuthProvider } from '../../auth';
import env from '../../env';
import { AuthenticationService, UsersService } from '../../services';
import { AuthDriverOptions, User, AuthData, SessionData } from '../../types';
import { AuthDriverOptions, User, AuthData } from '../../types';
import {
InvalidCredentialsException,
ServiceUnavailableException,
@@ -167,11 +167,11 @@ export class OpenIDAuthDriver extends LocalAuthDriver {
return (await this.fetchUserId(identifier)) as string;
}
async login(user: User): Promise<SessionData> {
return this.refresh(user, null);
async login(user: User): Promise<void> {
return this.refresh(user);
}
async refresh(user: User, sessionData: SessionData): Promise<SessionData> {
async refresh(user: User): Promise<void> {
let authData = user.auth_data as AuthData;
if (typeof authData === 'string') {
@@ -196,8 +196,6 @@ export class OpenIDAuthDriver extends LocalAuthDriver {
throw handleError(e);
}
}
return sessionData;
}
}

View File

@@ -122,7 +122,7 @@ export async function apply(snapshotPath: string, options?: { yes: boolean }): P
continue;
}
// Related collection doesn't exist for m2a relationship types
// Related collection doesn't exist for a2o relationship types
if (related_collection) {
message += `-> ${related_collection}`;
}

View File

@@ -1,4 +1,6 @@
import { TransformationParams } from './types';
import env from './env';
import ms from 'ms';
export const SYSTEM_ASSET_ALLOW_LIST: TransformationParams[] = [
{
@@ -40,8 +42,18 @@ export const ASSET_TRANSFORM_QUERY_KEYS = [
export const FILTER_VARIABLES = ['$NOW', '$CURRENT_USER', '$CURRENT_ROLE'];
export const ALIAS_TYPES = ['alias', 'o2m', 'm2m', 'm2a', 'files', 'translations'];
export const ALIAS_TYPES = ['alias', 'o2m', 'm2m', 'm2a', 'o2a', 'files', 'translations'];
export const DEFAULT_AUTH_PROVIDER = 'default';
export const COLUMN_TRANSFORMS = ['year', 'month', 'day', 'weekday', 'hour', 'minute', 'second'];
export const UUID_REGEX = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
export const COOKIE_OPTIONS = {
httpOnly: true,
domain: env.REFRESH_TOKEN_COOKIE_DOMAIN,
maxAge: ms(env.REFRESH_TOKEN_TTL as string),
secure: env.REFRESH_TOKEN_COOKIE_SECURE ?? false,
sameSite: (env.REFRESH_TOKEN_COOKIE_SAME_SITE as 'lax' | 'strict' | 'none') || 'strict',
};

View File

@@ -55,6 +55,7 @@ const readHandler = asyncHandler(async (req, res, next) => {
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,

View File

@@ -0,0 +1,287 @@
import express from 'express';
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
import { respond } from '../middleware/respond';
import useCollection from '../middleware/use-collection';
import { validateBatch } from '../middleware/validate-batch';
import { SharesService } from '../services';
import { PrimaryKey } from '../types';
import asyncHandler from '../utils/async-handler';
import { UUID_REGEX, COOKIE_OPTIONS } from '../constants';
import Joi from 'joi';
import env from '../env';
const router = express.Router();
router.use(useCollection('directus_shares'));
const sharedLoginSchema = Joi.object({
share: Joi.string().required(),
password: Joi.string(),
}).unknown();
router.post(
'/auth',
asyncHandler(async (req, res, next) => {
// This doesn't use accountability, as the user isn't logged in at this point
const service = new SharesService({
schema: req.schema,
});
const { error } = sharedLoginSchema.validate(req.body);
if (error) {
throw new InvalidPayloadException(error.message);
}
const { accessToken, refreshToken, expires } = await service.login(req.body);
res.cookie(env.REFRESH_TOKEN_COOKIE_NAME, refreshToken, COOKIE_OPTIONS);
res.locals.payload = { data: { access_token: accessToken, expires } };
return next();
}),
respond
);
const sharedInviteSchema = Joi.object({
share: Joi.string().required(),
emails: Joi.array().items(Joi.string()),
}).unknown();
router.post(
'/invite',
asyncHandler(async (req, res, next) => {
const service = new SharesService({
schema: req.schema,
accountability: req.accountability,
});
const { error } = sharedInviteSchema.validate(req.body);
if (error) {
throw new InvalidPayloadException(error.message);
}
await service.invite(req.body);
return next();
}),
respond
);
router.post(
'/',
asyncHandler(async (req, res, next) => {
const service = new SharesService({
accountability: req.accountability,
schema: req.schema,
});
const savedKeys: PrimaryKey[] = [];
if (Array.isArray(req.body)) {
const keys = await service.createMany(req.body);
savedKeys.push(...keys);
} else {
const key = await service.createOne(req.body);
savedKeys.push(key);
}
try {
if (Array.isArray(req.body)) {
const items = await service.readMany(savedKeys, req.sanitizedQuery);
res.locals.payload = { data: items };
} else {
const item = await service.readOne(savedKeys[0], req.sanitizedQuery);
res.locals.payload = { data: item };
}
} catch (error) {
if (error instanceof ForbiddenException) {
return next();
}
throw error;
}
return next();
}),
respond
);
const readHandler = asyncHandler(async (req, res, next) => {
const service = new SharesService({
accountability: req.accountability,
schema: req.schema,
});
const records = await service.readByQuery(req.sanitizedQuery);
res.locals.payload = { data: records || null };
return next();
});
router.get('/', validateBatch('read'), readHandler, respond);
router.search('/', validateBatch('read'), readHandler, respond);
router.get(
`/info/:pk(${UUID_REGEX})`,
asyncHandler(async (req, res, next) => {
const service = new SharesService({
schema: req.schema,
});
const record = await service.readOne(req.params.pk, {
fields: ['id', 'collection', 'item', 'password', 'max_uses', 'times_used', 'date_start', 'date_end'],
filter: {
_and: [
{
_or: [
{
date_start: {
_lte: '$NOW',
},
},
{
date_start: {
_null: true,
},
},
],
},
{
_or: [
{
date_end: {
_gte: '$NOW',
},
},
{
date_end: {
_null: true,
},
},
],
},
],
},
});
res.locals.payload = { data: record || null };
return next();
}),
respond
);
router.get(
`/:pk(${UUID_REGEX})`,
asyncHandler(async (req, res, next) => {
const service = new SharesService({
accountability: req.accountability,
schema: req.schema,
});
const record = await service.readOne(req.params.pk, req.sanitizedQuery);
res.locals.payload = { data: record || null };
return next();
}),
respond
);
router.patch(
'/',
validateBatch('update'),
asyncHandler(async (req, res, next) => {
const service = new SharesService({
accountability: req.accountability,
schema: req.schema,
});
let keys: PrimaryKey[] = [];
if (req.body.keys) {
keys = await service.updateMany(req.body.keys, req.body.data);
} else {
keys = await service.updateByQuery(req.body.query, req.body.data);
}
try {
const result = await service.readMany(keys, req.sanitizedQuery);
res.locals.payload = { data: result };
} catch (error) {
if (error instanceof ForbiddenException) {
return next();
}
throw error;
}
return next();
}),
respond
);
router.patch(
`/:pk(${UUID_REGEX})`,
asyncHandler(async (req, res, next) => {
const service = new SharesService({
accountability: req.accountability,
schema: req.schema,
});
const primaryKey = await service.updateOne(req.params.pk, req.body);
try {
const item = await service.readOne(primaryKey, req.sanitizedQuery);
res.locals.payload = { data: item || null };
} catch (error) {
if (error instanceof ForbiddenException) {
return next();
}
throw error;
}
return next();
}),
respond
);
router.delete(
'/',
asyncHandler(async (req, _res, next) => {
const service = new SharesService({
accountability: req.accountability,
schema: req.schema,
});
if (Array.isArray(req.body)) {
await service.deleteMany(req.body);
} else if (req.body.keys) {
await service.deleteMany(req.body.keys);
} else {
await service.deleteByQuery(req.body.query);
}
return next();
}),
respond
);
router.delete(
`/:pk(${UUID_REGEX})`,
asyncHandler(async (req, _res, next) => {
const service = new SharesService({
accountability: req.accountability,
schema: req.schema,
});
await service.deleteOne(req.params.pk);
return next();
}),
respond
);
export default router;

View File

@@ -56,6 +56,7 @@ const readHandler = asyncHandler(async (req, res, next) => {
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,
@@ -74,6 +75,19 @@ router.search('/', validateBatch('read'), readHandler, respond);
router.get(
'/me',
asyncHandler(async (req, res, next) => {
if (req.accountability?.share_scope) {
const user = {
share: req.accountability?.share,
role: {
id: req.accountability.role,
admin_access: false,
app_access: false,
},
};
res.locals.payload = { data: user };
return next();
}
if (!req.accountability?.user) {
throw new InvalidCredentialsException();
}
@@ -140,7 +154,7 @@ router.patch(
router.patch(
'/me/track/page',
asyncHandler(async (req, res, next) => {
asyncHandler(async (req, _res, next) => {
if (!req.accountability?.user) {
throw new InvalidCredentialsException();
}
@@ -219,7 +233,7 @@ router.patch(
router.delete(
'/',
validateBatch('delete'),
asyncHandler(async (req, res, next) => {
asyncHandler(async (req, _res, next) => {
const service = new UsersService({
accountability: req.accountability,
schema: req.schema,
@@ -240,7 +254,7 @@ router.delete(
router.delete(
'/:pk',
asyncHandler(async (req, res, next) => {
asyncHandler(async (req, _res, next) => {
const service = new UsersService({
accountability: req.accountability,
schema: req.schema,
@@ -261,7 +275,7 @@ const inviteSchema = Joi.object({
router.post(
'/invite',
asyncHandler(async (req, res, next) => {
asyncHandler(async (req, _res, next) => {
const { error } = inviteSchema.validate(req.body);
if (error) throw new InvalidPayloadException(error.message);
@@ -282,7 +296,7 @@ const acceptInviteSchema = Joi.object({
router.post(
'/invite/accept',
asyncHandler(async (req, res, next) => {
asyncHandler(async (req, _res, next) => {
const { error } = acceptInviteSchema.validate(req.body);
if (error) throw new InvalidPayloadException(error.message);
const service = new UsersService({
@@ -327,7 +341,7 @@ router.post(
router.post(
'/me/tfa/enable/',
asyncHandler(async (req, res, next) => {
asyncHandler(async (req, _res, next) => {
if (!req.accountability?.user) {
throw new InvalidCredentialsException();
}
@@ -354,7 +368,7 @@ router.post(
router.post(
'/me/tfa/disable',
asyncHandler(async (req, res, next) => {
asyncHandler(async (req, _res, next) => {
if (!req.accountability?.user) {
throw new InvalidCredentialsException();
}

View File

@@ -0,0 +1,38 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('directus_shares', (table) => {
table.uuid('id').primary();
table.string('name');
table.string('collection', 64).references('collection').inTable('directus_collections').onDelete('CASCADE');
table.string('item');
table.uuid('role').references('id').inTable('directus_roles').onDelete('CASCADE');
table.string('password');
table.uuid('user_created').references('id').inTable('directus_users').onDelete('SET NULL');
table.timestamp('date_created').defaultTo(knex.fn.now());
table.timestamp('date_start');
table.timestamp('date_end');
table.integer('times_used').defaultTo(0);
table.integer('max_uses');
});
await knex.schema.alterTable('directus_sessions', (table) => {
table.dropColumn('data');
});
await knex.schema.alterTable('directus_sessions', (table) => {
table.uuid('user').nullable().alter();
table.uuid('share').references('id').inTable('directus_shares').onDelete('CASCADE');
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('directus_sessions', (table) => {
table.uuid('user').notNullable().alter();
table.json('data');
table.dropForeign('share');
table.dropColumn('share');
});
await knex.schema.dropTable('directus_shares');
}

View File

@@ -46,7 +46,7 @@ export default async function runAST(
const knex = options?.knex || getDatabase();
if (ast.type === 'm2a') {
if (ast.type === 'a2o') {
const results: { [collection: string]: null | Item | Item[] } = {};
for (const collection of ast.names) {
@@ -141,7 +141,7 @@ async function parseCurrentLevel(
columnsToSelectInternal.push(child.fieldKey);
}
if (child.type === 'm2a') {
if (child.type === 'a2o') {
columnsToSelectInternal.push(child.relation.field);
columnsToSelectInternal.push(child.relation.meta!.one_collection_field!);
}
@@ -263,7 +263,7 @@ function applyParentFilters(
} else {
nestedNode.query.union = [foreignField, foreignIds];
}
} else if (nestedNode.type === 'm2a') {
} else if (nestedNode.type === 'a2o') {
const keysPerCollection: { [collection: string]: (string | number)[] } = {};
for (const parentItem of parentItems) {
@@ -346,7 +346,7 @@ function mergeWithParentItems(
parentItem[nestedNode.fieldKey] = itemChildren.length > 0 ? itemChildren : [];
}
} else if (nestedNode.type === 'm2a') {
} else if (nestedNode.type === 'a2o') {
for (const parentItem of parentItems) {
if (!nestedNode.relation.meta?.one_collection_field) {
parentItem[nestedNode.fieldKey] = null;
@@ -381,7 +381,7 @@ function removeTemporaryFields(
const rawItems = cloneDeep(toArray(rawItem));
const items: Item[] = [];
if (ast.type === 'm2a') {
if (ast.type === 'a2o') {
const fields: Record<string, string[]> = {};
const nestedCollectionNodes: Record<string, NestedCollectionNode[]> = {};

View File

@@ -13,18 +13,6 @@
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:
@@ -60,9 +48,6 @@
user:
_eq: $CURRENT_USER
- collection: directus_relations
action: read
- collection: directus_roles
action: read
permissions:

View File

@@ -11,6 +11,10 @@ const defaults: Partial<Permission> = {
system: true,
};
const schemaPermissionsRaw = requireYAML(require.resolve('./schema-access-permissions.yaml')) as Permission[];
const permissions = requireYAML(require.resolve('./app-access-permissions.yaml')) as Permission[];
export const appAccessMinimalPermissions: Permission[] = permissions.map((row) => merge({}, defaults, row));
export const schemaPermissions: Permission[] = schemaPermissionsRaw.map((row) => merge({}, defaults, row));
export const appAccessMinimalPermissions: Permission[] = [...schemaPermissions, ...permissions].map((row) =>
merge({}, defaults, row)
);

View File

@@ -0,0 +1,17 @@
# 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_collections
action: read
- collection: directus_fields
action: read
- collection: directus_permissions
action: read
permissions:
role:
_eq: $CURRENT_ROLE
- collection: directus_relations
action: read

View File

@@ -65,3 +65,6 @@ data:
note: $t:directus_collection.directus_panels
- collection: directus_notifications
note: $t:directus_collection.directus_notifications
- collection: directus_shares
icon: share
note: $t:directus_collection.directus_shares

View File

@@ -11,4 +11,4 @@ fields:
width: half
- field: user_agent
width: half
- field: data
- field: share

View File

@@ -0,0 +1,73 @@
table: directus_shares
fields:
- field: id
special: uuid
readonly: true
hidden: true
- field: name
- field: collection
width: half
hidden: true
- field: item
width: half
hidden: true
- field: role
interface: select-dropdown-m2o
width: half
options:
template: '{{name}}'
filter:
admin_access:
_eq: false
- field: password
special: hash,conceal
interface: input-hash
options:
iconRight: lock
masked: true
width: half
- field: date_start
width: half
- field: date_end
width: half
- field: max_uses
width: half
- field: times_used
width: half
readonly: true
- field: date_created
special: date-created
width: half
readonly: true
conditions:
- name: notCreatedYet
rule:
id:
_null: true
hidden: true
- field: user_created
special: user-created
interface: select-dropdown-m2o
width: half
display: user
options:
template: '{{avatar.$thumbnail}} {{first_name}} {{last_name}}'
readonly: true
conditions:
- name: notCreatedYet
rule:
id:
_null: true
hidden: true

View File

@@ -12,6 +12,9 @@ defaults:
sort_field: null
data:
- many_collection: directus_collections
many_field: group
one_collection: directus_collections
- many_collection: directus_users
many_field: role
one_collection: directus_roles
@@ -73,6 +76,9 @@ data:
- many_collection: directus_sessions
many_field: user
one_collection: directus_users
- many_collection: directus_sessions
many_field: share
one_collection: directus_shares
- many_collection: directus_settings
many_field: storage_default_folder
one_collection: directus_folders
@@ -88,3 +94,12 @@ data:
- many_collection: directus_notifications
many_field: sender
one_collection: directus_users
- many_collection: directus_shares
many_field: role
one_collection: directus_roles
- many_collection: directus_shares
many_field: collection
one_collection: directus_collections
- many_collection: directus_shares
many_field: user_created
one_collection: directus_users

View File

@@ -3,6 +3,7 @@ import jwt, { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
import getDatabase from '../database';
import env from '../env';
import { InvalidCredentialsException } from '../exceptions';
import { DirectusTokenPayload } from '../types';
import asyncHandler from '../utils/async-handler';
import isDirectusJWT from '../utils/is-directus-jwt';
@@ -23,10 +24,10 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
if (req.token) {
if (isDirectusJWT(req.token)) {
let payload: { id: string };
let payload: DirectusTokenPayload;
try {
payload = jwt.verify(req.token, env.SECRET as string, { issuer: 'directus' }) as { id: string };
payload = jwt.verify(req.token, env.SECRET as string, { issuer: 'directus' }) as DirectusTokenPayload;
} catch (err: any) {
if (err instanceof TokenExpiredError) {
throw new InvalidCredentialsException('Token expired.');
@@ -37,24 +38,12 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
}
}
const user = await database
.select('directus_users.role', 'directus_roles.admin_access', 'directus_roles.app_access')
.from('directus_users')
.leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id')
.where({
'directus_users.id': payload.id,
status: 'active',
})
.first();
if (!user) {
throw new InvalidCredentialsException();
}
req.accountability.share = payload.share;
req.accountability.share_scope = payload.share_scope;
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;
req.accountability.role = payload.role;
req.accountability.admin = payload.admin_access === true || payload.admin_access == 1;
req.accountability.app = payload.app_access === true || payload.app_access == 1;
} else {
// Try finding the user with the provided token
const user = await database

View File

@@ -3,14 +3,16 @@ import getDatabase from '../database';
import { InvalidIPException } from '../exceptions';
import asyncHandler from '../utils/async-handler';
export const checkIP: RequestHandler = asyncHandler(async (req, res, next) => {
export const checkIP: RequestHandler = asyncHandler(async (req, _res, next) => {
const database = getDatabase();
const role = await database
.select('ip_access')
.from('directus_roles')
.where({ id: req.accountability!.role })
.first();
const query = database.select('ip_access').from('directus_roles');
if (req.accountability!.role) {
query.where({ id: req.accountability!.role });
} else {
query.whereNull('id');
}
const role = await query.first();
const ipAllowlist = (role?.ip_access || '').split(',').filter((ip: string) => ip);

View File

@@ -1,5 +1,6 @@
import { AbstractServiceOptions, PrimaryKey, Item, Action } from '../types';
import { ItemsService, MutationOptions } from './index';
import { ItemsService } from './items';
import { MutationOptions } from '../types';
import { NotificationsService } from './notifications';
import { UsersService } from './users';
import { AuthorizationService } from './authorization';

View File

@@ -11,13 +11,20 @@ import { InvalidCredentialsException, InvalidOTPException, UserSuspendedExceptio
import { createRateLimiter } from '../rate-limiter';
import { ActivityService } from './activity';
import { TFAService } from './tfa';
import { AbstractServiceOptions, Action, SchemaOverview, Session, User, SessionData } from '../types';
import {
AbstractServiceOptions,
Action,
SchemaOverview,
Session,
User,
DirectusTokenPayload,
LoginResult,
} from '../types';
import { Accountability } from '@directus/shared/types';
import { SettingsService } from './settings';
import { clone, cloneDeep, omit } from 'lodash';
import { clone, cloneDeep } from 'lodash';
import { performance } from 'perf_hooks';
import { stall } from '../utils/stall';
import logger from '../logger';
const loginAttemptsLimiter = createRateLimiter({ duration: 0 });
@@ -44,7 +51,7 @@ export class AuthenticationService {
providerName: string = DEFAULT_AUTH_PROVIDER,
payload: Record<string, any>,
otp?: string
): Promise<{ accessToken: any; refreshToken: any; expires: any; id?: any }> {
): Promise<LoginResult> {
const STALL_TIME = 100;
const timeStart = performance.now();
@@ -52,21 +59,24 @@ export class AuthenticationService {
const user = await this.knex
.select<User & { tfa_secret: string | null }>(
'id',
'first_name',
'last_name',
'email',
'password',
'status',
'role',
'tfa_secret',
'provider',
'external_identifier',
'auth_data'
'u.id',
'u.first_name',
'u.last_name',
'u.email',
'u.password',
'u.status',
'u.role',
'r.admin_access',
'r.app_access',
'u.tfa_secret',
'u.provider',
'u.external_identifier',
'u.auth_data'
)
.from('directus_users')
.where('id', await provider.getUserID(cloneDeep(payload)))
.andWhere('provider', providerName)
.from('directus_users as u')
.innerJoin('directus_roles as r', 'u.role', 'r.id')
.where('u.id', await provider.getUserID(cloneDeep(payload)))
.andWhere('u.provider', providerName)
.first();
const updatedPayload = await emitter.emitFilter(
@@ -136,10 +146,8 @@ export class AuthenticationService {
}
}
let sessionData: SessionData = null;
try {
sessionData = await provider.login(clone(user), cloneDeep(updatedPayload));
await provider.login(clone(user), cloneDeep(updatedPayload));
} catch (e) {
emitStatus('fail');
await stall(STALL_TIME, timeStart);
@@ -165,6 +173,9 @@ export class AuthenticationService {
const tokenPayload = {
id: user.id,
role: user.role,
app_access: user.app_access,
admin_access: user.admin_access,
};
const customClaims = await emitter.emitFilter(
@@ -197,7 +208,6 @@ export class AuthenticationService {
expires: refreshTokenExpiration,
ip: this.accountability?.ip,
user_agent: this.accountability?.userAgent,
data: sessionData && JSON.stringify(sessionData),
});
await this.knex('directus_sessions').delete().where('expires', '<', new Date());
@@ -237,55 +247,89 @@ export class AuthenticationService {
}
const record = await this.knex
.select<Session & User>(
's.expires',
's.data',
'u.id',
'u.first_name',
'u.last_name',
'u.email',
'u.password',
'u.status',
'u.role',
'u.provider',
'u.external_identifier',
'u.auth_data'
)
.from('directus_sessions as s')
.innerJoin('directus_users as u', 's.user', 'u.id')
.select({
session_expires: 's.expires',
user_id: 'u.id',
user_first_name: 'u.first_name',
user_last_name: 'u.last_name',
user_email: 'u.email',
user_password: 'u.password',
user_status: 'u.status',
user_provider: 'u.provider',
user_external_identifier: 'u.external_identifier',
user_auth_data: 'u.auth_data',
role_id: 'r.id',
role_admin_access: 'r.admin_access',
role_app_access: 'r.app_access',
share_id: 'd.id',
share_item: 'd.item',
share_role: 'd.role',
share_collection: 'd.collection',
share_start: 'd.date_start',
share_end: 'd.date_end',
share_times_used: 'd.times_used',
share_max_uses: 'd.max_uses',
})
.from('directus_sessions AS s')
.leftJoin('directus_users AS u', 's.user', 'u.id')
.leftJoin('directus_shares AS d', 's.share', 'd.id')
.joinRaw('LEFT JOIN directus_roles AS r ON r.id IN (u.role, d.role)')
.where('s.token', refreshToken)
.andWhere('s.expires', '>=', this.knex.fn.now())
.andWhere((subQuery) => {
subQuery.whereNull('d.date_end').orWhere('d.date_end', '>=', this.knex.fn.now());
})
.andWhere((subQuery) => {
subQuery.whereNull('d.date_start').orWhere('d.date_start', '<=', this.knex.fn.now());
})
.first();
if (!record || record.expires < new Date()) {
if (!record || (!record.share_id && !record.user_id)) {
throw new InvalidCredentialsException();
}
let { data: sessionData } = record;
const user = omit(record, 'data');
if (record.user_id) {
const provider = getAuthProvider(record.user_provider);
if (typeof sessionData === 'string') {
try {
sessionData = JSON.parse(sessionData);
} catch {
logger.warn(`Session data isn't valid JSON: ${sessionData}`);
}
await provider.refresh({
id: record.user_id,
first_name: record.user_first_name,
last_name: record.user_last_name,
email: record.user_email,
password: record.user_password,
status: record.user_status,
provider: record.user_provider,
external_identifier: record.user_external_identifier,
auth_data: record.user_auth_data,
role: record.role_id,
app_access: record.role_app_access,
admin_access: record.role_admin_access,
});
}
const provider = getAuthProvider(user.provider);
const newSessionData = await provider.refresh(clone(user), sessionData as SessionData);
const tokenPayload = {
id: user.id,
const tokenPayload: DirectusTokenPayload = {
id: record.user_id,
role: record.role_id,
app_access: record.role_app_access,
admin_access: record.role_admin_access,
};
if (record.share_id) {
tokenPayload.share = record.share_id;
tokenPayload.role = record.share_role;
tokenPayload.share_scope = {
collection: record.share_collection,
item: record.share_item,
};
}
const customClaims = await emitter.emitFilter(
'auth.jwt',
tokenPayload,
{
status: 'pending',
user: user?.id,
provider: user.provider,
user: record.user_id,
provider: record.user_provider,
type: 'refresh',
},
{
@@ -307,17 +351,18 @@ export class AuthenticationService {
.update({
token: newRefreshToken,
expires: refreshTokenExpiration,
data: newSessionData && JSON.stringify(newSessionData),
})
.where({ token: refreshToken });
await this.knex('directus_users').update({ last_access: new Date() }).where({ id: user.id });
if (record.user_id) {
await this.knex('directus_users').update({ last_access: new Date() }).where({ id: record.user_id });
}
return {
accessToken,
refreshToken: newRefreshToken,
expires: ms(env.ACCESS_TOKEN_TTL as string),
id: user.id,
id: record.user_id,
};
}
@@ -333,8 +378,7 @@ export class AuthenticationService {
'u.role',
'u.provider',
'u.external_identifier',
'u.auth_data',
's.data'
'u.auth_data'
)
.from('directus_sessions as s')
.innerJoin('directus_users as u', 's.user', 'u.id')
@@ -342,19 +386,10 @@ export class AuthenticationService {
.first();
if (record) {
let { data: sessionData } = record;
const user = omit(record, 'data');
if (typeof sessionData === 'string') {
try {
sessionData = JSON.parse(sessionData);
} catch {
logger.warn(`Session data isn't valid JSON: ${sessionData}`);
}
}
const user = record;
const provider = getAuthProvider(user.provider);
await provider.logout(clone(user), sessionData as SessionData);
await provider.logout(clone(user));
await this.knex.delete().from('directus_sessions').where('token', refreshToken);
}

View File

@@ -66,7 +66,7 @@ export class AuthorizationService {
function getCollectionsFromAST(ast: AST | NestedCollectionNode): { collection: string; field: string }[] {
const collections = [];
if (ast.type === 'm2a') {
if (ast.type === 'a2o') {
collections.push(...ast.names.map((name) => ({ collection: name, field: ast.fieldKey })));
for (const children of Object.values(ast.children)) {
@@ -94,7 +94,7 @@ export class AuthorizationService {
function validateFields(ast: AST | NestedCollectionNode | FieldNode) {
if (ast.type !== 'field') {
if (ast.type === 'm2a') {
if (ast.type === 'a2o') {
for (const [collection, children] of Object.entries(ast.children)) {
checkFields(collection, children, ast.query?.[collection]?.aggregate);
}
@@ -144,7 +144,7 @@ export class AuthorizationService {
accountability: Accountability | null
): AST | NestedCollectionNode | FieldNode {
if (ast.type !== 'field') {
if (ast.type === 'm2a') {
if (ast.type === 'a2o') {
const collections = Object.keys(ast.children);
for (const collection of collections) {

View File

@@ -7,9 +7,9 @@ import { systemCollectionRows } from '../database/system-data/collections';
import env from '../env';
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
import { FieldsService } from '../services/fields';
import { ItemsService, MutationOptions } from '../services/items';
import { ItemsService } from '../services/items';
import Keyv from 'keyv';
import { AbstractServiceOptions, Collection, CollectionMeta, SchemaOverview } from '../types';
import { AbstractServiceOptions, Collection, CollectionMeta, SchemaOverview, MutationOptions } from '../types';
import { Accountability, FieldMeta, RawField } from '@directus/shared/types';
import { Table } from 'knex-schema-inspector/dist/types/table';
@@ -432,11 +432,11 @@ export class CollectionsService {
}
}
const m2aRelationsThatIncludeThisCollection = this.schema.relations.filter((relation) => {
const a2oRelationsThatIncludeThisCollection = this.schema.relations.filter((relation) => {
return relation.meta?.one_allowed_collections?.includes(collectionKey);
});
for (const relation of m2aRelationsThatIncludeThisCollection) {
for (const relation of a2oRelationsThatIncludeThisCollection) {
const newAllowedCollections = relation
.meta!.one_allowed_collections!.filter((collection) => collectionKey !== collection)
.join(',');

View File

@@ -11,9 +11,9 @@ import env from '../env';
import { ForbiddenException, ServiceUnavailableException } from '../exceptions';
import logger from '../logger';
import storage from '../storage';
import { AbstractServiceOptions, File, PrimaryKey } from '../types';
import { AbstractServiceOptions, File, PrimaryKey, MutationOptions } from '../types';
import { toArray } from '@directus/shared/utils';
import { ItemsService, MutationOptions } from './items';
import { ItemsService } from './items';
export class FilesService extends ItemsService {
constructor(options: AbstractServiceOptions) {

View File

@@ -72,6 +72,7 @@ import { RevisionsService } from './revisions';
import { RolesService } from './roles';
import { ServerService } from './server';
import { SettingsService } from './settings';
import { SharesService } from './shares';
import { SpecificationService } from './specifications';
import { TFAService } from './tfa';
import { UsersService } from './users';
@@ -188,13 +189,21 @@ export class GraphQLService {
const schema = {
read:
this.accountability?.admin === true ? this.schema : reduceSchema(this.schema, this.accountability, ['read']),
this.accountability?.admin === true
? this.schema
: reduceSchema(this.schema, this.accountability?.permissions || null, ['read']),
create:
this.accountability?.admin === true ? this.schema : reduceSchema(this.schema, this.accountability, ['create']),
this.accountability?.admin === true
? this.schema
: reduceSchema(this.schema, this.accountability?.permissions || null, ['create']),
update:
this.accountability?.admin === true ? this.schema : reduceSchema(this.schema, this.accountability, ['update']),
this.accountability?.admin === true
? this.schema
: reduceSchema(this.schema, this.accountability?.permissions || null, ['update']),
delete:
this.accountability?.admin === true ? this.schema : reduceSchema(this.schema, this.accountability, ['delete']),
this.accountability?.admin === true
? this.schema
: reduceSchema(this.schema, this.accountability?.permissions || null, ['delete']),
};
const { ReadCollectionTypes } = getReadableTypes();
@@ -1536,6 +1545,8 @@ export class GraphQLService {
return new UsersService(opts);
case 'directus_webhooks':
return new WebhooksService(opts);
case 'directus_shares':
return new SharesService(opts);
default:
return new ItemsService(collection, opts);
}

View File

@@ -27,3 +27,4 @@ export * from './tfa';
export * from './users';
export * from './utils';
export * from './webhooks';
export * from './shares';

View File

@@ -9,7 +9,15 @@ import env from '../env';
import { ForbiddenException } from '../exceptions';
import { translateDatabaseError } from '../exceptions/database/translate';
import { Accountability, Query, PermissionsAction } from '@directus/shared/types';
import { AbstractService, AbstractServiceOptions, Action, Item as AnyItem, PrimaryKey, SchemaOverview } from '../types';
import {
AbstractService,
AbstractServiceOptions,
Action,
Item as AnyItem,
PrimaryKey,
SchemaOverview,
MutationOptions,
} from '../types';
import getASTFromQuery from '../utils/get-ast-from-query';
import { AuthorizationService } from './authorization';
import { PayloadService } from './payload';
@@ -20,23 +28,6 @@ export type QueryOptions = {
permissionsAction?: PermissionsAction;
};
export type MutationOptions = {
/**
* Callback function that's fired whenever a revision is made in the mutation
*/
onRevisionCreate?: (pk: PrimaryKey) => void;
/**
* Flag to disable the auto purging of the cache. Is ignored when CACHE_AUTO_PURGE isn't enabled.
*/
autoPurgeCache?: false;
/**
* Allow disabling the emitting of hooks. Useful if a custom hook is fired (like files.upload)
*/
emitEvents?: boolean;
};
export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractService {
collection: string;
knex: Knex;

View File

@@ -1,6 +1,6 @@
import { UsersService, MailService } from '.';
import { AbstractServiceOptions, PrimaryKey } from '../types';
import { ItemsService, MutationOptions } from './items';
import { AbstractServiceOptions, PrimaryKey, MutationOptions } from '../types';
import { ItemsService } from './items';
import { Notification } from '@directus/shared/types';
import { md } from '../utils/md';

View File

@@ -1,6 +1,6 @@
import { appAccessMinimalPermissions } from '../database/system-data/app-access-permissions';
import { ItemsService, QueryOptions, MutationOptions } from '../services/items';
import { AbstractServiceOptions, Item, PrimaryKey } from '../types';
import { ItemsService, QueryOptions } from '../services/items';
import { AbstractServiceOptions, Item, PrimaryKey, MutationOptions } from '../types';
import { Query, PermissionsAction } from '@directus/shared/types';
import { filterItems } from '../utils/filter-items';
import Keyv from 'keyv';

View File

@@ -1,7 +1,7 @@
import { ForbiddenException, UnprocessableEntityException } from '../exceptions';
import { AbstractServiceOptions, PrimaryKey, Alterations, Item } from '../types';
import { AbstractServiceOptions, MutationOptions, PrimaryKey, Alterations, Item } from '../types';
import { Query } from '@directus/shared/types';
import { ItemsService, MutationOptions } from './items';
import { ItemsService } from './items';
import { PermissionsService } from './permissions';
import { PresetsService } from './presets';
import { UsersService } from './users';

156
api/src/services/shares.ts Normal file
View File

@@ -0,0 +1,156 @@
import {
AbstractServiceOptions,
ShareData,
LoginResult,
Item,
PrimaryKey,
MutationOptions,
DirectusTokenPayload,
} from '../types';
import { ItemsService } from './items';
import argon2 from 'argon2';
import jwt from 'jsonwebtoken';
import ms from 'ms';
import { InvalidCredentialsException, ForbiddenException } from '../exceptions';
import env from '../env';
import { nanoid } from 'nanoid';
import { AuthorizationService } from './authorization';
import { UsersService } from './users';
import { MailService } from './mail';
import { userName } from '../utils/user-name';
import { md } from '../utils/md';
export class SharesService extends ItemsService {
authorizationService: AuthorizationService;
constructor(options: AbstractServiceOptions) {
super('directus_shares', options);
this.authorizationService = new AuthorizationService({
accountability: this.accountability,
knex: this.knex,
schema: this.schema,
});
}
async createOne(data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey> {
await this.authorizationService.checkAccess('share', data.collection, data.item);
return super.createOne(data, opts);
}
async login(payload: Record<string, any>): Promise<LoginResult> {
const record = await this.knex
.select<ShareData>({
share_id: 'id',
share_role: 'role',
share_item: 'item',
share_collection: 'collection',
share_start: 'date_start',
share_end: 'date_end',
share_times_used: 'times_used',
share_max_uses: 'max_uses',
share_password: 'password',
})
.from('directus_shares')
.where('id', payload.share)
.andWhere((subQuery) => {
subQuery.whereNull('date_end').orWhere('date_end', '>=', this.knex.fn.now());
})
.andWhere((subQuery) => {
subQuery.whereNull('date_start').orWhere('date_start', '<=', this.knex.fn.now());
})
.andWhere((subQuery) => {
subQuery.whereNull('max_uses').orWhere('max_uses', '>=', this.knex.ref('times_used'));
})
.first();
if (!record) {
throw new InvalidCredentialsException();
}
if (record.share_password && !(await argon2.verify(record.share_password, payload.password))) {
throw new InvalidCredentialsException();
}
await this.knex('directus_shares')
.update({ times_used: record.share_times_used + 1 })
.where('id', record.share_id);
const tokenPayload: DirectusTokenPayload = {
app_access: false,
admin_access: false,
role: record.share_role,
share: record.share_id,
share_scope: {
item: record.share_item,
collection: record.share_collection,
},
};
const accessToken = jwt.sign(tokenPayload, env.SECRET as string, {
expiresIn: env.ACCESS_TOKEN_TTL,
issuer: 'directus',
});
const refreshToken = nanoid(64);
const refreshTokenExpiration = new Date(Date.now() + ms(env.REFRESH_TOKEN_TTL as string));
await this.knex('directus_sessions').insert({
token: refreshToken,
expires: refreshTokenExpiration,
ip: this.accountability?.ip,
user_agent: this.accountability?.userAgent,
share: record.share_id,
});
await this.knex('directus_sessions').delete().where('expires', '<', new Date());
return {
accessToken,
refreshToken,
expires: ms(env.ACCESS_TOKEN_TTL as string),
};
}
/**
* Send a link to the given share ID to the given email(s). Note: you can only send a link to a share
* if you have read access to that particular share
*/
async invite(payload: { emails: string[]; share: PrimaryKey }) {
if (!this.accountability?.user) throw new ForbiddenException();
const share = await this.readOne(payload.share, { fields: ['collection'] });
const usersService = new UsersService({
knex: this.knex,
schema: this.schema,
});
const mailService = new MailService({ schema: this.schema, accountability: this.accountability });
const userInfo = await usersService.readOne(this.accountability.user, {
fields: ['first_name', 'last_name', 'email', 'id'],
});
const message = `
Hello!
${userName(userInfo)} has invited you to view an item in ${share.collection}.
[Open](${env.PUBLIC_URL}/admin/shared/${payload.share})
`;
for (const email of payload.emails) {
await mailService.send({
template: {
name: 'base',
data: {
html: md(message),
},
},
to: email,
subject: `${userName(userInfo)} has shared an item with you`,
});
}
}
}

View File

@@ -454,7 +454,7 @@ class OASSpecsService implements SpecificationSubService {
},
],
};
} else if (relationType === 'm2a') {
} else if (relationType === 'a2o') {
const relatedTags = tags.filter((tag) => relation.meta!.one_allowed_collections!.includes(tag['x-collection']));
propertyObject.type = 'array';

View File

@@ -6,13 +6,13 @@ import env from '../env';
import { FailedValidationException } from '@directus/shared/exceptions';
import { ForbiddenException, InvalidPayloadException, UnprocessableEntityException } from '../exceptions';
import { RecordNotUniqueException } from '../exceptions/database/record-not-unique';
import { AbstractServiceOptions, Item, PrimaryKey, SchemaOverview } from '../types';
import { AbstractServiceOptions, Item, PrimaryKey, SchemaOverview, MutationOptions } from '../types';
import { Query } from '@directus/shared/types';
import { Accountability } from '@directus/shared/types';
import isUrlAllowed from '../utils/is-url-allowed';
import { toArray } from '@directus/shared/utils';
import { Url } from '../utils/url';
import { ItemsService, MutationOptions } from './items';
import { ItemsService } from './items';
import { MailService } from './mail';
import { SettingsService } from './settings';
import { stall } from '../utils/stall';

View File

@@ -1,6 +1,6 @@
import { AbstractServiceOptions, Item, PrimaryKey, Webhook } from '../types';
import { AbstractServiceOptions, Item, PrimaryKey, Webhook, MutationOptions } from '../types';
import { register } from '../webhooks';
import { ItemsService, MutationOptions } from './items';
import { ItemsService } from './items';
export class WebhooksService extends ItemsService<Webhook> {
constructor(options: AbstractServiceOptions) {

View File

@@ -12,8 +12,8 @@ export type M2ONode = {
relatedKey: string;
};
export type M2ANode = {
type: 'm2a';
export type A2MNode = {
type: 'a2o';
names: string[];
children: {
[collection: string]: (NestedCollectionNode | FieldNode)[];
@@ -40,7 +40,7 @@ export type O2MNode = {
relatedKey: string;
};
export type NestedCollectionNode = M2ONode | O2MNode | M2ANode;
export type NestedCollectionNode = M2ONode | O2MNode | A2MNode;
export type FieldNode = {
type: 'field';

View File

@@ -17,6 +17,8 @@ export interface User {
provider: string;
external_identifier: string | null;
auth_data: string | Record<string, unknown> | null;
app_access: boolean;
admin_access: boolean;
}
export type AuthData = Record<string, any> | null;
@@ -25,6 +27,38 @@ export interface Session {
token: string;
expires: Date;
data: string | Record<string, unknown> | null;
share: string;
}
export type SessionData = Record<string, any> | null;
export type DirectusTokenPayload = {
id?: string;
role: string | null;
app_access: boolean | number;
admin_access: boolean | number;
share?: string;
share_scope?: {
collection: string;
item: string;
};
};
export type ShareData = {
share_id: string;
share_role: string;
share_item: string;
share_collection: string;
share_start: Date;
share_end: Date;
share_times_used: number;
share_max_uses?: number;
share_password?: string;
};
export type LoginResult = {
accessToken: any;
refreshToken: any;
expires: any;
id?: any;
};

View File

@@ -16,3 +16,20 @@ export type Alterations = {
}[];
delete: (number | string)[];
};
export type MutationOptions = {
/**
* Callback function that's fired whenever a revision is made in the mutation
*/
onRevisionCreate?: (pk: PrimaryKey) => void;
/**
* Flag to disable the auto purging of the cache. Is ignored when CACHE_AUTO_PURGE isn't enabled.
*/
autoPurgeCache?: false;
/**
* Allow disabling the emitting of hooks. Useful if a custom hook is fired (like files.upload)
*/
emitEvents?: boolean;
};

View File

@@ -3,7 +3,7 @@ import { clone, cloneDeep, get, isPlainObject, set } from 'lodash';
import { customAlphabet } from 'nanoid';
import validate from 'uuid-validate';
import { InvalidQueryException } from '../exceptions';
import { Relation, SchemaOverview } from '../types';
import { Relation, RelationMeta, SchemaOverview } from '../types';
import { Aggregate, Filter, LogicalFilterAND, Query } from '@directus/shared/types';
import { getColumn } from './get-column';
import { getRelationType } from './get-relation-type';
@@ -133,6 +133,57 @@ export default function applyQuery(
* )
* ```
*/
type RelationInfo = {
relation: Relation | null;
relationType: string | null;
};
function getRelationInfo(relations: Relation[], collection: string, field: string): RelationInfo {
const implicitRelation = field.match(/^\$FOLLOW\((.*?),(.*?)(?:,(.*?))?\)$/)?.slice(1);
if (implicitRelation) {
if (implicitRelation[2] === undefined) {
const [m2oCollection, m2oField] = implicitRelation;
const relation: Relation = {
collection: m2oCollection,
field: m2oField,
related_collection: collection,
schema: null,
meta: null,
};
return { relation, relationType: 'o2m' };
} else {
const [a2oCollection, a2oItemField, a2oCollectionField] = implicitRelation;
const relation: Relation = {
collection: a2oCollection,
field: a2oItemField,
related_collection: collection,
schema: null,
meta: {
one_collection_field: a2oCollectionField,
one_field: field,
} as RelationMeta,
};
return { relation, relationType: 'o2a' };
}
}
const relation =
relations.find((relation) => {
return (
(relation.collection === collection && relation.field === field) ||
(relation.related_collection === collection && relation.meta?.one_field === field)
);
}) ?? null;
const relationType = relation ? getRelationType({ relation, collection, field }) : null;
return { relation, relationType };
}
export function applyFilter(
knex: Knex,
@@ -181,20 +232,15 @@ export function applyFilter(
function followRelation(pathParts: string[], parentCollection: string = collection, parentAlias?: string) {
/**
* For M2A fields, the path can contain an optional collection scope <field>:<scope>
* For A2M fields, the path can contain an optional collection scope <field>:<scope>
*/
const pathRoot = pathParts[0].split(':')[0];
const relation = relations.find((relation) => {
return (
(relation.collection === parentCollection && relation.field === pathRoot) ||
(relation.related_collection === parentCollection && relation.meta?.one_field === pathRoot)
);
});
const { relation, relationType } = getRelationInfo(relations, parentCollection, pathRoot);
if (!relation) return;
const relationType = getRelationType({ relation, collection: parentCollection, field: pathRoot });
if (!relation) {
return;
}
const alias = generateAlias();
@@ -208,7 +254,7 @@ export function applyFilter(
);
}
if (relationType === 'm2a') {
if (relationType === 'a2o') {
const pathScope = pathParts[0].split(':')[1];
if (!pathScope) {
@@ -219,12 +265,27 @@ export function applyFilter(
dbQuery.leftJoin({ [alias]: pathScope }, (joinClause) => {
joinClause
.on(
.onVal(relation.meta!.one_collection_field!, '=', pathScope)
.andOn(
`${parentAlias || parentCollection}.${relation.field}`,
'=',
knex.raw(`CAST(?? AS CHAR(255))`, `${alias}.${schema.collections[pathScope].primary}`)
)
.andOnVal(relation.meta!.one_collection_field!, '=', pathScope);
);
});
}
if (relationType === 'o2a') {
dbQuery.leftJoin({ [alias]: relation.collection }, (joinClause) => {
joinClause
.onVal(relation.meta!.one_collection_field!, '=', parentCollection)
.andOn(
`${alias}.${relation.field}`,
'=',
knex.raw(
`CAST(?? AS CHAR(255))`,
`${parentAlias || parentCollection}.${schema.collections[parentCollection].primary}`
)
);
});
}
@@ -242,7 +303,7 @@ export function applyFilter(
if (relationType === 'm2o') {
parent = relation.related_collection!;
} else if (relationType === 'm2a') {
} else if (relationType === 'a2o') {
const pathScope = pathParts[0].split(':')[1];
if (!pathScope) {
@@ -294,22 +355,15 @@ export function applyFilter(
const filterPath = getFilterPath(key, value);
/**
* For M2A fields, the path can contain an optional collection scope <field>:<scope>
* For A2M fields, the path can contain an optional collection scope <field>:<scope>
*/
const pathRoot = filterPath[0].split(':')[0];
const relation = relations.find((relation) => {
return (
(relation.collection === collection && relation.field === pathRoot) ||
(relation.related_collection === collection && relation.meta?.one_field === pathRoot)
);
});
const { relation, relationType } = getRelationInfo(relations, collection, pathRoot);
const { operator: filterOperator, value: filterValue } = getOperation(key, value);
const relationType = relation ? getRelationType({ relation, collection: collection, field: pathRoot }) : null;
if (relationType === 'm2o' || relationType === 'm2a' || relationType === null) {
if (relationType === 'm2o' || relationType === 'a2o' || relationType === null) {
if (filterPath.length > 1) {
const columnName = getWhereColumn(filterPath, collection);
if (!columnName) continue;
@@ -318,12 +372,22 @@ export function applyFilter(
applyFilterToQuery(`${collection}.${filterPath[0]}`, filterOperator, filterValue, logical);
}
} else if (subQuery === false) {
const pkField = `${collection}.${schema.collections[relation!.related_collection!].primary}`;
if (!relation) continue;
dbQuery[logical].whereIn(pkField, (subQueryKnex) => {
let pkField: Knex.Raw<any> | string = `${collection}.${
schema.collections[relation!.related_collection!].primary
}`;
if (relationType === 'o2a') {
pkField = knex.raw(`CAST(?? AS CHAR(255))`, [pkField]);
}
// Note: knex's types don't appreciate knex.raw in whereIn, even though it's officially supported
dbQuery[logical].whereIn(pkField as string, (subQueryKnex) => {
const field = relation!.field;
const collection = relation!.collection;
const column = `${collection}.${field}`;
subQueryKnex.select({ [field]: column }).from(collection);
applyQuery(
@@ -501,30 +565,23 @@ export function applyFilter(
parentAlias?: string
): string | void {
/**
* For M2A fields, the path can contain an optional collection scope <field>:<scope>
* For A2M fields, the path can contain an optional collection scope <field>:<scope>
*/
const pathRoot = pathParts[0].split(':')[0];
const relation = relations.find((relation) => {
return (
(relation.collection === parentCollection && relation.field === pathRoot) ||
(relation.related_collection === parentCollection && relation.meta?.one_field === pathRoot)
);
});
const { relation, relationType } = getRelationInfo(relations, parentCollection, pathRoot);
if (!relation) {
throw new InvalidQueryException(`"${parentCollection}.${pathRoot}" is not a relational field`);
}
const relationType = getRelationType({ relation, collection: parentCollection, field: pathRoot });
const alias = get(aliasMap, parentAlias ? [parentAlias, ...pathParts] : pathParts);
const remainingParts = pathParts.slice(1);
let parent: string;
if (relationType === 'm2a') {
if (relationType === 'a2o') {
const pathScope = pathParts[0].split(':')[1];
if (!pathScope) {

View File

@@ -136,7 +136,7 @@ export default async function getASTFromQuery(
let rootField = parts[0];
let collectionScope: string | null = null;
// m2a related collection scoped field selector `fields=sections.section_id:headings.title`
// a2o related collection scoped field selector `fields=sections.section_id:headings.title`
if (rootField.includes(':')) {
const [key, scope] = rootField.split(':');
rootField = key;
@@ -191,14 +191,14 @@ export default async function getASTFromQuery(
let child: NestedCollectionNode | null = null;
if (relationType === 'm2a') {
if (relationType === 'a2o') {
const allowedCollections = relation.meta!.one_allowed_collections!.filter((collection) => {
if (!permissions) return true;
return permissions.some((permission) => permission.collection === collection);
});
child = {
type: 'm2a',
type: 'a2o',
names: allowedCollections,
children: {},
query: {},

View File

@@ -4,6 +4,7 @@ import { cloneDeep } from 'lodash';
import getDatabase from '../database';
import { appAccessMinimalPermissions } from '../database/system-data/app-access-permissions';
import { mergePermissions } from '../utils/merge-permissions';
import { mergePermissionsForShare } from './merge-permissions-for-share';
import { UsersService } from '../services/users';
import { RolesService } from '../services/roles';
import { getCache } from '../cache';
@@ -17,8 +18,8 @@ export async function getPermissions(accountability: Accountability, schema: Sch
let permissions: Permission[] = [];
const { user, role, app, admin } = accountability;
const cacheKey = `permissions-${hash({ user, role, app, admin })}`;
const { user, role, app, admin, share_scope } = accountability;
const cacheKey = `permissions-${hash({ user, role, app, admin, share_scope })}`;
if (env.CACHE_PERMISSIONS !== false) {
const cachedPermissions = await systemCache.get(cacheKey);
@@ -57,10 +58,15 @@ export async function getPermissions(accountability: Accountability, schema: Sch
}
if (accountability.admin !== true) {
const permissionsForRole = await database
.select('*')
.from('directus_permissions')
.where({ role: accountability.role });
const query = database.select('*').from('directus_permissions');
if (accountability.role) {
query.where({ role: accountability.role });
} else {
query.whereNull('role');
}
const permissionsForRole = await query;
const {
permissions: parsedPermissions,
@@ -72,11 +78,16 @@ export async function getPermissions(accountability: Accountability, schema: Sch
if (accountability.app === true) {
permissions = mergePermissions(
'or',
permissions,
appAccessMinimalPermissions.map((perm) => ({ ...perm, role: accountability!.role }))
appAccessMinimalPermissions.map((perm) => ({ ...perm, role: accountability.role }))
);
}
if (accountability.share_scope) {
permissions = mergePermissionsForShare(permissions, accountability, schema);
}
const filterContext = containDynamicData
? await getFilterContext(schema, accountability, requiredPermissionData)
: {};

View File

@@ -4,7 +4,7 @@ export function getRelationType(getRelationOptions: {
relation: Relation;
collection: string | null;
field: string;
}): 'm2o' | 'o2m' | 'm2a' | null {
}): 'm2o' | 'o2m' | 'a2o' | null {
const { relation, collection, field } = getRelationOptions;
if (!relation) return null;
@@ -15,7 +15,7 @@ export function getRelationType(getRelationOptions: {
relation.meta?.one_collection_field &&
relation.meta?.one_allowed_collections
) {
return 'm2a';
return 'a2o';
}
if (relation.collection === collection && relation.field === field) {

View File

@@ -0,0 +1,180 @@
import { Permission, Accountability, Filter } from '@directus/shared/types';
import { SchemaOverview } from '../types';
import { assign, set, uniq } from 'lodash';
import { mergePermissions } from './merge-permissions';
import { schemaPermissions } from '../database/system-data/app-access-permissions';
import { reduceSchema } from './reduce-schema';
export function mergePermissionsForShare(
currentPermissions: Permission[],
accountability: Accountability,
schema: SchemaOverview
): Permission[] {
const defaults: Permission = {
action: 'read',
role: accountability.role,
collection: '',
permissions: {},
validation: null,
presets: null,
fields: null,
};
const { collection, item } = accountability.share_scope!;
const parentPrimaryKeyField = schema.collections[collection].primary;
const reducedSchema = reduceSchema(schema, currentPermissions, ['read']);
const relationalPermissions = traverse(reducedSchema, parentPrimaryKeyField, item, collection);
const parentCollectionPermission: Permission = assign({}, defaults, {
collection,
permissions: {
[parentPrimaryKeyField]: {
_eq: item,
},
},
});
// All permissions that will be merged into the original permissions set
const allGeneratedPermissions = [
parentCollectionPermission,
...relationalPermissions.map((generated) => assign({}, defaults, generated)),
...schemaPermissions,
];
// All the collections that are touched through the relational tree from the current root collection, and the schema collections
const allowedCollections = uniq(allGeneratedPermissions.map(({ collection }) => collection));
const generatedPermissions: Permission[] = [];
// Merge all the permissions that relate to the same collection with an _or (this allows you to properly retrieve)
// the items of a collection if you entered that collection from multiple angles
for (const collection of allowedCollections) {
const permissionsForCollection = allGeneratedPermissions.filter(
(permission) => permission.collection === collection
);
if (permissionsForCollection.length > 0) {
generatedPermissions.push(...mergePermissions('or', permissionsForCollection));
} else {
generatedPermissions.push(...permissionsForCollection);
}
}
// Explicitly filter out permissions to collections unrelated to the root parent item.
const limitedPermissions = currentPermissions.filter(({ collection }) => allowedCollections.includes(collection));
return mergePermissions('and', limitedPermissions, generatedPermissions);
}
export function traverse(
schema: SchemaOverview,
rootItemPrimaryKeyField: string,
rootItemPrimaryKey: string,
currentCollection: string,
parentCollections: string[] = [],
path: string[] = []
): Partial<Permission>[] {
const permissions: Partial<Permission>[] = [];
// If there's already a permissions rule for the collection we're currently checking, we'll shortcircuit.
// This prevents infinite loop in recursive relationships, like articles->related_articles->articles, or
// articles.author->users.avatar->files.created_by->users.avatar->files.created_by->🔁
if (parentCollections.includes(currentCollection)) {
return permissions;
}
const relationsInCollection = schema.relations.filter((relation) => {
return relation.collection === currentCollection || relation.related_collection === currentCollection;
});
for (const relation of relationsInCollection) {
let type;
if (relation.related_collection === currentCollection) {
type = 'o2m';
} else if (!relation.related_collection) {
type = 'a2o';
} else {
type = 'm2o';
}
if (type === 'o2m') {
permissions.push({
collection: relation.collection,
permissions: getFilterForPath(type, [...path, relation.field], rootItemPrimaryKeyField, rootItemPrimaryKey),
});
permissions.push(
...traverse(
schema,
rootItemPrimaryKeyField,
rootItemPrimaryKey,
relation.collection,
[...parentCollections, currentCollection],
[...path, relation.field]
)
);
}
if (type === 'a2o' && relation.meta?.one_allowed_collections) {
for (const collection of relation.meta.one_allowed_collections) {
permissions.push({
collection,
permissions: getFilterForPath(
type,
[...path, `$FOLLOW(${relation.collection},${relation.field},${relation.meta.one_collection_field})`],
rootItemPrimaryKeyField,
rootItemPrimaryKey
),
});
}
}
if (type === 'm2o') {
permissions.push({
collection: relation.related_collection!,
permissions: getFilterForPath(
type,
[...path, `$FOLLOW(${relation.collection},${relation.field})`],
rootItemPrimaryKeyField,
rootItemPrimaryKey
),
});
if (relation.meta?.one_field) {
permissions.push(
...traverse(
schema,
rootItemPrimaryKeyField,
rootItemPrimaryKey,
relation.related_collection!,
[...parentCollections, currentCollection],
[...path, relation.meta?.one_field]
)
);
}
}
}
return permissions;
}
export function getFilterForPath(
type: 'o2m' | 'm2o' | 'a2o',
path: string[],
rootPrimaryKeyField: string,
rootPrimaryKey: string
): Filter {
const filter: Filter = {};
if (type === 'm2o' || type === 'a2o') {
set(filter, path.reverse(), { [rootPrimaryKeyField]: { _eq: rootPrimaryKey } });
} else {
set(filter, path.reverse(), { _eq: rootPrimaryKey });
}
return filter;
}

View File

@@ -1,66 +1,72 @@
import { flatten, merge, omit } from 'lodash';
import { Permission, LogicalFilterOR } from '@directus/shared/types';
import { flatten, merge, omit, intersection } from 'lodash';
import { Permission, LogicalFilterOR, LogicalFilterAND } from '@directus/shared/types';
export function mergePermissions(...permissions: Permission[][]): Permission[] {
export function mergePermissions(strategy: 'and' | 'or', ...permissions: Permission[][]): Permission[] {
const allPermissions = flatten(permissions);
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);
acc.set(key, current ? mergePermission(strategy, current, val) : val);
return acc;
}, new Map())
.values();
const result = Array.from(mergedPermissions).map((perm) => {
return omit(perm, ['id', 'system']) as Permission;
});
return result;
return Array.from(mergedPermissions);
}
function mergePerm(currentPerm: Permission, newPerm: Permission) {
export function mergePermission(strategy: 'and' | 'or', currentPerm: Permission, newPerm: Permission) {
const logicalKey = `_${strategy}` as keyof LogicalFilterOR | keyof LogicalFilterAND;
let permissions = currentPerm.permissions;
let validation = currentPerm.validation;
let fields = currentPerm.fields;
let presets = currentPerm.presets;
if (newPerm.permissions) {
if (currentPerm.permissions && Object.keys(currentPerm.permissions)[0] === '_or') {
if (currentPerm.permissions && Object.keys(currentPerm.permissions)[0] === logicalKey) {
permissions = {
_or: [...(currentPerm.permissions as LogicalFilterOR)._or, newPerm.permissions],
};
[logicalKey]: [
...(currentPerm.permissions as LogicalFilterOR & LogicalFilterAND)[logicalKey],
newPerm.permissions,
],
} as LogicalFilterAND | LogicalFilterOR;
} else if (currentPerm.permissions) {
permissions = {
_or: [currentPerm.permissions, newPerm.permissions],
};
[logicalKey]: [currentPerm.permissions, newPerm.permissions],
} as LogicalFilterAND | LogicalFilterOR;
} else {
permissions = {
_or: [newPerm.permissions],
};
[logicalKey]: [newPerm.permissions],
} as LogicalFilterAND | LogicalFilterOR;
}
}
if (newPerm.validation) {
if (currentPerm.validation && Object.keys(currentPerm.validation)[0] === '_or') {
if (currentPerm.validation && Object.keys(currentPerm.validation)[0] === logicalKey) {
validation = {
_or: [...(currentPerm.validation as LogicalFilterOR)._or, newPerm.validation],
};
[logicalKey]: [
...(currentPerm.validation as LogicalFilterOR & LogicalFilterAND)[logicalKey],
newPerm.validation,
],
} as LogicalFilterAND | LogicalFilterOR;
} else if (currentPerm.validation) {
validation = {
_or: [currentPerm.validation, newPerm.validation],
};
[logicalKey]: [currentPerm.validation, newPerm.validation],
} as LogicalFilterAND | LogicalFilterOR;
} else {
validation = {
_or: [newPerm.validation],
};
[logicalKey]: [newPerm.validation],
} as LogicalFilterAND | LogicalFilterOR;
}
}
if (newPerm.fields) {
if (Array.isArray(currentPerm.fields)) {
if (Array.isArray(currentPerm.fields) && strategy === 'or') {
fields = [...new Set([...currentPerm.fields, ...newPerm.fields])];
} else if (Array.isArray(currentPerm.fields) && strategy === 'and') {
fields = intersection(currentPerm.fields, newPerm.fields);
} else {
fields = newPerm.fields;
}
@@ -72,11 +78,14 @@ function mergePerm(currentPerm: Permission, newPerm: Permission) {
presets = merge({}, presets, newPerm.presets);
}
return {
...currentPerm,
permissions,
validation,
fields,
presets,
};
return omit(
{
...currentPerm,
permissions,
validation,
fields,
presets,
},
['id', 'system']
);
}

View File

@@ -1,6 +1,6 @@
import { uniq } from 'lodash';
import { SchemaOverview } from '../types';
import { Accountability, PermissionsAction } from '@directus/shared/types';
import { Permission, PermissionsAction } from '@directus/shared/types';
/**
* Reduces the schema based on the included permissions. The resulting object is the schema structure, but with only
@@ -11,7 +11,7 @@ import { Accountability, PermissionsAction } from '@directus/shared/types';
*/
export function reduceSchema(
schema: SchemaOverview,
accountability: Accountability | null,
permissions: Permission[] | null,
actions: PermissionsAction[] = ['create', 'read', 'update', 'delete']
): SchemaOverview {
const reduced: SchemaOverview = {
@@ -20,7 +20,7 @@ export function reduceSchema(
};
const allowedFieldsInCollection =
accountability?.permissions
permissions
?.filter((permission) => actions.includes(permission.action))
.reduce((acc, permission) => {
if (!acc[permission.collection]) {
@@ -36,9 +36,7 @@ export function reduceSchema(
for (const [collectionName, collection] of Object.entries(schema.collections)) {
if (
accountability?.permissions?.some(
(permission) => permission.collection === collectionName && actions.includes(permission.action)
)
permissions?.some((permission) => permission.collection === collectionName && actions.includes(permission.action))
) {
const fields: SchemaOverview['collections'][string]['fields'] = {};

View File

@@ -1,6 +1,10 @@
import { User } from '@directus/shared/types';
export function userName(user: Partial<User>): string {
if (!user) {
return 'Unknown User';
}
if (user.first_name && user.last_name) {
return `${user.first_name} ${user.last_name}`;
}