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
3 changes: 3 additions & 0 deletions backend/src/common/data-injection.tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export enum UseCaseType {
SAAS_UPDATE_HOSTED_CONNECTION_PASSWORD = 'SAAS_UPDATE_HOSTED_CONNECTION_PASSWORD',
SAAS_GET_CONNECTIONS_INFO_BY_IDS = 'SAAS_GET_CONNECTIONS_INFO_BY_IDS',
SAAS_GET_HOSTED_CONNECTION_CREDENTIALS = 'SAAS_GET_HOSTED_CONNECTION_CREDENTIALS',
SAAS_OTP_LOGIN = 'SAAS_OTP_LOGIN',

INVITE_USER_IN_COMPANY_AND_CONNECTION_GROUP = 'INVITE_USER_IN_COMPANY_AND_CONNECTION_GROUP',
VERIFY_INVITE_USER_IN_COMPANY_AND_CONNECTION_GROUP = 'VERIFY_INVITE_USER_IN_COMPANY_AND_CONNECTION_GROUP',
Expand Down Expand Up @@ -242,6 +243,8 @@ export enum UseCaseType {

IS_CONFIGURED = 'IS_CONFIGURED',
CREATE_INITIAL_USER = 'CREATE_INITIAL_USER',
SELFHOSTED_UPDATE_USER_PASSWORD = 'SELFHOSTED_UPDATE_USER_PASSWORD',
SELFHOSTED_UPDATE_USER_EMAIL = 'SELFHOSTED_UPDATE_USER_EMAIL',

GENERATE_SCHEMA_CHANGE = 'GENERATE_SCHEMA_CHANGE',
APPROVE_SCHEMA_CHANGE = 'APPROVE_SCHEMA_CHANGE',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ export class InviteUserInCompanyAndConnectionGroupDs {
inviteLinkBase?: string;
/** Satellite-provided prefix for the confirmation link the re-invite branch sends to inactive users. */
emailVerificationLinkBase?: string;
/** Bridge-only (plan 15 Phase 2): skip the send(s) and return the email payload instead. */
suppressEmail?: boolean;
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
import { ApiProperty } from '@nestjs/swagger';
import { OutgoingEmailPayloadDs } from '../../../email/application/data-structures/outgoing-email-payload.ds.js';
import { UserRoleEnum } from '../../../user/enums/user-role.enum.js';

export class InvitedUserInCompanyAndConnectionGroupDs {
@ApiProperty()
companyId: string;

@ApiProperty()
groupId: string;
@ApiProperty({ nullable: true, type: String })
groupId: string | null;

@ApiProperty()
email: string;

@ApiProperty({ enum: UserRoleEnum })
role: UserRoleEnum;

// Present only when the /saas/* bridge caller set `suppressEmail: true` (plan 15 Phase 2).
@ApiProperty({ required: false, type: OutgoingEmailPayloadDs })
emailPayload?: OutgoingEmailPayloadDs;

// suppressEmail-only: the invited address belongs to an existing-but-inactive user, so no
// invitation was created — the payload above is a re-confirmation letter instead. Returned as
// a marked success (not thrown) because the global exception filter serializes a fixed shape
// and would strip the payload from an error body; the SaaS caller translates this back into
// the user-facing 400 after sending the letter.
@ApiProperty({ required: false })
userAlreadyAddedInactive?: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { BaseType } from '../../../common/data-injection.tokens.js';
import { Messages } from '../../../exceptions/text/messages.js';
import { isSaaS } from '../../../helpers/app/is-saas.js';
import { isTest } from '../../../helpers/app/is-test.js';
import { Constants } from '../../../helpers/constants/constants.js';
import { ValidationHelper } from '../../../helpers/validators/validation-helper.js';
import { SaasCompanyGatewayService } from '../../../microservices/gateways/saas-gateway.ts/saas-company-gateway.service.js';
import { EmailService } from '../../email/email/email.service.js';
Expand Down Expand Up @@ -72,28 +73,45 @@ export class InviteUserInCompanyAndConnectionGroupUseCase
}

if (foundInvitedUser && !foundInvitedUser.isActive) {
const companyCustomDomain = await this.saasCompanyGatewayService.getCompanyCustomDomainById(companyId);
// Trigger inversion (plan 15 Phase 2): with `suppressEmail` (bridge-only) the re-confirmation
// letter is NOT sent here — the raw token travels back as a MARKED SUCCESS (the global
// exception filter would strip extra fields from an error body) and the SaaS caller sends
// the letter, then surfaces the user-facing 400 itself. Never log the raw token here.
if (inputData.suppressEmail) {
const { rawToken: suppressedRawToken } =
await this._dbContext.emailVerificationRepository.createOrUpdateEmailVerification(foundInvitedUser);
return {
companyId,
groupId: groupId ?? null,
email: foundInvitedUser.email,
role: invitedUserCompanyRole,
userAlreadyAddedInactive: true,
emailPayload: {
type: 'email_confirmation',
to: foundInvitedUser.email,
rawToken: suppressedRawToken,
companyId,
},
};
}

const { rawToken } =
await this._dbContext.emailVerificationRepository.createOrUpdateEmailVerification(foundInvitedUser);

const sendEmailResult = await this.emailService.sendEmailConfirmation(
foundInvitedUser.email,
rawToken,
companyCustomDomain,
ValidationHelper.resolveEmailVerificationLinkBase(inputData.emailVerificationLinkBase),
);

if (!sendEmailResult && !isTest() && !isSaaS()) {
throw new HttpException(
{
message: Messages.EMAIL_SEND_FAILED(invitedUserEmail),
},
HttpStatus.INTERNAL_SERVER_ERROR,
if (isSaaS()) {
const companyCustomDomain = await this.saasCompanyGatewayService.getCompanyCustomDomainById(companyId);
await this.emailService.sendEmailConfirmation(
foundInvitedUser.email,
rawToken,
companyCustomDomain,
ValidationHelper.resolveEmailVerificationLinkBase(inputData.emailVerificationLinkBase),
);
} else {
// Plan 15 Phase 6 (rev 5): self-hosted sends no email — the admin takes the full
// confirmation link from the server logs and hands it to the user.
this.logger.printTechString(
`Email confirmation link: ${Constants.APP_DOMAIN_ADDRESS}/external/user/email/verify/${rawToken}`,
);
Comment on lines +109 to 114

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Raw single-use tokens are written to the application logs in self-hosted mode.

Both printTechString calls embed the raw verification token in a complete link. The token grants email confirmation or company-invitation acceptance. Log files are usually retained, rotated, and shipped to external transports, so the exposure is wider than the local administrator console.

The comments state this is the intended self-hosted delivery mechanism. If that decision stands, restrict the log level for these two lines and document the retention requirement. Consider gating the output behind an explicit configuration flag so operators who ship logs off-host can disable it.

Also applies to: 164-170

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/src/entities/company-info/use-cases/invite-user-in-company.use.case.ts`
around lines 109 - 114, Restrict the two self-hosted confirmation-link log calls
in the invite-user flow, including the nearby call around the company invitation
path, behind an explicit configuration flag that is disabled by default.
Preserve link delivery only when the flag is enabled, and document the required
secure log retention for operators who enable it.

}

if (!isSaaS()) {
this.logger.printTechString(`Invitation verification string: ${rawToken}`);
}
throw new HttpException(
{
Expand All @@ -110,21 +128,46 @@ export class InviteUserInCompanyAndConnectionGroupUseCase
invitedUserEmail,
invitedUserCompanyRole,
);
const companyCustomDomain = await this.saasCompanyGatewayService.getCompanyCustomDomainById(companyId);
await this.emailService.sendInvitationToCompany(
invitedUserEmail,
rawToken,
companyId,
foundCompany.name,
companyCustomDomain,
ValidationHelper.resolveEmailVerificationLinkBase(inputData.inviteLinkBase),
);
const invitationRO: any = {
const invitationRO: InvitedUserInCompanyAndConnectionGroupDs & { verificationString?: string } = {
companyId: companyId,
groupId: groupId,
email: invitedUserEmail,
role: invitedUserCompanyRole,
};

// Trigger inversion (plan 15 Phase 2): the SaaS caller builds the invite link and sends the
// letter itself — return the raw token instead of sending (and never log it).
if (inputData.suppressEmail) {
invitationRO.emailPayload = {
type: 'company_invite',
to: invitedUserEmail,
rawToken,
companyId,
companyName: foundCompany.name ?? null,
};
if (isTest()) {
invitationRO.verificationString = rawToken;
}
return invitationRO;
}

if (isSaaS()) {
const companyCustomDomain = await this.saasCompanyGatewayService.getCompanyCustomDomainById(companyId);
await this.emailService.sendInvitationToCompany(
invitedUserEmail,
rawToken,
companyId,
foundCompany.name,
companyCustomDomain,
ValidationHelper.resolveEmailVerificationLinkBase(inputData.inviteLinkBase),
);
} else {
// Plan 15 Phase 6 (rev 5): self-hosted sends no email — the admin takes the full
// invitation link from the server logs and hands it to the invited user.
this.logger.printTechString(
`Invitation link: ${Constants.APP_DOMAIN_ADDRESS}/company/${companyId}/verify/${rawToken}/`,
);
}
if (isTest()) {
invitationRO.verificationString = rawToken;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { ApiProperty } from '@nestjs/swagger';

export type OutgoingEmailPayloadType =
| 'email_confirmation'
| 'password_reset_request'
| 'email_change_request'
| 'email_changed'
| 'company_invite';

/**
* Email context a `/saas/*` bridge returns INSTEAD of sending the letter itself when the caller
* sets `suppressEmail: true` (plan 15 Phase 2 — trigger inversion). The SaaS control plane builds
* the final link from `rawToken` and sends the email in-process. The raw token transits exactly
* one internal HTTPS hop and must never be logged on either side.
*/
export class OutgoingEmailPayloadDs {
@ApiProperty({
enum: ['email_confirmation', 'password_reset_request', 'email_change_request', 'email_changed', 'company_invite'],
description: 'Email type discriminator (matches the SaaS EmailType catalog).',
})
type: OutgoingEmailPayloadType;

@ApiProperty({ description: 'Recipient address.' })
to: string;

@ApiProperty({ required: false, description: 'Raw verification token (absent for notice-only letters).' })
rawToken?: string;

@ApiProperty({ required: false })
companyId?: string;

@ApiProperty({ required: false, nullable: true, type: String })
companyName?: string | null;
}
Loading
Loading