UNPKG

@sanity/sdk

Version:
251 lines (218 loc) 8.32 kB
import {type SanityDocument} from '@sanity/types' import {evaluateSync, type ExprNode, parse} 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 {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}: {actions: DocumentAction[]}) => actions, ], (documentStates, actions) => { // Collect all document IDs needed for permission checks. // Important: liveEdit documents don't have drafts, so we only fetch the single document to avoid waiting for non-existent draft documents. const documentIds = new Set( actions .map((action) => { if (typeof action.documentId !== 'string') return [] // For liveEdit documents, only fetch the single document if (action.liveEdit) return [action.documentId] // For standard documents, fetch both draft and published return [getPublishedId(action.documentId), getDraftId(action.documentId)] }) .flat(), ) 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}: {actions: 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 { const value = evaluateSync(grantExpr, {params: {document}}) return value.type === 'boolean' && value.data } /** @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 // For liveEdit documents, only check the single document const doc = action.liveEdit ? documents[docId] : (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)), } }, )