mirror of
https://github.com/directus/directus.git
synced 2026-01-30 06:58:09 -05:00
* 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>
318 lines
9.4 KiB
TypeScript
318 lines
9.4 KiB
TypeScript
import { Knex } from 'knex';
|
|
import { cloneDeep, merge, uniq, uniqWith, flatten, isNil } from 'lodash';
|
|
import getDatabase from '../database';
|
|
import { ForbiddenException } from '../exceptions';
|
|
import { FailedValidationException } from '@directus/shared/exceptions';
|
|
import { validatePayload } from '@directus/shared/utils';
|
|
import { Accountability } from '@directus/shared/types';
|
|
import {
|
|
AbstractServiceOptions,
|
|
AST,
|
|
FieldNode,
|
|
Item,
|
|
NestedCollectionNode,
|
|
PrimaryKey,
|
|
SchemaOverview,
|
|
} from '../types';
|
|
import { Query, Aggregate, Permission, PermissionsAction } from '@directus/shared/types';
|
|
import { ItemsService } from './items';
|
|
import { PayloadService } from './payload';
|
|
|
|
export class AuthorizationService {
|
|
knex: Knex;
|
|
accountability: Accountability | null;
|
|
payloadService: PayloadService;
|
|
schema: SchemaOverview;
|
|
|
|
constructor(options: AbstractServiceOptions) {
|
|
this.knex = options.knex || getDatabase();
|
|
this.accountability = options.accountability || null;
|
|
this.schema = options.schema;
|
|
this.payloadService = new PayloadService('directus_permissions', {
|
|
knex: this.knex,
|
|
schema: this.schema,
|
|
});
|
|
}
|
|
|
|
async processAST(ast: AST, action: PermissionsAction = 'read'): Promise<AST> {
|
|
const collectionsRequested = getCollectionsFromAST(ast);
|
|
|
|
const permissionsForCollections =
|
|
uniqWith(
|
|
this.accountability?.permissions?.filter((permission) => {
|
|
return (
|
|
permission.action === action &&
|
|
collectionsRequested.map(({ collection }) => collection).includes(permission.collection)
|
|
);
|
|
}),
|
|
(curr, prev) => curr.collection === prev.collection && curr.action === prev.action && curr.role === prev.role
|
|
) ?? [];
|
|
|
|
// If the permissions don't match the collections, you don't have permission to read all of them
|
|
const uniqueCollectionsRequestedCount = uniq(collectionsRequested.map(({ collection }) => collection)).length;
|
|
|
|
if (uniqueCollectionsRequestedCount !== permissionsForCollections.length) {
|
|
throw new ForbiddenException();
|
|
}
|
|
|
|
validateFields(ast);
|
|
applyFilters(ast, this.accountability);
|
|
|
|
return ast;
|
|
|
|
/**
|
|
* Traverses the AST and returns an array of all collections that are being fetched
|
|
*/
|
|
function getCollectionsFromAST(ast: AST | NestedCollectionNode): { collection: string; field: string }[] {
|
|
const collections = [];
|
|
|
|
if (ast.type === 'a2o') {
|
|
collections.push(...ast.names.map((name) => ({ collection: name, field: ast.fieldKey })));
|
|
|
|
for (const children of Object.values(ast.children)) {
|
|
for (const nestedNode of children) {
|
|
if (nestedNode.type !== 'field') {
|
|
collections.push(...getCollectionsFromAST(nestedNode));
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
collections.push({
|
|
collection: ast.name,
|
|
field: ast.type === 'root' ? null : ast.fieldKey,
|
|
});
|
|
|
|
for (const nestedNode of ast.children) {
|
|
if (nestedNode.type !== 'field') {
|
|
collections.push(...getCollectionsFromAST(nestedNode));
|
|
}
|
|
}
|
|
}
|
|
|
|
return collections as { collection: string; field: string }[];
|
|
}
|
|
|
|
function validateFields(ast: AST | NestedCollectionNode | FieldNode) {
|
|
if (ast.type !== 'field') {
|
|
if (ast.type === 'a2o') {
|
|
for (const [collection, children] of Object.entries(ast.children)) {
|
|
checkFields(collection, children, ast.query?.[collection]?.aggregate);
|
|
}
|
|
} else {
|
|
checkFields(ast.name, ast.children, ast.query?.aggregate);
|
|
}
|
|
}
|
|
|
|
function checkFields(
|
|
collection: string,
|
|
children: (NestedCollectionNode | FieldNode)[],
|
|
aggregate?: Aggregate | null
|
|
) {
|
|
// We check the availability of the permissions in the step before this is run
|
|
const permissions = permissionsForCollections.find((permission) => permission.collection === collection)!;
|
|
const allowedFields = permissions.fields || [];
|
|
|
|
if (aggregate && allowedFields.includes('*') === false) {
|
|
for (const aliasMap of Object.values(aggregate)) {
|
|
if (!aliasMap) continue;
|
|
|
|
for (const column of Object.values(aliasMap)) {
|
|
if (allowedFields.includes(column) === false) throw new ForbiddenException();
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const childNode of children) {
|
|
if (childNode.type !== 'field') {
|
|
validateFields(childNode);
|
|
continue;
|
|
}
|
|
|
|
if (allowedFields.includes('*')) continue;
|
|
|
|
const fieldKey = childNode.name;
|
|
|
|
if (allowedFields.includes(fieldKey) === false) {
|
|
throw new ForbiddenException();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function applyFilters(
|
|
ast: AST | NestedCollectionNode | FieldNode,
|
|
accountability: Accountability | null
|
|
): AST | NestedCollectionNode | FieldNode {
|
|
if (ast.type !== 'field') {
|
|
if (ast.type === 'a2o') {
|
|
const collections = Object.keys(ast.children);
|
|
|
|
for (const collection of collections) {
|
|
updateFilterQuery(collection, ast.query[collection]);
|
|
}
|
|
|
|
for (const [collection, children] of Object.entries(ast.children)) {
|
|
ast.children[collection] = children.map((child) => applyFilters(child, accountability)) as (
|
|
| NestedCollectionNode
|
|
| FieldNode
|
|
)[];
|
|
}
|
|
} else {
|
|
const collection = ast.name;
|
|
|
|
updateFilterQuery(collection, ast.query);
|
|
|
|
ast.children = ast.children.map((child) => applyFilters(child, accountability)) as (
|
|
| NestedCollectionNode
|
|
| FieldNode
|
|
)[];
|
|
}
|
|
}
|
|
|
|
return ast;
|
|
|
|
function updateFilterQuery(collection: string, query: Query) {
|
|
// We check the availability of the permissions in the step before this is run
|
|
const permissions = permissionsForCollections.find((permission) => permission.collection === collection)!;
|
|
|
|
if (!query.filter || Object.keys(query.filter).length === 0) {
|
|
query.filter = { _and: [] };
|
|
} else {
|
|
query.filter = { _and: [query.filter] };
|
|
}
|
|
|
|
if (permissions.permissions && Object.keys(permissions.permissions).length > 0) {
|
|
query.filter._and.push(permissions.permissions);
|
|
}
|
|
|
|
if (query.filter._and.length === 0) delete query.filter;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if the provided payload matches the configured permissions, and adds the presets to the payload.
|
|
*/
|
|
validatePayload(action: PermissionsAction, collection: string, data: Partial<Item>): Partial<Item> {
|
|
const payload = cloneDeep(data);
|
|
|
|
let permission: Permission | undefined;
|
|
|
|
if (this.accountability?.admin === true) {
|
|
permission = {
|
|
id: 0,
|
|
role: this.accountability?.role,
|
|
collection,
|
|
action,
|
|
permissions: {},
|
|
validation: {},
|
|
fields: ['*'],
|
|
presets: {},
|
|
};
|
|
} else {
|
|
permission = this.accountability?.permissions?.find((permission) => {
|
|
return permission.collection === collection && permission.action === action;
|
|
});
|
|
|
|
if (!permission) throw new ForbiddenException();
|
|
|
|
// Check if you have permission to access the fields you're trying to access
|
|
|
|
const allowedFields = permission.fields || [];
|
|
|
|
if (allowedFields.includes('*') === false) {
|
|
const keysInData = Object.keys(payload);
|
|
const invalidKeys = keysInData.filter((fieldKey) => allowedFields.includes(fieldKey) === false);
|
|
|
|
if (invalidKeys.length > 0) {
|
|
throw new ForbiddenException();
|
|
}
|
|
}
|
|
}
|
|
|
|
const preset = permission.presets ?? {};
|
|
|
|
const payloadWithPresets = merge({}, preset, payload);
|
|
|
|
const hasValidationRules =
|
|
isNil(permission.validation) === false && Object.keys(permission.validation ?? {}).length > 0;
|
|
|
|
const requiredColumns: SchemaOverview['collections'][string]['fields'][string][] = [];
|
|
|
|
for (const field of Object.values(this.schema.collections[collection].fields)) {
|
|
const specials = field?.special ?? [];
|
|
|
|
const hasGenerateSpecial = ['uuid', 'date-created', 'role-created', 'user-created'].some((name) =>
|
|
specials.includes(name)
|
|
);
|
|
|
|
const nullable = field.nullable || hasGenerateSpecial || field.generated;
|
|
|
|
if (!nullable) {
|
|
requiredColumns.push(field);
|
|
}
|
|
}
|
|
|
|
if (hasValidationRules === false && requiredColumns.length === 0) {
|
|
return payloadWithPresets;
|
|
}
|
|
|
|
if (requiredColumns.length > 0) {
|
|
permission.validation = hasValidationRules ? { _and: [permission.validation!] } : { _and: [] };
|
|
|
|
for (const field of requiredColumns) {
|
|
if (action === 'create' && field.defaultValue === null) {
|
|
permission.validation._and.push({
|
|
[field.field]: {
|
|
_submitted: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
permission.validation._and.push({
|
|
[field.field]: {
|
|
_nnull: true,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
const validationErrors: FailedValidationException[] = [];
|
|
|
|
validationErrors.push(
|
|
...flatten(
|
|
validatePayload(permission.validation!, payloadWithPresets).map((error) =>
|
|
error.details.map((details) => new FailedValidationException(details))
|
|
)
|
|
)
|
|
);
|
|
|
|
if (validationErrors.length > 0) throw validationErrors;
|
|
|
|
return payloadWithPresets;
|
|
}
|
|
|
|
async checkAccess(action: PermissionsAction, collection: string, pk: PrimaryKey | PrimaryKey[]): Promise<void> {
|
|
if (this.accountability?.admin === true) return;
|
|
|
|
const itemsService = new ItemsService(collection, {
|
|
accountability: this.accountability,
|
|
knex: this.knex,
|
|
schema: this.schema,
|
|
});
|
|
|
|
const query: Query = {
|
|
fields: ['*'],
|
|
};
|
|
|
|
if (Array.isArray(pk)) {
|
|
const result = await itemsService.readMany(pk, { ...query, limit: pk.length }, { permissionsAction: action });
|
|
if (!result) throw new ForbiddenException();
|
|
if (result.length !== pk.length) throw new ForbiddenException();
|
|
} else {
|
|
const result = await itemsService.readOne(pk, query, { permissionsAction: action });
|
|
if (!result) throw new ForbiddenException();
|
|
}
|
|
}
|
|
}
|