mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
287
api/src/controllers/shares.ts
Normal file
287
api/src/controllers/shares.ts
Normal 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;
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
38
api/src/database/migrations/20211211A-add-shares.ts
Normal file
38
api/src/database/migrations/20211211A-add-shares.ts
Normal 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');
|
||||
}
|
||||
@@ -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[]> = {};
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -11,4 +11,4 @@ fields:
|
||||
width: half
|
||||
- field: user_agent
|
||||
width: half
|
||||
- field: data
|
||||
- field: share
|
||||
|
||||
73
api/src/database/system-data/fields/shares.yaml
Normal file
73
api/src/database/system-data/fields/shares.yaml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(',');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -27,3 +27,4 @@ export * from './tfa';
|
||||
export * from './users';
|
||||
export * from './utils';
|
||||
export * from './webhooks';
|
||||
export * from './shares';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
156
api/src/services/shares.ts
Normal 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`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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)
|
||||
: {};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
180
api/src/utils/merge-permissions-for-share.ts
Normal file
180
api/src/utils/merge-permissions-for-share.ts
Normal 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;
|
||||
}
|
||||
@@ -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']
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'] = {};
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user