diff --git a/.env.example b/.env.example index 68476d1b8a..70d62ee2eb 100644 --- a/.env.example +++ b/.env.example @@ -276,3 +276,18 @@ NEXT_PRIVATE_PLAIN_API_KEY= # DOS_INTERNAL_API_KEY="" # NEXT_PRIVATE_DOS_INTERNAL_API_KEY="" +# [[IN-HOUSE FEATURE FLAGS]] +# Custom sending domains and the organisation SSO portal are implemented +# in-house, so this installation gates them with its own flags instead of an +# upstream licence claim. Both default to ENABLED; set to `false` to switch a +# feature off instance-wide. The client-side flags +# NEXT_PUBLIC_FEATURE_EMAIL_DOMAINS_ENABLED / NEXT_PUBLIC_FEATURE_SSO_PORTAL_ENABLED +# are derived from these in createPublicEnv() - do not set them directly. +# OPTIONAL: Custom sending domains (DKIM). Also requires the NEXT_PRIVATE_SES_* +# credentials; without them the API fails closed with NOT_SETUP rather than +# creating a domain that could never send. +# CROVE_FEATURE_EMAIL_DOMAINS="true" +# OPTIONAL: Organisation single sign-on portal (any OpenID Connect provider). +# Each organisation must still enable and configure its own portal. +# CROVE_FEATURE_SSO_PORTAL="true" + diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx index 8836d058ad..6e6bfdeef7 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx @@ -1,5 +1,5 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; -import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app'; +import { IS_EMAIL_DOMAINS_ENABLED } from '@documenso/lib/constants/app'; import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains'; import { trpc } from '@documenso/trpc/react'; import type { TGetOrganisationEmailDomainResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-email-domain.types'; @@ -27,7 +27,6 @@ import { OrganisationEmailDomainRecordsDialog } from '~/components/dialogs/organ import { OrganisationEmailUpdateDialog } from '~/components/dialogs/organisation-email-update-dialog'; import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { SettingsHeader } from '~/components/general/settings-header'; -import { EmailDomainsUpsell } from '~/components/general/settings-upsell/email-domains-upsell'; import type { Route } from './+types/o.$orgUrl.settings.email-domains.$id'; @@ -100,6 +99,20 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co const pageHeader = t`Email Domain Settings`; const pageSubtitle = t`Manage your email domain settings.`; + if (!IS_EMAIL_DOMAINS_ENABLED()) { + return ( +
+ + + + + Custom sending domains are disabled on this installation. + + +
+ ); + } + if (isLoadingEmailDomain) { return ; } diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx index e6bd29dfa3..f95d75e000 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx @@ -1,15 +1,10 @@ -import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; -import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app'; -import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations'; +import { IS_EMAIL_DOMAINS_ENABLED } from '@documenso/lib/constants/app'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; -import { Button } from '@documenso/ui/primitives/button'; import { msg } from '@lingui/core/macro'; import { Trans, useLingui } from '@lingui/react/macro'; -import { Link } from 'react-router'; import { OrganisationEmailDomainCreateDialog } from '~/components/dialogs/organisation-email-domain-create-dialog'; import { SettingsHeader } from '~/components/general/settings-header'; -import { EmailDomainsUpsell } from '~/components/general/settings-upsell/email-domains-upsell'; import { OrganisationEmailDomainsDataTable } from '~/components/tables/organisation-email-domains-table'; import { appMetaTags } from '~/utils/meta'; @@ -20,9 +15,29 @@ export function meta() { export default function OrganisationSettingsEmailDomains() { const { t } = useLingui(); - const organisation = useCurrentOrganisation(); + const isEmailDomainsEnabled = IS_EMAIL_DOMAINS_ENABLED(); - const isEmailDomainsEnabled = true; + if (!isEmailDomainsEnabled) { + return ( +
+ + + + + Email Domains + + + + Custom sending domains are disabled on this installation. + + +
+ ); + } return (
diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx index d774d4a80d..c93db427a5 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx @@ -1,5 +1,5 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; -import { IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app'; +import { IS_DOCUMENSO_CLOUD, IS_SSO_PORTAL_ENABLED } from '@documenso/lib/constants/app'; import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations'; import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations'; import { @@ -65,7 +65,7 @@ export default function OrganisationSettingSSOLoginPage() { const { t } = useLingui(); const organisation = useCurrentOrganisation(); - const isAuthenticationPortalEnabled = true; + const isAuthenticationPortalEnabled = IS_SSO_PORTAL_ENABLED(); const { data: authenticationPortal, isLoading: isLoadingAuthenticationPortal } = trpc.enterprise.organisation.authenticationPortal.get.useQuery( @@ -91,6 +91,27 @@ export default function OrganisationSettingSSOLoginPage() { ); } + // Self-hosted installations that turned the feature off get a plain notice + // instead of an upsell: the query below is disabled, so without this branch + // the page would render its loading state forever. + if (!isAuthenticationPortalEnabled) { + return ( +
+ + + + + Single sign-on is disabled on this installation. + + +
+ ); + } + if (isLoadingAuthenticationPortal || !authenticationPortal) { return ; } diff --git a/apps/remix/app/routes/_unauthenticated+/o.$orgUrl.signin.tsx b/apps/remix/app/routes/_unauthenticated+/o.$orgUrl.signin.tsx index cbf226b7c8..7f9f215c65 100644 --- a/apps/remix/app/routes/_unauthenticated+/o.$orgUrl.signin.tsx +++ b/apps/remix/app/routes/_unauthenticated+/o.$orgUrl.signin.tsx @@ -1,5 +1,6 @@ import { authClient } from '@documenso/auth/client'; import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; +import { IS_SSO_PORTAL_ENABLED } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { prisma } from '@documenso/prisma'; import { Button } from '@documenso/ui/primitives/button'; @@ -68,10 +69,7 @@ export async function loader({ request, params }: Route.LoaderArgs) { }, }); - if ( - !organisation || - !organisation.organisationAuthenticationPortal.enabled - ) { + if (!IS_SSO_PORTAL_ENABLED() || !organisation || !organisation.organisationAuthenticationPortal.enabled) { throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Organisation not found', }); diff --git a/packages/auth/server/lib/utils/organisation-portal.ts b/packages/auth/server/lib/utils/organisation-portal.ts index 9fb4d4c67b..6c6bccbc4b 100644 --- a/packages/auth/server/lib/utils/organisation-portal.ts +++ b/packages/auth/server/lib/utils/organisation-portal.ts @@ -1,4 +1,4 @@ -import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { IS_SSO_PORTAL_ENABLED } from '@documenso/lib/constants/app'; import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; @@ -18,6 +18,12 @@ type GetOrganisationAuthenticationPortalOptions = export const getOrganisationAuthenticationPortalOptions = async ( options: GetOrganisationAuthenticationPortalOptions, ) => { + if (!IS_SSO_PORTAL_ENABLED()) { + throw new AppError(AppErrorCode.NOT_SETUP, { + message: 'The organisation SSO portal is disabled on this installation', + }); + } + const organisation = await prisma.organisation.findFirst({ where: options.type === 'url' diff --git a/packages/lib/constants/app.ts b/packages/lib/constants/app.ts index fb9e880a69..477c017e6b 100644 --- a/packages/lib/constants/app.ts +++ b/packages/lib/constants/app.ts @@ -53,6 +53,35 @@ export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = () => export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true'; +/** + * Custom sending domains are implemented in-house, so this installation gates + * them with its own flag instead of an upstream licence claim. Enabled unless + * the variable is explicitly `false`. + * + * Platform-aware like {@link IS_AI_FEATURES_CONFIGURED}: the server reads the + * private variable, the client reads the public flag derived from it in + * `createPublicEnv` so the navigation cannot advertise what the API will refuse. + */ +export const IS_EMAIL_DOMAINS_ENABLED = (): boolean => { + if (typeof window === 'undefined') { + return env('CROVE_FEATURE_EMAIL_DOMAINS') !== 'false'; + } + + return env('NEXT_PUBLIC_FEATURE_EMAIL_DOMAINS_ENABLED') !== 'false'; +}; + +/** + * The organisation SSO portal is implemented in-house and gated the same way as + * {@link IS_EMAIL_DOMAINS_ENABLED}. + */ +export const IS_SSO_PORTAL_ENABLED = (): boolean => { + if (typeof window === 'undefined') { + return env('CROVE_FEATURE_SSO_PORTAL') !== 'false'; + } + + return env('NEXT_PUBLIC_FEATURE_SSO_PORTAL_ENABLED') !== 'false'; +}; + /** * Whether this instance is Documenso Cloud (managed SaaS). * diff --git a/packages/lib/jobs/definitions/internal/sync-email-domains.handler.ts b/packages/lib/jobs/definitions/internal/sync-email-domains.handler.ts index 9acfc3d6a2..f80881414f 100644 --- a/packages/lib/jobs/definitions/internal/sync-email-domains.handler.ts +++ b/packages/lib/jobs/definitions/internal/sync-email-domains.handler.ts @@ -1,8 +1,8 @@ -// Use the lib (fork) implementations, not the EE originals: the fork's -// getSesClient() returns null when SES is unconfigured and falls back to DNS -// verification, while the EE versions throw - which made this job fail for -// every pending domain on SES-less deployments while the manual "Verify" -// button kept working. +// Custom sending domains are an in-house feature and require Amazon SES: the +// helpers below fail closed with NOT_SETUP when the NEXT_PRIVATE_SES_* +// credentials are missing. On such an installation this job degrades to an +// error count per pending domain (Promise.allSettled) instead of silently +// reporting progress it never made. import { reregisterEmailDomain } from '@documenso/lib/server-only/email-domain/reregister-email-domain'; import { verifyEmailDomain } from '@documenso/lib/server-only/email-domain/verify-email-domain'; import { prisma } from '@documenso/prisma'; diff --git a/packages/lib/server-only/email-domain/audit.ts b/packages/lib/server-only/email-domain/audit.ts new file mode 100644 index 0000000000..739ba0316b --- /dev/null +++ b/packages/lib/server-only/email-domain/audit.ts @@ -0,0 +1,33 @@ +import type { EmailDomainStatus } from '@prisma/client'; + +import { logger } from '../../utils/logger'; +import type { EmailDomainTransitionEvent } from './types'; + +export type EmailDomainTransition = { + event: EmailDomainTransitionEvent; + emailDomainId: string; + organisationId: string; + domain: string; + previousStatus: EmailDomainStatus | null; + nextStatus: EmailDomainStatus | null; + reason: string; + /** + * Only populated for `takeover`, where two organisations are involved and the + * audit line has to be attributable to both. + */ + takingOverOrganisationId?: string; +}; + +/** + * Emit the single structured audit line for a state transition. + * + * Key material is never part of a transition record: the DKIM private key and the + * ownership-challenge token are both secrets, and the selector/public key are + * already public in DNS so they add nothing to an investigation. + */ +export const logEmailDomainTransition = (transition: EmailDomainTransition): void => { + logger.info({ + msg: 'email_domain_transition', + ...transition, + }); +}; diff --git a/packages/lib/server-only/email-domain/concurrency.ts b/packages/lib/server-only/email-domain/concurrency.ts new file mode 100644 index 0000000000..7a2d21251b --- /dev/null +++ b/packages/lib/server-only/email-domain/concurrency.ts @@ -0,0 +1,62 @@ +import { MAX_CONCURRENT_EXTERNAL_OPERATIONS } from './constants'; + +export type Semaphore = { + run: (task: () => Promise) => Promise; +}; + +/** + * A counting semaphore over asynchronous work. + * + * Tasks must never acquire the semaphore recursively, otherwise the pool can + * deadlock waiting on a slot held by its own caller. + */ +export const createSemaphore = (limit: number): Semaphore => { + let activeCount = 0; + const waiters: Array<() => void> = []; + + const release = () => { + const nextWaiter = waiters.shift(); + + // Handing the slot straight to a waiter keeps `activeCount` correct without + // a decrement/increment pair that another task could slip in between. + if (nextWaiter) { + nextWaiter(); + return; + } + + activeCount -= 1; + }; + + const acquire = async (): Promise => { + if (activeCount < limit) { + activeCount += 1; + return; + } + + await new Promise((resolve) => { + waiters.push(resolve); + }); + }; + + return { + run: async (task) => { + await acquire(); + + try { + return await task(); + } finally { + release(); + } + }, + }; +}; + +/** + * Shared ceiling for every outbound DNS and SES call. + * + * Verification is triggered both by an administrator pressing "Verify" — which + * fans out across every domain in an organisation at once — and by an hourly job. + * Without a process-wide bound a single click could open hundreds of concurrent + * sockets to resolvers and to SES. + */ +export const externalOperationSemaphore = createSemaphore(MAX_CONCURRENT_EXTERNAL_OPERATIONS); diff --git a/packages/lib/server-only/email-domain/constant-time.ts b/packages/lib/server-only/email-domain/constant-time.ts new file mode 100644 index 0000000000..cb1c81e6b9 --- /dev/null +++ b/packages/lib/server-only/email-domain/constant-time.ts @@ -0,0 +1,17 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; + +/** + * Compare two strings without leaking how much of them matched. + * + * `timingSafeEqual` refuses buffers of differing length, and the length itself is + * already a hint, so both sides are folded through SHA-256 first. That keeps the + * comparison constant-time for inputs of any length while still being an exact + * equality test — a digest collision is not reachable by an attacker who cannot + * read the expected value. + */ +export const isConstantTimeEqual = (left: string, right: string): boolean => { + const leftDigest = createHash('sha256').update(left, 'utf8').digest(); + const rightDigest = createHash('sha256').update(right, 'utf8').digest(); + + return timingSafeEqual(leftDigest, rightDigest); +}; diff --git a/packages/lib/server-only/email-domain/constants.ts b/packages/lib/server-only/email-domain/constants.ts new file mode 100644 index 0000000000..9244a4eb1a --- /dev/null +++ b/packages/lib/server-only/email-domain/constants.ts @@ -0,0 +1,94 @@ +/** + * Host (relative to the zone apex) of the TXT record that proves control of a + * domain. + * + * Deliberately independent of Amazon SES: DKIM and SPF records can be published + * by anyone who can reach a zone's DNS, so they cannot on their own bind a + * domain claim to the organisation that started it. + */ +export const OWNERSHIP_CHALLENGE_LABEL = '_crove-verify'; + +export const OWNERSHIP_CHALLENGE_VALUE_PREFIX = 'crove-domain-verification='; + +/** + * Domain-separation prefix for the ownership-challenge HMAC. Bumping the version + * invalidates every outstanding challenge, which is the intended escape hatch if + * the derivation ever needs to change. + */ +export const OWNERSHIP_CHALLENGE_HMAC_CONTEXT = 'crove:email-domain-ownership-challenge:v1'; + +export const DKIM_SELECTOR_PREFIX = 'crove-'; + +export const DKIM_SELECTOR_RANDOM_LENGTH = 12; + +/** + * RFC 6376 fixes the parent of a DKIM public-key record to `_domainkey`. + * + * The stored `selector` column holds the record *host* (`