UNPKG

@sanity/sdk

Version:
241 lines (208 loc) 7.8 kB
import {type SanityDocument} from '@sanity/types' import {type ExprNode} from 'groq-js' import {createSelector} from 'reselect' import {type SelectorContext} from '../store/createStateSourceAction' import {getDraftId, getPublishedId} from '../utils/ids' import {MultiKeyWeakMap} from '../utils/MultiKeyWeakMap' import {evaluateSync, parse} from './_synchronous-groq-js.mjs' import {type DocumentAction} from './actions' import {ActionError, PermissionActionError, processActions} from './processActions' import {type DocumentSet} from './processMutations' import {type SyncTransactionState} from './reducers' export type Grant = 'read' | 'update' | 'create' | 'history' export type DatasetAcl = { filter: string permissions: Grant[] }[] export function createGrantsLookup(datasetAcl: DatasetAcl): Record<Grant, ExprNode> { const filtersByGrant: Record<Grant, Set<string>> = { create: new Set(), history: new Set(), read: new Set(), update: new Set(), } for (const entry of datasetAcl) { for (const grant of entry.permissions) { const set = filtersByGrant[grant] set.add(entry.filter) filtersByGrant[grant] = set } } return Object.fromEntries( Object.entries(filtersByGrant).map(([grant, filters]) => { const combinedFilter = Array.from(filters) .map((i) => `(${i})`) .join('||') if (!combinedFilter) return [grant, parse('false')] return [grant, parse(`$document {"_": ${combinedFilter}}._`)] }), ) as Record<Grant, ExprNode> } // Cache for documents based on an array of document objects. const documentsCache = new MultiKeyWeakMap<DocumentSet>() // Use a WeakMap so that when a computed DocumentSet is no longer in use, // its nested cache for actions can be garbage-collected. const actionsCache = new WeakMap<DocumentSet, Map<string, DocumentAction[]>>() const nullReplacer: object = {} // Compute documents from state and actions. // (If the same documents are computed, the MultiKeyWeakMap will return the same instance.) const documentsSelector = createSelector( [ ({state: {documentStates}}: SelectorContext<SyncTransactionState>) => documentStates, (_context: SelectorContext<SyncTransactionState>, actions: DocumentAction | DocumentAction[]) => actions, ], (documentStates, actions) => { const documentIds = new Set( (Array.isArray(actions) ? actions : [actions]) .map((i) => i.documentId) .filter((i) => typeof i === 'string') .flatMap((documentId) => [getPublishedId(documentId), getDraftId(documentId)]), ) const documents: DocumentSet = {} for (const documentId of documentIds) { const local = documentStates[documentId]?.local // early exit if we don't have all the documents yet if (local === undefined) return undefined documents[documentId] = local } // Create a key from the documents values (using a nullReplacer when needed). const keys = Object.values( // value in this record will be `undefined` because // of the early return if undefined is found above documents as Record<string, SanityDocument | null>, ).map((doc) => (doc === null ? nullReplacer : doc)) const cached = documentsCache.get(keys) if (cached) return cached documentsCache.set(keys, documents) return documents }, ) // Cache the actions array based on a serialized version, but “attach” the cache // to the computed documents. That way if the computed documents object is no longer in use, // the cache is eligible for GC. const memoizedActionsSelector = createSelector( [ documentsSelector, (_state: SelectorContext<SyncTransactionState>, actions: DocumentAction | DocumentAction[]) => actions, ], (documents, actions) => { if (!documents) return undefined // Get (or create) the nested Map for this computed documents. let nestedCache = actionsCache.get(documents) if (!nestedCache) { nestedCache = new Map<string, DocumentAction[]>() actionsCache.set(documents, nestedCache) } const normalizedActions = Array.isArray(actions) ? actions : [actions] // Use JSON.stringify to get a serialized key for the actions. // TODO: considering swapping thisfor a more efficient or stable hash const actionsKey = JSON.stringify(normalizedActions) const cached = nestedCache.get(actionsKey) if (cached) return cached nestedCache.set(actionsKey, normalizedActions) return normalizedActions }, ) function checkGrant(grantExpr: ExprNode, document: SanityDocument): boolean { return evaluateSync(grantExpr, {params: {document}}).get() } /** @beta */ export interface PermissionDeniedReason { type: 'precondition' | 'access' message: string documentId?: string } /** @beta */ export type DocumentPermissionsResult = | { allowed: false message: string reasons: PermissionDeniedReason[] } | {allowed: true; message?: undefined; reasons?: undefined} const enNarrowConjunction = new Intl.ListFormat('en', {style: 'narrow', type: 'conjunction'}) export function calculatePermissions( ...args: Parameters<typeof _calculatePermissions> ): ReturnType<typeof _calculatePermissions> { return _calculatePermissions(...args) } const _calculatePermissions = createSelector( [ ({state: {grants}}: SelectorContext<SyncTransactionState>) => grants, documentsSelector, memoizedActionsSelector, ], ( grants: Record<Grant, ExprNode> | undefined, documents: DocumentSet | undefined, actions: DocumentAction[] | undefined, ): DocumentPermissionsResult | undefined => { if (!documents) return undefined if (!grants) return undefined if (!actions) return undefined const timestamp = new Date().toISOString() const reasons: PermissionDeniedReason[] = [] try { processActions({ actions, transactionId: crypto.randomUUID(), working: documents, base: documents, timestamp, grants, }) } catch (error) { if (error instanceof PermissionActionError) { reasons.push({ message: error.message, documentId: error.documentId, type: 'access', }) } else if (error instanceof ActionError) { reasons.push({ message: error.message, documentId: error.documentId, type: 'precondition', }) } else { throw error } } for (const action of actions) { // Check edit actions with no patches if (action.type === 'document.edit' && !action.patches?.length) { const docId = action.documentId const doc = documents[getDraftId(docId)] ?? documents[getPublishedId(docId)] if (!doc) { reasons.push({ type: 'precondition', message: `The document with ID "${docId}" could not be found. Please check that it exists before editing.`, documentId: docId, }) } else if (!checkGrant(grants.update, doc)) { reasons.push({ type: 'access', message: `You are not allowed to edit the document with ID "${docId}".`, documentId: docId, }) } } } const allowed = reasons.length === 0 if (allowed) return {allowed} const sortedReasons = reasons .map((reason, index) => ({...reason, index})) .sort((a, b) => { if (a.type !== b.type) return a.type === 'access' ? -1 : 1 return a.message.localeCompare(b.message, 'en-US') }) .map(({index: _index, ...reason}) => reason) return { allowed, reasons: sortedReasons, message: enNarrowConjunction.format(sortedReasons.map((i) => i.message)), } }, )