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
302 lines (267 loc) • 9.17 kB
text/typescript
import {type SanityClient} from '@sanity/client'
import {type SanityDocument, type Schema, type SchemaType} from '@sanity/types'
import {useMemo} from 'react'
import {combineLatest, type Observable, of} from 'rxjs'
import {map, switchMap} from 'rxjs/operators'
import {useClient, useSchema} from '../../../hooks'
import {DEFAULT_STUDIO_CLIENT_OPTIONS} from '../../../studioClient'
import {
createHookFromObservableFactory,
getDraftId,
getPublishedId,
type PartialExcept,
} from '../../../util'
import {useGrantsStore} from '../datastores'
import {snapshotPair} from '../document'
import {type GrantsStore, type PermissionCheckResult} from './types'
function getSchemaType(schema: Schema, typeName: string): SchemaType {
const type = schema.get(typeName)
if (!type) {
throw new Error(`No such schema type: ${typeName}`)
}
return type
}
interface PairPermissionsOptions {
grantsStore: GrantsStore
permission: DocumentPermission
draft: SanityDocument | null
published: SanityDocument | null
liveEdit: boolean
}
function getPairPermissions({
grantsStore,
permission,
draft,
published,
liveEdit,
}: PairPermissionsOptions): Array<[string, Observable<PermissionCheckResult>] | null> {
// this was introduced because we ran into a bug where a user with publish
// access was marked as not allowed to duplicate a document unless it had a
// draft variant. this would happen in non-live edit cases where the document
// pair only had a published variant with the draft variant being null.
//
// note: this should _not_ be used if the draft and published versions should
// be considered separately/explicitly in the permissions.
const effectiveVersion = draft || published
const effectiveVersionType = effectiveVersion === draft ? 'draft' : 'published'
const {checkDocumentPermission} = grantsStore
switch (permission) {
case 'delete': {
if (liveEdit) {
return [
['delete published document (live-edit)', checkDocumentPermission('update', published)],
]
}
return [
['delete draft document', checkDocumentPermission('update', draft)],
['delete published document', checkDocumentPermission('update', published)],
]
}
case 'discardDraft': {
if (liveEdit) return []
return [['delete draft document', checkDocumentPermission('update', draft)]]
}
case 'publish': {
if (liveEdit) return []
return [
// precondition
[
'update published document at its current state',
checkDocumentPermission('update', published),
],
// post condition
['delete draft document', checkDocumentPermission('update', draft)],
[
'create published document from draft',
checkDocumentPermission('create', draft && {...draft, _id: getPublishedId(draft._id)}),
],
]
}
case 'unpublish': {
if (liveEdit) return []
return [
// precondition
['update draft document at its current state', checkDocumentPermission('create', draft)],
// post condition
['delete published document', checkDocumentPermission('update', published)],
[
'create draft document from published version',
checkDocumentPermission(
'create',
published && {...published, _id: getDraftId(published._id)},
),
],
]
}
case 'update': {
if (liveEdit) {
return [
['update published document (live-edit)', checkDocumentPermission('update', published)],
]
}
return [
[
`update ${effectiveVersionType} document`,
checkDocumentPermission('update', effectiveVersion),
],
]
}
case 'duplicate': {
if (liveEdit) {
return [
[
'create new published document from existing document (live-edit)',
checkDocumentPermission('create', {...published, _id: 'dummy-id'}),
],
]
}
return [
[
`create new draft document from existing ${effectiveVersionType} document`,
checkDocumentPermission('create', {...effectiveVersion, _id: getDraftId('dummy-id')}),
],
]
}
default: {
throw new Error(`Could not match permission: ${permission}`)
}
}
}
/** @internal */
export type DocumentPermission =
| 'delete'
| 'discardDraft'
| 'publish'
| 'unpublish'
| 'update'
| 'duplicate'
/** @internal */
export interface DocumentPairPermissionsOptions {
client: SanityClient
schema: Schema
grantsStore: GrantsStore
id: string
type: string
permission: DocumentPermission
}
/**
* The observable version of `useDocumentPairPermissions`
*
* @see useDocumentPairPermissions
*
* @internal
*/
export function getDocumentPairPermissions({
client,
grantsStore,
schema,
id,
permission,
type,
}: DocumentPairPermissionsOptions): Observable<PermissionCheckResult> {
// this case was added to fix a crash that would occur if the `schemaType` was
// omitted from `S.documentList()`
//
// see `resolveTypeForDocument` which returns `'*'` if no type is provided
// https://github.com/sanity-io/sanity/blob/4d49b83a987d5097064d567f75d21b268a410cbf/packages/%40sanity/base/src/datastores/document/resolveTypeForDocument.ts#L7
if (type === '*') {
return of({granted: false, reason: 'Type specified was `*`'})
}
const liveEdit = Boolean(getSchemaType(schema, type).liveEdit)
return snapshotPair(
client,
{draftId: getDraftId(id), publishedId: getPublishedId(id)},
type,
).pipe(
switchMap((pair) =>
combineLatest([pair.draft.snapshots$, pair.published.snapshots$]).pipe(
map(([draft, published]) => ({draft, published})),
),
),
switchMap(({draft, published}) => {
const pairPermissions = getPairPermissions({
grantsStore,
permission,
draft,
published,
liveEdit,
}).map(([label, observable]: any) =>
observable.pipe(
map(({granted, reason}: any) => ({
granted,
reason: granted ? '' : `not allowed to ${label}: ${reason}`,
label,
permission,
})),
),
)
if (!pairPermissions.length) return of({granted: true, reason: ''})
return combineLatest(pairPermissions).pipe(
map((permissionResults: any[]) => {
const granted = permissionResults.every((permissionResult) => permissionResult.granted)
const reason = granted
? ''
: `Unable to ${permission}:\n\t${permissionResults
.filter((permissionResult) => !permissionResult.granted)
.map((permissionResult) => permissionResult.reason)
.join('\n\t')}`
return {granted, reason}
}),
)
}),
)
}
/**
* Gets document pair permissions based on a document ID and a type.
*
* This permissions API is a high-level permissions API that is draft-model
* aware. In order to determine whether or not the user has the given
* permission, both the draft and published documents are pulled and run through
* all of the user's grants. If any pre or post conditions fail a permissions
* checks, the operations will not be granted.
*
* The operations this hook accepts are only relevant to document pairs. E.g.
* `'create'` is not included as an operation because it's not possible to tell
* if a document can be created by only using the initial ID and type because an
* initial template value may not have a matching grant (e.g. locked-document
* pattern `!locked`). In contrast, the operation `'duplicate'` is supported
* because the draft value of the document can be live queried and checked for
* matching grants.
*
* Note: for live-edit documents, non-applicable operations (e.g. publish) will
* return as true.
*
* @see useDocumentValuePermissions
*
* @internal
*/
export const useDocumentPairPermissionsFromHookFactory = createHookFromObservableFactory(
getDocumentPairPermissions,
)
/** @internal */
export function useDocumentPairPermissions({
id,
type,
permission,
client: overrideClient,
schema: overrideSchema,
grantsStore: overrideGrantsStore,
}: PartialExcept<DocumentPairPermissionsOptions, 'id' | 'type' | 'permission'>): ReturnType<
typeof useDocumentPairPermissionsFromHookFactory
> {
const defaultClient = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS)
const defaultSchema = useSchema()
const defaultGrantsStore = useGrantsStore()
const client = useMemo(() => overrideClient || defaultClient, [defaultClient, overrideClient])
const schema = useMemo(() => overrideSchema || defaultSchema, [defaultSchema, overrideSchema])
const grantsStore = useMemo(
() => overrideGrantsStore || defaultGrantsStore,
[defaultGrantsStore, overrideGrantsStore],
)
return useDocumentPairPermissionsFromHookFactory(
useMemo(
() => ({client, schema, grantsStore, id, permission, type}),
[client, grantsStore, id, permission, schema, type],
),
)
}