Roadmap/#1379 admin ui doc level access (#1624)

* feat: adds document level access endpoints so admin ui can now accurately reflect document level access control
* chore(docs): new doc access callout, updates useDocumentInfo props from change
This commit is contained in:
Jarrod Flesch
2022-12-14 16:14:29 -05:00
committed by GitHub
parent d9c45f62b1
commit eda6f70acb
23 changed files with 702 additions and 154 deletions

View File

@@ -163,7 +163,7 @@ The `useForm` hook returns an object with the following properties:
The `useDocumentInfo` hook provides lots of information about the document currently being edited, including the following:
| Property | Description |
|---------------------------|------------------------------------------------------------------------------------|
|---------------------------|--------------------------------------------------------------------------------------------------------------------| |
| **`collection`** | If the doc is a collection, its collection config will be returned |
| **`global`** | If the doc is a global, its global config will be returned |
| **`type`** | The type of document being edited (collection or global) |
@@ -173,6 +173,8 @@ The `useDocumentInfo` hook provides lots of information about the document curre
| **`unpublishedVersions`** | Unpublished versions of the current doc |
| **`publishedDoc`** | The currently published version of the doc being edited |
| **`getVersions`** | Method to trigger the retrieval of document versions |
| **`docPermissions`** | The current documents permissions. Collection document permissions fallback when no id is present (i.e. on create) |
| **`getDocPermissions`** | Method to trigger the retrieval of document level permissions |
**Example:**

View File

@@ -66,6 +66,8 @@ query {
}
```
Document access can also be queried on a collection/global basis. Access on a global can queried like `http://localhost:3000/api/global-slug/access`, Collection document access can be queried like `http://localhost:3000/api/collection-slug/access/:id`.
### Me
Returns either a logged in user with token or null when there is no logged in user.

View File

@@ -5,12 +5,13 @@ import qs from 'qs';
import { useTranslation } from 'react-i18next';
import { useConfig } from '../Config';
import { PaginatedDocs } from '../../../../mongoose/types';
import { ContextType, Props, Version } from './types';
import { ContextType, DocumentPermissions, EntityType, Props, Version } from './types';
import { TypeWithID } from '../../../../globals/config/types';
import { TypeWithTimestamps } from '../../../../collections/config/types';
import { Where } from '../../../../types';
import { DocumentPreferences } from '../../../../preferences/types';
import { usePreferences } from '../Preferences';
import { useAuth } from '../Auth';
const Context = createContext({} as ContextType);
@@ -23,24 +24,29 @@ export const DocumentInfoProvider: React.FC<Props> = ({
const { serverURL, routes: { api } } = useConfig();
const { getPreference } = usePreferences();
const { i18n } = useTranslation();
const { permissions } = useAuth();
const [publishedDoc, setPublishedDoc] = useState<TypeWithID & TypeWithTimestamps>(null);
const [versions, setVersions] = useState<PaginatedDocs<Version>>(null);
const [unpublishedVersions, setUnpublishedVersions] = useState<PaginatedDocs<Version>>(null);
const [docPermissions, setDocPermissions] = useState<DocumentPermissions>(null);
const baseURL = `${serverURL}${api}`;
let slug;
let type;
let preferencesKey;
let slug: string;
let type: EntityType;
let pluralType: 'globals' | 'collections';
let preferencesKey: string;
if (global) {
slug = global.slug;
type = 'global';
pluralType = 'globals';
preferencesKey = `global-${slug}`;
}
if (collection) {
slug = collection.slug;
type = 'collection';
pluralType = 'collections';
if (id) {
preferencesKey = `collection-${slug}-${id}`;
@@ -169,6 +175,25 @@ export const DocumentInfoProvider: React.FC<Props> = ({
}
}, [i18n, global, collection, id, baseURL]);
const getDocPermissions = React.useCallback(async () => {
let docAccessURL: string;
if (pluralType === 'globals') {
docAccessURL = `/globals/${slug}/access`;
} else if (pluralType === 'collections' && id) {
docAccessURL = `/${slug}/access/${id}`;
}
if (docAccessURL) {
const res = await fetch(`${serverURL}${api}${docAccessURL}`);
const json = await res.json();
setDocPermissions(json);
} else {
// fallback to permissions from the collection
// (i.e. create has no id)
setDocPermissions(permissions[pluralType][slug]);
}
}, [serverURL, api, pluralType, slug, id, permissions]);
useEffect(() => {
getVersions();
}, [getVersions]);
@@ -181,6 +206,10 @@ export const DocumentInfoProvider: React.FC<Props> = ({
getDocPreferences();
}, [getPreference, preferencesKey]);
useEffect(() => {
getDocPermissions();
}, [getDocPermissions]);
const value = {
slug,
type,
@@ -192,6 +221,8 @@ export const DocumentInfoProvider: React.FC<Props> = ({
getVersions,
publishedDoc,
id,
getDocPermissions,
docPermissions,
};
return (
@@ -202,5 +233,3 @@ export const DocumentInfoProvider: React.FC<Props> = ({
};
export const useDocumentInfo = (): ContextType => useContext(Context);
export default Context;

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { CollectionPermission, GlobalPermission } from '../../../../auth';
import { SanitizedCollectionConfig, TypeWithID, TypeWithTimestamps } from '../../../../collections/config/types';
import { SanitizedGlobalConfig } from '../../../../globals/config/types';
import { PaginatedDocs } from '../../../../mongoose/types';
@@ -6,10 +7,14 @@ import { TypeWithVersion } from '../../../../versions/types';
export type Version = TypeWithVersion<any>
export type DocumentPermissions = null | GlobalPermission | CollectionPermission
export type EntityType = 'global' | 'collection'
export type ContextType = {
collection?: SanitizedCollectionConfig
global?: SanitizedGlobalConfig
type: 'global' | 'collection'
type: EntityType
/** Slug of the collection or global */
slug?: string
id?: string | number
@@ -18,6 +23,8 @@ export type ContextType = {
unpublishedVersions?: PaginatedDocs<Version>
publishedDoc?: TypeWithID & TypeWithTimestamps & { _status?: string }
getVersions: () => Promise<void>
docPermissions: DocumentPermissions
getDocPermissions: () => Promise<void>
}
export type Props = {

View File

@@ -20,9 +20,9 @@ const GlobalView: React.FC<IndexProps> = (props) => {
const { state: locationState } = useLocation<{data?: Record<string, unknown>}>();
const locale = useLocale();
const { setStepNav } = useStepNav();
const { permissions, user } = useAuth();
const { user } = useAuth();
const [initialState, setInitialState] = useState<Fields>();
const { getVersions, preferencesKey } = useDocumentInfo();
const { getVersions, preferencesKey, docPermissions, getDocPermissions } = useDocumentInfo();
const { getPreference } = usePreferences();
const { t } = useTranslation();
@@ -50,9 +50,10 @@ const GlobalView: React.FC<IndexProps> = (props) => {
const onSave = useCallback(async (json) => {
getVersions();
getDocPermissions();
const state = await buildStateFromSchema({ fieldSchema: fields, data: json.result, operation: 'update', user, locale, t });
setInitialState(state);
}, [getVersions, fields, user, locale, t]);
}, [getVersions, fields, user, locale, t, getDocPermissions]);
const [{ data }] = usePayloadAPI(
`${serverURL}${api}/globals/${slug}`,
@@ -79,16 +80,14 @@ const GlobalView: React.FC<IndexProps> = (props) => {
awaitInitialState();
}, [dataToRender, fields, user, locale, getPreference, preferencesKey, t]);
const globalPermissions = permissions?.globals?.[slug];
return (
<RenderCustomComponent
DefaultComponent={DefaultGlobal}
CustomComponent={CustomEdit}
componentProps={{
isLoading: !initialState,
isLoading: !initialState || !docPermissions,
data: dataToRender,
permissions: globalPermissions,
permissions: docPermissions,
initialState,
global,
onSave,

View File

@@ -15,6 +15,7 @@ import { useDocumentInfo } from '../../../utilities/DocumentInfo';
import { Fields } from '../../../forms/Form/types';
import { usePreferences } from '../../../utilities/Preferences';
import { EditDepthContext } from '../../../utilities/EditDepth';
import { CollectionPermission } from '../../../../../auth';
const EditView: React.FC<IndexProps> = (props) => {
const { collection: incomingCollection, isEditing } = props;
@@ -40,20 +41,21 @@ const EditView: React.FC<IndexProps> = (props) => {
const { state: locationState } = useLocation();
const history = useHistory();
const [initialState, setInitialState] = useState<Fields>();
const { permissions, user } = useAuth();
const { getVersions, preferencesKey } = useDocumentInfo();
const { user } = useAuth();
const { getVersions, preferencesKey, getDocPermissions, docPermissions } = useDocumentInfo();
const { getPreference } = usePreferences();
const { t } = useTranslation('general');
const onSave = useCallback(async (json: any) => {
getVersions();
getDocPermissions();
if (!isEditing) {
setRedirect(`${admin}/collections/${collection.slug}/${json?.doc?.id}`);
} else {
const state = await buildStateFromSchema({ fieldSchema: collection.fields, data: json.doc, user, id, operation: 'update', locale, t });
setInitialState(state);
}
}, [admin, collection, isEditing, getVersions, user, id, t, locale]);
}, [admin, collection, isEditing, getVersions, user, id, t, locale, getDocPermissions]);
const [{ data, isLoading: isLoadingDocument, isError }] = usePayloadAPI(
(isEditing ? `${serverURL}${api}/${slug}/${id}` : null),
@@ -87,10 +89,9 @@ const EditView: React.FC<IndexProps> = (props) => {
);
}
const collectionPermissions = permissions?.collections?.[slug];
const apiURL = `${serverURL}${api}/${slug}/${id}${collection.versions.drafts ? '?draft=true' : ''}`;
const action = `${serverURL}${api}/${slug}${isEditing ? `/${id}` : ''}?locale=${locale}&depth=0&fallback-locale=null`;
const hasSavePermission = (isEditing && collectionPermissions?.update?.permission) || (!isEditing && collectionPermissions?.create?.permission);
const hasSavePermission = (isEditing && docPermissions?.update?.permission) || (!isEditing && (docPermissions as CollectionPermission)?.create?.permission);
return (
<EditDepthContext.Provider value={1}>
@@ -99,10 +100,10 @@ const EditView: React.FC<IndexProps> = (props) => {
CustomComponent={CustomEdit}
componentProps={{
id,
isLoading: !initialState,
isLoading: !initialState || !docPermissions,
data: dataToRender,
collection,
permissions: collectionPermissions,
permissions: docPermissions,
isEditing,
onSave,
initialState,

View File

@@ -1,9 +1,10 @@
import { PayloadRequest } from '../../express/types';
import { Permissions } from '../types';
import type { PayloadRequest } from '../../express/types';
import type { Permissions } from '../types';
import type { AllOperations } from '../../types';
import { adminInit as adminInitTelemetry } from '../../utilities/telemetry/events/adminInit';
import { tabHasName } from '../../fields/config/types';
import { getEntityPolicies } from '../../utilities/getEntityPolicies';
const allOperations = ['create', 'read', 'update', 'delete'];
const allOperations: AllOperations[] = ['create', 'read', 'update', 'delete'];
type Arguments = {
req: PayloadRequest
@@ -28,75 +29,6 @@ async function accessOperation(args: Arguments): Promise<Permissions> {
const isLoggedIn = !!(user);
const userCollectionConfig = (user && user.collection) ? config.collections.find((collection) => collection.slug === user.collection) : null;
const createAccessPromise = async (obj, access, operation, disableWhere = false) => {
const updatedObj = obj;
const result = await access({ req });
if (typeof result === 'object' && !disableWhere) {
updatedObj[operation] = {
permission: true,
where: result,
};
} else if (updatedObj[operation]?.permission !== false) {
updatedObj[operation] = {
permission: !!(result),
};
}
};
const executeFieldPolicies = (obj, fields, operation) => {
const updatedObj = obj;
fields.forEach(async (field) => {
if (field.name) {
if (!updatedObj[field.name]) updatedObj[field.name] = {};
if (field.access && typeof field.access[operation] === 'function') {
promises.push(createAccessPromise(updatedObj[field.name], field.access[operation], operation, true));
} else {
updatedObj[field.name][operation] = {
permission: isLoggedIn,
};
}
if (field.fields) {
if (!updatedObj[field.name].fields) updatedObj[field.name].fields = {};
executeFieldPolicies(updatedObj[field.name].fields, field.fields, operation);
}
} else if (field.fields) {
executeFieldPolicies(updatedObj, field.fields, operation);
} else if (field.type === 'tabs') {
field.tabs.forEach((tab) => {
if (tabHasName(tab)) {
if (!updatedObj[tab.name]) updatedObj[tab.name] = { fields: {} };
executeFieldPolicies(updatedObj[tab.name].fields, tab.fields, operation);
} else {
executeFieldPolicies(updatedObj, tab.fields, operation);
}
});
}
});
};
const executeEntityPolicies = async (entity, operations, type) => {
if (!results[type]) results[type] = {};
results[type][entity.slug] = {
fields: {},
};
operations.forEach((operation) => {
executeFieldPolicies(results[type][entity.slug].fields, entity.fields, operation);
if (typeof entity.access[operation] === 'function') {
promises.push(createAccessPromise(results[type][entity.slug], entity.access[operation], operation));
} else {
results[type][entity.slug][operation] = {
permission: isLoggedIn,
};
}
});
};
if (userCollectionConfig) {
results.canAccessAdmin = userCollectionConfig.access.admin ? await userCollectionConfig.access.admin(args) : isLoggedIn;
@@ -115,16 +47,37 @@ async function accessOperation(args: Arguments): Promise<Permissions> {
collectionOperations.push('readVersions');
}
executeEntityPolicies(collection, collectionOperations, 'collections');
const [collectionPolicy, collectionPromises] = getEntityPolicies({
type: 'collection',
req,
entity: collection,
operations: collectionOperations,
});
results.collections = {
...results.collections,
[collection.slug]: collectionPolicy,
};
promises.push(...collectionPromises);
});
config.globals.forEach((global) => {
const globalOperations = ['read', 'update'];
const globalOperations: AllOperations[] = ['read', 'update'];
if (global.versions) {
globalOperations.push('readVersions');
}
executeEntityPolicies(global, globalOperations, 'globals');
const [globalPolicy, globalPromises] = getEntityPolicies({
type: 'global',
req,
entity: global,
operations: globalOperations,
});
results.globals = {
...results.globals,
[global.slug]: globalPolicy,
};
promises.push(...globalPromises);
});
await Promise.all(promises);

View File

@@ -29,6 +29,7 @@ export type CollectionPermission = {
read: Permission
update: Permission
delete: Permission
readVersions?: Permission
fields: {
[field: string]: FieldPermissions
}
@@ -37,6 +38,7 @@ export type CollectionPermission = {
export type GlobalPermission = {
read: Permission
update: Permission
readVersions?: Permission
fields: {
[field: string]: FieldPermissions
}
@@ -44,8 +46,12 @@ export type GlobalPermission = {
export type Permissions = {
canAccessAdmin: boolean
collections: CollectionPermission[]
globals?: GlobalPermission[]
collections: {
[collectionSlug: string]: CollectionPermission
}
globals?: {
[globalSlug: string]: GlobalPermission
}
}
export type User = {

View File

@@ -18,6 +18,7 @@ import deleteHandler from './requestHandlers/delete';
import findByID from './requestHandlers/findByID';
import update, { deprecatedUpdate } from './requestHandlers/update';
import logoutHandler from '../auth/requestHandlers/logout';
import docAccessRequestHandler from './requestHandlers/docAccess';
const buildEndpoints = (collection: SanitizedCollectionConfig): Endpoint[] => {
let { endpoints } = collection;
@@ -119,6 +120,11 @@ const buildEndpoints = (collection: SanitizedCollectionConfig): Endpoint[] => {
method: 'post',
handler: create,
},
{
path: '/access/:id',
method: 'get',
handler: docAccessRequestHandler,
},
{
path: '/:id',
method: 'put',

View File

@@ -35,6 +35,8 @@ import buildWhereInputType from '../../graphql/schema/buildWhereInputType';
import getDeleteResolver from './resolvers/delete';
import { toWords, formatNames } from '../../utilities/formatLabels';
import { Collection, SanitizedCollectionConfig } from '../config/types';
import { buildPolicyType } from '../../graphql/schema/buildPoliciesType';
import { docAccessResolver } from './resolvers/docAccess';
function initCollectionsGraphQL(payload: Payload): void {
Object.keys(payload.collections).forEach((slug) => {
@@ -181,6 +183,19 @@ function initCollectionsGraphQL(payload: Payload): void {
resolve: findResolver(collection),
};
payload.Query.fields[`docAccess${singularName}`] = {
type: buildPolicyType({
typeSuffix: 'DocAccess',
entity: collection.config,
type: 'collection',
scope: 'docAccess',
}),
args: {
id: { type: new GraphQLNonNull(idType) },
},
resolve: docAccessResolver(),
};
payload.Mutation.fields[`create${singularName}`] = {
type: collection.graphQL.type,
args: {

View File

@@ -0,0 +1,25 @@
import { CollectionPermission, GlobalPermission } from '../../../auth';
import { PayloadRequest } from '../../../express/types';
import { docAccess } from '../../operations/docAccess';
export type Resolver = (
_: unknown,
args: {
id: string | number
},
context: {
req: PayloadRequest,
res: Response
}
) => Promise<CollectionPermission | GlobalPermission>
export function docAccessResolver(): Resolver {
async function resolver(_, args, context) {
return docAccess({
req: context.req,
id: args.id,
});
}
return resolver;
}

View File

@@ -0,0 +1,45 @@
import { AllOperations } from '../../types';
import { CollectionPermission } from '../../auth';
import type { PayloadRequest } from '../../express/types';
import { getEntityPolicies } from '../../utilities/getEntityPolicies';
const allOperations: AllOperations[] = ['create', 'read', 'update', 'delete'];
type Arguments = {
req: PayloadRequest
id: string
}
export async function docAccess(args: Arguments): Promise<CollectionPermission> {
const {
id,
req,
req: {
collection: {
config,
},
},
} = args;
const collectionOperations = [...allOperations];
if (config.auth && (typeof config.auth.maxLoginAttempts !== 'undefined' && config.auth.maxLoginAttempts !== 0)) {
collectionOperations.push('unlock');
}
if (config.versions) {
collectionOperations.push('readVersions');
}
const [policy, promises] = getEntityPolicies({
type: 'collection',
req,
entity: config,
operations: collectionOperations,
id,
});
await Promise.all(promises);
return policy;
}

View File

@@ -0,0 +1,18 @@
import { Response, NextFunction } from 'express';
import httpStatus from 'http-status';
import { PayloadRequest } from '../../express/types';
import { docAccess } from '../operations/docAccess';
import { CollectionPermission, GlobalPermission } from '../../auth';
export default async function docAccessRequestHandler(req: PayloadRequest, res: Response, next: NextFunction): Promise<Response<CollectionPermission | GlobalPermission> | void> {
try {
const accessResults = await docAccess({
req,
id: req.params.id,
});
return res.status(httpStatus.OK).json(accessResults);
} catch (error) {
return next(error);
}
}

View File

@@ -5,9 +5,10 @@ import restoreVersion from './requestHandlers/restoreVersion';
import { SanitizedGlobalConfig } from './config/types';
import update from './requestHandlers/update';
import findOne from './requestHandlers/findOne';
import docAccessRequestHandler from './requestHandlers/docAccess';
const buildEndpoints = (global: SanitizedGlobalConfig): Endpoint[] => {
const { endpoints } = global;
const { endpoints, slug } = global;
if (global.versions) {
endpoints.push(...[
@@ -30,6 +31,11 @@ const buildEndpoints = (global: SanitizedGlobalConfig): Endpoint[] => {
}
endpoints.push(...[
{
path: '/access',
method: 'get',
handler: async (req, res, next) => docAccessRequestHandler(req, res, next, global),
},
{
path: '/',
method: 'get',

View File

@@ -16,11 +16,13 @@ import buildWhereInputType from '../../graphql/schema/buildWhereInputType';
import { Field } from '../../fields/config/types';
import { toWords } from '../../utilities/formatLabels';
import { SanitizedGlobalConfig } from '../config/types';
import { buildPolicyType } from '../../graphql/schema/buildPoliciesType';
import { docAccessResolver } from './resolvers/docAccess';
function initGlobalsGraphQL(payload: Payload): void {
if (payload.config.globals) {
Object.keys(payload.globals.config).forEach((slug) => {
const global = payload.globals.config[slug];
const global: SanitizedGlobalConfig = payload.globals.config[slug];
const {
fields,
versions,
@@ -71,6 +73,16 @@ function initGlobalsGraphQL(payload: Payload): void {
resolve: updateResolver(global),
};
payload.Query.fields[`docAccess${formattedName}`] = {
type: buildPolicyType({
typeSuffix: 'DocAccess',
entity: global,
type: 'global',
scope: 'docAccess',
}),
resolve: docAccessResolver(global),
};
if (global.versions) {
const versionGlobalFields: Field[] = [
...buildVersionGlobalFields(global),

View File

@@ -0,0 +1,23 @@
import { CollectionPermission, GlobalPermission } from '../../../auth';
import { PayloadRequest } from '../../../express/types';
import { SanitizedGlobalConfig } from '../../config/types';
import { docAccess } from '../../operations/docAccess';
export type Resolver = (
_: unknown,
context: {
req: PayloadRequest,
res: Response
}
) => Promise<CollectionPermission | GlobalPermission>
export function docAccessResolver(global: SanitizedGlobalConfig): Resolver {
async function resolver(_, context) {
return docAccess({
req: context.req,
globalConfig: global,
});
}
return resolver;
}

View File

@@ -0,0 +1,34 @@
import { AllOperations } from '../../types';
import { GlobalPermission } from '../../auth';
import type { PayloadRequest } from '../../express/types';
import { getEntityPolicies } from '../../utilities/getEntityPolicies';
import { SanitizedGlobalConfig } from '../config/types';
type Arguments = {
req: PayloadRequest
globalConfig: SanitizedGlobalConfig
}
export async function docAccess(args: Arguments): Promise<GlobalPermission> {
const {
req,
globalConfig,
} = args;
const globalOperations: AllOperations[] = ['read', 'update'];
if (globalConfig.versions) {
globalOperations.push('readVersions');
}
const [policy, promises] = getEntityPolicies({
type: 'global',
req,
entity: globalConfig,
operations: globalOperations,
});
await Promise.all(promises);
return policy;
}

View File

@@ -0,0 +1,19 @@
import { Response, NextFunction } from 'express';
import httpStatus from 'http-status';
import { PayloadRequest } from '../../express/types';
import { docAccess } from '../operations/docAccess';
import { CollectionPermission, GlobalPermission } from '../../auth';
import { SanitizedGlobalConfig } from '../config/types';
export default async function docAccessRequestHandler(req: PayloadRequest, res: Response, next: NextFunction, globalConfig: SanitizedGlobalConfig): Promise<Response<CollectionPermission | GlobalPermission> | void> {
try {
const accessResults = await docAccess({
req,
globalConfig,
});
return res.status(httpStatus.OK).json(accessResults);
} catch (error) {
return next(error);
}
}

View File

@@ -2,14 +2,16 @@
import { GraphQLJSONObject } from 'graphql-type-json';
import { GraphQLBoolean, GraphQLNonNull, GraphQLObjectType } from 'graphql';
import formatName from '../utilities/formatName';
import { SanitizedCollectionConfig } from '../../collections/config/types';
import { SanitizedGlobalConfig } from '../../globals/config/types';
import { CollectionConfig, SanitizedCollectionConfig } from '../../collections/config/types';
import { GlobalConfig, SanitizedGlobalConfig } from '../../globals/config/types';
import { Field } from '../../fields/config/types';
import { Payload } from '../..';
import { toWords } from '../../utilities/formatLabels';
type OperationType = 'create' | 'read' | 'update' | 'delete' | 'unlock' | 'readVersions';
type AccessScopes = 'docAccess' | undefined
type ObjectTypeFields = {
[key in OperationType | 'fields']?: { type: GraphQLObjectType };
}
@@ -78,24 +80,31 @@ const buildFields = (label, fieldsToBuild) => fieldsToBuild.reduce((builtFields,
return builtFields;
}, {});
const buildEntity = (name: string, entityFields: Field[], operations: OperationType[]) => {
const formattedName = toWords(name, true);
type BuildEntityPolicy = {
name: string
entityFields: Field[]
operations: OperationType[]
scope: AccessScopes
}
export const buildEntityPolicy = (args: BuildEntityPolicy) => {
const { name, entityFields, operations, scope } = args;
const fieldsTypeName = toWords(`${name}-${scope || ''}-Fields`, true);
const fields = {
fields: {
type: new GraphQLObjectType({
name: formatName(`${formattedName}Fields`),
fields: buildFields(`${formattedName}Fields`, entityFields),
name: fieldsTypeName,
fields: buildFields(fieldsTypeName, entityFields),
}),
},
};
operations.forEach((operation) => {
const capitalizedOperation = operation.charAt(0).toUpperCase() + operation.slice(1);
const operationTypeName = toWords(`${name}-${operation}-${scope || 'Access'}`, true);
fields[operation] = {
type: new GraphQLObjectType({
name: `${formattedName}${capitalizedOperation}Access`,
name: operationTypeName,
fields: {
permission: { type: new GraphQLNonNull(GraphQLBoolean) },
where: { type: GraphQLJSONObject },
@@ -107,6 +116,66 @@ const buildEntity = (name: string, entityFields: Field[], operations: OperationT
return fields;
};
type BuildPolicyType = {
typeSuffix?: string
scope?: AccessScopes
} & ({
entity: CollectionConfig
type: 'collection'
} | {
entity: GlobalConfig
type: 'global'
})
export function buildPolicyType(args: BuildPolicyType): GraphQLObjectType {
const { typeSuffix, entity, type, scope } = args;
const { slug } = entity;
let operations = [];
if (type === 'collection') {
operations = ['create', 'read', 'update', 'delete'];
if (entity.auth && (typeof entity.auth === 'object' && typeof entity.auth.maxLoginAttempts !== 'undefined' && entity.auth.maxLoginAttempts !== 0)) {
operations.push('unlock');
}
if (entity.versions) {
operations.push('readVersions');
}
const collectionTypeName = formatName(`${slug}${typeSuffix || ''}`);
return new GraphQLObjectType({
name: collectionTypeName,
fields: buildEntityPolicy({
name: slug,
entityFields: entity.fields,
operations,
scope,
}),
});
}
// else create global type
operations = ['read', 'update'];
if (entity.versions) {
operations.push('readVersions');
}
const globalTypeName = formatName(`${global?.graphQL?.name || slug}${typeSuffix || ''}`);
return new GraphQLObjectType({
name: globalTypeName,
fields: buildEntityPolicy({
name: entity?.graphQL?.name || slug,
entityFields: entity.fields,
operations,
scope,
}),
});
}
export default function buildPoliciesType(payload: Payload): GraphQLObjectType {
const fields = {
canAccessAdmin: {
@@ -115,36 +184,26 @@ export default function buildPoliciesType(payload: Payload): GraphQLObjectType {
};
Object.values(payload.config.collections).forEach((collection: SanitizedCollectionConfig) => {
const collectionOperations: OperationType[] = ['create', 'read', 'update', 'delete'];
if (collection.auth && (typeof collection.auth.maxLoginAttempts !== 'undefined' && collection.auth.maxLoginAttempts !== 0)) {
collectionOperations.push('unlock');
}
if (collection.versions) {
collectionOperations.push('readVersions');
}
const collectionPolicyType = buildPolicyType({
typeSuffix: 'Access',
entity: collection,
type: 'collection',
});
fields[formatName(collection.slug)] = {
type: new GraphQLObjectType({
name: formatName(`${collection.slug}Access`),
fields: buildEntity(collection.slug, collection.fields, collectionOperations),
}),
type: collectionPolicyType,
};
});
Object.values(payload.config.globals).forEach((global: SanitizedGlobalConfig) => {
const globalOperations: OperationType[] = ['read', 'update'];
if (global.versions) {
globalOperations.push('readVersions');
}
const globalPolicyType = buildPolicyType({
typeSuffix: 'Access',
entity: global,
type: 'global',
});
fields[formatName(global.slug)] = {
type: new GraphQLObjectType({
name: formatName(`${global?.graphQL?.name || global.slug}Access`),
fields: buildEntity(global?.graphQL?.name || global.slug, global.fields, globalOperations),
}),
type: globalPolicyType,
};
});

View File

@@ -38,6 +38,9 @@ export interface PayloadMongooseDocument extends MongooseDocument {
}
export type Operation = 'create' | 'read' | 'update' | 'delete';
export type VersionOperations = 'readVersions';
export type AuthOperations = 'unlock';
export type AllOperations = Operation | VersionOperations | AuthOperations;
export function docHasTimestamps(doc: any): doc is TypeWithTimestamps {
return doc?.createdAt && doc?.updatedAt;

View File

@@ -0,0 +1,196 @@
import { Access } from '../config/types';
import { AllOperations, Where, Document } from '../types';
import { FieldAccess, tabHasName } from '../fields/config/types';
import type { CollectionConfig } from '../collections/config/types';
import type { GlobalConfig } from '../globals/config/types';
import type { PayloadRequest } from '../express/types';
import type { CollectionPermission, GlobalPermission } from '../auth/types';
import { TypeWithID } from '../collections/config/types';
type Args = ({
req: PayloadRequest
operations: AllOperations[]
id?: string
} & ({
type: 'collection'
entity: CollectionConfig
} | {
type: 'global'
entity: GlobalConfig
}))
type ReturnType<T extends Args> = T['type'] extends 'global' ? [GlobalPermission, Promise<void>[]] : [CollectionPermission, Promise<void>[]]
type CreateAccessPromise = (args: {
accessLevel: 'entity' | 'field',
policiesObj: {
[key: string]: any
}
access: Access | FieldAccess,
operation: AllOperations,
disableWhere?: boolean,
}) => Promise<void>
export function getEntityPolicies<T extends Args>(args: T): ReturnType<T> {
const { req, entity, operations, id, type } = args;
const isLoggedIn = !!(req.user);
// ---- ---- ---- ---- ---- ---- ---- ---- ----
// `policies` and `promises` get mutated in
// the functions below, and return in the end
// ---- ---- ---- ---- ---- ---- ---- ---- ----
const policies = {
fields: {},
} as ReturnType<T>[0];
const promises = [] as ReturnType<T>[1];
let docBeingAccessed;
async function getEntityDoc({ where }: { where?: Where } = {}): Promise<TypeWithID & Document> {
if (entity.slug) {
if (type === 'global') {
return req.payload.findGlobal({
overrideAccess: true,
slug: entity.slug,
});
}
if (type === 'collection' && id) {
if (typeof where === 'object') {
const paginatedRes = await req.payload.find({
overrideAccess: true,
collection: entity.slug,
where: {
...where,
and: [
...where.and || [],
{
id: {
equals: id,
},
},
],
},
limit: 1,
});
return paginatedRes?.docs?.[0] || undefined;
}
return req.payload.findByID({
overrideAccess: true,
collection: entity.slug,
id,
});
}
}
return undefined;
}
const createAccessPromise: CreateAccessPromise = async ({
policiesObj,
access,
operation,
disableWhere = false,
accessLevel,
}) => {
const mutablePolicies = policiesObj;
if (accessLevel === 'field' && docBeingAccessed === undefined) {
docBeingAccessed = await getEntityDoc();
}
const accessResult = await access({ req, id, doc: docBeingAccessed });
if (typeof accessResult === 'object' && !disableWhere) {
mutablePolicies[operation] = {
permission: !!(await getEntityDoc({ where: accessResult })),
where: accessResult,
};
} else if (mutablePolicies[operation]?.permission !== false) {
mutablePolicies[operation] = {
permission: !!(accessResult),
};
}
};
const executeFieldPolicies = ({
policiesObj = {},
fields,
operation,
}) => {
const mutablePolicies = policiesObj;
fields.forEach((field) => {
if (field.name) {
if (!mutablePolicies[field.name]) mutablePolicies[field.name] = {};
if (field.access && typeof field.access[operation] === 'function') {
promises.push(createAccessPromise({
policiesObj: mutablePolicies[field.name],
access: field.access[operation],
operation,
disableWhere: true,
accessLevel: 'field',
}));
} else {
mutablePolicies[field.name][operation] = {
permission: isLoggedIn,
};
}
if (field.fields) {
if (!mutablePolicies[field.name].fields) mutablePolicies[field.name].fields = {};
executeFieldPolicies({
policiesObj: mutablePolicies[field.name].fields,
fields: field.fields,
operation,
});
}
} else if (field.fields) {
executeFieldPolicies({
policiesObj: mutablePolicies,
fields: field.fields,
operation,
});
} else if (field.type === 'tabs') {
field.tabs.forEach((tab) => {
if (tabHasName(tab)) {
if (!mutablePolicies[tab.name]) mutablePolicies[tab.name] = { fields: {} };
executeFieldPolicies({
policiesObj: mutablePolicies[tab.name].fields,
fields: tab.fields,
operation,
});
} else {
executeFieldPolicies({
policiesObj: mutablePolicies,
fields: tab.fields,
operation,
});
}
});
}
});
};
operations.forEach((operation) => {
executeFieldPolicies({
policiesObj: policies.fields,
fields: entity.fields,
operation,
});
if (typeof entity.access[operation] === 'function') {
promises.push(createAccessPromise({
policiesObj: policies,
access: entity.access[operation],
operation,
accessLevel: 'entity',
}));
} else {
policies[operation] = {
permission: isLoggedIn,
};
}
});
return [policies, promises] as ReturnType<T>;
}

View File

@@ -10,6 +10,7 @@ export const restrictedSlug = 'restricted';
export const restrictedVersionsSlug = 'restricted-versions';
export const siblingDataSlug = 'sibling-data';
export const relyOnRequestHeadersSlug = 'rely-on-request-headers';
export const docLevelAccessSlug = 'doc-level-access';
const openAccess = {
create: () => true,
@@ -32,7 +33,7 @@ const UseRequestHeadersAccess: FieldAccess = ({ req: { headers } }) => {
export default buildConfig({
admin: {
user: 'users'
user: 'users',
},
collections: [
{
@@ -45,7 +46,7 @@ export default buildConfig({
setTimeout(resolve, 50, true); // set to 'true' or 'false' here to simulate the response
}),
},
fields: []
fields: [],
},
{
slug,
@@ -195,6 +196,52 @@ export default buildConfig({
},
],
},
{
slug: docLevelAccessSlug,
labels: {
singular: 'Doc Level Access',
plural: 'Doc Level Access',
},
access: {
delete: () => ({
and: [
{
approvedForRemoval: {
equals: true,
},
},
],
}),
},
fields: [
{
name: 'approvedForRemoval',
type: 'checkbox',
defaultValue: false,
admin: {
position: 'sidebar',
},
},
{
name: 'approvedTitle',
type: 'text',
localized: true,
access: {
update: (args) => {
if (args?.doc?.lockTitle) {
return false;
}
return true;
},
},
},
{
name: 'lockTitle',
type: 'checkbox',
defaultValue: false,
},
],
},
],
onInit: async (payload) => {
await payload.create({

View File

@@ -4,7 +4,7 @@ import payload from '../../src';
import { AdminUrlUtil } from '../helpers/adminUrlUtil';
import { initPayloadE2E } from '../helpers/configHelpers';
import { login } from '../helpers';
import { restrictedVersionsSlug, readOnlySlug, restrictedSlug, slug } from './config';
import { restrictedVersionsSlug, readOnlySlug, restrictedSlug, slug, docLevelAccessSlug } from './config';
import type { ReadOnlyCollection, RestrictedVersion } from './payload-types';
/**
@@ -17,16 +17,17 @@ import type { ReadOnlyCollection, RestrictedVersion } from './payload-types';
*/
const { beforeAll, describe } = test;
describe('access control', () => {
let page: Page;
let url: AdminUrlUtil;
let restrictedUrl: AdminUrlUtil;
let readOnlyUrl: AdminUrlUtil;
let restrictedVersionsUrl: AdminUrlUtil;
let serverURL: string;
beforeAll(async ({ browser }) => {
const { serverURL } = await initPayloadE2E(__dirname);
const config = await initPayloadE2E(__dirname);
serverURL = config.serverURL;
url = new AdminUrlUtil(serverURL, slug);
restrictedUrl = new AdminUrlUtil(serverURL, restrictedSlug);
@@ -174,11 +175,51 @@ describe('access control', () => {
await expect(page.locator('.versions-count')).not.toBeVisible();
});
});
describe('doc level access', () => {
let existingDoc: ReadOnlyCollection;
let docLevelAccessURL;
beforeAll(async () => {
docLevelAccessURL = new AdminUrlUtil(serverURL, docLevelAccessSlug);
existingDoc = await payload.create<any>({
collection: docLevelAccessSlug,
data: {
approvedTitle: 'Title',
lockTitle: true,
approvedForRemoval: false,
},
});
});
test('disable field based on document data', async () => {
await page.goto(docLevelAccessURL.edit(existingDoc.id));
// validate that the text input is disabled because the field is "locked"
const isDisabled = await page.locator('#field-approvedTitle').isDisabled();
expect(isDisabled).toBe(true);
});
test('disable operation based on document data', async () => {
await page.goto(docLevelAccessURL.edit(existingDoc.id));
// validate that the delete action is not displayed
const duplicateAction = page.locator('.collection-edit__collection-actions >> li').last();
await expect(duplicateAction).toContainText('Duplicate');
await page.locator('#field-approvedForRemoval + button').click();
await page.locator('#action-save').click();
const deleteAction = page.locator('.collection-edit__collection-actions >> li').last();
await expect(deleteAction).toContainText('Delete');
});
});
});
async function createDoc(data: any): Promise<{ id: string }> {
return payload.create({
collection: slug,
collection: docLevelAccessSlug,
data,
});
}