Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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 (
<div>
<SettingsHeader hideDivider title={pageHeader} subtitle={pageSubtitle} />

<Alert className="mt-8" variant="neutral">
<AlertDescription>
<Trans>Custom sending domains are disabled on this installation.</Trans>
</AlertDescription>
</Alert>
</div>
);
}

if (isLoadingEmailDomain) {
return <SpinnerBox className="py-32" />;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 (
<div>
<SettingsHeader
hideDivider
title={t`Email Domains`}
subtitle={t`Here you can add email domains to your organisation.`}
/>

<Alert className="mt-8" variant="neutral">
<AlertTitle>
<Trans>Email Domains</Trans>
</AlertTitle>

<AlertDescription>
<Trans>Custom sending domains are disabled on this installation.</Trans>
</AlertDescription>
</Alert>
</div>
);
}

return (
<div>
Expand Down
25 changes: 23 additions & 2 deletions apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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(
Expand All @@ -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 (
<div>
<SettingsHeader
hideDivider
title={t`Organisation SSO Portal`}
subtitle={t`Manage a custom SSO login portal for your organisation.`}
/>

<Alert>
<AlertDescription>
<Trans>Single sign-on is disabled on this installation.</Trans>
</AlertDescription>
</Alert>
</div>
);
}

if (isLoadingAuthenticationPortal || !authenticationPortal) {
return <SpinnerBox className="py-32" />;
}
Expand Down
6 changes: 2 additions & 4 deletions apps/remix/app/routes/_unauthenticated+/o.$orgUrl.signin.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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',
});
Expand Down
8 changes: 7 additions & 1 deletion packages/auth/server/lib/utils/organisation-portal.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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'
Expand Down
29 changes: 29 additions & 0 deletions packages/lib/constants/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
33 changes: 33 additions & 0 deletions packages/lib/server-only/email-domain/audit.ts
Original file line number Diff line number Diff line change
@@ -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,
});
};
62 changes: 62 additions & 0 deletions packages/lib/server-only/email-domain/concurrency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { MAX_CONCURRENT_EXTERNAL_OPERATIONS } from './constants';

export type Semaphore = {
run: <TResult>(task: () => Promise<TResult>) => Promise<TResult>;
};

/**
* 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<void> => {
if (activeCount < limit) {
activeCount += 1;
return;
}

await new Promise<void>((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);
17 changes: 17 additions & 0 deletions packages/lib/server-only/email-domain/constant-time.ts
Original file line number Diff line number Diff line change
@@ -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);
};
Loading
Loading