diff --git a/packages/auth/server/lib/session/session.ts b/packages/auth/server/lib/session/session.ts index 5e741406d..75d3830d7 100644 --- a/packages/auth/server/lib/session/session.ts +++ b/packages/auth/server/lib/session/session.ts @@ -14,7 +14,7 @@ import { AUTH_SESSION_LIFETIME } from '../../config'; */ export type SessionUser = Pick< User, - 'id' | 'name' | 'email' | 'emailVerified' | 'avatarImageId' | 'twoFactorEnabled' | 'roles' | 'signature' + 'id' | 'name' | 'email' | 'emailVerified' | 'avatarImageId' | 'twoFactorEnabled' | 'roles' | 'signature' | 'disabled' >; export type SessionValidationResult = @@ -86,6 +86,7 @@ export const validateSessionToken = async (token: string): Promise) => { + await assertUserNotDisabledById({ userId: user.userId }); + const metadata = c.get('requestMetadata'); const sessionToken = generateSessionToken(); diff --git a/packages/auth/server/routes/email-password.ts b/packages/auth/server/routes/email-password.ts index 26beb0175..174fb9d7b 100644 --- a/packages/auth/server/routes/email-password.ts +++ b/packages/auth/server/routes/email-password.ts @@ -171,12 +171,8 @@ export const emailPasswordRoute = new Hono() }); } - if (user.disabled) { - throw new AppError('ACCOUNT_DISABLED', { - message: 'Account disabled', - }); - } - + // The disabled check now lives inside `onAuthorize` so every sign-in path + // (password, passkey, OAuth, OIDC) shares the same enforcement. await onAuthorize({ userId: user.id }, c); return c.text('', 201); diff --git a/packages/lib/server-only/document/resend-document.ts b/packages/lib/server-only/document/resend-document.ts index 9b4ab968e..bb4fa7870 100644 --- a/packages/lib/server-only/document/resend-document.ts +++ b/packages/lib/server-only/document/resend-document.ts @@ -30,6 +30,7 @@ import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { assertOrgEmailSendAllowed } from '../email/assert-org-email-send-allowed'; import { getEmailContext } from '../email/get-email-context'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; +import { assertUserNotDisabled } from '../user/assert-user-not-disabled'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; export type ResendDocumentOptions = { @@ -49,9 +50,14 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe id: true, email: true, name: true, + disabled: true, }, }); + // Refuse to resend on behalf of a disabled account. Guards + // document.redistribute / envelope.redistribute and the API v1 equivalent. + assertUserNotDisabled(user); + const { envelopeWhereInput } = await getEnvelopeWhereInput({ id, type: EnvelopeType.DOCUMENT, diff --git a/packages/lib/server-only/document/send-document.ts b/packages/lib/server-only/document/send-document.ts index 09997e0c3..01fcffa1b 100644 --- a/packages/lib/server-only/document/send-document.ts +++ b/packages/lib/server-only/document/send-document.ts @@ -39,6 +39,7 @@ import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields'; import { getRecipientsWithMissingFields, isRecipientEmailValidForSending } from '../../utils/recipients'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf'; +import { assertUserNotDisabledById } from '../user/assert-user-not-disabled'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; export type SendDocumentOptions = { @@ -50,6 +51,11 @@ export type SendDocumentOptions = { }; export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetadata }: SendDocumentOptions) => { + // Refuse to send on behalf of a disabled account. Guards distribute / + // redistribute / template-use routes, the bulk-send job, and direct + // templates that auto-send on creation. + await assertUserNotDisabledById({ userId }); + const { envelopeWhereInput } = await getEnvelopeWhereInput({ id, type: EnvelopeType.DOCUMENT, diff --git a/packages/lib/server-only/envelope/create-envelope.ts b/packages/lib/server-only/envelope/create-envelope.ts index 37c221b90..f228a635e 100644 --- a/packages/lib/server-only/envelope/create-envelope.ts +++ b/packages/lib/server-only/envelope/create-envelope.ts @@ -37,6 +37,7 @@ import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../uti import { buildTeamWhereQuery } from '../../utils/teams'; import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id'; import { getTeamSettings } from '../team/get-team-settings'; +import { assertUserNotDisabledById } from '../user/assert-user-not-disabled'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; type CreateEnvelopeRecipientFieldOptions = TFieldAndMeta & { @@ -116,6 +117,11 @@ export const createEnvelope = async ({ internalVersion, bypassDefaultRecipients = false, }: CreateEnvelopeOptions) => { + // Refuse to create on behalf of a disabled account. Guards every route that + // funnels through here (document.create, envelope.use, template create, + // embedding template/document create, API v1) and the seed/job paths. + await assertUserNotDisabledById({ userId }); + const { type, title, diff --git a/packages/lib/server-only/user/assert-user-not-disabled.ts b/packages/lib/server-only/user/assert-user-not-disabled.ts new file mode 100644 index 000000000..d4e789da8 --- /dev/null +++ b/packages/lib/server-only/user/assert-user-not-disabled.ts @@ -0,0 +1,48 @@ +import { prisma } from '@documenso/prisma'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; + +/** + * Throws if the supplied user object is disabled. + * + * Synchronous variant for hot paths where the `disabled` field has already + * been loaded (e.g. TRPC middleware where the user comes from the session + * query or API token lookup). + */ +export const assertUserNotDisabled = (user: { disabled: boolean }): void => { + if (user.disabled) { + throw new AppError('ACCOUNT_DISABLED', { + message: 'Account disabled', + statusCode: 403, + }); + } +}; + +export type AssertUserNotDisabledByIdOptions = { + userId: number; +}; + +/** + * Throws if the user with the given id does not exist or is disabled. + * + * Used as a defence-in-depth guard for sign-in chokepoints and server-side + * actions that should not be performed on behalf of a disabled account + * (e.g. creating or sending documents). It deliberately re-queries from the + * database rather than relying on cached context so a freshly-disabled user + * cannot continue to act through a stale session or token. + */ +export const assertUserNotDisabledById = async ({ userId }: AssertUserNotDisabledByIdOptions): Promise => { + const user = await prisma.user.findFirst({ + where: { id: userId }, + select: { disabled: true }, + }); + + if (!user) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'User not found', + statusCode: 404, + }); + } + + assertUserNotDisabled(user); +}; diff --git a/packages/trpc/server/trpc.ts b/packages/trpc/server/trpc.ts index 00da2bbc9..2d2f8673c 100644 --- a/packages/trpc/server/trpc.ts +++ b/packages/trpc/server/trpc.ts @@ -1,5 +1,6 @@ import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error'; import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token'; +import { assertUserNotDisabled } from '@documenso/lib/server-only/user/assert-user-not-disabled'; import type { TrpcApiLog } from '@documenso/lib/types/api-logs'; import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { alphaid } from '@documenso/lib/universal/id'; @@ -96,6 +97,10 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path, me const apiToken = await getApiTokenByToken({ token }); + // Reject API requests from a disabled account. The token may still be + // present in the DB (e.g. before `disableUser` runs) so we enforce here. + assertUserNotDisabled(apiToken.user); + const trpcApiV2Logger = ctx.logger.child({ ...baseLogAttributes, auth: 'api', @@ -140,6 +145,11 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path, me }); } + // Reject session requests from a disabled account. The session may still be + // valid (sessions aren't invalidated by `disableUser`), so we gate every + // authenticated TRPC call here. + assertUserNotDisabled(ctx.user); + // Recreate the logger with a sub request ID to differentiate between batched // requests, as well as identifying attributes so every subsequent log line // (including errors) inherits them. @@ -199,6 +209,11 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat const apiToken = await getApiTokenByToken({ token }); + // Reject API requests from a disabled account. Presenting an API token is + // an explicit attempt to act under that account, so we don't downgrade to + // anonymous here — we reject. + assertUserNotDisabled(apiToken.user); + // Attach identifying attributes to the logger so every subsequent log line // within this request (including errors) inherits them. const trpcApiV2Logger = ctx.logger.child({ @@ -238,9 +253,17 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat }); } + // Treat a disabled session as anonymous. Most routes wired through + // `maybeAuthenticatedProcedure` are signer/invite flows that key off an + // input token rather than `ctx.user`, so downgrading lets those keep + // working while routes that genuinely need an account naturally fall + // through to their own auth checks. + const sessionUser = ctx.user && !ctx.user.disabled ? ctx.user : null; + const sessionRecord = sessionUser ? ctx.session : null; + // Resolve `auth` once so it stays in sync between the logger bindings and // the outgoing metadata. - const auth = ctx.session ? 'session' : null; + const auth = sessionRecord ? 'session' : null; // Recreate the logger with a sub request ID to differentiate between batched // requests, as well as identifying attributes so every subsequent log line @@ -249,7 +272,7 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat ...baseLogAttributes, auth, nonBatchedRequestId: alphaid(), - userId: ctx.user?.id, + userId: sessionUser?.id, apiTokenId: null, } satisfies TrpcApiLog); @@ -261,15 +284,15 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat ctx: { ...ctx, logger: trpcSessionLogger, - user: ctx.user, - session: ctx.session, + user: sessionUser, + session: sessionRecord, metadata: { ...ctx.metadata, - auditUser: ctx.user + auditUser: sessionUser ? { - id: ctx.user.id, - name: ctx.user.name, - email: ctx.user.email, + id: sessionUser.id, + name: sessionUser.name, + email: sessionUser.email, } : undefined, auth, @@ -286,6 +309,9 @@ export const adminMiddleware = t.middleware(async ({ ctx, next, path }) => { }); } + // Disabled admins shouldn't be able to do anything either. + assertUserNotDisabled(ctx.user); + const isUserAdmin = isAdmin(ctx.user); if (!isUserAdmin) {