feat: add document auth (#1029)

This commit is contained in:
David Nguyen
2024-03-28 13:13:29 +08:00
committed by GitHub
parent 956562d3b4
commit a54eb54ef7
77 changed files with 3904 additions and 846 deletions

View File

@ -7,6 +7,7 @@ import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import type { TRecipientActionAuth } from '../../types/document-auth';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sealDocument } from './seal-document';
import { sendPendingEmail } from './send-pending-email';
@ -14,6 +15,8 @@ import { sendPendingEmail } from './send-pending-email';
export type CompleteDocumentWithTokenOptions = {
token: string;
documentId: number;
userId?: number;
authOptions?: TRecipientActionAuth;
requestMetadata?: RequestMetadata;
};
@ -71,32 +74,54 @@ export const completeDocumentWithToken = async ({
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
}
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
},
});
// Document reauth for completing documents is currently not required.
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
documentId: document.id,
user: {
name: recipient.name,
email: recipient.email,
// const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
// documentAuth: document.authOptions,
// recipientAuth: recipient.authOptions,
// });
// const isValid = await isRecipientAuthorized({
// type: 'ACTION',
// document: document,
// recipient: recipient,
// userId,
// authOptions,
// });
// if (!isValid) {
// throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
// }
await prisma.$transaction(async (tx) => {
await tx.recipient.update({
where: {
id: recipient.id,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
},
}),
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
documentId: document.id,
user: {
name: recipient.name,
email: recipient.email,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
// actionAuth: derivedRecipientActionAuth || undefined,
},
}),
});
});
const pendingRecipients = await prisma.recipient.count({

View File

@ -1,13 +1,39 @@
import { prisma } from '@documenso/prisma';
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuthMethods } from '../../types/document-auth';
import { isRecipientAuthorized } from './is-recipient-authorized';
export interface GetDocumentAndSenderByTokenOptions {
token: string;
userId?: number;
accessAuth?: TDocumentAuthMethods;
/**
* Whether we enforce the access requirement.
*
* Defaults to true.
*/
requireAccessAuth?: boolean;
}
export interface GetDocumentAndRecipientByTokenOptions {
token: string;
userId?: number;
accessAuth?: TDocumentAuthMethods;
/**
* Whether we enforce the access requirement.
*
* Defaults to true.
*/
requireAccessAuth?: boolean;
}
export type GetDocumentByTokenOptions = {
token: string;
};
export type GetDocumentAndSenderByTokenOptions = GetDocumentByTokenOptions;
export type GetDocumentAndRecipientByTokenOptions = GetDocumentByTokenOptions;
export const getDocumentByToken = async ({ token }: GetDocumentByTokenOptions) => {
if (!token) {
throw new Error('Missing token');
@ -26,8 +52,13 @@ export const getDocumentByToken = async ({ token }: GetDocumentByTokenOptions) =
return result;
};
export type DocumentAndSender = Awaited<ReturnType<typeof getDocumentAndSenderByToken>>;
export const getDocumentAndSenderByToken = async ({
token,
userId,
accessAuth,
requireAccessAuth = true,
}: GetDocumentAndSenderByTokenOptions) => {
if (!token) {
throw new Error('Missing token');
@ -45,12 +76,40 @@ export const getDocumentAndSenderByToken = async ({
User: true,
documentData: true,
documentMeta: true,
Recipient: {
where: {
token,
},
},
},
});
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
const { password: _password, ...User } = result.User;
const recipient = result.Recipient[0];
// Sanity check, should not be possible.
if (!recipient) {
throw new Error('Missing recipient');
}
let documentAccessValid = true;
if (requireAccessAuth) {
documentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
document: result,
recipient,
userId,
authOptions: accessAuth,
});
}
if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values');
}
return {
...result,
User,
@ -62,6 +121,9 @@ export const getDocumentAndSenderByToken = async ({
*/
export const getDocumentAndRecipientByToken = async ({
token,
userId,
accessAuth,
requireAccessAuth = true,
}: GetDocumentAndRecipientByTokenOptions): Promise<DocumentWithRecipient> => {
if (!token) {
throw new Error('Missing token');
@ -85,6 +147,29 @@ export const getDocumentAndRecipientByToken = async ({
},
});
const recipient = result.Recipient[0];
// Sanity check, should not be possible.
if (!recipient) {
throw new Error('Missing recipient');
}
let documentAccessValid = true;
if (requireAccessAuth) {
documentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
document: result,
recipient,
userId,
authOptions: accessAuth,
});
}
if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values');
}
return {
...result,
Recipient: result.Recipient,

View File

@ -0,0 +1,86 @@
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import type { Document, Recipient } from '@documenso/prisma/client';
import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
type IsRecipientAuthorizedOptions = {
type: 'ACCESS' | 'ACTION';
document: Document;
recipient: Recipient;
/**
* The ID of the user who initiated the request.
*/
userId?: number;
/**
* The auth details to check.
*
* Optional because there are scenarios where no auth options are required such as
* using the user ID.
*/
authOptions?: TDocumentAuthMethods;
};
const getUserByEmail = async (email: string) => {
return await prisma.user.findFirst({
where: {
email,
},
select: {
id: true,
},
});
};
/**
* Whether the recipient is authorized to perform the requested operation on a
* document, given the provided auth options.
*
* @returns True if the recipient can perform the requested operation.
*/
export const isRecipientAuthorized = async ({
type,
document,
recipient,
userId,
authOptions,
}: IsRecipientAuthorizedOptions): Promise<boolean> => {
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
const authMethod: TDocumentAuth | null =
type === 'ACCESS' ? derivedRecipientAccessAuth : derivedRecipientActionAuth;
// Early true return when auth is not required.
if (!authMethod || authMethod === DocumentAuth.EXPLICIT_NONE) {
return true;
}
// Authentication required does not match provided method.
if (authOptions && authOptions.type !== authMethod) {
return false;
}
return await match(authMethod)
.with(DocumentAuth.ACCOUNT, async () => {
if (userId === undefined) {
return false;
}
const recipientUser = await getUserByEmail(recipient.email);
if (!recipientUser) {
return false;
}
return recipientUser.id === userId;
})
.exhaustive();
};

View File

@ -95,7 +95,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
data: {
emailType: 'DOCUMENT_COMPLETED',
recipientEmail: owner.email,
recipientName: owner.name,
recipientName: owner.name ?? '',
recipientId: owner.id,
recipientRole: 'OWNER',
isResending: false,

View File

@ -0,0 +1,178 @@
'use server';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
export type UpdateDocumentSettingsOptions = {
userId: number;
teamId?: number;
documentId: number;
data: {
title?: string;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
};
requestMetadata?: RequestMetadata;
};
export const updateDocumentSettings = async ({
userId,
teamId,
documentId,
data,
requestMetadata,
}: UpdateDocumentSettingsOptions) => {
if (!data.title && !data.globalAccessAuth && !data.globalActionAuth) {
throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update');
}
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
});
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null;
const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null;
// If the new global auth values aren't passed in, fallback to the current document values.
const newGlobalAccessAuth =
data?.globalAccessAuth === undefined ? documentGlobalAccessAuth : data.globalAccessAuth;
const newGlobalActionAuth =
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
// Check if user has permission to set the global action auth.
if (newGlobalActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isDocumentEnterprise) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to set the action auth',
);
}
}
const isTitleSame = data.title === document.title;
const isGlobalAccessSame = documentGlobalAccessAuth === newGlobalAccessAuth;
const isGlobalActionSame = documentGlobalActionAuth === newGlobalActionAuth;
const auditLogs: CreateDocumentAuditLogDataResponse[] = [];
if (!isTitleSame && document.status !== DocumentStatus.DRAFT) {
throw new AppError(
AppErrorCode.INVALID_BODY,
'You cannot update the title if the document has been sent',
);
}
if (!isTitleSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED,
documentId,
user,
requestMetadata,
data: {
from: document.title,
to: data.title || '',
},
}),
);
}
if (!isGlobalAccessSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED,
documentId,
user,
requestMetadata,
data: {
from: documentGlobalAccessAuth,
to: newGlobalAccessAuth,
},
}),
);
}
if (!isGlobalActionSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED,
documentId,
user,
requestMetadata,
data: {
from: documentGlobalActionAuth,
to: newGlobalActionAuth,
},
}),
);
}
// Early return if nothing is required.
if (auditLogs.length === 0) {
return document;
}
return await prisma.$transaction(async (tx) => {
const authOptions = createDocumentAuthOptions({
globalAccessAuth: newGlobalAccessAuth,
globalActionAuth: newGlobalActionAuth,
});
const updatedDocument = await tx.document.update({
where: {
id: documentId,
},
data: {
title: data.title,
authOptions,
},
});
await tx.documentAuditLog.createMany({
data: auditLogs,
});
return updatedDocument;
});
};

View File

@ -5,15 +5,21 @@ import { prisma } from '@documenso/prisma';
import { ReadStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import type { TDocumentAccessAuthTypes } from '../../types/document-auth';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getDocumentAndRecipientByToken } from './get-document-by-token';
export type ViewedDocumentOptions = {
token: string;
recipientAccessAuth?: TDocumentAccessAuthTypes | null;
requestMetadata?: RequestMetadata;
};
export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentOptions) => {
export const viewedDocument = async ({
token,
recipientAccessAuth,
requestMetadata,
}: ViewedDocumentOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
token,
@ -51,12 +57,13 @@ export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentO
recipientId: recipient.id,
recipientName: recipient.name,
recipientRole: recipient.role,
accessAuth: recipientAccessAuth || undefined,
},
}),
});
});
const document = await getDocumentAndRecipientByToken({ token });
const document = await getDocumentAndRecipientByToken({ token, requireAccessAuth: false });
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_OPENED,