UNPKG

sanity

Version:

Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches

143 lines (122 loc) 4.61 kB
import {type SanityClient} from '@sanity/client' import {type CurrentUser, type SanityDocument} from '@sanity/types' import {evaluate, parse} from 'groq-js' import {defer, of} from 'rxjs' import {distinctUntilChanged, publishReplay, switchMap} from 'rxjs/operators' import {refCountDelay} from 'rxjs-etc/operators' import shallowEquals from 'shallow-equals' import {debugGrants$} from './debug' import { type DocumentValuePermission, type EvaluationParams, type Grant, type GrantsStore, type PermissionCheckResult, } from './types' async function getDatasetGrants( client: SanityClient, projectId: string, dataset: string, ): Promise<Grant[]> { // `acl` stands for access control list and returns a list of grants const grants: Grant[] = await client.request({ uri: `/projects/${projectId}/datasets/${dataset}/acl`, tag: 'acl.get', withCredentials: true, }) return grants } function getParams(userId: string | null): EvaluationParams { const params: EvaluationParams = {} if (userId !== null) { params.identity = userId } return params } const PARSED_FILTERS_MEMO = new Map() async function matchesFilter(userId: string | null, filter: string, document: SanityDocument) { if (!PARSED_FILTERS_MEMO.has(filter)) { // note: it might be tempting to also memoize the result of the evaluation here, // Currently these filters are typically evaluated whenever a document change, which means they will be evaluated // quite frequently with different versions of the document. There might be some gains in finding out which subset of document // properties to use as key (e.g. by looking at the parsed filter and see what properties the filter cares about) // But as always, it's worth considering if the complexity/memory usage is worth the potential perf gain… PARSED_FILTERS_MEMO.set(filter, parse(`*[${filter}]`)) } const parsed = PARSED_FILTERS_MEMO.get(filter) const evalParams = getParams(userId) const {identity} = evalParams const params: Record<string, unknown> = {...evalParams} const data = await (await evaluate(parsed, {dataset: [document], identity, params})).get() return data?.length === 1 } interface GrantsStoreOptionsCurrentUser { client: SanityClient /** * @deprecated The `currentUser` option is deprecated. Use `userId` instead. */ currentUser: CurrentUser | null } interface GrantsStoreOptionsUserId { client: SanityClient userId: string | null } /** @internal */ export type GrantsStoreOptions = GrantsStoreOptionsCurrentUser | GrantsStoreOptionsUserId /** @internal */ export function createGrantsStore(opts: GrantsStoreOptions): GrantsStore { const {client} = opts const versionedClient = client.withConfig({apiVersion: '2021-06-07'}) const userId = 'userId' in opts ? opts.userId : opts?.currentUser?.id || null const datasetGrants$ = defer(() => of(versionedClient.config())).pipe( switchMap(({projectId, dataset}) => { if (!projectId || !dataset) { throw new Error('Missing projectId or dataset') } return getDatasetGrants(versionedClient, projectId, dataset) }), ) const currentUserDatasetGrants = debugGrants$.pipe( switchMap((debugGrants) => (debugGrants ? of(debugGrants) : datasetGrants$)), publishReplay(1), refCountDelay(1000), ) return { checkDocumentPermission(permission: DocumentValuePermission, document: SanityDocument) { return currentUserDatasetGrants.pipe( switchMap((grants) => grantsPermissionOn(userId, grants, permission, document)), distinctUntilChanged(shallowEquals), ) }, } } /** * @internal * Takes a grants object, a permission and a document * checks whether the permission is granted for the given document */ export async function grantsPermissionOn( userId: string | null, grants: Grant[], permission: DocumentValuePermission, document: SanityDocument | null, ): Promise<PermissionCheckResult> { if (!document) { // we say it's granted if null due to initial states return {granted: true, reason: 'Null document, nothing to check'} } if (!grants.length) { return {granted: false, reason: 'No document grants'} } const matchingGrants: Grant[] = [] for (const grant of grants) { if (await matchesFilter(userId, grant.filter, document)) { matchingGrants.push(grant) } } const foundMatch = matchingGrants.some((grant) => grant.permissions.some((p) => p === permission)) return { granted: foundMatch, reason: foundMatch ? `Matching grant` : `No matching grants found`, } }